aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/withdraw-links.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/withdraw-links.js')
-rw-r--r--src/features/withdraw-links.js195
1 files changed, 195 insertions, 0 deletions
diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js
new file mode 100644
index 0000000..a63d180
--- /dev/null
+++ b/src/features/withdraw-links.js
@@ -0,0 +1,195 @@
+// Prepaid Lightning withdraw links (LNURL-withdraw): make a link or QR that
+// whoever holds it can pull sats from — a voucher, a faucet, a printed card —
+// even while this wallet is asleep. "Do we have lnurl withdraw links
+// somewhere? can we add them?" → "Prepaid" (Adam, 2026-11-07).
+//
+// The money is paid up front, from Spending over Lightning, to the coinos
+// registrar (coinosv3-server names/withdraw.js), which pays each claim out of
+// it; until claimed it sits there, and the creator can take back whatever is
+// left at any time ("Take back"), which closes the link. Links belong to the
+// wallet's own nostr key, so every device with the seed sees them.
+//
+// It lives on the Send page as a sibling of the gift link: ui.giftMode ===
+// 'lnurl' (giftMode is already a navigation field with its resets).
+import { nip98Header } from '../nip98.js';
+import { encodeLnurl } from '../lnurl.js';
+import { qrSvg } from '../qr.js';
+import { getNetwork } from '../api.js';
+import { t } from '../i18n.js';
+
+const REGISTRAR = 'https://names.coinos.io';
+const NOTE_MAX = 120;
+const net = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet');
+const available = () => ['mainnet', 'mutinynet'].includes(getNetwork());
+
+export function withdrawLinks(ctx) {
+ const { h, ui, render, wallet, toast, copyBtn, goBack, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx;
+ const hook = ctx.hook || (() => null);
+ const base = () => ctx.withdrawRegistrar || REGISTRAR;
+ // ui.wl: { list: [...]|null, listError, amount, uses, note, busy, error, open: id|null, made: id|null, taking: id|null }
+ const st = () => (ui.wl ||= { list: null, amount: '', uses: '1', note: '' });
+
+ const signer = { signEvent: (e) => wallet.nostrSign(e) };
+ async function call(method, path, payload) {
+ const url = base() + path;
+ const body = payload ? JSON.stringify(payload) : '';
+ const auth = await nip98Header(signer, url, method, body);
+ const r = await fetch(url, { method, headers: { authorization: auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body } : {}) });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ return j;
+ }
+
+ async function load() {
+ const s = st();
+ try {
+ const j = await call('GET', '/withdraw/mine');
+ s.list = (j.links || []).filter((l) => l.net === net());
+ s.listError = '';
+ } catch (e) { s.listError = e.message; s.list ||= []; }
+ render();
+ }
+
+ function open() {
+ ui.wl = null;
+ ui.giftMode = 'lnurl';
+ ui.sendError = '';
+ st();
+ render();
+ load();
+ }
+
+ const lnurlOf = (l) => encodeLnurl(l.url).toUpperCase();
+ const amt = (sat) => fmtAmount(sat) + ' ' + unitLabel();
+
+ async function create() {
+ const s = st();
+ const sat = parseAmount(s.amount, getUnit()) || 0;
+ const uses = Math.floor(Number(s.uses) || 1);
+ s.error = '';
+ if (!(sat >= 1)) { s.error = t('wlAmountInvalid'); render(); return; }
+ if (!(uses >= 1 && uses <= 1000)) { s.error = t('wlUsesInvalid'); render(); return; }
+ if (wallet.watchOnly) { s.error = t('lnWithdrawWatchOnly'); render(); return; }
+ if (!hook('arkReady')) { s.error = t('hatNeedSpending'); render(); return; }
+ const total = sat * uses;
+ if ((hook('arkSpendableSat') || 0) < total + Math.max(10, Math.ceil(total * 0.002))) { s.error = t('wlNoFunds', { amount: amt(total) }); render(); return; }
+ s.busy = true; render();
+ try {
+ const inv = await call('POST', '/withdraw/new', { sat, uses, note: String(s.note || '').trim().slice(0, NOTE_MAX), net: net() });
+ await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.totalSat });
+ let link = null;
+ for (let i = 0; i < 10 && !link; i++) {
+ try { link = await call('POST', '/withdraw/funded', { id: inv.id }); }
+ catch (e) { if (!/not paid/i.test(e.message)) throw e; await new Promise((r) => setTimeout(r, 1500)); }
+ }
+ if (!link) throw new Error(t('wlNotConfirmed'));
+ s.list = [link, ...(s.list || []).filter((l) => l.id !== link.id)];
+ s.made = link.id; s.open = link.id;
+ s.amount = ''; s.uses = '1'; s.note = '';
+ } catch (e) {
+ s.error = e.message || String(e);
+ } finally { s.busy = false; render(); }
+ }
+
+ async function takeBack(l) {
+ const s = st();
+ s.taking = l.id; s.error = ''; render();
+ let lnId = null;
+ try {
+ if (l.remainingSat > 0) {
+ const inv = await hook('arkLnInvoice', l.remainingSat, t('wlTakeBackMemo'));
+ if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice'));
+ lnId = inv.id;
+ // the wallet has to claim the held payment while the registrar pays it
+ hook('arkLnWatch', inv.id, () => {});
+ const r = await call('POST', '/withdraw/reclaim', { id: l.id, invoice: inv.invoice });
+ Object.assign(l, r);
+ toast(r.paid === false ? t('wlTakeBackFailed') : t('wlTakenBack', { amount: amt(inv.amountSat) }));
+ } else {
+ Object.assign(l, await call('POST', '/withdraw/reclaim', { id: l.id }));
+ }
+ } catch (e) {
+ if (lnId) hook('arkLnCancel', lnId);
+ s.error = e.message || String(e);
+ } finally { s.taking = null; render(); load(); }
+ }
+
+ function linkCard(l) {
+ const s = st();
+ const lnurl = lnurlOf(l);
+ let svg = null;
+ try { svg = qrSvg(lnurl, { ec: 'M', mode: 'Alphanumeric' }); } catch {}
+ const live = l.status === 'open' && l.remainingSat > 0 && l.used < l.uses;
+ return h('div', { class: 'card col', style: 'align-items:center;gap:10px' },
+ h('h3', { style: 'align-self:stretch' }, s.made === l.id ? t('wlReady') : t('wlTitle')),
+ live && svg ? h('div', { html: svg }) : null,
+ l.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, l.note) : null,
+ h('div', { class: 'small muted', style: 'text-align:center' },
+ l.uses > 1 ? t('wlTermsMany', { amount: amt(l.sat), n: l.uses - l.used, total: l.uses }) : t('wlTermsOne', { amount: amt(l.sat) }),
+ ' · ', t('wlLeft', { amount: amt(l.remainingSat) })),
+ live ? h('div', { class: 'addr-box break mono', style: 'width:100%;font-size:11px' }, lnurl) : h('div', { class: 'notice small' }, l.status === 'closed' ? t('wlClosed') : t('wlUsedUp')),
+ live ? h('div', { class: 'row gap6 wrap', style: 'width:100%' }, copyBtn(lnurl, t('wlCopy'))) : null,
+ live ? h('div', { class: 'small faint', style: 'text-align:center' }, t('wlBearer')) : null,
+ s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error),
+ live || (l.status === 'open' && l.remainingSat > 0)
+ ? (s.taking === l.id
+ ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
+ : h('button', { class: 'btn-ghost btn-block', onClick: () => takeBack(l) }, t('wlTakeBack', { amount: amt(l.remainingSat) })))
+ : null,
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { s.open = null; s.made = null; s.error = ''; render(); } }, t('back')));
+ }
+
+ function formCard() {
+ const s = st();
+ const sat = parseAmount(s.amount, getUnit()) || 0;
+ const uses = Math.max(1, Math.floor(Number(s.uses) || 1));
+ return h('div', { class: 'card col gap6' },
+ h('h3', {}, t('wlTitle')),
+ h('div', { class: 'small muted' }, t('wlDesc')),
+ h('div', { class: 'input-group' },
+ h('input', { id: 'wl-amount', type: 'number', min: '0', inputmode: 'decimal', step: getUnit() === 'sats' ? '1' : '0.00000001',
+ placeholder: t('wlAmount'), value: s.amount, onInput: (e) => { s.amount = e.target.value; render(); } }),
+ h('div', { style: 'display:flex;align-items:center' }, unitTag())),
+ h('label', { class: 'row gap6', style: 'align-items:center' },
+ h('span', { class: 'small grow' }, t('wlUses')),
+ h('input', { id: 'wl-uses', type: 'number', min: '1', max: '1000', step: '1', inputmode: 'numeric', style: 'width:90px',
+ value: s.uses, onInput: (e) => { s.uses = e.target.value; render(); } })),
+ h('textarea', { class: 'gift-note-input', rows: 2, maxlength: NOTE_MAX, placeholder: t('wlNote'), value: s.note, onInput: (e) => { s.note = e.target.value; } }),
+ h('div', { class: 'small faint' }, sat >= 1 && uses > 1 ? t('wlPayNowMany', { total: amt(sat * uses), n: uses, amount: amt(sat) }) : t('wlPayNow')),
+ s.error && !s.open ? h('div', { class: 'notice err' }, s.error) : null,
+ s.busy
+ ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
+ : h('button', { id: 'wl-create', class: 'btn-primary btn-block', onClick: create }, sat >= 1 ? t('wlCreateFor', { amount: amt(sat * uses) }) : t('wlCreate')));
+ }
+
+ function listCard() {
+ const s = st();
+ if (s.list == null) return h('div', { class: 'card', style: 'text-align:center' }, h('span', { class: 'spinner sm' }));
+ if (!s.list.length) return s.listError ? h('div', { class: 'notice err small' }, s.listError) : null;
+ return h('div', { class: 'card col gap6' },
+ h('span', { class: 'small muted' }, t('wlYours')),
+ ...s.list.map((l) => h('button', { class: 'row between wl-row', style: 'text-align:left', onClick: () => { s.open = l.id; s.made = null; s.error = ''; render(); } },
+ h('span', { class: 'small' }, amt(l.sat), l.uses > 1 ? ` × ${l.uses}` : '', l.note ? h('span', { class: 'faint' }, ' · ' + l.note) : null),
+ h('span', { class: 'small faint' },
+ l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') : t('wlLeft', { amount: amt(l.remainingSat) })))));
+ }
+
+ function view() {
+ const s = st();
+ if (!available()) {
+ return h('div', { class: 'col', style: 'gap:12px' },
+ h('div', { class: 'card col' }, h('h3', {}, t('wlTitle')), h('div', { class: 'small muted' }, t('wlMainnetOnly'))),
+ h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.giftMode = false; }) }, t('back')));
+ }
+ const l = s.open && (s.list || []).find((x) => x.id === s.open);
+ if (l) return h('div', { class: 'col', style: 'gap:12px' }, linkCard(l));
+ return h('div', { class: 'col', style: 'gap:12px' },
+ formCard(),
+ listCard(),
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.wl = null; goBack(() => { ui.giftMode = false; }); } }, t('back')));
+ }
+
+ const entry = () => h('button', { id: 'wl-open', class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: open }, '⚡ ' + t('wlTitle'));
+
+ return { view, entry, open };
+}