Compare commits
24 Commits
master
...
ArkLabsHQ-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900853f7f2 | ||
|
|
1942071063 | ||
|
|
0f52e8d62c | ||
|
|
09a152e3c7 | ||
|
|
9b8b96b8e3 | ||
|
|
93e21d7433 | ||
|
|
c23f5afdc6 | ||
|
|
a53a49963e | ||
|
|
5c660dcebb | ||
|
|
bab87a04d0 | ||
|
|
e2f3c8d171 | ||
|
|
8d8b6669fb | ||
|
|
6a038424d1 | ||
|
|
7c733fa7fa | ||
|
|
747680445f | ||
|
|
a7beac4df2 | ||
|
|
73115d86aa | ||
|
|
4e384a1cd3 | ||
|
|
57688c8038 | ||
|
|
364fdee549 | ||
|
|
0ad4ade527 | ||
|
|
b58c67d661 | ||
|
|
5702eb2082 | ||
|
|
d154b9fb6c |
@ -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,
|
||||
};
|
||||
413
blue_modules/arkade-background.ts
Normal file
413
blue_modules/arkade-background.ts
Normal file
@ -0,0 +1,413 @@
|
||||
// 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);
|
||||
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> {
|
||||
try {
|
||||
await BackgroundFetch.stop();
|
||||
} catch (e: any) {
|
||||
recordError(`stop: ${e?.message ?? e}`);
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
@ -218,6 +218,16 @@ const startImport = (
|
||||
ark.setSecret(text);
|
||||
await ark.init();
|
||||
if (!offline) {
|
||||
// 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);
|
||||
}
|
||||
await ark.fetchBalance();
|
||||
await ark.fetchTransactions();
|
||||
}
|
||||
|
||||
@ -1,10 +1,22 @@
|
||||
import BigNumber from 'bignumber.js';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { ArkadeLightning, BoltzSwapProvider, decodeInvoice, PendingReverseSwap, PendingSubmarineSwap } from '@arkade-os/boltz-swap';
|
||||
import { SingleKey, VtxoManager, Ramps, Wallet, ExtendedCoin, ArkTransaction } from '@arkade-os/sdk';
|
||||
import {
|
||||
ArkadeSwaps,
|
||||
BoltzReverseSwap,
|
||||
BoltzSubmarineSwap,
|
||||
BoltzSwap,
|
||||
BoltzSwapProvider,
|
||||
SubmarineRefundOutcome,
|
||||
decodeInvoice,
|
||||
isChainSwapClaimable,
|
||||
isChainSwapRefundable,
|
||||
isReverseSwapClaimable,
|
||||
isSubmarineSwapRefundable,
|
||||
} from '@arkade-os/boltz-swap';
|
||||
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
import { RestDelegatorProvider, SingleKey, Wallet, ExtendedCoin, ArkTransaction, TxType } from '@arkade-os/sdk';
|
||||
import { ExpoArkProvider, ExpoIndexerProvider } from '@arkade-os/sdk/adapters/expo';
|
||||
import { fetch } from '../../util/fetch';
|
||||
import { RealmContractRepository, RealmWalletRepository } from '@arkade-os/sdk/repositories/realm';
|
||||
|
||||
import BIP32Factory from 'bip32';
|
||||
|
||||
@ -16,13 +28,43 @@ import { hexToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-
|
||||
import assert from 'assert';
|
||||
import ecc from '../../blue_modules/noble_ecc.ts';
|
||||
import { Measure } from '../measure.ts';
|
||||
import { deleteArkadeRealm, getArkadeRealm } from '../../blue_modules/arkade-adapters/realm/realmInstance';
|
||||
const { bech32m } = require('bech32');
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
// Delegate-service URL per Ark network. Mirrors the canonical wallet's map
|
||||
// (../master/wallet/src/lib/constants.ts:27): mainnet has a delegator,
|
||||
// mutinynet/regtest each have their own, and signet/testnet have none — for
|
||||
// those we must skip `delegatorProvider` on Wallet.create entirely instead of
|
||||
// falling back to the mainnet URL, which would build the wrong offchain
|
||||
// tapscript and hide funds from the indexer.
|
||||
const DELEGATOR_URLS = {
|
||||
bitcoin: 'https://delegate.arkade.money',
|
||||
mutinynet: 'https://delegator.mutinynet.arkade.sh',
|
||||
regtest: 'http://localhost:7012',
|
||||
signet: null,
|
||||
testnet: null,
|
||||
} as const;
|
||||
|
||||
const staticWalletCache: Record<string, Wallet> = {};
|
||||
const initLock: Record<string, boolean> = {};
|
||||
const staticSwapsCache: Record<string, ArkadeSwaps> = {};
|
||||
const initInFlight: Map<string, Promise<{ wallet: Wallet; arkadeSwaps: ArkadeSwaps }>> = new Map();
|
||||
const boardingLock: Record<string, boolean> = {};
|
||||
// Coalesce concurrent restoreSwaps() calls per namespace so a manual tap
|
||||
// during init (or two screens triggering it together) does not double-fetch
|
||||
// from Boltz.
|
||||
const restoreInFlight: Map<string, Promise<void>> = new Map();
|
||||
|
||||
// Test-only: exposes module-private caches so unit tests can observe / reset
|
||||
// them and verify deletion-vs-init race behavior. Not part of the public API.
|
||||
export const __testing__ = {
|
||||
staticWalletCache,
|
||||
staticSwapsCache,
|
||||
initInFlight,
|
||||
boardingLock,
|
||||
restoreInFlight,
|
||||
};
|
||||
|
||||
export class LightningArkWallet extends LightningCustodianWallet {
|
||||
static readonly type = 'lightningArkWallet';
|
||||
@ -33,32 +75,49 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
// @ts-ignore: override
|
||||
public readonly typeReadable = LightningArkWallet.typeReadable;
|
||||
|
||||
// Runtime SDK objects. The constructor re-defines these as non-enumerable so
|
||||
// saveToDisk's `Object.assign({}, key)` skips them and JSON.stringify never
|
||||
// sees a partially-initialized SDK snapshot. We avoid the `declare` modifier
|
||||
// here because @babel/preset-typescript in the React Native pipeline requires
|
||||
// `allowDeclareFields: true` for it, and tightening that setting is out of
|
||||
// scope.
|
||||
private _wallet: Wallet | undefined;
|
||||
private _arkadeLightning: ArkadeLightning | undefined = undefined;
|
||||
private _arkServerUrl: string = 'https://arkade.computer';
|
||||
private _arkServerPublicKey: string = '022b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aaba';
|
||||
private _boltzApiUrl: string = 'https://api.ark.boltz.exchange';
|
||||
private _arkadeSwaps: ArkadeSwaps | undefined;
|
||||
// sha256(secret) is cheap but getNamespace is called on every init, delete,
|
||||
// boarding poll, and background-task pass. Memoize keyed by `secret` so a
|
||||
// future setSecret() with a different mnemonic self-invalidates without us
|
||||
// having to override the inherited setter. Defined non-enumerable in the
|
||||
// constructor for the same saveToDisk serialization reason as the SDK refs.
|
||||
private _namespaceCache: { secret: string; namespace: string } | undefined;
|
||||
|
||||
private _swapHistory: (PendingReverseSwap | PendingSubmarineSwap)[] = [];
|
||||
private _arkServerUrl: string = 'https://arkade.computer';
|
||||
// Network this wallet speaks. Drives the delegator URL lookup below; today
|
||||
// the Ark server URL is fixed to mainnet, so this is always 'bitcoin', but
|
||||
// the indirection keeps a future testnet/mutinynet/regtest switch from
|
||||
// silently shipping the mainnet delegator URL to the wrong network.
|
||||
private _network: keyof typeof DELEGATOR_URLS = 'bitcoin';
|
||||
|
||||
private _swapHistory: BoltzSwap[] = [];
|
||||
private _transactionsHistory: ArkTransaction[] = [];
|
||||
private _claimedSwaps: Record<string, boolean> = {};
|
||||
private _privateKeyCache = '';
|
||||
private _boardingUtxos: ExtendedCoin[] = [];
|
||||
|
||||
// fees from Boltz:
|
||||
// limits/fees from Boltz reverse-swap (Lightning → Arkade) bracket:
|
||||
private _limitMin: number = 0;
|
||||
private _limitMax: number = 0;
|
||||
private _feePercentage: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
Object.defineProperty(this, '_wallet', { value: undefined, writable: true, enumerable: false, configurable: true });
|
||||
Object.defineProperty(this, '_arkadeSwaps', { value: undefined, writable: true, enumerable: false, configurable: true });
|
||||
Object.defineProperty(this, '_namespaceCache', { value: undefined, writable: true, enumerable: false, configurable: true });
|
||||
}
|
||||
|
||||
hashIt = (s: string): string => {
|
||||
return uint8ArrayToHex(sha256(s));
|
||||
};
|
||||
|
||||
prepareForSerialization() {
|
||||
this._wallet = undefined;
|
||||
this._arkadeLightning = undefined;
|
||||
}
|
||||
|
||||
_getIdentity() {
|
||||
assert(this.secret, 'No secret provided');
|
||||
|
||||
@ -82,112 +141,136 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
|
||||
getNamespace(): string {
|
||||
assert(this.secret, 'No secret provided');
|
||||
return this.hashIt(this.secret);
|
||||
if (this._namespaceCache?.secret === this.secret) return this._namespaceCache.namespace;
|
||||
const namespace = this.hashIt(this.secret);
|
||||
this._namespaceCache = { secret: this.secret, namespace };
|
||||
return namespace;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const namespace = this.getNamespace();
|
||||
|
||||
if (initLock[namespace]) {
|
||||
let c = 0;
|
||||
while (!this._wallet || !this._arkadeLightning) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // sleep
|
||||
if (c++ > 30) {
|
||||
throw new Error('Ark wallet initialization timed out');
|
||||
}
|
||||
}
|
||||
initLock[namespace] = false;
|
||||
return; // wallet is initialized, so we can return
|
||||
if (this._wallet && this._arkadeSwaps) return;
|
||||
|
||||
const cachedWallet = staticWalletCache[namespace];
|
||||
const cachedSwaps = staticSwapsCache[namespace];
|
||||
if (cachedWallet && cachedSwaps) {
|
||||
this._wallet = cachedWallet;
|
||||
this._arkadeSwaps = cachedSwaps;
|
||||
if (!this._limitMin || !this._limitMax) await this._fetchLightningFeesAndLimits();
|
||||
return;
|
||||
}
|
||||
|
||||
initLock[namespace] = true;
|
||||
let inFlight = initInFlight.get(namespace);
|
||||
if (!inFlight) {
|
||||
inFlight = (async () => {
|
||||
const realm = await getArkadeRealm(namespace);
|
||||
const walletRepository = new RealmWalletRepository(realm as any);
|
||||
const contractRepository = new RealmContractRepository(realm as any);
|
||||
const swapRepository = new RealmSwapRepository(realm as any);
|
||||
|
||||
try {
|
||||
const identity = this._getIdentity();
|
||||
|
||||
class ArkCustomStorage {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
return await AsyncStorage.getItem(`${namespace}_${key}`);
|
||||
// Resolve the delegator URL up front and preflight it. A mismatched
|
||||
// URL silently builds the wrong offchain tapscript, and a flaky
|
||||
// delegator turns into a generic mid-init Wallet.create rejection.
|
||||
// Networks with no delegator (signet/testnet) skip the provider
|
||||
// entirely.
|
||||
const delegatorUrl = DELEGATOR_URLS[this._network];
|
||||
let delegatorProvider: RestDelegatorProvider | undefined;
|
||||
if (delegatorUrl !== null) {
|
||||
delegatorProvider = new RestDelegatorProvider(delegatorUrl);
|
||||
try {
|
||||
await delegatorProvider.getDelegateInfo();
|
||||
} catch (e: any) {
|
||||
throw new Error(`Delegate service unreachable (${delegatorUrl}): ${e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
return await AsyncStorage.setItem(`${namespace}_${key}`, value);
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
await AsyncStorage.removeItem(`${namespace}_${key}`);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
// nop
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new ArkCustomStorage();
|
||||
|
||||
const mm = new Measure('Wallet.create()');
|
||||
if (!staticWalletCache[namespace]) {
|
||||
const mm = new Measure('Wallet.create()');
|
||||
const wallet = await Wallet.create({
|
||||
storage,
|
||||
identity,
|
||||
identity: this._getIdentity(),
|
||||
arkProvider: new ExpoArkProvider(this._arkServerUrl),
|
||||
indexerProvider: new ExpoIndexerProvider(this._arkServerUrl),
|
||||
arkServerPublicKey: this._arkServerPublicKey,
|
||||
storage: { walletRepository, contractRepository },
|
||||
delegatorProvider,
|
||||
});
|
||||
staticWalletCache[namespace] = wallet;
|
||||
}
|
||||
mm.end();
|
||||
|
||||
mm.end();
|
||||
this._wallet = staticWalletCache[namespace];
|
||||
// apiUrl omitted: @arkade-os/boltz-swap defaults to the production
|
||||
// mainnet URL when network is 'bitcoin'.
|
||||
const swapProvider = new BoltzSwapProvider({ network: 'bitcoin', referralId: 'arkade-blue-wallet' });
|
||||
|
||||
await this._initLightningSwaps();
|
||||
|
||||
// initialize VTXO manager in set timeout so it doesnt block the wallet initialization
|
||||
setTimeout(async () => {
|
||||
const manager = new VtxoManager(staticWalletCache[namespace], {
|
||||
enabled: true, // Enable expiration monitoring
|
||||
const arkadeSwaps = new ArkadeSwaps({
|
||||
wallet,
|
||||
swapProvider,
|
||||
swapRepository,
|
||||
});
|
||||
staticSwapsCache[namespace] = arkadeSwaps;
|
||||
|
||||
// Push refresh on swap lifecycle events so balance and history
|
||||
// reflect SwapManager's autonomous claim/refund actions without
|
||||
// waiting for the next user-driven fetchBalance tick.
|
||||
this._subscribeToSwapEvents(arkadeSwaps);
|
||||
|
||||
return { wallet, arkadeSwaps };
|
||||
})();
|
||||
|
||||
initInFlight.set(namespace, inFlight);
|
||||
inFlight
|
||||
.finally(() => {
|
||||
if (initInFlight.get(namespace) === inFlight) initInFlight.delete(namespace);
|
||||
})
|
||||
.catch(() => {
|
||||
// The same rejection is delivered to `await inFlight` callers below; silence here so
|
||||
// the discarded cleanup chain does not become an unhandled rejection.
|
||||
});
|
||||
try {
|
||||
const expiringVtxos = await manager.getExpiringVtxos();
|
||||
if (expiringVtxos.length > 0) {
|
||||
console.log(`ARK renewing ${expiringVtxos.length} expiring VTXOs...`);
|
||||
const renewTxid = await manager.renewVtxos();
|
||||
console.log('ARK VTXO renewed:', renewTxid);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log('ARK Error renewing VTXOs:', error.message);
|
||||
}
|
||||
}, 1_000);
|
||||
} finally {
|
||||
initLock[namespace] = false;
|
||||
}
|
||||
|
||||
const { wallet, arkadeSwaps } = await inFlight;
|
||||
this._wallet = wallet;
|
||||
this._arkadeSwaps = arkadeSwaps;
|
||||
|
||||
if (!this._limitMin || !this._limitMax) await this._fetchLightningFeesAndLimits();
|
||||
}
|
||||
|
||||
async _initLightningSwaps() {
|
||||
assert(this._wallet, 'Ark wallet must be initialized first');
|
||||
assert(this._boltzApiUrl, 'Boltz Api Url is not set');
|
||||
private _subscribeToSwapEvents(arkadeSwaps: ArkadeSwaps) {
|
||||
const swapManager = arkadeSwaps.getSwapManager();
|
||||
if (!swapManager) return;
|
||||
|
||||
// fetching fees boltz takes:
|
||||
const feesResponse = await fetch(this._boltzApiUrl + '/v2/swap/submarine');
|
||||
const feesResponseJson = await feesResponse.json();
|
||||
this._limitMin = feesResponseJson?.ARK?.BTC?.limits?.minimal ?? 333;
|
||||
this._limitMax = feesResponseJson?.ARK?.BTC?.limits?.maximal ?? 1000000;
|
||||
this._feePercentage = feesResponseJson?.ARK?.BTC?.fees?.percentage ?? 0;
|
||||
if (!feesResponseJson?.ARK?.BTC?.fees?.percentage) {
|
||||
console.log('warning: unexpected fees response from boltz:', JSON.stringify(feesResponseJson, null, 2));
|
||||
const refresh = async () => {
|
||||
try {
|
||||
if (this._arkadeSwaps !== arkadeSwaps) return; // stale subscription after onDelete
|
||||
this._swapHistory = await arkadeSwaps.getSwapHistory();
|
||||
if (this._wallet) {
|
||||
this._transactionsHistory = await this._wallet.getTransactionHistory();
|
||||
const balance = await this._wallet.getBalance();
|
||||
this.balance = balance.available;
|
||||
}
|
||||
this._lastBalanceFetch = +new Date();
|
||||
this._lastTxFetch = +new Date();
|
||||
} catch (e: any) {
|
||||
console.log('[ARK] swap-event refresh failed:', e?.message ?? e);
|
||||
}
|
||||
};
|
||||
|
||||
swapManager.onSwapCompleted(refresh).catch(() => {});
|
||||
swapManager.onSwapFailed(refresh).catch(() => {});
|
||||
swapManager.onActionExecuted(refresh).catch(() => {});
|
||||
}
|
||||
|
||||
private async _fetchLightningFeesAndLimits() {
|
||||
assert(this._arkadeSwaps, 'ArkadeSwaps must be initialized first');
|
||||
try {
|
||||
const [fees, limits] = await Promise.all([this._arkadeSwaps.getFees(), this._arkadeSwaps.getLimits()]);
|
||||
this._feePercentage = fees.reverse?.percentage ?? 0;
|
||||
this._limitMin = limits.min ?? 333;
|
||||
this._limitMax = limits.max ?? 1_000_000;
|
||||
if (!fees.reverse?.percentage) {
|
||||
console.log('warning: unexpected fees response from boltz:', JSON.stringify(fees, null, 2));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log('[ARK] Failed to fetch Boltz fees/limits:', e?.message ?? e);
|
||||
}
|
||||
|
||||
// Initialize the Lightning swap provider
|
||||
const swapProvider = new BoltzSwapProvider({
|
||||
apiUrl: this._boltzApiUrl,
|
||||
network: 'bitcoin',
|
||||
});
|
||||
|
||||
// Create the ArkadeLightning instance
|
||||
this._arkadeLightning = new ArkadeLightning({
|
||||
wallet: this._wallet,
|
||||
swapProvider,
|
||||
});
|
||||
}
|
||||
|
||||
async generate(): Promise<void> {
|
||||
@ -201,73 +284,197 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
return this.secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of activity for the BlueWallet transaction list,
|
||||
* coalesced from three feeds:
|
||||
*
|
||||
* 1. `_swapHistory` (Boltz). Branch on the `swap.type` discriminator:
|
||||
* - `reverse` → Lightning RECEIVE (`user_invoice`).
|
||||
* - `submarine` → Lightning SEND (`payment_request` while pending,
|
||||
* `paid_invoice` once `transaction.claimed`).
|
||||
* - `chain` → not surfaced (no LN-shaped UX entry point yet).
|
||||
* Settlement signal is `LightningTransaction.ispaid`; we never invent
|
||||
* a `confirmations` field for an LN/Ark row.
|
||||
*
|
||||
* 2. `_transactionsHistory` (Ark SDK):
|
||||
* - `key.boardingTxid && type==='RECEIVED' && settled` → "Refill" row.
|
||||
* - `key.boardingTxid` (other types/statuses) → suppressed.
|
||||
* - no `boardingTxid` → native Ark transfer; SENT renders negative,
|
||||
* RECEIVED renders positive.
|
||||
*
|
||||
* 3. `_boardingUtxos` → "Pending refill" rows (boarding UTXO not yet swept).
|
||||
*
|
||||
* Coalescing: a settled swap always produces an Ark-side tx in
|
||||
* `_transactionsHistory` — reverse `invoice.settled` is Boltz claiming
|
||||
* into our address, submarine `transaction.claimed` is our lockup being
|
||||
* claimed by Boltz. The native-Ark pass therefore drops any history
|
||||
* entry whose `(direction, |amount|)` matches a *settled* swap within
|
||||
* ±30 minutes of `swap.createdAt`. We do NOT dedupe against
|
||||
* pending/failed/refunded swaps — those don't guarantee an Ark-side leg
|
||||
* and matching against them would hide unrelated native transfers of
|
||||
* the same amount.
|
||||
*
|
||||
* Stable row ids: every row sets `txid` to a logical id that survives
|
||||
* status transitions — `swap-<id>`, `boarding-<txid>`,
|
||||
* `boarding-utxo-<txid>:<vout>`, `ark-<arkTxid|commitmentTxid>`. FlatList
|
||||
* still keys by index today, but the field is the canonical key for any
|
||||
* future consumer.
|
||||
*
|
||||
* Hidden states:
|
||||
* - Submarine `invoice.set` → dropped (no funds at risk yet).
|
||||
* - Submarine `swap.expired` / `invoice.expired` → kept with a `Failed: `
|
||||
* prefix; SDK classifies them as refundable so the user needs the row
|
||||
* to recover an on-chain lockup.
|
||||
* - Reverse expired-unpaid invoices (`type === 'reverse'` AND `!ispaid`
|
||||
* AND `!memoPrefix` AND `expiry+timestamp < now`) are dropped. The
|
||||
* expiry guard is gated to (a) reverse only — submarine pending rows
|
||||
* may have on-chain locked funds that need recovery visibility — and
|
||||
* (b) non-terminal rows so a `Failed: ` / `Refunded: ` row whose
|
||||
* BOLT11 has since expired is still preserved for diagnosis.
|
||||
* - Failed/refunded swaps stay visible with `ispaid:false` and a
|
||||
* `Failed: ` / `Refunded: ` memo prefix so support can diagnose them.
|
||||
*/
|
||||
getTransactions(): (Transaction & LightningTransaction)[] {
|
||||
const walletID = this.getID();
|
||||
const ret: LightningTransaction[] = [];
|
||||
const ret: any[] = [];
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const DEDUP_WINDOW_SEC = 30 * 60;
|
||||
|
||||
type SwapFingerprint = { type: TxType; amount: number; createdAtSec: number };
|
||||
const swapFingerprints: SwapFingerprint[] = [];
|
||||
|
||||
for (const swap of this._swapHistory) {
|
||||
let memo = '';
|
||||
let value = 0;
|
||||
let timestamp = 0;
|
||||
let payment_hash = '';
|
||||
let bolt11invoice = '';
|
||||
let direction = 1;
|
||||
let ispaid = false;
|
||||
let expiry = 3600;
|
||||
let payment_hash = '';
|
||||
let expiry: number | undefined;
|
||||
const timestamp = swap.createdAt;
|
||||
|
||||
try {
|
||||
// @ts-ignore properties do exist
|
||||
bolt11invoice = swap.request.invoice || swap.response.invoice;
|
||||
const invoiceDetails = this.decodeInvoice(bolt11invoice);
|
||||
value = invoiceDetails.num_satoshis;
|
||||
memo = invoiceDetails.description;
|
||||
payment_hash = invoiceDetails.payment_hash;
|
||||
expiry = invoiceDetails.expiry;
|
||||
// @ts-ignore: present on reverse and submarine variants
|
||||
bolt11invoice = swap.request.invoice || swap.response.invoice || '';
|
||||
if (bolt11invoice) {
|
||||
const invoiceDetails = this.decodeInvoice(bolt11invoice);
|
||||
value = invoiceDetails.num_satoshis;
|
||||
memo = invoiceDetails.description;
|
||||
payment_hash = invoiceDetails.payment_hash;
|
||||
expiry = invoiceDetails.expiry;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
timestamp = swap.createdAt;
|
||||
let direction: -1 | 1;
|
||||
let ispaid = false;
|
||||
let type: 'user_invoice' | 'payment_request' | 'paid_invoice';
|
||||
let memoPrefix = '';
|
||||
|
||||
switch (swap.status) {
|
||||
case 'transaction.claimed':
|
||||
direction = -1;
|
||||
ispaid = true;
|
||||
break;
|
||||
case 'invoice.settled':
|
||||
direction = 1;
|
||||
ispaid = true;
|
||||
break;
|
||||
case 'swap.created':
|
||||
// nop, this is invoice that we created
|
||||
break;
|
||||
case 'invoice.set':
|
||||
// dont return it, its an invoice we trief to pay but could not
|
||||
continue;
|
||||
if (swap.type === 'reverse') {
|
||||
direction = 1;
|
||||
type = 'user_invoice';
|
||||
switch (swap.status) {
|
||||
case 'invoice.settled':
|
||||
ispaid = true;
|
||||
break;
|
||||
case 'transaction.failed':
|
||||
case 'transaction.lockupFailed':
|
||||
case 'transaction.refunded':
|
||||
memoPrefix = 'Failed: ';
|
||||
break;
|
||||
// swap.created / transaction.mempool / transaction.confirmed → pending receive
|
||||
// invoice.expired / swap.expired → handled by the unpaid-expired filter below
|
||||
}
|
||||
} else if (swap.type === 'submarine') {
|
||||
direction = -1;
|
||||
switch (swap.status) {
|
||||
case 'transaction.claimed':
|
||||
ispaid = true;
|
||||
type = 'paid_invoice';
|
||||
break;
|
||||
case 'invoice.set':
|
||||
// No funds at risk yet — user hasn't broadcast the lockup.
|
||||
continue;
|
||||
case 'transaction.refunded':
|
||||
memoPrefix = 'Refunded: ';
|
||||
type = 'payment_request';
|
||||
break;
|
||||
case 'invoice.failedToPay':
|
||||
case 'transaction.failed':
|
||||
case 'transaction.lockupFailed':
|
||||
case 'swap.expired':
|
||||
case 'invoice.expired':
|
||||
// SDK classifies swap.expired as a refundable submarine failure
|
||||
// (lockup is still on-chain). Keep the row visible so users can
|
||||
// recover funds. invoice.expired is not reachable per the SDK
|
||||
// lifecycle today; treated as failed for safety.
|
||||
memoPrefix = 'Failed: ';
|
||||
type = 'payment_request';
|
||||
break;
|
||||
default:
|
||||
// swap.created / invoice.pending / invoice.paid → pending send
|
||||
type = 'payment_request';
|
||||
}
|
||||
} else {
|
||||
// 'chain' — no LN-shaped UX surface yet.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this._claimedSwaps[swap.id]) {
|
||||
ispaid = true;
|
||||
// Resolve effective amount: prefer the on-chain (Ark) leg, fall back to
|
||||
// the invoice amount, then to the swap-request invoiceAmount.
|
||||
// @ts-ignore properties exist on the variant union
|
||||
const rawValue = swap.response.onchainAmount || swap.response.expectedAmount || value || swap.request.invoiceAmount || 0;
|
||||
const absValue = Math.abs(rawValue);
|
||||
value = absValue * direction;
|
||||
|
||||
// Hide expired unpaid reverse invoices only. Three exclusions:
|
||||
// 1. Terminal rows (`Failed: ` / `Refunded: `) carry diagnostic value
|
||||
// beyond the BOLT11 lifetime — we keep them.
|
||||
// 2. Submarine pending rows are NEVER hidden: by the time a submarine
|
||||
// swap reaches `invoice.pending` / `invoice.paid` /
|
||||
// `transaction.claim.pending`, the user's lockup is on-chain.
|
||||
// Hiding the row before the SDK transitions it to swap.expired or
|
||||
// transaction.refunded would lose visibility into recoverable
|
||||
// locked funds.
|
||||
// 3. Without a decoded expiry we can't reason about freshness, so the
|
||||
// row stays visible.
|
||||
if (swap.type === 'reverse' && !ispaid && !memoPrefix && expiry !== undefined && expiry > 0 && timestamp + expiry < nowSec) continue;
|
||||
|
||||
// Pre-record the fingerprint so the native-Ark pass below can suppress
|
||||
// the matching SDK history entry. Only settled swaps are guaranteed to
|
||||
// have an Ark-side counterpart: reverse settles by
|
||||
// Boltz claiming into our address, submarine settles by our lockup
|
||||
// being claimed by Boltz. Pending/failed/refunded rows aren't
|
||||
// guaranteed to produce an Ark leg, and recording their fingerprints
|
||||
// would hide unrelated same-amount native transfers in the ±30 min
|
||||
// window.
|
||||
if (ispaid) {
|
||||
swapFingerprints.push({
|
||||
type: direction < 0 ? TxType.TxSent : TxType.TxReceived,
|
||||
amount: absValue,
|
||||
createdAtSec: timestamp,
|
||||
});
|
||||
}
|
||||
// @ts-ignore properties do exist
|
||||
value = swap.response.onchainAmount || swap.response.expectedAmount || value || swap.request.invoiceAmount || 0;
|
||||
value = value * direction;
|
||||
|
||||
ret.push({
|
||||
type: direction < 0 ? 'paid_invoice' : 'user_invoice',
|
||||
txid: `swap-${swap.id}`,
|
||||
type,
|
||||
walletID,
|
||||
description: memo,
|
||||
memo,
|
||||
description: memoPrefix + memo,
|
||||
memo: memoPrefix + memo,
|
||||
value,
|
||||
timestamp,
|
||||
ispaid,
|
||||
payment_hash,
|
||||
payment_request: bolt11invoice,
|
||||
amt: value,
|
||||
// @ts-ignore preimage is required for reverse, optional for submarine
|
||||
payment_preimage: swap.preimage,
|
||||
expire_time: expiry,
|
||||
expire_time: expiry ?? 3600,
|
||||
});
|
||||
}
|
||||
|
||||
for (const boardingTx of this._boardingUtxos) {
|
||||
ret.push({
|
||||
txid: `boarding-utxo-${boardingTx.txid}:${boardingTx.vout}`,
|
||||
type: 'bitcoind_tx',
|
||||
walletID,
|
||||
description: 'Pending refill',
|
||||
@ -278,20 +485,45 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
}
|
||||
|
||||
for (const histTx of this._transactionsHistory) {
|
||||
if (histTx.key.boardingTxid && histTx.type === 'RECEIVED' && histTx.settled) {
|
||||
// for now putting on the list only onchain top-up transactions:
|
||||
ret.push({
|
||||
type: 'bitcoind_tx',
|
||||
walletID,
|
||||
description: 'Refill',
|
||||
memo: 'Refill',
|
||||
value: histTx.amount,
|
||||
timestamp: Math.floor(histTx.createdAt / 1000),
|
||||
});
|
||||
if (histTx.key.boardingTxid) {
|
||||
// Boarding leg: keep the existing "settled refill only" rule.
|
||||
if (histTx.type === TxType.TxReceived && histTx.settled) {
|
||||
ret.push({
|
||||
txid: `boarding-${histTx.key.boardingTxid}`,
|
||||
type: 'bitcoind_tx',
|
||||
walletID,
|
||||
description: 'Refill',
|
||||
memo: 'Refill',
|
||||
value: histTx.amount,
|
||||
timestamp: Math.floor(histTx.createdAt / 1000),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Native Ark transfer. Skip the swap-side leg of any settlement we
|
||||
// already rendered as a Lightning row.
|
||||
const histAmount = Math.abs(histTx.amount);
|
||||
const histCreatedAtSec = Math.floor(histTx.createdAt / 1000);
|
||||
const matchesSwap = swapFingerprints.some(
|
||||
fp => fp.type === histTx.type && fp.amount === histAmount && Math.abs(fp.createdAtSec - histCreatedAtSec) <= DEDUP_WINDOW_SEC,
|
||||
);
|
||||
if (matchesSwap) continue;
|
||||
|
||||
const idKey = histTx.key.arkTxid || histTx.key.commitmentTxid || `${histTx.type}-${histCreatedAtSec}-${histAmount}`;
|
||||
const direction = histTx.type === TxType.TxSent ? -1 : 1;
|
||||
const description = histTx.type === TxType.TxSent ? 'Sent' : 'Received';
|
||||
ret.push({
|
||||
txid: `ark-${idKey}`,
|
||||
type: 'bitcoind_tx',
|
||||
walletID,
|
||||
description,
|
||||
memo: description,
|
||||
value: histAmount * direction,
|
||||
timestamp: histCreatedAtSec,
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore meh
|
||||
return ret;
|
||||
}
|
||||
|
||||
@ -302,58 +534,25 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
async fetchTransactions() {
|
||||
if (!this._wallet) await this.init();
|
||||
if (!this._wallet) throw new Error('Ark wallet not initialized');
|
||||
if (!this._arkadeLightning) throw new Error('Ark Lightning not initialized');
|
||||
if (!this._arkadeSwaps) throw new Error('ArkadeSwaps not initialized');
|
||||
|
||||
this._swapHistory = await this._arkadeLightning.getSwapHistory();
|
||||
this._swapHistory = await this._arkadeSwaps.getSwapHistory();
|
||||
this._transactionsHistory = await this._wallet.getTransactionHistory();
|
||||
this._lastTxFetch = +new Date();
|
||||
}
|
||||
|
||||
async _attemptToClaimPendingVHTLCs() {
|
||||
assert(this._wallet, 'Ark wallet not initialized');
|
||||
assert(this._arkadeLightning, 'Ark Lightning not initialized');
|
||||
const arkadeLightning = this._arkadeLightning;
|
||||
|
||||
const pendingReverseSwaps = await this._arkadeLightning.getPendingReverseSwaps();
|
||||
if ((pendingReverseSwaps ?? []).length > 0) console.log('got', pendingReverseSwaps?.length ?? [], 'pending swaps');
|
||||
|
||||
await Promise.all(
|
||||
(pendingReverseSwaps ?? []).map(async swap => {
|
||||
if (this._claimedSwaps[swap.id]) return;
|
||||
|
||||
console.log(`claiming ${swap.id}...`);
|
||||
if (swap?.response?.timeoutBlockHeights?.refund && swap?.response?.timeoutBlockHeights?.refund <= Date.now() / 1000) {
|
||||
console.log(`skipping ${swap.id} (too old)`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await arkadeLightning.claimVHTLC(swap);
|
||||
console.log('claimed!');
|
||||
this._claimedSwaps[swap.id] = true;
|
||||
} catch (error: any) {
|
||||
console.log(`could not claim ${swap.id}:`, error.message);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fetchBalance(noRetry?: boolean): Promise<void> {
|
||||
async fetchBalance(): Promise<void> {
|
||||
if (!this._wallet) await this.init();
|
||||
if (!this._wallet) throw new Error('Ark wallet not initialized');
|
||||
|
||||
if (this._arkadeLightning) {
|
||||
await this._attemptToClaimPendingVHTLCs();
|
||||
}
|
||||
|
||||
await this._attemptBoardUtxos();
|
||||
|
||||
const balance = await this._wallet.getBalance();
|
||||
this._lastBalanceFetch = +new Date();
|
||||
this.balance = balance.available;
|
||||
}
|
||||
|
||||
getBalance() {
|
||||
return this.balance;
|
||||
// Use SDK `total` (offchain available + recoverable + boarding) so the
|
||||
// headline balance reflects everything the user holds, including pending
|
||||
// refills. `available` alone hides boarding deposits until they swap.
|
||||
this.balance = balance.total;
|
||||
}
|
||||
|
||||
async payInvoice(invoice: string, freeAmount: number = 0) {
|
||||
@ -369,7 +568,7 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
return;
|
||||
}
|
||||
|
||||
assert(this._arkadeLightning, 'Ark Lightning not initialized');
|
||||
assert(this._arkadeSwaps, 'ArkadeSwaps not initialized');
|
||||
|
||||
const invoiceDetails = decodeInvoice(invoice);
|
||||
|
||||
@ -380,7 +579,7 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
assert(invoiceDetails.amountSats > this._limitMin, `Minimum you can send is ${this._limitMin} sat`);
|
||||
assert(invoiceDetails.amountSats < this._limitMax, `Maximum you can is ${this._limitMax} sat`);
|
||||
|
||||
const paymentResult = await this._arkadeLightning.sendLightningPayment({ invoice });
|
||||
const paymentResult = await this._arkadeSwaps.sendLightningPayment({ invoice });
|
||||
|
||||
console.log('Payment successful!');
|
||||
console.log('Amount:', paymentResult.amount);
|
||||
@ -388,10 +587,7 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
console.log('Transaction ID:', paymentResult.txid);
|
||||
}
|
||||
|
||||
async getUserInvoices(limit: number | false = false): Promise<LightningTransaction[]> {
|
||||
if (this._arkadeLightning) {
|
||||
await this._attemptToClaimPendingVHTLCs();
|
||||
}
|
||||
async getUserInvoices(): Promise<LightningTransaction[]> {
|
||||
await this.fetchTransactions();
|
||||
const txs = this.getTransactions();
|
||||
return txs.filter(tx => tx.value! > 0);
|
||||
@ -399,14 +595,14 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
|
||||
async addInvoice(amt: number, memo: string) {
|
||||
if (!this._wallet) await this.init();
|
||||
assert(this._arkadeLightning, 'Ark Lightning not initialized');
|
||||
assert(this._arkadeSwaps, 'ArkadeSwaps not initialized');
|
||||
assert(amt > this._limitMin, `Minimum to receive is ${this._limitMin} sat`);
|
||||
assert(amt < this._limitMax, `Maximum to receive is ${this._limitMin} sat`);
|
||||
assert(amt < this._limitMax, `Maximum to receive is ${this._limitMax} sat`);
|
||||
|
||||
// fee percentage is smth like `0.01`, but its not 1%, its one-hundredth of a percent, rounded up
|
||||
const serviceFee = Math.ceil(new BigNumber(amt).multipliedBy(this._feePercentage).dividedBy(100).toNumber());
|
||||
|
||||
const result = await this._arkadeLightning.createLightningInvoice({
|
||||
const result = await this._arkadeSwaps.createLightningInvoice({
|
||||
amount: amt + serviceFee,
|
||||
description: memo,
|
||||
});
|
||||
@ -465,7 +661,7 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
return this.getTransactions().some(tx => tx.payment_request === paymentRequest && typeof tx.value !== 'undefined' && tx?.value >= 0);
|
||||
}
|
||||
|
||||
async createAccount(isTest: boolean = false) {
|
||||
async createAccount() {
|
||||
// nop
|
||||
}
|
||||
|
||||
@ -478,30 +674,21 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
}
|
||||
|
||||
private async _attemptBoardUtxos() {
|
||||
// executing in background since it can take a lot of time, but setting the lock so there wont be any races
|
||||
// (for example, during another pull-to-refresh)
|
||||
// Refresh the boarding UTXO list so getTransactions() can render "Pending
|
||||
// refill" rows. The actual onboard intent is now driven by the SDK's
|
||||
// VtxoManager polling loop (enabled via settlementConfig on Wallet.create);
|
||||
// running Ramps.onboard here in parallel would double-submit the same
|
||||
// inputs and race the SDK's per-input cooldown bookkeeping.
|
||||
const namespace = this.getNamespace();
|
||||
if (boardingLock[namespace]) return;
|
||||
|
||||
if (!this._wallet) return;
|
||||
|
||||
boardingLock[namespace] = true;
|
||||
this._boardingUtxos = await this._wallet.getBoardingUtxos(); // calling it here so fetchBalance will pick it up and then `getTransactions` will show it in tx list
|
||||
(async () => {
|
||||
if (this._boardingUtxos.length > 0) {
|
||||
if (!this._wallet) return;
|
||||
// not instantiating, this is supposed to be called inside `fetchBalance`
|
||||
console.log('attempting to board ', this._boardingUtxos.length, 'UTXOs...');
|
||||
const info = await this._wallet.arkProvider.getInfo();
|
||||
const feeInfo = info.fees;
|
||||
await new Ramps(this._wallet).onboard(feeInfo, this._boardingUtxos);
|
||||
this._boardingUtxos = await this._wallet.getBoardingUtxos(); // refetch UTXOs, if we succeeded boarding previosuly the set should be reduced
|
||||
}
|
||||
})()
|
||||
.catch(e => console.log('ark boarding failed:', e.message))
|
||||
.finally(() => {
|
||||
boardingLock[namespace] = false;
|
||||
});
|
||||
try {
|
||||
this._boardingUtxos = await this._wallet.getBoardingUtxos();
|
||||
} finally {
|
||||
boardingLock[namespace] = false;
|
||||
}
|
||||
}
|
||||
|
||||
isAddressValid(address: string): boolean {
|
||||
@ -515,4 +702,135 @@ export class LightningArkWallet extends LightningCustodianWallet {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-swap claim/refund + import-time restore.
|
||||
// These are thin wrappers over `ArkadeSwaps`. We do not add app-side polling
|
||||
// or reliability layers — the SDK owns swap reliability internally
|
||||
// (claimVHTLC waits for VTXO availability; refundVHTLC reports
|
||||
// swept/skipped). UI code calls these from the swap detail screen.
|
||||
|
||||
getSwapById(id: string): BoltzSwap | undefined {
|
||||
return this._swapHistory.find(swap => swap.id === id);
|
||||
}
|
||||
|
||||
isSwapClaimable(swap: BoltzSwap): boolean {
|
||||
return isReverseSwapClaimable(swap) || isChainSwapClaimable(swap);
|
||||
}
|
||||
|
||||
isSwapRefundable(swap: BoltzSwap): boolean {
|
||||
return isSubmarineSwapRefundable(swap) || isChainSwapRefundable(swap);
|
||||
}
|
||||
|
||||
async claimSwap(swap: BoltzReverseSwap): Promise<void> {
|
||||
if (!this._wallet) await this.init();
|
||||
if (!this._arkadeSwaps) throw new Error('ArkadeSwaps not initialized');
|
||||
await this._arkadeSwaps.claimVHTLC(swap);
|
||||
await this.fetchTransactions();
|
||||
await this.fetchBalance();
|
||||
}
|
||||
|
||||
async refundSwap(swap: BoltzSubmarineSwap): Promise<SubmarineRefundOutcome> {
|
||||
if (!this._wallet) await this.init();
|
||||
if (!this._arkadeSwaps) throw new Error('ArkadeSwaps not initialized');
|
||||
const outcome = await this._arkadeSwaps.refundVHTLC(swap);
|
||||
await this.fetchTransactions();
|
||||
await this.fetchBalance();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async restoreSwaps(): Promise<void> {
|
||||
const namespace = this.getNamespace();
|
||||
let inFlight = restoreInFlight.get(namespace);
|
||||
if (!inFlight) {
|
||||
inFlight = (async () => {
|
||||
if (!this._wallet) await this.init();
|
||||
if (!this._arkadeSwaps) throw new Error('ArkadeSwaps not initialized');
|
||||
await this._arkadeSwaps.restoreSwaps();
|
||||
this._swapHistory = await this._arkadeSwaps.getSwapHistory();
|
||||
this._lastTxFetch = +new Date();
|
||||
})();
|
||||
restoreInFlight.set(namespace, inFlight);
|
||||
inFlight
|
||||
.finally(() => {
|
||||
if (restoreInFlight.get(namespace) === inFlight) restoreInFlight.delete(namespace);
|
||||
})
|
||||
.catch(() => {
|
||||
// Same rejection is delivered to the awaiting caller below; silence
|
||||
// the cleanup chain so it isn't an unhandled rejection.
|
||||
});
|
||||
await inFlight;
|
||||
} else {
|
||||
// Join an in-flight restore. The IIFE only writes to the instance that
|
||||
// created it, so pull results into this instance once the shared work
|
||||
// completes.
|
||||
await inFlight;
|
||||
const cachedSwaps = staticSwapsCache[namespace];
|
||||
if (cachedSwaps) {
|
||||
this._swapHistory = await cachedSwaps.getSwapHistory();
|
||||
this._lastTxFetch = +new Date();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup hook invoked when the wallet is removed from BlueWallet storage.
|
||||
* Drains any in-flight init so its post-await tail can no longer repopulate
|
||||
* staticWalletCache / staticSwapsCache / realmInstances after we've cleared
|
||||
* them, then closes the per-wallet Realm, deletes the Realm files, and
|
||||
* resets the Keychain entry. Errors are scoped here and never thrown to the
|
||||
* deletion path.
|
||||
*/
|
||||
async onDelete(): Promise<void> {
|
||||
if (!this.secret) return; // nothing to clean
|
||||
const namespace = this.getNamespace();
|
||||
|
||||
delete boardingLock[namespace];
|
||||
|
||||
// If init() is racing with us, await its settlement before clearing caches.
|
||||
// Without this drain, the IIFE in init() would write to staticWalletCache /
|
||||
// staticSwapsCache after our delete and the realm adapter would re-cache the
|
||||
// open Realm, resurrecting state for an already-deleted wallet. Note that
|
||||
// the racing init's `await inFlight` continuation runs *before* ours (it
|
||||
// was registered earlier), so when we resume here, init has already
|
||||
// re-assigned this._wallet / this._arkadeSwaps and populated the caches.
|
||||
// We then clear everything in one pass.
|
||||
const inFlightInit = initInFlight.get(namespace);
|
||||
if (inFlightInit) {
|
||||
try {
|
||||
await inFlightInit;
|
||||
} catch {
|
||||
// init's caller already received the rejection; we just need it to settle.
|
||||
}
|
||||
}
|
||||
|
||||
// Stop SwapManager + VtxoManager loops before tearing down storage so
|
||||
// their background timers / WebSocket / settlement polls don't keep
|
||||
// running against a wallet whose Realm we're about to delete.
|
||||
const cachedSwaps = staticSwapsCache[namespace];
|
||||
const cachedWallet = staticWalletCache[namespace];
|
||||
|
||||
this._wallet = undefined;
|
||||
this._arkadeSwaps = undefined;
|
||||
delete staticWalletCache[namespace];
|
||||
delete staticSwapsCache[namespace];
|
||||
initInFlight.delete(namespace);
|
||||
|
||||
// Type guards: real SDK objects always have dispose; unit-test stubs may not.
|
||||
try {
|
||||
if (typeof cachedSwaps?.dispose === 'function') await cachedSwaps.dispose();
|
||||
} catch (e: any) {
|
||||
console.log(`[LightningArkWallet] arkadeSwaps.dispose failed for ${namespace}:`, e?.message ?? e);
|
||||
}
|
||||
try {
|
||||
if (typeof cachedWallet?.dispose === 'function') await cachedWallet.dispose();
|
||||
} catch (e: any) {
|
||||
console.log(`[LightningArkWallet] wallet.dispose failed for ${namespace}:`, e?.message ?? e);
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteArkadeRealm(namespace);
|
||||
} catch (e: any) {
|
||||
console.log(`[LightningArkWallet] onDelete cleanup failed for ${namespace}:`, e?.message ?? e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,12 +2,14 @@ import React, { createContext, useCallback, useEffect, useMemo, useRef, useState
|
||||
import { AppState, AppStateStatus, LayoutAnimation } from 'react-native';
|
||||
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';
|
||||
@ -175,6 +177,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(
|
||||
@ -308,7 +319,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]);
|
||||
|
||||
@ -495,6 +510,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';
|
||||
@ -174,12 +175,30 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
|
||||
const isPending = listTitleKey === 'pending';
|
||||
|
||||
// For LightningArkWallet rows, prepend a kind tag so the user can tell
|
||||
// Lightning swaps, native Ark transfers, and on-chain refills apart at a
|
||||
// glance — they all share the generic "Sent"/"Received"/"Pending" title
|
||||
// and the same on-chain icon. Detection: Lightning swap rows are the
|
||||
// invoice-typed rows synthesized in lightning-ark-wallet.getTransactions();
|
||||
// native Ark and refill rows are bitcoind_tx-typed but carry a synthetic
|
||||
// `txid` prefix (`ark-…`, `boarding-…`). Other wallet types are
|
||||
// unaffected.
|
||||
const arkRowKind = useMemo<'Lightning' | 'Ark' | 'Refill' | undefined>(() => {
|
||||
const wallet = wallets.find(w => w.getID() === item.walletID);
|
||||
if (wallet?.type !== LightningArkWallet.type) return undefined;
|
||||
if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') return 'Lightning';
|
||||
const txid = (item as { txid?: string }).txid;
|
||||
if (txid?.startsWith('ark-')) return 'Ark';
|
||||
if (txid?.startsWith('boarding-')) return 'Refill';
|
||||
return undefined;
|
||||
}, [item, wallets]);
|
||||
|
||||
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();
|
||||
@ -352,15 +371,22 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
|
||||
walletID: lightningWallet[0].getID(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('cant handle press');
|
||||
} else if ((item as { txid?: string }).txid) {
|
||||
// Ark wallet rows (refills, native transfers, boarding UTXOs) carry a
|
||||
// synthetic `txid` and no on-chain `hash`. Route to TransactionStatus
|
||||
// passing the synthetic id as the lookup key.
|
||||
navigate('TransactionStatus', {
|
||||
tx: item,
|
||||
hash: (item as { txid: string }).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') {
|
||||
const lightningWallet = wallets.find(wallet => wallet?.getID() === item.walletID);
|
||||
if (lightningWallet) {
|
||||
navigate('LNDViewInvoice', {
|
||||
@ -368,6 +394,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.
|
||||
navigate('TransactionStatus', {
|
||||
tx: item,
|
||||
hash: (item as { txid: string }).txid,
|
||||
walletID,
|
||||
});
|
||||
}
|
||||
}, [item, navigate, walletID, wallets]);
|
||||
|
||||
@ -449,7 +482,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)
|
||||
|
||||
@ -383,9 +383,18 @@ 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.
|
||||
const isLightningShaped = item.type === LightningCustodianWallet.type || item.type === LightningArkWallet.type;
|
||||
const hasPendingTx = isLightningShaped
|
||||
? item.getTransactions().some((tx: any) => tx.ispaid === false)
|
||||
: 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, Platform, UIManager } 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'],
|
||||
|
||||
10
loc/en.json
10
loc/en.json
@ -76,7 +76,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.",
|
||||
"claim_funds": "Claim funds",
|
||||
"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",
|
||||
@ -445,6 +451,8 @@
|
||||
"details_show_xpub": "Show Wallet XPUB",
|
||||
"details_show_addresses": "Show addresses",
|
||||
"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': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/cjs/index.js'),
|
||||
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/cjs/adapters/expo.js'),
|
||||
'@arkade-os/sdk/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/cjs/repositories/realm/index.js'),
|
||||
'@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);
|
||||
},
|
||||
|
||||
391
package-lock.json
generated
391
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.30",
|
||||
"@arkade-os/sdk": "0.4.26",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bugsnag/react-native": "8.8.1",
|
||||
"@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",
|
||||
@ -180,10 +181,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/boltz-swap": {
|
||||
"version": "0.2.19",
|
||||
"version": "0.3.30",
|
||||
"resolved": "https://registry.npmjs.org/@arkade-os/boltz-swap/-/boltz-swap-0.3.30.tgz",
|
||||
"integrity": "sha512-U3kmcYER4W8PHYtbCNQoWD8ymiaQ0iFdfokmgxMV+UuEXTLvhooNjv25PADOzXZgXIB4GF7bvYegGmBHHXUB1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@arkade-os/sdk": "0.3.12",
|
||||
"@arkade-os/sdk": "0.4.26",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0",
|
||||
"@scure/btc-signer": "2.0.1",
|
||||
@ -191,7 +195,8 @@
|
||||
"light-bolt11-decoder": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.12.0 <23",
|
||||
"pnpm": ">=10.25.0 <11"
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/boltz-swap/node_modules/@noble/hashes": {
|
||||
@ -205,23 +210,81 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/sdk": {
|
||||
"version": "0.3.12",
|
||||
"hasInstallScript": true,
|
||||
"version": "0.4.26",
|
||||
"resolved": "https://registry.npmjs.org/@arkade-os/sdk/-/sdk-0.4.26.tgz",
|
||||
"integrity": "sha512-seyc+BdgWPpsFp/PdgcHAyCmK3FYlNP6+DdG/BMbYmARNxqDHFN/y3/IZcwcXMLJjQkcLVBR/iUL1FKFwBgpdQ==",
|
||||
"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 <23",
|
||||
"pnpm": ">=10.25.0 <11"
|
||||
},
|
||||
"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/curves": {
|
||||
"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.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@arkade-os/sdk/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/@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/"
|
||||
@ -1925,6 +1988,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.8.0",
|
||||
"license": "MIT",
|
||||
@ -3300,8 +3482,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"
|
||||
}
|
||||
@ -3328,10 +3515,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "2.0.0",
|
||||
"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.0.0"
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
@ -3341,7 +3530,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "2.0.0",
|
||||
"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"
|
||||
@ -4765,8 +4956,70 @@
|
||||
"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/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",
|
||||
@ -4778,8 +5031,25 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@noble/curves": {
|
||||
"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.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@ -6250,6 +6520,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"
|
||||
@ -9766,9 +10038,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -9777,8 +10049,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"xml-naming": "^0.1.0"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
@ -10071,21 +10342,6 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"license": "MIT",
|
||||
@ -11137,6 +11393,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,
|
||||
@ -13443,7 +13708,6 @@
|
||||
},
|
||||
"node_modules/lodash.memoize": {
|
||||
"version": "4.1.2",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@ -14345,6 +14609,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"
|
||||
@ -15248,9 +15514,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -15909,6 +16175,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",
|
||||
@ -19011,19 +19283,38 @@
|
||||
"async-limiter": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-naming": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"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.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"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/xmlbuilder": {
|
||||
|
||||
@ -93,8 +93,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.30",
|
||||
"@arkade-os/sdk": "0.4.26",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bugsnag/react-native": "8.8.1",
|
||||
"@bugsnag/source-maps": "2.3.3",
|
||||
@ -155,6 +155,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",
|
||||
|
||||
@ -21,6 +21,9 @@ 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 type { BoltzReverseSwap, BoltzSubmarineSwap } from '@arkade-os/boltz-swap';
|
||||
|
||||
type LNDViewInvoiceRouteParams = {
|
||||
walletID: string;
|
||||
@ -36,12 +39,73 @@ 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;
|
||||
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;
|
||||
|
||||
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 onClaimPressed = async () => {
|
||||
if (!arkWallet || !swap || isActioning) return;
|
||||
setIsActioning(true);
|
||||
try {
|
||||
await arkWallet.claimSwap(swap as BoltzReverseSwap);
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
|
||||
await refreshAfterAction();
|
||||
} catch (e: any) {
|
||||
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
|
||||
presentAlert({ message: e?.message ?? String(e) });
|
||||
} finally {
|
||||
setIsActioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
@ -187,6 +251,46 @@ const LNDViewInvoice = () => {
|
||||
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;
|
||||
|
||||
// Per-swap claim/refund CTA. When the SDK reports the underlying swap
|
||||
// is claimable (reverse: Boltz funded the VHTLC, we haven't claimed
|
||||
// yet) or refundable (submarine: payment failed, VTXO lockup
|
||||
// recoverable), render a primary CTA in place of the QR/expired
|
||||
// branches below. Once the action succeeds, the swap status
|
||||
// transitions and these predicates flip false, so the next render
|
||||
// falls through to the existing ispaid/expired branches.
|
||||
if (claimable) {
|
||||
const amount = (invoice.amt as number | undefined) ?? (invoice.value as number | undefined) ?? 0;
|
||||
return (
|
||||
<View style={[styles.activeRoot, stylesHook.root]}>
|
||||
<BlueTextCentered>
|
||||
{loc.lndViewInvoice.please_pay} {amount} {loc.lndViewInvoice.sats}
|
||||
</BlueTextCentered>
|
||||
<BlueSpacing20 />
|
||||
<Button
|
||||
onPress={onClaimPressed}
|
||||
title={loc.lndViewInvoice.claim_funds}
|
||||
disabled={isActioning}
|
||||
showActivityIndicator={isActioning}
|
||||
/>
|
||||
</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 (invoice.ispaid || invoice.type === 'paid_invoice') {
|
||||
let amount = 0;
|
||||
let description;
|
||||
|
||||
@ -29,6 +29,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 +94,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') {
|
||||
@ -117,7 +123,7 @@ export default class SelfTest extends Component {
|
||||
await spkw.init();
|
||||
assertStrictEqual(
|
||||
await spkw.getArkAddress(),
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t59s7u3fgnd3lyjda00ycjq53mgxl6wsxspe4s72t5dss3q6w5clv0xpgal',
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4damkjtcm90w43zn6f90ermjhr9d2qxmsw75r7daanhmasp6avmstu5est',
|
||||
'Ark failed',
|
||||
);
|
||||
} else {
|
||||
|
||||
@ -675,18 +675,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, '', true, 'plain-text', false, undefined, 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;
|
||||
|
||||
@ -37,7 +37,7 @@ import { BlueLoading } from '../../components/BlueLoading';
|
||||
|
||||
type RouteProps = RouteProp<DetailViewStackParamList, 'WalletDetails'>;
|
||||
const WalletDetails: React.FC = () => {
|
||||
const { saveToDisk, wallets, txMetadata, handleWalletDeletion } = useStorage();
|
||||
const { saveToDisk, wallets, txMetadata, handleWalletDeletion, fetchAndSaveWalletTransactions } = useStorage();
|
||||
const { isBiometricUseCapableAndEnabled } = useBiometrics();
|
||||
const { walletID } = useRoute<RouteProps>().params;
|
||||
const { direction } = useLocale();
|
||||
@ -96,6 +96,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());
|
||||
@ -669,6 +684,18 @@ const WalletDetails: React.FC = () => {
|
||||
<SecondButton onPress={navigateToSignVerify} testID="SignVerify" title={loc.addresses.sign_title} />
|
||||
</>
|
||||
)}
|
||||
{wallet.type === LightningArkWallet.type && (
|
||||
<>
|
||||
<BlueSpacing20 />
|
||||
<SecondButton
|
||||
onPress={onRestoreSwapsPressed}
|
||||
testID="RestoreSwapActivity"
|
||||
title={loc.wallets.restore_swap_activity}
|
||||
disabled={isRestoringSwaps}
|
||||
loading={isRestoringSwaps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<BlueSpacing20 />
|
||||
<BlueSpacing20 />
|
||||
</View>
|
||||
|
||||
@ -350,9 +350,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],
|
||||
);
|
||||
@ -371,7 +384,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() });
|
||||
@ -519,7 +532,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
|
||||
|
||||
@ -295,7 +295,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}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
@ -468,7 +475,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 = useMemo(
|
||||
|
||||
@ -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,15 @@ 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
|
||||
// SelfTest runs CPU-heavy crypto loops + network calls 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();
|
||||
await waitFor(element(by.id('SelfTestOk')))
|
||||
.toBeVisible()
|
||||
.withTimeout(300 * 1000);
|
||||
await device.enableSynchronization();
|
||||
await goBack();
|
||||
await goBack();
|
||||
await goBack();
|
||||
@ -487,9 +497,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');
|
||||
|
||||
@ -88,6 +88,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'))
|
||||
|
||||
@ -144,7 +144,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);
|
||||
|
||||
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 });
|
||||
},
|
||||
};
|
||||
101
tests/helpers/sdkProviderMocks.ts
Normal file
101
tests/helpers/sdkProviderMocks.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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 { 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);
|
||||
}
|
||||
|
||||
export function restoreSdkProviderSpies(): void {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
@ -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"}]
|
||||
@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
221
tests/setup.js
221
tests/setup.js
@ -67,6 +67,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', () => {
|
||||
@ -168,22 +196,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(),
|
||||
@ -202,14 +240,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() },
|
||||
};
|
||||
});
|
||||
|
||||
@ -217,32 +256,79 @@ 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.
|
||||
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();
|
||||
const makeRealmInstance = path => {
|
||||
let isClosed = false;
|
||||
return {
|
||||
path,
|
||||
get isClosed() {
|
||||
return isClosed;
|
||||
},
|
||||
create: function () {},
|
||||
delete: function () {},
|
||||
write: function (transactionFn) {
|
||||
if (typeof transactionFn === 'function') {
|
||||
transactionFn();
|
||||
}
|
||||
},
|
||||
objectForPrimaryKey: function () {
|
||||
return {};
|
||||
},
|
||||
objects: function () {
|
||||
return {
|
||||
filtered: function () {
|
||||
return [];
|
||||
},
|
||||
length: 0,
|
||||
[Symbol.iterator]: function* () {},
|
||||
};
|
||||
},
|
||||
close: function () {
|
||||
isClosed = true;
|
||||
},
|
||||
addListener: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
};
|
||||
};
|
||||
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: () => {
|
||||
mockRealmStore.clear();
|
||||
mockRealmFiles.clear();
|
||||
},
|
||||
store: mockRealmStore,
|
||||
files: mockRealmFiles,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@ -273,16 +359,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);
|
||||
});
|
||||
});
|
||||
944
tests/unit/lightning-ark-wallet.test.ts
Normal file
944
tests/unit/lightning-ark-wallet.test.ts
Normal file
@ -0,0 +1,944 @@
|
||||
import assert from 'assert';
|
||||
|
||||
import { ArkRealmSchemas, ARK_REALM_SCHEMA_VERSION } from '@arkade-os/sdk/repositories/realm';
|
||||
import { BoltzRealmSchemas } from '@arkade-os/boltz-swap/repositories/realm';
|
||||
|
||||
import { LightningArkWallet, __testing__ as walletTesting } from '../../class/wallets/lightning-ark-wallet.ts';
|
||||
import { resetArkadeTestState } from '../helpers/arkadeMocks';
|
||||
|
||||
const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
|
||||
beforeEach(() => {
|
||||
resetArkadeTestState();
|
||||
});
|
||||
|
||||
describe('LightningArkWallet — pure', () => {
|
||||
describe('isAddressValid', () => {
|
||||
const w = new LightningArkWallet();
|
||||
|
||||
it('accepts known valid Ark addresses', () => {
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t5z8sz5n95k570z5r004szc9h2q3qprkzdd5zveujdpx24srcrqg8hf6j4v',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9rw',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
w.isAddressValid(
|
||||
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t4sedhdvfcgaky2qk2p55wj4ut38v9tnpuvjr8ee8hv6htp23pzjpwx5esw',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects truncated, malformed, or non-ark addresses', () => {
|
||||
assert.ok(
|
||||
!w.isAddressValid(
|
||||
'ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9r',
|
||||
),
|
||||
'truncated bech32m -> reject',
|
||||
);
|
||||
assert.ok(
|
||||
!w.isAddressValid(
|
||||
'ark1qqellv77udfmr20tun8dvju5vgudpf9vxe8jwhthrkn26fz96pawqfdy8nk05rsmrf8h94j26905e7n6sng8y059z8ykn2j5xcuw4xt8ngt9',
|
||||
),
|
||||
);
|
||||
assert.ok(!w.isAddressValid('ark1sfhshhehehwer'), 'gibberish ark1 -> reject');
|
||||
assert.ok(!w.isAddressValid('test'), 'plain text -> reject');
|
||||
assert.ok(!w.isAddressValid('bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'), 'bech32 BTC address -> reject');
|
||||
assert.ok(!w.isAddressValid(''), 'empty -> reject');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeInvoice', () => {
|
||||
const w = new LightningArkWallet();
|
||||
const invoice =
|
||||
'lnbc20n1p59n9nkpp58s49flel3cz5u3lrve8qeqzxljxmu0gja06elfcgwrx2e9nq959ssp5z7ytwq0rm6yq8evn2kteduj6a0rs4svn3sfwvg92a29f8l022jjqxq9z0rgqnp4qvyndeaqzman7h898jxm98dzkm0mlrsx36s93smrur7h0azyyuxc5rzjq25carzepgd4vqsyn44jrk85ezrpju92xyrk9apw4cdjh6yrwt5jgqqqqrt49lmtcqqqqqqqqqqq86qq9qrzjqwghf7zxvfkxq5a6sr65g0gdkv768p83mhsnt0msszapamzx2qvuxqqqqrt49lmtcqqqqqqqqqqq86qq9qcqzpgdq023mk7gryv9uhxgq9qyyssqy4mv8te3l6mrc7qf4pksh4m4z76jz7s2qrwxd7q2s22ghnanqt33e9p0nahz9fr32g00vn2vhc9rrhpvtr54s40tle25tyyvp59sdpsqty30rp';
|
||||
|
||||
it('extracts amount, description, payment hash, expiry, and routing fields', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvoiceExpired', () => {
|
||||
const w = new LightningArkWallet();
|
||||
// Real BOLT11 with timestamp 1761137387, expiry 86400 (1 day) → expired now.
|
||||
const invoice =
|
||||
'lnbc6670n1p5jp0p9pp5jmyumdwfejjxzwhxh7wnckeugcwcpkqtf5t6dh2fzykjjh4hkatqdq6235x2grhdaexggrs09exzmtfvscqz3txqyyzzssp5ae74xvmlk5q6vxsxe3sqm90w2x4x0ekejt7qp9ca5zzhu83ru8hq9qxpqysgql4dexpmwacw98va6v6smww69a3w6hs5ng0573v8skyhlj7lylt8r65jm5zqaa7hzx3vlrs2fr3h0rtqjw7x94xprdwqy6rr9ff5pnxsppnpr5q';
|
||||
|
||||
it('flags an old invoice as expired against the current clock', () => {
|
||||
assert.strictEqual(w.isInvoiceExpired(invoice), true);
|
||||
});
|
||||
|
||||
it('treats the invoice as fresh if "now" is set to a moment before expiry', () => {
|
||||
// 1763752997 < timestamp (1761137387) + expiry (86400 * 30=2592000) wait, this BOLT11
|
||||
// actually has expiry=2592000 (30 days). Pinning: 1763752997 falls inside the
|
||||
// 30-day window, so the invoice has not expired yet.
|
||||
assert.strictEqual(w.isInvoiceExpired(invoice, 1763752997), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LightningArkWallet — getTransactions mapping', () => {
|
||||
let w: LightningArkWallet;
|
||||
// Real BOLT11 with timestamp=1761137387, expiry=86400 (1 day) → long expired.
|
||||
// Reused by the expiry-filter tests so we can assert on actual decoded
|
||||
// expiry behavior instead of relying on decodeInvoice() throwing on
|
||||
// placeholder strings.
|
||||
const EXPIRED_INVOICE =
|
||||
'lnbc6670n1p5jp0p9pp5jmyumdwfejjxzwhxh7wnckeugcwcpkqtf5t6dh2fzykjjh4hkatqdq6235x2grhdaexggrs09exzmtfvscqz3txqyyzzssp5ae74xvmlk5q6vxsxe3sqm90w2x4x0ekejt7qp9ca5zzhu83ru8hq9qxpqysgql4dexpmwacw98va6v6smww69a3w6hs5ng0573v8skyhlj7lylt8r65jm5zqaa7hzx3vlrs2fr3h0rtqjw7x94xprdwqy6rr9ff5pnxsppnpr5q';
|
||||
const EXPIRED_INVOICE_TIMESTAMP = 1761137387;
|
||||
|
||||
beforeEach(() => {
|
||||
w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
});
|
||||
|
||||
it('returns an empty list when there is no swap or boarding history', () => {
|
||||
assert.deepStrictEqual(w.getTransactions(), []);
|
||||
});
|
||||
|
||||
it('maps a settled submarine swap (transaction.claimed) as a paid_invoice with negative amount', () => {
|
||||
const swap = {
|
||||
id: 'swap-out',
|
||||
type: 'submarine',
|
||||
status: 'transaction.claimed',
|
||||
createdAt: 1700000000,
|
||||
preimage: 'aa'.repeat(32),
|
||||
request: { invoice: 'lnbc1234...send', invoiceAmount: 1234 },
|
||||
response: { expectedAmount: 1234 },
|
||||
} as any;
|
||||
(w as any)._swapHistory = [swap];
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].type, 'paid_invoice');
|
||||
assert.strictEqual(txs[0].value, -1234);
|
||||
assert.strictEqual(txs[0].ispaid, true);
|
||||
assert.strictEqual(txs[0].timestamp, 1700000000);
|
||||
assert.strictEqual(txs[0].payment_preimage, 'aa'.repeat(32));
|
||||
assert.strictEqual(txs[0].txid, 'swap-swap-out', 'stable id is swap-<id> regardless of status');
|
||||
});
|
||||
|
||||
it('maps a settled reverse swap (invoice.settled) as a user_invoice with positive amount', () => {
|
||||
const swap = {
|
||||
id: 'swap-in',
|
||||
type: 'reverse',
|
||||
status: 'invoice.settled',
|
||||
createdAt: 1700001000,
|
||||
preimage: 'bb'.repeat(32),
|
||||
request: { invoice: 'lnbc999...receive' },
|
||||
response: { invoice: 'lnbc999...receive', onchainAmount: 9999 },
|
||||
} as any;
|
||||
(w as any)._swapHistory = [swap];
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].type, 'user_invoice');
|
||||
assert.strictEqual(txs[0].value, 9999);
|
||||
assert.strictEqual(txs[0].ispaid, true);
|
||||
assert.strictEqual(txs[0].txid, 'swap-swap-in');
|
||||
});
|
||||
|
||||
it('maps a pending reverse swap (swap.created) as a user_invoice with ispaid=false', () => {
|
||||
const swap = {
|
||||
id: 'swap-pending',
|
||||
type: 'reverse',
|
||||
status: 'swap.created',
|
||||
createdAt: 1700002000,
|
||||
request: { invoice: 'lnbc1u1pjpending', invoiceAmount: 100000 },
|
||||
response: { invoice: 'lnbc1u1pjpending', onchainAmount: 100000 },
|
||||
} as any;
|
||||
(w as any)._swapHistory = [swap];
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].ispaid, false);
|
||||
assert.strictEqual(txs[0].value, 100000);
|
||||
assert.strictEqual(txs[0].type, 'user_invoice');
|
||||
});
|
||||
|
||||
it('maps a pending submarine swap (swap.created) as a payment_request with ispaid=false', () => {
|
||||
const swap = {
|
||||
id: 'sub-pending',
|
||||
type: 'submarine',
|
||||
status: 'swap.created',
|
||||
createdAt: 1700002500,
|
||||
request: { invoice: 'lnbc...subpending', invoiceAmount: 50000 },
|
||||
response: { expectedAmount: 50000 },
|
||||
} as any;
|
||||
(w as any)._swapHistory = [swap];
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].type, 'payment_request', 'submarine pending = LN send pending');
|
||||
assert.strictEqual(txs[0].ispaid, false);
|
||||
assert.strictEqual(txs[0].value, -50000, 'submarine = negative (outgoing)');
|
||||
});
|
||||
|
||||
it('hides submarine invoice.set entries (failed-to-pay attempts) from the visible list', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'failed',
|
||||
type: 'submarine',
|
||||
status: 'invoice.set',
|
||||
createdAt: 1700003000,
|
||||
request: { invoice: 'lnbc...failed', invoiceAmount: 50 },
|
||||
response: { expectedAmount: 50 },
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepStrictEqual(w.getTransactions(), []);
|
||||
});
|
||||
|
||||
it('skips chain swaps — no LN-shaped UX surface for them yet', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'chain1',
|
||||
type: 'chain',
|
||||
status: 'transaction.claimed',
|
||||
createdAt: 1700003500,
|
||||
request: {},
|
||||
response: { claimDetails: { amount: 1000 }, lockupDetails: { amount: 1000 } },
|
||||
amount: 1000,
|
||||
},
|
||||
];
|
||||
assert.deepStrictEqual(w.getTransactions(), []);
|
||||
});
|
||||
|
||||
it('preserves failed reverse swaps with a Failed: memo prefix and ispaid=false', () => {
|
||||
// Use a real, long-expired BOLT11 so the expiry filter would fire on
|
||||
// an unguarded path. Fix 2: terminal failed/refunded rows must survive
|
||||
// even when their underlying invoice is past expiry — they carry
|
||||
// diagnostic value beyond the BOLT11 lifetime.
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'rev-failed',
|
||||
type: 'reverse',
|
||||
status: 'transaction.failed',
|
||||
createdAt: EXPIRED_INVOICE_TIMESTAMP,
|
||||
request: { invoice: EXPIRED_INVOICE, invoiceAmount: 667 },
|
||||
response: { invoice: EXPIRED_INVOICE, onchainAmount: 667 },
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1, 'failed reverse with expired BOLT11 must stay visible');
|
||||
assert.strictEqual(txs[0].ispaid, false);
|
||||
assert.ok(txs[0].memo!.startsWith('Failed: '), 'failed reverse keeps a Failed: prefix');
|
||||
assert.strictEqual(txs[0].txid, 'swap-rev-failed');
|
||||
});
|
||||
|
||||
it('preserves refunded submarine swaps with a Refunded: memo prefix', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'sub-refunded',
|
||||
type: 'submarine',
|
||||
status: 'transaction.refunded',
|
||||
createdAt: EXPIRED_INVOICE_TIMESTAMP,
|
||||
request: { invoice: EXPIRED_INVOICE, invoiceAmount: 667 },
|
||||
response: { invoice: EXPIRED_INVOICE, expectedAmount: 667 },
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1, 'refunded submarine with expired BOLT11 must stay visible');
|
||||
assert.strictEqual(txs[0].ispaid, false);
|
||||
assert.strictEqual(txs[0].type, 'payment_request');
|
||||
assert.ok(txs[0].memo!.startsWith('Refunded: '));
|
||||
});
|
||||
|
||||
it('preserves submarine swap.expired with a Failed: prefix (lockup is refundable)', () => {
|
||||
// Fix 3: SDK classifies submarine swap.expired as a refundable failure
|
||||
// — the user's lockup is on-chain and they need the row to recover
|
||||
// funds. Dropping it (as the previous code did) hid that recovery
|
||||
// surface entirely.
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'sub-expired',
|
||||
type: 'submarine',
|
||||
status: 'swap.expired',
|
||||
createdAt: 1700004600,
|
||||
request: { invoice: 'lnbc...subexp', invoiceAmount: 400 },
|
||||
response: { expectedAmount: 400 },
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1, 'swap.expired submarine must stay visible');
|
||||
assert.strictEqual(txs[0].ispaid, false);
|
||||
assert.strictEqual(txs[0].type, 'payment_request');
|
||||
assert.ok(txs[0].memo!.startsWith('Failed: '));
|
||||
assert.strictEqual(txs[0].txid, 'swap-sub-expired');
|
||||
});
|
||||
|
||||
it('still drops submarine invoice.set rows (no funds at risk yet)', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'sub-noopen',
|
||||
type: 'submarine',
|
||||
status: 'invoice.set',
|
||||
createdAt: 1700004700,
|
||||
request: { invoice: 'lnbc...invset', invoiceAmount: 100 },
|
||||
response: { expectedAmount: 100 },
|
||||
},
|
||||
];
|
||||
assert.deepStrictEqual(w.getTransactions(), []);
|
||||
});
|
||||
|
||||
it('maps a pending boarding UTXO as a "Pending refill" bitcoind_tx row', () => {
|
||||
(w as any)._boardingUtxos = [{ txid: 'boardtx', vout: 0, value: 50000, status: { block_time: 1700005000 } }];
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].type, 'bitcoind_tx');
|
||||
assert.strictEqual(txs[0].description, 'Pending refill');
|
||||
assert.strictEqual(txs[0].value, 50000);
|
||||
assert.strictEqual(txs[0].timestamp, 1700005000);
|
||||
assert.strictEqual(txs[0].txid, 'boarding-utxo-boardtx:0');
|
||||
});
|
||||
|
||||
it('falls back to "now" when the boarding UTXO has no block_time yet', () => {
|
||||
(w as any)._boardingUtxos = [{ txid: 'pendingboard', vout: 1, value: 100, status: {} }];
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const tx = w.getTransactions()[0];
|
||||
const after = Math.floor(Date.now() / 1000);
|
||||
assert.ok(tx.timestamp! >= before && tx.timestamp! <= after, 'timestamp falls within now ± 1s');
|
||||
});
|
||||
|
||||
it('maps a settled boarding history record as a "Refill" bitcoind_tx row', () => {
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: 'abc', commitmentTxid: '', arkTxid: '' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 100000,
|
||||
createdAt: 1700006000_000, // SDK uses ms; mapper divides by 1000
|
||||
},
|
||||
];
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].description, 'Refill');
|
||||
assert.strictEqual(txs[0].value, 100000);
|
||||
assert.strictEqual(txs[0].timestamp, 1700006000);
|
||||
assert.strictEqual(txs[0].txid, 'boarding-abc');
|
||||
});
|
||||
|
||||
it('skips unsettled boarding history records (only completed refills surface)', () => {
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: 'pending', commitmentTxid: '', arkTxid: '' },
|
||||
type: 'RECEIVED',
|
||||
settled: false,
|
||||
amount: 5000,
|
||||
createdAt: 1700007000_000,
|
||||
},
|
||||
];
|
||||
assert.deepStrictEqual(w.getTransactions(), []);
|
||||
});
|
||||
|
||||
it('skips non-RECEIVED boarding history records (e.g. SENT, FORFEITED)', () => {
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: 'sent', commitmentTxid: '', arkTxid: '' },
|
||||
type: 'SENT',
|
||||
settled: true,
|
||||
amount: 5000,
|
||||
createdAt: 1700007000_000,
|
||||
},
|
||||
];
|
||||
assert.deepStrictEqual(w.getTransactions(), []);
|
||||
});
|
||||
|
||||
it('maps a native Ark RECEIVED entry (no boardingTxid) as a positive bitcoind_tx row', () => {
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: '', commitmentTxid: '', arkTxid: 'arkrx1' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 7777,
|
||||
createdAt: 1700008000_000,
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].type, 'bitcoind_tx');
|
||||
assert.strictEqual(txs[0].description, 'Received');
|
||||
assert.strictEqual(txs[0].value, 7777);
|
||||
assert.strictEqual(txs[0].timestamp, 1700008000);
|
||||
assert.strictEqual(txs[0].txid, 'ark-arkrx1');
|
||||
});
|
||||
|
||||
it('maps a native Ark SENT entry (no boardingTxid) as a negative bitcoind_tx row', () => {
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: '', commitmentTxid: '', arkTxid: 'arktx2' },
|
||||
type: 'SENT',
|
||||
settled: true,
|
||||
amount: 4321, // SDK reports magnitude; mapper signs from `type`
|
||||
createdAt: 1700009000_000,
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1);
|
||||
assert.strictEqual(txs[0].description, 'Sent');
|
||||
assert.strictEqual(txs[0].value, -4321);
|
||||
assert.strictEqual(txs[0].timestamp, 1700009000);
|
||||
assert.strictEqual(txs[0].txid, 'ark-arktx2');
|
||||
});
|
||||
|
||||
it('falls back to commitmentTxid when arkTxid is missing', () => {
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: '', commitmentTxid: 'commit-only', arkTxid: '' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 100,
|
||||
createdAt: 1700009500_000,
|
||||
},
|
||||
];
|
||||
assert.strictEqual(w.getTransactions()[0].txid, 'ark-commit-only');
|
||||
});
|
||||
|
||||
it('dedupes the Ark-history leg of a swap whose Lightning row we already render', () => {
|
||||
// A reverse swap that settled — Boltz claimed 1000 sat into our wallet.
|
||||
// The SDK history will also contain the matching RECEIVED entry; we must
|
||||
// not show it as a second native-Ark row.
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'rev1',
|
||||
type: 'reverse',
|
||||
status: 'invoice.settled',
|
||||
createdAt: 1700010000,
|
||||
preimage: 'ee'.repeat(32),
|
||||
request: { invoice: 'lnbc...rev1' },
|
||||
response: { invoice: 'lnbc...rev1', onchainAmount: 1000 },
|
||||
},
|
||||
];
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: '', commitmentTxid: '', arkTxid: 'rev1-arkleg' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 1000,
|
||||
createdAt: 1700010120_000, // 2 min after swap.createdAt → inside dedup window
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1, 'one row, not two');
|
||||
assert.strictEqual(txs[0].txid, 'swap-rev1');
|
||||
});
|
||||
|
||||
it('does not dedupe a native Ark transfer against a pending reverse swap', () => {
|
||||
// Fix 1: pending swaps don't yet have an Ark-side leg in
|
||||
// _transactionsHistory, so recording a fingerprint for them would hide
|
||||
// unrelated same-amount native transfers in the ±30-min window.
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'rev-pending-coalesce',
|
||||
type: 'reverse',
|
||||
status: 'swap.created',
|
||||
createdAt: 1700020000,
|
||||
request: { invoice: 'lnbc1u1pjpend' },
|
||||
response: { invoice: 'lnbc1u1pjpend', onchainAmount: 5000 },
|
||||
},
|
||||
];
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: '', commitmentTxid: '', arkTxid: 'unrelated-rx' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 5000,
|
||||
createdAt: 1700020120_000, // inside the ±30-min dedup window
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 2, 'pending swaps must not fingerprint native Ark history');
|
||||
assert.ok(txs.some(t => t.txid === 'swap-rev-pending-coalesce'));
|
||||
assert.ok(txs.some(t => t.txid === 'ark-unrelated-rx'));
|
||||
});
|
||||
|
||||
it('does not dedupe a native Ark transfer against a failed reverse swap', () => {
|
||||
// Fix 1: a failed reverse swap leaves no Ark-side leg (Boltz never
|
||||
// claimed). A coincident same-amount native RECEIVED in the window
|
||||
// must remain visible.
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'rev-failed-coalesce',
|
||||
type: 'reverse',
|
||||
status: 'transaction.failed',
|
||||
createdAt: 1700021000,
|
||||
request: { invoice: 'lnbc...revfail2' },
|
||||
response: { invoice: 'lnbc...revfail2', onchainAmount: 4321 },
|
||||
},
|
||||
];
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: '', commitmentTxid: '', arkTxid: 'real-rx' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 4321,
|
||||
createdAt: 1700021000_000,
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 2, 'failed swaps must not fingerprint native Ark history');
|
||||
assert.ok(txs.some(t => t.txid === 'ark-real-rx'));
|
||||
});
|
||||
|
||||
it('keeps the native-Ark row when its amount or direction differs from any swap', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'rev2',
|
||||
type: 'reverse',
|
||||
status: 'invoice.settled',
|
||||
createdAt: 1700011000,
|
||||
preimage: 'ff'.repeat(32),
|
||||
request: { invoice: 'lnbc...rev2' },
|
||||
response: { invoice: 'lnbc...rev2', onchainAmount: 1000 },
|
||||
},
|
||||
];
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: '', commitmentTxid: '', arkTxid: 'native' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 2222, // different amount → not a dedup match
|
||||
createdAt: 1700011000_000,
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 2);
|
||||
assert.ok(txs.some(t => t.txid === 'swap-rev2'));
|
||||
assert.ok(txs.some(t => t.txid === 'ark-native'));
|
||||
});
|
||||
|
||||
it('hides expired unpaid reverse-swap invoices when expiry was decoded', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'rev-expired',
|
||||
type: 'reverse',
|
||||
status: 'swap.created',
|
||||
createdAt: EXPIRED_INVOICE_TIMESTAMP,
|
||||
request: { invoice: EXPIRED_INVOICE },
|
||||
response: { invoice: EXPIRED_INVOICE, onchainAmount: 667 },
|
||||
},
|
||||
];
|
||||
assert.deepStrictEqual(w.getTransactions(), [], 'expired unpaid pending reverse invoice is hidden');
|
||||
});
|
||||
|
||||
it('keeps submarine pending rows visible even when their BOLT11 has aged out', () => {
|
||||
// Fix 4: by invoice.pending the user's lockup is on-chain. The
|
||||
// expiry-filter applies to reverse only — submarine pending rows must
|
||||
// stay visible until the swap transitions to swap.expired /
|
||||
// transaction.refunded so users don't lose visibility into recoverable
|
||||
// locked funds.
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'sub-stalled',
|
||||
type: 'submarine',
|
||||
status: 'invoice.pending',
|
||||
createdAt: EXPIRED_INVOICE_TIMESTAMP,
|
||||
request: { invoice: EXPIRED_INVOICE, invoiceAmount: 667 },
|
||||
response: { expectedAmount: 667 },
|
||||
},
|
||||
];
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 1, 'submarine pending with expired BOLT11 must stay visible');
|
||||
assert.strictEqual(txs[0].ispaid, false);
|
||||
assert.strictEqual(txs[0].type, 'payment_request');
|
||||
assert.strictEqual(txs[0].txid, 'swap-sub-stalled');
|
||||
});
|
||||
|
||||
it('returns mixed swap + boarding rows in a single list', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'paid',
|
||||
type: 'reverse',
|
||||
status: 'invoice.settled',
|
||||
createdAt: 1700000000,
|
||||
preimage: 'cc'.repeat(32),
|
||||
request: { invoice: 'lnbc...paid' },
|
||||
response: { invoice: 'lnbc...paid', onchainAmount: 1000 },
|
||||
},
|
||||
];
|
||||
(w as any)._boardingUtxos = [{ txid: 'mixboard', vout: 0, value: 2000, status: { block_time: 1700001000 } }];
|
||||
(w as any)._transactionsHistory = [
|
||||
{
|
||||
key: { boardingTxid: 'refilled', commitmentTxid: '', arkTxid: '' },
|
||||
type: 'RECEIVED',
|
||||
settled: true,
|
||||
amount: 3000,
|
||||
createdAt: 1700002000_000,
|
||||
},
|
||||
];
|
||||
|
||||
const txs = w.getTransactions();
|
||||
assert.strictEqual(txs.length, 3);
|
||||
assert.strictEqual(txs[0].type, 'user_invoice');
|
||||
assert.strictEqual(txs[1].description, 'Pending refill');
|
||||
assert.strictEqual(txs[2].description, 'Refill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LightningArkWallet — Realm schema integration', () => {
|
||||
it('combines Ark + Boltz schemas into a single open() schema list', () => {
|
||||
// Opening a per-wallet Realm against [...ArkRealmSchemas, ...BoltzRealmSchemas]
|
||||
// is the integration that lets the SDK and Boltz repositories share one
|
||||
// encrypted file. Pin both halves so a partial drop doesn't silently lose
|
||||
// one repository's data.
|
||||
const arkNames = ArkRealmSchemas.map((s: any) => s.name);
|
||||
const boltzNames = BoltzRealmSchemas.map((s: any) => s.name);
|
||||
|
||||
assert.ok(arkNames.includes('ArkVtxo'), 'Ark schema list missing ArkVtxo');
|
||||
assert.ok(arkNames.includes('ArkUtxo'), 'Ark schema list missing ArkUtxo');
|
||||
assert.ok(arkNames.includes('ArkContract'), 'Ark schema list missing ArkContract');
|
||||
assert.ok(arkNames.includes('ArkWalletState'), 'Ark schema list missing ArkWalletState');
|
||||
assert.ok(arkNames.includes('ArkTransaction'), 'Ark schema list missing ArkTransaction');
|
||||
assert.ok(boltzNames.includes('BoltzSwap'), 'Boltz schema list missing BoltzSwap');
|
||||
|
||||
assert.strictEqual(typeof ARK_REALM_SCHEMA_VERSION, 'number');
|
||||
assert.ok(ARK_REALM_SCHEMA_VERSION >= 1, 'schemaVersion must be a positive integer');
|
||||
|
||||
// Sanity: the two schema lists must not conflict on object name. If the
|
||||
// SDK adds an Ark-side schema with a name that collides with a Boltz one
|
||||
// (or vice versa), Realm.open will throw and re-import will fail silently
|
||||
// for affected users. Catch that at test time instead.
|
||||
const overlap = arkNames.filter((n: string) => boltzNames.includes(n));
|
||||
assert.deepStrictEqual(overlap, [], `schema name collision: ${overlap.join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LightningArkWallet — runtime SDK fields are non-enumerable', () => {
|
||||
it('saveToDisk-style Object.assign({}, wallet) skips _wallet and _arkadeSwaps', () => {
|
||||
// The constructor installs both runtime SDK fields as non-enumerable so
|
||||
// saveToDisk can't try to serialise a half-built SDK graph through
|
||||
// JSON.stringify, and the wallet stays initialised across saves.
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
(w as any)._wallet = { fake: 'wallet' };
|
||||
(w as any)._arkadeSwaps = { fake: 'swaps' };
|
||||
|
||||
// Touch the namespace cache so we can assert it stays non-enumerable too.
|
||||
w.getNamespace();
|
||||
|
||||
const cloned = Object.assign({}, w) as unknown as Record<string, unknown>;
|
||||
assert.ok(!('_wallet' in cloned), '_wallet must not be enumerable');
|
||||
assert.ok(!('_arkadeSwaps' in cloned), '_arkadeSwaps must not be enumerable');
|
||||
assert.ok(!('_namespaceCache' in cloned), '_namespaceCache must not be enumerable');
|
||||
|
||||
// The values are still present on the instance itself.
|
||||
assert.deepStrictEqual((w as any)._wallet, { fake: 'wallet' });
|
||||
assert.deepStrictEqual((w as any)._arkadeSwaps, { fake: 'swaps' });
|
||||
});
|
||||
|
||||
it('getNamespace requires a secret', () => {
|
||||
const w = new LightningArkWallet();
|
||||
assert.throws(() => w.getNamespace(), /No secret provided/);
|
||||
});
|
||||
|
||||
it('getNamespace memoizes per secret and self-invalidates on secret change', () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
|
||||
const first = w.getNamespace();
|
||||
assert.strictEqual(w.getNamespace(), first, 'second call must return cached value');
|
||||
|
||||
// Spy on the hash function to confirm we don't recompute on cache hits.
|
||||
let hashCalls = 0;
|
||||
const originalHashIt = (w as any).hashIt;
|
||||
(w as any).hashIt = (s: string) => {
|
||||
hashCalls += 1;
|
||||
return originalHashIt.call(w, s);
|
||||
};
|
||||
w.getNamespace();
|
||||
assert.strictEqual(hashCalls, 0, 'cached path must skip hashIt');
|
||||
|
||||
// A different secret must produce a different namespace (cache invalidates).
|
||||
w.setSecret('arkade://legal winner thank year wave sausage worth useful legal winner thank yellow');
|
||||
const second = w.getNamespace();
|
||||
assert.notStrictEqual(second, first, 'namespace must change when secret changes');
|
||||
assert.strictEqual(hashCalls, 1, 'cache miss must recompute exactly once');
|
||||
});
|
||||
|
||||
it('exposes module-private caches via __testing__ for tests only', () => {
|
||||
// These caches are exposed for the deletion-vs-init race test. Pin the
|
||||
// shape so a future refactor doesn't silently drop the test surface.
|
||||
assert.ok('staticWalletCache' in walletTesting);
|
||||
assert.ok('staticSwapsCache' in walletTesting);
|
||||
assert.ok('initInFlight' in walletTesting);
|
||||
assert.ok('boardingLock' in walletTesting);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LightningArkWallet — generate', () => {
|
||||
it('refuses init without a secret', async () => {
|
||||
const w = new LightningArkWallet();
|
||||
await assert.rejects(() => (w as any).getArkAddress(), /No secret provided/);
|
||||
});
|
||||
|
||||
it('isInvoiceGeneratedByWallet matches a known incoming swap by payment_request', () => {
|
||||
const w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
(w as any)._swapHistory = [
|
||||
{
|
||||
id: 'mine',
|
||||
type: 'reverse',
|
||||
status: 'invoice.settled',
|
||||
createdAt: 1700000000,
|
||||
preimage: 'dd'.repeat(32),
|
||||
request: { invoice: 'lnbc100u1p50528cpp5...mine' },
|
||||
response: { invoice: 'lnbc100u1p50528cpp5...mine', onchainAmount: 100 },
|
||||
},
|
||||
];
|
||||
assert.ok(w.isInvoiceGeneratedByWallet('lnbc100u1p50528cpp5...mine'));
|
||||
assert.ok(!w.isInvoiceGeneratedByWallet('lnbc999u1psomeoneelse'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('LightningArkWallet — addInvoice + payInvoice (mocked SDK runtime)', () => {
|
||||
// These tests bypass init() (which would auto-start VtxoManager polling and
|
||||
// ContractWatcher subscriptions) by injecting the runtime SDK objects
|
||||
// directly. We exercise the wallet's wiring — fee math, BOLT11 vs Ark
|
||||
// address routing, parameter forwarding — not the SDK's network behavior.
|
||||
let w: LightningArkWallet;
|
||||
const fakeWallet: { sendBitcoin: jest.Mock } = { sendBitcoin: jest.fn() };
|
||||
const fakeArkadeSwaps: {
|
||||
createLightningInvoice: jest.Mock;
|
||||
sendLightningPayment: jest.Mock;
|
||||
} = {
|
||||
createLightningInvoice: jest.fn(),
|
||||
sendLightningPayment: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
fakeWallet.sendBitcoin.mockReset().mockResolvedValue(undefined);
|
||||
fakeArkadeSwaps.createLightningInvoice.mockReset();
|
||||
fakeArkadeSwaps.sendLightningPayment.mockReset();
|
||||
// Wire the wallet up as if init() had already completed.
|
||||
(w as any)._wallet = fakeWallet;
|
||||
(w as any)._arkadeSwaps = fakeArkadeSwaps;
|
||||
// _fetchLightningFeesAndLimits seeds these from Boltz; bypass by setting
|
||||
// them directly so the assertion guards inside addInvoice / payInvoice
|
||||
// pass.
|
||||
(w as any)._limitMin = 100;
|
||||
(w as any)._limitMax = 1_000_000;
|
||||
(w as any)._feePercentage = 0;
|
||||
});
|
||||
|
||||
it('addInvoice returns the BOLT11 string from ArkadeSwaps.createLightningInvoice', async () => {
|
||||
fakeArkadeSwaps.createLightningInvoice.mockResolvedValue({
|
||||
invoice: 'lnbc1234u1pjabcdef',
|
||||
paymentHash: 'cafebabe',
|
||||
expiry: 3600,
|
||||
pendingSwap: {},
|
||||
preimage: undefined,
|
||||
});
|
||||
|
||||
const out = await w.addInvoice(50_000, 'coffee');
|
||||
|
||||
assert.strictEqual(out, 'lnbc1234u1pjabcdef');
|
||||
assert.strictEqual(fakeArkadeSwaps.createLightningInvoice.mock.calls.length, 1);
|
||||
const call = fakeArkadeSwaps.createLightningInvoice.mock.calls[0][0];
|
||||
assert.strictEqual(call.amount, 50_000); // _feePercentage=0 → no surcharge
|
||||
assert.strictEqual(call.description, 'coffee');
|
||||
});
|
||||
|
||||
it('addInvoice adds the Boltz reverse-fee surcharge to the amount it asks for', async () => {
|
||||
(w as any)._feePercentage = 0.5; // 0.5% reverse fee
|
||||
fakeArkadeSwaps.createLightningInvoice.mockResolvedValue({ invoice: 'lnbc...', paymentHash: '', expiry: 3600 });
|
||||
|
||||
await w.addInvoice(10_000, 'fees');
|
||||
|
||||
const call = fakeArkadeSwaps.createLightningInvoice.mock.calls[0][0];
|
||||
// 10_000 * 0.5 / 100 = 50 sat surcharge → request 10_050
|
||||
assert.strictEqual(call.amount, 10_050);
|
||||
});
|
||||
|
||||
it('addInvoice rejects amounts at or below the Boltz minimum', async () => {
|
||||
(w as any)._limitMin = 1000;
|
||||
await assert.rejects(() => w.addInvoice(1000, 'too small'), /Minimum to receive/);
|
||||
await assert.rejects(() => w.addInvoice(500, 'too small'), /Minimum to receive/);
|
||||
});
|
||||
|
||||
it('addInvoice rejects amounts at or above the Boltz maximum', async () => {
|
||||
(w as any)._limitMax = 1_000_000;
|
||||
await assert.rejects(() => w.addInvoice(1_000_000, 'too big'), /Maximum to receive/);
|
||||
await assert.rejects(() => w.addInvoice(2_000_000, 'too big'), /Maximum to receive/);
|
||||
});
|
||||
|
||||
it('payInvoice routes a BOLT11 invoice through ArkadeSwaps.sendLightningPayment', async () => {
|
||||
// Real BOLT11 with amount = 0.0001 BTC (10000 sat) so it passes the limits assertion.
|
||||
const invoice =
|
||||
'lnbc100u1p50528cpp5rhy4fgs0ff23asecxtxt9zvc3apn0p8h7fxsj0d5k7j3x92zwhlqdq5w3jhxapqd9h8vmmfvdjscqrp80xqyf8ucsp5vcsrzye432n9wh0zwuv5z8y5n9zvkwpctr685e80utzc2yueccms9qxpqysgqd87swq3hput9k6llp0wxg098hc7ge3e5nrtnvak6zreywzaf4k9s8d3u4hrmt3m22kf0jt7ruqj0caknk5ykzdenjdphz50t7xrstnqqn6aw0m';
|
||||
fakeArkadeSwaps.sendLightningPayment.mockResolvedValue({ amount: 10_000, preimage: 'pre', txid: 'tx' });
|
||||
|
||||
await w.payInvoice(invoice);
|
||||
|
||||
assert.strictEqual(fakeArkadeSwaps.sendLightningPayment.mock.calls.length, 1);
|
||||
assert.strictEqual(fakeArkadeSwaps.sendLightningPayment.mock.calls[0][0].invoice, invoice);
|
||||
assert.strictEqual(fakeWallet.sendBitcoin.mock.calls.length, 0, 'Ark sendBitcoin must not run for BOLT11');
|
||||
});
|
||||
|
||||
it('payInvoice routes a valid Ark address through Wallet.sendBitcoin', async () => {
|
||||
const arkAddress = 'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t5z8sz5n95k570z5r004szc9h2q3qprkzdd5zveujdpx24srcrqg8hf6j4v';
|
||||
|
||||
await w.payInvoice(arkAddress, 12_345);
|
||||
|
||||
assert.strictEqual(fakeWallet.sendBitcoin.mock.calls.length, 1);
|
||||
assert.deepStrictEqual(fakeWallet.sendBitcoin.mock.calls[0][0], {
|
||||
address: arkAddress,
|
||||
amount: 12_345,
|
||||
});
|
||||
assert.strictEqual(fakeArkadeSwaps.sendLightningPayment.mock.calls.length, 0, 'Lightning swap must not run for native Ark transfers');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LightningArkWallet — per-swap claim/refund + restore', () => {
|
||||
// Like the addInvoice/payInvoice block, we bypass init() and inject the SDK
|
||||
// runtime objects. These tests assert the wiring (delegation + post-action
|
||||
// refresh + concurrent-call coalescing), not SDK network behavior.
|
||||
let w: LightningArkWallet;
|
||||
const fakeArkadeSwaps: {
|
||||
claimVHTLC: jest.Mock;
|
||||
refundVHTLC: jest.Mock;
|
||||
restoreSwaps: jest.Mock;
|
||||
getSwapHistory: jest.Mock;
|
||||
} = {
|
||||
claimVHTLC: jest.fn(),
|
||||
refundVHTLC: jest.fn(),
|
||||
restoreSwaps: jest.fn(),
|
||||
getSwapHistory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
w = new LightningArkWallet();
|
||||
w.setSecret('arkade://' + TEST_MNEMONIC);
|
||||
fakeArkadeSwaps.claimVHTLC.mockReset().mockResolvedValue(undefined);
|
||||
fakeArkadeSwaps.refundVHTLC.mockReset().mockResolvedValue({ swept: 0, skipped: 0 });
|
||||
fakeArkadeSwaps.restoreSwaps.mockReset().mockResolvedValue({ chainSwaps: [], reverseSwaps: [], submarineSwaps: [] });
|
||||
fakeArkadeSwaps.getSwapHistory.mockReset().mockResolvedValue([]);
|
||||
// presence is enough; refresh helpers are stubbed below
|
||||
(w as any)._wallet = {};
|
||||
(w as any)._arkadeSwaps = fakeArkadeSwaps;
|
||||
jest.spyOn(w, 'fetchTransactions').mockResolvedValue();
|
||||
jest.spyOn(w, 'fetchBalance').mockResolvedValue();
|
||||
});
|
||||
|
||||
it('getSwapById returns the matching swap, undefined for unknown id', () => {
|
||||
(w as any)._swapHistory = [
|
||||
{ id: 'swap-A', type: 'reverse' },
|
||||
{ id: 'swap-B', type: 'submarine' },
|
||||
];
|
||||
assert.deepStrictEqual(w.getSwapById('swap-A'), { id: 'swap-A', type: 'reverse' });
|
||||
assert.deepStrictEqual(w.getSwapById('swap-B'), { id: 'swap-B', type: 'submarine' });
|
||||
assert.strictEqual(w.getSwapById('nope'), undefined);
|
||||
});
|
||||
|
||||
it('isSwapClaimable / isSwapRefundable use the SDK status predicates', () => {
|
||||
// The SDK predicates branch on swap.type + status. Use real swap shapes
|
||||
// with the right status (per node_modules/@arkade-os/boltz-swap status
|
||||
// tables) to verify the wiring without re-stubbing the predicates.
|
||||
const claimableReverse: any = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
const refundableSubmarine: any = { id: 's1', type: 'submarine', status: 'swap.expired' };
|
||||
const settledReverse: any = { id: 'r2', type: 'reverse', status: 'invoice.settled' };
|
||||
|
||||
assert.strictEqual(w.isSwapClaimable(claimableReverse), true);
|
||||
assert.strictEqual(w.isSwapClaimable(refundableSubmarine), false);
|
||||
assert.strictEqual(w.isSwapClaimable(settledReverse), false);
|
||||
|
||||
assert.strictEqual(w.isSwapRefundable(refundableSubmarine), true);
|
||||
assert.strictEqual(w.isSwapRefundable(claimableReverse), false);
|
||||
assert.strictEqual(w.isSwapRefundable(settledReverse), false);
|
||||
});
|
||||
|
||||
it('claimSwap delegates to ArkadeSwaps.claimVHTLC and refreshes balance + transactions', async () => {
|
||||
const swap: any = { id: 'r1', type: 'reverse', status: 'transaction.confirmed' };
|
||||
|
||||
await w.claimSwap(swap);
|
||||
|
||||
assert.strictEqual(fakeArkadeSwaps.claimVHTLC.mock.calls.length, 1);
|
||||
assert.strictEqual(fakeArkadeSwaps.claimVHTLC.mock.calls[0][0], swap);
|
||||
// @ts-expect-error spy
|
||||
assert.strictEqual(w.fetchTransactions.mock.calls.length, 1);
|
||||
// @ts-expect-error spy
|
||||
assert.strictEqual(w.fetchBalance.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('refundSwap delegates to ArkadeSwaps.refundVHTLC and forwards the SubmarineRefundOutcome', async () => {
|
||||
fakeArkadeSwaps.refundVHTLC.mockResolvedValue({ swept: 1, skipped: 0 });
|
||||
const swap: any = { id: 's1', type: 'submarine', status: 'swap.expired' };
|
||||
|
||||
const outcome = await w.refundSwap(swap);
|
||||
|
||||
assert.deepStrictEqual(outcome, { swept: 1, skipped: 0 });
|
||||
assert.strictEqual(fakeArkadeSwaps.refundVHTLC.mock.calls.length, 1);
|
||||
assert.strictEqual(fakeArkadeSwaps.refundVHTLC.mock.calls[0][0], swap);
|
||||
// @ts-expect-error spy
|
||||
assert.strictEqual(w.fetchTransactions.mock.calls.length, 1);
|
||||
// @ts-expect-error spy
|
||||
assert.strictEqual(w.fetchBalance.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('refundSwap with swept=0 still resolves and reports the deferred outcome', async () => {
|
||||
fakeArkadeSwaps.refundVHTLC.mockResolvedValue({ swept: 0, skipped: 2 });
|
||||
const swap: any = { id: 's2', type: 'submarine', status: 'swap.expired' };
|
||||
|
||||
const outcome = await w.refundSwap(swap);
|
||||
assert.deepStrictEqual(outcome, { swept: 0, skipped: 2 });
|
||||
});
|
||||
|
||||
it('restoreSwaps delegates to ArkadeSwaps.restoreSwaps and refreshes the local swap history', async () => {
|
||||
fakeArkadeSwaps.getSwapHistory.mockResolvedValue([{ id: 'restored', type: 'reverse' }]);
|
||||
|
||||
await w.restoreSwaps();
|
||||
|
||||
assert.strictEqual(fakeArkadeSwaps.restoreSwaps.mock.calls.length, 1);
|
||||
assert.strictEqual(fakeArkadeSwaps.getSwapHistory.mock.calls.length, 1);
|
||||
assert.deepStrictEqual((w as any)._swapHistory, [{ id: 'restored', type: 'reverse' }]);
|
||||
});
|
||||
|
||||
it('restoreSwaps coalesces concurrent calls into a single in-flight SDK request', async () => {
|
||||
let resolveRestore!: () => void;
|
||||
fakeArkadeSwaps.restoreSwaps.mockImplementation(
|
||||
() =>
|
||||
new Promise<{ chainSwaps: any[]; reverseSwaps: any[]; submarineSwaps: any[] }>(resolve => {
|
||||
resolveRestore = () => resolve({ chainSwaps: [], reverseSwaps: [], submarineSwaps: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const a = w.restoreSwaps();
|
||||
const b = w.restoreSwaps();
|
||||
const c = w.restoreSwaps();
|
||||
resolveRestore();
|
||||
await Promise.all([a, b, c]);
|
||||
|
||||
// Three callers, one underlying SDK request.
|
||||
assert.strictEqual(fakeArkadeSwaps.restoreSwaps.mock.calls.length, 1);
|
||||
assert.strictEqual(fakeArkadeSwaps.getSwapHistory.mock.calls.length, 1);
|
||||
});
|
||||
|
||||
it('restoreSwaps clears the in-flight entry on rejection so the next call can retry', async () => {
|
||||
fakeArkadeSwaps.restoreSwaps.mockRejectedValueOnce(new Error('boom'));
|
||||
await assert.rejects(() => w.restoreSwaps(), /boom/);
|
||||
|
||||
// Next call should issue a fresh SDK request, not surface the cached rejection.
|
||||
fakeArkadeSwaps.restoreSwaps.mockResolvedValueOnce({ chainSwaps: [], reverseSwaps: [], submarineSwaps: [] });
|
||||
await w.restoreSwaps();
|
||||
assert.strictEqual(fakeArkadeSwaps.restoreSwaps.mock.calls.length, 2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user