From e8d82b8b23c271aa57134d762e14fd6dcbed9c7a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 2 Oct 2026 07:19:29 -0700 Subject: NWC: lookup_invoice, so BTCPay Server can connect BTCPay's NWC connector requires get_info, make_invoice, lookup_invoice and list_transactions; we never offered lookup_invoice, so it refused the connection string. - ArkManager.lnLookup answers lookup_invoice from wallet state (by hash or bolt11, either direction; pending/settled/expired/failed, preimage once claimed). A retry's several actions on one hash defer to the furthest. - The open app pushes a pending receive along before answering, so a merchant polling sees it settle. - The service worker now answers make_invoice and lookup_invoice too, and claims a paid invoice's HTLCs during the lookup: a BTCPay sale settles with the wallet closed. Its list_transactions reports receives as well. - get_info and the 13194 info event list the new method. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dmcrt9KyNDKVWFKnb8BREo --- src/ark/lightning.js | 1 + src/ark/manager.js | 44 +++++++++++++++++++++++++++++ src/features/ark.js | 13 ++++++++- src/features/nwc.js | 13 +++++++-- src/nwc-respond.js | 73 +++++++++++++++++++++++++++++++++++++++++++------ tools/ln-lookup-test.js | 38 +++++++++++++++++++++++++ tools/nwc-sw-test.js | 30 ++++++++++++++++++++ tools/nwc-test.js | 11 ++++++++ 8 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 tools/ln-lookup-test.js diff --git a/src/ark/lightning.js b/src/ark/lightning.js index 0ac80a5..568d0ab 100644 --- a/src/ark/lightning.js +++ b/src/ark/lightning.js @@ -61,6 +61,7 @@ export function decodeBolt11(invoice) { amountMsat, amountSat: amountMsat != null ? Number(amountMsat / 1000n) : null, expiresAt: (ts + expiry) * 1000, + createdAt: ts * 1000, }; } diff --git a/src/ark/manager.js b/src/ark/manager.js index 4b65b27..dc80ca3 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -1068,6 +1068,50 @@ export class ArkManager { return this.driveLn(id); } + // A Lightning payment of this wallet's, either way, as NIP-47 + // lookup_invoice reports it — found by payment hash or bolt11. null when + // the wallet never made or paid that invoice. + lnLookup({ paymentHash, invoice } = {}) { + const hashOf = (inv) => { try { return decodeBolt11(inv).paymentHash; } catch { return null; } }; + const hash = String(paymentHash || (invoice && hashOf(invoice)) || '').toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(hash)) return null; + // a retry leaves several actions on one hash: the furthest along speaks + const rank = (x) => (x.step === 'done' ? 0 : x.step === 'failed' ? 2 : 1); + const a = (this.state.actions || []).filter((x) => (x.type === 'ln-recv' || x.type === 'ln-pay') + && (x.paymentHash === hash || (x.invoice && hashOf(x.invoice) === hash))) + .sort((x, y) => rank(x) - rank(y))[0]; + const incoming = a ? a.type === 'ln-recv' : null; + const mv = (this.state.movements || []).filter((m) => (incoming == null + ? (m.type === 'ln-receive' || m.type === 'ln-send') + : m.type === (incoming ? 'ln-receive' : 'ln-send')) && (m.paymentHash === hash || (m.invoice && hashOf(m.invoice) === hash))); + const done = mv.find((m) => m.status === 'complete'); + if (!a && !mv.length) return null; + const type = (a ? incoming : mv[0].type === 'ln-receive') ? 'incoming' : 'outgoing'; + const inv = (a && a.invoice) || mv[0]?.invoice || ''; + let dec = null; try { dec = inv ? decodeBolt11(inv) : null; } catch {} + const settled = (a && a.step === 'done') || !!done; + const sec = (ms) => (ms ? Math.floor(ms / 1000) : undefined); + let state = 'pending'; + if (settled) state = 'settled'; + else if ((a && a.step === 'failed') || (!a && mv.some((m) => m.status === 'failed'))) { + state = type === 'incoming' && dec?.expiresAt && Date.now() > dec.expiresAt ? 'expired' : 'failed'; + } else if (type === 'incoming' && a && a.step === 'awaiting' && dec?.expiresAt && Date.now() > dec.expiresAt) state = 'expired'; + let preimage = ''; + if (settled) { + if (type === 'outgoing') preimage = (a && a.preimage) || done?.preimage || ''; + else if (a && Number.isInteger(a.preimageIndex)) { try { preimage = hex.encode(this._lnPreimage(a.preimageIndex)); } catch {} } + } + const amountSat = (a && a.amountSat) || dec?.amountSat || done?.amountSat || 0; + return { + type, state, invoice: inv, payment_hash: hash, preimage, + description: '', amount: amountSat * 1000, + fees_paid: type === 'outgoing' ? ((a && a.feeSat) || 0) * 1000 : 0, + created_at: sec(dec?.createdAt || actionTs(a || {}) || mv[0]?.ts) || Math.floor(Date.now() / 1000), + expires_at: sec(dec?.expiresAt), + settled_at: settled ? sec(done?.ts || actionTs(a || {})) : undefined, + }; + } + lnAction(id) { return this.state.actions.find((a) => a.id === id && a.type.startsWith('ln-')); } diff --git a/src/features/ark.js b/src/features/ark.js index 67759c9..87bb5c9 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -3629,7 +3629,18 @@ export function arkFeature(ctx) { async arkMakeInvoice(amountSat, description) { const mgr = await connectArk(); const a = await mgr.createLnInvoice(amountSat, description); - return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat }; + return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat, expiresAt: a.expiresAt }; + }, + // NIP-47 lookup_invoice: a payment of ours by hash or bolt11. One that + // is still pending gets a push first (a receive whose HTLCs have + // arrived is claimed right here), so a merchant polling sees it settle. + async arkLnLookup(q) { + const mgr = await connectArk(); + const found = mgr.lnLookup(q); + if (!found || found.state !== 'pending') return found; + const a = (mgr.state.actions || []).find((x) => x.paymentHash === found.payment_hash && x.type.startsWith('ln-')); + if (a) await Promise.race([mgr.driveLn(a.id).catch(() => {}), new Promise((r) => setTimeout(r, 8000))]); + return mgr.lnLookup(q); }, arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; }, // The wallet's reusable ark receive address (BIP-353 names publish it). diff --git a/src/features/nwc.js b/src/features/nwc.js index 4259207..9cf8d0b 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -40,7 +40,7 @@ const RES_KIND = 23195; const INFO_KIND = 13194; // What we can actually honour. No pay_keysend (Ark can't), no multi_pay. -const METHODS = ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'list_transactions']; +const METHODS = ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions']; // Requests older than this are ignored — a relay replaying history must not // re-trigger payments. @@ -461,12 +461,19 @@ export function nwcFeature(ctx) { const inv = await hook('arkMakeInvoice', sat, params.description || ''); return { result: { - type: 'incoming', invoice: inv.invoice, payment_hash: inv.paymentHash, + type: 'incoming', state: 'pending', invoice: inv.invoice, payment_hash: inv.paymentHash, amount: sat * 1000, created_at: nowSec(), description: params.description || '', + expires_at: inv.expiresAt ? Math.floor(inv.expiresAt / 1000) : undefined, }, }; } + if (method === 'lookup_invoice') { + if (!params.payment_hash && !params.invoice) return errRes('OTHER', 'payment_hash or invoice required'); + const tx = await hook('arkLnLookup', { paymentHash: params.payment_hash, invoice: params.invoice }); + return tx ? { result: tx } : errRes('NOT_FOUND', 'invoice not found'); + } + if (method === 'list_transactions') { const movements = hook('arkMovements') || []; const txs = movements @@ -474,7 +481,7 @@ export function nwcFeature(ctx) { .slice(-(params.limit || 20)) .map((m) => ({ type: m.type === 'ln-receive' ? 'incoming' : 'outgoing', - invoice: m.invoice || '', preimage: m.preimage || '', + state: 'settled', invoice: m.invoice || '', preimage: m.preimage || '', amount: (m.amountSat || 0) * 1000, fees_paid: 0, created_at: Math.floor((m.ts || Date.now()) / 1000), settled_at: Math.floor((m.ts || Date.now()) / 1000), diff --git a/src/nwc-respond.js b/src/nwc-respond.js index 7bf983b..27699e7 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -204,23 +204,80 @@ export async function respondFromBg(data, { alias: 'Coinos', color: '#15171a', network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network, block_height: 0, block_hash: '', - methods: ['get_info', 'get_balance', 'pay_invoice', 'list_transactions'], + methods: ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'lookup_invoice', 'list_transactions'], }, }); return true; } + // A merchant (BTCPay) mints an invoice per checkout and polls it until + // paid. Minting works like a CLINK offer; a lookup that finds the payer's + // HTLCs waiting claims them here, so the sale settles with the app closed. + if (method === 'make_invoice') { + const sat = Math.floor((params.amount || 0) / 1000); + if (!sat) { await publish(errRes('make_invoice', 'OTHER', 'amount required (msat)')); return true; } + if (!rec.keys5 || !rec.key4 || !rec.mgr) return false; // old mirror: wake the user + if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted + if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) return false; // claim key window exhausted + let a; + try { + const mgr = await bgManager(rec, walletKey, saveFn); + a = await mgr.createLnInvoice(sat, String(params.description || '').slice(0, 100)); + await saveFn(walletKey, rec); + } catch (e) { + await publish(errRes('make_invoice', 'INTERNAL', e.message || 'failed')).catch(() => {}); + return true; + } + await publish({ + result_type: 'make_invoice', + result: { + type: 'incoming', state: 'pending', invoice: a.invoice, payment_hash: a.paymentHash, + amount: sat * 1000, created_at: nowSec(), description: params.description || '', + expires_at: a.expiresAt ? Math.floor(a.expiresAt / 1000) : undefined, + }, + }); + log(`minted ${sat} sat invoice while closed`); + return true; + } + if (method === 'lookup_invoice') { + if (!params.payment_hash && !params.invoice) { + await publish(errRes('lookup_invoice', 'OTHER', 'payment_hash or invoice required')); + return true; + } + if (!rec.mgr) return false; + const q = { paymentHash: params.payment_hash, invoice: params.invoice }; + // read straight off the mirror; the ASP is only needed to claim + const view = Object.assign(Object.create(ArkManager.prototype), { state: rec.mgr, account: shimAccount(rec) }); + let tx = view.lnLookup(q); + const a = tx && tx.state === 'pending' && tx.type === 'incoming' + && (rec.mgr.actions || []).find((x) => x.type === 'ln-recv' && x.paymentHash === tx.payment_hash); + if (a) { + try { + const mgr = await bgManager(rec, walletKey, saveFn); + await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, PAY_WAIT_MS))]); + tx = mgr.lnLookup(q); + if (tx.state === 'settled') log(`received ${tx.amount / 1000} sat while closed`); + } catch (e) { log('ark init failed: ' + e.message); } + } + await publish(tx ? { result_type: 'lookup_invoice', result: tx } : errRes('lookup_invoice', 'NOT_FOUND', 'invoice not found')); + return true; + } if (method === 'get_balance') { await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } }); return true; } if (method === 'list_transactions') { - const txs = (rec.spends || []).slice(-(params.limit || 20)).map((s) => ({ - type: 'outgoing', invoice: s.invoice || '', preimage: s.preimage || '', - amount: (s.amountSat || 0) * 1000, fees_paid: (s.feeSat || 0) * 1000, - created_at: Math.floor((s.ts || Date.now()) / 1000), - settled_at: Math.floor((s.ts || Date.now()) / 1000), - description: '', - })); + // the mirrored wallet's Lightning history, both ways, as the open app reports it + const txs = ((rec.mgr && rec.mgr.movements) || []) + .filter((m) => (m.type === 'ln-send' || m.type === 'ln-receive') && m.status === 'complete') + .slice(-(params.limit || 20)) + .map((m) => ({ + type: m.type === 'ln-receive' ? 'incoming' : 'outgoing', + state: 'settled', invoice: m.invoice || '', preimage: m.preimage || '', + amount: (m.amountSat || 0) * 1000, fees_paid: 0, + created_at: Math.floor((m.ts || Date.now()) / 1000), + settled_at: Math.floor((m.ts || Date.now()) / 1000), + description: '', + })); await publish({ result_type: 'list_transactions', result: { transactions: txs } }); return true; } diff --git a/tools/ln-lookup-test.js b/tools/ln-lookup-test.js new file mode 100644 index 0000000..d825d4b --- /dev/null +++ b/tools/ln-lookup-test.js @@ -0,0 +1,38 @@ +// ArkManager.lnLookup — the NIP-47 lookup_invoice answer, from wallet state. +// bun tools/ln-lookup-test.js +import { hex } from '@scure/base'; +import { ArkManager } from '../src/ark/manager.js'; +import { decodeBolt11 } from '../src/ark/lightning.js'; + +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; +const dec = decodeBolt11(INV); +const H = dec.paymentHash; +const pre = new Uint8Array(32).fill(7); +const mgr = (state) => { + const m = Object.create(ArkManager.prototype); + m.state = { actions: [], movements: [], ...state }; + m._lnPreimage = () => pre; + return m; +}; +const recv = (step) => ({ id: `lnrecv-${Date.now()}`, type: 'ln-recv', step, paymentHash: H, preimageIndex: 3, amountSat: 21, invoice: INV, expiresAt: dec.expiresAt }); + +check('unknown hash → null', mgr({}).lnLookup({ paymentHash: 'ab'.repeat(32) }) === null); +check('no hash or invoice → null', mgr({}).lnLookup({}) === null); +let t = mgr({ actions: [recv('awaiting')] }).lnLookup({ paymentHash: H }); +check('an awaiting receive past its expiry reads expired', t.type === 'incoming' && t.state === 'expired' && !t.preimage, JSON.stringify(t)); +t = mgr({ actions: [{ ...recv('awaiting'), invoice: INV }] }).lnLookup({ invoice: INV }); +check('found by bolt11 as well as hash', t && t.payment_hash === H); +t = mgr({ actions: [recv('htlcsReady')] }).lnLookup({ paymentHash: H }); +check('HTLCs in, not yet claimed → pending, no preimage', t.state === 'pending' && !t.preimage, t.state); +t = mgr({ actions: [recv('done')], movements: [{ type: 'ln-receive', status: 'complete', invoice: INV, amountSat: 21, ts: 1700000000000 }] }).lnLookup({ paymentHash: H.toUpperCase() }); +check('claimed → settled with the preimage', t.state === 'settled' && t.preimage === hex.encode(pre) && t.amount === 21000 && t.settled_at === 1700000000, JSON.stringify(t)); +t = mgr({ actions: [{ id: 'lnpay-1700000000000', type: 'ln-pay', step: 'done', paymentHash: H, invoice: INV, amountSat: 21, feeSat: 1, preimage: 'ab'.repeat(32) }] }).lnLookup({ paymentHash: H }); +check('a paid invoice reads outgoing, settled, with fees', t.type === 'outgoing' && t.state === 'settled' && t.preimage === 'ab'.repeat(32) && t.fees_paid === 1000, JSON.stringify(t)); +t = mgr({ actions: [{ id: 'lnpay-1', type: 'ln-pay', step: 'failed', paymentHash: H, invoice: INV, amountSat: 21 }] }).lnLookup({ paymentHash: H }); +check('a failed send reads failed', t.state === 'failed'); +t = mgr({ movements: [{ type: 'ln-send', status: 'complete', invoice: INV, preimage: 'cd'.repeat(32), paymentHash: H, amountSat: 21, ts: 1 }] }).lnLookup({ paymentHash: H }); +check('history alone (action pruned) still answers', t && t.type === 'outgoing' && t.state === 'settled' && t.preimage === 'cd'.repeat(32)); +console.log(ok ? '\n✅ lnLookup answers lookup_invoice' : '\n❌ failures'); +process.exit(ok ? 0 : 1); diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index ae79e18..62248a4 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -92,6 +92,36 @@ console.log('[balance + info]'); check('nip04 get_info answered in nip04', r.result?.alias === 'Coinos' && published[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04'); } +console.log('\n[merchant methods (BTCPay)]'); +{ + const { deps, published, decryptReply } = harness(); + await respondFromBg({ type: 'nwc', event: ev(makeReq('get_info')) }, deps); + const m = decryptReply(published[0]).result?.methods || []; + check('get_info has every method BTCPay requires', ['get_info', 'make_invoice', 'lookup_invoice', 'list_transactions'].every((x) => m.includes(x)), JSON.stringify(m)); +} +{ + const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; + const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e'; + const rec = baseRec(); + const k5 = generateSecretKey(); + rec.keys5 = { 0: hex.encode(k5) }; + rec.mgr.actions.push({ id: 'lnrecv-1700000000000', type: 'ln-recv', step: 'done', paymentHash: H, preimageIndex: 0, amountSat: 21, invoice: INV }); + rec.mgr.movements.push({ type: 'ln-receive', status: 'complete', amountSat: 21, invoice: INV, ts: 1700000000000 }); + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps); + const r = decryptReply(published[0]); + check('lookup_invoice of a claimed receive → settled, with the preimage, no ASP needed', handled === true && r.result?.state === 'settled' && r.result?.preimage?.length === 64, JSON.stringify(r.result || r.error)); + await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: 'ab'.repeat(32) })) }, deps); + check('lookup_invoice of an unknown hash → NOT_FOUND', decryptReply(published[1]).error?.code === 'NOT_FOUND'); + await respondFromBg({ type: 'nwc', event: ev(makeReq('list_transactions')) }, deps); + const txs = decryptReply(published[2]).result?.transactions || []; + check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs)); +} +{ + const { deps } = harness(); // a mirror without receive keys + check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false); +} + console.log('\n[guards]'); { const { deps } = harness(); diff --git a/tools/nwc-test.js b/tools/nwc-test.js index 9189d52..848b2c6 100644 --- a/tools/nwc-test.js +++ b/tools/nwc-test.js @@ -40,6 +40,7 @@ const hooks = { arkSpendableSat: () => 42000, arkPayInvoice: async (inv, opts) => { paid.push({ inv, opts }); return { preimage: 'ab'.repeat(32), feeSat: 1, amountSat: 25 }; }, arkMakeInvoice: async (sat) => ({ invoice: 'lnbc'+sat, paymentHash: 'cd'.repeat(32), amountSat: sat }), + arkLnLookup: async (q) => (q.paymentHash === 'cd'.repeat(32) ? { type: 'incoming', state: 'settled', payment_hash: q.paymentHash, preimage: '12'.repeat(32), amount: 21000 } : null), arkMovements: () => ([{ type:'ln-send', status:'complete', amountSat: 25, ts: Date.now(), invoice:'lnbc250n1', preimage:'ef'.repeat(32) }]), }; const ctx = { h:()=>null, ui:{}, render:()=>{}, wallet, hook:(n,...a)=>hooks[n]?hooks[n](...a):null, @@ -162,6 +163,16 @@ check('nip04 request answered', r?.result?.balance === 42000*1000); console.log('\n[other methods]'); r = await request('make_invoice', { amount: 21000 }); check('make_invoice returns a bolt11', !!r?.result?.invoice, r?.result?.invoice); +r = await request('lookup_invoice', { payment_hash: 'cd'.repeat(32) }); +check('lookup_invoice reports a paid invoice settled', r?.result?.state === 'settled' && r?.result?.preimage === '12'.repeat(32), JSON.stringify(r?.result)); +r = await request('lookup_invoice', { payment_hash: 'ee'.repeat(32) }); +check('lookup_invoice of an unknown invoice is NOT_FOUND', r?.error?.code === 'NOT_FOUND'); +r = await request('get_info', {}); +// BTCPay Server's NWC connector refuses a wallet missing any of these +const btcpay = ['get_info', 'make_invoice', 'lookup_invoice', 'list_transactions']; +check('get_info has every method BTCPay requires', btcpay.every((m) => r?.result?.methods?.includes(m)), JSON.stringify(r?.result?.methods)); +const info = published.find((e) => e.kind === 13194); +check('the 13194 info event lists them too', info && btcpay.every((m) => info.content.split(' ').includes(m)), info?.content); r = await request('list_transactions', {}); check('list_transactions returns history', Array.isArray(r?.result?.transactions) && r.result.transactions.length === 1); r = await request('pay_keysend', {}); -- cgit v1.2.3