Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions DecisionsLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -1078,3 +1078,42 @@ they now do.
Test note: `fast_config` gives every node a temp state_dir, unique per CALL.
Keying it on the identifier alone made `full_cycle_2_of_2` and
`full_cycle_3_of_3` share node 1's trie under the parallel runner.

**DEC-040: the TM validator comes from the chain, not from the config file;
`tm_script_cbor` is refused.** DEC-036 made the real TreasuryMovementValidator
mandatory for posting, and left it as an operator-typed CBOR string - 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 discovered the gap at the mint. On the shared preprod
bridge on 2026-08-20 that cost five batch opportunities: the leader cascade
walked all three SPOs onto the same missing value, and the Bitcoin transaction
they had already broadcast then sat unconfirmed, which reads as "a movement is
still in flight" and skips every batch behind it. A mistyped value fails later
and worse - after the ceremony, on chain, under a policy nothing scans.

heimdall cannot compile the script (it is Scalus, it lives in binocular, it is
parameterized per bridge instance), but it does not have to: Config #5 names it
and the chain holds it. `publish::resolve_tm_script` fetches
`/scripts/{hash}/cbor` and `bf_http::fetch_script_cbor` refuses any bytes whose
`blake2b224(0x03 || cbor)` is not the hash they were fetched by - which proves
the bytes AND the Plutus version in one digest, so there is nothing left for a
provider to get wrong. Startup does this before the daemon runs, preflight step
9 ("post a movement") reports it, 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 rather than measuring an operator's string, and
refuses the batch if it cannot - an assumed envelope understates by ~4 KB, in
the direction that builds a movement no co-signer reproduces.

A script exists on chain only once something uses it, so the first movement
would need the script to make the transaction that would publish it. binocular
`deploy-script-refs` therefore publishes `treasury_movement` as a CIP-33
reference output at deployment - the only entry in that list published for its
existence rather than to shrink a transaction. Re-running it against an older
bridge is the migration, since it skips what is already deployed.

heimdall still passes the script INLINE (`ProvidedScriptSource`) and charges
its bytes to the envelope. 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, and not this change.
8 changes: 5 additions & 3 deletions deploy/debian/heimdall.toml
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,11 @@ stake_source = "blockfrost"
#config_nft_policy_id = ""
#config_nft_asset_name = ""

# The treasury movement validator (PlutusV3 CBOR hex). Not an identifier: posting
# a TM needs the compiled script, which the Config publishes only the hash of.
#tm_script_cbor = ""
# The treasury movement validator is NOT a key. Posting a TM needs the compiled
# script and the Config publishes only its hash — so the node fetches the script
# from the chain by that hash (#5) and refuses bytes that do not hash back to it.
# A `tm_script_cbor` key is refused at load: an unset one used to let a node pass
# every check, sign a whole movement, and only then fail to post it.

# On-chain SPO registry. LEAVE ALL THREE UNSET on any bridge whose Config
# publishes the registry identity (#9-#10) — the node reads the registry
Expand Down
34 changes: 22 additions & 12 deletions docs/operator-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ sudo -u heimdall heimdall doctor --config /etc/heimdall/heimdall.toml
Run it as the `heimdall` user: the config is `0640 root:heimdall` so you cannot read it as
yourself, and running as root would leave root-owned files in the state directory.

This runs eight startup checks and prints all of them with the exact command that fixes each one,
This runs nine startup checks and prints all of them with the exact command that fixes each one,
then exits non-zero if any failed. It reads the chain and **posts nothing** — a missing reference
script and an unregistered SPO are both reported, never deployed or registered for you.

Expand All @@ -302,14 +302,15 @@ other direction, if you would rather not type a second command name. When someth
later, `heimdall doctor` is the first output to capture.

```
[1/8] local preflight PASS mnemonic from $HEIMDALL_MNEMONIC; bifrost identity key loaded
[2/8] cardano connectivity PASS https://cardano-preprod.blockfrost.io/api/v0 answering, epoch 306
[3/8] resolve the Config PASS 2dce4027…#0 (12 fields, fee_rate 1 sat/vB)
[4/8] reference script …
[5/8] ban list PASS roster is ban-filtered against addr_test1… — published by the bridge Config (detection only)
[6/8] registration status …
[7/8] key handoff (Update-Y) …
[8/8] federation identity PASS Y_fed 37b381ac…, csv 144 blocks — published in the Config datum
[1/9] local preflight PASS mnemonic from $HEIMDALL_MNEMONIC; bifrost identity key loaded
[2/9] cardano connectivity PASS https://cardano-preprod.blockfrost.io/api/v0 answering, epoch 306
[3/9] resolve the Config PASS 2dce4027…#0 (12 fields, fee_rate 1 sat/vB); peg-in requests at addr_test1…
[4/9] reference script …
[5/9] ban list PASS roster is ban-filtered against addr_test1… — published by the bridge Config (detection only)
[6/9] registration status …
[7/9] key handoff (Update-Y) …
[8/9] federation identity PASS Y_fed 37b381ac…, csv 144 blocks — published in the Config datum
[9/9] post a movement PASS TM validator f691433e… on chain, 4032 bytes, verified against Config #5
```

Step 3's field count is the datum's, and **more than twelve is normal** — the Config grows by
Expand All @@ -322,6 +323,14 @@ registry is configured without a ban list, since that node could not agree with
in the DKG.
Step 6 tells you whether this node is registered; it never spends — it names the command and stops.

Step 9 asks the question the rest of the report does not: **can this node actually post the
movement it would sign?** Minting the TM NFT needs the treasury-movement validator itself, not
just its hash, and the node fetches it from the chain by the hash the Config publishes (#5),
refusing any bytes that do not hash back to it. Nothing here is yours to configure — that is the
point. It used to be a CBOR string pasted into the config file, and a node missing it passed every
other check, took a full turn in a signing ceremony, and failed at the mint, having already
broadcast the Bitcoin transaction. That is why this one is a **FAIL** and not a warning.

Only `FAIL` blocks startup. A `WARN` is worth reading, and steps 4 and 7 are the two you will most
often see one on:

Expand Down Expand Up @@ -544,7 +553,7 @@ sudo -u heimdall heimdall show-roster --config /etc/heimdall/heimdall.toml
```

Read-only. Your pool id and `bifrost_url` should appear. Re-running the step-4 check now should
show `[6/8] registration status` satisfied.
show `[6/9] registration status` satisfied.

Before you register, that step FAILS and the daemon refuses to start. That is expected, not a
misconfiguration: an unregistered node is in no roster and would contribute nothing, so it says so
Expand Down Expand Up @@ -714,8 +723,9 @@ Do not expose your Blockfrost credentials, your config file, or `/var/lib/heimda
| the service will not start | `journalctl -u heimdall -p err`, then re-run the step-4 check — it names the failing check and what to fix |
| starts, then nothing happens for days | expected; see *Quiet is normal* |
| peers seem not to see you | step 5 — is the registered port open and reachable *from outside*? |
| `[3/8] resolve the Config FAIL` | the node cannot read the bridge Config — check `config_address`, `config_nft_policy_id` and your provider |
| `[6/8] registration status FAIL` on a fresh install | expected, and not a misconfiguration — you have not registered yet. Step 6 prints the `register-spo` command. (If you *have* registered, `[bifrost].skey_path` points at a different key than the one you registered.) |
| `[3/9] resolve the Config FAIL` | the node cannot read the bridge Config — check `config_address`, `config_nft_policy_id` and your provider |
| `[6/9] registration status FAIL` on a fresh install | expected, and not a misconfiguration — you have not registered yet. Step 6 prints the `register-spo` command. (If you *have* registered, `[bifrost].skey_path` points at a different key than the one you registered.) |
| `[9/9] post a movement FAIL` | this bridge has never published its treasury-movement validator on chain, so no SPO can post — `binocular deploy-script-refs`, re-run, publishes it and skips what already exists. Not something one operator's config can fix |
| a key you set is `refused` at load | it names a value the Config publishes; delete it, and `show-config-params` prints what the chain says |
| `trie diverged` or `trie is out of sync with the chain` | this node's cumulative state is behind the bridge's — run the `reconstruct-…` command the message names; it rebuilds from chain history and refuses anything it cannot explain |
| a transaction is refused | read the whole message: the min-stake gate and the preflight both refuse loudly rather than submitting something wrong |
Expand Down
10 changes: 5 additions & 5 deletions heimdall.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ submit_oracle = true # publish oracle-update UTxO to Cardano after signing
# config_address = "addr_test1..." # the config script address
# config_nft_policy_id = "<config NFT policy, 56 hex>"
# config_nft_asset_name = "424946434647" # "BIFCFG" hex
# REQUIRED for posting a TM: the TreasuryMovementValidator CBOR (from `binocular tm-script`),
# alongside the config_* fields above. Its hash must equal the published Config #5.
# Minting is permissionless: the redeemer names the bridge-state singleton reference
# input, and the validator checks the posted TM spends the singleton's head.
# tm_script_cbor = "<cbor from `binocular tm-script`>"
# Posting a TM needs the TreasuryMovementValidator itself, not just its hash. It is
# NOT a key: the node fetches the script from the chain by Config #5 and refuses any
# bytes that do not hash back to it (WI-HJ1N5). Minting is permissionless — the
# redeemer names the bridge-state singleton reference input, and the validator checks
# the posted TM spends the singleton's head.

[http]
# bind_address is the local interface; 0.0.0.0 by default, because peers must
Expand Down
112 changes: 112 additions & 0 deletions src/cardano/bf_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,74 @@ pub async fn fetch_address_utxos(
Ok(all)
}

/// The Plutus V3 script bytes for `script_hash`, from `/scripts/{hash}/cbor`,
/// **verified against the hash that asked for them**.
///
/// This is what lets a script the node cannot compile be sourced from the chain
/// instead of pasted into a config file (WI-HJ1N5). The verification is not a
/// nicety, it is the entire safety argument: `script_hash_v3` digests
/// `0x03 || bytes`, so a matching digest proves both that these are the bytes
/// the bridge named 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 type.
///
/// `Ok(None)` means the provider does not know the script — on a bridge whose
/// deployment published it as a reference script that means the wrong hash or
/// the wrong network, and it is distinguished from a transport failure because
/// only one of the two is repaired by editing a config file.
pub async fn fetch_script_cbor(
base_url: &str,
project_id: &str,
script_hash: &str,
) -> Result<Option<String>, String> {
let url = format!("{base_url}/scripts/{script_hash}/cbor");
let resp = reqwest::Client::new()
.get(&url)
.header("project_id", project_id)
.send()
.await
.map_err(|e| format!("script cbor request: {e}"))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !resp.status().is_success() {
return Err(format!(
"script cbor http {}: {}",
resp.status(),
resp.text().await.unwrap_or_default()
));
}
let v: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("script cbor json: {e}"))?;
// Blockfrost answers `{"cbor": null}` for a native script, which has no
// Plutus bytes at all — the same shape as a missing field, and neither is an
// answer we can use.
let cbor = v
.get("cbor")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("script {script_hash}: no cbor in the provider's answer"))?
.to_string();
verified_script_cbor(script_hash, cbor).map(Some)
}

/// The safety property of [`fetch_script_cbor`], separated from the transport so
/// it can be tested without one: bytes are only ever returned when they hash
/// back to the identifier that asked for them.
fn verified_script_cbor(script_hash: &str, cbor: String) -> Result<String, String> {
let bytes =
hex::decode(&cbor).map_err(|e| format!("script {script_hash}: cbor is not hex: {e}"))?;
let got = hex::encode(crate::cardano::blueprint::script_hash_v3(&bytes));
if !got.eq_ignore_ascii_case(script_hash) {
return Err(format!(
"script {script_hash}: the provider returned {} bytes hashing to {got} — refusing \
them. blake2b224(0x03 || cbor) must equal the hash they were fetched by",
bytes.len()
));
}
Ok(cbor)
}

/// `serialised_size` (bytes) of an on-chain script, from `/scripts/{hash}` —
/// the input to the Conway ref-script fee when a ref-script UTxO must be spent.
pub async fn fetch_script_size(
Expand Down Expand Up @@ -863,3 +931,47 @@ pub async fn fetch_cost_models(base_url: &str, project_id: &str) -> Result<Vec<V
}
Ok(out)
}

#[cfg(test)]
mod tests {
use super::*;

/// A Plutus script the node cannot compile is only usable if the bytes can be
/// checked, and the check is the whole reason `cardano.tm_script_cbor` could
/// be deleted rather than merely validated (WI-HJ1N5). `script_hash_v3`
/// digests `0x03 || bytes`, so a matching digest proves the bytes AND the
/// language version at once — there is nothing left for a provider to get
/// wrong and nothing an operator has to be trusted to paste.
#[test]
fn script_bytes_are_only_accepted_under_the_hash_that_asked_for_them() {
let bytes = [0x59u8, 0x01, 0x02, 0xde, 0xad, 0xbe, 0xef];
let hash = hex::encode(crate::cardano::blueprint::script_hash_v3(&bytes));
let cbor = hex::encode(bytes);

assert_eq!(
verified_script_cbor(&hash, cbor.clone()).unwrap(),
cbor,
"the bytes that hash to the identifier are the ones to use"
);

// One flipped byte is a different script — and it is the case that
// matters, because a wrong-but-well-formed validator mints under a policy
// nothing on this bridge scans, discovered only after a ceremony is spent.
let mut tampered = bytes;
tampered[4] ^= 0x01;
let err = verified_script_cbor(&hash, hex::encode(tampered))
.expect_err("bytes that hash elsewhere must be refused");
assert!(err.contains(&hash), "{err}");

// The hash cannot vouch for something that is not bytes at all.
assert!(
verified_script_cbor(&hash, "not hex".into()).is_err(),
"unparseable cbor must be refused, not hashed"
);

// Upper-case is the same identifier: Blockfrost and the Config datum
// render hex differently, and refusing on case alone would read to an
// operator exactly like a wrong bridge.
assert!(verified_script_cbor(&hash.to_uppercase(), cbor).is_ok());
}
}
20 changes: 13 additions & 7 deletions src/cardano/blockfrost_chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -634,9 +634,11 @@ pub struct BlockfrostCardanoChain {
/// Resolved Blockfrost base URL + project id, for raw-HTTP UTxO queries (lenient parsing).
bf_base_url: String,
bf_project_id: String,
/// TreasuryMovementValidator CBOR (`binocular tm-script`). When set, the TM NFT is minted under
/// this policy (and `treasury_policy_id` must be its hash, `treasury_asset_name_hex` empty); else
/// the always-ok scaffold is used.
/// TreasuryMovementValidator CBOR, chain-sourced by the hash the Config
/// publishes (#5) and verified against it — see `publish::resolve_tm_script`.
/// `treasury_policy_id` is that same hash and `treasury_asset_name_hex` is
/// empty. `None` means this chain cannot post a TM, which the startup gate
/// refuses on a configured bridge (WI-HJ1N5).
tm_script_cbor: Option<String>,
/// Validity window (seconds) for posted TM txs (`invalid_hereafter`/`created` =
/// latest + window). 1800 for preprod/mainnet; small on a short-epoch devnet whose
Expand Down Expand Up @@ -919,8 +921,9 @@ impl BlockfrostCardanoChain {
self
}

/// Mint the TM NFT under the real TreasuryMovementValidator policy (CBOR from
/// `binocular tm-script`). Without this the always-ok scaffold policy is used.
/// Mint the TM NFT under the real TreasuryMovementValidator policy. The CBOR
/// comes from `publish::resolve_tm_script` — the chain, by Config #5 — so
/// nothing here has to trust a value an operator typed.
pub fn with_tm_policy(mut self, script_cbor: &str) -> Self {
self.tm_script_cbor = Some(script_cbor.to_string());
self
Expand Down Expand Up @@ -2469,8 +2472,11 @@ impl CardanoChain for BlockfrostCardanoChain {
// under anything else lands at an address nothing scans).
let tm_script_cbor = self.tm_script_cbor.as_deref().ok_or_else(|| {
EpochError::Chain(
"cardano.tm_script_cbor not set (from `binocular tm-script`) — required to \
mint the TM NFT under the real TreasuryMovementValidator policy"
"no TM validator on this chain adapter — required to mint the TM NFT under the \
real TreasuryMovementValidator policy. It is sourced from the chain by Config \
#5 at startup, and preflight step 9 refuses to start a bridge node without \
it, so reaching here means this adapter was built off a path that skipped \
both"
.into(),
)
})?;
Expand Down
Loading
Loading