From 54f5e7e1410f74d08df69dcade7cf131497ad1bf Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 13:56:27 -0700 Subject: Auto-withdraw: forward Spending onward past a threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Payments takes a destination, a threshold, and an optional amount to keep behind. When Spending crosses the threshold the wallet sends the rest on by itself, resolving the destination the same way the send form does — an ark address goes native and free, a payment name prefers its ark instruction before falling back to lightning, an npub tries its ark advert then its lightning address, and an on-chain address becomes a cooperative offboard (carving out an exact-amount vtxo first when it isn't sending everything, since offboards move whole vtxos). Money moving without a human watching deserves guardrails: one transfer at a time, never while another ark action is in flight, a fifteen-minute backoff after a failure with the reason kept for the card, destinations validated before it can be switched on, and dust amounts skipped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/ark.js | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/i18n.js | 17 +++++ 2 files changed, 199 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/features/ark.js b/src/features/ark.js index 5c5e0a8..f37d707 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -18,6 +18,7 @@ import { } from '../api.js'; import { t } from '../i18n.js'; import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js'; +import { parseZapTarget, fetchPayParams, requestInvoice } from '../lnurl.js'; import { shortAddr, shortTxid, timeAgo, ARK_ICON, ARK_MARK } from '../format.js'; // t?ark1… bech32m — an Ark address for this or another ASP. @@ -344,7 +345,8 @@ export function arkFeature(ctx) { if (mgr.state && (mgr.state.vtxos || []).length) { try { wallet.saveCache(); } catch {} } const tick = () => mgr.sync().catch(() => {}) .then(() => driveExits(mgr)).catch(() => {}) - .then(() => { if (ark === mgr) return maybeAutoRefresh(mgr); }).catch(() => {}); + .then(() => { if (ark === mgr) return maybeAutoRefresh(mgr); }).catch(() => {}) + .then(() => { if (ark === mgr) return maybeAutoWithdraw(mgr); }).catch(() => {}); tick(); // Reconcile once on connect: a vtxo synced in from another device (or // one this device held while a spend happened elsewhere) is checked @@ -1031,6 +1033,184 @@ export function arkFeature(ctx) { : null)); } + // ---- auto-withdraw -------------------------------------------------------- + // Forward Spending onward once it crosses a threshold. Same destinations the + // send form takes — an ark address, a payment name, a lightning address, an + // npub, or an on-chain address — resolved through the same paths, just + // without a human present. Deliberately conservative: one transfer at a + // time, never while another action is in flight, and a failure backs off + // instead of retrying in a loop. + + const awState = () => wallet.loadFeatureState('autowithdraw', {}); + const awSave = (v) => wallet.saveFeatureState('autowithdraw', v); + + // What kind of destination is this, and can we pay it at all? Returns + // { kind, address } or null. Resolution that needs the network (BIP-353, + // LNURL, npub profiles) happens at send time, not here. + function awClassify(dest) { + const d = (dest || '').trim(); + if (!d) return null; + if (isArkAddress(d)) return { kind: 'ark', address: d }; + if (parsePaymentName(d)) return { kind: 'name', address: d }; + if (npubToHex(d)) return { kind: 'npub', address: d }; + if (/^lnurl1[ac-hj-np-z02-9]+$/i.test(d)) return { kind: 'lnurl', address: d }; + try { + btc.Address(wallet.netCfg.net).decode(d); + return { kind: 'onchain', address: d }; + } catch {} + return null; + } + + // Turn any destination into something payable right now. + async function awResolve(dest) { + const c = awClassify(dest); + if (!c) throw new Error(t('awBadDest')); + if (c.kind === 'ark' || c.kind === 'onchain') return c; + // a payment name may carry an ark instruction (free) or fall back to LNURL + if (c.kind === 'name') { + const p = parsePaymentName(c.address); + const uri = await resolveBip353(p.name, p.domain).catch(() => null); + const dec = uri ? parseBip21Uri(uri) : null; + const arkAddr = dec && dec.params && dec.params.ark; + if (arkAddr && isArkAddress(arkAddr)) return { kind: 'ark', address: arkAddr }; + if (dec && dec.onchain) return { kind: 'onchain', address: dec.onchain }; + return { kind: 'lnaddr', address: c.address }; + } + if (c.kind === 'npub') { + const pk = npubToHex(c.address); + const adv = await lookupArkZapTarget(pk).catch(() => null); + if (adv && adv.status === 'ready') return { kind: 'ark', address: adv.address }; + const profile = wallet.nostrProfile ? await wallet.nostrProfile(pk).catch(() => null) : null; + if (profile && profile.lud16) return { kind: 'lnaddr', address: profile.lud16 }; + throw new Error(t('awBadDest')); + } + return { kind: c.kind === 'lnurl' ? 'lnurl' : 'lnaddr', address: c.address }; + } + + async function awPay(target, amountSat) { + const mgr = await connectArk(); + if (target.kind === 'ark') { + await mgr.send(target.address, amountSat); + return; + } + if (target.kind === 'onchain') { + const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address)); + // Offboarding moves whole vtxos, so carve out the exact amount first + // when we're not sending everything. + const spendables = () => mgr.state.vtxos.filter((v) => v.state === 'spendable'); + const total = spendables().reduce((n, v) => n + v.amountSat, 0); + let ids; + if (amountSat < total) { + let exact = spendables().find((v) => v.amountSat === amountSat); + if (!exact) { + const before = new Set(spendables().map((v) => v.id)); + await mgr.send(mgr.address(), amountSat); + for (let i = 0; i < 30 && !exact; i++) { + await new Promise((r) => setTimeout(r, 2000)); + await mgr.sync().catch(() => {}); + exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id)); + } + if (!exact) throw new Error(t('arkSplitTimeout')); + } + ids = [exact.id]; + } + await mgr.startOffboard(spk, target.address, ids); + return; + } + // lightning: ask their server for an invoice, pay it over ark + const zt = parseZapTarget(target.address); + if (!zt || !zt.url) throw new Error(t('awBadDest')); + const params = await fetchPayParams(zt.url); + const msat = amountSat * 1000; + if (msat < params.minSendable || msat > params.maxSendable) throw new Error(t('awOutOfRange')); + const invoice = await requestInvoice(params, { amountMsat: msat, lnurlBech32: zt.lnurlBech32 }); + await mgr.payLnInvoice(invoice, { amountSat }); + } + + let awBusy = false; + async function maybeAutoWithdraw(mgr) { + const st = awState(); + if (!st.on || !st.dest || wallet.watchOnly || awBusy) return; + if (Date.now() - (st.failedAt || 0) < 15 * 60_000) return; // backing off + if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; + const spendable = (mgr.state.vtxos || []) + .filter((v) => v.state === 'spendable').reduce((n, v) => n + v.amountSat, 0); + const threshold = Number(st.threshold) || 0; + const keep = Math.max(0, Number(st.keep) || 0); + if (!threshold || spendable < threshold) return; + const amount = spendable - keep; + if (amount < 330) return; + awBusy = true; + try { + const target = await awResolve(st.dest); + await awPay(target, amount); + const s2 = awState(); + awSave({ ...s2, lastAt: Date.now(), lastSat: amount, failedAt: 0, error: '' }); + toast(t('awSent', { n: fmtAmount(amount) + ' ' + unitLabel() })); + render(); + } catch (e) { + const s2 = awState(); + awSave({ ...s2, failedAt: Date.now(), error: e.message || String(e) }); + console.warn('auto-withdraw failed:', e.message); + } finally { + awBusy = false; + } + } + + function autoWithdrawCard() { + if (!arkAvailable() || wallet.watchOnly) return null; + const st = awState(); + const draft = ui.awDraft || (ui.awDraft = { dest: st.dest || '', threshold: String(st.threshold || ''), keep: String(st.keep || '') }); + const save = (on) => { + const dest = (draft.dest || '').trim(); + const threshold = parseInt(String(draft.threshold).replace(/[^0-9]/g, ''), 10) || 0; + const keep = parseInt(String(draft.keep).replace(/[^0-9]/g, ''), 10) || 0; + if (on) { + if (!awClassify(dest)) { ui.awError = t('awBadDest'); render(); return; } + if (threshold < 1000) { ui.awError = t('awLowThreshold'); render(); return; } + if (keep >= threshold) { ui.awError = t('awKeepTooHigh'); render(); return; } + } + ui.awError = ''; + awSave({ ...st, on, dest, threshold, keep, failedAt: 0, error: '' }); + toast(on ? t('awOn') : t('awOff')); + render(); + }; + return h('div', { class: 'card col' }, + h('h3', {}, t('awTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('awDesc')), + h('label', { class: 'field' }, + h('span', { class: 'lab' }, t('awDest')), + h('input', { + type: 'text', class: 'mono-input', placeholder: t('awDestPh'), + autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', + value: draft.dest, + onInput: (e) => { draft.dest = e.target.value; }, + })), + h('div', { class: 'row gap6' }, + h('label', { class: 'field grow' }, + h('span', { class: 'lab' }, t('awThreshold')), + h('input', { + type: 'number', inputmode: 'numeric', min: '0', placeholder: '100000', + value: draft.threshold, onInput: (e) => { draft.threshold = e.target.value; }, + })), + h('label', { class: 'field grow' }, + h('span', { class: 'lab' }, t('awKeep')), + h('input', { + type: 'number', inputmode: 'numeric', min: '0', placeholder: '0', + value: draft.keep, onInput: (e) => { draft.keep = e.target.value; }, + }))), + ui.awError ? h('div', { class: 'notice err small' }, ui.awError) : null, + st.on && st.error ? h('div', { class: 'notice err small' }, t('awLastError', { why: st.error })) : null, + st.lastAt + ? h('div', { class: 'small faint' }, t('awLast', { n: fmtAmount(st.lastSat || 0) + ' ' + unitLabel(), when: new Date(st.lastAt).toLocaleString() })) + : null, + st.on + ? h('div', { class: 'row gap6' }, + h('button', { class: 'grow', onClick: () => save(true) }, t('save')), + h('button', { class: 'btn-ghost', onClick: () => save(false) }, t('awTurnOff'))) + : h('button', { class: 'btn-primary btn-block', onClick: () => save(true) }, t('awTurnOn'))); + } + // ---- NWC bridge funding (PoC) -------------------------------------------- // An external NWC wallet service (e.g. coinos) can bridge ark -> lightning: // a nostr client asks IT to pay_invoice, it asks US (kind 23196, funding @@ -1652,6 +1832,7 @@ export function arkFeature(ctx) { // swaps feature): the ASP pays invoices natively over ark. canLnPay() { return arkAvailable() && !wallet.watchOnly && !!(ark && ark.info); }, lnSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, + settingsCards() { return [autoWithdrawCard()]; }, startLnPay(invoice, meta) { return startArkLnPay(invoice, meta); }, // ---- headless seam for the NWC wallet service ---- arkPayInvoice(invoice, opts) { return payInvoiceHeadless(invoice, opts); }, diff --git a/src/i18n.js b/src/i18n.js index 18fe2cd..ab03ea6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -463,6 +463,23 @@ const STR = { arkUniDesc: 'Your escape hatch: claim the money yourself without anyone’s help. Takes about a day and costs mining fees, but works even if the server disappears.', arkExitNoBalance: 'Nothing in Spending to move.', arkZapTitle: 'Send a zap', + awTitle: 'Auto-withdraw', + awDesc: 'When Spending goes over an amount you choose, send it onward automatically — to an address, a payment name, a lightning address, or an npub.', + awDest: 'Send to', + awDestPh: 'name@domain, npub, bc1…, ark1…', + awThreshold: 'When Spending reaches (sats)', + awKeep: 'Keep behind (sats)', + awTurnOn: 'Turn on auto-withdraw', + awTurnOff: 'Turn off', + awOn: 'Auto-withdraw is on', + awOff: 'Auto-withdraw is off', + awBadDest: "That destination isn't one we can pay", + awLowThreshold: 'Use a threshold of at least 1,000 sats', + awKeepTooHigh: 'Keep less behind than the threshold', + awOutOfRange: "The amount is outside the destination's limits", + awSent: 'Auto-withdrew {n}', + awLast: 'Last sent {n} · {when}', + awLastError: 'Last attempt failed: {why}', zapTitle: 'Send a zap', zapFinding: 'Finding the best way to pay them…', arkZapLookup: 'Looking them up…', -- cgit v1.2.3