From 392350a5f2e347b144684172e6da704a7e49acaf Mon Sep 17 00:00:00 2001 From: Harry Beckwith Date: Wed, 26 Aug 2026 13:51:06 -0700 Subject: [PATCH] Add the routing quest, and explain fees where they belong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quest #4 showed a payment crossing hops without ever saying where the route came from or who decided what it cost. Routing read as magic and fees were a passing clause. Quest #6 fills it, in four stops that separate the two questions people actually conflate. How a fee is SET: each node picks two numbers per direction — fee_base_msat and fee_proportional_millionths — and gossips them. Nobody approves them, there is no schedule and no floor. How a fee is ASSESSED: one formula fixed by the specification, applied to those numbers, charged on the amount forwarded, paid entirely by the sender in advance so the payee always receives the invoice amount. BOLT #7 is unusually generous here: it works its own example through a four-node network and prints the answer, so the finale reproduces the specification's own sum and refuses to claim anything unless it matches to the millisatoshi. Writing that turned up a real bug. My first implementation charged each hop using the channel the payment ARRIVED on. The spec settles it in the worked example — A pays B the fee B specified on B->C, the channel it forwards OUT of — and the arithmetic came out at 5,099 instead of 10,199. Testing against a primary source caught what testing against myself never would have. A consequence worth stating: the first channel of any route is free, because the node forwarding out of it is the sender. Also: six glossary entries for the fee and gossip vocabulary, a fee calculator and a route race figure, and Quest #4 now points forward rather than leaving the clause hanging. whose-lightning renumbers to #7 so routing sits inside the Routing track. Co-Authored-By: Claude Opus 5 --- apps/web/src/runners/registry.ts | 2 + apps/web/src/runners/routing/route-finder.tsx | 206 ++++++++++++++++ apps/web/src/vizzes/fee-calculator.tsx | 138 +++++++++++ apps/web/src/vizzes/registry.ts | 4 + apps/web/src/vizzes/route-race.tsx | 79 +++++++ packages/lightning-logic/src/index.ts | 1 + .../lightning-logic/src/lib/routing.spec.ts | 115 +++++++++ packages/lightning-logic/src/lib/routing.ts | 220 ++++++++++++++++++ packages/quests/src/lib/glossary.ts | 63 +++++ packages/quests/src/lib/paths.ts | 5 +- packages/quests/src/lib/quest-ln-04-htlc.ts | 2 +- .../quests/src/lib/quest-ln-06-routing.ts | 196 ++++++++++++++++ ...ning.ts => quest-ln-07-whose-lightning.ts} | 8 +- packages/quests/src/lib/registry.ts | 4 +- 14 files changed, 1035 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/runners/routing/route-finder.tsx create mode 100644 apps/web/src/vizzes/fee-calculator.tsx create mode 100644 apps/web/src/vizzes/route-race.tsx create mode 100644 packages/lightning-logic/src/lib/routing.spec.ts create mode 100644 packages/lightning-logic/src/lib/routing.ts create mode 100644 packages/quests/src/lib/quest-ln-06-routing.ts rename packages/quests/src/lib/{quest-ln-06-whose-lightning.ts => quest-ln-07-whose-lightning.ts} (99%) diff --git a/apps/web/src/runners/registry.ts b/apps/web/src/runners/registry.ts index 442752e..ae38cb9 100644 --- a/apps/web/src/runners/registry.ts +++ b/apps/web/src/runners/registry.ts @@ -6,6 +6,7 @@ import { ClusterDetective } from './privacy/cluster-detective'; import { CustodyCheck } from './custody/custody-check'; import { DoubleSpend } from './doublespend/double-spend'; import { RevocationForge } from './revocation/revocation-forge'; +import { RouteFinder } from './routing/route-finder'; import { RewriteCost } from './rewrite/rewrite-cost'; import { EmissionRunner } from './emission/emission-runner'; import { FeeAuction } from './feemarket/fee-auction'; @@ -55,6 +56,7 @@ const runners: Record> = { 'htlc-relay': HtlcRelay, 'onion-lab': OnionLab, 'custody-check': CustodyCheck, + 'route-finder': RouteFinder, 'double-spend': DoubleSpend, 'rewrite-cost': RewriteCost, }; diff --git a/apps/web/src/runners/routing/route-finder.tsx b/apps/web/src/runners/routing/route-finder.tsx new file mode 100644 index 0000000..9ea2f7c --- /dev/null +++ b/apps/web/src/runners/routing/route-finder.tsx @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import { + BOLT7_EXAMPLE_GRAPH, + findRoutes, + hopFeeMsat, + type ChannelPolicy, +} from '@bitcoin4plebs/lightning-logic'; +import { Callout, RichText } from '@bitcoin4plebs/ui'; +import type { RunnerProps } from '../registry'; + +/** The exact sum BOLT #7 prints in its worked example. */ +const SPEC_CHECK = { + amountMsat: 4_999_999, + base: 200, + ppm: 2_000, + expected: 10_199, + line: '200 + ( 4999999 * 2000 / 1000000 ) = 10199', +}; + +const fmt = (m: number) => `${(m / 1000).toFixed(3)} sats`; + +/** + * The route finder: BOLT #7's own four-node network, with its own eight + * channel_update values, costed by this site's own arithmetic. The second + * act runs the sum the specification prints and refuses to claim anything + * unless it matches to the millisatoshi. + */ +export function RouteFinder({ finale }: RunnerProps) { + const [amountSats, setAmountSats] = useState(1_000); + const [bBase, setBBase] = useState(200); + const [bPpm, setBPpm] = useState(2_000); + const [checked, setChecked] = useState<{ got: number; ok: boolean } | null>(null); + + // Same graph, with B's outgoing terms under the reader's control. + const graph: ChannelPolicy[] = BOLT7_EXAMPLE_GRAPH.map((p) => + p.from === 'B' && p.to === 'C' + ? { ...p, feeBaseMsat: bBase, feeProportionalMillionths: bPpm } + : p + ); + const routes = findRoutes(graph, 'A', 'C', amountSats * 1_000); + const chosen = routes[0]; + + const runSpecCheck = () => { + const got = hopFeeMsat( + { + from: 'B', + to: 'C', + feeBaseMsat: SPEC_CHECK.base, + feeProportionalMillionths: SPEC_CHECK.ppm, + cltvExpiryDelta: 20, + }, + SPEC_CHECK.amountMsat + ); + setChecked({ got, ok: got === SPEC_CHECK.expected }); + }; + + return ( +
+
+

+ Act one: be the network. This is the four-node graph BOLT #7 works + through, with the eight channel_update values it prints. Change what B + charges to forward towards C and watch the routing decision move. +

+ + setAmountSats(Number(e.target.value))} + /> + + setBBase(Number(e.target.value))} + /> + + setBPpm(Number(e.target.value))} + /> + {chosen && ( + + + Cheapest route: {['A', ...chosen.hops.map((h) => h.to)].join(' → ')}, costing{' '} + {fmt(chosen.totalFeeMsat)}. + {' '} + {bBase > 3_000 || bPpm > 4_000 + ? 'B has priced itself out — the payment now prefers D, who charges more per unit but asked for less overall. A node that overcharges does not earn more; it simply stops seeing traffic.' + : 'B is the cheaper way through, so that is where the payment goes. Push B’s numbers up and watch the route abandon it.'} + + )} + +

+ Act two: check our arithmetic against the specification's. BOLT #7 + does one sum in full and prints the answer. Ours must match it exactly, truncation + included. +

+ + {checked?.ok && ( + + ✓ {checked.got.toLocaleString('en-US')} msat — exactly what the spec prints.{' '} + Note it is 10,199 and not 10,200: the division truncates, and getting that wrong would + mean offering a fee the next hop rejects. The same check runs in CI, and CI also + confirms this line is still in the pinned specification. + + )} + {checked && !checked.ok && ( + + ✗ Mismatch: we computed {checked.got}, the spec says {SPEC_CHECK.expected}.{' '} + If you are seeing this, the fee arithmetic on this site is wrong. + + )} + {finale.note && ( +

+ +

+ )} +
+
+
+
Routes from A to C
+
network and terms per BOLT #7 §Worked Example
+ +
+ {routes.map((route, i) => ( +
h.to).join()}> + + {i === 0 ? '✓ ' : ' '} + {['A', ...route.hops.map((h) => h.to)].join(' → ')} + + + {fmt(route.totalFeeMsat)} + ← {route.totalCltvDelta} blocks timelock + +
+ ))} +
+ + {chosen && ( +
+
what each hop carries and charges
+
+ {chosen.hops.map((hop) => ( +
+ + {hop.from} → {hop.to} + + + carries {fmt(hop.amountMsat)} + + {' '} + ← {hop.feeMsat === 0 ? 'no fee (the sender forwards out of it)' : `${hop.feeMsat.toLocaleString('en-US')} msat fee`} + + +
+ ))} +
+
+ A sends {fmt(chosen.totalSendMsat)} so that C receives{' '} + {amountSats.toLocaleString('en-US')} sats — fees never come out of the invoice +
+
+ )} + + {checked && ( +
+
the specification's own line
+ {SPEC_CHECK.line} +
what this page computed
+ + {SPEC_CHECK.base} + ( {SPEC_CHECK.amountMsat} × {SPEC_CHECK.ppm} ÷ 1000000 ) ={' '} + {checked.got} + + {checked.ok &&
✓ identical
} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/vizzes/fee-calculator.tsx b/apps/web/src/vizzes/fee-calculator.tsx new file mode 100644 index 0000000..0d6cc6d --- /dev/null +++ b/apps/web/src/vizzes/fee-calculator.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import { hopFeeMsat } from '@bitcoin4plebs/lightning-logic'; +import { VizFigure } from './viz-figure'; + +const PRESETS = [ + { label: 'a typical routing node', base: 1_000, ppm: 100 }, + { label: 'the spec’s example (B→C)', base: 200, ppm: 2_000 }, + { label: 'wants your traffic', base: 0, ppm: 0 }, + { label: 'priced itself out', base: 50_000, ppm: 5_000 }, +]; + +const fmt = (msat: number) => + msat % 1000 === 0 + ? `${(msat / 1000).toLocaleString('en-US')} sats` + : `${(msat / 1000).toFixed(3)} sats`; + +/** + * The fee calculator: two numbers a node picks for itself, put through + * the one formula the specification fixes. Change either and watch what + * a forward costs — including the truncation that makes the spec's own + * example land on 10,199 rather than 10,200. + */ +export function FeeCalculator() { + const [base, setBase] = useState(200); + const [ppm, setPpm] = useState(2_000); + const [amountSats, setAmountSats] = useState(5_000); + + const amountMsat = amountSats * 1_000; + const policy = { + from: 'B', + to: 'C', + feeBaseMsat: base, + feeProportionalMillionths: ppm, + cltvExpiryDelta: 20, + }; + const fee = hopFeeMsat(policy, amountMsat); + const exact = base + (amountMsat * ppm) / 1_000_000; + const truncated = exact !== fee; + const pct = amountMsat > 0 ? (fee / amountMsat) * 100 : 0; + + return ( + +
+ {PRESETS.map((p) => ( + + ))} +
+ + + setBase(Number(e.target.value))} + /> + + + setPpm(Number(e.target.value))} + /> + + + setAmountSats(Number(e.target.value))} + /> + +
+
+ {base.toLocaleString('en-US')} + ( {amountMsat.toLocaleString('en-US')} ×{' '} + {ppm.toLocaleString('en-US')} ÷ 1,000,000 ) +
+
+
+ {fee.toLocaleString('en-US')} msat = {fmt(fee)} +
+
+ + {pct < 0.001 ? 'under a thousandth of a percent' : `${pct.toFixed(4)}% of the amount`} + +
+ +

+ {truncated ? ( + <> + The exact product is {exact.toLocaleString('en-US')} msat and the fee is{' '} + {fee.toLocaleString('en-US')} — the division{' '} + truncates. This is not pedantry: it is why the specification's own + worked example comes to 10,199 and not 10,200, and an implementation that rounded up + would offer a fee the next hop rejects. + + ) : ( + <> + No remainder to lose here. Nudge the amount until the numbers stop dividing evenly and + watch the fee truncate rather than round. + + )} +

+
+ ); +} diff --git a/apps/web/src/vizzes/registry.ts b/apps/web/src/vizzes/registry.ts index 1669850..a17013f 100644 --- a/apps/web/src/vizzes/registry.ts +++ b/apps/web/src/vizzes/registry.ts @@ -3,9 +3,11 @@ import { ActivationTimeline } from './activation-timeline'; import { AddressPipeline } from './address-pipeline'; import { AvalancheGrid } from './avalanche-grid'; import { ChannelFootprint } from './channel-footprint'; +import { FeeCalculator } from './fee-calculator'; import { HtlcChain } from './htlc-chain'; import { InvoiceAnatomy } from './invoice-anatomy'; import { LiquiditySeesaw } from './liquidity-seesaw'; +import { RouteRace } from './route-race'; import { OnionPeel } from './onion-peel'; import { RevocationLadder } from './revocation-ladder'; import { BitshiftHalving } from './bitshift-halving'; @@ -32,6 +34,8 @@ const vizzes: Record = { 'htlc-chain': HtlcChain, 'invoice-anatomy': InvoiceAnatomy, 'liquidity-seesaw': LiquiditySeesaw, + 'fee-calculator': FeeCalculator, + 'route-race': RouteRace, 'onion-peel': OnionPeel, 'bitshift-halving': BitshiftHalving, 'utxo-flow': UtxoFlow, diff --git a/apps/web/src/vizzes/route-race.tsx b/apps/web/src/vizzes/route-race.tsx new file mode 100644 index 0000000..5207727 --- /dev/null +++ b/apps/web/src/vizzes/route-race.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { BOLT7_EXAMPLE_GRAPH, findRoutes } from '@bitcoin4plebs/lightning-logic'; +import { VizFigure } from './viz-figure'; + +const fmtMsat = (m: number) => `${(m / 1000).toFixed(3)} sats`; + +/** + * The route race: every path from A to C in the specification's own + * four-node network, costed and ranked. The point is that the cheapest + * route is chosen by price, not by hop count — and here the two-hop + * options differ by more than double. + */ +export function RouteRace() { + const [amountSats, setAmountSats] = useState(1_000); + const routes = findRoutes(BOLT7_EXAMPLE_GRAPH, 'A', 'C', amountSats * 1_000); + const cheapest = routes[0]; + const dearest = routes[routes.length - 1]; + + return ( + + + setAmountSats(Number(e.target.value))} + /> + +
+ {routes.map((route, i) => { + const path = ['A', ...route.hops.map((h) => h.to)].join(' → '); + const share = dearest.totalFeeMsat > 0 ? route.totalFeeMsat / dearest.totalFeeMsat : 0; + return ( +
+
+ {i === 0 ? '✓ chosen · ' : ''} + {path} · {route.hops.length} hops · {route.totalCltvDelta} blocks of timelock +
+ + + + + {fmtMsat(route.totalFeeMsat)} in fees +
+ ); + })} +
+ +

+ {cheapest && dearest && cheapest !== dearest ? ( + <> + Both routes are two hops, yet the dearest costs{' '} + {(dearest.totalFeeMsat / Math.max(1, cheapest.totalFeeMsat)).toFixed(1)}×{' '} + the cheapest — because D advertised higher numbers than B. Distance decides nothing + here; the advertised price decides everything. Note too that the cheaper route also + asks for less timelock, which is a second reason wallets favour it. + + ) : ( + <>Only one route available at this amount. + )} +

+
+ ); +} diff --git a/packages/lightning-logic/src/index.ts b/packages/lightning-logic/src/index.ts index 20faee1..2963cbf 100644 --- a/packages/lightning-logic/src/index.ts +++ b/packages/lightning-logic/src/index.ts @@ -3,4 +3,5 @@ export * from './lib/chacha20.js'; export * from './lib/crypto.js'; export * from './lib/bolt11.js'; export * from './lib/keys.js'; +export * from './lib/routing.js'; export * from './lib/sphinx.js'; diff --git a/packages/lightning-logic/src/lib/routing.spec.ts b/packages/lightning-logic/src/lib/routing.spec.ts new file mode 100644 index 0000000..c74575d --- /dev/null +++ b/packages/lightning-logic/src/lib/routing.spec.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + BOLT7_EXAMPLE_GRAPH, + buildRoute, + findCheapestRoute, + findRoutes, + hopFeeMsat, + type ChannelPolicy, +} from './routing.js'; + +/** + * BOLT #7 works its own routing example through in prose: a four-node + * network, eight channel_update messages with stated fees, and the + * arithmetic carried out to a number. That number is a test vector, and + * this file reads it out of the pinned specification rather than trusting + * a copy of it here. + */ + +const BOLTS_SRC = process.env['BOLTS_SRC']; +const boltsAvailable = !!BOLTS_SRC && existsSync(BOLTS_SRC); + +const spec = () => readFileSync(join(BOLTS_SRC as string, '07-routing-gossip.md'), 'utf8'); + +describe('BOLT #7 fee arithmetic', () => { + it('charges base plus proportional, truncating the division', () => { + const policy: ChannelPolicy = { + from: 'B', + to: 'C', + feeBaseMsat: 200, + feeProportionalMillionths: 2000, + cltvExpiryDelta: 20, + }; + // 200 + floor(4999999 * 2000 / 1000000) = 200 + 9999 = 10199 + expect(hopFeeMsat(policy, 4_999_999)).toBe(10_199); + // The truncation matters: rounding up would give 10200. + expect(hopFeeMsat(policy, 4_999_999)).not.toBe(10_200); + }); + + it('charges nothing proportional when the rate is zero', () => { + const free: ChannelPolicy = { + from: 'X', + to: 'Y', + feeBaseMsat: 0, + feeProportionalMillionths: 0, + cltvExpiryDelta: 40, + }; + expect(hopFeeMsat(free, 1_000_000)).toBe(0); + }); +}); + +describe('route construction', () => { + it('never charges the first channel, because the sender does not charge itself', () => { + const route = buildRoute( + [BOLT7_EXAMPLE_GRAPH[0], BOLT7_EXAMPLE_GRAPH[4]], // A->B, B->C + 1_000_000 + ); + expect(route.hops[0].feeMsat).toBe(0); + // ...and the fee that IS charged uses the channel B forwards out of. + expect(route.hops[1].feeMsat).toBe(hopFeeMsat(BOLT7_EXAMPLE_GRAPH[4], 1_000_000)); + }); + + it('builds from the destination backwards, so fees compound outward', () => { + // A->B->C: B charges on what it forwards to C. + const route = buildRoute([BOLT7_EXAMPLE_GRAPH[0], BOLT7_EXAMPLE_GRAPH[4]], 4_999_999); + expect(route.totalFeeMsat).toBe(10_199); + expect(route.totalSendMsat).toBe(4_999_999 + 10_199); + // And the timelock the sender commits to is B->C's delta. + expect(route.totalCltvDelta).toBe(20); + }); + + it('respects advertised htlc limits when choosing', () => { + const graph: ChannelPolicy[] = [ + { from: 'S', to: 'M', feeBaseMsat: 0, feeProportionalMillionths: 0, cltvExpiryDelta: 10, htlcMaximumMsat: 1_000 }, + { from: 'M', to: 'T', feeBaseMsat: 0, feeProportionalMillionths: 0, cltvExpiryDelta: 10 }, + { from: 'S', to: 'N', feeBaseMsat: 5_000, feeProportionalMillionths: 0, cltvExpiryDelta: 10 }, + { from: 'N', to: 'T', feeBaseMsat: 0, feeProportionalMillionths: 0, cltvExpiryDelta: 10 }, + ]; + // The free route cannot carry 50,000 msat; the expensive one must win. + const route = findCheapestRoute(graph, 'S', 'T', 50_000); + expect(route?.hops.map((h) => h.to)).toEqual(['N', 'T']); + }); + + it('prefers the cheapest route, which need not be the shortest', () => { + const routes = findRoutes(BOLT7_EXAMPLE_GRAPH, 'A', 'C', 1_000_000); + expect(routes.length).toBeGreaterThan(1); + // Sorted cheapest-first, and A->B->C beats A->D->C (B charges less than D). + expect(routes[0].hops.map((h) => h.to)).toEqual(['B', 'C']); + expect(routes[0].totalFeeMsat).toBeLessThan(routes[1].totalFeeMsat); + }); +}); + +describe.skipIf(!boltsAvailable)("BOLT #7's own worked example, read from the spec", () => { + it('states the fee formula we implemented', () => { + expect(spec()).toContain( + 'fee_base_msat + ( amount_to_forward * fee_proportional_millionths / 1000000 )' + ); + }); + + it('states the worked answer our arithmetic produces', () => { + // The spec does the sum itself. If it ever changes, this fails loudly. + expect(spec()).toContain('200 + ( 4999999 * 2000 / 1000000 ) = 10199'); + const policy = BOLT7_EXAMPLE_GRAPH.find((p) => p.from === 'B' && p.to === 'C'); + expect(hopFeeMsat(policy as ChannelPolicy, 4_999_999)).toBe(10_199); + }); + + it('has not drifted from the eight channel_updates the spec lists', () => { + const doc = spec(); + for (const p of BOLT7_EXAMPLE_GRAPH) { + const line = `${p.from}->${p.to}: \`cltv_expiry_delta\` = ${p.cltvExpiryDelta}, \`fee_base_msat\` = ${p.feeBaseMsat}, \`fee_proportional_millionths\` = ${p.feeProportionalMillionths}`; + expect(doc, `spec no longer lists ${p.from}->${p.to} with these terms`).toContain(line); + } + }); +}); diff --git a/packages/lightning-logic/src/lib/routing.ts b/packages/lightning-logic/src/lib/routing.ts new file mode 100644 index 0000000..35aa117 --- /dev/null +++ b/packages/lightning-logic/src/lib/routing.ts @@ -0,0 +1,220 @@ +/** + * BOLT #7: how a payment finds its way, and what it costs. + * + * Two ideas, and both are simpler than they sound. + * + * A node ANNOUNCES its terms — what it charges to forward, and how much + * timelock it insists on — in a `channel_update`. Nobody sets these + * centrally; every node picks its own and gossips them, which makes the + * fee "market" the sum of thousands of independent decisions rather than + * a schedule anyone publishes. + * + * A sender then CHOOSES a route by adding those advertised costs up. The + * fee formula is fixed by the spec; what varies is what each node puts + * into it. That is the whole of Lightning's fee system. + */ + +/** One direction of one channel, as its owner advertises it. */ +export interface ChannelPolicy { + /** Who charges this — the node forwarding out of this channel. */ + from: string; + to: string; + /** Flat charge per forward, in millisatoshis. */ + feeBaseMsat: number; + /** Proportional charge, in millionths of the amount forwarded. */ + feeProportionalMillionths: number; + /** Extra timelock this hop insists on, in blocks. */ + cltvExpiryDelta: number; + /** Smallest HTLC this hop will accept, in millisatoshis. */ + htlcMinimumMsat?: number; + /** Largest HTLC this hop will accept — its liquidity ceiling. */ + htlcMaximumMsat?: number; +} + +/** + * The fee one hop charges to forward an amount, exactly as BOLT #7 states + * it: + * + * fee_base_msat + ( amount_to_forward * fee_proportional_millionths / 1000000 ) + * + * Integer arithmetic throughout — the division truncates, which is why + * the specification's own worked example lands on 10,199 and not 10,200. + */ +export function hopFeeMsat(policy: ChannelPolicy, amountToForwardMsat: number): number { + return ( + policy.feeBaseMsat + + Math.floor((amountToForwardMsat * policy.feeProportionalMillionths) / 1_000_000) + ); +} + +export interface RouteHop { + from: string; + to: string; + /** What flows through this channel. */ + amountMsat: number; + /** + * What the node at the START of this channel charges to forward out of + * it. Zero for the first channel: that node is the sender, and a sender + * does not charge itself. + */ + feeMsat: number; + /** Timelock this hop adds, for the same reason and with the same zero. */ + cltvExpiryDelta: number; +} + +export interface Route { + hops: RouteHop[]; + /** What the sender must part with, including every fee on the way. */ + totalSendMsat: number; + /** Fees only. */ + totalFeeMsat: number; + /** Total timelock the sender must commit to, in blocks. */ + totalCltvDelta: number; +} + +/** + * Work a route's costs out from the destination backwards. + * + * Backwards is forced, not stylistic: a hop charges on the amount it + * forwards, and that depends on what every later hop needs. The only + * fixed number is the one at the end — what the payee must receive. + * + * The subtle part, and the thing most explanations get wrong: a node + * charges using the policy of the channel it forwards OUT of, not the one + * the payment arrived on. In A->B->C, the fee B takes is the one B + * advertised on B->C. BOLT #7 spells this out in its worked example, and + * routing.spec.ts checks our arithmetic against the number it prints. + * + * The consequence is that the FIRST channel is free — the sender is the + * one forwarding out of it, and it does not charge itself. + */ +export function buildRoute( + path: readonly ChannelPolicy[], + finalAmountMsat: number, + finalCltvDelta = 0 +): Route { + const amounts = new Array(path.length); + const fees = new Array(path.length).fill(0); + + // What the last channel carries is simply what the payee receives. + amounts[path.length - 1] = finalAmountMsat; + for (let k = path.length - 2; k >= 0; k--) { + fees[k + 1] = hopFeeMsat(path[k + 1], amounts[k + 1]); + amounts[k] = amounts[k + 1] + fees[k + 1]; + } + + const hops: RouteHop[] = path.map((policy, k) => ({ + from: policy.from, + to: policy.to, + amountMsat: amounts[k], + feeMsat: fees[k], + cltvExpiryDelta: k === 0 ? 0 : policy.cltvExpiryDelta, + })); + + const totalSendMsat = amounts[0]; + return { + hops, + totalSendMsat, + totalFeeMsat: totalSendMsat - finalAmountMsat, + totalCltvDelta: + finalCltvDelta + path.slice(1).reduce((sum, p) => sum + p.cltvExpiryDelta, 0), + }; +} + +/** Can this hop carry the amount at all? Limits are advertised, not guessed. */ +export function hopCanCarry(policy: ChannelPolicy, amountMsat: number): boolean { + if (policy.htlcMinimumMsat !== undefined && amountMsat < policy.htlcMinimumMsat) return false; + if (policy.htlcMaximumMsat !== undefined && amountMsat > policy.htlcMaximumMsat) return false; + return true; +} + +/** + * Find the cheapest route by total fee. + * + * Deliberately an exhaustive search over simple paths rather than a + * Dijkstra: real pathfinding is Dijkstra-ish but the cost of an edge + * depends on the amount flowing through it, which depends on the hops + * after it — so the tidy textbook version does not quite apply, and + * implementations all approximate. On the handful of nodes a reader + * will explore, exhaustive is exact and easy to check by hand. + */ +export function findCheapestRoute( + graph: readonly ChannelPolicy[], + source: string, + destination: string, + finalAmountMsat: number, + options: { maxHops?: number; finalCltvDelta?: number } = {} +): Route | null { + const maxHops = options.maxHops ?? 6; + let best: Route | null = null; + + const walk = (at: string, path: ChannelPolicy[], visited: Set) => { + if (path.length > maxHops) return; + if (at === destination && path.length > 0) { + const route = buildRoute(path, finalAmountMsat, options.finalCltvDelta ?? 0); + // Check every hop can actually carry what it is being asked to. + const carries = route.hops.every((hop, i) => hopCanCarry(path[i], hop.amountMsat)); + if (carries && (!best || route.totalFeeMsat < best.totalFeeMsat)) best = route; + return; + } + for (const edge of graph) { + if (edge.from !== at || visited.has(edge.to)) continue; + visited.add(edge.to); + path.push(edge); + walk(edge.to, path, visited); + path.pop(); + visited.delete(edge.to); + } + }; + + walk(source, [], new Set([source])); + return best; +} + +/** Every route from source to destination, cheapest first. */ +export function findRoutes( + graph: readonly ChannelPolicy[], + source: string, + destination: string, + finalAmountMsat: number, + options: { maxHops?: number; finalCltvDelta?: number } = {} +): Route[] { + const maxHops = options.maxHops ?? 6; + const found: Route[] = []; + + const walk = (at: string, path: ChannelPolicy[], visited: Set) => { + if (path.length > maxHops) return; + if (at === destination && path.length > 0) { + const route = buildRoute(path, finalAmountMsat, options.finalCltvDelta ?? 0); + if (route.hops.every((hop, i) => hopCanCarry(path[i], hop.amountMsat))) found.push(route); + return; + } + for (const edge of graph) { + if (edge.from !== at || visited.has(edge.to)) continue; + visited.add(edge.to); + path.push(edge); + walk(edge.to, path, visited); + path.pop(); + visited.delete(edge.to); + } + }; + + walk(source, [], new Set([source])); + return found.sort((a, b) => a.totalFeeMsat - b.totalFeeMsat); +} + +/** + * The four-node network BOLT #7 uses for its own worked example, with the + * eight `channel_update` messages it says the network will see. + * routing.spec.ts checks these against the pinned specification. + */ +export const BOLT7_EXAMPLE_GRAPH: readonly ChannelPolicy[] = [ + { from: 'A', to: 'B', feeBaseMsat: 100, feeProportionalMillionths: 1000, cltvExpiryDelta: 10 }, + { from: 'A', to: 'D', feeBaseMsat: 100, feeProportionalMillionths: 1000, cltvExpiryDelta: 10 }, + { from: 'B', to: 'A', feeBaseMsat: 200, feeProportionalMillionths: 2000, cltvExpiryDelta: 20 }, + { from: 'D', to: 'A', feeBaseMsat: 400, feeProportionalMillionths: 4000, cltvExpiryDelta: 40 }, + { from: 'B', to: 'C', feeBaseMsat: 200, feeProportionalMillionths: 2000, cltvExpiryDelta: 20 }, + { from: 'D', to: 'C', feeBaseMsat: 400, feeProportionalMillionths: 4000, cltvExpiryDelta: 40 }, + { from: 'C', to: 'B', feeBaseMsat: 300, feeProportionalMillionths: 3000, cltvExpiryDelta: 30 }, + { from: 'C', to: 'D', feeBaseMsat: 300, feeProportionalMillionths: 3000, cltvExpiryDelta: 30 }, +]; diff --git a/packages/quests/src/lib/glossary.ts b/packages/quests/src/lib/glossary.ts index ec3dcf0..925cba1 100644 --- a/packages/quests/src/lib/glossary.ts +++ b/packages/quests/src/lib/glossary.ts @@ -1367,6 +1367,69 @@ export const glossary: GlossaryEntry[] = [ quest: 2, questSite: 'lightning', }, + { + term: 'Routing fee', + match: ['routing fee', 'routing fees'], + category: 'Lightning & channels', + definition: + 'What a node charges to forward someone else’s payment. There is no schedule and no authority: each node picks two numbers per direction — [[fee_base_msat]] and [[fee_proportional_millionths]] — and advertises them. The **sender pays every fee on the route in advance**, so the payee always receives exactly the invoice amount.', + cite: '07-routing-gossip.md:981', + quest: 6, + questSite: 'lightning', + }, + { + term: 'fee_base_msat', + code: true, + match: ['fee_base_msat', 'base fee'], + category: 'Lightning & channels', + definition: + 'The flat part of a [[Routing fee]]: charged on every forward regardless of size, in millisatoshis. Set by the node itself and broadcast in a [[channel_update]]. Plenty of nodes advertise zero because they want the traffic.', + cite: '07-routing-gossip.md:456', + quest: 6, + questSite: 'lightning', + }, + { + term: 'fee_proportional_millionths', + code: true, + match: ['fee_proportional_millionths', 'ppm', 'proportional fee'], + category: 'Lightning & channels', + definition: + 'The proportional part of a [[Routing fee]], in millionths of the amount forwarded — so 1,000 is 0.1%. Multiplied by the amount and divided by a million, with the division **truncating**, which is why the specification’s worked example comes to 10,199 and not 10,200.', + cite: '07-routing-gossip.md:457', + quest: 6, + questSite: 'lightning', + }, + { + term: 'channel_update', + code: true, + match: ['channel_update'], + category: 'Lightning & channels', + definition: + 'The gossip message in which a node announces its current terms for one direction of one channel: its two fee numbers, the timelock it insists on, and the smallest and largest payment it will carry. Replaceable at any time — a node changes its prices by broadcasting a new one.', + cite: '07-routing-gossip.md:446', + quest: 6, + questSite: 'lightning', + }, + { + term: 'Gossip', + match: ['gossip', 'gossip protocol'], + category: 'Lightning & channels', + definition: + 'How the network map spreads: nodes announce their own channels and terms, neighbours pass the announcements on, and every wallet builds its own copy. Nobody publishes a directory. Two wallets can briefly hold different maps and both be right, which is one reason a payment can fail for a reason yours could not have known.', + quest: 6, + questSite: 'lightning', + }, + { + term: 'short_channel_id', + code: true, + match: ['short_channel_id'], + category: 'Lightning & channels', + definition: + 'A channel’s on-chain birth certificate: the block height, transaction index and output index of its funding transaction. Because it points at a real output, anyone can check a channel announcement against the blockchain — which is what stops invented capacity spreading through [[Gossip]].', + cite: '07-routing-gossip.md:450', + quest: 6, + questSite: 'lightning', + }, ]; /** Glossary entries visible on one front door (shared terms appear on both). */ diff --git a/packages/quests/src/lib/paths.ts b/packages/quests/src/lib/paths.ts index 5414148..f558193 100644 --- a/packages/quests/src/lib/paths.ts +++ b/packages/quests/src/lib/paths.ts @@ -88,7 +88,7 @@ export const entryPaths: EntryPath[] = [ prompt: 'What can go wrong with my money?', blurb: 'What a force close costs, why your funds sit behind a delay, what a stuck payment locks up, and the one choice — custodial or not — that matters more than all of it.', - questNumbers: [6, 2, 4], + questNumbers: [7, 2, 4], }, ]; @@ -126,7 +126,8 @@ const lightningPrerequisites: Record = { 3: [1], 4: [1, 3], 5: [4], - 6: [1, 2], + 6: [4], + 7: [1, 2], }; export const prerequisites: Record> = { diff --git a/packages/quests/src/lib/quest-ln-04-htlc.ts b/packages/quests/src/lib/quest-ln-04-htlc.ts index 33a3ae6..1ce980a 100644 --- a/packages/quests/src/lib/quest-ln-04-htlc.ts +++ b/packages/quests/src/lib/quest-ln-04-htlc.ts @@ -160,7 +160,7 @@ export const questLn04: Quest = { prose: [ "The deadlines have to descend. If the hub's outgoing HTLC to Ines expired *after* its incoming one from Mira, Ines could wait for Mira's to lapse, then claim — leaving the hub paying out with nothing to claim back. So every hop insists on a comfortable margin, and those margins add up down the route.", "That is the real cost, and it is worth naming plainly rather than skipping. **Long routes need long timelocks.** A payment that gets stuck — a hop that goes offline mid-flight, say — can leave funds locked in an HTLC for hours or, on an unlucky route, over a day. Nothing is lost; it comes back. But it is unavailable, and if it was your whole channel balance, you cannot pay anyone else with it in the meantime.", - "Two more honest costs, since this quest is where they belong. **Liquidity is directional**: the hub can only forward what is on its side of the outgoing channel, so a route can fail simply because the money is pointing the wrong way — and the payer usually cannot tell in advance. And **fees are per hop**, tiny but real: a base amount plus a proportion, which is why millisatoshis exist at all.", + "Two more honest costs, since this quest is where they belong. **Liquidity is directional**: the hub can only forward what is on its side of the outgoing channel, so a route can fail simply because the money is pointing the wrong way — and the payer usually cannot tell in advance. And **fees are per hop**, tiny but real: a base amount plus a proportion, which is why millisatoshis exist at all — who chooses those numbers, and how they add up into the price you actually pay, is the next quest.", "None of this is a flaw in the design so much as the price of the design. A system where a stranger cannot steal from you is a system where a stranger can still fail to help you, and where the money you have committed to a promise is genuinely committed until the promise resolves.", ], }, diff --git a/packages/quests/src/lib/quest-ln-06-routing.ts b/packages/quests/src/lib/quest-ln-06-routing.ts new file mode 100644 index 0000000..4618373 --- /dev/null +++ b/packages/quests/src/lib/quest-ln-06-routing.ts @@ -0,0 +1,196 @@ +import type { Quest } from './types.js'; +import { BOLTS_PIN } from './excerpts.js'; + +/** + * Lightning Quest #6: gossip, pathfinding and fees. + * + * Quest #4 showed a payment crossing hops without saying where the route + * came from or what it costs. This is that quest: how a node learns the + * network exists, how each node SETS its own price, and how those prices + * are ASSESSED into the total a sender pays. + */ +export const questLn06: Quest = { + id: 'quest-ln-06', + site: 'lightning', + slug: 'finding-a-route', + number: 6, + kicker: "Don't trust. Verify.", + track: 'Routing', + title: 'How does your wallet find a route?', + summary: + 'Nobody publishes a map of Lightning, and no one sets its prices. Nodes gossip their own terms, senders add them up, and the cheapest path wins — arithmetic you can run against the specification’s own worked example.', + duration: '12 min', + pin: BOLTS_PIN, + story: { + stage: 'the map', + text: "Mira's payment to Ines went through the market hub, and it cost her 180 sats. Nobody quoted her that price and no authority set it: the hub had announced its own terms to anyone listening, Mira's wallet had been quietly collecting those announcements for weeks, and when the moment came it added up a few candidate paths and picked the cheapest one that could carry the money. She saw a spinner for half a second.", + }, + intro: [ + "Quest #4 waved a hand at something important. A payment crosses hops — fine — but *which* hops, chosen by whom, and at what price? There is no directory of Lightning. No company publishes a routing table and no committee sets a fee schedule.", + "What exists instead is gossip: every node announces its own channels and its own terms, those announcements spread, and every wallet builds its own private map from them. When you pay, your wallet searches that map. This quest reads the announcement format, the fee rule applied to it, and then runs the specification's own worked example — a four-node network the BOLTs carry the arithmetic for, right down to the answer.", + ], + promise: + "Every snippet below is copied verbatim from the Lightning specifications at commit [94eb038](https://github.com/lightning/bolts/commit/94eb038c42e664dd7862faeec6508ccd25f63ff8). The finale reproduces the fee the specification calculates in its own worked example — to the satoshi, including the truncation.", + stops: [ + { + id: 'the-map', + title: 'There is no map, so everyone builds their own', + takeaway: + "Nodes broadcast two kinds of message: *this channel exists* and *these are my terms right now*. Everything spreads to everyone, so every wallet holds its own copy of the network and its own opinion of what a payment should cost.", + myth: { + belief: 'Someone must be coordinating this — a directory, or the big nodes.', + reality: + "There is no directory and no coordinator. A node announces its own channels, its neighbours pass the announcement on, and that is the whole mechanism. Two wallets can hold slightly different maps at the same moment and both be right, because an announcement takes time to spread. This is why a payment can fail for a reason your wallet could not have known: it was routing on a map that was a few minutes stale.", + }, + prose: [ + "A channel announcement is not a claim anyone has to take on faith. It names a `short_channel_id` — the block, the transaction and the output where that channel's funding transaction lives — so any node can go and look at the chain to confirm there really is a 2-of-2 output backing it. **You cannot announce a channel you did not fund.** That is what stops the map filling up with invented capacity.", + "The second message is the interesting one for this quest. A `channel_update` says: here is what I currently charge, here is how much timelock I want, here is the smallest and largest payment I will carry. It is per direction — Mira's terms for forwarding towards the hub are a different message from the hub's terms for forwarding towards Ines — and a node can replace it whenever it likes.", + "So a wallet's map is not just a graph of who is connected to whom. It is a graph annotated with prices, and those prices are the input to everything that follows.", + ], + }, + { + id: 'setting-fees', + title: 'How a fee is set: every node names its own price', + takeaway: + "Two numbers, chosen by each node for each direction, and advertised to the whole network: a **flat charge** per forward, and a **proportional charge** in millionths of the amount. Nobody approves them. There is no schedule and no floor.", + viz: 'fee-calculator', + quiz: [ + { + question: 'Who decides what a routing node charges?', + options: [ + 'The Lightning specification sets the rates', + 'The node itself, unilaterally, and it advertises the choice to everyone', + 'The sender bids and the node accepts or declines', + ], + answer: 1, + explain: + "Each node picks its own two numbers and gossips them. The specification fixes the *formula* those numbers go into, never the numbers. That is why Lightning fees vary so much between nodes and why they can be near zero: an operator wanting traffic can advertise 0 and 0, and plenty do.", + }, + ], + prose: [ + "Here is the whole of fee-setting, in two fields of one message. `fee_base_msat` is a flat amount charged on every forward regardless of size. `fee_proportional_millionths` is charged per millionth of the amount — so 1,000 means 0.1%, and 1 means one part per million.", + "A node picks both, for each direction of each channel, and changes them whenever it wants by broadcasting a fresh `channel_update`. Nobody approves the choice. There is no minimum, which is why plenty of nodes advertise zero and simply want the traffic, and no maximum, which is why a badly-configured node can price itself out of every route and never find out.", + "Two neighbouring fields matter as much as the fees and get less attention. `cltv_expiry_delta` is how much timelock this hop insists on — Quest #4's descending ladder, chosen here. And `htlc_minimum_msat` / `htlc_maximum_msat` are the smallest and largest payment it will carry; the maximum is usually a node telling you, politely, how much liquidity it actually has in that direction.", + "All of it is a claim, advertised in advance, that a sender can add up without asking anyone.", + ], + annotations: [ + { lines: 'L450', text: "The channel's on-chain identity: block, transaction, output. Anyone can check it exists." }, + { lines: 'L454', text: "This hop's contribution to Quest #4's timelock ladder, chosen by its operator." }, + { lines: 'L455', text: 'The smallest payment this hop will carry.' }, + { lines: 'L456–57', text: 'The two fee numbers. Set unilaterally by the node, advertised to everyone, changeable at will.' }, + ], + excerpt: { + pin: BOLTS_PIN, + ref: { file: '07-routing-gossip.md', startLine: 446, endLine: 458 }, + language: 'text', + lines: [ + { n: 446, text: '1. type: 258 (`channel_update`)' }, + { n: 447, text: '2. data:' }, + { n: 448, text: ' * [`signature`:`signature`]' }, + { n: 449, text: ' * [`chain_hash`:`chain_hash`]' }, + { n: 450, text: ' * [`short_channel_id`:`short_channel_id`]', highlight: true }, + { n: 451, text: ' * [`u32`:`timestamp`]' }, + { n: 452, text: ' * [`byte`:`message_flags`]' }, + { n: 453, text: ' * [`byte`:`channel_flags`]' }, + { n: 454, text: ' * [`u16`:`cltv_expiry_delta`]', highlight: true }, + { n: 455, text: ' * [`u64`:`htlc_minimum_msat`]' }, + { n: 456, text: ' * [`u32`:`fee_base_msat`]', highlight: true }, + { n: 457, text: ' * [`u32`:`fee_proportional_millionths`]', highlight: true }, + { n: 458, text: ' * [`u64`:`htlc_maximum_msat`]' }, + ], + }, + }, + { + id: 'assessing-fees', + title: 'How a fee is assessed: one line of arithmetic, and who pays it', + takeaway: + "One formula, fixed by the specification, applied to each hop's own advertised numbers. The **sender pays every fee on the route**, up front, and each node charges using the channel it forwards *out of* — not the one the payment arrived on.", + annotationsOpen: true, + quiz: [ + { + question: 'A pays C through B. Whose advertised fee does B collect?', + options: [ + "The fee B advertised on the A→B channel, where the payment arrived", + 'The fee B advertised on the B→C channel, the one it forwards out of', + 'Half of each', + ], + answer: 1, + explain: + "A node charges for the forward it performs, so it uses the terms of the channel it is forwarding *into the network* — B→C. This trips people up constantly, and the specification settles it in its own worked example: A pays B *the fee it specified in the B->C channel_update*. It also means the first channel of any route is free, because the node forwarding out of it is the sender, and a sender does not charge itself.", + }, + ], + prose: [ + "There is exactly one fee formula in Lightning and this is it. Base, plus the amount times the proportional rate divided by a million. Everything else is which numbers go in.", + "Two details in that arithmetic are easy to miss and both are load-bearing. The division **truncates** — the specification's own example works out to 10,199 rather than 10,200, and an implementation that rounded up would compute a fee the next hop refuses. And the fee is charged on the amount **forwarded**, not the amount the payer sends, which is why routes are costed from the destination backwards: each hop's fee depends on what the hops after it need.", + "Who pays? The sender, entirely and in advance. A payment of 4,999,999 msat through one intermediate becomes 5,010,198 msat leaving the sender, of which 10,199 is B's. The payee receives exactly what they asked for; fees never come out of the invoice amount. This is the opposite of a card network, where the merchant absorbs the cost, and it is why Lightning fees feel invisible to whoever is being paid.", + "The last line is the specification doing our homework for us: `200 + ( 4999999 * 2000 / 1000000 ) = 10199`. The finale runs that exact sum in your browser and refuses to claim anything unless it matches.", + ], + annotations: [ + { lines: 'L981', text: 'The formula, in full. Base plus proportional, integer division, no rounding up.' }, + { lines: 'L1114–15', text: 'The answer to the quiz above, stated by the spec: A pays the fee from the B->C update.' }, + { lines: 'L1118', text: 'The same formula again, now with real numbers about to go into it.' }, + { lines: 'L1120', text: '10,199 — not 10,200. The truncation is the whole reason to check your arithmetic against this line.' }, + ], + excerpt: { + pin: BOLTS_PIN, + ref: { file: '07-routing-gossip.md', startLine: 979, endLine: 981 }, + language: 'text', + lines: [ + { n: 979, text: 'The origin node:' }, + { n: 980, text: ' - SHOULD accept HTLCs that pay a fee equal to or greater than:' }, + { n: 981, text: ' - fee_base_msat + ( amount_to_forward * fee_proportional_millionths / 1000000 )', highlight: true }, + ], + }, + }, + { + id: 'choosing', + title: 'Choosing a path, and why it sometimes fails', + takeaway: + "With prices on every edge, finding a route is a shortest-path search where 'short' means cheap. The catch is that the map says what nodes *charge*, never what they can currently *carry* — so a wallet is optimising over information it does not fully have.", + viz: 'route-race', + prose: [ + "Add the numbers up and the search is almost boring: enumerate plausible paths, cost each one, take the cheapest that fits inside the hops' advertised minimums and maximums. Real implementations dress this up — they weight timelock as a cost too, penalise long routes, and remember which hops failed recently — but the core is arithmetic on a graph you already hold.", + "The cheapest route is frequently not the shortest. A four-hop path of nodes charging nothing beats a two-hop path through someone charging a thousand base, and wallets take it. That is worth knowing when a payment goes somewhere surprising: the route was chosen by price, not by distance.", + "Now the honest limit, which is the whole reason Lightning payments fail. **Liquidity is not gossiped.** A `channel_update` tells you a channel exists, what it charges and the largest HTLC it will accept — it does not and cannot tell you how much is currently sitting on the near side. That changes with every payment through it, and broadcasting it would leak everyone's balances to the world.", + "So your wallet picks a route on price, tries it, and finds out. A failure comes back saying which hop could not continue, the wallet marks it, and tries again. What you experience as a spinner is often two or three real attempts. This is not a bug that will be fixed; it is the direct cost of not publishing everybody's balances, and most people would make the same trade.", + ], + }, + ], + finale: { + title: "Route a payment across the specification's own network", + takeaway: + "Load the four-node network BOLT #7 works through, set your own fees, and watch routes get costed and ranked. Then check the spec's own sum: 200 + (4,999,999 × 2,000 / 1,000,000) should give 10,199, and nothing less will do.", + runnerId: 'route-finder', + note: "The graph and its eight channel_update values are the ones printed in BOLT #7's worked example, and CI checks they still match the pinned specification. The fee arithmetic is this site's own TypeScript, integer-only, checked against the number the specification calculates. Real wallets add penalties and retry history this does not model — the costing itself is exact.", + }, + recap: { + tryIt: + "Next Lightning payment you make, look at the fee your wallet reports and divide it by the amount. It will usually be a few thousandths of a percent — and now you know it is not a rate anyone published, but the sum of two numbers chosen independently by each node you happened to route through, added up by your own wallet before it asked you to confirm.", + items: [ + { + text: '**Nobody publishes a map.** Nodes gossip their own channels, and a channel announcement names the on-chain output backing it, so invented capacity cannot spread.', + }, + { + text: '**Every node sets its own price**, per direction, in two advertised fields — a flat `fee_base_msat` and a proportional `fee_proportional_millionths`. No schedule, no floor, no approval.', + cite: '07-routing-gossip.md:456', + }, + { + text: '**One formula assesses it**, with integer truncation, charged on the amount forwarded — and a node charges using the channel it forwards *out of*.', + cite: '07-routing-gossip.md:981', + }, + { + text: '**The sender pays every fee, in advance**, so the payee always receives exactly the invoice amount.', + }, + { + text: '**Liquidity is never gossiped**, so a wallet routes on price and discovers capacity by trying. That is why payments fail and retry rather than being quoted.', + }, + ], + closing: + "**Keep verifying.** The fee you pay on Lightning is not a rate somebody set — it is thousands of independent decisions, advertised in public, added up by your own software before it spends a satoshi. You have now read the announcement that carries those decisions, the one line of arithmetic that turns them into a price, and the reason your wallet sometimes has to guess. One question is left, and it is the one that decides whether any of this is yours.", + }, + feynman: { + prompt: 'Explain to a friend who decides what a Lightning payment costs, and how the number is arrived at.', + model: + "Nobody decides it centrally. Every node that forwards payments picks two numbers for itself — a flat charge and a percentage — and broadcasts them to the whole network, changing them whenever it likes. When you pay, your wallet already holds those broadcasts, so it works out several possible paths, adds up each hop's two numbers against the amount being carried, and takes the cheapest path that can handle it. The sender covers all of it up front, so the person being paid always receives exactly what they asked for.", + }, +}; diff --git a/packages/quests/src/lib/quest-ln-06-whose-lightning.ts b/packages/quests/src/lib/quest-ln-07-whose-lightning.ts similarity index 99% rename from packages/quests/src/lib/quest-ln-06-whose-lightning.ts rename to packages/quests/src/lib/quest-ln-07-whose-lightning.ts index 30f802f..5718a69 100644 --- a/packages/quests/src/lib/quest-ln-06-whose-lightning.ts +++ b/packages/quests/src/lib/quest-ln-07-whose-lightning.ts @@ -2,16 +2,16 @@ import type { Quest } from './types.js'; import { BOLTS_PIN } from './excerpts.js'; /** - * Lightning Quest #6: the practical capstone. Five quests of mechanism + * Lightning Quest #7: the practical capstone. Five quests of mechanism * are worth nothing to a reader whose wallet holds none of the keys, so * this one is about the choices that decide whether any of it applies to * them — and the honest costs nobody mentions in the marketing. */ -export const questLn06: Quest = { - id: 'quest-ln-06', +export const questLn07: Quest = { + id: 'quest-ln-07', site: 'lightning', slug: 'whose-lightning', - number: 6, + number: 7, kicker: "Don't trust. Verify.", track: 'The big questions', title: 'Whose Lightning are you using?', diff --git a/packages/quests/src/lib/registry.ts b/packages/quests/src/lib/registry.ts index 19bef61..7c6d4d7 100644 --- a/packages/quests/src/lib/registry.ts +++ b/packages/quests/src/lib/registry.ts @@ -24,7 +24,8 @@ import { questLn02 } from './quest-ln-02-revocation.js'; import { questLn03 } from './quest-ln-03-invoice.js'; import { questLn04 } from './quest-ln-04-htlc.js'; import { questLn05 } from './quest-ln-05-onion.js'; -import { questLn06 } from './quest-ln-06-whose-lightning.js'; +import { questLn06 } from './quest-ln-06-routing.js'; +import { questLn07 } from './quest-ln-07-whose-lightning.js'; /** * The quest registry, in curriculum order — each quest builds on the @@ -59,6 +60,7 @@ export const quests: Quest[] = [ questLn04, questLn05, questLn06, + questLn07, ]; export function getQuestBySlug(slug: string): Quest | undefined {