diff options
| author | Adam Soltys <[email protected]> | 2026-08-14 00:02:06 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-14 00:02:06 -0700 |
| commit | 5abf76764c445b742a50080ebb97c313531d5962 (patch) | |
| tree | 7a9f531971e93102dfd2c4d49ecf872299e893a0 /src | |
| parent | b7a8fc0254d6e08013e3672b8da09b508057ec93 (diff) | |
| download | coinosv3-5abf76764c445b742a50080ebb97c313531d5962.tar.gz coinosv3-5abf76764c445b742a50080ebb97c313531d5962.zip | |
Pay what the route costs: sends quote the network fee up front (zero between coinos wallets), lock exactly that, and a refunded miss retries with one more sat
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/lightning.js | 13 | ||||
| -rw-r--r-- | src/ark/manager.js | 30 | ||||
| -rw-r--r-- | src/features/ark.js | 61 |
3 files changed, 84 insertions, 20 deletions
diff --git a/src/ark/lightning.js b/src/ark/lightning.js index bbfe958..dab2d6d 100644 --- a/src/ark/lightning.js +++ b/src/ark/lightning.js @@ -92,6 +92,19 @@ export function lnSendFee(amountSat, fees, inputs, tip) { export const lnReceiveFee = (amountSat, fees) => (fees.baseFeeSat || 0) + Math.ceil((amountSat * (fees.ppm || 0)) / 1_000_000); +// Ask the quote service what the network will charge to deliver this payment +// (it runs askrene against the ASP's own CLN). The wallet locks +// amount + schedule fee + this on the HTLC and the server hands the whole +// surplus to CLN as the routing budget, so this number is the user's entire +// network cost — 0 between wallets on this ASP and to direct peers. +export async function fetchLnRouteFee(quoteUrl, invoice, amountSat) { + const r = await fetch(`${quoteUrl}?invoice=${encodeURIComponent(invoice)}&amount_msat=${amountSat * 1000}`); + if (!r.ok) throw new Error(`quote failed: ${r.status}`); + const d = await r.json(); + if (d.error) throw new Error(d.error); + return d.feeSat | 0; +} + // --------------------------------------------------------------------------- // pay (HTLC-out) // --------------------------------------------------------------------------- diff --git a/src/ark/manager.js b/src/ark/manager.js index a57b280..b2c2837 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -29,7 +29,7 @@ import { genUserNonces, cosignPartBytes, combineCosign, } from './send.js'; import { - decodeBolt11, lnSendFee, lnReceiveFee, + decodeBolt11, lnSendFee, lnReceiveFee, fetchLnRouteFee, requestLightningPayHtlcCosign, initiateLightningPayment, checkLightningPayment, requestLightningPayHtlcRevocation, startLightningReceive, checkLightningReceive, prepareLightningReceiveClaim, claimLightningReceive, cancelLightningReceive, @@ -63,12 +63,15 @@ const EMPTY_STATE = () => ({ }); export class ArkManager { - constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate }) { + constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl }) { this.account = account; // HDKey node; ark keys derived beneath it this.storage = storage; // { load(): obj|null, save(obj): void } this.arkUrl = arkUrl; this.esploraUrl = esploraUrl; this.network = network; + // where to ask what a lightning payment's route will cost (names /lnquote) + this.lnQuoteUrl = lnQuoteUrl !== undefined ? lnQuoteUrl + : (network === 'mainnet' ? 'https://names.halwallet.app/lnquote' : null); this.onUpdate = onUpdate || (() => {}); this.state = null; this.info = null; @@ -630,9 +633,23 @@ export class ArkManager { return a; } + // Routing fee the user must bring for this payment. Non-zero only when the + // server prices lightning sends at zero — then whatever the HTLC locks + // beyond the amount becomes CLN's routing budget, and the quote service + // reports what the route actually costs (0 between wallets on this ASP and + // to direct peers). When the quote can't be reached we fall back to the old + // flat pricing so payments keep working, at worst overpaying a few sats. + async lnRouteFee(invoice, amountSat) { + const f = this.info.lnSendFees || {}; + const zeroPriced = !f.minFeeSat && !f.baseFeeSat && !(f.ppmExpiryTable || []).length; + if (!zeroPriced || !this.lnQuoteUrl) return 0; + try { return await fetchLnRouteFee(this.lnQuoteUrl, invoice, amountSat); } + catch { return Math.max(3, Math.ceil(amountSat / 1000)); } + } + // Pay a bolt11 invoice with ark funds. Returns the action id; drive to a // terminal step ('done' | 'failed') via driveLn()/sync. - async payLnInvoice(invoice, { amountSat: userAmountSat } = {}) { + async payLnInvoice(invoice, { amountSat: userAmountSat, routingFeeSat } = {}) { const dec = decodeBolt11(invoice); const expectNet = { bitcoin: 'mainnet', regtest: 'regtest', signet: 'signet', testnet: 'testnet' }[this.info.network]; if (expectNet && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`); @@ -643,13 +660,14 @@ export class ArkManager { throw new Error('this invoice was already paid or is being paid'); } const tip = await this.chain.tipHeight(); - // smallest single vtxo that covers amount + its expiry-dependent fee + const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat); + // smallest single vtxo that covers amount + routing + its expiry-dependent fee const candidates = this.state.vtxos .filter((v) => v.state === 'spendable') .sort((a, b) => a.amountSat - b.amountSat); let input = null, feeSat = 0; for (const v of candidates) { - const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip); + const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing; if (v.amountSat >= amountSat + fee) { input = v; feeSat = fee; break; } } if (!input) { @@ -660,7 +678,7 @@ export class ArkManager { } const action = { id: `lnpay-${Date.now()}`, type: 'ln-pay', step: 'created', - invoice, paymentHash: dec.paymentHash, amountSat, feeSat, + invoice, paymentHash: dec.paymentHash, amountSat, feeSat, routingFeeSat: routing, inputId: input.id, htlcKeyIndex: this.state.nextKeyIndex++, // HTLC lock key, reused for change (bark does the same) revKeyIndex: this.state.nextKeyIndex++, diff --git a/src/features/ark.js b/src/features/ark.js index 30ca90c..4693336 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -570,17 +570,26 @@ export function arkFeature(ctx) { // payment picks the stale coin and the ASP rejects it ("state: spent"). await mgr.reconcile().catch(() => {}); - const id = await mgr.payLnInvoice(invoice); - for (let i = 0; i < 60; i++) { + // Up to three attempts: each failure refunds in full, and the next try + // brings one extra sat of routing budget for liquidity that shifted + // between the quote and the send. + let last = null; + for (let attempt = 0; attempt < 3; attempt++) { + const routingFeeSat = attempt === 0 ? undefined : (last?.routingFeeSat ?? 0) + 1; + const id = await mgr.payLnInvoice(invoice, { routingFeeSat }); + for (let i = 0; i < 60; i++) { + const a = mgr.lnAction(id); + if (!a || ['done', 'failed'].includes(a.step)) break; + await new Promise((r) => setTimeout(r, 2000)); + await mgr.driveLn(id).catch(() => {}); + } const a = mgr.lnAction(id); - if (!a || a.step === 'done') break; - if (a.step === 'failed') throw new Error(a.error || 'payment failed'); - await new Promise((r) => setTimeout(r, 2000)); - await mgr.driveLn(id).catch(() => {}); + if (a && a.step === 'done') return { preimage: a.preimage, feeSat: a.feeSat, amountSat: a.amountSat }; + last = a; + if (a && a.step === 'failed' && !lnRetryable(a)) break; + if (!a || a.step !== 'failed') break; // still pending after 2 min — don't double-pay } - const a = mgr.lnAction(id); - if (!a || a.step !== 'done') throw new Error(a?.error || a?.lastError || 'payment did not complete'); - return { preimage: a.preimage, feeSat: a.feeSat, amountSat: a.amountSat }; + throw new Error(last?.error || last?.lastError || 'payment did not complete'); } // A bolt11 lands in Send (via the swaps feature's delegation hook, or @@ -614,11 +623,15 @@ export function arkFeature(ctx) { if (!p || p.invoice !== invoice) return; if (!p.amountSat) { p.status = 'ready'; render(); return; } const tip = await mgr.chain.tipHeight(); + // what the lightning network itself will charge (0 for coinos-to-coinos) + const routing = await mgr.lnRouteFee(invoice, p.amountSat); + if (!ui.arkLnPay || ui.arkLnPay !== p || p.invoice !== invoice) return; + p.routingFeeSat = routing; const candidates = mgr.vtxos().filter((v) => v.state === 'spendable') .sort((a, b) => a.amountSat - b.amountSat); let fee = null; for (const v of candidates) { - const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip); + const f = lnSendFee(p.amountSat, mgr.info.lnSendFees, [v], tip) + routing; if (v.amountSat >= p.amountSat + f) { fee = f; break; } } if (fee == null) { @@ -650,27 +663,47 @@ export function arkFeature(ctx) { ui.busy = true; ui.sendError = ''; render(); try { const mgr = await connectArk(); - const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats }); + // Routing conditions can shift between quote and send: a failed + // (and fully refunded) attempt re-tries with one extra sat of routing + // budget before giving up — askrene fees are exact, liquidity isn't. + let tries = 0; + const attempt = async (routingFeeSat) => { + const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats, routingFeeSat }); + const a = mgr.lnAction(id); + if (['done', 'failed'].includes(a.step)) settle(a); + else { p.actionId = id; p.status = 'paying'; pollArkLn(id, settle); } + }; const settle = (a) => { if (a.step === 'done') { ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta }; if (p.meta && p.meta.pk) noteZap('inv:' + p.invoice, p.meta.pk); ui.arkLnPay = null; } else if (ui.arkLnPay) { + if (tries < 2 && lnRetryable(a)) { + tries += 1; + attempt((a.routingFeeSat ?? 0) + 1).catch((e) => { + ui.arkLnPay.status = 'ready'; ui.sendError = e.message; render(); + }); + return; + } ui.arkLnPay.status = 'ready'; ui.sendError = a.error === 'payment failed' ? t('arkLnRefunded') : (a.error || t('arkLnPayFailed')); } render(); }; - const a = mgr.lnAction(id); - if (['done', 'failed'].includes(a.step)) settle(a); - else { p.actionId = id; p.status = 'paying'; pollArkLn(id, settle); } + await attempt(p.routingFeeSat); } catch (e) { ui.sendError = e.message; } ui.busy = false; render(); } + // A failed lightning pay is worth re-trying with a bumped routing budget + // unless the error says the attempt itself was ill-formed. + function lnRetryable(a) { + return !a?.error || !/already|invalid|expired|unpayable|insufficient|consolidate|no amount|exceeds/i.test(a.error); + } + function arkLnPayView() { const u = ' ' + unitLabel(); if (ui.arkLnPaid) { |
