Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9e6adce
feat(pusher): metered relay mode + Stage 0 shadow metering
v1rtl Aug 6, 2026
634d4c8
feat(client): wire the metered payment loop
v1rtl Aug 7, 2026
1d76987
fix(pusher): bound the /v1/pay body read; make the reservation an RAI…
v1rtl Aug 7, 2026
7a081bd
feat(cli): hoverfly chequebook deploy/fund/status; size POSTs to the …
v1rtl Aug 7, 2026
74781b2
fix(metered): one-round-trip chequebook reads; correct client-side ac…
v1rtl Aug 7, 2026
13ae636
feat(cli): hoverfly cashout — redeem cheques a metered relay accepted
v1rtl Aug 7, 2026
4ca9fe6
fix(metered): dedup acks are marked, so client and relay stay in step
v1rtl Aug 7, 2026
43e0097
fix(metered): pay debt the relay carried across sessions, and stop
v1rtl Aug 8, 2026
adf6537
docs(incentives): §17 — the two bugs only a persistent relay ledger f…
v1rtl Aug 8, 2026
5b05a9e
fix(metered): resolve §10.1's thresholds against the credit line that
v1rtl Aug 9, 2026
1c473bc
fix(metered): resume a lane refused for bytes in flight, and don't bi…
v1rtl Aug 9, 2026
6a5a436
fix(metered): learn carried debt before sizing, and yield to the rela…
v1rtl Aug 9, 2026
50dd2d9
feat(pushers): make paying optional per lane, and add the metered VPS…
v1rtl Aug 10, 2026
5d657b1
fix(build): the relay's cargo feature no longer gates the client's ab…
v1rtl Aug 10, 2026
07ba8d5
fix(metered): bound a relay's claimed debt by the ceiling it signed, …
v1rtl Aug 11, 2026
6d7198c
docs(slides): rewrite the bullets in plain language
v1rtl Aug 11, 2026
19acacc
docs(slides): plainer title and headings
v1rtl Aug 11, 2026
964148f
docs(slides): plain-language sweep over headings, callouts and prose
v1rtl Aug 11, 2026
4def682
docs(deck): markdown is the source, HTML is built from it
v1rtl Aug 11, 2026
6dfb558
docs(deck): cut 41 slides to 10
v1rtl Aug 11, 2026
a3725a2
docs(deck): sparser slides, and fix the auto-fit that was letting con…
v1rtl Aug 11, 2026
ab44500
docs(deck): headings name the mechanism, eyebrows mark the act
v1rtl Aug 14, 2026
3362f30
docs(deck): say what the money slide meant, and cut slide 3 down
v1rtl Aug 14, 2026
c1c900c
docs(deck): reprice the economics against measured numbers
v1rtl Aug 14, 2026
aa6d5f2
docs: correct §9.3's gas, and withdraw the egress "measurement"
v1rtl Aug 15, 2026
8a0ef05
docs: fact-check pass over the deck and its source
v1rtl Aug 22, 2026
b6a3b52
style: cargo fmt
v1rtl Aug 22, 2026
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
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ peers.json
# Overlay identity nonce written to CWD by CLI runs (default `--nonce-file`).
# A runtime artifact, like peers.json — never commit it.
overlay-nonce
# Cumulative payouts issued to bee peers and metered relays, written to CWD
# by paid uploads. Runtime money state: committing it would publish who was
# paid what, and restoring a stale copy re-issues cheques the counterparty
# has already banked.
cheques.json
# Agent-local tool config. `settings.local.json` itself is usually covered by
# a global ignore, but the editor writes `*.tmp.<pid>.<hash>` siblings that
# such a pattern misses — and those files quote whole shell commands, so a
# `git add -A` mid-session can publish a key that was passed on a command
# line. Ignore the whole directory.
.claude/
# `peers.seed.json` is checked in: an IP-diverse cold-start seed
# harvested from a long-running daemon (~800 peers across ~800 unique
# /32 IPs as of the last refresh; regenerate via
Expand Down
12 changes: 11 additions & 1 deletion apps/upload/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,24 @@
*
* Chunks are sharded across the lanes (rendezvous hashing) and pushed
* concurrently; a chunk unacked by one lane fails over to the next.
*
* Payment is per lane and optional (docs/pusher-incentives.md). A lane
* advertising `enforcement: "hard"` in its `/v1/status` is dropped from
* rotation at startup by `setLaneStatus`, because this dApp only stamps —
* the chequebook lives in the native client. Such a lane is listed anyway so
* a native `--chequebook` run of the same fleet picks it up.
*/
export const PUSHER_URLS: string[] = [
'https://hoverfly-pusher.onrender.com',
'https://hoverfly-pusher-2.onrender.com',
'https://hoverfly-pusher-3.onrender.com',
// Hugging Face Space — a different provider/IP-range from Render (probed:
// HF permits outbound TCP to bee nodes), for cross-provider lane diversity.
'https://ivam5567-hoverfly.hf.space'
'https://ivam5567-hoverfly.hf.space',
// A self-hosted VPS lane, and the first metered one: it runs `--meter` with
// hard enforcement, so it serves paying native clients and is skipped by
// the browser. Unlike the free tiers above it doesn't cold-start.
'https://pusher.browserbzz.link'
]
/**
* How long to wait for a relay's `/v1/status` before scheduling it on
Expand Down
23 changes: 20 additions & 3 deletions apps/upload/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,12 @@ interface UploadSession {
readonly failed: number
readonly hedges: number
readonly done: boolean
/** Feed a lane's /v1/status JSON (pool size, batch_max, budget, overlay). */
setLaneStatus: (lane: number, status: unknown) => void
/**
* Feed a lane's /v1/status JSON (pool size, batch_max, budget, overlay).
* False when the lane was retired instead of scheduled — it enforces
* payment and this build has no chequebook.
*/
setLaneStatus: (lane: number, status: unknown) => boolean
/** Next POST to issue, or undefined if nothing is dispatchable now. */
nextRequest: (nowMs: number) => PushRequest | undefined
/** One streamed NDJSON ack. Idempotent per address (hedges rely on this). */
Expand Down Expand Up @@ -375,10 +379,23 @@ async function pushSession (session: UploadSession, lanes: string[]): Promise<st
// Warm the scheduler with each lane's advertisement (pool size, batch_max,
// budget) before the first dispatch, so weights start from measurements
// rather than priors. Lanes that don't answer are simply left on defaults.
//
// `setLaneStatus` returns false for a lane it retired rather than
// scheduled — a relay that *enforces* payment, which this build cannot
// make (the chequebook lives in the native client; the browser only
// stamps). Paying is optional across the fleet, so free, soft-metered and
// hard lanes can all sit in PUSHER_URLS and each client uses the subset it
// can actually be served by.
let usable = 0
await Promise.all(lanes.map(async (u, i) => {
const st = await fetchLaneStatus(u)
if (st !== undefined) session.setLaneStatus(i, st)
if (st === undefined) { usable++; return } // asleep, not refusing — keep it
if (session.setLaneStatus(i, st)) usable++
else log(`Pusher ${u} requires payment; skipping it (browser uploads are unpaid).`)
}))
if (usable === 0) {
throw new Error('every relay in PUSHER_URLS requires payment — the browser cannot pay')
}

const pushUrls = lanes.map(u => `${u.replace(/\/+$/, '')}/v1/push`)
let lastPost = 0
Expand Down
141 changes: 141 additions & 0 deletions docs/deck/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Build the slide deck HTML from the Marp markdown.

python3 docs/deck/build.py docs/pusher-incentives-slides.md -o /tmp/deck.html

The markdown is the source. It stays a valid Marp deck — `marp-cli` renders it
to PDF unchanged — and this script produces the self-contained HTML version
with keyboard navigation. Inline markdown, GFM tables and column alignment are
handled by `md2html` (md4c), not by anything here.

Four directives, all HTML comments, so every markdown renderer ignores them:

<!-- title --> this slide is the title card
<!-- part: Part one --> section divider, with that kicker
<!-- eyebrow: Theory --> the small label above the heading
<!-- hazard --> the next blockquote is the warning colour

Blockquotes become the accent callout. Tables are wrapped so wide ones scroll
inside the slide rather than pushing the page sideways.
"""
import argparse
import html
import pathlib
import re
import shutil
import subprocess
import sys

HERE = pathlib.Path(__file__).parent


def render(md: str) -> str:
"""Markdown fragment -> HTML, via md2html."""
if not md.strip():
return ""
out = subprocess.run(
["md2html", "--github"], input=md, capture_output=True, text=True, check=True
).stdout
# Wide content scrolls in its own box; the slide itself never does.
out = re.sub(r"(<table>.*?</table>)", r'<div class="scroll">\1</div>', out, flags=re.S)
return out.strip()


def slide(body: str, index: int) -> str:
"""One markdown slide -> one <section>."""
title = "<!-- title -->" in body
part = re.search(r"<!--\s*part:\s*(.*?)\s*-->", body)
eyebrow = re.search(r"<!--\s*eyebrow:\s*(.*?)\s*-->", body)
body = re.sub(r"<!--\s*(title|part:.*?|eyebrow:.*?)\s*-->", "", body)

# `<!-- hazard -->` tags the blockquote that follows it. md2html drops
# comments, so the flag rides through as a sentinel inside the quote.
body = re.sub(r"<!--\s*hazard\s*-->\s*\n+>", "> @@HZ@@", body)
out = render(body)

# Blockquote is the deck's callout. Unwrap its paragraph so the callout is
# one styled block rather than a quote wrapping a paragraph.
def callout(m: re.Match) -> str:
inner = re.sub(r"</?p>", "", m.group(1)).strip()
cls = "claim"
if inner.startswith("@@HZ@@"):
cls, inner = "claim hz", inner[len("@@HZ@@"):].strip()
return f'<div class="{cls}">{inner}</div>'

out = re.sub(r"<blockquote>(.*?)</blockquote>", callout, out, flags=re.S)

num = f'<div class="num">{index:02d}</div>'

if title:
h1 = re.search(r"<h1>(.*?)</h1>", out, re.S)
h2 = re.search(r"<h2>(.*?)</h2>", out, re.S)
meta = re.search(r"<p><em>(.*?)</em></p>", out, re.S)
return (
'<section class="slide title on">\n'
' <div class="rule"></div>\n'
f" <h1>{h1.group(1) if h1 else ''}</h1>\n"
f' <p class="lede sub">{h2.group(1) if h2 else ""}.</p>\n'
f' <div class="meta">{meta.group(1) if meta else ""}</div>\n'
" </section>"
)

if part:
h1 = re.search(r"<h1>(.*?)</h1>", out, re.S)
return (
'<section class="slide part">\n'
f' <div class="kicker">{html.escape(part.group(1))}</div>\n'
f" <h2>{h1.group(1) if h1 else ''}</h2>\n"
f" {num}\n"
" </section>"
)

# A slide's `#` heading is an <h2> visually — <h1> is reserved for the deck.
out = re.sub(r"<h1>(.*?)</h1>", r"<h2>\1</h2>", out, flags=re.S)
eb = (
f'<div class="eyebrow">{eyebrow.group(1)}</div>\n '
if eyebrow
else ""
)
# Everything that is not a heading, table, code block or callout is prose.
out = re.sub(
r"((?:<(?:p|ul|ol)>.*?</(?:p|ul|ol)>\s*)+)",
lambda m: f'<div class="body">{m.group(1).strip()}</div>',
out,
flags=re.S,
)
return f'<section class="slide">\n {eb}{out}\n {num}\n </section>'


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("source", type=pathlib.Path)
ap.add_argument("-o", "--output", type=pathlib.Path, required=True)
args = ap.parse_args()

if not shutil.which("md2html"):
print("md2html not found (md4c). Install it, or render with marp-cli.", file=sys.stderr)
return 1

text = args.source.read_text()
# Drop the Marp front matter; it configures marp-cli, not this.
text = re.sub(r"\A---\n.*?\n---\n", "", text, flags=re.S)
title = "Paying for relay — an incentive layer for hoverfly pushers"

slides = [s for s in re.split(r"\n---\n", text) if s.strip()]
sections = "\n\n ".join(slide(s, i + 1) for i, s in enumerate(slides))

args.output.write_text(
f"<title>{title}</title>\n\n"
+ (HERE / "shell.css.html").read_text()
+ f'\n<div class="stage" id="stage">\n\n {sections}\n\n</div>\n\n'
+ (HERE / "shell.bar.html").read_text()
+ "\n"
+ (HERE / "shell.js.html").read_text()
+ "\n"
)
print(f"{len(slides)} slides -> {args.output}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
7 changes: 7 additions & 0 deletions docs/deck/shell.bar.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="bar">
<button id="prev" aria-label="Previous slide">←</button>
<button id="next" aria-label="Next slide">→</button>
<div class="track"><div class="fill" id="fill"></div></div>
<span class="counter" id="counter"></span>
</div>

Loading