Skip to content

Metered relay mode: pay a pusher for the bandwidth it spends on you - #33

Open
v1rtl wants to merge 27 commits into
mainfrom
metered-relay
Open

Metered relay mode: pay a pusher for the bandwidth it spends on you#33
v1rtl wants to merge 27 commits into
mainfrom
metered-relay

Conversation

@v1rtl

@v1rtl v1rtl commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Today a pusher relay eats a cost it did not incur. In a native upload the
user's own node opens the pushsync streams and bee debits it per chunk;
put a relay in the middle and that debt moves wholesale to the relay,
because the relay is the peer bee sees. The client pays postage and
nothing else.

This branch adds an optional metered mode so a relay can bill for the
bandwidth it spends, reusing the parts of SWAP that already exist rather
than inventing a payment system. docs/pusher-incentives.md is the design;
docs/pusher-incentives-slides.md is a deck built from it.

The billing unit

owed = (kib_admitted − kib_dedup) × price_plur_per_kib.

Bytes admitted, not delivery receipts. That one choice is what keeps the
rest small. An earlier draft billed per verified pushsync receipt, which
needed a five-clause predicate anchored in the staking registry, a
set-valued invoice, a sampled retrieval audit, a staked-set log sweep on
both sides, and a predicate module that had to stay byte-identical across
client and relay or generate disputes with no adjudicator. All of it
existed to make a third party's signature into a billing input. Billing
bytes removes the apparatus and every attack against it: the client
produced the bytes, the relay counted them, and neither can lie to the
other about a number they both measured.

What's here

  • Relaymetered.rs, ledger.rs, challenge.rs. Admission decides
    before reading a body, off a MAC'd challenge that carries the account,
    the batch and a credit line sealed in at issue time, so /v1/push reads
    no chain state at all. Chain lookups happen once per challenge, not once
    per POST, which keeps the unauthenticated amplification surface closed.
  • Clientpayer.rs, cheques.rs, protocols/swap.rs. Cumulative
    EIP-712 cheques, per-lane accounting, POSTs sized to live headroom.
  • CLIhoverfly chequebook deploy/fund/status and hoverfly cashout.
  • Stage 0 shadow meteringmeter.rs. Counts what a metered relay
    would have billed while changing nothing on the wire, so the parameters
    can be checked against real traffic before enforcement.
  • Credit line derived, not asserted
    min(remaining_value ÷ 1000, ceiling). "Owns a live batch" proves
    nothing when the cheapest acceptable batch costs a fraction of a cent, so
    the line scales to on-chain value and the Sybil margin is 1000× by
    construction at any batch size.

Paying is optional and mode is per relay. A fleet mixes freely: four open
lanes and one hard-metered one in the same PUSHER_URLS, each client using
the subset that will serve it. Both drivers retire a hard-metered lane up
front when they cannot pay — the browser unconditionally, since it stamps
chunks but has no chequebook.

Status

pusher.browserbzz.link runs metered with hard enforcement. Uploads of
128 KiB through 4 MiB complete every frame, and three 2 MiB runs over
public HTTPS through a reverse proxy delivered 567/567 each with no
refusals, no rejected cheques, and owed: 0 on the relay after each.

Real money to date is $0.0003 billed and $0.00006 cashed across two
cashChequeBeneficiary calls, from one paying account — mine. The
mechanism works; nobody is paying it yet.

Worth reviewing closely

  • §17 — six bugs only a real metered relay found. All six needed a
    ledger surviving restarts, several POSTs in flight, and a batch spent
    down far enough for its credit line to bind. None is reachable from one
    upload against a fresh relay, which is why the test suite and the
    Stage 1 round-trip missed them.
  • §11.2 — cheques are unsecured claims, bounded by the per-account cap.
    Inherited from SWAP; hard deposits work but are inert as bee deploys them.
  • §15 — a relay can take payment and drop chunks. Out of scope for
    cryptographic treatment: pushsync's storer signs the bare chunk address,
    so a receipt is valid forever, for everybody, and can be replayed for
    content already in the swarm. What bounds it is that the client pinned
    the lane, caps exposure at one credit line, and deweights a lane whose
    acks stop arriving.
  • §9.1's cost basis is modelled, not measured. The Stage 0 egress
    counter was meant to settle it and does not: it is bumped after the await
    inside a future the racing dispatcher cancels, so it counts completions
    and floors near 1.0 by construction. That gate is marked not met, and
    the counter needs moving to dispatch before anything is repriced on it.

🤖 Generated with Claude Code

v1rtl and others added 27 commits August 6, 2026 15:14
Implements docs/pusher-incentives.md Stages 0 and 1 in soft mode: the
relay can be paid, but never refuses. All of it is behind `--meter`, so
open mode — every production lane — is byte-identical to before.

Billing unit is **bytes admitted**, not pushsync receipts. The client
cannot lie about it because the client produced the bytes and the relay
counted them: no third-party attestation to forge, no chain state to
disagree about. That choice deletes the staking-registry anchor, the
set-valued invoice and the sampled audit an earlier draft needed, along
with every attack against them.

Relay side:
  challenge.rs      capability MAC, domain-tagged length-prefixed preimage
  ledger.rs         owed/reserved/cumulative/binding, durable except
                    `reserved` — no in-flight POST survives a restart to
                    release it, so restoring it leaks credit permanently
  metered.rs        endpoint logic + state, keeping pusher.rs a router
  inbound_limit.rs  fail-closed limiter: only full buckets are evictable,
                    so flooding keys cannot mint budget
  meter.rs          Stage 0 shadow accounting, /v1/meter

Client side:
  payer.rs          signed-quote verification, lane pinning on
                    (url, node_eth_address, beneficiary), POST sizing
                    against the credit line, local owed tracking
  pushsched.rs      BatchOutcome::PaymentRequired + non-terminal
                    LaneHealth::Unfunded — a 402 is a bill, not a fault,
                    and must not retire a healthy lane
  cheques.rs        total_issued + `relay:` key namespace, so lanes
                    sharing a beneficiary share one cumulative

Verified against swap-swear-and-swindle rather than inferred from bee:
cheque signatures must be canonical (OZ 3.4.1 rejects high-s and
v∉{27,28}, so a non-canonical cheque buys service and reverts at
cashout), and the funding check reads liquidBalanceFor rather than
balance, which counts other beneficiaries' hard deposits as our coverage.

90 new tests. Not wired: the driver does not yet act on a 402, and the
/v1/pay chain reads have not been exercised against a real chequebook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Connects the pieces Stage 1 already had but never joined: on a 402 — or
once the lane's own settlement window is crossed — the driver fetches a
challenge, signs it, POSTs a cumulative cheque to /v1/pay, and calls
fund_lane to restore the lane to the health it had before it ran out of
credit. Paying on the window is what stops an upload reaching its cap at
all; paying on 402 is the recovery path when it does.

The client bills itself from bytes it *sent*, by the same arithmetic the
relay uses, so the two sides agree without exchanging anything and a
disagreement is visible immediately rather than adjudicated.

LaneInfo now carries the whole verified quote rather than just the price,
so the payment loop has the beneficiary and parameters without re-fetching
and re-verifying /v1/status.

Also records a design decision in §14: chequebook deployment is an
explicit user command, never an automatic side effect of an upload.

Still not exercised end to end — the /v1/pay chain reads have never
touched a real chequebook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…I guard

Two findings from review, both real and both verified against the code.

**Unbounded body read on /v1/pay.** `read_body_limited` enforced its cap
via a `size_hint().upper()` pre-check plus a post-`collect()` compare. But
`Incoming::size_hint().upper()` is `None` for a chunked body — length
genuinely isn't known until the terminating chunk — so the pre-check
short-circuits to *skipping*, and `.collect()` then accumulates whatever
the client streams until the 30s timeout, checking the size only after the
memory is already committed. An authenticated caller (one live batch buys
a challenge) could stream ~30s of link bandwidth per connection across the
256-connection cap. Now uses `Limited::new`, matching /v1/push, which
enforces per-frame inside `poll_frame`.

**Reservations leaked on four early returns.** `admit_metered` reserves
before the body is read; oversize body, read timeout, frame-decode failure
and empty batch all return without reaching `run_push`, the only caller of
`commit` — and `commit`/`release` are the only things that lower
`reserved_plur`. Paying doesn't help: `credit` reduces `owed`, never
`reserved`. So a leak was permanent until restart and it ratcheted: under
hard mode the account ends up above its cap with no cheque able to clear
it (§10.1's no-exit failure), and under soft mode leaks accumulate against
MAX_LIVE_RESERVATIONS until real clients are shed.

Fixed with RAII rather than four `release` calls, so exit paths added
later are covered by construction — which is precisely the bug that
occurred. `Admitted` now holds the state and releases on drop unless
`commit` consumed it.

Four ledger-level regression tests pin the invariants the guard relies on,
including that paying a cheque does not release a reservation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…credit line

Adds the chequebook commands as their own subcommand group — never a side
effect of an upload, since deploying a contract is irreversible and spends
real funds. `deploy` simulates through the canonical factory first (a
revert surfaces free rather than as a burnt tx), reads the address back out
of the receipt's SimpleSwapDeployed log rather than trusting the
simulation, and verifies issuer() before returning. `fund` refuses any
address that does not answer issuer(), or whose issuer is not the signing
key — only the issuer can ever withdraw, so a mistyped address strands the
deposit.

Also fixes a real gap the first end-to-end run exposed: `max_body_bytes`
existed and was tested, but nothing connected it to the scheduler, so
POSTs stayed full-size, exceeded the credit line, and 402'd forever — and
the client could not pay its way out because nothing had been accepted, so
it owed nothing. §7.2 says to size the POST rather than discover the
ceiling as a 402; now `batch_max` is clamped from the cap before scheduling
and a lane settles pre-dispatch when the next body would not fit.

Verified end to end against Gnosis mainnet: batch created, chequebook
deployed and funded with 0.1 BZZ, 262144 bytes pushed through a hard-mode
metered relay. Multiple cumulative cheques accepted through the full chain
(deployedContracts, issuer, liquidBalanceFor, paidOut, bounced). Client and
relay agreed to the PLUR — 305 KiB x 4.8e8 = 146,400,000,000 billed,
144,480,000,000 paid, 1,920,000,000 residual left unsettled because it is
below the dust floor. Data round-tripped byte-identically via bzz.limo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…counting

Four fixes, all found by running the thing against Gnosis rather than by
reading it.

**RPC per cheque: 4 sequential eth_calls -> 1 batched request, usually 0.**
issuer/liquidBalanceFor/paidOut/bounced are reads against the same block,
so they now go out as one JSON-RPC batch, and Metered caches the result for
30s. That cache weakens nothing: §11.2 already states the funding check is
true at acceptance time, not cashout time, so a fresh read only narrows the
window in which an attacker must withdraw, against an exposure bounded by
max_outstanding either way. Measured: 15 eth_calls in 6 HTTP requests for a
whole paid upload.

**Client billed at dispatch, relay bills what it admits.** Every 402'd POST
inflated the client's total while the relay's stayed flat, so it signed
cheques for ~5x what was owed — caught by the Overpayment guard, which
would otherwise have banked money for work never done. Bytes are now held
as `pending` until the POST is answered, mirroring the relay's
reserve->commit exactly.

**Billing only clean answers under-counted interrupted POSTs.** The
question is whether the relay read the body, not whether we got a clean
response: a 402 is refused before a byte is read, but a stream that breaks
halfway was received and billed. This is §7.3's ack-tail case, which §8.4
claims immunity from precisely because the client counts what it sent.

**A tight credit line could fail an upload instead of pausing it.** Bytes
still in flight at exit were never converted to debt, so the final
settlement saw nothing to pay; and dispatching without headroom earned a
402, while handing the assignment back cost every chunk a retry attempt.
Now the driver declines to *take* work it cannot pay for, and drains
in-flight bytes into debt before settling.

Also corrects §7.1: soft mode still requires the challenge. "Never
refuses" means it does not enforce the cap, not that it serves clients with
no capability — otherwise metering is bypassable by omitting a header.

Verified against Gnosis mainnet: 512 KiB through a hard-mode relay whose
credit line is 83 KiB (6x smaller than the payload), repeatedly hitting the
cap and settling out of it. Client and relay agreed to the PLUR, residual
480,000,000 left unpaid because it is below the dust floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the loop: cheques could be issued, accepted and verified, but never
collected. Run from a machine holding the beneficiary key, never the relay
— `cashChequeBeneficiary` takes `msg.sender` as the beneficiary, so the key
the contract will pay is exactly the one §6 keeps off a relay box.

**The ledger was storing a number it could prove nothing about.** It kept
each chequebook's cumulative but discarded the signature, so nothing in it
was cashable — the whole ledger was a record of money that could not be
collected. On-disk format is now version 2 and holds the latest signed
cheque per chequebook (cumulative payouts mean only the newest is ever
worth presenting). Version 1 entries still load, because losing the
cumulative would let a client replay (§11.4), but are skipped at cashout
rather than submitted for the contract to reject.

Prices every cheque before spending gas: `cumulative - paidOut` against
`liquidBalanceFor`, so an underfunded claim is visible rather than
discovered by a permanent `bounced` flag. Non-canonical signatures are
refused locally, where it costs nothing, instead of reverting on-chain.
`--min-amount` defaults to 0.25 BZZ per §9.3 — cashing costs ~300k gas
whatever the amount.

Verified on Gnosis mainnet: a 145,920,000,000 PLUR cheque presented,
`paidOut` moved by exactly that, beneficiary BZZ balance moved by exactly
that, and a repeated run correctly presented nothing (`unclaimed 0`) —
idempotent via `paidOut`, which matters for something run on a timer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`refund_dedup` existed, was tested, and was never called — which was not
merely dead code but a live divergence. The relay bills
`admitted - dedup` (§8.2), while a dedup ack was indistinguishable from a
real push, so a paying client counted bytes it was never charged for. Its
`owed` drifted above the relay's and the next cheque was refused as an
overpayment, jamming settlement. It fires whenever a client re-POSTs inside
the 120s recent-ack window — which the design's own retry model does.

The relay now marks the ack `"dedup": true` and the client subtracts those
bytes. The claim only ever lowers what is owed, so taking it at face value
is safe.

Also lets a client shed debt the relay disclaims. A POST whose completion
the client never saw is charged locally but was never billed remotely; a
cheque for it is refused every time, and carrying it forever only eats the
client's own headroom. On "nothing owed" the client now drops it.

Verified: two uploads of the same file, the second entirely served from the
relay's cache. Relay billed 164 of 328 KiB; both sides agreed at
78,720,000,000 with zero residual. Before the fix the client would have
tried to pay double.

Adds env fallbacks (HOVERFLY_METER*) so a deployed relay can be switched to
metered without changing its start command, matching the existing
HOVERFLY_PUSH_* pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dispatching POSTs the credit line cannot hold

Found by running a metered relay on a real host with a persistent disk
and uploading to it twice.

**The relay's ledger outlives the client's.** A run ends leaving the
sub-dust residual unpaid — deliberately, since a cheque for it would be
refused — but the relay keeps counting it against the credit line while
the next client process starts believing it owes nothing. The second
upload was refused on its first POST for 290,400,000,000 PLUR it could
not compute a cheque for, and failed 151/151. With the shipped defaults
the residual is under 3.9e12 against a 62.2e12 cap, so it takes ~16 runs
to deadlock rather than 2; the tight test params only made it prompt.

The recovery is to ask. `/v1/account` already reports the relay's own
`owed`, so a 402 the client cannot pay now triggers a reconcile and pays
what the relay says. Deliberately *not* the `outstanding_plur` in the 402
body: `reserve()` adds the reservation before computing it, so that
figure includes the body just refused and adopting it over-pays by
exactly that amount — the next cheque then bounces as an overpayment.
Reservations are excluded on our side too, being already counted as
`pending`. The figure is bounded by the chequebook balance rather than
the credit line, because debt reaches the line by construction and an
operator who lowers it leaves real debt above it.

**The headroom guard admitted a frame and then sent a batch.** It asked
whether one more *frame* fit, then dispatched a full `batch_max` POST,
and `dispatch_ok` was computed once for an unbounded run of them. Several
POSTs were each waved through against the same headroom, the relay
reserved all of them, and it 402'd a client whose books said it had room
— a refusal nothing could pay, since the bytes were still in flight. The
batch then came back having spent an attempt per chunk. The guard now
prices the POST the scheduler will actually build and is re-evaluated per
dispatch; where the line holds only one POST this serialises the lane,
which is the honest answer.

`LaneAccount::max_body_bytes` had the same confusion, sizing against
`owed` while its siblings bound on `outstanding`. It is only reached from
tests today, but it would have reintroduced the bug at its next caller.

Verified against a metered hard-mode relay on an amd64 VPS: 402s per
upload 11 -> 1, unpayable refusals 6 -> 0, five uploads of 512 KiB
settling to `owed: 0` on both sides, and the relay's held cheque redeemed
on Gnosis from a separate machine (tx 0x9d4a8b29..., paidOut now equals
the cheque's cumulative exactly).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…inds

Both need what the test suite and the Stage 1 round-trip did not have: a
relay whose ledger survives client runs, and several POSTs on the wire at
once. §17.1 is the dust-floor residual becoming an unpayable debt after
enough runs; §17.2 is the headroom guard pricing a frame and dispatching a
batch. Records which number the client must reconcile against and why each
of the other two candidates over-pays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
actually binds, and size each POST to live headroom

Three findings from running uploads against a metered relay until the
batch's value decayed.

**§10.1's invariant was checked against the wrong quantity.**
`Params::validate` asserts `min_cheque <= settle_every < max_outstanding`,
but `max_outstanding_plur` is only the ceiling on a credit line — the line
that binds is per batch, `min(remaining/credit_ratio, ceiling)`. For any
batch under `min_cheque_plur * credit_ratio` (~0.39 BZZ at the defaults)
the floor sits above everything the account can ever owe, so it accrues to
its cap, is answered 402, and cannot write an acceptable cheque:
`next_cumulative()` returns None below the floor and `/v1/pay` rejects the
same amount as dust. Neither side is broken; the parameters cannot be
satisfied. Live: a 679,783,122,862 line against a 3,900,000,000,000 floor
halted an upload at 16 of 219 chunks with the relay reporting err=0. That
is §10.3's small batch — the case the scaled line exists to keep serving.

`Params::effective(cap)` now resolves both thresholds against the line:
settle at half of it, never demand a cheque larger than that. Both sides
derive it from `(params, cap)` and `cap` is already in the challenge, so
they agree with no protocol change. A generous line is unaffected.
Accepting a smaller cheque costs the relay nothing — cheques are
cumulative, and `cashout --min-amount` already declines to spend gas on a
claim not worth collecting. The dust floor belongs at redemption.

**POSTs are sized to live headroom.** The body was built from a ceiling
computed before any debt existed, so concurrent POSTs were each sized as
if alone on the lane and the relay refused their sum — a 402 nothing could
pay, since the bytes were in flight and nothing was owed yet.
`affordable_frames()` deducts current debt and in-flight bytes, and
`Scheduler::set_lane_batch_max` re-clamps between dispatches.

My first attempt gated dispatch on whether a *full* POST would fit. That
is worse, and caused the §17.3 stall above in a second form: when the
leftover debt is under the dust floor it can never be settled, so the
headroom such a guard waits for never returns. `has_headroom` is back to
asking about one frame — the smallest dispatchable unit — with sizing done
separately, and the two are documented as a pair.

Also removes two pieces of dead code: `ack_ok` in the relay's push path,
superseded by the streaming `on_chunk` callback that emits `bpo` as well
(its comment on what `po` measures is kept, moved to the live site), and a
stale `mut` on a delegating wrapper. The crate now builds warning-free.

Verified against the relay on an amd64 VPS: the 768 KiB upload that
stalled at 16/219 now completes 219/219 at 86.79 KiB/s with one 402, one
reconcile, one payment; relay ledger settles to owed 0 holding a signed
cumulative cheque.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ll an

in-flight POST twice when reconciling

Two more from pushing 128 KiB - 4 MiB through the metered relay. Both scale
with upload size, which is why smaller test payloads missed them.

**A lane over its line on reservations was parked for good.** The relay
refuses on `owed + reserved`, but only `owed` is payable — reservations are
bodies it is still reading. So a lane can be over its line while its debt
is under the dust floor, which is not a disagreement and not something a
cheque fixes: those bytes have to land. A 402 marks the lane `Unfunded` and
only a successful settle re-funds it, so with nothing payable the lane
stayed parked and the upload ended with chunks pending and the relay
reporting err=0 — 2 MiB stopping at 311 of 567 frames. The driver now
separates busy from stuck: bytes in flight re-funds the lane, nothing in
flight and nothing payable still warns. It cannot spin, because
`has_headroom` binds on `outstanding` and dispatches nothing until those
bytes clear.

**Reconciling mid-flight billed a POST twice.** `adopt_relay_debt` took the
relay's `owed` raw, but a body the relay has finished reading is already in
that figure while locally it is still `pending` until its response closes —
so `record_answered` then added the same bytes again. The run ended with
`cheque credits 535680000000 but only 510240000000 is owed` and settlement
jammed. It now deducts what is in flight; under-adopting is safe, since the
remainder is still owed and the next settle collects it. The docstring
already argued for this and the code did not do it.

Verified against the relay on an amd64 VPS — every frame acked at every
size, no unpayable refusals, no rejected cheques, relay ledger settling to
owed 0:

   128 KiB    43/43     402=0  512 KiB   151/151   402=0
   256 KiB    76/76     402=0    1 MiB   290/290   402=2
     2 MiB   567/567    402=2    4 MiB  1122/1122  402=4

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y when

it booked less than we billed

Found by putting the relay behind a real reverse proxy on a public HTTPS
name, which is the first time the client met a broken response stream.

**A broken stream made every later cheque bounce.** §7.3's ack-tail cuts
both ways: a POST whose response stream breaks was still read, so the
client bills it — but if the relay's task is cancelled before it commits,
its `Admitted` guard releases the reservation and it books nothing. The
client cannot tell the cases apart, so it over-counts, and the overshoot
rides on the cumulative: every later cheque carries it and is refused for
the same reason, so the lane never settles again (`cheque credits
212640000000 but only 148800000000 is owed`). The relay decides what it
accepts, so the client now yields to it — re-read /v1/account, take that
figure, re-present once, bounded so a refusing relay cannot loop us.
`forgive_phantom_debt` was already the total-loss case of this.

**The first POST of a run was sized before the debt was known.** §17.1 made
carried debt recoverable but did not stop the client walking into it: a
fresh process believes it owes nothing and sizes against the whole credit
line while the relay already holds part of it. If the carried debt happens
to sit below the dust floor, the recovery has nowhere to go — 16 of 567
frames, 152,160,000,000 carried against a 416,771,800,039 line. One GET per
lane at setup makes every later size correct; §17.1 stays as the mid-run
recovery.

Sizing also has to be recomputed per *dispatch*, not per pass of the driver
loop — the inner loop hands out several assignments in a row, and a ceiling
refreshed only on the outer pass sizes the second and third as if each were
alone on the lane. That was the remaining source of unpayable 402s.

Verified over public HTTPS through Caddy, relay on an amd64 VPS with a
pinned identity. Every size completes every frame with no 402s at all, and
three back-to-back 2 MiB runs each settle to owed 0 so the next carries
nothing:

  256 KiB   76/76    1 MiB  290/290   2 MiB  567/567   4 MiB  1122/1122
  402s=0    stuck=0  stream errors=0  rejected cheques=0

402 is the recovery path now, not the mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… lane

Payment is a property of a relay, not of the fleet. A client should be
able to point at a mix of `open`, soft-metered and hard-metered lanes and
use whichever subset it can be served by — but `hard_enforcement` was
parsed into `LaneInfo` and then read by nobody, so an unpaying client
scheduled work onto a lane that answers 401 for the missing challenge.
A 401 is not a 402: it charges lane health and burns one attempt per
chunk rediscovering something the lane advertised before the first byte
moved.

Both drivers now stand down from a hard lane they cannot pay:

- native (`src/client.rs`): lanes advertising hard enforcement with no
  chequebook configured are retired before the first dispatch, and a run
  where *every* lane is hard fails immediately with that reason instead
  of grinding through retries.
- browser (`UploadSession::set_lane_status`): retires unconditionally —
  the dApp only stamps, the chequebook lives in the native client.
  `setLaneStatus` now returns whether the lane was scheduled, and the
  worker logs the ones it skipped.

Soft-metered lanes are deliberately kept by both: they bill and serve, so
an unpaying client is served exactly as on `open`.

The browser wasm build was broken as a side effect of the incentive work
— `LaneInfo::quote` names `crate::payer`, which is behind the `pusher`
feature that the dApp's `--no-default-features` build turns off, and the
wasm `setLaneStatus` literal was missing the new fields. The quote field
is now gated to match its type; `hard_enforcement` and the price stay
unconditional, because recognising a lane you cannot pay is exactly the
thing a client without the payment stack still needs to do.

Adds `pusher.browserbzz.link` to PUSHER_URLS — the first metered
production lane, and the only one that doesn't cold-start. The three
Render lanes stay `open` (verified: no `payment` block in /v1/status);
they're short-lived free tiers, and §5 requires durable storage for
metering anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ility to pay one

`cargo build --no-default-features --features cli` — the config Cargo.toml
documents for compiling out `bridge` — has not compiled since the incentive
layer landed. `client.rs` reaches for `crate::payer`, `crate::meter` and
`crate::metered` on every relay push, but all three sat behind `pusher`,
and `pusher` is the *relay* feature: it exists to pull in hyper.

The split was in the wrong place. A client paying a metered relay needs the
challenge wire format, the pricing arithmetic and the payer; it needs no
server. So `challenge`, `meter` and `payer` become plain `not(wasm32)`,
while `ledger`, `inbound_limit`, `metered` and `pusher` — the ledger the
relay bills against and the state machine defending it — stay behind the
feature.

Breaking the last client→relay edge meant moving the `x-hoverfly-challenge`
codec (`CHALLENGE_HEADER`, `IssuedChallenge`, `PresentedChallenge`,
`encode_challenge_header`) out of `metered.rs` into `challenge.rs`. That is
where it belonged anyway: `challenge.rs` already owns the MAC, the preimage
and the verifier, and a format defined by only one of the two ends that
speak it is a format that drifts.

`hoverfly cashout` moves behind `pusher` with them — it reads the relay's
ledger, so without a relay there is nothing to cash.

Verified: default features 236 tests, `--no-default-features --features
cli` 186 tests (previously would not build), and the dApp's
`--no-default-features` wasm build.

Also drops `.claude/settings.local.json.tmp.*` from the tree and ignores
the directory: a `git add -A` swept one in, and those files quote whole
shell commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not by the chequebook

§2 justified the one-directional trust model with "relays are a curated,
pinned set — four URLs, all ours". That is not true of the system, only of
one client's configuration. A relay is a plain HTTP service: there is no
registry, no discovery and no list to be admitted to, and any client can
point `--pusher` at any URL. `PUSHER_URLS` is the dApp's default fleet, not
a federation roster.

The real asymmetry is **pinning, not curation** — a client verifies the
signed quote and pins `(url, node_eth_address, beneficiary)` before sending
a byte, while a relay gets whoever POSTs. That still points every relay-side
defence at the client, so nothing downstream of §2 changes. What it does
change is how much a relay's own assertions may be trusted.

`LanePayer::reconcile` adopted whatever `owed` the relay reported, bounded
only by the chequebook balance. Under the curated premise that was
defensible. Under the real one it is not: any lane a client points at could
answer an unpayable 402 by naming the entire chequebook, and `settle` would
sign for it. The advertised worst case is one credit line — ~$0.0024; the
actual one was the funded balance.

The sound bound is the quote's `max_outstanding_plur`. Admission refuses
whenever `outstanding + reserve > cap`, and §10.3 makes every per-batch cap
`min(value / credit_ratio, ceiling)` — so no debt can legitimately stand
above the ceiling. Unlike the per-batch line it is a constant of the signed
quote and does not shrink as the batch decays, which is what §17.1 rejected
the line for. The balance check stays as a looser second gate.

Split the decision out as `check_reported_debt` so it is testable without a
server, with regression tests for both gates.

Also corrects §2, §7.3, §8.4, §15 and §17.1, which all inherited the
curation premise, and replaces "governed socially: the lane is removed from
PUSHER_URLS" with the four bounds that actually protect a client — self-
computed bill, pinned price, capped exposure, measured outcomes.

Adds docs/pusher-incentives-slides.md: a 41-slide Marp deck covering the
design and the six bugs from §17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bullets read like excerpts from the design doc, because that is what
they were. Slides get glanced at while someone is talking, so a phrase like
"gas-amortizing by construction" or "the invoice was set-valued" costs the
reader the sentence the speaker is currently saying.

Every bullet now leads with what happens, in ordinary words, and names the
mechanism second. All the numbers stay — this is a legibility pass, not a
vagueness one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Subtitle is now what the deck is about rather than a tagline. Two headings
along with it: the problem slide says the relay pays for traffic it did not
cause, and "What changes, precisely" drops the self-congratulation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bullets got fixed last pass; the headings and body text had the same
problem. Slogan headings became statements ("Derive the credit line, don't
assert it" -> "Credit scales with what the batch is worth"), and the prose
lost the shorthand that only makes sense if you already know the design:
"session- and RTT-bound", "per-identity volume oracle", "the precise
unauthenticated amplification surface", "monotone debit counter".

Every figure stays. The point was that the compressed phrasing was hiding
the mechanism, not carrying it.

Adds docs/deck2md.py and makes the markdown a generated file. Keeping the
two decks in step by hand meant applying every copy edit twice, which is
how they drift — the HTML is now the source, and the markdown is derived
from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Had the pipeline backwards. md4c's md2html is installed, so the markdown can
be the source and the HTML generated from it — which is the right direction
anyway: the markdown is what gets copy-edited, and it is what marp-cli renders
to PDF unchanged.

docs/deck/build.py splits the deck on slide breaks, hands each one to
md2html --github, and wraps the result in the deck shell (CSS, nav, print
rules). md2html does the inline markup, GFM tables and column alignment; the
script only assembles. Four HTML-comment directives carry what markdown has no
syntax for — title card, section divider, eyebrow label, hazard callout — and
every markdown renderer ignores them.

Blockquotes become the accent callout, so a plain reading of the markdown and
the rendered deck emphasise the same lines.

Drops docs/deck2md.py, which went the other way. Output verified against the
hand-written HTML: identical slide, callout, eyebrow and table counts, and
99.4% token-identical text (the difference is slide numbers, which the old
file filled in via JS).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restructured rather than trimmed. The deck now follows the argument's spine:
problem, who trusts whom, the billing unit, admission, credit, settlement,
economics, deployment, the bugs, results.

Cut whole slides that were interesting but not load-bearing — the SWAP
borrow/drop tables, the endpoint list, the soft-mode rollout, dedup-at-zero,
the per-payee cheque gotcha — and folded the rest into the slide that needed
them. Every figure that carried an argument survived; the ones that only
carried detail did not.

Also adds per-slide type auto-fit. The stage is a fixed 16:9 box with
overflow hidden, and slides now vary a lot in density, so a dense one could
silently lose its last line. Type scales through a --fit multiplier that the
JS lowers until the slide fits; slides with room to spare are untouched.

The 41-slide version is in this branch's history if the long form is ever
wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tent crop

Two problems, one of which was mine.

The cropping: .slide is a column flexbox, and flex items shrink to fit by
default. So a slide with too much content had its children squeezed rather
than overflowing, which made scrollHeight equal clientHeight — and the
auto-fit concluded everything fitted while the content was visibly crushed
and clipped. Children are now flex: none, so the measurement is honest, and
fit() also checks each child's own overflow. It re-measures after layout
settles and again once fonts load, since the first pass can run against
fallback metrics.

The text: slides were carrying a paragraph *and* a table *and* another
paragraph *and* a callout. Now the budget is a heading, one block, one
callout — about 64 words a slide, down from ~110. Dropped the settings table,
the second economics table, the per-bug conditions paragraph, and a good deal
of connective prose. The header comment records the budget so the next edit
does not quietly undo it.

Also scales the table-header and paragraph-spacing sizes with --fit; they
were staying large while everything around them shrank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Who has to trust whom" is chatty where "Trust model" is the term, and
the deck had no single rule for what a heading is: some stated a claim,
some asked a question, some were a gerund plus a colon.

One rule now — the heading names the mechanism and the body makes the
claim about it, which is where there is room to say it precisely. The
eyebrows stop restating the heading ("5 · credit" over "How much a
client may owe") and mark the act instead, restoring the theory/practice
split the deck was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"This works on repeat and bulk uploads, or not at all" was a riddle. It
meant §9.3 — one cashout costs ~$0.0005 of gas and is paid once per
account, so an account that never returns is written off — and slide 8
already says that with the numbers. Slide 2 is the problem statement, so
it now just sizes the problem: the egress a relay absorbs, and what
metering would bill for it.

Slide 3 spent a lead-in, two bullets and a callout to establish that a
relay is a standalone HTTP service. Same content, one paragraph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slide 8 was built on two figures that measurement contradicts. Egress
amplification was modelled at 3.7x; the metered lane reports 1.151x, so
three times the payload fits under a given bandwidth allowance. And gas
was carried at $0.0005 per cashout, assuming 300k gas at gwei prices —
the two real cashouts used 75k and 110k gas at 169 and 1292 wei, which
is a fee of ~1e-10 xDAI. Six orders of magnitude.

Both errors pointed the same way, so the conclusion flipped. The slide
had it as a per-GiB loss that only flat-rate bandwidth could excuse. On
a host whose cost is already sunk it is closer to all margin, and what
actually bounds it is the bandwidth allowance: ~$10/mo under a 2 TB cap
at the modelled ratio, ~$33 at the measured one.

That is a ceiling, not a floor, so slide 11 now carries what has really
been earned — $0.00006 cashed, one paying client, and it was me.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two corrections pointing opposite ways.

§9.3 carried $0.0005 per cashout, from the 300k gas *limit* at gwei-scale
prices. The two real cashChequeBeneficiary calls used 75k and 110k gas at
169 and 1292 wei — about 1e-10 xDAI, six orders of magnitude cheaper. The
"a one-shot user is not profitable" corollary was downstream of that
number and is withdrawn: a 71 MB upload worth $0.0014 clears its own
settlement fee by seven orders of magnitude. The 0.25 BZZ threshold is a
batching convenience, not a break-even, and should come from live gas.

The other way: `egress.attempts_per_frame` reports 1.077 against a
modelled 3.45, and I took that as a measurement. It is not one.
PUSH_OUTCOME_* is bumped after the await inside the racing future, and
the dispatcher cancels the losers the moment it takes a receipt — their
Delivery is already on the wire, so the egress is paid, but the increment
never runs. ok=3756 against frames_admitted=3756 is the tell: exactly one
counted success per chunk. The metric floors near 1.0 by construction.

So §9.1's model stands unverified, the Stage 0 gate that was supposed to
settle it is not met, and the comments in meter.rs and pusher.rs that
claimed losing racers were counted are the reason this went unnoticed.
Fixed those too. Counting at dispatch, beside inflight_pushes, is the fix.

Slides follow the doc: the ceiling is ~$10/mo under a 2 TB cap, not the
$33 the withdrawn measurement implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified every figure on the slides against the doc, the code, the live
relay and Gnosis. The numbers hold — $0.0003 billed matches the relay's
own `owed_usd`, and the $0.00006 cashed is the sum of two on-chain
`cashChequeBeneficiary` transfers. What did not hold:

Doc and code:

- §9.3's table had the two cashouts' dates swapped. 109 590 gas at
  1 292 wei is 08-07; 75 378 at 169 wei is 08-08. Added the chequebook
  and tx hashes so the row is checkable rather than asserted.
- §9.2 priced per-GB clouds at $0.33/GiB in prose and $0.36 in the table
  directly above it — the prose had dropped the GB→GiB conversion.
- §15 still carried "one-shot users do not cover their own cashout gas"
  as a residual risk, which is the exact claim §9.3 withdraws. Replaced
  with the risk that actually survives: a threshold pinned to a gas cost
  that no longer holds.
- §17's intro said "all three survived the test suite" in a section
  about six bugs.
- `meter.rs` asserted the withdrawn claim twice more, on
  CASHOUT_THRESHOLD_PLUR and in the module header.

Slides:

- The unit-economics callout blamed the inversion on going "past the
  cap". $0.36/GiB is AWS on-demand egress, not an overage rate; §9.2's
  point is that metered mode only works on bandwidth nobody bills by the
  gigabyte.
- The problem slide read 70–100 GB as consumption. Design §6 quotes it
  as the free tier's allowance — a ceiling. Its table also said the
  relay pays bee nothing directly under a lead saying bee bills the
  relay; the debt is real, pseudosettle just pays it in time.
- The credit-limit formula dropped the `min()` with the global ceiling,
  so "a thousandth at any batch size" overstated a bound that is only
  ever tighter.
- The trust-model callout claimed the client needs no defences. §2 lists
  four bounds that protect it and says outright it is not unprotected.
- §17.4's row inverted the actor: the client was refused, and it parked
  the lane.
- "All six needed the same three things at once" is stronger than §17,
  which states the three as the union across the set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmt -- --check` is a CI step and this branch had never been run
through it. No behaviour change — every hunk is rustfmt's own line
breaking, and every file it touched is one the branch already added or
modified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant