Repository navigation
feat: source the TM validator from the chain, and refuse to start without it - #77
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withThe 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_cborfetches/scripts/{hash}/cborand refuses any bytes whoseblake2b224(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_cborjoins the retired keys and is refused at load, naming Config docs: FaultProof token-name / ban-policy divergence (technical_questions §5, WI-018) #5.FAILstops 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.serialised_sizefrom 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-refspublishestreasury_movementat 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, whichpost_tm_envelopealready 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_cborset 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}.tomlbefore 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-treasuryreporting the treasury's ownY_51instead of a demo-derived one.cargo test: 733 lib + all integration green.cargo fmt --check,cargo clippyclean.