aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-14 00:02:06 -0700
committerAdam Soltys <[email protected]>2026-08-14 00:02:06 -0700
commit5abf76764c445b742a50080ebb97c313531d5962 (patch)
tree7a9f531971e93102dfd2c4d49ecf872299e893a0 /src
parentb7a8fc0254d6e08013e3672b8da09b508057ec93 (diff)
downloadcoinosv3-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.js13
-rw-r--r--src/ark/manager.js30
-rw-r--r--src/features/ark.js61
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) {