From 80648df394c6ce1031bcf94af36d1edcea0938bd Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 23 Sep 2026 10:06:24 -0400 Subject: [PATCH 01/80] test(zeronym): model the divert protocol in Quint and check it in CI zeronym/spec/divert.qnt models the wallet, shim and hub over a mixnet that loses, duplicates and reorders messages, with a hub that can be set to lie. It mirrors the get_transaction reply arms in intercept.rs, the queue-first lookup in server.rs, admission in queue.rs and the epoch flush in batcher.rs. check.sh simulates eight module/invariant pairs and asserts each outcome: - the four safety claims hold for the current code (L4 guard, queued bytes stay in the hub, a queue hit reads as pending, bytes only after publish); - ecb4641f7e and 45e408f0ff reappear with their fixes switched off; - three known gaps stay visible: silent refusal over Nym, a lying hub faking pending, and the flush-window pending -> NOT_FOUND flip. It runs as a cheap-tier job in zeronym-guards. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/zeronym-guards.yml | 11 + zeronym/spec/check.sh | 46 +++++ zeronym/spec/divert.qnt | 290 +++++++++++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100755 zeronym/spec/check.sh create mode 100644 zeronym/spec/divert.qnt diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index 200c33db..0b96fb34 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -40,6 +40,17 @@ jobs: - name: Repository claim guards run: sh zeronym/guards.sh + # The Quint model of the shim <-> hub divert protocol. Simulation only, so it + # stays in the cheap tier. + spec: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + # The runner image's Node is enough for Quint. + - name: Divert protocol model + run: sh zeronym/spec/check.sh + tests: runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh new file mode 100755 index 00000000..bd392c83 --- /dev/null +++ b/zeronym/spec/check.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Simulate the divert model and assert every invariant's expected outcome. +# +# "holds" rows are the protocol's safety claims. "fails" rows are the two fixed +# bugs, reproduced with their fix switched off, and the three known gaps: if +# one starts holding, the code or the model changed and divert.qnt needs a +# look. +# +# QUINT defaults to `npx @informalsystems/quint@0.32.0`. QUINT_BACKEND=typescript +# skips the Rust evaluator, which is downloaded from GitHub on first use. +set -u + +cd "$(dirname "$0")" +QUINT=${QUINT:-"npx --yes @informalsystems/quint@0.32.0"} +BACKEND=${QUINT_BACKEND:-rust} +SAMPLES=${QUINT_SAMPLES:-20000} +failures=0 + +check() { + main=$1 invariant=$2 expect=$3 + if $QUINT run divert.qnt --backend="$BACKEND" --main="$main" --invariant="$invariant" \ + --max-samples="$SAMPLES" --max-steps=30 --seed=7 >/dev/null 2>&1; then + got=holds + else + got=fails + fi + if [ "$got" = "$expect" ]; then + echo "ok $main.$invariant $got" + else + echo "FAIL $main.$invariant expected $expect, got $got" + failures=$((failures + 1)) + fi +} + +$QUINT typecheck divert.qnt || exit 1 + +check current safety holds +check honest safety holds +check honest pendingIsTrue holds +check before_ecb4641 pendingVisible fails +check before_45e408f noQueuedBytes fails +check current noSilentRefusal fails +check current pendingIsTrue fails +check honest pendingMonotone fails + +exit "$failures" diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt new file mode 100644 index 00000000..c754c4d5 --- /dev/null +++ b/zeronym/spec/divert.qnt @@ -0,0 +1,290 @@ +// Shim <-> hub divert protocol, over the Nym transport. +// +// Scope: a wallet diverts migrations through the stateless shim to the hub, +// then polls GetTransaction. The hub queues, flushes on epochs, and answers +// lookups from its queue or its indexer. The mixnet may lose, duplicate and +// reorder any message. A transaction's bytes are modelled as its id, and "" +// is an empty body. +// +// Code this mirrors: +// shim intercept.rs get_transaction reply arms, nym.rs nonce correlation +// hub server.rs lookup, queue.rs admit, batcher.rs flush +// +// Run all of these with spec/check.sh, or one at a time (npm i -g @informalsystems/quint): +// quint run --main=current --invariant=safety spec/divert.qnt holds +// quint run --main=before_ecb4641 --invariant=pendingVisible ... fails: the sentinel bug +// quint run --main=before_45e408f --invariant=noQueuedBytes ... fails: the byte leak +// quint run --main=current --invariant=noSilentRefusal ... fails: known gap +// quint run --main=current --invariant=pendingIsTrue ... fails: known gap (lying hub) +// quint run --main=honest --invariant=pendingMonotone ... fails: known gap (flush window) +// Simulation samples random traces; `quint verify` (Apalache) checks every +// trace up to a bound. +module divert { + // Toggles that reproduce fixed bugs. + const SENTINEL_ARM: bool // ecb4641f7e: shim relays found/height 0/empty ahead of the L4 guard + const HUB_HIDES_QUEUED: bool // 45e408f0ff: hub answers a queue hit with no bytes + const HONEST_HUB: bool // false lets the hub answer lookups with anything + const TXS: Set[str] + const MAX_NONCE: int + + pure val EMPTY = "" + + // One record shape for every message; `kind` picks the fields that matter. + // `hubSaw` is a ghost field: the hub's state for the tx when it replied. + type Msg = { + kind: str, // "submit" | "lookup" | "reply" + tx: str, + nonce: int, + disp: str, // "found" | "notfound" | "error" + height: int, + data: str, + hubSaw: str, + } + + // What the wallet saw for one lookup. + type Obs = { tx: str, status: str, data: str, hubSaw: str } + + // hub[tx]: "absent" | "queued" | "flushing" | "mempool" | "mined" | "dropped" + var hub: str -> str + var net: Set[Msg] + var waiters: int -> str // shim: lookup nonce -> queried tx + var nextNonce: int + var told: Set[str] // wallet was told its submit succeeded + var refused: Set[str] // hub refused a submit (the ack never reaches the wallet) + var obs: List[Obs] + + pure def submitMsg(t: str): Msg = + { kind: "submit", tx: t, nonce: -1, disp: "", height: 0, data: EMPTY, hubSaw: "" } + + pure def lookupMsg(t: str, n: int): Msg = + { kind: "lookup", tx: t, nonce: n, disp: "", height: 0, data: EMPTY, hubSaw: "" } + + pure def replyMsg(t: str, n: int, disp: str, h: int, d: str, saw: str): Msg = + { kind: "reply", tx: t, nonce: n, disp: disp, height: h, data: d, hubSaw: saw } + + // server.rs lookup: queue first, then the indexer. + pure def hubReply(t: str, n: int, st: str, hides: bool): Msg = + if (st == "queued") replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) + else if (st == "mempool") replyMsg(t, n, "found", 0, t, st) + else if (st == "mined") replyMsg(t, n, "found", 1, t, st) + else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped + + // intercept.rs get_transaction: the reply arms, in order. + pure def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = + if (r.disp == "found" and sentinelArm and r.data == EMPTY and r.height == 0) + { tx: q, status: "pending", data: EMPTY, hubSaw: r.hubSaw } + else if (r.disp == "found") + // L4 guard: the bytes must be the queried transaction. + if (r.data == q) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw } + else { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw } + else if (r.disp == "notfound") { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw } + else { tx: q, status: "unavailable", data: EMPTY, hubSaw: r.hubSaw } + + action init = all { + hub' = TXS.mapBy(_ => "absent"), + net' = Set(), + waiters' = Map(), + nextNonce' = 0, + told' = Set(), + refused' = Set(), + obs' = List(), + } + + action unchangedExcept_net_hub = all { + waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, + } + + // Nym submit is dispatch-only: the wallet hears success once the frame is + // handed to the transport (nym.rs send_submit). + action walletSubmit(t: str): bool = all { + net' = net.union(Set(submitMsg(t))), + told' = told.union(Set(t)), + hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, + } + + // queue.rs admit. Delivery leaves the message in `net`, so duplicates are + // free; a duplicate of a queued tx is a no-op (sha256 dedup). + action hubAdmit(t: str): bool = all { + net.contains(submitMsg(t)), + hub.get(t).in(Set("absent", "dropped")), + hub' = hub.set(t, "queued"), + net' = net, + unchangedExcept_net_hub, + } + + // TipStale, Draining, ExpiryTooTight, Full: the ack says refused and the + // shim has already dropped the waiter. + action hubRefuse(t: str): bool = all { + net.contains(submitMsg(t)), + hub.get(t).in(Set("absent", "dropped")), + refused' = refused.union(Set(t)), + hub' = hub, net' = net, + waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, + } + + // batcher.rs: an epoch boundary drains the whole queue at once. + action flushStart: bool = all { + TXS.exists(t => hub.get(t) == "queued"), + hub' = hub.keys().mapBy(t => if (hub.get(t) == "queued") "flushing" else hub.get(t)), + net' = net, + unchangedExcept_net_hub, + } + + // broadcast_batch verdicts: published, rejected, or retryable (requeued). + action flushOutcome(t: str): bool = all { + hub.get(t) == "flushing", + nondet outcome = Set("mempool", "dropped", "queued").oneOf() + hub' = hub.set(t, outcome), + net' = net, + unchangedExcept_net_hub, + } + + action mine(t: str): bool = all { + hub.get(t) == "mempool", + hub' = hub.set(t, "mined"), + net' = net, + unchangedExcept_net_hub, + } + + action walletLookup(t: str): bool = all { + nextNonce < MAX_NONCE, + net' = net.union(Set(lookupMsg(t, nextNonce))), + waiters' = waiters.put(nextNonce, t), + nextNonce' = nextNonce + 1, + hub' = hub, told' = told, refused' = refused, obs' = obs, + } + + action hubAnswer(n: int, t: str): bool = all { + net.contains(lookupMsg(t, n)), + net' = net.union(Set(hubReply(t, n, hub.get(t), HUB_HIDES_QUEUED))), + hub' = hub, + unchangedExcept_net_hub, + } + + // Indexer unreachable (chain.rs: no endpoint answered). A queue hit answers + // before the indexer is asked. + action hubError(n: int, t: str): bool = all { + net.contains(lookupMsg(t, n)), + hub.get(t) != "queued", + net' = net.union(Set(replyMsg(t, n, "error", 0, EMPTY, hub.get(t)))), + hub' = hub, + unchangedExcept_net_hub, + } + + // A buggy or hostile hub may answer any lookup with anything. + action hubLie(n: int, t: str): bool = all { + not(HONEST_HUB), + net.contains(lookupMsg(t, n)), + nondet d = TXS.union(Set(EMPTY)).oneOf() + nondet h = Set(0, 1).oneOf() + net' = net.union(Set(replyMsg(t, n, "found", h, d, "lie"))), + hub' = hub, + unchangedExcept_net_hub, + } + + // nym.rs: a reply for an unknown nonce is dropped; a known one resolves the + // waiter once. + action shimDeliver(r: Msg): bool = all { + r.kind == "reply", + waiters.keys().contains(r.nonce), + obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), + waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, + } + + // 90 s lookup deadline. + action shimTimeout(n: int): bool = all { + waiters.keys().contains(n), + obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout" }), + waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, + } + + action lose(m: Msg): bool = all { + net' = net.exclude(Set(m)), + hub' = hub, + unchangedExcept_net_hub, + } + + action step = { + nondet t = TXS.oneOf() + nondet n = 0.to(MAX_NONCE - 1).oneOf() + any { + walletSubmit(t), + hubAdmit(t), + hubRefuse(t), + flushStart, + flushOutcome(t), + mine(t), + walletLookup(t), + hubAnswer(n, t), + hubError(n, t), + hubLie(n, t), + shimTimeout(n), + all { net.size() > 0, nondet m = net.oneOf() any { shimDeliver(m), lose(m) } }, + } + } + + // ---- Safety: must hold for the current code ---- + + // L4: bytes served to the wallet are always the queried transaction. + val guardHolds: bool = obs.foldl(true, (ok, o) => ok and (o.status != "tx" or o.data == o.tx)) + + // A queued migration's bytes never leave the hub before publication. + val noQueuedBytes: bool = net.forall(m => + not(m.kind == "reply" and m.hubSaw == "queued" and m.data != EMPTY)) + + // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). + val pendingVisible: bool = obs.foldl(true, (ok, o) => + ok and (o.hubSaw != "queued" or o.status == "pending")) + + // The wallet sees a transaction's bytes only once it is published. + val noEarlyBytes: bool = obs.foldl(true, (ok, o) => + ok and (o.status != "tx" or o.hubSaw.in(Set("mempool", "mined", "lie")))) + + val safety: bool = guardHolds and noQueuedBytes and pendingVisible and noEarlyBytes + + // ---- Known gaps: expected to fail; each counterexample documents one ---- + + // Over Nym the wallet is told success even when the hub refuses. + val noSilentRefusal: bool = told.intersect(refused) == Set() + + // Pending only ever comes from a real queue hit. A lying hub can fake it. + val pendingIsTrue: bool = obs.foldl(true, (ok, o) => + ok and (o.status != "pending" or o.hubSaw == "queued")) + + // Once pending, a tx reads as not-found only if the hub dropped it. The + // flush window (server.rs lookup note) breaks this. + val pendingMonotone: bool = + 0.to(obs.length() - 1).forall(i => 0.to(obs.length() - 1).forall(j => + not(i < j and obs[i].tx == obs[j].tx and obs[i].status == "pending" + and obs[j].status == "notfound" and obs[j].hubSaw != "dropped"))) +} + +module current { + import divert( + SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = false, + TXS = Set("a", "b"), MAX_NONCE = 4, + ).* +} + +module honest { + import divert( + SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = true, + TXS = Set("a", "b"), MAX_NONCE = 4, + ).* +} + +module before_ecb4641 { + import divert( + SENTINEL_ARM = false, HUB_HIDES_QUEUED = true, HONEST_HUB = true, + TXS = Set("a", "b"), MAX_NONCE = 4, + ).* +} + +module before_45e408f { + import divert( + SENTINEL_ARM = false, HUB_HIDES_QUEUED = false, HONEST_HUB = true, + TXS = Set("a", "b"), MAX_NONCE = 4, + ).* +} From 4a456c9f63223ecff6112802e36d86f96e398461 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 23 Sep 2026 11:24:28 -0400 Subject: [PATCH 02/80] test(zeronym): scope the divert invariants by hub honesty; verdict-aware check.sh From review on #82. Lying replies were tagged hubSaw "lie", which exempted them from noQueuedBytes and noEarlyBytes, so a lying hub releasing a queued migration's bytes passed as safe. Replies now carry the hub's real state plus an `honest` ghost flag. The shim's claims (guardHolds, pendingVisible) are checked against a lying hub as shimSafety; the hub's claims (noQueuedBytes, noEarlyBytes) are checked against an honest hub, and current.noEarlyBytes is a new expected failure recording that attestation is what stands between a queued migration and early release. check.sh read every nonzero exit as a counterexample, so a crash, a download failure or a misspelt invariant passed each "fails" row. It now classifies runs by Quint's [ok] / [violation] line and fails the gate on anything else. Co-Authored-By: Claude Opus 5.5 --- zeronym/spec/check.sh | 28 ++++++++++++++-------- zeronym/spec/divert.qnt | 51 +++++++++++++++++++++++++---------------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh index bd392c83..ad63d5c5 100755 --- a/zeronym/spec/check.sh +++ b/zeronym/spec/check.sh @@ -1,8 +1,9 @@ #!/bin/sh # Simulate the divert model and assert every invariant's expected outcome. # -# "holds" rows are the protocol's safety claims. "fails" rows are the two fixed -# bugs, reproduced with their fix switched off, and the three known gaps: if +# "holds" rows are the protocol's safety claims: the shim's against any hub, +# the hub's against an honest one. "fails" rows are the two fixed +# bugs, reproduced with their fix switched off, and the known gaps: if # one starts holding, the code or the model changed and divert.qnt needs a # look. # @@ -16,14 +17,22 @@ BACKEND=${QUINT_BACKEND:-rust} SAMPLES=${QUINT_SAMPLES:-20000} failures=0 +# Classified by Quint's verdict line, so a crash, a download failure or a +# misspelt invariant fails the gate instead of passing as a counterexample. check() { main=$1 invariant=$2 expect=$3 - if $QUINT run divert.qnt --backend="$BACKEND" --main="$main" --invariant="$invariant" \ - --max-samples="$SAMPLES" --max-steps=30 --seed=7 >/dev/null 2>&1; then - got=holds - else - got=fails - fi + out=$($QUINT run divert.qnt --backend="$BACKEND" --main="$main" --invariant="$invariant" \ + --max-samples="$SAMPLES" --max-steps=30 --seed=7 2>&1) + case $out in + *"[ok] No violation found"*) got=holds ;; + *"[violation] Found an issue"*) got=fails ;; + *) + echo "ERROR $main.$invariant: quint gave no verdict" + echo "$out" | tail -20 + failures=$((failures + 1)) + return + ;; + esac if [ "$got" = "$expect" ]; then echo "ok $main.$invariant $got" else @@ -34,11 +43,12 @@ check() { $QUINT typecheck divert.qnt || exit 1 -check current safety holds +check current shimSafety holds check honest safety holds check honest pendingIsTrue holds check before_ecb4641 pendingVisible fails check before_45e408f noQueuedBytes fails +check current noEarlyBytes fails check current noSilentRefusal fails check current pendingIsTrue fails check honest pendingMonotone fails diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index c754c4d5..73e4da5a 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -11,7 +11,9 @@ // hub server.rs lookup, queue.rs admit, batcher.rs flush // // Run all of these with spec/check.sh, or one at a time (npm i -g @informalsystems/quint): -// quint run --main=current --invariant=safety spec/divert.qnt holds +// quint run --main=honest --invariant=safety spec/divert.qnt holds +// quint run --main=current --invariant=shimSafety ... holds +// quint run --main=current --invariant=noEarlyBytes ... fails: known gap (lying hub) // quint run --main=before_ecb4641 --invariant=pendingVisible ... fails: the sentinel bug // quint run --main=before_45e408f --invariant=noQueuedBytes ... fails: the byte leak // quint run --main=current --invariant=noSilentRefusal ... fails: known gap @@ -30,7 +32,8 @@ module divert { pure val EMPTY = "" // One record shape for every message; `kind` picks the fields that matter. - // `hubSaw` is a ghost field: the hub's state for the tx when it replied. + // `hubSaw` and `honest` are ghost fields: the hub's state for the tx when it + // replied, and whether the reply followed the hub's code. type Msg = { kind: str, // "submit" | "lookup" | "reply" tx: str, @@ -39,10 +42,11 @@ module divert { height: int, data: str, hubSaw: str, + honest: bool, } // What the wallet saw for one lookup. - type Obs = { tx: str, status: str, data: str, hubSaw: str } + type Obs = { tx: str, status: str, data: str, hubSaw: str, honest: bool } // hub[tx]: "absent" | "queued" | "flushing" | "mempool" | "mined" | "dropped" var hub: str -> str @@ -54,13 +58,13 @@ module divert { var obs: List[Obs] pure def submitMsg(t: str): Msg = - { kind: "submit", tx: t, nonce: -1, disp: "", height: 0, data: EMPTY, hubSaw: "" } + { kind: "submit", tx: t, nonce: -1, disp: "", height: 0, data: EMPTY, hubSaw: "", honest: true } pure def lookupMsg(t: str, n: int): Msg = - { kind: "lookup", tx: t, nonce: n, disp: "", height: 0, data: EMPTY, hubSaw: "" } + { kind: "lookup", tx: t, nonce: n, disp: "", height: 0, data: EMPTY, hubSaw: "", honest: true } pure def replyMsg(t: str, n: int, disp: str, h: int, d: str, saw: str): Msg = - { kind: "reply", tx: t, nonce: n, disp: disp, height: h, data: d, hubSaw: saw } + { kind: "reply", tx: t, nonce: n, disp: disp, height: h, data: d, hubSaw: saw, honest: true } // server.rs lookup: queue first, then the indexer. pure def hubReply(t: str, n: int, st: str, hides: bool): Msg = @@ -72,13 +76,13 @@ module divert { // intercept.rs get_transaction: the reply arms, in order. pure def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = if (r.disp == "found" and sentinelArm and r.data == EMPTY and r.height == 0) - { tx: q, status: "pending", data: EMPTY, hubSaw: r.hubSaw } + { tx: q, status: "pending", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } else if (r.disp == "found") // L4 guard: the bytes must be the queried transaction. - if (r.data == q) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw } - else { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw } - else if (r.disp == "notfound") { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw } - else { tx: q, status: "unavailable", data: EMPTY, hubSaw: r.hubSaw } + if (r.data == q) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw, honest: r.honest } + else { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } + else if (r.disp == "notfound") { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } + else { tx: q, status: "unavailable", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } action init = all { hub' = TXS.mapBy(_ => "absent"), @@ -177,7 +181,7 @@ module divert { net.contains(lookupMsg(t, n)), nondet d = TXS.union(Set(EMPTY)).oneOf() nondet h = Set(0, 1).oneOf() - net' = net.union(Set(replyMsg(t, n, "found", h, d, "lie"))), + net' = net.union(Set(replyMsg(t, n, "found", h, d, hub.get(t)).with("honest", false))), hub' = hub, unchangedExcept_net_hub, } @@ -195,7 +199,7 @@ module divert { // 90 s lookup deadline. action shimTimeout(n: int): bool = all { waiters.keys().contains(n), - obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout" }), + obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, } @@ -225,30 +229,37 @@ module divert { } } - // ---- Safety: must hold for the current code ---- + // ---- Shim claims: hold against any hub ---- // L4: bytes served to the wallet are always the queried transaction. val guardHolds: bool = obs.foldl(true, (ok, o) => ok and (o.status != "tx" or o.data == o.tx)) + // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). + val pendingVisible: bool = obs.foldl(true, (ok, o) => + ok and (not(o.honest) or o.hubSaw != "queued" or o.status == "pending")) + + val shimSafety: bool = guardHolds and pendingVisible + + // ---- Hub claims: hold for an honest hub; attestation is what makes it one ---- + // A queued migration's bytes never leave the hub before publication. val noQueuedBytes: bool = net.forall(m => not(m.kind == "reply" and m.hubSaw == "queued" and m.data != EMPTY)) - // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). - val pendingVisible: bool = obs.foldl(true, (ok, o) => - ok and (o.hubSaw != "queued" or o.status == "pending")) - // The wallet sees a transaction's bytes only once it is published. val noEarlyBytes: bool = obs.foldl(true, (ok, o) => - ok and (o.status != "tx" or o.hubSaw.in(Set("mempool", "mined", "lie")))) + ok and (o.status != "tx" or o.hubSaw.in(Set("mempool", "mined")))) - val safety: bool = guardHolds and noQueuedBytes and pendingVisible and noEarlyBytes + val safety: bool = shimSafety and noQueuedBytes and noEarlyBytes // ---- Known gaps: expected to fail; each counterexample documents one ---- // Over Nym the wallet is told success even when the hub refuses. val noSilentRefusal: bool = told.intersect(refused) == Set() + // noQueuedBytes and noEarlyBytes also fail under a lying hub: a hub holding + // a queued migration can release its bytes early. + // Pending only ever comes from a real queue hit. A lying hub can fake it. val pendingIsTrue: bool = obs.foldl(true, (ok, o) => ok and (o.status != "pending" or o.hubSaw == "queued")) From 2e90d6f2c80f8e654a4b0392eb9aa3aee32473fe Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 23 Sep 2026 16:26:21 -0400 Subject: [PATCH 03/80] ci(zeronym): pin the spec job's checkout and give it a read-only token From review on #82: the job runs code fetched at run time (npx, Quint's evaluator), so it should run with a SHA-pinned checkout, no persisted credentials, and contents: read. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/zeronym-guards.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index 0b96fb34..4941a773 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -45,8 +45,14 @@ jobs: spec: runs-on: ubuntu-latest timeout-minutes: 10 + # It runs code fetched at run time (npx, Quint's evaluator), so it gets a + # read-only token and a pinned checkout. + permissions: + contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # The runner image's Node is enough for Quint. - name: Divert protocol model run: sh zeronym/spec/check.sh From 262bd78e78fd755b1a23fced3b8e74640132a44a Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Wed, 23 Sep 2026 17:52:43 -0400 Subject: [PATCH 04/80] test(zeronym): assert the lying-hub noQueuedBytes counterexample From review on #82: divert.qnt documents that a lying hub breaks both hub claims, but check.sh asserted only noEarlyBytes, so the noQueuedBytes gap could disappear without failing the gate. Ten rows now. Co-Authored-By: Claude Opus 5.5 --- zeronym/spec/check.sh | 1 + zeronym/spec/divert.qnt | 1 + 2 files changed, 2 insertions(+) diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh index ad63d5c5..849eddbf 100755 --- a/zeronym/spec/check.sh +++ b/zeronym/spec/check.sh @@ -48,6 +48,7 @@ check honest safety holds check honest pendingIsTrue holds check before_ecb4641 pendingVisible fails check before_45e408f noQueuedBytes fails +check current noQueuedBytes fails check current noEarlyBytes fails check current noSilentRefusal fails check current pendingIsTrue fails diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index 73e4da5a..53d85218 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -13,6 +13,7 @@ // Run all of these with spec/check.sh, or one at a time (npm i -g @informalsystems/quint): // quint run --main=honest --invariant=safety spec/divert.qnt holds // quint run --main=current --invariant=shimSafety ... holds +// quint run --main=current --invariant=noQueuedBytes ... fails: known gap (lying hub) // quint run --main=current --invariant=noEarlyBytes ... fails: known gap (lying hub) // quint run --main=before_ecb4641 --invariant=pendingVisible ... fails: the sentinel bug // quint run --main=before_45e408f --invariant=noQueuedBytes ... fails: the byte leak From 5f1b3d869b18f65b6a8a41c1d92658f64ff89308 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 19:04:55 +0400 Subject: [PATCH 05/80] test(zeronym): record a non-conforming indexer's zero reply An honest hub forwards a Found with an empty body and height 0, and the shim renders pending. A conforming indexer does not send that image; the model now can, and pendingIsTrue fails when it does. Co-authored-by: Cursor --- zeronym/spec/check.sh | 10 ++++++++++ zeronym/spec/divert.qnt | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh index 849eddbf..a3defb5a 100755 --- a/zeronym/spec/check.sh +++ b/zeronym/spec/check.sh @@ -43,9 +43,19 @@ check() { $QUINT typecheck divert.qnt || exit 1 +echo "---- runs" +for main in badIndexer; do + if $QUINT test divert.qnt --main "$main"; then + : + else + failures=$((failures + 1)) + fi +done + check current shimSafety holds check honest safety holds check honest pendingIsTrue holds +check badIndexer pendingIsTrue fails check before_ecb4641 pendingVisible fails check before_45e408f noQueuedBytes fails check current noQueuedBytes fails diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index 53d85218..b537d42a 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -19,6 +19,7 @@ // quint run --main=before_45e408f --invariant=noQueuedBytes ... fails: the byte leak // quint run --main=current --invariant=noSilentRefusal ... fails: known gap // quint run --main=current --invariant=pendingIsTrue ... fails: known gap (lying hub) +// quint run --main=badIndexer --invariant=pendingIsTrue ... fails: zero RawTransaction // quint run --main=honest --invariant=pendingMonotone ... fails: known gap (flush window) // Simulation samples random traces; `quint verify` (Apalache) checks every // trace up to a bound. @@ -27,6 +28,7 @@ module divert { const SENTINEL_ARM: bool // ecb4641f7e: shim relays found/height 0/empty ahead of the L4 guard const HUB_HIDES_QUEUED: bool // 45e408f0ff: hub answers a queue hit with no bytes const HONEST_HUB: bool // false lets the hub answer lookups with anything + const CONFORMING_INDEXER: bool // false lets a missed lookup return a zero RawTransaction const TXS: Set[str] const MAX_NONCE: int @@ -176,6 +178,18 @@ module divert { unchangedExcept_net_hub, } + // Queue miss forwards the indexer image. A non-conforming indexer answers + // that path with a zero RawTransaction: found, empty body, height 0. The + // shim renders pending. A queue hit returns before the indexer is asked. + action indexerZero(n: int, t: str): bool = all { + not(CONFORMING_INDEXER), + net.contains(lookupMsg(t, n)), + hub.get(t) != "queued", + net' = net.union(Set(replyMsg(t, n, "found", 0, EMPTY, hub.get(t)))), + hub' = hub, + unchangedExcept_net_hub, + } + // A buggy or hostile hub may answer any lookup with anything. action hubLie(n: int, t: str): bool = all { not(HONEST_HUB), @@ -224,6 +238,7 @@ module divert { walletLookup(t), hubAnswer(n, t), hubError(n, t), + indexerZero(n, t), hubLie(n, t), shimTimeout(n), all { net.size() > 0, nondet m = net.oneOf() any { shimDeliver(m), lose(m) } }, @@ -276,6 +291,7 @@ module divert { module current { import divert( SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = false, + CONFORMING_INDEXER = true, TXS = Set("a", "b"), MAX_NONCE = 4, ).* } @@ -283,6 +299,7 @@ module current { module honest { import divert( SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = true, + CONFORMING_INDEXER = true, TXS = Set("a", "b"), MAX_NONCE = 4, ).* } @@ -290,6 +307,7 @@ module honest { module before_ecb4641 { import divert( SENTINEL_ARM = false, HUB_HIDES_QUEUED = true, HONEST_HUB = true, + CONFORMING_INDEXER = true, TXS = Set("a", "b"), MAX_NONCE = 4, ).* } @@ -297,6 +315,23 @@ module before_ecb4641 { module before_45e408f { import divert( SENTINEL_ARM = false, HUB_HIDES_QUEUED = false, HONEST_HUB = true, + CONFORMING_INDEXER = true, TXS = Set("a", "b"), MAX_NONCE = 4, ).* } + +// Honest hub, indexer answers the forward path with a zero RawTransaction. +module badIndexer { + import divert( + SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = true, + CONFORMING_INDEXER = false, + TXS = Set("a", "b"), MAX_NONCE = 4, + ).* + + run indexerZeroForgesPendingTest = + init + .then(walletLookup("a")) + .then(indexerZero(0, "a")) + .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "absent"))) + .expect(obs[0].status == "pending" and obs[0].hubSaw == "absent" and obs[0].honest) +} From 82eb22b636c1b3b1b14a1c5f9d65d1094caffc82 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 19:04:55 +0400 Subject: [PATCH 06/80] test(zeronym): check queue-hit pending on an honest hub pendingVisible ignores every reply the hub was not following its code to send, so it is not a claim about an arbitrary hub. The any-reply bundle no longer includes it. Co-authored-by: Cursor --- zeronym/spec/check.sh | 1 + zeronym/spec/divert.qnt | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh index a3defb5a..ce07f146 100755 --- a/zeronym/spec/check.sh +++ b/zeronym/spec/check.sh @@ -53,6 +53,7 @@ for main in badIndexer; do done check current shimSafety holds +check honest pendingVisible holds check honest safety holds check honest pendingIsTrue holds check badIndexer pendingIsTrue fails diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index b537d42a..fa0b977b 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -12,7 +12,8 @@ // // Run all of these with spec/check.sh, or one at a time (npm i -g @informalsystems/quint): // quint run --main=honest --invariant=safety spec/divert.qnt holds -// quint run --main=current --invariant=shimSafety ... holds +// quint run --main=current --invariant=shimSafety ... holds (any reply) +// quint run --main=honest --invariant=pendingVisible ... holds (honest hub) // quint run --main=current --invariant=noQueuedBytes ... fails: known gap (lying hub) // quint run --main=current --invariant=noEarlyBytes ... fails: known gap (lying hub) // quint run --main=before_ecb4641 --invariant=pendingVisible ... fails: the sentinel bug @@ -245,17 +246,20 @@ module divert { } } - // ---- Shim claims: hold against any hub ---- + // ---- Shim claims: hold for any reply the model can deliver ---- // L4: bytes served to the wallet are always the queried transaction. val guardHolds: bool = obs.foldl(true, (ok, o) => ok and (o.status != "tx" or o.data == o.tx)) + val shimSafety: bool = guardHolds + + // ---- Shim claims: hold for an honest hub's replies ---- + // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). + // `not(o.honest)` drops lying replies, so this says nothing about a hub that may lie. val pendingVisible: bool = obs.foldl(true, (ok, o) => ok and (not(o.honest) or o.hubSaw != "queued" or o.status == "pending")) - val shimSafety: bool = guardHolds and pendingVisible - // ---- Hub claims: hold for an honest hub; attestation is what makes it one ---- // A queued migration's bytes never leave the hub before publication. @@ -266,7 +270,7 @@ module divert { val noEarlyBytes: bool = obs.foldl(true, (ok, o) => ok and (o.status != "tx" or o.hubSaw.in(Set("mempool", "mined")))) - val safety: bool = shimSafety and noQueuedBytes and noEarlyBytes + val safety: bool = shimSafety and pendingVisible and noQueuedBytes and noEarlyBytes // ---- Known gaps: expected to fail; each counterexample documents one ---- From d3030580e79157a2310f504247db2b1397a508fb Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 19:04:55 +0400 Subject: [PATCH 07/80] test(zeronym): lock the lookup arms with runs guardHolds repeated the only assignment of status tx, so a hub could not falsify it. The mismatch arm and the queue-hit sentinel are now runs. Co-authored-by: Cursor --- zeronym/spec/check.sh | 6 ++---- zeronym/spec/divert.qnt | 24 +++++++++++++++--------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh index ce07f146..7ed3aafd 100755 --- a/zeronym/spec/check.sh +++ b/zeronym/spec/check.sh @@ -1,8 +1,7 @@ #!/bin/sh # Simulate the divert model and assert every invariant's expected outcome. # -# "holds" rows are the protocol's safety claims: the shim's against any hub, -# the hub's against an honest one. "fails" rows are the two fixed +# "holds" rows are the protocol's safety claims against an honest hub. "fails" rows are the two fixed # bugs, reproduced with their fix switched off, and the known gaps: if # one starts holding, the code or the model changed and divert.qnt needs a # look. @@ -44,7 +43,7 @@ check() { $QUINT typecheck divert.qnt || exit 1 echo "---- runs" -for main in badIndexer; do +for main in badIndexer honest; do if $QUINT test divert.qnt --main "$main"; then : else @@ -52,7 +51,6 @@ for main in badIndexer; do fi done -check current shimSafety holds check honest pendingVisible holds check honest safety holds check honest pendingIsTrue holds diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index fa0b977b..87e72f33 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -12,7 +12,6 @@ // // Run all of these with spec/check.sh, or one at a time (npm i -g @informalsystems/quint): // quint run --main=honest --invariant=safety spec/divert.qnt holds -// quint run --main=current --invariant=shimSafety ... holds (any reply) // quint run --main=honest --invariant=pendingVisible ... holds (honest hub) // quint run --main=current --invariant=noQueuedBytes ... fails: known gap (lying hub) // quint run --main=current --invariant=noEarlyBytes ... fails: known gap (lying hub) @@ -246,13 +245,6 @@ module divert { } } - // ---- Shim claims: hold for any reply the model can deliver ---- - - // L4: bytes served to the wallet are always the queried transaction. - val guardHolds: bool = obs.foldl(true, (ok, o) => ok and (o.status != "tx" or o.data == o.tx)) - - val shimSafety: bool = guardHolds - // ---- Shim claims: hold for an honest hub's replies ---- // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). @@ -270,7 +262,7 @@ module divert { val noEarlyBytes: bool = obs.foldl(true, (ok, o) => ok and (o.status != "tx" or o.hubSaw.in(Set("mempool", "mined")))) - val safety: bool = shimSafety and pendingVisible and noQueuedBytes and noEarlyBytes + val safety: bool = pendingVisible and noQueuedBytes and noEarlyBytes // ---- Known gaps: expected to fail; each counterexample documents one ---- @@ -306,6 +298,20 @@ module honest { CONFORMING_INDEXER = true, TXS = Set("a", "b"), MAX_NONCE = 4, ).* + + // L4: a found body that is not the queried tx is not served. + run mismatchedLookupRefusedTest = + init + .then(walletLookup("a")) + .then(shimDeliver(replyMsg("a", 0, "found", 1, "b", "mined"))) + .expect(obs[0].status == "notfound") + + // The queue-hit image is pending, ahead of L4. + run sentinelLookupPendingTest = + init + .then(walletLookup("a")) + .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) + .expect(obs[0].status == "pending") } module before_ecb4641 { From 16491f2c80c04c48bc86f6d0c7960a481a198384 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 19:04:55 +0400 Subject: [PATCH 08/80] test(zeronym): let a hostile hub hide a queued migration The lie was only a found reply. It can now be not-found or error, and a queued migration reported not-found is an expected failure. Co-authored-by: Cursor --- zeronym/spec/check.sh | 3 ++- zeronym/spec/divert.qnt | 21 ++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh index 7ed3aafd..1ed2e470 100755 --- a/zeronym/spec/check.sh +++ b/zeronym/spec/check.sh @@ -43,7 +43,7 @@ check() { $QUINT typecheck divert.qnt || exit 1 echo "---- runs" -for main in badIndexer honest; do +for main in badIndexer honest current; do if $QUINT test divert.qnt --main "$main"; then : else @@ -61,6 +61,7 @@ check current noQueuedBytes fails check current noEarlyBytes fails check current noSilentRefusal fails check current pendingIsTrue fails +check current queuedNotSuppressed fails check honest pendingMonotone fails exit "$failures" diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index 87e72f33..fe0a63fa 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -19,6 +19,7 @@ // quint run --main=before_45e408f --invariant=noQueuedBytes ... fails: the byte leak // quint run --main=current --invariant=noSilentRefusal ... fails: known gap // quint run --main=current --invariant=pendingIsTrue ... fails: known gap (lying hub) +// quint run --main=current --invariant=queuedNotSuppressed ... fails: hostile not-found // quint run --main=badIndexer --invariant=pendingIsTrue ... fails: zero RawTransaction // quint run --main=honest --invariant=pendingMonotone ... fails: known gap (flush window) // Simulation samples random traces; `quint verify` (Apalache) checks every @@ -27,7 +28,7 @@ module divert { // Toggles that reproduce fixed bugs. const SENTINEL_ARM: bool // ecb4641f7e: shim relays found/height 0/empty ahead of the L4 guard const HUB_HIDES_QUEUED: bool // 45e408f0ff: hub answers a queue hit with no bytes - const HONEST_HUB: bool // false lets the hub answer lookups with anything + const HONEST_HUB: bool // false lets the hub answer found, notfound, or error const CONFORMING_INDEXER: bool // false lets a missed lookup return a zero RawTransaction const TXS: Set[str] const MAX_NONCE: int @@ -190,13 +191,15 @@ module divert { unchangedExcept_net_hub, } - // A buggy or hostile hub may answer any lookup with anything. + // A hostile hub may answer a lookup with found, notfound, or error. + // Height is 0 or 1. The body is empty or one of the modelled tx ids. action hubLie(n: int, t: str): bool = all { not(HONEST_HUB), net.contains(lookupMsg(t, n)), + nondet disp = Set("found", "notfound", "error").oneOf() nondet d = TXS.union(Set(EMPTY)).oneOf() nondet h = Set(0, 1).oneOf() - net' = net.union(Set(replyMsg(t, n, "found", h, d, hub.get(t)).with("honest", false))), + net' = net.union(Set(replyMsg(t, n, disp, h, d, hub.get(t)).with("honest", false))), hub' = hub, unchangedExcept_net_hub, } @@ -276,6 +279,10 @@ module divert { val pendingIsTrue: bool = obs.foldl(true, (ok, o) => ok and (o.status != "pending" or o.hubSaw == "queued")) + // A hostile hub can answer not-found for a migration it has queued. + val queuedNotSuppressed: bool = obs.foldl(true, (ok, o) => + ok and not(o.hubSaw == "queued" and o.status == "notfound" and not(o.honest))) + // Once pending, a tx reads as not-found only if the hub dropped it. The // flush window (server.rs lookup note) breaks this. val pendingMonotone: bool = @@ -290,6 +297,14 @@ module current { CONFORMING_INDEXER = true, TXS = Set("a", "b"), MAX_NONCE = 4, ).* + + run hostileHubHidesQueueHitTest = + init + .then(walletSubmit("a")) + .then(hubAdmit("a")) + .then(walletLookup("a")) + .then(shimDeliver(replyMsg("a", 0, "notfound", 0, EMPTY, "queued").with("honest", false))) + .expect(obs[0].status == "notfound" and not(queuedNotSuppressed)) } module honest { From 8845332df6c1ff46151d2c611dd4a70d847e8206 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 19:04:55 +0400 Subject: [PATCH 09/80] test(zeronym): record honest status regression Once the wallet has seen a transaction's bytes, a later pending or not-found is a regression. Reorder, a resend after publication, and the other hub's not-found each reach it. The seeded sample does not, so the runs are the check. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 89 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 7 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index fe0a63fa..e2d46aae 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -60,6 +60,7 @@ module divert { var told: Set[str] // wallet was told its submit succeeded var refused: Set[str] // hub refused a submit (the ack never reaches the wallet) var obs: List[Obs] + var replica: str -> str // second hub; submits are not copied, so it answers not-found pure def submitMsg(t: str): Msg = { kind: "submit", tx: t, nonce: -1, disp: "", height: 0, data: EMPTY, hubSaw: "", honest: true } @@ -96,10 +97,12 @@ module divert { told' = Set(), refused' = Set(), obs' = List(), + replica' = TXS.mapBy(_ => "absent"), } action unchangedExcept_net_hub = all { waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, + replica' = replica, } // Nym submit is dispatch-only: the wallet hears success once the frame is @@ -108,13 +111,16 @@ module divert { net' = net.union(Set(submitMsg(t))), told' = told.union(Set(t)), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, + replica' = replica, } - // queue.rs admit. Delivery leaves the message in `net`, so duplicates are - // free; a duplicate of a queued tx is a no-op (sha256 dedup). + // A resend of the same bytes is admitted again while the tx is published, + // flushing, or returning from a retry. Lookup prefers the queue, so the next + // answer is the empty sentinel. A resend that arrives while the bytes are + // already queued stays a no-op (sha256 dedup). action hubAdmit(t: str): bool = all { net.contains(submitMsg(t)), - hub.get(t).in(Set("absent", "dropped")), + hub.get(t).in(Set("absent", "dropped", "flushing", "mempool", "mined")), hub' = hub.set(t, "queued"), net' = net, unchangedExcept_net_hub, @@ -126,7 +132,7 @@ module divert { net.contains(submitMsg(t)), hub.get(t).in(Set("absent", "dropped")), refused' = refused.union(Set(t)), - hub' = hub, net' = net, + hub' = hub, net' = net, replica' = replica, waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, } @@ -138,6 +144,14 @@ module divert { unchangedExcept_net_hub, } + // The run's stand-in for one broadcast_batch verdict: the tx is in the mempool. + action markMempool(t: str): bool = all { + hub.get(t) == "flushing", + hub' = hub.set(t, "mempool"), + net' = net, + unchangedExcept_net_hub, + } + // broadcast_batch verdicts: published, rejected, or retryable (requeued). action flushOutcome(t: str): bool = all { hub.get(t) == "flushing", @@ -159,7 +173,7 @@ module divert { net' = net.union(Set(lookupMsg(t, nextNonce))), waiters' = waiters.put(nextNonce, t), nextNonce' = nextNonce + 1, - hub' = hub, told' = told, refused' = refused, obs' = obs, + hub' = hub, told' = told, refused' = refused, obs' = obs, replica' = replica, } action hubAnswer(n: int, t: str): bool = all { @@ -169,6 +183,16 @@ module divert { unchangedExcept_net_hub, } + // The other hub answers the same lookup. It never sees the submit, so a tx + // this hub has published is not-found there. The shim returns the first + // answer that is not a timeout. + action replicaAnswer(n: int, t: str): bool = all { + net.contains(lookupMsg(t, n)), + net' = net.union(Set(hubReply(t, n, replica.get(t), HUB_HIDES_QUEUED))), + hub' = hub, + unchangedExcept_net_hub, + } + // Indexer unreachable (chain.rs: no endpoint answered). A queue hit answers // before the indexer is asked. action hubError(n: int, t: str): bool = all { @@ -211,7 +235,7 @@ module divert { waiters.keys().contains(r.nonce), obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, } // 90 s lookup deadline. @@ -219,7 +243,7 @@ module divert { waiters.keys().contains(n), obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, } action lose(m: Msg): bool = all { @@ -289,6 +313,14 @@ module divert { 0.to(obs.length() - 1).forall(i => 0.to(obs.length() - 1).forall(j => not(i < j and obs[i].tx == obs[j].tx and obs[i].status == "pending" and obs[j].status == "notfound" and obs[j].hubSaw != "dropped"))) + + // Once the wallet has been shown a transaction's bytes, a later observation + // of that tx is those bytes again or unavailable. Reorder, a resend after + // publication, and another hub's not-found all break this. + val statusNeverRegresses: bool = + 0.to(obs.length() - 1).forall(i => 0.to(obs.length() - 1).forall(j => + not(i < j and obs[i].tx == obs[j].tx and obs[i].status == "tx" + and not(obs[j].status.in(Set("tx", "unavailable")))))) } module current { @@ -327,6 +359,49 @@ module honest { .then(walletLookup("a")) .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) .expect(obs[0].status == "pending") + + run reorderedReplyRegressesTest = + init + .then(walletSubmit("a")) + .then(hubAdmit("a")) + .then(walletLookup("a")) + .then(hubAnswer(0, "a")) + .then(flushStart) + .then(markMempool("a")) + .then(walletLookup("a")) + .then(hubAnswer(1, "a")) + .then(shimDeliver(hubReply("a", 1, "mempool", true))) + .then(shimDeliver(hubReply("a", 0, "queued", true))) + .expect(not(statusNeverRegresses)) + + run resendAfterPublishRegressesTest = + init + .then(walletSubmit("a")) + .then(hubAdmit("a")) + .then(flushStart) + .then(markMempool("a")) + .then(walletLookup("a")) + .then(hubAnswer(0, "a")) + .then(shimDeliver(hubReply("a", 0, "mempool", true))) + .then(hubAdmit("a")) + .then(walletLookup("a")) + .then(hubAnswer(1, "a")) + .then(shimDeliver(hubReply("a", 1, "queued", true))) + .expect(not(statusNeverRegresses)) + + run replicaNotFoundRegressesTest = + init + .then(walletSubmit("a")) + .then(hubAdmit("a")) + .then(flushStart) + .then(markMempool("a")) + .then(walletLookup("a")) + .then(hubAnswer(0, "a")) + .then(shimDeliver(hubReply("a", 0, "mempool", true))) + .then(walletLookup("a")) + .then(replicaAnswer(1, "a")) + .then(shimDeliver(hubReply("a", 1, "absent", true))) + .expect(not(statusNeverRegresses)) } module before_ecb4641 { From 62ec0d94860f08b79742677d5da93fcd2265af94 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 20:10:17 +0400 Subject: [PATCH 10/80] test(zeronym): miss a queued body that has no txid find_by_txid matches a queue entry only when its txid is the query. An unparseable body stays queued and answers not-found, and pending is only claimed for a body that has a txid. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 52 +++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index e2d46aae..1cfcb74e 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -4,7 +4,7 @@ // then polls GetTransaction. The hub queues, flushes on epochs, and answers // lookups from its queue or its indexer. The mixnet may lose, duplicate and // reorder any message. A transaction's bytes are modelled as its id, and "" -// is an empty body. +// is an empty body. An unparseable body has no txid: `parseable` is per tx. // // Code this mirrors: // shim intercept.rs get_transaction reply arms, nym.rs nonce correlation @@ -60,6 +60,7 @@ module divert { var told: Set[str] // wallet was told its submit succeeded var refused: Set[str] // hub refused a submit (the ack never reaches the wallet) var obs: List[Obs] + var parseable: str -> bool // false: that body has no txid; find_by_txid skips it var replica: str -> str // second hub; submits are not copied, so it answers not-found pure def submitMsg(t: str): Msg = @@ -71,12 +72,17 @@ module divert { pure def replyMsg(t: str, n: int, disp: str, h: int, d: str, saw: str): Msg = { kind: "reply", tx: t, nonce: n, disp: disp, height: h, data: d, hubSaw: saw, honest: true } + // find_by_txid: the queue hits only when some entry's txid is the query. + // A parseable body's txid is its id. An unparseable body has none. + def queueHit(st: str, t: str): bool = + st == "queued" and TXS.exists(e => parseable.get(e) and e == t) + // server.rs lookup: queue first, then the indexer. - pure def hubReply(t: str, n: int, st: str, hides: bool): Msg = - if (st == "queued") replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) + def hubReply(t: str, n: int, st: str, hides: bool): Msg = + if (queueHit(st, t)) replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) else if (st == "mempool") replyMsg(t, n, "found", 0, t, st) else if (st == "mined") replyMsg(t, n, "found", 1, t, st) - else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped + else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped, or queued with no txid // intercept.rs get_transaction: the reply arms, in order. pure def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = @@ -97,12 +103,13 @@ module divert { told' = Set(), refused' = Set(), obs' = List(), + parseable' = TXS.mapBy(_ => true), replica' = TXS.mapBy(_ => "absent"), } action unchangedExcept_net_hub = all { waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - replica' = replica, + parseable' = parseable, replica' = replica, } // Nym submit is dispatch-only: the wallet hears success once the frame is @@ -111,6 +118,16 @@ module divert { net' = net.union(Set(submitMsg(t))), told' = told.union(Set(t)), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, + parseable' = parseable, replica' = replica, + } + + // The body does not deserialize. It is still submitted; admit holds the bytes + // with no txid. Kept out of `step`; the run is the witness. + action submitUnparseable(t: str): bool = all { + net' = net.union(Set(submitMsg(t))), + told' = told.union(Set(t)), + parseable' = parseable.set(t, false), + hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, replica' = replica, } @@ -132,7 +149,7 @@ module divert { net.contains(submitMsg(t)), hub.get(t).in(Set("absent", "dropped")), refused' = refused.union(Set(t)), - hub' = hub, net' = net, replica' = replica, + hub' = hub, net' = net, parseable' = parseable, replica' = replica, waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, } @@ -173,7 +190,7 @@ module divert { net' = net.union(Set(lookupMsg(t, nextNonce))), waiters' = waiters.put(nextNonce, t), nextNonce' = nextNonce + 1, - hub' = hub, told' = told, refused' = refused, obs' = obs, replica' = replica, + hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, replica' = replica, } action hubAnswer(n: int, t: str): bool = all { @@ -235,7 +252,7 @@ module divert { waiters.keys().contains(r.nonce), obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, } // 90 s lookup deadline. @@ -243,7 +260,7 @@ module divert { waiters.keys().contains(n), obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, } action lose(m: Msg): bool = all { @@ -274,10 +291,11 @@ module divert { // ---- Shim claims: hold for an honest hub's replies ---- - // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). - // `not(o.honest)` drops lying replies, so this says nothing about a hub that may lie. + // An honest queue hit on a parseable tx reaches the wallet as pending. + // `not(o.honest)` drops lying replies. An unparseable body has no txid, so + // a lookup misses it even while the hub holds the bytes. val pendingVisible: bool = obs.foldl(true, (ok, o) => - ok and (not(o.honest) or o.hubSaw != "queued" or o.status == "pending")) + ok and (not(o.honest) or o.hubSaw != "queued" or not(parseable.get(o.tx)) or o.status == "pending")) // ---- Hub claims: hold for an honest hub; attestation is what makes it one ---- @@ -360,6 +378,16 @@ module honest { .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) .expect(obs[0].status == "pending") + // Unparseable bytes stay queued. Lookup misses them: no entry has that txid. + run unparseableLookupMissesTest = + init + .then(submitUnparseable("a")) + .then(hubAdmit("a")) + .then(walletLookup("a")) + .then(hubAnswer(0, "a")) + .then(shimDeliver(hubReply("a", 0, "queued", true))) + .expect(not(parseable.get("a")) and hub.get("a") == "queued" and obs[0].status == "notfound" and obs[0].hubSaw == "queued") + run reorderedReplyRegressesTest = init .then(walletSubmit("a")) From c22aa1274d3974a7e04388fcde6348e2ac35bce5 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:06:02 +0400 Subject: [PATCH 11/80] Revert "test(zeronym): miss a queued body that has no txid" This reverts commit 62ec0d94860f08b79742677d5da93fcd2265af94. --- zeronym/spec/divert.qnt | 52 ++++++++++------------------------------- 1 file changed, 12 insertions(+), 40 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index 1cfcb74e..e2d46aae 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -4,7 +4,7 @@ // then polls GetTransaction. The hub queues, flushes on epochs, and answers // lookups from its queue or its indexer. The mixnet may lose, duplicate and // reorder any message. A transaction's bytes are modelled as its id, and "" -// is an empty body. An unparseable body has no txid: `parseable` is per tx. +// is an empty body. // // Code this mirrors: // shim intercept.rs get_transaction reply arms, nym.rs nonce correlation @@ -60,7 +60,6 @@ module divert { var told: Set[str] // wallet was told its submit succeeded var refused: Set[str] // hub refused a submit (the ack never reaches the wallet) var obs: List[Obs] - var parseable: str -> bool // false: that body has no txid; find_by_txid skips it var replica: str -> str // second hub; submits are not copied, so it answers not-found pure def submitMsg(t: str): Msg = @@ -72,17 +71,12 @@ module divert { pure def replyMsg(t: str, n: int, disp: str, h: int, d: str, saw: str): Msg = { kind: "reply", tx: t, nonce: n, disp: disp, height: h, data: d, hubSaw: saw, honest: true } - // find_by_txid: the queue hits only when some entry's txid is the query. - // A parseable body's txid is its id. An unparseable body has none. - def queueHit(st: str, t: str): bool = - st == "queued" and TXS.exists(e => parseable.get(e) and e == t) - // server.rs lookup: queue first, then the indexer. - def hubReply(t: str, n: int, st: str, hides: bool): Msg = - if (queueHit(st, t)) replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) + pure def hubReply(t: str, n: int, st: str, hides: bool): Msg = + if (st == "queued") replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) else if (st == "mempool") replyMsg(t, n, "found", 0, t, st) else if (st == "mined") replyMsg(t, n, "found", 1, t, st) - else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped, or queued with no txid + else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped // intercept.rs get_transaction: the reply arms, in order. pure def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = @@ -103,13 +97,12 @@ module divert { told' = Set(), refused' = Set(), obs' = List(), - parseable' = TXS.mapBy(_ => true), replica' = TXS.mapBy(_ => "absent"), } action unchangedExcept_net_hub = all { waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - parseable' = parseable, replica' = replica, + replica' = replica, } // Nym submit is dispatch-only: the wallet hears success once the frame is @@ -118,16 +111,6 @@ module divert { net' = net.union(Set(submitMsg(t))), told' = told.union(Set(t)), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, - parseable' = parseable, replica' = replica, - } - - // The body does not deserialize. It is still submitted; admit holds the bytes - // with no txid. Kept out of `step`; the run is the witness. - action submitUnparseable(t: str): bool = all { - net' = net.union(Set(submitMsg(t))), - told' = told.union(Set(t)), - parseable' = parseable.set(t, false), - hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, replica' = replica, } @@ -149,7 +132,7 @@ module divert { net.contains(submitMsg(t)), hub.get(t).in(Set("absent", "dropped")), refused' = refused.union(Set(t)), - hub' = hub, net' = net, parseable' = parseable, replica' = replica, + hub' = hub, net' = net, replica' = replica, waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, } @@ -190,7 +173,7 @@ module divert { net' = net.union(Set(lookupMsg(t, nextNonce))), waiters' = waiters.put(nextNonce, t), nextNonce' = nextNonce + 1, - hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, replica' = replica, + hub' = hub, told' = told, refused' = refused, obs' = obs, replica' = replica, } action hubAnswer(n: int, t: str): bool = all { @@ -252,7 +235,7 @@ module divert { waiters.keys().contains(r.nonce), obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, } // 90 s lookup deadline. @@ -260,7 +243,7 @@ module divert { waiters.keys().contains(n), obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, } action lose(m: Msg): bool = all { @@ -291,11 +274,10 @@ module divert { // ---- Shim claims: hold for an honest hub's replies ---- - // An honest queue hit on a parseable tx reaches the wallet as pending. - // `not(o.honest)` drops lying replies. An unparseable body has no txid, so - // a lookup misses it even while the hub holds the bytes. + // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). + // `not(o.honest)` drops lying replies, so this says nothing about a hub that may lie. val pendingVisible: bool = obs.foldl(true, (ok, o) => - ok and (not(o.honest) or o.hubSaw != "queued" or not(parseable.get(o.tx)) or o.status == "pending")) + ok and (not(o.honest) or o.hubSaw != "queued" or o.status == "pending")) // ---- Hub claims: hold for an honest hub; attestation is what makes it one ---- @@ -378,16 +360,6 @@ module honest { .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) .expect(obs[0].status == "pending") - // Unparseable bytes stay queued. Lookup misses them: no entry has that txid. - run unparseableLookupMissesTest = - init - .then(submitUnparseable("a")) - .then(hubAdmit("a")) - .then(walletLookup("a")) - .then(hubAnswer(0, "a")) - .then(shimDeliver(hubReply("a", 0, "queued", true))) - .expect(not(parseable.get("a")) and hub.get("a") == "queued" and obs[0].status == "notfound" and obs[0].hubSaw == "queued") - run reorderedReplyRegressesTest = init .then(walletSubmit("a")) From 46e7082c33835aec0970eb0e62b458dc0be692fb Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:06:40 +0400 Subject: [PATCH 12/80] test(zeronym): count a hostile not-found by the reply's disposition L4 turns a mismatched found into status not-found. The suppression check follows the reply's disposition, so that mismatch is not the witness. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index e2d46aae..acb8005e 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -50,7 +50,9 @@ module divert { } // What the wallet saw for one lookup. - type Obs = { tx: str, status: str, data: str, hubSaw: str, honest: bool } + // `disp` is the reply's disposition, before the shim rewrites a mismatched + // found into not-found. A timeout has no reply, so its disp is "". + type Obs = { tx: str, status: str, data: str, hubSaw: str, honest: bool, disp: str } // hub[tx]: "absent" | "queued" | "flushing" | "mempool" | "mined" | "dropped" var hub: str -> str @@ -81,13 +83,14 @@ module divert { // intercept.rs get_transaction: the reply arms, in order. pure def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = if (r.disp == "found" and sentinelArm and r.data == EMPTY and r.height == 0) - { tx: q, status: "pending", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } + { tx: q, status: "pending", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } else if (r.disp == "found") - // L4 guard: the bytes must be the queried transaction. - if (r.data == q) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw, honest: r.honest } - else { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } - else if (r.disp == "notfound") { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } - else { tx: q, status: "unavailable", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest } + // L4 guard: the bytes must be the queried transaction. Status becomes + // not-found; disp stays found, so a mismatch is not a not-found reply. + if (r.data == q) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } + else { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } + else if (r.disp == "notfound") { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } + else { tx: q, status: "unavailable", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } action init = all { hub' = TXS.mapBy(_ => "absent"), @@ -241,7 +244,7 @@ module divert { // 90 s lookup deadline. action shimTimeout(n: int): bool = all { waiters.keys().contains(n), - obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true }), + obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true, disp: "" }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, } @@ -304,8 +307,10 @@ module divert { ok and (o.status != "pending" or o.hubSaw == "queued")) // A hostile hub can answer not-found for a migration it has queued. + // This is the reply's disposition. L4 rewrites a mismatched found into + // status not-found and must not count. val queuedNotSuppressed: bool = obs.foldl(true, (ok, o) => - ok and not(o.hubSaw == "queued" and o.status == "notfound" and not(o.honest))) + ok and not(o.hubSaw == "queued" and o.disp == "notfound" and not(o.honest))) // Once pending, a tx reads as not-found only if the hub dropped it. The // flush window (server.rs lookup note) breaks this. @@ -336,7 +341,7 @@ module current { .then(hubAdmit("a")) .then(walletLookup("a")) .then(shimDeliver(replyMsg("a", 0, "notfound", 0, EMPTY, "queued").with("honest", false))) - .expect(obs[0].status == "notfound" and not(queuedNotSuppressed)) + .expect(obs[0].disp == "notfound" and obs[0].status == "notfound" and not(queuedNotSuppressed)) } module honest { From 91e13826c192bd71f2674cd689c4f26d55f30f66 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:09:47 +0400 Subject: [PATCH 13/80] Reapply "test(zeronym): miss a queued body that has no txid" This reverts commit c22aa1274d3974a7e04388fcde6348e2ac35bce5. --- zeronym/spec/divert.qnt | 52 +++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index acb8005e..dd73113f 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -4,7 +4,7 @@ // then polls GetTransaction. The hub queues, flushes on epochs, and answers // lookups from its queue or its indexer. The mixnet may lose, duplicate and // reorder any message. A transaction's bytes are modelled as its id, and "" -// is an empty body. +// is an empty body. An unparseable body has no txid: `parseable` is per tx. // // Code this mirrors: // shim intercept.rs get_transaction reply arms, nym.rs nonce correlation @@ -62,6 +62,7 @@ module divert { var told: Set[str] // wallet was told its submit succeeded var refused: Set[str] // hub refused a submit (the ack never reaches the wallet) var obs: List[Obs] + var parseable: str -> bool // false: that body has no txid; find_by_txid skips it var replica: str -> str // second hub; submits are not copied, so it answers not-found pure def submitMsg(t: str): Msg = @@ -73,12 +74,17 @@ module divert { pure def replyMsg(t: str, n: int, disp: str, h: int, d: str, saw: str): Msg = { kind: "reply", tx: t, nonce: n, disp: disp, height: h, data: d, hubSaw: saw, honest: true } + // find_by_txid: the queue hits only when some entry's txid is the query. + // A parseable body's txid is its id. An unparseable body has none. + def queueHit(st: str, t: str): bool = + st == "queued" and TXS.exists(e => parseable.get(e) and e == t) + // server.rs lookup: queue first, then the indexer. - pure def hubReply(t: str, n: int, st: str, hides: bool): Msg = - if (st == "queued") replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) + def hubReply(t: str, n: int, st: str, hides: bool): Msg = + if (queueHit(st, t)) replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) else if (st == "mempool") replyMsg(t, n, "found", 0, t, st) else if (st == "mined") replyMsg(t, n, "found", 1, t, st) - else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped + else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped, or queued with no txid // intercept.rs get_transaction: the reply arms, in order. pure def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = @@ -100,12 +106,13 @@ module divert { told' = Set(), refused' = Set(), obs' = List(), + parseable' = TXS.mapBy(_ => true), replica' = TXS.mapBy(_ => "absent"), } action unchangedExcept_net_hub = all { waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - replica' = replica, + parseable' = parseable, replica' = replica, } // Nym submit is dispatch-only: the wallet hears success once the frame is @@ -114,6 +121,16 @@ module divert { net' = net.union(Set(submitMsg(t))), told' = told.union(Set(t)), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, + parseable' = parseable, replica' = replica, + } + + // The body does not deserialize. It is still submitted; admit holds the bytes + // with no txid. Kept out of `step`; the run is the witness. + action submitUnparseable(t: str): bool = all { + net' = net.union(Set(submitMsg(t))), + told' = told.union(Set(t)), + parseable' = parseable.set(t, false), + hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, replica' = replica, } @@ -135,7 +152,7 @@ module divert { net.contains(submitMsg(t)), hub.get(t).in(Set("absent", "dropped")), refused' = refused.union(Set(t)), - hub' = hub, net' = net, replica' = replica, + hub' = hub, net' = net, parseable' = parseable, replica' = replica, waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, } @@ -176,7 +193,7 @@ module divert { net' = net.union(Set(lookupMsg(t, nextNonce))), waiters' = waiters.put(nextNonce, t), nextNonce' = nextNonce + 1, - hub' = hub, told' = told, refused' = refused, obs' = obs, replica' = replica, + hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, replica' = replica, } action hubAnswer(n: int, t: str): bool = all { @@ -238,7 +255,7 @@ module divert { waiters.keys().contains(r.nonce), obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, } // 90 s lookup deadline. @@ -246,7 +263,7 @@ module divert { waiters.keys().contains(n), obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true, disp: "" }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, } action lose(m: Msg): bool = all { @@ -277,10 +294,11 @@ module divert { // ---- Shim claims: hold for an honest hub's replies ---- - // An honest queue hit reaches the wallet as pending (the ecb4641f7e bug). - // `not(o.honest)` drops lying replies, so this says nothing about a hub that may lie. + // An honest queue hit on a parseable tx reaches the wallet as pending. + // `not(o.honest)` drops lying replies. An unparseable body has no txid, so + // a lookup misses it even while the hub holds the bytes. val pendingVisible: bool = obs.foldl(true, (ok, o) => - ok and (not(o.honest) or o.hubSaw != "queued" or o.status == "pending")) + ok and (not(o.honest) or o.hubSaw != "queued" or not(parseable.get(o.tx)) or o.status == "pending")) // ---- Hub claims: hold for an honest hub; attestation is what makes it one ---- @@ -365,6 +383,16 @@ module honest { .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) .expect(obs[0].status == "pending") + // Unparseable bytes stay queued. Lookup misses them: no entry has that txid. + run unparseableLookupMissesTest = + init + .then(submitUnparseable("a")) + .then(hubAdmit("a")) + .then(walletLookup("a")) + .then(hubAnswer(0, "a")) + .then(shimDeliver(hubReply("a", 0, "queued", true))) + .expect(not(parseable.get("a")) and hub.get("a") == "queued" and obs[0].status == "notfound" and obs[0].hubSaw == "queued") + run reorderedReplyRegressesTest = init .then(walletSubmit("a")) From ee1fde550baeb565c501fcaac157b595c03de477 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:10:31 +0400 Subject: [PATCH 14/80] test(zeronym): drop the second hub that never sees a submit Submit goes to every address, and a published transaction is visible to the other hub through its indexer. The model kept that hub absent forever. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 43 ++++++++--------------------------------- 1 file changed, 8 insertions(+), 35 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index dd73113f..17014cd2 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -63,7 +63,6 @@ module divert { var refused: Set[str] // hub refused a submit (the ack never reaches the wallet) var obs: List[Obs] var parseable: str -> bool // false: that body has no txid; find_by_txid skips it - var replica: str -> str // second hub; submits are not copied, so it answers not-found pure def submitMsg(t: str): Msg = { kind: "submit", tx: t, nonce: -1, disp: "", height: 0, data: EMPTY, hubSaw: "", honest: true } @@ -107,12 +106,11 @@ module divert { refused' = Set(), obs' = List(), parseable' = TXS.mapBy(_ => true), - replica' = TXS.mapBy(_ => "absent"), } action unchangedExcept_net_hub = all { waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - parseable' = parseable, replica' = replica, + parseable' = parseable, } // Nym submit is dispatch-only: the wallet hears success once the frame is @@ -121,7 +119,7 @@ module divert { net' = net.union(Set(submitMsg(t))), told' = told.union(Set(t)), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, - parseable' = parseable, replica' = replica, + parseable' = parseable, } // The body does not deserialize. It is still submitted; admit holds the bytes @@ -131,7 +129,6 @@ module divert { told' = told.union(Set(t)), parseable' = parseable.set(t, false), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, - replica' = replica, } // A resend of the same bytes is admitted again while the tx is published, @@ -152,7 +149,7 @@ module divert { net.contains(submitMsg(t)), hub.get(t).in(Set("absent", "dropped")), refused' = refused.union(Set(t)), - hub' = hub, net' = net, parseable' = parseable, replica' = replica, + hub' = hub, net' = net, parseable' = parseable, waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, } @@ -193,7 +190,7 @@ module divert { net' = net.union(Set(lookupMsg(t, nextNonce))), waiters' = waiters.put(nextNonce, t), nextNonce' = nextNonce + 1, - hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, replica' = replica, + hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, } action hubAnswer(n: int, t: str): bool = all { @@ -203,16 +200,6 @@ module divert { unchangedExcept_net_hub, } - // The other hub answers the same lookup. It never sees the submit, so a tx - // this hub has published is not-found there. The shim returns the first - // answer that is not a timeout. - action replicaAnswer(n: int, t: str): bool = all { - net.contains(lookupMsg(t, n)), - net' = net.union(Set(hubReply(t, n, replica.get(t), HUB_HIDES_QUEUED))), - hub' = hub, - unchangedExcept_net_hub, - } - // Indexer unreachable (chain.rs: no endpoint answered). A queue hit answers // before the indexer is asked. action hubError(n: int, t: str): bool = all { @@ -255,7 +242,7 @@ module divert { waiters.keys().contains(r.nonce), obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, } // 90 s lookup deadline. @@ -263,7 +250,7 @@ module divert { waiters.keys().contains(n), obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true, disp: "" }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, replica' = replica, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, } action lose(m: Msg): bool = all { @@ -338,8 +325,8 @@ module divert { and obs[j].status == "notfound" and obs[j].hubSaw != "dropped"))) // Once the wallet has been shown a transaction's bytes, a later observation - // of that tx is those bytes again or unavailable. Reorder, a resend after - // publication, and another hub's not-found all break this. + // of that tx is those bytes again or unavailable. A reordered reply and a + // resend after publication both break this. val statusNeverRegresses: bool = 0.to(obs.length() - 1).forall(i => 0.to(obs.length() - 1).forall(j => not(i < j and obs[i].tx == obs[j].tx and obs[i].status == "tx" @@ -421,20 +408,6 @@ module honest { .then(hubAnswer(1, "a")) .then(shimDeliver(hubReply("a", 1, "queued", true))) .expect(not(statusNeverRegresses)) - - run replicaNotFoundRegressesTest = - init - .then(walletSubmit("a")) - .then(hubAdmit("a")) - .then(flushStart) - .then(markMempool("a")) - .then(walletLookup("a")) - .then(hubAnswer(0, "a")) - .then(shimDeliver(hubReply("a", 0, "mempool", true))) - .then(walletLookup("a")) - .then(replicaAnswer(1, "a")) - .then(shimDeliver(hubReply("a", 1, "absent", true))) - .expect(not(statusNeverRegresses)) } module before_ecb4641 { From fb45f021e44252d8df4f9f17956d5ca81b56b052 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:15:04 +0400 Subject: [PATCH 15/80] test(zeronym): remember a broadcast after the queue copy is gone A resend admits the bytes again and lookup prefers that queue entry. Dropping the copy does not unpublish: the indexer still has the transaction. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 49 +++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index 17014cd2..d2189824 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -55,7 +55,10 @@ module divert { type Obs = { tx: str, status: str, data: str, hubSaw: str, honest: bool, disp: str } // hub[tx]: "absent" | "queued" | "flushing" | "mempool" | "mined" | "dropped" + // chain[tx]: what a broadcast left on the indexer. "none" until the first + // acceptance. Admit and a dropped queue copy do not clear it. var hub: str -> str + var chain: str -> str var net: Set[Msg] var waiters: int -> str // shim: lookup nonce -> queried tx var nextNonce: int @@ -78,11 +81,12 @@ module divert { def queueHit(st: str, t: str): bool = st == "queued" and TXS.exists(e => parseable.get(e) and e == t) - // server.rs lookup: queue first, then the indexer. + // server.rs lookup: queue first, then the indexer. The indexer image survives + // a later queue copy being admitted or dropped. def hubReply(t: str, n: int, st: str, hides: bool): Msg = if (queueHit(st, t)) replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) - else if (st == "mempool") replyMsg(t, n, "found", 0, t, st) - else if (st == "mined") replyMsg(t, n, "found", 1, t, st) + else if (chain.get(t) == "mempool") replyMsg(t, n, "found", 0, t, "mempool") + else if (chain.get(t) == "mined") replyMsg(t, n, "found", 1, t, "mined") else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped, or queued with no txid // intercept.rs get_transaction: the reply arms, in order. @@ -106,11 +110,12 @@ module divert { refused' = Set(), obs' = List(), parseable' = TXS.mapBy(_ => true), + chain' = TXS.mapBy(_ => "none"), } action unchangedExcept_net_hub = all { waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - parseable' = parseable, + parseable' = parseable, chain' = chain, } // Nym submit is dispatch-only: the wallet hears success once the frame is @@ -119,7 +124,7 @@ module divert { net' = net.union(Set(submitMsg(t))), told' = told.union(Set(t)), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, - parseable' = parseable, + parseable' = parseable, chain' = chain, } // The body does not deserialize. It is still submitted; admit holds the bytes @@ -129,6 +134,7 @@ module divert { told' = told.union(Set(t)), parseable' = parseable.set(t, false), hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, + chain' = chain, } // A resend of the same bytes is admitted again while the tx is published, @@ -149,7 +155,7 @@ module divert { net.contains(submitMsg(t)), hub.get(t).in(Set("absent", "dropped")), refused' = refused.union(Set(t)), - hub' = hub, net' = net, parseable' = parseable, + hub' = hub, net' = net, parseable' = parseable, chain' = chain, waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, } @@ -166,23 +172,38 @@ module divert { hub.get(t) == "flushing", hub' = hub.set(t, "mempool"), net' = net, - unchangedExcept_net_hub, + chain' = chain.set(t, "mempool"), + waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, + parseable' = parseable, } + // A published tx stays on the indexer. A later mempool acceptance must not + // downgrade a mined one. + pure def chainAfter(prev: str, outcome: str): str = + if (outcome == "mempool" and prev != "mined") "mempool" else prev + // broadcast_batch verdicts: published, rejected, or retryable (requeued). + // Only an acceptance updates the indexer. A rejection or a requeue leaves + // whatever was already broadcast. action flushOutcome(t: str): bool = all { hub.get(t) == "flushing", nondet outcome = Set("mempool", "dropped", "queued").oneOf() - hub' = hub.set(t, outcome), - net' = net, - unchangedExcept_net_hub, + all { + hub' = hub.set(t, outcome), + net' = net, + chain' = chain.set(t, chainAfter(chain.get(t), outcome)), + waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, + parseable' = parseable, + }, } action mine(t: str): bool = all { hub.get(t) == "mempool", hub' = hub.set(t, "mined"), net' = net, - unchangedExcept_net_hub, + chain' = chain.set(t, "mined"), + waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, + parseable' = parseable, } action walletLookup(t: str): bool = all { @@ -190,7 +211,7 @@ module divert { net' = net.union(Set(lookupMsg(t, nextNonce))), waiters' = waiters.put(nextNonce, t), nextNonce' = nextNonce + 1, - hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, + hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, chain' = chain, } action hubAnswer(n: int, t: str): bool = all { @@ -242,7 +263,7 @@ module divert { waiters.keys().contains(r.nonce), obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, chain' = chain, } // 90 s lookup deadline. @@ -250,7 +271,7 @@ module divert { waiters.keys().contains(n), obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true, disp: "" }), waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, + net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, chain' = chain, } action lose(m: Msg): bool = all { From 89f2058a9165f158c3b319b16a7f2e76f45dde58 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:15:43 +0400 Subject: [PATCH 16/80] test(zeronym): deliver the reply the hub actually queued A run cannot hand the shim a message that was never on the network. The regression runs pin both statuses. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index d2189824..b770e983 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -257,9 +257,10 @@ module divert { } // nym.rs: a reply for an unknown nonce is dropped; a known one resolves the - // waiter once. + // waiter once. The message has to be one that was actually queued. action shimDeliver(r: Msg): bool = all { r.kind == "reply", + net.contains(r), waiters.keys().contains(r.nonce), obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), @@ -274,6 +275,25 @@ module divert { net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, chain' = chain, } + // A run injects a reply no hub action produces: an L4 mismatch, the sentinel + // image alone, or a dishonest not-found. Not part of `step`. + action stageReply(r: Msg): bool = all { + r.kind == "reply", + net' = net.union(Set(r)), + hub' = hub, + unchangedExcept_net_hub, + } + + // Take the reply an action queued for this nonce, not one the run wrote down. + action deliverReply(n: int): bool = all { + nondet r = net.oneOf() + all { + r.kind == "reply", + r.nonce == n, + shimDeliver(r), + }, + } + action lose(m: Msg): bool = all { net' = net.exclude(Set(m)), hub' = hub, @@ -366,6 +386,7 @@ module current { .then(walletSubmit("a")) .then(hubAdmit("a")) .then(walletLookup("a")) + .then(stageReply(replyMsg("a", 0, "notfound", 0, EMPTY, "queued").with("honest", false))) .then(shimDeliver(replyMsg("a", 0, "notfound", 0, EMPTY, "queued").with("honest", false))) .expect(obs[0].disp == "notfound" and obs[0].status == "notfound" and not(queuedNotSuppressed)) } @@ -381,6 +402,7 @@ module honest { run mismatchedLookupRefusedTest = init .then(walletLookup("a")) + .then(stageReply(replyMsg("a", 0, "found", 1, "b", "mined"))) .then(shimDeliver(replyMsg("a", 0, "found", 1, "b", "mined"))) .expect(obs[0].status == "notfound") @@ -388,6 +410,7 @@ module honest { run sentinelLookupPendingTest = init .then(walletLookup("a")) + .then(stageReply(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) .expect(obs[0].status == "pending") @@ -398,7 +421,7 @@ module honest { .then(hubAdmit("a")) .then(walletLookup("a")) .then(hubAnswer(0, "a")) - .then(shimDeliver(hubReply("a", 0, "queued", true))) + .then(deliverReply(0)) .expect(not(parseable.get("a")) and hub.get("a") == "queued" and obs[0].status == "notfound" and obs[0].hubSaw == "queued") run reorderedReplyRegressesTest = @@ -411,9 +434,9 @@ module honest { .then(markMempool("a")) .then(walletLookup("a")) .then(hubAnswer(1, "a")) - .then(shimDeliver(hubReply("a", 1, "mempool", true))) - .then(shimDeliver(hubReply("a", 0, "queued", true))) - .expect(not(statusNeverRegresses)) + .then(deliverReply(1)) + .then(deliverReply(0)) + .expect(obs[0].status == "tx" and obs[1].status == "pending" and not(statusNeverRegresses)) run resendAfterPublishRegressesTest = init @@ -423,12 +446,12 @@ module honest { .then(markMempool("a")) .then(walletLookup("a")) .then(hubAnswer(0, "a")) - .then(shimDeliver(hubReply("a", 0, "mempool", true))) + .then(deliverReply(0)) .then(hubAdmit("a")) .then(walletLookup("a")) .then(hubAnswer(1, "a")) - .then(shimDeliver(hubReply("a", 1, "queued", true))) - .expect(not(statusNeverRegresses)) + .then(deliverReply(1)) + .expect(obs[0].status == "tx" and obs[1].status == "pending" and not(statusNeverRegresses)) } module before_ecb4641 { @@ -459,6 +482,6 @@ module badIndexer { init .then(walletLookup("a")) .then(indexerZero(0, "a")) - .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "absent"))) + .then(deliverReply(0)) .expect(obs[0].status == "pending" and obs[0].hubSaw == "absent" and obs[0].honest) } From bdad5055e03eee48192fea1fe743557f812b4fb9 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:16:18 +0400 Subject: [PATCH 17/80] test(zeronym): ask the indexer when a queued body has no txid find_by_txid misses that body. The old guard treated every queued state as a hit. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index b770e983..69ba4204 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -225,7 +225,7 @@ module divert { // before the indexer is asked. action hubError(n: int, t: str): bool = all { net.contains(lookupMsg(t, n)), - hub.get(t) != "queued", + not(queueHit(hub.get(t), t)), net' = net.union(Set(replyMsg(t, n, "error", 0, EMPTY, hub.get(t)))), hub' = hub, unchangedExcept_net_hub, @@ -237,7 +237,7 @@ module divert { action indexerZero(n: int, t: str): bool = all { not(CONFORMING_INDEXER), net.contains(lookupMsg(t, n)), - hub.get(t) != "queued", + not(queueHit(hub.get(t), t)), net' = net.union(Set(replyMsg(t, n, "found", 0, EMPTY, hub.get(t)))), hub' = hub, unchangedExcept_net_hub, @@ -424,6 +424,16 @@ module honest { .then(deliverReply(0)) .expect(not(parseable.get("a")) and hub.get("a") == "queued" and obs[0].status == "notfound" and obs[0].hubSaw == "queued") + // A queued body with no txid is a miss, so the indexer is asked. + run unparseableAsksIndexerTest = + init + .then(submitUnparseable("a")) + .then(hubAdmit("a")) + .then(walletLookup("a")) + .then(hubError(0, "a")) + .then(deliverReply(0)) + .expect(hub.get("a") == "queued" and obs[0].status == "unavailable" and obs[0].hubSaw == "queued") + run reorderedReplyRegressesTest = init .then(walletSubmit("a")) From cb5644a100bac601bdad3799b47632bf9bf4e9a7 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Tue, 6 Oct 2026 21:17:28 +0400 Subject: [PATCH 18/80] test(zeronym): refuse an unparseable body in the found arm lookup_is_for_query rejects bytes that do not deserialize. The mempool and mined image is only for a body that has a txid. Co-authored-by: Cursor --- zeronym/spec/divert.qnt | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt index 69ba4204..54cb5485 100644 --- a/zeronym/spec/divert.qnt +++ b/zeronym/spec/divert.qnt @@ -85,18 +85,20 @@ module divert { // a later queue copy being admitted or dropped. def hubReply(t: str, n: int, st: str, hides: bool): Msg = if (queueHit(st, t)) replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) - else if (chain.get(t) == "mempool") replyMsg(t, n, "found", 0, t, "mempool") - else if (chain.get(t) == "mined") replyMsg(t, n, "found", 1, t, "mined") - else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped, or queued with no txid + else if (chain.get(t) == "mempool" and parseable.get(t)) replyMsg(t, n, "found", 0, t, "mempool") + else if (chain.get(t) == "mined" and parseable.get(t)) replyMsg(t, n, "found", 1, t, "mined") + else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped, queued with no txid, or an image of one // intercept.rs get_transaction: the reply arms, in order. - pure def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = + // lookup_is_for_query rejects bytes that do not deserialize, so a found body + // is the queried tx only when it has a txid. + def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = if (r.disp == "found" and sentinelArm and r.data == EMPTY and r.height == 0) { tx: q, status: "pending", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } else if (r.disp == "found") // L4 guard: the bytes must be the queried transaction. Status becomes // not-found; disp stays found, so a mismatch is not a not-found reply. - if (r.data == q) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } + if (r.data == q and parseable.get(q)) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } else { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } else if (r.disp == "notfound") { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } else { tx: q, status: "unavailable", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } @@ -434,6 +436,27 @@ module honest { .then(deliverReply(0)) .expect(hub.get("a") == "queued" and obs[0].status == "unavailable" and obs[0].hubSaw == "queued") + // A found body that does not deserialize is not the queried transaction. + run unparseableFoundRefusedTest = + init + .then(submitUnparseable("a")) + .then(walletLookup("a")) + .then(stageReply(replyMsg("a", 0, "found", 1, "a", "mined"))) + .then(shimDeliver(replyMsg("a", 0, "found", 1, "a", "mined"))) + .expect(not(parseable.get("a")) and obs[0].status == "notfound" and obs[0].disp == "found") + + // The mempool image is only returned for a body that has a txid. + run unparseableMempoolMissesTest = + init + .then(submitUnparseable("a")) + .then(hubAdmit("a")) + .then(flushStart) + .then(markMempool("a")) + .then(walletLookup("a")) + .then(hubAnswer(0, "a")) + .then(deliverReply(0)) + .expect(chain.get("a") == "mempool" and obs[0].status == "notfound" and obs[0].disp == "notfound") + run reorderedReplyRegressesTest = init .then(walletSubmit("a")) From 6c485d48acc2d993fc310fd6f7284448869da032 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 03:30:17 +0400 Subject: [PATCH 19/80] test(zeronym): add the spells and message soup for the protocol spec --- zeronym/spec/protocol/spells/basicSpells.qnt | 59 +++++++++++++++++ zeronym/spec/protocol/spells/soup.qnt | 67 ++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 zeronym/spec/protocol/spells/basicSpells.qnt create mode 100644 zeronym/spec/protocol/spells/soup.qnt diff --git a/zeronym/spec/protocol/spells/basicSpells.qnt b/zeronym/spec/protocol/spells/basicSpells.qnt new file mode 100644 index 00000000..9ceac460 --- /dev/null +++ b/zeronym/spec/protocol/spells/basicSpells.qnt @@ -0,0 +1,59 @@ +// -*- mode: Bluespec; -*- + +/// Small, protocol-free helpers. A spell lives here only while something in the +/// specification calls it, and each one carries its own test. +module basicSpells { + /// A value that may be absent. + type Option[a] = Some(a) | None + + /// Whether the option holds a value. + pure def isSome(opt: Option[a]): bool = + match opt { + | Some(_) => true + | None => false + } + + run isSomeTest = all { + assert(isSome(Some(1))), + assert(not(isSome(None))), + } + + /// The held value, or `default` when there is none. + pure def unwrapOr(opt: Option[a], default: a): a = + match opt { + | Some(value) => value + | None => default + } + + run unwrapOrTest = all { + assert(unwrapOr(Some(1), 7) == 1), + assert(unwrapOr(None, 7) == 7), + } + + /// `items` without `elem`. + pure def setRemove(items: Set[a], elem: a): Set[a] = + items.exclude(Set(elem)) + + run setRemoveTest = all { + assert(Set(1, 2, 3).setRemove(2) == Set(1, 3)), + assert(Set(1, 2).setRemove(5) == Set(1, 2)), + } + + /// `entries` without the keys in `dropped`. + pure def mapRemoveSet(entries: a -> b, dropped: Set[a]): a -> b = + entries.keys().exclude(dropped).mapBy(key => entries.get(key)) + + run mapRemoveSetTest = all { + assert(Map(1 -> "a", 2 -> "b", 3 -> "c").mapRemoveSet(Set(1, 3)) == Map(2 -> "b")), + assert(Map(1 -> "a").mapRemoveSet(Set()) == Map(1 -> "a")), + } + + /// `entries` without `key`. + pure def mapRemove(entries: a -> b, key: a): a -> b = + entries.mapRemoveSet(Set(key)) + + run mapRemoveTest = all { + assert(Map(1 -> "a", 2 -> "b").mapRemove(1) == Map(2 -> "b")), + assert(Map(1 -> "a").mapRemove(9) == Map(1 -> "a")), + } +} diff --git a/zeronym/spec/protocol/spells/soup.qnt b/zeronym/spec/protocol/spells/soup.qnt new file mode 100644 index 00000000..7044b986 --- /dev/null +++ b/zeronym/spec/protocol/spells/soup.qnt @@ -0,0 +1,67 @@ +// -*- mode: Bluespec; -*- + +/// A message soup: the standard abstraction of an unreliable network. +/// +/// The soup only grows. A message, once sent, stays available for delivery +/// forever, and delivering it does not remove it. That one rule gives every +/// fault an asynchronous network can show without a separate action for each: +/// +/// - loss: a message is never chosen for delivery; +/// - duplication: a message is chosen more than once; +/// - delay and reordering: messages are chosen in any order, at any time. +/// +/// What the soup does not give is forgery. A message is in it only because some +/// participant's transition put it there. +/// +/// The module is generic in the address type `p` and the message type `m`. +module soup { + /// A message in transit, with the addresses it travels between. + type Envelope[p, m] = { src: p, dst: p, msg: m } + + /// Everything ever sent. + type Soup[p, m] = Set[Envelope[p, m]] + + /// `soup` after one more message is sent. + pure def send(soup: Soup[p, m], envelope: Envelope[p, m]): Soup[p, m] = + soup.union(Set(envelope)) + + /// `soup` after a set of messages is sent. + pure def sendAll(soup: Soup[p, m], envelopes: Set[Envelope[p, m]]): Soup[p, m] = + soup.union(envelopes) + + /// The messages that may be delivered to `addr`. + pure def inbox(soup: Soup[p, m], addr: p): Soup[p, m] = + soup.filter(envelope => envelope.dst == addr) + + /// The messages `addr` has sent. + pure def outbox(soup: Soup[p, m], addr: p): Soup[p, m] = + soup.filter(envelope => envelope.src == addr) + + // Tested at two instantiations, (str, int) and (int, bool), to show the + // definitions do not depend on either type. + + pure val ab = { src: "a", dst: "b", msg: 1 } + pure val ba = { src: "b", dst: "a", msg: 2 } + + run sendTest = all { + assert(Set().send(ab) == Set(ab)), + assert(Set(ab).send(ab) == Set(ab)), + assert(Set().send({ src: 1, dst: 2, msg: true }).size() == 1), + } + + run sendAllTest = all { + assert(Set(ab).sendAll(Set(ab, ba)) == Set(ab, ba)), + assert(Set({ src: 1, dst: 2, msg: true }).sendAll(Set()).size() == 1), + } + + run inboxTest = all { + assert(Set(ab, ba).inbox("b") == Set(ab)), + assert(Set(ab, ba).inbox("c") == Set()), + assert(Set({ src: 1, dst: 2, msg: true }).inbox(2).size() == 1), + } + + run outboxTest = all { + assert(Set(ab, ba).outbox("b") == Set(ba)), + assert(Set({ src: 1, dst: 2, msg: true }).outbox(2) == Set()), + } +} From 26de07f829b29ae062b95eb2fe776ffd4bfa8e94 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 03:30:27 +0400 Subject: [PATCH 20/80] test(zeronym): add the protocol vocabulary and wire layer --- zeronym/spec/protocol/tests/wireTest.qnt | 112 ++++++++++++ zeronym/spec/protocol/types.qnt | 218 +++++++++++++++++++++++ zeronym/spec/protocol/wire.qnt | 120 +++++++++++++ 3 files changed, 450 insertions(+) create mode 100644 zeronym/spec/protocol/tests/wireTest.qnt create mode 100644 zeronym/spec/protocol/types.qnt create mode 100644 zeronym/spec/protocol/wire.qnt diff --git a/zeronym/spec/protocol/tests/wireTest.qnt b/zeronym/spec/protocol/tests/wireTest.qnt new file mode 100644 index 00000000..c25d7c6c --- /dev/null +++ b/zeronym/spec/protocol/tests/wireTest.qnt @@ -0,0 +1,112 @@ +// -*- mode: Bluespec; -*- + +/// The wire layer, checked exhaustively over a small universe of payloads, +/// heights and queries. +module wireTest { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + + pure val pA = { id: "a", txid: Some("ta"), created: 1, expiry: Some(9), class: OrchardTouching, oversize: false } + pure val pATwin = { ...pA, id: "a-twin" } + pure val pB = { id: "b", txid: Some("tb"), created: 1, expiry: None, class: PassThrough, oversize: false } + pure val pJunk = { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } + + pure val PAYLOADS = Set(pA, pATwin, pB, pJunk) + pure val HEIGHTS = Set(MEMPOOL_HEIGHT, 1, 5) + pure val QUERIES = Set("ta", "tb", "unknown") + pure val BODIES = Set(None).union(PAYLOADS.map(payload => Some(payload))) + + /// Every indexer answer the type admits. + pure val ANSWERS = + tuples(BODIES, HEIGHTS).map(((body, height)) => IFound({ body: body, height: height })) + .union(Set(INotFound, IUnavailable)) + + /// The answers an honest indexer gives: a found transaction has a body, and + /// the body parses. + pure val HONEST_ANSWERS = ANSWERS.filter(answer => + match answer { + | IFound(found) => + match found.body { + | Some(payload) => isSome(payload.txid) + | None => false + } + | INotFound => true + | IUnavailable => true + }) + + pure val HONEST_OUTCOMES = Set(QueueHit).union(HONEST_ANSWERS.map(answer => FromIndexer(answer))) + + pure val REPLIES = + tuples(BODIES, HEIGHTS).map(((body, height)) => WFound({ body: body, height: height })) + .union(Set(WNotFound, WError)) + + pure val REFUSALS = Set(TipStale, Draining, TooLarge, ExpiryTooTight, Full) + + /// F1. Rendering an honest outcome and reading it back gives what the + /// outcome means. + run renderThenInterpretIsMeaningTest = + assert(tuples(HONEST_OUTCOMES, QUERIES).forall(((outcome, query)) => + interpretReply(render(outcome), query) == meaning(outcome, query))) + + /// F2. The documented collision: a queue hit and an indexer answer of + /// "found, height 0, no body" are the same reply, though they mean different + /// things. No two honest outcomes collide. + run sentinelCollisionTest = all { + assert(render(QueueHit) == render(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT })))), + assert(QUERIES.forall(query => + meaning(QueueHit, query) + != meaning(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT })), query))), + assert(tuples(HONEST_OUTCOMES, HONEST_OUTCOMES).forall(((left, right)) => + left != right implies render(left) != render(right))), + } + + /// F3. The shim serves a transaction only when its txid is the one asked + /// for, and compares nothing else. + run servedOnlyOnMatchingTxidTest = all { + assert(tuples(REPLIES, QUERIES).forall(((reply, query)) => + match interpretReply(reply, query) { + | Tx(tx) => tx.payload.txid == Some(query) + | _ => true + })), + // Found with no body at a mined height is not a transaction and not the + // pending sentinel. + assert(interpretReply(WFound({ body: None, height: 5 }), "ta") == NotFound), + // A twin of the transaction asked for is served. + assert(interpretReply(WFound({ body: Some(pATwin), height: 5 }), "ta") + == Tx({ payload: pATwin, height: 5 })), + // The height is passed through as given. + assert(HEIGHTS.forall(height => + interpretReply(WFound({ body: Some(pA), height: height }), "ta") + == Tx({ payload: pA, height: height }))), + // Bytes that do not parse are never served, at any height. + assert(tuples(HEIGHTS, QUERIES).forall(((height, query)) => + interpretReply(WFound({ body: Some(pJunk), height: height }), query) == NotFound)), + } + + /// F4. A hub error fails closed. It is never reported as not found. + run errorIsNeverNotFoundTest = + assert(QUERIES.forall(query => interpretReply(WError, query) == Unavailable)) + + /// F6. A frame's length depends on its kind and on nothing else. + run sizeIsIndependentOfContentTest = all { + assert(tuples(REPLIES, Set(0, 1)).forall(((reply, nonce)) => + sizeOf(LookupReply({ nonce: nonce, reply: reply })) == FrameSize)), + assert(tuples(PAYLOADS, Set(0, 1)).forall(((payload, nonce)) => + sizeOf(Submit({ nonce: nonce, payload: payload })) == FrameSize)), + assert(sizeOf(Ack({ nonce: 0, ack: WAccepted })) == sizeOf(Ack({ nonce: 1, ack: WRefused(WTipStale) }))), + assert(sizeOf(Lookup({ nonce: 0, txid: "ta" })) == sizeOf(Lookup({ nonce: 1, txid: "tb" }))), + } + + /// F10. Draining and full are one refusal on the wire; a fresh admission and + /// a duplicate are one acceptance. Every other refusal keeps its own code. + run ackRenderingTest = all { + assert(renderAck(Refused(Draining)) == renderAck(Refused(Full))), + assert(renderAck(Admitted) == WAccepted), + assert(renderAck(Duplicate) == WAccepted), + assert(REFUSALS.forall(refusal => renderAck(Refused(refusal)) != WAccepted)), + assert(tuples(REFUSALS, REFUSALS).forall(((left, right)) => + renderAck(Refused(left)) == renderAck(Refused(right)) + implies (left == right or Set(left, right) == Set(Draining, Full)))), + } +} diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt new file mode 100644 index 00000000..d86d5ed1 --- /dev/null +++ b/zeronym/spec/protocol/types.qnt @@ -0,0 +1,218 @@ +// -*- mode: Bluespec; -*- + +/// The vocabulary of the zeronym protocol: every domain the components, the +/// wire and the properties talk about, and nothing that computes. +/// +/// Domains are sum types. A value that the protocol cannot produce (a refusal +/// carrying a transaction, a lookup answer that is both found and not found) +/// has no representation here, so no definition downstream has to rule it out. +module types { + import basicSpells.* from "./spells/basicSpells" + + type TxId = str + type Height = int + type Nonce = int + type HubId = str + + // ------------------------------------------------------------------------ + // Transactions + // ------------------------------------------------------------------------ + + /// How the shim classifies a `SendTransaction` body. `Unparseable` is the + /// shim's own parser giving up; it is treated as a migration, never forwarded. + type Class = OrchardTouching | PassThrough | Unparseable + + /// A transaction's bytes, abstracted to what a party can compute from them. + /// + /// - `id` stands for the bytes themselves, and so for their SHA-256, the key + /// a hub's queue uses. + /// - `txid` is what the hub's and the node's parser compute, absent when the + /// bytes do not deserialise. Two payloads with different `id` and the same + /// `txid` are twins: the txid does not commit to every byte (ZIP 244). + /// - `created` is the chain height the wallet built the transaction at. + /// - `expiry` is `nExpiryHeight`, absent when the transaction never expires + /// or does not parse. + /// - `class` is the shim's classification. It is independent of `txid`: the + /// shim rejects trailing bytes that the hub's parser accepts. + /// - `oversize`: the bytes do not fit the fixed hub frame. + type Payload = { + id: str, + txid: Option[TxId], + created: Height, + expiry: Option[Height], + class: Class, + oversize: bool, + } + + /// Whether `payload` was built by a wallet that honours the supported expiry + /// floor: it never expires, or expires at least `minWalletExpiry` blocks + /// after the height it was built at. + pure def conforming(payload: Payload, minWalletExpiry: int): bool = + match payload.expiry { + | None => true + | Some(expiry) => expiry >= payload.created + minWalletExpiry + } + + /// Whether two payloads are different bytes with the same transaction id. + pure def areTwins(left: Payload, right: Payload): bool = + and { + left.id != right.id, + isSome(left.txid), + left.txid == right.txid, + } + + /// The transaction ids a set of payloads carries. + pure def txidsOf(payloads: Set[Payload]): Set[TxId] = + payloads.fold(Set(), (acc, payload) => + match payload.txid { + | Some(txid) => acc.union(Set(txid)) + | None => acc + }) + + /// What the wallet hands the shim in a `SendTransaction`: a body the shim + /// read in full, a body it could not read, or an empty one. + type SendInput = Clean(Payload) | Unreadable | EmptyBody + + // ------------------------------------------------------------------------ + // Chain and indexer + // ------------------------------------------------------------------------ + + /// Where a transaction stands on the chain. It only ever moves forward. + type Inclusion = Absent | InMempool | MinedAt(Height) + + /// The height a lookup answer carries for a transaction still in the mempool. + pure val MEMPOOL_HEIGHT: Height = 0 + + /// The height a lookup answer carries for `inclusion`. + pure def answerHeight(inclusion: Inclusion): Height = + match inclusion { + | MinedAt(height) => height + | InMempool => MEMPOOL_HEIGHT + | Absent => MEMPOOL_HEIGHT + } + + /// An indexer's answer to a transaction lookup. An honest indexer always + /// returns a body with `IFound`; the type admits a missing one because the + /// hub forwards whatever it is given. + type IndexerAnswer = + | IFound({ body: Option[Payload], height: Height }) + | INotFound + | IUnavailable + + /// An indexer's verdict on one broadcast transaction. `Retryable` means + /// nothing judged it: the indexer could not be reached or could not be read. + type Verdict = Accepted | AlreadyKnown | Rejected | Retryable + + // ------------------------------------------------------------------------ + // Hub + // ------------------------------------------------------------------------ + + /// Why a hub refuses a submission, in the order admission checks them. + type Refusal = TipStale | Draining | TooLarge | ExpiryTooTight | Full + + /// A hub's decision on a submission. `Duplicate` is a success: the bytes are + /// already queued here. + type AckKind = Admitted | Duplicate | Refused(Refusal) + + /// Whether a decision promises the hub holds the payload. + pure def isAccepted(kind: AckKind): bool = + match kind { + | Admitted => true + | Duplicate => true + | Refused(_) => false + } + + /// What a hub found for a lookup: its own queue, or its indexer's answer. + type HubOutcome = QueueHit | FromIndexer(IndexerAnswer) + + // ------------------------------------------------------------------------ + // Participants + // ------------------------------------------------------------------------ + + /// A network address. The third party is any client of a hub's public, + /// unauthenticated address other than the shim. + type Addr = ShimAddr | HubAddr(HubId) | ThirdPartyAddr + + /// Whether a component follows the protocol. A Byzantine component is not + /// marked in any message or state: it simply draws its transitions from a + /// wider relation than the honest one. + type Role = Honest | Byzantine + + /// The role of each component. Each hub has its own. + type Roles = { shim: Role, hubs: HubId -> Role, indexer: Role } + + /// How a hub's view of the chain tip relates to the true height. + /// + /// - `TipTimely`: every running hub sees each block before the next one. + /// - `TipMayRegress`: a report may be up to the reorg allowance behind. + /// - `TipMayLag`: a hub may go without a report for a while, and is stale + /// once the silence reaches the staleness window. + type TipModel = TipTimely | TipMayRegress | TipMayLag + + /// How a stale hub's free-running cadence clock relates to the true height. + type FreeRun = NotSlower | MayBeSlower + + /// Who the wallet hears from on a diverted send. `DispatchOnly` is the mixnet + /// transport: the shim answers once a frame is handed over and never waits + /// for the ack. `AwaitVerdict` is the HTTP transport: the hub's decision is + /// the answer. + type SubmitMode = DispatchOnly | AwaitVerdict + + // ------------------------------------------------------------------------ + // What the wallet observes + // ------------------------------------------------------------------------ + + /// The answer to a `SendTransaction`. + type SendObs = + | SentOk // diverted; error code 0 + | SentRejected // the hub refused it + | SentToOperator // not a migration; the operator's indexer has it + | SendUnavailable // failed closed: unreadable body, or no hub reachable + | SendInvalid // empty body + | SendTooLarge // does not fit the hub frame + + /// The answer to a `GetTransaction`. + type LookupObs = + | Pending // found, height 0, no body + | Tx({ payload: Payload, height: Height }) // the transaction, as served + | NotFound + | Unavailable // failed closed + + /// One completed wallet request. For a lookup, `via` is the nonce of the hub + /// request whose reply produced the answer, absent when no reply did. + type WalletEvent = + | Sent({ input: SendInput, obs: SendObs }) + | Got({ query: TxId, obs: LookupObs, via: Option[Nonce] }) + + // ------------------------------------------------------------------------ + // Shapes shared by the components + // ------------------------------------------------------------------------ + + /// What a component's transition function returns: its next state and the + /// one output the step produced. + type Result[s, o] = { state: s, out: o } + + /// One configuration of the protocol: the transactions in play, the hub + /// schedule, the bounds of the model, and the trust and timing assumptions. + /// Field by field it is the list of constants in `protocol.qnt`. + type Config = { + payloads: Set[Payload], + twins: Set[Payload], + tpPayloads: Set[Payload], + hubs: List[HubId], + flushInterval: int, + miningMargin: int, + deliveryLag: int, + reorgAllowance: int, + staleWindow: int, + freeRun: FreeRun, + minWalletExpiry: int, + maxAttempts: int, + queueCap: int, + maxHeight: Height, + maxRequests: int, + submitMode: SubmitMode, + roles: Roles, + tip: TipModel, + } +} diff --git a/zeronym/spec/protocol/wire.qnt b/zeronym/spec/protocol/wire.qnt new file mode 100644 index 00000000..5bdb5810 --- /dev/null +++ b/zeronym/spec/protocol/wire.qnt @@ -0,0 +1,120 @@ +// -*- mode: Bluespec; -*- + +/// The shim-to-hub wire, at the level of what a frame can say. +/// +/// Four frames exist: a submission and its ack, a lookup and its reply. Byte +/// layout, padding and malformed frames are below this level. What is modelled +/// is the part with protocol consequences: which distinctions a hub's answer +/// keeps when it is put on the wire, and how the shim reads it back. +module wire { + import basicSpells.* from "./spells/basicSpells" + import types.* from "./types" + + /// The refusal codes an ack can carry. There are four, for five refusals. + type WireRefusal = WExpiryTooTight | WTooLarge | WQueueFull | WTipStale + + /// An ack's disposition. A fresh admission and a duplicate are the same ack. + type WireAck = WAccepted | WRefused(WireRefusal) + + /// A lookup reply's disposition. Only `WFound` has room for a height and a + /// body; a `not_found` or an `error` that carries either does not decode. + type WireReply = + | WFound({ body: Option[Payload], height: Height }) + | WNotFound + | WError + + /// A frame. Requests and replies are matched by `nonce` and by nothing else. + type Msg = + | Submit({ nonce: Nonce, payload: Payload }) + | Ack({ nonce: Nonce, ack: WireAck }) + | Lookup({ nonce: Nonce, txid: TxId }) + | LookupReply({ nonce: Nonce, reply: WireReply }) + + /// The on-wire length of a frame. Every frame of a kind is padded to one + /// length, so length reveals the kind and nothing about the content. + type SizeClass = FrameSize | AckSize | LookupSize + + pure def sizeOf(msg: Msg): SizeClass = + match msg { + | Submit(_) => FrameSize + | LookupReply(_) => FrameSize + | Ack(_) => AckSize + | Lookup(_) => LookupSize + } + + /// A hub's decision as an ack. A draining hub answers as a full one does: + /// the shim reacts to both the same way. + pure def renderAck(kind: AckKind): WireAck = + match kind { + | Admitted => WAccepted + | Duplicate => WAccepted + | Refused(refusal) => + match refusal { + | TipStale => WRefused(WTipStale) + | Draining => WRefused(WQueueFull) + | TooLarge => WRefused(WTooLarge) + | ExpiryTooTight => WRefused(WExpiryTooTight) + | Full => WRefused(WQueueFull) + } + } + + /// A hub's lookup outcome as a reply. A queue hit is "found, height 0, no + /// body": the hub confirms the transaction is pending without handing its + /// bytes to whoever asked. An indexer answer is forwarded as it came. + pure def render(outcome: HubOutcome): WireReply = + match outcome { + | QueueHit => WFound({ body: None, height: MEMPOOL_HEIGHT }) + | FromIndexer(answer) => + match answer { + | IFound(found) => WFound(found) + | INotFound => WNotFound + | IUnavailable => WError + } + } + + /// What a hub outcome is meant to tell a wallet asking for `query`. This is + /// the intent, written without reference to the wire; `interpretReply` after + /// `render` is the mechanism, and the two agree on every outcome a queue or + /// an honest indexer produces. + pure def meaning(outcome: HubOutcome, query: TxId): LookupObs = + match outcome { + | QueueHit => Pending + | FromIndexer(answer) => + match answer { + | IFound(found) => + match found.body { + | Some(payload) => + if (payload.txid == Some(query)) + Tx({ payload: payload, height: found.height }) + else NotFound + // An indexer that reports a transaction and returns none has + // told the wallet nothing. + | None => NotFound + } + | INotFound => NotFound + | IUnavailable => Unavailable + } + } + + /// How the shim reads a lookup reply for `query`. The arms, in order: + /// + /// 1. found, height 0, no body: relayed as pending; + /// 2. found otherwise: served only if the returned bytes parse and their + /// txid is the one asked for, else not found. Nothing else is compared: + /// not the bytes, so a twin passes, and not the height; + /// 3. not found; + /// 4. error: the lookup fails closed, and is never reported as not found. + pure def interpretReply(reply: WireReply, query: TxId): LookupObs = + match reply { + | WFound(found) => + match found.body { + | None => if (found.height == MEMPOOL_HEIGHT) Pending else NotFound + | Some(payload) => + if (payload.txid == Some(query)) + Tx({ payload: payload, height: found.height }) + else NotFound + } + | WNotFound => NotFound + | WError => Unavailable + } +} From f0406bfed9e32ef438dcf8419f0a7d7b0dce9bf0 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 03:38:09 +0400 Subject: [PATCH 21/80] test(zeronym): name the draining refusal apart from the draining phase --- zeronym/spec/protocol/tests/wireTest.qnt | 6 +++--- zeronym/spec/protocol/types.qnt | 2 +- zeronym/spec/protocol/wire.qnt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/zeronym/spec/protocol/tests/wireTest.qnt b/zeronym/spec/protocol/tests/wireTest.qnt index c25d7c6c..4d67bb1b 100644 --- a/zeronym/spec/protocol/tests/wireTest.qnt +++ b/zeronym/spec/protocol/tests/wireTest.qnt @@ -41,7 +41,7 @@ module wireTest { tuples(BODIES, HEIGHTS).map(((body, height)) => WFound({ body: body, height: height })) .union(Set(WNotFound, WError)) - pure val REFUSALS = Set(TipStale, Draining, TooLarge, ExpiryTooTight, Full) + pure val REFUSALS = Set(TipStale, HubDraining, TooLarge, ExpiryTooTight, Full) /// F1. Rendering an honest outcome and reading it back gives what the /// outcome means. @@ -101,12 +101,12 @@ module wireTest { /// F10. Draining and full are one refusal on the wire; a fresh admission and /// a duplicate are one acceptance. Every other refusal keeps its own code. run ackRenderingTest = all { - assert(renderAck(Refused(Draining)) == renderAck(Refused(Full))), + assert(renderAck(Refused(HubDraining)) == renderAck(Refused(Full))), assert(renderAck(Admitted) == WAccepted), assert(renderAck(Duplicate) == WAccepted), assert(REFUSALS.forall(refusal => renderAck(Refused(refusal)) != WAccepted)), assert(tuples(REFUSALS, REFUSALS).forall(((left, right)) => renderAck(Refused(left)) == renderAck(Refused(right)) - implies (left == right or Set(left, right) == Set(Draining, Full)))), + implies (left == right or Set(left, right) == Set(HubDraining, Full)))), } } diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index d86d5ed1..35e0484d 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -108,7 +108,7 @@ module types { // ------------------------------------------------------------------------ /// Why a hub refuses a submission, in the order admission checks them. - type Refusal = TipStale | Draining | TooLarge | ExpiryTooTight | Full + type Refusal = TipStale | HubDraining | TooLarge | ExpiryTooTight | Full /// A hub's decision on a submission. `Duplicate` is a success: the bytes are /// already queued here. diff --git a/zeronym/spec/protocol/wire.qnt b/zeronym/spec/protocol/wire.qnt index 5bdb5810..25959555 100644 --- a/zeronym/spec/protocol/wire.qnt +++ b/zeronym/spec/protocol/wire.qnt @@ -51,7 +51,7 @@ module wire { | Refused(refusal) => match refusal { | TipStale => WRefused(WTipStale) - | Draining => WRefused(WQueueFull) + | HubDraining => WRefused(WQueueFull) | TooLarge => WRefused(WTooLarge) | ExpiryTooTight => WRefused(WExpiryTooTight) | Full => WRefused(WQueueFull) From 7eea1c69879aa9b96166b18e9e47817241698b30 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 03:38:09 +0400 Subject: [PATCH 22/80] test(zeronym): specify the hub and indexer as input-output functions --- zeronym/spec/protocol/hub.qnt | 455 ++++++++++++++++++++ zeronym/spec/protocol/indexer.qnt | 214 +++++++++ zeronym/spec/protocol/tests/hubTest.qnt | 335 ++++++++++++++ zeronym/spec/protocol/tests/indexerTest.qnt | 140 ++++++ 4 files changed, 1144 insertions(+) create mode 100644 zeronym/spec/protocol/hub.qnt create mode 100644 zeronym/spec/protocol/indexer.qnt create mode 100644 zeronym/spec/protocol/tests/hubTest.qnt create mode 100644 zeronym/spec/protocol/tests/indexerTest.qnt diff --git a/zeronym/spec/protocol/hub.qnt b/zeronym/spec/protocol/hub.qnt new file mode 100644 index 00000000..af507e3d --- /dev/null +++ b/zeronym/spec/protocol/hub.qnt @@ -0,0 +1,455 @@ +// -*- mode: Bluespec; -*- + +/// The hub: it admits diverted transactions into a queue held in memory, +/// answers lookups, and publishes the whole queue at once on a block cadence. +/// +/// The hub is one total function, `hub(state, input)`, from its state and one +/// input to its next state and one output. Each input is one thing that can +/// happen to a hub (a frame arrives, a tip is observed, a flush becomes due, a +/// verdict comes back) and corresponds to one seam in the implementation. An +/// input that makes no sense in the current state returns `HubErrorOutput` and +/// leaves the state alone. +/// +/// `byzHubResults` is the wider relation a Byzantine hub draws from. +module hub { + import basicSpells.* from "./spells/basicSpells" + import types.* from "./types" + + // ------------------------------------------------------------------------ + // State + // ------------------------------------------------------------------------ + + /// The schedule a hub is started with. + /// + /// `deliveryLag` and `minWalletExpiry` are not read by any transition. They + /// are the wallet-side budget the schedule is validated against at startup + /// (`budgetFits`), and the properties read them from here. + type HubParams = { + flushInterval: int, // blocks between scheduled flushes + miningMargin: int, // blocks a published transaction needs to be mined + deliveryLag: int, // blocks a submission may take to arrive + minWalletExpiry: int, // the smallest expiry delta a supported wallet sets + reorgAllowance: int, // how far back a tip report is followed + maxAttempts: int, // requeues an entry is allowed + queueCap: int, // entries admission will hold + } + + /// The startup check: a transaction that takes `deliveryLag` blocks to + /// arrive, waits a full interval and needs `miningMargin` blocks to be mined + /// still fits inside the smallest supported expiry. + pure def budgetFits(params: HubParams): bool = + params.flushInterval + params.miningMargin + params.deliveryLag <= params.minWalletExpiry + + /// Where the process is in its life. `Starting` has not seen a tip yet; + /// `Stale` has seen no forward tip progress for the staleness window; + /// `Draining` has had its shutdown signal and owes one final flush; + /// `Stopped` has done it. + type Phase = Down | Starting | Running | Stale | Draining | Stopped + + /// The clock the flush schedule runs on. It follows the observed tip until + /// the hub is stale, and then free-runs from an estimate of elapsed time. + /// Admission and requeue never use it: they read the observed tip. + type Cadence = Tracking | FreeRunning(Height) + + /// The flush cycle. While broadcasting, `batch` holds the entries still + /// waiting for a verdict and `unplaced` those nothing judged; both map a + /// payload to the requeues it has had. `final` marks the shutdown flush. + type Flush = + | Idle + | Broadcasting({ batch: Payload -> int, unplaced: Payload -> int, final: bool }) + + /// - `tip`: the last tip observed, absent until the first observation. + /// - `queue`: the admitted entries, keyed by their bytes, each with the + /// number of requeues it has had. + /// - `lastEpoch`: the flush epoch last acted on, absent until adopted. + type HubState = { + params: HubParams, + phase: Phase, + tip: Option[Height], + cadence: Cadence, + queue: Payload -> int, + flush: Flush, + lastEpoch: Option[int], + } + + /// A hub that is not running. It holds nothing: the queue lives in memory. + pure def downHub(params: HubParams): HubState = { + params: params, + phase: Down, + tip: None, + cadence: Tracking, + queue: Map(), + flush: Idle, + lastEpoch: None, + } + + /// A hub that has just started and has not yet seen a tip. + pure def startingHub(params: HubParams): HubState = + { ...downHub(params), phase: Starting } + + // ------------------------------------------------------------------------ + // Inputs and outputs + // ------------------------------------------------------------------------ + + type HubInput = + | SubmitHInput({ nonce: Nonce, payload: Payload }) + // A lookup, together with what the hub's indexer would answer. The answer + // is used only when the queue misses. + | LookupHInput({ nonce: Nonce, txid: TxId, answer: IndexerAnswer }) + | TipHInput(Height) // the cadence loop observes a tip + // No forward tip progress for the staleness window. The height is what + // the free-running cadence clock now reads. + | StaleHInput(Height) + | FlushDueHInput // the cadence loop finds a flush due + | VerdictHInput({ payload: Payload, verdict: Verdict }) + | FlushDoneHInput // every entry of the batch has a verdict + | DrainHInput // shutdown signal + | CrashHInput + | RestartHInput + + type HubOutput = + | AckOutput({ nonce: Nonce, kind: AckKind }) + | LookupReplyOutput({ nonce: Nonce, outcome: HubOutcome }) + | BroadcastOutput(Set[Payload]) + | RequeuedOutput({ held: int, droppedExpired: int, droppedExhausted: int }) + | NoHubOutput + | HubErrorOutput(str) + + type HubResult = Result[HubState, HubOutput] + + pure def toAckOutput(state: HubState, nonce: Nonce, kind: AckKind): HubResult = + { state: state, out: AckOutput({ nonce: nonce, kind: kind }) } + + pure def toLookupReplyOutput(state: HubState, nonce: Nonce, outcome: HubOutcome): HubResult = + { state: state, out: LookupReplyOutput({ nonce: nonce, outcome: outcome }) } + + pure def toBroadcastOutput(state: HubState, batch: Set[Payload]): HubResult = + { state: state, out: BroadcastOutput(batch) } + + pure def toRequeuedOutput(state: HubState, held: int, droppedExpired: int, droppedExhausted: int): HubResult = { + state: state, + out: RequeuedOutput({ held: held, droppedExpired: droppedExpired, droppedExhausted: droppedExhausted }), + } + + pure def toNoHubOutput(state: HubState): HubResult = + { state: state, out: NoHubOutput } + + pure def toHubErrorOutput(state: HubState, reason: str): HubResult = + { state: state, out: HubErrorOutput(reason) } + + // ------------------------------------------------------------------------ + // Views + // ------------------------------------------------------------------------ + + /// Whether the hub answers frames at all. + pure def isServing(state: HubState): bool = + state.phase != Down and state.phase != Stopped + + /// Whether the tip cannot be trusted: none has been observed, or the hub is + /// stale and its cadence is free-running. + pure def isTipStale(state: HubState): bool = + state.tip == None or state.cadence != Tracking + + /// The last observed tip. Read only where a tip has been observed. + pure def observedTip(state: HubState): Height = + state.tip.unwrapOr(0) + + /// The height the flush schedule runs on. + pure def cadenceHeight(state: HubState): Height = + match state.cadence { + | Tracking => state.observedTip() + | FreeRunning(height) => height + } + + /// The flush epoch the cadence clock is in. + pure def cadenceEpoch(state: HubState): int = + state.cadenceHeight() / state.params.flushInterval + + /// The payloads waiting in the queue. + pure def queued(state: HubState): Set[Payload] = + state.queue.keys() + + /// The payloads out with a flush: awaiting a verdict, or awaiting requeue. + pure def inFlight(state: HubState): Set[Payload] = + match state.flush { + | Idle => Set() + | Broadcasting(flush) => flush.batch.keys().union(flush.unplaced.keys()) + } + + /// Whether a lookup for `txid` hits the queue. An entry whose bytes do not + /// parse has no txid and is never hit. + pure def isQueuedTxid(state: HubState, txid: TxId): bool = + state.queued().exists(payload => payload.txid == Some(txid)) + + /// Whether the cadence loop would start a flush now: the cadence clock has + /// crossed into a new epoch, or the hub is draining and owes its final one. + pure def isFlushDue(state: HubState): bool = + and { + state.flush == Idle, + or { + state.phase == Draining, + and { + state.phase == Running or state.phase == Stale, + match state.lastEpoch { + | Some(epoch) => state.cadenceEpoch() > epoch + | None => false + }, + }, + }, + } + + // ------------------------------------------------------------------------ + // Admission + // ------------------------------------------------------------------------ + + /// The next height at which a flush is scheduled, strictly after `height`. + pure def nextFlushHeight(height: Height, flushInterval: int): Height = + (height / flushInterval + 1) * flushInterval + + /// Whether an entry with `expiry`, judged at `tip`, provably survives the + /// flush that would publish it with `miningMargin` blocks to spare. A + /// transaction with no expiry always does. + pure def survivesNextFlush(expiry: Option[Height], tip: Height, flushInterval: int, miningMargin: int): bool = + match expiry { + | None => true + | Some(height) => height >= nextFlushHeight(tip, flushInterval) + miningMargin + } + + /// The hub's decision on a submission, in the order the checks are made. No + /// check asks a node anything: admission must not leak a transaction's + /// arrival. + pure def admission(state: HubState, payload: Payload): AckKind = + if (state.isTipStale()) + Refused(TipStale) + else if (state.phase == Draining) + Refused(HubDraining) + else if (payload.oversize) + Refused(TooLarge) + else if (not(survivesNextFlush(payload.expiry, state.observedTip(), state.params.flushInterval, state.params.miningMargin))) + Refused(ExpiryTooTight) + else if (state.queued().contains(payload)) + Duplicate + else if (state.queued().size() >= state.params.queueCap) + Refused(Full) + else + Admitted + + // ------------------------------------------------------------------------ + // Tip + // ------------------------------------------------------------------------ + + /// The cadence loop observes `height`. The first observation is adopted, + /// with its epoch, and nothing is flushed. After that a forward move is + /// followed and ends staleness; a move back within the reorg allowance is + /// followed and does not; a larger move back is ignored. + pure def observeTip(state: HubState, height: Height): HubResult = + match state.tip { + | None => + if (state.phase == Starting) + { ...state, + phase: Running, + tip: Some(height), + lastEpoch: Some(height / state.params.flushInterval), + }.toNoHubOutput() + else state.toHubErrorOutput("no cadence loop is running") + | Some(tip) => + if (state.phase != Running and state.phase != Stale) + state.toHubErrorOutput("no cadence loop is running") + else if (state.flush != Idle) + state.toHubErrorOutput("the tip is not observed during a flush") + else if (height > tip) + { ...state, phase: Running, tip: Some(height), cadence: Tracking }.toNoHubOutput() + else if (tip - height <= state.params.reorgAllowance) + { ...state, tip: Some(height) }.toNoHubOutput() + else + state.toNoHubOutput() + } + + /// The staleness window passes with no forward progress, or passes further: + /// the cadence clock free-runs and now reads `height`. The estimate starts at + /// or above the observed tip and never runs backwards. + pure def freeRun(state: HubState, height: Height): HubResult = + if (state.flush != Idle) + state.toHubErrorOutput("the cadence clock is not read during a flush") + else + match state.cadence { + | Tracking => + if (state.phase == Running and height >= state.observedTip()) + { ...state, phase: Stale, cadence: FreeRunning(height) }.toNoHubOutput() + else state.toHubErrorOutput("not a running hub, or an estimate below the observed tip") + | FreeRunning(estimate) => + if (state.phase == Stale and height >= estimate) + { ...state, cadence: FreeRunning(height) }.toNoHubOutput() + else state.toHubErrorOutput("not a stale hub, or an estimate that runs backwards") + } + + // ------------------------------------------------------------------------ + // Flush + // ------------------------------------------------------------------------ + + /// A flush begins: the whole queue moves out at once. An empty queue is + /// still a flush event, and the epoch is recorded. The final flush of a + /// draining hub with nothing to publish stops it. + pure def beginFlush(state: HubState): HubResult = + if (not(state.isFlushDue())) + state.toHubErrorOutput("no flush is due") + else if (state.queue == Map()) + if (state.phase == Draining) + { ...state, phase: Stopped }.toNoHubOutput() + else + { ...state, lastEpoch: Some(state.cadenceEpoch()) }.toNoHubOutput() + else + { ...state, + queue: Map(), + flush: Broadcasting({ batch: state.queue, unplaced: Map(), final: state.phase == Draining }), + }.toBroadcastOutput(state.queued()) + + /// The indexer's verdict on one entry. Accepted and already-known entries + /// are published and leave; a rejected entry is dropped; a retryable one is + /// set aside for requeue. + pure def takeVerdict(state: HubState, payload: Payload, verdict: Verdict): HubResult = + match state.flush { + | Idle => state.toHubErrorOutput("no flush is in flight") + | Broadcasting(flush) => + if (not(flush.batch.keys().contains(payload))) + state.toHubErrorOutput("the payload is not awaiting a verdict") + else + val attempts = flush.batch.get(payload) + { ...state, + flush: Broadcasting({ ...flush, + batch: flush.batch.mapRemove(payload), + unplaced: if (verdict == Retryable) flush.unplaced.put(payload, attempts) else flush.unplaced, + }), + }.toNoHubOutput() + } + + /// A flush ends: the entries nothing judged go back into the queue, each + /// decided on its own. + /// + /// - The same bytes already resident (resubmitted during the flush) win, and + /// the returning copy is dropped without being counted. + /// - An entry that no longer survives the next flush, judged at the observed + /// tip exactly as admission judges it, is dropped as expired. + /// - An entry out of attempts is dropped as exhausted. Only an entry with no + /// expiry gets that far. + /// - The rest are held, even past the queue's capacity: they were admitted + /// before anything now resident. + /// + /// After the final flush the process exits and whatever was held is lost. + pure def endFlush(state: HubState): HubResult = + match state.flush { + | Idle => state.toHubErrorOutput("no flush is in flight") + | Broadcasting(flush) => + if (flush.batch != Map()) + state.toHubErrorOutput("entries are still awaiting a verdict") + else + val params = state.params + val returning = flush.unplaced.keys().exclude(state.queued()) + val expired = returning.filter(payload => + not(survivesNextFlush(payload.expiry, state.observedTip(), params.flushInterval, params.miningMargin))) + val exhausted = returning.exclude(expired).filter(payload => + flush.unplaced.get(payload) + 1 > params.maxAttempts) + val held = returning.exclude(expired).exclude(exhausted) + val queue = state.queued().union(held).mapBy(payload => + if (held.contains(payload)) flush.unplaced.get(payload) + 1 else state.queue.get(payload)) + val idle = + if (flush.final) { ...state, phase: Stopped, queue: Map(), flush: Idle } + else { ...state, queue: queue, flush: Idle, lastEpoch: Some(state.cadenceEpoch()) } + idle.toRequeuedOutput(held.size(), expired.size(), exhausted.size()) + } + + // ------------------------------------------------------------------------ + // The hub function + // ------------------------------------------------------------------------ + + pure def hub(state: HubState, input: HubInput): HubResult = + match input { + | SubmitHInput(submit) => + if (not(state.isServing())) + state.toHubErrorOutput("the hub is not serving") + else + val kind = admission(state, submit.payload) + // The entry is resident before the ack exists: an accepted ack is a + // promise that the hub holds the bytes. + val admitted = + if (kind == Admitted) { ...state, queue: state.queue.put(submit.payload, 0) } else state + admitted.toAckOutput(submit.nonce, kind) + + | LookupHInput(lookup) => + if (not(state.isServing())) + state.toHubErrorOutput("the hub is not serving") + // The queue first: a diverted transaction that has not been flushed + // exists nowhere else. Then the indexer, whose answer is forwarded. + else if (state.isQueuedTxid(lookup.txid)) + state.toLookupReplyOutput(lookup.nonce, QueueHit) + else + state.toLookupReplyOutput(lookup.nonce, FromIndexer(lookup.answer)) + + | TipHInput(height) => observeTip(state, height) + + | StaleHInput(height) => freeRun(state, height) + + | FlushDueHInput => beginFlush(state) + + | VerdictHInput(judged) => takeVerdict(state, judged.payload, judged.verdict) + + | FlushDoneHInput => endFlush(state) + + | DrainHInput => + // Admission closes first; a flush already in flight finishes, and then + // the final one runs. + if (state.phase == Running or state.phase == Stale) + { ...state, phase: Draining }.toNoHubOutput() + else state.toHubErrorOutput("only a running hub can be drained") + + | CrashHInput => + if (state.phase == Down) state.toHubErrorOutput("the hub is already down") + else downHub(state.params).toNoHubOutput() + + | RestartHInput => + if (state.phase == Down) startingHub(state.params).toNoHubOutput() + else state.toHubErrorOutput("the hub is already up") + } + + // ------------------------------------------------------------------------ + // Byzantine relation + // ------------------------------------------------------------------------ + + /// The transitions of a Byzantine hub. It keeps the honest schedule, and is + /// free in what it tells its clients and in what it admits: + /// + /// - a submission is answered with any decision, and the payload is queued + /// or not, independently of the answer and of every admission rule; + /// - a lookup is answered with any outcome: a queue hit, or anything an + /// indexer could say, with a body drawn from `universe` or none, at any + /// height in `heights`. + /// + /// The honest transition is always a member. + pure def byzHubResults( + state: HubState, + input: HubInput, + universe: Set[Payload], + heights: Set[Height], + ): Set[HubResult] = + val honest = Set(hub(state, input)) + if (not(state.isServing())) honest + else + match input { + | SubmitHInput(submit) => + val kinds = Set(Admitted, Duplicate) + .union(Set(TipStale, HubDraining, TooLarge, ExpiryTooTight, Full).map(refusal => Refused(refusal))) + val holding = + if (state.queued().contains(submit.payload)) state + else { ...state, queue: state.queue.put(submit.payload, 0) } + honest.union(tuples(Set(state, holding), kinds).map(((after, kind)) => + after.toAckOutput(submit.nonce, kind))) + | LookupHInput(lookup) => + val bodies = Set(None).union(universe.map(payload => Some(payload))) + val answers = tuples(bodies, heights) + .map(((body, height)) => IFound({ body: body, height: height })) + .union(Set(INotFound, IUnavailable)) + val outcomes = Set(QueueHit).union(answers.map(answer => FromIndexer(answer))) + honest.union(outcomes.map(outcome => state.toLookupReplyOutput(lookup.nonce, outcome))) + | _ => honest + } +} diff --git a/zeronym/spec/protocol/indexer.qnt b/zeronym/spec/protocol/indexer.qnt new file mode 100644 index 00000000..7311d8aa --- /dev/null +++ b/zeronym/spec/protocol/indexer.qnt @@ -0,0 +1,214 @@ +// -*- mode: Bluespec; -*- + +/// The chain as a hub sees it: one abstract indexer standing for all of a +/// hub's indexer endpoints and the network behind them. +/// +/// It is the environment, written in the same shape as the components: an +/// input, a set of possible outputs, and the effect of each on the state. The +/// honest relation is already nondeterministic, because a hub cannot tell in +/// advance whether a broadcast will be taken. The Byzantine relation is a +/// superset of it. +/// +/// A hub folds several endpoints into one answer, and the folds are not +/// symmetric. The tip is the maximum over the endpoints that answer; a lookup +/// returns the first `found`; a broadcast takes the best verdict. So a single +/// misbehaving endpoint is enough to raise the tip, inject a lookup answer or +/// change a verdict, while lowering or freezing the tip takes every endpoint. +/// "Byzantine indexer" in this specification covers both cases. +module indexer { + import basicSpells.* from "./spells/basicSpells" + import types.* from "./types" + + /// A transaction the chain has, with the bytes it was accepted as. + type ChainTx = { payload: Payload, at: Inclusion } + + /// - `height`: the true chain height. + /// - `txs`: the transactions in the mempool or in a block, by txid. + /// - `offered`: every payload a hub has broadcast, whatever came of it. It + /// is what an indexer has seen, and so what a Byzantine one could reveal. + type IndexerState = { + height: Height, + txs: TxId -> ChainTx, + offered: Set[Payload], + } + + type IndexerInput = + | BroadcastIInput(Payload) // a hub publishes one entry of a batch + | LookupIInput(TxId) // a hub's lookup missed its queue + | AdvanceIInput // the chain grows by one block + | MineIInput(TxId) // a mempool transaction is included + + type IndexerOutput = + | VerdictOutput(Verdict) + | AnswerOutput(IndexerAnswer) + | NoIndexerOutput + + type IndexerResult = Result[IndexerState, IndexerOutput] + + pure def initialIndexer(height: Height): IndexerState = + { height: height, txs: Map(), offered: Set() } + + // ------------------------------------------------------------------------ + // Views + // ------------------------------------------------------------------------ + + /// Where `txid` stands on the chain. + pure def inclusion(state: IndexerState, txid: TxId): Inclusion = + if (state.txs.keys().contains(txid)) state.txs.get(txid).at else Absent + + /// The payloads the chain has made public. + pure def published(state: IndexerState): Set[Payload] = + state.txs.keys().map(txid => state.txs.get(txid).payload) + + /// Whether the chain already has a transaction with `payload`'s txid. + pure def isKnown(state: IndexerState, payload: Payload): bool = + match payload.txid { + | Some(txid) => state.txs.keys().contains(txid) + | None => false + } + + /// Whether a node would take `payload` into its mempool now: it parses, it + /// can still be mined in the next block, and it is not there already. + pure def isAcceptable(state: IndexerState, payload: Payload): bool = + and { + isSome(payload.txid), + not(state.isKnown(payload)), + match payload.expiry { + | Some(expiry) => expiry > state.height + | None => true + }, + } + + // ------------------------------------------------------------------------ + // Effect + // ------------------------------------------------------------------------ + + /// The state after `input` was answered with `output`. A broadcast is always + /// remembered as offered; it reaches the mempool only on `Accepted`. + pure def indexerApply(state: IndexerState, input: IndexerInput, output: IndexerOutput): IndexerState = + match input { + | BroadcastIInput(payload) => + val seen = { ...state, offered: state.offered.union(Set(payload)) } + match payload.txid { + | Some(txid) => + if (output == VerdictOutput(Accepted)) + { ...seen, txs: seen.txs.put(txid, { payload: payload, at: InMempool }) } + else seen + | None => seen + } + | LookupIInput(_) => state + | AdvanceIInput => { ...state, height: state.height + 1 } + | MineIInput(txid) => + if (state.inclusion(txid) == InMempool) + { ...state, txs: state.txs.setBy(txid, tx => { ...tx, at: MinedAt(state.height) }) } + else state + } + + // ------------------------------------------------------------------------ + // Honest relation + // ------------------------------------------------------------------------ + + /// The outputs an honest indexer may give. An empty set means the input is + /// not enabled. + /// + /// - Broadcast: `Rejected` and `Retryable` are always possible (the node has + /// reasons this model does not see, and the indexer may be unreachable); + /// `Accepted` only for a transaction a node would take; `AlreadyKnown` + /// only for one the chain has. + /// - Lookup: the chain's answer, or unavailable. + pure def honestIndexerOutputs(state: IndexerState, input: IndexerInput): Set[IndexerOutput] = + match input { + | BroadcastIInput(payload) => + Set(Rejected, Retryable) + .union(if (state.isAcceptable(payload)) Set(Accepted) else Set()) + .union(if (state.isKnown(payload)) Set(AlreadyKnown) else Set()) + .map(verdict => VerdictOutput(verdict)) + | LookupIInput(txid) => + if (state.txs.keys().contains(txid)) + val tx = state.txs.get(txid) + Set( + AnswerOutput(IFound({ body: Some(tx.payload), height: answerHeight(tx.at) })), + AnswerOutput(IUnavailable), + ) + else Set(AnswerOutput(INotFound), AnswerOutput(IUnavailable)) + | AdvanceIInput => Set(NoIndexerOutput) + | MineIInput(txid) => + if (state.inclusion(txid) == InMempool) Set(NoIndexerOutput) else Set() + } + + pure def honestIndexerResults(state: IndexerState, input: IndexerInput): Set[IndexerResult] = + honestIndexerOutputs(state, input).map(output => + { state: indexerApply(state, input, output), out: output }) + + /// The truthful, available answer to a lookup: the one member of the honest + /// relation that is not `IUnavailable`. + pure def chainAnswer(state: IndexerState, txid: TxId): IndexerAnswer = + if (state.txs.keys().contains(txid)) + val tx = state.txs.get(txid) + IFound({ body: Some(tx.payload), height: answerHeight(tx.at) }) + else INotFound + + /// The tips an honest indexer may report when reports can trail the true + /// height by up to `slack` blocks. + pure def honestTips(state: IndexerState, slack: int): Set[Height] = + (if (state.height > slack) state.height - slack else 0).to(state.height) + + // ------------------------------------------------------------------------ + // Byzantine relation + // ------------------------------------------------------------------------ + + /// The payloads a Byzantine indexer can put in a lookup answer: those it was + /// offered, those the chain made public, and any twin of either that exists + /// in `universe`. + pure def servable(state: IndexerState, universe: Set[Payload]): Set[Payload] = + val known = state.offered.union(state.published()) + universe.filter(candidate => + known.exists(payload => candidate == payload or areTwins(candidate, payload))) + + /// The outputs a Byzantine indexer may give: any verdict, and any lookup + /// answer built from a payload it can serve, at any height in `heights`, + /// with or without a body. + pure def byzIndexerOutputs( + state: IndexerState, + input: IndexerInput, + universe: Set[Payload], + heights: Set[Height], + ): Set[IndexerOutput] = + match input { + | BroadcastIInput(_) => + Set(Accepted, AlreadyKnown, Rejected, Retryable).map(verdict => VerdictOutput(verdict)) + | LookupIInput(_) => + val bodies = Set(None).union(state.servable(universe).map(payload => Some(payload))) + tuples(bodies, heights) + .map(((body, height)) => AnswerOutput(IFound({ body: body, height: height }))) + .union(Set(AnswerOutput(INotFound), AnswerOutput(IUnavailable))) + .union(honestIndexerOutputs(state, input)) + | AdvanceIInput => honestIndexerOutputs(state, input) + | MineIInput(_) => honestIndexerOutputs(state, input) + } + + /// The transitions of a Byzantine indexer. On a broadcast the verdict it + /// reports and what it does with the transaction are independent: it may + /// relay a transaction it claims to have rejected, or sit on one it claims to + /// have accepted. It cannot make the network take a transaction a node would + /// refuse: the chain itself stays honest. + pure def byzIndexerResults( + state: IndexerState, + input: IndexerInput, + universe: Set[Payload], + heights: Set[Height], + ): Set[IndexerResult] = + val outputs = byzIndexerOutputs(state, input, universe, heights) + match input { + | BroadcastIInput(payload) => + val withheld = indexerApply(state, input, VerdictOutput(Retryable)) + val relayed = indexerApply(state, input, VerdictOutput(Accepted)) + val effects = if (state.isAcceptable(payload)) Set(withheld, relayed) else Set(withheld) + tuples(effects, outputs).map(((effect, output)) => { state: effect, out: output }) + | _ => outputs.map(output => { state: indexerApply(state, input, output), out: output }) + } + + /// The tips a Byzantine indexer may report: anything up to `maxHeight`. + pure def byzTips(maxHeight: Height): Set[Height] = + 0.to(maxHeight) +} diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt new file mode 100644 index 00000000..7dd78fa4 --- /dev/null +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -0,0 +1,335 @@ +// -*- mode: Bluespec; -*- + +/// The hub function, checked on a small schedule: a flush every 3 blocks, a +/// mining margin of 1, at most 2 requeues, room for 2 entries. +module hubTest { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import hub.* from "../hub" + + pure val PARAMS: HubParams = { + flushInterval: 3, + miningMargin: 1, + deliveryLag: 1, + minWalletExpiry: 6, + reorgAllowance: 1, + maxAttempts: 2, + queueCap: 2, + } + + pure def orchard(id: str, expiry: Option[Height]): Payload = + { id: id, txid: Some(id), created: 1, expiry: expiry, class: OrchardTouching, oversize: false } + + pure val pA = orchard("a", Some(9)) + pure val pB = orchard("b", Some(9)) + pure val pC = orchard("c", None) + pure val pTight = orchard("tight", Some(6)) + pure val pBig = { ...orchard("big", None), oversize: true } + pure val pBigTight = { ...pTight, id: "big-tight", oversize: true } + pure val pJunk = { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } + + pure val PAYLOADS = Set(pA, pB, pC, pTight, pBig, pBigTight, pJunk) + pure val HEIGHTS = Set(0, 4) + pure val REFUSALS = Set(TipStale, HubDraining, TooLarge, ExpiryTooTight, Full) + + /// The state after `input`. + pure def after(state: HubState, input: HubInput): HubState = + hub(state, input).state + + /// The output of `input`. + pure def outputOf(state: HubState, input: HubInput): HubOutput = + hub(state, input).out + + pure def isError(output: HubOutput): bool = + match output { + | HubErrorOutput(_) => true + | _ => false + } + + pure def submit(payload: Payload): HubInput = + SubmitHInput({ nonce: 0, payload: payload }) + + pure def verdict(payload: Payload, given: Verdict): HubInput = + VerdictHInput({ payload: payload, verdict: given }) + + pure val down = downHub(PARAMS) + pure val starting = startingHub(PARAMS) + pure def running(tip: Height): HubState = starting.after(TipHInput(tip)) + + /// Running at tip 5 with `pA` and `pJunk` queued: full. + pure val full = running(5).after(submit(pA)).after(submit(pJunk)) + /// The same hub one block later, with its flush in flight. + pure val flushing = full.after(TipHInput(6)).after(FlushDueHInput) + pure val stale = running(5).after(StaleHInput(8)) + pure val draining = full.after(DrainHInput) + pure val stopped = draining.after(FlushDueHInput).after(verdict(pA, Accepted)) + .after(verdict(pJunk, Rejected)).after(FlushDoneHInput) + + pure val STATES = Set(down, starting, running(2), running(5), full, flushing, stale, draining, stopped, + stale.after(DrainHInput), flushing.after(verdict(pA, Retryable))) + + pure val INPUTS: Set[HubInput] = + PAYLOADS.map(payload => submit(payload)) + .union(Set( + LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), + LookupHInput({ nonce: 1, txid: "zz", answer: IFound({ body: Some(pB), height: 4 }) }), + TipHInput(0), TipHInput(4), TipHInput(5), TipHInput(6), TipHInput(9), + StaleHInput(4), StaleHInput(8), StaleHInput(9), + FlushDueHInput, FlushDoneHInput, DrainHInput, CrashHInput, RestartHInput, + )) + .union(tuples(Set(pA, pB, pJunk), Set(Accepted, AlreadyKnown, Rejected, Retryable)) + .map(((payload, given)) => verdict(payload, given))) + + // ------------------------------------------------------------------------ + // Admission + // ------------------------------------------------------------------------ + + /// F7. Under the startup budget, a conforming payload that arrives within + /// the delivery lag passes the expiry check. The lemma is about admission at + /// one tip. It does not say at what height the flush later happens. + run conformingTimelyPayloadIsAdmissibleTest = all { + assert(budgetFits(PARAMS)), + assert(tuples(0.to(9), 0.to(PARAMS.deliveryLag), 0.to(2)).forall(((created, lag, extra)) => + survivesNextFlush( + Some(created + PARAMS.minWalletExpiry + extra), created + lag, + PARAMS.flushInterval, PARAMS.miningMargin))), + // The budget is what makes it true: one block less and a payload built + // one block before a boundary, arriving on it, is refused. + assert(not(budgetFits({ ...PARAMS, minWalletExpiry: 4 }))), + assert(not(survivesNextFlush(Some(2 + 4), 2 + 1, PARAMS.flushInterval, PARAMS.miningMargin))), + } + + run nextFlushHeightTest = all { + assert(nextFlushHeight(0, 3) == 3), + assert(nextFlushHeight(2, 3) == 3), + // Strictly after: a tip on a boundary waits a whole interval. + assert(nextFlushHeight(3, 3) == 6), + assert(survivesNextFlush(None, 100, 3, 1)), + assert(survivesNextFlush(Some(7), 5, 3, 1)), + assert(not(survivesNextFlush(Some(6), 5, 3, 1))), + } + + /// F8. Each refusal, and the order the checks are made in. + run admissionDecisionTableTest = all { + // No tip yet, or a stale one: nothing else is looked at. + assert(PAYLOADS.forall(payload => admission(starting, payload) == Refused(TipStale))), + assert(PAYLOADS.forall(payload => admission(stale, payload) == Refused(TipStale))), + assert(PAYLOADS.forall(payload => admission(stale.after(DrainHInput), payload) == Refused(TipStale))), + // Draining comes before anything about the payload. + assert(PAYLOADS.forall(payload => admission(draining, payload) == Refused(HubDraining))), + // Size before expiry. + assert(admission(running(5), pBig) == Refused(TooLarge)), + assert(admission(running(5), pBigTight) == Refused(TooLarge)), + // Expiry before the queue is looked at. At tip 5 the next flush is at 6. + assert(admission(running(5), pTight) == Refused(ExpiryTooTight)), + assert(admission(full, pTight) == Refused(ExpiryTooTight)), + assert(admission(running(2), pTight) == Admitted), + // A duplicate is recognised before capacity is checked. + assert(admission(full, pA) == Duplicate), + assert(admission(full, pB) == Refused(Full)), + assert(admission(running(5), pA) == Admitted), + // An unparseable payload has no expiry and is admitted like any other. + assert(admission(running(5), pJunk) == Admitted), + // Every refusal is produced, and a refusal changes nothing. + assert(REFUSALS.forall(refusal => + tuples(STATES, PAYLOADS).exists(((state, payload)) => + state.isServing() and outputOf(state, submit(payload)) == AckOutput({ nonce: 0, kind: Refused(refusal) })))), + assert(tuples(STATES, PAYLOADS).forall(((state, payload)) => + match outputOf(state, submit(payload)) { + | AckOutput(ack) => ack.kind == Admitted or state.after(submit(payload)) == state + | _ => true + })), + } + + /// F13. An accepted ack is given only for a payload the hub then holds. + run ackImpliesQueuedTest = all { + assert(tuples(STATES, PAYLOADS).forall(((state, payload)) => + match outputOf(state, submit(payload)) { + | AckOutput(ack) => isAccepted(ack.kind) implies state.after(submit(payload)).queued().contains(payload) + | _ => true + })), + assert(running(5).after(submit(pA)).queue == Map(pA -> 0)), + } + + // ------------------------------------------------------------------------ + // Lookup + // ------------------------------------------------------------------------ + + run lookupTest = all { + // A queue hit, whatever the indexer would have said. + assert(outputOf(full, LookupHInput({ nonce: 7, txid: "a", answer: IFound({ body: Some(pA), height: 4 }) })) + == LookupReplyOutput({ nonce: 7, outcome: QueueHit })), + // A miss forwards the indexer's answer as it came. + assert(Set(INotFound, IUnavailable, IFound({ body: Some(pB), height: 4 }), IFound({ body: None, height: 0 })) + .forall(answer => + outputOf(full, LookupHInput({ nonce: 7, txid: "b", answer: answer })) + == LookupReplyOutput({ nonce: 7, outcome: FromIndexer(answer) }))), + // A queued payload that does not parse is never hit. + assert(full.queued().contains(pJunk) and not(full.isQueuedTxid("junk"))), + // Once the flush has taken the queue, the same lookup misses. + assert(outputOf(flushing, LookupHInput({ nonce: 7, txid: "a", answer: INotFound })) + == LookupReplyOutput({ nonce: 7, outcome: FromIndexer(INotFound) })), + assert(STATES.forall(state => + after(state, LookupHInput({ nonce: 7, txid: "a", answer: INotFound })) == state)), + } + + // ------------------------------------------------------------------------ + // Tip + // ------------------------------------------------------------------------ + + /// F14. The tip rule. + run tipRuleTest = all { + // The first observation is adopted with its epoch, and nothing is due. + assert(running(5).tip == Some(5) and running(5).phase == Running), + assert(running(5).lastEpoch == Some(1) and not(running(5).isFlushDue())), + // Forward: followed. + assert(running(5).after(TipHInput(6)).tip == Some(6)), + // Back within the allowance: followed. + assert(running(5).after(TipHInput(4)).tip == Some(4)), + // Back beyond it: ignored. + assert(running(5).after(TipHInput(3)) == running(5)), + assert(outputOf(running(5), TipHInput(3)) == NoHubOutput), + // A forward move ends staleness; a move back does not. + assert(stale.after(TipHInput(6)).phase == Running and stale.after(TipHInput(6)).cadence == Tracking), + assert(stale.after(TipHInput(4)).phase == Stale and stale.after(TipHInput(4)).cadence == FreeRunning(8)), + // Never during a flush, and never without a cadence loop. + assert(isError(outputOf(flushing, TipHInput(7)))), + assert(isError(outputOf(down, TipHInput(7)))), + assert(isError(outputOf(draining, TipHInput(7)))), + } + + run staleCadenceTest = all { + assert(stale.phase == Stale and stale.cadence == FreeRunning(8)), + // The observed tip is untouched; only the schedule moves. + assert(stale.observedTip() == 5 and stale.cadenceHeight() == 8 and stale.cadenceEpoch() == 2), + assert(stale.isFlushDue()), + assert(stale.after(StaleHInput(9)).cadence == FreeRunning(9)), + assert(isError(outputOf(stale, StaleHInput(7)))), + assert(isError(outputOf(running(5), StaleHInput(4)))), + assert(isError(outputOf(starting, StaleHInput(4)))), + assert(isError(outputOf(flushing, StaleHInput(9)))), + } + + // ------------------------------------------------------------------------ + // Flush and requeue + // ------------------------------------------------------------------------ + + run flushCycleTest = all { + assert(not(full.isFlushDue()) and isError(outputOf(full, FlushDueHInput))), + assert(full.after(TipHInput(6)).isFlushDue()), + // The whole queue moves out at once. + assert(outputOf(full.after(TipHInput(6)), FlushDueHInput) == BroadcastOutput(Set(pA, pJunk))), + assert(flushing.queue == Map() and flushing.inFlight() == Set(pA, pJunk)), + // Published, already known and rejected entries leave. + assert(Set(Accepted, AlreadyKnown, Rejected).forall(given => + flushing.after(verdict(pA, given)).inFlight() == Set(pJunk))), + // A retryable entry stays out until the flush ends. + assert(flushing.after(verdict(pA, Retryable)).inFlight() == Set(pA, pJunk)), + assert(isError(outputOf(flushing, verdict(pB, Accepted)))), + assert(isError(outputOf(flushing.after(verdict(pA, Accepted)), verdict(pA, Accepted)))), + assert(isError(outputOf(full, verdict(pA, Accepted)))), + // The flush cannot end while a verdict is outstanding. + assert(isError(outputOf(flushing, FlushDoneHInput))), + assert(isError(outputOf(full, FlushDoneHInput))), + // An empty queue is still a flush event: the epoch is recorded. + assert(running(5).after(TipHInput(6)).after(FlushDueHInput).lastEpoch == Some(2)), + assert(outputOf(running(5).after(TipHInput(6)), FlushDueHInput) == NoHubOutput), + } + + /// A flush at tip 5 that left four entries unjudged, with three resident. + pure val pExpired = orchard("expired", Some(6)) + pure val pResident = orchard("resident", Some(9)) + pure val pWornOut = { ...pJunk, id: "worn-out" } + pure val pWornOutTight = { ...pExpired, id: "worn-out-tight" } + pure val returning: HubState = { + ...running(5), + queue: Map(pResident -> 0, pB -> 0, pC -> 0), + flush: Broadcasting({ + batch: Map(), + unplaced: Map(pA -> 0, pExpired -> 0, pWornOut -> 2, pResident -> 1, pJunk -> 1, pWornOutTight -> 2), + final: false, + }), + } + + /// F9. Requeue, entry by entry, and the counts it reports. + run requeueTest = all { + assert(outputOf(returning, FlushDoneHInput) + == RequeuedOutput({ held: 2, droppedExpired: 2, droppedExhausted: 1 })), + // Held entries come back with one more attempt. The resident copy of the + // same bytes wins and keeps its own count. Capacity does not apply. + assert(returning.after(FlushDoneHInput).queue + == Map(pResident -> 0, pB -> 0, pC -> 0, pA -> 1, pJunk -> 2)), + assert(returning.after(FlushDoneHInput).queued().size() > PARAMS.queueCap), + assert(returning.after(FlushDoneHInput).flush == Idle), + assert(returning.after(FlushDoneHInput).lastEpoch == Some(1)), + // Expiry is judged at the observed tip, not at the cadence height. + assert({ ...returning, phase: Stale, cadence: FreeRunning(11) }.after(FlushDoneHInput).queued().contains(pA)), + assert({ ...returning, phase: Stale, cadence: FreeRunning(11) }.after(FlushDoneHInput).lastEpoch == Some(3)), + assert(not({ ...returning, tip: Some(8) }.after(FlushDoneHInput).queued().contains(pA))), + } + + run drainTest = all { + assert(draining.phase == Draining and draining.isFlushDue()), + assert(isError(outputOf(starting, DrainHInput))), + assert(isError(outputOf(draining, DrainHInput))), + assert(stale.after(DrainHInput).phase == Draining), + // The final flush publishes what is held; what it cannot place is lost. + assert(stopped.phase == Stopped and stopped.queue == Map()), + assert(draining.after(FlushDueHInput).after(verdict(pA, Retryable)).after(verdict(pJunk, Retryable)) + .after(FlushDoneHInput).queue == Map()), + assert(outputOf( + draining.after(FlushDueHInput).after(verdict(pA, Retryable)).after(verdict(pJunk, Retryable)), + FlushDoneHInput) == RequeuedOutput({ held: 2, droppedExpired: 0, droppedExhausted: 0 })), + // With nothing held, the final flush stops the hub at once. + assert(running(5).after(DrainHInput).after(FlushDueHInput).phase == Stopped), + // A flush already in flight finishes first, and the final one follows. + assert(flushing.after(DrainHInput).after(verdict(pA, Accepted)).after(verdict(pJunk, Retryable)) + .after(FlushDoneHInput).phase == Draining), + assert(flushing.after(DrainHInput).after(verdict(pA, Accepted)).after(verdict(pJunk, Retryable)) + .after(FlushDoneHInput).isFlushDue()), + } + + run crashAndRestartTest = all { + assert(STATES.exclude(Set(down)).forall(state => state.after(CrashHInput) == down)), + assert(isError(outputOf(down, CrashHInput))), + assert(down.after(RestartHInput) == starting), + assert(STATES.exclude(Set(down)).forall(state => isError(outputOf(state, RestartHInput)))), + // A hub that is not serving answers nothing. + assert(Set(down, stopped).forall(state => + isError(outputOf(state, submit(pA))) + and isError(outputOf(state, LookupHInput({ nonce: 1, txid: "a", answer: INotFound }))))), + } + + // ------------------------------------------------------------------------ + // Totality and the Byzantine relation + // ------------------------------------------------------------------------ + + /// F11. The function answers every input in every state, and an input that + /// is invalid in a state leaves that state unchanged. + run totalityTest = + assert(tuples(STATES, INPUTS).forall(((state, input)) => + val result = hub(state, input) + and { + result.state.params == state.params, + isError(result.out) implies result.state == state, + })) + + /// F12. The Byzantine relation contains the honest transition. + run byzantineContainsHonestTest = + assert(tuples(STATES, INPUTS).forall(((state, input)) => + byzHubResults(state, input, PAYLOADS, HEIGHTS).contains(hub(state, input)))) + + /// F13, companion. A Byzantine hub can accept a payload it does not hold, + /// can hold one admission would refuse, and can answer a lookup with a + /// queued body. + run byzantineHubTest = all { + assert(byzHubResults(running(5), submit(pA), PAYLOADS, HEIGHTS).exists(result => + result.out == AckOutput({ nonce: 0, kind: Admitted }) and not(result.state.queued().contains(pA)))), + assert(byzHubResults(running(5), submit(pTight), PAYLOADS, HEIGHTS).exists(result => + result.state.queued().contains(pTight))), + assert(byzHubResults(full, LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), PAYLOADS, HEIGHTS) + .contains(full.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(pA), height: 0 }))))), + // It still cannot act while it is not running. + assert(byzHubResults(down, submit(pA), PAYLOADS, HEIGHTS) == Set(hub(down, submit(pA)))), + } +} diff --git a/zeronym/spec/protocol/tests/indexerTest.qnt b/zeronym/spec/protocol/tests/indexerTest.qnt new file mode 100644 index 00000000..2e652e91 --- /dev/null +++ b/zeronym/spec/protocol/tests/indexerTest.qnt @@ -0,0 +1,140 @@ +// -*- mode: Bluespec; -*- + +/// The indexer relations, checked over a small universe. +module indexerTest { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import indexer.* from "../indexer" + + pure def orchard(id: str, txid: TxId, expiry: Option[Height]): Payload = + { id: id, txid: Some(txid), created: 1, expiry: expiry, class: OrchardTouching, oversize: false } + + pure val pA = orchard("a", "ta", Some(9)) + pure val pATwin = orchard("a-twin", "ta", Some(9)) + pure val pB = orchard("b", "tb", None) + pure val pOld = orchard("old", "told", Some(4)) + pure val pJunk = { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } + + pure val UNIVERSE = Set(pA, pATwin, pB, pOld, pJunk) + pure val HEIGHTS = Set(0, 3, 7) + + pure def verdictOn(given: Verdict): IndexerOutput = VerdictOutput(given) + + pure val empty = initialIndexer(4) + /// `pA` accepted at height 4. + pure val withA = indexerApply(empty, BroadcastIInput(pA), verdictOn(Accepted)) + /// `pA` mined at height 5. + pure val minedA = indexerApply(indexerApply(withA, AdvanceIInput, NoIndexerOutput), MineIInput("ta"), NoIndexerOutput) + /// `pB` offered and not taken. + pure val offeredB = indexerApply(empty, BroadcastIInput(pB), verdictOn(Retryable)) + + pure val STATES = Set(empty, withA, minedA, offeredB) + pure val INPUTS: Set[IndexerInput] = + UNIVERSE.map(payload => BroadcastIInput(payload)) + .union(Set(LookupIInput("ta"), LookupIInput("tb"), AdvanceIInput, MineIInput("ta"), MineIInput("tb"))) + + run broadcastVerdictsTest = all { + // A valid, unexpired, unknown transaction may be accepted. + assert(honestIndexerOutputs(empty, BroadcastIInput(pA)) + == Set(Accepted, Rejected, Retryable).map(verdictOn)), + // One that can no longer be mined may not. + assert(honestIndexerOutputs(empty, BroadcastIInput(pOld)) + == Set(Rejected, Retryable).map(verdictOn)), + // Nor one that does not parse. + assert(honestIndexerOutputs(empty, BroadcastIInput(pJunk)) + == Set(Rejected, Retryable).map(verdictOn)), + // The chain has it: already known, under these bytes or a twin's. + assert(honestIndexerOutputs(withA, BroadcastIInput(pA)) + == Set(AlreadyKnown, Rejected, Retryable).map(verdictOn)), + assert(honestIndexerOutputs(withA, BroadcastIInput(pATwin)) + == Set(AlreadyKnown, Rejected, Retryable).map(verdictOn)), + } + + run broadcastEffectTest = all { + assert(withA.inclusion("ta") == InMempool and withA.published() == Set(pA)), + assert(withA.offered == Set(pA)), + // Offered is remembered whatever the verdict; the chain is untouched. + assert(offeredB.offered == Set(pB) and offeredB.txs == Map()), + assert(Set(AlreadyKnown, Rejected, Retryable).forall(given => + indexerApply(empty, BroadcastIInput(pA), verdictOn(given)).txs == Map())), + // The chain keeps the bytes it accepted first. + assert(indexerApply(withA, BroadcastIInput(pATwin), verdictOn(AlreadyKnown)).published() == Set(pA)), + } + + run lookupTest = all { + assert(honestIndexerOutputs(empty, LookupIInput("ta")) + == Set(AnswerOutput(INotFound), AnswerOutput(IUnavailable))), + // A mempool transaction is found at height 0, with its bytes. + assert(honestIndexerOutputs(withA, LookupIInput("ta")) + == Set(AnswerOutput(IFound({ body: Some(pA), height: MEMPOOL_HEIGHT })), AnswerOutput(IUnavailable))), + assert(honestIndexerOutputs(minedA, LookupIInput("ta")) + == Set(AnswerOutput(IFound({ body: Some(pA), height: 5 })), AnswerOutput(IUnavailable))), + assert(chainAnswer(empty, "ta") == INotFound), + assert(chainAnswer(minedA, "ta") == IFound({ body: Some(pA), height: 5 })), + // A lookup changes nothing. + assert(STATES.forall(state => + honestIndexerResults(state, LookupIInput("ta")).forall(result => result.state == state))), + // An honest indexer never reports a transaction without its bytes. + assert(tuples(STATES, Set("ta", "tb")).forall(((state, txid)) => + honestIndexerOutputs(state, LookupIInput(txid)).forall(output => + match output { + | AnswerOutput(answer) => + match answer { + | IFound(found) => isSome(found.body) + | _ => true + } + | _ => true + }))), + } + + run chainTest = all { + assert(indexerApply(empty, AdvanceIInput, NoIndexerOutput).height == 5), + assert(minedA.inclusion("ta") == MinedAt(5)), + // Only a mempool transaction can be mined: not an absent one, not twice. + assert(honestIndexerOutputs(withA, MineIInput("ta")) == Set(NoIndexerOutput)), + assert(honestIndexerOutputs(empty, MineIInput("ta")) == Set()), + assert(honestIndexerOutputs(minedA, MineIInput("ta")) == Set()), + assert(indexerApply(minedA, MineIInput("ta"), NoIndexerOutput) == minedA), + } + + run tipReportsTest = all { + assert(honestTips(empty, 0) == Set(4)), + assert(honestTips(empty, 1) == Set(3, 4)), + assert(honestTips(initialIndexer(1), 3) == Set(0, 1)), + } + + /// F12. The Byzantine relation contains every honest transition, and every + /// honest tip report is one a Byzantine indexer could make. + run byzantineContainsHonestTest = all { + assert(tuples(STATES, INPUTS).forall(((state, input)) => + honestIndexerResults(state, input).subseteq(byzIndexerResults(state, input, UNIVERSE, HEIGHTS)))), + assert(tuples(STATES, INPUTS).forall(((state, input)) => + honestIndexerOutputs(state, input).subseteq(byzIndexerOutputs(state, input, UNIVERSE, HEIGHTS)))), + assert(tuples(STATES, 0.to(3)).forall(((state, slack)) => + honestTips(state, slack).subseteq(byzTips(7)))), + } + + run byzantineIndexerTest = all { + // It may serve what it was offered and the chain has not published. + assert(offeredB.servable(UNIVERSE) == Set(pB)), + assert(byzIndexerOutputs(offeredB, LookupIInput("tb"), UNIVERSE, HEIGHTS) + .contains(AnswerOutput(IFound({ body: Some(pB), height: 7 })))), + // A twin of what it knows, too. Nothing else. + assert(withA.servable(UNIVERSE) == Set(pA, pATwin)), + assert(empty.servable(UNIVERSE) == Set()), + // "Found, height 0, no body" for a transaction that does not exist. + assert(byzIndexerOutputs(empty, LookupIInput("ta"), UNIVERSE, HEIGHTS) + .contains(AnswerOutput(IFound({ body: None, height: MEMPOOL_HEIGHT })))), + // The verdict and the effect are independent. + assert(byzIndexerResults(empty, BroadcastIInput(pA), UNIVERSE, HEIGHTS) + .contains({ state: offeredB.with("offered", Set(pA)), out: verdictOn(Accepted) })), + assert(byzIndexerResults(empty, BroadcastIInput(pA), UNIVERSE, HEIGHTS) + .contains({ state: withA, out: verdictOn(Rejected) })), + // It cannot put an expired transaction on the chain. + assert(byzIndexerResults(empty, BroadcastIInput(pOld), UNIVERSE, HEIGHTS) + .forall(result => result.state.txs == Map())), + // The chain's own steps are not the indexer's to change. + assert(byzIndexerResults(withA, AdvanceIInput, UNIVERSE, HEIGHTS) == honestIndexerResults(withA, AdvanceIInput)), + assert(byzIndexerResults(withA, MineIInput("ta"), UNIVERSE, HEIGHTS) == honestIndexerResults(withA, MineIInput("ta"))), + } +} From 369b070c0c1a1971ea93017f90a24feb8d91076c Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 03:38:09 +0400 Subject: [PATCH 23/80] test(zeronym): specify the shim as an input-output function --- zeronym/spec/protocol/shim.qnt | 335 +++++++++++++++++++++++ zeronym/spec/protocol/tests/shimTest.qnt | 238 ++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 zeronym/spec/protocol/shim.qnt create mode 100644 zeronym/spec/protocol/tests/shimTest.qnt diff --git a/zeronym/spec/protocol/shim.qnt b/zeronym/spec/protocol/shim.qnt new file mode 100644 index 00000000..231f6737 --- /dev/null +++ b/zeronym/spec/protocol/shim.qnt @@ -0,0 +1,335 @@ +// -*- mode: Bluespec; -*- + +/// The shim: it sits in front of an operator's indexer, forwards what is not +/// a migration, and diverts every migration, and every transaction lookup, to +/// the hubs. +/// +/// Like the hub it is one total function, `shim(state, input)`. Its state is +/// only the requests it is waiting on: it keeps no record of a migration once +/// the wallet has its answer, which is why every lookup has to go to a hub. +/// +/// `byzShimResults` is the wider relation a Byzantine shim draws from. +module shim { + import basicSpells.* from "./spells/basicSpells" + import types.* from "./types" + import wire.* from "./wire" + + // ------------------------------------------------------------------------ + // State + // ------------------------------------------------------------------------ + + /// A frame on its way to a hub. + type Frame = { hub: HubId, msg: Msg } + + /// What the shim remembers about a request it has sent. + /// + /// - An ack waiter exists for every submission. Only under `AwaitVerdict` is + /// anyone waiting on it (`awaited`); under `DispatchOnly` the wallet has + /// its answer already and the ack, if it comes, is discarded. + /// - A lookup waiter remembers where the sweep over the hubs started and how + /// many hubs it has tried. + type Waiter = + | AckWaiter({ payload: Payload, awaited: bool }) + | LookupWaiter({ query: TxId, start: int, attempt: int }) + + /// - `hubs`: the hub addresses, in configured order. + /// - `waiters`: the outstanding requests, by nonce. + /// - `nextNonce`: the source of fresh nonces. A counter stands for a random + /// value nobody else can guess. + type ShimState = { + hubs: List[HubId], + mode: SubmitMode, + waiters: Nonce -> Waiter, + nextNonce: Nonce, + } + + pure def initialShim(hubs: List[HubId], mode: SubmitMode): ShimState = + { hubs: hubs, mode: mode, waiters: Map(), nextNonce: 0 } + + // ------------------------------------------------------------------------ + // Inputs and outputs + // ------------------------------------------------------------------------ + + type ShimInput = + // A wallet's `SendTransaction`. `handedOver` is how many hub addresses, in + // order, take a frame before the transport stops taking them. + | SendTxSInput({ input: SendInput, handedOver: int }) + // A wallet's `GetTransaction`. `start` is where the rotating cursor points: + // the index of the hub asked first. + | GetTxSInput({ query: TxId, start: int }) + | FrameSInput(Msg) // a frame arrives from the network + | LookupTimeoutSInput(Nonce) // no reply to a lookup in time + | AckTimeoutSInput(Nonce) // no ack in time + + type ShimOutput = + | ForwardOutput(Payload) // to the operator's indexer + | DivertedOutput({ frames: Set[Frame], told: Option[SendObs] }) + | SendDoneOutput({ input: SendInput, obs: SendObs }) + | LookupSentOutput({ frame: Frame }) + | LookupDoneOutput({ query: TxId, result: LookupObs }) + | NoShimOutput + | ShimErrorOutput(str) + + type ShimResult = Result[ShimState, ShimOutput] + + pure def toForwardOutput(state: ShimState, payload: Payload): ShimResult = + { state: state, out: ForwardOutput(payload) } + + pure def toDivertedOutput(state: ShimState, frames: Set[Frame], told: Option[SendObs]): ShimResult = + { state: state, out: DivertedOutput({ frames: frames, told: told }) } + + pure def toSendDoneOutput(state: ShimState, input: SendInput, obs: SendObs): ShimResult = + { state: state, out: SendDoneOutput({ input: input, obs: obs }) } + + pure def toLookupSentOutput(state: ShimState, frame: Frame): ShimResult = + { state: state, out: LookupSentOutput({ frame: frame }) } + + pure def toLookupDoneOutput(state: ShimState, query: TxId, result: LookupObs): ShimResult = + { state: state, out: LookupDoneOutput({ query: query, result: result }) } + + pure def toNoShimOutput(state: ShimState): ShimResult = + { state: state, out: NoShimOutput } + + pure def toShimErrorOutput(state: ShimState, reason: str): ShimResult = + { state: state, out: ShimErrorOutput(reason) } + + // ------------------------------------------------------------------------ + // Views + // ------------------------------------------------------------------------ + + /// Whether a transaction of this class is diverted. A body the shim cannot + /// parse is treated as a migration: forwarding it is the one outcome the + /// shim exists to prevent. + pure def treatAsMigration(class: Class): bool = + class != PassThrough + + pure def hasWaiter(state: ShimState, nonce: Nonce): bool = + state.waiters.keys().contains(nonce) + + /// The hub a lookup sweep that started at `start` asks on its `attempt`-th + /// try. + pure def sweepTarget(state: ShimState, start: int, attempt: int): HubId = + state.hubs[(start + attempt) % state.hubs.length()] + + // ------------------------------------------------------------------------ + // SendTransaction + // ------------------------------------------------------------------------ + + /// Divert a migration: one frame per hub address, each under a fresh nonce, + /// to the first `handedOver` addresses. + /// + /// Under `DispatchOnly` the wallet is told ok as soon as one frame has been + /// handed over, whether or not the sweep reached the later addresses, and + /// no ack is waited for. Under `AwaitVerdict` there is one hub, and the + /// wallet's answer is that hub's. + pure def divert(state: ShimState, payload: Payload, handedOver: int): ShimResult = + val reach = match state.mode { + | DispatchOnly => handedOver + | AwaitVerdict => if (handedOver > 0) 1 else 0 + } + val targets = state.hubs.slice(0, reach) + if (targets.length() == 0) + state.toSendDoneOutput(Clean(payload), SendUnavailable) + else + val awaited = state.mode == AwaitVerdict + val frames = targets.indices().map(i => + { hub: targets[i], msg: Submit({ nonce: state.nextNonce + i, payload: payload }) }) + val waiting = targets.indices().fold(state.waiters, (waiters, i) => + waiters.put(state.nextNonce + i, AckWaiter({ payload: payload, awaited: awaited }))) + { ...state, waiters: waiting, nextNonce: state.nextNonce + targets.length() } + .toDivertedOutput(frames, if (awaited) None else Some(SentOk)) + + /// Route a `SendTransaction`. Nothing but a cleanly read pass-through + /// transaction ever reaches the operator; everything else is diverted or + /// fails closed. + pure def sendTransaction(state: ShimState, input: SendInput, handedOver: int): ShimResult = + if (handedOver < 0 or handedOver > state.hubs.length()) + state.toShimErrorOutput("more frames handed over than there are hubs") + else + match input { + | Unreadable => state.toSendDoneOutput(input, SendUnavailable) + | EmptyBody => state.toSendDoneOutput(input, SendInvalid) + | Clean(payload) => + if (not(treatAsMigration(payload.class))) state.toForwardOutput(payload) + else if (payload.oversize) state.toSendDoneOutput(input, SendTooLarge) + else divert(state, payload, handedOver) + } + + // ------------------------------------------------------------------------ + // GetTransaction + // ------------------------------------------------------------------------ + + /// Ask one hub, under a fresh nonce. + pure def askHub(state: ShimState, query: TxId, start: int, attempt: int): ShimResult = + val nonce = state.nextNonce + { ...state, + waiters: state.waiters.put(nonce, LookupWaiter({ query: query, start: start, attempt: attempt })), + nextNonce: nonce + 1, + }.toLookupSentOutput({ hub: state.sweepTarget(start, attempt), msg: Lookup({ nonce: nonce, txid: query }) }) + + /// Every lookup goes to a hub, starting at the hub the cursor points at. + pure def getTransaction(state: ShimState, query: TxId, start: int): ShimResult = + if (state.hubs.length() == 0) + state.toLookupDoneOutput(query, Unavailable) + else if (start < 0 or start >= state.hubs.length()) + state.toShimErrorOutput("the cursor points at no hub") + else + askHub(state, query, start, 0) + + /// A lookup got no reply in time. Only a timeout moves the sweep on to the + /// next hub; when every hub has been tried the lookup fails closed. + pure def lookupTimeout(state: ShimState, nonce: Nonce): ShimResult = + if (not(state.hasWaiter(nonce))) + state.toShimErrorOutput("no request is waiting under this nonce") + else + match state.waiters.get(nonce) { + | AckWaiter(_) => state.toShimErrorOutput("not a lookup") + | LookupWaiter(waiter) => + val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } + if (waiter.attempt + 1 < state.hubs.length()) + askHub(forgotten, waiter.query, waiter.start, waiter.attempt + 1) + else + forgotten.toLookupDoneOutput(waiter.query, Unavailable) + } + + /// A submission got no ack in time. + pure def ackTimeout(state: ShimState, nonce: Nonce): ShimResult = + if (not(state.hasWaiter(nonce))) + state.toShimErrorOutput("no request is waiting under this nonce") + else + match state.waiters.get(nonce) { + | LookupWaiter(_) => state.toShimErrorOutput("not a submission") + | AckWaiter(waiter) => + val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } + if (waiter.awaited) forgotten.toSendDoneOutput(Clean(waiter.payload), SendUnavailable) + else forgotten.toNoShimOutput() + } + + // ------------------------------------------------------------------------ + // Frames from the network + // ------------------------------------------------------------------------ + + /// A frame arrives. It is matched to a request by its nonce alone. A frame + /// under an unknown nonce is dropped. A frame of the wrong kind for a known + /// nonce is ignored and the request keeps waiting. + pure def receive(state: ShimState, msg: Msg): ShimResult = + match msg { + | Ack(ack) => + if (not(state.hasWaiter(ack.nonce))) state.toNoShimOutput() + else + match state.waiters.get(ack.nonce) { + | LookupWaiter(_) => state.toNoShimOutput() + | AckWaiter(waiter) => + val forgotten = { ...state, waiters: state.waiters.mapRemove(ack.nonce) } + if (waiter.awaited) + forgotten.toSendDoneOutput( + Clean(waiter.payload), + if (ack.ack == WAccepted) SentOk else SentRejected) + else forgotten.toNoShimOutput() + } + | LookupReply(reply) => + if (not(state.hasWaiter(reply.nonce))) state.toNoShimOutput() + else + match state.waiters.get(reply.nonce) { + | AckWaiter(_) => state.toNoShimOutput() + | LookupWaiter(waiter) => + { ...state, waiters: state.waiters.mapRemove(reply.nonce) } + .toLookupDoneOutput(waiter.query, interpretReply(reply.reply, waiter.query)) + } + | Submit(_) => state.toShimErrorOutput("not a reply frame") + | Lookup(_) => state.toShimErrorOutput("not a reply frame") + } + + // ------------------------------------------------------------------------ + // The shim function + // ------------------------------------------------------------------------ + + pure def shim(state: ShimState, input: ShimInput): ShimResult = + match input { + | SendTxSInput(send) => sendTransaction(state, send.input, send.handedOver) + | GetTxSInput(lookup) => getTransaction(state, lookup.query, lookup.start) + | FrameSInput(msg) => receive(state, msg) + | LookupTimeoutSInput(nonce) => lookupTimeout(state, nonce) + | AckTimeoutSInput(nonce) => ackTimeout(state, nonce) + } + + // ------------------------------------------------------------------------ + // Byzantine relation + // ------------------------------------------------------------------------ + + pure val SEND_OBSERVATIONS = + Set(SentOk, SentRejected, SentToOperator, SendUnavailable, SendInvalid, SendTooLarge) + + /// Every lookup answer that can be built from `universe` and `heights`. + pure def lookupObservations(universe: Set[Payload], heights: Set[Height]): Set[LookupObs] = + tuples(universe, heights) + .map(((payload, height)) => Tx({ payload: payload, height: height })) + .union(Set(Pending, NotFound, Unavailable)) + + /// The nonce a reply frame or a timeout refers to, if any. + pure def nonceOf(input: ShimInput): Option[Nonce] = + match input { + | FrameSInput(msg) => + match msg { + | Ack(ack) => Some(ack.nonce) + | LookupReply(reply) => Some(reply.nonce) + | _ => None + } + | LookupTimeoutSInput(nonce) => Some(nonce) + | AckTimeoutSInput(nonce) => Some(nonce) + | _ => None + } + + /// The transitions of a Byzantine shim. It sees every transaction in the + /// clear and is the only thing the wallet talks to, so it is free in what it + /// tells the wallet, what it hands the operator and what it sends the hubs: + /// + /// - a send is answered with anything, with nothing sent; or handed to the + /// operator whatever its class; or answered with anything while a frame + /// carrying any payload of `universe` goes to any one hub; + /// - a lookup is answered with anything, at once or when a reply or a + /// timeout would have resolved it; + /// - an awaited submission is resolved with anything. + /// + /// The honest transition is always a member. + pure def byzShimResults( + state: ShimState, + input: ShimInput, + universe: Set[Payload], + heights: Set[Height], + ): Set[ShimResult] = + val honest = Set(shim(state, input)) + val lies = lookupObservations(universe, heights) + match input { + | SendTxSInput(send) => + val forwarded = match send.input { + | Clean(payload) => Set(state.toForwardOutput(payload)) + | _ => Set() + } + val unsent = SEND_OBSERVATIONS.map(obs => state.toSendDoneOutput(send.input, obs)) + val stray = tuples(universe, state.hubs.indices(), SEND_OBSERVATIONS).map(((payload, i, obs)) => + { ...state, nextNonce: state.nextNonce + 1 }.toDivertedOutput( + Set({ hub: state.hubs[i], msg: Submit({ nonce: state.nextNonce, payload: payload }) }), + Some(obs))) + honest.union(forwarded).union(unsent).union(stray) + | GetTxSInput(lookup) => + honest.union(lies.map(result => state.toLookupDoneOutput(lookup.query, result))) + | _ => + match nonceOf(input) { + | None => honest + | Some(nonce) => + if (not(state.hasWaiter(nonce))) honest + else + val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } + match state.waiters.get(nonce) { + | LookupWaiter(waiter) => + honest.union(lies.map(result => forgotten.toLookupDoneOutput(waiter.query, result))) + | AckWaiter(waiter) => + if (waiter.awaited) + honest.union(SEND_OBSERVATIONS.map(obs => + forgotten.toSendDoneOutput(Clean(waiter.payload), obs))) + else honest + } + } + } +} diff --git a/zeronym/spec/protocol/tests/shimTest.qnt b/zeronym/spec/protocol/tests/shimTest.qnt new file mode 100644 index 00000000..aab1ac94 --- /dev/null +++ b/zeronym/spec/protocol/tests/shimTest.qnt @@ -0,0 +1,238 @@ +// -*- mode: Bluespec; -*- + +/// The shim function, checked with two hubs under both submit modes. +module shimTest { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import shim.* from "../shim" + + pure def payload(id: str, class: Class): Payload = + { id: id, txid: Some(id), created: 1, expiry: Some(9), class: class, oversize: false } + + pure val pOrchard = payload("orchard", OrchardTouching) + pure val pPlain = payload("plain", PassThrough) + pure val pJunk = { ...payload("junk", Unparseable), txid: None, expiry: None } + pure val pBig = { ...payload("big", OrchardTouching), oversize: true } + pure val pBigPlain = { ...payload("big-plain", PassThrough), oversize: true } + + pure val PAYLOADS = Set(pOrchard, pPlain, pJunk, pBig, pBigPlain) + pure val HEIGHTS = Set(0, 4) + pure val SEND_INPUTS = PAYLOADS.map(p => Clean(p)).union(Set(Unreadable, EmptyBody)) + + pure val dispatching = initialShim(["h1", "h2"], DispatchOnly) + pure val awaiting = initialShim(["h1"], AwaitVerdict) + + pure def after(state: ShimState, input: ShimInput): ShimState = shim(state, input).state + pure def outputOf(state: ShimState, input: ShimInput): ShimOutput = shim(state, input).out + + pure def isError(output: ShimOutput): bool = + match output { + | ShimErrorOutput(_) => true + | _ => false + } + + pure def send(input: SendInput, handedOver: int): ShimInput = + SendTxSInput({ input: input, handedOver: handedOver }) + + pure def getTx(query: TxId, start: int): ShimInput = + GetTxSInput({ query: query, start: start }) + + pure def ack(nonce: Nonce, given: WireAck): ShimInput = + FrameSInput(Ack({ nonce: nonce, ack: given })) + + pure def reply(nonce: Nonce, given: WireReply): ShimInput = + FrameSInput(LookupReply({ nonce: nonce, reply: given })) + + /// Nonces 0 and 1 wait on acks nobody reads; nonce 2 is a lookup at `h2`. + pure val busy = dispatching.after(send(Clean(pOrchard), 2)).after(getTx("orchard", 1)) + /// Nonce 0 is a submission whose ack is the wallet's answer. + pure val pendingSend = awaiting.after(send(Clean(pOrchard), 1)) + + pure val STATES = Set(dispatching, awaiting, busy, pendingSend, awaiting.after(getTx("orchard", 0))) + + pure val INPUTS: Set[ShimInput] = + tuples(SEND_INPUTS, 0.to(3)).map(((input, handedOver)) => send(input, handedOver)) + .union(tuples(Set("orchard", "zz"), 0.to(2)).map(((query, start)) => getTx(query, start))) + .union(0.to(3).map(nonce => ack(nonce, WAccepted))) + .union(0.to(3).map(nonce => ack(nonce, WRefused(WQueueFull)))) + .union(0.to(3).map(nonce => reply(nonce, WNotFound))) + .union(0.to(3).map(nonce => reply(nonce, WFound({ body: Some(pOrchard), height: 4 })))) + .union(0.to(3).map(nonce => LookupTimeoutSInput(nonce))) + .union(0.to(3).map(nonce => AckTimeoutSInput(nonce))) + .union(Set( + FrameSInput(Submit({ nonce: 0, payload: pOrchard })), + FrameSInput(Lookup({ nonce: 0, txid: "orchard" })), + )) + + // ------------------------------------------------------------------------ + // SendTransaction + // ------------------------------------------------------------------------ + + /// F5. Only a cleanly read pass-through transaction is forwarded. Every + /// other input is diverted or fails closed, in every state. + run onlyPassThroughIsForwardedTest = all { + assert(tuples(STATES, SEND_INPUTS, 0.to(2)).forall(((state, input, handedOver)) => + match outputOf(state, send(input, handedOver)) { + | ForwardOutput(forwarded) => input == Clean(forwarded) and forwarded.class == PassThrough + | _ => true + })), + assert(outputOf(dispatching, send(Clean(pPlain), 2)) == ForwardOutput(pPlain)), + // Forwarding does not depend on the hubs, or on the hub frame's size. + assert(outputOf(dispatching, send(Clean(pPlain), 0)) == ForwardOutput(pPlain)), + assert(outputOf(dispatching, send(Clean(pBigPlain), 2)) == ForwardOutput(pBigPlain)), + assert(dispatching.after(send(Clean(pPlain), 2)) == dispatching), + // A body the shim cannot parse is diverted, never forwarded. + assert(outputOf(dispatching, send(Clean(pJunk), 1)) + == DivertedOutput({ frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pJunk }) }), told: Some(SentOk) })), + } + + run failClosedTest = all { + assert(outputOf(dispatching, send(Unreadable, 2)) == SendDoneOutput({ input: Unreadable, obs: SendUnavailable })), + assert(outputOf(dispatching, send(EmptyBody, 2)) == SendDoneOutput({ input: EmptyBody, obs: SendInvalid })), + assert(outputOf(dispatching, send(Clean(pBig), 2)) == SendDoneOutput({ input: Clean(pBig), obs: SendTooLarge })), + // No frame handed over: the hub is unreachable. + assert(Set(dispatching, awaiting).forall(state => + outputOf(state, send(Clean(pOrchard), 0)) == SendDoneOutput({ input: Clean(pOrchard), obs: SendUnavailable }))), + // None of these leaves anything behind. + assert(Set(send(Unreadable, 2), send(EmptyBody, 2), send(Clean(pBig), 2), send(Clean(pOrchard), 0)) + .forall(input => dispatching.after(input) == dispatching)), + } + + run dispatchOnlyTest = all { + // One frame per hub, each under its own nonce, and the wallet is told ok + // at once. + assert(outputOf(dispatching, send(Clean(pOrchard), 2)) == DivertedOutput({ + frames: Set( + { hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }, + { hub: "h2", msg: Submit({ nonce: 1, payload: pOrchard }) }, + ), + told: Some(SentOk), + })), + // A sweep that stopped after the first address still tells the wallet ok. + assert(outputOf(dispatching, send(Clean(pOrchard), 1)) == DivertedOutput({ + frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }), + told: Some(SentOk), + })), + assert(dispatching.after(send(Clean(pOrchard), 2)).nextNonce == 2), + // The ack is never awaited: it clears the waiter and tells nobody. + assert(outputOf(busy, ack(0, WRefused(WQueueFull))) == NoShimOutput), + assert(not(busy.after(ack(0, WRefused(WQueueFull))).hasWaiter(0))), + assert(outputOf(busy, AckTimeoutSInput(1)) == NoShimOutput), + assert(not(busy.after(AckTimeoutSInput(1)).hasWaiter(1))), + } + + run awaitVerdictTest = all { + // One hub, and nothing is told until it answers. + assert(outputOf(awaiting, send(Clean(pOrchard), 1)) == DivertedOutput({ + frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }), + told: None, + })), + assert(outputOf(pendingSend, ack(0, WAccepted)) == SendDoneOutput({ input: Clean(pOrchard), obs: SentOk })), + assert(outputOf(pendingSend, ack(0, WRefused(WTipStale))) + == SendDoneOutput({ input: Clean(pOrchard), obs: SentRejected })), + assert(outputOf(pendingSend, AckTimeoutSInput(0)) + == SendDoneOutput({ input: Clean(pOrchard), obs: SendUnavailable })), + assert(pendingSend.after(ack(0, WAccepted)).waiters == Map()), + // A second ack for the same nonce finds nothing waiting. + assert(outputOf(pendingSend.after(ack(0, WAccepted)), ack(0, WRefused(WTipStale))) == NoShimOutput), + } + + // ------------------------------------------------------------------------ + // GetTransaction + // ------------------------------------------------------------------------ + + run lookupTest = all { + // The lookup goes to the hub the cursor points at. + assert(outputOf(dispatching, getTx("orchard", 0)) + == LookupSentOutput({ frame: { hub: "h1", msg: Lookup({ nonce: 0, txid: "orchard" }) } })), + assert(outputOf(dispatching, getTx("orchard", 1)) + == LookupSentOutput({ frame: { hub: "h2", msg: Lookup({ nonce: 0, txid: "orchard" }) } })), + assert(isError(outputOf(dispatching, getTx("orchard", 2)))), + // No hub configured to ask: unavailable. + assert(outputOf(initialShim([], DispatchOnly), getTx("orchard", 0)) + == LookupDoneOutput({ query: "orchard", result: Unavailable })), + // Each reply arm. + assert(outputOf(busy, reply(2, WFound({ body: None, height: MEMPOOL_HEIGHT }))) + == LookupDoneOutput({ query: "orchard", result: Pending })), + assert(outputOf(busy, reply(2, WFound({ body: Some(pOrchard), height: 4 }))) + == LookupDoneOutput({ query: "orchard", result: Tx({ payload: pOrchard, height: 4 }) })), + assert(outputOf(busy, reply(2, WFound({ body: Some(pPlain), height: 4 }))) + == LookupDoneOutput({ query: "orchard", result: NotFound })), + assert(outputOf(busy, reply(2, WNotFound)) == LookupDoneOutput({ query: "orchard", result: NotFound })), + assert(outputOf(busy, reply(2, WError)) == LookupDoneOutput({ query: "orchard", result: Unavailable })), + // Any reply is final: the waiter is gone, and no other hub is asked. + assert(Set(WNotFound, WError).forall(given => not(busy.after(reply(2, given)).hasWaiter(2)))), + } + + run lookupFailoverTest = all { + // Only a timeout moves on, to the next hub round the ring, under a fresh + // nonce. + assert(outputOf(busy, LookupTimeoutSInput(2)) + == LookupSentOutput({ frame: { hub: "h1", msg: Lookup({ nonce: 3, txid: "orchard" }) } })), + assert(not(busy.after(LookupTimeoutSInput(2)).hasWaiter(2))), + // The last hub timing out fails the lookup closed. + assert(outputOf(busy.after(LookupTimeoutSInput(2)), LookupTimeoutSInput(3)) + == LookupDoneOutput({ query: "orchard", result: Unavailable })), + // A late reply to the abandoned attempt is dropped. + assert(outputOf(busy.after(LookupTimeoutSInput(2)), reply(2, WNotFound)) == NoShimOutput), + assert(isError(outputOf(busy, LookupTimeoutSInput(0)))), + assert(isError(outputOf(busy, LookupTimeoutSInput(9)))), + assert(isError(outputOf(busy, AckTimeoutSInput(2)))), + } + + run correlationTest = all { + // An unknown nonce is dropped. + assert(outputOf(busy, ack(9, WAccepted)) == NoShimOutput and busy.after(ack(9, WAccepted)) == busy), + assert(outputOf(busy, reply(9, WNotFound)) == NoShimOutput and busy.after(reply(9, WNotFound)) == busy), + // The wrong kind of reply for a known nonce is ignored, and the request + // keeps waiting. + assert(outputOf(busy, ack(2, WAccepted)) == NoShimOutput and busy.after(ack(2, WAccepted)) == busy), + assert(outputOf(busy, reply(0, WNotFound)) == NoShimOutput and busy.after(reply(0, WNotFound)) == busy), + assert(outputOf(pendingSend, reply(0, WNotFound)) == NoShimOutput), + // Request frames are not replies. + assert(isError(outputOf(busy, FrameSInput(Submit({ nonce: 0, payload: pOrchard }))))), + assert(isError(outputOf(busy, FrameSInput(Lookup({ nonce: 2, txid: "orchard" }))))), + } + + // ------------------------------------------------------------------------ + // Totality and the Byzantine relation + // ------------------------------------------------------------------------ + + /// F11. The function answers every input in every state, and an invalid + /// input leaves the state unchanged. + run totalityTest = + assert(tuples(STATES, INPUTS).forall(((state, input)) => + val result = shim(state, input) + and { + result.state.hubs == state.hubs, + result.state.nextNonce >= state.nextNonce, + isError(result.out) implies result.state == state, + })) + + /// F12. The Byzantine relation contains the honest transition. + run byzantineContainsHonestTest = + assert(tuples(STATES, INPUTS).forall(((state, input)) => + byzShimResults(state, input, PAYLOADS, HEIGHTS).contains(shim(state, input)))) + + run byzantineShimTest = all { + // It can hand a migration to the operator. + assert(byzShimResults(dispatching, send(Clean(pOrchard), 2), PAYLOADS, HEIGHTS) + .contains(dispatching.toForwardOutput(pOrchard))), + // It can tell the wallet ok and send nothing. + assert(byzShimResults(awaiting, send(Clean(pOrchard), 1), PAYLOADS, HEIGHTS) + .contains(awaiting.toSendDoneOutput(Clean(pOrchard), SentOk))), + // It can answer a lookup with a transaction that has another txid. + assert(byzShimResults(dispatching, getTx("orchard", 0), PAYLOADS, HEIGHTS) + .contains(dispatching.toLookupDoneOutput("orchard", Tx({ payload: pPlain, height: 4 })))), + // It can resolve an awaited submission against the hub's answer. + assert(byzShimResults(pendingSend, ack(0, WRefused(WTipStale)), PAYLOADS, HEIGHTS) + .contains(awaiting.with("nextNonce", 1).toSendDoneOutput(Clean(pOrchard), SentOk))), + // It can send a hub something other than what the wallet sent. + assert(byzShimResults(dispatching, send(Clean(pOrchard), 2), PAYLOADS, HEIGHTS).exists(result => + result.out == DivertedOutput({ + frames: Set({ hub: "h2", msg: Submit({ nonce: 0, payload: pJunk }) }), + told: Some(SentOk), + }))), + } +} From 84e06c396ce1be029b83cbf30019b54fdfa39f63 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 03:59:17 +0400 Subject: [PATCH 24/80] test(zeronym): add the protocol state machine, its properties and configurations --- zeronym/spec/protocol/hub.qnt | 4 +- zeronym/spec/protocol/instances.qnt | 281 +++++ zeronym/spec/protocol/properties.qnt | 563 +++++++++ zeronym/spec/protocol/protocol.qnt | 1039 +++++++++++++++++ zeronym/spec/protocol/shim.qnt | 27 +- zeronym/spec/protocol/state.qnt | 357 ++++++ zeronym/spec/protocol/tests/hubTest.qnt | 4 +- zeronym/spec/protocol/tests/scenariosTest.qnt | 45 + zeronym/spec/protocol/tests/shimTest.qnt | 10 +- 9 files changed, 2313 insertions(+), 17 deletions(-) create mode 100644 zeronym/spec/protocol/instances.qnt create mode 100644 zeronym/spec/protocol/properties.qnt create mode 100644 zeronym/spec/protocol/protocol.qnt create mode 100644 zeronym/spec/protocol/state.qnt create mode 100644 zeronym/spec/protocol/tests/scenariosTest.qnt diff --git a/zeronym/spec/protocol/hub.qnt b/zeronym/spec/protocol/hub.qnt index af507e3d..6d531919 100644 --- a/zeronym/spec/protocol/hub.qnt +++ b/zeronym/spec/protocol/hub.qnt @@ -23,7 +23,7 @@ module hub { /// /// `deliveryLag` and `minWalletExpiry` are not read by any transition. They /// are the wallet-side budget the schedule is validated against at startup - /// (`budgetFits`), and the properties read them from here. + /// (`scheduleFitsBudget`), and the properties read them from here. type HubParams = { flushInterval: int, // blocks between scheduled flushes miningMargin: int, // blocks a published transaction needs to be mined @@ -37,7 +37,7 @@ module hub { /// The startup check: a transaction that takes `deliveryLag` blocks to /// arrive, waits a full interval and needs `miningMargin` blocks to be mined /// still fits inside the smallest supported expiry. - pure def budgetFits(params: HubParams): bool = + pure def scheduleFitsBudget(params: HubParams): bool = params.flushInterval + params.miningMargin + params.deliveryLag <= params.minWalletExpiry /// Where the process is in its life. `Starting` has not seen a tip yet; diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt new file mode 100644 index 00000000..bb20ceff --- /dev/null +++ b/zeronym/spec/protocol/instances.qnt @@ -0,0 +1,281 @@ +// -*- mode: Bluespec; -*- + +/// The configurations the protocol is checked in. +/// +/// `configs` names each configuration as a value. Below it there is one module +/// per configuration, which is what `quint run --main=` simulates. A +/// module that scripts runs against a configuration instantiates `protocol` +/// with the same value. + +module configs { + import basicSpells.* from "./spells/basicSpells" + import types.* from "./types" + + // ------------------------------------------------------------------------ + // Transactions + // ------------------------------------------------------------------------ + // + // The schedule below flushes every 3 blocks with a mining margin of 1 and a + // delivery lag of 1, and supports wallets that set an expiry 6 blocks out. + + pure def orchard(id: str, created: Height, expiry: Height): Payload = + { id: id, txid: Some(id), created: created, expiry: Some(expiry), class: OrchardTouching, oversize: false } + + /// Two migrations from supported wallets, built at heights 2 and 4. + pure val early = orchard("early", 2, 8) + pure val late = orchard("late", 4, 10) + /// A migration whose wallet set its expiry tighter than the supported floor. + pure val tight = orchard("tight", 2, 5) + /// Bytes the shim cannot parse and neither can a hub: no txid, no expiry. + pure val junk: Payload = + { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } + /// A transaction that is not a migration. + pure val plain: Payload = + { id: "plain", txid: Some("plain"), created: 1, expiry: Some(9), class: PassThrough, oversize: false } + /// Other bytes with the txid of `early`. + pure val earlyTwin = { ...early, id: "early-twin" } + /// The third party's own: one payload a hub will queue, one too large to. + pure val garbage: Payload = + { id: "garbage", txid: Some("garbage"), created: 1, expiry: None, class: OrchardTouching, oversize: false } + pure val bloat = { ...garbage, id: "bloat", txid: Some("bloat"), oversize: true } + + // ------------------------------------------------------------------------ + // Configurations + // ------------------------------------------------------------------------ + + pure val allHonest: Roles = { shim: Honest, hubs: Map("h1" -> Honest), indexer: Honest } + + /// One hub, the mixnet transport, every component honest, a timely tip. + /// + /// The schedule is the shipped one scaled down (shipped: interval 20, + /// margin 4, lag 6, reorg allowance 10, staleness window 12 blocks, expiry + /// floor 40), keeping the relations between the numbers that matter: + /// + /// - the slack `floor - (interval + margin + lag)` equals the reorg + /// allowance (10 there, 1 here); + /// - the staleness window is larger than the margin and than the slack; + /// - `interval + margin + lag + (window - 1)` exceeds the floor by one + /// (41 > 40 there, 7 > 6 here), while `interval + lag + (window - 1)` does + /// not (37 <= 40, 6 <= 6). + pure val baseline: Config = { + payloads: Set(early, late, tight, junk, plain), + twins: Set(earlyTwin), + tpPayloads: Set(garbage, bloat), + hubs: ["h1"], + flushInterval: 3, + miningMargin: 1, + deliveryLag: 1, + reorgAllowance: 1, + staleWindow: 3, + freeRun: NotSlower, + minWalletExpiry: 6, + maxAttempts: 2, + queueCap: 2, + maxHeight: 12, + maxRequests: 3, + submitMode: DispatchOnly, + roles: allHonest, + tip: TipTimely, + } + + // One Byzantine component at a time. + pure val byzShim = { ...baseline, roles: { ...allHonest, shim: Byzantine } } + pure val byzHub = { ...baseline, roles: { ...allHonest, hubs: Map("h1" -> Byzantine) } } + pure val byzIndexer = { ...baseline, roles: { ...allHonest, indexer: Byzantine } } + + // The HTTP transport: the wallet's answer is the hub's decision. + pure val awaitAck = { ...baseline, submitMode: AwaitVerdict } + pure val awaitAckByzShim = { ...byzShim, submitMode: AwaitVerdict } + pure val awaitAckByzHub = { ...byzHub, submitMode: AwaitVerdict } + pure val awaitAckByzIndexer = { ...byzIndexer, submitMode: AwaitVerdict } + + // Two hubs sharing one chain. + pure val replicated = { + ...baseline, + hubs: ["h1", "h2"], + roles: { ...allHonest, hubs: Map("h1" -> Honest, "h2" -> Honest) }, + } + pure val replicatedOneByz = { + ...replicated, + roles: { ...allHonest, hubs: Map("h1" -> Honest, "h2" -> Byzantine) }, + } + + // A tip that may be reported up to the reorg allowance behind the chain: + // with the slack that covers it, and without. + pure val flakyTip = { ...baseline, tip: TipMayRegress } + pure val flakyTipNoSlack = { + ...flakyTip, + minWalletExpiry: 5, + payloads: Set(orchard("early", 2, 7), late, tight, junk, plain), + twins: Set({ ...orchard("early", 2, 7), id: "early-twin" }), + } + + // A hub that may go without a tip for a while: on the shipped relation + // between the staleness window and the expiry floor, and on the relation + // that would cover the silence. + pure val staleLag = { ...baseline, tip: TipMayLag } + pure val staleLagWithSlack = { + ...staleLag, + minWalletExpiry: 7, + payloads: Set(orchard("early", 2, 9), orchard("late", 4, 11), tight, junk, plain), + twins: Set({ ...orchard("early", 2, 9), id: "early-twin" }), + } +} + +module baseline { + import types.* from "./types" + import configs.* + import protocol(CONFIG = baseline).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module byzShim { + import types.* from "./types" + import configs.* + import protocol(CONFIG = byzShim).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module byzHub { + import types.* from "./types" + import configs.* + import protocol(CONFIG = byzHub).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module byzIndexer { + import types.* from "./types" + import configs.* + import protocol(CONFIG = byzIndexer).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module awaitAck { + import types.* from "./types" + import configs.* + import protocol(CONFIG = awaitAck).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module awaitAckByzShim { + import types.* from "./types" + import configs.* + import protocol(CONFIG = awaitAckByzShim).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module awaitAckByzHub { + import types.* from "./types" + import configs.* + import protocol(CONFIG = awaitAckByzHub).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module awaitAckByzIndexer { + import types.* from "./types" + import configs.* + import protocol(CONFIG = awaitAckByzIndexer).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module replicated { + import types.* from "./types" + import configs.* + import protocol(CONFIG = replicated).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module replicatedOneByz { + import types.* from "./types" + import configs.* + import protocol(CONFIG = replicatedOneByz).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +module flakyTip { + import types.* from "./types" + import configs.* + import protocol(CONFIG = flakyTip).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + } +} + +// The reorg slack is dropped on purpose: this configuration shows what it buys. +module flakyTipNoSlack { + import types.* from "./types" + import configs.* + import protocol(CONFIG = flakyTipNoSlack).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(not(reorgSlackFits)), + } +} + +// The stale slack does not hold here, as it does not for the shipped constants. +module staleLag { + import types.* from "./types" + import configs.* + import protocol(CONFIG = staleLag).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + assert(not(staleSlackFits)), + } +} + +module staleLagWithSlack { + import types.* from "./types" + import configs.* + import protocol(CONFIG = staleLagWithSlack).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + assert(staleSlackFits), + } +} diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt new file mode 100644 index 00000000..f1973829 --- /dev/null +++ b/zeronym/spec/protocol/properties.qnt @@ -0,0 +1,563 @@ +// -*- mode: Bluespec; -*- + +/// What a wallet can rely on, what it cannot, and what must be reachable. +/// +/// Every definition is a pure predicate over the system and the audit record. +/// There are three classes, kept apart: +/// +/// - guarantees: invariants claimed under stated trust and timing assumptions; +/// - known gaps: invariants a wallet might hope for that do not hold even when +/// every component is honest, each with the reason; +/// - witnesses: states that must be reachable, so that the guarantees are not +/// vacuous and the behaviours the gaps describe are shown to happen. +/// +/// Two rules keep the guarantees honest. A predicate reads the system and the +/// audit record, and the audit record is derived from consecutive states by +/// `advance` below, never from what a component says about itself. And a +/// guarantee is something a change to an honest component can break. +module properties { + import basicSpells.* from "./spells/basicSpells" + import soup.* from "./spells/soup" + import types.* from "./types" + import wire.* from "./wire" + import indexer.* from "./indexer" + import hub.* from "./hub" + import shim.* from "./shim" + import state.* from "./state" + + // ------------------------------------------------------------------------ + // The audit record + // ------------------------------------------------------------------------ + + /// The true answer to a lookup for `query` at `hub`, read off the hub's + /// queue and the chain. The queue comes first, as it does in the hub. + /// + /// `NotFound` is the true answer for a transaction that is with a flush and + /// not yet on the chain. That window is part of the lookup contract. The + /// implementation says so (`zeronym/hub/src/server.rs`, on `Hub::lookup`): + /// + /// > Note the flush-in-flight gap: `flush()` drains the queue before + /// > `broadcast_batch` has reached the indexer, so a lookup in that window + /// > gets a queue miss then an indexer NOT_FOUND for a transaction it was + /// > told height-0 about seconds earlier. Wallets poll on multi-second + /// > intervals and tolerate a transient NOT_FOUND; a resubmit is harmless + /// > (deduped pre-flush, already-known post-flush). Holding entries until + /// > broadcast returns would extend how long the hub remembers a txid, which + /// > is the wrong trade. + /// + /// It is also the true answer for a queued payload that does not parse. + pure def truth(s: System, hub: HubId, query: TxId): LookupObs = + if (s.queuedAt(hub).exists(payload => payload.txid == Some(query))) + Pending + else if (s.indexer.txs.keys().contains(query)) + val tx = s.indexer.txs.get(query) + Tx({ payload: tx.payload, height: answerHeight(tx.at) }) + else + NotFound + + pure def initialAudit(s: System): Audit = { + everQueued: s.hubIds().mapBy(_ => Set()), + admitted: Map(), + offers: Set(), + windows: Map(), + refusals: Set(), + dropped: Set(), + } + + /// The refusal a refused ack stands for. The wire has one code for a full + /// hub and a draining one; the hub's phase tells them apart. + pure def refusalBehind(code: WireRefusal, phase: Phase): Refusal = + match code { + | WTipStale => TipStale + | WTooLarge => TooLarge + | WExpiryTooTight => ExpiryTooTight + | WQueueFull => if (phase == Draining) HubDraining else Full + } + + /// The audit record after one step, from the states before and after it. + pure def advance(audit: Audit, pre: System, post: System): Audit = + val hubs = post.hubIds() + val entered = hubs.map(hub => + post.queuedAt(hub).map(payload => (hub, payload))).flatten() + // A flush that was idle and is now broadcasting has just put its batch in + // flight. + val offered = hubs.map(hub => + match post.hubs.get(hub).flush { + | Broadcasting(flush) => + if (pre.hubs.get(hub).flush == Idle) + flush.batch.keys().map(payload => { + hub: hub, + payload: payload, + height: post.height(), + attempt: flush.batch.get(payload), + nth: audit.offers.filter(offer => offer.hub == hub and offer.payload == payload).size(), + }) + else Set() + | Idle => Set() + }).flatten() + // A flush that has ended, other than a final one, gave up on every entry + // nothing judged that is not back in the queue. + val dropped = hubs.map(hub => + match pre.hubs.get(hub).flush { + | Broadcasting(flush) => + if (post.hubs.get(hub).flush == Idle and not(flush.final) and post.hubs.get(hub).phase != Down) + flush.unplaced.keys().exclude(post.queuedAt(hub)).map(payload => + { hub: hub, payload: payload, attempts: flush.unplaced.get(payload) }) + else Set() + | Idle => Set() + }).flatten() + // The lookups the shim is waiting on, or was until this step, each widen + // their window by what is true at the hub they went to. + val waiting = pre.shim.waiters.keys().union(post.shim.waiters.keys()) + val windows = post.lookups(ShimAddr) + .filter(lookup => waiting.contains(lookup._1)) + .fold(audit.windows, (acc, lookup) => + val seen = if (acc.keys().contains(lookup._1)) acc.get(lookup._1) else Set() + acc.put(lookup._1, seen.union(Set(truth(post, lookup._2, lookup._3))))) + val refused = post.net.exclude(pre.net).fold(Set(), (acc, mail) => + match mail.msg { + | Ack(ack) => + match ack.ack { + | WRefused(code) => + match mail.src { + | HubAddr(hub) => acc.union(Set(refusalBehind(code, pre.hubs.get(hub).phase))) + | _ => acc + } + | WAccepted => acc + } + | _ => acc + }) + { + everQueued: hubs.mapBy(hub => audit.everQueued.get(hub).union(post.queuedAt(hub))), + admitted: entered.fold(audit.admitted, (acc, entry) => + if (acc.keys().contains(entry)) acc + else acc.put(entry, { height: post.height(), tip: post.hubs.get(entry._1).observedTip() })), + offers: audit.offers.union(offered), + windows: windows, + refusals: audit.refusals.union(refused), + dropped: audit.dropped.union(dropped), + } + + // ------------------------------------------------------------------------ + // Guarantees + // ------------------------------------------------------------------------ + + /// G1. The operator sees no migration: everything the shim hands it is a + /// pass-through transaction. + pure def operatorBlind(s: System): bool = + s.operator.forall(payload => payload.class == PassThrough) + + /// G2. A transaction's bytes do not reach a third party before the chain has + /// published them. Everything the third party has learned is on the chain, + /// or was a pass-through transaction the operator was given. + pure def queuedBytesConfidential(s: System): bool = + s.tpLearned().forall(payload => + or { + s.indexer.published().contains(payload), + s.operator.contains(payload) and payload.class == PassThrough, + }) + + /// G3. A transaction served to the wallet has the txid the wallet asked for. + /// That is all: it need not be the bytes the wallet sent (a twin passes), + /// and its height is whatever the hub said. + pure def txidAuthenticity(s: System): bool = + s.events().forall(event => + match event { + | Got(got) => + match got.obs { + | Tx(tx) => tx.payload.txid == Some(got.query) + | _ => true + } + | _ => true + }) + + /// G4. Every lookup answer other than `Unavailable` was true, at the hub + /// that gave it, at some point between the request and the answer. + /// + /// It is a statement about one request and one hub. It does not say that + /// successive answers agree, nor that hubs agree with each other; see + /// `statusNeverRegresses`. + pure def lookupValidityPerHub(s: System, audit: Audit): bool = + s.events().forall(event => + match event { + | Got(got) => + or { + got.obs == Unavailable, + match got.via { + | Some(nonce) => + audit.windows.keys().contains(nonce) and audit.windows.get(nonce).contains(got.obs) + | None => false + }, + } + | _ => true + }) + + /// G5. A wallet told its transaction was diverted can rely on some hub + /// having queued it. Claimed when the wallet's answer is the hub's + /// (`AwaitVerdict`); see `wToldNeverDelivered` for when it is not. + pure def toldImpliesQueued(s: System, audit: Audit): bool = + s.toldOk().forall(payload => + s.hubIds().exists(hub => audit.everQueued.get(hub).contains(payload))) + + /// Whether an offer left the mining margin: the transaction can still be + /// mined `miningMargin` blocks after the true height it was published at. + pure def offeredInTime(s: System, offer: Offer): bool = + match offer.payload.expiry { + | Some(expiry) => expiry >= offer.height + s.hubs.get(offer.hub).params.miningMargin + | None => true + } + + /// Whether a payload reached `offer.hub` as a supported wallet's would: it + /// honours the expiry floor, and it first entered the queue within the + /// delivery lag of the height it was built at. + pure def isConformingAndTimely(s: System, audit: Audit, offer: Offer): bool = + val params = s.hubs.get(offer.hub).params + and { + conforming(offer.payload, params.minWalletExpiry), + audit.admitted.keys().contains((offer.hub, offer.payload)), + audit.admitted.get((offer.hub, offer.payload)).height <= offer.payload.created + params.deliveryLag, + } + + /// G6a. Every transaction one of `hubs` publishes is published with the + /// mining margin to spare: whatever was admitted, on every attempt. + pure def offeredBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = + audit.offers.forall(offer => hubs.contains(offer.hub) implies offeredInTime(s, offer)) + + /// G6b. The same, for supported wallets only and for the first time a hub + /// publishes the transaction. It says nothing about a later offer of an + /// entry that was requeued; see `conformingEveryOfferBeforeExpiry`. + pure def conformingFirstOfferBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = + audit.offers.forall(offer => + and { + hubs.contains(offer.hub), + offer.nth == 0, + isConformingAndTimely(s, audit, offer), + } implies offeredInTime(s, offer)) + + /// G7. Structural sanity: a queued entry is within its attempts; a hub that + /// is down holds nothing and knows nothing; every nonce in use was minted. + pure def wellFormed(s: System): bool = + and { + s.hubIds().forall(hub => + val state = s.hubs.get(hub) + and { + state.queued().forall(payload => + state.queue.get(payload) >= 0 and state.queue.get(payload) <= state.params.maxAttempts), + state.phase == Down implies state == downHub(state.params), + }), + s.shim.waiters.keys().forall(nonce => nonce < s.shim.nextNonce), + s.net.forall(mail => + match mail.msg { + | Submit(submit) => + submit.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) + | Lookup(lookup) => + lookup.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) + | _ => true + }), + } + + /// G8. An accepted ack from one of `hubs` is for a payload that hub had + /// queued by the time it acked. It holds whether or not anyone waits for + /// the ack. + pure def ackImpliesQueued(s: System, audit: Audit, hubs: Set[HubId]): bool = + hubs.forall(hub => s.ackedAt(hub).subseteq(audit.everQueued.get(hub))) + + // ------------------------------------------------------------------------ + // Known gaps + // ------------------------------------------------------------------------ + + /// K2. What a wallet sees of one transaction never goes backwards: once + /// served, it is not later pending or missing; once pending, it is not later + /// missing. This does not hold. Replies are reordered; a published + /// transaction can be queued again; a flush empties the queue before the + /// chain has the batch; the node can reject at flush; and two hubs need not + /// agree. + pure def statusNeverRegresses(s: System): bool = + val log = s.wallet.log + tuples(log.indices(), log.indices()).forall(((i, j)) => + i < j implies + match log[i] { + | Got(earlier) => + match log[j] { + | Got(later) => + earlier.query != later.query or + match earlier.obs { + | Tx(_) => + match later.obs { + | Tx(_) => true + | Unavailable => true + | _ => false + } + | Pending => later.obs != NotFound + | _ => true + } + | _ => true + } + | _ => true + }) + + /// K5. A payload a hub has acknowledged is still held by it, or has been + /// offered to the chain. This does not hold: the queue lives in memory, and + /// a crash after the ack loses it. + pure def ackedIsHeldOrOffered(s: System, audit: Audit): bool = + s.hubIds().forall(hub => + s.ackedAt(hub).forall(payload => + or { + s.queuedAt(hub).contains(payload), + s.inFlightAt(hub).contains(payload), + audit.offers.exists(offer => offer.hub == hub and offer.payload == payload), + })) + + /// K6. G6b without its restriction to the first offer. This does not hold + /// once a hub is stale: requeue judges an entry at the observed tip, which + /// has stopped, while the flush schedule runs on. + pure def conformingEveryOfferBeforeExpiry(s: System, audit: Audit): bool = + audit.offers.forall(offer => isConformingAndTimely(s, audit, offer) implies offeredInTime(s, offer)) + + // K1. Under `DispatchOnly`, "told ok" promises nothing about any hub. It is + // stated as three reachable states, not as a violated invariant, because the + // invariant is false on the ordinary success path too: the wallet is told + // before any hub has the frame. + + /// The shim's submissions of `payload`, as (hub, nonce). + pure def shimSubmissionsOf(s: System, payload: Payload): Set[(HubId, Nonce)] = + s.hubIds().map(hub => + s.submissions(ShimAddr, hub).filter(submit => submit._2 == payload).map(submit => (hub, submit._1)) + ).flatten() + + pure def neverQueued(s: System, audit: Audit, payload: Payload): bool = + s.hubIds().forall(hub => not(audit.everQueued.get(hub).contains(payload))) + + /// K1a. The wallet was told ok; every submission that reached a hub was + /// refused; no hub ever queued the payload. + pure def wToldRefusedEverywhere(s: System, audit: Audit): bool = + s.toldOk().exists(payload => + val submitted = s.shimSubmissionsOf(payload) + and { + neverQueued(s, audit, payload), + submitted != Set(), + submitted.forall(submit => + s.acks(submit._1, ShimAddr).exists(ack => ack._1 == submit._2 and ack._2 != WAccepted)), + }) + + /// K1b. The wallet was told ok; no hub has answered any of the frames; no + /// hub ever queued the payload. The network may leave it so forever. + pure def wToldNeverDelivered(s: System, audit: Audit): bool = + s.toldOk().exists(payload => + and { + neverQueued(s, audit, payload), + s.shimSubmissionsOf(payload).forall(submit => + not(s.acks(submit._1, ShimAddr).exists(ack => ack._1 == submit._2))), + }) + + /// K1c. The wallet was told ok though the sweep stopped early: some hub was + /// never sent the payload. + pure def wToldPrefixOnly(s: System): bool = + s.toldOk().exists(payload => + val reached = s.shimSubmissionsOf(payload).map(submit => submit._1) + reached != Set() and reached != s.hubIds()) + + // ------------------------------------------------------------------------ + // Witnesses + // ------------------------------------------------------------------------ + + pure def wasGiven(s: System, isIt: LookupObs => bool): bool = + s.events().exists(event => + match event { + | Got(got) => isIt(got.obs) + | _ => false + }) + + /// W1. The wallet is told its transaction is pending. + pure def wPending(s: System): bool = + s.wasGiven(obs => obs == Pending) + + /// W2. The wallet is served its transaction from the mempool. + pure def wTxInMempool(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => tx.height == MEMPOOL_HEIGHT + | _ => false + }) + + /// W3. The wallet is served its transaction from a block. + pure def wTxMined(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => tx.height != MEMPOOL_HEIGHT + | _ => false + }) + + /// W4. A hub has refused a submission for `refusal`. + pure def wRefused(audit: Audit, refusal: Refusal): bool = + audit.refusals.contains(refusal) + + /// W5. An entry nothing judged is back in a queue. + pure def wRequeued(s: System): bool = + s.hubIds().exists(hub => + s.queuedAt(hub).exists(payload => s.hubs.get(hub).queue.get(payload) > 0)) + + /// W6. A requeue dropped an entry that could no longer survive the next + /// flush: it was given up on while it still had attempts left. + pure def wDroppedExpired(s: System, audit: Audit): bool = + audit.dropped.exists(entry => + entry.attempts + 1 <= s.hubs.get(entry.hub).params.maxAttempts) + + /// W7. A requeue dropped an entry that was out of attempts: one with no + /// expiry, which nothing else would ever have stopped. + pure def wDroppedExhausted(audit: Audit): bool = + audit.dropped.exists(entry => entry.payload.expiry == None) + + /// W8. The accepted disclosure: a third party that knows a txid learns that + /// it is queued at a hub. The hub withholds the bytes; it does not withhold + /// the fact. The implementation leaves this open on purpose + /// (`zeronym/hub/src/server.rs`, in `Hub::lookup`): + /// + /// > What this does NOT close: the 200-versus-NotFound distinction still + /// > discloses that a given txid is queued here. Closing that too means + /// > answering NotFound, which costs a wallet the ability to tell "pending" + /// > from "never seen". That is a product decision, not a code one, and it + /// > is left open deliberately. + pure def wQueuedDisclosed(s: System): bool = + tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => + and { + lookup._1 == reply._1, + lookup._2 == reply._2, + reply._3 == WFound({ body: None, height: MEMPOOL_HEIGHT }), + }) + + /// W9. A hub holds a payload it cannot parse, the wallet that sent it asks + /// that hub about a transaction the hub has no trace of, and is told not + /// found: an entry without a txid can never be hit. + pure def wUnparseableMissed(s: System): bool = + tuples(s.events(), s.lookups(ShimAddr)).exists(((event, lookup)) => + match event { + | Got(got) => + and { + got.obs == NotFound, + got.via == Some(lookup._1), + s.queuedAt(lookup._2).exists(payload => payload.txid == None and s.toldOk().contains(payload)), + } + | _ => false + }) + + /// W12. A queue holds more than its capacity: requeue honours the older + /// promise over the newer limit. + pure def wQueueOverCapacity(s: System): bool = + s.hubIds().exists(hub => s.queuedAt(hub).size() > s.hubs.get(hub).params.queueCap) + + /// W13. A lookup has moved on from a hub that did not answer, and the next + /// hub has answered it. + pure def wFailoverAnswered(s: System): bool = + s.shim.waiters.keys().exists(nonce => + match s.shim.waiters.get(nonce) { + | LookupWaiter(waiter) => + waiter.attempt > 0 and s.replies(ShimAddr).exists(reply => reply._1 == nonce and reply._3 != WError) + | _ => false + }) + + /// W14. Two hubs have each published the same payload, in their own flushes, + /// and it is on the chain: replication, not failover. + pure def wPublishedByTwoHubs(s: System, audit: Audit): bool = + s.indexer.published().exists(payload => + and { + audit.offers.filter(offer => offer.payload == payload).map(offer => offer.hub).size() > 1, + s.hubIds().forall(hub => + not(s.queuedAt(hub).contains(payload)) and not(s.inFlightAt(hub).contains(payload))), + }) + + /// A hub is publishing a scheduled batch for an epoch the chain has not + /// reached: its cadence clock is ahead of the true height. The batch is + /// smaller than the schedule intended. + pure def flushesAheadOfChain(s: System, hub: HubId): bool = + val state = s.hubs.get(hub) + match state.flush { + | Broadcasting(flush) => + not(flush.final) and state.cadenceEpoch() > s.height() / state.params.flushInterval + | Idle => false + } + + /// W15. Premature flush: a hub that follows its tip flushes ahead of the + /// chain, because the tip it was given is ahead of the chain. + pure def wPrematureFlush(s: System): bool = + s.hubIds().exists(hub => s.hubs.get(hub).cadence == Tracking and flushesAheadOfChain(s, hub)) + + /// W18. Early flush: a stale hub flushes ahead of the chain, because its + /// free-running clock is ahead of the chain. + pure def wEarlyFreeRunFlush(s: System): bool = + s.hubIds().exists(hub => s.hubs.get(hub).cadence != Tracking and flushesAheadOfChain(s, hub)) + + /// W16a. The wallet is served a twin of what it sent: other bytes, same txid. + pure def wTwinServed(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => s.seenByShim().exists(sent => s.toldOk().contains(sent) and areTwins(sent, tx.payload)) + | _ => false + }) + + /// W16b. The wallet is served a transaction at a height that cannot be + /// true: the chain does not have it, or has not got that far. + pure def wFalseHeightServed(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => + tx.height > s.height() or not(s.indexer.published().exists(payload => payload.txid == tx.payload.txid)) + | _ => false + }) + + /// W17. A hub has queued a payload of the third party's own making. + pure def wThirdPartyPayloadQueued(s: System): bool = + s.hubIds().exists(hub => s.queuedAt(hub).intersect(s.thirdParty.own) != Set()) + + // ------------------------------------------------------------------------ + // Non-vacuity: the antecedent of each guarantee is reachable + // ------------------------------------------------------------------------ + + pure def vOperatorBlind(s: System): bool = + s.operator != Set() + + pure def vQueuedBytesConfidential(s: System): bool = + s.tpLearned() != Set() + + pure def vTxidAuthenticity(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(_) => true + | _ => false + }) + + pure def vLookupValidityPerHub(s: System): bool = + and { + s.wPending(), + s.vTxidAuthenticity(), + s.wasGiven(obs => obs == NotFound), + } + + pure def vToldImpliesQueued(s: System): bool = + s.toldOk() != Set() + + pure def vOfferedBeforeExpiry(audit: Audit): bool = + audit.offers.exists(offer => isSome(offer.payload.expiry)) + + pure def vConformingFirstOfferBeforeExpiry(s: System, audit: Audit): bool = + audit.offers.exists(offer => + and { + isSome(offer.payload.expiry), + offer.nth == 0, + isConformingAndTimely(s, audit, offer), + }) + + /// The same, for a payload admitted while the hub's tip was behind the chain. + pure def vConformingOfferAdmittedBehind(s: System, audit: Audit): bool = + audit.offers.exists(offer => + and { + isSome(offer.payload.expiry), + offer.nth == 0, + isConformingAndTimely(s, audit, offer), + val entry = audit.admitted.get((offer.hub, offer.payload)) + entry.tip < entry.height, + }) + + pure def vAckImpliesQueued(s: System): bool = + s.hubIds().exists(hub => s.ackedAt(hub) != Set()) +} diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt new file mode 100644 index 00000000..75968e1e --- /dev/null +++ b/zeronym/spec/protocol/protocol.qnt @@ -0,0 +1,1039 @@ +// -*- mode: Bluespec; -*- + +/// The zeronym protocol as a state machine: a wallet, a shim, one or more +/// hubs, the chain behind the hubs' indexer, an unreliable network between +/// them, and a third party that can reach the hubs. +/// +/// What is assumed: +/// +/// - Roles. The shim and the hubs run in enclaves and are honest in the +/// baseline. The shim, each hub and the hubs' indexer can each be made +/// Byzantine through `ROLES`; a Byzantine component draws its transitions +/// from a wider relation and is not marked in any other way. +/// - Network. Frames may be lost, duplicated, delayed and reordered. They +/// cannot be forged or read in transit. +/// - Third party. A client of the hubs' public address. It looks up txids it +/// knows and submits payloads it has learned or made. It cannot read or +/// forge frames, so it does not know a nonce and cannot answer the shim. +/// - Nonces are unique. A counter stands for an unguessable random value. +/// - Chain. A transaction's status only moves forward: no reorg of an +/// included transaction, no mempool eviction. How a hub's view of the tip +/// relates to the true height is the constant `TIP`. +/// - Wallets. A supported wallet sets an expiry at least `MIN_WALLET_EXPIRY` +/// blocks after the height it builds at, and its frame reaches a hub within +/// `DELIVERY_LAG` blocks. +/// - Time. There is no clock. A timeout is an event that may happen at any +/// moment, and the staleness window is counted in blocks. +/// +/// How it is built. This module holds no protocol logic. Every step picks an +/// input, hands it to one component function (`shim`, `hub`, or the indexer +/// relation) and puts the output where it goes. Each step exists in two +/// forms: `xWith(..)`, which takes every choice as a parameter and is what a +/// scripted run is made of, and `x`, which picks the choices and is what +/// `step` is made of. All state is written by `commit`, so no action has a +/// frame condition. +module protocol { + import basicSpells.* from "./spells/basicSpells" + import soup.* from "./spells/soup" + import types.* from "./types" + import wire.* from "./wire" + import indexer.* from "./indexer" + import hub.* from "./hub" + import shim.* from "./shim" + import state.* from "./state" + import properties as P from "./properties" + + // ------------------------------------------------------------------------ + // Constants + // ------------------------------------------------------------------------ + + /// The configuration. It is one record so that a configuration is a value + /// that can be named, derived from another and shared between the module + /// that simulates it and the modules that script runs against it. The names + /// below are its fields, and are what the rest of the module uses. + const CONFIG: Config + + /// The transactions a wallet may send. + pure val PAYLOADS = CONFIG.payloads + /// Twins of wallet transactions: other bytes with the same txid. No honest + /// party sends one. + pure val TWINS = CONFIG.twins + /// Payloads of the third party's own making. + pure val TP_PAYLOADS = CONFIG.tpPayloads + /// The hub addresses, in the order the shim is configured with. + pure val HUB_ORDER = CONFIG.hubs + pure val HUBS = HUB_ORDER.indices().map(i => HUB_ORDER[i]) + + /// Blocks between scheduled flushes. + pure val FLUSH_INTERVAL = CONFIG.flushInterval + /// Blocks a published transaction needs to be mined. + pure val MINING_MARGIN = CONFIG.miningMargin + /// Blocks a submission may take to reach a hub. + pure val DELIVERY_LAG = CONFIG.deliveryLag + /// How far behind the chain a tip report may be and still be followed. + pure val REORG_ALLOWANCE = CONFIG.reorgAllowance + /// Blocks without a forward tip observation after which a hub is stale. + pure val STALE_WINDOW = CONFIG.staleWindow + /// How a stale hub's free-running clock relates to the true height. + pure val FREE_RUN = CONFIG.freeRun + /// The smallest expiry delta a supported wallet sets. + pure val MIN_WALLET_EXPIRY = CONFIG.minWalletExpiry + /// Requeues an entry is allowed. + pure val MAX_ATTEMPTS = CONFIG.maxAttempts + /// Entries a hub's admission will hold. + pure val QUEUE_CAP = CONFIG.queueCap + + /// Bounds of the model: the chain stops growing at `MAX_HEIGHT`, and the + /// wallet makes at most `MAX_REQUESTS` sends and as many lookups, as does + /// the third party. + pure val MAX_HEIGHT = CONFIG.maxHeight + pure val MAX_REQUESTS = CONFIG.maxRequests + + pure val SUBMIT_MODE = CONFIG.submitMode + pure val ROLES = CONFIG.roles + pure val TIP = CONFIG.tip + + /// Every payload that exists. A Byzantine component builds its lies from it. + pure val UNIVERSE = PAYLOADS.union(TWINS).union(TP_PAYLOADS) + /// Every height a Byzantine component may claim. + pure val HEIGHTS = 0.to(MAX_HEIGHT) + /// What a wallet may send. + pure val SEND_INPUTS = PAYLOADS.map(payload => Clean(payload)).union(Set(Unreadable, EmptyBody)) + /// Every transaction id there is. + pure val TXIDS = txidsOf(UNIVERSE) + /// The height the chain starts at. Height 0 is kept for "in the mempool". + pure val GENESIS_HEIGHT = 1 + + pure val HUB_PARAMS: HubParams = { + flushInterval: FLUSH_INTERVAL, + miningMargin: MINING_MARGIN, + deliveryLag: DELIVERY_LAG, + minWalletExpiry: MIN_WALLET_EXPIRY, + reorgAllowance: REORG_ALLOWANCE, + maxAttempts: MAX_ATTEMPTS, + queueCap: QUEUE_CAP, + } + + pure val HONEST_HUBS = HUBS.filter(hub => ROLES.hubs.get(hub) == Honest) + + // ------------------------------------------------------------------------ + // Assumptions + // ------------------------------------------------------------------------ + // + // Each is a named value, so that an instance's `assumptionsTest` can assert + // it: the simulator does not enforce `assume`. + + /// The hub's startup check: delivery, one full interval and the mining + /// margin fit inside the smallest supported expiry. + pure val budgetFits = scheduleFitsBudget(HUB_PARAMS) + + /// The same budget with the reorg allowance added. A hub that follows a tip + /// report up to the allowance behind the chain can flush that much late. + /// Nothing in the implementation checks this; its shipped constants meet it + /// with equality. + pure val reorgSlackFits = + FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + REORG_ALLOWANCE <= MIN_WALLET_EXPIRY + + /// The same budget with the longest silence that does not yet make a hub + /// stale. It is named, not assumed: the shipped constants do not meet it. + pure val staleSlackFits = + FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + (STALE_WINDOW - 1) <= MIN_WALLET_EXPIRY + + /// A stale hub's free-running cadence clock is never behind the true height. + /// The implementation relies on this ("during a real stall blocks arrive + /// slower than this, so the free-running clock runs ahead of the true + /// height") and does not enforce it. + pure val freeRunNotSlowerThanChain = FREE_RUN == NotSlower + + pure val flushIntervalPositive = FLUSH_INTERVAL > 0 + + pure val hubsNonEmpty = HUBS != Set() and HUBS.size() == HUB_ORDER.length() + + /// Payloads are told apart by their bytes; a twin is a twin of something a + /// wallet sends; the third party's payloads are its own; every hub has a + /// role. + pure val payloadsWellFormed = and { + UNIVERSE.size() == UNIVERSE.map(payload => payload.id).size(), + PAYLOADS.intersect(TP_PAYLOADS) == Set(), + TWINS.forall(twin => PAYLOADS.exists(payload => areTwins(twin, payload))), + UNIVERSE.forall(payload => payload.created >= GENESIS_HEIGHT), + ROLES.hubs.keys() == HUBS, + } + + /// Only the HTTP transport returns the hub's decision to the wallet, and it + /// has one hub address. + pure val awaitVerdictSingleHub = SUBMIT_MODE == AwaitVerdict implies HUBS.size() == 1 + + /// The assumptions every configuration is expected to meet. The two slack + /// relations are kept apart: some configurations exist to drop one. + pure val standingAssumptions = and { + budgetFits, + freeRunNotSlowerThanChain, + flushIntervalPositive, + hubsNonEmpty, + payloadsWellFormed, + awaitVerdictSingleHub, + } + + assume _ = budgetFits + assume _ = reorgSlackFits + assume _ = freeRunNotSlowerThanChain + assume _ = flushIntervalPositive + assume _ = hubsNonEmpty + assume _ = payloadsWellFormed + assume _ = awaitVerdictSingleHub + + // ------------------------------------------------------------------------ + // State + // ------------------------------------------------------------------------ + + /// The protocol state. + var s: System + /// The observer's record of the run. No step reads it. + var audit: Audit + /// The step that produced the current state. + var lastAction: Label + + /// The only writer of the variables. + action commit(post: System, label: Label): bool = all { + s' = post, + audit' = P::advance(audit, s, post), + lastAction' = label, + } + + pure val INITIAL = initialSystem(CONFIG, HUB_PARAMS, GENESIS_HEIGHT) + + action init = all { + s' = INITIAL, + audit' = P::initialAudit(INITIAL), + lastAction' = Init, + } + + // ------------------------------------------------------------------------ + // Roles + // ------------------------------------------------------------------------ + + /// The transitions the shim may take on `input`: the one the protocol + /// prescribes, or, for a Byzantine shim, any of the wider set. + pure def shimResults(state: ShimState, input: ShimInput): Set[ShimResult] = + match ROLES.shim { + | Honest => Set(shim(state, input)) + | Byzantine => byzShimResults(state, input, UNIVERSE, HEIGHTS) + } + + pure def hubResults(id: HubId, state: HubState, input: HubInput): Set[HubResult] = + match ROLES.hubs.get(id) { + | Honest => Set(hub(state, input)) + | Byzantine => byzHubResults(state, input, UNIVERSE, HEIGHTS) + } + + pure def indexerResults(state: IndexerState, input: IndexerInput): Set[IndexerResult] = + match ROLES.indexer { + | Honest => honestIndexerResults(state, input) + | Byzantine => byzIndexerResults(state, input, UNIVERSE, HEIGHTS) + } + + /// What the indexer may answer a hub's lookup for the transaction `msg` + /// asks about. A frame that is not a lookup needs no answer. + pure def lookupAnswers(state: IndexerState, msg: Msg): Set[IndexerAnswer] = + match msg { + | Lookup(lookup) => + indexerResults(state, LookupIInput(lookup.txid)).fold(Set(), (acc, result) => + match result.out { + | AnswerOutput(answer) => acc.union(Set(answer)) + | _ => acc + }) + | _ => Set(INotFound) + } + + pure def isShimError(output: ShimOutput): bool = + match output { + | ShimErrorOutput(_) => true + | _ => false + } + + pure def isHubError(output: HubOutput): bool = + match output { + | HubErrorOutput(_) => true + | _ => false + } + + // ------------------------------------------------------------------------ + // Tip models + // ------------------------------------------------------------------------ + + /// The tips the indexer may report to a hub now. + def reportableTips: Set[Height] = + match ROLES.indexer { + | Byzantine => byzTips(MAX_HEIGHT) + | Honest => + match TIP { + | TipTimely => honestTips(s.indexer, 0) + | TipMayRegress => honestTips(s.indexer, REORG_ALLOWANCE) + | TipMayLag => honestTips(s.indexer, 0) + } + } + + /// How far behind the chain `hub`'s observed tip is. + def lagOf(id: HubId): int = + s.height() - s.hubs.get(id).observedTip() + + /// The heights a stale hub's free-running clock may read now: up to an + /// interval ahead of the chain, and not behind it unless `FREE_RUN` allows. + def freeRunEstimates(id: HubId): Set[Height] = + match FREE_RUN { + | NotSlower => s.height().to(s.height() + FLUSH_INTERVAL) + | MayBeSlower => s.hubs.get(id).observedTip().to(s.height() + FLUSH_INTERVAL) + } + + /// Whether the next block may arrive. This is where the timing assumptions + /// live: a block is held back until every hub has done what the tip model + /// says it does within a block. + /// + /// In every model, a flush that is due has begun. + /// + /// - `TipTimely`: every running hub has observed the current block. With an + /// honest indexer its lag is therefore zero at every block. + /// - `TipMayRegress`: no running hub is further behind than the allowance. + /// - `TipMayLag`: a hub whose silence has reached the staleness window has + /// become stale, and a stale hub's free-running clock has caught up with + /// the current block. + def chainMayAdvance: bool = and { + s.height() < MAX_HEIGHT, + HUBS.forall(id => not(s.hubs.get(id).isFlushDue())), + HUBS.forall(id => + val state = s.hubs.get(id) + match TIP { + | TipTimely => + ROLES.indexer == Byzantine or (state.phase == Running implies lagOf(id) == 0) + | TipMayRegress => + state.phase == Running implies lagOf(id) <= REORG_ALLOWANCE + | TipMayLag => + and { + state.phase == Running implies lagOf(id) < STALE_WINDOW, + (state.phase == Stale and FREE_RUN == NotSlower) implies state.cadenceHeight() >= s.height(), + } + }), + } + + // ------------------------------------------------------------------------ + // Wallet + // ------------------------------------------------------------------------ + + /// The wallet sends a transaction. The shim routes it and, unless it is + /// waiting for a hub's decision, answers. + action walletSendWith(input: SendInput, handedOver: int, result: ShimResult): bool = all { + s.wallet.sends < MAX_REQUESTS, + SEND_INPUTS.contains(input), + // A wallet cannot send a transaction before it has built it. + match input { + | Clean(payload) => payload.created <= s.height() + | _ => true + }, + shimResults(s.shim, SendTxSInput({ input: input, handedOver: handedOver })).contains(result), + not(isShimError(result.out)), + commit(s.countSend().shimStepped(result, None), WalletSend({ input: input, handedOver: handedOver })), + } + + action walletSend = { + nondet input = oneOf(SEND_INPUTS) + nondet handedOver = oneOf(0.to(HUB_ORDER.length())) + nondet result = oneOf(shimResults(s.shim, SendTxSInput({ input: input, handedOver: handedOver }))) + walletSendWith(input, handedOver, result) + } + + /// The txids of the transactions the wallet has handed the shim. A wallet + /// asks only about its own transactions. + def walletTxids: Set[TxId] = + txidsOf(s.sentByWallet()) + + /// The wallet asks for a transaction it has sent. The shim asks the hub its + /// cursor points at. + action walletGetWith(query: TxId, start: int, result: ShimResult): bool = all { + s.wallet.gets < MAX_REQUESTS, + walletTxids.contains(query), + shimResults(s.shim, GetTxSInput({ query: query, start: start })).contains(result), + not(isShimError(result.out)), + commit(s.countGet().shimStepped(result, None), WalletGet({ query: query, start: start })), + } + + action walletGet = all { + walletTxids != Set(), + { + nondet query = oneOf(walletTxids) + nondet start = oneOf(HUB_ORDER.indices()) + nondet result = oneOf(shimResults(s.shim, GetTxSInput({ query: query, start: start }))) + walletGetWith(query, start, result) + }, + } + + // ------------------------------------------------------------------------ + // Shim + // ------------------------------------------------------------------------ + + /// The frames whose delivery to the shim would do something: replies under + /// a nonce it is waiting on. Any other frame is dropped without effect. + def shimDeliverable: Set[Mail] = + s.net.inbox(ShimAddr).filter(mail => + match nonceOf(FrameSInput(mail.msg)) { + | Some(nonce) => s.shim.hasWaiter(nonce) + | None => false + }) + + /// The network delivers a frame to the shim. + action shimReceiveWith(mail: Mail, result: ShimResult): bool = all { + shimDeliverable.contains(mail), + shimResults(s.shim, FrameSInput(mail.msg)).contains(result), + not(isShimError(result.out)), + commit(s.shimStepped(result, nonceOf(FrameSInput(mail.msg))), ShimReceive(mail)), + } + + action shimReceive = all { + shimDeliverable != Set(), + { + nondet mail = oneOf(shimDeliverable) + nondet result = oneOf(shimResults(s.shim, FrameSInput(mail.msg))) + shimReceiveWith(mail, result) + }, + } + + /// The shim gives up waiting for a lookup reply. + action shimLookupTimeoutWith(nonce: Nonce, result: ShimResult): bool = all { + s.shim.hasWaiter(nonce), + shimResults(s.shim, LookupTimeoutSInput(nonce)).contains(result), + not(isShimError(result.out)), + commit(s.shimStepped(result, None), ShimLookupTimeout(nonce)), + } + + action shimLookupTimeout = all { + s.shim.waiters.keys() != Set(), + { + nondet nonce = oneOf(s.shim.waiters.keys()) + nondet result = oneOf(shimResults(s.shim, LookupTimeoutSInput(nonce))) + shimLookupTimeoutWith(nonce, result) + }, + } + + /// The shim gives up waiting for an ack. + action shimAckTimeoutWith(nonce: Nonce, result: ShimResult): bool = all { + s.shim.hasWaiter(nonce), + shimResults(s.shim, AckTimeoutSInput(nonce)).contains(result), + not(isShimError(result.out)), + commit(s.shimStepped(result, None), ShimAckTimeout(nonce)), + } + + action shimAckTimeout = all { + s.shim.waiters.keys() != Set(), + { + nondet nonce = oneOf(s.shim.waiters.keys()) + nondet result = oneOf(shimResults(s.shim, AckTimeoutSInput(nonce))) + shimAckTimeoutWith(nonce, result) + }, + } + + // ------------------------------------------------------------------------ + // Hub + // ------------------------------------------------------------------------ + + /// The request frames that may be delivered to `id`: all of them, as often + /// as the network likes, while the hub is serving. + def hubDeliverable(id: HubId): Set[Mail] = + if (s.hubs.get(id).isServing()) + s.net.inbox(HubAddr(id)).filter(mail => requestInput(mail.msg, INotFound) != Set()) + else Set() + + /// The transitions `id` may take when `mail` is delivered and its indexer + /// would answer a lookup with `answer`. + def hubReceipts(id: HubId, mail: Mail, answer: IndexerAnswer): Set[HubResult] = + requestInput(mail.msg, answer).map(input => hubResults(id, s.hubs.get(id), input)).flatten() + + /// The network delivers a request to a hub, which answers its sender. For a + /// lookup, `answer` is what the hub's indexer says if the queue misses. + action hubReceiveWith(id: HubId, mail: Mail, answer: IndexerAnswer, result: HubResult): bool = all { + HUBS.contains(id), + hubDeliverable(id).contains(mail), + lookupAnswers(s.indexer, mail.msg).contains(answer), + hubReceipts(id, mail, answer).contains(result), + not(isHubError(result.out)), + commit(s.hubReplied(id, mail.src, result), HubReceive({ hub: id, mail: mail })), + } + + action hubReceive = { + nondet id = oneOf(HUBS) + all { + hubDeliverable(id) != Set(), + { + nondet mail = oneOf(hubDeliverable(id)) + nondet answer = oneOf(lookupAnswers(s.indexer, mail.msg)) + nondet result = oneOf(hubReceipts(id, mail, answer)) + hubReceiveWith(id, mail, answer, result) + }, + } + } + + /// A step of the hub's own schedule, taken in the system `around` (the + /// current one, or the current one after its indexer moved). A Byzantine hub + /// keeps the honest schedule, so these take the hub function's transition + /// whatever the role. A step that would change nothing is not taken. + action hubTakesIn(around: System, id: HubId, input: HubInput, label: Label): bool = + val result = hub(s.hubs.get(id), input) + all { + HUBS.contains(id), + not(isHubError(result.out)), + result.state != s.hubs.get(id), + commit({ ...around, hubs: around.hubs.set(id, result.state) }, label), + } + + action hubTakes(id: HubId, input: HubInput, label: Label): bool = + hubTakesIn(s, id, input, label) + + /// A hub's cadence loop observes the tip its indexer reports. + action hubObserveTipWith(id: HubId, tip: Height): bool = all { + reportableTips.contains(tip), + hubTakes(id, TipHInput(tip), HubObserveTip({ hub: id, tip: tip })), + } + + action hubObserveTip = { + nondet id = oneOf(HUBS) + nondet tip = oneOf(reportableTips) + hubObserveTipWith(id, tip) + } + + /// A hub that has seen no tip progress for the staleness window goes stale, + /// or, already stale, reads its free-running clock again. + action hubTipStaleWith(id: HubId, estimate: Height): bool = all { + TIP == TipMayLag, + HUBS.contains(id), + s.hubs.get(id).phase == Stale or lagOf(id) >= STALE_WINDOW, + freeRunEstimates(id).contains(estimate), + hubTakes(id, StaleHInput(estimate), HubTipStale({ hub: id, estimate: estimate })), + } + + action hubTipStale = { + nondet id = oneOf(HUBS) + nondet estimate = oneOf(freeRunEstimates(id)) + hubTipStaleWith(id, estimate) + } + + /// A flush begins: the hub's whole queue goes out at once. + action hubFlushBeginWith(id: HubId): bool = + hubTakes(id, FlushDueHInput, HubFlushBegin(id)) + + action hubFlushBegin = { + nondet id = oneOf(HUBS) + hubFlushBeginWith(id) + } + + /// The verdict an indexer output carries. Read only where it carries one. + pure def verdictIn(output: IndexerOutput): Verdict = + match output { + | VerdictOutput(verdict) => verdict + | _ => Retryable + } + + /// The indexer returns its verdict on one entry of a batch. `result` is the + /// indexer's transition: the verdict, and what became of the transaction. + action indexerVerdictWith(id: HubId, payload: Payload, result: IndexerResult): bool = all { + indexerResults(s.indexer, BroadcastIInput(payload)).contains(result), + result.out == VerdictOutput(verdictIn(result.out)), + hubTakesIn( + { ...s, indexer: result.state }, + id, + VerdictHInput({ payload: payload, verdict: verdictIn(result.out) }), + IndexerVerdict({ hub: id, payload: payload }), + ), + } + + /// The entries of `id`'s batch still waiting for a verdict. + def awaitingVerdict(id: HubId): Set[Payload] = + match s.hubs.get(id).flush { + | Broadcasting(flush) => flush.batch.keys() + | Idle => Set() + } + + action indexerVerdict = { + nondet id = oneOf(HUBS) + all { + awaitingVerdict(id) != Set(), + { + nondet payload = oneOf(awaitingVerdict(id)) + nondet result = oneOf(indexerResults(s.indexer, BroadcastIInput(payload))) + indexerVerdictWith(id, payload, result) + }, + } + } + + /// A flush ends: what nothing judged is requeued or dropped. + action hubFlushEndWith(id: HubId): bool = + hubTakes(id, FlushDoneHInput, HubFlushEnd(id)) + + action hubFlushEnd = { + nondet id = oneOf(HUBS) + hubFlushEndWith(id) + } + + /// A hub gets its shutdown signal. + action hubBeginDrainWith(id: HubId): bool = + hubTakes(id, DrainHInput, HubBeginDrain(id)) + + action hubBeginDrain = { + nondet id = oneOf(HUBS) + hubBeginDrainWith(id) + } + + /// A hub's process dies, or exits after its final flush. + action hubCrashWith(id: HubId): bool = + hubTakes(id, CrashHInput, HubCrash(id)) + + action hubCrash = { + nondet id = oneOf(HUBS) + hubCrashWith(id) + } + + action hubRestartWith(id: HubId): bool = + hubTakes(id, RestartHInput, HubRestart(id)) + + action hubRestart = { + nondet id = oneOf(HUBS) + hubRestartWith(id) + } + + // ------------------------------------------------------------------------ + // Chain + // ------------------------------------------------------------------------ + + /// The chain grows by one block. + action chainAdvance = all { + chainMayAdvance, + commit({ ...s, indexer: indexerApply(s.indexer, AdvanceIInput, NoIndexerOutput) }, ChainAdvance), + } + + /// The transactions waiting in the mempool. + def mempool: Set[TxId] = + s.indexer.txs.keys().filter(txid => s.onChain(txid) == InMempool) + + /// A mempool transaction is included in the current block. + action chainMineWith(txid: TxId): bool = all { + mempool.contains(txid), + commit({ ...s, indexer: indexerApply(s.indexer, MineIInput(txid), NoIndexerOutput) }, ChainMine(txid)), + } + + action chainMine = all { + mempool != Set(), + { + nondet txid = oneOf(mempool) + chainMineWith(txid) + }, + } + + // ------------------------------------------------------------------------ + // Third party + // ------------------------------------------------------------------------ + + /// The txids of wallet transactions the third party has yet to learn. + def unknownTxids: Set[TxId] = + txidsOf(PAYLOADS).exclude(s.tpTxids()) + + /// The third party learns a txid out of band, before the transaction is + /// published. An operator can recover one from a wallet's transparent-pool + /// queries. + action thirdPartyLearnsTxidWith(txid: TxId): bool = all { + unknownTxids.contains(txid), + commit( + { ...s, thirdParty: { ...s.thirdParty, txids: s.thirdParty.txids.union(Set(txid)) } }, + ThirdPartyLearnsTxid(txid), + ), + } + + action thirdPartyLearnsTxid = all { + unknownTxids != Set(), + { + nondet txid = oneOf(unknownTxids) + thirdPartyLearnsTxidWith(txid) + }, + } + + /// The third party asks a hub about a txid it knows. The lookup is not + /// authenticated. + action thirdPartyLookupWith(txid: TxId, id: HubId): bool = all { + s.thirdParty.requests < MAX_REQUESTS, + HUBS.contains(id), + s.tpTxids().contains(txid), + commit( + s.thirdPartySent(id, Lookup({ nonce: s.thirdParty.nextNonce, txid: txid })), + ThirdPartyLookup({ txid: txid, hub: id }), + ), + } + + action thirdPartyLookup = all { + s.tpTxids() != Set(), + { + nondet txid = oneOf(s.tpTxids()) + nondet id = oneOf(HUBS) + thirdPartyLookupWith(txid, id) + }, + } + + /// The third party submits a payload it has: one it made, or one it has + /// learned. Submission is not authenticated either. + action thirdPartySubmitWith(payload: Payload, id: HubId): bool = all { + s.thirdParty.requests < MAX_REQUESTS, + HUBS.contains(id), + s.tpPayloads().contains(payload), + commit( + s.thirdPartySent(id, Submit({ nonce: s.thirdParty.nextNonce, payload: payload })), + ThirdPartySubmit({ payload: payload, hub: id }), + ), + } + + action thirdPartySubmit = all { + s.tpPayloads() != Set(), + { + nondet payload = oneOf(s.tpPayloads()) + nondet id = oneOf(HUBS) + thirdPartySubmitWith(payload, id) + }, + } + + /// What a Byzantine component could still reveal. + def undisclosed: Set[Payload] = + s.disclosable(ROLES).exclude(s.disclosed) + + /// A Byzantine component reveals a payload it has seen. + action byzDiscloseWith(payload: Payload): bool = all { + undisclosed.contains(payload), + commit({ ...s, disclosed: s.disclosed.union(Set(payload)) }, ByzDisclose(payload)), + } + + action byzDisclose = all { + undisclosed != Set(), + { + nondet payload = oneOf(undisclosed) + byzDiscloseWith(payload) + }, + } + + // ------------------------------------------------------------------------ + // Step + // ------------------------------------------------------------------------ + + /// A fault: a timeout, a shutdown, a crash, a restart. + action faultStep = any { + shimLookupTimeout, shimAckTimeout, hubBeginDrain, hubCrash, hubRestart, + } + + /// A step by someone outside the protocol: the third party, or a Byzantine + /// component revealing what it has seen. + action outsiderStep = any { + thirdPartyLearnsTxid, thirdPartyLookup, thirdPartySubmit, byzDisclose, + } + + /// One step of the system. + /// + /// Faults and outsiders are grouped so that the simulator, which chooses + /// uniformly among the alternatives it is given, takes one of each about as + /// often as it takes any single step of the protocol. + action step = any { + walletSend, walletGet, + shimReceive, + hubReceive, hubObserveTip, hubTipStale, hubFlushBegin, indexerVerdict, hubFlushEnd, + chainAdvance, chainMine, + faultStep, + outsiderStep, + } + + /// The indexer cannot be reached: an entry of a batch gets no verdict. + action indexerUnreachable = { + nondet id = oneOf(HUBS) + all { + awaitingVerdict(id) != Set(), + { + nondet payload = oneOf(awaitingVerdict(id)) + indexerVerdictWith(id, payload, { + state: indexerApply(s.indexer, BroadcastIInput(payload), VerdictOutput(Retryable)), + out: VerdictOutput(Retryable), + }) + }, + } + } + + // The relations below are parts of `step`. Everything one of them reaches, + // `step` reaches, so they are sound for showing that a state is reachable + // and for nothing else. They keep a simulation on one part of the behaviour + // long enough to get deep into it. + + /// The protocol with no faults and no outsiders. + action quietStep = any { + walletSend, walletGet, + shimReceive, + hubReceive, hubObserveTip, hubTipStale, hubFlushBegin, indexerVerdict, hubFlushEnd, + chainAdvance, chainMine, + } + + /// The same, during an indexer outage: every flush comes back unjudged. + action outageStep = any { + walletSend, walletGet, + shimReceive, + hubReceive, hubObserveTip, hubTipStale, hubFlushBegin, indexerUnreachable, hubFlushEnd, + chainAdvance, + } + + // ------------------------------------------------------------------------ + // Guarantees + // ------------------------------------------------------------------------ + // + // The predicates are defined, and documented, in `properties.qnt`. These + // are their values in the current state, under the names the gate checks. + + val operatorBlind = P::operatorBlind(s) + val queuedBytesConfidential = P::queuedBytesConfidential(s) + val txidAuthenticity = P::txidAuthenticity(s) + val lookupValidityPerHub = P::lookupValidityPerHub(s, audit) + val toldImpliesQueued = P::toldImpliesQueued(s, audit) + val offeredBeforeExpiry = P::offeredBeforeExpiry(s, audit, HUBS) + val conformingFirstOfferBeforeExpiry = P::conformingFirstOfferBeforeExpiry(s, audit, HUBS) + val wellFormed = P::wellFormed(s) + val ackImpliesQueued = P::ackImpliesQueued(s, audit, HUBS) + + // The per-hub guarantees, claimed of the honest hubs only. Each is the same + // predicate as its namesake above, restricted to the hubs whose role is + // `Honest`; it is not a weaker property. With every hub honest the two + // coincide. + val offeredBeforeExpiryForHonestHubs = P::offeredBeforeExpiry(s, audit, HONEST_HUBS) + val conformingFirstOfferBeforeExpiryForHonestHubs = P::conformingFirstOfferBeforeExpiry(s, audit, HONEST_HUBS) + val ackImpliesQueuedForHonestHubs = P::ackImpliesQueued(s, audit, HONEST_HUBS) + + // ------------------------------------------------------------------------ + // Known gaps: invariants that do not hold + // ------------------------------------------------------------------------ + + val statusNeverRegresses = P::statusNeverRegresses(s) + val ackedIsHeldOrOffered = P::ackedIsHeldOrOffered(s, audit) + val conformingEveryOfferBeforeExpiry = P::conformingEveryOfferBeforeExpiry(s, audit) + + // ------------------------------------------------------------------------ + // Witnesses + // ------------------------------------------------------------------------ + + val wToldRefusedEverywhere = P::wToldRefusedEverywhere(s, audit) + val wToldNeverDelivered = P::wToldNeverDelivered(s, audit) + val wToldPrefixOnly = P::wToldPrefixOnly(s) + + val wPending = P::wPending(s) + val wTxInMempool = P::wTxInMempool(s) + val wTxMined = P::wTxMined(s) + val wRefusedTipStale = P::wRefused(audit, TipStale) + val wRefusedDraining = P::wRefused(audit, HubDraining) + val wRefusedTooLarge = P::wRefused(audit, TooLarge) + val wRefusedExpiryTooTight = P::wRefused(audit, ExpiryTooTight) + val wRefusedFull = P::wRefused(audit, Full) + val wRequeued = P::wRequeued(s) + val wDroppedExpired = P::wDroppedExpired(s, audit) + val wDroppedExhausted = P::wDroppedExhausted(audit) + val wQueuedDisclosed = P::wQueuedDisclosed(s) + val wUnparseableMissed = P::wUnparseableMissed(s) + val wQueueOverCapacity = P::wQueueOverCapacity(s) + val wFailoverAnswered = P::wFailoverAnswered(s) + val wPublishedByTwoHubs = P::wPublishedByTwoHubs(s, audit) + val wPrematureFlush = P::wPrematureFlush(s) + val wTwinServed = P::wTwinServed(s) + val wFalseHeightServed = P::wFalseHeightServed(s) + val wThirdPartyPayloadQueued = P::wThirdPartyPayloadQueued(s) + val wEarlyFreeRunFlush = P::wEarlyFreeRunFlush(s) + + // Non-vacuity: the antecedent of each guarantee is reachable. + val vOperatorBlind = P::vOperatorBlind(s) + val vQueuedBytesConfidential = P::vQueuedBytesConfidential(s) + val vTxidAuthenticity = P::vTxidAuthenticity(s) + val vLookupValidityPerHub = P::vLookupValidityPerHub(s) + val vToldImpliesQueued = P::vToldImpliesQueued(s) + val vOfferedBeforeExpiry = P::vOfferedBeforeExpiry(audit) + val vConformingFirstOfferBeforeExpiry = P::vConformingFirstOfferBeforeExpiry(s, audit) + val vConformingOfferAdmittedBehind = P::vConformingOfferAdmittedBehind(s, audit) + val vAckImpliesQueued = P::vAckImpliesQueued(s) + + // ------------------------------------------------------------------------ + // Two-state properties + // ------------------------------------------------------------------------ + // + // These relate a state to its successor, which an invariant cannot. The + // simulator does not check them. `next` has to wrap a complete expression + // that reads the state, which is why they are written out here and not as + // predicates over a pair of systems. + + /// How far along the chain a transaction is. + def chainRank(txid: TxId): int = + match s.onChain(txid) { + | Absent => 0 + | InMempool => 1 + | MinedAt(_) => 2 + } + + def chainStatus(txid: TxId): Inclusion = s.onChain(txid) + def queueOf(id: HubId): Set[Payload] = s.queuedAt(id) + def flightOf(id: HubId): Set[Payload] = s.inFlightAt(id) + def phaseOf(id: HubId): Phase = s.hubs.get(id).phase + + /// A1. A transaction's status on the chain never moves backwards, and a + /// mined transaction stays where it was mined. An assumption about the + /// environment, true by construction of the chain steps. + temporal chainMonotone = always( + TXIDS.forall(txid => + and { + chainRank(txid) <= next(chainRank(txid)), + chainRank(txid) == 2 implies next(chainStatus(txid)) == chainStatus(txid), + } + ).orKeep(s) + ) + + /// A2. An entry leaves a hub's queue only into a flush, or because the hub + /// went down. Nothing evicts it. + temporal neverEvict = always( + HUBS.forall(id => + or { + queueOf(id).subseteq(next(queueOf(id)).union(next(flightOf(id)))), + next(phaseOf(id)) == Down, + } + ).orKeep(s) + ) + + /// A3. A draining hub admits nothing: its queue gains only what a flush + /// hands back. + temporal drainIsFinal = always( + HUBS.forall(id => + phaseOf(id) == Draining implies next(queueOf(id)).subseteq(queueOf(id).union(flightOf(id))) + ).orKeep(s) + ) + + // ------------------------------------------------------------------------ + // Run vocabulary + // ------------------------------------------------------------------------ + // + // Shorthands for scripted runs. Each is a `...With` step with the choices an + // honest component and a truthful, reachable indexer would make, or a short + // sequence of such steps. A run that needs another choice (a Byzantine + // transition, an indexer that is down) uses the `...With` step itself. + + /// A submission of `payload` from the shim to `id` under `nonce`. + pure def submitMail(id: HubId, nonce: Nonce, payload: Payload): Mail = + { src: ShimAddr, dst: HubAddr(id), msg: Submit({ nonce: nonce, payload: payload }) } + + /// A lookup of `txid` from the shim to `id` under `nonce`. + pure def lookupMail(id: HubId, nonce: Nonce, txid: TxId): Mail = + { src: ShimAddr, dst: HubAddr(id), msg: Lookup({ nonce: nonce, txid: txid }) } + + /// The same frame, sent by the third party instead. + pure def fromThirdParty(mail: Mail): Mail = + { ...mail, src: ThirdPartyAddr } + + /// `id`'s ack to the shim under `nonce`. + pure def ackMail(id: HubId, nonce: Nonce, ack: WireAck): Mail = + { src: HubAddr(id), dst: ShimAddr, msg: Ack({ nonce: nonce, ack: ack }) } + + /// `id`'s lookup reply to the shim under `nonce`. + pure def replyMail(id: HubId, nonce: Nonce, reply: WireReply): Mail = + { src: HubAddr(id), dst: ShimAddr, msg: LookupReply({ nonce: nonce, reply: reply }) } + + /// The wallet sends, and the shim routes the transaction as it should. + action sends(input: SendInput, handedOver: int): bool = + walletSendWith(input, handedOver, shim(s.shim, SendTxSInput({ input: input, handedOver: handedOver }))) + + /// The wallet sends a transaction and every hub address takes a frame. + action sendToAll(payload: Payload): bool = + sends(Clean(payload), HUB_ORDER.length()) + + /// The wallet asks, and the shim asks the hub at `start`. + action ask(query: TxId, start: int): bool = + walletGetWith(query, start, shim(s.shim, GetTxSInput({ query: query, start: start }))) + + /// `client`'s submission of `payload` under `nonce` reaches `id`, which acts + /// as it should. + action deliverSubmitFrom(client: Addr, id: HubId, nonce: Nonce, payload: Payload): bool = + val submit = { nonce: nonce, payload: payload } + hubReceiveWith( + id, + { src: client, dst: HubAddr(id), msg: Submit(submit) }, + INotFound, + hub(s.hubs.get(id), SubmitHInput(submit)), + ) + + /// The shim's submission of `payload` under `nonce` reaches `id`. + action deliverSubmit(id: HubId, nonce: Nonce, payload: Payload): bool = + deliverSubmitFrom(ShimAddr, id, nonce, payload) + + /// `client`'s lookup of `txid` under `nonce` reaches `id`, which acts as it + /// should on the answer `answer` from its indexer. + action deliverLookupFrom(client: Addr, id: HubId, nonce: Nonce, txid: TxId, answer: IndexerAnswer): bool = + hubReceiveWith( + id, + { src: client, dst: HubAddr(id), msg: Lookup({ nonce: nonce, txid: txid }) }, + answer, + hub(s.hubs.get(id), LookupHInput({ nonce: nonce, txid: txid, answer: answer })), + ) + + /// The shim's lookup of `txid` under `nonce` reaches `id`, whose indexer is + /// reachable and truthful. + action deliverLookup(id: HubId, nonce: Nonce, txid: TxId): bool = + deliverLookupFrom(ShimAddr, id, nonce, txid, chainAnswer(s.indexer, txid)) + + /// `mail` reaches the shim, which acts as it should. + action deliverToShim(mail: Mail): bool = + shimReceiveWith(mail, shim(s.shim, FrameSInput(mail.msg))) + + /// The reply an honest `id` with a truthful indexer gives, in the current + /// state, to a lookup of `txid`. + def honestReply(id: HubId, txid: TxId): WireReply = + render(if (s.hubs.get(id).isQueuedTxid(txid)) QueueHit else FromIndexer(chainAnswer(s.indexer, txid))) + + /// The shim's wait under `nonce` times out. + action timeOutLookup(nonce: Nonce): bool = + shimLookupTimeoutWith(nonce, shim(s.shim, LookupTimeoutSInput(nonce))) + + action timeOutAck(nonce: Nonce): bool = + shimAckTimeoutWith(nonce, shim(s.shim, AckTimeoutSInput(nonce))) + + /// An honest indexer gives `verdict` on `payload`, with the effect that + /// verdict has. + action judge(id: HubId, payload: Payload, verdict: Verdict): bool = + indexerVerdictWith(id, payload, { + state: indexerApply(s.indexer, BroadcastIInput(payload), VerdictOutput(verdict)), + out: VerdictOutput(verdict), + }) + + /// `id` observes the true height. + action observe(id: HubId): bool = + hubObserveTipWith(id, s.height()) + + /// Every hub observes the true height. + run allObserve = HUB_ORDER.length().reps(i => observe(HUB_ORDER[i])) + + /// The system with every hub running at the genesis height. + run started = init.then(allObserve) + + /// One block arrives and every hub sees it. + run block = chainAdvance.then(allObserve) + + /// `count` blocks arrive, each seen by every hub. No flush may fall due. + run blocks(count: int): bool = count.reps(_ => block) + + /// The wallet sends `payload`, and the frame under `nonce` reaches `id`. + run submitTo(id: HubId, nonce: Nonce, payload: Payload): bool = + sendToAll(payload).then(deliverSubmit(id, nonce, payload)) + + /// The wallet asks for `query`; the lookup under `nonce` reaches `id`; the + /// reply reaches the shim. The hub's state does not change in between. + run lookUp(id: HubId, nonce: Nonce, query: TxId): bool = + ask(query, 0) + .then(deliverLookup(id, nonce, query)) + .then(deliverToShim(replyMail(id, nonce, honestReply(id, query)))) + + /// `id` flushes `batch`, and the indexer gives every entry `verdict`. + run flush(id: HubId, batch: List[Payload], verdict: Verdict): bool = + hubFlushBeginWith(id) + .then(batch.length().reps(i => judge(id, batch[i], verdict))) + .then(hubFlushEndWith(id)) + + /// The wallet's most recent answer. + def lastEvent: WalletEvent = + s.wallet.log[s.wallet.log.length() - 1] +} diff --git a/zeronym/spec/protocol/shim.qnt b/zeronym/spec/protocol/shim.qnt index 231f6737..ea1c5ebb 100644 --- a/zeronym/spec/protocol/shim.qnt +++ b/zeronym/spec/protocol/shim.qnt @@ -63,7 +63,8 @@ module shim { type ShimOutput = | ForwardOutput(Payload) // to the operator's indexer - | DivertedOutput({ frames: Set[Frame], told: Option[SendObs] }) + // `payload` is what the wallet sent; `told` is its answer, if it has one yet. + | DivertedOutput({ payload: Payload, frames: Set[Frame], told: Option[SendObs] }) | SendDoneOutput({ input: SendInput, obs: SendObs }) | LookupSentOutput({ frame: Frame }) | LookupDoneOutput({ query: TxId, result: LookupObs }) @@ -75,8 +76,8 @@ module shim { pure def toForwardOutput(state: ShimState, payload: Payload): ShimResult = { state: state, out: ForwardOutput(payload) } - pure def toDivertedOutput(state: ShimState, frames: Set[Frame], told: Option[SendObs]): ShimResult = - { state: state, out: DivertedOutput({ frames: frames, told: told }) } + pure def toDivertedOutput(state: ShimState, payload: Payload, frames: Set[Frame], told: Option[SendObs]): ShimResult = + { state: state, out: DivertedOutput({ payload: payload, frames: frames, told: told }) } pure def toSendDoneOutput(state: ShimState, input: SendInput, obs: SendObs): ShimResult = { state: state, out: SendDoneOutput({ input: input, obs: obs }) } @@ -137,7 +138,7 @@ module shim { val waiting = targets.indices().fold(state.waiters, (waiters, i) => waiters.put(state.nextNonce + i, AckWaiter({ payload: payload, awaited: awaited }))) { ...state, waiters: waiting, nextNonce: state.nextNonce + targets.length() } - .toDivertedOutput(frames, if (awaited) None else Some(SentOk)) + .toDivertedOutput(payload, frames, if (awaited) None else Some(SentOk)) /// Route a `SendTransaction`. Nothing but a cleanly read pass-through /// transaction ever reaches the operator; everything else is diverted or @@ -302,16 +303,18 @@ module shim { val lies = lookupObservations(universe, heights) match input { | SendTxSInput(send) => - val forwarded = match send.input { - | Clean(payload) => Set(state.toForwardOutput(payload)) + val unsent = SEND_OBSERVATIONS.map(obs => state.toSendDoneOutput(send.input, obs)) + val misrouted = match send.input { + | Clean(sent) => + tuples(universe, state.hubs.indices(), SEND_OBSERVATIONS).map(((payload, i, obs)) => + { ...state, nextNonce: state.nextNonce + 1 }.toDivertedOutput( + sent, + Set({ hub: state.hubs[i], msg: Submit({ nonce: state.nextNonce, payload: payload }) }), + Some(obs))) + .union(Set(state.toForwardOutput(sent))) | _ => Set() } - val unsent = SEND_OBSERVATIONS.map(obs => state.toSendDoneOutput(send.input, obs)) - val stray = tuples(universe, state.hubs.indices(), SEND_OBSERVATIONS).map(((payload, i, obs)) => - { ...state, nextNonce: state.nextNonce + 1 }.toDivertedOutput( - Set({ hub: state.hubs[i], msg: Submit({ nonce: state.nextNonce, payload: payload }) }), - Some(obs))) - honest.union(forwarded).union(unsent).union(stray) + honest.union(unsent).union(misrouted) | GetTxSInput(lookup) => honest.union(lies.map(result => state.toLookupDoneOutput(lookup.query, result))) | _ => diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt new file mode 100644 index 00000000..61fa45b1 --- /dev/null +++ b/zeronym/spec/protocol/state.qnt @@ -0,0 +1,357 @@ +// -*- mode: Bluespec; -*- + +/// The whole system as one value: the components' states, the network between +/// them, and what each outside party has seen. +/// +/// Nothing here decides anything. The functions in this module put a +/// component's output where it goes (a frame into the soup, an answer into the +/// wallet's log, a forwarded transaction in front of the operator) and derive +/// the views the properties are stated over. +module state { + import basicSpells.* from "./spells/basicSpells" + import soup.* from "./spells/soup" + import types.* from "./types" + import wire.* from "./wire" + import indexer.* from "./indexer" + import hub.* from "./hub" + import shim.* from "./shim" + + // ------------------------------------------------------------------------ + // The system + // ------------------------------------------------------------------------ + + type Mail = Envelope[Addr, Msg] + type Net = Soup[Addr, Msg] + + /// The wallet: the answers it has been given, in the order it got them, and + /// how many requests of each kind it has made. + type Wallet = { log: List[WalletEvent], sends: int, gets: int } + + /// The third party: a client of the hubs' public address that is not the + /// shim. `txids` are the transaction ids it has learned out of band; `own` + /// are payloads of its own making. + type ThirdParty = { txids: Set[TxId], own: Set[Payload], nextNonce: Nonce, requests: int } + + /// - `operator`: every transaction the shim has handed the operator's + /// indexer. The operator is assumed to publish nothing itself. + /// - `disclosed`: payloads a Byzantine component has revealed. It is written + /// by the disclosure step and by nothing else. + type System = { + indexer: IndexerState, + hubs: HubId -> HubState, + shim: ShimState, + net: Net, + wallet: Wallet, + operator: Set[Payload], + thirdParty: ThirdParty, + disclosed: Set[Payload], + } + + /// The system at rest: the chain at `height`, every hub started and waiting + /// for its first tip, nothing sent. + pure def initialSystem(config: Config, params: HubParams, height: Height): System = { + indexer: initialIndexer(height), + hubs: config.hubs.indices().map(i => config.hubs[i]).mapBy(_ => startingHub(params)), + shim: initialShim(config.hubs, config.submitMode), + net: Set(), + wallet: { log: [], sends: 0, gets: 0 }, + operator: Set(), + thirdParty: { txids: Set(), own: config.tpPayloads, nextNonce: 0, requests: 0 }, + disclosed: Set(), + } + + /// The step that produced a state, with the choices that identify it. It + /// names the input a component was given, never the transition the + /// component then took. + type Label = + | Init + | WalletSend({ input: SendInput, handedOver: int }) + | WalletGet({ query: TxId, start: int }) + | ShimReceive(Mail) + | ShimLookupTimeout(Nonce) + | ShimAckTimeout(Nonce) + | HubReceive({ hub: HubId, mail: Mail }) + | HubObserveTip({ hub: HubId, tip: Height }) + | HubTipStale({ hub: HubId, estimate: Height }) + | HubFlushBegin(HubId) + | IndexerVerdict({ hub: HubId, payload: Payload }) + | HubFlushEnd(HubId) + | HubBeginDrain(HubId) + | HubCrash(HubId) + | HubRestart(HubId) + | ChainAdvance + | ChainMine(TxId) + | ThirdPartyLearnsTxid(TxId) + | ThirdPartyLookup({ txid: TxId, hub: HubId }) + | ThirdPartySubmit({ payload: Payload, hub: HubId }) + | ByzDisclose(Payload) + + /// What an observer of the run has recorded. It is not protocol state: no + /// component reads it, and it is derived at every step from the states + /// before and after, never from what a component reports about itself. + /// + /// - `everQueued`: every payload that has been in a hub's queue. + /// - `admitted`: for a payload's first entry into a hub's queue, the true + /// chain height at that moment and the tip the hub believed in. + /// - `offers`: every time a flush put a payload in flight: the true chain + /// height, the requeues the entry had had (`attempt`), and how many times + /// this hub had offered the payload before (`nth`). + /// - `windows`: for each lookup the shim has sent, the answers that were + /// true at the hub it asked at some point while it waited. + /// - `refusals`: the admission refusals that have been sent. + /// - `dropped`: entries a requeue gave up on, with the requeues they had had. + type Offer = { hub: HubId, payload: Payload, height: Height, attempt: int, nth: int } + type Audit = { + everQueued: HubId -> Set[Payload], + admitted: (HubId, Payload) -> { height: Height, tip: Height }, + offers: Set[Offer], + windows: Nonce -> Set[LookupObs], + refusals: Set[Refusal], + dropped: Set[{ hub: HubId, payload: Payload, attempts: int }], + } + + // ------------------------------------------------------------------------ + // Views + // ------------------------------------------------------------------------ + + pure def hubIds(s: System): Set[HubId] = + s.hubs.keys() + + pure def height(s: System): Height = + s.indexer.height + + /// The payloads in `hub`'s queue. + pure def queuedAt(s: System, hub: HubId): Set[Payload] = + s.hubs.get(hub).queued() + + /// The payloads `hub` has out with a flush. + pure def inFlightAt(s: System, hub: HubId): Set[Payload] = + s.hubs.get(hub).inFlight() + + /// Where `txid` stands on the chain. + pure def onChain(s: System, txid: TxId): Inclusion = + s.indexer.inclusion(txid) + + /// The answers the wallet has been given, without their order. + pure def events(s: System): Set[WalletEvent] = + s.wallet.log.indices().map(i => s.wallet.log[i]) + + /// The payloads the wallet has been told were diverted. + pure def toldOk(s: System): Set[Payload] = + s.events().fold(Set(), (acc, event) => + match event { + | Sent(sent) => + match sent.input { + | Clean(payload) => if (sent.obs == SentOk) acc.union(Set(payload)) else acc + | _ => acc + } + | _ => acc + }) + + /// The submissions `client` has addressed to `hub`, as (nonce, payload). + pure def submissions(s: System, client: Addr, hub: HubId): Set[(Nonce, Payload)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | Submit(submit) => + if (mail.src == client and mail.dst == HubAddr(hub)) + acc.union(Set((submit.nonce, submit.payload))) + else acc + | _ => acc + }) + + /// The acks `hub` has sent `client`, as (nonce, ack). + pure def acks(s: System, hub: HubId, client: Addr): Set[(Nonce, WireAck)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | Ack(ack) => + if (mail.src == HubAddr(hub) and mail.dst == client) acc.union(Set((ack.nonce, ack.ack))) + else acc + | _ => acc + }) + + /// The payloads `hub` has acknowledged as accepted, to any client. + pure def ackedAt(s: System, hub: HubId): Set[Payload] = + Set(ShimAddr, ThirdPartyAddr).map(client => + tuples(s.submissions(client, hub), s.acks(hub, client)) + .filter(((submit, ack)) => submit._1 == ack._1 and ack._2 == WAccepted) + .map(((submit, _)) => submit._2) + ).flatten() + + /// The lookups `client` has addressed to hubs, as (nonce, hub, txid). + pure def lookups(s: System, client: Addr): Set[(Nonce, HubId, TxId)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | Lookup(lookup) => + match mail.dst { + | HubAddr(hub) => + if (mail.src == client) acc.union(Set((lookup.nonce, hub, lookup.txid))) else acc + | _ => acc + } + | _ => acc + }) + + /// The lookup replies addressed to `client`, as (nonce, hub, reply). + pure def replies(s: System, client: Addr): Set[(Nonce, HubId, WireReply)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | LookupReply(reply) => + match mail.src { + | HubAddr(hub) => + if (mail.dst == client) acc.union(Set((reply.nonce, hub, reply.reply))) else acc + | _ => acc + } + | _ => acc + }) + + /// The transaction bodies in lookup replies addressed to `client`. + pure def repliedBodies(s: System, client: Addr): Set[Payload] = + s.replies(client).fold(Set(), (acc, reply) => + match reply._3 { + | WFound(found) => + match found.body { + | Some(payload) => acc.union(Set(payload)) + | None => acc + } + | _ => acc + }) + + // ------------------------------------------------------------------------ + // What each party knows + // ------------------------------------------------------------------------ + + /// The transaction ids the third party knows: those it learned out of band + /// and everything the chain has made public. + pure def tpTxids(s: System): Set[TxId] = + s.thirdParty.txids.union(s.indexer.txs.keys()) + + /// The payloads the third party did not make and yet has: the bodies of + /// replies sent to it, whatever reached the operator, and whatever a + /// Byzantine component disclosed. The chain is left out on purpose: what is + /// published is public. + /// + /// This is derived from what the third party can observe. Nothing updates it + /// when a transaction is published, so a property over it constrains what + /// the hubs put in their replies. + pure def tpLearned(s: System): Set[Payload] = + s.repliedBodies(ThirdPartyAddr).union(s.operator).union(s.disclosed).exclude(s.thirdParty.own) + + /// Every payload the third party has: what it learned, what the chain + /// published, and its own. + pure def tpPayloads(s: System): Set[Payload] = + s.tpLearned().union(s.indexer.published()).union(s.thirdParty.own) + + /// The payloads the wallet has handed the shim: every send that was + /// answered, and every send still waiting for a hub's decision. + pure def sentByWallet(s: System): Set[Payload] = + val answered = s.events().fold(Set(), (acc, event) => + match event { + | Sent(done) => + match done.input { + | Clean(payload) => acc.union(Set(payload)) + | _ => acc + } + | _ => acc + }) + val waiting = s.shim.waiters.keys().fold(Set(), (acc, nonce) => + match s.shim.waiters.get(nonce) { + | AckWaiter(waiter) => acc.union(Set(waiter.payload)) + | _ => acc + }) + answered.union(waiting) + + /// The payloads the shim has seen in the clear: every transaction the wallet + /// handed it, and every body a hub returned to it. + pure def seenByShim(s: System): Set[Payload] = + s.sentByWallet().union(s.repliedBodies(ShimAddr)) + + /// The payloads addressed to `hub` by anyone: what it has received or may + /// yet receive. + pure def seenByHub(s: System, hub: HubId): Set[Payload] = + Set(ShimAddr, ThirdPartyAddr).map(client => + s.submissions(client, hub).map(submit => submit._2)).flatten() + + /// What the Byzantine components, taken together, are able to reveal. + pure def disclosable(s: System, roles: Roles): Set[Payload] = + (if (roles.shim == Byzantine) s.seenByShim() else Set()) + .union(if (roles.indexer == Byzantine) s.indexer.offered else Set()) + .union(s.hubIds().filter(hub => roles.hubs.get(hub) == Byzantine) + .map(hub => s.seenByHub(hub)).flatten()) + + // ------------------------------------------------------------------------ + // Putting outputs where they go + // ------------------------------------------------------------------------ + + pure def logged(s: System, event: WalletEvent): System = + { ...s, wallet: { ...s.wallet, log: s.wallet.log.append(event) } } + + pure def posted(s: System, mails: Set[Mail]): System = + { ...s, net: s.net.sendAll(mails) } + + /// The system after the shim took `result`. `via` is the nonce of the frame + /// that was delivered to it, if the step was a delivery: it is recorded with + /// a lookup answer so the answer can be traced to the hub that gave it. + pure def shimStepped(s: System, result: ShimResult, via: Option[Nonce]): System = + val stepped = { ...s, shim: result.state } + match result.out { + | ForwardOutput(payload) => + { ...stepped, operator: stepped.operator.union(Set(payload)) } + .logged(Sent({ input: Clean(payload), obs: SentToOperator })) + | DivertedOutput(diverted) => + val dispatched = stepped.posted(diverted.frames.map(frame => + { src: ShimAddr, dst: HubAddr(frame.hub), msg: frame.msg })) + match diverted.told { + | Some(obs) => dispatched.logged(Sent({ input: Clean(diverted.payload), obs: obs })) + | None => dispatched + } + | SendDoneOutput(done) => stepped.logged(Sent(done)) + | LookupSentOutput(sent) => + stepped.posted(Set({ src: ShimAddr, dst: HubAddr(sent.frame.hub), msg: sent.frame.msg })) + | LookupDoneOutput(done) => + stepped.logged(Got({ query: done.query, obs: done.result, via: via })) + | NoShimOutput => stepped + | ShimErrorOutput(_) => stepped + } + + /// The system after `hub` took `result` on a frame from `client`. Its ack or + /// lookup reply is put on the wire and sent back; nothing else a hub outputs + /// leaves it. + pure def hubReplied(s: System, hub: HubId, client: Addr, result: HubResult): System = + val stepped = { ...s, hubs: s.hubs.set(hub, result.state) } + match result.out { + | AckOutput(ack) => + stepped.posted(Set({ + src: HubAddr(hub), dst: client, msg: Ack({ nonce: ack.nonce, ack: renderAck(ack.kind) }), + })) + | LookupReplyOutput(reply) => + stepped.posted(Set({ + src: HubAddr(hub), dst: client, + msg: LookupReply({ nonce: reply.nonce, reply: render(reply.outcome) }), + })) + | _ => stepped + } + + /// The hub input a request frame becomes. `answer` is what the hub's indexer + /// would say to a lookup. Reply frames are not requests and give nothing. + pure def requestInput(msg: Msg, answer: IndexerAnswer): Set[HubInput] = + match msg { + | Submit(submit) => Set(SubmitHInput(submit)) + | Lookup(lookup) => Set(LookupHInput({ nonce: lookup.nonce, txid: lookup.txid, answer: answer })) + | _ => Set() + } + + /// The wallet makes one more request of a kind. + pure def countSend(s: System): System = + { ...s, wallet: { ...s.wallet, sends: s.wallet.sends + 1 } } + + pure def countGet(s: System): System = + { ...s, wallet: { ...s.wallet, gets: s.wallet.gets + 1 } } + + /// The third party sends `msg` to `hub`, under a nonce of its own. + pure def thirdPartySent(s: System, hub: HubId, msg: Msg): System = + { ...s, + thirdParty: { ...s.thirdParty, + nextNonce: s.thirdParty.nextNonce + 1, + requests: s.thirdParty.requests + 1, + }, + }.posted(Set({ src: ThirdPartyAddr, dst: HubAddr(hub), msg: msg })) +} diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt index 7dd78fa4..98eda5e0 100644 --- a/zeronym/spec/protocol/tests/hubTest.qnt +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -88,14 +88,14 @@ module hubTest { /// the delivery lag passes the expiry check. The lemma is about admission at /// one tip. It does not say at what height the flush later happens. run conformingTimelyPayloadIsAdmissibleTest = all { - assert(budgetFits(PARAMS)), + assert(scheduleFitsBudget(PARAMS)), assert(tuples(0.to(9), 0.to(PARAMS.deliveryLag), 0.to(2)).forall(((created, lag, extra)) => survivesNextFlush( Some(created + PARAMS.minWalletExpiry + extra), created + lag, PARAMS.flushInterval, PARAMS.miningMargin))), // The budget is what makes it true: one block less and a payload built // one block before a boundary, arriving on it, is refused. - assert(not(budgetFits({ ...PARAMS, minWalletExpiry: 4 }))), + assert(not(scheduleFitsBudget({ ...PARAMS, minWalletExpiry: 4 }))), assert(not(survivesNextFlush(Some(2 + 4), 2 + 1, PARAMS.flushInterval, PARAMS.miningMargin))), } diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt new file mode 100644 index 00000000..f0c9214c --- /dev/null +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -0,0 +1,45 @@ +// -*- mode: Bluespec; -*- + +/// Scripted runs against the honest configurations: one for each behaviour +/// that must be reachable, and one for each cause of each known gap. +/// +/// A run is a sequence of the machine's own steps. Where it says a property +/// fails, it also says what is in the wallet's log, the soup or the audit +/// record that makes it fail. + +module baselineScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = baseline).* from "../protocol" + + /// A migration from send to mined, with the wallet polling along the way. + run happyPathTest = + started + .then(block) + // Height 2. The wallet sends; the shim diverts and answers at once. + .then(submitTo("h1", 0, early)) + .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) + .expect(s.queuedAt("h1") == Set(early) and s.operator == Set()) + .then(lookUp("h1", 1, "early")) + .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) + // Height 3 is a flush boundary. + .then(block) + .then(flush("h1", [early], Accepted)) + .expect(s.queuedAt("h1") == Set() and s.onChain("early") == InMempool) + .then(lookUp("h1", 2, "early")) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(2) })) + .then(block) + .then(chainMineWith("early")) + .then(lookUp("h1", 3, "early")) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 4 }), via: Some(3) })) + .expect(wPending and wTxInMempool and wTxMined) + .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) + .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry and ackImpliesQueued and wellFormed) + .expect(statusNeverRegresses and vLookupValidityPerHub == false) +} diff --git a/zeronym/spec/protocol/tests/shimTest.qnt b/zeronym/spec/protocol/tests/shimTest.qnt index aab1ac94..2968109a 100644 --- a/zeronym/spec/protocol/tests/shimTest.qnt +++ b/zeronym/spec/protocol/tests/shimTest.qnt @@ -84,7 +84,11 @@ module shimTest { assert(dispatching.after(send(Clean(pPlain), 2)) == dispatching), // A body the shim cannot parse is diverted, never forwarded. assert(outputOf(dispatching, send(Clean(pJunk), 1)) - == DivertedOutput({ frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pJunk }) }), told: Some(SentOk) })), + == DivertedOutput({ + payload: pJunk, + frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pJunk }) }), + told: Some(SentOk), + })), } run failClosedTest = all { @@ -103,6 +107,7 @@ module shimTest { // One frame per hub, each under its own nonce, and the wallet is told ok // at once. assert(outputOf(dispatching, send(Clean(pOrchard), 2)) == DivertedOutput({ + payload: pOrchard, frames: Set( { hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }, { hub: "h2", msg: Submit({ nonce: 1, payload: pOrchard }) }, @@ -111,6 +116,7 @@ module shimTest { })), // A sweep that stopped after the first address still tells the wallet ok. assert(outputOf(dispatching, send(Clean(pOrchard), 1)) == DivertedOutput({ + payload: pOrchard, frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }), told: Some(SentOk), })), @@ -125,6 +131,7 @@ module shimTest { run awaitVerdictTest = all { // One hub, and nothing is told until it answers. assert(outputOf(awaiting, send(Clean(pOrchard), 1)) == DivertedOutput({ + payload: pOrchard, frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }), told: None, })), @@ -231,6 +238,7 @@ module shimTest { // It can send a hub something other than what the wallet sent. assert(byzShimResults(dispatching, send(Clean(pOrchard), 2), PAYLOADS, HEIGHTS).exists(result => result.out == DivertedOutput({ + payload: pOrchard, frames: Set({ hub: "h2", msg: Submit({ nonce: 0, payload: pJunk }) }), told: Some(SentOk), }))), From 461c6dc7c2cd735509c8a944526b94bb933186cc Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 04:10:53 +0400 Subject: [PATCH 25/80] test(zeronym): script the witnesses, the known gaps and the trust matrix --- zeronym/spec/protocol/properties.qnt | 9 +- zeronym/spec/protocol/protocol.qnt | 2 +- zeronym/spec/protocol/tests/scenariosTest.qnt | 706 +++++++++++++++++- zeronym/spec/protocol/tests/trustTest.qnt | 576 ++++++++++++++ zeronym/spec/protocol/types.qnt | 5 + 5 files changed, 1286 insertions(+), 12 deletions(-) create mode 100644 zeronym/spec/protocol/tests/trustTest.qnt diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index f1973829..857b0e7a 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -426,9 +426,9 @@ module properties { reply._3 == WFound({ body: None, height: MEMPOOL_HEIGHT }), }) - /// W9. A hub holds a payload it cannot parse, the wallet that sent it asks - /// that hub about a transaction the hub has no trace of, and is told not - /// found: an entry without a txid can never be hit. + /// W9. A hub holds a payload it cannot parse; the wallet that sent it asks + /// that hub for it and is told not found. An entry without a txid can never + /// be hit. pure def wUnparseableMissed(s: System): bool = tuples(s.events(), s.lookups(ShimAddr)).exists(((event, lookup)) => match event { @@ -436,7 +436,8 @@ module properties { and { got.obs == NotFound, got.via == Some(lookup._1), - s.queuedAt(lookup._2).exists(payload => payload.txid == None and s.toldOk().contains(payload)), + s.queuedAt(lookup._2).exists(payload => + payload.txid == None and walletTxid(payload) == got.query), } | _ => false }) diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 75968e1e..9bfb06f6 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -345,7 +345,7 @@ module protocol { /// The txids of the transactions the wallet has handed the shim. A wallet /// asks only about its own transactions. def walletTxids: Set[TxId] = - txidsOf(s.sentByWallet()) + s.sentByWallet().map(payload => walletTxid(payload)) /// The wallet asks for a transaction it has sent. The shim asks the hub its /// cursor points at. diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index f0c9214c..b07691c6 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -1,11 +1,16 @@ // -*- mode: Bluespec; -*- -/// Scripted runs against the honest configurations: one for each behaviour -/// that must be reachable, and one for each cause of each known gap. +/// Scripted runs against the honest configurations, and the two witnesses that +/// need a Byzantine component: one run for each behaviour that must be +/// reachable, and one for each cause of each known gap. /// -/// A run is a sequence of the machine's own steps. Where it says a property +/// A run is a sequence of the machine's own steps. Where a run says a property /// fails, it also says what is in the wallet's log, the soup or the audit /// record that makes it fail. +/// +/// The schedule in every configuration: the chain starts at height 1, a flush +/// is scheduled at heights 3, 6, 9 and 12, and a published transaction needs +/// one more block to be mined. Shim nonces count up from 0, one per frame. module baselineScenarios { import basicSpells.* from "../spells/basicSpells" @@ -18,11 +23,19 @@ module baselineScenarios { import configs.* from "../instances" import protocol(CONFIG = baseline).* from "../protocol" - /// A migration from send to mined, with the wallet polling along the way. - run happyPathTest = + def queue = s.hubs.get("h1").queue + def tpAcks = s.acks("h1", ThirdPartyAddr) + + // ------------------------------------------------------------------------ + // Witnesses + // ------------------------------------------------------------------------ + + /// W1, W2, W3. A migration from send to mined, with the wallet polling. + run pendingThenMempoolThenMinedTest = started .then(block) - // Height 2. The wallet sends; the shim diverts and answers at once. + // Height 2. The shim diverts and answers at once; the operator sees + // nothing. .then(submitTo("h1", 0, early)) .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) .expect(s.queuedAt("h1") == Set(early) and s.operator == Set()) @@ -41,5 +54,684 @@ module baselineScenarios { .expect(wPending and wTxInMempool and wTxMined) .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry and ackImpliesQueued and wellFormed) - .expect(statusNeverRegresses and vLookupValidityPerHub == false) + .expect(statusNeverRegresses) + + /// A pass-through transaction goes to the operator and nowhere else. + run passThroughIsForwardedTest = + started + .then(sendToAll(plain)) + .expect(lastEvent == Sent({ input: Clean(plain), obs: SentToOperator })) + .expect(s.operator == Set(plain) and s.net == Set()) + .expect(vOperatorBlind and operatorBlind and vQueuedBytesConfidential and queuedBytesConfidential) + + /// W4. Each of the five refusals, in one hub's life. + run everyRefusalTest = + init + // The hub has started and has seen no tip. + .then(thirdPartySubmitWith(garbage, "h1")) + .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) + .expect(wRefusedTipStale and tpAcks == Set((0, WRefused(WTipStale)))) + .then(allObserve) + .then(thirdPartySubmitWith(bloat, "h1")) + .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 1, bloat)) + .expect(wRefusedTooLarge and tpAcks.contains((1, WRefused(WTooLarge)))) + // Height 3, and the flush scheduled there has run. The next is at 6, + // which a transaction expiring at 5 does not survive. + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(submitTo("h1", 0, tight)) + .expect(wRefusedExpiryTooTight and s.acks("h1", ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) + // Two admissions fill the queue. The network then delivers the third + // party's first frame a second time. + .then(submitTo("h1", 1, junk)) + .then(submitTo("h1", 2, early)) + .expect(s.queuedAt("h1") == Set(junk, early)) + .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) + .expect(wRefusedFull and not(wRefusedDraining)) + // Draining is checked before size: the oversize frame, delivered again, + // is now refused for that. On the wire it is the same refusal as full. + .then(hubBeginDrainWith("h1")) + .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 1, bloat)) + .expect(wRefusedDraining) + .expect(tpAcks == Set( + (0, WRefused(WTipStale)), (0, WRefused(WQueueFull)), + (1, WRefused(WTooLarge)), (1, WRefused(WQueueFull)), + )) + .expect(audit.everQueued.get("h1") == Set(junk, early)) + + /// W5, W6, W7, W12. An indexer that cannot be reached: entries are requeued, + /// dropped as expired, dropped as exhausted, and held past capacity. + run requeueAndDropTest = + started + .then(submitTo("h1", 0, junk)) + .then(block) + // Height 2: a transaction expiring at 5 survives the flush at 3. + .then(submitTo("h1", 1, tight)) + .then(block) + .then(hubFlushBeginWith("h1")) + // While the batch is out, two fresh admissions fill the queue. + .then(submitTo("h1", 2, early)) + .then(thirdPartySubmitWith(garbage, "h1")) + .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) + .expect(s.queuedAt("h1") == Set(early, garbage) and s.inFlightAt("h1") == Set(junk, tight)) + .then(judge("h1", junk, Retryable)) + .then(judge("h1", tight, Retryable)) + .then(hubFlushEndWith("h1")) + // `junk` never expires and comes back, over capacity. `tight` would not + // survive the flush at 6 and is given up on. + .expect(queue == Map(early -> 0, garbage -> 0, junk -> 1)) + .expect(audit.dropped == Set({ hub: "h1", payload: tight, attempts: 0 })) + .expect(wRequeued and wDroppedExpired and wQueueOverCapacity and not(wDroppedExhausted)) + .then(blocks(3)) + .then(hubFlushBeginWith("h1")) + .then(judge("h1", early, Accepted)) + .then(judge("h1", garbage, Accepted)) + .then(judge("h1", junk, Retryable)) + .then(hubFlushEndWith("h1")) + .expect(queue == Map(junk -> 2)) + // The third failure is one more than the attempts allowed. + .then(blocks(3)) + .then(flush("h1", [junk], Retryable)) + .expect(queue == Map() and wDroppedExhausted) + .expect(audit.dropped.contains({ hub: "h1", payload: junk, attempts: 2 })) + .expect(offeredBeforeExpiry and wellFormed) + + /// W8. The accepted disclosure: a third party that knows a txid is told it + /// is queued, and is not given the bytes. + run thirdPartyLearnsItIsQueuedTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(thirdPartyLearnsTxidWith("early")) + .then(thirdPartyLookupWith("early", "h1")) + .then(deliverLookupFrom(ThirdPartyAddr, "h1", 0, "early", INotFound)) + .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(wQueuedDisclosed) + .expect(s.tpLearned() == Set() and queuedBytesConfidential) + + /// W9. A queued payload the hub cannot parse has no txid to be found by. + run unparseableIsQueuedAndMissedTest = + started + .then(submitTo("h1", 0, junk)) + .expect(lastEvent == Sent({ input: Clean(junk), obs: SentOk }) and s.queuedAt("h1") == Set(junk)) + .then(lookUp("h1", 1, "junk")) + .expect(lastEvent == Got({ query: "junk", obs: NotFound, via: Some(1) })) + .expect(wUnparseableMissed and lookupValidityPerHub) + + /// W17. Anyone can put a payload in a hub's queue. + run thirdPartyPayloadIsQueuedTest = + started + .then(thirdPartySubmitWith(garbage, "h1")) + .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) + .expect(tpAcks == Set((0, WAccepted)) and s.queuedAt("h1") == Set(garbage)) + .expect(wThirdPartyPayloadQueued and queuedBytesConfidential) + + // ------------------------------------------------------------------------ + // K1. Told ok, and no hub ever has it + // ------------------------------------------------------------------------ + + /// K1a. The only hub refuses the frame after the wallet was told ok. + run toldOkThenRefusedTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(submitTo("h1", 0, tight)) + .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) + .expect(s.acks("h1", ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) + .expect(audit.everQueued.get("h1") == Set()) + .expect(wToldRefusedEverywhere and not(toldImpliesQueued)) + + /// K1b. The frame is never delivered. Nothing obliges the network to. + run toldOkAndNeverDeliveredTest = + started + .then(block) + .then(sendToAll(early)) + .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) + .expect(s.net == Set(submitMail("h1", 0, early))) + .expect(audit.everQueued.get("h1") == Set()) + .expect(wToldNeverDelivered and not(toldImpliesQueued)) + + // ------------------------------------------------------------------------ + // K2. The status a wallet sees goes backwards + // ------------------------------------------------------------------------ + + /// K2a. Two polls, answered in order, delivered out of order. + run repliesReorderedTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(ask("early", 0)) + .then(deliverLookup("h1", 1, "early")) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(ask("early", 0)) + .then(deliverLookup("h1", 2, "early")) + .then(deliverToShim(replyMail("h1", 2, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .then(deliverToShim(replyMail("h1", 1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(s.wallet.log == [ + Sent({ input: Clean(early), obs: SentOk }), + Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(2) }), + Got({ query: "early", obs: Pending, via: Some(1) }), + ]) + // Each answer was true when it was given. + .expect(not(statusNeverRegresses) and lookupValidityPerHub) + + /// K2b. The wallet sends published bytes again. The hub's memory of them + /// went with the flush, so they are admitted and pending once more. + run walletResendsPublishedTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(lookUp("h1", 1, "early")) + .then(submitTo("h1", 2, early)) + .expect(s.queuedAt("h1") == Set(early) and s.onChain("early") == InMempool) + .then(lookUp("h1", 3, "early")) + .expect(s.wallet.log.slice(1, 4) == [ + Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) }), + Sent({ input: Clean(early), obs: SentOk }), + Got({ query: "early", obs: Pending, via: Some(3) }), + ]) + .expect(not(statusNeverRegresses) and lookupValidityPerHub) + + /// K2c. The same, done by a third party: the bytes are public once + /// published, and submission is open to anyone. + run thirdPartyResubmitsPublishedTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(lookUp("h1", 1, "early")) + .expect(s.tpPayloads().contains(early)) + .then(thirdPartySubmitWith(early, "h1")) + .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, early)) + .then(lookUp("h1", 2, "early")) + .expect(s.wallet.log.slice(1, 3) == [ + Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) }), + Got({ query: "early", obs: Pending, via: Some(2) }), + ]) + .expect(not(statusNeverRegresses) and lookupValidityPerHub) + + /// K2d. The flush window: the queue is empty and the chain does not have + /// the batch yet. + run flushWindowTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(lookUp("h1", 1, "early")) + .then(block) + .then(hubFlushBeginWith("h1")) + .expect(s.inFlightAt("h1") == Set(early) and s.onChain("early") == Absent) + .then(lookUp("h1", 2, "early")) + .expect(s.wallet.log.slice(1, 3) == [ + Got({ query: "early", obs: Pending, via: Some(1) }), + Got({ query: "early", obs: NotFound, via: Some(2) }), + ]) + .expect(not(statusNeverRegresses) and lookupValidityPerHub) + + /// K2e. The node rejects the transaction at flush. It was pending; now it + /// is nowhere. + run rejectedAtFlushTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(lookUp("h1", 1, "early")) + .then(block) + .then(flush("h1", [early], Rejected)) + .expect(s.queuedAt("h1") == Set() and s.inFlightAt("h1") == Set() and s.onChain("early") == Absent) + .then(lookUp("h1", 2, "early")) + .expect(s.wallet.log.slice(1, 3) == [ + Got({ query: "early", obs: Pending, via: Some(1) }), + Got({ query: "early", obs: NotFound, via: Some(2) }), + ]) + .expect(not(statusNeverRegresses) and lookupValidityPerHub) + + // ------------------------------------------------------------------------ + // K5. Acknowledged, then lost + // ------------------------------------------------------------------------ + + /// K5a. The hub crashes after the ack. The queue was in memory. + run ackedThenCrashedTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and ackedIsHeldOrOffered) + .then(hubCrashWith("h1")) + .expect(s.hubs.get("h1") == downHub(HUB_PARAMS)) + .expect(audit.offers == Set() and s.onChain("early") == Absent) + .expect(not(ackedIsHeldOrOffered) and ackImpliesQueued) + + /// K5b. The final flush of a draining hub finds the indexer unreachable. + /// The entry was offered, which is all `ackedIsHeldOrOffered` asks, so that + /// invariant still holds here; the transaction is lost all the same. + run ackedThenLostAtDrainTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(hubBeginDrainWith("h1")) + .then(flush("h1", [early], Retryable)) + .expect(s.hubs.get("h1").phase == Stopped) + .expect(s.queuedAt("h1") == Set() and s.inFlightAt("h1") == Set() and s.onChain("early") == Absent) + .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted))) + .expect(audit.offers == Set({ hub: "h1", payload: early, height: 2, attempt: 0, nth: 0 })) + .expect(ackedIsHeldOrOffered) + + // ------------------------------------------------------------------------ + // K6, control + // ------------------------------------------------------------------------ + + /// The wallet inputs of K6 under a timely tip. The second requeue is judged + /// at tip 9, finds that an expiry of 10 does not survive the flush at 12, + /// and drops the entry: it is never offered past its expiry. + run requeueUnderTimelyTipDropsTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(block) + .then(sendToAll(late)) + .then(block) + .then(deliverSubmit("h1", 0, late)) + .then(block) + .then(flush("h1", [late], Retryable)) + .expect(queue == Map(late -> 1)) + .then(blocks(3)) + .then(flush("h1", [late], Retryable)) + .expect(queue == Map()) + .expect(audit.dropped == Set({ hub: "h1", payload: late, attempts: 1 })) + .expect(audit.offers.map(offer => offer.height) == Set(6, 9)) + .expect(conformingEveryOfferBeforeExpiry) +} + +module awaitAckScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = awaitAck).* from "../protocol" + + /// Under `AwaitVerdict` the wallet hears nothing until the hub answers, and + /// then hears the hub's decision. + run toldWhatTheHubDecidedTest = + started + .then(block) + .then(sendToAll(early)) + .expect(s.wallet.log == [] and s.sentByWallet() == Set(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverToShim(ackMail("h1", 0, WAccepted))) + .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) + .expect(vToldImpliesQueued and toldImpliesQueued) + // A transaction the hub refuses is reported as refused. + .then(blocks(1)) + .then(sendToAll(tight)) + .then(deliverSubmit("h1", 1, tight)) + .then(deliverToShim(ackMail("h1", 1, WRefused(WExpiryTooTight)))) + .expect(lastEvent == Sent({ input: Clean(tight), obs: SentRejected })) + // And silence fails closed. + .then(sendToAll(junk)) + .then(timeOutAck(2)) + .expect(lastEvent == Sent({ input: Clean(junk), obs: SendUnavailable })) + .expect(toldImpliesQueued) + + /// K5a under `AwaitVerdict`: told ok on the hub's word, admitted, and lost + /// to a crash, with every component honest. + run toldOkAdmittedThenLostTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverToShim(ackMail("h1", 0, WAccepted))) + .then(hubCrashWith("h1")) + .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) + .expect(s.queuedAt("h1") == Set() and audit.offers == Set() and s.onChain("early") == Absent) + .expect(toldImpliesQueued and not(ackedIsHeldOrOffered)) +} + +module replicatedScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = replicated).* from "../protocol" + + /// Every hub that receives a migration queues it and publishes it. + /// W14. Two hubs publish the same payload in their own flushes. + run publishedByBothHubsTest = + started + .then(block) + .then(sendToAll(early)) + .expect(s.net == Set(submitMail("h1", 0, early), submitMail("h2", 1, early))) + .then(deliverSubmit("h1", 0, early)) + .then(deliverSubmit("h2", 1, early)) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(flush("h2", [early], AlreadyKnown)) + .expect(audit.offers.map(offer => offer.hub) == Set("h1", "h2")) + .expect(wPublishedByTwoHubs) + + /// K1c. The sweep stops after the first address. One frame was handed over, + /// so the wallet is told ok; the second hub is never sent the migration. + run toldOkAfterPrefixSendTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) + .expect(s.net == Set(submitMail("h1", 0, early))) + .expect(wToldPrefixOnly) + + /// K2f. One hub has the migration queued and says so. The next poll starts + /// at the other hub, which never received it; its not-found is final. + run hubsDisagreeTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(ask("early", 0)) + .then(deliverLookup("h1", 2, "early")) + .then(deliverToShim(replyMail("h1", 2, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .then(ask("early", 1)) + .then(deliverLookup("h2", 3, "early")) + .then(deliverToShim(replyMail("h2", 3, WNotFound))) + .expect(s.wallet.log.slice(1, 3) == [ + Got({ query: "early", obs: Pending, via: Some(2) }), + Got({ query: "early", obs: NotFound, via: Some(3) }), + ]) + // No lookup is outstanding: the shim asked nobody else. (Nonces 0 and 1 + // are the acks nobody waits for.) + .expect(s.shim.waiters.keys() == Set(0, 1)) + // Each hub told the truth about itself. + .expect(not(statusNeverRegresses) and lookupValidityPerHub) + + /// W13. A lookup whose first hub stays silent moves on, and the next hub + /// answers. + run lookupFailsOverOnTimeoutTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(ask("early", 1)) + .expect(s.lookups(ShimAddr) == Set((2, "h2", "early"))) + .then(timeOutLookup(2)) + .expect(s.lookups(ShimAddr) == Set((2, "h2", "early"), (3, "h1", "early"))) + .then(deliverLookup("h1", 3, "early")) + .expect(wFailoverAnswered) + .then(deliverToShim(replyMail("h1", 3, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(3) })) + .expect(lookupValidityPerHub) +} + +module flakyTipScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = flakyTip).* from "../protocol" + + /// K3. A tight-expiry transaction is admitted against a tip reported one + /// block back, below a boundary the hub has already flushed. Admission + /// reasons that the next flush is at 3; it is at 6, after the expiry. + run tightExpiryAdmittedBehindFlushedBoundaryTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(hubObserveTipWith("h1", 2)) + .then(submitTo("h1", 0, tight)) + .expect(s.queuedAt("h1") == Set(tight)) + .expect(audit.admitted.get(("h1", tight)) == { height: 3, tip: 2 }) + .then(blocks(3)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 6, attempt: 0, nth: 0 })) + // Not a supported wallet: the guarantee for those is untouched. + .expect(not(conforming(tight, MIN_WALLET_EXPIRY))) + .expect(not(offeredBeforeExpiry) and conformingFirstOfferBeforeExpiry) + + /// A supported wallet's transaction, admitted the same way and then flushed + /// one block late because the tip is reported one block back. The reorg + /// slack is exactly what it needs. + run conformingSurvivesRegressionTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(hubObserveTipWith("h1", 2)) + .then(submitTo("h1", 0, early)) + .then(blocks(2)) + .then(2.reps(_ => chainAdvance)) + .then(hubObserveTipWith("h1", 6)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: early, height: 7, attempt: 0, nth: 0 })) + .expect(vConformingOfferAdmittedBehind and conformingFirstOfferBeforeExpiry and offeredBeforeExpiry) +} + +module flakyTipNoSlackScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = flakyTipNoSlack).* from "../protocol" + + /// This configuration's migration from a supported wallet: built at height + /// 2, expiring exactly at the floor of 5 blocks. + pure val atFloor = orchard("early", 2, 7) + + /// K3'. The steps of `conformingSurvivesRegressionTest` with the expiry + /// floor equal to the three-term budget. The flush is one block late and + /// the transaction misses the mining margin by that block. + run conformingMissesMarginWithoutSlackTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(hubObserveTipWith("h1", 2)) + .then(submitTo("h1", 0, atFloor)) + .then(blocks(2)) + .then(2.reps(_ => chainAdvance)) + .then(hubObserveTipWith("h1", 6)) + .then(hubFlushBeginWith("h1")) + .expect(conforming(atFloor, MIN_WALLET_EXPIRY)) + .expect(audit.admitted.get(("h1", atFloor)).height == 3) + .expect(audit.offers == Set({ hub: "h1", payload: atFloor, height: 7, attempt: 0, nth: 0 })) + .expect(not(conformingFirstOfferBeforeExpiry)) +} + +module staleLagScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = staleLag).* from "../protocol" + + def queue = s.hubs.get("h1").queue + + /// K4. The hub hears nothing after height 5, one block short of the flush + /// at 6, while the chain goes on. Its cadence still follows the tip it last + /// saw, so nothing is flushed until it goes stale at height 8. A supported + /// wallet's transaction, expiring at 8, is published at 8: not yet expired, + /// and without the block the mining margin reserves. + run silenceAcrossBoundaryMissesMarginTest = + started + .then(block) + .then(sendToAll(early)) + .then(block) + .then(hubFlushBeginWith("h1")) + .then(deliverSubmit("h1", 0, early)) + .then(blocks(2)) + .then(3.reps(_ => chainAdvance)) + .expect(s.height() == 8 and s.hubs.get("h1").tip == Some(5) and not(s.hubs.get("h1").isFlushDue())) + .then(hubTipStaleWith("h1", 8)) + .then(hubFlushBeginWith("h1")) + .expect(conforming(early, MIN_WALLET_EXPIRY)) + .expect(audit.admitted.get(("h1", early)) == { height: 3, tip: 3 }) + .expect(audit.offers == Set({ hub: "h1", payload: early, height: 8, attempt: 0, nth: 0 })) + .expect(early.expiry == Some(8) and MINING_MARGIN == 1) + .expect(not(conformingFirstOfferBeforeExpiry) and not(offeredBeforeExpiry)) + + /// K6. A stale hub's flush finds the indexer unreachable, twice. Requeue + /// judges the entry at the observed tip, which stopped at 5: the next flush + /// it knows of is the one at 6, so an expiry of 10 looks safe both times. + /// The free-running schedule offers it again at 12. + run requeuedPastExpiryTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(block) + .then(sendToAll(late)) + .then(block) + .then(deliverSubmit("h1", 0, late)) + .then(3.reps(_ => chainAdvance)) + .then(hubTipStaleWith("h1", 8)) + .then(flush("h1", [late], Retryable)) + .expect(queue == Map(late -> 1)) + .then(chainAdvance) + .then(hubTipStaleWith("h1", 9)) + .then(flush("h1", [late], Retryable)) + .expect(queue == Map(late -> 2) and s.hubs.get("h1").tip == Some(5)) + .then(chainAdvance) + .then(hubTipStaleWith("h1", 10)) + .then(chainAdvance) + .then(hubTipStaleWith("h1", 11)) + .then(chainAdvance) + .then(hubTipStaleWith("h1", 12)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set( + { hub: "h1", payload: late, height: 8, attempt: 0, nth: 0 }, + { hub: "h1", payload: late, height: 9, attempt: 1, nth: 1 }, + { hub: "h1", payload: late, height: 12, attempt: 2, nth: 2 }, + )) + .expect(late.expiry == Some(10)) + // The first offer was in time, which is all G6b covers. + .expect(not(conformingEveryOfferBeforeExpiry) and conformingFirstOfferBeforeExpiry) + + /// W18. A stale hub's free-running clock reads 6 at true height 5, and the + /// flush scheduled for 6 runs a block early. + run freeRunningClockFlushesEarlyTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(3.reps(_ => chainAdvance)) + .then(hubTipStaleWith("h1", 6)) + .then(hubFlushBeginWith("h1")) + .expect(s.height() == 5 and s.hubs.get("h1").cadence == FreeRunning(6)) + .expect(s.inFlightAt("h1") == Set(early)) + .expect(wEarlyFreeRunFlush and conformingFirstOfferBeforeExpiry) + + /// A stale hub refuses submissions until it sees the tip move again. + run staleHubRefusesTest = + started + .then(3.reps(_ => chainAdvance)) + .then(hubTipStaleWith("h1", 4)) + .then(submitTo("h1", 0, early)) + .expect(wRefusedTipStale and s.queuedAt("h1") == Set()) + .then(observe("h1")) + .expect(s.hubs.get("h1").phase == Running and s.hubs.get("h1").cadence == Tracking) + .then(deliverSubmit("h1", 0, early)) + .expect(s.queuedAt("h1") == Set(early)) +} + +module staleLagWithSlackScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = staleLagWithSlack).* from "../protocol" + + /// This configuration's migration from a supported wallet: built at height + /// 2, expiring at the floor of 7 blocks. + pure val atFloor = orchard("early", 2, 9) + + /// The steps of K4 with an expiry floor one block higher, which is the + /// relation `staleSlackFits` asks for. The same late flush leaves the margin. + run sameSilenceWithSlackKeepsMarginTest = + started + .then(block) + .then(sendToAll(atFloor)) + .then(block) + .then(hubFlushBeginWith("h1")) + .then(deliverSubmit("h1", 0, atFloor)) + .then(blocks(2)) + .then(3.reps(_ => chainAdvance)) + .then(hubTipStaleWith("h1", 8)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: atFloor, height: 8, attempt: 0, nth: 0 })) + .expect(vConformingFirstOfferBeforeExpiry and conformingFirstOfferBeforeExpiry) +} + +module byzIndexerScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = byzIndexer).* from "../protocol" + + /// W15. Premature flush. The indexer reports tip 3 at true height 2; the + /// hub believes the boundary has come and publishes what it holds. One + /// lying endpoint is enough for this: the tip is the maximum over endpoints. + run tipAheadOfChainFlushesEarlyTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(hubObserveTipWith("h1", 3)) + .then(hubFlushBeginWith("h1")) + .expect(s.height() == 2 and s.hubs.get("h1").tip == Some(3)) + .expect(s.inFlightAt("h1") == Set(early)) + .expect(wPrematureFlush) + // The transaction is published early, which costs it nothing. + .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry) +} + +module byzHubScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = byzHub).* from "../protocol" + + /// W16. A Byzantine hub serves a twin of the wallet's transaction at a + /// height the chain has not reached. The shim checks the txid, which a twin + /// shares, and nothing else: it serves both. + run twinAtFalseHeightIsServedTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(ask("early", 0)) + .then(hubReceiveWith( + "h1", lookupMail("h1", 1, "early"), INotFound, + s.hubs.get("h1").toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(earlyTwin), height: 9 }))), + )) + .then(deliverToShim(replyMail("h1", 1, WFound({ body: Some(earlyTwin), height: 9 })))) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: earlyTwin, height: 9 }), via: Some(1) })) + .expect(s.height() == 2 and s.onChain("early") == Absent) + .expect(wTwinServed and wFalseHeightServed) + .expect(txidAuthenticity and not(lookupValidityPerHub)) } diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt new file mode 100644 index 00000000..dbb08e92 --- /dev/null +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -0,0 +1,576 @@ +// -*- mode: Bluespec; -*- + +/// The trust matrix, cell by cell: for every guarantee that needs a component +/// to be honest, a run in which that component is Byzantine and the guarantee +/// fails. +/// +/// Each run is built from the machine's own steps. It names the one Byzantine +/// transition it relies on, written out as a `...With` step, and ends by +/// stating what in the wallet's log, the soup or the audit record constitutes +/// the failure. Each is followed by its control: the same wallet inputs in the +/// same configuration, with the component taking the honest transition, which +/// is always among those it may take. In the control the guarantee holds. + +module byzShimTrust { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = byzShim).* from "../protocol" + + /// G1 needs the shim. It hands a migration to the operator. + run operatorSeesMigrationTest = + started + .then(block) + .then(walletSendWith(Clean(early), 1, s.shim.toForwardOutput(early))) + .expect(s.operator == Set(early) and early.class == OrchardTouching) + .expect(not(operatorBlind)) + + run operatorSeesMigrationControlTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .expect(s.operator == Set() and s.net == Set(submitMail("h1", 0, early))) + .expect(operatorBlind) + + /// G2 needs the shim. It sees every migration in the clear and reveals one. + run shimDisclosesPlaintextTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .then(byzDiscloseWith(early)) + .expect(s.disclosed == Set(early) and s.tpLearned() == Set(early) and s.onChain("early") == Absent) + .expect(not(queuedBytesConfidential)) + + run shimDisclosesPlaintextControlTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .expect(s.tpLearned() == Set()) + .expect(queuedBytesConfidential) + + /// G3 needs the shim. Asked for one transaction, it serves another. + run shimServesAnotherTransactionTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(walletGetWith("early", 0, s.shim.toLookupDoneOutput("early", Tx({ payload: tight, height: 3 })))) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: tight, height: 3 }), via: None })) + .expect(not(txidAuthenticity)) + + run shimServesAnotherTransactionControlTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(lookUp("h1", 1, "early")) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) })) + .expect(txidAuthenticity) + + /// G4 needs the shim. It reports a transaction pending that no hub holds, + /// without asking one. + run shimInventsStatusTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .then(walletGetWith("early", 0, s.shim.toLookupDoneOutput("early", Pending))) + .expect(lastEvent == Got({ query: "early", obs: Pending, via: None })) + .expect(s.queuedAt("h1") == Set() and s.lookups(ShimAddr) == Set()) + .expect(not(lookupValidityPerHub)) + + run shimInventsStatusControlTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .then(lookUp("h1", 1, "early")) + .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) + .expect(lookupValidityPerHub) +} + +module awaitAckByzShimTrust { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = awaitAckByzShim).* from "../protocol" + + /// G5 needs the shim. It tells the wallet ok and sends nothing. + run toldOkWithoutSendingTest = + started + .then(block) + .then(walletSendWith(Clean(early), 1, s.shim.toSendDoneOutput(Clean(early), SentOk))) + .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) + .expect(s.net == Set() and audit.everQueued.get("h1") == Set()) + .expect(not(toldImpliesQueued)) + + run toldOkWithoutSendingControlTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverToShim(ackMail("h1", 0, WAccepted))) + .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) + .expect(audit.everQueued.get("h1") == Set(early)) + .expect(toldImpliesQueued) +} + +module byzHubTrust { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = byzHub).* from "../protocol" + + def h1 = s.hubs.get("h1") + + /// G2 needs the hub. Asked by a third party about a queued txid, it answers + /// with the queued bytes. + run hubServesQueuedBodyTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(thirdPartyLearnsTxidWith("early")) + .then(thirdPartyLookupWith("early", "h1")) + .then(hubReceiveWith( + "h1", fromThirdParty(lookupMail("h1", 0, "early")), INotFound, + h1.toLookupReplyOutput(0, FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT }))), + )) + .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) + .expect(not(queuedBytesConfidential)) + + run hubServesQueuedBodyControlTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(thirdPartyLearnsTxidWith("early")) + .then(thirdPartyLookupWith("early", "h1")) + .then(deliverLookupFrom(ThirdPartyAddr, "h1", 0, "early", INotFound)) + .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(queuedBytesConfidential) + + /// G4 needs the hub. It answers not found for a transaction it has queued. + run hubDeniesQueuedTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(ask("early", 0)) + .then(hubReceiveWith( + "h1", lookupMail("h1", 1, "early"), INotFound, + h1.toLookupReplyOutput(1, FromIndexer(INotFound)), + )) + .then(deliverToShim(replyMail("h1", 1, WNotFound))) + .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) + .expect(s.queuedAt("h1") == Set(early) and audit.windows.get(1) == Set(Pending)) + .expect(not(lookupValidityPerHub)) + + run hubDeniesQueuedControlTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(lookUp("h1", 1, "early")) + .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) + .expect(lookupValidityPerHub) + + /// G4 needs the hub, second run. It serves a mempool transaction as mined, + /// at a height it made up. The txid is right, so the shim passes it on. + run hubServesFalseHeightTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(ask("early", 0)) + .then(hubReceiveWith( + "h1", lookupMail("h1", 1, "early"), chainAnswer(s.indexer, "early"), + h1.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(early), height: 9 }))), + )) + .then(deliverToShim(replyMail("h1", 1, WFound({ body: Some(early), height: 9 })))) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 9 }), via: Some(1) })) + .expect(s.onChain("early") == InMempool) + .expect(audit.windows.get(1) == Set(Tx({ payload: early, height: MEMPOOL_HEIGHT }))) + .expect(not(lookupValidityPerHub) and txidAuthenticity) + + run hubServesFalseHeightControlTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(flush("h1", [early], Accepted)) + .then(lookUp("h1", 1, "early")) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) })) + .expect(lookupValidityPerHub) + + /// G8 needs the hub. It acks a submission as accepted and does not queue it. + run hubAcksWithoutAdmittingTest = + started + .then(block) + .then(sendToAll(early)) + .then(hubReceiveWith("h1", submitMail("h1", 0, early), INotFound, h1.toAckOutput(0, Admitted))) + .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted))) + .expect(s.queuedAt("h1") == Set() and audit.everQueued.get("h1") == Set()) + .expect(not(ackImpliesQueued)) + + run hubAcksWithoutAdmittingControlTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and s.queuedAt("h1") == Set(early)) + .expect(ackImpliesQueued) + + /// G6a needs the hub. It admits a transaction the expiry rule refuses: one + /// expiring at 5, taken at tip 3, when the next flush is at 6. + run hubAdmitsPastExpiryRuleTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(sendToAll(tight)) + .then(hubReceiveWith( + "h1", submitMail("h1", 0, tight), INotFound, + { ...h1, queue: Map(tight -> 0) }.toAckOutput(0, Admitted), + )) + .then(blocks(3)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 6, attempt: 0, nth: 0 })) + .expect(not(offeredBeforeExpiry)) + + run hubAdmitsPastExpiryRuleControlTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(sendToAll(tight)) + .then(deliverSubmit("h1", 0, tight)) + .expect(s.acks("h1", ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) + .then(blocks(3)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set()) + .expect(offeredBeforeExpiry) + + /// G6b needs the hub. A supported wallet's transaction is refused by an + /// honest hub only for reasons that are not about the transaction. This hub + /// takes one while it has not yet seen a tip, when an honest hub refuses + /// everything. It first sees the chain at height 7, adopts that epoch + /// without flushing, and publishes at 9, past the expiry of 8. + run hubAdmitsBeforeFirstTipTest = + init + .then(2.reps(_ => chainAdvance)) + .then(sendToAll(early)) + .then(hubReceiveWith( + "h1", submitMail("h1", 0, early), INotFound, + { ...h1, queue: Map(early -> 0) }.toAckOutput(0, Admitted), + )) + .expect(h1.phase == Starting and audit.admitted.get(("h1", early)).height == 3) + .then(4.reps(_ => chainAdvance)) + .then(observe("h1")) + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .expect(conforming(early, MIN_WALLET_EXPIRY)) + .expect(audit.offers == Set({ hub: "h1", payload: early, height: 9, attempt: 0, nth: 0 })) + .expect(not(conformingFirstOfferBeforeExpiry)) + + run hubAdmitsBeforeFirstTipControlTest = + init + .then(2.reps(_ => chainAdvance)) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .expect(s.acks("h1", ShimAddr) == Set((0, WRefused(WTipStale)))) + .then(4.reps(_ => chainAdvance)) + .then(observe("h1")) + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set()) + .expect(conformingFirstOfferBeforeExpiry) +} + +module awaitAckByzHubTrust { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = awaitAckByzHub).* from "../protocol" + + /// G5 needs the hub. The wallet's answer is the hub's word, and the hub + /// says accepted without queueing anything. + run toldOkOnAFalseAckTest = + started + .then(block) + .then(sendToAll(early)) + .then(hubReceiveWith( + "h1", submitMail("h1", 0, early), INotFound, + s.hubs.get("h1").toAckOutput(0, Admitted), + )) + .then(deliverToShim(ackMail("h1", 0, WAccepted))) + .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) + .expect(audit.everQueued.get("h1") == Set()) + .expect(not(toldImpliesQueued)) + + run toldOkOnAFalseAckControlTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverToShim(ackMail("h1", 0, WAccepted))) + .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) + .expect(toldImpliesQueued) +} + +module byzIndexerTrust { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = byzIndexer).* from "../protocol" + + // A hub folds several indexer endpoints into one answer. A lookup answer + // comes from the first endpoint that says found, so one misbehaving endpoint + // is enough for the first two runs. The tip is the maximum over endpoints, + // so holding it back, as the last two do, takes every endpoint. + + /// G2 needs the indexer. It is offered a batch, reports nothing judged, and + /// then serves the unpublished bytes in a lookup answer, which the honest + /// hub forwards to whoever asked. + run indexerServesUnpublishedBodyTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(hubFlushBeginWith("h1")) + .then(judge("h1", early, Retryable)) + .expect(s.indexer.offered == Set(early) and s.onChain("early") == Absent) + .then(thirdPartyLearnsTxidWith("early")) + .then(thirdPartyLookupWith("early", "h1")) + .then(deliverLookupFrom( + ThirdPartyAddr, "h1", 0, "early", IFound({ body: Some(early), height: MEMPOOL_HEIGHT }), + )) + .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) + .expect(not(queuedBytesConfidential)) + + run indexerServesUnpublishedBodyControlTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(hubFlushBeginWith("h1")) + .then(judge("h1", early, Retryable)) + .then(thirdPartyLearnsTxidWith("early")) + .then(thirdPartyLookupWith("early", "h1")) + .then(deliverLookupFrom(ThirdPartyAddr, "h1", 0, "early", INotFound)) + .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WNotFound))) + .expect(queuedBytesConfidential) + + /// G4 needs the indexer. For a transaction that exists nowhere it answers + /// "found, height 0, no body". The hub forwards it unchanged, and on the + /// wire it is the hub's own "queued here": the wallet sees pending. + run indexerForgesPendingTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .then(ask("early", 0)) + .then(deliverLookupFrom(ShimAddr, "h1", 1, "early", IFound({ body: None, height: MEMPOOL_HEIGHT }))) + .then(deliverToShim(replyMail("h1", 1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) + .expect(audit.everQueued.get("h1") == Set() and audit.windows.get(1) == Set(NotFound)) + .expect(not(lookupValidityPerHub)) + + run indexerForgesPendingControlTest = + started + .then(block) + .then(sends(Clean(early), 1)) + .then(lookUp("h1", 1, "early")) + .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) + .expect(lookupValidityPerHub) + + /// G6a needs the indexer. It stops reporting tip progress at height 2 while + /// the chain goes on, then reports the truth at 5. The flush scheduled for + /// 3 runs at 5, where a transaction expiring at 5 has no margin left. + run indexerWithholdsTipTest = + started + .then(block) + .then(submitTo("h1", 0, tight)) + .then(3.reps(_ => chainAdvance)) + .expect(s.height() == 5 and s.hubs.get("h1").tip == Some(2)) + .then(observe("h1")) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 5, attempt: 0, nth: 0 })) + .expect(not(offeredBeforeExpiry)) + + run indexerWithholdsTipControlTest = + started + .then(block) + .then(submitTo("h1", 0, tight)) + .then(block) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 3, attempt: 0, nth: 0 })) + .expect(offeredBeforeExpiry) + + /// G6b needs the indexer. The same silence, kept up to height 8, does it to + /// a supported wallet's transaction. + run indexerWithholdsTipFromConformingTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(6.reps(_ => chainAdvance)) + .then(observe("h1")) + .then(hubFlushBeginWith("h1")) + .expect(conforming(early, MIN_WALLET_EXPIRY) and audit.admitted.get(("h1", early)).height == 2) + .expect(audit.offers == Set({ hub: "h1", payload: early, height: 8, attempt: 0, nth: 0 })) + .expect(not(conformingFirstOfferBeforeExpiry)) + + run indexerWithholdsTipFromConformingControlTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(block) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: early, height: 3, attempt: 0, nth: 0 })) + .expect(conformingFirstOfferBeforeExpiry) +} + +module replicatedOneByzTrust { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = replicatedOneByz).* from "../protocol" + + // Two hubs, `h1` honest and `h2` Byzantine. Replication does not dilute + // trust: every hub receives every migration, and a lookup may start at + // either. + + def h2 = s.hubs.get("h2") + + /// G2 is required of every hub. The Byzantine replica holds the same bytes + /// as the honest one and gives them away. + run oneReplicaServesQueuedBodyTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverSubmit("h2", 1, early)) + .then(thirdPartyLearnsTxidWith("early")) + .then(thirdPartyLookupWith("early", "h2")) + .then(hubReceiveWith( + "h2", fromThirdParty(lookupMail("h2", 0, "early")), INotFound, + h2.toLookupReplyOutput(0, FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT }))), + )) + .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) + .expect(not(queuedBytesConfidential)) + + run oneReplicaServesQueuedBodyControlTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverSubmit("h2", 1, early)) + .then(thirdPartyLearnsTxidWith("early")) + .then(thirdPartyLookupWith("early", "h2")) + .then(deliverLookupFrom(ThirdPartyAddr, "h2", 0, "early", INotFound)) + .expect(s.tpLearned() == Set()) + .expect(queuedBytesConfidential) + + /// G4 is required of every hub. The cursor points at the Byzantine replica, + /// which denies a transaction it has queued; its answer is final. + run cursorLandsOnLyingReplicaTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverSubmit("h2", 1, early)) + .then(ask("early", 1)) + .then(hubReceiveWith( + "h2", lookupMail("h2", 2, "early"), INotFound, + h2.toLookupReplyOutput(2, FromIndexer(INotFound)), + )) + .then(deliverToShim(replyMail("h2", 2, WNotFound))) + .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(2) })) + .expect(s.queuedAt("h1") == Set(early) and s.queuedAt("h2") == Set(early)) + .expect(not(lookupValidityPerHub)) + + run cursorLandsOnLyingReplicaControlTest = + started + .then(block) + .then(sendToAll(early)) + .then(deliverSubmit("h1", 0, early)) + .then(deliverSubmit("h2", 1, early)) + .then(ask("early", 1)) + .then(deliverLookup("h2", 2, "early")) + .then(deliverToShim(replyMail("h2", 2, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(2) })) + .expect(lookupValidityPerHub) + + /// G3 survives. The Byzantine replica answers with another transaction; the + /// shim compares txids and refuses it. + run wrongTransactionIsRefusedTest = + started + .then(block) + .then(sendToAll(early)) + .then(ask("early", 1)) + .then(hubReceiveWith( + "h2", lookupMail("h2", 2, "early"), INotFound, + h2.toLookupReplyOutput(2, FromIndexer(IFound({ body: Some(tight), height: 3 }))), + )) + .then(deliverToShim(replyMail("h2", 2, WFound({ body: Some(tight), height: 3 })))) + .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(2) })) + .expect(txidAuthenticity) + + /// The per-hub guarantees survive for the honest hub. The Byzantine replica + /// acks a migration it does not queue, and admits one the expiry rule + /// refuses and publishes it late. Both are failures of that replica alone. + /// + /// And the wallet, told ok, has no honest hub holding its first migration: + /// the honest hub's frame was never delivered. Under `DispatchOnly` that is + /// not something replication promises. + run honestReplicaKeepsItsGuaranteesTest = + started + .then(block) + .then(sendToAll(early)) + .then(hubReceiveWith("h2", submitMail("h2", 1, early), INotFound, h2.toAckOutput(1, Admitted))) + .expect(s.toldOk() == Set(early)) + .expect(audit.everQueued.get("h1") == Set() and audit.everQueued.get("h2") == Set()) + .expect(not(ackImpliesQueued) and ackImpliesQueuedForHonestHubs) + .then(block) + .then(hubFlushBeginWith("h1")) + .then(hubFlushBeginWith("h2")) + .then(sendToAll(tight)) + .then(deliverSubmit("h1", 2, tight)) + .then(hubReceiveWith( + "h2", submitMail("h2", 3, tight), INotFound, + { ...h2, queue: Map(tight -> 0) }.toAckOutput(3, Admitted), + )) + .expect(s.acks("h1", ShimAddr) == Set((2, WRefused(WExpiryTooTight)))) + .then(blocks(3)) + .then(hubFlushBeginWith("h1")) + .then(hubFlushBeginWith("h2")) + .expect(audit.offers == Set({ hub: "h2", payload: tight, height: 6, attempt: 0, nth: 0 })) + .expect(not(offeredBeforeExpiry) and offeredBeforeExpiryForHonestHubs) + .expect(conformingFirstOfferBeforeExpiryForHonestHubs) +} diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index 35e0484d..bd8f6039 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -69,6 +69,11 @@ module types { | None => acc }) + /// The id a wallet knows its transaction by, and asks for. For bytes no + /// parser accepts it is a name that no hub or node will ever compute. + pure def walletTxid(payload: Payload): TxId = + payload.txid.unwrapOr(payload.id) + /// What the wallet hands the shim in a `SendTransaction`: a body the shim /// read in full, a body it could not read, or an empty one. type SendInput = Clean(Payload) | Unreadable | EmptyBody From 1dcd8fab9b5e6d52c4e590de58e8c6c3c057bed1 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 13:02:27 +0400 Subject: [PATCH 26/80] test(zeronym): pin the early free-running flush that spends the next epoch --- zeronym/spec/protocol/protocol.qnt | 20 +++++++---- zeronym/spec/protocol/tests/scenariosTest.qnt | 35 +++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 9bfb06f6..35269597 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -335,11 +335,14 @@ module protocol { commit(s.countSend().shimStepped(result, None), WalletSend({ input: input, handedOver: handedOver })), } - action walletSend = { - nondet input = oneOf(SEND_INPUTS) - nondet handedOver = oneOf(0.to(HUB_ORDER.length())) - nondet result = oneOf(shimResults(s.shim, SendTxSInput({ input: input, handedOver: handedOver }))) - walletSendWith(input, handedOver, result) + action walletSend = all { + s.wallet.sends < MAX_REQUESTS, + { + nondet input = oneOf(SEND_INPUTS) + nondet handedOver = oneOf(0.to(HUB_ORDER.length())) + nondet result = oneOf(shimResults(s.shim, SendTxSInput({ input: input, handedOver: handedOver }))) + walletSendWith(input, handedOver, result) + }, } /// The txids of the transactions the wallet has handed the shim. A wallet @@ -358,6 +361,7 @@ module protocol { } action walletGet = all { + s.wallet.gets < MAX_REQUESTS, walletTxids != Set(), { nondet query = oneOf(walletTxids) @@ -666,6 +670,7 @@ module protocol { } action thirdPartyLookup = all { + s.thirdParty.requests < MAX_REQUESTS, s.tpTxids() != Set(), { nondet txid = oneOf(s.tpTxids()) @@ -687,6 +692,7 @@ module protocol { } action thirdPartySubmit = all { + s.thirdParty.requests < MAX_REQUESTS, s.tpPayloads() != Set(), { nondet payload = oneOf(s.tpPayloads()) @@ -770,12 +776,14 @@ module protocol { chainAdvance, chainMine, } - /// The same, during an indexer outage: every flush comes back unjudged. + /// The protocol during an indexer outage: every flush comes back unjudged, + /// while outsiders go on submitting. action outageStep = any { walletSend, walletGet, shimReceive, hubReceive, hubObserveTip, hubTipStale, hubFlushBegin, indexerUnreachable, hubFlushEnd, chainAdvance, + outsiderStep, } // ------------------------------------------------------------------------ diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index b07691c6..a56974ff 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -677,6 +677,41 @@ module staleLagWithSlackScenarios { .then(hubFlushBeginWith("h1")) .expect(audit.offers == Set({ hub: "h1", payload: atFloor, height: 8, attempt: 0, nth: 0 })) .expect(vConformingFirstOfferBeforeExpiry and conformingFirstOfferBeforeExpiry) + + /// This configuration's second migration: built at height 4, expiring at + /// the floor. + pure val lateAtFloor = orchard("late", 4, 11) + + /// Found by simulation; the slack does not cover it. A stale hub's + /// free-running clock reads 6 at true height 4, and the flush scheduled for + /// 6 runs then, with nothing to publish. When the hub sees the tip again the + /// chain is at 5, and admission, which knows the tip and not the schedule's + /// history, counts on the flush at 6. That flush has already happened. The + /// transaction waits for the one at 9, and a second, shorter silence makes + /// that one late too: it is published at 11, with no margin. + /// + /// Neither half is enough alone at these numbers. Without the early flush + /// the transaction goes out at 6; without the second silence, at 9. + run earlyFlushSpendsTheNextEpochTest = + started + .then(3.reps(_ => chainAdvance)) + .then(hubTipStaleWith("h1", 6)) + .then(hubFlushBeginWith("h1")) + .expect(s.height() == 4 and s.hubs.get("h1").lastEpoch == Some(2)) + .then(sendToAll(lateAtFloor)) + .then(block) + .expect(s.hubs.get("h1").cadence == Tracking and s.hubs.get("h1").tip == Some(5)) + .then(deliverSubmit("h1", 0, lateAtFloor)) + .expect(audit.admitted.get(("h1", lateAtFloor)) == { height: 5, tip: 5 }) + .then(blocks(3)) + // Height 8. The boundary at 6 has passed and nothing was flushed. + .expect(audit.offers == Set() and s.queuedAt("h1") == Set(lateAtFloor)) + .then(3.reps(_ => chainAdvance)) + .then(hubTipStaleWith("h1", 11)) + .then(hubFlushBeginWith("h1")) + .expect(conforming(lateAtFloor, MIN_WALLET_EXPIRY) and staleSlackFits) + .expect(audit.offers == Set({ hub: "h1", payload: lateAtFloor, height: 11, attempt: 0, nth: 0 })) + .expect(not(conformingFirstOfferBeforeExpiry)) } module byzIndexerScenarios { From de778a7b50517446d8b2951e5d7f55bca8c32e5a Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 13:02:27 +0400 Subject: [PATCH 27/80] test(zeronym): add the gate for the protocol spec --- zeronym/spec/protocol/.gitignore | 2 + zeronym/spec/protocol/check.sh | 371 +++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 zeronym/spec/protocol/.gitignore create mode 100755 zeronym/spec/protocol/check.sh diff --git a/zeronym/spec/protocol/.gitignore b/zeronym/spec/protocol/.gitignore new file mode 100644 index 00000000..4eba4666 --- /dev/null +++ b/zeronym/spec/protocol/.gitignore @@ -0,0 +1,2 @@ +*.itf.json +_apalache-out/ diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh new file mode 100755 index 00000000..07d01b14 --- /dev/null +++ b/zeronym/spec/protocol/check.sh @@ -0,0 +1,371 @@ +#!/bin/sh +# Check the zeronym protocol specification and assert every expected outcome. +# +# "Holds" here means bounded random simulation: fixed constants, a fixed step +# bound, a fixed number of traces and one seed. It is not a proof and it is +# not exhaustive to any depth. `quint verify` is not run by this script unless +# QUINT_TLC=1 asks for the last tier. +# +# Tiers: +# 1 typecheck every file. +# 2 quint test: the functional layer, the scripted runs, and each +# configuration's assumptions. +# 3 quint run: invariants. "holds" rows are the guarantees, on the +# configurations where they are claimed. "fails" rows are the known gaps +# and the guarantees under the Byzantine component they depend on; such a +# row shows polarity only, and the scripted run in tier 2 carries the cause. +# 3b quint run: witnesses. Every listed state must be reached in at least +# one trace. These runs also re-check the configuration's guarantees, on +# longer traces and under the narrower step relations, which get deeper +# into the protocol than `step` does. +# 5 opt-in, QUINT_TLC=1: the two-state properties, with TLC. Needs Java 21. +# +# A row that starts holding where it is expected to fail, or the reverse, +# means the specification or the prediction changed: read README.md before +# changing the row. +# +# QUINT defaults to `npx @informalsystems/quint@0.33.0`. QUINT_BACKEND=typescript +# skips the Rust evaluator, which is downloaded from GitHub on first use; the +# sample counts and seeds below were settled on the Rust evaluator. +# QUINT_JOBS is how many rows run at once. +set -u + +cd "$(dirname "$0")" +QUINT=${QUINT:-"npx --yes @informalsystems/quint@0.33.0"} +BACKEND=${QUINT_BACKEND:-rust} +SAMPLES=${QUINT_SAMPLES:-2000} +JOBS=${QUINT_JOBS:-4} +SEED=7 +failures=0 + +# Rows run JOBS at a time, each writing its result lines to a file of its own; +# `finish` prints them in the order the rows were written and counts the +# failures. +results=$(mktemp -d) +trap 'rm -rf "$results"' EXIT +queued=0 +running=0 + +job() { + queued=$((queued + 1)) + ("$@") >"$results/$(printf '%04d' "$queued")" 2>&1 & + running=$((running + 1)) + if [ "$running" -ge "$JOBS" ]; then + wait + running=0 + fi +} + +finish() { + wait + running=0 + for file in "$results"/*; do + [ -f "$file" ] || continue + cat "$file" + failures=$((failures + $(grep -c '^FAIL' "$file"))) + rm -f "$file" + done +} + +SPELLS="spells/basicSpells.qnt spells/soup.qnt" +MODULES="types.qnt wire.qnt indexer.qnt hub.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" +FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt" +INSTANCES="baseline byzShim byzHub byzIndexer awaitAck awaitAckByzShim awaitAckByzHub awaitAckByzIndexer replicated replicatedOneByz flakyTip flakyTipNoSlack staleLag staleLagWithSlack" +SCENARIOS="baselineScenarios awaitAckScenarios replicatedScenarios flakyTipScenarios flakyTipNoSlackScenarios staleLagScenarios staleLagWithSlackScenarios byzIndexerScenarios byzHubScenarios" +TRUST="byzShimTrust awaitAckByzShimTrust byzHubTrust awaitAckByzHubTrust byzIndexerTrust replicatedOneByzTrust" + +fail() { + echo "FAIL $1" +} + +# run_tests FILE [MODULE] +run_tests() { + if [ $# -eq 2 ]; then + out=$($QUINT test "$1" --main="$2" --backend="$BACKEND" 2>&1) + else + out=$($QUINT test "$1" --backend="$BACKEND" 2>&1) + fi + status=$? + passing=$(echo "$out" | sed -n 's/^ *\([0-9][0-9]*\) passing.*/\1/p') + if [ "$status" -eq 0 ] && [ -n "$passing" ]; then + echo "ok test ${2:-$1}: $passing passing" + else + echo "$out" | tail -25 + fail "test ${2:-$1}" + fi +} + +# simulate MAIN STEP MAX_STEPS ARGS...: one simulation of $samples traces; the +# output is left in $out. +samples=$SAMPLES +simulate() { + main=$1 step=$2 steps=$3 + shift 3 + out=$($QUINT run instances.qnt --backend="$BACKEND" --main="$main" --step="$step" \ + --max-samples="$samples" --max-steps="$steps" --seed="$SEED" "$@" 2>&1) +} + +# Classified by Quint's verdict line, so a crash, a download failure or a +# misspelt name fails the gate instead of passing as a counterexample. +verdict() { + case $out in + *"[ok] No violation found"*) got=holds ;; + *"[violation] Found an issue"*) got=fails ;; + *) got=none ;; + esac +} + +# holds MAIN INVARIANT...: all of them, together, under `step`. +holds() { + main=$1 + shift + simulate "$main" step 40 --invariants "$@" + verdict + case $got in + holds) echo "ok $main: holds: $*" ;; + fails) + echo "$out" | grep -a -E '^ *❌' | sed 's/^/ /' + fail "$main: expected to hold, violated (among: $*)" + ;; + *) + echo "$out" | tail -20 + fail "$main: quint gave no verdict" + ;; + esac +} + +# fails MAIN STEP MAX_STEPS INVARIANT [TRACES]: violated in some trace of STEP. +fails() { + samples=${5:-$SAMPLES} + simulate "$1" "$2" "$3" --invariant="$4" + verdict + case $got in + fails) echo "ok $1: fails: $4 ($2, $3 steps)" ;; + holds) fail "$1: $4 expected to fail under $2, no violation in $samples traces" ;; + *) + echo "$out" | tail -20 + fail "$1: $4: quint gave no verdict" + ;; + esac +} + +# The names after `--` in "$@", and the names before it. +after_dashes() { + seen=0 + for arg in "$@"; do + if [ "$seen" -eq 1 ]; then printf '%s ' "$arg"; fi + if [ "$arg" = "--" ]; then seen=1; fi + done +} + +before_dashes() { + for arg in "$@"; do + if [ "$arg" = "--" ]; then break; fi + printf '%s ' "$arg" + done +} + +# reaches MAIN STEP MAX_STEPS WITNESS... -- INVARIANT...: every witness is +# reached in at least one trace of STEP, and no invariant is violated on the +# way. +reaches() { + main=$1 step=$2 steps=$3 + shift 3 + witnesses=$(before_dashes "$@") + invariants=$(after_dashes "$@") + # shellcheck disable=SC2086 + simulate "$main" "$step" "$steps" --witnesses $witnesses --invariants $invariants + verdict + case $got in + holds) ;; + fails) + echo "$out" | grep -a -E '^ *❌' | sed 's/^/ /' + fail "$main: an invariant was violated under $step (among: $invariants)" + return + ;; + *) + echo "$out" | tail -20 + fail "$main: witnesses under $step: quint gave no verdict" + return + ;; + esac + for witness in $witnesses; do + count=$(echo "$out" | sed -n "s/^$witness was witnessed in \([0-9][0-9]*\) trace.*/\1/p") + if [ -z "$count" ]; then + fail "$main: witness $witness: no count reported" + elif [ "$count" -eq 0 ]; then + fail "$main: witness $witness never reached under $step in $samples traces" + else + echo "ok $main: reached: $witness ($count of $samples, $step, $steps steps)" + fi + done +} + +typecheck() { + if out=$($QUINT typecheck "$1" 2>&1); then + echo "ok typecheck $1" + else + echo "$out" | tail -20 + fail "typecheck $1" + fi +} + +echo "---- 1 typecheck" +for file in $SPELLS $MODULES $FUNCTIONAL tests/scenariosTest.qnt tests/trustTest.qnt; do + job typecheck "$file" +done +finish +if [ "$failures" -ne 0 ]; then + exit "$failures" +fi + +echo "---- 2 tests" +for file in $SPELLS $FUNCTIONAL; do + job run_tests "$file" +done +for module in $SCENARIOS; do + job run_tests tests/scenariosTest.qnt "$module" +done +for module in $TRUST; do + job run_tests tests/trustTest.qnt "$module" +done +for module in $INSTANCES; do + job run_tests instances.qnt "$module" +done +finish + +echo "---- 3 invariants ($SAMPLES traces, seed $SEED)" + +# The guarantees, where they are claimed. G7 `wellFormed` is checked everywhere. +job holds baseline operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub \ + offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed +job holds byzShim offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed +job holds byzHub operatorBlind txidAuthenticity wellFormed +job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed +job holds awaitAck toldImpliesQueued ackImpliesQueued wellFormed +job holds awaitAckByzShim wellFormed +job holds awaitAckByzHub wellFormed +job holds awaitAckByzIndexer toldImpliesQueued wellFormed +job holds replicated lookupValidityPerHub wellFormed +job holds replicatedOneByz txidAuthenticity ackImpliesQueuedForHonestHubs offeredBeforeExpiryForHonestHubs \ + conformingFirstOfferBeforeExpiryForHonestHubs wellFormed +job holds flakyTip conformingFirstOfferBeforeExpiry wellFormed +job holds flakyTipNoSlack wellFormed +job holds staleLag wellFormed +job holds staleLagWithSlack wellFormed + +# The trust matrix: each guarantee fails once the component it depends on is +# Byzantine. +job fails byzShim step 40 operatorBlind +job fails byzShim step 40 queuedBytesConfidential +job fails byzShim step 40 txidAuthenticity +job fails byzShim step 40 lookupValidityPerHub +job fails awaitAckByzShim step 40 toldImpliesQueued +job fails byzHub step 40 queuedBytesConfidential +job fails byzHub step 40 lookupValidityPerHub +job fails byzHub step 40 ackImpliesQueued +job fails byzHub quietStep 40 offeredBeforeExpiry +job fails byzHub quietStep 40 conformingFirstOfferBeforeExpiry +job fails awaitAckByzHub step 40 toldImpliesQueued +job fails byzIndexer step 40 queuedBytesConfidential +job fails byzIndexer step 40 lookupValidityPerHub +job fails byzIndexer quietStep 40 offeredBeforeExpiry +job fails byzIndexer quietStep 80 conformingFirstOfferBeforeExpiry +job fails replicatedOneByz step 40 queuedBytesConfidential +job fails replicatedOneByz step 40 lookupValidityPerHub + +# The known gaps, with every component honest. +job fails baseline quietStep 40 statusNeverRegresses # K2 +job fails replicated quietStep 40 statusNeverRegresses # K2 +job fails flakyTip quietStep 40 offeredBeforeExpiry # K3 +job fails flakyTipNoSlack quietStep 40 conformingFirstOfferBeforeExpiry # K3' +job fails staleLag quietStep 40 offeredBeforeExpiry # K4 +job fails staleLag quietStep 80 conformingFirstOfferBeforeExpiry # K4 +job fails baseline step 40 ackedIsHeldOrOffered # K5 +job fails awaitAck step 40 ackedIsHeldOrOffered # K5 +job fails staleLag outageStep 80 conformingEveryOfferBeforeExpiry # K6 + +# Predicted to hold, observed to fail: the stale slack does not give G6b. +# See "Findings" in README.md. Simulation finds this about once in ten thousand +# traces, so the row has its own, larger, trace count; the scripted run +# `earlyFlushSpendsTheNextEpochTest` is the evidence that does not depend on it. +job fails staleLagWithSlack quietStep 40 conformingFirstOfferBeforeExpiry 15000 + +finish + +echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" + +BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed" + +# W4 (all five refusals), W8, W17, K1a, K1b, and the antecedents of G1, G2, G8. +job reaches baseline step 40 \ + wRefusedTipStale wRefusedDraining wRefusedTooLarge wRefusedExpiryTooTight wRefusedFull \ + wQueuedDisclosed wThirdPartyPayloadQueued wToldRefusedEverywhere wToldNeverDelivered \ + vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ + -- $BASELINE_HOLDS +# W1, W2, W3, W5, W6, W9, and the antecedents of G3, G4, G6a, G6b. +job reaches baseline quietStep 80 \ + wPending wTxInMempool wTxMined wRequeued wDroppedExpired wUnparseableMissed \ + vTxidAuthenticity vLookupValidityPerHub vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry \ + -- $BASELINE_HOLDS +# W7, W12. +job reaches baseline outageStep 80 \ + wDroppedExhausted wQueueOverCapacity \ + -- $BASELINE_HOLDS + +job reaches byzShim quietStep 40 \ + vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry vAckImpliesQueued \ + -- offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed +# W16, both halves. +job reaches byzHub quietStep 40 \ + vOperatorBlind vTxidAuthenticity wTwinServed wFalseHeightServed \ + -- operatorBlind txidAuthenticity wellFormed +# W15. +job reaches byzIndexer quietStep 40 \ + vOperatorBlind vTxidAuthenticity vAckImpliesQueued wPrematureFlush \ + -- operatorBlind txidAuthenticity ackImpliesQueued wellFormed +job reaches awaitAck quietStep 40 \ + vToldImpliesQueued vAckImpliesQueued \ + -- toldImpliesQueued ackImpliesQueued wellFormed +job reaches awaitAckByzIndexer quietStep 40 \ + vToldImpliesQueued \ + -- toldImpliesQueued wellFormed +# W13, K1c. +job reaches replicated step 40 \ + wFailoverAnswered wToldPrefixOnly \ + -- lookupValidityPerHub wellFormed +# W14. +job reaches replicated quietStep 80 \ + vLookupValidityPerHub wPublishedByTwoHubs \ + -- lookupValidityPerHub wellFormed +job reaches replicatedOneByz quietStep 40 \ + vTxidAuthenticity vAckImpliesQueued vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry \ + -- txidAuthenticity ackImpliesQueuedForHonestHubs offeredBeforeExpiryForHonestHubs \ + conformingFirstOfferBeforeExpiryForHonestHubs wellFormed +job reaches flakyTip quietStep 40 \ + vConformingFirstOfferBeforeExpiry vConformingOfferAdmittedBehind \ + -- conformingFirstOfferBeforeExpiry wellFormed +# W18. +job reaches staleLag quietStep 40 \ + wEarlyFreeRunFlush \ + -- wellFormed +finish + +# Tier 5. Not part of the default gate, not run in CI, and never executed while +# this script was written: the verdict strings matched below are what Quint +# prints for the simulator, and are untested against the TLC backend. +if [ "${QUINT_TLC:-0}" = "1" ]; then + echo "---- 5 two-state properties (TLC)" + out=$($QUINT verify instances.qnt --backend=tlc --main=baseline \ + --temporal=chainMonotone,neverEvict,drainIsFinal 2>&1) + verdict + case $got in + holds) echo "ok baseline: holds: chainMonotone neverEvict drainIsFinal (TLC)" ;; + *) + echo "$out" | tail -40 + fail "baseline: two-state properties under TLC" + ;; + esac +fi + +exit "$failures" From ae11e6050ffbf0cf06b11ebbc76a4a20d088185a Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 13:06:18 +0400 Subject: [PATCH 28/80] test(zeronym): document the protocol spec and what it found --- zeronym/spec/protocol/README.md | 767 ++++++++++++++++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 zeronym/spec/protocol/README.md diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md new file mode 100644 index 00000000..cd60fbc9 --- /dev/null +++ b/zeronym/spec/protocol/README.md @@ -0,0 +1,767 @@ +# The zeronym protocol, specified in Quint + +A specification of the protocol between a wallet, the shim in front of an +operator's indexer, the hubs that batch diverted transactions, and the chain. +It is written from the protocol, not from the code's structure: it says what a +wallet can rely on, which components each guarantee trusts, where the known +gaps are, and it checks each of those statements. + +It is separate from `zeronym/spec/divert.qnt`, which it does not replace or +modify. + +## What "holds" means here + +**Bounded random simulation.** Every "holds" below was produced by +`quint run`: fixed constants, at most 40 or 80 steps per trace, 2000 random +traces per run, one seed. It is not a proof and it is not exhaustive to any +depth. A property that "holds" is one no sampled trace violated. + +**`quint verify` has not been run**, on Apalache or on TLC, by anyone, on any +part of this specification. The commands are given under +["Bounded model checking (not run)"](#bounded-model-checking-not-run). + +**The two-state properties A1-A3 are typechecked only.** Their verdicts are +unknown. + +Statements that do not rest on sampling are the ones backed by `quint test`: +the functional properties F1-F14, which are exhaustive over small finite +universes, and the scripted runs, each of which is one concrete execution. + +## Running it + +```sh +sh zeronym/spec/protocol/check.sh +``` + +Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; +set `QUINT=quint` to use an installed one). The two-state properties use the +action-property syntax introduced in 0.33, so 0.32 does not typecheck the +specification. No Java is needed. + +| Tier | What | Command | Expectation | +|---|---|---|---| +| 1 | typecheck | `quint typecheck` on every file | ok | +| 2 | tests | `quint test` on the spells, the four functional test files, each scenario and trust module, each configuration | all pass | +| 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` | "holds" rows hold; "fails" rows are violated | +| 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | +| 5 | two-state properties | `QUINT_TLC=1`, opt-in, **never run** | unknown | + +Measured on the machine it was written on (Apple silicon, Quint's Rust +evaluator): 3 min 24 s wall with four rows at a time (`QUINT_JOBS=4`, the +default), about 11 minutes of CPU. `QUINT_SAMPLES` changes the trace count. +The rarest witnesses are reached in only 3 to 6 of the 2000 traces, so a lower +count risks losing them. One "fails" row has a count of its own, 15000; see +finding 2. + +Tier 3 "fails" rows and tier 3b run under `step` or under one of two narrower +relations, `quietStep` (no faults, no outsiders) and `outageStep` (the indexer +is unreachable throughout). Each is a part of `step`, so a state or a violation +found under one is reachable under `step`. Uniform random choice over `step` +rarely gets a transaction as far as a block in 40 steps; the narrower relations +do. Tier 3b re-checks each configuration's guarantees on those deeper traces. + +## Scope + +### Protocol facts the specification rests on + +| # | Fact | Source | +|---|---|---| +| S1 | Shim classifies `SendTransaction` by presence of Orchard actions; unparseable folds into "treat as migration" | `zeronym/shim/src/classify.rs:70-101`, `:246-248` | +| S2 | Shim-unparseable includes trailing bytes, which the hub's parser accepts, so "shim cannot parse" does not imply "hub computes no txid" | `zeronym/shim/src/classify.rs:269-283`, `zeronym/hub/src/queue.rs:281-289` | +| S3 | Divert arms: unreadable body fails closed; empty body INVALID_ARGUMENT; too large RESOURCE_EXHAUSTED; hub unreachable UNAVAILABLE; never the operator | `zeronym/shim/src/intercept.rs:180-283` | +| S4 | With a hub configured every `GetTransaction` goes to the hub; shim keeps no per-migration state | `zeronym/shim/src/intercept.rs:305-314`, `:58-64` | +| S5 | Lookup reply arms, in order: found/height 0/empty relayed as pending; found served only if the bytes' txid equals the query (L4), else NOT_FOUND; not-found; error fails closed | `zeronym/shim/src/intercept.rs:370-422`, `:453-466` | +| S6 | Two transports behind one enum: HTTP (verdict returned synchronously) and Nym | `zeronym/shim/src/hub.rs:277-328` | +| S7 | Nym submit is dispatch-only: success once one frame is handed over, fresh nonce per hub address, sent to every address; the ack is never awaited | `zeronym/shim/src/nym.rs:595-703` | +| S8 | Nym lookup tries addresses in turn; only a timeout moves on; fresh nonce per attempt | `zeronym/shim/src/nym.rs:708-797` | +| S9 | Correlation by nonce only; unknown nonce dropped; wrong reply kind for a known nonce ignored, waiter stays | `zeronym/shim/src/nym.rs:1040-1077`, `zeronym/hub/src/wire.rs:22-27` | +| S10 | Hub admit: tip-stale gate, then draining, too large, expiry survives next scheduled flush, payload-hash dedup, byte and entry budget. Admission never asks a node | `zeronym/hub/src/server.rs:343-392`, `:299-303`, `zeronym/hub/src/queue.rs:256-340` | +| S11 | Queue identity is `sha256(bytes)`; dedup is against resident entries only (`inner.entries.contains_key`), and a flush removes every entry (`inner.entries.drain()`); accepted entries are not put back. So bytes that were published are admitted again if resubmitted | `zeronym/hub/src/queue.rs:17-22`, `:308-310`, `:358-359`, `zeronym/hub/src/batcher.rs:389` | +| S12 | Hub lookup: queue first (found, height 0, no bytes), then indexer; unparseable entries never hit; flush window answers not-found, deliberately | `zeronym/hub/src/server.rs:403-462`, `zeronym/hub/src/queue.rs:455-475` | +| S13 | Lookup and submit to the hub are unauthenticated; the hub's Nym address is public with no ACL; the queue-hit reply discloses that a txid is queued | `zeronym/hub/src/server.rs:413-437`, `zeronym/hub/src/nym.rs:220-227` | +| S14 | Flush fires only when `cadence_height / flush_interval` exceeds the last flushed epoch; first observation adopts the epoch without flushing; shutdown flushes once more | `zeronym/hub/src/batcher.rs:316-335` | +| S15 | Flush drains everything, broadcasts, then: accepted / already-known leave; rejected dropped; retryable requeued | `zeronym/hub/src/batcher.rs:358-422`, `zeronym/hub/src/chain.rs:129-134` | +| S16 | Requeue: resident copy wins; attempts + 1; dropped if it no longer survives the next flush or attempts exceed 8; may overrun the byte budget; reports `held` / `dropped_expired` / `dropped_exhausted` | `zeronym/hub/src/queue.rs:186-199`, `:366-422`, `:96` | +| S17 | Tip is the max over answering endpoints; a regression within 10 blocks is followed; staleness stops admission only | `zeronym/hub/src/chain.rs:183-201`, `zeronym/hub/src/batcher.rs:161-205`, `:222-225` | +| S18 | Budget inequality `flush_interval + mining_margin + delivery_lag <= min_wallet_expiry` asserted at startup | `zeronym/hub/src/batcher.rs:93-118` | +| S19 | Drain closes admission before the final flush; the queue is RAM-only | `zeronym/hub/src/main.rs:142-177`, `zeronym/hub/src/queue.rs:226-243`, `zeronym/hub/src/batcher.rs:337-347` | +| S20 | Wire: four fixed-size frames; reply dispositions found / not_found / error; not_found or error with a payload is a decode error; `Draining` shares `QueueFull`'s code | `zeronym/hub/src/wire.rs:29-59`, `:278-290`, `:531-569` | +| S21 | Hub drops lookups past 64 in flight, replies older than 60 s, acks when the driver queue is full | `zeronym/hub/src/nym.rs:54`, `:75`, `:171-213`, `:285-292` | +| S22 | Indexer lookup answer is forwarded verbatim, so a zero `RawTransaction` is byte-identical to the queue-hit sentinel | `zeronym/hub/src/server.rs:445-451`, `zeronym/hub/src/chain.rs:284-290` | +| S23 | No attestation or STEVE handshake exists in code | `zeronym/README.md:88` | +| S24 | Replicate, never fail over: every hub that receives a migration queues and broadcasts it | `zeronym/README.md:86`, `zeronym/shim/src/nym.rs:602-647` | +| S26 | Shipped constants: `FLUSH_INTERVAL_BLOCKS = 20`, `MINING_MARGIN = 4`, `MAX_DELIVERY_LAG = 6`, `MIN_WALLET_EXPIRY = 40`, `REORG_ALLOWANCE = 10`. The slack `40 - (20 + 4 + 6) = 10` equals the reorg allowance exactly. `BatchParams::validate` asserts only the three-term sum; nothing asserts the four-term one | `zeronym/hub/src/batcher.rs:40-59`, `:101-113` | +| S27 | Lookup starts at a rotating cursor, so consecutive polls start at different hubs; a `NotFound` from the first hub asked is final | `zeronym/shim/src/nym.rs:756-793` | +| S28 | Indexer folds are asymmetric: tip is the max over answering endpoints (one endpoint can only win high; a low tip needs every endpoint); lookup returns the first `Found` in endpoint order (one endpoint suffices to inject an answer); publish takes the best verdict | `zeronym/hub/src/chain.rs:183-201`, `:305-319`, `:517-532` | +| S29 | Submit sweep tells the wallet ok when at least one frame was handed over, even if the loop broke before later addresses | `zeronym/shim/src/nym.rs:673-702` | +| S30 | L4 deserialises the returned bytes, computes their txid and compares it with the queried hash in both byte orders. It compares nothing else: not the bytes, not the height | `zeronym/shim/src/intercept.rs:453-466` | +| S31 | HTTP transport: one `SocketAddr`; hub answers `"accepted"` for both a fresh admission and a duplicate, so the client's `"already_known"` arm has no source; a 200 lookup without the octet-stream content type and `x-tx-height` is an error | `zeronym/shim/src/hub.rs:69-72`, `:201-208`, `:259-263`, `zeronym/hub/src/server.rs:741-747` | +| S32 | Two hub clocks. Admission and requeue use the observed height. The flush epoch uses the cadence height, which equals the observed height until no forward move has been seen for `TIP_STALE_AFTER` (15 min, 12 blocks at the nominal 75 s) and then free-runs at the nominal rate. The code comment claims the free-running clock runs ahead of the true height, "the safe direction"; nothing enforces it. Only the cadence loop (and startup) calls `observe`, and it does so before, never during, a flush | `zeronym/hub/src/batcher.rs:59-71`, `:227-247`, `:307-325`, `:414-422`, `zeronym/hub/src/main.rs:62` | +| S25 | The operator can recover a diverted transaction's txid from transparent-pool queries, so a txid can be known to an outsider before publication | `zeronym/README.md:34` | + +Two comments in the implementation are quoted in `properties.qnt` next to the +definitions they justify. + +- The accepted disclosure (W8), `zeronym/hub/src/server.rs`, in `Hub::lookup`: + "What this does NOT close: the 200-versus-NotFound distinction still + discloses that a given txid is queued here. Closing that too means answering + NotFound, which costs a wallet the ability to tell "pending" from "never + seen". That is a product decision, not a code one, and it is left open + deliberately." +- The flush window (`truth`, used by G4), same file, on `Hub::lookup`: "Note + the flush-in-flight gap: `flush()` drains the queue before `broadcast_batch` + has reached the indexer, so a lookup in that window gets a queue miss then an + indexer NOT_FOUND for a transaction it was told height-0 about seconds + earlier. Wallets poll on multi-second intervals and tolerate a transient + NOT_FOUND; a resubmit is harmless (deduped pre-flush, already-known + post-flush). Holding entries until broadcast returns would extend how long + the hub remembers a txid, which is the wrong trade." + +### In the model + +| Area | What is modelled | Why | +|---|---|---| +| Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4. `divert.qnt` omits it | +| Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; waiter kinds; lookup starting at a nondeterministic hub (the cursor, S27), timeout and failover; submit fan-out including the prefix send (S29); submit mode `DispatchOnly \| AwaitVerdict` | S6-S9. `AwaitVerdict` is the one HTTP difference this model represents (who the wallet hears from); the others are listed out of scope | +| Hub | lifecycle; admission with all five refusals; queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash | S10-S19 | +| Chain / indexer | height; per-txid status; what the indexer has been offered; verdict and lookup-answer relations | S15, S22 | +| Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation; frame size classes | S20, S22 | +| Trust | role `Honest \| Byzantine` for shim, hub, hub indexer | S23 | +| Third party | a client of the hubs' public, unauthenticated address: looks up txids it knows; submits payloads it has learned and payloads of its own making; its payload knowledge is derived from what it can observe | S13, S25 | +| Network | drop, duplicate, delay, reorder; cannot forge | | +| Replication | `HUBS` is a set; one shared chain | S24 | +| Tip | `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, `REORG_ALLOWANCE`, `STALE_WINDOW` and the wallet expiry floor as constants | S17, S26, S32 | + +### Out of the model + +| Item | Reason | +|---|---| +| Attestation, PCRs, TLS, STEVE, keymaker quorum | No in-protocol messages exist (S23). Represented by the roles | +| Mixnet internals: SURBs, Sphinx, cover traffic, gateways, throttling; shim client rotation supervisor (`zeronym/shim/src/nym.rs:942-1024`); both `nym_driver.rs` | Protocol-visible effect is loss and delay | +| Hub lookup concurrency bound, reply deadline, dropped acks (S21) | Refinements of "the network lost the message" | +| Wall-clock time | The staleness window is counted in blocks (`STALE_WINDOW`), and a free-running cadence height is chosen by the environment under the named assumption `freeRunNotSlowerThanChain`; there is no clock | +| Multiple indexer endpoints and their folds | One abstract indexer per model stands for all of a hub's endpoints. Because the folds are asymmetric (S28), this document states for each Byzantine-indexer behaviour whether one lying endpoint suffices or all must lie | +| Wire codecs `ZNS1` / `ZNA1` / `ZNL1` / `ZNR1` and the golden vectors (`zeronym/hub/src/wire.rs:576-579`) | Byte layouts are scoped out and are pinned by the Rust tests in both crates; the abstract `render` / `interpretReply` layer is the level this spec works at. The spec does not claim to bind the codec | +| HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | +| Two or more hubs under `AwaitVerdict` | Does not exist in code: HTTP is one address (S31) and Nym never awaits the ack (S7). `awaitVerdictSingleHub` stays an assumption | +| Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | +| Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties. Only the pure lemma "frame size is independent of content" is stated | +| Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | +| Forward-only shim, transparent-pool RPCs, health / address / attestation endpoints, DoS bounds, logging | Not divert-protocol state | +| More than one Byzantine component at once | The trust matrix is single-fault | +| A model-based test harness for the Rust | Later work; see "Model-based testing, later" | + +### Assumptions + +- **Roles.** The shim and the hubs run in enclaves and are honest in the + baseline. The shim, each hub and the hubs' indexer can each be made + Byzantine, one at a time. A Byzantine component draws its transitions from a + wider relation than the honest one; no message, state field or observation + records which it drew. +- **Network.** May lose, duplicate, delay and reorder frames. Cannot forge or + read them. +- **Third party.** A client of the hubs' public address. It looks up txids it + knows and submits payloads it has learned or made. It cannot read or forge + frames, so it does not know a nonce and cannot answer the shim. +- **Nonces** are unique. A counter stands for an unguessable value. +- **Chain.** A transaction's status only moves forward: no reorg of an included + transaction, no mempool eviction. The operator's indexer publishes nothing. +- **Tip.** `TipTimely`: every running hub observes each block before the next, + and a due flush has begun before the next block. `TipMayRegress`: a tip + report may trail the chain by up to `REORG_ALLOWANCE`. `TipMayLag`: a hub may + hear nothing for a while, and is stale once the silence reaches + `STALE_WINDOW` blocks; a stale hub's free-running clock is assumed never + behind the chain (`freeRunNotSlowerThanChain`) and at most one flush interval + ahead of it. +- **Wallets.** A supported ("conforming") wallet sets an expiry at least + `MIN_WALLET_EXPIRY` after the height it builds at, and its frame reaches a + hub within `DELIVERY_LAG` blocks. A wallet asks only about transactions it + has sent. +- **Honest indexer.** Answers lookups from chain state or "unavailable". A + broadcast may always be rejected or left unjudged; it is accepted only if a + node would take it, and reported already-known only if the chain has it. +- **Time.** There is no clock. A timeout may happen at any moment; the + staleness window is counted in blocks. + +## State machines + +### Shim: `SendTransaction` routing + +```mermaid +stateDiagram-v2 + [*] --> Inspect + Inspect --> Forwarded: Clean and class PassThrough + Inspect --> FailClosed: Unreadable or EmptyBody + Inspect --> Framing: Clean and class OrchardTouching or Unparseable + Framing --> FailClosed: oversize + Framing --> Dispatched: DispatchOnly, frames to a non-empty prefix of the hubs, fresh nonce each + Framing --> FailClosed: DispatchOnly, no frame handed over + Framing --> AwaitingAck: AwaitVerdict + Dispatched --> ToldOk + AwaitingAck --> ToldOk: Ack accepted + AwaitingAck --> ToldRejected: Ack refused + AwaitingAck --> FailClosed: timeout + Forwarded --> [*] + ToldOk --> [*] + ToldRejected --> [*] + FailClosed --> [*] +``` + +### Shim: lookup request lifecycle (one `GetTransaction`) + +```mermaid +stateDiagram-v2 + [*] --> Awaiting: send Lookup to the hub the cursor points at, fresh nonce + Awaiting --> Awaiting: timeout and hubs remain, fresh nonce to next hub + Awaiting --> Awaiting: wrong-kind or unknown-nonce frame ignored + Awaiting --> Pending: reply found, height 0, no body + Awaiting --> Tx: reply found, body txid equals query + Awaiting --> NotFound: reply not_found, or found that fails L4 + Awaiting --> Unavailable: reply error, or timeout on last hub + Pending --> [*] + Tx --> [*] + NotFound --> [*] + Unavailable --> [*] +``` + +### Hub: lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Down + Down --> Starting: restart, queue empty, no tip, no epoch + Starting --> Running: first tip observed, epoch adopted without a flush + Running --> Stale: no forward tip progress + Stale --> Running: tip advances + Running --> Draining: shutdown signal + Stale --> Draining: shutdown signal + Draining --> Stopped: final flush done, leftovers lost + Starting --> Down: crash + Running --> Down: crash, queue and in-flight batch lost + Stale --> Down: crash, queue and in-flight batch lost + Draining --> Down: crash + Stopped --> Down +``` + +### Hub: flush cycle + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Broadcasting: tip epoch exceeds last flushed epoch, or draining; whole queue moves in flight + Idle --> Idle: nothing queued, epoch recorded + Broadcasting --> Broadcasting: indexer returns one entry's verdict + Broadcasting --> Idle: all verdicts in; requeue retryable entries; record epoch +``` + +### Hub: per-payload entry lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Refused: admit fails (TipStale, Draining, TooLarge, ExpiryTooTight, Full) + Refused --> Absent + Absent --> Queued: admit + Queued --> Queued: same bytes again (duplicate) + Queued --> InFlight: flush begins + InFlight --> Published: verdict Accepted or AlreadyKnown + InFlight --> Rejected: verdict Rejected + InFlight --> Queued: Retryable, still survives next flush, attempts within bound, no resident copy + InFlight --> DroppedExpired: Retryable, no longer survives next flush + InFlight --> DroppedExhausted: Retryable, attempts over bound + Queued --> Lost: crash + InFlight --> Lost: crash + Published --> Absent + Rejected --> Absent + DroppedExpired --> Absent + DroppedExhausted --> Absent + Lost --> Absent +``` + +`Absent --> Queued` is reachable again after `Published` (S11). + +### Chain: per-txid status (environment) + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Mempool: a broadcast is accepted + Mempool --> Mined: included at a height + Mined --> [*] +``` + +### Network: one message in the soup + +```mermaid +stateDiagram-v2 + [*] --> Sent: added to the soup, never removed + Sent --> Sent: delivered to its destination (any number of times, any order) + Sent --> [*]: never delivered (loss) +``` + +### Third party: knowledge of one transaction + +```mermaid +stateDiagram-v2 + [*] --> Nothing + Nothing --> KnowsTxid: learns a txid out of band (S25) + KnowsTxid --> KnowsQueued: Lookup answered found, height 0, no body + KnowsTxid --> KnowsPayload: payload published on chain + KnowsQueued --> KnowsPayload: payload published on chain + Nothing --> KnowsPayload: payload published on chain + KnowsPayload --> KnowsPayload: may resubmit the payload to any hub + Nothing --> Nothing: may submit payloads of its own making to any hub +``` + +### Encoding: hub outcome to wallet observation + +```mermaid +flowchart LR + QH[QueueHit] -->|render| S["found, height 0, no body"] + IZ["indexer answers found, height 0, no body; an honest hub forwards it unchanged"] -->|render| S + IF["indexer: found p at h"] -->|render| F["found, h, p"] + INF[indexer: not found] -->|render| NF[not_found] + IU[indexer: unavailable] -->|render| E[error] + S -->|interpretReply q| P[Pending] + F -->|"interpretReply q, txid(p) = q"| T[Tx p] + F -->|"interpretReply q, txid(p) != q"| N[NotFound] + NF -->|interpretReply q| N + E -->|interpretReply q| U[Unavailable] +``` + +The zero-body indexer answer is not something the honest indexer relation produces, but the honest hub and honest shim pass it through (S22), and one endpoint out of several is enough to inject it (S28). It is therefore reachable only in `byzIndexer`, where "Byzantine indexer" includes "one misbehaving endpoint". + +## Layout + +Only `protocol.qnt` declares a constant or a variable. Every other module is +pure. + +| File | Module | Owns | +|---|---|---| +| `spells/basicSpells.qnt` | `basicSpells` | `Option`, and a few set and map helpers, each with its test | +| `spells/soup.qnt` | `soup` | The message soup: `Envelope[p, m]`, `Soup[p, m]`, `send`, `sendAll`, `inbox`, `outbox` | +| `types.qnt` | `types` | The vocabulary: payloads, verdicts, refusals, roles, observations, `Result[s, o]`, `Config` | +| `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply`, `sizeOf` | +| `indexer.qnt` | `indexer` | The chain and indexer as a relation: honest and Byzantine outputs, and their effect | +| `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | +| `shim.qnt` | `shim` | `shim(state, input)`; routing, the lookup sweep, reply correlation; `byzShimResults` | +| `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | +| `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | +| `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, A1-A3, the run vocabulary | +| `instances.qnt` | `configs`, then one module per configuration | The fourteen configurations | +| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14 | +| `tests/scenariosTest.qnt` | one module per configuration used | Witnesses and pinned gap causes | +| `tests/trustTest.qnt` | one module per Byzantine configuration | One run and one control per "required" cell | + +```mermaid +flowchart BT + soup --> basicSpells + types --> basicSpells + wire --> types + indexer --> types + hub --> types + shim --> wire + state --> soup + state --> wire + state --> indexer + state --> hub + state --> shim + properties --> state + protocol --> properties + instances --> protocol + tests --> instances + tests --> properties +``` + +### Components as functions + +Each component is one total function from its state and one input to its next +state and one output. An input that is invalid in the current state returns an +error output and leaves the state alone. The state machine holds no protocol +logic: a step picks an input, calls the function, and puts the output where it +goes. + +| Component | Inputs | Outputs | Seam in the implementation | +|---|---|---|---| +| `hub` | `SubmitHInput`, `LookupHInput` (with the indexer's answer), `TipHInput(height)`, `StaleHInput(estimate)`, `FlushDueHInput`, `VerdictHInput`, `FlushDoneHInput`, `DrainHInput`, `CrashHInput`, `RestartHInput` | `AckOutput`, `LookupReplyOutput`, `BroadcastOutput`, `RequeuedOutput`, `NoHubOutput`, `HubErrorOutput` | `Hub::admit`, `Hub::lookup` (`hub/src/server.rs`), `run_listener` (`hub/src/nym.rs`), `TipTracker::observe`, `cadence_height`, `flush` (`hub/src/batcher.rs`), `Queue::requeue`, `Queue::begin_draining` (`hub/src/queue.rs`) | +| `shim` | `SendTxSInput` (with how many hub addresses take a frame), `GetTxSInput` (with where the cursor points), `FrameSInput`, `LookupTimeoutSInput`, `AckTimeoutSInput` | `ForwardOutput`, `DivertedOutput`, `SendDoneOutput`, `LookupSentOutput`, `LookupDoneOutput`, `NoShimOutput`, `ShimErrorOutput` | `send_transaction`, `divert`, `get_transaction` (`shim/src/intercept.rs`), `NymHandle::submit`, `get_transaction`, `deliver` (`shim/src/nym.rs`) | +| indexer | `BroadcastIInput`, `LookupIInput`, `AdvanceIInput`, `MineIInput` | `VerdictOutput`, `AnswerOutput`, `NoIndexerOutput` | the mock indexer in `hub/tests/common/mod.rs` | + +### Roles + +`ROLES` gives the shim, each hub and the indexer a role. An honest component +takes exactly the transition its function gives. A Byzantine one takes any +member of a finite set that contains it (F12): + +- **Byzantine hub.** Any ack for a submission it receives, with the payload + queued or not, whatever admission says. Any reply to a lookup: a queue hit, + not found, error, or found with no body or any payload that exists, at any + height. It keeps the honest flush schedule. +- **Byzantine shim.** Any answer to the wallet for a send or a lookup. Any + transaction handed to the operator. A frame carrying any payload to any hub. +- **Byzantine indexer.** Any verdict, with the transaction relayed to the + network or not. Any lookup answer built from a payload it was offered, one + the chain published, or a twin of either. Any tip up to `MAX_HEIGHT`. +- Any of them may disclose a payload it has seen (`byzDisclose`). + +A hub folds several indexer endpoints into one answer, and the folds are not +symmetric: the tip is the maximum over endpoints, a lookup takes the first +"found", a broadcast takes the best verdict. So **one** misbehaving endpoint is +enough to raise the tip, inject a lookup answer or change a verdict, while +lowering or freezing the tip takes **every** endpoint. Each indexer cell below +says which it needs. + +### Configurations + +One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields +(`PAYLOADS`, `FLUSH_INTERVAL`, `ROLES`, `TIP`, ...). + +| Module | Hubs | Submit mode | Roles (shim / hubs / indexer) | Tip | +|---|---|---|---|---| +| `baseline` | 1 | `DispatchOnly` | H / H / H | timely | +| `byzShim` | 1 | `DispatchOnly` | **B** / H / H | timely | +| `byzHub` | 1 | `DispatchOnly` | H / **B** / H | timely | +| `byzIndexer` | 1 | `DispatchOnly` | H / H / **B** | timely for honest reports | +| `awaitAck` | 1 | `AwaitVerdict` | H / H / H | timely | +| `awaitAckByzShim` | 1 | `AwaitVerdict` | **B** / H / H | timely | +| `awaitAckByzHub` | 1 | `AwaitVerdict` | H / **B** / H | timely | +| `awaitAckByzIndexer` | 1 | `AwaitVerdict` | H / H / **B** | timely | +| `replicated` | 2 | `DispatchOnly` | H / H, H / H | timely | +| `replicatedOneByz` | 2 | `DispatchOnly` | H / H, **B** / H | timely | +| `flakyTip` | 1 | `DispatchOnly` | H / H / H | may regress | +| `flakyTipNoSlack` | 1 | `DispatchOnly` | H / H / H | may regress; `reorgSlackFits` false | +| `staleLag` | 1 | `DispatchOnly` | H / H / H | may lag; `staleSlackFits` false, as shipped | +| `staleLagWithSlack` | 1 | `DispatchOnly` | H / H / H | may lag; `staleSlackFits` true | + +The schedule is the shipped one scaled down, keeping the relations between the +numbers: + +| | Interval | Margin | Delivery lag | Reorg allowance | Staleness window | Expiry floor | +|---|---|---|---|---|---|---| +| Shipped | 20 | 4 | 6 | 10 | 12 blocks (15 min at 75 s) | 40 | +| Model | 3 | 1 | 1 | 1 | 3 | 6 | + +The slack `floor - (interval + margin + lag)` equals the reorg allowance in +both (10 and 1). `interval + margin + lag + (window - 1)` exceeds the floor by +one in both (41 > 40, 7 > 6). `flakyTipNoSlack` uses a floor of 5 and +`staleLagWithSlack` a floor of 7. Also: at most 2 requeues, room for 2 entries, +heights up to 12, at most 3 sends and 3 lookups by the wallet and 3 requests by +the third party. + +Each configuration has an `assumptionsTest`. The simulator does not enforce +`assume`, so that test is the check that counts. + +## Properties + +### Functional properties (`quint test`, exhaustive over small universes) + +| Id | Statement | Test | +|---|---|---| +| F1 | `interpretReply(render(o), q) == meaning(o, q)` for every outcome a queue or an honest indexer produces | `wireTest::renderThenInterpretIsMeaningTest` | +| F2 | The documented collision: a queue hit and an indexer's "found, height 0, no body" render to the same reply. `render` is injective on honest outcomes | `wireTest::sentinelCollisionTest` | +| F3 | The shim serves a transaction only if its txid is the one asked for. A twin is served; the height is passed through unchecked | `wireTest::servedOnlyOnMatchingTxidTest` | +| F4 | An error never becomes "not found" | `wireTest::errorIsNeverNotFoundTest` | +| F5 | The shim forwards only cleanly read pass-through transactions | `shimTest::onlyPassThroughIsForwardedTest` | +| F6 | A frame's size depends on its kind only | `wireTest::sizeIsIndependentOfContentTest` | +| F7 | Under the startup budget, a conforming payload arriving within the delivery lag passes the expiry check. This is about admission at one tip, not about when the flush happens | `hubTest::conformingTimelyPayloadIsAdmissibleTest` | +| F8 | The admission decision table, in the implementation's order | `hubTest::admissionDecisionTableTest` | +| F9 | Requeue, entry by entry, and the counts it reports | `hubTest::requeueTest` | +| F10 | Draining and full are one refusal on the wire | `wireTest::ackRenderingTest` | +| F11 | `hub` and `shim` are total; an invalid input returns an error and changes nothing | `hubTest::totalityTest`, `shimTest::totalityTest` | +| F12 | Each Byzantine relation contains the honest transition | `byzantineContainsHonestTest` in `hubTest`, `shimTest`, `indexerTest` | +| F13 | An accepted ack is given only for a payload the hub then holds; a Byzantine hub can do otherwise | `hubTest::ackImpliesQueuedTest`, `hubTest::byzantineHubTest` | +| F14 | The tip rule: first observation adopted; forward followed; a drop within the allowance followed; a larger drop ignored | `hubTest::tipRuleTest` | + +### Guarantees + +| Id | Name | What it says | +|---|---|---| +| G1 | `operatorBlind` | Everything the shim hands the operator is a pass-through transaction | +| G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it, the operator's view and explicit disclosures; nothing updates it at publication | +| G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | +| G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree, or that hubs agree | +| G5 | `toldImpliesQueued` | A wallet told ok can rely on some hub having queued the transaction. Claimed under `AwaitVerdict` only | +| G6a | `offeredBeforeExpiry` | Every transaction a hub publishes is published with the mining margin to spare: whatever was admitted, on every attempt. Claimed under a timely tip | +| G6b | `conformingFirstOfferBeforeExpiry` | The same for supported wallets and for the first time a hub publishes the transaction. Nothing about a later offer of a requeued entry | +| G7 | `wellFormed` | Structural sanity; checked in every configuration; not a trust-matrix row | +| G8 | `ackImpliesQueued` | An accepted ack from a hub is for a payload that hub had queued by then, whether or not anyone waits for the ack | + +No guarantee reads a field written by the function it constrains. The history +the guarantees need (`audit`) is derived by `commit` from the state before and +the state after each step. + +Each guarantee can be broken by a change to an honest component. These were +tried by hand, with the result shown, and reverted: + +| Guarantee | Change | Result | +|---|---|---| +| F1 | `interpretReply` loses the pending arm | F1 fails | +| G1 | `shim` forwards an unparseable body | violated on `baseline` | +| G2 | `hub` answers a queue hit with the queued body | violated on `baseline` | +| G3 | `interpretReply` skips the txid comparison | **holds on `baseline`**; violated on `byzHub` and `byzIndexer` | +| G4 | `hub` answers not-found on a queue hit | violated on `baseline` and `replicated` | +| G5, G8 | `hub` acks accepted without inserting | G5 violated on `awaitAck`, G8 on `baseline` | +| G6a | `hub` admits without the expiry check | violated on `baseline` | + +The G3 row is not what was predicted; see [Findings](#findings). + +### Trust matrix + +Which components must be honest for each guarantee. Single-fault. "holds" is a +tier 3 simulation row on the named configuration, with its antecedent witnessed +there in tier 3b. "required" is a scripted run in `tests/trustTest.qnt` in which +the component is Byzantine and the guarantee fails, followed by its control +(same wallet inputs, honest transition, guarantee holds); the simulation row +for such a cell shows polarity only. + +Every cell was a prediction. **Observed verdicts agree with the predictions in +every cell of this table except the G6b entries marked below.** + +| | All honest | Byzantine shim | Byzantine hub | Byzantine indexer | +|---|---|---|---|---| +| G1 | holds (`baseline`) | **required**: `operatorSeesMigrationTest` | holds (`byzHub`) | holds (`byzIndexer`) | +| G2 | holds (`baseline`) | **required**: `shimDisclosesPlaintextTest` | **required**: `hubServesQueuedBodyTest` | **required**: `indexerServesUnpublishedBodyTest`. One endpoint suffices | +| G3 | holds (`baseline`) | **required**: `shimServesAnotherTransactionTest` | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | +| G4 | holds (`baseline`, `replicated`) | **required**: `shimInventsStatusTest` | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | +| G5 | holds (`awaitAck`) | **required**: `toldOkWithoutSendingTest` | **required**: `toldOkOnAFalseAckTest` | holds (`awaitAckByzIndexer`) | +| G8 | holds (`baseline`, `awaitAck`) | holds (`byzShim`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | +| G6a | holds (`baseline`) | holds (`byzShim`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | +| G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | + +One Byzantine replica out of two (`replicatedOneByz`): + +| Property | Observed | Backing | +|---|---|---| +| G2 | required of every hub | `oneReplicaServesQueuedBodyTest` | +| G4 | required of every hub | `cursorLandsOnLyingReplicaTest` | +| G3 | holds | simulation; `wrongTransactionIsRefusedTest` | +| G8, G6a, G6b for the honest hub | hold | simulation of `ackImpliesQueuedForHonestHubs`, `offeredBeforeExpiryForHonestHubs`, `conformingFirstOfferBeforeExpiryForHonestHubs`; `honestReplicaKeepsItsGuaranteesTest` | +| G5 | not applicable | `AwaitVerdict` has one hub | +| "some honest hub queued it" after told ok | not a guarantee | `honestReplicaKeepsItsGuaranteesTest` | + +The `...ForHonestHubs` names are the same predicates restricted to the hubs +whose role is honest. They are not weaker properties. + +In short: a Byzantine shim voids every wallet-facing guarantee (G1-G5); the +hub-side G6 and G8 survive it. G3 is the only wallet-facing guarantee that +survives a Byzantine hub or indexer, and it authenticates the txid only. G1 +depends on the shim alone. Replication does not dilute trust: one Byzantine +replica is enough to void G2 and G4. + +### Known gaps, with every component honest + +| Id | What is lost | Where | Form | Observed | Scripted runs | +|---|---|---|---|---|---| +| K1 | Under `DispatchOnly`, told ok does not mean any hub ever admits it | `baseline`, `replicated` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered`, `wToldPrefixOnly` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest`, `toldOkAfterPrefixSendTest` | +| K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline`, `replicated` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest`, `hubsDisagreeTest` | +| K3 | G6a for a tight-expiry transaction: admitted against a tip reported below a boundary already flushed | `flakyTip` | violated invariant | violated, as predicted | `tightExpiryAdmittedBehindFlushedBoundaryTest` | +| K3' | G6b when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | +| K4 | G6a, and G6b on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | +| K5 | `ackedIsHeldOrOffered`: an acknowledged payload is still held, or was offered | `baseline`, `awaitAck` | violated invariant | violated by a crash. **Not violated by a failed final flush**, which was predicted as a second cause | `ackedThenCrashedTest`, `toldOkAdmittedThenLostTest`, `ackedThenLostAtDrainTest` | +| K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | + +K1 is not stated as a violated invariant because the invariant is false on the +ordinary success path too: under `DispatchOnly` the wallet is told ok before +any hub has the frame. In `toldOkAndNeverDeliveredTest` the run ends with the +frame undelivered, and nothing obliges the network ever to deliver it. + +### Witnesses + +Each has a scripted run and is counted in tier 3b. + +| Id | Witness | Name | Configuration | +|---|---|---|---| +| W1-W3 | the wallet sees pending; its transaction in the mempool; mined | `wPending`, `wTxInMempool`, `wTxMined` | `baseline` | +| W4 | each of the five refusals | `wRefusedTipStale`, `wRefusedDraining`, `wRefusedTooLarge`, `wRefusedExpiryTooTight`, `wRefusedFull` | `baseline` | +| W5-W7 | an entry is requeued; dropped as expired; dropped as exhausted | `wRequeued`, `wDroppedExpired`, `wDroppedExhausted` | `baseline` | +| W8 | **Accepted disclosure**: a third party that knows a txid learns it is queued. The hub withholds the bytes, not the fact. See the quoted comment under [Scope](#scope) | `wQueuedDisclosed` | `baseline` | +| W9 | a queued payload the hub cannot parse is asked for and missed | `wUnparseableMissed` | `baseline` | +| W12 | a queue holds more than its capacity after a requeue | `wQueueOverCapacity` | `baseline` | +| W13 | a lookup moves on after a timeout and the next hub answers | `wFailoverAnswered` | `replicated` | +| W14 | two hubs publish the same payload in their own flushes | `wPublishedByTwoHubs` | `replicated` | +| W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | `wPrematureFlush` | `byzIndexer` | +| W16 | **Twin served**: the wallet is served a twin of what it sent, and a transaction at a false height; G3 holds throughout | `wTwinServed`, `wFalseHeightServed` | `byzHub` | +| W17 | the third party's own payload is queued | `wThirdPartyPayloadQueued` | `baseline` | +| W18 | **Early flush by the free-running clock**: a stale hub's clock is ahead of the chain and it flushes before the true boundary, every component honest | `wEarlyFreeRunFlush` | `staleLag` | + +Non-vacuity: for each guarantee, a state where its antecedent holds, reached on +every configuration where the guarantee is claimed: `vOperatorBlind`, +`vQueuedBytesConfidential`, `vTxidAuthenticity`, `vLookupValidityPerHub` (the +log has a pending, a served transaction and a not-found), `vToldImpliesQueued`, +`vOfferedBeforeExpiry`, `vConformingFirstOfferBeforeExpiry`, `vAckImpliesQueued`, +and on `flakyTip` also `vConformingOfferAdmittedBehind` (a conforming first +offer of a payload admitted while the hub's tip was behind the chain). + +### Two-state properties: not checked + +Written in `protocol.qnt` as `temporal` definitions in the 0.33 action-property +form, and typechecked. **None has been run.** + +| Id | Name | What it says | Class | +|---|---|---|---| +| A1 | `chainMonotone` | A transaction's chain status never moves backwards | assumption about the environment | +| A2 | `neverEvict` | An entry leaves a hub's queue only into a flush, or because the hub went down | guarantee | +| A3 | `drainIsFinal` | A draining hub's queue gains only what a flush hands back | guarantee | + +No liveness property is claimed: the network may lose everything, and under +`DispatchOnly` nobody waits for an ack. + +## Findings + +These are what the model showed that the predictions did not, or showed about +the code. Nothing here has been fixed, and no property or role relation was +changed to make a prediction come out. + +**1. K4, confirmed: a short tip silence costs a supported wallet its mining +margin, on the shipped relation between the constants.** In +`silenceAcrossBoundaryMissesMarginTest`: a transaction built at height 2 with +expiry 8 (the floor) is admitted at 3. The hub last sees the tip at 5, one +block short of the flush at 6. Its cadence follows the tip it last saw, so +nothing is flushed until it goes stale at 8. The transaction is published at +height 8: not yet expired, and without the block the margin reserves +(`8 < 8 + 1`). With the shipped numbers the same shape gives a first offer at +`created + 6 + 20 + 11 = created + 37` against an expiry of `created + 40`: +three blocks of margin where four are reserved. `staleSlackFits` +(`interval + margin + lag + window - 1 <= floor`) is false of the shipped +constants (41 > 40). The one-block figure depends on reading 15 minutes as +exactly 12 blocks; blocks are not that regular, so the real shortfall is +sometimes larger. + +**2. The relation that fixes K4 does not give G6b: an early free-running flush +spends the next epoch.** This was predicted to hold on `staleLagWithSlack` and +does not. In `earlyFlushSpendsTheNextEpochTest`: a stale hub's free-running +clock reads 6 at true height 4, so the flush scheduled for 6 runs then, with +nothing to publish, and its epoch is recorded as done. The hub then sees the +tip again, at 5. Admission knows the tip and not the schedule's history: it +admits a transaction counting on the flush at 6. That flush has already +happened; the cadence loop flushes only when the epoch exceeds the last one it +recorded. The transaction waits for the flush at 9, and a further silence of +two blocks, short of the staleness window, makes that one late: it is published +at 11 with expiry 11. By the arithmetic of that run each half alone is +harmless at these numbers; simulation finds the combination about once in ten +thousand traces. +The implementation's comment calls a free-running clock that runs ahead "the +safe direction". It is safe for what is in the queue when it runs. It is not +safe for what is admitted after the tip returns, while the chain is still +behind an epoch the clock has already spent. The model bounds how far ahead the +clock may be (one interval); the code does not, and a clock further ahead +would spend more than one epoch. That last sentence is a reading of +`cadence_height` in `hub/src/batcher.rs`, not something the model shows. + +**3. K5: a failed final flush does not violate `ackedIsHeldOrOffered`.** The +entry was offered, which is all the invariant asks. The transaction is lost all +the same, and `ackedThenLostAtDrainTest` pins that: hub stopped, nothing held, +nothing on the chain, an accepted ack in the soup. The invariant is violated by +a crash only. + +**4. G3 does not depend on the shim's txid check when every component is +honest.** Removing the comparison from `interpretReply` leaves G3 holding on +`baseline`, because an honest hub and indexer never return another +transaction. It fails on `byzHub` and `byzIndexer`, which is where the check is +claimed to matter. G3 is kept as a guarantee: it is falsifiable where it is +claimed "by the check", and other changes to honest code would break it on +`baseline`. + +**5. G6b needs the hub, but not for the predicted reason.** A hub that admits +past the expiry rule cannot break G6b, because the expiry rule never refuses a +conforming, timely transaction (F7). What breaks it is a hub that admits while +it has no tip, when an honest hub refuses everything +(`hubAdmitsBeforeFirstTipTest`). + +**6. The second clause of G1, "and no lookup", is not stated.** No output of +the shim function routes a lookup to the operator, so the clause would hold by +construction and could not be broken by any of the listed changes. + +## Model-based testing, later + +Not built. The specification is shaped so it can be: + +- Every branch of `step` is a named action and every choice is a named `nondet` + inside it, so `--mbt` traces carry `mbt::actionTaken` and `mbt::nondetPicks`. +- `lastAction` records the step and the input it gave a component, in the + state, so scripted runs carry the same information. +- Each step gives one input to one component function and applies one output; + the pairs map onto the seams in the table above. +- All protocol state is in `s`. `audit` is a monitor a harness ignores. +- ITF variable names are qualified by configuration. Model nonces are counters, + to be bound to real nonces as frames appear. A payload's `id` maps to a + fixture. The frames a step emits are `s.net` after it minus before. + +## Bounded model checking (not run) + +**None of the commands in this section has been executed.** Both backends need +Java 21 (Quint 0.33.0's default Apalache is 0.62.1), which the machine this was +written on does not have. Whether Apalache or TLC accept the specification as +written is unknown. + +Each "holds" cell, with Apalache: + +```sh +quint verify --main=baseline --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=queuedBytesConfidential --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=lookupValidityPerHub --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=offeredBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=wellFormed --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzShim --invariant=offeredBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzShim --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzShim --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzHub --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzHub --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzIndexer --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzIndexer --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzIndexer --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=awaitAck --invariant=toldImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=awaitAck --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=awaitAckByzIndexer --invariant=toldImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=flakyTip --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +``` + +Each witness, as a reachability check that should report a violation: + +```sh +quint verify --main=baseline --invariant='not(wPending)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wTxInMempool)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wTxMined)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wRefusedTipStale)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wRefusedDraining)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wRefusedTooLarge)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wRefusedExpiryTooTight)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wRefusedFull)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wRequeued)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wDroppedExpired)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wDroppedExhausted)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wQueuedDisclosed)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wUnparseableMissed)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wQueueOverCapacity)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wThirdPartyPayloadQueued)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wToldRefusedEverywhere)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant='not(wToldNeverDelivered)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzHub --invariant='not(wTwinServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzHub --invariant='not(wFalseHeightServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzIndexer --invariant='not(wPrematureFlush)' --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=staleLag --invariant='not(wEarlyFreeRunFlush)' --max-steps=12 zeronym/spec/protocol/instances.qnt +``` + +The two-state properties, with TLC (also `QUINT_TLC=1 sh check.sh`): + +```sh +quint verify --backend tlc --main=baseline --temporal=chainMonotone,neverEvict,drainIsFinal zeronym/spec/protocol/instances.qnt +``` + +The commands use one-hub configurations: nested maps of records are supported +by Apalache but slow. + +What the specification does to give those runs a chance, from Apalache's +documentation and not from running it: + +| Construct | Consequence | +|---|---| +| `run`, `.then`, `.expect`, `--witnesses`, `--mbt` are simulator-only | The commands above cover invariants only; reachability is `not(w)` expected to be violated | +| `oneOf` on an empty set | Every pick is guarded | +| Unbounded integers, `powerset`, `allLists` | Not used; every universe is a finite set bounded by constants, the Byzantine sets included | +| A list in the state (the wallet's log) | Bounded by `--max-steps` | +| `assume` | Behaviour under verify unknown; `assumptionsTest` is the check that counts | +| Temporal definitions | For TLC only | From 1d5d6fa530502618afbdb86f12c8eab67506e3da Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 13:06:18 +0400 Subject: [PATCH 29/80] test(zeronym): run the protocol spec gate in CI --- .github/workflows/zeronym-guards.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index 4941a773..f62cd63a 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -57,6 +57,21 @@ jobs: - name: Divert protocol model run: sh zeronym/spec/check.sh + # The standalone Quint specification of the whole protocol: typecheck, tests + # and bounded random simulation. No model checker, so no Java. + spec-protocol: + runs-on: ubuntu-latest + timeout-minutes: 15 + # Same reasoning as `spec`: it runs code fetched at run time. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Protocol specification + run: sh zeronym/spec/protocol/check.sh + tests: runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 30 From ed95bd80c991699d23ef484ffdc5f132cf2eee4c Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 15:02:55 +0400 Subject: [PATCH 30/80] test(zeronym): judge offers at the verdict, make the hub poll each block and restate durability --- zeronym/spec/protocol/instances.qnt | 99 ++++++++++++--- zeronym/spec/protocol/properties.qnt | 106 ++++++++++++---- zeronym/spec/protocol/protocol.qnt | 98 +++++++++++---- zeronym/spec/protocol/state.qnt | 34 ++++++ zeronym/spec/protocol/tests/scenariosTest.qnt | 114 ++++++++++++++---- zeronym/spec/protocol/tests/trustTest.qnt | 71 ++++++++--- zeronym/spec/protocol/tests/wireTest.qnt | 10 +- zeronym/spec/protocol/types.qnt | 5 + zeronym/spec/protocol/wire.qnt | 5 + 9 files changed, 440 insertions(+), 102 deletions(-) diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index bb20ceff..823518b5 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -15,15 +15,15 @@ module configs { // Transactions // ------------------------------------------------------------------------ // - // The schedule below flushes every 3 blocks with a mining margin of 1 and a - // delivery lag of 1, and supports wallets that set an expiry 6 blocks out. + // The schedule below flushes every 3 blocks with a mining margin of 2 and a + // delivery lag of 1, and supports wallets that set an expiry 7 blocks out. pure def orchard(id: str, created: Height, expiry: Height): Payload = { id: id, txid: Some(id), created: created, expiry: Some(expiry), class: OrchardTouching, oversize: false } /// Two migrations from supported wallets, built at heights 2 and 4. - pure val early = orchard("early", 2, 8) - pure val late = orchard("late", 4, 10) + pure val early = orchard("early", 2, 9) + pure val late = orchard("late", 4, 11) /// A migration whose wallet set its expiry tighter than the supported floor. pure val tight = orchard("tight", 2, 5) /// Bytes the shim cannot parse and neither can a hub: no txid, no expiry. @@ -49,33 +49,40 @@ module configs { /// /// The schedule is the shipped one scaled down (shipped: interval 20, /// margin 4, lag 6, reorg allowance 10, staleness window 12 blocks, expiry - /// floor 40), keeping the relations between the numbers that matter: + /// floor 40), keeping the relations between the numbers that matter + /// (`shippedRelationsKept` in `protocol.qnt`): /// /// - the slack `floor - (interval + margin + lag)` equals the reorg /// allowance (10 there, 1 here); /// - the staleness window is larger than the margin and than the slack; /// - `interval + margin + lag + (window - 1)` exceeds the floor by one - /// (41 > 40 there, 7 > 6 here), while `interval + lag + (window - 1)` does - /// not (37 <= 40, 6 <= 6). + /// (41 > 40 there, 8 > 7 here), while `interval + lag + (window - 1)` does + /// not (37 <= 40, 6 <= 7). + /// + /// The margin is 2, the smallest that leaves room for a block to arrive + /// while a flush is in flight (`maxFlightBlocks`). pure val baseline: Config = { payloads: Set(early, late, tight, junk, plain), twins: Set(earlyTwin), tpPayloads: Set(garbage, bloat), hubs: ["h1"], flushInterval: 3, - miningMargin: 1, + miningMargin: 2, deliveryLag: 1, reorgAllowance: 1, staleWindow: 3, freeRun: NotSlower, - minWalletExpiry: 6, + minWalletExpiry: 7, maxAttempts: 2, queueCap: 2, + maxFlightBlocks: 1, maxHeight: 12, maxRequests: 3, submitMode: DispatchOnly, roles: allHonest, tip: TipTimely, + reliesOnReorgSlack: true, + reliesOnFlightWithinMargin: true, } // One Byzantine component at a time. @@ -101,24 +108,30 @@ module configs { } // A tip that may be reported up to the reorg allowance behind the chain: - // with the slack that covers it, and without. + // with the slack that covers it, and without. In the second the expiry + // floor is the three-term budget exactly. pure val flakyTip = { ...baseline, tip: TipMayRegress } pure val flakyTipNoSlack = { ...flakyTip, - minWalletExpiry: 5, - payloads: Set(orchard("early", 2, 7), late, tight, junk, plain), - twins: Set({ ...orchard("early", 2, 7), id: "early-twin" }), + minWalletExpiry: 6, + reliesOnReorgSlack: false, + payloads: Set(orchard("early", 2, 8), late, tight, junk, plain), + twins: Set({ ...orchard("early", 2, 8), id: "early-twin" }), } + // The same tip, and a flush that may stay in flight for as many blocks as + // the mining margin reserves. + pure val flakyTipSlowFlight = { ...flakyTip, maxFlightBlocks: 2, reliesOnFlightWithinMargin: false } + // A hub that may go without a tip for a while: on the shipped relation // between the staleness window and the expiry floor, and on the relation // that would cover the silence. pure val staleLag = { ...baseline, tip: TipMayLag } pure val staleLagWithSlack = { ...staleLag, - minWalletExpiry: 7, - payloads: Set(orchard("early", 2, 9), orchard("late", 4, 11), tight, junk, plain), - twins: Set({ ...orchard("early", 2, 9), id: "early-twin" }), + minWalletExpiry: 8, + payloads: Set(orchard("early", 2, 10), orchard("late", 4, 12), tight, junk, plain), + twins: Set({ ...orchard("early", 2, 10), id: "early-twin" }), } } @@ -130,6 +143,9 @@ module baseline { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -141,6 +157,9 @@ module byzShim { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -152,6 +171,9 @@ module byzHub { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -163,6 +185,9 @@ module byzIndexer { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -174,6 +199,9 @@ module awaitAck { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -185,6 +213,9 @@ module awaitAckByzShim { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -196,6 +227,9 @@ module awaitAckByzHub { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -207,6 +241,9 @@ module awaitAckByzIndexer { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -218,6 +255,9 @@ module replicated { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -229,6 +269,9 @@ module replicatedOneByz { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -240,6 +283,9 @@ module flakyTip { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), + assert(not(staleSlackFits)), } } @@ -251,7 +297,24 @@ module flakyTipNoSlack { run assumptionsTest = all { assert(standingAssumptions), + assert(flightWithinMargin), assert(not(reorgSlackFits)), + assert(not(shippedRelationsKept)), + } +} + +// A flush may outlast the mining margin: this configuration shows what the +// bound on flight time buys. +module flakyTipSlowFlight { + import types.* from "./types" + import configs.* + import protocol(CONFIG = flakyTipSlowFlight).* from "./protocol" + + run assumptionsTest = all { + assert(standingAssumptions), + assert(reorgSlackFits), + assert(shippedRelationsKept), + assert(not(flightWithinMargin)), } } @@ -264,6 +327,8 @@ module staleLag { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), + assert(shippedRelationsKept), assert(not(staleSlackFits)), } } @@ -276,6 +341,8 @@ module staleLagWithSlack { run assumptionsTest = all { assert(standingAssumptions), assert(reorgSlackFits), + assert(flightWithinMargin), assert(staleSlackFits), + assert(not(shippedRelationsKept)), } } diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index 857b0e7a..d5e17e80 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -59,6 +59,7 @@ module properties { everQueued: s.hubIds().mapBy(_ => Set()), admitted: Map(), offers: Set(), + verdicts: Set(), windows: Map(), refusals: Set(), dropped: Set(), @@ -95,6 +96,25 @@ module properties { else Set() | Idle => Set() }).flatten() + // An entry that was awaiting a verdict and no longer is, in a flush still + // in flight, has just had its answer. A node judged it unless it was set + // aside for requeue. + val judged = hubs.map(hub => + match pre.hubs.get(hub).flush { + | Broadcasting(before) => + match post.hubs.get(hub).flush { + | Broadcasting(after) => + before.batch.keys().exclude(after.batch.keys()).map(payload => { + hub: hub, + payload: payload, + height: post.height(), + nth: audit.offers.filter(offer => offer.hub == hub and offer.payload == payload).size() - 1, + final: not(after.unplaced.keys().contains(payload)), + }) + | Idle => Set() + } + | Idle => Set() + }).flatten() // A flush that has ended, other than a final one, gave up on every entry // nothing judged that is not back in the queue. val dropped = hubs.map(hub => @@ -133,6 +153,7 @@ module properties { if (acc.keys().contains(entry)) acc else acc.put(entry, { height: post.height(), tip: post.hubs.get(entry._1).observedTip() })), offers: audit.offers.union(offered), + verdicts: audit.verdicts.union(judged), windows: windows, refusals: audit.refusals.union(refused), dropped: audit.dropped.union(dropped), @@ -207,33 +228,61 @@ module properties { | None => true } - /// Whether a payload reached `offer.hub` as a supported wallet's would: it - /// honours the expiry floor, and it first entered the queue within the - /// delivery lag of the height it was built at. - pure def isConformingAndTimely(s: System, audit: Audit, offer: Offer): bool = - val params = s.hubs.get(offer.hub).params + /// Whether `payload` reached `hub` as a supported wallet's would: it honours + /// the expiry floor, and it first entered the queue within the delivery lag + /// of the height it was built at. + pure def isConformingAndTimely(s: System, audit: Audit, hub: HubId, payload: Payload): bool = + val params = s.hubs.get(hub).params and { - conforming(offer.payload, params.minWalletExpiry), - audit.admitted.keys().contains((offer.hub, offer.payload)), - audit.admitted.get((offer.hub, offer.payload)).height <= offer.payload.created + params.deliveryLag, + conforming(payload, params.minWalletExpiry), + audit.admitted.keys().contains((hub, payload)), + audit.admitted.get((hub, payload)).height <= payload.created + params.deliveryLag, } - /// G6a. Every transaction one of `hubs` publishes is published with the - /// mining margin to spare: whatever was admitted, on every attempt. + // G6 comes in three parts. G6a and G6b are about the moment a flush begins: + // how much of the mining margin is left when the hub hands the batch over. + // They do not say a node accepts the transaction, because the chain may move + // while the batch is in flight. G6c is about the moment a node judges it. + + /// G6a. Every transaction one of `hubs` offers is offered with the mining + /// margin to spare: whatever was admitted, on every attempt. A claim about + /// the margin left at the offer, not about acceptance. pure def offeredBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = audit.offers.forall(offer => hubs.contains(offer.hub) implies offeredInTime(s, offer)) /// G6b. The same, for supported wallets only and for the first time a hub - /// publishes the transaction. It says nothing about a later offer of an - /// entry that was requeued; see `conformingEveryOfferBeforeExpiry`. + /// offers the transaction. It says nothing about a later offer of an entry + /// that was requeued; see `conformingEveryOfferBeforeExpiry`. Like G6a it is + /// a claim about the margin left at the offer. pure def conformingFirstOfferBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = audit.offers.forall(offer => and { hubs.contains(offer.hub), offer.nth == 0, - isConformingAndTimely(s, audit, offer), + isConformingAndTimely(s, audit, offer.hub, offer.payload), } implies offeredInTime(s, offer)) + /// G6c. The end-to-end claim: when a node judges the first offer of a + /// supported wallet's transaction, the transaction has not expired. It can + /// still be mined in the next block, so the node does not turn it away for + /// its expiry. + /// + /// The margin is what pays for the blocks that arrive while the batch is in + /// flight. G6c therefore needs G6b and one thing more: that fewer blocks + /// than the mining margin arrive during a flush (`flightWithinMargin`). + pure def conformingFirstOfferJudgedBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = + audit.verdicts.forall(verdict => + and { + hubs.contains(verdict.hub), + verdict.nth == 0, + verdict.final, + isConformingAndTimely(s, audit, verdict.hub, verdict.payload), + } implies + match verdict.payload.expiry { + | Some(expiry) => expiry > verdict.height + | None => true + }) + /// G7. Structural sanity: a queued entry is within its attempts; a hub that /// is down holds nothing and knows nothing; every nonce in use was minted. pure def wellFormed(s: System): bool = @@ -296,23 +345,29 @@ module properties { | _ => true }) - /// K5. A payload a hub has acknowledged is still held by it, or has been - /// offered to the chain. This does not hold: the queue lives in memory, and - /// a crash after the ack loses it. - pure def ackedIsHeldOrOffered(s: System, audit: Audit): bool = + /// K5. A payload a hub has acknowledged is accounted for: the hub still + /// holds it, queued or in flight; or the chain has it; or a node judged it + /// when this hub offered it, and said accepted, already known or rejected. + /// Being offered is not enough: an offer nothing judged settles nothing. + /// + /// This does not hold. The queue lives in memory: a crash after the ack + /// loses it, and so does a final flush that finds the indexer unreachable. + pure def ackedIsHeldOrSettled(s: System, audit: Audit): bool = s.hubIds().forall(hub => s.ackedAt(hub).forall(payload => or { s.queuedAt(hub).contains(payload), s.inFlightAt(hub).contains(payload), - audit.offers.exists(offer => offer.hub == hub and offer.payload == payload), + s.indexer.published().contains(payload), + audit.verdicts.exists(verdict => + verdict.hub == hub and verdict.payload == payload and verdict.final), })) /// K6. G6b without its restriction to the first offer. This does not hold /// once a hub is stale: requeue judges an entry at the observed tip, which /// has stopped, while the flush schedule runs on. pure def conformingEveryOfferBeforeExpiry(s: System, audit: Audit): bool = - audit.offers.forall(offer => isConformingAndTimely(s, audit, offer) implies offeredInTime(s, offer)) + audit.offers.forall(offer => isConformingAndTimely(s, audit, offer.hub, offer.payload) implies offeredInTime(s, offer)) // K1. Under `DispatchOnly`, "told ok" promises nothing about any hub. It is // stated as three reachable states, not as a violated invariant, because the @@ -545,7 +600,7 @@ module properties { and { isSome(offer.payload.expiry), offer.nth == 0, - isConformingAndTimely(s, audit, offer), + isConformingAndTimely(s, audit, offer.hub, offer.payload), }) /// The same, for a payload admitted while the hub's tip was behind the chain. @@ -554,11 +609,20 @@ module properties { and { isSome(offer.payload.expiry), offer.nth == 0, - isConformingAndTimely(s, audit, offer), + isConformingAndTimely(s, audit, offer.hub, offer.payload), val entry = audit.admitted.get((offer.hub, offer.payload)) entry.tip < entry.height, }) + pure def vConformingFirstOfferJudged(s: System, audit: Audit): bool = + audit.verdicts.exists(verdict => + and { + isSome(verdict.payload.expiry), + verdict.nth == 0, + verdict.final, + isConformingAndTimely(s, audit, verdict.hub, verdict.payload), + }) + pure def vAckImpliesQueued(s: System): bool = s.hubIds().exists(hub => s.ackedAt(hub) != Set()) } diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 35269597..b6363f73 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -83,6 +83,9 @@ module protocol { /// Entries a hub's admission will hold. pure val QUEUE_CAP = CONFIG.queueCap + /// Blocks that may arrive while one flush is in flight. + pure val MAX_FLIGHT_BLOCKS = CONFIG.maxFlightBlocks + /// Bounds of the model: the chain stops growing at `MAX_HEIGHT`, and the /// wallet makes at most `MAX_REQUESTS` sends and as many lookups, as does /// the third party. @@ -134,6 +137,28 @@ module protocol { pure val reorgSlackFits = FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + REORG_ALLOWANCE <= MIN_WALLET_EXPIRY + /// Fewer blocks arrive while a flush is in flight than the mining margin + /// reserves. The margin is measured from the height at which a flush begins; + /// every block that arrives before the node judges a transaction is taken + /// out of it. The implementation bounds each call to the indexer + /// (`RPC_TIMEOUT`, `zeronym/hub/src/chain.rs`) and not the batch as a whole, + /// and bounds neither in blocks, so this is an assumption about the + /// environment that the code does not enforce. + pure val flightWithinMargin = MAX_FLIGHT_BLOCKS < MINING_MARGIN + + /// The relations between the shipped constants that the scaled-down schedule + /// is meant to keep: the slack left by the startup budget equals the reorg + /// allowance; the staleness window exceeds both the margin and that slack; + /// the budget with the longest non-stale silence added exceeds the expiry + /// floor by exactly one block, and without the margin it fits. + pure val shippedRelationsKept = and { + MIN_WALLET_EXPIRY - (FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG) == REORG_ALLOWANCE, + STALE_WINDOW > MINING_MARGIN, + STALE_WINDOW > REORG_ALLOWANCE, + FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + (STALE_WINDOW - 1) == MIN_WALLET_EXPIRY + 1, + FLUSH_INTERVAL + DELIVERY_LAG + (STALE_WINDOW - 1) <= MIN_WALLET_EXPIRY, + } + /// The same budget with the longest silence that does not yet make a hub /// stale. It is named, not assumed: the shipped constants do not meet it. pure val staleSlackFits = @@ -164,8 +189,9 @@ module protocol { /// has one hub address. pure val awaitVerdictSingleHub = SUBMIT_MODE == AwaitVerdict implies HUBS.size() == 1 - /// The assumptions every configuration is expected to meet. The two slack - /// relations are kept apart: some configurations exist to drop one. + /// The assumptions every configuration meets. `reorgSlackFits` and + /// `flightWithinMargin` are kept apart: a configuration says whether it + /// relies on each, and some exist to show what happens without one. pure val standingAssumptions = and { budgetFits, freeRunNotSlowerThanChain, @@ -176,7 +202,8 @@ module protocol { } assume _ = budgetFits - assume _ = reorgSlackFits + assume _ = CONFIG.reliesOnReorgSlack implies reorgSlackFits + assume _ = CONFIG.reliesOnFlightWithinMargin implies flightWithinMargin assume _ = freeRunNotSlowerThanChain assume _ = flushIntervalPositive assume _ = hubsNonEmpty @@ -290,10 +317,15 @@ module protocol { /// live: a block is held back until every hub has done what the tip model /// says it does within a block. /// - /// In every model, a flush that is due has begun. + /// In every model, a flush that is due has begun, and no flush has been in + /// flight for `MAX_FLIGHT_BLOCKS` blocks already. A hub whose flush is in + /// flight is not looking at the tip, so the clauses below bind a hub only + /// while it is idle. /// - /// - `TipTimely`: every running hub has observed the current block. With an - /// honest indexer its lag is therefore zero at every block. + /// - `TipTimely`: every running hub has asked for the tip since the last + /// block. An honest indexer answers with the true height, so the hub's lag + /// is zero at every block. A Byzantine one is asked just as often; what it + /// controls is the answer. /// - `TipMayRegress`: no running hub is further behind than the allowance. /// - `TipMayLag`: a hub whose silence has reached the staleness window has /// become stale, and a stale hub's free-running clock has caught up with @@ -301,19 +333,21 @@ module protocol { def chainMayAdvance: bool = and { s.height() < MAX_HEIGHT, HUBS.forall(id => not(s.hubs.get(id).isFlushDue())), + HUBS.forall(id => s.flightBlocks.get(id) < MAX_FLIGHT_BLOCKS or s.hubs.get(id).flush == Idle), HUBS.forall(id => val state = s.hubs.get(id) - match TIP { - | TipTimely => - ROLES.indexer == Byzantine or (state.phase == Running implies lagOf(id) == 0) - | TipMayRegress => - state.phase == Running implies lagOf(id) <= REORG_ALLOWANCE - | TipMayLag => - and { - state.phase == Running implies lagOf(id) < STALE_WINDOW, - (state.phase == Stale and FREE_RUN == NotSlower) implies state.cadenceHeight() >= s.height(), - } - }), + state.flush == Idle implies + match TIP { + | TipTimely => + state.phase == Running implies s.polled.contains(id) + | TipMayRegress => + state.phase == Running implies lagOf(id) <= REORG_ALLOWANCE + | TipMayLag => + and { + state.phase == Running implies lagOf(id) < STALE_WINDOW, + (state.phase == Stale and FREE_RUN == NotSlower) implies state.cadenceHeight() >= s.height(), + } + }), } // ------------------------------------------------------------------------ @@ -485,17 +519,29 @@ module protocol { HUBS.contains(id), not(isHubError(result.out)), result.state != s.hubs.get(id), - commit({ ...around, hubs: around.hubs.set(id, result.state) }, label), + commit(around.hubMoved(id, result.state), label), } action hubTakes(id: HubId, input: HubInput, label: Label): bool = hubTakesIn(s, id, input, label) /// A hub's cadence loop observes the tip its indexer reports. - action hubObserveTipWith(id: HubId, tip: Height): bool = all { - reportableTips.contains(tip), - hubTakes(id, TipHInput(tip), HubObserveTip({ hub: id, tip: tip })), - } + /// + /// The poll is recorded whether or not the answer moves the hub's tip: a hub + /// that asked and was told nothing new has still asked. A second poll in the + /// same block that changes nothing is not taken. + action hubObserveTipWith(id: HubId, tip: Height): bool = + val result = hub(s.hubs.get(id), TipHInput(tip)) + all { + HUBS.contains(id), + reportableTips.contains(tip), + not(isHubError(result.out)), + result.state != s.hubs.get(id) or not(s.polled.contains(id)), + commit( + { ...s.hubMoved(id, result.state), polled: s.polled.union(Set(id)) }, + HubObserveTip({ hub: id, tip: tip }), + ), + } action hubObserveTip = { nondet id = oneOf(HUBS) @@ -609,7 +655,7 @@ module protocol { /// The chain grows by one block. action chainAdvance = all { chainMayAdvance, - commit({ ...s, indexer: indexerApply(s.indexer, AdvanceIInput, NoIndexerOutput) }, ChainAdvance), + commit(s.blockArrived(), ChainAdvance), } /// The transactions waiting in the mempool. @@ -800,6 +846,7 @@ module protocol { val toldImpliesQueued = P::toldImpliesQueued(s, audit) val offeredBeforeExpiry = P::offeredBeforeExpiry(s, audit, HUBS) val conformingFirstOfferBeforeExpiry = P::conformingFirstOfferBeforeExpiry(s, audit, HUBS) + val conformingFirstOfferJudgedBeforeExpiry = P::conformingFirstOfferJudgedBeforeExpiry(s, audit, HUBS) val wellFormed = P::wellFormed(s) val ackImpliesQueued = P::ackImpliesQueued(s, audit, HUBS) @@ -809,6 +856,8 @@ module protocol { // coincide. val offeredBeforeExpiryForHonestHubs = P::offeredBeforeExpiry(s, audit, HONEST_HUBS) val conformingFirstOfferBeforeExpiryForHonestHubs = P::conformingFirstOfferBeforeExpiry(s, audit, HONEST_HUBS) + val conformingFirstOfferJudgedBeforeExpiryForHonestHubs = + P::conformingFirstOfferJudgedBeforeExpiry(s, audit, HONEST_HUBS) val ackImpliesQueuedForHonestHubs = P::ackImpliesQueued(s, audit, HONEST_HUBS) // ------------------------------------------------------------------------ @@ -816,7 +865,7 @@ module protocol { // ------------------------------------------------------------------------ val statusNeverRegresses = P::statusNeverRegresses(s) - val ackedIsHeldOrOffered = P::ackedIsHeldOrOffered(s, audit) + val ackedIsHeldOrSettled = P::ackedIsHeldOrSettled(s, audit) val conformingEveryOfferBeforeExpiry = P::conformingEveryOfferBeforeExpiry(s, audit) // ------------------------------------------------------------------------ @@ -858,6 +907,7 @@ module protocol { val vOfferedBeforeExpiry = P::vOfferedBeforeExpiry(audit) val vConformingFirstOfferBeforeExpiry = P::vConformingFirstOfferBeforeExpiry(s, audit) val vConformingOfferAdmittedBehind = P::vConformingOfferAdmittedBehind(s, audit) + val vConformingFirstOfferJudged = P::vConformingFirstOfferJudged(s, audit) val vAckImpliesQueued = P::vAckImpliesQueued(s) // ------------------------------------------------------------------------ diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt index 61fa45b1..a724d4e9 100644 --- a/zeronym/spec/protocol/state.qnt +++ b/zeronym/spec/protocol/state.qnt @@ -32,6 +32,11 @@ module state { /// are payloads of its own making. type ThirdParty = { txids: Set[TxId], own: Set[Payload], nextNonce: Nonce, requests: int } + /// - `polled`: the hubs whose cadence loop has asked for the tip since the + /// last block arrived. + /// - `flightBlocks`: for each hub, the blocks that have arrived since its + /// flush in flight began; zero while no flush is in flight. Both are the + /// environment's bookkeeping of time; no component reads them. /// - `operator`: every transaction the shim has handed the operator's /// indexer. The operator is assumed to publish nothing itself. /// - `disclosed`: payloads a Byzantine component has revealed. It is written @@ -39,6 +44,8 @@ module state { type System = { indexer: IndexerState, hubs: HubId -> HubState, + polled: Set[HubId], + flightBlocks: HubId -> int, shim: ShimState, net: Net, wallet: Wallet, @@ -52,6 +59,8 @@ module state { pure def initialSystem(config: Config, params: HubParams, height: Height): System = { indexer: initialIndexer(height), hubs: config.hubs.indices().map(i => config.hubs[i]).mapBy(_ => startingHub(params)), + polled: Set(), + flightBlocks: config.hubs.indices().map(i => config.hubs[i]).mapBy(_ => 0), shim: initialShim(config.hubs, config.submitMode), net: Set(), wallet: { log: [], sends: 0, gets: 0 }, @@ -96,15 +105,21 @@ module state { /// - `offers`: every time a flush put a payload in flight: the true chain /// height, the requeues the entry had had (`attempt`), and how many times /// this hub had offered the payload before (`nth`). + /// - `verdicts`: every time an entry in flight got its answer from the + /// indexer: the true chain height at that moment, which offer of the + /// payload it answers (`nth`), and whether a node judged the transaction + /// (`final`) or nothing did and it was set aside for requeue. /// - `windows`: for each lookup the shim has sent, the answers that were /// true at the hub it asked at some point while it waited. /// - `refusals`: the admission refusals that have been sent. /// - `dropped`: entries a requeue gave up on, with the requeues they had had. type Offer = { hub: HubId, payload: Payload, height: Height, attempt: int, nth: int } + type Judgement = { hub: HubId, payload: Payload, height: Height, nth: int, final: bool } type Audit = { everQueued: HubId -> Set[Payload], admitted: (HubId, Payload) -> { height: Height, tip: Height }, offers: Set[Offer], + verdicts: Set[Judgement], windows: Nonce -> Set[LookupObs], refusals: Set[Refusal], dropped: Set[{ hub: HubId, payload: Payload, attempts: int }], @@ -312,6 +327,25 @@ module state { | ShimErrorOutput(_) => stepped } + /// The system after `hub` moved to `state` on its own schedule. A hub with + /// no flush in flight has no flight time. + pure def hubMoved(s: System, hub: HubId, state: HubState): System = + { ...s, + hubs: s.hubs.set(hub, state), + flightBlocks: if (state.flush == Idle) s.flightBlocks.set(hub, 0) else s.flightBlocks, + } + + /// The system after one more block: the chain is a block higher, no hub has + /// asked for the new tip yet, and every flush in flight has been out one + /// block longer. + pure def blockArrived(s: System): System = + { ...s, + indexer: indexerApply(s.indexer, AdvanceIInput, NoIndexerOutput), + polled: Set(), + flightBlocks: s.hubs.keys().mapBy(hub => + if (s.hubs.get(hub).flush == Idle) 0 else s.flightBlocks.get(hub) + 1), + } + /// The system after `hub` took `result` on a frame from `client`. Its ack or /// lookup reply is put on the wire and sent back; nothing else a hub outputs /// leaves it. diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index a56974ff..ee133d3d 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -9,8 +9,8 @@ /// record that makes it fail. /// /// The schedule in every configuration: the chain starts at height 1, a flush -/// is scheduled at heights 3, 6, 9 and 12, and a published transaction needs -/// one more block to be mined. Shim nonces count up from 0, one per frame. +/// is scheduled at heights 3, 6, 9 and 12, and the mining margin is 2 blocks. +/// Shim nonces count up from 0, one per frame. module baselineScenarios { import basicSpells.* from "../spells/basicSpells" @@ -54,6 +54,7 @@ module baselineScenarios { .expect(wPending and wTxInMempool and wTxMined) .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry and ackImpliesQueued and wellFormed) + .expect(vConformingFirstOfferJudged and conformingFirstOfferJudgedBeforeExpiry) .expect(statusNeverRegresses) /// A pass-through transaction goes to the operator and nowhere else. @@ -297,15 +298,15 @@ module baselineScenarios { started .then(block) .then(submitTo("h1", 0, early)) - .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and ackedIsHeldOrOffered) + .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and ackedIsHeldOrSettled) .then(hubCrashWith("h1")) .expect(s.hubs.get("h1") == downHub(HUB_PARAMS)) .expect(audit.offers == Set() and s.onChain("early") == Absent) - .expect(not(ackedIsHeldOrOffered) and ackImpliesQueued) + .expect(not(ackedIsHeldOrSettled) and ackImpliesQueued) /// K5b. The final flush of a draining hub finds the indexer unreachable. - /// The entry was offered, which is all `ackedIsHeldOrOffered` asks, so that - /// invariant still holds here; the transaction is lost all the same. + /// The entry was offered and nothing judged it; the hub stops, and the + /// transaction is held nowhere. run ackedThenLostAtDrainTest = started .then(block) @@ -316,14 +317,15 @@ module baselineScenarios { .expect(s.queuedAt("h1") == Set() and s.inFlightAt("h1") == Set() and s.onChain("early") == Absent) .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted))) .expect(audit.offers == Set({ hub: "h1", payload: early, height: 2, attempt: 0, nth: 0 })) - .expect(ackedIsHeldOrOffered) + .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 2, nth: 0, final: false })) + .expect(not(ackedIsHeldOrSettled)) // ------------------------------------------------------------------------ // K6, control // ------------------------------------------------------------------------ /// The wallet inputs of K6 under a timely tip. The second requeue is judged - /// at tip 9, finds that an expiry of 10 does not survive the flush at 12, + /// at tip 9, finds that an expiry of 11 does not survive the flush at 12, /// and drops the entry: it is never offered past its expiry. run requeueUnderTimelyTipDropsTest = started @@ -389,7 +391,7 @@ module awaitAckScenarios { .then(hubCrashWith("h1")) .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) .expect(s.queuedAt("h1") == Set() and audit.offers == Set() and s.onChain("early") == Absent) - .expect(toldImpliesQueued and not(ackedIsHeldOrOffered)) + .expect(toldImpliesQueued and not(ackedIsHeldOrSettled)) } module replicatedScenarios { @@ -500,7 +502,9 @@ module flakyTipScenarios { /// A supported wallet's transaction, admitted the same way and then flushed /// one block late because the tip is reported one block back. The reorg - /// slack is exactly what it needs. + /// slack leaves it exactly the mining margin at the offer. One block arrives + /// while the batch is in flight, which the margin is there to pay for, and + /// the node still accepts it. run conformingSurvivesRegressionTest = started .then(blocks(2)) @@ -512,7 +516,53 @@ module flakyTipScenarios { .then(hubObserveTipWith("h1", 6)) .then(hubFlushBeginWith("h1")) .expect(audit.offers == Set({ hub: "h1", payload: early, height: 7, attempt: 0, nth: 0 })) + .expect(early.expiry == Some(7 + MINING_MARGIN)) .expect(vConformingOfferAdmittedBehind and conformingFirstOfferBeforeExpiry and offeredBeforeExpiry) + .then(chainAdvance) + .expect(s.height() == 8 and s.flightBlocks.get("h1") == MAX_FLIGHT_BLOCKS and not(chainMayAdvance)) + .then(judge("h1", early, Accepted)) + .then(hubFlushEndWith("h1")) + .expect(s.onChain("early") == InMempool) + .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 8, nth: 0, final: true })) + .expect(vConformingFirstOfferJudged and conformingFirstOfferJudgedBeforeExpiry) +} + +module flakyTipSlowFlightScenarios { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import indexer.* from "../indexer" + import hub.* from "../hub" + import shim.* from "../shim" + import state.* from "../state" + import configs.* from "../instances" + import protocol(CONFIG = flakyTipSlowFlight).* from "../protocol" + + /// K7. The steps of `conformingSurvivesRegressionTest`, with the batch in + /// flight for two blocks, as many as the margin reserves. The offer left the + /// whole margin, and G6b holds. By the time the node looks, the transaction + /// can no longer be mined: the budget had already spent the slack, and the + /// flight spent the margin. + run slowFlightSpendsTheMarginTest = + started + .then(blocks(2)) + .then(hubFlushBeginWith("h1")) + .then(hubObserveTipWith("h1", 2)) + .then(submitTo("h1", 0, early)) + .then(blocks(2)) + .then(2.reps(_ => chainAdvance)) + .then(hubObserveTipWith("h1", 6)) + .then(hubFlushBeginWith("h1")) + .expect(audit.offers == Set({ hub: "h1", payload: early, height: 7, attempt: 0, nth: 0 })) + .then(2.reps(_ => chainAdvance)) + .expect(s.height() == 9 and early.expiry == Some(9)) + // An honest node cannot take it now; it is rejected. + .expect(not(s.indexer.isAcceptable(early))) + .expect(not(honestIndexerOutputs(s.indexer, BroadcastIInput(early)).contains(VerdictOutput(Accepted)))) + .then(judge("h1", early, Rejected)) + .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 9, nth: 0, final: true })) + .expect(not(flightWithinMargin)) + .expect(conformingFirstOfferBeforeExpiry and not(conformingFirstOfferJudgedBeforeExpiry)) } module flakyTipNoSlackScenarios { @@ -527,8 +577,8 @@ module flakyTipNoSlackScenarios { import protocol(CONFIG = flakyTipNoSlack).* from "../protocol" /// This configuration's migration from a supported wallet: built at height - /// 2, expiring exactly at the floor of 5 blocks. - pure val atFloor = orchard("early", 2, 7) + /// 2, expiring exactly at the floor of 6 blocks. + pure val atFloor = orchard("early", 2, 8) /// K3'. The steps of `conformingSurvivesRegressionTest` with the expiry /// floor equal to the three-term budget. The flush is one block late and @@ -565,8 +615,10 @@ module staleLagScenarios { /// K4. The hub hears nothing after height 5, one block short of the flush /// at 6, while the chain goes on. Its cadence still follows the tip it last /// saw, so nothing is flushed until it goes stale at height 8. A supported - /// wallet's transaction, expiring at 8, is published at 8: not yet expired, - /// and without the block the mining margin reserves. + /// wallet's transaction, expiring at 9, is offered at 8: not yet expired, + /// with one block of margin where two are reserved. One block arrives while + /// the batch is in flight, as the margin allows for, and the node can no + /// longer take it. run silenceAcrossBoundaryMissesMarginTest = started .then(block) @@ -582,12 +634,18 @@ module staleLagScenarios { .expect(conforming(early, MIN_WALLET_EXPIRY)) .expect(audit.admitted.get(("h1", early)) == { height: 3, tip: 3 }) .expect(audit.offers == Set({ hub: "h1", payload: early, height: 8, attempt: 0, nth: 0 })) - .expect(early.expiry == Some(8) and MINING_MARGIN == 1) + .expect(early.expiry == Some(9) and MINING_MARGIN == 2) .expect(not(conformingFirstOfferBeforeExpiry) and not(offeredBeforeExpiry)) + .then(chainAdvance) + .expect(s.height() == 9 and s.flightBlocks.get("h1") == MAX_FLIGHT_BLOCKS and flightWithinMargin) + .expect(not(s.indexer.isAcceptable(early))) + .then(judge("h1", early, Rejected)) + .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 9, nth: 0, final: true })) + .expect(not(conformingFirstOfferJudgedBeforeExpiry)) /// K6. A stale hub's flush finds the indexer unreachable, twice. Requeue /// judges the entry at the observed tip, which stopped at 5: the next flush - /// it knows of is the one at 6, so an expiry of 10 looks safe both times. + /// it knows of is the one at 6, so an expiry of 11 looks safe both times. /// The free-running schedule offers it again at 12. run requeuedPastExpiryTest = started @@ -617,7 +675,7 @@ module staleLagScenarios { { hub: "h1", payload: late, height: 9, attempt: 1, nth: 1 }, { hub: "h1", payload: late, height: 12, attempt: 2, nth: 2 }, )) - .expect(late.expiry == Some(10)) + .expect(late.expiry == Some(11)) // The first offer was in time, which is all G6b covers. .expect(not(conformingEveryOfferBeforeExpiry) and conformingFirstOfferBeforeExpiry) @@ -659,11 +717,12 @@ module staleLagWithSlackScenarios { import protocol(CONFIG = staleLagWithSlack).* from "../protocol" /// This configuration's migration from a supported wallet: built at height - /// 2, expiring at the floor of 7 blocks. - pure val atFloor = orchard("early", 2, 9) + /// 2, expiring at the floor of 8 blocks. + pure val atFloor = orchard("early", 2, 10) /// The steps of K4 with an expiry floor one block higher, which is the - /// relation `staleSlackFits` asks for. The same late flush leaves the margin. + /// relation `staleSlackFits` asks for. The same late flush leaves the + /// margin, and after a block in flight the node accepts. run sameSilenceWithSlackKeepsMarginTest = started .then(block) @@ -677,10 +736,16 @@ module staleLagWithSlackScenarios { .then(hubFlushBeginWith("h1")) .expect(audit.offers == Set({ hub: "h1", payload: atFloor, height: 8, attempt: 0, nth: 0 })) .expect(vConformingFirstOfferBeforeExpiry and conformingFirstOfferBeforeExpiry) + .then(chainAdvance) + .expect(s.height() == 9 and s.flightBlocks.get("h1") == MAX_FLIGHT_BLOCKS) + .then(judge("h1", atFloor, Accepted)) + .expect(s.onChain("early") == InMempool) + .expect(audit.verdicts == Set({ hub: "h1", payload: atFloor, height: 9, nth: 0, final: true })) + .expect(conformingFirstOfferJudgedBeforeExpiry) /// This configuration's second migration: built at height 4, expiring at /// the floor. - pure val lateAtFloor = orchard("late", 4, 11) + pure val lateAtFloor = orchard("late", 4, 12) /// Found by simulation; the slack does not cover it. A stale hub's /// free-running clock reads 6 at true height 4, and the flush scheduled for @@ -688,7 +753,8 @@ module staleLagWithSlackScenarios { /// chain is at 5, and admission, which knows the tip and not the schedule's /// history, counts on the flush at 6. That flush has already happened. The /// transaction waits for the one at 9, and a second, shorter silence makes - /// that one late too: it is published at 11, with no margin. + /// that one late too: it is offered at 11 with expiry 12, one block of + /// margin where two are reserved, and a block in flight uses that up. /// /// Neither half is enough alone at these numbers. Without the early flush /// the transaction goes out at 6; without the second silence, at 9. @@ -712,6 +778,10 @@ module staleLagWithSlackScenarios { .expect(conforming(lateAtFloor, MIN_WALLET_EXPIRY) and staleSlackFits) .expect(audit.offers == Set({ hub: "h1", payload: lateAtFloor, height: 11, attempt: 0, nth: 0 })) .expect(not(conformingFirstOfferBeforeExpiry)) + .then(chainAdvance) + .expect(s.height() == 12 and not(s.indexer.isAcceptable(lateAtFloor))) + .then(judge("h1", lateAtFloor, Rejected)) + .expect(not(conformingFirstOfferJudgedBeforeExpiry)) } module byzIndexerScenarios { diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index dbb08e92..44b8dfbe 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -262,11 +262,12 @@ module byzHubTrust { .expect(audit.offers == Set()) .expect(offeredBeforeExpiry) - /// G6b needs the hub. A supported wallet's transaction is refused by an + /// G6b and G6c need the hub. A supported wallet's transaction is refused by an /// honest hub only for reasons that are not about the transaction. This hub /// takes one while it has not yet seen a tip, when an honest hub refuses /// everything. It first sees the chain at height 7, adopts that epoch - /// without flushing, and publishes at 9, past the expiry of 8. + /// without flushing, and offers the transaction at 9, its expiry, when no + /// node can take it. run hubAdmitsBeforeFirstTipTest = init .then(2.reps(_ => chainAdvance)) @@ -283,6 +284,9 @@ module byzHubTrust { .expect(conforming(early, MIN_WALLET_EXPIRY)) .expect(audit.offers == Set({ hub: "h1", payload: early, height: 9, attempt: 0, nth: 0 })) .expect(not(conformingFirstOfferBeforeExpiry)) + .expect(not(s.indexer.isAcceptable(early))) + .then(judge("h1", early, Rejected)) + .expect(not(conformingFirstOfferJudgedBeforeExpiry)) run hubAdmitsBeforeFirstTipControlTest = init @@ -295,7 +299,7 @@ module byzHubTrust { .then(blocks(2)) .then(hubFlushBeginWith("h1")) .expect(audit.offers == Set()) - .expect(conformingFirstOfferBeforeExpiry) + .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) } module awaitAckByzHubTrust { @@ -348,7 +352,8 @@ module byzIndexerTrust { // A hub folds several indexer endpoints into one answer. A lookup answer // comes from the first endpoint that says found, so one misbehaving endpoint // is enough for the first two runs. The tip is the maximum over endpoints, - // so holding it back, as the last two do, takes every endpoint. + // so holding it back, as the last two do, takes every endpoint. The model + // has one abstract indexer and does not enforce that difference. /// G2 needs the indexer. It is offered a batch, reports nothing judged, and /// then serves the unpublished bytes in a lookup answer, which the honest @@ -405,50 +410,80 @@ module byzIndexerTrust { .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) .expect(lookupValidityPerHub) - /// G6a needs the indexer. It stops reporting tip progress at height 2 while - /// the chain goes on, then reports the truth at 5. The flush scheduled for - /// 3 runs at 5, where a transaction expiring at 5 has no margin left. + /// G6a needs the indexer. The hub asks for the tip at every block. From + /// height 3 the indexer goes on answering 2, and at height 5 it answers + /// truthfully. The flush scheduled for 3 runs at 5, where a transaction + /// expiring at 5 has no margin left. run indexerWithholdsTipTest = started .then(block) .then(submitTo("h1", 0, tight)) - .then(3.reps(_ => chainAdvance)) - .expect(s.height() == 5 and s.hubs.get("h1").tip == Some(2)) - .then(observe("h1")) + .then(chainAdvance) + .then(hubObserveTipWith("h1", 2)) + .then(chainAdvance) + .then(hubObserveTipWith("h1", 2)) + .then(chainAdvance) + .expect(s.height() == 5 and s.hubs.get("h1").tip == Some(2) and not(chainMayAdvance)) + .then(hubObserveTipWith("h1", 5)) .then(hubFlushBeginWith("h1")) .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 5, attempt: 0, nth: 0 })) .expect(not(offeredBeforeExpiry)) + /// The same polls, answered truthfully. run indexerWithholdsTipControlTest = started .then(block) .then(submitTo("h1", 0, tight)) - .then(block) + .then(chainAdvance) + .then(hubObserveTipWith("h1", 3)) .then(hubFlushBeginWith("h1")) .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 3, attempt: 0, nth: 0 })) + .then(judge("h1", tight, Accepted)) + .then(hubFlushEndWith("h1")) + .then(chainAdvance) + .then(hubObserveTipWith("h1", 4)) + .then(chainAdvance) + .then(hubObserveTipWith("h1", 5)) + .expect(s.height() == 5 and audit.offers.size() == 1) .expect(offeredBeforeExpiry) - /// G6b needs the indexer. The same silence, kept up to height 8, does it to - /// a supported wallet's transaction. + /// G6b and G6c need the indexer. The same lie, kept up to height 8, does it + /// to a supported wallet's transaction: it is offered at 8 with expiry 9, + /// and after one block in flight the node cannot take it. run indexerWithholdsTipFromConformingTest = started .then(block) .then(submitTo("h1", 0, early)) - .then(6.reps(_ => chainAdvance)) - .then(observe("h1")) + .then(5.reps(_ => chainAdvance.then(hubObserveTipWith("h1", 2)))) + .then(chainAdvance) + .expect(s.height() == 8 and s.hubs.get("h1").tip == Some(2)) + .then(hubObserveTipWith("h1", 8)) .then(hubFlushBeginWith("h1")) .expect(conforming(early, MIN_WALLET_EXPIRY) and audit.admitted.get(("h1", early)).height == 2) .expect(audit.offers == Set({ hub: "h1", payload: early, height: 8, attempt: 0, nth: 0 })) .expect(not(conformingFirstOfferBeforeExpiry)) - + .then(chainAdvance) + .expect(not(s.indexer.isAcceptable(early))) + .then(judge("h1", early, Rejected)) + .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 9, nth: 0, final: true })) + .expect(not(conformingFirstOfferJudgedBeforeExpiry)) + + /// The same polls, answered truthfully: offered at 3, and accepted after a + /// block in flight. run indexerWithholdsTipFromConformingControlTest = started .then(block) .then(submitTo("h1", 0, early)) - .then(block) + .then(chainAdvance) + .then(hubObserveTipWith("h1", 3)) .then(hubFlushBeginWith("h1")) .expect(audit.offers == Set({ hub: "h1", payload: early, height: 3, attempt: 0, nth: 0 })) - .expect(conformingFirstOfferBeforeExpiry) + .then(chainAdvance) + .then(judge("h1", early, Accepted)) + .then(hubFlushEndWith("h1")) + .expect(s.onChain("early") == InMempool) + .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 4, nth: 0, final: true })) + .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) } module replicatedOneByzTrust { diff --git a/zeronym/spec/protocol/tests/wireTest.qnt b/zeronym/spec/protocol/tests/wireTest.qnt index 4d67bb1b..45f942d7 100644 --- a/zeronym/spec/protocol/tests/wireTest.qnt +++ b/zeronym/spec/protocol/tests/wireTest.qnt @@ -51,9 +51,17 @@ module wireTest { /// F2. The documented collision: a queue hit and an indexer answer of /// "found, height 0, no body" are the same reply, though they mean different - /// things. No two honest outcomes collide. + /// things. The wallet cannot tell them apart: it is told pending for both, + /// so an indexer that answers that way for a transaction nobody holds makes + /// the wallet see pending (`indexerForgesPendingTest` is that run). No two + /// honest outcomes collide. run sentinelCollisionTest = all { assert(render(QueueHit) == render(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT })))), + assert(QUERIES.forall(query => + and { + interpretReply(render(QueueHit), query) == Pending, + interpretReply(render(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT }))), query) == Pending, + })), assert(QUERIES.forall(query => meaning(QueueHit, query) != meaning(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT })), query))), diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index bd8f6039..dd49869c 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -214,10 +214,15 @@ module types { minWalletExpiry: int, maxAttempts: int, queueCap: int, + maxFlightBlocks: int, maxHeight: Height, maxRequests: int, submitMode: SubmitMode, roles: Roles, tip: TipModel, + // Which of the two optional timing relations this configuration relies + // on. A configuration that exists to show what one of them buys drops it. + reliesOnReorgSlack: bool, + reliesOnFlightWithinMargin: bool, } } diff --git a/zeronym/spec/protocol/wire.qnt b/zeronym/spec/protocol/wire.qnt index 25959555..2d99573d 100644 --- a/zeronym/spec/protocol/wire.qnt +++ b/zeronym/spec/protocol/wire.qnt @@ -76,6 +76,11 @@ module wire { /// the intent, written without reference to the wire; `interpretReply` after /// `render` is the mechanism, and the two agree on every outcome a queue or /// an honest indexer produces. + /// + /// They disagree on one outcome an honest indexer never produces: an indexer + /// answer of "found, height 0, no body". It means nothing was returned, and + /// on the wire it is the hub's own "queued here", so the wallet is told + /// pending. The encoding is not injective there, and the shim cannot tell. pure def meaning(outcome: HubOutcome, query: TxId): LookupObs = match outcome { | QueueHit => Pending From 88343824e3f5173368b8b0b362bdfda93a166c41 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 15:26:31 +0400 Subject: [PATCH 31/80] test(zeronym): gate the verdict-time claim, the slow flight and the restated durability --- zeronym/spec/protocol/check.sh | 81 ++++++++++++------- zeronym/spec/protocol/tests/scenariosTest.qnt | 3 + 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 07d01b14..9f31fc3d 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -70,8 +70,8 @@ finish() { SPELLS="spells/basicSpells.qnt spells/soup.qnt" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt" -INSTANCES="baseline byzShim byzHub byzIndexer awaitAck awaitAckByzShim awaitAckByzHub awaitAckByzIndexer replicated replicatedOneByz flakyTip flakyTipNoSlack staleLag staleLagWithSlack" -SCENARIOS="baselineScenarios awaitAckScenarios replicatedScenarios flakyTipScenarios flakyTipNoSlackScenarios staleLagScenarios staleLagWithSlackScenarios byzIndexerScenarios byzHubScenarios" +INSTANCES="baseline byzShim byzHub byzIndexer awaitAck awaitAckByzShim awaitAckByzHub awaitAckByzIndexer replicated replicatedOneByz flakyTip flakyTipNoSlack flakyTipSlowFlight staleLag staleLagWithSlack" +SCENARIOS="baselineScenarios awaitAckScenarios replicatedScenarios flakyTipScenarios flakyTipSlowFlightScenarios flakyTipNoSlackScenarios staleLagScenarios staleLagWithSlackScenarios byzIndexerScenarios byzHubScenarios" TRUST="byzShimTrust awaitAckByzShimTrust byzHubTrust awaitAckByzHubTrust byzIndexerTrust replicatedOneByzTrust" fail() { @@ -238,8 +238,10 @@ echo "---- 3 invariants ($SAMPLES traces, seed $SEED)" # The guarantees, where they are claimed. G7 `wellFormed` is checked everywhere. job holds baseline operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub \ - offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed -job holds byzShim offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed + offeredBeforeExpiry conformingFirstOfferBeforeExpiry \ + conformingFirstOfferJudgedBeforeExpiry ackImpliesQueued wellFormed +job holds byzShim offeredBeforeExpiry conformingFirstOfferBeforeExpiry \ + conformingFirstOfferJudgedBeforeExpiry ackImpliesQueued wellFormed job holds byzHub operatorBlind txidAuthenticity wellFormed job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed job holds awaitAck toldImpliesQueued ackImpliesQueued wellFormed @@ -248,14 +250,18 @@ job holds awaitAckByzHub wellFormed job holds awaitAckByzIndexer toldImpliesQueued wellFormed job holds replicated lookupValidityPerHub wellFormed job holds replicatedOneByz txidAuthenticity ackImpliesQueuedForHonestHubs offeredBeforeExpiryForHonestHubs \ - conformingFirstOfferBeforeExpiryForHonestHubs wellFormed -job holds flakyTip conformingFirstOfferBeforeExpiry wellFormed + conformingFirstOfferBeforeExpiryForHonestHubs \ + conformingFirstOfferJudgedBeforeExpiryForHonestHubs wellFormed +job holds flakyTip conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry wellFormed job holds flakyTipNoSlack wellFormed +job holds flakyTipSlowFlight conformingFirstOfferBeforeExpiry wellFormed job holds staleLag wellFormed job holds staleLagWithSlack wellFormed # The trust matrix: each guarantee fails once the component it depends on is -# Byzantine. +# Byzantine. Two of the Byzantine-hub rows have a trace count of their own: +# what they need (a hub that admits before it has seen a tip, and sees one +# late) is rare under random choice. job fails byzShim step 40 operatorBlind job fails byzShim step 40 queuedBytesConfidential job fails byzShim step 40 txidAuthenticity @@ -265,57 +271,66 @@ job fails byzHub step 40 queuedBytesConfidential job fails byzHub step 40 lookupValidityPerHub job fails byzHub step 40 ackImpliesQueued job fails byzHub quietStep 40 offeredBeforeExpiry -job fails byzHub quietStep 40 conformingFirstOfferBeforeExpiry +job fails byzHub quietStep 40 conformingFirstOfferBeforeExpiry 5000 +job fails byzHub quietStep 40 conformingFirstOfferJudgedBeforeExpiry 12000 job fails awaitAckByzHub step 40 toldImpliesQueued job fails byzIndexer step 40 queuedBytesConfidential job fails byzIndexer step 40 lookupValidityPerHub job fails byzIndexer quietStep 40 offeredBeforeExpiry job fails byzIndexer quietStep 80 conformingFirstOfferBeforeExpiry +job fails byzIndexer quietStep 80 conformingFirstOfferJudgedBeforeExpiry job fails replicatedOneByz step 40 queuedBytesConfidential job fails replicatedOneByz step 40 lookupValidityPerHub # The known gaps, with every component honest. -job fails baseline quietStep 40 statusNeverRegresses # K2 -job fails replicated quietStep 40 statusNeverRegresses # K2 -job fails flakyTip quietStep 40 offeredBeforeExpiry # K3 -job fails flakyTipNoSlack quietStep 40 conformingFirstOfferBeforeExpiry # K3' -job fails staleLag quietStep 40 offeredBeforeExpiry # K4 -job fails staleLag quietStep 80 conformingFirstOfferBeforeExpiry # K4 -job fails baseline step 40 ackedIsHeldOrOffered # K5 -job fails awaitAck step 40 ackedIsHeldOrOffered # K5 -job fails staleLag outageStep 80 conformingEveryOfferBeforeExpiry # K6 +job fails baseline quietStep 40 statusNeverRegresses # K2 +job fails replicated quietStep 40 statusNeverRegresses # K2 +job fails flakyTip quietStep 40 offeredBeforeExpiry # K3 +job fails flakyTipNoSlack quietStep 40 conformingFirstOfferBeforeExpiry # K3' +job fails flakyTipNoSlack quietStep 80 conformingFirstOfferJudgedBeforeExpiry # K3' +job fails staleLag quietStep 40 offeredBeforeExpiry # K4 +job fails staleLag quietStep 80 conformingFirstOfferBeforeExpiry # K4 +job fails staleLag quietStep 80 conformingFirstOfferJudgedBeforeExpiry 4000 # K4 +job fails baseline step 40 ackedIsHeldOrSettled # K5 +job fails awaitAck step 40 ackedIsHeldOrSettled # K5 +job fails staleLag outageStep 80 conformingEveryOfferBeforeExpiry # K6 +job fails flakyTipSlowFlight quietStep 80 conformingFirstOfferJudgedBeforeExpiry # K7 # Predicted to hold, observed to fail: the stale slack does not give G6b. -# See "Findings" in README.md. Simulation finds this about once in ten thousand -# traces, so the row has its own, larger, trace count; the scripted run -# `earlyFlushSpendsTheNextEpochTest` is the evidence that does not depend on it. -job fails staleLagWithSlack quietStep 40 conformingFirstOfferBeforeExpiry 15000 +# See "Findings" in README.md. Simulation finds this about once in a few +# thousand traces, so the row has its own, larger, trace count; the scripted +# run `earlyFlushSpendsTheNextEpochTest` is the evidence that does not depend +# on it. +job fails staleLagWithSlack quietStep 60 conformingFirstOfferBeforeExpiry 8000 finish echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" -BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed" +BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub offeredBeforeExpiry conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry ackImpliesQueued wellFormed" -# W4 (all five refusals), W8, W17, K1a, K1b, and the antecedents of G1, G2, G8. +# W4 (four of the five refusals), W8, W17, K1a, K1b, and the antecedents of +# G1, G2, G8. job reaches baseline step 40 \ - wRefusedTipStale wRefusedDraining wRefusedTooLarge wRefusedExpiryTooTight wRefusedFull \ + wRefusedTipStale wRefusedDraining wRefusedTooLarge wRefusedExpiryTooTight \ wQueuedDisclosed wThirdPartyPayloadQueued wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS -# W1, W2, W3, W5, W6, W9, and the antecedents of G3, G4, G6a, G6b. +# W1, W2, W3, W5, W6, W9, and the antecedents of G3, G4, G6a, G6b, G6c. job reaches baseline quietStep 80 \ wPending wTxInMempool wTxMined wRequeued wDroppedExpired wUnparseableMissed \ vTxidAuthenticity vLookupValidityPerHub vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry \ + vConformingFirstOfferJudged \ -- $BASELINE_HOLDS -# W7, W12. +# W4 (the fifth refusal), W7, W12. job reaches baseline outageStep 80 \ - wDroppedExhausted wQueueOverCapacity \ + wRefusedFull wDroppedExhausted wQueueOverCapacity \ -- $BASELINE_HOLDS job reaches byzShim quietStep 40 \ - vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry vAckImpliesQueued \ - -- offeredBeforeExpiry conformingFirstOfferBeforeExpiry ackImpliesQueued wellFormed + vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry vConformingFirstOfferJudged vAckImpliesQueued \ + -- offeredBeforeExpiry conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry \ + ackImpliesQueued wellFormed # W16, both halves. job reaches byzHub quietStep 40 \ vOperatorBlind vTxidAuthenticity wTwinServed wFalseHeightServed \ @@ -340,10 +355,14 @@ job reaches replicated quietStep 80 \ -- lookupValidityPerHub wellFormed job reaches replicatedOneByz quietStep 40 \ vTxidAuthenticity vAckImpliesQueued vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry \ + vConformingFirstOfferJudged \ -- txidAuthenticity ackImpliesQueuedForHonestHubs offeredBeforeExpiryForHonestHubs \ - conformingFirstOfferBeforeExpiryForHonestHubs wellFormed + conformingFirstOfferBeforeExpiryForHonestHubs conformingFirstOfferJudgedBeforeExpiryForHonestHubs wellFormed job reaches flakyTip quietStep 40 \ - vConformingFirstOfferBeforeExpiry vConformingOfferAdmittedBehind \ + vConformingFirstOfferBeforeExpiry vConformingOfferAdmittedBehind vConformingFirstOfferJudged \ + -- conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry wellFormed +job reaches flakyTipSlowFlight quietStep 40 \ + vConformingFirstOfferBeforeExpiry \ -- conformingFirstOfferBeforeExpiry wellFormed # W18. job reaches staleLag quietStep 40 \ diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index ee133d3d..70d86e70 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -123,6 +123,9 @@ module baselineScenarios { .expect(queue == Map(early -> 0, garbage -> 0, junk -> 1)) .expect(audit.dropped == Set({ hub: "h1", payload: tight, attempts: 0 })) .expect(wRequeued and wDroppedExpired and wQueueOverCapacity and not(wDroppedExhausted)) + // `tight` was acknowledged, and is now held nowhere and judged by nobody: + // a third way to lose an acknowledged payload (K5). + .expect(s.acks("h1", ShimAddr).contains((1, WAccepted)) and not(ackedIsHeldOrSettled)) .then(blocks(3)) .then(hubFlushBeginWith("h1")) .then(judge("h1", early, Accepted)) From 55d88082d6b903a22445511fa57c3b53d8de046c Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 15:26:31 +0400 Subject: [PATCH 32/80] test(zeronym): record the review's corrections and the re-derived verdicts --- zeronym/spec/protocol/README.md | 211 +++++++++++++++++++++++--------- 1 file changed, 154 insertions(+), 57 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index cd60fbc9..35c38724 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -12,8 +12,8 @@ modify. ## What "holds" means here **Bounded random simulation.** Every "holds" below was produced by -`quint run`: fixed constants, at most 40 or 80 steps per trace, 2000 random -traces per run, one seed. It is not a proof and it is not exhaustive to any +`quint run`: fixed constants, at most 40, 60 or 80 steps per trace, 2000 +random traces per run (a few rows more), one seed. It is not a proof and it is not exhaustive to any depth. A property that "holds" is one no sampled trace violated. **`quint verify` has not been run**, on Apalache or on TLC, by anyone, on any @@ -42,16 +42,16 @@ specification. No Java is needed. |---|---|---|---| | 1 | typecheck | `quint typecheck` on every file | ok | | 2 | tests | `quint test` on the spells, the four functional test files, each scenario and trust module, each configuration | all pass | -| 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` | "holds" rows hold; "fails" rows are violated | +| 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` ("fails" rows: 40 to 80 steps, a few with more traces) | "holds" rows hold; "fails" rows are violated | | 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | | 5 | two-state properties | `QUINT_TLC=1`, opt-in, **never run** | unknown | Measured on the machine it was written on (Apple silicon, Quint's Rust -evaluator): 3 min 24 s wall with four rows at a time (`QUINT_JOBS=4`, the -default), about 11 minutes of CPU. `QUINT_SAMPLES` changes the trace count. -The rarest witnesses are reached in only 3 to 6 of the 2000 traces, so a lower -count risks losing them. One "fails" row has a count of its own, 15000; see -finding 2. +evaluator): 4 min 40 s wall with four rows at a time (`QUINT_JOBS=4`, the +default), about 14 minutes of CPU. It has not been timed on a CI runner. +`QUINT_SAMPLES` changes the trace count. The rarest witnesses are reached in +only 2 to 6 of the 2000 traces, so a lower count risks losing them. Five +"fails" rows have a larger count of their own, written on the row. Tier 3 "fails" rows and tier 3b run under `step` or under one of two narrower relations, `quietStep` (no faults, no outsiders) and `outageStep` (the indexer @@ -166,8 +166,16 @@ definitions they justify. - **Nonces** are unique. A counter stands for an unguessable value. - **Chain.** A transaction's status only moves forward: no reorg of an included transaction, no mempool eviction. The operator's indexer publishes nothing. -- **Tip.** `TipTimely`: every running hub observes each block before the next, - and a due flush has begun before the next block. `TipMayRegress`: a tip +- **Flight time.** At most `MAX_FLIGHT_BLOCKS` blocks arrive while one flush + is in flight, and that is fewer than the mining margin + (`flightWithinMargin`). The implementation bounds each call to the indexer + (`RPC_TIMEOUT`, `hub/src/chain.rs`), not the batch, and neither in blocks: + the code does not enforce this. A hub whose flush is in flight does not look + at the tip. +- **Tip.** In every model a due flush has begun before the next block. + `TipTimely`: every running, idle hub asks for the tip at each block. An + honest indexer answers with the true height; a Byzantine one is asked just + as often and controls only the answer. `TipMayRegress`: a tip report may trail the chain by up to `REORG_ALLOWANCE`. `TipMayLag`: a hub may hear nothing for a while, and is stale once the silence reaches `STALE_WINDOW` blocks; a stale hub's free-running clock is assumed never @@ -331,6 +339,14 @@ flowchart LR The zero-body indexer answer is not something the honest indexer relation produces, but the honest hub and honest shim pass it through (S22), and one endpoint out of several is enough to inject it (S28). It is therefore reachable only in `byzIndexer`, where "Byzantine indexer" includes "one misbehaving endpoint". +The encoding is not injective at that point, and that is by design: the pending +sentinel has no bytes to tell it apart by. The shim reads both as pending, for +every query (F2), so a wallet cannot tell "queued at the hub" from "an indexer +said found and returned nothing". `meaning` gives the two different meanings; +`interpretReply` after `render` gives one observation. `indexerForgesPendingTest` +is the trace-level consequence: the wallet is told pending for a transaction +nobody holds, and G4 fails. + ## Layout Only `protocol.qnt` declares a constant or a variable. Every other module is @@ -348,7 +364,7 @@ pure. | `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | | `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | | `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, A1-A3, the run vocabulary | -| `instances.qnt` | `configs`, then one module per configuration | The fourteen configurations | +| `instances.qnt` | `configs`, then one module per configuration | The fifteen configurations | | `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14 | | `tests/scenariosTest.qnt` | one module per configuration used | Witnesses and pinned gap causes | | `tests/trustTest.qnt` | one module per Byzantine configuration | One run and one control per "required" cell | @@ -409,7 +425,12 @@ symmetric: the tip is the maximum over endpoints, a lookup takes the first "found", a broadcast takes the best verdict. So **one** misbehaving endpoint is enough to raise the tip, inject a lookup answer or change a verdict, while lowering or freezing the tip takes **every** endpoint. Each indexer cell below -says which it needs. +says which it needs. That is prose about the abstraction: the model has one +abstract indexer, standing for the fold over all endpoints, and **does not +enforce** the difference. Its Byzantine relation can report any tip, high or +low. Modelling the endpoints as a set, so that the transition relation itself +separates "one endpoint" from "all of them", was considered and not done: one +abstract indexer per hub was a decision of the design. ### Configurations @@ -430,6 +451,7 @@ One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields | `replicatedOneByz` | 2 | `DispatchOnly` | H / H, **B** / H | timely | | `flakyTip` | 1 | `DispatchOnly` | H / H / H | may regress | | `flakyTipNoSlack` | 1 | `DispatchOnly` | H / H / H | may regress; `reorgSlackFits` false | +| `flakyTipSlowFlight` | 1 | `DispatchOnly` | H / H / H | may regress; `flightWithinMargin` false | | `staleLag` | 1 | `DispatchOnly` | H / H / H | may lag; `staleSlackFits` false, as shipped | | `staleLagWithSlack` | 1 | `DispatchOnly` | H / H / H | may lag; `staleSlackFits` true | @@ -439,17 +461,34 @@ numbers: | | Interval | Margin | Delivery lag | Reorg allowance | Staleness window | Expiry floor | |---|---|---|---|---|---|---| | Shipped | 20 | 4 | 6 | 10 | 12 blocks (15 min at 75 s) | 40 | -| Model | 3 | 1 | 1 | 1 | 3 | 6 | - -The slack `floor - (interval + margin + lag)` equals the reorg allowance in -both (10 and 1). `interval + margin + lag + (window - 1)` exceeds the floor by -one in both (41 > 40, 7 > 6). `flakyTipNoSlack` uses a floor of 5 and -`staleLagWithSlack` a floor of 7. Also: at most 2 requeues, room for 2 entries, +| Model | 3 | 2 | 1 | 1 | 3 | 7 | + +The relations kept, each asserted by `assumptionsTest` through +`shippedRelationsKept`: + +- the slack `floor - (interval + margin + lag)` equals the reorg allowance + (10 and 1); +- the staleness window exceeds the margin (12 > 4, 3 > 2) and the slack + (12 > 10, 3 > 1); +- `interval + margin + lag + (window - 1)` exceeds the floor by exactly one + (41 = 40 + 1, 8 = 7 + 1); +- `interval + lag + (window - 1)` does not exceed it (37 <= 40, 6 <= 7). + +The margin is 2, not 1, so that one block can arrive while a flush is in flight +and still be inside it: `MAX_FLIGHT_BLOCKS` is 1 everywhere except +`flakyTipSlowFlight`, where it is 2. `flakyTipNoSlack` uses a floor of 6 and +`staleLagWithSlack` a floor of 8; those two do not keep the relations, and +their tests assert that. Also: at most 2 requeues, room for 2 entries, heights up to 12, at most 3 sends and 3 lookups by the wallet and 3 requests by the third party. Each configuration has an `assumptionsTest`. The simulator does not enforce -`assume`, so that test is the check that counts. +`assume`, so that test is the check that counts. Every `assume` in +`protocol.qnt` is true of every configuration. `reorgSlackFits` and +`flightWithinMargin` are assumed only where the configuration says it relies on +them (`reliesOnReorgSlack`, `reliesOnFlightWithinMargin`); `flakyTipNoSlack` +and `flakyTipSlowFlight` each drop one, and their tests assert it is false. +`staleSlackFits` is never assumed. ## Properties @@ -458,7 +497,7 @@ Each configuration has an `assumptionsTest`. The simulator does not enforce | Id | Statement | Test | |---|---|---| | F1 | `interpretReply(render(o), q) == meaning(o, q)` for every outcome a queue or an honest indexer produces | `wireTest::renderThenInterpretIsMeaningTest` | -| F2 | The documented collision: a queue hit and an indexer's "found, height 0, no body" render to the same reply. `render` is injective on honest outcomes | `wireTest::sentinelCollisionTest` | +| F2 | The documented collision: a queue hit and an indexer's "found, height 0, no body" render to the same reply, and the shim reads both as pending for every query. `render` is injective on honest outcomes | `wireTest::sentinelCollisionTest` | | F3 | The shim serves a transaction only if its txid is the one asked for. A twin is served; the height is passed through unchecked | `wireTest::servedOnlyOnMatchingTxidTest` | | F4 | An error never becomes "not found" | `wireTest::errorIsNeverNotFoundTest` | | F5 | The shim forwards only cleanly read pass-through transactions | `shimTest::onlyPassThroughIsForwardedTest` | @@ -481,8 +520,9 @@ Each configuration has an `assumptionsTest`. The simulator does not enforce | G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | | G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree, or that hubs agree | | G5 | `toldImpliesQueued` | A wallet told ok can rely on some hub having queued the transaction. Claimed under `AwaitVerdict` only | -| G6a | `offeredBeforeExpiry` | Every transaction a hub publishes is published with the mining margin to spare: whatever was admitted, on every attempt. Claimed under a timely tip | -| G6b | `conformingFirstOfferBeforeExpiry` | The same for supported wallets and for the first time a hub publishes the transaction. Nothing about a later offer of a requeued entry | +| G6a | `offeredBeforeExpiry` | Every transaction a hub offers is offered with the mining margin to spare: whatever was admitted, on every attempt. About the margin left when the flush begins, not about acceptance. Claimed under a timely tip | +| G6b | `conformingFirstOfferBeforeExpiry` | The same for supported wallets and for the first time a hub offers the transaction. Nothing about a later offer of a requeued entry. Also about the margin at the offer | +| G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and `flightWithinMargin` | | G7 | `wellFormed` | Structural sanity; checked in every configuration; not a trust-matrix row | | G8 | `ackImpliesQueued` | An accepted ack from a hub is for a payload that hub had queued by then, whether or not anyone waits for the ack | @@ -514,8 +554,17 @@ the component is Byzantine and the guarantee fails, followed by its control (same wallet inputs, honest transition, guarantee holds); the simulation row for such a cell shows polarity only. -Every cell was a prediction. **Observed verdicts agree with the predictions in -every cell of this table except the G6b entries marked below.** +Every cell was a prediction, except the G6c row, which was added after review +and derived by running. **Observed verdicts agree with the predictions in every +cell of this table except the G6b entries marked below.** + +The two tip-withholding runs in the indexer column have the hub ask for the tip +at every block and the indexer answer with a stale one; their controls are the +same polls answered truthfully. The simulation rows for those cells classify a +verdict line and cannot say which lie a trace used. The counterexamples the +simulator finds at seed 7 were read by hand and both use reports below the true +height. With truthful answers `byzIndexer` behaves as `baseline`, where the +chain cannot pass a running, idle hub that has not asked. | | All honest | Byzantine shim | Byzantine hub | Byzantine indexer | |---|---|---|---|---| @@ -527,6 +576,7 @@ every cell of this table except the G6b entries marked below.** | G8 | holds (`baseline`, `awaitAck`) | holds (`byzShim`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | | G6a | holds (`baseline`) | holds (`byzShim`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | | G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | One Byzantine replica out of two (`replicatedOneByz`): @@ -535,7 +585,7 @@ One Byzantine replica out of two (`replicatedOneByz`): | G2 | required of every hub | `oneReplicaServesQueuedBodyTest` | | G4 | required of every hub | `cursorLandsOnLyingReplicaTest` | | G3 | holds | simulation; `wrongTransactionIsRefusedTest` | -| G8, G6a, G6b for the honest hub | hold | simulation of `ackImpliesQueuedForHonestHubs`, `offeredBeforeExpiryForHonestHubs`, `conformingFirstOfferBeforeExpiryForHonestHubs`; `honestReplicaKeepsItsGuaranteesTest` | +| G8, G6a, G6b, G6c for the honest hub | hold | simulation of `ackImpliesQueuedForHonestHubs`, `offeredBeforeExpiryForHonestHubs`, `conformingFirstOfferBeforeExpiryForHonestHubs`, `conformingFirstOfferJudgedBeforeExpiryForHonestHubs`; `honestReplicaKeepsItsGuaranteesTest` | | G5 | not applicable | `AwaitVerdict` has one hub | | "some honest hub queued it" after told ok | not a guarantee | `honestReplicaKeepsItsGuaranteesTest` | @@ -555,11 +605,19 @@ replica is enough to void G2 and G4. | K1 | Under `DispatchOnly`, told ok does not mean any hub ever admits it | `baseline`, `replicated` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered`, `wToldPrefixOnly` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest`, `toldOkAfterPrefixSendTest` | | K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline`, `replicated` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest`, `hubsDisagreeTest` | | K3 | G6a for a tight-expiry transaction: admitted against a tip reported below a boundary already flushed | `flakyTip` | violated invariant | violated, as predicted | `tightExpiryAdmittedBehindFlushedBoundaryTest` | -| K3' | G6b when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | -| K4 | G6a, and G6b on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | -| K5 | `ackedIsHeldOrOffered`: an acknowledged payload is still held, or was offered | `baseline`, `awaitAck` | violated invariant | violated by a crash. **Not violated by a failed final flush**, which was predicted as a second cause | `ackedThenCrashedTest`, `toldOkAdmittedThenLostTest`, `ackedThenLostAtDrainTest` | +| K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | +| K4 | G6a, and G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | +| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `baseline`, `awaitAck` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `toldOkAdmittedThenLostTest`, `ackedThenLostAtDrainTest`, `requeueAndDropTest` | | K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | +| K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest`; contrast `conformingSurvivesRegressionTest` | + +K7 was added after review. The four-term budget (`reorgSlackFits`) holds with +equality in the shipped constants, so a transaction that uses all of it is +offered with exactly the mining margin left. The margin is then the only thing +that pays for blocks arriving while the batch is in flight, and nothing in the +code bounds a flight in blocks. + K1 is not stated as a violated invariant because the invariant is false on the ordinary success path too: under `DispatchOnly` the wallet is told ok before any hub has the frame. In `toldOkAndNeverDeliveredTest` the run ends with the @@ -588,7 +646,8 @@ Non-vacuity: for each guarantee, a state where its antecedent holds, reached on every configuration where the guarantee is claimed: `vOperatorBlind`, `vQueuedBytesConfidential`, `vTxidAuthenticity`, `vLookupValidityPerHub` (the log has a pending, a served transaction and a not-found), `vToldImpliesQueued`, -`vOfferedBeforeExpiry`, `vConformingFirstOfferBeforeExpiry`, `vAckImpliesQueued`, +`vOfferedBeforeExpiry`, `vConformingFirstOfferBeforeExpiry`, +`vConformingFirstOfferJudged`, `vAckImpliesQueued`, and on `flakyTip` also `vConformingOfferAdmittedBehind` (a conforming first offer of a payload admitted while the hub's tip was behind the chain). @@ -615,17 +674,19 @@ changed to make a prediction come out. **1. K4, confirmed: a short tip silence costs a supported wallet its mining margin, on the shipped relation between the constants.** In `silenceAcrossBoundaryMissesMarginTest`: a transaction built at height 2 with -expiry 8 (the floor) is admitted at 3. The hub last sees the tip at 5, one +expiry 9 (the floor) is admitted at 3. The hub last sees the tip at 5, one block short of the flush at 6. Its cadence follows the tip it last saw, so -nothing is flushed until it goes stale at 8. The transaction is published at -height 8: not yet expired, and without the block the margin reserves -(`8 < 8 + 1`). With the shipped numbers the same shape gives a first offer at -`created + 6 + 20 + 11 = created + 37` against an expiry of `created + 40`: -three blocks of margin where four are reserved. `staleSlackFits` -(`interval + margin + lag + window - 1 <= floor`) is false of the shipped -constants (41 > 40). The one-block figure depends on reading 15 minutes as -exactly 12 blocks; blocks are not that regular, so the real shortfall is -sometimes larger. +nothing is flushed until it goes stale at 8. The transaction is offered at +height 8 with one block of margin where two are reserved (`9 < 8 + 2`). One +block then arrives while the batch is in flight, which the model permits and +the margin exists to pay for, and at height 9 no honest node can accept it: +G6b and G6c both fail. With the shipped numbers the same shape gives a first +offer at `created + 6 + 20 + 11 = created + 37` against an expiry of +`created + 40`: three blocks of margin where four are reserved. +`staleSlackFits` (`interval + margin + lag + window - 1 <= floor`) is false of +the shipped constants (41 > 40). The one-block figure depends on reading 15 +minutes as exactly 12 blocks; blocks are not that regular, so the real +shortfall is sometimes larger. **2. The relation that fixes K4 does not give G6b: an early free-running flush spends the next epoch.** This was predicted to hold on `staleLagWithSlack` and @@ -636,23 +697,31 @@ tip again, at 5. Admission knows the tip and not the schedule's history: it admits a transaction counting on the flush at 6. That flush has already happened; the cadence loop flushes only when the epoch exceeds the last one it recorded. The transaction waits for the flush at 9, and a further silence of -two blocks, short of the staleness window, makes that one late: it is published -at 11 with expiry 11. By the arithmetic of that run each half alone is -harmless at these numbers; simulation finds the combination about once in ten -thousand traces. -The implementation's comment calls a free-running clock that runs ahead "the -safe direction". It is safe for what is in the queue when it runs. It is not -safe for what is admitted after the tip returns, while the chain is still -behind an epoch the clock has already spent. The model bounds how far ahead the -clock may be (one interval); the code does not, and a clock further ahead -would spend more than one epoch. That last sentence is a reading of -`cadence_height` in `hub/src/batcher.rs`, not something the model shows. - -**3. K5: a failed final flush does not violate `ackedIsHeldOrOffered`.** The -entry was offered, which is all the invariant asks. The transaction is lost all -the same, and `ackedThenLostAtDrainTest` pins that: hub stopped, nothing held, -nothing on the chain, an accepted ack in the soup. The invariant is violated by -a crash only. +two blocks, short of the staleness window, makes that one late: it is offered +at 11 with expiry 12, half its margin gone, and after one block in flight the +node cannot accept it. By the arithmetic of that run each half alone is +harmless at these numbers; simulation finds the combination about once in a few +thousand traces of 60 steps. The implementation's comment calls a free-running +clock that runs ahead "the safe direction". It is safe for what is in the queue +when it runs. It is not safe for what is admitted after the tip returns, while +the chain is still behind an epoch the clock has already spent. + +The model lets the free-running clock be at most one flush interval ahead of +the chain, so it can spend one epoch and no more. `cadence_height` +(`hub/src/batcher.rs`) adds elapsed time over the nominal block time with no +cap. Reading that code, a clock further ahead would record a later epoch and +skip more than one boundary. **That is a reading of the code. The model does +not exhibit it and no run here shows it.** + +**3. K5: an acknowledged payload can be lost three ways, all with every +component honest.** The invariant first written, "held or offered", counted an +offer at the start of a flush as settling the payload, and so was not violated +by a final flush that nothing judged. Review pointed that out. It is restated +as `ackedIsHeldOrSettled`: held by the hub, or on the chain, or judged by a +node. That is violated by a crash (`ackedThenCrashedTest`), by a draining hub's +final flush that finds the indexer unreachable (`ackedThenLostAtDrainTest`), +and by a requeue that drops an entry as expired after an outage +(`requeueAndDropTest`). The third was not predicted; simulation found it. **4. G3 does not depend on the shim's txid check when every component is honest.** Removing the comparison from `interpretReply` leaves G3 holding on @@ -662,12 +731,22 @@ claimed to matter. G3 is kept as a guarantee: it is falsifiable where it is claimed "by the check", and other changes to honest code would break it on `baseline`. -**5. G6b needs the hub, but not for the predicted reason.** A hub that admits +**5. G6b and G6c need the hub, but not for the predicted reason.** A hub that admits past the expiry rule cannot break G6b, because the expiry rule never refuses a conforming, timely transaction (F7). What breaks it is a hub that admits while it has no tip, when an honest hub refuses everything (`hubAdmitsBeforeFirstTipTest`). +**7. G6 stops at the offer; G6c and K7 were added to see past it.** G6a and +G6b stamp an offer when the flush begins. The node judges later, and the chain +may have moved. With the first scaling (margin 1) the runs that showed "the +slack is exactly enough" ended one enabled block before the transaction became +unacceptable. The schedule is now scaled with a margin of 2, flight time is +bounded by `MAX_FLIGHT_BLOCKS`, and G6c is checked at the verdict. Observed: +G6c holds on `baseline`, `flakyTip` and `byzShim`, and for the honest hub of +`replicatedOneByz`; it fails wherever G6b fails, and on `flakyTipSlowFlight` +where G6b holds. + **6. The second clause of G1, "and no lookup", is not stated.** No output of the shim function routes a lookup to the operator, so the clause would hold by construction and could not be broken by any of the listed changes. @@ -719,6 +798,24 @@ quint verify --main=awaitAckByzIndexer --invariant=toldImpliesQueued --max-steps quint verify --main=flakyTip --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt ``` +The same for G6c, and the configurations whose point is a violation (each +should report one; `flakyTipNoSlack` and `flakyTipSlowFlight` satisfy every +`assume`, so a checker that honours `assume` still has states to explore): + +```sh +quint verify --main=baseline --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=byzShim --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=flakyTip --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=flakyTipNoSlack --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=flakyTipSlowFlight --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=staleLag --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=staleLagWithSlack --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt +quint verify --main=baseline --invariant=ackedIsHeldOrSettled --max-steps=12 zeronym/spec/protocol/instances.qnt +``` + +Several of the scripted counterexamples are longer than 12 steps, so +`--max-steps=12` may not reach these violations; raise it as needed. + Each witness, as a reachability check that should report a violation: ```sh From 83133e3a9144158ae106547ad2f0c1978636a4cc Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Wed, 7 Oct 2026 16:02:30 +0400 Subject: [PATCH 33/80] test(zeronym): bind drainIsFinal to honest hubs and pin a Byzantine hub admitting while draining --- zeronym/spec/protocol/README.md | 13 ++++++++-- zeronym/spec/protocol/protocol.qnt | 7 +++--- zeronym/spec/protocol/tests/trustTest.qnt | 29 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 35c38724..c78220e8 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -577,6 +577,7 @@ chain cannot pass a running, idle hub that has not asked. | G6a | holds (`baseline`) | holds (`byzShim`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | | G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| A3 | not run (TLC, `baseline`) | not run | **required**: `hubAdmitsWhileDrainingTest` | not run | One Byzantine replica out of two (`replicatedOneByz`): @@ -596,7 +597,8 @@ In short: a Byzantine shim voids every wallet-facing guarantee (G1-G5); the hub-side G6 and G8 survive it. G3 is the only wallet-facing guarantee that survives a Byzantine hub or indexer, and it authenticates the txid only. G1 depends on the shim alone. Replication does not dilute trust: one Byzantine -replica is enough to void G2 and G4. +replica is enough to void G2 and G4. A3 needs the hub: its "required" +cell is a scripted step, and its "holds" cells are the unrun TLC property. ### Known gaps, with every component honest @@ -660,7 +662,14 @@ form, and typechecked. **None has been run.** |---|---|---|---| | A1 | `chainMonotone` | A transaction's chain status never moves backwards | assumption about the environment | | A2 | `neverEvict` | An entry leaves a hub's queue only into a flush, or because the hub went down | guarantee | -| A3 | `drainIsFinal` | A draining hub's queue gains only what a flush hands back | guarantee | +| A3 | `drainIsFinal` | A draining honest hub's queue gains only what a flush hands back | guarantee | + +A3 is stated over the honest hubs only. Draining is an admission rule, and a +Byzantine hub is not bound by admission rules: `hubAdmitsWhileDrainingTest` +takes a submission into the queue after the drain began, and its control +refuses the same frame. That run asserts the step, because the simulator +does not check `temporal` definitions. A2 is stated over every hub: the +Byzantine hub relation only ever adds to a queue. No liveness property is claimed: the network may lose everything, and under `DispatchOnly` nobody waits for an ack. diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index b6363f73..1ee18a6b 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -955,10 +955,11 @@ module protocol { ).orKeep(s) ) - /// A3. A draining hub admits nothing: its queue gains only what a flush - /// hands back. + /// A3. A draining honest hub admits nothing: its queue gains only what a + /// flush hands back. Draining is an admission rule like the others, so a + /// Byzantine hub is not bound by it (`hubAdmitsWhileDrainingTest`). temporal drainIsFinal = always( - HUBS.forall(id => + HONEST_HUBS.forall(id => phaseOf(id) == Draining implies next(queueOf(id)).subseteq(queueOf(id).union(flightOf(id))) ).orKeep(s) ) diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 44b8dfbe..bc19a5ad 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -234,6 +234,35 @@ module byzHubTrust { .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and s.queuedAt("h1") == Set(early)) .expect(ackImpliesQueued) + /// A3 needs the hub. Once its drain has begun, it takes a submission into + /// the queue. The simulator does not check `temporal` definitions, so the + /// run asserts the step itself: draining before and after, nothing in + /// flight, and the queue grown by a payload no flush handed back. + run hubAdmitsWhileDrainingTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(hubBeginDrainWith("h1")) + .then(sendToAll(tight)) + .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early) and s.inFlightAt("h1") == Set()) + .then(hubReceiveWith( + "h1", submitMail("h1", 1, tight), INotFound, + { ...h1, queue: h1.queue.put(tight, 0) }.toAckOutput(1, Admitted), + )) + .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early, tight)) + .expect(not(wRefusedDraining)) + + run hubAdmitsWhileDrainingControlTest = + started + .then(block) + .then(submitTo("h1", 0, early)) + .then(hubBeginDrainWith("h1")) + .then(sendToAll(tight)) + .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early) and s.inFlightAt("h1") == Set()) + .then(deliverSubmit("h1", 1, tight)) + .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early)) + .expect(wRefusedDraining) + /// G6a needs the hub. It admits a transaction the expiry rule refuses: one /// expiring at 5, taken at tip 3, when the next flush is at 6. run hubAdmitsPastExpiryRuleTest = From 7c41c7c4710e42713bc24e678aa0141294844ef4 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 00:38:40 +0400 Subject: [PATCH 34/80] test(zeronym): import the properties unqualified so the specification flattens --- zeronym/spec/protocol/properties.qnt | 88 ++++++++++---------- zeronym/spec/protocol/protocol.qnt | 117 ++++++++++++++------------- 2 files changed, 103 insertions(+), 102 deletions(-) diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index d5e17e80..7a1b7cbb 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -165,13 +165,13 @@ module properties { /// G1. The operator sees no migration: everything the shim hands it is a /// pass-through transaction. - pure def operatorBlind(s: System): bool = + pure def operatorBlindIn(s: System): bool = s.operator.forall(payload => payload.class == PassThrough) /// G2. A transaction's bytes do not reach a third party before the chain has /// published them. Everything the third party has learned is on the chain, /// or was a pass-through transaction the operator was given. - pure def queuedBytesConfidential(s: System): bool = + pure def queuedBytesConfidentialIn(s: System): bool = s.tpLearned().forall(payload => or { s.indexer.published().contains(payload), @@ -181,7 +181,7 @@ module properties { /// G3. A transaction served to the wallet has the txid the wallet asked for. /// That is all: it need not be the bytes the wallet sent (a twin passes), /// and its height is whatever the hub said. - pure def txidAuthenticity(s: System): bool = + pure def txidAuthenticityIn(s: System): bool = s.events().forall(event => match event { | Got(got) => @@ -198,7 +198,7 @@ module properties { /// It is a statement about one request and one hub. It does not say that /// successive answers agree, nor that hubs agree with each other; see /// `statusNeverRegresses`. - pure def lookupValidityPerHub(s: System, audit: Audit): bool = + pure def lookupValidityPerHubIn(s: System, audit: Audit): bool = s.events().forall(event => match event { | Got(got) => @@ -216,7 +216,7 @@ module properties { /// G5. A wallet told its transaction was diverted can rely on some hub /// having queued it. Claimed when the wallet's answer is the hub's /// (`AwaitVerdict`); see `wToldNeverDelivered` for when it is not. - pure def toldImpliesQueued(s: System, audit: Audit): bool = + pure def toldImpliesQueuedIn(s: System, audit: Audit): bool = s.toldOk().forall(payload => s.hubIds().exists(hub => audit.everQueued.get(hub).contains(payload))) @@ -247,14 +247,14 @@ module properties { /// G6a. Every transaction one of `hubs` offers is offered with the mining /// margin to spare: whatever was admitted, on every attempt. A claim about /// the margin left at the offer, not about acceptance. - pure def offeredBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = + pure def offeredBeforeExpiryIn(s: System, audit: Audit, hubs: Set[HubId]): bool = audit.offers.forall(offer => hubs.contains(offer.hub) implies offeredInTime(s, offer)) /// G6b. The same, for supported wallets only and for the first time a hub /// offers the transaction. It says nothing about a later offer of an entry /// that was requeued; see `conformingEveryOfferBeforeExpiry`. Like G6a it is /// a claim about the margin left at the offer. - pure def conformingFirstOfferBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = + pure def conformingFirstOfferBeforeExpiryIn(s: System, audit: Audit, hubs: Set[HubId]): bool = audit.offers.forall(offer => and { hubs.contains(offer.hub), @@ -270,7 +270,7 @@ module properties { /// The margin is what pays for the blocks that arrive while the batch is in /// flight. G6c therefore needs G6b and one thing more: that fewer blocks /// than the mining margin arrive during a flush (`flightWithinMargin`). - pure def conformingFirstOfferJudgedBeforeExpiry(s: System, audit: Audit, hubs: Set[HubId]): bool = + pure def conformingFirstOfferJudgedBeforeExpiryIn(s: System, audit: Audit, hubs: Set[HubId]): bool = audit.verdicts.forall(verdict => and { hubs.contains(verdict.hub), @@ -285,7 +285,7 @@ module properties { /// G7. Structural sanity: a queued entry is within its attempts; a hub that /// is down holds nothing and knows nothing; every nonce in use was minted. - pure def wellFormed(s: System): bool = + pure def wellFormedIn(s: System): bool = and { s.hubIds().forall(hub => val state = s.hubs.get(hub) @@ -308,7 +308,7 @@ module properties { /// G8. An accepted ack from one of `hubs` is for a payload that hub had /// queued by the time it acked. It holds whether or not anyone waits for /// the ack. - pure def ackImpliesQueued(s: System, audit: Audit, hubs: Set[HubId]): bool = + pure def ackImpliesQueuedIn(s: System, audit: Audit, hubs: Set[HubId]): bool = hubs.forall(hub => s.ackedAt(hub).subseteq(audit.everQueued.get(hub))) // ------------------------------------------------------------------------ @@ -321,7 +321,7 @@ module properties { /// transaction can be queued again; a flush empties the queue before the /// chain has the batch; the node can reject at flush; and two hubs need not /// agree. - pure def statusNeverRegresses(s: System): bool = + pure def statusNeverRegressesIn(s: System): bool = val log = s.wallet.log tuples(log.indices(), log.indices()).forall(((i, j)) => i < j implies @@ -352,7 +352,7 @@ module properties { /// /// This does not hold. The queue lives in memory: a crash after the ack /// loses it, and so does a final flush that finds the indexer unreachable. - pure def ackedIsHeldOrSettled(s: System, audit: Audit): bool = + pure def ackedIsHeldOrSettledIn(s: System, audit: Audit): bool = s.hubIds().forall(hub => s.ackedAt(hub).forall(payload => or { @@ -366,7 +366,7 @@ module properties { /// K6. G6b without its restriction to the first offer. This does not hold /// once a hub is stale: requeue judges an entry at the observed tip, which /// has stopped, while the flush schedule runs on. - pure def conformingEveryOfferBeforeExpiry(s: System, audit: Audit): bool = + pure def conformingEveryOfferBeforeExpiryIn(s: System, audit: Audit): bool = audit.offers.forall(offer => isConformingAndTimely(s, audit, offer.hub, offer.payload) implies offeredInTime(s, offer)) // K1. Under `DispatchOnly`, "told ok" promises nothing about any hub. It is @@ -385,7 +385,7 @@ module properties { /// K1a. The wallet was told ok; every submission that reached a hub was /// refused; no hub ever queued the payload. - pure def wToldRefusedEverywhere(s: System, audit: Audit): bool = + pure def wToldRefusedEverywhereIn(s: System, audit: Audit): bool = s.toldOk().exists(payload => val submitted = s.shimSubmissionsOf(payload) and { @@ -397,7 +397,7 @@ module properties { /// K1b. The wallet was told ok; no hub has answered any of the frames; no /// hub ever queued the payload. The network may leave it so forever. - pure def wToldNeverDelivered(s: System, audit: Audit): bool = + pure def wToldNeverDeliveredIn(s: System, audit: Audit): bool = s.toldOk().exists(payload => and { neverQueued(s, audit, payload), @@ -407,7 +407,7 @@ module properties { /// K1c. The wallet was told ok though the sweep stopped early: some hub was /// never sent the payload. - pure def wToldPrefixOnly(s: System): bool = + pure def wToldPrefixOnlyIn(s: System): bool = s.toldOk().exists(payload => val reached = s.shimSubmissionsOf(payload).map(submit => submit._1) reached != Set() and reached != s.hubIds()) @@ -424,11 +424,11 @@ module properties { }) /// W1. The wallet is told its transaction is pending. - pure def wPending(s: System): bool = + pure def wPendingIn(s: System): bool = s.wasGiven(obs => obs == Pending) /// W2. The wallet is served its transaction from the mempool. - pure def wTxInMempool(s: System): bool = + pure def wTxInMempoolIn(s: System): bool = s.wasGiven(obs => match obs { | Tx(tx) => tx.height == MEMPOOL_HEIGHT @@ -436,7 +436,7 @@ module properties { }) /// W3. The wallet is served its transaction from a block. - pure def wTxMined(s: System): bool = + pure def wTxMinedIn(s: System): bool = s.wasGiven(obs => match obs { | Tx(tx) => tx.height != MEMPOOL_HEIGHT @@ -448,19 +448,19 @@ module properties { audit.refusals.contains(refusal) /// W5. An entry nothing judged is back in a queue. - pure def wRequeued(s: System): bool = + pure def wRequeuedIn(s: System): bool = s.hubIds().exists(hub => s.queuedAt(hub).exists(payload => s.hubs.get(hub).queue.get(payload) > 0)) /// W6. A requeue dropped an entry that could no longer survive the next /// flush: it was given up on while it still had attempts left. - pure def wDroppedExpired(s: System, audit: Audit): bool = + pure def wDroppedExpiredIn(s: System, audit: Audit): bool = audit.dropped.exists(entry => entry.attempts + 1 <= s.hubs.get(entry.hub).params.maxAttempts) /// W7. A requeue dropped an entry that was out of attempts: one with no /// expiry, which nothing else would ever have stopped. - pure def wDroppedExhausted(audit: Audit): bool = + pure def wDroppedExhaustedIn(audit: Audit): bool = audit.dropped.exists(entry => entry.payload.expiry == None) /// W8. The accepted disclosure: a third party that knows a txid learns that @@ -473,7 +473,7 @@ module properties { /// > answering NotFound, which costs a wallet the ability to tell "pending" /// > from "never seen". That is a product decision, not a code one, and it /// > is left open deliberately. - pure def wQueuedDisclosed(s: System): bool = + pure def wQueuedDisclosedIn(s: System): bool = tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => and { lookup._1 == reply._1, @@ -484,7 +484,7 @@ module properties { /// W9. A hub holds a payload it cannot parse; the wallet that sent it asks /// that hub for it and is told not found. An entry without a txid can never /// be hit. - pure def wUnparseableMissed(s: System): bool = + pure def wUnparseableMissedIn(s: System): bool = tuples(s.events(), s.lookups(ShimAddr)).exists(((event, lookup)) => match event { | Got(got) => @@ -499,12 +499,12 @@ module properties { /// W12. A queue holds more than its capacity: requeue honours the older /// promise over the newer limit. - pure def wQueueOverCapacity(s: System): bool = + pure def wQueueOverCapacityIn(s: System): bool = s.hubIds().exists(hub => s.queuedAt(hub).size() > s.hubs.get(hub).params.queueCap) /// W13. A lookup has moved on from a hub that did not answer, and the next /// hub has answered it. - pure def wFailoverAnswered(s: System): bool = + pure def wFailoverAnsweredIn(s: System): bool = s.shim.waiters.keys().exists(nonce => match s.shim.waiters.get(nonce) { | LookupWaiter(waiter) => @@ -514,7 +514,7 @@ module properties { /// W14. Two hubs have each published the same payload, in their own flushes, /// and it is on the chain: replication, not failover. - pure def wPublishedByTwoHubs(s: System, audit: Audit): bool = + pure def wPublishedByTwoHubsIn(s: System, audit: Audit): bool = s.indexer.published().exists(payload => and { audit.offers.filter(offer => offer.payload == payload).map(offer => offer.hub).size() > 1, @@ -535,16 +535,16 @@ module properties { /// W15. Premature flush: a hub that follows its tip flushes ahead of the /// chain, because the tip it was given is ahead of the chain. - pure def wPrematureFlush(s: System): bool = + pure def wPrematureFlushIn(s: System): bool = s.hubIds().exists(hub => s.hubs.get(hub).cadence == Tracking and flushesAheadOfChain(s, hub)) /// W18. Early flush: a stale hub flushes ahead of the chain, because its /// free-running clock is ahead of the chain. - pure def wEarlyFreeRunFlush(s: System): bool = + pure def wEarlyFreeRunFlushIn(s: System): bool = s.hubIds().exists(hub => s.hubs.get(hub).cadence != Tracking and flushesAheadOfChain(s, hub)) /// W16a. The wallet is served a twin of what it sent: other bytes, same txid. - pure def wTwinServed(s: System): bool = + pure def wTwinServedIn(s: System): bool = s.wasGiven(obs => match obs { | Tx(tx) => s.seenByShim().exists(sent => s.toldOk().contains(sent) and areTwins(sent, tx.payload)) @@ -553,7 +553,7 @@ module properties { /// W16b. The wallet is served a transaction at a height that cannot be /// true: the chain does not have it, or has not got that far. - pure def wFalseHeightServed(s: System): bool = + pure def wFalseHeightServedIn(s: System): bool = s.wasGiven(obs => match obs { | Tx(tx) => @@ -562,40 +562,40 @@ module properties { }) /// W17. A hub has queued a payload of the third party's own making. - pure def wThirdPartyPayloadQueued(s: System): bool = + pure def wThirdPartyPayloadQueuedIn(s: System): bool = s.hubIds().exists(hub => s.queuedAt(hub).intersect(s.thirdParty.own) != Set()) // ------------------------------------------------------------------------ // Non-vacuity: the antecedent of each guarantee is reachable // ------------------------------------------------------------------------ - pure def vOperatorBlind(s: System): bool = + pure def vOperatorBlindIn(s: System): bool = s.operator != Set() - pure def vQueuedBytesConfidential(s: System): bool = + pure def vQueuedBytesConfidentialIn(s: System): bool = s.tpLearned() != Set() - pure def vTxidAuthenticity(s: System): bool = + pure def vTxidAuthenticityIn(s: System): bool = s.wasGiven(obs => match obs { | Tx(_) => true | _ => false }) - pure def vLookupValidityPerHub(s: System): bool = + pure def vLookupValidityPerHubIn(s: System): bool = and { - s.wPending(), - s.vTxidAuthenticity(), + s.wPendingIn(), + s.vTxidAuthenticityIn(), s.wasGiven(obs => obs == NotFound), } - pure def vToldImpliesQueued(s: System): bool = + pure def vToldImpliesQueuedIn(s: System): bool = s.toldOk() != Set() - pure def vOfferedBeforeExpiry(audit: Audit): bool = + pure def vOfferedBeforeExpiryIn(audit: Audit): bool = audit.offers.exists(offer => isSome(offer.payload.expiry)) - pure def vConformingFirstOfferBeforeExpiry(s: System, audit: Audit): bool = + pure def vConformingFirstOfferBeforeExpiryIn(s: System, audit: Audit): bool = audit.offers.exists(offer => and { isSome(offer.payload.expiry), @@ -604,7 +604,7 @@ module properties { }) /// The same, for a payload admitted while the hub's tip was behind the chain. - pure def vConformingOfferAdmittedBehind(s: System, audit: Audit): bool = + pure def vConformingOfferAdmittedBehindIn(s: System, audit: Audit): bool = audit.offers.exists(offer => and { isSome(offer.payload.expiry), @@ -614,7 +614,7 @@ module properties { entry.tip < entry.height, }) - pure def vConformingFirstOfferJudged(s: System, audit: Audit): bool = + pure def vConformingFirstOfferJudgedIn(s: System, audit: Audit): bool = audit.verdicts.exists(verdict => and { isSome(verdict.payload.expiry), @@ -623,6 +623,6 @@ module properties { isConformingAndTimely(s, audit, verdict.hub, verdict.payload), }) - pure def vAckImpliesQueued(s: System): bool = + pure def vAckImpliesQueuedIn(s: System): bool = s.hubIds().exists(hub => s.ackedAt(hub) != Set()) } diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 1ee18a6b..cd933f5d 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -41,7 +41,7 @@ module protocol { import hub.* from "./hub" import shim.* from "./shim" import state.* from "./state" - import properties as P from "./properties" + import properties.* from "./properties" // ------------------------------------------------------------------------ // Constants @@ -224,7 +224,7 @@ module protocol { /// The only writer of the variables. action commit(post: System, label: Label): bool = all { s' = post, - audit' = P::advance(audit, s, post), + audit' = advance(audit, s, post), lastAction' = label, } @@ -232,7 +232,7 @@ module protocol { action init = all { s' = INITIAL, - audit' = P::initialAudit(INITIAL), + audit' = initialAudit(INITIAL), lastAction' = Init, } @@ -836,79 +836,80 @@ module protocol { // Guarantees // ------------------------------------------------------------------------ // - // The predicates are defined, and documented, in `properties.qnt`. These - // are their values in the current state, under the names the gate checks. - - val operatorBlind = P::operatorBlind(s) - val queuedBytesConfidential = P::queuedBytesConfidential(s) - val txidAuthenticity = P::txidAuthenticity(s) - val lookupValidityPerHub = P::lookupValidityPerHub(s, audit) - val toldImpliesQueued = P::toldImpliesQueued(s, audit) - val offeredBeforeExpiry = P::offeredBeforeExpiry(s, audit, HUBS) - val conformingFirstOfferBeforeExpiry = P::conformingFirstOfferBeforeExpiry(s, audit, HUBS) - val conformingFirstOfferJudgedBeforeExpiry = P::conformingFirstOfferJudgedBeforeExpiry(s, audit, HUBS) - val wellFormed = P::wellFormed(s) - val ackImpliesQueued = P::ackImpliesQueued(s, audit, HUBS) + // The predicates are defined, and documented, in `properties.qnt`, each as + // `In(..)`: a predicate over a system and an audit record. These are + // their values in the current state, under the names the gate checks. + + val operatorBlind = operatorBlindIn(s) + val queuedBytesConfidential = queuedBytesConfidentialIn(s) + val txidAuthenticity = txidAuthenticityIn(s) + val lookupValidityPerHub = lookupValidityPerHubIn(s, audit) + val toldImpliesQueued = toldImpliesQueuedIn(s, audit) + val offeredBeforeExpiry = offeredBeforeExpiryIn(s, audit, HUBS) + val conformingFirstOfferBeforeExpiry = conformingFirstOfferBeforeExpiryIn(s, audit, HUBS) + val conformingFirstOfferJudgedBeforeExpiry = conformingFirstOfferJudgedBeforeExpiryIn(s, audit, HUBS) + val wellFormed = wellFormedIn(s) + val ackImpliesQueued = ackImpliesQueuedIn(s, audit, HUBS) // The per-hub guarantees, claimed of the honest hubs only. Each is the same // predicate as its namesake above, restricted to the hubs whose role is // `Honest`; it is not a weaker property. With every hub honest the two // coincide. - val offeredBeforeExpiryForHonestHubs = P::offeredBeforeExpiry(s, audit, HONEST_HUBS) - val conformingFirstOfferBeforeExpiryForHonestHubs = P::conformingFirstOfferBeforeExpiry(s, audit, HONEST_HUBS) + val offeredBeforeExpiryForHonestHubs = offeredBeforeExpiryIn(s, audit, HONEST_HUBS) + val conformingFirstOfferBeforeExpiryForHonestHubs = conformingFirstOfferBeforeExpiryIn(s, audit, HONEST_HUBS) val conformingFirstOfferJudgedBeforeExpiryForHonestHubs = - P::conformingFirstOfferJudgedBeforeExpiry(s, audit, HONEST_HUBS) - val ackImpliesQueuedForHonestHubs = P::ackImpliesQueued(s, audit, HONEST_HUBS) + conformingFirstOfferJudgedBeforeExpiryIn(s, audit, HONEST_HUBS) + val ackImpliesQueuedForHonestHubs = ackImpliesQueuedIn(s, audit, HONEST_HUBS) // ------------------------------------------------------------------------ // Known gaps: invariants that do not hold // ------------------------------------------------------------------------ - val statusNeverRegresses = P::statusNeverRegresses(s) - val ackedIsHeldOrSettled = P::ackedIsHeldOrSettled(s, audit) - val conformingEveryOfferBeforeExpiry = P::conformingEveryOfferBeforeExpiry(s, audit) + val statusNeverRegresses = statusNeverRegressesIn(s) + val ackedIsHeldOrSettled = ackedIsHeldOrSettledIn(s, audit) + val conformingEveryOfferBeforeExpiry = conformingEveryOfferBeforeExpiryIn(s, audit) // ------------------------------------------------------------------------ // Witnesses // ------------------------------------------------------------------------ - val wToldRefusedEverywhere = P::wToldRefusedEverywhere(s, audit) - val wToldNeverDelivered = P::wToldNeverDelivered(s, audit) - val wToldPrefixOnly = P::wToldPrefixOnly(s) - - val wPending = P::wPending(s) - val wTxInMempool = P::wTxInMempool(s) - val wTxMined = P::wTxMined(s) - val wRefusedTipStale = P::wRefused(audit, TipStale) - val wRefusedDraining = P::wRefused(audit, HubDraining) - val wRefusedTooLarge = P::wRefused(audit, TooLarge) - val wRefusedExpiryTooTight = P::wRefused(audit, ExpiryTooTight) - val wRefusedFull = P::wRefused(audit, Full) - val wRequeued = P::wRequeued(s) - val wDroppedExpired = P::wDroppedExpired(s, audit) - val wDroppedExhausted = P::wDroppedExhausted(audit) - val wQueuedDisclosed = P::wQueuedDisclosed(s) - val wUnparseableMissed = P::wUnparseableMissed(s) - val wQueueOverCapacity = P::wQueueOverCapacity(s) - val wFailoverAnswered = P::wFailoverAnswered(s) - val wPublishedByTwoHubs = P::wPublishedByTwoHubs(s, audit) - val wPrematureFlush = P::wPrematureFlush(s) - val wTwinServed = P::wTwinServed(s) - val wFalseHeightServed = P::wFalseHeightServed(s) - val wThirdPartyPayloadQueued = P::wThirdPartyPayloadQueued(s) - val wEarlyFreeRunFlush = P::wEarlyFreeRunFlush(s) + val wToldRefusedEverywhere = wToldRefusedEverywhereIn(s, audit) + val wToldNeverDelivered = wToldNeverDeliveredIn(s, audit) + val wToldPrefixOnly = wToldPrefixOnlyIn(s) + + val wPending = wPendingIn(s) + val wTxInMempool = wTxInMempoolIn(s) + val wTxMined = wTxMinedIn(s) + val wRefusedTipStale = wRefused(audit, TipStale) + val wRefusedDraining = wRefused(audit, HubDraining) + val wRefusedTooLarge = wRefused(audit, TooLarge) + val wRefusedExpiryTooTight = wRefused(audit, ExpiryTooTight) + val wRefusedFull = wRefused(audit, Full) + val wRequeued = wRequeuedIn(s) + val wDroppedExpired = wDroppedExpiredIn(s, audit) + val wDroppedExhausted = wDroppedExhaustedIn(audit) + val wQueuedDisclosed = wQueuedDisclosedIn(s) + val wUnparseableMissed = wUnparseableMissedIn(s) + val wQueueOverCapacity = wQueueOverCapacityIn(s) + val wFailoverAnswered = wFailoverAnsweredIn(s) + val wPublishedByTwoHubs = wPublishedByTwoHubsIn(s, audit) + val wPrematureFlush = wPrematureFlushIn(s) + val wTwinServed = wTwinServedIn(s) + val wFalseHeightServed = wFalseHeightServedIn(s) + val wThirdPartyPayloadQueued = wThirdPartyPayloadQueuedIn(s) + val wEarlyFreeRunFlush = wEarlyFreeRunFlushIn(s) // Non-vacuity: the antecedent of each guarantee is reachable. - val vOperatorBlind = P::vOperatorBlind(s) - val vQueuedBytesConfidential = P::vQueuedBytesConfidential(s) - val vTxidAuthenticity = P::vTxidAuthenticity(s) - val vLookupValidityPerHub = P::vLookupValidityPerHub(s) - val vToldImpliesQueued = P::vToldImpliesQueued(s) - val vOfferedBeforeExpiry = P::vOfferedBeforeExpiry(audit) - val vConformingFirstOfferBeforeExpiry = P::vConformingFirstOfferBeforeExpiry(s, audit) - val vConformingOfferAdmittedBehind = P::vConformingOfferAdmittedBehind(s, audit) - val vConformingFirstOfferJudged = P::vConformingFirstOfferJudged(s, audit) - val vAckImpliesQueued = P::vAckImpliesQueued(s) + val vOperatorBlind = vOperatorBlindIn(s) + val vQueuedBytesConfidential = vQueuedBytesConfidentialIn(s) + val vTxidAuthenticity = vTxidAuthenticityIn(s) + val vLookupValidityPerHub = vLookupValidityPerHubIn(s) + val vToldImpliesQueued = vToldImpliesQueuedIn(s) + val vOfferedBeforeExpiry = vOfferedBeforeExpiryIn(audit) + val vConformingFirstOfferBeforeExpiry = vConformingFirstOfferBeforeExpiryIn(s, audit) + val vConformingOfferAdmittedBehind = vConformingOfferAdmittedBehindIn(s, audit) + val vConformingFirstOfferJudged = vConformingFirstOfferJudgedIn(s, audit) + val vAckImpliesQueued = vAckImpliesQueuedIn(s) // ------------------------------------------------------------------------ // Two-state properties From a9c35ca5ba193551d698f9aeafa7c5822e4cf296 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 00:38:40 +0400 Subject: [PATCH 35/80] test(zeronym): draw tips and clock estimates from constant ranges --- zeronym/spec/protocol/indexer.qnt | 13 +++++++------ zeronym/spec/protocol/protocol.qnt | 18 +++++++++++------- zeronym/spec/protocol/tests/indexerTest.qnt | 8 ++++---- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/zeronym/spec/protocol/indexer.qnt b/zeronym/spec/protocol/indexer.qnt index 7311d8aa..375e57d0 100644 --- a/zeronym/spec/protocol/indexer.qnt +++ b/zeronym/spec/protocol/indexer.qnt @@ -149,9 +149,10 @@ module indexer { else INotFound /// The tips an honest indexer may report when reports can trail the true - /// height by up to `slack` blocks. - pure def honestTips(state: IndexerState, slack: int): Set[Height] = - (if (state.height > slack) state.height - slack else 0).to(state.height) + /// height by up to `slack` blocks. `heights` is every height there is: the + /// answer is a part of a fixed range, never a range with a moving bound. + pure def honestTips(state: IndexerState, slack: int, heights: Set[Height]): Set[Height] = + heights.filter(height => state.height - slack <= height and height <= state.height) // ------------------------------------------------------------------------ // Byzantine relation @@ -208,7 +209,7 @@ module indexer { | _ => outputs.map(output => { state: indexerApply(state, input, output), out: output }) } - /// The tips a Byzantine indexer may report: anything up to `maxHeight`. - pure def byzTips(maxHeight: Height): Set[Height] = - 0.to(maxHeight) + /// The tips a Byzantine indexer may report: any height there is. + pure def byzTips(heights: Set[Height]): Set[Height] = + heights } diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index cd933f5d..241f08ca 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -100,6 +100,9 @@ module protocol { pure val UNIVERSE = PAYLOADS.union(TWINS).union(TP_PAYLOADS) /// Every height a Byzantine component may claim. pure val HEIGHTS = 0.to(MAX_HEIGHT) + /// Every height a free-running cadence clock may read: up to one flush + /// interval past the last block. + pure val CLOCK_HEIGHTS = 0.to(MAX_HEIGHT + FLUSH_INTERVAL) /// What a wallet may send. pure val SEND_INPUTS = PAYLOADS.map(payload => Clean(payload)).union(Set(Unreadable, EmptyBody)) /// Every transaction id there is. @@ -292,12 +295,12 @@ module protocol { /// The tips the indexer may report to a hub now. def reportableTips: Set[Height] = match ROLES.indexer { - | Byzantine => byzTips(MAX_HEIGHT) + | Byzantine => byzTips(HEIGHTS) | Honest => match TIP { - | TipTimely => honestTips(s.indexer, 0) - | TipMayRegress => honestTips(s.indexer, REORG_ALLOWANCE) - | TipMayLag => honestTips(s.indexer, 0) + | TipTimely => honestTips(s.indexer, 0, HEIGHTS) + | TipMayRegress => honestTips(s.indexer, REORG_ALLOWANCE, HEIGHTS) + | TipMayLag => honestTips(s.indexer, 0, HEIGHTS) } } @@ -308,10 +311,11 @@ module protocol { /// The heights a stale hub's free-running clock may read now: up to an /// interval ahead of the chain, and not behind it unless `FREE_RUN` allows. def freeRunEstimates(id: HubId): Set[Height] = - match FREE_RUN { - | NotSlower => s.height().to(s.height() + FLUSH_INTERVAL) - | MayBeSlower => s.hubs.get(id).observedTip().to(s.height() + FLUSH_INTERVAL) + val earliest = match FREE_RUN { + | NotSlower => s.height() + | MayBeSlower => s.hubs.get(id).observedTip() } + CLOCK_HEIGHTS.filter(estimate => earliest <= estimate and estimate <= s.height() + FLUSH_INTERVAL) /// Whether the next block may arrive. This is where the timing assumptions /// live: a block is held back until every hub has done what the tip model diff --git a/zeronym/spec/protocol/tests/indexerTest.qnt b/zeronym/spec/protocol/tests/indexerTest.qnt index 2e652e91..e4898127 100644 --- a/zeronym/spec/protocol/tests/indexerTest.qnt +++ b/zeronym/spec/protocol/tests/indexerTest.qnt @@ -98,9 +98,9 @@ module indexerTest { } run tipReportsTest = all { - assert(honestTips(empty, 0) == Set(4)), - assert(honestTips(empty, 1) == Set(3, 4)), - assert(honestTips(initialIndexer(1), 3) == Set(0, 1)), + assert(honestTips(empty, 0, 0.to(7)) == Set(4)), + assert(honestTips(empty, 1, 0.to(7)) == Set(3, 4)), + assert(honestTips(initialIndexer(1), 3, 0.to(7)) == Set(0, 1)), } /// F12. The Byzantine relation contains every honest transition, and every @@ -111,7 +111,7 @@ module indexerTest { assert(tuples(STATES, INPUTS).forall(((state, input)) => honestIndexerOutputs(state, input).subseteq(byzIndexerOutputs(state, input, UNIVERSE, HEIGHTS)))), assert(tuples(STATES, 0.to(3)).forall(((state, slack)) => - honestTips(state, slack).subseteq(byzTips(7)))), + honestTips(state, slack, 0.to(7)).subseteq(byzTips(0.to(7))))), } run byzantineIndexerTest = all { From c34647b49587fb19848f9c3254bcb8aedc0fa079 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 00:40:00 +0400 Subject: [PATCH 36/80] test(zeronym): add a wrapper that checks one invariant exhaustively with TLC --- zeronym/spec/protocol/tlc.sh | 153 +++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100755 zeronym/spec/protocol/tlc.sh diff --git a/zeronym/spec/protocol/tlc.sh b/zeronym/spec/protocol/tlc.sh new file mode 100755 index 00000000..734ba486 --- /dev/null +++ b/zeronym/spec/protocol/tlc.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# Check one invariant of one configuration exhaustively, with TLC. +# +# tlc.sh FILE MAIN INIT STEP INVARIANT +# +# FILE is a Quint file, MAIN the module in it, INIT a named init action built +# on `initWith`, STEP a step relation and INVARIANT a state predicate. On +# success exactly one line is printed: +# +# holds every reachable state satisfies it +# violated TLC found a counterexample that long +# +# Anything else is a failure: a line `FAIL : ` on stderr, no +# verdict line, and a non-zero exit status. Nothing skips a stage. +# +# The route, and why it is not `quint verify`: the Apalache server behind +# `quint verify` refuses a specification larger than 20 MB, and these compile +# to more. So the three stages are run by hand: +# +# 1. quint compile --target=json (stdout; `--out` writes another format) +# 2. apalache-mc typecheck (exports TLA+, named after the output file) +# 3. tlc2.TLC from the Apalache jar +# +# Between 2 and 3 a filter removes the primes that the export leaves on the +# assignments of the init operator, which TLC rejects. That is a workaround +# for the export, tied to the pinned versions below. +# +# TLC_WORKERS and TLC_HEAP size the run. QUINT is the Quint command. TLC_TRACE, +# if set, names a file that receives TLC's output when it finds a violation. +set -u + +QUINT=${QUINT:-"npx --yes @informalsystems/quint@0.33.0"} +QUINT_VERSION=0.33.0 +APALACHE_VERSION=0.62.1 +APALACHE=$HOME/.quint/apalache-dist-$APALACHE_VERSION/apalache +JAR=$APALACHE/lib/apalache.jar +WORKERS=${TLC_WORKERS:-2} +HEAP=${TLC_HEAP:-4g} + +die() { + echo "FAIL $1" >&2 + exit 1 +} + +[ $# -eq 5 ] || die "usage: tlc.sh FILE MAIN INIT STEP INVARIANT" +case $1 in + /*) file=$1 ;; + *) file=$PWD/$1 ;; +esac +main=$2 init=$3 step=$4 invariant=$5 +[ -f "$file" ] || die "compile: no such file: $file" + +command -v java >/dev/null 2>&1 || die "toolchain: java not found" +java -version >/dev/null 2>&1 || die "toolchain: java does not run" +version=$($QUINT --version 2>/dev/null) +[ "$version" = "$QUINT_VERSION" ] || die "toolchain: quint is '$version', not $QUINT_VERSION" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +cd "$work" || die "toolchain: cannot enter $work" + +# The Apalache distribution is fetched by Quint the first time it verifies +# anything. The verdict of this run is irrelevant. +if [ ! -f "$JAR" ]; then + cat >fetch.qnt <<'EOF' +module fetch { + var x: int + action init = x' = 0 + action step = x' = x +} +EOF + $QUINT verify fetch.qnt --max-steps=1 >fetch.log 2>&1 + [ -f "$JAR" ] || { tail -5 fetch.log >&2; die "toolchain: no Apalache $APALACHE_VERSION at $JAR"; } +fi + +# 1. Compile. A misspelt name leaves stdout empty and exits non-zero. +if ! $QUINT compile --target=json --main="$main" --init="$init" --step="$step" \ + --invariant="$invariant" "$file" >"$main.qnt.json" 2>compile.log; then + tail -5 compile.log >&2 + die "compile: quint compile failed ($main $init $step $invariant)" +fi +[ -s "$main.qnt.json" ] || die "compile: quint compile wrote nothing" + +# 2. Export. TLC wants the module named after its file. +if ! "$APALACHE/bin/apalache-mc" typecheck --out-dir="$work/apalache" \ + --output="$work/export.tla" "$main.qnt.json" >export.log 2>&1; then + tail -5 export.log >&2 + die "export: apalache-mc typecheck failed" +fi +grep -q "^-* MODULE export -*\$" export.tla 2>/dev/null || die "export: no TLA+ module was written" + +# The filter. It rewrites `x' := e` to `x := e` in the definitions named +# `initWith` and INIT and in no other, so a step action whose name merely +# contains "init" keeps its assignments. Exactly one of the two holds +# assignments (`initWith`); any other count means the export is not shaped as +# expected, and a verdict from it would be about another machine. +if ! awk -v names="initWith $init" -v expected=1 ' + BEGIN { count = split(names, list, " "); for (i = 1; i <= count; i++) wanted[list[i]] = 1 } + /^[A-Za-z_][A-Za-z0-9_]*(\(.*\))? ==/ { + name = $0 + sub(/[( ].*/, "", name) + inside = (name in wanted) + fresh = 1 + } + /^$/ { inside = 0 } + inside { + if (gsub(/\047 :=/, " :=") > 0 && fresh) { changed++; fresh = 0 } + if ($0 ~ /[A-Za-z0-9_]\047/) { left++ } + } + { sub(/ MODULE export /, " MODULE " module " ") ; print } + END { + if (left > 0) { print "a primed variable is left in an init operator" > "/dev/stderr"; exit 4 } + if (changed != expected) { + print changed + 0 " init definitions rewritten, expected " expected > "/dev/stderr" + exit 3 + } + }' module="$main" export.tla >"$main.tla" 2>filter.log; then + cat filter.log >&2 + die "filter: the init operator is not as expected" +fi + +# 3. TLC. `-deadlock` switches deadlock checking off: every machine here stops +# at its height bound. +printf 'INIT q_init\nNEXT q_step\nINVARIANT q_inv\n' >"$main.cfg" +java "-Xmx$HEAP" -XX:+UseParallelGC -cp "$JAR" tlc2.TLC -deadlock -workers "$WORKERS" \ + -metadir "$work/states" -config "$main.cfg" "$main.tla" >tlc.log 2>&1 +status=$? + +# A dead init (a guard that is false) leaves TLC with nothing to explore, and +# it reports "No error has been found". +if grep -q "Finished computing initial states: 0 distinct states generated" tlc.log; then + die "tlc: no initial state: the guard of $init is false" +fi +if [ "$status" -eq 0 ] \ + && grep -q "Finished computing initial states: [1-9]" tlc.log \ + && grep -q "Model checking completed. No error has been found." tlc.log \ + && grep -q " 0 states left on queue" tlc.log; then + states=$(sed -n 's/^[0-9][0-9]* states generated, \([0-9][0-9]*\) distinct states found, 0 states left on queue.*/\1/p' tlc.log | tail -1) + depth=$(sed -n 's/^The depth of the complete state graph search is \([0-9][0-9]*\).*/\1/p' tlc.log | tail -1) + [ -n "$states" ] && [ -n "$depth" ] || { tail -15 tlc.log >&2; die "tlc: no state count or depth reported"; } + echo "holds $states $depth" +elif [ "$status" -eq 12 ] && grep -q "Error: Invariant q_inv is violated by the initial state" tlc.log; then + [ -n "${TLC_TRACE:-}" ] && cp tlc.log "$TLC_TRACE" + echo "violated 1" +elif [ "$status" -eq 12 ] && grep -q "Error: Invariant q_inv is violated." tlc.log; then + length=$(grep -c '^State [0-9][0-9]*:' tlc.log) + [ "$length" -gt 0 ] || { tail -15 tlc.log >&2; die "tlc: a violation with no trace"; } + [ -n "${TLC_TRACE:-}" ] && cp tlc.log "$TLC_TRACE" + echo "violated $length" +else + tail -15 tlc.log >&2 + die "tlc: no verdict (exit status $status)" +fi From faf489c31f548f003b9d0260ce07c7e373de7e18 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 01:56:04 +0400 Subject: [PATCH 37/80] test(zeronym): add the hub machine with named inits and record what TLC exhausts --- zeronym/spec/protocol/README.md | 78 ++ zeronym/spec/protocol/hub.qnt | 2 +- zeronym/spec/protocol/hubMachine.qnt | 710 ++++++++++++++++++ zeronym/spec/protocol/indexer.qnt | 10 +- .../spec/protocol/tests/hubScenariosTest.qnt | 136 ++++ zeronym/spec/protocol/tlc.sh | 26 +- 6 files changed, 951 insertions(+), 11 deletions(-) create mode 100644 zeronym/spec/protocol/hubMachine.qnt create mode 100644 zeronym/spec/protocol/tests/hubScenariosTest.qnt diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index c78220e8..200104a9 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -775,6 +775,84 @@ Not built. The specification is shaped so it can be: to be bound to real nonces as frames appear. A payload's `id` maps to a fixture. The frames a step emits are `s.net` after it minus before. +## The hub specification under TLC + +`hubMachine.qnt` is one hub, the chain and the two things the hub asks its +indexer, built on the same `hub(state, input)` as everything else. Its +observer keeps four sets of payloads and one height and no history, which is +what lets TLC visit every reachable state. `tlc.sh FILE MAIN INIT STEP +INVARIANT` checks one invariant of one configuration and prints `holds + ` or `violated `; anything else, +including a run that TLC has not finished in five minutes, is a failure. + +A configuration is a value held in the state and selected by a named init +(`initTimely`, ...), whose guard is the assumptions that configuration is +checked under. A guard that is false leaves no initial state, and `tlc.sh` +fails on that. + +Measured on the machine this was written on (Apple silicon, 16 cores, 64 GB; +Quint 0.33.0, Apalache 0.62.1, Java 27), under today's definition of +timeliness and the hub function before the capacity refusals are removed. +Every run had a five-minute limit. Times are for the whole route (compile, +export, TLC), of which compile and export are about 10 s; "peak" is the +resident size of the largest process. + +Exhausting each configuration (`step`, an invariant that is true everywhere): + +| Configuration | Payloads | Distinct states | Depth | 8 workers, 8 GB | 2 workers, 4 GB | +|---|---|---|---|---|---| +| `timely` | 3 | 189 297 | 44 | 17 s, 3.1 GB | 27 s, 1.9 GB | +| `flakyTip` | 3 | 1 319 986 | 44 | 67 s, 6.3 GB | 165 s, 4.4 GB | +| `flakyTipNoSlack` | 3 | 1 319 986 | 44 | 72 s, 6.3 GB | 166 s, 4.4 GB | +| `flakyTipSlowFlight` | 3 | 1 761 078 | 45 | 95 s, 6.8 GB | 229 s, 4.4 GB (122 s with 4 workers) | +| `staleLag` | 3 | not exhausted: 6 521 452 at depth 32, 679 376 on the queue | | 300 s, 8.5 GB | | +| `staleLag` | 2 (`early`, `late`) | 1 130 260 | 42 | 62 s, 6.1 GB | 139 s, 4.4 GB | +| `staleLagWithSlack` | 3 | not exhausted: 7 713 309 at depth 34, 580 992 on the queue | | 300 s, 8.6 GB | | +| `staleLagWithSlack` | 2 (`early`, `late`) | 1 131 714 | 42 | 63 s, 6.2 GB | 141 s, 4.4 GB | + +The 8-worker runs were two at a time and the 2-worker runs three at a time, +on 16 cores, so each is slower than it would be alone; the two timeouts were +measured that way and were not repeated alone. The two lagging-tip +configurations are therefore checked with two payloads. What that costs: with +`tight`, TLC's counterexample to G6a on `staleLag` is 8 states long; without +it, 13. No verdict differs. + +Verdicts (`step` unless said; 8 workers, 8 GB; every row 11 to 17 s): + +| Configuration | Invariant | Verdict | +|---|---|---| +| `timely` | G6a and G6b and G6c | holds, 189 297 states, depth 44 | +| `timely` | `conformingEveryOfferBeforeExpiry` (K6's predicate) | holds, 189 297 states, depth 44 | +| `timely` | `ackedIsHeldOrSettled` (K5) | violated: 5 states under `step`, 8 under `noCrashStep`, 10 under `quietStep` | +| `flakyTip` | G6a (K3) | violated, 8 states | +| `flakyTip` | G6b; G6c | violated, 18; 19 states | +| `flakyTipSlowFlight` | G6b; G6c (K7) | violated, 18; 14 states | +| `flakyTipNoSlack` | G6b (K3') | violated, 12 states | +| `staleLag` | G6a; G6b; G6c; K6 | violated, 13; 13; 14; 13 states | +| `staleLagWithSlack` | G6b; G6c | violated, 19; 20 states | + +G6b and G6c are violated on `flakyTip` and G6b on `flakyTipSlowFlight`, where +simulation of the whole protocol reports that they hold. The counterexample +needs a crash and a late duplicate of a submission that was first admitted on +time; it is about what "timely" means across a restart. + +Reachability, each as `not(..)` and each violated: on `timely`, +`wOfferWithExpiry` (6 states), `wConformingFirstOffer` (6), +`wConformingFirstOfferInFlightABlock` (7), `wOffered` (7), `wRequeued` (9), +`wDown` (2), `wRestartedOwing` (6), `wBlockInFlight` (7), `wStopped` (4); on +`flakyTip`, the first three (6, 6, 7); `wStale` on `staleLag` (9) and on +`staleLagWithSlack` (6). + +Configuration in the state against configuration as a constant, on `timely` +with G6a, G6b and G6c: the compiled JSON is 13.7 MB with named inits and +23.6 MB with `const CONFIG` and an instance module; both give 189 297 states +at depth 44. TLC alone took 5 s on the constant form; the named form was timed +only as a whole row (17 s, beside another run). Named inits are kept. + +Not measured: Apalache at bounded depths on this machine (one attempt failed +on its configuration and was not repeated); the route with an empty `~/.quint` +and Quint fetched by `npx`. + ## Bounded model checking (not run) **None of the commands in this section has been executed.** Both backends need diff --git a/zeronym/spec/protocol/hub.qnt b/zeronym/spec/protocol/hub.qnt index 6d531919..01606367 100644 --- a/zeronym/spec/protocol/hub.qnt +++ b/zeronym/spec/protocol/hub.qnt @@ -446,7 +446,7 @@ module hub { | LookupHInput(lookup) => val bodies = Set(None).union(universe.map(payload => Some(payload))) val answers = tuples(bodies, heights) - .map(((body, height)) => IFound({ body: body, height: height })) + .map(((body, claimed)) => IFound({ body: body, height: claimed })) .union(Set(INotFound, IUnavailable)) val outcomes = Set(QueueHit).union(answers.map(answer => FromIndexer(answer))) honest.union(outcomes.map(outcome => state.toLookupReplyOutput(lookup.nonce, outcome))) diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/protocol/hubMachine.qnt new file mode 100644 index 00000000..c8513d09 --- /dev/null +++ b/zeronym/spec/protocol/hubMachine.qnt @@ -0,0 +1,710 @@ +// -*- mode: Bluespec; -*- + +/// The hub specification: one hub, the chain it publishes to, and the two +/// things it asks its indexer (what the tip is, and what became of a +/// broadcast). Submissions are inputs: who sent one, and over what network, +/// is the protocol specification's business. +/// +/// This is where the schedule is checked: whether a transaction a hub admits +/// is offered to the chain, and judged by a node, before it expires. The +/// state is small enough for TLC to visit every reachable state of every +/// configuration, so "holds" here means exactly that. +/// +/// How it is built. The module holds no hub logic. Every step picks an input, +/// hands it to `hub(state, input)` and records what an observer needs. A step +/// exists in two forms: `xWith(..)`, which takes every choice as a parameter +/// and is what a scripted run is made of, and `x`, which picks the choices and +/// is what `step` is made of. +/// +/// A configuration is a value, held in the state and set once by `initWith`. +/// One named init per configuration (`initTimely`, ...) selects it, guarded by +/// the assumptions that configuration is checked under. +/// +/// The observer's memory is four sets of payloads and one height. It is never +/// read by a step: only the properties read it. +module hubMachine { + import basicSpells.* from "./spells/basicSpells" + import types.* from "./types" + import indexer.* from "./indexer" + import hub.* from "./hub" + + // ------------------------------------------------------------------------ + // Configuration + // ------------------------------------------------------------------------ + + /// - `payloads`: the transactions that may be submitted. + /// - `params`: the schedule the hub is started with. + /// - `staleWindow`: blocks without a forward tip observation after which a + /// hub is stale. + /// - `maxFlightBlocks`: blocks that may arrive while one flush is in flight. + /// - `maxHeight`: where the chain stops growing; a bound of the model. + /// - `tip`: how the hub's view of the tip relates to the true height. + type HubConfig = { + payloads: Set[Payload], + params: HubParams, + staleWindow: int, + maxFlightBlocks: int, + maxHeight: Height, + tip: TipModel, + hubRole: Role, + indexerRole: Role, + } + + /// The height the chain starts at. Height 0 is kept for "in the mempool". + pure val GENESIS_HEIGHT = 1 + + /// Every height a tip report or a free-running clock may carry, in any + /// configuration. Picks are drawn from this fixed range and filtered by the + /// configuration, so no range has a bound that depends on the state. + pure val CLOCK_HEIGHTS = 0.to(16) + + // ------------------------------------------------------------------------ + // Assumptions + // ------------------------------------------------------------------------ + // + // Each is a predicate over a configuration. A named init asserts the ones + // its configuration is checked under, so a configuration that does not meet + // them has no initial state and every check of it fails. + + /// The hub's startup check: delivery, one full interval and the mining + /// margin fit inside the smallest supported expiry. + pure def budgetFits(c: HubConfig): bool = + scheduleFitsBudget(c.params) + + /// The same budget with the reorg allowance added. A hub that follows a tip + /// report up to the allowance behind the chain can flush that much late. + /// Nothing in the implementation checks this; its shipped constants meet it + /// with equality. + pure def reorgSlackFits(c: HubConfig): bool = + c.params.flushInterval + c.params.miningMargin + c.params.deliveryLag + c.params.reorgAllowance + <= c.params.minWalletExpiry + + /// Fewer blocks arrive while a flush is in flight than the mining margin + /// reserves. The margin is measured from the height at which a flush begins; + /// every block that arrives before the node judges a transaction is taken + /// out of it. The implementation bounds each call to the indexer + /// (`RPC_TIMEOUT`, `zeronym/hub/src/chain.rs`) and not the batch as a whole, + /// and bounds neither in blocks, so this is an assumption about the + /// environment that the code does not enforce. + pure def flightWithinMargin(c: HubConfig): bool = + c.maxFlightBlocks < c.params.miningMargin + + /// The same budget with the longest silence that does not yet make a hub + /// stale. The shipped constants do not meet it. + pure def staleSlackFits(c: HubConfig): bool = + c.params.flushInterval + c.params.miningMargin + c.params.deliveryLag + (c.staleWindow - 1) + <= c.params.minWalletExpiry + + /// The relations between the shipped constants that the scaled-down schedule + /// keeps: the slack left by the startup budget equals the reorg allowance; + /// the staleness window exceeds both the margin and that slack; the budget + /// with the longest non-stale silence added exceeds the expiry floor by + /// exactly one block, and without the margin it fits. + pure def shippedRelationsKept(c: HubConfig): bool = + val budget = c.params.flushInterval + c.params.miningMargin + c.params.deliveryLag + and { + c.params.minWalletExpiry - budget == c.params.reorgAllowance, + c.staleWindow > c.params.miningMargin, + c.staleWindow > c.params.reorgAllowance, + budget + (c.staleWindow - 1) == c.params.minWalletExpiry + 1, + budget - c.params.miningMargin + (c.staleWindow - 1) <= c.params.minWalletExpiry, + } + + /// What every configuration meets: the startup budget; a schedule that + /// moves; payloads told apart by their bytes and, where they parse, by their + /// txid, none built before the chain starts; and a clock range wide enough + /// for a free-running clock one interval past the last block. + pure def standingAssumptions(c: HubConfig): bool = and { + budgetFits(c), + c.params.flushInterval > 0, + c.payloads.size() == c.payloads.map(payload => payload.id).size(), + c.payloads.filter(payload => isSome(payload.txid)).size() == txidsOf(c.payloads).size(), + c.payloads.forall(payload => payload.created >= GENESIS_HEIGHT), + CLOCK_HEIGHTS.contains(c.maxHeight + c.params.flushInterval), + } + + /// The shipped relations, with the slack and the flight bound they give. + pure def shippedAssumptions(c: HubConfig): bool = and { + standingAssumptions(c), + shippedRelationsKept(c), + reorgSlackFits(c), + flightWithinMargin(c), + not(staleSlackFits(c)), + } + + // ------------------------------------------------------------------------ + // State + // ------------------------------------------------------------------------ + + /// The configuration. Written by `initWith` and by nothing else. + var cfg: HubConfig + /// The hub. Only ever the state of a result of `hub`. + var h: HubState + /// The true chain height. + var height: Height + /// The payloads a node has taken. + var onChain: Set[Payload] + /// Whether the cadence loop has asked for the tip since the last block. + var polled: bool + /// The true height at which the flush in flight began; 0 while none is. + var flightStart: Height + + // The observer's memory. + + /// The payloads that have entered this hub's queue. + var seen: Set[Payload] + /// Those that first did so within the delivery lag of the height they were + /// built at. + var onTime: Set[Payload] + /// The payloads a flight has carried, once that flight has had a node's + /// answer for them or has ended. + var offered: Set[Payload] + /// The payloads acknowledged as accepted that no node has judged since. + var owed: Set[Payload] + + action initWith(c: HubConfig): bool = all { + cfg' = c, + h' = startingHub(c.params), + height' = GENESIS_HEIGHT, + onChain' = Set(), + polled' = false, + flightStart' = 0, + seen' = Set(), + onTime' = Set(), + offered' = Set(), + owed' = Set(), + } + + // ------------------------------------------------------------------------ + // Views + // ------------------------------------------------------------------------ + + /// The chain as the indexer relations read it. Every transaction a node has + /// taken is in the mempool: nothing here depends on its being mined. + def chain: IndexerState = { + height: height, + txs: onChain.fold(Map(), (txs, payload) => + match payload.txid { + | Some(txid) => txs.put(txid, { payload: payload, at: InMempool }) + | None => txs + }), + offered: Set(), + } + + /// How far behind the chain the hub's observed tip is. + def lag: int = height - h.observedTip() + + /// The entries of the batch still waiting for a verdict. + def awaiting: Set[Payload] = + match h.flush { + | Broadcasting(flush) => flush.batch.keys() + | Idle => Set() + } + + pure def isError(output: HubOutput): bool = + match output { + | HubErrorOutput(_) => true + | _ => false + } + + /// Whether `output` is an ack that promises the hub holds the payload. + pure def isAcceptedAck(output: HubOutput): bool = + match output { + | AckOutput(ack) => isAccepted(ack.kind) + | _ => false + } + + // ------------------------------------------------------------------------ + // The oracles + // ------------------------------------------------------------------------ + // + // The hub asks its indexer two things. Both answers are the relations of + // `indexer.qnt`, not a second model of them. + + /// The tips the indexer may report now. + def tips: Set[Height] = + honestTips(chain, if (cfg.tip == TipMayRegress) cfg.params.reorgAllowance else 0, CLOCK_HEIGHTS) + + /// What a broadcast of `payload` may come to: the verdict the hub is given, + /// and whether the network took the transaction. + def broadcastResults(payload: Payload): Set[IndexerResult] = + honestIndexerResults(chain, BroadcastIInput(payload)) + + /// The indexer's transition that gives `given` on `payload` and relays the + /// transaction to the network, or does not. + def broadcastResult(payload: Payload, given: Verdict, relayed: bool): IndexerResult = { + state: indexerApply(chain, BroadcastIInput(payload), VerdictOutput(if (relayed) Accepted else Retryable)), + out: VerdictOutput(given), + } + + /// The verdicts on `payload`, each with whether the network took it. + def verdictsOn(payload: Payload): Set[(Verdict, bool)] = + tuples(Set(Accepted, AlreadyKnown, Rejected, Retryable), Set(true, false)).filter(((given, relayed)) => + broadcastResults(payload).contains(broadcastResult(payload, given, relayed))) + + /// The heights a stale hub's free-running clock may read now: not behind + /// the chain, and at most one flush interval ahead of it. The + /// implementation relies on the first ("during a real stall blocks arrive + /// slower than this", `zeronym/hub/src/batcher.rs:64-67`) and enforces + /// neither. + def freeRunEstimates: Set[Height] = + CLOCK_HEIGHTS.filter(estimate => height <= estimate and estimate <= height + cfg.params.flushInterval) + + /// The transitions the hub may take on a submission of `payload`. + def submitResults(payload: Payload): Set[HubResult] = + Set(hub(h, SubmitHInput({ nonce: 0, payload: payload }))) + + // ------------------------------------------------------------------------ + // Frames + // ------------------------------------------------------------------------ + + action chainKept = all { height' = height, onChain' = onChain, polled' = polled } + action admissionsKept = all { seen' = seen, onTime' = onTime } + action memoryKept = all { admissionsKept, offered' = offered, owed' = owed } + + /// The hub takes `input` on its own schedule. A step that would change + /// nothing is not taken. + action hubTakes(input: HubInput): bool = + val result = hub(h, input) + all { + not(isError(result.out)), + result.state != h, + h' = result.state, + cfg' = cfg, + } + + // ------------------------------------------------------------------------ + // Steps + // ------------------------------------------------------------------------ + + /// The cadence loop observes the tip its indexer reports. + /// + /// The poll is recorded whether or not the answer moves the hub's tip: a hub + /// that asked and was told nothing new has still asked. A second poll in the + /// same block that changes nothing is not taken. + action observeWith(tip: Height): bool = + val result = hub(h, TipHInput(tip)) + all { + tips.contains(tip), + not(isError(result.out)), + result.state != h or not(polled), + h' = result.state, + polled' = true, + height' = height, + onChain' = onChain, + flightStart' = flightStart, + memoryKept, + cfg' = cfg, + } + + action observe = { + nondet tip = oneOf(tips) + observeWith(tip) + } + + /// A hub that has seen no tip progress for the staleness window goes stale, + /// or, already stale, reads its free-running clock again. + action staleWith(estimate: Height): bool = all { + cfg.tip == TipMayLag, + h.phase == Stale or lag >= cfg.staleWindow, + freeRunEstimates.contains(estimate), + hubTakes(StaleHInput(estimate)), + flightStart' = flightStart, + chainKept, + memoryKept, + } + + action stale = { + nondet estimate = oneOf(freeRunEstimates) + staleWith(estimate) + } + + /// A submission of `payload` reaches the hub, which takes `result`. A + /// payload does not exist before it is built, whoever submits it; after + /// that it may arrive at any time and any number of times. + action submitWith(payload: Payload, result: HubResult): bool = + val entered = result.state.queued().contains(payload) and not(seen.contains(payload)) + all { + cfg.payloads.contains(payload), + height >= payload.created, + h.isServing(), + submitResults(payload).contains(result), + not(isError(result.out)), + h' = result.state, + seen' = if (entered) seen.union(Set(payload)) else seen, + onTime' = + if (entered and height <= payload.created + cfg.params.deliveryLag) onTime.union(Set(payload)) + else onTime, + owed' = if (isAcceptedAck(result.out)) owed.union(Set(payload)) else owed, + offered' = offered, + flightStart' = flightStart, + chainKept, + cfg' = cfg, + } + + action submit = all { + h.isServing(), + { + nondet payload = oneOf(cfg.payloads) + nondet result = oneOf(submitResults(payload)) + submitWith(payload, result) + }, + } + + /// A flush begins: the hub's whole queue goes out at once. With nothing + /// queued the epoch is recorded, or a draining hub stops, and no flight + /// starts. + action flushBegin = + val result = hub(h, FlushDueHInput) + all { + hubTakes(FlushDueHInput), + flightStart' = if (result.state.flush == Idle) 0 else height, + chainKept, + memoryKept, + } + + /// The indexer returns `given` on one entry of the batch, and the network + /// has taken the transaction or has not. A verdict other than `Retryable` + /// is a node's judgement: it settles the entry. + action verdictWith(payload: Payload, given: Verdict, relayed: bool): bool = + val judged = given != Retryable + all { + awaiting.contains(payload), + verdictsOn(payload).contains((given, relayed)), + hubTakes(VerdictHInput({ payload: payload, verdict: given })), + onChain' = if (relayed) onChain.union(Set(payload)) else onChain, + offered' = if (judged) offered.union(Set(payload)) else offered, + owed' = if (judged) owed.exclude(Set(payload)) else owed, + height' = height, + polled' = polled, + flightStart' = flightStart, + admissionsKept, + } + + action verdict = all { + awaiting != Set(), + { + nondet payload = oneOf(awaiting) + nondet outcome = oneOf(verdictsOn(payload)) + verdictWith(payload, outcome._1, outcome._2) + }, + } + + /// A flush ends: what nothing judged is requeued or dropped. The flight is + /// over for every entry it carried. + action flushEnd = all { + hubTakes(FlushDoneHInput), + offered' = offered.union(h.inFlight()), + flightStart' = 0, + owed' = owed, + admissionsKept, + chainKept, + } + + /// The hub gets its shutdown signal. + action drain = all { + hubTakes(DrainHInput), + flightStart' = flightStart, + chainKept, + memoryKept, + } + + /// The hub's process dies, or exits after its final flush. A flight it had + /// out is over. + action crash = all { + hubTakes(CrashHInput), + offered' = offered.union(h.inFlight()), + flightStart' = 0, + owed' = owed, + admissionsKept, + chainKept, + } + + action restart = all { + hubTakes(RestartHInput), + flightStart' = flightStart, + chainKept, + memoryKept, + } + + /// Whether the next block may arrive. This is where the timing assumptions + /// live: a block is held back until the hub has done what the tip model says + /// it does within a block. + /// + /// In every model, a flush that is due has begun, and no flush has been in + /// flight for `maxFlightBlocks` blocks already. A hub whose flush is in + /// flight is not looking at the tip, so the clauses below bind it only while + /// it is idle. + /// + /// - `TipTimely`: a running hub has asked for the tip since the last block. + /// An honest indexer answers with the true height, so the hub's lag is + /// zero at every block. A Byzantine one is asked just as often; what it + /// controls is the answer. + /// - `TipMayRegress`: a running hub is no further behind than the allowance. + /// - `TipMayLag`: a hub whose silence has reached the staleness window has + /// become stale, and a stale hub's free-running clock has caught up with + /// the current block. + def mayAdvance: bool = and { + height < cfg.maxHeight, + not(h.isFlushDue()), + h.flush == Idle or height - flightStart < cfg.maxFlightBlocks, + h.flush == Idle implies + match cfg.tip { + | TipTimely => h.phase == Running implies polled + | TipMayRegress => h.phase == Running implies lag <= cfg.params.reorgAllowance + | TipMayLag => + and { + h.phase == Running implies lag < cfg.staleWindow, + h.phase == Stale implies h.cadenceHeight() >= height, + } + }, + } + + /// The chain grows by one block. + action advance = all { + mayAdvance, + height' = height + 1, + polled' = false, + onChain' = onChain, + h' = h, + flightStart' = flightStart, + memoryKept, + cfg' = cfg, + } + + /// One step of the hub and its environment. + action step = any { + observe, stale, submit, flushBegin, verdict, flushEnd, advance, + drain, crash, restart, + } + + // The relations below are parts of `step`. A property that holds under + // `step` holds under each; one that fails under a part fails under `step`, + // and the part says which faults the failure does not need. + + /// No crash: the hub may still be shut down and started again. + action noCrashStep = any { + observe, stale, submit, flushBegin, verdict, flushEnd, advance, + drain, restart, + } + + /// No shutdown, no crash, no restart. + action quietStep = any { + observe, stale, submit, flushBegin, verdict, flushEnd, advance, + } + + // ------------------------------------------------------------------------ + // Guarantees + // ------------------------------------------------------------------------ + // + // G6 comes in three parts. G6a and G6b are about the moment a flush begins: + // how much of the mining margin is left when the hub hands the batch over. + // They do not say a node accepts the transaction, because the chain may move + // while the batch is in flight. G6c is about the moment a node judges it. + // + // Each is a predicate on the current state. That is enough: the whole batch + // is in flight in the state `flushBegin` produces, with `flightStart` the + // height it began at; and an entry awaiting a verdict can be given one, a + // rejection at least, at whatever height the chain has reached. + + /// Whether `payload`, published at height `at`, can still be mined + /// `miningMargin` blocks later. + def marginLeft(payload: Payload, at: Height): bool = + match payload.expiry { + | Some(expiry) => expiry >= at + cfg.params.miningMargin + | None => true + } + + /// Whether `payload` reached this hub as a supported wallet's would: it + /// honours the expiry floor, and it first entered the queue within the + /// delivery lag of the height it was built at. + def isConformingAndOnTime(payload: Payload): bool = + conforming(payload, cfg.params.minWalletExpiry) and onTime.contains(payload) + + /// Whether the flight `payload` is on, if any, is its first. + def isFirstOffer(payload: Payload): bool = + not(offered.contains(payload)) + + /// G6a. Every transaction the hub offers is offered with the mining margin + /// to spare: whatever was admitted, on every attempt. A claim about the + /// margin left at the offer, not about acceptance. + val offeredBeforeExpiry = + h.inFlight().forall(payload => marginLeft(payload, flightStart)) + + /// G6b. The same, for supported wallets only and for the first time the hub + /// offers the transaction. It says nothing about a later offer of an entry + /// that was requeued; see `conformingEveryOfferBeforeExpiry`. + val conformingFirstOfferBeforeExpiry = + h.inFlight().forall(payload => + isConformingAndOnTime(payload) and isFirstOffer(payload) implies marginLeft(payload, flightStart)) + + /// G6c. The end-to-end claim: when a node judges the first offer of a + /// supported wallet's transaction, the transaction has not expired. It can + /// still be mined in the next block, so the node does not turn it away for + /// its expiry. + /// + /// The margin is what pays for the blocks that arrive while the batch is in + /// flight. G6c therefore needs G6b and one thing more: that fewer blocks + /// than the mining margin arrive during a flush (`flightWithinMargin`). + val conformingFirstOfferJudgedBeforeExpiry = + awaiting.forall(payload => + isConformingAndOnTime(payload) and isFirstOffer(payload) implies + match payload.expiry { + | Some(expiry) => expiry > height + | None => true + }) + + // ------------------------------------------------------------------------ + // Known gaps: invariants that do not hold + // ------------------------------------------------------------------------ + + /// K6. G6b without its restriction to the first offer. This does not hold + /// once a hub is stale: requeue judges an entry at the observed tip, which + /// has stopped, while the flush schedule runs on. + val conformingEveryOfferBeforeExpiry = + h.inFlight().forall(payload => isConformingAndOnTime(payload) implies marginLeft(payload, flightStart)) + + /// K5. A payload the hub has acknowledged is accounted for: the hub still + /// holds it, queued or in flight; or the chain has it; or a node has judged + /// it since the ack. Being offered is not enough: an offer nothing judged + /// settles nothing. + /// + /// This does not hold. The queue lives in memory: a crash after the ack + /// loses it; so does a final flush that finds the indexer unreachable; and + /// so does a requeue that gives the entry up as expired. + val ackedIsHeldOrSettled = + owed.subseteq(h.queued().union(h.inFlight()).union(onChain)) + + // ------------------------------------------------------------------------ + // Reachability + // ------------------------------------------------------------------------ + // + // States that must be reachable, each checked as `not(..)` expected to be + // violated. The first three are the antecedents of G6a, G6b and G6c: without + // them a "holds" could be vacuous. The rest are one per family of steps: TLC + // is run with deadlock checking off, so a configuration whose steps died + // after `init` would otherwise hold everything on a handful of states. + + /// An entry with an expiry is in flight. + val wOfferWithExpiry = + h.inFlight().exists(payload => isSome(payload.expiry)) + + /// A supported wallet's transaction, with an expiry, is on its first flight. + val wConformingFirstOffer = + h.inFlight().exists(payload => + isSome(payload.expiry) and isConformingAndOnTime(payload) and isFirstOffer(payload)) + + /// The same entry is still unjudged after a block has arrived. + val wConformingFirstOfferInFlightABlock = + height > flightStart and awaiting.exists(payload => + isSome(payload.expiry) and isConformingAndOnTime(payload) and isFirstOffer(payload)) + + /// A flight has been answered or has ended. + val wOffered = offered != Set() + /// An entry nothing judged is back in the queue. + val wRequeued = h.queued().exists(payload => h.queue.get(payload) > 0) + val wDown = h.phase == Down + /// The hub has been started again, owing a payload from before. + val wRestartedOwing = h.phase == Starting and owed != Set() + /// A block has arrived while a flush is in flight. + val wBlockInFlight = flightStart > 0 and height > flightStart + val wStopped = h.phase == Stopped + val wStale = h.phase == Stale + + // ------------------------------------------------------------------------ + // Configurations + // ------------------------------------------------------------------------ + // + // The schedule flushes every 3 blocks with a mining margin of 2 and a + // delivery lag of 1, and supports wallets that set an expiry 7 blocks out. + // It is the shipped one scaled down (shipped: interval 20, margin 4, lag 6, + // reorg allowance 10, staleness window 12 blocks, expiry floor 40), keeping + // the relations `shippedRelationsKept` names. The margin is 2, the smallest + // that leaves room for a block to arrive while a flush is in flight. + + pure def orchard(id: str, created: Height, expiry: Height): Payload = + { id: id, txid: Some(id), created: created, expiry: Some(expiry), class: OrchardTouching, oversize: false } + + /// Two migrations from supported wallets, built at heights 2 and 4. + pure val early = orchard("early", 2, 9) + pure val late = orchard("late", 4, 11) + /// A migration whose wallet set its expiry tighter than the supported floor. + pure val tight = orchard("tight", 2, 5) + + /// Every component honest, and a hub that sees each block before the next. + pure val timely: HubConfig = { + payloads: Set(early, late, tight), + params: { + flushInterval: 3, + miningMargin: 2, + deliveryLag: 1, + minWalletExpiry: 7, + reorgAllowance: 1, + maxAttempts: 2, + queueCap: 2, + }, + staleWindow: 3, + maxFlightBlocks: 1, + maxHeight: 12, + tip: TipTimely, + hubRole: Honest, + indexerRole: Honest, + } + + // A tip that may be reported up to the reorg allowance behind the chain: + // with the slack that covers it, and without. In the second the expiry + // floor is the three-term budget exactly. + pure val flakyTip: HubConfig = { ...timely, tip: TipMayRegress } + pure val flakyTipNoSlack: HubConfig = { + ...flakyTip, + params: { ...flakyTip.params, minWalletExpiry: 6 }, + payloads: Set(orchard("early", 2, 8), late, tight), + } + + // The same tip, and a flush that may stay in flight for as many blocks as + // the mining margin reserves. + pure val flakyTipSlowFlight: HubConfig = { ...flakyTip, maxFlightBlocks: 2 } + + // A hub that may go without a tip for a while: on the shipped relation + // between the staleness window and the expiry floor, and on the relation + // that would cover the silence. These two carry the supported wallets' + // migrations only. A free-running clock multiplies the states, and with + // `tight` as well TLC does not exhaust either in five minutes. `tight` gives + // G6a a shorter counterexample on them (8 states against 13) and no check + // of them a different verdict. + pure val staleLag: HubConfig = { ...timely, tip: TipMayLag, payloads: Set(early, late) } + pure val staleLagWithSlack: HubConfig = { + ...staleLag, + params: { ...staleLag.params, minWalletExpiry: 8 }, + payloads: Set(orchard("early", 2, 10), orchard("late", 4, 12)), + } + + // One named init per configuration. A configuration built to show what a + // relation buys asserts that the relation is false of it, so that its known + // gap stays pinned on the missing relation. + + action initTimely = all { shippedAssumptions(timely), initWith(timely) } + action initFlakyTip = all { shippedAssumptions(flakyTip), initWith(flakyTip) } + action initFlakyTipNoSlack = all { + standingAssumptions(flakyTipNoSlack), + flightWithinMargin(flakyTipNoSlack), + not(reorgSlackFits(flakyTipNoSlack)), + initWith(flakyTipNoSlack), + } + action initFlakyTipSlowFlight = all { + standingAssumptions(flakyTipSlowFlight), + shippedRelationsKept(flakyTipSlowFlight), + reorgSlackFits(flakyTipSlowFlight), + not(flightWithinMargin(flakyTipSlowFlight)), + initWith(flakyTipSlowFlight), + } + action initStaleLag = all { shippedAssumptions(staleLag), initWith(staleLag) } + action initStaleLagWithSlack = all { + standingAssumptions(staleLagWithSlack), + reorgSlackFits(staleLagWithSlack), + flightWithinMargin(staleLagWithSlack), + staleSlackFits(staleLagWithSlack), + not(shippedRelationsKept(staleLagWithSlack)), + initWith(staleLagWithSlack), + } +} diff --git a/zeronym/spec/protocol/indexer.qnt b/zeronym/spec/protocol/indexer.qnt index 375e57d0..830c6509 100644 --- a/zeronym/spec/protocol/indexer.qnt +++ b/zeronym/spec/protocol/indexer.qnt @@ -88,13 +88,13 @@ module indexer { pure def indexerApply(state: IndexerState, input: IndexerInput, output: IndexerOutput): IndexerState = match input { | BroadcastIInput(payload) => - val seen = { ...state, offered: state.offered.union(Set(payload)) } + val noted = { ...state, offered: state.offered.union(Set(payload)) } match payload.txid { | Some(txid) => if (output == VerdictOutput(Accepted)) - { ...seen, txs: seen.txs.put(txid, { payload: payload, at: InMempool }) } - else seen - | None => seen + { ...noted, txs: noted.txs.put(txid, { payload: payload, at: InMempool }) } + else noted + | None => noted } | LookupIInput(_) => state | AdvanceIInput => { ...state, height: state.height + 1 } @@ -181,7 +181,7 @@ module indexer { | LookupIInput(_) => val bodies = Set(None).union(state.servable(universe).map(payload => Some(payload))) tuples(bodies, heights) - .map(((body, height)) => AnswerOutput(IFound({ body: body, height: height }))) + .map(((body, claimed)) => AnswerOutput(IFound({ body: body, height: claimed }))) .union(Set(AnswerOutput(INotFound), AnswerOutput(IUnavailable))) .union(honestIndexerOutputs(state, input)) | AdvanceIInput => honestIndexerOutputs(state, input) diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/protocol/tests/hubScenariosTest.qnt new file mode 100644 index 00000000..ccfd9d2c --- /dev/null +++ b/zeronym/spec/protocol/tests/hubScenariosTest.qnt @@ -0,0 +1,136 @@ +// -*- mode: Bluespec; -*- + +/// Scripted runs of the hub specification. +/// +/// A run is a sequence of the machine's own steps from `initWith(c)`. Where a +/// run says a property fails, it also says what in the state makes it fail. +/// A control is the same inputs in the same configuration with the one +/// decisive choice made the honest way; in the control the property holds. +/// +/// The schedule in every configuration: the chain starts at height 1, a flush +/// is scheduled at heights 3, 6, 9 and 12, and the mining margin is 2 blocks. +module hubScenariosTest { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import indexer.* from "../indexer" + import hub.* from "../hub" + import hubMachine.* from "../hubMachine" + + // ------------------------------------------------------------------------ + // Run vocabulary + // ------------------------------------------------------------------------ + // + // Each is a `...With` step with the choice an honest hub and a truthful, + // reachable indexer would make, or a short sequence of such steps. A run + // that needs another choice uses the `...With` step itself. + + /// What an honest hub does with a submission of `payload` now. + def honestly(payload: Payload): HubResult = + hub(h, SubmitHInput({ nonce: 0, payload: payload })) + + /// The decision an honest hub would give `payload` now. + def decision(payload: Payload): AckKind = + admission(h, payload) + + /// A submission of `payload` reaches the hub, which acts as it should. + action deliver(payload: Payload): bool = + submitWith(payload, honestly(payload)) + + /// The hub observes the true height. + action see = observeWith(height) + + /// The hub of configuration `c`, running at the genesis height. + run started(c: HubConfig): bool = initWith(c).then(see) + + /// One block arrives and the hub sees it. + run block = advance.then(see) + + /// `count` blocks arrive, each seen by the hub. No flush may fall due on the + /// way. + run blocks(count: int): bool = count.reps(_ => block) + + /// `count` blocks arrive and the hub hears of none. + run unseenBlocks(count: int): bool = count.reps(_ => advance) + + /// An honest indexer gives `given` on `payload`, with the effect that + /// verdict has. + action judge(payload: Payload, given: Verdict): bool = + verdictWith(payload, given, given == Accepted) + + /// The hub flushes `batch`, and the indexer gives every entry `given`. + run flush(batch: List[Payload], given: Verdict): bool = + flushBegin + .then(batch.length().reps(i => judge(batch[i], given))) + .then(flushEnd) + + /// What the requeue at the end of the flush in flight would report. + def requeueReport: HubOutput = + hub(h, FlushDoneHInput).out + + /// The hub is publishing a scheduled batch for an epoch the chain has not + /// reached: its cadence clock is ahead of the true height. + def flushesAheadOfChain: bool = + h.flush != Idle and h.cadenceEpoch() > height / cfg.params.flushInterval + + pure def floorOf(c: HubConfig): int = c.params.minWalletExpiry + + // ------------------------------------------------------------------------ + // Configurations + // ------------------------------------------------------------------------ + + /// Every named init has an initial state. A configuration that does not + /// meet the assumptions in its guard stops this run. + run liveInitsTest = + initTimely + .then(initFlakyTip) + .then(initFlakyTipNoSlack) + .then(initFlakyTipSlowFlight) + .then(initStaleLag) + .then(initStaleLagWithSlack) + .expect(cfg == staleLagWithSlack and h == startingHub(staleLagWithSlack.params) and height == GENESIS_HEIGHT) + + // ------------------------------------------------------------------------ + // The schedule under a timely tip + // ------------------------------------------------------------------------ + + /// A supported wallet's transaction, from admission to the mempool: offered + /// at the first boundary after it arrived, and accepted after a block in + /// flight. + run admittedThenPublishedTest = + started(timely) + .then(block) + .then(deliver(early)) + .expect(h.queued() == Set(early) and seen == Set(early) and onTime == Set(early) and owed == Set(early)) + .then(block) + .then(flushBegin) + .expect(h.inFlight() == Set(early) and flightStart == 3) + .expect(wOfferWithExpiry and wConformingFirstOffer) + .then(advance) + .expect(height == 4 and not(mayAdvance) and wConformingFirstOfferInFlightABlock and wBlockInFlight) + .then(judge(early, Accepted)) + .then(flushEnd) + .expect(onChain == Set(early) and offered == Set(early) and owed == Set() and flightStart == 0) + .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) + .expect(conformingEveryOfferBeforeExpiry and ackedIsHeldOrSettled) + + /// K6, control. The wallet inputs of K6 under a timely tip. The second + /// requeue is judged at tip 9, finds that an expiry of 11 does not survive + /// the flush at 12, and drops the entry: it is never offered past its + /// expiry. + run requeueUnderTimelyTipDropsTest = + started(timely) + .then(blocks(2)) + .then(flushBegin) + .then(blocks(2)) + .then(deliver(late)) + .then(block) + .then(flush([late], Retryable)) + .expect(h.queue == Map(late -> 1) and wRequeued) + .then(blocks(3)) + .then(flushBegin) + .expect(flightStart == 9 and conformingEveryOfferBeforeExpiry) + .then(judge(late, Retryable)) + .expect(requeueReport == RequeuedOutput({ held: 0, droppedExpired: 1, droppedExhausted: 0 })) + .then(flushEnd) + .expect(h.queue == Map()) +} diff --git a/zeronym/spec/protocol/tlc.sh b/zeronym/spec/protocol/tlc.sh index 734ba486..5a017308 100755 --- a/zeronym/spec/protocol/tlc.sh +++ b/zeronym/spec/protocol/tlc.sh @@ -25,8 +25,10 @@ # assignments of the init operator, which TLC rejects. That is a workaround # for the export, tied to the pinned versions below. # -# TLC_WORKERS and TLC_HEAP size the run. QUINT is the Quint command. TLC_TRACE, -# if set, names a file that receives TLC's output when it finds a violation. +# TLC_WORKERS and TLC_HEAP size the run, and TLC_TIMEOUT (seconds) bounds it: a +# run that TLC has not finished by then is a failure, never a verdict. QUINT is +# the Quint command. TLC_TRACE, if set, names a file that receives TLC's output +# when it finds a violation. set -u QUINT=${QUINT:-"npx --yes @informalsystems/quint@0.33.0"} @@ -36,6 +38,7 @@ APALACHE=$HOME/.quint/apalache-dist-$APALACHE_VERSION/apalache JAR=$APALACHE/lib/apalache.jar WORKERS=${TLC_WORKERS:-2} HEAP=${TLC_HEAP:-4g} +TIMEOUT=${TLC_TIMEOUT:-300} die() { echo "FAIL $1" >&2 @@ -89,7 +92,8 @@ if ! "$APALACHE/bin/apalache-mc" typecheck --out-dir="$work/apalache" \ fi grep -q "^-* MODULE export -*\$" export.tla 2>/dev/null || die "export: no TLA+ module was written" -# The filter. It rewrites `x' := e` to `x := e` in the definitions named +# The filter. It rewrites `x' := e` to `x := e` (the export may break the line +# after the prime) in the definitions named # `initWith` and INIT and in no other, so a step action whose name merely # contains "init" keeps its assignments. Exactly one of the two holds # assignments (`initWith`); any other count means the export is not shaped as @@ -104,7 +108,7 @@ if ! awk -v names="initWith $init" -v expected=1 ' } /^$/ { inside = 0 } inside { - if (gsub(/\047 :=/, " :=") > 0 && fresh) { changed++; fresh = 0 } + if (gsub(/\047 :=/, " :=") + sub(/\047$/, "") > 0 && fresh) { changed++; fresh = 0 } if ($0 ~ /[A-Za-z0-9_]\047/) { left++ } } { sub(/ MODULE export /, " MODULE " module " ") ; print } @@ -123,8 +127,20 @@ fi # at its height bound. printf 'INIT q_init\nNEXT q_step\nINVARIANT q_inv\n' >"$main.cfg" java "-Xmx$HEAP" -XX:+UseParallelGC -cp "$JAR" tlc2.TLC -deadlock -workers "$WORKERS" \ - -metadir "$work/states" -config "$main.cfg" "$main.tla" >tlc.log 2>&1 + -metadir "$work/states" -config "$main.cfg" "$main.tla" >tlc.log 2>&1 & +tlc=$! +(sleep "$TIMEOUT" && touch "$work/timed-out" && kill "$tlc") >/dev/null 2>&1 & +watchdog=$! +wait "$tlc" status=$? +kill "$watchdog" 2>/dev/null +wait "$watchdog" 2>/dev/null + +# Out of time. How far TLC got is reported, as its last progress line has it. +if [ -f "$work/timed-out" ]; then + reached=$(sed -n 's/^Progress(\([0-9]*\)).*, \([0-9,]*\) distinct states found.*, \([0-9,]*\) states left on queue.*/\2 distinct states, depth \1, \3 on queue/p' tlc.log | tail -1) + die "tlc: not exhausted in $TIMEOUT s (${reached:-no progress reported})" +fi # A dead init (a guard that is false) leaves TLC with nothing to explore, and # it reports "No error has been found". From 1fca688af5a3b3256ffb9c6e1e8b4560ded02573 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 01:58:06 +0400 Subject: [PATCH 38/80] test(zeronym): give the hub machine its Byzantine roles and port the schedule runs to it --- zeronym/spec/protocol/README.md | 13 + zeronym/spec/protocol/hubMachine.qnt | 38 +- .../spec/protocol/tests/hubScenariosTest.qnt | 470 +++++++++++++++++- 3 files changed, 513 insertions(+), 8 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 200104a9..0f46253e 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -760,6 +760,19 @@ where G6b holds. the shim function routes a lookup to the operator, so the clause would hold by construction and could not be broken by any of the listed changes. +**9. An entry with an expiry can be dropped as exhausted.** In +`expiringEntryDroppedAsExhaustedTest` (hub specification, `staleLag`): `late`, +expiry 11, is queued by a hub whose tip stops at 5. Three flushes come back +unjudged. Each requeue judges the entry against the observed tip, as admission +does, so the next flush it knows of is still the one at 6 and the expiry rule +never gives the entry up; the attempt bound does. The implementation's requeue +has the same two checks in the same order and is passed the observed tip +(`zeronym/hub/src/queue.rs:408-415`, `zeronym/hub/src/batcher.rs:413-422`), +while `queue.rs:197` says of the exhausted count "Only reachable for a payload +with no expiry". Shown on the model; the code was read at those lines and not +run. The bound there is 8 requeues, so the shape needs nine unjudged flushes +of a hub that sees no tip throughout. + ## Model-based testing, later Not built. The specification is shaped so it can be: diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/protocol/hubMachine.qnt index c8513d09..34732f2a 100644 --- a/zeronym/spec/protocol/hubMachine.qnt +++ b/zeronym/spec/protocol/hubMachine.qnt @@ -219,16 +219,24 @@ module hubMachine { // ------------------------------------------------------------------------ // // The hub asks its indexer two things. Both answers are the relations of - // `indexer.qnt`, not a second model of them. + // `indexer.qnt`, not a second model of them: the honest one, or, for a + // Byzantine indexer, the wider one. /// The tips the indexer may report now. def tips: Set[Height] = - honestTips(chain, if (cfg.tip == TipMayRegress) cfg.params.reorgAllowance else 0, CLOCK_HEIGHTS) + match cfg.indexerRole { + | Honest => + honestTips(chain, if (cfg.tip == TipMayRegress) cfg.params.reorgAllowance else 0, CLOCK_HEIGHTS) + | Byzantine => byzTips(CLOCK_HEIGHTS) + } /// What a broadcast of `payload` may come to: the verdict the hub is given, /// and whether the network took the transaction. def broadcastResults(payload: Payload): Set[IndexerResult] = - honestIndexerResults(chain, BroadcastIInput(payload)) + match cfg.indexerRole { + | Honest => honestIndexerResults(chain, BroadcastIInput(payload)) + | Byzantine => byzIndexerResults(chain, BroadcastIInput(payload), cfg.payloads, CLOCK_HEIGHTS) + } /// The indexer's transition that gives `given` on `payload` and relays the /// transaction to the network, or does not. @@ -250,9 +258,15 @@ module hubMachine { def freeRunEstimates: Set[Height] = CLOCK_HEIGHTS.filter(estimate => height <= estimate and estimate <= height + cfg.params.flushInterval) - /// The transitions the hub may take on a submission of `payload`. + /// The transitions the hub may take on a submission of `payload`: the one + /// the protocol prescribes, or, for a Byzantine hub, any decision with the + /// payload queued or not. A Byzantine hub keeps the honest schedule. def submitResults(payload: Payload): Set[HubResult] = - Set(hub(h, SubmitHInput({ nonce: 0, payload: payload }))) + val input = SubmitHInput({ nonce: 0, payload: payload }) + match cfg.hubRole { + | Honest => Set(hub(h, input)) + | Byzantine => byzHubResults(h, input, cfg.payloads, Set()) + } // ------------------------------------------------------------------------ // Frames @@ -679,6 +693,17 @@ module hubMachine { payloads: Set(orchard("early", 2, 10), orchard("late", 4, 12)), } + // One Byzantine component at a time, on the schedule of `timely`. + pure val byzHub: HubConfig = { ...timely, hubRole: Byzantine } + pure val byzIndexer: HubConfig = { ...timely, indexerRole: Byzantine } + + /// Bytes neither the shim nor the hub can parse: no txid, no expiry. After + /// a network upgrade a build does not know, every transaction looks like + /// this to it. + pure val junk: Payload = + { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } + pure val unknownUpgrade: HubConfig = { ...timely, payloads: Set(early, tight, junk) } + // One named init per configuration. A configuration built to show what a // relation buys asserts that the relation is false of it, so that its known // gap stays pinned on the missing relation. @@ -699,6 +724,9 @@ module hubMachine { initWith(flakyTipSlowFlight), } action initStaleLag = all { shippedAssumptions(staleLag), initWith(staleLag) } + action initByzHub = all { shippedAssumptions(byzHub), initWith(byzHub) } + action initByzIndexer = all { shippedAssumptions(byzIndexer), initWith(byzIndexer) } + action initUnknownUpgrade = all { shippedAssumptions(unknownUpgrade), initWith(unknownUpgrade) } action initStaleLagWithSlack = all { standingAssumptions(staleLagWithSlack), reorgSlackFits(staleLagWithSlack), diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/protocol/tests/hubScenariosTest.qnt index ccfd9d2c..033d51de 100644 --- a/zeronym/spec/protocol/tests/hubScenariosTest.qnt +++ b/zeronym/spec/protocol/tests/hubScenariosTest.qnt @@ -72,7 +72,17 @@ module hubScenariosTest { def flushesAheadOfChain: bool = h.flush != Idle and h.cadenceEpoch() > height / cfg.params.flushInterval - pure def floorOf(c: HubConfig): int = c.params.minWalletExpiry + /// Whether a node would take `payload` now. + def nodeWouldTake(payload: Payload): bool = + verdictsOn(payload).contains((Accepted, true)) + + /// What a Byzantine hub answers when it takes `payload` into its queue + /// whatever the admission rules say. + def admitting(payload: Payload): HubResult = + { ...h, queue: h.queue.put(payload, 0) }.toAckOutput(0, Admitted) + + pure def requeued(held: int, droppedExpired: int, droppedExhausted: int): HubOutput = + RequeuedOutput({ held: held, droppedExpired: droppedExpired, droppedExhausted: droppedExhausted }) // ------------------------------------------------------------------------ // Configurations @@ -87,7 +97,10 @@ module hubScenariosTest { .then(initFlakyTipSlowFlight) .then(initStaleLag) .then(initStaleLagWithSlack) - .expect(cfg == staleLagWithSlack and h == startingHub(staleLagWithSlack.params) and height == GENESIS_HEIGHT) + .then(initByzHub) + .then(initByzIndexer) + .then(initUnknownUpgrade) + .expect(cfg == unknownUpgrade and h == startingHub(unknownUpgrade.params) and height == GENESIS_HEIGHT) // ------------------------------------------------------------------------ // The schedule under a timely tip @@ -130,7 +143,458 @@ module hubScenariosTest { .then(flushBegin) .expect(flightStart == 9 and conformingEveryOfferBeforeExpiry) .then(judge(late, Retryable)) - .expect(requeueReport == RequeuedOutput({ held: 0, droppedExpired: 1, droppedExhausted: 0 })) + .expect(requeueReport == requeued(0, 1, 0)) + .then(flushEnd) + .expect(h.queue == Map()) + + // ------------------------------------------------------------------------ + // K5. Acknowledged, then lost + // ------------------------------------------------------------------------ + + /// K5a. The hub crashes after the ack. The queue was in memory. + run ackedThenCrashedTest = + started(timely) + .then(block) + .then(deliver(early)) + .expect(owed == Set(early) and ackedIsHeldOrSettled) + .then(crash) + .expect(h == downHub(timely.params) and onChain == Set() and offered == Set()) + .expect(not(ackedIsHeldOrSettled)) + + /// K5b. The final flush of a draining hub finds the indexer unreachable. + /// The entry was offered and nothing judged it; the hub stops, and the + /// transaction is held nowhere. + run ackedThenLostAtDrainTest = + started(timely) + .then(block) + .then(deliver(early)) + .then(drain) + .then(flush([early], Retryable)) + .expect(h.phase == Stopped and h.queued() == Set() and h.inFlight() == Set() and onChain == Set()) + .expect(offered == Set(early) and owed == Set(early)) + .expect(not(ackedIsHeldOrSettled)) + + /// K5c. A requeue gives an acknowledged entry up as expired. No crash and + /// no shutdown: the flush at 3 comes back unjudged, and an expiry of 5 does + /// not survive the flush at 6. + run requeueDropsAckedAsExpiredTest = + started(timely) + .then(block) + .then(deliver(tight)) + .then(block) + .then(flushBegin) + .then(judge(tight, Retryable)) + .expect(requeueReport == requeued(0, 1, 0)) + .then(flushEnd) + .expect(h.phase == Running and h.queue == Map()) + .expect(owed == Set(tight) and not(ackedIsHeldOrSettled)) + + // ------------------------------------------------------------------------ + // The unknown upgrade + // ------------------------------------------------------------------------ + + /// A payload the hub cannot parse has no txid and no expiry. The expiry + /// rule never refuses it and never gives it up, so the attempt bound is the + /// only limit on its requeue: after the third unjudged flush it is dropped + /// as exhausted. On the way `tight`, acknowledged, is dropped as expired. + run requeueAndDropTest = + started(unknownUpgrade) + .then(deliver(junk)) + .then(block) + .then(deliver(tight)) + .then(block) + .then(flushBegin) + // A fresh admission while the batch is out. + .then(deliver(early)) + .expect(h.queued() == Set(early) and h.inFlight() == Set(junk, tight)) + .then(judge(junk, Retryable)) + .then(judge(tight, Retryable)) + .expect(requeueReport == requeued(1, 1, 0)) + .then(flushEnd) + .expect(h.queue == Map(early -> 0, junk -> 1) and wRequeued) + .expect(owed.contains(tight) and not(ackedIsHeldOrSettled)) + .then(blocks(3)) + .then(flushBegin) + .then(judge(early, Accepted)) + .then(judge(junk, Retryable)) + .then(flushEnd) + .expect(h.queue == Map(junk -> 2)) + // The third failure is one more than the attempts allowed. + .then(blocks(3)) + .then(flushBegin) + .then(judge(junk, Retryable)) + .expect(requeueReport == requeued(0, 0, 1)) + .then(flushEnd) + .expect(h.queue == Map() and owed.contains(junk)) + .expect(offeredBeforeExpiry) + + // ------------------------------------------------------------------------ + // A tip reported behind the chain + // ------------------------------------------------------------------------ + + /// K3. A tight-expiry transaction is admitted against a tip reported one + /// block back, below a boundary the hub has already flushed. Admission + /// reasons that the next flush is at 3; it is at 6, after the expiry. + run tightExpiryAdmittedBehindFlushedBoundaryTest = + started(flakyTip) + .then(blocks(2)) + .then(flushBegin) + .then(observeWith(2)) + .then(deliver(tight)) + .expect(height == 3 and h.tip == Some(2) and h.queued() == Set(tight)) + .then(blocks(3)) + .then(flushBegin) + .expect(flightStart == 6 and h.inFlight() == Set(tight)) + // Not a supported wallet: the guarantee for those is untouched. + .expect(not(conforming(tight, cfg.params.minWalletExpiry))) + .expect(not(offeredBeforeExpiry) and conformingFirstOfferBeforeExpiry) + + /// A supported wallet's transaction, admitted the same way and then flushed + /// one block late because the tip is reported one block back. + run regressedTipDelaysFlush(c: HubConfig, payload: Payload): bool = + started(c) + .then(blocks(2)) + .then(flushBegin) + .then(observeWith(2)) + .then(deliver(payload)) + .expect(height == 3 and onTime == Set(payload)) + .then(blocks(2)) + .then(unseenBlocks(2)) + .then(observeWith(6)) + .then(flushBegin) + .expect(flightStart == 7 and h.inFlight() == Set(payload)) + .expect(conforming(payload, cfg.params.minWalletExpiry)) + + /// K3', contrast. The reorg slack leaves the transaction exactly the mining + /// margin at the offer. One block arrives while the batch is in flight, + /// which the margin is there to pay for, and the node still accepts it. + run conformingSurvivesRegressionTest = + regressedTipDelaysFlush(flakyTip, early) + .expect(early.expiry == Some(7 + cfg.params.miningMargin)) + .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry) + .then(advance) + .expect(height == 8 and not(mayAdvance)) + .expect(wConformingFirstOfferInFlightABlock and conformingFirstOfferJudgedBeforeExpiry) + .then(judge(early, Accepted)) + .then(flushEnd) + .expect(onChain == Set(early)) + + /// K3'. The same steps with the expiry floor equal to the three-term + /// budget. The flush is one block late and the transaction misses the + /// mining margin by that block. + run conformingMissesMarginWithoutSlackTest = + regressedTipDelaysFlush(flakyTipNoSlack, orchard("early", 2, 8)) + .expect(not(reorgSlackFits(cfg))) + .expect(not(conformingFirstOfferBeforeExpiry)) + + /// K7. The same steps with the batch in flight for two blocks, as many as + /// the margin reserves. The offer left the whole margin, and G6b holds. By + /// the time the node looks, the transaction can no longer be mined: the + /// budget had already spent the slack, and the flight spent the margin. + run slowFlightSpendsTheMarginTest = + regressedTipDelaysFlush(flakyTipSlowFlight, early) + .then(unseenBlocks(2)) + .expect(height == 9 and early.expiry == Some(9) and not(flightWithinMargin(cfg))) + .expect(not(nodeWouldTake(early))) + .expect(conformingFirstOfferBeforeExpiry and not(conformingFirstOfferJudgedBeforeExpiry)) + .then(judge(early, Rejected)) + + // ------------------------------------------------------------------------ + // A hub that hears nothing for a while + // ------------------------------------------------------------------------ + + /// The hub hears nothing after height 5, one block short of the flush at 6, + /// while the chain goes on. Its cadence still follows the tip it last saw, + /// so nothing is flushed until it goes stale at height 8. + run silenceAcrossBoundary(c: HubConfig, payload: Payload): bool = + started(c) + .then(blocks(2)) + .then(flushBegin) + .then(deliver(payload)) + .expect(height == 3 and onTime == Set(payload)) + .then(blocks(2)) + .then(unseenBlocks(3)) + .expect(height == 8 and h.tip == Some(5) and not(h.isFlushDue())) + .then(staleWith(8)) + .then(flushBegin) + .expect(flightStart == 8 and h.inFlight() == Set(payload)) + .expect(conforming(payload, cfg.params.minWalletExpiry)) + + /// K4. A supported wallet's transaction, expiring at 9, is offered at 8: + /// not yet expired, with one block of margin where two are reserved. One + /// block arrives while the batch is in flight, as the margin allows for, + /// and the node can no longer take it. + run silenceAcrossBoundaryMissesMarginTest = + silenceAcrossBoundary(staleLag, early) + .expect(not(conformingFirstOfferBeforeExpiry) and not(offeredBeforeExpiry)) + .then(advance) + .expect(height == 9 and not(mayAdvance) and flightWithinMargin(cfg)) + .expect(not(nodeWouldTake(early)) and not(conformingFirstOfferJudgedBeforeExpiry)) + .then(judge(early, Rejected)) + + /// K4, contrast. The same steps with an expiry floor one block higher, + /// which is the relation `staleSlackFits` asks for. The same late flush + /// leaves the margin, and after a block in flight the node accepts. + run sameSilenceWithSlackKeepsMarginTest = + silenceAcrossBoundary(staleLagWithSlack, orchard("early", 2, 10)) + .expect(staleSlackFits(cfg) and wConformingFirstOffer and conformingFirstOfferBeforeExpiry) + .then(advance) + .expect(height == 9 and conformingFirstOfferJudgedBeforeExpiry) + .then(judge(orchard("early", 2, 10), Accepted)) + .expect(onChain == Set(orchard("early", 2, 10))) + + /// A stale hub's flush finds the indexer unreachable, twice. Requeue judges + /// the entry at the observed tip, which stopped at 5: the next flush it + /// knows of is the one at 6, so an expiry of 11 looks safe both times. The + /// free-running schedule offers it again at 12. + run requeuedTwiceWhileStale: bool = + started(staleLag) + .then(blocks(2)) + .then(flushBegin) + .then(blocks(2)) + .then(deliver(late)) + .then(unseenBlocks(3)) + .then(staleWith(8)) + .then(flush([late], Retryable)) + .expect(h.queue == Map(late -> 1)) + .then(advance) + .then(staleWith(9)) + .then(flush([late], Retryable)) + .expect(h.queue == Map(late -> 2) and h.tip == Some(5)) + .then(3.reps(i => advance.then(staleWith(10 + i)))) + .then(flushBegin) + .expect(flightStart == 12 and late.expiry == Some(11)) + + /// K6. The third offer is past the expiry. The first was in time, which is + /// all G6b covers. + run requeuedPastExpiryTest = + requeuedTwiceWhileStale + .expect(not(conformingEveryOfferBeforeExpiry) and conformingFirstOfferBeforeExpiry) + + /// Finding 9. The third flush comes back unjudged as well, and the entry, + /// which has an expiry, is dropped as exhausted: the expiry rule, reading a + /// tip that stopped at 5, still does not give it up. + run expiringEntryDroppedAsExhaustedTest = + requeuedTwiceWhileStale + .then(judge(late, Retryable)) + .expect(isSome(late.expiry) and requeueReport == requeued(0, 0, 1)) .then(flushEnd) .expect(h.queue == Map()) + + /// A stale hub's free-running clock reads 6 at true height 5, and the flush + /// scheduled for 6 runs a block early. + run freeRunningClockFlushesEarlyTest = + started(staleLag) + .then(block) + .then(deliver(early)) + .then(unseenBlocks(3)) + .then(staleWith(6)) + .then(flushBegin) + .expect(height == 5 and h.cadence == FreeRunning(6) and h.inFlight() == Set(early)) + .expect(flushesAheadOfChain and conformingFirstOfferBeforeExpiry) + + /// A stale hub refuses submissions until it sees the tip move again. + run staleHubRefusesTest = + started(staleLag) + .then(unseenBlocks(3)) + .then(staleWith(4)) + .expect(wStale and decision(early) == Refused(TipStale)) + .then(deliver(early)) + .expect(h.queued() == Set()) + .then(see) + .expect(h.phase == Running and h.cadence == Tracking) + .then(deliver(early)) + .expect(h.queued() == Set(early)) + + /// Finding 2; the slack does not cover it. A stale hub's free-running clock + /// reads 6 at true height 4, and the flush scheduled for 6 runs then, with + /// nothing to publish. When the hub sees the tip again the chain is at 5, + /// and admission, which knows the tip and not the schedule's history, + /// counts on the flush at 6. That flush has already happened. The + /// transaction waits for the one at 9, and a second, shorter silence makes + /// that one late too: it is offered at 11 with expiry 12, one block of + /// margin where two are reserved, and a block in flight uses that up. + /// + /// Neither half is enough alone at these numbers. Without the early flush + /// the transaction goes out at 6; without the second silence, at 9. + run earlyFlushSpendsTheNextEpochTest = + val lateAtFloor = orchard("late", 4, 12) + started(staleLagWithSlack) + .then(unseenBlocks(3)) + .then(staleWith(6)) + .then(flushBegin) + .expect(height == 4 and h.lastEpoch == Some(2)) + .then(block) + .expect(h.cadence == Tracking and h.tip == Some(5)) + .then(deliver(lateAtFloor)) + .expect(onTime == Set(lateAtFloor)) + .then(blocks(3)) + // Height 8. The boundary at 6 has passed and nothing was flushed. + .expect(offered == Set() and h.queued() == Set(lateAtFloor)) + .then(unseenBlocks(3)) + .then(staleWith(11)) + .then(flushBegin) + .expect(conforming(lateAtFloor, cfg.params.minWalletExpiry) and staleSlackFits(cfg)) + .expect(flightStart == 11 and not(conformingFirstOfferBeforeExpiry)) + .then(advance) + .expect(height == 12 and not(nodeWouldTake(lateAtFloor))) + .expect(not(conformingFirstOfferJudgedBeforeExpiry)) + .then(judge(lateAtFloor, Rejected)) + + // ------------------------------------------------------------------------ + // A Byzantine hub + // ------------------------------------------------------------------------ + + /// G6a needs the hub. It admits a transaction the expiry rule refuses: one + /// expiring at 5, taken at tip 3, when the next flush is at 6. + run hubAdmitsPastExpiryRuleTest = + started(byzHub) + .then(blocks(2)) + .then(flushBegin) + .expect(decision(tight) == Refused(ExpiryTooTight)) + .then(submitWith(tight, admitting(tight))) + .then(blocks(3)) + .then(flushBegin) + .expect(flightStart == 6 and h.inFlight() == Set(tight)) + .expect(not(offeredBeforeExpiry)) + + run hubAdmitsPastExpiryRuleControlTest = + started(byzHub) + .then(blocks(2)) + .then(flushBegin) + .then(deliver(tight)) + .expect(h.queued() == Set()) + .then(blocks(3)) + .then(flushBegin) + .expect(h.inFlight() == Set() and offeredBeforeExpiry) + + /// G6b and G6c need the hub. A supported wallet's transaction is refused by + /// an honest hub only for reasons that are not about the transaction. This + /// hub takes one while it has not yet seen a tip, when an honest hub + /// refuses everything. It first sees the chain at height 7, adopts that + /// epoch without flushing, and offers the transaction at 9, its expiry, + /// when no node can take it. + run hubAdmitsBeforeFirstTipTest = + initWith(byzHub) + .then(unseenBlocks(2)) + .expect(decision(early) == Refused(TipStale)) + .then(submitWith(early, admitting(early))) + .expect(h.phase == Starting and height == 3 and onTime == Set(early)) + .then(unseenBlocks(4)) + .then(see) + .then(blocks(2)) + .then(flushBegin) + .expect(conforming(early, cfg.params.minWalletExpiry) and flightStart == 9) + .expect(not(conformingFirstOfferBeforeExpiry)) + .expect(not(nodeWouldTake(early)) and not(conformingFirstOfferJudgedBeforeExpiry)) + .then(judge(early, Rejected)) + + run hubAdmitsBeforeFirstTipControlTest = + initWith(byzHub) + .then(unseenBlocks(2)) + .then(deliver(early)) + .expect(h.queued() == Set()) + .then(unseenBlocks(4)) + .then(see) + .then(blocks(2)) + .then(flushBegin) + .expect(h.inFlight() == Set()) + .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) + + /// A3 needs the hub. Once its drain has begun, it takes a submission into + /// the queue: draining before and after, nothing in flight, and the queue + /// grown by a payload no flush handed back. + run hubAdmitsWhileDrainingTest = + started(byzHub) + .then(block) + .then(deliver(early)) + .then(drain) + .expect(h.phase == Draining and h.queued() == Set(early) and h.inFlight() == Set()) + .expect(decision(tight) == Refused(HubDraining)) + .then(submitWith(tight, admitting(tight))) + .expect(h.phase == Draining and h.queued() == Set(early, tight)) + + run hubAdmitsWhileDrainingControlTest = + started(byzHub) + .then(block) + .then(deliver(early)) + .then(drain) + .then(deliver(tight)) + .expect(h.phase == Draining and h.queued() == Set(early)) + + // ------------------------------------------------------------------------ + // A Byzantine indexer + // ------------------------------------------------------------------------ + + /// Premature flush. The indexer reports tip 3 at true height 2; the hub + /// believes the boundary has come and publishes what it holds. One lying + /// endpoint is enough for this: the tip is the maximum over endpoints. The + /// transaction is published early, which costs it nothing. + run tipAheadOfChainFlushesEarlyTest = + started(byzIndexer) + .then(block) + .then(deliver(early)) + .then(observeWith(3)) + .then(flushBegin) + .expect(height == 2 and h.tip == Some(3) and h.inFlight() == Set(early)) + .expect(flushesAheadOfChain) + .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry) + + /// The hub asks for the tip at every block. The indexer goes on answering 2 + /// while `silence` more blocks arrive, and then answers truthfully. The + /// flush scheduled for 3 runs then. + run tipWithheld(payload: Payload, silence: int): bool = + started(byzIndexer) + .then(block) + .then(deliver(payload)) + .then((silence - 1).reps(_ => advance.then(observeWith(2)))) + .then(advance) + .expect(height == 2 + silence and h.tip == Some(2) and not(mayAdvance)) + .then(see) + .then(flushBegin) + .expect(flightStart == 2 + silence and h.inFlight() == Set(payload)) + + /// The same polls, answered truthfully: the flush runs at 3. + run tipReported(payload: Payload): bool = + started(byzIndexer) + .then(block) + .then(deliver(payload)) + .then(block) + .then(flushBegin) + .expect(flightStart == 3 and h.inFlight() == Set(payload)) + + /// G6a needs the indexer. With the tip withheld until height 5, a + /// transaction expiring at 5 is offered with no margin left. + run indexerWithholdsTipTest = + tipWithheld(tight, 3) + .expect(not(offeredBeforeExpiry)) + + run indexerWithholdsTipControlTest = + tipReported(tight) + .expect(offeredBeforeExpiry) + .then(judge(tight, Accepted)) + .then(flushEnd) + .then(blocks(2)) + .expect(height == 5 and offeredBeforeExpiry) + + /// G6b and G6c need the indexer. The same lie, kept up to height 8, does it + /// to a supported wallet's transaction: it is offered at 8 with expiry 9, + /// and after one block in flight the node cannot take it. + run indexerWithholdsTipFromConformingTest = + tipWithheld(early, 6) + .expect(conforming(early, cfg.params.minWalletExpiry) and onTime == Set(early)) + .expect(not(conformingFirstOfferBeforeExpiry)) + .then(advance) + .expect(not(nodeWouldTake(early)) and not(conformingFirstOfferJudgedBeforeExpiry)) + .then(judge(early, Rejected)) + + /// Offered at 3, and accepted after a block in flight. + run indexerWithholdsTipFromConformingControlTest = + tipReported(early) + .expect(conformingFirstOfferBeforeExpiry) + .then(advance) + .expect(conformingFirstOfferJudgedBeforeExpiry) + .then(judge(early, Accepted)) + .then(flushEnd) + .expect(onChain == Set(early)) } From 9efa23714c6e5e668f5b2767fd2b81c59df0977e Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 02:14:45 +0400 Subject: [PATCH 39/80] test(zeronym): gate the hub specification with TLC and record finding 8 --- .github/workflows/zeronym-guards.yml | 12 +- zeronym/spec/protocol/README.md | 47 ++++++- zeronym/spec/protocol/check.sh | 120 +++++++++++++++++- .../spec/protocol/tests/hubScenariosTest.qnt | 34 +++++ 4 files changed, 205 insertions(+), 8 deletions(-) diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index f62cd63a..55e94cae 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -57,11 +57,13 @@ jobs: - name: Divert protocol model run: sh zeronym/spec/check.sh - # The standalone Quint specification of the whole protocol: typecheck, tests - # and bounded random simulation. No model checker, so no Java. + # The standalone Quint specification of the whole protocol: typecheck, tests, + # bounded random simulation, and the hub specification checked exhaustively + # with TLC, which needs Java. The Apalache distribution that carries TLC is + # fetched by Quint on first use. spec-protocol: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 # Same reasoning as `spec`: it runs code fetched at run time. permissions: contents: read @@ -69,6 +71,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '21' - name: Protocol specification run: sh zeronym/spec/protocol/check.sh diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 0f46253e..7a8f336c 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -36,7 +36,9 @@ sh zeronym/spec/protocol/check.sh Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; set `QUINT=quint` to use an installed one). The two-state properties use the action-property syntax introduced in 0.33, so 0.32 does not typecheck the -specification. No Java is needed. +specification. Tier 4 needs Java (21 in CI) and Apalache 0.62.1, whose jar +carries TLC and which Quint fetches into `~/.quint` on first use; without +either the tier fails. | Tier | What | Command | Expectation | |---|---|---|---| @@ -44,6 +46,7 @@ specification. No Java is needed. | 2 | tests | `quint test` on the spells, the four functional test files, each scenario and trust module, each configuration | all pass | | 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` ("fails" rows: 40 to 80 steps, a few with more traces) | "holds" rows hold; "fails" rows are violated | | 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | +| 4 | hub specification | `tlc.sh hubMachine.qnt hubMachine `, one row each | "holds" rows hold over every reachable state; "violated" rows are violated, by a counterexample no longer than the recorded one | | 5 | two-state properties | `QUINT_TLC=1`, opt-in, **never run** | unknown | Measured on the machine it was written on (Apple silicon, Quint's Rust @@ -773,6 +776,20 @@ with no expiry". Shown on the model; the code was read at those lines and not run. The bound there is 8 requeues, so the shape needs nine unjudged flushes of a hub that sees no tip throughout. +**8. With "timely" as defined, G6b and G6c do not hold where the tip may be +reported behind the chain; simulation said they did.** TLC violates both on +`flakyTip`, and G6b on `flakyTipSlowFlight`, with every component honest and +the reorg slack in place. `crashThenLateDuplicateTest`: `early` (built at 2, +expiry 9) is admitted at 2 and the hub crashes. It restarts at height 6, +adopts that epoch without flushing, and is then told the tip is 5. A duplicate +of the same submission arrives and is admitted, because `9 >= 6 + 2` and +admission counts on the flush at 6. The hub is shut down at 8; its final flush +offers the transaction with `9 < 8 + 2`. The property counts the transaction +as timely because its first entry into this hub's queue, before the crash, was +on time. Whether this is a statement about the property (a hub that has lost +its queue cannot be held to an arrival it no longer knows of) or about the +code has not been confirmed against the code. + ## Model-based testing, later Not built. The specification is shaped so it can be: @@ -856,6 +873,34 @@ Reachability, each as `not(..)` and each violated: on `timely`, `flakyTip`, the first three (6, 6, 7); `wStale` on `staleLag` (9) and on `staleLagWithSlack` (6). +### The schedule rows, before and after the move + +Every row about the schedule, as the gate of the whole-protocol specification +has it (bounded simulation), under simulation of the hub machine with the same +bounds (`quint run hubMachine.qnt --init=`, seed 7, 60 or 80 steps, the +row's trace count), and under TLC. "v" is violated; the number is the length +of TLC's counterexample in states. + +| Configuration | Invariant | Protocol gate | Hub machine, simulated | Hub machine, TLC | +|---|---|---|---|---| +| `baseline` / `timely` | G6a, G6b, G6c | holds | holds | holds, exhaustive | +| `flakyTip` | G6a (K3) | v | v | v, 8 | +| `flakyTip` | G6b | holds | holds | **v, 18** | +| `flakyTip` | G6c | holds | holds | **v, 19** | +| `flakyTipSlowFlight` | G6b | holds | holds | **v, 18** | +| `flakyTipSlowFlight` | G6c (K7) | v | v | v, 14 | +| `flakyTipNoSlack` | G6b (K3') | v | v | v, 12 | +| `flakyTipNoSlack` | G6c (K3') | v | v | not a TLC row; G6b's is | +| `staleLag` | G6a, G6b, G6c (K4) | v | v | v, 13; 13; 14 | +| `staleLag` | K6 | v | v | v, 13 | +| `staleLagWithSlack` | G6b (finding 2) | v, 8000 traces | v, 8000 traces | v, 19 | +| `baseline` / `timely` | K5 | v | v | v, 5; 8 without a crash; 10 without a shutdown | +| `byzHub` | G6a, G6b, G6c | v (5000 and 12000 traces for the last two) | v | v, 8; 12; 13 | +| `byzIndexer` | G6a, G6b, G6c | v | v | v, 8; 16; 17 | + +The three rows in bold are finding 8. Simulation, of either machine, does not +find the counterexample in the traces it samples; TLC does. + Configuration in the state against configuration as a constant, on `timely` with G6a, G6b and G6c: the compiled JSON is 13.7 MB with named inits and 23.6 MB with `const CONFIG` and an instance module; both give 189 297 states diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 9f31fc3d..effe542c 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -3,8 +3,8 @@ # # "Holds" here means bounded random simulation: fixed constants, a fixed step # bound, a fixed number of traces and one seed. It is not a proof and it is -# not exhaustive to any depth. `quint verify` is not run by this script unless -# QUINT_TLC=1 asks for the last tier. +# not exhaustive to any depth. The exception is tier 4, the hub specification, +# where TLC visits every reachable state of each configuration. # # Tiers: # 1 typecheck every file. @@ -18,6 +18,10 @@ # one trace. These runs also re-check the configuration's guarantees, on # longer traces and under the narrower step relations, which get deeper # into the protocol than `step` does. +# 4 tlc.sh: the hub specification, exhaustively. "holds" rows are the +# schedule guarantees; "violated" rows are the known gaps, the guarantees +# under a Byzantine hub or indexer, and the states that must be reachable +# (each as `not(..)`). Needs Java; fails, never skips, without it. # 5 opt-in, QUINT_TLC=1: the two-state properties, with TLC. Needs Java 21. # # A row that starts holding where it is expected to fail, or the reverse, @@ -68,8 +72,8 @@ finish() { } SPELLS="spells/basicSpells.qnt spells/soup.qnt" -MODULES="types.qnt wire.qnt indexer.qnt hub.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" -FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt" +MODULES="types.qnt wire.qnt indexer.qnt hub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" +FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt" INSTANCES="baseline byzShim byzHub byzIndexer awaitAck awaitAckByzShim awaitAckByzHub awaitAckByzIndexer replicated replicatedOneByz flakyTip flakyTipNoSlack flakyTipSlowFlight staleLag staleLagWithSlack" SCENARIOS="baselineScenarios awaitAckScenarios replicatedScenarios flakyTipScenarios flakyTipSlowFlightScenarios flakyTipNoSlackScenarios staleLagScenarios staleLagWithSlackScenarios byzIndexerScenarios byzHubScenarios" TRUST="byzShimTrust awaitAckByzShimTrust byzHubTrust awaitAckByzHubTrust byzIndexerTrust replicatedOneByzTrust" @@ -201,6 +205,43 @@ reaches() { done } +# tlc_holds INIT STEP INVARIANT: TLC exhausts the configuration and finds +# every reachable state satisfies the invariant. +tlc_holds() { + out=$(QUINT=$QUINT sh ./tlc.sh hubMachine.qnt hubMachine "$1" "$2" "$3" 2>&1) + case $out in + "holds "*) echo "ok $1: holds, exhaustively: $3 ($2; states and depth: ${out#holds })" ;; + "violated "*) fail "$1: $3 expected to hold under $2, TLC found a counterexample of ${out#violated } states" ;; + *) + echo "$out" | tail -20 + fail "$1: $3: tlc.sh gave no verdict" + ;; + esac +} + +# tlc_violated INIT STEP INVARIANT LENGTH: TLC finds a counterexample, and it +# is no longer than the recorded one. With one worker TLC searches breadth +# first and its counterexample is a shortest one, so a longer one means the +# recorded counterexample is gone. +tlc_violated() { + out=$(QUINT=$QUINT TLC_WORKERS=1 sh ./tlc.sh hubMachine.qnt hubMachine "$1" "$2" "$3" 2>&1) + case $out in + "violated "*) + length=${out#violated } + if [ "$length" -le "$4" ]; then + echo "ok $1: violated: $3 ($2, $length states)" + else + fail "$1: $3 violated under $2 in $length states, recorded as $4" + fi + ;; + "holds "*) fail "$1: $3 expected to be violated under $2, TLC found it holds" ;; + *) + echo "$out" | tail -20 + fail "$1: $3: tlc.sh gave no verdict" + ;; + esac +} + typecheck() { if out=$($QUINT typecheck "$1" 2>&1); then echo "ok typecheck $1" @@ -370,6 +411,77 @@ job reaches staleLag quietStep 40 \ -- wellFormed finish +echo "---- 4 hub specification (TLC, exhaustive)" + +G6A=offeredBeforeExpiry +G6B=conformingFirstOfferBeforeExpiry +G6C=conformingFirstOfferJudgedBeforeExpiry +K5=ackedIsHeldOrSettled +K6=conformingEveryOfferBeforeExpiry + +# The schedule guarantees with every component honest. +job tlc_holds initTimely step "$G6A and $G6B and $G6C" +job tlc_holds initTimely step $K6 + +# The known gaps, each on the configuration that isolates its cause. The last +# argument is the length of TLC's counterexample. +job tlc_violated initFlakyTip step $G6A 8 # K3 +job tlc_violated initFlakyTipNoSlack step $G6B 12 # K3' +job tlc_violated initFlakyTipSlowFlight step $G6C 14 # K7 +job tlc_violated initStaleLag step $G6A 13 # K4 +job tlc_violated initStaleLag step $G6B 13 # K4 +job tlc_violated initStaleLag step $G6C 14 # K4 +job tlc_violated initStaleLag step $K6 13 # K6 +job tlc_violated initStaleLagWithSlack step $G6B 19 # finding 2 +job tlc_violated initStaleLagWithSlack step $G6C 20 # finding 2 +# K5, three causes: a crash; without one, a final flush nothing judged; with +# no shutdown either, a requeue that gives the entry up as expired. +job tlc_violated initTimely step $K5 5 +job tlc_violated initTimely noCrashStep $K5 8 +job tlc_violated initTimely quietStep $K5 10 + +# Finding 8. A crash, then a late duplicate of a submission first admitted on +# time: "timely" as defined does not survive a restart. +job tlc_violated initFlakyTip step $G6B 18 +job tlc_violated initFlakyTip step $G6C 19 +job tlc_violated initFlakyTipSlowFlight step $G6B 18 + +# The trust matrix: each schedule guarantee is violated once the component it +# depends on is Byzantine. +job tlc_violated initByzHub step $G6A 8 +job tlc_violated initByzHub step $G6B 12 +job tlc_violated initByzHub step $G6C 13 +job tlc_violated initByzIndexer step $G6A 8 +job tlc_violated initByzIndexer step $G6B 16 +job tlc_violated initByzIndexer step $G6C 17 + +# Reachability. The antecedents of G6a, G6b and G6c, so that a "holds" is not +# vacuous; and one state per family of steps, because TLC runs with deadlock +# checking off and a machine whose steps died would hold everything. +job tlc_violated initTimely step "not(wOfferWithExpiry)" 6 +job tlc_violated initTimely step "not(wConformingFirstOffer)" 6 +job tlc_violated initTimely step "not(wConformingFirstOfferInFlightABlock)" 7 +job tlc_violated initTimely step "not(wOffered)" 7 +job tlc_violated initTimely step "not(wRequeued)" 9 +job tlc_violated initTimely step "not(wDown)" 2 +job tlc_violated initTimely step "not(wRestartedOwing)" 6 +job tlc_violated initTimely step "not(wBlockInFlight)" 7 +job tlc_violated initTimely step "not(wStopped)" 4 +job tlc_violated initFlakyTip step "not(wOfferWithExpiry)" 6 +job tlc_violated initFlakyTip step "not(wConformingFirstOffer)" 6 +job tlc_violated initFlakyTip step "not(wConformingFirstOfferInFlightABlock)" 7 +job tlc_violated initFlakyTip step "not(wOffered)" 7 +job tlc_violated initFlakyTip step "not(wRequeued)" 9 +job tlc_violated initFlakyTip step "not(wDown)" 2 +job tlc_violated initFlakyTip step "not(wRestartedOwing)" 6 +job tlc_violated initFlakyTip step "not(wBlockInFlight)" 7 +job tlc_violated initFlakyTip step "not(wStopped)" 4 +job tlc_violated initFlakyTipSlowFlight step "not(wConformingFirstOffer)" 6 +job tlc_violated initFlakyTipSlowFlight step "not(wBlockInFlight)" 7 +job tlc_violated initStaleLag step "not(wStale)" 9 +job tlc_violated initStaleLagWithSlack step "not(wStale)" 6 +finish + # Tier 5. Not part of the default gate, not run in CI, and never executed while # this script was written: the verdict strings matched below are what Quint # prints for the simulator, and are untested against the TLC backend. diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/protocol/tests/hubScenariosTest.qnt index 033d51de..7f5d9e57 100644 --- a/zeronym/spec/protocol/tests/hubScenariosTest.qnt +++ b/zeronym/spec/protocol/tests/hubScenariosTest.qnt @@ -299,6 +299,40 @@ module hubScenariosTest { .expect(conformingFirstOfferBeforeExpiry and not(conformingFirstOfferJudgedBeforeExpiry)) .then(judge(early, Rejected)) + /// Finding 8. A supported wallet's transaction is admitted on time, and the + /// hub crashes. It comes back at height 6, adopts that epoch without + /// flushing, and is then told the tip is 5. A duplicate of the submission + /// arrives; admission counts on the flush at 6, which will not happen. The + /// hub is shut down at 8 and its final flush offers the transaction with + /// one block of margin where two are reserved. + run crashThenLateDuplicate(c: HubConfig): bool = + started(c) + .then(block) + .then(deliver(early)) + .expect(onTime == Set(early)) + .then(crash) + .then(unseenBlocks(4)) + .then(restart) + .then(see) + .expect(height == 6 and h.lastEpoch == Some(2)) + .then(observeWith(5)) + .then(deliver(early)) + .expect(h.queued() == Set(early)) + .then(see) + .then(blocks(2)) + .then(drain) + .then(flushBegin) + .expect(flightStart == 8 and h.inFlight() == Set(early) and not(marginLeft(early, flightStart))) + + /// The transaction first entered the queue on time, so it counts as timely, + /// and its first offer misses the margin. + run crashThenLateDuplicateTest = + crashThenLateDuplicate(flakyTip) + .expect(reorgSlackFits(cfg) and onTime == Set(early) and offered == Set()) + .expect(not(conformingFirstOfferBeforeExpiry)) + .then(advance) + .expect(not(conformingFirstOfferJudgedBeforeExpiry)) + // ------------------------------------------------------------------------ // A hub that hears nothing for a while // ------------------------------------------------------------------------ From 87ab195ab0e8e72cf0a1c6dee3d7c06bdfc03a9c Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 03:16:17 +0400 Subject: [PATCH 40/80] test(zeronym): forget timeliness when the hub goes down and pin the crash-then-resend gap Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 123 ++++++++++++------ zeronym/spec/protocol/check.sh | 19 ++- zeronym/spec/protocol/hubMachine.qnt | 47 ++++--- .../spec/protocol/tests/hubScenariosTest.qnt | 69 +++++++--- 4 files changed, 172 insertions(+), 86 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 7a8f336c..23e2dcee 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -616,6 +616,7 @@ cell is a scripted step, and its "holds" cells are the unrun TLC property. | K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | | K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest`; contrast `conformingSurvivesRegressionTest` | +| K8 | A supported wallet's transaction, acknowledged on time, then lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. G6b and G6c do not cover it: to the restarted hub the resend is a late first arrival | `flakyTip` (hub specification) | scripted run | shown; not a TLC row | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | K7 was added after review. The four-term budget (`reorgSlackFits`) holds with equality in the shipped constants, so a transaction that uses all of it is @@ -776,19 +777,41 @@ with no expiry". Shown on the model; the code was read at those lines and not run. The bound there is 8 requeues, so the shape needs nine unjudged flushes of a hub that sees no tip throughout. -**8. With "timely" as defined, G6b and G6c do not hold where the tip may be -reported behind the chain; simulation said they did.** TLC violates both on -`flakyTip`, and G6b on `flakyTipSlowFlight`, with every component honest and -the reorg slack in place. `crashThenLateDuplicateTest`: `early` (built at 2, +**8. "Timely" did not survive a restart; restated, G6b and G6c hold where the +tip may be reported behind the chain, and what is left is K8.** As first +written, "timely" remembered a payload's first entry into the hub's queue +forever. TLC then violated G6b and G6c on `flakyTip`, and G6b on +`flakyTipSlowFlight`, with every component honest and the reorg slack in +place; simulation had reported them holding. The trace: `early` (built at 2, expiry 9) is admitted at 2 and the hub crashes. It restarts at height 6, -adopts that epoch without flushing, and is then told the tip is 5. A duplicate -of the same submission arrives and is admitted, because `9 >= 6 + 2` and -admission counts on the flush at 6. The hub is shut down at 8; its final flush -offers the transaction with `9 < 8 + 2`. The property counts the transaction -as timely because its first entry into this hub's queue, before the crash, was -on time. Whether this is a statement about the property (a hub that has lost -its queue cannot be held to an arrival it no longer knows of) or about the -code has not been confirmed against the code. +adopts that epoch without flushing, and is then told the tip is 5. A +duplicate of the same submission arrives and is admitted, because +`9 >= 6 + 2` and admission counts on the flush at 6. The hub is shut down at +8; its final flush offers the transaction with `9 < 8 + 2`. + +Timeliness is now forgotten when the hub goes down (a crash, or the final +flush that stops it), as its queue is. Under that definition TLC exhausts +`flakyTip` with G6b and G6c holding, and `flakyTipSlowFlight` with G6b +holding (table below). `freshAfterRestartMeetsMarginTest` shows a restarted +hub giving a fresh arrival the whole margin with the tip one block behind +throughout, and the reachability row `wTimelyQueuedBehindEpoch` shows that a +timely payload does get queued while the cadence epoch is behind the one the +hub last recorded, so the "holds" is not vacuous there. + +The three facts the trace rests on were read in the code, and the model has +each right. A restarted hub has no tip and no recorded epoch, and its first +observation adopts the current epoch without flushing +(`zeronym/hub/src/batcher.rs:316-322`). A regression within the reorg +allowance is followed (`batcher.rs:177-186`), and a cadence epoch below the +recorded one flushes nothing (`batcher.rs:323-327`). Admission computes its +deadline from the observed tip alone (`zeronym/hub/src/server.rs:351-356`, +`zeronym/hub/src/queue.rs:294`, `:507-519`). So the trace is the code's +behaviour, and what the restatement changes is only which guarantee claims +it. That behaviour is K8: the wallet did everything right, was acknowledged +on time, and its resend after the crash is offered with one block of margin +where two are reserved. Its control, `lateDuplicateWithoutCrashTest`, has no +crash: the first offer is at 3, in time, and the late offer at 8 is a second +offer (K6's ground, not G6b's). ## Model-based testing, later @@ -821,8 +844,9 @@ checked under. A guard that is false leaves no initial state, and `tlc.sh` fails on that. Measured on the machine this was written on (Apple silicon, 16 cores, 64 GB; -Quint 0.33.0, Apalache 0.62.1, Java 27), under today's definition of -timeliness and the hub function before the capacity refusals are removed. +Quint 0.33.0, Apalache 0.62.1, Java 27), under the first definition of +timeliness (finding 8) and the hub function before the capacity refusals are +removed. Every run had a five-minute limit. Times are for the whole route (compile, export, TLC), of which compile and export are about 10 s; "peak" is the resident size of the largest process. @@ -853,25 +877,36 @@ Verdicts (`step` unless said; 8 workers, 8 GB; every row 11 to 17 s): |---|---|---| | `timely` | G6a and G6b and G6c | holds, 189 297 states, depth 44 | | `timely` | `conformingEveryOfferBeforeExpiry` (K6's predicate) | holds, 189 297 states, depth 44 | -| `timely` | `ackedIsHeldOrSettled` (K5) | violated: 5 states under `step`, 8 under `noCrashStep`, 10 under `quietStep` | +| `timely` | `ackedIsHeldOrSettled` (K5) | violated: 5 states under `step`, 8 under `noCrashStep`, 9 under `quietStep` | | `flakyTip` | G6a (K3) | violated, 8 states | -| `flakyTip` | G6b; G6c | violated, 18; 19 states | -| `flakyTipSlowFlight` | G6b; G6c (K7) | violated, 18; 14 states | +| `flakyTip` | G6b; G6c | violated, 18; 19 states. **Holds, 1 468 808 states, depth 44, once timeliness is forgotten on going down** | +| `flakyTipSlowFlight` | G6b; G6c (K7) | violated, 18; 14 states. **G6b holds, 2 020 400 states, depth 44, once timeliness is forgotten** | | `flakyTipNoSlack` | G6b (K3') | violated, 12 states | -| `staleLag` | G6a; G6b; G6c; K6 | violated, 13; 13; 14; 13 states | +| `staleLag` | G6a; G6b; G6c; K6 | violated, 12; 13; 14; 13 states | | `staleLagWithSlack` | G6b; G6c | violated, 19; 20 states | -G6b and G6c are violated on `flakyTip` and G6b on `flakyTipSlowFlight`, where -simulation of the whole protocol reports that they hold. The counterexample -needs a crash and a late duplicate of a submission that was first admitted on -time; it is about what "timely" means across a restart. +Under the first definition G6b and G6c are violated on `flakyTip` and G6b on +`flakyTipSlowFlight`, where simulation of the whole protocol reports that they +hold. The counterexample needs a crash and a late duplicate of a submission +that was first admitted on time; it is about what "timely" means across a +restart (finding 8). With timeliness forgotten on going down, the restated +rows were run with 2 workers and 4 GB, side by side: `timely` 229 339 states +at depth 51 in 38 s, `flakyTip` 205 s, `flakyTipSlowFlight` 286 s. The +restatement raises the state counts, because `seen` and the timely set now +differ between states that agreed before. Every other verdict and trace +length is unchanged by it. + +Trace lengths are with one worker, when TLC's search is breadth first and its +counterexample a shortest one. Three were first recorded from runs with more +workers and were one to three states too long: G6a on `staleLag` (13, now 12), +K5 under `quietStep` (10, now 9), `wStale` on `staleLag` (9, now 6). Reachability, each as `not(..)` and each violated: on `timely`, `wOfferWithExpiry` (6 states), `wConformingFirstOffer` (6), `wConformingFirstOfferInFlightABlock` (7), `wOffered` (7), `wRequeued` (9), `wDown` (2), `wRestartedOwing` (6), `wBlockInFlight` (7), `wStopped` (4); on -`flakyTip`, the first three (6, 6, 7); `wStale` on `staleLag` (9) and on -`staleLagWithSlack` (6). +`flakyTip`, the first three (6, 6, 7) and `wTimelyQueuedBehindEpoch` (6); +`wStale` on `staleLag` (6) and on `staleLagWithSlack` (6). ### The schedule rows, before and after the move @@ -881,25 +916,27 @@ bounds (`quint run hubMachine.qnt --init=`, seed 7, 60 or 80 steps, the row's trace count), and under TLC. "v" is violated; the number is the length of TLC's counterexample in states. -| Configuration | Invariant | Protocol gate | Hub machine, simulated | Hub machine, TLC | -|---|---|---|---|---| -| `baseline` / `timely` | G6a, G6b, G6c | holds | holds | holds, exhaustive | -| `flakyTip` | G6a (K3) | v | v | v, 8 | -| `flakyTip` | G6b | holds | holds | **v, 18** | -| `flakyTip` | G6c | holds | holds | **v, 19** | -| `flakyTipSlowFlight` | G6b | holds | holds | **v, 18** | -| `flakyTipSlowFlight` | G6c (K7) | v | v | v, 14 | -| `flakyTipNoSlack` | G6b (K3') | v | v | v, 12 | -| `flakyTipNoSlack` | G6c (K3') | v | v | not a TLC row; G6b's is | -| `staleLag` | G6a, G6b, G6c (K4) | v | v | v, 13; 13; 14 | -| `staleLag` | K6 | v | v | v, 13 | -| `staleLagWithSlack` | G6b (finding 2) | v, 8000 traces | v, 8000 traces | v, 19 | -| `baseline` / `timely` | K5 | v | v | v, 5; 8 without a crash; 10 without a shutdown | -| `byzHub` | G6a, G6b, G6c | v (5000 and 12000 traces for the last two) | v | v, 8; 12; 13 | -| `byzIndexer` | G6a, G6b, G6c | v | v | v, 8; 16; 17 | - -The three rows in bold are finding 8. Simulation, of either machine, does not -find the counterexample in the traces it samples; TLC does. +| Configuration | Invariant | Protocol gate | Hub machine, simulated | Hub machine, TLC | TLC, timeliness restated | +|---|---|---|---|---|---| +| `baseline` / `timely` | G6a, G6b, G6c | holds | holds | holds, exhaustive | holds, exhaustive | +| `flakyTip` | G6a (K3) | v | v | v, 8 | v, 8 | +| `flakyTip` | G6b | holds | holds | **v, 18** | holds, exhaustive | +| `flakyTip` | G6c | holds | holds | **v, 19** | holds, exhaustive | +| `flakyTipSlowFlight` | G6b | holds | holds | **v, 18** | holds, exhaustive | +| `flakyTipSlowFlight` | G6c (K7) | v | v | v, 14 | v, 14 | +| `flakyTipNoSlack` | G6b (K3') | v | v | v, 12 | v, 12 | +| `flakyTipNoSlack` | G6c (K3') | v | v | not a TLC row; G6b's is | | +| `staleLag` | G6a, G6b, G6c (K4) | v | v | v, 12; 13; 14 | v, 12; 13; 14 | +| `staleLag` | K6 | v | v | v, 13 | v, 13 | +| `staleLagWithSlack` | G6b (finding 2) | v, 8000 traces | v, 8000 traces | v, 19 | v, 19 | +| `baseline` / `timely` | K5 | v | v | v, 5; 8 without a crash; 9 without a shutdown | the same | +| `byzHub` | G6a, G6b, G6c | v (5000 and 12000 traces for the last two) | v | v, 8; 12; 13 | the same | +| `byzIndexer` | G6a, G6b, G6c | v | v | v, 8; 16; 17 | the same | + +The three rows in bold are finding 8 under the first definition. Simulation, +of either machine, does not find the counterexample in the traces it samples; +TLC does. The protocol gate's rows are still simulated on the whole-protocol +machine, with timeliness as first defined, until they are removed from it. Configuration in the state against configuration as a constant, on `timely` with G6a, G6b and G6c: the compiled JSON is 13.7 MB with named inits and diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index effe542c..73650fd2 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -419,16 +419,20 @@ G6C=conformingFirstOfferJudgedBeforeExpiry K5=ackedIsHeldOrSettled K6=conformingEveryOfferBeforeExpiry -# The schedule guarantees with every component honest. +# The schedule guarantees with every component honest: all of them under a +# timely tip; under a tip that may be reported behind the chain, those for +# supported wallets; and with a slow flight as well, the one about the offer. job tlc_holds initTimely step "$G6A and $G6B and $G6C" job tlc_holds initTimely step $K6 +job tlc_holds initFlakyTip step "$G6B and $G6C" +job tlc_holds initFlakyTipSlowFlight step $G6B # The known gaps, each on the configuration that isolates its cause. The last # argument is the length of TLC's counterexample. job tlc_violated initFlakyTip step $G6A 8 # K3 job tlc_violated initFlakyTipNoSlack step $G6B 12 # K3' job tlc_violated initFlakyTipSlowFlight step $G6C 14 # K7 -job tlc_violated initStaleLag step $G6A 13 # K4 +job tlc_violated initStaleLag step $G6A 12 # K4 job tlc_violated initStaleLag step $G6B 13 # K4 job tlc_violated initStaleLag step $G6C 14 # K4 job tlc_violated initStaleLag step $K6 13 # K6 @@ -438,13 +442,7 @@ job tlc_violated initStaleLagWithSlack step $G6C 20 # findin # no shutdown either, a requeue that gives the entry up as expired. job tlc_violated initTimely step $K5 5 job tlc_violated initTimely noCrashStep $K5 8 -job tlc_violated initTimely quietStep $K5 10 - -# Finding 8. A crash, then a late duplicate of a submission first admitted on -# time: "timely" as defined does not survive a restart. -job tlc_violated initFlakyTip step $G6B 18 -job tlc_violated initFlakyTip step $G6C 19 -job tlc_violated initFlakyTipSlowFlight step $G6B 18 +job tlc_violated initTimely quietStep $K5 9 # The trust matrix: each schedule guarantee is violated once the component it # depends on is Byzantine. @@ -476,9 +474,10 @@ job tlc_violated initFlakyTip step "not(wDown)" 2 job tlc_violated initFlakyTip step "not(wRestartedOwing)" 6 job tlc_violated initFlakyTip step "not(wBlockInFlight)" 7 job tlc_violated initFlakyTip step "not(wStopped)" 4 +job tlc_violated initFlakyTip step "not(wTimelyQueuedBehindEpoch)" 6 job tlc_violated initFlakyTipSlowFlight step "not(wConformingFirstOffer)" 6 job tlc_violated initFlakyTipSlowFlight step "not(wBlockInFlight)" 7 -job tlc_violated initStaleLag step "not(wStale)" 9 +job tlc_violated initStaleLag step "not(wStale)" 6 job tlc_violated initStaleLagWithSlack step "not(wStale)" 6 finish diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/protocol/hubMachine.qnt index 34732f2a..00c595a8 100644 --- a/zeronym/spec/protocol/hubMachine.qnt +++ b/zeronym/spec/protocol/hubMachine.qnt @@ -151,10 +151,11 @@ module hubMachine { // The observer's memory. - /// The payloads that have entered this hub's queue. + /// The payloads that have entered this hub's queue since it last came up. var seen: Set[Payload] /// Those that first did so within the delivery lag of the height they were - /// built at. + /// built at. Both are forgotten when the hub goes down, as its queue is: a + /// hub cannot be held to an arrival it no longer knows of. var onTime: Set[Payload] /// The payloads a flight has carried, once that flight has had a node's /// answer for them or has ended. @@ -274,6 +275,7 @@ module hubMachine { action chainKept = all { height' = height, onChain' = onChain, polled' = polled } action admissionsKept = all { seen' = seen, onTime' = onTime } + action admissionsForgotten = all { seen' = Set(), onTime' = Set() } action memoryKept = all { admissionsKept, offered' = offered, owed' = owed } /// The hub takes `input` on its own schedule. A step that would change @@ -373,8 +375,10 @@ module hubMachine { all { hubTakes(FlushDueHInput), flightStart' = if (result.state.flush == Idle) 0 else height, + if (result.state.phase == Stopped) admissionsForgotten else admissionsKept, + offered' = offered, + owed' = owed, chainKept, - memoryKept, } /// The indexer returns `given` on one entry of the batch, and the network @@ -405,15 +409,17 @@ module hubMachine { } /// A flush ends: what nothing judged is requeued or dropped. The flight is - /// over for every entry it carried. - action flushEnd = all { - hubTakes(FlushDoneHInput), - offered' = offered.union(h.inFlight()), - flightStart' = 0, - owed' = owed, - admissionsKept, - chainKept, - } + /// over for every entry it carried. The final flush stops the hub. + action flushEnd = + val result = hub(h, FlushDoneHInput) + all { + hubTakes(FlushDoneHInput), + offered' = offered.union(h.inFlight()), + flightStart' = 0, + owed' = owed, + if (result.state.phase == Stopped) admissionsForgotten else admissionsKept, + chainKept, + } /// The hub gets its shutdown signal. action drain = all { @@ -430,7 +436,7 @@ module hubMachine { offered' = offered.union(h.inFlight()), flightStart' = 0, owed' = owed, - admissionsKept, + admissionsForgotten, chainKept, } @@ -530,8 +536,10 @@ module hubMachine { } /// Whether `payload` reached this hub as a supported wallet's would: it - /// honours the expiry floor, and it first entered the queue within the - /// delivery lag of the height it was built at. + /// honours the expiry floor, and it first entered the queue of this run of + /// the hub within the delivery lag of the height it was built at. A copy + /// that arrives late at a hub that has been down since the first one is not + /// timely, whatever happened before (K8). def isConformingAndOnTime(payload: Payload): bool = conforming(payload, cfg.params.minWalletExpiry) and onTime.contains(payload) @@ -624,6 +632,15 @@ module hubMachine { val wBlockInFlight = flightStart > 0 and height > flightStart val wStopped = h.phase == Stopped val wStale = h.phase == Stale + /// A timely payload is queued while the cadence clock is in an epoch before + /// the one the hub last recorded: the tip went back across a boundary the + /// hub has already acted on. + val wTimelyQueuedBehindEpoch = + h.queued().exists(payload => onTime.contains(payload)) and + match h.lastEpoch { + | Some(epoch) => h.cadenceEpoch() < epoch + | None => false + } // ------------------------------------------------------------------------ // Configurations diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/protocol/tests/hubScenariosTest.qnt index 7f5d9e57..cf74bfbd 100644 --- a/zeronym/spec/protocol/tests/hubScenariosTest.qnt +++ b/zeronym/spec/protocol/tests/hubScenariosTest.qnt @@ -299,21 +299,19 @@ module hubScenariosTest { .expect(conformingFirstOfferBeforeExpiry and not(conformingFirstOfferJudgedBeforeExpiry)) .then(judge(early, Rejected)) - /// Finding 8. A supported wallet's transaction is admitted on time, and the - /// hub crashes. It comes back at height 6, adopts that epoch without - /// flushing, and is then told the tip is 5. A duplicate of the submission - /// arrives; admission counts on the flush at 6, which will not happen. The - /// hub is shut down at 8 and its final flush offers the transaction with - /// one block of margin where two are reserved. - run crashThenLateDuplicate(c: HubConfig): bool = - started(c) + /// A supported wallet's transaction is admitted on time at height 2. At + /// height 6 the hub, having adopted that epoch without flushing, is told + /// the tip is 5, and a duplicate of the submission arrives. The hub is shut + /// down at 8. + run duplicateBehindTheBoundary(lost: bool): bool = + started(flakyTip) .then(block) .then(deliver(early)) .expect(onTime == Set(early)) - .then(crash) - .then(unseenBlocks(4)) - .then(restart) - .then(see) + .then( + if (lost) crash.then(unseenBlocks(4)).then(restart).then(see) + else block.then(flush([early], Accepted)).then(blocks(3)).then(flushBegin) + ) .expect(height == 6 and h.lastEpoch == Some(2)) .then(observeWith(5)) .then(deliver(early)) @@ -324,14 +322,49 @@ module hubScenariosTest { .then(flushBegin) .expect(flightStart == 8 and h.inFlight() == Set(early) and not(marginLeft(early, flightStart))) - /// The transaction first entered the queue on time, so it counts as timely, - /// and its first offer misses the margin. + /// K8 (finding 8). The hub crashed in between and lost its queue. To it the + /// duplicate is a first arrival, four blocks late, and admission counts on + /// the flush at 6, which will not happen. The wallet's transaction was on + /// time, was acknowledged, and is first offered with one block of margin + /// where two are reserved. G6b and G6c hold: they are about arrivals this + /// run of the hub knows to be timely. run crashThenLateDuplicateTest = - crashThenLateDuplicate(flakyTip) - .expect(reorgSlackFits(cfg) and onTime == Set(early) and offered == Set()) - .expect(not(conformingFirstOfferBeforeExpiry)) + duplicateBehindTheBoundary(true) + .expect(reorgSlackFits(cfg) and onTime == Set() and offered == Set()) + .expect(conformingFirstOfferBeforeExpiry) .then(advance) - .expect(not(conformingFirstOfferJudgedBeforeExpiry)) + .expect(not(nodeWouldTake(early)) and conformingFirstOfferJudgedBeforeExpiry) + + /// K8, control. No crash: the transaction is published by the flush at 3. + /// The late offer at 8 is a second offer of an entry whose first was in + /// time. + run lateDuplicateWithoutCrashTest = + duplicateBehindTheBoundary(false) + .expect(onTime == Set(early) and offered == Set(early)) + .expect(conformingFirstOfferBeforeExpiry and not(conformingEveryOfferBeforeExpiry)) + + /// A hub that comes back up owes a fresh arrival the whole margin, even + /// with the tip reported one block behind throughout. The hub restarts at + /// height 3 and is told the tip is 2; `early` arrives then, on time. The + /// flush falls due when the hub hears of height 6, which is at 7, and the + /// offer leaves exactly the margin. + run freshAfterRestartMeetsMarginTest = + initWith(flakyTip) + .then(crash) + .then(unseenBlocks(2)) + .then(restart) + .then(see) + .then(observeWith(2)) + .then(deliver(early)) + .expect(height == 3 and onTime == Set(early)) + .then(4.reps(i => advance.then(observeWith(3 + i)))) + .expect(height == 7 and h.tip == Some(6) and not(mayAdvance)) + .then(flushBegin) + .expect(flightStart == 7 and wConformingFirstOffer and conformingFirstOfferBeforeExpiry) + .then(advance) + .expect(height == 8 and not(mayAdvance)) + .expect(wConformingFirstOfferInFlightABlock and conformingFirstOfferJudgedBeforeExpiry) + .then(judge(early, Accepted)) // ------------------------------------------------------------------------ // A hub that hears nothing for a while From e2649adf0e5fef8e71ac32f6df88738864c85193 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 03:19:10 +0400 Subject: [PATCH 41/80] test(zeronym): drop the schedule rows from the protocol gate now the hub specification checks them Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 5 - zeronym/spec/protocol/check.sh | 77 +-- zeronym/spec/protocol/instances.qnt | 99 ---- zeronym/spec/protocol/tests/scenariosTest.qnt | 448 ------------------ zeronym/spec/protocol/tests/trustTest.qnt | 169 ------- 5 files changed, 14 insertions(+), 784 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 23e2dcee..e610bb0b 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -452,11 +452,6 @@ One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields | `awaitAckByzIndexer` | 1 | `AwaitVerdict` | H / H / **B** | timely | | `replicated` | 2 | `DispatchOnly` | H / H, H / H | timely | | `replicatedOneByz` | 2 | `DispatchOnly` | H / H, **B** / H | timely | -| `flakyTip` | 1 | `DispatchOnly` | H / H / H | may regress | -| `flakyTipNoSlack` | 1 | `DispatchOnly` | H / H / H | may regress; `reorgSlackFits` false | -| `flakyTipSlowFlight` | 1 | `DispatchOnly` | H / H / H | may regress; `flightWithinMargin` false | -| `staleLag` | 1 | `DispatchOnly` | H / H / H | may lag; `staleSlackFits` false, as shipped | -| `staleLagWithSlack` | 1 | `DispatchOnly` | H / H / H | may lag; `staleSlackFits` true | The schedule is the shipped one scaled down, keeping the relations between the numbers: diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 73650fd2..83bbe8ca 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -74,8 +74,8 @@ finish() { SPELLS="spells/basicSpells.qnt spells/soup.qnt" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt" -INSTANCES="baseline byzShim byzHub byzIndexer awaitAck awaitAckByzShim awaitAckByzHub awaitAckByzIndexer replicated replicatedOneByz flakyTip flakyTipNoSlack flakyTipSlowFlight staleLag staleLagWithSlack" -SCENARIOS="baselineScenarios awaitAckScenarios replicatedScenarios flakyTipScenarios flakyTipSlowFlightScenarios flakyTipNoSlackScenarios staleLagScenarios staleLagWithSlackScenarios byzIndexerScenarios byzHubScenarios" +INSTANCES="baseline byzShim byzHub byzIndexer awaitAck awaitAckByzShim awaitAckByzHub awaitAckByzIndexer replicated replicatedOneByz" +SCENARIOS="baselineScenarios awaitAckScenarios replicatedScenarios byzHubScenarios" TRUST="byzShimTrust awaitAckByzShimTrust byzHubTrust awaitAckByzHubTrust byzIndexerTrust replicatedOneByzTrust" fail() { @@ -279,10 +279,8 @@ echo "---- 3 invariants ($SAMPLES traces, seed $SEED)" # The guarantees, where they are claimed. G7 `wellFormed` is checked everywhere. job holds baseline operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub \ - offeredBeforeExpiry conformingFirstOfferBeforeExpiry \ - conformingFirstOfferJudgedBeforeExpiry ackImpliesQueued wellFormed -job holds byzShim offeredBeforeExpiry conformingFirstOfferBeforeExpiry \ - conformingFirstOfferJudgedBeforeExpiry ackImpliesQueued wellFormed + ackImpliesQueued wellFormed +job holds byzShim ackImpliesQueued wellFormed job holds byzHub operatorBlind txidAuthenticity wellFormed job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed job holds awaitAck toldImpliesQueued ackImpliesQueued wellFormed @@ -290,19 +288,10 @@ job holds awaitAckByzShim wellFormed job holds awaitAckByzHub wellFormed job holds awaitAckByzIndexer toldImpliesQueued wellFormed job holds replicated lookupValidityPerHub wellFormed -job holds replicatedOneByz txidAuthenticity ackImpliesQueuedForHonestHubs offeredBeforeExpiryForHonestHubs \ - conformingFirstOfferBeforeExpiryForHonestHubs \ - conformingFirstOfferJudgedBeforeExpiryForHonestHubs wellFormed -job holds flakyTip conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry wellFormed -job holds flakyTipNoSlack wellFormed -job holds flakyTipSlowFlight conformingFirstOfferBeforeExpiry wellFormed -job holds staleLag wellFormed -job holds staleLagWithSlack wellFormed +job holds replicatedOneByz txidAuthenticity ackImpliesQueuedForHonestHubs wellFormed # The trust matrix: each guarantee fails once the component it depends on is -# Byzantine. Two of the Byzantine-hub rows have a trace count of their own: -# what they need (a hub that admits before it has seen a tip, and sees one -# late) is rare under random choice. +# Byzantine. The schedule guarantees' rows are in tier 4. job fails byzShim step 40 operatorBlind job fails byzShim step 40 queuedBytesConfidential job fails byzShim step 40 txidAuthenticity @@ -311,44 +300,21 @@ job fails awaitAckByzShim step 40 toldImpliesQueued job fails byzHub step 40 queuedBytesConfidential job fails byzHub step 40 lookupValidityPerHub job fails byzHub step 40 ackImpliesQueued -job fails byzHub quietStep 40 offeredBeforeExpiry -job fails byzHub quietStep 40 conformingFirstOfferBeforeExpiry 5000 -job fails byzHub quietStep 40 conformingFirstOfferJudgedBeforeExpiry 12000 job fails awaitAckByzHub step 40 toldImpliesQueued job fails byzIndexer step 40 queuedBytesConfidential job fails byzIndexer step 40 lookupValidityPerHub -job fails byzIndexer quietStep 40 offeredBeforeExpiry -job fails byzIndexer quietStep 80 conformingFirstOfferBeforeExpiry -job fails byzIndexer quietStep 80 conformingFirstOfferJudgedBeforeExpiry job fails replicatedOneByz step 40 queuedBytesConfidential job fails replicatedOneByz step 40 lookupValidityPerHub # The known gaps, with every component honest. job fails baseline quietStep 40 statusNeverRegresses # K2 job fails replicated quietStep 40 statusNeverRegresses # K2 -job fails flakyTip quietStep 40 offeredBeforeExpiry # K3 -job fails flakyTipNoSlack quietStep 40 conformingFirstOfferBeforeExpiry # K3' -job fails flakyTipNoSlack quietStep 80 conformingFirstOfferJudgedBeforeExpiry # K3' -job fails staleLag quietStep 40 offeredBeforeExpiry # K4 -job fails staleLag quietStep 80 conformingFirstOfferBeforeExpiry # K4 -job fails staleLag quietStep 80 conformingFirstOfferJudgedBeforeExpiry 4000 # K4 -job fails baseline step 40 ackedIsHeldOrSettled # K5 -job fails awaitAck step 40 ackedIsHeldOrSettled # K5 -job fails staleLag outageStep 80 conformingEveryOfferBeforeExpiry # K6 -job fails flakyTipSlowFlight quietStep 80 conformingFirstOfferJudgedBeforeExpiry # K7 - -# Predicted to hold, observed to fail: the stale slack does not give G6b. -# See "Findings" in README.md. Simulation finds this about once in a few -# thousand traces, so the row has its own, larger, trace count; the scripted -# run `earlyFlushSpendsTheNextEpochTest` is the evidence that does not depend -# on it. -job fails staleLagWithSlack quietStep 60 conformingFirstOfferBeforeExpiry 8000 finish echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" -BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub offeredBeforeExpiry conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry ackImpliesQueued wellFormed" +BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued wellFormed" # W4 (four of the five refusals), W8, W17, K1a, K1b, and the antecedents of # G1, G2, G8. @@ -357,11 +323,10 @@ job reaches baseline step 40 \ wQueuedDisclosed wThirdPartyPayloadQueued wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS -# W1, W2, W3, W5, W6, W9, and the antecedents of G3, G4, G6a, G6b, G6c. +# W1, W2, W3, W5, W6, W9, and the antecedents of G3, G4. job reaches baseline quietStep 80 \ wPending wTxInMempool wTxMined wRequeued wDroppedExpired wUnparseableMissed \ - vTxidAuthenticity vLookupValidityPerHub vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry \ - vConformingFirstOfferJudged \ + vTxidAuthenticity vLookupValidityPerHub \ -- $BASELINE_HOLDS # W4 (the fifth refusal), W7, W12. job reaches baseline outageStep 80 \ @@ -369,16 +334,14 @@ job reaches baseline outageStep 80 \ -- $BASELINE_HOLDS job reaches byzShim quietStep 40 \ - vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry vConformingFirstOfferJudged vAckImpliesQueued \ - -- offeredBeforeExpiry conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry \ - ackImpliesQueued wellFormed + vAckImpliesQueued \ + -- ackImpliesQueued wellFormed # W16, both halves. job reaches byzHub quietStep 40 \ vOperatorBlind vTxidAuthenticity wTwinServed wFalseHeightServed \ -- operatorBlind txidAuthenticity wellFormed -# W15. job reaches byzIndexer quietStep 40 \ - vOperatorBlind vTxidAuthenticity vAckImpliesQueued wPrematureFlush \ + vOperatorBlind vTxidAuthenticity vAckImpliesQueued \ -- operatorBlind txidAuthenticity ackImpliesQueued wellFormed job reaches awaitAck quietStep 40 \ vToldImpliesQueued vAckImpliesQueued \ @@ -395,20 +358,8 @@ job reaches replicated quietStep 80 \ vLookupValidityPerHub wPublishedByTwoHubs \ -- lookupValidityPerHub wellFormed job reaches replicatedOneByz quietStep 40 \ - vTxidAuthenticity vAckImpliesQueued vOfferedBeforeExpiry vConformingFirstOfferBeforeExpiry \ - vConformingFirstOfferJudged \ - -- txidAuthenticity ackImpliesQueuedForHonestHubs offeredBeforeExpiryForHonestHubs \ - conformingFirstOfferBeforeExpiryForHonestHubs conformingFirstOfferJudgedBeforeExpiryForHonestHubs wellFormed -job reaches flakyTip quietStep 40 \ - vConformingFirstOfferBeforeExpiry vConformingOfferAdmittedBehind vConformingFirstOfferJudged \ - -- conformingFirstOfferBeforeExpiry conformingFirstOfferJudgedBeforeExpiry wellFormed -job reaches flakyTipSlowFlight quietStep 40 \ - vConformingFirstOfferBeforeExpiry \ - -- conformingFirstOfferBeforeExpiry wellFormed -# W18. -job reaches staleLag quietStep 40 \ - wEarlyFreeRunFlush \ - -- wellFormed + vTxidAuthenticity vAckImpliesQueued \ + -- txidAuthenticity ackImpliesQueuedForHonestHubs wellFormed finish echo "---- 4 hub specification (TLC, exhaustive)" diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index 823518b5..4ef7bee3 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -106,33 +106,6 @@ module configs { ...replicated, roles: { ...allHonest, hubs: Map("h1" -> Honest, "h2" -> Byzantine) }, } - - // A tip that may be reported up to the reorg allowance behind the chain: - // with the slack that covers it, and without. In the second the expiry - // floor is the three-term budget exactly. - pure val flakyTip = { ...baseline, tip: TipMayRegress } - pure val flakyTipNoSlack = { - ...flakyTip, - minWalletExpiry: 6, - reliesOnReorgSlack: false, - payloads: Set(orchard("early", 2, 8), late, tight, junk, plain), - twins: Set({ ...orchard("early", 2, 8), id: "early-twin" }), - } - - // The same tip, and a flush that may stay in flight for as many blocks as - // the mining margin reserves. - pure val flakyTipSlowFlight = { ...flakyTip, maxFlightBlocks: 2, reliesOnFlightWithinMargin: false } - - // A hub that may go without a tip for a while: on the shipped relation - // between the staleness window and the expiry floor, and on the relation - // that would cover the silence. - pure val staleLag = { ...baseline, tip: TipMayLag } - pure val staleLagWithSlack = { - ...staleLag, - minWalletExpiry: 8, - payloads: Set(orchard("early", 2, 10), orchard("late", 4, 12), tight, junk, plain), - twins: Set({ ...orchard("early", 2, 10), id: "early-twin" }), - } } module baseline { @@ -274,75 +247,3 @@ module replicatedOneByz { assert(not(staleSlackFits)), } } - -module flakyTip { - import types.* from "./types" - import configs.* - import protocol(CONFIG = flakyTip).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - -// The reorg slack is dropped on purpose: this configuration shows what it buys. -module flakyTipNoSlack { - import types.* from "./types" - import configs.* - import protocol(CONFIG = flakyTipNoSlack).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(flightWithinMargin), - assert(not(reorgSlackFits)), - assert(not(shippedRelationsKept)), - } -} - -// A flush may outlast the mining margin: this configuration shows what the -// bound on flight time buys. -module flakyTipSlowFlight { - import types.* from "./types" - import configs.* - import protocol(CONFIG = flakyTipSlowFlight).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(shippedRelationsKept), - assert(not(flightWithinMargin)), - } -} - -// The stale slack does not hold here, as it does not for the shipped constants. -module staleLag { - import types.* from "./types" - import configs.* - import protocol(CONFIG = staleLag).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - -module staleLagWithSlack { - import types.* from "./types" - import configs.* - import protocol(CONFIG = staleLagWithSlack).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(staleSlackFits), - assert(not(shippedRelationsKept)), - } -} diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index 70d86e70..40de71f6 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -100,46 +100,6 @@ module baselineScenarios { )) .expect(audit.everQueued.get("h1") == Set(junk, early)) - /// W5, W6, W7, W12. An indexer that cannot be reached: entries are requeued, - /// dropped as expired, dropped as exhausted, and held past capacity. - run requeueAndDropTest = - started - .then(submitTo("h1", 0, junk)) - .then(block) - // Height 2: a transaction expiring at 5 survives the flush at 3. - .then(submitTo("h1", 1, tight)) - .then(block) - .then(hubFlushBeginWith("h1")) - // While the batch is out, two fresh admissions fill the queue. - .then(submitTo("h1", 2, early)) - .then(thirdPartySubmitWith(garbage, "h1")) - .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) - .expect(s.queuedAt("h1") == Set(early, garbage) and s.inFlightAt("h1") == Set(junk, tight)) - .then(judge("h1", junk, Retryable)) - .then(judge("h1", tight, Retryable)) - .then(hubFlushEndWith("h1")) - // `junk` never expires and comes back, over capacity. `tight` would not - // survive the flush at 6 and is given up on. - .expect(queue == Map(early -> 0, garbage -> 0, junk -> 1)) - .expect(audit.dropped == Set({ hub: "h1", payload: tight, attempts: 0 })) - .expect(wRequeued and wDroppedExpired and wQueueOverCapacity and not(wDroppedExhausted)) - // `tight` was acknowledged, and is now held nowhere and judged by nobody: - // a third way to lose an acknowledged payload (K5). - .expect(s.acks("h1", ShimAddr).contains((1, WAccepted)) and not(ackedIsHeldOrSettled)) - .then(blocks(3)) - .then(hubFlushBeginWith("h1")) - .then(judge("h1", early, Accepted)) - .then(judge("h1", garbage, Accepted)) - .then(judge("h1", junk, Retryable)) - .then(hubFlushEndWith("h1")) - .expect(queue == Map(junk -> 2)) - // The third failure is one more than the attempts allowed. - .then(blocks(3)) - .then(flush("h1", [junk], Retryable)) - .expect(queue == Map() and wDroppedExhausted) - .expect(audit.dropped.contains({ hub: "h1", payload: junk, attempts: 2 })) - .expect(offeredBeforeExpiry and wellFormed) - /// W8. The accepted disclosure: a third party that knows a txid is told it /// is queued, and is not given the bytes. run thirdPartyLearnsItIsQueuedTest = @@ -291,62 +251,6 @@ module baselineScenarios { Got({ query: "early", obs: NotFound, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) - - // ------------------------------------------------------------------------ - // K5. Acknowledged, then lost - // ------------------------------------------------------------------------ - - /// K5a. The hub crashes after the ack. The queue was in memory. - run ackedThenCrashedTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and ackedIsHeldOrSettled) - .then(hubCrashWith("h1")) - .expect(s.hubs.get("h1") == downHub(HUB_PARAMS)) - .expect(audit.offers == Set() and s.onChain("early") == Absent) - .expect(not(ackedIsHeldOrSettled) and ackImpliesQueued) - - /// K5b. The final flush of a draining hub finds the indexer unreachable. - /// The entry was offered and nothing judged it; the hub stops, and the - /// transaction is held nowhere. - run ackedThenLostAtDrainTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(hubBeginDrainWith("h1")) - .then(flush("h1", [early], Retryable)) - .expect(s.hubs.get("h1").phase == Stopped) - .expect(s.queuedAt("h1") == Set() and s.inFlightAt("h1") == Set() and s.onChain("early") == Absent) - .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted))) - .expect(audit.offers == Set({ hub: "h1", payload: early, height: 2, attempt: 0, nth: 0 })) - .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 2, nth: 0, final: false })) - .expect(not(ackedIsHeldOrSettled)) - - // ------------------------------------------------------------------------ - // K6, control - // ------------------------------------------------------------------------ - - /// The wallet inputs of K6 under a timely tip. The second requeue is judged - /// at tip 9, finds that an expiry of 11 does not survive the flush at 12, - /// and drops the entry: it is never offered past its expiry. - run requeueUnderTimelyTipDropsTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(block) - .then(sendToAll(late)) - .then(block) - .then(deliverSubmit("h1", 0, late)) - .then(block) - .then(flush("h1", [late], Retryable)) - .expect(queue == Map(late -> 1)) - .then(blocks(3)) - .then(flush("h1", [late], Retryable)) - .expect(queue == Map()) - .expect(audit.dropped == Set({ hub: "h1", payload: late, attempts: 1 })) - .expect(audit.offers.map(offer => offer.height) == Set(6, 9)) - .expect(conformingEveryOfferBeforeExpiry) } module awaitAckScenarios { @@ -383,18 +287,6 @@ module awaitAckScenarios { .expect(lastEvent == Sent({ input: Clean(junk), obs: SendUnavailable })) .expect(toldImpliesQueued) - /// K5a under `AwaitVerdict`: told ok on the hub's word, admitted, and lost - /// to a crash, with every component honest. - run toldOkAdmittedThenLostTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverToShim(ackMail("h1", 0, WAccepted))) - .then(hubCrashWith("h1")) - .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) - .expect(s.queuedAt("h1") == Set() and audit.offers == Set() and s.onChain("early") == Absent) - .expect(toldImpliesQueued and not(ackedIsHeldOrSettled)) } module replicatedScenarios { @@ -474,346 +366,6 @@ module replicatedScenarios { .expect(lookupValidityPerHub) } -module flakyTipScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = flakyTip).* from "../protocol" - - /// K3. A tight-expiry transaction is admitted against a tip reported one - /// block back, below a boundary the hub has already flushed. Admission - /// reasons that the next flush is at 3; it is at 6, after the expiry. - run tightExpiryAdmittedBehindFlushedBoundaryTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(hubObserveTipWith("h1", 2)) - .then(submitTo("h1", 0, tight)) - .expect(s.queuedAt("h1") == Set(tight)) - .expect(audit.admitted.get(("h1", tight)) == { height: 3, tip: 2 }) - .then(blocks(3)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 6, attempt: 0, nth: 0 })) - // Not a supported wallet: the guarantee for those is untouched. - .expect(not(conforming(tight, MIN_WALLET_EXPIRY))) - .expect(not(offeredBeforeExpiry) and conformingFirstOfferBeforeExpiry) - - /// A supported wallet's transaction, admitted the same way and then flushed - /// one block late because the tip is reported one block back. The reorg - /// slack leaves it exactly the mining margin at the offer. One block arrives - /// while the batch is in flight, which the margin is there to pay for, and - /// the node still accepts it. - run conformingSurvivesRegressionTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(hubObserveTipWith("h1", 2)) - .then(submitTo("h1", 0, early)) - .then(blocks(2)) - .then(2.reps(_ => chainAdvance)) - .then(hubObserveTipWith("h1", 6)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: early, height: 7, attempt: 0, nth: 0 })) - .expect(early.expiry == Some(7 + MINING_MARGIN)) - .expect(vConformingOfferAdmittedBehind and conformingFirstOfferBeforeExpiry and offeredBeforeExpiry) - .then(chainAdvance) - .expect(s.height() == 8 and s.flightBlocks.get("h1") == MAX_FLIGHT_BLOCKS and not(chainMayAdvance)) - .then(judge("h1", early, Accepted)) - .then(hubFlushEndWith("h1")) - .expect(s.onChain("early") == InMempool) - .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 8, nth: 0, final: true })) - .expect(vConformingFirstOfferJudged and conformingFirstOfferJudgedBeforeExpiry) -} - -module flakyTipSlowFlightScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = flakyTipSlowFlight).* from "../protocol" - - /// K7. The steps of `conformingSurvivesRegressionTest`, with the batch in - /// flight for two blocks, as many as the margin reserves. The offer left the - /// whole margin, and G6b holds. By the time the node looks, the transaction - /// can no longer be mined: the budget had already spent the slack, and the - /// flight spent the margin. - run slowFlightSpendsTheMarginTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(hubObserveTipWith("h1", 2)) - .then(submitTo("h1", 0, early)) - .then(blocks(2)) - .then(2.reps(_ => chainAdvance)) - .then(hubObserveTipWith("h1", 6)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: early, height: 7, attempt: 0, nth: 0 })) - .then(2.reps(_ => chainAdvance)) - .expect(s.height() == 9 and early.expiry == Some(9)) - // An honest node cannot take it now; it is rejected. - .expect(not(s.indexer.isAcceptable(early))) - .expect(not(honestIndexerOutputs(s.indexer, BroadcastIInput(early)).contains(VerdictOutput(Accepted)))) - .then(judge("h1", early, Rejected)) - .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 9, nth: 0, final: true })) - .expect(not(flightWithinMargin)) - .expect(conformingFirstOfferBeforeExpiry and not(conformingFirstOfferJudgedBeforeExpiry)) -} - -module flakyTipNoSlackScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = flakyTipNoSlack).* from "../protocol" - - /// This configuration's migration from a supported wallet: built at height - /// 2, expiring exactly at the floor of 6 blocks. - pure val atFloor = orchard("early", 2, 8) - - /// K3'. The steps of `conformingSurvivesRegressionTest` with the expiry - /// floor equal to the three-term budget. The flush is one block late and - /// the transaction misses the mining margin by that block. - run conformingMissesMarginWithoutSlackTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(hubObserveTipWith("h1", 2)) - .then(submitTo("h1", 0, atFloor)) - .then(blocks(2)) - .then(2.reps(_ => chainAdvance)) - .then(hubObserveTipWith("h1", 6)) - .then(hubFlushBeginWith("h1")) - .expect(conforming(atFloor, MIN_WALLET_EXPIRY)) - .expect(audit.admitted.get(("h1", atFloor)).height == 3) - .expect(audit.offers == Set({ hub: "h1", payload: atFloor, height: 7, attempt: 0, nth: 0 })) - .expect(not(conformingFirstOfferBeforeExpiry)) -} - -module staleLagScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = staleLag).* from "../protocol" - - def queue = s.hubs.get("h1").queue - - /// K4. The hub hears nothing after height 5, one block short of the flush - /// at 6, while the chain goes on. Its cadence still follows the tip it last - /// saw, so nothing is flushed until it goes stale at height 8. A supported - /// wallet's transaction, expiring at 9, is offered at 8: not yet expired, - /// with one block of margin where two are reserved. One block arrives while - /// the batch is in flight, as the margin allows for, and the node can no - /// longer take it. - run silenceAcrossBoundaryMissesMarginTest = - started - .then(block) - .then(sendToAll(early)) - .then(block) - .then(hubFlushBeginWith("h1")) - .then(deliverSubmit("h1", 0, early)) - .then(blocks(2)) - .then(3.reps(_ => chainAdvance)) - .expect(s.height() == 8 and s.hubs.get("h1").tip == Some(5) and not(s.hubs.get("h1").isFlushDue())) - .then(hubTipStaleWith("h1", 8)) - .then(hubFlushBeginWith("h1")) - .expect(conforming(early, MIN_WALLET_EXPIRY)) - .expect(audit.admitted.get(("h1", early)) == { height: 3, tip: 3 }) - .expect(audit.offers == Set({ hub: "h1", payload: early, height: 8, attempt: 0, nth: 0 })) - .expect(early.expiry == Some(9) and MINING_MARGIN == 2) - .expect(not(conformingFirstOfferBeforeExpiry) and not(offeredBeforeExpiry)) - .then(chainAdvance) - .expect(s.height() == 9 and s.flightBlocks.get("h1") == MAX_FLIGHT_BLOCKS and flightWithinMargin) - .expect(not(s.indexer.isAcceptable(early))) - .then(judge("h1", early, Rejected)) - .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 9, nth: 0, final: true })) - .expect(not(conformingFirstOfferJudgedBeforeExpiry)) - - /// K6. A stale hub's flush finds the indexer unreachable, twice. Requeue - /// judges the entry at the observed tip, which stopped at 5: the next flush - /// it knows of is the one at 6, so an expiry of 11 looks safe both times. - /// The free-running schedule offers it again at 12. - run requeuedPastExpiryTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(block) - .then(sendToAll(late)) - .then(block) - .then(deliverSubmit("h1", 0, late)) - .then(3.reps(_ => chainAdvance)) - .then(hubTipStaleWith("h1", 8)) - .then(flush("h1", [late], Retryable)) - .expect(queue == Map(late -> 1)) - .then(chainAdvance) - .then(hubTipStaleWith("h1", 9)) - .then(flush("h1", [late], Retryable)) - .expect(queue == Map(late -> 2) and s.hubs.get("h1").tip == Some(5)) - .then(chainAdvance) - .then(hubTipStaleWith("h1", 10)) - .then(chainAdvance) - .then(hubTipStaleWith("h1", 11)) - .then(chainAdvance) - .then(hubTipStaleWith("h1", 12)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set( - { hub: "h1", payload: late, height: 8, attempt: 0, nth: 0 }, - { hub: "h1", payload: late, height: 9, attempt: 1, nth: 1 }, - { hub: "h1", payload: late, height: 12, attempt: 2, nth: 2 }, - )) - .expect(late.expiry == Some(11)) - // The first offer was in time, which is all G6b covers. - .expect(not(conformingEveryOfferBeforeExpiry) and conformingFirstOfferBeforeExpiry) - - /// W18. A stale hub's free-running clock reads 6 at true height 5, and the - /// flush scheduled for 6 runs a block early. - run freeRunningClockFlushesEarlyTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(3.reps(_ => chainAdvance)) - .then(hubTipStaleWith("h1", 6)) - .then(hubFlushBeginWith("h1")) - .expect(s.height() == 5 and s.hubs.get("h1").cadence == FreeRunning(6)) - .expect(s.inFlightAt("h1") == Set(early)) - .expect(wEarlyFreeRunFlush and conformingFirstOfferBeforeExpiry) - - /// A stale hub refuses submissions until it sees the tip move again. - run staleHubRefusesTest = - started - .then(3.reps(_ => chainAdvance)) - .then(hubTipStaleWith("h1", 4)) - .then(submitTo("h1", 0, early)) - .expect(wRefusedTipStale and s.queuedAt("h1") == Set()) - .then(observe("h1")) - .expect(s.hubs.get("h1").phase == Running and s.hubs.get("h1").cadence == Tracking) - .then(deliverSubmit("h1", 0, early)) - .expect(s.queuedAt("h1") == Set(early)) -} - -module staleLagWithSlackScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = staleLagWithSlack).* from "../protocol" - - /// This configuration's migration from a supported wallet: built at height - /// 2, expiring at the floor of 8 blocks. - pure val atFloor = orchard("early", 2, 10) - - /// The steps of K4 with an expiry floor one block higher, which is the - /// relation `staleSlackFits` asks for. The same late flush leaves the - /// margin, and after a block in flight the node accepts. - run sameSilenceWithSlackKeepsMarginTest = - started - .then(block) - .then(sendToAll(atFloor)) - .then(block) - .then(hubFlushBeginWith("h1")) - .then(deliverSubmit("h1", 0, atFloor)) - .then(blocks(2)) - .then(3.reps(_ => chainAdvance)) - .then(hubTipStaleWith("h1", 8)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: atFloor, height: 8, attempt: 0, nth: 0 })) - .expect(vConformingFirstOfferBeforeExpiry and conformingFirstOfferBeforeExpiry) - .then(chainAdvance) - .expect(s.height() == 9 and s.flightBlocks.get("h1") == MAX_FLIGHT_BLOCKS) - .then(judge("h1", atFloor, Accepted)) - .expect(s.onChain("early") == InMempool) - .expect(audit.verdicts == Set({ hub: "h1", payload: atFloor, height: 9, nth: 0, final: true })) - .expect(conformingFirstOfferJudgedBeforeExpiry) - - /// This configuration's second migration: built at height 4, expiring at - /// the floor. - pure val lateAtFloor = orchard("late", 4, 12) - - /// Found by simulation; the slack does not cover it. A stale hub's - /// free-running clock reads 6 at true height 4, and the flush scheduled for - /// 6 runs then, with nothing to publish. When the hub sees the tip again the - /// chain is at 5, and admission, which knows the tip and not the schedule's - /// history, counts on the flush at 6. That flush has already happened. The - /// transaction waits for the one at 9, and a second, shorter silence makes - /// that one late too: it is offered at 11 with expiry 12, one block of - /// margin where two are reserved, and a block in flight uses that up. - /// - /// Neither half is enough alone at these numbers. Without the early flush - /// the transaction goes out at 6; without the second silence, at 9. - run earlyFlushSpendsTheNextEpochTest = - started - .then(3.reps(_ => chainAdvance)) - .then(hubTipStaleWith("h1", 6)) - .then(hubFlushBeginWith("h1")) - .expect(s.height() == 4 and s.hubs.get("h1").lastEpoch == Some(2)) - .then(sendToAll(lateAtFloor)) - .then(block) - .expect(s.hubs.get("h1").cadence == Tracking and s.hubs.get("h1").tip == Some(5)) - .then(deliverSubmit("h1", 0, lateAtFloor)) - .expect(audit.admitted.get(("h1", lateAtFloor)) == { height: 5, tip: 5 }) - .then(blocks(3)) - // Height 8. The boundary at 6 has passed and nothing was flushed. - .expect(audit.offers == Set() and s.queuedAt("h1") == Set(lateAtFloor)) - .then(3.reps(_ => chainAdvance)) - .then(hubTipStaleWith("h1", 11)) - .then(hubFlushBeginWith("h1")) - .expect(conforming(lateAtFloor, MIN_WALLET_EXPIRY) and staleSlackFits) - .expect(audit.offers == Set({ hub: "h1", payload: lateAtFloor, height: 11, attempt: 0, nth: 0 })) - .expect(not(conformingFirstOfferBeforeExpiry)) - .then(chainAdvance) - .expect(s.height() == 12 and not(s.indexer.isAcceptable(lateAtFloor))) - .then(judge("h1", lateAtFloor, Rejected)) - .expect(not(conformingFirstOfferJudgedBeforeExpiry)) -} - -module byzIndexerScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = byzIndexer).* from "../protocol" - - /// W15. Premature flush. The indexer reports tip 3 at true height 2; the - /// hub believes the boundary has come and publishes what it holds. One - /// lying endpoint is enough for this: the tip is the maximum over endpoints. - run tipAheadOfChainFlushesEarlyTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(hubObserveTipWith("h1", 3)) - .then(hubFlushBeginWith("h1")) - .expect(s.height() == 2 and s.hubs.get("h1").tip == Some(3)) - .expect(s.inFlightAt("h1") == Set(early)) - .expect(wPrematureFlush) - // The transaction is published early, which costs it nothing. - .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry) -} - module byzHubScenarios { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index bc19a5ad..16210743 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -234,101 +234,6 @@ module byzHubTrust { .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and s.queuedAt("h1") == Set(early)) .expect(ackImpliesQueued) - /// A3 needs the hub. Once its drain has begun, it takes a submission into - /// the queue. The simulator does not check `temporal` definitions, so the - /// run asserts the step itself: draining before and after, nothing in - /// flight, and the queue grown by a payload no flush handed back. - run hubAdmitsWhileDrainingTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(hubBeginDrainWith("h1")) - .then(sendToAll(tight)) - .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early) and s.inFlightAt("h1") == Set()) - .then(hubReceiveWith( - "h1", submitMail("h1", 1, tight), INotFound, - { ...h1, queue: h1.queue.put(tight, 0) }.toAckOutput(1, Admitted), - )) - .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early, tight)) - .expect(not(wRefusedDraining)) - - run hubAdmitsWhileDrainingControlTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(hubBeginDrainWith("h1")) - .then(sendToAll(tight)) - .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early) and s.inFlightAt("h1") == Set()) - .then(deliverSubmit("h1", 1, tight)) - .expect(h1.phase == Draining and s.queuedAt("h1") == Set(early)) - .expect(wRefusedDraining) - - /// G6a needs the hub. It admits a transaction the expiry rule refuses: one - /// expiring at 5, taken at tip 3, when the next flush is at 6. - run hubAdmitsPastExpiryRuleTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(sendToAll(tight)) - .then(hubReceiveWith( - "h1", submitMail("h1", 0, tight), INotFound, - { ...h1, queue: Map(tight -> 0) }.toAckOutput(0, Admitted), - )) - .then(blocks(3)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 6, attempt: 0, nth: 0 })) - .expect(not(offeredBeforeExpiry)) - - run hubAdmitsPastExpiryRuleControlTest = - started - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(sendToAll(tight)) - .then(deliverSubmit("h1", 0, tight)) - .expect(s.acks("h1", ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) - .then(blocks(3)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set()) - .expect(offeredBeforeExpiry) - - /// G6b and G6c need the hub. A supported wallet's transaction is refused by an - /// honest hub only for reasons that are not about the transaction. This hub - /// takes one while it has not yet seen a tip, when an honest hub refuses - /// everything. It first sees the chain at height 7, adopts that epoch - /// without flushing, and offers the transaction at 9, its expiry, when no - /// node can take it. - run hubAdmitsBeforeFirstTipTest = - init - .then(2.reps(_ => chainAdvance)) - .then(sendToAll(early)) - .then(hubReceiveWith( - "h1", submitMail("h1", 0, early), INotFound, - { ...h1, queue: Map(early -> 0) }.toAckOutput(0, Admitted), - )) - .expect(h1.phase == Starting and audit.admitted.get(("h1", early)).height == 3) - .then(4.reps(_ => chainAdvance)) - .then(observe("h1")) - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .expect(conforming(early, MIN_WALLET_EXPIRY)) - .expect(audit.offers == Set({ hub: "h1", payload: early, height: 9, attempt: 0, nth: 0 })) - .expect(not(conformingFirstOfferBeforeExpiry)) - .expect(not(s.indexer.isAcceptable(early))) - .then(judge("h1", early, Rejected)) - .expect(not(conformingFirstOfferJudgedBeforeExpiry)) - - run hubAdmitsBeforeFirstTipControlTest = - init - .then(2.reps(_ => chainAdvance)) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .expect(s.acks("h1", ShimAddr) == Set((0, WRefused(WTipStale)))) - .then(4.reps(_ => chainAdvance)) - .then(observe("h1")) - .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set()) - .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) } module awaitAckByzHubTrust { @@ -439,80 +344,6 @@ module byzIndexerTrust { .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) .expect(lookupValidityPerHub) - /// G6a needs the indexer. The hub asks for the tip at every block. From - /// height 3 the indexer goes on answering 2, and at height 5 it answers - /// truthfully. The flush scheduled for 3 runs at 5, where a transaction - /// expiring at 5 has no margin left. - run indexerWithholdsTipTest = - started - .then(block) - .then(submitTo("h1", 0, tight)) - .then(chainAdvance) - .then(hubObserveTipWith("h1", 2)) - .then(chainAdvance) - .then(hubObserveTipWith("h1", 2)) - .then(chainAdvance) - .expect(s.height() == 5 and s.hubs.get("h1").tip == Some(2) and not(chainMayAdvance)) - .then(hubObserveTipWith("h1", 5)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 5, attempt: 0, nth: 0 })) - .expect(not(offeredBeforeExpiry)) - - /// The same polls, answered truthfully. - run indexerWithholdsTipControlTest = - started - .then(block) - .then(submitTo("h1", 0, tight)) - .then(chainAdvance) - .then(hubObserveTipWith("h1", 3)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: tight, height: 3, attempt: 0, nth: 0 })) - .then(judge("h1", tight, Accepted)) - .then(hubFlushEndWith("h1")) - .then(chainAdvance) - .then(hubObserveTipWith("h1", 4)) - .then(chainAdvance) - .then(hubObserveTipWith("h1", 5)) - .expect(s.height() == 5 and audit.offers.size() == 1) - .expect(offeredBeforeExpiry) - - /// G6b and G6c need the indexer. The same lie, kept up to height 8, does it - /// to a supported wallet's transaction: it is offered at 8 with expiry 9, - /// and after one block in flight the node cannot take it. - run indexerWithholdsTipFromConformingTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(5.reps(_ => chainAdvance.then(hubObserveTipWith("h1", 2)))) - .then(chainAdvance) - .expect(s.height() == 8 and s.hubs.get("h1").tip == Some(2)) - .then(hubObserveTipWith("h1", 8)) - .then(hubFlushBeginWith("h1")) - .expect(conforming(early, MIN_WALLET_EXPIRY) and audit.admitted.get(("h1", early)).height == 2) - .expect(audit.offers == Set({ hub: "h1", payload: early, height: 8, attempt: 0, nth: 0 })) - .expect(not(conformingFirstOfferBeforeExpiry)) - .then(chainAdvance) - .expect(not(s.indexer.isAcceptable(early))) - .then(judge("h1", early, Rejected)) - .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 9, nth: 0, final: true })) - .expect(not(conformingFirstOfferJudgedBeforeExpiry)) - - /// The same polls, answered truthfully: offered at 3, and accepted after a - /// block in flight. - run indexerWithholdsTipFromConformingControlTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(chainAdvance) - .then(hubObserveTipWith("h1", 3)) - .then(hubFlushBeginWith("h1")) - .expect(audit.offers == Set({ hub: "h1", payload: early, height: 3, attempt: 0, nth: 0 })) - .then(chainAdvance) - .then(judge("h1", early, Accepted)) - .then(hubFlushEndWith("h1")) - .expect(s.onChain("early") == InMempool) - .expect(audit.verdicts == Set({ hub: "h1", payload: early, height: 4, nth: 0, final: true })) - .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) } module replicatedOneByzTrust { From 6678b59b26c07f49cc425c940d48461af0c2bad7 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 03:21:32 +0400 Subject: [PATCH 42/80] test(zeronym): remove the HTTP transport from the protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 58 +++----- zeronym/spec/protocol/check.sh | 18 +-- zeronym/spec/protocol/instances.qnt | 63 --------- zeronym/spec/protocol/properties.qnt | 19 +-- zeronym/spec/protocol/protocol.qnt | 31 +---- zeronym/spec/protocol/shim.qnt | 130 +++++------------- zeronym/spec/protocol/state.qnt | 20 +-- zeronym/spec/protocol/tests/scenariosTest.qnt | 45 +----- zeronym/spec/protocol/tests/shimTest.qnt | 62 +++------ zeronym/spec/protocol/tests/trustTest.qnt | 71 +--------- zeronym/spec/protocol/types.qnt | 8 -- 11 files changed, 94 insertions(+), 431 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index e610bb0b..60bd235b 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -146,7 +146,8 @@ definitions they justify. | Multiple indexer endpoints and their folds | One abstract indexer per model stands for all of a hub's endpoints. Because the folds are asymmetric (S28), this document states for each Byzantine-indexer behaviour whether one lying endpoint suffices or all must lie | | Wire codecs `ZNS1` / `ZNA1` / `ZNL1` / `ZNR1` and the golden vectors (`zeronym/hub/src/wire.rs:576-579`) | Byte layouts are scoped out and are pinned by the Rust tests in both crates; the abstract `render` / `interpretReply` layer is the level this spec works at. The spec does not claim to bind the codec | | HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | -| Two or more hubs under `AwaitVerdict` | Does not exist in code: HTTP is one address (S31) and Nym never awaits the ack (S7). `awaitVerdictSingleHub` stays an assumption | +| The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | +| The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | | Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties. Only the pure lemma "frame size is independent of content" is stated | | Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | @@ -205,16 +206,11 @@ stateDiagram-v2 Inspect --> FailClosed: Unreadable or EmptyBody Inspect --> Framing: Clean and class OrchardTouching or Unparseable Framing --> FailClosed: oversize - Framing --> Dispatched: DispatchOnly, frames to a non-empty prefix of the hubs, fresh nonce each - Framing --> FailClosed: DispatchOnly, no frame handed over - Framing --> AwaitingAck: AwaitVerdict + Framing --> Dispatched: frames to a non-empty prefix of the hubs, fresh nonce each + Framing --> FailClosed: no frame handed over Dispatched --> ToldOk - AwaitingAck --> ToldOk: Ack accepted - AwaitingAck --> ToldRejected: Ack refused - AwaitingAck --> FailClosed: timeout Forwarded --> [*] ToldOk --> [*] - ToldRejected --> [*] FailClosed --> [*] ``` @@ -403,7 +399,7 @@ goes. | Component | Inputs | Outputs | Seam in the implementation | |---|---|---|---| | `hub` | `SubmitHInput`, `LookupHInput` (with the indexer's answer), `TipHInput(height)`, `StaleHInput(estimate)`, `FlushDueHInput`, `VerdictHInput`, `FlushDoneHInput`, `DrainHInput`, `CrashHInput`, `RestartHInput` | `AckOutput`, `LookupReplyOutput`, `BroadcastOutput`, `RequeuedOutput`, `NoHubOutput`, `HubErrorOutput` | `Hub::admit`, `Hub::lookup` (`hub/src/server.rs`), `run_listener` (`hub/src/nym.rs`), `TipTracker::observe`, `cadence_height`, `flush` (`hub/src/batcher.rs`), `Queue::requeue`, `Queue::begin_draining` (`hub/src/queue.rs`) | -| `shim` | `SendTxSInput` (with how many hub addresses take a frame), `GetTxSInput` (with where the cursor points), `FrameSInput`, `LookupTimeoutSInput`, `AckTimeoutSInput` | `ForwardOutput`, `DivertedOutput`, `SendDoneOutput`, `LookupSentOutput`, `LookupDoneOutput`, `NoShimOutput`, `ShimErrorOutput` | `send_transaction`, `divert`, `get_transaction` (`shim/src/intercept.rs`), `NymHandle::submit`, `get_transaction`, `deliver` (`shim/src/nym.rs`) | +| `shim` | `SendTxSInput` (with how many hub addresses take a frame), `GetTxSInput` (with where the cursor points), `FrameSInput`, `LookupTimeoutSInput` | `ForwardOutput`, `DivertedOutput`, `SendDoneOutput`, `LookupSentOutput`, `LookupDoneOutput`, `NoShimOutput`, `ShimErrorOutput` | `send_transaction`, `divert`, `get_transaction` (`shim/src/intercept.rs`), `NymHandle::submit`, `get_transaction`, `deliver` (`shim/src/nym.rs`) | | indexer | `BroadcastIInput`, `LookupIInput`, `AdvanceIInput`, `MineIInput` | `VerdictOutput`, `AnswerOutput`, `NoIndexerOutput` | the mock indexer in `hub/tests/common/mod.rs` | ### Roles @@ -440,18 +436,14 @@ abstract indexer per hub was a decision of the design. One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields (`PAYLOADS`, `FLUSH_INTERVAL`, `ROLES`, `TIP`, ...). -| Module | Hubs | Submit mode | Roles (shim / hubs / indexer) | Tip | -|---|---|---|---|---| -| `baseline` | 1 | `DispatchOnly` | H / H / H | timely | -| `byzShim` | 1 | `DispatchOnly` | **B** / H / H | timely | -| `byzHub` | 1 | `DispatchOnly` | H / **B** / H | timely | -| `byzIndexer` | 1 | `DispatchOnly` | H / H / **B** | timely for honest reports | -| `awaitAck` | 1 | `AwaitVerdict` | H / H / H | timely | -| `awaitAckByzShim` | 1 | `AwaitVerdict` | **B** / H / H | timely | -| `awaitAckByzHub` | 1 | `AwaitVerdict` | H / **B** / H | timely | -| `awaitAckByzIndexer` | 1 | `AwaitVerdict` | H / H / **B** | timely | -| `replicated` | 2 | `DispatchOnly` | H / H, H / H | timely | -| `replicatedOneByz` | 2 | `DispatchOnly` | H / H, **B** / H | timely | +| Module | Hubs | Roles (shim / hubs / indexer) | Tip | +|---|---|---|---| +| `baseline` | 1 | H / H / H | timely | +| `byzShim` | 1 | **B** / H / H | timely | +| `byzHub` | 1 | H / **B** / H | timely | +| `byzIndexer` | 1 | H / H / **B** | timely for honest reports | +| `replicated` | 2 | H / H, H / H | timely | +| `replicatedOneByz` | 2 | H / H, **B** / H | timely | The schedule is the shipped one scaled down, keeping the relations between the numbers: @@ -517,7 +509,6 @@ and `flakyTipSlowFlight` each drop one, and their tests assert it is false. | G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it, the operator's view and explicit disclosures; nothing updates it at publication | | G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | | G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree, or that hubs agree | -| G5 | `toldImpliesQueued` | A wallet told ok can rely on some hub having queued the transaction. Claimed under `AwaitVerdict` only | | G6a | `offeredBeforeExpiry` | Every transaction a hub offers is offered with the mining margin to spare: whatever was admitted, on every attempt. About the margin left when the flush begins, not about acceptance. Claimed under a timely tip | | G6b | `conformingFirstOfferBeforeExpiry` | The same for supported wallets and for the first time a hub offers the transaction. Nothing about a later offer of a requeued entry. Also about the margin at the offer | | G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and `flightWithinMargin` | @@ -538,7 +529,7 @@ tried by hand, with the result shown, and reverted: | G2 | `hub` answers a queue hit with the queued body | violated on `baseline` | | G3 | `interpretReply` skips the txid comparison | **holds on `baseline`**; violated on `byzHub` and `byzIndexer` | | G4 | `hub` answers not-found on a queue hit | violated on `baseline` and `replicated` | -| G5, G8 | `hub` acks accepted without inserting | G5 violated on `awaitAck`, G8 on `baseline` | +| G8 | `hub` acks accepted without inserting | violated on `baseline` | | G6a | `hub` admits without the expiry check | violated on `baseline` | The G3 row is not what was predicted; see [Findings](#findings). @@ -570,8 +561,7 @@ chain cannot pass a running, idle hub that has not asked. | G2 | holds (`baseline`) | **required**: `shimDisclosesPlaintextTest` | **required**: `hubServesQueuedBodyTest` | **required**: `indexerServesUnpublishedBodyTest`. One endpoint suffices | | G3 | holds (`baseline`) | **required**: `shimServesAnotherTransactionTest` | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | | G4 | holds (`baseline`, `replicated`) | **required**: `shimInventsStatusTest` | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | -| G5 | holds (`awaitAck`) | **required**: `toldOkWithoutSendingTest` | **required**: `toldOkOnAFalseAckTest` | holds (`awaitAckByzIndexer`) | -| G8 | holds (`baseline`, `awaitAck`) | holds (`byzShim`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | +| G8 | holds (`baseline`) | holds (`byzShim`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | | G6a | holds (`baseline`) | holds (`byzShim`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | | G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | @@ -585,13 +575,12 @@ One Byzantine replica out of two (`replicatedOneByz`): | G4 | required of every hub | `cursorLandsOnLyingReplicaTest` | | G3 | holds | simulation; `wrongTransactionIsRefusedTest` | | G8, G6a, G6b, G6c for the honest hub | hold | simulation of `ackImpliesQueuedForHonestHubs`, `offeredBeforeExpiryForHonestHubs`, `conformingFirstOfferBeforeExpiryForHonestHubs`, `conformingFirstOfferJudgedBeforeExpiryForHonestHubs`; `honestReplicaKeepsItsGuaranteesTest` | -| G5 | not applicable | `AwaitVerdict` has one hub | | "some honest hub queued it" after told ok | not a guarantee | `honestReplicaKeepsItsGuaranteesTest` | The `...ForHonestHubs` names are the same predicates restricted to the hubs whose role is honest. They are not weaker properties. -In short: a Byzantine shim voids every wallet-facing guarantee (G1-G5); the +In short: a Byzantine shim voids every wallet-facing guarantee (G1-G4); the hub-side G6 and G8 survive it. G3 is the only wallet-facing guarantee that survives a Byzantine hub or indexer, and it authenticates the txid only. G1 depends on the shim alone. Replication does not dilute trust: one Byzantine @@ -602,12 +591,12 @@ cell is a scripted step, and its "holds" cells are the unrun TLC property. | Id | What is lost | Where | Form | Observed | Scripted runs | |---|---|---|---|---|---| -| K1 | Under `DispatchOnly`, told ok does not mean any hub ever admits it | `baseline`, `replicated` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered`, `wToldPrefixOnly` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest`, `toldOkAfterPrefixSendTest` | +| K1 | Told ok does not mean any hub ever admits it | `baseline`, `replicated` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered`, `wToldPrefixOnly` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest`, `toldOkAfterPrefixSendTest` | | K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline`, `replicated` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest`, `hubsDisagreeTest` | | K3 | G6a for a tight-expiry transaction: admitted against a tip reported below a boundary already flushed | `flakyTip` | violated invariant | violated, as predicted | `tightExpiryAdmittedBehindFlushedBoundaryTest` | | K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | | K4 | G6a, and G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | -| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `baseline`, `awaitAck` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `toldOkAdmittedThenLostTest`, `ackedThenLostAtDrainTest`, `requeueAndDropTest` | +| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `baseline` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `toldOkAdmittedThenLostTest`, `ackedThenLostAtDrainTest`, `requeueAndDropTest` | | K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | | K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest`; contrast `conformingSurvivesRegressionTest` | @@ -620,8 +609,8 @@ that pays for blocks arriving while the batch is in flight, and nothing in the code bounds a flight in blocks. K1 is not stated as a violated invariant because the invariant is false on the -ordinary success path too: under `DispatchOnly` the wallet is told ok before -any hub has the frame. In `toldOkAndNeverDeliveredTest` the run ends with the +ordinary success path too: the wallet is told ok before any hub has the +frame. In `toldOkAndNeverDeliveredTest` the run ends with the frame undelivered, and nothing obliges the network ever to deliver it. ### Witnesses @@ -670,8 +659,8 @@ refuses the same frame. That run asserts the step, because the simulator does not check `temporal` definitions. A2 is stated over every hub: the Byzantine hub relation only ever adds to a queue. -No liveness property is claimed: the network may lose everything, and under -`DispatchOnly` nobody waits for an ack. +No liveness property is claimed: the network may lose everything, and nobody +waits for an ack. ## Findings @@ -969,9 +958,6 @@ quint verify --main=byzHub --invariant=txidAuthenticity --max-steps=12 zeronym/s quint verify --main=byzIndexer --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzIndexer --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzIndexer --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=awaitAck --invariant=toldImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=awaitAck --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=awaitAckByzIndexer --invariant=toldImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=flakyTip --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt ``` diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 83bbe8ca..13444dc4 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -74,9 +74,9 @@ finish() { SPELLS="spells/basicSpells.qnt spells/soup.qnt" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt" -INSTANCES="baseline byzShim byzHub byzIndexer awaitAck awaitAckByzShim awaitAckByzHub awaitAckByzIndexer replicated replicatedOneByz" -SCENARIOS="baselineScenarios awaitAckScenarios replicatedScenarios byzHubScenarios" -TRUST="byzShimTrust awaitAckByzShimTrust byzHubTrust awaitAckByzHubTrust byzIndexerTrust replicatedOneByzTrust" +INSTANCES="baseline byzShim byzHub byzIndexer replicated replicatedOneByz" +SCENARIOS="baselineScenarios replicatedScenarios byzHubScenarios" +TRUST="byzShimTrust byzHubTrust byzIndexerTrust replicatedOneByzTrust" fail() { echo "FAIL $1" @@ -283,10 +283,6 @@ job holds baseline operatorBlind queuedBytesConfidential txidAuthenti job holds byzShim ackImpliesQueued wellFormed job holds byzHub operatorBlind txidAuthenticity wellFormed job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed -job holds awaitAck toldImpliesQueued ackImpliesQueued wellFormed -job holds awaitAckByzShim wellFormed -job holds awaitAckByzHub wellFormed -job holds awaitAckByzIndexer toldImpliesQueued wellFormed job holds replicated lookupValidityPerHub wellFormed job holds replicatedOneByz txidAuthenticity ackImpliesQueuedForHonestHubs wellFormed @@ -296,11 +292,9 @@ job fails byzShim step 40 operatorBlind job fails byzShim step 40 queuedBytesConfidential job fails byzShim step 40 txidAuthenticity job fails byzShim step 40 lookupValidityPerHub -job fails awaitAckByzShim step 40 toldImpliesQueued job fails byzHub step 40 queuedBytesConfidential job fails byzHub step 40 lookupValidityPerHub job fails byzHub step 40 ackImpliesQueued -job fails awaitAckByzHub step 40 toldImpliesQueued job fails byzIndexer step 40 queuedBytesConfidential job fails byzIndexer step 40 lookupValidityPerHub job fails replicatedOneByz step 40 queuedBytesConfidential @@ -343,12 +337,6 @@ job reaches byzHub quietStep 40 \ job reaches byzIndexer quietStep 40 \ vOperatorBlind vTxidAuthenticity vAckImpliesQueued \ -- operatorBlind txidAuthenticity ackImpliesQueued wellFormed -job reaches awaitAck quietStep 40 \ - vToldImpliesQueued vAckImpliesQueued \ - -- toldImpliesQueued ackImpliesQueued wellFormed -job reaches awaitAckByzIndexer quietStep 40 \ - vToldImpliesQueued \ - -- toldImpliesQueued wellFormed # W13, K1c. job reaches replicated step 40 \ wFailoverAnswered wToldPrefixOnly \ diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index 4ef7bee3..0bb82cf8 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -78,7 +78,6 @@ module configs { maxFlightBlocks: 1, maxHeight: 12, maxRequests: 3, - submitMode: DispatchOnly, roles: allHonest, tip: TipTimely, reliesOnReorgSlack: true, @@ -90,12 +89,6 @@ module configs { pure val byzHub = { ...baseline, roles: { ...allHonest, hubs: Map("h1" -> Byzantine) } } pure val byzIndexer = { ...baseline, roles: { ...allHonest, indexer: Byzantine } } - // The HTTP transport: the wallet's answer is the hub's decision. - pure val awaitAck = { ...baseline, submitMode: AwaitVerdict } - pure val awaitAckByzShim = { ...byzShim, submitMode: AwaitVerdict } - pure val awaitAckByzHub = { ...byzHub, submitMode: AwaitVerdict } - pure val awaitAckByzIndexer = { ...byzIndexer, submitMode: AwaitVerdict } - // Two hubs sharing one chain. pure val replicated = { ...baseline, @@ -164,62 +157,6 @@ module byzIndexer { } } -module awaitAck { - import types.* from "./types" - import configs.* - import protocol(CONFIG = awaitAck).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - -module awaitAckByzShim { - import types.* from "./types" - import configs.* - import protocol(CONFIG = awaitAckByzShim).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - -module awaitAckByzHub { - import types.* from "./types" - import configs.* - import protocol(CONFIG = awaitAckByzHub).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - -module awaitAckByzIndexer { - import types.* from "./types" - import configs.* - import protocol(CONFIG = awaitAckByzIndexer).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - module replicated { import types.* from "./types" import configs.* diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index 7a1b7cbb..09fa4142 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -213,13 +213,6 @@ module properties { | _ => true }) - /// G5. A wallet told its transaction was diverted can rely on some hub - /// having queued it. Claimed when the wallet's answer is the hub's - /// (`AwaitVerdict`); see `wToldNeverDelivered` for when it is not. - pure def toldImpliesQueuedIn(s: System, audit: Audit): bool = - s.toldOk().forall(payload => - s.hubIds().exists(hub => audit.everQueued.get(hub).contains(payload))) - /// Whether an offer left the mining margin: the transaction can still be /// mined `miningMargin` blocks after the true height it was published at. pure def offeredInTime(s: System, offer: Offer): bool = @@ -369,7 +362,7 @@ module properties { pure def conformingEveryOfferBeforeExpiryIn(s: System, audit: Audit): bool = audit.offers.forall(offer => isConformingAndTimely(s, audit, offer.hub, offer.payload) implies offeredInTime(s, offer)) - // K1. Under `DispatchOnly`, "told ok" promises nothing about any hub. It is + // K1. "Told ok" promises nothing about any hub. It is // stated as three reachable states, not as a violated invariant, because the // invariant is false on the ordinary success path too: the wallet is told // before any hub has the frame. @@ -506,11 +499,8 @@ module properties { /// hub has answered it. pure def wFailoverAnsweredIn(s: System): bool = s.shim.waiters.keys().exists(nonce => - match s.shim.waiters.get(nonce) { - | LookupWaiter(waiter) => - waiter.attempt > 0 and s.replies(ShimAddr).exists(reply => reply._1 == nonce and reply._3 != WError) - | _ => false - }) + s.shim.waiters.get(nonce).attempt > 0 and + s.replies(ShimAddr).exists(reply => reply._1 == nonce and reply._3 != WError)) /// W14. Two hubs have each published the same payload, in their own flushes, /// and it is on the chain: replication, not failover. @@ -589,9 +579,6 @@ module properties { s.wasGiven(obs => obs == NotFound), } - pure def vToldImpliesQueuedIn(s: System): bool = - s.toldOk() != Set() - pure def vOfferedBeforeExpiryIn(audit: Audit): bool = audit.offers.exists(offer => isSome(offer.payload.expiry)) diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 241f08ca..f8d4a320 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -92,7 +92,6 @@ module protocol { pure val MAX_HEIGHT = CONFIG.maxHeight pure val MAX_REQUESTS = CONFIG.maxRequests - pure val SUBMIT_MODE = CONFIG.submitMode pure val ROLES = CONFIG.roles pure val TIP = CONFIG.tip @@ -188,10 +187,6 @@ module protocol { ROLES.hubs.keys() == HUBS, } - /// Only the HTTP transport returns the hub's decision to the wallet, and it - /// has one hub address. - pure val awaitVerdictSingleHub = SUBMIT_MODE == AwaitVerdict implies HUBS.size() == 1 - /// The assumptions every configuration meets. `reorgSlackFits` and /// `flightWithinMargin` are kept apart: a configuration says whether it /// relies on each, and some exist to show what happens without one. @@ -201,7 +196,6 @@ module protocol { flushIntervalPositive, hubsNonEmpty, payloadsWellFormed, - awaitVerdictSingleHub, } assume _ = budgetFits @@ -211,7 +205,6 @@ module protocol { assume _ = flushIntervalPositive assume _ = hubsNonEmpty assume _ = payloadsWellFormed - assume _ = awaitVerdictSingleHub // ------------------------------------------------------------------------ // State @@ -456,23 +449,6 @@ module protocol { }, } - /// The shim gives up waiting for an ack. - action shimAckTimeoutWith(nonce: Nonce, result: ShimResult): bool = all { - s.shim.hasWaiter(nonce), - shimResults(s.shim, AckTimeoutSInput(nonce)).contains(result), - not(isShimError(result.out)), - commit(s.shimStepped(result, None), ShimAckTimeout(nonce)), - } - - action shimAckTimeout = all { - s.shim.waiters.keys() != Set(), - { - nondet nonce = oneOf(s.shim.waiters.keys()) - nondet result = oneOf(shimResults(s.shim, AckTimeoutSInput(nonce))) - shimAckTimeoutWith(nonce, result) - }, - } - // ------------------------------------------------------------------------ // Hub // ------------------------------------------------------------------------ @@ -775,7 +751,7 @@ module protocol { /// A fault: a timeout, a shutdown, a crash, a restart. action faultStep = any { - shimLookupTimeout, shimAckTimeout, hubBeginDrain, hubCrash, hubRestart, + shimLookupTimeout, hubBeginDrain, hubCrash, hubRestart, } /// A step by someone outside the protocol: the third party, or a Byzantine @@ -848,7 +824,6 @@ module protocol { val queuedBytesConfidential = queuedBytesConfidentialIn(s) val txidAuthenticity = txidAuthenticityIn(s) val lookupValidityPerHub = lookupValidityPerHubIn(s, audit) - val toldImpliesQueued = toldImpliesQueuedIn(s, audit) val offeredBeforeExpiry = offeredBeforeExpiryIn(s, audit, HUBS) val conformingFirstOfferBeforeExpiry = conformingFirstOfferBeforeExpiryIn(s, audit, HUBS) val conformingFirstOfferJudgedBeforeExpiry = conformingFirstOfferJudgedBeforeExpiryIn(s, audit, HUBS) @@ -908,7 +883,6 @@ module protocol { val vQueuedBytesConfidential = vQueuedBytesConfidentialIn(s) val vTxidAuthenticity = vTxidAuthenticityIn(s) val vLookupValidityPerHub = vLookupValidityPerHubIn(s) - val vToldImpliesQueued = vToldImpliesQueuedIn(s) val vOfferedBeforeExpiry = vOfferedBeforeExpiryIn(audit) val vConformingFirstOfferBeforeExpiry = vConformingFirstOfferBeforeExpiryIn(s, audit) val vConformingOfferAdmittedBehind = vConformingOfferAdmittedBehindIn(s, audit) @@ -1053,9 +1027,6 @@ module protocol { action timeOutLookup(nonce: Nonce): bool = shimLookupTimeoutWith(nonce, shim(s.shim, LookupTimeoutSInput(nonce))) - action timeOutAck(nonce: Nonce): bool = - shimAckTimeoutWith(nonce, shim(s.shim, AckTimeoutSInput(nonce))) - /// An honest indexer gives `verdict` on `payload`, with the effect that /// verdict has. action judge(id: HubId, payload: Payload, verdict: Verdict): bool = diff --git a/zeronym/spec/protocol/shim.qnt b/zeronym/spec/protocol/shim.qnt index ea1c5ebb..bcfb99d0 100644 --- a/zeronym/spec/protocol/shim.qnt +++ b/zeronym/spec/protocol/shim.qnt @@ -21,30 +21,24 @@ module shim { /// A frame on its way to a hub. type Frame = { hub: HubId, msg: Msg } - /// What the shim remembers about a request it has sent. - /// - /// - An ack waiter exists for every submission. Only under `AwaitVerdict` is - /// anyone waiting on it (`awaited`); under `DispatchOnly` the wallet has - /// its answer already and the ack, if it comes, is discarded. - /// - A lookup waiter remembers where the sweep over the hubs started and how - /// many hubs it has tried. - type Waiter = - | AckWaiter({ payload: Payload, awaited: bool }) - | LookupWaiter({ query: TxId, start: int, attempt: int }) + /// What the shim remembers about a lookup it has sent: where the sweep over + /// the hubs started and how many hubs it has tried. A submission leaves + /// nothing behind: the wallet has its answer when the frames are handed + /// over, and an ack, if one comes, is discarded. + type Waiter = { query: TxId, start: int, attempt: int } /// - `hubs`: the hub addresses, in configured order. - /// - `waiters`: the outstanding requests, by nonce. + /// - `waiters`: the outstanding lookups, by nonce. /// - `nextNonce`: the source of fresh nonces. A counter stands for a random /// value nobody else can guess. type ShimState = { hubs: List[HubId], - mode: SubmitMode, waiters: Nonce -> Waiter, nextNonce: Nonce, } - pure def initialShim(hubs: List[HubId], mode: SubmitMode): ShimState = - { hubs: hubs, mode: mode, waiters: Map(), nextNonce: 0 } + pure def initialShim(hubs: List[HubId]): ShimState = + { hubs: hubs, waiters: Map(), nextNonce: 0 } // ------------------------------------------------------------------------ // Inputs and outputs @@ -59,12 +53,11 @@ module shim { | GetTxSInput({ query: TxId, start: int }) | FrameSInput(Msg) // a frame arrives from the network | LookupTimeoutSInput(Nonce) // no reply to a lookup in time - | AckTimeoutSInput(Nonce) // no ack in time type ShimOutput = | ForwardOutput(Payload) // to the operator's indexer - // `payload` is what the wallet sent; `told` is its answer, if it has one yet. - | DivertedOutput({ payload: Payload, frames: Set[Frame], told: Option[SendObs] }) + // `payload` is what the wallet sent; `told` is its answer. + | DivertedOutput({ payload: Payload, frames: Set[Frame], told: SendObs }) | SendDoneOutput({ input: SendInput, obs: SendObs }) | LookupSentOutput({ frame: Frame }) | LookupDoneOutput({ query: TxId, result: LookupObs }) @@ -76,7 +69,7 @@ module shim { pure def toForwardOutput(state: ShimState, payload: Payload): ShimResult = { state: state, out: ForwardOutput(payload) } - pure def toDivertedOutput(state: ShimState, payload: Payload, frames: Set[Frame], told: Option[SendObs]): ShimResult = + pure def toDivertedOutput(state: ShimState, payload: Payload, frames: Set[Frame], told: SendObs): ShimResult = { state: state, out: DivertedOutput({ payload: payload, frames: frames, told: told }) } pure def toSendDoneOutput(state: ShimState, input: SendInput, obs: SendObs): ShimResult = @@ -117,28 +110,18 @@ module shim { // ------------------------------------------------------------------------ /// Divert a migration: one frame per hub address, each under a fresh nonce, - /// to the first `handedOver` addresses. - /// - /// Under `DispatchOnly` the wallet is told ok as soon as one frame has been - /// handed over, whether or not the sweep reached the later addresses, and - /// no ack is waited for. Under `AwaitVerdict` there is one hub, and the - /// wallet's answer is that hub's. + /// to the first `handedOver` addresses. The wallet is told ok as soon as one + /// frame has been handed over, whether or not the sweep reached the later + /// addresses, and no ack is waited for. pure def divert(state: ShimState, payload: Payload, handedOver: int): ShimResult = - val reach = match state.mode { - | DispatchOnly => handedOver - | AwaitVerdict => if (handedOver > 0) 1 else 0 - } - val targets = state.hubs.slice(0, reach) + val targets = state.hubs.slice(0, handedOver) if (targets.length() == 0) state.toSendDoneOutput(Clean(payload), SendUnavailable) else - val awaited = state.mode == AwaitVerdict val frames = targets.indices().map(i => { hub: targets[i], msg: Submit({ nonce: state.nextNonce + i, payload: payload }) }) - val waiting = targets.indices().fold(state.waiters, (waiters, i) => - waiters.put(state.nextNonce + i, AckWaiter({ payload: payload, awaited: awaited }))) - { ...state, waiters: waiting, nextNonce: state.nextNonce + targets.length() } - .toDivertedOutput(payload, frames, if (awaited) None else Some(SentOk)) + { ...state, nextNonce: state.nextNonce + targets.length() } + .toDivertedOutput(payload, frames, SentOk) /// Route a `SendTransaction`. Nothing but a cleanly read pass-through /// transaction ever reaches the operator; everything else is diverted or @@ -164,7 +147,7 @@ module shim { pure def askHub(state: ShimState, query: TxId, start: int, attempt: int): ShimResult = val nonce = state.nextNonce { ...state, - waiters: state.waiters.put(nonce, LookupWaiter({ query: query, start: start, attempt: attempt })), + waiters: state.waiters.put(nonce, { query: query, start: start, attempt: attempt }), nextNonce: nonce + 1, }.toLookupSentOutput({ hub: state.sweepTarget(start, attempt), msg: Lookup({ nonce: nonce, txid: query }) }) @@ -183,60 +166,29 @@ module shim { if (not(state.hasWaiter(nonce))) state.toShimErrorOutput("no request is waiting under this nonce") else - match state.waiters.get(nonce) { - | AckWaiter(_) => state.toShimErrorOutput("not a lookup") - | LookupWaiter(waiter) => - val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } - if (waiter.attempt + 1 < state.hubs.length()) - askHub(forgotten, waiter.query, waiter.start, waiter.attempt + 1) - else - forgotten.toLookupDoneOutput(waiter.query, Unavailable) - } - - /// A submission got no ack in time. - pure def ackTimeout(state: ShimState, nonce: Nonce): ShimResult = - if (not(state.hasWaiter(nonce))) - state.toShimErrorOutput("no request is waiting under this nonce") - else - match state.waiters.get(nonce) { - | LookupWaiter(_) => state.toShimErrorOutput("not a submission") - | AckWaiter(waiter) => - val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } - if (waiter.awaited) forgotten.toSendDoneOutput(Clean(waiter.payload), SendUnavailable) - else forgotten.toNoShimOutput() - } + val waiter = state.waiters.get(nonce) + val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } + if (waiter.attempt + 1 < state.hubs.length()) + askHub(forgotten, waiter.query, waiter.start, waiter.attempt + 1) + else + forgotten.toLookupDoneOutput(waiter.query, Unavailable) // ------------------------------------------------------------------------ // Frames from the network // ------------------------------------------------------------------------ - /// A frame arrives. It is matched to a request by its nonce alone. A frame - /// under an unknown nonce is dropped. A frame of the wrong kind for a known - /// nonce is ignored and the request keeps waiting. + /// A frame arrives. A lookup reply is matched to its lookup by its nonce + /// alone; under an unknown nonce it is dropped. An ack is dropped: nobody + /// waits for one. pure def receive(state: ShimState, msg: Msg): ShimResult = match msg { - | Ack(ack) => - if (not(state.hasWaiter(ack.nonce))) state.toNoShimOutput() - else - match state.waiters.get(ack.nonce) { - | LookupWaiter(_) => state.toNoShimOutput() - | AckWaiter(waiter) => - val forgotten = { ...state, waiters: state.waiters.mapRemove(ack.nonce) } - if (waiter.awaited) - forgotten.toSendDoneOutput( - Clean(waiter.payload), - if (ack.ack == WAccepted) SentOk else SentRejected) - else forgotten.toNoShimOutput() - } + | Ack(_) => state.toNoShimOutput() | LookupReply(reply) => if (not(state.hasWaiter(reply.nonce))) state.toNoShimOutput() else - match state.waiters.get(reply.nonce) { - | AckWaiter(_) => state.toNoShimOutput() - | LookupWaiter(waiter) => - { ...state, waiters: state.waiters.mapRemove(reply.nonce) } - .toLookupDoneOutput(waiter.query, interpretReply(reply.reply, waiter.query)) - } + val waiter = state.waiters.get(reply.nonce) + { ...state, waiters: state.waiters.mapRemove(reply.nonce) } + .toLookupDoneOutput(waiter.query, interpretReply(reply.reply, waiter.query)) | Submit(_) => state.toShimErrorOutput("not a reply frame") | Lookup(_) => state.toShimErrorOutput("not a reply frame") } @@ -251,7 +203,6 @@ module shim { | GetTxSInput(lookup) => getTransaction(state, lookup.query, lookup.start) | FrameSInput(msg) => receive(state, msg) | LookupTimeoutSInput(nonce) => lookupTimeout(state, nonce) - | AckTimeoutSInput(nonce) => ackTimeout(state, nonce) } // ------------------------------------------------------------------------ @@ -259,7 +210,7 @@ module shim { // ------------------------------------------------------------------------ pure val SEND_OBSERVATIONS = - Set(SentOk, SentRejected, SentToOperator, SendUnavailable, SendInvalid, SendTooLarge) + Set(SentOk, SentToOperator, SendUnavailable, SendInvalid, SendTooLarge) /// Every lookup answer that can be built from `universe` and `heights`. pure def lookupObservations(universe: Set[Payload], heights: Set[Height]): Set[LookupObs] = @@ -277,7 +228,6 @@ module shim { | _ => None } | LookupTimeoutSInput(nonce) => Some(nonce) - | AckTimeoutSInput(nonce) => Some(nonce) | _ => None } @@ -289,8 +239,7 @@ module shim { /// operator whatever its class; or answered with anything while a frame /// carrying any payload of `universe` goes to any one hub; /// - a lookup is answered with anything, at once or when a reply or a - /// timeout would have resolved it; - /// - an awaited submission is resolved with anything. + /// timeout would have resolved it. /// /// The honest transition is always a member. pure def byzShimResults( @@ -310,7 +259,7 @@ module shim { { ...state, nextNonce: state.nextNonce + 1 }.toDivertedOutput( sent, Set({ hub: state.hubs[i], msg: Submit({ nonce: state.nextNonce, payload: payload }) }), - Some(obs))) + obs)) .union(Set(state.toForwardOutput(sent))) | _ => Set() } @@ -324,15 +273,8 @@ module shim { if (not(state.hasWaiter(nonce))) honest else val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } - match state.waiters.get(nonce) { - | LookupWaiter(waiter) => - honest.union(lies.map(result => forgotten.toLookupDoneOutput(waiter.query, result))) - | AckWaiter(waiter) => - if (waiter.awaited) - honest.union(SEND_OBSERVATIONS.map(obs => - forgotten.toSendDoneOutput(Clean(waiter.payload), obs))) - else honest - } + val waiter = state.waiters.get(nonce) + honest.union(lies.map(result => forgotten.toLookupDoneOutput(waiter.query, result))) } } } diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt index a724d4e9..79ee69d1 100644 --- a/zeronym/spec/protocol/state.qnt +++ b/zeronym/spec/protocol/state.qnt @@ -61,7 +61,7 @@ module state { hubs: config.hubs.indices().map(i => config.hubs[i]).mapBy(_ => startingHub(params)), polled: Set(), flightBlocks: config.hubs.indices().map(i => config.hubs[i]).mapBy(_ => 0), - shim: initialShim(config.hubs, config.submitMode), + shim: initialShim(config.hubs), net: Set(), wallet: { log: [], sends: 0, gets: 0 }, operator: Set(), @@ -78,7 +78,6 @@ module state { | WalletGet({ query: TxId, start: int }) | ShimReceive(Mail) | ShimLookupTimeout(Nonce) - | ShimAckTimeout(Nonce) | HubReceive({ hub: HubId, mail: Mail }) | HubObserveTip({ hub: HubId, tip: Height }) | HubTipStale({ hub: HubId, estimate: Height }) @@ -255,10 +254,10 @@ module state { pure def tpPayloads(s: System): Set[Payload] = s.tpLearned().union(s.indexer.published()).union(s.thirdParty.own) - /// The payloads the wallet has handed the shim: every send that was - /// answered, and every send still waiting for a hub's decision. + /// The payloads the wallet has handed the shim: every send is answered at + /// once. pure def sentByWallet(s: System): Set[Payload] = - val answered = s.events().fold(Set(), (acc, event) => + s.events().fold(Set(), (acc, event) => match event { | Sent(done) => match done.input { @@ -267,12 +266,6 @@ module state { } | _ => acc }) - val waiting = s.shim.waiters.keys().fold(Set(), (acc, nonce) => - match s.shim.waiters.get(nonce) { - | AckWaiter(waiter) => acc.union(Set(waiter.payload)) - | _ => acc - }) - answered.union(waiting) /// The payloads the shim has seen in the clear: every transaction the wallet /// handed it, and every body a hub returned to it. @@ -314,10 +307,7 @@ module state { | DivertedOutput(diverted) => val dispatched = stepped.posted(diverted.frames.map(frame => { src: ShimAddr, dst: HubAddr(frame.hub), msg: frame.msg })) - match diverted.told { - | Some(obs) => dispatched.logged(Sent({ input: Clean(diverted.payload), obs: obs })) - | None => dispatched - } + dispatched.logged(Sent({ input: Clean(diverted.payload), obs: diverted.told })) | SendDoneOutput(done) => stepped.logged(Sent(done)) | LookupSentOutput(sent) => stepped.posted(Set({ src: ShimAddr, dst: HubAddr(sent.frame.hub), msg: sent.frame.msg })) diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index 40de71f6..4810940a 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -143,7 +143,7 @@ module baselineScenarios { .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) .expect(s.acks("h1", ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) .expect(audit.everQueued.get("h1") == Set()) - .expect(wToldRefusedEverywhere and not(toldImpliesQueued)) + .expect(wToldRefusedEverywhere) /// K1b. The frame is never delivered. Nothing obliges the network to. run toldOkAndNeverDeliveredTest = @@ -153,7 +153,7 @@ module baselineScenarios { .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) .expect(s.net == Set(submitMail("h1", 0, early))) .expect(audit.everQueued.get("h1") == Set()) - .expect(wToldNeverDelivered and not(toldImpliesQueued)) + .expect(wToldNeverDelivered) // ------------------------------------------------------------------------ // K2. The status a wallet sees goes backwards @@ -253,42 +253,6 @@ module baselineScenarios { .expect(not(statusNeverRegresses) and lookupValidityPerHub) } -module awaitAckScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = awaitAck).* from "../protocol" - - /// Under `AwaitVerdict` the wallet hears nothing until the hub answers, and - /// then hears the hub's decision. - run toldWhatTheHubDecidedTest = - started - .then(block) - .then(sendToAll(early)) - .expect(s.wallet.log == [] and s.sentByWallet() == Set(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverToShim(ackMail("h1", 0, WAccepted))) - .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) - .expect(vToldImpliesQueued and toldImpliesQueued) - // A transaction the hub refuses is reported as refused. - .then(blocks(1)) - .then(sendToAll(tight)) - .then(deliverSubmit("h1", 1, tight)) - .then(deliverToShim(ackMail("h1", 1, WRefused(WExpiryTooTight)))) - .expect(lastEvent == Sent({ input: Clean(tight), obs: SentRejected })) - // And silence fails closed. - .then(sendToAll(junk)) - .then(timeOutAck(2)) - .expect(lastEvent == Sent({ input: Clean(junk), obs: SendUnavailable })) - .expect(toldImpliesQueued) - -} - module replicatedScenarios { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -342,9 +306,8 @@ module replicatedScenarios { Got({ query: "early", obs: Pending, via: Some(2) }), Got({ query: "early", obs: NotFound, via: Some(3) }), ]) - // No lookup is outstanding: the shim asked nobody else. (Nonces 0 and 1 - // are the acks nobody waits for.) - .expect(s.shim.waiters.keys() == Set(0, 1)) + // No lookup is outstanding: the shim asked nobody else. + .expect(s.shim.waiters.keys() == Set()) // Each hub told the truth about itself. .expect(not(statusNeverRegresses) and lookupValidityPerHub) diff --git a/zeronym/spec/protocol/tests/shimTest.qnt b/zeronym/spec/protocol/tests/shimTest.qnt index 2968109a..32e80391 100644 --- a/zeronym/spec/protocol/tests/shimTest.qnt +++ b/zeronym/spec/protocol/tests/shimTest.qnt @@ -1,6 +1,6 @@ // -*- mode: Bluespec; -*- -/// The shim function, checked with two hubs under both submit modes. +/// The shim function, checked with one hub and with two. module shimTest { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -20,8 +20,8 @@ module shimTest { pure val HEIGHTS = Set(0, 4) pure val SEND_INPUTS = PAYLOADS.map(p => Clean(p)).union(Set(Unreadable, EmptyBody)) - pure val dispatching = initialShim(["h1", "h2"], DispatchOnly) - pure val awaiting = initialShim(["h1"], AwaitVerdict) + pure val dispatching = initialShim(["h1", "h2"]) + pure val single = initialShim(["h1"]) pure def after(state: ShimState, input: ShimInput): ShimState = shim(state, input).state pure def outputOf(state: ShimState, input: ShimInput): ShimOutput = shim(state, input).out @@ -44,12 +44,10 @@ module shimTest { pure def reply(nonce: Nonce, given: WireReply): ShimInput = FrameSInput(LookupReply({ nonce: nonce, reply: given })) - /// Nonces 0 and 1 wait on acks nobody reads; nonce 2 is a lookup at `h2`. + /// Nonces 0 and 1 went out with a submission; nonce 2 is a lookup at `h2`. pure val busy = dispatching.after(send(Clean(pOrchard), 2)).after(getTx("orchard", 1)) - /// Nonce 0 is a submission whose ack is the wallet's answer. - pure val pendingSend = awaiting.after(send(Clean(pOrchard), 1)) - pure val STATES = Set(dispatching, awaiting, busy, pendingSend, awaiting.after(getTx("orchard", 0))) + pure val STATES = Set(dispatching, single, busy, single.after(getTx("orchard", 0))) pure val INPUTS: Set[ShimInput] = tuples(SEND_INPUTS, 0.to(3)).map(((input, handedOver)) => send(input, handedOver)) @@ -59,7 +57,6 @@ module shimTest { .union(0.to(3).map(nonce => reply(nonce, WNotFound))) .union(0.to(3).map(nonce => reply(nonce, WFound({ body: Some(pOrchard), height: 4 })))) .union(0.to(3).map(nonce => LookupTimeoutSInput(nonce))) - .union(0.to(3).map(nonce => AckTimeoutSInput(nonce))) .union(Set( FrameSInput(Submit({ nonce: 0, payload: pOrchard })), FrameSInput(Lookup({ nonce: 0, txid: "orchard" })), @@ -87,7 +84,7 @@ module shimTest { == DivertedOutput({ payload: pJunk, frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pJunk }) }), - told: Some(SentOk), + told: SentOk, })), } @@ -96,7 +93,7 @@ module shimTest { assert(outputOf(dispatching, send(EmptyBody, 2)) == SendDoneOutput({ input: EmptyBody, obs: SendInvalid })), assert(outputOf(dispatching, send(Clean(pBig), 2)) == SendDoneOutput({ input: Clean(pBig), obs: SendTooLarge })), // No frame handed over: the hub is unreachable. - assert(Set(dispatching, awaiting).forall(state => + assert(Set(dispatching, single).forall(state => outputOf(state, send(Clean(pOrchard), 0)) == SendDoneOutput({ input: Clean(pOrchard), obs: SendUnavailable }))), // None of these leaves anything behind. assert(Set(send(Unreadable, 2), send(EmptyBody, 2), send(Clean(pBig), 2), send(Clean(pOrchard), 0)) @@ -112,37 +109,19 @@ module shimTest { { hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }, { hub: "h2", msg: Submit({ nonce: 1, payload: pOrchard }) }, ), - told: Some(SentOk), + told: SentOk, })), // A sweep that stopped after the first address still tells the wallet ok. assert(outputOf(dispatching, send(Clean(pOrchard), 1)) == DivertedOutput({ payload: pOrchard, frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }), - told: Some(SentOk), + told: SentOk, })), assert(dispatching.after(send(Clean(pOrchard), 2)).nextNonce == 2), - // The ack is never awaited: it clears the waiter and tells nobody. + // A submission leaves nothing to wait on, and an ack tells nobody. + assert(dispatching.after(send(Clean(pOrchard), 2)).waiters == Map()), assert(outputOf(busy, ack(0, WRefused(WQueueFull))) == NoShimOutput), - assert(not(busy.after(ack(0, WRefused(WQueueFull))).hasWaiter(0))), - assert(outputOf(busy, AckTimeoutSInput(1)) == NoShimOutput), - assert(not(busy.after(AckTimeoutSInput(1)).hasWaiter(1))), - } - - run awaitVerdictTest = all { - // One hub, and nothing is told until it answers. - assert(outputOf(awaiting, send(Clean(pOrchard), 1)) == DivertedOutput({ - payload: pOrchard, - frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }), - told: None, - })), - assert(outputOf(pendingSend, ack(0, WAccepted)) == SendDoneOutput({ input: Clean(pOrchard), obs: SentOk })), - assert(outputOf(pendingSend, ack(0, WRefused(WTipStale))) - == SendDoneOutput({ input: Clean(pOrchard), obs: SentRejected })), - assert(outputOf(pendingSend, AckTimeoutSInput(0)) - == SendDoneOutput({ input: Clean(pOrchard), obs: SendUnavailable })), - assert(pendingSend.after(ack(0, WAccepted)).waiters == Map()), - // A second ack for the same nonce finds nothing waiting. - assert(outputOf(pendingSend.after(ack(0, WAccepted)), ack(0, WRefused(WTipStale))) == NoShimOutput), + assert(busy.after(ack(0, WRefused(WQueueFull))) == busy), } // ------------------------------------------------------------------------ @@ -157,7 +136,7 @@ module shimTest { == LookupSentOutput({ frame: { hub: "h2", msg: Lookup({ nonce: 0, txid: "orchard" }) } })), assert(isError(outputOf(dispatching, getTx("orchard", 2)))), // No hub configured to ask: unavailable. - assert(outputOf(initialShim([], DispatchOnly), getTx("orchard", 0)) + assert(outputOf(initialShim([]), getTx("orchard", 0)) == LookupDoneOutput({ query: "orchard", result: Unavailable })), // Each reply arm. assert(outputOf(busy, reply(2, WFound({ body: None, height: MEMPOOL_HEIGHT }))) @@ -185,18 +164,16 @@ module shimTest { assert(outputOf(busy.after(LookupTimeoutSInput(2)), reply(2, WNotFound)) == NoShimOutput), assert(isError(outputOf(busy, LookupTimeoutSInput(0)))), assert(isError(outputOf(busy, LookupTimeoutSInput(9)))), - assert(isError(outputOf(busy, AckTimeoutSInput(2)))), } run correlationTest = all { // An unknown nonce is dropped. assert(outputOf(busy, ack(9, WAccepted)) == NoShimOutput and busy.after(ack(9, WAccepted)) == busy), assert(outputOf(busy, reply(9, WNotFound)) == NoShimOutput and busy.after(reply(9, WNotFound)) == busy), - // The wrong kind of reply for a known nonce is ignored, and the request - // keeps waiting. + // An ack under a lookup's nonce is ignored, and the lookup keeps waiting. + // A reply under a submission's nonce is dropped. assert(outputOf(busy, ack(2, WAccepted)) == NoShimOutput and busy.after(ack(2, WAccepted)) == busy), assert(outputOf(busy, reply(0, WNotFound)) == NoShimOutput and busy.after(reply(0, WNotFound)) == busy), - assert(outputOf(pendingSend, reply(0, WNotFound)) == NoShimOutput), // Request frames are not replies. assert(isError(outputOf(busy, FrameSInput(Submit({ nonce: 0, payload: pOrchard }))))), assert(isError(outputOf(busy, FrameSInput(Lookup({ nonce: 2, txid: "orchard" }))))), @@ -227,20 +204,17 @@ module shimTest { assert(byzShimResults(dispatching, send(Clean(pOrchard), 2), PAYLOADS, HEIGHTS) .contains(dispatching.toForwardOutput(pOrchard))), // It can tell the wallet ok and send nothing. - assert(byzShimResults(awaiting, send(Clean(pOrchard), 1), PAYLOADS, HEIGHTS) - .contains(awaiting.toSendDoneOutput(Clean(pOrchard), SentOk))), + assert(byzShimResults(single, send(Clean(pOrchard), 1), PAYLOADS, HEIGHTS) + .contains(single.toSendDoneOutput(Clean(pOrchard), SentOk))), // It can answer a lookup with a transaction that has another txid. assert(byzShimResults(dispatching, getTx("orchard", 0), PAYLOADS, HEIGHTS) .contains(dispatching.toLookupDoneOutput("orchard", Tx({ payload: pPlain, height: 4 })))), - // It can resolve an awaited submission against the hub's answer. - assert(byzShimResults(pendingSend, ack(0, WRefused(WTipStale)), PAYLOADS, HEIGHTS) - .contains(awaiting.with("nextNonce", 1).toSendDoneOutput(Clean(pOrchard), SentOk))), // It can send a hub something other than what the wallet sent. assert(byzShimResults(dispatching, send(Clean(pOrchard), 2), PAYLOADS, HEIGHTS).exists(result => result.out == DivertedOutput({ payload: pOrchard, frames: Set({ hub: "h2", msg: Submit({ nonce: 0, payload: pJunk }) }), - told: Some(SentOk), + told: SentOk, }))), } } diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 16210743..658398f8 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -94,37 +94,6 @@ module byzShimTrust { .expect(lookupValidityPerHub) } -module awaitAckByzShimTrust { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = awaitAckByzShim).* from "../protocol" - - /// G5 needs the shim. It tells the wallet ok and sends nothing. - run toldOkWithoutSendingTest = - started - .then(block) - .then(walletSendWith(Clean(early), 1, s.shim.toSendDoneOutput(Clean(early), SentOk))) - .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) - .expect(s.net == Set() and audit.everQueued.get("h1") == Set()) - .expect(not(toldImpliesQueued)) - - run toldOkWithoutSendingControlTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverToShim(ackMail("h1", 0, WAccepted))) - .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) - .expect(audit.everQueued.get("h1") == Set(early)) - .expect(toldImpliesQueued) -} - module byzHubTrust { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -236,42 +205,6 @@ module byzHubTrust { } -module awaitAckByzHubTrust { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = awaitAckByzHub).* from "../protocol" - - /// G5 needs the hub. The wallet's answer is the hub's word, and the hub - /// says accepted without queueing anything. - run toldOkOnAFalseAckTest = - started - .then(block) - .then(sendToAll(early)) - .then(hubReceiveWith( - "h1", submitMail("h1", 0, early), INotFound, - s.hubs.get("h1").toAckOutput(0, Admitted), - )) - .then(deliverToShim(ackMail("h1", 0, WAccepted))) - .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) - .expect(audit.everQueued.get("h1") == Set()) - .expect(not(toldImpliesQueued)) - - run toldOkOnAFalseAckControlTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverToShim(ackMail("h1", 0, WAccepted))) - .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) - .expect(toldImpliesQueued) -} - module byzIndexerTrust { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -442,8 +375,8 @@ module replicatedOneByzTrust { /// refuses and publishes it late. Both are failures of that replica alone. /// /// And the wallet, told ok, has no honest hub holding its first migration: - /// the honest hub's frame was never delivered. Under `DispatchOnly` that is - /// not something replication promises. + /// the honest hub's frame was never delivered. That is not something + /// replication promises. run honestReplicaKeepsItsGuaranteesTest = started .then(block) diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index dd49869c..d2c99d58 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -157,12 +157,6 @@ module types { /// How a stale hub's free-running cadence clock relates to the true height. type FreeRun = NotSlower | MayBeSlower - /// Who the wallet hears from on a diverted send. `DispatchOnly` is the mixnet - /// transport: the shim answers once a frame is handed over and never waits - /// for the ack. `AwaitVerdict` is the HTTP transport: the hub's decision is - /// the answer. - type SubmitMode = DispatchOnly | AwaitVerdict - // ------------------------------------------------------------------------ // What the wallet observes // ------------------------------------------------------------------------ @@ -170,7 +164,6 @@ module types { /// The answer to a `SendTransaction`. type SendObs = | SentOk // diverted; error code 0 - | SentRejected // the hub refused it | SentToOperator // not a migration; the operator's indexer has it | SendUnavailable // failed closed: unreadable body, or no hub reachable | SendInvalid // empty body @@ -217,7 +210,6 @@ module types { maxFlightBlocks: int, maxHeight: Height, maxRequests: int, - submitMode: SubmitMode, roles: Roles, tip: TipModel, // Which of the two optional timing relations this configuration relies From 91f5ac3c530670624c55836d0ccc9f3c8a7b6870 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 03:28:15 +0400 Subject: [PATCH 43/80] test(zeronym): remove the Byzantine shim from the protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 49 ++++++------ zeronym/spec/protocol/check.sh | 12 +-- zeronym/spec/protocol/instances.qnt | 17 +---- zeronym/spec/protocol/properties.qnt | 2 +- zeronym/spec/protocol/protocol.qnt | 68 ++++++++--------- zeronym/spec/protocol/shim.qnt | 90 ++++------------------- zeronym/spec/protocol/state.qnt | 8 +- zeronym/spec/protocol/tests/shimTest.qnt | 27 +------ zeronym/spec/protocol/tests/trustTest.qnt | 83 --------------------- zeronym/spec/protocol/types.qnt | 2 +- 10 files changed, 77 insertions(+), 281 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 60bd235b..14ad8232 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -147,6 +147,7 @@ definitions they justify. | Wire codecs `ZNS1` / `ZNA1` / `ZNL1` / `ZNR1` and the golden vectors (`zeronym/hub/src/wire.rs:576-579`) | Byte layouts are scoped out and are pinned by the Rust tests in both crates; the abstract `render` / `interpretReply` layer is the level this spec works at. The spec does not claim to bind the codec | | HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | | The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | +| A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | | Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties. Only the pure lemma "frame size is independent of content" is stated | @@ -359,7 +360,7 @@ pure. | `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply`, `sizeOf` | | `indexer.qnt` | `indexer` | The chain and indexer as a relation: honest and Byzantine outputs, and their effect | | `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | -| `shim.qnt` | `shim` | `shim(state, input)`; routing, the lookup sweep, reply correlation; `byzShimResults` | +| `shim.qnt` | `shim` | `shim(state, input)`; routing, the lookup sweep, reply correlation | | `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | | `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | | `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, A1-A3, the run vocabulary | @@ -412,8 +413,6 @@ member of a finite set that contains it (F12): queued or not, whatever admission says. Any reply to a lookup: a queue hit, not found, error, or found with no body or any payload that exists, at any height. It keeps the honest flush schedule. -- **Byzantine shim.** Any answer to the wallet for a send or a lookup. Any - transaction handed to the operator. A frame carrying any payload to any hub. - **Byzantine indexer.** Any verdict, with the transaction relayed to the network or not. Any lookup answer built from a payload it was offered, one the chain published, or a twin of either. Any tip up to `MAX_HEIGHT`. @@ -436,14 +435,13 @@ abstract indexer per hub was a decision of the design. One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields (`PAYLOADS`, `FLUSH_INTERVAL`, `ROLES`, `TIP`, ...). -| Module | Hubs | Roles (shim / hubs / indexer) | Tip | +| Module | Hubs | Roles (hubs / indexer) | Tip | |---|---|---|---| -| `baseline` | 1 | H / H / H | timely | -| `byzShim` | 1 | **B** / H / H | timely | -| `byzHub` | 1 | H / **B** / H | timely | -| `byzIndexer` | 1 | H / H / **B** | timely for honest reports | -| `replicated` | 2 | H / H, H / H | timely | -| `replicatedOneByz` | 2 | H / H, **B** / H | timely | +| `baseline` | 1 | H / H | timely | +| `byzHub` | 1 | **B** / H | timely | +| `byzIndexer` | 1 | H / **B** | timely for honest reports | +| `replicated` | 2 | H, H / H | timely | +| `replicatedOneByz` | 2 | H, **B** / H | timely | The schedule is the shipped one scaled down, keeping the relations between the numbers: @@ -555,17 +553,17 @@ simulator finds at seed 7 were read by hand and both use reports below the true height. With truthful answers `byzIndexer` behaves as `baseline`, where the chain cannot pass a running, idle hub that has not asked. -| | All honest | Byzantine shim | Byzantine hub | Byzantine indexer | -|---|---|---|---|---| -| G1 | holds (`baseline`) | **required**: `operatorSeesMigrationTest` | holds (`byzHub`) | holds (`byzIndexer`) | -| G2 | holds (`baseline`) | **required**: `shimDisclosesPlaintextTest` | **required**: `hubServesQueuedBodyTest` | **required**: `indexerServesUnpublishedBodyTest`. One endpoint suffices | -| G3 | holds (`baseline`) | **required**: `shimServesAnotherTransactionTest` | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | -| G4 | holds (`baseline`, `replicated`) | **required**: `shimInventsStatusTest` | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | -| G8 | holds (`baseline`) | holds (`byzShim`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | -| G6a | holds (`baseline`) | holds (`byzShim`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | -| G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | -| G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | holds (`byzShim`) | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | -| A3 | not run (TLC, `baseline`) | not run | **required**: `hubAdmitsWhileDrainingTest` | not run | +| | All honest | Byzantine hub | Byzantine indexer | +|---|---|---|---| +| G1 | holds (`baseline`) | holds (`byzHub`) | holds (`byzIndexer`) | +| G2 | holds (`baseline`) | **required**: `hubServesQueuedBodyTest` | **required**: `indexerServesUnpublishedBodyTest`. One endpoint suffices | +| G3 | holds (`baseline`) | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | +| G4 | holds (`baseline`, `replicated`) | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | +| G8 | holds (`baseline`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | +| G6a | holds (`baseline`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | +| G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| A3 | not run (TLC, `baseline`) | **required**: `hubAdmitsWhileDrainingTest` | not run | One Byzantine replica out of two (`replicatedOneByz`): @@ -580,8 +578,9 @@ One Byzantine replica out of two (`replicatedOneByz`): The `...ForHonestHubs` names are the same predicates restricted to the hubs whose role is honest. They are not weaker properties. -In short: a Byzantine shim voids every wallet-facing guarantee (G1-G4); the -hub-side G6 and G8 survive it. G3 is the only wallet-facing guarantee that +There is no Byzantine-shim column: the shim sees every migration in plaintext +and controls everything the wallet observes, so every wallet-facing guarantee +assumes an honest (attested) shim. G3 is the only wallet-facing guarantee that survives a Byzantine hub or indexer, and it authenticates the txid only. G1 depends on the shim alone. Replication does not dilute trust: one Byzantine replica is enough to void G2 and G4. A3 needs the hub: its "required" @@ -950,9 +949,6 @@ quint verify --main=baseline --invariant=offeredBeforeExpiry --max-steps=12 zero quint verify --main=baseline --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant=wellFormed --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzShim --invariant=offeredBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzShim --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzShim --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzHub --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzHub --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzIndexer --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt @@ -967,7 +963,6 @@ should report one; `flakyTipNoSlack` and `flakyTipSlowFlight` satisfy every ```sh quint verify --main=baseline --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzShim --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=flakyTip --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=flakyTipNoSlack --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=flakyTipSlowFlight --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 13444dc4..565535d8 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -74,9 +74,9 @@ finish() { SPELLS="spells/basicSpells.qnt spells/soup.qnt" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt" -INSTANCES="baseline byzShim byzHub byzIndexer replicated replicatedOneByz" +INSTANCES="baseline byzHub byzIndexer replicated replicatedOneByz" SCENARIOS="baselineScenarios replicatedScenarios byzHubScenarios" -TRUST="byzShimTrust byzHubTrust byzIndexerTrust replicatedOneByzTrust" +TRUST="byzHubTrust byzIndexerTrust replicatedOneByzTrust" fail() { echo "FAIL $1" @@ -280,7 +280,6 @@ echo "---- 3 invariants ($SAMPLES traces, seed $SEED)" # The guarantees, where they are claimed. G7 `wellFormed` is checked everywhere. job holds baseline operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub \ ackImpliesQueued wellFormed -job holds byzShim ackImpliesQueued wellFormed job holds byzHub operatorBlind txidAuthenticity wellFormed job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed job holds replicated lookupValidityPerHub wellFormed @@ -288,10 +287,6 @@ job holds replicatedOneByz txidAuthenticity ackImpliesQueuedForHonestHubs wel # The trust matrix: each guarantee fails once the component it depends on is # Byzantine. The schedule guarantees' rows are in tier 4. -job fails byzShim step 40 operatorBlind -job fails byzShim step 40 queuedBytesConfidential -job fails byzShim step 40 txidAuthenticity -job fails byzShim step 40 lookupValidityPerHub job fails byzHub step 40 queuedBytesConfidential job fails byzHub step 40 lookupValidityPerHub job fails byzHub step 40 ackImpliesQueued @@ -327,9 +322,6 @@ job reaches baseline outageStep 80 \ wRefusedFull wDroppedExhausted wQueueOverCapacity \ -- $BASELINE_HOLDS -job reaches byzShim quietStep 40 \ - vAckImpliesQueued \ - -- ackImpliesQueued wellFormed # W16, both halves. job reaches byzHub quietStep 40 \ vOperatorBlind vTxidAuthenticity wTwinServed wFalseHeightServed \ diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index 0bb82cf8..c73fcc95 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -43,7 +43,7 @@ module configs { // Configurations // ------------------------------------------------------------------------ - pure val allHonest: Roles = { shim: Honest, hubs: Map("h1" -> Honest), indexer: Honest } + pure val allHonest: Roles = { hubs: Map("h1" -> Honest), indexer: Honest } /// One hub, the mixnet transport, every component honest, a timely tip. /// @@ -85,7 +85,6 @@ module configs { } // One Byzantine component at a time. - pure val byzShim = { ...baseline, roles: { ...allHonest, shim: Byzantine } } pure val byzHub = { ...baseline, roles: { ...allHonest, hubs: Map("h1" -> Byzantine) } } pure val byzIndexer = { ...baseline, roles: { ...allHonest, indexer: Byzantine } } @@ -115,20 +114,6 @@ module baseline { } } -module byzShim { - import types.* from "./types" - import configs.* - import protocol(CONFIG = byzShim).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - module byzHub { import types.* from "./types" import configs.* diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index 09fa4142..f985878a 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -537,7 +537,7 @@ module properties { pure def wTwinServedIn(s: System): bool = s.wasGiven(obs => match obs { - | Tx(tx) => s.seenByShim().exists(sent => s.toldOk().contains(sent) and areTwins(sent, tx.payload)) + | Tx(tx) => s.toldOk().exists(sent => areTwins(sent, tx.payload)) | _ => false }) diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index f8d4a320..23e6384a 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -236,14 +236,6 @@ module protocol { // Roles // ------------------------------------------------------------------------ - /// The transitions the shim may take on `input`: the one the protocol - /// prescribes, or, for a Byzantine shim, any of the wider set. - pure def shimResults(state: ShimState, input: ShimInput): Set[ShimResult] = - match ROLES.shim { - | Honest => Set(shim(state, input)) - | Byzantine => byzShimResults(state, input, UNIVERSE, HEIGHTS) - } - pure def hubResults(id: HubId, state: HubState, input: HubInput): Set[HubResult] = match ROLES.hubs.get(id) { | Honest => Set(hub(state, input)) @@ -353,7 +345,7 @@ module protocol { /// The wallet sends a transaction. The shim routes it and, unless it is /// waiting for a hub's decision, answers. - action walletSendWith(input: SendInput, handedOver: int, result: ShimResult): bool = all { + action walletSendWith(input: SendInput, handedOver: int): bool = all { s.wallet.sends < MAX_REQUESTS, SEND_INPUTS.contains(input), // A wallet cannot send a transaction before it has built it. @@ -361,9 +353,11 @@ module protocol { | Clean(payload) => payload.created <= s.height() | _ => true }, - shimResults(s.shim, SendTxSInput({ input: input, handedOver: handedOver })).contains(result), - not(isShimError(result.out)), - commit(s.countSend().shimStepped(result, None), WalletSend({ input: input, handedOver: handedOver })), + val result = shim(s.shim, SendTxSInput({ input: input, handedOver: handedOver })) + all { + not(isShimError(result.out)), + commit(s.countSend().shimStepped(result, None), WalletSend({ input: input, handedOver: handedOver })), + }, } action walletSend = all { @@ -371,8 +365,7 @@ module protocol { { nondet input = oneOf(SEND_INPUTS) nondet handedOver = oneOf(0.to(HUB_ORDER.length())) - nondet result = oneOf(shimResults(s.shim, SendTxSInput({ input: input, handedOver: handedOver }))) - walletSendWith(input, handedOver, result) + walletSendWith(input, handedOver) }, } @@ -383,12 +376,14 @@ module protocol { /// The wallet asks for a transaction it has sent. The shim asks the hub its /// cursor points at. - action walletGetWith(query: TxId, start: int, result: ShimResult): bool = all { + action walletGetWith(query: TxId, start: int): bool = all { s.wallet.gets < MAX_REQUESTS, walletTxids.contains(query), - shimResults(s.shim, GetTxSInput({ query: query, start: start })).contains(result), - not(isShimError(result.out)), - commit(s.countGet().shimStepped(result, None), WalletGet({ query: query, start: start })), + val result = shim(s.shim, GetTxSInput({ query: query, start: start })) + all { + not(isShimError(result.out)), + commit(s.countGet().shimStepped(result, None), WalletGet({ query: query, start: start })), + }, } action walletGet = all { @@ -397,8 +392,7 @@ module protocol { { nondet query = oneOf(walletTxids) nondet start = oneOf(HUB_ORDER.indices()) - nondet result = oneOf(shimResults(s.shim, GetTxSInput({ query: query, start: start }))) - walletGetWith(query, start, result) + walletGetWith(query, start) }, } @@ -416,36 +410,38 @@ module protocol { }) /// The network delivers a frame to the shim. - action shimReceiveWith(mail: Mail, result: ShimResult): bool = all { + action shimReceiveWith(mail: Mail): bool = all { shimDeliverable.contains(mail), - shimResults(s.shim, FrameSInput(mail.msg)).contains(result), - not(isShimError(result.out)), - commit(s.shimStepped(result, nonceOf(FrameSInput(mail.msg))), ShimReceive(mail)), + val result = shim(s.shim, FrameSInput(mail.msg)) + all { + not(isShimError(result.out)), + commit(s.shimStepped(result, nonceOf(FrameSInput(mail.msg))), ShimReceive(mail)), + }, } action shimReceive = all { shimDeliverable != Set(), { nondet mail = oneOf(shimDeliverable) - nondet result = oneOf(shimResults(s.shim, FrameSInput(mail.msg))) - shimReceiveWith(mail, result) + shimReceiveWith(mail) }, } /// The shim gives up waiting for a lookup reply. - action shimLookupTimeoutWith(nonce: Nonce, result: ShimResult): bool = all { + action shimLookupTimeoutWith(nonce: Nonce): bool = all { s.shim.hasWaiter(nonce), - shimResults(s.shim, LookupTimeoutSInput(nonce)).contains(result), - not(isShimError(result.out)), - commit(s.shimStepped(result, None), ShimLookupTimeout(nonce)), + val result = shim(s.shim, LookupTimeoutSInput(nonce)) + all { + not(isShimError(result.out)), + commit(s.shimStepped(result, None), ShimLookupTimeout(nonce)), + }, } action shimLookupTimeout = all { s.shim.waiters.keys() != Set(), { nondet nonce = oneOf(s.shim.waiters.keys()) - nondet result = oneOf(shimResults(s.shim, LookupTimeoutSInput(nonce))) - shimLookupTimeoutWith(nonce, result) + shimLookupTimeoutWith(nonce) }, } @@ -974,7 +970,7 @@ module protocol { /// The wallet sends, and the shim routes the transaction as it should. action sends(input: SendInput, handedOver: int): bool = - walletSendWith(input, handedOver, shim(s.shim, SendTxSInput({ input: input, handedOver: handedOver }))) + walletSendWith(input, handedOver) /// The wallet sends a transaction and every hub address takes a frame. action sendToAll(payload: Payload): bool = @@ -982,7 +978,7 @@ module protocol { /// The wallet asks, and the shim asks the hub at `start`. action ask(query: TxId, start: int): bool = - walletGetWith(query, start, shim(s.shim, GetTxSInput({ query: query, start: start }))) + walletGetWith(query, start) /// `client`'s submission of `payload` under `nonce` reaches `id`, which acts /// as it should. @@ -1016,7 +1012,7 @@ module protocol { /// `mail` reaches the shim, which acts as it should. action deliverToShim(mail: Mail): bool = - shimReceiveWith(mail, shim(s.shim, FrameSInput(mail.msg))) + shimReceiveWith(mail) /// The reply an honest `id` with a truthful indexer gives, in the current /// state, to a lookup of `txid`. @@ -1025,7 +1021,7 @@ module protocol { /// The shim's wait under `nonce` times out. action timeOutLookup(nonce: Nonce): bool = - shimLookupTimeoutWith(nonce, shim(s.shim, LookupTimeoutSInput(nonce))) + shimLookupTimeoutWith(nonce) /// An honest indexer gives `verdict` on `payload`, with the effect that /// verdict has. diff --git a/zeronym/spec/protocol/shim.qnt b/zeronym/spec/protocol/shim.qnt index bcfb99d0..0deb2619 100644 --- a/zeronym/spec/protocol/shim.qnt +++ b/zeronym/spec/protocol/shim.qnt @@ -8,7 +8,9 @@ /// only the requests it is waiting on: it keeps no record of a migration once /// the wallet has its answer, which is why every lookup has to go to a hub. /// -/// `byzShimResults` is the wider relation a Byzantine shim draws from. +/// The shim is honest in every configuration: it sees every migration in the +/// clear and controls everything the wallet observes, so a Byzantine shim +/// would void every wallet-facing guarantee and nothing else. module shim { import basicSpells.* from "./spells/basicSpells" import types.* from "./types" @@ -100,6 +102,19 @@ module shim { pure def hasWaiter(state: ShimState, nonce: Nonce): bool = state.waiters.keys().contains(nonce) + /// The nonce a reply frame or a timeout refers to, if any. + pure def nonceOf(input: ShimInput): Option[Nonce] = + match input { + | FrameSInput(msg) => + match msg { + | Ack(ack) => Some(ack.nonce) + | LookupReply(reply) => Some(reply.nonce) + | _ => None + } + | LookupTimeoutSInput(nonce) => Some(nonce) + | _ => None + } + /// The hub a lookup sweep that started at `start` asks on its `attempt`-th /// try. pure def sweepTarget(state: ShimState, start: int, attempt: int): HubId = @@ -204,77 +219,4 @@ module shim { | FrameSInput(msg) => receive(state, msg) | LookupTimeoutSInput(nonce) => lookupTimeout(state, nonce) } - - // ------------------------------------------------------------------------ - // Byzantine relation - // ------------------------------------------------------------------------ - - pure val SEND_OBSERVATIONS = - Set(SentOk, SentToOperator, SendUnavailable, SendInvalid, SendTooLarge) - - /// Every lookup answer that can be built from `universe` and `heights`. - pure def lookupObservations(universe: Set[Payload], heights: Set[Height]): Set[LookupObs] = - tuples(universe, heights) - .map(((payload, height)) => Tx({ payload: payload, height: height })) - .union(Set(Pending, NotFound, Unavailable)) - - /// The nonce a reply frame or a timeout refers to, if any. - pure def nonceOf(input: ShimInput): Option[Nonce] = - match input { - | FrameSInput(msg) => - match msg { - | Ack(ack) => Some(ack.nonce) - | LookupReply(reply) => Some(reply.nonce) - | _ => None - } - | LookupTimeoutSInput(nonce) => Some(nonce) - | _ => None - } - - /// The transitions of a Byzantine shim. It sees every transaction in the - /// clear and is the only thing the wallet talks to, so it is free in what it - /// tells the wallet, what it hands the operator and what it sends the hubs: - /// - /// - a send is answered with anything, with nothing sent; or handed to the - /// operator whatever its class; or answered with anything while a frame - /// carrying any payload of `universe` goes to any one hub; - /// - a lookup is answered with anything, at once or when a reply or a - /// timeout would have resolved it. - /// - /// The honest transition is always a member. - pure def byzShimResults( - state: ShimState, - input: ShimInput, - universe: Set[Payload], - heights: Set[Height], - ): Set[ShimResult] = - val honest = Set(shim(state, input)) - val lies = lookupObservations(universe, heights) - match input { - | SendTxSInput(send) => - val unsent = SEND_OBSERVATIONS.map(obs => state.toSendDoneOutput(send.input, obs)) - val misrouted = match send.input { - | Clean(sent) => - tuples(universe, state.hubs.indices(), SEND_OBSERVATIONS).map(((payload, i, obs)) => - { ...state, nextNonce: state.nextNonce + 1 }.toDivertedOutput( - sent, - Set({ hub: state.hubs[i], msg: Submit({ nonce: state.nextNonce, payload: payload }) }), - obs)) - .union(Set(state.toForwardOutput(sent))) - | _ => Set() - } - honest.union(unsent).union(misrouted) - | GetTxSInput(lookup) => - honest.union(lies.map(result => state.toLookupDoneOutput(lookup.query, result))) - | _ => - match nonceOf(input) { - | None => honest - | Some(nonce) => - if (not(state.hasWaiter(nonce))) honest - else - val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } - val waiter = state.waiters.get(nonce) - honest.union(lies.map(result => forgotten.toLookupDoneOutput(waiter.query, result))) - } - } } diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt index 79ee69d1..6dfd37c9 100644 --- a/zeronym/spec/protocol/state.qnt +++ b/zeronym/spec/protocol/state.qnt @@ -267,11 +267,6 @@ module state { | _ => acc }) - /// The payloads the shim has seen in the clear: every transaction the wallet - /// handed it, and every body a hub returned to it. - pure def seenByShim(s: System): Set[Payload] = - s.sentByWallet().union(s.repliedBodies(ShimAddr)) - /// The payloads addressed to `hub` by anyone: what it has received or may /// yet receive. pure def seenByHub(s: System, hub: HubId): Set[Payload] = @@ -280,8 +275,7 @@ module state { /// What the Byzantine components, taken together, are able to reveal. pure def disclosable(s: System, roles: Roles): Set[Payload] = - (if (roles.shim == Byzantine) s.seenByShim() else Set()) - .union(if (roles.indexer == Byzantine) s.indexer.offered else Set()) + (if (roles.indexer == Byzantine) s.indexer.offered else Set()) .union(s.hubIds().filter(hub => roles.hubs.get(hub) == Byzantine) .map(hub => s.seenByHub(hub)).flatten()) diff --git a/zeronym/spec/protocol/tests/shimTest.qnt b/zeronym/spec/protocol/tests/shimTest.qnt index 32e80391..0720aac3 100644 --- a/zeronym/spec/protocol/tests/shimTest.qnt +++ b/zeronym/spec/protocol/tests/shimTest.qnt @@ -17,7 +17,6 @@ module shimTest { pure val pBigPlain = { ...payload("big-plain", PassThrough), oversize: true } pure val PAYLOADS = Set(pOrchard, pPlain, pJunk, pBig, pBigPlain) - pure val HEIGHTS = Set(0, 4) pure val SEND_INPUTS = PAYLOADS.map(p => Clean(p)).union(Set(Unreadable, EmptyBody)) pure val dispatching = initialShim(["h1", "h2"]) @@ -180,7 +179,7 @@ module shimTest { } // ------------------------------------------------------------------------ - // Totality and the Byzantine relation + // Totality // ------------------------------------------------------------------------ /// F11. The function answers every input in every state, and an invalid @@ -193,28 +192,4 @@ module shimTest { result.state.nextNonce >= state.nextNonce, isError(result.out) implies result.state == state, })) - - /// F12. The Byzantine relation contains the honest transition. - run byzantineContainsHonestTest = - assert(tuples(STATES, INPUTS).forall(((state, input)) => - byzShimResults(state, input, PAYLOADS, HEIGHTS).contains(shim(state, input)))) - - run byzantineShimTest = all { - // It can hand a migration to the operator. - assert(byzShimResults(dispatching, send(Clean(pOrchard), 2), PAYLOADS, HEIGHTS) - .contains(dispatching.toForwardOutput(pOrchard))), - // It can tell the wallet ok and send nothing. - assert(byzShimResults(single, send(Clean(pOrchard), 1), PAYLOADS, HEIGHTS) - .contains(single.toSendDoneOutput(Clean(pOrchard), SentOk))), - // It can answer a lookup with a transaction that has another txid. - assert(byzShimResults(dispatching, getTx("orchard", 0), PAYLOADS, HEIGHTS) - .contains(dispatching.toLookupDoneOutput("orchard", Tx({ payload: pPlain, height: 4 })))), - // It can send a hub something other than what the wallet sent. - assert(byzShimResults(dispatching, send(Clean(pOrchard), 2), PAYLOADS, HEIGHTS).exists(result => - result.out == DivertedOutput({ - payload: pOrchard, - frames: Set({ hub: "h2", msg: Submit({ nonce: 0, payload: pJunk }) }), - told: SentOk, - }))), - } } diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 658398f8..0ad276f7 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -11,89 +11,6 @@ /// same configuration, with the component taking the honest transition, which /// is always among those it may take. In the control the guarantee holds. -module byzShimTrust { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = byzShim).* from "../protocol" - - /// G1 needs the shim. It hands a migration to the operator. - run operatorSeesMigrationTest = - started - .then(block) - .then(walletSendWith(Clean(early), 1, s.shim.toForwardOutput(early))) - .expect(s.operator == Set(early) and early.class == OrchardTouching) - .expect(not(operatorBlind)) - - run operatorSeesMigrationControlTest = - started - .then(block) - .then(sends(Clean(early), 1)) - .expect(s.operator == Set() and s.net == Set(submitMail("h1", 0, early))) - .expect(operatorBlind) - - /// G2 needs the shim. It sees every migration in the clear and reveals one. - run shimDisclosesPlaintextTest = - started - .then(block) - .then(sends(Clean(early), 1)) - .then(byzDiscloseWith(early)) - .expect(s.disclosed == Set(early) and s.tpLearned() == Set(early) and s.onChain("early") == Absent) - .expect(not(queuedBytesConfidential)) - - run shimDisclosesPlaintextControlTest = - started - .then(block) - .then(sends(Clean(early), 1)) - .expect(s.tpLearned() == Set()) - .expect(queuedBytesConfidential) - - /// G3 needs the shim. Asked for one transaction, it serves another. - run shimServesAnotherTransactionTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(block) - .then(flush("h1", [early], Accepted)) - .then(walletGetWith("early", 0, s.shim.toLookupDoneOutput("early", Tx({ payload: tight, height: 3 })))) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: tight, height: 3 }), via: None })) - .expect(not(txidAuthenticity)) - - run shimServesAnotherTransactionControlTest = - started - .then(block) - .then(submitTo("h1", 0, early)) - .then(block) - .then(flush("h1", [early], Accepted)) - .then(lookUp("h1", 1, "early")) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) })) - .expect(txidAuthenticity) - - /// G4 needs the shim. It reports a transaction pending that no hub holds, - /// without asking one. - run shimInventsStatusTest = - started - .then(block) - .then(sends(Clean(early), 1)) - .then(walletGetWith("early", 0, s.shim.toLookupDoneOutput("early", Pending))) - .expect(lastEvent == Got({ query: "early", obs: Pending, via: None })) - .expect(s.queuedAt("h1") == Set() and s.lookups(ShimAddr) == Set()) - .expect(not(lookupValidityPerHub)) - - run shimInventsStatusControlTest = - started - .then(block) - .then(sends(Clean(early), 1)) - .then(lookUp("h1", 1, "early")) - .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) - .expect(lookupValidityPerHub) -} - module byzHubTrust { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index d2c99d58..1eda57a2 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -144,7 +144,7 @@ module types { type Role = Honest | Byzantine /// The role of each component. Each hub has its own. - type Roles = { shim: Role, hubs: HubId -> Role, indexer: Role } + type Roles = { hubs: HubId -> Role, indexer: Role } /// How a hub's view of the chain tip relates to the true height. /// From 34d926ac4ae59dbbc6b3e4698391fc582d3f7233 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 03:33:06 +0400 Subject: [PATCH 44/80] test(zeronym): check one hub in the protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 163 +++--- zeronym/spec/protocol/check.sh | 22 +- zeronym/spec/protocol/instances.qnt | 43 +- zeronym/spec/protocol/properties.qnt | 356 ++++-------- zeronym/spec/protocol/protocol.qnt | 508 +++++++----------- zeronym/spec/protocol/shim.qnt | 134 ++--- zeronym/spec/protocol/state.qnt | 184 +++---- zeronym/spec/protocol/tests/scenariosTest.qnt | 239 +++----- zeronym/spec/protocol/tests/shimTest.qnt | 137 ++--- zeronym/spec/protocol/tests/trustTest.qnt | 245 +++------ zeronym/spec/protocol/types.qnt | 10 +- 11 files changed, 750 insertions(+), 1291 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 14ad8232..8b56ddac 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -125,14 +125,14 @@ definitions they justify. | Area | What is modelled | Why | |---|---|---| | Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4. `divert.qnt` omits it | -| Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; waiter kinds; lookup starting at a nondeterministic hub (the cursor, S27), timeout and failover; submit fan-out including the prefix send (S29); submit mode `DispatchOnly \| AwaitVerdict` | S6-S9. `AwaitVerdict` is the one HTTP difference this model represents (who the wallet hears from); the others are listed out of scope | +| Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | | Hub | lifecycle; admission with all five refusals; queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash | S10-S19 | | Chain / indexer | height; per-txid status; what the indexer has been offered; verdict and lookup-answer relations | S15, S22 | | Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation; frame size classes | S20, S22 | -| Trust | role `Honest \| Byzantine` for shim, hub, hub indexer | S23 | -| Third party | a client of the hubs' public, unauthenticated address: looks up txids it knows; submits payloads it has learned and payloads of its own making; its payload knowledge is derived from what it can observe | S13, S25 | +| Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | +| Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned and payloads of its own making; its payload knowledge is derived from what it can observe | S13, S25 | | Network | drop, duplicate, delay, reorder; cannot forge | | -| Replication | `HUBS` is a set; one shared chain | S24 | +| Hubs | one; see [One hub](#one-hub) | S24 | | Tip | `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, `REORG_ALLOWANCE`, `STALE_WINDOW` and the wallet expiry floor as constants | S17, S26, S32 | ### Out of the model @@ -148,6 +148,7 @@ definitions they justify. | HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | | The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | +| More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | | Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties. Only the pure lemma "frame size is independent of content" is stated | @@ -156,16 +157,71 @@ definitions they justify. | More than one Byzantine component at once | The trust matrix is single-fault | | A model-based test harness for the Rust | Later work; see "Model-based testing, later" | +### One hub + +The spec checks one hub; production runs one or more, replicated: every shim +sends every submission to every hub, and each hub that receives a migration +queues and broadcasts it. The single hub is a scope choice, not a claim about +production. + +Lost, observed at `83133e3` with two hubs. Each row is a result a one-hub spec +cannot check; the gate rows and runs named are that commit's: + +| Result at `83133e3` | Backing there | Now | +|---|---|---| +| K2 cause (f): one hub says pending, the next poll starts at a hub that never received the frame and its not-found is final | `hubsDisagreeTest`; `fails replicated quietStep 40 statusNeverRegresses` | lost | +| K1c: told ok after a partial send. Under the replicate rule this is also an anonymity cost: the migration sits in a strict subset of the hubs' batches, and an observer of the broadcasts learns which | `toldOkAfterPrefixSendTest`; `reaches replicated step 40 wToldPrefixOnly` | lost | +| W14: duplicate publication by two hubs, and a second enclave holding the plaintext; accepted deliberately in production (`zeronym/shim/src/nym.rs:641-647`) | `publishedByBothHubsTest`; `reaches replicated quietStep 80 wPublishedByTwoHubs` | lost | +| W13: a lookup moves on after a timeout and the next hub answers | `lookupFailsOverOnTimeoutTest`; `reaches replicated step 40 wFailoverAnswered` | lost; see the lookup concern below | +| G4 holds with two honest hubs | `holds replicated lookupValidityPerHub` | lost as a check; argued below | +| G2 is required of every hub | `oneReplicaServesQueuedBodyTest` and control; `fails replicatedOneByz step 40 queuedBytesConfidential` | the leak survives as `hubServesQueuedBodyTest` on `byzHub`; that an honest replica beside it does not help is lost as a check | +| G4 is required of every hub, and the cursor can land on the lying one | `cursorLandsOnLyingReplicaTest` and control; `fails replicatedOneByz step 40 lookupValidityPerHub` | `hubDeniesQueuedTest` on `byzHub` survives. Lost: that the lie reaches the wallet while an honest replica holds the transaction, because the cursor chose the liar | +| G3 survives a Byzantine replica | `wrongTransactionIsRefusedTest`; `holds replicatedOneByz txidAuthenticity` | the run is on `byzHub` now | +| The honest hub keeps G8, G6a, G6b and G6c beside a Byzantine one | `honestReplicaKeepsItsGuaranteesTest`; `holds replicatedOneByz ackImpliesQueuedForHonestHubs` and the three `...ForHonestHubs` G6 rows | lost as a check; argued below. The Byzantine halves survive as `hubAcksWithoutAdmittingTest` and `hubAdmitsPastExpiryRuleTest` | +| "Some honest hub queued it after told ok" is not a guarantee | the same run, its first half | lost. Its one-hub shadow is K1b | + +What one hub keeps. `Unavailable` is exempt from G4, so a lookup that +production would complete at another address and the model answers +`Unavailable` loses only a success path. K2 still fails: its causes (a) to (e) +are one-hub runs. The already-known verdict stays reachable: published bytes +resubmitted to the same hub are queued and offered again (K2 b and c). + +**Composition, argued and not checked.** Assumption: hubs share no state but +the chain and the indexer, and each property below is about one hub's own +queue, acks, replies and schedule. + +- Compose per hub: G1 (the shim alone); G3 (the shim's txid check on each + reply); G4, which is why its name keeps "per hub": an answer was true at the + hub that gave it; G8; G6a, G6b and G6c, which read offer and verdict + heights, not verdict values, so another hub publishing first changes nothing + they read; K5. +- Compose only if every hub is honest: G2. One Byzantine replica holds the + same bytes and can give them away. +- Do not compose: K1 (two hubs add K1c and its anonymity cost); K2 (two hubs + add cause f); duplicate publication; which hub answers a lookup. + +**The lookup-routing concern, unexamined, not a bug.** Lookups are not +replicated. `each_target` (`zeronym/shim/src/nym.rs:746-797`, comment at +`:729-745`) starts at a rotating cursor and moves to the next address only on +a timeout; any other outcome from the first address that answers is final. +That is a choice of hub by apparent liveness on the read path, the pattern +`zeronym/shim/src/nym.rs:630-633` forbids for submits: whoever can make one hub +time out decides which hub answers a wallet's lookup, and learns which txids +it asks about. A one-hub spec cannot express it. + +Not checked: whether a rotated or dead address has any protocol-visible effect +beyond loss in the soup. + ### Assumptions -- **Roles.** The shim and the hubs run in enclaves and are honest in the - baseline. The shim, each hub and the hubs' indexer can each be made - Byzantine, one at a time. A Byzantine component draws its transitions from a +- **Roles.** The shim and the hub run in enclaves and are honest in the + baseline. The shim is honest in every configuration; the hub and its + indexer can each be made Byzantine, one at a time. A Byzantine component draws its transitions from a wider relation than the honest one; no message, state field or observation records which it drew. - **Network.** May lose, duplicate, delay and reorder frames. Cannot forge or read them. -- **Third party.** A client of the hubs' public address. It looks up txids it +- **Third party.** A client of the hub's public address. It looks up txids it knows and submits payloads it has learned or made. It cannot read or forge frames, so it does not know a nonce and cannot answer the shim. - **Nonces** are unique. A counter stands for an unguessable value. @@ -178,7 +234,7 @@ definitions they justify. the code does not enforce this. A hub whose flush is in flight does not look at the tip. - **Tip.** In every model a due flush has begun before the next block. - `TipTimely`: every running, idle hub asks for the tip at each block. An + `TipTimely`: a running, idle hub asks for the tip at each block. An honest indexer answers with the true height; a Byzantine one is asked just as often and controls only the answer. `TipMayRegress`: a tip report may trail the chain by up to `REORG_ALLOWANCE`. `TipMayLag`: a hub may @@ -187,7 +243,7 @@ definitions they justify. behind the chain (`freeRunNotSlowerThanChain`) and at most one flush interval ahead of it. - **Wallets.** A supported ("conforming") wallet sets an expiry at least - `MIN_WALLET_EXPIRY` after the height it builds at, and its frame reaches a + `MIN_WALLET_EXPIRY` after the height it builds at, and its frame reaches the hub within `DELIVERY_LAG` blocks. A wallet asks only about transactions it has sent. - **Honest indexer.** Answers lookups from chain state or "unavailable". A @@ -207,7 +263,7 @@ stateDiagram-v2 Inspect --> FailClosed: Unreadable or EmptyBody Inspect --> Framing: Clean and class OrchardTouching or Unparseable Framing --> FailClosed: oversize - Framing --> Dispatched: frames to a non-empty prefix of the hubs, fresh nonce each + Framing --> Dispatched: one frame to the hub, fresh nonce Framing --> FailClosed: no frame handed over Dispatched --> ToldOk Forwarded --> [*] @@ -219,13 +275,12 @@ stateDiagram-v2 ```mermaid stateDiagram-v2 - [*] --> Awaiting: send Lookup to the hub the cursor points at, fresh nonce - Awaiting --> Awaiting: timeout and hubs remain, fresh nonce to next hub + [*] --> Awaiting: send Lookup to the hub, fresh nonce Awaiting --> Awaiting: wrong-kind or unknown-nonce frame ignored Awaiting --> Pending: reply found, height 0, no body Awaiting --> Tx: reply found, body txid equals query Awaiting --> NotFound: reply not_found, or found that fails L4 - Awaiting --> Unavailable: reply error, or timeout on last hub + Awaiting --> Unavailable: reply error, or timeout Pending --> [*] Tx --> [*] NotFound --> [*] @@ -360,7 +415,7 @@ pure. | `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply`, `sizeOf` | | `indexer.qnt` | `indexer` | The chain and indexer as a relation: honest and Byzantine outputs, and their effect | | `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | -| `shim.qnt` | `shim` | `shim(state, input)`; routing, the lookup sweep, reply correlation | +| `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | | `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | | `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | | `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, A1-A3, the run vocabulary | @@ -400,12 +455,12 @@ goes. | Component | Inputs | Outputs | Seam in the implementation | |---|---|---|---| | `hub` | `SubmitHInput`, `LookupHInput` (with the indexer's answer), `TipHInput(height)`, `StaleHInput(estimate)`, `FlushDueHInput`, `VerdictHInput`, `FlushDoneHInput`, `DrainHInput`, `CrashHInput`, `RestartHInput` | `AckOutput`, `LookupReplyOutput`, `BroadcastOutput`, `RequeuedOutput`, `NoHubOutput`, `HubErrorOutput` | `Hub::admit`, `Hub::lookup` (`hub/src/server.rs`), `run_listener` (`hub/src/nym.rs`), `TipTracker::observe`, `cadence_height`, `flush` (`hub/src/batcher.rs`), `Queue::requeue`, `Queue::begin_draining` (`hub/src/queue.rs`) | -| `shim` | `SendTxSInput` (with how many hub addresses take a frame), `GetTxSInput` (with where the cursor points), `FrameSInput`, `LookupTimeoutSInput` | `ForwardOutput`, `DivertedOutput`, `SendDoneOutput`, `LookupSentOutput`, `LookupDoneOutput`, `NoShimOutput`, `ShimErrorOutput` | `send_transaction`, `divert`, `get_transaction` (`shim/src/intercept.rs`), `NymHandle::submit`, `get_transaction`, `deliver` (`shim/src/nym.rs`) | +| `shim` | `SendTxSInput` (with whether the transport took the frame), `GetTxSInput`, `FrameSInput`, `LookupTimeoutSInput` | `ForwardOutput`, `DivertedOutput`, `SendDoneOutput`, `LookupSentOutput`, `LookupDoneOutput`, `NoShimOutput`, `ShimErrorOutput` | `send_transaction`, `divert`, `get_transaction` (`shim/src/intercept.rs`), `NymHandle::submit`, `get_transaction`, `deliver` (`shim/src/nym.rs`) | | indexer | `BroadcastIInput`, `LookupIInput`, `AdvanceIInput`, `MineIInput` | `VerdictOutput`, `AnswerOutput`, `NoIndexerOutput` | the mock indexer in `hub/tests/common/mod.rs` | ### Roles -`ROLES` gives the shim, each hub and the indexer a role. An honest component +`ROLES` gives the hub and the indexer a role. An honest component takes exactly the transition its function gives. A Byzantine one takes any member of a finite set that contains it (F12): @@ -435,13 +490,11 @@ abstract indexer per hub was a decision of the design. One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields (`PAYLOADS`, `FLUSH_INTERVAL`, `ROLES`, `TIP`, ...). -| Module | Hubs | Roles (hubs / indexer) | Tip | -|---|---|---|---| -| `baseline` | 1 | H / H | timely | -| `byzHub` | 1 | **B** / H | timely | -| `byzIndexer` | 1 | H / **B** | timely for honest reports | -| `replicated` | 2 | H, H / H | timely | -| `replicatedOneByz` | 2 | H, **B** / H | timely | +| Module | Roles (hub / indexer) | Tip | +|---|---|---| +| `baseline` | H / H | timely | +| `byzHub` | **B** / H | timely | +| `byzIndexer` | H / **B** | timely for honest reports | The schedule is the shipped one scaled down, keeping the relations between the numbers: @@ -526,7 +579,7 @@ tried by hand, with the result shown, and reverted: | G1 | `shim` forwards an unparseable body | violated on `baseline` | | G2 | `hub` answers a queue hit with the queued body | violated on `baseline` | | G3 | `interpretReply` skips the txid comparison | **holds on `baseline`**; violated on `byzHub` and `byzIndexer` | -| G4 | `hub` answers not-found on a queue hit | violated on `baseline` and `replicated` | +| G4 | `hub` answers not-found on a queue hit | violated on `baseline` | | G8 | `hub` acks accepted without inserting | violated on `baseline` | | G6a | `hub` admits without the expiry check | violated on `baseline` | @@ -558,40 +611,27 @@ chain cannot pass a running, idle hub that has not asked. | G1 | holds (`baseline`) | holds (`byzHub`) | holds (`byzIndexer`) | | G2 | holds (`baseline`) | **required**: `hubServesQueuedBodyTest` | **required**: `indexerServesUnpublishedBodyTest`. One endpoint suffices | | G3 | holds (`baseline`) | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | -| G4 | holds (`baseline`, `replicated`) | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | +| G4 | holds (`baseline`) | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | | G8 | holds (`baseline`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | | G6a | holds (`baseline`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | | G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | A3 | not run (TLC, `baseline`) | **required**: `hubAdmitsWhileDrainingTest` | not run | -One Byzantine replica out of two (`replicatedOneByz`): - -| Property | Observed | Backing | -|---|---|---| -| G2 | required of every hub | `oneReplicaServesQueuedBodyTest` | -| G4 | required of every hub | `cursorLandsOnLyingReplicaTest` | -| G3 | holds | simulation; `wrongTransactionIsRefusedTest` | -| G8, G6a, G6b, G6c for the honest hub | hold | simulation of `ackImpliesQueuedForHonestHubs`, `offeredBeforeExpiryForHonestHubs`, `conformingFirstOfferBeforeExpiryForHonestHubs`, `conformingFirstOfferJudgedBeforeExpiryForHonestHubs`; `honestReplicaKeepsItsGuaranteesTest` | -| "some honest hub queued it" after told ok | not a guarantee | `honestReplicaKeepsItsGuaranteesTest` | - -The `...ForHonestHubs` names are the same predicates restricted to the hubs -whose role is honest. They are not weaker properties. - There is no Byzantine-shim column: the shim sees every migration in plaintext and controls everything the wallet observes, so every wallet-facing guarantee assumes an honest (attested) shim. G3 is the only wallet-facing guarantee that survives a Byzantine hub or indexer, and it authenticates the txid only. G1 -depends on the shim alone. Replication does not dilute trust: one Byzantine -replica is enough to void G2 and G4. A3 needs the hub: its "required" +depends on the shim alone. With more than one hub, G2 is required of every +hub (argued, see [One hub](#one-hub)). A3 needs the hub: its "required" cell is a scripted step, and its "holds" cells are the unrun TLC property. ### Known gaps, with every component honest | Id | What is lost | Where | Form | Observed | Scripted runs | |---|---|---|---|---|---| -| K1 | Told ok does not mean any hub ever admits it | `baseline`, `replicated` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered`, `wToldPrefixOnly` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest`, `toldOkAfterPrefixSendTest` | -| K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline`, `replicated` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest`, `hubsDisagreeTest` | +| K1 | Told ok does not mean the hub ever admits it | `baseline` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | +| K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | | K3 | G6a for a tight-expiry transaction: admitted against a tip reported below a boundary already flushed | `flakyTip` | violated invariant | violated, as predicted | `tightExpiryAdmittedBehindFlushedBoundaryTest` | | K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | | K4 | G6a, and G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | @@ -608,13 +648,13 @@ that pays for blocks arriving while the batch is in flight, and nothing in the code bounds a flight in blocks. K1 is not stated as a violated invariant because the invariant is false on the -ordinary success path too: the wallet is told ok before any hub has the +ordinary success path too: the wallet is told ok before the hub has the frame. In `toldOkAndNeverDeliveredTest` the run ends with the frame undelivered, and nothing obliges the network ever to deliver it. ### Witnesses -Each has a scripted run and is counted in tier 3b. +Each has a scripted run and, except W15 and W18, is counted in tier 3b. | Id | Witness | Name | Configuration | |---|---|---|---| @@ -624,21 +664,17 @@ Each has a scripted run and is counted in tier 3b. | W8 | **Accepted disclosure**: a third party that knows a txid learns it is queued. The hub withholds the bytes, not the fact. See the quoted comment under [Scope](#scope) | `wQueuedDisclosed` | `baseline` | | W9 | a queued payload the hub cannot parse is asked for and missed | `wUnparseableMissed` | `baseline` | | W12 | a queue holds more than its capacity after a requeue | `wQueueOverCapacity` | `baseline` | -| W13 | a lookup moves on after a timeout and the next hub answers | `wFailoverAnswered` | `replicated` | -| W14 | two hubs publish the same payload in their own flushes | `wPublishedByTwoHubs` | `replicated` | -| W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | `wPrematureFlush` | `byzIndexer` | +| W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | scripted run `tipAheadOfChainFlushesEarlyTest` (hub specification) | `byzIndexer` | | W16 | **Twin served**: the wallet is served a twin of what it sent, and a transaction at a false height; G3 holds throughout | `wTwinServed`, `wFalseHeightServed` | `byzHub` | | W17 | the third party's own payload is queued | `wThirdPartyPayloadQueued` | `baseline` | -| W18 | **Early flush by the free-running clock**: a stale hub's clock is ahead of the chain and it flushes before the true boundary, every component honest | `wEarlyFreeRunFlush` | `staleLag` | +| W18 | **Early flush by the free-running clock**: a stale hub's clock is ahead of the chain and it flushes before the true boundary, every component honest | scripted run `freeRunningClockFlushesEarlyTest` (hub specification) | `staleLag` | Non-vacuity: for each guarantee, a state where its antecedent holds, reached on every configuration where the guarantee is claimed: `vOperatorBlind`, `vQueuedBytesConfidential`, `vTxidAuthenticity`, `vLookupValidityPerHub` (the -log has a pending, a served transaction and a not-found), `vToldImpliesQueued`, -`vOfferedBeforeExpiry`, `vConformingFirstOfferBeforeExpiry`, -`vConformingFirstOfferJudged`, `vAckImpliesQueued`, -and on `flakyTip` also `vConformingOfferAdmittedBehind` (a conforming first -offer of a payload admitted while the hub's tip was behind the chain). +log has a pending, a served transaction and a not-found), `vAckImpliesQueued`. +The antecedents of G6a, G6b and G6c are reachability rows of the hub +specification. ### Two-state properties: not checked @@ -648,15 +684,15 @@ form, and typechecked. **None has been run.** | Id | Name | What it says | Class | |---|---|---|---| | A1 | `chainMonotone` | A transaction's chain status never moves backwards | assumption about the environment | -| A2 | `neverEvict` | An entry leaves a hub's queue only into a flush, or because the hub went down | guarantee | +| A2 | `neverEvict` | An entry leaves the hub's queue only into a flush, or because the hub went down | guarantee | | A3 | `drainIsFinal` | A draining honest hub's queue gains only what a flush hands back | guarantee | -A3 is stated over the honest hubs only. Draining is an admission rule, and a +A3 is stated of an honest hub only. Draining is an admission rule, and a Byzantine hub is not bound by admission rules: `hubAdmitsWhileDrainingTest` takes a submission into the queue after the drain began, and its control refuses the same frame. That run asserts the step, because the simulator -does not check `temporal` definitions. A2 is stated over every hub: the -Byzantine hub relation only ever adds to a queue. +does not check `temporal` definitions. A2 is stated whatever the hub's role: +the Byzantine hub relation only ever adds to a queue. No liveness property is claimed: the network may lose everything, and nobody waits for an ack. @@ -741,7 +777,7 @@ unacceptable. The schedule is now scaled with a margin of 2, flight time is bounded by `MAX_FLIGHT_BLOCKS`, and G6c is checked at the verdict. Observed: G6c holds on `baseline`, `flakyTip` and `byzShim`, and for the honest hub of `replicatedOneByz`; it fails wherever G6b fails, and on `flakyTipSlowFlight` -where G6b holds. +where G6b holds. (`byzShim` and `replicatedOneByz` have since been removed.) **6. The second clause of G1, "and no lookup", is not stated.** No output of the shim function routes a lookup to the operator, so the clause would hold by @@ -954,7 +990,6 @@ quint verify --main=byzHub --invariant=txidAuthenticity --max-steps=12 zeronym/s quint verify --main=byzIndexer --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzIndexer --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzIndexer --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=flakyTip --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt ``` The same for G6c, and the configurations whose point is a violation (each @@ -963,12 +998,6 @@ should report one; `flakyTipNoSlack` and `flakyTipSlowFlight` satisfy every ```sh quint verify --main=baseline --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=flakyTip --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=flakyTipNoSlack --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=flakyTipSlowFlight --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=staleLag --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=staleLagWithSlack --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=ackedIsHeldOrSettled --max-steps=12 zeronym/spec/protocol/instances.qnt ``` Several of the scripted counterexamples are longer than 12 steps, so @@ -996,8 +1025,6 @@ quint verify --main=baseline --invariant='not(wToldRefusedEverywhere)' --max-ste quint verify --main=baseline --invariant='not(wToldNeverDelivered)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzHub --invariant='not(wTwinServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzHub --invariant='not(wFalseHeightServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzIndexer --invariant='not(wPrematureFlush)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=staleLag --invariant='not(wEarlyFreeRunFlush)' --max-steps=12 zeronym/spec/protocol/instances.qnt ``` The two-state properties, with TLC (also `QUINT_TLC=1 sh check.sh`): diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 565535d8..4a8f041e 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -74,9 +74,9 @@ finish() { SPELLS="spells/basicSpells.qnt spells/soup.qnt" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt" -INSTANCES="baseline byzHub byzIndexer replicated replicatedOneByz" -SCENARIOS="baselineScenarios replicatedScenarios byzHubScenarios" -TRUST="byzHubTrust byzIndexerTrust replicatedOneByzTrust" +INSTANCES="baseline byzHub byzIndexer" +SCENARIOS="baselineScenarios byzHubScenarios" +TRUST="byzHubTrust byzIndexerTrust" fail() { echo "FAIL $1" @@ -282,8 +282,6 @@ job holds baseline operatorBlind queuedBytesConfidential txidAuthenti ackImpliesQueued wellFormed job holds byzHub operatorBlind txidAuthenticity wellFormed job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed -job holds replicated lookupValidityPerHub wellFormed -job holds replicatedOneByz txidAuthenticity ackImpliesQueuedForHonestHubs wellFormed # The trust matrix: each guarantee fails once the component it depends on is # Byzantine. The schedule guarantees' rows are in tier 4. @@ -292,12 +290,9 @@ job fails byzHub step 40 lookupValidityPerHub job fails byzHub step 40 ackImpliesQueued job fails byzIndexer step 40 queuedBytesConfidential job fails byzIndexer step 40 lookupValidityPerHub -job fails replicatedOneByz step 40 queuedBytesConfidential -job fails replicatedOneByz step 40 lookupValidityPerHub # The known gaps, with every component honest. job fails baseline quietStep 40 statusNeverRegresses # K2 -job fails replicated quietStep 40 statusNeverRegresses # K2 finish @@ -329,17 +324,6 @@ job reaches byzHub quietStep 40 \ job reaches byzIndexer quietStep 40 \ vOperatorBlind vTxidAuthenticity vAckImpliesQueued \ -- operatorBlind txidAuthenticity ackImpliesQueued wellFormed -# W13, K1c. -job reaches replicated step 40 \ - wFailoverAnswered wToldPrefixOnly \ - -- lookupValidityPerHub wellFormed -# W14. -job reaches replicated quietStep 80 \ - vLookupValidityPerHub wPublishedByTwoHubs \ - -- lookupValidityPerHub wellFormed -job reaches replicatedOneByz quietStep 40 \ - vTxidAuthenticity vAckImpliesQueued \ - -- txidAuthenticity ackImpliesQueuedForHonestHubs wellFormed finish echo "---- 4 hub specification (TLC, exhaustive)" diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index c73fcc95..a320c5bc 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -43,7 +43,7 @@ module configs { // Configurations // ------------------------------------------------------------------------ - pure val allHonest: Roles = { hubs: Map("h1" -> Honest), indexer: Honest } + pure val allHonest: Roles = { hub: Honest, indexer: Honest } /// One hub, the mixnet transport, every component honest, a timely tip. /// @@ -65,7 +65,6 @@ module configs { payloads: Set(early, late, tight, junk, plain), twins: Set(earlyTwin), tpPayloads: Set(garbage, bloat), - hubs: ["h1"], flushInterval: 3, miningMargin: 2, deliveryLag: 1, @@ -85,19 +84,9 @@ module configs { } // One Byzantine component at a time. - pure val byzHub = { ...baseline, roles: { ...allHonest, hubs: Map("h1" -> Byzantine) } } + pure val byzHub = { ...baseline, roles: { ...allHonest, hub: Byzantine } } pure val byzIndexer = { ...baseline, roles: { ...allHonest, indexer: Byzantine } } - // Two hubs sharing one chain. - pure val replicated = { - ...baseline, - hubs: ["h1", "h2"], - roles: { ...allHonest, hubs: Map("h1" -> Honest, "h2" -> Honest) }, - } - pure val replicatedOneByz = { - ...replicated, - roles: { ...allHonest, hubs: Map("h1" -> Honest, "h2" -> Byzantine) }, - } } module baseline { @@ -141,31 +130,3 @@ module byzIndexer { assert(not(staleSlackFits)), } } - -module replicated { - import types.* from "./types" - import configs.* - import protocol(CONFIG = replicated).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} - -module replicatedOneByz { - import types.* from "./types" - import configs.* - import protocol(CONFIG = replicatedOneByz).* from "./protocol" - - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } -} diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index f985878a..061de30f 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -29,7 +29,7 @@ module properties { // The audit record // ------------------------------------------------------------------------ - /// The true answer to a lookup for `query` at `hub`, read off the hub's + /// The true answer to a lookup for `query` at the hub, read off its /// queue and the chain. The queue comes first, as it does in the hub. /// /// `NotFound` is the true answer for a transaction that is with a flush and @@ -46,8 +46,8 @@ module properties { /// > is the wrong trade. /// /// It is also the true answer for a queued payload that does not parse. - pure def truth(s: System, hub: HubId, query: TxId): LookupObs = - if (s.queuedAt(hub).exists(payload => payload.txid == Some(query))) + pure def truth(s: System, query: TxId): LookupObs = + if (s.hub.queued().exists(payload => payload.txid == Some(query))) Pending else if (s.indexer.txs.keys().contains(query)) val tx = s.indexer.txs.get(query) @@ -55,8 +55,8 @@ module properties { else NotFound - pure def initialAudit(s: System): Audit = { - everQueued: s.hubIds().mapBy(_ => Set()), + pure val initialAudit: Audit = { + everQueued: Set(), admitted: Map(), offers: Set(), verdicts: Set(), @@ -75,83 +75,75 @@ module properties { | WQueueFull => if (phase == Draining) HubDraining else Full } + /// How many times the hub has offered `payload`. + pure def offersOf(audit: Audit, payload: Payload): int = + audit.offers.filter(offer => offer.payload == payload).size() + /// The audit record after one step, from the states before and after it. pure def advance(audit: Audit, pre: System, post: System): Audit = - val hubs = post.hubIds() - val entered = hubs.map(hub => - post.queuedAt(hub).map(payload => (hub, payload))).flatten() // A flush that was idle and is now broadcasting has just put its batch in // flight. - val offered = hubs.map(hub => - match post.hubs.get(hub).flush { - | Broadcasting(flush) => - if (pre.hubs.get(hub).flush == Idle) - flush.batch.keys().map(payload => { - hub: hub, - payload: payload, - height: post.height(), - attempt: flush.batch.get(payload), - nth: audit.offers.filter(offer => offer.hub == hub and offer.payload == payload).size(), - }) - else Set() - | Idle => Set() - }).flatten() + val offered = match post.hub.flush { + | Broadcasting(flush) => + if (pre.hub.flush == Idle) + flush.batch.keys().map(payload => { + payload: payload, + height: post.height(), + attempt: flush.batch.get(payload), + nth: audit.offersOf(payload), + }) + else Set() + | Idle => Set() + } // An entry that was awaiting a verdict and no longer is, in a flush still // in flight, has just had its answer. A node judged it unless it was set // aside for requeue. - val judged = hubs.map(hub => - match pre.hubs.get(hub).flush { - | Broadcasting(before) => - match post.hubs.get(hub).flush { - | Broadcasting(after) => - before.batch.keys().exclude(after.batch.keys()).map(payload => { - hub: hub, - payload: payload, - height: post.height(), - nth: audit.offers.filter(offer => offer.hub == hub and offer.payload == payload).size() - 1, - final: not(after.unplaced.keys().contains(payload)), - }) - | Idle => Set() - } - | Idle => Set() - }).flatten() + val judged = match pre.hub.flush { + | Broadcasting(before) => + match post.hub.flush { + | Broadcasting(after) => + before.batch.keys().exclude(after.batch.keys()).map(payload => { + payload: payload, + height: post.height(), + nth: audit.offersOf(payload) - 1, + final: not(after.unplaced.keys().contains(payload)), + }) + | Idle => Set() + } + | Idle => Set() + } // A flush that has ended, other than a final one, gave up on every entry // nothing judged that is not back in the queue. - val dropped = hubs.map(hub => - match pre.hubs.get(hub).flush { - | Broadcasting(flush) => - if (post.hubs.get(hub).flush == Idle and not(flush.final) and post.hubs.get(hub).phase != Down) - flush.unplaced.keys().exclude(post.queuedAt(hub)).map(payload => - { hub: hub, payload: payload, attempts: flush.unplaced.get(payload) }) - else Set() - | Idle => Set() - }).flatten() + val dropped = match pre.hub.flush { + | Broadcasting(flush) => + if (post.hub.flush == Idle and not(flush.final) and post.hub.phase != Down) + flush.unplaced.keys().exclude(post.hub.queued()).map(payload => + { payload: payload, attempts: flush.unplaced.get(payload) }) + else Set() + | Idle => Set() + } // The lookups the shim is waiting on, or was until this step, each widen - // their window by what is true at the hub they went to. + // their window by what is true at the hub. val waiting = pre.shim.waiters.keys().union(post.shim.waiters.keys()) val windows = post.lookups(ShimAddr) .filter(lookup => waiting.contains(lookup._1)) .fold(audit.windows, (acc, lookup) => val seen = if (acc.keys().contains(lookup._1)) acc.get(lookup._1) else Set() - acc.put(lookup._1, seen.union(Set(truth(post, lookup._2, lookup._3))))) + acc.put(lookup._1, seen.union(Set(truth(post, lookup._2))))) val refused = post.net.exclude(pre.net).fold(Set(), (acc, mail) => match mail.msg { | Ack(ack) => match ack.ack { - | WRefused(code) => - match mail.src { - | HubAddr(hub) => acc.union(Set(refusalBehind(code, pre.hubs.get(hub).phase))) - | _ => acc - } + | WRefused(code) => acc.union(Set(refusalBehind(code, pre.hub.phase))) | WAccepted => acc } | _ => acc }) { - everQueued: hubs.mapBy(hub => audit.everQueued.get(hub).union(post.queuedAt(hub))), - admitted: entered.fold(audit.admitted, (acc, entry) => - if (acc.keys().contains(entry)) acc - else acc.put(entry, { height: post.height(), tip: post.hubs.get(entry._1).observedTip() })), + everQueued: audit.everQueued.union(post.hub.queued()), + admitted: post.hub.queued().fold(audit.admitted, (acc, payload) => + if (acc.keys().contains(payload)) acc + else acc.put(payload, { height: post.height(), tip: post.hub.observedTip() })), offers: audit.offers.union(offered), verdicts: audit.verdicts.union(judged), windows: windows, @@ -195,9 +187,8 @@ module properties { /// G4. Every lookup answer other than `Unavailable` was true, at the hub /// that gave it, at some point between the request and the answer. /// - /// It is a statement about one request and one hub. It does not say that - /// successive answers agree, nor that hubs agree with each other; see - /// `statusNeverRegresses`. + /// It is a statement about one request. It does not say that successive + /// answers agree; see `statusNeverRegresses`. pure def lookupValidityPerHubIn(s: System, audit: Audit): bool = s.events().forall(event => match event { @@ -217,59 +208,51 @@ module properties { /// mined `miningMargin` blocks after the true height it was published at. pure def offeredInTime(s: System, offer: Offer): bool = match offer.payload.expiry { - | Some(expiry) => expiry >= offer.height + s.hubs.get(offer.hub).params.miningMargin + | Some(expiry) => expiry >= offer.height + s.hub.params.miningMargin | None => true } - /// Whether `payload` reached `hub` as a supported wallet's would: it honours - /// the expiry floor, and it first entered the queue within the delivery lag - /// of the height it was built at. - pure def isConformingAndTimely(s: System, audit: Audit, hub: HubId, payload: Payload): bool = - val params = s.hubs.get(hub).params + /// Whether `payload` reached the hub as a supported wallet's would: it + /// honours the expiry floor, and it first entered the queue within the + /// delivery lag of the height it was built at. + pure def isConformingAndTimely(s: System, audit: Audit, payload: Payload): bool = + val params = s.hub.params and { conforming(payload, params.minWalletExpiry), - audit.admitted.keys().contains((hub, payload)), - audit.admitted.get((hub, payload)).height <= payload.created + params.deliveryLag, + audit.admitted.keys().contains(payload), + audit.admitted.get(payload).height <= payload.created + params.deliveryLag, } // G6 comes in three parts. G6a and G6b are about the moment a flush begins: // how much of the mining margin is left when the hub hands the batch over. // They do not say a node accepts the transaction, because the chain may move // while the batch is in flight. G6c is about the moment a node judges it. - - /// G6a. Every transaction one of `hubs` offers is offered with the mining - /// margin to spare: whatever was admitted, on every attempt. A claim about - /// the margin left at the offer, not about acceptance. - pure def offeredBeforeExpiryIn(s: System, audit: Audit, hubs: Set[HubId]): bool = - audit.offers.forall(offer => hubs.contains(offer.hub) implies offeredInTime(s, offer)) - - /// G6b. The same, for supported wallets only and for the first time a hub - /// offers the transaction. It says nothing about a later offer of an entry - /// that was requeued; see `conformingEveryOfferBeforeExpiry`. Like G6a it is - /// a claim about the margin left at the offer. - pure def conformingFirstOfferBeforeExpiryIn(s: System, audit: Audit, hubs: Set[HubId]): bool = + // The hub specification checks all three exhaustively; here they are the + // closing assertions of scripted runs. + + /// G6a. Every transaction the hub offers is offered with the mining margin + /// to spare: whatever was admitted, on every attempt. A claim about the + /// margin left at the offer, not about acceptance. + pure def offeredBeforeExpiryIn(s: System, audit: Audit): bool = + audit.offers.forall(offer => offeredInTime(s, offer)) + + /// G6b. The same, for supported wallets only and for the first time the hub + /// offers the transaction. Like G6a it is a claim about the margin left at + /// the offer. + pure def conformingFirstOfferBeforeExpiryIn(s: System, audit: Audit): bool = audit.offers.forall(offer => - and { - hubs.contains(offer.hub), - offer.nth == 0, - isConformingAndTimely(s, audit, offer.hub, offer.payload), - } implies offeredInTime(s, offer)) + offer.nth == 0 and isConformingAndTimely(s, audit, offer.payload) implies offeredInTime(s, offer)) /// G6c. The end-to-end claim: when a node judges the first offer of a /// supported wallet's transaction, the transaction has not expired. It can /// still be mined in the next block, so the node does not turn it away for /// its expiry. - /// - /// The margin is what pays for the blocks that arrive while the batch is in - /// flight. G6c therefore needs G6b and one thing more: that fewer blocks - /// than the mining margin arrive during a flush (`flightWithinMargin`). - pure def conformingFirstOfferJudgedBeforeExpiryIn(s: System, audit: Audit, hubs: Set[HubId]): bool = + pure def conformingFirstOfferJudgedBeforeExpiryIn(s: System, audit: Audit): bool = audit.verdicts.forall(verdict => and { - hubs.contains(verdict.hub), verdict.nth == 0, verdict.final, - isConformingAndTimely(s, audit, verdict.hub, verdict.payload), + isConformingAndTimely(s, audit, verdict.payload), } implies match verdict.payload.expiry { | Some(expiry) => expiry > verdict.height @@ -280,13 +263,9 @@ module properties { /// is down holds nothing and knows nothing; every nonce in use was minted. pure def wellFormedIn(s: System): bool = and { - s.hubIds().forall(hub => - val state = s.hubs.get(hub) - and { - state.queued().forall(payload => - state.queue.get(payload) >= 0 and state.queue.get(payload) <= state.params.maxAttempts), - state.phase == Down implies state == downHub(state.params), - }), + s.hub.queued().forall(payload => + s.hub.queue.get(payload) >= 0 and s.hub.queue.get(payload) <= s.hub.params.maxAttempts), + s.hub.phase == Down implies s.hub == downHub(s.hub.params), s.shim.waiters.keys().forall(nonce => nonce < s.shim.nextNonce), s.net.forall(mail => match mail.msg { @@ -298,11 +277,10 @@ module properties { }), } - /// G8. An accepted ack from one of `hubs` is for a payload that hub had - /// queued by the time it acked. It holds whether or not anyone waits for - /// the ack. - pure def ackImpliesQueuedIn(s: System, audit: Audit, hubs: Set[HubId]): bool = - hubs.forall(hub => s.ackedAt(hub).subseteq(audit.everQueued.get(hub))) + /// G8. An accepted ack from the hub is for a payload it had queued by the + /// time it acked. It holds whether or not anyone waits for the ack. + pure def ackImpliesQueuedIn(s: System, audit: Audit): bool = + s.acked().subseteq(audit.everQueued) // ------------------------------------------------------------------------ // Known gaps @@ -312,8 +290,7 @@ module properties { /// served, it is not later pending or missing; once pending, it is not later /// missing. This does not hold. Replies are reordered; a published /// transaction can be queued again; a flush empties the queue before the - /// chain has the batch; the node can reject at flush; and two hubs need not - /// agree. + /// chain has the batch; and the node can reject at flush. pure def statusNeverRegressesIn(s: System): bool = val log = s.wallet.log tuples(log.indices(), log.indices()).forall(((i, j)) => @@ -338,73 +315,35 @@ module properties { | _ => true }) - /// K5. A payload a hub has acknowledged is accounted for: the hub still - /// holds it, queued or in flight; or the chain has it; or a node judged it - /// when this hub offered it, and said accepted, already known or rejected. - /// Being offered is not enough: an offer nothing judged settles nothing. - /// - /// This does not hold. The queue lives in memory: a crash after the ack - /// loses it, and so does a final flush that finds the indexer unreachable. - pure def ackedIsHeldOrSettledIn(s: System, audit: Audit): bool = - s.hubIds().forall(hub => - s.ackedAt(hub).forall(payload => - or { - s.queuedAt(hub).contains(payload), - s.inFlightAt(hub).contains(payload), - s.indexer.published().contains(payload), - audit.verdicts.exists(verdict => - verdict.hub == hub and verdict.payload == payload and verdict.final), - })) - - /// K6. G6b without its restriction to the first offer. This does not hold - /// once a hub is stale: requeue judges an entry at the observed tip, which - /// has stopped, while the flush schedule runs on. - pure def conformingEveryOfferBeforeExpiryIn(s: System, audit: Audit): bool = - audit.offers.forall(offer => isConformingAndTimely(s, audit, offer.hub, offer.payload) implies offeredInTime(s, offer)) - - // K1. "Told ok" promises nothing about any hub. It is - // stated as three reachable states, not as a violated invariant, because the - // invariant is false on the ordinary success path too: the wallet is told - // before any hub has the frame. - - /// The shim's submissions of `payload`, as (hub, nonce). - pure def shimSubmissionsOf(s: System, payload: Payload): Set[(HubId, Nonce)] = - s.hubIds().map(hub => - s.submissions(ShimAddr, hub).filter(submit => submit._2 == payload).map(submit => (hub, submit._1)) - ).flatten() - - pure def neverQueued(s: System, audit: Audit, payload: Payload): bool = - s.hubIds().forall(hub => not(audit.everQueued.get(hub).contains(payload))) - - /// K1a. The wallet was told ok; every submission that reached a hub was - /// refused; no hub ever queued the payload. + // K1. "Told ok" promises nothing about the hub. It is stated as two + // reachable states, not as a violated invariant, because the invariant is + // false on the ordinary success path too: the wallet is told before the hub + // has the frame. + + /// The nonces of the shim's submissions of `payload`. + pure def shimSubmissionsOf(s: System, payload: Payload): Set[Nonce] = + s.submissions(ShimAddr).filter(submit => submit._2 == payload).map(submit => submit._1) + + /// K1a. The wallet was told ok; every submission that reached the hub was + /// refused; the hub never queued the payload. pure def wToldRefusedEverywhereIn(s: System, audit: Audit): bool = s.toldOk().exists(payload => val submitted = s.shimSubmissionsOf(payload) and { - neverQueued(s, audit, payload), + not(audit.everQueued.contains(payload)), submitted != Set(), - submitted.forall(submit => - s.acks(submit._1, ShimAddr).exists(ack => ack._1 == submit._2 and ack._2 != WAccepted)), + submitted.forall(nonce => s.acks(ShimAddr).exists(ack => ack._1 == nonce and ack._2 != WAccepted)), }) - /// K1b. The wallet was told ok; no hub has answered any of the frames; no - /// hub ever queued the payload. The network may leave it so forever. + /// K1b. The wallet was told ok; the hub has answered none of the frames and + /// never queued the payload. The network may leave it so forever. pure def wToldNeverDeliveredIn(s: System, audit: Audit): bool = s.toldOk().exists(payload => and { - neverQueued(s, audit, payload), - s.shimSubmissionsOf(payload).forall(submit => - not(s.acks(submit._1, ShimAddr).exists(ack => ack._1 == submit._2))), + not(audit.everQueued.contains(payload)), + s.shimSubmissionsOf(payload).forall(nonce => not(s.acks(ShimAddr).exists(ack => ack._1 == nonce))), }) - /// K1c. The wallet was told ok though the sweep stopped early: some hub was - /// never sent the payload. - pure def wToldPrefixOnlyIn(s: System): bool = - s.toldOk().exists(payload => - val reached = s.shimSubmissionsOf(payload).map(submit => submit._1) - reached != Set() and reached != s.hubIds()) - // ------------------------------------------------------------------------ // Witnesses // ------------------------------------------------------------------------ @@ -440,16 +379,15 @@ module properties { pure def wRefused(audit: Audit, refusal: Refusal): bool = audit.refusals.contains(refusal) - /// W5. An entry nothing judged is back in a queue. + /// W5. An entry nothing judged is back in the queue. pure def wRequeuedIn(s: System): bool = - s.hubIds().exists(hub => - s.queuedAt(hub).exists(payload => s.hubs.get(hub).queue.get(payload) > 0)) + s.hub.queued().exists(payload => s.hub.queue.get(payload) > 0) /// W6. A requeue dropped an entry that could no longer survive the next /// flush: it was given up on while it still had attempts left. pure def wDroppedExpiredIn(s: System, audit: Audit): bool = audit.dropped.exists(entry => - entry.attempts + 1 <= s.hubs.get(entry.hub).params.maxAttempts) + entry.attempts + 1 <= s.hub.params.maxAttempts) /// W7. A requeue dropped an entry that was out of attempts: one with no /// expiry, which nothing else would ever have stopped. @@ -457,7 +395,7 @@ module properties { audit.dropped.exists(entry => entry.payload.expiry == None) /// W8. The accepted disclosure: a third party that knows a txid learns that - /// it is queued at a hub. The hub withholds the bytes; it does not withhold + /// it is queued at the hub. The hub withholds the bytes; it does not withhold /// the fact. The implementation leaves this open on purpose /// (`zeronym/hub/src/server.rs`, in `Hub::lookup`): /// @@ -468,14 +406,10 @@ module properties { /// > is left open deliberately. pure def wQueuedDisclosedIn(s: System): bool = tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => - and { - lookup._1 == reply._1, - lookup._2 == reply._2, - reply._3 == WFound({ body: None, height: MEMPOOL_HEIGHT }), - }) + lookup._1 == reply._1 and reply._2 == WFound({ body: None, height: MEMPOOL_HEIGHT })) - /// W9. A hub holds a payload it cannot parse; the wallet that sent it asks - /// that hub for it and is told not found. An entry without a txid can never + /// W9. The hub holds a payload it cannot parse; the wallet that sent it asks + /// for it and is told not found. An entry without a txid can never /// be hit. pure def wUnparseableMissedIn(s: System): bool = tuples(s.events(), s.lookups(ShimAddr)).exists(((event, lookup)) => @@ -484,54 +418,16 @@ module properties { and { got.obs == NotFound, got.via == Some(lookup._1), - s.queuedAt(lookup._2).exists(payload => + s.hub.queued().exists(payload => payload.txid == None and walletTxid(payload) == got.query), } | _ => false }) - /// W12. A queue holds more than its capacity: requeue honours the older + /// W12. The queue holds more than its capacity: requeue honours the older /// promise over the newer limit. pure def wQueueOverCapacityIn(s: System): bool = - s.hubIds().exists(hub => s.queuedAt(hub).size() > s.hubs.get(hub).params.queueCap) - - /// W13. A lookup has moved on from a hub that did not answer, and the next - /// hub has answered it. - pure def wFailoverAnsweredIn(s: System): bool = - s.shim.waiters.keys().exists(nonce => - s.shim.waiters.get(nonce).attempt > 0 and - s.replies(ShimAddr).exists(reply => reply._1 == nonce and reply._3 != WError)) - - /// W14. Two hubs have each published the same payload, in their own flushes, - /// and it is on the chain: replication, not failover. - pure def wPublishedByTwoHubsIn(s: System, audit: Audit): bool = - s.indexer.published().exists(payload => - and { - audit.offers.filter(offer => offer.payload == payload).map(offer => offer.hub).size() > 1, - s.hubIds().forall(hub => - not(s.queuedAt(hub).contains(payload)) and not(s.inFlightAt(hub).contains(payload))), - }) - - /// A hub is publishing a scheduled batch for an epoch the chain has not - /// reached: its cadence clock is ahead of the true height. The batch is - /// smaller than the schedule intended. - pure def flushesAheadOfChain(s: System, hub: HubId): bool = - val state = s.hubs.get(hub) - match state.flush { - | Broadcasting(flush) => - not(flush.final) and state.cadenceEpoch() > s.height() / state.params.flushInterval - | Idle => false - } - - /// W15. Premature flush: a hub that follows its tip flushes ahead of the - /// chain, because the tip it was given is ahead of the chain. - pure def wPrematureFlushIn(s: System): bool = - s.hubIds().exists(hub => s.hubs.get(hub).cadence == Tracking and flushesAheadOfChain(s, hub)) - - /// W18. Early flush: a stale hub flushes ahead of the chain, because its - /// free-running clock is ahead of the chain. - pure def wEarlyFreeRunFlushIn(s: System): bool = - s.hubIds().exists(hub => s.hubs.get(hub).cadence != Tracking and flushesAheadOfChain(s, hub)) + s.hub.queued().size() > s.hub.params.queueCap /// W16a. The wallet is served a twin of what it sent: other bytes, same txid. pure def wTwinServedIn(s: System): bool = @@ -551,9 +447,9 @@ module properties { | _ => false }) - /// W17. A hub has queued a payload of the third party's own making. + /// W17. The hub has queued a payload of the third party's own making. pure def wThirdPartyPayloadQueuedIn(s: System): bool = - s.hubIds().exists(hub => s.queuedAt(hub).intersect(s.thirdParty.own) != Set()) + s.hub.queued().intersect(s.thirdParty.own) != Set() // ------------------------------------------------------------------------ // Non-vacuity: the antecedent of each guarantee is reachable @@ -579,37 +475,15 @@ module properties { s.wasGiven(obs => obs == NotFound), } - pure def vOfferedBeforeExpiryIn(audit: Audit): bool = - audit.offers.exists(offer => isSome(offer.payload.expiry)) - - pure def vConformingFirstOfferBeforeExpiryIn(s: System, audit: Audit): bool = - audit.offers.exists(offer => - and { - isSome(offer.payload.expiry), - offer.nth == 0, - isConformingAndTimely(s, audit, offer.hub, offer.payload), - }) - - /// The same, for a payload admitted while the hub's tip was behind the chain. - pure def vConformingOfferAdmittedBehindIn(s: System, audit: Audit): bool = - audit.offers.exists(offer => - and { - isSome(offer.payload.expiry), - offer.nth == 0, - isConformingAndTimely(s, audit, offer.hub, offer.payload), - val entry = audit.admitted.get((offer.hub, offer.payload)) - entry.tip < entry.height, - }) - pure def vConformingFirstOfferJudgedIn(s: System, audit: Audit): bool = audit.verdicts.exists(verdict => and { isSome(verdict.payload.expiry), verdict.nth == 0, verdict.final, - isConformingAndTimely(s, audit, verdict.hub, verdict.payload), + isConformingAndTimely(s, audit, verdict.payload), }) pure def vAckImpliesQueuedIn(s: System): bool = - s.hubIds().exists(hub => s.ackedAt(hub) != Set()) + s.acked() != Set() } diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 23e6384a..c28e65b2 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -1,18 +1,23 @@ // -*- mode: Bluespec; -*- -/// The zeronym protocol as a state machine: a wallet, a shim, one or more -/// hubs, the chain behind the hubs' indexer, an unreliable network between -/// them, and a third party that can reach the hubs. +/// The zeronym protocol as a state machine: a wallet, a shim, one hub, the +/// chain behind the hub's indexer, an unreliable network between them, and a +/// third party that can reach the hub. +/// +/// The spec checks one hub; production runs one or more, replicated: every +/// shim sends every submission to every hub, and each hub that receives a +/// migration queues and broadcasts it. /// /// What is assumed: /// -/// - Roles. The shim and the hubs run in enclaves and are honest in the -/// baseline. The shim, each hub and the hubs' indexer can each be made -/// Byzantine through `ROLES`; a Byzantine component draws its transitions -/// from a wider relation and is not marked in any other way. +/// - Roles. The shim and the hub run in enclaves and are honest in the +/// baseline. The shim is honest in every configuration; the hub and its +/// indexer can each be made Byzantine through `ROLES`. A Byzantine +/// component draws its transitions from a wider relation and is not marked +/// in any other way. /// - Network. Frames may be lost, duplicated, delayed and reordered. They /// cannot be forged or read in transit. -/// - Third party. A client of the hubs' public address. It looks up txids it +/// - Third party. A client of the hub's public address. It looks up txids it /// knows and submits payloads it has learned or made. It cannot read or /// forge frames, so it does not know a nonce and cannot answer the shim. /// - Nonces are unique. A counter stands for an unguessable random value. @@ -20,7 +25,7 @@ /// included transaction, no mempool eviction. How a hub's view of the tip /// relates to the true height is the constant `TIP`. /// - Wallets. A supported wallet sets an expiry at least `MIN_WALLET_EXPIRY` -/// blocks after the height it builds at, and its frame reaches a hub within +/// blocks after the height it builds at, and its frame reaches the hub within /// `DELIVERY_LAG` blocks. /// - Time. There is no clock. A timeout is an event that may happen at any /// moment, and the staleness window is counted in blocks. @@ -60,9 +65,6 @@ module protocol { pure val TWINS = CONFIG.twins /// Payloads of the third party's own making. pure val TP_PAYLOADS = CONFIG.tpPayloads - /// The hub addresses, in the order the shim is configured with. - pure val HUB_ORDER = CONFIG.hubs - pure val HUBS = HUB_ORDER.indices().map(i => HUB_ORDER[i]) /// Blocks between scheduled flushes. pure val FLUSH_INTERVAL = CONFIG.flushInterval @@ -119,8 +121,6 @@ module protocol { queueCap: QUEUE_CAP, } - pure val HONEST_HUBS = HUBS.filter(hub => ROLES.hubs.get(hub) == Honest) - // ------------------------------------------------------------------------ // Assumptions // ------------------------------------------------------------------------ @@ -174,17 +174,13 @@ module protocol { pure val flushIntervalPositive = FLUSH_INTERVAL > 0 - pure val hubsNonEmpty = HUBS != Set() and HUBS.size() == HUB_ORDER.length() - /// Payloads are told apart by their bytes; a twin is a twin of something a - /// wallet sends; the third party's payloads are its own; every hub has a - /// role. + /// wallet sends; the third party's payloads are its own. pure val payloadsWellFormed = and { UNIVERSE.size() == UNIVERSE.map(payload => payload.id).size(), PAYLOADS.intersect(TP_PAYLOADS) == Set(), TWINS.forall(twin => PAYLOADS.exists(payload => areTwins(twin, payload))), UNIVERSE.forall(payload => payload.created >= GENESIS_HEIGHT), - ROLES.hubs.keys() == HUBS, } /// The assumptions every configuration meets. `reorgSlackFits` and @@ -194,7 +190,6 @@ module protocol { budgetFits, freeRunNotSlowerThanChain, flushIntervalPositive, - hubsNonEmpty, payloadsWellFormed, } @@ -203,7 +198,6 @@ module protocol { assume _ = CONFIG.reliesOnFlightWithinMargin implies flightWithinMargin assume _ = freeRunNotSlowerThanChain assume _ = flushIntervalPositive - assume _ = hubsNonEmpty assume _ = payloadsWellFormed // ------------------------------------------------------------------------ @@ -228,7 +222,7 @@ module protocol { action init = all { s' = INITIAL, - audit' = initialAudit(INITIAL), + audit' = initialAudit, lastAction' = Init, } @@ -236,8 +230,8 @@ module protocol { // Roles // ------------------------------------------------------------------------ - pure def hubResults(id: HubId, state: HubState, input: HubInput): Set[HubResult] = - match ROLES.hubs.get(id) { + pure def hubResults(state: HubState, input: HubInput): Set[HubResult] = + match ROLES.hub { | Honest => Set(hub(state, input)) | Byzantine => byzHubResults(state, input, UNIVERSE, HEIGHTS) } @@ -289,63 +283,59 @@ module protocol { } } - /// How far behind the chain `hub`'s observed tip is. - def lagOf(id: HubId): int = - s.height() - s.hubs.get(id).observedTip() + /// How far behind the chain the hub's observed tip is. + def lag: int = + s.height() - s.hub.observedTip() /// The heights a stale hub's free-running clock may read now: up to an /// interval ahead of the chain, and not behind it unless `FREE_RUN` allows. - def freeRunEstimates(id: HubId): Set[Height] = + def freeRunEstimates: Set[Height] = val earliest = match FREE_RUN { | NotSlower => s.height() - | MayBeSlower => s.hubs.get(id).observedTip() + | MayBeSlower => s.hub.observedTip() } CLOCK_HEIGHTS.filter(estimate => earliest <= estimate and estimate <= s.height() + FLUSH_INTERVAL) /// Whether the next block may arrive. This is where the timing assumptions - /// live: a block is held back until every hub has done what the tip model + /// live: a block is held back until the hub has done what the tip model /// says it does within a block. /// /// In every model, a flush that is due has begun, and no flush has been in /// flight for `MAX_FLIGHT_BLOCKS` blocks already. A hub whose flush is in - /// flight is not looking at the tip, so the clauses below bind a hub only + /// flight is not looking at the tip, so the clauses below bind the hub only /// while it is idle. /// - /// - `TipTimely`: every running hub has asked for the tip since the last - /// block. An honest indexer answers with the true height, so the hub's lag - /// is zero at every block. A Byzantine one is asked just as often; what it + /// - `TipTimely`: a running hub has asked for the tip since the last block. + /// An honest indexer answers with the true height, so the hub's lag is + /// zero at every block. A Byzantine one is asked just as often; what it /// controls is the answer. - /// - `TipMayRegress`: no running hub is further behind than the allowance. + /// - `TipMayRegress`: a running hub is no further behind than the allowance. /// - `TipMayLag`: a hub whose silence has reached the staleness window has /// become stale, and a stale hub's free-running clock has caught up with /// the current block. def chainMayAdvance: bool = and { s.height() < MAX_HEIGHT, - HUBS.forall(id => not(s.hubs.get(id).isFlushDue())), - HUBS.forall(id => s.flightBlocks.get(id) < MAX_FLIGHT_BLOCKS or s.hubs.get(id).flush == Idle), - HUBS.forall(id => - val state = s.hubs.get(id) - state.flush == Idle implies - match TIP { - | TipTimely => - state.phase == Running implies s.polled.contains(id) - | TipMayRegress => - state.phase == Running implies lagOf(id) <= REORG_ALLOWANCE - | TipMayLag => - and { - state.phase == Running implies lagOf(id) < STALE_WINDOW, - (state.phase == Stale and FREE_RUN == NotSlower) implies state.cadenceHeight() >= s.height(), - } - }), + not(s.hub.isFlushDue()), + s.flightBlocks < MAX_FLIGHT_BLOCKS or s.hub.flush == Idle, + s.hub.flush == Idle implies + match TIP { + | TipTimely => s.hub.phase == Running implies s.polled + | TipMayRegress => s.hub.phase == Running implies lag <= REORG_ALLOWANCE + | TipMayLag => + and { + s.hub.phase == Running implies lag < STALE_WINDOW, + (s.hub.phase == Stale and FREE_RUN == NotSlower) implies s.hub.cadenceHeight() >= s.height(), + } + }, } // ------------------------------------------------------------------------ // Wallet // ------------------------------------------------------------------------ - /// The wallet sends a transaction. The shim routes it and, unless it is - /// waiting for a hub's decision, answers. - action walletSendWith(input: SendInput, handedOver: int): bool = all { + /// The wallet sends a transaction, and the shim routes it and answers. + /// `handedOver` is whether the transport takes the frame for the hub. + action walletSendWith(input: SendInput, handedOver: bool): bool = all { s.wallet.sends < MAX_REQUESTS, SEND_INPUTS.contains(input), // A wallet cannot send a transaction before it has built it. @@ -364,7 +354,7 @@ module protocol { s.wallet.sends < MAX_REQUESTS, { nondet input = oneOf(SEND_INPUTS) - nondet handedOver = oneOf(0.to(HUB_ORDER.length())) + nondet handedOver = oneOf(Set(true, false)) walletSendWith(input, handedOver) }, } @@ -374,15 +364,14 @@ module protocol { def walletTxids: Set[TxId] = s.sentByWallet().map(payload => walletTxid(payload)) - /// The wallet asks for a transaction it has sent. The shim asks the hub its - /// cursor points at. - action walletGetWith(query: TxId, start: int): bool = all { + /// The wallet asks for a transaction it has sent. The shim asks the hub. + action walletGetWith(query: TxId): bool = all { s.wallet.gets < MAX_REQUESTS, walletTxids.contains(query), - val result = shim(s.shim, GetTxSInput({ query: query, start: start })) + val result = shim(s.shim, GetTxSInput(query)) all { not(isShimError(result.out)), - commit(s.countGet().shimStepped(result, None), WalletGet({ query: query, start: start })), + commit(s.countGet().shimStepped(result, None), WalletGet(query)), }, } @@ -391,8 +380,7 @@ module protocol { walletTxids != Set(), { nondet query = oneOf(walletTxids) - nondet start = oneOf(HUB_ORDER.indices()) - walletGetWith(query, start) + walletGetWith(query) }, } @@ -449,106 +437,88 @@ module protocol { // Hub // ------------------------------------------------------------------------ - /// The request frames that may be delivered to `id`: all of them, as often - /// as the network likes, while the hub is serving. - def hubDeliverable(id: HubId): Set[Mail] = - if (s.hubs.get(id).isServing()) - s.net.inbox(HubAddr(id)).filter(mail => requestInput(mail.msg, INotFound) != Set()) + /// The request frames that may be delivered to the hub: all of them, as + /// often as the network likes, while the hub is serving. + def hubDeliverable: Set[Mail] = + if (s.hub.isServing()) + s.net.inbox(HubAddr).filter(mail => requestInput(mail.msg, INotFound) != Set()) else Set() - /// The transitions `id` may take when `mail` is delivered and its indexer - /// would answer a lookup with `answer`. - def hubReceipts(id: HubId, mail: Mail, answer: IndexerAnswer): Set[HubResult] = - requestInput(mail.msg, answer).map(input => hubResults(id, s.hubs.get(id), input)).flatten() + /// The transitions the hub may take when `mail` is delivered and its + /// indexer would answer a lookup with `answer`. + def hubReceipts(mail: Mail, answer: IndexerAnswer): Set[HubResult] = + requestInput(mail.msg, answer).map(input => hubResults(s.hub, input)).flatten() - /// The network delivers a request to a hub, which answers its sender. For a - /// lookup, `answer` is what the hub's indexer says if the queue misses. - action hubReceiveWith(id: HubId, mail: Mail, answer: IndexerAnswer, result: HubResult): bool = all { - HUBS.contains(id), - hubDeliverable(id).contains(mail), + /// The network delivers a request to the hub, which answers its sender. For + /// a lookup, `answer` is what the hub's indexer says if the queue misses. + action hubReceiveWith(mail: Mail, answer: IndexerAnswer, result: HubResult): bool = all { + hubDeliverable.contains(mail), lookupAnswers(s.indexer, mail.msg).contains(answer), - hubReceipts(id, mail, answer).contains(result), + hubReceipts(mail, answer).contains(result), not(isHubError(result.out)), - commit(s.hubReplied(id, mail.src, result), HubReceive({ hub: id, mail: mail })), + commit(s.hubReplied(mail.src, result), HubReceive(mail)), } - action hubReceive = { - nondet id = oneOf(HUBS) - all { - hubDeliverable(id) != Set(), - { - nondet mail = oneOf(hubDeliverable(id)) - nondet answer = oneOf(lookupAnswers(s.indexer, mail.msg)) - nondet result = oneOf(hubReceipts(id, mail, answer)) - hubReceiveWith(id, mail, answer, result) - }, - } + action hubReceive = all { + hubDeliverable != Set(), + { + nondet mail = oneOf(hubDeliverable) + nondet answer = oneOf(lookupAnswers(s.indexer, mail.msg)) + nondet result = oneOf(hubReceipts(mail, answer)) + hubReceiveWith(mail, answer, result) + }, } /// A step of the hub's own schedule, taken in the system `around` (the /// current one, or the current one after its indexer moved). A Byzantine hub /// keeps the honest schedule, so these take the hub function's transition /// whatever the role. A step that would change nothing is not taken. - action hubTakesIn(around: System, id: HubId, input: HubInput, label: Label): bool = - val result = hub(s.hubs.get(id), input) + action hubTakesIn(around: System, input: HubInput, label: Label): bool = + val result = hub(s.hub, input) all { - HUBS.contains(id), not(isHubError(result.out)), - result.state != s.hubs.get(id), - commit(around.hubMoved(id, result.state), label), + result.state != s.hub, + commit(around.hubMoved(result.state), label), } - action hubTakes(id: HubId, input: HubInput, label: Label): bool = - hubTakesIn(s, id, input, label) + action hubTakes(input: HubInput, label: Label): bool = + hubTakesIn(s, input, label) - /// A hub's cadence loop observes the tip its indexer reports. + /// The hub's cadence loop observes the tip its indexer reports. /// /// The poll is recorded whether or not the answer moves the hub's tip: a hub /// that asked and was told nothing new has still asked. A second poll in the /// same block that changes nothing is not taken. - action hubObserveTipWith(id: HubId, tip: Height): bool = - val result = hub(s.hubs.get(id), TipHInput(tip)) + action hubObserveTipWith(tip: Height): bool = + val result = hub(s.hub, TipHInput(tip)) all { - HUBS.contains(id), reportableTips.contains(tip), not(isHubError(result.out)), - result.state != s.hubs.get(id) or not(s.polled.contains(id)), - commit( - { ...s.hubMoved(id, result.state), polled: s.polled.union(Set(id)) }, - HubObserveTip({ hub: id, tip: tip }), - ), + result.state != s.hub or not(s.polled), + commit({ ...s.hubMoved(result.state), polled: true }, HubObserveTip(tip)), } action hubObserveTip = { - nondet id = oneOf(HUBS) nondet tip = oneOf(reportableTips) - hubObserveTipWith(id, tip) + hubObserveTipWith(tip) } /// A hub that has seen no tip progress for the staleness window goes stale, /// or, already stale, reads its free-running clock again. - action hubTipStaleWith(id: HubId, estimate: Height): bool = all { + action hubTipStaleWith(estimate: Height): bool = all { TIP == TipMayLag, - HUBS.contains(id), - s.hubs.get(id).phase == Stale or lagOf(id) >= STALE_WINDOW, - freeRunEstimates(id).contains(estimate), - hubTakes(id, StaleHInput(estimate), HubTipStale({ hub: id, estimate: estimate })), + s.hub.phase == Stale or lag >= STALE_WINDOW, + freeRunEstimates.contains(estimate), + hubTakes(StaleHInput(estimate), HubTipStale(estimate)), } action hubTipStale = { - nondet id = oneOf(HUBS) - nondet estimate = oneOf(freeRunEstimates(id)) - hubTipStaleWith(id, estimate) + nondet estimate = oneOf(freeRunEstimates) + hubTipStaleWith(estimate) } /// A flush begins: the hub's whole queue goes out at once. - action hubFlushBeginWith(id: HubId): bool = - hubTakes(id, FlushDueHInput, HubFlushBegin(id)) - - action hubFlushBegin = { - nondet id = oneOf(HUBS) - hubFlushBeginWith(id) - } + action hubFlushBegin = hubTakes(FlushDueHInput, HubFlushBegin) /// The verdict an indexer output carries. Read only where it carries one. pure def verdictIn(output: IndexerOutput): Verdict = @@ -559,70 +529,42 @@ module protocol { /// The indexer returns its verdict on one entry of a batch. `result` is the /// indexer's transition: the verdict, and what became of the transaction. - action indexerVerdictWith(id: HubId, payload: Payload, result: IndexerResult): bool = all { + action indexerVerdictWith(payload: Payload, result: IndexerResult): bool = all { indexerResults(s.indexer, BroadcastIInput(payload)).contains(result), result.out == VerdictOutput(verdictIn(result.out)), hubTakesIn( { ...s, indexer: result.state }, - id, VerdictHInput({ payload: payload, verdict: verdictIn(result.out) }), - IndexerVerdict({ hub: id, payload: payload }), + IndexerVerdict(payload), ), } - /// The entries of `id`'s batch still waiting for a verdict. - def awaitingVerdict(id: HubId): Set[Payload] = - match s.hubs.get(id).flush { + /// The entries of the hub's batch still waiting for a verdict. + def awaitingVerdict: Set[Payload] = + match s.hub.flush { | Broadcasting(flush) => flush.batch.keys() | Idle => Set() } - action indexerVerdict = { - nondet id = oneOf(HUBS) - all { - awaitingVerdict(id) != Set(), - { - nondet payload = oneOf(awaitingVerdict(id)) - nondet result = oneOf(indexerResults(s.indexer, BroadcastIInput(payload))) - indexerVerdictWith(id, payload, result) - }, - } + action indexerVerdict = all { + awaitingVerdict != Set(), + { + nondet payload = oneOf(awaitingVerdict) + nondet result = oneOf(indexerResults(s.indexer, BroadcastIInput(payload))) + indexerVerdictWith(payload, result) + }, } /// A flush ends: what nothing judged is requeued or dropped. - action hubFlushEndWith(id: HubId): bool = - hubTakes(id, FlushDoneHInput, HubFlushEnd(id)) - - action hubFlushEnd = { - nondet id = oneOf(HUBS) - hubFlushEndWith(id) - } + action hubFlushEnd = hubTakes(FlushDoneHInput, HubFlushEnd) - /// A hub gets its shutdown signal. - action hubBeginDrainWith(id: HubId): bool = - hubTakes(id, DrainHInput, HubBeginDrain(id)) + /// The hub gets its shutdown signal. + action hubBeginDrain = hubTakes(DrainHInput, HubBeginDrain) - action hubBeginDrain = { - nondet id = oneOf(HUBS) - hubBeginDrainWith(id) - } - - /// A hub's process dies, or exits after its final flush. - action hubCrashWith(id: HubId): bool = - hubTakes(id, CrashHInput, HubCrash(id)) - - action hubCrash = { - nondet id = oneOf(HUBS) - hubCrashWith(id) - } - - action hubRestartWith(id: HubId): bool = - hubTakes(id, RestartHInput, HubRestart(id)) + /// The hub's process dies, or exits after its final flush. + action hubCrash = hubTakes(CrashHInput, HubCrash) - action hubRestart = { - nondet id = oneOf(HUBS) - hubRestartWith(id) - } + action hubRestart = hubTakes(RestartHInput, HubRestart) // ------------------------------------------------------------------------ // Chain @@ -679,16 +621,12 @@ module protocol { }, } - /// The third party asks a hub about a txid it knows. The lookup is not + /// The third party asks the hub about a txid it knows. The lookup is not /// authenticated. - action thirdPartyLookupWith(txid: TxId, id: HubId): bool = all { + action thirdPartyLookupWith(txid: TxId): bool = all { s.thirdParty.requests < MAX_REQUESTS, - HUBS.contains(id), s.tpTxids().contains(txid), - commit( - s.thirdPartySent(id, Lookup({ nonce: s.thirdParty.nextNonce, txid: txid })), - ThirdPartyLookup({ txid: txid, hub: id }), - ), + commit(s.thirdPartySent(Lookup({ nonce: s.thirdParty.nextNonce, txid: txid })), ThirdPartyLookup(txid)), } action thirdPartyLookup = all { @@ -696,20 +634,18 @@ module protocol { s.tpTxids() != Set(), { nondet txid = oneOf(s.tpTxids()) - nondet id = oneOf(HUBS) - thirdPartyLookupWith(txid, id) + thirdPartyLookupWith(txid) }, } /// The third party submits a payload it has: one it made, or one it has /// learned. Submission is not authenticated either. - action thirdPartySubmitWith(payload: Payload, id: HubId): bool = all { + action thirdPartySubmitWith(payload: Payload): bool = all { s.thirdParty.requests < MAX_REQUESTS, - HUBS.contains(id), s.tpPayloads().contains(payload), commit( - s.thirdPartySent(id, Submit({ nonce: s.thirdParty.nextNonce, payload: payload })), - ThirdPartySubmit({ payload: payload, hub: id }), + s.thirdPartySent(Submit({ nonce: s.thirdParty.nextNonce, payload: payload })), + ThirdPartySubmit(payload), ), } @@ -718,8 +654,7 @@ module protocol { s.tpPayloads() != Set(), { nondet payload = oneOf(s.tpPayloads()) - nondet id = oneOf(HUBS) - thirdPartySubmitWith(payload, id) + thirdPartySubmitWith(payload) }, } @@ -771,18 +706,15 @@ module protocol { } /// The indexer cannot be reached: an entry of a batch gets no verdict. - action indexerUnreachable = { - nondet id = oneOf(HUBS) - all { - awaitingVerdict(id) != Set(), - { - nondet payload = oneOf(awaitingVerdict(id)) - indexerVerdictWith(id, payload, { - state: indexerApply(s.indexer, BroadcastIInput(payload), VerdictOutput(Retryable)), - out: VerdictOutput(Retryable), - }) - }, - } + action indexerUnreachable = all { + awaitingVerdict != Set(), + { + nondet payload = oneOf(awaitingVerdict) + indexerVerdictWith(payload, { + state: indexerApply(s.indexer, BroadcastIInput(payload), VerdictOutput(Retryable)), + out: VerdictOutput(Retryable), + }) + }, } // The relations below are parts of `step`. Everything one of them reaches, @@ -820,29 +752,17 @@ module protocol { val queuedBytesConfidential = queuedBytesConfidentialIn(s) val txidAuthenticity = txidAuthenticityIn(s) val lookupValidityPerHub = lookupValidityPerHubIn(s, audit) - val offeredBeforeExpiry = offeredBeforeExpiryIn(s, audit, HUBS) - val conformingFirstOfferBeforeExpiry = conformingFirstOfferBeforeExpiryIn(s, audit, HUBS) - val conformingFirstOfferJudgedBeforeExpiry = conformingFirstOfferJudgedBeforeExpiryIn(s, audit, HUBS) + val offeredBeforeExpiry = offeredBeforeExpiryIn(s, audit) + val conformingFirstOfferBeforeExpiry = conformingFirstOfferBeforeExpiryIn(s, audit) + val conformingFirstOfferJudgedBeforeExpiry = conformingFirstOfferJudgedBeforeExpiryIn(s, audit) val wellFormed = wellFormedIn(s) - val ackImpliesQueued = ackImpliesQueuedIn(s, audit, HUBS) - - // The per-hub guarantees, claimed of the honest hubs only. Each is the same - // predicate as its namesake above, restricted to the hubs whose role is - // `Honest`; it is not a weaker property. With every hub honest the two - // coincide. - val offeredBeforeExpiryForHonestHubs = offeredBeforeExpiryIn(s, audit, HONEST_HUBS) - val conformingFirstOfferBeforeExpiryForHonestHubs = conformingFirstOfferBeforeExpiryIn(s, audit, HONEST_HUBS) - val conformingFirstOfferJudgedBeforeExpiryForHonestHubs = - conformingFirstOfferJudgedBeforeExpiryIn(s, audit, HONEST_HUBS) - val ackImpliesQueuedForHonestHubs = ackImpliesQueuedIn(s, audit, HONEST_HUBS) + val ackImpliesQueued = ackImpliesQueuedIn(s, audit) // ------------------------------------------------------------------------ // Known gaps: invariants that do not hold // ------------------------------------------------------------------------ val statusNeverRegresses = statusNeverRegressesIn(s) - val ackedIsHeldOrSettled = ackedIsHeldOrSettledIn(s, audit) - val conformingEveryOfferBeforeExpiry = conformingEveryOfferBeforeExpiryIn(s, audit) // ------------------------------------------------------------------------ // Witnesses @@ -850,7 +770,6 @@ module protocol { val wToldRefusedEverywhere = wToldRefusedEverywhereIn(s, audit) val wToldNeverDelivered = wToldNeverDeliveredIn(s, audit) - val wToldPrefixOnly = wToldPrefixOnlyIn(s) val wPending = wPendingIn(s) val wTxInMempool = wTxInMempoolIn(s) @@ -866,22 +785,15 @@ module protocol { val wQueuedDisclosed = wQueuedDisclosedIn(s) val wUnparseableMissed = wUnparseableMissedIn(s) val wQueueOverCapacity = wQueueOverCapacityIn(s) - val wFailoverAnswered = wFailoverAnsweredIn(s) - val wPublishedByTwoHubs = wPublishedByTwoHubsIn(s, audit) - val wPrematureFlush = wPrematureFlushIn(s) val wTwinServed = wTwinServedIn(s) val wFalseHeightServed = wFalseHeightServedIn(s) val wThirdPartyPayloadQueued = wThirdPartyPayloadQueuedIn(s) - val wEarlyFreeRunFlush = wEarlyFreeRunFlushIn(s) // Non-vacuity: the antecedent of each guarantee is reachable. val vOperatorBlind = vOperatorBlindIn(s) val vQueuedBytesConfidential = vQueuedBytesConfidentialIn(s) val vTxidAuthenticity = vTxidAuthenticityIn(s) val vLookupValidityPerHub = vLookupValidityPerHubIn(s) - val vOfferedBeforeExpiry = vOfferedBeforeExpiryIn(audit) - val vConformingFirstOfferBeforeExpiry = vConformingFirstOfferBeforeExpiryIn(s, audit) - val vConformingOfferAdmittedBehind = vConformingOfferAdmittedBehindIn(s, audit) val vConformingFirstOfferJudged = vConformingFirstOfferJudgedIn(s, audit) val vAckImpliesQueued = vAckImpliesQueuedIn(s) @@ -903,9 +815,6 @@ module protocol { } def chainStatus(txid: TxId): Inclusion = s.onChain(txid) - def queueOf(id: HubId): Set[Payload] = s.queuedAt(id) - def flightOf(id: HubId): Set[Payload] = s.inFlightAt(id) - def phaseOf(id: HubId): Phase = s.hubs.get(id).phase /// A1. A transaction's status on the chain never moves backwards, and a /// mined transaction stays where it was mined. An assumption about the @@ -919,23 +828,21 @@ module protocol { ).orKeep(s) ) - /// A2. An entry leaves a hub's queue only into a flush, or because the hub - /// went down. Nothing evicts it. + /// A2. An entry leaves the hub's queue only into a flush, or because the + /// hub went down. Nothing evicts it. temporal neverEvict = always( - HUBS.forall(id => - or { - queueOf(id).subseteq(next(queueOf(id)).union(next(flightOf(id)))), - next(phaseOf(id)) == Down, - } - ).orKeep(s) + or { + s.hub.queued().subseteq(next(s.hub.queued()).union(next(s.hub.inFlight()))), + next(s.hub.phase) == Down, + }.orKeep(s) ) /// A3. A draining honest hub admits nothing: its queue gains only what a /// flush hands back. Draining is an admission rule like the others, so a /// Byzantine hub is not bound by it (`hubAdmitsWhileDrainingTest`). temporal drainIsFinal = always( - HONEST_HUBS.forall(id => - phaseOf(id) == Draining implies next(queueOf(id)).subseteq(queueOf(id).union(flightOf(id))) + (ROLES.hub == Honest and s.hub.phase == Draining implies + next(s.hub.queued()).subseteq(s.hub.queued().union(s.hub.inFlight())) ).orKeep(s) ) @@ -948,76 +855,70 @@ module protocol { // sequence of such steps. A run that needs another choice (a Byzantine // transition, an indexer that is down) uses the `...With` step itself. - /// A submission of `payload` from the shim to `id` under `nonce`. - pure def submitMail(id: HubId, nonce: Nonce, payload: Payload): Mail = - { src: ShimAddr, dst: HubAddr(id), msg: Submit({ nonce: nonce, payload: payload }) } + /// A submission of `payload` from the shim to the hub under `nonce`. + pure def submitMail(nonce: Nonce, payload: Payload): Mail = + { src: ShimAddr, dst: HubAddr, msg: Submit({ nonce: nonce, payload: payload }) } - /// A lookup of `txid` from the shim to `id` under `nonce`. - pure def lookupMail(id: HubId, nonce: Nonce, txid: TxId): Mail = - { src: ShimAddr, dst: HubAddr(id), msg: Lookup({ nonce: nonce, txid: txid }) } + /// A lookup of `txid` from the shim to the hub under `nonce`. + pure def lookupMail(nonce: Nonce, txid: TxId): Mail = + { src: ShimAddr, dst: HubAddr, msg: Lookup({ nonce: nonce, txid: txid }) } /// The same frame, sent by the third party instead. pure def fromThirdParty(mail: Mail): Mail = { ...mail, src: ThirdPartyAddr } - /// `id`'s ack to the shim under `nonce`. - pure def ackMail(id: HubId, nonce: Nonce, ack: WireAck): Mail = - { src: HubAddr(id), dst: ShimAddr, msg: Ack({ nonce: nonce, ack: ack }) } + /// The hub's ack to the shim under `nonce`. + pure def ackMail(nonce: Nonce, ack: WireAck): Mail = + { src: HubAddr, dst: ShimAddr, msg: Ack({ nonce: nonce, ack: ack }) } - /// `id`'s lookup reply to the shim under `nonce`. - pure def replyMail(id: HubId, nonce: Nonce, reply: WireReply): Mail = - { src: HubAddr(id), dst: ShimAddr, msg: LookupReply({ nonce: nonce, reply: reply }) } + /// The hub's lookup reply to the shim under `nonce`. + pure def replyMail(nonce: Nonce, reply: WireReply): Mail = + { src: HubAddr, dst: ShimAddr, msg: LookupReply({ nonce: nonce, reply: reply }) } /// The wallet sends, and the shim routes the transaction as it should. - action sends(input: SendInput, handedOver: int): bool = + action sends(input: SendInput, handedOver: bool): bool = walletSendWith(input, handedOver) - /// The wallet sends a transaction and every hub address takes a frame. - action sendToAll(payload: Payload): bool = - sends(Clean(payload), HUB_ORDER.length()) + /// The wallet sends a transaction and the transport takes the frame. + action sendToHub(payload: Payload): bool = + sends(Clean(payload), true) - /// The wallet asks, and the shim asks the hub at `start`. - action ask(query: TxId, start: int): bool = - walletGetWith(query, start) + /// The wallet asks, and the shim asks the hub. + action ask(query: TxId): bool = + walletGetWith(query) - /// `client`'s submission of `payload` under `nonce` reaches `id`, which acts - /// as it should. - action deliverSubmitFrom(client: Addr, id: HubId, nonce: Nonce, payload: Payload): bool = + /// `client`'s submission of `payload` under `nonce` reaches the hub, which + /// acts as it should. + action deliverSubmitFrom(client: Addr, nonce: Nonce, payload: Payload): bool = val submit = { nonce: nonce, payload: payload } - hubReceiveWith( - id, - { src: client, dst: HubAddr(id), msg: Submit(submit) }, - INotFound, - hub(s.hubs.get(id), SubmitHInput(submit)), - ) + hubReceiveWith({ src: client, dst: HubAddr, msg: Submit(submit) }, INotFound, hub(s.hub, SubmitHInput(submit))) - /// The shim's submission of `payload` under `nonce` reaches `id`. - action deliverSubmit(id: HubId, nonce: Nonce, payload: Payload): bool = - deliverSubmitFrom(ShimAddr, id, nonce, payload) + /// The shim's submission of `payload` under `nonce` reaches the hub. + action deliverSubmit(nonce: Nonce, payload: Payload): bool = + deliverSubmitFrom(ShimAddr, nonce, payload) - /// `client`'s lookup of `txid` under `nonce` reaches `id`, which acts as it - /// should on the answer `answer` from its indexer. - action deliverLookupFrom(client: Addr, id: HubId, nonce: Nonce, txid: TxId, answer: IndexerAnswer): bool = + /// `client`'s lookup of `txid` under `nonce` reaches the hub, which acts as + /// it should on the answer `answer` from its indexer. + action deliverLookupFrom(client: Addr, nonce: Nonce, txid: TxId, answer: IndexerAnswer): bool = hubReceiveWith( - id, - { src: client, dst: HubAddr(id), msg: Lookup({ nonce: nonce, txid: txid }) }, + { src: client, dst: HubAddr, msg: Lookup({ nonce: nonce, txid: txid }) }, answer, - hub(s.hubs.get(id), LookupHInput({ nonce: nonce, txid: txid, answer: answer })), + hub(s.hub, LookupHInput({ nonce: nonce, txid: txid, answer: answer })), ) - /// The shim's lookup of `txid` under `nonce` reaches `id`, whose indexer is - /// reachable and truthful. - action deliverLookup(id: HubId, nonce: Nonce, txid: TxId): bool = - deliverLookupFrom(ShimAddr, id, nonce, txid, chainAnswer(s.indexer, txid)) + /// The shim's lookup of `txid` under `nonce` reaches the hub, whose indexer + /// is reachable and truthful. + action deliverLookup(nonce: Nonce, txid: TxId): bool = + deliverLookupFrom(ShimAddr, nonce, txid, chainAnswer(s.indexer, txid)) /// `mail` reaches the shim, which acts as it should. action deliverToShim(mail: Mail): bool = shimReceiveWith(mail) - /// The reply an honest `id` with a truthful indexer gives, in the current + /// The reply an honest hub with a truthful indexer gives, in the current /// state, to a lookup of `txid`. - def honestReply(id: HubId, txid: TxId): WireReply = - render(if (s.hubs.get(id).isQueuedTxid(txid)) QueueHit else FromIndexer(chainAnswer(s.indexer, txid))) + def honestReply(txid: TxId): WireReply = + render(if (s.hub.isQueuedTxid(txid)) QueueHit else FromIndexer(chainAnswer(s.indexer, txid))) /// The shim's wait under `nonce` times out. action timeOutLookup(nonce: Nonce): bool = @@ -1025,44 +926,41 @@ module protocol { /// An honest indexer gives `verdict` on `payload`, with the effect that /// verdict has. - action judge(id: HubId, payload: Payload, verdict: Verdict): bool = - indexerVerdictWith(id, payload, { + action judge(payload: Payload, verdict: Verdict): bool = + indexerVerdictWith(payload, { state: indexerApply(s.indexer, BroadcastIInput(payload), VerdictOutput(verdict)), out: VerdictOutput(verdict), }) - /// `id` observes the true height. - action observe(id: HubId): bool = - hubObserveTipWith(id, s.height()) - - /// Every hub observes the true height. - run allObserve = HUB_ORDER.length().reps(i => observe(HUB_ORDER[i])) + /// The hub observes the true height. + action observe: bool = + hubObserveTipWith(s.height()) - /// The system with every hub running at the genesis height. - run started = init.then(allObserve) + /// The system with the hub running at the genesis height. + run started = init.then(observe) - /// One block arrives and every hub sees it. - run block = chainAdvance.then(allObserve) + /// One block arrives and the hub sees it. + run block = chainAdvance.then(observe) - /// `count` blocks arrive, each seen by every hub. No flush may fall due. + /// `count` blocks arrive, each seen by the hub. No flush may fall due. run blocks(count: int): bool = count.reps(_ => block) - /// The wallet sends `payload`, and the frame under `nonce` reaches `id`. - run submitTo(id: HubId, nonce: Nonce, payload: Payload): bool = - sendToAll(payload).then(deliverSubmit(id, nonce, payload)) - - /// The wallet asks for `query`; the lookup under `nonce` reaches `id`; the - /// reply reaches the shim. The hub's state does not change in between. - run lookUp(id: HubId, nonce: Nonce, query: TxId): bool = - ask(query, 0) - .then(deliverLookup(id, nonce, query)) - .then(deliverToShim(replyMail(id, nonce, honestReply(id, query)))) - - /// `id` flushes `batch`, and the indexer gives every entry `verdict`. - run flush(id: HubId, batch: List[Payload], verdict: Verdict): bool = - hubFlushBeginWith(id) - .then(batch.length().reps(i => judge(id, batch[i], verdict))) - .then(hubFlushEndWith(id)) + /// The wallet sends `payload`, and the frame under `nonce` reaches the hub. + run submitTo(nonce: Nonce, payload: Payload): bool = + sendToHub(payload).then(deliverSubmit(nonce, payload)) + + /// The wallet asks for `query`; the lookup under `nonce` reaches the hub; + /// the reply reaches the shim. The hub's state does not change in between. + run lookUp(nonce: Nonce, query: TxId): bool = + ask(query) + .then(deliverLookup(nonce, query)) + .then(deliverToShim(replyMail(nonce, honestReply(query)))) + + /// The hub flushes `batch`, and the indexer gives every entry `verdict`. + run flush(batch: List[Payload], verdict: Verdict): bool = + hubFlushBegin + .then(batch.length().reps(i => judge(batch[i], verdict))) + .then(hubFlushEnd) /// The wallet's most recent answer. def lastEvent: WalletEvent = diff --git a/zeronym/spec/protocol/shim.qnt b/zeronym/spec/protocol/shim.qnt index 0deb2619..4d5cefb7 100644 --- a/zeronym/spec/protocol/shim.qnt +++ b/zeronym/spec/protocol/shim.qnt @@ -2,11 +2,11 @@ /// The shim: it sits in front of an operator's indexer, forwards what is not /// a migration, and diverts every migration, and every transaction lookup, to -/// the hubs. +/// the hub. /// /// Like the hub it is one total function, `shim(state, input)`. Its state is -/// only the requests it is waiting on: it keeps no record of a migration once -/// the wallet has its answer, which is why every lookup has to go to a hub. +/// only the lookups it is waiting on: it keeps no record of a migration once +/// the wallet has its answer, which is why every lookup has to go to the hub. /// /// The shim is honest in every configuration: it sees every migration in the /// clear and controls everything the wallet observes, so a Byzantine shim @@ -20,48 +20,34 @@ module shim { // State // ------------------------------------------------------------------------ - /// A frame on its way to a hub. - type Frame = { hub: HubId, msg: Msg } - - /// What the shim remembers about a lookup it has sent: where the sweep over - /// the hubs started and how many hubs it has tried. A submission leaves - /// nothing behind: the wallet has its answer when the frames are handed - /// over, and an ack, if one comes, is discarded. - type Waiter = { query: TxId, start: int, attempt: int } - - /// - `hubs`: the hub addresses, in configured order. - /// - `waiters`: the outstanding lookups, by nonce. + /// - `waiters`: the outstanding lookups, by nonce, with the txid each asks + /// about. A submission leaves nothing behind: the wallet has its answer + /// when the frame is handed over, and an ack, if one comes, is discarded. /// - `nextNonce`: the source of fresh nonces. A counter stands for a random /// value nobody else can guess. - type ShimState = { - hubs: List[HubId], - waiters: Nonce -> Waiter, - nextNonce: Nonce, - } + type ShimState = { waiters: Nonce -> TxId, nextNonce: Nonce } - pure def initialShim(hubs: List[HubId]): ShimState = - { hubs: hubs, waiters: Map(), nextNonce: 0 } + pure val initialShim: ShimState = { waiters: Map(), nextNonce: 0 } // ------------------------------------------------------------------------ // Inputs and outputs // ------------------------------------------------------------------------ type ShimInput = - // A wallet's `SendTransaction`. `handedOver` is how many hub addresses, in - // order, take a frame before the transport stops taking them. - | SendTxSInput({ input: SendInput, handedOver: int }) - // A wallet's `GetTransaction`. `start` is where the rotating cursor points: - // the index of the hub asked first. - | GetTxSInput({ query: TxId, start: int }) + // A wallet's `SendTransaction`. `handedOver` is whether the transport took + // the frame for the hub. + | SendTxSInput({ input: SendInput, handedOver: bool }) + | GetTxSInput(TxId) // a wallet's `GetTransaction` | FrameSInput(Msg) // a frame arrives from the network | LookupTimeoutSInput(Nonce) // no reply to a lookup in time type ShimOutput = | ForwardOutput(Payload) // to the operator's indexer - // `payload` is what the wallet sent; `told` is its answer. - | DivertedOutput({ payload: Payload, frames: Set[Frame], told: SendObs }) + // `payload` is what the wallet sent; `frame` goes to the hub; `told` is + // the wallet's answer. + | DivertedOutput({ payload: Payload, frame: Msg, told: SendObs }) | SendDoneOutput({ input: SendInput, obs: SendObs }) - | LookupSentOutput({ frame: Frame }) + | LookupSentOutput(Msg) // to the hub | LookupDoneOutput({ query: TxId, result: LookupObs }) | NoShimOutput | ShimErrorOutput(str) @@ -71,14 +57,14 @@ module shim { pure def toForwardOutput(state: ShimState, payload: Payload): ShimResult = { state: state, out: ForwardOutput(payload) } - pure def toDivertedOutput(state: ShimState, payload: Payload, frames: Set[Frame], told: SendObs): ShimResult = - { state: state, out: DivertedOutput({ payload: payload, frames: frames, told: told }) } + pure def toDivertedOutput(state: ShimState, payload: Payload, frame: Msg, told: SendObs): ShimResult = + { state: state, out: DivertedOutput({ payload: payload, frame: frame, told: told }) } pure def toSendDoneOutput(state: ShimState, input: SendInput, obs: SendObs): ShimResult = { state: state, out: SendDoneOutput({ input: input, obs: obs }) } - pure def toLookupSentOutput(state: ShimState, frame: Frame): ShimResult = - { state: state, out: LookupSentOutput({ frame: frame }) } + pure def toLookupSentOutput(state: ShimState, frame: Msg): ShimResult = + { state: state, out: LookupSentOutput(frame) } pure def toLookupDoneOutput(state: ShimState, query: TxId, result: LookupObs): ShimResult = { state: state, out: LookupDoneOutput({ query: query, result: result }) } @@ -115,78 +101,50 @@ module shim { | _ => None } - /// The hub a lookup sweep that started at `start` asks on its `attempt`-th - /// try. - pure def sweepTarget(state: ShimState, start: int, attempt: int): HubId = - state.hubs[(start + attempt) % state.hubs.length()] - // ------------------------------------------------------------------------ // SendTransaction // ------------------------------------------------------------------------ - /// Divert a migration: one frame per hub address, each under a fresh nonce, - /// to the first `handedOver` addresses. The wallet is told ok as soon as one - /// frame has been handed over, whether or not the sweep reached the later - /// addresses, and no ack is waited for. - pure def divert(state: ShimState, payload: Payload, handedOver: int): ShimResult = - val targets = state.hubs.slice(0, handedOver) - if (targets.length() == 0) + /// Divert a migration: one frame to the hub, under a fresh nonce. The wallet + /// is told ok as soon as the frame has been handed over, and no ack is + /// waited for. + pure def divert(state: ShimState, payload: Payload, handedOver: bool): ShimResult = + if (not(handedOver)) state.toSendDoneOutput(Clean(payload), SendUnavailable) else - val frames = targets.indices().map(i => - { hub: targets[i], msg: Submit({ nonce: state.nextNonce + i, payload: payload }) }) - { ...state, nextNonce: state.nextNonce + targets.length() } - .toDivertedOutput(payload, frames, SentOk) + { ...state, nextNonce: state.nextNonce + 1 } + .toDivertedOutput(payload, Submit({ nonce: state.nextNonce, payload: payload }), SentOk) /// Route a `SendTransaction`. Nothing but a cleanly read pass-through /// transaction ever reaches the operator; everything else is diverted or /// fails closed. - pure def sendTransaction(state: ShimState, input: SendInput, handedOver: int): ShimResult = - if (handedOver < 0 or handedOver > state.hubs.length()) - state.toShimErrorOutput("more frames handed over than there are hubs") - else - match input { - | Unreadable => state.toSendDoneOutput(input, SendUnavailable) - | EmptyBody => state.toSendDoneOutput(input, SendInvalid) - | Clean(payload) => - if (not(treatAsMigration(payload.class))) state.toForwardOutput(payload) - else if (payload.oversize) state.toSendDoneOutput(input, SendTooLarge) - else divert(state, payload, handedOver) - } + pure def sendTransaction(state: ShimState, input: SendInput, handedOver: bool): ShimResult = + match input { + | Unreadable => state.toSendDoneOutput(input, SendUnavailable) + | EmptyBody => state.toSendDoneOutput(input, SendInvalid) + | Clean(payload) => + if (not(treatAsMigration(payload.class))) state.toForwardOutput(payload) + else if (payload.oversize) state.toSendDoneOutput(input, SendTooLarge) + else divert(state, payload, handedOver) + } // ------------------------------------------------------------------------ // GetTransaction // ------------------------------------------------------------------------ - /// Ask one hub, under a fresh nonce. - pure def askHub(state: ShimState, query: TxId, start: int, attempt: int): ShimResult = + /// Every lookup goes to the hub, under a fresh nonce. + pure def getTransaction(state: ShimState, query: TxId): ShimResult = val nonce = state.nextNonce - { ...state, - waiters: state.waiters.put(nonce, { query: query, start: start, attempt: attempt }), - nextNonce: nonce + 1, - }.toLookupSentOutput({ hub: state.sweepTarget(start, attempt), msg: Lookup({ nonce: nonce, txid: query }) }) - - /// Every lookup goes to a hub, starting at the hub the cursor points at. - pure def getTransaction(state: ShimState, query: TxId, start: int): ShimResult = - if (state.hubs.length() == 0) - state.toLookupDoneOutput(query, Unavailable) - else if (start < 0 or start >= state.hubs.length()) - state.toShimErrorOutput("the cursor points at no hub") - else - askHub(state, query, start, 0) + { waiters: state.waiters.put(nonce, query), nextNonce: nonce + 1 } + .toLookupSentOutput(Lookup({ nonce: nonce, txid: query })) - /// A lookup got no reply in time. Only a timeout moves the sweep on to the - /// next hub; when every hub has been tried the lookup fails closed. + /// A lookup got no reply in time. It fails closed. pure def lookupTimeout(state: ShimState, nonce: Nonce): ShimResult = if (not(state.hasWaiter(nonce))) state.toShimErrorOutput("no request is waiting under this nonce") else - val waiter = state.waiters.get(nonce) - val forgotten = { ...state, waiters: state.waiters.mapRemove(nonce) } - if (waiter.attempt + 1 < state.hubs.length()) - askHub(forgotten, waiter.query, waiter.start, waiter.attempt + 1) - else - forgotten.toLookupDoneOutput(waiter.query, Unavailable) + { ...state, waiters: state.waiters.mapRemove(nonce) } + .toLookupDoneOutput(state.waiters.get(nonce), Unavailable) // ------------------------------------------------------------------------ // Frames from the network @@ -201,9 +159,9 @@ module shim { | LookupReply(reply) => if (not(state.hasWaiter(reply.nonce))) state.toNoShimOutput() else - val waiter = state.waiters.get(reply.nonce) + val query = state.waiters.get(reply.nonce) { ...state, waiters: state.waiters.mapRemove(reply.nonce) } - .toLookupDoneOutput(waiter.query, interpretReply(reply.reply, waiter.query)) + .toLookupDoneOutput(query, interpretReply(reply.reply, query)) | Submit(_) => state.toShimErrorOutput("not a reply frame") | Lookup(_) => state.toShimErrorOutput("not a reply frame") } @@ -215,7 +173,7 @@ module shim { pure def shim(state: ShimState, input: ShimInput): ShimResult = match input { | SendTxSInput(send) => sendTransaction(state, send.input, send.handedOver) - | GetTxSInput(lookup) => getTransaction(state, lookup.query, lookup.start) + | GetTxSInput(query) => getTransaction(state, query) | FrameSInput(msg) => receive(state, msg) | LookupTimeoutSInput(nonce) => lookupTimeout(state, nonce) } diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt index 6dfd37c9..8f313b5d 100644 --- a/zeronym/spec/protocol/state.qnt +++ b/zeronym/spec/protocol/state.qnt @@ -27,15 +27,15 @@ module state { /// how many requests of each kind it has made. type Wallet = { log: List[WalletEvent], sends: int, gets: int } - /// The third party: a client of the hubs' public address that is not the + /// The third party: a client of the hub's public address that is not the /// shim. `txids` are the transaction ids it has learned out of band; `own` /// are payloads of its own making. type ThirdParty = { txids: Set[TxId], own: Set[Payload], nextNonce: Nonce, requests: int } - /// - `polled`: the hubs whose cadence loop has asked for the tip since the + /// - `polled`: whether the hub's cadence loop has asked for the tip since the /// last block arrived. - /// - `flightBlocks`: for each hub, the blocks that have arrived since its - /// flush in flight began; zero while no flush is in flight. Both are the + /// - `flightBlocks`: the blocks that have arrived since the hub's flush in + /// flight began; zero while no flush is in flight. Both are the /// environment's bookkeeping of time; no component reads them. /// - `operator`: every transaction the shim has handed the operator's /// indexer. The operator is assumed to publish nothing itself. @@ -43,9 +43,9 @@ module state { /// by the disclosure step and by nothing else. type System = { indexer: IndexerState, - hubs: HubId -> HubState, - polled: Set[HubId], - flightBlocks: HubId -> int, + hub: HubState, + polled: bool, + flightBlocks: int, shim: ShimState, net: Net, wallet: Wallet, @@ -54,14 +54,14 @@ module state { disclosed: Set[Payload], } - /// The system at rest: the chain at `height`, every hub started and waiting + /// The system at rest: the chain at `height`, the hub started and waiting /// for its first tip, nothing sent. pure def initialSystem(config: Config, params: HubParams, height: Height): System = { indexer: initialIndexer(height), - hubs: config.hubs.indices().map(i => config.hubs[i]).mapBy(_ => startingHub(params)), - polled: Set(), - flightBlocks: config.hubs.indices().map(i => config.hubs[i]).mapBy(_ => 0), - shim: initialShim(config.hubs), + hub: startingHub(params), + polled: false, + flightBlocks: 0, + shim: initialShim, net: Set(), wallet: { log: [], sends: 0, gets: 0 }, operator: Set(), @@ -74,74 +74,63 @@ module state { /// component then took. type Label = | Init - | WalletSend({ input: SendInput, handedOver: int }) - | WalletGet({ query: TxId, start: int }) + | WalletSend({ input: SendInput, handedOver: bool }) + | WalletGet(TxId) | ShimReceive(Mail) | ShimLookupTimeout(Nonce) - | HubReceive({ hub: HubId, mail: Mail }) - | HubObserveTip({ hub: HubId, tip: Height }) - | HubTipStale({ hub: HubId, estimate: Height }) - | HubFlushBegin(HubId) - | IndexerVerdict({ hub: HubId, payload: Payload }) - | HubFlushEnd(HubId) - | HubBeginDrain(HubId) - | HubCrash(HubId) - | HubRestart(HubId) + | HubReceive(Mail) + | HubObserveTip(Height) + | HubTipStale(Height) + | HubFlushBegin + | IndexerVerdict(Payload) + | HubFlushEnd + | HubBeginDrain + | HubCrash + | HubRestart | ChainAdvance | ChainMine(TxId) | ThirdPartyLearnsTxid(TxId) - | ThirdPartyLookup({ txid: TxId, hub: HubId }) - | ThirdPartySubmit({ payload: Payload, hub: HubId }) + | ThirdPartyLookup(TxId) + | ThirdPartySubmit(Payload) | ByzDisclose(Payload) /// What an observer of the run has recorded. It is not protocol state: no /// component reads it, and it is derived at every step from the states /// before and after, never from what a component reports about itself. /// - /// - `everQueued`: every payload that has been in a hub's queue. - /// - `admitted`: for a payload's first entry into a hub's queue, the true - /// chain height at that moment and the tip the hub believed in. + /// - `everQueued`: every payload that has been in the hub's queue. + /// - `admitted`: for a payload's first entry into the queue, the true chain + /// height at that moment and the tip the hub believed in. /// - `offers`: every time a flush put a payload in flight: the true chain /// height, the requeues the entry had had (`attempt`), and how many times - /// this hub had offered the payload before (`nth`). + /// the hub had offered the payload before (`nth`). /// - `verdicts`: every time an entry in flight got its answer from the /// indexer: the true chain height at that moment, which offer of the /// payload it answers (`nth`), and whether a node judged the transaction /// (`final`) or nothing did and it was set aside for requeue. /// - `windows`: for each lookup the shim has sent, the answers that were - /// true at the hub it asked at some point while it waited. + /// true at the hub at some point while it waited. /// - `refusals`: the admission refusals that have been sent. /// - `dropped`: entries a requeue gave up on, with the requeues they had had. - type Offer = { hub: HubId, payload: Payload, height: Height, attempt: int, nth: int } - type Judgement = { hub: HubId, payload: Payload, height: Height, nth: int, final: bool } + type Offer = { payload: Payload, height: Height, attempt: int, nth: int } + type Judgement = { payload: Payload, height: Height, nth: int, final: bool } type Audit = { - everQueued: HubId -> Set[Payload], - admitted: (HubId, Payload) -> { height: Height, tip: Height }, + everQueued: Set[Payload], + admitted: Payload -> { height: Height, tip: Height }, offers: Set[Offer], verdicts: Set[Judgement], windows: Nonce -> Set[LookupObs], refusals: Set[Refusal], - dropped: Set[{ hub: HubId, payload: Payload, attempts: int }], + dropped: Set[{ payload: Payload, attempts: int }], } // ------------------------------------------------------------------------ // Views // ------------------------------------------------------------------------ - pure def hubIds(s: System): Set[HubId] = - s.hubs.keys() - pure def height(s: System): Height = s.indexer.height - /// The payloads in `hub`'s queue. - pure def queuedAt(s: System, hub: HubId): Set[Payload] = - s.hubs.get(hub).queued() - - /// The payloads `hub` has out with a flush. - pure def inFlightAt(s: System, hub: HubId): Set[Payload] = - s.hubs.get(hub).inFlight() - /// Where `txid` stands on the chain. pure def onChain(s: System, txid: TxId): Inclusion = s.indexer.inclusion(txid) @@ -162,65 +151,58 @@ module state { | _ => acc }) - /// The submissions `client` has addressed to `hub`, as (nonce, payload). - pure def submissions(s: System, client: Addr, hub: HubId): Set[(Nonce, Payload)] = + /// The submissions `client` has addressed to the hub, as (nonce, payload). + pure def submissions(s: System, client: Addr): Set[(Nonce, Payload)] = s.net.fold(Set(), (acc, mail) => match mail.msg { | Submit(submit) => - if (mail.src == client and mail.dst == HubAddr(hub)) - acc.union(Set((submit.nonce, submit.payload))) + if (mail.src == client and mail.dst == HubAddr) acc.union(Set((submit.nonce, submit.payload))) else acc | _ => acc }) - /// The acks `hub` has sent `client`, as (nonce, ack). - pure def acks(s: System, hub: HubId, client: Addr): Set[(Nonce, WireAck)] = + /// The acks the hub has sent `client`, as (nonce, ack). + pure def acks(s: System, client: Addr): Set[(Nonce, WireAck)] = s.net.fold(Set(), (acc, mail) => match mail.msg { | Ack(ack) => - if (mail.src == HubAddr(hub) and mail.dst == client) acc.union(Set((ack.nonce, ack.ack))) + if (mail.src == HubAddr and mail.dst == client) acc.union(Set((ack.nonce, ack.ack))) else acc | _ => acc }) - /// The payloads `hub` has acknowledged as accepted, to any client. - pure def ackedAt(s: System, hub: HubId): Set[Payload] = + /// The payloads the hub has acknowledged as accepted, to any client. + pure def acked(s: System): Set[Payload] = Set(ShimAddr, ThirdPartyAddr).map(client => - tuples(s.submissions(client, hub), s.acks(hub, client)) + tuples(s.submissions(client), s.acks(client)) .filter(((submit, ack)) => submit._1 == ack._1 and ack._2 == WAccepted) .map(((submit, _)) => submit._2) ).flatten() - /// The lookups `client` has addressed to hubs, as (nonce, hub, txid). - pure def lookups(s: System, client: Addr): Set[(Nonce, HubId, TxId)] = + /// The lookups `client` has addressed to the hub, as (nonce, txid). + pure def lookups(s: System, client: Addr): Set[(Nonce, TxId)] = s.net.fold(Set(), (acc, mail) => match mail.msg { | Lookup(lookup) => - match mail.dst { - | HubAddr(hub) => - if (mail.src == client) acc.union(Set((lookup.nonce, hub, lookup.txid))) else acc - | _ => acc - } + if (mail.src == client and mail.dst == HubAddr) acc.union(Set((lookup.nonce, lookup.txid))) + else acc | _ => acc }) - /// The lookup replies addressed to `client`, as (nonce, hub, reply). - pure def replies(s: System, client: Addr): Set[(Nonce, HubId, WireReply)] = + /// The lookup replies the hub has addressed to `client`, as (nonce, reply). + pure def replies(s: System, client: Addr): Set[(Nonce, WireReply)] = s.net.fold(Set(), (acc, mail) => match mail.msg { | LookupReply(reply) => - match mail.src { - | HubAddr(hub) => - if (mail.dst == client) acc.union(Set((reply.nonce, hub, reply.reply))) else acc - | _ => acc - } + if (mail.src == HubAddr and mail.dst == client) acc.union(Set((reply.nonce, reply.reply))) + else acc | _ => acc }) /// The transaction bodies in lookup replies addressed to `client`. pure def repliedBodies(s: System, client: Addr): Set[Payload] = s.replies(client).fold(Set(), (acc, reply) => - match reply._3 { + match reply._2 { | WFound(found) => match found.body { | Some(payload) => acc.union(Set(payload)) @@ -245,7 +227,7 @@ module state { /// /// This is derived from what the third party can observe. Nothing updates it /// when a transaction is published, so a property over it constrains what - /// the hubs put in their replies. + /// the hub puts in its replies. pure def tpLearned(s: System): Set[Payload] = s.repliedBodies(ThirdPartyAddr).union(s.operator).union(s.disclosed).exclude(s.thirdParty.own) @@ -267,17 +249,15 @@ module state { | _ => acc }) - /// The payloads addressed to `hub` by anyone: what it has received or may + /// The payloads addressed to the hub by anyone: what it has received or may /// yet receive. - pure def seenByHub(s: System, hub: HubId): Set[Payload] = - Set(ShimAddr, ThirdPartyAddr).map(client => - s.submissions(client, hub).map(submit => submit._2)).flatten() + pure def seenByHub(s: System): Set[Payload] = + Set(ShimAddr, ThirdPartyAddr).map(client => s.submissions(client).map(submit => submit._2)).flatten() /// What the Byzantine components, taken together, are able to reveal. pure def disclosable(s: System, roles: Roles): Set[Payload] = (if (roles.indexer == Byzantine) s.indexer.offered else Set()) - .union(s.hubIds().filter(hub => roles.hubs.get(hub) == Byzantine) - .map(hub => s.seenByHub(hub)).flatten()) + .union(if (roles.hub == Byzantine) s.seenByHub() else Set()) // ------------------------------------------------------------------------ // Putting outputs where they go @@ -291,7 +271,7 @@ module state { /// The system after the shim took `result`. `via` is the nonce of the frame /// that was delivered to it, if the step was a delivery: it is recorded with - /// a lookup answer so the answer can be traced to the hub that gave it. + /// a lookup answer so the answer can be traced to the reply that gave it. pure def shimStepped(s: System, result: ShimResult, via: Option[Nonce]): System = val stepped = { ...s, shim: result.state } match result.out { @@ -299,50 +279,44 @@ module state { { ...stepped, operator: stepped.operator.union(Set(payload)) } .logged(Sent({ input: Clean(payload), obs: SentToOperator })) | DivertedOutput(diverted) => - val dispatched = stepped.posted(diverted.frames.map(frame => - { src: ShimAddr, dst: HubAddr(frame.hub), msg: frame.msg })) - dispatched.logged(Sent({ input: Clean(diverted.payload), obs: diverted.told })) + stepped.posted(Set({ src: ShimAddr, dst: HubAddr, msg: diverted.frame })) + .logged(Sent({ input: Clean(diverted.payload), obs: diverted.told })) | SendDoneOutput(done) => stepped.logged(Sent(done)) - | LookupSentOutput(sent) => - stepped.posted(Set({ src: ShimAddr, dst: HubAddr(sent.frame.hub), msg: sent.frame.msg })) + | LookupSentOutput(frame) => stepped.posted(Set({ src: ShimAddr, dst: HubAddr, msg: frame })) | LookupDoneOutput(done) => stepped.logged(Got({ query: done.query, obs: done.result, via: via })) | NoShimOutput => stepped | ShimErrorOutput(_) => stepped } - /// The system after `hub` moved to `state` on its own schedule. A hub with + /// The system after the hub moved to `state` on its own schedule. A hub with /// no flush in flight has no flight time. - pure def hubMoved(s: System, hub: HubId, state: HubState): System = - { ...s, - hubs: s.hubs.set(hub, state), - flightBlocks: if (state.flush == Idle) s.flightBlocks.set(hub, 0) else s.flightBlocks, - } + pure def hubMoved(s: System, state: HubState): System = + { ...s, hub: state, flightBlocks: if (state.flush == Idle) 0 else s.flightBlocks } - /// The system after one more block: the chain is a block higher, no hub has - /// asked for the new tip yet, and every flush in flight has been out one + /// The system after one more block: the chain is a block higher, the hub has + /// not asked for the new tip yet, and a flush in flight has been out one /// block longer. pure def blockArrived(s: System): System = { ...s, indexer: indexerApply(s.indexer, AdvanceIInput, NoIndexerOutput), - polled: Set(), - flightBlocks: s.hubs.keys().mapBy(hub => - if (s.hubs.get(hub).flush == Idle) 0 else s.flightBlocks.get(hub) + 1), + polled: false, + flightBlocks: if (s.hub.flush == Idle) 0 else s.flightBlocks + 1, } - /// The system after `hub` took `result` on a frame from `client`. Its ack or - /// lookup reply is put on the wire and sent back; nothing else a hub outputs - /// leaves it. - pure def hubReplied(s: System, hub: HubId, client: Addr, result: HubResult): System = - val stepped = { ...s, hubs: s.hubs.set(hub, result.state) } + /// The system after the hub took `result` on a frame from `client`. Its ack + /// or lookup reply is put on the wire and sent back; nothing else a hub + /// outputs leaves it. + pure def hubReplied(s: System, client: Addr, result: HubResult): System = + val stepped = { ...s, hub: result.state } match result.out { | AckOutput(ack) => stepped.posted(Set({ - src: HubAddr(hub), dst: client, msg: Ack({ nonce: ack.nonce, ack: renderAck(ack.kind) }), + src: HubAddr, dst: client, msg: Ack({ nonce: ack.nonce, ack: renderAck(ack.kind) }), })) | LookupReplyOutput(reply) => stepped.posted(Set({ - src: HubAddr(hub), dst: client, + src: HubAddr, dst: client, msg: LookupReply({ nonce: reply.nonce, reply: render(reply.outcome) }), })) | _ => stepped @@ -364,12 +338,12 @@ module state { pure def countGet(s: System): System = { ...s, wallet: { ...s.wallet, gets: s.wallet.gets + 1 } } - /// The third party sends `msg` to `hub`, under a nonce of its own. - pure def thirdPartySent(s: System, hub: HubId, msg: Msg): System = + /// The third party sends `msg` to the hub, under a nonce of its own. + pure def thirdPartySent(s: System, msg: Msg): System = { ...s, thirdParty: { ...s.thirdParty, nextNonce: s.thirdParty.nextNonce + 1, requests: s.thirdParty.requests + 1, }, - }.posted(Set({ src: ThirdPartyAddr, dst: HubAddr(hub), msg: msg })) + }.posted(Set({ src: ThirdPartyAddr, dst: HubAddr, msg: msg })) } diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index 4810940a..c097da28 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -23,8 +23,7 @@ module baselineScenarios { import configs.* from "../instances" import protocol(CONFIG = baseline).* from "../protocol" - def queue = s.hubs.get("h1").queue - def tpAcks = s.acks("h1", ThirdPartyAddr) + def tpAcks = s.acks(ThirdPartyAddr) // ------------------------------------------------------------------------ // Witnesses @@ -36,20 +35,20 @@ module baselineScenarios { .then(block) // Height 2. The shim diverts and answers at once; the operator sees // nothing. - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) - .expect(s.queuedAt("h1") == Set(early) and s.operator == Set()) - .then(lookUp("h1", 1, "early")) + .expect(s.hub.queued() == Set(early) and s.operator == Set()) + .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) // Height 3 is a flush boundary. .then(block) - .then(flush("h1", [early], Accepted)) - .expect(s.queuedAt("h1") == Set() and s.onChain("early") == InMempool) - .then(lookUp("h1", 2, "early")) + .then(flush([early], Accepted)) + .expect(s.hub.queued() == Set() and s.onChain("early") == InMempool) + .then(lookUp(2, "early")) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(2) })) .then(block) .then(chainMineWith("early")) - .then(lookUp("h1", 3, "early")) + .then(lookUp(3, "early")) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 4 }), via: Some(3) })) .expect(wPending and wTxInMempool and wTxMined) .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) @@ -60,7 +59,7 @@ module baselineScenarios { /// A pass-through transaction goes to the operator and nowhere else. run passThroughIsForwardedTest = started - .then(sendToAll(plain)) + .then(sendToHub(plain)) .expect(lastEvent == Sent({ input: Clean(plain), obs: SentToOperator })) .expect(s.operator == Set(plain) and s.net == Set()) .expect(vOperatorBlind and operatorBlind and vQueuedBytesConfidential and queuedBytesConfidential) @@ -69,65 +68,75 @@ module baselineScenarios { run everyRefusalTest = init // The hub has started and has seen no tip. - .then(thirdPartySubmitWith(garbage, "h1")) - .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) + .then(thirdPartySubmitWith(garbage)) + .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) .expect(wRefusedTipStale and tpAcks == Set((0, WRefused(WTipStale)))) - .then(allObserve) - .then(thirdPartySubmitWith(bloat, "h1")) - .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 1, bloat)) + .then(observe) + .then(thirdPartySubmitWith(bloat)) + .then(deliverSubmitFrom(ThirdPartyAddr, 1, bloat)) .expect(wRefusedTooLarge and tpAcks.contains((1, WRefused(WTooLarge)))) // Height 3, and the flush scheduled there has run. The next is at 6, // which a transaction expiring at 5 does not survive. .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(submitTo("h1", 0, tight)) - .expect(wRefusedExpiryTooTight and s.acks("h1", ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) + .then(hubFlushBegin) + .then(submitTo(0, tight)) + .expect(wRefusedExpiryTooTight and s.acks(ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) // Two admissions fill the queue. The network then delivers the third // party's first frame a second time. - .then(submitTo("h1", 1, junk)) - .then(submitTo("h1", 2, early)) - .expect(s.queuedAt("h1") == Set(junk, early)) - .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) + .then(submitTo(1, junk)) + .then(submitTo(2, early)) + .expect(s.hub.queued() == Set(junk, early)) + .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) .expect(wRefusedFull and not(wRefusedDraining)) // Draining is checked before size: the oversize frame, delivered again, // is now refused for that. On the wire it is the same refusal as full. - .then(hubBeginDrainWith("h1")) - .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 1, bloat)) + .then(hubBeginDrain) + .then(deliverSubmitFrom(ThirdPartyAddr, 1, bloat)) .expect(wRefusedDraining) .expect(tpAcks == Set( (0, WRefused(WTipStale)), (0, WRefused(WQueueFull)), (1, WRefused(WTooLarge)), (1, WRefused(WQueueFull)), )) - .expect(audit.everQueued.get("h1") == Set(junk, early)) + .expect(audit.everQueued == Set(junk, early)) /// W8. The accepted disclosure: a third party that knows a txid is told it /// is queued, and is not given the bytes. run thirdPartyLearnsItIsQueuedTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early", "h1")) - .then(deliverLookupFrom(ThirdPartyAddr, "h1", 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .then(thirdPartyLookupWith("early")) + .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: MEMPOOL_HEIGHT })))) .expect(wQueuedDisclosed) .expect(s.tpLearned() == Set() and queuedBytesConfidential) + /// A lookup that gets no reply in time fails closed, and G4 holds of it. + run lookupTimesOutTest = + started + .then(block) + .then(submitTo(0, early)) + .then(ask("early")) + .then(timeOutLookup(1)) + .expect(lastEvent == Got({ query: "early", obs: Unavailable, via: None })) + .expect(s.shim.waiters == Map() and lookupValidityPerHub) + /// W9. A queued payload the hub cannot parse has no txid to be found by. run unparseableIsQueuedAndMissedTest = started - .then(submitTo("h1", 0, junk)) - .expect(lastEvent == Sent({ input: Clean(junk), obs: SentOk }) and s.queuedAt("h1") == Set(junk)) - .then(lookUp("h1", 1, "junk")) + .then(submitTo(0, junk)) + .expect(lastEvent == Sent({ input: Clean(junk), obs: SentOk }) and s.hub.queued() == Set(junk)) + .then(lookUp(1, "junk")) .expect(lastEvent == Got({ query: "junk", obs: NotFound, via: Some(1) })) .expect(wUnparseableMissed and lookupValidityPerHub) /// W17. Anyone can put a payload in a hub's queue. run thirdPartyPayloadIsQueuedTest = started - .then(thirdPartySubmitWith(garbage, "h1")) - .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, garbage)) - .expect(tpAcks == Set((0, WAccepted)) and s.queuedAt("h1") == Set(garbage)) + .then(thirdPartySubmitWith(garbage)) + .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) + .expect(tpAcks == Set((0, WAccepted)) and s.hub.queued() == Set(garbage)) .expect(wThirdPartyPayloadQueued and queuedBytesConfidential) // ------------------------------------------------------------------------ @@ -138,21 +147,21 @@ module baselineScenarios { run toldOkThenRefusedTest = started .then(blocks(2)) - .then(hubFlushBeginWith("h1")) - .then(submitTo("h1", 0, tight)) + .then(hubFlushBegin) + .then(submitTo(0, tight)) .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) - .expect(s.acks("h1", ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) - .expect(audit.everQueued.get("h1") == Set()) + .expect(s.acks(ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) + .expect(audit.everQueued == Set()) .expect(wToldRefusedEverywhere) /// K1b. The frame is never delivered. Nothing obliges the network to. run toldOkAndNeverDeliveredTest = started .then(block) - .then(sendToAll(early)) + .then(sendToHub(early)) .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) - .expect(s.net == Set(submitMail("h1", 0, early))) - .expect(audit.everQueued.get("h1") == Set()) + .expect(s.net == Set(submitMail(0, early))) + .expect(audit.everQueued == Set()) .expect(wToldNeverDelivered) // ------------------------------------------------------------------------ @@ -163,15 +172,15 @@ module baselineScenarios { run repliesReorderedTest = started .then(block) - .then(submitTo("h1", 0, early)) - .then(ask("early", 0)) - .then(deliverLookup("h1", 1, "early")) + .then(submitTo(0, early)) + .then(ask("early")) + .then(deliverLookup(1, "early")) .then(block) - .then(flush("h1", [early], Accepted)) - .then(ask("early", 0)) - .then(deliverLookup("h1", 2, "early")) - .then(deliverToShim(replyMail("h1", 2, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) - .then(deliverToShim(replyMail("h1", 1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .then(flush([early], Accepted)) + .then(ask("early")) + .then(deliverLookup(2, "early")) + .then(deliverToShim(replyMail(2, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .then(deliverToShim(replyMail(1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) .expect(s.wallet.log == [ Sent({ input: Clean(early), obs: SentOk }), Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(2) }), @@ -185,13 +194,13 @@ module baselineScenarios { run walletResendsPublishedTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(block) - .then(flush("h1", [early], Accepted)) - .then(lookUp("h1", 1, "early")) - .then(submitTo("h1", 2, early)) - .expect(s.queuedAt("h1") == Set(early) and s.onChain("early") == InMempool) - .then(lookUp("h1", 3, "early")) + .then(flush([early], Accepted)) + .then(lookUp(1, "early")) + .then(submitTo(2, early)) + .expect(s.hub.queued() == Set(early) and s.onChain("early") == InMempool) + .then(lookUp(3, "early")) .expect(s.wallet.log.slice(1, 4) == [ Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) }), Sent({ input: Clean(early), obs: SentOk }), @@ -204,14 +213,14 @@ module baselineScenarios { run thirdPartyResubmitsPublishedTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(block) - .then(flush("h1", [early], Accepted)) - .then(lookUp("h1", 1, "early")) + .then(flush([early], Accepted)) + .then(lookUp(1, "early")) .expect(s.tpPayloads().contains(early)) - .then(thirdPartySubmitWith(early, "h1")) - .then(deliverSubmitFrom(ThirdPartyAddr, "h1", 0, early)) - .then(lookUp("h1", 2, "early")) + .then(thirdPartySubmitWith(early)) + .then(deliverSubmitFrom(ThirdPartyAddr, 0, early)) + .then(lookUp(2, "early")) .expect(s.wallet.log.slice(1, 3) == [ Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) }), Got({ query: "early", obs: Pending, via: Some(2) }), @@ -223,12 +232,12 @@ module baselineScenarios { run flushWindowTest = started .then(block) - .then(submitTo("h1", 0, early)) - .then(lookUp("h1", 1, "early")) + .then(submitTo(0, early)) + .then(lookUp(1, "early")) .then(block) - .then(hubFlushBeginWith("h1")) - .expect(s.inFlightAt("h1") == Set(early) and s.onChain("early") == Absent) - .then(lookUp("h1", 2, "early")) + .then(hubFlushBegin) + .expect(s.hub.inFlight() == Set(early) and s.onChain("early") == Absent) + .then(lookUp(2, "early")) .expect(s.wallet.log.slice(1, 3) == [ Got({ query: "early", obs: Pending, via: Some(1) }), Got({ query: "early", obs: NotFound, via: Some(2) }), @@ -240,12 +249,12 @@ module baselineScenarios { run rejectedAtFlushTest = started .then(block) - .then(submitTo("h1", 0, early)) - .then(lookUp("h1", 1, "early")) + .then(submitTo(0, early)) + .then(lookUp(1, "early")) .then(block) - .then(flush("h1", [early], Rejected)) - .expect(s.queuedAt("h1") == Set() and s.inFlightAt("h1") == Set() and s.onChain("early") == Absent) - .then(lookUp("h1", 2, "early")) + .then(flush([early], Rejected)) + .expect(s.hub.queued() == Set() and s.hub.inFlight() == Set() and s.onChain("early") == Absent) + .then(lookUp(2, "early")) .expect(s.wallet.log.slice(1, 3) == [ Got({ query: "early", obs: Pending, via: Some(1) }), Got({ query: "early", obs: NotFound, via: Some(2) }), @@ -253,82 +262,6 @@ module baselineScenarios { .expect(not(statusNeverRegresses) and lookupValidityPerHub) } -module replicatedScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = replicated).* from "../protocol" - - /// Every hub that receives a migration queues it and publishes it. - /// W14. Two hubs publish the same payload in their own flushes. - run publishedByBothHubsTest = - started - .then(block) - .then(sendToAll(early)) - .expect(s.net == Set(submitMail("h1", 0, early), submitMail("h2", 1, early))) - .then(deliverSubmit("h1", 0, early)) - .then(deliverSubmit("h2", 1, early)) - .then(block) - .then(flush("h1", [early], Accepted)) - .then(flush("h2", [early], AlreadyKnown)) - .expect(audit.offers.map(offer => offer.hub) == Set("h1", "h2")) - .expect(wPublishedByTwoHubs) - - /// K1c. The sweep stops after the first address. One frame was handed over, - /// so the wallet is told ok; the second hub is never sent the migration. - run toldOkAfterPrefixSendTest = - started - .then(block) - .then(sends(Clean(early), 1)) - .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) - .expect(s.net == Set(submitMail("h1", 0, early))) - .expect(wToldPrefixOnly) - - /// K2f. One hub has the migration queued and says so. The next poll starts - /// at the other hub, which never received it; its not-found is final. - run hubsDisagreeTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(ask("early", 0)) - .then(deliverLookup("h1", 2, "early")) - .then(deliverToShim(replyMail("h1", 2, WFound({ body: None, height: MEMPOOL_HEIGHT })))) - .then(ask("early", 1)) - .then(deliverLookup("h2", 3, "early")) - .then(deliverToShim(replyMail("h2", 3, WNotFound))) - .expect(s.wallet.log.slice(1, 3) == [ - Got({ query: "early", obs: Pending, via: Some(2) }), - Got({ query: "early", obs: NotFound, via: Some(3) }), - ]) - // No lookup is outstanding: the shim asked nobody else. - .expect(s.shim.waiters.keys() == Set()) - // Each hub told the truth about itself. - .expect(not(statusNeverRegresses) and lookupValidityPerHub) - - /// W13. A lookup whose first hub stays silent moves on, and the next hub - /// answers. - run lookupFailsOverOnTimeoutTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(ask("early", 1)) - .expect(s.lookups(ShimAddr) == Set((2, "h2", "early"))) - .then(timeOutLookup(2)) - .expect(s.lookups(ShimAddr) == Set((2, "h2", "early"), (3, "h1", "early"))) - .then(deliverLookup("h1", 3, "early")) - .expect(wFailoverAnswered) - .then(deliverToShim(replyMail("h1", 3, WFound({ body: None, height: MEMPOOL_HEIGHT })))) - .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(3) })) - .expect(lookupValidityPerHub) -} - module byzHubScenarios { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -346,13 +279,13 @@ module byzHubScenarios { run twinAtFalseHeightIsServedTest = started .then(block) - .then(submitTo("h1", 0, early)) - .then(ask("early", 0)) + .then(submitTo(0, early)) + .then(ask("early")) .then(hubReceiveWith( - "h1", lookupMail("h1", 1, "early"), INotFound, - s.hubs.get("h1").toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(earlyTwin), height: 9 }))), + lookupMail(1, "early"), INotFound, + s.hub.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(earlyTwin), height: 9 }))), )) - .then(deliverToShim(replyMail("h1", 1, WFound({ body: Some(earlyTwin), height: 9 })))) + .then(deliverToShim(replyMail(1, WFound({ body: Some(earlyTwin), height: 9 })))) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: earlyTwin, height: 9 }), via: Some(1) })) .expect(s.height() == 2 and s.onChain("early") == Absent) .expect(wTwinServed and wFalseHeightServed) diff --git a/zeronym/spec/protocol/tests/shimTest.qnt b/zeronym/spec/protocol/tests/shimTest.qnt index 0720aac3..086cbec1 100644 --- a/zeronym/spec/protocol/tests/shimTest.qnt +++ b/zeronym/spec/protocol/tests/shimTest.qnt @@ -1,6 +1,6 @@ // -*- mode: Bluespec; -*- -/// The shim function, checked with one hub and with two. +/// The shim function. module shimTest { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -19,9 +19,6 @@ module shimTest { pure val PAYLOADS = Set(pOrchard, pPlain, pJunk, pBig, pBigPlain) pure val SEND_INPUTS = PAYLOADS.map(p => Clean(p)).union(Set(Unreadable, EmptyBody)) - pure val dispatching = initialShim(["h1", "h2"]) - pure val single = initialShim(["h1"]) - pure def after(state: ShimState, input: ShimInput): ShimState = shim(state, input).state pure def outputOf(state: ShimState, input: ShimInput): ShimOutput = shim(state, input).out @@ -31,31 +28,28 @@ module shimTest { | _ => false } - pure def send(input: SendInput, handedOver: int): ShimInput = + pure def send(input: SendInput, handedOver: bool): ShimInput = SendTxSInput({ input: input, handedOver: handedOver }) - pure def getTx(query: TxId, start: int): ShimInput = - GetTxSInput({ query: query, start: start }) - pure def ack(nonce: Nonce, given: WireAck): ShimInput = FrameSInput(Ack({ nonce: nonce, ack: given })) pure def reply(nonce: Nonce, given: WireReply): ShimInput = FrameSInput(LookupReply({ nonce: nonce, reply: given })) - /// Nonces 0 and 1 went out with a submission; nonce 2 is a lookup at `h2`. - pure val busy = dispatching.after(send(Clean(pOrchard), 2)).after(getTx("orchard", 1)) + /// Nonce 0 went out with a submission; nonce 1 is a lookup. + pure val busy = initialShim.after(send(Clean(pOrchard), true)).after(GetTxSInput("orchard")) - pure val STATES = Set(dispatching, single, busy, single.after(getTx("orchard", 0))) + pure val STATES = Set(initialShim, busy, initialShim.after(GetTxSInput("orchard"))) pure val INPUTS: Set[ShimInput] = - tuples(SEND_INPUTS, 0.to(3)).map(((input, handedOver)) => send(input, handedOver)) - .union(tuples(Set("orchard", "zz"), 0.to(2)).map(((query, start)) => getTx(query, start))) - .union(0.to(3).map(nonce => ack(nonce, WAccepted))) - .union(0.to(3).map(nonce => ack(nonce, WRefused(WQueueFull)))) - .union(0.to(3).map(nonce => reply(nonce, WNotFound))) - .union(0.to(3).map(nonce => reply(nonce, WFound({ body: Some(pOrchard), height: 4 })))) - .union(0.to(3).map(nonce => LookupTimeoutSInput(nonce))) + tuples(SEND_INPUTS, Set(true, false)).map(((input, handedOver)) => send(input, handedOver)) + .union(Set("orchard", "zz").map(query => GetTxSInput(query))) + .union(0.to(2).map(nonce => ack(nonce, WAccepted))) + .union(0.to(2).map(nonce => ack(nonce, WRefused(WQueueFull)))) + .union(0.to(2).map(nonce => reply(nonce, WNotFound))) + .union(0.to(2).map(nonce => reply(nonce, WFound({ body: Some(pOrchard), height: 4 })))) + .union(0.to(2).map(nonce => LookupTimeoutSInput(nonce))) .union(Set( FrameSInput(Submit({ nonce: 0, payload: pOrchard })), FrameSInput(Lookup({ nonce: 0, txid: "orchard" })), @@ -68,57 +62,40 @@ module shimTest { /// F5. Only a cleanly read pass-through transaction is forwarded. Every /// other input is diverted or fails closed, in every state. run onlyPassThroughIsForwardedTest = all { - assert(tuples(STATES, SEND_INPUTS, 0.to(2)).forall(((state, input, handedOver)) => + assert(tuples(STATES, SEND_INPUTS, Set(true, false)).forall(((state, input, handedOver)) => match outputOf(state, send(input, handedOver)) { | ForwardOutput(forwarded) => input == Clean(forwarded) and forwarded.class == PassThrough | _ => true })), - assert(outputOf(dispatching, send(Clean(pPlain), 2)) == ForwardOutput(pPlain)), - // Forwarding does not depend on the hubs, or on the hub frame's size. - assert(outputOf(dispatching, send(Clean(pPlain), 0)) == ForwardOutput(pPlain)), - assert(outputOf(dispatching, send(Clean(pBigPlain), 2)) == ForwardOutput(pBigPlain)), - assert(dispatching.after(send(Clean(pPlain), 2)) == dispatching), + assert(outputOf(initialShim, send(Clean(pPlain), true)) == ForwardOutput(pPlain)), + // Forwarding does not depend on the hub, or on the hub frame's size. + assert(outputOf(initialShim, send(Clean(pPlain), false)) == ForwardOutput(pPlain)), + assert(outputOf(initialShim, send(Clean(pBigPlain), true)) == ForwardOutput(pBigPlain)), + assert(initialShim.after(send(Clean(pPlain), true)) == initialShim), // A body the shim cannot parse is diverted, never forwarded. - assert(outputOf(dispatching, send(Clean(pJunk), 1)) - == DivertedOutput({ - payload: pJunk, - frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pJunk }) }), - told: SentOk, - })), + assert(outputOf(initialShim, send(Clean(pJunk), true)) + == DivertedOutput({ payload: pJunk, frame: Submit({ nonce: 0, payload: pJunk }), told: SentOk })), } run failClosedTest = all { - assert(outputOf(dispatching, send(Unreadable, 2)) == SendDoneOutput({ input: Unreadable, obs: SendUnavailable })), - assert(outputOf(dispatching, send(EmptyBody, 2)) == SendDoneOutput({ input: EmptyBody, obs: SendInvalid })), - assert(outputOf(dispatching, send(Clean(pBig), 2)) == SendDoneOutput({ input: Clean(pBig), obs: SendTooLarge })), - // No frame handed over: the hub is unreachable. - assert(Set(dispatching, single).forall(state => - outputOf(state, send(Clean(pOrchard), 0)) == SendDoneOutput({ input: Clean(pOrchard), obs: SendUnavailable }))), + assert(outputOf(initialShim, send(Unreadable, true)) == SendDoneOutput({ input: Unreadable, obs: SendUnavailable })), + assert(outputOf(initialShim, send(EmptyBody, true)) == SendDoneOutput({ input: EmptyBody, obs: SendInvalid })), + assert(outputOf(initialShim, send(Clean(pBig), true)) == SendDoneOutput({ input: Clean(pBig), obs: SendTooLarge })), + // The frame not handed over: the hub is unreachable. + assert(outputOf(initialShim, send(Clean(pOrchard), false)) + == SendDoneOutput({ input: Clean(pOrchard), obs: SendUnavailable })), // None of these leaves anything behind. - assert(Set(send(Unreadable, 2), send(EmptyBody, 2), send(Clean(pBig), 2), send(Clean(pOrchard), 0)) - .forall(input => dispatching.after(input) == dispatching)), + assert(Set(send(Unreadable, true), send(EmptyBody, true), send(Clean(pBig), true), send(Clean(pOrchard), false)) + .forall(input => initialShim.after(input) == initialShim)), } run dispatchOnlyTest = all { - // One frame per hub, each under its own nonce, and the wallet is told ok - // at once. - assert(outputOf(dispatching, send(Clean(pOrchard), 2)) == DivertedOutput({ - payload: pOrchard, - frames: Set( - { hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }, - { hub: "h2", msg: Submit({ nonce: 1, payload: pOrchard }) }, - ), - told: SentOk, - })), - // A sweep that stopped after the first address still tells the wallet ok. - assert(outputOf(dispatching, send(Clean(pOrchard), 1)) == DivertedOutput({ - payload: pOrchard, - frames: Set({ hub: "h1", msg: Submit({ nonce: 0, payload: pOrchard }) }), - told: SentOk, - })), - assert(dispatching.after(send(Clean(pOrchard), 2)).nextNonce == 2), + // One frame, under a fresh nonce, and the wallet is told ok at once. + assert(outputOf(initialShim, send(Clean(pOrchard), true)) + == DivertedOutput({ payload: pOrchard, frame: Submit({ nonce: 0, payload: pOrchard }), told: SentOk })), + assert(initialShim.after(send(Clean(pOrchard), true)).nextNonce == 1), // A submission leaves nothing to wait on, and an ack tells nobody. - assert(dispatching.after(send(Clean(pOrchard), 2)).waiters == Map()), + assert(initialShim.after(send(Clean(pOrchard), true)).waiters == Map()), assert(outputOf(busy, ack(0, WRefused(WQueueFull))) == NoShimOutput), assert(busy.after(ack(0, WRefused(WQueueFull))) == busy), } @@ -128,39 +105,28 @@ module shimTest { // ------------------------------------------------------------------------ run lookupTest = all { - // The lookup goes to the hub the cursor points at. - assert(outputOf(dispatching, getTx("orchard", 0)) - == LookupSentOutput({ frame: { hub: "h1", msg: Lookup({ nonce: 0, txid: "orchard" }) } })), - assert(outputOf(dispatching, getTx("orchard", 1)) - == LookupSentOutput({ frame: { hub: "h2", msg: Lookup({ nonce: 0, txid: "orchard" }) } })), - assert(isError(outputOf(dispatching, getTx("orchard", 2)))), - // No hub configured to ask: unavailable. - assert(outputOf(initialShim([]), getTx("orchard", 0)) - == LookupDoneOutput({ query: "orchard", result: Unavailable })), + // The lookup goes to the hub under a fresh nonce. + assert(outputOf(initialShim, GetTxSInput("orchard")) == LookupSentOutput(Lookup({ nonce: 0, txid: "orchard" }))), + assert(busy.waiters == Map(1 -> "orchard")), // Each reply arm. - assert(outputOf(busy, reply(2, WFound({ body: None, height: MEMPOOL_HEIGHT }))) + assert(outputOf(busy, reply(1, WFound({ body: None, height: MEMPOOL_HEIGHT }))) == LookupDoneOutput({ query: "orchard", result: Pending })), - assert(outputOf(busy, reply(2, WFound({ body: Some(pOrchard), height: 4 }))) + assert(outputOf(busy, reply(1, WFound({ body: Some(pOrchard), height: 4 }))) == LookupDoneOutput({ query: "orchard", result: Tx({ payload: pOrchard, height: 4 }) })), - assert(outputOf(busy, reply(2, WFound({ body: Some(pPlain), height: 4 }))) + assert(outputOf(busy, reply(1, WFound({ body: Some(pPlain), height: 4 }))) == LookupDoneOutput({ query: "orchard", result: NotFound })), - assert(outputOf(busy, reply(2, WNotFound)) == LookupDoneOutput({ query: "orchard", result: NotFound })), - assert(outputOf(busy, reply(2, WError)) == LookupDoneOutput({ query: "orchard", result: Unavailable })), - // Any reply is final: the waiter is gone, and no other hub is asked. - assert(Set(WNotFound, WError).forall(given => not(busy.after(reply(2, given)).hasWaiter(2)))), + assert(outputOf(busy, reply(1, WNotFound)) == LookupDoneOutput({ query: "orchard", result: NotFound })), + assert(outputOf(busy, reply(1, WError)) == LookupDoneOutput({ query: "orchard", result: Unavailable })), + // Any reply is final: the waiter is gone. + assert(Set(WNotFound, WError).forall(given => not(busy.after(reply(1, given)).hasWaiter(1)))), } - run lookupFailoverTest = all { - // Only a timeout moves on, to the next hub round the ring, under a fresh - // nonce. - assert(outputOf(busy, LookupTimeoutSInput(2)) - == LookupSentOutput({ frame: { hub: "h1", msg: Lookup({ nonce: 3, txid: "orchard" }) } })), - assert(not(busy.after(LookupTimeoutSInput(2)).hasWaiter(2))), - // The last hub timing out fails the lookup closed. - assert(outputOf(busy.after(LookupTimeoutSInput(2)), LookupTimeoutSInput(3)) - == LookupDoneOutput({ query: "orchard", result: Unavailable })), - // A late reply to the abandoned attempt is dropped. - assert(outputOf(busy.after(LookupTimeoutSInput(2)), reply(2, WNotFound)) == NoShimOutput), + run lookupTimeoutTest = all { + // A timeout fails the lookup closed. + assert(outputOf(busy, LookupTimeoutSInput(1)) == LookupDoneOutput({ query: "orchard", result: Unavailable })), + assert(not(busy.after(LookupTimeoutSInput(1)).hasWaiter(1))), + // A late reply to the abandoned lookup is dropped. + assert(outputOf(busy.after(LookupTimeoutSInput(1)), reply(1, WNotFound)) == NoShimOutput), assert(isError(outputOf(busy, LookupTimeoutSInput(0)))), assert(isError(outputOf(busy, LookupTimeoutSInput(9)))), } @@ -171,11 +137,11 @@ module shimTest { assert(outputOf(busy, reply(9, WNotFound)) == NoShimOutput and busy.after(reply(9, WNotFound)) == busy), // An ack under a lookup's nonce is ignored, and the lookup keeps waiting. // A reply under a submission's nonce is dropped. - assert(outputOf(busy, ack(2, WAccepted)) == NoShimOutput and busy.after(ack(2, WAccepted)) == busy), + assert(outputOf(busy, ack(1, WAccepted)) == NoShimOutput and busy.after(ack(1, WAccepted)) == busy), assert(outputOf(busy, reply(0, WNotFound)) == NoShimOutput and busy.after(reply(0, WNotFound)) == busy), // Request frames are not replies. assert(isError(outputOf(busy, FrameSInput(Submit({ nonce: 0, payload: pOrchard }))))), - assert(isError(outputOf(busy, FrameSInput(Lookup({ nonce: 2, txid: "orchard" }))))), + assert(isError(outputOf(busy, FrameSInput(Lookup({ nonce: 1, txid: "orchard" }))))), } // ------------------------------------------------------------------------ @@ -188,7 +154,6 @@ module shimTest { assert(tuples(STATES, INPUTS).forall(((state, input)) => val result = shim(state, input) and { - result.state.hubs == state.hubs, result.state.nextNonce >= state.nextNonce, isError(result.out) implies result.state == state, })) diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 0ad276f7..c695b6b0 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -22,54 +22,52 @@ module byzHubTrust { import configs.* from "../instances" import protocol(CONFIG = byzHub).* from "../protocol" - def h1 = s.hubs.get("h1") - /// G2 needs the hub. Asked by a third party about a queued txid, it answers /// with the queued bytes. run hubServesQueuedBodyTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early", "h1")) + .then(thirdPartyLookupWith("early")) .then(hubReceiveWith( - "h1", fromThirdParty(lookupMail("h1", 0, "early")), INotFound, - h1.toLookupReplyOutput(0, FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT }))), + fromThirdParty(lookupMail(0, "early")), INotFound, + s.hub.toLookupReplyOutput(0, FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT }))), )) - .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) .expect(not(queuedBytesConfidential)) run hubServesQueuedBodyControlTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early", "h1")) - .then(deliverLookupFrom(ThirdPartyAddr, "h1", 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .then(thirdPartyLookupWith("early")) + .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: MEMPOOL_HEIGHT })))) .expect(queuedBytesConfidential) /// G4 needs the hub. It answers not found for a transaction it has queued. run hubDeniesQueuedTest = started .then(block) - .then(submitTo("h1", 0, early)) - .then(ask("early", 0)) + .then(submitTo(0, early)) + .then(ask("early")) .then(hubReceiveWith( - "h1", lookupMail("h1", 1, "early"), INotFound, - h1.toLookupReplyOutput(1, FromIndexer(INotFound)), + lookupMail(1, "early"), INotFound, + s.hub.toLookupReplyOutput(1, FromIndexer(INotFound)), )) - .then(deliverToShim(replyMail("h1", 1, WNotFound))) + .then(deliverToShim(replyMail(1, WNotFound))) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) - .expect(s.queuedAt("h1") == Set(early) and audit.windows.get(1) == Set(Pending)) + .expect(s.hub.queued() == Set(early) and audit.windows.get(1) == Set(Pending)) .expect(not(lookupValidityPerHub)) run hubDeniesQueuedControlTest = started .then(block) - .then(submitTo("h1", 0, early)) - .then(lookUp("h1", 1, "early")) + .then(submitTo(0, early)) + .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) .expect(lookupValidityPerHub) @@ -78,15 +76,15 @@ module byzHubTrust { run hubServesFalseHeightTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(block) - .then(flush("h1", [early], Accepted)) - .then(ask("early", 0)) + .then(flush([early], Accepted)) + .then(ask("early")) .then(hubReceiveWith( - "h1", lookupMail("h1", 1, "early"), chainAnswer(s.indexer, "early"), - h1.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(early), height: 9 }))), + lookupMail(1, "early"), chainAnswer(s.indexer, "early"), + s.hub.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(early), height: 9 }))), )) - .then(deliverToShim(replyMail("h1", 1, WFound({ body: Some(early), height: 9 })))) + .then(deliverToShim(replyMail(1, WFound({ body: Some(early), height: 9 })))) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 9 }), via: Some(1) })) .expect(s.onChain("early") == InMempool) .expect(audit.windows.get(1) == Set(Tx({ payload: early, height: MEMPOOL_HEIGHT }))) @@ -95,10 +93,10 @@ module byzHubTrust { run hubServesFalseHeightControlTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(block) - .then(flush("h1", [early], Accepted)) - .then(lookUp("h1", 1, "early")) + .then(flush([early], Accepted)) + .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) })) .expect(lookupValidityPerHub) @@ -106,20 +104,34 @@ module byzHubTrust { run hubAcksWithoutAdmittingTest = started .then(block) - .then(sendToAll(early)) - .then(hubReceiveWith("h1", submitMail("h1", 0, early), INotFound, h1.toAckOutput(0, Admitted))) - .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted))) - .expect(s.queuedAt("h1") == Set() and audit.everQueued.get("h1") == Set()) + .then(sendToHub(early)) + .then(hubReceiveWith(submitMail(0, early), INotFound, s.hub.toAckOutput(0, Admitted))) + .expect(s.acks(ShimAddr) == Set((0, WAccepted))) + .expect(s.hub.queued() == Set() and audit.everQueued == Set()) .expect(not(ackImpliesQueued)) run hubAcksWithoutAdmittingControlTest = started .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .expect(s.acks("h1", ShimAddr) == Set((0, WAccepted)) and s.queuedAt("h1") == Set(early)) + .then(sendToHub(early)) + .then(deliverSubmit(0, early)) + .expect(s.acks(ShimAddr) == Set((0, WAccepted)) and s.hub.queued() == Set(early)) .expect(ackImpliesQueued) + /// G3 survives. The hub answers with another transaction; the shim compares + /// txids and refuses it. + run wrongTransactionIsRefusedTest = + started + .then(block) + .then(sendToHub(early)) + .then(ask("early")) + .then(hubReceiveWith( + lookupMail(1, "early"), INotFound, + s.hub.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(tight), height: 3 }))), + )) + .then(deliverToShim(replyMail(1, WFound({ body: Some(tight), height: 3 })))) + .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) + .expect(txidAuthenticity) } module byzIndexerTrust { @@ -145,31 +157,31 @@ module byzIndexerTrust { run indexerServesUnpublishedBodyTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(block) - .then(hubFlushBeginWith("h1")) - .then(judge("h1", early, Retryable)) + .then(hubFlushBegin) + .then(judge(early, Retryable)) .expect(s.indexer.offered == Set(early) and s.onChain("early") == Absent) .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early", "h1")) + .then(thirdPartyLookupWith("early")) .then(deliverLookupFrom( - ThirdPartyAddr, "h1", 0, "early", IFound({ body: Some(early), height: MEMPOOL_HEIGHT }), + ThirdPartyAddr, 0, "early", IFound({ body: Some(early), height: MEMPOOL_HEIGHT }), )) - .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) .expect(not(queuedBytesConfidential)) run indexerServesUnpublishedBodyControlTest = started .then(block) - .then(submitTo("h1", 0, early)) + .then(submitTo(0, early)) .then(block) - .then(hubFlushBeginWith("h1")) - .then(judge("h1", early, Retryable)) + .then(hubFlushBegin) + .then(judge(early, Retryable)) .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early", "h1")) - .then(deliverLookupFrom(ThirdPartyAddr, "h1", 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, "h1", WNotFound))) + .then(thirdPartyLookupWith("early")) + .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) + .expect(s.replies(ThirdPartyAddr) == Set((0, WNotFound))) .expect(queuedBytesConfidential) /// G4 needs the indexer. For a transaction that exists nowhere it answers @@ -178,144 +190,19 @@ module byzIndexerTrust { run indexerForgesPendingTest = started .then(block) - .then(sends(Clean(early), 1)) - .then(ask("early", 0)) - .then(deliverLookupFrom(ShimAddr, "h1", 1, "early", IFound({ body: None, height: MEMPOOL_HEIGHT }))) - .then(deliverToShim(replyMail("h1", 1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .then(sends(Clean(early), true)) + .then(ask("early")) + .then(deliverLookupFrom(ShimAddr, 1, "early", IFound({ body: None, height: MEMPOOL_HEIGHT }))) + .then(deliverToShim(replyMail(1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) - .expect(audit.everQueued.get("h1") == Set() and audit.windows.get(1) == Set(NotFound)) + .expect(audit.everQueued == Set() and audit.windows.get(1) == Set(NotFound)) .expect(not(lookupValidityPerHub)) run indexerForgesPendingControlTest = started .then(block) - .then(sends(Clean(early), 1)) - .then(lookUp("h1", 1, "early")) + .then(sends(Clean(early), true)) + .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) .expect(lookupValidityPerHub) - -} - -module replicatedOneByzTrust { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import hub.* from "../hub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = replicatedOneByz).* from "../protocol" - - // Two hubs, `h1` honest and `h2` Byzantine. Replication does not dilute - // trust: every hub receives every migration, and a lookup may start at - // either. - - def h2 = s.hubs.get("h2") - - /// G2 is required of every hub. The Byzantine replica holds the same bytes - /// as the honest one and gives them away. - run oneReplicaServesQueuedBodyTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverSubmit("h2", 1, early)) - .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early", "h2")) - .then(hubReceiveWith( - "h2", fromThirdParty(lookupMail("h2", 0, "early")), INotFound, - h2.toLookupReplyOutput(0, FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT }))), - )) - .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) - .expect(not(queuedBytesConfidential)) - - run oneReplicaServesQueuedBodyControlTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverSubmit("h2", 1, early)) - .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early", "h2")) - .then(deliverLookupFrom(ThirdPartyAddr, "h2", 0, "early", INotFound)) - .expect(s.tpLearned() == Set()) - .expect(queuedBytesConfidential) - - /// G4 is required of every hub. The cursor points at the Byzantine replica, - /// which denies a transaction it has queued; its answer is final. - run cursorLandsOnLyingReplicaTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverSubmit("h2", 1, early)) - .then(ask("early", 1)) - .then(hubReceiveWith( - "h2", lookupMail("h2", 2, "early"), INotFound, - h2.toLookupReplyOutput(2, FromIndexer(INotFound)), - )) - .then(deliverToShim(replyMail("h2", 2, WNotFound))) - .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(2) })) - .expect(s.queuedAt("h1") == Set(early) and s.queuedAt("h2") == Set(early)) - .expect(not(lookupValidityPerHub)) - - run cursorLandsOnLyingReplicaControlTest = - started - .then(block) - .then(sendToAll(early)) - .then(deliverSubmit("h1", 0, early)) - .then(deliverSubmit("h2", 1, early)) - .then(ask("early", 1)) - .then(deliverLookup("h2", 2, "early")) - .then(deliverToShim(replyMail("h2", 2, WFound({ body: None, height: MEMPOOL_HEIGHT })))) - .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(2) })) - .expect(lookupValidityPerHub) - - /// G3 survives. The Byzantine replica answers with another transaction; the - /// shim compares txids and refuses it. - run wrongTransactionIsRefusedTest = - started - .then(block) - .then(sendToAll(early)) - .then(ask("early", 1)) - .then(hubReceiveWith( - "h2", lookupMail("h2", 2, "early"), INotFound, - h2.toLookupReplyOutput(2, FromIndexer(IFound({ body: Some(tight), height: 3 }))), - )) - .then(deliverToShim(replyMail("h2", 2, WFound({ body: Some(tight), height: 3 })))) - .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(2) })) - .expect(txidAuthenticity) - - /// The per-hub guarantees survive for the honest hub. The Byzantine replica - /// acks a migration it does not queue, and admits one the expiry rule - /// refuses and publishes it late. Both are failures of that replica alone. - /// - /// And the wallet, told ok, has no honest hub holding its first migration: - /// the honest hub's frame was never delivered. That is not something - /// replication promises. - run honestReplicaKeepsItsGuaranteesTest = - started - .then(block) - .then(sendToAll(early)) - .then(hubReceiveWith("h2", submitMail("h2", 1, early), INotFound, h2.toAckOutput(1, Admitted))) - .expect(s.toldOk() == Set(early)) - .expect(audit.everQueued.get("h1") == Set() and audit.everQueued.get("h2") == Set()) - .expect(not(ackImpliesQueued) and ackImpliesQueuedForHonestHubs) - .then(block) - .then(hubFlushBeginWith("h1")) - .then(hubFlushBeginWith("h2")) - .then(sendToAll(tight)) - .then(deliverSubmit("h1", 2, tight)) - .then(hubReceiveWith( - "h2", submitMail("h2", 3, tight), INotFound, - { ...h2, queue: Map(tight -> 0) }.toAckOutput(3, Admitted), - )) - .expect(s.acks("h1", ShimAddr) == Set((2, WRefused(WExpiryTooTight)))) - .then(blocks(3)) - .then(hubFlushBeginWith("h1")) - .then(hubFlushBeginWith("h2")) - .expect(audit.offers == Set({ hub: "h2", payload: tight, height: 6, attempt: 0, nth: 0 })) - .expect(not(offeredBeforeExpiry) and offeredBeforeExpiryForHonestHubs) - .expect(conformingFirstOfferBeforeExpiryForHonestHubs) } diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index 1eda57a2..e778dc14 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -12,7 +12,6 @@ module types { type TxId = str type Height = int type Nonce = int - type HubId = str // ------------------------------------------------------------------------ // Transactions @@ -134,17 +133,17 @@ module types { // Participants // ------------------------------------------------------------------------ - /// A network address. The third party is any client of a hub's public, + /// A network address. The third party is any client of the hub's public, /// unauthenticated address other than the shim. - type Addr = ShimAddr | HubAddr(HubId) | ThirdPartyAddr + type Addr = ShimAddr | HubAddr | ThirdPartyAddr /// Whether a component follows the protocol. A Byzantine component is not /// marked in any message or state: it simply draws its transitions from a /// wider relation than the honest one. type Role = Honest | Byzantine - /// The role of each component. Each hub has its own. - type Roles = { hubs: HubId -> Role, indexer: Role } + /// The role of each component. + type Roles = { hub: Role, indexer: Role } /// How a hub's view of the chain tip relates to the true height. /// @@ -197,7 +196,6 @@ module types { payloads: Set[Payload], twins: Set[Payload], tpPayloads: Set[Payload], - hubs: List[HubId], flushInterval: int, miningMargin: int, deliveryLag: int, From 07a5335bdb462c0d8cfb4c78dede83c7dceca492 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 03:58:31 +0400 Subject: [PATCH 45/80] test(zeronym): remove the capacity and size refusals Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 34 +++++++----- zeronym/spec/protocol/check.sh | 9 ++-- zeronym/spec/protocol/hub.qnt | 10 +--- zeronym/spec/protocol/hubMachine.qnt | 12 +++-- zeronym/spec/protocol/instances.qnt | 6 +-- zeronym/spec/protocol/properties.qnt | 15 ++---- zeronym/spec/protocol/protocol.qnt | 6 --- zeronym/spec/protocol/tests/hubTest.qnt | 53 ++++++++----------- zeronym/spec/protocol/tests/scenariosTest.qnt | 25 +++------ zeronym/spec/protocol/tests/wireTest.qnt | 11 ++-- zeronym/spec/protocol/types.qnt | 3 +- zeronym/spec/protocol/wire.qnt | 10 ++-- 12 files changed, 80 insertions(+), 114 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 8b56ddac..1ca21ec7 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -126,7 +126,7 @@ definitions they justify. |---|---|---| | Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4. `divert.qnt` omits it | | Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | -| Hub | lifecycle; admission with all five refusals; queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash | S10-S19 | +| Hub | lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash | S10-S19 | | Chain / indexer | height; per-txid status; what the indexer has been offered; verdict and lookup-answer relations | S15, S22 | | Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation; frame size classes | S20, S22 | | Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | @@ -149,6 +149,7 @@ definitions they justify. | The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | +| The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | | Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties. Only the pure lemma "frame size is independent of content" is stated | @@ -322,7 +323,7 @@ stateDiagram-v2 ```mermaid stateDiagram-v2 [*] --> Absent - Absent --> Refused: admit fails (TipStale, Draining, TooLarge, ExpiryTooTight, Full) + Absent --> Refused: admit fails (TipStale, Draining, ExpiryTooTight) Refused --> Absent Absent --> Queued: admit Queued --> Queued: same bytes again (duplicate) @@ -419,7 +420,7 @@ pure. | `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | | `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | | `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, A1-A3, the run vocabulary | -| `instances.qnt` | `configs`, then one module per configuration | The fifteen configurations | +| `instances.qnt` | `configs`, then one module per configuration | The three configurations: `baseline`, `byzHub`, `byzIndexer` | | `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14 | | `tests/scenariosTest.qnt` | one module per configuration used | Witnesses and pinned gap causes | | `tests/trustTest.qnt` | one module per Byzantine configuration | One run and one control per "required" cell | @@ -519,9 +520,8 @@ The margin is 2, not 1, so that one block can arrive while a flush is in flight and still be inside it: `MAX_FLIGHT_BLOCKS` is 1 everywhere except `flakyTipSlowFlight`, where it is 2. `flakyTipNoSlack` uses a floor of 6 and `staleLagWithSlack` a floor of 8; those two do not keep the relations, and -their tests assert that. Also: at most 2 requeues, room for 2 entries, -heights up to 12, at most 3 sends and 3 lookups by the wallet and 3 requests by -the third party. +their tests assert that. Also: at most 2 requeues, heights up to 12, +at most 3 sends and 3 lookups by the wallet and 3 requests by the third party. Each configuration has an `assumptionsTest`. The simulator does not enforce `assume`, so that test is the check that counts. Every `assume` in @@ -546,7 +546,7 @@ and `flakyTipSlowFlight` each drop one, and their tests assert it is false. | F7 | Under the startup budget, a conforming payload arriving within the delivery lag passes the expiry check. This is about admission at one tip, not about when the flush happens | `hubTest::conformingTimelyPayloadIsAdmissibleTest` | | F8 | The admission decision table, in the implementation's order | `hubTest::admissionDecisionTableTest` | | F9 | Requeue, entry by entry, and the counts it reports | `hubTest::requeueTest` | -| F10 | Draining and full are one refusal on the wire | `wireTest::ackRenderingTest` | +| F10 | A draining hub refuses under the queue-full code | `wireTest::ackRenderingTest` | | F11 | `hub` and `shim` are total; an invalid input returns an error and changes nothing | `hubTest::totalityTest`, `shimTest::totalityTest` | | F12 | Each Byzantine relation contains the honest transition | `byzantineContainsHonestTest` in `hubTest`, `shimTest`, `indexerTest` | | F13 | An accepted ack is given only for a payload the hub then holds; a Byzantine hub can do otherwise | `hubTest::ackImpliesQueuedTest`, `hubTest::byzantineHubTest` | @@ -659,11 +659,10 @@ Each has a scripted run and, except W15 and W18, is counted in tier 3b. | Id | Witness | Name | Configuration | |---|---|---|---| | W1-W3 | the wallet sees pending; its transaction in the mempool; mined | `wPending`, `wTxInMempool`, `wTxMined` | `baseline` | -| W4 | each of the five refusals | `wRefusedTipStale`, `wRefusedDraining`, `wRefusedTooLarge`, `wRefusedExpiryTooTight`, `wRefusedFull` | `baseline` | +| W4 | each of the three refusals | `wRefusedTipStale`, `wRefusedDraining`, `wRefusedExpiryTooTight` | `baseline` | | W5-W7 | an entry is requeued; dropped as expired; dropped as exhausted | `wRequeued`, `wDroppedExpired`, `wDroppedExhausted` | `baseline` | | W8 | **Accepted disclosure**: a third party that knows a txid learns it is queued. The hub withholds the bytes, not the fact. See the quoted comment under [Scope](#scope) | `wQueuedDisclosed` | `baseline` | | W9 | a queued payload the hub cannot parse is asked for and missed | `wUnparseableMissed` | `baseline` | -| W12 | a queue holds more than its capacity after a requeue | `wQueueOverCapacity` | `baseline` | | W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | scripted run `tipAheadOfChainFlushesEarlyTest` (hub specification) | `byzIndexer` | | W16 | **Twin served**: the wallet is served a twin of what it sent, and a transaction at a false height; G3 holds throughout | `wTwinServed`, `wFalseHeightServed` | `byzHub` | | W17 | the third party's own payload is queued | `wThirdPartyPayloadQueued` | `baseline` | @@ -920,6 +919,20 @@ counterexample a shortest one. Three were first recorded from runs with more workers and were one to three states too long: G6a on `staleLag` (13, now 12), K5 under `quietStep` (10, now 9), `wStale` on `staleLag` (9, now 6). +With the capacity refusals removed, a hub may hold all three payloads at +once, and the tier was re-run. Every verdict and every trace length is +unchanged. `timely` still exhausts at 229 339 states, depth 51; `flakyTip` +grows from 1 468 808 to 1 753 204 states, depth 44. Two rows then missed the +five-minute limit: G6b on `flakyTipSlowFlight` (1 824 007 states at depth 30, +152 337 on the queue) and G6c on `byzIndexer` with one worker (1 229 802 +states at depth 17). Those two configurations are now checked with two +payloads: `flakyTipSlowFlight` with `early` and `late`, the supported wallets' +migrations, and `byzIndexer` with `early` and `tight`, which +`indexerWithholdsTipTest` needs. On them G6b on `flakyTipSlowFlight` holds, +164 264 states, depth 36, in 24 s with 4 workers; G6c on `byzIndexer` is +violated in 17 states, in 100 s with one worker; the other five rows have +their recorded lengths. + Reachability, each as `not(..)` and each violated: on `timely`, `wOfferWithExpiry` (6 states), `wConformingFirstOffer` (6), `wConformingFirstOfferInFlightABlock` (7), `wOffered` (7), `wRequeued` (9), @@ -1011,15 +1024,12 @@ quint verify --main=baseline --invariant='not(wTxInMempool)' --max-steps=12 zero quint verify --main=baseline --invariant='not(wTxMined)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wRefusedTipStale)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wRefusedDraining)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wRefusedTooLarge)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wRefusedExpiryTooTight)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wRefusedFull)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wRequeued)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wDroppedExpired)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wDroppedExhausted)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wQueuedDisclosed)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wUnparseableMissed)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wQueueOverCapacity)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wThirdPartyPayloadQueued)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wToldRefusedEverywhere)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=baseline --invariant='not(wToldNeverDelivered)' --max-steps=12 zeronym/spec/protocol/instances.qnt diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 4a8f041e..012a3d75 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -300,10 +300,9 @@ echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued wellFormed" -# W4 (four of the five refusals), W8, W17, K1a, K1b, and the antecedents of -# G1, G2, G8. +# W4, W8, W17, K1a, K1b, and the antecedents of G1, G2, G8. job reaches baseline step 40 \ - wRefusedTipStale wRefusedDraining wRefusedTooLarge wRefusedExpiryTooTight \ + wRefusedTipStale wRefusedDraining wRefusedExpiryTooTight \ wQueuedDisclosed wThirdPartyPayloadQueued wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS @@ -312,9 +311,9 @@ job reaches baseline quietStep 80 \ wPending wTxInMempool wTxMined wRequeued wDroppedExpired wUnparseableMissed \ vTxidAuthenticity vLookupValidityPerHub \ -- $BASELINE_HOLDS -# W4 (the fifth refusal), W7, W12. +# W7. job reaches baseline outageStep 80 \ - wRefusedFull wDroppedExhausted wQueueOverCapacity \ + wDroppedExhausted \ -- $BASELINE_HOLDS # W16, both halves. diff --git a/zeronym/spec/protocol/hub.qnt b/zeronym/spec/protocol/hub.qnt index 01606367..447e6d91 100644 --- a/zeronym/spec/protocol/hub.qnt +++ b/zeronym/spec/protocol/hub.qnt @@ -31,7 +31,6 @@ module hub { minWalletExpiry: int, // the smallest expiry delta a supported wallet sets reorgAllowance: int, // how far back a tip report is followed maxAttempts: int, // requeues an entry is allowed - queueCap: int, // entries admission will hold } /// The startup check: a transaction that takes `deliveryLag` blocks to @@ -223,14 +222,10 @@ module hub { Refused(TipStale) else if (state.phase == Draining) Refused(HubDraining) - else if (payload.oversize) - Refused(TooLarge) else if (not(survivesNextFlush(payload.expiry, state.observedTip(), state.params.flushInterval, state.params.miningMargin))) Refused(ExpiryTooTight) else if (state.queued().contains(payload)) Duplicate - else if (state.queued().size() >= state.params.queueCap) - Refused(Full) else Admitted @@ -332,8 +327,7 @@ module hub { /// tip exactly as admission judges it, is dropped as expired. /// - An entry out of attempts is dropped as exhausted. Only an entry with no /// expiry gets that far. - /// - The rest are held, even past the queue's capacity: they were admitted - /// before anything now resident. + /// - The rest are held. /// /// After the final flush the process exits and whatever was held is lost. pure def endFlush(state: HubState): HubResult = @@ -437,7 +431,7 @@ module hub { match input { | SubmitHInput(submit) => val kinds = Set(Admitted, Duplicate) - .union(Set(TipStale, HubDraining, TooLarge, ExpiryTooTight, Full).map(refusal => Refused(refusal))) + .union(Set(TipStale, HubDraining, ExpiryTooTight).map(refusal => Refused(refusal))) val holding = if (state.queued().contains(submit.payload)) state else { ...state, queue: state.queue.put(submit.payload, 0) } diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/protocol/hubMachine.qnt index 00c595a8..c8348e02 100644 --- a/zeronym/spec/protocol/hubMachine.qnt +++ b/zeronym/spec/protocol/hubMachine.qnt @@ -672,7 +672,6 @@ module hubMachine { minWalletExpiry: 7, reorgAllowance: 1, maxAttempts: 2, - queueCap: 2, }, staleWindow: 3, maxFlightBlocks: 1, @@ -693,8 +692,9 @@ module hubMachine { } // The same tip, and a flush that may stay in flight for as many blocks as - // the mining margin reserves. - pure val flakyTipSlowFlight: HubConfig = { ...flakyTip, maxFlightBlocks: 2 } + // the mining margin reserves. The supported wallets' migrations only: with + // `tight` as well TLC does not exhaust it in five minutes. + pure val flakyTipSlowFlight: HubConfig = { ...flakyTip, maxFlightBlocks: 2, payloads: Set(early, late) } // A hub that may go without a tip for a while: on the shipped relation // between the staleness window and the expiry floor, and on the relation @@ -710,9 +710,11 @@ module hubMachine { payloads: Set(orchard("early", 2, 10), orchard("late", 4, 12)), } - // One Byzantine component at a time, on the schedule of `timely`. + // One Byzantine component at a time, on the schedule of `timely`. The + // Byzantine indexer gets one supported migration and `tight`: with three + // payloads TLC does not reach its G6c counterexample in five minutes. pure val byzHub: HubConfig = { ...timely, hubRole: Byzantine } - pure val byzIndexer: HubConfig = { ...timely, indexerRole: Byzantine } + pure val byzIndexer: HubConfig = { ...timely, indexerRole: Byzantine, payloads: Set(early, tight) } /// Bytes neither the shim nor the hub can parse: no txid, no expiry. After /// a network upgrade a build does not know, every transaction looks like diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index a320c5bc..c8b569ec 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -34,10 +34,9 @@ module configs { { id: "plain", txid: Some("plain"), created: 1, expiry: Some(9), class: PassThrough, oversize: false } /// Other bytes with the txid of `early`. pure val earlyTwin = { ...early, id: "early-twin" } - /// The third party's own: one payload a hub will queue, one too large to. + /// The third party's own payload. pure val garbage: Payload = { id: "garbage", txid: Some("garbage"), created: 1, expiry: None, class: OrchardTouching, oversize: false } - pure val bloat = { ...garbage, id: "bloat", txid: Some("bloat"), oversize: true } // ------------------------------------------------------------------------ // Configurations @@ -64,7 +63,7 @@ module configs { pure val baseline: Config = { payloads: Set(early, late, tight, junk, plain), twins: Set(earlyTwin), - tpPayloads: Set(garbage, bloat), + tpPayloads: Set(garbage), flushInterval: 3, miningMargin: 2, deliveryLag: 1, @@ -73,7 +72,6 @@ module configs { freeRun: NotSlower, minWalletExpiry: 7, maxAttempts: 2, - queueCap: 2, maxFlightBlocks: 1, maxHeight: 12, maxRequests: 3, diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index 061de30f..42ed3efd 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -65,14 +65,12 @@ module properties { dropped: Set(), } - /// The refusal a refused ack stands for. The wire has one code for a full - /// hub and a draining one; the hub's phase tells them apart. - pure def refusalBehind(code: WireRefusal, phase: Phase): Refusal = + /// The refusal a refused ack stands for. + pure def refusalBehind(code: WireRefusal): Refusal = match code { | WTipStale => TipStale - | WTooLarge => TooLarge | WExpiryTooTight => ExpiryTooTight - | WQueueFull => if (phase == Draining) HubDraining else Full + | WQueueFull => HubDraining } /// How many times the hub has offered `payload`. @@ -134,7 +132,7 @@ module properties { match mail.msg { | Ack(ack) => match ack.ack { - | WRefused(code) => acc.union(Set(refusalBehind(code, pre.hub.phase))) + | WRefused(code) => acc.union(Set(refusalBehind(code))) | WAccepted => acc } | _ => acc @@ -424,11 +422,6 @@ module properties { | _ => false }) - /// W12. The queue holds more than its capacity: requeue honours the older - /// promise over the newer limit. - pure def wQueueOverCapacityIn(s: System): bool = - s.hub.queued().size() > s.hub.params.queueCap - /// W16a. The wallet is served a twin of what it sent: other bytes, same txid. pure def wTwinServedIn(s: System): bool = s.wasGiven(obs => diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index c28e65b2..83e11264 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -82,8 +82,6 @@ module protocol { pure val MIN_WALLET_EXPIRY = CONFIG.minWalletExpiry /// Requeues an entry is allowed. pure val MAX_ATTEMPTS = CONFIG.maxAttempts - /// Entries a hub's admission will hold. - pure val QUEUE_CAP = CONFIG.queueCap /// Blocks that may arrive while one flush is in flight. pure val MAX_FLIGHT_BLOCKS = CONFIG.maxFlightBlocks @@ -118,7 +116,6 @@ module protocol { minWalletExpiry: MIN_WALLET_EXPIRY, reorgAllowance: REORG_ALLOWANCE, maxAttempts: MAX_ATTEMPTS, - queueCap: QUEUE_CAP, } // ------------------------------------------------------------------------ @@ -776,15 +773,12 @@ module protocol { val wTxMined = wTxMinedIn(s) val wRefusedTipStale = wRefused(audit, TipStale) val wRefusedDraining = wRefused(audit, HubDraining) - val wRefusedTooLarge = wRefused(audit, TooLarge) val wRefusedExpiryTooTight = wRefused(audit, ExpiryTooTight) - val wRefusedFull = wRefused(audit, Full) val wRequeued = wRequeuedIn(s) val wDroppedExpired = wDroppedExpiredIn(s, audit) val wDroppedExhausted = wDroppedExhaustedIn(audit) val wQueuedDisclosed = wQueuedDisclosedIn(s) val wUnparseableMissed = wUnparseableMissedIn(s) - val wQueueOverCapacity = wQueueOverCapacityIn(s) val wTwinServed = wTwinServedIn(s) val wFalseHeightServed = wFalseHeightServedIn(s) val wThirdPartyPayloadQueued = wThirdPartyPayloadQueuedIn(s) diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt index 98eda5e0..c83a068f 100644 --- a/zeronym/spec/protocol/tests/hubTest.qnt +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -1,7 +1,7 @@ // -*- mode: Bluespec; -*- /// The hub function, checked on a small schedule: a flush every 3 blocks, a -/// mining margin of 1, at most 2 requeues, room for 2 entries. +/// mining margin of 1, at most 2 requeues. module hubTest { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -14,7 +14,6 @@ module hubTest { minWalletExpiry: 6, reorgAllowance: 1, maxAttempts: 2, - queueCap: 2, } pure def orchard(id: str, expiry: Option[Height]): Payload = @@ -24,13 +23,11 @@ module hubTest { pure val pB = orchard("b", Some(9)) pure val pC = orchard("c", None) pure val pTight = orchard("tight", Some(6)) - pure val pBig = { ...orchard("big", None), oversize: true } - pure val pBigTight = { ...pTight, id: "big-tight", oversize: true } pure val pJunk = { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } - pure val PAYLOADS = Set(pA, pB, pC, pTight, pBig, pBigTight, pJunk) + pure val PAYLOADS = Set(pA, pB, pC, pTight, pJunk) pure val HEIGHTS = Set(0, 4) - pure val REFUSALS = Set(TipStale, HubDraining, TooLarge, ExpiryTooTight, Full) + pure val REFUSALS = Set(TipStale, HubDraining, ExpiryTooTight) /// The state after `input`. pure def after(state: HubState, input: HubInput): HubState = @@ -56,16 +53,16 @@ module hubTest { pure val starting = startingHub(PARAMS) pure def running(tip: Height): HubState = starting.after(TipHInput(tip)) - /// Running at tip 5 with `pA` and `pJunk` queued: full. - pure val full = running(5).after(submit(pA)).after(submit(pJunk)) + /// Running at tip 5 with `pA` and `pJunk` queued. + pure val holding = running(5).after(submit(pA)).after(submit(pJunk)) /// The same hub one block later, with its flush in flight. - pure val flushing = full.after(TipHInput(6)).after(FlushDueHInput) + pure val flushing = holding.after(TipHInput(6)).after(FlushDueHInput) pure val stale = running(5).after(StaleHInput(8)) - pure val draining = full.after(DrainHInput) + pure val draining = holding.after(DrainHInput) pure val stopped = draining.after(FlushDueHInput).after(verdict(pA, Accepted)) .after(verdict(pJunk, Rejected)).after(FlushDoneHInput) - pure val STATES = Set(down, starting, running(2), running(5), full, flushing, stale, draining, stopped, + pure val STATES = Set(down, starting, running(2), running(5), holding, flushing, stale, draining, stopped, stale.after(DrainHInput), flushing.after(verdict(pA, Retryable))) pure val INPUTS: Set[HubInput] = @@ -117,16 +114,13 @@ module hubTest { assert(PAYLOADS.forall(payload => admission(stale.after(DrainHInput), payload) == Refused(TipStale))), // Draining comes before anything about the payload. assert(PAYLOADS.forall(payload => admission(draining, payload) == Refused(HubDraining))), - // Size before expiry. - assert(admission(running(5), pBig) == Refused(TooLarge)), - assert(admission(running(5), pBigTight) == Refused(TooLarge)), // Expiry before the queue is looked at. At tip 5 the next flush is at 6. assert(admission(running(5), pTight) == Refused(ExpiryTooTight)), - assert(admission(full, pTight) == Refused(ExpiryTooTight)), + assert(admission(holding, pTight) == Refused(ExpiryTooTight)), assert(admission(running(2), pTight) == Admitted), - // A duplicate is recognised before capacity is checked. - assert(admission(full, pA) == Duplicate), - assert(admission(full, pB) == Refused(Full)), + // A duplicate is recognised; anything else that passes is admitted. + assert(admission(holding, pA) == Duplicate), + assert(admission(holding, pB) == Admitted), assert(admission(running(5), pA) == Admitted), // An unparseable payload has no expiry and is admitted like any other. assert(admission(running(5), pJunk) == Admitted), @@ -157,15 +151,15 @@ module hubTest { run lookupTest = all { // A queue hit, whatever the indexer would have said. - assert(outputOf(full, LookupHInput({ nonce: 7, txid: "a", answer: IFound({ body: Some(pA), height: 4 }) })) + assert(outputOf(holding, LookupHInput({ nonce: 7, txid: "a", answer: IFound({ body: Some(pA), height: 4 }) })) == LookupReplyOutput({ nonce: 7, outcome: QueueHit })), // A miss forwards the indexer's answer as it came. assert(Set(INotFound, IUnavailable, IFound({ body: Some(pB), height: 4 }), IFound({ body: None, height: 0 })) .forall(answer => - outputOf(full, LookupHInput({ nonce: 7, txid: "b", answer: answer })) + outputOf(holding, LookupHInput({ nonce: 7, txid: "b", answer: answer })) == LookupReplyOutput({ nonce: 7, outcome: FromIndexer(answer) }))), // A queued payload that does not parse is never hit. - assert(full.queued().contains(pJunk) and not(full.isQueuedTxid("junk"))), + assert(holding.queued().contains(pJunk) and not(holding.isQueuedTxid("junk"))), // Once the flush has taken the queue, the same lookup misses. assert(outputOf(flushing, LookupHInput({ nonce: 7, txid: "a", answer: INotFound })) == LookupReplyOutput({ nonce: 7, outcome: FromIndexer(INotFound) })), @@ -215,10 +209,10 @@ module hubTest { // ------------------------------------------------------------------------ run flushCycleTest = all { - assert(not(full.isFlushDue()) and isError(outputOf(full, FlushDueHInput))), - assert(full.after(TipHInput(6)).isFlushDue()), + assert(not(holding.isFlushDue()) and isError(outputOf(holding, FlushDueHInput))), + assert(holding.after(TipHInput(6)).isFlushDue()), // The whole queue moves out at once. - assert(outputOf(full.after(TipHInput(6)), FlushDueHInput) == BroadcastOutput(Set(pA, pJunk))), + assert(outputOf(holding.after(TipHInput(6)), FlushDueHInput) == BroadcastOutput(Set(pA, pJunk))), assert(flushing.queue == Map() and flushing.inFlight() == Set(pA, pJunk)), // Published, already known and rejected entries leave. assert(Set(Accepted, AlreadyKnown, Rejected).forall(given => @@ -227,10 +221,10 @@ module hubTest { assert(flushing.after(verdict(pA, Retryable)).inFlight() == Set(pA, pJunk)), assert(isError(outputOf(flushing, verdict(pB, Accepted)))), assert(isError(outputOf(flushing.after(verdict(pA, Accepted)), verdict(pA, Accepted)))), - assert(isError(outputOf(full, verdict(pA, Accepted)))), + assert(isError(outputOf(holding, verdict(pA, Accepted)))), // The flush cannot end while a verdict is outstanding. assert(isError(outputOf(flushing, FlushDoneHInput))), - assert(isError(outputOf(full, FlushDoneHInput))), + assert(isError(outputOf(holding, FlushDoneHInput))), // An empty queue is still a flush event: the epoch is recorded. assert(running(5).after(TipHInput(6)).after(FlushDueHInput).lastEpoch == Some(2)), assert(outputOf(running(5).after(TipHInput(6)), FlushDueHInput) == NoHubOutput), @@ -256,10 +250,9 @@ module hubTest { assert(outputOf(returning, FlushDoneHInput) == RequeuedOutput({ held: 2, droppedExpired: 2, droppedExhausted: 1 })), // Held entries come back with one more attempt. The resident copy of the - // same bytes wins and keeps its own count. Capacity does not apply. + // same bytes wins and keeps its own count. assert(returning.after(FlushDoneHInput).queue == Map(pResident -> 0, pB -> 0, pC -> 0, pA -> 1, pJunk -> 2)), - assert(returning.after(FlushDoneHInput).queued().size() > PARAMS.queueCap), assert(returning.after(FlushDoneHInput).flush == Idle), assert(returning.after(FlushDoneHInput).lastEpoch == Some(1)), // Expiry is judged at the observed tip, not at the cadence height. @@ -327,8 +320,8 @@ module hubTest { result.out == AckOutput({ nonce: 0, kind: Admitted }) and not(result.state.queued().contains(pA)))), assert(byzHubResults(running(5), submit(pTight), PAYLOADS, HEIGHTS).exists(result => result.state.queued().contains(pTight))), - assert(byzHubResults(full, LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), PAYLOADS, HEIGHTS) - .contains(full.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(pA), height: 0 }))))), + assert(byzHubResults(holding, LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), PAYLOADS, HEIGHTS) + .contains(holding.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(pA), height: 0 }))))), // It still cannot act while it is not running. assert(byzHubResults(down, submit(pA), PAYLOADS, HEIGHTS) == Set(hub(down, submit(pA)))), } diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index c097da28..2f057dae 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -64,7 +64,7 @@ module baselineScenarios { .expect(s.operator == Set(plain) and s.net == Set()) .expect(vOperatorBlind and operatorBlind and vQueuedBytesConfidential and queuedBytesConfidential) - /// W4. Each of the five refusals, in one hub's life. + /// W4. Each of the three refusals, in one hub's life. run everyRefusalTest = init // The hub has started and has seen no tip. @@ -72,32 +72,19 @@ module baselineScenarios { .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) .expect(wRefusedTipStale and tpAcks == Set((0, WRefused(WTipStale)))) .then(observe) - .then(thirdPartySubmitWith(bloat)) - .then(deliverSubmitFrom(ThirdPartyAddr, 1, bloat)) - .expect(wRefusedTooLarge and tpAcks.contains((1, WRefused(WTooLarge)))) // Height 3, and the flush scheduled there has run. The next is at 6, // which a transaction expiring at 5 does not survive. .then(blocks(2)) .then(hubFlushBegin) .then(submitTo(0, tight)) .expect(wRefusedExpiryTooTight and s.acks(ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) - // Two admissions fill the queue. The network then delivers the third - // party's first frame a second time. - .then(submitTo(1, junk)) - .then(submitTo(2, early)) - .expect(s.hub.queued() == Set(junk, early)) - .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) - .expect(wRefusedFull and not(wRefusedDraining)) - // Draining is checked before size: the oversize frame, delivered again, - // is now refused for that. On the wire it is the same refusal as full. + // A draining hub refuses under the queue-full code. The network + // delivers the third party's frame a second time. .then(hubBeginDrain) - .then(deliverSubmitFrom(ThirdPartyAddr, 1, bloat)) + .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) .expect(wRefusedDraining) - .expect(tpAcks == Set( - (0, WRefused(WTipStale)), (0, WRefused(WQueueFull)), - (1, WRefused(WTooLarge)), (1, WRefused(WQueueFull)), - )) - .expect(audit.everQueued == Set(junk, early)) + .expect(tpAcks == Set((0, WRefused(WTipStale)), (0, WRefused(WQueueFull)))) + .expect(audit.everQueued == Set()) /// W8. The accepted disclosure: a third party that knows a txid is told it /// is queued, and is not given the bytes. diff --git a/zeronym/spec/protocol/tests/wireTest.qnt b/zeronym/spec/protocol/tests/wireTest.qnt index 45f942d7..37b96ff2 100644 --- a/zeronym/spec/protocol/tests/wireTest.qnt +++ b/zeronym/spec/protocol/tests/wireTest.qnt @@ -41,7 +41,7 @@ module wireTest { tuples(BODIES, HEIGHTS).map(((body, height)) => WFound({ body: body, height: height })) .union(Set(WNotFound, WError)) - pure val REFUSALS = Set(TipStale, HubDraining, TooLarge, ExpiryTooTight, Full) + pure val REFUSALS = Set(TipStale, HubDraining, ExpiryTooTight) /// F1. Rendering an honest outcome and reading it back gives what the /// outcome means. @@ -106,15 +106,14 @@ module wireTest { assert(sizeOf(Lookup({ nonce: 0, txid: "ta" })) == sizeOf(Lookup({ nonce: 1, txid: "tb" }))), } - /// F10. Draining and full are one refusal on the wire; a fresh admission and - /// a duplicate are one acceptance. Every other refusal keeps its own code. + /// F10. A draining hub refuses under the queue-full code; a fresh admission + /// and a duplicate are one acceptance. Every refusal has its own code. run ackRenderingTest = all { - assert(renderAck(Refused(HubDraining)) == renderAck(Refused(Full))), + assert(renderAck(Refused(HubDraining)) == WRefused(WQueueFull)), assert(renderAck(Admitted) == WAccepted), assert(renderAck(Duplicate) == WAccepted), assert(REFUSALS.forall(refusal => renderAck(Refused(refusal)) != WAccepted)), assert(tuples(REFUSALS, REFUSALS).forall(((left, right)) => - renderAck(Refused(left)) == renderAck(Refused(right)) - implies (left == right or Set(left, right) == Set(HubDraining, Full)))), + renderAck(Refused(left)) == renderAck(Refused(right)) implies left == right)), } } diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index e778dc14..1b4e8772 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -112,7 +112,7 @@ module types { // ------------------------------------------------------------------------ /// Why a hub refuses a submission, in the order admission checks them. - type Refusal = TipStale | HubDraining | TooLarge | ExpiryTooTight | Full + type Refusal = TipStale | HubDraining | ExpiryTooTight /// A hub's decision on a submission. `Duplicate` is a success: the bytes are /// already queued here. @@ -204,7 +204,6 @@ module types { freeRun: FreeRun, minWalletExpiry: int, maxAttempts: int, - queueCap: int, maxFlightBlocks: int, maxHeight: Height, maxRequests: int, diff --git a/zeronym/spec/protocol/wire.qnt b/zeronym/spec/protocol/wire.qnt index 2d99573d..a62a1c38 100644 --- a/zeronym/spec/protocol/wire.qnt +++ b/zeronym/spec/protocol/wire.qnt @@ -10,8 +10,8 @@ module wire { import basicSpells.* from "./spells/basicSpells" import types.* from "./types" - /// The refusal codes an ack can carry. There are four, for five refusals. - type WireRefusal = WExpiryTooTight | WTooLarge | WQueueFull | WTipStale + /// The refusal codes an ack can carry, one per refusal. + type WireRefusal = WExpiryTooTight | WQueueFull | WTipStale /// An ack's disposition. A fresh admission and a duplicate are the same ack. type WireAck = WAccepted | WRefused(WireRefusal) @@ -42,8 +42,8 @@ module wire { | Lookup(_) => LookupSize } - /// A hub's decision as an ack. A draining hub answers as a full one does: - /// the shim reacts to both the same way. + /// A hub's decision as an ack. A draining hub answers under the queue-full + /// code, as the implementation does. pure def renderAck(kind: AckKind): WireAck = match kind { | Admitted => WAccepted @@ -52,9 +52,7 @@ module wire { match refusal { | TipStale => WRefused(WTipStale) | HubDraining => WRefused(WQueueFull) - | TooLarge => WRefused(WTooLarge) | ExpiryTooTight => WRefused(WExpiryTooTight) - | Full => WRefused(WQueueFull) } } From 745401d50f5586371273e6092ce843d802d348fc Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 04:00:23 +0400 Subject: [PATCH 46/80] test(zeronym): drop the slower free-running clock variant Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 9 ++++++--- zeronym/spec/protocol/instances.qnt | 1 - zeronym/spec/protocol/protocol.qnt | 25 +++++++------------------ zeronym/spec/protocol/types.qnt | 4 ---- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 1ca21ec7..4317ab9a 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -142,7 +142,7 @@ definitions they justify. | Attestation, PCRs, TLS, STEVE, keymaker quorum | No in-protocol messages exist (S23). Represented by the roles | | Mixnet internals: SURBs, Sphinx, cover traffic, gateways, throttling; shim client rotation supervisor (`zeronym/shim/src/nym.rs:942-1024`); both `nym_driver.rs` | Protocol-visible effect is loss and delay | | Hub lookup concurrency bound, reply deadline, dropped acks (S21) | Refinements of "the network lost the message" | -| Wall-clock time | The staleness window is counted in blocks (`STALE_WINDOW`), and a free-running cadence height is chosen by the environment under the named assumption `freeRunNotSlowerThanChain`; there is no clock | +| Wall-clock time | The staleness window is counted in blocks (`STALE_WINDOW`), and a free-running cadence height is chosen by the environment, never behind the chain (see the tip assumption); there is no clock | | Multiple indexer endpoints and their folds | One abstract indexer per model stands for all of a hub's endpoints. Because the folds are asymmetric (S28), this document states for each Byzantine-indexer behaviour whether one lying endpoint suffices or all must lie | | Wire codecs `ZNS1` / `ZNA1` / `ZNL1` / `ZNR1` and the golden vectors (`zeronym/hub/src/wire.rs:576-579`) | Byte layouts are scoped out and are pinned by the Rust tests in both crates; the abstract `render` / `interpretReply` layer is the level this spec works at. The spec does not claim to bind the codec | | HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | @@ -150,6 +150,7 @@ definitions they justify. | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | | The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | +| A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | | Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties. Only the pure lemma "frame size is independent of content" is stated | @@ -241,8 +242,10 @@ beyond loss in the soup. report may trail the chain by up to `REORG_ALLOWANCE`. `TipMayLag`: a hub may hear nothing for a while, and is stale once the silence reaches `STALE_WINDOW` blocks; a stale hub's free-running clock is assumed never - behind the chain (`freeRunNotSlowerThanChain`) and at most one flush interval - ahead of it. + behind the chain and at most one flush interval ahead of it. The + implementation relies on the first and does not enforce it: "during a real + stall blocks arrive slower than this, so the free-running clock runs ahead + of the true height" (`zeronym/hub/src/batcher.rs:64-67`). - **Wallets.** A supported ("conforming") wallet sets an expiry at least `MIN_WALLET_EXPIRY` after the height it builds at, and its frame reaches the hub within `DELIVERY_LAG` blocks. A wallet asks only about transactions it diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index c8b569ec..02424135 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -69,7 +69,6 @@ module configs { deliveryLag: 1, reorgAllowance: 1, staleWindow: 3, - freeRun: NotSlower, minWalletExpiry: 7, maxAttempts: 2, maxFlightBlocks: 1, diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 83e11264..5b196e02 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -76,8 +76,6 @@ module protocol { pure val REORG_ALLOWANCE = CONFIG.reorgAllowance /// Blocks without a forward tip observation after which a hub is stale. pure val STALE_WINDOW = CONFIG.staleWindow - /// How a stale hub's free-running clock relates to the true height. - pure val FREE_RUN = CONFIG.freeRun /// The smallest expiry delta a supported wallet sets. pure val MIN_WALLET_EXPIRY = CONFIG.minWalletExpiry /// Requeues an entry is allowed. @@ -163,12 +161,6 @@ module protocol { pure val staleSlackFits = FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + (STALE_WINDOW - 1) <= MIN_WALLET_EXPIRY - /// A stale hub's free-running cadence clock is never behind the true height. - /// The implementation relies on this ("during a real stall blocks arrive - /// slower than this, so the free-running clock runs ahead of the true - /// height") and does not enforce it. - pure val freeRunNotSlowerThanChain = FREE_RUN == NotSlower - pure val flushIntervalPositive = FLUSH_INTERVAL > 0 /// Payloads are told apart by their bytes; a twin is a twin of something a @@ -185,7 +177,6 @@ module protocol { /// relies on each, and some exist to show what happens without one. pure val standingAssumptions = and { budgetFits, - freeRunNotSlowerThanChain, flushIntervalPositive, payloadsWellFormed, } @@ -193,7 +184,6 @@ module protocol { assume _ = budgetFits assume _ = CONFIG.reliesOnReorgSlack implies reorgSlackFits assume _ = CONFIG.reliesOnFlightWithinMargin implies flightWithinMargin - assume _ = freeRunNotSlowerThanChain assume _ = flushIntervalPositive assume _ = payloadsWellFormed @@ -284,14 +274,13 @@ module protocol { def lag: int = s.height() - s.hub.observedTip() - /// The heights a stale hub's free-running clock may read now: up to an - /// interval ahead of the chain, and not behind it unless `FREE_RUN` allows. + /// The heights a stale hub's free-running clock may read now: not behind + /// the chain, and up to an interval ahead of it. The implementation relies + /// on the first ("during a real stall blocks arrive slower than this, so the + /// free-running clock runs ahead of the true height", + /// `zeronym/hub/src/batcher.rs:64-67`) and does not enforce it. def freeRunEstimates: Set[Height] = - val earliest = match FREE_RUN { - | NotSlower => s.height() - | MayBeSlower => s.hub.observedTip() - } - CLOCK_HEIGHTS.filter(estimate => earliest <= estimate and estimate <= s.height() + FLUSH_INTERVAL) + CLOCK_HEIGHTS.filter(estimate => s.height() <= estimate and estimate <= s.height() + FLUSH_INTERVAL) /// Whether the next block may arrive. This is where the timing assumptions /// live: a block is held back until the hub has done what the tip model @@ -321,7 +310,7 @@ module protocol { | TipMayLag => and { s.hub.phase == Running implies lag < STALE_WINDOW, - (s.hub.phase == Stale and FREE_RUN == NotSlower) implies s.hub.cadenceHeight() >= s.height(), + s.hub.phase == Stale implies s.hub.cadenceHeight() >= s.height(), } }, } diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index 1b4e8772..284a14ed 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -153,9 +153,6 @@ module types { /// once the silence reaches the staleness window. type TipModel = TipTimely | TipMayRegress | TipMayLag - /// How a stale hub's free-running cadence clock relates to the true height. - type FreeRun = NotSlower | MayBeSlower - // ------------------------------------------------------------------------ // What the wallet observes // ------------------------------------------------------------------------ @@ -201,7 +198,6 @@ module types { deliveryLag: int, reorgAllowance: int, staleWindow: int, - freeRun: FreeRun, minWalletExpiry: int, maxAttempts: int, maxFlightBlocks: int, From 44d02f9d2826f39e7d6df8309ee0f5e4be07b69c Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 04:02:41 +0400 Subject: [PATCH 47/80] test(zeronym): check the two-state properties over every reachable hub state Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 74 ++++++++++++++----------- zeronym/spec/protocol/check.sh | 18 ------ zeronym/spec/protocol/protocol.qnt | 49 ---------------- zeronym/spec/protocol/tests/hubTest.qnt | 58 +++++++++++++++++++ 4 files changed, 99 insertions(+), 100 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 4317ab9a..a5236566 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -20,12 +20,10 @@ depth. A property that "holds" is one no sampled trace violated. part of this specification. The commands are given under ["Bounded model checking (not run)"](#bounded-model-checking-not-run). -**The two-state properties A1-A3 are typechecked only.** Their verdicts are -unknown. - Statements that do not rest on sampling are the ones backed by `quint test`: -the functional properties F1-F14, which are exhaustive over small finite -universes, and the scripted runs, each of which is one concrete execution. +the functional properties F1-F14 and the two-state properties A2-A3, which are +exhaustive over small finite universes, and the scripted runs, each of which +is one concrete execution. ## Running it @@ -34,9 +32,7 @@ sh zeronym/spec/protocol/check.sh ``` Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; -set `QUINT=quint` to use an installed one). The two-state properties use the -action-property syntax introduced in 0.33, so 0.32 does not typecheck the -specification. Tier 4 needs Java (21 in CI) and Apalache 0.62.1, whose jar +set `QUINT=quint` to use an installed one). Tier 4 needs Java (21 in CI) and Apalache 0.62.1, whose jar carries TLC and which Quint fetches into `~/.quint` on first use; without either the tier fails. @@ -47,8 +43,6 @@ either the tier fails. | 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` ("fails" rows: 40 to 80 steps, a few with more traces) | "holds" rows hold; "fails" rows are violated | | 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | | 4 | hub specification | `tlc.sh hubMachine.qnt hubMachine `, one row each | "holds" rows hold over every reachable state; "violated" rows are violated, by a counterexample no longer than the recorded one | -| 5 | two-state properties | `QUINT_TLC=1`, opt-in, **never run** | unknown | - Measured on the machine it was written on (Apple silicon, Quint's Rust evaluator): 4 min 40 s wall with four rows at a time (`QUINT_JOBS=4`, the default), about 14 minutes of CPU. It has not been timed on a CI runner. @@ -422,9 +416,9 @@ pure. | `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | | `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | | `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | -| `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, A1-A3, the run vocabulary | +| `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, the run vocabulary | | `instances.qnt` | `configs`, then one module per configuration | The three configurations: `baseline`, `byzHub`, `byzIndexer` | -| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14 | +| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14; A2-A3 in `hubTest.qnt` | | `tests/scenariosTest.qnt` | one module per configuration used | Witnesses and pinned gap causes | | `tests/trustTest.qnt` | one module per Byzantine configuration | One run and one control per "required" cell | @@ -619,15 +613,16 @@ chain cannot pass a running, idle hub that has not asked. | G6a | holds (`baseline`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | | G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | -| A3 | not run (TLC, `baseline`) | **required**: `hubAdmitsWhileDrainingTest` | not run | +| A3 | holds (`drainIsFinalTest`) | **required**: `hubAdmitsWhileDrainingTest` | holds (`drainIsFinalTest`) | There is no Byzantine-shim column: the shim sees every migration in plaintext and controls everything the wallet observes, so every wallet-facing guarantee assumes an honest (attested) shim. G3 is the only wallet-facing guarantee that survives a Byzantine hub or indexer, and it authenticates the txid only. G1 depends on the shim alone. With more than one hub, G2 is required of every -hub (argued, see [One hub](#one-hub)). A3 needs the hub: its "required" -cell is a scripted step, and its "holds" cells are the unrun TLC property. +hub (argued, see [One hub](#one-hub)). A3 is a property of the hub function +alone, so the indexer's role does not reach it: its "holds" cells are one +exhaustive test, and its "required" cell is a scripted step. ### Known gaps, with every component honest @@ -678,23 +673,44 @@ log has a pending, a served transaction and a not-found), `vAckImpliesQueued`. The antecedents of G6a, G6b and G6c are reachability rows of the hub specification. -### Two-state properties: not checked +### Two-state properties + +Both are steps of the hub function, so they are checked on the hub alone, in +`tests/hubTest.qnt`, over every pair of a reachable hub state and an input. +`REACH` is the closure of `starting` under: -Written in `protocol.qnt` as `temporal` definitions in the 0.33 action-property -form, and typechecked. **None has been run.** +- submits of `pA` (Orchard-touching, expiry 9) and `pJunk` (unparseable), + each through the honest hub and through every Byzantine result, with + Byzantine heights drawn from 0 and 4; +- tips 0, 4, 5, 6 and 9, and stale reports at 4, 8 and 9; +- `FlushDue`, `FlushDone`, `Drain`, `Crash` and `Restart`; +- each of the four verdicts on each payload; -| Id | Name | What it says | Class | +with the `hubTest` schedule (flush interval 3, mining margin 1, two attempts, +reorg allowance 1). `reachTest` checks that `REACH` is closed under all of +these, so the checks below are exhaustive over those parameters, not +depth-bounded. + +| Id | Test | What it says | Class | |---|---|---|---| -| A1 | `chainMonotone` | A transaction's chain status never moves backwards | assumption about the environment | -| A2 | `neverEvict` | An entry leaves the hub's queue only into a flush, or because the hub went down | guarantee | -| A3 | `drainIsFinal` | A draining honest hub's queue gains only what a flush hands back | guarantee | +| A2 | `neverEvictTest` | An entry leaves the hub's queue only into a flush, or because the hub went down or exited after its final flush | guarantee, any role | +| A3 | `drainIsFinalTest` | A draining honest hub's queue gains only what a flush hands back | guarantee, honest hub | + +A2 is stated whatever the hub's role: the Byzantine submit relation only ever +adds to a queue. The exit clause matters only for a Byzantine hub, which can +admit while draining (`hubAdmitsWhileDrainingTest`); the final flush then +stops it with those entries still queued, and they are lost with the process. +Before `REACH`, A2 was written without that clause, as an unrun `temporal` +definition; the closure found the counterexample. A3 is stated of an honest hub only. Draining is an admission rule, and a Byzantine hub is not bound by admission rules: `hubAdmitsWhileDrainingTest` takes a submission into the queue after the drain began, and its control -refuses the same frame. That run asserts the step, because the simulator -does not check `temporal` definitions. A2 is stated whatever the hub's role: -the Byzantine hub relation only ever adds to a queue. +refuses the same frame. + +The old A1, "a transaction's chain status never moves backwards", was an +assumption about the environment and is true by construction of the chain +model, so it is not stated. No liveness property is claimed: the network may lose everything, and nobody waits for an ack. @@ -1039,13 +1055,6 @@ quint verify --main=baseline --invariant='not(wToldNeverDelivered)' --max-steps= quint verify --main=byzHub --invariant='not(wTwinServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt quint verify --main=byzHub --invariant='not(wFalseHeightServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt ``` - -The two-state properties, with TLC (also `QUINT_TLC=1 sh check.sh`): - -```sh -quint verify --backend tlc --main=baseline --temporal=chainMonotone,neverEvict,drainIsFinal zeronym/spec/protocol/instances.qnt -``` - The commands use one-hub configurations: nested maps of records are supported by Apalache but slow. @@ -1059,4 +1068,3 @@ documentation and not from running it: | Unbounded integers, `powerset`, `allLists` | Not used; every universe is a finite set bounded by constants, the Byzantine sets included | | A list in the state (the wallet's log) | Bounded by `--max-steps` | | `assume` | Behaviour under verify unknown; `assumptionsTest` is the check that counts | -| Temporal definitions | For TLC only | diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 012a3d75..fd485039 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -22,7 +22,6 @@ # schedule guarantees; "violated" rows are the known gaps, the guarantees # under a Byzantine hub or indexer, and the states that must be reachable # (each as `not(..)`). Needs Java; fails, never skips, without it. -# 5 opt-in, QUINT_TLC=1: the two-state properties, with TLC. Needs Java 21. # # A row that starts holding where it is expected to fail, or the reverse, # means the specification or the prediction changed: read README.md before @@ -395,21 +394,4 @@ job tlc_violated initStaleLag step "not(wStale)" 6 job tlc_violated initStaleLagWithSlack step "not(wStale)" 6 finish -# Tier 5. Not part of the default gate, not run in CI, and never executed while -# this script was written: the verdict strings matched below are what Quint -# prints for the simulator, and are untested against the TLC backend. -if [ "${QUINT_TLC:-0}" = "1" ]; then - echo "---- 5 two-state properties (TLC)" - out=$($QUINT verify instances.qnt --backend=tlc --main=baseline \ - --temporal=chainMonotone,neverEvict,drainIsFinal 2>&1) - verdict - case $got in - holds) echo "ok baseline: holds: chainMonotone neverEvict drainIsFinal (TLC)" ;; - *) - echo "$out" | tail -40 - fail "baseline: two-state properties under TLC" - ;; - esac -fi - exit "$failures" diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 5b196e02..da9c22b4 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -780,55 +780,6 @@ module protocol { val vConformingFirstOfferJudged = vConformingFirstOfferJudgedIn(s, audit) val vAckImpliesQueued = vAckImpliesQueuedIn(s) - // ------------------------------------------------------------------------ - // Two-state properties - // ------------------------------------------------------------------------ - // - // These relate a state to its successor, which an invariant cannot. The - // simulator does not check them. `next` has to wrap a complete expression - // that reads the state, which is why they are written out here and not as - // predicates over a pair of systems. - - /// How far along the chain a transaction is. - def chainRank(txid: TxId): int = - match s.onChain(txid) { - | Absent => 0 - | InMempool => 1 - | MinedAt(_) => 2 - } - - def chainStatus(txid: TxId): Inclusion = s.onChain(txid) - - /// A1. A transaction's status on the chain never moves backwards, and a - /// mined transaction stays where it was mined. An assumption about the - /// environment, true by construction of the chain steps. - temporal chainMonotone = always( - TXIDS.forall(txid => - and { - chainRank(txid) <= next(chainRank(txid)), - chainRank(txid) == 2 implies next(chainStatus(txid)) == chainStatus(txid), - } - ).orKeep(s) - ) - - /// A2. An entry leaves the hub's queue only into a flush, or because the - /// hub went down. Nothing evicts it. - temporal neverEvict = always( - or { - s.hub.queued().subseteq(next(s.hub.queued()).union(next(s.hub.inFlight()))), - next(s.hub.phase) == Down, - }.orKeep(s) - ) - - /// A3. A draining honest hub admits nothing: its queue gains only what a - /// flush hands back. Draining is an admission rule like the others, so a - /// Byzantine hub is not bound by it (`hubAdmitsWhileDrainingTest`). - temporal drainIsFinal = always( - (ROLES.hub == Honest and s.hub.phase == Draining implies - next(s.hub.queued()).subseteq(s.hub.queued().union(s.hub.inFlight())) - ).orKeep(s) - ) - // ------------------------------------------------------------------------ // Run vocabulary // ------------------------------------------------------------------------ diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt index c83a068f..aaa31005 100644 --- a/zeronym/spec/protocol/tests/hubTest.qnt +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -325,4 +325,62 @@ module hubTest { // It still cannot act while it is not running. assert(byzHubResults(down, submit(pA), PAYLOADS, HEIGHTS) == Set(hub(down, submit(pA)))), } + + // ------------------------------------------------------------------------ + // Every reachable state + // ------------------------------------------------------------------------ + // + // `REACH` is every hub state reachable from a starting hub under + // `REACH_INPUTS`, honest or through the Byzantine submit relation. The + // properties below are exhaustive over it, for these parameters: two + // payloads, one with no txid and no expiry; tips 0, 4, 5, 6 and 9; free-run + // estimates 4, 8 and 9; every verdict on either payload. + + pure val REACH_PAYLOADS = Set(pA, pJunk) + pure val REACH_INPUTS: Set[HubInput] = + REACH_PAYLOADS.map(payload => submit(payload)) + .union(Set( + TipHInput(0), TipHInput(4), TipHInput(5), TipHInput(6), TipHInput(9), + StaleHInput(4), StaleHInput(8), StaleHInput(9), + FlushDueHInput, FlushDoneHInput, DrainHInput, CrashHInput, RestartHInput, + )) + .union(tuples(REACH_PAYLOADS, Set(Accepted, AlreadyKnown, Rejected, Retryable)) + .map(((payload, given)) => verdict(payload, given))) + + /// The states a Byzantine hub may move to on a submission. + pure def byzantineSteps(state: HubState): Set[HubState] = + REACH_PAYLOADS.map(payload => byzHubResults(state, submit(payload), REACH_PAYLOADS, HEIGHTS) + .map(result => result.state)).flatten() + + pure def successors(state: HubState): Set[HubState] = + REACH_INPUTS.map(input => hub(state, input).state).union(byzantineSteps(state)) + + /// The closure, breadth first. The bound on rounds is a budget, not a + /// depth bound: `reachTest` fails unless the result is closed. + pure val REACH: Set[HubState] = + 1.to(60).fold({ reached: Set(starting), frontier: Set(starting) }, (acc, _) => + val fresh = acc.frontier.map(state => successors(state)).flatten().exclude(acc.reached) + { reached: acc.reached.union(fresh), frontier: fresh }).reached + + /// `REACH` is a fixpoint: no input, honest or Byzantine, leaves it. + run reachTest = assert(REACH.forall(state => successors(state).subseteq(REACH))) + + /// A2. An entry leaves the queue only into a flush, or because the hub went + /// down or exited after its final flush. Nothing evicts it, whatever the + /// hub's role: the Byzantine submit relation only ever adds to the queue. + /// The exit matters only to a Byzantine hub, which can admit while + /// draining; an honest one has nothing queued by then. + run neverEvictTest = + assert(REACH.forall(state => + successors(state).forall(stepped => + stepped.phase == Down or stepped.phase == Stopped + or state.queued().subseteq(stepped.queued().union(stepped.inFlight()))))) + + /// A3. A draining honest hub admits nothing: its queue gains only what a + /// flush hands back. A Byzantine hub is not bound by it + /// (`hubAdmitsWhileDrainingTest`). + run drainIsFinalTest = + assert(REACH.forall(state => + state.phase == Draining implies REACH_INPUTS.forall(input => + hub(state, input).state.queued().subseteq(state.queued().union(state.inFlight()))))) } From bdb640b21f053035b68d0587107ff3900d9d3d45 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 04:06:44 +0400 Subject: [PATCH 48/80] test(zeronym): add the abstract hub and the abstraction lemma Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 37 ++++- zeronym/spec/protocol/abstractHub.qnt | 77 ++++++++++ zeronym/spec/protocol/check.sh | 4 +- zeronym/spec/protocol/tests/hubTest.qnt | 76 ++++++++++ .../spec/protocol/tests/realisedRunsTest.qnt | 135 ++++++++++++++++++ 5 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 zeronym/spec/protocol/abstractHub.qnt create mode 100644 zeronym/spec/protocol/tests/realisedRunsTest.qnt diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index a5236566..abc14c84 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -413,12 +413,14 @@ pure. | `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply`, `sizeOf` | | `indexer.qnt` | `indexer` | The chain and indexer as a relation: honest and Byzantine outputs, and their effect | | `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | +| `abstractHub.qnt` | `abstractHub` | The hub as the protocol sees it: `AHub`, its honest and Byzantine answers, its internal moves | | `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | | `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | | `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | | `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, the run vocabulary | | `instances.qnt` | `configs`, then one module per configuration | The three configurations: `baseline`, `byzHub`, `byzIndexer` | -| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14; A2-A3 in `hubTest.qnt` | +| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14; A2-A3 and the abstraction lemma in `hubTest.qnt` | +| `tests/realisedRunsTest.qnt` | `realisedRunsTest` | The hub inputs of each pinned run, replayed through the real hub | | `tests/scenariosTest.qnt` | one module per configuration used | Witnesses and pinned gap causes | | `tests/trustTest.qnt` | one module per Byzantine configuration | One run and one control per "required" cell | @@ -712,6 +714,39 @@ The old A1, "a transaction's chain status never moves backwards", was an assumption about the environment and is true by construction of the chain model, so it is not stated. +### The abstraction lemma + +`abstractHub.qnt` is the hub as the protocol sees it: the payloads it has +queued, the payloads out with a flush, and its wire replies. It has no phase, +tip or schedule. A submit is accepted (the payload joins the queue) or +refused under one of the three codes; a lookup is a queue hit for a queued +txid and the indexer's answer otherwise; and the internal moves are take, +settle, give back what is kept, and lose everything. The Byzantine answers +are anything, with any body from the universe, and queue the payload or not. + +Over the same `REACH` as A2 and A3, with lookups added, `hubTest` checks: + +| Test | What it says | +|---|---| +| `abstractionTest` | Every honest step of the real hub is an honest abstract step, or an error that changes nothing | +| `byzantineAbstractionTest` | Every member of `byzHubResults` for a submit or a lookup is a Byzantine abstract step | +| `realisesTest` | Each abstract move (accept, refuse, take, settle, a retryable verdict, give back, lose) has a concrete step that projects onto it | + +A temporary edit that makes `hub` ack a submission without queueing it fails +`abstractionTest`. + +What the lemma transfers: an invariant that holds over the abstract hub, and +reads only queue membership and wire replies, holds over the real hub with +these parameters. That covers G2, G3, G4 and G8. What it does not transfer is +reachability. The abstract hub answers where the real one is down, starting, +stopped or stale, so a violation or a reached state shown over it may not +happen. `tests/realisedRunsTest.qnt` closes that gap for the pinned runs: for +K1a, K2 (a) to (e), W8, W9, W16, and each "required" run of the trust matrix, +it replays the hub inputs of the run through the real hub function from a +starting hub on the `baseline` schedule, each lie as a member of the +Byzantine relation, and checks the replies and the final queue. K1b has no +hub step. + No liveness property is claimed: the network may lose everything, and nobody waits for an ack. diff --git a/zeronym/spec/protocol/abstractHub.qnt b/zeronym/spec/protocol/abstractHub.qnt new file mode 100644 index 00000000..f560d13e --- /dev/null +++ b/zeronym/spec/protocol/abstractHub.qnt @@ -0,0 +1,77 @@ +// -*- mode: Bluespec; -*- + +/// The hub as the protocol sees it: which payloads it has queued, which are +/// out with a flush, and what it puts on the wire. No phase, no tip, no +/// schedule. +/// +/// `hubTest` checks that every step of the real hub from a reachable state is +/// a step of this one (the abstraction lemma), so a protocol invariant that +/// holds over this hub, and reads only queue membership and wire replies, +/// holds over the real one. The converse does not hold: this hub answers +/// where the real one would refuse or error, so a reachable state here need +/// not be reachable there. +module abstractHub { + import basicSpells.* from "./spells/basicSpells" + import types.* from "./types" + import wire.* from "./wire" + + type AHub = { queue: Set[Payload], held: Set[Payload] } + + /// A client's request, with the indexer's answer for a lookup. + type ARequest = + | ASubmit(Payload) + | ALookup({ txid: TxId, answer: IndexerAnswer }) + + type AReply = AAck(WireAck) | AWire(WireReply) + + /// A request's effect: the hub after it, and its reply. + type AAnswer = { hub: AHub, reply: AReply } + + pure val emptyAHub: AHub = { queue: Set(), held: Set() } + + pure val WIRE_ACKS: Set[WireAck] = + Set(WAccepted, WRefused(WExpiryTooTight), WRefused(WQueueFull), WRefused(WTipStale)) + + /// An honest hub accepts a submission into its queue or refuses it under any + /// code, and answers a lookup from its queue first and its indexer second. + pure def honestAnswers(h: AHub, request: ARequest): Set[AAnswer] = + match request { + | ASubmit(payload) => + WIRE_ACKS.map(ack => + { hub: if (ack == WAccepted) { ...h, queue: h.queue.union(Set(payload)) } else h, reply: AAck(ack) }) + | ALookup(lookup) => + val hit = h.queue.exists(payload => payload.txid == Some(lookup.txid)) + val outcome = if (hit) QueueHit else FromIndexer(lookup.answer) + Set({ hub: h, reply: AWire(render(outcome)) }) + } + + /// A Byzantine hub queues a submission or not, whatever it answers, and + /// answers a lookup with anything: a body from `universe` or none, at any + /// height in `heights`, or not found, or an error. + pure def byzantineAnswers(h: AHub, request: ARequest, universe: Set[Payload], heights: Set[Height]): Set[AAnswer] = + match request { + | ASubmit(payload) => + tuples(Set(h, { ...h, queue: h.queue.union(Set(payload)) }), WIRE_ACKS) + .map(((after, ack)) => { hub: after, reply: AAck(ack) }) + | ALookup(_) => + tuples(Set(None).union(universe.map(payload => Some(payload))), heights) + .map(((body, claimed)) => WFound({ body: body, height: claimed })) + .union(Set(WNotFound, WError)) + .map(reply => { hub: h, reply: AWire(reply) }) + } + + /// The moves no client asks for: the queue goes out with a flush; an entry + /// out with it is settled; what is kept comes back; everything is lost. + pure def take(h: AHub): AHub = { queue: Set(), held: h.held.union(h.queue) } + pure def settle(h: AHub, payload: Payload): AHub = { ...h, held: h.held.exclude(Set(payload)) } + pure def giveBack(h: AHub, kept: Set[Payload]): AHub = { queue: h.queue.union(kept), held: Set() } + + pure def isInternal(h: AHub, after: AHub): bool = + or { + after == h, + after == h.take(), + h.held.exists(payload => after == h.settle(payload)), + after.held == Set() and after.queue.subseteq(h.queue.union(h.held)) and h.queue.subseteq(after.queue), + after == emptyAHub, + } +} diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index fd485039..59176bb8 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -71,8 +71,8 @@ finish() { } SPELLS="spells/basicSpells.qnt spells/soup.qnt" -MODULES="types.qnt wire.qnt indexer.qnt hub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" -FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt" +MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" +FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt tests/realisedRunsTest.qnt" INSTANCES="baseline byzHub byzIndexer" SCENARIOS="baselineScenarios byzHubScenarios" TRUST="byzHubTrust byzIndexerTrust" diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt index aaa31005..a09e42af 100644 --- a/zeronym/spec/protocol/tests/hubTest.qnt +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -6,6 +6,8 @@ module hubTest { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" import hub.* from "../hub" + import wire.* from "../wire" + import abstractHub.* from "../abstractHub" pure val PARAMS: HubParams = { flushInterval: 3, @@ -383,4 +385,78 @@ module hubTest { assert(REACH.forall(state => state.phase == Draining implies REACH_INPUTS.forall(input => hub(state, input).state.queued().subseteq(state.queued().union(state.inFlight()))))) + + // ------------------------------------------------------------------------ + // The abstraction lemma + // ------------------------------------------------------------------------ + + pure def project(state: HubState): AHub = + { queue: state.queued(), held: state.inFlight() } + + pure def requestOf(input: HubInput): Option[ARequest] = + match input { + | SubmitHInput(submitted) => Some(ASubmit(submitted.payload)) + | LookupHInput(lookup) => Some(ALookup({ txid: lookup.txid, answer: lookup.answer })) + | _ => None + } + + pure def replyOf(output: HubOutput): Option[AReply] = + match output { + | AckOutput(ack) => Some(AAck(renderAck(ack.kind))) + | LookupReplyOutput(reply) => Some(AWire(render(reply.outcome))) + | _ => None + } + + pure val LOOKUPS: Set[HubInput] = + Set(INotFound, IUnavailable, IFound({ body: Some(pA), height: 4 })) + .map(answer => LookupHInput({ nonce: 1, txid: "a", answer: answer })) + .union(Set(LookupHInput({ nonce: 1, txid: "zz", answer: IFound({ body: Some(pB), height: 4 }) }))) + + /// An error changes nothing. A request is one of `answers`; anything else + /// is an internal move with no reply. + pure def isAbstracted(state: HubState, input: HubInput, result: HubResult, answers: (AHub, ARequest) => Set[AAnswer]): bool = + val before = state.project() + val after = result.state.project() + if (result.out.isError()) result.state == state + else match input.requestOf() { + | Some(request) => + match result.out.replyOf() { + | Some(reply) => answers(before, request).contains({ hub: after, reply: reply }) + | None => false + } + | None => result.out.replyOf() == None and isInternal(before, after) + } + + /// Every step of the real hub from a reachable state is a step of the + /// abstract one, honest against honest and Byzantine against Byzantine. + run abstractionTest = + assert(REACH.forall(state => + REACH_INPUTS.union(LOOKUPS).forall(input => + isAbstracted(state, input, hub(state, input), honestAnswers)))) + + run byzantineAbstractionTest = + assert(REACH.forall(state => + REACH_PAYLOADS.map(payload => submit(payload)).union(LOOKUPS).forall(input => + byzHubResults(state, input, PAYLOADS, HEIGHTS).forall(result => + isAbstracted(state, input, result, (h, request) => byzantineAnswers(h, request, PAYLOADS, HEIGHTS)))))) + + /// Each abstract move has a concrete step that projects onto it. + pure def realises(state: HubState, input: HubInput, move: AHub, reply: Option[AReply]): bool = + val result = hub(state, input) + and { not(result.out.isError()), result.state.project() == move, result.out.replyOf() == reply } + + run realisesTest = all { + // accept, refuse + assert(realises(running(5), submit(pA), { queue: Set(pA), held: Set() }, Some(AAck(WAccepted)))), + assert(realises(draining, submit(pB), draining.project(), Some(AAck(WRefused(WQueueFull))))), + // take, settle, a retryable verdict that moves nothing + assert(realises(holding.after(TipHInput(6)), FlushDueHInput, holding.project().take(), None)), + assert(realises(flushing, verdict(pA, Accepted), flushing.project().settle(pA), None)), + assert(realises(flushing, verdict(pA, Retryable), flushing.project(), None)), + // give back what is kept: `pJunk` is held, `pA` was settled + val ending = flushing.after(verdict(pA, Accepted)).after(verdict(pJunk, Retryable)) + assert(realises(ending, FlushDoneHInput, ending.project().giveBack(Set(pJunk)), None)), + // lose + assert(realises(holding, CrashHInput, emptyAHub, None)), + } } diff --git a/zeronym/spec/protocol/tests/realisedRunsTest.qnt b/zeronym/spec/protocol/tests/realisedRunsTest.qnt new file mode 100644 index 00000000..29d3da37 --- /dev/null +++ b/zeronym/spec/protocol/tests/realisedRunsTest.qnt @@ -0,0 +1,135 @@ +// -*- mode: Bluespec; -*- + +/// The hub's part of each pinned protocol run that shows a violation or a +/// reached state, replayed through the real hub function. +/// +/// The protocol spec's hub answers wherever the real one would refuse or +/// error, so a protocol run proves nothing about reachability on its own. +/// Each realisation here is the sequence of inputs the hub sees in that run, +/// on the `baseline` schedule, with the replies the run relies on. A lie is a +/// member of the Byzantine relation, with the state unchanged. +/// +/// K1b has no hub step: the frame is never delivered. +module realisedRunsTest { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import hub.* from "../hub" + import abstractHub.* from "../abstractHub" + import configs.* from "../instances" + + pure val PARAMS: HubParams = { + flushInterval: baseline.flushInterval, + miningMargin: baseline.miningMargin, + deliveryLag: baseline.deliveryLag, + minWalletExpiry: baseline.minWalletExpiry, + reorgAllowance: baseline.reorgAllowance, + maxAttempts: baseline.maxAttempts, + } + pure val UNIVERSE = baseline.payloads.union(baseline.twins).union(baseline.tpPayloads) + pure val HEIGHTS = 0.to(baseline.maxHeight) + + type Step = Do(HubInput) | Lie({ input: HubInput, out: HubOutput }) + + pure def sub(nonce: Nonce, payload: Payload): HubInput = SubmitHInput({ nonce: nonce, payload: payload }) + pure def look(nonce: Nonce, txid: TxId, answer: IndexerAnswer): HubInput = + LookupHInput({ nonce: nonce, txid: txid, answer: answer }) + pure def judged(payload: Payload, verdict: Verdict): HubInput = + VerdictHInput({ payload: payload, verdict: verdict }) + pure def found(body: Option[Payload], height: Height): IndexerAnswer = IFound({ body: body, height: height }) + pure def tips(heights: List[Height]): List[Step] = heights.foldl([], (acc, h) => acc.append(Do(TipHInput(h)))) + pure def does(inputs: List[HubInput]): List[Step] = inputs.foldl([], (acc, i) => acc.append(Do(i))) + + pure def flushed(payload: Payload, verdict: Verdict): List[Step] = + does([FlushDueHInput, judged(payload, verdict), FlushDoneHInput]) + + pure def replyOf(output: HubOutput): List[AReply] = + match output { + | AckOutput(ack) => [AAck(renderAck(ack.kind))] + | LookupReplyOutput(reply) => [AWire(render(reply.outcome))] + | _ => [] + } + + /// The run, from a starting hub: whether every step was taken, the replies + /// in order, and the hub as the protocol sees it at the end. + pure def replay(steps: List[Step]): { ok: bool, replies: List[AReply], last: AHub } = + val end = steps.foldl({ state: startingHub(PARAMS), ok: true, replies: [] }, (acc, step) => + val result = match step { + | Do(input) => hub(acc.state, input) + | Lie(lie) => { state: acc.state, out: lie.out } + } + val taken = match step { + | Do(_) => not(match result.out { | HubErrorOutput(_) => true | _ => false }) + | Lie(lie) => byzHubResults(acc.state, lie.input, UNIVERSE, HEIGHTS).contains(result) + } + { state: result.state, ok: acc.ok and taken, replies: acc.replies.concat(result.out.replyOf()) }) + { ok: end.ok, replies: end.replies, last: { queue: end.state.queued(), held: end.state.inFlight() } } + + pure def realises(steps: List[Step], replies: List[AReply], last: AHub): bool = + replay(steps) == { ok: true, replies: replies, last: last } + + pure val accepted = AAck(WAccepted) + pure val pending = AWire(WFound({ body: None, height: MEMPOOL_HEIGHT })) + pure val inMempool = AWire(WFound({ body: Some(early), height: MEMPOOL_HEIGHT })) + pure val queuedEarly = { queue: Set(early), held: Set() } + + /// Running at height 2 with `early` queued, as most runs begin. + pure val earlyQueued = tips([1, 2]).append(Do(sub(0, early))) + /// The same, flushed and accepted at height 3. + pure val earlyPublished = earlyQueued.concat(tips([3])).concat(flushed(early, Accepted)) + + run realisedRunsTest = all { + // K1a: refused after the flush at 3. + assert(realises(tips([1, 2, 3]).concat(does([FlushDueHInput, sub(0, tight)])), + [AAck(WRefused(WExpiryTooTight))], emptyAHub)), + // K2a: pending, then in the mempool. + assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Accepted)) + .append(Do(look(2, "early", found(Some(early), MEMPOOL_HEIGHT)))), + [accepted, pending, inMempool], emptyAHub)), + // K2b and K2c (the resubmission comes from the wallet or the third party). + assert(realises(earlyPublished.append(Do(look(1, "early", found(Some(early), MEMPOOL_HEIGHT)))).append(Do(sub(2, early))) + .append(Do(look(3, "early", found(Some(early), MEMPOOL_HEIGHT)))), + [accepted, inMempool, accepted, pending], queuedEarly)), + // K2d: the flush window. + assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).append(Do(FlushDueHInput)) + .append(Do(look(2, "early", INotFound))), + [accepted, pending, AWire(WNotFound)], { queue: Set(), held: Set(early) })), + // K2e: rejected at flush. + assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Rejected)) + .append(Do(look(2, "early", INotFound))), + [accepted, pending, AWire(WNotFound)], emptyAHub)), + // W8: a third party is told `early` is queued. + assert(realises(earlyQueued.append(Do(look(0, "early", INotFound))), [accepted, pending], queuedEarly)), + // W9, the unknown upgrade: queued and missed. + assert(realises(tips([1]).concat(does([sub(0, junk), look(1, "junk", INotFound)])), + [accepted, AWire(WNotFound)], { queue: Set(junk), held: Set() })), + } + + /// The "required" runs of the trust matrix and W16, each one lie. + pure def lieAbout(nonce: Nonce, answer: IndexerAnswer, outcome: HubOutcome): Step = + Lie({ input: look(nonce, "early", answer), out: LookupReplyOutput({ nonce: nonce, outcome: outcome }) }) + + run realisedByzantineRunsTest = all { + // G2, the hub: the queued bytes, to a third party. + assert(realises(earlyQueued.append(lieAbout(0, INotFound, FromIndexer(found(Some(early), MEMPOOL_HEIGHT)))), + [accepted, inMempool], queuedEarly)), + // G4, the hub: not found for a queued transaction. + assert(realises(earlyQueued.append(lieAbout(1, INotFound, FromIndexer(INotFound))), + [accepted, AWire(WNotFound)], queuedEarly)), + // G4, the hub: a false height. + assert(realises(earlyPublished.append(lieAbout(1, found(Some(early), MEMPOOL_HEIGHT), FromIndexer(found(Some(early), 9)))), + [accepted, AWire(WFound({ body: Some(early), height: 9 }))], emptyAHub)), + // G8, the hub: accepted and not queued. + assert(realises(tips([1, 2]).append(Lie({ input: sub(0, early), out: AckOutput({ nonce: 0, kind: Admitted }) })), + [accepted], emptyAHub)), + // W16: a twin at a false height. + assert(realises(earlyQueued.append(lieAbout(1, INotFound, FromIndexer(found(Some(earlyTwin), 9)))), + [accepted, AWire(WFound({ body: Some(earlyTwin), height: 9 }))], queuedEarly)), + // G2, the indexer: the honest hub forwards the unpublished bytes. + assert(realises(earlyQueued.concat(tips([3])).concat(does([FlushDueHInput, judged(early, Retryable), + look(0, "early", found(Some(early), MEMPOOL_HEIGHT))])), + [accepted, inMempool], { queue: Set(), held: Set(early) })), + // G4, the indexer: a forged pending for a transaction nobody queued. + assert(realises(tips([1, 2]).append(Do(look(1, "early", found(None, MEMPOOL_HEIGHT)))), [pending], emptyAHub)), + } +} From f500dd9bd95b9cba64d4f503e1e9c92bfbf72018 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 04:13:23 +0400 Subject: [PATCH 49/80] test(zeronym): swap in the abstract hub in the protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 79 ++-- zeronym/spec/protocol/check.sh | 11 +- zeronym/spec/protocol/instances.qnt | 56 +-- zeronym/spec/protocol/properties.qnt | 182 +------- zeronym/spec/protocol/protocol.qnt | 388 ++++-------------- zeronym/spec/protocol/state.qnt | 102 ++--- zeronym/spec/protocol/tests/hubTest.qnt | 8 + .../spec/protocol/tests/realisedRunsTest.qnt | 16 +- zeronym/spec/protocol/tests/scenariosTest.qnt | 86 ++-- zeronym/spec/protocol/tests/trustTest.qnt | 50 +-- zeronym/spec/protocol/types.qnt | 13 - 11 files changed, 229 insertions(+), 762 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index abc14c84..4ee5008d 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -50,10 +50,9 @@ default), about 14 minutes of CPU. It has not been timed on a CI runner. only 2 to 6 of the 2000 traces, so a lower count risks losing them. Five "fails" rows have a larger count of their own, written on the row. -Tier 3 "fails" rows and tier 3b run under `step` or under one of two narrower -relations, `quietStep` (no faults, no outsiders) and `outageStep` (the indexer -is unreachable throughout). Each is a part of `step`, so a state or a violation -found under one is reachable under `step`. Uniform random choice over `step` +Tier 3 "fails" rows and tier 3b run under `step` or under a narrower +relation, `quietStep` (no faults, no outsiders). It is a part of `step`, so a +state or a violation found under it is reachable under `step`. Uniform random choice over `step` rarely gets a transaction as far as a block in 40 steps; the narrower relations do. Tier 3b re-checks each configuration's guarantees on those deeper traces. @@ -120,14 +119,14 @@ definitions they justify. |---|---|---| | Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4. `divert.qnt` omits it | | Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | -| Hub | lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash | S10-S19 | +| Hub | In the hub specification: lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash. In the protocol specification: the abstract hub, a queue and the entries out with a flush, which accepts, refuses, takes, settles, gives back and loses (see [The abstraction lemma](#the-abstraction-lemma)) | S10-S19 | | Chain / indexer | height; per-txid status; what the indexer has been offered; verdict and lookup-answer relations | S15, S22 | | Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation; frame size classes | S20, S22 | | Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | | Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned and payloads of its own making; its payload knowledge is derived from what it can observe | S13, S25 | | Network | drop, duplicate, delay, reorder; cannot forge | | | Hubs | one; see [One hub](#one-hub) | S24 | -| Tip | `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, `REORG_ALLOWANCE`, `STALE_WINDOW` and the wallet expiry floor as constants | S17, S26, S32 | +| Tip | Hub specification only: `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, the reorg allowance, the staleness window and the wallet expiry floor as parameters | S17, S26, S32 | ### Out of the model @@ -144,6 +143,7 @@ definitions they justify. | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | | The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | +| The hub's schedule in the protocol specification: its phases, tip, cadence, drain, crash and restart, flight time, and what read them there: G6a-G6c, the refusal witnesses W4, the requeue witnesses W5-W7, the offer, verdict, admission, refusal and drop records, the tip models | Moved: the protocol uses the abstract hub, which `hubTest` checks the real hub refines; the schedule is checked exhaustively in the hub specification. The protocol's pinned runs that need a real hub step are replayed through it in `realisedRunsTest` | | A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | @@ -223,6 +223,9 @@ beyond loss in the soup. - **Nonces** are unique. A counter stands for an unguessable value. - **Chain.** A transaction's status only moves forward: no reorg of an included transaction, no mempool eviction. The operator's indexer publishes nothing. +- **Hub.** In the protocol specification the hub is abstract: it may accept + or refuse any submission, and take, settle, give back or lose its entries at + any time. The three assumptions below are the hub specification's. - **Flight time.** At most `MAX_FLIGHT_BLOCKS` blocks arrive while one flush is in flight, and that is fewer than the mining margin (`flightWithinMargin`). The implementation bounds each call to the indexer @@ -488,47 +491,19 @@ abstract indexer per hub was a decision of the design. ### Configurations One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields -(`PAYLOADS`, `FLUSH_INTERVAL`, `ROLES`, `TIP`, ...). +(`PAYLOADS`, `ROLES`, ...). -| Module | Roles (hub / indexer) | Tip | -|---|---|---| -| `baseline` | H / H | timely | -| `byzHub` | **B** / H | timely | -| `byzIndexer` | H / **B** | timely for honest reports | - -The schedule is the shipped one scaled down, keeping the relations between the -numbers: - -| | Interval | Margin | Delivery lag | Reorg allowance | Staleness window | Expiry floor | -|---|---|---|---|---|---|---| -| Shipped | 20 | 4 | 6 | 10 | 12 blocks (15 min at 75 s) | 40 | -| Model | 3 | 2 | 1 | 1 | 3 | 7 | - -The relations kept, each asserted by `assumptionsTest` through -`shippedRelationsKept`: - -- the slack `floor - (interval + margin + lag)` equals the reorg allowance - (10 and 1); -- the staleness window exceeds the margin (12 > 4, 3 > 2) and the slack - (12 > 10, 3 > 1); -- `interval + margin + lag + (window - 1)` exceeds the floor by exactly one - (41 = 40 + 1, 8 = 7 + 1); -- `interval + lag + (window - 1)` does not exceed it (37 <= 40, 6 <= 7). - -The margin is 2, not 1, so that one block can arrive while a flush is in flight -and still be inside it: `MAX_FLIGHT_BLOCKS` is 1 everywhere except -`flakyTipSlowFlight`, where it is 2. `flakyTipNoSlack` uses a floor of 6 and -`staleLagWithSlack` a floor of 8; those two do not keep the relations, and -their tests assert that. Also: at most 2 requeues, heights up to 12, -at most 3 sends and 3 lookups by the wallet and 3 requests by the third party. - -Each configuration has an `assumptionsTest`. The simulator does not enforce -`assume`, so that test is the check that counts. Every `assume` in -`protocol.qnt` is true of every configuration. `reorgSlackFits` and -`flightWithinMargin` are assumed only where the configuration says it relies on -them (`reliesOnReorgSlack`, `reliesOnFlightWithinMargin`); `flakyTipNoSlack` -and `flakyTipSlowFlight` each drop one, and their tests assert it is false. -`staleSlackFits` is never assumed. +| Module | Roles (hub / indexer) | +|---|---| +| `baseline` | H / H | +| `byzHub` | **B** / H | +| `byzIndexer` | H / **B** | + +Heights up to 12, at most 3 sends and 3 lookups by the wallet and 3 requests +by the third party. Each configuration's `assumptionsTest` asserts +`payloadsWellFormed`; the simulator does not enforce `assume`. The hub +specification's configurations, its scaled-down schedule and the relations +it keeps with the shipped one are in `hubMachine.qnt`. ## Properties @@ -562,7 +537,7 @@ and `flakyTipSlowFlight` each drop one, and their tests assert it is false. | G6a | `offeredBeforeExpiry` | Every transaction a hub offers is offered with the mining margin to spare: whatever was admitted, on every attempt. About the margin left when the flush begins, not about acceptance. Claimed under a timely tip | | G6b | `conformingFirstOfferBeforeExpiry` | The same for supported wallets and for the first time a hub offers the transaction. Nothing about a later offer of a requeued entry. Also about the margin at the offer | | G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and `flightWithinMargin` | -| G7 | `wellFormed` | Structural sanity; checked in every configuration; not a trust-matrix row | +| G7 | `wellFormed` | Structural sanity: every nonce in use was minted. Checked in every configuration; not a trust-matrix row. Its hub half, a queued entry within its attempts and a down hub holding nothing, is `hubTest::wellFormedTest` over `REACH` | | G8 | `ackImpliesQueued` | An accepted ack from a hub is for a payload that hub had queued by then, whether or not anyone waits for the ack | No guarantee reads a field written by the function it constrains. The history @@ -655,12 +630,15 @@ frame undelivered, and nothing obliges the network ever to deliver it. ### Witnesses Each has a scripted run and, except W15 and W18, is counted in tier 3b. +W4 (each refusal) and W5-W7 (requeued, dropped as expired, dropped as +exhausted) were witnesses here; they read the hub's internals and are gone +with the real hub. F8 produces each refusal and F9 each requeue outcome; the +hub specification reaches `wRequeued` under TLC and both drops in +`requeueAndDropTest`. | Id | Witness | Name | Configuration | |---|---|---|---| | W1-W3 | the wallet sees pending; its transaction in the mempool; mined | `wPending`, `wTxInMempool`, `wTxMined` | `baseline` | -| W4 | each of the three refusals | `wRefusedTipStale`, `wRefusedDraining`, `wRefusedExpiryTooTight` | `baseline` | -| W5-W7 | an entry is requeued; dropped as expired; dropped as exhausted | `wRequeued`, `wDroppedExpired`, `wDroppedExhausted` | `baseline` | | W8 | **Accepted disclosure**: a third party that knows a txid learns it is queued. The hub withholds the bytes, not the fact. See the quoted comment under [Scope](#scope) | `wQueuedDisclosed` | `baseline` | | W9 | a queued payload the hub cannot parse is asked for and missed | `wUnparseableMissed` | `baseline` | | W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | scripted run `tipAheadOfChainFlushesEarlyTest` (hub specification) | `byzIndexer` | @@ -735,7 +713,8 @@ Over the same `REACH` as A2 and A3, with lookups added, `hubTest` checks: A temporary edit that makes `hub` ack a submission without queueing it fails `abstractionTest`. -What the lemma transfers: an invariant that holds over the abstract hub, and +The protocol specification's hub is this abstract one. What the lemma +transfers: an invariant that holds over the abstract hub, and reads only queue membership and wire replies, holds over the real hub with these parameters. That covers G2, G3, G4 and G8. What it does not transfer is reachability. The abstract hub answers where the real one is down, starting, diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 59176bb8..4e3a1727 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -299,21 +299,16 @@ echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued wellFormed" -# W4, W8, W17, K1a, K1b, and the antecedents of G1, G2, G8. +# W8, W17, K1a, K1b, and the antecedents of G1, G2, G8. job reaches baseline step 40 \ - wRefusedTipStale wRefusedDraining wRefusedExpiryTooTight \ wQueuedDisclosed wThirdPartyPayloadQueued wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS -# W1, W2, W3, W5, W6, W9, and the antecedents of G3, G4. +# W1, W2, W3, W9, and the antecedents of G3, G4. job reaches baseline quietStep 80 \ - wPending wTxInMempool wTxMined wRequeued wDroppedExpired wUnparseableMissed \ + wPending wTxInMempool wTxMined wUnparseableMissed \ vTxidAuthenticity vLookupValidityPerHub \ -- $BASELINE_HOLDS -# W7. -job reaches baseline outageStep 80 \ - wDroppedExhausted \ - -- $BASELINE_HOLDS # W16, both halves. job reaches byzHub quietStep 40 \ diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index 02424135..bca2d295 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -15,8 +15,8 @@ module configs { // Transactions // ------------------------------------------------------------------------ // - // The schedule below flushes every 3 blocks with a mining margin of 2 and a - // delivery lag of 1, and supports wallets that set an expiry 7 blocks out. + // The expiries are set against the hub specification's schedule; the + // protocol does not read them. pure def orchard(id: str, created: Height, expiry: Height): Payload = { id: id, txid: Some(id), created: created, expiry: Some(expiry), class: OrchardTouching, oversize: false } @@ -44,40 +44,14 @@ module configs { pure val allHonest: Roles = { hub: Honest, indexer: Honest } - /// One hub, the mixnet transport, every component honest, a timely tip. - /// - /// The schedule is the shipped one scaled down (shipped: interval 20, - /// margin 4, lag 6, reorg allowance 10, staleness window 12 blocks, expiry - /// floor 40), keeping the relations between the numbers that matter - /// (`shippedRelationsKept` in `protocol.qnt`): - /// - /// - the slack `floor - (interval + margin + lag)` equals the reorg - /// allowance (10 there, 1 here); - /// - the staleness window is larger than the margin and than the slack; - /// - `interval + margin + lag + (window - 1)` exceeds the floor by one - /// (41 > 40 there, 8 > 7 here), while `interval + lag + (window - 1)` does - /// not (37 <= 40, 6 <= 7). - /// - /// The margin is 2, the smallest that leaves room for a block to arrive - /// while a flush is in flight (`maxFlightBlocks`). + /// One hub, the mixnet transport, every component honest. pure val baseline: Config = { payloads: Set(early, late, tight, junk, plain), twins: Set(earlyTwin), tpPayloads: Set(garbage), - flushInterval: 3, - miningMargin: 2, - deliveryLag: 1, - reorgAllowance: 1, - staleWindow: 3, - minWalletExpiry: 7, - maxAttempts: 2, - maxFlightBlocks: 1, maxHeight: 12, maxRequests: 3, roles: allHonest, - tip: TipTimely, - reliesOnReorgSlack: true, - reliesOnFlightWithinMargin: true, } // One Byzantine component at a time. @@ -91,13 +65,7 @@ module baseline { import configs.* import protocol(CONFIG = baseline).* from "./protocol" - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } + run assumptionsTest = assert(payloadsWellFormed) } module byzHub { @@ -105,13 +73,7 @@ module byzHub { import configs.* import protocol(CONFIG = byzHub).* from "./protocol" - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } + run assumptionsTest = assert(payloadsWellFormed) } module byzIndexer { @@ -119,11 +81,5 @@ module byzIndexer { import configs.* import protocol(CONFIG = byzIndexer).* from "./protocol" - run assumptionsTest = all { - assert(standingAssumptions), - assert(reorgSlackFits), - assert(flightWithinMargin), - assert(shippedRelationsKept), - assert(not(staleSlackFits)), - } + run assumptionsTest = assert(payloadsWellFormed) } diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index 42ed3efd..26735956 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -21,7 +21,7 @@ module properties { import types.* from "./types" import wire.* from "./wire" import indexer.* from "./indexer" - import hub.* from "./hub" + import abstractHub.* from "./abstractHub" import shim.* from "./shim" import state.* from "./state" @@ -47,7 +47,7 @@ module properties { /// /// It is also the true answer for a queued payload that does not parse. pure def truth(s: System, query: TxId): LookupObs = - if (s.hub.queued().exists(payload => payload.txid == Some(query))) + if (s.hub.queue.exists(payload => payload.txid == Some(query))) Pending else if (s.indexer.txs.keys().contains(query)) val tx = s.indexer.txs.get(query) @@ -55,71 +55,10 @@ module properties { else NotFound - pure val initialAudit: Audit = { - everQueued: Set(), - admitted: Map(), - offers: Set(), - verdicts: Set(), - windows: Map(), - refusals: Set(), - dropped: Set(), - } - - /// The refusal a refused ack stands for. - pure def refusalBehind(code: WireRefusal): Refusal = - match code { - | WTipStale => TipStale - | WExpiryTooTight => ExpiryTooTight - | WQueueFull => HubDraining - } - - /// How many times the hub has offered `payload`. - pure def offersOf(audit: Audit, payload: Payload): int = - audit.offers.filter(offer => offer.payload == payload).size() + pure val initialAudit: Audit = { everQueued: Set(), windows: Map() } /// The audit record after one step, from the states before and after it. pure def advance(audit: Audit, pre: System, post: System): Audit = - // A flush that was idle and is now broadcasting has just put its batch in - // flight. - val offered = match post.hub.flush { - | Broadcasting(flush) => - if (pre.hub.flush == Idle) - flush.batch.keys().map(payload => { - payload: payload, - height: post.height(), - attempt: flush.batch.get(payload), - nth: audit.offersOf(payload), - }) - else Set() - | Idle => Set() - } - // An entry that was awaiting a verdict and no longer is, in a flush still - // in flight, has just had its answer. A node judged it unless it was set - // aside for requeue. - val judged = match pre.hub.flush { - | Broadcasting(before) => - match post.hub.flush { - | Broadcasting(after) => - before.batch.keys().exclude(after.batch.keys()).map(payload => { - payload: payload, - height: post.height(), - nth: audit.offersOf(payload) - 1, - final: not(after.unplaced.keys().contains(payload)), - }) - | Idle => Set() - } - | Idle => Set() - } - // A flush that has ended, other than a final one, gave up on every entry - // nothing judged that is not back in the queue. - val dropped = match pre.hub.flush { - | Broadcasting(flush) => - if (post.hub.flush == Idle and not(flush.final) and post.hub.phase != Down) - flush.unplaced.keys().exclude(post.hub.queued()).map(payload => - { payload: payload, attempts: flush.unplaced.get(payload) }) - else Set() - | Idle => Set() - } // The lookups the shim is waiting on, or was until this step, each widen // their window by what is true at the hub. val waiting = pre.shim.waiters.keys().union(post.shim.waiters.keys()) @@ -128,26 +67,7 @@ module properties { .fold(audit.windows, (acc, lookup) => val seen = if (acc.keys().contains(lookup._1)) acc.get(lookup._1) else Set() acc.put(lookup._1, seen.union(Set(truth(post, lookup._2))))) - val refused = post.net.exclude(pre.net).fold(Set(), (acc, mail) => - match mail.msg { - | Ack(ack) => - match ack.ack { - | WRefused(code) => acc.union(Set(refusalBehind(code))) - | WAccepted => acc - } - | _ => acc - }) - { - everQueued: audit.everQueued.union(post.hub.queued()), - admitted: post.hub.queued().fold(audit.admitted, (acc, payload) => - if (acc.keys().contains(payload)) acc - else acc.put(payload, { height: post.height(), tip: post.hub.observedTip() })), - offers: audit.offers.union(offered), - verdicts: audit.verdicts.union(judged), - windows: windows, - refusals: audit.refusals.union(refused), - dropped: audit.dropped.union(dropped), - } + { everQueued: audit.everQueued.union(post.hub.queue), windows: windows } // ------------------------------------------------------------------------ // Guarantees @@ -202,68 +122,9 @@ module properties { | _ => true }) - /// Whether an offer left the mining margin: the transaction can still be - /// mined `miningMargin` blocks after the true height it was published at. - pure def offeredInTime(s: System, offer: Offer): bool = - match offer.payload.expiry { - | Some(expiry) => expiry >= offer.height + s.hub.params.miningMargin - | None => true - } - - /// Whether `payload` reached the hub as a supported wallet's would: it - /// honours the expiry floor, and it first entered the queue within the - /// delivery lag of the height it was built at. - pure def isConformingAndTimely(s: System, audit: Audit, payload: Payload): bool = - val params = s.hub.params - and { - conforming(payload, params.minWalletExpiry), - audit.admitted.keys().contains(payload), - audit.admitted.get(payload).height <= payload.created + params.deliveryLag, - } - - // G6 comes in three parts. G6a and G6b are about the moment a flush begins: - // how much of the mining margin is left when the hub hands the batch over. - // They do not say a node accepts the transaction, because the chain may move - // while the batch is in flight. G6c is about the moment a node judges it. - // The hub specification checks all three exhaustively; here they are the - // closing assertions of scripted runs. - - /// G6a. Every transaction the hub offers is offered with the mining margin - /// to spare: whatever was admitted, on every attempt. A claim about the - /// margin left at the offer, not about acceptance. - pure def offeredBeforeExpiryIn(s: System, audit: Audit): bool = - audit.offers.forall(offer => offeredInTime(s, offer)) - - /// G6b. The same, for supported wallets only and for the first time the hub - /// offers the transaction. Like G6a it is a claim about the margin left at - /// the offer. - pure def conformingFirstOfferBeforeExpiryIn(s: System, audit: Audit): bool = - audit.offers.forall(offer => - offer.nth == 0 and isConformingAndTimely(s, audit, offer.payload) implies offeredInTime(s, offer)) - - /// G6c. The end-to-end claim: when a node judges the first offer of a - /// supported wallet's transaction, the transaction has not expired. It can - /// still be mined in the next block, so the node does not turn it away for - /// its expiry. - pure def conformingFirstOfferJudgedBeforeExpiryIn(s: System, audit: Audit): bool = - audit.verdicts.forall(verdict => - and { - verdict.nth == 0, - verdict.final, - isConformingAndTimely(s, audit, verdict.payload), - } implies - match verdict.payload.expiry { - | Some(expiry) => expiry > verdict.height - | None => true - }) - - /// G7. Structural sanity: a queued entry is within its attempts; a hub that - /// is down holds nothing and knows nothing; every nonce in use was minted. + /// G7. Structural sanity: every nonce in use was minted. pure def wellFormedIn(s: System): bool = and { - s.hub.queued().forall(payload => - s.hub.queue.get(payload) >= 0 and s.hub.queue.get(payload) <= s.hub.params.maxAttempts), - s.hub.phase == Down implies s.hub == downHub(s.hub.params), s.shim.waiters.keys().forall(nonce => nonce < s.shim.nextNonce), s.net.forall(mail => match mail.msg { @@ -373,25 +234,6 @@ module properties { | _ => false }) - /// W4. A hub has refused a submission for `refusal`. - pure def wRefused(audit: Audit, refusal: Refusal): bool = - audit.refusals.contains(refusal) - - /// W5. An entry nothing judged is back in the queue. - pure def wRequeuedIn(s: System): bool = - s.hub.queued().exists(payload => s.hub.queue.get(payload) > 0) - - /// W6. A requeue dropped an entry that could no longer survive the next - /// flush: it was given up on while it still had attempts left. - pure def wDroppedExpiredIn(s: System, audit: Audit): bool = - audit.dropped.exists(entry => - entry.attempts + 1 <= s.hub.params.maxAttempts) - - /// W7. A requeue dropped an entry that was out of attempts: one with no - /// expiry, which nothing else would ever have stopped. - pure def wDroppedExhaustedIn(audit: Audit): bool = - audit.dropped.exists(entry => entry.payload.expiry == None) - /// W8. The accepted disclosure: a third party that knows a txid learns that /// it is queued at the hub. The hub withholds the bytes; it does not withhold /// the fact. The implementation leaves this open on purpose @@ -416,8 +258,7 @@ module properties { and { got.obs == NotFound, got.via == Some(lookup._1), - s.hub.queued().exists(payload => - payload.txid == None and walletTxid(payload) == got.query), + s.hub.queue.exists(payload => payload.txid == None and walletTxid(payload) == got.query), } | _ => false }) @@ -442,7 +283,7 @@ module properties { /// W17. The hub has queued a payload of the third party's own making. pure def wThirdPartyPayloadQueuedIn(s: System): bool = - s.hub.queued().intersect(s.thirdParty.own) != Set() + s.hub.queue.intersect(s.thirdParty.own) != Set() // ------------------------------------------------------------------------ // Non-vacuity: the antecedent of each guarantee is reachable @@ -468,15 +309,6 @@ module properties { s.wasGiven(obs => obs == NotFound), } - pure def vConformingFirstOfferJudgedIn(s: System, audit: Audit): bool = - audit.verdicts.exists(verdict => - and { - isSome(verdict.payload.expiry), - verdict.nth == 0, - verdict.final, - isConformingAndTimely(s, audit, verdict.payload), - }) - pure def vAckImpliesQueuedIn(s: System): bool = s.acked() != Set() } diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index da9c22b4..0aae7604 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -22,16 +22,15 @@ /// forge frames, so it does not know a nonce and cannot answer the shim. /// - Nonces are unique. A counter stands for an unguessable random value. /// - Chain. A transaction's status only moves forward: no reorg of an -/// included transaction, no mempool eviction. How a hub's view of the tip -/// relates to the true height is the constant `TIP`. -/// - Wallets. A supported wallet sets an expiry at least `MIN_WALLET_EXPIRY` -/// blocks after the height it builds at, and its frame reaches the hub within -/// `DELIVERY_LAG` blocks. +/// included transaction, no mempool eviction. +/// - Hub. The hub is abstract (`abstractHub.qnt`): a queue, the entries out +/// with a flush, and its replies, with no schedule. `hubTest` checks that +/// the real hub function refines it. Its timing is the hub specification's. /// - Time. There is no clock. A timeout is an event that may happen at any -/// moment, and the staleness window is counted in blocks. +/// moment. /// /// How it is built. This module holds no protocol logic. Every step picks an -/// input, hands it to one component function (`shim`, `hub`, or the indexer +/// input, hands it to one component (`shim`, the abstract hub, or the indexer /// relation) and puts the output where it goes. Each step exists in two /// forms: `xWith(..)`, which takes every choice as a parameter and is what a /// scripted run is made of, and `x`, which picks the choices and is what @@ -43,7 +42,7 @@ module protocol { import types.* from "./types" import wire.* from "./wire" import indexer.* from "./indexer" - import hub.* from "./hub" + import abstractHub.* from "./abstractHub" import shim.* from "./shim" import state.* from "./state" import properties.* from "./properties" @@ -66,24 +65,6 @@ module protocol { /// Payloads of the third party's own making. pure val TP_PAYLOADS = CONFIG.tpPayloads - /// Blocks between scheduled flushes. - pure val FLUSH_INTERVAL = CONFIG.flushInterval - /// Blocks a published transaction needs to be mined. - pure val MINING_MARGIN = CONFIG.miningMargin - /// Blocks a submission may take to reach a hub. - pure val DELIVERY_LAG = CONFIG.deliveryLag - /// How far behind the chain a tip report may be and still be followed. - pure val REORG_ALLOWANCE = CONFIG.reorgAllowance - /// Blocks without a forward tip observation after which a hub is stale. - pure val STALE_WINDOW = CONFIG.staleWindow - /// The smallest expiry delta a supported wallet sets. - pure val MIN_WALLET_EXPIRY = CONFIG.minWalletExpiry - /// Requeues an entry is allowed. - pure val MAX_ATTEMPTS = CONFIG.maxAttempts - - /// Blocks that may arrive while one flush is in flight. - pure val MAX_FLIGHT_BLOCKS = CONFIG.maxFlightBlocks - /// Bounds of the model: the chain stops growing at `MAX_HEIGHT`, and the /// wallet makes at most `MAX_REQUESTS` sends and as many lookups, as does /// the third party. @@ -91,15 +72,11 @@ module protocol { pure val MAX_REQUESTS = CONFIG.maxRequests pure val ROLES = CONFIG.roles - pure val TIP = CONFIG.tip /// Every payload that exists. A Byzantine component builds its lies from it. pure val UNIVERSE = PAYLOADS.union(TWINS).union(TP_PAYLOADS) /// Every height a Byzantine component may claim. pure val HEIGHTS = 0.to(MAX_HEIGHT) - /// Every height a free-running cadence clock may read: up to one flush - /// interval past the last block. - pure val CLOCK_HEIGHTS = 0.to(MAX_HEIGHT + FLUSH_INTERVAL) /// What a wallet may send. pure val SEND_INPUTS = PAYLOADS.map(payload => Clean(payload)).union(Set(Unreadable, EmptyBody)) /// Every transaction id there is. @@ -107,61 +84,12 @@ module protocol { /// The height the chain starts at. Height 0 is kept for "in the mempool". pure val GENESIS_HEIGHT = 1 - pure val HUB_PARAMS: HubParams = { - flushInterval: FLUSH_INTERVAL, - miningMargin: MINING_MARGIN, - deliveryLag: DELIVERY_LAG, - minWalletExpiry: MIN_WALLET_EXPIRY, - reorgAllowance: REORG_ALLOWANCE, - maxAttempts: MAX_ATTEMPTS, - } - // ------------------------------------------------------------------------ // Assumptions // ------------------------------------------------------------------------ // - // Each is a named value, so that an instance's `assumptionsTest` can assert - // it: the simulator does not enforce `assume`. - - /// The hub's startup check: delivery, one full interval and the mining - /// margin fit inside the smallest supported expiry. - pure val budgetFits = scheduleFitsBudget(HUB_PARAMS) - - /// The same budget with the reorg allowance added. A hub that follows a tip - /// report up to the allowance behind the chain can flush that much late. - /// Nothing in the implementation checks this; its shipped constants meet it - /// with equality. - pure val reorgSlackFits = - FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + REORG_ALLOWANCE <= MIN_WALLET_EXPIRY - - /// Fewer blocks arrive while a flush is in flight than the mining margin - /// reserves. The margin is measured from the height at which a flush begins; - /// every block that arrives before the node judges a transaction is taken - /// out of it. The implementation bounds each call to the indexer - /// (`RPC_TIMEOUT`, `zeronym/hub/src/chain.rs`) and not the batch as a whole, - /// and bounds neither in blocks, so this is an assumption about the - /// environment that the code does not enforce. - pure val flightWithinMargin = MAX_FLIGHT_BLOCKS < MINING_MARGIN - - /// The relations between the shipped constants that the scaled-down schedule - /// is meant to keep: the slack left by the startup budget equals the reorg - /// allowance; the staleness window exceeds both the margin and that slack; - /// the budget with the longest non-stale silence added exceeds the expiry - /// floor by exactly one block, and without the margin it fits. - pure val shippedRelationsKept = and { - MIN_WALLET_EXPIRY - (FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG) == REORG_ALLOWANCE, - STALE_WINDOW > MINING_MARGIN, - STALE_WINDOW > REORG_ALLOWANCE, - FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + (STALE_WINDOW - 1) == MIN_WALLET_EXPIRY + 1, - FLUSH_INTERVAL + DELIVERY_LAG + (STALE_WINDOW - 1) <= MIN_WALLET_EXPIRY, - } - - /// The same budget with the longest silence that does not yet make a hub - /// stale. It is named, not assumed: the shipped constants do not meet it. - pure val staleSlackFits = - FLUSH_INTERVAL + MINING_MARGIN + DELIVERY_LAG + (STALE_WINDOW - 1) <= MIN_WALLET_EXPIRY - - pure val flushIntervalPositive = FLUSH_INTERVAL > 0 + // A named value, so that an instance's `assumptionsTest` can assert it: the + // simulator does not enforce `assume`. /// Payloads are told apart by their bytes; a twin is a twin of something a /// wallet sends; the third party's payloads are its own. @@ -172,19 +100,6 @@ module protocol { UNIVERSE.forall(payload => payload.created >= GENESIS_HEIGHT), } - /// The assumptions every configuration meets. `reorgSlackFits` and - /// `flightWithinMargin` are kept apart: a configuration says whether it - /// relies on each, and some exist to show what happens without one. - pure val standingAssumptions = and { - budgetFits, - flushIntervalPositive, - payloadsWellFormed, - } - - assume _ = budgetFits - assume _ = CONFIG.reliesOnReorgSlack implies reorgSlackFits - assume _ = CONFIG.reliesOnFlightWithinMargin implies flightWithinMargin - assume _ = flushIntervalPositive assume _ = payloadsWellFormed // ------------------------------------------------------------------------ @@ -205,7 +120,7 @@ module protocol { lastAction' = label, } - pure val INITIAL = initialSystem(CONFIG, HUB_PARAMS, GENESIS_HEIGHT) + pure val INITIAL = initialSystem(CONFIG, GENESIS_HEIGHT) action init = all { s' = INITIAL, @@ -217,10 +132,10 @@ module protocol { // Roles // ------------------------------------------------------------------------ - pure def hubResults(state: HubState, input: HubInput): Set[HubResult] = + pure def hubAnswers(h: AHub, request: ARequest): Set[AAnswer] = match ROLES.hub { - | Honest => Set(hub(state, input)) - | Byzantine => byzHubResults(state, input, UNIVERSE, HEIGHTS) + | Honest => honestAnswers(h, request) + | Byzantine => byzantineAnswers(h, request, UNIVERSE, HEIGHTS) } pure def indexerResults(state: IndexerState, input: IndexerInput): Set[IndexerResult] = @@ -248,73 +163,6 @@ module protocol { | _ => false } - pure def isHubError(output: HubOutput): bool = - match output { - | HubErrorOutput(_) => true - | _ => false - } - - // ------------------------------------------------------------------------ - // Tip models - // ------------------------------------------------------------------------ - - /// The tips the indexer may report to a hub now. - def reportableTips: Set[Height] = - match ROLES.indexer { - | Byzantine => byzTips(HEIGHTS) - | Honest => - match TIP { - | TipTimely => honestTips(s.indexer, 0, HEIGHTS) - | TipMayRegress => honestTips(s.indexer, REORG_ALLOWANCE, HEIGHTS) - | TipMayLag => honestTips(s.indexer, 0, HEIGHTS) - } - } - - /// How far behind the chain the hub's observed tip is. - def lag: int = - s.height() - s.hub.observedTip() - - /// The heights a stale hub's free-running clock may read now: not behind - /// the chain, and up to an interval ahead of it. The implementation relies - /// on the first ("during a real stall blocks arrive slower than this, so the - /// free-running clock runs ahead of the true height", - /// `zeronym/hub/src/batcher.rs:64-67`) and does not enforce it. - def freeRunEstimates: Set[Height] = - CLOCK_HEIGHTS.filter(estimate => s.height() <= estimate and estimate <= s.height() + FLUSH_INTERVAL) - - /// Whether the next block may arrive. This is where the timing assumptions - /// live: a block is held back until the hub has done what the tip model - /// says it does within a block. - /// - /// In every model, a flush that is due has begun, and no flush has been in - /// flight for `MAX_FLIGHT_BLOCKS` blocks already. A hub whose flush is in - /// flight is not looking at the tip, so the clauses below bind the hub only - /// while it is idle. - /// - /// - `TipTimely`: a running hub has asked for the tip since the last block. - /// An honest indexer answers with the true height, so the hub's lag is - /// zero at every block. A Byzantine one is asked just as often; what it - /// controls is the answer. - /// - `TipMayRegress`: a running hub is no further behind than the allowance. - /// - `TipMayLag`: a hub whose silence has reached the staleness window has - /// become stale, and a stale hub's free-running clock has caught up with - /// the current block. - def chainMayAdvance: bool = and { - s.height() < MAX_HEIGHT, - not(s.hub.isFlushDue()), - s.flightBlocks < MAX_FLIGHT_BLOCKS or s.hub.flush == Idle, - s.hub.flush == Idle implies - match TIP { - | TipTimely => s.hub.phase == Running implies s.polled - | TipMayRegress => s.hub.phase == Running implies lag <= REORG_ALLOWANCE - | TipMayLag => - and { - s.hub.phase == Running implies lag < STALE_WINDOW, - s.hub.phase == Stale implies s.hub.cadenceHeight() >= s.height(), - } - }, - } - // ------------------------------------------------------------------------ // Wallet // ------------------------------------------------------------------------ @@ -424,25 +272,22 @@ module protocol { // ------------------------------------------------------------------------ /// The request frames that may be delivered to the hub: all of them, as - /// often as the network likes, while the hub is serving. + /// often as the network likes. def hubDeliverable: Set[Mail] = - if (s.hub.isServing()) - s.net.inbox(HubAddr).filter(mail => requestInput(mail.msg, INotFound) != Set()) - else Set() + s.net.inbox(HubAddr).filter(mail => requestOf(mail.msg, INotFound) != Set()) - /// The transitions the hub may take when `mail` is delivered and its - /// indexer would answer a lookup with `answer`. - def hubReceipts(mail: Mail, answer: IndexerAnswer): Set[HubResult] = - requestInput(mail.msg, answer).map(input => hubResults(s.hub, input)).flatten() + /// What the hub may answer when `mail` is delivered and its indexer would + /// answer a lookup with `answer`. + def hubReceipts(mail: Mail, answer: IndexerAnswer): Set[AAnswer] = + requestOf(mail.msg, answer).map(request => hubAnswers(s.hub, request)).flatten() /// The network delivers a request to the hub, which answers its sender. For /// a lookup, `answer` is what the hub's indexer says if the queue misses. - action hubReceiveWith(mail: Mail, answer: IndexerAnswer, result: HubResult): bool = all { + action hubReceiveWith(mail: Mail, answer: IndexerAnswer, result: AAnswer): bool = all { hubDeliverable.contains(mail), lookupAnswers(s.indexer, mail.msg).contains(answer), hubReceipts(mail, answer).contains(result), - not(isHubError(result.out)), - commit(s.hubReplied(mail.src, result), HubReceive(mail)), + commit(s.hubReplied(mail, result), HubReceive(mail)), } action hubReceive = all { @@ -455,57 +300,13 @@ module protocol { }, } - /// A step of the hub's own schedule, taken in the system `around` (the - /// current one, or the current one after its indexer moved). A Byzantine hub - /// keeps the honest schedule, so these take the hub function's transition - /// whatever the role. A step that would change nothing is not taken. - action hubTakesIn(around: System, input: HubInput, label: Label): bool = - val result = hub(s.hub, input) - all { - not(isHubError(result.out)), - result.state != s.hub, - commit(around.hubMoved(result.state), label), - } - - action hubTakes(input: HubInput, label: Label): bool = - hubTakesIn(s, input, label) - - /// The hub's cadence loop observes the tip its indexer reports. - /// - /// The poll is recorded whether or not the answer moves the hub's tip: a hub - /// that asked and was told nothing new has still asked. A second poll in the - /// same block that changes nothing is not taken. - action hubObserveTipWith(tip: Height): bool = - val result = hub(s.hub, TipHInput(tip)) - all { - reportableTips.contains(tip), - not(isHubError(result.out)), - result.state != s.hub or not(s.polled), - commit({ ...s.hubMoved(result.state), polled: true }, HubObserveTip(tip)), - } - - action hubObserveTip = { - nondet tip = oneOf(reportableTips) - hubObserveTipWith(tip) - } - - /// A hub that has seen no tip progress for the staleness window goes stale, - /// or, already stale, reads its free-running clock again. - action hubTipStaleWith(estimate: Height): bool = all { - TIP == TipMayLag, - s.hub.phase == Stale or lag >= STALE_WINDOW, - freeRunEstimates.contains(estimate), - hubTakes(StaleHInput(estimate), HubTipStale(estimate)), + /// A flush begins: the whole queue goes out at once. A Byzantine hub keeps + /// the schedule, so these steps are the same whatever its role. + action hubTake = all { + s.hub.queue != Set(), + commit({ ...s, hub: s.hub.take() }, HubTake), } - action hubTipStale = { - nondet estimate = oneOf(freeRunEstimates) - hubTipStaleWith(estimate) - } - - /// A flush begins: the hub's whole queue goes out at once. - action hubFlushBegin = hubTakes(FlushDueHInput, HubFlushBegin) - /// The verdict an indexer output carries. Read only where it carries one. pure def verdictIn(output: IndexerOutput): Verdict = match output { @@ -513,44 +314,49 @@ module protocol { | _ => Retryable } - /// The indexer returns its verdict on one entry of a batch. `result` is the - /// indexer's transition: the verdict, and what became of the transaction. + /// The indexer returns its verdict on one entry out with a flush. `result` + /// is the indexer's transition: the verdict, and what became of the + /// transaction. A node's verdict settles the entry; a retryable one leaves it + /// out with the flush. action indexerVerdictWith(payload: Payload, result: IndexerResult): bool = all { + s.hub.held.contains(payload), indexerResults(s.indexer, BroadcastIInput(payload)).contains(result), result.out == VerdictOutput(verdictIn(result.out)), - hubTakesIn( - { ...s, indexer: result.state }, - VerdictHInput({ payload: payload, verdict: verdictIn(result.out) }), - IndexerVerdict(payload), - ), + val hub = if (verdictIn(result.out) == Retryable) s.hub else s.hub.settle(payload) + commit({ ...s, indexer: result.state, hub: hub }, IndexerVerdict(payload)), } - /// The entries of the hub's batch still waiting for a verdict. - def awaitingVerdict: Set[Payload] = - match s.hub.flush { - | Broadcasting(flush) => flush.batch.keys() - | Idle => Set() - } - action indexerVerdict = all { - awaitingVerdict != Set(), + s.hub.held != Set(), { - nondet payload = oneOf(awaitingVerdict) + nondet payload = oneOf(s.hub.held) nondet result = oneOf(indexerResults(s.indexer, BroadcastIInput(payload))) indexerVerdictWith(payload, result) }, } - /// A flush ends: what nothing judged is requeued or dropped. - action hubFlushEnd = hubTakes(FlushDoneHInput, HubFlushEnd) - - /// The hub gets its shutdown signal. - action hubBeginDrain = hubTakes(DrainHInput, HubBeginDrain) + /// A flush ends: of what nothing judged, `kept` goes back into the queue and + /// the rest is dropped. + action hubReturnWith(kept: Set[Payload]): bool = all { + s.hub.held != Set(), + kept.subseteq(s.hub.held), + commit({ ...s, hub: s.hub.giveBack(kept) }, HubReturn(kept)), + } - /// The hub's process dies, or exits after its final flush. - action hubCrash = hubTakes(CrashHInput, HubCrash) + action hubReturn = all { + s.hub.held != Set(), + { + nondet kept = oneOf(s.hub.held.powerset()) + hubReturnWith(kept) + }, + } - action hubRestart = hubTakes(RestartHInput, HubRestart) + /// The hub loses everything: its process dies, or exits after its final + /// flush. + action hubLose = all { + s.hub != emptyAHub, + commit({ ...s, hub: emptyAHub }, HubLose), + } // ------------------------------------------------------------------------ // Chain @@ -558,7 +364,7 @@ module protocol { /// The chain grows by one block. action chainAdvance = all { - chainMayAdvance, + s.height() < MAX_HEIGHT, commit(s.blockArrived(), ChainAdvance), } @@ -666,9 +472,9 @@ module protocol { // Step // ------------------------------------------------------------------------ - /// A fault: a timeout, a shutdown, a crash, a restart. + /// A fault: a timeout, or the hub losing what it holds. action faultStep = any { - shimLookupTimeout, hubBeginDrain, hubCrash, hubRestart, + shimLookupTimeout, hubLose, } /// A step by someone outside the protocol: the third party, or a Byzantine @@ -685,24 +491,12 @@ module protocol { action step = any { walletSend, walletGet, shimReceive, - hubReceive, hubObserveTip, hubTipStale, hubFlushBegin, indexerVerdict, hubFlushEnd, + hubReceive, hubTake, indexerVerdict, hubReturn, chainAdvance, chainMine, faultStep, outsiderStep, } - /// The indexer cannot be reached: an entry of a batch gets no verdict. - action indexerUnreachable = all { - awaitingVerdict != Set(), - { - nondet payload = oneOf(awaitingVerdict) - indexerVerdictWith(payload, { - state: indexerApply(s.indexer, BroadcastIInput(payload), VerdictOutput(Retryable)), - out: VerdictOutput(Retryable), - }) - }, - } - // The relations below are parts of `step`. Everything one of them reaches, // `step` reaches, so they are sound for showing that a state is reachable // and for nothing else. They keep a simulation on one part of the behaviour @@ -712,20 +506,10 @@ module protocol { action quietStep = any { walletSend, walletGet, shimReceive, - hubReceive, hubObserveTip, hubTipStale, hubFlushBegin, indexerVerdict, hubFlushEnd, + hubReceive, hubTake, indexerVerdict, hubReturn, chainAdvance, chainMine, } - /// The protocol during an indexer outage: every flush comes back unjudged, - /// while outsiders go on submitting. - action outageStep = any { - walletSend, walletGet, - shimReceive, - hubReceive, hubObserveTip, hubTipStale, hubFlushBegin, indexerUnreachable, hubFlushEnd, - chainAdvance, - outsiderStep, - } - // ------------------------------------------------------------------------ // Guarantees // ------------------------------------------------------------------------ @@ -738,9 +522,6 @@ module protocol { val queuedBytesConfidential = queuedBytesConfidentialIn(s) val txidAuthenticity = txidAuthenticityIn(s) val lookupValidityPerHub = lookupValidityPerHubIn(s, audit) - val offeredBeforeExpiry = offeredBeforeExpiryIn(s, audit) - val conformingFirstOfferBeforeExpiry = conformingFirstOfferBeforeExpiryIn(s, audit) - val conformingFirstOfferJudgedBeforeExpiry = conformingFirstOfferJudgedBeforeExpiryIn(s, audit) val wellFormed = wellFormedIn(s) val ackImpliesQueued = ackImpliesQueuedIn(s, audit) @@ -760,12 +541,6 @@ module protocol { val wPending = wPendingIn(s) val wTxInMempool = wTxInMempoolIn(s) val wTxMined = wTxMinedIn(s) - val wRefusedTipStale = wRefused(audit, TipStale) - val wRefusedDraining = wRefused(audit, HubDraining) - val wRefusedExpiryTooTight = wRefused(audit, ExpiryTooTight) - val wRequeued = wRequeuedIn(s) - val wDroppedExpired = wDroppedExpiredIn(s, audit) - val wDroppedExhausted = wDroppedExhaustedIn(audit) val wQueuedDisclosed = wQueuedDisclosedIn(s) val wUnparseableMissed = wUnparseableMissedIn(s) val wTwinServed = wTwinServedIn(s) @@ -777,7 +552,6 @@ module protocol { val vQueuedBytesConfidential = vQueuedBytesConfidentialIn(s) val vTxidAuthenticity = vTxidAuthenticityIn(s) val vLookupValidityPerHub = vLookupValidityPerHubIn(s) - val vConformingFirstOfferJudged = vConformingFirstOfferJudgedIn(s, audit) val vAckImpliesQueued = vAckImpliesQueuedIn(s) // ------------------------------------------------------------------------ @@ -822,10 +596,17 @@ module protocol { walletGetWith(query) /// `client`'s submission of `payload` under `nonce` reaches the hub, which - /// acts as it should. + /// answers `ack`, and queues the payload if it accepts it. + action answerSubmitFrom(client: Addr, nonce: Nonce, payload: Payload, ack: WireAck): bool = + hubReceiveWith( + { src: client, dst: HubAddr, msg: Submit({ nonce: nonce, payload: payload }) }, + INotFound, + { hub: if (ack == WAccepted) { ...s.hub, queue: s.hub.queue.union(Set(payload)) } else s.hub, reply: AAck(ack) }, + ) + + /// The same, accepted. action deliverSubmitFrom(client: Addr, nonce: Nonce, payload: Payload): bool = - val submit = { nonce: nonce, payload: payload } - hubReceiveWith({ src: client, dst: HubAddr, msg: Submit(submit) }, INotFound, hub(s.hub, SubmitHInput(submit))) + answerSubmitFrom(client, nonce, payload, WAccepted) /// The shim's submission of `payload` under `nonce` reaches the hub. action deliverSubmit(nonce: Nonce, payload: Payload): bool = @@ -837,7 +618,7 @@ module protocol { hubReceiveWith( { src: client, dst: HubAddr, msg: Lookup({ nonce: nonce, txid: txid }) }, answer, - hub(s.hub, LookupHInput({ nonce: nonce, txid: txid, answer: answer })), + { hub: s.hub, reply: AWire(honestReplyOn(txid, answer)) }, ) /// The shim's lookup of `txid` under `nonce` reaches the hub, whose indexer @@ -849,10 +630,14 @@ module protocol { action deliverToShim(mail: Mail): bool = shimReceiveWith(mail) - /// The reply an honest hub with a truthful indexer gives, in the current - /// state, to a lookup of `txid`. + /// The reply an honest hub gives, in the current state, to a lookup of + /// `txid` on which its indexer would answer `answer`. + def honestReplyOn(txid: TxId, answer: IndexerAnswer): WireReply = + render(if (s.hub.queue.exists(payload => payload.txid == Some(txid))) QueueHit else FromIndexer(answer)) + + /// The same, with a truthful indexer. def honestReply(txid: TxId): WireReply = - render(if (s.hub.isQueuedTxid(txid)) QueueHit else FromIndexer(chainAnswer(s.indexer, txid))) + honestReplyOn(txid, chainAnswer(s.indexer, txid)) /// The shim's wait under `nonce` times out. action timeOutLookup(nonce: Nonce): bool = @@ -866,17 +651,10 @@ module protocol { out: VerdictOutput(verdict), }) - /// The hub observes the true height. - action observe: bool = - hubObserveTipWith(s.height()) - - /// The system with the hub running at the genesis height. - run started = init.then(observe) - - /// One block arrives and the hub sees it. - run block = chainAdvance.then(observe) + /// One block arrives. + run block = chainAdvance - /// `count` blocks arrive, each seen by the hub. No flush may fall due. + /// `count` blocks arrive. run blocks(count: int): bool = count.reps(_ => block) /// The wallet sends `payload`, and the frame under `nonce` reaches the hub. @@ -892,9 +670,7 @@ module protocol { /// The hub flushes `batch`, and the indexer gives every entry `verdict`. run flush(batch: List[Payload], verdict: Verdict): bool = - hubFlushBegin - .then(batch.length().reps(i => judge(batch[i], verdict))) - .then(hubFlushEnd) + hubTake.then(batch.length().reps(i => judge(batch[i], verdict))) /// The wallet's most recent answer. def lastEvent: WalletEvent = diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt index 8f313b5d..34fd3255 100644 --- a/zeronym/spec/protocol/state.qnt +++ b/zeronym/spec/protocol/state.qnt @@ -13,7 +13,7 @@ module state { import types.* from "./types" import wire.* from "./wire" import indexer.* from "./indexer" - import hub.* from "./hub" + import abstractHub.* from "./abstractHub" import shim.* from "./shim" // ------------------------------------------------------------------------ @@ -32,20 +32,14 @@ module state { /// are payloads of its own making. type ThirdParty = { txids: Set[TxId], own: Set[Payload], nextNonce: Nonce, requests: int } - /// - `polled`: whether the hub's cadence loop has asked for the tip since the - /// last block arrived. - /// - `flightBlocks`: the blocks that have arrived since the hub's flush in - /// flight began; zero while no flush is in flight. Both are the - /// environment's bookkeeping of time; no component reads them. + /// - `hub`: the hub as the protocol sees it (`abstractHub.qnt`). /// - `operator`: every transaction the shim has handed the operator's /// indexer. The operator is assumed to publish nothing itself. /// - `disclosed`: payloads a Byzantine component has revealed. It is written /// by the disclosure step and by nothing else. type System = { indexer: IndexerState, - hub: HubState, - polled: bool, - flightBlocks: int, + hub: AHub, shim: ShimState, net: Net, wallet: Wallet, @@ -54,13 +48,10 @@ module state { disclosed: Set[Payload], } - /// The system at rest: the chain at `height`, the hub started and waiting - /// for its first tip, nothing sent. - pure def initialSystem(config: Config, params: HubParams, height: Height): System = { + /// The system at rest: the chain at `height`, the hub empty, nothing sent. + pure def initialSystem(config: Config, height: Height): System = { indexer: initialIndexer(height), - hub: startingHub(params), - polled: false, - flightBlocks: 0, + hub: emptyAHub, shim: initialShim, net: Set(), wallet: { log: [], sends: 0, gets: 0 }, @@ -79,14 +70,10 @@ module state { | ShimReceive(Mail) | ShimLookupTimeout(Nonce) | HubReceive(Mail) - | HubObserveTip(Height) - | HubTipStale(Height) - | HubFlushBegin + | HubTake | IndexerVerdict(Payload) - | HubFlushEnd - | HubBeginDrain - | HubCrash - | HubRestart + | HubReturn(Set[Payload]) + | HubLose | ChainAdvance | ChainMine(TxId) | ThirdPartyLearnsTxid(TxId) @@ -99,29 +86,11 @@ module state { /// before and after, never from what a component reports about itself. /// /// - `everQueued`: every payload that has been in the hub's queue. - /// - `admitted`: for a payload's first entry into the queue, the true chain - /// height at that moment and the tip the hub believed in. - /// - `offers`: every time a flush put a payload in flight: the true chain - /// height, the requeues the entry had had (`attempt`), and how many times - /// the hub had offered the payload before (`nth`). - /// - `verdicts`: every time an entry in flight got its answer from the - /// indexer: the true chain height at that moment, which offer of the - /// payload it answers (`nth`), and whether a node judged the transaction - /// (`final`) or nothing did and it was set aside for requeue. /// - `windows`: for each lookup the shim has sent, the answers that were /// true at the hub at some point while it waited. - /// - `refusals`: the admission refusals that have been sent. - /// - `dropped`: entries a requeue gave up on, with the requeues they had had. - type Offer = { payload: Payload, height: Height, attempt: int, nth: int } - type Judgement = { payload: Payload, height: Height, nth: int, final: bool } type Audit = { everQueued: Set[Payload], - admitted: Payload -> { height: Height, tip: Height }, - offers: Set[Offer], - verdicts: Set[Judgement], windows: Nonce -> Set[LookupObs], - refusals: Set[Refusal], - dropped: Set[{ payload: Payload, attempts: int }], } // ------------------------------------------------------------------------ @@ -289,45 +258,30 @@ module state { | ShimErrorOutput(_) => stepped } - /// The system after the hub moved to `state` on its own schedule. A hub with - /// no flush in flight has no flight time. - pure def hubMoved(s: System, state: HubState): System = - { ...s, hub: state, flightBlocks: if (state.flush == Idle) 0 else s.flightBlocks } - - /// The system after one more block: the chain is a block higher, the hub has - /// not asked for the new tip yet, and a flush in flight has been out one - /// block longer. + /// The system after one more block. pure def blockArrived(s: System): System = - { ...s, - indexer: indexerApply(s.indexer, AdvanceIInput, NoIndexerOutput), - polled: false, - flightBlocks: if (s.hub.flush == Idle) 0 else s.flightBlocks + 1, + { ...s, indexer: indexerApply(s.indexer, AdvanceIInput, NoIndexerOutput) } + + /// The system after the hub answered `answer` to the request `mail`. The + /// reply goes back to its sender under its nonce. + pure def hubReplied(s: System, mail: Mail, answer: AAnswer): System = + val nonce = match mail.msg { + | Submit(submit) => submit.nonce + | Lookup(lookup) => lookup.nonce + | _ => 0 } - - /// The system after the hub took `result` on a frame from `client`. Its ack - /// or lookup reply is put on the wire and sent back; nothing else a hub - /// outputs leaves it. - pure def hubReplied(s: System, client: Addr, result: HubResult): System = - val stepped = { ...s, hub: result.state } - match result.out { - | AckOutput(ack) => - stepped.posted(Set({ - src: HubAddr, dst: client, msg: Ack({ nonce: ack.nonce, ack: renderAck(ack.kind) }), - })) - | LookupReplyOutput(reply) => - stepped.posted(Set({ - src: HubAddr, dst: client, - msg: LookupReply({ nonce: reply.nonce, reply: render(reply.outcome) }), - })) - | _ => stepped + val msg = match answer.reply { + | AAck(ack) => Ack({ nonce: nonce, ack: ack }) + | AWire(reply) => LookupReply({ nonce: nonce, reply: reply }) } + { ...s, hub: answer.hub }.posted(Set({ src: HubAddr, dst: mail.src, msg: msg })) - /// The hub input a request frame becomes. `answer` is what the hub's indexer - /// would say to a lookup. Reply frames are not requests and give nothing. - pure def requestInput(msg: Msg, answer: IndexerAnswer): Set[HubInput] = + /// The request a frame carries. `answer` is what the hub's indexer would say + /// to a lookup. Reply frames are not requests and give nothing. + pure def requestOf(msg: Msg, answer: IndexerAnswer): Set[ARequest] = match msg { - | Submit(submit) => Set(SubmitHInput(submit)) - | Lookup(lookup) => Set(LookupHInput({ nonce: lookup.nonce, txid: lookup.txid, answer: answer })) + | Submit(submit) => Set(ASubmit(submit.payload)) + | Lookup(lookup) => Set(ALookup({ txid: lookup.txid, answer: answer })) | _ => Set() } diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt index a09e42af..6a3b275e 100644 --- a/zeronym/spec/protocol/tests/hubTest.qnt +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -386,6 +386,14 @@ module hubTest { state.phase == Draining implies REACH_INPUTS.forall(input => hub(state, input).state.queued().subseteq(state.queued().union(state.inFlight()))))) + /// G7's hub half: a queued entry is within its attempts, and a hub that is + /// down holds nothing and knows nothing. + run wellFormedTest = + assert(REACH.forall(state => and { + state.queued().forall(payload => state.queue.get(payload) >= 0 and state.queue.get(payload) <= PARAMS.maxAttempts), + state.phase == Down implies state == down, + })) + // ------------------------------------------------------------------------ // The abstraction lemma // ------------------------------------------------------------------------ diff --git a/zeronym/spec/protocol/tests/realisedRunsTest.qnt b/zeronym/spec/protocol/tests/realisedRunsTest.qnt index 29d3da37..0d8f92f7 100644 --- a/zeronym/spec/protocol/tests/realisedRunsTest.qnt +++ b/zeronym/spec/protocol/tests/realisedRunsTest.qnt @@ -6,7 +6,8 @@ /// The protocol spec's hub answers wherever the real one would refuse or /// error, so a protocol run proves nothing about reachability on its own. /// Each realisation here is the sequence of inputs the hub sees in that run, -/// on the `baseline` schedule, with the replies the run relies on. A lie is a +/// on the hub specification's `timely` schedule, with the replies the run +/// relies on. A lie is a /// member of the Byzantine relation, with the state unchanged. /// /// K1b has no hub step: the frame is never delivered. @@ -18,13 +19,14 @@ module realisedRunsTest { import abstractHub.* from "../abstractHub" import configs.* from "../instances" + /// The hub specification's `timely` schedule. pure val PARAMS: HubParams = { - flushInterval: baseline.flushInterval, - miningMargin: baseline.miningMargin, - deliveryLag: baseline.deliveryLag, - minWalletExpiry: baseline.minWalletExpiry, - reorgAllowance: baseline.reorgAllowance, - maxAttempts: baseline.maxAttempts, + flushInterval: 3, + miningMargin: 2, + deliveryLag: 1, + minWalletExpiry: 7, + reorgAllowance: 1, + maxAttempts: 2, } pure val UNIVERSE = baseline.payloads.union(baseline.twins).union(baseline.tpPayloads) pure val HEIGHTS = 0.to(baseline.maxHeight) diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index 2f057dae..39002d26 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -17,7 +17,7 @@ module baselineScenarios { import types.* from "../types" import wire.* from "../wire" import indexer.* from "../indexer" - import hub.* from "../hub" + import abstractHub.* from "../abstractHub" import shim.* from "../shim" import state.* from "../state" import configs.* from "../instances" @@ -31,19 +31,19 @@ module baselineScenarios { /// W1, W2, W3. A migration from send to mined, with the wallet polling. run pendingThenMempoolThenMinedTest = - started + init .then(block) // Height 2. The shim diverts and answers at once; the operator sees // nothing. .then(submitTo(0, early)) .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) - .expect(s.hub.queued() == Set(early) and s.operator == Set()) + .expect(s.hub.queue == Set(early) and s.operator == Set()) .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) // Height 3 is a flush boundary. .then(block) .then(flush([early], Accepted)) - .expect(s.hub.queued() == Set() and s.onChain("early") == InMempool) + .expect(s.hub.queue == Set() and s.onChain("early") == InMempool) .then(lookUp(2, "early")) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(2) })) .then(block) @@ -52,44 +52,20 @@ module baselineScenarios { .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 4 }), via: Some(3) })) .expect(wPending and wTxInMempool and wTxMined) .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) - .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry and ackImpliesQueued and wellFormed) - .expect(vConformingFirstOfferJudged and conformingFirstOfferJudgedBeforeExpiry) - .expect(statusNeverRegresses) + .expect(ackImpliesQueued and wellFormed and statusNeverRegresses) /// A pass-through transaction goes to the operator and nowhere else. run passThroughIsForwardedTest = - started + init .then(sendToHub(plain)) .expect(lastEvent == Sent({ input: Clean(plain), obs: SentToOperator })) .expect(s.operator == Set(plain) and s.net == Set()) .expect(vOperatorBlind and operatorBlind and vQueuedBytesConfidential and queuedBytesConfidential) - /// W4. Each of the three refusals, in one hub's life. - run everyRefusalTest = - init - // The hub has started and has seen no tip. - .then(thirdPartySubmitWith(garbage)) - .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) - .expect(wRefusedTipStale and tpAcks == Set((0, WRefused(WTipStale)))) - .then(observe) - // Height 3, and the flush scheduled there has run. The next is at 6, - // which a transaction expiring at 5 does not survive. - .then(blocks(2)) - .then(hubFlushBegin) - .then(submitTo(0, tight)) - .expect(wRefusedExpiryTooTight and s.acks(ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) - // A draining hub refuses under the queue-full code. The network - // delivers the third party's frame a second time. - .then(hubBeginDrain) - .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) - .expect(wRefusedDraining) - .expect(tpAcks == Set((0, WRefused(WTipStale)), (0, WRefused(WQueueFull)))) - .expect(audit.everQueued == Set()) - /// W8. The accepted disclosure: a third party that knows a txid is told it /// is queued, and is not given the bytes. run thirdPartyLearnsItIsQueuedTest = - started + init .then(block) .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) @@ -101,7 +77,7 @@ module baselineScenarios { /// A lookup that gets no reply in time fails closed, and G4 holds of it. run lookupTimesOutTest = - started + init .then(block) .then(submitTo(0, early)) .then(ask("early")) @@ -111,31 +87,33 @@ module baselineScenarios { /// W9. A queued payload the hub cannot parse has no txid to be found by. run unparseableIsQueuedAndMissedTest = - started + init .then(submitTo(0, junk)) - .expect(lastEvent == Sent({ input: Clean(junk), obs: SentOk }) and s.hub.queued() == Set(junk)) + .expect(lastEvent == Sent({ input: Clean(junk), obs: SentOk }) and s.hub.queue == Set(junk)) .then(lookUp(1, "junk")) .expect(lastEvent == Got({ query: "junk", obs: NotFound, via: Some(1) })) .expect(wUnparseableMissed and lookupValidityPerHub) /// W17. Anyone can put a payload in a hub's queue. run thirdPartyPayloadIsQueuedTest = - started + init .then(thirdPartySubmitWith(garbage)) .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) - .expect(tpAcks == Set((0, WAccepted)) and s.hub.queued() == Set(garbage)) + .expect(tpAcks == Set((0, WAccepted)) and s.hub.queue == Set(garbage)) .expect(wThirdPartyPayloadQueued and queuedBytesConfidential) // ------------------------------------------------------------------------ // K1. Told ok, and no hub ever has it // ------------------------------------------------------------------------ - /// K1a. The only hub refuses the frame after the wallet was told ok. + /// K1a. The only hub refuses the frame after the wallet was told ok. Its + /// realisation, refused for its expiry after the flush at 3, is in + /// `realisedRunsTest`. run toldOkThenRefusedTest = - started - .then(blocks(2)) - .then(hubFlushBegin) - .then(submitTo(0, tight)) + init + .then(block) + .then(sendToHub(tight)) + .then(answerSubmitFrom(ShimAddr, 0, tight, WRefused(WExpiryTooTight))) .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) .expect(s.acks(ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) .expect(audit.everQueued == Set()) @@ -143,7 +121,7 @@ module baselineScenarios { /// K1b. The frame is never delivered. Nothing obliges the network to. run toldOkAndNeverDeliveredTest = - started + init .then(block) .then(sendToHub(early)) .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) @@ -157,7 +135,7 @@ module baselineScenarios { /// K2a. Two polls, answered in order, delivered out of order. run repliesReorderedTest = - started + init .then(block) .then(submitTo(0, early)) .then(ask("early")) @@ -179,14 +157,14 @@ module baselineScenarios { /// K2b. The wallet sends published bytes again. The hub's memory of them /// went with the flush, so they are admitted and pending once more. run walletResendsPublishedTest = - started + init .then(block) .then(submitTo(0, early)) .then(block) .then(flush([early], Accepted)) .then(lookUp(1, "early")) .then(submitTo(2, early)) - .expect(s.hub.queued() == Set(early) and s.onChain("early") == InMempool) + .expect(s.hub.queue == Set(early) and s.onChain("early") == InMempool) .then(lookUp(3, "early")) .expect(s.wallet.log.slice(1, 4) == [ Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) }), @@ -198,7 +176,7 @@ module baselineScenarios { /// K2c. The same, done by a third party: the bytes are public once /// published, and submission is open to anyone. run thirdPartyResubmitsPublishedTest = - started + init .then(block) .then(submitTo(0, early)) .then(block) @@ -217,13 +195,13 @@ module baselineScenarios { /// K2d. The flush window: the queue is empty and the chain does not have /// the batch yet. run flushWindowTest = - started + init .then(block) .then(submitTo(0, early)) .then(lookUp(1, "early")) .then(block) - .then(hubFlushBegin) - .expect(s.hub.inFlight() == Set(early) and s.onChain("early") == Absent) + .then(hubTake) + .expect(s.hub.held == Set(early) and s.onChain("early") == Absent) .then(lookUp(2, "early")) .expect(s.wallet.log.slice(1, 3) == [ Got({ query: "early", obs: Pending, via: Some(1) }), @@ -234,13 +212,13 @@ module baselineScenarios { /// K2e. The node rejects the transaction at flush. It was pending; now it /// is nowhere. run rejectedAtFlushTest = - started + init .then(block) .then(submitTo(0, early)) .then(lookUp(1, "early")) .then(block) .then(flush([early], Rejected)) - .expect(s.hub.queued() == Set() and s.hub.inFlight() == Set() and s.onChain("early") == Absent) + .expect(s.hub.queue == Set() and s.hub.held == Set() and s.onChain("early") == Absent) .then(lookUp(2, "early")) .expect(s.wallet.log.slice(1, 3) == [ Got({ query: "early", obs: Pending, via: Some(1) }), @@ -254,7 +232,7 @@ module byzHubScenarios { import types.* from "../types" import wire.* from "../wire" import indexer.* from "../indexer" - import hub.* from "../hub" + import abstractHub.* from "../abstractHub" import shim.* from "../shim" import state.* from "../state" import configs.* from "../instances" @@ -264,13 +242,13 @@ module byzHubScenarios { /// height the chain has not reached. The shim checks the txid, which a twin /// shares, and nothing else: it serves both. run twinAtFalseHeightIsServedTest = - started + init .then(block) .then(submitTo(0, early)) .then(ask("early")) .then(hubReceiveWith( lookupMail(1, "early"), INotFound, - s.hub.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(earlyTwin), height: 9 }))), + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(earlyTwin), height: 9 })))) }, )) .then(deliverToShim(replyMail(1, WFound({ body: Some(earlyTwin), height: 9 })))) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: earlyTwin, height: 9 }), via: Some(1) })) diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index c695b6b0..e9715675 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -16,7 +16,7 @@ module byzHubTrust { import types.* from "../types" import wire.* from "../wire" import indexer.* from "../indexer" - import hub.* from "../hub" + import abstractHub.* from "../abstractHub" import shim.* from "../shim" import state.* from "../state" import configs.* from "../instances" @@ -25,21 +25,21 @@ module byzHubTrust { /// G2 needs the hub. Asked by a third party about a queued txid, it answers /// with the queued bytes. run hubServesQueuedBodyTest = - started + init .then(block) .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(hubReceiveWith( fromThirdParty(lookupMail(0, "early")), INotFound, - s.hub.toLookupReplyOutput(0, FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT }))), + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) }, )) .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) .expect(not(queuedBytesConfidential)) run hubServesQueuedBodyControlTest = - started + init .then(block) .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) @@ -50,21 +50,21 @@ module byzHubTrust { /// G4 needs the hub. It answers not found for a transaction it has queued. run hubDeniesQueuedTest = - started + init .then(block) .then(submitTo(0, early)) .then(ask("early")) .then(hubReceiveWith( lookupMail(1, "early"), INotFound, - s.hub.toLookupReplyOutput(1, FromIndexer(INotFound)), + { hub: s.hub, reply: AWire(render(FromIndexer(INotFound))) }, )) .then(deliverToShim(replyMail(1, WNotFound))) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) - .expect(s.hub.queued() == Set(early) and audit.windows.get(1) == Set(Pending)) + .expect(s.hub.queue == Set(early) and audit.windows.get(1) == Set(Pending)) .expect(not(lookupValidityPerHub)) run hubDeniesQueuedControlTest = - started + init .then(block) .then(submitTo(0, early)) .then(lookUp(1, "early")) @@ -74,7 +74,7 @@ module byzHubTrust { /// G4 needs the hub, second run. It serves a mempool transaction as mined, /// at a height it made up. The txid is right, so the shim passes it on. run hubServesFalseHeightTest = - started + init .then(block) .then(submitTo(0, early)) .then(block) @@ -82,7 +82,7 @@ module byzHubTrust { .then(ask("early")) .then(hubReceiveWith( lookupMail(1, "early"), chainAnswer(s.indexer, "early"), - s.hub.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(early), height: 9 }))), + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: 9 })))) }, )) .then(deliverToShim(replyMail(1, WFound({ body: Some(early), height: 9 })))) .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 9 }), via: Some(1) })) @@ -91,7 +91,7 @@ module byzHubTrust { .expect(not(lookupValidityPerHub) and txidAuthenticity) run hubServesFalseHeightControlTest = - started + init .then(block) .then(submitTo(0, early)) .then(block) @@ -102,32 +102,32 @@ module byzHubTrust { /// G8 needs the hub. It acks a submission as accepted and does not queue it. run hubAcksWithoutAdmittingTest = - started + init .then(block) .then(sendToHub(early)) - .then(hubReceiveWith(submitMail(0, early), INotFound, s.hub.toAckOutput(0, Admitted))) + .then(hubReceiveWith(submitMail(0, early), INotFound, { hub: s.hub, reply: AAck(WAccepted) })) .expect(s.acks(ShimAddr) == Set((0, WAccepted))) - .expect(s.hub.queued() == Set() and audit.everQueued == Set()) + .expect(s.hub.queue == Set() and audit.everQueued == Set()) .expect(not(ackImpliesQueued)) run hubAcksWithoutAdmittingControlTest = - started + init .then(block) .then(sendToHub(early)) .then(deliverSubmit(0, early)) - .expect(s.acks(ShimAddr) == Set((0, WAccepted)) and s.hub.queued() == Set(early)) + .expect(s.acks(ShimAddr) == Set((0, WAccepted)) and s.hub.queue == Set(early)) .expect(ackImpliesQueued) /// G3 survives. The hub answers with another transaction; the shim compares /// txids and refuses it. run wrongTransactionIsRefusedTest = - started + init .then(block) .then(sendToHub(early)) .then(ask("early")) .then(hubReceiveWith( lookupMail(1, "early"), INotFound, - s.hub.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(tight), height: 3 }))), + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(tight), height: 3 })))) }, )) .then(deliverToShim(replyMail(1, WFound({ body: Some(tight), height: 3 })))) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) @@ -139,7 +139,7 @@ module byzIndexerTrust { import types.* from "../types" import wire.* from "../wire" import indexer.* from "../indexer" - import hub.* from "../hub" + import abstractHub.* from "../abstractHub" import shim.* from "../shim" import state.* from "../state" import configs.* from "../instances" @@ -155,11 +155,11 @@ module byzIndexerTrust { /// then serves the unpublished bytes in a lookup answer, which the honest /// hub forwards to whoever asked. run indexerServesUnpublishedBodyTest = - started + init .then(block) .then(submitTo(0, early)) .then(block) - .then(hubFlushBegin) + .then(hubTake) .then(judge(early, Retryable)) .expect(s.indexer.offered == Set(early) and s.onChain("early") == Absent) .then(thirdPartyLearnsTxidWith("early")) @@ -172,11 +172,11 @@ module byzIndexerTrust { .expect(not(queuedBytesConfidential)) run indexerServesUnpublishedBodyControlTest = - started + init .then(block) .then(submitTo(0, early)) .then(block) - .then(hubFlushBegin) + .then(hubTake) .then(judge(early, Retryable)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) @@ -188,7 +188,7 @@ module byzIndexerTrust { /// "found, height 0, no body". The hub forwards it unchanged, and on the /// wire it is the hub's own "queued here": the wallet sees pending. run indexerForgesPendingTest = - started + init .then(block) .then(sends(Clean(early), true)) .then(ask("early")) @@ -199,7 +199,7 @@ module byzIndexerTrust { .expect(not(lookupValidityPerHub)) run indexerForgesPendingControlTest = - started + init .then(block) .then(sends(Clean(early), true)) .then(lookUp(1, "early")) diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index 284a14ed..2e3a2787 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -193,21 +193,8 @@ module types { payloads: Set[Payload], twins: Set[Payload], tpPayloads: Set[Payload], - flushInterval: int, - miningMargin: int, - deliveryLag: int, - reorgAllowance: int, - staleWindow: int, - minWalletExpiry: int, - maxAttempts: int, - maxFlightBlocks: int, maxHeight: Height, maxRequests: int, roles: Roles, - tip: TipModel, - // Which of the two optional timing relations this configuration relies - // on. A configuration that exists to show what one of them buys drops it. - reliesOnReorgSlack: bool, - reliesOnFlightWithinMargin: bool, } } From e541e77b4b9379a5b4cd0781510caa03d64c962e Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 12:40:01 +0400 Subject: [PATCH 50/80] test(zeronym): remove heights and the block clock from the protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 9 ++-- zeronym/spec/protocol/abstractHub.qnt | 6 +-- zeronym/spec/protocol/hub.qnt | 5 +- zeronym/spec/protocol/hubMachine.qnt | 4 +- zeronym/spec/protocol/indexer.qnt | 37 +++++++------- zeronym/spec/protocol/instances.qnt | 1 - zeronym/spec/protocol/properties.qnt | 15 ++++-- zeronym/spec/protocol/protocol.qnt | 40 ++++------------ zeronym/spec/protocol/state.qnt | 15 ++---- zeronym/spec/protocol/tests/hubTest.qnt | 29 ++++++----- zeronym/spec/protocol/tests/indexerTest.qnt | 43 ++++++++++------- .../spec/protocol/tests/realisedRunsTest.qnt | 29 ++++++----- zeronym/spec/protocol/tests/scenariosTest.qnt | 48 +++++-------------- zeronym/spec/protocol/tests/shimTest.qnt | 10 ++-- zeronym/spec/protocol/tests/trustTest.qnt | 45 ++++++----------- zeronym/spec/protocol/tests/wireTest.qnt | 14 +++--- zeronym/spec/protocol/types.qnt | 29 ++++++----- zeronym/spec/protocol/wire.qnt | 6 +-- 18 files changed, 167 insertions(+), 218 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 4ee5008d..95138c19 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -120,7 +120,7 @@ definitions they justify. | Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4. `divert.qnt` omits it | | Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | | Hub | In the hub specification: lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash. In the protocol specification: the abstract hub, a queue and the entries out with a flush, which accepts, refuses, takes, settles, gives back and loses (see [The abstraction lemma](#the-abstraction-lemma)) | S10-S19 | -| Chain / indexer | height; per-txid status; what the indexer has been offered; verdict and lookup-answer relations | S15, S22 | +| Chain / indexer | per-txid status (absent, mempool, mined); what the indexer has been offered; verdict and lookup-answer relations. A lookup answer's height is 0, the height the transaction was mined at, or another (`WireHeight`). The protocol specification has no chain height and no block clock, and its verdict relation has no expiry clause (`heightlessIndexerResults`); the hub specification keeps the chain height, which its tip and expiry rules read | S15, S22 | | Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation; frame size classes | S20, S22 | | Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | | Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned and payloads of its own making; its payload knowledge is derived from what it can observe | S13, S25 | @@ -350,7 +350,7 @@ stateDiagram-v2 stateDiagram-v2 [*] --> Absent Absent --> Mempool: a broadcast is accepted - Mempool --> Mined: included at a height + Mempool --> Mined: included in a block Mined --> [*] ``` @@ -499,8 +499,8 @@ One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields | `byzHub` | **B** / H | | `byzIndexer` | H / **B** | -Heights up to 12, at most 3 sends and 3 lookups by the wallet and 3 requests -by the third party. Each configuration's `assumptionsTest` asserts +At most 3 sends and 3 lookups by the wallet and 3 requests by the third +party. Each configuration's `assumptionsTest` asserts `payloadsWellFormed`; the simulator does not enforce `assume`. The hub specification's configurations, its scaled-down schedule and the relations it keeps with the shipped one are in `hubMachine.qnt`. @@ -523,6 +523,7 @@ it keeps with the shipped one are in `hubMachine.qnt`. | F10 | A draining hub refuses under the queue-full code | `wireTest::ackRenderingTest` | | F11 | `hub` and `shim` are total; an invalid input returns an error and changes nothing | `hubTest::totalityTest`, `shimTest::totalityTest` | | F12 | Each Byzantine relation contains the honest transition | `byzantineContainsHonestTest` in `hubTest`, `shimTest`, `indexerTest` | +| F15 | The protocol's heightless verdict relation contains the honest one and is wider only by the expiry clause | `indexerTest::heightlessCoversTest` | | F13 | An accepted ack is given only for a payload the hub then holds; a Byzantine hub can do otherwise | `hubTest::ackImpliesQueuedTest`, `hubTest::byzantineHubTest` | | F14 | The tip rule: first observation adopted; forward followed; a drop within the allowance followed; a larger drop ignored | `hubTest::tipRuleTest` | diff --git a/zeronym/spec/protocol/abstractHub.qnt b/zeronym/spec/protocol/abstractHub.qnt index f560d13e..88beca2c 100644 --- a/zeronym/spec/protocol/abstractHub.qnt +++ b/zeronym/spec/protocol/abstractHub.qnt @@ -47,14 +47,14 @@ module abstractHub { /// A Byzantine hub queues a submission or not, whatever it answers, and /// answers a lookup with anything: a body from `universe` or none, at any - /// height in `heights`, or not found, or an error. - pure def byzantineAnswers(h: AHub, request: ARequest, universe: Set[Payload], heights: Set[Height]): Set[AAnswer] = + /// height, or not found, or an error. + pure def byzantineAnswers(h: AHub, request: ARequest, universe: Set[Payload]): Set[AAnswer] = match request { | ASubmit(payload) => tuples(Set(h, { ...h, queue: h.queue.union(Set(payload)) }), WIRE_ACKS) .map(((after, ack)) => { hub: after, reply: AAck(ack) }) | ALookup(_) => - tuples(Set(None).union(universe.map(payload => Some(payload))), heights) + tuples(Set(None).union(universe.map(payload => Some(payload))), WIRE_HEIGHTS) .map(((body, claimed)) => WFound({ body: body, height: claimed })) .union(Set(WNotFound, WError)) .map(reply => { hub: h, reply: AWire(reply) }) diff --git a/zeronym/spec/protocol/hub.qnt b/zeronym/spec/protocol/hub.qnt index 447e6d91..22232334 100644 --- a/zeronym/spec/protocol/hub.qnt +++ b/zeronym/spec/protocol/hub.qnt @@ -416,14 +416,13 @@ module hub { /// or not, independently of the answer and of every admission rule; /// - a lookup is answered with any outcome: a queue hit, or anything an /// indexer could say, with a body drawn from `universe` or none, at any - /// height in `heights`. + /// height. /// /// The honest transition is always a member. pure def byzHubResults( state: HubState, input: HubInput, universe: Set[Payload], - heights: Set[Height], ): Set[HubResult] = val honest = Set(hub(state, input)) if (not(state.isServing())) honest @@ -439,7 +438,7 @@ module hub { after.toAckOutput(submit.nonce, kind))) | LookupHInput(lookup) => val bodies = Set(None).union(universe.map(payload => Some(payload))) - val answers = tuples(bodies, heights) + val answers = tuples(bodies, WIRE_HEIGHTS) .map(((body, claimed)) => IFound({ body: body, height: claimed })) .union(Set(INotFound, IUnavailable)) val outcomes = Set(QueueHit).union(answers.map(answer => FromIndexer(answer))) diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/protocol/hubMachine.qnt index c8348e02..afa1b369 100644 --- a/zeronym/spec/protocol/hubMachine.qnt +++ b/zeronym/spec/protocol/hubMachine.qnt @@ -236,7 +236,7 @@ module hubMachine { def broadcastResults(payload: Payload): Set[IndexerResult] = match cfg.indexerRole { | Honest => honestIndexerResults(chain, BroadcastIInput(payload)) - | Byzantine => byzIndexerResults(chain, BroadcastIInput(payload), cfg.payloads, CLOCK_HEIGHTS) + | Byzantine => byzIndexerResults(chain, BroadcastIInput(payload), cfg.payloads) } /// The indexer's transition that gives `given` on `payload` and relays the @@ -266,7 +266,7 @@ module hubMachine { val input = SubmitHInput({ nonce: 0, payload: payload }) match cfg.hubRole { | Honest => Set(hub(h, input)) - | Byzantine => byzHubResults(h, input, cfg.payloads, Set()) + | Byzantine => byzHubResults(h, input, cfg.payloads) } // ------------------------------------------------------------------------ diff --git a/zeronym/spec/protocol/indexer.qnt b/zeronym/spec/protocol/indexer.qnt index 830c6509..c7f3abfb 100644 --- a/zeronym/spec/protocol/indexer.qnt +++ b/zeronym/spec/protocol/indexer.qnt @@ -100,7 +100,7 @@ module indexer { | AdvanceIInput => { ...state, height: state.height + 1 } | MineIInput(txid) => if (state.inclusion(txid) == InMempool) - { ...state, txs: state.txs.setBy(txid, tx => { ...tx, at: MinedAt(state.height) }) } + { ...state, txs: state.txs.setBy(txid, tx => { ...tx, at: Mined }) } else state } @@ -140,6 +140,19 @@ module indexer { honestIndexerOutputs(state, input).map(output => { state: indexerApply(state, input, output), out: output }) + /// The honest relation as the protocol specification uses it, which has no + /// chain height: a broadcast that parses and is new may be accepted whatever + /// its expiry. It contains the honest relation (`heightlessCoversTest`). + pure def heightlessIndexerResults(state: IndexerState, input: IndexerInput): Set[IndexerResult] = + val accepted = match input { + | BroadcastIInput(payload) => + if (isSome(payload.txid) and not(state.isKnown(payload))) + Set({ state: indexerApply(state, input, VerdictOutput(Accepted)), out: VerdictOutput(Accepted) }) + else Set() + | _ => Set() + } + honestIndexerResults(state, input).union(accepted) + /// The truthful, available answer to a lookup: the one member of the honest /// relation that is not `IUnavailable`. pure def chainAnswer(state: IndexerState, txid: TxId): IndexerAnswer = @@ -167,20 +180,15 @@ module indexer { known.exists(payload => candidate == payload or areTwins(candidate, payload))) /// The outputs a Byzantine indexer may give: any verdict, and any lookup - /// answer built from a payload it can serve, at any height in `heights`, - /// with or without a body. - pure def byzIndexerOutputs( - state: IndexerState, - input: IndexerInput, - universe: Set[Payload], - heights: Set[Height], - ): Set[IndexerOutput] = + /// answer built from a payload it can serve, at any height, with or without + /// a body. + pure def byzIndexerOutputs(state: IndexerState, input: IndexerInput, universe: Set[Payload]): Set[IndexerOutput] = match input { | BroadcastIInput(_) => Set(Accepted, AlreadyKnown, Rejected, Retryable).map(verdict => VerdictOutput(verdict)) | LookupIInput(_) => val bodies = Set(None).union(state.servable(universe).map(payload => Some(payload))) - tuples(bodies, heights) + tuples(bodies, WIRE_HEIGHTS) .map(((body, claimed)) => AnswerOutput(IFound({ body: body, height: claimed }))) .union(Set(AnswerOutput(INotFound), AnswerOutput(IUnavailable))) .union(honestIndexerOutputs(state, input)) @@ -193,13 +201,8 @@ module indexer { /// relay a transaction it claims to have rejected, or sit on one it claims to /// have accepted. It cannot make the network take a transaction a node would /// refuse: the chain itself stays honest. - pure def byzIndexerResults( - state: IndexerState, - input: IndexerInput, - universe: Set[Payload], - heights: Set[Height], - ): Set[IndexerResult] = - val outputs = byzIndexerOutputs(state, input, universe, heights) + pure def byzIndexerResults(state: IndexerState, input: IndexerInput, universe: Set[Payload]): Set[IndexerResult] = + val outputs = byzIndexerOutputs(state, input, universe) match input { | BroadcastIInput(payload) => val withheld = indexerApply(state, input, VerdictOutput(Retryable)) diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt index bca2d295..6fa507b9 100644 --- a/zeronym/spec/protocol/instances.qnt +++ b/zeronym/spec/protocol/instances.qnt @@ -49,7 +49,6 @@ module configs { payloads: Set(early, late, tight, junk, plain), twins: Set(earlyTwin), tpPayloads: Set(garbage), - maxHeight: 12, maxRequests: 3, roles: allHonest, } diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt index 26735956..ac45d798 100644 --- a/zeronym/spec/protocol/properties.qnt +++ b/zeronym/spec/protocol/properties.qnt @@ -222,7 +222,7 @@ module properties { pure def wTxInMempoolIn(s: System): bool = s.wasGiven(obs => match obs { - | Tx(tx) => tx.height == MEMPOOL_HEIGHT + | Tx(tx) => tx.height == AtZero | _ => false }) @@ -230,7 +230,7 @@ module properties { pure def wTxMinedIn(s: System): bool = s.wasGiven(obs => match obs { - | Tx(tx) => tx.height != MEMPOOL_HEIGHT + | Tx(tx) => tx.height == AtMined | _ => false }) @@ -246,7 +246,7 @@ module properties { /// > is left open deliberately. pure def wQueuedDisclosedIn(s: System): bool = tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => - lookup._1 == reply._1 and reply._2 == WFound({ body: None, height: MEMPOOL_HEIGHT })) + lookup._1 == reply._1 and reply._2 == WFound({ body: None, height: AtZero })) /// W9. The hub holds a payload it cannot parse; the wallet that sent it asks /// for it and is told not found. An entry without a txid can never @@ -272,12 +272,17 @@ module properties { }) /// W16b. The wallet is served a transaction at a height that cannot be - /// true: the chain does not have it, or has not got that far. + /// true: the chain does not have it, or has it in the mempool and the + /// height says mined, or the height is not where it was mined. pure def wFalseHeightServedIn(s: System): bool = s.wasGiven(obs => match obs { | Tx(tx) => - tx.height > s.height() or not(s.indexer.published().exists(payload => payload.txid == tx.payload.txid)) + val at = match tx.payload.txid { + | Some(txid) => s.onChain(txid) + | None => Absent + } + or { at == Absent, tx.height == AtOther, tx.height == AtMined and at != Mined } | _ => false }) diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 0aae7604..2b94ff11 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -65,24 +65,18 @@ module protocol { /// Payloads of the third party's own making. pure val TP_PAYLOADS = CONFIG.tpPayloads - /// Bounds of the model: the chain stops growing at `MAX_HEIGHT`, and the - /// wallet makes at most `MAX_REQUESTS` sends and as many lookups, as does - /// the third party. - pure val MAX_HEIGHT = CONFIG.maxHeight + /// The bound of the model: the wallet makes at most `MAX_REQUESTS` sends + /// and as many lookups, as does the third party. pure val MAX_REQUESTS = CONFIG.maxRequests pure val ROLES = CONFIG.roles /// Every payload that exists. A Byzantine component builds its lies from it. pure val UNIVERSE = PAYLOADS.union(TWINS).union(TP_PAYLOADS) - /// Every height a Byzantine component may claim. - pure val HEIGHTS = 0.to(MAX_HEIGHT) /// What a wallet may send. pure val SEND_INPUTS = PAYLOADS.map(payload => Clean(payload)).union(Set(Unreadable, EmptyBody)) /// Every transaction id there is. pure val TXIDS = txidsOf(UNIVERSE) - /// The height the chain starts at. Height 0 is kept for "in the mempool". - pure val GENESIS_HEIGHT = 1 // ------------------------------------------------------------------------ // Assumptions @@ -97,7 +91,6 @@ module protocol { UNIVERSE.size() == UNIVERSE.map(payload => payload.id).size(), PAYLOADS.intersect(TP_PAYLOADS) == Set(), TWINS.forall(twin => PAYLOADS.exists(payload => areTwins(twin, payload))), - UNIVERSE.forall(payload => payload.created >= GENESIS_HEIGHT), } assume _ = payloadsWellFormed @@ -120,7 +113,7 @@ module protocol { lastAction' = label, } - pure val INITIAL = initialSystem(CONFIG, GENESIS_HEIGHT) + pure val INITIAL = initialSystem(CONFIG) action init = all { s' = INITIAL, @@ -135,13 +128,13 @@ module protocol { pure def hubAnswers(h: AHub, request: ARequest): Set[AAnswer] = match ROLES.hub { | Honest => honestAnswers(h, request) - | Byzantine => byzantineAnswers(h, request, UNIVERSE, HEIGHTS) + | Byzantine => byzantineAnswers(h, request, UNIVERSE) } pure def indexerResults(state: IndexerState, input: IndexerInput): Set[IndexerResult] = match ROLES.indexer { - | Honest => honestIndexerResults(state, input) - | Byzantine => byzIndexerResults(state, input, UNIVERSE, HEIGHTS) + | Honest => heightlessIndexerResults(state, input) + | Byzantine => byzIndexerResults(state, input, UNIVERSE) } /// What the indexer may answer a hub's lookup for the transaction `msg` @@ -172,11 +165,6 @@ module protocol { action walletSendWith(input: SendInput, handedOver: bool): bool = all { s.wallet.sends < MAX_REQUESTS, SEND_INPUTS.contains(input), - // A wallet cannot send a transaction before it has built it. - match input { - | Clean(payload) => payload.created <= s.height() - | _ => true - }, val result = shim(s.shim, SendTxSInput({ input: input, handedOver: handedOver })) all { not(isShimError(result.out)), @@ -362,12 +350,6 @@ module protocol { // Chain // ------------------------------------------------------------------------ - /// The chain grows by one block. - action chainAdvance = all { - s.height() < MAX_HEIGHT, - commit(s.blockArrived(), ChainAdvance), - } - /// The transactions waiting in the mempool. def mempool: Set[TxId] = s.indexer.txs.keys().filter(txid => s.onChain(txid) == InMempool) @@ -492,7 +474,7 @@ module protocol { walletSend, walletGet, shimReceive, hubReceive, hubTake, indexerVerdict, hubReturn, - chainAdvance, chainMine, + chainMine, faultStep, outsiderStep, } @@ -507,7 +489,7 @@ module protocol { walletSend, walletGet, shimReceive, hubReceive, hubTake, indexerVerdict, hubReturn, - chainAdvance, chainMine, + chainMine, } // ------------------------------------------------------------------------ @@ -651,12 +633,6 @@ module protocol { out: VerdictOutput(verdict), }) - /// One block arrives. - run block = chainAdvance - - /// `count` blocks arrive. - run blocks(count: int): bool = count.reps(_ => block) - /// The wallet sends `payload`, and the frame under `nonce` reaches the hub. run submitTo(nonce: Nonce, payload: Payload): bool = sendToHub(payload).then(deliverSubmit(nonce, payload)) diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt index 34fd3255..28bba21f 100644 --- a/zeronym/spec/protocol/state.qnt +++ b/zeronym/spec/protocol/state.qnt @@ -48,9 +48,10 @@ module state { disclosed: Set[Payload], } - /// The system at rest: the chain at `height`, the hub empty, nothing sent. - pure def initialSystem(config: Config, height: Height): System = { - indexer: initialIndexer(height), + /// The system at rest: the chain and the hub empty, nothing sent. The + /// protocol has no chain height, and the indexer's stays at 0. + pure def initialSystem(config: Config): System = { + indexer: initialIndexer(0), hub: emptyAHub, shim: initialShim, net: Set(), @@ -74,7 +75,6 @@ module state { | IndexerVerdict(Payload) | HubReturn(Set[Payload]) | HubLose - | ChainAdvance | ChainMine(TxId) | ThirdPartyLearnsTxid(TxId) | ThirdPartyLookup(TxId) @@ -97,9 +97,6 @@ module state { // Views // ------------------------------------------------------------------------ - pure def height(s: System): Height = - s.indexer.height - /// Where `txid` stands on the chain. pure def onChain(s: System, txid: TxId): Inclusion = s.indexer.inclusion(txid) @@ -258,10 +255,6 @@ module state { | ShimErrorOutput(_) => stepped } - /// The system after one more block. - pure def blockArrived(s: System): System = - { ...s, indexer: indexerApply(s.indexer, AdvanceIInput, NoIndexerOutput) } - /// The system after the hub answered `answer` to the request `mail`. The /// reply goes back to its sender under its nonce. pure def hubReplied(s: System, mail: Mail, answer: AAnswer): System = diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt index 6a3b275e..7a40e378 100644 --- a/zeronym/spec/protocol/tests/hubTest.qnt +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -28,7 +28,6 @@ module hubTest { pure val pJunk = { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } pure val PAYLOADS = Set(pA, pB, pC, pTight, pJunk) - pure val HEIGHTS = Set(0, 4) pure val REFUSALS = Set(TipStale, HubDraining, ExpiryTooTight) /// The state after `input`. @@ -71,7 +70,7 @@ module hubTest { PAYLOADS.map(payload => submit(payload)) .union(Set( LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), - LookupHInput({ nonce: 1, txid: "zz", answer: IFound({ body: Some(pB), height: 4 }) }), + LookupHInput({ nonce: 1, txid: "zz", answer: IFound({ body: Some(pB), height: AtMined }) }), TipHInput(0), TipHInput(4), TipHInput(5), TipHInput(6), TipHInput(9), StaleHInput(4), StaleHInput(8), StaleHInput(9), FlushDueHInput, FlushDoneHInput, DrainHInput, CrashHInput, RestartHInput, @@ -153,10 +152,10 @@ module hubTest { run lookupTest = all { // A queue hit, whatever the indexer would have said. - assert(outputOf(holding, LookupHInput({ nonce: 7, txid: "a", answer: IFound({ body: Some(pA), height: 4 }) })) + assert(outputOf(holding, LookupHInput({ nonce: 7, txid: "a", answer: IFound({ body: Some(pA), height: AtMined }) })) == LookupReplyOutput({ nonce: 7, outcome: QueueHit })), // A miss forwards the indexer's answer as it came. - assert(Set(INotFound, IUnavailable, IFound({ body: Some(pB), height: 4 }), IFound({ body: None, height: 0 })) + assert(Set(INotFound, IUnavailable, IFound({ body: Some(pB), height: AtMined }), IFound({ body: None, height: AtZero })) .forall(answer => outputOf(holding, LookupHInput({ nonce: 7, txid: "b", answer: answer })) == LookupReplyOutput({ nonce: 7, outcome: FromIndexer(answer) }))), @@ -312,20 +311,20 @@ module hubTest { /// F12. The Byzantine relation contains the honest transition. run byzantineContainsHonestTest = assert(tuples(STATES, INPUTS).forall(((state, input)) => - byzHubResults(state, input, PAYLOADS, HEIGHTS).contains(hub(state, input)))) + byzHubResults(state, input, PAYLOADS).contains(hub(state, input)))) /// F13, companion. A Byzantine hub can accept a payload it does not hold, /// can hold one admission would refuse, and can answer a lookup with a /// queued body. run byzantineHubTest = all { - assert(byzHubResults(running(5), submit(pA), PAYLOADS, HEIGHTS).exists(result => + assert(byzHubResults(running(5), submit(pA), PAYLOADS).exists(result => result.out == AckOutput({ nonce: 0, kind: Admitted }) and not(result.state.queued().contains(pA)))), - assert(byzHubResults(running(5), submit(pTight), PAYLOADS, HEIGHTS).exists(result => + assert(byzHubResults(running(5), submit(pTight), PAYLOADS).exists(result => result.state.queued().contains(pTight))), - assert(byzHubResults(holding, LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), PAYLOADS, HEIGHTS) - .contains(holding.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(pA), height: 0 }))))), + assert(byzHubResults(holding, LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), PAYLOADS) + .contains(holding.toLookupReplyOutput(1, FromIndexer(IFound({ body: Some(pA), height: AtZero }))))), // It still cannot act while it is not running. - assert(byzHubResults(down, submit(pA), PAYLOADS, HEIGHTS) == Set(hub(down, submit(pA)))), + assert(byzHubResults(down, submit(pA), PAYLOADS) == Set(hub(down, submit(pA)))), } // ------------------------------------------------------------------------ @@ -351,7 +350,7 @@ module hubTest { /// The states a Byzantine hub may move to on a submission. pure def byzantineSteps(state: HubState): Set[HubState] = - REACH_PAYLOADS.map(payload => byzHubResults(state, submit(payload), REACH_PAYLOADS, HEIGHTS) + REACH_PAYLOADS.map(payload => byzHubResults(state, submit(payload), REACH_PAYLOADS) .map(result => result.state)).flatten() pure def successors(state: HubState): Set[HubState] = @@ -416,9 +415,9 @@ module hubTest { } pure val LOOKUPS: Set[HubInput] = - Set(INotFound, IUnavailable, IFound({ body: Some(pA), height: 4 })) + Set(INotFound, IUnavailable, IFound({ body: Some(pA), height: AtMined })) .map(answer => LookupHInput({ nonce: 1, txid: "a", answer: answer })) - .union(Set(LookupHInput({ nonce: 1, txid: "zz", answer: IFound({ body: Some(pB), height: 4 }) }))) + .union(Set(LookupHInput({ nonce: 1, txid: "zz", answer: IFound({ body: Some(pB), height: AtMined }) }))) /// An error changes nothing. A request is one of `answers`; anything else /// is an internal move with no reply. @@ -445,8 +444,8 @@ module hubTest { run byzantineAbstractionTest = assert(REACH.forall(state => REACH_PAYLOADS.map(payload => submit(payload)).union(LOOKUPS).forall(input => - byzHubResults(state, input, PAYLOADS, HEIGHTS).forall(result => - isAbstracted(state, input, result, (h, request) => byzantineAnswers(h, request, PAYLOADS, HEIGHTS)))))) + byzHubResults(state, input, PAYLOADS).forall(result => + isAbstracted(state, input, result, (h, request) => byzantineAnswers(h, request, PAYLOADS)))))) /// Each abstract move has a concrete step that projects onto it. pure def realises(state: HubState, input: HubInput, move: AHub, reply: Option[AReply]): bool = diff --git a/zeronym/spec/protocol/tests/indexerTest.qnt b/zeronym/spec/protocol/tests/indexerTest.qnt index e4898127..8caf2807 100644 --- a/zeronym/spec/protocol/tests/indexerTest.qnt +++ b/zeronym/spec/protocol/tests/indexerTest.qnt @@ -16,7 +16,6 @@ module indexerTest { pure val pJunk = { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } pure val UNIVERSE = Set(pA, pATwin, pB, pOld, pJunk) - pure val HEIGHTS = Set(0, 3, 7) pure def verdictOn(given: Verdict): IndexerOutput = VerdictOutput(given) @@ -66,11 +65,11 @@ module indexerTest { == Set(AnswerOutput(INotFound), AnswerOutput(IUnavailable))), // A mempool transaction is found at height 0, with its bytes. assert(honestIndexerOutputs(withA, LookupIInput("ta")) - == Set(AnswerOutput(IFound({ body: Some(pA), height: MEMPOOL_HEIGHT })), AnswerOutput(IUnavailable))), + == Set(AnswerOutput(IFound({ body: Some(pA), height: AtZero })), AnswerOutput(IUnavailable))), assert(honestIndexerOutputs(minedA, LookupIInput("ta")) - == Set(AnswerOutput(IFound({ body: Some(pA), height: 5 })), AnswerOutput(IUnavailable))), + == Set(AnswerOutput(IFound({ body: Some(pA), height: AtMined })), AnswerOutput(IUnavailable))), assert(chainAnswer(empty, "ta") == INotFound), - assert(chainAnswer(minedA, "ta") == IFound({ body: Some(pA), height: 5 })), + assert(chainAnswer(minedA, "ta") == IFound({ body: Some(pA), height: AtMined })), // A lookup changes nothing. assert(STATES.forall(state => honestIndexerResults(state, LookupIInput("ta")).forall(result => result.state == state))), @@ -89,7 +88,7 @@ module indexerTest { run chainTest = all { assert(indexerApply(empty, AdvanceIInput, NoIndexerOutput).height == 5), - assert(minedA.inclusion("ta") == MinedAt(5)), + assert(minedA.inclusion("ta") == Mined), // Only a mempool transaction can be mined: not an absent one, not twice. assert(honestIndexerOutputs(withA, MineIInput("ta")) == Set(NoIndexerOutput)), assert(honestIndexerOutputs(empty, MineIInput("ta")) == Set()), @@ -107,34 +106,46 @@ module indexerTest { /// honest tip report is one a Byzantine indexer could make. run byzantineContainsHonestTest = all { assert(tuples(STATES, INPUTS).forall(((state, input)) => - honestIndexerResults(state, input).subseteq(byzIndexerResults(state, input, UNIVERSE, HEIGHTS)))), + honestIndexerResults(state, input).subseteq(byzIndexerResults(state, input, UNIVERSE)))), assert(tuples(STATES, INPUTS).forall(((state, input)) => - honestIndexerOutputs(state, input).subseteq(byzIndexerOutputs(state, input, UNIVERSE, HEIGHTS)))), + honestIndexerOutputs(state, input).subseteq(byzIndexerOutputs(state, input, UNIVERSE)))), assert(tuples(STATES, 0.to(3)).forall(((state, slack)) => honestTips(state, slack, 0.to(7)).subseteq(byzTips(0.to(7))))), } + /// The protocol's relation, which has no chain height, contains the honest + /// relation, and is wider by the expiry clause only: it may accept an + /// expired transaction. + run heightlessCoversTest = all { + assert(tuples(STATES, INPUTS).forall(((state, input)) => + honestIndexerResults(state, input).subseteq(heightlessIndexerResults(state, input)))), + assert(tuples(STATES, INPUTS).forall(((state, input)) => + heightlessIndexerResults(state, input).exclude(honestIndexerResults(state, input)).forall(result => + input == BroadcastIInput(pOld) and result.out == VerdictOutput(Accepted)))), + assert(heightlessIndexerResults(empty, BroadcastIInput(pOld)).exists(result => result.out == VerdictOutput(Accepted))), + } + run byzantineIndexerTest = all { // It may serve what it was offered and the chain has not published. assert(offeredB.servable(UNIVERSE) == Set(pB)), - assert(byzIndexerOutputs(offeredB, LookupIInput("tb"), UNIVERSE, HEIGHTS) - .contains(AnswerOutput(IFound({ body: Some(pB), height: 7 })))), + assert(byzIndexerOutputs(offeredB, LookupIInput("tb"), UNIVERSE) + .contains(AnswerOutput(IFound({ body: Some(pB), height: AtOther })))), // A twin of what it knows, too. Nothing else. assert(withA.servable(UNIVERSE) == Set(pA, pATwin)), assert(empty.servable(UNIVERSE) == Set()), // "Found, height 0, no body" for a transaction that does not exist. - assert(byzIndexerOutputs(empty, LookupIInput("ta"), UNIVERSE, HEIGHTS) - .contains(AnswerOutput(IFound({ body: None, height: MEMPOOL_HEIGHT })))), + assert(byzIndexerOutputs(empty, LookupIInput("ta"), UNIVERSE) + .contains(AnswerOutput(IFound({ body: None, height: AtZero })))), // The verdict and the effect are independent. - assert(byzIndexerResults(empty, BroadcastIInput(pA), UNIVERSE, HEIGHTS) + assert(byzIndexerResults(empty, BroadcastIInput(pA), UNIVERSE) .contains({ state: offeredB.with("offered", Set(pA)), out: verdictOn(Accepted) })), - assert(byzIndexerResults(empty, BroadcastIInput(pA), UNIVERSE, HEIGHTS) + assert(byzIndexerResults(empty, BroadcastIInput(pA), UNIVERSE) .contains({ state: withA, out: verdictOn(Rejected) })), // It cannot put an expired transaction on the chain. - assert(byzIndexerResults(empty, BroadcastIInput(pOld), UNIVERSE, HEIGHTS) + assert(byzIndexerResults(empty, BroadcastIInput(pOld), UNIVERSE) .forall(result => result.state.txs == Map())), // The chain's own steps are not the indexer's to change. - assert(byzIndexerResults(withA, AdvanceIInput, UNIVERSE, HEIGHTS) == honestIndexerResults(withA, AdvanceIInput)), - assert(byzIndexerResults(withA, MineIInput("ta"), UNIVERSE, HEIGHTS) == honestIndexerResults(withA, MineIInput("ta"))), + assert(byzIndexerResults(withA, AdvanceIInput, UNIVERSE) == honestIndexerResults(withA, AdvanceIInput)), + assert(byzIndexerResults(withA, MineIInput("ta"), UNIVERSE) == honestIndexerResults(withA, MineIInput("ta"))), } } diff --git a/zeronym/spec/protocol/tests/realisedRunsTest.qnt b/zeronym/spec/protocol/tests/realisedRunsTest.qnt index 0d8f92f7..b02b3732 100644 --- a/zeronym/spec/protocol/tests/realisedRunsTest.qnt +++ b/zeronym/spec/protocol/tests/realisedRunsTest.qnt @@ -29,7 +29,6 @@ module realisedRunsTest { maxAttempts: 2, } pure val UNIVERSE = baseline.payloads.union(baseline.twins).union(baseline.tpPayloads) - pure val HEIGHTS = 0.to(baseline.maxHeight) type Step = Do(HubInput) | Lie({ input: HubInput, out: HubOutput }) @@ -38,7 +37,7 @@ module realisedRunsTest { LookupHInput({ nonce: nonce, txid: txid, answer: answer }) pure def judged(payload: Payload, verdict: Verdict): HubInput = VerdictHInput({ payload: payload, verdict: verdict }) - pure def found(body: Option[Payload], height: Height): IndexerAnswer = IFound({ body: body, height: height }) + pure def found(body: Option[Payload], height: WireHeight): IndexerAnswer = IFound({ body: body, height: height }) pure def tips(heights: List[Height]): List[Step] = heights.foldl([], (acc, h) => acc.append(Do(TipHInput(h)))) pure def does(inputs: List[HubInput]): List[Step] = inputs.foldl([], (acc, i) => acc.append(Do(i))) @@ -62,7 +61,7 @@ module realisedRunsTest { } val taken = match step { | Do(_) => not(match result.out { | HubErrorOutput(_) => true | _ => false }) - | Lie(lie) => byzHubResults(acc.state, lie.input, UNIVERSE, HEIGHTS).contains(result) + | Lie(lie) => byzHubResults(acc.state, lie.input, UNIVERSE).contains(result) } { state: result.state, ok: acc.ok and taken, replies: acc.replies.concat(result.out.replyOf()) }) { ok: end.ok, replies: end.replies, last: { queue: end.state.queued(), held: end.state.inFlight() } } @@ -71,8 +70,8 @@ module realisedRunsTest { replay(steps) == { ok: true, replies: replies, last: last } pure val accepted = AAck(WAccepted) - pure val pending = AWire(WFound({ body: None, height: MEMPOOL_HEIGHT })) - pure val inMempool = AWire(WFound({ body: Some(early), height: MEMPOOL_HEIGHT })) + pure val pending = AWire(WFound({ body: None, height: AtZero })) + pure val inMempool = AWire(WFound({ body: Some(early), height: AtZero })) pure val queuedEarly = { queue: Set(early), held: Set() } /// Running at height 2 with `early` queued, as most runs begin. @@ -86,11 +85,11 @@ module realisedRunsTest { [AAck(WRefused(WExpiryTooTight))], emptyAHub)), // K2a: pending, then in the mempool. assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Accepted)) - .append(Do(look(2, "early", found(Some(early), MEMPOOL_HEIGHT)))), + .append(Do(look(2, "early", found(Some(early), AtZero)))), [accepted, pending, inMempool], emptyAHub)), // K2b and K2c (the resubmission comes from the wallet or the third party). - assert(realises(earlyPublished.append(Do(look(1, "early", found(Some(early), MEMPOOL_HEIGHT)))).append(Do(sub(2, early))) - .append(Do(look(3, "early", found(Some(early), MEMPOOL_HEIGHT)))), + assert(realises(earlyPublished.append(Do(look(1, "early", found(Some(early), AtZero)))).append(Do(sub(2, early))) + .append(Do(look(3, "early", found(Some(early), AtZero)))), [accepted, inMempool, accepted, pending], queuedEarly)), // K2d: the flush window. assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).append(Do(FlushDueHInput)) @@ -113,25 +112,25 @@ module realisedRunsTest { run realisedByzantineRunsTest = all { // G2, the hub: the queued bytes, to a third party. - assert(realises(earlyQueued.append(lieAbout(0, INotFound, FromIndexer(found(Some(early), MEMPOOL_HEIGHT)))), + assert(realises(earlyQueued.append(lieAbout(0, INotFound, FromIndexer(found(Some(early), AtZero)))), [accepted, inMempool], queuedEarly)), // G4, the hub: not found for a queued transaction. assert(realises(earlyQueued.append(lieAbout(1, INotFound, FromIndexer(INotFound))), [accepted, AWire(WNotFound)], queuedEarly)), // G4, the hub: a false height. - assert(realises(earlyPublished.append(lieAbout(1, found(Some(early), MEMPOOL_HEIGHT), FromIndexer(found(Some(early), 9)))), - [accepted, AWire(WFound({ body: Some(early), height: 9 }))], emptyAHub)), + assert(realises(earlyPublished.append(lieAbout(1, found(Some(early), AtZero), FromIndexer(found(Some(early), AtOther)))), + [accepted, AWire(WFound({ body: Some(early), height: AtOther }))], emptyAHub)), // G8, the hub: accepted and not queued. assert(realises(tips([1, 2]).append(Lie({ input: sub(0, early), out: AckOutput({ nonce: 0, kind: Admitted }) })), [accepted], emptyAHub)), // W16: a twin at a false height. - assert(realises(earlyQueued.append(lieAbout(1, INotFound, FromIndexer(found(Some(earlyTwin), 9)))), - [accepted, AWire(WFound({ body: Some(earlyTwin), height: 9 }))], queuedEarly)), + assert(realises(earlyQueued.append(lieAbout(1, INotFound, FromIndexer(found(Some(earlyTwin), AtOther)))), + [accepted, AWire(WFound({ body: Some(earlyTwin), height: AtOther }))], queuedEarly)), // G2, the indexer: the honest hub forwards the unpublished bytes. assert(realises(earlyQueued.concat(tips([3])).concat(does([FlushDueHInput, judged(early, Retryable), - look(0, "early", found(Some(early), MEMPOOL_HEIGHT))])), + look(0, "early", found(Some(early), AtZero))])), [accepted, inMempool], { queue: Set(), held: Set(early) })), // G4, the indexer: a forged pending for a transaction nobody queued. - assert(realises(tips([1, 2]).append(Do(look(1, "early", found(None, MEMPOOL_HEIGHT)))), [pending], emptyAHub)), + assert(realises(tips([1, 2]).append(Do(look(1, "early", found(None, AtZero)))), [pending], emptyAHub)), } } diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index 39002d26..f9b96ee9 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -8,8 +8,6 @@ /// fails, it also says what is in the wallet's log, the soup or the audit /// record that makes it fail. /// -/// The schedule in every configuration: the chain starts at height 1, a flush -/// is scheduled at heights 3, 6, 9 and 12, and the mining margin is 2 blocks. /// Shim nonces count up from 0, one per frame. module baselineScenarios { @@ -32,24 +30,19 @@ module baselineScenarios { /// W1, W2, W3. A migration from send to mined, with the wallet polling. run pendingThenMempoolThenMinedTest = init - .then(block) - // Height 2. The shim diverts and answers at once; the operator sees - // nothing. + // The shim diverts and answers at once; the operator sees nothing. .then(submitTo(0, early)) .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) .expect(s.hub.queue == Set(early) and s.operator == Set()) .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) - // Height 3 is a flush boundary. - .then(block) .then(flush([early], Accepted)) .expect(s.hub.queue == Set() and s.onChain("early") == InMempool) .then(lookUp(2, "early")) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(2) })) - .then(block) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtZero }), via: Some(2) })) .then(chainMineWith("early")) .then(lookUp(3, "early")) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 4 }), via: Some(3) })) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtMined }), via: Some(3) })) .expect(wPending and wTxInMempool and wTxMined) .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) .expect(ackImpliesQueued and wellFormed and statusNeverRegresses) @@ -66,19 +59,17 @@ module baselineScenarios { /// is queued, and is not given the bytes. run thirdPartyLearnsItIsQueuedTest = init - .then(block) .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: AtZero })))) .expect(wQueuedDisclosed) .expect(s.tpLearned() == Set() and queuedBytesConfidential) /// A lookup that gets no reply in time fails closed, and G4 holds of it. run lookupTimesOutTest = init - .then(block) .then(submitTo(0, early)) .then(ask("early")) .then(timeOutLookup(1)) @@ -111,7 +102,6 @@ module baselineScenarios { /// `realisedRunsTest`. run toldOkThenRefusedTest = init - .then(block) .then(sendToHub(tight)) .then(answerSubmitFrom(ShimAddr, 0, tight, WRefused(WExpiryTooTight))) .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) @@ -122,7 +112,6 @@ module baselineScenarios { /// K1b. The frame is never delivered. Nothing obliges the network to. run toldOkAndNeverDeliveredTest = init - .then(block) .then(sendToHub(early)) .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) .expect(s.net == Set(submitMail(0, early))) @@ -136,19 +125,17 @@ module baselineScenarios { /// K2a. Two polls, answered in order, delivered out of order. run repliesReorderedTest = init - .then(block) .then(submitTo(0, early)) .then(ask("early")) .then(deliverLookup(1, "early")) - .then(block) .then(flush([early], Accepted)) .then(ask("early")) .then(deliverLookup(2, "early")) - .then(deliverToShim(replyMail(2, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) - .then(deliverToShim(replyMail(1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .then(deliverToShim(replyMail(2, WFound({ body: Some(early), height: AtZero })))) + .then(deliverToShim(replyMail(1, WFound({ body: None, height: AtZero })))) .expect(s.wallet.log == [ Sent({ input: Clean(early), obs: SentOk }), - Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(2) }), + Got({ query: "early", obs: Tx({ payload: early, height: AtZero }), via: Some(2) }), Got({ query: "early", obs: Pending, via: Some(1) }), ]) // Each answer was true when it was given. @@ -158,16 +145,14 @@ module baselineScenarios { /// went with the flush, so they are admitted and pending once more. run walletResendsPublishedTest = init - .then(block) .then(submitTo(0, early)) - .then(block) .then(flush([early], Accepted)) .then(lookUp(1, "early")) .then(submitTo(2, early)) .expect(s.hub.queue == Set(early) and s.onChain("early") == InMempool) .then(lookUp(3, "early")) .expect(s.wallet.log.slice(1, 4) == [ - Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) }), + Got({ query: "early", obs: Tx({ payload: early, height: AtZero }), via: Some(1) }), Sent({ input: Clean(early), obs: SentOk }), Got({ query: "early", obs: Pending, via: Some(3) }), ]) @@ -177,9 +162,7 @@ module baselineScenarios { /// published, and submission is open to anyone. run thirdPartyResubmitsPublishedTest = init - .then(block) .then(submitTo(0, early)) - .then(block) .then(flush([early], Accepted)) .then(lookUp(1, "early")) .expect(s.tpPayloads().contains(early)) @@ -187,7 +170,7 @@ module baselineScenarios { .then(deliverSubmitFrom(ThirdPartyAddr, 0, early)) .then(lookUp(2, "early")) .expect(s.wallet.log.slice(1, 3) == [ - Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) }), + Got({ query: "early", obs: Tx({ payload: early, height: AtZero }), via: Some(1) }), Got({ query: "early", obs: Pending, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) @@ -196,10 +179,8 @@ module baselineScenarios { /// the batch yet. run flushWindowTest = init - .then(block) .then(submitTo(0, early)) .then(lookUp(1, "early")) - .then(block) .then(hubTake) .expect(s.hub.held == Set(early) and s.onChain("early") == Absent) .then(lookUp(2, "early")) @@ -213,10 +194,8 @@ module baselineScenarios { /// is nowhere. run rejectedAtFlushTest = init - .then(block) .then(submitTo(0, early)) .then(lookUp(1, "early")) - .then(block) .then(flush([early], Rejected)) .expect(s.hub.queue == Set() and s.hub.held == Set() and s.onChain("early") == Absent) .then(lookUp(2, "early")) @@ -243,16 +222,15 @@ module byzHubScenarios { /// shares, and nothing else: it serves both. run twinAtFalseHeightIsServedTest = init - .then(block) .then(submitTo(0, early)) .then(ask("early")) .then(hubReceiveWith( lookupMail(1, "early"), INotFound, - { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(earlyTwin), height: 9 })))) }, + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(earlyTwin), height: AtOther })))) }, )) - .then(deliverToShim(replyMail(1, WFound({ body: Some(earlyTwin), height: 9 })))) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: earlyTwin, height: 9 }), via: Some(1) })) - .expect(s.height() == 2 and s.onChain("early") == Absent) + .then(deliverToShim(replyMail(1, WFound({ body: Some(earlyTwin), height: AtOther })))) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: earlyTwin, height: AtOther }), via: Some(1) })) + .expect(s.onChain("early") == Absent) .expect(wTwinServed and wFalseHeightServed) .expect(txidAuthenticity and not(lookupValidityPerHub)) } diff --git a/zeronym/spec/protocol/tests/shimTest.qnt b/zeronym/spec/protocol/tests/shimTest.qnt index 086cbec1..4b6e9939 100644 --- a/zeronym/spec/protocol/tests/shimTest.qnt +++ b/zeronym/spec/protocol/tests/shimTest.qnt @@ -48,7 +48,7 @@ module shimTest { .union(0.to(2).map(nonce => ack(nonce, WAccepted))) .union(0.to(2).map(nonce => ack(nonce, WRefused(WQueueFull)))) .union(0.to(2).map(nonce => reply(nonce, WNotFound))) - .union(0.to(2).map(nonce => reply(nonce, WFound({ body: Some(pOrchard), height: 4 })))) + .union(0.to(2).map(nonce => reply(nonce, WFound({ body: Some(pOrchard), height: AtMined })))) .union(0.to(2).map(nonce => LookupTimeoutSInput(nonce))) .union(Set( FrameSInput(Submit({ nonce: 0, payload: pOrchard })), @@ -109,11 +109,11 @@ module shimTest { assert(outputOf(initialShim, GetTxSInput("orchard")) == LookupSentOutput(Lookup({ nonce: 0, txid: "orchard" }))), assert(busy.waiters == Map(1 -> "orchard")), // Each reply arm. - assert(outputOf(busy, reply(1, WFound({ body: None, height: MEMPOOL_HEIGHT }))) + assert(outputOf(busy, reply(1, WFound({ body: None, height: AtZero }))) == LookupDoneOutput({ query: "orchard", result: Pending })), - assert(outputOf(busy, reply(1, WFound({ body: Some(pOrchard), height: 4 }))) - == LookupDoneOutput({ query: "orchard", result: Tx({ payload: pOrchard, height: 4 }) })), - assert(outputOf(busy, reply(1, WFound({ body: Some(pPlain), height: 4 }))) + assert(outputOf(busy, reply(1, WFound({ body: Some(pOrchard), height: AtMined }))) + == LookupDoneOutput({ query: "orchard", result: Tx({ payload: pOrchard, height: AtMined }) })), + assert(outputOf(busy, reply(1, WFound({ body: Some(pPlain), height: AtMined }))) == LookupDoneOutput({ query: "orchard", result: NotFound })), assert(outputOf(busy, reply(1, WNotFound)) == LookupDoneOutput({ query: "orchard", result: NotFound })), assert(outputOf(busy, reply(1, WError)) == LookupDoneOutput({ query: "orchard", result: Unavailable })), diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index e9715675..630cb28e 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -26,32 +26,29 @@ module byzHubTrust { /// with the queued bytes. run hubServesQueuedBodyTest = init - .then(block) .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(hubReceiveWith( fromThirdParty(lookupMail(0, "early")), INotFound, - { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) }, + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: AtZero })))) }, )) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: AtZero })))) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) .expect(not(queuedBytesConfidential)) run hubServesQueuedBodyControlTest = init - .then(block) .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: AtZero })))) .expect(queuedBytesConfidential) /// G4 needs the hub. It answers not found for a transaction it has queued. run hubDeniesQueuedTest = init - .then(block) .then(submitTo(0, early)) .then(ask("early")) .then(hubReceiveWith( @@ -65,7 +62,6 @@ module byzHubTrust { run hubDeniesQueuedControlTest = init - .then(block) .then(submitTo(0, early)) .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) @@ -75,35 +71,30 @@ module byzHubTrust { /// at a height it made up. The txid is right, so the shim passes it on. run hubServesFalseHeightTest = init - .then(block) .then(submitTo(0, early)) - .then(block) .then(flush([early], Accepted)) .then(ask("early")) .then(hubReceiveWith( lookupMail(1, "early"), chainAnswer(s.indexer, "early"), - { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: 9 })))) }, + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: AtOther })))) }, )) - .then(deliverToShim(replyMail(1, WFound({ body: Some(early), height: 9 })))) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: 9 }), via: Some(1) })) + .then(deliverToShim(replyMail(1, WFound({ body: Some(early), height: AtOther })))) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtOther }), via: Some(1) })) .expect(s.onChain("early") == InMempool) - .expect(audit.windows.get(1) == Set(Tx({ payload: early, height: MEMPOOL_HEIGHT }))) + .expect(audit.windows.get(1) == Set(Tx({ payload: early, height: AtZero }))) .expect(not(lookupValidityPerHub) and txidAuthenticity) run hubServesFalseHeightControlTest = init - .then(block) .then(submitTo(0, early)) - .then(block) .then(flush([early], Accepted)) .then(lookUp(1, "early")) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: MEMPOOL_HEIGHT }), via: Some(1) })) + .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtZero }), via: Some(1) })) .expect(lookupValidityPerHub) /// G8 needs the hub. It acks a submission as accepted and does not queue it. run hubAcksWithoutAdmittingTest = init - .then(block) .then(sendToHub(early)) .then(hubReceiveWith(submitMail(0, early), INotFound, { hub: s.hub, reply: AAck(WAccepted) })) .expect(s.acks(ShimAddr) == Set((0, WAccepted))) @@ -112,7 +103,6 @@ module byzHubTrust { run hubAcksWithoutAdmittingControlTest = init - .then(block) .then(sendToHub(early)) .then(deliverSubmit(0, early)) .expect(s.acks(ShimAddr) == Set((0, WAccepted)) and s.hub.queue == Set(early)) @@ -122,14 +112,13 @@ module byzHubTrust { /// txids and refuses it. run wrongTransactionIsRefusedTest = init - .then(block) .then(sendToHub(early)) .then(ask("early")) .then(hubReceiveWith( lookupMail(1, "early"), INotFound, - { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(tight), height: 3 })))) }, + { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(tight), height: AtMined })))) }, )) - .then(deliverToShim(replyMail(1, WFound({ body: Some(tight), height: 3 })))) + .then(deliverToShim(replyMail(1, WFound({ body: Some(tight), height: AtMined })))) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) .expect(txidAuthenticity) } @@ -156,26 +145,22 @@ module byzIndexerTrust { /// hub forwards to whoever asked. run indexerServesUnpublishedBodyTest = init - .then(block) .then(submitTo(0, early)) - .then(block) .then(hubTake) .then(judge(early, Retryable)) .expect(s.indexer.offered == Set(early) and s.onChain("early") == Absent) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(deliverLookupFrom( - ThirdPartyAddr, 0, "early", IFound({ body: Some(early), height: MEMPOOL_HEIGHT }), + ThirdPartyAddr, 0, "early", IFound({ body: Some(early), height: AtZero }), )) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: MEMPOOL_HEIGHT })))) + .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: AtZero })))) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) .expect(not(queuedBytesConfidential)) run indexerServesUnpublishedBodyControlTest = init - .then(block) .then(submitTo(0, early)) - .then(block) .then(hubTake) .then(judge(early, Retryable)) .then(thirdPartyLearnsTxidWith("early")) @@ -189,18 +174,16 @@ module byzIndexerTrust { /// wire it is the hub's own "queued here": the wallet sees pending. run indexerForgesPendingTest = init - .then(block) .then(sends(Clean(early), true)) .then(ask("early")) - .then(deliverLookupFrom(ShimAddr, 1, "early", IFound({ body: None, height: MEMPOOL_HEIGHT }))) - .then(deliverToShim(replyMail(1, WFound({ body: None, height: MEMPOOL_HEIGHT })))) + .then(deliverLookupFrom(ShimAddr, 1, "early", IFound({ body: None, height: AtZero }))) + .then(deliverToShim(replyMail(1, WFound({ body: None, height: AtZero })))) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) .expect(audit.everQueued == Set() and audit.windows.get(1) == Set(NotFound)) .expect(not(lookupValidityPerHub)) run indexerForgesPendingControlTest = init - .then(block) .then(sends(Clean(early), true)) .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) diff --git a/zeronym/spec/protocol/tests/wireTest.qnt b/zeronym/spec/protocol/tests/wireTest.qnt index 37b96ff2..5b6ac3a3 100644 --- a/zeronym/spec/protocol/tests/wireTest.qnt +++ b/zeronym/spec/protocol/tests/wireTest.qnt @@ -13,7 +13,7 @@ module wireTest { pure val pJunk = { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } pure val PAYLOADS = Set(pA, pATwin, pB, pJunk) - pure val HEIGHTS = Set(MEMPOOL_HEIGHT, 1, 5) + pure val HEIGHTS = WIRE_HEIGHTS pure val QUERIES = Set("ta", "tb", "unknown") pure val BODIES = Set(None).union(PAYLOADS.map(payload => Some(payload))) @@ -56,15 +56,15 @@ module wireTest { /// the wallet see pending (`indexerForgesPendingTest` is that run). No two /// honest outcomes collide. run sentinelCollisionTest = all { - assert(render(QueueHit) == render(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT })))), + assert(render(QueueHit) == render(FromIndexer(IFound({ body: None, height: AtZero })))), assert(QUERIES.forall(query => and { interpretReply(render(QueueHit), query) == Pending, - interpretReply(render(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT }))), query) == Pending, + interpretReply(render(FromIndexer(IFound({ body: None, height: AtZero }))), query) == Pending, })), assert(QUERIES.forall(query => meaning(QueueHit, query) - != meaning(FromIndexer(IFound({ body: None, height: MEMPOOL_HEIGHT })), query))), + != meaning(FromIndexer(IFound({ body: None, height: AtZero })), query))), assert(tuples(HONEST_OUTCOMES, HONEST_OUTCOMES).forall(((left, right)) => left != right implies render(left) != render(right))), } @@ -79,10 +79,10 @@ module wireTest { })), // Found with no body at a mined height is not a transaction and not the // pending sentinel. - assert(interpretReply(WFound({ body: None, height: 5 }), "ta") == NotFound), + assert(interpretReply(WFound({ body: None, height: AtMined }), "ta") == NotFound), // A twin of the transaction asked for is served. - assert(interpretReply(WFound({ body: Some(pATwin), height: 5 }), "ta") - == Tx({ payload: pATwin, height: 5 })), + assert(interpretReply(WFound({ body: Some(pATwin), height: AtMined }), "ta") + == Tx({ payload: pATwin, height: AtMined })), // The height is passed through as given. assert(HEIGHTS.forall(height => interpretReply(WFound({ body: Some(pA), height: height }), "ta") diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index 2e3a2787..93179e1d 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -82,24 +82,28 @@ module types { // ------------------------------------------------------------------------ /// Where a transaction stands on the chain. It only ever moves forward. - type Inclusion = Absent | InMempool | MinedAt(Height) + type Inclusion = Absent | InMempool | Mined - /// The height a lookup answer carries for a transaction still in the mempool. - pure val MEMPOOL_HEIGHT: Height = 0 + /// The height a lookup answer carries, relative to the transaction it names: + /// 0, meaning the mempool; the height it was mined at; or any other height. + /// No answer is compared against the chain height except through this. + type WireHeight = AtZero | AtMined | AtOther + + pure val WIRE_HEIGHTS = Set(AtZero, AtMined, AtOther) /// The height a lookup answer carries for `inclusion`. - pure def answerHeight(inclusion: Inclusion): Height = + pure def answerHeight(inclusion: Inclusion): WireHeight = match inclusion { - | MinedAt(height) => height - | InMempool => MEMPOOL_HEIGHT - | Absent => MEMPOOL_HEIGHT + | Mined => AtMined + | InMempool => AtZero + | Absent => AtZero } /// An indexer's answer to a transaction lookup. An honest indexer always /// returns a body with `IFound`; the type admits a missing one because the /// hub forwards whatever it is given. type IndexerAnswer = - | IFound({ body: Option[Payload], height: Height }) + | IFound({ body: Option[Payload], height: WireHeight }) | INotFound | IUnavailable @@ -168,7 +172,7 @@ module types { /// The answer to a `GetTransaction`. type LookupObs = | Pending // found, height 0, no body - | Tx({ payload: Payload, height: Height }) // the transaction, as served + | Tx({ payload: Payload, height: WireHeight }) // the transaction, as served | NotFound | Unavailable // failed closed @@ -186,14 +190,13 @@ module types { /// one output the step produced. type Result[s, o] = { state: s, out: o } - /// One configuration of the protocol: the transactions in play, the hub - /// schedule, the bounds of the model, and the trust and timing assumptions. - /// Field by field it is the list of constants in `protocol.qnt`. + /// One configuration of the protocol: the transactions in play, the bound + /// of the model, and the roles. Field by field it is the list of constants + /// in `protocol.qnt`. type Config = { payloads: Set[Payload], twins: Set[Payload], tpPayloads: Set[Payload], - maxHeight: Height, maxRequests: int, roles: Roles, } diff --git a/zeronym/spec/protocol/wire.qnt b/zeronym/spec/protocol/wire.qnt index a62a1c38..1cfa1d13 100644 --- a/zeronym/spec/protocol/wire.qnt +++ b/zeronym/spec/protocol/wire.qnt @@ -19,7 +19,7 @@ module wire { /// A lookup reply's disposition. Only `WFound` has room for a height and a /// body; a `not_found` or an `error` that carries either does not decode. type WireReply = - | WFound({ body: Option[Payload], height: Height }) + | WFound({ body: Option[Payload], height: WireHeight }) | WNotFound | WError @@ -61,7 +61,7 @@ module wire { /// bytes to whoever asked. An indexer answer is forwarded as it came. pure def render(outcome: HubOutcome): WireReply = match outcome { - | QueueHit => WFound({ body: None, height: MEMPOOL_HEIGHT }) + | QueueHit => WFound({ body: None, height: AtZero }) | FromIndexer(answer) => match answer { | IFound(found) => WFound(found) @@ -111,7 +111,7 @@ module wire { match reply { | WFound(found) => match found.body { - | None => if (found.height == MEMPOOL_HEIGHT) Pending else NotFound + | None => if (found.height == AtZero) Pending else NotFound | Some(payload) => if (payload.txid == Some(query)) Tx({ payload: payload, height: found.height }) From 1d1d24b00991ac99482bcaf062486df4bcc5549e Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 12:46:15 +0400 Subject: [PATCH 51/80] test(zeronym): drop the unread step label from the protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 4 +-- zeronym/spec/protocol/protocol.qnt | 40 +++++++++++------------------- zeronym/spec/protocol/state.qnt | 20 --------------- 3 files changed, 16 insertions(+), 48 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 95138c19..564ac74e 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -418,7 +418,7 @@ pure. | `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | | `abstractHub.qnt` | `abstractHub` | The hub as the protocol sees it: `AHub`, its honest and Byzantine answers, its internal moves | | `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | -| `state.qnt` | `state` | `System`, `Label`, `Audit`; where each output goes; the derived views | +| `state.qnt` | `state` | `System`, `Audit`; where each output goes; the derived views | | `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | | `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, the run vocabulary | | `instances.qnt` | `configs`, then one module per configuration | The three configurations: `baseline`, `byzHub`, `byzIndexer` | @@ -871,8 +871,6 @@ Not built. The specification is shaped so it can be: - Every branch of `step` is a named action and every choice is a named `nondet` inside it, so `--mbt` traces carry `mbt::actionTaken` and `mbt::nondetPicks`. -- `lastAction` records the step and the input it gave a component, in the - state, so scripted runs carry the same information. - Each step gives one input to one component function and applies one output; the pairs map onto the seams in the table above. - All protocol state is in `s`. `audit` is a monitor a harness ignores. diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 2b94ff11..7aa4ae0a 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -103,14 +103,11 @@ module protocol { var s: System /// The observer's record of the run. No step reads it. var audit: Audit - /// The step that produced the current state. - var lastAction: Label /// The only writer of the variables. - action commit(post: System, label: Label): bool = all { + action commit(post: System): bool = all { s' = post, audit' = advance(audit, s, post), - lastAction' = label, } pure val INITIAL = initialSystem(CONFIG) @@ -118,7 +115,6 @@ module protocol { action init = all { s' = INITIAL, audit' = initialAudit, - lastAction' = Init, } // ------------------------------------------------------------------------ @@ -168,7 +164,7 @@ module protocol { val result = shim(s.shim, SendTxSInput({ input: input, handedOver: handedOver })) all { not(isShimError(result.out)), - commit(s.countSend().shimStepped(result, None), WalletSend({ input: input, handedOver: handedOver })), + commit(s.countSend().shimStepped(result, None)), }, } @@ -193,7 +189,7 @@ module protocol { val result = shim(s.shim, GetTxSInput(query)) all { not(isShimError(result.out)), - commit(s.countGet().shimStepped(result, None), WalletGet(query)), + commit(s.countGet().shimStepped(result, None)), }, } @@ -225,7 +221,7 @@ module protocol { val result = shim(s.shim, FrameSInput(mail.msg)) all { not(isShimError(result.out)), - commit(s.shimStepped(result, nonceOf(FrameSInput(mail.msg))), ShimReceive(mail)), + commit(s.shimStepped(result, nonceOf(FrameSInput(mail.msg)))), }, } @@ -243,7 +239,7 @@ module protocol { val result = shim(s.shim, LookupTimeoutSInput(nonce)) all { not(isShimError(result.out)), - commit(s.shimStepped(result, None), ShimLookupTimeout(nonce)), + commit(s.shimStepped(result, None)), }, } @@ -275,7 +271,7 @@ module protocol { hubDeliverable.contains(mail), lookupAnswers(s.indexer, mail.msg).contains(answer), hubReceipts(mail, answer).contains(result), - commit(s.hubReplied(mail, result), HubReceive(mail)), + commit(s.hubReplied(mail, result)), } action hubReceive = all { @@ -292,7 +288,7 @@ module protocol { /// the schedule, so these steps are the same whatever its role. action hubTake = all { s.hub.queue != Set(), - commit({ ...s, hub: s.hub.take() }, HubTake), + commit({ ...s, hub: s.hub.take() }), } /// The verdict an indexer output carries. Read only where it carries one. @@ -311,7 +307,7 @@ module protocol { indexerResults(s.indexer, BroadcastIInput(payload)).contains(result), result.out == VerdictOutput(verdictIn(result.out)), val hub = if (verdictIn(result.out) == Retryable) s.hub else s.hub.settle(payload) - commit({ ...s, indexer: result.state, hub: hub }, IndexerVerdict(payload)), + commit({ ...s, indexer: result.state, hub: hub }), } action indexerVerdict = all { @@ -328,7 +324,7 @@ module protocol { action hubReturnWith(kept: Set[Payload]): bool = all { s.hub.held != Set(), kept.subseteq(s.hub.held), - commit({ ...s, hub: s.hub.giveBack(kept) }, HubReturn(kept)), + commit({ ...s, hub: s.hub.giveBack(kept) }), } action hubReturn = all { @@ -343,7 +339,7 @@ module protocol { /// flush. action hubLose = all { s.hub != emptyAHub, - commit({ ...s, hub: emptyAHub }, HubLose), + commit({ ...s, hub: emptyAHub }), } // ------------------------------------------------------------------------ @@ -357,7 +353,7 @@ module protocol { /// A mempool transaction is included in the current block. action chainMineWith(txid: TxId): bool = all { mempool.contains(txid), - commit({ ...s, indexer: indexerApply(s.indexer, MineIInput(txid), NoIndexerOutput) }, ChainMine(txid)), + commit({ ...s, indexer: indexerApply(s.indexer, MineIInput(txid), NoIndexerOutput) }), } action chainMine = all { @@ -381,10 +377,7 @@ module protocol { /// queries. action thirdPartyLearnsTxidWith(txid: TxId): bool = all { unknownTxids.contains(txid), - commit( - { ...s, thirdParty: { ...s.thirdParty, txids: s.thirdParty.txids.union(Set(txid)) } }, - ThirdPartyLearnsTxid(txid), - ), + commit({ ...s, thirdParty: { ...s.thirdParty, txids: s.thirdParty.txids.union(Set(txid)) } }), } action thirdPartyLearnsTxid = all { @@ -400,7 +393,7 @@ module protocol { action thirdPartyLookupWith(txid: TxId): bool = all { s.thirdParty.requests < MAX_REQUESTS, s.tpTxids().contains(txid), - commit(s.thirdPartySent(Lookup({ nonce: s.thirdParty.nextNonce, txid: txid })), ThirdPartyLookup(txid)), + commit(s.thirdPartySent(Lookup({ nonce: s.thirdParty.nextNonce, txid: txid }))), } action thirdPartyLookup = all { @@ -417,10 +410,7 @@ module protocol { action thirdPartySubmitWith(payload: Payload): bool = all { s.thirdParty.requests < MAX_REQUESTS, s.tpPayloads().contains(payload), - commit( - s.thirdPartySent(Submit({ nonce: s.thirdParty.nextNonce, payload: payload })), - ThirdPartySubmit(payload), - ), + commit(s.thirdPartySent(Submit({ nonce: s.thirdParty.nextNonce, payload: payload }))), } action thirdPartySubmit = all { @@ -439,7 +429,7 @@ module protocol { /// A Byzantine component reveals a payload it has seen. action byzDiscloseWith(payload: Payload): bool = all { undisclosed.contains(payload), - commit({ ...s, disclosed: s.disclosed.union(Set(payload)) }, ByzDisclose(payload)), + commit({ ...s, disclosed: s.disclosed.union(Set(payload)) }), } action byzDisclose = all { diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt index 28bba21f..4e3bb137 100644 --- a/zeronym/spec/protocol/state.qnt +++ b/zeronym/spec/protocol/state.qnt @@ -61,26 +61,6 @@ module state { disclosed: Set(), } - /// The step that produced a state, with the choices that identify it. It - /// names the input a component was given, never the transition the - /// component then took. - type Label = - | Init - | WalletSend({ input: SendInput, handedOver: bool }) - | WalletGet(TxId) - | ShimReceive(Mail) - | ShimLookupTimeout(Nonce) - | HubReceive(Mail) - | HubTake - | IndexerVerdict(Payload) - | HubReturn(Set[Payload]) - | HubLose - | ChainMine(TxId) - | ThirdPartyLearnsTxid(TxId) - | ThirdPartyLookup(TxId) - | ThirdPartySubmit(Payload) - | ByzDisclose(Payload) - /// What an observer of the run has recorded. It is not protocol state: no /// component reads it, and it is derived at every step from the states /// before and after, never from what a component reports about itself. From 4dfe8db65d189f3cc6e47e917a9fed252b613abd Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 12:50:39 +0400 Subject: [PATCH 52/80] test(zeronym): give the protocol spec named inits and fold it into one module Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 130 +--- zeronym/spec/protocol/check.sh | 40 +- zeronym/spec/protocol/instances.qnt | 84 --- zeronym/spec/protocol/properties.qnt | 319 -------- zeronym/spec/protocol/protocol.qnt | 704 +++++++++++++++++- zeronym/spec/protocol/state.qnt | 276 ------- .../spec/protocol/tests/realisedRunsTest.qnt | 7 +- zeronym/spec/protocol/tests/scenariosTest.qnt | 58 +- zeronym/spec/protocol/tests/trustTest.qnt | 50 +- 9 files changed, 763 insertions(+), 905 deletions(-) delete mode 100644 zeronym/spec/protocol/instances.qnt delete mode 100644 zeronym/spec/protocol/properties.qnt delete mode 100644 zeronym/spec/protocol/state.qnt diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 564ac74e..842604ac 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -16,9 +16,8 @@ modify. random traces per run (a few rows more), one seed. It is not a proof and it is not exhaustive to any depth. A property that "holds" is one no sampled trace violated. -**`quint verify` has not been run**, on Apalache or on TLC, by anyone, on any -part of this specification. The commands are given under -["Bounded model checking (not run)"](#bounded-model-checking-not-run). +**`quint verify` has not been run** on any part of this specification. Tier 4 +runs TLC on the hub specification through `tlc.sh`, not through `quint verify`. Statements that do not rest on sampling are the ones backed by `quint test`: the functional properties F1-F14 and the two-state properties A2-A3, which are @@ -39,7 +38,7 @@ either the tier fails. | Tier | What | Command | Expectation | |---|---|---|---| | 1 | typecheck | `quint typecheck` on every file | ok | -| 2 | tests | `quint test` on the spells, the four functional test files, each scenario and trust module, each configuration | all pass | +| 2 | tests | `quint test` on the spells and every test file | all pass | | 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` ("fails" rows: 40 to 80 steps, a few with more traces) | "holds" rows hold; "fails" rows are violated | | 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | | 4 | hub specification | `tlc.sh hubMachine.qnt hubMachine `, one row each | "holds" rows hold over every reachable state; "violated" rows are violated, by a counterexample no longer than the recorded one | @@ -95,7 +94,7 @@ do. Tier 3b re-checks each configuration's guarantees on those deeper traces. | S32 | Two hub clocks. Admission and requeue use the observed height. The flush epoch uses the cadence height, which equals the observed height until no forward move has been seen for `TIP_STALE_AFTER` (15 min, 12 blocks at the nominal 75 s) and then free-runs at the nominal rate. The code comment claims the free-running clock runs ahead of the true height, "the safe direction"; nothing enforces it. Only the cadence loop (and startup) calls `observe`, and it does so before, never during, a flush | `zeronym/hub/src/batcher.rs:59-71`, `:227-247`, `:307-325`, `:414-422`, `zeronym/hub/src/main.rs:62` | | S25 | The operator can recover a diverted transaction's txid from transparent-pool queries, so a txid can be known to an outsider before publication | `zeronym/README.md:34` | -Two comments in the implementation are quoted in `properties.qnt` next to the +Two comments in the implementation are quoted in `protocol.qnt` next to the definitions they justify. - The accepted disclosure (W8), `zeronym/hub/src/server.rs`, in `Hub::lookup`: @@ -405,8 +404,8 @@ nobody holds, and G4 fails. ## Layout -Only `protocol.qnt` declares a constant or a variable. Every other module is -pure. +Only `protocol.qnt` and `hubMachine.qnt` declare variables, and no module +declares a constant. Every other module is pure. | File | Module | Owns | |---|---|---| @@ -418,14 +417,11 @@ pure. | `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | | `abstractHub.qnt` | `abstractHub` | The hub as the protocol sees it: `AHub`, its honest and Byzantine answers, its internal moves | | `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | -| `state.qnt` | `state` | `System`, `Audit`; where each output goes; the derived views | -| `properties.qnt` | `properties` | `truth` and the audit monitor `advance`; guarantees, gaps, witnesses | -| `protocol.qnt` | `protocol` | The constant, the assumptions, the variables, `commit`, the steps, the property aliases, the run vocabulary | -| `instances.qnt` | `configs`, then one module per configuration | The three configurations: `baseline`, `byzHub`, `byzIndexer` | +| `protocol.qnt` | `protocol` | The transactions and the three configurations; `System`, `Audit`, where each output goes and the derived views; `truth`, the audit monitor `advance`, the guarantees, gaps and witnesses; the variables, `commit`, the named inits, the steps, the property aliases, the run vocabulary | | `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14; A2-A3 and the abstraction lemma in `hubTest.qnt` | | `tests/realisedRunsTest.qnt` | `realisedRunsTest` | The hub inputs of each pinned run, replayed through the real hub | -| `tests/scenariosTest.qnt` | one module per configuration used | Witnesses and pinned gap causes | -| `tests/trustTest.qnt` | one module per Byzantine configuration | One run and one control per "required" cell | +| `tests/scenariosTest.qnt` | `scenariosTest` | Witnesses and pinned gap causes; `liveInitsTest` | +| `tests/trustTest.qnt` | `trustTest` | One run and one control per "required" cell | ```mermaid flowchart BT @@ -434,17 +430,16 @@ flowchart BT wire --> types indexer --> types hub --> types + abstractHub --> wire shim --> wire - state --> soup - state --> wire - state --> indexer - state --> hub - state --> shim - properties --> state - protocol --> properties - instances --> protocol - tests --> instances - tests --> properties + hubMachine --> hub + hubMachine --> indexer + protocol --> soup + protocol --> indexer + protocol --> abstractHub + protocol --> shim + tests --> protocol + tests --> hubMachine ``` ### Components as functions @@ -490,18 +485,19 @@ abstract indexer per hub was a decision of the design. ### Configurations -One constant, `CONFIG`, holds a configuration; `protocol.qnt` names its fields -(`PAYLOADS`, `ROLES`, ...). +One variable, `cfg`, holds a configuration. It is written by `initWith` and +kept by every step; `protocol.qnt` names its fields (`PAYLOADS`, `ROLES`, ...). +Each configuration has a named init whose guard is `payloadsWellFormed`. -| Module | Roles (hub / indexer) | -|---|---| -| `baseline` | H / H | -| `byzHub` | **B** / H | -| `byzIndexer` | H / **B** | +| Configuration | Init | Roles (hub / indexer) | +|---|---|---| +| `baseline` | `initBaseline` | H / H | +| `byzHub` | `initByzHub` | **B** / H | +| `byzIndexer` | `initByzIndexer` | H / **B** | At most 3 sends and 3 lookups by the wallet and 3 requests by the third -party. Each configuration's `assumptionsTest` asserts -`payloadsWellFormed`; the simulator does not enforce `assume`. The hub +party. `liveInitsTest` starts from each init in turn, so a guard that is false +fails tier 2. The hub specification's configurations, its scaled-down schedule and the relations it keeps with the shipped one are in `hubMachine.qnt`. @@ -1011,73 +1007,3 @@ only as a whole row (17 s, beside another run). Named inits are kept. Not measured: Apalache at bounded depths on this machine (one attempt failed on its configuration and was not repeated); the route with an empty `~/.quint` and Quint fetched by `npx`. - -## Bounded model checking (not run) - -**None of the commands in this section has been executed.** Both backends need -Java 21 (Quint 0.33.0's default Apalache is 0.62.1), which the machine this was -written on does not have. Whether Apalache or TLC accept the specification as -written is unknown. - -Each "holds" cell, with Apalache: - -```sh -quint verify --main=baseline --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=queuedBytesConfidential --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=lookupValidityPerHub --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=offeredBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=conformingFirstOfferBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant=wellFormed --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzHub --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzHub --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzIndexer --invariant=operatorBlind --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzIndexer --invariant=txidAuthenticity --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzIndexer --invariant=ackImpliesQueued --max-steps=12 zeronym/spec/protocol/instances.qnt -``` - -The same for G6c, and the configurations whose point is a violation (each -should report one; `flakyTipNoSlack` and `flakyTipSlowFlight` satisfy every -`assume`, so a checker that honours `assume` still has states to explore): - -```sh -quint verify --main=baseline --invariant=conformingFirstOfferJudgedBeforeExpiry --max-steps=12 zeronym/spec/protocol/instances.qnt -``` - -Several of the scripted counterexamples are longer than 12 steps, so -`--max-steps=12` may not reach these violations; raise it as needed. - -Each witness, as a reachability check that should report a violation: - -```sh -quint verify --main=baseline --invariant='not(wPending)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wTxInMempool)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wTxMined)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wRefusedTipStale)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wRefusedDraining)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wRefusedExpiryTooTight)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wRequeued)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wDroppedExpired)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wDroppedExhausted)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wQueuedDisclosed)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wUnparseableMissed)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wThirdPartyPayloadQueued)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wToldRefusedEverywhere)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=baseline --invariant='not(wToldNeverDelivered)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzHub --invariant='not(wTwinServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt -quint verify --main=byzHub --invariant='not(wFalseHeightServed)' --max-steps=12 zeronym/spec/protocol/instances.qnt -``` -The commands use one-hub configurations: nested maps of records are supported -by Apalache but slow. - -What the specification does to give those runs a chance, from Apalache's -documentation and not from running it: - -| Construct | Consequence | -|---|---| -| `run`, `.then`, `.expect`, `--witnesses`, `--mbt` are simulator-only | The commands above cover invariants only; reachability is `not(w)` expected to be violated | -| `oneOf` on an empty set | Every pick is guarded | -| Unbounded integers, `powerset`, `allLists` | Not used; every universe is a finite set bounded by constants, the Byzantine sets included | -| A list in the state (the wallet's log) | Bounded by `--max-steps` | -| `assume` | Behaviour under verify unknown; `assumptionsTest` is the check that counts | diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 4e3a1727..34bcfd4f 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -8,8 +8,8 @@ # # Tiers: # 1 typecheck every file. -# 2 quint test: the functional layer, the scripted runs, and each -# configuration's assumptions. +# 2 quint test: the functional layer and the scripted runs, among them +# `liveInitsTest`, which starts from every named init. # 3 quint run: invariants. "holds" rows are the guarantees, on the # configurations where they are claimed. "fails" rows are the known gaps # and the guarantees under the Byzantine component they depend on; such a @@ -71,11 +71,8 @@ finish() { } SPELLS="spells/basicSpells.qnt spells/soup.qnt" -MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt state.qnt properties.qnt protocol.qnt instances.qnt" -FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt tests/realisedRunsTest.qnt" -INSTANCES="baseline byzHub byzIndexer" -SCENARIOS="baselineScenarios byzHubScenarios" -TRUST="byzHubTrust byzIndexerTrust" +MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt protocol.qnt" +FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt tests/realisedRunsTest.qnt tests/scenariosTest.qnt tests/trustTest.qnt" fail() { echo "FAIL $1" @@ -98,13 +95,19 @@ run_tests() { fi } -# simulate MAIN STEP MAX_STEPS ARGS...: one simulation of $samples traces; the -# output is left in $out. +# simulate CONFIG STEP MAX_STEPS ARGS...: one simulation of $samples traces +# from CONFIG's named init; the output is left in $out. samples=$SAMPLES simulate() { main=$1 step=$2 steps=$3 shift 3 - out=$($QUINT run instances.qnt --backend="$BACKEND" --main="$main" --step="$step" \ + case $main in + baseline) init=initBaseline ;; + byzHub) init=initByzHub ;; + byzIndexer) init=initByzIndexer ;; + *) init=none ;; + esac + out=$($QUINT run protocol.qnt --backend="$BACKEND" --main=protocol --init="$init" --step="$step" \ --max-samples="$samples" --max-steps="$steps" --seed="$SEED" "$@" 2>&1) } @@ -118,7 +121,7 @@ verdict() { esac } -# holds MAIN INVARIANT...: all of them, together, under `step`. +# holds CONFIG INVARIANT...: all of them, together, under `step`. holds() { main=$1 shift @@ -137,7 +140,7 @@ holds() { esac } -# fails MAIN STEP MAX_STEPS INVARIANT [TRACES]: violated in some trace of STEP. +# fails CONFIG STEP MAX_STEPS INVARIANT [TRACES]: violated in some trace of STEP. fails() { samples=${5:-$SAMPLES} simulate "$1" "$2" "$3" --invariant="$4" @@ -168,7 +171,7 @@ before_dashes() { done } -# reaches MAIN STEP MAX_STEPS WITNESS... -- INVARIANT...: every witness is +# reaches CONFIG STEP MAX_STEPS WITNESS... -- INVARIANT...: every witness is # reached in at least one trace of STEP, and no invariant is violated on the # way. reaches() { @@ -251,7 +254,7 @@ typecheck() { } echo "---- 1 typecheck" -for file in $SPELLS $MODULES $FUNCTIONAL tests/scenariosTest.qnt tests/trustTest.qnt; do +for file in $SPELLS $MODULES $FUNCTIONAL; do job typecheck "$file" done finish @@ -263,15 +266,6 @@ echo "---- 2 tests" for file in $SPELLS $FUNCTIONAL; do job run_tests "$file" done -for module in $SCENARIOS; do - job run_tests tests/scenariosTest.qnt "$module" -done -for module in $TRUST; do - job run_tests tests/trustTest.qnt "$module" -done -for module in $INSTANCES; do - job run_tests instances.qnt "$module" -done finish echo "---- 3 invariants ($SAMPLES traces, seed $SEED)" diff --git a/zeronym/spec/protocol/instances.qnt b/zeronym/spec/protocol/instances.qnt deleted file mode 100644 index 6fa507b9..00000000 --- a/zeronym/spec/protocol/instances.qnt +++ /dev/null @@ -1,84 +0,0 @@ -// -*- mode: Bluespec; -*- - -/// The configurations the protocol is checked in. -/// -/// `configs` names each configuration as a value. Below it there is one module -/// per configuration, which is what `quint run --main=` simulates. A -/// module that scripts runs against a configuration instantiates `protocol` -/// with the same value. - -module configs { - import basicSpells.* from "./spells/basicSpells" - import types.* from "./types" - - // ------------------------------------------------------------------------ - // Transactions - // ------------------------------------------------------------------------ - // - // The expiries are set against the hub specification's schedule; the - // protocol does not read them. - - pure def orchard(id: str, created: Height, expiry: Height): Payload = - { id: id, txid: Some(id), created: created, expiry: Some(expiry), class: OrchardTouching, oversize: false } - - /// Two migrations from supported wallets, built at heights 2 and 4. - pure val early = orchard("early", 2, 9) - pure val late = orchard("late", 4, 11) - /// A migration whose wallet set its expiry tighter than the supported floor. - pure val tight = orchard("tight", 2, 5) - /// Bytes the shim cannot parse and neither can a hub: no txid, no expiry. - pure val junk: Payload = - { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } - /// A transaction that is not a migration. - pure val plain: Payload = - { id: "plain", txid: Some("plain"), created: 1, expiry: Some(9), class: PassThrough, oversize: false } - /// Other bytes with the txid of `early`. - pure val earlyTwin = { ...early, id: "early-twin" } - /// The third party's own payload. - pure val garbage: Payload = - { id: "garbage", txid: Some("garbage"), created: 1, expiry: None, class: OrchardTouching, oversize: false } - - // ------------------------------------------------------------------------ - // Configurations - // ------------------------------------------------------------------------ - - pure val allHonest: Roles = { hub: Honest, indexer: Honest } - - /// One hub, the mixnet transport, every component honest. - pure val baseline: Config = { - payloads: Set(early, late, tight, junk, plain), - twins: Set(earlyTwin), - tpPayloads: Set(garbage), - maxRequests: 3, - roles: allHonest, - } - - // One Byzantine component at a time. - pure val byzHub = { ...baseline, roles: { ...allHonest, hub: Byzantine } } - pure val byzIndexer = { ...baseline, roles: { ...allHonest, indexer: Byzantine } } - -} - -module baseline { - import types.* from "./types" - import configs.* - import protocol(CONFIG = baseline).* from "./protocol" - - run assumptionsTest = assert(payloadsWellFormed) -} - -module byzHub { - import types.* from "./types" - import configs.* - import protocol(CONFIG = byzHub).* from "./protocol" - - run assumptionsTest = assert(payloadsWellFormed) -} - -module byzIndexer { - import types.* from "./types" - import configs.* - import protocol(CONFIG = byzIndexer).* from "./protocol" - - run assumptionsTest = assert(payloadsWellFormed) -} diff --git a/zeronym/spec/protocol/properties.qnt b/zeronym/spec/protocol/properties.qnt deleted file mode 100644 index ac45d798..00000000 --- a/zeronym/spec/protocol/properties.qnt +++ /dev/null @@ -1,319 +0,0 @@ -// -*- mode: Bluespec; -*- - -/// What a wallet can rely on, what it cannot, and what must be reachable. -/// -/// Every definition is a pure predicate over the system and the audit record. -/// There are three classes, kept apart: -/// -/// - guarantees: invariants claimed under stated trust and timing assumptions; -/// - known gaps: invariants a wallet might hope for that do not hold even when -/// every component is honest, each with the reason; -/// - witnesses: states that must be reachable, so that the guarantees are not -/// vacuous and the behaviours the gaps describe are shown to happen. -/// -/// Two rules keep the guarantees honest. A predicate reads the system and the -/// audit record, and the audit record is derived from consecutive states by -/// `advance` below, never from what a component says about itself. And a -/// guarantee is something a change to an honest component can break. -module properties { - import basicSpells.* from "./spells/basicSpells" - import soup.* from "./spells/soup" - import types.* from "./types" - import wire.* from "./wire" - import indexer.* from "./indexer" - import abstractHub.* from "./abstractHub" - import shim.* from "./shim" - import state.* from "./state" - - // ------------------------------------------------------------------------ - // The audit record - // ------------------------------------------------------------------------ - - /// The true answer to a lookup for `query` at the hub, read off its - /// queue and the chain. The queue comes first, as it does in the hub. - /// - /// `NotFound` is the true answer for a transaction that is with a flush and - /// not yet on the chain. That window is part of the lookup contract. The - /// implementation says so (`zeronym/hub/src/server.rs`, on `Hub::lookup`): - /// - /// > Note the flush-in-flight gap: `flush()` drains the queue before - /// > `broadcast_batch` has reached the indexer, so a lookup in that window - /// > gets a queue miss then an indexer NOT_FOUND for a transaction it was - /// > told height-0 about seconds earlier. Wallets poll on multi-second - /// > intervals and tolerate a transient NOT_FOUND; a resubmit is harmless - /// > (deduped pre-flush, already-known post-flush). Holding entries until - /// > broadcast returns would extend how long the hub remembers a txid, which - /// > is the wrong trade. - /// - /// It is also the true answer for a queued payload that does not parse. - pure def truth(s: System, query: TxId): LookupObs = - if (s.hub.queue.exists(payload => payload.txid == Some(query))) - Pending - else if (s.indexer.txs.keys().contains(query)) - val tx = s.indexer.txs.get(query) - Tx({ payload: tx.payload, height: answerHeight(tx.at) }) - else - NotFound - - pure val initialAudit: Audit = { everQueued: Set(), windows: Map() } - - /// The audit record after one step, from the states before and after it. - pure def advance(audit: Audit, pre: System, post: System): Audit = - // The lookups the shim is waiting on, or was until this step, each widen - // their window by what is true at the hub. - val waiting = pre.shim.waiters.keys().union(post.shim.waiters.keys()) - val windows = post.lookups(ShimAddr) - .filter(lookup => waiting.contains(lookup._1)) - .fold(audit.windows, (acc, lookup) => - val seen = if (acc.keys().contains(lookup._1)) acc.get(lookup._1) else Set() - acc.put(lookup._1, seen.union(Set(truth(post, lookup._2))))) - { everQueued: audit.everQueued.union(post.hub.queue), windows: windows } - - // ------------------------------------------------------------------------ - // Guarantees - // ------------------------------------------------------------------------ - - /// G1. The operator sees no migration: everything the shim hands it is a - /// pass-through transaction. - pure def operatorBlindIn(s: System): bool = - s.operator.forall(payload => payload.class == PassThrough) - - /// G2. A transaction's bytes do not reach a third party before the chain has - /// published them. Everything the third party has learned is on the chain, - /// or was a pass-through transaction the operator was given. - pure def queuedBytesConfidentialIn(s: System): bool = - s.tpLearned().forall(payload => - or { - s.indexer.published().contains(payload), - s.operator.contains(payload) and payload.class == PassThrough, - }) - - /// G3. A transaction served to the wallet has the txid the wallet asked for. - /// That is all: it need not be the bytes the wallet sent (a twin passes), - /// and its height is whatever the hub said. - pure def txidAuthenticityIn(s: System): bool = - s.events().forall(event => - match event { - | Got(got) => - match got.obs { - | Tx(tx) => tx.payload.txid == Some(got.query) - | _ => true - } - | _ => true - }) - - /// G4. Every lookup answer other than `Unavailable` was true, at the hub - /// that gave it, at some point between the request and the answer. - /// - /// It is a statement about one request. It does not say that successive - /// answers agree; see `statusNeverRegresses`. - pure def lookupValidityPerHubIn(s: System, audit: Audit): bool = - s.events().forall(event => - match event { - | Got(got) => - or { - got.obs == Unavailable, - match got.via { - | Some(nonce) => - audit.windows.keys().contains(nonce) and audit.windows.get(nonce).contains(got.obs) - | None => false - }, - } - | _ => true - }) - - /// G7. Structural sanity: every nonce in use was minted. - pure def wellFormedIn(s: System): bool = - and { - s.shim.waiters.keys().forall(nonce => nonce < s.shim.nextNonce), - s.net.forall(mail => - match mail.msg { - | Submit(submit) => - submit.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) - | Lookup(lookup) => - lookup.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) - | _ => true - }), - } - - /// G8. An accepted ack from the hub is for a payload it had queued by the - /// time it acked. It holds whether or not anyone waits for the ack. - pure def ackImpliesQueuedIn(s: System, audit: Audit): bool = - s.acked().subseteq(audit.everQueued) - - // ------------------------------------------------------------------------ - // Known gaps - // ------------------------------------------------------------------------ - - /// K2. What a wallet sees of one transaction never goes backwards: once - /// served, it is not later pending or missing; once pending, it is not later - /// missing. This does not hold. Replies are reordered; a published - /// transaction can be queued again; a flush empties the queue before the - /// chain has the batch; and the node can reject at flush. - pure def statusNeverRegressesIn(s: System): bool = - val log = s.wallet.log - tuples(log.indices(), log.indices()).forall(((i, j)) => - i < j implies - match log[i] { - | Got(earlier) => - match log[j] { - | Got(later) => - earlier.query != later.query or - match earlier.obs { - | Tx(_) => - match later.obs { - | Tx(_) => true - | Unavailable => true - | _ => false - } - | Pending => later.obs != NotFound - | _ => true - } - | _ => true - } - | _ => true - }) - - // K1. "Told ok" promises nothing about the hub. It is stated as two - // reachable states, not as a violated invariant, because the invariant is - // false on the ordinary success path too: the wallet is told before the hub - // has the frame. - - /// The nonces of the shim's submissions of `payload`. - pure def shimSubmissionsOf(s: System, payload: Payload): Set[Nonce] = - s.submissions(ShimAddr).filter(submit => submit._2 == payload).map(submit => submit._1) - - /// K1a. The wallet was told ok; every submission that reached the hub was - /// refused; the hub never queued the payload. - pure def wToldRefusedEverywhereIn(s: System, audit: Audit): bool = - s.toldOk().exists(payload => - val submitted = s.shimSubmissionsOf(payload) - and { - not(audit.everQueued.contains(payload)), - submitted != Set(), - submitted.forall(nonce => s.acks(ShimAddr).exists(ack => ack._1 == nonce and ack._2 != WAccepted)), - }) - - /// K1b. The wallet was told ok; the hub has answered none of the frames and - /// never queued the payload. The network may leave it so forever. - pure def wToldNeverDeliveredIn(s: System, audit: Audit): bool = - s.toldOk().exists(payload => - and { - not(audit.everQueued.contains(payload)), - s.shimSubmissionsOf(payload).forall(nonce => not(s.acks(ShimAddr).exists(ack => ack._1 == nonce))), - }) - - // ------------------------------------------------------------------------ - // Witnesses - // ------------------------------------------------------------------------ - - pure def wasGiven(s: System, isIt: LookupObs => bool): bool = - s.events().exists(event => - match event { - | Got(got) => isIt(got.obs) - | _ => false - }) - - /// W1. The wallet is told its transaction is pending. - pure def wPendingIn(s: System): bool = - s.wasGiven(obs => obs == Pending) - - /// W2. The wallet is served its transaction from the mempool. - pure def wTxInMempoolIn(s: System): bool = - s.wasGiven(obs => - match obs { - | Tx(tx) => tx.height == AtZero - | _ => false - }) - - /// W3. The wallet is served its transaction from a block. - pure def wTxMinedIn(s: System): bool = - s.wasGiven(obs => - match obs { - | Tx(tx) => tx.height == AtMined - | _ => false - }) - - /// W8. The accepted disclosure: a third party that knows a txid learns that - /// it is queued at the hub. The hub withholds the bytes; it does not withhold - /// the fact. The implementation leaves this open on purpose - /// (`zeronym/hub/src/server.rs`, in `Hub::lookup`): - /// - /// > What this does NOT close: the 200-versus-NotFound distinction still - /// > discloses that a given txid is queued here. Closing that too means - /// > answering NotFound, which costs a wallet the ability to tell "pending" - /// > from "never seen". That is a product decision, not a code one, and it - /// > is left open deliberately. - pure def wQueuedDisclosedIn(s: System): bool = - tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => - lookup._1 == reply._1 and reply._2 == WFound({ body: None, height: AtZero })) - - /// W9. The hub holds a payload it cannot parse; the wallet that sent it asks - /// for it and is told not found. An entry without a txid can never - /// be hit. - pure def wUnparseableMissedIn(s: System): bool = - tuples(s.events(), s.lookups(ShimAddr)).exists(((event, lookup)) => - match event { - | Got(got) => - and { - got.obs == NotFound, - got.via == Some(lookup._1), - s.hub.queue.exists(payload => payload.txid == None and walletTxid(payload) == got.query), - } - | _ => false - }) - - /// W16a. The wallet is served a twin of what it sent: other bytes, same txid. - pure def wTwinServedIn(s: System): bool = - s.wasGiven(obs => - match obs { - | Tx(tx) => s.toldOk().exists(sent => areTwins(sent, tx.payload)) - | _ => false - }) - - /// W16b. The wallet is served a transaction at a height that cannot be - /// true: the chain does not have it, or has it in the mempool and the - /// height says mined, or the height is not where it was mined. - pure def wFalseHeightServedIn(s: System): bool = - s.wasGiven(obs => - match obs { - | Tx(tx) => - val at = match tx.payload.txid { - | Some(txid) => s.onChain(txid) - | None => Absent - } - or { at == Absent, tx.height == AtOther, tx.height == AtMined and at != Mined } - | _ => false - }) - - /// W17. The hub has queued a payload of the third party's own making. - pure def wThirdPartyPayloadQueuedIn(s: System): bool = - s.hub.queue.intersect(s.thirdParty.own) != Set() - - // ------------------------------------------------------------------------ - // Non-vacuity: the antecedent of each guarantee is reachable - // ------------------------------------------------------------------------ - - pure def vOperatorBlindIn(s: System): bool = - s.operator != Set() - - pure def vQueuedBytesConfidentialIn(s: System): bool = - s.tpLearned() != Set() - - pure def vTxidAuthenticityIn(s: System): bool = - s.wasGiven(obs => - match obs { - | Tx(_) => true - | _ => false - }) - - pure def vLookupValidityPerHubIn(s: System): bool = - and { - s.wPendingIn(), - s.vTxidAuthenticityIn(), - s.wasGiven(obs => obs == NotFound), - } - - pure def vAckImpliesQueuedIn(s: System): bool = - s.acked() != Set() -} diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 7aa4ae0a..22d2ad12 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -12,7 +12,7 @@ /// /// - Roles. The shim and the hub run in enclaves and are honest in the /// baseline. The shim is honest in every configuration; the hub and its -/// indexer can each be made Byzantine through `ROLES`. A Byzantine +/// indexer can each be made Byzantine through `cfg.roles`. A Byzantine /// component draws its transitions from a wider relation and is not marked /// in any other way. /// - Network. Frames may be lost, duplicated, delayed and reordered. They @@ -44,56 +44,674 @@ module protocol { import indexer.* from "./indexer" import abstractHub.* from "./abstractHub" import shim.* from "./shim" - import state.* from "./state" - import properties.* from "./properties" + // ======================================================================== + // Transactions and configurations + // ======================================================================== + + // ------------------------------------------------------------------------ + // Transactions + // ------------------------------------------------------------------------ + // + // The expiries are set against the hub specification's schedule; the + // protocol does not read them. + + pure def orchard(id: str, created: Height, expiry: Height): Payload = + { id: id, txid: Some(id), created: created, expiry: Some(expiry), class: OrchardTouching, oversize: false } + + /// Two migrations from supported wallets, built at heights 2 and 4. + pure val early = orchard("early", 2, 9) + pure val late = orchard("late", 4, 11) + /// A migration whose wallet set its expiry tighter than the supported floor. + pure val tight = orchard("tight", 2, 5) + /// Bytes the shim cannot parse and neither can a hub: no txid, no expiry. + pure val junk: Payload = + { id: "junk", txid: None, created: 1, expiry: None, class: Unparseable, oversize: false } + /// A transaction that is not a migration. + pure val plain: Payload = + { id: "plain", txid: Some("plain"), created: 1, expiry: Some(9), class: PassThrough, oversize: false } + /// Other bytes with the txid of `early`. + pure val earlyTwin = { ...early, id: "early-twin" } + /// The third party's own payload. + pure val garbage: Payload = + { id: "garbage", txid: Some("garbage"), created: 1, expiry: None, class: OrchardTouching, oversize: false } + + // ------------------------------------------------------------------------ + // Configurations + // ------------------------------------------------------------------------ + + pure val allHonest: Roles = { hub: Honest, indexer: Honest } + + /// One hub, the mixnet transport, every component honest. + pure val baseline: Config = { + payloads: Set(early, late, tight, junk, plain), + twins: Set(earlyTwin), + tpPayloads: Set(garbage), + maxRequests: 3, + roles: allHonest, + } + + // One Byzantine component at a time. + pure val byzHub = { ...baseline, roles: { ...allHonest, hub: Byzantine } } + pure val byzIndexer = { ...baseline, roles: { ...allHonest, indexer: Byzantine } } + + // ======================================================================== + // The system + // + // The whole system as one value: the components' states, the network + // between them, and what each outside party has seen. Nothing here decides + // anything: these functions put a component's output where it goes and + // derive the views the properties are stated over. + // ======================================================================== + + // ------------------------------------------------------------------------ + // The system + // ------------------------------------------------------------------------ + + type Mail = Envelope[Addr, Msg] + type Net = Soup[Addr, Msg] + + /// The wallet: the answers it has been given, in the order it got them, and + /// how many requests of each kind it has made. + type Wallet = { log: List[WalletEvent], sends: int, gets: int } + + /// The third party: a client of the hub's public address that is not the + /// shim. `txids` are the transaction ids it has learned out of band; `own` + /// are payloads of its own making. + type ThirdParty = { txids: Set[TxId], own: Set[Payload], nextNonce: Nonce, requests: int } + + /// - `hub`: the hub as the protocol sees it (`abstractHub.qnt`). + /// - `operator`: every transaction the shim has handed the operator's + /// indexer. The operator is assumed to publish nothing itself. + /// - `disclosed`: payloads a Byzantine component has revealed. It is written + /// by the disclosure step and by nothing else. + type System = { + indexer: IndexerState, + hub: AHub, + shim: ShimState, + net: Net, + wallet: Wallet, + operator: Set[Payload], + thirdParty: ThirdParty, + disclosed: Set[Payload], + } + + /// The system at rest: the chain and the hub empty, nothing sent. The + /// protocol has no chain height, and the indexer's stays at 0. + pure def initialSystem(config: Config): System = { + indexer: initialIndexer(0), + hub: emptyAHub, + shim: initialShim, + net: Set(), + wallet: { log: [], sends: 0, gets: 0 }, + operator: Set(), + thirdParty: { txids: Set(), own: config.tpPayloads, nextNonce: 0, requests: 0 }, + disclosed: Set(), + } + + /// What an observer of the run has recorded. It is not protocol state: no + /// component reads it, and it is derived at every step from the states + /// before and after, never from what a component reports about itself. + /// + /// - `everQueued`: every payload that has been in the hub's queue. + /// - `windows`: for each lookup the shim has sent, the answers that were + /// true at the hub at some point while it waited. + type Audit = { + everQueued: Set[Payload], + windows: Nonce -> Set[LookupObs], + } + + // ------------------------------------------------------------------------ + // Views + // ------------------------------------------------------------------------ + + /// Where `txid` stands on the chain. + pure def onChain(s: System, txid: TxId): Inclusion = + s.indexer.inclusion(txid) + + /// The answers the wallet has been given, without their order. + pure def events(s: System): Set[WalletEvent] = + s.wallet.log.indices().map(i => s.wallet.log[i]) + + /// The payloads the wallet has been told were diverted. + pure def toldOk(s: System): Set[Payload] = + s.events().fold(Set(), (acc, event) => + match event { + | Sent(sent) => + match sent.input { + | Clean(payload) => if (sent.obs == SentOk) acc.union(Set(payload)) else acc + | _ => acc + } + | _ => acc + }) + + /// The submissions `client` has addressed to the hub, as (nonce, payload). + pure def submissions(s: System, client: Addr): Set[(Nonce, Payload)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | Submit(submit) => + if (mail.src == client and mail.dst == HubAddr) acc.union(Set((submit.nonce, submit.payload))) + else acc + | _ => acc + }) + + /// The acks the hub has sent `client`, as (nonce, ack). + pure def acks(s: System, client: Addr): Set[(Nonce, WireAck)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | Ack(ack) => + if (mail.src == HubAddr and mail.dst == client) acc.union(Set((ack.nonce, ack.ack))) + else acc + | _ => acc + }) + + /// The payloads the hub has acknowledged as accepted, to any client. + pure def acked(s: System): Set[Payload] = + Set(ShimAddr, ThirdPartyAddr).map(client => + tuples(s.submissions(client), s.acks(client)) + .filter(((submit, ack)) => submit._1 == ack._1 and ack._2 == WAccepted) + .map(((submit, _)) => submit._2) + ).flatten() + + /// The lookups `client` has addressed to the hub, as (nonce, txid). + pure def lookups(s: System, client: Addr): Set[(Nonce, TxId)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | Lookup(lookup) => + if (mail.src == client and mail.dst == HubAddr) acc.union(Set((lookup.nonce, lookup.txid))) + else acc + | _ => acc + }) + + /// The lookup replies the hub has addressed to `client`, as (nonce, reply). + pure def replies(s: System, client: Addr): Set[(Nonce, WireReply)] = + s.net.fold(Set(), (acc, mail) => + match mail.msg { + | LookupReply(reply) => + if (mail.src == HubAddr and mail.dst == client) acc.union(Set((reply.nonce, reply.reply))) + else acc + | _ => acc + }) + + /// The transaction bodies in lookup replies addressed to `client`. + pure def repliedBodies(s: System, client: Addr): Set[Payload] = + s.replies(client).fold(Set(), (acc, reply) => + match reply._2 { + | WFound(found) => + match found.body { + | Some(payload) => acc.union(Set(payload)) + | None => acc + } + | _ => acc + }) + + // ------------------------------------------------------------------------ + // What each party knows + // ------------------------------------------------------------------------ + + /// The transaction ids the third party knows: those it learned out of band + /// and everything the chain has made public. + pure def tpTxids(s: System): Set[TxId] = + s.thirdParty.txids.union(s.indexer.txs.keys()) + + /// The payloads the third party did not make and yet has: the bodies of + /// replies sent to it, whatever reached the operator, and whatever a + /// Byzantine component disclosed. The chain is left out on purpose: what is + /// published is public. + /// + /// This is derived from what the third party can observe. Nothing updates it + /// when a transaction is published, so a property over it constrains what + /// the hub puts in its replies. + pure def tpLearned(s: System): Set[Payload] = + s.repliedBodies(ThirdPartyAddr).union(s.operator).union(s.disclosed).exclude(s.thirdParty.own) + + /// Every payload the third party has: what it learned, what the chain + /// published, and its own. + pure def tpPayloads(s: System): Set[Payload] = + s.tpLearned().union(s.indexer.published()).union(s.thirdParty.own) + + /// The payloads the wallet has handed the shim: every send is answered at + /// once. + pure def sentByWallet(s: System): Set[Payload] = + s.events().fold(Set(), (acc, event) => + match event { + | Sent(done) => + match done.input { + | Clean(payload) => acc.union(Set(payload)) + | _ => acc + } + | _ => acc + }) + + /// The payloads addressed to the hub by anyone: what it has received or may + /// yet receive. + pure def seenByHub(s: System): Set[Payload] = + Set(ShimAddr, ThirdPartyAddr).map(client => s.submissions(client).map(submit => submit._2)).flatten() + + /// What the Byzantine components, taken together, are able to reveal. + pure def disclosable(s: System, roles: Roles): Set[Payload] = + (if (roles.indexer == Byzantine) s.indexer.offered else Set()) + .union(if (roles.hub == Byzantine) s.seenByHub() else Set()) + + // ------------------------------------------------------------------------ + // Putting outputs where they go // ------------------------------------------------------------------------ - // Constants + + pure def logged(s: System, event: WalletEvent): System = + { ...s, wallet: { ...s.wallet, log: s.wallet.log.append(event) } } + + pure def posted(s: System, mails: Set[Mail]): System = + { ...s, net: s.net.sendAll(mails) } + + /// The system after the shim took `result`. `via` is the nonce of the frame + /// that was delivered to it, if the step was a delivery: it is recorded with + /// a lookup answer so the answer can be traced to the reply that gave it. + pure def shimStepped(s: System, result: ShimResult, via: Option[Nonce]): System = + val stepped = { ...s, shim: result.state } + match result.out { + | ForwardOutput(payload) => + { ...stepped, operator: stepped.operator.union(Set(payload)) } + .logged(Sent({ input: Clean(payload), obs: SentToOperator })) + | DivertedOutput(diverted) => + stepped.posted(Set({ src: ShimAddr, dst: HubAddr, msg: diverted.frame })) + .logged(Sent({ input: Clean(diverted.payload), obs: diverted.told })) + | SendDoneOutput(done) => stepped.logged(Sent(done)) + | LookupSentOutput(frame) => stepped.posted(Set({ src: ShimAddr, dst: HubAddr, msg: frame })) + | LookupDoneOutput(done) => + stepped.logged(Got({ query: done.query, obs: done.result, via: via })) + | NoShimOutput => stepped + | ShimErrorOutput(_) => stepped + } + + /// The system after the hub answered `answer` to the request `mail`. The + /// reply goes back to its sender under its nonce. + pure def hubReplied(s: System, mail: Mail, answer: AAnswer): System = + val nonce = match mail.msg { + | Submit(submit) => submit.nonce + | Lookup(lookup) => lookup.nonce + | _ => 0 + } + val msg = match answer.reply { + | AAck(ack) => Ack({ nonce: nonce, ack: ack }) + | AWire(reply) => LookupReply({ nonce: nonce, reply: reply }) + } + { ...s, hub: answer.hub }.posted(Set({ src: HubAddr, dst: mail.src, msg: msg })) + + /// The request a frame carries. `answer` is what the hub's indexer would say + /// to a lookup. Reply frames are not requests and give nothing. + pure def requestOf(msg: Msg, answer: IndexerAnswer): Set[ARequest] = + match msg { + | Submit(submit) => Set(ASubmit(submit.payload)) + | Lookup(lookup) => Set(ALookup({ txid: lookup.txid, answer: answer })) + | _ => Set() + } + + /// The wallet makes one more request of a kind. + pure def countSend(s: System): System = + { ...s, wallet: { ...s.wallet, sends: s.wallet.sends + 1 } } + + pure def countGet(s: System): System = + { ...s, wallet: { ...s.wallet, gets: s.wallet.gets + 1 } } + + /// The third party sends `msg` to the hub, under a nonce of its own. + pure def thirdPartySent(s: System, msg: Msg): System = + { ...s, + thirdParty: { ...s.thirdParty, + nextNonce: s.thirdParty.nextNonce + 1, + requests: s.thirdParty.requests + 1, + }, + }.posted(Set({ src: ThirdPartyAddr, dst: HubAddr, msg: msg })) + + // ======================================================================== + // Properties + // + // What a wallet can rely on, what it cannot, and what must be reachable. + // Every definition is a pure predicate over the system and the audit + // record. Three classes, kept apart: guarantees, invariants claimed under + // stated trust assumptions; known gaps, invariants a wallet might hope for + // that fail even when every component is honest; witnesses, states that + // must be reachable. A predicate reads the system and the audit record, and + // the audit record is derived from consecutive states by `advance`, never + // from what a component says about itself. + // ======================================================================== + + // ------------------------------------------------------------------------ + // The audit record // ------------------------------------------------------------------------ - /// The configuration. It is one record so that a configuration is a value - /// that can be named, derived from another and shared between the module - /// that simulates it and the modules that script runs against it. The names + /// The true answer to a lookup for `query` at the hub, read off its + /// queue and the chain. The queue comes first, as it does in the hub. + /// + /// `NotFound` is the true answer for a transaction that is with a flush and + /// not yet on the chain. That window is part of the lookup contract. The + /// implementation says so (`zeronym/hub/src/server.rs`, on `Hub::lookup`): + /// + /// > Note the flush-in-flight gap: `flush()` drains the queue before + /// > `broadcast_batch` has reached the indexer, so a lookup in that window + /// > gets a queue miss then an indexer NOT_FOUND for a transaction it was + /// > told height-0 about seconds earlier. Wallets poll on multi-second + /// > intervals and tolerate a transient NOT_FOUND; a resubmit is harmless + /// > (deduped pre-flush, already-known post-flush). Holding entries until + /// > broadcast returns would extend how long the hub remembers a txid, which + /// > is the wrong trade. + /// + /// It is also the true answer for a queued payload that does not parse. + pure def truth(s: System, query: TxId): LookupObs = + if (s.hub.queue.exists(payload => payload.txid == Some(query))) + Pending + else if (s.indexer.txs.keys().contains(query)) + val tx = s.indexer.txs.get(query) + Tx({ payload: tx.payload, height: answerHeight(tx.at) }) + else + NotFound + + pure val initialAudit: Audit = { everQueued: Set(), windows: Map() } + + /// The audit record after one step, from the states before and after it. + pure def advance(audit: Audit, pre: System, post: System): Audit = + // The lookups the shim is waiting on, or was until this step, each widen + // their window by what is true at the hub. + val waiting = pre.shim.waiters.keys().union(post.shim.waiters.keys()) + val windows = post.lookups(ShimAddr) + .filter(lookup => waiting.contains(lookup._1)) + .fold(audit.windows, (acc, lookup) => + val seen = if (acc.keys().contains(lookup._1)) acc.get(lookup._1) else Set() + acc.put(lookup._1, seen.union(Set(truth(post, lookup._2))))) + { everQueued: audit.everQueued.union(post.hub.queue), windows: windows } + + // ------------------------------------------------------------------------ + // Guarantees + // ------------------------------------------------------------------------ + + /// G1. The operator sees no migration: everything the shim hands it is a + /// pass-through transaction. + pure def operatorBlindIn(s: System): bool = + s.operator.forall(payload => payload.class == PassThrough) + + /// G2. A transaction's bytes do not reach a third party before the chain has + /// published them. Everything the third party has learned is on the chain, + /// or was a pass-through transaction the operator was given. + pure def queuedBytesConfidentialIn(s: System): bool = + s.tpLearned().forall(payload => + or { + s.indexer.published().contains(payload), + s.operator.contains(payload) and payload.class == PassThrough, + }) + + /// G3. A transaction served to the wallet has the txid the wallet asked for. + /// That is all: it need not be the bytes the wallet sent (a twin passes), + /// and its height is whatever the hub said. + pure def txidAuthenticityIn(s: System): bool = + s.events().forall(event => + match event { + | Got(got) => + match got.obs { + | Tx(tx) => tx.payload.txid == Some(got.query) + | _ => true + } + | _ => true + }) + + /// G4. Every lookup answer other than `Unavailable` was true, at the hub + /// that gave it, at some point between the request and the answer. + /// + /// It is a statement about one request. It does not say that successive + /// answers agree; see `statusNeverRegresses`. + pure def lookupValidityPerHubIn(s: System, audit: Audit): bool = + s.events().forall(event => + match event { + | Got(got) => + or { + got.obs == Unavailable, + match got.via { + | Some(nonce) => + audit.windows.keys().contains(nonce) and audit.windows.get(nonce).contains(got.obs) + | None => false + }, + } + | _ => true + }) + + /// G7. Structural sanity: every nonce in use was minted. + pure def wellFormedIn(s: System): bool = + and { + s.shim.waiters.keys().forall(nonce => nonce < s.shim.nextNonce), + s.net.forall(mail => + match mail.msg { + | Submit(submit) => + submit.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) + | Lookup(lookup) => + lookup.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) + | _ => true + }), + } + + /// G8. An accepted ack from the hub is for a payload it had queued by the + /// time it acked. It holds whether or not anyone waits for the ack. + pure def ackImpliesQueuedIn(s: System, audit: Audit): bool = + s.acked().subseteq(audit.everQueued) + + // ------------------------------------------------------------------------ + // Known gaps + // ------------------------------------------------------------------------ + + /// K2. What a wallet sees of one transaction never goes backwards: once + /// served, it is not later pending or missing; once pending, it is not later + /// missing. This does not hold. Replies are reordered; a published + /// transaction can be queued again; a flush empties the queue before the + /// chain has the batch; and the node can reject at flush. + pure def statusNeverRegressesIn(s: System): bool = + val log = s.wallet.log + tuples(log.indices(), log.indices()).forall(((i, j)) => + i < j implies + match log[i] { + | Got(earlier) => + match log[j] { + | Got(later) => + earlier.query != later.query or + match earlier.obs { + | Tx(_) => + match later.obs { + | Tx(_) => true + | Unavailable => true + | _ => false + } + | Pending => later.obs != NotFound + | _ => true + } + | _ => true + } + | _ => true + }) + + // K1. "Told ok" promises nothing about the hub. It is stated as two + // reachable states, not as a violated invariant, because the invariant is + // false on the ordinary success path too: the wallet is told before the hub + // has the frame. + + /// The nonces of the shim's submissions of `payload`. + pure def shimSubmissionsOf(s: System, payload: Payload): Set[Nonce] = + s.submissions(ShimAddr).filter(submit => submit._2 == payload).map(submit => submit._1) + + /// K1a. The wallet was told ok; every submission that reached the hub was + /// refused; the hub never queued the payload. + pure def wToldRefusedEverywhereIn(s: System, audit: Audit): bool = + s.toldOk().exists(payload => + val submitted = s.shimSubmissionsOf(payload) + and { + not(audit.everQueued.contains(payload)), + submitted != Set(), + submitted.forall(nonce => s.acks(ShimAddr).exists(ack => ack._1 == nonce and ack._2 != WAccepted)), + }) + + /// K1b. The wallet was told ok; the hub has answered none of the frames and + /// never queued the payload. The network may leave it so forever. + pure def wToldNeverDeliveredIn(s: System, audit: Audit): bool = + s.toldOk().exists(payload => + and { + not(audit.everQueued.contains(payload)), + s.shimSubmissionsOf(payload).forall(nonce => not(s.acks(ShimAddr).exists(ack => ack._1 == nonce))), + }) + + // ------------------------------------------------------------------------ + // Witnesses + // ------------------------------------------------------------------------ + + pure def wasGiven(s: System, isIt: LookupObs => bool): bool = + s.events().exists(event => + match event { + | Got(got) => isIt(got.obs) + | _ => false + }) + + /// W1. The wallet is told its transaction is pending. + pure def wPendingIn(s: System): bool = + s.wasGiven(obs => obs == Pending) + + /// W2. The wallet is served its transaction from the mempool. + pure def wTxInMempoolIn(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => tx.height == AtZero + | _ => false + }) + + /// W3. The wallet is served its transaction from a block. + pure def wTxMinedIn(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => tx.height == AtMined + | _ => false + }) + + /// W8. The accepted disclosure: a third party that knows a txid learns that + /// it is queued at the hub. The hub withholds the bytes; it does not withhold + /// the fact. The implementation leaves this open on purpose + /// (`zeronym/hub/src/server.rs`, in `Hub::lookup`): + /// + /// > What this does NOT close: the 200-versus-NotFound distinction still + /// > discloses that a given txid is queued here. Closing that too means + /// > answering NotFound, which costs a wallet the ability to tell "pending" + /// > from "never seen". That is a product decision, not a code one, and it + /// > is left open deliberately. + pure def wQueuedDisclosedIn(s: System): bool = + tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => + lookup._1 == reply._1 and reply._2 == WFound({ body: None, height: AtZero })) + + /// W9. The hub holds a payload it cannot parse; the wallet that sent it asks + /// for it and is told not found. An entry without a txid can never + /// be hit. + pure def wUnparseableMissedIn(s: System): bool = + tuples(s.events(), s.lookups(ShimAddr)).exists(((event, lookup)) => + match event { + | Got(got) => + and { + got.obs == NotFound, + got.via == Some(lookup._1), + s.hub.queue.exists(payload => payload.txid == None and walletTxid(payload) == got.query), + } + | _ => false + }) + + /// W16a. The wallet is served a twin of what it sent: other bytes, same txid. + pure def wTwinServedIn(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => s.toldOk().exists(sent => areTwins(sent, tx.payload)) + | _ => false + }) + + /// W16b. The wallet is served a transaction at a height that cannot be + /// true: the chain does not have it, or has it in the mempool and the + /// height says mined, or the height is not where it was mined. + pure def wFalseHeightServedIn(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(tx) => + val at = match tx.payload.txid { + | Some(txid) => s.onChain(txid) + | None => Absent + } + or { at == Absent, tx.height == AtOther, tx.height == AtMined and at != Mined } + | _ => false + }) + + /// W17. The hub has queued a payload of the third party's own making. + pure def wThirdPartyPayloadQueuedIn(s: System): bool = + s.hub.queue.intersect(s.thirdParty.own) != Set() + + // ------------------------------------------------------------------------ + // Non-vacuity: the antecedent of each guarantee is reachable + // ------------------------------------------------------------------------ + + pure def vOperatorBlindIn(s: System): bool = + s.operator != Set() + + pure def vQueuedBytesConfidentialIn(s: System): bool = + s.tpLearned() != Set() + + pure def vTxidAuthenticityIn(s: System): bool = + s.wasGiven(obs => + match obs { + | Tx(_) => true + | _ => false + }) + + pure def vLookupValidityPerHubIn(s: System): bool = + and { + s.wPendingIn(), + s.vTxidAuthenticityIn(), + s.wasGiven(obs => obs == NotFound), + } + + pure def vAckImpliesQueuedIn(s: System): bool = + s.acked() != Set() + + // ======================================================================== + // The machine + // ======================================================================== + + + // ------------------------------------------------------------------------ + // Configuration + // ------------------------------------------------------------------------ + + /// The configuration. Written by `initWith` and by nothing else. The names /// below are its fields, and are what the rest of the module uses. - const CONFIG: Config + var cfg: Config /// The transactions a wallet may send. - pure val PAYLOADS = CONFIG.payloads + def PAYLOADS = cfg.payloads /// Twins of wallet transactions: other bytes with the same txid. No honest /// party sends one. - pure val TWINS = CONFIG.twins + def TWINS = cfg.twins /// Payloads of the third party's own making. - pure val TP_PAYLOADS = CONFIG.tpPayloads + def TP_PAYLOADS = cfg.tpPayloads /// The bound of the model: the wallet makes at most `MAX_REQUESTS` sends /// and as many lookups, as does the third party. - pure val MAX_REQUESTS = CONFIG.maxRequests + def MAX_REQUESTS = cfg.maxRequests - pure val ROLES = CONFIG.roles + def ROLES = cfg.roles /// Every payload that exists. A Byzantine component builds its lies from it. - pure val UNIVERSE = PAYLOADS.union(TWINS).union(TP_PAYLOADS) + pure def universeOf(c: Config): Set[Payload] = c.payloads.union(c.twins).union(c.tpPayloads) + def UNIVERSE = universeOf(cfg) /// What a wallet may send. - pure val SEND_INPUTS = PAYLOADS.map(payload => Clean(payload)).union(Set(Unreadable, EmptyBody)) + def SEND_INPUTS = PAYLOADS.map(payload => Clean(payload)).union(Set(Unreadable, EmptyBody)) /// Every transaction id there is. - pure val TXIDS = txidsOf(UNIVERSE) - - // ------------------------------------------------------------------------ - // Assumptions - // ------------------------------------------------------------------------ - // - // A named value, so that an instance's `assumptionsTest` can assert it: the - // simulator does not enforce `assume`. - - /// Payloads are told apart by their bytes; a twin is a twin of something a - /// wallet sends; the third party's payloads are its own. - pure val payloadsWellFormed = and { - UNIVERSE.size() == UNIVERSE.map(payload => payload.id).size(), - PAYLOADS.intersect(TP_PAYLOADS) == Set(), - TWINS.forall(twin => PAYLOADS.exists(payload => areTwins(twin, payload))), - } - - assume _ = payloadsWellFormed + def TXIDS = txidsOf(UNIVERSE) + + /// The standing assumption, checked by every named init: payloads are told + /// apart by their bytes; a twin is a twin of something a wallet sends; the + /// third party's payloads are its own. + pure def payloadsWellFormed(c: Config): bool = + val universe = universeOf(c) + and { + universe.size() == universe.map(payload => payload.id).size(), + c.payloads.intersect(c.tpPayloads) == Set(), + c.twins.forall(twin => c.payloads.exists(payload => areTwins(twin, payload))), + } // ------------------------------------------------------------------------ // State @@ -104,30 +722,34 @@ module protocol { /// The observer's record of the run. No step reads it. var audit: Audit - /// The only writer of the variables. + /// The only writer of the variables after `initWith`. action commit(post: System): bool = all { + cfg' = cfg, s' = post, audit' = advance(audit, s, post), } - pure val INITIAL = initialSystem(CONFIG) - - action init = all { - s' = INITIAL, + action initWith(c: Config): bool = all { + cfg' = c, + s' = initialSystem(c), audit' = initialAudit, } + action initBaseline = all { payloadsWellFormed(baseline), initWith(baseline) } + action initByzHub = all { payloadsWellFormed(byzHub), initWith(byzHub) } + action initByzIndexer = all { payloadsWellFormed(byzIndexer), initWith(byzIndexer) } + // ------------------------------------------------------------------------ // Roles // ------------------------------------------------------------------------ - pure def hubAnswers(h: AHub, request: ARequest): Set[AAnswer] = + def hubAnswers(h: AHub, request: ARequest): Set[AAnswer] = match ROLES.hub { | Honest => honestAnswers(h, request) | Byzantine => byzantineAnswers(h, request, UNIVERSE) } - pure def indexerResults(state: IndexerState, input: IndexerInput): Set[IndexerResult] = + def indexerResults(state: IndexerState, input: IndexerInput): Set[IndexerResult] = match ROLES.indexer { | Honest => heightlessIndexerResults(state, input) | Byzantine => byzIndexerResults(state, input, UNIVERSE) @@ -135,7 +757,7 @@ module protocol { /// What the indexer may answer a hub's lookup for the transaction `msg` /// asks about. A frame that is not a lookup needs no answer. - pure def lookupAnswers(state: IndexerState, msg: Msg): Set[IndexerAnswer] = + def lookupAnswers(state: IndexerState, msg: Msg): Set[IndexerAnswer] = match msg { | Lookup(lookup) => indexerResults(state, LookupIInput(lookup.txid)).fold(Set(), (acc, result) => diff --git a/zeronym/spec/protocol/state.qnt b/zeronym/spec/protocol/state.qnt deleted file mode 100644 index 4e3bb137..00000000 --- a/zeronym/spec/protocol/state.qnt +++ /dev/null @@ -1,276 +0,0 @@ -// -*- mode: Bluespec; -*- - -/// The whole system as one value: the components' states, the network between -/// them, and what each outside party has seen. -/// -/// Nothing here decides anything. The functions in this module put a -/// component's output where it goes (a frame into the soup, an answer into the -/// wallet's log, a forwarded transaction in front of the operator) and derive -/// the views the properties are stated over. -module state { - import basicSpells.* from "./spells/basicSpells" - import soup.* from "./spells/soup" - import types.* from "./types" - import wire.* from "./wire" - import indexer.* from "./indexer" - import abstractHub.* from "./abstractHub" - import shim.* from "./shim" - - // ------------------------------------------------------------------------ - // The system - // ------------------------------------------------------------------------ - - type Mail = Envelope[Addr, Msg] - type Net = Soup[Addr, Msg] - - /// The wallet: the answers it has been given, in the order it got them, and - /// how many requests of each kind it has made. - type Wallet = { log: List[WalletEvent], sends: int, gets: int } - - /// The third party: a client of the hub's public address that is not the - /// shim. `txids` are the transaction ids it has learned out of band; `own` - /// are payloads of its own making. - type ThirdParty = { txids: Set[TxId], own: Set[Payload], nextNonce: Nonce, requests: int } - - /// - `hub`: the hub as the protocol sees it (`abstractHub.qnt`). - /// - `operator`: every transaction the shim has handed the operator's - /// indexer. The operator is assumed to publish nothing itself. - /// - `disclosed`: payloads a Byzantine component has revealed. It is written - /// by the disclosure step and by nothing else. - type System = { - indexer: IndexerState, - hub: AHub, - shim: ShimState, - net: Net, - wallet: Wallet, - operator: Set[Payload], - thirdParty: ThirdParty, - disclosed: Set[Payload], - } - - /// The system at rest: the chain and the hub empty, nothing sent. The - /// protocol has no chain height, and the indexer's stays at 0. - pure def initialSystem(config: Config): System = { - indexer: initialIndexer(0), - hub: emptyAHub, - shim: initialShim, - net: Set(), - wallet: { log: [], sends: 0, gets: 0 }, - operator: Set(), - thirdParty: { txids: Set(), own: config.tpPayloads, nextNonce: 0, requests: 0 }, - disclosed: Set(), - } - - /// What an observer of the run has recorded. It is not protocol state: no - /// component reads it, and it is derived at every step from the states - /// before and after, never from what a component reports about itself. - /// - /// - `everQueued`: every payload that has been in the hub's queue. - /// - `windows`: for each lookup the shim has sent, the answers that were - /// true at the hub at some point while it waited. - type Audit = { - everQueued: Set[Payload], - windows: Nonce -> Set[LookupObs], - } - - // ------------------------------------------------------------------------ - // Views - // ------------------------------------------------------------------------ - - /// Where `txid` stands on the chain. - pure def onChain(s: System, txid: TxId): Inclusion = - s.indexer.inclusion(txid) - - /// The answers the wallet has been given, without their order. - pure def events(s: System): Set[WalletEvent] = - s.wallet.log.indices().map(i => s.wallet.log[i]) - - /// The payloads the wallet has been told were diverted. - pure def toldOk(s: System): Set[Payload] = - s.events().fold(Set(), (acc, event) => - match event { - | Sent(sent) => - match sent.input { - | Clean(payload) => if (sent.obs == SentOk) acc.union(Set(payload)) else acc - | _ => acc - } - | _ => acc - }) - - /// The submissions `client` has addressed to the hub, as (nonce, payload). - pure def submissions(s: System, client: Addr): Set[(Nonce, Payload)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | Submit(submit) => - if (mail.src == client and mail.dst == HubAddr) acc.union(Set((submit.nonce, submit.payload))) - else acc - | _ => acc - }) - - /// The acks the hub has sent `client`, as (nonce, ack). - pure def acks(s: System, client: Addr): Set[(Nonce, WireAck)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | Ack(ack) => - if (mail.src == HubAddr and mail.dst == client) acc.union(Set((ack.nonce, ack.ack))) - else acc - | _ => acc - }) - - /// The payloads the hub has acknowledged as accepted, to any client. - pure def acked(s: System): Set[Payload] = - Set(ShimAddr, ThirdPartyAddr).map(client => - tuples(s.submissions(client), s.acks(client)) - .filter(((submit, ack)) => submit._1 == ack._1 and ack._2 == WAccepted) - .map(((submit, _)) => submit._2) - ).flatten() - - /// The lookups `client` has addressed to the hub, as (nonce, txid). - pure def lookups(s: System, client: Addr): Set[(Nonce, TxId)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | Lookup(lookup) => - if (mail.src == client and mail.dst == HubAddr) acc.union(Set((lookup.nonce, lookup.txid))) - else acc - | _ => acc - }) - - /// The lookup replies the hub has addressed to `client`, as (nonce, reply). - pure def replies(s: System, client: Addr): Set[(Nonce, WireReply)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | LookupReply(reply) => - if (mail.src == HubAddr and mail.dst == client) acc.union(Set((reply.nonce, reply.reply))) - else acc - | _ => acc - }) - - /// The transaction bodies in lookup replies addressed to `client`. - pure def repliedBodies(s: System, client: Addr): Set[Payload] = - s.replies(client).fold(Set(), (acc, reply) => - match reply._2 { - | WFound(found) => - match found.body { - | Some(payload) => acc.union(Set(payload)) - | None => acc - } - | _ => acc - }) - - // ------------------------------------------------------------------------ - // What each party knows - // ------------------------------------------------------------------------ - - /// The transaction ids the third party knows: those it learned out of band - /// and everything the chain has made public. - pure def tpTxids(s: System): Set[TxId] = - s.thirdParty.txids.union(s.indexer.txs.keys()) - - /// The payloads the third party did not make and yet has: the bodies of - /// replies sent to it, whatever reached the operator, and whatever a - /// Byzantine component disclosed. The chain is left out on purpose: what is - /// published is public. - /// - /// This is derived from what the third party can observe. Nothing updates it - /// when a transaction is published, so a property over it constrains what - /// the hub puts in its replies. - pure def tpLearned(s: System): Set[Payload] = - s.repliedBodies(ThirdPartyAddr).union(s.operator).union(s.disclosed).exclude(s.thirdParty.own) - - /// Every payload the third party has: what it learned, what the chain - /// published, and its own. - pure def tpPayloads(s: System): Set[Payload] = - s.tpLearned().union(s.indexer.published()).union(s.thirdParty.own) - - /// The payloads the wallet has handed the shim: every send is answered at - /// once. - pure def sentByWallet(s: System): Set[Payload] = - s.events().fold(Set(), (acc, event) => - match event { - | Sent(done) => - match done.input { - | Clean(payload) => acc.union(Set(payload)) - | _ => acc - } - | _ => acc - }) - - /// The payloads addressed to the hub by anyone: what it has received or may - /// yet receive. - pure def seenByHub(s: System): Set[Payload] = - Set(ShimAddr, ThirdPartyAddr).map(client => s.submissions(client).map(submit => submit._2)).flatten() - - /// What the Byzantine components, taken together, are able to reveal. - pure def disclosable(s: System, roles: Roles): Set[Payload] = - (if (roles.indexer == Byzantine) s.indexer.offered else Set()) - .union(if (roles.hub == Byzantine) s.seenByHub() else Set()) - - // ------------------------------------------------------------------------ - // Putting outputs where they go - // ------------------------------------------------------------------------ - - pure def logged(s: System, event: WalletEvent): System = - { ...s, wallet: { ...s.wallet, log: s.wallet.log.append(event) } } - - pure def posted(s: System, mails: Set[Mail]): System = - { ...s, net: s.net.sendAll(mails) } - - /// The system after the shim took `result`. `via` is the nonce of the frame - /// that was delivered to it, if the step was a delivery: it is recorded with - /// a lookup answer so the answer can be traced to the reply that gave it. - pure def shimStepped(s: System, result: ShimResult, via: Option[Nonce]): System = - val stepped = { ...s, shim: result.state } - match result.out { - | ForwardOutput(payload) => - { ...stepped, operator: stepped.operator.union(Set(payload)) } - .logged(Sent({ input: Clean(payload), obs: SentToOperator })) - | DivertedOutput(diverted) => - stepped.posted(Set({ src: ShimAddr, dst: HubAddr, msg: diverted.frame })) - .logged(Sent({ input: Clean(diverted.payload), obs: diverted.told })) - | SendDoneOutput(done) => stepped.logged(Sent(done)) - | LookupSentOutput(frame) => stepped.posted(Set({ src: ShimAddr, dst: HubAddr, msg: frame })) - | LookupDoneOutput(done) => - stepped.logged(Got({ query: done.query, obs: done.result, via: via })) - | NoShimOutput => stepped - | ShimErrorOutput(_) => stepped - } - - /// The system after the hub answered `answer` to the request `mail`. The - /// reply goes back to its sender under its nonce. - pure def hubReplied(s: System, mail: Mail, answer: AAnswer): System = - val nonce = match mail.msg { - | Submit(submit) => submit.nonce - | Lookup(lookup) => lookup.nonce - | _ => 0 - } - val msg = match answer.reply { - | AAck(ack) => Ack({ nonce: nonce, ack: ack }) - | AWire(reply) => LookupReply({ nonce: nonce, reply: reply }) - } - { ...s, hub: answer.hub }.posted(Set({ src: HubAddr, dst: mail.src, msg: msg })) - - /// The request a frame carries. `answer` is what the hub's indexer would say - /// to a lookup. Reply frames are not requests and give nothing. - pure def requestOf(msg: Msg, answer: IndexerAnswer): Set[ARequest] = - match msg { - | Submit(submit) => Set(ASubmit(submit.payload)) - | Lookup(lookup) => Set(ALookup({ txid: lookup.txid, answer: answer })) - | _ => Set() - } - - /// The wallet makes one more request of a kind. - pure def countSend(s: System): System = - { ...s, wallet: { ...s.wallet, sends: s.wallet.sends + 1 } } - - pure def countGet(s: System): System = - { ...s, wallet: { ...s.wallet, gets: s.wallet.gets + 1 } } - - /// The third party sends `msg` to the hub, under a nonce of its own. - pure def thirdPartySent(s: System, msg: Msg): System = - { ...s, - thirdParty: { ...s.thirdParty, - nextNonce: s.thirdParty.nextNonce + 1, - requests: s.thirdParty.requests + 1, - }, - }.posted(Set({ src: ThirdPartyAddr, dst: HubAddr, msg: msg })) -} diff --git a/zeronym/spec/protocol/tests/realisedRunsTest.qnt b/zeronym/spec/protocol/tests/realisedRunsTest.qnt index b02b3732..1d8434aa 100644 --- a/zeronym/spec/protocol/tests/realisedRunsTest.qnt +++ b/zeronym/spec/protocol/tests/realisedRunsTest.qnt @@ -17,7 +17,7 @@ module realisedRunsTest { import wire.* from "../wire" import hub.* from "../hub" import abstractHub.* from "../abstractHub" - import configs.* from "../instances" + import protocol.* from "../protocol" /// The hub specification's `timely` schedule. pure val PARAMS: HubParams = { @@ -28,7 +28,8 @@ module realisedRunsTest { reorgAllowance: 1, maxAttempts: 2, } - pure val UNIVERSE = baseline.payloads.union(baseline.twins).union(baseline.tpPayloads) + /// What a Byzantine hub builds its lies from. + pure val LIES = universeOf(baseline) type Step = Do(HubInput) | Lie({ input: HubInput, out: HubOutput }) @@ -61,7 +62,7 @@ module realisedRunsTest { } val taken = match step { | Do(_) => not(match result.out { | HubErrorOutput(_) => true | _ => false }) - | Lie(lie) => byzHubResults(acc.state, lie.input, UNIVERSE).contains(result) + | Lie(lie) => byzHubResults(acc.state, lie.input, LIES).contains(result) } { state: result.state, ok: acc.ok and taken, replies: acc.replies.concat(result.out.replyOf()) }) { ok: end.ok, replies: end.replies, last: { queue: end.state.queued(), held: end.state.inFlight() } } diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index f9b96ee9..e5952906 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -10,16 +10,24 @@ /// /// Shim nonces count up from 0, one per frame. -module baselineScenarios { +module scenariosTest { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" import wire.* from "../wire" import indexer.* from "../indexer" import abstractHub.* from "../abstractHub" import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = baseline).* from "../protocol" + import protocol.* from "../protocol" + + /// Every named init is live: its guard holds and it starts a run. + run liveInitsTest = + initBaseline + .then(initByzHub) + .then(initByzIndexer) + + // ------------------------------------------------------------------------ + // Every component honest (`baseline`) + // ------------------------------------------------------------------------ def tpAcks = s.acks(ThirdPartyAddr) @@ -29,7 +37,7 @@ module baselineScenarios { /// W1, W2, W3. A migration from send to mined, with the wallet polling. run pendingThenMempoolThenMinedTest = - init + initBaseline // The shim diverts and answers at once; the operator sees nothing. .then(submitTo(0, early)) .expect(lastEvent == Sent({ input: Clean(early), obs: SentOk })) @@ -49,7 +57,7 @@ module baselineScenarios { /// A pass-through transaction goes to the operator and nowhere else. run passThroughIsForwardedTest = - init + initBaseline .then(sendToHub(plain)) .expect(lastEvent == Sent({ input: Clean(plain), obs: SentToOperator })) .expect(s.operator == Set(plain) and s.net == Set()) @@ -58,7 +66,7 @@ module baselineScenarios { /// W8. The accepted disclosure: a third party that knows a txid is told it /// is queued, and is not given the bytes. run thirdPartyLearnsItIsQueuedTest = - init + initBaseline .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) @@ -69,7 +77,7 @@ module baselineScenarios { /// A lookup that gets no reply in time fails closed, and G4 holds of it. run lookupTimesOutTest = - init + initBaseline .then(submitTo(0, early)) .then(ask("early")) .then(timeOutLookup(1)) @@ -78,7 +86,7 @@ module baselineScenarios { /// W9. A queued payload the hub cannot parse has no txid to be found by. run unparseableIsQueuedAndMissedTest = - init + initBaseline .then(submitTo(0, junk)) .expect(lastEvent == Sent({ input: Clean(junk), obs: SentOk }) and s.hub.queue == Set(junk)) .then(lookUp(1, "junk")) @@ -87,7 +95,7 @@ module baselineScenarios { /// W17. Anyone can put a payload in a hub's queue. run thirdPartyPayloadIsQueuedTest = - init + initBaseline .then(thirdPartySubmitWith(garbage)) .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) .expect(tpAcks == Set((0, WAccepted)) and s.hub.queue == Set(garbage)) @@ -101,7 +109,7 @@ module baselineScenarios { /// realisation, refused for its expiry after the flush at 3, is in /// `realisedRunsTest`. run toldOkThenRefusedTest = - init + initBaseline .then(sendToHub(tight)) .then(answerSubmitFrom(ShimAddr, 0, tight, WRefused(WExpiryTooTight))) .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) @@ -111,7 +119,7 @@ module baselineScenarios { /// K1b. The frame is never delivered. Nothing obliges the network to. run toldOkAndNeverDeliveredTest = - init + initBaseline .then(sendToHub(early)) .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) .expect(s.net == Set(submitMail(0, early))) @@ -124,7 +132,7 @@ module baselineScenarios { /// K2a. Two polls, answered in order, delivered out of order. run repliesReorderedTest = - init + initBaseline .then(submitTo(0, early)) .then(ask("early")) .then(deliverLookup(1, "early")) @@ -144,7 +152,7 @@ module baselineScenarios { /// K2b. The wallet sends published bytes again. The hub's memory of them /// went with the flush, so they are admitted and pending once more. run walletResendsPublishedTest = - init + initBaseline .then(submitTo(0, early)) .then(flush([early], Accepted)) .then(lookUp(1, "early")) @@ -161,7 +169,7 @@ module baselineScenarios { /// K2c. The same, done by a third party: the bytes are public once /// published, and submission is open to anyone. run thirdPartyResubmitsPublishedTest = - init + initBaseline .then(submitTo(0, early)) .then(flush([early], Accepted)) .then(lookUp(1, "early")) @@ -178,7 +186,7 @@ module baselineScenarios { /// K2d. The flush window: the queue is empty and the chain does not have /// the batch yet. run flushWindowTest = - init + initBaseline .then(submitTo(0, early)) .then(lookUp(1, "early")) .then(hubTake) @@ -193,7 +201,7 @@ module baselineScenarios { /// K2e. The node rejects the transaction at flush. It was pending; now it /// is nowhere. run rejectedAtFlushTest = - init + initBaseline .then(submitTo(0, early)) .then(lookUp(1, "early")) .then(flush([early], Rejected)) @@ -204,24 +212,16 @@ module baselineScenarios { Got({ query: "early", obs: NotFound, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) -} -module byzHubScenarios { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import abstractHub.* from "../abstractHub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = byzHub).* from "../protocol" + // ------------------------------------------------------------------------ + // A Byzantine hub (`byzHub`) + // ------------------------------------------------------------------------ /// W16. A Byzantine hub serves a twin of the wallet's transaction at a /// height the chain has not reached. The shim checks the txid, which a twin /// shares, and nothing else: it serves both. run twinAtFalseHeightIsServedTest = - init + initByzHub .then(submitTo(0, early)) .then(ask("early")) .then(hubReceiveWith( diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 630cb28e..e0dced0f 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -11,21 +11,23 @@ /// same configuration, with the component taking the honest transition, which /// is always among those it may take. In the control the guarantee holds. -module byzHubTrust { +module trustTest { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" import wire.* from "../wire" import indexer.* from "../indexer" import abstractHub.* from "../abstractHub" import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = byzHub).* from "../protocol" + import protocol.* from "../protocol" + + // ------------------------------------------------------------------------ + // A Byzantine hub (`byzHub`) + // ------------------------------------------------------------------------ /// G2 needs the hub. Asked by a third party about a queued txid, it answers /// with the queued bytes. run hubServesQueuedBodyTest = - init + initByzHub .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) @@ -38,7 +40,7 @@ module byzHubTrust { .expect(not(queuedBytesConfidential)) run hubServesQueuedBodyControlTest = - init + initByzHub .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) @@ -48,7 +50,7 @@ module byzHubTrust { /// G4 needs the hub. It answers not found for a transaction it has queued. run hubDeniesQueuedTest = - init + initByzHub .then(submitTo(0, early)) .then(ask("early")) .then(hubReceiveWith( @@ -61,7 +63,7 @@ module byzHubTrust { .expect(not(lookupValidityPerHub)) run hubDeniesQueuedControlTest = - init + initByzHub .then(submitTo(0, early)) .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) @@ -70,7 +72,7 @@ module byzHubTrust { /// G4 needs the hub, second run. It serves a mempool transaction as mined, /// at a height it made up. The txid is right, so the shim passes it on. run hubServesFalseHeightTest = - init + initByzHub .then(submitTo(0, early)) .then(flush([early], Accepted)) .then(ask("early")) @@ -85,7 +87,7 @@ module byzHubTrust { .expect(not(lookupValidityPerHub) and txidAuthenticity) run hubServesFalseHeightControlTest = - init + initByzHub .then(submitTo(0, early)) .then(flush([early], Accepted)) .then(lookUp(1, "early")) @@ -94,7 +96,7 @@ module byzHubTrust { /// G8 needs the hub. It acks a submission as accepted and does not queue it. run hubAcksWithoutAdmittingTest = - init + initByzHub .then(sendToHub(early)) .then(hubReceiveWith(submitMail(0, early), INotFound, { hub: s.hub, reply: AAck(WAccepted) })) .expect(s.acks(ShimAddr) == Set((0, WAccepted))) @@ -102,7 +104,7 @@ module byzHubTrust { .expect(not(ackImpliesQueued)) run hubAcksWithoutAdmittingControlTest = - init + initByzHub .then(sendToHub(early)) .then(deliverSubmit(0, early)) .expect(s.acks(ShimAddr) == Set((0, WAccepted)) and s.hub.queue == Set(early)) @@ -111,7 +113,7 @@ module byzHubTrust { /// G3 survives. The hub answers with another transaction; the shim compares /// txids and refuses it. run wrongTransactionIsRefusedTest = - init + initByzHub .then(sendToHub(early)) .then(ask("early")) .then(hubReceiveWith( @@ -121,18 +123,10 @@ module byzHubTrust { .then(deliverToShim(replyMail(1, WFound({ body: Some(tight), height: AtMined })))) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) .expect(txidAuthenticity) -} -module byzIndexerTrust { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import indexer.* from "../indexer" - import abstractHub.* from "../abstractHub" - import shim.* from "../shim" - import state.* from "../state" - import configs.* from "../instances" - import protocol(CONFIG = byzIndexer).* from "../protocol" + // ------------------------------------------------------------------------ + // A Byzantine indexer (`byzIndexer`) + // ------------------------------------------------------------------------ // A hub folds several indexer endpoints into one answer. A lookup answer // comes from the first endpoint that says found, so one misbehaving endpoint @@ -144,7 +138,7 @@ module byzIndexerTrust { /// then serves the unpublished bytes in a lookup answer, which the honest /// hub forwards to whoever asked. run indexerServesUnpublishedBodyTest = - init + initByzIndexer .then(submitTo(0, early)) .then(hubTake) .then(judge(early, Retryable)) @@ -159,7 +153,7 @@ module byzIndexerTrust { .expect(not(queuedBytesConfidential)) run indexerServesUnpublishedBodyControlTest = - init + initByzIndexer .then(submitTo(0, early)) .then(hubTake) .then(judge(early, Retryable)) @@ -173,7 +167,7 @@ module byzIndexerTrust { /// "found, height 0, no body". The hub forwards it unchanged, and on the /// wire it is the hub's own "queued here": the wallet sees pending. run indexerForgesPendingTest = - init + initByzIndexer .then(sends(Clean(early), true)) .then(ask("early")) .then(deliverLookupFrom(ShimAddr, 1, "early", IFound({ body: None, height: AtZero }))) @@ -183,7 +177,7 @@ module byzIndexerTrust { .expect(not(lookupValidityPerHub)) run indexerForgesPendingControlTest = - init + initByzIndexer .then(sends(Clean(early), true)) .then(lookUp(1, "early")) .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) From 1a14405a89725d9cb56c831318cfb737329e1fa0 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 12:56:38 +0400 Subject: [PATCH 53/80] test(zeronym): record one TLC measurement of the protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 842604ac..dc975f50 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -1007,3 +1007,21 @@ only as a whole row (17 s, beside another run). Named inits are kept. Not measured: Apalache at bounded depths on this machine (one attempt failed on its configuration and was not repeated); the route with an empty `~/.quint` and Quint fetched by `npx`. + +## The protocol specification under TLC (measured once, not a gate) + +Measured once at step 19, on the all-honest configuration with +`maxRequests` 2 and invariant `wellFormed`, through `tlc.sh` with 4 workers, +an 8 GB heap and a 300 s limit (the plan said 10 minutes; the cap used for +every TLC run here is 5). A tier 1-3 gate shared the machine for most of the +run. The compiled JSON is 39.2 MB (133.0 MB before step 18, with a constant +and an instance module). TLC did not exhaust it: after 300 s it had +6 942 646 distinct states at depth 11, with 5 392 316 still on the queue, +and a resident set of 6.3 GB. The queue grew by about 1.2 million states a +minute throughout (0.10 M at 4 s, 1.66 M at 64 s, 2.99 M, 4.20 M, 5.39 M at +244 s) and the depth reached only 11, against the 40 to 80 steps the +simulation rows use. Exhaustive checking of the protocol specification does +not look feasible at this bound in minutes; it would need the bound lowered +to one request of each kind, or the soup and the wallet's log bounded, and +whether either is enough was not measured. The protocol gate stays +simulation. From c783fb9efeea0cbd3ef3e246c79bc758663bef3f Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 12:56:53 +0400 Subject: [PATCH 54/80] test(zeronym): cut the simulation rows the scripted runs already carry Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 7 ++++--- zeronym/spec/protocol/check.sh | 18 +++++------------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index dc975f50..de323a6d 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -562,8 +562,8 @@ Which components must be honest for each guarantee. Single-fault. "holds" is a tier 3 simulation row on the named configuration, with its antecedent witnessed there in tier 3b. "required" is a scripted run in `tests/trustTest.qnt` in which the component is Byzantine and the guarantee fails, followed by its control -(same wallet inputs, honest transition, guarantee holds); the simulation row -for such a cell shows polarity only. +(same wallet inputs, honest transition, guarantee holds). Such a cell has no +simulation row. Every cell was a prediction, except the G6c row, which was added after review and derived by running. **Observed verdicts agree with the predictions in every @@ -626,7 +626,8 @@ frame undelivered, and nothing obliges the network ever to deliver it. ### Witnesses -Each has a scripted run and, except W15 and W18, is counted in tier 3b. +Each has a scripted run. W8, W16 and W17 are also counted in tier 3b; W1-W3, +W9, W15 and W18 are scripted only. W4 (each refusal) and W5-W7 (requeued, dropped as expired, dropped as exhausted) were witnesses here; they read the hub's internals and are gone with the real hub. F8 produces each refusal and F9 each requeue outcome; the diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 34bcfd4f..da78d6bb 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -11,9 +11,10 @@ # 2 quint test: the functional layer and the scripted runs, among them # `liveInitsTest`, which starts from every named init. # 3 quint run: invariants. "holds" rows are the guarantees, on the -# configurations where they are claimed. "fails" rows are the known gaps -# and the guarantees under the Byzantine component they depend on; such a -# row shows polarity only, and the scripted run in tier 2 carries the cause. +# configurations where they are claimed. The "fails" row is K2, a known +# gap; it shows polarity only, and the scripted runs in tier 2 carry the +# causes. A guarantee under the Byzantine component it depends on has a +# scripted run and its control, and no row here. # 3b quint run: witnesses. Every listed state must be reached in at least # one trace. These runs also re-check the configuration's guarantees, on # longer traces and under the narrower step relations, which get deeper @@ -276,14 +277,6 @@ job holds baseline operatorBlind queuedBytesConfidential txidAuthenti job holds byzHub operatorBlind txidAuthenticity wellFormed job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed -# The trust matrix: each guarantee fails once the component it depends on is -# Byzantine. The schedule guarantees' rows are in tier 4. -job fails byzHub step 40 queuedBytesConfidential -job fails byzHub step 40 lookupValidityPerHub -job fails byzHub step 40 ackImpliesQueued -job fails byzIndexer step 40 queuedBytesConfidential -job fails byzIndexer step 40 lookupValidityPerHub - # The known gaps, with every component honest. job fails baseline quietStep 40 statusNeverRegresses # K2 @@ -298,9 +291,8 @@ job reaches baseline step 40 \ wQueuedDisclosed wThirdPartyPayloadQueued wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS -# W1, W2, W3, W9, and the antecedents of G3, G4. +# The antecedents of G3, G4. job reaches baseline quietStep 80 \ - wPending wTxInMempool wTxMined wUnparseableMissed \ vTxidAuthenticity vLookupValidityPerHub \ -- $BASELINE_HOLDS From dfd41711e0f24ee7ae9fb74371dc06b7f332b78b Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 13:00:25 +0400 Subject: [PATCH 55/80] test(zeronym): cut the disclosure step; Byzantine components leak through replies Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 7 +++- zeronym/spec/protocol/protocol.qnt | 45 +++-------------------- zeronym/spec/protocol/tests/trustTest.qnt | 4 ++ 3 files changed, 15 insertions(+), 41 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index de323a6d..9d58156f 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -140,6 +140,7 @@ definitions they justify. | HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | | The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | +| Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed (C7): a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it, with its control | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | | The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | | The hub's schedule in the protocol specification: its phases, tip, cadence, drain, crash and restart, flight time, and what read them there: G6a-G6c, the refusal witnesses W4, the requeue witnesses W5-W7, the offer, verdict, admission, refusal and drop records, the tip models | Moved: the protocol uses the abstract hub, which `hubTest` checks the real hub refines; the schedule is checked exhaustively in the hub specification. The protocol's pinned runs that need a real hub step are replayed through it in `realisedRunsTest` | @@ -469,7 +470,9 @@ member of a finite set that contains it (F12): - **Byzantine indexer.** Any verdict, with the transaction relayed to the network or not. Any lookup answer built from a payload it was offered, one the chain published, or a twin of either. Any tip up to `MAX_HEIGHT`. -- Any of them may disclose a payload it has seen (`byzDisclose`). +- Neither discloses a payload except in a lookup reply. There is no separate + disclosure step: a Byzantine hub or indexer already leaks through a reply + (`hubServesQueuedBodyTest`, `indexerServesUnpublishedBodyTest`). A hub folds several indexer endpoints into one answer, and the folds are not symmetric: the tip is the maximum over endpoints, a lookup takes the first @@ -528,7 +531,7 @@ it keeps with the shipped one are in `hubMachine.qnt`. | Id | Name | What it says | |---|---|---| | G1 | `operatorBlind` | Everything the shim hands the operator is a pass-through transaction | -| G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it, the operator's view and explicit disclosures; nothing updates it at publication | +| G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it and the operator's view; nothing updates it at publication | | G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | | G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree, or that hubs agree | | G6a | `offeredBeforeExpiry` | Every transaction a hub offers is offered with the mining margin to spare: whatever was admitted, on every attempt. About the margin left when the flush begins, not about acceptance. Claimed under a timely tip | diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 22d2ad12..4ecc8cfa 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -123,8 +123,6 @@ module protocol { /// - `hub`: the hub as the protocol sees it (`abstractHub.qnt`). /// - `operator`: every transaction the shim has handed the operator's /// indexer. The operator is assumed to publish nothing itself. - /// - `disclosed`: payloads a Byzantine component has revealed. It is written - /// by the disclosure step and by nothing else. type System = { indexer: IndexerState, hub: AHub, @@ -133,7 +131,6 @@ module protocol { wallet: Wallet, operator: Set[Payload], thirdParty: ThirdParty, - disclosed: Set[Payload], } /// The system at rest: the chain and the hub empty, nothing sent. The @@ -146,7 +143,6 @@ module protocol { wallet: { log: [], sends: 0, gets: 0 }, operator: Set(), thirdParty: { txids: Set(), own: config.tpPayloads, nextNonce: 0, requests: 0 }, - disclosed: Set(), } /// What an observer of the run has recorded. It is not protocol state: no @@ -255,15 +251,15 @@ module protocol { s.thirdParty.txids.union(s.indexer.txs.keys()) /// The payloads the third party did not make and yet has: the bodies of - /// replies sent to it, whatever reached the operator, and whatever a - /// Byzantine component disclosed. The chain is left out on purpose: what is - /// published is public. + /// replies sent to it, and whatever reached the operator. The chain is left + /// out on purpose: what is published is public. A Byzantine hub or indexer + /// leaks through a lookup reply; there is no other disclosure. /// /// This is derived from what the third party can observe. Nothing updates it /// when a transaction is published, so a property over it constrains what /// the hub puts in its replies. pure def tpLearned(s: System): Set[Payload] = - s.repliedBodies(ThirdPartyAddr).union(s.operator).union(s.disclosed).exclude(s.thirdParty.own) + s.repliedBodies(ThirdPartyAddr).union(s.operator).exclude(s.thirdParty.own) /// Every payload the third party has: what it learned, what the chain /// published, and its own. @@ -283,16 +279,6 @@ module protocol { | _ => acc }) - /// The payloads addressed to the hub by anyone: what it has received or may - /// yet receive. - pure def seenByHub(s: System): Set[Payload] = - Set(ShimAddr, ThirdPartyAddr).map(client => s.submissions(client).map(submit => submit._2)).flatten() - - /// What the Byzantine components, taken together, are able to reveal. - pure def disclosable(s: System, roles: Roles): Set[Payload] = - (if (roles.indexer == Byzantine) s.indexer.offered else Set()) - .union(if (roles.hub == Byzantine) s.seenByHub() else Set()) - // ------------------------------------------------------------------------ // Putting outputs where they go // ------------------------------------------------------------------------ @@ -1044,24 +1030,6 @@ module protocol { }, } - /// What a Byzantine component could still reveal. - def undisclosed: Set[Payload] = - s.disclosable(ROLES).exclude(s.disclosed) - - /// A Byzantine component reveals a payload it has seen. - action byzDiscloseWith(payload: Payload): bool = all { - undisclosed.contains(payload), - commit({ ...s, disclosed: s.disclosed.union(Set(payload)) }), - } - - action byzDisclose = all { - undisclosed != Set(), - { - nondet payload = oneOf(undisclosed) - byzDiscloseWith(payload) - }, - } - // ------------------------------------------------------------------------ // Step // ------------------------------------------------------------------------ @@ -1071,10 +1039,9 @@ module protocol { shimLookupTimeout, hubLose, } - /// A step by someone outside the protocol: the third party, or a Byzantine - /// component revealing what it has seen. + /// A step by someone outside the protocol: the third party. action outsiderStep = any { - thirdPartyLearnsTxid, thirdPartyLookup, thirdPartySubmit, byzDisclose, + thirdPartyLearnsTxid, thirdPartyLookup, thirdPartySubmit, } /// One step of the system. diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index e0dced0f..3697f12f 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -37,6 +37,8 @@ module trustTest { )) .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: AtZero })))) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) + // What it learned came in the body of a reply addressed to it. + .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) run hubServesQueuedBodyControlTest = @@ -150,6 +152,8 @@ module trustTest { )) .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: AtZero })))) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) + // What it learned came in the body of a reply addressed to it. + .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) run indexerServesUnpublishedBodyControlTest = From f162913a777b1cec9640873b4881c09cb300f9df Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 13:02:53 +0400 Subject: [PATCH 56/80] test(zeronym): cut the third party's own payloads Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 7 ++-- zeronym/spec/protocol/check.sh | 4 +- zeronym/spec/protocol/protocol.qnt | 38 ++++++------------- zeronym/spec/protocol/tests/scenariosTest.qnt | 10 ----- zeronym/spec/protocol/types.qnt | 1 - 5 files changed, 17 insertions(+), 43 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 9d58156f..aad7b68e 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -122,7 +122,7 @@ definitions they justify. | Chain / indexer | per-txid status (absent, mempool, mined); what the indexer has been offered; verdict and lookup-answer relations. A lookup answer's height is 0, the height the transaction was mined at, or another (`WireHeight`). The protocol specification has no chain height and no block clock, and its verdict relation has no expiry clause (`heightlessIndexerResults`); the hub specification keeps the chain height, which its tip and expiry rules read | S15, S22 | | Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation; frame size classes | S20, S22 | | Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | -| Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned and payloads of its own making; its payload knowledge is derived from what it can observe | S13, S25 | +| Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned or the chain has published; its payload knowledge is derived from what it can observe | S13, S25 | | Network | drop, duplicate, delay, reorder; cannot forge | | | Hubs | one; see [One hub](#one-hub) | S24 | | Tip | Hub specification only: `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, the reorg allowance, the staleness window and the wallet expiry floor as parameters | S17, S26, S32 | @@ -141,6 +141,7 @@ definitions they justify. | The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | | Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed (C7): a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it, with its control | +| Payloads of the third party's own making, and W17 (one of them queued) | Removed (C8): the hub's address is public and unauthenticated, so this is possible, but only W17 read them. The third party still submits what it has learned or the chain has published (K2c) | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | | The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | | The hub's schedule in the protocol specification: its phases, tip, cadence, drain, crash and restart, flight time, and what read them there: G6a-G6c, the refusal witnesses W4, the requeue witnesses W5-W7, the offer, verdict, admission, refusal and drop records, the tip models | Moved: the protocol uses the abstract hub, which `hubTest` checks the real hub refines; the schedule is checked exhaustively in the hub specification. The protocol's pinned runs that need a real hub step are replayed through it in `realisedRunsTest` | @@ -374,7 +375,6 @@ stateDiagram-v2 KnowsQueued --> KnowsPayload: payload published on chain Nothing --> KnowsPayload: payload published on chain KnowsPayload --> KnowsPayload: may resubmit the payload to any hub - Nothing --> Nothing: may submit payloads of its own making to any hub ``` ### Encoding: hub outcome to wallet observation @@ -629,7 +629,7 @@ frame undelivered, and nothing obliges the network ever to deliver it. ### Witnesses -Each has a scripted run. W8, W16 and W17 are also counted in tier 3b; W1-W3, +Each has a scripted run. W8 and W16 are also counted in tier 3b; W1-W3, W9, W15 and W18 are scripted only. W4 (each refusal) and W5-W7 (requeued, dropped as expired, dropped as exhausted) were witnesses here; they read the hub's internals and are gone @@ -644,7 +644,6 @@ hub specification reaches `wRequeued` under TLC and both drops in | W9 | a queued payload the hub cannot parse is asked for and missed | `wUnparseableMissed` | `baseline` | | W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | scripted run `tipAheadOfChainFlushesEarlyTest` (hub specification) | `byzIndexer` | | W16 | **Twin served**: the wallet is served a twin of what it sent, and a transaction at a false height; G3 holds throughout | `wTwinServed`, `wFalseHeightServed` | `byzHub` | -| W17 | the third party's own payload is queued | `wThirdPartyPayloadQueued` | `baseline` | | W18 | **Early flush by the free-running clock**: a stale hub's clock is ahead of the chain and it flushes before the true boundary, every component honest | scripted run `freeRunningClockFlushesEarlyTest` (hub specification) | `staleLag` | Non-vacuity: for each guarantee, a state where its antecedent holds, reached on diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index da78d6bb..dcf922a5 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -286,9 +286,9 @@ echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued wellFormed" -# W8, W17, K1a, K1b, and the antecedents of G1, G2, G8. +# W8, K1a, K1b, and the antecedents of G1, G2, G8. job reaches baseline step 40 \ - wQueuedDisclosed wThirdPartyPayloadQueued wToldRefusedEverywhere wToldNeverDelivered \ + wQueuedDisclosed wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS # The antecedents of G3, G4. diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 4ecc8cfa..c8bb7c15 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -18,7 +18,7 @@ /// - Network. Frames may be lost, duplicated, delayed and reordered. They /// cannot be forged or read in transit. /// - Third party. A client of the hub's public address. It looks up txids it -/// knows and submits payloads it has learned or made. It cannot read or +/// knows and submits payloads it has learned or the chain has published. It cannot read or /// forge frames, so it does not know a nonce and cannot answer the shim. /// - Nonces are unique. A counter stands for an unguessable random value. /// - Chain. A transaction's status only moves forward: no reorg of an @@ -72,9 +72,6 @@ module protocol { { id: "plain", txid: Some("plain"), created: 1, expiry: Some(9), class: PassThrough, oversize: false } /// Other bytes with the txid of `early`. pure val earlyTwin = { ...early, id: "early-twin" } - /// The third party's own payload. - pure val garbage: Payload = - { id: "garbage", txid: Some("garbage"), created: 1, expiry: None, class: OrchardTouching, oversize: false } // ------------------------------------------------------------------------ // Configurations @@ -86,7 +83,6 @@ module protocol { pure val baseline: Config = { payloads: Set(early, late, tight, junk, plain), twins: Set(earlyTwin), - tpPayloads: Set(garbage), maxRequests: 3, roles: allHonest, } @@ -116,9 +112,8 @@ module protocol { type Wallet = { log: List[WalletEvent], sends: int, gets: int } /// The third party: a client of the hub's public address that is not the - /// shim. `txids` are the transaction ids it has learned out of band; `own` - /// are payloads of its own making. - type ThirdParty = { txids: Set[TxId], own: Set[Payload], nextNonce: Nonce, requests: int } + /// shim. `txids` are the transaction ids it has learned out of band. + type ThirdParty = { txids: Set[TxId], nextNonce: Nonce, requests: int } /// - `hub`: the hub as the protocol sees it (`abstractHub.qnt`). /// - `operator`: every transaction the shim has handed the operator's @@ -142,7 +137,7 @@ module protocol { net: Set(), wallet: { log: [], sends: 0, gets: 0 }, operator: Set(), - thirdParty: { txids: Set(), own: config.tpPayloads, nextNonce: 0, requests: 0 }, + thirdParty: { txids: Set(), nextNonce: 0, requests: 0 }, } /// What an observer of the run has recorded. It is not protocol state: no @@ -259,12 +254,12 @@ module protocol { /// when a transaction is published, so a property over it constrains what /// the hub puts in its replies. pure def tpLearned(s: System): Set[Payload] = - s.repliedBodies(ThirdPartyAddr).union(s.operator).exclude(s.thirdParty.own) + s.repliedBodies(ThirdPartyAddr).union(s.operator) - /// Every payload the third party has: what it learned, what the chain - /// published, and its own. + /// Every payload the third party has: what it learned and what the chain + /// published. pure def tpPayloads(s: System): Set[Payload] = - s.tpLearned().union(s.indexer.published()).union(s.thirdParty.own) + s.tpLearned().union(s.indexer.published()) /// The payloads the wallet has handed the shim: every send is answered at /// once. @@ -622,10 +617,6 @@ module protocol { | _ => false }) - /// W17. The hub has queued a payload of the third party's own making. - pure def wThirdPartyPayloadQueuedIn(s: System): bool = - s.hub.queue.intersect(s.thirdParty.own) != Set() - // ------------------------------------------------------------------------ // Non-vacuity: the antecedent of each guarantee is reachable // ------------------------------------------------------------------------ @@ -671,8 +662,6 @@ module protocol { /// Twins of wallet transactions: other bytes with the same txid. No honest /// party sends one. def TWINS = cfg.twins - /// Payloads of the third party's own making. - def TP_PAYLOADS = cfg.tpPayloads /// The bound of the model: the wallet makes at most `MAX_REQUESTS` sends /// and as many lookups, as does the third party. @@ -681,7 +670,7 @@ module protocol { def ROLES = cfg.roles /// Every payload that exists. A Byzantine component builds its lies from it. - pure def universeOf(c: Config): Set[Payload] = c.payloads.union(c.twins).union(c.tpPayloads) + pure def universeOf(c: Config): Set[Payload] = c.payloads.union(c.twins) def UNIVERSE = universeOf(cfg) /// What a wallet may send. def SEND_INPUTS = PAYLOADS.map(payload => Clean(payload)).union(Set(Unreadable, EmptyBody)) @@ -689,13 +678,11 @@ module protocol { def TXIDS = txidsOf(UNIVERSE) /// The standing assumption, checked by every named init: payloads are told - /// apart by their bytes; a twin is a twin of something a wallet sends; the - /// third party's payloads are its own. + /// apart by their bytes, and a twin is a twin of something a wallet sends. pure def payloadsWellFormed(c: Config): bool = val universe = universeOf(c) and { universe.size() == universe.map(payload => payload.id).size(), - c.payloads.intersect(c.tpPayloads) == Set(), c.twins.forall(twin => c.payloads.exists(payload => areTwins(twin, payload))), } @@ -1013,8 +1000,8 @@ module protocol { }, } - /// The third party submits a payload it has: one it made, or one it has - /// learned. Submission is not authenticated either. + /// The third party submits a payload it has learned or the chain has + /// published. Submission is not authenticated either. action thirdPartySubmitWith(payload: Payload): bool = all { s.thirdParty.requests < MAX_REQUESTS, s.tpPayloads().contains(payload), @@ -1106,7 +1093,6 @@ module protocol { val wUnparseableMissed = wUnparseableMissedIn(s) val wTwinServed = wTwinServedIn(s) val wFalseHeightServed = wFalseHeightServedIn(s) - val wThirdPartyPayloadQueued = wThirdPartyPayloadQueuedIn(s) // Non-vacuity: the antecedent of each guarantee is reachable. val vOperatorBlind = vOperatorBlindIn(s) diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index e5952906..16637c87 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -29,8 +29,6 @@ module scenariosTest { // Every component honest (`baseline`) // ------------------------------------------------------------------------ - def tpAcks = s.acks(ThirdPartyAddr) - // ------------------------------------------------------------------------ // Witnesses // ------------------------------------------------------------------------ @@ -93,14 +91,6 @@ module scenariosTest { .expect(lastEvent == Got({ query: "junk", obs: NotFound, via: Some(1) })) .expect(wUnparseableMissed and lookupValidityPerHub) - /// W17. Anyone can put a payload in a hub's queue. - run thirdPartyPayloadIsQueuedTest = - initBaseline - .then(thirdPartySubmitWith(garbage)) - .then(deliverSubmitFrom(ThirdPartyAddr, 0, garbage)) - .expect(tpAcks == Set((0, WAccepted)) and s.hub.queue == Set(garbage)) - .expect(wThirdPartyPayloadQueued and queuedBytesConfidential) - // ------------------------------------------------------------------------ // K1. Told ok, and no hub ever has it // ------------------------------------------------------------------------ diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/protocol/types.qnt index 93179e1d..aed53c38 100644 --- a/zeronym/spec/protocol/types.qnt +++ b/zeronym/spec/protocol/types.qnt @@ -196,7 +196,6 @@ module types { type Config = { payloads: Set[Payload], twins: Set[Payload], - tpPayloads: Set[Payload], maxRequests: int, roles: Roles, } From 28d0b99e3f284c27200e8778c71590d74c61cc04 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 13:05:38 +0400 Subject: [PATCH 57/80] test(zeronym): cut the size class and the well-formedness invariant Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 14 +++++++------- zeronym/spec/protocol/check.sh | 14 +++++++------- zeronym/spec/protocol/protocol.qnt | 15 --------------- zeronym/spec/protocol/tests/scenariosTest.qnt | 2 +- zeronym/spec/protocol/tests/wireTest.qnt | 10 ---------- zeronym/spec/protocol/wire.qnt | 12 ------------ 6 files changed, 15 insertions(+), 52 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index aad7b68e..490d6dd8 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -20,7 +20,7 @@ depth. A property that "holds" is one no sampled trace violated. runs TLC on the hub specification through `tlc.sh`, not through `quint verify`. Statements that do not rest on sampling are the ones backed by `quint test`: -the functional properties F1-F14 and the two-state properties A2-A3, which are +the functional properties F1-F15 (F6 is cut) and the two-state properties A2-A3, which are exhaustive over small finite universes, and the scripted runs, each of which is one concrete execution. @@ -120,7 +120,7 @@ definitions they justify. | Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | | Hub | In the hub specification: lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash. In the protocol specification: the abstract hub, a queue and the entries out with a flush, which accepts, refuses, takes, settles, gives back and loses (see [The abstraction lemma](#the-abstraction-lemma)) | S10-S19 | | Chain / indexer | per-txid status (absent, mempool, mined); what the indexer has been offered; verdict and lookup-answer relations. A lookup answer's height is 0, the height the transaction was mined at, or another (`WireHeight`). The protocol specification has no chain height and no block clock, and its verdict relation has no expiry clause (`heightlessIndexerResults`); the hub specification keeps the chain height, which its tip and expiry rules read | S15, S22 | -| Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation; frame size classes | S20, S22 | +| Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation | S20, S22 | | Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | | Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned or the chain has published; its payload knowledge is derived from what it can observe | S13, S25 | | Network | drop, duplicate, delay, reorder; cannot forge | | @@ -142,6 +142,7 @@ definitions they justify. | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | | Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed (C7): a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it, with its control | | Payloads of the third party's own making, and W17 (one of them queued) | Removed (C8): the hub's address is public and unauthenticated, so this is possible, but only W17 read them. The third party still submits what it has learned or the chain has published (K2c) | +| The frame-size lemma, `sizeOf` and F6 | Removed (C9): true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | | The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | | The hub's schedule in the protocol specification: its phases, tip, cadence, drain, crash and restart, flight time, and what read them there: G6a-G6c, the refusal witnesses W4, the requeue witnesses W5-W7, the offer, verdict, admission, refusal and drop records, the tip models | Moved: the protocol uses the abstract hub, which `hubTest` checks the real hub refines; the schedule is checked exhaustively in the hub specification. The protocol's pinned runs that need a real hub step are replayed through it in `realisedRunsTest` | @@ -413,13 +414,13 @@ declares a constant. Every other module is pure. | `spells/basicSpells.qnt` | `basicSpells` | `Option`, and a few set and map helpers, each with its test | | `spells/soup.qnt` | `soup` | The message soup: `Envelope[p, m]`, `Soup[p, m]`, `send`, `sendAll`, `inbox`, `outbox` | | `types.qnt` | `types` | The vocabulary: payloads, verdicts, refusals, roles, observations, `Result[s, o]`, `Config` | -| `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply`, `sizeOf` | +| `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply` | | `indexer.qnt` | `indexer` | The chain and indexer as a relation: honest and Byzantine outputs, and their effect | | `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | | `abstractHub.qnt` | `abstractHub` | The hub as the protocol sees it: `AHub`, its honest and Byzantine answers, its internal moves | | `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | | `protocol.qnt` | `protocol` | The transactions and the three configurations; `System`, `Audit`, where each output goes and the derived views; `truth`, the audit monitor `advance`, the guarantees, gaps and witnesses; the variables, `commit`, the named inits, the steps, the property aliases, the run vocabulary | -| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F14; A2-A3 and the abstraction lemma in `hubTest.qnt` | +| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F15; A2-A3 and the abstraction lemma in `hubTest.qnt` | | `tests/realisedRunsTest.qnt` | `realisedRunsTest` | The hub inputs of each pinned run, replayed through the real hub | | `tests/scenariosTest.qnt` | `scenariosTest` | Witnesses and pinned gap causes; `liveInitsTest` | | `tests/trustTest.qnt` | `trustTest` | One run and one control per "required" cell | @@ -515,7 +516,6 @@ it keeps with the shipped one are in `hubMachine.qnt`. | F3 | The shim serves a transaction only if its txid is the one asked for. A twin is served; the height is passed through unchecked | `wireTest::servedOnlyOnMatchingTxidTest` | | F4 | An error never becomes "not found" | `wireTest::errorIsNeverNotFoundTest` | | F5 | The shim forwards only cleanly read pass-through transactions | `shimTest::onlyPassThroughIsForwardedTest` | -| F6 | A frame's size depends on its kind only | `wireTest::sizeIsIndependentOfContentTest` | | F7 | Under the startup budget, a conforming payload arriving within the delivery lag passes the expiry check. This is about admission at one tip, not about when the flush happens | `hubTest::conformingTimelyPayloadIsAdmissibleTest` | | F8 | The admission decision table, in the implementation's order | `hubTest::admissionDecisionTableTest` | | F9 | Requeue, entry by entry, and the counts it reports | `hubTest::requeueTest` | @@ -537,7 +537,7 @@ it keeps with the shipped one are in `hubMachine.qnt`. | G6a | `offeredBeforeExpiry` | Every transaction a hub offers is offered with the mining margin to spare: whatever was admitted, on every attempt. About the margin left when the flush begins, not about acceptance. Claimed under a timely tip | | G6b | `conformingFirstOfferBeforeExpiry` | The same for supported wallets and for the first time a hub offers the transaction. Nothing about a later offer of a requeued entry. Also about the margin at the offer | | G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and `flightWithinMargin` | -| G7 | `wellFormed` | Structural sanity: every nonce in use was minted. Checked in every configuration; not a trust-matrix row. Its hub half, a queued entry within its attempts and a down hub holding nothing, is `hubTest::wellFormedTest` over `REACH` | +| G7 | `hubTest::wellFormedTest` | Structural sanity of the hub: a queued entry is within its attempts and a down hub holds nothing, over every state in `REACH`. Not a trust-matrix row. Its nonce half (every nonce in use was minted) was a trace invariant and is cut (C10): the shim and the third party mint every nonce they send | | G8 | `ackImpliesQueued` | An accepted ack from a hub is for a payload that hub had queued by then, whether or not anyone waits for the ack | No guarantee reads a field written by the function it constrains. The history @@ -1014,7 +1014,7 @@ and Quint fetched by `npx`. ## The protocol specification under TLC (measured once, not a gate) Measured once at step 19, on the all-honest configuration with -`maxRequests` 2 and invariant `wellFormed`, through `tlc.sh` with 4 workers, +`maxRequests` 2 and invariant `wellFormed` (since cut, C10), through `tlc.sh` with 4 workers, an 8 GB heap and a 300 s limit (the plan said 10 minutes; the cap used for every TLC run here is 5). A tier 1-3 gate shared the machine for most of the run. The compiled JSON is 39.2 MB (133.0 MB before step 18, with a constant diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index dcf922a5..32345172 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -271,11 +271,11 @@ finish echo "---- 3 invariants ($SAMPLES traces, seed $SEED)" -# The guarantees, where they are claimed. G7 `wellFormed` is checked everywhere. +# The guarantees, where they are claimed. job holds baseline operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub \ - ackImpliesQueued wellFormed -job holds byzHub operatorBlind txidAuthenticity wellFormed -job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued wellFormed + ackImpliesQueued +job holds byzHub operatorBlind txidAuthenticity +job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued # The known gaps, with every component honest. job fails baseline quietStep 40 statusNeverRegresses # K2 @@ -284,7 +284,7 @@ finish echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" -BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued wellFormed" +BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued" # W8, K1a, K1b, and the antecedents of G1, G2, G8. job reaches baseline step 40 \ @@ -299,10 +299,10 @@ job reaches baseline quietStep 80 \ # W16, both halves. job reaches byzHub quietStep 40 \ vOperatorBlind vTxidAuthenticity wTwinServed wFalseHeightServed \ - -- operatorBlind txidAuthenticity wellFormed + -- operatorBlind txidAuthenticity job reaches byzIndexer quietStep 40 \ vOperatorBlind vTxidAuthenticity vAckImpliesQueued \ - -- operatorBlind txidAuthenticity ackImpliesQueued wellFormed + -- operatorBlind txidAuthenticity ackImpliesQueued finish echo "---- 4 hub specification (TLC, exhaustive)" diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index c8bb7c15..51592237 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -453,20 +453,6 @@ module protocol { | _ => true }) - /// G7. Structural sanity: every nonce in use was minted. - pure def wellFormedIn(s: System): bool = - and { - s.shim.waiters.keys().forall(nonce => nonce < s.shim.nextNonce), - s.net.forall(mail => - match mail.msg { - | Submit(submit) => - submit.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) - | Lookup(lookup) => - lookup.nonce < (if (mail.src == ShimAddr) s.shim.nextNonce else s.thirdParty.nextNonce) - | _ => true - }), - } - /// G8. An accepted ack from the hub is for a payload it had queued by the /// time it acked. It holds whether or not anyone waits for the ack. pure def ackImpliesQueuedIn(s: System, audit: Audit): bool = @@ -1070,7 +1056,6 @@ module protocol { val queuedBytesConfidential = queuedBytesConfidentialIn(s) val txidAuthenticity = txidAuthenticityIn(s) val lookupValidityPerHub = lookupValidityPerHubIn(s, audit) - val wellFormed = wellFormedIn(s) val ackImpliesQueued = ackImpliesQueuedIn(s, audit) // ------------------------------------------------------------------------ diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index 16637c87..54d8719c 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -51,7 +51,7 @@ module scenariosTest { .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtMined }), via: Some(3) })) .expect(wPending and wTxInMempool and wTxMined) .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) - .expect(ackImpliesQueued and wellFormed and statusNeverRegresses) + .expect(ackImpliesQueued and statusNeverRegresses) /// A pass-through transaction goes to the operator and nowhere else. run passThroughIsForwardedTest = diff --git a/zeronym/spec/protocol/tests/wireTest.qnt b/zeronym/spec/protocol/tests/wireTest.qnt index 5b6ac3a3..cbac6e9d 100644 --- a/zeronym/spec/protocol/tests/wireTest.qnt +++ b/zeronym/spec/protocol/tests/wireTest.qnt @@ -96,16 +96,6 @@ module wireTest { run errorIsNeverNotFoundTest = assert(QUERIES.forall(query => interpretReply(WError, query) == Unavailable)) - /// F6. A frame's length depends on its kind and on nothing else. - run sizeIsIndependentOfContentTest = all { - assert(tuples(REPLIES, Set(0, 1)).forall(((reply, nonce)) => - sizeOf(LookupReply({ nonce: nonce, reply: reply })) == FrameSize)), - assert(tuples(PAYLOADS, Set(0, 1)).forall(((payload, nonce)) => - sizeOf(Submit({ nonce: nonce, payload: payload })) == FrameSize)), - assert(sizeOf(Ack({ nonce: 0, ack: WAccepted })) == sizeOf(Ack({ nonce: 1, ack: WRefused(WTipStale) }))), - assert(sizeOf(Lookup({ nonce: 0, txid: "ta" })) == sizeOf(Lookup({ nonce: 1, txid: "tb" }))), - } - /// F10. A draining hub refuses under the queue-full code; a fresh admission /// and a duplicate are one acceptance. Every refusal has its own code. run ackRenderingTest = all { diff --git a/zeronym/spec/protocol/wire.qnt b/zeronym/spec/protocol/wire.qnt index 1cfa1d13..8c655437 100644 --- a/zeronym/spec/protocol/wire.qnt +++ b/zeronym/spec/protocol/wire.qnt @@ -30,18 +30,6 @@ module wire { | Lookup({ nonce: Nonce, txid: TxId }) | LookupReply({ nonce: Nonce, reply: WireReply }) - /// The on-wire length of a frame. Every frame of a kind is padded to one - /// length, so length reveals the kind and nothing about the content. - type SizeClass = FrameSize | AckSize | LookupSize - - pure def sizeOf(msg: Msg): SizeClass = - match msg { - | Submit(_) => FrameSize - | LookupReply(_) => FrameSize - | Ack(_) => AckSize - | Lookup(_) => LookupSize - } - /// A hub's decision as an ack. A draining hub answers under the queue-full /// code, as the implementation does. pure def renderAck(kind: AckKind): WireAck = From c40ea72c0f3f09b99f6b7a4b44885641d2c7a1ea Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 13:07:54 +0400 Subject: [PATCH 58/80] test(zeronym): check universeCoversLies and read messages as records Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 7 +- zeronym/spec/protocol/protocol.qnt | 123 ++++++++---------- zeronym/spec/protocol/spells/basicSpells.qnt | 13 ++ zeronym/spec/protocol/tests/scenariosTest.qnt | 4 +- zeronym/spec/protocol/tests/trustTest.qnt | 12 +- 5 files changed, 82 insertions(+), 77 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 490d6dd8..4c9e720d 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -411,7 +411,7 @@ declares a constant. Every other module is pure. | File | Module | Owns | |---|---|---| -| `spells/basicSpells.qnt` | `basicSpells` | `Option`, and a few set and map helpers, each with its test | +| `spells/basicSpells.qnt` | `basicSpells` | `Option`, `filterMap`, and a few set and map helpers, each with its test | | `spells/soup.qnt` | `soup` | The message soup: `Envelope[p, m]`, `Soup[p, m]`, `send`, `sendAll`, `inbox`, `outbox` | | `types.qnt` | `types` | The vocabulary: payloads, verdicts, refusals, roles, observations, `Result[s, o]`, `Config` | | `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply` | @@ -491,7 +491,10 @@ abstract indexer per hub was a decision of the design. One variable, `cfg`, holds a configuration. It is written by `initWith` and kept by every step; `protocol.qnt` names its fields (`PAYLOADS`, `ROLES`, ...). -Each configuration has a named init whose guard is `payloadsWellFormed`. +Each configuration has a named init whose guard is `payloadsWellFormed`. The +Byzantine inits also check `universeCoversLies`: the universe a lie is built +from holds a wallet payload, its twin, and a payload with another txid, so a +lie can be the twin or a foreign transaction and G3 has something to catch. | Configuration | Init | Roles (hub / indexer) | |---|---|---| diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 51592237..640a3e7c 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -166,74 +166,54 @@ module protocol { /// The payloads the wallet has been told were diverted. pure def toldOk(s: System): Set[Payload] = - s.events().fold(Set(), (acc, event) => + s.events().filterMap(event => match event { | Sent(sent) => match sent.input { - | Clean(payload) => if (sent.obs == SentOk) acc.union(Set(payload)) else acc - | _ => acc + | Clean(payload) => if (sent.obs == SentOk) Some(payload) else None + | _ => None } - | _ => acc + | _ => None }) - /// The submissions `client` has addressed to the hub, as (nonce, payload). - pure def submissions(s: System, client: Addr): Set[(Nonce, Payload)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | Submit(submit) => - if (mail.src == client and mail.dst == HubAddr) acc.union(Set((submit.nonce, submit.payload))) - else acc - | _ => acc - }) + /// The frames `client` has addressed to the hub. + pure def toHub(s: System, client: Addr): Set[Msg] = + s.net.filter(mail => mail.src == client and mail.dst == HubAddr).map(mail => mail.msg) - /// The acks the hub has sent `client`, as (nonce, ack). - pure def acks(s: System, client: Addr): Set[(Nonce, WireAck)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | Ack(ack) => - if (mail.src == HubAddr and mail.dst == client) acc.union(Set((ack.nonce, ack.ack))) - else acc - | _ => acc - }) + /// The frames the hub has addressed to `client`. + pure def fromHub(s: System, client: Addr): Set[Msg] = + s.net.filter(mail => mail.src == HubAddr and mail.dst == client).map(mail => mail.msg) + + /// The submissions `client` has addressed to the hub. + pure def submissions(s: System, client: Addr): Set[{ nonce: Nonce, payload: Payload }] = + s.toHub(client).filterMap(msg => match msg { | Submit(submit) => Some(submit) | _ => None }) + + /// The acks the hub has sent `client`. + pure def acks(s: System, client: Addr): Set[{ nonce: Nonce, ack: WireAck }] = + s.fromHub(client).filterMap(msg => match msg { | Ack(ack) => Some(ack) | _ => None }) /// The payloads the hub has acknowledged as accepted, to any client. pure def acked(s: System): Set[Payload] = Set(ShimAddr, ThirdPartyAddr).map(client => tuples(s.submissions(client), s.acks(client)) - .filter(((submit, ack)) => submit._1 == ack._1 and ack._2 == WAccepted) - .map(((submit, _)) => submit._2) + .filter(((submit, ack)) => submit.nonce == ack.nonce and ack.ack == WAccepted) + .map(((submit, _)) => submit.payload) ).flatten() - /// The lookups `client` has addressed to the hub, as (nonce, txid). - pure def lookups(s: System, client: Addr): Set[(Nonce, TxId)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | Lookup(lookup) => - if (mail.src == client and mail.dst == HubAddr) acc.union(Set((lookup.nonce, lookup.txid))) - else acc - | _ => acc - }) + /// The lookups `client` has addressed to the hub. + pure def lookups(s: System, client: Addr): Set[{ nonce: Nonce, txid: TxId }] = + s.toHub(client).filterMap(msg => match msg { | Lookup(lookup) => Some(lookup) | _ => None }) - /// The lookup replies the hub has addressed to `client`, as (nonce, reply). - pure def replies(s: System, client: Addr): Set[(Nonce, WireReply)] = - s.net.fold(Set(), (acc, mail) => - match mail.msg { - | LookupReply(reply) => - if (mail.src == HubAddr and mail.dst == client) acc.union(Set((reply.nonce, reply.reply))) - else acc - | _ => acc - }) + /// The lookup replies the hub has addressed to `client`. + pure def replies(s: System, client: Addr): Set[{ nonce: Nonce, reply: WireReply }] = + s.fromHub(client).filterMap(msg => match msg { | LookupReply(reply) => Some(reply) | _ => None }) /// The transaction bodies in lookup replies addressed to `client`. pure def repliedBodies(s: System, client: Addr): Set[Payload] = - s.replies(client).fold(Set(), (acc, reply) => - match reply._2 { - | WFound(found) => - match found.body { - | Some(payload) => acc.union(Set(payload)) - | None => acc - } - | _ => acc + s.replies(client).filterMap(reply => + match reply.reply { + | WFound(found) => found.body + | _ => None }) // ------------------------------------------------------------------------ @@ -264,14 +244,14 @@ module protocol { /// The payloads the wallet has handed the shim: every send is answered at /// once. pure def sentByWallet(s: System): Set[Payload] = - s.events().fold(Set(), (acc, event) => + s.events().filterMap(event => match event { | Sent(done) => match done.input { - | Clean(payload) => acc.union(Set(payload)) - | _ => acc + | Clean(payload) => Some(payload) + | _ => None } - | _ => acc + | _ => None }) // ------------------------------------------------------------------------ @@ -394,10 +374,10 @@ module protocol { // their window by what is true at the hub. val waiting = pre.shim.waiters.keys().union(post.shim.waiters.keys()) val windows = post.lookups(ShimAddr) - .filter(lookup => waiting.contains(lookup._1)) + .filter(lookup => waiting.contains(lookup.nonce)) .fold(audit.windows, (acc, lookup) => - val seen = if (acc.keys().contains(lookup._1)) acc.get(lookup._1) else Set() - acc.put(lookup._1, seen.union(Set(truth(post, lookup._2))))) + val seen = if (acc.keys().contains(lookup.nonce)) acc.get(lookup.nonce) else Set() + acc.put(lookup.nonce, seen.union(Set(truth(post, lookup.txid))))) { everQueued: audit.everQueued.union(post.hub.queue), windows: windows } // ------------------------------------------------------------------------ @@ -498,7 +478,7 @@ module protocol { /// The nonces of the shim's submissions of `payload`. pure def shimSubmissionsOf(s: System, payload: Payload): Set[Nonce] = - s.submissions(ShimAddr).filter(submit => submit._2 == payload).map(submit => submit._1) + s.submissions(ShimAddr).filter(submit => submit.payload == payload).map(submit => submit.nonce) /// K1a. The wallet was told ok; every submission that reached the hub was /// refused; the hub never queued the payload. @@ -508,7 +488,7 @@ module protocol { and { not(audit.everQueued.contains(payload)), submitted != Set(), - submitted.forall(nonce => s.acks(ShimAddr).exists(ack => ack._1 == nonce and ack._2 != WAccepted)), + submitted.forall(nonce => s.acks(ShimAddr).exists(ack => ack.nonce == nonce and ack.ack != WAccepted)), }) /// K1b. The wallet was told ok; the hub has answered none of the frames and @@ -517,7 +497,7 @@ module protocol { s.toldOk().exists(payload => and { not(audit.everQueued.contains(payload)), - s.shimSubmissionsOf(payload).forall(nonce => not(s.acks(ShimAddr).exists(ack => ack._1 == nonce))), + s.shimSubmissionsOf(payload).forall(nonce => not(s.acks(ShimAddr).exists(ack => ack.nonce == nonce))), }) // ------------------------------------------------------------------------ @@ -563,7 +543,7 @@ module protocol { /// > is left open deliberately. pure def wQueuedDisclosedIn(s: System): bool = tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => - lookup._1 == reply._1 and reply._2 == WFound({ body: None, height: AtZero })) + lookup.nonce == reply.nonce and reply.reply == WFound({ body: None, height: AtZero })) /// W9. The hub holds a payload it cannot parse; the wallet that sent it asks /// for it and is told not found. An entry without a txid can never @@ -574,7 +554,7 @@ module protocol { | Got(got) => and { got.obs == NotFound, - got.via == Some(lookup._1), + got.via == Some(lookup.nonce), s.hub.queue.exists(payload => payload.txid == None and walletTxid(payload) == got.query), } | _ => false @@ -672,6 +652,15 @@ module protocol { c.twins.forall(twin => c.payloads.exists(payload => areTwins(twin, payload))), } + /// Checked by the Byzantine inits: the universe holds a wallet payload, its + /// twin, and a payload with another txid, so a lie can be the twin or a + /// foreign transaction. + pure def universeCoversLies(c: Config): bool = + c.payloads.exists(payload => and { + c.twins.exists(twin => areTwins(twin, payload)), + c.payloads.exists(other => other.txid != None and other.txid != payload.txid), + }) + // ------------------------------------------------------------------------ // State // ------------------------------------------------------------------------ @@ -695,8 +684,8 @@ module protocol { } action initBaseline = all { payloadsWellFormed(baseline), initWith(baseline) } - action initByzHub = all { payloadsWellFormed(byzHub), initWith(byzHub) } - action initByzIndexer = all { payloadsWellFormed(byzIndexer), initWith(byzIndexer) } + action initByzHub = all { payloadsWellFormed(byzHub), universeCoversLies(byzHub), initWith(byzHub) } + action initByzIndexer = all { payloadsWellFormed(byzIndexer), universeCoversLies(byzIndexer), initWith(byzIndexer) } // ------------------------------------------------------------------------ // Roles @@ -719,10 +708,10 @@ module protocol { def lookupAnswers(state: IndexerState, msg: Msg): Set[IndexerAnswer] = match msg { | Lookup(lookup) => - indexerResults(state, LookupIInput(lookup.txid)).fold(Set(), (acc, result) => + indexerResults(state, LookupIInput(lookup.txid)).filterMap(result => match result.out { - | AnswerOutput(answer) => acc.union(Set(answer)) - | _ => acc + | AnswerOutput(answer) => Some(answer) + | _ => None }) | _ => Set(INotFound) } diff --git a/zeronym/spec/protocol/spells/basicSpells.qnt b/zeronym/spec/protocol/spells/basicSpells.qnt index 9ceac460..522f2c1d 100644 --- a/zeronym/spec/protocol/spells/basicSpells.qnt +++ b/zeronym/spec/protocol/spells/basicSpells.qnt @@ -30,6 +30,19 @@ module basicSpells { assert(unwrapOr(None, 7) == 7), } + /// What `f` keeps of `items`. + pure def filterMap(items: Set[a], f: a => Option[b]): Set[b] = + items.fold(Set(), (acc, elem) => + match f(elem) { + | Some(kept) => acc.union(Set(kept)) + | None => acc + }) + + run filterMapTest = all { + assert(Set(1, 2, 3).filterMap(n => if (n > 1) Some(n * 10) else None) == Set(20, 30)), + assert(Set(1).filterMap(n => if (n > 1) Some(n) else None) == Set()), + } + /// `items` without `elem`. pure def setRemove(items: Set[a], elem: a): Set[a] = items.exclude(Set(elem)) diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index 54d8719c..a9fdb781 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -69,7 +69,7 @@ module scenariosTest { .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: AtZero })))) + .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: None, height: AtZero }) })) .expect(wQueuedDisclosed) .expect(s.tpLearned() == Set() and queuedBytesConfidential) @@ -103,7 +103,7 @@ module scenariosTest { .then(sendToHub(tight)) .then(answerSubmitFrom(ShimAddr, 0, tight, WRefused(WExpiryTooTight))) .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) - .expect(s.acks(ShimAddr) == Set((0, WRefused(WExpiryTooTight)))) + .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WRefused(WExpiryTooTight) })) .expect(audit.everQueued == Set()) .expect(wToldRefusedEverywhere) diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 3697f12f..3f7de6e1 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -35,7 +35,7 @@ module trustTest { fromThirdParty(lookupMail(0, "early")), INotFound, { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: AtZero })))) }, )) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: AtZero })))) + .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: Some(early), height: AtZero }) })) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) // What it learned came in the body of a reply addressed to it. .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) @@ -47,7 +47,7 @@ module trustTest { .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: None, height: AtZero })))) + .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: None, height: AtZero }) })) .expect(queuedBytesConfidential) /// G4 needs the hub. It answers not found for a transaction it has queued. @@ -101,7 +101,7 @@ module trustTest { initByzHub .then(sendToHub(early)) .then(hubReceiveWith(submitMail(0, early), INotFound, { hub: s.hub, reply: AAck(WAccepted) })) - .expect(s.acks(ShimAddr) == Set((0, WAccepted))) + .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WAccepted })) .expect(s.hub.queue == Set() and audit.everQueued == Set()) .expect(not(ackImpliesQueued)) @@ -109,7 +109,7 @@ module trustTest { initByzHub .then(sendToHub(early)) .then(deliverSubmit(0, early)) - .expect(s.acks(ShimAddr) == Set((0, WAccepted)) and s.hub.queue == Set(early)) + .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WAccepted }) and s.hub.queue == Set(early)) .expect(ackImpliesQueued) /// G3 survives. The hub answers with another transaction; the shim compares @@ -150,7 +150,7 @@ module trustTest { .then(deliverLookupFrom( ThirdPartyAddr, 0, "early", IFound({ body: Some(early), height: AtZero }), )) - .expect(s.replies(ThirdPartyAddr) == Set((0, WFound({ body: Some(early), height: AtZero })))) + .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: Some(early), height: AtZero }) })) .expect(s.tpLearned() == Set(early) and s.onChain("early") == Absent) // What it learned came in the body of a reply addressed to it. .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) @@ -164,7 +164,7 @@ module trustTest { .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set((0, WNotFound))) + .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WNotFound })) .expect(queuedBytesConfidential) /// G4 needs the indexer. For a transaction that exists nowhere it answers From da3f129ef3fdc5d26cb247849eed2643d092d5b2 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 13:10:14 +0400 Subject: [PATCH 59/80] test(zeronym): redo the mutation table on the final protocol spec Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 4c9e720d..9708931b 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -548,17 +548,20 @@ the guarantees need (`audit`) is derived by `commit` from the state before and the state after each step. Each guarantee can be broken by a change to an honest component. These were -tried by hand, with the result shown, and reverted: +tried on the current specification, one at a time, and reverted. A simulation +is 2000 traces of 40 steps at seed 7 under `step`, unless a step is named. -| Guarantee | Change | Result | -|---|---|---| -| F1 | `interpretReply` loses the pending arm | F1 fails | -| G1 | `shim` forwards an unparseable body | violated on `baseline` | -| G2 | `hub` answers a queue hit with the queued body | violated on `baseline` | -| G3 | `interpretReply` skips the txid comparison | **holds on `baseline`**; violated on `byzHub` and `byzIndexer` | -| G4 | `hub` answers not-found on a queue hit | violated on `baseline` | -| G8 | `hub` acks accepted without inserting | violated on `baseline` | -| G6a | `hub` admits without the expiry check | violated on `baseline` | +| Guarantee | Change | Checked by | Result | +|---|---|---|---| +| F1 | `interpretReply` loses the pending arm | `renderThenInterpretIsMeaningTest` | fails | +| G1 | `shim` forwards an unparseable body | `operatorBlind` on `baseline` | violated | +| G1 | `shim` forwards a migration | `operatorBlind` on `baseline` | violated | +| G2 | the abstract hub answers a queue hit with the queued body | `queuedBytesConfidential` on `baseline` | violated | +| G3 | `interpretReply` skips the txid comparison | `txidAuthenticity` | **holds on `baseline`** (also under `quietStep`, 80 steps); violated on `byzHub` and `byzIndexer` (`quietStep`) | +| G4 | the abstract hub answers not-found on a queue hit | `lookupValidityPerHub` on `baseline` | violated | +| G8 | the abstract hub acks accepted without queueing | `ackImpliesQueued` on `baseline` | violated | +| G8 | `hub` acks accepted without inserting | `abstractionTest` (the lemma) | fails | +| G6a | `hub` admits without the expiry check | `offeredBeforeExpiry` on the hub specification's `initTimely`, TLC | violated, 8 states | The G3 row is not what was predicted; see [Findings](#findings). @@ -714,7 +717,8 @@ Over the same `REACH` as A2 and A3, with lookups added, `hubTest` checks: | `realisesTest` | Each abstract move (accept, refuse, take, settle, a retryable verdict, give back, lose) has a concrete step that projects onto it | A temporary edit that makes `hub` ack a submission without queueing it fails -`abstractionTest`. +`abstractionTest` (the G8 rows of the mutation table under +[Guarantees](#guarantees)). The protocol specification's hub is this abstract one. What the lemma transfers: an invariant that holds over the abstract hub, and @@ -794,7 +798,7 @@ and by a requeue that drops an entry as expired after an outage honest.** Removing the comparison from `interpretReply` leaves G3 holding on `baseline`, because an honest hub and indexer never return another transaction. It fails on `byzHub` and `byzIndexer`, which is where the check is -claimed to matter. G3 is kept as a guarantee: it is falsifiable where it is +claimed to matter. Rerun on the current specification, with the same result. G3 is kept as a guarantee: it is falsifiable where it is claimed "by the check", and other changes to honest code would break it on `baseline`. From 0720a331d7aa5946f9917a8fb9893c91e8fe86d7 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 13:20:44 +0400 Subject: [PATCH 60/80] test(zeronym): final README pass and the measured CI budget Co-authored-by: Cursor --- .github/workflows/zeronym-guards.yml | 3 +- zeronym/spec/protocol/README.md | 63 +++++++++++++++------------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index 55e94cae..d7fec0e4 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -60,7 +60,8 @@ jobs: # The standalone Quint specification of the whole protocol: typecheck, tests, # bounded random simulation, and the hub specification checked exhaustively # with TLC, which needs Java. The Apalache distribution that carries TLC is - # fetched by Quint on first use. + # fetched by Quint on first use. About 9.5 minutes on a 16-core machine; + # the limit allows a runner three times slower. spec-protocol: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 9708931b..87712d79 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -12,8 +12,8 @@ modify. ## What "holds" means here **Bounded random simulation.** Every "holds" below was produced by -`quint run`: fixed constants, at most 40, 60 or 80 steps per trace, 2000 -random traces per run (a few rows more), one seed. It is not a proof and it is not exhaustive to any +`quint run`: fixed constants, at most 40 or 80 steps per trace, 2000 +random traces per run, one seed. It is not a proof and it is not exhaustive to any depth. A property that "holds" is one no sampled trace violated. **`quint verify` has not been run** on any part of this specification. Tier 4 @@ -39,17 +39,17 @@ either the tier fails. |---|---|---|---| | 1 | typecheck | `quint typecheck` on every file | ok | | 2 | tests | `quint test` on the spells and every test file | all pass | -| 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` ("fails" rows: 40 to 80 steps, a few with more traces) | "holds" rows hold; "fails" rows are violated | +| 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` | "holds" rows hold; the "fails" row is violated | | 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | | 4 | hub specification | `tlc.sh hubMachine.qnt hubMachine `, one row each | "holds" rows hold over every reachable state; "violated" rows are violated, by a counterexample no longer than the recorded one | -Measured on the machine it was written on (Apple silicon, Quint's Rust -evaluator): 4 min 40 s wall with four rows at a time (`QUINT_JOBS=4`, the -default), about 14 minutes of CPU. It has not been timed on a CI runner. -`QUINT_SAMPLES` changes the trace count. The rarest witnesses are reached in -only 2 to 6 of the 2000 traces, so a lower count risks losing them. Five -"fails" rows have a larger count of their own, written on the row. - -Tier 3 "fails" rows and tier 3b run under `step` or under a narrower +Measured on the machine it was written on (Apple silicon, 16 cores, Quint's +Rust evaluator): 9 min 27 s wall for all four tiers with four rows at a time +(`QUINT_JOBS=4`, the default), of which tiers 1 to 3 are about 2 minutes. It +has not been timed on a CI runner. `QUINT_SAMPLES` changes the trace count. +The rarest witness, `vLookupValidityPerHub`, is reached in 2 of the 2000 +traces, so a lower count risks losing it. + +The tier 3 "fails" row and tier 3b run under `step` or under a narrower relation, `quietStep` (no faults, no outsiders). It is a part of `step`, so a state or a violation found under it is reachable under `step`. Uniform random choice over `step` rarely gets a transaction as far as a block in 40 steps; the narrower relations @@ -149,7 +149,7 @@ definitions they justify. | A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | -| Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties. Only the pure lemma "frame size is independent of content" is stated | +| Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties | | Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | | Forward-only shim, transparent-pool RPCs, health / address / attestation endpoints, DoS bounds, logging | Not divert-protocol state | | More than one Byzantine component at once | The trust matrix is single-fault | @@ -220,7 +220,7 @@ beyond loss in the soup. - **Network.** May lose, duplicate, delay and reorder frames. Cannot forge or read them. - **Third party.** A client of the hub's public address. It looks up txids it - knows and submits payloads it has learned or made. It cannot read or forge + knows and submits payloads it has learned or the chain has published. It cannot read or forge frames, so it does not know a nonce and cannot answer the shim. - **Nonces** are unique. A counter stands for an unguessable value. - **Chain.** A transaction's status only moves forward: no reorg of an included @@ -252,6 +252,8 @@ beyond loss in the soup. - **Honest indexer.** Answers lookups from chain state or "unavailable". A broadcast may always be rejected or left unjudged; it is accepted only if a node would take it, and reported already-known only if the chain has it. + The protocol specification has no heights, so there a node takes any + parseable transaction the chain does not have, whatever its expiry. - **Time.** There is no clock. A timeout may happen at any moment; the staleness window is counted in blocks. @@ -465,12 +467,13 @@ takes exactly the transition its function gives. A Byzantine one takes any member of a finite set that contains it (F12): - **Byzantine hub.** Any ack for a submission it receives, with the payload - queued or not, whatever admission says. Any reply to a lookup: a queue hit, - not found, error, or found with no body or any payload that exists, at any - height. It keeps the honest flush schedule. + queued or not. Any reply to a lookup: not found, error, or found with no + body or any payload of the universe, at any of the three wire heights. Its + internal moves (take, settle, give back, lose) are the honest ones. - **Byzantine indexer.** Any verdict, with the transaction relayed to the network or not. Any lookup answer built from a payload it was offered, one - the chain published, or a twin of either. Any tip up to `MAX_HEIGHT`. + the chain published, or a twin of either, at any of the three wire heights. + In the hub specification it also reports any tip. - Neither discloses a payload except in a lookup reply. There is no separate disclosure step: a Byzantine hub or indexer already leaks through a reply (`hubServesQueuedBodyTest`, `indexerServesUnpublishedBodyTest`). @@ -609,6 +612,9 @@ exhaustive test, and its "required" cell is a scripted step. ### Known gaps, with every component honest +K1 and K2 are on the protocol specification; K3 to K8 on the hub +specification, under its configurations. + | Id | What is lost | Where | Form | Observed | Scripted runs | |---|---|---|---|---|---| | K1 | Told ok does not mean the hub ever admits it | `baseline` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | @@ -616,11 +622,11 @@ exhaustive test, and its "required" cell is a scripted step. | K3 | G6a for a tight-expiry transaction: admitted against a tip reported below a boundary already flushed | `flakyTip` | violated invariant | violated, as predicted | `tightExpiryAdmittedBehindFlushedBoundaryTest` | | K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | | K4 | G6a, and G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | -| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `baseline` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `toldOkAdmittedThenLostTest`, `ackedThenLostAtDrainTest`, `requeueAndDropTest` | +| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `timely` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueAndDropTest` | | K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | | K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest`; contrast `conformingSurvivesRegressionTest` | -| K8 | A supported wallet's transaction, acknowledged on time, then lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. G6b and G6c do not cover it: to the restarted hub the resend is a late first arrival | `flakyTip` (hub specification) | scripted run | shown; not a TLC row | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | +| K8 | A supported wallet's transaction, acknowledged on time, then lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. G6b and G6c do not cover it: to the restarted hub the resend is a late first arrival | `flakyTip` | scripted run | shown; not a TLC row | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | K7 was added after review. The four-term budget (`reorgSlackFits`) holds with equality in the shipped constants, so a transaction that uses all of it is @@ -880,7 +886,7 @@ Not built. The specification is shaped so it can be: - Each step gives one input to one component function and applies one output; the pairs map onto the seams in the table above. - All protocol state is in `s`. `audit` is a monitor a harness ignores. -- ITF variable names are qualified by configuration. Model nonces are counters, +- ITF traces carry `cfg`, `s` and `audit`. Model nonces are counters, to be bound to real nonces as frames appear. A payload's `id` maps to a fixture. The frames a step emits are `s.net` after it minus before. @@ -960,7 +966,7 @@ K5 under `quietStep` (10, now 9), `wStale` on `staleLag` (9, now 6). With the capacity refusals removed, a hub may hold all three payloads at once, and the tier was re-run. Every verdict and every trace length is unchanged. `timely` still exhausts at 229 339 states, depth 51; `flakyTip` -grows from 1 468 808 to 1 753 204 states, depth 44. Two rows then missed the +grows from 1 468 808 to 1 753 204 states, depth 43. Two rows then missed the five-minute limit: G6b on `flakyTipSlowFlight` (1 824 007 states at depth 30, 152 337 on the queue) and G6c on `byzIndexer` with one worker (1 229 802 states at depth 17). Those two configurations are now checked with two @@ -1005,8 +1011,8 @@ of TLC's counterexample in states. The three rows in bold are finding 8 under the first definition. Simulation, of either machine, does not find the counterexample in the traces it samples; -TLC does. The protocol gate's rows are still simulated on the whole-protocol -machine, with timeliness as first defined, until they are removed from it. +TLC does. The protocol gate's column is from before the move; those rows +have since been removed from it. Configuration in the state against configuration as a constant, on `timely` with G6a, G6b and G6c: the compiled JSON is 13.7 MB with named inits and @@ -1020,12 +1026,11 @@ and Quint fetched by `npx`. ## The protocol specification under TLC (measured once, not a gate) -Measured once at step 19, on the all-honest configuration with -`maxRequests` 2 and invariant `wellFormed` (since cut, C10), through `tlc.sh` with 4 workers, -an 8 GB heap and a 300 s limit (the plan said 10 minutes; the cap used for -every TLC run here is 5). A tier 1-3 gate shared the machine for most of the -run. The compiled JSON is 39.2 MB (133.0 MB before step 18, with a constant -and an instance module). TLC did not exhaust it: after 300 s it had +Measured once, on the all-honest configuration with `maxRequests` 2 and +invariant `wellFormed` (since cut, C10), through `tlc.sh` with 4 workers, an +8 GB heap and a 300 s limit. A tier 1-3 gate shared the machine for most of +the run. The compiled JSON is 39.2 MB (133.0 MB with the configuration as a +constant and an instance module). TLC did not exhaust it: after 300 s it had 6 942 646 distinct states at depth 11, with 5 392 316 still on the queue, and a resident set of 6.3 GB. The queue grew by about 1.2 million states a minute throughout (0.10 M at 4 s, 1.66 M at 64 s, 2.99 M, 4.20 M, 5.39 M at From 154bde49707b1a9446f9faa353379812229514ed Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 15:40:40 +0400 Subject: [PATCH 61/80] test(zeronym): record review round 2 in the README and give TLC its own CI job Co-authored-by: Cursor --- .github/workflows/zeronym-guards.yml | 36 +++++++++++++++---- zeronym/spec/protocol/README.md | 52 ++++++++++++++++++++++------ zeronym/spec/protocol/check.sh | 12 ++++++- zeronym/spec/protocol/protocol.qnt | 2 +- 4 files changed, 82 insertions(+), 20 deletions(-) diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index d7fec0e4..0a727522 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -57,14 +57,31 @@ jobs: - name: Divert protocol model run: sh zeronym/spec/check.sh - # The standalone Quint specification of the whole protocol: typecheck, tests, - # bounded random simulation, and the hub specification checked exhaustively - # with TLC, which needs Java. The Apalache distribution that carries TLC is - # fetched by Quint on first use. About 9.5 minutes on a 16-core machine; - # the limit allows a runner three times slower. + # The standalone Quint specification of the whole protocol: typecheck, tests + # and bounded random simulation. About 2 minutes on a 16-core machine. spec-protocol: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 15 + # Same reasoning as `spec`: it runs code fetched at run time. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Protocol specification, tiers 1 to 3b + run: sh zeronym/spec/protocol/check.sh + env: + CHECK_TIERS: simulation + + # The hub specification checked exhaustively with TLC, which needs Java. The + # Apalache distribution that carries TLC is fetched by Quint on first use. + # About 7.5 minutes on a 16-core machine with four rows at a time. Rows that + # expect a violation run TLC on one worker, so the trace lengths are + # shortest; two rows at a time with 6 GB each fit a 4-core, 16 GB runner. + spec-protocol-tlc: + runs-on: ubuntu-latest + timeout-minutes: 45 # Same reasoning as `spec`: it runs code fetched at run time. permissions: contents: read @@ -76,8 +93,13 @@ jobs: with: distribution: temurin java-version: '21' - - name: Protocol specification + - name: Hub specification, tier 4 run: sh zeronym/spec/protocol/check.sh + env: + CHECK_TIERS: tlc + QUINT_JOBS: '2' + TLC_HEAP: 6g + TLC_TIMEOUT: '900' tests: runs-on: blacksmith-8vcpu-ubuntu-2404 diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 87712d79..42da2c68 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -33,7 +33,9 @@ sh zeronym/spec/protocol/check.sh Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; set `QUINT=quint` to use an installed one). Tier 4 needs Java (21 in CI) and Apalache 0.62.1, whose jar carries TLC and which Quint fetches into `~/.quint` on first use; without -either the tier fails. +either the tier fails. `CHECK_TIERS=simulation` runs tiers 1 to 3b and +`CHECK_TIERS=tlc` tiers 1 and 4; CI runs them as two jobs, the TLC one with +`QUINT_JOBS=2 TLC_HEAP=6g TLC_TIMEOUT=900`. | Tier | What | Command | Expectation | |---|---|---|---| @@ -228,6 +230,16 @@ beyond loss in the soup. - **Hub.** In the protocol specification the hub is abstract: it may accept or refuse any submission, and take, settle, give back or lose its entries at any time. The three assumptions below are the hub specification's. +- **Byzantine hub.** A Byzantine hub lies only in what it acks and replies: + on a submit it may queue the payload or not and send any ack, and on a + lookup it may send any reply (see [Roles](#roles)). Every other move is the + honest one: in the hub specification its flushes, verdicts, requeues, drain, + crash and restart; in the protocol specification its take, settle, give back + and lose. It cannot evict or withhold a queued entry, flush off schedule, or + send a frame nobody asked for. The rows that hold under a Byzantine hub hold + under this model: G1 does not read the hub, G3 holds because the shim + compares txids on every reply (so an unsolicited reply would change + nothing), and A2 follows from the model itself. - **Flight time.** At most `MAX_FLIGHT_BLOCKS` blocks arrive while one flush is in flight, and that is fewer than the mining margin (`flightWithinMargin`). The implementation bounds each call to the indexer @@ -596,9 +608,9 @@ chain cannot pass a running, idle hub that has not asked. | G3 | holds (`baseline`) | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | | G4 | holds (`baseline`) | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | | G8 | holds (`baseline`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | -| G6a | holds (`baseline`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | -| G6b | holds (`baseline`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | -| G6c | holds (`baseline`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| G6a | holds (`timely`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | +| G6b | holds (`timely`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| G6c | holds (`timely`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | A3 | holds (`drainIsFinalTest`) | **required**: `hubAdmitsWhileDrainingTest` | holds (`drainIsFinalTest`) | There is no Byzantine-shim column: the shim sees every migration in plaintext @@ -622,7 +634,7 @@ specification, under its configurations. | K3 | G6a for a tight-expiry transaction: admitted against a tip reported below a boundary already flushed | `flakyTip` | violated invariant | violated, as predicted | `tightExpiryAdmittedBehindFlushedBoundaryTest` | | K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | | K4 | G6a, and G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | -| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `timely` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueAndDropTest` | +| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `timely` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | | K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | | K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest`; contrast `conformingSurvivesRegressionTest` | @@ -672,8 +684,7 @@ Both are steps of the hub function, so they are checked on the hub alone, in `REACH` is the closure of `starting` under: - submits of `pA` (Orchard-touching, expiry 9) and `pJunk` (unparseable), - each through the honest hub and through every Byzantine result, with - Byzantine heights drawn from 0 and 4; + each through the honest hub and through every Byzantine result; - tips 0, 4, 5, 6 and 9, and stale reports at 4, 8 and 9; - `FlushDue`, `FlushDone`, `Drain`, `Crash` and `Restart`; - each of the four verdicts on each payload; @@ -683,9 +694,18 @@ reorg allowance 1). `reachTest` checks that `REACH` is closed under all of these, so the checks below are exhaustive over those parameters, not depth-bounded. +These are not the hub specification's parameters (`timely`: mining margin 2, +expiry floor 7, which `realisedRunsTest` also uses), and `REACH` has one +parseable payload, no twin and no tight payload. The abstraction lemma is +carried to the hub specification's parameters by argument, not by a check: +`hub()` takes its parameters as arguments, and the abstract hub has none and +reads only queue membership and wire replies. `REACH` was not run at +`timely`'s parameters; as an exhaustive closure it would very likely not +finish. + | Id | Test | What it says | Class | |---|---|---|---| -| A2 | `neverEvictTest` | An entry leaves the hub's queue only into a flush, or because the hub went down or exited after its final flush | guarantee, any role | +| A2 | `neverEvictTest` | An entry leaves the hub's queue only into a flush, or because the hub went down or exited after its final flush | guarantee, any role, under the Byzantine-hub model ([Assumptions](#assumptions)) | | A3 | `drainIsFinalTest` | A draining honest hub's queue gains only what a flush hands back | guarantee, honest hub | A2 is stated whatever the hub's role: the Byzantine submit relation only ever @@ -735,7 +755,7 @@ stopped or stale, so a violation or a reached state shown over it may not happen. `tests/realisedRunsTest.qnt` closes that gap for the pinned runs: for K1a, K2 (a) to (e), W8, W9, W16, and each "required" run of the trust matrix, it replays the hub inputs of the run through the real hub function from a -starting hub on the `baseline` schedule, each lie as a member of the +starting hub on `timely`'s parameters, each lie as a member of the Byzantine relation, and checks the replies and the final queue. K1b has no hub step. @@ -798,7 +818,7 @@ as `ackedIsHeldOrSettled`: held by the hub, or on the chain, or judged by a node. That is violated by a crash (`ackedThenCrashedTest`), by a draining hub's final flush that finds the indexer unreachable (`ackedThenLostAtDrainTest`), and by a requeue that drops an entry as expired after an outage -(`requeueAndDropTest`). The third was not predicted; simulation found it. +(`requeueDropsAckedAsExpiredTest`). The third was not predicted; simulation found it. **4. G3 does not depend on the shim's txid check when every component is honest.** Removing the comparison from `interpretReply` leaves G3 holding on @@ -898,7 +918,9 @@ observer keeps four sets of payloads and one height and no history, which is what lets TLC visit every reachable state. `tlc.sh FILE MAIN INIT STEP INVARIANT` checks one invariant of one configuration and prints `holds ` or `violated `; anything else, -including a run that TLC has not finished in five minutes, is a failure. +including a run that TLC has not finished within `TLC_TIMEOUT` (five minutes +by default, 15 in CI), is a failure. No recorded verdict or trace length +depends on the limit. A configuration is a value held in the state and selected by a named init (`initTimely`, ...), whose guard is the assumptions that configuration is @@ -1026,6 +1048,14 @@ and Quint fetched by `npx`. ## The protocol specification under TLC (measured once, not a gate) +The protocol state is still one record, `s: System`. A planned split into +separate variables, with the audit recorded by each action, was not done, by +decision during the work. `Audit` has the fields the split would have used, +`everQueued` and `windows`, but `advance(audit, pre, post)` still computes +them by comparing the whole state before and after each step. The +measurement below is of that unsplit machine, so it does not say whether the +split would make the protocol specification checkable. + Measured once, on the all-honest configuration with `maxRequests` 2 and invariant `wellFormed` (since cut, C10), through `tlc.sh` with 4 workers, an 8 GB heap and a 300 s limit. A tier 1-3 gate shared the machine for most of diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 32345172..70e3dbd6 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -31,7 +31,8 @@ # QUINT defaults to `npx @informalsystems/quint@0.33.0`. QUINT_BACKEND=typescript # skips the Rust evaluator, which is downloaded from GitHub on first use; the # sample counts and seeds below were settled on the Rust evaluator. -# QUINT_JOBS is how many rows run at once. +# QUINT_JOBS is how many rows run at once. CHECK_TIERS picks what runs after +# tier 1: `simulation` (2 to 3b), `tlc` (4) or `all`, the default. set -u cd "$(dirname "$0")" @@ -39,6 +40,11 @@ QUINT=${QUINT:-"npx --yes @informalsystems/quint@0.33.0"} BACKEND=${QUINT_BACKEND:-rust} SAMPLES=${QUINT_SAMPLES:-2000} JOBS=${QUINT_JOBS:-4} +TIERS=${CHECK_TIERS:-all} +case $TIERS in + all | simulation | tlc) ;; + *) echo "CHECK_TIERS is '$TIERS', not all, simulation or tlc" >&2; exit 2 ;; +esac SEED=7 failures=0 @@ -263,6 +269,7 @@ if [ "$failures" -ne 0 ]; then exit "$failures" fi +if [ "$TIERS" != tlc ]; then echo "---- 2 tests" for file in $SPELLS $FUNCTIONAL; do job run_tests "$file" @@ -304,7 +311,9 @@ job reaches byzIndexer quietStep 40 \ vOperatorBlind vTxidAuthenticity vAckImpliesQueued \ -- operatorBlind txidAuthenticity ackImpliesQueued finish +fi +if [ "$TIERS" != simulation ]; then echo "---- 4 hub specification (TLC, exhaustive)" G6A=offeredBeforeExpiry @@ -374,5 +383,6 @@ job tlc_violated initFlakyTipSlowFlight step "not(wBlockInFlight)" 7 job tlc_violated initStaleLag step "not(wStale)" 6 job tlc_violated initStaleLagWithSlack step "not(wStale)" 6 finish +fi exit "$failures" diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 640a3e7c..f0d48de5 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -1037,7 +1037,7 @@ module protocol { // Guarantees // ------------------------------------------------------------------------ // - // The predicates are defined, and documented, in `properties.qnt`, each as + // The predicates are defined, and documented, above, each as // `In(..)`: a predicate over a system and an audit record. These are // their values in the current state, under the names the gate checks. From 8950187452c9bd9d724ed8a4618375d545d14856 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 15:59:01 +0400 Subject: [PATCH 62/80] test(zeronym): forget offers with the run, link realisations to their runs, witness G2's reply body Co-authored-by: Cursor --- .github/workflows/zeronym-guards.yml | 2 +- zeronym/spec/protocol/README.md | 33 +++- zeronym/spec/protocol/check.sh | 5 +- zeronym/spec/protocol/hubMachine.qnt | 28 +-- zeronym/spec/protocol/protocol.qnt | 8 + .../spec/protocol/tests/hubScenariosTest.qnt | 6 +- zeronym/spec/protocol/tests/realisations.qnt | 182 ++++++++++++++++++ .../spec/protocol/tests/realisedRunsTest.qnt | 102 ++-------- zeronym/spec/protocol/tests/scenariosTest.qnt | 23 +++ zeronym/spec/protocol/tests/trustTest.qnt | 7 + 10 files changed, 288 insertions(+), 108 deletions(-) create mode 100644 zeronym/spec/protocol/tests/realisations.qnt diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index 0a727522..f574a296 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -76,7 +76,7 @@ jobs: # The hub specification checked exhaustively with TLC, which needs Java. The # Apalache distribution that carries TLC is fetched by Quint on first use. - # About 7.5 minutes on a 16-core machine with four rows at a time. Rows that + # About 8.5 minutes on a 16-core machine with four rows at a time. Rows that # expect a violation run TLC on one worker, so the trace lengths are # shortest; two rows at a time with 6 GB each fit a 4-core, 16 GB runner. spec-protocol-tlc: diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 42da2c68..4d4a91a5 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -45,8 +45,9 @@ either the tier fails. `CHECK_TIERS=simulation` runs tiers 1 to 3b and | 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | | 4 | hub specification | `tlc.sh hubMachine.qnt hubMachine `, one row each | "holds" rows hold over every reachable state; "violated" rows are violated, by a counterexample no longer than the recorded one | Measured on the machine it was written on (Apple silicon, 16 cores, Quint's -Rust evaluator): 9 min 27 s wall for all four tiers with four rows at a time -(`QUINT_JOBS=4`, the default), of which tiers 1 to 3 are about 2 minutes. It +Rust evaluator): about 10 minutes wall for all four tiers with four rows at +a time (`QUINT_JOBS=4`, the default), of which tiers 1 to 3 are about 2 +minutes. It has not been timed on a CI runner. `QUINT_SAMPLES` changes the trace count. The rarest witness, `vLookupValidityPerHub`, is reached in 2 of the 2000 traces, so a lower count risks losing it. @@ -435,7 +436,8 @@ declares a constant. Every other module is pure. | `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | | `protocol.qnt` | `protocol` | The transactions and the three configurations; `System`, `Audit`, where each output goes and the derived views; `truth`, the audit monitor `advance`, the guarantees, gaps and witnesses; the variables, `commit`, the named inits, the steps, the property aliases, the run vocabulary | | `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F15; A2-A3 and the abstraction lemma in `hubTest.qnt` | -| `tests/realisedRunsTest.qnt` | `realisedRunsTest` | The hub inputs of each pinned run, replayed through the real hub | +| `tests/realisations.qnt` | `realisations` | The hub inputs, replies and final hub of each pinned run, and `realisedBy`, which each of those runs ends with. No runs of its own | +| `tests/realisedRunsTest.qnt` | `realisedRunsTest` | Each realisation, replayed through the real hub | | `tests/scenariosTest.qnt` | `scenariosTest` | Witnesses and pinned gap causes; `liveInitsTest` | | `tests/trustTest.qnt` | `trustTest` | One run and one control per "required" cell | @@ -653,7 +655,7 @@ frame undelivered, and nothing obliges the network ever to deliver it. ### Witnesses -Each has a scripted run. W8 and W16 are also counted in tier 3b; W1-W3, +Each has a scripted run. W8, W16 and W19 are also counted in tier 3b; W1-W3, W9, W15 and W18 are scripted only. W4 (each refusal) and W5-W7 (requeued, dropped as expired, dropped as exhausted) were witnesses here; they read the hub's internals and are gone @@ -669,11 +671,13 @@ hub specification reaches `wRequeued` under TLC and both drops in | W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | scripted run `tipAheadOfChainFlushesEarlyTest` (hub specification) | `byzIndexer` | | W16 | **Twin served**: the wallet is served a twin of what it sent, and a transaction at a false height; G3 holds throughout | `wTwinServed`, `wFalseHeightServed` | `byzHub` | | W18 | **Early flush by the free-running clock**: a stale hub's clock is ahead of the chain and it flushes before the true boundary, every component honest | scripted run `freeRunningClockFlushesEarlyTest` (hub specification) | `staleLag` | +| W19 | A third party is served a published transaction's bytes from the indexer: the branch of G2 that `vQueuedBytesConfidential` does not reach on `baseline`, where `plain` at the operator satisfies it | `wThirdPartyServedBody` | `baseline` | Non-vacuity: for each guarantee, a state where its antecedent holds, reached on every configuration where the guarantee is claimed: `vOperatorBlind`, -`vQueuedBytesConfidential`, `vTxidAuthenticity`, `vLookupValidityPerHub` (the -log has a pending, a served transaction and a not-found), `vAckImpliesQueued`. +`vQueuedBytesConfidential` (with W19 for its reply-body branch), +`vTxidAuthenticity`, `vLookupValidityPerHub` (the log has a pending, a served +transaction and a not-found), `vAckImpliesQueued`. The antecedents of G6a, G6b and G6c are reachability rows of the hub specification. @@ -757,7 +761,10 @@ K1a, K2 (a) to (e), W8, W9, W16, and each "required" run of the trust matrix, it replays the hub inputs of the run through the real hub function from a starting hub on `timely`'s parameters, each lie as a member of the Byzantine relation, and checks the replies and the final queue. K1b has no -hub step. +hub step. Each realisation is a value in `tests/realisations.qnt`, and each of +those protocol runs ends with `realisedBy`: the hub replies it sent, as a set +(the soup has no order), and its final abstract hub are the realisation's. A +protocol run edited without its realisation fails. No liveness property is claimed: the network may lose everything, and nobody waits for an ack. @@ -882,6 +889,13 @@ throughout, and the reachability row `wTimelyQueuedBehindEpoch` shows that a timely payload does get queued while the cadence epoch is behind the one the hub last recorded, so the "holds" is not vacuous there. +"First offer" is forgotten with them. A restarted hub's entries start again +at no attempts (`zeronym/hub/src/queue.rs:334`), so the observer's `offered` +is cleared whenever `seen` and `onTime` are. Until review round 2 it was kept +across a crash, and a payload offered before a crash and resent on time +afterwards had its first flight after the restart left out of G6b and G6c. +Clearing it changes no verdict and no trace length (see the TLC section). + The three facts the trace rests on were read in the code, and the model has each right. A restarted hub has no tip and no recorded epoch, and its first observation adopts the current epoch without flushing @@ -999,6 +1013,11 @@ migrations, and `byzIndexer` with `early` and `tight`, which violated in 17 states, in 100 s with one worker; the other five rows have their recorded lengths. +With `offered` forgotten when the hub goes down, as `seen` and `onTime` are +(finding 8), the tier was re-run. Every verdict and every trace length is +unchanged. The state counts fall: `timely` 141 492 states, depth 51; +`flakyTip` 1 424 284, depth 43; `flakyTipSlowFlight` 156 352, depth 39. + Reachability, each as `not(..)` and each violated: on `timely`, `wOfferWithExpiry` (6 states), `wConformingFirstOffer` (6), `wConformingFirstOfferInFlightABlock` (7), `wOffered` (7), `wRequeued` (9), diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 70e3dbd6..b04055f1 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -293,9 +293,10 @@ echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued" -# W8, K1a, K1b, and the antecedents of G1, G2, G8. +# W8, W19, K1a, K1b, and the antecedents of G1, G2, G8. W19 is G2's reply-body +# branch, which `vQueuedBytesConfidential` does not reach on its own. job reaches baseline step 40 \ - wQueuedDisclosed wToldRefusedEverywhere wToldNeverDelivered \ + wQueuedDisclosed wThirdPartyServedBody wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS # The antecedents of G3, G4. diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/protocol/hubMachine.qnt index afa1b369..4ace7fcd 100644 --- a/zeronym/spec/protocol/hubMachine.qnt +++ b/zeronym/spec/protocol/hubMachine.qnt @@ -154,11 +154,14 @@ module hubMachine { /// The payloads that have entered this hub's queue since it last came up. var seen: Set[Payload] /// Those that first did so within the delivery lag of the height they were - /// built at. Both are forgotten when the hub goes down, as its queue is: a - /// hub cannot be held to an arrival it no longer knows of. + /// built at. var onTime: Set[Payload] - /// The payloads a flight has carried, once that flight has had a node's - /// answer for them or has ended. + /// The payloads a flight of this run of the process has carried, once that + /// flight has had a node's answer for them or has ended. `seen`, `onTime` + /// and `offered` are forgotten together when the hub goes down, as its + /// queue is: a hub cannot be held to an arrival it no longer knows of, and + /// a restarted hub's entries start again at no attempts + /// (`zeronym/hub/src/queue.rs:334`). var offered: Set[Payload] /// The payloads acknowledged as accepted that no node has judged since. var owed: Set[Payload] @@ -275,7 +278,9 @@ module hubMachine { action chainKept = all { height' = height, onChain' = onChain, polled' = polled } action admissionsKept = all { seen' = seen, onTime' = onTime } - action admissionsForgotten = all { seen' = Set(), onTime' = Set() } + /// The hub went down. `owed` is kept: an acknowledged payload lost with the + /// process is K5. + action runForgotten = all { seen' = Set(), onTime' = Set(), offered' = Set() } action memoryKept = all { admissionsKept, offered' = offered, owed' = owed } /// The hub takes `input` on its own schedule. A step that would change @@ -375,8 +380,7 @@ module hubMachine { all { hubTakes(FlushDueHInput), flightStart' = if (result.state.flush == Idle) 0 else height, - if (result.state.phase == Stopped) admissionsForgotten else admissionsKept, - offered' = offered, + if (result.state.phase == Stopped) runForgotten else all { admissionsKept, offered' = offered }, owed' = owed, chainKept, } @@ -414,10 +418,10 @@ module hubMachine { val result = hub(h, FlushDoneHInput) all { hubTakes(FlushDoneHInput), - offered' = offered.union(h.inFlight()), flightStart' = 0, owed' = owed, - if (result.state.phase == Stopped) admissionsForgotten else admissionsKept, + if (result.state.phase == Stopped) runForgotten + else all { admissionsKept, offered' = offered.union(h.inFlight()) }, chainKept, } @@ -433,10 +437,9 @@ module hubMachine { /// out is over. action crash = all { hubTakes(CrashHInput), - offered' = offered.union(h.inFlight()), flightStart' = 0, owed' = owed, - admissionsForgotten, + runForgotten, chainKept, } @@ -543,7 +546,8 @@ module hubMachine { def isConformingAndOnTime(payload: Payload): bool = conforming(payload, cfg.params.minWalletExpiry) and onTime.contains(payload) - /// Whether the flight `payload` is on, if any, is its first. + /// Whether the flight `payload` is on, if any, is its first since the hub + /// last came up. def isFirstOffer(payload: Payload): bool = not(offered.contains(payload)) diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index f0d48de5..49d71786 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -560,6 +560,13 @@ module protocol { | _ => false }) + /// W19. A third party is given a transaction's bytes in a lookup reply. + /// With every component honest these are published bytes, served from the + /// indexer: the branch of G2 that `vQueuedBytesConfidential` alone does not + /// reach, since `plain` at the operator satisfies it. + pure def wThirdPartyServedBodyIn(s: System): bool = + s.repliedBodies(ThirdPartyAddr) != Set() + /// W16a. The wallet is served a twin of what it sent: other bytes, same txid. pure def wTwinServedIn(s: System): bool = s.wasGiven(obs => @@ -1065,6 +1072,7 @@ module protocol { val wTxMined = wTxMinedIn(s) val wQueuedDisclosed = wQueuedDisclosedIn(s) val wUnparseableMissed = wUnparseableMissedIn(s) + val wThirdPartyServedBody = wThirdPartyServedBodyIn(s) val wTwinServed = wTwinServedIn(s) val wFalseHeightServed = wFalseHeightServedIn(s) diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/protocol/tests/hubScenariosTest.qnt index cf74bfbd..122e93fe 100644 --- a/zeronym/spec/protocol/tests/hubScenariosTest.qnt +++ b/zeronym/spec/protocol/tests/hubScenariosTest.qnt @@ -162,8 +162,8 @@ module hubScenariosTest { .expect(not(ackedIsHeldOrSettled)) /// K5b. The final flush of a draining hub finds the indexer unreachable. - /// The entry was offered and nothing judged it; the hub stops, and the - /// transaction is held nowhere. + /// The entry was offered and nothing judged it; the hub stops, forgetting + /// the offer with the rest of its run, and the transaction is held nowhere. run ackedThenLostAtDrainTest = started(timely) .then(block) @@ -171,7 +171,7 @@ module hubScenariosTest { .then(drain) .then(flush([early], Retryable)) .expect(h.phase == Stopped and h.queued() == Set() and h.inFlight() == Set() and onChain == Set()) - .expect(offered == Set(early) and owed == Set(early)) + .expect(offered == Set() and owed == Set(early)) .expect(not(ackedIsHeldOrSettled)) /// K5c. A requeue gives an acknowledged entry up as expired. No crash and diff --git a/zeronym/spec/protocol/tests/realisations.qnt b/zeronym/spec/protocol/tests/realisations.qnt new file mode 100644 index 00000000..6629f28e --- /dev/null +++ b/zeronym/spec/protocol/tests/realisations.qnt @@ -0,0 +1,182 @@ +// -*- mode: Bluespec; -*- + +/// The hub's part of each pinned protocol run that shows a violation or a +/// reached state: the inputs the real hub sees in that run, the replies the +/// run relies on, in order, and the hub as the protocol sees it at the end. +/// +/// `realisedRunsTest` replays each through the real hub function. Each +/// protocol run ends with `realisedBy`, so the two cannot drift apart. The +/// soup has no order, so a protocol run is compared on the set of replies. +/// +/// No runs here: a module that imports this one does not run them again. +/// +/// K1b has no hub step: the frame is never delivered. +module realisations { + import basicSpells.* from "../spells/basicSpells" + import types.* from "../types" + import wire.* from "../wire" + import hub.* from "../hub" + import abstractHub.* from "../abstractHub" + import protocol.* from "../protocol" + + /// A step the hub takes, or a lie: a member of the Byzantine relation, with + /// the state unchanged. + type Step = Do(HubInput) | Lie({ input: HubInput, out: HubOutput }) + + type Realisation = { steps: List[Step], replies: List[AReply], last: AHub } + + pure def sub(nonce: Nonce, payload: Payload): HubInput = SubmitHInput({ nonce: nonce, payload: payload }) + pure def look(nonce: Nonce, txid: TxId, answer: IndexerAnswer): HubInput = + LookupHInput({ nonce: nonce, txid: txid, answer: answer }) + pure def judged(payload: Payload, verdict: Verdict): HubInput = + VerdictHInput({ payload: payload, verdict: verdict }) + pure def found(body: Option[Payload], height: WireHeight): IndexerAnswer = IFound({ body: body, height: height }) + pure def tips(heights: List[Height]): List[Step] = heights.foldl([], (acc, h) => acc.append(Do(TipHInput(h)))) + pure def does(inputs: List[HubInput]): List[Step] = inputs.foldl([], (acc, i) => acc.append(Do(i))) + + pure def flushed(payload: Payload, verdict: Verdict): List[Step] = + does([FlushDueHInput, judged(payload, verdict), FlushDoneHInput]) + + /// One lie about `early`, in answer to a lookup. + pure def lieAbout(nonce: Nonce, answer: IndexerAnswer, outcome: HubOutcome): Step = + Lie({ input: look(nonce, "early", answer), out: LookupReplyOutput({ nonce: nonce, outcome: outcome }) }) + + pure val accepted = AAck(WAccepted) + pure val pending = AWire(WFound({ body: None, height: AtZero })) + pure val inMempool = AWire(WFound({ body: Some(early), height: AtZero })) + pure val queuedEarly = { queue: Set(early), held: Set() } + + /// Running at height 2 with `early` queued, as most runs begin. + pure val earlyQueued = tips([1, 2]).append(Do(sub(0, early))) + /// The same, flushed and accepted at height 3. + pure val earlyPublished = earlyQueued.concat(tips([3])).concat(flushed(early, Accepted)) + + // ------------------------------------------------------------------------ + // Every component honest + // ------------------------------------------------------------------------ + + /// K1a: refused after the flush at 3. + pure val k1a: Realisation = { + steps: tips([1, 2, 3]).concat(does([FlushDueHInput, sub(0, tight)])), + replies: [AAck(WRefused(WExpiryTooTight))], + last: emptyAHub, + } + + /// K2a: pending, then in the mempool. + pure val k2a: Realisation = { + steps: earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Accepted)) + .append(Do(look(2, "early", found(Some(early), AtZero)))), + replies: [accepted, pending, inMempool], + last: emptyAHub, + } + + /// K2b and K2c: the resubmission comes from the wallet or the third party. + pure val k2bc: Realisation = { + steps: earlyPublished.append(Do(look(1, "early", found(Some(early), AtZero)))).append(Do(sub(2, early))) + .append(Do(look(3, "early", found(Some(early), AtZero)))), + replies: [accepted, inMempool, accepted, pending], + last: queuedEarly, + } + + /// K2d: the flush window. + pure val k2d: Realisation = { + steps: earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).append(Do(FlushDueHInput)) + .append(Do(look(2, "early", INotFound))), + replies: [accepted, pending, AWire(WNotFound)], + last: { queue: Set(), held: Set(early) }, + } + + /// K2e: rejected at flush. + pure val k2e: Realisation = { + steps: earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Rejected)) + .append(Do(look(2, "early", INotFound))), + replies: [accepted, pending, AWire(WNotFound)], + last: emptyAHub, + } + + /// W8: a third party is told `early` is queued. + pure val w8: Realisation = { + steps: earlyQueued.append(Do(look(0, "early", INotFound))), + replies: [accepted, pending], + last: queuedEarly, + } + + /// W9, the unknown upgrade: queued and missed. + pure val w9: Realisation = { + steps: tips([1]).concat(does([sub(0, junk), look(1, "junk", INotFound)])), + replies: [accepted, AWire(WNotFound)], + last: { queue: Set(junk), held: Set() }, + } + + // ------------------------------------------------------------------------ + // The "required" runs of the trust matrix and W16, each one lie + // ------------------------------------------------------------------------ + + /// G2, the hub: the queued bytes, to a third party. + pure val hubLeaksBody: Realisation = { + steps: earlyQueued.append(lieAbout(0, INotFound, FromIndexer(found(Some(early), AtZero)))), + replies: [accepted, inMempool], + last: queuedEarly, + } + + /// G4, the hub: not found for a queued transaction. + pure val hubDeniesQueued: Realisation = { + steps: earlyQueued.append(lieAbout(1, INotFound, FromIndexer(INotFound))), + replies: [accepted, AWire(WNotFound)], + last: queuedEarly, + } + + /// G4, the hub: a false height. + pure val hubFalseHeight: Realisation = { + steps: earlyPublished.append(lieAbout(1, found(Some(early), AtZero), FromIndexer(found(Some(early), AtOther)))), + replies: [accepted, AWire(WFound({ body: Some(early), height: AtOther }))], + last: emptyAHub, + } + + /// G8, the hub: accepted and not queued. + pure val hubAcksUnqueued: Realisation = { + steps: tips([1, 2]).append(Lie({ input: sub(0, early), out: AckOutput({ nonce: 0, kind: Admitted }) })), + replies: [accepted], + last: emptyAHub, + } + + /// W16: a twin at a false height. + pure val w16: Realisation = { + steps: earlyQueued.append(lieAbout(1, INotFound, FromIndexer(found(Some(earlyTwin), AtOther)))), + replies: [accepted, AWire(WFound({ body: Some(earlyTwin), height: AtOther }))], + last: queuedEarly, + } + + /// G2, the indexer: the honest hub forwards the unpublished bytes. + pure val indexerLeaksBody: Realisation = { + steps: earlyQueued.concat(tips([3])).concat(does([FlushDueHInput, judged(early, Retryable), + look(0, "early", found(Some(early), AtZero))])), + replies: [accepted, inMempool], + last: { queue: Set(), held: Set(early) }, + } + + /// G4, the indexer: a forged pending for a transaction nobody queued. + pure val indexerForgesPending: Realisation = { + steps: tips([1, 2]).append(Do(look(1, "early", found(None, AtZero)))), + replies: [pending], + last: emptyAHub, + } + + // ------------------------------------------------------------------------ + // The link to the protocol runs + // ------------------------------------------------------------------------ + + /// Every ack and lookup reply the hub has sent, to any client. + pure def hubReplies(s: System): Set[AReply] = + Set(ShimAddr, ThirdPartyAddr).map(client => + s.acks(client).map(ack => AAck(ack.ack)).union(s.replies(client).map(reply => AWire(reply.reply))) + ).flatten() + + /// The protocol run that ends in `s` has the hub replies and the final hub + /// of `r`. + pure def realisedBy(s: System, r: Realisation): bool = + and { + s.hubReplies() == r.replies.foldl(Set(), (acc, reply) => acc.union(Set(reply))), + s.hub == r.last, + } +} diff --git a/zeronym/spec/protocol/tests/realisedRunsTest.qnt b/zeronym/spec/protocol/tests/realisedRunsTest.qnt index 1d8434aa..c1268c3a 100644 --- a/zeronym/spec/protocol/tests/realisedRunsTest.qnt +++ b/zeronym/spec/protocol/tests/realisedRunsTest.qnt @@ -1,16 +1,10 @@ // -*- mode: Bluespec; -*- -/// The hub's part of each pinned protocol run that shows a violation or a -/// reached state, replayed through the real hub function. +/// Each realisation in `realisations.qnt`, replayed through the real hub +/// function on the hub specification's `timely` schedule. /// /// The protocol spec's hub answers wherever the real one would refuse or /// error, so a protocol run proves nothing about reachability on its own. -/// Each realisation here is the sequence of inputs the hub sees in that run, -/// on the hub specification's `timely` schedule, with the replies the run -/// relies on. A lie is a -/// member of the Byzantine relation, with the state unchanged. -/// -/// K1b has no hub step: the frame is never delivered. module realisedRunsTest { import basicSpells.* from "../spells/basicSpells" import types.* from "../types" @@ -18,6 +12,7 @@ module realisedRunsTest { import hub.* from "../hub" import abstractHub.* from "../abstractHub" import protocol.* from "../protocol" + import realisations.* from "./realisations" /// The hub specification's `timely` schedule. pure val PARAMS: HubParams = { @@ -31,20 +26,6 @@ module realisedRunsTest { /// What a Byzantine hub builds its lies from. pure val LIES = universeOf(baseline) - type Step = Do(HubInput) | Lie({ input: HubInput, out: HubOutput }) - - pure def sub(nonce: Nonce, payload: Payload): HubInput = SubmitHInput({ nonce: nonce, payload: payload }) - pure def look(nonce: Nonce, txid: TxId, answer: IndexerAnswer): HubInput = - LookupHInput({ nonce: nonce, txid: txid, answer: answer }) - pure def judged(payload: Payload, verdict: Verdict): HubInput = - VerdictHInput({ payload: payload, verdict: verdict }) - pure def found(body: Option[Payload], height: WireHeight): IndexerAnswer = IFound({ body: body, height: height }) - pure def tips(heights: List[Height]): List[Step] = heights.foldl([], (acc, h) => acc.append(Do(TipHInput(h)))) - pure def does(inputs: List[HubInput]): List[Step] = inputs.foldl([], (acc, i) => acc.append(Do(i))) - - pure def flushed(payload: Payload, verdict: Verdict): List[Step] = - does([FlushDueHInput, judged(payload, verdict), FlushDoneHInput]) - pure def replyOf(output: HubOutput): List[AReply] = match output { | AckOutput(ack) => [AAck(renderAck(ack.kind))] @@ -67,71 +48,26 @@ module realisedRunsTest { { state: result.state, ok: acc.ok and taken, replies: acc.replies.concat(result.out.replyOf()) }) { ok: end.ok, replies: end.replies, last: { queue: end.state.queued(), held: end.state.inFlight() } } - pure def realises(steps: List[Step], replies: List[AReply], last: AHub): bool = - replay(steps) == { ok: true, replies: replies, last: last } - - pure val accepted = AAck(WAccepted) - pure val pending = AWire(WFound({ body: None, height: AtZero })) - pure val inMempool = AWire(WFound({ body: Some(early), height: AtZero })) - pure val queuedEarly = { queue: Set(early), held: Set() } - - /// Running at height 2 with `early` queued, as most runs begin. - pure val earlyQueued = tips([1, 2]).append(Do(sub(0, early))) - /// The same, flushed and accepted at height 3. - pure val earlyPublished = earlyQueued.concat(tips([3])).concat(flushed(early, Accepted)) + pure def realises(r: Realisation): bool = + replay(r.steps) == { ok: true, replies: r.replies, last: r.last } run realisedRunsTest = all { - // K1a: refused after the flush at 3. - assert(realises(tips([1, 2, 3]).concat(does([FlushDueHInput, sub(0, tight)])), - [AAck(WRefused(WExpiryTooTight))], emptyAHub)), - // K2a: pending, then in the mempool. - assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Accepted)) - .append(Do(look(2, "early", found(Some(early), AtZero)))), - [accepted, pending, inMempool], emptyAHub)), - // K2b and K2c (the resubmission comes from the wallet or the third party). - assert(realises(earlyPublished.append(Do(look(1, "early", found(Some(early), AtZero)))).append(Do(sub(2, early))) - .append(Do(look(3, "early", found(Some(early), AtZero)))), - [accepted, inMempool, accepted, pending], queuedEarly)), - // K2d: the flush window. - assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).append(Do(FlushDueHInput)) - .append(Do(look(2, "early", INotFound))), - [accepted, pending, AWire(WNotFound)], { queue: Set(), held: Set(early) })), - // K2e: rejected at flush. - assert(realises(earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Rejected)) - .append(Do(look(2, "early", INotFound))), - [accepted, pending, AWire(WNotFound)], emptyAHub)), - // W8: a third party is told `early` is queued. - assert(realises(earlyQueued.append(Do(look(0, "early", INotFound))), [accepted, pending], queuedEarly)), - // W9, the unknown upgrade: queued and missed. - assert(realises(tips([1]).concat(does([sub(0, junk), look(1, "junk", INotFound)])), - [accepted, AWire(WNotFound)], { queue: Set(junk), held: Set() })), + assert(realises(k1a)), + assert(realises(k2a)), + assert(realises(k2bc)), + assert(realises(k2d)), + assert(realises(k2e)), + assert(realises(w8)), + assert(realises(w9)), } - /// The "required" runs of the trust matrix and W16, each one lie. - pure def lieAbout(nonce: Nonce, answer: IndexerAnswer, outcome: HubOutcome): Step = - Lie({ input: look(nonce, "early", answer), out: LookupReplyOutput({ nonce: nonce, outcome: outcome }) }) - run realisedByzantineRunsTest = all { - // G2, the hub: the queued bytes, to a third party. - assert(realises(earlyQueued.append(lieAbout(0, INotFound, FromIndexer(found(Some(early), AtZero)))), - [accepted, inMempool], queuedEarly)), - // G4, the hub: not found for a queued transaction. - assert(realises(earlyQueued.append(lieAbout(1, INotFound, FromIndexer(INotFound))), - [accepted, AWire(WNotFound)], queuedEarly)), - // G4, the hub: a false height. - assert(realises(earlyPublished.append(lieAbout(1, found(Some(early), AtZero), FromIndexer(found(Some(early), AtOther)))), - [accepted, AWire(WFound({ body: Some(early), height: AtOther }))], emptyAHub)), - // G8, the hub: accepted and not queued. - assert(realises(tips([1, 2]).append(Lie({ input: sub(0, early), out: AckOutput({ nonce: 0, kind: Admitted }) })), - [accepted], emptyAHub)), - // W16: a twin at a false height. - assert(realises(earlyQueued.append(lieAbout(1, INotFound, FromIndexer(found(Some(earlyTwin), AtOther)))), - [accepted, AWire(WFound({ body: Some(earlyTwin), height: AtOther }))], queuedEarly)), - // G2, the indexer: the honest hub forwards the unpublished bytes. - assert(realises(earlyQueued.concat(tips([3])).concat(does([FlushDueHInput, judged(early, Retryable), - look(0, "early", found(Some(early), AtZero))])), - [accepted, inMempool], { queue: Set(), held: Set(early) })), - // G4, the indexer: a forged pending for a transaction nobody queued. - assert(realises(tips([1, 2]).append(Do(look(1, "early", found(None, AtZero)))), [pending], emptyAHub)), + assert(realises(hubLeaksBody)), + assert(realises(hubDeniesQueued)), + assert(realises(hubFalseHeight)), + assert(realises(hubAcksUnqueued)), + assert(realises(w16)), + assert(realises(indexerLeaksBody)), + assert(realises(indexerForgesPending)), } } diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index a9fdb781..cc02738c 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -18,6 +18,7 @@ module scenariosTest { import abstractHub.* from "../abstractHub" import shim.* from "../shim" import protocol.* from "../protocol" + import realisations as R from "./realisations" /// Every named init is live: its guard holds and it starts a run. run liveInitsTest = @@ -72,6 +73,20 @@ module scenariosTest { .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: None, height: AtZero }) })) .expect(wQueuedDisclosed) .expect(s.tpLearned() == Set() and queuedBytesConfidential) + .expect(R::realisedBy(s, R::w8)) + + /// W19. Once `early` is published its txid is public, and a third party + /// that asks is served its bytes from the indexer. G2 holds: they are + /// public. + run thirdPartyIsServedPublishedBodyTest = + initBaseline + .then(submitTo(0, early)) + .then(flush([early], Accepted)) + .then(thirdPartyLookupWith("early")) + .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", chainAnswer(s.indexer, "early"))) + .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: Some(early), height: AtZero }) })) + .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) + .expect(wThirdPartyServedBody and vQueuedBytesConfidential and queuedBytesConfidential) /// A lookup that gets no reply in time fails closed, and G4 holds of it. run lookupTimesOutTest = @@ -90,6 +105,7 @@ module scenariosTest { .then(lookUp(1, "junk")) .expect(lastEvent == Got({ query: "junk", obs: NotFound, via: Some(1) })) .expect(wUnparseableMissed and lookupValidityPerHub) + .expect(R::realisedBy(s, R::w9)) // ------------------------------------------------------------------------ // K1. Told ok, and no hub ever has it @@ -106,6 +122,7 @@ module scenariosTest { .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WRefused(WExpiryTooTight) })) .expect(audit.everQueued == Set()) .expect(wToldRefusedEverywhere) + .expect(R::realisedBy(s, R::k1a)) /// K1b. The frame is never delivered. Nothing obliges the network to. run toldOkAndNeverDeliveredTest = @@ -138,6 +155,7 @@ module scenariosTest { ]) // Each answer was true when it was given. .expect(not(statusNeverRegresses) and lookupValidityPerHub) + .expect(R::realisedBy(s, R::k2a)) /// K2b. The wallet sends published bytes again. The hub's memory of them /// went with the flush, so they are admitted and pending once more. @@ -155,6 +173,7 @@ module scenariosTest { Got({ query: "early", obs: Pending, via: Some(3) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) + .expect(R::realisedBy(s, R::k2bc)) /// K2c. The same, done by a third party: the bytes are public once /// published, and submission is open to anyone. @@ -172,6 +191,7 @@ module scenariosTest { Got({ query: "early", obs: Pending, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) + .expect(R::realisedBy(s, R::k2bc)) /// K2d. The flush window: the queue is empty and the chain does not have /// the batch yet. @@ -187,6 +207,7 @@ module scenariosTest { Got({ query: "early", obs: NotFound, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) + .expect(R::realisedBy(s, R::k2d)) /// K2e. The node rejects the transaction at flush. It was pending; now it /// is nowhere. @@ -202,6 +223,7 @@ module scenariosTest { Got({ query: "early", obs: NotFound, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) + .expect(R::realisedBy(s, R::k2e)) // ------------------------------------------------------------------------ // A Byzantine hub (`byzHub`) @@ -223,4 +245,5 @@ module scenariosTest { .expect(s.onChain("early") == Absent) .expect(wTwinServed and wFalseHeightServed) .expect(txidAuthenticity and not(lookupValidityPerHub)) + .expect(R::realisedBy(s, R::w16)) } diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 3f7de6e1..d2670181 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -19,6 +19,7 @@ module trustTest { import abstractHub.* from "../abstractHub" import shim.* from "../shim" import protocol.* from "../protocol" + import realisations as R from "./realisations" // ------------------------------------------------------------------------ // A Byzantine hub (`byzHub`) @@ -40,6 +41,7 @@ module trustTest { // What it learned came in the body of a reply addressed to it. .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) + .expect(R::realisedBy(s, R::hubLeaksBody)) run hubServesQueuedBodyControlTest = initByzHub @@ -63,6 +65,7 @@ module trustTest { .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) .expect(s.hub.queue == Set(early) and audit.windows.get(1) == Set(Pending)) .expect(not(lookupValidityPerHub)) + .expect(R::realisedBy(s, R::hubDeniesQueued)) run hubDeniesQueuedControlTest = initByzHub @@ -87,6 +90,7 @@ module trustTest { .expect(s.onChain("early") == InMempool) .expect(audit.windows.get(1) == Set(Tx({ payload: early, height: AtZero }))) .expect(not(lookupValidityPerHub) and txidAuthenticity) + .expect(R::realisedBy(s, R::hubFalseHeight)) run hubServesFalseHeightControlTest = initByzHub @@ -104,6 +108,7 @@ module trustTest { .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WAccepted })) .expect(s.hub.queue == Set() and audit.everQueued == Set()) .expect(not(ackImpliesQueued)) + .expect(R::realisedBy(s, R::hubAcksUnqueued)) run hubAcksWithoutAdmittingControlTest = initByzHub @@ -155,6 +160,7 @@ module trustTest { // What it learned came in the body of a reply addressed to it. .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) + .expect(R::realisedBy(s, R::indexerLeaksBody)) run indexerServesUnpublishedBodyControlTest = initByzIndexer @@ -179,6 +185,7 @@ module trustTest { .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) .expect(audit.everQueued == Set() and audit.windows.get(1) == Set(NotFound)) .expect(not(lookupValidityPerHub)) + .expect(R::realisedBy(s, R::indexerForgesPending)) run indexerForgesPendingControlTest = initByzIndexer From 21fe2ae2ca11414db9dfc31b7e85a751ae2708f3 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 16:07:14 +0400 Subject: [PATCH 63/80] test(zeronym): a narrower step for G4's antecedent and a floor on each test file's count Co-authored-by: Cursor --- zeronym/spec/protocol/README.md | 12 ++++++----- zeronym/spec/protocol/check.sh | 33 +++++++++++++++++------------- zeronym/spec/protocol/protocol.qnt | 8 ++++++++ 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index 4d4a91a5..c4a8e599 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -40,7 +40,7 @@ either the tier fails. `CHECK_TIERS=simulation` runs tiers 1 to 3b and | Tier | What | Command | Expectation | |---|---|---|---| | 1 | typecheck | `quint typecheck` on every file | ok | -| 2 | tests | `quint test` on the spells and every test file | all pass | +| 2 | tests | `quint test` on the spells and every test file | all pass, and each file reports at least the count `check.sh` gives it | | 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` | "holds" rows hold; the "fails" row is violated | | 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | | 4 | hub specification | `tlc.sh hubMachine.qnt hubMachine `, one row each | "holds" rows hold over every reachable state; "violated" rows are violated, by a counterexample no longer than the recorded one | @@ -49,11 +49,12 @@ Rust evaluator): about 10 minutes wall for all four tiers with four rows at a time (`QUINT_JOBS=4`, the default), of which tiers 1 to 3 are about 2 minutes. It has not been timed on a CI runner. `QUINT_SAMPLES` changes the trace count. -The rarest witness, `vLookupValidityPerHub`, is reached in 2 of the 2000 -traces, so a lower count risks losing it. +The rarest witness, W19 (`wThirdPartyServedBody`), is reached in 10 of the +2000 traces, so a lower count risks losing it. The tier 3 "fails" row and tier 3b run under `step` or under a narrower -relation, `quietStep` (no faults, no outsiders). It is a part of `step`, so a +relation: `quietStep` (no faults, no outsiders), or `earlyLookupStep` (only +`early` sent and asked about, for G4's antecedent). Each is a part of `step`, so a state or a violation found under it is reachable under `step`. Uniform random choice over `step` rarely gets a transaction as far as a block in 40 steps; the narrower relations do. Tier 3b re-checks each configuration's guarantees on those deeper traces. @@ -677,7 +678,8 @@ Non-vacuity: for each guarantee, a state where its antecedent holds, reached on every configuration where the guarantee is claimed: `vOperatorBlind`, `vQueuedBytesConfidential` (with W19 for its reply-body branch), `vTxidAuthenticity`, `vLookupValidityPerHub` (the log has a pending, a served -transaction and a not-found), `vAckImpliesQueued`. +transaction and a not-found; reached under `earlyLookupStep`, about 25 traces +in 2000, against 2 under `quietStep`), `vAckImpliesQueued`. The antecedents of G6a, G6b and G6c are reachability rows of the hub specification. diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index b04055f1..a91e533b 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -77,28 +77,30 @@ finish() { done } -SPELLS="spells/basicSpells.qnt spells/soup.qnt" +# Each file with tests carries the fewest it may report, so a test that stops +# being found (renamed so it no longer ends in `Test`, say) fails the gate. +SPELLS="spells/basicSpells.qnt:6 spells/soup.qnt:4" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt protocol.qnt" -FUNCTIONAL="tests/wireTest.qnt tests/indexerTest.qnt tests/hubTest.qnt tests/shimTest.qnt tests/hubScenariosTest.qnt tests/realisedRunsTest.qnt tests/scenariosTest.qnt tests/trustTest.qnt" +FUNCTIONAL="tests/wireTest.qnt:11 tests/indexerTest.qnt:14 tests/hubTest.qnt:27 tests/shimTest.qnt:13 + tests/hubScenariosTest.qnt:38 tests/realisedRunsTest.qnt:8 tests/scenariosTest.qnt:21 tests/trustTest.qnt:19" fail() { echo "FAIL $1" } -# run_tests FILE [MODULE] +# run_tests FILE:MIN: every test passes, and there are at least MIN. run_tests() { - if [ $# -eq 2 ]; then - out=$($QUINT test "$1" --main="$2" --backend="$BACKEND" 2>&1) - else - out=$($QUINT test "$1" --backend="$BACKEND" 2>&1) - fi + file=${1%:*} least=${1##*:} + out=$($QUINT test "$file" --backend="$BACKEND" 2>&1) status=$? passing=$(echo "$out" | sed -n 's/^ *\([0-9][0-9]*\) passing.*/\1/p') - if [ "$status" -eq 0 ] && [ -n "$passing" ]; then - echo "ok test ${2:-$1}: $passing passing" + if [ "$status" -eq 0 ] && [ -n "$passing" ] && [ "$passing" -ge "$least" ]; then + echo "ok test $file: $passing passing" + elif [ "$status" -eq 0 ] && [ -n "$passing" ]; then + fail "test $file: $passing passing, expected at least $least" else echo "$out" | tail -25 - fail "test ${2:-$1}" + fail "test $file" fi } @@ -262,7 +264,7 @@ typecheck() { echo "---- 1 typecheck" for file in $SPELLS $MODULES $FUNCTIONAL; do - job typecheck "$file" + job typecheck "${file%:*}" done finish if [ "$failures" -ne 0 ]; then @@ -299,9 +301,12 @@ job reaches baseline step 40 \ wQueuedDisclosed wThirdPartyServedBody wToldRefusedEverywhere wToldNeverDelivered \ vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ -- $BASELINE_HOLDS -# The antecedents of G3, G4. +# The antecedent of G3, and G4's under a step that keeps to one migration. job reaches baseline quietStep 80 \ - vTxidAuthenticity vLookupValidityPerHub \ + vTxidAuthenticity \ + -- $BASELINE_HOLDS +job reaches baseline earlyLookupStep 40 \ + vLookupValidityPerHub \ -- $BASELINE_HOLDS # W16, both halves. diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 49d71786..87f197be 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -1040,6 +1040,14 @@ module protocol { chainMine, } + /// One migration, `early`, sent and asked about, with no faults and no + /// outsiders. Its three lookups can be answered pending (queued), not found + /// (the flush window) and served (accepted): G4's antecedent. + action earlyLookupStep = any { + walletSendWith(Clean(early), true), walletGetWith("early"), + shimReceive, hubReceive, hubTake, indexerVerdict, + } + // ------------------------------------------------------------------------ // Guarantees // ------------------------------------------------------------------------ From d50ecd319ba28e5c21cae6f5d820d06d490ae46c Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 16:50:41 +0400 Subject: [PATCH 64/80] test(zeronym): drop the ack guarantee, the Byzantine hub's false ack and the replayed runs from the protocol spec --- zeronym/spec/protocol/abstractHub.qnt | 11 +- zeronym/spec/protocol/check.sh | 23 ++- zeronym/spec/protocol/hub.qnt | 17 +- zeronym/spec/protocol/protocol.qnt | 60 +----- zeronym/spec/protocol/tests/hubTest.qnt | 17 +- zeronym/spec/protocol/tests/realisations.qnt | 182 ------------------ .../spec/protocol/tests/realisedRunsTest.qnt | 73 ------- zeronym/spec/protocol/tests/scenariosTest.qnt | 22 +-- zeronym/spec/protocol/tests/trustTest.qnt | 25 +-- 9 files changed, 33 insertions(+), 397 deletions(-) delete mode 100644 zeronym/spec/protocol/tests/realisations.qnt delete mode 100644 zeronym/spec/protocol/tests/realisedRunsTest.qnt diff --git a/zeronym/spec/protocol/abstractHub.qnt b/zeronym/spec/protocol/abstractHub.qnt index 88beca2c..6e6fe026 100644 --- a/zeronym/spec/protocol/abstractHub.qnt +++ b/zeronym/spec/protocol/abstractHub.qnt @@ -45,14 +45,13 @@ module abstractHub { Set({ hub: h, reply: AWire(render(outcome)) }) } - /// A Byzantine hub queues a submission or not, whatever it answers, and - /// answers a lookup with anything: a body from `universe` or none, at any - /// height, or not found, or an error. + /// A Byzantine hub answers a lookup with anything: a body from `universe` or + /// none, at any height, or not found, or an error. A submission it accepts + /// or refuses as it likes, which the honest relation already allows; its ack + /// says which. pure def byzantineAnswers(h: AHub, request: ARequest, universe: Set[Payload]): Set[AAnswer] = match request { - | ASubmit(payload) => - tuples(Set(h, { ...h, queue: h.queue.union(Set(payload)) }), WIRE_ACKS) - .map(((after, ack)) => { hub: after, reply: AAck(ack) }) + | ASubmit(_) => honestAnswers(h, request) | ALookup(_) => tuples(Set(None).union(universe.map(payload => Some(payload))), WIRE_HEIGHTS) .map(((body, claimed)) => WFound({ body: body, height: claimed })) diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index a91e533b..55bb7ee3 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -81,8 +81,8 @@ finish() { # being found (renamed so it no longer ends in `Test`, say) fails the gate. SPELLS="spells/basicSpells.qnt:6 spells/soup.qnt:4" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt protocol.qnt" -FUNCTIONAL="tests/wireTest.qnt:11 tests/indexerTest.qnt:14 tests/hubTest.qnt:27 tests/shimTest.qnt:13 - tests/hubScenariosTest.qnt:38 tests/realisedRunsTest.qnt:8 tests/scenariosTest.qnt:21 tests/trustTest.qnt:19" +FUNCTIONAL="tests/wireTest.qnt:11 tests/indexerTest.qnt:14 tests/hubTest.qnt:26 tests/shimTest.qnt:13 + tests/hubScenariosTest.qnt:38 tests/scenariosTest.qnt:21 tests/trustTest.qnt:17" fail() { echo "FAIL $1" @@ -281,10 +281,9 @@ finish echo "---- 3 invariants ($SAMPLES traces, seed $SEED)" # The guarantees, where they are claimed. -job holds baseline operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub \ - ackImpliesQueued +job holds baseline operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub job holds byzHub operatorBlind txidAuthenticity -job holds byzIndexer operatorBlind txidAuthenticity ackImpliesQueued +job holds byzIndexer operatorBlind txidAuthenticity # The known gaps, with every component honest. job fails baseline quietStep 40 statusNeverRegresses # K2 @@ -293,13 +292,13 @@ finish echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" -BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub ackImpliesQueued" +BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub" -# W8, W19, K1a, K1b, and the antecedents of G1, G2, G8. W19 is G2's reply-body -# branch, which `vQueuedBytesConfidential` does not reach on its own. +# W8, W19, and the antecedents of G1 and G2. W19 is G2's reply-body branch, +# which `vQueuedBytesConfidential` does not reach on its own. job reaches baseline step 40 \ - wQueuedDisclosed wThirdPartyServedBody wToldRefusedEverywhere wToldNeverDelivered \ - vOperatorBlind vQueuedBytesConfidential vAckImpliesQueued \ + wQueuedDisclosed wThirdPartyServedBody \ + vOperatorBlind vQueuedBytesConfidential \ -- $BASELINE_HOLDS # The antecedent of G3, and G4's under a step that keeps to one migration. job reaches baseline quietStep 80 \ @@ -314,8 +313,8 @@ job reaches byzHub quietStep 40 \ vOperatorBlind vTxidAuthenticity wTwinServed wFalseHeightServed \ -- operatorBlind txidAuthenticity job reaches byzIndexer quietStep 40 \ - vOperatorBlind vTxidAuthenticity vAckImpliesQueued \ - -- operatorBlind txidAuthenticity ackImpliesQueued + vOperatorBlind vTxidAuthenticity \ + -- operatorBlind txidAuthenticity finish fi diff --git a/zeronym/spec/protocol/hub.qnt b/zeronym/spec/protocol/hub.qnt index 22232334..2884f93b 100644 --- a/zeronym/spec/protocol/hub.qnt +++ b/zeronym/spec/protocol/hub.qnt @@ -412,8 +412,8 @@ module hub { /// The transitions of a Byzantine hub. It keeps the honest schedule, and is /// free in what it tells its clients and in what it admits: /// - /// - a submission is answered with any decision, and the payload is queued - /// or not, independently of the answer and of every admission rule; + /// - a submission is admitted or refused whatever the admission rules say, + /// and the ack says which; /// - a lookup is answered with any outcome: a queue hit, or anything an /// indexer could say, with a body drawn from `universe` or none, at any /// height. @@ -429,13 +429,12 @@ module hub { else match input { | SubmitHInput(submit) => - val kinds = Set(Admitted, Duplicate) - .union(Set(TipStale, HubDraining, ExpiryTooTight).map(refusal => Refused(refusal))) - val holding = - if (state.queued().contains(submit.payload)) state - else { ...state, queue: state.queue.put(submit.payload, 0) } - honest.union(tuples(Set(state, holding), kinds).map(((after, kind)) => - after.toAckOutput(submit.nonce, kind))) + val admitted = + if (state.queued().contains(submit.payload)) state.toAckOutput(submit.nonce, Duplicate) + else { ...state, queue: state.queue.put(submit.payload, 0) }.toAckOutput(submit.nonce, Admitted) + val refused = Set(TipStale, HubDraining, ExpiryTooTight).map(refusal => + state.toAckOutput(submit.nonce, Refused(refusal))) + honest.union(Set(admitted)).union(refused) | LookupHInput(lookup) => val bodies = Set(None).union(universe.map(payload => Some(payload))) val answers = tuples(bodies, WIRE_HEIGHTS) diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/protocol/protocol.qnt index 87f197be..562f2a93 100644 --- a/zeronym/spec/protocol/protocol.qnt +++ b/zeronym/spec/protocol/protocol.qnt @@ -144,11 +144,9 @@ module protocol { /// component reads it, and it is derived at every step from the states /// before and after, never from what a component reports about itself. /// - /// - `everQueued`: every payload that has been in the hub's queue. /// - `windows`: for each lookup the shim has sent, the answers that were /// true at the hub at some point while it waited. type Audit = { - everQueued: Set[Payload], windows: Nonce -> Set[LookupObs], } @@ -184,22 +182,10 @@ module protocol { pure def fromHub(s: System, client: Addr): Set[Msg] = s.net.filter(mail => mail.src == HubAddr and mail.dst == client).map(mail => mail.msg) - /// The submissions `client` has addressed to the hub. - pure def submissions(s: System, client: Addr): Set[{ nonce: Nonce, payload: Payload }] = - s.toHub(client).filterMap(msg => match msg { | Submit(submit) => Some(submit) | _ => None }) - /// The acks the hub has sent `client`. pure def acks(s: System, client: Addr): Set[{ nonce: Nonce, ack: WireAck }] = s.fromHub(client).filterMap(msg => match msg { | Ack(ack) => Some(ack) | _ => None }) - /// The payloads the hub has acknowledged as accepted, to any client. - pure def acked(s: System): Set[Payload] = - Set(ShimAddr, ThirdPartyAddr).map(client => - tuples(s.submissions(client), s.acks(client)) - .filter(((submit, ack)) => submit.nonce == ack.nonce and ack.ack == WAccepted) - .map(((submit, _)) => submit.payload) - ).flatten() - /// The lookups `client` has addressed to the hub. pure def lookups(s: System, client: Addr): Set[{ nonce: Nonce, txid: TxId }] = s.toHub(client).filterMap(msg => match msg { | Lookup(lookup) => Some(lookup) | _ => None }) @@ -366,7 +352,7 @@ module protocol { else NotFound - pure val initialAudit: Audit = { everQueued: Set(), windows: Map() } + pure val initialAudit: Audit = { windows: Map() } /// The audit record after one step, from the states before and after it. pure def advance(audit: Audit, pre: System, post: System): Audit = @@ -378,7 +364,7 @@ module protocol { .fold(audit.windows, (acc, lookup) => val seen = if (acc.keys().contains(lookup.nonce)) acc.get(lookup.nonce) else Set() acc.put(lookup.nonce, seen.union(Set(truth(post, lookup.txid))))) - { everQueued: audit.everQueued.union(post.hub.queue), windows: windows } + { windows: windows } // ------------------------------------------------------------------------ // Guarantees @@ -433,11 +419,6 @@ module protocol { | _ => true }) - /// G8. An accepted ack from the hub is for a payload it had queued by the - /// time it acked. It holds whether or not anyone waits for the ack. - pure def ackImpliesQueuedIn(s: System, audit: Audit): bool = - s.acked().subseteq(audit.everQueued) - // ------------------------------------------------------------------------ // Known gaps // ------------------------------------------------------------------------ @@ -471,35 +452,6 @@ module protocol { | _ => true }) - // K1. "Told ok" promises nothing about the hub. It is stated as two - // reachable states, not as a violated invariant, because the invariant is - // false on the ordinary success path too: the wallet is told before the hub - // has the frame. - - /// The nonces of the shim's submissions of `payload`. - pure def shimSubmissionsOf(s: System, payload: Payload): Set[Nonce] = - s.submissions(ShimAddr).filter(submit => submit.payload == payload).map(submit => submit.nonce) - - /// K1a. The wallet was told ok; every submission that reached the hub was - /// refused; the hub never queued the payload. - pure def wToldRefusedEverywhereIn(s: System, audit: Audit): bool = - s.toldOk().exists(payload => - val submitted = s.shimSubmissionsOf(payload) - and { - not(audit.everQueued.contains(payload)), - submitted != Set(), - submitted.forall(nonce => s.acks(ShimAddr).exists(ack => ack.nonce == nonce and ack.ack != WAccepted)), - }) - - /// K1b. The wallet was told ok; the hub has answered none of the frames and - /// never queued the payload. The network may leave it so forever. - pure def wToldNeverDeliveredIn(s: System, audit: Audit): bool = - s.toldOk().exists(payload => - and { - not(audit.everQueued.contains(payload)), - s.shimSubmissionsOf(payload).forall(nonce => not(s.acks(ShimAddr).exists(ack => ack.nonce == nonce))), - }) - // ------------------------------------------------------------------------ // Witnesses // ------------------------------------------------------------------------ @@ -614,9 +566,6 @@ module protocol { s.wasGiven(obs => obs == NotFound), } - pure def vAckImpliesQueuedIn(s: System): bool = - s.acked() != Set() - // ======================================================================== // The machine // ======================================================================== @@ -1060,7 +1009,6 @@ module protocol { val queuedBytesConfidential = queuedBytesConfidentialIn(s) val txidAuthenticity = txidAuthenticityIn(s) val lookupValidityPerHub = lookupValidityPerHubIn(s, audit) - val ackImpliesQueued = ackImpliesQueuedIn(s, audit) // ------------------------------------------------------------------------ // Known gaps: invariants that do not hold @@ -1072,9 +1020,6 @@ module protocol { // Witnesses // ------------------------------------------------------------------------ - val wToldRefusedEverywhere = wToldRefusedEverywhereIn(s, audit) - val wToldNeverDelivered = wToldNeverDeliveredIn(s, audit) - val wPending = wPendingIn(s) val wTxInMempool = wTxInMempoolIn(s) val wTxMined = wTxMinedIn(s) @@ -1089,7 +1034,6 @@ module protocol { val vQueuedBytesConfidential = vQueuedBytesConfidentialIn(s) val vTxidAuthenticity = vTxidAuthenticityIn(s) val vLookupValidityPerHub = vLookupValidityPerHubIn(s) - val vAckImpliesQueued = vAckImpliesQueuedIn(s) // ------------------------------------------------------------------------ // Run vocabulary diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/protocol/tests/hubTest.qnt index 7a40e378..19b8d48d 100644 --- a/zeronym/spec/protocol/tests/hubTest.qnt +++ b/zeronym/spec/protocol/tests/hubTest.qnt @@ -136,16 +136,6 @@ module hubTest { })), } - /// F13. An accepted ack is given only for a payload the hub then holds. - run ackImpliesQueuedTest = all { - assert(tuples(STATES, PAYLOADS).forall(((state, payload)) => - match outputOf(state, submit(payload)) { - | AckOutput(ack) => isAccepted(ack.kind) implies state.after(submit(payload)).queued().contains(payload) - | _ => true - })), - assert(running(5).after(submit(pA)).queue == Map(pA -> 0)), - } - // ------------------------------------------------------------------------ // Lookup // ------------------------------------------------------------------------ @@ -313,12 +303,9 @@ module hubTest { assert(tuples(STATES, INPUTS).forall(((state, input)) => byzHubResults(state, input, PAYLOADS).contains(hub(state, input)))) - /// F13, companion. A Byzantine hub can accept a payload it does not hold, - /// can hold one admission would refuse, and can answer a lookup with a - /// queued body. + /// A Byzantine hub can hold a payload admission would refuse, and can + /// answer a lookup with a queued body. run byzantineHubTest = all { - assert(byzHubResults(running(5), submit(pA), PAYLOADS).exists(result => - result.out == AckOutput({ nonce: 0, kind: Admitted }) and not(result.state.queued().contains(pA)))), assert(byzHubResults(running(5), submit(pTight), PAYLOADS).exists(result => result.state.queued().contains(pTight))), assert(byzHubResults(holding, LookupHInput({ nonce: 1, txid: "a", answer: INotFound }), PAYLOADS) diff --git a/zeronym/spec/protocol/tests/realisations.qnt b/zeronym/spec/protocol/tests/realisations.qnt deleted file mode 100644 index 6629f28e..00000000 --- a/zeronym/spec/protocol/tests/realisations.qnt +++ /dev/null @@ -1,182 +0,0 @@ -// -*- mode: Bluespec; -*- - -/// The hub's part of each pinned protocol run that shows a violation or a -/// reached state: the inputs the real hub sees in that run, the replies the -/// run relies on, in order, and the hub as the protocol sees it at the end. -/// -/// `realisedRunsTest` replays each through the real hub function. Each -/// protocol run ends with `realisedBy`, so the two cannot drift apart. The -/// soup has no order, so a protocol run is compared on the set of replies. -/// -/// No runs here: a module that imports this one does not run them again. -/// -/// K1b has no hub step: the frame is never delivered. -module realisations { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import hub.* from "../hub" - import abstractHub.* from "../abstractHub" - import protocol.* from "../protocol" - - /// A step the hub takes, or a lie: a member of the Byzantine relation, with - /// the state unchanged. - type Step = Do(HubInput) | Lie({ input: HubInput, out: HubOutput }) - - type Realisation = { steps: List[Step], replies: List[AReply], last: AHub } - - pure def sub(nonce: Nonce, payload: Payload): HubInput = SubmitHInput({ nonce: nonce, payload: payload }) - pure def look(nonce: Nonce, txid: TxId, answer: IndexerAnswer): HubInput = - LookupHInput({ nonce: nonce, txid: txid, answer: answer }) - pure def judged(payload: Payload, verdict: Verdict): HubInput = - VerdictHInput({ payload: payload, verdict: verdict }) - pure def found(body: Option[Payload], height: WireHeight): IndexerAnswer = IFound({ body: body, height: height }) - pure def tips(heights: List[Height]): List[Step] = heights.foldl([], (acc, h) => acc.append(Do(TipHInput(h)))) - pure def does(inputs: List[HubInput]): List[Step] = inputs.foldl([], (acc, i) => acc.append(Do(i))) - - pure def flushed(payload: Payload, verdict: Verdict): List[Step] = - does([FlushDueHInput, judged(payload, verdict), FlushDoneHInput]) - - /// One lie about `early`, in answer to a lookup. - pure def lieAbout(nonce: Nonce, answer: IndexerAnswer, outcome: HubOutcome): Step = - Lie({ input: look(nonce, "early", answer), out: LookupReplyOutput({ nonce: nonce, outcome: outcome }) }) - - pure val accepted = AAck(WAccepted) - pure val pending = AWire(WFound({ body: None, height: AtZero })) - pure val inMempool = AWire(WFound({ body: Some(early), height: AtZero })) - pure val queuedEarly = { queue: Set(early), held: Set() } - - /// Running at height 2 with `early` queued, as most runs begin. - pure val earlyQueued = tips([1, 2]).append(Do(sub(0, early))) - /// The same, flushed and accepted at height 3. - pure val earlyPublished = earlyQueued.concat(tips([3])).concat(flushed(early, Accepted)) - - // ------------------------------------------------------------------------ - // Every component honest - // ------------------------------------------------------------------------ - - /// K1a: refused after the flush at 3. - pure val k1a: Realisation = { - steps: tips([1, 2, 3]).concat(does([FlushDueHInput, sub(0, tight)])), - replies: [AAck(WRefused(WExpiryTooTight))], - last: emptyAHub, - } - - /// K2a: pending, then in the mempool. - pure val k2a: Realisation = { - steps: earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Accepted)) - .append(Do(look(2, "early", found(Some(early), AtZero)))), - replies: [accepted, pending, inMempool], - last: emptyAHub, - } - - /// K2b and K2c: the resubmission comes from the wallet or the third party. - pure val k2bc: Realisation = { - steps: earlyPublished.append(Do(look(1, "early", found(Some(early), AtZero)))).append(Do(sub(2, early))) - .append(Do(look(3, "early", found(Some(early), AtZero)))), - replies: [accepted, inMempool, accepted, pending], - last: queuedEarly, - } - - /// K2d: the flush window. - pure val k2d: Realisation = { - steps: earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).append(Do(FlushDueHInput)) - .append(Do(look(2, "early", INotFound))), - replies: [accepted, pending, AWire(WNotFound)], - last: { queue: Set(), held: Set(early) }, - } - - /// K2e: rejected at flush. - pure val k2e: Realisation = { - steps: earlyQueued.append(Do(look(1, "early", INotFound))).concat(tips([3])).concat(flushed(early, Rejected)) - .append(Do(look(2, "early", INotFound))), - replies: [accepted, pending, AWire(WNotFound)], - last: emptyAHub, - } - - /// W8: a third party is told `early` is queued. - pure val w8: Realisation = { - steps: earlyQueued.append(Do(look(0, "early", INotFound))), - replies: [accepted, pending], - last: queuedEarly, - } - - /// W9, the unknown upgrade: queued and missed. - pure val w9: Realisation = { - steps: tips([1]).concat(does([sub(0, junk), look(1, "junk", INotFound)])), - replies: [accepted, AWire(WNotFound)], - last: { queue: Set(junk), held: Set() }, - } - - // ------------------------------------------------------------------------ - // The "required" runs of the trust matrix and W16, each one lie - // ------------------------------------------------------------------------ - - /// G2, the hub: the queued bytes, to a third party. - pure val hubLeaksBody: Realisation = { - steps: earlyQueued.append(lieAbout(0, INotFound, FromIndexer(found(Some(early), AtZero)))), - replies: [accepted, inMempool], - last: queuedEarly, - } - - /// G4, the hub: not found for a queued transaction. - pure val hubDeniesQueued: Realisation = { - steps: earlyQueued.append(lieAbout(1, INotFound, FromIndexer(INotFound))), - replies: [accepted, AWire(WNotFound)], - last: queuedEarly, - } - - /// G4, the hub: a false height. - pure val hubFalseHeight: Realisation = { - steps: earlyPublished.append(lieAbout(1, found(Some(early), AtZero), FromIndexer(found(Some(early), AtOther)))), - replies: [accepted, AWire(WFound({ body: Some(early), height: AtOther }))], - last: emptyAHub, - } - - /// G8, the hub: accepted and not queued. - pure val hubAcksUnqueued: Realisation = { - steps: tips([1, 2]).append(Lie({ input: sub(0, early), out: AckOutput({ nonce: 0, kind: Admitted }) })), - replies: [accepted], - last: emptyAHub, - } - - /// W16: a twin at a false height. - pure val w16: Realisation = { - steps: earlyQueued.append(lieAbout(1, INotFound, FromIndexer(found(Some(earlyTwin), AtOther)))), - replies: [accepted, AWire(WFound({ body: Some(earlyTwin), height: AtOther }))], - last: queuedEarly, - } - - /// G2, the indexer: the honest hub forwards the unpublished bytes. - pure val indexerLeaksBody: Realisation = { - steps: earlyQueued.concat(tips([3])).concat(does([FlushDueHInput, judged(early, Retryable), - look(0, "early", found(Some(early), AtZero))])), - replies: [accepted, inMempool], - last: { queue: Set(), held: Set(early) }, - } - - /// G4, the indexer: a forged pending for a transaction nobody queued. - pure val indexerForgesPending: Realisation = { - steps: tips([1, 2]).append(Do(look(1, "early", found(None, AtZero)))), - replies: [pending], - last: emptyAHub, - } - - // ------------------------------------------------------------------------ - // The link to the protocol runs - // ------------------------------------------------------------------------ - - /// Every ack and lookup reply the hub has sent, to any client. - pure def hubReplies(s: System): Set[AReply] = - Set(ShimAddr, ThirdPartyAddr).map(client => - s.acks(client).map(ack => AAck(ack.ack)).union(s.replies(client).map(reply => AWire(reply.reply))) - ).flatten() - - /// The protocol run that ends in `s` has the hub replies and the final hub - /// of `r`. - pure def realisedBy(s: System, r: Realisation): bool = - and { - s.hubReplies() == r.replies.foldl(Set(), (acc, reply) => acc.union(Set(reply))), - s.hub == r.last, - } -} diff --git a/zeronym/spec/protocol/tests/realisedRunsTest.qnt b/zeronym/spec/protocol/tests/realisedRunsTest.qnt deleted file mode 100644 index c1268c3a..00000000 --- a/zeronym/spec/protocol/tests/realisedRunsTest.qnt +++ /dev/null @@ -1,73 +0,0 @@ -// -*- mode: Bluespec; -*- - -/// Each realisation in `realisations.qnt`, replayed through the real hub -/// function on the hub specification's `timely` schedule. -/// -/// The protocol spec's hub answers wherever the real one would refuse or -/// error, so a protocol run proves nothing about reachability on its own. -module realisedRunsTest { - import basicSpells.* from "../spells/basicSpells" - import types.* from "../types" - import wire.* from "../wire" - import hub.* from "../hub" - import abstractHub.* from "../abstractHub" - import protocol.* from "../protocol" - import realisations.* from "./realisations" - - /// The hub specification's `timely` schedule. - pure val PARAMS: HubParams = { - flushInterval: 3, - miningMargin: 2, - deliveryLag: 1, - minWalletExpiry: 7, - reorgAllowance: 1, - maxAttempts: 2, - } - /// What a Byzantine hub builds its lies from. - pure val LIES = universeOf(baseline) - - pure def replyOf(output: HubOutput): List[AReply] = - match output { - | AckOutput(ack) => [AAck(renderAck(ack.kind))] - | LookupReplyOutput(reply) => [AWire(render(reply.outcome))] - | _ => [] - } - - /// The run, from a starting hub: whether every step was taken, the replies - /// in order, and the hub as the protocol sees it at the end. - pure def replay(steps: List[Step]): { ok: bool, replies: List[AReply], last: AHub } = - val end = steps.foldl({ state: startingHub(PARAMS), ok: true, replies: [] }, (acc, step) => - val result = match step { - | Do(input) => hub(acc.state, input) - | Lie(lie) => { state: acc.state, out: lie.out } - } - val taken = match step { - | Do(_) => not(match result.out { | HubErrorOutput(_) => true | _ => false }) - | Lie(lie) => byzHubResults(acc.state, lie.input, LIES).contains(result) - } - { state: result.state, ok: acc.ok and taken, replies: acc.replies.concat(result.out.replyOf()) }) - { ok: end.ok, replies: end.replies, last: { queue: end.state.queued(), held: end.state.inFlight() } } - - pure def realises(r: Realisation): bool = - replay(r.steps) == { ok: true, replies: r.replies, last: r.last } - - run realisedRunsTest = all { - assert(realises(k1a)), - assert(realises(k2a)), - assert(realises(k2bc)), - assert(realises(k2d)), - assert(realises(k2e)), - assert(realises(w8)), - assert(realises(w9)), - } - - run realisedByzantineRunsTest = all { - assert(realises(hubLeaksBody)), - assert(realises(hubDeniesQueued)), - assert(realises(hubFalseHeight)), - assert(realises(hubAcksUnqueued)), - assert(realises(w16)), - assert(realises(indexerLeaksBody)), - assert(realises(indexerForgesPending)), - } -} diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/protocol/tests/scenariosTest.qnt index cc02738c..5896614a 100644 --- a/zeronym/spec/protocol/tests/scenariosTest.qnt +++ b/zeronym/spec/protocol/tests/scenariosTest.qnt @@ -18,7 +18,6 @@ module scenariosTest { import abstractHub.* from "../abstractHub" import shim.* from "../shim" import protocol.* from "../protocol" - import realisations as R from "./realisations" /// Every named init is live: its guard holds and it starts a run. run liveInitsTest = @@ -52,7 +51,7 @@ module scenariosTest { .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtMined }), via: Some(3) })) .expect(wPending and wTxInMempool and wTxMined) .expect(operatorBlind and queuedBytesConfidential and txidAuthenticity and lookupValidityPerHub) - .expect(ackImpliesQueued and statusNeverRegresses) + .expect(statusNeverRegresses) /// A pass-through transaction goes to the operator and nowhere else. run passThroughIsForwardedTest = @@ -73,7 +72,6 @@ module scenariosTest { .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: None, height: AtZero }) })) .expect(wQueuedDisclosed) .expect(s.tpLearned() == Set() and queuedBytesConfidential) - .expect(R::realisedBy(s, R::w8)) /// W19. Once `early` is published its txid is public, and a third party /// that asks is served its bytes from the indexer. G2 holds: they are @@ -105,24 +103,19 @@ module scenariosTest { .then(lookUp(1, "junk")) .expect(lastEvent == Got({ query: "junk", obs: NotFound, via: Some(1) })) .expect(wUnparseableMissed and lookupValidityPerHub) - .expect(R::realisedBy(s, R::w9)) // ------------------------------------------------------------------------ // K1. Told ok, and no hub ever has it // ------------------------------------------------------------------------ - /// K1a. The only hub refuses the frame after the wallet was told ok. Its - /// realisation, refused for its expiry after the flush at 3, is in - /// `realisedRunsTest`. + /// K1a. The hub refuses the frame after the wallet was told ok. run toldOkThenRefusedTest = initBaseline .then(sendToHub(tight)) .then(answerSubmitFrom(ShimAddr, 0, tight, WRefused(WExpiryTooTight))) .expect(s.wallet.log == [Sent({ input: Clean(tight), obs: SentOk })]) .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WRefused(WExpiryTooTight) })) - .expect(audit.everQueued == Set()) - .expect(wToldRefusedEverywhere) - .expect(R::realisedBy(s, R::k1a)) + .expect(s.hub == emptyAHub) /// K1b. The frame is never delivered. Nothing obliges the network to. run toldOkAndNeverDeliveredTest = @@ -130,8 +123,7 @@ module scenariosTest { .then(sendToHub(early)) .expect(s.wallet.log == [Sent({ input: Clean(early), obs: SentOk })]) .expect(s.net == Set(submitMail(0, early))) - .expect(audit.everQueued == Set()) - .expect(wToldNeverDelivered) + .expect(s.hub == emptyAHub) // ------------------------------------------------------------------------ // K2. The status a wallet sees goes backwards @@ -155,7 +147,6 @@ module scenariosTest { ]) // Each answer was true when it was given. .expect(not(statusNeverRegresses) and lookupValidityPerHub) - .expect(R::realisedBy(s, R::k2a)) /// K2b. The wallet sends published bytes again. The hub's memory of them /// went with the flush, so they are admitted and pending once more. @@ -173,7 +164,6 @@ module scenariosTest { Got({ query: "early", obs: Pending, via: Some(3) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) - .expect(R::realisedBy(s, R::k2bc)) /// K2c. The same, done by a third party: the bytes are public once /// published, and submission is open to anyone. @@ -191,7 +181,6 @@ module scenariosTest { Got({ query: "early", obs: Pending, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) - .expect(R::realisedBy(s, R::k2bc)) /// K2d. The flush window: the queue is empty and the chain does not have /// the batch yet. @@ -207,7 +196,6 @@ module scenariosTest { Got({ query: "early", obs: NotFound, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) - .expect(R::realisedBy(s, R::k2d)) /// K2e. The node rejects the transaction at flush. It was pending; now it /// is nowhere. @@ -223,7 +211,6 @@ module scenariosTest { Got({ query: "early", obs: NotFound, via: Some(2) }), ]) .expect(not(statusNeverRegresses) and lookupValidityPerHub) - .expect(R::realisedBy(s, R::k2e)) // ------------------------------------------------------------------------ // A Byzantine hub (`byzHub`) @@ -245,5 +232,4 @@ module scenariosTest { .expect(s.onChain("early") == Absent) .expect(wTwinServed and wFalseHeightServed) .expect(txidAuthenticity and not(lookupValidityPerHub)) - .expect(R::realisedBy(s, R::w16)) } diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index d2670181..422f1adc 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -19,7 +19,6 @@ module trustTest { import abstractHub.* from "../abstractHub" import shim.* from "../shim" import protocol.* from "../protocol" - import realisations as R from "./realisations" // ------------------------------------------------------------------------ // A Byzantine hub (`byzHub`) @@ -41,7 +40,6 @@ module trustTest { // What it learned came in the body of a reply addressed to it. .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) - .expect(R::realisedBy(s, R::hubLeaksBody)) run hubServesQueuedBodyControlTest = initByzHub @@ -65,7 +63,6 @@ module trustTest { .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) .expect(s.hub.queue == Set(early) and audit.windows.get(1) == Set(Pending)) .expect(not(lookupValidityPerHub)) - .expect(R::realisedBy(s, R::hubDeniesQueued)) run hubDeniesQueuedControlTest = initByzHub @@ -90,7 +87,6 @@ module trustTest { .expect(s.onChain("early") == InMempool) .expect(audit.windows.get(1) == Set(Tx({ payload: early, height: AtZero }))) .expect(not(lookupValidityPerHub) and txidAuthenticity) - .expect(R::realisedBy(s, R::hubFalseHeight)) run hubServesFalseHeightControlTest = initByzHub @@ -100,23 +96,6 @@ module trustTest { .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtZero }), via: Some(1) })) .expect(lookupValidityPerHub) - /// G8 needs the hub. It acks a submission as accepted and does not queue it. - run hubAcksWithoutAdmittingTest = - initByzHub - .then(sendToHub(early)) - .then(hubReceiveWith(submitMail(0, early), INotFound, { hub: s.hub, reply: AAck(WAccepted) })) - .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WAccepted })) - .expect(s.hub.queue == Set() and audit.everQueued == Set()) - .expect(not(ackImpliesQueued)) - .expect(R::realisedBy(s, R::hubAcksUnqueued)) - - run hubAcksWithoutAdmittingControlTest = - initByzHub - .then(sendToHub(early)) - .then(deliverSubmit(0, early)) - .expect(s.acks(ShimAddr) == Set({ nonce: 0, ack: WAccepted }) and s.hub.queue == Set(early)) - .expect(ackImpliesQueued) - /// G3 survives. The hub answers with another transaction; the shim compares /// txids and refuses it. run wrongTransactionIsRefusedTest = @@ -160,7 +139,6 @@ module trustTest { // What it learned came in the body of a reply addressed to it. .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) - .expect(R::realisedBy(s, R::indexerLeaksBody)) run indexerServesUnpublishedBodyControlTest = initByzIndexer @@ -183,9 +161,8 @@ module trustTest { .then(deliverLookupFrom(ShimAddr, 1, "early", IFound({ body: None, height: AtZero }))) .then(deliverToShim(replyMail(1, WFound({ body: None, height: AtZero })))) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) - .expect(audit.everQueued == Set() and audit.windows.get(1) == Set(NotFound)) + .expect(s.hub.queue == Set() and audit.windows.get(1) == Set(NotFound)) .expect(not(lookupValidityPerHub)) - .expect(R::realisedBy(s, R::indexerForgesPending)) run indexerForgesPendingControlTest = initByzIndexer From 53b9b0012cfca96c750dd6660c335b3106a726fd Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 16:50:41 +0400 Subject: [PATCH 65/80] test(zeronym): drop the all-payload offer guarantee and check the timely and regressing tips on supported migrations only --- zeronym/spec/protocol/check.sh | 21 ++--- zeronym/spec/protocol/hubMachine.qnt | 41 +++------ .../spec/protocol/tests/hubScenariosTest.qnt | 91 +++++-------------- 3 files changed, 42 insertions(+), 111 deletions(-) diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 55bb7ee3..7dd8bb61 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -82,7 +82,7 @@ finish() { SPELLS="spells/basicSpells.qnt:6 spells/soup.qnt:4" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt protocol.qnt" FUNCTIONAL="tests/wireTest.qnt:11 tests/indexerTest.qnt:14 tests/hubTest.qnt:26 tests/shimTest.qnt:13 - tests/hubScenariosTest.qnt:38 tests/scenariosTest.qnt:21 tests/trustTest.qnt:17" + tests/hubScenariosTest.qnt:33 tests/scenariosTest.qnt:21 tests/trustTest.qnt:17" fail() { echo "FAIL $1" @@ -321,26 +321,23 @@ fi if [ "$TIERS" != simulation ]; then echo "---- 4 hub specification (TLC, exhaustive)" -G6A=offeredBeforeExpiry G6B=conformingFirstOfferBeforeExpiry G6C=conformingFirstOfferJudgedBeforeExpiry K5=ackedIsHeldOrSettled K6=conformingEveryOfferBeforeExpiry -# The schedule guarantees with every component honest: all of them under a -# timely tip; under a tip that may be reported behind the chain, those for -# supported wallets; and with a slow flight as well, the one about the offer. -job tlc_holds initTimely step "$G6A and $G6B and $G6C" +# The schedule guarantees with every component honest: under a timely tip; +# under a tip that may be reported behind the chain; and with a slow flight as +# well, the one about the offer. +job tlc_holds initTimely step "$G6B and $G6C" job tlc_holds initTimely step $K6 job tlc_holds initFlakyTip step "$G6B and $G6C" job tlc_holds initFlakyTipSlowFlight step $G6B # The known gaps, each on the configuration that isolates its cause. The last # argument is the length of TLC's counterexample. -job tlc_violated initFlakyTip step $G6A 8 # K3 job tlc_violated initFlakyTipNoSlack step $G6B 12 # K3' job tlc_violated initFlakyTipSlowFlight step $G6C 14 # K7 -job tlc_violated initStaleLag step $G6A 12 # K4 job tlc_violated initStaleLag step $G6B 13 # K4 job tlc_violated initStaleLag step $G6C 14 # K4 job tlc_violated initStaleLag step $K6 13 # K6 @@ -350,21 +347,18 @@ job tlc_violated initStaleLagWithSlack step $G6C 20 # findin # no shutdown either, a requeue that gives the entry up as expired. job tlc_violated initTimely step $K5 5 job tlc_violated initTimely noCrashStep $K5 8 -job tlc_violated initTimely quietStep $K5 9 +job tlc_violated initTimely quietStep $K5 12 # The trust matrix: each schedule guarantee is violated once the component it # depends on is Byzantine. -job tlc_violated initByzHub step $G6A 8 job tlc_violated initByzHub step $G6B 12 job tlc_violated initByzHub step $G6C 13 -job tlc_violated initByzIndexer step $G6A 8 job tlc_violated initByzIndexer step $G6B 16 job tlc_violated initByzIndexer step $G6C 17 -# Reachability. The antecedents of G6a, G6b and G6c, so that a "holds" is not +# Reachability. The antecedents of G6b and G6c, so that a "holds" is not # vacuous; and one state per family of steps, because TLC runs with deadlock # checking off and a machine whose steps died would hold everything. -job tlc_violated initTimely step "not(wOfferWithExpiry)" 6 job tlc_violated initTimely step "not(wConformingFirstOffer)" 6 job tlc_violated initTimely step "not(wConformingFirstOfferInFlightABlock)" 7 job tlc_violated initTimely step "not(wOffered)" 7 @@ -373,7 +367,6 @@ job tlc_violated initTimely step "not(wDown)" 2 job tlc_violated initTimely step "not(wRestartedOwing)" 6 job tlc_violated initTimely step "not(wBlockInFlight)" 7 job tlc_violated initTimely step "not(wStopped)" 4 -job tlc_violated initFlakyTip step "not(wOfferWithExpiry)" 6 job tlc_violated initFlakyTip step "not(wConformingFirstOffer)" 6 job tlc_violated initFlakyTip step "not(wConformingFirstOfferInFlightABlock)" 7 job tlc_violated initFlakyTip step "not(wOffered)" 7 diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/protocol/hubMachine.qnt index 4ace7fcd..c3ecd5ee 100644 --- a/zeronym/spec/protocol/hubMachine.qnt +++ b/zeronym/spec/protocol/hubMachine.qnt @@ -551,15 +551,10 @@ module hubMachine { def isFirstOffer(payload: Payload): bool = not(offered.contains(payload)) - /// G6a. Every transaction the hub offers is offered with the mining margin - /// to spare: whatever was admitted, on every attempt. A claim about the - /// margin left at the offer, not about acceptance. - val offeredBeforeExpiry = - h.inFlight().forall(payload => marginLeft(payload, flightStart)) - - /// G6b. The same, for supported wallets only and for the first time the hub - /// offers the transaction. It says nothing about a later offer of an entry - /// that was requeued; see `conformingEveryOfferBeforeExpiry`. + /// G6b. A supported wallet's transaction is offered with the mining margin + /// to spare, the first time the hub offers it. A claim about the margin left + /// at the offer, not about acceptance. It says nothing about a later offer of + /// an entry that was requeued; see `conformingEveryOfferBeforeExpiry`. val conformingFirstOfferBeforeExpiry = h.inFlight().forall(payload => isConformingAndOnTime(payload) and isFirstOffer(payload) implies marginLeft(payload, flightStart)) @@ -611,10 +606,6 @@ module hubMachine { // is run with deadlock checking off, so a configuration whose steps died // after `init` would otherwise hold everything on a handful of states. - /// An entry with an expiry is in flight. - val wOfferWithExpiry = - h.inFlight().exists(payload => isSome(payload.expiry)) - /// A supported wallet's transaction, with an expiry, is on its first flight. val wConformingFirstOffer = h.inFlight().exists(payload => @@ -668,7 +659,7 @@ module hubMachine { /// Every component honest, and a hub that sees each block before the next. pure val timely: HubConfig = { - payloads: Set(early, late, tight), + payloads: Set(early, late), params: { flushInterval: 3, miningMargin: 2, @@ -692,32 +683,28 @@ module hubMachine { pure val flakyTipNoSlack: HubConfig = { ...flakyTip, params: { ...flakyTip.params, minWalletExpiry: 6 }, - payloads: Set(orchard("early", 2, 8), late, tight), + payloads: Set(orchard("early", 2, 8), late), } // The same tip, and a flush that may stay in flight for as many blocks as - // the mining margin reserves. The supported wallets' migrations only: with - // `tight` as well TLC does not exhaust it in five minutes. - pure val flakyTipSlowFlight: HubConfig = { ...flakyTip, maxFlightBlocks: 2, payloads: Set(early, late) } + // the mining margin reserves. + pure val flakyTipSlowFlight: HubConfig = { ...flakyTip, maxFlightBlocks: 2 } // A hub that may go without a tip for a while: on the shipped relation // between the staleness window and the expiry floor, and on the relation - // that would cover the silence. These two carry the supported wallets' - // migrations only. A free-running clock multiplies the states, and with - // `tight` as well TLC does not exhaust either in five minutes. `tight` gives - // G6a a shorter counterexample on them (8 states against 13) and no check - // of them a different verdict. - pure val staleLag: HubConfig = { ...timely, tip: TipMayLag, payloads: Set(early, late) } + // that would cover the silence. + pure val staleLag: HubConfig = { ...timely, tip: TipMayLag } pure val staleLagWithSlack: HubConfig = { ...staleLag, params: { ...staleLag.params, minWalletExpiry: 8 }, payloads: Set(orchard("early", 2, 10), orchard("late", 4, 12)), } - // One Byzantine component at a time, on the schedule of `timely`. The - // Byzantine indexer gets one supported migration and `tight`: with three + // One Byzantine component at a time, on the schedule of `timely`, each + // with `tight` as well: a Byzantine hub admits what the expiry rule refuses. + // The Byzantine indexer gets one supported migration and `tight`: with three // payloads TLC does not reach its G6c counterexample in five minutes. - pure val byzHub: HubConfig = { ...timely, hubRole: Byzantine } + pure val byzHub: HubConfig = { ...timely, hubRole: Byzantine, payloads: Set(early, late, tight) } pure val byzIndexer: HubConfig = { ...timely, indexerRole: Byzantine, payloads: Set(early, tight) } /// Bytes neither the shim nor the hub can parse: no txid, no expiry. After diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/protocol/tests/hubScenariosTest.qnt index 122e93fe..11359e81 100644 --- a/zeronym/spec/protocol/tests/hubScenariosTest.qnt +++ b/zeronym/spec/protocol/tests/hubScenariosTest.qnt @@ -117,13 +117,13 @@ module hubScenariosTest { .then(block) .then(flushBegin) .expect(h.inFlight() == Set(early) and flightStart == 3) - .expect(wOfferWithExpiry and wConformingFirstOffer) + .expect(wConformingFirstOffer) .then(advance) .expect(height == 4 and not(mayAdvance) and wConformingFirstOfferInFlightABlock and wBlockInFlight) .then(judge(early, Accepted)) .then(flushEnd) .expect(onChain == Set(early) and offered == Set(early) and owed == Set() and flightStart == 0) - .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) + .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) .expect(conformingEveryOfferBeforeExpiry and ackedIsHeldOrSettled) /// K6, control. The wallet inputs of K6 under a timely tip. The second @@ -175,19 +175,24 @@ module hubScenariosTest { .expect(not(ackedIsHeldOrSettled)) /// K5c. A requeue gives an acknowledged entry up as expired. No crash and - /// no shutdown: the flush at 3 comes back unjudged, and an expiry of 5 does - /// not survive the flush at 6. + /// no shutdown: the flushes at 3 and at 6 both come back unjudged, and an + /// expiry of 9 does not survive the flush at 9. run requeueDropsAckedAsExpiredTest = started(timely) .then(block) - .then(deliver(tight)) + .then(deliver(early)) .then(block) .then(flushBegin) - .then(judge(tight, Retryable)) + .then(judge(early, Retryable)) + .expect(requeueReport == requeued(1, 0, 0)) + .then(flushEnd) + .then(blocks(3)) + .then(flushBegin) + .then(judge(early, Retryable)) .expect(requeueReport == requeued(0, 1, 0)) .then(flushEnd) .expect(h.phase == Running and h.queue == Map()) - .expect(owed == Set(tight) and not(ackedIsHeldOrSettled)) + .expect(owed == Set(early) and not(ackedIsHeldOrSettled)) // ------------------------------------------------------------------------ // The unknown upgrade @@ -226,31 +231,14 @@ module hubScenariosTest { .expect(requeueReport == requeued(0, 0, 1)) .then(flushEnd) .expect(h.queue == Map() and owed.contains(junk)) - .expect(offeredBeforeExpiry) // ------------------------------------------------------------------------ // A tip reported behind the chain // ------------------------------------------------------------------------ - /// K3. A tight-expiry transaction is admitted against a tip reported one - /// block back, below a boundary the hub has already flushed. Admission - /// reasons that the next flush is at 3; it is at 6, after the expiry. - run tightExpiryAdmittedBehindFlushedBoundaryTest = - started(flakyTip) - .then(blocks(2)) - .then(flushBegin) - .then(observeWith(2)) - .then(deliver(tight)) - .expect(height == 3 and h.tip == Some(2) and h.queued() == Set(tight)) - .then(blocks(3)) - .then(flushBegin) - .expect(flightStart == 6 and h.inFlight() == Set(tight)) - // Not a supported wallet: the guarantee for those is untouched. - .expect(not(conforming(tight, cfg.params.minWalletExpiry))) - .expect(not(offeredBeforeExpiry) and conformingFirstOfferBeforeExpiry) - - /// A supported wallet's transaction, admitted the same way and then flushed - /// one block late because the tip is reported one block back. + /// A supported wallet's transaction is admitted against a tip reported one + /// block back, below a boundary the hub has already flushed, and so flushed + /// one block late. run regressedTipDelaysFlush(c: HubConfig, payload: Payload): bool = started(c) .then(blocks(2)) @@ -271,7 +259,7 @@ module hubScenariosTest { run conformingSurvivesRegressionTest = regressedTipDelaysFlush(flakyTip, early) .expect(early.expiry == Some(7 + cfg.params.miningMargin)) - .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry) + .expect(conformingFirstOfferBeforeExpiry) .then(advance) .expect(height == 8 and not(mayAdvance)) .expect(wConformingFirstOfferInFlightABlock and conformingFirstOfferJudgedBeforeExpiry) @@ -393,7 +381,7 @@ module hubScenariosTest { /// and the node can no longer take it. run silenceAcrossBoundaryMissesMarginTest = silenceAcrossBoundary(staleLag, early) - .expect(not(conformingFirstOfferBeforeExpiry) and not(offeredBeforeExpiry)) + .expect(not(conformingFirstOfferBeforeExpiry)) .then(advance) .expect(height == 9 and not(mayAdvance) and flightWithinMargin(cfg)) .expect(not(nodeWouldTake(early)) and not(conformingFirstOfferJudgedBeforeExpiry)) @@ -512,29 +500,6 @@ module hubScenariosTest { // A Byzantine hub // ------------------------------------------------------------------------ - /// G6a needs the hub. It admits a transaction the expiry rule refuses: one - /// expiring at 5, taken at tip 3, when the next flush is at 6. - run hubAdmitsPastExpiryRuleTest = - started(byzHub) - .then(blocks(2)) - .then(flushBegin) - .expect(decision(tight) == Refused(ExpiryTooTight)) - .then(submitWith(tight, admitting(tight))) - .then(blocks(3)) - .then(flushBegin) - .expect(flightStart == 6 and h.inFlight() == Set(tight)) - .expect(not(offeredBeforeExpiry)) - - run hubAdmitsPastExpiryRuleControlTest = - started(byzHub) - .then(blocks(2)) - .then(flushBegin) - .then(deliver(tight)) - .expect(h.queued() == Set()) - .then(blocks(3)) - .then(flushBegin) - .expect(h.inFlight() == Set() and offeredBeforeExpiry) - /// G6b and G6c need the hub. A supported wallet's transaction is refused by /// an honest hub only for reasons that are not about the transaction. This /// hub takes one while it has not yet seen a tip, when an honest hub @@ -605,7 +570,7 @@ module hubScenariosTest { .then(flushBegin) .expect(height == 2 and h.tip == Some(3) and h.inFlight() == Set(early)) .expect(flushesAheadOfChain) - .expect(offeredBeforeExpiry and conformingFirstOfferBeforeExpiry) + .expect(conformingFirstOfferBeforeExpiry) /// The hub asks for the tip at every block. The indexer goes on answering 2 /// while `silence` more blocks arrive, and then answers truthfully. The @@ -630,23 +595,9 @@ module hubScenariosTest { .then(flushBegin) .expect(flightStart == 3 and h.inFlight() == Set(payload)) - /// G6a needs the indexer. With the tip withheld until height 5, a - /// transaction expiring at 5 is offered with no margin left. - run indexerWithholdsTipTest = - tipWithheld(tight, 3) - .expect(not(offeredBeforeExpiry)) - - run indexerWithholdsTipControlTest = - tipReported(tight) - .expect(offeredBeforeExpiry) - .then(judge(tight, Accepted)) - .then(flushEnd) - .then(blocks(2)) - .expect(height == 5 and offeredBeforeExpiry) - - /// G6b and G6c need the indexer. The same lie, kept up to height 8, does it - /// to a supported wallet's transaction: it is offered at 8 with expiry 9, - /// and after one block in flight the node cannot take it. + /// G6b and G6c need the indexer. With the tip withheld up to height 8, a + /// supported wallet's transaction is offered at 8 with expiry 9, and after + /// one block in flight the node cannot take it. run indexerWithholdsTipFromConformingTest = tipWithheld(early, 6) .expect(conforming(early, cfg.params.minWalletExpiry) and onTime == Set(early)) From 48cd16377ce1b8cabab982753d337fb07e89f272 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 16:50:41 +0400 Subject: [PATCH 66/80] test(zeronym): add a threat model to the spec README and record what was removed --- zeronym/spec/protocol/README.md | 114 +++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 39 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index c4a8e599..af8d15de 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -59,6 +59,45 @@ state or a violation found under it is reachable under `step`. Uniform random ch rarely gets a transaction as far as a block in 40 steps; the narrower relations do. Tier 3b re-checks each configuration's guarantees on those deeper traces. +## Threat model + +What the specification is afraid of, who could cause it, and which property +answers it. The network can drop, duplicate, delay and reorder any message; it +cannot forge one. The shim is assumed honest (attested); see T18. + +Threats the specification answers: + +| # | Threat | Adversary | Answered by | Strength | +|---|---|---|---|---| +| T1 | The operator sees a migration's contents | Operator behind the shim | G1 | Simulation; depends on the shim alone | +| T2 | Someone obtains a queued migration's bytes and publishes it early, breaking the batch | Unauthenticated third party; Byzantine hub or indexer | G2 | Simulation; needs hub and indexer honest | +| T3 | The wallet is served a different transaction than the one it asked for | Byzantine hub or indexer | G3 | Survives a Byzantine hub or indexer; txid only, not bytes or height | +| T4 | The wallet is told something false about its transaction's status | Byzantine hub or indexer; network reordering | G4 | Simulation; needs hub and indexer honest; per answer, not across answers | +| T5 | A supported wallet's migration expires while the hub holds it | Chain timing; flaky tip; Byzantine hub or indexer | G6b, G6c | Exhaustive (TLC); fails under a stale tip | +| T8 | The hub silently drops or admits entries outside its rules | Hub implementation error | A2, A3, G7 | Exhaustive over hub states | + +Threats the specification records but does not prevent: + +| # | Threat | Where it is recorded | +|---|---|---| +| T6 | Any admitted transaction, including one from an unsupported wallet, is offered too late | Not checked. G6a and its gap K3 were removed; see [Out of the model](#out-of-the-model) | +| T9 | The wallet is told "sent" but the hub never admits it | Gap K1 | +| T10 | The wallet sees its transaction's status go backwards | Gap K2 | +| T11 | An acknowledged migration is lost to a crash, a failed final flush, or a requeue drop | Gap K5 | +| T12 | A third party who knows a txid learns it is queued | Accepted disclosure W8 | +| T13 | A lying indexer makes the hub flush early, shrinking the batch | Witness W15 | + +Threats the specification does not model: + +| # | Threat | Status | +|---|---|---| +| T7 | The hub acknowledges a migration it never queued | Not modelled: nothing reads an ack. G8 was removed | +| T14 | Linking a wallet to its migration by source IP | Claimed protected in the repository README; not in the specification | +| T15 | Linking by submission size and arrival time | Listed there as not protected; not in the specification | +| T16 | The operator recovering txid and value through transparent-pool queries | Listed there as not protected; not in the specification | +| T17 | Batch-size and timing anonymity; partitioning the anonymity set across hubs | Out of scope (timing and anonymity; more than one hub) | +| T18 | A compromised shim or enclave host | A compromised enclave host is delegated to AWS (`zeronym/README.md`, "Physical security is delegated to AWS"). A malicious shim build is assumed away by attestation and is not discussed there | + ## Scope ### Protocol facts the specification rests on @@ -149,9 +188,14 @@ definitions they justify. | The frame-size lemma, `sizeOf` and F6 | Removed (C9): true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | | The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | -| The hub's schedule in the protocol specification: its phases, tip, cadence, drain, crash and restart, flight time, and what read them there: G6a-G6c, the refusal witnesses W4, the requeue witnesses W5-W7, the offer, verdict, admission, refusal and drop records, the tip models | Moved: the protocol uses the abstract hub, which `hubTest` checks the real hub refines; the schedule is checked exhaustively in the hub specification. The protocol's pinned runs that need a real hub step are replayed through it in `realisedRunsTest` | +| The hub's schedule in the protocol specification: its phases, tip, cadence, drain, crash and restart, flight time, and what read them there: G6b and G6c, the refusal witnesses W4, the requeue witnesses W5-W7, the offer, verdict, admission, refusal and drop records, the tip models | Moved: the protocol uses the abstract hub, which `hubTest` checks the real hub refines; the schedule is checked exhaustively in the hub specification. | | A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | +| G8 `ackImpliesQueued` and F13: an accepted ack is only for a payload the hub queued. In code: `queue.rs` admits before it acks | Removed: nothing reads an ack since the HTTP transport went. The abstraction lemma still fails if `hub` acks without queueing | +| A Byzantine hub's false ack (accepted but not queued, or queued but refused) | Removed with G8: no remaining guarantee reads it. A Byzantine hub still admits or refuses against the rules, and lies in lookup replies | +| G6a `offeredBeforeExpiry` and K3: the margin at the offer for every admitted transaction, including one whose wallet set an expiry below the supported floor. In code: admission's "provably survives its scheduled flush" (`zeronym/hub/src/queue.rs:497-519`) | Removed: it adds only unsupported wallets to G6b. Known not to hold under a tip reported behind the chain (K3, at `83133e3`); no longer checked | +| K1 as reachable-state rows, and the `everQueued` history they read | K1 is pinned by its two scripted runs. The simulation rows were the last readers of that history | +| Replaying each pinned protocol run through the real hub (`realisations`, `realisedRunsTest`) | Removed: it produced no finding. Violations and reached states of the protocol specification are shown over the abstract hub; `realisesTest` shows each abstract move has a real step | | Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | | Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties | | Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | @@ -194,7 +238,7 @@ queue, acks, replies and schedule. - Compose per hub: G1 (the shim alone); G3 (the shim's txid check on each reply); G4, which is why its name keeps "per hub": an answer was true at the - hub that gave it; G8; G6a, G6b and G6c, which read offer and verdict + hub that gave it; G6b and G6c, which read offer and verdict heights, not verdict values, so another hub publishing first changes nothing they read; K5. - Compose only if every hub is honest: G2. One Byzantine replica holds the @@ -437,8 +481,6 @@ declares a constant. Every other module is pure. | `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | | `protocol.qnt` | `protocol` | The transactions and the three configurations; `System`, `Audit`, where each output goes and the derived views; `truth`, the audit monitor `advance`, the guarantees, gaps and witnesses; the variables, `commit`, the named inits, the steps, the property aliases, the run vocabulary | | `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F15; A2-A3 and the abstraction lemma in `hubTest.qnt` | -| `tests/realisations.qnt` | `realisations` | The hub inputs, replies and final hub of each pinned run, and `realisedBy`, which each of those runs ends with. No runs of its own | -| `tests/realisedRunsTest.qnt` | `realisedRunsTest` | Each realisation, replayed through the real hub | | `tests/scenariosTest.qnt` | `scenariosTest` | Witnesses and pinned gap causes; `liveInitsTest` | | `tests/trustTest.qnt` | `trustTest` | One run and one control per "required" cell | @@ -481,8 +523,8 @@ goes. takes exactly the transition its function gives. A Byzantine one takes any member of a finite set that contains it (F12): -- **Byzantine hub.** Any ack for a submission it receives, with the payload - queued or not. Any reply to a lookup: not found, error, or found with no +- **Byzantine hub.** It admits or refuses a submission whatever the admission + rules say, and its ack says which. Any reply to a lookup: not found, error, or found with no body or any payload of the universe, at any of the three wire heights. Its internal moves (take, settle, give back, lose) are the honest ones. - **Byzantine indexer.** Any verdict, with the transaction relayed to the @@ -544,7 +586,6 @@ it keeps with the shipped one are in `hubMachine.qnt`. | F11 | `hub` and `shim` are total; an invalid input returns an error and changes nothing | `hubTest::totalityTest`, `shimTest::totalityTest` | | F12 | Each Byzantine relation contains the honest transition | `byzantineContainsHonestTest` in `hubTest`, `shimTest`, `indexerTest` | | F15 | The protocol's heightless verdict relation contains the honest one and is wider only by the expiry clause | `indexerTest::heightlessCoversTest` | -| F13 | An accepted ack is given only for a payload the hub then holds; a Byzantine hub can do otherwise | `hubTest::ackImpliesQueuedTest`, `hubTest::byzantineHubTest` | | F14 | The tip rule: first observation adopted; forward followed; a drop within the allowance followed; a larger drop ignored | `hubTest::tipRuleTest` | ### Guarantees @@ -555,11 +596,9 @@ it keeps with the shipped one are in `hubMachine.qnt`. | G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it and the operator's view; nothing updates it at publication | | G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | | G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree, or that hubs agree | -| G6a | `offeredBeforeExpiry` | Every transaction a hub offers is offered with the mining margin to spare: whatever was admitted, on every attempt. About the margin left when the flush begins, not about acceptance. Claimed under a timely tip | -| G6b | `conformingFirstOfferBeforeExpiry` | The same for supported wallets and for the first time a hub offers the transaction. Nothing about a later offer of a requeued entry. Also about the margin at the offer | +| G6b | `conformingFirstOfferBeforeExpiry` | A supported wallet's transaction is offered with the mining margin to spare, the first time a hub offers it. Nothing about a later offer of a requeued entry. About the margin left when the flush begins, not about acceptance | | G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and `flightWithinMargin` | | G7 | `hubTest::wellFormedTest` | Structural sanity of the hub: a queued entry is within its attempts and a down hub holds nothing, over every state in `REACH`. Not a trust-matrix row. Its nonce half (every nonce in use was minted) was a trace invariant and is cut (C10): the shim and the third party mint every nonce they send | -| G8 | `ackImpliesQueued` | An accepted ack from a hub is for a payload that hub had queued by then, whether or not anyone waits for the ack | No guarantee reads a field written by the function it constrains. The history the guarantees need (`audit`) is derived by `commit` from the state before and @@ -577,9 +616,6 @@ is 2000 traces of 40 steps at seed 7 under `step`, unless a step is named. | G2 | the abstract hub answers a queue hit with the queued body | `queuedBytesConfidential` on `baseline` | violated | | G3 | `interpretReply` skips the txid comparison | `txidAuthenticity` | **holds on `baseline`** (also under `quietStep`, 80 steps); violated on `byzHub` and `byzIndexer` (`quietStep`) | | G4 | the abstract hub answers not-found on a queue hit | `lookupValidityPerHub` on `baseline` | violated | -| G8 | the abstract hub acks accepted without queueing | `ackImpliesQueued` on `baseline` | violated | -| G8 | `hub` acks accepted without inserting | `abstractionTest` (the lemma) | fails | -| G6a | `hub` admits without the expiry check | `offeredBeforeExpiry` on the hub specification's `initTimely`, TLC | violated, 8 states | The G3 row is not what was predicted; see [Findings](#findings). @@ -610,8 +646,6 @@ chain cannot pass a running, idle hub that has not asked. | G2 | holds (`baseline`) | **required**: `hubServesQueuedBodyTest` | **required**: `indexerServesUnpublishedBodyTest`. One endpoint suffices | | G3 | holds (`baseline`) | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | | G4 | holds (`baseline`) | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | -| G8 | holds (`baseline`) | **required**: `hubAcksWithoutAdmittingTest` | holds (`byzIndexer`) | -| G6a | holds (`timely`) | **required**: `hubAdmitsPastExpiryRuleTest` | **required**: `indexerWithholdsTipTest`. Needs every endpoint | | G6b | holds (`timely`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | G6c | holds (`timely`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | | A3 | holds (`drainIsFinalTest`) | **required**: `hubAdmitsWhileDrainingTest` | holds (`drainIsFinalTest`) | @@ -627,16 +661,15 @@ exhaustive test, and its "required" cell is a scripted step. ### Known gaps, with every component honest -K1 and K2 are on the protocol specification; K3 to K8 on the hub +K1 and K2 are on the protocol specification; K3' to K8 on the hub specification, under its configurations. | Id | What is lost | Where | Form | Observed | Scripted runs | |---|---|---|---|---|---| -| K1 | Told ok does not mean the hub ever admits it | `baseline` | reachable states `wToldRefusedEverywhere`, `wToldNeverDelivered` | reached | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | +| K1 | Told ok does not mean the hub ever admits it | `baseline` | scripted runs only | shown | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | | K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | -| K3 | G6a for a tight-expiry transaction: admitted against a tip reported below a boundary already flushed | `flakyTip` | violated invariant | violated, as predicted | `tightExpiryAdmittedBehindFlushedBoundaryTest` | | K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | -| K4 | G6a, and G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | +| K4 | G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | | K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `timely` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | | K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | @@ -651,7 +684,7 @@ code bounds a flight in blocks. K1 is not stated as a violated invariant because the invariant is false on the ordinary success path too: the wallet is told ok before the hub has the -frame. In `toldOkAndNeverDeliveredTest` the run ends with the +frame. It is pinned by its two scripted runs and has no simulation row. In `toldOkAndNeverDeliveredTest` the run ends with the frame undelivered, and nothing obliges the network ever to deliver it. ### Witnesses @@ -679,8 +712,8 @@ every configuration where the guarantee is claimed: `vOperatorBlind`, `vQueuedBytesConfidential` (with W19 for its reply-body branch), `vTxidAuthenticity`, `vLookupValidityPerHub` (the log has a pending, a served transaction and a not-found; reached under `earlyLookupStep`, about 25 traces -in 2000, against 2 under `quietStep`), `vAckImpliesQueued`. -The antecedents of G6a, G6b and G6c are reachability rows of the hub +in 2000, against 2 under `quietStep`). +The antecedents of G6b and G6c are reachability rows of the hub specification. ### Two-state properties @@ -701,7 +734,7 @@ these, so the checks below are exhaustive over those parameters, not depth-bounded. These are not the hub specification's parameters (`timely`: mining margin 2, -expiry floor 7, which `realisedRunsTest` also uses), and `REACH` has one +expiry floor 7), and `REACH` has one parseable payload, no twin and no tight payload. The abstraction lemma is carried to the hub specification's parameters by argument, not by a check: `hub()` takes its parameters as arguments, and the abstract hub has none and @@ -749,24 +782,16 @@ Over the same `REACH` as A2 and A3, with lookups added, `hubTest` checks: | `realisesTest` | Each abstract move (accept, refuse, take, settle, a retryable verdict, give back, lose) has a concrete step that projects onto it | A temporary edit that makes `hub` ack a submission without queueing it fails -`abstractionTest` (the G8 rows of the mutation table under -[Guarantees](#guarantees)). +`abstractionTest`. The protocol specification's hub is this abstract one. What the lemma transfers: an invariant that holds over the abstract hub, and reads only queue membership and wire replies, holds over the real hub with -these parameters. That covers G2, G3, G4 and G8. What it does not transfer is +these parameters. That covers G2, G3 and G4. What it does not transfer is reachability. The abstract hub answers where the real one is down, starting, -stopped or stale, so a violation or a reached state shown over it may not -happen. `tests/realisedRunsTest.qnt` closes that gap for the pinned runs: for -K1a, K2 (a) to (e), W8, W9, W16, and each "required" run of the trust matrix, -it replays the hub inputs of the run through the real hub function from a -starting hub on `timely`'s parameters, each lie as a member of the -Byzantine relation, and checks the replies and the final queue. K1b has no -hub step. Each realisation is a value in `tests/realisations.qnt`, and each of -those protocol runs ends with `realisedBy`: the hub replies it sent, as a set -(the soup has no order), and its final abstract hub are the realisation's. A -protocol run edited without its realisation fails. +stopped or stale, so a violation or a reached state shown over it is a state +of the abstract hub. `realisesTest` shows each abstract move has a real hub +step behind it; no run is replayed through the real hub as a whole. No liveness property is claimed: the network may lose everything, and nobody waits for an ack. @@ -843,8 +868,8 @@ conforming, timely transaction (F7). What breaks it is a hub that admits while it has no tip, when an honest hub refuses everything (`hubAdmitsBeforeFirstTipTest`). -**7. G6 stops at the offer; G6c and K7 were added to see past it.** G6a and -G6b stamp an offer when the flush begins. The node judges later, and the chain +**7. G6 stops at the offer; G6c and K7 were added to see past it.** G6b +stamps an offer when the flush begins. The node judges later, and the chain may have moved. With the first scaling (margin 1) the runs that showed "the slack is exactly enough" ended one enabled block before the transaction became unacceptable. The schedule is now scaled with a margin of 2, flight time is @@ -1020,8 +1045,19 @@ With `offered` forgotten when the hub goes down, as `seen` and `onTime` are unchanged. The state counts fall: `timely` 141 492 states, depth 51; `flakyTip` 1 424 284, depth 43; `flakyTipSlowFlight` 156 352, depth 39. +With G6a and its rows removed, the tier was re-run with `timely`, `flakyTip` +and `flakyTipNoSlack` carrying the two supported migrations only (`early`, +`late`); `byzHub`, `byzIndexer` and `unknownUpgrade` keep `tight`. Every +remaining verdict is unchanged. The state counts fall: `timely` 20 030 states, +depth 40; `flakyTip` 113 496, depth 38; `flakyTipSlowFlight` 156 352, depth +39. One trace is longer: K5 under `quietStep` is violated in 12 states, not 9. +Without `tight`, the entry a requeue gives up as expired is a supported +wallet's, and that takes two unjudged flushes +(`requeueDropsAckedAsExpiredTest`). The tables above are as measured before +this change and still name G6a and the three-payload configurations. + Reachability, each as `not(..)` and each violated: on `timely`, -`wOfferWithExpiry` (6 states), `wConformingFirstOffer` (6), +`wConformingFirstOffer` (6 states), `wConformingFirstOfferInFlightABlock` (7), `wOffered` (7), `wRequeued` (9), `wDown` (2), `wRestartedOwing` (6), `wBlockInFlight` (7), `wStopped` (4); on `flakyTip`, the first three (6, 6, 7) and `wTimelyQueuedBehindEpoch` (6); From 0abd6512127b367edc1fcbe79d60d8d195f6591e Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 16:52:13 +0400 Subject: [PATCH 67/80] test(zeronym): say in the README that the Byzantine hub's ack is modelled as truthful --- zeronym/spec/protocol/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index af8d15de..d31c17e7 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -276,9 +276,11 @@ beyond loss in the soup. - **Hub.** In the protocol specification the hub is abstract: it may accept or refuse any submission, and take, settle, give back or lose its entries at any time. The three assumptions below are the hub specification's. -- **Byzantine hub.** A Byzantine hub lies only in what it acks and replies: - on a submit it may queue the payload or not and send any ack, and on a - lookup it may send any reply (see [Roles](#roles)). Every other move is the +- **Byzantine hub.** A Byzantine hub admits or refuses a submission whatever + the admission rules say, and on a lookup it may send any reply (see + [Roles](#roles)). Its ack is modelled as truthful: a real one could ack + anything, but nothing reads an ack, so no property here depends on it. + Every other move is the honest one: in the hub specification its flushes, verdicts, requeues, drain, crash and restart; in the protocol specification its take, settle, give back and lose. It cannot evict or withhold a queued entry, flush off schedule, or @@ -770,8 +772,9 @@ queued, the payloads out with a flush, and its wire replies. It has no phase, tip or schedule. A submit is accepted (the payload joins the queue) or refused under one of the three codes; a lookup is a queue hit for a queued txid and the indexer's answer otherwise; and the internal moves are take, -settle, give back what is kept, and lose everything. The Byzantine answers -are anything, with any body from the universe, and queue the payload or not. +settle, give back what is kept, and lose everything. A Byzantine hub answers a +lookup with anything, with any body from the universe; a submission it accepts +or refuses as the honest relation already allows. Over the same `REACH` as A2 and A3, with lookups added, `hubTest` checks: From 14aa25f8980ee503c576db8c5eb8526e8b47f501 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 17:01:01 +0400 Subject: [PATCH 68/80] test(zeronym): remove the divert model now the protocol spec covers each of its checks --- .github/workflows/zeronym-guards.yml | 24 +- zeronym/spec/check.sh | 67 ---- zeronym/spec/divert.qnt | 520 --------------------------- zeronym/spec/protocol/README.md | 3 - 4 files changed, 4 insertions(+), 610 deletions(-) delete mode 100755 zeronym/spec/check.sh delete mode 100644 zeronym/spec/divert.qnt diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index f574a296..141cc5fd 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -40,29 +40,13 @@ jobs: - name: Repository claim guards run: sh zeronym/guards.sh - # The Quint model of the shim <-> hub divert protocol. Simulation only, so it - # stays in the cheap tier. - spec: - runs-on: ubuntu-latest - timeout-minutes: 10 - # It runs code fetched at run time (npx, Quint's evaluator), so it gets a - # read-only token and a pinned checkout. - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - # The runner image's Node is enough for Quint. - - name: Divert protocol model - run: sh zeronym/spec/check.sh - - # The standalone Quint specification of the whole protocol: typecheck, tests + # The Quint specification of the protocol: typecheck, tests # and bounded random simulation. About 2 minutes on a 16-core machine. spec-protocol: runs-on: ubuntu-latest timeout-minutes: 15 - # Same reasoning as `spec`: it runs code fetched at run time. + # It runs code fetched at run time (npx, Quint's evaluator), so it gets a + # read-only token and a pinned checkout. permissions: contents: read steps: @@ -82,7 +66,7 @@ jobs: spec-protocol-tlc: runs-on: ubuntu-latest timeout-minutes: 45 - # Same reasoning as `spec`: it runs code fetched at run time. + # Same reasoning as `spec-protocol`: it runs code fetched at run time. permissions: contents: read steps: diff --git a/zeronym/spec/check.sh b/zeronym/spec/check.sh deleted file mode 100755 index 1ed2e470..00000000 --- a/zeronym/spec/check.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/sh -# Simulate the divert model and assert every invariant's expected outcome. -# -# "holds" rows are the protocol's safety claims against an honest hub. "fails" rows are the two fixed -# bugs, reproduced with their fix switched off, and the known gaps: if -# one starts holding, the code or the model changed and divert.qnt needs a -# look. -# -# QUINT defaults to `npx @informalsystems/quint@0.32.0`. QUINT_BACKEND=typescript -# skips the Rust evaluator, which is downloaded from GitHub on first use. -set -u - -cd "$(dirname "$0")" -QUINT=${QUINT:-"npx --yes @informalsystems/quint@0.32.0"} -BACKEND=${QUINT_BACKEND:-rust} -SAMPLES=${QUINT_SAMPLES:-20000} -failures=0 - -# Classified by Quint's verdict line, so a crash, a download failure or a -# misspelt invariant fails the gate instead of passing as a counterexample. -check() { - main=$1 invariant=$2 expect=$3 - out=$($QUINT run divert.qnt --backend="$BACKEND" --main="$main" --invariant="$invariant" \ - --max-samples="$SAMPLES" --max-steps=30 --seed=7 2>&1) - case $out in - *"[ok] No violation found"*) got=holds ;; - *"[violation] Found an issue"*) got=fails ;; - *) - echo "ERROR $main.$invariant: quint gave no verdict" - echo "$out" | tail -20 - failures=$((failures + 1)) - return - ;; - esac - if [ "$got" = "$expect" ]; then - echo "ok $main.$invariant $got" - else - echo "FAIL $main.$invariant expected $expect, got $got" - failures=$((failures + 1)) - fi -} - -$QUINT typecheck divert.qnt || exit 1 - -echo "---- runs" -for main in badIndexer honest current; do - if $QUINT test divert.qnt --main "$main"; then - : - else - failures=$((failures + 1)) - fi -done - -check honest pendingVisible holds -check honest safety holds -check honest pendingIsTrue holds -check badIndexer pendingIsTrue fails -check before_ecb4641 pendingVisible fails -check before_45e408f noQueuedBytes fails -check current noQueuedBytes fails -check current noEarlyBytes fails -check current noSilentRefusal fails -check current pendingIsTrue fails -check current queuedNotSuppressed fails -check honest pendingMonotone fails - -exit "$failures" diff --git a/zeronym/spec/divert.qnt b/zeronym/spec/divert.qnt deleted file mode 100644 index 54cb5485..00000000 --- a/zeronym/spec/divert.qnt +++ /dev/null @@ -1,520 +0,0 @@ -// Shim <-> hub divert protocol, over the Nym transport. -// -// Scope: a wallet diverts migrations through the stateless shim to the hub, -// then polls GetTransaction. The hub queues, flushes on epochs, and answers -// lookups from its queue or its indexer. The mixnet may lose, duplicate and -// reorder any message. A transaction's bytes are modelled as its id, and "" -// is an empty body. An unparseable body has no txid: `parseable` is per tx. -// -// Code this mirrors: -// shim intercept.rs get_transaction reply arms, nym.rs nonce correlation -// hub server.rs lookup, queue.rs admit, batcher.rs flush -// -// Run all of these with spec/check.sh, or one at a time (npm i -g @informalsystems/quint): -// quint run --main=honest --invariant=safety spec/divert.qnt holds -// quint run --main=honest --invariant=pendingVisible ... holds (honest hub) -// quint run --main=current --invariant=noQueuedBytes ... fails: known gap (lying hub) -// quint run --main=current --invariant=noEarlyBytes ... fails: known gap (lying hub) -// quint run --main=before_ecb4641 --invariant=pendingVisible ... fails: the sentinel bug -// quint run --main=before_45e408f --invariant=noQueuedBytes ... fails: the byte leak -// quint run --main=current --invariant=noSilentRefusal ... fails: known gap -// quint run --main=current --invariant=pendingIsTrue ... fails: known gap (lying hub) -// quint run --main=current --invariant=queuedNotSuppressed ... fails: hostile not-found -// quint run --main=badIndexer --invariant=pendingIsTrue ... fails: zero RawTransaction -// quint run --main=honest --invariant=pendingMonotone ... fails: known gap (flush window) -// Simulation samples random traces; `quint verify` (Apalache) checks every -// trace up to a bound. -module divert { - // Toggles that reproduce fixed bugs. - const SENTINEL_ARM: bool // ecb4641f7e: shim relays found/height 0/empty ahead of the L4 guard - const HUB_HIDES_QUEUED: bool // 45e408f0ff: hub answers a queue hit with no bytes - const HONEST_HUB: bool // false lets the hub answer found, notfound, or error - const CONFORMING_INDEXER: bool // false lets a missed lookup return a zero RawTransaction - const TXS: Set[str] - const MAX_NONCE: int - - pure val EMPTY = "" - - // One record shape for every message; `kind` picks the fields that matter. - // `hubSaw` and `honest` are ghost fields: the hub's state for the tx when it - // replied, and whether the reply followed the hub's code. - type Msg = { - kind: str, // "submit" | "lookup" | "reply" - tx: str, - nonce: int, - disp: str, // "found" | "notfound" | "error" - height: int, - data: str, - hubSaw: str, - honest: bool, - } - - // What the wallet saw for one lookup. - // `disp` is the reply's disposition, before the shim rewrites a mismatched - // found into not-found. A timeout has no reply, so its disp is "". - type Obs = { tx: str, status: str, data: str, hubSaw: str, honest: bool, disp: str } - - // hub[tx]: "absent" | "queued" | "flushing" | "mempool" | "mined" | "dropped" - // chain[tx]: what a broadcast left on the indexer. "none" until the first - // acceptance. Admit and a dropped queue copy do not clear it. - var hub: str -> str - var chain: str -> str - var net: Set[Msg] - var waiters: int -> str // shim: lookup nonce -> queried tx - var nextNonce: int - var told: Set[str] // wallet was told its submit succeeded - var refused: Set[str] // hub refused a submit (the ack never reaches the wallet) - var obs: List[Obs] - var parseable: str -> bool // false: that body has no txid; find_by_txid skips it - - pure def submitMsg(t: str): Msg = - { kind: "submit", tx: t, nonce: -1, disp: "", height: 0, data: EMPTY, hubSaw: "", honest: true } - - pure def lookupMsg(t: str, n: int): Msg = - { kind: "lookup", tx: t, nonce: n, disp: "", height: 0, data: EMPTY, hubSaw: "", honest: true } - - pure def replyMsg(t: str, n: int, disp: str, h: int, d: str, saw: str): Msg = - { kind: "reply", tx: t, nonce: n, disp: disp, height: h, data: d, hubSaw: saw, honest: true } - - // find_by_txid: the queue hits only when some entry's txid is the query. - // A parseable body's txid is its id. An unparseable body has none. - def queueHit(st: str, t: str): bool = - st == "queued" and TXS.exists(e => parseable.get(e) and e == t) - - // server.rs lookup: queue first, then the indexer. The indexer image survives - // a later queue copy being admitted or dropped. - def hubReply(t: str, n: int, st: str, hides: bool): Msg = - if (queueHit(st, t)) replyMsg(t, n, "found", 0, if (hides) EMPTY else t, st) - else if (chain.get(t) == "mempool" and parseable.get(t)) replyMsg(t, n, "found", 0, t, "mempool") - else if (chain.get(t) == "mined" and parseable.get(t)) replyMsg(t, n, "found", 1, t, "mined") - else replyMsg(t, n, "notfound", 0, EMPTY, st) // absent, flushing, dropped, queued with no txid, or an image of one - - // intercept.rs get_transaction: the reply arms, in order. - // lookup_is_for_query rejects bytes that do not deserialize, so a found body - // is the queried tx only when it has a txid. - def shimHandle(q: str, r: Msg, sentinelArm: bool): Obs = - if (r.disp == "found" and sentinelArm and r.data == EMPTY and r.height == 0) - { tx: q, status: "pending", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } - else if (r.disp == "found") - // L4 guard: the bytes must be the queried transaction. Status becomes - // not-found; disp stays found, so a mismatch is not a not-found reply. - if (r.data == q and parseable.get(q)) { tx: q, status: "tx", data: r.data, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } - else { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } - else if (r.disp == "notfound") { tx: q, status: "notfound", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } - else { tx: q, status: "unavailable", data: EMPTY, hubSaw: r.hubSaw, honest: r.honest, disp: r.disp } - - action init = all { - hub' = TXS.mapBy(_ => "absent"), - net' = Set(), - waiters' = Map(), - nextNonce' = 0, - told' = Set(), - refused' = Set(), - obs' = List(), - parseable' = TXS.mapBy(_ => true), - chain' = TXS.mapBy(_ => "none"), - } - - action unchangedExcept_net_hub = all { - waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - parseable' = parseable, chain' = chain, - } - - // Nym submit is dispatch-only: the wallet hears success once the frame is - // handed to the transport (nym.rs send_submit). - action walletSubmit(t: str): bool = all { - net' = net.union(Set(submitMsg(t))), - told' = told.union(Set(t)), - hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, - parseable' = parseable, chain' = chain, - } - - // The body does not deserialize. It is still submitted; admit holds the bytes - // with no txid. Kept out of `step`; the run is the witness. - action submitUnparseable(t: str): bool = all { - net' = net.union(Set(submitMsg(t))), - told' = told.union(Set(t)), - parseable' = parseable.set(t, false), - hub' = hub, waiters' = waiters, nextNonce' = nextNonce, refused' = refused, obs' = obs, - chain' = chain, - } - - // A resend of the same bytes is admitted again while the tx is published, - // flushing, or returning from a retry. Lookup prefers the queue, so the next - // answer is the empty sentinel. A resend that arrives while the bytes are - // already queued stays a no-op (sha256 dedup). - action hubAdmit(t: str): bool = all { - net.contains(submitMsg(t)), - hub.get(t).in(Set("absent", "dropped", "flushing", "mempool", "mined")), - hub' = hub.set(t, "queued"), - net' = net, - unchangedExcept_net_hub, - } - - // TipStale, Draining, ExpiryTooTight, Full: the ack says refused and the - // shim has already dropped the waiter. - action hubRefuse(t: str): bool = all { - net.contains(submitMsg(t)), - hub.get(t).in(Set("absent", "dropped")), - refused' = refused.union(Set(t)), - hub' = hub, net' = net, parseable' = parseable, chain' = chain, - waiters' = waiters, nextNonce' = nextNonce, told' = told, obs' = obs, - } - - // batcher.rs: an epoch boundary drains the whole queue at once. - action flushStart: bool = all { - TXS.exists(t => hub.get(t) == "queued"), - hub' = hub.keys().mapBy(t => if (hub.get(t) == "queued") "flushing" else hub.get(t)), - net' = net, - unchangedExcept_net_hub, - } - - // The run's stand-in for one broadcast_batch verdict: the tx is in the mempool. - action markMempool(t: str): bool = all { - hub.get(t) == "flushing", - hub' = hub.set(t, "mempool"), - net' = net, - chain' = chain.set(t, "mempool"), - waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - parseable' = parseable, - } - - // A published tx stays on the indexer. A later mempool acceptance must not - // downgrade a mined one. - pure def chainAfter(prev: str, outcome: str): str = - if (outcome == "mempool" and prev != "mined") "mempool" else prev - - // broadcast_batch verdicts: published, rejected, or retryable (requeued). - // Only an acceptance updates the indexer. A rejection or a requeue leaves - // whatever was already broadcast. - action flushOutcome(t: str): bool = all { - hub.get(t) == "flushing", - nondet outcome = Set("mempool", "dropped", "queued").oneOf() - all { - hub' = hub.set(t, outcome), - net' = net, - chain' = chain.set(t, chainAfter(chain.get(t), outcome)), - waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - parseable' = parseable, - }, - } - - action mine(t: str): bool = all { - hub.get(t) == "mempool", - hub' = hub.set(t, "mined"), - net' = net, - chain' = chain.set(t, "mined"), - waiters' = waiters, nextNonce' = nextNonce, told' = told, refused' = refused, obs' = obs, - parseable' = parseable, - } - - action walletLookup(t: str): bool = all { - nextNonce < MAX_NONCE, - net' = net.union(Set(lookupMsg(t, nextNonce))), - waiters' = waiters.put(nextNonce, t), - nextNonce' = nextNonce + 1, - hub' = hub, told' = told, refused' = refused, obs' = obs, parseable' = parseable, chain' = chain, - } - - action hubAnswer(n: int, t: str): bool = all { - net.contains(lookupMsg(t, n)), - net' = net.union(Set(hubReply(t, n, hub.get(t), HUB_HIDES_QUEUED))), - hub' = hub, - unchangedExcept_net_hub, - } - - // Indexer unreachable (chain.rs: no endpoint answered). A queue hit answers - // before the indexer is asked. - action hubError(n: int, t: str): bool = all { - net.contains(lookupMsg(t, n)), - not(queueHit(hub.get(t), t)), - net' = net.union(Set(replyMsg(t, n, "error", 0, EMPTY, hub.get(t)))), - hub' = hub, - unchangedExcept_net_hub, - } - - // Queue miss forwards the indexer image. A non-conforming indexer answers - // that path with a zero RawTransaction: found, empty body, height 0. The - // shim renders pending. A queue hit returns before the indexer is asked. - action indexerZero(n: int, t: str): bool = all { - not(CONFORMING_INDEXER), - net.contains(lookupMsg(t, n)), - not(queueHit(hub.get(t), t)), - net' = net.union(Set(replyMsg(t, n, "found", 0, EMPTY, hub.get(t)))), - hub' = hub, - unchangedExcept_net_hub, - } - - // A hostile hub may answer a lookup with found, notfound, or error. - // Height is 0 or 1. The body is empty or one of the modelled tx ids. - action hubLie(n: int, t: str): bool = all { - not(HONEST_HUB), - net.contains(lookupMsg(t, n)), - nondet disp = Set("found", "notfound", "error").oneOf() - nondet d = TXS.union(Set(EMPTY)).oneOf() - nondet h = Set(0, 1).oneOf() - net' = net.union(Set(replyMsg(t, n, disp, h, d, hub.get(t)).with("honest", false))), - hub' = hub, - unchangedExcept_net_hub, - } - - // nym.rs: a reply for an unknown nonce is dropped; a known one resolves the - // waiter once. The message has to be one that was actually queued. - action shimDeliver(r: Msg): bool = all { - r.kind == "reply", - net.contains(r), - waiters.keys().contains(r.nonce), - obs' = obs.append(shimHandle(waiters.get(r.nonce), r, SENTINEL_ARM)), - waiters' = waiters.keys().exclude(Set(r.nonce)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, chain' = chain, - } - - // 90 s lookup deadline. - action shimTimeout(n: int): bool = all { - waiters.keys().contains(n), - obs' = obs.append({ tx: waiters.get(n), status: "unavailable", data: EMPTY, hubSaw: "timeout", honest: true, disp: "" }), - waiters' = waiters.keys().exclude(Set(n)).mapBy(k => waiters.get(k)), - net' = net, hub' = hub, nextNonce' = nextNonce, told' = told, refused' = refused, parseable' = parseable, chain' = chain, - } - - // A run injects a reply no hub action produces: an L4 mismatch, the sentinel - // image alone, or a dishonest not-found. Not part of `step`. - action stageReply(r: Msg): bool = all { - r.kind == "reply", - net' = net.union(Set(r)), - hub' = hub, - unchangedExcept_net_hub, - } - - // Take the reply an action queued for this nonce, not one the run wrote down. - action deliverReply(n: int): bool = all { - nondet r = net.oneOf() - all { - r.kind == "reply", - r.nonce == n, - shimDeliver(r), - }, - } - - action lose(m: Msg): bool = all { - net' = net.exclude(Set(m)), - hub' = hub, - unchangedExcept_net_hub, - } - - action step = { - nondet t = TXS.oneOf() - nondet n = 0.to(MAX_NONCE - 1).oneOf() - any { - walletSubmit(t), - hubAdmit(t), - hubRefuse(t), - flushStart, - flushOutcome(t), - mine(t), - walletLookup(t), - hubAnswer(n, t), - hubError(n, t), - indexerZero(n, t), - hubLie(n, t), - shimTimeout(n), - all { net.size() > 0, nondet m = net.oneOf() any { shimDeliver(m), lose(m) } }, - } - } - - // ---- Shim claims: hold for an honest hub's replies ---- - - // An honest queue hit on a parseable tx reaches the wallet as pending. - // `not(o.honest)` drops lying replies. An unparseable body has no txid, so - // a lookup misses it even while the hub holds the bytes. - val pendingVisible: bool = obs.foldl(true, (ok, o) => - ok and (not(o.honest) or o.hubSaw != "queued" or not(parseable.get(o.tx)) or o.status == "pending")) - - // ---- Hub claims: hold for an honest hub; attestation is what makes it one ---- - - // A queued migration's bytes never leave the hub before publication. - val noQueuedBytes: bool = net.forall(m => - not(m.kind == "reply" and m.hubSaw == "queued" and m.data != EMPTY)) - - // The wallet sees a transaction's bytes only once it is published. - val noEarlyBytes: bool = obs.foldl(true, (ok, o) => - ok and (o.status != "tx" or o.hubSaw.in(Set("mempool", "mined")))) - - val safety: bool = pendingVisible and noQueuedBytes and noEarlyBytes - - // ---- Known gaps: expected to fail; each counterexample documents one ---- - - // Over Nym the wallet is told success even when the hub refuses. - val noSilentRefusal: bool = told.intersect(refused) == Set() - - // noQueuedBytes and noEarlyBytes also fail under a lying hub: a hub holding - // a queued migration can release its bytes early. - - // Pending only ever comes from a real queue hit. A lying hub can fake it. - val pendingIsTrue: bool = obs.foldl(true, (ok, o) => - ok and (o.status != "pending" or o.hubSaw == "queued")) - - // A hostile hub can answer not-found for a migration it has queued. - // This is the reply's disposition. L4 rewrites a mismatched found into - // status not-found and must not count. - val queuedNotSuppressed: bool = obs.foldl(true, (ok, o) => - ok and not(o.hubSaw == "queued" and o.disp == "notfound" and not(o.honest))) - - // Once pending, a tx reads as not-found only if the hub dropped it. The - // flush window (server.rs lookup note) breaks this. - val pendingMonotone: bool = - 0.to(obs.length() - 1).forall(i => 0.to(obs.length() - 1).forall(j => - not(i < j and obs[i].tx == obs[j].tx and obs[i].status == "pending" - and obs[j].status == "notfound" and obs[j].hubSaw != "dropped"))) - - // Once the wallet has been shown a transaction's bytes, a later observation - // of that tx is those bytes again or unavailable. A reordered reply and a - // resend after publication both break this. - val statusNeverRegresses: bool = - 0.to(obs.length() - 1).forall(i => 0.to(obs.length() - 1).forall(j => - not(i < j and obs[i].tx == obs[j].tx and obs[i].status == "tx" - and not(obs[j].status.in(Set("tx", "unavailable")))))) -} - -module current { - import divert( - SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = false, - CONFORMING_INDEXER = true, - TXS = Set("a", "b"), MAX_NONCE = 4, - ).* - - run hostileHubHidesQueueHitTest = - init - .then(walletSubmit("a")) - .then(hubAdmit("a")) - .then(walletLookup("a")) - .then(stageReply(replyMsg("a", 0, "notfound", 0, EMPTY, "queued").with("honest", false))) - .then(shimDeliver(replyMsg("a", 0, "notfound", 0, EMPTY, "queued").with("honest", false))) - .expect(obs[0].disp == "notfound" and obs[0].status == "notfound" and not(queuedNotSuppressed)) -} - -module honest { - import divert( - SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = true, - CONFORMING_INDEXER = true, - TXS = Set("a", "b"), MAX_NONCE = 4, - ).* - - // L4: a found body that is not the queried tx is not served. - run mismatchedLookupRefusedTest = - init - .then(walletLookup("a")) - .then(stageReply(replyMsg("a", 0, "found", 1, "b", "mined"))) - .then(shimDeliver(replyMsg("a", 0, "found", 1, "b", "mined"))) - .expect(obs[0].status == "notfound") - - // The queue-hit image is pending, ahead of L4. - run sentinelLookupPendingTest = - init - .then(walletLookup("a")) - .then(stageReply(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) - .then(shimDeliver(replyMsg("a", 0, "found", 0, EMPTY, "queued"))) - .expect(obs[0].status == "pending") - - // Unparseable bytes stay queued. Lookup misses them: no entry has that txid. - run unparseableLookupMissesTest = - init - .then(submitUnparseable("a")) - .then(hubAdmit("a")) - .then(walletLookup("a")) - .then(hubAnswer(0, "a")) - .then(deliverReply(0)) - .expect(not(parseable.get("a")) and hub.get("a") == "queued" and obs[0].status == "notfound" and obs[0].hubSaw == "queued") - - // A queued body with no txid is a miss, so the indexer is asked. - run unparseableAsksIndexerTest = - init - .then(submitUnparseable("a")) - .then(hubAdmit("a")) - .then(walletLookup("a")) - .then(hubError(0, "a")) - .then(deliverReply(0)) - .expect(hub.get("a") == "queued" and obs[0].status == "unavailable" and obs[0].hubSaw == "queued") - - // A found body that does not deserialize is not the queried transaction. - run unparseableFoundRefusedTest = - init - .then(submitUnparseable("a")) - .then(walletLookup("a")) - .then(stageReply(replyMsg("a", 0, "found", 1, "a", "mined"))) - .then(shimDeliver(replyMsg("a", 0, "found", 1, "a", "mined"))) - .expect(not(parseable.get("a")) and obs[0].status == "notfound" and obs[0].disp == "found") - - // The mempool image is only returned for a body that has a txid. - run unparseableMempoolMissesTest = - init - .then(submitUnparseable("a")) - .then(hubAdmit("a")) - .then(flushStart) - .then(markMempool("a")) - .then(walletLookup("a")) - .then(hubAnswer(0, "a")) - .then(deliverReply(0)) - .expect(chain.get("a") == "mempool" and obs[0].status == "notfound" and obs[0].disp == "notfound") - - run reorderedReplyRegressesTest = - init - .then(walletSubmit("a")) - .then(hubAdmit("a")) - .then(walletLookup("a")) - .then(hubAnswer(0, "a")) - .then(flushStart) - .then(markMempool("a")) - .then(walletLookup("a")) - .then(hubAnswer(1, "a")) - .then(deliverReply(1)) - .then(deliverReply(0)) - .expect(obs[0].status == "tx" and obs[1].status == "pending" and not(statusNeverRegresses)) - - run resendAfterPublishRegressesTest = - init - .then(walletSubmit("a")) - .then(hubAdmit("a")) - .then(flushStart) - .then(markMempool("a")) - .then(walletLookup("a")) - .then(hubAnswer(0, "a")) - .then(deliverReply(0)) - .then(hubAdmit("a")) - .then(walletLookup("a")) - .then(hubAnswer(1, "a")) - .then(deliverReply(1)) - .expect(obs[0].status == "tx" and obs[1].status == "pending" and not(statusNeverRegresses)) -} - -module before_ecb4641 { - import divert( - SENTINEL_ARM = false, HUB_HIDES_QUEUED = true, HONEST_HUB = true, - CONFORMING_INDEXER = true, - TXS = Set("a", "b"), MAX_NONCE = 4, - ).* -} - -module before_45e408f { - import divert( - SENTINEL_ARM = false, HUB_HIDES_QUEUED = false, HONEST_HUB = true, - CONFORMING_INDEXER = true, - TXS = Set("a", "b"), MAX_NONCE = 4, - ).* -} - -// Honest hub, indexer answers the forward path with a zero RawTransaction. -module badIndexer { - import divert( - SENTINEL_ARM = true, HUB_HIDES_QUEUED = true, HONEST_HUB = true, - CONFORMING_INDEXER = false, - TXS = Set("a", "b"), MAX_NONCE = 4, - ).* - - run indexerZeroForgesPendingTest = - init - .then(walletLookup("a")) - .then(indexerZero(0, "a")) - .then(deliverReply(0)) - .expect(obs[0].status == "pending" and obs[0].hubSaw == "absent" and obs[0].honest) -} diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index d31c17e7..bf7ac6e6 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -6,9 +6,6 @@ It is written from the protocol, not from the code's structure: it says what a wallet can rely on, which components each guarantee trusts, where the known gaps are, and it checks each of those statements. -It is separate from `zeronym/spec/divert.qnt`, which it does not replace or -modify. - ## What "holds" means here **Bounded random simulation.** Every "holds" below was produced by From 01775f4034771cef21947bc18520aeca0437733f Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 17:09:21 +0400 Subject: [PATCH 69/80] test(zeronym): assert each guarantee before the Byzantine step in place of control runs, and drop two contrast runs TLC covers --- zeronym/spec/protocol/README.md | 23 +++-- zeronym/spec/protocol/check.sh | 2 +- .../spec/protocol/tests/hubScenariosTest.qnt | 89 ++----------------- zeronym/spec/protocol/tests/trustTest.qnt | 52 ++--------- 4 files changed, 28 insertions(+), 138 deletions(-) diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/protocol/README.md index bf7ac6e6..0294b0dd 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/protocol/README.md @@ -180,7 +180,7 @@ definitions they justify. | HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | | The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | -| Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed (C7): a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it, with its control | +| Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed (C7): a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it | | Payloads of the third party's own making, and W17 (one of them queued) | Removed (C8): the hub's address is public and unauthenticated, so this is possible, but only W17 read them. The third party still submits what it has learned or the chain has published (K2c) | | The frame-size lemma, `sizeOf` and F6 | Removed (C9): true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | | More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | @@ -481,7 +481,7 @@ declares a constant. Every other module is pure. | `protocol.qnt` | `protocol` | The transactions and the three configurations; `System`, `Audit`, where each output goes and the derived views; `truth`, the audit monitor `advance`, the guarantees, gaps and witnesses; the variables, `commit`, the named inits, the steps, the property aliases, the run vocabulary | | `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F15; A2-A3 and the abstraction lemma in `hubTest.qnt` | | `tests/scenariosTest.qnt` | `scenariosTest` | Witnesses and pinned gap causes; `liveInitsTest` | -| `tests/trustTest.qnt` | `trustTest` | One run and one control per "required" cell | +| `tests/trustTest.qnt` | `trustTest` | One run per "required" cell, with the guarantee asserted just before the Byzantine step | ```mermaid flowchart BT @@ -623,17 +623,16 @@ The G3 row is not what was predicted; see [Findings](#findings). Which components must be honest for each guarantee. Single-fault. "holds" is a tier 3 simulation row on the named configuration, with its antecedent witnessed there in tier 3b. "required" is a scripted run in `tests/trustTest.qnt` in which -the component is Byzantine and the guarantee fails, followed by its control -(same wallet inputs, honest transition, guarantee holds). Such a cell has no -simulation row. +the component is Byzantine and the guarantee fails. The run asserts the +guarantee in the state just before the Byzantine step, so the lie is what +breaks it. Such a cell has no simulation row. Every cell was a prediction, except the G6c row, which was added after review and derived by running. **Observed verdicts agree with the predictions in every cell of this table except the G6b entries marked below.** The two tip-withholding runs in the indexer column have the hub ask for the tip -at every block and the indexer answer with a stale one; their controls are the -same polls answered truthfully. The simulation rows for those cells classify a +at every block and the indexer answer with a stale one. The simulation rows for those cells classify a verdict line and cannot say which lie a trace used. The counterexamples the simulator finds at seed 7 were read by hand and both use reports below the true height. With truthful answers `byzIndexer` behaves as `baseline`, where the @@ -667,12 +666,12 @@ specification, under its configurations. |---|---|---|---|---|---| | K1 | Told ok does not mean the hub ever admits it | `baseline` | scripted runs only | shown | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | | K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | -| K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; contrast `conformingSurvivesRegressionTest` | +| K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; with the slack, G6b and G6c hold on `flakyTip` under TLC | | K4 | G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | | K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `timely` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | -| K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; control `requeueUnderTimelyTipDropsTest` | +| K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; under a timely tip the predicate holds on `timely` under TLC | -| K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest`; contrast `conformingSurvivesRegressionTest` | +| K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest` | | K8 | A supported wallet's transaction, acknowledged on time, then lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. G6b and G6c do not cover it: to the restarted hub the resend is a late first arrival | `flakyTip` | scripted run | shown; not a TLC row | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | K7 was added after review. The four-term budget (`reorgSlackFits`) holds with @@ -755,8 +754,8 @@ definition; the closure found the counterexample. A3 is stated of an honest hub only. Draining is an admission rule, and a Byzantine hub is not bound by admission rules: `hubAdmitsWhileDrainingTest` -takes a submission into the queue after the drain began, and its control -refuses the same frame. +takes a submission into the queue after the drain began, having first shown +that an honest hub would refuse the same frame. The old A1, "a transaction's chain status never moves backwards", was an assumption about the environment and is true by construction of the chain diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/protocol/check.sh index 7dd8bb61..befb6609 100755 --- a/zeronym/spec/protocol/check.sh +++ b/zeronym/spec/protocol/check.sh @@ -82,7 +82,7 @@ finish() { SPELLS="spells/basicSpells.qnt:6 spells/soup.qnt:4" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt protocol.qnt" FUNCTIONAL="tests/wireTest.qnt:11 tests/indexerTest.qnt:14 tests/hubTest.qnt:26 tests/shimTest.qnt:13 - tests/hubScenariosTest.qnt:33 tests/scenariosTest.qnt:21 tests/trustTest.qnt:17" + tests/hubScenariosTest.qnt:28 tests/scenariosTest.qnt:21 tests/trustTest.qnt:12" fail() { echo "FAIL $1" diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/protocol/tests/hubScenariosTest.qnt index 11359e81..bc94af58 100644 --- a/zeronym/spec/protocol/tests/hubScenariosTest.qnt +++ b/zeronym/spec/protocol/tests/hubScenariosTest.qnt @@ -4,8 +4,8 @@ /// /// A run is a sequence of the machine's own steps from `initWith(c)`. Where a /// run says a property fails, it also says what in the state makes it fail. -/// A control is the same inputs in the same configuration with the one -/// decisive choice made the honest way; in the control the property holds. +/// Where a Byzantine choice breaks a property, the run asserts the property +/// just before that choice. /// /// The schedule in every configuration: the chain starts at height 1, a flush /// is scheduled at heights 3, 6, 9 and 12, and the mining margin is 2 blocks. @@ -126,27 +126,6 @@ module hubScenariosTest { .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) .expect(conformingEveryOfferBeforeExpiry and ackedIsHeldOrSettled) - /// K6, control. The wallet inputs of K6 under a timely tip. The second - /// requeue is judged at tip 9, finds that an expiry of 11 does not survive - /// the flush at 12, and drops the entry: it is never offered past its - /// expiry. - run requeueUnderTimelyTipDropsTest = - started(timely) - .then(blocks(2)) - .then(flushBegin) - .then(blocks(2)) - .then(deliver(late)) - .then(block) - .then(flush([late], Retryable)) - .expect(h.queue == Map(late -> 1) and wRequeued) - .then(blocks(3)) - .then(flushBegin) - .expect(flightStart == 9 and conformingEveryOfferBeforeExpiry) - .then(judge(late, Retryable)) - .expect(requeueReport == requeued(0, 1, 0)) - .then(flushEnd) - .expect(h.queue == Map()) - // ------------------------------------------------------------------------ // K5. Acknowledged, then lost // ------------------------------------------------------------------------ @@ -253,29 +232,16 @@ module hubScenariosTest { .expect(flightStart == 7 and h.inFlight() == Set(payload)) .expect(conforming(payload, cfg.params.minWalletExpiry)) - /// K3', contrast. The reorg slack leaves the transaction exactly the mining - /// margin at the offer. One block arrives while the batch is in flight, - /// which the margin is there to pay for, and the node still accepts it. - run conformingSurvivesRegressionTest = - regressedTipDelaysFlush(flakyTip, early) - .expect(early.expiry == Some(7 + cfg.params.miningMargin)) - .expect(conformingFirstOfferBeforeExpiry) - .then(advance) - .expect(height == 8 and not(mayAdvance)) - .expect(wConformingFirstOfferInFlightABlock and conformingFirstOfferJudgedBeforeExpiry) - .then(judge(early, Accepted)) - .then(flushEnd) - .expect(onChain == Set(early)) - - /// K3'. The same steps with the expiry floor equal to the three-term - /// budget. The flush is one block late and the transaction misses the - /// mining margin by that block. + /// K3'. The expiry floor equals the three-term budget, with no slack for + /// the reorg allowance. The flush is one block late and the transaction + /// misses the mining margin by that block. With the slack, G6b and G6c hold + /// on `flakyTip` in every reachable state. run conformingMissesMarginWithoutSlackTest = regressedTipDelaysFlush(flakyTipNoSlack, orchard("early", 2, 8)) .expect(not(reorgSlackFits(cfg))) .expect(not(conformingFirstOfferBeforeExpiry)) - /// K7. The same steps with the batch in flight for two blocks, as many as + /// K7. With the slack, and the batch in flight for two blocks, as many as /// the margin reserves. The offer left the whole margin, and G6b holds. By /// the time the node looks, the transaction can no longer be mined: the /// budget had already spent the slack, and the flight spent the margin. @@ -510,6 +476,7 @@ module hubScenariosTest { initWith(byzHub) .then(unseenBlocks(2)) .expect(decision(early) == Refused(TipStale)) + .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) .then(submitWith(early, admitting(early))) .expect(h.phase == Starting and height == 3 and onTime == Set(early)) .then(unseenBlocks(4)) @@ -521,18 +488,6 @@ module hubScenariosTest { .expect(not(nodeWouldTake(early)) and not(conformingFirstOfferJudgedBeforeExpiry)) .then(judge(early, Rejected)) - run hubAdmitsBeforeFirstTipControlTest = - initWith(byzHub) - .then(unseenBlocks(2)) - .then(deliver(early)) - .expect(h.queued() == Set()) - .then(unseenBlocks(4)) - .then(see) - .then(blocks(2)) - .then(flushBegin) - .expect(h.inFlight() == Set()) - .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) - /// A3 needs the hub. Once its drain has begun, it takes a submission into /// the queue: draining before and after, nothing in flight, and the queue /// grown by a payload no flush handed back. @@ -546,14 +501,6 @@ module hubScenariosTest { .then(submitWith(tight, admitting(tight))) .expect(h.phase == Draining and h.queued() == Set(early, tight)) - run hubAdmitsWhileDrainingControlTest = - started(byzHub) - .then(block) - .then(deliver(early)) - .then(drain) - .then(deliver(tight)) - .expect(h.phase == Draining and h.queued() == Set(early)) - // ------------------------------------------------------------------------ // A Byzantine indexer // ------------------------------------------------------------------------ @@ -579,6 +526,7 @@ module hubScenariosTest { started(byzIndexer) .then(block) .then(deliver(payload)) + .expect(conformingFirstOfferBeforeExpiry and conformingFirstOfferJudgedBeforeExpiry) .then((silence - 1).reps(_ => advance.then(observeWith(2)))) .then(advance) .expect(height == 2 + silence and h.tip == Some(2) and not(mayAdvance)) @@ -586,15 +534,6 @@ module hubScenariosTest { .then(flushBegin) .expect(flightStart == 2 + silence and h.inFlight() == Set(payload)) - /// The same polls, answered truthfully: the flush runs at 3. - run tipReported(payload: Payload): bool = - started(byzIndexer) - .then(block) - .then(deliver(payload)) - .then(block) - .then(flushBegin) - .expect(flightStart == 3 and h.inFlight() == Set(payload)) - /// G6b and G6c need the indexer. With the tip withheld up to height 8, a /// supported wallet's transaction is offered at 8 with expiry 9, and after /// one block in flight the node cannot take it. @@ -605,14 +544,4 @@ module hubScenariosTest { .then(advance) .expect(not(nodeWouldTake(early)) and not(conformingFirstOfferJudgedBeforeExpiry)) .then(judge(early, Rejected)) - - /// Offered at 3, and accepted after a block in flight. - run indexerWithholdsTipFromConformingControlTest = - tipReported(early) - .expect(conformingFirstOfferBeforeExpiry) - .then(advance) - .expect(conformingFirstOfferJudgedBeforeExpiry) - .then(judge(early, Accepted)) - .then(flushEnd) - .expect(onChain == Set(early)) } diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/protocol/tests/trustTest.qnt index 422f1adc..1af164e9 100644 --- a/zeronym/spec/protocol/tests/trustTest.qnt +++ b/zeronym/spec/protocol/tests/trustTest.qnt @@ -7,9 +7,8 @@ /// Each run is built from the machine's own steps. It names the one Byzantine /// transition it relies on, written out as a `...With` step, and ends by /// stating what in the wallet's log, the soup or the audit record constitutes -/// the failure. Each is followed by its control: the same wallet inputs in the -/// same configuration, with the component taking the honest transition, which -/// is always among those it may take. In the control the guarantee holds. +/// the failure. The guarantee is asserted in the state just before that +/// transition, so the run shows the lie is what breaks it. module trustTest { import basicSpells.* from "../spells/basicSpells" @@ -31,6 +30,7 @@ module trustTest { .then(submitTo(0, early)) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) + .expect(queuedBytesConfidential) .then(hubReceiveWith( fromThirdParty(lookupMail(0, "early")), INotFound, { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: AtZero })))) }, @@ -41,20 +41,12 @@ module trustTest { .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) - run hubServesQueuedBodyControlTest = - initByzHub - .then(submitTo(0, early)) - .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early")) - .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WFound({ body: None, height: AtZero }) })) - .expect(queuedBytesConfidential) - /// G4 needs the hub. It answers not found for a transaction it has queued. run hubDeniesQueuedTest = initByzHub .then(submitTo(0, early)) .then(ask("early")) + .expect(lookupValidityPerHub) .then(hubReceiveWith( lookupMail(1, "early"), INotFound, { hub: s.hub, reply: AWire(render(FromIndexer(INotFound))) }, @@ -64,13 +56,6 @@ module trustTest { .expect(s.hub.queue == Set(early) and audit.windows.get(1) == Set(Pending)) .expect(not(lookupValidityPerHub)) - run hubDeniesQueuedControlTest = - initByzHub - .then(submitTo(0, early)) - .then(lookUp(1, "early")) - .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) - .expect(lookupValidityPerHub) - /// G4 needs the hub, second run. It serves a mempool transaction as mined, /// at a height it made up. The txid is right, so the shim passes it on. run hubServesFalseHeightTest = @@ -78,6 +63,7 @@ module trustTest { .then(submitTo(0, early)) .then(flush([early], Accepted)) .then(ask("early")) + .expect(lookupValidityPerHub) .then(hubReceiveWith( lookupMail(1, "early"), chainAnswer(s.indexer, "early"), { hub: s.hub, reply: AWire(render(FromIndexer(IFound({ body: Some(early), height: AtOther })))) }, @@ -88,14 +74,6 @@ module trustTest { .expect(audit.windows.get(1) == Set(Tx({ payload: early, height: AtZero }))) .expect(not(lookupValidityPerHub) and txidAuthenticity) - run hubServesFalseHeightControlTest = - initByzHub - .then(submitTo(0, early)) - .then(flush([early], Accepted)) - .then(lookUp(1, "early")) - .expect(lastEvent == Got({ query: "early", obs: Tx({ payload: early, height: AtZero }), via: Some(1) })) - .expect(lookupValidityPerHub) - /// G3 survives. The hub answers with another transaction; the shim compares /// txids and refuses it. run wrongTransactionIsRefusedTest = @@ -131,6 +109,7 @@ module trustTest { .expect(s.indexer.offered == Set(early) and s.onChain("early") == Absent) .then(thirdPartyLearnsTxidWith("early")) .then(thirdPartyLookupWith("early")) + .expect(queuedBytesConfidential) .then(deliverLookupFrom( ThirdPartyAddr, 0, "early", IFound({ body: Some(early), height: AtZero }), )) @@ -140,17 +119,6 @@ module trustTest { .expect(s.repliedBodies(ThirdPartyAddr) == Set(early) and s.operator == Set()) .expect(not(queuedBytesConfidential)) - run indexerServesUnpublishedBodyControlTest = - initByzIndexer - .then(submitTo(0, early)) - .then(hubTake) - .then(judge(early, Retryable)) - .then(thirdPartyLearnsTxidWith("early")) - .then(thirdPartyLookupWith("early")) - .then(deliverLookupFrom(ThirdPartyAddr, 0, "early", INotFound)) - .expect(s.replies(ThirdPartyAddr) == Set({ nonce: 0, reply: WNotFound })) - .expect(queuedBytesConfidential) - /// G4 needs the indexer. For a transaction that exists nowhere it answers /// "found, height 0, no body". The hub forwards it unchanged, and on the /// wire it is the hub's own "queued here": the wallet sees pending. @@ -158,16 +126,10 @@ module trustTest { initByzIndexer .then(sends(Clean(early), true)) .then(ask("early")) + .expect(lookupValidityPerHub) .then(deliverLookupFrom(ShimAddr, 1, "early", IFound({ body: None, height: AtZero }))) .then(deliverToShim(replyMail(1, WFound({ body: None, height: AtZero })))) .expect(lastEvent == Got({ query: "early", obs: Pending, via: Some(1) })) .expect(s.hub.queue == Set() and audit.windows.get(1) == Set(NotFound)) .expect(not(lookupValidityPerHub)) - - run indexerForgesPendingControlTest = - initByzIndexer - .then(sends(Clean(early), true)) - .then(lookUp(1, "early")) - .expect(lastEvent == Got({ query: "early", obs: NotFound, via: Some(1) })) - .expect(lookupValidityPerHub) } From e89743fac80b840c1a8bf0b785698915b434859d Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 17:13:31 +0400 Subject: [PATCH 70/80] test(zeronym): move the Quint spec to zeronym/spec/quint --- .github/workflows/zeronym-guards.yml | 4 ++-- zeronym/spec/{protocol => quint}/.gitignore | 0 zeronym/spec/{protocol => quint}/README.md | 2 +- zeronym/spec/{protocol => quint}/abstractHub.qnt | 0 zeronym/spec/{protocol => quint}/check.sh | 0 zeronym/spec/{protocol => quint}/hub.qnt | 0 zeronym/spec/{protocol => quint}/hubMachine.qnt | 0 zeronym/spec/{protocol => quint}/indexer.qnt | 0 zeronym/spec/{protocol => quint}/protocol.qnt | 0 zeronym/spec/{protocol => quint}/shim.qnt | 0 zeronym/spec/{protocol => quint}/spells/basicSpells.qnt | 0 zeronym/spec/{protocol => quint}/spells/soup.qnt | 0 zeronym/spec/{protocol => quint}/tests/hubScenariosTest.qnt | 0 zeronym/spec/{protocol => quint}/tests/hubTest.qnt | 0 zeronym/spec/{protocol => quint}/tests/indexerTest.qnt | 0 zeronym/spec/{protocol => quint}/tests/scenariosTest.qnt | 0 zeronym/spec/{protocol => quint}/tests/shimTest.qnt | 0 zeronym/spec/{protocol => quint}/tests/trustTest.qnt | 0 zeronym/spec/{protocol => quint}/tests/wireTest.qnt | 0 zeronym/spec/{protocol => quint}/tlc.sh | 0 zeronym/spec/{protocol => quint}/types.qnt | 0 zeronym/spec/{protocol => quint}/wire.qnt | 0 22 files changed, 3 insertions(+), 3 deletions(-) rename zeronym/spec/{protocol => quint}/.gitignore (100%) rename zeronym/spec/{protocol => quint}/README.md (99%) rename zeronym/spec/{protocol => quint}/abstractHub.qnt (100%) rename zeronym/spec/{protocol => quint}/check.sh (100%) rename zeronym/spec/{protocol => quint}/hub.qnt (100%) rename zeronym/spec/{protocol => quint}/hubMachine.qnt (100%) rename zeronym/spec/{protocol => quint}/indexer.qnt (100%) rename zeronym/spec/{protocol => quint}/protocol.qnt (100%) rename zeronym/spec/{protocol => quint}/shim.qnt (100%) rename zeronym/spec/{protocol => quint}/spells/basicSpells.qnt (100%) rename zeronym/spec/{protocol => quint}/spells/soup.qnt (100%) rename zeronym/spec/{protocol => quint}/tests/hubScenariosTest.qnt (100%) rename zeronym/spec/{protocol => quint}/tests/hubTest.qnt (100%) rename zeronym/spec/{protocol => quint}/tests/indexerTest.qnt (100%) rename zeronym/spec/{protocol => quint}/tests/scenariosTest.qnt (100%) rename zeronym/spec/{protocol => quint}/tests/shimTest.qnt (100%) rename zeronym/spec/{protocol => quint}/tests/trustTest.qnt (100%) rename zeronym/spec/{protocol => quint}/tests/wireTest.qnt (100%) rename zeronym/spec/{protocol => quint}/tlc.sh (100%) rename zeronym/spec/{protocol => quint}/types.qnt (100%) rename zeronym/spec/{protocol => quint}/wire.qnt (100%) diff --git a/.github/workflows/zeronym-guards.yml b/.github/workflows/zeronym-guards.yml index 141cc5fd..d46c2547 100644 --- a/.github/workflows/zeronym-guards.yml +++ b/.github/workflows/zeronym-guards.yml @@ -54,7 +54,7 @@ jobs: with: persist-credentials: false - name: Protocol specification, tiers 1 to 3b - run: sh zeronym/spec/protocol/check.sh + run: sh zeronym/spec/quint/check.sh env: CHECK_TIERS: simulation @@ -78,7 +78,7 @@ jobs: distribution: temurin java-version: '21' - name: Hub specification, tier 4 - run: sh zeronym/spec/protocol/check.sh + run: sh zeronym/spec/quint/check.sh env: CHECK_TIERS: tlc QUINT_JOBS: '2' diff --git a/zeronym/spec/protocol/.gitignore b/zeronym/spec/quint/.gitignore similarity index 100% rename from zeronym/spec/protocol/.gitignore rename to zeronym/spec/quint/.gitignore diff --git a/zeronym/spec/protocol/README.md b/zeronym/spec/quint/README.md similarity index 99% rename from zeronym/spec/protocol/README.md rename to zeronym/spec/quint/README.md index 0294b0dd..9143c8e8 100644 --- a/zeronym/spec/protocol/README.md +++ b/zeronym/spec/quint/README.md @@ -24,7 +24,7 @@ is one concrete execution. ## Running it ```sh -sh zeronym/spec/protocol/check.sh +sh zeronym/spec/quint/check.sh ``` Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; diff --git a/zeronym/spec/protocol/abstractHub.qnt b/zeronym/spec/quint/abstractHub.qnt similarity index 100% rename from zeronym/spec/protocol/abstractHub.qnt rename to zeronym/spec/quint/abstractHub.qnt diff --git a/zeronym/spec/protocol/check.sh b/zeronym/spec/quint/check.sh similarity index 100% rename from zeronym/spec/protocol/check.sh rename to zeronym/spec/quint/check.sh diff --git a/zeronym/spec/protocol/hub.qnt b/zeronym/spec/quint/hub.qnt similarity index 100% rename from zeronym/spec/protocol/hub.qnt rename to zeronym/spec/quint/hub.qnt diff --git a/zeronym/spec/protocol/hubMachine.qnt b/zeronym/spec/quint/hubMachine.qnt similarity index 100% rename from zeronym/spec/protocol/hubMachine.qnt rename to zeronym/spec/quint/hubMachine.qnt diff --git a/zeronym/spec/protocol/indexer.qnt b/zeronym/spec/quint/indexer.qnt similarity index 100% rename from zeronym/spec/protocol/indexer.qnt rename to zeronym/spec/quint/indexer.qnt diff --git a/zeronym/spec/protocol/protocol.qnt b/zeronym/spec/quint/protocol.qnt similarity index 100% rename from zeronym/spec/protocol/protocol.qnt rename to zeronym/spec/quint/protocol.qnt diff --git a/zeronym/spec/protocol/shim.qnt b/zeronym/spec/quint/shim.qnt similarity index 100% rename from zeronym/spec/protocol/shim.qnt rename to zeronym/spec/quint/shim.qnt diff --git a/zeronym/spec/protocol/spells/basicSpells.qnt b/zeronym/spec/quint/spells/basicSpells.qnt similarity index 100% rename from zeronym/spec/protocol/spells/basicSpells.qnt rename to zeronym/spec/quint/spells/basicSpells.qnt diff --git a/zeronym/spec/protocol/spells/soup.qnt b/zeronym/spec/quint/spells/soup.qnt similarity index 100% rename from zeronym/spec/protocol/spells/soup.qnt rename to zeronym/spec/quint/spells/soup.qnt diff --git a/zeronym/spec/protocol/tests/hubScenariosTest.qnt b/zeronym/spec/quint/tests/hubScenariosTest.qnt similarity index 100% rename from zeronym/spec/protocol/tests/hubScenariosTest.qnt rename to zeronym/spec/quint/tests/hubScenariosTest.qnt diff --git a/zeronym/spec/protocol/tests/hubTest.qnt b/zeronym/spec/quint/tests/hubTest.qnt similarity index 100% rename from zeronym/spec/protocol/tests/hubTest.qnt rename to zeronym/spec/quint/tests/hubTest.qnt diff --git a/zeronym/spec/protocol/tests/indexerTest.qnt b/zeronym/spec/quint/tests/indexerTest.qnt similarity index 100% rename from zeronym/spec/protocol/tests/indexerTest.qnt rename to zeronym/spec/quint/tests/indexerTest.qnt diff --git a/zeronym/spec/protocol/tests/scenariosTest.qnt b/zeronym/spec/quint/tests/scenariosTest.qnt similarity index 100% rename from zeronym/spec/protocol/tests/scenariosTest.qnt rename to zeronym/spec/quint/tests/scenariosTest.qnt diff --git a/zeronym/spec/protocol/tests/shimTest.qnt b/zeronym/spec/quint/tests/shimTest.qnt similarity index 100% rename from zeronym/spec/protocol/tests/shimTest.qnt rename to zeronym/spec/quint/tests/shimTest.qnt diff --git a/zeronym/spec/protocol/tests/trustTest.qnt b/zeronym/spec/quint/tests/trustTest.qnt similarity index 100% rename from zeronym/spec/protocol/tests/trustTest.qnt rename to zeronym/spec/quint/tests/trustTest.qnt diff --git a/zeronym/spec/protocol/tests/wireTest.qnt b/zeronym/spec/quint/tests/wireTest.qnt similarity index 100% rename from zeronym/spec/protocol/tests/wireTest.qnt rename to zeronym/spec/quint/tests/wireTest.qnt diff --git a/zeronym/spec/protocol/tlc.sh b/zeronym/spec/quint/tlc.sh similarity index 100% rename from zeronym/spec/protocol/tlc.sh rename to zeronym/spec/quint/tlc.sh diff --git a/zeronym/spec/protocol/types.qnt b/zeronym/spec/quint/types.qnt similarity index 100% rename from zeronym/spec/protocol/types.qnt rename to zeronym/spec/quint/types.qnt diff --git a/zeronym/spec/protocol/wire.qnt b/zeronym/spec/quint/wire.qnt similarity index 100% rename from zeronym/spec/protocol/wire.qnt rename to zeronym/spec/quint/wire.qnt From 2b60d7705bb3203672bb2bb9f352c3f6ad4c67fc Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 19:16:46 +0400 Subject: [PATCH 71/80] test(zeronym): restructure the spec README around the threat model, guarantees and findings --- zeronym/spec/quint/README.md | 1337 ++++++++++------------------------ 1 file changed, 371 insertions(+), 966 deletions(-) diff --git a/zeronym/spec/quint/README.md b/zeronym/spec/quint/README.md index 9143c8e8..14cd603a 100644 --- a/zeronym/spec/quint/README.md +++ b/zeronym/spec/quint/README.md @@ -1,322 +1,43 @@ # The zeronym protocol, specified in Quint -A specification of the protocol between a wallet, the shim in front of an -operator's indexer, the hubs that batch diverted transactions, and the chain. -It is written from the protocol, not from the code's structure: it says what a -wallet can rely on, which components each guarantee trusts, where the known -gaps are, and it checks each of those statements. +A [Quint](https://quint-lang.org) specification of zeronym: the protocol between a wallet, the shim in front of an operator's indexer, the hub that batches diverted transactions, and the chain. -## What "holds" means here +- [System](#system) +- [Assumptions](#assumptions) +- [Threat model](#threat-model) +- [Guarantees](#guarantees) +- [Trust matrix](#trust-matrix) +- [Known gaps](#known-gaps) +- [Findings](#findings) +- [Scope](#scope) +- [How it is checked](#how-it-is-checked) +- [Future work](#future-work) -**Bounded random simulation.** Every "holds" below was produced by -`quint run`: fixed constants, at most 40 or 80 steps per trace, 2000 -random traces per run, one seed. It is not a proof and it is not exhaustive to any -depth. A property that "holds" is one no sampled trace violated. +## System -**`quint verify` has not been run** on any part of this specification. Tier 4 -runs TLC on the hub specification through `tlc.sh`, not through `quint verify`. +> Who the actors are, what each one does, and where each lives in the specification. -Statements that do not rest on sampling are the ones backed by `quint test`: -the functional properties F1-F15 (F6 is cut) and the two-state properties A2-A3, which are -exhaustive over small finite universes, and the scripted runs, each of which -is one concrete execution. +The architecture diagram and the prose description of the deployment are in [`zeronym/README.md`](../../README.md). -## Running it +Only the mixnet transport is modelled; the HTTP transport is out of scope (see [Out of the model](#out-of-the-model)). -```sh -sh zeronym/spec/quint/check.sh -``` - -Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; -set `QUINT=quint` to use an installed one). Tier 4 needs Java (21 in CI) and Apalache 0.62.1, whose jar -carries TLC and which Quint fetches into `~/.quint` on first use; without -either the tier fails. `CHECK_TIERS=simulation` runs tiers 1 to 3b and -`CHECK_TIERS=tlc` tiers 1 and 4; CI runs them as two jobs, the TLC one with -`QUINT_JOBS=2 TLC_HEAP=6g TLC_TIMEOUT=900`. - -| Tier | What | Command | Expectation | -|---|---|---|---| -| 1 | typecheck | `quint typecheck` on every file | ok | -| 2 | tests | `quint test` on the spells and every test file | all pass, and each file reports at least the count `check.sh` gives it | -| 3 | invariants | `quint run --invariants ... --max-samples=2000 --max-steps=40 --seed=7` | "holds" rows hold; the "fails" row is violated | -| 3b | witnesses | `quint run --witnesses ... --invariants ...` | every witness reached at least once; no invariant violated on the way | -| 4 | hub specification | `tlc.sh hubMachine.qnt hubMachine `, one row each | "holds" rows hold over every reachable state; "violated" rows are violated, by a counterexample no longer than the recorded one | -Measured on the machine it was written on (Apple silicon, 16 cores, Quint's -Rust evaluator): about 10 minutes wall for all four tiers with four rows at -a time (`QUINT_JOBS=4`, the default), of which tiers 1 to 3 are about 2 -minutes. It -has not been timed on a CI runner. `QUINT_SAMPLES` changes the trace count. -The rarest witness, W19 (`wThirdPartyServedBody`), is reached in 10 of the -2000 traces, so a lower count risks losing it. - -The tier 3 "fails" row and tier 3b run under `step` or under a narrower -relation: `quietStep` (no faults, no outsiders), or `earlyLookupStep` (only -`early` sent and asked about, for G4's antecedent). Each is a part of `step`, so a -state or a violation found under it is reachable under `step`. Uniform random choice over `step` -rarely gets a transaction as far as a block in 40 steps; the narrower relations -do. Tier 3b re-checks each configuration's guarantees on those deeper traces. - -## Threat model - -What the specification is afraid of, who could cause it, and which property -answers it. The network can drop, duplicate, delay and reorder any message; it -cannot forge one. The shim is assumed honest (attested); see T18. - -Threats the specification answers: - -| # | Threat | Adversary | Answered by | Strength | -|---|---|---|---|---| -| T1 | The operator sees a migration's contents | Operator behind the shim | G1 | Simulation; depends on the shim alone | -| T2 | Someone obtains a queued migration's bytes and publishes it early, breaking the batch | Unauthenticated third party; Byzantine hub or indexer | G2 | Simulation; needs hub and indexer honest | -| T3 | The wallet is served a different transaction than the one it asked for | Byzantine hub or indexer | G3 | Survives a Byzantine hub or indexer; txid only, not bytes or height | -| T4 | The wallet is told something false about its transaction's status | Byzantine hub or indexer; network reordering | G4 | Simulation; needs hub and indexer honest; per answer, not across answers | -| T5 | A supported wallet's migration expires while the hub holds it | Chain timing; flaky tip; Byzantine hub or indexer | G6b, G6c | Exhaustive (TLC); fails under a stale tip | -| T8 | The hub silently drops or admits entries outside its rules | Hub implementation error | A2, A3, G7 | Exhaustive over hub states | - -Threats the specification records but does not prevent: - -| # | Threat | Where it is recorded | -|---|---|---| -| T6 | Any admitted transaction, including one from an unsupported wallet, is offered too late | Not checked. G6a and its gap K3 were removed; see [Out of the model](#out-of-the-model) | -| T9 | The wallet is told "sent" but the hub never admits it | Gap K1 | -| T10 | The wallet sees its transaction's status go backwards | Gap K2 | -| T11 | An acknowledged migration is lost to a crash, a failed final flush, or a requeue drop | Gap K5 | -| T12 | A third party who knows a txid learns it is queued | Accepted disclosure W8 | -| T13 | A lying indexer makes the hub flush early, shrinking the batch | Witness W15 | - -Threats the specification does not model: - -| # | Threat | Status | -|---|---|---| -| T7 | The hub acknowledges a migration it never queued | Not modelled: nothing reads an ack. G8 was removed | -| T14 | Linking a wallet to its migration by source IP | Claimed protected in the repository README; not in the specification | -| T15 | Linking by submission size and arrival time | Listed there as not protected; not in the specification | -| T16 | The operator recovering txid and value through transparent-pool queries | Listed there as not protected; not in the specification | -| T17 | Batch-size and timing anonymity; partitioning the anonymity set across hubs | Out of scope (timing and anonymity; more than one hub) | -| T18 | A compromised shim or enclave host | A compromised enclave host is delegated to AWS (`zeronym/README.md`, "Physical security is delegated to AWS"). A malicious shim build is assumed away by attestation and is not discussed there | - -## Scope - -### Protocol facts the specification rests on - -| # | Fact | Source | -|---|---|---| -| S1 | Shim classifies `SendTransaction` by presence of Orchard actions; unparseable folds into "treat as migration" | `zeronym/shim/src/classify.rs:70-101`, `:246-248` | -| S2 | Shim-unparseable includes trailing bytes, which the hub's parser accepts, so "shim cannot parse" does not imply "hub computes no txid" | `zeronym/shim/src/classify.rs:269-283`, `zeronym/hub/src/queue.rs:281-289` | -| S3 | Divert arms: unreadable body fails closed; empty body INVALID_ARGUMENT; too large RESOURCE_EXHAUSTED; hub unreachable UNAVAILABLE; never the operator | `zeronym/shim/src/intercept.rs:180-283` | -| S4 | With a hub configured every `GetTransaction` goes to the hub; shim keeps no per-migration state | `zeronym/shim/src/intercept.rs:305-314`, `:58-64` | -| S5 | Lookup reply arms, in order: found/height 0/empty relayed as pending; found served only if the bytes' txid equals the query (L4), else NOT_FOUND; not-found; error fails closed | `zeronym/shim/src/intercept.rs:370-422`, `:453-466` | -| S6 | Two transports behind one enum: HTTP (verdict returned synchronously) and Nym | `zeronym/shim/src/hub.rs:277-328` | -| S7 | Nym submit is dispatch-only: success once one frame is handed over, fresh nonce per hub address, sent to every address; the ack is never awaited | `zeronym/shim/src/nym.rs:595-703` | -| S8 | Nym lookup tries addresses in turn; only a timeout moves on; fresh nonce per attempt | `zeronym/shim/src/nym.rs:708-797` | -| S9 | Correlation by nonce only; unknown nonce dropped; wrong reply kind for a known nonce ignored, waiter stays | `zeronym/shim/src/nym.rs:1040-1077`, `zeronym/hub/src/wire.rs:22-27` | -| S10 | Hub admit: tip-stale gate, then draining, too large, expiry survives next scheduled flush, payload-hash dedup, byte and entry budget. Admission never asks a node | `zeronym/hub/src/server.rs:343-392`, `:299-303`, `zeronym/hub/src/queue.rs:256-340` | -| S11 | Queue identity is `sha256(bytes)`; dedup is against resident entries only (`inner.entries.contains_key`), and a flush removes every entry (`inner.entries.drain()`); accepted entries are not put back. So bytes that were published are admitted again if resubmitted | `zeronym/hub/src/queue.rs:17-22`, `:308-310`, `:358-359`, `zeronym/hub/src/batcher.rs:389` | -| S12 | Hub lookup: queue first (found, height 0, no bytes), then indexer; unparseable entries never hit; flush window answers not-found, deliberately | `zeronym/hub/src/server.rs:403-462`, `zeronym/hub/src/queue.rs:455-475` | -| S13 | Lookup and submit to the hub are unauthenticated; the hub's Nym address is public with no ACL; the queue-hit reply discloses that a txid is queued | `zeronym/hub/src/server.rs:413-437`, `zeronym/hub/src/nym.rs:220-227` | -| S14 | Flush fires only when `cadence_height / flush_interval` exceeds the last flushed epoch; first observation adopts the epoch without flushing; shutdown flushes once more | `zeronym/hub/src/batcher.rs:316-335` | -| S15 | Flush drains everything, broadcasts, then: accepted / already-known leave; rejected dropped; retryable requeued | `zeronym/hub/src/batcher.rs:358-422`, `zeronym/hub/src/chain.rs:129-134` | -| S16 | Requeue: resident copy wins; attempts + 1; dropped if it no longer survives the next flush or attempts exceed 8; may overrun the byte budget; reports `held` / `dropped_expired` / `dropped_exhausted` | `zeronym/hub/src/queue.rs:186-199`, `:366-422`, `:96` | -| S17 | Tip is the max over answering endpoints; a regression within 10 blocks is followed; staleness stops admission only | `zeronym/hub/src/chain.rs:183-201`, `zeronym/hub/src/batcher.rs:161-205`, `:222-225` | -| S18 | Budget inequality `flush_interval + mining_margin + delivery_lag <= min_wallet_expiry` asserted at startup | `zeronym/hub/src/batcher.rs:93-118` | -| S19 | Drain closes admission before the final flush; the queue is RAM-only | `zeronym/hub/src/main.rs:142-177`, `zeronym/hub/src/queue.rs:226-243`, `zeronym/hub/src/batcher.rs:337-347` | -| S20 | Wire: four fixed-size frames; reply dispositions found / not_found / error; not_found or error with a payload is a decode error; `Draining` shares `QueueFull`'s code | `zeronym/hub/src/wire.rs:29-59`, `:278-290`, `:531-569` | -| S21 | Hub drops lookups past 64 in flight, replies older than 60 s, acks when the driver queue is full | `zeronym/hub/src/nym.rs:54`, `:75`, `:171-213`, `:285-292` | -| S22 | Indexer lookup answer is forwarded verbatim, so a zero `RawTransaction` is byte-identical to the queue-hit sentinel | `zeronym/hub/src/server.rs:445-451`, `zeronym/hub/src/chain.rs:284-290` | -| S23 | No attestation or STEVE handshake exists in code | `zeronym/README.md:88` | -| S24 | Replicate, never fail over: every hub that receives a migration queues and broadcasts it | `zeronym/README.md:86`, `zeronym/shim/src/nym.rs:602-647` | -| S26 | Shipped constants: `FLUSH_INTERVAL_BLOCKS = 20`, `MINING_MARGIN = 4`, `MAX_DELIVERY_LAG = 6`, `MIN_WALLET_EXPIRY = 40`, `REORG_ALLOWANCE = 10`. The slack `40 - (20 + 4 + 6) = 10` equals the reorg allowance exactly. `BatchParams::validate` asserts only the three-term sum; nothing asserts the four-term one | `zeronym/hub/src/batcher.rs:40-59`, `:101-113` | -| S27 | Lookup starts at a rotating cursor, so consecutive polls start at different hubs; a `NotFound` from the first hub asked is final | `zeronym/shim/src/nym.rs:756-793` | -| S28 | Indexer folds are asymmetric: tip is the max over answering endpoints (one endpoint can only win high; a low tip needs every endpoint); lookup returns the first `Found` in endpoint order (one endpoint suffices to inject an answer); publish takes the best verdict | `zeronym/hub/src/chain.rs:183-201`, `:305-319`, `:517-532` | -| S29 | Submit sweep tells the wallet ok when at least one frame was handed over, even if the loop broke before later addresses | `zeronym/shim/src/nym.rs:673-702` | -| S30 | L4 deserialises the returned bytes, computes their txid and compares it with the queried hash in both byte orders. It compares nothing else: not the bytes, not the height | `zeronym/shim/src/intercept.rs:453-466` | -| S31 | HTTP transport: one `SocketAddr`; hub answers `"accepted"` for both a fresh admission and a duplicate, so the client's `"already_known"` arm has no source; a 200 lookup without the octet-stream content type and `x-tx-height` is an error | `zeronym/shim/src/hub.rs:69-72`, `:201-208`, `:259-263`, `zeronym/hub/src/server.rs:741-747` | -| S32 | Two hub clocks. Admission and requeue use the observed height. The flush epoch uses the cadence height, which equals the observed height until no forward move has been seen for `TIP_STALE_AFTER` (15 min, 12 blocks at the nominal 75 s) and then free-runs at the nominal rate. The code comment claims the free-running clock runs ahead of the true height, "the safe direction"; nothing enforces it. Only the cadence loop (and startup) calls `observe`, and it does so before, never during, a flush | `zeronym/hub/src/batcher.rs:59-71`, `:227-247`, `:307-325`, `:414-422`, `zeronym/hub/src/main.rs:62` | -| S25 | The operator can recover a diverted transaction's txid from transparent-pool queries, so a txid can be known to an outsider before publication | `zeronym/README.md:34` | +There are two specifications, sharing one hub function: -Two comments in the implementation are quoted in `protocol.qnt` next to the -definitions they justify. - -- The accepted disclosure (W8), `zeronym/hub/src/server.rs`, in `Hub::lookup`: - "What this does NOT close: the 200-versus-NotFound distinction still - discloses that a given txid is queued here. Closing that too means answering - NotFound, which costs a wallet the ability to tell "pending" from "never - seen". That is a product decision, not a code one, and it is left open - deliberately." -- The flush window (`truth`, used by G4), same file, on `Hub::lookup`: "Note - the flush-in-flight gap: `flush()` drains the queue before `broadcast_batch` - has reached the indexer, so a lookup in that window gets a queue miss then an - indexer NOT_FOUND for a transaction it was told height-0 about seconds - earlier. Wallets poll on multi-second intervals and tolerate a transient - NOT_FOUND; a resubmit is harmless (deduped pre-flush, already-known - post-flush). Holding entries until broadcast returns would extend how long - the hub remembers a txid, which is the wrong trade." +- **The protocol specification** (`protocol.qnt`): wallet, shim, network, hub, indexer and an outside third party. Its hub is abstract: a queue and the entries out with a flush, with no tip, schedule or phases. It is checked by random simulation and scripted runs. +- **The hub specification** (`hubMachine.qnt`): one hub, the chain, and the two things the hub asks its indexer (the tip, and a verdict on each broadcast). It owns the schedule, expiry, requeue, crash and drain. TLC visits every reachable state of each of its configurations. -### In the model +Each component is one total function from its state and one input to its next state and one output. An input that is invalid in the current state returns an error output and leaves the state alone. The state machines hold no protocol logic: a step picks an input, calls the function, and puts the output where it goes. `hub.qnt` and `shim.qnt` are the precise statement of what each does. -| Area | What is modelled | Why | -|---|---|---| -| Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4. `divert.qnt` omits it | -| Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | -| Hub | In the hub specification: lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash. In the protocol specification: the abstract hub, a queue and the entries out with a flush, which accepts, refuses, takes, settles, gives back and loses (see [The abstraction lemma](#the-abstraction-lemma)) | S10-S19 | -| Chain / indexer | per-txid status (absent, mempool, mined); what the indexer has been offered; verdict and lookup-answer relations. A lookup answer's height is 0, the height the transaction was mined at, or another (`WireHeight`). The protocol specification has no chain height and no block clock, and its verdict relation has no expiry clause (`heightlessIndexerResults`); the hub specification keeps the chain height, which its tip and expiry rules read | S15, S22 | -| Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation | S20, S22 | -| Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | -| Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned or the chain has published; its payload knowledge is derived from what it can observe | S13, S25 | -| Network | drop, duplicate, delay, reorder; cannot forge | | -| Hubs | one; see [One hub](#one-hub) | S24 | -| Tip | Hub specification only: `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, the reorg allowance, the staleness window and the wallet expiry floor as parameters | S17, S26, S32 | - -### Out of the model - -| Item | Reason | -|---|---| -| Attestation, PCRs, TLS, STEVE, keymaker quorum | No in-protocol messages exist (S23). Represented by the roles | -| Mixnet internals: SURBs, Sphinx, cover traffic, gateways, throttling; shim client rotation supervisor (`zeronym/shim/src/nym.rs:942-1024`); both `nym_driver.rs` | Protocol-visible effect is loss and delay | -| Hub lookup concurrency bound, reply deadline, dropped acks (S21) | Refinements of "the network lost the message" | -| Wall-clock time | The staleness window is counted in blocks (`STALE_WINDOW`), and a free-running cadence height is chosen by the environment, never behind the chain (see the tip assumption); there is no clock | -| Multiple indexer endpoints and their folds | One abstract indexer per model stands for all of a hub's endpoints. Because the folds are asymmetric (S28), this document states for each Byzantine-indexer behaviour whether one lying endpoint suffices or all must lie | -| Wire codecs `ZNS1` / `ZNA1` / `ZNL1` / `ZNR1` and the golden vectors (`zeronym/hub/src/wire.rs:576-579`) | Byte layouts are scoped out and are pinned by the Rust tests in both crates; the abstract `render` / `interpretReply` layer is the level this spec works at. The spec does not claim to bind the codec | -| HTTP `"already_known"` and the lookup content-type tripwire (S31) | Checked in code: `"already_known"` has no hub source, so the wallet can never observe it; the tripwire turns a malformed 200 into the same `Unavailable` the wallet sees for `error`. Neither is a distinct wallet observation that changes a property | -| The HTTP (ack-awaiting) transport, and with it G5 "told ok implies some hub queued it". In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the K5 run under that transport, `toldOkAdmittedThenLostTest` (told ok on the hub's word, admitted, lost to a crash) | -| A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. Also lost: the checked claim that the hub-side G6 and G8 survive a Byzantine shim | -| Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed (C7): a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it | -| Payloads of the third party's own making, and W17 (one of them queued) | Removed (C8): the hub's address is public and unauthenticated, so this is possible, but only W17 read them. The third party still submits what it has learned or the chain has published (K2c) | -| The frame-size lemma, `sizeOf` and F6 | Removed (C9): true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | -| More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | -| The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. With them went W12, a queue over capacity after a requeue. The shim's own too-large arm (S3) stays | -| The hub's schedule in the protocol specification: its phases, tip, cadence, drain, crash and restart, flight time, and what read them there: G6b and G6c, the refusal witnesses W4, the requeue witnesses W5-W7, the offer, verdict, admission, refusal and drop records, the tip models | Moved: the protocol uses the abstract hub, which `hubTest` checks the real hub refines; the schedule is checked exhaustively in the hub specification. | -| A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | -| The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | -| G8 `ackImpliesQueued` and F13: an accepted ack is only for a payload the hub queued. In code: `queue.rs` admits before it acks | Removed: nothing reads an ack since the HTTP transport went. The abstraction lemma still fails if `hub` acks without queueing | -| A Byzantine hub's false ack (accepted but not queued, or queued but refused) | Removed with G8: no remaining guarantee reads it. A Byzantine hub still admits or refuses against the rules, and lies in lookup replies | -| G6a `offeredBeforeExpiry` and K3: the margin at the offer for every admitted transaction, including one whose wallet set an expiry below the supported floor. In code: admission's "provably survives its scheduled flush" (`zeronym/hub/src/queue.rs:497-519`) | Removed: it adds only unsupported wallets to G6b. Known not to hold under a tip reported behind the chain (K3, at `83133e3`); no longer checked | -| K1 as reachable-state rows, and the `everQueued` history they read | K1 is pinned by its two scripted runs. The simulation rows were the last readers of that history | -| Replaying each pinned protocol run through the real hub (`realisations`, `realisedRunsTest`) | Removed: it produced no finding. Violations and reached states of the protocol specification are shown over the abstract hub; `realisesTest` shows each abstract move has a real step | -| Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | -| Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties | -| Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | -| Forward-only shim, transparent-pool RPCs, health / address / attestation endpoints, DoS bounds, logging | Not divert-protocol state | -| More than one Byzantine component at once | The trust matrix is single-fault | -| A model-based test harness for the Rust | Later work; see "Model-based testing, later" | - -### One hub +| Component | Inputs | Outputs | Seam in the implementation | +|---|---|---|---| +| `hub` | `SubmitHInput`, `LookupHInput` (with the indexer's answer), `TipHInput(height)`, `StaleHInput(estimate)`, `FlushDueHInput`, `VerdictHInput`, `FlushDoneHInput`, `DrainHInput`, `CrashHInput`, `RestartHInput` | `AckOutput`, `LookupReplyOutput`, `BroadcastOutput`, `RequeuedOutput`, `NoHubOutput`, `HubErrorOutput` | `Hub::admit`, `Hub::lookup` (`hub/src/server.rs`), `run_listener` (`hub/src/nym.rs`), `TipTracker::observe`, `cadence_height`, `flush` (`hub/src/batcher.rs`), `Queue::requeue`, `Queue::begin_draining` (`hub/src/queue.rs`) | +| `shim` | `SendTxSInput` (with whether the transport took the frame), `GetTxSInput`, `FrameSInput`, `LookupTimeoutSInput` | `ForwardOutput`, `DivertedOutput`, `SendDoneOutput`, `LookupSentOutput`, `LookupDoneOutput`, `NoShimOutput`, `ShimErrorOutput` | `send_transaction`, `divert`, `get_transaction` (`shim/src/intercept.rs`), `NymHandle::submit`, `get_transaction`, `deliver` (`shim/src/nym.rs`) | +| indexer | `BroadcastIInput`, `LookupIInput`, `AdvanceIInput`, `MineIInput` | `VerdictOutput`, `AnswerOutput`, `NoIndexerOutput` | the mock indexer in `hub/tests/common/mod.rs` | -The spec checks one hub; production runs one or more, replicated: every shim -sends every submission to every hub, and each hub that receives a migration -queues and broadcasts it. The single hub is a scope choice, not a claim about -production. +
+Shim: routing a send, and one lookup -Lost, observed at `83133e3` with two hubs. Each row is a result a one-hub spec -cannot check; the gate rows and runs named are that commit's: - -| Result at `83133e3` | Backing there | Now | -|---|---|---| -| K2 cause (f): one hub says pending, the next poll starts at a hub that never received the frame and its not-found is final | `hubsDisagreeTest`; `fails replicated quietStep 40 statusNeverRegresses` | lost | -| K1c: told ok after a partial send. Under the replicate rule this is also an anonymity cost: the migration sits in a strict subset of the hubs' batches, and an observer of the broadcasts learns which | `toldOkAfterPrefixSendTest`; `reaches replicated step 40 wToldPrefixOnly` | lost | -| W14: duplicate publication by two hubs, and a second enclave holding the plaintext; accepted deliberately in production (`zeronym/shim/src/nym.rs:641-647`) | `publishedByBothHubsTest`; `reaches replicated quietStep 80 wPublishedByTwoHubs` | lost | -| W13: a lookup moves on after a timeout and the next hub answers | `lookupFailsOverOnTimeoutTest`; `reaches replicated step 40 wFailoverAnswered` | lost; see the lookup concern below | -| G4 holds with two honest hubs | `holds replicated lookupValidityPerHub` | lost as a check; argued below | -| G2 is required of every hub | `oneReplicaServesQueuedBodyTest` and control; `fails replicatedOneByz step 40 queuedBytesConfidential` | the leak survives as `hubServesQueuedBodyTest` on `byzHub`; that an honest replica beside it does not help is lost as a check | -| G4 is required of every hub, and the cursor can land on the lying one | `cursorLandsOnLyingReplicaTest` and control; `fails replicatedOneByz step 40 lookupValidityPerHub` | `hubDeniesQueuedTest` on `byzHub` survives. Lost: that the lie reaches the wallet while an honest replica holds the transaction, because the cursor chose the liar | -| G3 survives a Byzantine replica | `wrongTransactionIsRefusedTest`; `holds replicatedOneByz txidAuthenticity` | the run is on `byzHub` now | -| The honest hub keeps G8, G6a, G6b and G6c beside a Byzantine one | `honestReplicaKeepsItsGuaranteesTest`; `holds replicatedOneByz ackImpliesQueuedForHonestHubs` and the three `...ForHonestHubs` G6 rows | lost as a check; argued below. The Byzantine halves survive as `hubAcksWithoutAdmittingTest` and `hubAdmitsPastExpiryRuleTest` | -| "Some honest hub queued it after told ok" is not a guarantee | the same run, its first half | lost. Its one-hub shadow is K1b | - -What one hub keeps. `Unavailable` is exempt from G4, so a lookup that -production would complete at another address and the model answers -`Unavailable` loses only a success path. K2 still fails: its causes (a) to (e) -are one-hub runs. The already-known verdict stays reachable: published bytes -resubmitted to the same hub are queued and offered again (K2 b and c). - -**Composition, argued and not checked.** Assumption: hubs share no state but -the chain and the indexer, and each property below is about one hub's own -queue, acks, replies and schedule. - -- Compose per hub: G1 (the shim alone); G3 (the shim's txid check on each - reply); G4, which is why its name keeps "per hub": an answer was true at the - hub that gave it; G6b and G6c, which read offer and verdict - heights, not verdict values, so another hub publishing first changes nothing - they read; K5. -- Compose only if every hub is honest: G2. One Byzantine replica holds the - same bytes and can give them away. -- Do not compose: K1 (two hubs add K1c and its anonymity cost); K2 (two hubs - add cause f); duplicate publication; which hub answers a lookup. - -**The lookup-routing concern, unexamined, not a bug.** Lookups are not -replicated. `each_target` (`zeronym/shim/src/nym.rs:746-797`, comment at -`:729-745`) starts at a rotating cursor and moves to the next address only on -a timeout; any other outcome from the first address that answers is final. -That is a choice of hub by apparent liveness on the read path, the pattern -`zeronym/shim/src/nym.rs:630-633` forbids for submits: whoever can make one hub -time out decides which hub answers a wallet's lookup, and learns which txids -it asks about. A one-hub spec cannot express it. - -Not checked: whether a rotated or dead address has any protocol-visible effect -beyond loss in the soup. - -### Assumptions - -- **Roles.** The shim and the hub run in enclaves and are honest in the - baseline. The shim is honest in every configuration; the hub and its - indexer can each be made Byzantine, one at a time. A Byzantine component draws its transitions from a - wider relation than the honest one; no message, state field or observation - records which it drew. -- **Network.** May lose, duplicate, delay and reorder frames. Cannot forge or - read them. -- **Third party.** A client of the hub's public address. It looks up txids it - knows and submits payloads it has learned or the chain has published. It cannot read or forge - frames, so it does not know a nonce and cannot answer the shim. -- **Nonces** are unique. A counter stands for an unguessable value. -- **Chain.** A transaction's status only moves forward: no reorg of an included - transaction, no mempool eviction. The operator's indexer publishes nothing. -- **Hub.** In the protocol specification the hub is abstract: it may accept - or refuse any submission, and take, settle, give back or lose its entries at - any time. The three assumptions below are the hub specification's. -- **Byzantine hub.** A Byzantine hub admits or refuses a submission whatever - the admission rules say, and on a lookup it may send any reply (see - [Roles](#roles)). Its ack is modelled as truthful: a real one could ack - anything, but nothing reads an ack, so no property here depends on it. - Every other move is the - honest one: in the hub specification its flushes, verdicts, requeues, drain, - crash and restart; in the protocol specification its take, settle, give back - and lose. It cannot evict or withhold a queued entry, flush off schedule, or - send a frame nobody asked for. The rows that hold under a Byzantine hub hold - under this model: G1 does not read the hub, G3 holds because the shim - compares txids on every reply (so an unsolicited reply would change - nothing), and A2 follows from the model itself. -- **Flight time.** At most `MAX_FLIGHT_BLOCKS` blocks arrive while one flush - is in flight, and that is fewer than the mining margin - (`flightWithinMargin`). The implementation bounds each call to the indexer - (`RPC_TIMEOUT`, `hub/src/chain.rs`), not the batch, and neither in blocks: - the code does not enforce this. A hub whose flush is in flight does not look - at the tip. -- **Tip.** In every model a due flush has begun before the next block. - `TipTimely`: a running, idle hub asks for the tip at each block. An - honest indexer answers with the true height; a Byzantine one is asked just - as often and controls only the answer. `TipMayRegress`: a tip - report may trail the chain by up to `REORG_ALLOWANCE`. `TipMayLag`: a hub may - hear nothing for a while, and is stale once the silence reaches - `STALE_WINDOW` blocks; a stale hub's free-running clock is assumed never - behind the chain and at most one flush interval ahead of it. The - implementation relies on the first and does not enforce it: "during a real - stall blocks arrive slower than this, so the free-running clock runs ahead - of the true height" (`zeronym/hub/src/batcher.rs:64-67`). -- **Wallets.** A supported ("conforming") wallet sets an expiry at least - `MIN_WALLET_EXPIRY` after the height it builds at, and its frame reaches the - hub within `DELIVERY_LAG` blocks. A wallet asks only about transactions it - has sent. -- **Honest indexer.** Answers lookups from chain state or "unavailable". A - broadcast may always be rejected or left unjudged; it is accepted only if a - node would take it, and reported already-known only if the chain has it. - The protocol specification has no heights, so there a node takes any - parseable transaction the chain does not have, whatever its expiry. -- **Time.** There is no clock. A timeout may happen at any moment; the - staleness window is counted in blocks. - -## State machines - -### Shim: `SendTransaction` routing +Routing one `SendTransaction` (`shim.qnt`): ```mermaid stateDiagram-v2 @@ -333,7 +54,7 @@ stateDiagram-v2 FailClosed --> [*] ``` -### Shim: lookup request lifecycle (one `GetTransaction`) +One `GetTransaction` lookup: ```mermaid stateDiagram-v2 @@ -349,7 +70,12 @@ stateDiagram-v2 Unavailable --> [*] ``` -### Hub: lifecycle +
+ +
+Hub: phases, the flush cycle, and one payload's entry + +The hub's phases (`hub.qnt`). `Starting` and `Stale` restate what the tip fields already say (no tip yet; the clock is free-running) and are phases for readability: ```mermaid stateDiagram-v2 @@ -368,7 +94,7 @@ stateDiagram-v2 Stopped --> Down ``` -### Hub: flush cycle +The flush cycle: ```mermaid stateDiagram-v2 @@ -379,7 +105,7 @@ stateDiagram-v2 Broadcasting --> Idle: all verdicts in; requeue retryable entries; record epoch ``` -### Hub: per-payload entry lifecycle +One payload's entry. `Absent --> Queued` is reachable again after `Published`: bytes that were published are admitted again if resubmitted. ```mermaid stateDiagram-v2 @@ -403,173 +129,100 @@ stateDiagram-v2 Lost --> Absent ``` -`Absent --> Queued` is reachable again after `Published` (S11). - -### Chain: per-txid status (environment) - -```mermaid -stateDiagram-v2 - [*] --> Absent - Absent --> Mempool: a broadcast is accepted - Mempool --> Mined: included in a block - Mined --> [*] -``` +
-### Network: one message in the soup +
+Indexer, network and third party -```mermaid -stateDiagram-v2 - [*] --> Sent: added to the soup, never removed - Sent --> Sent: delivered to its destination (any number of times, any order) - Sent --> [*]: never delivered (loss) -``` +- **Chain and indexer** (`indexer.qnt`). A transaction's status only moves forward: absent, in the mempool, mined. The indexer answers lookups and gives a verdict on each broadcast. +- **Network** (`spells/soup.qnt`). A message is added to the soup and never removed. It may be delivered any number of times, in any order, or never. +- **Third party.** A client of the hub's public, unauthenticated address. It may learn a txid out of band, look it up, and resubmit any payload the chain has published. -### Third party: knowledge of one transaction +
-```mermaid -stateDiagram-v2 - [*] --> Nothing - Nothing --> KnowsTxid: learns a txid out of band (S25) - KnowsTxid --> KnowsQueued: Lookup answered found, height 0, no body - KnowsTxid --> KnowsPayload: payload published on chain - KnowsQueued --> KnowsPayload: payload published on chain - Nothing --> KnowsPayload: payload published on chain - KnowsPayload --> KnowsPayload: may resubmit the payload to any hub -``` +### Wire encoding -### Encoding: hub outcome to wallet observation +A hub answers a lookup with one of three wire replies, and the shim turns that into what the wallet sees (`wire.qnt`). -```mermaid -flowchart LR - QH[QueueHit] -->|render| S["found, height 0, no body"] - IZ["indexer answers found, height 0, no body; an honest hub forwards it unchanged"] -->|render| S - IF["indexer: found p at h"] -->|render| F["found, h, p"] - INF[indexer: not found] -->|render| NF[not_found] - IU[indexer: unavailable] -->|render| E[error] - S -->|interpretReply q| P[Pending] - F -->|"interpretReply q, txid(p) = q"| T[Tx p] - F -->|"interpretReply q, txid(p) != q"| N[NotFound] - NF -->|interpretReply q| N - E -->|interpretReply q| U[Unavailable] -``` +| The hub's situation | Reply on the wire | What the wallet sees | +|---|---|---| +| The transaction is queued here | found, height 0, no body | Pending | +| Its indexer has the transaction | found, with the height and the body | The transaction, if the body's txid is the one asked for; otherwise not found | +| Its indexer says not found | not found | Not found | +| Its indexer cannot be reached | error | Unavailable | +| Its indexer says "found, height 0, no body" (a fault) | found, height 0, no body | Pending | -The zero-body indexer answer is not something the honest indexer relation produces, but the honest hub and honest shim pass it through (S22), and one endpoint out of several is enough to inject it (S28). It is therefore reachable only in `byzIndexer`, where "Byzantine indexer" includes "one misbehaving endpoint". +"Pending" has no reply of its own: it is a found reply with nothing in it. So the first and last rows are the same bytes, and neither the shim nor the wallet can tell "queued at the hub" from "an indexer said found and returned nothing". One misbehaving indexer endpoint is enough to produce the last row (finding 8). -The encoding is not injective at that point, and that is by design: the pending -sentinel has no bytes to tell it apart by. The shim reads both as pending, for -every query (F2), so a wallet cannot tell "queued at the hub" from "an indexer -said found and returned nothing". `meaning` gives the two different meanings; -`interpretReply` after `render` gives one observation. `indexerForgesPendingTest` -is the trace-level consequence: the wallet is told pending for a transaction -nobody holds, and G4 fails. +## Assumptions -## Layout +> What must be true of the world for the claims to apply. -Only `protocol.qnt` and `hubMachine.qnt` declare variables, and no module -declares a constant. Every other module is pure. +- **Roles.** The shim and the hub both run attested. The shim is modelled as honest in every configuration: it sees every migration in plaintext and controls everything the wallet observes, so no wallet-facing guarantee could survive its compromise. The hub is modelled as honest or Byzantine, not because it is trusted less, but to measure how much each guarantee depends on the hub's enclave. The indexer runs outside any enclave, so a Byzantine indexer is the realistic adversary. One component is Byzantine at a time. +- **Honest and Byzantine.** An honest component takes exactly the transition its function gives. A Byzantine one takes any member of a finite set that contains the honest transition (F12). No message or state field records which it took. +- **Byzantine hub.** It admits or refuses a submission whatever the admission rules say, and may send any reply to a lookup. Its ack is modelled as truthful: a real one could ack anything, but nothing reads an ack. Every other move is the honest one: it cannot evict or withhold a queued entry, flush off schedule, or send a frame nobody asked for. +- **Byzantine indexer.** Any verdict, with the transaction relayed or not. Any lookup answer built from a payload it was offered, one the chain published, or a twin of either. In the hub specification it also reports any tip. +- **Network.** May lose, duplicate, delay and reorder frames. Cannot forge or read them. +- **Third party.** Looks up txids it knows and submits payloads it has learned or the chain has published. It cannot read or forge frames, so it does not know a nonce and cannot answer the shim. +- **Nonces** are unique. A counter stands for an unguessable value. +- **Chain.** No reorg of an included transaction and no mempool eviction. The operator's indexer publishes nothing. +- **Wallets.** A supported ("conforming") wallet sets an expiry at least `MIN_WALLET_EXPIRY` after the height it builds at, and its frame reaches the hub within `DELIVERY_LAG` blocks. A wallet asks only about transactions it has sent. +- **Hub schedule** (hub specification only). How promptly the hub learns the tip depends on the configuration: at every block, up to the reorg allowance behind, or not at all for a while. Fewer blocks arrive while a flush is in flight than the mining margin reserves. The code enforces neither; see [Configurations](#configurations) and findings 1, 2 and 4. +- **Time.** There is no clock. A timeout may happen at any moment; the staleness window is counted in blocks. -| File | Module | Owns | -|---|---|---| -| `spells/basicSpells.qnt` | `basicSpells` | `Option`, `filterMap`, and a few set and map helpers, each with its test | -| `spells/soup.qnt` | `soup` | The message soup: `Envelope[p, m]`, `Soup[p, m]`, `send`, `sendAll`, `inbox`, `outbox` | -| `types.qnt` | `types` | The vocabulary: payloads, verdicts, refusals, roles, observations, `Result[s, o]`, `Config` | -| `wire.qnt` | `wire` | The four frames; `render`, `renderAck`, `meaning`, `interpretReply` | -| `indexer.qnt` | `indexer` | The chain and indexer as a relation: honest and Byzantine outputs, and their effect | -| `hub.qnt` | `hub` | `hub(state, input)`; admission, the tip rule, the flush cycle, requeue; `byzHubResults` | -| `abstractHub.qnt` | `abstractHub` | The hub as the protocol sees it: `AHub`, its honest and Byzantine answers, its internal moves | -| `shim.qnt` | `shim` | `shim(state, input)`; routing, reply correlation | -| `protocol.qnt` | `protocol` | The transactions and the three configurations; `System`, `Audit`, where each output goes and the derived views; `truth`, the audit monitor `advance`, the guarantees, gaps and witnesses; the variables, `commit`, the named inits, the steps, the property aliases, the run vocabulary | -| `tests/wireTest.qnt`, `indexerTest.qnt`, `hubTest.qnt`, `shimTest.qnt` | | F1-F15; A2-A3 and the abstraction lemma in `hubTest.qnt` | -| `tests/scenariosTest.qnt` | `scenariosTest` | Witnesses and pinned gap causes; `liveInitsTest` | -| `tests/trustTest.qnt` | `trustTest` | One run per "required" cell, with the guarantee asserted just before the Byzantine step | +Each configuration's assumptions are the guard of its named `init`. A guard that is false leaves no initial state, and the gate fails on that. -```mermaid -flowchart BT - soup --> basicSpells - types --> basicSpells - wire --> types - indexer --> types - hub --> types - abstractHub --> wire - shim --> wire - hubMachine --> hub - hubMachine --> indexer - protocol --> soup - protocol --> indexer - protocol --> abstractHub - protocol --> shim - tests --> protocol - tests --> hubMachine -``` +## Threat model -### Components as functions +> What the specification is afraid of, who could cause it, and whether a property answers it. -Each component is one total function from its state and one input to its next -state and one output. An input that is invalid in the current state returns an -error output and leaves the state alone. The state machine holds no protocol -logic: a step picks an input, calls the function, and puts the output where it -goes. +The deployment targets the server-side and network-metadata adversaries of Taylor Hornby's [wallet app threat model](https://zcash.readthedocs.io/en/latest/rtd_pages/wallet_threat_model.html); the Security section of [`zeronym/README.md`](../../README.md) says what is and is not protected. This specification covers the part of that which is a property of protocol runs. Which guarantee answers a threat is the "Answers" column under [Guarantees](#guarantees), and which gap records one that is not prevented is the "Threat" column under [Known gaps](#known-gaps). -| Component | Inputs | Outputs | Seam in the implementation | +| # | Threat | Adversary | Status | |---|---|---|---| -| `hub` | `SubmitHInput`, `LookupHInput` (with the indexer's answer), `TipHInput(height)`, `StaleHInput(estimate)`, `FlushDueHInput`, `VerdictHInput`, `FlushDoneHInput`, `DrainHInput`, `CrashHInput`, `RestartHInput` | `AckOutput`, `LookupReplyOutput`, `BroadcastOutput`, `RequeuedOutput`, `NoHubOutput`, `HubErrorOutput` | `Hub::admit`, `Hub::lookup` (`hub/src/server.rs`), `run_listener` (`hub/src/nym.rs`), `TipTracker::observe`, `cadence_height`, `flush` (`hub/src/batcher.rs`), `Queue::requeue`, `Queue::begin_draining` (`hub/src/queue.rs`) | -| `shim` | `SendTxSInput` (with whether the transport took the frame), `GetTxSInput`, `FrameSInput`, `LookupTimeoutSInput` | `ForwardOutput`, `DivertedOutput`, `SendDoneOutput`, `LookupSentOutput`, `LookupDoneOutput`, `NoShimOutput`, `ShimErrorOutput` | `send_transaction`, `divert`, `get_transaction` (`shim/src/intercept.rs`), `NymHandle::submit`, `get_transaction`, `deliver` (`shim/src/nym.rs`) | -| indexer | `BroadcastIInput`, `LookupIInput`, `AdvanceIInput`, `MineIInput` | `VerdictOutput`, `AnswerOutput`, `NoIndexerOutput` | the mock indexer in `hub/tests/common/mod.rs` | - -### Roles - -`ROLES` gives the hub and the indexer a role. An honest component -takes exactly the transition its function gives. A Byzantine one takes any -member of a finite set that contains it (F12): - -- **Byzantine hub.** It admits or refuses a submission whatever the admission - rules say, and its ack says which. Any reply to a lookup: not found, error, or found with no - body or any payload of the universe, at any of the three wire heights. Its - internal moves (take, settle, give back, lose) are the honest ones. -- **Byzantine indexer.** Any verdict, with the transaction relayed to the - network or not. Any lookup answer built from a payload it was offered, one - the chain published, or a twin of either, at any of the three wire heights. - In the hub specification it also reports any tip. -- Neither discloses a payload except in a lookup reply. There is no separate - disclosure step: a Byzantine hub or indexer already leaks through a reply - (`hubServesQueuedBodyTest`, `indexerServesUnpublishedBodyTest`). - -A hub folds several indexer endpoints into one answer, and the folds are not -symmetric: the tip is the maximum over endpoints, a lookup takes the first -"found", a broadcast takes the best verdict. So **one** misbehaving endpoint is -enough to raise the tip, inject a lookup answer or change a verdict, while -lowering or freezing the tip takes **every** endpoint. Each indexer cell below -says which it needs. That is prose about the abstraction: the model has one -abstract indexer, standing for the fold over all endpoints, and **does not -enforce** the difference. Its Byzantine relation can report any tip, high or -low. Modelling the endpoints as a set, so that the transition relation itself -separates "one endpoint" from "all of them", was considered and not done: one -abstract indexer per hub was a decision of the design. - -### Configurations - -One variable, `cfg`, holds a configuration. It is written by `initWith` and -kept by every step; `protocol.qnt` names its fields (`PAYLOADS`, `ROLES`, ...). -Each configuration has a named init whose guard is `payloadsWellFormed`. The -Byzantine inits also check `universeCoversLies`: the universe a lie is built -from holds a wallet payload, its twin, and a payload with another txid, so a -lie can be the twin or a foreign transaction and G3 has something to catch. +| T1 | The operator sees a migration's contents | Operator behind the shim | Answered | +| T2 | Someone obtains a queued migration's bytes and publishes it early, breaking the batch | Unauthenticated third party; Byzantine hub or indexer | Answered, if the hub and the indexer are honest | +| T3 | The wallet is served a different transaction than the one it asked for | Byzantine hub or indexer | Answered for the txid; not for the bytes or the height | +| T4 | The wallet is told something false about its transaction's status | Byzantine hub or indexer; network reordering | Answered for each answer, if the hub and the indexer are honest; not across answers (T8) | +| T5 | A supported wallet's migration expires while the hub holds it | Chain timing; a flaky tip; Byzantine hub or indexer | Answered in part: under a timely or regressing tip, not under a stale one | +| T6 | The hub silently drops or admits entries outside its rules | Hub implementation error | Answered | +| T7 | The wallet is told "sent" but the hub never admits it | Network; the hub's own refusals | Not prevented | +| T8 | The wallet sees its transaction's status go backwards | Network reordering; resubmission; the flush window | Not prevented | +| T9 | An acknowledged migration is lost to a crash, a failed final flush, or a requeue drop | None needed | Not prevented | +| T10 | A third party who knows a txid learns it is queued | Unauthenticated third party | Accepted | +| T11 | A lying indexer makes the hub flush early, shrinking the batch | Byzantine indexer; one endpoint suffices | Not prevented; recorded | +| T12 | Any admitted transaction, including one from an unsupported wallet, is offered too late | As T5 | Not checked | +| T13 | The hub acknowledges a migration it never queued | Byzantine hub | Not modelled: nothing reads an ack | +| T14 | Linking a wallet to its migration by source IP | Network observer; operator | Not modelled. Claimed protected in `zeronym/README.md` | +| T15 | Linking by submission size and arrival time | Operator | Not modelled. Listed as not protected in `zeronym/README.md` | +| T16 | The operator recovering txid and value through transparent-pool queries | Operator | Not modelled. Listed as not protected in `zeronym/README.md` | +| T17 | Batch-size and timing anonymity; partitioning the anonymity set across hubs | Network observer | Out of scope: timing and anonymity are not trace properties here, and there is one hub | +| T18 | A compromised shim or enclave host | Host; a malicious build | Assumed away. A compromised host is delegated to AWS in `zeronym/README.md`; a malicious shim build is excluded by attestation and is not discussed there | + +## Guarantees + +> Promises about whole runs of the system: this bad thing never happens. + +| Id | Name | What it says | Answers | Checked by | +|---|---|---|---|---| +| G1 | `operatorBlind` | Everything the shim hands the operator is a pass-through transaction | T1 | Simulation | +| G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it and the operator's view | T2 | Simulation | +| G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | T3 | Simulation; F3 exhaustively | +| G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree | T4 | Simulation | +| G6b | `conformingFirstOfferBeforeExpiry` | A supported wallet's transaction is offered with the mining margin to spare, the first time a hub offers it. About the margin left when the flush begins, not about acceptance; nothing about a later offer of a requeued entry | T5 | TLC, exhaustive | +| G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and the flight-time assumption | T5 | TLC, exhaustive | +| G7 | `wellFormedTest` | Structural sanity of the hub: a queued entry is within its attempts and a down hub holds nothing | T6 | Exhaustive test over every reachable hub state | +| A2 | `neverEvictTest` | An entry leaves the hub's queue only into a flush, or because the hub went down or exited after its final flush | T6 | Exhaustive test over every reachable hub state and input | +| A3 | `drainIsFinalTest` | A draining honest hub's queue gains only what a flush hands back | T6 | Exhaustive test over every reachable hub state and input | -| Configuration | Init | Roles (hub / indexer) | -|---|---|---| -| `baseline` | `initBaseline` | H / H | -| `byzHub` | `initByzHub` | **B** / H | -| `byzIndexer` | `initByzIndexer` | H / **B** | +A2 and A3 constrain a single hub step, not a state; the rest are state invariants. -At most 3 sends and 3 lookups by the wallet and 3 requests by the third -party. `liveInitsTest` starts from each init in turn, so a guard that is false -fails tier 2. The hub -specification's configurations, its scaled-down schedule and the relations -it keeps with the shipped one are in `hubMachine.qnt`. +"Simulation" is bounded random sampling, not a proof; see [How it is checked](#how-it-is-checked). Every simulated guarantee has a non-vacuity row: a state where its antecedent holds must be reached on each configuration where it is claimed. No liveness property is claimed: the network may lose everything, and nobody waits for an ack. -## Properties +No guarantee reads a field written by the function it constrains. The one piece of history the protocol guarantees need, the answers that were true while each lookup waited, is derived from the states before and after each step. -### Functional properties (`quint test`, exhaustive over small universes) +
+Functional properties: facts about one function, checked on every small input | Id | Statement | Test | |---|---|---| @@ -587,542 +240,294 @@ it keeps with the shipped one are in `hubMachine.qnt`. | F15 | The protocol's heightless verdict relation contains the honest one and is wider only by the expiry clause | `indexerTest::heightlessCoversTest` | | F14 | The tip rule: first observation adopted; forward followed; a drop within the allowance followed; a larger drop ignored | `hubTest::tipRuleTest` | -### Guarantees +
-| Id | Name | What it says | -|---|---|---| -| G1 | `operatorBlind` | Everything the shim hands the operator is a pass-through transaction | -| G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it and the operator's view; nothing updates it at publication | -| G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | -| G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree, or that hubs agree | -| G6b | `conformingFirstOfferBeforeExpiry` | A supported wallet's transaction is offered with the mining margin to spare, the first time a hub offers it. Nothing about a later offer of a requeued entry. About the margin left when the flush begins, not about acceptance | -| G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and `flightWithinMargin` | -| G7 | `hubTest::wellFormedTest` | Structural sanity of the hub: a queued entry is within its attempts and a down hub holds nothing, over every state in `REACH`. Not a trust-matrix row. Its nonce half (every nonce in use was minted) was a trace invariant and is cut (C10): the shim and the third party mint every nonce they send | - -No guarantee reads a field written by the function it constrains. The history -the guarantees need (`audit`) is derived by `commit` from the state before and -the state after each step. - -Each guarantee can be broken by a change to an honest component. These were -tried on the current specification, one at a time, and reverted. A simulation -is 2000 traces of 40 steps at seed 7 under `step`, unless a step is named. - -| Guarantee | Change | Checked by | Result | -|---|---|---|---| -| F1 | `interpretReply` loses the pending arm | `renderThenInterpretIsMeaningTest` | fails | -| G1 | `shim` forwards an unparseable body | `operatorBlind` on `baseline` | violated | -| G1 | `shim` forwards a migration | `operatorBlind` on `baseline` | violated | -| G2 | the abstract hub answers a queue hit with the queued body | `queuedBytesConfidential` on `baseline` | violated | -| G3 | `interpretReply` skips the txid comparison | `txidAuthenticity` | **holds on `baseline`** (also under `quietStep`, 80 steps); violated on `byzHub` and `byzIndexer` (`quietStep`) | -| G4 | the abstract hub answers not-found on a queue hit | `lookupValidityPerHub` on `baseline` | violated | - -The G3 row is not what was predicted; see [Findings](#findings). - -### Trust matrix - -Which components must be honest for each guarantee. Single-fault. "holds" is a -tier 3 simulation row on the named configuration, with its antecedent witnessed -there in tier 3b. "required" is a scripted run in `tests/trustTest.qnt` in which -the component is Byzantine and the guarantee fails. The run asserts the -guarantee in the state just before the Byzantine step, so the lie is what -breaks it. Such a cell has no simulation row. - -Every cell was a prediction, except the G6c row, which was added after review -and derived by running. **Observed verdicts agree with the predictions in every -cell of this table except the G6b entries marked below.** - -The two tip-withholding runs in the indexer column have the hub ask for the tip -at every block and the indexer answer with a stale one. The simulation rows for those cells classify a -verdict line and cannot say which lie a trace used. The counterexamples the -simulator finds at seed 7 were read by hand and both use reports below the true -height. With truthful answers `byzIndexer` behaves as `baseline`, where the -chain cannot pass a running, idle hub that has not asked. +## Trust matrix + +> For each guarantee, whose honesty it depends on. + +Single-fault. "holds" is a checked row on the named configuration. "required" is a scripted run in which the component is Byzantine and the guarantee fails; the run asserts the guarantee in the state just before the Byzantine step, so the lie is what breaks it. | | All honest | Byzantine hub | Byzantine indexer | |---|---|---|---| | G1 | holds (`baseline`) | holds (`byzHub`) | holds (`byzIndexer`) | | G2 | holds (`baseline`) | **required**: `hubServesQueuedBodyTest` | **required**: `indexerServesUnpublishedBodyTest`. One endpoint suffices | -| G3 | holds (`baseline`) | holds (`byzHub`); a twin and a false height are both served (W16) | holds (`byzIndexer`) | +| G3 | holds (`baseline`) | holds (`byzHub`); a twin and a false height are both served | holds (`byzIndexer`) | | G4 | holds (`baseline`) | **required**: `hubDeniesQueuedTest`, `hubServesFalseHeightTest` | **required**: `indexerForgesPendingTest`. One endpoint suffices | -| G6b | holds (`timely`, `flakyTip`). **Fails on `staleLag` (K4, predicted) and on `staleLagWithSlack` (predicted to hold)** | **required**: `hubAdmitsBeforeFirstTipTest`. The cause differs from the one predicted | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | -| G6c | holds (`timely`, `flakyTip`). Fails on `staleLag` (K4), `flakyTipNoSlack` (K3'), `flakyTipSlowFlight` (K7), and by scripted run on `staleLagWithSlack` | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | -| A3 | holds (`drainIsFinalTest`) | **required**: `hubAdmitsWhileDrainingTest` | holds (`drainIsFinalTest`) | +| G6b | holds (`timely`, `flakyTip`, `flakyTipSlowFlight`) | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| G6c | holds (`timely`, `flakyTip`) | **required**: `hubAdmitsBeforeFirstTipTest` | **required**: `indexerWithholdsTipFromConformingTest`. Needs every endpoint | +| A3 | holds | **required**: `hubAdmitsWhileDrainingTest` | holds | -There is no Byzantine-shim column: the shim sees every migration in plaintext -and controls everything the wallet observes, so every wallet-facing guarantee -assumes an honest (attested) shim. G3 is the only wallet-facing guarantee that -survives a Byzantine hub or indexer, and it authenticates the txid only. G1 -depends on the shim alone. With more than one hub, G2 is required of every -hub (argued, see [One hub](#one-hub)). A3 is a property of the hub function -alone, so the indexer's role does not reach it: its "holds" cells are one -exhaustive test, and its "required" cell is a scripted step. +- A hub folds several indexer endpoints into one answer: the tip is the maximum, a lookup takes the first "found", a broadcast takes the best verdict. So one misbehaving endpoint can raise the tip, inject a lookup answer or change a verdict, while lowering or freezing the tip takes every endpoint. The model has one abstract indexer and does not enforce that difference; the indexer cells say which each needs. +- There is no shim column: every wallet-facing guarantee assumes an honest, attested shim. +- G3 is the only wallet-facing guarantee that survives a Byzantine hub or indexer, and it authenticates the txid only. +- G1 depends on the shim alone. +- A Byzantine hub breaks G6b by admitting while it has no tip, when an honest hub refuses everything. Admitting past the expiry rule cannot break it, because that rule never refuses a supported wallet's timely transaction (F7). -### Known gaps, with every component honest +## Known gaps -K1 and K2 are on the protocol specification; K3' to K8 on the hub -specification, under its configurations. +> Things you might expect to hold that don't, each with a concrete example run. Every component is honest in all of them. -| Id | What is lost | Where | Form | Observed | Scripted runs | +| Id | Threat | What is lost | Where | Checked by | Scripted run | |---|---|---|---|---|---| -| K1 | Told ok does not mean the hub ever admits it | `baseline` | scripted runs only | shown | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | -| K2 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline` | violated invariant | violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | -| K3' | G6b, and with it G6c, when the expiry floor equals the three-term budget | `flakyTipNoSlack` | violated invariant | violated, as predicted | `conformingMissesMarginWithoutSlackTest`; with the slack, G6b and G6c hold on `flakyTip` under TLC | -| K4 | G6b and G6c on the shipped relation, across a silence shorter than the staleness window | `staleLag` | violated invariant | violated, as predicted; the node then cannot accept | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | -| K5 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it (accepted, already known, rejected) | `timely` | violated invariant | violated, by a crash, by a final flush nothing judged, and by a requeue that drops the entry as expired | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | -| K6 | `conformingEveryOfferBeforeExpiry`: G6b without "first offer" | `staleLag` | violated invariant | violated, as predicted | `requeuedPastExpiryTest`; under a timely tip the predicate holds on `timely` under TLC | - -| K7 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | violated invariant | violated; G6b holds there | `slowFlightSpendsTheMarginTest` | -| K8 | A supported wallet's transaction, acknowledged on time, then lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. G6b and G6c do not cover it: to the restarted hub the resend is a late first arrival | `flakyTip` | scripted run | shown; not a TLC row | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | - -K7 was added after review. The four-term budget (`reorgSlackFits`) holds with -equality in the shipped constants, so a transaction that uses all of it is -offered with exactly the mining margin left. The margin is then the only thing -that pays for blocks arriving while the batch is in flight, and nothing in the -code bounds a flight in blocks. - -K1 is not stated as a violated invariant because the invariant is false on the -ordinary success path too: the wallet is told ok before the hub has the -frame. It is pinned by its two scripted runs and has no simulation row. In `toldOkAndNeverDeliveredTest` the run ends with the -frame undelivered, and nothing obliges the network ever to deliver it. - -### Witnesses - -Each has a scripted run. W8, W16 and W19 are also counted in tier 3b; W1-W3, -W9, W15 and W18 are scripted only. -W4 (each refusal) and W5-W7 (requeued, dropped as expired, dropped as -exhausted) were witnesses here; they read the hub's internals and are gone -with the real hub. F8 produces each refusal and F9 each requeue outcome; the -hub specification reaches `wRequeued` under TLC and both drops in -`requeueAndDropTest`. - -| Id | Witness | Name | Configuration | -|---|---|---|---| -| W1-W3 | the wallet sees pending; its transaction in the mempool; mined | `wPending`, `wTxInMempool`, `wTxMined` | `baseline` | -| W8 | **Accepted disclosure**: a third party that knows a txid learns it is queued. The hub withholds the bytes, not the fact. See the quoted comment under [Scope](#scope) | `wQueuedDisclosed` | `baseline` | -| W9 | a queued payload the hub cannot parse is asked for and missed | `wUnparseableMissed` | `baseline` | -| W15 | **Premature flush**: a Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not a G6 one. One endpoint suffices | scripted run `tipAheadOfChainFlushesEarlyTest` (hub specification) | `byzIndexer` | -| W16 | **Twin served**: the wallet is served a twin of what it sent, and a transaction at a false height; G3 holds throughout | `wTwinServed`, `wFalseHeightServed` | `byzHub` | -| W18 | **Early flush by the free-running clock**: a stale hub's clock is ahead of the chain and it flushes before the true boundary, every component honest | scripted run `freeRunningClockFlushesEarlyTest` (hub specification) | `staleLag` | -| W19 | A third party is served a published transaction's bytes from the indexer: the branch of G2 that `vQueuedBytesConfidential` does not reach on `baseline`, where `plain` at the operator satisfies it | `wThirdPartyServedBody` | `baseline` | - -Non-vacuity: for each guarantee, a state where its antecedent holds, reached on -every configuration where the guarantee is claimed: `vOperatorBlind`, -`vQueuedBytesConfidential` (with W19 for its reply-body branch), -`vTxidAuthenticity`, `vLookupValidityPerHub` (the log has a pending, a served -transaction and a not-found; reached under `earlyLookupStep`, about 25 traces -in 2000, against 2 under `quietStep`). -The antecedents of G6b and G6c are reachability rows of the hub -specification. - -### Two-state properties - -Both are steps of the hub function, so they are checked on the hub alone, in -`tests/hubTest.qnt`, over every pair of a reachable hub state and an input. -`REACH` is the closure of `starting` under: - -- submits of `pA` (Orchard-touching, expiry 9) and `pJunk` (unparseable), - each through the honest hub and through every Byzantine result; -- tips 0, 4, 5, 6 and 9, and stale reports at 4, 8 and 9; -- `FlushDue`, `FlushDone`, `Drain`, `Crash` and `Restart`; -- each of the four verdicts on each payload; - -with the `hubTest` schedule (flush interval 3, mining margin 1, two attempts, -reorg allowance 1). `reachTest` checks that `REACH` is closed under all of -these, so the checks below are exhaustive over those parameters, not -depth-bounded. - -These are not the hub specification's parameters (`timely`: mining margin 2, -expiry floor 7), and `REACH` has one -parseable payload, no twin and no tight payload. The abstraction lemma is -carried to the hub specification's parameters by argument, not by a check: -`hub()` takes its parameters as arguments, and the abstract hub has none and -reads only queue membership and wire replies. `REACH` was not run at -`timely`'s parameters; as an exhaustive closure it would very likely not -finish. - -| Id | Test | What it says | Class | +| K1 | T7 | Told ok does not mean the hub ever admits it: it may refuse the frame, or never receive it | `baseline` | scripted runs | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | +| K2 | T8 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline` | simulation, violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | +| K3' | T5 | G6b and G6c when the expiry floor leaves no slack for the reorg allowance | `flakyTipNoSlack` | TLC, violated | `conformingMissesMarginWithoutSlackTest` | +| K4 | T5 | G6b and G6c on the shipped relation between the constants, across a tip silence shorter than the staleness window | `staleLag` | TLC, violated | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | +| K5 | T9 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it | `timely` | TLC, violated three ways | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | +| K6 | T5 | `conformingEveryOfferBeforeExpiry`: G6b for every offer, not only the first | `staleLag` | TLC, violated | `requeuedPastExpiryTest` | +| K7 | T5 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | TLC, violated; G6b holds there | `slowFlightSpendsTheMarginTest` | +| K8 | T5 | A supported wallet's transaction, acknowledged on time, lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. To the restarted hub the resend is a late first arrival, so G6b and G6c do not cover it | `flakyTip` | scripted run | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | + +K1 is not an invariant because it would be false on the ordinary success path too: the wallet is told ok before the hub has the frame. + +Behaviours that are accepted or only recorded: + +| Behaviour | Threat | Shown by | +|---|---|---| +| **Accepted disclosure.** A third party that knows a txid learns that it is queued. The hub withholds the bytes, not the fact. The implementation leaves this open deliberately (`Hub::lookup`, `zeronym/hub/src/server.rs`): "the 200-versus-NotFound distinction still discloses that a given txid is queued here. Closing that too means answering NotFound, which costs a wallet the ability to tell "pending" from "never seen"" | T10 | `thirdPartyLearnsItIsQueuedTest`; witness `wQueuedDisclosed` on `baseline` | +| **Twin and false height served.** The wallet can be served a twin of what it sent, and a transaction at a height the hub made up. G3 holds throughout | T3 | `wTwinServed`, `wFalseHeightServed` on `byzHub` | +| **Premature flush.** A Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not an expiry one | T11 | `tipAheadOfChainFlushesEarlyTest` | +| **Early flush by the free-running clock.** A stale hub's clock is ahead of the chain and it flushes before the true boundary, with every component honest | T11, with no liar | `freeRunningClockFlushesEarlyTest` | +| **Unparseable and queued.** A payload the hub cannot parse has no txid, so a lookup misses it while it is queued | none | `unparseableIsQueuedAndMissedTest` | + +## Findings + +> Where the model disagrees with what the code or its comments assume. + +Nothing here has been fixed. "Code read" means the cited lines were read and match the model; nothing was run against the Rust. + +| # | Finding | Shown by | Against the Rust | |---|---|---|---| -| A2 | `neverEvictTest` | An entry leaves the hub's queue only into a flush, or because the hub went down or exited after its final flush | guarantee, any role, under the Byzantine-hub model ([Assumptions](#assumptions)) | -| A3 | `drainIsFinalTest` | A draining honest hub's queue gains only what a flush hands back | guarantee, honest hub | +| 1 | **A short tip silence costs a supported wallet its mining margin.** The cadence follows the last tip seen, so a silence across a flush boundary delays the flush until the hub goes stale. On the shipped constants the first offer is at `created + 37` against an expiry of `created + 40`: three blocks of margin where four are reserved | K4, `silenceAcrossBoundaryMissesMarginTest`; TLC on `staleLag` | Code read: `batcher.rs:40-71`, `:227-247`. The one-block shortfall reads 15 minutes as exactly 12 blocks | +| 2 | **An early free-running flush spends the next epoch.** A stale hub's clock runs ahead, flushes an empty queue and records that epoch. When the tip returns, admission counts on a flush that has already happened, and the transaction waits a full interval. A wider expiry floor does not fix it | `earlyFlushSpendsTheNextEpochTest`; TLC on `staleLagWithSlack` | Code read: `batcher.rs:316-326`. The comment there calls a clock that runs ahead "the safe direction" | +| 3 | **The reorg slack holds by coincidence of constants.** The expiry floor minus the three-term budget is 10 blocks, exactly the reorg allowance, and startup validation checks only the three-term sum. Without the slack a supported wallet's transaction misses its margin | K3', `conformingMissesMarginWithoutSlackTest`; TLC on `flakyTipNoSlack` | Code read: `batcher.rs:40-59`, `:101-113` | +| 4 | **Nothing bounds a flush's flight in blocks.** The budget leaves exactly the mining margin at the offer, so blocks that arrive while the batch is in flight come out of it | K7, `slowFlightSpendsTheMarginTest`; TLC on `flakyTipSlowFlight` | Code read: `chain.rs` bounds each call (`RPC_TIMEOUT`), not the batch | +| 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired after two unjudged flushes | K5 and its three runs; TLC on `timely` | Code read: the queue is in memory only (`queue.rs:226-243`, `batcher.rs:337-347`) | +| 6 | **A crash plus a late duplicate is offered past the margin.** A restarted hub adopts the current epoch without flushing; told a tip one block back, it admits the resend counting on a flush that will not happen | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-519` | +| 7 | **An entry with an expiry can be dropped as exhausted.** On a hub that sees no tip, each requeue judges the entry against the same stale tip, so the expiry rule never gives it up and the attempt bound does | `expiringEntryDroppedAsExhaustedTest` | Code read, and it contradicts a comment: `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | +| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | F2, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | +| 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:630-633`. Unexamined; not claimed as a bug | + +On finding 2, the model lets the free-running clock be at most one flush interval ahead, so it can spend one epoch. `cadence_height` has no such cap. Reading that code, a clock further ahead would skip more than one boundary; the model does not exhibit that. -A2 is stated whatever the hub's role: the Byzantine submit relation only ever -adds to a queue. The exit clause matters only for a Byzantine hub, which can -admit while draining (`hubAdmitsWhileDrainingTest`); the final flush then -stops it with those entries still queued, and they are lost with the process. -Before `REACH`, A2 was written without that clause, as an unrun `temporal` -definition; the closure found the counterexample. +## Scope -A3 is stated of an honest hub only. Draining is an admission rule, and a -Byzantine hub is not bound by admission rules: `hubAdmitsWhileDrainingTest` -takes a submission into the queue after the drain began, having first shown -that an honest hub would refuse the same frame. +> What is in the model, what is out and why, and the facts read from the Rust that it rests on. -The old A1, "a transaction's chain status never moves backwards", was an -assumption about the environment and is true by construction of the chain -model, so it is not stated. +### In the model -### The abstraction lemma +| Area | What is modelled | Why | +|---|---|---| +| Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4 | +| Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | +| Hub | In the hub specification: lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash. In the protocol specification: the abstract hub, a queue and the entries out with a flush, which accepts, refuses, takes, settles, gives back and loses (see [The abstraction lemma](#the-abstraction-lemma)) | S10-S19 | +| Chain / indexer | per-txid status (absent, mempool, mined); what the indexer has been offered; verdict and lookup-answer relations. A lookup answer's height is 0, the height the transaction was mined at, or another (`WireHeight`). The protocol specification has no chain height and no block clock, and its verdict relation has no expiry clause (`heightlessIndexerResults`); the hub specification keeps the chain height, which its tip and expiry rules read | S15, S22 | +| Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation | S20, S22 | +| Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | +| Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned or the chain has published; its payload knowledge is derived from what it can observe | S13, S25 | +| Network | drop, duplicate, delay, reorder; cannot forge | | +| Hubs | one; see [One hub](#one-hub) | S24 | +| Tip | Hub specification only: `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, the reorg allowance, the staleness window and the wallet expiry floor as parameters | S17, S26, S32 | + +### One hub + +The specification checks one hub; production runs one or more, replicated: every shim sends every submission to every hub, and each hub that receives a migration queues and broadcasts it (`zeronym/shim/src/nym.rs:602-647`). The single hub is a scope choice, not a claim about production. -`abstractHub.qnt` is the hub as the protocol sees it: the payloads it has -queued, the payloads out with a flush, and its wire replies. It has no phase, -tip or schedule. A submit is accepted (the payload joins the queue) or -refused under one of the three codes; a lookup is a queue hit for a queued -txid and the indexer's answer otherwise; and the internal moves are take, -settle, give back what is kept, and lose everything. A Byzantine hub answers a -lookup with anything, with any body from the universe; a submission it accepts -or refuses as the honest relation already allows. +Not checked as a result: two hubs disagreeing about one transaction; duplicate publication, and a second enclave holding the plaintext, both accepted deliberately in production; told ok after a partial send, and its anonymity cost; a lookup moving to the next address on a timeout (finding 9); that one Byzantine replica is enough to break G2 and G4. -Over the same `REACH` as A2 and A3, with lookups added, `hubTest` checks: +Argued, not checked, on the assumption that hubs share nothing but the chain and the indexer: -| Test | What it says | +- **Compose per hub:** G1, G3, G4 (which is why its name says "per hub"), G6b and G6c, and gap K5. +- **Compose only if every hub is honest:** G2. One Byzantine replica holds the same bytes and can give them away. +- **Do not compose:** K1 and K2 each gain a cause with a second hub. + +### Out of the model + +Never modelled: + +| Item | Reason | |---|---| -| `abstractionTest` | Every honest step of the real hub is an honest abstract step, or an error that changes nothing | -| `byzantineAbstractionTest` | Every member of `byzHubResults` for a submit or a lookup is a Byzantine abstract step | -| `realisesTest` | Each abstract move (accept, refuse, take, settle, a retryable verdict, give back, lose) has a concrete step that projects onto it | +| Attestation, PCRs, TLS, STEVE, keymaker quorum | No in-protocol messages exist (S23). Represented by the roles | +| Mixnet internals: SURBs, Sphinx, cover traffic, gateways, throttling; shim client rotation supervisor (`zeronym/shim/src/nym.rs:942-1024`); both `nym_driver.rs` | Protocol-visible effect is loss and delay | +| Hub lookup concurrency bound, reply deadline, dropped acks (S21) | Refinements of "the network lost the message" | +| Wall-clock time | The staleness window is counted in blocks (`STALE_WINDOW`), and a free-running cadence height is chosen by the environment, never behind the chain (see the tip assumption); there is no clock | +| Multiple indexer endpoints and their folds | One abstract indexer per model stands for all of a hub's endpoints. Because the folds are asymmetric (S28), this document states for each Byzantine-indexer behaviour whether one lying endpoint suffices or all must lie | +| Wire codecs `ZNS1` / `ZNA1` / `ZNL1` / `ZNR1` and the golden vectors (`zeronym/hub/src/wire.rs:576-579`) | Byte layouts are scoped out and are pinned by the Rust tests in both crates; the abstract `render` / `interpretReply` layer is the level this spec works at. The spec does not claim to bind the codec | +| Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | +| Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties | +| Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | +| Forward-only shim, transparent-pool RPCs, health / address / attestation endpoints, DoS bounds, logging | Not divert-protocol state | +| More than one Byzantine component at once | The trust matrix is single-fault | -A temporary edit that makes `hub` ack a submission without queueing it fails -`abstractionTest`. +Removed, each because it produced no finding and removing it made the specification smaller: -The protocol specification's hub is this abstract one. What the lemma -transfers: an invariant that holds over the abstract hub, and -reads only queue membership and wire replies, holds over the real hub with -these parameters. That covers G2, G3 and G4. What it does not transfer is -reachability. The abstract hub answers where the real one is down, starting, -stopped or stale, so a violation or a reached state shown over it is a state -of the abstract hub. `realisesTest` shows each abstract move has a real hub -step behind it; no run is replayed through the real hub as a whole. +| Item | Reason | +|---|---| +| More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | +| The HTTP transport, where the shim waits for the hub's verdict before answering the wallet. In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the only configuration in which the shim's ok meant the hub had the transaction. Its two HTTP-only details were read in the code and are not distinct wallet observations: the client's `"already_known"` arm has no hub source, and a malformed 200 on a lookup becomes the same `Unavailable` as an error | +| A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. | +| Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed: a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it | +| Payloads of the third party's own making | Removed: the hub's address is public and unauthenticated, so this is possible, but nothing read them. The third party still submits what it has learned or the chain has published (a cause of K2) | +| The frame-size lemma, `sizeOf` and F6 | Removed: true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | +| The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. The shim's own too-large arm (S3) stays | +| A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | +| The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | +| G8 `ackImpliesQueued` and F13: an accepted ack is only for a payload the hub queued. In code: `queue.rs` admits before it acks | Removed: nothing reads an ack since the HTTP transport went. The abstraction lemma still fails if `hub` acks without queueing | +| A Byzantine hub's false ack (accepted but not queued, or queued but refused) | Removed with G8: no remaining guarantee reads it. A Byzantine hub still admits or refuses against the rules, and lies in lookup replies | +| G6a `offeredBeforeExpiry` and K3: the margin at the offer for every admitted transaction, including one whose wallet set an expiry below the supported floor. In code: admission's "provably survives its scheduled flush" (`zeronym/hub/src/queue.rs:497-519`) | Removed: it adds only unsupported wallets to G6b. Known not to hold under a tip reported behind the chain (K3); no longer checked | +| K1 as reachable-state rows, and the `everQueued` history they read | K1 is pinned by its two scripted runs. The simulation rows were the last readers of that history | +| Replaying each pinned protocol run through the real hub (`realisations`, `realisedRunsTest`) | Removed: it produced no finding. Violations and reached states of the protocol specification are shown over the abstract hub; `realisesTest` shows each abstract move has a real step | -No liveness property is claimed: the network may lose everything, and nobody -waits for an ack. +
+Protocol facts read from the Rust -## Findings +| # | Fact | Source | +|---|---|---| +| S1 | Shim classifies `SendTransaction` by presence of Orchard actions; unparseable folds into "treat as migration" | `zeronym/shim/src/classify.rs:70-101`, `:246-248` | +| S2 | Shim-unparseable includes trailing bytes, which the hub's parser accepts, so "shim cannot parse" does not imply "hub computes no txid" | `zeronym/shim/src/classify.rs:269-283`, `zeronym/hub/src/queue.rs:281-289` | +| S3 | Divert arms: unreadable body fails closed; empty body INVALID_ARGUMENT; too large RESOURCE_EXHAUSTED; hub unreachable UNAVAILABLE; never the operator | `zeronym/shim/src/intercept.rs:180-283` | +| S4 | With a hub configured every `GetTransaction` goes to the hub; shim keeps no per-migration state | `zeronym/shim/src/intercept.rs:305-314`, `:58-64` | +| S5 | Lookup reply arms, in order: found/height 0/empty relayed as pending; found served only if the bytes' txid equals the query (L4), else NOT_FOUND; not-found; error fails closed | `zeronym/shim/src/intercept.rs:370-422`, `:453-466` | +| S6 | Two transports behind one enum: HTTP (verdict returned synchronously) and Nym | `zeronym/shim/src/hub.rs:277-328` | +| S7 | Nym submit is dispatch-only: success once one frame is handed over, fresh nonce per hub address, sent to every address; the ack is never awaited | `zeronym/shim/src/nym.rs:595-703` | +| S8 | Nym lookup tries addresses in turn; only a timeout moves on; fresh nonce per attempt | `zeronym/shim/src/nym.rs:708-797` | +| S9 | Correlation by nonce only; unknown nonce dropped; wrong reply kind for a known nonce ignored, waiter stays | `zeronym/shim/src/nym.rs:1040-1077`, `zeronym/hub/src/wire.rs:22-27` | +| S10 | Hub admit: tip-stale gate, then draining, too large, expiry survives next scheduled flush, payload-hash dedup, byte and entry budget. Admission never asks a node | `zeronym/hub/src/server.rs:343-392`, `:299-303`, `zeronym/hub/src/queue.rs:256-340` | +| S11 | Queue identity is `sha256(bytes)`; dedup is against resident entries only (`inner.entries.contains_key`), and a flush removes every entry (`inner.entries.drain()`); accepted entries are not put back. So bytes that were published are admitted again if resubmitted | `zeronym/hub/src/queue.rs:17-22`, `:308-310`, `:358-359`, `zeronym/hub/src/batcher.rs:389` | +| S12 | Hub lookup: queue first (found, height 0, no bytes), then indexer; unparseable entries never hit; flush window answers not-found, deliberately | `zeronym/hub/src/server.rs:403-462`, `zeronym/hub/src/queue.rs:455-475` | +| S13 | Lookup and submit to the hub are unauthenticated; the hub's Nym address is public with no ACL; the queue-hit reply discloses that a txid is queued | `zeronym/hub/src/server.rs:413-437`, `zeronym/hub/src/nym.rs:220-227` | +| S14 | Flush fires only when `cadence_height / flush_interval` exceeds the last flushed epoch; first observation adopts the epoch without flushing; shutdown flushes once more | `zeronym/hub/src/batcher.rs:316-335` | +| S15 | Flush drains everything, broadcasts, then: accepted / already-known leave; rejected dropped; retryable requeued | `zeronym/hub/src/batcher.rs:358-422`, `zeronym/hub/src/chain.rs:129-134` | +| S16 | Requeue: resident copy wins; attempts + 1; dropped if it no longer survives the next flush or attempts exceed 8; may overrun the byte budget; reports `held` / `dropped_expired` / `dropped_exhausted` | `zeronym/hub/src/queue.rs:186-199`, `:366-422`, `:96` | +| S17 | Tip is the max over answering endpoints; a regression within 10 blocks is followed; staleness stops admission only | `zeronym/hub/src/chain.rs:183-201`, `zeronym/hub/src/batcher.rs:161-205`, `:222-225` | +| S18 | Budget inequality `flush_interval + mining_margin + delivery_lag <= min_wallet_expiry` asserted at startup | `zeronym/hub/src/batcher.rs:93-118` | +| S19 | Drain closes admission before the final flush; the queue is RAM-only | `zeronym/hub/src/main.rs:142-177`, `zeronym/hub/src/queue.rs:226-243`, `zeronym/hub/src/batcher.rs:337-347` | +| S20 | Wire: four fixed-size frames; reply dispositions found / not_found / error; not_found or error with a payload is a decode error; `Draining` shares `QueueFull`'s code | `zeronym/hub/src/wire.rs:29-59`, `:278-290`, `:531-569` | +| S21 | Hub drops lookups past 64 in flight, replies older than 60 s, acks when the driver queue is full | `zeronym/hub/src/nym.rs:54`, `:75`, `:171-213`, `:285-292` | +| S22 | Indexer lookup answer is forwarded verbatim, so a zero `RawTransaction` is byte-identical to the queue-hit sentinel | `zeronym/hub/src/server.rs:445-451`, `zeronym/hub/src/chain.rs:284-290` | +| S23 | No attestation or STEVE handshake exists in code | `zeronym/README.md:88` | +| S24 | Replicate, never fail over: every hub that receives a migration queues and broadcasts it | `zeronym/README.md:86`, `zeronym/shim/src/nym.rs:602-647` | +| S26 | Shipped constants: `FLUSH_INTERVAL_BLOCKS = 20`, `MINING_MARGIN = 4`, `MAX_DELIVERY_LAG = 6`, `MIN_WALLET_EXPIRY = 40`, `REORG_ALLOWANCE = 10`. The slack `40 - (20 + 4 + 6) = 10` equals the reorg allowance exactly. `BatchParams::validate` asserts only the three-term sum; nothing asserts the four-term one | `zeronym/hub/src/batcher.rs:40-59`, `:101-113` | +| S27 | Lookup starts at a rotating cursor, so consecutive polls start at different hubs; a `NotFound` from the first hub asked is final | `zeronym/shim/src/nym.rs:756-793` | +| S28 | Indexer folds are asymmetric: tip is the max over answering endpoints (one endpoint can only win high; a low tip needs every endpoint); lookup returns the first `Found` in endpoint order (one endpoint suffices to inject an answer); publish takes the best verdict | `zeronym/hub/src/chain.rs:183-201`, `:305-319`, `:517-532` | +| S29 | Submit sweep tells the wallet ok when at least one frame was handed over, even if the loop broke before later addresses | `zeronym/shim/src/nym.rs:673-702` | +| S30 | L4 deserialises the returned bytes, computes their txid and compares it with the queried hash in both byte orders. It compares nothing else: not the bytes, not the height | `zeronym/shim/src/intercept.rs:453-466` | +| S31 | HTTP transport: one `SocketAddr`; hub answers `"accepted"` for both a fresh admission and a duplicate, so the client's `"already_known"` arm has no source; a 200 lookup without the octet-stream content type and `x-tx-height` is an error | `zeronym/shim/src/hub.rs:69-72`, `:201-208`, `:259-263`, `zeronym/hub/src/server.rs:741-747` | +| S32 | Two hub clocks. Admission and requeue use the observed height. The flush epoch uses the cadence height, which equals the observed height until no forward move has been seen for `TIP_STALE_AFTER` (15 min, 12 blocks at the nominal 75 s) and then free-runs at the nominal rate. The code comment claims the free-running clock runs ahead of the true height, "the safe direction"; nothing enforces it. Only the cadence loop (and startup) calls `observe`, and it does so before, never during, a flush | `zeronym/hub/src/batcher.rs:59-71`, `:227-247`, `:307-325`, `:414-422`, `zeronym/hub/src/main.rs:62` | +| S25 | The operator can recover a diverted transaction's txid from transparent-pool queries, so a txid can be known to an outsider before publication | `zeronym/README.md:34` | -These are what the model showed that the predictions did not, or showed about -the code. Nothing here has been fixed, and no property or role relation was -changed to make a prediction come out. - -**1. K4, confirmed: a short tip silence costs a supported wallet its mining -margin, on the shipped relation between the constants.** In -`silenceAcrossBoundaryMissesMarginTest`: a transaction built at height 2 with -expiry 9 (the floor) is admitted at 3. The hub last sees the tip at 5, one -block short of the flush at 6. Its cadence follows the tip it last saw, so -nothing is flushed until it goes stale at 8. The transaction is offered at -height 8 with one block of margin where two are reserved (`9 < 8 + 2`). One -block then arrives while the batch is in flight, which the model permits and -the margin exists to pay for, and at height 9 no honest node can accept it: -G6b and G6c both fail. With the shipped numbers the same shape gives a first -offer at `created + 6 + 20 + 11 = created + 37` against an expiry of -`created + 40`: three blocks of margin where four are reserved. -`staleSlackFits` (`interval + margin + lag + window - 1 <= floor`) is false of -the shipped constants (41 > 40). The one-block figure depends on reading 15 -minutes as exactly 12 blocks; blocks are not that regular, so the real -shortfall is sometimes larger. - -**2. The relation that fixes K4 does not give G6b: an early free-running flush -spends the next epoch.** This was predicted to hold on `staleLagWithSlack` and -does not. In `earlyFlushSpendsTheNextEpochTest`: a stale hub's free-running -clock reads 6 at true height 4, so the flush scheduled for 6 runs then, with -nothing to publish, and its epoch is recorded as done. The hub then sees the -tip again, at 5. Admission knows the tip and not the schedule's history: it -admits a transaction counting on the flush at 6. That flush has already -happened; the cadence loop flushes only when the epoch exceeds the last one it -recorded. The transaction waits for the flush at 9, and a further silence of -two blocks, short of the staleness window, makes that one late: it is offered -at 11 with expiry 12, half its margin gone, and after one block in flight the -node cannot accept it. By the arithmetic of that run each half alone is -harmless at these numbers; simulation finds the combination about once in a few -thousand traces of 60 steps. The implementation's comment calls a free-running -clock that runs ahead "the safe direction". It is safe for what is in the queue -when it runs. It is not safe for what is admitted after the tip returns, while -the chain is still behind an epoch the clock has already spent. - -The model lets the free-running clock be at most one flush interval ahead of -the chain, so it can spend one epoch and no more. `cadence_height` -(`hub/src/batcher.rs`) adds elapsed time over the nominal block time with no -cap. Reading that code, a clock further ahead would record a later epoch and -skip more than one boundary. **That is a reading of the code. The model does -not exhibit it and no run here shows it.** - -**3. K5: an acknowledged payload can be lost three ways, all with every -component honest.** The invariant first written, "held or offered", counted an -offer at the start of a flush as settling the payload, and so was not violated -by a final flush that nothing judged. Review pointed that out. It is restated -as `ackedIsHeldOrSettled`: held by the hub, or on the chain, or judged by a -node. That is violated by a crash (`ackedThenCrashedTest`), by a draining hub's -final flush that finds the indexer unreachable (`ackedThenLostAtDrainTest`), -and by a requeue that drops an entry as expired after an outage -(`requeueDropsAckedAsExpiredTest`). The third was not predicted; simulation found it. - -**4. G3 does not depend on the shim's txid check when every component is -honest.** Removing the comparison from `interpretReply` leaves G3 holding on -`baseline`, because an honest hub and indexer never return another -transaction. It fails on `byzHub` and `byzIndexer`, which is where the check is -claimed to matter. Rerun on the current specification, with the same result. G3 is kept as a guarantee: it is falsifiable where it is -claimed "by the check", and other changes to honest code would break it on -`baseline`. - -**5. G6b and G6c need the hub, but not for the predicted reason.** A hub that admits -past the expiry rule cannot break G6b, because the expiry rule never refuses a -conforming, timely transaction (F7). What breaks it is a hub that admits while -it has no tip, when an honest hub refuses everything -(`hubAdmitsBeforeFirstTipTest`). - -**7. G6 stops at the offer; G6c and K7 were added to see past it.** G6b -stamps an offer when the flush begins. The node judges later, and the chain -may have moved. With the first scaling (margin 1) the runs that showed "the -slack is exactly enough" ended one enabled block before the transaction became -unacceptable. The schedule is now scaled with a margin of 2, flight time is -bounded by `MAX_FLIGHT_BLOCKS`, and G6c is checked at the verdict. Observed: -G6c holds on `baseline`, `flakyTip` and `byzShim`, and for the honest hub of -`replicatedOneByz`; it fails wherever G6b fails, and on `flakyTipSlowFlight` -where G6b holds. (`byzShim` and `replicatedOneByz` have since been removed.) - -**6. The second clause of G1, "and no lookup", is not stated.** No output of -the shim function routes a lookup to the operator, so the clause would hold by -construction and could not be broken by any of the listed changes. - -**9. An entry with an expiry can be dropped as exhausted.** In -`expiringEntryDroppedAsExhaustedTest` (hub specification, `staleLag`): `late`, -expiry 11, is queued by a hub whose tip stops at 5. Three flushes come back -unjudged. Each requeue judges the entry against the observed tip, as admission -does, so the next flush it knows of is still the one at 6 and the expiry rule -never gives the entry up; the attempt bound does. The implementation's requeue -has the same two checks in the same order and is passed the observed tip -(`zeronym/hub/src/queue.rs:408-415`, `zeronym/hub/src/batcher.rs:413-422`), -while `queue.rs:197` says of the exhausted count "Only reachable for a payload -with no expiry". Shown on the model; the code was read at those lines and not -run. The bound there is 8 requeues, so the shape needs nine unjudged flushes -of a hub that sees no tip throughout. - -**8. "Timely" did not survive a restart; restated, G6b and G6c hold where the -tip may be reported behind the chain, and what is left is K8.** As first -written, "timely" remembered a payload's first entry into the hub's queue -forever. TLC then violated G6b and G6c on `flakyTip`, and G6b on -`flakyTipSlowFlight`, with every component honest and the reorg slack in -place; simulation had reported them holding. The trace: `early` (built at 2, -expiry 9) is admitted at 2 and the hub crashes. It restarts at height 6, -adopts that epoch without flushing, and is then told the tip is 5. A -duplicate of the same submission arrives and is admitted, because -`9 >= 6 + 2` and admission counts on the flush at 6. The hub is shut down at -8; its final flush offers the transaction with `9 < 8 + 2`. - -Timeliness is now forgotten when the hub goes down (a crash, or the final -flush that stops it), as its queue is. Under that definition TLC exhausts -`flakyTip` with G6b and G6c holding, and `flakyTipSlowFlight` with G6b -holding (table below). `freshAfterRestartMeetsMarginTest` shows a restarted -hub giving a fresh arrival the whole margin with the tip one block behind -throughout, and the reachability row `wTimelyQueuedBehindEpoch` shows that a -timely payload does get queued while the cadence epoch is behind the one the -hub last recorded, so the "holds" is not vacuous there. - -"First offer" is forgotten with them. A restarted hub's entries start again -at no attempts (`zeronym/hub/src/queue.rs:334`), so the observer's `offered` -is cleared whenever `seen` and `onTime` are. Until review round 2 it was kept -across a crash, and a payload offered before a crash and resent on time -afterwards had its first flight after the restart left out of G6b and G6c. -Clearing it changes no verdict and no trace length (see the TLC section). - -The three facts the trace rests on were read in the code, and the model has -each right. A restarted hub has no tip and no recorded epoch, and its first -observation adopts the current epoch without flushing -(`zeronym/hub/src/batcher.rs:316-322`). A regression within the reorg -allowance is followed (`batcher.rs:177-186`), and a cadence epoch below the -recorded one flushes nothing (`batcher.rs:323-327`). Admission computes its -deadline from the observed tip alone (`zeronym/hub/src/server.rs:351-356`, -`zeronym/hub/src/queue.rs:294`, `:507-519`). So the trace is the code's -behaviour, and what the restatement changes is only which guarantee claims -it. That behaviour is K8: the wallet did everything right, was acknowledged -on time, and its resend after the crash is offered with one block of margin -where two are reserved. Its control, `lateDuplicateWithoutCrashTest`, has no -crash: the first offer is at 3, in time, and the late offer at 8 is a second -offer (K6's ground, not G6b's). - -## Model-based testing, later - -Not built. The specification is shaped so it can be: - -- Every branch of `step` is a named action and every choice is a named `nondet` - inside it, so `--mbt` traces carry `mbt::actionTaken` and `mbt::nondetPicks`. -- Each step gives one input to one component function and applies one output; - the pairs map onto the seams in the table above. -- All protocol state is in `s`. `audit` is a monitor a harness ignores. -- ITF traces carry `cfg`, `s` and `audit`. Model nonces are counters, - to be bound to real nonces as frames appear. A payload's `id` maps to a - fixture. The frames a step emits are `s.net` after it minus before. - -## The hub specification under TLC - -`hubMachine.qnt` is one hub, the chain and the two things the hub asks its -indexer, built on the same `hub(state, input)` as everything else. Its -observer keeps four sets of payloads and one height and no history, which is -what lets TLC visit every reachable state. `tlc.sh FILE MAIN INIT STEP -INVARIANT` checks one invariant of one configuration and prints `holds - ` or `violated `; anything else, -including a run that TLC has not finished within `TLC_TIMEOUT` (five minutes -by default, 15 in CI), is a failure. No recorded verdict or trace length -depends on the limit. - -A configuration is a value held in the state and selected by a named init -(`initTimely`, ...), whose guard is the assumptions that configuration is -checked under. A guard that is false leaves no initial state, and `tlc.sh` -fails on that. - -Measured on the machine this was written on (Apple silicon, 16 cores, 64 GB; -Quint 0.33.0, Apalache 0.62.1, Java 27), under the first definition of -timeliness (finding 8) and the hub function before the capacity refusals are -removed. -Every run had a five-minute limit. Times are for the whole route (compile, -export, TLC), of which compile and export are about 10 s; "peak" is the -resident size of the largest process. - -Exhausting each configuration (`step`, an invariant that is true everywhere): - -| Configuration | Payloads | Distinct states | Depth | 8 workers, 8 GB | 2 workers, 4 GB | -|---|---|---|---|---|---| -| `timely` | 3 | 189 297 | 44 | 17 s, 3.1 GB | 27 s, 1.9 GB | -| `flakyTip` | 3 | 1 319 986 | 44 | 67 s, 6.3 GB | 165 s, 4.4 GB | -| `flakyTipNoSlack` | 3 | 1 319 986 | 44 | 72 s, 6.3 GB | 166 s, 4.4 GB | -| `flakyTipSlowFlight` | 3 | 1 761 078 | 45 | 95 s, 6.8 GB | 229 s, 4.4 GB (122 s with 4 workers) | -| `staleLag` | 3 | not exhausted: 6 521 452 at depth 32, 679 376 on the queue | | 300 s, 8.5 GB | | -| `staleLag` | 2 (`early`, `late`) | 1 130 260 | 42 | 62 s, 6.1 GB | 139 s, 4.4 GB | -| `staleLagWithSlack` | 3 | not exhausted: 7 713 309 at depth 34, 580 992 on the queue | | 300 s, 8.6 GB | | -| `staleLagWithSlack` | 2 (`early`, `late`) | 1 131 714 | 42 | 63 s, 6.2 GB | 141 s, 4.4 GB | - -The 8-worker runs were two at a time and the 2-worker runs three at a time, -on 16 cores, so each is slower than it would be alone; the two timeouts were -measured that way and were not repeated alone. The two lagging-tip -configurations are therefore checked with two payloads. What that costs: with -`tight`, TLC's counterexample to G6a on `staleLag` is 8 states long; without -it, 13. No verdict differs. - -Verdicts (`step` unless said; 8 workers, 8 GB; every row 11 to 17 s): +Two comments in the implementation that the model follows: + +- The accepted disclosure, `zeronym/hub/src/server.rs`, in `Hub::lookup`: "What this does NOT close: the 200-versus-NotFound distinction still discloses that a given txid is queued here. Closing that too means answering NotFound, which costs a wallet the ability to tell "pending" from "never seen". That is a product decision, not a code one, and it is left open deliberately." +- The flush window (`truth`, used by G4), same file, on `Hub::lookup`: "Note the flush-in-flight gap: `flush()` drains the queue before `broadcast_batch` has reached the indexer, so a lookup in that window gets a queue miss then an indexer NOT_FOUND for a transaction it was told height-0 about seconds earlier. Wallets poll on multi-second intervals and tolerate a transient NOT_FOUND; a resubmit is harmless (deduped pre-flush, already-known post-flush). Holding entries until broadcast returns would extend how long the hub remembers a txid, which is the wrong trade." + +
+ +## How it is checked + +> How much to trust a green row, and how to produce one. + +```sh +sh zeronym/spec/quint/check.sh +``` + +"Holds" means one of three things, and the "Checked by" column says which: + +- **TLC, exhaustive.** The hub specification. TLC visits every reachable state of the named configuration. The configurations are small (two or three payloads, a schedule scaled down from the shipped one, heights up to 12), so this is exhaustive for those parameters and not beyond them. +- **Exhaustive test.** `quint test` over a small finite universe: every input of a function, or every reachable hub state. +- **Simulation.** The protocol specification. `quint run`: at most 40 or 80 steps per trace, 2000 random traces, one seed. Not a proof and not exhaustive to any depth; a property that holds is one no sampled trace violated. + +`quint verify` has not been run on any part of this. TLC is run through `tlc.sh`, because the compiled specification is larger than the Apalache server accepts. + +| Tier | What | Expectation | +|---|---|---| +| 1 | `quint typecheck` on every file | ok | +| 2 | `quint test` on every test file | all pass, and each file reports at least the count `check.sh` gives it | +| 3 | `quint run`, invariants | "holds" rows hold; K2 is violated | +| 3b | `quint run`, witnesses | every listed state is reached at least once; no invariant is violated on the way | +| 4 | `tlc.sh`, one row per invariant and configuration | "holds" rows hold over every reachable state; "violated" rows are violated by a counterexample no longer than the recorded one | + +Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; set `QUINT=quint` to use an installed one). Tier 4 needs Java and Apalache 0.62.1, whose jar carries TLC; without either the tier fails, it never skips. `CHECK_TIERS=simulation` runs tiers 1 to 3b and `CHECK_TIERS=tlc` tiers 1 and 4; CI runs them as two jobs. All four tiers take about six minutes on a 16-core machine. It has not been timed on a CI runner. + +### Configurations + +A configuration is a value held in the state and selected by a named init. + +Protocol specification (at most 3 sends and 3 lookups by the wallet, 3 requests by the third party): + +| Configuration | Hub | Indexer | +|---|---|---| +| `baseline` | honest | honest | +| `byzHub` | **Byzantine** | honest | +| `byzIndexer` | honest | **Byzantine** | + +Hub specification. The schedule flushes every 3 blocks with a mining margin of 2, a delivery lag of 1, a reorg allowance of 1, a staleness window of 3 and an expiry floor of 7. It is the shipped schedule scaled down (interval 20, margin 4, lag 6, reorg allowance 10, staleness window 12 blocks, expiry floor 40), keeping the relations between the constants that the findings turn on. + +| Configuration | Differs from `timely` by | For | +|---|---|---| +| `timely` | | G6b, G6c, K5 | +| `flakyTip` | the tip may be reported up to the reorg allowance behind | G6b, G6c under regression; K8 | +| `flakyTipNoSlack` | and the expiry floor is 6, leaving no reorg slack | K3' | +| `flakyTipSlowFlight` | and a flush may be in flight for 2 blocks | K7 | +| `staleLag` | the hub may hear no tip, and goes stale | K4, K6 | +| `staleLagWithSlack` | and the expiry floor is 8, enough to cover the silence | finding 2 | +| `byzHub`, `byzIndexer` | one Byzantine component, and a tight-expiry payload | the trust matrix | +| `unknownUpgrade` | an unparseable payload; scripted runs only | the attempt bound | + +In every configuration a due flush begins before the next block, and at most one block arrives while a flush is in flight (two in `flakyTipSlowFlight`). Under `staleLag` a hub is stale once it has heard no tip for the staleness window; its free-running clock is then assumed never behind the chain and at most one flush interval ahead of it. The implementation relies on "never behind" and does not enforce it (`zeronym/hub/src/batcher.rs:64-67`). + +
+What TLC visits + +With 8 workers, on the machine this was written on: | Configuration | Invariant | Verdict | |---|---|---| -| `timely` | G6a and G6b and G6c | holds, 189 297 states, depth 44 | -| `timely` | `conformingEveryOfferBeforeExpiry` (K6's predicate) | holds, 189 297 states, depth 44 | -| `timely` | `ackedIsHeldOrSettled` (K5) | violated: 5 states under `step`, 8 under `noCrashStep`, 9 under `quietStep` | -| `flakyTip` | G6a (K3) | violated, 8 states | -| `flakyTip` | G6b; G6c | violated, 18; 19 states. **Holds, 1 468 808 states, depth 44, once timeliness is forgotten on going down** | -| `flakyTipSlowFlight` | G6b; G6c (K7) | violated, 18; 14 states. **G6b holds, 2 020 400 states, depth 44, once timeliness is forgotten** | +| `timely` | G6b and G6c | holds, 20 030 states, depth 40 | +| `timely` | K6's predicate | holds, 20 030 states, depth 40 | +| `flakyTip` | G6b and G6c | holds, 113 496 states, depth 38 | +| `flakyTipSlowFlight` | G6b | holds, 156 352 states, depth 39 | | `flakyTipNoSlack` | G6b (K3') | violated, 12 states | -| `staleLag` | G6a; G6b; G6c; K6 | violated, 12; 13; 14; 13 states | -| `staleLagWithSlack` | G6b; G6c | violated, 19; 20 states | - -Under the first definition G6b and G6c are violated on `flakyTip` and G6b on -`flakyTipSlowFlight`, where simulation of the whole protocol reports that they -hold. The counterexample needs a crash and a late duplicate of a submission -that was first admitted on time; it is about what "timely" means across a -restart (finding 8). With timeliness forgotten on going down, the restated -rows were run with 2 workers and 4 GB, side by side: `timely` 229 339 states -at depth 51 in 38 s, `flakyTip` 205 s, `flakyTipSlowFlight` 286 s. The -restatement raises the state counts, because `seen` and the timely set now -differ between states that agreed before. Every other verdict and trace -length is unchanged by it. - -Trace lengths are with one worker, when TLC's search is breadth first and its -counterexample a shortest one. Three were first recorded from runs with more -workers and were one to three states too long: G6a on `staleLag` (13, now 12), -K5 under `quietStep` (10, now 9), `wStale` on `staleLag` (9, now 6). - -With the capacity refusals removed, a hub may hold all three payloads at -once, and the tier was re-run. Every verdict and every trace length is -unchanged. `timely` still exhausts at 229 339 states, depth 51; `flakyTip` -grows from 1 468 808 to 1 753 204 states, depth 43. Two rows then missed the -five-minute limit: G6b on `flakyTipSlowFlight` (1 824 007 states at depth 30, -152 337 on the queue) and G6c on `byzIndexer` with one worker (1 229 802 -states at depth 17). Those two configurations are now checked with two -payloads: `flakyTipSlowFlight` with `early` and `late`, the supported wallets' -migrations, and `byzIndexer` with `early` and `tight`, which -`indexerWithholdsTipTest` needs. On them G6b on `flakyTipSlowFlight` holds, -164 264 states, depth 36, in 24 s with 4 workers; G6c on `byzIndexer` is -violated in 17 states, in 100 s with one worker; the other five rows have -their recorded lengths. - -With `offered` forgotten when the hub goes down, as `seen` and `onTime` are -(finding 8), the tier was re-run. Every verdict and every trace length is -unchanged. The state counts fall: `timely` 141 492 states, depth 51; -`flakyTip` 1 424 284, depth 43; `flakyTipSlowFlight` 156 352, depth 39. - -With G6a and its rows removed, the tier was re-run with `timely`, `flakyTip` -and `flakyTipNoSlack` carrying the two supported migrations only (`early`, -`late`); `byzHub`, `byzIndexer` and `unknownUpgrade` keep `tight`. Every -remaining verdict is unchanged. The state counts fall: `timely` 20 030 states, -depth 40; `flakyTip` 113 496, depth 38; `flakyTipSlowFlight` 156 352, depth -39. One trace is longer: K5 under `quietStep` is violated in 12 states, not 9. -Without `tight`, the entry a requeue gives up as expired is a supported -wallet's, and that takes two unjudged flushes -(`requeueDropsAckedAsExpiredTest`). The tables above are as measured before -this change and still name G6a and the three-payload configurations. - -Reachability, each as `not(..)` and each violated: on `timely`, -`wConformingFirstOffer` (6 states), -`wConformingFirstOfferInFlightABlock` (7), `wOffered` (7), `wRequeued` (9), -`wDown` (2), `wRestartedOwing` (6), `wBlockInFlight` (7), `wStopped` (4); on -`flakyTip`, the first three (6, 6, 7) and `wTimelyQueuedBehindEpoch` (6); -`wStale` on `staleLag` (6) and on `staleLagWithSlack` (6). - -### The schedule rows, before and after the move - -Every row about the schedule, as the gate of the whole-protocol specification -has it (bounded simulation), under simulation of the hub machine with the same -bounds (`quint run hubMachine.qnt --init=`, seed 7, 60 or 80 steps, the -row's trace count), and under TLC. "v" is violated; the number is the length -of TLC's counterexample in states. - -| Configuration | Invariant | Protocol gate | Hub machine, simulated | Hub machine, TLC | TLC, timeliness restated | -|---|---|---|---|---|---| -| `baseline` / `timely` | G6a, G6b, G6c | holds | holds | holds, exhaustive | holds, exhaustive | -| `flakyTip` | G6a (K3) | v | v | v, 8 | v, 8 | -| `flakyTip` | G6b | holds | holds | **v, 18** | holds, exhaustive | -| `flakyTip` | G6c | holds | holds | **v, 19** | holds, exhaustive | -| `flakyTipSlowFlight` | G6b | holds | holds | **v, 18** | holds, exhaustive | -| `flakyTipSlowFlight` | G6c (K7) | v | v | v, 14 | v, 14 | -| `flakyTipNoSlack` | G6b (K3') | v | v | v, 12 | v, 12 | -| `flakyTipNoSlack` | G6c (K3') | v | v | not a TLC row; G6b's is | | -| `staleLag` | G6a, G6b, G6c (K4) | v | v | v, 12; 13; 14 | v, 12; 13; 14 | -| `staleLag` | K6 | v | v | v, 13 | v, 13 | -| `staleLagWithSlack` | G6b (finding 2) | v, 8000 traces | v, 8000 traces | v, 19 | v, 19 | -| `baseline` / `timely` | K5 | v | v | v, 5; 8 without a crash; 9 without a shutdown | the same | -| `byzHub` | G6a, G6b, G6c | v (5000 and 12000 traces for the last two) | v | v, 8; 12; 13 | the same | -| `byzIndexer` | G6a, G6b, G6c | v | v | v, 8; 16; 17 | the same | - -The three rows in bold are finding 8 under the first definition. Simulation, -of either machine, does not find the counterexample in the traces it samples; -TLC does. The protocol gate's column is from before the move; those rows -have since been removed from it. - -Configuration in the state against configuration as a constant, on `timely` -with G6a, G6b and G6c: the compiled JSON is 13.7 MB with named inits and -23.6 MB with `const CONFIG` and an instance module; both give 189 297 states -at depth 44. TLC alone took 5 s on the constant form; the named form was timed -only as a whole row (17 s, beside another run). Named inits are kept. - -Not measured: Apalache at bounded depths on this machine (one attempt failed -on its configuration and was not repeated); the route with an empty `~/.quint` -and Quint fetched by `npx`. - -## The protocol specification under TLC (measured once, not a gate) - -The protocol state is still one record, `s: System`. A planned split into -separate variables, with the audit recorded by each action, was not done, by -decision during the work. `Audit` has the fields the split would have used, -`everQueued` and `windows`, but `advance(audit, pre, post)` still computes -them by comparing the whole state before and after each step. The -measurement below is of that unsplit machine, so it does not say whether the -split would make the protocol specification checkable. - -Measured once, on the all-honest configuration with `maxRequests` 2 and -invariant `wellFormed` (since cut, C10), through `tlc.sh` with 4 workers, an -8 GB heap and a 300 s limit. A tier 1-3 gate shared the machine for most of -the run. The compiled JSON is 39.2 MB (133.0 MB with the configuration as a -constant and an instance module). TLC did not exhaust it: after 300 s it had -6 942 646 distinct states at depth 11, with 5 392 316 still on the queue, -and a resident set of 6.3 GB. The queue grew by about 1.2 million states a -minute throughout (0.10 M at 4 s, 1.66 M at 64 s, 2.99 M, 4.20 M, 5.39 M at -244 s) and the depth reached only 11, against the 40 to 80 steps the -simulation rows use. Exhaustive checking of the protocol specification does -not look feasible at this bound in minutes; it would need the bound lowered -to one request of each kind, or the soup and the wallet's log bounded, and -whether either is enough was not measured. The protocol gate stays -simulation. +| `flakyTipSlowFlight` | G6c (K7) | violated, 14 states | +| `staleLag` | G6b; G6c; K6 | violated, 13; 14; 13 states | +| `staleLagWithSlack` | G6b; G6c (finding 2) | violated, 19; 20 states | +| `timely` | K5 | violated: 5 states; 8 without a crash; 12 without a shutdown either | +| `byzHub` | G6b; G6c | violated, 12; 13 states | +| `byzIndexer` | G6b; G6c | violated, 16; 17 states | + +TLC runs with deadlock checking off, so a machine whose steps had died would hold everything. The gate therefore also requires one reachable state per family of steps, and the antecedents of G6b and G6c, each as a violated `not(..)` row. + +
+ +### The abstraction lemma + +The protocol specification's hub is the abstract one in `abstractHub.qnt`. `hubTest` checks, over every reachable state of the real hub function and every input, that each real step is a step of the abstract hub (`abstractionTest`, `byzantineAbstractionTest`), and that each abstract move has a real step behind it (`realisesTest`). So an invariant that holds over the abstract hub, and reads only queue membership and wire replies, holds over the real one: that covers G2, G3 and G4. It does not transfer reachability: the abstract hub answers where the real one is down or stale, so a violation shown over it is a state of the abstract hub. The reachable set is computed at a smaller schedule than the hub specification's and carried over by argument. + +### Layout + +Only `protocol.qnt` and `hubMachine.qnt` declare variables, and no module declares a constant. Every other module is pure. + +| File | Owns | +|---|---| +| `spells/basicSpells.qnt`, `spells/soup.qnt` | `Option` and set and map helpers; the message soup | +| `types.qnt` | The vocabulary: payloads, verdicts, refusals, roles, observations | +| `wire.qnt` | The four frames; `render`, `meaning`, `interpretReply` | +| `indexer.qnt` | The chain and indexer as a relation, honest and Byzantine | +| `hub.qnt` | `hub(state, input)`: admission, the tip rule, the flush cycle, requeue; the Byzantine relation | +| `shim.qnt` | `shim(state, input)`: routing and reply correlation | +| `hubMachine.qnt` | The hub specification: one hub, the chain, its configurations and invariants | +| `abstractHub.qnt` | The hub as the protocol sees it | +| `protocol.qnt` | The protocol specification: state, steps, guarantees, gaps, witnesses, configurations | +| `tests/wireTest.qnt`, `indexerTest.qnt`, `shimTest.qnt`, `hubTest.qnt` | The functional properties; in `hubTest.qnt` also G7, A2, A3 and the abstraction lemma | +| `tests/hubScenariosTest.qnt` | Scripted runs of the hub specification: one per gap and per trust-matrix cell | +| `tests/scenariosTest.qnt`, `tests/trustTest.qnt` | Scripted runs of the protocol specification: gaps and witnesses; trust-matrix cells | +| `check.sh`, `tlc.sh` | The gate; one TLC check of one invariant on one configuration | + +## Future work + +> What would make these results bind the code. + +1. **Failing Rust tests for the findings.** Each finding above is shown on the model and matched to the code by reading. Turning each into a failing test needs changes to the code so that an end-to-end run can be driven deterministically: a controllable tip, a controllable indexer, and a hub that can be crashed and restarted in a test. +2. **Model-based testing with `quint-connect`.** Depends on 1, and needs driver code in Rust. The specification is shaped for it: every branch of a step is a named action with its choices as named picks; each step gives one input to one component function and applies one output; and those functions map onto the seams in the table under [System](#system). +3. **`quint verify` with Apalache for a subset of the claims.** The compiled specification is too large for the Apalache server today. A subset small enough to pass would give bounded symbolic checking of the protocol guarantees, which are simulated only. From f21e7081bb4c32966944d5732d325bb5ccd83f8c Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 19:27:55 +0400 Subject: [PATCH 72/80] test(zeronym): trim the spec README's guarantees section and record that liveness is not modelled --- zeronym/spec/quint/README.md | 38 ++++++++---------------------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/zeronym/spec/quint/README.md b/zeronym/spec/quint/README.md index 14cd603a..20f1012b 100644 --- a/zeronym/spec/quint/README.md +++ b/zeronym/spec/quint/README.md @@ -159,7 +159,7 @@ A hub answers a lookup with one of three wire replies, and the shim turns that i > What must be true of the world for the claims to apply. - **Roles.** The shim and the hub both run attested. The shim is modelled as honest in every configuration: it sees every migration in plaintext and controls everything the wallet observes, so no wallet-facing guarantee could survive its compromise. The hub is modelled as honest or Byzantine, not because it is trusted less, but to measure how much each guarantee depends on the hub's enclave. The indexer runs outside any enclave, so a Byzantine indexer is the realistic adversary. One component is Byzantine at a time. -- **Honest and Byzantine.** An honest component takes exactly the transition its function gives. A Byzantine one takes any member of a finite set that contains the honest transition (F12). No message or state field records which it took. +- **Honest and Byzantine.** An honest component takes exactly the transition its function gives. A Byzantine one takes any member of a finite set that contains the honest transition (`byzantineContainsHonestTest`). No message or state field records which it took. - **Byzantine hub.** It admits or refuses a submission whatever the admission rules say, and may send any reply to a lookup. Its ack is modelled as truthful: a real one could ack anything, but nothing reads an ack. Every other move is the honest one: it cannot evict or withhold a queued entry, flush off schedule, or send a frame nobody asked for. - **Byzantine indexer.** Any verdict, with the transaction relayed or not. Any lookup answer built from a payload it was offered, one the chain published, or a twin of either. In the hub specification it also reports any tip. - **Network.** May lose, duplicate, delay and reorder frames. Cannot forge or read them. @@ -207,7 +207,7 @@ The deployment targets the server-side and network-metadata adversaries of Taylo |---|---|---|---|---| | G1 | `operatorBlind` | Everything the shim hands the operator is a pass-through transaction | T1 | Simulation | | G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it and the operator's view | T2 | Simulation | -| G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | T3 | Simulation; F3 exhaustively | +| G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | T3 | Simulation; `servedOnlyOnMatchingTxidTest` exhaustively | | G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree | T4 | Simulation | | G6b | `conformingFirstOfferBeforeExpiry` | A supported wallet's transaction is offered with the mining margin to spare, the first time a hub offers it. About the margin left when the flush begins, not about acceptance; nothing about a later offer of a requeued entry | T5 | TLC, exhaustive | | G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and the flight-time assumption | T5 | TLC, exhaustive | @@ -217,30 +217,7 @@ The deployment targets the server-side and network-metadata adversaries of Taylo A2 and A3 constrain a single hub step, not a state; the rest are state invariants. -"Simulation" is bounded random sampling, not a proof; see [How it is checked](#how-it-is-checked). Every simulated guarantee has a non-vacuity row: a state where its antecedent holds must be reached on each configuration where it is claimed. No liveness property is claimed: the network may lose everything, and nobody waits for an ack. - -No guarantee reads a field written by the function it constrains. The one piece of history the protocol guarantees need, the answers that were true while each lookup waited, is derived from the states before and after each step. - -
-Functional properties: facts about one function, checked on every small input - -| Id | Statement | Test | -|---|---|---| -| F1 | `interpretReply(render(o), q) == meaning(o, q)` for every outcome a queue or an honest indexer produces | `wireTest::renderThenInterpretIsMeaningTest` | -| F2 | The documented collision: a queue hit and an indexer's "found, height 0, no body" render to the same reply, and the shim reads both as pending for every query. `render` is injective on honest outcomes | `wireTest::sentinelCollisionTest` | -| F3 | The shim serves a transaction only if its txid is the one asked for. A twin is served; the height is passed through unchecked | `wireTest::servedOnlyOnMatchingTxidTest` | -| F4 | An error never becomes "not found" | `wireTest::errorIsNeverNotFoundTest` | -| F5 | The shim forwards only cleanly read pass-through transactions | `shimTest::onlyPassThroughIsForwardedTest` | -| F7 | Under the startup budget, a conforming payload arriving within the delivery lag passes the expiry check. This is about admission at one tip, not about when the flush happens | `hubTest::conformingTimelyPayloadIsAdmissibleTest` | -| F8 | The admission decision table, in the implementation's order | `hubTest::admissionDecisionTableTest` | -| F9 | Requeue, entry by entry, and the counts it reports | `hubTest::requeueTest` | -| F10 | A draining hub refuses under the queue-full code | `wireTest::ackRenderingTest` | -| F11 | `hub` and `shim` are total; an invalid input returns an error and changes nothing | `hubTest::totalityTest`, `shimTest::totalityTest` | -| F12 | Each Byzantine relation contains the honest transition | `byzantineContainsHonestTest` in `hubTest`, `shimTest`, `indexerTest` | -| F15 | The protocol's heightless verdict relation contains the honest one and is wider only by the expiry clause | `indexerTest::heightlessCoversTest` | -| F14 | The tip rule: first observation adopted; forward followed; a drop within the allowance followed; a larger drop ignored | `hubTest::tipRuleTest` | - -
+Each pure function also has exhaustive tests over small inputs, in `tests/*Test.qnt`. ## Trust matrix @@ -262,7 +239,7 @@ Single-fault. "holds" is a checked row on the named configuration. "required" is - There is no shim column: every wallet-facing guarantee assumes an honest, attested shim. - G3 is the only wallet-facing guarantee that survives a Byzantine hub or indexer, and it authenticates the txid only. - G1 depends on the shim alone. -- A Byzantine hub breaks G6b by admitting while it has no tip, when an honest hub refuses everything. Admitting past the expiry rule cannot break it, because that rule never refuses a supported wallet's timely transaction (F7). +- A Byzantine hub breaks G6b by admitting while it has no tip, when an honest hub refuses everything. Admitting past the expiry rule cannot break it, because that rule never refuses a supported wallet's timely transaction (`conformingTimelyPayloadIsAdmissibleTest`). ## Known gaps @@ -306,7 +283,7 @@ Nothing here has been fixed. "Code read" means the cited lines were read and mat | 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired after two unjudged flushes | K5 and its three runs; TLC on `timely` | Code read: the queue is in memory only (`queue.rs:226-243`, `batcher.rs:337-347`) | | 6 | **A crash plus a late duplicate is offered past the margin.** A restarted hub adopts the current epoch without flushing; told a tip one block back, it admits the resend counting on a flush that will not happen | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-519` | | 7 | **An entry with an expiry can be dropped as exhausted.** On a hub that sees no tip, each requeue judges the entry against the same stale tip, so the expiry rule never gives it up and the attempt bound does | `expiringEntryDroppedAsExhaustedTest` | Code read, and it contradicts a comment: `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | -| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | F2, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | +| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | | 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:630-633`. Unexamined; not claimed as a bug | On finding 2, the model lets the free-running clock be at most one flush interval ahead, so it can spend one epoch. `cadence_height` has no such cap. Reading that code, a clock further ahead would skip more than one boundary; the model does not exhibit that. @@ -359,6 +336,7 @@ Never modelled: | Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | | Forward-only shim, transparent-pool RPCs, health / address / attestation endpoints, DoS bounds, logging | Not divert-protocol state | | More than one Byzantine component at once | The trust matrix is single-fault | +| Liveness: that anything eventually happens, such as a submitted migration being published | The network may lose everything, and nobody waits for an ack | Removed, each because it produced no finding and removing it made the specification smaller: @@ -369,11 +347,11 @@ Removed, each because it produced no finding and removing it made the specificat | A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. | | Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed: a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it | | Payloads of the third party's own making | Removed: the hub's address is public and unauthenticated, so this is possible, but nothing read them. The third party still submits what it has learned or the chain has published (a cause of K2) | -| The frame-size lemma, `sizeOf` and F6 | Removed: true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | +| The frame-size lemma and `sizeOf` | Removed: true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | | The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. The shim's own too-large arm (S3) stays | | A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | | The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | -| G8 `ackImpliesQueued` and F13: an accepted ack is only for a payload the hub queued. In code: `queue.rs` admits before it acks | Removed: nothing reads an ack since the HTTP transport went. The abstraction lemma still fails if `hub` acks without queueing | +| G8 `ackImpliesQueued` and its function-level test: an accepted ack is only for a payload the hub queued. In code: `queue.rs` admits before it acks | Removed: nothing reads an ack since the HTTP transport went. The abstraction lemma still fails if `hub` acks without queueing | | A Byzantine hub's false ack (accepted but not queued, or queued but refused) | Removed with G8: no remaining guarantee reads it. A Byzantine hub still admits or refuses against the rules, and lies in lookup replies | | G6a `offeredBeforeExpiry` and K3: the margin at the offer for every admitted transaction, including one whose wallet set an expiry below the supported floor. In code: admission's "provably survives its scheduled flush" (`zeronym/hub/src/queue.rs:497-519`) | Removed: it adds only unsupported wallets to G6b. Known not to hold under a tip reported behind the chain (K3); no longer checked | | K1 as reachable-state rows, and the `everQueued` history they read | K1 is pinned by its two scripted runs. The simulation rows were the last readers of that history | From 86f252231d0dd6d6d66609963cbd0802c9e83465 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 19:39:23 +0400 Subject: [PATCH 73/80] test(zeronym): replace the spec README's scope section with what is out of scope --- zeronym/spec/quint/README.md | 148 +++++++++-------------------------- 1 file changed, 36 insertions(+), 112 deletions(-) diff --git a/zeronym/spec/quint/README.md b/zeronym/spec/quint/README.md index 20f1012b..45187174 100644 --- a/zeronym/spec/quint/README.md +++ b/zeronym/spec/quint/README.md @@ -8,8 +8,8 @@ A [Quint](https://quint-lang.org) specification of zeronym: the protocol between - [Guarantees](#guarantees) - [Trust matrix](#trust-matrix) - [Known gaps](#known-gaps) +- [Out of scope](#out-of-scope) - [Findings](#findings) -- [Scope](#scope) - [How it is checked](#how-it-is-checked) - [Future work](#future-work) @@ -19,7 +19,7 @@ A [Quint](https://quint-lang.org) specification of zeronym: the protocol between The architecture diagram and the prose description of the deployment are in [`zeronym/README.md`](../../README.md). -Only the mixnet transport is modelled; the HTTP transport is out of scope (see [Out of the model](#out-of-the-model)). +Only the mixnet transport is modelled; the HTTP transport is out of scope (see [Out of scope](#out-of-scope)). There are two specifications, sharing one hub function: @@ -208,7 +208,7 @@ The deployment targets the server-side and network-metadata adversaries of Taylo | G1 | `operatorBlind` | Everything the shim hands the operator is a pass-through transaction | T1 | Simulation | | G2 | `queuedBytesConfidential` | Everything the third party has learned is on the chain, or was a pass-through transaction given to the operator. Its knowledge is derived from the replies sent to it and the operator's view | T2 | Simulation | | G3 | `txidAuthenticity` | A transaction served to the wallet has the txid asked for. It need not be the bytes the wallet sent, and its height is whatever the hub said | T3 | Simulation; `servedOnlyOnMatchingTxidTest` exhaustively | -| G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true. It does not say that successive answers agree | T4 | Simulation | +| G4 | `lookupValidityPerHub` | Every lookup answer other than "unavailable" was true at the hub that gave it at some point between request and answer. Not-found during the flush window counts as true, as the implementation intends (`Hub::lookup`, `zeronym/hub/src/server.rs`). It does not say that successive answers agree | T4 | Simulation | | G6b | `conformingFirstOfferBeforeExpiry` | A supported wallet's transaction is offered with the mining margin to spare, the first time a hub offers it. About the margin left when the flush begins, not about acceptance; nothing about a later offer of a requeued entry | T5 | TLC, exhaustive | | G6c | `conformingFirstOfferJudgedBeforeExpiry` | End to end: when a node judges the first offer of a supported wallet's transaction, it has not expired. Needs G6b and the flight-time assumption | T5 | TLC, exhaustive | | G7 | `wellFormedTest` | Structural sanity of the hub: a queued entry is within its attempts and a down hub holds nothing | T6 | Exhaustive test over every reachable hub state | @@ -268,44 +268,9 @@ Behaviours that are accepted or only recorded: | **Early flush by the free-running clock.** A stale hub's clock is ahead of the chain and it flushes before the true boundary, with every component honest | T11, with no liar | `freeRunningClockFlushesEarlyTest` | | **Unparseable and queued.** A payload the hub cannot parse has no txid, so a lookup misses it while it is queued | none | `unparseableIsQueuedAndMissedTest` | -## Findings - -> Where the model disagrees with what the code or its comments assume. - -Nothing here has been fixed. "Code read" means the cited lines were read and match the model; nothing was run against the Rust. - -| # | Finding | Shown by | Against the Rust | -|---|---|---|---| -| 1 | **A short tip silence costs a supported wallet its mining margin.** The cadence follows the last tip seen, so a silence across a flush boundary delays the flush until the hub goes stale. On the shipped constants the first offer is at `created + 37` against an expiry of `created + 40`: three blocks of margin where four are reserved | K4, `silenceAcrossBoundaryMissesMarginTest`; TLC on `staleLag` | Code read: `batcher.rs:40-71`, `:227-247`. The one-block shortfall reads 15 minutes as exactly 12 blocks | -| 2 | **An early free-running flush spends the next epoch.** A stale hub's clock runs ahead, flushes an empty queue and records that epoch. When the tip returns, admission counts on a flush that has already happened, and the transaction waits a full interval. A wider expiry floor does not fix it | `earlyFlushSpendsTheNextEpochTest`; TLC on `staleLagWithSlack` | Code read: `batcher.rs:316-326`. The comment there calls a clock that runs ahead "the safe direction" | -| 3 | **The reorg slack holds by coincidence of constants.** The expiry floor minus the three-term budget is 10 blocks, exactly the reorg allowance, and startup validation checks only the three-term sum. Without the slack a supported wallet's transaction misses its margin | K3', `conformingMissesMarginWithoutSlackTest`; TLC on `flakyTipNoSlack` | Code read: `batcher.rs:40-59`, `:101-113` | -| 4 | **Nothing bounds a flush's flight in blocks.** The budget leaves exactly the mining margin at the offer, so blocks that arrive while the batch is in flight come out of it | K7, `slowFlightSpendsTheMarginTest`; TLC on `flakyTipSlowFlight` | Code read: `chain.rs` bounds each call (`RPC_TIMEOUT`), not the batch | -| 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired after two unjudged flushes | K5 and its three runs; TLC on `timely` | Code read: the queue is in memory only (`queue.rs:226-243`, `batcher.rs:337-347`) | -| 6 | **A crash plus a late duplicate is offered past the margin.** A restarted hub adopts the current epoch without flushing; told a tip one block back, it admits the resend counting on a flush that will not happen | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-519` | -| 7 | **An entry with an expiry can be dropped as exhausted.** On a hub that sees no tip, each requeue judges the entry against the same stale tip, so the expiry rule never gives it up and the attempt bound does | `expiringEntryDroppedAsExhaustedTest` | Code read, and it contradicts a comment: `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | -| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | -| 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:630-633`. Unexamined; not claimed as a bug | - -On finding 2, the model lets the free-running clock be at most one flush interval ahead, so it can spend one epoch. `cadence_height` has no such cap. Reading that code, a clock further ahead would skip more than one boundary; the model does not exhibit that. - -## Scope - -> What is in the model, what is out and why, and the facts read from the Rust that it rests on. - -### In the model +## Out of scope -| Area | What is modelled | Why | -|---|---|---| -| Wallet / shim front door | `SendTransaction` input as `Clean(payload) \| Unreadable \| EmptyBody`; routing to divert / forward / fail-closed; `GetTransaction` always to the hub | S1, S3, S4 | -| Shim / hub exchange | `Submit`, `Ack`, `Lookup`, `LookupReply` over a grow-only soup; nonce correlation; one hub: a submission is one frame, handed over or not, and a lookup goes to the hub and fails closed on a timeout | S6-S9 | -| Hub | In the hub specification: lifecycle; admission with its three refusals (tip stale, draining, expiry too tight); queue keyed by payload; flush cadence on tip epochs; flush window; per-entry verdicts; requeue; crash. In the protocol specification: the abstract hub, a queue and the entries out with a flush, which accepts, refuses, takes, settles, gives back and loses (see [The abstraction lemma](#the-abstraction-lemma)) | S10-S19 | -| Chain / indexer | per-txid status (absent, mempool, mined); what the indexer has been offered; verdict and lookup-answer relations. A lookup answer's height is 0, the height the transaction was mined at, or another (`WireHeight`). The protocol specification has no chain height and no block clock, and its verdict relation has no expiry clause (`heightlessIndexerResults`); the hub specification keeps the chain height, which its tip and expiry rules read | S15, S22 | -| Wire encoding | pure `render` / `interpretReply` between hub outcome and wallet observation | S20, S22 | -| Trust | role `Honest \| Byzantine` for the hub and its indexer; the shim is honest | S23 | -| Third party | a client of the hub's public, unauthenticated address: looks up txids it knows; submits payloads it has learned or the chain has published; its payload knowledge is derived from what it can observe | S13, S25 | -| Network | drop, duplicate, delay, reorder; cannot forge | | -| Hubs | one; see [One hub](#one-hub) | S24 | -| Tip | Hub specification only: `TipTimely \| TipMayRegress \| TipMayLag`, the observed tip and the cadence height as two hub clocks, the reorg allowance, the staleness window and the wallet expiry floor as parameters | S17, S26, S32 | +> What the specification deliberately does not cover, and why. ### One hub @@ -319,88 +284,47 @@ Argued, not checked, on the assumption that hubs share nothing but the chain and - **Compose only if every hub is honest:** G2. One Byzantine replica holds the same bytes and can give them away. - **Do not compose:** K1 and K2 each gain a cause with a second hub. -### Out of the model - -Never modelled: +### Not modelled | Item | Reason | |---|---| -| Attestation, PCRs, TLS, STEVE, keymaker quorum | No in-protocol messages exist (S23). Represented by the roles | -| Mixnet internals: SURBs, Sphinx, cover traffic, gateways, throttling; shim client rotation supervisor (`zeronym/shim/src/nym.rs:942-1024`); both `nym_driver.rs` | Protocol-visible effect is loss and delay | -| Hub lookup concurrency bound, reply deadline, dropped acks (S21) | Refinements of "the network lost the message" | -| Wall-clock time | The staleness window is counted in blocks (`STALE_WINDOW`), and a free-running cadence height is chosen by the environment, never behind the chain (see the tip assumption); there is no clock | -| Multiple indexer endpoints and their folds | One abstract indexer per model stands for all of a hub's endpoints. Because the folds are asymmetric (S28), this document states for each Byzantine-indexer behaviour whether one lying endpoint suffices or all must lie | -| Wire codecs `ZNS1` / `ZNA1` / `ZNL1` / `ZNR1` and the golden vectors (`zeronym/hub/src/wire.rs:576-579`) | Byte layouts are scoped out and are pinned by the Rust tests in both crates; the abstract `render` / `interpretReply` layer is the level this spec works at. The spec does not claim to bind the codec | -| Reorgs of included transactions, mempool eviction | Environment assumption: per-txid chain status is monotone | -| Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not trace properties | -| Byte layout, malformed frames, `bad_frame` | Sum types make them unrepresentable; pinned by the Rust golden vectors | -| Forward-only shim, transparent-pool RPCs, health / address / attestation endpoints, DoS bounds, logging | Not divert-protocol state | +| Attestation, PCRs, TLS, STEVE, keymaker quorum | No in-protocol messages exist. Represented by the roles | +| Mixnet internals: SURBs, Sphinx, cover traffic, gateways, throttling; the shim's client rotation; both `nym_driver.rs` | Their protocol-visible effect is loss and delay | +| The hub's lookup concurrency bound, reply deadline and dropped acks | Refinements of "the network lost the message" | +| Wall-clock time | There is no clock: the staleness window is counted in blocks, and a timeout may happen at any moment | +| Multiple indexer endpoints | One abstract indexer stands for all of a hub's endpoints. The trust matrix says, for each Byzantine-indexer cell, whether one lying endpoint suffices | +| Wire codecs, byte layout, malformed frames | Pinned by the Rust tests and golden vectors in both crates. The specification works at the level of what a reply means, and does not bind the codec | +| Reorgs of included transactions, mempool eviction | Assumed away: a transaction's chain status only moves forward | +| Anonymity-set size, shuffle, simultaneity, timing and length side channels | Not properties of a single run | +| The forward-only shim, transparent-pool RPCs, health, address and attestation endpoints, logging | Not part of the divert protocol | +| The HTTP transport, where the shim waits for the hub's verdict before answering the wallet (`HubTransport::Http`, `--hub`) | The production deployment is the mixnet (`HTTP_SUBMIT=0`). So there is no configuration here in which the shim's ok means the hub has the transaction | +| A Byzantine shim | The shim runs attested, sees every migration in plaintext and controls what the wallet observes. Every wallet-facing guarantee assumes it honest | +| What the hub's ack says | Nothing reads an ack: the shim tells the wallet ok without waiting for it. That an accepted ack is only for a queued payload is not a checked property, and a Byzantine hub's ack is modelled as truthful | +| The hub's capacity and size refusals, and denial of service generally | Not claimed properties. The shim's own too-large refusal is modelled | +| A third party submitting payloads of its own making | Possible, since the hub's address is public and unauthenticated. The model's third party submits only what it has learned or the chain has published | +| Wallets whose expiry is below the supported floor | No schedule guarantee is made for them. Admission's own claim that every admitted entry "provably survives" its scheduled flush (`zeronym/hub/src/queue.rs:497-519`) is not checked, and is known not to hold under a tip reported behind the chain | | More than one Byzantine component at once | The trust matrix is single-fault | | Liveness: that anything eventually happens, such as a submitted migration being published | The network may lose everything, and nobody waits for an ack | -Removed, each because it produced no finding and removing it made the specification smaller: +## Findings -| Item | Reason | -|---|---| -| More than one hub: replication (S24), the lookup cursor and its failover on a timeout (S8, S27), the prefix send (S29) | A scope choice; see [One hub](#one-hub) for what it costs and what composes | -| The HTTP transport, where the shim waits for the hub's verdict before answering the wallet. In code (`HubTransport::Http`, `--hub`); `deploy.env.example` sets `HTTP_SUBMIT=0` | Removed: it increases complexity without much gain, and the production deployment is the mixnet. With it went the only configuration in which the shim's ok meant the hub had the transaction. Its two HTTP-only details were read in the code and are not distinct wallet observations: the client's `"already_known"` arm has no hub source, and a malformed 200 on a lookup becomes the same `Unavailable` as an error | -| A Byzantine shim. Not a code path: the production shim runs attested (`DEBUG=0`) | Removed. Its column said only that every wallet-facing guarantee needs it honest. | -| Disclosure by a Byzantine hub or indexer outside the protocol (`byzDisclose`) | Removed: a Byzantine hub or indexer already leaks through a lookup reply; for each, a scripted run violates G2 with the third party's knowledge coming from the body of a reply addressed to it | -| Payloads of the third party's own making | Removed: the hub's address is public and unauthenticated, so this is possible, but nothing read them. The third party still submits what it has learned or the chain has published (a cause of K2) | -| The frame-size lemma and `sizeOf` | Removed: true by construction; the code pads four fixed-size frames (`zeronym/hub/src/wire.rs:29-59`), and length side channels were already out of the model | -| The hub's capacity and size refusals (`Full`, `TooLarge`) and the queue's entry budget (`queueCap`). In code: S10's byte and entry budget and its too-large check | Removed: no finding came from them. The shim's own too-large arm (S3) stays | -| A free-running clock slower than the chain (`MayBeSlower`) | Removed: no configuration used it, and nothing else told the two variants apart. The assumption that the clock is not slower is prose under [Assumptions](#assumptions) | -| The shim's ack waiter | In code a waiter is registered and its receiver dropped at once (`zeronym/shim/src/nym.rs:578-591`, `:665`). Nothing reads it once nobody awaits an ack, so the model's shim keeps no state for a submission and drops every ack | -| G8 `ackImpliesQueued` and its function-level test: an accepted ack is only for a payload the hub queued. In code: `queue.rs` admits before it acks | Removed: nothing reads an ack since the HTTP transport went. The abstraction lemma still fails if `hub` acks without queueing | -| A Byzantine hub's false ack (accepted but not queued, or queued but refused) | Removed with G8: no remaining guarantee reads it. A Byzantine hub still admits or refuses against the rules, and lies in lookup replies | -| G6a `offeredBeforeExpiry` and K3: the margin at the offer for every admitted transaction, including one whose wallet set an expiry below the supported floor. In code: admission's "provably survives its scheduled flush" (`zeronym/hub/src/queue.rs:497-519`) | Removed: it adds only unsupported wallets to G6b. Known not to hold under a tip reported behind the chain (K3); no longer checked | -| K1 as reachable-state rows, and the `everQueued` history they read | K1 is pinned by its two scripted runs. The simulation rows were the last readers of that history | -| Replaying each pinned protocol run through the real hub (`realisations`, `realisedRunsTest`) | Removed: it produced no finding. Violations and reached states of the protocol specification are shown over the abstract hub; `realisesTest` shows each abstract move has a real step | +> Where the model disagrees with what the code or its comments assume. -
-Protocol facts read from the Rust +Nothing here has been fixed. "Code read" means the cited lines were read and match the model; nothing was run against the Rust. -| # | Fact | Source | -|---|---|---| -| S1 | Shim classifies `SendTransaction` by presence of Orchard actions; unparseable folds into "treat as migration" | `zeronym/shim/src/classify.rs:70-101`, `:246-248` | -| S2 | Shim-unparseable includes trailing bytes, which the hub's parser accepts, so "shim cannot parse" does not imply "hub computes no txid" | `zeronym/shim/src/classify.rs:269-283`, `zeronym/hub/src/queue.rs:281-289` | -| S3 | Divert arms: unreadable body fails closed; empty body INVALID_ARGUMENT; too large RESOURCE_EXHAUSTED; hub unreachable UNAVAILABLE; never the operator | `zeronym/shim/src/intercept.rs:180-283` | -| S4 | With a hub configured every `GetTransaction` goes to the hub; shim keeps no per-migration state | `zeronym/shim/src/intercept.rs:305-314`, `:58-64` | -| S5 | Lookup reply arms, in order: found/height 0/empty relayed as pending; found served only if the bytes' txid equals the query (L4), else NOT_FOUND; not-found; error fails closed | `zeronym/shim/src/intercept.rs:370-422`, `:453-466` | -| S6 | Two transports behind one enum: HTTP (verdict returned synchronously) and Nym | `zeronym/shim/src/hub.rs:277-328` | -| S7 | Nym submit is dispatch-only: success once one frame is handed over, fresh nonce per hub address, sent to every address; the ack is never awaited | `zeronym/shim/src/nym.rs:595-703` | -| S8 | Nym lookup tries addresses in turn; only a timeout moves on; fresh nonce per attempt | `zeronym/shim/src/nym.rs:708-797` | -| S9 | Correlation by nonce only; unknown nonce dropped; wrong reply kind for a known nonce ignored, waiter stays | `zeronym/shim/src/nym.rs:1040-1077`, `zeronym/hub/src/wire.rs:22-27` | -| S10 | Hub admit: tip-stale gate, then draining, too large, expiry survives next scheduled flush, payload-hash dedup, byte and entry budget. Admission never asks a node | `zeronym/hub/src/server.rs:343-392`, `:299-303`, `zeronym/hub/src/queue.rs:256-340` | -| S11 | Queue identity is `sha256(bytes)`; dedup is against resident entries only (`inner.entries.contains_key`), and a flush removes every entry (`inner.entries.drain()`); accepted entries are not put back. So bytes that were published are admitted again if resubmitted | `zeronym/hub/src/queue.rs:17-22`, `:308-310`, `:358-359`, `zeronym/hub/src/batcher.rs:389` | -| S12 | Hub lookup: queue first (found, height 0, no bytes), then indexer; unparseable entries never hit; flush window answers not-found, deliberately | `zeronym/hub/src/server.rs:403-462`, `zeronym/hub/src/queue.rs:455-475` | -| S13 | Lookup and submit to the hub are unauthenticated; the hub's Nym address is public with no ACL; the queue-hit reply discloses that a txid is queued | `zeronym/hub/src/server.rs:413-437`, `zeronym/hub/src/nym.rs:220-227` | -| S14 | Flush fires only when `cadence_height / flush_interval` exceeds the last flushed epoch; first observation adopts the epoch without flushing; shutdown flushes once more | `zeronym/hub/src/batcher.rs:316-335` | -| S15 | Flush drains everything, broadcasts, then: accepted / already-known leave; rejected dropped; retryable requeued | `zeronym/hub/src/batcher.rs:358-422`, `zeronym/hub/src/chain.rs:129-134` | -| S16 | Requeue: resident copy wins; attempts + 1; dropped if it no longer survives the next flush or attempts exceed 8; may overrun the byte budget; reports `held` / `dropped_expired` / `dropped_exhausted` | `zeronym/hub/src/queue.rs:186-199`, `:366-422`, `:96` | -| S17 | Tip is the max over answering endpoints; a regression within 10 blocks is followed; staleness stops admission only | `zeronym/hub/src/chain.rs:183-201`, `zeronym/hub/src/batcher.rs:161-205`, `:222-225` | -| S18 | Budget inequality `flush_interval + mining_margin + delivery_lag <= min_wallet_expiry` asserted at startup | `zeronym/hub/src/batcher.rs:93-118` | -| S19 | Drain closes admission before the final flush; the queue is RAM-only | `zeronym/hub/src/main.rs:142-177`, `zeronym/hub/src/queue.rs:226-243`, `zeronym/hub/src/batcher.rs:337-347` | -| S20 | Wire: four fixed-size frames; reply dispositions found / not_found / error; not_found or error with a payload is a decode error; `Draining` shares `QueueFull`'s code | `zeronym/hub/src/wire.rs:29-59`, `:278-290`, `:531-569` | -| S21 | Hub drops lookups past 64 in flight, replies older than 60 s, acks when the driver queue is full | `zeronym/hub/src/nym.rs:54`, `:75`, `:171-213`, `:285-292` | -| S22 | Indexer lookup answer is forwarded verbatim, so a zero `RawTransaction` is byte-identical to the queue-hit sentinel | `zeronym/hub/src/server.rs:445-451`, `zeronym/hub/src/chain.rs:284-290` | -| S23 | No attestation or STEVE handshake exists in code | `zeronym/README.md:88` | -| S24 | Replicate, never fail over: every hub that receives a migration queues and broadcasts it | `zeronym/README.md:86`, `zeronym/shim/src/nym.rs:602-647` | -| S26 | Shipped constants: `FLUSH_INTERVAL_BLOCKS = 20`, `MINING_MARGIN = 4`, `MAX_DELIVERY_LAG = 6`, `MIN_WALLET_EXPIRY = 40`, `REORG_ALLOWANCE = 10`. The slack `40 - (20 + 4 + 6) = 10` equals the reorg allowance exactly. `BatchParams::validate` asserts only the three-term sum; nothing asserts the four-term one | `zeronym/hub/src/batcher.rs:40-59`, `:101-113` | -| S27 | Lookup starts at a rotating cursor, so consecutive polls start at different hubs; a `NotFound` from the first hub asked is final | `zeronym/shim/src/nym.rs:756-793` | -| S28 | Indexer folds are asymmetric: tip is the max over answering endpoints (one endpoint can only win high; a low tip needs every endpoint); lookup returns the first `Found` in endpoint order (one endpoint suffices to inject an answer); publish takes the best verdict | `zeronym/hub/src/chain.rs:183-201`, `:305-319`, `:517-532` | -| S29 | Submit sweep tells the wallet ok when at least one frame was handed over, even if the loop broke before later addresses | `zeronym/shim/src/nym.rs:673-702` | -| S30 | L4 deserialises the returned bytes, computes their txid and compares it with the queried hash in both byte orders. It compares nothing else: not the bytes, not the height | `zeronym/shim/src/intercept.rs:453-466` | -| S31 | HTTP transport: one `SocketAddr`; hub answers `"accepted"` for both a fresh admission and a duplicate, so the client's `"already_known"` arm has no source; a 200 lookup without the octet-stream content type and `x-tx-height` is an error | `zeronym/shim/src/hub.rs:69-72`, `:201-208`, `:259-263`, `zeronym/hub/src/server.rs:741-747` | -| S32 | Two hub clocks. Admission and requeue use the observed height. The flush epoch uses the cadence height, which equals the observed height until no forward move has been seen for `TIP_STALE_AFTER` (15 min, 12 blocks at the nominal 75 s) and then free-runs at the nominal rate. The code comment claims the free-running clock runs ahead of the true height, "the safe direction"; nothing enforces it. Only the cadence loop (and startup) calls `observe`, and it does so before, never during, a flush | `zeronym/hub/src/batcher.rs:59-71`, `:227-247`, `:307-325`, `:414-422`, `zeronym/hub/src/main.rs:62` | -| S25 | The operator can recover a diverted transaction's txid from transparent-pool queries, so a txid can be known to an outsider before publication | `zeronym/README.md:34` | - -Two comments in the implementation that the model follows: - -- The accepted disclosure, `zeronym/hub/src/server.rs`, in `Hub::lookup`: "What this does NOT close: the 200-versus-NotFound distinction still discloses that a given txid is queued here. Closing that too means answering NotFound, which costs a wallet the ability to tell "pending" from "never seen". That is a product decision, not a code one, and it is left open deliberately." -- The flush window (`truth`, used by G4), same file, on `Hub::lookup`: "Note the flush-in-flight gap: `flush()` drains the queue before `broadcast_batch` has reached the indexer, so a lookup in that window gets a queue miss then an indexer NOT_FOUND for a transaction it was told height-0 about seconds earlier. Wallets poll on multi-second intervals and tolerate a transient NOT_FOUND; a resubmit is harmless (deduped pre-flush, already-known post-flush). Holding entries until broadcast returns would extend how long the hub remembers a txid, which is the wrong trade." +| # | Finding | Shown by | Against the Rust | +|---|---|---|---| +| 1 | **A short tip silence costs a supported wallet its mining margin.** The cadence follows the last tip seen, so a silence across a flush boundary delays the flush until the hub goes stale. On the shipped constants the first offer is at `created + 37` against an expiry of `created + 40`: three blocks of margin where four are reserved | K4, `silenceAcrossBoundaryMissesMarginTest`; TLC on `staleLag` | Code read: `batcher.rs:40-71`, `:227-247`. The one-block shortfall reads 15 minutes as exactly 12 blocks | +| 2 | **An early free-running flush spends the next epoch.** A stale hub's clock runs ahead, flushes an empty queue and records that epoch. When the tip returns, admission counts on a flush that has already happened, and the transaction waits a full interval. A wider expiry floor does not fix it | `earlyFlushSpendsTheNextEpochTest`; TLC on `staleLagWithSlack` | Code read: `batcher.rs:316-326`. The comment there calls a clock that runs ahead "the safe direction" | +| 3 | **The reorg slack holds by coincidence of constants.** The expiry floor minus the three-term budget is 10 blocks, exactly the reorg allowance, and startup validation checks only the three-term sum. Without the slack a supported wallet's transaction misses its margin | K3', `conformingMissesMarginWithoutSlackTest`; TLC on `flakyTipNoSlack` | Code read: `batcher.rs:40-59`, `:101-113` | +| 4 | **Nothing bounds a flush's flight in blocks.** The budget leaves exactly the mining margin at the offer, so blocks that arrive while the batch is in flight come out of it | K7, `slowFlightSpendsTheMarginTest`; TLC on `flakyTipSlowFlight` | Code read: `chain.rs` bounds each call (`RPC_TIMEOUT`), not the batch | +| 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired after two unjudged flushes | K5 and its three runs; TLC on `timely` | Code read: the queue is in memory only (`queue.rs:226-243`, `batcher.rs:337-347`) | +| 6 | **A crash plus a late duplicate is offered past the margin.** A restarted hub adopts the current epoch without flushing; told a tip one block back, it admits the resend counting on a flush that will not happen | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-519` | +| 7 | **An entry with an expiry can be dropped as exhausted.** On a hub that sees no tip, each requeue judges the entry against the same stale tip, so the expiry rule never gives it up and the attempt bound does | `expiringEntryDroppedAsExhaustedTest` | Code read, and it contradicts a comment: `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | +| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | +| 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:630-633`. Unexamined; not claimed as a bug | -
+On finding 2, the model lets the free-running clock be at most one flush interval ahead, so it can spend one epoch. `cadence_height` has no such cap. Reading that code, a clock further ahead would skip more than one boundary; the model does not exhibit that. ## How it is checked From edd181cd80d27e11803f2142ad9fc0049208ccfe Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 19:53:01 +0400 Subject: [PATCH 74/80] test(zeronym): give the README's configurations headings and fold the abstraction lemma away --- zeronym/spec/quint/README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/zeronym/spec/quint/README.md b/zeronym/spec/quint/README.md index 45187174..fea1dc34 100644 --- a/zeronym/spec/quint/README.md +++ b/zeronym/spec/quint/README.md @@ -356,7 +356,9 @@ Quint 0.33.0 is pinned (`npx --yes @informalsystems/quint@0.33.0` by default; se A configuration is a value held in the state and selected by a named init. -Protocol specification (at most 3 sends and 3 lookups by the wallet, 3 requests by the third party): +#### Protocol specification + +At most 3 sends and 3 lookups by the wallet, 3 requests by the third party. | Configuration | Hub | Indexer | |---|---|---| @@ -364,7 +366,9 @@ Protocol specification (at most 3 sends and 3 lookups by the wallet, 3 requests | `byzHub` | **Byzantine** | honest | | `byzIndexer` | honest | **Byzantine** | -Hub specification. The schedule flushes every 3 blocks with a mining margin of 2, a delivery lag of 1, a reorg allowance of 1, a staleness window of 3 and an expiry floor of 7. It is the shipped schedule scaled down (interval 20, margin 4, lag 6, reorg allowance 10, staleness window 12 blocks, expiry floor 40), keeping the relations between the constants that the findings turn on. +#### Hub specification + +The schedule flushes every 3 blocks with a mining margin of 2, a delivery lag of 1, a reorg allowance of 1, a staleness window of 3 and an expiry floor of 7. It is the shipped schedule scaled down (interval 20, margin 4, lag 6, reorg allowance 10, staleness window 12 blocks, expiry floor 40), keeping the relations between the constants that the findings turn on. | Configuration | Differs from `timely` by | For | |---|---|---| @@ -402,10 +406,13 @@ TLC runs with deadlock checking off, so a machine whose steps had died would hol -### The abstraction lemma +
+The abstraction lemma The protocol specification's hub is the abstract one in `abstractHub.qnt`. `hubTest` checks, over every reachable state of the real hub function and every input, that each real step is a step of the abstract hub (`abstractionTest`, `byzantineAbstractionTest`), and that each abstract move has a real step behind it (`realisesTest`). So an invariant that holds over the abstract hub, and reads only queue membership and wire replies, holds over the real one: that covers G2, G3 and G4. It does not transfer reachability: the abstract hub answers where the real one is down or stale, so a violation shown over it is a state of the abstract hub. The reachable set is computed at a smaller schedule than the hub specification's and carried over by argument. +
+ ### Layout Only `protocol.qnt` and `hubMachine.qnt` declare variables, and no module declares a constant. Every other module is pure. From 08f89427a1c9f6b2ee4866952a089ef6b7b7df9b Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 19:56:59 +0400 Subject: [PATCH 75/80] test(zeronym): drop ids the README no longer defines from the spec's comments --- zeronym/spec/quint/check.sh | 7 ++++--- zeronym/spec/quint/hubMachine.qnt | 10 +++++----- zeronym/spec/quint/protocol.qnt | 12 ++++++------ zeronym/spec/quint/tests/hubTest.qnt | 12 ++++++------ zeronym/spec/quint/tests/indexerTest.qnt | 2 +- zeronym/spec/quint/tests/scenariosTest.qnt | 10 +++++----- zeronym/spec/quint/tests/shimTest.qnt | 4 ++-- zeronym/spec/quint/tests/wireTest.qnt | 10 +++++----- 8 files changed, 34 insertions(+), 33 deletions(-) diff --git a/zeronym/spec/quint/check.sh b/zeronym/spec/quint/check.sh index befb6609..35a7e3ee 100755 --- a/zeronym/spec/quint/check.sh +++ b/zeronym/spec/quint/check.sh @@ -294,8 +294,9 @@ echo "---- 3b witnesses ($SAMPLES traces, seed $SEED)" BASELINE_HOLDS="operatorBlind queuedBytesConfidential txidAuthenticity lookupValidityPerHub" -# W8, W19, and the antecedents of G1 and G2. W19 is G2's reply-body branch, -# which `vQueuedBytesConfidential` does not reach on its own. +# The accepted disclosure, a third party served a published body, and the +# antecedents of G1 and G2. The served body is G2's reply-body branch, which +# `vQueuedBytesConfidential` does not reach on its own. job reaches baseline step 40 \ wQueuedDisclosed wThirdPartyServedBody \ vOperatorBlind vQueuedBytesConfidential \ @@ -308,7 +309,7 @@ job reaches baseline earlyLookupStep 40 \ vLookupValidityPerHub \ -- $BASELINE_HOLDS -# W16, both halves. +# A twin served, and a transaction served at a false height. job reaches byzHub quietStep 40 \ vOperatorBlind vTxidAuthenticity wTwinServed wFalseHeightServed \ -- operatorBlind txidAuthenticity diff --git a/zeronym/spec/quint/hubMachine.qnt b/zeronym/spec/quint/hubMachine.qnt index c3ecd5ee..7725eb56 100644 --- a/zeronym/spec/quint/hubMachine.qnt +++ b/zeronym/spec/quint/hubMachine.qnt @@ -520,10 +520,10 @@ module hubMachine { // Guarantees // ------------------------------------------------------------------------ // - // G6 comes in three parts. G6a and G6b are about the moment a flush begins: - // how much of the mining margin is left when the hub hands the batch over. - // They do not say a node accepts the transaction, because the chain may move - // while the batch is in flight. G6c is about the moment a node judges it. + // G6b is about the moment a flush begins: how much of the mining margin is + // left when the hub hands the batch over. It does not say a node accepts the + // transaction, because the chain may move while the batch is in flight. G6c + // is about the moment a node judges it. // // Each is a predicate on the current state. That is enough: the whole batch // is in flight in the state `flushBegin` produces, with `flightStart` the @@ -601,7 +601,7 @@ module hubMachine { // ------------------------------------------------------------------------ // // States that must be reachable, each checked as `not(..)` expected to be - // violated. The first three are the antecedents of G6a, G6b and G6c: without + // violated. The first two are the antecedents of G6b and G6c: without // them a "holds" could be vacuous. The rest are one per family of steps: TLC // is run with deadlock checking off, so a configuration whose steps died // after `init` would otherwise hold everything on a handful of states. diff --git a/zeronym/spec/quint/protocol.qnt b/zeronym/spec/quint/protocol.qnt index 562f2a93..398fd22d 100644 --- a/zeronym/spec/quint/protocol.qnt +++ b/zeronym/spec/quint/protocol.qnt @@ -463,11 +463,11 @@ module protocol { | _ => false }) - /// W1. The wallet is told its transaction is pending. + /// The wallet is told its transaction is pending. pure def wPendingIn(s: System): bool = s.wasGiven(obs => obs == Pending) - /// W2. The wallet is served its transaction from the mempool. + /// The wallet is served its transaction from the mempool. pure def wTxInMempoolIn(s: System): bool = s.wasGiven(obs => match obs { @@ -475,7 +475,7 @@ module protocol { | _ => false }) - /// W3. The wallet is served its transaction from a block. + /// The wallet is served its transaction from a block. pure def wTxMinedIn(s: System): bool = s.wasGiven(obs => match obs { @@ -483,7 +483,7 @@ module protocol { | _ => false }) - /// W8. The accepted disclosure: a third party that knows a txid learns that + /// The accepted disclosure: a third party that knows a txid learns that /// it is queued at the hub. The hub withholds the bytes; it does not withhold /// the fact. The implementation leaves this open on purpose /// (`zeronym/hub/src/server.rs`, in `Hub::lookup`): @@ -497,7 +497,7 @@ module protocol { tuples(s.lookups(ThirdPartyAddr), s.replies(ThirdPartyAddr)).exists(((lookup, reply)) => lookup.nonce == reply.nonce and reply.reply == WFound({ body: None, height: AtZero })) - /// W9. The hub holds a payload it cannot parse; the wallet that sent it asks + /// The hub holds a payload it cannot parse; the wallet that sent it asks /// for it and is told not found. An entry without a txid can never /// be hit. pure def wUnparseableMissedIn(s: System): bool = @@ -512,7 +512,7 @@ module protocol { | _ => false }) - /// W19. A third party is given a transaction's bytes in a lookup reply. + /// A third party is given a transaction's bytes in a lookup reply. /// With every component honest these are published bytes, served from the /// indexer: the branch of G2 that `vQueuedBytesConfidential` alone does not /// reach, since `plain` at the operator satisfies it. diff --git a/zeronym/spec/quint/tests/hubTest.qnt b/zeronym/spec/quint/tests/hubTest.qnt index 19b8d48d..cda38aa2 100644 --- a/zeronym/spec/quint/tests/hubTest.qnt +++ b/zeronym/spec/quint/tests/hubTest.qnt @@ -82,7 +82,7 @@ module hubTest { // Admission // ------------------------------------------------------------------------ - /// F7. Under the startup budget, a conforming payload that arrives within + /// Under the startup budget, a conforming payload that arrives within /// the delivery lag passes the expiry check. The lemma is about admission at /// one tip. It does not say at what height the flush later happens. run conformingTimelyPayloadIsAdmissibleTest = all { @@ -107,7 +107,7 @@ module hubTest { assert(not(survivesNextFlush(Some(6), 5, 3, 1))), } - /// F8. Each refusal, and the order the checks are made in. + /// Each refusal, and the order the checks are made in. run admissionDecisionTableTest = all { // No tip yet, or a stale one: nothing else is looked at. assert(PAYLOADS.forall(payload => admission(starting, payload) == Refused(TipStale))), @@ -162,7 +162,7 @@ module hubTest { // Tip // ------------------------------------------------------------------------ - /// F14. The tip rule. + /// The tip rule. run tipRuleTest = all { // The first observation is adopted with its epoch, and nothing is due. assert(running(5).tip == Some(5) and running(5).phase == Running), @@ -236,7 +236,7 @@ module hubTest { }), } - /// F9. Requeue, entry by entry, and the counts it reports. + /// Requeue, entry by entry, and the counts it reports. run requeueTest = all { assert(outputOf(returning, FlushDoneHInput) == RequeuedOutput({ held: 2, droppedExpired: 2, droppedExhausted: 1 })), @@ -288,7 +288,7 @@ module hubTest { // Totality and the Byzantine relation // ------------------------------------------------------------------------ - /// F11. The function answers every input in every state, and an input that + /// The function answers every input in every state, and an input that /// is invalid in a state leaves that state unchanged. run totalityTest = assert(tuples(STATES, INPUTS).forall(((state, input)) => @@ -298,7 +298,7 @@ module hubTest { isError(result.out) implies result.state == state, })) - /// F12. The Byzantine relation contains the honest transition. + /// The Byzantine relation contains the honest transition. run byzantineContainsHonestTest = assert(tuples(STATES, INPUTS).forall(((state, input)) => byzHubResults(state, input, PAYLOADS).contains(hub(state, input)))) diff --git a/zeronym/spec/quint/tests/indexerTest.qnt b/zeronym/spec/quint/tests/indexerTest.qnt index 8caf2807..292061a0 100644 --- a/zeronym/spec/quint/tests/indexerTest.qnt +++ b/zeronym/spec/quint/tests/indexerTest.qnt @@ -102,7 +102,7 @@ module indexerTest { assert(honestTips(initialIndexer(1), 3, 0.to(7)) == Set(0, 1)), } - /// F12. The Byzantine relation contains every honest transition, and every + /// The Byzantine relation contains every honest transition, and every /// honest tip report is one a Byzantine indexer could make. run byzantineContainsHonestTest = all { assert(tuples(STATES, INPUTS).forall(((state, input)) => diff --git a/zeronym/spec/quint/tests/scenariosTest.qnt b/zeronym/spec/quint/tests/scenariosTest.qnt index 5896614a..c59a4c5a 100644 --- a/zeronym/spec/quint/tests/scenariosTest.qnt +++ b/zeronym/spec/quint/tests/scenariosTest.qnt @@ -33,7 +33,7 @@ module scenariosTest { // Witnesses // ------------------------------------------------------------------------ - /// W1, W2, W3. A migration from send to mined, with the wallet polling. + /// A migration from send to mined, with the wallet polling. run pendingThenMempoolThenMinedTest = initBaseline // The shim diverts and answers at once; the operator sees nothing. @@ -61,7 +61,7 @@ module scenariosTest { .expect(s.operator == Set(plain) and s.net == Set()) .expect(vOperatorBlind and operatorBlind and vQueuedBytesConfidential and queuedBytesConfidential) - /// W8. The accepted disclosure: a third party that knows a txid is told it + /// The accepted disclosure: a third party that knows a txid is told it /// is queued, and is not given the bytes. run thirdPartyLearnsItIsQueuedTest = initBaseline @@ -73,7 +73,7 @@ module scenariosTest { .expect(wQueuedDisclosed) .expect(s.tpLearned() == Set() and queuedBytesConfidential) - /// W19. Once `early` is published its txid is public, and a third party + /// Once `early` is published its txid is public, and a third party /// that asks is served its bytes from the indexer. G2 holds: they are /// public. run thirdPartyIsServedPublishedBodyTest = @@ -95,7 +95,7 @@ module scenariosTest { .expect(lastEvent == Got({ query: "early", obs: Unavailable, via: None })) .expect(s.shim.waiters == Map() and lookupValidityPerHub) - /// W9. A queued payload the hub cannot parse has no txid to be found by. + /// A queued payload the hub cannot parse has no txid to be found by. run unparseableIsQueuedAndMissedTest = initBaseline .then(submitTo(0, junk)) @@ -216,7 +216,7 @@ module scenariosTest { // A Byzantine hub (`byzHub`) // ------------------------------------------------------------------------ - /// W16. A Byzantine hub serves a twin of the wallet's transaction at a + /// A Byzantine hub serves a twin of the wallet's transaction at a /// height the chain has not reached. The shim checks the txid, which a twin /// shares, and nothing else: it serves both. run twinAtFalseHeightIsServedTest = diff --git a/zeronym/spec/quint/tests/shimTest.qnt b/zeronym/spec/quint/tests/shimTest.qnt index 4b6e9939..daee962f 100644 --- a/zeronym/spec/quint/tests/shimTest.qnt +++ b/zeronym/spec/quint/tests/shimTest.qnt @@ -59,7 +59,7 @@ module shimTest { // SendTransaction // ------------------------------------------------------------------------ - /// F5. Only a cleanly read pass-through transaction is forwarded. Every + /// Only a cleanly read pass-through transaction is forwarded. Every /// other input is diverted or fails closed, in every state. run onlyPassThroughIsForwardedTest = all { assert(tuples(STATES, SEND_INPUTS, Set(true, false)).forall(((state, input, handedOver)) => @@ -148,7 +148,7 @@ module shimTest { // Totality // ------------------------------------------------------------------------ - /// F11. The function answers every input in every state, and an invalid + /// The function answers every input in every state, and an invalid /// input leaves the state unchanged. run totalityTest = assert(tuples(STATES, INPUTS).forall(((state, input)) => diff --git a/zeronym/spec/quint/tests/wireTest.qnt b/zeronym/spec/quint/tests/wireTest.qnt index cbac6e9d..54792ca0 100644 --- a/zeronym/spec/quint/tests/wireTest.qnt +++ b/zeronym/spec/quint/tests/wireTest.qnt @@ -43,13 +43,13 @@ module wireTest { pure val REFUSALS = Set(TipStale, HubDraining, ExpiryTooTight) - /// F1. Rendering an honest outcome and reading it back gives what the + /// Rendering an honest outcome and reading it back gives what the /// outcome means. run renderThenInterpretIsMeaningTest = assert(tuples(HONEST_OUTCOMES, QUERIES).forall(((outcome, query)) => interpretReply(render(outcome), query) == meaning(outcome, query))) - /// F2. The documented collision: a queue hit and an indexer answer of + /// The documented collision: a queue hit and an indexer answer of /// "found, height 0, no body" are the same reply, though they mean different /// things. The wallet cannot tell them apart: it is told pending for both, /// so an indexer that answers that way for a transaction nobody holds makes @@ -69,7 +69,7 @@ module wireTest { left != right implies render(left) != render(right))), } - /// F3. The shim serves a transaction only when its txid is the one asked + /// The shim serves a transaction only when its txid is the one asked /// for, and compares nothing else. run servedOnlyOnMatchingTxidTest = all { assert(tuples(REPLIES, QUERIES).forall(((reply, query)) => @@ -92,11 +92,11 @@ module wireTest { interpretReply(WFound({ body: Some(pJunk), height: height }), query) == NotFound)), } - /// F4. A hub error fails closed. It is never reported as not found. + /// A hub error fails closed. It is never reported as not found. run errorIsNeverNotFoundTest = assert(QUERIES.forall(query => interpretReply(WError, query) == Unavailable)) - /// F10. A draining hub refuses under the queue-full code; a fresh admission + /// A draining hub refuses under the queue-full code; a fresh admission /// and a duplicate are one acceptance. Every refusal has its own code. run ackRenderingTest = all { assert(renderAck(Refused(HubDraining)) == WRefused(WQueueFull)), From 0cd9bdc493eb6ead72823f87a32c4f8db7eee203 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 20:08:07 +0400 Subject: [PATCH 76/80] test(zeronym): say in the README which known gaps are by design and which were found, and tie each to a finding --- zeronym/spec/quint/README.md | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/zeronym/spec/quint/README.md b/zeronym/spec/quint/README.md index fea1dc34..33476545 100644 --- a/zeronym/spec/quint/README.md +++ b/zeronym/spec/quint/README.md @@ -245,16 +245,19 @@ Single-fault. "holds" is a checked row on the named configuration. "required" is > Things you might expect to hold that don't, each with a concrete example run. Every component is honest in all of them. -| Id | Threat | What is lost | Where | Checked by | Scripted run | -|---|---|---|---|---|---| -| K1 | T7 | Told ok does not mean the hub ever admits it: it may refuse the frame, or never receive it | `baseline` | scripted runs | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | -| K2 | T8 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | `baseline` | simulation, violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | -| K3' | T5 | G6b and G6c when the expiry floor leaves no slack for the reorg allowance | `flakyTipNoSlack` | TLC, violated | `conformingMissesMarginWithoutSlackTest` | -| K4 | T5 | G6b and G6c on the shipped relation between the constants, across a tip silence shorter than the staleness window | `staleLag` | TLC, violated | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | -| K5 | T9 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it | `timely` | TLC, violated three ways | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | -| K6 | T5 | `conformingEveryOfferBeforeExpiry`: G6b for every offer, not only the first | `staleLag` | TLC, violated | `requeuedPastExpiryTest` | -| K7 | T5 | G6c when a flush may stay in flight for as many blocks as the mining margin | `flakyTipSlowFlight` | TLC, violated; G6b holds there | `slowFlightSpendsTheMarginTest` | -| K8 | T5 | A supported wallet's transaction, acknowledged on time, lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. To the restarted hub the resend is a late first arrival, so G6b and G6c do not cover it | `flakyTip` | scripted run | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | +"By design" means the implementation says so itself. "Found here" means this specification showed it and nobody has yet decided whether it is acceptable. + +| Id | Threat | What is lost | Status | Where | Checked by | Scripted run | +|---|---|---|---|---|---|---| +| K1 | T7 | Told ok does not mean the hub ever admits it: it may refuse the frame, or never receive it | By design: the hub's verdict "is deliberately not waited for" (`zeronym/shim/src/hub.rs:289-295`) | `baseline` | scripted runs | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | +| K2 | T8 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | By design for the flush window, which the code accepts in a comment on `Hub::lookup` (`zeronym/hub/src/server.rs`); the other causes follow from the same design and are not discussed there | `baseline` | simulation, violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | +| K3' | T5 | G6b and G6c when the expiry floor leaves no slack for the reorg allowance | Found here (finding 3); not triaged | `flakyTipNoSlack` | TLC, violated | `conformingMissesMarginWithoutSlackTest` | +| K4 | T5 | G6b and G6c on the shipped relation between the constants, across a tip silence shorter than the staleness window | Found here (finding 1); not triaged | `staleLag` | TLC, violated | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | +| K5 | T9 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it | By design for a crash and a failed final flush: the queue is in memory only, and the code logs what it loses (`zeronym/hub/src/batcher.rs:337-347`). The requeue drop was found here (finding 5); not triaged | `timely` | TLC, violated three ways | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | +| K6 | T5 | `conformingEveryOfferBeforeExpiry`: G6b for every offer, not only the first | Found here (finding 7); not triaged | `staleLag` | TLC, violated | `requeuedPastExpiryTest` | +| K7 | T5 | G6c when a flush may stay in flight for as many blocks as the mining margin | Found here (finding 4); not triaged | `flakyTipSlowFlight` | TLC, violated; G6b holds there | `slowFlightSpendsTheMarginTest` | +| K8 | T5 | A supported wallet's transaction, acknowledged on time, lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. To the restarted hub the resend is a late first arrival, so G6b and G6c do not cover it | Found here (finding 6); not triaged | `flakyTip` | scripted run | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | +| K9 | T5 | G6b and G6c even when the expiry floor is wide enough to cover a tip silence: a stale hub's free-running clock flushes an empty queue early and spends the next epoch, so a transaction admitted after the tip returns waits a full interval | Found here (finding 2); not triaged | `staleLagWithSlack` | TLC, violated | `earlyFlushSpendsTheNextEpochTest` | K1 is not an invariant because it would be false on the ordinary success path too: the wallet is told ok before the hub has the frame. @@ -320,7 +323,7 @@ Nothing here has been fixed. "Code read" means the cited lines were read and mat | 4 | **Nothing bounds a flush's flight in blocks.** The budget leaves exactly the mining margin at the offer, so blocks that arrive while the batch is in flight come out of it | K7, `slowFlightSpendsTheMarginTest`; TLC on `flakyTipSlowFlight` | Code read: `chain.rs` bounds each call (`RPC_TIMEOUT`), not the batch | | 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired after two unjudged flushes | K5 and its three runs; TLC on `timely` | Code read: the queue is in memory only (`queue.rs:226-243`, `batcher.rs:337-347`) | | 6 | **A crash plus a late duplicate is offered past the margin.** A restarted hub adopts the current epoch without flushing; told a tip one block back, it admits the resend counting on a flush that will not happen | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-519` | -| 7 | **An entry with an expiry can be dropped as exhausted.** On a hub that sees no tip, each requeue judges the entry against the same stale tip, so the expiry rule never gives it up and the attempt bound does | `expiringEntryDroppedAsExhaustedTest` | Code read, and it contradicts a comment: `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | +| 7 | **On a stale hub a requeued entry is offered again past its expiry, and only the attempt bound stops it.** Each requeue judges the entry against the same stale tip, as admission does, so the expiry rule never gives it up. A supported wallet's transaction is offered a third time after its expiry, and is finally dropped as exhausted | K6, `requeuedPastExpiryTest`; TLC on `staleLag`; `expiringEntryDroppedAsExhaustedTest` | Code read, and it contradicts a comment: `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | | 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | | 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:630-633`. Unexamined; not claimed as a bug | From e3c361bcd0157740999315b702b2a60db2138c63 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 21:04:45 +0400 Subject: [PATCH 77/80] test(zeronym): fail a row killed before it finishes, fetch Apalache once before TLC rows run in parallel, and test bytes only the hub parses Co-authored-by: Cursor --- zeronym/spec/.gitignore | 1 + zeronym/spec/quint/check.sh | 27 ++++++++++++----- zeronym/spec/quint/tests/hubTest.qnt | 14 +++++++++ zeronym/spec/quint/tests/shimTest.qnt | 6 ++++ zeronym/spec/quint/tlc.sh | 43 +++++++++++++++++++-------- 5 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 zeronym/spec/.gitignore diff --git a/zeronym/spec/.gitignore b/zeronym/spec/.gitignore new file mode 100644 index 00000000..a4074725 --- /dev/null +++ b/zeronym/spec/.gitignore @@ -0,0 +1 @@ +_apalache-out/ diff --git a/zeronym/spec/quint/check.sh b/zeronym/spec/quint/check.sh index 35a7e3ee..c153b8a1 100755 --- a/zeronym/spec/quint/check.sh +++ b/zeronym/spec/quint/check.sh @@ -48,9 +48,11 @@ esac SEED=7 failures=0 -# Rows run JOBS at a time, each writing its result lines to a file of its own; -# `finish` prints them in the order the rows were written and counts the -# failures. +# Rows run JOBS at a time, each writing its result lines to a file of its own +# and, once it has run to the end, its exit status to a second file; `finish` +# prints them in the order the rows were written and counts the failures. A +# row with no status was killed before it finished, and is a failure whatever +# it printed. results=$(mktemp -d) trap 'rm -rf "$results"' EXIT queued=0 @@ -58,7 +60,8 @@ running=0 job() { queued=$((queued + 1)) - ("$@") >"$results/$(printf '%04d' "$queued")" 2>&1 & + row_file="$results/$(printf '%04d' "$queued")" + ( "$@"; echo $? >"$row_file.status" ) >"$row_file" 2>&1 & running=$((running + 1)) if [ "$running" -ge "$JOBS" ]; then wait @@ -69,11 +72,15 @@ job() { finish() { wait running=0 - for file in "$results"/*; do + for file in "$results"/[0-9][0-9][0-9][0-9]; do [ -f "$file" ] || continue cat "$file" failures=$((failures + $(grep -c '^FAIL' "$file"))) - rm -f "$file" + if [ ! -s "$file.status" ]; then + echo "FAIL row $(basename "$file"): killed before it finished" + failures=$((failures + 1)) + fi + rm -f "$file" "$file.status" done } @@ -81,7 +88,7 @@ finish() { # being found (renamed so it no longer ends in `Test`, say) fails the gate. SPELLS="spells/basicSpells.qnt:6 spells/soup.qnt:4" MODULES="types.qnt wire.qnt indexer.qnt hub.qnt abstractHub.qnt hubMachine.qnt shim.qnt protocol.qnt" -FUNCTIONAL="tests/wireTest.qnt:11 tests/indexerTest.qnt:14 tests/hubTest.qnt:26 tests/shimTest.qnt:13 +FUNCTIONAL="tests/wireTest.qnt:11 tests/indexerTest.qnt:14 tests/hubTest.qnt:27 tests/shimTest.qnt:13 tests/hubScenariosTest.qnt:28 tests/scenariosTest.qnt:21 tests/trustTest.qnt:12" fail() { @@ -322,6 +329,12 @@ fi if [ "$TIERS" != simulation ]; then echo "---- 4 hub specification (TLC, exhaustive)" +# Fetched once here, not by the first rows: rows run in parallel would unpack +# it into ~/.quint at the same time. +if ! QUINT=$QUINT sh ./tlc.sh --fetch; then + exit 1 +fi + G6B=conformingFirstOfferBeforeExpiry G6C=conformingFirstOfferJudgedBeforeExpiry K5=ackedIsHeldOrSettled diff --git a/zeronym/spec/quint/tests/hubTest.qnt b/zeronym/spec/quint/tests/hubTest.qnt index cda38aa2..9a899f30 100644 --- a/zeronym/spec/quint/tests/hubTest.qnt +++ b/zeronym/spec/quint/tests/hubTest.qnt @@ -158,6 +158,20 @@ module hubTest { after(state, LookupHInput({ nonce: 7, txid: "a", answer: INotFound })) == state)), } + /// Bytes the shim rejects for their trailing junk and the hub's parser + /// accepts: the shim diverts them, and the hub queues them under the txid it + /// computes, so a lookup by that txid is a queue hit. + pure val pTrailing = { ...pJunk, id: "trailing", txid: Some("tt") } + + run trailingBytesAreQueuedAndHitTest = + val queued = running(5).after(submit(pTrailing)) + all { + assert(admission(running(5), pTrailing) == Admitted), + assert(queued.isQueuedTxid("tt")), + assert(outputOf(queued, LookupHInput({ nonce: 7, txid: "tt", answer: INotFound })) + == LookupReplyOutput({ nonce: 7, outcome: QueueHit })), + } + // ------------------------------------------------------------------------ // Tip // ------------------------------------------------------------------------ diff --git a/zeronym/spec/quint/tests/shimTest.qnt b/zeronym/spec/quint/tests/shimTest.qnt index daee962f..03de158a 100644 --- a/zeronym/spec/quint/tests/shimTest.qnt +++ b/zeronym/spec/quint/tests/shimTest.qnt @@ -13,6 +13,8 @@ module shimTest { pure val pOrchard = payload("orchard", OrchardTouching) pure val pPlain = payload("plain", PassThrough) pure val pJunk = { ...payload("junk", Unparseable), txid: None, expiry: None } + /// Trailing bytes: the shim's parser gives up, the hub's computes a txid. + pure val pTrailing = { ...payload("trailing", Unparseable), expiry: None } pure val pBig = { ...payload("big", OrchardTouching), oversize: true } pure val pBigPlain = { ...payload("big-plain", PassThrough), oversize: true } @@ -75,6 +77,10 @@ module shimTest { // A body the shim cannot parse is diverted, never forwarded. assert(outputOf(initialShim, send(Clean(pJunk), true)) == DivertedOutput({ payload: pJunk, frame: Submit({ nonce: 0, payload: pJunk }), told: SentOk })), + // So is one the hub's parser accepts: the shim's classification alone + // decides. + assert(outputOf(initialShim, send(Clean(pTrailing), true)) + == DivertedOutput({ payload: pTrailing, frame: Submit({ nonce: 0, payload: pTrailing }), told: SentOk })), } run failClosedTest = all { diff --git a/zeronym/spec/quint/tlc.sh b/zeronym/spec/quint/tlc.sh index 5a017308..7e6cf91e 100755 --- a/zeronym/spec/quint/tlc.sh +++ b/zeronym/spec/quint/tlc.sh @@ -2,6 +2,7 @@ # Check one invariant of one configuration exhaustively, with TLC. # # tlc.sh FILE MAIN INIT STEP INVARIANT +# tlc.sh --fetch only fetch Apalache, if it is missing # # FILE is a Quint file, MAIN the module in it, INIT a named init action built # on `initWith`, STEP a step relation and INVARIANT a state predicate. On @@ -45,6 +46,34 @@ die() { exit 1 } +# The Apalache distribution is fetched by Quint the first time it verifies +# anything. The verdict of that run is irrelevant. +fetch() { + [ -f "$JAR" ] && return 0 + dir=$(mktemp -d) + cat >"$dir/fetch.qnt" <<'EOF' +module fetch { + var x: int + action init = x' = 0 + action step = x' = x +} +EOF + (cd "$dir" && $QUINT verify fetch.qnt --max-steps=1 >fetch.log 2>&1) + if [ ! -f "$JAR" ]; then + tail -5 "$dir/fetch.log" >&2 + rm -rf "$dir" + die "toolchain: no Apalache $APALACHE_VERSION at $JAR" + fi + rm -rf "$dir" +} + +# `tlc.sh --fetch` only fetches. A caller that runs several checks at once +# fetches first: two first runs would unpack it into ~/.quint together. +if [ "${1:-}" = --fetch ]; then + fetch + exit 0 +fi + [ $# -eq 5 ] || die "usage: tlc.sh FILE MAIN INIT STEP INVARIANT" case $1 in /*) file=$1 ;; @@ -62,19 +91,7 @@ work=$(mktemp -d) trap 'rm -rf "$work"' EXIT cd "$work" || die "toolchain: cannot enter $work" -# The Apalache distribution is fetched by Quint the first time it verifies -# anything. The verdict of this run is irrelevant. -if [ ! -f "$JAR" ]; then - cat >fetch.qnt <<'EOF' -module fetch { - var x: int - action init = x' = 0 - action step = x' = x -} -EOF - $QUINT verify fetch.qnt --max-steps=1 >fetch.log 2>&1 - [ -f "$JAR" ] || { tail -5 fetch.log >&2; die "toolchain: no Apalache $APALACHE_VERSION at $JAR"; } -fi +fetch # 1. Compile. A misspelt name leaves stdout empty and exits non-zero. if ! $QUINT compile --target=json --main="$main" --init="$init" --step="$step" \ From fb678340615dfee695e3b97edcea93002d357f36 Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 21:10:25 +0400 Subject: [PATCH 78/80] test(zeronym): fetch Quint and its evaluator once before rows run in parallel On a fresh runner the first parallel rows each ran npx, which filled one cache at the same time; a row loaded a half-written lodash.js and failed its typecheck. Co-authored-by: Cursor --- zeronym/spec/quint/check.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/zeronym/spec/quint/check.sh b/zeronym/spec/quint/check.sh index c153b8a1..36b8a47d 100755 --- a/zeronym/spec/quint/check.sh +++ b/zeronym/spec/quint/check.sh @@ -269,6 +269,22 @@ typecheck() { fi } +# Quint, through npx, and its Rust evaluator are fetched on first use, into +# caches that rows run in parallel would fill at the same time. One run here +# fetches both before any row starts. +echo "---- 0 toolchain" +mkdir "$results/warm" +printf 'module warm {\n var x: int\n action init = x'"'"' = 0\n action step = x'"'"' = x\n}\n' \ + >"$results/warm/warm.qnt" +if out=$($QUINT run "$results/warm/warm.qnt" --max-samples=1 --max-steps=1 --backend="$BACKEND" 2>&1); then + echo "ok quint $($QUINT --version 2>/dev/null), $BACKEND evaluator" +else + echo "$out" | tail -25 + fail "toolchain: quint run failed" + exit 1 +fi +rm -rf "$results/warm" + echo "---- 1 typecheck" for file in $SPELLS $MODULES $FUNCTIONAL; do job typecheck "${file%:*}" From a806432b33f7bb8c4211e233d594354c9878023a Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 21:21:27 +0400 Subject: [PATCH 79/80] test(zeronym): classify each finding in the README and correct what a review found wrong --- zeronym/spec/quint/README.md | 37 ++++++++++--------- zeronym/spec/quint/tests/hubScenariosTest.qnt | 4 +- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/zeronym/spec/quint/README.md b/zeronym/spec/quint/README.md index 33476545..29588b51 100644 --- a/zeronym/spec/quint/README.md +++ b/zeronym/spec/quint/README.md @@ -191,7 +191,7 @@ The deployment targets the server-side and network-metadata adversaries of Taylo | T9 | An acknowledged migration is lost to a crash, a failed final flush, or a requeue drop | None needed | Not prevented | | T10 | A third party who knows a txid learns it is queued | Unauthenticated third party | Accepted | | T11 | A lying indexer makes the hub flush early, shrinking the batch | Byzantine indexer; one endpoint suffices | Not prevented; recorded | -| T12 | Any admitted transaction, including one from an unsupported wallet, is offered too late | As T5 | Not checked | +| T12 | Any admitted transaction, including a late arrival or one from an unsupported wallet, is offered too late | As T5 | Not checked | | T13 | The hub acknowledges a migration it never queued | Byzantine hub | Not modelled: nothing reads an ack | | T14 | Linking a wallet to its migration by source IP | Network observer; operator | Not modelled. Claimed protected in `zeronym/README.md` | | T15 | Linking by submission size and arrival time | Operator | Not modelled. Listed as not protected in `zeronym/README.md` | @@ -250,10 +250,10 @@ Single-fault. "holds" is a checked row on the named configuration. "required" is | Id | Threat | What is lost | Status | Where | Checked by | Scripted run | |---|---|---|---|---|---|---| | K1 | T7 | Told ok does not mean the hub ever admits it: it may refuse the frame, or never receive it | By design: the hub's verdict "is deliberately not waited for" (`zeronym/shim/src/hub.rs:289-295`) | `baseline` | scripted runs | `toldOkThenRefusedTest`, `toldOkAndNeverDeliveredTest` | -| K2 | T8 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | By design for the flush window, which the code accepts in a comment on `Hub::lookup` (`zeronym/hub/src/server.rs`); the other causes follow from the same design and are not discussed there | `baseline` | simulation, violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | +| K2 | T8 | `statusNeverRegresses`: what a wallet sees of one transaction never goes backwards | By design for the flush window, which the code accepts in a comment on `Hub::lookup` (`zeronym/hub/src/server.rs`); resubmission of a published transaction is finding 10, not triaged | `baseline` | simulation, violated | `repliesReorderedTest`, `walletResendsPublishedTest`, `thirdPartyResubmitsPublishedTest`, `flushWindowTest`, `rejectedAtFlushTest` | | K3' | T5 | G6b and G6c when the expiry floor leaves no slack for the reorg allowance | Found here (finding 3); not triaged | `flakyTipNoSlack` | TLC, violated | `conformingMissesMarginWithoutSlackTest` | | K4 | T5 | G6b and G6c on the shipped relation between the constants, across a tip silence shorter than the staleness window | Found here (finding 1); not triaged | `staleLag` | TLC, violated | `silenceAcrossBoundaryMissesMarginTest`; contrast `sameSilenceWithSlackKeepsMarginTest` | -| K5 | T9 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it | By design for a crash and a failed final flush: the queue is in memory only, and the code logs what it loses (`zeronym/hub/src/batcher.rs:337-347`). The requeue drop was found here (finding 5); not triaged | `timely` | TLC, violated three ways | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | +| K5 | T9 | `ackedIsHeldOrSettled`: an acknowledged payload is still held by the hub, or is on the chain, or a node judged it | By design: the queue is in memory only, and the code logs what it loses (`zeronym/hub/src/batcher.rs:337-347`, `:467-478`). How much one unjudged flush drops is not triaged (finding 5) | `timely` | TLC, violated three ways | `ackedThenCrashedTest`, `ackedThenLostAtDrainTest`, `requeueDropsAckedAsExpiredTest` | | K6 | T5 | `conformingEveryOfferBeforeExpiry`: G6b for every offer, not only the first | Found here (finding 7); not triaged | `staleLag` | TLC, violated | `requeuedPastExpiryTest` | | K7 | T5 | G6c when a flush may stay in flight for as many blocks as the mining margin | Found here (finding 4); not triaged | `flakyTipSlowFlight` | TLC, violated; G6b holds there | `slowFlightSpendsTheMarginTest` | | K8 | T5 | A supported wallet's transaction, acknowledged on time, lost to a crash and resent, is first offered by the restarted hub with less than the mining margin. To the restarted hub the resend is a late first arrival, so G6b and G6c do not cover it | Found here (finding 6); not triaged | `flakyTip` | scripted run | `crashThenLateDuplicateTest`; control `lateDuplicateWithoutCrashTest` | @@ -267,7 +267,7 @@ Behaviours that are accepted or only recorded: |---|---|---| | **Accepted disclosure.** A third party that knows a txid learns that it is queued. The hub withholds the bytes, not the fact. The implementation leaves this open deliberately (`Hub::lookup`, `zeronym/hub/src/server.rs`): "the 200-versus-NotFound distinction still discloses that a given txid is queued here. Closing that too means answering NotFound, which costs a wallet the ability to tell "pending" from "never seen"" | T10 | `thirdPartyLearnsItIsQueuedTest`; witness `wQueuedDisclosed` on `baseline` | | **Twin and false height served.** The wallet can be served a twin of what it sent, and a transaction at a height the hub made up. G3 holds throughout | T3 | `wTwinServed`, `wFalseHeightServed` on `byzHub` | -| **Premature flush.** A Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary. A batching harm, not an expiry one | T11 | `tipAheadOfChainFlushesEarlyTest` | +| **A tip reported ahead of the chain.** A Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary: a batching harm, not an expiry one. Found by reading and not modelled: `observe` adopts any higher height and ignores a later drop larger than the reorg allowance (`zeronym/hub/src/batcher.rs:171-203`), so one endpoint reporting a height P pins the hub's tip until the chain reaches P minus the allowance. That is one off-cadence flush, and then every expiring submission is refused. The code says taking the maximum over endpoints defends the clock against being advanced (`batcher.rs:19-22`); it defends only against slowing it | T11 | `tipAheadOfChainFlushesEarlyTest` | | **Early flush by the free-running clock.** A stale hub's clock is ahead of the chain and it flushes before the true boundary, with every component honest | T11, with no liar | `freeRunningClockFlushesEarlyTest` | | **Unparseable and queued.** A payload the hub cannot parse has no txid, so a lookup misses it while it is queued | none | `unparseableIsQueuedAndMissedTest` | @@ -305,7 +305,7 @@ Argued, not checked, on the assumption that hubs share nothing but the chain and | What the hub's ack says | Nothing reads an ack: the shim tells the wallet ok without waiting for it. That an accepted ack is only for a queued payload is not a checked property, and a Byzantine hub's ack is modelled as truthful | | The hub's capacity and size refusals, and denial of service generally | Not claimed properties. The shim's own too-large refusal is modelled | | A third party submitting payloads of its own making | Possible, since the hub's address is public and unauthenticated. The model's third party submits only what it has learned or the chain has published | -| Wallets whose expiry is below the supported floor | No schedule guarantee is made for them. Admission's own claim that every admitted entry "provably survives" its scheduled flush (`zeronym/hub/src/queue.rs:497-519`) is not checked, and is known not to hold under a tip reported behind the chain | +| Wallets whose expiry is below the supported floor, and submissions that arrive later than the delivery lag | No schedule guarantee is made for them (but see finding 6). Admission's own claim that every admitted entry "provably survives" its scheduled flush (`zeronym/hub/src/queue.rs:497-519`) is not checked, and is known not to hold under a tip reported behind the chain | | More than one Byzantine component at once | The trust matrix is single-fault | | Liveness: that anything eventually happens, such as a submitted migration being published | The network may lose everything, and nobody waits for an ack | @@ -313,19 +313,20 @@ Argued, not checked, on the assumption that hubs share nothing but the chain and > Where the model disagrees with what the code or its comments assume. -Nothing here has been fixed. "Code read" means the cited lines were read and match the model; nothing was run against the Rust. +Nothing here has been fixed. "Code read" means the cited lines were read and match the model; nothing was run against the Rust. A tip reported far ahead of the chain is under [Known gaps](#known-gaps), found by reading. -| # | Finding | Shown by | Against the Rust | -|---|---|---|---| -| 1 | **A short tip silence costs a supported wallet its mining margin.** The cadence follows the last tip seen, so a silence across a flush boundary delays the flush until the hub goes stale. On the shipped constants the first offer is at `created + 37` against an expiry of `created + 40`: three blocks of margin where four are reserved | K4, `silenceAcrossBoundaryMissesMarginTest`; TLC on `staleLag` | Code read: `batcher.rs:40-71`, `:227-247`. The one-block shortfall reads 15 minutes as exactly 12 blocks | -| 2 | **An early free-running flush spends the next epoch.** A stale hub's clock runs ahead, flushes an empty queue and records that epoch. When the tip returns, admission counts on a flush that has already happened, and the transaction waits a full interval. A wider expiry floor does not fix it | `earlyFlushSpendsTheNextEpochTest`; TLC on `staleLagWithSlack` | Code read: `batcher.rs:316-326`. The comment there calls a clock that runs ahead "the safe direction" | -| 3 | **The reorg slack holds by coincidence of constants.** The expiry floor minus the three-term budget is 10 blocks, exactly the reorg allowance, and startup validation checks only the three-term sum. Without the slack a supported wallet's transaction misses its margin | K3', `conformingMissesMarginWithoutSlackTest`; TLC on `flakyTipNoSlack` | Code read: `batcher.rs:40-59`, `:101-113` | -| 4 | **Nothing bounds a flush's flight in blocks.** The budget leaves exactly the mining margin at the offer, so blocks that arrive while the batch is in flight come out of it | K7, `slowFlightSpendsTheMarginTest`; TLC on `flakyTipSlowFlight` | Code read: `chain.rs` bounds each call (`RPC_TIMEOUT`), not the batch | -| 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired after two unjudged flushes | K5 and its three runs; TLC on `timely` | Code read: the queue is in memory only (`queue.rs:226-243`, `batcher.rs:337-347`) | -| 6 | **A crash plus a late duplicate is offered past the margin.** A restarted hub adopts the current epoch without flushing; told a tip one block back, it admits the resend counting on a flush that will not happen | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-519` | -| 7 | **On a stale hub a requeued entry is offered again past its expiry, and only the attempt bound stops it.** Each requeue judges the entry against the same stale tip, as admission does, so the expiry rule never gives it up. A supported wallet's transaction is offered a third time after its expiry, and is finally dropped as exhausted | K6, `requeuedPastExpiryTest`; TLC on `staleLag`; `expiringEntryDroppedAsExhaustedTest` | Code read, and it contradicts a comment: `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | -| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | -| 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:630-633`. Unexamined; not claimed as a bug | +| # | Finding | Kind | Shown by | Against the Rust | +|---|---|---|---|---| +| 1 | **A short tip silence costs a supported wallet its mining margin.** The cadence follows the last tip seen, so a silence across a flush boundary delays the flush until the hub goes stale. The staleness window is 12 blocks and the slack is 10, so on the shipped constants the first offer is at `created + 37` against an expiry of `created + 40`: three blocks of margin where four are reserved. It needs the hub not to hear the tip while a broadcast would still succeed, and the tip not to return before the hub goes stale; the tip and the broadcast use the same endpoints, so an unreachable indexer delays both | Latent: a real gap between the constants, in a narrow environment | K4, `silenceAcrossBoundaryMissesMarginTest`; TLC on `staleLag` | Code read: `batcher.rs:40-71`, `:227-247`. The one-block shortfall reads 15 minutes as exactly 12 blocks | +| 2 | **An early free-running flush spends the next epoch.** A stale hub's clock runs ahead, flushes an empty queue and records that epoch. When the tip returns, admission counts on a flush that has already happened, and the transaction waits a full interval. A wider expiry floor does not fix it. The supported-wallet witness also needs a second, shorter silence | Code bug, one root cause with finding 6: the loop records the epoch from the cadence height, and admission estimates the next flush from the observed height | K9, `earlyFlushSpendsTheNextEpochTest`; TLC on `staleLagWithSlack` | Code read: `batcher.rs:236-247`, `:316-326`. The comment there calls a clock that runs ahead "the safe direction" | +| 3 | **The reorg slack holds by coincidence of constants.** The expiry floor minus the three-term budget is 10 blocks, exactly the reorg allowance, and startup validation checks only the three-term sum. Without the slack a supported wallet's transaction misses its margin | Latent: no failure on the shipped constants | K3', `conformingMissesMarginWithoutSlackTest`; TLC on `flakyTipNoSlack` | Code read: `batcher.rs:40-59`, `:101-113` | +| 4 | **A flush's flight is bounded in time, not in blocks.** The budget leaves exactly the mining margin at the offer, so blocks that arrive while the batch is in flight come out of it. Each call is bounded at 10 s with 64 in flight, about 160 s at the 1,024-entry cap, which is about two nominal blocks against a margin of four. Block arrival is not bounded by the clock, and requeue does not enforce the entry cap | Bounded in time, not in blocks; reachable only with a slow or selectively hanging indexer | K7, `slowFlightSpendsTheMarginTest`; TLC on `flakyTipSlowFlight` | Code read: `chain.rs:49`, `:59`. Estimate, not measured: with exponential block times at a 75 s mean, a flight of that length sees four or more blocks about one time in six | +| 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired. On the shipped constants a requeue keeps an entry only if it was created at most 16 blocks before the flush (`40 - 20 - 4`). For wallets at the 40-block floor, one unjudged flush therefore drops between 20% and 50% of a batch, depending on delivery lag, each entry with 14 to 23 blocks of life left | By design; the magnitude is not triaged | K5 and its three runs; TLC on `timely` | Code read: the loss is logged at error level (`batcher.rs:337-347`, `:467-478`) and unit-tested (`queue.rs`, `a_requeue_gives_up_on_an_entry_that_can_no_longer_be_mined`); the rule is `queue.rs:507-520`. The percentages are arithmetic on the constants, assuming arrivals uniform over the interval | +| 6 | **An entry admitted while the tip is reported below a boundary already flushed waits a full interval.** Admission counts on the flush at that boundary, which will not happen again. Any late arrival meets it; the supported-wallet case is a resend after a crash, which a restarted hub offers with less than the mining margin | Code bug, one root cause with finding 2 | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-520` | +| 7 | **On a stale hub a requeued entry is offered again past its expiry, and only the attempt bound stops it.** Each requeue judges the entry against the same stale tip, as admission does, so the expiry rule never gives it up. A supported wallet's transaction is offered a third time after its expiry, and is finally dropped as exhausted. The late offers normally reach no node: a stale hub whose flushes come back unjudged almost always has an unreachable indexer | Real behaviour that contradicts a comment; low harm | K6, `requeuedPastExpiryTest`; TLC on `staleLag`; `expiringEntryDroppedAsExhaustedTest` | Code read: `batcher.rs:417-422`; `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | +| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | Code bug: an ambiguous wire reply, and no check on an untrusted answer | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | +| 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Undecided: deliberate and tested, and in conflict with a rule written for submits. The `PRODUCTION.md` that rule cites is not in the repository | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:628-633`; `shim/tests/nym.rs` asserts the behaviour today | +| 10 | **Anyone can make the hub answer "pending" for a published transaction.** The queue deduplicates against resident entries only, so published bytes are admitted again if resubmitted, and a lookup answers from the queue first. The wallet reads "pending" for a transaction it has already been served, until the next flush | Real behaviour that nothing in the code accepts; not triaged | K2, `thirdPartyResubmitsPublishedTest`, `walletResendsPublishedTest` | Code read: `queue.rs:308-310`, `:358-359`; `Hub::lookup` in `server.rs` | On finding 2, the model lets the free-running clock be at most one flush interval ahead, so it can spend one epoch. `cadence_height` has no such cap. Reading that code, a clock further ahead would skip more than one boundary; the model does not exhibit that. @@ -440,6 +441,6 @@ Only `protocol.qnt` and `hubMachine.qnt` declare variables, and no module declar > What would make these results bind the code. -1. **Failing Rust tests for the findings.** Each finding above is shown on the model and matched to the code by reading. Turning each into a failing test needs changes to the code so that an end-to-end run can be driven deterministically: a controllable tip, a controllable indexer, and a hub that can be crashed and restarted in a test. +1. **Failing Rust tests for the findings.** Each finding is shown on the model and matched to the code by reading; no test has been written. A reading of the existing harnesses suggests most need no production change: finding 8 fits the hub's integration tests with its mock indexer, and the cadence findings (1, 2 and 6) fit `batcher.rs`'s own unit tests, where the loop and the cadence clock are visible. Missing today: a fixture with a non-zero expiry, and a way to know the cadence loop has completed a poll. Finding 4 is impractical to reproduce. 2. **Model-based testing with `quint-connect`.** Depends on 1, and needs driver code in Rust. The specification is shaped for it: every branch of a step is a named action with its choices as named picks; each step gives one input to one component function and applies one output; and those functions map onto the seams in the table under [System](#system). 3. **`quint verify` with Apalache for a subset of the claims.** The compiled specification is too large for the Apalache server today. A subset small enough to pass would give bounded symbolic checking of the protocol guarantees, which are simulated only. diff --git a/zeronym/spec/quint/tests/hubScenariosTest.qnt b/zeronym/spec/quint/tests/hubScenariosTest.qnt index bc94af58..b6c3ae9d 100644 --- a/zeronym/spec/quint/tests/hubScenariosTest.qnt +++ b/zeronym/spec/quint/tests/hubScenariosTest.qnt @@ -276,7 +276,7 @@ module hubScenariosTest { .then(flushBegin) .expect(flightStart == 8 and h.inFlight() == Set(early) and not(marginLeft(early, flightStart))) - /// K8 (finding 8). The hub crashed in between and lost its queue. To it the + /// K8 (finding 6). The hub crashed in between and lost its queue. To it the /// duplicate is a first arrival, four blocks late, and admission counts on /// the flush at 6, which will not happen. The wallet's transaction was on /// time, was acknowledged, and is first offered with one block of margin @@ -392,7 +392,7 @@ module hubScenariosTest { requeuedTwiceWhileStale .expect(not(conformingEveryOfferBeforeExpiry) and conformingFirstOfferBeforeExpiry) - /// Finding 9. The third flush comes back unjudged as well, and the entry, + /// Finding 7. The third flush comes back unjudged as well, and the entry, /// which has an expiry, is dropped as exhausted: the expiry rule, reading a /// tip that stopped at 5, still does not give it up. run expiringEntryDroppedAsExhaustedTest = From 6cdd82e8d7994516e8ee7f828aff41f2099feb2a Mon Sep 17 00:00:00 2001 From: Shoaib Ahmed Date: Thu, 8 Oct 2026 22:23:11 +0400 Subject: [PATCH 80/80] test(zeronym): give the README's code bugs a severity and record the pinned tip as a finding --- zeronym/spec/quint/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/zeronym/spec/quint/README.md b/zeronym/spec/quint/README.md index 29588b51..73fb34b2 100644 --- a/zeronym/spec/quint/README.md +++ b/zeronym/spec/quint/README.md @@ -190,7 +190,7 @@ The deployment targets the server-side and network-metadata adversaries of Taylo | T8 | The wallet sees its transaction's status go backwards | Network reordering; resubmission; the flush window | Not prevented | | T9 | An acknowledged migration is lost to a crash, a failed final flush, or a requeue drop | None needed | Not prevented | | T10 | A third party who knows a txid learns it is queued | Unauthenticated third party | Accepted | -| T11 | A lying indexer makes the hub flush early, shrinking the batch | Byzantine indexer; one endpoint suffices | Not prevented; recorded | +| T11 | A lying indexer makes the hub flush early, shrinking the batch, or stop admitting | Byzantine indexer; one endpoint suffices | Not prevented; recorded | | T12 | Any admitted transaction, including a late arrival or one from an unsupported wallet, is offered too late | As T5 | Not checked | | T13 | The hub acknowledges a migration it never queued | Byzantine hub | Not modelled: nothing reads an ack | | T14 | Linking a wallet to its migration by source IP | Network observer; operator | Not modelled. Claimed protected in `zeronym/README.md` | @@ -267,7 +267,7 @@ Behaviours that are accepted or only recorded: |---|---|---| | **Accepted disclosure.** A third party that knows a txid learns that it is queued. The hub withholds the bytes, not the fact. The implementation leaves this open deliberately (`Hub::lookup`, `zeronym/hub/src/server.rs`): "the 200-versus-NotFound distinction still discloses that a given txid is queued here. Closing that too means answering NotFound, which costs a wallet the ability to tell "pending" from "never seen"" | T10 | `thirdPartyLearnsItIsQueuedTest`; witness `wQueuedDisclosed` on `baseline` | | **Twin and false height served.** The wallet can be served a twin of what it sent, and a transaction at a height the hub made up. G3 holds throughout | T3 | `wTwinServed`, `wFalseHeightServed` on `byzHub` | -| **A tip reported ahead of the chain.** A Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary: a batching harm, not an expiry one. Found by reading and not modelled: `observe` adopts any higher height and ignores a later drop larger than the reorg allowance (`zeronym/hub/src/batcher.rs:171-203`), so one endpoint reporting a height P pins the hub's tip until the chain reaches P minus the allowance. That is one off-cadence flush, and then every expiring submission is refused. The code says taking the maximum over endpoints defends the clock against being advanced (`batcher.rs:19-22`); it defends only against slowing it | T11 | `tipAheadOfChainFlushesEarlyTest` | +| **Premature flush.** A Byzantine indexer reports a tip ahead of the chain and the hub flushes before the true boundary: a batching harm, not an expiry one. What a report far ahead of the chain does is finding 11 | T11 | `tipAheadOfChainFlushesEarlyTest` | | **Early flush by the free-running clock.** A stale hub's clock is ahead of the chain and it flushes before the true boundary, with every component honest | T11, with no liar | `freeRunningClockFlushesEarlyTest` | | **Unparseable and queued.** A payload the hub cannot parse has no txid, so a lookup misses it while it is queued | none | `unparseableIsQueuedAndMissedTest` | @@ -313,20 +313,21 @@ Argued, not checked, on the assumption that hubs share nothing but the chain and > Where the model disagrees with what the code or its comments assume. -Nothing here has been fixed. "Code read" means the cited lines were read and match the model; nothing was run against the Rust. A tip reported far ahead of the chain is under [Known gaps](#known-gaps), found by reading. +Nothing here has been fixed. "Code read" means the cited lines were read and match the model; nothing was run against the Rust. The severity beside a code bug is a judgement from that reading, not from a reproduction: HIGH, one in-scope adversary degrades batching or availability for every user of the hub; MEDIUM, one wallet loses a transaction or is misled about one; LOW, a transient wrong status or a wrong operator signal. Only code bugs carry a severity, so a row without one is not thereby minor: finding 5 is by design and may matter more than any MEDIUM bug. | # | Finding | Kind | Shown by | Against the Rust | |---|---|---|---|---| | 1 | **A short tip silence costs a supported wallet its mining margin.** The cadence follows the last tip seen, so a silence across a flush boundary delays the flush until the hub goes stale. The staleness window is 12 blocks and the slack is 10, so on the shipped constants the first offer is at `created + 37` against an expiry of `created + 40`: three blocks of margin where four are reserved. It needs the hub not to hear the tip while a broadcast would still succeed, and the tip not to return before the hub goes stale; the tip and the broadcast use the same endpoints, so an unreachable indexer delays both | Latent: a real gap between the constants, in a narrow environment | K4, `silenceAcrossBoundaryMissesMarginTest`; TLC on `staleLag` | Code read: `batcher.rs:40-71`, `:227-247`. The one-block shortfall reads 15 minutes as exactly 12 blocks | -| 2 | **An early free-running flush spends the next epoch.** A stale hub's clock runs ahead, flushes an empty queue and records that epoch. When the tip returns, admission counts on a flush that has already happened, and the transaction waits a full interval. A wider expiry floor does not fix it. The supported-wallet witness also needs a second, shorter silence | Code bug, one root cause with finding 6: the loop records the epoch from the cadence height, and admission estimates the next flush from the observed height | K9, `earlyFlushSpendsTheNextEpochTest`; TLC on `staleLagWithSlack` | Code read: `batcher.rs:236-247`, `:316-326`. The comment there calls a clock that runs ahead "the safe direction" | +| 2 | **An early free-running flush spends the next epoch.** A stale hub's clock runs ahead, flushes an empty queue and records that epoch. When the tip returns, admission counts on a flush that has already happened, and the transaction waits a full interval. A wider expiry floor does not fix it. The supported-wallet witness also needs a second, shorter silence | Code bug (MEDIUM), one root cause with finding 6: the loop records the epoch from the cadence height, and admission estimates the next flush from the observed height | K9, `earlyFlushSpendsTheNextEpochTest`; TLC on `staleLagWithSlack` | Code read: `batcher.rs:236-247`, `:316-326`. The comment there calls a clock that runs ahead "the safe direction" | | 3 | **The reorg slack holds by coincidence of constants.** The expiry floor minus the three-term budget is 10 blocks, exactly the reorg allowance, and startup validation checks only the three-term sum. Without the slack a supported wallet's transaction misses its margin | Latent: no failure on the shipped constants | K3', `conformingMissesMarginWithoutSlackTest`; TLC on `flakyTipNoSlack` | Code read: `batcher.rs:40-59`, `:101-113` | | 4 | **A flush's flight is bounded in time, not in blocks.** The budget leaves exactly the mining margin at the offer, so blocks that arrive while the batch is in flight come out of it. Each call is bounded at 10 s with 64 in flight, about 160 s at the 1,024-entry cap, which is about two nominal blocks against a margin of four. Block arrival is not bounded by the clock, and requeue does not enforce the entry cap | Bounded in time, not in blocks; reachable only with a slow or selectively hanging indexer | K7, `slowFlightSpendsTheMarginTest`; TLC on `flakyTipSlowFlight` | Code read: `chain.rs:49`, `:59`. Estimate, not measured: with exponential block times at a 75 s mean, a flight of that length sees four or more blocks about one time in six | | 5 | **An acknowledged payload can be lost three ways with every component honest:** a crash; a draining hub's final flush that finds the indexer unreachable; a requeue that gives the entry up as expired. On the shipped constants a requeue keeps an entry only if it was created at most 16 blocks before the flush (`40 - 20 - 4`). For wallets at the 40-block floor, one unjudged flush therefore drops between 20% and 50% of a batch, depending on delivery lag, each entry with 14 to 23 blocks of life left | By design; the magnitude is not triaged | K5 and its three runs; TLC on `timely` | Code read: the loss is logged at error level (`batcher.rs:337-347`, `:467-478`) and unit-tested (`queue.rs`, `a_requeue_gives_up_on_an_entry_that_can_no_longer_be_mined`); the rule is `queue.rs:507-520`. The percentages are arithmetic on the constants, assuming arrivals uniform over the interval | -| 6 | **An entry admitted while the tip is reported below a boundary already flushed waits a full interval.** Admission counts on the flush at that boundary, which will not happen again. Any late arrival meets it; the supported-wallet case is a resend after a crash, which a restarted hub offers with less than the mining margin | Code bug, one root cause with finding 2 | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-520` | +| 6 | **An entry admitted while the tip is reported below a boundary already flushed waits a full interval.** Admission counts on the flush at that boundary, which will not happen again. Any late arrival meets it; the supported-wallet case is a resend after a crash, which a restarted hub offers with less than the mining margin | Code bug (MEDIUM), one root cause with finding 2 | K8, `crashThenLateDuplicateTest` | Code read: `batcher.rs:177-186`, `:316-327`; `queue.rs:294`, `:507-520` | | 7 | **On a stale hub a requeued entry is offered again past its expiry, and only the attempt bound stops it.** Each requeue judges the entry against the same stale tip, as admission does, so the expiry rule never gives it up. A supported wallet's transaction is offered a third time after its expiry, and is finally dropped as exhausted. The late offers normally reach no node: a stale hub whose flushes come back unjudged almost always has an unreachable indexer | Real behaviour that contradicts a comment; low harm | K6, `requeuedPastExpiryTest`; TLC on `staleLag`; `expiringEntryDroppedAsExhaustedTest` | Code read: `batcher.rs:417-422`; `queue.rs:197` says "Only reachable for a payload with no expiry". The shipped bound is 8 requeues | -| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | Code bug: an ambiguous wire reply, and no check on an untrusted answer | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | +| 8 | **One indexer endpoint can make a wallet see "pending" for a transaction nobody holds.** "Found, height 0, no body" from an indexer is byte-identical to the hub's own queue-hit reply, and the hub forwards it unchanged | Code bug (MEDIUM): an ambiguous wire reply, and no check on an untrusted answer | `sentinelCollisionTest`, `indexerForgesPendingTest` | Code read: `server.rs:445-451`, `chain.rs:284-290`, `:305-319` | | 9 | **Lookups choose a hub by apparent liveness.** A lookup starts at a rotating cursor and moves to the next address only on a timeout, the pattern the submit path forbids. Whoever can make one hub time out decides which hub answers | Undecided: deliberate and tested, and in conflict with a rule written for submits. The `PRODUCTION.md` that rule cites is not in the repository | Not modelled: needs more than one hub | Code read: `shim/src/nym.rs:746-797`, against the rule at `:628-633`; `shim/tests/nym.rs` asserts the behaviour today | -| 10 | **Anyone can make the hub answer "pending" for a published transaction.** The queue deduplicates against resident entries only, so published bytes are admitted again if resubmitted, and a lookup answers from the queue first. The wallet reads "pending" for a transaction it has already been served, until the next flush | Real behaviour that nothing in the code accepts; not triaged | K2, `thirdPartyResubmitsPublishedTest`, `walletResendsPublishedTest` | Code read: `queue.rs:308-310`, `:358-359`; `Hub::lookup` in `server.rs` | +| 10 | **Anyone can make the hub answer "pending" for a published transaction, and pad its batch-size signal.** The queue deduplicates against resident entries only, so published bytes are admitted again if resubmitted, and a lookup answers from the queue first. The wallet reads "pending" for a transaction it has already been served, until the next flush. The flush also counts an already-known verdict toward the achieved batch size, which the code calls "the honest measure of the privacy", so free, public bytes can raise that figure and suppress the warning for a batch of one | Code bug (LOW): the comment on `Hub::lookup` calls a resubmit harmless. The batch-size figure is logged and returned but drives no decision | K2, `thirdPartyResubmitsPublishedTest`, `walletResendsPublishedTest` | Code read: `queue.rs:308-310`, `:358-359`; `Hub::lookup` in `server.rs`; `batcher.rs:352`, `:389`, `:480`. Not checked: that a node answers already-known for a transaction that is already mined | +| 11 | **One high tip report pins the hub's tip.** `observe` adopts any higher height and ignores a later drop larger than the reorg allowance. So one endpoint reporting a height P makes every real height look like an oversized regression until the chain reaches P minus the allowance, which for a large P is until restart. The epoch jumps, so the hub flushes once off cadence; after that admission refuses every expiring transaction, because the observed height is P | Code bug (HIGH): the code says taking the maximum over endpoints defends the clock against being advanced, and it defends only against slowing it | Found by reading; not modelled, because the model's heights are bounded. `tipAheadOfChainFlushesEarlyTest` shows the one early flush | Code read: `batcher.rs:19-22`, `:171-203`, `:316-326`; `chain.rs:176-201` | On finding 2, the model lets the free-running clock be at most one flush interval ahead, so it can spend one epoch. `cadence_height` has no such cap. Reading that code, a clock further ahead would skip more than one boundary; the model does not exhibit that.