diff --git a/DecisionsLog.md b/DecisionsLog.md index 1748582..29e4cde 100644 --- a/DecisionsLog.md +++ b/DecisionsLog.md @@ -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. diff --git a/deploy/debian/heimdall.toml b/deploy/debian/heimdall.toml index a5fc4f6..07bf0b3 100644 --- a/deploy/debian/heimdall.toml +++ b/deploy/debian/heimdall.toml @@ -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 diff --git a/docs/operator-guide.md b/docs/operator-guide.md index c67a117..7f6389a 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -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. @@ -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 @@ -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: @@ -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 @@ -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 | diff --git a/heimdall.toml b/heimdall.toml index e0606d3..498d3ed 100644 --- a/heimdall.toml +++ b/heimdall.toml @@ -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_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 = "" +# 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 diff --git a/src/cardano/bf_http.rs b/src/cardano/bf_http.rs index e495212..ab38f75 100644 --- a/src/cardano/bf_http.rs +++ b/src/cardano/bf_http.rs @@ -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, 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 { + 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( @@ -863,3 +931,47 @@ pub async fn fetch_cost_models(base_url: &str, project_id: &str) -> Result, /// Validity window (seconds) for posted TM txs (`invalid_hereafter`/`created` = /// latest + window). 1800 for preprod/mainnet; small on a short-epoch devnet whose @@ -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 @@ -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(), ) })?; diff --git a/src/cardano/publish.rs b/src/cardano/publish.rs index 31accb5..f76cf1e 100644 --- a/src/cardano/publish.rs +++ b/src/cardano/publish.rs @@ -19,7 +19,9 @@ //! UTxO rides along as a second reference input (the validator reads //! `bridge_state_policy` from it, [PAR-1]), and the validator checks //! the embedded BTC tx spends the singleton's `treasury_utxo_id`. -//! There is no scaffold fallback: `tm_script_cbor` is required. +//! There is no scaffold fallback: the real validator is required, and +//! [`resolve_tm_script`] sources it from the chain by the hash the Config +//! publishes. //! //! The current treasury is the singleton's head — no Confirmed chain //! walk exists any more. @@ -36,6 +38,48 @@ use crate::cardano::tx_common::whisky_network; use crate::cardano::wallet::pub_key_hash_hex; use crate::epoch::state::{EpochError, EpochResult}; +/// The TreasuryMovementValidator bytes for this bridge, sourced from the chain +/// by the hash the Config publishes (#5, `tm_script_hash`). +/// +/// Minting the TM NFT needs the minting script itself, and heimdall cannot +/// compile it: it is Scalus, it lives in binocular, and it is parameterized per +/// bridge instance. It used to be an operator-typed `cardano.tm_script_cbor`, +/// which is the worst shape a must-match value can take — unset it and the +/// node passes every check, runs a whole signing ceremony, +/// and only then discovers it cannot post; mistype it and the mint fails on +/// chain after the ceremony is already spent. Both cost a batch opportunity, and +/// on the shared preprod bridge the first cost five (WI-HJ1N5). +/// +/// So the node reads it the way it reads everything else about the bridge: from +/// the chain, by an identifier the Config publishes, verified against that +/// identifier. `fetch_script_cbor` does the verifying, which is why nothing here +/// has to trust the provider. +/// +/// The bytes are on chain because `binocular deploy-script-refs` publishes the +/// TM validator as a reference script at deployment — before any TM exists, so +/// there is no first-movement chicken-and-egg. (Heimdall still rides them INLINE +/// in the Post-TM witness set; using the reference UTxO instead would save ~4 KB +/// per movement, but which of the two a node does changes the batch byte budget, +/// and a budget that depends on what each node happened to find is a consensus +/// value decided per node. That is a bridge-wide switch, not this change.) +pub async fn resolve_tm_script( + base_url: &str, + project_id: &str, + tm_script_hash: &str, +) -> Result { + crate::cardano::bf_http::fetch_script_cbor(base_url, project_id, tm_script_hash) + .await? + .ok_or_else(|| { + format!( + "the TM validator {tm_script_hash} (Config #5) is not on chain as far as this \ + provider can see, so this node cannot mint the TM NFT and cannot post a \ + movement it signs. `binocular deploy-script-refs` publishes it at deployment \ + and skips whatever is already deployed, so re-running it against this bridge \ + is the repair" + ) + }) +} + /// A wallet UTxO fetched from Blockfrost, suitable for coin selection. #[derive(Debug, Clone)] pub struct WalletUtxo { diff --git a/src/config.rs b/src/config.rs index 0433a76..7061808 100644 --- a/src/config.rs +++ b/src/config.rs @@ -445,11 +445,6 @@ pub struct CardanoConfig { /// Whether to publish an oracle-update UTxO to Cardano after signing. /// Requires `blockfrost_project_id` and `mnemonic`. Default: true. pub submit_oracle: bool, - /// TreasuryMovementValidator CBOR (from `binocular tm-script`). REQUIRED for posting a - /// TM (with the `config_*` fields below): the TM NFT is only ever minted under the real - /// validator policy — `treasury_policy_id` must be the validator's script hash and - /// `treasury_asset_name` empty. The always-ok scaffold fallback is gone. - pub tm_script_cbor: Option, /// Validity window (seconds) for posted TM txs (`invalid_hereafter`/`created` = latest + /// window). `None` → 1800 (preprod/mainnet). MUST be small (e.g. 90) on a short-epoch /// devnet, whose era-forecast horizon is only ~tens-to-hundreds of slots ahead — a large @@ -465,8 +460,8 @@ pub struct CardanoConfig { /// is what moves a node off its local `bitcoin.fee_rate_sat_per_vb` and onto the value its /// co-signers use — see `cardano::config_params` and `show-config-params`. pub config_address: Option, - /// Config NFT policy id (56 hex chars) locating the Config UTxO. Required alongside - /// `tm_script_cbor` and `config_address`. + /// Config NFT policy id (56 hex chars) locating the Config UTxO. Required + /// alongside `config_address`. pub config_nft_policy_id: Option, /// Config NFT asset name (hex). Required alongside `config_nft_policy_id`. pub config_nft_asset_name: Option, @@ -529,7 +524,6 @@ impl Default for CardanoConfig { stake_source: None, demo_exclude_unstaked: false, submit_oracle: true, - tm_script_cbor: None, tm_validity_window_secs: None, config_address: None, config_nft_policy_id: None, @@ -799,6 +793,17 @@ const RETIRED_KEYS: &[RetiredKey] = &[ "ban_bootstrap", "Config #12 (federation_one_shot) — the same outpoint again", ), + // WI-HJ1N5: the TM validator's own bytes. The one artifact left on the + // posting path that an operator pasted in, and the one whose absence was + // silent — a node without it passed all 8 preflight steps, ran a full signing + // ceremony, and only then could not post the movement it had just signed. + // Config #5 names the script and the chain holds it, so nothing is typed. + ( + "cardano", + "tm_script_cbor", + "Config #5 (tm_script_hash) — the validator is fetched from the chain by that hash and \ + refused unless blake2b224(0x03 || cbor) equals it", + ), // WI-070: the bridge's own identifiers. Every one was an operator-typed copy // of a Config field, and a copy that can disagree is the whole defect — a // mistyped script hash yields a well-formed address holding nothing, which @@ -1153,6 +1158,25 @@ mod tests { } } + /// The TM validator's bytes were the last artifact on the posting path that + /// an operator pasted in, and the only one whose absence was SILENT: a node + /// without it passed every startup check, ran a whole signing ceremony, and + /// failed at the mint — after the batch opportunity was spent and the Bitcoin + /// transaction was already broadcast. Refusing the key is how an operator who + /// upgrades into this learns their pasted copy is no longer what the node + /// uses; the node fetches the script by Config #5 and verifies it. + #[test] + fn the_retired_tm_script_cbor_is_refused_and_points_at_the_chain() { + let err = HeimdallConfig::from_toml_str("[cardano]\ntm_script_cbor = \"59ab\"\n") + .expect_err("a retired key must be refused, not ignored"); + let rendered = format!("{err}"); + assert!(rendered.contains("tm_script_cbor"), "{rendered}"); + // Naming the field is what makes the diagnostic actionable: the operator + // has to know the value still exists, just not here. + assert!(rendered.contains("#5"), "{rendered}"); + assert!(rendered.contains("tm_script_hash"), "{rendered}"); + } + /// WI-071's key is in `[protocol]`, and what replaced it is a compiled-in /// constant rather than a Config field — so this also pins that the refusal /// is section-aware and does not claim everything is published. diff --git a/src/main.rs b/src/main.rs index d23dcb3..4d15e87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1851,13 +1851,21 @@ fn main() { } } -/// Apply the TM-NFT minting policy and the Config-UTxO locator to the chain. Requires -/// `cardano.tm_script_cbor`, `cardano.config_address` and `cardano.config_nft_policy_id` to be -/// configured together — posting has no scaffold fallback, and the Config UTxO locates the -/// bridge-state singleton the mint redeemer links against. Errors on a half-configured set. -fn apply_tm_policy( +/// Apply the TM-NFT minting policy and the Config-UTxO locator to the chain. +/// +/// The minting script is the TreasuryMovementValidator, fetched from the chain by +/// `tm_script_hash` (Config #5) and verified against it — see +/// [`heimdall::cardano::publish::resolve_tm_script`]. It was an operator-typed +/// `cardano.tm_script_cbor` until WI-HJ1N5; nothing is typed now, and a node that +/// cannot resolve it refuses to start rather than discovering it after a ceremony. +/// +/// `cardano.config_address` and `cardano.config_nft_policy_id` must still be +/// configured together — the Config UTxO locates the bridge-state singleton the +/// mint redeemer links against. Errors on a half-configured set. +async fn apply_tm_policy( chain: BlockfrostCardanoChain, cfg: &HeimdallConfig, + tm_script_hash: &str, ) -> Result { // The Config-UTxO locator is needed by query_treasury (the singleton read) // independently of posting, so apply it whenever configured. @@ -1880,20 +1888,25 @@ fn apply_tm_policy( ); } }; - match &cfg.cardano.tm_script_cbor { - Some(cbor) => { - if cfg.cardano.config_address.is_none() { - return Err( - "cardano.tm_script_cbor requires cardano.config_address and \ - cardano.config_nft_policy_id (the mint redeemer links against the config \ - anchor)" - .into(), - ); - } - Ok(chain.with_tm_policy(cbor)) - } - None => Ok(chain), + // No Config locator means no bridge to post to — the fixture/demo shape. + // Fetching a validator there would be a network call in service of a + // transaction that has nowhere to go. + let Some(project_id) = cfg.cardano.blockfrost_project_id.as_deref() else { + return Ok(chain); + }; + if cfg.cardano.config_address.is_none() { + return Ok(chain); } + let base_url = + heimdall::cardano::bf_http::base_url(project_id, cfg.cardano.blockfrost_url.as_deref()); + let cbor = heimdall::cardano::publish::resolve_tm_script(&base_url, project_id, tm_script_hash) + .await?; + info!( + "[config] TM validator {tm_script_hash} ({} bytes) sourced from the chain and verified \ + against Config #5", + cbor.len() / 2 + ); + Ok(chain.with_tm_policy(&cbor)) } async fn run_spo( @@ -2041,6 +2054,16 @@ async fn run_spo( // authenticated source, so the half-configured daemon this used to guard // against (peg-out-capable in its own report, blind in practice) can no // longer be expressed. + // The peg-in side of the line below. Both addresses come from the same + // Config record, and only one of them used to be reported — so a node + // scanning an unexpected peg-in address looked identical to one with + // nothing to sweep, and "0 eligible peg-ins" was the only symptom either + // way. That is the state to be able to tell apart at a glance: this is + // where deposits are found, and the policy is what a request NFT carries. + info!( + "peg-in requests: {} (policy {})", + bridge.pegin_script_address, bridge.pegin_policy_id + ); info!("peg-out requests: {}", bridge.pegout_script_address); bf_chain = bf_chain.with_pegout_source(&bridge.pegout_script_address, &bridge.bridged_token_unit); @@ -2171,7 +2194,17 @@ async fn run_spo( } } - let bf_chain = apply_tm_policy(bf_chain, &cfg).expect("invalid TM policy config"); + let bf_chain = match apply_tm_policy(bf_chain, &cfg, &bridge.tm_policy_id).await { + Ok(c) => c, + // Fatal, and deliberately so: this node would run every check green, + // take part in a whole signing ceremony, and only then fail to post + // the movement it just helped sign — spending a batch opportunity to + // learn something knowable at boot (WI-HJ1N5). + Err(e) => { + error!("Error: {e}"); + std::process::exit(1); + } + }; // Everything resolved above is what THIS PROCESS SAW AT BOOT. The // federation identity is chain state a governance Update can move, so @@ -2740,26 +2773,41 @@ struct SweepBatch { /// The batch byte budget for the CLI drivers, mirroring /// `BlockfrostChain::post_tm_envelope` / `query_batch_snapshot`. /// -/// `loc` is `None` when there is no Config UTxO to read, in which case +/// `bridge` is `None` when there is no Config UTxO to read, in which case /// `max_tx_size` falls back to the post-Alonzo value and says so — the same -/// local-override shape the fee parameters already take on that path. +/// local-override shape the fee parameters already take on that path — and no TM +/// validator rides inline because there is no bridge to post to. fn sweep_budget( rt: &tokio::runtime::Runtime, - cfg: &HeimdallConfig, - loc: Option<&ConfigLocator>, -) -> heimdall::epoch::batch::TmBudget { + bridge: Option<(&ConfigLocator, &str)>, +) -> Result { use heimdall::epoch::traits::{DEFAULT_MAX_TX_SIZE, POST_TM_ENVELOPE_WITHOUT_SCRIPT}; // The TM validator rides inline in every Post-TM, so its bytes are the // dominant non-batch term and are charged honestly rather than assumed away. - let envelope = POST_TM_ENVELOPE_WITHOUT_SCRIPT - + cfg - .cardano - .tm_script_cbor - .as_deref() - .map_or(0, |hex| (hex.len() as u64) / 2 + 8); - let max_tx_size = loc - .and_then(|l| { + // Its SIZE is asked of the chain directly (`/scripts/{hash}` carries + // `serialised_size`), which is cheaper than fetching the bytes and is the + // same number every SPO reads — the property a batch budget needs. + // + // An error here refuses the batch for the same reason `max_tx_size` does: an + // assumed envelope is an assumed CONSENSUS value, and assuming the script + // away OVERSTATES capacity by ~4 KB, which is the direction that builds a + // movement no co-signer will reproduce. + let script_bytes = match bridge { + Some((l, hash)) => { + rt.block_on(heimdall::cardano::bf_http::fetch_script_size( + &l.base_url, + &l.project_id, + hash, + )) + .map_err(|e| format!("TM validator {hash} (Config #5): {e}"))? + + 8 + } + None => 0, + }; + let envelope = POST_TM_ENVELOPE_WITHOUT_SCRIPT + script_bytes; + let max_tx_size = bridge + .and_then(|(l, _)| { let epoch = rt .block_on(heimdall::cardano::bf_http::fetch_current_epoch( &l.base_url, @@ -2782,11 +2830,11 @@ fn sweep_budget( ); DEFAULT_MAX_TX_SIZE }); - heimdall::epoch::batch::TmBudget { + Ok(heimdall::epoch::batch::TmBudget { max_tx_size, envelope, variant: heimdall::epoch::batch::SpendVariant::KeyPath, - } + }) } fn batch_params( @@ -2804,7 +2852,7 @@ fn batch_params( now_ms: None, tip: None, batch: heimdall::epoch::batch::BatchWindow::NoGrid, - budget: sweep_budget(rt, cfg, None), + budget: sweep_budget(rt, None)?, }); }; let snapshot = rt.block_on(fetch_param_snapshot( @@ -2849,7 +2897,10 @@ fn batch_params( now_ms: Some(time_ms), tip: Some((snapshot.slot, time_ms)), batch, - budget: sweep_budget(rt, cfg, Some(&loc)), + budget: sweep_budget( + rt, + Some((&loc, &hex::encode(snapshot.config.params.tm_script_hash))), + )?, }) } @@ -2984,6 +3035,29 @@ fn assert_mover_key_owns_treasury( cfg: &HeimdallConfig, y_51: bitcoin::key::UntweakedPublicKey, ) -> Result<(), String> { + let Some(authorized) = treasury_authorized_key(rt, cfg, "mover")? else { + return Ok(()); + }; + if authorized == y_51 { + return Ok(()); + } + Err(mover_key_mismatch_error(y_51, authorized)) +} + +/// The x-only key the `treasury_info` datum currently authorizes — the key the +/// treasury is actually locked under, and the one a mover's signer is chosen by. +/// +/// `None` means there is no readable datum: a fixture/mock deployment has no +/// `treasury_info` at all, and that is the one configuration where a demo key IS +/// the treasury's. Every read failure is reported through `who` and answers +/// `None` rather than an error, because both callers degrade rather than abort. +/// `Err` is reserved for a datum that IS readable but holds something that is not +/// a key — that is corruption, not absence. +fn treasury_authorized_key( + rt: &tokio::runtime::Runtime, + cfg: &HeimdallConfig, + who: &str, +) -> Result, String> { use heimdall::cardano::roster::RegistryRosterSource; let config = rt.block_on(config_view_async(cfg)).ok().flatten(); @@ -2991,10 +3065,10 @@ fn assert_mover_key_owns_treasury( RegistryRosterSource::resolve(&cfg.cardano, config.as_ref().map(|v| &v.params)) else { info!( - " [mover] no on-chain treasury_info to check the signing key against — assuming the \ + " [{who}] no on-chain treasury_info to read the treasury's key from — assuming the \ fixture deployment, where the demo key IS the treasury's" ); - return Ok(()); + return Ok(None); }; let utxos = match rt.block_on(heimdall::cardano::bf_http::fetch_address_utxos( &heimdall::cardano::bf_http::base_url( @@ -3006,8 +3080,8 @@ fn assert_mover_key_owns_treasury( )) { Ok(u) => u, Err(e) => { - warn!(" [mover] could not read treasury_info to check the signing key: {e}"); - return Ok(()); + warn!(" [{who}] could not read treasury_info: {e}"); + return Ok(None); } }; let state = match heimdall::cardano::treasury_spend::find_treasury_state( @@ -3017,23 +3091,18 @@ fn assert_mover_key_owns_treasury( ) { Ok(s) => s, Err(e) => { - warn!(" [mover] could not locate the treasury_info state: {e}"); - return Ok(()); + warn!(" [{who}] could not locate the treasury_info state: {e}"); + return Ok(None); } }; - let authorized = bitcoin::key::UntweakedPublicKey::from_slice( - &state.datum.current_spos_frost_key, - ) - .map_err(|e| { - format!( - "treasury_info current_spos_frost_key ({}) is not an x-only key: {e}", - hex::encode(&state.datum.current_spos_frost_key) - ) - })?; - if authorized == y_51 { - return Ok(()); - } - Err(mover_key_mismatch_error(y_51, authorized)) + bitcoin::key::UntweakedPublicKey::from_slice(&state.datum.current_spos_frost_key) + .map(Some) + .map_err(|e| { + format!( + "treasury_info current_spos_frost_key ({}) is not an x-only key: {e}", + hex::encode(&state.datum.current_spos_frost_key) + ) + }) } /// The refusal message, separated so a test can pin it against @@ -7212,23 +7281,41 @@ fn run_show_treasury(cfg: &HeimdallConfig) -> Result<(), String> { ); } - // Our expected treasury keys: demo Y_51 (deterministic DKG) + the federation + // The treasury's keys: Y_51 from the treasury_info datum + the federation // identity. Read-only: this reports what the treasury SHOULD look like, so it // must use the same source the mover signs against — the treasury_info datum, // not this node's local seed (WI-069). + // + // It used to print the DEMO Y_51 here (a deterministic DKG over a hardcoded + // seed) against that very comment, which on a real bridge names a key no node + // holds and derives an "expected" scriptPubKey the treasury does not have — + // stated with the same confidence as the chain-read line beside it. The demo + // key is still the answer where it IS the treasury's: a fixture deployment + // with no treasury_info datum. It just has to say which one it is. let secp = Secp256k1::new(); let federation = rt.block_on(resolve_federation(cfg))?; let y_fed = federation.y_fed; - let dkg = run_demo_dkg( - b"heimdall-demo-seed-v1-0123456789", - cfg.demo.min_signers, - cfg.demo.max_signers, - ); - let y_51 = group_xonly(dkg.public_key_package.verifying_key())?.xonly; + let (y_51, y_51_origin) = match treasury_authorized_key(&rt, cfg, "show-treasury")? { + Some(k) => (k, "from the treasury_info datum"), + None => { + let dkg = run_demo_dkg( + b"heimdall-demo-seed-v1-0123456789", + cfg.demo.min_signers, + cfg.demo.max_signers, + ); + ( + group_xonly(dkg.public_key_package.verifying_key())?.xonly, + "DEMO seed — no treasury_info datum to read", + ) + } + }; let csv = federation.csv_blocks; let expected_spk = ScriptBuf::new_p2tr_tweaked(treasury_spend_info(&secp, y_51, y_fed, csv).output_key()); - println!("our Y_51: {}", hex::encode(y_51.serialize())); + println!( + "treasury Y_51: {} ({y_51_origin})", + hex::encode(y_51.serialize()) + ); println!( "expected treasury spk: {}", hex::encode(expected_spk.as_bytes()) @@ -8005,7 +8092,7 @@ fn run_sweep_pegins( // transaction; with nothing able to broadcast, the guard has no subject. chain = chain.with_submit_config(cfg.cardano.submit_oracle); chain = chain.with_validity_window(cfg.cardano.tm_validity_window_secs.unwrap_or(1800)); - let chain = apply_tm_policy(chain, cfg)?; + let chain = rt.block_on(apply_tm_policy(chain, cfg, &bridge.tm_policy_id))?; // The data-availability hint describes the peg-outs of the tx being posted. // Under --existing-tm-hex the posted bytes are somebody ELSE'S transaction: // the locally built TM's `fulfilled` list says nothing about which requests diff --git a/src/preflight.rs b/src/preflight.rs index 8d4730a..3f9f3cf 100644 --- a/src/preflight.rs +++ b/src/preflight.rs @@ -420,6 +420,7 @@ pub async fn preflight(cfg: &HeimdallConfig) -> Report { (6, "registration status"), (7, "key handoff (Update-Y)"), (8, "federation identity"), + (9, "post a movement"), ] { b.push(n, title, Status::Skipped, "needs a Cardano provider"); } @@ -520,12 +521,27 @@ pub async fn preflight(cfg: &HeimdallConfig) -> Report { None } Ok(view) => { + // The peg-in scan address, derived from this very datum + // (#6). Reported because "0 eligible peg-ins" is the same + // line whether there are no deposits or the node is + // watching an address no depositor uses — and without the + // address in any output, telling those apart meant + // decoding the Config datum by hand. + let scanning = cfg + .cardano + .is_mainnet() + .ok() + .and_then(|m| view.params.bridge_contracts(m).ok()) + .map_or_else( + || String::from("; peg-in address underivable from this datum"), + |c| format!("; peg-in requests at {}", c.pegin_script_address), + ); b.push( 3, "resolve the Config", Status::Pass, format!( - "{} ({} fields, fee_rate {} sat/vB)", + "{} ({} fields, fee_rate {} sat/vB){scanning}", view.utxo, view.params.field_count, view.params.tunables.fee_rate_sat_per_vb @@ -921,6 +937,56 @@ pub async fn preflight(cfg: &HeimdallConfig) -> Report { } } + // ── 9. Post a movement — the capability the node exists for ────────── + // Signing a movement is only worth doing if it can be POSTED, and nothing + // here used to check that. The TM validator was an operator-typed CBOR + // string, so a node without it passed all eight steps above, took part in a + // whole signing ceremony, and met the gap only when it tried to mint. On the + // shared preprod bridge that cost five batch opportunities: the leader + // cascade walked every node 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 Fail, and fatal — unlike the reference script of step 4, which only + // `register-spo` needs. Every SPO takes a turn on the leader cascade, so a + // node that cannot post is a node whose turn is a wasted hop, and it is + // invisible from that node's own log. + match &config { + None => b.push( + 9, + "post a movement", + Status::Skipped, + "the Config did not resolve, so the TM validator has no published hash (step 3)", + ), + Some(view) => { + let hash = hex::encode(view.params.tm_script_hash); + match crate::cardano::publish::resolve_tm_script(&base_url, &project_id, &hash).await { + Ok(cbor) => b.push( + 9, + "post a movement", + Status::Pass, + format!( + "TM validator {hash} on chain, {} bytes, verified against Config #5", + cbor.len() / 2 + ), + ), + Err(e) => b.push_fix( + 9, + "post a movement", + Status::Fail, + e, + "the TM NFT can only be minted by supplying the validator, and this node \ + sources it from the chain rather than from a config file — there is \ + nothing to type here and nothing to paste. Either the provider cannot \ + answer (check step 2 and network access) or this bridge never published \ + the script, which re-running `binocular deploy-script-refs` repairs — it \ + skips what is already deployed. Until then this node can sign movements it \ + can never post", + ), + } + } + } + Report { steps: b.steps } } @@ -1103,8 +1169,9 @@ mod tests { /// "[9/7]" — and its entry for 6 carried step 7's title, so the ban-list check /// was reported as "registration status". WI-070 removed the contract-set /// check (there are no operator copies left to verify), taking the count from - /// nine to eight — which this test pins, since the early return lists the - /// steps by hand and would otherwise drift again. + /// nine to eight, and WI-HJ1N5 added "post a movement" to take it back to + /// nine — which this test pins, since the early return lists the steps by + /// hand and would otherwise drift again. #[tokio::test] async fn the_no_provider_report_accounts_for_every_step() { let cfg = HeimdallConfig::default(); @@ -1114,10 +1181,10 @@ mod tests { ); let report = preflight(&cfg).await; let numbers: Vec = report.steps.iter().map(|s| s.n).collect(); - assert_eq!(numbers, (1..=8).collect::>(), "{numbers:?}"); + assert_eq!(numbers, (1..=9).collect::>(), "{numbers:?}"); // Every rendered line's step number is within the total it prints. let total = report.steps.len(); - assert_eq!(total, 8); + assert_eq!(total, 9); for s in &report.steps { assert!(usize::from(s.n) <= total, "step {} of {total}", s.n); } @@ -1127,6 +1194,7 @@ mod tests { assert_eq!(titled[4], "ban list"); assert_eq!(titled[5], "registration status"); assert_eq!(titled[7], "federation identity"); + assert_eq!(titled[8], "post a movement"); } #[test]