diff options
| author | Adam Soltys <[email protected]> | 2026-08-03 13:56:27 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-03 13:56:27 -0700 |
| commit | 54f5e7e1410f74d08df69dcade7cf131497ad1bf (patch) | |
| tree | 3ecec66228f50faa537417b938a5dc89955a74cb /src/features/ark.js | |
| parent | 921ad3fadbe68049c161b2124657e13352e22afa (diff) | |
| download | coinosv3-54f5e7e1410f74d08df69dcade7cf131497ad1bf.tar.gz coinosv3-54f5e7e1410f74d08df69dcade7cf131497ad1bf.zip | |
Auto-withdraw: forward Spending onward past a threshold
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
Diffstat (limited to 'src/features/ark.js')
| -rw-r--r-- | src/features/ark.js | 183 |
1 files changed, 182 insertions, 1 deletions
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); }, |
