BlueWallet/blue_modules/arkade-background.ts
pietro909 e37c4a693c
Some checks are pending
Build Release and Upload to TestFlight (iOS) / build (push) Waiting to run
Build Release and Upload to TestFlight (iOS) / testflight-upload (push) Blocked by required conditions
BuildReleaseApk / buildReleaseApk (push) Waiting to run
BuildReleaseApk / browserstack (push) Blocked by required conditions
OPS: upgrade Arkade SDKs and harden Ark wallet integration (#8585)
2026-06-01 15:22:17 +01:00

424 lines
14 KiB
TypeScript

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