Metered relay mode: pay a pusher for the bandwidth it spends on you - #33
Open
v1rtl wants to merge 27 commits into
Open
Metered relay mode: pay a pusher for the bandwidth it spends on you#33v1rtl wants to merge 27 commits into
v1rtl wants to merge 27 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdis the design;docs/pusher-incentives-slides.mdis 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
metered.rs,ledger.rs,challenge.rs. Admission decidesbefore 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/pushreadsno chain state at all. Chain lookups happen once per challenge, not once
per POST, which keeps the unauthenticated amplification surface closed.
payer.rs,cheques.rs,protocols/swap.rs. CumulativeEIP-712 cheques, per-lane accounting, POSTs sized to live headroom.
hoverfly chequebook deploy/fund/statusandhoverfly cashout.meter.rs. Counts what a metered relaywould have billed while changing nothing on the wire, so the parameters
can be checked against real traffic before enforcement.
min(remaining_value ÷ 1000, ceiling). "Owns a live batch" provesnothing 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
openlanes and one hard-metered one in the same
PUSHER_URLS, each client usingthe 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.linkruns metered with hard enforcement. Uploads of128 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: 0on the relay after each.Real money to date is $0.0003 billed and $0.00006 cashed across two
cashChequeBeneficiarycalls, from one paying account — mine. Themechanism works; nobody is paying it yet.
Worth reviewing closely
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.
Inherited from SWAP; hard deposits work but are inert as bee deploys them.
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.
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