Compare commits

...

42 Commits

Author SHA1 Message Date
Pietro Grandi
196beeda9f
FIX: Arkade Refill rows are not tappable, matching master
On master, Refill / Pending refill rows (type 'bitcoind_tx', no hash, no txid)
are not tappable — tapping is a no-op. This branch gave them synthetic boarding
txids and a txid -> TransactionStatus tap branch, so they began opening the
on-chain detail. Gate that branch on the txid prefix: native Ark transfer legs
(ark-) keep opening the hash-less-tolerant TransactionStatus detail, while Refill
rows (boarding- / boarding-utxo-) navigate nowhere, restoring master behavior.

Applied to both the row tap (onPress) and the long-press Details menu. The guard
uses the same boarding- prefix that arkRowKind uses to label a row 'Refill', so
tap behavior stays consistent with the label.
2026-06-01 00:21:42 +02:00
Pietro Grandi
9b433c6511
FIX: settled Arkade swaps open the Lightning invoice view, not the on-chain detail
Settled swaps are emitted as enriched native Ark legs (type 'bitcoind_tx',
synthetic 'ark-' id) that carry the swap's invoice payload. TransactionListItem
routed taps by type, so these missed the invoice branch and fell through to the
on-chain TransactionStatus screen. Route on 'payment_request' presence instead, in
both onPress and the long-press Details path, so enriched legs reach LNDViewInvoice
while refills and pure Ark transfers (no payload) keep the on-chain detail.

LNDViewInvoice gains an amount fallback to abs(value): an enriched leg has no 'amt',
so the settled success view would otherwise render 0 sats.

Locks the data contract with four unit assertions: enriched settled swaps carry
payment_request; refill rows do not.
2026-06-01 00:11:24 +02:00
Pietro Grandi
d0b6d0e90c
FIX: Arkade refill stays Pending until settled; no balance double-count
Manage Funds -> Refill had two issues:

1. A refill appeared immediately as a completed "Received / Refill" row and the headline balance jumped right away. fetchBalance() used SDK balance.total (offchain + boarding), so an unconfirmed boarding deposit inflated the balance before it was usable, and the pending boarding row (boarding-utxo-...) rendered as a confirmed receive.

2. After the deposit confirmed, the balance briefly showed ~2x the refill. getBalance().total = boarding(confirmed+unconfirmed) + offchain; during settlement the boarding UTXO is still unspent in getCoins() while its freshly-minted preconfirmed VTXO is already counted, so total double-counts the refill until getCoins() drops the spent UTXO.

Fix: headline balance is now available + recoverable (= total minus boarding.total) in both fetchBalance() and the swap-event handler, so a refill enters the balance once, at settlement. Refill rows render as "Pending" until settlement promotes them to a settled "Refill" row, and the detail screen (resolveTxDisplayState) is kept in parity. A dedup guard prevents one refill from showing as both settled and pending if the SDK feeds momentarily disagree.

Matches the reference wallets (../wallet preconfirmed:!settled, ../trixie-wallet headline = available).
2026-05-30 22:24:22 +02:00
pietro909
7fcf9317f6
Merge branch 'master' into feat/arkade-sdk-upgrade 2026-05-29 07:31:19 +02:00
Pietro Grandi
0ba77f2e9b
REF: remove boarding-twin workaround, fixed upstream in SDK 0.4.32
The consumer-side fingerprint pre-pass that collapsed duplicate boarding +
commitment-sweep rows into one Refill entry is no longer needed.
arkade-os/ts-sdk#531 (released in 0.4.32) fixes commitmentsToIgnore de-dup
at the source.
2026-05-29 07:29:47 +02:00
Pietro Grandi
219693368e
Upgrade arkade-os SDKs 2026-05-29 07:23:48 +02:00
Pietro Grandi
7192fa49f9
FIX: collapse SDK boarding+commitment refill twins into one Arkade row
getTransactionHistory() reports a swept boarding refill TWICE: once as the
boarding leg (key.boardingTxid) and once as the round key.commitmentTxid that
sweeps it into a VTXO — both RECEIVED, same amount, minutes apart, the
commitment leg carrying no arkTxid. Pass 1 of getTransactions() routed them to
two rows: a "Refill" and a phantom "Lightning" leg of the same amount.

Pre-collect the boarding fingerprints, then drop the commitment-sweep twin: a
RECEIVED entry keyed only by commitmentTxid (no arkTxid) that matches a boarding
fingerprint within the match window is the sweep, already represented by the
Refill row. Fingerprints are consumed once so each refill drops exactly one
twin; genuine arkTxid receives/sends are untouched.

Downstream workaround for an SDK bug: the SDK's own commitmentsToIgnore de-dup
misses when the boarding output's on-chain spender txid differs from the
resulting VTXO's commitmentTxId. Remove once fixed upstream.
2026-05-28 22:06:12 +02:00
pietro909
b7bac765a4
Merge branch 'master' into feat/arkade-sdk-upgrade 2026-05-28 22:04:06 +02:00
pietro909
876a49767b
Merge branch 'master' into feat/arkade-sdk-upgrade 2026-05-28 16:02:29 +02:00
Pietro Grandi
4dd3216f42
FIX: dedupe Arkade history by making SDK history the single row source
getTransactions() merged three feeds (Boltz swaps, boarding UTXOs, SDK
history) and reconciled the swap overlay against the SDK rows with a
fragile fingerprint matcher, so any timestamp skew surfaced one
settlement twice — e.g. a refill also showing as a phantom "Lightning"
row of the same amount.

Rebuild it: getTransactionHistory() is the single source of rows
(boarding entry -> Refill via TxKey.boardingTxid, everything else ->
native Lightning leg). Settled swaps enrich their matching native leg
in place and emit no row of their own; only non-settled swaps that need
visibility (claimable/in-flight/failed/refunded, plus open invoices for
the registry) emit a swap- row. A settlement can no longer appear both
as its native leg and as a swap- row, regardless of timestamp skew.

isInvoiceGeneratedByWallet() now answers from _swapHistory directly so a
settled invoice stays recognizable after its row is enriched onto the leg.
2026-05-28 13:18:27 +02:00
pietro909
c91deba2d5
Merge branch 'master' into feat/arkade-sdk-upgrade 2026-05-28 10:48:14 +02:00
Pietro Grandi
e77afb7d6f
Merge bw/master into feat/arkade-sdk-upgrade
Both conflicts resolved by keeping this branch's versions, which deliberately
diverged for New Architecture / Detox reasons that bw/master's changes would
regress:

- components/TransactionsNavigationHeader.tsx: kept our ActionSheet-based
  Manage Funds button. It avoids ToolTipMenu because react-native-context-menu-view
  mispositions under Fabric (overlapping the wallet label); bw/master's
  "FIX: Manage funds layout" keeps ToolTipMenu inside a balanceSection wrapper.
- screen/settings/SelfTest.tsx: kept our removal of the BlueElectrum network
  self-test (network calls + live timers hang Detox/FabricTimersIdlingResource
  on CI); bw/master's "REF: blue electrum" only refactored that block.
2026-05-28 10:20:03 +02:00
Pietro Grandi
d8c2074259
FIX: keep open Arkade invoices in the invoice registry, hidden from history
getTransactions() is both the history-list feed and the source for
getUserInvoices()/isInvoiceGeneratedByWallet(). The unpaid-reverse display
drop (6dcc4a369) therefore also removed freshly-created invoices from those
two methods, so the receive-screen poll couldn't match an open invoice and
the clipboard heuristic offered to pay the user's own just-created invoice.

Make the drop opt-out via includeUnpaidInvoices (default false); the two
registry methods pass true. The history list and every other caller are
unchanged, so the no-phantom-Pending behavior is preserved.
2026-05-28 09:57:56 +02:00
Pietro Grandi
c036ea4c73
FIX: show restored Arkade Lightning history as Lightning, not Arkade
An Arkade Lightning wallet transacts entirely via Boltz swaps, so every
history row is Lightning except onboarding/refill. On a restored wallet
_swapHistory is empty, so recovered swap legs surfaced as generic "Arkade"
on-chain rows. Tag rows off the synthetic txid prefix (boarding- = refill,
everything else = Lightning) and render the recovered bitcoind_tx legs with
the off-chain icon.
2026-05-28 09:39:41 +02:00
Pietro Grandi
15deae4a17
FIX: correct Arkade tx-detail status, stop Electrum lookup of synthetic ids
Opening an Arkade/Lightning row on TransactionStatus showed a permanent
"Pending" and logged "Transaction from Electrum with hash ark-... not found"
on every poll. The screen derived status from confirmations (which Ark/LN
rows never carry) and fed their synthetic ark-/boarding- id to Electrum as
if it were a Bitcoin txid.

Branch on transaction kind at the UI boundary: rows with a real on-chain
hash keep confirmations-based status and Electrum polling; hash-less Ark
rows derive status from type/value/ispaid and never poll. Suppress the
otherwise-"NaN confirmations" sub-value for off-chain rows. Status logic
extracted to blue_modules/transactionDisplayState.ts (pure, unit-tested);
on-chain Bitcoin behavior is unchanged.
2026-05-27 18:17:15 +02:00
Pietro Grandi
ff7e206d4e
FIX: use "Arkade" instead of "Ark" in user-facing strings 2026-05-27 15:25:58 +02:00
Pietro Grandi
6dcc4a3695
FIX: don't show Arkade Lightning unpaid invoices as Pending
A reverse swap in swap.created means an invoice was generated but nobody
has paid it yet -- there are no funds in flight, so it is not a pending
receive. getTransactions() was surfacing every non-settled, non-failed
reverse swap as pending, pinning both the wallet card and the tx row to
Pending indefinitely for open/unpaid invoices.

Replace the BOLT11-expiry heuristic with the SDK's isReverseClaimableStatus
predicate: only transaction.mempool / transaction.confirmed (payer
committed, funds locked on-chain, claim imminent) survive as pending
receives. swap.created and expired/unpaid reverse swaps are dropped;
failed/refunded rows and all submarine rows are unaffected.
2026-05-27 12:32:48 +02:00
Pietro Grandi
5da07ccd4e
Fix Realm type - check https://github.com/arkade-os/ts-sdk/pull/527 2026-05-27 10:41:08 +02:00
Pietro Grandi
1140c24743
FIX: relabel default Arkade receive memo 2026-05-27 10:32:58 +02:00
Pietro Grandi
b232185973
FIX: update Arkade SDK Metro aliases to new dist layout
SDK 0.4.29 moved its CJS build from dist/cjs/*.js to dist/*.cjs, so the
stale dist/cjs alias paths resolved to missing files and broke bundling.
2026-05-26 18:19:53 +02:00
Pietro Grandi
164eb8492e
FIX: Manage Funds button overlapping wallet label on iOS
Replace the Paper-only native context menu with a TouchableOpacity +
ActionSheet; the context-menu host is mispositioned to the header
origin under Fabric interop on the New Architecture.
2026-05-26 18:03:54 +02:00
Pietro Grandi
70e1c9aa5a
FIX: Pay button dead when LN invoice opened from clipboard
Source the invoice from destination state instead of the route param,
which isn't reliably retained on the clipboard/deeplink open path.
2026-05-26 17:48:54 +02:00
Pietro Grandi
bd41c17bde
Upgrade Arkade OS SDKs 2026-05-26 17:37:33 +02:00
Pietro Grandi
803aaa21f0
FIX: stable amount and memo on Lightning invoice screen
Derive the unpaid invoice's amount and description from the decoded
BOLT11 in both render phases — the raw-string placeholder and the
polled-object phase — via a single decodeForDisplay() helper. The two
phases previously disagreed, so values visibly changed after load:

- "Please pay X sats" showed the BOLT11 amount first, then dropped to
  invoice.amt (the post-fee on-chain amount) once polling swapped the
  route param. Both phases now show the invoice-encoded amount, i.e.
  what the payer is actually charged.
- The "For ..." line rendered the row's synthesized description, which
  getTransactions() backfills with a "BlueWallet" label for memo-less
  reverse swaps, so "For: BlueWallet" popped in after the poll. It now
  reads the real BOLT11 description and treats the SDK's default
  "Send to Arkade address" as no description.

Also render the full block (amount, description, copy, share) in the
raw-string phase so the page is complete immediately, and let Share
work on the raw payment request.
2026-05-26 14:48:30 +02:00
Pietro Grandi
962742b6e5
FIX: show real Boltz submarine-swap fee on Lightning pay screen 2026-05-26 13:17:25 +02:00
Pietro Grandi
67dec43c32
Fix copy and text on LN invoice 2026-05-26 11:31:20 +02:00
Pietro Grandi
c8f58865de
FIX: clipped Description input on Lightning invoice screen 2026-05-25 22:29:50 +02:00
Pietro Grandi
18f46d154f
FIX: clearer memo for received Arkade Lightning payments 2026-05-25 22:29:46 +02:00
Pietro Grandi
d5d8a4a5b1
FIX: Manage Funds button overlapping wallet label on iOS
Restore flat header structure so the context-menu native view gets its Yoga frame instead of drawing at origin.
2026-05-25 17:33:55 +02:00
Pietro Grandi
6a2602b8ea
FIX: use SSL electrum peers in integration tests (plaintext TCP 50001 retired) 2026-05-25 17:06:15 +02:00
Pietro Grandi
546af9ffbe
FIX: don't keep wallet card pending on terminal Ark swaps
Flag failed/refunded/expired swap rows in getTransactions and gate the
carousel pending pill on it, so a dead swap no longer pins the card to
pending forever.
2026-05-25 16:31:07 +02:00
pietro909
71ae8a1a82
Merge branch 'master' into feat/arkade-sdk-upgrade 2026-05-25 15:37:55 +02:00
Pietro Grandi
d5c7faeefc
Fix lint 2026-05-22 00:35:36 +02:00
Pietro Grandi
546a81ea68
Merge remote-tracking branch 'bw/master' into feat/arkade-sdk-upgrade
# Conflicts:
#	package-lock.json
#	package.json
#	screen/transactions/TransactionStatus.tsx
2026-05-22 00:27:58 +02:00
Pietro Grandi
5a32c6c881
TST: stabilize e2e/Detox suite (iOS sync, Arkade indexer)
Stabilize the e2e suite on real devices: unstall Detox iOS sync on
NotificationSettings, skip the Arkade indexer stream in iOS sync,
and tighten the wallet-discovery / import / watch-only e2e flows.
2026-05-21 18:10:34 +02:00
Pietro Grandi
feca38bb96
TST: cover Ark wallet SDK contract
Add unit and integration coverage for the lightning-ark wallet's
contract with the Arkade SDK (derivation, sync, swaps); refresh
SDK fixture set.
2026-05-21 18:10:18 +02:00
Pietro Grandi
679f350dad
ADD: Ark swap background monitoring with notifications
Wire iOS/Android background task plumbing for Ark swap monitoring,
surface actionable swap notifications via the notification suppression
repository, and drop the racing Electrum probe from SelfTest.
2026-05-21 18:09:34 +02:00
Pietro Grandi
cc95979828
ADD: per-swap claim/refund and import-time restore for Arkade
Surface per-swap claim and refund controls, restore swaps on wallet
import, drop the racing Claim CTA in the LN invoice view (show a
Receiving spinner and let SwapManager events drive UI state), and
keep the transactions header mounted so the list does not jump on
scroll.
2026-05-21 18:09:26 +02:00
Pietro Grandi
9a47dcd39b
REF: use Arkade swap manager in foreground; map activity from swaps and wallet history 2026-05-21 18:04:32 +02:00
Pietro Grandi
1eb2b0e093
REF: harden Arkade Realm persistence lifecycle
Tighten Realm-backed Arkade adapter setup so the database lifecycle is
deterministic across wallet import, app boot, and tests. Fix integration
tests to handle Realm persistence in mocks and proper initialization in
wallet-import.
2026-05-21 18:03:29 +02:00
Pietro Grandi
dec68751ca
FIX: wire Ark wallet to Arkade SDK repositories
Hook the lightning-ark wallet up to the new Arkade SDK repositories layer,
introduce the Realm-backed Arkade adapter instance, register the delegate
contract via RestDelegatorProvider on Wallet.create, and set the Boltz
swap referral.
2026-05-21 18:03:13 +02:00
Pietro Grandi
f08c731d91
OPS: Upgrade Arkade ts-sdk to 0.4.25, boltz-swap to 0.3.29 2026-05-21 18:03:02 +02:00
56 changed files with 6185 additions and 705 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,423 @@
// Background task module for Ark swap monitoring.
//
// Responsibilities:
// - Passive monitoring: poll Boltz swap status for non-terminal swaps in
// every Ark wallet's per-wallet Realm and persist remote changes through
// the SDK update helpers.
// - Post a local notification when an SDK predicate flags a swap as
// claimable/refundable. No claim, refund, recover, or signing happens in
// background — those remain foreground-only.
//
// State here is in-process: it survives configure→fetch→fetch ticks within a
// single JS runtime but is gone after process kill. Realm remains the
// durable source of truth for swap status and notification suppression.
import BackgroundFetch from 'react-native-background-fetch';
import {
BoltzSwapProvider,
isChainFinalStatus,
isReverseFinalStatus,
isSubmarineFinalStatus,
updateChainSwapStatus,
updateReverseSwapStatus,
updateSubmarineSwapStatus,
} from '@arkade-os/boltz-swap';
import type { BoltzChainSwap, BoltzReverseSwap, BoltzSubmarineSwap, BoltzSwap } from '@arkade-os/boltz-swap';
import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm';
import { BlueApp as BlueAppClass } from '../class/blue-app';
import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet';
import { getArkadeRealm } from './arkade-adapters/realm/realmInstance';
import {
RealmNotificationSuppressionRepository,
type ArkSwapNotificationAction,
} from './arkade-adapters/realm/notificationSuppressionRepository';
import { notifyArkSwapActionable, resolveActionableAction } from './arkade-notifications';
const BlueApp = BlueAppClass.getInstance();
// Single shared provider. The constructor only stores config; it does not
// open sockets. Re-using one instance avoids per-poll allocation.
const swapProvider = new BoltzSwapProvider({ network: 'bitcoin' });
const DEFAULT_MAX_RUN_MS = 25_000;
let maxRunMs = DEFAULT_MAX_RUN_MS;
interface ArkTaskState {
lastRegisteredAt: number | null;
lastUnregisteredAt: number | null;
lastRunStartedAt: number | null;
lastRunFinishedAt: number | null;
walletsScanned: number;
swapsPolled: number;
swapsUpdated: number;
lastError: string | null;
exitedDueToUnavailableStorage: boolean;
availability: 'unknown' | 'available' | 'denied' | 'restricted';
// Set whenever swapsUpdated is incremented. Used by reconcile() to detect
// updates that crossed run boundaries (per-run swapsUpdated is reset).
lastSwapUpdateAt: number;
lastReconciledAt: number;
}
const state: ArkTaskState = {
lastRegisteredAt: null,
lastUnregisteredAt: null,
lastRunStartedAt: null,
lastRunFinishedAt: null,
walletsScanned: 0,
swapsPolled: 0,
swapsUpdated: 0,
lastError: null,
exitedDueToUnavailableStorage: false,
availability: 'unknown',
lastSwapUpdateAt: 0,
lastReconciledAt: 0,
};
// Per-wallet last-seen status cache. Outer key: wallet namespace; inner key:
// swap ID; value: last status this background module observed. Diagnostic +
// reconciliation hint only — Realm is durable.
const swapStatusCache: Map<string, Map<string, string>> = new Map();
// Per-poll last-seen actionable action keyed by `${namespace}:${swapId}`.
// Used to detect predicate flips (true → false or claim ↔ refund) so we can
// clear the corresponding Realm suppression row even when the swap status
// has not yet reached a terminal state. In-process only; cleared by
// stopArkBackgroundTask so a later run does not falsely diagnose a flip on
// the first poll after restart.
const lastSeenActionMap: Map<string, ArkSwapNotificationAction> = new Map();
let configured = false;
let running = false;
let cancelRequested = false;
let runDeadline: number | null = null;
export function getArkTaskState(): Readonly<ArkTaskState> {
return Object.freeze({ ...state });
}
function recordError(message: string): void {
state.lastError = message;
}
function shouldStopRun(): boolean {
return cancelRequested || (runDeadline !== null && Date.now() >= runDeadline);
}
function remainingRunMs(): number {
if (runDeadline === null) return maxRunMs;
return Math.max(runDeadline - Date.now(), 0);
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('deadline exceeded')), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function isFinalStatus(swap: BoltzSwap): boolean {
switch (swap.type) {
case 'reverse':
return isReverseFinalStatus(swap.status);
case 'submarine':
return isSubmarineFinalStatus(swap.status);
case 'chain':
return isChainFinalStatus(swap.status);
}
}
async function persistStatusChange(swap: BoltzSwap, newStatus: BoltzSwap['status'], repo: RealmSwapRepository): Promise<void> {
if (swap.type === 'reverse') {
await updateReverseSwapStatus(swap as BoltzReverseSwap, newStatus, s => repo.saveSwap(s));
} else if (swap.type === 'submarine') {
await updateSubmarineSwapStatus(swap as BoltzSubmarineSwap, newStatus, s => repo.saveSwap(s));
} else {
await updateChainSwapStatus(swap as BoltzChainSwap, newStatus, s => repo.saveSwap(s));
}
}
async function pollSwap(
swap: BoltzSwap,
namespace: string,
repo: RealmSwapRepository,
suppression: RealmNotificationSuppressionRepository,
walletID: string,
walletLabel: string,
): Promise<void> {
if (shouldStopRun()) return;
state.swapsPolled += 1;
let response;
try {
response = await withTimeout(swapProvider.getSwapStatus(swap.id), remainingRunMs());
} catch (e: any) {
recordError(`getSwapStatus(${swap.id}): ${e?.message ?? e}`);
if (e?.message === 'deadline exceeded' || remainingRunMs() <= 0) cancelRequested = true;
return;
}
if (shouldStopRun()) return;
const remoteStatus = response.status;
const statusChanged = remoteStatus !== swap.status;
// The SDK update helpers (updateReverseSwapStatus etc.) save a copy and do
// not mutate `swap`, so any post-persist predicate or terminal check on
// `swap` would read the pre-update status. effectiveSwap carries the
// status we want subsequent checks to evaluate against.
const effectiveSwap: BoltzSwap = statusChanged ? ({ ...swap, status: remoteStatus } as BoltzSwap) : swap;
if (statusChanged) {
try {
await persistStatusChange(swap, remoteStatus, repo);
} catch (e: any) {
recordError(`persistStatusChange(${swap.id}): ${e?.message ?? e}`);
return;
}
state.swapsUpdated += 1;
state.lastSwapUpdateAt = Date.now();
let perWallet = swapStatusCache.get(namespace);
if (!perWallet) {
perWallet = new Map();
swapStatusCache.set(namespace, perWallet);
}
perWallet.set(swap.id, remoteStatus);
}
// Actionable evaluation runs on every non-terminal poll, NOT only after a
// status change. Otherwise a swap that became actionable in a previous run
// but never received a successful post (notify failed mid-run, OS-level
// drop, permission-denied skip, app cold-started with already-actionable
// Realm state) would never be re-checked because subsequent polls observe
// remoteStatus === swap.status and would otherwise exit. The Realm
// suppression repo is the dedup layer.
const lastKey = `${namespace}:${effectiveSwap.id}`;
if (isFinalStatus(effectiveSwap)) {
try {
suppression.clearForSwap(effectiveSwap.id);
} catch (e: any) {
recordError(`suppression.clearForSwap(${effectiveSwap.id}): ${e?.message ?? e}`);
}
lastSeenActionMap.delete(lastKey);
return;
}
const action = resolveActionableAction(effectiveSwap);
const lastSeen = lastSeenActionMap.get(lastKey);
if (lastSeen && lastSeen !== action) {
// Predicate flipped out of `lastSeen` (either to null or to the other
// action). Clear the stale suppression so the next observed flip back
// re-fires.
try {
suppression.clearForSwapAction(effectiveSwap.id, lastSeen);
} catch (e: any) {
recordError(`suppression.clearForSwapAction(${effectiveSwap.id}): ${e?.message ?? e}`);
}
}
if (action) {
try {
await notifyArkSwapActionable(effectiveSwap, suppression, walletID, walletLabel);
} catch (e: any) {
recordError(`notifyArkSwapActionable(${effectiveSwap.id}): ${e?.message ?? e}`);
}
lastSeenActionMap.set(lastKey, action);
} else {
lastSeenActionMap.delete(lastKey);
}
}
async function processWallet(wallet: LightningArkWallet): Promise<void> {
state.walletsScanned += 1;
const namespace = wallet.getNamespace();
const walletID = wallet.getID();
const walletLabel = wallet.getLabel();
let realm;
try {
realm = await getArkadeRealm(namespace);
} catch (e: any) {
// Most likely the Keychain is locked (WHEN_UNLOCKED_THIS_DEVICE_ONLY) or
// the Realm file is unreachable. Either way the background task no-ops
// for this wallet — claim/refund is foreground-only anyway.
state.exitedDueToUnavailableStorage = true;
recordError(`getArkadeRealm(${namespace}): ${e?.message ?? e}`);
return;
}
let swaps: BoltzSwap[];
const repo = new RealmSwapRepository(realm as any);
const suppression = new RealmNotificationSuppressionRepository(realm);
try {
swaps = await repo.getAllSwaps<BoltzSwap>();
} catch (e: any) {
recordError(`getAllSwaps(${namespace}): ${e?.message ?? e}`);
return;
}
for (const swap of swaps) {
if (isFinalStatus(swap)) continue;
if (shouldStopRun()) return;
await pollSwap(swap, namespace, repo, suppression, walletID, walletLabel);
}
}
export async function runArkBackgroundTask(taskId: string): Promise<void> {
if (running) {
BackgroundFetch.finish(taskId);
return;
}
running = true;
cancelRequested = false;
runDeadline = Date.now() + maxRunMs;
state.lastRunStartedAt = Date.now();
state.walletsScanned = 0;
state.swapsPolled = 0;
state.swapsUpdated = 0;
state.exitedDueToUnavailableStorage = false;
try {
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
if (wallets.length === 0) return;
for (const wallet of wallets) {
if (shouldStopRun()) break;
try {
await processWallet(wallet);
} catch (e: any) {
recordError(`processWallet: ${e?.message ?? e}`);
}
}
} finally {
state.lastRunFinishedAt = Date.now();
runDeadline = null;
cancelRequested = false;
running = false;
BackgroundFetch.finish(taskId);
}
}
export function onArkBackgroundTaskTimeout(taskId: string): void {
cancelRequested = true;
state.lastError = 'timeout';
state.lastRunFinishedAt = Date.now();
BackgroundFetch.finish(taskId);
}
function availabilityFromStatus(status: number): ArkTaskState['availability'] {
if (status === BackgroundFetch.STATUS_AVAILABLE) return 'available';
if (status === BackgroundFetch.STATUS_DENIED) return 'denied';
if (status === BackgroundFetch.STATUS_RESTRICTED) return 'restricted';
return 'unknown';
}
export async function registerArkBackgroundTask(): Promise<void> {
if (configured) {
await BackgroundFetch.start();
state.lastRegisteredAt = Date.now();
return;
}
const config: Parameters<typeof BackgroundFetch.configure>[0] = {
minimumFetchInterval: 15,
stopOnTerminate: false,
startOnBoot: true,
enableHeadless: true,
requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY,
};
try {
const status = await BackgroundFetch.configure(config, runArkBackgroundTask, onArkBackgroundTaskTimeout);
state.availability = availabilityFromStatus(status);
if (state.availability === 'available') {
configured = true;
state.lastRegisteredAt = Date.now();
} else {
console.warn(`[ArkBackground] Background fetch unavailable: ${state.availability}`);
}
} catch (e: any) {
recordError(`configure: ${e?.message ?? e}`);
}
}
export async function stopArkBackgroundTask(): Promise<void> {
cancelRequested = true;
try {
await BackgroundFetch.stop();
} catch (e: any) {
recordError(`stop: ${e?.message ?? e}`);
}
// Await in-flight run completion (draining). A live background run keeps
// Detox's FabricTimersIdlingResource busy and disconnects the JS bridge.
const start = Date.now();
// eslint-disable-next-line no-unmodified-loop-condition
while (running && Date.now() - start < 30_000) {
await new Promise(resolve => setTimeout(resolve, 50));
}
swapStatusCache.clear();
// Clear in-process predicate-flip tracker so a later run does not
// diagnose a flip on the first poll after restart. Persistent suppression
// (Realm) is intentionally untouched — re-registering must keep history.
lastSeenActionMap.clear();
state.lastUnregisteredAt = Date.now();
}
export function reconcileArkBackgroundTaskResults(triggerRefreshForWallet: (walletId: string) => void): void {
if (state.lastSwapUpdateAt <= state.lastReconciledAt) return;
const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet);
for (const wallet of wallets) {
const namespace = wallet.getNamespace();
const perWallet = swapStatusCache.get(namespace);
if (perWallet && perWallet.size > 0) {
triggerRefreshForWallet(wallet.getID());
}
}
state.lastReconciledAt = Date.now();
}
// Exported for tests only.
export const __testing__ = {
state,
swapStatusCache,
lastSeenActionMap,
resetConfigured: (): void => {
configured = false;
},
setMaxRunMs: (ms: number): void => {
maxRunMs = ms;
},
reset: (): void => {
state.lastRegisteredAt = null;
state.lastUnregisteredAt = null;
state.lastRunStartedAt = null;
state.lastRunFinishedAt = null;
state.walletsScanned = 0;
state.swapsPolled = 0;
state.swapsUpdated = 0;
state.lastError = null;
state.exitedDueToUnavailableStorage = false;
state.availability = 'unknown';
state.lastSwapUpdateAt = 0;
state.lastReconciledAt = 0;
swapStatusCache.clear();
lastSeenActionMap.clear();
configured = false;
running = false;
cancelRequested = false;
runDeadline = null;
maxRunMs = DEFAULT_MAX_RUN_MS;
},
};

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

@ -0,0 +1,41 @@
// Display state for the transaction detail screen.
//
// On-chain rows (a real Bitcoin txid is present in `hash`) keep the existing
// confirmations-based logic. Ark/Lightning rows synthesized by
// LightningArkWallet.getTransactions() carry no on-chain `hash` and never a
// `confirmations` field, so their state is derived from row semantics instead.
// The off-chain branch mirrors the off-chain cases of
// components/TransactionListItem.tsx `listTitleKey` so the list row and the detail
// screen always agree. A `boarding-utxo-` row is a refill still awaiting
// settlement and is pending (matches TransactionListItem.isPendingRefill); a
// settled `boarding-` refill is a confirmed receive. Today only `bitcoind_tx` Ark
// rows reach the detail screen (swap rows route to LNDViewInvoice); the invoice
// cases are handled defensively.
export type TxDisplayState = 'pending' | 'sent' | 'received';
export function isOnChainTransaction(tx: any): boolean {
return typeof tx?.hash === 'string' && tx.hash.length > 0;
}
export function resolveTxDisplayState(tx: any): TxDisplayState {
if (isOnChainTransaction(tx)) {
const confs = Number(tx?.confirmations);
const pending = Number.isFinite(confs) ? confs <= 0 : !tx?.confirmations;
if (pending) return 'pending';
return Number(tx?.value) < 0 ? 'sent' : 'received';
}
// A refill awaiting settlement (boarding UTXO not yet swept into a VTXO) is
// pending until it promotes to a settled `boarding-<txid>` refill — mirror
// TransactionListItem.isPendingRefill so the list row and detail screen agree.
if (typeof tx?.txid === 'string' && tx.txid.startsWith('boarding-utxo-')) return 'pending';
// Off-chain Ark/Lightning row — never confirmations-based.
switch (tx?.type) {
case 'paid_invoice':
return 'sent';
case 'user_invoice':
case 'payment_request':
return tx?.ispaid ? 'received' : 'pending';
default: // settled refill (boarding-<txid>), native Ark legs (ark-), any other hash-less row
return Number(tx?.value) < 0 ? 'sent' : 'received';
}
}

View File

@ -216,10 +216,35 @@ const startImport = (
if (text.startsWith('arkade://')) {
const ark = new LightningArkWallet();
ark.setSecret(text);
await ark.init();
// Defer init() to first wallet open when offline — init touches the ASP
// and delegator over the network. We still detect the wallet by prefix
// and persist it with its secret.
// A network or SDK failure during init must not abort the import: the
// wallet type and secret are known, and the SDK runtime can be brought
// up the next time the wallet is opened.
if (!offline) {
await ark.fetchBalance();
await ark.fetchTransactions();
try {
await ark.init();
// Restore any previous Boltz swap activity for this seed exactly
// once, here at import time. We never run this on later wallet
// opens — the app does not sweep all swaps on bootstrap. A failure
// must not block the import: the wallet itself is fine, the
// restored rows are an optional bonus for imported-from-elsewhere
// wallets.
try {
await ark.restoreSwaps();
} catch (e: any) {
console.log('[wallet-import] restoreSwaps failed:', e?.message ?? e);
}
try {
await ark.fetchBalance();
await ark.fetchTransactions();
} catch (e: any) {
console.log('[wallet-import] initial Ark sync failed:', e?.message ?? e);
}
} catch (e: any) {
console.log('[wallet-import] Ark init failed; deferring to next open:', e?.message ?? e);
}
}
yield { wallet: ark };
}

File diff suppressed because it is too large Load Diff

View File

@ -104,6 +104,11 @@ export type LightningTransaction = {
timestamp: number; // seconds, not milliseconds
expire_time?: number;
ispaid?: boolean;
// Terminal non-success state (failed/refunded/expired swap). Distinct from
// `ispaid:false`, which on its own only means "not settled yet" and is also
// true for in-flight rows. Consumers that gate on pending vs. dead state
// (e.g. the wallet-card pending pill) must treat `failed` rows as terminal.
failed?: boolean;
walletID?: string;
value?: number;
amt?: number;

View File

@ -1,12 +1,14 @@
import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BlueApp as BlueAppClass, TCounterpartyMetadata, TTXMetadata } from '../../class/blue-app';
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
import { WatchOnlyWallet } from '../../class/wallets/watch-only-wallet';
import type { TWallet } from '../../class/wallets/types';
import presentAlert from '../../components/Alert';
import loc, { formatBalanceWithoutSuffix } from '../../loc';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
import { registerArkBackgroundTask, stopArkBackgroundTask } from '../../blue_modules/arkade-background';
import { startAndDecrypt } from '../../blue_modules/start-and-decrypt';
import { isNotificationsEnabled, majorTomToGroundControl, unsubscribe } from '../../blue_modules/notifications';
import { BitcoinUnit } from '../../models/bitcoinUnits';
@ -174,6 +176,15 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
const deleteWallet = useCallback((wallet: TWallet) => {
BlueApp.deleteWallet(wallet);
setWallets([...BlueApp.getWallets()]);
if (wallet.type === LightningArkWallet.type) {
// Fire-and-forget: cleans up the per-wallet Arkade Realm (close + delete files)
// and the Keychain encryption key. Errors stay scoped to the Ark wallet path
// and never block deletion.
(wallet as LightningArkWallet).onDelete().catch(e => console.warn('[StorageProvider] Ark wallet cleanup failed:', e?.message ?? e));
if (!BlueApp.getWallets().some(w => w.type === LightningArkWallet.type)) {
stopArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task stop failed:', e?.message ?? e));
}
}
}, []);
const handleWalletDeletion = useCallback(
@ -307,7 +318,11 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
if (walletsInitialized) {
txMetadata.current = BlueApp.tx_metadata;
counterpartyMetadata.current = BlueApp.counterparty_metadata;
setWallets(BlueApp.getWallets());
const loaded = BlueApp.getWallets();
setWallets(loaded);
if (loaded.some(w => w.type === LightningArkWallet.type)) {
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
}
}
}, [walletsInitialized]);
@ -453,6 +468,9 @@ export const StorageProvider = ({ children }: { children: React.ReactNode }) =>
if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable);
w.setUserHasSavedExport(true);
addWallet(w);
if (w instanceof LightningArkWallet) {
registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e));
}
if (getScanWasBBQR()) {
// to avoid proxying `useBBQR` through a bunch of screens during import procedure, we use a trick:
// on add-wallet screen we reset `lastScanWasBBQR` to false. then potentially user scans QR in BBQR format

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';
@ -154,7 +155,30 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? '');
const noteForCopy = (txMemo || item.memo || '').trim() || undefined;
// For LightningArkWallet rows, prepend a kind tag to the date subtitle. Such a
// wallet transacts entirely via Boltz swaps, so every row is Lightning; the
// only genuinely on-chain activity is onboarding/refill (boarding UTXOs),
// tagged from the synthetic `boarding-…` txid set in
// lightning-ark-wallet.getTransactions(). Other wallet types are unaffected.
const arkRowKind = useMemo<'Lightning' | 'Refill' | undefined>(() => {
const wallet = wallets.find(w => w.getID() === item.walletID);
if (wallet?.type !== LightningArkWallet.type) return undefined;
const txid = (item as { txid?: string }).txid;
if (txid?.startsWith('boarding-')) return 'Refill';
return 'Lightning';
}, [item, wallets]);
// A refill is "Pending" until the SDK settles its boarding UTXO into a VTXO
// (also when it enters the spendable balance). getTransactions() pass 2 tags
// those not-yet-settled rows with a `boarding-utxo-…` id; settled refills use
// `boarding-…` and render as a normal confirmed receive.
const isPendingRefill = useMemo(
() => arkRowKind === 'Refill' && !!(item as { txid?: string }).txid?.startsWith('boarding-utxo-'),
[arkRowKind, item],
);
const listTitleKey = useMemo((): 'pending' | 'sent' | 'received' => {
if (isPendingRefill) return 'pending';
if (item.category === 'receive' && item.confirmations! < 3) return 'pending';
if (item.type === 'bitcoind_tx') return item.value! < 0 ? 'sent' : 'received';
if (item.type === 'paid_invoice') return 'sent';
@ -164,7 +188,7 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
}
if (!item.confirmations) return 'pending';
return item.value! < 0 ? 'sent' : 'received';
}, [item.category, item.confirmations, item.type, item.value, item.ispaid]);
}, [isPendingRefill, item.category, item.confirmations, item.type, item.value, item.ispaid]);
const listTitle = useMemo(() => {
if (listTitleKey === 'pending') return loc.transactions.pending;
@ -175,11 +199,11 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
const isPending = listTitleKey === 'pending';
const dateLine = useMemo(() => {
if (isPending) return transactionTimeToReadable(item.timestamp);
return formatTransactionListDate(item.timestamp * 1000);
const formatted = isPending ? transactionTimeToReadable(item.timestamp) : formatTransactionListDate(item.timestamp * 1000);
return arkRowKind ? `${arkRowKind} · ${formatted}` : formatted;
// language in deps so date format updates when locale changes (formatters use global locale)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPending, item.timestamp, language]);
}, [isPending, item.timestamp, language, arkRowKind]);
const formattedAmount = useMemo(() => {
return formatBalanceWithoutSuffix(item.value, itemPriceUnit, true).toString();
@ -241,6 +265,14 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
]);
const determineTransactionTypeAndAvatar = () => {
// A refill awaiting settlement: show it as pending, not as a completed receive.
if (isPendingRefill) {
return {
label: loc.transactions.pending_transaction,
icon: <TransactionPendingIcon />,
};
}
if (item.category === 'receive' && item.confirmations! < 3) {
return {
label: loc.transactions.pending_transaction,
@ -248,6 +280,14 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
};
}
// Recovered Arkade Lightning legs are bitcoind_tx but represent Boltz swaps,
// not on-chain transfers — render them with the off-chain (Lightning) icon.
if (arkRowKind === 'Lightning' && item.type === 'bitcoind_tx') {
return item.value! < 0
? { label: loc.transactions.offchain, icon: <TransactionOffchainIcon /> }
: { label: loc.transactions.incoming_transaction, icon: <TransactionOffchainIncomingIcon /> };
}
if (item.type && item.type === 'bitcoind_tx') {
return {
label: loc.transactions.onchain,
@ -321,7 +361,11 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
pop();
}
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') {
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
// A settled Arkade swap is an enriched native Ark leg (type 'bitcoind_tx')
// carrying the swap's invoice payload (payment_request/hash/preimage). Route
// it to the Lightning invoice view by that payload, not by type — otherwise
// it falls through to the on-chain TransactionStatus branch below.
const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID);
if (lightningWallet.length === 1) {
try {
@ -352,15 +396,24 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
walletID: lightningWallet[0].getID(),
});
}
} else {
console.log('cant handle press');
} else if ((item as { txid?: string }).txid) {
// Hash-less Ark rows carry a synthetic `txid`. Native transfer legs
// (`ark-…`) open the hash-less-tolerant TransactionStatus detail. Refill
// rows (`boarding-…` / `boarding-utxo-…`) have no detail surface and are
// not tappable — matching master, where on-chain top-ups aren't tappable.
const txid = (item as { txid: string }).txid;
if (!txid.startsWith('boarding-')) {
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
}
}
}, [item, renderHighlightedText, navigate, walletID, wallets, customOnPress, disableNavigation]);
const handleOnDetailsPress = useCallback(() => {
if (walletID && item && item.hash) {
navigate('TransactionStatus', { hash: item.hash, walletID, tx: item });
} else {
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) {
// Settled Arkade swaps carry invoice data on a 'bitcoind_tx' leg; route by
// payload so they open the Lightning invoice view (see onPress above).
const lightningWallet = wallets.find(wallet => wallet?.getID() === item.walletID);
if (lightningWallet) {
navigate('LNDViewInvoice', {
@ -368,6 +421,13 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
walletID: lightningWallet.getID(),
});
}
} else if ((item as { txid?: string }).txid) {
// Match the regular tap path for Ark non-swap rows: native transfer legs
// open TransactionStatus; refills (`boarding-…`) are not tappable (master).
const txid = (item as { txid: string }).txid;
if (!txid.startsWith('boarding-')) {
navigate('TransactionStatus', { tx: item, hash: txid, walletID });
}
}
}, [item, navigate, walletID, wallets]);
@ -449,7 +509,10 @@ const TransactionListItemComponent: React.FC<TransactionListItemProps> = ({
if (renderHighlightedText && searchQuery) {
const highlighted = renderHighlightedText(subtitle, searchQuery);
if (React.isValidElement(highlighted)) {
const highlightedElement = highlighted as React.ReactElement<{ numberOfLines?: number; style?: TextStyle | TextStyle[] }>;
const highlightedElement = highlighted as React.ReactElement<{
numberOfLines?: number;
style?: TextStyle | TextStyle[];
}>;
const existingStyle = highlightedElement.props?.style;
const mergedStyle: TextStyle[] = (
Array.isArray(existingStyle)

View File

@ -16,6 +16,7 @@ import { useSettings } from '../hooks/context/useSettings';
import ToolTipMenu from './TooltipMenu';
import useAnimateOnChange from '../hooks/useAnimateOnChange';
import { useLocale } from '@react-navigation/native';
import ActionSheet from '../screen/ActionSheet';
interface TransactionsNavigationHeaderProps {
wallet: TWallet;
@ -111,20 +112,25 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
[handleBalanceVisibility, handleCopyPress],
);
const toolTipActions = useMemo(() => {
return [
// The Manage Funds menu is presented via a JS ActionSheet rather than the
// native context menu (ToolTipMenu): react-native-context-menu-view is
// Paper-only and, routed through Fabric's legacy interop on the New
// Architecture, its host view gets mispositioned to the header origin —
// overlapping the wallet label. A plain TouchableOpacity + ActionSheet lays
// out correctly (same pattern as the Multisig button below).
const showManageFundsActionSheet = useCallback(() => {
ActionSheet.showActionSheetWithOptions(
{
id: actionKeys.Refill,
text: loc.lnd.refill,
icon: actionIcons.Refill,
title: loc.lnd.title,
options: [loc._.cancel, loc.lnd.refill, loc.lnd.refill_external],
cancelButtonIndex: 0,
},
{
id: actionKeys.RefillWithExternalWallet,
text: loc.lnd.refill_external,
icon: actionIcons.RefillWithExternalWallet,
buttonIndex => {
if (buttonIndex === 1) handleManageFundsPressed(actionKeys.Refill);
else if (buttonIndex === 2) handleManageFundsPressed(actionKeys.RefillWithExternalWallet);
},
];
}, []);
);
}, [handleManageFundsPressed]);
const currentBalance = wallet ? wallet.getBalance() : 0;
const formattedBalance = useMemo(() => {
@ -210,68 +216,48 @@ const TransactionsNavigationHeader: React.FC<TransactionsNavigationHeaderProps>
<Text testID="WalletLabel" numberOfLines={1} style={[styles.walletLabel, { writingDirection: direction }]}>
{wallet.getLabel()}
</Text>
<View style={styles.balanceSection}>
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
<ToolTipMenu
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={styles.walletBalance}
onPressMenuItem={onPressMenuItem}
actions={toolTipWalletBalanceActions}
>
<View style={styles.walletBalance}>
{hideBalance ? (
<BlurredBalanceView />
) : (
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
<Animated.Text
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
>
{balance}
</Animated.Text>
</View>
)}
</View>
</ToolTipMenu>
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
</Animated.View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<View style={styles.manageFundsSection}>
<View style={styles.manageFundsButtonContainer}>
<ToolTipMenu
shouldOpenOnLongPress={false}
isButton
onPressMenuItem={handleManageFundsPressed}
actions={toolTipActions}
buttonStyle={styles.manageFundsButtonTouchable}
>
<View style={styles.manageFundsButtonContent}>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</View>
</ToolTipMenu>
</View>
</View>
)}
</View>
{wallet.type === MultisigHDWallet.type && (
<TouchableOpacity
style={[styles.manageFundsButtonContainer, styles.manageFundsButtonTouchable]}
accessibilityRole="button"
onPress={() => handleManageFundsPressed()}
<Animated.View style={[styles.walletBalanceAndUnitContainer, balanceAnimatedStyle]}>
<ToolTipMenu
shouldOpenOnLongPress
isButton
enableAndroidRipple={false}
buttonStyle={styles.walletBalance}
onPressMenuItem={onPressMenuItem}
actions={toolTipWalletBalanceActions}
>
<View style={styles.manageFundsButtonContent}>
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
<View style={styles.walletBalance}>
{hideBalance ? (
<BlurredBalanceView />
) : (
<View key={`wallet-balance-textwrap-${wallet.getID?.() ?? ''}-${String(balance)}`}>
<Animated.Text
key={`wallet-balance-text-${wallet.getID?.() ?? ''}-${String(balance)}`} // force recreation on balance change for RTL correctness
testID="WalletBalance"
numberOfLines={1}
minimumFontScale={0.5}
adjustsFontSizeToFit
style={[styles.walletBalanceText, animatedBalanceTextStyle]}
>
{balance}
</Animated.Text>
</View>
)}
</View>
</ToolTipMenu>
<TouchableOpacity style={styles.walletPreferredUnitView} onPress={changeWalletBalanceUnit} disabled={unitSwitching}>
<Text style={styles.walletPreferredUnitText}>
{unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit}
</Text>
</TouchableOpacity>
</Animated.View>
{(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={showManageFundsActionSheet}>
<Text style={styles.manageFundsButtonText}>{loc.lnd.title}</Text>
</TouchableOpacity>
)}
{wallet.type === MultisigHDWallet.type && (
<TouchableOpacity style={styles.manageFundsButton} accessibilityRole="button" onPress={() => handleManageFundsPressed()}>
<Text style={styles.manageFundsButtonText}>{loc.multisig.manage_keys}</Text>
</TouchableOpacity>
)}
</View>
@ -287,10 +273,6 @@ const styles = StyleSheet.create({
contentContainer: {
padding: 15,
},
balanceSection: {
flexDirection: 'column',
alignItems: 'flex-start',
},
walletLabel: {
backgroundColor: 'transparent',
fontSize: 19,
@ -301,39 +283,21 @@ const styles = StyleSheet.create({
flexShrink: 1,
marginRight: 6,
},
manageFundsButtonContainer: {
manageFundsButton: {
marginTop: 14,
marginBottom: 10,
alignSelf: 'flex-start',
},
manageFundsButtonTouchable: {
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: 9,
height: 39,
paddingHorizontal: 12,
overflow: 'hidden',
minHeight: 39,
alignSelf: 'flex-start',
justifyContent: 'center',
alignItems: 'center',
},
manageFundsButtonContent: {
flex: 1,
width: '100%',
justifyContent: 'center',
alignItems: 'center',
},
manageFundsSection: {
width: '100%',
alignItems: 'flex-start',
},
manageFundsButtonText: {
fontWeight: '500',
fontSize: 14,
lineHeight: 18,
color: '#FFFFFF',
padding: 0,
textAlign: 'center',
includeFontPadding: false,
textAlignVertical: 'center',
padding: 12,
},
walletBalanceAndUnitContainer: {
flexDirection: 'row',

View File

@ -383,9 +383,21 @@ export const WalletCarouselItem: React.FC<WalletCarouselItemProps> = React.memo(
let latestTransactionText;
// Lightning / Ark wallets do not have on-chain confirmations — settlement is
// signaled by `ispaid`. Bitcoin/on-chain wallets keep the existing
// `confirmations === 0` rule unchanged so their pending-pill semantics
// never depend on a Lightning shape.
// `ispaid === false` alone is not "pending": it is also true for terminal
// failed/refunded swaps, which stay in history. Gate on `!tx.failed` so a
// dead swap doesn't pin the card to "pending" forever.
const isLightningShaped = item.type === LightningCustodianWallet.type || item.type === LightningArkWallet.type;
const hasPendingTx = isLightningShaped
? item.getTransactions().some((tx: any) => tx.ispaid === false && !tx.failed)
: item.getTransactions().some((tx: Transaction) => tx.confirmations === 0);
if (item.getBalance() !== 0 && item.getLatestTransactionTime() === 0) {
latestTransactionText = loc.wallets.pull_to_refresh;
} else if (item.getTransactions().find((tx: Transaction) => tx.confirmations === 0)) {
} else if (hasPendingTx) {
latestTransactionText = loc.transactions.pending;
} else {
latestTransactionText = transactionTimeToReadable(item.getLatestTransactionTime());

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

@ -56,6 +56,7 @@
"errorInvoiceExpired": "Invoice expired.",
"expired": "Expired",
"expiresIn": "Expires in {time} minutes",
"network_fee": "Network fee: {fee}",
"payButton": "Pay",
"payment": "Payment",
"placeholder": "Invoice or address",
@ -76,7 +77,13 @@
"preimage": "Pre-image",
"sats": "sats.",
"date_time": "Date and Time",
"wasnt_paid_and_expired": "This invoice was not paid and has expired."
"wasnt_paid_and_expired": "This invoice was not paid and has expired.",
"receiving_payment": "Receiving payment…",
"refund_funds": "Refund funds",
"refund_deferred": "Funds aren't refundable yet. Try again after the swap timelock expires.",
"notification_action_title": "Action needed",
"notification_claim_body": "{walletLabel}: tap to claim your incoming Lightning payment.",
"notification_refund_body": "{walletLabel}: tap to refund your stuck Lightning payment."
},
"plausibledeniability": {
"create_fake_storage": "Create Encrypted Storage",
@ -447,6 +454,8 @@
"details_show_addresses": "Show addresses",
"details_stats_coins": "Coins",
"details_title": "Wallet",
"restore_swap_activity": "Restore swap activity",
"restore_swap_activity_done": "Swap activity restored.",
"wallets": "Wallets",
"swipe_balance_hide": "Hide",
"swipe_balance_show": "Show",

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/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/cjs/adapters/expo.js'),
'@arkade-os/sdk': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/index.cjs'),
'@arkade-os/sdk/adapters/expo': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/adapters/expo.cjs'),
'@arkade-os/sdk/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/sdk/dist/repositories/realm/index.cjs'),
'@arkade-os/boltz-swap/repositories/realm': path.join(__dirname, 'node_modules/@arkade-os/boltz-swap/dist/repositories/realm/index.cjs'),
'expo/fetch': path.join(__dirname, 'util/expo-fetch.js'),
};
// @bitcoinerlab/descriptors-core uses @noble/hashes 2.x APIs (`./legacy.js`,
// `./sha2.js`) but does not declare @noble/hashes as a direct dep. npm
// resolves up to the top-level @noble/hashes@1.3.3 (kept for bitcoinjs-lib),
// which doesn't expose those subpaths. Redirect any @noble/hashes import that
// originates inside descriptors-core to the v2 copy already nested under
// descriptors-scure.
const nobleHashesV2 = path.join(__dirname, 'node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes');
const descriptorsCoreDir = path.join('node_modules', '@bitcoinerlab', 'descriptors-core');
/**
* Metro configuration
* https://reactnative.dev/docs/metro
@ -27,6 +44,13 @@ const config = {
filePath: resolveAliases[moduleName],
};
if (moduleName.startsWith('@noble/hashes/') && context.originModulePath.includes(descriptorsCoreDir)) {
return {
type: 'sourceFile',
filePath: path.join(nobleHashesV2, moduleName.slice('@noble/hashes/'.length)),
};
}
// Fall back to default resolution
return context.resolveRequest(context, moduleName, platform);
},

View File

@ -7,7 +7,6 @@ export type LNDStackParamsList = {
ScanLNDInvoice: {
walletID: string | undefined;
uri: string | undefined;
invoice: string | undefined;
onBarScanned: string | undefined;
};
LnurlPay: {

338
package-lock.json generated
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.37",
"@arkade-os/sdk": "0.4.32",
"@babel/preset-env": "7.29.5",
"@bugsnag/react-native": "8.9.0",
"@bugsnag/source-maps": "2.3.3",
@ -72,6 +72,7 @@
"react": "19.2.3",
"react-localization": "github:BlueWallet/react-localization#ae7969a",
"react-native": "0.85.3",
"react-native-background-fetch": "4.2.9",
"react-native-biometrics": "3.0.1",
"react-native-blue-crypto": "github:BlueWallet/react-native-blue-crypto#3cb5442",
"react-native-camera-kit-no-google": "github:BlueWallet/react-native-camera-kit-no-google#0ed049a62da29cf304019363ec9d9ef3a73652e6",
@ -179,18 +180,30 @@
}
},
"node_modules/@arkade-os/boltz-swap": {
"version": "0.2.19",
"version": "0.3.37",
"resolved": "https://registry.npmjs.org/@arkade-os/boltz-swap/-/boltz-swap-0.3.37.tgz",
"integrity": "sha512-wP4daP/sDpUahmivaIZC8Lfvqz4lhQMWM1R8/Ib5x7NMS6k++FSs4KKQ6wjPKpweF8ULilsJdorhmLpNlEba6A==",
"license": "MIT",
"dependencies": {
"@arkade-os/sdk": "0.3.12",
"@arkade-os/sdk": "0.4.32",
"@noble/curves": "2.0.1",
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0",
"@scure/btc-signer": "2.0.1",
"bip68": "^1.0.4",
"bip68": "1.0.4",
"light-bolt11-decoder": "3.2.0"
},
"engines": {
"node": ">=22"
"peerDependencies": {
"expo-background-task": ">=0.1.0",
"expo-task-manager": ">=3.0.0"
},
"peerDependenciesMeta": {
"expo-background-task": {
"optional": true
},
"expo-task-manager": {
"optional": true
}
}
},
"node_modules/@arkade-os/boltz-swap/node_modules/@noble/hashes": {
@ -204,23 +217,53 @@
}
},
"node_modules/@arkade-os/sdk": {
"version": "0.3.12",
"hasInstallScript": true,
"version": "0.4.32",
"resolved": "https://registry.npmjs.org/@arkade-os/sdk/-/sdk-0.4.32.tgz",
"integrity": "sha512-we7eNPuuW9PWRS/B4Nlw5MHXTgJ7CuQzbdSrisH0u3P2PPQd/0FbSspEW/OQRNjMrJl+29zAEKN5kswy9MTjxA==",
"license": "MIT",
"dependencies": {
"@marcbachmann/cel-js": "7.0.0",
"@noble/curves": "2.0.0",
"@bitcoinerlab/descriptors-scure": "3.1.7",
"@marcbachmann/cel-js": "7.3.1",
"@noble/curves": "2.0.1",
"@noble/secp256k1": "3.0.0",
"@scure/base": "2.0.0",
"@scure/bip39": "2.0.1",
"@scure/btc-signer": "2.0.1",
"bip68": "1.0.4"
"bip68": "1.0.4",
"ws-electrumx-client": "1.0.5"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.12.0 <25"
},
"peerDependencies": {
"@react-native-async-storage/async-storage": ">=1.0.0",
"expo": ">=54.0.0",
"expo-background-task": "~1.0.10 || >=55.0.0",
"expo-sqlite": "~16.0.10 || >=55.0.0",
"expo-task-manager": "~14.0.9 || >=55.0.0"
},
"peerDependenciesMeta": {
"@react-native-async-storage/async-storage": {
"optional": true
},
"expo": {
"optional": true
},
"expo-background-task": {
"optional": true
},
"expo-sqlite": {
"optional": true
},
"expo-task-manager": {
"optional": true
}
}
},
"node_modules/@arkade-os/sdk/node_modules/@noble/secp256k1": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz",
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
@ -1946,6 +1989,125 @@
"dev": true,
"license": "MIT"
},
"node_modules/@bitcoinerlab/descriptors-core": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors-core/-/descriptors-core-3.1.7.tgz",
"integrity": "sha512-7VccUDvKcHK7RF07Vo19Obax9jO3wlPWIXtvXy61GBqXptKv156O9Z4+sm2py1CuxPRpTXHlvH70G4KVVDoKlw==",
"license": "MIT",
"dependencies": {
"@bitcoinerlab/miniscript": "^2.0.0",
"lodash.memoize": "^4.1.2",
"uint8array-tools": "^0.0.9",
"varuint-bitcoin": "^2.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@ledgerhq/ledger-bitcoin": "^0.3.1",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/base": "^2.0.0",
"@scure/bip32": "^2.0.1",
"@scure/btc-signer": "^2.0.1",
"bip32": "^5.0.1",
"bitcoinjs-lib": "^7.0.1",
"ecpair": "^3.0.1"
},
"peerDependenciesMeta": {
"@ledgerhq/ledger-bitcoin": {
"optional": true
},
"@noble/curves": {
"optional": true
},
"@noble/hashes": {
"optional": true
},
"@scure/base": {
"optional": true
},
"@scure/bip32": {
"optional": true
},
"@scure/btc-signer": {
"optional": true
},
"bip32": {
"optional": true
},
"bitcoinjs-lib": {
"optional": true
},
"ecpair": {
"optional": true
}
}
},
"node_modules/@bitcoinerlab/descriptors-core/node_modules/varuint-bitcoin": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
"license": "MIT",
"dependencies": {
"uint8array-tools": "^0.0.8"
}
},
"node_modules/@bitcoinerlab/descriptors-core/node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@bitcoinerlab/descriptors-scure": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors-scure/-/descriptors-scure-3.1.7.tgz",
"integrity": "sha512-jeyi8L3hzOquJn3t5w+NY3G93B/amZw83xeF8hrpwe7w4FMt2SH2o9rithEydQ2tP3Tlqfog+LnJOOChmfFPWw==",
"license": "MIT",
"dependencies": {
"@bitcoinerlab/descriptors-core": "3.1.7",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/base": "^2.0.0",
"@scure/bip32": "^2.0.1",
"@scure/btc-signer": "^2.0.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@ledgerhq/ledger-bitcoin": "^0.3.1"
},
"peerDependenciesMeta": {
"@ledgerhq/ledger-bitcoin": {
"optional": true
}
}
},
"node_modules/@bitcoinerlab/descriptors-scure/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@bitcoinerlab/miniscript": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@bitcoinerlab/miniscript/-/miniscript-2.0.0.tgz",
"integrity": "sha512-P8yyubPf6lphmIZfyD/ZbhT/umJX7zH1mKjGql7z0Qt+xuffnz2AueQqq2/01VE2rTIq80VM0oRFdJClGBYx/g==",
"license": "MIT",
"dependencies": {
"bip68": "^1.0.4"
}
},
"node_modules/@bugsnag/core": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/@bugsnag/core/-/core-8.9.0.tgz",
@ -3347,8 +3509,13 @@
}
},
"node_modules/@marcbachmann/cel-js": {
"version": "7.0.0",
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@marcbachmann/cel-js/-/cel-js-7.3.1.tgz",
"integrity": "sha512-P6o26TvjStT8V8+8EF+yq9Pp7ZFV00bpiUMbssr76XbIZGxaB+NNWeBp6WNxOrR9gp0JPzvJueCKHpOs5LE9PQ==",
"license": "MIT",
"bin": {
"cel-evaluate": "bin/cel-evaluate.js"
},
"engines": {
"node": ">=20.19.0"
}
@ -3375,10 +3542,12 @@
}
},
"node_modules/@noble/curves": {
"version": "2.0.0",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.0"
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
@ -3388,7 +3557,9 @@
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.0.0",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@ -4812,8 +4983,85 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
"license": "MIT",
"dependencies": {
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@scure/base": "2.2.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.2.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.0.1.tgz",
"integrity": "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1",
"@scure/base": "2.0.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/btc-signer": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.0.1.tgz",
"integrity": "sha512-vk5a/16BbSFZkhh1JIJ0+4H9nceZVo5WzKvJGGWiPp3sQOExeW+L53z3dI6u0adTPoE8ZbL+XEb6hEGzVZSvvQ==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~2.0.0",
@ -4827,6 +5075,8 @@
},
"node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
@ -6297,6 +6547,8 @@
},
"node_modules/bip68": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/bip68/-/bip68-1.0.4.tgz",
"integrity": "sha512-O1htyufFTYy3EO0JkHg2CLykdXEtV2ssqw47Gq9A0WByp662xpJnMEB9m43LZjsSDjIAOozWRExlFQk2hlV1XQ==",
"license": "ISC",
"engines": {
"node": ">=4.5.0"
@ -11184,6 +11436,15 @@
"version": "2.0.0",
"license": "ISC"
},
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"dev": true,
@ -13490,7 +13751,6 @@
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.merge": {
@ -14392,6 +14652,8 @@
},
"node_modules/micro-packed": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.8.0.tgz",
"integrity": "sha512-AKb8znIvg9sooythbXzyFeChEY0SkW0C6iXECpy/ls0e5BtwXO45J9wD9SLzBztnS4XmF/5kwZknsq+jyynd/A==",
"license": "MIT",
"dependencies": {
"@scure/base": "2.0.0"
@ -15956,6 +16218,12 @@
}
}
},
"node_modules/react-native-background-fetch": {
"version": "4.2.9",
"resolved": "https://registry.npmjs.org/react-native-background-fetch/-/react-native-background-fetch-4.2.9.tgz",
"integrity": "sha512-BjBGnJ41PbzYC6GC/v/SNWJ6Eri5M7sMf29qMW3s1Lne4XJER43JWf0PP47JHqLc8I+Q7DK7VnyHn6LolJlHTQ==",
"license": "MIT"
},
"node_modules/react-native-biometrics": {
"version": "3.0.1",
"license": "MIT",
@ -19058,6 +19326,40 @@
"async-limiter": "~1.0.0"
}
},
"node_modules/ws-electrumx-client": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/ws-electrumx-client/-/ws-electrumx-client-1.0.5.tgz",
"integrity": "sha512-pBjfFqb9j2FBz7NPbnd8r2lOYanEw8ACzfKxOtHCgEGqre5QiTax5XHLVgbsiOvST0vmsHAiMtkJPvsZm77PIQ==",
"license": "MIT",
"dependencies": {
"isomorphic-ws": "^5.0.0",
"ws": "^8.12.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ws-electrumx-client/node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",

View File

@ -94,8 +94,8 @@
"unit": "jest -b -w tests/unit/*"
},
"dependencies": {
"@arkade-os/boltz-swap": "0.2.19",
"@arkade-os/sdk": "0.3.12",
"@arkade-os/boltz-swap": "0.3.37",
"@arkade-os/sdk": "0.4.32",
"@babel/preset-env": "7.29.5",
"@bugsnag/react-native": "8.9.0",
"@bugsnag/source-maps": "2.3.3",
@ -156,6 +156,7 @@
"react": "19.2.3",
"react-localization": "github:BlueWallet/react-localization#ae7969a",
"react-native": "0.85.3",
"react-native-background-fetch": "4.2.9",
"react-native-biometrics": "3.0.1",
"react-native-blue-crypto": "github:BlueWallet/react-native-blue-crypto#3cb5442",
"react-native-camera-kit-no-google": "github:BlueWallet/react-native-camera-kit-no-google#0ed049a62da29cf304019363ec9d9ef3a73652e6",

View File

@ -37,7 +37,7 @@ const ScanLNDInvoice = () => {
const { colors } = useTheme();
const { direction } = useLocale();
const route = useRoute<RouteProps>();
const { walletID, uri, invoice } = route.params || {};
const { walletID, uri } = route.params || {};
const [wallet, setWallet] = useState<LightningCustodianWallet | undefined>(
(wallets.find(item => item.getID() === walletID) as LightningCustodianWallet) ||
(wallets.find(item => item.chain === Chain.OFFCHAIN) as LightningCustodianWallet),
@ -51,6 +51,7 @@ const ScanLNDInvoice = () => {
const [amount, setAmount] = useState<string | undefined>();
const [isAmountInitiallyEmpty, setIsAmountInitiallyEmpty] = useState<boolean | undefined>();
const [expiresIn, setExpiresIn] = useState<string | undefined>();
const [arkFeesReady, setArkFeesReady] = useState<boolean>(false);
const stylesHook = StyleSheet.create({
walletWrapLabel: {
color: colors.buttonAlternativeTextColor,
@ -83,6 +84,25 @@ const ScanLNDInvoice = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletID]);
useEffect(() => {
// Reset readiness whenever the selected wallet changes (or is not an Ark
// wallet) so a stale `true` from a previously-selected wallet never carries
// over to one whose fees are not loaded yet.
if (!(wallet instanceof LightningArkWallet)) {
setArkFeesReady(false);
return;
}
setArkFeesReady(false);
let cancelled = false;
wallet
.ensureLightningFeesLoaded()
.then(() => !cancelled && setArkFeesReady(true))
.catch(() => {}); // fee label is non-critical; stay silent and keep the line hidden
return () => {
cancelled = true;
};
}, [wallet]);
useFocusEffect(
useCallback(() => {
if (!wallet) {
@ -115,7 +135,7 @@ const ScanLNDInvoice = () => {
if (data.toLowerCase().startsWith('ark1')) {
const arkw = new LightningArkWallet();
if (arkw.isAddressValid(data)) {
setParams({ uri: undefined, invoice: data });
setParams({ uri: undefined });
// @ts-ignore we need it to be set to something
setDecoded({});
setIsAmountInitiallyEmpty(true);
@ -140,7 +160,7 @@ const ScanLNDInvoice = () => {
}
Keyboard.dismiss();
setParams({ uri: undefined, invoice: data });
setParams({ uri: undefined });
setIsAmountInitiallyEmpty(newDecoded.num_satoshis === 0);
setDestination(data);
setIsLoading(false);
@ -186,7 +206,7 @@ const ScanLNDInvoice = () => {
};
const pay = async () => {
if (!decoded || !wallet || !amount || !invoice) {
if (!decoded || !wallet || !amount || !destination) {
return null;
}
@ -227,7 +247,7 @@ const ScanLNDInvoice = () => {
}
try {
await wallet.payInvoice(invoice, amountSats);
await wallet.payInvoice(destination, amountSats);
} catch (Err: any) {
console.log(Err.message);
setIsLoading(false);
@ -299,8 +319,22 @@ const ScanLNDInvoice = () => {
};
const getFees = (): string => {
if (!decoded) return '';
// Guard the amount, not just `decoded`: the Ark-address path sets
// `decoded = {}` (truthy) and amountless invoices leave num_satoshis
// 0/absent. The old `if (!decoded)` was safe only because getFees() used to
// be called solely inside the `num_satoshis > 0` JSX guard; the fee value is
// now hoisted unconditionally, so it must short-circuit here or
// `undefined.toString()` throws.
if (!decoded?.num_satoshis) return '';
const num_satoshis = parseInt(decoded.num_satoshis.toString(), 10);
if (wallet instanceof LightningArkWallet) {
if (!arkFeesReady) return ''; // not loaded yet → fee line stays hidden until warm
const est = wallet.getSubmarineFeeEstimate(num_satoshis);
return est === undefined ? '' : `${est} ${BitcoinUnit.SATS}`;
}
// LightningCustodianWallet (LndHub): keep the legacy hardcoded estimate.
const min = Math.floor(num_satoshis * 0.003);
const max = Math.floor(num_satoshis * 0.01) + 1;
return `${min} ${BitcoinUnit.SATS} - ${max} ${BitcoinUnit.SATS}`;
@ -348,6 +382,12 @@ const ScanLNDInvoice = () => {
);
}
const feeText = getFees();
// Boltz publishes deterministic fees, so Arkade shows a single fixed amount
// under a definite label ("Network fee"), not a "potential" bracket. Custodial
// keeps the legacy "Potential fee" label + range.
const feeLabel = wallet instanceof LightningArkWallet ? loc.lnd.network_fee : loc.lnd.potentialFee;
return (
<SafeArea style={stylesHook.root}>
<View style={[styles.root, stylesHook.root]}>
@ -389,8 +429,8 @@ const ScanLNDInvoice = () => {
{expiresIn !== undefined && (
<View>
<Text style={stylesHook.expiresIn}>{expiresIn}</Text>
{decoded && decoded.num_satoshis > 0 && (
<Text style={stylesHook.expiresIn}>{loc.formatString(loc.lnd.potentialFee, { fee: getFees() })}</Text>
{decoded && decoded.num_satoshis > 0 && feeText !== '' && (
<Text style={stylesHook.expiresIn}>{loc.formatString(feeLabel, { fee: feeText })}</Text>
)}
</View>
)}

View File

@ -510,6 +510,8 @@ const styles = StyleSheet.create({
flex: 1,
marginHorizontal: 8,
minHeight: 33,
fontSize: 15,
lineHeight: 19,
color: '#81868e',
},
});

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useReducer, useRef, useState } from 'react';
import { RouteProp, useNavigation, useNavigationState, useRoute, useLocale } from '@react-navigation/native';
import { BackHandler, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ActivityIndicator, BackHandler, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Icon from '../../components/Icon';
import Share from 'react-native-share';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
@ -22,6 +22,10 @@ import dayjs from 'dayjs';
import SafeAreaScrollView from '../../components/SafeAreaScrollView';
import { BlueSpacing20 } from '../../components/BlueSpacing';
import { LightningCustodianWallet } from '../../class/wallets/lightning-custodian-wallet';
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
import presentAlert from '../../components/Alert';
import { isReverseSuccessStatus } from '@arkade-os/boltz-swap';
import type { BoltzSubmarineSwap } from '@arkade-os/boltz-swap';
type LNDViewInvoiceRouteParams = {
walletID: string;
@ -37,12 +41,75 @@ const LNDViewInvoice = () => {
const navigation = useNavigation();
const wallet = wallets.find(w => w.getID() === walletID) as LightningCustodianWallet | undefined;
const arkWallet =
wallet && (wallet as { type?: string }).type === LightningArkWallet.type ? (wallet as unknown as LightningArkWallet) : undefined;
const [isFetchingInvoices, setIsFetchingInvoices] = useState<boolean>(true);
const [invoiceStatusChanged, setInvoiceStatusChanged] = useState<boolean>(false);
const [qrCodeSize, setQRCodeSize] = useState<number>(90);
const fetchInvoiceInterval = useRef<any>(null);
const isModal = useNavigationState(state => state.routeNames[0] === LNDCreateInvoice.routeName);
// Per-swap claim/refund lookup, by the `swap-${id}` prefix mapped onto
// the row's `txid` field by lightning-ark-wallet getTransactions(). The
// route param is typed as LightningTransaction (which doesn't declare
// txid) but at runtime carries the merged `Transaction & LightningTransaction`
// shape, so we read txid through a narrow local cast. For non-Ark wallets
// and non-swap rows this resolves to undefined and the UI falls through
// to the existing branches.
const invoiceTxid = typeof invoice === 'object' ? (invoice as { txid?: unknown }).txid : undefined;
const swapId = typeof invoiceTxid === 'string' && invoiceTxid.startsWith('swap-') ? invoiceTxid.slice('swap-'.length) : undefined;
// Force-render token: bumped by the swap-event subscription below so live
// `swap.status` lookups (via getSwapById → _swapHistory) re-evaluate the
// moment the SDK observes a status transition, without waiting for the
// 3s polling tick to update the route-param snapshot.
const [, forceRender] = useReducer((x: number) => x + 1, 0);
const swap = swapId && arkWallet ? arkWallet.getSwapById(swapId) : undefined;
const [isActioning, setIsActioning] = useState<boolean>(false);
const claimable = arkWallet && swap ? arkWallet.isSwapClaimable(swap) : false;
const refundable = arkWallet && swap ? arkWallet.isSwapRefundable(swap) : false;
// Subscribe to SwapManager status transitions for our swap so the spinner
// → success transition is driven by SDK events, not the 3s polling lag.
// The SDK mutates `swap.status` in place before invoking listeners, so by
// the time we force a render `getSwapById(swapId).status` reflects the
// new state and the success/refund branches re-evaluate correctly.
useEffect(() => {
if (!arkWallet || !swapId) return;
return arkWallet.subscribeToSwapEvents(updatedSwap => {
if (updatedSwap.id === swapId) forceRender();
});
}, [arkWallet, swapId]);
const refreshAfterAction = async () => {
if (!arkWallet || !swapId) return;
const updatedRow = arkWallet.getTransactions().find(tx => tx.txid === `swap-${swapId}`);
if (updatedRow) setParams({ invoice: updatedRow });
setInvoiceStatusChanged(true);
fetchAndSaveWalletTransactions(walletID);
};
const onRefundPressed = async () => {
if (!arkWallet || !swap || isActioning) return;
setIsActioning(true);
try {
const outcome = await arkWallet.refundSwap(swap as BoltzSubmarineSwap);
if (outcome.swept === 0) {
// Lockup not yet refundable (CLTV not reached / Boltz declined to
// co-sign). Surface as info, not an error: the row stays refundable
// and the user can retry later.
presentAlert({ message: loc.lndViewInvoice.refund_deferred });
} else {
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
}
await refreshAfterAction();
} catch (e: any) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
presentAlert({ message: e?.message ?? String(e) });
} finally {
setIsActioning(false);
}
};
const stylesHook = StyleSheet.create({
root: {
backgroundColor: colors.background,
@ -160,8 +227,9 @@ const LNDViewInvoice = () => {
};
const handleOnSharePressed = () => {
if (typeof invoice === 'string' || !invoice.payment_request) return;
Share.open({ message: `lightning:${invoice.payment_request}` }).catch(error => console.log(error));
const paymentRequest = typeof invoice === 'string' ? invoice : invoice.payment_request;
if (!paymentRequest) return;
Share.open({ message: `lightning:${paymentRequest}` }).catch(error => console.log(error));
};
useEffect(() => {
@ -183,12 +251,42 @@ const LNDViewInvoice = () => {
setQRCodeSize(height > width ? width - 40 : e.nativeEvent.layout.width / 1.8);
};
// Drive both the amount and the description straight off the BOLT11 — the
// source of truth, and the one thing identical whether the route param is
// still the raw string or the polled-in object, so both render phases agree
// and nothing changes after the page first paints. Decode is sync + cached.
// "Please pay" deliberately shows the invoice-encoded amount (what the payer
// is actually charged), not invoice.amt — which getTransactions() resolves to
// the post-fee on-chain amount and so differs from the BOLT11 by the swap fee.
// Likewise we ignore the row's synthesized description/memo: getTransactions()
// backfills a "BlueWallet" label there for memo-less reverse swaps (so the tx
// list isn't blank) and that placeholder must never surface here as
// "For: BlueWallet". "Send to Arkade address" is the SDK's hardcoded default
// for a memo-less reverse swap, so it counts as "no description" too.
const decodeForDisplay = (paymentRequest?: string): { amountSats?: number; description?: string } => {
if (!paymentRequest) return {};
try {
const d = wallet?.decodeInvoice(paymentRequest);
const description = d?.description && d.description !== 'Send to Arkade address' ? d.description : undefined;
return { amountSats: d?.num_satoshis || undefined, description };
} catch {
return {};
}
};
const render = () => {
if (typeof invoice === 'object') {
const currentDate = new Date();
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
const invoiceExpiration = invoice?.timestamp && invoice?.expire_time ? invoice.timestamp + invoice.expire_time : undefined;
if (invoice.ispaid || invoice.type === 'paid_invoice') {
// Settlement wins over any claim/refund CTA. The SDK auto-claims
// reverse swaps as soon as Boltz funds the VHTLC, so a stale
// route-param snapshot (`invoice.ispaid:false`) can race a live
// `_swapHistory` already at `invoice.settled`; checking the live
// swap status alongside the snapshot prevents Claim from rendering
// (and failing) after the SDK has already claimed.
if (invoice.ispaid || invoice.type === 'paid_invoice' || (swap && isReverseSuccessStatus(swap.status))) {
let amount = 0;
let description;
let invoiceDate;
@ -196,6 +294,10 @@ const LNDViewInvoice = () => {
amount = invoice.value;
} else if (invoice.type === 'user_invoice' && invoice.amt) {
amount = invoice.amt;
} else if (invoice.value) {
// Settled Arkade swap: an enriched native Ark leg (type 'bitcoind_tx')
// has no `amt`; its magnitude lives in the signed `value`.
amount = Math.abs(invoice.value);
}
if (invoice.description) {
description = invoice.description;
@ -237,6 +339,36 @@ const LNDViewInvoice = () => {
</View>
);
}
// Reverse swap mid-flight: Boltz funded the VHTLC and the SDK is
// auto-claiming (SwapManager.executeAutonomousAction → claimVHTLC).
// No manual CTA — the SDK owns claim reliability — so we just show
// a "Receiving" indicator until the status transitions to
// `invoice.settled` and the success branch above catches it.
if (claimable) {
return (
<View style={[styles.activeRoot, stylesHook.root]}>
<ActivityIndicator size="large" color={colors.foregroundColor} />
<BlueSpacing20 />
<BlueTextCentered>{loc.lndViewInvoice.receiving_payment}</BlueTextCentered>
</View>
);
}
if (refundable) {
return (
<View style={[styles.activeRoot, stylesHook.root]}>
<BlueTextCentered>{invoice.description ?? invoice.memo ?? ''}</BlueTextCentered>
<BlueSpacing20 />
<Button
onPress={onRefundPressed}
title={loc.lndViewInvoice.refund_funds}
disabled={isActioning}
showActivityIndicator={isActioning}
/>
</View>
);
}
if (invoiceExpiration ? invoiceExpiration < now : undefined) {
return (
<View style={[styles.root, stylesHook.root, styles.justifyContentCenter]}>
@ -249,6 +381,8 @@ const LNDViewInvoice = () => {
}
// Invoice has not expired, nor has it been paid for.
if (invoice.payment_request) {
const { amountSats: bolt11Amount, description } = decodeForDisplay(invoice.payment_request);
const amountSats = bolt11Amount ?? invoice.amt;
return (
<ScrollView>
<View style={[styles.activeRoot, stylesHook.root]}>
@ -257,13 +391,13 @@ const LNDViewInvoice = () => {
</View>
<BlueSpacing20 />
<BlueText>
{loc.lndViewInvoice.please_pay} {invoice.amt} {loc.lndViewInvoice.sats}
{loc.lndViewInvoice.please_pay} {amountSats} {loc.lndViewInvoice.sats}
</BlueText>
{'description' in invoice && (invoice.description?.length ?? 0) > 0 && (
{description ? (
<BlueText>
{loc.lndViewInvoice.for} {invoice.description ?? ''}
{loc.lndViewInvoice.for} {description}
</BlueText>
)}
) : null}
<View style={styles.copyText}>
<CopyTextToClipboard truncated text={invoice.payment_request} />
</View>
@ -273,14 +407,36 @@ const LNDViewInvoice = () => {
);
}
} else if (invoice) {
// `invoice` is string, just not decoded yet. lets just display it as a QR code first (till it gets decoded
// and more data is rendered)
// `invoice` is the raw BOLT11 string — the polling effect hasn't yet swapped
// it for the decoded object. Don't make the amount/description wait for that
// 3s round-trip: both are encoded in the string and decode synchronously
// (offline, cached) via the same decodeForDisplay() the object branch uses,
// so we render the full "please pay" block now and it doesn't change when
// the object arrives. A malformed string just falls back to QR + copy.
const { amountSats, description } = decodeForDisplay(invoice);
return (
<View style={[styles.activeRoot, stylesHook.root]}>
<View style={styles.activeQrcode}>
<QRCode value={invoice} size={qrCodeSize} />
<ScrollView>
<View style={[styles.activeRoot, stylesHook.root]}>
<View style={styles.activeQrcode}>
<QRCode value={invoice} size={qrCodeSize} />
</View>
<BlueSpacing20 />
{amountSats ? (
<BlueText>
{loc.lndViewInvoice.please_pay} {amountSats} {loc.lndViewInvoice.sats}
</BlueText>
) : null}
{description ? (
<BlueText>
{loc.lndViewInvoice.for} {description}
</BlueText>
) : null}
<View style={styles.copyText}>
<CopyTextToClipboard truncated text={invoice} />
</View>
<Button onPress={handleOnSharePressed} title={loc.receive.details_share} />
</View>
</View>
</ScrollView>
);
} else {
// something is not right

View File

@ -102,8 +102,6 @@ const NotificationSettings: React.FC = () => {
await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true');
setNotificationsEnabledState(false);
}
setNotificationsEnabledState(await isNotificationsEnabled());
} catch (error) {
console.error(error);
presentAlert({ message: (error as Error).message });

View File

@ -8,14 +8,12 @@ import { Linking, StyleSheet, View } from 'react-native';
import BlueCrypto from 'react-native-blue-crypto';
import wif from 'wif';
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import * as encryption from '../../blue_modules/encryption';
import * as fs from '../../blue_modules/fs';
import ecc from '../../blue_modules/noble_ecc';
import { hexToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras';
import BlueText from '../../components/BlueText';
import { HDAezeedWallet } from '../../class/wallets/hd-aezeed-wallet';
import { HDSegwitBech32Wallet } from '../../class/wallets/hd-segwit-bech32-wallet';
import { HDSegwitP2SHWallet } from '../../class/wallets/hd-segwit-p2sh-wallet';
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
import { SegwitP2SHWallet } from '../../class/wallets/segwit-p2sh-wallet';
@ -29,6 +27,7 @@ import { CreateTransactionUtxo } from '../../class/wallets/types';
import { BlueSpacing20 } from '../../components/BlueSpacing';
import { BlueLoading } from '../../components/BlueLoading';
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
import { stopArkBackgroundTask } from '../../blue_modules/arkade-background';
import { SettingsCard, SettingsScrollView } from '../../components/platform';
const bip32 = BIP32Factory(ecc);
@ -93,6 +92,11 @@ export default class SelfTest extends Component {
let isOk = true;
try {
// Drain any Ark background-fetch listener before running the self-test.
// A live background-fetch timer keeps Detox's FabricTimersIdlingResource
// busy and disconnects the JS bridge before SelfTestOk can be observed.
await stopArkBackgroundTask();
await new Promise(resolve => setTimeout(resolve, 1_000)); // propagate ui
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
@ -112,33 +116,25 @@ export default class SelfTest extends Component {
//
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
// Offline Ark smoke check: derive identity + namespace from a fixed
// mnemonic. No init() / SDK / network — those calls hang Detox on CI.
// The full Ark address regression (BIP86 path, DelegateVtxo wiring,
// delegatorProvider) is pinned in tests/unit/lightning-ark-derivation.test.ts.
const spkw = new LightningArkWallet();
spkw.setSecret('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
await spkw.init();
assertStrictEqual(
await spkw.getArkAddress(),
'ark1qq4hfssprtcgnjzf8qlw2f78yvjau5kldfugg29k34y7j96q2w4t59s7u3fgnd3lyjda00ycjq53mgxl6wsxspe4s72t5dss3q6w5clv0xpgal',
'Ark failed',
);
spkw.setSecret('arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
const pubkey = await spkw._getIdentity().xOnlyPublicKey();
if (!(pubkey instanceof Uint8Array) || pubkey.length !== 32) {
throw new Error('Arkade x-only pubkey shape regression: length=' + (pubkey as Uint8Array | undefined)?.length);
}
const expectedNamespace = 'e13b00f781e8dfc57f8f2a936220ff24d132eaaf8c85d4b10b5337645085ee9a';
const namespace = spkw.getNamespace();
if (namespace !== expectedNamespace) {
throw new Error(`Arkade namespace regression: expected ${expectedNamespace}, got ${namespace}`);
}
} else {
// skipping RN-specific test
}
//
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
if (!(await BlueElectrum.ensureConnected())) throw new Error('Could not connect to Electrum');
const addr4elect = '3GCvDBAktgQQtsbN6x5DYiQCMmgZ9Yk8BK';
const electrumBalance = await BlueElectrum.getBalanceByAddress(addr4elect);
if (electrumBalance.confirmed !== 51432)
throw new Error('BlueElectrum getBalanceByAddress failure, got ' + JSON.stringify(electrumBalance));
const electrumTxs = await BlueElectrum.getTransactionsByAddress(addr4elect);
if (electrumTxs.length !== 1) throw new Error('BlueElectrum getTransactionsByAddress failure, got ' + JSON.stringify(electrumTxs));
} else {
// skipping RN-specific test'
}
if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') {
const aezeed = new HDAezeedWallet();
aezeed.setSecret(
@ -304,15 +300,6 @@ export default class SelfTest extends Component {
if (!hd2.validateMnemonic()) {
throw new Error('mnemonic phrase validation not ok');
}
//
const hd4 = new HDSegwitBech32Wallet();
hd4._xpub = 'zpub6rnbAtzupLPpSrsBKRsHupFvv1h6pwfRnZxX3qs6RL4LiLqKQ6kfBaDckn2apQWfyw1D2TdQMMDCfUDHMwtrcbGoy88xoKBLmADTFK9AhLe';
await hd4.fetchBalance();
if (hd4.getBalance() !== 2400) throw new Error('Could not fetch HD Bech32 balance');
await hd4.fetchTransactions();
if (hd4.getTransactions().length !== 4) throw new Error('Could not fetch HD Bech32 transactions');
} else {
// skipping RN-specific test
}

View File

@ -32,6 +32,7 @@ import useWalletSubscribe from '../../hooks/useWalletSubscribe';
import loc, { formatBalanceWithoutSuffix } from '../../loc';
import { BitcoinUnit } from '../../models/bitcoinUnits';
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
import { isOnChainTransaction, resolveTxDisplayState } from '../../blue_modules/transactionDisplayState';
dayjs.extend(relativeTime);
@ -336,7 +337,10 @@ const TransactionStatus: React.FC = () => {
console.debug('transactionDetail - useEffect');
if (!tx || tx?.confirmations) return;
if (!hash) return;
// Ark/Lightning rows carry a synthetic id (ark-/swap-/boarding-), not an on-chain
// txid. Never poll Electrum for them — the old `if (!hash) return;` let the
// synthetic id through and logged "… with hash ark-… not found" every interval.
if (!isOnChainTransaction(tx)) return;
if (fetchTxInterval.current) {
clearInterval(fetchTxInterval.current);
@ -675,18 +679,20 @@ const TransactionStatus: React.FC = () => {
};
const handleNotePress = useCallback(async () => {
const currentMemo = txMetadata[tx.hash]?.memo || '';
// Ark rows have no on-chain hash; use their synthetic txid as fallback key.
const metadataKey = tx.hash ?? (tx as { txid?: string }).txid;
const currentMemo = (metadataKey && txMetadata[metadataKey]?.memo) || '';
try {
const newMemo = await prompt(loc.send.details_note_placeholder, '', { type: 'plain-text', defaultValue: currentMemo });
if (newMemo !== undefined) {
txMetadata[tx.hash] = { memo: newMemo };
if (newMemo !== undefined && metadataKey) {
txMetadata[metadataKey] = { memo: newMemo };
await saveToDisk();
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
}
} catch (error) {
// User cancelled
}
}, [tx?.hash, txMetadata, saveToDisk]);
}, [tx, txMetadata, saveToDisk]);
const handleOpenBlockExplorer = useCallback(() => {
if (!tx?.hash || !selectedBlockExplorer) return;
@ -828,7 +834,8 @@ const TransactionStatus: React.FC = () => {
const parsedTxValue = Number(tx?.value);
const txValue = Number.isFinite(parsedTxValue) ? parsedTxValue : null;
const parsedConfirmations = Number(tx?.confirmations);
const isPending = Number.isFinite(parsedConfirmations) ? parsedConfirmations <= 0 : !tx?.confirmations;
const isOnChainTx = isOnChainTransaction(tx);
const isPending = resolveTxDisplayState(tx) === 'pending';
const preferredBalanceUnit = wallet?.preferredBalanceUnit ?? BitcoinUnit.BTC;
// Get transaction direction and date
@ -1023,11 +1030,13 @@ const TransactionStatus: React.FC = () => {
<TransactionOutgoingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelSent]}>{loc.transactions.details_sent}</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueSent, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
</View>
) : (
@ -1035,11 +1044,13 @@ const TransactionStatus: React.FC = () => {
<TransactionIncomingIcon />
<View style={styles.stateLabelContainer}>
<BlueText style={[styles.stateLabel, stylesHook.stateLabelReceived]}>{loc.transactions.details_received}</BlueText>
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
{isOnChainTx && (
<BlueText style={[styles.stateValue, stylesHook.stateValueReceived, styles.stateValueInline]}>
{loc.formatString(loc.transactions.confirmations_lowercase, {
confirmations: parsedConfirmations > 6 ? '6+' : parsedConfirmations,
})}
</BlueText>
)}
</View>
</View>
)}

View File

@ -49,7 +49,7 @@ function getCoinControlStats(w: TWallet): { hasCoinControl: boolean; utxoCount:
}
const WalletDetails: React.FC = () => {
const { saveToDisk, wallets, txMetadata, handleWalletDeletion, sleep } = useStorage();
const { saveToDisk, wallets, txMetadata, handleWalletDeletion, fetchAndSaveWalletTransactions, sleep } = useStorage();
const { isBiometricUseCapableAndEnabled } = useBiometrics();
const { walletID } = useRoute<RouteProps>().params;
const { direction } = useLocale();
@ -140,6 +140,21 @@ const WalletDetails: React.FC = () => {
fetchArkAddress();
}, [wallet]);
const [isRestoringSwaps, setIsRestoringSwaps] = useState<boolean>(false);
const onRestoreSwapsPressed = useCallback(async () => {
if (wallet.type !== LightningArkWallet.type || !(wallet as unknown as LightningArkWallet).restoreSwaps) return;
setIsRestoringSwaps(true);
try {
await (wallet as unknown as LightningArkWallet).restoreSwaps();
await fetchAndSaveWalletTransactions(wallet.getID());
presentAlert({ message: loc.wallets.restore_swap_activity_done });
} catch (e: any) {
presentAlert({ message: e?.message ?? String(e) });
} finally {
setIsRestoringSwaps(false);
}
}, [wallet, fetchAndSaveWalletTransactions]);
const navigateToOverviewAndDeleteWallet = useCallback(async () => {
setIsLoading(true);
const deletionSucceeded = await handleWalletDeletion(wallet.getID());
@ -628,7 +643,7 @@ const WalletDetails: React.FC = () => {
<View style={[styles.detailsCard, stylesHook.detailsCard]}>
<View style={stylesHook.optionsContent}>
<Text style={[styles.textLabel2, stylesHook.textLabel2, styles.optionsSubheader]}>
{`Ark ${loc.wallets.details_address}`}
{`Arkade ${loc.wallets.details_address}`}
</Text>
<CopyTextToClipboard
text={arkAddress}
@ -900,6 +915,18 @@ const WalletDetails: React.FC = () => {
backgroundColor={colors.redBG}
textColor={colors.redText}
/>
{wallet.type === LightningArkWallet.type && (
<>
<BlueSpacing20 />
<SecondButton
onPress={onRestoreSwapsPressed}
testID="RestoreSwapActivity"
title={loc.wallets.restore_swap_activity}
disabled={isRestoringSwaps}
loading={isRestoringSwaps}
/>
</>
)}
</View>
</BlueCard>
</>

View File

@ -349,9 +349,22 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
});
const renderItem = useCallback(
// react/no-unused-prop-types misfires on inline arrow renderers: it reads the
// destructured `item: Transaction` annotation as a propTypes definition and
// ignores that the value is consumed on the next line.
// eslint-disable-next-line react/no-unused-prop-types
({ item }: { item: Transaction }) => (
<TransactionListItem key={item.hash} item={item} itemPriceUnit={displayUnit} walletID={walletID} />
// Ark wallet rows lack on-chain `hash` and instead carry a synthetic
// `txid` (`swap-…`, `ark-…`, `boarding-…`, `boarding-utxo-…`). Falling
// back to `txid` prevents multiple Ark rows from sharing
// `key={undefined}`, which made React reuse stale memoized renders
// across rows.
<TransactionListItem
key={item.hash ?? (item as { txid?: string }).txid}
item={item}
itemPriceUnit={displayUnit}
walletID={walletID}
/>
),
[displayUnit, walletID],
);
@ -370,7 +383,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
});
};
const _keyExtractor = useCallback((_item: any, index: number) => index.toString(), []);
const _keyExtractor = useCallback((item: Transaction, index: number) => item.hash || item.txid || index.toString(), []);
const pasteFromClipboard = async () => {
onBarCodeRead({ data: await getClipboardContent() });
@ -522,7 +535,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }: { rout
return () => clearTimeout(timer);
}, [walletID, measureHeaderHeight]);
const ListHeaderComponent = useCallback(
const ListHeaderComponent = useMemo(
() => (
<View ref={headerRef} onLayout={measureHeaderHeight}>
<TransactionsNavigationHeader

View File

@ -290,7 +290,14 @@ const WalletsList: React.FC = () => {
const renderTransactionListsRow = useCallback(
(item: ExtendedTransaction) => (
<TransactionListItem key={item.hash} item={item} itemPriceUnit={item.walletPreferredBalanceUnit} walletID={item.walletID} />
// Ark wallet rows have no on-chain `hash` — fall back to their
// synthetic `txid` so each row gets a unique React key.
<TransactionListItem
key={item.hash ?? (item as { txid?: string }).txid}
item={item}
itemPriceUnit={item.walletPreferredBalanceUnit}
walletID={item.walletID}
/>
),
[],
);
@ -463,7 +470,8 @@ const WalletsList: React.FC = () => {
}, [onScanButtonPressed, scanImage, sendButtonLongPress, wallets.length]);
const sectionListKeyExtractor = useCallback((item: any, index: any) => {
return `${item}${index}`;
if (typeof item === 'string') return item;
return item?.hash || item?.txid || `${item}${index}`;
}, []);
const refreshProps = isDesktop || isElectrumDisabled ? {} : { refreshing: isLoading, onRefresh };

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,18 @@ describe('BlueWallet UI Tests - no wallets', () => {
await tapAndTapAgainIfElementIsNotVisible('RunSelfTestButton', 'SelfTestLoading');
await element(by.id('SelfTestLoading')).tap(); // tapping START button
// Wait for the self-test to complete
await waitFor(element(by.id('SelfTestOk')))
.toBeVisible()
.withTimeout(300 * 1000);
// SelfTest runs CPU-heavy crypto loops for 100+ seconds. Detox's
// FabricTimersIdlingResource never goes idle during that, so a synchronized
// waitFor would throw IdlingResourceTimeoutException long before
// SelfTestOk renders. Disable synchronization just for the wait.
await device.disableSynchronization();
try {
await waitFor(element(by.id('SelfTestOk')))
.toBeVisible()
.withTimeout(300 * 1000);
} finally {
await device.enableSynchronization();
}
await goBack();
await goBack();
await goBack();
@ -506,9 +519,14 @@ describe('BlueWallet UI Tests - no wallets', () => {
if (device.getPlatform() === 'ios') {
// FIXME: WAllets does not exists on android
await waitForId('Wallets');
await scrollUpOnHomeScreen();
}
await sleep(1000); // propagate
// Match t4's flow: scroll up so the next helperCreateWallet's
// whileElement(WalletsList).scroll('right') starts from a known
// position. Without this, Android lands the user on a list state
// where CreateAWallet is not visible after scroll-right and the
// 6s tapAndTapAgainIfElementIsNotVisible budget runs out.
await scrollUpOnHomeScreen();
// created fake storage.
// creating a wallet inside this fake storage
await helperCreateWallet('fake_wallet');

View File

@ -50,6 +50,11 @@ describe('BlueWallet UI Tests - import Watch-only wallet (zpub)', () => {
await sleep(1000);
} catch (_) {}
try {
// in case notification popup appeared early and is blocking taps
await element(by.text(`No, and do not ask me again.`)).tap();
} catch (_) {}
await element(by.id('ReceiveButton')).tap();
await expect(element(by.id('BitcoinAddressQRCode'))).toBeVisible();
await expect(element(by.label('bc1qgrhr5xc5774maph97d73ydrjlqqmg2v6jjlr29'))).toBeVisible();
@ -82,6 +87,12 @@ describe('BlueWallet UI Tests - import Watch-only wallet (zpub)', () => {
// now lets test scanning back QR with UR PSBT. this should lead straight to broadcast dialog
// Same race as the t1 AboutScrollView fix in bluewallet.spec.js: the
// PSBT-with-hardware screen has not always mounted by the time
// whileElement(...).scroll() runs.
await waitFor(element(by.id('PsbtWithHardwareScrollView')))
.toBeVisible()
.withTimeout(15_000);
await waitFor(element(by.id('PsbtTxScanButton')))
.toBeVisible()
.whileElement(by.id('PsbtWithHardwareScrollView'))

View File

@ -163,7 +163,14 @@ export function hashIt(s) {
}
export async function helperDeleteWallet(label, remainingBalanceSat = false) {
// Tapping the wallet card by visible text (`by.text(label)`) is what
// bluewallet3's import-then-delete flow uses successfully. On a wallet
// that has been opened before, this navigates to WalletTransactions
// immediately. On a freshly-created wallet (t10) the carousel
// Pressable's first onPress is swallowed before navigation fires —
// that case is a known limitation of the e2e harness.
await element(by.text(label)).tap();
await waitForId('WalletDetails');
await element(by.id('WalletDetails')).tap();
await element(by.id('WalletDetailsScroll')).swipe('up', 'fast', 1);
await sleep(200);

View File

@ -6,6 +6,7 @@ module.exports = {
globalSetup: 'detox/runners/jest/globalSetup',
globalTeardown: 'detox/runners/jest/globalTeardown',
testEnvironment: 'detox/runners/jest/testEnvironment',
setupFilesAfterEnv: ['<rootDir>/e2e/setup.js'],
rootDir: '..',
testMatch: ['<rootDir>/e2e/**/*.spec.js'],
transform: {

34
tests/e2e/setup.js Normal file
View File

@ -0,0 +1,34 @@
/* eslint-env jest */
/* global device */
// Detox's iOS network synchronization waits on all in-flight NSURLSession
// requests before considering the app idle. The Arkade SDK's indexer opens
// a long-lived SSE-style stream (`expo/fetch` →
// /v1/indexer/script/subscription/<id>) that never completes during the
// test's lifetime, so every action would time out waiting for idle.
//
// Tell Detox to ignore that endpoint. The blacklist is process-scoped on
// iOS, so we re-apply it after every launchApp.
const URL_BLACKLIST = ['.*arkade\\.computer/v1/indexer/script/subscription.*', '.*groundcontrol-bluewallet\\.herokuapp\\.com.*'];
beforeAll(async () => {
if (typeof device === 'undefined' || !device?.launchApp) return;
const originalLaunchApp = device.launchApp.bind(device);
device.launchApp = async (...args) => {
const result = await originalLaunchApp(...args);
try {
await device.setURLBlacklist(URL_BLACKLIST);
} catch (e) {
console.log('[detox-setup] setURLBlacklist after launchApp failed:', e?.message ?? e);
}
return result;
};
// Detox auto-launches the app before the first beforeAll; cover that launch too.
try {
await device.setURLBlacklist(URL_BLACKLIST);
} catch (e) {
console.log('[detox-setup] initial setURLBlacklist failed:', e?.message ?? e);
}
});

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,112 @@
/**
* Spy installers for the Arkade SDK / Boltz provider classes that
* `LightningArkWallet.init()` reaches over the network for.
*
* Tests call `installSdkProviderSpies()` in `beforeEach` and
* `restoreSdkProviderSpies()` in `afterEach`. The spies stub the methods
* `Wallet.create` calls during init (`getInfo`, `getDelegateInfo`) plus the
* Boltz fee/limit lookups invoked by `_fetchLightningFeesAndLimits`. With
* these in place `init()` runs offline and the wallet's address derivation
* is fully deterministic.
*
* We spy on `RestArkProvider.prototype` rather than `ExpoArkProvider.prototype`
* because Expo* extend Rest* installing the stub on the parent prototype
* covers both.
*/
import { ContractManager, RestArkProvider, RestDelegatorProvider, VtxoManager } from '@arkade-os/sdk';
import { BoltzSwapProvider, SwapManager } from '@arkade-os/boltz-swap';
/** Snapshot of `https://arkade.computer/v1/info` for offline tests. */
export const FAKE_ASP_INFO = {
signerPubkey: '022b74c2011af089c849383ee527c72325de52df6a788428b68d49e9174053aaba',
forfeitPubkey: '03b43a8363118c084a04d4f6a50ebfa58e81957f8cceceb2aee0ab64c9fd2d9977',
forfeitAddress: 'bc1qzzdzp5c443vsetzatf2ra6hku322y7e5aq50rs',
checkpointTapscript: '039e0440b27520b43a8363118c084a04d4f6a50ebfa58e81957f8cceceb2aee0ab64c9fd2d9977ac',
network: 'bitcoin' as const,
sessionDuration: 60,
unilateralExitDelay: 605184,
boardingExitDelay: 7776256,
utxoMinAmount: 330,
utxoMaxAmount: -1,
vtxoMinAmount: 1,
vtxoMaxAmount: -1,
dust: 330,
fees: {
intentFee: { offchainInput: '', offchainOutput: '', onchainInput: '', onchainOutput: '200.0' },
txFeeRate: 0,
},
scheduledSession: null,
deprecatedSigners: [],
serviceStatus: {},
digest: 'test-digest',
maxTxWeight: 40000,
maxOpReturnOutputs: 2,
};
/**
* Test-only delegate pubkey. Does not need to match the production delegator
* the derivation test pins the wallet's algorithm, not the production
* service. The value is the secp256k1 generator G in 33-byte compressed form
* (private key = 1) so it is always on-curve and the SDK's taproot validation
* accepts it.
*/
export const FAKE_DELEGATE_PUBKEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798';
export const FAKE_DELEGATE_INFO = {
pubkey: FAKE_DELEGATE_PUBKEY,
fee: '0',
delegatorAddress: 'bc1qzzdzp5c443vsetzatf2ra6hku322y7e5aq50rs',
};
export const FAKE_BOLTZ_FEES = {
reverse: { percentage: 0.5, minerFees: 0 },
submarine: { percentage: 0.1, minerFees: 0 },
};
export const FAKE_BOLTZ_LIMITS = {
min: 1000,
max: 1_000_000,
};
/**
* Install Jest spies on the SDK provider prototypes so init() runs offline.
* Returns nothing; cleanup happens via `restoreSdkProviderSpies()`.
*/
export function installSdkProviderSpies(): void {
jest.spyOn(RestArkProvider.prototype, 'getInfo').mockResolvedValue(FAKE_ASP_INFO as any);
jest.spyOn(RestDelegatorProvider.prototype, 'getDelegateInfo').mockResolvedValue(FAKE_DELEGATE_INFO as any);
jest.spyOn(BoltzSwapProvider.prototype, 'getFees').mockResolvedValue(FAKE_BOLTZ_FEES as any);
jest.spyOn(BoltzSwapProvider.prototype, 'getLimits').mockResolvedValue(FAKE_BOLTZ_LIMITS as any);
// VtxoManager auto-runs `initializeSubscription()` from its constructor,
// which schedules a setTimeout polling loop AND awaits getContractManager
// (which opens a ContractWatcher SSE subscription via subscribeForScripts).
// Neither shuts down without a `dispose()` call, so a Jest worker that
// runs Wallet.create through to completion hangs after the test asserts.
// Stub the entry point to a resolved no-op; the wallet's address-derivation
// path doesn't need either side effect.
jest.spyOn(VtxoManager.prototype as any, 'initializeSubscription').mockResolvedValue(undefined);
// ArkadeSwaps auto-starts SwapManager in its constructor (autoStart defaults
// to true). SwapManager.start() calls tryConnectWebSocket(), which opens a
// real OS WebSocket. On failure it enters startPollingFallback(), a recursive
// setTimeout loop that keeps the Node.js event loop alive indefinitely and
// prevents Jest from exiting after the test completes.
jest.spyOn(SwapManager.prototype as any, 'start').mockResolvedValue(undefined);
// Any code path that calls `wallet.getContractManager()` lazily constructs
// a ContractManager whose `initialize()` opens a ContractWatcher SSE stream
// (via `indexerProvider.getSubscription`) and runs a delta sync against the
// indexer. Both leave handles or pending fetches that block Jest from
// exiting. Unit tests that only exercise address derivation never trigger
// this, but tests that call `fetchBalance` / `fetchTransactions` /
// `getTransactionHistory` after init do. Stub initialize to a no-op so the
// manager exists with no watched contracts — the manager-querying methods
// then return empty results without touching the network.
jest.spyOn(ContractManager.prototype as any, 'initialize').mockResolvedValue(undefined);
}
export function restoreSdkProviderSpies(): void {
jest.restoreAllMocks();
}

View File

@ -100,7 +100,7 @@ describe('BlueElectrum', () => {
assert.ok(!(await BlueElectrum.testConnection('joyreactor.cc', 80, false)));
assert.ok(!(await BlueElectrum.testConnection('joyreactor.cc', false, 80)));
assert.ok(await BlueElectrum.testConnection('electrum1.bluewallet.io', '50001'));
assert.ok(await BlueElectrum.testConnection('mainnet.foundationdevices.com', false, 50002));
assert.ok(await BlueElectrum.testConnection('electrum1.bluewallet.io', false, 443));
});

View File

@ -12,9 +12,6 @@ const hardcodedPeers = [
{ host: 'electrum1.bluewallet.io', ssl: '443' },
{ host: 'electrum2.bluewallet.io', ssl: '443' },
{ host: 'electrum3.bluewallet.io', ssl: '443' },
{ host: 'electrum1.bluewallet.io', tcp: '50001' },
{ host: 'electrum2.bluewallet.io', tcp: '50001' },
{ host: 'electrum3.bluewallet.io', tcp: '50001' },
];
function bitcoinjs_crypto_sha256(buffer /*: Buffer */) /*: Buffer */ {

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

@ -12,6 +12,7 @@ import { HDSegwitP2SHWallet } from '../../class/wallets/hd-segwit-p2sh-wallet';
import { HDTaprootWallet } from '../../class/wallets/hd-taproot-wallet';
import { LegacyWallet } from '../../class/wallets/legacy-wallet';
import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet';
import { installSdkProviderSpies, restoreSdkProviderSpies } from '../helpers/sdkProviderMocks';
import { SegwitBech32Wallet } from '../../class/wallets/segwit-bech32-wallet';
import { SegwitP2SHWallet } from '../../class/wallets/segwit-p2sh-wallet';
import { SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from '../../class/wallets/slip39-wallets';
@ -695,23 +696,33 @@ describe('import procedure', () => {
// not checking other 2 wallets
});
it('can import lightning ark wallet', async () => {
const store = createStore();
const { promise } = startImport(
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
false,
false,
false,
...store.callbacks,
);
await promise;
// Stub the Arkade SDK network providers so the import-time `ark.init()` does
// not open real SSE/WebSocket subscriptions. Without these, Wallet.create
// brings up VtxoManager + SwapManager — both keep the Node event loop alive
// and force jest to hang at the end of the run. See `tests/helpers/
// sdkProviderMocks.ts` for the rationale.
describe('lightning ark', () => {
beforeEach(() => installSdkProviderSpies());
afterEach(() => restoreSdkProviderSpies());
assert.strictEqual(store.state.wallets.length, 1);
assert.strictEqual(store.state.wallets[0].type, LightningArkWallet.type);
assert.strictEqual(
store.state.wallets[0].getSecret(),
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
);
it('can import lightning ark wallet', async () => {
const store = createStore();
const { promise } = startImport(
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
false,
false,
false,
...store.callbacks,
);
await promise;
assert.strictEqual(store.state.wallets.length, 1);
assert.strictEqual(store.state.wallets[0].type, LightningArkWallet.type);
assert.strictEqual(
store.state.wallets[0].getSecret(),
'arkade://abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
);
});
});
it('can import private key in hex format', async () => {

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

@ -72,6 +72,34 @@ jest.mock('react-native-notifications', () => {
return {};
});
jest.mock('react-native-background-fetch', () => {
// The real module instantiates `new NativeEventEmitter(...)` at module
// load, which throws under jest because the underlying native module is
// null. Test files that don't drive scheduler behavior (i.e. anything
// that transitively imports `blue_modules/arkade-background`) just need a
// safe default. Tests that exercise registration/run paths jest.mock this
// module locally with their own factory.
const noop = jest.fn();
const noopAsync = jest.fn().mockResolvedValue(undefined);
const stub = {
configure: noopAsync,
start: noopAsync,
stop: jest.fn().mockResolvedValue(true),
finish: noop,
scheduleTask: noopAsync,
registerHeadlessTask: noop,
STATUS_RESTRICTED: 0,
STATUS_DENIED: 1,
STATUS_AVAILABLE: 2,
NETWORK_TYPE_NONE: 0,
NETWORK_TYPE_ANY: 1,
NETWORK_TYPE_CELLULAR: 2,
NETWORK_TYPE_UNMETERED: 3,
NETWORK_TYPE_NOT_ROAMING: 4,
};
return { __esModule: true, default: stub, ...stub };
});
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'));
jest.mock('react-native-device-info', () => {
@ -173,22 +201,32 @@ jest.mock('react-native-default-preference', () => {
});
jest.mock('react-native-fs', () => {
// Track existence per absolute path so the Arkade Realm adapter's
// ensureArkadeDir() / unlink() round trips behave coherently in tests.
const mockFsExisting = new Set();
const setExists = p => mockFsExisting.add(p);
const clearExists = p => mockFsExisting.delete(p);
return {
mkdir: jest.fn(),
mkdir: jest.fn(async p => {
setExists(p);
}),
moveFile: jest.fn(),
copyFile: jest.fn(),
pathForBundle: jest.fn(),
pathForGroup: jest.fn(),
getFSInfo: jest.fn(),
getAllExternalFilesDirs: jest.fn(),
unlink: jest.fn(),
exists: jest.fn(),
unlink: jest.fn(async p => {
clearExists(p);
}),
exists: jest.fn(async p => mockFsExisting.has(p)),
stopDownload: jest.fn(),
resumeDownload: jest.fn(),
isResumable: jest.fn(),
stopUpload: jest.fn(),
completeHandlerIOS: jest.fn(),
readDir: jest.fn(),
readDir: jest.fn(async () => []),
readDirAssets: jest.fn(),
existsAssets: jest.fn(),
readdir: jest.fn(),
@ -207,14 +245,15 @@ jest.mock('react-native-fs', () => {
downloadFile: jest.fn(),
uploadFiles: jest.fn(),
touch: jest.fn(),
MainBundlePath: jest.fn(),
CachesDirectoryPath: jest.fn(),
DocumentDirectoryPath: jest.fn(),
ExternalDirectoryPath: jest.fn(),
ExternalStorageDirectoryPath: jest.fn(),
TemporaryDirectoryPath: jest.fn(),
LibraryDirectoryPath: jest.fn(),
PicturesDirectoryPath: jest.fn(),
MainBundlePath: '/mock/MainBundle',
CachesDirectoryPath: '/mock/Caches',
DocumentDirectoryPath: '/mock/Documents',
ExternalDirectoryPath: '/mock/External',
ExternalStorageDirectoryPath: '/mock/ExternalStorage',
TemporaryDirectoryPath: '/mock/Temporary',
LibraryDirectoryPath: '/mock/Library',
PicturesDirectoryPath: '/mock/Pictures',
__mockFsHelpers: { setExists, clearExists, reset: () => mockFsExisting.clear() },
};
});
@ -222,32 +261,231 @@ jest.mock('@react-native-documents/picker', () => ({}));
jest.mock('react-native-haptic-feedback', () => ({}));
const realmInstanceMock = {
create: function () {},
delete: function () {},
close: function () {},
write: function (transactionFn) {
if (typeof transactionFn === 'function') {
// to test if something is not right in Realm transactional database write
transactionFn();
}
},
objectForPrimaryKey: function () {
return {};
},
objects: function () {
const wallets = {
filtered: function () {
return [];
},
};
return wallets;
},
};
// Per-path Realm mock so the Arkade Realm adapter (one encrypted file per Ark wallet)
// can be exercised in unit tests. Each `Realm.open({ path })` returns a stable
// instance for that path until it is closed or deleted; concurrent opens for the
// same path observe the same instance.
//
// The mock supports in-memory CRUD so SDK repository operations (saveContract,
// getContracts, saveVtxos, getVtxos, etc.) round-trip correctly. Without this,
// `annotateVtxos` cannot find contracts that were just saved and throws
// "no contract matched vtxo.script" when the test wallet has live VTXOs.
jest.mock('realm', () => {
const mockRealmStore = new Map();
// Persisted-on-disk view: paths that have been opened at least once and not
// yet deleted. Realm.exists / Realm.deleteFile read this rather than the
// live (memory-cached, possibly-closed) instance map so deleteArkadeRealm
// can realistically test the file-cleanup path.
const mockRealmFiles = new Set();
// Primary-key field per Realm object type. Used by create() to key the
// in-memory store and by delete() to remove individual objects.
const PK_FIELD = {
ArkContract: 'script',
ArkVtxo: 'pk',
ArkUtxo: 'pk',
ArkTransaction: 'pk',
ArkWalletState: 'key',
BoltzSwap: 'id',
ArkSwapNotificationSuppression: 'id',
};
// Split a query string at a top-level separator (i.e. not inside parens/braces).
const splitTop = (s, sep) => {
const parts = [];
let depth = 0;
let start = 0;
for (let i = 0; i <= s.length - sep.length; i++) {
const c = s[i];
if (c === '(' || c === '{') depth++;
else if (c === ')' || c === '}') depth--;
else if (depth === 0 && s.slice(i, i + sep.length) === sep) {
parts.push(s.slice(start, i).trim());
i += sep.length - 1;
start = i + 1;
}
}
parts.push(s.slice(start).trim());
return parts.length > 1 ? parts : [s.trim()];
};
// Evaluate a Realm query expression against a plain object.
// Handles: `field == $N`, `field IN {$0,$1,...}`, AND, OR, and parens.
const evalExpr = (obj, expr, args) => {
expr = expr.trim();
// Strip matching outer parens — e.g. "(a == $0 OR a == $1)" → "a == $0 OR a == $1"
while (expr.startsWith('(') && expr.endsWith(')')) {
let depth = 0;
let allWrapped = true;
for (let i = 0; i < expr.length - 1; i++) {
if (expr[i] === '(') depth++;
else if (expr[i] === ')') {
if (--depth === 0) {
allWrapped = false;
break;
}
}
}
if (allWrapped) expr = expr.slice(1, -1).trim();
else break;
}
// AND: all sub-expressions must match
const andParts = splitTop(expr, ' AND ');
if (andParts.length > 1) return andParts.every(p => evalExpr(obj, p, args));
// OR: any sub-expression must match
const orParts = splitTop(expr, ' OR ');
if (orParts.length > 1) return orParts.some(p => evalExpr(obj, p, args));
// IN {$0, $1, ...} — used by BoltzSwap repository
const inMatch = expr.match(/^(\w+)\s+IN\s+\{([^}]*)\}$/i);
if (inMatch) {
const field = inMatch[1];
const values = inMatch[2].split(',').map(p => {
const m = p.trim().match(/^\$(\d+)$/);
return m ? args[+m[1]] : undefined;
});
return values.includes(obj[field]);
}
// field == $N
const eqMatch = expr.match(/^(\w+)\s*==\s*\$(\d+)$/);
if (eqMatch) return obj[eqMatch[1]] === args[+eqMatch[2]];
return true; // unknown expression — pass through
};
// Build a chainable collection over an array of Realm objects.
const makeCollection = (type, items) => {
const arr = Array.isArray(items) ? items : [...items];
return {
filtered: (query, ...args) =>
makeCollection(
type,
arr.filter(o => evalExpr(o, query, args)),
),
sorted: (field, reverse) => {
const sorted = [...arr].sort((a, b) => {
if (a[field] < b[field]) return reverse ? 1 : -1;
if (a[field] > b[field]) return reverse ? -1 : 1;
return 0;
});
return makeCollection(type, sorted);
},
get length() {
return arr.length;
},
[Symbol.iterator]: function* () {
yield* arr;
},
// Internal: used by delete() to identify the backing type and items.
_type: type,
_items: arr,
};
};
const makeRealmInstance = path => {
let isClosed = false;
// type → Map<primaryKey, object>
const typeStore = new Map();
const getStore = type => {
if (!typeStore.has(type)) typeStore.set(type, new Map());
return typeStore.get(type);
};
return {
path,
get isClosed() {
return isClosed;
},
create(type, data) {
const store = getStore(type);
const pkField = PK_FIELD[type];
const pk = pkField !== undefined ? data[pkField] : JSON.stringify(data);
// Shallow-copy so later mutations to the caller's object don't affect
// what the store holds. Attach a non-enumerable tag for delete().
const stored = Object.defineProperty({ ...data }, '_realmMeta', {
value: { type, pk },
enumerable: false,
});
store.set(pk, stored);
},
delete(target) {
if (!target) return;
// Single object returned by objectForPrimaryKey (has _realmMeta)
if (target._realmMeta) {
const { type, pk } = target._realmMeta;
getStore(type).delete(pk);
return;
}
// Collection returned by objects() / filtered()
if (target._type !== undefined && target._items !== undefined) {
const store = getStore(target._type);
const pkField = PK_FIELD[target._type];
for (const item of target._items) {
const pk = pkField !== undefined ? item[pkField] : undefined;
if (pk !== undefined) store.delete(pk);
}
}
},
write(transactionFn) {
if (typeof transactionFn === 'function') transactionFn();
},
objectForPrimaryKey(type, pk) {
return getStore(type).get(pk) ?? null;
},
objects(type) {
return makeCollection(type, getStore(type).values());
},
close() {
isClosed = true;
},
addListener: jest.fn(),
removeAllListeners: jest.fn(),
// Exposed so __mockRealmHelpers.reset() can wipe data in open instances.
_clearData: () => typeStore.clear(),
};
};
return {
UpdateMode: { Modified: 1 },
open: jest.fn(() => realmInstanceMock),
open: jest.fn(async config => {
const path = (config && config.path) || '__default__';
const existing = mockRealmStore.get(path);
if (existing && !existing.isClosed) return existing;
const inst = makeRealmInstance(path);
mockRealmStore.set(path, inst);
mockRealmFiles.add(path);
return inst;
}),
// Real Realm.exists / Realm.deleteFile are synchronous in this version.
exists: jest.fn(arg => {
const path = typeof arg === 'string' ? arg : (arg && arg.path) || '__default__';
return mockRealmFiles.has(path);
}),
deleteFile: jest.fn(config => {
const path = (config && config.path) || '__default__';
mockRealmStore.delete(path);
mockRealmFiles.delete(path);
}),
__mockRealmHelpers: {
reset: () => {
// Clear data inside any open instances so tests don't leak state
// through instances cached in the app module's realmInstances map.
for (const inst of mockRealmStore.values()) {
if (typeof inst._clearData === 'function') inst._clearData();
}
mockRealmStore.clear();
mockRealmFiles.clear();
},
store: mockRealmStore,
files: mockRealmFiles,
},
};
});
@ -278,16 +516,61 @@ jest.mock('react-native-share', () => {
};
});
const mockKeychain = {
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
setGenericPassword: jest.fn().mockResolvedValue(),
getGenericPassword: jest.fn().mockResolvedValue(),
resetGenericPassword: jest.fn().mockResolvedValue(),
};
jest.mock('react-native-keychain', () => mockKeychain);
// Service-keyed Keychain mock so Arkade adapter tests can exercise the per-wallet
// encryption-key lifecycle (load-or-create, then read on subsequent open). Defined
// inside the factory because Jest hoists `jest.mock` above module scope and refuses
// out-of-scope captures (only names matching /mock/i are allowed through).
jest.mock('react-native-keychain', () => {
const mockKeychainCreds = new Map();
return {
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
ACCESSIBLE: {
WHEN_UNLOCKED: 'AccessibleWhenUnlocked',
AFTER_FIRST_UNLOCK: 'AccessibleAfterFirstUnlock',
ALWAYS: 'AccessibleAlways',
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 'AccessibleWhenPasscodeSetThisDeviceOnly',
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly',
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AccessibleAfterFirstUnlockThisDeviceOnly',
},
SECURITY_LEVEL: {
SECURE_SOFTWARE: 'SECURE_SOFTWARE',
SECURE_HARDWARE: 'SECURE_HARDWARE',
ANY: 'ANY',
},
setGenericPassword: jest.fn(async (username, password, options) => {
const svc = (options && options.service) || '__default__';
mockKeychainCreds.set(svc, { username, password, service: svc });
return true;
}),
getGenericPassword: jest.fn(async options => {
const svc = (options && options.service) || '__default__';
return mockKeychainCreds.get(svc) || false;
}),
resetGenericPassword: jest.fn(async options => {
const svc = (options && options.service) || '__default__';
return mockKeychainCreds.delete(svc);
}),
// Default to the strongest level so the adapter's preflight selects
// SECURE_HARDWARE in the happy path. Tests override per-case via
// mockResolvedValueOnce when they need a downgrade scenario.
getSecurityLevel: jest.fn(async () => 'SECURE_HARDWARE'),
__mockKeychainHelpers: { reset: () => mockKeychainCreds.clear(), store: mockKeychainCreds },
};
});
jest.mock('react-native-tcp-socket', () => mockKeychain);
// Historic copy-paste: react-native-tcp-socket pulled the Keychain mock. Keep the
// same surface so existing tests continue to mount, just with a fresh map.
jest.mock('react-native-tcp-socket', () => {
return {
SECURITY_LEVEL_ANY: 'MOCK_SECURITY_LEVEL_ANY',
SECURITY_LEVEL_SECURE_SOFTWARE: 'MOCK_SECURITY_LEVEL_SECURE_SOFTWARE',
SECURITY_LEVEL_SECURE_HARDWARE: 'MOCK_SECURITY_LEVEL_SECURE_HARDWARE',
setGenericPassword: jest.fn().mockResolvedValue(true),
getGenericPassword: jest.fn().mockResolvedValue(false),
resetGenericPassword: jest.fn().mockResolvedValue(true),
};
});
global.alert = () => {};

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

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,7 @@ jest.mock('../../hooks/useWalletSubscribe', () => ({
default: () => mockWalletSubscribe,
}));
const routeParams = { hash: 'mock-tx', walletID: 'mock-wallet' };
let routeParams: any = { hash: 'mock-tx', walletID: 'mock-wallet' };
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
@ -226,6 +226,7 @@ describe('TransactionStatus regression', () => {
saveToDisk: jest.fn(() => Promise.resolve()),
};
mockWalletSubscribe = null;
routeParams = { hash: 'mock-tx', walletID: 'mock-wallet' };
});
afterEach(() => {
@ -276,4 +277,31 @@ describe('TransactionStatus regression', () => {
{ type: 'plain-text', defaultValue: existingMemo }, // defaultValue: pre-fill input for easy editing
);
});
it('renders an Arkade row as received (not pending) and never queries Electrum for its synthetic id', async () => {
const BlueElectrum = require('../../blue_modules/BlueElectrum');
const arkRow = { txid: 'ark-deadbeef', type: 'bitcoind_tx', value: 1200, walletID: 'mock-wallet', timestamp: 1700000000 };
routeParams = { tx: arkRow, hash: 'ark-deadbeef', walletID: 'mock-wallet' };
const walletMock = {
getID: () => 'mock-wallet',
getTransactions: jest.fn(() => [arkRow]),
getLastTxFetch: jest.fn(() => 1000),
allowRBF: jest.fn(() => false),
preferredBalanceUnit: 'BTC',
} as any;
mockStorageState = { ...mockStorageState, wallets: [walletMock] };
mockWalletSubscribe = walletMock;
const view = render(<TransactionStatus />);
// #1: the row shows its real direction (received), not a false "Pending".
await waitFor(() => {
expect(view.getByText('received')).toBeTruthy();
});
// #1/#3: no "confirmations" sub-value for an off-chain row (would have rendered "NaN confirmations").
expect(view.queryByText(/confirmations/)).toBeNull();
// #2: the synthetic id is never handed to Electrum (the source of "hash ark-… not found").
expect(BlueElectrum.multiGetTransactionByTxid).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,67 @@
import assert from 'assert';
import { isOnChainTransaction, resolveTxDisplayState } from '../../blue_modules/transactionDisplayState';
describe('transactionDisplayState', () => {
describe('isOnChainTransaction', () => {
it('is true only when a non-empty hash string is present', () => {
assert.strictEqual(isOnChainTransaction({ hash: 'abc123' }), true);
assert.strictEqual(isOnChainTransaction({ hash: '' }), false);
assert.strictEqual(isOnChainTransaction({ txid: 'ark-deadbeef' }), false);
assert.strictEqual(isOnChainTransaction({}), false);
assert.strictEqual(isOnChainTransaction(null), false);
assert.strictEqual(isOnChainTransaction(undefined), false);
});
});
describe('resolveTxDisplayState — on-chain (real hash present)', () => {
it('confirmed receive → received', () => {
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', confirmations: 3, value: 1000 }), 'received');
});
it('confirmed send → sent', () => {
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', confirmations: 6, value: -1000 }), 'sent');
});
it('zero confirmations → pending', () => {
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', confirmations: 0, value: -1000 }), 'pending');
});
it('missing confirmations → pending (matches the !confirmations fallback)', () => {
assert.strictEqual(resolveTxDisplayState({ hash: 'ab', value: 500 }), 'pending');
});
});
describe('resolveTxDisplayState — off-chain Ark/Lightning (no hash)', () => {
it('native Ark receive (bitcoind_tx, positive) → received', () => {
assert.strictEqual(resolveTxDisplayState({ txid: 'ark-deadbeef', type: 'bitcoind_tx', value: 5000 }), 'received');
});
it('native Ark send (bitcoind_tx, negative) → sent', () => {
assert.strictEqual(resolveTxDisplayState({ txid: 'ark-deadbeef', type: 'bitcoind_tx', value: -5000 }), 'sent');
});
it('refill (boarding-, positive) → received', () => {
assert.strictEqual(resolveTxDisplayState({ txid: 'boarding-deadbeef', type: 'bitcoind_tx', value: 5000 }), 'received');
});
it('pending refill (boarding-utxo-, positive) → pending (parity with the list)', () => {
assert.strictEqual(resolveTxDisplayState({ txid: 'boarding-utxo-deadbeef:0', type: 'bitcoind_tx', value: 5000 }), 'pending');
});
it('a native Ark leg (ark-) is never pending, even with no confirmations field', () => {
assert.notStrictEqual(resolveTxDisplayState({ txid: 'ark-x', type: 'bitcoind_tx', value: 1 }), 'pending');
});
it('a settled refill (boarding-) is a confirmed receive, not pending', () => {
assert.strictEqual(resolveTxDisplayState({ txid: 'boarding-deadbeef', type: 'bitcoind_tx', value: 5000 }), 'received');
});
});
describe('resolveTxDisplayState — defensive invoice rows (route to LNDViewInvoice today)', () => {
it('paid_invoice → sent', () => {
assert.strictEqual(resolveTxDisplayState({ type: 'paid_invoice', value: -5000 }), 'sent');
});
it('unpaid user_invoice → pending', () => {
assert.strictEqual(resolveTxDisplayState({ type: 'user_invoice', ispaid: false, value: 5000 }), 'pending');
});
it('paid user_invoice → received', () => {
assert.strictEqual(resolveTxDisplayState({ type: 'user_invoice', ispaid: true, value: 5000 }), 'received');
});
it('unpaid payment_request → pending', () => {
assert.strictEqual(resolveTxDisplayState({ type: 'payment_request', ispaid: false, value: 5000 }), 'pending');
});
});
});