weed-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,870 @@
1
+ Metadata-Version: 2.4
2
+ Name: weed-cli
3
+ Version: 0.1.0
4
+ Summary: Censorship-resistant video PoC — discovery/hosting/download over signed relay events, a real Kademlia DHT, or a TLS-capable NAT-traversal tunnel
5
+ License: MIT
6
+ Keywords: p2p,video,censorship-resistant,discovery,dht,kademlia,nat-traversal
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Topic :: Communications :: File Sharing
13
+ Classifier: Topic :: Internet
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: btcvm>=1.3.7
17
+ Requires-Dist: cryptography>=41.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == "dev"
20
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
21
+ Requires-Dist: ruff>=0.5; extra == "dev"
22
+ Provides-Extra: dht
23
+ Requires-Dist: kademlia>=2.2; extra == "dht"
24
+ Provides-Extra: qr
25
+ Requires-Dist: qrcode>=7.4; extra == "qr"
26
+ Provides-Extra: viz
27
+ Requires-Dist: matplotlib>=3.8; extra == "viz"
28
+
29
+ # Censorship-resistant video platform — PoC notes
30
+
31
+ Brainstormed in #all-pdx 2026-08-22: a YouTube replacement indexed on Bitcoin,
32
+ distributed over BitTorrent-style magnet links. This tracks what got built and
33
+ what was actually learned, not just the idea.
34
+
35
+ ## The core design problem
36
+
37
+ Storage and delivery over BitTorrent-plus-a-chain-timestamp is the easy part —
38
+ solved plumbing. The two things that actually kill projects like this:
39
+
40
+ - **Incentives.** LBRY minted a token and got sued as an unregistered security.
41
+ BitTube minted a token and died to the standard watch-to-earn Ponzi spiral.
42
+ PeerTube minted nothing and stayed permanently niche — no incentive layer at
43
+ all, purely volunteer-hosted. Anchoring to *existing* BTC (no new token) and
44
+ paying for service directly over Lightning avoids both failure modes at once.
45
+ - **Discovery.** A single global index or "most popular" list is exactly as
46
+ seizable as YouTube's own trending page. The answer that doesn't reintroduce
47
+ a chokepoint: no canonical list at all — gossiped signals (payments, in this
48
+ case), any number of independent, replaceable indexer apps computing their
49
+ own view, Nostr-style. Not built yet, just designed.
50
+
51
+ ## What's built
52
+
53
+ ### `poc_challenge_auction.py` — possession-gated reverse auction
54
+
55
+ `merkle_root`/`merkle_proof`/`verify_proof` are imported from the real,
56
+ [published](https://pypi.org/project/btcvm/) `btcvm` package (`pip install
57
+ btcvm`) — same functions `ott verify-chunk` runs locally. Used to be
58
+ vendored copies (kept this repo dependency-free before btcvm was on PyPI);
59
+ now that it's real and installable, this repo depends on it properly
60
+ instead. `ott` is the storage/archive layer — what you have and can prove
61
+ you have; this repo is the distribution/incentive layer built on top of
62
+ it. Deliberately kept as separate packages rather than merged: `ott` stays
63
+ the stable, already-published tool with real users, this stays free to be
64
+ a rougher-edged PoC without dragging Lightning/Docker into `ott`'s
65
+ dependency surface.
66
+
67
+ In-process simulation, five peers, one 8-chunk file.
68
+
69
+ **Part 1 — chunk-index challenge + auction.** A naive price-only auction picks
70
+ the cheapest bidder regardless of whether they can deliver — and does, in the
71
+ run captured here (a peer holding zero chunks wins on price alone). Gating bid
72
+ eligibility on passing a random chunk-index + Merkle-proof challenge fixes it:
73
+ a peer with nothing gets caught every round; a peer that tampers its response
74
+ ~70% of the time gets caught most rounds and wins once by pure luck — real
75
+ evidence that a single spot-check isn't airtight, only a statistical one is.
76
+ A legitimately partial holder (only half the chunks) correctly sits out
77
+ rounds for chunks it doesn't have, without being flagged dishonest.
78
+
79
+ **Part 2 — nonce-salted challenge + timing bound.** Knowing a file's public
80
+ SHA256 gives an attacker nothing — SHA256 preimage resistance means you can't
81
+ derive `hash(chunk||nonce)` from `hash(chunk)` alone, so a peer with only the
82
+ published hash can't even attempt a response. A peer that *does* have the
83
+ real bytes but fetches them from someone else in real time (relaying) answers
84
+ with a cryptographically **correct** hash every time — the nonce alone can't
85
+ catch that. Only an added timing bound can, because the relay hop costs real
86
+ latency a local holder never pays.
87
+
88
+ ### `poc_network_challenge.py` — the same mechanism over real sockets
89
+
90
+ Takes the timing-bound claim off paper: real TCP, real OS subprocesses, real
91
+ `time.perf_counter()`, loopback (127.0.0.1). Three modes:
92
+
93
+ - `holder <port>` — actually stores chunks, answers directly
94
+ - `relay <port> <holder_host> <holder_port>` — stores nothing, chains a
95
+ second real TCP connection to the holder to fetch real bytes before
96
+ answering
97
+ - `verify-remote <h_host> <h_port> <r_host> <r_port> [b_host] [b_port]` —
98
+ client mode: connects to already-running holder/relay (e.g. other
99
+ containers) and runs the separation analysis against them, with an
100
+ optional second honest holder as a same-vs-same jitter baseline
101
+ - (no args) — local convenience: single-shot challenge rounds on loopback, narrated
102
+ - `stats` — local convenience: spawns holder+relay as subprocesses on
103
+ loopback, bulk collection (80 real samples per role) + bootstrap analysis
104
+ of how many repeated challenges it takes for the *session mean* to
105
+ reliably separate holder from relay
106
+
107
+ **Real finding, not the expected one:** on loopback, single-shot timing does
108
+ *not* reliably separate a holder from a relay — the honest holder's worst
109
+ recorded round was slower than the relay's best. Averaging repeated
110
+ challenges does separate them (typically somewhere in the k=3–20 range across
111
+ several runs — see below); trust a session average, not any one round.
112
+
113
+ RunPod was down the first time this was tried, so real WAN latency got
114
+ measured against public hosts as a substitute (1.1.1.1, 8.8.8.8,
115
+ api.github.com: 5-30ms real TCP-connect RTT) — suggestive, not conclusive.
116
+ **Update: ran it for real once a RunPod box came back up**, tunneled over a
117
+ real SSH connection (`ssh -L`, since the pod only exposes its SSH port, not
118
+ arbitrary TCP) — local honest holder vs. a local relay that secretly fetches
119
+ from that real remote box for every challenge:
120
+
121
+ ```
122
+ holder: mean 0.254ms min 0.186ms max 1.250ms
123
+ relay: mean 432.648ms min 395.583ms max 638.218ms
124
+
125
+ session size k worst honest mean best cheater mean separated?
126
+ 1 1.250ms 395.583ms YES
127
+ ```
128
+
129
+ ~1700x gap, separates cleanly at k=1 — single-shot is all you need once real
130
+ geographic distance is involved. Confirms the substitute-host hypothesis:
131
+ real WAN distance makes this *easy*; the hard case this PoC actually
132
+ stress-tests is two peers that are genuinely close together, which is
133
+ exactly when a nearby relay is hardest to catch on timing
134
+ alone.
135
+
136
+ ![session-size separation chart](poc_challenge_separation.png)
137
+
138
+ Chart from `viz_challenge_separation.py` — regenerates real measurements each
139
+ run rather than plotting a fixed snapshot. **The exact crossover k is not a
140
+ fixed constant** — it moved between k=3, k=8, and k=20 across different runs
141
+ of this same script on the same machine, purely from real system jitter. That
142
+ instability is itself the finding: don't hardcode a specific k, measure it
143
+ live and adapt, and prefer statistical separation over any fixed threshold.
144
+
145
+ ### `docker-compose.yml` — the same test over real container networking
146
+
147
+ Four services: `holder1` and `holder2` (two independent honest peers),
148
+ `relay` (holds nothing, relays from `holder1` over the compose network),
149
+ `verifier` (runs the same repeated-challenge analysis against all three,
150
+ using DNS service names instead of loopback).
151
+
152
+ ```bash
153
+ docker compose up --build --abort-on-container-exit verifier
154
+ ```
155
+
156
+ Real run, over podman's docker-compose shim, actual separate containers on
157
+ the compose bridge network:
158
+
159
+ ```
160
+ holder: mean 0.246ms min 0.188ms max 0.953ms
161
+ relay: mean 0.483ms min 0.414ms max 1.134ms
162
+ holder2 (2nd honest holder, same-vs-same jitter baseline): mean 0.236ms min 0.209ms max 0.311ms
163
+
164
+ session size k worst honest mean best cheater mean separated?
165
+ 1 0.953ms 0.414ms no
166
+ 2 0.626ms 0.414ms no
167
+ 3 0.712ms 0.421ms no
168
+ 5 0.541ms 0.432ms no
169
+ 8 0.430ms 0.437ms YES
170
+ 12 0.425ms 0.445ms YES
171
+ 20 0.346ms 0.448ms YES
172
+ 30 0.314ms 0.451ms YES
173
+ ```
174
+
175
+ Same shape as loopback (single-shot doesn't separate, k≈8 does), plus one
176
+ useful sanity check the loopback version can't give: holder2's baseline mean
177
+ (0.236ms) sits right next to holder1's (0.246ms) — two equally honest,
178
+ unrelated containers naturally land close together, while the relay
179
+ (0.483ms, roughly double) is a real structural gap, not just inter-container
180
+ noise.
181
+
182
+ ### `poc_reputation.py` — persistent local reputation + signed portable attestations
183
+
184
+ Two mechanisms, both real (Ed25519 via the `cryptography` package, actual
185
+ signing and verification, not simulated):
186
+
187
+ 1. **Local reputation store** (`ReputationStore`, persisted to JSON) — a
188
+ client's own record of direct experience with a peer (passes/fails/avg
189
+ latency), so a known-good peer doesn't need to re-earn trust from zero on
190
+ every interaction.
191
+ 2. **Signed attestations** — a client signs its own verification outcome for
192
+ a peer and hands the signed blob to another client, who didn't do the
193
+ verification but can check the signature and decide how much to trust it.
194
+ Same shape as PGP's Web of Trust, applied to possession-verification
195
+ outcomes instead of key identity — including PGP's actual historical
196
+ weak point: the crypto is the easy part, "how much do I trust this
197
+ signer" is the unsolved UX problem, not a technical one.
198
+ 3. **Revocation** — a signer can kill their own earlier vouch (`sign_revocation`,
199
+ keyed to the attestation's content-hash `attestation_id`). Only accepted
200
+ if the revocation's signer matches the original attestation's signer;
201
+ the revoked attestation stays on record rather than being deleted, so
202
+ "X vouched for Y, then revoked it" stays an honest, auditable fact
203
+ instead of quietly disappearing.
204
+
205
+ Demonstrated for real in one run: a fresh client with zero direct history
206
+ bootstraps a trust score for an unknown peer purely from another client's
207
+ signed vouch; a vouch from a signer you don't trust at all is cryptographically
208
+ valid but contributes zero weight; mutating a signed payload after the fact
209
+ (`passes: 8 → 800`) is caught by signature verification; a 90-day-old
210
+ attestation is worth 0.125x a fresh one under a 30-day trust half-life;
211
+ alice revoking her own vouch drops bob's trust score for that peer without
212
+ bob ever re-verifying it himself; mallory forging a revocation of *alice's*
213
+ vouch (valid signature, wrong signer) is correctly rejected; a revocation
214
+ referencing an attestation nobody's ever seen is rejected too.
215
+
216
+ ### `lightning_settle.py` + `lightning/` — real Lightning HTLC settlement
217
+
218
+ Replaces `poc_challenge_auction.py`'s mock "settlement" print with a real
219
+ one: two real LND nodes (Lightning Labs' production node software) on
220
+ regtest, real bitcoind backing them, a real funded channel between them.
221
+ `poc_challenge_auction.py --lightning` settles every auction round's winner
222
+ with a genuine BOLT11 invoice + HTLC — not simulated, and not just trusting
223
+ LND's own "SUCCEEDED" status: `lightning_settle.py` independently re-hashes
224
+ the revealed preimage and checks it against the invoice's payment_hash
225
+ locally before calling it settled.
226
+
227
+ Real run, 5 winning rounds, real preimages each verified against their own
228
+ payment hash:
229
+
230
+ ```
231
+ WINNER: bob 9 sat preimage 4ac71143706b... payment_hash d1be1130c553...
232
+ WINNER: bob 5 sat preimage eea88d802d1a... payment_hash 8acabea4ddf7...
233
+ WINNER: bob 6 sat preimage 76f1ade0f9be... payment_hash f9c9bda28be0...
234
+ WINNER: bob 10 sat preimage 96e696c1cde9... payment_hash f8eeb66d1878...
235
+ WINNER: mallory 1 sat preimage 508385841cb3... payment_hash 1c2872b6018f...
236
+ ```
237
+
238
+ Bob's cumulative channel balance after the run matched the sum of every
239
+ settled payment exactly, checked directly against LND rather than assumed.
240
+ Full setup steps in `lightning/README.md` — real bitcoind + LND takes a
241
+ one-time channel-funding setup regtest can't skip (mine to coinbase
242
+ maturity, open a channel, mine confirmations) before it's usable.
243
+
244
+ ### `poc_real_archive_challenge.py` — real `.ott` archive, real video, real scale
245
+
246
+ Every other PoC file here used `os.urandom` fake chunks (8 of them).
247
+ This one points the same mechanism at a real 217MB video, archived with the
248
+ real `ott` CLI at a real 64KB chunk size:
249
+
250
+ ```
251
+ real archive: real_video.mp4, 217,831,234 bytes, 3324 real chunks x 65536 bytes
252
+ recomputed Merkle root matches ott's own commit: True
253
+ ```
254
+
255
+ The thing this was actually checking — proof size at real scale:
256
+
257
+ ```
258
+ chunk 0: 12 steps, 396B raw, 1176B as JSON
259
+ chunk 1662: 12 steps, 396B raw, 1168B as JSON
260
+ chunk 3323: 12 steps, 396B raw, 1167B as JSON
261
+ ```
262
+
263
+ 12 proof steps at 3324 real chunks vs. 3 steps at the toy 8-chunk scale —
264
+ exactly log2(N), not linear, confirmed with real numbers instead of just
265
+ trusting the math. Even a 2-hour movie at these settings (~10GB, ~163,840
266
+ 64KB chunks) would only need ~17 steps, still under 1KB. Then ran the same
267
+ nonce-salted-challenge logic from `poc_challenge_auction.py` Part 2 against
268
+ real bytes read straight off disk at real offsets — all 5 real rounds
269
+ checked out: hash matches ott's own committed leaf, Merkle proof verifies,
270
+ nonce response is internally consistent.
271
+
272
+ `real_archive/real_video.mp4` isn't committed to this repo (208MB, and it's
273
+ not this repo's to redistribute) — `real_archive/.ott/`'s metadata is
274
+ tracked, so the chunk list and commitment are there for inspection even
275
+ without the video itself. Reproduce with any file:
276
+
277
+ ```bash
278
+ cd real_archive
279
+ python3 /path/to/btcvm/ott.py init
280
+ # edit .ott/config, set "chunk_size" to whatever you want (65536 used here)
281
+ python3 /path/to/btcvm/ott.py add your_video.mp4
282
+ python3 /path/to/btcvm/ott.py commit
283
+ cd ..
284
+ python3 poc_real_archive_challenge.py
285
+ ```
286
+
287
+ ### `discovery_relay.py` + `poc_discovery.py` — discovery, no canonical index
288
+
289
+ The last unsolved piece from the original brainstorm, actually built: no
290
+ single "trending" list, no server whose seizure kills discoverability.
291
+ Three independent relay processes (`discovery_relay.py` — real stdlib
292
+ `http.server`, no deps), each deliberately dumb: verifies a posted event's
293
+ signature (a relay won't store garbage) but has zero opinion on content
294
+ quality, zero ranking logic. A creator (carol) publishes a real event
295
+ pointing at the real video's real Merkle root from item 6. Viewers like it
296
+ and subscribe to each other, spread across the three relays — nobody posts
297
+ to all three, on purpose.
298
+
299
+ Two clients, `bob` (subscribes to dan + erin) and `mallory` (subscribes to
300
+ frank only), each query all three relays, verify every event's signature
301
+ themselves (never trust a relay's word for it), and compute their own
302
+ ranking from their own subscribe graph — subscriptions *are* the trust
303
+ graph, not a separate feature, same insight from the Slack thread now
304
+ actually running as code:
305
+
306
+ ```
307
+ same 27 gossiped events, both clients saw all 23 likes (3 honest + 20 sybil),
308
+ but scored the content differently — 2.0 (bob) vs 1.0 (mallory) — because
309
+ ranking runs on each client's own trust graph, not vote count.
310
+ ```
311
+
312
+ A 20-identity sybil swarm likes the same content — every signature is
313
+ real and individually valid, a relay has no basis to reject any of them —
314
+ and moves neither client's score, because neither bob nor mallory
315
+ subscribes to any of the sybils. Sybil resistance from the trust graph,
316
+ not from relay-side moderation.
317
+
318
+ Then relay:9101 — the one carol's publish event and dan's like both
319
+ happened to live on — gets killed outright. Real result, not a clean win:
320
+ the content stays discoverable and rankable (erin's like survived on a
321
+ different relay), but the human-readable title and dan's like are gone for
322
+ good, since neither was posted anywhere else. Redundancy has to be
323
+ deliberate — post to more than one relay — it isn't automatic just because
324
+ relays are plural. Same limitation a real Nostr relay dying would have.
325
+
326
+ ```bash
327
+ python3 poc_discovery.py
328
+ ```
329
+
330
+ ### `node.py` — the integration piece: host, discover, download, for real
331
+
332
+ Everything above is a demo of one mechanism at a time. `node.py` (via
333
+ `weed.py host/discover/download/like/subscribe/whoami`) is the actual
334
+ integration: a real node that hosts a real archived file over the real
335
+ wire protocol from `poc_network_challenge.py` (extended with `INFO` and
336
+ `LEAVES` so a downloader can learn the archive's shape first), announces
337
+ itself on a real relay, and — new, not just wired from existing pieces —
338
+ actually downloads a file from a peer and reassembles it on disk, which
339
+ nothing before this verified chunk-by-chunk *and* wrote a real file.
340
+
341
+ A persistent identity now lives at `~/.weed_identity.key` — every other
342
+ script tonight generated a fresh Ed25519 keypair per run, which is fine for
343
+ a demo but means nobody could ever accumulate reputation or be subscribed
344
+ to across invocations. A real node needs a stable pubkey.
345
+
346
+ Real end-to-end run: hosted the real 217MB video, discovered it from a
347
+ separate process, downloaded it to a new path, and diffed the result
348
+ against the original with `cmp` (not just checking the tool's own claim of
349
+ success) — byte-for-byte identical, matching SHA256 on both sides, 3324
350
+ chunks downloaded and verified in 1.3s.
351
+
352
+ Caught a real bug doing this, not a clean pass on the first try: `ott`
353
+ records a video's `sha256` manifest field as the **Merkle root** over its
354
+ chunk hashes (`digest = merkle_root(chunks)` in `ott.py`'s `cmd_add`), not
355
+ a linear whole-file hash — my first version streamed a plain
356
+ `hashlib.sha256()` over the received bytes and compared that, which does
357
+ not and structurally cannot equal a Merkle root. Every individual chunk
358
+ was verifying correctly the whole time; only the final whole-file check
359
+ was comparing the wrong thing. Fixed by recomputing the Merkle root over
360
+ the received leaves and checking it against the host's advertised
361
+ `sha256` — done *before* downloading any chunk, not after, so a host lying
362
+ about its own archive gets caught immediately instead of after wasting
363
+ bandwidth on it.
364
+
365
+ ```bash
366
+ # terminal 1
367
+ python3 discovery_relay.py 9101
368
+
369
+ # terminal 2 — host the video from item 6
370
+ python3 weed.py host real_archive --port 9201 --relay http://127.0.0.1:9101
371
+
372
+ # terminal 3
373
+ python3 weed.py whoami
374
+ python3 weed.py discover --relay http://127.0.0.1:9101
375
+ python3 weed.py download <content_hash_prefix> --relay http://127.0.0.1:9101 --out downloaded.mp4
376
+ python3 weed.py like <content_hash> --relay http://127.0.0.1:9101
377
+ python3 weed.py subscribe <target_pubkey> --relay http://127.0.0.1:9101
378
+ ```
379
+
380
+ `--advertise-host` on `host` matters if you're not on localhost — no NAT
381
+ traversal here, it just tells the relay what address to hand out, real
382
+ reachability is on you. Same point-to-point-known-address limitation
383
+ named earlier in this README, now visible as an actual CLI flag instead of
384
+ just a caveat in prose.
385
+
386
+ ### `download` now runs the actual stack, not just a direct fetch
387
+
388
+ Until this point, `download` trusted whichever host `discover` found
389
+ first, for free, with no possession check and no reputation. That was the
390
+ real gap flagged after the last round of shell bug-fixes: the auction
391
+ (`poc_challenge_auction.py`), the reputation/trust-graph layer
392
+ (`poc_reputation.py`), and Lightning settlement (`lightning_settle.py`)
393
+ were all built and validated standalone, but none of them were reachable
394
+ from a real download. Now they are:
395
+
396
+ 1. **Resolve every host** claiming to have the content (`discover` already
397
+ deduped by event, not by content — `download_with_auction` groups by
398
+ content_hash so a second host publishing the same file actually gets
399
+ considered, not silently dropped).
400
+ 2. **Possession-challenge each one** — sample-FETCH `k` random chunks
401
+ (default 3) and verify against the already Merkle-root-checked LEAVES.
402
+ Scoped deliberately to poc_challenge_auction.py's **Part 1** mechanism
403
+ (chunk-index challenge), not Part 2's nonce/timing relay-detection —
404
+ that one needs ground-truth bytes the verifier already trusts, which a
405
+ first-time downloader doesn't have until *after* this same sampling
406
+ step. Noted here rather than silently narrowed.
407
+ 3. **Auction survivors** by local reputation first, then price — same
408
+ "challenge gates the auction" shape as the original PoC's naive-vs-
409
+ gated comparison. A host with no history starts at 0.0, same as
410
+ everyone; reputation only pulls ahead of price once there's real
411
+ experience behind it (verified — see below).
412
+ 4. **Pay the winner** over a real Lightning HTLC if `--lightning` is given
413
+ and the price is nonzero — same `lightning_settle.py` regtest demo path
414
+ as before. Honest limitation, not glossed over: this settles with the
415
+ fixed alice/bob demo nodes, not a general "pay this specific host's own
416
+ Lightning node" protocol — that would need hosts to serve their own
417
+ real BOLT11 invoices, which isn't built.
418
+ 5. **Download and record** — same chunk-verified `download()` as before,
419
+ then the outcome (pass/fail, latency) gets written to
420
+ `~/.weed_reputation.json` via `ReputationStore.record_direct`, so the
421
+ next auction for this host starts from real history instead of 0.0.
422
+
423
+ Real test, two independent hosts (separate identities, separate `HOME`s so
424
+ podman's rootless state didn't collide) serving the same real video —
425
+ one free, one priced at 500 sat:
426
+
427
+ ```
428
+ found 2 candidate host(s) for 7f2477c7ea675004...
429
+ + 127.0.0.1:9202: possession verified (3/3 chunks), price=0 sat, reputation=0.00, avg_latency=3.4ms
430
+ + 127.0.0.1:9203: possession verified (3/3 chunks), price=500 sat, reputation=0.00, avg_latency=0.7ms
431
+ selected 127.0.0.1:9202 — price 0 sat, reputation 0.00, 3.4ms avg
432
+ ```
433
+
434
+ Ties on reputation, cheapest wins — correct. Then manually seeded
435
+ contrasting reputations (hostA bad, hostB good) to prove reputation
436
+ actually *overrides* price rather than the selection just always
437
+ defaulting to cheapest:
438
+
439
+ ```
440
+ + 127.0.0.1:9202: ... price=0 sat, reputation=0.10 ...
441
+ + 127.0.0.1:9203: ... price=500 sat, reputation=1.00 ...
442
+ selected 127.0.0.1:9203 — price 500 sat, reputation 1.00, 0.4ms avg
443
+ ```
444
+
445
+ Picked the pricier, more trusted host. Then killed the free host outright
446
+ and re-ran with `--lightning`: real HTLC settled (500 sat, preimage
447
+ independently re-verified against the invoice's own `r_hash` via
448
+ `lncli listinvoices`, not just trusting the printed claim), download
449
+ proceeded, byte-identical against the source via `cmp`.
450
+
451
+ ```bash
452
+ python3 weed.py download <content_hash> --relay http://127.0.0.1:9101 \
453
+ --rounds 5 --lightning --out downloaded.mp4
454
+ ```
455
+
456
+ `host --price N` sets what a host charges (sats, default free — `PRICE` is
457
+ a new wire-protocol verb, backward compatible: a host that doesn't
458
+ implement it just gets treated as free by an older/newer client either way).
459
+
460
+ ### Transitive trust — real attestations flow through the subscribe graph
461
+
462
+ The gap named right after the auction landed: `select_host` was only ever
463
+ passed *your own* direct history, never other people's signed vouches, so
464
+ a host you'd genuinely never dealt with always scored a flat 0.0 no matter
465
+ who else had already verified it. `poc_reputation.py`'s attestation/
466
+ revocation machinery could support exactly this — it just was never fed
467
+ anything, since attestations were never gossiped through a relay the way
468
+ publish/like/subscribe already were.
469
+
470
+ Considered adopting real PGP for this (Ryan asked) — decided against it:
471
+ PGP's actual trust-level/path-counting *idea* (marginal vs full trust,
472
+ computed transitively) is worth borrowing, but the OpenPGP *format* is
473
+ built for signing emails, not cheaply gossiping dozens of small JSON
474
+ events, and its classic path to real interoperability — public keyservers
475
+ — reintroduces exactly the single-point-of-failure problem discovery.py's
476
+ relay design exists to avoid. Built the trust-level idea on the Ed25519
477
+ signing already in place instead of adopting the standard.
478
+
479
+ `build_trust_graph()`: real BFS outward from your own pubkey through real
480
+ signed `subscribe` events pulled from a relay (a subscribe *is* a trust
481
+ edge — same insight from the original discovery-layer design, now actually
482
+ computed transitively instead of 1-hop-only). Trust decays per hop
483
+ (default 0.5×) — a friend counts fully, a friend-of-a-friend counts less.
484
+ Takes the shortest path to each reachable pubkey, not the sum across every
485
+ path — summing would let a sybil ring inflate a target's trust just by
486
+ adding more low-value paths to it.
487
+
488
+ `download_with_auction` now also pulls real `attestation` events from
489
+ relays (new event type — no relay code changes needed, `discovery_relay.py`
490
+ already verifies and filters by type generically), verifies each one, and
491
+ feeds them into `select_host` alongside the trust graph. And after every
492
+ successful download it publishes its own outcome as a real signed
493
+ attestation, not just recording it locally — so the next person who trusts
494
+ *you*, even transitively, benefits without ever dealing with that host
495
+ first.
496
+
497
+ Real end-to-end proof, not just the math: identity A downloaded from a
498
+ host directly (0.00 reputation, no history, same as before this change),
499
+ which auto-published a real attestation. A second identity, ROOT, who had
500
+ **never talked to that host**, subscribed to A (one real signed edge), then
501
+ ran `download`:
502
+
503
+ ```
504
+ trust graph: 1 pubkey(s) reachable within 3 hop(s) of your own subscribes
505
+ pulled 1/1 real attestation(s) from relays (others' vouches, weighted by your trust in whoever signed them)
506
+ + 127.0.0.1:9204: possession verified (3/3 chunks), price=0 sat, reputation=1.00 (1 attestation(s), weighted by signer trust + age), avg_latency=0.4ms
507
+ ```
508
+
509
+ Would've been a flat `reputation=0.00` before this change — ROOT had zero
510
+ direct history with the host. Instead it inherited A's real, already-
511
+ verified experience through one real hop of trust. Byte-identical download
512
+ confirmed via `cmp`, same as every other download in this repo.
513
+
514
+ Separately verified the decay math itself against a real 3-hop chain
515
+ (root→A→B→C) plus a disconnected stranger: `A=0.5, B=0.25, C=0.125`,
516
+ stranger absent from the graph entirely, `max_hops` correctly bounding how
517
+ far it searches — exact, not approximate.
518
+
519
+ ### `tunnel_relay.py` + persistent sessions in `node.py` — NAT traversal
520
+
521
+ `host --advertise-host`'s own help text used to say it outright: "no NAT
522
+ traversal here." Real gap — almost nobody has a directly reachable
523
+ inbound port. Fixed with a relay-mediated tunnel rather than real
524
+ STUN/ICE hole-punching: works behind *any* NAT including CGNAT (both
525
+ sides only ever make outbound connections, so there's nothing for a
526
+ firewall to block), at the honest cost of relay bandwidth/latency and
527
+ someone having to run `tunnel_relay.py` somewhere reachable — same
528
+ operational shape as already running a discovery relay, not a new kind of
529
+ problem.
530
+
531
+ Rendezvous protocol (hand-rolled to match this repo's own line-based wire
532
+ protocol rather than pulling in an external tunnel tool like `bore`):
533
+ a NAT'd host opens one persistent outbound `REGISTER <token>` control
534
+ connection; a downloader connects and sends `CONNECT <token>`; the relay
535
+ asks the host to dial back (`NEWSTREAM <stream_id>` on the control
536
+ channel, `DATA <stream_id>` as the reply) and, once paired, does nothing
537
+ but shovel raw bytes between the two sockets — same "dumb relay, no
538
+ opinion on the payload" design `discovery_relay.py` already uses, just
539
+ for bytes instead of signed JSON events. `token` is the archive's
540
+ `content_hash`; the relay has no idea what it means, same as everywhere
541
+ else in this repo that keys off it.
542
+
543
+ Had to fix a real prerequisite bug first, not just add the relay: every
544
+ wire-protocol command (`INFO`, `LEAVES`, one `FETCH` per chunk — 3324 of
545
+ them for the real archive) used to open a brand-new TCP connection.
546
+ Cheap directly, fatal through a relay — every single chunk would pay a
547
+ full rendezvous round-trip before any bytes moved. Fixed by giving both
548
+ sides a persistent session (`HostConnection` client-side, `serve_session`
549
+ host-side, looping over many commands per connection instead of one) —
550
+ a net win for direct connections too, not just a tunnel workaround.
551
+
552
+ Second real bug, caught while verifying this against real sockets, not
553
+ just in review: Python's `socketserver.ThreadingMixIn` closes a
554
+ connection's socket the instant its handler function *returns* — a
555
+ handler that spawns a pipe thread and returns immediately gets its own
556
+ socket killed out from under that thread mid-transfer. Fixed by having
557
+ each handler thread block for the tunneled session's full lifetime
558
+ (`_Pairing`'s `ready`/`done` events in `tunnel_relay.py`) instead of
559
+ firing off detached threads and returning early. First attempt failed
560
+ with `json.decoder.JSONDecodeError: Expecting value` — an empty response,
561
+ not a corrupted one, which is exactly what a socket closed mid-read looks
562
+ like.
563
+
564
+ Real end-to-end proof: hosted the real 217MB/3324-chunk archive,
565
+ advertised at a deliberately unreachable address (`10.255.255.1`, not
566
+ localhost) so there was no possibility of a direct connection carrying
567
+ the download, `--tunnel 127.0.0.1:9199`. Downloaded entirely through the
568
+ tunnel, `cmp`-verified byte-identical against the source, 3324 chunks in
569
+ 1.4s — comparable to a direct download, not a meaningfully slower path.
570
+
571
+ ```bash
572
+ # terminal 1
573
+ python3 discovery_relay.py 9101
574
+ # terminal 2
575
+ python3 tunnel_relay.py 9199
576
+ # terminal 3 — no reachable --advertise-host at all
577
+ python3 weed.py host real_archive --port 9201 --tunnel 127.0.0.1:9199 \
578
+ --relay http://127.0.0.1:9101 --advertise-host 10.255.255.1
579
+ # terminal 4
580
+ python3 weed.py download <content_hash> --relay http://127.0.0.1:9101 --out downloaded.mp4
581
+ ```
582
+
583
+ Additive, backward-compatible protocol change, same shape as the
584
+ optional `PRICE` wire verb: `publish()` gained an optional `tunnel`
585
+ field (`'relay_host:relay_port'` or absent); a candidate without it is
586
+ just connected to directly, exactly as before this existed.
587
+
588
+ **TLS**, for tunnel relays that terminate it at the edge instead of
589
+ speaking it themselves (deployed one on Fly with `handlers = ["tls"]` —
590
+ `fly.tunnel-relay.toml` + `Dockerfile.tunnel-relay` in this repo): prefix
591
+ the address with `tls://` — `--tunnel tls://tunnel.example.com:9199`.
592
+ `tunnel_relay.py` itself never changes; edge termination decrypts before
593
+ the bytes ever reach it, so only the two ends that actually cross the
594
+ public internet (the host's `REGISTER`/`DATA` connections, a
595
+ downloader's `CONNECT`) wrap the socket in
596
+ `ssl.create_default_context()` — real CA validation by default, not a
597
+ weakened check. Verified against a real self-signed-cert TLS-terminating
598
+ proxy built specifically to test this (mimicking exactly what Fly's edge
599
+ does): confirmed the client correctly *rejects* an untrusted cert before
600
+ trusting it, then a full host→tunnel→download round-trip over the
601
+ encrypted path, byte-identical result.
602
+
603
+ **Heartbeat**, for the same idle-connection problem real deployments
604
+ actually hit: the `REGISTER` control connection sends nothing between
605
+ registering and the first real download, sometimes for a long time.
606
+ Fly's own edge (confirmed from real production logs, not a guess) resets
607
+ TCP connections idle more than a few minutes, which silently
608
+ unregistered the host with no error until the next download failed.
609
+ Fixed with a small periodic `PING` on the control connection —
610
+ `tunnel_relay.py` needed zero changes, its `REGISTER` loop already
611
+ discards anything it receives that isn't relevant. Proved it against a
612
+ real idle-enforcing test server (3-second idle limit): without the
613
+ heartbeat, killed at exactly 3.0s; with it, survived 8 full seconds with
614
+ no kill event at all.
615
+
616
+ ### `dht.py` — real Kademlia DHT discovery, no relay required
617
+
618
+ Every other discovery path in this repo needs a relay URL, told to you
619
+ out of band — real, and a real limitation, until now: `dht.py` answers
620
+ "how do two nodes find each other with no shared server" using an actual
621
+ Kademlia DHT, via the real `kademlia` PyPI library rather than
622
+ reimplementing node-IDs/k-buckets/RPC routing from scratch. Scoped
623
+ honestly: this covers `announce(content_hash, host_addr)` /
624
+ `lookup(content_hash)` — not the richer signed-event system
625
+ (publish/like/subscribe/attestation/trust-graph) `discovery_relay.py`
626
+ already handles, since Kademlia's plain key→value store isn't a natural
627
+ fit for an append-only event log. That richer system staying on relays
628
+ is a deliberate scope boundary, not an oversight.
629
+
630
+ Multiple announcers of the same content are merged, not overwritten —
631
+ `kademlia`'s `set()` is single-value-per-key, so a naive announce would
632
+ silently drop everyone else's listing; `_announce()` fetches, merges,
633
+ re-announcing from the same host again correctly doesn't duplicate.
634
+
635
+ Proved with three separate, escalating real tests: three chained nodes
636
+ (C only ever bootstrapped through B, never spoke to A directly) — content
637
+ announced on A was found from C, real Kademlia routing, not a shared-
638
+ memory illusion. Two different hosts announcing the same content — both
639
+ preserved. And the strongest one: a node announced content, then that
640
+ node's *entire process exited* — a completely independent fourth process,
641
+ knowing only the original bootstrap node, still found what was announced.
642
+ Real value replication surviving the announcer going offline, which is
643
+ the actual point of a DHT over a relay.
644
+
645
+ ```bash
646
+ python3 dht.py 8468 # first node, new swarm
647
+ python3 dht.py 8469 127.0.0.1:8468 # second node, joins the first
648
+ ```
649
+
650
+ Or from the shell: `dht start [port] [bootstrap_host:port]`,
651
+ `dht announce <content_hash> <host:port> [title]`, `dht lookup <content_hash>`.
652
+
653
+ ### `web_ui.py` — local web UI
654
+
655
+ Hosting/discovering/downloading/liking/subscribing all required
656
+ memorizing `weed.py`'s CLI flags — real friction for anyone who isn't
657
+ already comfortable with argparse. `web_ui.py` is a small stdlib
658
+ `http.server`/`ThreadingHTTPServer` JSON API — same tool
659
+ `discovery_relay.py` already uses, no new dependency, still just the
660
+ three packages in `requirements.txt` — wrapping the exact same
661
+ `node.py` functions the CLI calls, no reimplemented protocol logic. The
662
+ frontend (`web/`) is a single static page, vanilla JS, no build step —
663
+ matches the rest of this repo's no-toolchain style.
664
+
665
+ Binds `127.0.0.1` by default on purpose: a local control surface, not
666
+ something meant to face the internet, and there's no auth built — same
667
+ "reachability is on you" honesty `--advertise-host`'s docs already apply
668
+ elsewhere. `--bind` widens it at your own risk.
669
+
670
+ Endpoints, all thin wrappers: `GET /api/whoami`, `GET /api/discover`,
671
+ `POST /api/host` (backgrounds `run_host_server`/`run_host_tunnel` the
672
+ same way `shell.py`'s `do_host` already does) + `GET /api/hosts`,
673
+ `POST /api/download` + `GET /api/download/<job_id>` for polling progress
674
+ (`download()` gained an optional `on_progress(idx, n_chunks)` callback,
675
+ default no-op, so CLI output is unaffected), `POST /api/like`,
676
+ `POST /api/subscribe`, `GET /api/reputation/<pubkey>`,
677
+ `GET /api/stream/<job_id>`, `GET /api/qr?data=...`, `GET /api/lan-url`.
678
+
679
+ Real end-to-end proof, not just the API responding: hosted a file
680
+ through the **Host** form, confirmed a second terminal's `weed discover`
681
+ actually saw it (proves the API called the real `node.publish`, not a
682
+ mock); downloaded through the **Downloads** form with a live-polling
683
+ progress bar, `cmp`-verified byte-identical; liked and subscribed
684
+ through the UI, confirmed the real signed events landed on the relay by
685
+ querying it directly.
686
+
687
+ **Streaming** (`/api/stream/<job_id>`) — the actual gap behind "play
688
+ media on my phone": the UI could already trigger a download, but the
689
+ bytes only ever landed on this server's disk, never reached the browser.
690
+ Real HTTP range support (`Accept-Ranges`, `206 Partial Content`, `416`
691
+ for out-of-range), so a `<video>` tag can seek instead of downloading
692
+ blind. Verified with real range requests against a real completed
693
+ download — full fetch, a mid-file range, and an open-ended range, all
694
+ byte-exact against the source; error paths (out-of-range, unknown job)
695
+ checked too.
696
+
697
+ **QR codes** (`/api/qr`, terminal QR on startup) — same `qrcode` package
698
+ `ott`'s own `ott qr` already uses. `--bind 0.0.0.0` auto-detects the real
699
+ LAN IP (a UDP-route trick, no packet actually sent) instead of printing
700
+ the useless literal `0.0.0.0`, so the printed/scanned URL is one a phone
701
+ can actually reach — and it's computed server-side and exposed via
702
+ `/api/lan-url` specifically because the browser's own
703
+ `location.origin` lies the instant you load the page via `localhost`
704
+ instead of the LAN address; a real bug this caused (the header's "open
705
+ on phone" QR pointing at `127.0.0.1`) was reproduced and fixed by having
706
+ the client fetch the server's own answer instead of trusting
707
+ `location.origin`.
708
+
709
+ ```bash
710
+ python3 weed.py serve # alias for `web`, positional args: serve [bind] [port]
711
+ python3 weed.py serve 0.0.0.0 8080 # reachable from your phone; prints a scan-to-open QR
712
+ # or, from the shell: `serve [bind] [port]`
713
+ ```
714
+
715
+ ### `shell.py` — interactive, tab-completing, same pattern as `ott`'s shell
716
+
717
+ `python3 weed.py` with no arguments (or `weed.py shell`) drops into an
718
+ interactive shell — same `cmd.Cmd` + readline pattern as `ott`'s own shell,
719
+ same conventions: short aliases (`w`/`h`/`r`/`disc`/`dl`/`l`/`sub`), `help`
720
+ or `?` for commands, `Ctrl-D` or `q` to exit, tab completes.
721
+
722
+ `relay` runs a real discovery relay in the background too, same pattern as
723
+ `host` — the whole flow (relay, host, discover, download, like, subscribe)
724
+ runs from one shell session, no second terminal required. Discovered
725
+ running it that way for real (`host` with no relay running produces
726
+ "unreachable, skipped, nothing found," `discover` alone can't conjure a
727
+ relay that isn't there — real friction that surfaced from actually using
728
+ it, not a hypothetical). `relay` also sets itself as the session's default
729
+ relay, so `discover`/`download`/`like`/`subscribe` don't need `--relay`
730
+ repeated every time. `host` also takes `--tunnel [tls://]RELAY_HOST:PORT`
731
+ now, same as the CLI — see `tunnel_relay.py` above. `serve [bind] [port]`
732
+ runs the web UI in the background without a second terminal — see
733
+ `web_ui.py` above. `dht start/announce/lookup` runs a real Kademlia node
734
+ in the background — see `dht.py` above. Every background command
735
+ (`host`, `relay`, `serve`, `dht start`) goes through a shared `_bg()`
736
+ wrapper now: a real failure inside one of those threads used to dump a
737
+ raw Python traceback into the middle of the prompt (an uncaught
738
+ exception in a background thread was never covered by `onecmd()`'s own
739
+ try/except, which only wraps the synchronous part of a command) — caught
740
+ live from a real `--tunnel ~/share` typo, fixed, reproduced the same
741
+ crash again afterward to confirm it now prints a clean `✗ ...` line
742
+ instead and the shell stays fully usable.
743
+
744
+ Completion resolves against real state, not a fixed list — same idea as
745
+ `ott`'s completions (which complete against the real archive). `download`
746
+ and `like` tab-complete against content hashes actually seen in the last
747
+ `discover`; `subscribe` completes against pubkeys actually seen:
748
+
749
+ ```
750
+ weed> relay
751
+ relay running on port 9101 in the background — set as your default relay
752
+ weed> host real_archive --relay http://127.0.0.1:9101
753
+ hosting real_video.mp4 on port 9201 in the background — shell still usable
754
+ weed> discover
755
+ 'real_video.mp4' hash=7f2477c7ea675004... host=127.0.0.1:9201 by=409a15dcfc59...
756
+ weed> download 7f24<TAB>
757
+ 7f2477c7ea675004ad5dbab6dc7c44327c724b880cc389807df1965b77966acc
758
+ weed> download 7f2477c7ea675004ad5dbab6dc7c44327c724b880cc389807df1965b77966acc
759
+ 3324 chunks downloaded and verified in 1.4s
760
+ ```
761
+
762
+ `host` runs the server in a background thread instead of blocking the
763
+ shell — genuinely new, not copied from `ott`, since nothing in `ott`
764
+ blocks forever the way a hosting server does. Ran this exact sequence for
765
+ real (scripted, not just described): host → discover → download →
766
+ `cmp`-verified byte-identical → `like`, all in one shell session, download
767
+ still finishing in 1.4s with the server running in the background thread
768
+ the whole time.
769
+
770
+ ## Running it
771
+
772
+ `./weed.py --help` (or `weed.py lightning --help` for the nested ones) is
773
+ the friendliest entry point — it's a thin argparse wrapper over the
774
+ Makefile, same targets, real subcommands and `--help` text instead of
775
+ needing to remember `make` target names. `make help` lists the same
776
+ targets directly. Or run any command below on its own:
777
+
778
+ ```bash
779
+ python3 poc_challenge_auction.py # in-process, Parts 1 + 2, narrated
780
+ python3 poc_network_challenge.py # real sockets, single-shot rounds, loopback
781
+ python3 poc_network_challenge.py stats # real sockets, repeated-challenge separation, loopback
782
+ python3 poc_reputation.py # real Ed25519 signing/verification demo
783
+ python3 viz_challenge_separation.py # regenerates the chart above from fresh data
784
+ docker compose up --build --abort-on-container-exit verifier # same test, real containers
785
+ cd lightning && docker compose up -d && cd .. # real bitcoind + 2 LND nodes (see lightning/README.md for setup)
786
+ python3 poc_challenge_auction.py --lightning # same auction, real HTLC settlement
787
+ python3 poc_real_archive_challenge.py # same challenge mechanism, real 3324-chunk video
788
+ python3 poc_discovery.py # 3 real relays, personalized ranking, sybil test
789
+ python3 tunnel_relay.py 9199 # NAT-traversal relay — see host --tunnel below
790
+ python3 weed.py serve --bind 0.0.0.0 --port 8080 # local web UI, reachable from your phone
791
+ python3 dht.py 8468 # real Kademlia DHT node — see dht.py above
792
+ ```
793
+
794
+ `pip install -r requirements.txt` gets everything (`btcvm`, `cryptography`,
795
+ `matplotlib`, `kademlia`). Broken down: `poc_challenge_auction.py` (and
796
+ `poc_real_archive_challenge.py`, which imports from it) needs `btcvm`;
797
+ `poc_reputation.py` and `poc_discovery.py` (which imports from it) need
798
+ `cryptography`; `viz_challenge_separation.py` needs `matplotlib`;
799
+ `dht.py` needs `kademlia`. `poc_network_challenge.py` and
800
+ `discovery_relay.py` are pure stdlib, no install needed. `qrcode` is
801
+ optional (`pip install qrcode`) — `web_ui.py`'s QR endpoints degrade to a
802
+ plain URL, printed instead of rendered, if it's missing. Docker/Compose
803
+ needed for the container-network test and for `--lightning` (real
804
+ bitcoind + LND, see `lightning/README.md`).
805
+
806
+ Or, packaged: `pip install -e .` (see `pyproject.toml`) installs a real
807
+ `weed` command on your `PATH` instead of `python3 weed.py`.
808
+
809
+ ## Next steps
810
+
811
+ 1. ~~Nonce-salted challenge + timing bound~~ — done, `poc_challenge_auction.py` Part 2
812
+ 2. ~~Real network round-trip instead of in-process~~ — done, `poc_network_challenge.py`
813
+ 3. ~~Local reputation + signed portable attestations~~ — done, `poc_reputation.py`
814
+ 4. ~~Real WAN calibration against an actual second machine~~ — done, real
815
+ RunPod box over an SSH tunnel: ~1700x gap, separates at k=1
816
+ 5. ~~Real Lightning HTLC settlement~~ — done, `lightning_settle.py` +
817
+ `lightning/` (real bitcoind + 2 LND nodes, real BOLT11 invoices, real
818
+ preimage reveal independently re-verified). Regtest, not public testnet —
819
+ same reasoning as #4: real protocol code, skip the wait on chain
820
+ sync/faucets.
821
+ 6. ~~Point the mechanism at a real `.ott` archive~~ — done,
822
+ `poc_real_archive_challenge.py`: real 217MB video, 3324 real chunks,
823
+ 12-step proofs (~400B), confirmed O(log N) not linear.
824
+ 7. ~~Attestation revocation~~ — done, `poc_reputation.py`: signer-only
825
+ revocation keyed to `attestation_id`, forged revocation from a different
826
+ signer correctly rejected, revoked attestation kept on record not deleted.
827
+ 8. ~~Discovery layer~~ — done, `discovery_relay.py` + `poc_discovery.py`:
828
+ 3 independent dumb relays, personalized client-side ranking from each
829
+ client's own subscribe graph, sybil-resistant (20 fake identities move
830
+ neither client's score), real relay-death test (content survives,
831
+ anything posted only to the dead relay doesn't — redundancy isn't free).
832
+ 9. ~~NAT traversal~~ — done, `tunnel_relay.py`: relay-mediated rendezvous
833
+ (not real STUN/ICE hole-punching), real 217MB/3324-chunk archive
834
+ downloaded end-to-end through the tunnel with the host advertised at
835
+ an unreachable address, byte-identical. Required a persistent-session
836
+ refactor of `node.py`'s wire protocol first — see above.
837
+ 10. ~~Local UI~~ — done, `web_ui.py` + `web/`: stdlib JSON API wrapping
838
+ the same `node.py` functions the CLI calls, static vanilla-JS
839
+ frontend, no new dependency. Host/discover/download/like/subscribe
840
+ all verified working from the browser, not just the API responding.
841
+ 11. ~~Real P2P/DHT discovery~~ — done, `dht.py`: real Kademlia via the
842
+ `kademlia` library, not reimplemented. Content survived the
843
+ announcing node's process exiting entirely, found by a fourth,
844
+ independent process that only knew the original bootstrap node — real
845
+ value replication, not a two-party memory trick. Scoped honestly:
846
+ covers host-discovery only, not the richer event system.
847
+ 12. ~~TLS for tunnel relays~~ — done, `tls://` prefix on `--tunnel`,
848
+ verified against a real self-signed-cert TLS-terminating proxy built
849
+ specifically to test it, full host→tunnel→download round-trip over
850
+ the encrypted path.
851
+ 13. ~~Play media from the web UI~~ — done, `/api/stream` with real HTTP
852
+ range support, verified byte-exact against full/mid-file/open-ended
853
+ range requests.
854
+ 14. ~~Multi-file hosting~~ — done, `host <dir>` (no `--file`) serves every
855
+ archived file over one port, downloader `SELECT`s by content hash.
856
+ Fixes the original bug: a 45-file archive silently collapsed to
857
+ whichever file happened to be last in the manifest.
858
+
859
+ Every item on the original roadmap is now built and verified against real
860
+ output, not just designed — and `node.py` (below) wires host/discover/
861
+ download/like/subscribe into one real tool instead of six disconnected
862
+ demos. What's left is scaling and hardening this, not proving the
863
+ mechanisms work — see each section above for the honest edges that are
864
+ still real constraints even though the core ideas held up: loopback
865
+ timing separation isn't airtight without averaging, relay death loses
866
+ non-redundant data, RunPod flakiness, regtest-only Lightning, the DHT
867
+ covers host-discovery but not the richer signed-event system, the tunnel
868
+ relay (even with TLS) is still a single point of failure/bandwidth cost
869
+ with no redundancy story the way discovery relays have, and the web UI
870
+ has no auth, local-only by design.