Skip to content

feat: source the TM validator from the chain, and refuse to start without it - #77

Merged
rssh merged 3 commits into
mainfrom
feat/wi-hj1n5-chain-sourced-tm-script
Aug 21, 2026
Merged

rssh merged 3 commits into
mainfrom
feat/wi-hj1n5-chain-sourced-tm-script

Conversation

@rssh

@rssh rssh commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

What broke

At batch B_5 on 2026-08-20 all three preprod SPOs froze the batch, ran the ceremony, signed TM e86ed19d…, broadcast it to Bitcoin — and then every one of them failed the Cardano post with

cardano.tm_script_cbor not set (from `binocular tm-script`)

The leader cascade worked exactly as designed and walked all three nodes onto the same missing value. The broadcast Bitcoin transaction then sat unconfirmed, which sets in_flight = true, so B_6–B_9 were skipped with "a treasury movement is still in flight". Five batch opportunities lost, with all 8 preflight steps green throughout.

Minting the TM NFT needs the validator itself, not just its hash. heimdall cannot compile it — it is Scalus, it lives in binocular, it is parameterized per bridge instance — so it was an operator-typed CBOR string: the last hand-copied artifact on the posting path, and the only one whose absence was silent. Unset, a node signs a movement it can never post. Mistyped, it mints under a policy nothing scans, discovered on chain after the ceremony is already spent.

What this does

Config #5 names the script and the chain holds it, so the node reads it the way it reads everything else about the bridge.

  • bf_http::fetch_script_cbor fetches /scripts/{hash}/cbor and refuses any bytes whose blake2b224(0x03 || cbor) is not the hash they were fetched by. That digest covers the language prefix, so one check proves both the bytes and that they are Plutus V3 — there is nothing left for a provider to get wrong, and nothing an operator has to be trusted to paste.
  • cardano.tm_script_cbor joins the retired keys and is refused at load, naming Config docs: FaultProof token-name / ban-policy divergence (technical_questions §5, WI-018) #5.
  • New preflight step 9, "post a movement" — the question the rest of the report did not ask. A FAIL stops the node, because a node that cannot post is a node whose turn on the cascade is a wasted hop, and it is invisible from its own log.
  • The batch byte budget takes the script's serialised_size from the chain instead of measuring an operator's string, and refuses the batch if it cannot read it. Assuming the script away understates the envelope by ~4 KB — the direction that builds a movement no co-signer reproduces.

A Plutus script is on chain only once something uses it, so the companion change is lantr-io/binocular#32: deploy-script-refs publishes treasury_movement at deployment. Without it the bridge's first movement would need the script in order to make the transaction that would publish it. It is the one entry in that list published for its existence rather than to shrink a transaction.

Deliberately not done

heimdall still passes the script inline (ProvidedScriptSource) and charges its bytes to the envelope, which post_tm_envelope already did correctly — the ticket's "the WI-107 budget may understate the envelope by 4 KB" does not hold. Spending the reference output instead would save ~4 KB per movement and roughly ten more peg-in/peg-out pairs per batch, but whether a node inlines or references changes the byte budget, and a budget that depends on what each node happened to find on chain is a consensus value decided per node. That is a bridge-wide switch, not this change.

Deploying this

The live preprod nodes have tm_script_cbor set by hand (2026-08-21 09:05 UTC, chain-sourced and hash-verified) — this build refuses to load those files. The line must be deleted from /etc/heimdall/spo{1,2,3}.toml before restarting. The script is already on chain for that bridge (past mints put it there), so nothing else is needed there; the binocular change matters for bridges deployed from here on.

Also in this branch

Two commits from the same incident, already running on the preprod nodes: the peg-in scan address in the startup banner and step 3, and show-treasury reporting the treasury's own Y_51 instead of a demo-derived one.

cargo test: 733 lib + all integration green. cargo fmt --check, cargo clippy clean.

rssh added 3 commits August 21, 2026 08:11
The startup banner named the peg-out request address and the daemon then built
its peg-in source from the sibling field of the same Config record without ever
printing it. That made two very different states look identical: a bridge with no
deposits, and a node watching a peg-in address no depositor uses — both report
`0 eligible pegins` and nothing else. Finding out which required decoding the
Config datum by hand.

The banner now prints both addresses together, with the policy a request NFT must
carry, and `doctor`'s Config step appends the peg-in address it derives from the
datum it just resolved.
The command derived `our Y_51` from `run_demo_dkg` over a hardcoded seed, three
lines under a comment saying it must use "the same source the mover signs
against - the treasury_info datum, not this node's local seed". On a real bridge
that names a key no node holds and derives an "expected treasury spk" the
treasury does not have, stated beside chain-read values with the same confidence:
on the shared preprod bridge it printed b1e15a53... while every node signs with
ccd11322..., and the expected scriptPubKey matched nothing on chain.

The datum read that `assert_mover_key_owns_treasury` already did is now a shared
`treasury_authorized_key`, and show-treasury prints what it returns, labelled with
where it came from. The demo key survives as the fallback for the one deployment
where it IS the treasury's - a fixture with no treasury_info datum - and says so.
…hout it

Posting a treasury movement needs the TreasuryMovementValidator itself, not
just its hash, and it was an operator-typed `cardano.tm_script_cbor` — the last
hand-copied artifact on the posting path and the only one whose absence was
silent. A node without it passed all eight startup checks, took a full turn in
a signing ceremony, and met the gap at the mint; on the shared preprod bridge
that cost five batch opportunities, because the leader cascade walked all three
SPOs onto the same missing value and the Bitcoin transaction they had already
broadcast then sat unconfirmed, reading as "a movement is still in flight". A
mistyped value fails later and worse. heimdall cannot compile the script, but
it does not have to: Config #5 names it and the chain holds it, so
`publish::resolve_tm_script` fetches it and `bf_http::fetch_script_cbor`
refuses any bytes whose blake2b224(0x03 || cbor) is not the hash they were
fetched by — one digest proving both the bytes and the Plutus version. The key
is refused at load, new preflight step 9 ("post a movement") reports the
capability, and a Fail there stops the node instead of letting it sign what it
cannot post. The batch byte budget reads the script's serialised_size the same
way and refuses the batch rather than assuming an envelope 4 KB too small.

A script is on chain only once something uses it, so `deploy-script-refs` in
binocular now publishes treasury_movement at deployment — the one entry in that
list published for its existence rather than to shrink a transaction, without
which the bridge's first movement would need the script in order to make the
transaction that would publish it. heimdall still passes the script inline and
charges its bytes honestly; spending the reference output instead would save
~4 KB a movement, but whether a node inlines or references changes the byte
budget, and a budget that depends on what each node found on chain is a
consensus value decided per node.
@rssh
rssh merged commit 4d40f8f into main Aug 21, 2026
3 checks passed
@rssh
rssh deleted the feat/wi-hj1n5-chain-sourced-tm-script branch August 21, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant