aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-13 23:55:53 -0700
committerAdam Soltys <[email protected]>2026-08-13 23:55:53 -0700
commitb7a8fc0254d6e08013e3672b8da09b508057ec93 (patch)
tree27fefea55a457638027287fc1de45d79018bce32
parentb0980c2df8835965a0dc779f118e59610091134d (diff)
downloadcoinosv3-b7a8fc0254d6e08013e3672b8da09b508057ec93.tar.gz
coinosv3-b7a8fc0254d6e08013e3672b8da09b508057ec93.zip
The network fee gets asked, not assumed: /lnquote prices a lightning payment through askrene before anything locks — zero to ourselves and direct peers
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
-rw-r--r--names/server.js77
1 files changed, 77 insertions, 0 deletions
diff --git a/names/server.js b/names/server.js
index 3de2315..c76be68 100644
--- a/names/server.js
+++ b/names/server.js
@@ -207,6 +207,34 @@ function requestInvoiceFromWallet(offerPk, { amountSat, description, zap }) {
}
const ln = CFG.ln ? lnBackend(CFG.ln) : null;
+
+// -- routing-fee quotes (the /lnquote endpoint) ------------------------------
+
+let _ourNodeId = null;
+async function ourNodeId() {
+ if (!_ourNodeId) _ourNodeId = (await ln.call('getinfo')).id;
+ return _ourNodeId;
+}
+
+// Ask askrene (the router xpay itself uses) what delivering amountMsat to
+// dest would cost from here. The maxfee passed in is only the SEARCH cap —
+// generous on purpose, since the user sees and approves the quoted fee
+// before anything is locked.
+async function routeFeeMsat(our, dest, amountMsat, finalCltv) {
+ const cap = Math.max(50_000, Math.ceil(amountMsat * 0.05));
+ const r = await ln.call('getroutes', {
+ source: our, destination: dest, amount_msat: amountMsat,
+ // localchans FIRST: it re-adds our channels with their advertised fees,
+ // so sourcefree must come after it to zero our own hops (verified live —
+ // the other order quotes 1 sat on a direct-peer payment).
+ layers: ['auto.localchans', 'auto.sourcefree'],
+ maxfee_msat: cap, final_cltv: finalCltv,
+ });
+ if (!r.routes || !r.routes.length) throw new Error('no route found');
+ const sent = r.routes.reduce((s, rt) => s + Number(rt.path[0].amount_msat), 0);
+ const delivered = r.routes.reduce((s, rt) => s + Number(rt.amount_msat), 0);
+ return Math.max(0, sent - delivered);
+}
let fwd = null; // the float ark wallet
if (CFG.forwarder?.mnemonic) {
const account = HDKey.fromMasterSeed(mnemonicToSeedSync(CFG.forwarder.mnemonic)).derive("m/86'/0'/9'");
@@ -447,6 +475,55 @@ Bun.serve({
});
}
+ // Routing-fee quote for an outgoing lightning payment. The wallet locks
+ // amount + this fee into its HTLC and the ASP hands the whole surplus to
+ // CLN as the routing budget, so the number returned here is exactly what
+ // the user pays — zero for invoices minted on our own node (every
+ // coinos-to-coinos payment) and for direct peers. Informational only:
+ // a wrong quote fails the payment (refunded in full) or overpays a route,
+ // it can never spend more than the user chose to lock.
+ if (url.pathname === '/lnquote' && req.method === 'GET') {
+ if (!ln) return json({ error: 'no lightning backend' }, 503);
+ if (!rateOk(ip, 30)) return json({ error: 'rate limited' }, 429);
+ try {
+ const invoice = (url.searchParams.get('invoice') || '').trim().toLowerCase();
+ if (!invoice || invoice.length > 4000) return json({ error: 'invoice required' }, 400);
+ const dec = await ln.call('decode', { string: invoice });
+ if (dec.valid === false) return json({ error: 'invalid invoice' }, 400);
+ const dest = dec.payee || dec.invoice_node_id;
+ const amountMsat = Math.floor(Number(url.searchParams.get('amount_msat')) || 0)
+ || Number(dec.amount_msat || dec.invoice_amount_msat || 0);
+ if (!dest) return json({ error: 'no destination in invoice' }, 400);
+ if (!amountMsat || amountMsat < 0) return json({ error: 'amount required' }, 400);
+ const our = await ourNodeId();
+ if (dest === our) return json({ feeMsat: 0, feeSat: 0, direct: true });
+ const finalCltv = dec.min_final_cltv_expiry || 18;
+ try {
+ const feeMsat = await routeFeeMsat(our, dest, amountMsat, finalCltv);
+ return json({ feeMsat, feeSat: Math.ceil(feeMsat / 1000), direct: false });
+ } catch (e) {
+ // Private recipient: not in gossip, reachable only via the
+ // invoice's route hints. Quote = route to the hint's public entry
+ // node + the hint hops' advertised fees.
+ const hint = (dec.routes || [])[0];
+ if (!hint || !hint.length) throw e;
+ let hintFeeMsat = 0;
+ let carried = amountMsat;
+ for (const hop of [...hint].reverse()) {
+ const f = (hop.fee_base_msat || 0) + Math.ceil(carried * (hop.fee_proportional_millionths || 0) / 1_000_000);
+ hintFeeMsat += f; carried += f;
+ }
+ const entry = hint[0].pubkey;
+ const toEntry = entry === our ? 0
+ : await routeFeeMsat(our, entry, amountMsat + hintFeeMsat, finalCltv);
+ const feeMsat = toEntry + hintFeeMsat;
+ return json({ feeMsat, feeSat: Math.ceil(feeMsat / 1000), direct: false, hinted: true });
+ }
+ } catch (e) {
+ return json({ error: e.message || 'no route found' }, 404);
+ }
+ }
+
// Prefix search over registered names — powers recipient search in the
// wallet's DMs. Public data (every name is a public DNS record).
if (url.pathname === '/search' && req.method === 'GET') {