diff options
Diffstat (limited to 'src/bg-autowithdraw.js')
| -rw-r--r-- | src/bg-autowithdraw.js | 91 |
1 files changed, 91 insertions, 0 deletions
diff --git a/src/bg-autowithdraw.js b/src/bg-autowithdraw.js new file mode 100644 index 0000000..60648df --- /dev/null +++ b/src/bg-autowithdraw.js @@ -0,0 +1,91 @@ +// Auto-withdraw while the app is closed. +// +// The service worker can't watch a relay or resolve a nostr profile, so it +// doesn't try: the app resolves the destination to something concrete (an ark +// address, a lightning address, or an on-chain address) and mirrors it into +// IndexedDB alongside the keys. All this does is the arithmetic and the +// payment — the same ArkManager the background NWC payer uses. +// +// It runs on a push wake-up, which in practice is exactly when it matters: a +// payment just landed. Anything unexpected returns quietly and the next open +// app picks it up. + +import { bgManager } from './nwc-respond.js'; +import { allBgs, saveBg } from './nwc-bg.js'; + +const DUST = 330; +const FAIL_BACKOFF_MS = 15 * 60_000; + +const spendableOf = (mgr) => (mgr.state.vtxos || []) + .filter((v) => v.state === 'spendable') + .reduce((n, v) => n + (v.amountSat || 0), 0); + +// Same gate as the foreground (see maybeAutoWithdraw in features/ark.js). +export function bgShouldWithdraw(aw, spendable, actions, now = Date.now()) { + if (!aw || !aw.on || !aw.target || !aw.target.address) return 0; + if (now - (aw.failedAt || 0) < FAIL_BACKOFF_MS) return 0; + if ((actions || []).some((a) => !['done', 'failed'].includes(a.step))) return 0; + const threshold = Number(aw.threshold) || 0; + const keep = Math.max(0, Number(aw.keep) || 0); + if (!threshold || spendable < threshold) return 0; + const amount = spendable - keep; + return amount >= DUST ? amount : 0; +} + +async function payTarget(mgr, target, amountSat, fetchFn) { + if (target.kind === 'ark') return mgr.send(target.address, amountSat); + if (target.kind === 'lnaddr' || target.kind === 'lnurl') { + const url = target.url; + if (!url) throw new Error('no lnurl endpoint mirrored'); + const p = await fetchFn(url).then((r) => r.json()); + if (!p || p.tag !== 'payRequest' || !p.callback) throw new Error('bad pay endpoint'); + const msat = amountSat * 1000; + if (msat < p.minSendable || msat > p.maxSendable) throw new Error('amount outside limits'); + const sep = p.callback.includes('?') ? '&' : '?'; + const inv = await fetchFn(`${p.callback}${sep}amount=${msat}`).then((r) => r.json()); + if (!inv || !inv.pr) throw new Error('no invoice'); + const id = await mgr.payLnInvoice(inv.pr, { amountSat }); + await mgr.driveLn(id).catch(() => {}); + return id; + } + // on-chain: only when it's the whole balance. Carving an exact vtxo needs a + // round trip the worker's budget can't promise, so a partial on-chain + // forward waits for the app. + if (target.kind === 'onchain') { + if (!target.spkHex) throw new Error('no script mirrored'); + if (amountSat !== spendableOf(mgr)) throw new Error('partial on-chain forward needs the app'); + const spk = Uint8Array.from(target.spkHex.match(/../g).map((b) => parseInt(b, 16))); + return mgr.startOffboard(spk, target.address); + } + throw new Error(`unknown destination kind ${target.kind}`); +} + +// Run auto-withdraw for every mirrored wallet that wants it. Returns a summary +// for the caller to log or notify with. +export async function bgAutoWithdraw({ fetchFn = fetch, saveFn = saveBg, log = () => {} } = {}) { + let sent = 0; + let records; + try { records = await allBgs(); } catch { return { sent: 0 }; } + for (const { walletKey: key, rec } of records || []) { + const aw = rec && rec.autowithdraw; + if (!aw || !aw.on || !rec.mgr) continue; + let mgr; + try { mgr = await bgManager(rec, key, saveFn); } catch { continue; } + let amount = bgShouldWithdraw(aw, spendableOf(mgr), mgr.state.actions); + if (!amount) continue; + try { + await mgr.sync().catch(() => {}); + amount = bgShouldWithdraw(aw, spendableOf(mgr), mgr.state.actions); // recheck after sync + if (!amount) continue; + log(`auto-withdraw: sending ${amount} to ${aw.target.kind}`); + await payTarget(mgr, aw.target, amount, fetchFn); + rec.autowithdraw = { ...aw, lastAt: Date.now(), lastSat: amount, failedAt: 0, error: '' }; + sent += amount; + } catch (e) { + rec.autowithdraw = { ...aw, failedAt: Date.now(), error: (e && e.message) || String(e) }; + log(`auto-withdraw failed: ${(e && e.message) || e}`); + } + await saveFn(key, rec).catch(() => {}); + } + return { sent }; +} |
