// 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 }; }