From 6a164d0748a99945f3b52a746b8fe9e2d3c3bb59 Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy Date: Tue, 6 Oct 2026 18:58:29 +0800 Subject: [PATCH 1/5] =?UTF-8?q?indexer:=20fee=20feedback=20=E2=80=94=20ran?= =?UTF-8?q?d=5FgetSupply.base=5Ffees=5Fburned=20and=20rand=5FgetLimits.fee?= =?UTF-8?q?=5Frules=20kept=20and=20served;=20a=20reply=20carrying=20them?= =?UTF-8?q?=20and=20unmodelled=20fields=20beside=20them=20is=20pinned=20by?= =?UTF-8?q?=20a=20unit=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tup3MjwcZy2X6hdCRWGkFc --- crates/randscan-core/src/types/stats.rs | 26 +++++++++++ crates/randscan-indexer/src/rpc.rs | 60 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/crates/randscan-core/src/types/stats.rs b/crates/randscan-core/src/types/stats.rs index 5cb5e70..89e6be0 100644 --- a/crates/randscan-core/src/types/stats.rs +++ b/crates/randscan-core/src/types/stats.rs @@ -153,6 +153,27 @@ pub struct ChainLimits { /// where the genesis leaves both at 256. #[serde(default)] pub proof_window_blocks: Option, + /// Fee feedback (fullnode `feat/fee-feedback`, unreleased; `docs/fees.md` §1.3): the genesis + /// `fees` section's flags. `None` on a chain without the section (or none of its flags set) + /// and on a node predating the field. Informational: no fee a wallet pays changes. + #[serde(default)] + pub fee_rules: Option, +} + +/// The `fee_rules` group of `rand_getLimits` (fee feedback). Each flag reads `false` when absent, +/// so a node that adds a flag later is still read. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeeRules { + /// A bundle's `BUNDLE_BASE` is destroyed instead of paid to the proposer. + #[serde(default)] + pub burn_base: bool, + /// The aggregation subsidy carries only the minted part, the schedule's shortfall over the + /// proving share. + #[serde(default)] + pub subsidy_net_of_fees: bool, + /// The whole settled floor is burned (only ever beside `burn_base`). + #[serde(default)] + pub burn_floor: bool, } /// The `slashing` group of `rand_getLimits` (audit v6, STAKE-1). @@ -217,4 +238,9 @@ pub struct Supply { pub program_rand_out: Option, #[serde(default, deserialize_with = "crate::amount::amount_opt")] pub program_rand_held: Option, + /// Fee feedback (fullnode `feat/fee-feedback`, unreleased): every bundle base (under + /// `fees.burn_floor`, every settled floor) burned under the genesis `fees.burn_base`, inside + /// `burned`. `"0"` on a chain without the flag, `None` on a node predating the field. + #[serde(default, deserialize_with = "crate::amount::amount_opt")] + pub base_fees_burned: Option, } diff --git a/crates/randscan-indexer/src/rpc.rs b/crates/randscan-indexer/src/rpc.rs index 9de8443..e5bb8f9 100644 --- a/crates/randscan-indexer/src/rpc.rs +++ b/crates/randscan-indexer/src/rpc.rs @@ -1573,6 +1573,66 @@ mod tests { assert_eq!(back, l); } + /// Fee feedback (fullnode `feat/fee-feedback`, not yet released; its `docs/rpc.md`): + /// `rand_getSupply` gains `base_fees_burned` and `rand_getLimits` gains `fee_rules`. A reply + /// carrying them — and fields this build has never modelled, beside them — must still parse, + /// keep the new values, and round-trip through the stats row's JSONB as this crate writes it; + /// a reply from a node predating them reads as `None`. + #[test] + fn the_fee_feedback_supply_and_limits_are_tolerated_and_kept() { + let s: randscan_core::Supply = serde_json::from_str( + r#"{ "height": 812, "genesis_deposited": "1000000000000", "genesis_staked": "1000000000000", + "faucet_minted": "50000000000", "withdraw_deposited": "0", "fees_paid": "1900000", + "burned": "100000", "registration_fees_burned": "0", "base_fees_burned": "100000", + "faucet_epoch": "0", "faucet_minted_in_epoch": "50000000000", "subsidised": "0", + "vesting_issued": "0", "vesting_released": "0", "vesting_in_register": "0", "vesting_locked": "0", + "program_rand_out": "0", "program_rand_held": "0", + "pool_value": "1049998000000", "register_total": "1000001900000", + "total_supply": "2049999900000", "invariant_holds": true, + "a_counter_from_the_future": "7" }"#, + ) + .unwrap(); + assert_eq!(s.base_fees_burned.as_deref(), Some("100000")); + assert_eq!(s.burned, "100000"); + let wire = serde_json::to_value(&s).unwrap(); + assert_eq!(wire["base_fees_burned"], "100000", "GET /api/v1/supply serves it as the node did: {wire}"); + // A number on the wire is still read as its decimal text. + let s: randscan_core::Supply = serde_json::from_value({ + let mut v = wire.clone(); + v["base_fees_burned"] = serde_json::json!(5); + v + }) + .unwrap(); + assert_eq!(s.base_fees_burned.as_deref(), Some("5")); + // A node predating the field (v0.7.1). + let mut old = wire; + old.as_object_mut().unwrap().remove("base_fees_burned"); + let s: randscan_core::Supply = serde_json::from_value(old).unwrap(); + assert_eq!(s.base_fees_burned, None); + + let limits = |fee_rules: &str| -> randscan_core::ChainLimits { + serde_json::from_str(&format!( + r#"{{"adjust_bps":1250,"binding_domain":1,"bundle_gas_limit":20479,"byte_price":"800","envelope_bytes":1860, + "gas_metering":"circuit","gas_price":"100","hardening_v6":true,"hc_auth":"1e4e","max_block_bytes":20971520, + "max_call_envelope_bytes":65536,"max_program_public_words":32768,"max_program_words":65535, + "max_proof_bytes":4194304,"program_state":null,"proof_window_blocks":1024,"slashing":null,"testnet":true + {fee_rules}}}"# + )) + .unwrap() + }; + let l = limits(r#","fee_rules":{"burn_base":true,"subsidy_net_of_fees":false,"burn_floor":true,"a_flag_from_the_future":true}"#); + let rules = l.fee_rules.clone().expect("fee_rules kept"); + assert!(rules.burn_base && rules.burn_floor && !rules.subsidy_net_of_fees); + let back: randscan_core::ChainLimits = serde_json::from_value(serde_json::to_value(&l).unwrap()).unwrap(); + assert_eq!(back, l, "the limits round-trip through the stats row's JSONB"); + assert_eq!(serde_json::to_value(&l).unwrap()["fee_rules"]["burn_base"], true); + // `null` (a chain without a `fees` section) and absent (a node predating the field). + assert_eq!(limits(r#","fee_rules":null"#).fee_rules, None); + assert_eq!(limits("").fee_rules, None); + // A group a later node trims to the flags it sets still parses. + assert_eq!(limits(r#","fee_rules":{"burn_base":true}"#).fee_rules.map(|r| (r.burn_base, r.burn_floor)), Some((true, false))); + } + #[test] fn a_rotation_attestation_has_no_deposit() { let json = r#"{"kind":"bridge_attest","attestation_len":700,"recipient":"rand1x","asset":null,"asset_index":null,"amount":null,"time":3,"r":"00","commitment":null}"#; From 2d0ac907d5b5b713836cc32a46ec0749c36f479b Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy Date: Tue, 6 Oct 2026 18:58:29 +0800 Subject: [PATCH 2/5] tests: the mock node serves base_fees_burned "0" and fee_rules null, a superset of the live shape again Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tup3MjwcZy2X6hdCRWGkFc --- crates/randscan-api/tests/common/mock_node.rs | 9 +++++++-- crates/randscan-api/tests/mock_node.rs | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/randscan-api/tests/common/mock_node.rs b/crates/randscan-api/tests/common/mock_node.rs index a100be9..5d2bc9e 100644 --- a/crates/randscan-api/tests/common/mock_node.rs +++ b/crates/randscan-api/tests/common/mock_node.rs @@ -315,7 +315,10 @@ impl MockChain { "height": self.head()["height"], "genesis_deposited": "1000000000000", "genesis_staked": "100000000000000", "faucet_minted": "100000000000", "withdraw_deposited": "0", "fees_paid": "3000000", "burned": "0", "pool_value": "1099997000000", "register_total": "100000003000000", "total_supply": "101100000000000", - "invariant_holds": true + "invariant_holds": true, + // Fee feedback (fullnode `feat/fee-feedback`): `"0"` on a chain without + // `fees.burn_base`. Kept so this mock stays a superset of the live shape. + "base_fees_burned": "0" }); // RPL-2 (a v0.6.8 node): what invokes paid out of vaults and what vaults hold; // "0" on a chain without the section. @@ -450,7 +453,9 @@ impl MockChain { "max_block_bytes": 20971520, "max_call_envelope_bytes": 65536, "max_program_public_words": 32768, "envelope_bytes": 1860, "hardening_v6": true, "hc_auth": h("hc_auth"), "gas_price": "100", "byte_price": "800", "gas_metering": "circuit", - "bundle_gas_limit": 20479, "adjust_bps": 1250 }); + "bundle_gas_limit": 20479, "adjust_bps": 1250, + // Fee feedback: `null` on a chain without a `fees` section. + "fee_rules": null }); // RPL-2 (a v0.6.8 node): the `program_state` group, `null` without the section. l["program_state"] = if self.program_state { json!({ "cell_fee": "10000000", "max_reads": 8, "max_writes": 8, "max_payouts": 4 }) diff --git a/crates/randscan-api/tests/mock_node.rs b/crates/randscan-api/tests/mock_node.rs index f9fe096..a53efef 100644 --- a/crates/randscan-api/tests/mock_node.rs +++ b/crates/randscan-api/tests/mock_node.rs @@ -453,7 +453,9 @@ async fn indexes_every_shielded_kind_and_survives_hard_forks() { "program_state": { "cell_fee": "10000000", "max_reads": 8, "max_writes": 8, "max_payouts": 4 }, // Audit v6's fields, absent from this mock's (chain-18-shaped) reply: their defaults. "max_gas_price": null, "max_byte_price": null, "byte_load": null, "admission_by_vote": false, - "testnet": false, "slashing": null, "binding_domain": null, "proof_window_blocks": null })); + "testnet": false, "slashing": null, "binding_domain": null, "proof_window_blocks": null, + // Fee feedback: the mock serves `null`, a chain without a `fees` section. + "fee_rules": null })); // Off `rand_status`, refreshed every commit: the auth guest, and the tip's live prices (one // 12.5% step above the genesis prices the limits report — a dynamic chain moves them). assert_eq!(stats["hc_auth"], h("hc_auth")); @@ -491,6 +493,7 @@ async fn indexes_every_shielded_kind_and_survives_hard_forks() { let (_, _, supply) = call_api(&live.app, "/api/v1/supply").await; assert_eq!(supply["invariant_holds"], true); assert_eq!((&supply["program_rand_out"], &supply["program_rand_held"]), (&json!("300"), &json!("700")), "RPL-2 vault counters: {supply}"); + assert_eq!(supply["base_fees_burned"], "0", "fee feedback's burn counter passes through: {supply}"); let (_, _, bridge) = call_api(&live.app, "/api/v1/bridge").await; assert_eq!(bridge["enabled"], true); assert_eq!(bridge["assets"][0]["index"], 1); From 360ded771114259f90af6695815cb19a8be4686b Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy Date: Tue, 6 Oct 2026 18:58:29 +0800 Subject: [PATCH 3/5] tests: real_node runs against a v0.7 node (test FRI allowed, --binding-domain 1, a 1 024-block proof window) and checks the REST contract zusd.money and randprotocol.org read Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tup3MjwcZy2X6hdCRWGkFc --- crates/randscan-api/tests/real_node.rs | 219 ++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 6 deletions(-) diff --git a/crates/randscan-api/tests/real_node.rs b/crates/randscan-api/tests/real_node.rs index 702a1dd..130b568 100644 --- a/crates/randscan-api/tests/real_node.rs +++ b/crates/randscan-api/tests/real_node.rs @@ -4,7 +4,7 @@ //! number: the bundle's nullifiers and commitments, the mint's note, the tree, the register. //! //! Needs `DATABASE_URL` and `RAND_NODE_BIN` (path to a built `rand-node`); skips when either -//! is unset. The wallet CLI (`rand`) must sit next to the node binary or be named by +//! is unset, unless `RANDSCAN_REQUIRE_REAL_NODE` is set (CI), which makes that a failure. The wallet CLI (`rand`) must sit next to the node binary or be named by //! `RAND_CLI`; it proves the bundle locally under the chain's `test` FRI profile. With the //! wallet the test also deploys a guest, proves and submits one confidential call, and checks //! the explorer's receipt (tier, outputs, `h_in`) against the node's — the proof is made under @@ -37,8 +37,16 @@ impl Drop for Node { } } +/// Since fullnode v0.7 a release `rand-node` (and the wallet) refuses a genesis under the `test` +/// FRI profile unless told it is a harness; an older build ignores the variable. +const ALLOW_TEST_FRI: (&str, &str) = ("RAND_ALLOW_TEST_FRI_PROFILE", "1"); + fn run(bin: &PathBuf, args: &[&str]) -> String { - let out = Command::new(bin).args(args).output().expect("run binary"); + let out = Command::new(bin) + .args(args) + .env(ALLOW_TEST_FRI.0, ALLOW_TEST_FRI.1) + .output() + .expect("run binary"); assert!( out.status.success(), "{:?} failed: {}", @@ -156,6 +164,16 @@ async fn start_node(bin: &PathBuf, cli: &PathBuf) -> (Node, String, String) { .map(str::to_string), ); } + // Audit v6 BIND-1 (fullnode v0.7): a wallet signs nothing for a chain id outside 14–19 + // unless the genesis binds its hash (`--binding-domain 1`, how every chain from 20 is cut); + // and the chain-20 proof window (issue #118), which gives a slow CI prover four times the + // anchor's 256 blocks. An older build has neither flag and keeps the plain genesis. + if help.contains("--binding-domain") { + args.extend(["--binding-domain", "1"].into_iter().map(str::to_string)); + } + if help.contains("--proof-window-blocks") { + args.extend(["--proof-window-blocks", "1024"].into_iter().map(str::to_string)); + } // RPL-2 (fullnode v0.6.8, `feat/rpl2`): a build that knows the `program_state` section gets // one, with the token registry it stands on, so the explorer meets an `invoke`, a program's // cells and its vault — the shape of the chain the feature is cut on, not chain 19's. @@ -211,6 +229,7 @@ async fn start_node(bin: &PathBuf, cli: &PathBuf) -> (Node, String, String) { "2000", ]) .env("RUST_LOG", "warn") + .env(ALLOW_TEST_FRI.0, ALLOW_TEST_FRI.1) .stdout(Stdio::null()) .stderr(Stdio::inherit()) .spawn() @@ -242,11 +261,16 @@ async fn start_node(bin: &PathBuf, cli: &PathBuf) -> (Node, String, String) { #[tokio::test] async fn explorer_agrees_with_a_real_shielded_node() { + // CI's `real-node` job sets RANDSCAN_REQUIRE_REAL_NODE so a lost variable fails the job + // instead of passing it as a skip. + let required = std::env::var_os("RANDSCAN_REQUIRE_REAL_NODE").is_some(); let Ok(bin) = std::env::var("RAND_NODE_BIN") else { + assert!(!required, "RANDSCAN_REQUIRE_REAL_NODE is set but RAND_NODE_BIN is not"); eprintln!("skipping: RAND_NODE_BIN unset"); return; }; if std::env::var("DATABASE_URL").is_err() { + assert!(!required, "RANDSCAN_REQUIRE_REAL_NODE is set but DATABASE_URL is not"); eprintln!("skipping: DATABASE_URL unset"); return; } @@ -628,9 +652,13 @@ async fn explorer_agrees_with_a_real_shielded_node() { .await; assert_eq!(notes["pagination"]["total"], tree["next_index"]); - if node.rpl2 { - invoke_round_trip(&live, &client, &node, &cli, wallet, &wallet2_addr).await; - } + let token_index = if node.rpl2 { + Some(invoke_round_trip(&live, &client, &node, &cli, wallet, &wallet2_addr).await) + } else { + eprintln!("skipping the RPL-2 invoke round trip: this rand-node has no --program-state-cell-fee (pre-v0.6.8)"); + None + }; + consumer_contract(&live, &client, &node, &mint_hash, &mint["cm"], token_index).await; drop(node); } @@ -640,7 +668,7 @@ async fn explorer_agrees_with_a_real_shielded_node() { /// the program token — three proofs. Then the explorer's view against the node's: the invoke /// with its transition, a call's receipt, both payout leaves linked to it, the program's cells /// and vault, the limits' `program_state` group and the supply's vault counters. -async fn invoke_round_trip(live: &LiveApp, client: &reqwest::Client, node: &Node, cli: &PathBuf, wallet: &str, payee: &str) { +async fn invoke_round_trip(live: &LiveApp, client: &reqwest::Client, node: &Node, cli: &PathBuf, wallet: &str, payee: &str) -> i64 { let dir = node.dir.path(); let counter_json = dir.join("counter.json"); run(cli, &["program", "build", "--guest", "rpl2_counter", "--out", counter_json.to_str().unwrap()]); @@ -752,4 +780,183 @@ async fn invoke_round_trip(live: &LiveApp, client: &reqwest::Client, node: &Node assert_eq!(supply["program_rand_out"], units(&node_supply["program_rand_out"])); assert_eq!(supply["program_rand_out"], "200000000"); assert_eq!(supply["invariant_holds"], true, "{supply}"); + share_index +} + +/// The REST contract two sites read with no tests of their own, checked against what this real +/// node made of the chain above. Every message names the consumer file: +/// +/// - zusd.money `src/components/BalanceSheet.astro`: `GET /bridge` (`enabled`, `mint_paused`), +/// `GET /bridge/assets` (`index`, `chain`, `symbol`, `locked`), `GET /tokens/{index}` +/// (`total_supply`); +/// - randprotocol.org `src/components/BridgeReserves.astro`: the same three, plus each row's +/// `minted_today` / `mint_cap_per_day`; +/// - randprotocol.org `src/scripts/balance.js`: `GET /envelopes?from_leaf=&limit=` (`notes`, +/// `total_leaves`, `next_leaf`; per note `leaf_index`, `cm`, `height`, `tx_hash`, `envelope`, +/// `public`). +/// +/// Also the stats and supply objects, which carry fields the node grows: `limits.fee_rules` and +/// `base_fees_burned` (fullnode `feat/fee-feedback`, unreleased) pass through when the node serves +/// them and are not required when it does not. +async fn consumer_contract( + live: &LiveApp, + client: &reqwest::Client, + node: &Node, + mint_hash: &str, + mint_cm: &Value, + token_index: Option, +) { + const ZUSD: &str = "zusd.money src/components/BalanceSheet.astro"; + const RESERVES: &str = "randprotocol.org src/components/BridgeReserves.astro"; + const BALANCE: &str = "randprotocol.org src/scripts/balance.js"; + let get = |path: String| async move { call(&live.app, json_req("GET", &path, None, None)).await }; + let is_units = |v: &Value| v.as_str().is_some_and(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())); + + // Settle on the node's head first, so the bridge/token/supply caches have been refreshed. + let head = rpc(client, &node.url, "rand_getHead", json!([])).await["height"].as_i64().unwrap(); + live.wait_for("/api/v1/health", WAIT, |h| h["indexer"]["current_height"].as_i64().unwrap_or(-1) >= head).await; + + // --- /bridge --------------------------------------------------------------------------- + let node_bridge = rpc(client, &node.url, "rand_getBridgeState", json!([])).await; + let (status, _, bridge) = get("/api/v1/bridge".into()).await; + assert_eq!(status, 200, "{ZUSD} and {RESERVES} fetch /bridge: {bridge}"); + assert!(bridge["enabled"].is_boolean(), "{ZUSD} reads bridge.enabled as a boolean: {bridge}"); + assert!(bridge["mint_paused"].is_boolean(), "{ZUSD} reads bridge.mint_paused as a boolean: {bridge}"); + let enabled = node_bridge["enabled"].as_bool().unwrap_or(false); + assert_eq!(bridge["enabled"], enabled, "{ZUSD}: /bridge.enabled must be the node's: node {node_bridge}"); + if !enabled { + assert_eq!(bridge["mint_paused"], false, "{ZUSD}: a chain without a bridge reports no pause: {bridge}"); + } + + // --- /bridge/assets ---------------------------------------------------------------------- + let (status, _, assets) = get("/api/v1/bridge/assets".into()).await; + assert_eq!(status, 200, "{ZUSD} and {RESERVES} fetch /bridge/assets: {assets}"); + let rows = assets.as_array().unwrap_or_else(|| panic!("{ZUSD} iterates /bridge/assets as an array: {assets}")); + if !enabled { + assert!(rows.is_empty(), "{ZUSD}: a chain without a bridge lists no backings ([]): {assets}"); + } + for a in rows { + assert!(a["index"].is_i64(), "{ZUSD} groups by row.index: {a}"); + assert!(a["chain"].is_i64(), "{ZUSD} keys rows by row.chain: {a}"); + assert!(a["symbol"].is_string() || a["symbol"].is_null(), "{ZUSD} filters on row.symbol (string or null): {a}"); + if a["symbol"].is_string() { + // Only symbol'd rows are summed, with BigInt(row.locked): null there would throw. + assert!(is_units(&a["locked"]), "{ZUSD} sums BigInt(row.locked): {a}"); + assert!(is_units(&a["minted_today"]) && is_units(&a["mint_cap_per_day"]), "{RESERVES} renders minted_today / mint_cap_per_day: {a}"); + } + } + + // --- /tokens/{index} --------------------------------------------------------------------- + // The sites ask for each index the bridge rows name and treat a non-2xx as "unknown"; an + // index nobody registered must be a 404, never a 200 with a made-up supply. + let (status, _, missing) = get("/api/v1/tokens/987654".into()).await; + assert_eq!(status, 404, "{ZUSD} rejects a non-ok /tokens/{{index}}: {missing}"); + match token_index { + Some(index) => { + let node_token = rpc(client, &node.url, "rand_getToken", json!([index])).await; + let token = live + .wait_for(&format!("/api/v1/tokens/{index}"), WAIT, |t| t["total_supply"] == json!(units(&node_token["total_supply"]))) + .await; + assert!(is_units(&token["total_supply"]), "{ZUSD} and {RESERVES} take BigInt(token.total_supply): {token}"); + assert_eq!(token["index"], index, "{RESERVES} reads token.index: {token}"); + assert!(token["symbol"].is_string(), "{RESERVES} reads token.symbol: {token}"); + assert_eq!(token["total_supply"], "1", "{ZUSD}: the one unit the invoke minted: {token}"); + } + None => eprintln!("skipping /tokens/{{index}} for a registered token: no token registry on this build (pre-RPL-2)"), + } + + // --- /envelopes ---------------------------------------------------------------------------- + // balance.js walks the tree a page at a time from leaf 0 with limit=1000, follows next_leaf + // until it is null, and reports progress against total_leaves. + let tree = rpc(client, &node.url, "rand_getTreeInfo", json!([])).await; + let leaves = tree["next_index"].as_i64().unwrap(); + let page = live + .wait_for("/api/v1/envelopes?from_leaf=0&limit=1000", WAIT, |p| p["total_leaves"] == leaves) + .await; + assert!(page["next_leaf"].is_null(), "{BALANCE} stops when next_leaf is null (every leaf on one page): {page}"); + let notes = page["notes"].as_array().unwrap_or_else(|| panic!("{BALANCE} iterates page.notes: {page}")); + assert_eq!(notes.len() as i64, leaves, "{BALANCE}: one row per leaf: {page}"); + for (i, n) in notes.iter().enumerate() { + assert_eq!(n["leaf_index"], i as i64, "{BALANCE} sorts on n.leaf_index, oldest first: {n}"); + assert!(n["cm"].is_string(), "{BALANCE} opens n.cm: {n}"); + assert!(n["height"].is_i64(), "{BALANCE} shows n.height: {n}"); + let o = n.as_object().unwrap(); + for key in ["tx_hash", "envelope", "public"] { + assert!(o.contains_key(key), "{BALANCE} destructures n.{key} (null allowed, never missing): {n}"); + } + assert!(n["tx_hash"].is_string() || n["tx_hash"].is_null(), "{BALANCE}: n.tx_hash: {n}"); + assert!(n["envelope"].is_object() || n["envelope"].is_null(), "{BALANCE} passes n.envelope to the opener: {n}"); + } + let mint_row = notes + .iter() + .find(|n| n["cm"] == *mint_cm) + .unwrap_or_else(|| panic!("{BALANCE}: the faucet mint's leaf is served: {page}")); + assert_eq!(mint_row["tx_hash"], mint_hash, "{BALANCE}: the mint's leaf names its transaction: {mint_row}"); + assert!( + notes.iter().any(|n| n["envelope"].is_object() && n["tx_hash"].is_string()), + "{BALANCE}: at least one leaf carries an envelope to open: {page}" + ); + // Paging: a short page points at the next leaf, and that page starts there. + let (status, _, first) = get("/api/v1/envelopes?from_leaf=0&limit=2".into()).await; + assert_eq!(status, 200, "{BALANCE}: {first}"); + assert_eq!(first["next_leaf"], 2, "{BALANCE} follows page.next_leaf: {first}"); + let (_, _, second) = get("/api/v1/envelopes?from_leaf=2&limit=2".into()).await; + assert_eq!(second["notes"][0]["leaf_index"], 2, "{BALANCE}: the next page starts at next_leaf: {second}"); + assert_eq!(second["total_leaves"], leaves, "{BALANCE} reports progress against total_leaves: {second}"); + + // --- /stats -------------------------------------------------------------------------------- + let node_limits = rpc(client, &node.url, "rand_getLimits", json!([])).await; + let stats = live.wait_for("/api/v1/stats", WAIT, |s| s["limits"].is_object()).await; + let so = stats.as_object().unwrap(); + assert!(so.contains_key("gas_prices"), "stats.gas_prices is served (null allowed): {stats}"); + if node_limits["gas_metering"] == "circuit" { + assert!(is_units(&stats["gas_prices"]["gas_price"]) && is_units(&stats["gas_prices"]["byte_price"]), "a gas-section chain's tip prices: {stats}"); + } + match node_limits.get("fee_rules") { + Some(rules) => assert_eq!(stats["limits"]["fee_rules"], *rules, "limits.fee_rules passes through as the node serves it: {stats}"), + None => { + eprintln!("note: this rand-node predates rand_getLimits.fee_rules (fee feedback); only its absence is tolerated"); + assert!(stats["limits"]["fee_rules"].is_null(), "{stats}"); + } + } + + // --- /supply ------------------------------------------------------------------------------- + // Every amount the explorer serves is a decimal string and equals the node's own figure; the + // node may serve more than the explorer models (`registration_fees_burned`, `faucet_epoch`, + // …) and that must not stop the refresh. Wait on a supply at least as new as the head above. + // Both sides are read until they describe the same height (the node commits every 500 ms). + let start = std::time::Instant::now(); + let (supply, node_supply) = loop { + let (status, _, ours) = get("/api/v1/supply".into()).await; + let theirs = rpc(client, &node.url, "rand_getSupply", json!([])).await; + if status == 200 && ours["height"] == theirs["height"] { + break (ours, theirs); + } + assert!(start.elapsed() < WAIT, "/api/v1/supply never caught the node's height: {ours} vs {theirs}"); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + for (k, v) in supply.as_object().unwrap() { + match v { + Value::String(_) => { + assert!(is_units(v), "supply.{k} is a decimal string: {supply}"); + if let Some(theirs) = node_supply.get(k) { + assert_eq!(*v, json!(units(theirs)), "supply.{k} is the node's figure: node {node_supply}"); + } + } + Value::Null => assert!(node_supply.get(k).is_none_or(Value::is_null), "supply.{k} dropped: node {node_supply}"), + _ => {} + } + } + match node_supply.get("base_fees_burned") { + Some(b) => assert_eq!(supply["base_fees_burned"], json!(units(b)), "base_fees_burned passes through: {supply}"), + None => eprintln!("note: this rand-node predates rand_getSupply.base_fees_burned (fee feedback); the indexer unit test covers it"), + } + assert_eq!(supply["invariant_holds"], true, "{supply}"); + eprintln!( + "consumer contract: bridge enabled={enabled}, {} backing rows, token {:?}, {leaves} envelope leaves, fee_rules {}, base_fees_burned {}", + rows.len(), + token_index, + if node_limits.get("fee_rules").is_some() { "served" } else { "absent" }, + supply["base_fees_burned"], + ); } From aaaa6dfc01e052641eabb9abb799279dadb39ea0 Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy Date: Tue, 6 Oct 2026 18:58:29 +0800 Subject: [PATCH 4/5] =?UTF-8?q?ci:=20real-node=20job=20=E2=80=94=20the=20l?= =?UTF-8?q?atest=20fullnode=20release=20against=20tests/real=5Fnode.rs,=20?= =?UTF-8?q?gating;=20README=20says=20how=20to=20run=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tup3MjwcZy2X6hdCRWGkFc --- .github/workflows/ci.yml | 68 ++++++++++++++++++++++++++++++++++++++++ README.md | 34 ++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 599714b..f4b94df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,74 @@ jobs: env: DATABASE_URL: postgres://randscan:randscan@localhost:5432/randscan + # The explorer against a real `rand-node`: tests/real_node.rs spawns the latest fullnode + # release (node + `rand` wallet) on a one-validator chain, makes a faucet mint, a proved + # transfer, a deploy and a confidential call (and an RPL-2 invoke where the build has it), and + # compares the explorer's every public number with the node's — including the REST contract + # zusd.money and randprotocol.org read (/bridge, /bridge/assets, /tokens/:index, /envelopes, + # /stats, /supply). It gates: the test skips a sub-check the released binary has no flag for + # (with a printed reason), never the whole run. The wallet proves under the chain's `test` FRI + # profile in its own release binary, so the test itself builds in the default profile. + real-node: + name: Real node + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: randscan + POSTGRES_PASSWORD: randscan + POSTGRES_DB: randscan + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-real-node-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Download the latest fullnode release + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p "$RUNNER_TEMP/fullnode" + gh release download --repo randprotocol/fullnode --pattern rand-node --pattern rand --dir "$RUNNER_TEMP/fullnode" + chmod +x "$RUNNER_TEMP/fullnode/rand-node" "$RUNNER_TEMP/fullnode/rand" + "$RUNNER_TEMP/fullnode/rand-node" --version + echo "RAND_NODE_BIN=$RUNNER_TEMP/fullnode/rand-node" >> "$GITHUB_ENV" + echo "RAND_CLI=$RUNNER_TEMP/fullnode/rand" >> "$GITHUB_ENV" + + - name: Explorer against the real node + run: cargo test -p randscan-api --test real_node -- --nocapture + env: + DATABASE_URL: postgres://randscan:randscan@localhost:5432/randscan + RANDSCAN_REQUIRE_REAL_NODE: "1" + # 1 s blocks: the anchor window is four minutes at 256 blocks (more under the + # genesis' 1 024-block proof window), room for a shared runner's proving. + RAND_BLOCK_INTERVAL_MS: "1000" + # Next.js frontend build frontend: name: Frontend Build diff --git a/README.md b/README.md index 9554713..5319a12 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,40 @@ deploy and a confidential call) and compares every public number. Both need `DAT the real-node test also needs `RAND_NODE_BIN` (a build of the fullnode's `shielded-s3` branch or later, with `rand` beside it or `RAND_CLI` set). +### Integration test against a real node + +`tests/real_node.rs` is also the contract test for the two sites that read this API with no tests +of their own: zusd.money's `BalanceSheet.astro` and randprotocol.org's `BridgeReserves.astro` +(`/bridge` → `enabled`, `mint_paused`; `/bridge/assets` → `index`, `chain`, `symbol`, `locked`; +`/tokens/{index}` → `total_supply`) and randprotocol.org's `balance.js` +(`/envelopes?from_leaf=&limit=` → `notes`, `total_leaves`, `next_leaf`, and per leaf `leaf_index`, +`cm`, `height`, `tx_hash`, `envelope`, `public`). On a chain without a bridge it pins what those +pages must handle (`enabled: false`, `[]`, a 404 for an unknown token); it also checks that +`/stats` carries `limits` and `gas_prices`, that every `/supply` amount is the node's decimal +string, and that the node's newer fields (`rand_getLimits.fee_rules`, +`rand_getSupply.base_fees_burned`) pass through when served and are not required when not. + +```bash +gh release download --repo randprotocol/fullnode --pattern rand-node --pattern rand --dir /tmp/fullnode +chmod +x /tmp/fullnode/rand-node /tmp/fullnode/rand +DATABASE_URL=postgres://randscan:randscan@localhost:5432/randscan_it \ +RAND_NODE_BIN=/tmp/fullnode/rand-node RAND_CLI=/tmp/fullnode/rand \ + cargo test -p randscan-api --test real_node -- --nocapture +``` + +| Variable | Meaning | +|---|---| +| `DATABASE_URL` | a PostgreSQL database the test may re-index (use a scratch one: the chain tables are truncated on a chain switch) | +| `RAND_NODE_BIN` | the `rand-node` binary to spawn | +| `RAND_CLI` | the `rand` wallet; defaults to `rand` beside `RAND_NODE_BIN` | +| `RAND_BLOCK_INTERVAL_MS` | block interval, default `500`; raise to `1000`–`1500` on a slow prover | +| `RANDSCAN_REQUIRE_REAL_NODE` | fail instead of skipping when either of the first two is unset | + +The wallet proves locally under the chain's `test` FRI profile; a run takes a few minutes. A +check the given build has no flag for (an RPL-2 invoke on a pre-v0.6.8 node, `fee_rules` on a +node before fee feedback) is skipped with a printed reason. CI's `real-node` job runs this against +the latest fullnode release on every push and pull request, and gates on it. + ### Environment | Variable | Default | Description | From 134586d37bf4b3c0a17cdb0bb49ddf08d9ccb464 Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy Date: Tue, 6 Oct 2026 19:06:34 +0800 Subject: [PATCH 5/5] =?UTF-8?q?tests:=20review=20=E2=80=94=20zusd.money's?= =?UTF-8?q?=20reader=20is=20src/lib/balance-sheet.mjs,=20fee=5Frules=20com?= =?UTF-8?q?pared=20flag=20by=20flag,=20paging=20guarded=20on=20a=20third?= =?UTF-8?q?=20leaf;=20ci:=20release=20pinned=20by=20FULLNODE=5FTAG=20and?= =?UTF-8?q?=20checked=20against=20SHA256SUMS;=20the=20new=20rpc.rs=20test?= =?UTF-8?q?=20formatted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tup3MjwcZy2X6hdCRWGkFc --- .github/workflows/ci.yml | 8 ++++-- README.md | 2 +- crates/randscan-api/tests/real_node.rs | 36 ++++++++++++++++++-------- crates/randscan-indexer/src/rpc.rs | 29 ++++++++++++++++----- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4b94df..bfac7db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,12 +114,16 @@ jobs: restore-keys: | ${{ runner.os }}-cargo- - - name: Download the latest fullnode release + - name: Download the fullnode release env: GH_TOKEN: ${{ github.token }} + # A tag (e.g. v0.7.1) pins the run to that release; empty means the latest. + FULLNODE_TAG: "" run: | mkdir -p "$RUNNER_TEMP/fullnode" - gh release download --repo randprotocol/fullnode --pattern rand-node --pattern rand --dir "$RUNNER_TEMP/fullnode" + gh release download $FULLNODE_TAG --repo randprotocol/fullnode \ + --pattern rand-node --pattern rand --pattern SHA256SUMS --dir "$RUNNER_TEMP/fullnode" + (cd "$RUNNER_TEMP/fullnode" && sha256sum -c --ignore-missing SHA256SUMS) chmod +x "$RUNNER_TEMP/fullnode/rand-node" "$RUNNER_TEMP/fullnode/rand" "$RUNNER_TEMP/fullnode/rand-node" --version echo "RAND_NODE_BIN=$RUNNER_TEMP/fullnode/rand-node" >> "$GITHUB_ENV" diff --git a/README.md b/README.md index 5319a12..475a4dd 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ or later, with `rand` beside it or `RAND_CLI` set). ### Integration test against a real node `tests/real_node.rs` is also the contract test for the two sites that read this API with no tests -of their own: zusd.money's `BalanceSheet.astro` and randprotocol.org's `BridgeReserves.astro` +of their own: zusd.money's `src/lib/balance-sheet.mjs` (rendered by `BalanceSheet.astro`) and randprotocol.org's `BridgeReserves.astro` (`/bridge` → `enabled`, `mint_paused`; `/bridge/assets` → `index`, `chain`, `symbol`, `locked`; `/tokens/{index}` → `total_supply`) and randprotocol.org's `balance.js` (`/envelopes?from_leaf=&limit=` → `notes`, `total_leaves`, `next_leaf`, and per leaf `leaf_index`, diff --git a/crates/randscan-api/tests/real_node.rs b/crates/randscan-api/tests/real_node.rs index 130b568..cd74743 100644 --- a/crates/randscan-api/tests/real_node.rs +++ b/crates/randscan-api/tests/real_node.rs @@ -786,7 +786,7 @@ async fn invoke_round_trip(live: &LiveApp, client: &reqwest::Client, node: &Node /// The REST contract two sites read with no tests of their own, checked against what this real /// node made of the chain above. Every message names the consumer file: /// -/// - zusd.money `src/components/BalanceSheet.astro`: `GET /bridge` (`enabled`, `mint_paused`), +/// - zusd.money `src/lib/balance-sheet.mjs` (rendered by `BalanceSheet.astro`): `GET /bridge` (`enabled`, `mint_paused`), /// `GET /bridge/assets` (`index`, `chain`, `symbol`, `locked`), `GET /tokens/{index}` /// (`total_supply`); /// - randprotocol.org `src/components/BridgeReserves.astro`: the same three, plus each row's @@ -806,7 +806,7 @@ async fn consumer_contract( mint_cm: &Value, token_index: Option, ) { - const ZUSD: &str = "zusd.money src/components/BalanceSheet.astro"; + const ZUSD: &str = "zusd.money src/lib/balance-sheet.mjs (via BalanceSheet.astro)"; const RESERVES: &str = "randprotocol.org src/components/BridgeReserves.astro"; const BALANCE: &str = "randprotocol.org src/scripts/balance.js"; let get = |path: String| async move { call(&live.app, json_req("GET", &path, None, None)).await }; @@ -896,13 +896,15 @@ async fn consumer_contract( notes.iter().any(|n| n["envelope"].is_object() && n["tx_hash"].is_string()), "{BALANCE}: at least one leaf carries an envelope to open: {page}" ); - // Paging: a short page points at the next leaf, and that page starts there. - let (status, _, first) = get("/api/v1/envelopes?from_leaf=0&limit=2".into()).await; - assert_eq!(status, 200, "{BALANCE}: {first}"); - assert_eq!(first["next_leaf"], 2, "{BALANCE} follows page.next_leaf: {first}"); - let (_, _, second) = get("/api/v1/envelopes?from_leaf=2&limit=2".into()).await; - assert_eq!(second["notes"][0]["leaf_index"], 2, "{BALANCE}: the next page starts at next_leaf: {second}"); - assert_eq!(second["total_leaves"], leaves, "{BALANCE} reports progress against total_leaves: {second}"); + // Paging: a short page points at the next leaf, and that page starts there (needs a third leaf). + if leaves > 2 { + let (status, _, first) = get("/api/v1/envelopes?from_leaf=0&limit=2".into()).await; + assert_eq!(status, 200, "{BALANCE}: {first}"); + assert_eq!(first["next_leaf"], 2, "{BALANCE} follows page.next_leaf: {first}"); + let (_, _, second) = get("/api/v1/envelopes?from_leaf=2&limit=2".into()).await; + assert_eq!(second["notes"][0]["leaf_index"], 2, "{BALANCE}: the next page starts at next_leaf: {second}"); + assert_eq!(second["total_leaves"], leaves, "{BALANCE} reports progress against total_leaves: {second}"); + } // --- /stats -------------------------------------------------------------------------------- let node_limits = rpc(client, &node.url, "rand_getLimits", json!([])).await; @@ -913,7 +915,18 @@ async fn consumer_contract( assert!(is_units(&stats["gas_prices"]["gas_price"]) && is_units(&stats["gas_prices"]["byte_price"]), "a gas-section chain's tip prices: {stats}"); } match node_limits.get("fee_rules") { - Some(rules) => assert_eq!(stats["limits"]["fee_rules"], *rules, "limits.fee_rules passes through as the node serves it: {stats}"), + // Only the flags the explorer models (`FeeRules`) are compared, so a flag a later node + // adds beside them does not fail the gate; a `null` group must stay `null`. + Some(Value::Null) => assert!(stats["limits"]["fee_rules"].is_null(), "limits.fee_rules is null as the node serves it: {stats}"), + Some(rules) => { + for flag in ["burn_base", "subsidy_net_of_fees", "burn_floor"] { + assert_eq!( + stats["limits"]["fee_rules"][flag], + *rules.get(flag).unwrap_or(&json!(false)), + "limits.fee_rules.{flag} passes through as the node serves it: {stats}" + ); + } + } None => { eprintln!("note: this rand-node predates rand_getLimits.fee_rules (fee feedback); only its absence is tolerated"); assert!(stats["limits"]["fee_rules"].is_null(), "{stats}"); @@ -924,7 +937,8 @@ async fn consumer_contract( // Every amount the explorer serves is a decimal string and equals the node's own figure; the // node may serve more than the explorer models (`registration_fees_burned`, `faucet_epoch`, // …) and that must not stop the refresh. Wait on a supply at least as new as the head above. - // Both sides are read until they describe the same height (the node commits every 500 ms). + // Both sides are read until they describe the same height (the node commits every + // `RAND_BLOCK_INTERVAL_MS`: 500 ms by default, 1000 ms in CI). let start = std::time::Instant::now(); let (supply, node_supply) = loop { let (status, _, ours) = get("/api/v1/supply".into()).await; diff --git a/crates/randscan-indexer/src/rpc.rs b/crates/randscan-indexer/src/rpc.rs index e5bb8f9..1c7fadb 100644 --- a/crates/randscan-indexer/src/rpc.rs +++ b/crates/randscan-indexer/src/rpc.rs @@ -1595,7 +1595,10 @@ mod tests { assert_eq!(s.base_fees_burned.as_deref(), Some("100000")); assert_eq!(s.burned, "100000"); let wire = serde_json::to_value(&s).unwrap(); - assert_eq!(wire["base_fees_burned"], "100000", "GET /api/v1/supply serves it as the node did: {wire}"); + assert_eq!( + wire["base_fees_burned"], "100000", + "GET /api/v1/supply serves it as the node did: {wire}" + ); // A number on the wire is still read as its decimal text. let s: randscan_core::Supply = serde_json::from_value({ let mut v = wire.clone(); @@ -1620,17 +1623,31 @@ mod tests { )) .unwrap() }; - let l = limits(r#","fee_rules":{"burn_base":true,"subsidy_net_of_fees":false,"burn_floor":true,"a_flag_from_the_future":true}"#); + let l = limits( + r#","fee_rules":{"burn_base":true,"subsidy_net_of_fees":false,"burn_floor":true,"a_flag_from_the_future":true}"#, + ); let rules = l.fee_rules.clone().expect("fee_rules kept"); assert!(rules.burn_base && rules.burn_floor && !rules.subsidy_net_of_fees); - let back: randscan_core::ChainLimits = serde_json::from_value(serde_json::to_value(&l).unwrap()).unwrap(); - assert_eq!(back, l, "the limits round-trip through the stats row's JSONB"); - assert_eq!(serde_json::to_value(&l).unwrap()["fee_rules"]["burn_base"], true); + let back: randscan_core::ChainLimits = + serde_json::from_value(serde_json::to_value(&l).unwrap()).unwrap(); + assert_eq!( + back, l, + "the limits round-trip through the stats row's JSONB" + ); + assert_eq!( + serde_json::to_value(&l).unwrap()["fee_rules"]["burn_base"], + true + ); // `null` (a chain without a `fees` section) and absent (a node predating the field). assert_eq!(limits(r#","fee_rules":null"#).fee_rules, None); assert_eq!(limits("").fee_rules, None); // A group a later node trims to the flags it sets still parses. - assert_eq!(limits(r#","fee_rules":{"burn_base":true}"#).fee_rules.map(|r| (r.burn_base, r.burn_floor)), Some((true, false))); + assert_eq!( + limits(r#","fee_rules":{"burn_base":true}"#) + .fee_rules + .map(|r| (r.burn_base, r.burn_floor)), + Some((true, false)) + ); } #[test]