Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/runners/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,6 +56,7 @@ const runners: Record<string, ComponentType<RunnerProps>> = {
'htlc-relay': HtlcRelay,
'onion-lab': OnionLab,
'custody-check': CustodyCheck,
'route-finder': RouteFinder,
'double-spend': DoubleSpend,
'rewrite-cost': RewriteCost,
};
Expand Down
206 changes: 206 additions & 0 deletions apps/web/src/runners/routing/route-finder.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="cols">
<div className="prose">
<p>
Act one: <strong>be the network</strong>. This is the four-node graph BOLT #7 works
through, with the eight <code>channel_update</code> values it prints. Change what B
charges to forward towards C and watch the routing decision move.
</p>
<label className="height-input-label" htmlFor="rf-amount">
A pays C <strong>{amountSats.toLocaleString('en-US')} sats</strong>
</label>
<input
id="rf-amount"
className="fee-slider"
type="range"
min={1}
max={50_000}
value={amountSats}
onChange={(e) => setAmountSats(Number(e.target.value))}
/>
<label className="height-input-label" htmlFor="rf-base">
B→C <code>fee_base_msat</code>: <strong>{bBase.toLocaleString('en-US')}</strong>
</label>
<input
id="rf-base"
className="fee-slider"
type="range"
min={0}
max={10_000}
step={100}
value={bBase}
onChange={(e) => setBBase(Number(e.target.value))}
/>
<label className="height-input-label" htmlFor="rf-ppm">
B→C <code>fee_proportional_millionths</code>:{' '}
<strong>{bPpm.toLocaleString('en-US')}</strong>
</label>
<input
id="rf-ppm"
className="fee-slider"
type="range"
min={0}
max={10_000}
step={100}
value={bPpm}
onChange={(e) => setBPpm(Number(e.target.value))}
/>
{chosen && (
<Callout>
<strong>
Cheapest route: {['A', ...chosen.hops.map((h) => h.to)].join(' → ')}, costing{' '}
{fmt(chosen.totalFeeMsat)}.
</strong>{' '}
{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.'}
</Callout>
)}

<p>
Act two: <strong>check our arithmetic against the specification's</strong>. BOLT #7
does one sum in full and prints the answer. Ours must match it exactly, truncation
included.
</p>
<button className="runbtn" onClick={runSpecCheck}>
{checked ? '▶ Run the check again' : "▶ Run the specification's own sum"}
</button>
{checked?.ok && (
<Callout>
<strong>✓ {checked.got.toLocaleString('en-US')} msat — exactly what the spec prints.</strong>{' '}
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.
</Callout>
)}
{checked && !checked.ok && (
<Callout>
<strong>✗ Mismatch: we computed {checked.got}, the spec says {SPEC_CHECK.expected}.</strong>{' '}
If you are seeing this, the fee arithmetic on this site is wrong.
</Callout>
)}
{finale.note && (
<p className="finale-note">
<RichText text={finale.note} />
</p>
)}
</div>
<div>
<div className="viz-root">
<div className="viz-title">Routes from A to C</div>
<div className="viz-sub">network and terms per BOLT #7 §Worked Example</div>

<div className="field-rows">
{routes.map((route, i) => (
<div className="field-row" key={route.hops.map((h) => h.to).join()}>
<span className="field-label">
{i === 0 ? '✓ ' : ' '}
{['A', ...route.hops.map((h) => h.to)].join(' → ')}
</span>
<span className="field-hex">
{fmt(route.totalFeeMsat)}
<span className="field-src"> ← {route.totalCltvDelta} blocks timelock</span>
</span>
</div>
))}
</div>

{chosen && (
<div className="guess-feed">
<div className="stat-label">what each hop carries and charges</div>
<div className="field-rows">
{chosen.hops.map((hop) => (
<div className="field-row" key={`${hop.from}${hop.to}`}>
<span className="field-label">
{hop.from} → {hop.to}
</span>
<span className="field-hex">
carries {fmt(hop.amountMsat)}
<span className="field-src">
{' '}
← {hop.feeMsat === 0 ? 'no fee (the sender forwards out of it)' : `${hop.feeMsat.toLocaleString('en-US')} msat fee`}
</span>
</span>
</div>
))}
</div>
<div className="guess-verdict">
A sends {fmt(chosen.totalSendMsat)} so that C receives{' '}
{amountSats.toLocaleString('en-US')} sats — fees never come out of the invoice
</div>
</div>
)}

{checked && (
<div className="guess-feed">
<div className="stat-label">the specification's own line</div>
<code className="guess-hex mine-target">{SPEC_CHECK.line}</code>
<div className="stat-label">what this page computed</div>
<code className={`guess-hex ${checked.ok ? 'mine-winner' : ''}`}>
{SPEC_CHECK.base} + ( {SPEC_CHECK.amountMsat} × {SPEC_CHECK.ppm} ÷ 1000000 ) ={' '}
{checked.got}
</code>
{checked.ok && <div className="guess-verdict mine-won">✓ identical</div>}
</div>
)}
</div>
</div>
</div>
);
}
138 changes: 138 additions & 0 deletions apps/web/src/vizzes/fee-calculator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<VizFigure
title="What one hop charges"
caption="Two numbers the node picked for itself, in the one formula the specification fixes."
>
<div className="paths-chips" role="group" aria-label="Example fee policies">
{PRESETS.map((p) => (
<button
key={p.label}
className={`preset ${base === p.base && ppm === p.ppm ? 'preset-active' : ''}`}
onClick={() => {
setBase(p.base);
setPpm(p.ppm);
}}
>
{p.label}
</button>
))}
</div>

<label className="height-input-label" htmlFor="fee-base">
<code>fee_base_msat</code>: <strong>{base.toLocaleString('en-US')}</strong> — flat, every
forward
</label>
<input
id="fee-base"
className="fee-slider"
type="range"
min={0}
max={5_000}
step={100}
value={Math.min(base, 5_000)}
onChange={(e) => setBase(Number(e.target.value))}
/>

<label className="height-input-label" htmlFor="fee-ppm">
<code>fee_proportional_millionths</code>: <strong>{ppm.toLocaleString('en-US')}</strong> —{' '}
{(ppm / 10_000).toFixed(4)}% of the amount
</label>
<input
id="fee-ppm"
className="fee-slider"
type="range"
min={0}
max={5_000}
step={100}
value={Math.min(ppm, 5_000)}
onChange={(e) => setPpm(Number(e.target.value))}
/>

<label className="height-input-label" htmlFor="fee-amount">
forwarding <strong>{amountSats.toLocaleString('en-US')} sats</strong>
</label>
<input
id="fee-amount"
className="fee-slider"
type="range"
min={1}
max={100_000}
step={1}
value={amountSats}
onChange={(e) => setAmountSats(Number(e.target.value))}
/>

<div className="viz-readout">
<div className="stat-label">
{base.toLocaleString('en-US')} + ( {amountMsat.toLocaleString('en-US')} ×{' '}
{ppm.toLocaleString('en-US')} ÷ 1,000,000 )
</div>
<div className="stat">
<div className="stat-value">
{fee.toLocaleString('en-US')} <span className="stat-unit">msat = {fmt(fee)}</span>
</div>
</div>
<span className="stat-unit">
{pct < 0.001 ? 'under a thousandth of a percent' : `${pct.toFixed(4)}% of the amount`}
</span>
</div>

<p className="utxo-box-note">
{truncated ? (
<>
The exact product is {exact.toLocaleString('en-US')} msat and the fee is{' '}
<strong>{fee.toLocaleString('en-US')}</strong> — the division{' '}
<strong>truncates</strong>. 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.
</>
)}
</p>
</VizFigure>
);
}
4 changes: 4 additions & 0 deletions apps/web/src/vizzes/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,6 +34,8 @@ const vizzes: Record<string, ComponentType> = {
'htlc-chain': HtlcChain,
'invoice-anatomy': InvoiceAnatomy,
'liquidity-seesaw': LiquiditySeesaw,
'fee-calculator': FeeCalculator,
'route-race': RouteRace,
'onion-peel': OnionPeel,
'bitshift-halving': BitshiftHalving,
'utxo-flow': UtxoFlow,
Expand Down
Loading