aboutsummaryrefslogtreecommitdiffstats
path: root/names
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-29 00:50:09 -0700
committerAdam Soltys <[email protected]>2026-08-29 00:50:09 -0700
commit2023fe06d5cc4867f09d5916680b5fe92394dacb (patch)
treeaf8e230bd283776273537da60fe9c0d6782cba28 /names
parent8ea0416c08535750839088c060d3252415510cc5 (diff)
downloadcoinosv3-2023fe06d5cc4867f09d5916680b5fe92394dacb.tar.gz
coinosv3-2023fe06d5cc4867f09d5916680b5fe92394dacb.zip
The wallet learns to read BOLT 12 for itself: offers and invoices decode client-side, and every invoice the ASP fetches over onion messages is verified here — payment hash read from the raw TLVs, offer terms mirrored byte-for-byte, the issuer's schnorr signature checked against LDK's exact merkle construction (proven against a live CLN-signed fixture) — so no server can hand back an invoice whose preimage it already holds. Paste an lno1 or resolve a name whose record carries one and the existing HTLC pay flow carries it (captaind's Invoice enum takes lni strings natively); a name's owner may now publish their OWN node's offer in their DNS record, retiring the registrar's stand-in — senders fetch invoices straight from their node and no coinos service ever touches the money. The /lnquote fallback prices blinded paths by entry node + advertised payinfo
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019WboeoetMF3MTmgT7Ew8yi
Diffstat (limited to 'names')
-rw-r--r--names/server.js57
1 files changed, 49 insertions, 8 deletions
diff --git a/names/server.js b/names/server.js
index bf90b1a..61a3ca1 100644
--- a/names/server.js
+++ b/names/server.js
@@ -30,6 +30,7 @@ import { ArkManager } from '../src/ark/manager.js';
import { decodeAddress } from '../src/ark/proto.js';
import { decodeNoffer } from '../src/noffer.js';
import { decodeBolt11 } from '../src/ark/lightning.js';
+import { decodeOffer } from '../src/bolt12.js';
const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
|| join(import.meta.dir, 'config.json'), 'utf8'));
@@ -694,7 +695,23 @@ Bun.serve({
const feeMsat = await routeFeeMsat(our, dest, amountMsat, finalCltv);
return json({ feeMsat, feeSat: quoteSat(feeMsat), direct: false });
} catch (e) {
- // Private recipient: not in gossip, reachable only via the
+ // A bolt12 invoice with blinded paths: its node id is a blinded
+ // key no router knows. Quote = route to a path's public entry
+ // node + the blinded segment's advertised payinfo fees.
+ const paths = dec.invoice_paths || [];
+ let best = null;
+ for (const p of paths) {
+ const pay = p.payinfo || {};
+ const entry = p.first_node_id;
+ if (!entry) continue;
+ const f = (pay.fee_base_msat || 0) + Math.ceil(amountMsat * (pay.fee_proportional_millionths || 0) / 1_000_000);
+ try {
+ const toEntry = entry === our ? 0 : await routeFeeMsat(our, entry, amountMsat + f, finalCltv);
+ if (best == null || toEntry + f < best) best = toEntry + f;
+ } catch {}
+ }
+ if (best != null) return json({ feeMsat: best, feeSat: quoteSat(best), direct: false, blinded: true });
+ // Private bolt11 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];
@@ -785,7 +802,7 @@ Bun.serve({
if (m && req.method === 'GET') {
const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
const rec = state.names[`${m[1]}@${domain}`];
- if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null });
+ if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null, lno: rec.lno || null });
const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || await takenByCoinosUser(domain, m[1]);
return json({ name: m[1], domain, taken: reserved, reserved });
}
@@ -809,7 +826,7 @@ Bun.serve({
if (!mine.length) return json({});
const [key, r] = mine[0];
const [name, domain] = key.split('@');
- return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null });
+ return json({ name, domain, uri: r.uri, noffer: r.noffer?.raw || null, lno: r.lno || null });
}
// --- hats ------------------------------------------------------------
@@ -1108,6 +1125,22 @@ Bun.serve({
} catch (e) { return json({ error: 'bad noffer: ' + e.message }, 400); }
}
}
+ // Own BOLT 12 offer: same absent/''/set semantics. When set, the DNS
+ // record carries THEIR lno — senders' fetchinvoice goes straight to
+ // their node and no money ever touches this service.
+ let lno = existing?.lno;
+ if (claim.lno !== undefined) {
+ if (!claim.lno) lno = undefined;
+ else {
+ try {
+ const d = decodeOffer(String(claim.lno).trim());
+ if (domain !== 'staging.coinos.io' && d.network && d.network !== 'mainnet') {
+ throw new Error(`offer is for ${d.network}`);
+ }
+ lno = String(claim.lno).trim().toLowerCase();
+ } catch (e) { return json({ error: 'bad offer: ' + e.message }, 400); }
+ }
+ }
// A corrupt ark address recorded here is a payment black hole: forwards
// fail forever while the sats sit on the node (goyslop's 190k sat sat
// behind an invalid checksum). Refuse it at the door.
@@ -1117,11 +1150,18 @@ Bun.serve({
catch { return json({ error: 'the ark address in the uri does not decode' }, 400); }
}
- // Every name also gets a static Lightning offer on our node; payments
- // to it are forwarded to the ark destination in the claim.
+ // The record's lno=: the owner's own offer when they set one, else a
+ // static offer on our node with the settle forwarder behind it.
let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null;
- if (!offer) { try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); } }
- const published = offer ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${offer.bolt12}` : claim.uri;
+ if (lno) {
+ // their node answers fetchinvoice directly now — retire our stand-in
+ if (offer && ln) await ln.call('disableoffer', { offer_id: offer.offerId }).catch(() => {});
+ offer = null;
+ } else if (!offer) {
+ try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); }
+ }
+ const pubLno = lno || offer?.bolt12;
+ const published = pubLno ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${pubLno}` : claim.uri;
const recordId = await cfWrite(name, domain, published, existing?.recordId);
state.names[key] = {
@@ -1132,8 +1172,9 @@ Bun.serve({
uri: published, recordId, updated: Date.now(), domain,
// the wallet's CLINK offer key, so LNURL can ask it for invoices
offerPk: (claim.offerPk && /^[0-9a-f]{64}$/.test(claim.offerPk)) ? claim.offerPk : existing?.offerPk,
- // the owner's own CLINK service, when they pointed delivery at it
+ // the owner's own CLINK service / BOLT 12 offer, when they set one
...(noffer ? { noffer } : {}),
+ ...(lno ? { lno } : {}),
offerId: offer?.offerId, bolt12: offer?.bolt12,
};
persist();