Compare commits

...

24 Commits

Author SHA1 Message Date
Pietro Grandi
900853f7f2
Update lock file 2026-05-15 11:41:59 +02:00
pietro909
1942071063
Merge branch 'master' into upgrade-arkade-sdks 2026-05-15 11:40:41 +02:00
Pietro Grandi
0f52e8d62c
Fix unit tests lag 2026-05-15 10:13:11 +02:00
pietro909
09a152e3c7
Merge branch 'master' into upgrade-arkade-sdks 2026-05-14 17:20:43 +02:00
Pietro Grandi
9b8b96b8e3
Fix merge commit: screen/transactions/TransactionDetails.tsx -> screen/transactions/TransactionStatus.tsx 2026-05-13 11:12:32 +02:00
Pietro Grandi
93e21d7433
Merge branch 'master' of github.com:BlueWallet/BlueWallet into upgrade-arkade-sdks 2026-05-13 10:52:07 +02:00
Pietro Grandi
c23f5afdc6
FIX: detect withTimeout deadline by error message, not clock
setTimeout can fire before Date.now() reflects the deadline, causing
cancelRequested to stay false and the swap loop to over-poll.
2026-05-11 21:39:06 +02:00
Pietro Grandi
a53a49963e
Fix lint errors 2026-05-11 16:31:26 +02:00
Pietro Grandi
5c660dcebb
FIX: post-merge fixes, cleanup, updates 2026-05-11 00:25:54 +02:00
Pietro Grandi
bab87a04d0
Merge branch 'master' of github.com:BlueWallet/BlueWallet into upgrade-arkade-sdks 2026-05-10 23:19:00 +02:00
Pietro Grandi
e2f3c8d171
Set referral for Boltz 2026-05-10 22:56:04 +02:00
Pietro Grandi
8d8b6669fb
FIX: address PR review nits — limitMax message, getNamespace memo, lint suppression rationale
- lightning-ark-wallet.addInvoice: max-receive error message reported the
  min limit instead of the max (limpbrains).
- lightning-ark-wallet.getNamespace: memoize sha256(secret) per instance,
  keyed by the secret value so a later setSecret with a different mnemonic
  self-invalidates without overriding the inherited setter (Overtorment).
- WalletTransactions.renderItem: keep the eslint-disable but explain why
  react/no-unused-prop-types misfires here, since removing it surfaces a
  real lint error (limpbrains' nit was inverted).
2026-05-10 22:54:17 +02:00
Pietro Grandi
6a038424d1
ADD: notify for actionable Ark swaps
When background polling observes an SDK predicate flagging a swap as
claimable/refundable, post a local notification routed back into the
foreground LNDViewInvoice CTA. Suppression lives per-wallet in the
Arkade Realm (bucket-scoped, encrypted) — not in global AsyncStorage —
so namespace handles do not leak across encryption buckets. The OS
payload omits namespace for the same reason; tap routing re-derives
it from the loaded wallet.

Predicates run on every non-terminal poll against an explicit
effectiveSwap = { ...swap, status: remoteStatus } because the SDK
update helpers save a copy and do not mutate; otherwise a swap that
became actionable but never received a successful post would never
be re-checked. Pre-flight reads OS permission and the app-level
opt-out flag without prompting from headless context. Channel
registration is lazy — calling setNotificationChannel at module-top
breaks RN bootstrap on real Android.
2026-05-09 15:56:37 +02:00
Pietro Grandi
7c733fa7fa
ADD: background-task plumbing for Ark swap monitoring
Adds react-native-background-fetch with passive (no claim/refund,
no notifications) status polling for non-terminal Boltz swaps in
every Ark wallet's per-wallet Realm. Foreground reconciliation
refreshes affected wallets on resume; SelfTest drains the
background-fetch listener before running so Detox idle resources
do not stall the JS bridge.

Identity remains foreground-only (Phase 1A): the task no-ops
cleanly when the device is locked.
2026-05-08 23:09:25 +02:00
Pietro Grandi
747680445f
FIX: keep transactions header mounted 2026-05-08 14:35:56 +02:00
Pietro Grandi
a7beac4df2
TST: stabilize e2e suite to 22/23 on real device
- SelfTest.tsx: refresh full Ark address fixture for current delegator output.
- android/build.gradle: keep the support-* exclusion. Real source is
react-native-device-info -> play-services-iid:16.0.1 -> support-v4:26.1.0
in :debugAndroidTestRuntimeClasspath. Documented inline.
- t1 selftest: wrap the SelfTestOk waitFor in disableSynchronization so the
1000-iteration crypto loops don't trip FabricTimersIdlingResource.
- t1 selftest: explicit waitFor(AboutScrollView).toBeVisible() before
whileElement().scroll() to fix the cold-launch navigation race.
- bluewallet3: same treatment for PsbtWithHardwareScrollView.
- t5 plausible-deniability: make scrollUpOnHomeScreen() after fake-storage
creation unconditional (was iOS-only); matches t4's path so the next
helperCreateWallet finds CreateAWallet on Android too.
- helperDeleteWallet: defensive waitForId('WalletDetails') between the
wallet tap and the kebab tap.
2026-05-08 13:44:44 +02:00
Pietro Grandi
73115d86aa
ADD: per-swap claim/refund and import-time restore for Arkade
Exposes Claim/Refund CTAs on the existing swap detail screen, runs
restoreSwaps() once at import time, and adds a manual restore button in
wallet details. Fixes restoreSwaps() joiner-instance stale state, derives
swap freshly on each render in lndViewInvoice, and aligns FlatList/
SectionList keyExtractors with the row-level key props.
2026-05-07 14:25:49 +02:00
Pietro Grandi
4e384a1cd3
REF: map Arkade activity from swaps and wallet history
Coalesces _swapHistory (Boltz), _transactionsHistory (SDK), and
_boardingUtxos into the existing transaction list as the single
source of activity for getTransactions.

Mapping:
- Reverse swaps → Lightning receive. settled → paid; failed/refunded
  → kept with a "Failed: " / "Refunded: " memo prefix.
- Submarine swaps → Lightning send. claimed → paid; refunded →
  Refunded:; failed states → Failed:. swap.expired is refundable so
  the row stays visible for recovery. Only invoice.set is dropped.
- Boarding UTXOs → Pending refill rows; settled boarding (boardingTxid
  + RECEIVED + settled) → Refill rows; others suppressed.
- Native Ark history → SENT/RECEIVED rows with sign from TxType.

Coalescing — settled-only fingerprint dedupe: only reverse
invoice.settled and submarine transaction.claimed produce fingerprints
(direction, |amount|, ±30 min against _transactionsHistory).
Pending/failed/refunded swaps don't fingerprint to avoid swallowing a
coincident native Ark transfer. Stable row ids: swap-<id>,
boarding-<txid>, boarding-utxo-<txid>:<vout>, ark-<txid>.

Hiding rules:
- Submarine invoice.set dropped.
- Expired-unpaid filter: reverse AND !ispaid AND !memoPrefix AND
  timestamp+expiry < now. Gated to reverse (submarine lockup stays
  visible while on-chain) and !memoPrefix (terminal rows stay for
  diagnosis).

Settlement signal: every Lightning/Ark row uses ispaid.
WalletsCarousel pending-pill splits by wallet type — Lightning wallets
match on ispaid===false; Bitcoin wallets keep confirmations===0.

43 unit tests cover the full mapping matrix, dedupe boundaries, and
expired-BOLT11 behavior. tsc clean.
2026-05-07 14:25:49 +02:00
Pietro Grandi
57688c8038
REF: use Arkade swap manager in foreground
Hand swap claim/refund and VTXO renewal off to the SDK so the wallet
stops polling and driving flows the SDK already owns.

- swapManager: true on ArkadeSwaps — the SDK's WebSocket+poll-fallback
  monitor auto-claims reverse swaps and auto-refunds failed submarine
  swaps. _attemptToClaimPendingVHTLCs and _claimedSwaps cache removed.
- _subscribeToSwapEvents wires onSwapCompleted/onSwapFailed/
  onActionExecuted to refresh balance + history so the UI poller picks
  up SwapManager actions without a user-driven fetch.
- settlementConfig: {} on Wallet.create so VtxoManager handles VTXO
  renewal and boarding-UTXO settlement. The setTimeout renewVtxos shim
  and foreground Ramps.onboard call in _attemptBoardUtxos are removed —
  running them alongside the SDK's per-input cooldown invites double-submit.
- DELEGATOR_URLS keyed by network (bitcoin/mutinynet/regtest/signet/
  testnet); signet/testnet pass delegatorProvider: undefined to avoid
  building the wrong offchain tapscript against the mainnet URL.
- Preflight getDelegateInfo() before Wallet.create so a flaky delegator
  surfaces as "Delegate service unreachable (<url>): <reason>" via the
  existing screen alert.
- onDelete disposes the cached ArkadeSwaps and Wallet so SwapManager's
  WebSocket/poll timers and VtxoManager's settle loop stop before Realm
  files are deleted.
2026-05-07 14:24:52 +02:00
Pietro Grandi
364fdee549
TST: cover Ark wallet SDK contract
Pin BlueWallet-facing behavior of LightningArkWallet before further
internal changes. Adds 32 unit tests across four areas:

- Derivation regression: lock delegate-flavored Ark address and
  namespace hash for the canonical test mnemonic.
- Pure surface: isAddressValid, decodeInvoice, isInvoiceExpired.
- getTransactions mapping: empty, send/receive, pending/hidden
  invoices, _claimedSwaps, boarding UTXOs, refill variants.
- addInvoice/payInvoice: BOLT11 path, fee surcharge, min/max guards,
  BOLT11 vs Ark address routing.

Two test helpers added under tests/helpers/: arkadeMocks.ts and
sdkProviderMocks.ts. Drop the obsolete AsyncStorage mock from the
integration file (Ark storage moved to Realm in Phase 1). Update
SelfTest.tsx to assert on the stable address prefix instead of the
full address, which now depends on a rotatable delegator pubkey.
2026-05-07 14:23:42 +02:00
Pietro Grandi
0ad4ade527
FIX: register delegate contract via RestDelegatorProvider on Wallet.create
Without a delegatorProvider the SDK builds a DefaultVtxo.Script as the
offchain tapscript and registers only the `default` contract, so a
restored wallet's getAddress() returns the non-delegate address and
the indexer query never sees funds previously sent by the canonical
Arkade wallet (which defaults to delegate-on for mainnet). Symptom:
restore shows zero balance and zero VTXOs even when the same mnemonic
holds a balance in the canonical wallet.

The SDK ships no built-in fallback — every consumer wires the URL.
Match ../master/wallet/src/lib/constants.ts and point at
https://delegate.arkade.money for mainnet so the SDK constructs a
DelegateVtxo.Script offchain tapscript and registers both default and
delegate contracts; the indexer is then queried on the right script
and the balance comes back.
2026-05-07 14:19:17 +02:00
Pietro Grandi
b58c67d661
REF: harden Arkade Realm persistence lifecycle
Phase 2 of the Arkade upgrade. Per-wallet encryption-key creation now
preflights Keychain.getSecurityLevel() instead of catching every
setGenericPassword failure, and deleteArkadeRealm gates the key reset
on Realm.deleteFile success so a partial cleanup cannot strand the
user with an orphan encrypted file. LightningArkWallet's runtime SDK
objects are non-enumerable (saveToDisk's Object.assign skips them) and
onDelete drains any in-flight init before clearing caches so the
racing IIFE tail cannot resurrect staticWalletCache / staticSwapsCache
/ realmInstances after deletion. The Jest harness gains a per-path
Realm mock with deleteFile/exists, a service-keyed Keychain mock with
getSecurityLevel, and a @noble/hashes/X.js moduleNameMapper mirroring
metro's resolveRequest fix.
2026-05-07 14:19:15 +02:00
Pietro Grandi
5702eb2082
FIX: wire Ark wallet to Arkade SDK repositories
Replace ArkadeLightning with ArkadeSwaps + per-wallet Realm-backed
RealmWalletRepository/ContractRepository/SwapRepository. Metro now
aliases the Arkade SDK to its CJS build and redirects descriptors-core
@noble/hashes 2.x imports to the v2 copy nested under descriptors-scure.
2026-05-07 14:19:14 +02:00
Pietro Grandi
d154b9fb6c
OPS: Upgrade ts-sdk 0.4.25 - boltz-swap 0.3.29 2026-05-07 14:19:13 +02:00
42 changed files with 4906 additions and 511 deletions

View File

@ -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 ->

View File

@ -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);
});
}
}

View 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,
};

View 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;
},
};

View 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;
},
};

View File

@ -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();
}

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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)

View File

@ -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());

View File

@ -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(() => {

View File

@ -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

View File

@ -5,6 +5,7 @@
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>io.bluewallet.bluewallet.fetchTxsForWallet</string>
<string>com.transistorsoft.fetch</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>

View File

@ -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'],

View File

@ -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",

View File

@ -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
View File

@ -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": {

View File

@ -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",

View File

@ -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;

View File

@ -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 {

View File

@ -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;

View File

@ -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>

View File

@ -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

View File

@ -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(

View File

@ -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');

View File

@ -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'))

View File

@ -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);

View 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 });
},
};

View 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();
}

View File

@ -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"}]

View File

@ -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"}]

View File

@ -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"}]

View File

@ -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'));
});
});

View File

@ -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 = () => {};

View 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);
});
});

View 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;
}
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});