aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 14:02:25 -0700
committerAdam Soltys <[email protected]>2026-08-03 14:02:25 -0700
commitcdd34e88be8a58c1cc9fa3d0cfbbd02b23439629 (patch)
treeeca597b57765aa6adbfbbe36dffb00087713b413 /src
parentda13a93a3852eb15156a5a84a73a8200d1344685 (diff)
downloadcoinosv3-cdd34e88be8a58c1cc9fa3d0cfbbd02b23439629.tar.gz
coinosv3-cdd34e88be8a58c1cc9fa3d0cfbbd02b23439629.zip
Auto-withdraw runs with the app closed; fix the dead Back on the legacy step
The service worker can't watch a relay, so it doesn't try: the app resolves the destination to something concrete (ark address, LNURL endpoint, or on-chain script) and mirrors it beside the keys the background NWC payer already uses. On a push wake-up — which is exactly when money has just landed — the worker re-checks the same gate and sends, writing the outcome back into the mirror for the app to absorb on next open. A partial on-chain forward still waits for the app, since carving an exact vtxo needs more round trips than a worker is promised. writeBgMirror now carries forward the NWC connections and offer when another caller refreshes the mirror, instead of blanking them. And onboarding's legacy step loses its Back: the wallet exists by then, so returning to the nostr question just bounced straight forward again and the link looked broken. Its copy no longer promises a rename either. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src')
-rw-r--r--src/app.js3
-rw-r--r--src/bg-autowithdraw.js91
-rw-r--r--src/features/ark.js45
-rw-r--r--src/i18n.js2
-rw-r--r--src/nwc-bg.js5
-rw-r--r--src/nwc-respond.js4
-rw-r--r--src/sw-nwc.js10
7 files changed, 155 insertions, 5 deletions
diff --git a/src/app.js b/src/app.js
index 328e03b..fdbe8f4 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2107,7 +2107,8 @@ function onboardScreen() {
h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => { o.step = 'username'; render(); } },
o.wentToLegacy ? t('onbLegacyDone') : t('onbLegacyNo')),
ui.onbError ? h('div', { class: 'notice err' }, ui.onbError) : null,
- h('button', { class: 'linklike small', onClick: () => { o.step = 'nostr'; render(); } }, t('back')),
+ // No Back: the wallet exists by now, so the nostr question behind us
+ // would just bounce straight back here. Both choices lead forward.
]);
}
if (o.step === 'welcome') {
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 };
+}
diff --git a/src/features/ark.js b/src/features/ark.js
index 0488274..c61d301 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -478,16 +478,22 @@ export function arkFeature(ctx) {
// full manager state plus the chain-3 keys for its live coins and a window
// of upcoming change indices (the worker cannot derive). nwc.js decides
// WHEN to mirror (background answering on) and supplies the connections.
+ // connections/offer belong to the nwc feature; when it isn't the caller
+ // (an auto-withdraw setting changed, say) carry forward what's mirrored
+ // rather than blanking someone's wallet connections.
async function writeBgMirror(connections, offer) {
const cfg = getArkConfig();
if (!cfg || !ark || !ark.info || !ark.state) return;
const prev = await loadBg(wallet._cacheKey());
+ if (connections === undefined) connections = (prev && prev.connections) || [];
+ if (offer === undefined) offer = (prev && prev.offer) || null;
const rec = buildBg({
ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey },
mgrState: ark.state,
keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey),
connections,
offer,
+ autowithdraw: await awMirror(prev),
});
if (prev) rec.spends = prev.spends; // the worker's log survives rewrites
await saveBg(wallet._cacheKey(), rec);
@@ -500,6 +506,7 @@ export function arkFeature(ctx) {
async function mergeBgWorkerState(mgr) {
const rec = await loadBg(wallet._cacheKey());
if (!rec) return;
+ absorbBgAutoWithdraw(rec);
if (rec.v < 3) {
await sweepLegacyPouch(rec).catch((e) => console.warn('ark: pouch sweep failed', e.message));
return;
@@ -1127,6 +1134,43 @@ export function arkFeature(ctx) {
await mgr.payLnInvoice(invoice, { amountSat });
}
+ // What the service worker gets: the setting plus a destination it can pay
+ // without a relay. Resolution can fail (DNS down, profile missing) — then we
+ // keep whatever we mirrored last rather than dropping the feature.
+ async function awMirror(prev) {
+ const st = awState();
+ if (!st.on || !st.dest) return null;
+ const prevAw = prev && prev.autowithdraw;
+ let target = prevAw && prevAw.dest === st.dest ? prevAw.target : null;
+ try {
+ const r = await awResolve(st.dest);
+ target = { kind: r.kind, address: r.address };
+ if (r.kind === 'lnaddr' || r.kind === 'lnurl') {
+ const zt = parseZapTarget(r.address);
+ if (zt && zt.url) target.url = zt.url; else target = null;
+ } else if (r.kind === 'onchain') {
+ target.spkHex = hex.encode(btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(r.address)));
+ }
+ } catch {}
+ if (!target) return null;
+ return {
+ on: true, dest: st.dest, target,
+ threshold: Number(st.threshold) || 0,
+ keep: Math.max(0, Number(st.keep) || 0),
+ failedAt: st.failedAt || 0,
+ };
+ }
+
+ // What the worker did while we were closed becomes the app's state again.
+ function absorbBgAutoWithdraw(rec) {
+ const bg = rec && rec.autowithdraw;
+ if (!bg) return;
+ const st = awState();
+ if (!st.on) return;
+ if ((bg.lastAt || 0) > (st.lastAt || 0)) awSave({ ...st, lastAt: bg.lastAt, lastSat: bg.lastSat, failedAt: 0, error: '' });
+ else if ((bg.failedAt || 0) > (st.failedAt || 0)) awSave({ ...st, failedAt: bg.failedAt, error: bg.error || '' });
+ }
+
let awBusy = false;
async function maybeAutoWithdraw(mgr) {
const st = awState();
@@ -1172,6 +1216,7 @@ export function arkFeature(ctx) {
}
ui.awError = '';
awSave({ ...st, on, dest, threshold, keep, failedAt: 0, error: '' });
+ writeBgMirror().catch(() => {}); // the worker needs the new destination
toast(on ? t('awOn') : t('awOff'));
render();
};
diff --git a/src/i18n.js b/src/i18n.js
index af71814..fe1e70a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -510,7 +510,7 @@ const STR = {
onbNostrNo: 'No — create my wallet',
onbSigninTitle: 'Sign in with Nostr',
onbLegacyTitle: 'Do you have an account on coinos.io?',
- onbLegacyBody: 'The original coinos. We can move your balance over and bring your username with you — your old account gets renamed and stops receiving.',
+ onbLegacyBody: 'The original coinos. We can move your balance over and point your username here — your old account keeps working, it just stops receiving payments.',
onbLegacyGo: 'Move my coinos.io account',
onbLegacyNoAddr: "Your payment address isn't ready yet — try again in a moment.",
onbLegacyDone: "I'm done — continue",
diff --git a/src/nwc-bg.js b/src/nwc-bg.js
index af0bb6d..b22c36e 100644
--- a/src/nwc-bg.js
+++ b/src/nwc-bg.js
@@ -84,7 +84,7 @@ export const BG_KEY_LOOKAHEAD = 25;
// module). Chain 3 covers coins + change, chain 4/0 the mailbox, chain 5 a
// window of receive preimages — everything the worker needs to pay AND to
// mint invoices while the app is closed.
-export function buildBg({ ark, mgrState, keyFor, connections, offer }) {
+export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw }) {
const keys = {};
const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex));
for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i);
@@ -104,6 +104,9 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer }) {
keys5,
key4: keyFor(4, 0),
offer: offer || null, // { sk, pk } — the CLINK offer service keypair
+ // { on, threshold, keep, target: { kind, address, url?, spkHex? } } — the
+ // destination is resolved by the app, so the worker never needs a relay
+ autowithdraw: autowithdraw || null,
connections: (connections || []).map((c) => ({
id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
maxSat: c.maxSat, dailySat: c.dailySat,
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index fbbb98c..258b7e7 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -48,7 +48,7 @@ const spendableSat = (rec) =>
// The worker can't derive keys — the mirror carries them per chain:
// 3 = coins/change, 4/0 = mailbox, 5 = receive preimages.
-function shimAccount(rec) {
+export function shimAccount(rec) {
const byChain = (chain, i) => {
const k = chain === 5 ? rec.keys5?.[String(i)]
: chain === 4 ? rec.key4
@@ -59,7 +59,7 @@ function shimAccount(rec) {
return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) };
}
-async function bgManager(rec, walletKey, saveFn) {
+export async function bgManager(rec, walletKey, saveFn) {
const storage = {
load: () => rec.mgr,
save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); },
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index 0fdc4d7..e887963 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -5,6 +5,7 @@
// when it can't.
import { respondFromBg } from './nwc-respond.js';
+import { bgAutoWithdraw } from './bg-autowithdraw.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
@@ -17,6 +18,13 @@ self.addEventListener('push', (e) => {
e.waitUntil((async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.some((c) => c.visibilityState === 'visible')) return;
+ // Money landing is exactly when a forwarding rule wants to run, and this
+ // wake-up is the only moment the worker gets. Failures are recorded in
+ // the mirror; the notification still goes out either way.
+ if (data.reason === 'payment') {
+ try { await bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }); }
+ catch (err) { console.warn('[sw-aw] failed:', err && err.message); }
+ }
const T = {
payment: ['Payment received', data.amountSat ? `+${Number(data.amountSat).toLocaleString()} sats — open coinos to see it.` : 'Open coinos to see it.'],
dm: ['New message', 'You have a new private message.'],
@@ -41,6 +49,8 @@ self.addEventListener('push', (e) => {
for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey });
return;
}
+ // an NWC wake-up may also have moved money in; check the rule after
+ setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0);
let handled = false;
try {
handled = await respondFromBg(data, {