summaryrefslogtreecommitdiffstats
path: root/src/bg-autowithdraw.js
blob: 60648df645d285f5953eecf1216c6fe4063bafae (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
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 };
}