upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorlog <log@invalid>2026-10-04 01:11:02 +0000
committerlog <log@invalid>2026-10-04 01:11:02 +0000
commit9333aefa19a55050cdd20fbeaed41efd42e5bacc (patch)
treee1dd758c1e458ba2096162057ae93f1dabc66a98
parentb7805a6feb361809831e89eed55a25548491431b (diff)
size 48428
-rw-r--r--reveal/v5-000003/manifest.txt.otsbin584 -> 2518 bytes
-rw-r--r--reveal/v5-000003/seal.json2
-rw-r--r--reveal/v5-000004/check.py319
-rw-r--r--reveal/v5-000004/manifest.txt8
-rw-r--r--reveal/v5-000004/manifest.txt.otsbin0 -> 759 bytes
-rw-r--r--reveal/v5-000004/r33389812.ctbin0 -> 1013 bytes
-rw-r--r--reveal/v5-000004/r33389812.nostr.json1
-rw-r--r--reveal/v5-000004/r33389812.tlock11
-rw-r--r--reveal/v5-000004/r34541812.ctbin0 -> 1016 bytes
-rw-r--r--reveal/v5-000004/r34541812.nostr.json1
-rw-r--r--reveal/v5-000004/r34541812.tlock11
-rw-r--r--reveal/v5-000004/seal.json43
12 files changed, 395 insertions, 1 deletions
diff --git a/reveal/v5-000003/manifest.txt.ots b/reveal/v5-000003/manifest.txt.ots
index 50d510b..f4798e5 100644
--- a/reveal/v5-000003/manifest.txt.ots
+++ b/reveal/v5-000003/manifest.txt.ots
Binary files differ
diff --git a/reveal/v5-000003/seal.json b/reveal/v5-000003/seal.json
index c82ed7a..9ebd7bf 100644
--- a/reveal/v5-000003/seal.json
+++ b/reveal/v5-000003/seal.json
@@ -1,6 +1,6 @@
1{ 1{
2 "algorithm": "sha256-live-v5", 2 "algorithm": "sha256-live-v5",
3 "anchor_ref": "sha256:baba31b4d3ca99d52c6204fceab089efc398aa3c152bc7305e9cd3ea83997c19", 3 "anchor_ref": "bitcoin:block:969776",
4 "covers_after": "2026-10-03T22:17:20.526176Z", 4 "covers_after": "2026-10-03T22:17:20.526176Z",
5 "covers_through": "2026-10-03T23:07:43.366835Z", 5 "covers_through": "2026-10-03T23:07:43.366835Z",
6 "digest": "baba31b4d3ca99d52c6204fceab089efc398aa3c152bc7305e9cd3ea83997c19", 6 "digest": "baba31b4d3ca99d52c6204fceab089efc398aa3c152bc7305e9cd3ea83997c19",
diff --git a/reveal/v5-000004/check.py b/reveal/v5-000004/check.py
new file mode 100644
index 0000000..7f86b8d
--- /dev/null
+++ b/reveal/v5-000004/check.py
@@ -0,0 +1,319 @@
1#!/usr/bin/env python3
2"""
3Check Echo's self-revealing ledger seals (`sha256-live-v5`) without trusting Echo.
4
5 python3 check.py SEAL_DIR [SEAL_DIR ...] [--tle PATH] [--offline]
6
7Needs Python 3.11+, the `cryptography` package (ChaCha20-Poly1305) and drand's `tle` binary
8(`go install github.com/drand/tlock/cmd/tle@v1.2.0`). It imports nothing of Echo's: a checker that needs the thing it
9checks is not a checker, which is why it is copied, whole, into every seal directory.
10
11Each SEAL_DIR is one seal: `manifest.txt` (the exact text the seal's digest is the sha256 of), `seal.json`, and per
12reveal group `r<ROUND>.tlock` (the group's key, time-locked to that drand round) and `r<ROUND>.ct` (the group,
13encrypted under that key), and, once the group has been revealed, `r<ROUND>.held.json` (how many of its withheld
14lines Echo publishes no salt for, and why) and any number of `r<ROUND>.salts.<N>.json`: each the salts of withheld
15lines Echo decided to open and had not erased, each with its line. `manifest.txt.ots`, when present, is an
16OpenTimestamps proof over the manifest: `ots verify manifest.txt.ots` checks it against Bitcoin.
17
18What it checks, from the files alone and the beacon:
19 1. the manifest hashes to the seal's digest, and each seal names the one before it (when several are given);
20 2. each group's ciphertext and locked key hash to what the manifest commits, and the key is locked to the
21 manifest's round of the manifest's chain;
22 3. for every group whose round the beacon has signed: the round's signature as each relay serves it, the key
23 unlocked with it, the group decrypted, its bytes hashed to the committed digest, its lines and withheld lines
24 counted against the committed counts, its lines in id order, and every line's reveal day recomputed from its
25 own settling instant under the seal's named rule (`utc-day-after-close`: the first 00:00 UTC after
26 `settles_at`, else after the deadline);
27 4. for every opened group with releases: each names this seal, round and group and its own number; each released
28 salt and line hash to the withheld line's committed hash; no line is settled twice; and a hold, where there is
29 one, names the group and counts no more lines than are withheld;
30 5. once every group of every seal from the first is open and every withheld line released: the union of their v4
31 lines, sorted by id, rehashes to the v4 digest the last seal follows, so no sealed forecast was left out of a
32 reveal. Where a withheld line has no released salt (held, erased, or not yet released) that digest cannot be
33 rebuilt, and this says so and why: what is checked then is only that every forecast the v4 seal covers is in a
34 group, counted. A withheld line is never called verified.
35
36A group whose round is not yet signed, or whose withheld lines have no release yet, is reported as pending or held,
37not as a failure. Exit 0 when everything checkable passed, 1 when anything did not match.
38"""
39from __future__ import annotations
40
41import argparse
42import base64
43import hashlib
44import json
45import re
46import subprocess
47import sys
48import urllib.request
49import zlib
50from datetime import datetime, timedelta, timezone
51from pathlib import Path
52
53
54def sha(b: bytes) -> str:
55 return hashlib.sha256(b).hexdigest()
56
57
58def parse_manifest(text: str) -> dict:
59 lines = text.split("\n")
60 if lines[0] != "sha256-live-v5":
61 raise ValueError(f"not a v5 manifest: {lines[0]!r}")
62 m = {"groups": []}
63 for ln in lines[1:]:
64 word, _, rest = ln.partition(" ")
65 if word == "prev":
66 m["prev"] = None if rest == "-" else rest
67 elif word == "follows":
68 seq, digest, n = rest.split(" ")
69 m["follows"] = {"seq": int(seq), "digest": digest, "n": int(n)}
70 elif word == "covers":
71 m["covers"] = rest.split(" ")
72 elif word == "variant":
73 m["variant"] = json.loads(rest)
74 elif word == "counts":
75 m["n"], m["n_withheld"], m["n_groups"] = map(int, rest.split(" "))
76 elif word == "group":
77 r, at, n, w, digest, ct, key, late = rest.split(" ")
78 m["groups"].append({"round": int(r), "reveal_at": at, "n": int(n), "n_withheld": int(w),
79 "digest": digest, "ct": ct, "key": key, "late": late == "1"})
80 else:
81 raise ValueError(f"unknown manifest line {ln!r}")
82 return m
83
84
85def stanza(locked: str) -> tuple[int, str] | None:
86 body = "".join(ln for ln in locked.strip().splitlines() if not ln.startswith("-----"))
87 m = re.search(rb"-> tlock (\d+) ([0-9a-f]{64})", base64.b64decode(body))
88 return (int(m.group(1)), m.group(2).decode()) if m else None
89
90
91def round_at(beacon: dict, at: datetime) -> int:
92 t = at.timestamp()
93 whole = int(t) + (t != int(t))
94 if whole <= beacon["genesis_time"]:
95 return 1
96 return -(-(whole - beacon["genesis_time"]) // beacon["period"]) + 1
97
98
99def reveal_day(closes: datetime) -> datetime:
100 d = closes.astimezone(timezone.utc).date() + timedelta(days=1)
101 return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
102
103
104def parse_line(ln: str) -> dict:
105 """One line: `id|issued_at|deadline|forecaster|p|settlement|anchor<TAB>open:<v4>` or `...<TAB>withheld:<hex>`."""
106 numeric, tab, text = ln.partition("\t")
107 if not tab:
108 raise ValueError("a line without its text layer")
109 fid, issued, deadline, forecaster, p, rest = numeric.split("|", 5)
110 dec = json.JSONDecoder()
111 settle, end = dec.raw_decode(rest)
112 if rest[end] != "|":
113 raise ValueError("settlement not followed by the anchor")
114 anchor, end2 = dec.raw_decode(rest, end + 1)
115 if end2 != len(rest):
116 raise ValueError("trailing bytes after the anchor")
117 out = {"id": int(fid), "issued_at": issued, "deadline": deadline, "forecaster": forecaster, "p": float(p),
118 "settlement": settle, "anchor": anchor}
119 if text.startswith("open:"):
120 out["v4"] = text[5:]
121 if not out["v4"].startswith(f"{fid}|"):
122 raise ValueError("the text layer is another forecast's line")
123 elif re.fullmatch(r"withheld:[0-9a-f]{64}", text):
124 out["withheld"] = text[9:]
125 else:
126 raise ValueError(f"unknown text layer {text[:20]!r}")
127 return out
128
129
130def beacon_round(beacon: dict, rnd: int) -> dict:
131 """What each relay serves for the round: its signature, or why there is none."""
132 out = {}
133 for relay in beacon["relays"]:
134 url = f"{relay.rstrip('/')}/{beacon['chain_hash']}/public/{rnd}"
135 try:
136 with urllib.request.urlopen(url, timeout=20) as r: # noqa: S310 — the seal's own relays
137 out[relay] = json.loads(r.read()).get("signature")
138 except Exception as exc: # noqa: BLE001 — report, go on
139 out[relay] = f"unavailable ({type(exc).__name__})"
140 return out
141
142
143def unlock(tle: str, beacon: dict, locked: str) -> bytes | None:
144 """The key, or None while the round is unsigned. Each relay is tried in turn."""
145 errors = []
146 for relay in beacon["relays"]:
147 r = subprocess.run([tle, "-d", "-n", relay.rstrip("/") + "/", "-c", beacon["chain_hash"]],
148 input=locked.encode(), capture_output=True, timeout=120, check=False)
149 if r.returncode == 0:
150 return r.stdout
151 if b"too early" in r.stderr:
152 return None
153 errors.append(f"{relay}: {r.stderr.decode(errors='replace').strip()[-160:]}")
154 raise RuntimeError("; ".join(errors))
155
156
157def release(d: Path, seal_digest: str, g: dict, lines: list[dict], say) -> tuple[dict[int, str], int, str | None]:
158 """An opened group's releases and hold, checked against the group: (id -> the v4 line each released salt opens,
159 how many withheld lines were opened and found erased, and the hold's reason where Echo published one)."""
160 r = g["round"]
161 shut = {x["id"]: x["withheld"] for x in lines if "withheld" in x}
162 out, erased, seen, bad = {}, set(), set(), []
163 files = sorted(d.glob(f"r{r}.salts.*.json"), key=lambda p: int(p.name.split(".")[-2]))
164 for k, path in enumerate(files):
165 rel = json.loads(path.read_text())
166 say(rel.get("format") == "echo-reveal-salts/1" and rel.get("seal") == seal_digest
167 and rel.get("round") == r and rel.get("group") == g["digest"] and rel.get("n") == k
168 and path.name == f"r{r}.salts.{k}.json",
169 f"round {r}: release {k} names this seal, round and group, and follows the one before")
170 for x in rel.get("released", []):
171 i, line = x["id"], x["line"]
172 opens = (i in shut and i not in seen and line.startswith(f"{i}|")
173 and sha(bytes.fromhex(x["salt"]) + line.encode()) == shut[i])
174 if opens:
175 out[i] = line
176 else:
177 bad.append(i)
178 seen.add(i)
179 for i in rel.get("erased", []):
180 if i not in shut or i in seen:
181 bad.append(i)
182 erased.add(i)
183 seen.add(i)
184 if files:
185 say(not bad, f"round {r}: {len(out)} released lines open their committed hashes, {len(erased)} opened lines "
186 f"found erased, none settled twice" + (f" ({len(bad)} do not, e.g. {bad[:3]})" if bad else ""))
187 why = None
188 hold = d / f"r{r}.held.json"
189 if hold.exists():
190 h = json.loads(hold.read_text())
191 say(h.get("format") == "echo-reveal-held/1" and h.get("seal") == seal_digest and h.get("round") == r
192 and h.get("group") == g["digest"] and 0 <= h.get("held", -1) <= len(shut),
193 f"round {r}: the hold names this seal, round and group, and counts at most its withheld lines")
194 why = str(h.get("why") or "no reason given")
195 left = len(shut) - len(out) - len(erased)
196 if left:
197 print(f" .... round {r}: {left} withheld lines have no released salt: counted, not rehashed"
198 + (f" (Echo publishes none: {why})" if why else " (no release or hold published yet)"))
199 return out, len(erased), why
200
201
202def check(dirs: list[Path], tle: str = "tle", offline: bool = False) -> int:
203 from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
204
205 failed, pending, withheld, held, erased, union = 0, 0, 0, 0, 0, {}
206 reasons: set[str] = set()
207 first_prev = "unset"
208
209 def say(ok: bool, what: str) -> None:
210 nonlocal failed
211 failed += not ok
212 print(f" {'PASS' if ok else 'FAIL'} {what}")
213
214 prev_digest = None
215 seals = []
216 for d in dirs:
217 text = (d / "manifest.txt").read_bytes()
218 row = json.loads((d / "seal.json").read_text())
219 seals.append((d, text, row, parse_manifest(text.decode())))
220 seals.sort(key=lambda s: s[2]["seq"])
221 last = None
222 for i, (d, text, row, m) in enumerate(seals):
223 print(f"v5 seal {row['seq']}: {m['n']} forecasts in {m['n_groups']} reveal groups, {m['n_withheld']} withheld, "
224 f"follows v4 seal {m['follows']['seq']}")
225 say(sha(text) == row["digest"], f"sha256(manifest.txt) is the seal's digest {row['digest'][:16]}…")
226 say(len(m["groups"]) == m["n_groups"] and sum(g["n"] for g in m["groups"]) == m["n"]
227 and sum(g["n_withheld"] for g in m["groups"]) == m["n_withheld"], "the manifest's counts add up")
228 if i == 0:
229 first_prev = m["prev"]
230 else:
231 say(m["prev"] == prev_digest, "it names the seal before it")
232 prev_digest, last = row["digest"], m
233 b = m["variant"]["beacon"]
234 for g in m["groups"]:
235 r = g["round"]
236 ct, locked = (d / f"r{r}.ct").read_bytes(), (d / f"r{r}.tlock").read_text()
237 say(sha(ct) == g["ct"] and sha(locked.encode()) == g["key"],
238 f"round {r}: the ciphertext and the locked key are the committed ones")
239 say(stanza(locked) == (r, b["chain_hash"]), f"round {r}: the key is locked to that round of that chain")
240 say(round_at(b, datetime.fromisoformat(g["reveal_at"])) == r, f"round {r} is signed at {g['reveal_at']}")
241 if not offline:
242 sigs = beacon_round(b, r)
243 good = {s for s in sigs.values() if s and not s.startswith("unavailable")}
244 print(f" .... round {r} signature from {len(sigs)} relays: "
245 + ("not yet signed" if not good else f"{len(good)} distinct: {min(good)[:24]}…"))
246 if len(good) > 1:
247 say(False, f"round {r}: relays disagree on its signature")
248 key = unlock(tle, b, locked)
249 if key is None:
250 pending += 1
251 print(f" .... round {r} ({g['reveal_at']}) not yet signed: {g['n']} forecasts stay locked")
252 continue
253 try:
254 plain = zlib.decompress(ChaCha20Poly1305(key).decrypt(b"\x00" * 12, ct, None))
255 except Exception as exc: # noqa: BLE001 — a finding, not a crash
256 say(False, f"round {r}: the ciphertext does not open under its key ({type(exc).__name__})")
257 continue
258 say(sha(plain) == g["digest"], f"round {r}: the opened group hashes to its committed digest")
259 lines = [parse_line(ln) for ln in plain.decode().split("\n")]
260 ids = [x["id"] for x in lines]
261 say(len(lines) == g["n"] and sum("withheld" in x for x in lines) == g["n_withheld"],
262 f"round {r}: {len(lines)} lines, {g['n_withheld']} withheld, as committed")
263 say(ids == sorted(set(ids)), f"round {r}: lines in id order, none twice")
264 due = datetime.fromisoformat(g["reveal_at"])
265 if m["variant"].get("reveal") == "utc-day-after-close":
266 wrong = [x["id"] for x in lines if reveal_day(datetime.fromisoformat(
267 (x["settlement"] or {}).get("settles_at") or x["deadline"])) != due]
268 say(not wrong, f"round {r}: every line belongs to this reveal day"
269 + (f" ({len(wrong)} do not, e.g. {wrong[:3]})" if wrong else ""))
270 else:
271 print(f" .... round {r}: reveal rule {m['variant'].get('reveal')!r} is not one this checker "
272 "recomputes; which lines belong to the day is unchecked")
273 opened, gone, why = release(d, row["digest"], g, lines, say)
274 for x in lines:
275 if x["id"] in union:
276 say(False, f"forecast {x['id']} is in two groups")
277 union[x["id"]] = x.get("v4") or opened.get(x["id"])
278 shut = sum(1 for x in lines if "withheld" in x and x["id"] not in opened) - gone
279 erased += gone
280 if why is None:
281 withheld += shut
282 else:
283 held += shut
284 reasons.add(why)
285 if last is not None:
286 if first_prev is not None:
287 print(" .... completeness: the first seal given is not the chain's first; give every seal from it")
288 elif pending:
289 print(f" .... completeness: {pending} groups still locked; it is checkable once all have opened")
290 elif withheld or held or erased:
291 told = [f"{withheld} withheld lines not yet released"] if withheld else []
292 told += ([f"{held} withheld lines held, for which Echo publishes no salt ({'; '.join(sorted(reasons))})"]
293 if held else [])
294 told += [f"{erased} erased stay hashes"] if erased else []
295 print(f" .... completeness: {', '.join(told)}: those lines can be counted but not rehashed, so the v4 "
296 f"lines cannot all be rebuilt and the v4 digest is NOT checked; {len(union)} forecasts against "
297 f"the v4 seal's {last['follows']['n']}")
298 say(len(union) == last["follows"]["n"], "every forecast the v4 seal covers is in a group, counted")
299 else:
300 got = sha("\n".join(union[k] for k in sorted(union)).encode())
301 say(got == last["follows"]["digest"] and len(union) == last["follows"]["n"],
302 f"the union of {len(union)} lines rehashes to v4 seal {last['follows']['seq']}'s digest")
303 print("\nNOT VERIFIED" if failed else f"\nVERIFIED{f' ({pending} groups not yet revealed)' if pending else ''}")
304 return 1 if failed else 0
305
306
307def main(argv: list[str]) -> int:
308 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
309 ap.add_argument("dirs", nargs="*", default=[str(Path(__file__).resolve().parent)],
310 help="seal directories, any order (default: the one this script sits in)")
311 ap.add_argument("--tle", default="tle", help="the tle binary that unlocks a key once its round is signed")
312 ap.add_argument("--offline", action="store_true",
313 help="do not fetch each round's signature from the relays for display; tle still asks them")
314 args = ap.parse_args(argv)
315 return check([Path(d) for d in args.dirs], tle=args.tle, offline=args.offline)
316
317
318if __name__ == "__main__":
319 raise SystemExit(main(sys.argv[1:]))
diff --git a/reveal/v5-000004/manifest.txt b/reveal/v5-000004/manifest.txt
new file mode 100644
index 0000000..6743e18
--- /dev/null
+++ b/reveal/v5-000004/manifest.txt
@@ -0,0 +1,8 @@
1sha256-live-v5
2prev baba31b4d3ca99d52c6204fceab089efc398aa3c152bc7305e9cd3ea83997c19
3follows 34 737e4a1b4b7b4a23f087f974f2f3d9865211a73b0990837791b7a44b71bc3cfa 39128
4covers 2026-10-03T23:07:43.366835Z 2026-10-04T00:08:35.532031Z
5variant {"beacon":{"chain_hash":"52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971","genesis_time":1692803367,"period":3,"relays":["https://api.drand.sh","https://api2.drand.sh","https://api3.drand.sh","https://drand.cloudflare.com"],"scheme":"bls-unchained-g1-rfc9380"},"cipher":"chacha20poly1305-zlib6-nonce0","gate":{"default":"withheld: a kept salt can be released later; an opened text can never be withdrawn","opened":"none","why":"no decision by Echo yet"},"line":"numeric<TAB>open:<v4 line> | numeric<TAB>withheld:sha256(salt||v4 line)","reveal":"utc-day-after-close","salts":"key ring: one content a seal, one part a line","tool":{"name":"tle","sha256":"69157d8c14a1d390d1b7744946288f83d4b10748471d5447f9abb77a5902c541","version":"tlock v1.2.0 -- github.com/drand/tlock"}}
6counts 24 24 2
7group 33389812 2026-10-26T00:00:00.000000Z 12 12 c7218af247b604a2dcf44cc788ce5e7180b492a91b0005dd7dbeea118ffeee33 a79f9f6b909b5ce75081420bc9e6dcfc45b4745c6751ef7873172781163b5d47 7667b76cdbbfbceab379de408ef27235a251dec95b736dbe062ff18354638134 0
8group 34541812 2026-12-05T00:00:00.000000Z 12 12 71f3b8b4de94f03c6422fdf67d055535e79b0411595168d406729c17f62a988a c056fab6d09a1abf85c6c41fba220e0a5982e32f67cfb67a8e76e5b2a1c3aced 42cede3b2e0c2c04db31256b113203659e9bab903142fc119298ddd2034da7d9 0 \ No newline at end of file
diff --git a/reveal/v5-000004/manifest.txt.ots b/reveal/v5-000004/manifest.txt.ots
new file mode 100644
index 0000000..4039a25
--- /dev/null
+++ b/reveal/v5-000004/manifest.txt.ots
Binary files differ
diff --git a/reveal/v5-000004/r33389812.ct b/reveal/v5-000004/r33389812.ct
new file mode 100644
index 0000000..b59d533
--- /dev/null
+++ b/reveal/v5-000004/r33389812.ct
Binary files differ
diff --git a/reveal/v5-000004/r33389812.nostr.json b/reveal/v5-000004/r33389812.nostr.json
new file mode 100644
index 0000000..059ef4d
--- /dev/null
+++ b/reveal/v5-000004/r33389812.nostr.json
@@ -0,0 +1 @@
{"content":"-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHRsb2NrIDMzMzg5ODEyIDUyZGI5YmE3\nMGUwY2MwZjZlYWY3ODAzZGQwNzQ0N2ExZjU0Nzc3MzVmZDNmNjYxNzkyYmE5NDYw\nMGM4NGU5NzEKc1BqWmV3WTlqUU4xdE9vZXFkb3dtNE92cTNUcllEM2NTWXVxeGQ2\nRndObEhrZ2VSZ3lYSGFhSzh2NnVSNERyTwpGd1dmNjVna1FWdWduUWpDQ29UMDUr\nMm96NGRrUlpDNDFoY1RlL0ZrdXp1WDdQVHBlL0t1YXJiVDVjd2ZCY1I2ClBwanRK\nVzhOTFZ3UUdLUEJ1SGd2RDg1ZUwzYlJURjRpN0M2aDNlNFhXN2cKLS0tIGUwc05o\nMGFjSm9zREtVZXJWUE5wSmZ3MFFYbTVsMVpxNnV1NTYvUkFpMVUK8BWH7JgkFaRs\nSDIU5APiPXldsjSFN3Ae7vEiugR5A91M9WIGzoZBerf7MXDq2EqOp/hkUcSns5Qv\ndGEb/4nx5w==\n-----END AGE ENCRYPTED FILE-----\n","tags":[["t","echo-ledger-reveal"],["round","33389812"],["reveal_at","2026-10-26T00:00:00.000000Z"],["chain","52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"],["digest","c7218af247b604a2dcf44cc788ce5e7180b492a91b0005dd7dbeea118ffeee33"],["ct","a79f9f6b909b5ce75081420bc9e6dcfc45b4745c6751ef7873172781163b5d47"],["seal","0577329be46aad3dc6b6ba6588d255eb95d6137d7abd48f8ad8568134ff4437d"]]}
diff --git a/reveal/v5-000004/r33389812.tlock b/reveal/v5-000004/r33389812.tlock
new file mode 100644
index 0000000..c07742b
--- /dev/null
+++ b/reveal/v5-000004/r33389812.tlock
@@ -0,0 +1,11 @@
1-----BEGIN AGE ENCRYPTED FILE-----
2YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHRsb2NrIDMzMzg5ODEyIDUyZGI5YmE3
3MGUwY2MwZjZlYWY3ODAzZGQwNzQ0N2ExZjU0Nzc3MzVmZDNmNjYxNzkyYmE5NDYw
4MGM4NGU5NzEKc1BqWmV3WTlqUU4xdE9vZXFkb3dtNE92cTNUcllEM2NTWXVxeGQ2
5RndObEhrZ2VSZ3lYSGFhSzh2NnVSNERyTwpGd1dmNjVna1FWdWduUWpDQ29UMDUr
6Mm96NGRrUlpDNDFoY1RlL0ZrdXp1WDdQVHBlL0t1YXJiVDVjd2ZCY1I2ClBwanRK
7VzhOTFZ3UUdLUEJ1SGd2RDg1ZUwzYlJURjRpN0M2aDNlNFhXN2cKLS0tIGUwc05o
8MGFjSm9zREtVZXJWUE5wSmZ3MFFYbTVsMVpxNnV1NTYvUkFpMVUK8BWH7JgkFaRs
9SDIU5APiPXldsjSFN3Ae7vEiugR5A91M9WIGzoZBerf7MXDq2EqOp/hkUcSns5Qv
10dGEb/4nx5w==
11-----END AGE ENCRYPTED FILE-----
diff --git a/reveal/v5-000004/r34541812.ct b/reveal/v5-000004/r34541812.ct
new file mode 100644
index 0000000..e94d924
--- /dev/null
+++ b/reveal/v5-000004/r34541812.ct
Binary files differ
diff --git a/reveal/v5-000004/r34541812.nostr.json b/reveal/v5-000004/r34541812.nostr.json
new file mode 100644
index 0000000..618eb16
--- /dev/null
+++ b/reveal/v5-000004/r34541812.nostr.json
@@ -0,0 +1 @@
{"content":"-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHRsb2NrIDM0NTQxODEyIDUyZGI5YmE3\nMGUwY2MwZjZlYWY3ODAzZGQwNzQ0N2ExZjU0Nzc3MzVmZDNmNjYxNzkyYmE5NDYw\nMGM4NGU5NzEKbUs1MkdILzk3YkRzN3p1S0x0cmo5YnptRXNIN05PVjFRcm9ES0Uv\nNGlOOVBoL21BRjljZmdGR2YrbFFtM0IrKwpEL0dzcXFXR2NVWVZTTjZDMElJbXJm\nQkdXMWkxOU1qL24rNm1oc1dtbEVEbVRyUlJIUmk0eGlHaSt1cjhxeHVkClBCMGZk\naGY3bkh4b3JTVVV4cGtjNjlIaDBiZSt1YUNiUmJFQzdoam9WYnMKLS0tIHZraFpR\nUVM5VnF0VmVhN3k3OVNxVUVzQkF3a1RXczFSWC9FVGhodDhmM2MKxtV6Gr7sJqmI\nbRUKCeBpkNCnRbdE4TEjR4E5HphvFvPhfKyD2jFUxEwYyWyGr/046XtRJHZfr42o\nWnEmZGeEew==\n-----END AGE ENCRYPTED FILE-----\n","tags":[["t","echo-ledger-reveal"],["round","34541812"],["reveal_at","2026-12-05T00:00:00.000000Z"],["chain","52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"],["digest","71f3b8b4de94f03c6422fdf67d055535e79b0411595168d406729c17f62a988a"],["ct","c056fab6d09a1abf85c6c41fba220e0a5982e32f67cfb67a8e76e5b2a1c3aced"],["seal","0577329be46aad3dc6b6ba6588d255eb95d6137d7abd48f8ad8568134ff4437d"]]}
diff --git a/reveal/v5-000004/r34541812.tlock b/reveal/v5-000004/r34541812.tlock
new file mode 100644
index 0000000..1912b1d
--- /dev/null
+++ b/reveal/v5-000004/r34541812.tlock
@@ -0,0 +1,11 @@
1-----BEGIN AGE ENCRYPTED FILE-----
2YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IHRsb2NrIDM0NTQxODEyIDUyZGI5YmE3
3MGUwY2MwZjZlYWY3ODAzZGQwNzQ0N2ExZjU0Nzc3MzVmZDNmNjYxNzkyYmE5NDYw
4MGM4NGU5NzEKbUs1MkdILzk3YkRzN3p1S0x0cmo5YnptRXNIN05PVjFRcm9ES0Uv
5NGlOOVBoL21BRjljZmdGR2YrbFFtM0IrKwpEL0dzcXFXR2NVWVZTTjZDMElJbXJm
6QkdXMWkxOU1qL24rNm1oc1dtbEVEbVRyUlJIUmk0eGlHaSt1cjhxeHVkClBCMGZk
7aGY3bkh4b3JTVVV4cGtjNjlIaDBiZSt1YUNiUmJFQzdoam9WYnMKLS0tIHZraFpR
8UVM5VnF0VmVhN3k3OVNxVUVzQkF3a1RXczFSWC9FVGhodDhmM2MKxtV6Gr7sJqmI
9bRUKCeBpkNCnRbdE4TEjR4E5HphvFvPhfKyD2jFUxEwYyWyGr/046XtRJHZfr42o
10WnEmZGeEew==
11-----END AGE ENCRYPTED FILE-----
diff --git a/reveal/v5-000004/seal.json b/reveal/v5-000004/seal.json
new file mode 100644
index 0000000..6d21747
--- /dev/null
+++ b/reveal/v5-000004/seal.json
@@ -0,0 +1,43 @@
1{
2 "algorithm": "sha256-live-v5",
3 "anchor_ref": "sha256:0577329be46aad3dc6b6ba6588d255eb95d6137d7abd48f8ad8568134ff4437d",
4 "covers_after": "2026-10-03T23:07:43.366835Z",
5 "covers_through": "2026-10-04T00:08:35.532031Z",
6 "digest": "0577329be46aad3dc6b6ba6588d255eb95d6137d7abd48f8ad8568134ff4437d",
7 "follows_seq": 34,
8 "n_forecasts": 24,
9 "n_groups": 2,
10 "n_withheld": 24,
11 "prev_digest": "baba31b4d3ca99d52c6204fceab089efc398aa3c152bc7305e9cd3ea83997c19",
12 "prev_seq": 3,
13 "sealed_at": "2026-10-04T01:10:50.806839Z",
14 "seq": 4,
15 "variant": {
16 "beacon": {
17 "chain_hash": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
18 "genesis_time": 1692803367,
19 "period": 3,
20 "relays": [
21 "https://api.drand.sh",
22 "https://api2.drand.sh",
23 "https://api3.drand.sh",
24 "https://drand.cloudflare.com"
25 ],
26 "scheme": "bls-unchained-g1-rfc9380"
27 },
28 "cipher": "chacha20poly1305-zlib6-nonce0",
29 "gate": {
30 "default": "withheld: a kept salt can be released later; an opened text can never be withdrawn",
31 "opened": "none",
32 "why": "no decision by Echo yet"
33 },
34 "line": "numeric<TAB>open:<v4 line> | numeric<TAB>withheld:sha256(salt||v4 line)",
35 "reveal": "utc-day-after-close",
36 "salts": "key ring: one content a seal, one part a line",
37 "tool": {
38 "name": "tle",
39 "sha256": "69157d8c14a1d390d1b7744946288f83d4b10748471d5447f9abb77a5902c541",
40 "version": "tlock v1.2.0 -- github.com/drand/tlock"
41 }
42 }
43}