aboutsummaryrefslogtreecommitdiffstats
path: root/src/lnurl.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-15 15:24:25 -0700
committerAdam Soltys <[email protected]>2026-10-15 15:24:25 -0700
commit38ce0b441505d0bdd048d38c82ac95afe4d3b407 (patch)
tree111711030a02cd8eec42c8e75c5de8c8ed1bacc5 /src/lnurl.js
parent46829a7a54bf21138d8bb068966f7f083ac8df4c (diff)
downloadcoinosv3-38ce0b441505d0bdd048d38c82ac95afe4d3b407.tar.gz
coinosv3-38ce0b441505d0bdd048d38c82ac95afe4d3b407.zip
Lightning withdraw (LNURL-withdraw): a site's withdraw lnurl in Send gets paid to you; the media pager says when a picture can't be loaded. (1) LUD-03 (Adam, 2026-10-16: "add lnurlw support"): an lnurl whose terms are a withdrawRequest — found when the pay fetch meets one (fetchPayParams throws with the terms attached, resolve() takes them up) — shows the site, their range (or the fixed amount) and description, the amount field at their max; Withdraw makes an invoice over Ark for the amount (arkLnInvoice), hands it to their callback with k1 (submitWithdraw), and watches it (arkLnWatch): "has your invoice for…", then "Received …" on settlement; their refusal in their words, with Try again; Back before it is paid stops watching. An amount outside their range is told the range, nothing sent; a watch-only device says it can't receive. LUD-17 schemes lnurlw:// and lnurlp:// (and lightning: before them) are the URL over https, http on an onion (parseZapTarget). (2) A pager slide whose picture can't load — the server copy and then the original both failing (isolabellart's relay answering 502 that afternoon: the broken-picture glyph sat in the corner of a black slide) — hides the <img> and says "This picture couldn't be loaded" with the host, caption and controls kept (mp-gone / .mp-missing). tools/lnurl-auth-unit-test.js (lnurlw:// and lnurlp://, an onion, the terms, a pay fetch that finds a withdraw, the callback's k1 and pr, a refusal); tools/lnurl-withdraw-test.js (the zaps feature against a faked Ark and a stand-in site: the terms, max prefilled, out of range refused unsent, the invoice at the callback and watched, Received on settlement, a fixed amount with no field, Back unwatches, a refusal's reason); tools/pager-gone-picture-test.js; lnurl-auth and feed-picture-pager tests pass.
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'src/lnurl.js')
-rw-r--r--src/lnurl.js34
1 files changed, 34 insertions, 0 deletions
diff --git a/src/lnurl.js b/src/lnurl.js
index cf851b5..bfebf0d 100644
--- a/src/lnurl.js
+++ b/src/lnurl.js
@@ -47,6 +47,9 @@ export function lnAddressToUrl(addr) {
export function parseZapTarget(input, { pk = null } = {}) {
const s = (input || '').trim().replace(/^lightning:/i, '');
if (!s) return null;
+ // LUD-17: lnurlp:// (pay) and lnurlw:// (withdraw) are the URL itself over https (http on an onion)
+ const scheme = /^(lnurlp|lnurlw):\/\/(.+)$/i.exec(s);
+ if (scheme) { const url = (/^[^/]+\.onion(\/|$)/i.test(scheme[2]) ? 'http://' : 'https://') + scheme[2]; return { kind: 'lnurl', url, lnurlBech32: safeEncode(url), pk }; }
// An npub/hex pubkey — the caller resolves lud16/lud06 from the profile and
// calls fromProfile() below; here we just report the shape.
const npk = parseNostrPubkey(s);
@@ -96,10 +99,14 @@ async function fetchJson(url, ms = 8000) {
}
// GET the LNURL-pay params. Normalizes the bits we use; keeps `callback`.
+// An lnurl that turns out to be a withdraw (LUD-03) is not an error here:
+// the thrown error carries its terms (`withdraw`) for the caller to take up.
export async function fetchPayParams(url) {
const d = await fetchJson(url);
if (!d || d.tag !== 'payRequest' || !d.callback) {
if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error');
+ const w = withdrawParamsOf(d);
+ if (w) { const e = new Error('a Lightning withdraw, not a payment'); e.withdraw = w; throw e; }
throw new Error('not a Lightning pay endpoint');
}
return {
@@ -179,3 +186,30 @@ export async function lnurlAuthLogin(auth, key) {
return { key: bytesToHex(key.publicKey) };
}
+// ---- LNURL-withdraw (LUD-03): the other side asks to PAY us ----
+// Their terms: an amount range (msat), a callback and k1. We make an invoice
+// for the amount chosen and hand it to the callback; they pay it.
+export function withdrawParamsOf(d) {
+ if (!d || d.tag !== 'withdrawRequest' || !d.callback || !d.k1) return null;
+ const min = Math.max(0, Number(d.minWithdrawable) || 0), max = Number(d.maxWithdrawable) || 0;
+ if (!(max > 0)) return null;
+ let domain = '';
+ try { domain = new URL(d.callback).hostname; } catch {}
+ return { callback: d.callback, k1: String(d.k1), minMsat: min, maxMsat: Math.max(min, max), description: String(d.defaultDescription || '').slice(0, 200), domain };
+}
+export async function fetchWithdrawParams(url) {
+ const d = await fetchJson(url);
+ const w = withdrawParamsOf(d);
+ if (!w) { if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error'); throw new Error('not a Lightning withdraw'); }
+ return w;
+}
+// Hand them the invoice to pay. Their OK means they have it, not that it is paid.
+export async function submitWithdraw(params, invoice) {
+ const url = new URL(params.callback);
+ url.searchParams.set('k1', params.k1);
+ url.searchParams.set('pr', invoice);
+ const d = await fetchJson(url.href);
+ if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'withdraw refused');
+ return true;
+}
+