Compare commits
42 Commits
master
...
feat/arkad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
196beeda9f | ||
|
|
9b433c6511 | ||
|
|
d0b6d0e90c | ||
|
|
7fcf9317f6 | ||
|
|
0ba77f2e9b | ||
|
|
219693368e | ||
|
|
7192fa49f9 | ||
|
|
b7bac765a4 | ||
|
|
876a49767b | ||
|
|
4dd3216f42 | ||
|
|
c91deba2d5 | ||
|
|
e77afb7d6f | ||
|
|
d8c2074259 | ||
|
|
c036ea4c73 | ||
|
|
15deae4a17 | ||
|
|
ff7e206d4e | ||
|
|
6dcc4a3695 | ||
|
|
5da07ccd4e | ||
|
|
1140c24743 | ||
|
|
b232185973 | ||
|
|
164eb8492e | ||
|
|
70e1c9aa5a | ||
|
|
bd41c17bde | ||
|
|
803aaa21f0 | ||
|
|
962742b6e5 | ||
|
|
67dec43c32 | ||
|
|
c8f58865de | ||
|
|
18f46d154f | ||
|
|
d5d8a4a5b1 | ||
|
|
6a2602b8ea | ||
|
|
546af9ffbe | ||
|
|
71ae8a1a82 | ||
|
|
d5c7faeefc | ||
|
|
546a81ea68 | ||
|
|
5a32c6c881 | ||
|
|
feca38bb96 | ||
|
|
679f350dad | ||
|
|
cc95979828 | ||
|
|
9a47dcd39b | ||
|
|
1eb2b0e093 | ||
|
|
dec68751ca | ||
|
|
f08c731d91 |
@ -57,6 +57,13 @@ allprojects {
|
||||
maven {
|
||||
url("$rootDir/../node_modules/detox/Detox-android")
|
||||
}
|
||||
// react-native-background-fetch ships com.transistorsoft:tsbackgroundfetch
|
||||
// as a bundled local Maven repo; the package's own build.gradle adds it
|
||||
// for itself, but :app's runtime classpath resolution needs it visible
|
||||
// at the root level too.
|
||||
maven {
|
||||
url("$rootDir/../node_modules/react-native-background-fetch/android/libs")
|
||||
}
|
||||
|
||||
mavenCentral {
|
||||
// We don't want to fetch react-native from Maven Central as there are
|
||||
@ -85,6 +92,17 @@ if (buildscript != null) {
|
||||
}
|
||||
|
||||
subprojects { project ->
|
||||
// react-native-device-info's androidTest classpath pulls
|
||||
// play-services-iid:16.0.1 -> play-services-base:16.0.1 -> support-v4:26.1.0,
|
||||
// which collides with androidx.core:core:1.13.1 (Duplicate class
|
||||
// android.support.v4.app.INotificationSideChannel). Exclude the pre-AndroidX
|
||||
// support-* modules so the AndroidX equivalents in core win.
|
||||
configurations.all {
|
||||
exclude group: 'com.android.support', module: 'support-compat'
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
exclude group: 'com.android.support', module: 'support-core-utils'
|
||||
}
|
||||
|
||||
// Remove and block any jcenter() repositories at both project and buildscript levels
|
||||
def scrub = { repoContainer ->
|
||||
repoContainer.all { repo ->
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
// Per-wallet Realm storage for notification-suppression entries.
|
||||
//
|
||||
// Lives inside the per-wallet Arkade Realm so suppression state is
|
||||
// bucket-scoped, encrypted by the wallet's existing Realm key, and removed
|
||||
// automatically when the wallet is deleted (deleteArkadeRealm tears down the
|
||||
// whole file). Avoids leaking a stable per-wallet handle into a global
|
||||
// AsyncStorage key.
|
||||
|
||||
export type ArkSwapNotificationAction = 'claim' | 'refund';
|
||||
|
||||
// Realm schema. `realm` is a peer dependency we don't import here directly;
|
||||
// the schema is a plain object consumed by realmInstance.ts via the schemas
|
||||
// array. Pattern matches BoltzSwapSchema in @arkade-os/boltz-swap.
|
||||
export const ArkSwapNotificationSuppressionSchema = {
|
||||
name: 'ArkSwapNotificationSuppression',
|
||||
primaryKey: 'id',
|
||||
properties: {
|
||||
id: 'string',
|
||||
swapId: 'string',
|
||||
action: 'string',
|
||||
postedAt: 'int',
|
||||
},
|
||||
};
|
||||
|
||||
const compositeId = (swapId: string, action: ArkSwapNotificationAction): string => `${swapId}:${action}`;
|
||||
|
||||
interface ArkSwapNotificationSuppressionRow {
|
||||
id: string;
|
||||
swapId: string;
|
||||
action: ArkSwapNotificationAction;
|
||||
postedAt: number;
|
||||
}
|
||||
|
||||
export class RealmNotificationSuppressionRepository {
|
||||
private readonly realm: any;
|
||||
|
||||
constructor(realm: any) {
|
||||
this.realm = realm;
|
||||
}
|
||||
|
||||
has(swapId: string, action: ArkSwapNotificationAction): boolean {
|
||||
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
record(swapId: string, action: ArkSwapNotificationAction): void {
|
||||
this.realm.write(() => {
|
||||
const row: ArkSwapNotificationSuppressionRow = {
|
||||
id: compositeId(swapId, action),
|
||||
swapId,
|
||||
action,
|
||||
postedAt: Date.now(),
|
||||
};
|
||||
this.realm.create('ArkSwapNotificationSuppression', row, 'modified');
|
||||
});
|
||||
}
|
||||
|
||||
clearForSwap(swapId: string): void {
|
||||
this.realm.write(() => {
|
||||
const matches = this.realm.objects('ArkSwapNotificationSuppression').filtered('swapId == $0', swapId);
|
||||
this.realm.delete(matches);
|
||||
});
|
||||
}
|
||||
|
||||
clearForSwapAction(swapId: string, action: ArkSwapNotificationAction): void {
|
||||
this.realm.write(() => {
|
||||
const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action));
|
||||
if (row) this.realm.delete(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
197
blue_modules/arkade-adapters/realm/realmInstance.ts
Normal file
197
blue_modules/arkade-adapters/realm/realmInstance.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import RNFS from 'react-native-fs';
|
||||
import Realm from 'realm';
|
||||
import Keychain, { ACCESSIBLE, SECURITY_LEVEL } from 'react-native-keychain';
|
||||
|
||||
import { ArkRealmSchemas, ARK_REALM_SCHEMA_VERSION, runArkRealmMigrations } from '@arkade-os/sdk/repositories/realm';
|
||||
import { BoltzRealmSchemas } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
import { randomBytes } from '../../../class/rng';
|
||||
import { uint8ArrayToHex, hexToUint8Array } from '../../uint8array-extras';
|
||||
import { ArkSwapNotificationSuppressionSchema } from './notificationSuppressionRepository';
|
||||
|
||||
const AllArkadeSchemas = [...ArkRealmSchemas, ...BoltzRealmSchemas, ArkSwapNotificationSuppressionSchema];
|
||||
|
||||
// App-owned schemas added on top of the SDK's. Bump when an app-owned schema
|
||||
// changes; SDK bumps are handled by ARK_REALM_SCHEMA_VERSION. Realm requires
|
||||
// a strictly increasing schemaVersion when objects are added; computing
|
||||
// `SDK + offset` keeps the local additions ahead of any future SDK bump.
|
||||
const LOCAL_ARK_SCHEMA_OFFSET = 1;
|
||||
const ARKADE_REALM_SCHEMA_VERSION = ARK_REALM_SCHEMA_VERSION + LOCAL_ARK_SCHEMA_OFFSET;
|
||||
|
||||
const realmInstances: Map<string, Realm> = new Map();
|
||||
const openInFlight: Map<string, Promise<Realm>> = new Map();
|
||||
|
||||
// Files live in a dedicated subdirectory so BlueApp.moveRealmFilesToCacheDirectory()
|
||||
// — which sweeps top-level *.realm files from Documents into the OS-purgeable cache
|
||||
// — never sees them. RNFS.readDir is non-recursive, so the subdirectory is invisible
|
||||
// to that scan. Ark Realm holds non-recoverable swap/claim data and must stay in
|
||||
// Documents.
|
||||
const arkadeDir = (): string => `${RNFS.DocumentDirectoryPath}/arkade`;
|
||||
const realmPathFor = (namespace: string): string => `${arkadeDir()}/arkade-${namespace}.realm`;
|
||||
const keychainServiceFor = (namespace: string): string => `arkade_realm_${namespace}`;
|
||||
|
||||
async function ensureArkadeDir(): Promise<void> {
|
||||
const dir = arkadeDir();
|
||||
if (!(await RNFS.exists(dir))) await RNFS.mkdir(dir);
|
||||
}
|
||||
|
||||
async function loadOrCreateEncryptionKey(namespace: string): Promise<Uint8Array> {
|
||||
const service = keychainServiceFor(namespace);
|
||||
|
||||
const credentials = await Keychain.getGenericPassword({ service });
|
||||
if (credentials) return hexToUint8Array(credentials.password);
|
||||
|
||||
const buf = await randomBytes(64);
|
||||
const password = uint8ArrayToHex(buf);
|
||||
|
||||
// Accessibility: match the rest of the app's secret accessibility. RNSecureKeyStore
|
||||
// in class/blue-app.ts and hooks/useBiometrics.ts both use WHEN_UNLOCKED_THIS_DEVICE_ONLY;
|
||||
// the default of AFTER_FIRST_UNLOCK would expose the Realm key while the device is locked.
|
||||
//
|
||||
// Security level: preflight via getSecurityLevel() rather than try/catch around
|
||||
// SECURE_HARDWARE. getSecurityLevel returns null on iOS (where the option is moot)
|
||||
// and the highest supported level on Android. We only opt into SECURE_HARDWARE when
|
||||
// the device actually backs it; otherwise let react-native-keychain pick its default.
|
||||
// Catching every setGenericPassword error and silently retrying with ANY (the previous
|
||||
// shape) downgrades on unrelated failures — preflight surfaces those instead.
|
||||
const supportedLevel = await Keychain.getSecurityLevel();
|
||||
const opts: Parameters<typeof Keychain.setGenericPassword>[2] = {
|
||||
service,
|
||||
accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
||||
};
|
||||
if (supportedLevel === SECURITY_LEVEL.SECURE_HARDWARE) {
|
||||
opts.securityLevel = SECURITY_LEVEL.SECURE_HARDWARE;
|
||||
}
|
||||
await Keychain.setGenericPassword(service, password, opts);
|
||||
|
||||
return hexToUint8Array(password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a per-wallet Realm instance keyed by `namespace`. Each Ark wallet
|
||||
* gets its own encrypted Realm file and its own Keychain entry so wallets
|
||||
* never collide on WalletState/contracts/swaps and storage buckets stay
|
||||
* isolated.
|
||||
*
|
||||
* Concurrent callers for the same namespace receive the same in-flight
|
||||
* promise. Errors are surfaced to the caller; the in-flight entry is cleared
|
||||
* so a later retry can succeed.
|
||||
*/
|
||||
export async function getArkadeRealm(namespace: string): Promise<Realm> {
|
||||
const cached = realmInstances.get(namespace);
|
||||
if (cached && !cached.isClosed) return cached;
|
||||
if (cached && cached.isClosed) realmInstances.delete(namespace);
|
||||
|
||||
const inFlight = openInFlight.get(namespace);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const opening = (async () => {
|
||||
await ensureArkadeDir();
|
||||
|
||||
const encryptionKey = await loadOrCreateEncryptionKey(namespace);
|
||||
|
||||
const realm = await Realm.open({
|
||||
schema: AllArkadeSchemas as unknown as Realm.ObjectSchema[],
|
||||
schemaVersion: ARKADE_REALM_SCHEMA_VERSION,
|
||||
onMigration: (oldRealm, newRealm) => {
|
||||
runArkRealmMigrations(oldRealm, newRealm);
|
||||
},
|
||||
path: realmPathFor(namespace),
|
||||
encryptionKey,
|
||||
excludeFromIcloudBackup: true,
|
||||
});
|
||||
|
||||
realmInstances.set(namespace, realm);
|
||||
return realm;
|
||||
})();
|
||||
|
||||
openInFlight.set(namespace, opening);
|
||||
try {
|
||||
return await opening;
|
||||
} finally {
|
||||
openInFlight.delete(namespace);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the cached Realm for `namespace`, if any. The file and Keychain
|
||||
* entry are preserved.
|
||||
*/
|
||||
export function closeArkadeRealm(namespace: string): void {
|
||||
const realm = realmInstances.get(namespace);
|
||||
if (realm && !realm.isClosed) {
|
||||
realm.removeAllListeners();
|
||||
realm.close();
|
||||
}
|
||||
realmInstances.delete(namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every cached Arkade Realm instance. Used on app shutdown / sign out.
|
||||
*/
|
||||
export function closeAllArkadeRealms(): void {
|
||||
for (const ns of Array.from(realmInstances.keys())) {
|
||||
closeArkadeRealm(ns);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the Realm file and the Keychain entry for `namespace`. Used when
|
||||
* an Ark wallet is removed. Failures are logged but do not throw — leaving
|
||||
* an orphan file or Keychain entry is preferable to crashing the app's
|
||||
* delete path. Ark Realm failures stay scoped to the Ark wallet path.
|
||||
*
|
||||
* The Keychain encryption key is reset only when the Realm file is gone
|
||||
* (or never existed). Resetting the key while the encrypted file remains
|
||||
* would leave the user unable to open the orphan on a future re-import:
|
||||
* a fresh random key would be generated and the old file's ciphertext
|
||||
* could not be decrypted.
|
||||
*/
|
||||
export async function deleteArkadeRealm(namespace: string): Promise<void> {
|
||||
closeArkadeRealm(namespace);
|
||||
|
||||
const path = realmPathFor(namespace);
|
||||
let realmRemoved = false;
|
||||
try {
|
||||
// Realm.deleteFile is sync and removes the .realm + .lock + .management
|
||||
// siblings in one call. It is forgiving when the file does not exist
|
||||
// (no-op), but we guard via Realm.exists to keep behavior explicit.
|
||||
if (Realm.exists(path)) {
|
||||
Realm.deleteFile({ path });
|
||||
}
|
||||
realmRemoved = true;
|
||||
} catch (e: any) {
|
||||
console.log(`[ArkadeRealm] Realm.deleteFile failed for ${path}:`, e?.message ?? e);
|
||||
}
|
||||
|
||||
// Best-effort sweep of any sibling files Realm.deleteFile might have left
|
||||
// behind. These are not load-bearing for re-import; failures are tolerated.
|
||||
for (const suffix of ['.note']) {
|
||||
const sibling = `${path}${suffix}`;
|
||||
try {
|
||||
if (await RNFS.exists(sibling)) await RNFS.unlink(sibling);
|
||||
} catch (e: any) {
|
||||
console.log(`[ArkadeRealm] failed to delete ${sibling}:`, e?.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!realmRemoved) {
|
||||
console.log(
|
||||
`[ArkadeRealm] keeping encryption key for ${namespace} because Realm file cleanup failed; key preserved so a future delete retry can still decrypt the orphan`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Keychain.resetGenericPassword({ service: keychainServiceFor(namespace) });
|
||||
} catch (e: any) {
|
||||
console.log(`[ArkadeRealm] failed to reset keychain for ${namespace}:`, e?.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for tests only.
|
||||
export const __testing__ = {
|
||||
realmInstances,
|
||||
openInFlight,
|
||||
realmPathFor,
|
||||
keychainServiceFor,
|
||||
};
|
||||
423
blue_modules/arkade-background.ts
Normal file
423
blue_modules/arkade-background.ts
Normal file
@ -0,0 +1,423 @@
|
||||
// Background task module for Ark swap monitoring.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Passive monitoring: poll Boltz swap status for non-terminal swaps in
|
||||
// every Ark wallet's per-wallet Realm and persist remote changes through
|
||||
// the SDK update helpers.
|
||||
// - Post a local notification when an SDK predicate flags a swap as
|
||||
// claimable/refundable. No claim, refund, recover, or signing happens in
|
||||
// background — those remain foreground-only.
|
||||
//
|
||||
// State here is in-process: it survives configure→fetch→fetch ticks within a
|
||||
// single JS runtime but is gone after process kill. Realm remains the
|
||||
// durable source of truth for swap status and notification suppression.
|
||||
import BackgroundFetch from 'react-native-background-fetch';
|
||||
|
||||
import {
|
||||
BoltzSwapProvider,
|
||||
isChainFinalStatus,
|
||||
isReverseFinalStatus,
|
||||
isSubmarineFinalStatus,
|
||||
updateChainSwapStatus,
|
||||
updateReverseSwapStatus,
|
||||
updateSubmarineSwapStatus,
|
||||
} from '@arkade-os/boltz-swap';
|
||||
import type { BoltzChainSwap, BoltzReverseSwap, BoltzSubmarineSwap, BoltzSwap } from '@arkade-os/boltz-swap';
|
||||
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
|
||||
import { BlueApp as BlueAppClass } from '../class/blue-app';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import { getArkadeRealm } from './arkade-adapters/realm/realmInstance';
|
||||
import {
|
||||
RealmNotificationSuppressionRepository,
|
||||
type ArkSwapNotificationAction,
|
||||
} from './arkade-adapters/realm/notificationSuppressionRepository';
|
||||
import { notifyArkSwapActionable, resolveActionableAction } from './arkade-notifications';
|
||||
|
||||
const BlueApp = BlueAppClass.getInstance();
|
||||
|
||||
// Single shared provider. The constructor only stores config; it does not
|
||||
// open sockets. Re-using one instance avoids per-poll allocation.
|
||||
const swapProvider = new BoltzSwapProvider({ network: 'bitcoin' });
|
||||
const DEFAULT_MAX_RUN_MS = 25_000;
|
||||
let maxRunMs = DEFAULT_MAX_RUN_MS;
|
||||
|
||||
interface ArkTaskState {
|
||||
lastRegisteredAt: number | null;
|
||||
lastUnregisteredAt: number | null;
|
||||
lastRunStartedAt: number | null;
|
||||
lastRunFinishedAt: number | null;
|
||||
walletsScanned: number;
|
||||
swapsPolled: number;
|
||||
swapsUpdated: number;
|
||||
lastError: string | null;
|
||||
exitedDueToUnavailableStorage: boolean;
|
||||
availability: 'unknown' | 'available' | 'denied' | 'restricted';
|
||||
// Set whenever swapsUpdated is incremented. Used by reconcile() to detect
|
||||
// updates that crossed run boundaries (per-run swapsUpdated is reset).
|
||||
lastSwapUpdateAt: number;
|
||||
lastReconciledAt: number;
|
||||
}
|
||||
|
||||
const state: ArkTaskState = {
|
||||
lastRegisteredAt: null,
|
||||
lastUnregisteredAt: null,
|
||||
lastRunStartedAt: null,
|
||||
lastRunFinishedAt: null,
|
||||
walletsScanned: 0,
|
||||
swapsPolled: 0,
|
||||
swapsUpdated: 0,
|
||||
lastError: null,
|
||||
exitedDueToUnavailableStorage: false,
|
||||
availability: 'unknown',
|
||||
lastSwapUpdateAt: 0,
|
||||
lastReconciledAt: 0,
|
||||
};
|
||||
|
||||
// Per-wallet last-seen status cache. Outer key: wallet namespace; inner key:
|
||||
// swap ID; value: last status this background module observed. Diagnostic +
|
||||
// reconciliation hint only — Realm is durable.
|
||||
const swapStatusCache: Map<string, Map<string, string>> = new Map();
|
||||
|
||||
// Per-poll last-seen actionable action keyed by `${namespace}:${swapId}`.
|
||||
// Used to detect predicate flips (true → false or claim ↔ refund) so we can
|
||||
// clear the corresponding Realm suppression row even when the swap status
|
||||
// has not yet reached a terminal state. In-process only; cleared by
|
||||
// stopArkBackgroundTask so a later run does not falsely diagnose a flip on
|
||||
// the first poll after restart.
|
||||
const lastSeenActionMap: Map<string, ArkSwapNotificationAction> = new Map();
|
||||
|
||||
let configured = false;
|
||||
let running = false;
|
||||
let cancelRequested = false;
|
||||
let runDeadline: number | null = null;
|
||||
|
||||
export function getArkTaskState(): Readonly<ArkTaskState> {
|
||||
return Object.freeze({ ...state });
|
||||
}
|
||||
|
||||
function recordError(message: string): void {
|
||||
state.lastError = message;
|
||||
}
|
||||
|
||||
function shouldStopRun(): boolean {
|
||||
return cancelRequested || (runDeadline !== null && Date.now() >= runDeadline);
|
||||
}
|
||||
|
||||
function remainingRunMs(): number {
|
||||
if (runDeadline === null) return maxRunMs;
|
||||
return Math.max(runDeadline - Date.now(), 0);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('deadline exceeded')), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalStatus(swap: BoltzSwap): boolean {
|
||||
switch (swap.type) {
|
||||
case 'reverse':
|
||||
return isReverseFinalStatus(swap.status);
|
||||
case 'submarine':
|
||||
return isSubmarineFinalStatus(swap.status);
|
||||
case 'chain':
|
||||
return isChainFinalStatus(swap.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistStatusChange(swap: BoltzSwap, newStatus: BoltzSwap['status'], repo: RealmSwapRepository): Promise<void> {
|
||||
if (swap.type === 'reverse') {
|
||||
await updateReverseSwapStatus(swap as BoltzReverseSwap, newStatus, s => repo.saveSwap(s));
|
||||
} else if (swap.type === 'submarine') {
|
||||
await updateSubmarineSwapStatus(swap as BoltzSubmarineSwap, newStatus, s => repo.saveSwap(s));
|
||||
} else {
|
||||
await updateChainSwapStatus(swap as BoltzChainSwap, newStatus, s => repo.saveSwap(s));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSwap(
|
||||
swap: BoltzSwap,
|
||||
namespace: string,
|
||||
repo: RealmSwapRepository,
|
||||
suppression: RealmNotificationSuppressionRepository,
|
||||
walletID: string,
|
||||
walletLabel: string,
|
||||
): Promise<void> {
|
||||
if (shouldStopRun()) return;
|
||||
|
||||
state.swapsPolled += 1;
|
||||
let response;
|
||||
try {
|
||||
response = await withTimeout(swapProvider.getSwapStatus(swap.id), remainingRunMs());
|
||||
} catch (e: any) {
|
||||
recordError(`getSwapStatus(${swap.id}): ${e?.message ?? e}`);
|
||||
if (e?.message === 'deadline exceeded' || remainingRunMs() <= 0) cancelRequested = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStopRun()) return;
|
||||
|
||||
const remoteStatus = response.status;
|
||||
const statusChanged = remoteStatus !== swap.status;
|
||||
// The SDK update helpers (updateReverseSwapStatus etc.) save a copy and do
|
||||
// not mutate `swap`, so any post-persist predicate or terminal check on
|
||||
// `swap` would read the pre-update status. effectiveSwap carries the
|
||||
// status we want subsequent checks to evaluate against.
|
||||
const effectiveSwap: BoltzSwap = statusChanged ? ({ ...swap, status: remoteStatus } as BoltzSwap) : swap;
|
||||
|
||||
if (statusChanged) {
|
||||
try {
|
||||
await persistStatusChange(swap, remoteStatus, repo);
|
||||
} catch (e: any) {
|
||||
recordError(`persistStatusChange(${swap.id}): ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.swapsUpdated += 1;
|
||||
state.lastSwapUpdateAt = Date.now();
|
||||
let perWallet = swapStatusCache.get(namespace);
|
||||
if (!perWallet) {
|
||||
perWallet = new Map();
|
||||
swapStatusCache.set(namespace, perWallet);
|
||||
}
|
||||
perWallet.set(swap.id, remoteStatus);
|
||||
}
|
||||
|
||||
// Actionable evaluation runs on every non-terminal poll, NOT only after a
|
||||
// status change. Otherwise a swap that became actionable in a previous run
|
||||
// but never received a successful post (notify failed mid-run, OS-level
|
||||
// drop, permission-denied skip, app cold-started with already-actionable
|
||||
// Realm state) would never be re-checked because subsequent polls observe
|
||||
// remoteStatus === swap.status and would otherwise exit. The Realm
|
||||
// suppression repo is the dedup layer.
|
||||
const lastKey = `${namespace}:${effectiveSwap.id}`;
|
||||
if (isFinalStatus(effectiveSwap)) {
|
||||
try {
|
||||
suppression.clearForSwap(effectiveSwap.id);
|
||||
} catch (e: any) {
|
||||
recordError(`suppression.clearForSwap(${effectiveSwap.id}): ${e?.message ?? e}`);
|
||||
}
|
||||
lastSeenActionMap.delete(lastKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const action = resolveActionableAction(effectiveSwap);
|
||||
const lastSeen = lastSeenActionMap.get(lastKey);
|
||||
if (lastSeen && lastSeen !== action) {
|
||||
// Predicate flipped out of `lastSeen` (either to null or to the other
|
||||
// action). Clear the stale suppression so the next observed flip back
|
||||
// re-fires.
|
||||
try {
|
||||
suppression.clearForSwapAction(effectiveSwap.id, lastSeen);
|
||||
} catch (e: any) {
|
||||
recordError(`suppression.clearForSwapAction(${effectiveSwap.id}): ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (action) {
|
||||
try {
|
||||
await notifyArkSwapActionable(effectiveSwap, suppression, walletID, walletLabel);
|
||||
} catch (e: any) {
|
||||
recordError(`notifyArkSwapActionable(${effectiveSwap.id}): ${e?.message ?? e}`);
|
||||
}
|
||||
lastSeenActionMap.set(lastKey, action);
|
||||
} else {
|
||||
lastSeenActionMap.delete(lastKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function processWallet(wallet: LightningArkWallet): Promise<void> {
|
||||
state.walletsScanned += 1;
|
||||
const namespace = wallet.getNamespace();
|
||||
const walletID = wallet.getID();
|
||||
const walletLabel = wallet.getLabel();
|
||||
|
||||
let realm;
|
||||
try {
|
||||
realm = await getArkadeRealm(namespace);
|
||||
} catch (e: any) {
|
||||
// Most likely the Keychain is locked (WHEN_UNLOCKED_THIS_DEVICE_ONLY) or
|
||||
// the Realm file is unreachable. Either way the background task no-ops
|
||||
// for this wallet — claim/refund is foreground-only anyway.
|
||||
state.exitedDueToUnavailableStorage = true;
|
||||
recordError(`getArkadeRealm(${namespace}): ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let swaps: BoltzSwap[];
|
||||
const repo = new RealmSwapRepository(realm as any);
|
||||
const suppression = new RealmNotificationSuppressionRepository(realm);
|
||||
try {
|
||||
swaps = await repo.getAllSwaps<BoltzSwap>();
|
||||
} catch (e: any) {
|
||||
recordError(`getAllSwaps(${namespace}): ${e?.message ?? e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const swap of swaps) {
|
||||
if (isFinalStatus(swap)) continue;
|
||||
if (shouldStopRun()) return;
|
||||
await pollSwap(swap, namespace, repo, suppression, walletID, walletLabel);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runArkBackgroundTask(taskId: string): Promise<void> {
|
||||
if (running) {
|
||||
BackgroundFetch.finish(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
cancelRequested = false;
|
||||
runDeadline = Date.now() + maxRunMs;
|
||||
state.lastRunStartedAt = Date.now();
|
||||
state.walletsScanned = 0;
|
||||
state.swapsPolled = 0;
|
||||
state.swapsUpdated = 0;
|
||||
state.exitedDueToUnavailableStorage = false;
|
||||
|
||||
try {
|
||||
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
|
||||
if (wallets.length === 0) return;
|
||||
|
||||
for (const wallet of wallets) {
|
||||
if (shouldStopRun()) break;
|
||||
try {
|
||||
await processWallet(wallet);
|
||||
} catch (e: any) {
|
||||
recordError(`processWallet: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
state.lastRunFinishedAt = Date.now();
|
||||
runDeadline = null;
|
||||
cancelRequested = false;
|
||||
running = false;
|
||||
BackgroundFetch.finish(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
export function onArkBackgroundTaskTimeout(taskId: string): void {
|
||||
cancelRequested = true;
|
||||
state.lastError = 'timeout';
|
||||
state.lastRunFinishedAt = Date.now();
|
||||
BackgroundFetch.finish(taskId);
|
||||
}
|
||||
|
||||
function availabilityFromStatus(status: number): ArkTaskState['availability'] {
|
||||
if (status === BackgroundFetch.STATUS_AVAILABLE) return 'available';
|
||||
if (status === BackgroundFetch.STATUS_DENIED) return 'denied';
|
||||
if (status === BackgroundFetch.STATUS_RESTRICTED) return 'restricted';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export async function registerArkBackgroundTask(): Promise<void> {
|
||||
if (configured) {
|
||||
await BackgroundFetch.start();
|
||||
state.lastRegisteredAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const config: Parameters<typeof BackgroundFetch.configure>[0] = {
|
||||
minimumFetchInterval: 15,
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
enableHeadless: true,
|
||||
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
|
||||
};
|
||||
|
||||
try {
|
||||
const status = await BackgroundFetch.configure(config, runArkBackgroundTask, onArkBackgroundTaskTimeout);
|
||||
state.availability = availabilityFromStatus(status);
|
||||
if (state.availability === 'available') {
|
||||
configured = true;
|
||||
state.lastRegisteredAt = Date.now();
|
||||
} else {
|
||||
console.warn(`[ArkBackground] Background fetch unavailable: ${state.availability}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
recordError(`configure: ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopArkBackgroundTask(): Promise<void> {
|
||||
cancelRequested = true;
|
||||
try {
|
||||
await BackgroundFetch.stop();
|
||||
} catch (e: any) {
|
||||
recordError(`stop: ${e?.message ?? e}`);
|
||||
}
|
||||
|
||||
// Await in-flight run completion (draining). A live background run keeps
|
||||
// Detox's FabricTimersIdlingResource busy and disconnects the JS bridge.
|
||||
const start = Date.now();
|
||||
// eslint-disable-next-line no-unmodified-loop-condition
|
||||
while (running && Date.now() - start < 30_000) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
swapStatusCache.clear();
|
||||
// Clear in-process predicate-flip tracker so a later run does not
|
||||
// diagnose a flip on the first poll after restart. Persistent suppression
|
||||
// (Realm) is intentionally untouched — re-registering must keep history.
|
||||
lastSeenActionMap.clear();
|
||||
state.lastUnregisteredAt = Date.now();
|
||||
}
|
||||
|
||||
export function reconcileArkBackgroundTaskResults(triggerRefreshForWallet: (walletId: string) => void): void {
|
||||
if (state.lastSwapUpdateAt <= state.lastReconciledAt) return;
|
||||
|
||||
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
|
||||
for (const wallet of wallets) {
|
||||
const namespace = wallet.getNamespace();
|
||||
const perWallet = swapStatusCache.get(namespace);
|
||||
if (perWallet && perWallet.size > 0) {
|
||||
triggerRefreshForWallet(wallet.getID());
|
||||
}
|
||||
}
|
||||
|
||||
state.lastReconciledAt = Date.now();
|
||||
}
|
||||
|
||||
// Exported for tests only.
|
||||
export const __testing__ = {
|
||||
state,
|
||||
swapStatusCache,
|
||||
lastSeenActionMap,
|
||||
resetConfigured: (): void => {
|
||||
configured = false;
|
||||
},
|
||||
setMaxRunMs: (ms: number): void => {
|
||||
maxRunMs = ms;
|
||||
},
|
||||
reset: (): void => {
|
||||
state.lastRegisteredAt = null;
|
||||
state.lastUnregisteredAt = null;
|
||||
state.lastRunStartedAt = null;
|
||||
state.lastRunFinishedAt = null;
|
||||
state.walletsScanned = 0;
|
||||
state.swapsPolled = 0;
|
||||
state.swapsUpdated = 0;
|
||||
state.lastError = null;
|
||||
state.exitedDueToUnavailableStorage = false;
|
||||
state.availability = 'unknown';
|
||||
state.lastSwapUpdateAt = 0;
|
||||
state.lastReconciledAt = 0;
|
||||
swapStatusCache.clear();
|
||||
lastSeenActionMap.clear();
|
||||
configured = false;
|
||||
running = false;
|
||||
cancelRequested = false;
|
||||
runDeadline = null;
|
||||
maxRunMs = DEFAULT_MAX_RUN_MS;
|
||||
},
|
||||
};
|
||||
163
blue_modules/arkade-notifications.ts
Normal file
163
blue_modules/arkade-notifications.ts
Normal file
@ -0,0 +1,163 @@
|
||||
// Local-notification posting for actionable Ark swaps. Imported from headless
|
||||
// background runtimes (no React dependency).
|
||||
//
|
||||
// Design notes:
|
||||
// - Suppression state lives per-wallet in the Arkade Realm
|
||||
// (RealmNotificationSuppressionRepository), not in a global AsyncStorage
|
||||
// key — bucket-scoped and encrypted, so the suppression record never
|
||||
// leaks a stable handle outside the wallet's encryption boundary.
|
||||
// - Permission and app-level opt-out are checked read-only before each post
|
||||
// (no prompting from headless context). Suppression is NOT recorded when
|
||||
// the post is skipped, so a later state where the user grants permission
|
||||
// triggers a fresh post on the next wake.
|
||||
// - Notification payload deliberately does NOT include `namespace`. The OS
|
||||
// notification database persists payloads and is global across BlueWallet
|
||||
// encryption buckets; embedding a deterministic per-wallet identifier
|
||||
// would tie a stable handle to the OS-visible record.
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { AppState, Platform } from 'react-native';
|
||||
import { Notification, Notifications } from 'react-native-notifications';
|
||||
import { checkNotifications, RESULTS } from 'react-native-permissions';
|
||||
|
||||
import { isChainSwapClaimable, isChainSwapRefundable, isReverseSwapClaimable, isSubmarineSwapRefundable } from '@arkade-os/boltz-swap';
|
||||
import type { BoltzSwap } from '@arkade-os/boltz-swap';
|
||||
|
||||
import loc from '../loc';
|
||||
import { NOTIFICATIONS_NO_AND_DONT_ASK_FLAG } from './notifications';
|
||||
import type {
|
||||
RealmNotificationSuppressionRepository,
|
||||
ArkSwapNotificationAction,
|
||||
} from './arkade-adapters/realm/notificationSuppressionRepository';
|
||||
|
||||
export const ARK_SWAP_NOTIFICATION_TYPE = 100;
|
||||
|
||||
const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01';
|
||||
let channelEnsured = false;
|
||||
|
||||
export function ensureArkNotificationChannel(): void {
|
||||
if (Platform.OS !== 'android') return;
|
||||
if (channelEnsured) return;
|
||||
channelEnsured = true;
|
||||
// Reuses the BlueWallet channel from blue_modules/notifications.ts:80-91 so
|
||||
// headless runs do not register a second channel under a different name.
|
||||
Notifications.setNotificationChannel({
|
||||
channelId: ANDROID_NOTIFICATION_CHANNEL_ID,
|
||||
name: 'BlueWallet notifications',
|
||||
description: 'Notifications about incoming payments',
|
||||
importance: 4,
|
||||
enableVibration: true,
|
||||
showBadge: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Channel registration runs lazily on the first post (see notifyArkSwapActionable).
|
||||
// Calling it at module-top would invoke the native bridge during JS bundle
|
||||
// evaluation, which racy-blocks RN bootstrap on some devices and breaks
|
||||
// Detox's RN-context wait. The existing blue_modules/notifications.ts pattern
|
||||
// also defers channel setup to lazy invocation.
|
||||
|
||||
export function resolveActionableAction(swap: BoltzSwap): ArkSwapNotificationAction | null {
|
||||
if (isReverseSwapClaimable(swap) || isChainSwapClaimable(swap)) return 'claim';
|
||||
if (isSubmarineSwapRefundable(swap) || isChainSwapRefundable(swap)) return 'refund';
|
||||
return null;
|
||||
}
|
||||
|
||||
const interpolate = (template: string, walletLabel: string): string => template.replace('{walletLabel}', walletLabel);
|
||||
|
||||
// Static references so scripts/find-unused-loc.js can detect these keys.
|
||||
const titleFor = (): string => loc.lndViewInvoice.notification_action_title;
|
||||
const bodyFor = (action: ArkSwapNotificationAction): string =>
|
||||
action === 'claim' ? loc.lndViewInvoice.notification_claim_body : loc.lndViewInvoice.notification_refund_body;
|
||||
|
||||
let appStateOverrideForTest: string | null = null;
|
||||
let permissionResultOverrideForTest: string | null = null;
|
||||
let optOutFlagOverrideForTest: string | null | undefined;
|
||||
|
||||
function currentAppState(): string {
|
||||
return appStateOverrideForTest ?? AppState.currentState;
|
||||
}
|
||||
|
||||
async function isOsNotificationPermissionGranted(): Promise<boolean> {
|
||||
if (permissionResultOverrideForTest !== null) {
|
||||
return permissionResultOverrideForTest === RESULTS.GRANTED;
|
||||
}
|
||||
try {
|
||||
const { status } = await checkNotifications();
|
||||
return status === RESULTS.GRANTED;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isAppLevelOptedOut(): Promise<boolean> {
|
||||
if (optOutFlagOverrideForTest !== undefined) {
|
||||
return optOutFlagOverrideForTest === 'true';
|
||||
}
|
||||
try {
|
||||
const flag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG);
|
||||
return flag === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyArkSwapActionable(
|
||||
swap: BoltzSwap,
|
||||
suppression: RealmNotificationSuppressionRepository,
|
||||
walletID: string,
|
||||
walletLabel: string,
|
||||
): Promise<void> {
|
||||
const action = resolveActionableAction(swap);
|
||||
if (!action) return;
|
||||
|
||||
if (currentAppState() === 'active') return;
|
||||
|
||||
if (suppression.has(swap.id, action)) return;
|
||||
|
||||
if (!(await isOsNotificationPermissionGranted())) return;
|
||||
if (await isAppLevelOptedOut()) return;
|
||||
|
||||
ensureArkNotificationChannel();
|
||||
|
||||
const title = titleFor();
|
||||
const body = interpolate(bodyFor(action), walletLabel);
|
||||
|
||||
try {
|
||||
Notifications.postLocalNotification(
|
||||
// namespace is intentionally omitted; tap routing re-derives it from the loaded wallet.
|
||||
new Notification({
|
||||
title,
|
||||
body,
|
||||
type: ARK_SWAP_NOTIFICATION_TYPE,
|
||||
walletID,
|
||||
swapId: swap.id,
|
||||
action,
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.warn('[ArkNotifications] postLocalNotification failed:', e?.message ?? e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
suppression.record(swap.id, action);
|
||||
} catch (e: any) {
|
||||
console.warn('[ArkNotifications] suppression.record failed:', e?.message ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
export const __testing__ = {
|
||||
resetChannel: (): void => {
|
||||
channelEnsured = false;
|
||||
},
|
||||
setAppStateForTest: (state: string | null): void => {
|
||||
appStateOverrideForTest = state;
|
||||
},
|
||||
setPermissionResultForTest: (result: string | null): void => {
|
||||
permissionResultOverrideForTest = result;
|
||||
},
|
||||
setOptOutFlagForTest: (value: string | null | undefined): void => {
|
||||
optOutFlagOverrideForTest = value;
|
||||
},
|
||||
};
|
||||
41
blue_modules/transactionDisplayState.ts
Normal file
41
blue_modules/transactionDisplayState.ts
Normal file
@ -0,0 +1,41 @@
|
||||
// Display state for the transaction detail screen.
|
||||
//
|
||||
// On-chain rows (a real Bitcoin txid is present in `hash`) keep the existing
|
||||
// confirmations-based logic. Ark/Lightning rows synthesized by
|
||||
// LightningArkWallet.getTransactions() carry no on-chain `hash` and never a
|
||||
// `confirmations` field, so their state is derived from row semantics instead.
|
||||
// The off-chain branch mirrors the off-chain cases of
|
||||
// components/TransactionListItem.tsx `listTitleKey` so the list row and the detail
|
||||
// screen always agree. A `boarding-utxo-` row is a refill still awaiting
|
||||
// settlement and is pending (matches TransactionListItem.isPendingRefill); a
|
||||
// settled `boarding-` refill is a confirmed receive. Today only `bitcoind_tx` Ark
|
||||
// rows reach the detail screen (swap rows route to LNDViewInvoice); the invoice
|
||||
// cases are handled defensively.
|
||||
export type TxDisplayState = 'pending' | 'sent' | 'received';
|
||||
|
||||
export function isOnChainTransaction(tx: any): boolean {
|
||||
return typeof tx?.hash === 'string' && tx.hash.length > 0;
|
||||
}
|
||||
|
||||
export function resolveTxDisplayState(tx: any): TxDisplayState {
|
||||
if (isOnChainTransaction(tx)) {
|
||||
const confs = Number(tx?.confirmations);
|
||||
const pending = Number.isFinite(confs) ? confs <= 0 : !tx?.confirmations;
|
||||
if (pending) return 'pending';
|
||||
return Number(tx?.value) < 0 ? 'sent' : 'received';
|
||||
}
|
||||
// A refill awaiting settlement (boarding UTXO not yet swept into a VTXO) is
|
||||
// pending until it promotes to a settled `boarding-<txid>` refill — mirror
|
||||
// TransactionListItem.isPendingRefill so the list row and detail screen agree.
|
||||
if (typeof tx?.txid === 'string' && tx.txid.startsWith('boarding-utxo-')) return 'pending';
|
||||
// Off-chain Ark/Lightning row — never confirmations-based.
|
||||
switch (tx?.type) {
|
||||
case 'paid_invoice':
|
||||
return 'sent';
|
||||
case 'user_invoice':
|
||||
case 'payment_request':
|
||||
return tx?.ispaid ? 'received' : 'pending';
|
||||
default: // settled refill (boarding-<txid>), native Ark legs (ark-), any other hash-less row
|
||||
return Number(tx?.value) < 0 ? 'sent' : 'received';
|
||||
}
|
||||
}
|
||||
@ -216,10 +216,35 @@ const startImport = (
|
||||
if (text.startsWith('arkade://')) {
|
||||
const ark = new LightningArkWallet();
|
||||
ark.setSecret(text);
|
||||
await ark.init();
|
||||
// Defer init() to first wallet open when offline — init touches the ASP
|
||||
// and delegator over the network. We still detect the wallet by prefix
|
||||
// and persist it with its secret.
|
||||
// A network or SDK failure during init must not abort the import: the
|
||||
// wallet type and secret are known, and the SDK runtime can be brought
|
||||
// up the next time the wallet is opened.
|
||||
if (!offline) {
|
||||
await ark.fetchBalance();
|
||||
await ark.fetchTransactions();
|
||||
try {
|
||||
await ark.init();
|
||||
// Restore any previous Boltz swap activity for this seed exactly
|
||||
// once, here at import time. We never run this on later wallet
|
||||
// opens — the app does not sweep all swaps on bootstrap. A failure
|
||||
// must not block the import: the wallet itself is fine, the
|
||||
// restored rows are an optional bonus for imported-from-elsewhere
|
||||
// wallets.
|
||||
try {
|
||||
await ark.restoreSwaps();
|
||||
} catch (e: any) {
|
||||
console.log('[wallet-import] restoreSwaps failed:', e?.message ?? e);
|
||||
}
|
||||
try {
|
||||
await ark.fetchBalance();
|
||||
await ark.fetchTransactions();
|
||||
} catch (e: any) {
|
||||
console.log('[wallet-import] initial Ark sync failed:', e?.message ?? e);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log('[wallet-import] Ark init failed; deferring to next open:', e?.message ?? e);
|
||||
}
|
||||
}
|
||||
yield { wallet: ark };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -104,6 +104,11 @@ export type LightningTransaction = {
|
||||
timestamp: number; // seconds, not milliseconds
|
||||
expire_time?: number;
|
||||
ispaid?: boolean;
|
||||
// Terminal non-success state (failed/refunded/expired swap). Distinct from
|
||||
// `ispaid:false`, which on its own only means "not settled yet" and is also
|
||||
// true for in-flight rows. Consumers that gate on pending vs. dead state
|
||||
// (e.g. the wallet-card pending pill) must treat `failed` rows as terminal.
|
||||
failed?: boolean;
|
||||
walletID?: string;
|
||||
value?: number;
|
||||
amt?: number;
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { BlueApp as BlueAppClass, TCounterpartyMetadata, TTXMetadata } from '../../class/blue-app';
|
||||
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { WatchOnlyWallet } from '../../class/wallets/watch-only-wallet';
|
||||
import type { TWallet } from '../../class/wallets/types';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import loc, { formatBalanceWithoutSuffix } from '../../loc';
|
||||
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
||||
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
|
||||
import { registerArkBackgroundTask, stopArkBackgroundTask } from '../../blue_modules/arkade-background';
|
||||
import { startAndDecrypt } from '../../blue_modules/start-and-decrypt';
|
||||
import { isNotificationsEnabled, majorTomToGroundControl, unsubscribe } from '../../blue_modules/notifications';
|
||||
import { BitcoinUnit } from '../../models/bitcoinUnits';
|
||||
@ -174,6 +176,15 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
const deleteWallet = useCallback((wallet: TWallet) => {
|
||||
BlueApp.deleteWallet(wallet);
|
||||
setWallets([...BlueApp.getWallets()]);
|
||||
if (wallet.type === LightningArkWallet.type) {
|
||||
// Fire-and-forget: cleans up the per-wallet Arkade Realm (close + delete files)
|
||||
// and the Keychain encryption key. Errors stay scoped to the Ark wallet path
|
||||
// and never block deletion.
|
||||
(wallet as LightningArkWallet).onDelete().catch(e => console.warn('[StorageProvider] Ark wallet cleanup failed:', e?.message ?? e));
|
||||
if (!BlueApp.getWallets().some(w => w.type === LightningArkWallet.type)) {
|
||||
stopArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task stop failed:', e?.message ?? e));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleWalletDeletion = useCallback(
|
||||
@ -307,7 +318,11 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
if (walletsInitialized) {
|
||||
txMetadata.current = BlueApp.tx_metadata;
|
||||
counterpartyMetadata.current = BlueApp.counterparty_metadata;
|
||||
setWallets(BlueApp.getWallets());
|
||||
const loaded = BlueApp.getWallets();
|
||||
setWallets(loaded);
|
||||
if (loaded.some(w => w.type === LightningArkWallet.type)) {
|
||||
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
|
||||
}
|
||||
}
|
||||
}, [walletsInitialized]);
|
||||
|
||||
@ -453,6 +468,9 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable);
|
||||
w.setUserHasSavedExport(true);
|
||||
addWallet(w);
|
||||
if (w instanceof LightningArkWallet) {
|
||||
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
|
||||
}
|
||||
if (getScanWasBBQR()) {
|
||||
// to avoid proxying `useBBQR` through a bunch of screens during import procedure, we use a trick:
|
||||
// on add-wallet screen we reset `lastScanWasBBQR` to false. then potentially user scans QR in BBQR format
|
||||
|
||||
@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View } from 'react-native';
|
||||
import Lnurl from '../class/lnurl';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import { LightningTransaction, Transaction } from '../class/wallets/types';
|
||||
import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon';
|
||||
import TransactionIncomingIcon from '../components/icons/TransactionIncomingIcon';
|
||||
@ -154,7 +155,30 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? '');
|
||||
const noteForCopy = (txMemo || item.memo || '').trim() || undefined;
|
||||
|
||||
// For LightningArkWallet rows, prepend a kind tag to the date subtitle. Such a
|
||||
// wallet transacts entirely via Boltz swaps, so every row is Lightning; the
|
||||
// only genuinely on-chain activity is onboarding/refill (boarding UTXOs),
|
||||
// tagged from the synthetic `boarding-…` txid set in
|
||||
// lightning-ark-wallet.getTransactions(). Other wallet types are unaffected.
|
||||
const arkRowKind = useMemo<'Lightning' | 'Refill' | undefined>(() => {
|
||||
const wallet = wallets.find(w => w.getID() === item.walletID);
|
||||
if (wallet?.type !== LightningArkWallet.type) return undefined;
|
||||
const txid = (item as { txid?: string }).txid;
|
||||
if (txid?.startsWith('boarding-')) return 'Refill';
|
||||
return 'Lightning';
|
||||
}, [item, wallets]);
|
||||
|
||||
// A refill is "Pending" until the SDK settles its boarding UTXO into a VTXO
|
||||
// (also when it enters the spendable balance). getTransactions() pass 2 tags
|
||||
// those not-yet-settled rows with a `boarding-utxo-…` id; settled refills use
|
||||
// `boarding-…` and render as a normal confirmed receive.
|
||||
const isPendingRefill = useMemo(
|
||||
() => arkRowKind === 'Refill' && !!(item as { txid?: string }).txid?.startsWith('boarding-utxo-'),
|
||||
[arkRowKind, item],
|
||||
);
|
||||
|
||||
const listTitleKey = useMemo((): 'pending' | 'sent' | 'received' => {
|
||||
if (isPendingRefill) return 'pending';
|
||||
if (item.category === 'receive' && item.confirmations! < 3) return 'pending';
|
||||
if (item.type === 'bitcoind_tx') return item.value! < 0 ? 'sent' : 'received';
|
||||
if (item.type === 'paid_invoice') return 'sent';
|
||||
@ -164,7 +188,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
}
|
||||
if (!item.confirmations) return 'pending';
|
||||
return item.value! < 0 ? 'sent' : 'received';
|
||||
}, [item.category, item.confirmations, item.type, item.value, item.ispaid]);
|
||||
}, [isPendingRefill, item.category, item.confirmations, item.type, item.value, item.ispaid]);
|
||||
|
||||
const listTitle = useMemo(() => {
|
||||
if (listTitleKey === 'pending') return loc.transactions.pending;
|
||||
@ -175,11 +199,11 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
const isPending = listTitleKey === 'pending';
|
||||
|
||||
const dateLine = useMemo(() => {
|
||||
if (isPending) return transactionTimeToReadable(item.timestamp);
|
||||
return formatTransactionListDate(item.timestamp * 1000);
|
||||
const formatted = isPending ? transactionTimeToReadable(item.timestamp) : formatTransactionListDate(item.timestamp * 1000);
|
||||
return arkRowKind ? `${arkRowKind} · ${formatted}` : formatted;
|
||||
// language in deps so date format updates when locale changes (formatters use global locale)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPending, item.timestamp, language]);
|
||||
}, [isPending, item.timestamp, language, arkRowKind]);
|
||||
|
||||
const formattedAmount = useMemo(() => {
|
||||
return formatBalanceWithoutSuffix(item.value, itemPriceUnit, true).toString();
|
||||
@ -241,6 +265,14 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
]);
|
||||
|
||||
const determineTransactionTypeAndAvatar = () => {
|
||||
// A refill awaiting settlement: show it as pending, not as a completed receive.
|
||||
if (isPendingRefill) {
|
||||
return {
|
||||
label: loc.transactions.pending_transaction,
|
||||
icon: <TransactionPendingIcon />,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.category === 'receive' && item.confirmations! < 3) {
|
||||
return {
|
||||
label: loc.transactions.pending_transaction,
|
||||
@ -248,6 +280,14 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
// Recovered Arkade Lightning legs are bitcoind_tx but represent Boltz swaps,
|
||||
// not on-chain transfers — render them with the off-chain (Lightning) icon.
|
||||
if (arkRowKind === 'Lightning' && item.type === 'bitcoind_tx') {
|
||||
return item.value! < 0
|
||||
? { label: loc.transactions.offchain, icon: <TransactionOffchainIcon /> }
|
||||
: { label: loc.transactions.incoming_transaction, icon: <TransactionOffchainIncomingIcon /> };
|
||||
}
|
||||
|
||||
if (item.type && item.type === 'bitcoind_tx') {
|
||||
return {
|
||||
label: loc.transactions.onchain,
|
||||
@ -321,7 +361,11 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
pop();
|
||||
}
|
||||
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
|
||||
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') {
|
||||
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
|
||||
// A settled Arkade swap is an enriched native Ark leg (type 'bitcoind_tx')
|
||||
// carrying the swap's invoice payload (payment_request/hash/preimage). Route
|
||||
// it to the Lightning invoice view by that payload, not by type — otherwise
|
||||
// it falls through to the on-chain TransactionStatus branch below.
|
||||
const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID);
|
||||
if (lightningWallet.length === 1) {
|
||||
try {
|
||||
@ -352,15 +396,24 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
walletID: lightningWallet[0].getID(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('cant handle press');
|
||||
} else if ((item as { txid?: string }).txid) {
|
||||
// Hash-less Ark rows carry a synthetic `txid`. Native transfer legs
|
||||
// (`ark-…`) open the hash-less-tolerant TransactionStatus detail. Refill
|
||||
// rows (`boarding-…` / `boarding-utxo-…`) have no detail surface and are
|
||||
// not tappable — matching master, where on-chain top-ups aren't tappable.
|
||||
const txid = (item as { txid: string }).txid;
|
||||
if (!txid.startsWith('boarding-')) {
|
||||
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
|
||||
}
|
||||
}
|
||||
}, [item, renderHighlightedText, navigate, walletID, wallets, customOnPress, disableNavigation]);
|
||||
|
||||
const handleOnDetailsPress = useCallback(() => {
|
||||
if (walletID && item && item.hash) {
|
||||
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
|
||||
} else {
|
||||
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
|
||||
// Settled Arkade swaps carry invoice data on a 'bitcoind_tx' leg; route by
|
||||
// payload so they open the Lightning invoice view (see onPress above).
|
||||
const lightningWallet = wallets.find(wallet => wallet?.getID() === item.walletID);
|
||||
if (lightningWallet) {
|
||||
navigate('LNDViewInvoice', {
|
||||
@ -368,6 +421,13 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
walletID: lightningWallet.getID(),
|
||||
});
|
||||
}
|
||||
} else if ((item as { txid?: string }).txid) {
|
||||
// Match the regular tap path for Ark non-swap rows: native transfer legs
|
||||
// open TransactionStatus; refills (`boarding-…`) are not tappable (master).
|
||||
const txid = (item as { txid: string }).txid;
|
||||
if (!txid.startsWith('boarding-')) {
|
||||
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
|
||||
}
|
||||
}
|
||||
}, [item, navigate, walletID, wallets]);
|
||||
|
||||
@ -449,7 +509,10 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
if (renderHighlightedText && searchQuery) {
|
||||
const highlighted = renderHighlightedText(subtitle, searchQuery);
|
||||
if (React.isValidElement(highlighted)) {
|
||||
const highlightedElement = highlighted as React.ReactElement<{ numberOfLines?: number; style?: TextStyle | TextStyle[] }>;
|
||||
const highlightedElement = highlighted as React.ReactElement<{
|
||||
numberOfLines?: number;
|
||||
style?: TextStyle | TextStyle[];
|
||||
}>;
|
||||
const existingStyle = highlightedElement.props?.style;
|
||||
const mergedStyle: TextStyle[] = (
|
||||
Array.isArray(existingStyle)
|
||||
|
||||
@ -16,6 +16,7 @@ import { useSettings } from '../hooks/context/useSettings';
|
||||
import ToolTipMenu from './TooltipMenu';
|
||||
import useAnimateOnChange from '../hooks/useAnimateOnChange';
|
||||
import { useLocale } from '@react-navigation/native';
|
||||
import ActionSheet from '../screen/ActionSheet';
|
||||
|
||||
interface TransactionsNavigationHeaderProps {
|
||||
wallet: TWallet;
|
||||
@ -111,20 +112,25 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
[handleBalanceVisibility, handleCopyPress],
|
||||
);
|
||||
|
||||
const toolTipActions = useMemo(() => {
|
||||
return [
|
||||
// The Manage Funds menu is presented via a JS ActionSheet rather than the
|
||||
// native context menu (ToolTipMenu): react-native-context-menu-view is
|
||||
// Paper-only and, routed through Fabric's legacy interop on the New
|
||||
// Architecture, its host view gets mispositioned to the header origin —
|
||||
// overlapping the wallet label. A plain TouchableOpacity + ActionSheet lays
|
||||
// out correctly (same pattern as the Multisig button below).
|
||||
const showManageFundsActionSheet = useCallback(() => {
|
||||
ActionSheet.showActionSheetWithOptions(
|
||||
{
|
||||
id: actionKeys.Refill,
|
||||
text: loc.lnd.refill,
|
||||
icon: actionIcons.Refill,
|
||||
title: loc.lnd.title,
|
||||
options: [loc._.cancel, loc.lnd.refill, loc.lnd.refill_external],
|
||||
cancelButtonIndex: 0,
|
||||
},
|
||||
{
|
||||
id: actionKeys.RefillWithExternalWallet,
|
||||
text: loc.lnd.refill_external,
|
||||
icon: actionIcons.RefillWithExternalWallet,
|
||||
buttonIndex => {
|
||||
if (buttonIndex === 1) handleManageFundsPressed(actionKeys.Refill);
|
||||
else if (buttonIndex === 2) handleManageFundsPressed(actionKeys.RefillWithExternalWallet);
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
);
|
||||
}, [handleManageFundsPressed]);
|
||||
|
||||
const currentBalance = wallet ? wallet.getBalance() : 0;
|
||||
const formattedBalance = useMemo(() => {
|
||||
@ -210,68 +216,48 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
|
||||
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
|
||||
{wallet.getLabel()}
|
||||
</Text>
|
||||
<View style={styles.balanceSection}>
|
||||
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
enableAndroidRipple={false}
|
||||
buttonStyle={styles.walletBalance}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={toolTipWalletBalanceActions}
|
||||
>
|
||||
<View style={styles.walletBalance}>
|
||||
{hideBalance ? (
|
||||
<BlurredBalanceView />
|
||||
) : (
|
||||
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
|
||||
<Animated.Text
|
||||
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
|
||||
testID="WalletBalance"
|
||||
numberOfLines={1}
|
||||
minimumFontScale={0.5}
|
||||
adjustsFontSizeToFit
|
||||
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
|
||||
>
|
||||
{balance}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<View style={styles.manageFundsSection}>
|
||||
<View style={styles.manageFundsButtonContainer}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress={false}
|
||||
isButton
|
||||
onPressMenuItem={handleManageFundsPressed}
|
||||
actions={toolTipActions}
|
||||
buttonStyle={styles.manageFundsButtonTouchable}
|
||||
>
|
||||
<View style={styles.manageFundsButtonContent}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{wallet.type === MultisigHDWallet.type && (
|
||||
<TouchableOpacity
|
||||
style={[styles.manageFundsButtonContainer, styles.manageFundsButtonTouchable]}
|
||||
accessibilityRole="button"
|
||||
onPress={() => handleManageFundsPressed()}
|
||||
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
|
||||
<ToolTipMenu
|
||||
shouldOpenOnLongPress
|
||||
isButton
|
||||
enableAndroidRipple={false}
|
||||
buttonStyle={styles.walletBalance}
|
||||
onPressMenuItem={onPressMenuItem}
|
||||
actions={toolTipWalletBalanceActions}
|
||||
>
|
||||
<View style={styles.manageFundsButtonContent}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
|
||||
<View style={styles.walletBalance}>
|
||||
{hideBalance ? (
|
||||
<BlurredBalanceView />
|
||||
) : (
|
||||
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
|
||||
<Animated.Text
|
||||
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
|
||||
testID="WalletBalance"
|
||||
numberOfLines={1}
|
||||
minimumFontScale={0.5}
|
||||
adjustsFontSizeToFit
|
||||
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
|
||||
>
|
||||
{balance}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ToolTipMenu>
|
||||
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
|
||||
<Text style={styles.walletPreferredUnitText}>
|
||||
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{wallet.type === MultisigHDWallet.type && (
|
||||
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
|
||||
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@ -287,10 +273,6 @@ const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
padding: 15,
|
||||
},
|
||||
balanceSection: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
walletLabel: {
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 19,
|
||||
@ -301,39 +283,21 @@ const styles = StyleSheet.create({
|
||||
flexShrink: 1,
|
||||
marginRight: 6,
|
||||
},
|
||||
manageFundsButtonContainer: {
|
||||
manageFundsButton: {
|
||||
marginTop: 14,
|
||||
marginBottom: 10,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
manageFundsButtonTouchable: {
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
borderRadius: 9,
|
||||
height: 39,
|
||||
paddingHorizontal: 12,
|
||||
overflow: 'hidden',
|
||||
minHeight: 39,
|
||||
alignSelf: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
manageFundsButtonContent: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
manageFundsSection: {
|
||||
width: '100%',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
manageFundsButtonText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
lineHeight: 18,
|
||||
color: '#FFFFFF',
|
||||
padding: 0,
|
||||
textAlign: 'center',
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center',
|
||||
padding: 12,
|
||||
},
|
||||
walletBalanceAndUnitContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@ -383,9 +383,21 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
|
||||
|
||||
let latestTransactionText;
|
||||
|
||||
// Lightning / Ark wallets do not have on-chain confirmations — settlement is
|
||||
// signaled by `ispaid`. Bitcoin/on-chain wallets keep the existing
|
||||
// `confirmations === 0` rule unchanged so their pending-pill semantics
|
||||
// never depend on a Lightning shape.
|
||||
// `ispaid === false` alone is not "pending": it is also true for terminal
|
||||
// failed/refunded swaps, which stay in history. Gate on `!tx.failed` so a
|
||||
// dead swap doesn't pin the card to "pending" forever.
|
||||
const isLightningShaped = item.type === LightningCustodianWallet.type || item.type === LightningArkWallet.type;
|
||||
const hasPendingTx = isLightningShaped
|
||||
? item.getTransactions().some((tx: any) => tx.ispaid === false && !tx.failed)
|
||||
: item.getTransactions().some((tx: Transaction) => tx.confirmations === 0);
|
||||
|
||||
if (item.getBalance() !== 0 && item.getLatestTransactionTime() === 0) {
|
||||
latestTransactionText = loc.wallets.pull_to_refresh;
|
||||
} else if (item.getTransactions().find((tx: Transaction) => tx.confirmations === 0)) {
|
||||
} else if (hasPendingTx) {
|
||||
latestTransactionText = loc.transactions.pending;
|
||||
} else {
|
||||
latestTransactionText = transactionTimeToReadable(item.getLatestTransactionTime());
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { CommonActions } from '@react-navigation/native';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus, Linking } from 'react-native';
|
||||
import { reconcileArkBackgroundTaskResults } from '../blue_modules/arkade-background';
|
||||
import { getClipboardContent } from '../blue_modules/clipboard';
|
||||
import { updateExchangeRate } from '../blue_modules/currency';
|
||||
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
|
||||
@ -13,6 +14,7 @@ import {
|
||||
setApplicationIconBadgeNumber,
|
||||
} from '../blue_modules/notifications';
|
||||
import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet';
|
||||
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
|
||||
import DeeplinkSchemaMatch from '../class/deeplink-schema-match';
|
||||
import loc from '../loc';
|
||||
import { Chain } from '../models/bitcoinUnits';
|
||||
@ -86,6 +88,47 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction);
|
||||
|
||||
console.log('processing push notification:', payload);
|
||||
|
||||
// Local notification for actionable Ark swaps. Routed by walletID
|
||||
// rather than address/txid because the payload is locally generated;
|
||||
// see blue_modules/arkade-notifications.ts.
|
||||
if (+payload.type === 100) {
|
||||
const arkWallet = wallets.find(w => w.getID() === payload.walletID);
|
||||
if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) {
|
||||
if (wasTapped) {
|
||||
navigation.navigate('WalletTransactions', {
|
||||
walletID: payload.walletID,
|
||||
walletType: arkWallet?.type,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Refresh swap-derived rows directly via the wallet method to
|
||||
// bypass the 5-second NOP throttle in StorageProvider.fetchAndSaveWalletTransactions:
|
||||
// reconcileArkBackgroundTaskResults often runs on app resume immediately
|
||||
// before this handler, which would make a throttled call NOP and
|
||||
// leave the synthetic row stale.
|
||||
try {
|
||||
await arkWallet.fetchTransactions();
|
||||
await saveToDisk();
|
||||
} catch (e: any) {
|
||||
console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e);
|
||||
}
|
||||
|
||||
if (wasTapped) {
|
||||
const arkWalletID = arkWallet.getID();
|
||||
const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`);
|
||||
if (row) {
|
||||
navigation.navigate('LNDViewInvoice', { invoice: row, walletID: arkWalletID });
|
||||
} else {
|
||||
navigation.navigate('WalletTransactions', { walletID: arkWalletID, walletType: arkWallet.type });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let wallet;
|
||||
switch (+payload.type) {
|
||||
case 2:
|
||||
@ -126,6 +169,51 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction);
|
||||
|
||||
console.log('processing push notification:', payload);
|
||||
|
||||
if (+payload.type === 100) {
|
||||
const arkWallet = wallets.find(w => w.getID() === payload.walletID);
|
||||
if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) {
|
||||
if (wasTapped) {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: 'WalletTransactions',
|
||||
params: { walletID: payload.walletID, walletType: arkWallet?.type },
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await arkWallet.fetchTransactions();
|
||||
await saveToDisk();
|
||||
} catch (e: any) {
|
||||
console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e);
|
||||
}
|
||||
|
||||
if (wasTapped) {
|
||||
const arkWalletID = arkWallet.getID();
|
||||
const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`);
|
||||
if (row) {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: 'LNDViewInvoice',
|
||||
params: { invoice: row, walletID: arkWalletID },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
navigationRef.dispatch(
|
||||
CommonActions.navigate({
|
||||
name: 'WalletTransactions',
|
||||
params: { walletID: arkWalletID, walletType: arkWallet.type },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let wallet;
|
||||
switch (+payload.type) {
|
||||
case 2:
|
||||
@ -179,7 +267,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
console.error('Failed to process push notifications:', error);
|
||||
}
|
||||
return false;
|
||||
}, [shouldActivateListeners, wallets, fetchAndSaveWalletTransactions, navigation, refreshAllWalletTransactions]);
|
||||
}, [shouldActivateListeners, wallets, fetchAndSaveWalletTransactions, saveToDisk, navigation, refreshAllWalletTransactions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldActivateListeners) return;
|
||||
@ -214,16 +302,12 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
throw new Error(loc.send.qr_error_no_qrcode);
|
||||
}
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
DeeplinkSchemaMatch.navigationRouteFor(
|
||||
{ url: qrValue },
|
||||
(value: [string, any]) => navigationRef.navigate(...value),
|
||||
{
|
||||
wallets,
|
||||
addWallet,
|
||||
saveToDisk,
|
||||
setSharedCosigner,
|
||||
},
|
||||
);
|
||||
DeeplinkSchemaMatch.navigationRouteFor({ url: qrValue }, (value: [string, any]) => navigationRef.navigate(...value), {
|
||||
wallets,
|
||||
addWallet,
|
||||
saveToDisk,
|
||||
setSharedCosigner,
|
||||
});
|
||||
} else {
|
||||
DeeplinkSchemaMatch.navigationRouteFor(event, (value: [string, any]) => navigationRef.navigate(...value), {
|
||||
wallets,
|
||||
@ -277,6 +361,12 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
if ((appState.current.match(/inactive|background/) && nextAppState === 'active') || nextAppState === undefined) {
|
||||
updateExchangeRate();
|
||||
const processed = await processPushNotifications();
|
||||
// Reconcile in-process Ark background task results before the
|
||||
// notification-handled early return: if the background task observed
|
||||
// status changes while the app was backgrounded, the affected
|
||||
// wallets need a transactions refresh whether or not a notification
|
||||
// also fired.
|
||||
reconcileArkBackgroundTaskResults(fetchAndSaveWalletTransactions);
|
||||
if (processed) return;
|
||||
const clipboard = await getClipboardContent();
|
||||
if (!clipboard) return;
|
||||
@ -312,7 +402,7 @@ const useCompanionListeners = (skipIfNotInitialized = true) => {
|
||||
appState.current = nextAppState;
|
||||
}
|
||||
},
|
||||
[processPushNotifications, showClipboardAlert, wallets, shouldActivateListeners],
|
||||
[processPushNotifications, fetchAndSaveWalletTransactions, showClipboardAlert, wallets, shouldActivateListeners],
|
||||
);
|
||||
|
||||
const addListeners = useCallback(() => {
|
||||
|
||||
14
index.js
14
index.js
@ -5,9 +5,23 @@ import './shim.js';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { AppRegistry, LogBox } from 'react-native';
|
||||
import BackgroundFetch from 'react-native-background-fetch';
|
||||
|
||||
import App from './App';
|
||||
import { restoreSavedPreferredFiatCurrencyAndExchangeFromStorage } from './blue_modules/currency';
|
||||
import { runArkBackgroundTask } from './blue_modules/arkade-background';
|
||||
|
||||
// Android headless execution boots a bare JS runtime without the React tree.
|
||||
// The headless task callback must be registered at module scope before
|
||||
// AppRegistry.registerComponent so the symbol exists when the OS dispatches a
|
||||
// terminated-process wake.
|
||||
BackgroundFetch.registerHeadlessTask(async event => {
|
||||
if (event.timeout) {
|
||||
BackgroundFetch.finish(event.taskId);
|
||||
return;
|
||||
}
|
||||
await runArkBackgroundTask(event.taskId);
|
||||
});
|
||||
|
||||
if (!Error.captureStackTrace) {
|
||||
// captureStackTrace is only available when debugging
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>io.bluewallet.bluewallet.fetchTxsForWallet</string>
|
||||
<string>com.transistorsoft.fetch</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
|
||||
@ -11,6 +11,13 @@ module.exports = {
|
||||
'^expo/fetch$': '<rootDir>/util/expo-fetch-nodejs.js',
|
||||
'^@react-native-vector-icons/(.*)$': '<rootDir>/tests/mocks/vector-icons.js',
|
||||
'^react-native-svg$': '<rootDir>/tests/mocks/react-native-svg.js',
|
||||
// Mirror of metro.config.js resolveRequest: descriptors-core uses @noble/hashes v2
|
||||
// subpaths (e.g. `sha2.js`, `legacy.js`) but does not declare it as a dep, so npm
|
||||
// resolves up to v1.3.3 (which only exposes the no-extension subpaths via `exports`).
|
||||
// Redirect any `.js`-suffixed @noble/hashes subpath to the v2 copy nested under
|
||||
// descriptors-scure. bitcoinjs-lib imports `@noble/hashes/sha256` (no extension)
|
||||
// so it is unaffected.
|
||||
'^@noble/hashes/(.+\\.js)$': '<rootDir>/node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes/$1',
|
||||
},
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
watchPathIgnorePatterns: ['<rootDir>/node_modules'],
|
||||
|
||||
11
loc/en.json
11
loc/en.json
@ -56,6 +56,7 @@
|
||||
"errorInvoiceExpired": "Invoice expired.",
|
||||
"expired": "Expired",
|
||||
"expiresIn": "Expires in {time} minutes",
|
||||
"network_fee": "Network fee: {fee}",
|
||||
"payButton": "Pay",
|
||||
"payment": "Payment",
|
||||
"placeholder": "Invoice or address",
|
||||
@ -76,7 +77,13 @@
|
||||
"preimage": "Pre-image",
|
||||
"sats": "sats.",
|
||||
"date_time": "Date and Time",
|
||||
"wasnt_paid_and_expired": "This invoice was not paid and has expired."
|
||||
"wasnt_paid_and_expired": "This invoice was not paid and has expired.",
|
||||
"receiving_payment": "Receiving payment…",
|
||||
"refund_funds": "Refund funds",
|
||||
"refund_deferred": "Funds aren't refundable yet. Try again after the swap timelock expires.",
|
||||
"notification_action_title": "Action needed",
|
||||
"notification_claim_body": "{walletLabel}: tap to claim your incoming Lightning payment.",
|
||||
"notification_refund_body": "{walletLabel}: tap to refund your stuck Lightning payment."
|
||||
},
|
||||
"plausibledeniability": {
|
||||
"create_fake_storage": "Create Encrypted Storage",
|
||||
@ -447,6 +454,8 @@
|
||||
"details_show_addresses": "Show addresses",
|
||||
"details_stats_coins": "Coins",
|
||||
"details_title": "Wallet",
|
||||
"restore_swap_activity": "Restore swap activity",
|
||||
"restore_swap_activity_done": "Swap activity restored.",
|
||||
"wallets": "Wallets",
|
||||
"swipe_balance_hide": "Hide",
|
||||
"swipe_balance_show": "Show",
|
||||
|
||||
@ -1,11 +1,28 @@
|
||||
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
// Force the Arkade SDK and its subpaths to resolve to their CJS builds. The ESM
|
||||
// build uses `export * as ns from '...'` (ES2020), which the React Native babel
|
||||
// preset does not transform, so loading the ESM entry triggers a Babel error.
|
||||
// The boltz-swap realm subpath is forced to CJS for the same reason — it
|
||||
// re-exports the SDK realm types.
|
||||
const resolveAliases = {
|
||||
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/cjs/adapters/expo.js'),
|
||||
'@arkade-os/sdk': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/index.cjs'),
|
||||
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/adapters/expo.cjs'),
|
||||
'@arkade-os/sdk/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/repositories/realm/index.cjs'),
|
||||
'@arkade-os/boltz-swap/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/boltz-swap/dist/repositories/realm/index.cjs'),
|
||||
'expo/fetch': path.join(__dirname, 'util/expo-fetch.js'),
|
||||
};
|
||||
|
||||
// @bitcoinerlab/descriptors-core uses @noble/hashes 2.x APIs (`./legacy.js`,
|
||||
// `./sha2.js`) but does not declare @noble/hashes as a direct dep. npm
|
||||
// resolves up to the top-level @noble/hashes@1.3.3 (kept for bitcoinjs-lib),
|
||||
// which doesn't expose those subpaths. Redirect any @noble/hashes import that
|
||||
// originates inside descriptors-core to the v2 copy already nested under
|
||||
// descriptors-scure.
|
||||
const nobleHashesV2 = path.join(__dirname, 'node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes');
|
||||
const descriptorsCoreDir = path.join('node_modules', '@bitcoinerlab', 'descriptors-core');
|
||||
|
||||
/**
|
||||
* Metro configuration
|
||||
* https://reactnative.dev/docs/metro
|
||||
@ -27,6 +44,13 @@ const config = {
|
||||
filePath: resolveAliases[moduleName],
|
||||
};
|
||||
|
||||
if (moduleName.startsWith('@noble/hashes/') && context.originModulePath.includes(descriptorsCoreDir)) {
|
||||
return {
|
||||
type: 'sourceFile',
|
||||
filePath: path.join(nobleHashesV2, moduleName.slice('@noble/hashes/'.length)),
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to default resolution
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
},
|
||||
|
||||
@ -7,7 +7,6 @@ export type LNDStackParamsList = {
|
||||
ScanLNDInvoice: {
|
||||
walletID: string | undefined;
|
||||
uri: string | undefined;
|
||||
invoice: string | undefined;
|
||||
onBarScanned: string | undefined;
|
||||
};
|
||||
LnurlPay: {
|
||||
|
||||
338
package-lock.json
generated
338
package-lock.json
generated
@ -10,8 +10,8 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@arkade-os/boltz-swap": "0.2.19",
|
||||
"@arkade-os/sdk": "0.3.12",
|
||||
"@arkade-os/boltz-swap": "0.3.37",
|
||||
"@arkade-os/sdk": "0.4.32",
|
||||
"@babel/preset-env": "7.29.5",
|
||||
"@bugsnag/react-native": "8.9.0",
|
||||
"@bugsnag/source-maps": "2.3.3",
|
||||
@ -72,6 +72,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-localization": "github:BlueWallet/react-localization#ae7969a",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-background-fetch": "4.2.9",
|
||||
"react-native-biometrics": "3.0.1",
|
||||
"react-native-blue-crypto": "github:BlueWallet/react-native-blue-crypto#3cb5442",
|
||||
"react-native-camera-kit-no-google": "github:BlueWallet/react-native-camera-kit-no-google#0ed049a62da29cf304019363ec9d9ef3a73652e6",
|
||||
@ -179,18 +180,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/boltz-swap": {
|
||||
"version": "0.2.19",
|
||||
"version": "0.3.37",
|
||||
"resolved": "https://registry.npmjs.org/@arkade-os/boltz-swap/-/boltz-swap-0.3.37.tgz",
|
||||
"integrity": "sha512-wP4daP/sDpUahmivaIZC8Lfvqz4lhQMWM1R8/Ib5x7NMS6k++FSs4KKQ6wjPKpweF8ULilsJdorhmLpNlEba6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@arkade-os/sdk": "0.3.12",
|
||||
"@arkade-os/sdk": "0.4.32",
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0",
|
||||
"@scure/btc-signer": "2.0.1",
|
||||
"bip68": "^1.0.4",
|
||||
"bip68": "1.0.4",
|
||||
"light-bolt11-decoder": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"peerDependencies": {
|
||||
"expo-background-task": ">=0.1.0",
|
||||
"expo-task-manager": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo-background-task": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-task-manager": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/boltz-swap/node_modules/@noble/hashes": {
|
||||
@ -204,23 +217,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/sdk": {
|
||||
"version": "0.3.12",
|
||||
"hasInstallScript": true,
|
||||
"version": "0.4.32",
|
||||
"resolved": "https://registry.npmjs.org/@arkade-os/sdk/-/sdk-0.4.32.tgz",
|
||||
"integrity": "sha512-we7eNPuuW9PWRS/B4Nlw5MHXTgJ7CuQzbdSrisH0u3P2PPQd/0FbSspEW/OQRNjMrJl+29zAEKN5kswy9MTjxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marcbachmann/cel-js": "7.0.0",
|
||||
"@noble/curves": "2.0.0",
|
||||
"@bitcoinerlab/descriptors-scure": "3.1.7",
|
||||
"@marcbachmann/cel-js": "7.3.1",
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/secp256k1": "3.0.0",
|
||||
"@scure/base": "2.0.0",
|
||||
"@scure/bip39": "2.0.1",
|
||||
"@scure/btc-signer": "2.0.1",
|
||||
"bip68": "1.0.4"
|
||||
"bip68": "1.0.4",
|
||||
"ws-electrumx-client": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.12.0 <25"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": ">=1.0.0",
|
||||
"expo": ">=54.0.0",
|
||||
"expo-background-task": "~1.0.10 || >=55.0.0",
|
||||
"expo-sqlite": "~16.0.10 || >=55.0.0",
|
||||
"expo-task-manager": "~14.0.9 || >=55.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-native-async-storage/async-storage": {
|
||||
"optional": true
|
||||
},
|
||||
"expo": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-background-task": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-sqlite": {
|
||||
"optional": true
|
||||
},
|
||||
"expo-task-manager": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/sdk/node_modules/@noble/secp256k1": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz",
|
||||
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
@ -1946,6 +1989,125 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bitcoinerlab/descriptors-core": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors-core/-/descriptors-core-3.1.7.tgz",
|
||||
"integrity": "sha512-7VccUDvKcHK7RF07Vo19Obax9jO3wlPWIXtvXy61GBqXptKv156O9Z4+sm2py1CuxPRpTXHlvH70G4KVVDoKlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/miniscript": "^2.0.0",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"uint8array-tools": "^0.0.9",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ledgerhq/ledger-bitcoin": "^0.3.1",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@scure/bip32": "^2.0.1",
|
||||
"@scure/btc-signer": "^2.0.1",
|
||||
"bip32": "^5.0.1",
|
||||
"bitcoinjs-lib": "^7.0.1",
|
||||
"ecpair": "^3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ledgerhq/ledger-bitcoin": {
|
||||
"optional": true
|
||||
},
|
||||
"@noble/curves": {
|
||||
"optional": true
|
||||
},
|
||||
"@noble/hashes": {
|
||||
"optional": true
|
||||
},
|
||||
"@scure/base": {
|
||||
"optional": true
|
||||
},
|
||||
"@scure/bip32": {
|
||||
"optional": true
|
||||
},
|
||||
"@scure/btc-signer": {
|
||||
"optional": true
|
||||
},
|
||||
"bip32": {
|
||||
"optional": true
|
||||
},
|
||||
"bitcoinjs-lib": {
|
||||
"optional": true
|
||||
},
|
||||
"ecpair": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/descriptors-core/node_modules/varuint-bitcoin": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
|
||||
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/descriptors-core/node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/descriptors-scure": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors-scure/-/descriptors-scure-3.1.7.tgz",
|
||||
"integrity": "sha512-jeyi8L3hzOquJn3t5w+NY3G93B/amZw83xeF8hrpwe7w4FMt2SH2o9rithEydQ2tP3Tlqfog+LnJOOChmfFPWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/descriptors-core": "3.1.7",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@scure/bip32": "^2.0.1",
|
||||
"@scure/btc-signer": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ledgerhq/ledger-bitcoin": "^0.3.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ledgerhq/ledger-bitcoin": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/miniscript": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/miniscript/-/miniscript-2.0.0.tgz",
|
||||
"integrity": "sha512-P8yyubPf6lphmIZfyD/ZbhT/umJX7zH1mKjGql7z0Qt+xuffnz2AueQqq2/01VE2rTIq80VM0oRFdJClGBYx/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bip68": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@bugsnag/core": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@bugsnag/core/-/core-8.9.0.tgz",
|
||||
@ -3347,8 +3509,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@marcbachmann/cel-js": {
|
||||
"version": "7.0.0",
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@marcbachmann/cel-js/-/cel-js-7.3.1.tgz",
|
||||
"integrity": "sha512-P6o26TvjStT8V8+8EF+yq9Pp7ZFV00bpiUMbssr76XbIZGxaB+NNWeBp6WNxOrR9gp0JPzvJueCKHpOs5LE9PQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cel-evaluate": "bin/cel-evaluate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@ -3375,10 +3542,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.0"
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
@ -3388,7 +3557,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
@ -4812,8 +4983,85 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
|
||||
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
|
||||
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
|
||||
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.0.1.tgz",
|
||||
"integrity": "sha512-vk5a/16BbSFZkhh1JIJ0+4H9nceZVo5WzKvJGGWiPp3sQOExeW+L53z3dI6u0adTPoE8ZbL+XEb6hEGzVZSvvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~2.0.0",
|
||||
@ -4827,6 +5075,8 @@
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
@ -6297,6 +6547,8 @@
|
||||
},
|
||||
"node_modules/bip68": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/bip68/-/bip68-1.0.4.tgz",
|
||||
"integrity": "sha512-O1htyufFTYy3EO0JkHg2CLykdXEtV2ssqw47Gq9A0WByp662xpJnMEB9m43LZjsSDjIAOozWRExlFQk2hlV1XQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.5.0"
|
||||
@ -11184,6 +11436,15 @@
|
||||
"version": "2.0.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isomorphic-ws": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
|
||||
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ws": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"dev": true,
|
||||
@ -13490,7 +13751,6 @@
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@ -14392,6 +14652,8 @@
|
||||
},
|
||||
"node_modules/micro-packed": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.8.0.tgz",
|
||||
"integrity": "sha512-AKb8znIvg9sooythbXzyFeChEY0SkW0C6iXECpy/ls0e5BtwXO45J9wD9SLzBztnS4XmF/5kwZknsq+jyynd/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@scure/base": "2.0.0"
|
||||
@ -15956,6 +16218,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-background-fetch": {
|
||||
"version": "4.2.9",
|
||||
"resolved": "https://registry.npmjs.org/react-native-background-fetch/-/react-native-background-fetch-4.2.9.tgz",
|
||||
"integrity": "sha512-BjBGnJ41PbzYC6GC/v/SNWJ6Eri5M7sMf29qMW3s1Lne4XJER43JWf0PP47JHqLc8I+Q7DK7VnyHn6LolJlHTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-biometrics": {
|
||||
"version": "3.0.1",
|
||||
"license": "MIT",
|
||||
@ -19058,6 +19326,40 @@
|
||||
"async-limiter": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws-electrumx-client": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ws-electrumx-client/-/ws-electrumx-client-1.0.5.tgz",
|
||||
"integrity": "sha512-pBjfFqb9j2FBz7NPbnd8r2lOYanEw8ACzfKxOtHCgEGqre5QiTax5XHLVgbsiOvST0vmsHAiMtkJPvsZm77PIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"ws": "^8.12.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ws-electrumx-client/node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||
|
||||
@ -94,8 +94,8 @@
|
||||
"unit": "jest -b -w tests/unit/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arkade-os/boltz-swap": "0.2.19",
|
||||
"@arkade-os/sdk": "0.3.12",
|
||||
"@arkade-os/boltz-swap": "0.3.37",
|
||||
"@arkade-os/sdk": "0.4.32",
|
||||
"@babel/preset-env": "7.29.5",
|
||||
"@bugsnag/react-native": "8.9.0",
|
||||
"@bugsnag/source-maps": "2.3.3",
|
||||
@ -156,6 +156,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-localization": "github:BlueWallet/react-localization#ae7969a",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-background-fetch": "4.2.9",
|
||||
"react-native-biometrics": "3.0.1",
|
||||
"react-native-blue-crypto": "github:BlueWallet/react-native-blue-crypto#3cb5442",
|
||||
"react-native-camera-kit-no-google": "github:BlueWallet/react-native-camera-kit-no-google#0ed049a62da29cf304019363ec9d9ef3a73652e6",
|
||||
|
||||
@ -37,7 +37,7 @@ const ScanLNDInvoice = () => {
|
||||
const { colors } = useTheme();
|
||||
const { direction } = useLocale();
|
||||
const route = useRoute<RouteProps>();
|
||||
const { walletID, uri, invoice } = route.params || {};
|
||||
const { walletID, uri } = route.params || {};
|
||||
const [wallet, setWallet] = useState<LightningCustodianWallet | undefined>(
|
||||
(wallets.find(item => item.getID() === walletID) as LightningCustodianWallet) ||
|
||||
(wallets.find(item => item.chain === Chain.OFFCHAIN) as LightningCustodianWallet),
|
||||
@ -51,6 +51,7 @@ const ScanLNDInvoice = () => {
|
||||
const [amount, setAmount] = useState<string | undefined>();
|
||||
const [isAmountInitiallyEmpty, setIsAmountInitiallyEmpty] = useState<boolean | undefined>();
|
||||
const [expiresIn, setExpiresIn] = useState<string | undefined>();
|
||||
const [arkFeesReady, setArkFeesReady] = useState<boolean>(false);
|
||||
const stylesHook = StyleSheet.create({
|
||||
walletWrapLabel: {
|
||||
color: colors.buttonAlternativeTextColor,
|
||||
@ -83,6 +84,25 @@ const ScanLNDInvoice = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [walletID]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset readiness whenever the selected wallet changes (or is not an Ark
|
||||
// wallet) so a stale `true` from a previously-selected wallet never carries
|
||||
// over to one whose fees are not loaded yet.
|
||||
if (!(wallet instanceof LightningArkWallet)) {
|
||||
setArkFeesReady(false);
|
||||
return;
|
||||
}
|
||||
setArkFeesReady(false);
|
||||
let cancelled = false;
|
||||
wallet
|
||||
.ensureLightningFeesLoaded()
|
||||
.then(() => !cancelled && setArkFeesReady(true))
|
||||
.catch(() => {}); // fee label is non-critical; stay silent and keep the line hidden
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [wallet]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!wallet) {
|
||||
@ -115,7 +135,7 @@ const ScanLNDInvoice = () => {
|
||||
if (data.toLowerCase().startsWith('ark1')) {
|
||||
const arkw = new LightningArkWallet();
|
||||
if (arkw.isAddressValid(data)) {
|
||||
setParams({ uri: undefined, invoice: data });
|
||||
setParams({ uri: undefined });
|
||||
// @ts-ignore we need it to be set to something
|
||||
setDecoded({});
|
||||
setIsAmountInitiallyEmpty(true);
|
||||
@ -140,7 +160,7 @@ const ScanLNDInvoice = () => {
|
||||
}
|
||||
|
||||
Keyboard.dismiss();
|
||||
setParams({ uri: undefined, invoice: data });
|
||||
setParams({ uri: undefined });
|
||||
setIsAmountInitiallyEmpty(newDecoded.num_satoshis === 0);
|
||||
setDestination(data);
|
||||
setIsLoading(false);
|
||||
@ -186,7 +206,7 @@ const ScanLNDInvoice = () => {
|
||||
};
|
||||
|
||||
const pay = async () => {
|
||||
if (!decoded || !wallet || !amount || !invoice) {
|
||||
if (!decoded || !wallet || !amount || !destination) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -227,7 +247,7 @@ const ScanLNDInvoice = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
await wallet.payInvoice(invoice, amountSats);
|
||||
await wallet.payInvoice(destination, amountSats);
|
||||
} catch (Err: any) {
|
||||
console.log(Err.message);
|
||||
setIsLoading(false);
|
||||
@ -299,8 +319,22 @@ const ScanLNDInvoice = () => {
|
||||
};
|
||||
|
||||
const getFees = (): string => {
|
||||
if (!decoded) return '';
|
||||
// Guard the amount, not just `decoded`: the Ark-address path sets
|
||||
// `decoded = {}` (truthy) and amountless invoices leave num_satoshis
|
||||
// 0/absent. The old `if (!decoded)` was safe only because getFees() used to
|
||||
// be called solely inside the `num_satoshis > 0` JSX guard; the fee value is
|
||||
// now hoisted unconditionally, so it must short-circuit here or
|
||||
// `undefined.toString()` throws.
|
||||
if (!decoded?.num_satoshis) return '';
|
||||
const num_satoshis = parseInt(decoded.num_satoshis.toString(), 10);
|
||||
|
||||
if (wallet instanceof LightningArkWallet) {
|
||||
if (!arkFeesReady) return ''; // not loaded yet → fee line stays hidden until warm
|
||||
const est = wallet.getSubmarineFeeEstimate(num_satoshis);
|
||||
return est === undefined ? '' : `${est} ${BitcoinUnit.SATS}`;
|
||||
}
|
||||
|
||||
// LightningCustodianWallet (LndHub): keep the legacy hardcoded estimate.
|
||||
const min = Math.floor(num_satoshis * 0.003);
|
||||
const max = Math.floor(num_satoshis * 0.01) + 1;
|
||||
return `${min} ${BitcoinUnit.SATS} - ${max} ${BitcoinUnit.SATS}`;
|
||||
@ -348,6 +382,12 @@ const ScanLNDInvoice = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const feeText = getFees();
|
||||
// Boltz publishes deterministic fees, so Arkade shows a single fixed amount
|
||||
// under a definite label ("Network fee"), not a "potential" bracket. Custodial
|
||||
// keeps the legacy "Potential fee" label + range.
|
||||
const feeLabel = wallet instanceof LightningArkWallet ? loc.lnd.network_fee : loc.lnd.potentialFee;
|
||||
|
||||
return (
|
||||
<SafeArea style={stylesHook.root}>
|
||||
<View style={[styles.root, stylesHook.root]}>
|
||||
@ -389,8 +429,8 @@ const ScanLNDInvoice = () => {
|
||||
{expiresIn !== undefined && (
|
||||
<View>
|
||||
<Text style={stylesHook.expiresIn}>{expiresIn}</Text>
|
||||
{decoded && decoded.num_satoshis > 0 && (
|
||||
<Text style={stylesHook.expiresIn}>{loc.formatString(loc.lnd.potentialFee, { fee: getFees() })}</Text>
|
||||
{decoded && decoded.num_satoshis > 0 && feeText !== '' && (
|
||||
<Text style={stylesHook.expiresIn}>{loc.formatString(feeLabel, { fee: feeText })}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@ -510,6 +510,8 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
marginHorizontal: 8,
|
||||
minHeight: 33,
|
||||
fontSize: 15,
|
||||
lineHeight: 19,
|
||||
color: '#81868e',
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { RouteProp, useNavigation, useNavigationState, useRoute, useLocale } from '@react-navigation/native';
|
||||
import { BackHandler, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { ActivityIndicator, BackHandler, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Icon from '../../components/Icon';
|
||||
import Share from 'react-native-share';
|
||||
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
|
||||
@ -22,6 +22,10 @@ import dayjs from 'dayjs';
|
||||
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
|
||||
import { BlueSpacing20 } from '../../components/BlueSpacing';
|
||||
import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import presentAlert from '../../components/Alert';
|
||||
import { isReverseSuccessStatus } from '@arkade-os/boltz-swap';
|
||||
import type { BoltzSubmarineSwap } from '@arkade-os/boltz-swap';
|
||||
|
||||
type LNDViewInvoiceRouteParams = {
|
||||
walletID: string;
|
||||
@ -37,12 +41,75 @@ const LNDViewInvoice = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const wallet = wallets.find(w => w.getID() === walletID) as LightningCustodianWallet | undefined;
|
||||
const arkWallet =
|
||||
wallet && (wallet as { type?: string }).type === LightningArkWallet.type ? (wallet as unknown as LightningArkWallet) : undefined;
|
||||
const [isFetchingInvoices, setIsFetchingInvoices] = useState<boolean>(true);
|
||||
const [invoiceStatusChanged, setInvoiceStatusChanged] = useState<boolean>(false);
|
||||
const [qrCodeSize, setQRCodeSize] = useState<number>(90);
|
||||
const fetchInvoiceInterval = useRef<any>(null);
|
||||
const isModal = useNavigationState(state => state.routeNames[0] === LNDCreateInvoice.routeName);
|
||||
|
||||
// Per-swap claim/refund lookup, by the `swap-${id}` prefix mapped onto
|
||||
// the row's `txid` field by lightning-ark-wallet getTransactions(). The
|
||||
// route param is typed as LightningTransaction (which doesn't declare
|
||||
// txid) but at runtime carries the merged `Transaction & LightningTransaction`
|
||||
// shape, so we read txid through a narrow local cast. For non-Ark wallets
|
||||
// and non-swap rows this resolves to undefined and the UI falls through
|
||||
// to the existing branches.
|
||||
const invoiceTxid = typeof invoice === 'object' ? (invoice as { txid?: unknown }).txid : undefined;
|
||||
const swapId = typeof invoiceTxid === 'string' && invoiceTxid.startsWith('swap-') ? invoiceTxid.slice('swap-'.length) : undefined;
|
||||
// Force-render token: bumped by the swap-event subscription below so live
|
||||
// `swap.status` lookups (via getSwapById → _swapHistory) re-evaluate the
|
||||
// moment the SDK observes a status transition, without waiting for the
|
||||
// 3s polling tick to update the route-param snapshot.
|
||||
const [, forceRender] = useReducer((x: number) => x + 1, 0);
|
||||
const swap = swapId && arkWallet ? arkWallet.getSwapById(swapId) : undefined;
|
||||
const [isActioning, setIsActioning] = useState<boolean>(false);
|
||||
const claimable = arkWallet && swap ? arkWallet.isSwapClaimable(swap) : false;
|
||||
const refundable = arkWallet && swap ? arkWallet.isSwapRefundable(swap) : false;
|
||||
|
||||
// Subscribe to SwapManager status transitions for our swap so the spinner
|
||||
// → success transition is driven by SDK events, not the 3s polling lag.
|
||||
// The SDK mutates `swap.status` in place before invoking listeners, so by
|
||||
// the time we force a render `getSwapById(swapId).status` reflects the
|
||||
// new state and the success/refund branches re-evaluate correctly.
|
||||
useEffect(() => {
|
||||
if (!arkWallet || !swapId) return;
|
||||
return arkWallet.subscribeToSwapEvents(updatedSwap => {
|
||||
if (updatedSwap.id === swapId) forceRender();
|
||||
});
|
||||
}, [arkWallet, swapId]);
|
||||
|
||||
const refreshAfterAction = async () => {
|
||||
if (!arkWallet || !swapId) return;
|
||||
const updatedRow = arkWallet.getTransactions().find(tx => tx.txid === `swap-${swapId}`);
|
||||
if (updatedRow) setParams({ invoice: updatedRow });
|
||||
setInvoiceStatusChanged(true);
|
||||
fetchAndSaveWalletTransactions(walletID);
|
||||
};
|
||||
|
||||
const onRefundPressed = async () => {
|
||||
if (!arkWallet || !swap || isActioning) return;
|
||||
setIsActioning(true);
|
||||
try {
|
||||
const outcome = await arkWallet.refundSwap(swap as BoltzSubmarineSwap);
|
||||
if (outcome.swept === 0) {
|
||||
// Lockup not yet refundable (CLTV not reached / Boltz declined to
|
||||
// co-sign). Surface as info, not an error: the row stays refundable
|
||||
// and the user can retry later.
|
||||
presentAlert({ message: loc.lndViewInvoice.refund_deferred });
|
||||
} else {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
}
|
||||
await refreshAfterAction();
|
||||
} catch (e: any) {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
|
||||
presentAlert({ message: e?.message ?? String(e) });
|
||||
} finally {
|
||||
setIsActioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stylesHook = StyleSheet.create({
|
||||
root: {
|
||||
backgroundColor: colors.background,
|
||||
@ -160,8 +227,9 @@ const LNDViewInvoice = () => {
|
||||
};
|
||||
|
||||
const handleOnSharePressed = () => {
|
||||
if (typeof invoice === 'string' || !invoice.payment_request) return;
|
||||
Share.open({ message: `lightning:${invoice.payment_request}` }).catch(error => console.log(error));
|
||||
const paymentRequest = typeof invoice === 'string' ? invoice : invoice.payment_request;
|
||||
if (!paymentRequest) return;
|
||||
Share.open({ message: `lightning:${paymentRequest}` }).catch(error => console.log(error));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -183,12 +251,42 @@ const LNDViewInvoice = () => {
|
||||
setQRCodeSize(height > width ? width - 40 : e.nativeEvent.layout.width / 1.8);
|
||||
};
|
||||
|
||||
// Drive both the amount and the description straight off the BOLT11 — the
|
||||
// source of truth, and the one thing identical whether the route param is
|
||||
// still the raw string or the polled-in object, so both render phases agree
|
||||
// and nothing changes after the page first paints. Decode is sync + cached.
|
||||
// "Please pay" deliberately shows the invoice-encoded amount (what the payer
|
||||
// is actually charged), not invoice.amt — which getTransactions() resolves to
|
||||
// the post-fee on-chain amount and so differs from the BOLT11 by the swap fee.
|
||||
// Likewise we ignore the row's synthesized description/memo: getTransactions()
|
||||
// backfills a "BlueWallet" label there for memo-less reverse swaps (so the tx
|
||||
// list isn't blank) and that placeholder must never surface here as
|
||||
// "For: BlueWallet". "Send to Arkade address" is the SDK's hardcoded default
|
||||
// for a memo-less reverse swap, so it counts as "no description" too.
|
||||
const decodeForDisplay = (paymentRequest?: string): { amountSats?: number; description?: string } => {
|
||||
if (!paymentRequest) return {};
|
||||
try {
|
||||
const d = wallet?.decodeInvoice(paymentRequest);
|
||||
const description = d?.description && d.description !== 'Send to Arkade address' ? d.description : undefined;
|
||||
return { amountSats: d?.num_satoshis || undefined, description };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
if (typeof invoice === 'object') {
|
||||
const currentDate = new Date();
|
||||
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
|
||||
const invoiceExpiration = invoice?.timestamp && invoice?.expire_time ? invoice.timestamp + invoice.expire_time : undefined;
|
||||
if (invoice.ispaid || invoice.type === 'paid_invoice') {
|
||||
|
||||
// Settlement wins over any claim/refund CTA. The SDK auto-claims
|
||||
// reverse swaps as soon as Boltz funds the VHTLC, so a stale
|
||||
// route-param snapshot (`invoice.ispaid:false`) can race a live
|
||||
// `_swapHistory` already at `invoice.settled`; checking the live
|
||||
// swap status alongside the snapshot prevents Claim from rendering
|
||||
// (and failing) after the SDK has already claimed.
|
||||
if (invoice.ispaid || invoice.type === 'paid_invoice' || (swap && isReverseSuccessStatus(swap.status))) {
|
||||
let amount = 0;
|
||||
let description;
|
||||
let invoiceDate;
|
||||
@ -196,6 +294,10 @@ const LNDViewInvoice = () => {
|
||||
amount = invoice.value;
|
||||
} else if (invoice.type === 'user_invoice' && invoice.amt) {
|
||||
amount = invoice.amt;
|
||||
} else if (invoice.value) {
|
||||
// Settled Arkade swap: an enriched native Ark leg (type 'bitcoind_tx')
|
||||
// has no `amt`; its magnitude lives in the signed `value`.
|
||||
amount = Math.abs(invoice.value);
|
||||
}
|
||||
if (invoice.description) {
|
||||
description = invoice.description;
|
||||
@ -237,6 +339,36 @@ const LNDViewInvoice = () => {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Reverse swap mid-flight: Boltz funded the VHTLC and the SDK is
|
||||
// auto-claiming (SwapManager.executeAutonomousAction → claimVHTLC).
|
||||
// No manual CTA — the SDK owns claim reliability — so we just show
|
||||
// a "Receiving" indicator until the status transitions to
|
||||
// `invoice.settled` and the success branch above catches it.
|
||||
if (claimable) {
|
||||
return (
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<ActivityIndicator size="large" color={colors.foregroundColor} />
|
||||
<BlueSpacing20 />
|
||||
<BlueTextCentered>{loc.lndViewInvoice.receiving_payment}</BlueTextCentered>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (refundable) {
|
||||
return (
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<BlueTextCentered>{invoice.description ?? invoice.memo ?? ''}</BlueTextCentered>
|
||||
<BlueSpacing20 />
|
||||
<Button
|
||||
onPress={onRefundPressed}
|
||||
title={loc.lndViewInvoice.refund_funds}
|
||||
disabled={isActioning}
|
||||
showActivityIndicator={isActioning}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (invoiceExpiration ? invoiceExpiration < now : undefined) {
|
||||
return (
|
||||
<View style={[styles.root, stylesHook.root, styles.justifyContentCenter]}>
|
||||
@ -249,6 +381,8 @@ const LNDViewInvoice = () => {
|
||||
}
|
||||
// Invoice has not expired, nor has it been paid for.
|
||||
if (invoice.payment_request) {
|
||||
const { amountSats: bolt11Amount, description } = decodeForDisplay(invoice.payment_request);
|
||||
const amountSats = bolt11Amount ?? invoice.amt;
|
||||
return (
|
||||
<ScrollView>
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
@ -257,13 +391,13 @@ const LNDViewInvoice = () => {
|
||||
</View>
|
||||
<BlueSpacing20 />
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.please_pay} {invoice.amt} {loc.lndViewInvoice.sats}
|
||||
{loc.lndViewInvoice.please_pay} {amountSats} {loc.lndViewInvoice.sats}
|
||||
</BlueText>
|
||||
{'description' in invoice && (invoice.description?.length ?? 0) > 0 && (
|
||||
{description ? (
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.for} {invoice.description ?? ''}
|
||||
{loc.lndViewInvoice.for} {description}
|
||||
</BlueText>
|
||||
)}
|
||||
) : null}
|
||||
<View style={styles.copyText}>
|
||||
<CopyTextToClipboard truncated text={invoice.payment_request} />
|
||||
</View>
|
||||
@ -273,14 +407,36 @@ const LNDViewInvoice = () => {
|
||||
);
|
||||
}
|
||||
} else if (invoice) {
|
||||
// `invoice` is string, just not decoded yet. lets just display it as a QR code first (till it gets decoded
|
||||
// and more data is rendered)
|
||||
// `invoice` is the raw BOLT11 string — the polling effect hasn't yet swapped
|
||||
// it for the decoded object. Don't make the amount/description wait for that
|
||||
// 3s round-trip: both are encoded in the string and decode synchronously
|
||||
// (offline, cached) via the same decodeForDisplay() the object branch uses,
|
||||
// so we render the full "please pay" block now and it doesn't change when
|
||||
// the object arrives. A malformed string just falls back to QR + copy.
|
||||
const { amountSats, description } = decodeForDisplay(invoice);
|
||||
return (
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<View style={styles.activeQrcode}>
|
||||
<QRCode value={invoice} size={qrCodeSize} />
|
||||
<ScrollView>
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<View style={styles.activeQrcode}>
|
||||
<QRCode value={invoice} size={qrCodeSize} />
|
||||
</View>
|
||||
<BlueSpacing20 />
|
||||
{amountSats ? (
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.please_pay} {amountSats} {loc.lndViewInvoice.sats}
|
||||
</BlueText>
|
||||
) : null}
|
||||
{description ? (
|
||||
<BlueText>
|
||||
{loc.lndViewInvoice.for} {description}
|
||||
</BlueText>
|
||||
) : null}
|
||||
<View style={styles.copyText}>
|
||||
<CopyTextToClipboard truncated text={invoice} />
|
||||
</View>
|
||||
<Button onPress={handleOnSharePressed} title={loc.receive.details_share} />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
} else {
|
||||
// something is not right
|
||||
|
||||
@ -102,8 +102,6 @@ const NotificationSettings: React.FC = () => {
|
||||
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
|
||||
setNotificationsEnabledState(false);
|
||||
}
|
||||
|
||||
setNotificationsEnabledState(await isNotificationsEnabled());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
presentAlert({ message: (error as Error).message });
|
||||
|
||||
@ -8,14 +8,12 @@ import { Linking, StyleSheet, View } from 'react-native';
|
||||
import BlueCrypto from 'react-native-blue-crypto';
|
||||
import wif from 'wif';
|
||||
|
||||
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
||||
import * as encryption from '../../blue_modules/encryption';
|
||||
import * as fs from '../../blue_modules/fs';
|
||||
import ecc from '../../blue_modules/noble_ecc';
|
||||
import { hexToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
|
||||
import BlueText from '../../components/BlueText';
|
||||
import { HDAezeedWallet } from '../../class/wallets/hd-aezeed-wallet';
|
||||
import { HDSegwitBech32Wallet } from '../../class/wallets/hd-segwit-bech32-wallet';
|
||||
import { HDSegwitP2SHWallet } from '../../class/wallets/hd-segwit-p2sh-wallet';
|
||||
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
|
||||
import { SegwitP2SHWallet } from '../../class/wallets/segwit-p2sh-wallet';
|
||||
@ -29,6 +27,7 @@ import { CreateTransactionUtxo } from '../../class/wallets/types';
|
||||
import { BlueSpacing20 } from '../../components/BlueSpacing';
|
||||
import { BlueLoading } from '../../components/BlueLoading';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { stopArkBackgroundTask } from '../../blue_modules/arkade-background';
|
||||
import { SettingsCard, SettingsScrollView } from '../../components/platform';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
@ -93,6 +92,11 @@ export default class SelfTest extends Component {
|
||||
let isOk = true;
|
||||
|
||||
try {
|
||||
// Drain any Ark background-fetch listener before running the self-test.
|
||||
// A live background-fetch timer keeps Detox's FabricTimersIdlingResource
|
||||
// busy and disconnects the JS bridge before SelfTestOk can be observed.
|
||||
await stopArkBackgroundTask();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000)); // propagate ui
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
@ -112,33 +116,25 @@ export default class SelfTest extends Component {
|
||||
//
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
// Offline Ark smoke check: derive identity + namespace from a fixed
|
||||
// mnemonic. No init() / SDK / network — those calls hang Detox on CI.
|
||||
// The full Ark address regression (BIP86 path, DelegateVtxo wiring,
|
||||
// delegatorProvider) is pinned in tests/unit/lightning-ark-derivation.test.ts.
|
||||
const spkw = new LightningArkWallet();
|
||||
spkw.setSecret('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
|
||||
await spkw.init();
|
||||
assertStrictEqual(
|
||||
await spkw.getArkAddress(),
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t59s7u3fgnd3lyjda00ycjq53mgxl6wsxspe4s72t5dss3q6w5clv0xpgal',
|
||||
'Ark failed',
|
||||
);
|
||||
spkw.setSecret('arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
|
||||
const pubkey = await spkw._getIdentity().xOnlyPublicKey();
|
||||
if (!(pubkey instanceof Uint8Array) || pubkey.length !== 32) {
|
||||
throw new Error('Arkade x-only pubkey shape regression: length=' + (pubkey as Uint8Array | undefined)?.length);
|
||||
}
|
||||
const expectedNamespace = 'e13b00f781e8dfc57f8f2a936220ff24d132eaaf8c85d4b10b5337645085ee9a';
|
||||
const namespace = spkw.getNamespace();
|
||||
if (namespace !== expectedNamespace) {
|
||||
throw new Error(`Arkade namespace regression: expected ${expectedNamespace}, got ${namespace}`);
|
||||
}
|
||||
} else {
|
||||
// skipping RN-specific test
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
if (!(await BlueElectrum.ensureConnected())) throw new Error('Could not connect to Electrum');
|
||||
const addr4elect = '3GCvDBAktgQQtsbN6x5DYiQCMmgZ9Yk8BK';
|
||||
const electrumBalance = await BlueElectrum.getBalanceByAddress(addr4elect);
|
||||
if (electrumBalance.confirmed !== 51432)
|
||||
throw new Error('BlueElectrum getBalanceByAddress failure, got ' + JSON.stringify(electrumBalance));
|
||||
|
||||
const electrumTxs = await BlueElectrum.getTransactionsByAddress(addr4elect);
|
||||
if (electrumTxs.length !== 1) throw new Error('BlueElectrum getTransactionsByAddress failure, got ' + JSON.stringify(electrumTxs));
|
||||
} else {
|
||||
// skipping RN-specific test'
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
|
||||
const aezeed = new HDAezeedWallet();
|
||||
aezeed.setSecret(
|
||||
@ -304,15 +300,6 @@ export default class SelfTest extends Component {
|
||||
if (!hd2.validateMnemonic()) {
|
||||
throw new Error('mnemonic phrase validation not ok');
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
const hd4 = new HDSegwitBech32Wallet();
|
||||
hd4._xpub = 'zpub6rnbAtzupLPpSrsBKRsHupFvv1h6pwfRnZxX3qs6RL4LiLqKQ6kfBaDckn2apQWfyw1D2TdQMMDCfUDHMwtrcbGoy88xoKBLmADTFK9AhLe';
|
||||
await hd4.fetchBalance();
|
||||
if (hd4.getBalance() !== 2400) throw new Error('Could not fetch HD Bech32 balance');
|
||||
await hd4.fetchTransactions();
|
||||
if (hd4.getTransactions().length !== 4) throw new Error('Could not fetch HD Bech32 transactions');
|
||||
} else {
|
||||
// skipping RN-specific test
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ import useWalletSubscribe from '../../hooks/useWalletSubscribe';
|
||||
import loc, { formatBalanceWithoutSuffix } from '../../loc';
|
||||
import { BitcoinUnit } from '../../models/bitcoinUnits';
|
||||
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
|
||||
import { isOnChainTransaction, resolveTxDisplayState } from '../../blue_modules/transactionDisplayState';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@ -336,7 +337,10 @@ const TransactionStatus: React.FC = () => {
|
||||
console.debug('transactionDetail - useEffect');
|
||||
|
||||
if (!tx || tx?.confirmations) return;
|
||||
if (!hash) return;
|
||||
// Ark/Lightning rows carry a synthetic id (ark-/swap-/boarding-), not an on-chain
|
||||
// txid. Never poll Electrum for them — the old `if (!hash) return;` let the
|
||||
// synthetic id through and logged "… with hash ark-… not found" every interval.
|
||||
if (!isOnChainTransaction(tx)) return;
|
||||
|
||||
if (fetchTxInterval.current) {
|
||||
clearInterval(fetchTxInterval.current);
|
||||
@ -675,18 +679,20 @@ const TransactionStatus: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleNotePress = useCallback(async () => {
|
||||
const currentMemo = txMetadata[tx.hash]?.memo || '';
|
||||
// Ark rows have no on-chain hash; use their synthetic txid as fallback key.
|
||||
const metadataKey = tx.hash ?? (tx as { txid?: string }).txid;
|
||||
const currentMemo = (metadataKey && txMetadata[metadataKey]?.memo) || '';
|
||||
try {
|
||||
const newMemo = await prompt(loc.send.details_note_placeholder, '', { type: 'plain-text', defaultValue: currentMemo });
|
||||
if (newMemo !== undefined) {
|
||||
txMetadata[tx.hash] = { memo: newMemo };
|
||||
if (newMemo !== undefined && metadataKey) {
|
||||
txMetadata[metadataKey] = { memo: newMemo };
|
||||
await saveToDisk();
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
}
|
||||
} catch (error) {
|
||||
// User cancelled
|
||||
}
|
||||
}, [tx?.hash, txMetadata, saveToDisk]);
|
||||
}, [tx, txMetadata, saveToDisk]);
|
||||
|
||||
const handleOpenBlockExplorer = useCallback(() => {
|
||||
if (!tx?.hash || !selectedBlockExplorer) return;
|
||||
@ -828,7 +834,8 @@ const TransactionStatus: React.FC = () => {
|
||||
const parsedTxValue = Number(tx?.value);
|
||||
const txValue = Number.isFinite(parsedTxValue) ? parsedTxValue : null;
|
||||
const parsedConfirmations = Number(tx?.confirmations);
|
||||
const isPending = Number.isFinite(parsedConfirmations) ? parsedConfirmations <= 0 : !tx?.confirmations;
|
||||
const isOnChainTx = isOnChainTransaction(tx);
|
||||
const isPending = resolveTxDisplayState(tx) === 'pending';
|
||||
const preferredBalanceUnit = wallet?.preferredBalanceUnit ?? BitcoinUnit.BTC;
|
||||
|
||||
// Get transaction direction and date
|
||||
@ -1023,11 +1030,13 @@ const TransactionStatus: React.FC = () => {
|
||||
<TransactionOutgoingIcon />
|
||||
<View style={styles.stateLabelContainer}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
{isOnChainTx && (
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
@ -1035,11 +1044,13 @@ const TransactionStatus: React.FC = () => {
|
||||
<TransactionIncomingIcon />
|
||||
<View style={styles.stateLabelContainer}>
|
||||
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
{isOnChainTx && (
|
||||
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
|
||||
{loc.formatString(loc.transactions.confirmations_lowercase, {
|
||||
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
|
||||
})}
|
||||
</BlueText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@ -49,7 +49,7 @@ function getCoinControlStats(w: TWallet): { hasCoinControl: boolean; utxoCount:
|
||||
}
|
||||
|
||||
const WalletDetails: React.FC = () => {
|
||||
const { saveToDisk, wallets, txMetadata, handleWalletDeletion, sleep } = useStorage();
|
||||
const { saveToDisk, wallets, txMetadata, handleWalletDeletion, fetchAndSaveWalletTransactions, sleep } = useStorage();
|
||||
const { isBiometricUseCapableAndEnabled } = useBiometrics();
|
||||
const { walletID } = useRoute<RouteProps>().params;
|
||||
const { direction } = useLocale();
|
||||
@ -140,6 +140,21 @@ const WalletDetails: React.FC = () => {
|
||||
fetchArkAddress();
|
||||
}, [wallet]);
|
||||
|
||||
const [isRestoringSwaps, setIsRestoringSwaps] = useState<boolean>(false);
|
||||
const onRestoreSwapsPressed = useCallback(async () => {
|
||||
if (wallet.type !== LightningArkWallet.type || !(wallet as unknown as LightningArkWallet).restoreSwaps) return;
|
||||
setIsRestoringSwaps(true);
|
||||
try {
|
||||
await (wallet as unknown as LightningArkWallet).restoreSwaps();
|
||||
await fetchAndSaveWalletTransactions(wallet.getID());
|
||||
presentAlert({ message: loc.wallets.restore_swap_activity_done });
|
||||
} catch (e: any) {
|
||||
presentAlert({ message: e?.message ?? String(e) });
|
||||
} finally {
|
||||
setIsRestoringSwaps(false);
|
||||
}
|
||||
}, [wallet, fetchAndSaveWalletTransactions]);
|
||||
|
||||
const navigateToOverviewAndDeleteWallet = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
const deletionSucceeded = await handleWalletDeletion(wallet.getID());
|
||||
@ -628,7 +643,7 @@ const WalletDetails: React.FC = () => {
|
||||
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
|
||||
<View style={stylesHook.optionsContent}>
|
||||
<Text style={[styles.textLabel2, stylesHook.textLabel2, styles.optionsSubheader]}>
|
||||
{`Ark ${loc.wallets.details_address}`}
|
||||
{`Arkade ${loc.wallets.details_address}`}
|
||||
</Text>
|
||||
<CopyTextToClipboard
|
||||
text={arkAddress}
|
||||
@ -900,6 +915,18 @@ const WalletDetails: React.FC = () => {
|
||||
backgroundColor={colors.redBG}
|
||||
textColor={colors.redText}
|
||||
/>
|
||||
{wallet.type === LightningArkWallet.type && (
|
||||
<>
|
||||
<BlueSpacing20 />
|
||||
<SecondButton
|
||||
onPress={onRestoreSwapsPressed}
|
||||
testID="RestoreSwapActivity"
|
||||
title={loc.wallets.restore_swap_activity}
|
||||
disabled={isRestoringSwaps}
|
||||
loading={isRestoringSwaps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</BlueCard>
|
||||
</>
|
||||
|
||||
@ -349,9 +349,22 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
|
||||
// destructured `item: Transaction` annotation as a propTypes definition and
|
||||
// ignores that the value is consumed on the next line.
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
({ item }: { item: Transaction }) => (
|
||||
<TransactionListItem key={item.hash} item={item} itemPriceUnit={displayUnit} walletID={walletID} />
|
||||
// Ark wallet rows lack on-chain `hash` and instead carry a synthetic
|
||||
// `txid` (`swap-…`, `ark-…`, `boarding-…`, `boarding-utxo-…`). Falling
|
||||
// back to `txid` prevents multiple Ark rows from sharing
|
||||
// `key={undefined}`, which made React reuse stale memoized renders
|
||||
// across rows.
|
||||
<TransactionListItem
|
||||
key={item.hash ?? (item as { txid?: string }).txid}
|
||||
item={item}
|
||||
itemPriceUnit={displayUnit}
|
||||
walletID={walletID}
|
||||
/>
|
||||
),
|
||||
[displayUnit, walletID],
|
||||
);
|
||||
@ -370,7 +383,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
});
|
||||
};
|
||||
|
||||
const _keyExtractor = useCallback((_item: any, index: number) => index.toString(), []);
|
||||
const _keyExtractor = useCallback((item: Transaction, index: number) => item.hash || item.txid || index.toString(), []);
|
||||
|
||||
const pasteFromClipboard = async () => {
|
||||
onBarCodeRead({ data: await getClipboardContent() });
|
||||
@ -522,7 +535,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
|
||||
return () => clearTimeout(timer);
|
||||
}, [walletID, measureHeaderHeight]);
|
||||
|
||||
const ListHeaderComponent = useCallback(
|
||||
const ListHeaderComponent = useMemo(
|
||||
() => (
|
||||
<View ref={headerRef} onLayout={measureHeaderHeight}>
|
||||
<TransactionsNavigationHeader
|
||||
|
||||
@ -290,7 +290,14 @@ const WalletsList: React.FC = () => {
|
||||
|
||||
const renderTransactionListsRow = useCallback(
|
||||
(item: ExtendedTransaction) => (
|
||||
<TransactionListItem key={item.hash} item={item} itemPriceUnit={item.walletPreferredBalanceUnit} walletID={item.walletID} />
|
||||
// Ark wallet rows have no on-chain `hash` — fall back to their
|
||||
// synthetic `txid` so each row gets a unique React key.
|
||||
<TransactionListItem
|
||||
key={item.hash ?? (item as { txid?: string }).txid}
|
||||
item={item}
|
||||
itemPriceUnit={item.walletPreferredBalanceUnit}
|
||||
walletID={item.walletID}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
@ -463,7 +470,8 @@ const WalletsList: React.FC = () => {
|
||||
}, [onScanButtonPressed, scanImage, sendButtonLongPress, wallets.length]);
|
||||
|
||||
const sectionListKeyExtractor = useCallback((item: any, index: any) => {
|
||||
return `${item}${index}`;
|
||||
if (typeof item === 'string') return item;
|
||||
return item?.hash || item?.txid || `${item}${index}`;
|
||||
}, []);
|
||||
|
||||
const refreshProps = isDesktop || isElectrumDisabled ? {} : { refreshing: isLoading, onRefresh };
|
||||
|
||||
@ -50,6 +50,11 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
.whileElement(by.id('SettingsRoot'))
|
||||
.scroll(500, 'down');
|
||||
await element(by.id('AboutButton')).tap();
|
||||
// Ensure About has mounted before scrolling — race seen on cold launches
|
||||
// where the scroll fires before the FlatList is in the view hierarchy.
|
||||
await waitFor(element(by.id('AboutScrollView')))
|
||||
.toBeVisible()
|
||||
.withTimeout(15_000);
|
||||
await waitFor(element(by.id('RunSelfTestButton')))
|
||||
.toBeVisible()
|
||||
.whileElement(by.id('AboutScrollView'))
|
||||
@ -57,10 +62,18 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
await tapAndTapAgainIfElementIsNotVisible('RunSelfTestButton', 'SelfTestLoading');
|
||||
await element(by.id('SelfTestLoading')).tap(); // tapping START button
|
||||
|
||||
// Wait for the self-test to complete
|
||||
await waitFor(element(by.id('SelfTestOk')))
|
||||
.toBeVisible()
|
||||
.withTimeout(300 * 1000);
|
||||
// SelfTest runs CPU-heavy crypto loops for 100+ seconds. Detox's
|
||||
// FabricTimersIdlingResource never goes idle during that, so a synchronized
|
||||
// waitFor would throw IdlingResourceTimeoutException long before
|
||||
// SelfTestOk renders. Disable synchronization just for the wait.
|
||||
await device.disableSynchronization();
|
||||
try {
|
||||
await waitFor(element(by.id('SelfTestOk')))
|
||||
.toBeVisible()
|
||||
.withTimeout(300 * 1000);
|
||||
} finally {
|
||||
await device.enableSynchronization();
|
||||
}
|
||||
await goBack();
|
||||
await goBack();
|
||||
await goBack();
|
||||
@ -506,9 +519,14 @@ describe('BlueWallet UI Tests - no wallets', () => {
|
||||
if (device.getPlatform() === 'ios') {
|
||||
// FIXME: WAllets does not exists on android
|
||||
await waitForId('Wallets');
|
||||
await scrollUpOnHomeScreen();
|
||||
}
|
||||
await sleep(1000); // propagate
|
||||
// Match t4's flow: scroll up so the next helperCreateWallet's
|
||||
// whileElement(WalletsList).scroll('right') starts from a known
|
||||
// position. Without this, Android lands the user on a list state
|
||||
// where CreateAWallet is not visible after scroll-right and the
|
||||
// 6s tapAndTapAgainIfElementIsNotVisible budget runs out.
|
||||
await scrollUpOnHomeScreen();
|
||||
// created fake storage.
|
||||
// creating a wallet inside this fake storage
|
||||
await helperCreateWallet('fake_wallet');
|
||||
|
||||
@ -50,6 +50,11 @@ describe('BlueWallet UI Tests - import Watch-only wallet (zpub)', () => {
|
||||
await sleep(1000);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
// in case notification popup appeared early and is blocking taps
|
||||
await element(by.text(`No, and do not ask me again.`)).tap();
|
||||
} catch (_) {}
|
||||
|
||||
await element(by.id('ReceiveButton')).tap();
|
||||
await expect(element(by.id('BitcoinAddressQRCode'))).toBeVisible();
|
||||
await expect(element(by.label('bc1qgrhr5xc5774maph97d73ydrjlqqmg2v6jjlr29'))).toBeVisible();
|
||||
@ -82,6 +87,12 @@ describe('BlueWallet UI Tests - import Watch-only wallet (zpub)', () => {
|
||||
|
||||
// now lets test scanning back QR with UR PSBT. this should lead straight to broadcast dialog
|
||||
|
||||
// Same race as the t1 AboutScrollView fix in bluewallet.spec.js: the
|
||||
// PSBT-with-hardware screen has not always mounted by the time
|
||||
// whileElement(...).scroll() runs.
|
||||
await waitFor(element(by.id('PsbtWithHardwareScrollView')))
|
||||
.toBeVisible()
|
||||
.withTimeout(15_000);
|
||||
await waitFor(element(by.id('PsbtTxScanButton')))
|
||||
.toBeVisible()
|
||||
.whileElement(by.id('PsbtWithHardwareScrollView'))
|
||||
|
||||
@ -163,7 +163,14 @@ export function hashIt(s) {
|
||||
}
|
||||
|
||||
export async function helperDeleteWallet(label, remainingBalanceSat = false) {
|
||||
// Tapping the wallet card by visible text (`by.text(label)`) is what
|
||||
// bluewallet3's import-then-delete flow uses successfully. On a wallet
|
||||
// that has been opened before, this navigates to WalletTransactions
|
||||
// immediately. On a freshly-created wallet (t10) the carousel
|
||||
// Pressable's first onPress is swallowed before navigation fires —
|
||||
// that case is a known limitation of the e2e harness.
|
||||
await element(by.text(label)).tap();
|
||||
await waitForId('WalletDetails');
|
||||
await element(by.id('WalletDetails')).tap();
|
||||
await element(by.id('WalletDetailsScroll')).swipe('up', 'fast', 1);
|
||||
await sleep(200);
|
||||
|
||||
@ -6,6 +6,7 @@ module.exports = {
|
||||
globalSetup: 'detox/runners/jest/globalSetup',
|
||||
globalTeardown: 'detox/runners/jest/globalTeardown',
|
||||
testEnvironment: 'detox/runners/jest/testEnvironment',
|
||||
setupFilesAfterEnv: ['<rootDir>/e2e/setup.js'],
|
||||
rootDir: '..',
|
||||
testMatch: ['<rootDir>/e2e/**/*.spec.js'],
|
||||
transform: {
|
||||
|
||||
34
tests/e2e/setup.js
Normal file
34
tests/e2e/setup.js
Normal file
@ -0,0 +1,34 @@
|
||||
/* eslint-env jest */
|
||||
/* global device */
|
||||
|
||||
// Detox's iOS network synchronization waits on all in-flight NSURLSession
|
||||
// requests before considering the app idle. The Arkade SDK's indexer opens
|
||||
// a long-lived SSE-style stream (`expo/fetch` →
|
||||
// /v1/indexer/script/subscription/<id>) that never completes during the
|
||||
// test's lifetime, so every action would time out waiting for idle.
|
||||
//
|
||||
// Tell Detox to ignore that endpoint. The blacklist is process-scoped on
|
||||
// iOS, so we re-apply it after every launchApp.
|
||||
const URL_BLACKLIST = ['.*arkade\\.computer/v1/indexer/script/subscription.*', '.*groundcontrol-bluewallet\\.herokuapp\\.com.*'];
|
||||
|
||||
beforeAll(async () => {
|
||||
if (typeof device === 'undefined' || !device?.launchApp) return;
|
||||
|
||||
const originalLaunchApp = device.launchApp.bind(device);
|
||||
device.launchApp = async (...args) => {
|
||||
const result = await originalLaunchApp(...args);
|
||||
try {
|
||||
await device.setURLBlacklist(URL_BLACKLIST);
|
||||
} catch (e) {
|
||||
console.log('[detox-setup] setURLBlacklist after launchApp failed:', e?.message ?? e);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Detox auto-launches the app before the first beforeAll; cover that launch too.
|
||||
try {
|
||||
await device.setURLBlacklist(URL_BLACKLIST);
|
||||
} catch (e) {
|
||||
console.log('[detox-setup] initial setURLBlacklist failed:', e?.message ?? e);
|
||||
}
|
||||
});
|
||||
83
tests/helpers/arkadeMocks.ts
Normal file
83
tests/helpers/arkadeMocks.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Reusable test helpers around the Arkade Realm + Keychain Jest mocks installed
|
||||
* in tests/setup.js. The mock factories themselves must live in setup.js
|
||||
* because Jest hoists `jest.mock()` above module scope and refuses out-of-scope
|
||||
* captures, but each individual test still needs to reset the mock state and
|
||||
* inspect or seed it. Centralising those calls here avoids per-test
|
||||
* boilerplate and keeps the shape stable as the harness grows.
|
||||
*
|
||||
* Three sets of adjacent module-private caches need resetting between tests:
|
||||
* - The Realm adapter's `realmInstances` / `openInFlight` (closed via
|
||||
* closeAllArkadeRealms + the __testing__ accessor).
|
||||
* - The wallet module's `staticWalletCache`, `staticSwapsCache`,
|
||||
* `initInFlight`, `boardingLock` (exposed via wallet `__testing__`).
|
||||
* - The mock backing stores in setup.js (Realm files-on-disk, Keychain
|
||||
* credential map, FS existence set).
|
||||
* Without all three, a test that opens a Realm leaks a closed instance into
|
||||
* the next test, which then sees a ghost cached entry that fails the
|
||||
* `isClosed` short-circuit asynchronously.
|
||||
*/
|
||||
|
||||
import { closeAllArkadeRealms, __testing__ as realmTesting } from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
import { __testing__ as walletTesting } from '../../class/wallets/lightning-ark-wallet';
|
||||
|
||||
const Realm = require('realm');
|
||||
|
||||
const Keychain = require('react-native-keychain');
|
||||
|
||||
const RNFS = require('react-native-fs');
|
||||
|
||||
/**
|
||||
* Reset every piece of mock state the Arkade test harness depends on:
|
||||
* - mocked Realm files-on-disk + open instances
|
||||
* - mocked Keychain credential store
|
||||
* - mocked react-native-fs existence set
|
||||
* - LightningArkWallet's process-wide caches and in-flight init promises
|
||||
*
|
||||
* Call from `beforeEach`.
|
||||
*/
|
||||
export function resetArkadeTestState(): void {
|
||||
// Drop adapter-level Realm instance refs first. closeAllArkadeRealms walks
|
||||
// realmInstances and closes each, which would no-op against the mock but
|
||||
// also removes them from the map. openInFlight isn't touched by the close
|
||||
// helpers (it self-clears in the success/error path) so we clear it
|
||||
// explicitly to drop any test-leaked promise.
|
||||
closeAllArkadeRealms();
|
||||
realmTesting.openInFlight.clear();
|
||||
|
||||
Realm.__mockRealmHelpers.reset();
|
||||
Keychain.__mockKeychainHelpers.reset();
|
||||
RNFS.__mockFsHelpers.reset();
|
||||
|
||||
for (const k of Object.keys(walletTesting.staticWalletCache)) delete walletTesting.staticWalletCache[k];
|
||||
for (const k of Object.keys(walletTesting.staticSwapsCache)) delete walletTesting.staticSwapsCache[k];
|
||||
walletTesting.initInFlight.clear();
|
||||
walletTesting.restoreInFlight.clear();
|
||||
for (const k of Object.keys(walletTesting.boardingLock)) delete walletTesting.boardingLock[k];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all `mock.calls` history on the spies the Ark tests inspect.
|
||||
* Useful when a test wants to assert call counts after a setup phase that
|
||||
* also exercised the mocks.
|
||||
*/
|
||||
export function clearArkadeMockCallHistory(): void {
|
||||
Realm.open.mockClear();
|
||||
Realm.exists.mockClear();
|
||||
Realm.deleteFile.mockClear();
|
||||
Keychain.setGenericPassword.mockClear();
|
||||
Keychain.getGenericPassword.mockClear();
|
||||
Keychain.resetGenericPassword.mockClear();
|
||||
Keychain.getSecurityLevel.mockClear();
|
||||
}
|
||||
|
||||
/** Direct accessors for tests that need to inspect/seed mock state. */
|
||||
export const arkadeMockState = {
|
||||
realmFiles: () => Realm.__mockRealmHelpers.files as Set<string>,
|
||||
realmInstances: () => Realm.__mockRealmHelpers.store as Map<string, unknown>,
|
||||
keychainStore: () => Keychain.__mockKeychainHelpers.store as Map<string, { username: string; password: string; service: string }>,
|
||||
/** Seed a Keychain entry directly, e.g. to simulate a leaked-from-previous-run state. */
|
||||
seedKeychain(service: string, password: string): void {
|
||||
Keychain.__mockKeychainHelpers.store.set(service, { username: service, password, service });
|
||||
},
|
||||
};
|
||||
112
tests/helpers/sdkProviderMocks.ts
Normal file
112
tests/helpers/sdkProviderMocks.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Spy installers for the Arkade SDK / Boltz provider classes that
|
||||
* `LightningArkWallet.init()` reaches over the network for.
|
||||
*
|
||||
* Tests call `installSdkProviderSpies()` in `beforeEach` and
|
||||
* `restoreSdkProviderSpies()` in `afterEach`. The spies stub the methods
|
||||
* `Wallet.create` calls during init (`getInfo`, `getDelegateInfo`) plus the
|
||||
* Boltz fee/limit lookups invoked by `_fetchLightningFeesAndLimits`. With
|
||||
* these in place `init()` runs offline and the wallet's address derivation
|
||||
* is fully deterministic.
|
||||
*
|
||||
* We spy on `RestArkProvider.prototype` rather than `ExpoArkProvider.prototype`
|
||||
* because Expo* extend Rest* — installing the stub on the parent prototype
|
||||
* covers both.
|
||||
*/
|
||||
|
||||
import { ContractManager, RestArkProvider, RestDelegatorProvider, VtxoManager } from '@arkade-os/sdk';
|
||||
import { BoltzSwapProvider, SwapManager } from '@arkade-os/boltz-swap';
|
||||
|
||||
/** Snapshot of `https://arkade.computer/v1/info` for offline tests. */
|
||||
export const FAKE_ASP_INFO = {
|
||||
signerPubkey: '022b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aaba',
|
||||
forfeitPubkey: '03b43a8363118c084a04d4f6a50ebfa58e81957f8cceceb2aee0ab64c9fd2d9977',
|
||||
forfeitAddress: 'bc1qzzdzp5c443vsetzatf2ra6hku322y7e5aq50rs',
|
||||
checkpointTapscript: '039e0440b27520b43a8363118c084a04d4f6a50ebfa58e81957f8cceceb2aee0ab64c9fd2d9977ac',
|
||||
network: 'bitcoin' as const,
|
||||
sessionDuration: 60,
|
||||
unilateralExitDelay: 605184,
|
||||
boardingExitDelay: 7776256,
|
||||
utxoMinAmount: 330,
|
||||
utxoMaxAmount: -1,
|
||||
vtxoMinAmount: 1,
|
||||
vtxoMaxAmount: -1,
|
||||
dust: 330,
|
||||
fees: {
|
||||
intentFee: { offchainInput: '', offchainOutput: '', onchainInput: '', onchainOutput: '200.0' },
|
||||
txFeeRate: 0,
|
||||
},
|
||||
scheduledSession: null,
|
||||
deprecatedSigners: [],
|
||||
serviceStatus: {},
|
||||
digest: 'test-digest',
|
||||
maxTxWeight: 40000,
|
||||
maxOpReturnOutputs: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Test-only delegate pubkey. Does not need to match the production delegator
|
||||
* — the derivation test pins the wallet's algorithm, not the production
|
||||
* service. The value is the secp256k1 generator G in 33-byte compressed form
|
||||
* (private key = 1) so it is always on-curve and the SDK's taproot validation
|
||||
* accepts it.
|
||||
*/
|
||||
export const FAKE_DELEGATE_PUBKEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
|
||||
|
||||
export const FAKE_DELEGATE_INFO = {
|
||||
pubkey: FAKE_DELEGATE_PUBKEY,
|
||||
fee: '0',
|
||||
delegatorAddress: 'bc1qzzdzp5c443vsetzatf2ra6hku322y7e5aq50rs',
|
||||
};
|
||||
|
||||
export const FAKE_BOLTZ_FEES = {
|
||||
reverse: { percentage: 0.5, minerFees: 0 },
|
||||
submarine: { percentage: 0.1, minerFees: 0 },
|
||||
};
|
||||
|
||||
export const FAKE_BOLTZ_LIMITS = {
|
||||
min: 1000,
|
||||
max: 1_000_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Install Jest spies on the SDK provider prototypes so init() runs offline.
|
||||
* Returns nothing; cleanup happens via `restoreSdkProviderSpies()`.
|
||||
*/
|
||||
export function installSdkProviderSpies(): void {
|
||||
jest.spyOn(RestArkProvider.prototype, 'getInfo').mockResolvedValue(FAKE_ASP_INFO as any);
|
||||
jest.spyOn(RestDelegatorProvider.prototype, 'getDelegateInfo').mockResolvedValue(FAKE_DELEGATE_INFO as any);
|
||||
jest.spyOn(BoltzSwapProvider.prototype, 'getFees').mockResolvedValue(FAKE_BOLTZ_FEES as any);
|
||||
jest.spyOn(BoltzSwapProvider.prototype, 'getLimits').mockResolvedValue(FAKE_BOLTZ_LIMITS as any);
|
||||
|
||||
// VtxoManager auto-runs `initializeSubscription()` from its constructor,
|
||||
// which schedules a setTimeout polling loop AND awaits getContractManager
|
||||
// (which opens a ContractWatcher SSE subscription via subscribeForScripts).
|
||||
// Neither shuts down without a `dispose()` call, so a Jest worker that
|
||||
// runs Wallet.create through to completion hangs after the test asserts.
|
||||
// Stub the entry point to a resolved no-op; the wallet's address-derivation
|
||||
// path doesn't need either side effect.
|
||||
jest.spyOn(VtxoManager.prototype as any, 'initializeSubscription').mockResolvedValue(undefined);
|
||||
|
||||
// ArkadeSwaps auto-starts SwapManager in its constructor (autoStart defaults
|
||||
// to true). SwapManager.start() calls tryConnectWebSocket(), which opens a
|
||||
// real OS WebSocket. On failure it enters startPollingFallback(), a recursive
|
||||
// setTimeout loop that keeps the Node.js event loop alive indefinitely and
|
||||
// prevents Jest from exiting after the test completes.
|
||||
jest.spyOn(SwapManager.prototype as any, 'start').mockResolvedValue(undefined);
|
||||
|
||||
// Any code path that calls `wallet.getContractManager()` lazily constructs
|
||||
// a ContractManager whose `initialize()` opens a ContractWatcher SSE stream
|
||||
// (via `indexerProvider.getSubscription`) and runs a delta sync against the
|
||||
// indexer. Both leave handles or pending fetches that block Jest from
|
||||
// exiting. Unit tests that only exercise address derivation never trigger
|
||||
// this, but tests that call `fetchBalance` / `fetchTransactions` /
|
||||
// `getTransactionHistory` after init do. Stub initialize to a no-op so the
|
||||
// manager exists with no watched contracts — the manager-querying methods
|
||||
// then return empty results without touching the network.
|
||||
jest.spyOn(ContractManager.prototype as any, 'initialize').mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
export function restoreSdkProviderSpies(): void {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
@ -100,7 +100,7 @@ describe('BlueElectrum', () => {
|
||||
assert.ok(!(await BlueElectrum.testConnection('joyreactor.cc', 80, false)));
|
||||
assert.ok(!(await BlueElectrum.testConnection('joyreactor.cc', false, 80)));
|
||||
|
||||
assert.ok(await BlueElectrum.testConnection('electrum1.bluewallet.io', '50001'));
|
||||
assert.ok(await BlueElectrum.testConnection('mainnet.foundationdevices.com', false, 50002));
|
||||
assert.ok(await BlueElectrum.testConnection('electrum1.bluewallet.io', false, 443));
|
||||
});
|
||||
|
||||
|
||||
@ -12,9 +12,6 @@ const hardcodedPeers = [
|
||||
{ host: 'electrum1.bluewallet.io', ssl: '443' },
|
||||
{ host: 'electrum2.bluewallet.io', ssl: '443' },
|
||||
{ host: 'electrum3.bluewallet.io', ssl: '443' },
|
||||
{ host: 'electrum1.bluewallet.io', tcp: '50001' },
|
||||
{ host: 'electrum2.bluewallet.io', tcp: '50001' },
|
||||
{ host: 'electrum3.bluewallet.io', tcp: '50001' },
|
||||
];
|
||||
|
||||
function bitcoinjs_crypto_sha256(buffer /*: Buffer */) /*: Buffer */ {
|
||||
|
||||
@ -1 +0,0 @@
|
||||
[]
|
||||
@ -1 +0,0 @@
|
||||
[{"id":"H8b2stB9ASah","type":"reverse","createdAt":1761224952,"preimage":"7244f7e956a91171038ea935d56cdb758cc36c345f0aa92764bfed6fe6fc9b17","request":{"invoiceAmount":10000,"claimPublicKey":"024a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35","preimageHash":"1dc954a20f4a551ec33832ccb289988f433784f7f24d093db4b7a513154275fe","description":"test invoice"},"response":{"id":"H8b2stB9ASah","lockupAddress":"ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4sux5cq6zdxfmlzkm06epkuqetmaspl4zleptjm4xskhf6nxsu5jmrufq3","refundPublicKey":"03bb59af95b370f0a6133d92ee543e64b7763b301c22e47cdb8dbb76baa998581d","timeoutBlockHeights":{"refund":1761828489,"unilateralClaim":9728,"unilateralRefund":606208,"unilateralRefundWithoutReceiver":606208},"invoice":"lnbc100u1p50528cpp5rhy4fgs0ff23asecxtxt9zvc3apn0p8h7fxsj0d5k7j3x92zwhlqdq5w3jhxapqd9h8vmmfvdjscqrp80xqyf8ucsp5vcsrzye432n9wh0zwuv5z8y5n9zvkwpctr685e80utzc2yueccms9qxpqysgqd87swq3hput9k6llp0wxg098hc7ge3e5nrtnvak6zreywzaf4k9s8d3u4hrmt3m22kf0jt7ruqj0caknk5ykzdenjdphz50t7xrstnqqn6aw0m","onchainAmount":9999},"status":"invoice.settled"}]
|
||||
@ -1 +0,0 @@
|
||||
[{"id":"6c2o1R1KBX3Y","type":"submarine","createdAt":1761225645,"request":{"invoice":"lnbc80u1p5052hwpp5z4ln6hyq4wcck809pt7f0q54ag5he6ce797flm7gl9vuccm9lx2sdqqcqzysxqyz5vqsp5nh9fl4g36606tvxswtnfxzy55yze2656cw2fya7dhl8r6u0czyds9qxpqysgq83sw25g9d9ltr05nkfzejnvvunzkrk4qeuxhszuvvsguk5m6vmg3a7n5nd67l9frru3kjzpt8x6jfusjyc7ezh49jeeh900kt3v30qsqzq7fst","refundPublicKey":"024a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35"},"response":{"acceptZeroConf":true,"expectedAmount":8001,"id":"6c2o1R1KBX3Y","address":"ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4sedhdvfcgaky2qk2p55wj4ut38v9tnpuvjr8ee8hv6htp23pzjpwx5esw","claimPublicKey":"03bb59af95b370f0a6133d92ee543e64b7763b301c22e47cdb8dbb76baa998581d","timeoutBlockHeights":{"refund":1762427602,"unilateralClaim":9728,"unilateralRefund":1205248,"unilateralRefundWithoutReceiver":1205248}},"status":"transaction.claimed","preimage":"182fb8f273bda01b22c0e91991e093e18b2970f389fc7f7a2121870324eb2de5"}]
|
||||
@ -1 +0,0 @@
|
||||
[]
|
||||
@ -1 +0,0 @@
|
||||
[{"txid":"3bc61895d41759789465730c4e26e260950bb77b5f3a06870547418a0340444e","vout":0,"value":9999,"status":{"confirmed":false},"virtualStatus":{"state":"preconfirmed","commitmentTxIds":["3a74555034c7f3c8053d0b30441178630dd98f645d9ed42aa9425fdc2279e159"],"batchExpiry":1763227538000},"spentBy":"","settledBy":"","arkTxId":"","createdAt":"2025-10-23T13:12:57.000Z","isUnrolled":false,"isSpent":false,"forfeitTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0add97e531438e619e16a16c17f3389d06568c45f6030bcd28b991ea57c53bb9a","s":"204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaacc0"},"intentTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0ae1f11d2405ccce6de489e4a350add214e8bf14f3e139aa52756615402933e72","s":"039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35acc0"},"tapTree":"01c044204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaac01c028039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ac"},{"txid":"3343712495c9745c2344ea4c7311bd3d697463e26c86c9fa2799b7e6dfc3a0d9","vout":1,"value":1998,"status":{"confirmed":false},"virtualStatus":{"state":"preconfirmed","commitmentTxIds":["3a74555034c7f3c8053d0b30441178630dd98f645d9ed42aa9425fdc2279e159"],"batchExpiry":1763227538000},"spentBy":"","settledBy":"","arkTxId":"","createdAt":"2025-10-23T13:20:46.000Z","isUnrolled":false,"isSpent":false,"forfeitTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0add97e531438e619e16a16c17f3389d06568c45f6030bcd28b991ea57c53bb9a","s":"204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaacc0"},"intentTapLeafScript":{"cb":"c150929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0ae1f11d2405ccce6de489e4a350add214e8bf14f3e139aa52756615402933e72","s":"039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35acc0"},"tapTree":"01c044204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ad202b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aabaac01c028039e0440b275204a181382f8fa50a736a72b3f63ad3054e288cd5af2fc9a363304e4fb1a356c35ac"}]
|
||||
@ -12,6 +12,7 @@ import { HDSegwitP2SHWallet } from '../../class/wallets/hd-segwit-p2sh-wallet';
|
||||
import { HDTaprootWallet } from '../../class/wallets/hd-taproot-wallet';
|
||||
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { installSdkProviderSpies, restoreSdkProviderSpies } from '../helpers/sdkProviderMocks';
|
||||
import { SegwitBech32Wallet } from '../../class/wallets/segwit-bech32-wallet';
|
||||
import { SegwitP2SHWallet } from '../../class/wallets/segwit-p2sh-wallet';
|
||||
import { SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from '../../class/wallets/slip39-wallets';
|
||||
@ -695,23 +696,33 @@ describe('import procedure', () => {
|
||||
// not checking other 2 wallets
|
||||
});
|
||||
|
||||
it('can import lightning ark wallet', async () => {
|
||||
const store = createStore();
|
||||
const { promise } = startImport(
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
...store.callbacks,
|
||||
);
|
||||
await promise;
|
||||
// Stub the Arkade SDK network providers so the import-time `ark.init()` does
|
||||
// not open real SSE/WebSocket subscriptions. Without these, Wallet.create
|
||||
// brings up VtxoManager + SwapManager — both keep the Node event loop alive
|
||||
// and force jest to hang at the end of the run. See `tests/helpers/
|
||||
// sdkProviderMocks.ts` for the rationale.
|
||||
describe('lightning ark', () => {
|
||||
beforeEach(() => installSdkProviderSpies());
|
||||
afterEach(() => restoreSdkProviderSpies());
|
||||
|
||||
assert.strictEqual(store.state.wallets.length, 1);
|
||||
assert.strictEqual(store.state.wallets[0].type, LightningArkWallet.type);
|
||||
assert.strictEqual(
|
||||
store.state.wallets[0].getSecret(),
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
);
|
||||
it('can import lightning ark wallet', async () => {
|
||||
const store = createStore();
|
||||
const { promise } = startImport(
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
...store.callbacks,
|
||||
);
|
||||
await promise;
|
||||
|
||||
assert.strictEqual(store.state.wallets.length, 1);
|
||||
assert.strictEqual(store.state.wallets[0].type, LightningArkWallet.type);
|
||||
assert.strictEqual(
|
||||
store.state.wallets[0].getSecret(),
|
||||
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('can import private key in hex format', async () => {
|
||||
|
||||
@ -1,108 +1,14 @@
|
||||
import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { HDSegwitBech32Wallet } from '../../class/wallets/hd-segwit-bech32-wallet';
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet.ts';
|
||||
|
||||
// Mock AsyncStorage using fs in tests/integration/fixtures/ark/
|
||||
jest.mock('@react-native-async-storage/async-storage', () => {
|
||||
const STORAGE_DIR = path.join(__dirname, 'fixtures', 'ark');
|
||||
|
||||
// Ensure storage directory exists
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const getFilePath = (key: string) => {
|
||||
const sanitizedKey = key.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
return path.join(STORAGE_DIR, sanitizedKey);
|
||||
};
|
||||
|
||||
async function _multiSet(keyValuePairs: [string, string][], callback?: any) {
|
||||
keyValuePairs.forEach(keyValue => {
|
||||
const key = keyValue[0];
|
||||
const value = keyValue[1];
|
||||
const filePath = getFilePath(key);
|
||||
fs.writeFileSync(filePath, value, 'utf8');
|
||||
});
|
||||
callback && callback(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _multiGet(keys: string[], callback?: any) {
|
||||
const values = keys.map(key => {
|
||||
const filePath = getFilePath(key);
|
||||
let value = null;
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
value = fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
return [key, value];
|
||||
});
|
||||
callback && callback(null, values);
|
||||
return values;
|
||||
}
|
||||
|
||||
async function _multiRemove(keys: string[], callback?: any) {
|
||||
keys.forEach(key => {
|
||||
const filePath = getFilePath(key);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
});
|
||||
callback && callback(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _clear(callback?: any) {
|
||||
if (fs.existsSync(STORAGE_DIR)) {
|
||||
const files = fs.readdirSync(STORAGE_DIR);
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(STORAGE_DIR, file));
|
||||
}
|
||||
}
|
||||
callback && callback(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _getAllKeys() {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readdirSync(STORAGE_DIR);
|
||||
}
|
||||
|
||||
const asMock: any = {
|
||||
setItem: jest.fn(async (key: string, value: string, callback?: any) => {
|
||||
const setResult = await asMock.multiSet([[key, value]], undefined);
|
||||
callback && callback(setResult);
|
||||
return setResult;
|
||||
}),
|
||||
|
||||
getItem: jest.fn(async (key: string, callback?: any) => {
|
||||
const getResult = await asMock.multiGet([key], undefined);
|
||||
const result = getResult[0] ? getResult[0][1] : null;
|
||||
callback && callback(null, result);
|
||||
return result;
|
||||
}),
|
||||
|
||||
removeItem: jest.fn((key: string, callback?: any) => asMock.multiRemove([key], callback)),
|
||||
|
||||
clear: jest.fn(_clear),
|
||||
getAllKeys: jest.fn(_getAllKeys),
|
||||
flushGetRequests: jest.fn(),
|
||||
|
||||
multiGet: jest.fn(_multiGet),
|
||||
multiSet: jest.fn(_multiSet),
|
||||
multiRemove: jest.fn(_multiRemove),
|
||||
};
|
||||
|
||||
return asMock;
|
||||
});
|
||||
// Ark storage lives in Realm, not AsyncStorage. Realm + Keychain are mocked
|
||||
// globally by tests/setup.js (per-path Realm + service-keyed Keychain), and
|
||||
// pure unit-level coverage lives in tests/unit/lightning-ark-wallet.test.ts
|
||||
// and tests/unit/lightning-ark-derivation.test.ts. What remains here are the
|
||||
// env-gated tests that exercise the real init pipeline against the
|
||||
// production ASP / delegator using a real mnemonic.
|
||||
|
||||
jest.setTimeout(30_000);
|
||||
|
||||
@ -121,7 +27,7 @@ afterAll(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 3_000)); // sleep
|
||||
});
|
||||
|
||||
describe('LightningArkWallet', () => {
|
||||
describe('LightningArkWallet (integration)', () => {
|
||||
it('can generate', async () => {
|
||||
const wGenerated = new LightningArkWallet();
|
||||
await wGenerated.generate();
|
||||
@ -146,32 +52,6 @@ describe('LightningArkWallet', () => {
|
||||
assert.ok(balance > 0);
|
||||
});
|
||||
|
||||
it('can decode invoice', async () => {
|
||||
const invoice =
|
||||
'lnbc20n1p59n9nkpp58s49flel3cz5u3lrve8qeqzxljxmu0gja06elfcgwrx2e9nq959ssp5z7ytwq0rm6yq8evn2kteduj6a0rs4svn3sfwvg92a29f8l022jjqxq9z0rgqnp4qvyndeaqzman7h898jxm98dzkm0mlrsx36s93smrur7h0azyyuxc5rzjq25carzepgd4vqsyn44jrk85ezrpju92xyrk9apw4cdjh6yrwt5jgqqqqrt49lmtcqqqqqqqqqqq86qq9qrzjqwghf7zxvfkxq5a6sr65g0gdkv768p83mhsnt0msszapamzx2qvuxqqqqrt49lmtcqqqqqqqqqqq86qq9qcqzpgdq023mk7gryv9uhxgq9qyyssqy4mv8te3l6mrc7qf4pksh4m4z76jz7s2qrwxd7q2s22ghnanqt33e9p0nahz9fr32g00vn2vhc9rrhpvtr54s40tle25tyyvp59sdpsqty30rp';
|
||||
|
||||
const decoded = w.decodeInvoice(invoice);
|
||||
|
||||
assert.strictEqual(decoded.num_satoshis, 2);
|
||||
assert.strictEqual(decoded.num_millisatoshis, 2000);
|
||||
assert.strictEqual(decoded.timestamp, 1750701686);
|
||||
assert.strictEqual(decoded.expiry, 2592000);
|
||||
assert.strictEqual(decoded.description, 'Two days ');
|
||||
assert.strictEqual(decoded.payment_hash, '3c2a54ff3f8e054e47e3664e0c8046fc8dbe3d12ebf59fa70870ccac96602d0b');
|
||||
assert.strictEqual(decoded.destination, '030936e7a016fb3f5ce53c8db29da2b6dfbf8e068ea058c363e0fd77f444270d8a');
|
||||
assert.strictEqual(decoded.fallback_addr, '');
|
||||
assert.strictEqual(decoded.description_hash, '');
|
||||
assert.strictEqual(decoded.cltv_expiry, '40');
|
||||
assert.strictEqual(decoded.route_hints.length, 0); // decode function does not decode this yet cause we dont need it for now
|
||||
});
|
||||
|
||||
it('can tell if invoice expired', async () => {
|
||||
const invoice =
|
||||
'lnbc6670n1p5jp0p9pp5jmyumdwfejjxzwhxh7wnckeugcwcpkqtf5t6dh2fzykjjh4hkatqdq6235x2grhdaexggrs09exzmtfvscqz3txqyyzzssp5ae74xvmlk5q6vxsxe3sqm90w2x4x0ekejt7qp9ca5zzhu83ru8hq9qxpqysgql4dexpmwacw98va6v6smww69a3w6hs5ng0573v8skyhlj7lylt8r65jm5zqaa7hzx3vlrs2fr3h0rtqjw7x94xprdwqy6rr9ff5pnxsppnpr5q';
|
||||
assert.strictEqual(w.isInvoiceExpired(invoice), true);
|
||||
assert.strictEqual(w.isInvoiceExpired(invoice, 1763752997), false);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip('can create invoice', async () => {
|
||||
if (!process.env.HD_MNEMONIC_OLD) {
|
||||
@ -244,33 +124,4 @@ describe('LightningArkWallet', () => {
|
||||
'lnbc80u1p5052hwpp5z4ln6hyq4wcck809pt7f0q54ag5he6ce797flm7gl9vuccm9lx2sdqqcqzysxqyz5vqsp5nh9fl4g36606tvxswtnfxzy55yze2656cw2fya7dhl8r6u0czyds9qxpqysgq83sw25g9d9ltr05nkfzejnvvunzkrk4qeuxhszuvvsguk5m6vmg3a7n5nd67l9frru3kjzpt8x6jfusjyc7ezh49jeeh900kt3v30qsqzq7fst',
|
||||
);
|
||||
});
|
||||
|
||||
it('can validate ark native address', async () => {
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t5z8sz5n95k570z5r004szc9h2q3qprkzdd5zveujdpx24srcrqg8hf6j4v',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9rw',
|
||||
),
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!w.isAddressValid(
|
||||
'ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9r',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
!w.isAddressValid('ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9'),
|
||||
);
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4sedhdvfcgaky2qk2p55wj4ut38v9tnpuvjr8ee8hv6htp23pzjpwx5esw',
|
||||
),
|
||||
);
|
||||
assert.ok(!w.isAddressValid('ark1sfhshhehehwer'));
|
||||
assert.ok(!w.isAddressValid('test'));
|
||||
});
|
||||
});
|
||||
|
||||
373
tests/setup.js
373
tests/setup.js
@ -72,6 +72,34 @@ jest.mock('react-native-notifications', () => {
|
||||
return {};
|
||||
});
|
||||
|
||||
jest.mock('react-native-background-fetch', () => {
|
||||
// The real module instantiates `new NativeEventEmitter(...)` at module
|
||||
// load, which throws under jest because the underlying native module is
|
||||
// null. Test files that don't drive scheduler behavior (i.e. anything
|
||||
// that transitively imports `blue_modules/arkade-background`) just need a
|
||||
// safe default. Tests that exercise registration/run paths jest.mock this
|
||||
// module locally with their own factory.
|
||||
const noop = jest.fn();
|
||||
const noopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
const stub = {
|
||||
configure: noopAsync,
|
||||
start: noopAsync,
|
||||
stop: jest.fn().mockResolvedValue(true),
|
||||
finish: noop,
|
||||
scheduleTask: noopAsync,
|
||||
registerHeadlessTask: noop,
|
||||
STATUS_RESTRICTED: 0,
|
||||
STATUS_DENIED: 1,
|
||||
STATUS_AVAILABLE: 2,
|
||||
NETWORK_TYPE_NONE: 0,
|
||||
NETWORK_TYPE_ANY: 1,
|
||||
NETWORK_TYPE_CELLULAR: 2,
|
||||
NETWORK_TYPE_UNMETERED: 3,
|
||||
NETWORK_TYPE_NOT_ROAMING: 4,
|
||||
};
|
||||
return { __esModule: true, default: stub, ...stub };
|
||||
});
|
||||
|
||||
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
|
||||
|
||||
jest.mock('react-native-device-info', () => {
|
||||
@ -173,22 +201,32 @@ jest.mock('react-native-default-preference', () => {
|
||||
});
|
||||
|
||||
jest.mock('react-native-fs', () => {
|
||||
// Track existence per absolute path so the Arkade Realm adapter's
|
||||
// ensureArkadeDir() / unlink() round trips behave coherently in tests.
|
||||
const mockFsExisting = new Set();
|
||||
const setExists = p => mockFsExisting.add(p);
|
||||
const clearExists = p => mockFsExisting.delete(p);
|
||||
|
||||
return {
|
||||
mkdir: jest.fn(),
|
||||
mkdir: jest.fn(async p => {
|
||||
setExists(p);
|
||||
}),
|
||||
moveFile: jest.fn(),
|
||||
copyFile: jest.fn(),
|
||||
pathForBundle: jest.fn(),
|
||||
pathForGroup: jest.fn(),
|
||||
getFSInfo: jest.fn(),
|
||||
getAllExternalFilesDirs: jest.fn(),
|
||||
unlink: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
unlink: jest.fn(async p => {
|
||||
clearExists(p);
|
||||
}),
|
||||
exists: jest.fn(async p => mockFsExisting.has(p)),
|
||||
stopDownload: jest.fn(),
|
||||
resumeDownload: jest.fn(),
|
||||
isResumable: jest.fn(),
|
||||
stopUpload: jest.fn(),
|
||||
completeHandlerIOS: jest.fn(),
|
||||
readDir: jest.fn(),
|
||||
readDir: jest.fn(async () => []),
|
||||
readDirAssets: jest.fn(),
|
||||
existsAssets: jest.fn(),
|
||||
readdir: jest.fn(),
|
||||
@ -207,14 +245,15 @@ jest.mock('react-native-fs', () => {
|
||||
downloadFile: jest.fn(),
|
||||
uploadFiles: jest.fn(),
|
||||
touch: jest.fn(),
|
||||
MainBundlePath: jest.fn(),
|
||||
CachesDirectoryPath: jest.fn(),
|
||||
DocumentDirectoryPath: jest.fn(),
|
||||
ExternalDirectoryPath: jest.fn(),
|
||||
ExternalStorageDirectoryPath: jest.fn(),
|
||||
TemporaryDirectoryPath: jest.fn(),
|
||||
LibraryDirectoryPath: jest.fn(),
|
||||
PicturesDirectoryPath: jest.fn(),
|
||||
MainBundlePath: '/mock/MainBundle',
|
||||
CachesDirectoryPath: '/mock/Caches',
|
||||
DocumentDirectoryPath: '/mock/Documents',
|
||||
ExternalDirectoryPath: '/mock/External',
|
||||
ExternalStorageDirectoryPath: '/mock/ExternalStorage',
|
||||
TemporaryDirectoryPath: '/mock/Temporary',
|
||||
LibraryDirectoryPath: '/mock/Library',
|
||||
PicturesDirectoryPath: '/mock/Pictures',
|
||||
__mockFsHelpers: { setExists, clearExists, reset: () => mockFsExisting.clear() },
|
||||
};
|
||||
});
|
||||
|
||||
@ -222,32 +261,231 @@ jest.mock('@react-native-documents/picker', () => ({}));
|
||||
|
||||
jest.mock('react-native-haptic-feedback', () => ({}));
|
||||
|
||||
const realmInstanceMock = {
|
||||
create: function () {},
|
||||
delete: function () {},
|
||||
close: function () {},
|
||||
write: function (transactionFn) {
|
||||
if (typeof transactionFn === 'function') {
|
||||
// to test if something is not right in Realm transactional database write
|
||||
transactionFn();
|
||||
}
|
||||
},
|
||||
objectForPrimaryKey: function () {
|
||||
return {};
|
||||
},
|
||||
objects: function () {
|
||||
const wallets = {
|
||||
filtered: function () {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
return wallets;
|
||||
},
|
||||
};
|
||||
// Per-path Realm mock so the Arkade Realm adapter (one encrypted file per Ark wallet)
|
||||
// can be exercised in unit tests. Each `Realm.open({ path })` returns a stable
|
||||
// instance for that path until it is closed or deleted; concurrent opens for the
|
||||
// same path observe the same instance.
|
||||
//
|
||||
// The mock supports in-memory CRUD so SDK repository operations (saveContract,
|
||||
// getContracts, saveVtxos, getVtxos, etc.) round-trip correctly. Without this,
|
||||
// `annotateVtxos` cannot find contracts that were just saved and throws
|
||||
// "no contract matched vtxo.script" when the test wallet has live VTXOs.
|
||||
jest.mock('realm', () => {
|
||||
const mockRealmStore = new Map();
|
||||
// Persisted-on-disk view: paths that have been opened at least once and not
|
||||
// yet deleted. Realm.exists / Realm.deleteFile read this rather than the
|
||||
// live (memory-cached, possibly-closed) instance map so deleteArkadeRealm
|
||||
// can realistically test the file-cleanup path.
|
||||
const mockRealmFiles = new Set();
|
||||
|
||||
// Primary-key field per Realm object type. Used by create() to key the
|
||||
// in-memory store and by delete() to remove individual objects.
|
||||
const PK_FIELD = {
|
||||
ArkContract: 'script',
|
||||
ArkVtxo: 'pk',
|
||||
ArkUtxo: 'pk',
|
||||
ArkTransaction: 'pk',
|
||||
ArkWalletState: 'key',
|
||||
BoltzSwap: 'id',
|
||||
ArkSwapNotificationSuppression: 'id',
|
||||
};
|
||||
|
||||
// Split a query string at a top-level separator (i.e. not inside parens/braces).
|
||||
const splitTop = (s, sep) => {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= s.length - sep.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === '(' || c === '{') depth++;
|
||||
else if (c === ')' || c === '}') depth--;
|
||||
else if (depth === 0 && s.slice(i, i + sep.length) === sep) {
|
||||
parts.push(s.slice(start, i).trim());
|
||||
i += sep.length - 1;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(s.slice(start).trim());
|
||||
return parts.length > 1 ? parts : [s.trim()];
|
||||
};
|
||||
|
||||
// Evaluate a Realm query expression against a plain object.
|
||||
// Handles: `field == $N`, `field IN {$0,$1,...}`, AND, OR, and parens.
|
||||
const evalExpr = (obj, expr, args) => {
|
||||
expr = expr.trim();
|
||||
// Strip matching outer parens — e.g. "(a == $0 OR a == $1)" → "a == $0 OR a == $1"
|
||||
while (expr.startsWith('(') && expr.endsWith(')')) {
|
||||
let depth = 0;
|
||||
let allWrapped = true;
|
||||
for (let i = 0; i < expr.length - 1; i++) {
|
||||
if (expr[i] === '(') depth++;
|
||||
else if (expr[i] === ')') {
|
||||
if (--depth === 0) {
|
||||
allWrapped = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allWrapped) expr = expr.slice(1, -1).trim();
|
||||
else break;
|
||||
}
|
||||
// AND: all sub-expressions must match
|
||||
const andParts = splitTop(expr, ' AND ');
|
||||
if (andParts.length > 1) return andParts.every(p => evalExpr(obj, p, args));
|
||||
// OR: any sub-expression must match
|
||||
const orParts = splitTop(expr, ' OR ');
|
||||
if (orParts.length > 1) return orParts.some(p => evalExpr(obj, p, args));
|
||||
// IN {$0, $1, ...} — used by BoltzSwap repository
|
||||
const inMatch = expr.match(/^(\w+)\s+IN\s+\{([^}]*)\}$/i);
|
||||
if (inMatch) {
|
||||
const field = inMatch[1];
|
||||
const values = inMatch[2].split(',').map(p => {
|
||||
const m = p.trim().match(/^\$(\d+)$/);
|
||||
return m ? args[+m[1]] : undefined;
|
||||
});
|
||||
return values.includes(obj[field]);
|
||||
}
|
||||
// field == $N
|
||||
const eqMatch = expr.match(/^(\w+)\s*==\s*\$(\d+)$/);
|
||||
if (eqMatch) return obj[eqMatch[1]] === args[+eqMatch[2]];
|
||||
return true; // unknown expression — pass through
|
||||
};
|
||||
|
||||
// Build a chainable collection over an array of Realm objects.
|
||||
const makeCollection = (type, items) => {
|
||||
const arr = Array.isArray(items) ? items : [...items];
|
||||
return {
|
||||
filtered: (query, ...args) =>
|
||||
makeCollection(
|
||||
type,
|
||||
arr.filter(o => evalExpr(o, query, args)),
|
||||
),
|
||||
sorted: (field, reverse) => {
|
||||
const sorted = [...arr].sort((a, b) => {
|
||||
if (a[field] < b[field]) return reverse ? 1 : -1;
|
||||
if (a[field] > b[field]) return reverse ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
return makeCollection(type, sorted);
|
||||
},
|
||||
get length() {
|
||||
return arr.length;
|
||||
},
|
||||
[Symbol.iterator]: function* () {
|
||||
yield* arr;
|
||||
},
|
||||
// Internal: used by delete() to identify the backing type and items.
|
||||
_type: type,
|
||||
_items: arr,
|
||||
};
|
||||
};
|
||||
|
||||
const makeRealmInstance = path => {
|
||||
let isClosed = false;
|
||||
// type → Map<primaryKey, object>
|
||||
const typeStore = new Map();
|
||||
|
||||
const getStore = type => {
|
||||
if (!typeStore.has(type)) typeStore.set(type, new Map());
|
||||
return typeStore.get(type);
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
get isClosed() {
|
||||
return isClosed;
|
||||
},
|
||||
|
||||
create(type, data) {
|
||||
const store = getStore(type);
|
||||
const pkField = PK_FIELD[type];
|
||||
const pk = pkField !== undefined ? data[pkField] : JSON.stringify(data);
|
||||
// Shallow-copy so later mutations to the caller's object don't affect
|
||||
// what the store holds. Attach a non-enumerable tag for delete().
|
||||
const stored = Object.defineProperty({ ...data }, '_realmMeta', {
|
||||
value: { type, pk },
|
||||
enumerable: false,
|
||||
});
|
||||
store.set(pk, stored);
|
||||
},
|
||||
|
||||
delete(target) {
|
||||
if (!target) return;
|
||||
// Single object returned by objectForPrimaryKey (has _realmMeta)
|
||||
if (target._realmMeta) {
|
||||
const { type, pk } = target._realmMeta;
|
||||
getStore(type).delete(pk);
|
||||
return;
|
||||
}
|
||||
// Collection returned by objects() / filtered()
|
||||
if (target._type !== undefined && target._items !== undefined) {
|
||||
const store = getStore(target._type);
|
||||
const pkField = PK_FIELD[target._type];
|
||||
for (const item of target._items) {
|
||||
const pk = pkField !== undefined ? item[pkField] : undefined;
|
||||
if (pk !== undefined) store.delete(pk);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
write(transactionFn) {
|
||||
if (typeof transactionFn === 'function') transactionFn();
|
||||
},
|
||||
|
||||
objectForPrimaryKey(type, pk) {
|
||||
return getStore(type).get(pk) ?? null;
|
||||
},
|
||||
|
||||
objects(type) {
|
||||
return makeCollection(type, getStore(type).values());
|
||||
},
|
||||
|
||||
close() {
|
||||
isClosed = true;
|
||||
},
|
||||
|
||||
addListener: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
|
||||
// Exposed so __mockRealmHelpers.reset() can wipe data in open instances.
|
||||
_clearData: () => typeStore.clear(),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
UpdateMode: { Modified: 1 },
|
||||
open: jest.fn(() => realmInstanceMock),
|
||||
open: jest.fn(async config => {
|
||||
const path = (config && config.path) || '__default__';
|
||||
const existing = mockRealmStore.get(path);
|
||||
if (existing && !existing.isClosed) return existing;
|
||||
const inst = makeRealmInstance(path);
|
||||
mockRealmStore.set(path, inst);
|
||||
mockRealmFiles.add(path);
|
||||
return inst;
|
||||
}),
|
||||
// Real Realm.exists / Realm.deleteFile are synchronous in this version.
|
||||
exists: jest.fn(arg => {
|
||||
const path = typeof arg === 'string' ? arg : (arg && arg.path) || '__default__';
|
||||
return mockRealmFiles.has(path);
|
||||
}),
|
||||
deleteFile: jest.fn(config => {
|
||||
const path = (config && config.path) || '__default__';
|
||||
mockRealmStore.delete(path);
|
||||
mockRealmFiles.delete(path);
|
||||
}),
|
||||
__mockRealmHelpers: {
|
||||
reset: () => {
|
||||
// Clear data inside any open instances so tests don't leak state
|
||||
// through instances cached in the app module's realmInstances map.
|
||||
for (const inst of mockRealmStore.values()) {
|
||||
if (typeof inst._clearData === 'function') inst._clearData();
|
||||
}
|
||||
mockRealmStore.clear();
|
||||
mockRealmFiles.clear();
|
||||
},
|
||||
store: mockRealmStore,
|
||||
files: mockRealmFiles,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -278,16 +516,61 @@ jest.mock('react-native-share', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockKeychain = {
|
||||
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
|
||||
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
|
||||
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
|
||||
setGenericPassword: jest.fn().mockResolvedValue(),
|
||||
getGenericPassword: jest.fn().mockResolvedValue(),
|
||||
resetGenericPassword: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
jest.mock('react-native-keychain', () => mockKeychain);
|
||||
// Service-keyed Keychain mock so Arkade adapter tests can exercise the per-wallet
|
||||
// encryption-key lifecycle (load-or-create, then read on subsequent open). Defined
|
||||
// inside the factory because Jest hoists `jest.mock` above module scope and refuses
|
||||
// out-of-scope captures (only names matching /mock/i are allowed through).
|
||||
jest.mock('react-native-keychain', () => {
|
||||
const mockKeychainCreds = new Map();
|
||||
return {
|
||||
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
|
||||
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
|
||||
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
|
||||
ACCESSIBLE: {
|
||||
WHEN_UNLOCKED: 'AccessibleWhenUnlocked',
|
||||
AFTER_FIRST_UNLOCK: 'AccessibleAfterFirstUnlock',
|
||||
ALWAYS: 'AccessibleAlways',
|
||||
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 'AccessibleWhenPasscodeSetThisDeviceOnly',
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly',
|
||||
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AccessibleAfterFirstUnlockThisDeviceOnly',
|
||||
},
|
||||
SECURITY_LEVEL: {
|
||||
SECURE_SOFTWARE: 'SECURE_SOFTWARE',
|
||||
SECURE_HARDWARE: 'SECURE_HARDWARE',
|
||||
ANY: 'ANY',
|
||||
},
|
||||
setGenericPassword: jest.fn(async (username, password, options) => {
|
||||
const svc = (options && options.service) || '__default__';
|
||||
mockKeychainCreds.set(svc, { username, password, service: svc });
|
||||
return true;
|
||||
}),
|
||||
getGenericPassword: jest.fn(async options => {
|
||||
const svc = (options && options.service) || '__default__';
|
||||
return mockKeychainCreds.get(svc) || false;
|
||||
}),
|
||||
resetGenericPassword: jest.fn(async options => {
|
||||
const svc = (options && options.service) || '__default__';
|
||||
return mockKeychainCreds.delete(svc);
|
||||
}),
|
||||
// Default to the strongest level so the adapter's preflight selects
|
||||
// SECURE_HARDWARE in the happy path. Tests override per-case via
|
||||
// mockResolvedValueOnce when they need a downgrade scenario.
|
||||
getSecurityLevel: jest.fn(async () => 'SECURE_HARDWARE'),
|
||||
__mockKeychainHelpers: { reset: () => mockKeychainCreds.clear(), store: mockKeychainCreds },
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-tcp-socket', () => mockKeychain);
|
||||
// Historic copy-paste: react-native-tcp-socket pulled the Keychain mock. Keep the
|
||||
// same surface so existing tests continue to mount, just with a fresh map.
|
||||
jest.mock('react-native-tcp-socket', () => {
|
||||
return {
|
||||
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
|
||||
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
|
||||
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
|
||||
setGenericPassword: jest.fn().mockResolvedValue(true),
|
||||
getGenericPassword: jest.fn().mockResolvedValue(false),
|
||||
resetGenericPassword: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
global.alert = () => {};
|
||||
|
||||
626
tests/unit/arkade-background.test.ts
Normal file
626
tests/unit/arkade-background.test.ts
Normal file
@ -0,0 +1,626 @@
|
||||
import assert from 'assert';
|
||||
import BackgroundFetch from 'react-native-background-fetch';
|
||||
import { BoltzSwapProvider, updateChainSwapStatus, updateReverseSwapStatus, updateSubmarineSwapStatus } from '@arkade-os/boltz-swap';
|
||||
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
|
||||
import { BlueApp } from '../../class/blue-app';
|
||||
import { getArkadeRealm } from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
import {
|
||||
getArkTaskState,
|
||||
onArkBackgroundTaskTimeout,
|
||||
reconcileArkBackgroundTaskResults,
|
||||
registerArkBackgroundTask,
|
||||
runArkBackgroundTask,
|
||||
stopArkBackgroundTask,
|
||||
__testing__ as backgroundTesting,
|
||||
} from '../../blue_modules/arkade-background';
|
||||
|
||||
// jest.mock calls are hoisted before imports at runtime, so imports above
|
||||
// receive the mocked module. Factories cannot reference outer-scope user
|
||||
// variables — keep all shared mock fns inside the factory and surface them
|
||||
// through the constructor or the module's exports.
|
||||
jest.mock('react-native-background-fetch', () => {
|
||||
const mockApi = {
|
||||
configure: jest.fn().mockResolvedValue(2),
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(true),
|
||||
finish: jest.fn(),
|
||||
registerHeadlessTask: jest.fn(),
|
||||
STATUS_RESTRICTED: 0,
|
||||
STATUS_DENIED: 1,
|
||||
STATUS_AVAILABLE: 2,
|
||||
NETWORK_TYPE_ANY: 1,
|
||||
NETWORK_TYPE_NONE: 0,
|
||||
};
|
||||
return { __esModule: true, default: mockApi, ...mockApi };
|
||||
});
|
||||
|
||||
jest.mock('@arkade-os/boltz-swap', () => {
|
||||
const actual = jest.requireActual('@arkade-os/boltz-swap');
|
||||
const getSwapStatus = jest.fn();
|
||||
const updateReverseSwapStatusFn = jest.fn().mockResolvedValue(undefined);
|
||||
const updateSubmarineSwapStatusFn = jest.fn().mockResolvedValue(undefined);
|
||||
const updateChainSwapStatusFn = jest.fn().mockResolvedValue(undefined);
|
||||
const Provider = jest.fn().mockImplementation(() => ({ getSwapStatus }));
|
||||
// Hang the shared getSwapStatus mock off the constructor so the test can
|
||||
// reach it without referencing outer-scope variables in the factory.
|
||||
(Provider as any).__getSwapStatus = getSwapStatus;
|
||||
return {
|
||||
...actual,
|
||||
BoltzSwapProvider: Provider,
|
||||
updateReverseSwapStatus: updateReverseSwapStatusFn,
|
||||
updateSubmarineSwapStatus: updateSubmarineSwapStatusFn,
|
||||
updateChainSwapStatus: updateChainSwapStatusFn,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@arkade-os/boltz-swap/repositories/realm', () => {
|
||||
const getAllSwaps = jest.fn().mockResolvedValue([]);
|
||||
const saveSwap = jest.fn().mockResolvedValue(undefined);
|
||||
const Repo = jest.fn().mockImplementation(() => ({ getAllSwaps, saveSwap }));
|
||||
(Repo as any).__getAllSwaps = getAllSwaps;
|
||||
(Repo as any).__saveSwap = saveSwap;
|
||||
return { RealmSwapRepository: Repo };
|
||||
});
|
||||
|
||||
jest.mock('../../blue_modules/arkade-adapters/realm/realmInstance', () => {
|
||||
return { getArkadeRealm: jest.fn() };
|
||||
});
|
||||
|
||||
jest.mock('../../blue_modules/arkade-adapters/realm/notificationSuppressionRepository', () => {
|
||||
// Single shared instance backed by an in-memory map so the test can
|
||||
// observe the same state pollSwap manipulates. Repo construction
|
||||
// happens inside processWallet, so we surface mocked methods through
|
||||
// the constructor.
|
||||
const store = new Map<string, true>();
|
||||
const has = jest.fn((swapId: string, action: string) => store.has(`${swapId}:${action}`));
|
||||
const record = jest.fn((swapId: string, action: string) => {
|
||||
store.set(`${swapId}:${action}`, true);
|
||||
});
|
||||
const clearForSwap = jest.fn((swapId: string) => {
|
||||
for (const k of Array.from(store.keys())) if (k.startsWith(`${swapId}:`)) store.delete(k);
|
||||
});
|
||||
const clearForSwapAction = jest.fn((swapId: string, action: string) => {
|
||||
store.delete(`${swapId}:${action}`);
|
||||
});
|
||||
const Repo = jest.fn().mockImplementation(() => ({ has, record, clearForSwap, clearForSwapAction }));
|
||||
(Repo as any).__store = store;
|
||||
(Repo as any).__has = has;
|
||||
(Repo as any).__record = record;
|
||||
(Repo as any).__clearForSwap = clearForSwap;
|
||||
(Repo as any).__clearForSwapAction = clearForSwapAction;
|
||||
return { RealmNotificationSuppressionRepository: Repo, ArkSwapNotificationSuppressionSchema: {} };
|
||||
});
|
||||
|
||||
jest.mock('../../blue_modules/arkade-notifications', () => {
|
||||
const notifyArkSwapActionable = jest.fn().mockResolvedValue(undefined);
|
||||
const resolveActionableAction = jest.fn().mockReturnValue(null);
|
||||
return {
|
||||
notifyArkSwapActionable,
|
||||
resolveActionableAction,
|
||||
ARK_SWAP_NOTIFICATION_TYPE: 100,
|
||||
ensureArkNotificationChannel: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const configureMock = BackgroundFetch.configure as unknown as jest.Mock;
|
||||
const startMock = BackgroundFetch.start as unknown as jest.Mock;
|
||||
const stopMock = BackgroundFetch.stop as unknown as jest.Mock;
|
||||
const finishMock = BackgroundFetch.finish as unknown as jest.Mock;
|
||||
|
||||
const getSwapStatusMock = (BoltzSwapProvider as any).__getSwapStatus as jest.Mock;
|
||||
const updateReverseSwapStatusMock = updateReverseSwapStatus as unknown as jest.Mock;
|
||||
const updateSubmarineSwapStatusMock = updateSubmarineSwapStatus as unknown as jest.Mock;
|
||||
const updateChainSwapStatusMock = updateChainSwapStatus as unknown as jest.Mock;
|
||||
|
||||
const getAllSwapsMock = (RealmSwapRepository as any).__getAllSwaps as jest.Mock;
|
||||
const getArkadeRealmMock = getArkadeRealm as unknown as jest.Mock;
|
||||
|
||||
const suppressionMockModule = jest.requireMock('../../blue_modules/arkade-adapters/realm/notificationSuppressionRepository') as any;
|
||||
const suppressionStore: Map<string, true> = suppressionMockModule.RealmNotificationSuppressionRepository.__store;
|
||||
const suppressionHasMock = suppressionMockModule.RealmNotificationSuppressionRepository.__has as jest.Mock;
|
||||
const suppressionRecordMock = suppressionMockModule.RealmNotificationSuppressionRepository.__record as jest.Mock;
|
||||
const suppressionClearForSwapMock = suppressionMockModule.RealmNotificationSuppressionRepository.__clearForSwap as jest.Mock;
|
||||
const suppressionClearForSwapActionMock = suppressionMockModule.RealmNotificationSuppressionRepository.__clearForSwapAction as jest.Mock;
|
||||
|
||||
const notificationsMockModule = jest.requireMock('../../blue_modules/arkade-notifications') as any;
|
||||
const notifyArkSwapActionableMock = notificationsMockModule.notifyArkSwapActionable as jest.Mock;
|
||||
const resolveActionableActionMock = notificationsMockModule.resolveActionableAction as jest.Mock;
|
||||
|
||||
const TEST_SECRET_A = 'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const TEST_SECRET_B = 'arkade://about above absent absorb abstract absurd abuse access accident account accuse achieve';
|
||||
|
||||
const stubRealm = { id: 'realm' } as any;
|
||||
|
||||
function makeArkWallet(secret: string): LightningArkWallet {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret(secret);
|
||||
return w;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
configureMock.mockClear();
|
||||
configureMock.mockResolvedValue(BackgroundFetch.STATUS_AVAILABLE);
|
||||
startMock.mockClear();
|
||||
startMock.mockResolvedValue(undefined);
|
||||
stopMock.mockClear();
|
||||
stopMock.mockResolvedValue(true);
|
||||
finishMock.mockClear();
|
||||
|
||||
getSwapStatusMock.mockReset();
|
||||
updateReverseSwapStatusMock.mockReset();
|
||||
updateReverseSwapStatusMock.mockResolvedValue(undefined);
|
||||
updateSubmarineSwapStatusMock.mockReset();
|
||||
updateSubmarineSwapStatusMock.mockResolvedValue(undefined);
|
||||
updateChainSwapStatusMock.mockReset();
|
||||
updateChainSwapStatusMock.mockResolvedValue(undefined);
|
||||
|
||||
getAllSwapsMock.mockReset();
|
||||
getAllSwapsMock.mockResolvedValue([]);
|
||||
|
||||
getArkadeRealmMock.mockReset();
|
||||
getArkadeRealmMock.mockResolvedValue(stubRealm);
|
||||
|
||||
suppressionStore.clear();
|
||||
suppressionHasMock.mockClear();
|
||||
suppressionRecordMock.mockClear();
|
||||
suppressionClearForSwapMock.mockClear();
|
||||
suppressionClearForSwapActionMock.mockClear();
|
||||
|
||||
notifyArkSwapActionableMock.mockReset();
|
||||
notifyArkSwapActionableMock.mockResolvedValue(undefined);
|
||||
resolveActionableActionMock.mockReset();
|
||||
resolveActionableActionMock.mockReturnValue(null);
|
||||
|
||||
BlueApp.getInstance().wallets = [];
|
||||
backgroundTesting.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('registerArkBackgroundTask', () => {
|
||||
it('configures the scheduler once and records lastRegisteredAt', async () => {
|
||||
await registerArkBackgroundTask();
|
||||
assert.strictEqual(configureMock.mock.calls.length, 1);
|
||||
const cfg = configureMock.mock.calls[0][0];
|
||||
assert.strictEqual(cfg.minimumFetchInterval, 15);
|
||||
assert.strictEqual(cfg.stopOnTerminate, false);
|
||||
assert.strictEqual(cfg.startOnBoot, true);
|
||||
assert.strictEqual(cfg.enableHeadless, true);
|
||||
assert.strictEqual(cfg.requiredNetworkType, 1);
|
||||
assert.notStrictEqual(getArkTaskState().lastRegisteredAt, null);
|
||||
assert.strictEqual(getArkTaskState().availability, 'available');
|
||||
});
|
||||
|
||||
it('after stop, calls BackgroundFetch.start instead of reconfiguring', async () => {
|
||||
await registerArkBackgroundTask();
|
||||
await stopArkBackgroundTask();
|
||||
|
||||
configureMock.mockClear();
|
||||
startMock.mockClear();
|
||||
|
||||
await registerArkBackgroundTask();
|
||||
|
||||
assert.strictEqual(configureMock.mock.calls.length, 0);
|
||||
assert.strictEqual(startMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('records denied status without marking the scheduler configured', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
configureMock.mockResolvedValueOnce(BackgroundFetch.STATUS_DENIED);
|
||||
|
||||
await registerArkBackgroundTask();
|
||||
|
||||
assert.strictEqual(getArkTaskState().availability, 'denied');
|
||||
assert.strictEqual(getArkTaskState().lastRegisteredAt, null);
|
||||
assert.strictEqual(startMock.mock.calls.length, 0);
|
||||
assert.strictEqual(warnSpy.mock.calls.length, 1);
|
||||
|
||||
configureMock.mockClear();
|
||||
await registerArkBackgroundTask();
|
||||
assert.strictEqual(configureMock.mock.calls.length, 1);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runArkBackgroundTask', () => {
|
||||
it('finishes immediately with empty wallet list', async () => {
|
||||
await runArkBackgroundTask('task-1');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.walletsScanned, 0);
|
||||
assert.strictEqual(s.swapsPolled, 0);
|
||||
assert.strictEqual(s.swapsUpdated, 0);
|
||||
assert.strictEqual(s.lastError, null);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls[0][0], 'task-1');
|
||||
});
|
||||
|
||||
it('marks exitedDueToUnavailableStorage when getArkadeRealm throws', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
getArkadeRealmMock.mockRejectedValue(new Error('keychain locked'));
|
||||
|
||||
await runArkBackgroundTask('task-keychain');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.exitedDueToUnavailableStorage, true);
|
||||
assert.strictEqual(s.swapsPolled, 0);
|
||||
assert.strictEqual(s.swapsUpdated, 0);
|
||||
assert.ok(s.lastError && s.lastError.includes('keychain locked'));
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('polls non-terminal swaps without persisting when status matches', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const reverseSwap = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
const submarineSwap = { id: 's1', type: 'submarine', status: 'transaction.mempool' };
|
||||
getAllSwapsMock.mockResolvedValue([reverseSwap, submarineSwap]);
|
||||
getSwapStatusMock.mockImplementation(async (id: string) => {
|
||||
if (id === 'r1') return { status: 'swap.created' };
|
||||
if (id === 's1') return { status: 'transaction.mempool' };
|
||||
return { status: 'unknown' };
|
||||
});
|
||||
|
||||
await runArkBackgroundTask('task-poll');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.walletsScanned, 1);
|
||||
assert.strictEqual(s.swapsPolled, 2);
|
||||
assert.strictEqual(s.swapsUpdated, 0);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('persists changed reverse-swap status through updateReverseSwapStatus', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const reverseSwap = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValue([reverseSwap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.mempool' });
|
||||
|
||||
await runArkBackgroundTask('task-r1');
|
||||
|
||||
const s = getArkTaskState();
|
||||
assert.strictEqual(s.swapsPolled, 1);
|
||||
assert.strictEqual(s.swapsUpdated, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls[0][0], reverseSwap);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls[0][1], 'transaction.mempool');
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateChainSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.notStrictEqual(s.lastSwapUpdateAt, 0);
|
||||
});
|
||||
|
||||
it('routes submarine status changes to updateSubmarineSwapStatus', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const submarineSwap = { id: 's1', type: 'submarine', status: 'invoice.set' };
|
||||
getAllSwapsMock.mockResolvedValue([submarineSwap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'invoice.failedToPay' });
|
||||
|
||||
await runArkBackgroundTask('task-s1');
|
||||
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateChainSwapStatusMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('routes chain status changes to updateChainSwapStatus', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const chainSwap = { id: 'c1', type: 'chain', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValue([chainSwap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.mempool' });
|
||||
|
||||
await runArkBackgroundTask('task-c1');
|
||||
|
||||
assert.strictEqual(updateChainSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(updateSubmarineSwapStatusMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('skips terminal swaps according to the per-type final-status predicate', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const swaps = [
|
||||
{ id: 'r1', type: 'reverse', status: 'invoice.settled' },
|
||||
{ id: 's1', type: 'submarine', status: 'transaction.claimed' },
|
||||
{ id: 'c1', type: 'chain', status: 'transaction.refunded' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.expired' },
|
||||
];
|
||||
getAllSwapsMock.mockResolvedValue(swaps);
|
||||
|
||||
await runArkBackgroundTask('task-terminal');
|
||||
|
||||
assert.strictEqual(getSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 0);
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 0);
|
||||
});
|
||||
|
||||
it('continues to next swap when one getSwapStatus call fails', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'swap.created' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.created' },
|
||||
]);
|
||||
getSwapStatusMock.mockImplementation(async (id: string) => {
|
||||
if (id === 'r1') throw new Error('network');
|
||||
return { status: 'transaction.mempool' };
|
||||
});
|
||||
|
||||
await runArkBackgroundTask('task-onefail');
|
||||
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 2);
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 1);
|
||||
assert.ok(getArkTaskState().lastError && getArkTaskState().lastError!.includes('network'));
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('finishes overlapping runs immediately without starting a second scan', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
let resolveSwaps: (value: any[]) => void = () => {};
|
||||
getAllSwapsMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveSwaps = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const firstRun = runArkBackgroundTask('task-first');
|
||||
await Promise.resolve();
|
||||
await runArkBackgroundTask('task-second');
|
||||
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls[0][0], 'task-second');
|
||||
assert.strictEqual(getArkTaskState().walletsScanned, 1);
|
||||
|
||||
resolveSwaps([]);
|
||||
await firstRun;
|
||||
|
||||
assert.strictEqual(finishMock.mock.calls.length, 2);
|
||||
assert.strictEqual(finishMock.mock.calls[1][0], 'task-first');
|
||||
});
|
||||
|
||||
it('times out a hung status poll before persisting and skips later swaps', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'swap.created' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.created' },
|
||||
]);
|
||||
getSwapStatusMock.mockImplementation(() => new Promise(() => {}));
|
||||
// 100ms gives enough headroom for the await chain (getArkadeRealm,
|
||||
// getAllSwaps, processWallet entry) under loaded parallel workers so
|
||||
// the first pollSwap definitely starts before the deadline; the
|
||||
// never-resolving getSwapStatus then drives the withTimeout reject path.
|
||||
backgroundTesting.setMaxRunMs(100);
|
||||
|
||||
await runArkBackgroundTask('task-timeout');
|
||||
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 1);
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 0);
|
||||
assert.ok(getArkTaskState().lastError && getArkTaskState().lastError!.includes('deadline exceeded'));
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
assert.strictEqual(getSwapStatusMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onArkBackgroundTaskTimeout', () => {
|
||||
it('records lastError = timeout and calls finish', () => {
|
||||
onArkBackgroundTaskTimeout('task-to');
|
||||
assert.strictEqual(getArkTaskState().lastError, 'timeout');
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.strictEqual(finishMock.mock.calls[0][0], 'task-to');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopArkBackgroundTask', () => {
|
||||
it('calls BackgroundFetch.stop, clears swap cache, sets lastUnregisteredAt', async () => {
|
||||
backgroundTesting.swapStatusCache.set('ns-x', new Map([['s1', 'swap.created']]));
|
||||
|
||||
await stopArkBackgroundTask();
|
||||
|
||||
assert.strictEqual(stopMock.mock.calls.length, 1);
|
||||
assert.strictEqual(backgroundTesting.swapStatusCache.size, 0);
|
||||
assert.notStrictEqual(getArkTaskState().lastUnregisteredAt, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileArkBackgroundTaskResults', () => {
|
||||
it('does not call back when no swap update happened since last reconcile', () => {
|
||||
const cb = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb);
|
||||
assert.strictEqual(cb.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('calls back exactly once per Ark wallet whose namespace has cache entries after an update', async () => {
|
||||
const wA = makeArkWallet(TEST_SECRET_A);
|
||||
const wB = makeArkWallet(TEST_SECRET_B);
|
||||
BlueApp.getInstance().wallets = [wA as any, wB as any];
|
||||
|
||||
// Trigger one persisted update for wallet A.
|
||||
getAllSwapsMock.mockResolvedValueOnce([{ id: 'r1', type: 'reverse', status: 'swap.created' }]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.mempool' });
|
||||
// Wallet B has no swaps to poll.
|
||||
getAllSwapsMock.mockResolvedValueOnce([]);
|
||||
|
||||
await runArkBackgroundTask('task-reconcile');
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 1);
|
||||
|
||||
const cb = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb);
|
||||
|
||||
assert.strictEqual(cb.mock.calls.length, 1);
|
||||
assert.strictEqual(cb.mock.calls[0][0], wA.getID());
|
||||
});
|
||||
|
||||
it('skips on second invocation when no further updates have arrived', async () => {
|
||||
const wA = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [wA as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValueOnce([{ id: 'r1', type: 'reverse', status: 'swap.created' }]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.mempool' });
|
||||
|
||||
await runArkBackgroundTask('task-rec1');
|
||||
const cb1 = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb1);
|
||||
assert.strictEqual(cb1.mock.calls.length, 1);
|
||||
|
||||
const cb2 = jest.fn();
|
||||
reconcileArkBackgroundTaskResults(cb2);
|
||||
assert.strictEqual(cb2.mock.calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionable swap notifications', () => {
|
||||
it('calls notifyArkSwapActionable with an updatedSwap (status === remoteStatus) on transition into actionable', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const swap = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValue([swap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValue('claim');
|
||||
|
||||
await runArkBackgroundTask('task-actionable');
|
||||
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 1);
|
||||
const [passedSwap, , walletID, walletLabel] = notifyArkSwapActionableMock.mock.calls[0];
|
||||
// Regression guard for the SDK-non-mutation issue: the first arg must
|
||||
// carry remoteStatus, not the pre-update status. updateReverseSwapStatus
|
||||
// saves a copy and does not mutate the input, so passing `swap` here
|
||||
// would silently evaluate predicates against the old status.
|
||||
assert.strictEqual(passedSwap.status, 'transaction.confirmed');
|
||||
assert.strictEqual(passedSwap.id, 'r1');
|
||||
assert.strictEqual(walletID, w.getID());
|
||||
assert.strictEqual(typeof walletLabel, 'string');
|
||||
});
|
||||
|
||||
it('clears suppression and skips notify on transition into terminal status', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
const swap = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
getAllSwapsMock.mockResolvedValue([swap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'invoice.settled' });
|
||||
|
||||
await runArkBackgroundTask('task-terminal-clear');
|
||||
|
||||
assert.strictEqual(suppressionClearForSwapMock.mock.calls.length, 1);
|
||||
assert.strictEqual(suppressionClearForSwapMock.mock.calls[0][0], 'r1');
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('clears the previous-action suppression on predicate flip out of actionable', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
// Run 1: swap is actionable (claim) — populates lastSeenActionMap.
|
||||
const swap1 = { id: 'r1', type: 'reverse', status: 'swap.created' };
|
||||
getAllSwapsMock.mockResolvedValueOnce([swap1]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValueOnce('claim');
|
||||
await runArkBackgroundTask('task-flip-1');
|
||||
|
||||
// Run 2: same swap, status moved to a non-terminal but no-longer-actionable
|
||||
// state (predicate flipped false). Realm reflects the prior persisted
|
||||
// status, so the swap presented to processWallet has status 'transaction.confirmed'.
|
||||
const swap2 = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
getAllSwapsMock.mockResolvedValueOnce([swap2]);
|
||||
getSwapStatusMock.mockResolvedValueOnce({ status: 'transaction.mempool' });
|
||||
resolveActionableActionMock.mockReturnValueOnce(null);
|
||||
notifyArkSwapActionableMock.mockClear();
|
||||
suppressionClearForSwapActionMock.mockClear();
|
||||
await runArkBackgroundTask('task-flip-2');
|
||||
|
||||
assert.strictEqual(suppressionClearForSwapActionMock.mock.calls.length, 1);
|
||||
assert.strictEqual(suppressionClearForSwapActionMock.mock.calls[0][0], 'r1');
|
||||
assert.strictEqual(suppressionClearForSwapActionMock.mock.calls[0][1], 'claim');
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('re-evaluates actionable on a poll where remoteStatus === swap.status (regression guard)', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
// Realm already reflects an actionable status. The remote returns the
|
||||
// same status. Old behavior would early-return; new behavior must still
|
||||
// run the actionable evaluation because a previous wake may have failed
|
||||
// to post.
|
||||
const swap = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
getAllSwapsMock.mockResolvedValue([swap]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValue('claim');
|
||||
|
||||
await runArkBackgroundTask('task-stable-actionable');
|
||||
|
||||
// No persistence — status didn't change.
|
||||
assert.strictEqual(getArkTaskState().swapsUpdated, 0);
|
||||
assert.strictEqual(updateReverseSwapStatusMock.mock.calls.length, 0);
|
||||
// But notify is still invoked.
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('survives notify failure: pollSwap completes, BackgroundFetch.finish is called, run continues', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'swap.created' },
|
||||
{ id: 'r2', type: 'reverse', status: 'swap.created' },
|
||||
]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'transaction.confirmed' });
|
||||
resolveActionableActionMock.mockReturnValue('claim');
|
||||
notifyArkSwapActionableMock.mockRejectedValue(new Error('notify exploded'));
|
||||
|
||||
await runArkBackgroundTask('task-notify-throw');
|
||||
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 2);
|
||||
assert.strictEqual(notifyArkSwapActionableMock.mock.calls.length, 2);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
assert.ok(getArkTaskState().lastError && getArkTaskState().lastError!.includes('notify exploded'));
|
||||
});
|
||||
|
||||
it('survives suppression-write failure: pollSwap completes, subsequent polls run', async () => {
|
||||
const w = makeArkWallet(TEST_SECRET_A);
|
||||
BlueApp.getInstance().wallets = [w as any];
|
||||
|
||||
getAllSwapsMock.mockResolvedValue([
|
||||
{ id: 'r1', type: 'reverse', status: 'transaction.confirmed' },
|
||||
{ id: 'r2', type: 'reverse', status: 'transaction.confirmed' },
|
||||
]);
|
||||
getSwapStatusMock.mockResolvedValue({ status: 'invoice.settled' });
|
||||
suppressionClearForSwapMock.mockImplementationOnce(() => {
|
||||
throw new Error('realm closed');
|
||||
});
|
||||
|
||||
await runArkBackgroundTask('task-suppression-throw');
|
||||
|
||||
// Both polls still happen.
|
||||
assert.strictEqual(getArkTaskState().swapsPolled, 2);
|
||||
assert.strictEqual(finishMock.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('stopArkBackgroundTask clears the in-process lastSeenActionMap', async () => {
|
||||
backgroundTesting.lastSeenActionMap.set('ns-x:r1', 'claim');
|
||||
await stopArkBackgroundTask();
|
||||
assert.strictEqual(backgroundTesting.lastSeenActionMap.size, 0);
|
||||
});
|
||||
});
|
||||
288
tests/unit/arkade-notifications.test.ts
Normal file
288
tests/unit/arkade-notifications.test.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { isChainSwapClaimable, isChainSwapRefundable, isReverseSwapClaimable, isSubmarineSwapRefundable } from '@arkade-os/boltz-swap';
|
||||
|
||||
import {
|
||||
ARK_SWAP_NOTIFICATION_TYPE,
|
||||
ensureArkNotificationChannel,
|
||||
notifyArkSwapActionable,
|
||||
resolveActionableAction,
|
||||
__testing__ as notificationsTesting,
|
||||
} from '../../blue_modules/arkade-notifications';
|
||||
|
||||
// jest.mock calls are hoisted before imports at runtime, so the imports
|
||||
// above receive the mocked module. Factories cannot reference outer-scope
|
||||
// user variables — keep all shared mock fns inside the factory and surface
|
||||
// them through the module's exports.
|
||||
jest.mock('react-native-notifications', () => {
|
||||
const postLocalNotification = jest.fn();
|
||||
const setNotificationChannel = jest.fn();
|
||||
class Notification {
|
||||
payload: any;
|
||||
constructor(payload: any) {
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
return {
|
||||
Notification,
|
||||
Notifications: { postLocalNotification, setNotificationChannel },
|
||||
__postLocalNotification: postLocalNotification,
|
||||
__setNotificationChannel: setNotificationChannel,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('react-native-permissions', () => ({
|
||||
checkNotifications: jest.fn().mockResolvedValue({ status: 'granted' }),
|
||||
RESULTS: { GRANTED: 'granted', DENIED: 'denied', BLOCKED: 'blocked' },
|
||||
}));
|
||||
|
||||
jest.mock('@react-native-async-storage/async-storage', () => ({
|
||||
getItem: jest.fn().mockResolvedValue(null),
|
||||
setItem: jest.fn().mockResolvedValue(undefined),
|
||||
removeItem: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('@arkade-os/boltz-swap', () => ({
|
||||
isReverseSwapClaimable: jest.fn(),
|
||||
isSubmarineSwapRefundable: jest.fn(),
|
||||
isChainSwapClaimable: jest.fn(),
|
||||
isChainSwapRefundable: jest.fn(),
|
||||
}));
|
||||
|
||||
const isReverseSwapClaimableMock = isReverseSwapClaimable as unknown as jest.Mock;
|
||||
const isSubmarineSwapRefundableMock = isSubmarineSwapRefundable as unknown as jest.Mock;
|
||||
const isChainSwapClaimableMock = isChainSwapClaimable as unknown as jest.Mock;
|
||||
const isChainSwapRefundableMock = isChainSwapRefundable as unknown as jest.Mock;
|
||||
|
||||
const rnNotifications = jest.requireMock('react-native-notifications');
|
||||
const postLocalNotificationMock = rnNotifications.__postLocalNotification as jest.Mock;
|
||||
const setNotificationChannelMock = rnNotifications.__setNotificationChannel as jest.Mock;
|
||||
|
||||
interface SuppressionStub {
|
||||
has: jest.Mock;
|
||||
record: jest.Mock;
|
||||
clearForSwap: jest.Mock;
|
||||
clearForSwapAction: jest.Mock;
|
||||
}
|
||||
|
||||
function makeSuppressionStub(): SuppressionStub {
|
||||
const store = new Map<string, true>();
|
||||
return {
|
||||
has: jest.fn((swapId: string, action: string) => store.has(`${swapId}:${action}`)),
|
||||
record: jest.fn((swapId: string, action: string) => {
|
||||
store.set(`${swapId}:${action}`, true);
|
||||
}),
|
||||
clearForSwap: jest.fn((swapId: string) => {
|
||||
for (const k of Array.from(store.keys())) if (k.startsWith(`${swapId}:`)) store.delete(k);
|
||||
}),
|
||||
clearForSwapAction: jest.fn((swapId: string, action: string) => {
|
||||
store.delete(`${swapId}:${action}`);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
postLocalNotificationMock.mockReset();
|
||||
setNotificationChannelMock.mockReset();
|
||||
isReverseSwapClaimableMock.mockReset().mockReturnValue(false);
|
||||
isSubmarineSwapRefundableMock.mockReset().mockReturnValue(false);
|
||||
isChainSwapClaimableMock.mockReset().mockReturnValue(false);
|
||||
isChainSwapRefundableMock.mockReset().mockReturnValue(false);
|
||||
|
||||
notificationsTesting.setAppStateForTest('background');
|
||||
notificationsTesting.setPermissionResultForTest('granted');
|
||||
notificationsTesting.setOptOutFlagForTest(null);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
notificationsTesting.setAppStateForTest(null);
|
||||
notificationsTesting.setPermissionResultForTest(null);
|
||||
notificationsTesting.setOptOutFlagForTest(undefined);
|
||||
});
|
||||
|
||||
describe('resolveActionableAction', () => {
|
||||
it('returns claim for reverse-claimable swaps', () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any), 'claim');
|
||||
});
|
||||
|
||||
it('returns claim for chain-claimable swaps', () => {
|
||||
isChainSwapClaimableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 'c1', type: 'chain', status: 'transaction.server.confirmed' } as any), 'claim');
|
||||
});
|
||||
|
||||
it('returns refund for submarine-refundable swaps', () => {
|
||||
isSubmarineSwapRefundableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 's1', type: 'submarine', status: 'transaction.lockupFailed' } as any), 'refund');
|
||||
});
|
||||
|
||||
it('returns refund for chain-refundable swaps', () => {
|
||||
isChainSwapRefundableMock.mockReturnValue(true);
|
||||
assert.strictEqual(resolveActionableAction({ id: 'c2', type: 'chain', status: 'transaction.server.refundable' } as any), 'refund');
|
||||
});
|
||||
|
||||
it('returns null when no predicate matches', () => {
|
||||
assert.strictEqual(resolveActionableAction({ id: 'x', type: 'reverse', status: 'swap.created' } as any), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyArkSwapActionable', () => {
|
||||
it('posts a notification with the expected payload for a reverse-claimable swap', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-A',
|
||||
'My Wallet',
|
||||
);
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 1);
|
||||
const notif = postLocalNotificationMock.mock.calls[0][0];
|
||||
assert.strictEqual(notif.payload.type, ARK_SWAP_NOTIFICATION_TYPE);
|
||||
assert.strictEqual(notif.payload.walletID, 'wallet-id-A');
|
||||
assert.strictEqual(notif.payload.swapId, 'r1');
|
||||
assert.strictEqual(notif.payload.action, 'claim');
|
||||
// Regression guard: namespace is intentionally absent from the OS payload
|
||||
// so the persistent notification record never carries a stable per-wallet
|
||||
// handle outside the encryption boundary.
|
||||
assert.strictEqual(Object.prototype.hasOwnProperty.call(notif.payload, 'namespace'), false);
|
||||
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 1);
|
||||
assert.strictEqual(suppression.record.mock.calls[0][0], 'r1');
|
||||
assert.strictEqual(suppression.record.mock.calls[0][1], 'claim');
|
||||
});
|
||||
|
||||
it('posts with action=refund for a submarine-refundable swap', async () => {
|
||||
isSubmarineSwapRefundableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 's1', type: 'submarine', status: 'transaction.lockupFailed' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-B',
|
||||
'Wallet B',
|
||||
);
|
||||
|
||||
const notif = postLocalNotificationMock.mock.calls[0][0];
|
||||
assert.strictEqual(notif.payload.action, 'refund');
|
||||
assert.strictEqual(suppression.record.mock.calls[0][1], 'refund');
|
||||
});
|
||||
|
||||
it('routes chain-claimable predicates to claim', async () => {
|
||||
isChainSwapClaimableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 'c1', type: 'chain', status: 'transaction.server.confirmed' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-C',
|
||||
'Wallet C',
|
||||
);
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls[0][0].payload.action, 'claim');
|
||||
});
|
||||
|
||||
it('routes chain-refundable predicates to refund', async () => {
|
||||
isChainSwapRefundableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable(
|
||||
{ id: 'c2', type: 'chain', status: 'transaction.server.refundable' } as any,
|
||||
suppression as any,
|
||||
'wallet-id-C',
|
||||
'Wallet C',
|
||||
);
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls[0][0].payload.action, 'refund');
|
||||
});
|
||||
|
||||
it('does not post when suppression already recorded for this swap+action', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
const suppression = makeSuppressionStub();
|
||||
suppression.has.mockImplementation(() => true);
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('does not post when AppState is active', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
notificationsTesting.setAppStateForTest('active');
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('does not post when no predicate matches', async () => {
|
||||
const suppression = makeSuppressionStub();
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'swap.created' } as any, suppression as any, 'w', 'L');
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('skips post AND suppression when OS permission is denied', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
notificationsTesting.setPermissionResultForTest('denied');
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('skips post AND suppression when app-level opt-out flag is set', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
notificationsTesting.setOptOutFlagForTest('true');
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(postLocalNotificationMock.mock.calls.length, 0);
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('does not write suppression when postLocalNotification throws', async () => {
|
||||
isReverseSwapClaimableMock.mockReturnValue(true);
|
||||
postLocalNotificationMock.mockImplementation(() => {
|
||||
throw new Error('post failed');
|
||||
});
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const suppression = makeSuppressionStub();
|
||||
|
||||
await notifyArkSwapActionable({ id: 'r1', type: 'reverse', status: 'transaction.confirmed' } as any, suppression as any, 'w', 'L');
|
||||
|
||||
assert.strictEqual(suppression.record.mock.calls.length, 0);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureArkNotificationChannel', () => {
|
||||
it('calls setNotificationChannel at most once across multiple invocations', () => {
|
||||
// The module-level channelEnsured guard was set true by the import above.
|
||||
// Reset and re-invoke to assert the guard suppresses the second call.
|
||||
notificationsTesting.resetChannel();
|
||||
setNotificationChannelMock.mockClear();
|
||||
|
||||
// The mocked Platform defaults to ios in the harness; force android.
|
||||
const ReactNative = require('react-native');
|
||||
const originalOS = ReactNative.Platform.OS;
|
||||
ReactNative.Platform.OS = 'android';
|
||||
|
||||
try {
|
||||
ensureArkNotificationChannel();
|
||||
ensureArkNotificationChannel();
|
||||
ensureArkNotificationChannel();
|
||||
assert.strictEqual(setNotificationChannelMock.mock.calls.length, 1);
|
||||
} finally {
|
||||
ReactNative.Platform.OS = originalOS;
|
||||
}
|
||||
});
|
||||
});
|
||||
212
tests/unit/arkade-realm.test.ts
Normal file
212
tests/unit/arkade-realm.test.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import {
|
||||
closeAllArkadeRealms,
|
||||
closeArkadeRealm,
|
||||
deleteArkadeRealm,
|
||||
getArkadeRealm,
|
||||
__testing__,
|
||||
} from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
|
||||
const Keychain = require('react-native-keychain');
|
||||
const Realm = require('realm');
|
||||
const RNFS = require('react-native-fs');
|
||||
|
||||
beforeEach(() => {
|
||||
Keychain.__mockKeychainHelpers.reset();
|
||||
Realm.__mockRealmHelpers.reset();
|
||||
RNFS.__mockFsHelpers.reset();
|
||||
Keychain.setGenericPassword.mockClear();
|
||||
Keychain.getGenericPassword.mockClear();
|
||||
Keychain.resetGenericPassword.mockClear();
|
||||
Keychain.getSecurityLevel.mockClear();
|
||||
Realm.open.mockClear();
|
||||
Realm.exists.mockClear();
|
||||
Realm.deleteFile.mockClear();
|
||||
// Reset adapter's internal cache between tests so each test starts cold.
|
||||
closeAllArkadeRealms();
|
||||
});
|
||||
|
||||
describe('arkade realm adapter', () => {
|
||||
it('opens distinct Realm files per namespace', async () => {
|
||||
const r1 = await getArkadeRealm('ns-one');
|
||||
const r2 = await getArkadeRealm('ns-two');
|
||||
|
||||
assert.notStrictEqual(r1, r2);
|
||||
assert.notStrictEqual(__testing__.realmPathFor('ns-one'), __testing__.realmPathFor('ns-two'));
|
||||
assert.notStrictEqual(__testing__.keychainServiceFor('ns-one'), __testing__.keychainServiceFor('ns-two'));
|
||||
});
|
||||
|
||||
it('returns the cached instance on repeat opens for the same namespace', async () => {
|
||||
const r1 = await getArkadeRealm('ns');
|
||||
const r2 = await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(r1, r2);
|
||||
assert.strictEqual(Realm.open.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent opens for the same namespace', async () => {
|
||||
const [r1, r2, r3] = await Promise.all([getArkadeRealm('ns'), getArkadeRealm('ns'), getArkadeRealm('ns')]);
|
||||
|
||||
assert.strictEqual(r1, r2);
|
||||
assert.strictEqual(r2, r3);
|
||||
assert.strictEqual(Realm.open.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('opens encrypted Realm with Ark + Boltz schemas', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
const config = Realm.open.mock.calls[0][0];
|
||||
assert.ok(Array.isArray(config.schema), 'schema is array');
|
||||
assert.ok(config.schema.length > 0, 'schema is non-empty');
|
||||
assert.ok(
|
||||
config.schema.some((s: any) => s.name === 'BoltzSwap'),
|
||||
'has BoltzSwap schema',
|
||||
);
|
||||
assert.ok(
|
||||
config.schema.some((s: any) => s.name === 'ArkVtxo'),
|
||||
'has ArkVtxo schema',
|
||||
);
|
||||
assert.ok(typeof config.schemaVersion === 'number', 'schemaVersion is a number');
|
||||
assert.ok(config.encryptionKey instanceof Uint8Array, 'encryptionKey is Uint8Array');
|
||||
assert.strictEqual(config.encryptionKey.length, 64, 'encryption key is 64 bytes');
|
||||
assert.strictEqual(config.excludeFromIcloudBackup, true);
|
||||
assert.ok(typeof config.onMigration === 'function', 'onMigration is a function');
|
||||
});
|
||||
|
||||
it('persists Realm encryption key per namespace and reuses it on reopen', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
closeArkadeRealm('ns');
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1, 'set called once');
|
||||
assert.ok(Keychain.getGenericPassword.mock.calls.length >= 2, 'get called at least twice');
|
||||
|
||||
const firstSet = Keychain.setGenericPassword.mock.calls[0];
|
||||
assert.strictEqual(firstSet[2].accessible, 'AccessibleWhenUnlockedThisDeviceOnly');
|
||||
assert.strictEqual(firstSet[2].service, __testing__.keychainServiceFor('ns'));
|
||||
});
|
||||
|
||||
it('reopens a fresh instance after closeArkadeRealm', async () => {
|
||||
const r1 = await getArkadeRealm('ns');
|
||||
closeArkadeRealm('ns');
|
||||
assert.strictEqual(r1.isClosed, true);
|
||||
|
||||
const r2 = await getArkadeRealm('ns');
|
||||
assert.notStrictEqual(r1, r2);
|
||||
assert.strictEqual(r2.isClosed, false);
|
||||
});
|
||||
|
||||
it('closeAllArkadeRealms closes every cached instance', async () => {
|
||||
const r1 = await getArkadeRealm('ns-a');
|
||||
const r2 = await getArkadeRealm('ns-b');
|
||||
|
||||
closeAllArkadeRealms();
|
||||
|
||||
assert.strictEqual(r1.isClosed, true);
|
||||
assert.strictEqual(r2.isClosed, true);
|
||||
});
|
||||
|
||||
it('deleteArkadeRealm closes Realm, removes Keychain entry, and clears cache', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
assert.ok(Keychain.__mockKeychainHelpers.store.has(__testing__.keychainServiceFor('ns')));
|
||||
|
||||
await deleteArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls.length, 1, 'Realm.deleteFile invoked');
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls[0][0].path, __testing__.realmPathFor('ns'));
|
||||
assert.ok(!Keychain.__mockKeychainHelpers.store.has(__testing__.keychainServiceFor('ns')), 'keychain entry removed');
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 1);
|
||||
|
||||
// Subsequent open creates a fresh keychain entry (rather than reusing the deleted one).
|
||||
Keychain.setGenericPassword.mockClear();
|
||||
await getArkadeRealm('ns');
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1, 'new key generated after delete');
|
||||
});
|
||||
|
||||
it('preserves Keychain encryption key when Realm file deletion fails', async () => {
|
||||
await getArkadeRealm('ns');
|
||||
Realm.deleteFile.mockImplementationOnce(() => {
|
||||
throw new Error('disk error');
|
||||
});
|
||||
|
||||
await deleteArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 0, 'keychain key preserved');
|
||||
assert.ok(Keychain.__mockKeychainHelpers.store.has(__testing__.keychainServiceFor('ns')), 'keychain entry still present');
|
||||
});
|
||||
|
||||
it('skips Realm.deleteFile when no realm file exists but still resets keychain', async () => {
|
||||
// Simulate a never-opened wallet whose keychain entry leaked. Realm.exists
|
||||
// returns false because we never called getArkadeRealm — but a stray
|
||||
// setGenericPassword still seeded Keychain.
|
||||
Keychain.__mockKeychainHelpers.store.set(__testing__.keychainServiceFor('ns'), {
|
||||
username: 'svc',
|
||||
password: 'deadbeef',
|
||||
service: __testing__.keychainServiceFor('ns'),
|
||||
});
|
||||
|
||||
await deleteArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls.length, 0, 'no file deletion attempted');
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 1, 'keychain still cleared');
|
||||
});
|
||||
|
||||
it('does not leak namespaces across wallets via shared keychain entries', async () => {
|
||||
await getArkadeRealm('walletA');
|
||||
await getArkadeRealm('walletB');
|
||||
|
||||
const services = Array.from(Keychain.__mockKeychainHelpers.store.keys());
|
||||
assert.ok(services.includes(__testing__.keychainServiceFor('walletA')));
|
||||
assert.ok(services.includes(__testing__.keychainServiceFor('walletB')));
|
||||
assert.notStrictEqual(services[0], services[1]);
|
||||
});
|
||||
|
||||
it('opts into SECURE_HARDWARE when getSecurityLevel reports it as supported', async () => {
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce('SECURE_HARDWARE');
|
||||
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls[0][2].securityLevel, 'SECURE_HARDWARE');
|
||||
});
|
||||
|
||||
it('omits securityLevel option when hardware-backed keystore is not supported', async () => {
|
||||
// Android device without TEE/StrongBox: getSecurityLevel returns SECURE_SOFTWARE.
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce('SECURE_SOFTWARE');
|
||||
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
assert.strictEqual(
|
||||
Keychain.setGenericPassword.mock.calls[0][2].securityLevel,
|
||||
undefined,
|
||||
'no securityLevel passed → react-native-keychain default',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits securityLevel option on iOS where getSecurityLevel returns null', async () => {
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce(null);
|
||||
|
||||
await getArkadeRealm('ns');
|
||||
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
assert.strictEqual(
|
||||
Keychain.setGenericPassword.mock.calls[0][2].securityLevel,
|
||||
undefined,
|
||||
'null preflight result → no securityLevel passed',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not silently downgrade when SECURE_HARDWARE setGenericPassword fails for unrelated reasons', async () => {
|
||||
Keychain.getSecurityLevel.mockResolvedValueOnce('SECURE_HARDWARE');
|
||||
Keychain.setGenericPassword.mockImplementationOnce(async () => {
|
||||
throw new Error('keystore write failed');
|
||||
});
|
||||
|
||||
await assert.rejects(getArkadeRealm('ns'), /keystore write failed/);
|
||||
|
||||
// Only one attempt — no fallback retry.
|
||||
assert.strictEqual(Keychain.setGenericPassword.mock.calls.length, 1);
|
||||
});
|
||||
});
|
||||
163
tests/unit/lightning-ark-derivation.test.ts
Normal file
163
tests/unit/lightning-ark-derivation.test.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { DelegateVtxo, networks } from '@arkade-os/sdk';
|
||||
|
||||
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet.ts';
|
||||
import { hexToUint8Array } from '../../blue_modules/uint8array-extras';
|
||||
import { resetArkadeTestState } from '../helpers/arkadeMocks';
|
||||
import { FAKE_ASP_INFO, FAKE_DELEGATE_PUBKEY, installSdkProviderSpies, restoreSdkProviderSpies } from '../helpers/sdkProviderMocks';
|
||||
|
||||
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
// Locked delegate-flavored Ark address for TEST_MNEMONIC against the canned
|
||||
// FAKE_ASP_INFO (mainnet signerPubkey, unilateralExitDelay = 605184) and the
|
||||
// canned FAKE_DELEGATE_PUBKEY (secp256k1 generator G in compressed form).
|
||||
//
|
||||
// What this pins:
|
||||
// - LightningArkWallet's BIP86 derivation path (m/86'/0'/0'/0/0).
|
||||
// - Wallet.secret -> identity wiring (the `arkade://` strip).
|
||||
// - The choice of DelegateVtxo (vs DefaultVtxo) when delegator is configured.
|
||||
// - The csvTimelock format (seconds) and value derived from
|
||||
// ASP `unilateralExitDelay`.
|
||||
// - The Arkade address encoding (mainnet `ark` HRP, server-pubkey carrier).
|
||||
//
|
||||
// What this does NOT pin: the production ASP/delegator state. The test runs
|
||||
// against canned inputs; Wallet.create's runtime trust path is exercised by
|
||||
// the regression suite at integration time.
|
||||
//
|
||||
// Any change to identity path, delegate enable/disable, or DelegateVtxo.Script
|
||||
// construction must break this assertion. To regenerate after a deliberate
|
||||
// change: replace EXPECTED with `'__CAPTURE_ME__'`, run the test, copy the
|
||||
// printed address, paste it here, lock again.
|
||||
const EXPECTED_ARK_ADDRESS =
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t47e8x64mwqpvyvaxefy4ennzfu3qxrnarqx9l3x4rspjavwj04af6kepxv';
|
||||
const EXPECTED_NAMESPACE = 'e13b00f781e8dfc57f8f2a936220ff24d132eaaf8c85d4b10b5337645085ee9a';
|
||||
|
||||
beforeEach(() => {
|
||||
resetArkadeTestState();
|
||||
});
|
||||
|
||||
describe('LightningArkWallet derivation regression', () => {
|
||||
it('derives the locked delegate-flavored Ark address for the test mnemonic', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
|
||||
// Faithfully replicates the SDK's setupWalletConfig recipe:
|
||||
// serverPubKey = hex.decode(info.signerPubkey).slice(1)
|
||||
// delegatePubKey = hex.decode(delegateInfo.pubkey).slice(1)
|
||||
// csvTimelock = delayToTimelock(info.unilateralExitDelay)
|
||||
// offchainTapscript = new DelegateVtxo.Script({pubKey, serverPubKey, delegatePubKey, csvTimelock})
|
||||
// arkAddress = offchainTapscript.address(network.hrp, serverPubKey).encode()
|
||||
const identity = w._getIdentity();
|
||||
const userPubKey = await identity.xOnlyPublicKey();
|
||||
const serverPubKey = hexToUint8Array(FAKE_ASP_INFO.signerPubkey).slice(1);
|
||||
const delegatePubKey = hexToUint8Array(FAKE_DELEGATE_PUBKEY).slice(1);
|
||||
|
||||
const offchainTapscript = new DelegateVtxo.Script({
|
||||
pubKey: userPubKey,
|
||||
serverPubKey,
|
||||
delegatePubKey,
|
||||
csvTimelock: { value: BigInt(FAKE_ASP_INFO.unilateralExitDelay), type: 'seconds' },
|
||||
});
|
||||
|
||||
const address = offchainTapscript.address(networks.bitcoin.hrp, serverPubKey).encode();
|
||||
|
||||
assert.strictEqual(address, EXPECTED_ARK_ADDRESS);
|
||||
});
|
||||
|
||||
it('produces a different address with a different delegate pubkey', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
|
||||
const identity = w._getIdentity();
|
||||
const userPubKey = await identity.xOnlyPublicKey();
|
||||
const serverPubKey = hexToUint8Array(FAKE_ASP_INFO.signerPubkey).slice(1);
|
||||
|
||||
const csvTimelock = { value: BigInt(FAKE_ASP_INFO.unilateralExitDelay), type: 'seconds' as const };
|
||||
|
||||
const withDelegateG = new DelegateVtxo.Script({
|
||||
pubKey: userPubKey,
|
||||
serverPubKey,
|
||||
delegatePubKey: hexToUint8Array(FAKE_DELEGATE_PUBKEY).slice(1),
|
||||
csvTimelock,
|
||||
})
|
||||
.address(networks.bitcoin.hrp, serverPubKey)
|
||||
.encode();
|
||||
|
||||
// 2*G in compressed form — a different on-curve pubkey.
|
||||
const altDelegate = hexToUint8Array('02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5').slice(1);
|
||||
const withDelegate2G = new DelegateVtxo.Script({
|
||||
pubKey: userPubKey,
|
||||
serverPubKey,
|
||||
delegatePubKey: altDelegate,
|
||||
csvTimelock,
|
||||
})
|
||||
.address(networks.bitcoin.hrp, serverPubKey)
|
||||
.encode();
|
||||
|
||||
assert.notStrictEqual(withDelegateG, withDelegate2G);
|
||||
});
|
||||
|
||||
it('locks the namespace value so a derivation tweak cannot silently re-key Realm', () => {
|
||||
// BlueWallet's per-wallet Realm path is keyed by hashIt(secret). A change
|
||||
// in this hash splits a wallet from its existing Realm file and Keychain
|
||||
// entry, stranding funds. Pin it.
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
const namespace = w.getNamespace();
|
||||
assert.strictEqual(namespace, EXPECTED_NAMESPACE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LightningArkWallet derivation regression — full init pipeline', () => {
|
||||
// The algorithmic test above replicates the SDK's setupWalletConfig recipe
|
||||
// inline. That catches SDK-level drift but not BlueWallet wiring drift —
|
||||
// e.g., dropping `delegatorProvider` from Wallet.create at
|
||||
// class/wallets/lightning-ark-wallet.ts:139 would leave the algorithmic
|
||||
// test green even though the running wallet would now derive a
|
||||
// DefaultVtxo address. This block actually runs `wallet.init()` against
|
||||
// the canned providers and asserts the address matches the pinned value,
|
||||
// so any wiring change at the call site is caught.
|
||||
//
|
||||
// Offline init requires three mock layers:
|
||||
// - SDK provider spies (getInfo, getDelegateInfo, fees, limits) so no
|
||||
// HTTP traffic;
|
||||
// - VtxoManager.initializeSubscription stub so the SDK doesn't open SSE
|
||||
// subscriptions or schedule polling timers from its constructor;
|
||||
// - resetArkadeTestState() in afterEach BEFORE restoring spies so the
|
||||
// wallet's setTimeout(VTXO renewal, 1s) — registered at the end of
|
||||
// init — finds an empty staticWalletCache when it eventually fires
|
||||
// and short-circuits before reaching SDK methods that no longer have
|
||||
// stubs installed.
|
||||
beforeEach(() => {
|
||||
// Fake timers prevent the wallet's `setTimeout(VTXO renewal, 1s)` at the
|
||||
// tail of init() from leaking past the test (Jest force-kills the worker
|
||||
// otherwise). The full-init derivation does not depend on any real
|
||||
// timer firing — it returns synchronously after Wallet.create. Modern
|
||||
// fake timers leave the promise microtask queue untouched, so awaits
|
||||
// still resolve normally.
|
||||
jest.useFakeTimers();
|
||||
installSdkProviderSpies();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
// Order matters: clear caches before restoring prototype spies. If the
|
||||
// wallet's setTimeout had still been live, an empty staticWalletCache
|
||||
// would short-circuit its callback before reaching SDK methods that no
|
||||
// longer have stubs installed.
|
||||
resetArkadeTestState();
|
||||
restoreSdkProviderSpies();
|
||||
});
|
||||
|
||||
it('init() + getArkAddress() returns the locked address (catches delegatorProvider wiring drift)', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
|
||||
await w.init();
|
||||
const address = await w.getArkAddress();
|
||||
|
||||
assert.strictEqual(address, EXPECTED_ARK_ADDRESS);
|
||||
});
|
||||
});
|
||||
124
tests/unit/lightning-ark-wallet-onDelete.test.ts
Normal file
124
tests/unit/lightning-ark-wallet-onDelete.test.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { LightningArkWallet, __testing__ as walletTesting } from '../../class/wallets/lightning-ark-wallet.ts';
|
||||
import { __testing__ as realmTesting } from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
|
||||
const Realm = require('realm');
|
||||
const Keychain = require('react-native-keychain');
|
||||
|
||||
const TEST_SECRET = 'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear module-private wallet caches between tests so namespaces start cold.
|
||||
for (const k of Object.keys(walletTesting.staticWalletCache)) delete walletTesting.staticWalletCache[k];
|
||||
for (const k of Object.keys(walletTesting.staticSwapsCache)) delete walletTesting.staticSwapsCache[k];
|
||||
walletTesting.initInFlight.clear();
|
||||
for (const k of Object.keys(walletTesting.boardingLock)) delete walletTesting.boardingLock[k];
|
||||
|
||||
Realm.__mockRealmHelpers.reset();
|
||||
Keychain.__mockKeychainHelpers.reset();
|
||||
Realm.deleteFile.mockClear();
|
||||
Realm.exists.mockClear();
|
||||
Keychain.resetGenericPassword.mockClear();
|
||||
});
|
||||
|
||||
describe('LightningArkWallet.onDelete', () => {
|
||||
it('clears caches and Realm/Keychain when no init is in flight', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret(TEST_SECRET);
|
||||
const namespace = w.getNamespace();
|
||||
|
||||
// Pretend a previous init populated caches.
|
||||
walletTesting.staticWalletCache[namespace] = { tag: 'wallet' } as any;
|
||||
walletTesting.staticSwapsCache[namespace] = { tag: 'swaps' } as any;
|
||||
walletTesting.boardingLock[namespace] = true;
|
||||
Keychain.__mockKeychainHelpers.store.set(realmTesting.keychainServiceFor(namespace), {
|
||||
username: 'svc',
|
||||
password: 'beef',
|
||||
service: realmTesting.keychainServiceFor(namespace),
|
||||
});
|
||||
|
||||
await w.onDelete();
|
||||
|
||||
assert.strictEqual(walletTesting.staticWalletCache[namespace], undefined);
|
||||
assert.strictEqual(walletTesting.staticSwapsCache[namespace], undefined);
|
||||
assert.strictEqual(walletTesting.boardingLock[namespace], undefined);
|
||||
assert.ok(!walletTesting.initInFlight.has(namespace));
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('drains in-flight init and undoes its cache resurrection (race fix)', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret(TEST_SECRET);
|
||||
const namespace = w.getNamespace();
|
||||
|
||||
// Inject a controlled in-flight init so we can interleave onDelete with its tail.
|
||||
let resolveInit!: (v: { wallet: any; arkadeSwaps: any }) => void;
|
||||
const inFlight = new Promise<{ wallet: any; arkadeSwaps: any }>(resolve => {
|
||||
resolveInit = resolve;
|
||||
});
|
||||
|
||||
// Simulate the init IIFE's tail: when Wallet.create resolves, the IIFE
|
||||
// synchronously writes the static caches. We register this BEFORE onDelete
|
||||
// attaches its own .then so it fires first when inFlight settles.
|
||||
inFlight.then(({ wallet, arkadeSwaps }) => {
|
||||
walletTesting.staticWalletCache[namespace] = wallet;
|
||||
walletTesting.staticSwapsCache[namespace] = arkadeSwaps;
|
||||
});
|
||||
// Simulate init()'s outer continuation that re-assigns this._wallet on the
|
||||
// wallet instance. This is the resurrection that the bug reproduces.
|
||||
inFlight.then(({ wallet, arkadeSwaps }) => {
|
||||
(w as any)._wallet = wallet;
|
||||
(w as any)._arkadeSwaps = arkadeSwaps;
|
||||
});
|
||||
|
||||
walletTesting.initInFlight.set(namespace, inFlight);
|
||||
|
||||
// Kick off onDelete; before inFlight resolves, deletion must not have
|
||||
// started — onDelete is awaiting the drain.
|
||||
const onDeletePromise = w.onDelete();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls.length, 0, 'deleteArkadeRealm must not run before in-flight init settles');
|
||||
|
||||
// Resolve inFlight: simulated IIFE tail writes caches, simulated init outer
|
||||
// continuation re-assigns this._wallet, then onDelete continues.
|
||||
resolveInit({ wallet: { tag: 'racy-wallet' }, arkadeSwaps: { tag: 'racy-swaps' } });
|
||||
|
||||
await onDeletePromise;
|
||||
|
||||
// The drain ensures caches are cleared after the resurrection, not before.
|
||||
assert.strictEqual(walletTesting.staticWalletCache[namespace], undefined, 'staticWalletCache cleared after drain');
|
||||
assert.strictEqual(walletTesting.staticSwapsCache[namespace], undefined, 'staticSwapsCache cleared after drain');
|
||||
assert.strictEqual((w as any)._wallet, undefined, 'wallet instance _wallet cleared after drain');
|
||||
assert.strictEqual((w as any)._arkadeSwaps, undefined, 'wallet instance _arkadeSwaps cleared after drain');
|
||||
assert.ok(!walletTesting.initInFlight.has(namespace), 'in-flight entry removed');
|
||||
});
|
||||
|
||||
it('survives in-flight init that rejects', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret(TEST_SECRET);
|
||||
const namespace = w.getNamespace();
|
||||
|
||||
const inFlight = Promise.reject(new Error('init blew up'));
|
||||
// Attach a no-op handler so Node does not flag this as an unhandled rejection
|
||||
// before onDelete has a chance to await it.
|
||||
inFlight.catch(() => {});
|
||||
walletTesting.initInFlight.set(namespace, inFlight);
|
||||
|
||||
await w.onDelete();
|
||||
|
||||
assert.ok(!walletTesting.initInFlight.has(namespace));
|
||||
assert.strictEqual((w as any)._wallet, undefined);
|
||||
});
|
||||
|
||||
it('is a no-op when secret is unset', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
// No setSecret call; w.secret === ''.
|
||||
|
||||
await w.onDelete();
|
||||
|
||||
assert.strictEqual(Realm.deleteFile.mock.calls.length, 0);
|
||||
assert.strictEqual(Keychain.resetGenericPassword.mock.calls.length, 0);
|
||||
});
|
||||
});
|
||||
1450
tests/unit/lightning-ark-wallet.test.ts
Normal file
1450
tests/unit/lightning-ark-wallet.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -33,7 +33,7 @@ jest.mock('../../hooks/useWalletSubscribe', () => ({
|
||||
default: () => mockWalletSubscribe,
|
||||
}));
|
||||
|
||||
const routeParams = { hash: 'mock-tx', walletID: 'mock-wallet' };
|
||||
let routeParams: any = { hash: 'mock-tx', walletID: 'mock-wallet' };
|
||||
|
||||
jest.mock('@react-navigation/native', () => {
|
||||
const actual = jest.requireActual('@react-navigation/native');
|
||||
@ -226,6 +226,7 @@ describe('TransactionStatus regression', () => {
|
||||
saveToDisk: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
mockWalletSubscribe = null;
|
||||
routeParams = { hash: 'mock-tx', walletID: 'mock-wallet' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -276,4 +277,31 @@ describe('TransactionStatus regression', () => {
|
||||
{ type: 'plain-text', defaultValue: existingMemo }, // defaultValue: pre-fill input for easy editing
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an Arkade row as received (not pending) and never queries Electrum for its synthetic id', async () => {
|
||||
const BlueElectrum = require('../../blue_modules/BlueElectrum');
|
||||
const arkRow = { txid: 'ark-deadbeef', type: 'bitcoind_tx', value: 1200, walletID: 'mock-wallet', timestamp: 1700000000 };
|
||||
routeParams = { tx: arkRow, hash: 'ark-deadbeef', walletID: 'mock-wallet' };
|
||||
|
||||
const walletMock = {
|
||||
getID: () => 'mock-wallet',
|
||||
getTransactions: jest.fn(() => [arkRow]),
|
||||
getLastTxFetch: jest.fn(() => 1000),
|
||||
allowRBF: jest.fn(() => false),
|
||||
preferredBalanceUnit: 'BTC',
|
||||
} as any;
|
||||
mockStorageState = { ...mockStorageState, wallets: [walletMock] };
|
||||
mockWalletSubscribe = walletMock;
|
||||
|
||||
const view = render(<TransactionStatus />);
|
||||
|
||||
// #1: the row shows its real direction (received), not a false "Pending".
|
||||
await waitFor(() => {
|
||||
expect(view.getByText('received')).toBeTruthy();
|
||||
});
|
||||
// #1/#3: no "confirmations" sub-value for an off-chain row (would have rendered "NaN confirmations").
|
||||
expect(view.queryByText(/confirmations/)).toBeNull();
|
||||
// #2: the synthetic id is never handed to Electrum (the source of "hash ark-… not found").
|
||||
expect(BlueElectrum.multiGetTransactionByTxid).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
67
tests/unit/transactionDisplayState.test.ts
Normal file
67
tests/unit/transactionDisplayState.test.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { isOnChainTransaction, resolveTxDisplayState } from '../../blue_modules/transactionDisplayState';
|
||||
|
||||
describe('transactionDisplayState', () => {
|
||||
describe('isOnChainTransaction', () => {
|
||||
it('is true only when a non-empty hash string is present', () => {
|
||||
assert.strictEqual(isOnChainTransaction({ hash: 'abc123' }), true);
|
||||
assert.strictEqual(isOnChainTransaction({ hash: '' }), false);
|
||||
assert.strictEqual(isOnChainTransaction({ txid: 'ark-deadbeef' }), false);
|
||||
assert.strictEqual(isOnChainTransaction({}), false);
|
||||
assert.strictEqual(isOnChainTransaction(null), false);
|
||||
assert.strictEqual(isOnChainTransaction(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTxDisplayState — on-chain (real hash present)', () => {
|
||||
it('confirmed receive → received', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', confirmations: 3, value: 1000 }), 'received');
|
||||
});
|
||||
it('confirmed send → sent', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', confirmations: 6, value: -1000 }), 'sent');
|
||||
});
|
||||
it('zero confirmations → pending', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', confirmations: 0, value: -1000 }), 'pending');
|
||||
});
|
||||
it('missing confirmations → pending (matches the !confirmations fallback)', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', value: 500 }), 'pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTxDisplayState — off-chain Ark/Lightning (no hash)', () => {
|
||||
it('native Ark receive (bitcoind_tx, positive) → received', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ txid: 'ark-deadbeef', type: 'bitcoind_tx', value: 5000 }), 'received');
|
||||
});
|
||||
it('native Ark send (bitcoind_tx, negative) → sent', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ txid: 'ark-deadbeef', type: 'bitcoind_tx', value: -5000 }), 'sent');
|
||||
});
|
||||
it('refill (boarding-, positive) → received', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ txid: 'boarding-deadbeef', type: 'bitcoind_tx', value: 5000 }), 'received');
|
||||
});
|
||||
it('pending refill (boarding-utxo-, positive) → pending (parity with the list)', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ txid: 'boarding-utxo-deadbeef:0', type: 'bitcoind_tx', value: 5000 }), 'pending');
|
||||
});
|
||||
it('a native Ark leg (ark-) is never pending, even with no confirmations field', () => {
|
||||
assert.notStrictEqual(resolveTxDisplayState({ txid: 'ark-x', type: 'bitcoind_tx', value: 1 }), 'pending');
|
||||
});
|
||||
it('a settled refill (boarding-) is a confirmed receive, not pending', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ txid: 'boarding-deadbeef', type: 'bitcoind_tx', value: 5000 }), 'received');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTxDisplayState — defensive invoice rows (route to LNDViewInvoice today)', () => {
|
||||
it('paid_invoice → sent', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ type: 'paid_invoice', value: -5000 }), 'sent');
|
||||
});
|
||||
it('unpaid user_invoice → pending', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ type: 'user_invoice', ispaid: false, value: 5000 }), 'pending');
|
||||
});
|
||||
it('paid user_invoice → received', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ type: 'user_invoice', ispaid: true, value: 5000 }), 'received');
|
||||
});
|
||||
it('unpaid payment_request → pending', () => {
|
||||
assert.strictEqual(resolveTxDisplayState({ type: 'payment_request', ispaid: false, value: 5000 }), 'pending');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user