aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ark/lightning.js1
-rw-r--r--src/ark/manager.js44
-rw-r--r--src/features/ark.js13
-rw-r--r--src/features/nwc.js13
-rw-r--r--src/nwc-respond.js73
5 files changed, 132 insertions, 12 deletions
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;
}