From 0166db6cdbb5df542eca22a75688ece8d3e2a542 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 07:00:28 +0400 Subject: [PATCH 01/24] docs: treasury node breaking-release implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-27-treasury-node-break.md | 2383 +++++++++++++++++ 1 file changed, 2383 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-treasury-node-break.md diff --git a/docs/superpowers/plans/2026-07-27-treasury-node-break.md b/docs/superpowers/plans/2026-07-27-treasury-node-break.md new file mode 100644 index 0000000..faa4278 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-treasury-node-break.md @@ -0,0 +1,2383 @@ +# Treasury Node Breaking Release — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One breaking testnet release of clutch-node that adds Mint/Burn transaction types with exactly-once refs, a chain ID in the signed payload, consensus parameters committed by the genesis hash, a flat transaction fee replacing block rewards, basis-point floor-rounded referrer fees, and a total-supply counter + `get_chain_info` RPC — the on-chain foundation for the Treasury Service. + +**Architecture:** A new genesis-only `ChainInit` transaction carries all consensus parameters (chain_id, is_testnet, tx_fee, referrer bps, mint_authority, faucet allocation). Because the tx hash feeds the genesis block hash, which peers compare at p2p handshake, mismatched params can never peer — this fixes the existing class of bug where `block_reward_amount` was per-node config. Runtime reads params from state (`chain_params` key), never from config. Supply changes and author fee credits are computed **once per block** (single read-modify-write), never per-tx, because the deferred RocksDB batch applies all tx updates against pre-block state (last-write-wins — see `ponytail:` comments in `transaction.rs`/`blockchain.rs`). + +**Tech Stack:** Rust (existing repo), rlp 0.5.2, RocksDB, secp256k1, serde_json; proptest (new dev-dep) for fee-invariant property tests. + +## Deviations from spec (explicit, per spec §11) + +- **Spec §4.1/§4a (locked: i64→i128, 6 decimals) is superseded**, approved by the user 2026-07-27 with the peg decision: 1 USD = 1,000,000 CLT makes CLT itself the micro-USD base unit, so 6 decimals + i128 add scope with no benefit — full rationale in `D:\source\clutch\treasury-analysis-2026-07-27.md` §2. §4a's "i128 boundary" property tests are correspondingly reinterpreted as u64 boundary tests with wide-integer intermediates (Task 1 provides them). + +## Global Constraints + +- **Peg (decided):** 1 USD = 1,000,000 CLT. CLT is the base unit (micro-USD). **Zero decimals. Keep `u64` balances / `i64` deltas.** No i128/u128 in stored state or RLP; wide-integer *intermediates* for overflow-safe arithmetic are fine and used deliberately (Task 1's u128 fee math, Task 6's i128 supply delta). Never floats. +- **Never run host `cargo build`/`cargo test`** (user convention). All test runs use the Docker image built in Task 1: + `docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test` +- All commands run from repo root `D:\source\clutch\clutch-node` on branch `treasury-break`. **Never push to `main`** — user reviews first (spec §11). +- Breaking changes are expected (alpha/testnet, DBs get wiped) — no backward-compat shims for state or RLP formats. +- RLP tags: Transfer=0, RideRequest=1, RideOffer=2, RideAcceptance=3, RidePay=4, RideCancel=5, **Mint=6, Burn=7**, RideRequestCancel=8, **ChainInit=9**. These must byte-match the JS SDK encoder in the follow-up SDK release. +- New tests that open a database must carry `#[serial]` (serial_test crate) and clean up via `blockchain.shutdown_blockchain()` (developer_mode) or `db.delete_database(name)` — repo convention. +- Chain params for this testnet: `chain_id = 2077`, `is_testnet = true`, `tx_fee = 1000` (= $0.001), `ride_request_referrer_fee_bps = 200`, `ride_offer_referrer_fee_bps = 200`, `faucet_allocation = 1_000_000_000_000_000` (= $1B, JS-safe below 2^53). +- The three node TOMLs must carry **identical** chain-param values or the nodes cannot peer (genesis hash mismatch by design). +- Known deferred ceiling (do NOT fix here): intra-block state uses one deferred batch with fresh-DB reads, so two writes to the same account key in one block collide (pre-existing; documented in CLAUDE.md). This plan avoids adding new collisions by (a) one-tx-per-sender already enforced, (b) block-level single-write for author fees and total_supply, (c) merging each sender's fee into its single balance write. Mark any spot relying on this with a `ponytail:` comment. + +## File Structure + +| File | Change | +|---|---| +| `Dockerfile.test` | new — test-runner image (rust + clang for rocksdb) | +| `Cargo.toml` | + `proptest` dev-dep | +| `src/node/transactions/chain_init.rs` | new — ChainInit params tx + state keys + getters | +| `src/node/transactions/mint.rs` | new — Mint tx | +| `src/node/transactions/burn.rs` | new — Burn tx | +| `src/node/transactions/function_call.rs` | + 3 enum variants | +| `src/node/rlp_encoding.rs` | + tags 6/7/9; Transaction 7→8 items (chain_id) | +| `src/node/transactions/transaction.rs` | chain_id field + hash preimage; genesis via ChainInit; fee helpers; params-based dispatch | +| `src/node/transactions/transfer.rs` | fee merged into sender debit | +| `src/node/transactions/ride_acceptance.rs` | fee merged into escrow debit; verify fare+fee | +| `src/node/transactions/ride_cancel.rs` | fee merged into refund (passenger) / standalone (driver) | +| `src/node/transactions/ride_pay.rs` | percent→bps, ceiling→floor | +| `src/node/account_state.rs` | + `apply_balance_change_with_fee` | +| `src/node/balance_effect.rs` | + 4 `BalanceEffectKind` variants | +| `src/node/blocks/block.rs` | genesis(params); add_block_to_chain: params from state, reward code → fee credit + supply delta | +| `src/node/blockchain.rs` | new constructor signature; boot validation; drop reward/percent fields | +| `src/node/configuration.rs` | + 6 fields, − block_reward_amount, percent→bps | +| `src/node/wss/websocket.rs` | + `get_chain_info`; block_reward literal 0 | +| `src/main.rs` | build ChainInit from config | +| `config/node/*.toml` (6 files) | new/renamed keys | +| `tests/` | new: `chain_genesis.rs`, `mint_burn.rs`, `tx_fee.rs`; updated: all existing | +| `docs/state_keys.csv`, `CLAUDE.md` | document new keys/types/RPC | + +--- + +### Task 1: Basis-point referrer fees with floor rounding + +**Files:** +- Create: `Dockerfile.test` +- Modify: `Cargo.toml`, `src/node/transactions/ride_pay.rs`, `src/node/configuration.rs`, `src/node/transactions/transaction.rs:233-238`, `src/node/blockchain.rs:26-28,38-40,51-53,119-125,151-157`, `src/node/blocks/block.rs:296-302,336-341`, `config/node/{default,node1,node2,node3,node2-docker,node3-docker}.toml` +- Test: `src/node/transactions/ride_pay.rs` (unit + proptest in `#[cfg(test)]`) + +**Interfaces:** +- Consumes: existing `split_fare(fare, request_fee, offer_fee) -> (u64, u64, u64)` (unchanged). +- Produces: `fn referrer_fee_floor(bps: u16, fare: u64) -> u64`; `RidePay::state_transaction(&self, tx_hash: &String, db: &Database, request_fee_bps: u16, offer_fee_bps: u16, passenger: &String)`; `AppConfig.ride_request_referrer_fee_bps: u16` / `ride_offer_referrer_fee_bps: u16`; `Transaction::state_transaction(&self, db, request_fee_bps: u16, offer_fee_bps: u16)`; same u16 plumbing through `Blockchain` and `Block::add_block_to_chain`. (Task 3 replaces this plumbing with a params struct — keep it mechanical.) + +- [ ] **Step 1: Branch + test image** + +```bash +git checkout -b treasury-break +``` + +Create `Dockerfile.test`: + +```dockerfile +# Test-runner image: rocksdb (librocksdb-sys) needs clang/libclang for bindgen. +FROM rust:1.86-bookworm +RUN apt-get update && apt-get install -y clang libclang-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +``` + +```bash +docker build -f Dockerfile.test -t clutch-node-test . +``` + +- [ ] **Step 2: Add proptest dev-dependency** + +In `Cargo.toml` `[dev-dependencies]`: + +```toml +[dev-dependencies] +serial_test = "3.1.1" +proptest = "1.5" +``` + +- [ ] **Step 3: Write the failing tests** — in `ride_pay.rs` replace the `#[cfg(test)]` module's fee tests: + +```rust +#[cfg(test)] +mod tests { + use super::{referrer_fee_floor, split_fare}; + use proptest::prelude::*; + + #[test] + fn referrer_fee_floor_bps() { + assert_eq!(referrer_fee_floor(0, 100), 0); + assert_eq!(referrer_fee_floor(200, 0), 0); + assert_eq!(referrer_fee_floor(200, 100), 2); // 2% of 100 + // Floor kills the old ceiling distortion (2% of 3 ceiling-rounded to 33%). + assert_eq!(referrer_fee_floor(200, 3), 0); + assert_eq!(referrer_fee_floor(200, 49), 0); + assert_eq!(referrer_fee_floor(200, 50), 1); + assert_eq!(referrer_fee_floor(10_000, u64::MAX), u64::MAX); // 100%, no overflow + assert_eq!(referrer_fee_floor(1, 10_000), 1); // 1 bp granularity + } + + #[test] + fn split_fare_never_exceeds_fare() { + assert_eq!(split_fare(100, 2, 2), (2, 2, 96)); + assert_eq!(split_fare(1, 1, 1), (1, 0, 0)); + assert_eq!(split_fare(10, 8, 8), (8, 2, 0)); + assert_eq!(split_fare(50, 0, 0), (0, 0, 50)); + } + + proptest! { + // Spec §4a: request + offer + driver == fare, exactly, for every input. + #[test] + fn fee_split_sums_exactly(fare in any::(), rbps in 0u16..=10_000, obps in 0u16..=10_000) { + let (r, o, d) = split_fare( + fare, + referrer_fee_floor(rbps, fare), + referrer_fee_floor(obps, fare), + ); + prop_assert!(r <= fare && o <= fare - r); + prop_assert_eq!(r + o + d, fare); + } + + #[test] + fn floor_fee_bounded_by_fare(fare in any::(), bps in 0u16..=10_000) { + prop_assert!(referrer_fee_floor(bps, fare) <= fare); + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --lib ride_pay +``` + +Expected: FAIL — `referrer_fee_floor` not found. + +- [ ] **Step 5: Implement** — in `ride_pay.rs` replace `referrer_fee_ceiling` (lines 16-22): + +```rust +/// Referrer fee in base units: floor(fare * bps / 10_000). Stored as basis points so +/// fractional percentages need no config migration (spec §4a). u128 intermediate — +/// the product can exceed u64 but the result never does (result <= fare). +fn referrer_fee_floor(bps: u16, fare: u64) -> u64 { + ((fare as u128 * bps as u128) / 10_000) as u64 +} +``` + +Update the two call sites in `RidePay::state_transaction` (lines 170-177) to `referrer_fee_floor(request_fee_bps, self.fare)` / `referrer_fee_floor(offer_fee_bps, self.fare)`, and its signature to `request_fee_bps: u16, offer_fee_bps: u16`. + +Also add the spec-mandated runtime assert inside `split_fare` (§4a "Assert it"), just before the return: + +```rust + debug_assert_eq!(request + offer + driver, fare, "fee split must sum exactly"); +``` + +- [ ] **Step 6: Mechanical rename through the plumbing** — `percent`→`bps`, `u8`→`u16`, everywhere: + +- `configuration.rs:20-21`: `pub ride_request_referrer_fee_bps: u16, pub ride_offer_referrer_fee_bps: u16` +- `blockchain.rs`: fields (27-28), constructor params (39-40), initializers (52-53), `import_block` args (123-124), getters (151-157) — rename to `_bps`, type `u16` +- `block.rs:296-302`: `add_block_to_chain(db, block, block_reward_amount: u64, ride_request_referrer_fee_bps: u16, ride_offer_referrer_fee_bps: u16)`; pass-through at 336-341 +- `transaction.rs:233-238`: `state_transaction(&self, db, ride_request_referrer_fee_bps: u16, ride_offer_referrer_fee_bps: u16)`; RidePay arm (250-256) passes bps +- All 6 TOMLs: replace `ride_request_referrer_fee_percent = 2` → `ride_request_referrer_fee_bps = 200`, same for offer side +- Find stragglers (incl. `src/main.rs` constructing `Blockchain::new`, and `tests/`) — case-insensitive, because `tests/balance_effects.rs:17` has an uppercase `const REFERRER_FEE_PERCENT`: + +```bash +grep -rni "fee_percent" src/ tests/ config/ && echo FOUND-FIX-THESE || echo CLEAN +``` + +Also fix the positional `2, 2` percent literals passed to `Blockchain::new` in `tests/ride_sharing.rs` and `tests/block_reward.rs` (grep won't catch bare numbers) — they become `200, 200`. + +- [ ] **Step 7: Run full test suite** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +``` + +Expected: PASS after these test updates: +- `tests/balance_effects.rs`: `const REFERRER_FEE_PERCENT: u8 = 2` → `const REFERRER_FEE_BPS: u16 = 200`. Its RidePay test uses fare 10, and floor(10 × 200 / 10_000) = **0** — no referrer effect is emitted at all (`request_fee > 0` gate). Raise the fare in that test to 100 so the expected referrer delta is 2, and update the driver-remainder assertion to 98 accordingly. +- `tests/referrer_account.rs` carries its own local `referrer_fee_ceiling` copy and does not touch node fee code — it keeps passing unchanged; leave it (its floor migration is cosmetic). + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat!: referrer fees in basis points with floor rounding + +Replaces ceiling percent (2% of 3 rounded to 33%) with bps floor per +treasury spec 4a. Driver share stays remainder-based; property test +pins request+offer+driver == fare for all inputs. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: ChainInit transaction type (+ new BalanceEffectKind variants) + +**Files:** +- Create: `src/node/transactions/chain_init.rs` +- Modify: `src/node/transactions/function_call.rs`, `src/node/transactions/mod.rs`, `src/node/rlp_encoding.rs`, `src/node/transactions/transaction.rs` (match arms only), `src/node/balance_effect.rs:7-16` +- Test: `tests/chain_init.rs` + +**Interfaces:** +- Produces (used by Tasks 3-8): + - `pub struct ChainInit { pub chain_id: u64, pub is_testnet: bool, pub tx_fee: u64, pub ride_request_referrer_fee_bps: u16, pub ride_offer_referrer_fee_bps: u16, pub mint_authority: String, pub faucet_address: String, pub faucet_allocation: u64 }` + - `ChainInit::get(db: &Database) -> Result` — reads state key `chain_params` + - `ChainInit::get_total_supply(db: &Database) -> Result` — reads state key `total_supply`, `Ok(0)` if absent + - `pub const CHAIN_PARAMS_KEY: &[u8]`, `pub const TOTAL_SUPPLY_KEY: &[u8]` + - `FunctionCall::ChainInit(ChainInit)` — RLP tag 9 + - `BalanceEffectKind::{Mint, Burn, TxFeePaid, TxFeeEarned}` + +- [ ] **Step 1: Write the failing test** — `tests/chain_init.rs`: + +```rust +use clutch_node::node::database::Database; +use clutch_node::node::transactions::chain_init::ChainInit; +use serial_test::serial; + +fn test_params() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + faucet_address: "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +#[test] +fn chain_init_rlp_roundtrip() { + let ci = test_params(); + let encoded = clutch_node::node::rlp_encoding::encode(&ci); + let decoded: ChainInit = clutch_node::node::rlp_encoding::decode(&encoded).unwrap(); + assert_eq!(ci, decoded); +} + +#[test] +#[serial] +fn chain_init_rejected_outside_genesis() { + let db = Database::new_db("test-chain-init-reject"); + let err = test_params() + .verify_state(&"0xanyone".to_string(), &db) + .unwrap_err(); + assert!(err.contains("genesis"), "got: {}", err); + drop(db); + let mut db = Database::new_db("test-chain-init-reject"); + db.close(); + db.delete_database("test-chain-init-reject").unwrap(); +} + +#[test] +#[serial] +fn chain_init_state_writes_params_supply_and_faucet() { + let name = "test-chain-init-state"; + let db = Database::new_db(name); + let ci = test_params(); + let updates = ci.state_transaction(&db); + // Apply the storage updates the way add_block_to_chain would. + let ops: Vec<(&str, &[u8], Option<&[u8]>)> = updates + .iter() + .filter_map(|u| u.storage.as_ref()) + .map(|(k, v)| ("state", k.as_slice(), Some(v.as_slice()))) + .collect(); + db.write(ops).unwrap(); + + assert_eq!(ChainInit::get(&db).unwrap(), ci); + assert_eq!(ChainInit::get_total_supply(&db).unwrap(), ci.faucet_allocation); + let faucet = clutch_node::node::account_state::AccountState::get_current_state( + &ci.faucet_address, &db, + ); + assert_eq!(faucet.balance, ci.faucet_allocation); + + let mut db = db; + db.close(); + db.delete_database(name).unwrap(); +} + +#[test] +#[serial] +fn chain_init_mainnet_flag_zeroes_supply() { + let name = "test-chain-init-mainnet"; + let db = Database::new_db(name); + let ci = ChainInit { is_testnet: false, faucet_allocation: 0, ..test_params() }; + let updates = ci.state_transaction(&db); + let ops: Vec<(&str, &[u8], Option<&[u8]>)> = updates + .iter() + .filter_map(|u| u.storage.as_ref()) + .map(|(k, v)| ("state", k.as_slice(), Some(v.as_slice()))) + .collect(); + db.write(ops).unwrap(); + assert_eq!(ChainInit::get_total_supply(&db).unwrap(), 0); + let mut db = db; + db.close(); + db.delete_database(name).unwrap(); +} +``` + +> Database API verified against `src/node/database.rs`: `new_db(&str) -> Database`, `get(&self, cf, key) -> Result>, String>`, `write(&self, Vec<(&str, &[u8], Option<&[u8]>)>)`, `close(&mut self)`, `delete_database(&self, name)`. The test code above matches these signatures as written. + +- [ ] **Step 2: Run to verify failure** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test chain_init +``` + +Expected: FAIL — module `chain_init` not found. + +- [ ] **Step 3: Create `src/node/transactions/chain_init.rs`** + +```rust +use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; +use serde::{Deserialize, Serialize}; + +use crate::node::account_state::AccountState; +use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; +use crate::node::database::Database; + +pub const CHAIN_PARAMS_KEY: &[u8] = b"chain_params"; +pub const TOTAL_SUPPLY_KEY: &[u8] = b"total_supply"; + +/// Consensus parameters, committed to by the genesis hash: this struct rides in the +/// genesis block's single ChainInit transaction, whose hash feeds the block hash that +/// peers compare at p2p handshake. Runtime reads them from state via `get`, never from +/// per-node config — a node with different values gets a different genesis and cannot +/// peer. This closes the block_reward-style consensus-divergence bug class. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ChainInit { + pub chain_id: u64, + pub is_testnet: bool, + pub tx_fee: u64, + pub ride_request_referrer_fee_bps: u16, + pub ride_offer_referrer_fee_bps: u16, + pub mint_authority: String, + pub faucet_address: String, + pub faucet_allocation: u64, +} + +impl ChainInit { + pub fn get(db: &Database) -> Result { + match db.get("state", CHAIN_PARAMS_KEY) { + Ok(Some(v)) => serde_json::from_slice(&v) + .map_err(|e| format!("corrupt chain_params in state: {}", e)), + Ok(None) => Err("chain_params missing from state (genesis not imported?)".to_string()), + Err(e) => Err(format!("failed to read chain_params: {}", e)), + } + } + + pub fn get_total_supply(db: &Database) -> Result { + match db.get("state", TOTAL_SUPPLY_KEY) { + Ok(Some(v)) => serde_json::from_slice(&v) + .map_err(|e| format!("corrupt total_supply in state: {}", e)), + Ok(None) => Ok(0), + Err(e) => Err(format!("failed to read total_supply: {}", e)), + } + } + + pub fn verify_state(&self, _from: &String, _db: &Database) -> Result<(), String> { + // Genesis import bypasses validate_transaction entirely, so reaching this check + // means the tx arrived via the pool or a non-genesis block — always reject. + Err("ChainInit is only valid in the genesis block".to_string()) + } + + pub fn state_transaction(&self, db: &Database) -> Vec { + let initial_supply = if self.is_testnet { self.faucet_allocation } else { 0 }; + let mut updates = vec![ + StateUpdate::storage_only( + CHAIN_PARAMS_KEY.to_vec(), + serde_json::to_vec(self).expect("serialize chain params"), + ), + StateUpdate::storage_only( + TOTAL_SUPPLY_KEY.to_vec(), + serde_json::to_vec(&initial_supply).expect("serialize supply"), + ), + ]; + if initial_supply > 0 { + // faucet_allocation is validated <= i64::MAX at boot (Blockchain::new). + updates.push(AccountState::apply_balance_change( + &self.faucet_address, + initial_supply as i64, + BalanceEffectKind::Mint, + None, + db, + )); + } + updates + } +} + +impl Encodable for ChainInit { + fn rlp_append(&self, stream: &mut RlpStream) { + stream.begin_list(8); + stream.append(&self.chain_id); + stream.append(&(self.is_testnet as u8)); + stream.append(&self.tx_fee); + stream.append(&self.ride_request_referrer_fee_bps); + stream.append(&self.ride_offer_referrer_fee_bps); + stream.append(&self.mint_authority); + stream.append(&self.faucet_address); + stream.append(&self.faucet_allocation); + } +} + +impl Decodable for ChainInit { + fn decode(rlp: &Rlp) -> Result { + if !rlp.is_list() || rlp.item_count()? != 8 { + return Err(DecoderError::RlpIncorrectListLen); + } + Ok(ChainInit { + chain_id: rlp.val_at(0)?, + is_testnet: rlp.val_at::(1)? != 0, + tx_fee: rlp.val_at(2)?, + ride_request_referrer_fee_bps: rlp.val_at(3)?, + ride_offer_referrer_fee_bps: rlp.val_at(4)?, + mint_authority: rlp.val_at(5)?, + faucet_address: rlp.val_at(6)?, + faucet_allocation: rlp.val_at(7)?, + }) + } +} +``` + +- [ ] **Step 4: Wire the enum and effects** + +`function_call.rs` — add import, variant, Display arm: + +```rust +use super::chain_init::ChainInit; +// in enum FunctionCall: + ChainInit(ChainInit), +// in Display: + FunctionCall::ChainInit(args) => write!(f, "ChainInit: {:?}", args), +``` + +`src/node/transactions/mod.rs` — add `pub mod chain_init;` (mirror how `transfer` is declared). + +`rlp_encoding.rs` — encode arm (tag 9, after RideRequestCancel) and decode arm: + +```rust +// encode, inside the match: +FunctionCall::ChainInit(args) => { + stream.begin_list(2); + stream.append(&9u8); // Tag for ChainInit (genesis-only) + stream.append(args); +} +// decode, inside the match: +9 => { + let args: ChainInit = rlp.val_at(1)?; + Ok(FunctionCall::ChainInit(args)) +} +``` + +(and `use super::transactions::chain_init::ChainInit;` at the top.) + +`transaction.rs` — three match arms: + +```rust +// verify_state: +FunctionCall::ChainInit(chain_init) => chain_init.verify_state(&self.from, db), +// function_call_type: +FunctionCall::ChainInit(_) => "ChainInit", +// state_transaction: +FunctionCall::ChainInit(chain_init) => chain_init.state_transaction(db), +``` + +`balance_effect.rs:7-16` — extend the kind enum: + +```rust +pub enum BalanceEffectKind { + TransferOut, + TransferIn, + RideAcceptanceDebit, + RidePayDriverCredit, + ReferrerRequestFee, + ReferrerOfferFee, + RideCancelRefund, + BlockReward, + Mint, + Burn, + TxFeePaid, + TxFeeEarned, +} +``` + +> Follow-up flagged for later plans: clutch-explorer deserializes these kinds — confirm it tolerates unknown variants before deploying a node emitting them. + +- [ ] **Step 5: Run tests to verify pass** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test chain_init +``` + +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "feat!: ChainInit genesis transaction carrying consensus parameters + +New RLP tag 9, genesis-only (verify_state always rejects). Writes +chain_params + total_supply state keys and the testnet faucet credit. +Adds Mint/Burn/TxFeePaid/TxFeeEarned balance-effect kinds. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Genesis rework — params from state, block reward removed, testnet-gated faucet + +**Files:** +- Modify: `src/node/configuration.rs`, `src/node/transactions/transaction.rs:43-58,233-261`, `src/node/blocks/block.rs:49-65,264-280,296-400`, `src/node/blockchain.rs`, `src/main.rs`, `src/node/wss/websocket.rs:355-370`, `config/node/*.toml` (6 files) +- Test: `tests/chain_genesis.rs`; update `Blockchain::new` call sites in `tests/*.rs` and `src/main.rs` + +**Interfaces:** +- Consumes: `ChainInit` (Task 2), bps fee fns (Task 1). +- Produces (relied on by Tasks 4-8): + - `Blockchain::new(name: String, author_public_key: String, author_secret_key: String, developer_mode: bool, authorities: Vec, chain_init: ChainInit) -> Blockchain` — panics at boot if `request_bps + offer_bps > 10_000`, if `faucet_allocation > i64::MAX as u64`, or if `!is_testnet && faucet_allocation > 0` (spec §4.5 "fails loudly"). + - `Transaction::new_genesis_transactions(params: &ChainInit) -> Vec` — single ChainInit tx from `0xGENESIS`. + - `Block::new_genesis_block(params: &ChainInit) -> Block`; `Block::genesis_import_block(db: &Database, params: &ChainInit)`. + - `Block::add_block_to_chain(db: &Database, block: &Block) -> Result<(), String>` — resolves params internally via `params_for_block`. + - `Transaction::state_transaction(&self, db: &Database, params: &ChainInit) -> Vec`. + - `AppConfig` gains `chain_id: u64, is_testnet: bool, tx_fee: u64, mint_authority: String, faucet_address: String, faucet_allocation: u64`; **loses** `block_reward_amount`. + - Block rewards are gone: no `BlockReward` effects are emitted anywhere. + +- [ ] **Step 1: Write the failing test** — `tests/chain_genesis.rs`: + +```rust +use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::transactions::chain_init::ChainInit; +use serial_test::serial; + +fn test_chain_init() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + faucet_address: "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +fn new_test_chain(name: &str, ci: ChainInit) -> Blockchain { + Blockchain::new( + name.to_string(), + "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509".to_string(), + true, // developer_mode: DB wiped on shutdown_blockchain + vec!["0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string()], + ci, + ) +} + +#[test] +#[serial] +fn genesis_funds_faucet_and_stores_params() { + let ci = test_chain_init(); + let mut chain = new_test_chain("test-genesis-testnet", ci.clone()); + assert_eq!(chain.get_account_balance(&ci.faucet_address), ci.faucet_allocation); + let (params, supply) = chain.get_chain_info().unwrap(); + assert_eq!(params, ci); + assert_eq!(supply, ci.faucet_allocation); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn genesis_hash_commits_to_chain_params() { + let mut a = new_test_chain("test-genesis-a", test_chain_init()); + let hash_a = a.get_genesis_block().unwrap().unwrap().hash; + a.shutdown_blockchain(); + + let mut b = new_test_chain( + "test-genesis-b", + ChainInit { chain_id: 1, ..test_chain_init() }, + ); + let hash_b = b.get_genesis_block().unwrap().unwrap().hash; + b.shutdown_blockchain(); + + assert_ne!(hash_a, hash_b, "different chain params must yield different genesis hashes"); +} + +#[test] +#[serial] +fn mainnet_genesis_has_zero_supply() { + let ci = ChainInit { is_testnet: false, faucet_allocation: 0, ..test_chain_init() }; + let mut chain = new_test_chain("test-genesis-mainnet", ci.clone()); + assert_eq!(chain.get_account_balance(&ci.faucet_address), 0); + let (_, supply) = chain.get_chain_info().unwrap(); + assert_eq!(supply, 0); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +#[should_panic(expected = "faucet")] +fn mainnet_with_faucet_allocation_fails_loudly() { + let ci = ChainInit { is_testnet: false, faucet_allocation: 1, ..test_chain_init() }; + let _ = new_test_chain("test-genesis-loud", ci); +} +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test chain_genesis +``` + +Expected: FAIL — `Blockchain::new` arity / `get_chain_info` missing. + +- [ ] **Step 3: Config fields** — `configuration.rs` `AppConfig`: remove `block_reward_amount`, add: + +```rust + pub chain_id: u64, + pub is_testnet: bool, + pub tx_fee: u64, + pub mint_authority: String, + pub faucet_address: String, + pub faucet_allocation: u64, +``` + +All 6 TOMLs — remove `block_reward_amount = 50`, add (identical values in every file; without identical values the nodes can't peer): + +```toml +chain_id = 2077 +is_testnet = true +tx_fee = 1000 +mint_authority = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20" +faucet_address = "0xdeb4cfb63db134698e1879ea24904df074726cc0" +faucet_allocation = 1000000000000000 +``` + +> `mint_authority` here is node1's dev authority key — testnet convenience only. Production genesis uses a dedicated treasury key from the key ceremony (never a validator key). + +- [ ] **Step 4: Genesis transactions** — `transaction.rs:43-58` replace `new_genesis_transactions`: + +```rust + pub fn new_genesis_transactions(params: &ChainInit) -> Vec { + vec![Self::new_transaction( + FROM_GENESIS.to_string(), + 0, + FunctionCall::ChainInit(params.clone()), + )] + } +``` + +(add `use super::chain_init::ChainInit;` — the old faucet Transfer and its comment are deleted; the `0xGENESIS`-debit clamp path in `account_state.rs` is no longer exercised because ChainInit only credits.) + +- [ ] **Step 5: Params-based state dispatch** — `transaction.rs:233-261` change signature and RidePay arm: + +```rust + pub fn state_transaction(&self, db: &Database, params: &ChainInit) -> Vec { + let mut states = match &self.data { + // ... all arms unchanged except RidePay: + FunctionCall::RidePay(ride_pay) => ride_pay.state_transaction( + &self.hash, + db, + params.ride_request_referrer_fee_bps, + params.ride_offer_referrer_fee_bps, + &self.from, + ), + FunctionCall::ChainInit(chain_init) => chain_init.state_transaction(db), + // ... + }; + // nonce push unchanged + states + } +``` + +- [ ] **Step 6: Block-side wiring** — `block.rs`: + +`new_genesis_block` (49-65) and `genesis_import_block` (264-280) take `params: &ChainInit` and pass through (`Transaction::new_genesis_transactions(params)`; `Self::add_block_to_chain(db, &genesis_block)`). + +Replace `add_block_to_chain` signature (296-302) and delete the block-reward section (375-400): + +```rust + /// Resolve consensus params: from state for normal blocks; from the block's own + /// ChainInit for the genesis import (its params aren't in state yet). + fn params_for_block(db: &Database, block: &Block) -> Result { + if block.index == 0 { + block + .transactions + .iter() + .find_map(|tx| match &tx.data { + FunctionCall::ChainInit(ci) => Some(ci.clone()), + _ => None, + }) + .ok_or_else(|| "genesis block missing ChainInit transaction".to_string()) + } else { + ChainInit::get(db) + } + } + + pub fn add_block_to_chain(db: &Database, block: &Block) -> Result<(), String> { + let params = Self::params_for_block(db, block)?; +``` + +The rest of the body is the existing code with two edits. The per-tx loop head (old lines 336-341) becomes: + +```rust + for (tx_index, tx) in block.transactions.iter().enumerate() { + let updates = tx.state_transaction(&db, ¶ms); +``` + +and the entire `if block.index > 0 && block_reward_amount > 0 { ... }` block (old lines 375-400) is **deleted** — block rewards no longer exist (spec §4.2). Task 5 puts the fee credit in that slot. + +(imports: `use crate::node::transactions::chain_init::ChainInit;` and `use crate::node::transactions::function_call::FunctionCall;`; `persist_block_effects` stays imported — Task 5 uses it for the fee credit.) + +- [ ] **Step 7: Blockchain constructor + boot validation** — `blockchain.rs`: remove fields `block_reward_amount`, `ride_request_referrer_fee_bps`, `ride_offer_referrer_fee_bps` and their getters; add field `chain_init: ChainInit`: + +```rust + pub fn new( + name: String, + author_public_key: String, + author_secret_key: String, + developer_mode: bool, + authorities: Vec, + chain_init: ChainInit, + ) -> Blockchain { + // Fail loudly at boot on inconsistent economics — spec §4.5. Genesis must never + // be importable with a mainnet flag and a faucet pre-mint. + assert!( + chain_init.ride_request_referrer_fee_bps as u32 + + chain_init.ride_offer_referrer_fee_bps as u32 + <= 10_000, + "referrer fee bps sum exceeds 100%" + ); + assert!( + chain_init.faucet_allocation <= i64::MAX as u64, + "faucet_allocation exceeds i64::MAX (balance deltas are i64)" + ); + assert!( + chain_init.is_testnet || chain_init.faucet_allocation == 0, + "non-testnet chain must have zero faucet_allocation (a surviving faucet pre-mint destroys the peg)" + ); + + let db = Database::new_db(&name); + let step_duration = 60 / authorities.len() as u64; + let blockchain = Blockchain { + name, + db, + developer_mode, + consensus: Aura::new(authorities, step_duration), + author_public_key, + author_secret_key, + chain_init, + }; + + Block::genesis_import_block(&blockchain.db, &blockchain.chain_init); + blockchain + } + + /// Consensus params + total supply, read from state (post-genesis truth). + pub fn get_chain_info(&self) -> Result<(ChainInit, u64), String> { + let params = ChainInit::get(&self.db)?; + let supply = ChainInit::get_total_supply(&self.db)?; + Ok((params, supply)) + } +``` + +`import_block` (115-128): `Block::add_block_to_chain(&self.db, block)?;` — no param plumbing. + +- [ ] **Step 8: main.rs + websocket compat** — `src/main.rs` declares `mod node;` and imports via `use node::...` (NOT the `clutch_node` lib path — that resolves to a *different* copy of the types and E0308s against the bin's `Blockchain::new`). Add alongside the existing `use node::blockchain::Blockchain;`: + +```rust +use node::transactions::chain_init::ChainInit; +``` + +then build the struct from config and pass it: + +```rust + let chain_init = ChainInit { + chain_id: config.chain_id, + is_testnet: config.is_testnet, + tx_fee: config.tx_fee, + ride_request_referrer_fee_bps: config.ride_request_referrer_fee_bps, + ride_offer_referrer_fee_bps: config.ride_offer_referrer_fee_bps, + mint_authority: config.mint_authority.clone(), + faucet_address: config.faucet_address.clone(), + faucet_allocation: config.faucet_allocation, + }; + + let blockchain = Blockchain::new( + config.blockchain_name.clone(), + config.author_public_key.clone(), + config.author_secret_key.clone(), + config.developer_mode, + config.authorities.clone(), + chain_init, + ); +``` + +(match the existing call's argument sources — the current call passes the same config fields plus the three now-removed reward/percent values.) + +`websocket.rs` `handle_get_block_by_index` (~355-370): the getter `blockchain.block_reward_amount()` is gone — keep the JSON field for explorer compatibility with a literal: + +```rust + // ponytail: block rewards removed; field kept as 0 until clutch-explorer drops it. + let block_reward: u64 = 0; +``` + +- [ ] **Step 9: Fix remaining call sites** + +```bash +grep -rn "block_reward\|Blockchain::new\|new_genesis\|genesis_import_block\|add_block_to_chain\|state_transaction(" src/ tests/ --include="*.rs" | grep -v "docs/" +``` + +(`genesis_import_block` matters: `tests/balance_effects.rs:49` calls `Block::genesis_import_block(&db)` directly for its DB setup — it now needs a `&ChainInit` argument; use the test-fixture `ci()` pattern.) + +Update every caller to the new signatures (tests use the `new_test_chain` helper pattern from Step 1). `tests/block_reward.rs`: **delete the file** — rewards no longer exist; Task 5 adds `tests/tx_fee.rs` as its successor. + +- [ ] **Step 10: Run full suite** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +``` + +Expected: PASS. + +- [ ] **Step 11: Commit** + +```bash +git add -A +git commit -m "feat!: genesis carries ChainInit; params read from state; block rewards removed + +Genesis hash now commits to chain_id/fees/mint authority, so mismatched +nodes cannot peer (fixes the config-divergence class block_reward had). +Faucet allocation is testnet-gated and fails loudly on mainnet flags. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: chain_id in the signed transaction payload + +**Files:** +- Modify: `src/node/transactions/transaction.rs`, `src/node/rlp_encoding.rs:107-159`, plus every `Transaction::new_transaction` / raw-RLP fixture call site (`tests/rlp_decode_test.rs`, in-file tests) +- Test: `transaction.rs` `#[cfg(test)]` + `tests/` updates + +**Interfaces:** +- Consumes: `ChainInit::get` (Task 2). +- Produces: + - `Transaction.chain_id: u64` (serde + RLP). + - Wire RLP: **8-item list** `[from, nonce, chain_id, signature_r, signature_s, signature_v, hash, data]`. + - Hash preimage: **4-item list** `[from (no 0x), nonce, chain_id, data]` (Keccak-256). + - `Transaction::new_transaction(from: String, nonce: u64, chain_id: u64, function_call: FunctionCall) -> Transaction`. + - `validate_transaction` rejects `tx.chain_id != chain_params.chain_id`. +- **Cross-repo contract (SDK/hub follow-up plans):** the JS SDK and hub faucet must adopt the same 8-item wire format and 4-item preimage, same field order, or every tx is rejected. + +- [ ] **Step 1: Write the failing test** — add to `transaction.rs` tests (the `sdk_style_tx` helper gets a `chain_id` param in Step 3; write the new expectations first): + +```rust + #[test] + fn hash_commits_to_chain_id() { + let a = Transaction::new_transaction( + "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + 1, + 2077, + FunctionCall::Transfer(Transfer { to: "0xA".to_string(), value: 10 }), + ); + let b = Transaction::new_transaction( + "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + 1, + 1, + FunctionCall::Transfer(Transfer { to: "0xA".to_string(), value: 10 }), + ); + assert_ne!(a.hash, b.hash, "same tx on a different chain must hash differently"); + } +``` + +- [ ] **Step 2: Run to verify failure** — `new_transaction` arity error: + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --lib transactions::transaction +``` + +- [ ] **Step 3: Implement** + +`transaction.rs`: +- struct: add `pub chain_id: u64,` after `nonce`. +- `new_transaction(from, nonce, chain_id, function_call)` — set the field. +- `new_genesis_transactions`: `Self::new_transaction(FROM_GENESIS.to_string(), 0, params.chain_id, FunctionCall::ChainInit(params.clone()))`. +- `calculate_hash` (65-77): 4-item preimage — + +```rust + let mut stream = RlpStream::new(); + stream.begin_list(4); + stream.append(&from_no_prefix.to_string()); + stream.append(&self.nonce); + stream.append(&self.chain_id); + stream.append(&self.data); +``` + +(update the doc comment: preimage is now `[from (no 0x), nonce, chain_id, data]`; SDK/faucet must match.) +- `validate_transaction` (168-175): load params once and check chain: + +```rust + pub fn validate_transaction(&self, db: &Database) -> Result<(), String> { + self.verify_hash()?; + self.verify_signature()?; + let params = ChainInit::get(db)?; + if self.chain_id != params.chain_id { + return Err(format!( + "Verification failed: transaction chain_id {} does not match chain {}", + self.chain_id, params.chain_id + )); + } + self.verify_nonce(db)?; + self.verify_state(db)?; + Ok(()) + } +``` + +`rlp_encoding.rs:107-159` — Transaction 8 items: + +```rust +impl Encodable for Transaction { + fn rlp_append(&self, stream: &mut RlpStream) { + stream.begin_list(8); + stream.append(&self.from); + stream.append(&self.nonce); + stream.append(&self.chain_id); + stream.append(&self.signature_r); + stream.append(&self.signature_s); + let signature_v_as_u64 = self.signature_v as u64; + stream.append(&signature_v_as_u64); + stream.append(&self.hash); + stream.append(&self.data); + } +} +``` + +Decode — full replacement (the `from` string/bytes dual decoding stays byte-identical): + +```rust +impl Decodable for Transaction { + fn decode(rlp: &Rlp) -> Result { + if !rlp.is_list() || rlp.item_count()? != 8 { + return Err(DecoderError::RlpIncorrectListLen); + } + + // Handle 'from' field which may be encoded as binary data by JavaScript RLP library + let from = { + let from_item = rlp.at(0)?; + let from_value = if let Ok(string_val) = from_item.as_val::() { + string_val + } else if let Ok(bytes_val) = from_item.as_val::>() { + hex::encode(&bytes_val) + } else { + return Err(DecoderError::Custom("Unable to decode 'from' field as string or bytes")); + }; + if from_value.starts_with("0x") { + from_value + } else { + format!("0x{}", from_value) + } + }; + + Ok(Transaction { + from, + nonce: rlp.val_at(1)?, + chain_id: rlp.val_at(2)?, + signature_r: rlp.val_at(3)?, + signature_s: rlp.val_at(4)?, + signature_v: rlp.val_at::(5)? as i32, + hash: rlp.val_at(6)?, + data: rlp.val_at(7)?, + }) + } +} +``` + +- [ ] **Step 4: Update fixtures** — in `transaction.rs` tests, the shared builder becomes (full replacement): + +```rust + /// Builds a full signed tx for a `data` payload the way the SDK will in v3: the hash is + /// Keccak-256 over the unsigned `[from (no 0x), nonce, chain_id, data]` preimage, so these + /// bytes are self-consistent by construction. + fn sdk_style_tx(from_clean: &str, nonce: u64, chain_id: u64, data_rlp: &[u8]) -> Transaction { + let mut unsigned = RlpStream::new_list(4); + unsigned.append(&from_clean.to_string()); + unsigned.append(&nonce); + unsigned.append(&chain_id); + unsigned.append_raw(data_rlp, 1); + let mut hasher = Keccak256::new(); + hasher.update(unsigned.out().as_ref()); + let hash_hex = hex::encode(hasher.finalize()); + + let dummy = "cd".repeat(32); + let mut full = RlpStream::new_list(8); + full.append(&from_clean.to_string()); + full.append(&nonce); + full.append(&chain_id); + full.append(&dummy); + full.append(&dummy); + full.append(&28u64); + full.append(&hash_hex); + full.append_raw(data_rlp, 1); + crate::node::rlp_encoding::decode(full.out().as_ref()).expect("decode sdk-style tx") + } +``` + +- Update its two callers (`WIRE_*` tests) to pass `2077`. +- `accepts_sdk_generated_ride_acceptance_hash`: the pinned raw hex predates chain_id and cannot be regenerated without the SDK — **replace** with a `sdk_style_tx`-built RideAcceptance equivalent and this comment: `// TODO(sdk-v3): re-pin with real clutch-hub-sdk-js output once the SDK adds chain_id.` +- `accepts_faucet_style_transfer_hash` — full replacement of the builder section (mirrors the future faucet format): + +```rust + let mut unsigned = RlpStream::new_list(4); + unsigned.append(&from_clean.to_string()); + unsigned.append(&nonce); + unsigned.append(&2077u64); + unsigned.append_raw(data_rlp.as_ref(), 1); + let mut hasher = Keccak256::new(); + hasher.update(unsigned.out().as_ref()); + let hash_hex = hex::encode(hasher.finalize()); + + let dummy = "cd".repeat(32); + let mut full = RlpStream::new_list(8); + full.append(&from_clean.to_string()); + full.append(&nonce); + full.append(&2077u64); + full.append(&dummy); + full.append(&dummy); + full.append(&28u64); + full.append(&hash_hex); + full.append_raw(data_rlp.as_ref(), 1); + let raw = full.out(); +``` + +- `tf` helpers in `transaction.rs`/`blockchain.rs` tests: add chain_id `2077` (any constant — these never hit chain validation). +- `src/node/rlp_encoding.rs` in-file tests: add `chain_id: 2077,` to the `Transaction { ... }` struct literals (~lines 360-412). +- `tests/rlp_decode_test.rs`: only the `new_transaction` arity fix is required (its raw 7-item hex fixture feeds a decode test that never asserts — println-only; optionally rebuild it as an 8-item fixture with the builder above). + +```bash +grep -rn "new_transaction(\|new_list(7)\|new_list(3)" src/ tests/ --include="*.rs" +``` + +- [ ] **Step 5: Wrong-chain rejection test** — append to `tests/chain_genesis.rs`: + +```rust +#[test] +#[serial] +fn wrong_chain_id_rejected_at_pool() { + use clutch_node::node::transactions::function_call::FunctionCall; + use clutch_node::node::transactions::transaction::Transaction; + use clutch_node::node::transactions::transfer::Transfer; + + let ci = test_chain_init(); // chain_id 2077 + let mut chain = new_test_chain("test-wrong-chain", ci.clone()); + + let mut tx = Transaction::new_transaction( + ci.faucet_address.clone(), + 1, + 1, // wrong chain + FunctionCall::Transfer(Transfer { + to: "0x1111111111111111111111111111111111111111".to_string(), + value: 1, + }), + ); + tx.sign("d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"); + + let err = chain.add_transaction_to_pool(&tx).unwrap_err(); + assert!(err.contains("chain_id"), "got: {}", err); + chain.shutdown_blockchain(); +} +``` + +- [ ] **Step 6: Full suite** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat!: chain_id in transaction hash preimage and wire format + +Signatures now commit to the chain — a testnet Mint can never replay on +mainnet. Wire RLP is 8 items; preimage is [from, nonce, chain_id, data]. +SDK and hub faucet must adopt the same format (coordinated release). + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Flat transaction fee paid to the block author + +**Files:** +- Modify: `src/node/transactions/transaction.rs`, `src/node/transactions/transfer.rs`, `src/node/transactions/ride_acceptance.rs`, `src/node/transactions/ride_cancel.rs`, `src/node/account_state.rs`, `src/node/blocks/block.rs` (fee credit where reward code was) +- Test: `tests/tx_fee.rs` (successor to deleted `tests/block_reward.rs`) + +**Fee routing table — the load-bearing design.** A sender's balance key must be written **at most once per tx** (deferred-batch last-write-wins). Which types touch the sender's balance in `state_transaction` (verified against source): + +| Type | Sender balance touched in-type? | Fee handling | +|---|---|---| +| Transfer | yes — debits `value` | merged in-type via `apply_balance_change_with_fee` | +| Burn (Task 7) | yes — debits `amount` | merged in-type | +| RideAcceptance | **yes — debits full fare escrow** (`ride_acceptance.rs:159`, sender == passenger enforced) | merged in-type | +| RideCancel | **when sender == passenger** — refund credit hits sender (`ride_cancel.rs:138`) | handled fully in-type: merge into refund when sender==passenger, standalone debit when sender==driver | +| RideRequest, RideOffer, RidePay, RideRequestCancel | no balance writes | central standalone fee debit in `Transaction::state_transaction` | +| Mint, ChainInit | — | fee-exempt | + +**Interfaces:** +- Consumes: `ChainInit.tx_fee`, `BalanceEffectKind::{TxFeePaid, TxFeeEarned}`. +- Produces: + - `Transaction::fee_exempt(&self) -> bool` — true for `Mint` (Task 6; write the match arm with `ChainInit` only for now) and `ChainInit`. + - `Transaction::sender_direct_debit(&self) -> u64` — **statically-known** sender debits only: `Transfer.value` now, `Burn.amount` in Task 7, else 0. RideAcceptance's fare debit is db-dependent, so its fare+fee sufficiency is enforced inside `RideAcceptance::verify_state` instead (below). + - `Transaction::effective_fee(&self, block_author: &str, params: &ChainInit) -> u64` — 0 if exempt or sender == author (canonical compare), else `params.tx_fee`. + - `Transaction::state_transaction(&self, db, params: &ChainInit, block_author: &str)`. + - `Transfer::state_transaction(&self, from, db, fee: u64)`; `RideAcceptance::state_transaction(&self, from, tx_hash, db, fee: u64)`; `RideCancel::state_transaction(&self, from, tx_hash, db, fee: u64)` (gains `from`). + - `AccountState::apply_balance_change_with_fee(public_key, main_delta: i64, fee: u64, kind, counterparty, db) -> Vec`. + - `RideAcceptance::verify_state` additionally requires `balance >= fare + tx_fee` (reads `ChainInit::get(db)`). + - `add_block_to_chain`: single block-level author credit `TxFeeEarned` = Σ effective fees. +- Validation rule: non-exempt tx requires `balance >= sender_direct_debit() + tx_fee` (checked add) in `validate_transaction`. Pool-side check has no author context, so it conservatively requires the fee even for the author's own tx. Known conservative edge, accepted + documented: a passenger whose entire balance is escrowed cannot RideCancel until they hold `tx_fee` loose CLT (the incoming refund doesn't count at validation time). + +- [ ] **Step 1: Write the failing tests** — `tests/tx_fee.rs`: + +```rust +use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::transactions::chain_init::ChainInit; +use clutch_node::node::transactions::function_call::FunctionCall; +use clutch_node::node::transactions::transaction::Transaction; +use clutch_node::node::transactions::transfer::Transfer; +use serial_test::serial; + +const AUTHOR_PK: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; +const AUTHOR_SK: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; +const FAUCET_PK: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; +// Faucet secret is the committed dev key from clutch-hub-api config/default.toml:18. +const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; +const CHAIN_ID: u64 = 2077; +const TX_FEE: u64 = 1000; + +fn ci() -> ChainInit { + ChainInit { + chain_id: CHAIN_ID, + is_testnet: true, + tx_fee: TX_FEE, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: AUTHOR_PK.to_string(), + faucet_address: FAUCET_PK.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +fn chain(name: &str) -> Blockchain { + Blockchain::new( + name.to_string(), + AUTHOR_PK.to_string(), + AUTHOR_SK.to_string(), + true, + vec![AUTHOR_PK.to_string()], + ci(), + ) +} + +fn signed_transfer(from: &str, sk: &str, nonce: u64, to: &str, value: u64) -> Transaction { + let mut tx = Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Transfer(Transfer { to: to.to_string(), value }), + ); + tx.sign(sk); + tx +} + +#[test] +#[serial] +fn transfer_charges_fee_and_credits_author() { + let mut chain = chain("test-fee-basic"); + let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); + + let tx = signed_transfer(FAUCET_PK, FAUCET_SK, 1, "0x1111111111111111111111111111111111111111", 500); + chain.add_transaction_to_pool(&tx).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before - 500 - TX_FEE, + "sender pays value + fee" + ); + assert_eq!( + chain.get_account_balance(&"0x1111111111111111111111111111111111111111".to_string()), + 500 + ); + assert_eq!( + chain.get_account_balance(&AUTHOR_PK.to_string()), + TX_FEE, + "author earns the fee (no block reward anymore)" + ); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn exact_balance_without_fee_is_rejected() { + use clutch_node::node::signature_keys::SignatureKeys; + let mut chain = chain("test-fee-insufficient"); + // Fund a fresh account with exactly `value` (no headroom for the fee). + let poor = SignatureKeys::generate_new_keypair(); + let fund = signed_transfer(FAUCET_PK, FAUCET_SK, 1, &poor.address_key, 500); + chain.add_transaction_to_pool(&fund).unwrap(); + chain.author_new_block().unwrap(); + + let overspend = signed_transfer(&poor.address_key, &poor.secret_key, 1, FAUCET_PK, 500); + let err = chain.add_transaction_to_pool(&overspend).unwrap_err(); + assert!(err.to_lowercase().contains("fee") || err.to_lowercase().contains("insufficient"), "got: {}", err); + chain.shutdown_blockchain(); +} +``` + +> `signed_transfer` takes `&str` — adjust the helper's params or call with `poor.address_key.as_str()`. + +> `author_new_block` requires this node to be the current Aura slot author; with a single authority (as here) every slot is ours, so it always succeeds. + +> The author-pays-no-fee case is tested in Task 6's `author_own_tx_pays_no_fee` — it needs Mint to fund the author collision-free (funding the author by Transfer in a self-authored block would hit the known deferred-batch same-account collision: TransferIn write + fee-credit write, last one wins). + +- [ ] **Step 2: Run to verify failure** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test tx_fee +``` + +Expected: FAIL — balances off by fee amounts / helpers missing. + +- [ ] **Step 3: AccountState helper** — `account_state.rs` after `apply_balance_change`: + +```rust + /// Sender leg with fee merged into ONE balance write and two audit effects. + /// Two separate apply_balance_change calls on the same account within a tx would + /// each read pre-block state and the deferred batch keeps only the last write — + /// silently dropping one debit. One write, split effects, no collision. + pub fn apply_balance_change_with_fee( + public_key: &String, + main_delta: i64, + fee: u64, + kind: BalanceEffectKind, + counterparty: Option, + db: &Database, + ) -> Vec { + if fee == 0 { + return vec![Self::apply_balance_change(public_key, main_delta, kind, counterparty, db)]; + } + let canonical = canonical_account_address(public_key); + let combined = main_delta - fee as i64; + let (key, value) = Self::update_account_state_key(public_key, combined, db); + vec![ + StateUpdate { + storage: Some((key, value)), + effect: Some(BalanceEffect { + address: canonical.clone(), + delta: main_delta, + kind, + counterparty, + }), + }, + StateUpdate { + storage: None, // effect-only: storage already carries the combined write + effect: Some(BalanceEffect { + address: canonical, + delta: -(fee as i64), + kind: BalanceEffectKind::TxFeePaid, + counterparty: None, + }), + }, + ] + } +``` + +- [ ] **Step 4: Transfer merges its fee** — `transfer.rs`: + +```rust + pub fn state_transaction(&self, from: &String, db: &Database, fee: u64) -> Vec { + let transfer_value: i64 = self.value as i64; + let to = self.to.clone(); + + let mut updates = AccountState::apply_balance_change_with_fee( + from, + -transfer_value, + fee, + BalanceEffectKind::TransferOut, + Some(to.clone()), + db, + ); + updates.push(AccountState::apply_balance_change( + &to, + transfer_value, + BalanceEffectKind::TransferIn, + Some(from.clone()), + db, + )); + updates + } +``` + +- [ ] **Step 5: Transaction fee helpers + dispatch** — `transaction.rs`: + +```rust + /// Mint is exempt: the treasury authority mints TO users and may itself hold zero + /// balance. ChainInit is genesis-only. Everything else pays the flat fee. + fn fee_exempt(&self) -> bool { + matches!(&self.data, FunctionCall::ChainInit(_)) + // Task 6 extends this to: FunctionCall::Mint(_) | FunctionCall::ChainInit(_) + } + + /// CLT the sender's balance is directly debited by this tx (excluding the fee). + fn sender_direct_debit(&self) -> u64 { + match &self.data { + FunctionCall::Transfer(t) => t.value, + // Task 7 adds: FunctionCall::Burn(b) => b.amount, + _ => 0, + } + } + + /// ponytail: author's own tx nets zero fee — a debit and an aggregate credit on the + /// same account in one block collide in the deferred batch (last write wins), so we + /// skip both sides instead. Lift with incremental intra-block state. + pub fn effective_fee(&self, block_author: &str, params: &ChainInit) -> u64 { + use crate::node::transactions::address::canonical_account_address; + if self.fee_exempt() + || canonical_account_address(&self.from) == canonical_account_address(block_author) + { + 0 + } else { + params.tx_fee + } + } +``` + +In `validate_transaction`, after the chain_id check: + +```rust + if !self.fee_exempt() { + let required = self + .sender_direct_debit() + .checked_add(params.tx_fee) + .ok_or("Verification failed: amount + fee overflows u64")?; + let balance = AccountState::get_current_state(&self.from, db).balance; + if balance < required { + return Err(format!( + "Verification failed: insufficient balance for amount + fee. Required: {}, available: {}", + required, balance + )); + } + } +``` + +`state_transaction` gains the author and routes fees per the routing table: + +```rust + pub fn state_transaction( + &self, + db: &Database, + params: &ChainInit, + block_author: &str, + ) -> Vec { + let fee = self.effective_fee(block_author, params); + let mut states = match &self.data { + FunctionCall::Transfer(transfer) => transfer.state_transaction(&self.from, db, fee), + FunctionCall::RideAcceptance(ride_acceptance) => { + ride_acceptance.state_transaction(&self.from, &self.hash, db, fee) + } + FunctionCall::RideCancel(ride_cancel) => { + ride_cancel.state_transaction(&self.from, &self.hash, db, fee) + } + // Task 7 adds: FunctionCall::Burn(burn) => burn.state_transaction(&self.from, &self.hash, db, fee), + // ... remaining arms unchanged ... + }; + + // Standalone fee debit ONLY for types that never write the sender's balance + // in-type (see routing table). Types that do (Transfer, Burn, RideAcceptance, + // RideCancel) merge the fee themselves — two writes to one account key in a tx + // collide in the deferred batch (last write wins). + let fee_handled_in_type = matches!( + &self.data, + FunctionCall::Transfer(_) + | FunctionCall::RideAcceptance(_) + | FunctionCall::RideCancel(_) + // Task 7 adds: | FunctionCall::Burn(_) + ); + if fee > 0 && !fee_handled_in_type { + states.push(AccountState::apply_balance_change( + &self.from, + -(fee as i64), + BalanceEffectKind::TxFeePaid, + None, + db, + )); + } + + // nonce push unchanged + ... + states + } +``` + +`ride_acceptance.rs` — escrow debit merges the fee. Signature `state_transaction(&self, from: &String, tx_hash: &String, db: &Database, fee: u64)` (unchanged params otherwise); replace the `passenger_update` block (lines 158-165) and splice the resulting Vec: + +```rust + let transfer_value: i64 = ride_offer.fare as i64; + // ponytail: fee merged into the single escrow write — a separate TxFeePaid write + // on the same account would collide in the deferred batch. Lift with + // incremental intra-block state. + let passenger_updates = AccountState::apply_balance_change_with_fee( + from, + -transfer_value, + fee, + BalanceEffectKind::RideAcceptanceDebit, + None, + db, + ); + + let mut updates = vec![ + StateUpdate::storage_only(ride_acceptance_key, ride_acceptance_value), + StateUpdate::storage_only(ride_request_acceptance_key, ride_request_acceptance_value), + StateUpdate::storage_only(ride_offer_acceptance_key, ride_offer_acceptance_value), + ]; + updates.extend(passenger_updates); + updates +``` + +`ride_acceptance.rs::verify_state` — the balance check (lines 80-87) must cover fare + fee (the fare is db-dependent, so the central `sender_direct_debit` check can't see it): + +```rust + let tx_fee = crate::node::transactions::chain_init::ChainInit::get(db)?.tx_fee; + let required = ride_offer + .fare + .checked_add(tx_fee) + .ok_or("fare + fee overflows u64")?; + let passenger_account_state = AccountState::get_current_state(from, db); + if passenger_account_state.balance < required { + return Err(format!( + "The account balance is insufficient to cover the fare plus the transaction fee. \ + Account balance is: {}, fare: {}, fee: {}", + passenger_account_state.balance, ride_offer.fare, tx_fee + )); + } +``` + +`ride_cancel.rs` — signature `state_transaction(&self, from: &String, tx_hash: &String, db: &Database, fee: u64)`; replace the `passenger_update` block (lines 136-150): + +```rust + let remaining_amount = (ride_offer.fare as i64) - (fare_paid as i64); + + use crate::node::transactions::address::canonical_account_address; + let sender_is_passenger = + canonical_account_address(from) == canonical_account_address(&passenger); + + // ponytail: when the passenger cancels, refund credit and fee debit hit the SAME + // account — merge into one write. Driver-cancel: driver's key is otherwise + // untouched, standalone fee debit is safe. + let mut updates = vec![ + StateUpdate::storage_only(ride_cancel_key, ride_cancel_value), + StateUpdate::storage_only(ride_acceptance_cancel_key, ride_acceptance_cancel_value), + ]; + if sender_is_passenger { + updates.extend(AccountState::apply_balance_change_with_fee( + &passenger, + remaining_amount, + fee, + BalanceEffectKind::RideCancelRefund, + None, + db, + )); + } else { + updates.push(AccountState::apply_balance_change( + &passenger, + remaining_amount, + BalanceEffectKind::RideCancelRefund, + None, + db, + )); + if fee > 0 { + updates.push(AccountState::apply_balance_change( + from, + -(fee as i64), + BalanceEffectKind::TxFeePaid, + None, + db, + )); + } + } + updates +``` + +- [ ] **Step 6: Author credit in add_block_to_chain** — `block.rs`, in the slot where the reward code was (after the tx loop, before the write): + +```rust + // Fees replace block rewards: one aggregate author credit per block (single + // write — per-tx credits would collide in the deferred batch). Fee revenue is + // backed CLT changing hands, so the reserve invariant is untouched. + // ponytail: residual ceiling (pre-existing class, same as the old block reward): + // if any tx in a fee-paying block ALSO credits the author's balance (Transfer to + // author, Mint to author, author-as-driver RidePay), that credit collides with + // this write and is lost. Operational rule: validator accounts are not app + // accounts. Lift with incremental intra-block state. + let total_fees: u64 = block + .transactions + .iter() + .map(|tx| tx.effective_fee(&block.author, ¶ms)) + .sum(); + if block.index > 0 && total_fees > 0 { + let fee_update = AccountState::apply_balance_change( + &block.author, + total_fees as i64, + BalanceEffectKind::TxFeeEarned, + None, + &db, + ); + if let Some((key, value)) = fee_update.storage { + cf_storage.push("state".to_string()); + keys_storage.push(key); + values_storage.push(value); + } + if let Some(effect) = fee_update.effect { + for (key, value) in persist_block_effects( + block.index as u64, + block.timestamp, + std::slice::from_ref(&effect), + ) { + cf_storage.push("state".to_string()); + keys_storage.push(key); + values_storage.push(value); + } + } + } +``` + +And the tx loop passes the author: `tx.state_transaction(&db, ¶ms, &block.author)`. + +- [ ] **Step 7: Run full suite** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +``` + +Expected: PASS after substantial test surgery — the fee rule breaks existing ride-flow tests structurally, not just numerically: + +- **`tests/ride_sharing.rs`**: the driver (`0x8f19...d5e9`) holds **zero balance** and sends RideOffer txs — under the fee rule a zero-balance sender fails validation, block 2 fails import, and the whole downstream flow silently never runs (the test swallows import errors with `error!()` + `break` around lines 66-71). Two mandatory fixes: (1) add a faucet→driver funding Transfer block at the start of the flow (renumber nonces/block indexes for everything after), (2) **make import failures hard failures** — replace the `error!` + `break` with `panic!`/`.expect()` so a dead flow can never pass green. +- **`tests/balance_effects.rs`**: same funding requirement for any zero-balance sender, and every expected balance shifts by `TX_FEE` per non-exempt tx sent. +- Adjust expected balances everywhere by the fee; don't weaken assertions. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat!: flat tx fee to block author replaces block reward + +Every non-exempt tx pays chain_params.tx_fee; validation requires +balance >= direct debit + fee. Sender fee merges into one balance write; +author credited once per block. Spam now has a price. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Mint transaction — treasury-authorized, exactly-once credit_ref + +**Files:** +- Create: `src/node/transactions/mint.rs` +- Modify: `function_call.rs`, `mod.rs`, `rlp_encoding.rs` (tag 6), `transaction.rs` (arms + `fee_exempt` + Mint dispatch passes tx_hash), `block.rs` (supply delta) +- Test: `tests/mint_burn.rs` + +**Interfaces:** +- Consumes: `ChainInit::{get, get_total_supply}`, `TOTAL_SUPPLY_KEY`, `BalanceEffectKind::Mint`. +- Produces: + - `pub struct Mint { pub to: String, pub amount: u64, pub credit_ref: String }` — RLP `[to, amount, credit_ref]`, tag 6. `credit_ref` = 64 lowercase hex chars (hash of the treasury intent id), no `0x`. + - `Mint::verify_state(&self, from: &String, db: &Database) -> Result<(), String>` — authority, amount bounds, ref format, ref-unseen. + - `Mint::state_transaction(&self, tx_hash: &String, db: &Database) -> Vec` — credit + `processed_ref_{ref}` marker (value = tx hash). + - `pub fn processed_ref_key(reference: &str) -> Vec` — `format!("processed_ref_{}", reference)`. + - `add_block_to_chain` computes `supply_delta` per block and single-writes `total_supply`. + - `Transaction::fee_exempt` now includes `Mint`. +- **Cross-repo contract:** the Treasury Service builds Mint txs with `credit_ref = hex(keccak256(intent_id))`, signs with the mint-authority key, same RLP as here. + +- [ ] **Step 1: Write the failing tests** — `tests/mint_burn.rs` (Mint half; Burn tests arrive in Task 7): + +```rust +use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::transactions::chain_init::ChainInit; +use clutch_node::node::transactions::function_call::FunctionCall; +use clutch_node::node::transactions::mint::Mint; +use clutch_node::node::transactions::transaction::Transaction; +use serial_test::serial; + +const AUTHOR_PK: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; +const AUTHOR_SK: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; +const FAUCET_PK: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; +// Same committed dev key as tests/tx_fee.rs (clutch-hub-api config/default.toml:18). +const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; +const CHAIN_ID: u64 = 2077; +const USER: &str = "0x4444444444444444444444444444444444444444"; +const REF_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn ci() -> ChainInit { + ChainInit { + chain_id: CHAIN_ID, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + // Mint authority = node1 dev key so tests can sign mints. Prod: dedicated key. + mint_authority: AUTHOR_PK.to_string(), + faucet_address: FAUCET_PK.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +fn chain(name: &str) -> Blockchain { + Blockchain::new( + name.to_string(), + AUTHOR_PK.to_string(), + AUTHOR_SK.to_string(), + true, + vec![AUTHOR_PK.to_string()], + ci(), + ) +} + +fn signed_mint(sk: &str, from: &str, nonce: u64, to: &str, amount: u64, credit_ref: &str) -> Transaction { + let mut tx = Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Mint(Mint { + to: to.to_string(), + amount, + credit_ref: credit_ref.to_string(), + }), + ); + tx.sign(sk); + tx +} + +#[test] +#[serial] +fn authorized_mint_credits_and_grows_supply() { + let mut chain = chain("test-mint-ok"); + let (_, supply0) = chain.get_chain_info().unwrap(); + + let mint = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 5_000_000, REF_A); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!(chain.get_account_balance(&USER.to_string()), 5_000_000); + let (_, supply1) = chain.get_chain_info().unwrap(); + assert_eq!(supply1, supply0 + 5_000_000, "total_supply tracks the mint"); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn unauthorized_mint_rejected() { + let mut chain = chain("test-mint-unauth"); + // Faucet key is NOT the mint authority. + let mint = signed_mint(FAUCET_SK, FAUCET_PK, 1, USER, 100, REF_A); + let err = chain.add_transaction_to_pool(&mint).unwrap_err(); + assert!(err.contains("authority"), "got: {}", err); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn duplicate_credit_ref_rejected() { + let mut chain = chain("test-mint-dup"); + let m1 = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, REF_A); + chain.add_transaction_to_pool(&m1).unwrap(); + chain.author_new_block().unwrap(); + + let m2 = signed_mint(AUTHOR_SK, AUTHOR_PK, 2, USER, 100, REF_A); + let err = chain.add_transaction_to_pool(&m2).unwrap_err(); + assert!(err.contains("credit_ref"), "exactly-once minting: {}", err); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn mint_rejects_zero_and_bad_ref() { + let mut chain = chain("test-mint-bad"); + let zero = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 0, REF_A); + assert!(chain.add_transaction_to_pool(&zero).is_err()); + let bad_ref = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, "not-hex"); + assert!(chain.add_transaction_to_pool(&bad_ref).is_err()); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn mint_works_with_zero_treasury_balance() { + // Mint is fee-exempt: the authority holds no CLT at genesis and must still mint. + let mut chain = chain("test-mint-feeless"); + assert_eq!(chain.get_account_balance(&AUTHOR_PK.to_string()), 0); + let mint = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, REF_A); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + assert_eq!(chain.get_account_balance(&USER.to_string()), 100); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn author_own_tx_pays_no_fee() { + use clutch_node::node::transactions::transfer::Transfer; + // Fund the author via Mint (fee-exempt, single balance write — no deferred-batch + // collision), then the author sends a transfer in a block it authors itself. + let mut chain = chain("test-fee-author"); + let mint = signed_mint( + AUTHOR_SK, AUTHOR_PK, 1, AUTHOR_PK, 10_000, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + assert_eq!(chain.get_account_balance(&AUTHOR_PK.to_string()), 10_000); + + let mut own = Transaction::new_transaction( + AUTHOR_PK.to_string(), + 2, + CHAIN_ID, + FunctionCall::Transfer(Transfer { + to: "0x3333333333333333333333333333333333333333".to_string(), + value: 100, + }), + ); + own.sign(AUTHOR_SK); + chain.add_transaction_to_pool(&own).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!( + chain.get_account_balance(&AUTHOR_PK.to_string()), + 10_000 - 100, + "author's own tx nets zero fee" + ); + chain.shutdown_blockchain(); +} +``` + +> Note: the mint credit_ref here (`cc…cc`) must differ from REF_A/REF_B — refs are globally exactly-once. + +- [ ] **Step 2: Run to verify failure** — module `mint` not found: + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test mint_burn +``` + +- [ ] **Step 3: Create `src/node/transactions/mint.rs`** + +```rust +use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; +use serde::{Deserialize, Serialize}; + +use crate::node::account_state::AccountState; +use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; +use crate::node::database::Database; + +use super::address::canonical_account_address; +use super::chain_init::ChainInit; + +/// Exactly-once ref marker: `processed_ref_{64-hex}` in the state CF, value = tx hash. +/// Shared by Mint (credit_ref) and Burn (redemption_ref) — refs are keccak256 hashes of +/// treasury intent ids, so one namespace cannot collide across the two uses. +pub fn processed_ref_key(reference: &str) -> Vec { + format!("processed_ref_{}", reference).into_bytes() +} + +pub fn ref_is_valid(reference: &str) -> bool { + reference.len() == 64 && reference.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +pub fn ref_already_processed(db: &Database, reference: &str) -> Result { + match db.get("state", &processed_ref_key(reference)) { + Ok(Some(_)) => Ok(true), + Ok(None) => Ok(false), + Err(e) => Err(format!("failed to read processed ref: {}", e)), + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Mint { + pub to: String, + pub amount: u64, + pub credit_ref: String, +} + +impl Mint { + pub fn verify_state(&self, from: &String, db: &Database) -> Result<(), String> { + let params = ChainInit::get(db)?; + if canonical_account_address(from) != canonical_account_address(¶ms.mint_authority) { + return Err(format!( + "Mint rejected: '{}' is not the mint authority", + from + )); + } + if self.amount == 0 { + return Err("Mint rejected: amount must be positive".to_string()); + } + if self.amount > i64::MAX as u64 { + return Err("Mint rejected: amount exceeds i64::MAX (balance deltas are i64)".to_string()); + } + if !ref_is_valid(&self.credit_ref) { + return Err("Mint rejected: credit_ref must be 64 lowercase hex chars".to_string()); + } + if ref_already_processed(db, &self.credit_ref)? { + return Err(format!( + "Mint rejected: credit_ref '{}' already processed (exactly-once)", + self.credit_ref + )); + } + Ok(()) + } + + pub fn state_transaction(&self, tx_hash: &String, db: &Database) -> Vec { + vec![ + AccountState::apply_balance_change( + &self.to, + self.amount as i64, + BalanceEffectKind::Mint, + None, + db, + ), + StateUpdate::storage_only( + processed_ref_key(&self.credit_ref), + tx_hash.clone().into_bytes(), + ), + ] + } +} + +impl Encodable for Mint { + fn rlp_append(&self, stream: &mut RlpStream) { + stream.begin_list(3); + stream.append(&self.to); + stream.append(&self.amount); + stream.append(&self.credit_ref); + } +} + +impl Decodable for Mint { + fn decode(rlp: &Rlp) -> Result { + if !rlp.is_list() || rlp.item_count()? != 3 { + return Err(DecoderError::RlpIncorrectListLen); + } + Ok(Mint { + to: rlp.val_at(0)?, + amount: rlp.val_at(1)?, + credit_ref: rlp.val_at(2)?, + }) + } +} +``` + +- [ ] **Step 4: Wire it** — mirror Task 2's wiring: +- `mod.rs`: `pub mod mint;` +- `function_call.rs`: `Mint(Mint)` variant + Display arm +- `rlp_encoding.rs`: encode/decode arms, tag `6u8` +- `transaction.rs`: `verify_state` arm → `mint.verify_state(&self.from, db)`; `function_call_type` → `"Mint"`; `state_transaction` arm → `mint.state_transaction(&self.hash, db)`; `fee_exempt` → `matches!(&self.data, FunctionCall::Mint(_) | FunctionCall::ChainInit(_))` + +- [ ] **Step 5: Supply delta in add_block_to_chain** — `block.rs`, next to the fee-credit block: + +```rust + // Supply changes once per block: per-tx read-modify-writes of the single + // total_supply key would collide in the deferred batch (last write wins, + // e.g. two Burns in one block). Sum first, then one read + one write. + let mut supply_delta: i128 = 0; + for tx in &block.transactions { + match &tx.data { + FunctionCall::Mint(m) => supply_delta += m.amount as i128, + _ => {} + } + } + if block.index > 0 && supply_delta != 0 { + let current = ChainInit::get_total_supply(db)? as i128; + let next = current + supply_delta; + // Cap at i64::MAX, not u64::MAX: supply <= i64::MAX implies every balance + // <= i64::MAX, keeping all deltas representable in i64 (Transfer casts + // `value as i64` — a balance above i64::MAX would wrap negative). + if next < 0 || next > i64::MAX as i128 { + return Err(format!( + "total_supply out of range: {} + {} = {}", + current, supply_delta, next + )); + } + cf_storage.push("state".to_string()); + keys_storage.push(chain_init::TOTAL_SUPPLY_KEY.to_vec()); + values_storage.push(serde_json::to_vec(&(next as u64)).expect("serialize supply")); + } +``` + +(import `chain_init` module; Task 7 adds the `Burn` match arm here.) + +- [ ] **Step 6: Run tests** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test mint_burn +``` + +Expected: PASS (5 tests). + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat!: Mint transaction with authority check and exactly-once credit_ref + +RLP tag 6. Only chain_params.mint_authority may mint; credit_ref +(64-hex, hash of the treasury intent id) is a write-once state marker, +so a replayed or duplicated mint intent can never credit twice. +total_supply updates once per block. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Burn transaction — permissionless, redemption_ref for payout matching + +**Files:** +- Create: `src/node/transactions/burn.rs` +- Modify: `function_call.rs`, `mod.rs`, `rlp_encoding.rs` (tag 7), `transaction.rs` (arms, `sender_direct_debit`, fee routing), `block.rs` (supply arm) +- Test: `tests/mint_burn.rs` (extend) + +**Interfaces:** +- Consumes: `processed_ref_key` / `ref_is_valid` / `ref_already_processed` (Task 6), `apply_balance_change_with_fee` (Task 5). +- Produces: + - `pub struct Burn { pub amount: u64, pub redemption_ref: Option }` — RLP `[amount, redemption_ref-or-empty-string]`, tag 7. Ref optional: plain burns allowed; redemptions carry `hex(keccak256(intent_id))` so the Treasury payout worker matches Burn → intent. + - `Burn::verify_state(&self, from, db)`; `Burn::state_transaction(&self, from, tx_hash, db, fee) -> Vec` — single sender balance write of `-(amount+fee)`, effects `Burn` + `TxFeePaid`, optional ref marker. + - `Transaction::sender_direct_debit` includes `Burn.amount`; the standalone-fee branch in `Transaction::state_transaction` excludes Burn (fee merged like Transfer). +- Burn **pays the fee** (spam resistance; the burner has balance by definition). + +- [ ] **Step 1: Write the failing tests** — append to `tests/mint_burn.rs`: + +```rust +use clutch_node::node::transactions::burn::Burn; + +const REF_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const TX_FEE: u64 = 1000; + +fn signed_burn(sk: &str, from: &str, nonce: u64, amount: u64, redemption_ref: Option<&str>) -> Transaction { + let mut tx = Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Burn(Burn { + amount, + redemption_ref: redemption_ref.map(|s| s.to_string()), + }), + ); + tx.sign(sk); + tx +} + +#[test] +#[serial] +fn burn_reduces_balance_and_supply() { + let mut chain = chain("test-burn-ok"); + let (_, supply0) = chain.get_chain_info().unwrap(); + let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); + + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, 2_000_000, Some(REF_B)); + chain.add_transaction_to_pool(&burn).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before - 2_000_000 - TX_FEE, + "burner pays amount + fee" + ); + let (_, supply1) = chain.get_chain_info().unwrap(); + assert_eq!(supply1, supply0 - 2_000_000, "supply shrinks by burn amount only (fee just moves)"); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn duplicate_redemption_ref_rejected() { + let mut chain = chain("test-burn-dup"); + let b1 = signed_burn(FAUCET_SK, FAUCET_PK, 1, 100, Some(REF_B)); + chain.add_transaction_to_pool(&b1).unwrap(); + chain.author_new_block().unwrap(); + let b2 = signed_burn(FAUCET_SK, FAUCET_PK, 2, 100, Some(REF_B)); + assert!(chain.add_transaction_to_pool(&b2).is_err()); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn burn_more_than_balance_rejected() { + let mut chain = chain("test-burn-overdraw"); + let balance = chain.get_account_balance(&FAUCET_PK.to_string()); + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, balance, None); // no headroom for fee + assert!(chain.add_transaction_to_pool(&burn).is_err()); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn plain_burn_without_ref_works() { + let mut chain = chain("test-burn-plain"); + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, 100, None); + chain.add_transaction_to_pool(&burn).unwrap(); + chain.author_new_block().unwrap(); + chain.shutdown_blockchain(); +} +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test mint_burn +``` + +- [ ] **Step 3: Create `src/node/transactions/burn.rs`** + +```rust +use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; +use serde::{Deserialize, Serialize}; + +use crate::node::account_state::AccountState; +use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; +use crate::node::database::Database; + +use super::mint::{processed_ref_key, ref_already_processed, ref_is_valid}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Burn { + pub amount: u64, + /// hex(keccak256(intent_id)) for treasury redemptions; None for a plain burn. + pub redemption_ref: Option, +} + +impl Burn { + pub fn verify_state(&self, _from: &String, db: &Database) -> Result<(), String> { + if self.amount == 0 { + return Err("Burn rejected: amount must be positive".to_string()); + } + if self.amount > i64::MAX as u64 { + return Err("Burn rejected: amount exceeds i64::MAX".to_string()); + } + if let Some(r) = &self.redemption_ref { + if !ref_is_valid(r) { + return Err("Burn rejected: redemption_ref must be 64 lowercase hex chars".to_string()); + } + if ref_already_processed(db, r)? { + return Err(format!( + "Burn rejected: redemption_ref '{}' already processed", + r + )); + } + } + // Balance sufficiency (amount + fee) is enforced centrally in validate_transaction. + Ok(()) + } + + pub fn state_transaction( + &self, + from: &String, + tx_hash: &String, + db: &Database, + fee: u64, + ) -> Vec { + let mut updates = AccountState::apply_balance_change_with_fee( + from, + -(self.amount as i64), + fee, + BalanceEffectKind::Burn, + None, + db, + ); + if let Some(r) = &self.redemption_ref { + updates.push(StateUpdate::storage_only( + processed_ref_key(r), + tx_hash.clone().into_bytes(), + )); + } + updates + } +} + +impl Encodable for Burn { + fn rlp_append(&self, stream: &mut RlpStream) { + stream.begin_list(2); + stream.append(&self.amount); + // Same optional-string convention as referrers: empty string = None. + let ref_str = self.redemption_ref.clone().unwrap_or_default(); + stream.append(&ref_str); + } +} + +impl Decodable for Burn { + fn decode(rlp: &Rlp) -> Result { + if !rlp.is_list() || rlp.item_count()? != 2 { + return Err(DecoderError::RlpIncorrectListLen); + } + let ref_str: String = rlp.val_at(1)?; + Ok(Burn { + amount: rlp.val_at(0)?, + redemption_ref: if ref_str.is_empty() { None } else { Some(ref_str) }, + }) + } +} +``` + +- [ ] **Step 4: Wire it** — `mod.rs`, `function_call.rs`, `rlp_encoding.rs` tag `7u8` (mirror Task 6). `transaction.rs`: +- `verify_state` arm → `burn.verify_state(&self.from, db)` +- `function_call_type` → `"Burn"` +- `state_transaction` arm → `burn.state_transaction(&self.from, &self.hash, db, fee)` (the `fee` local from Task 5 Step 5) +- `sender_direct_debit`: add `FunctionCall::Burn(b) => b.amount,` +- `fee_handled_in_type` match: add `| FunctionCall::Burn(_)` (Burn merges its own fee like Transfer) +- `block.rs` supply loop: add `FunctionCall::Burn(b) => supply_delta -= b.amount as i128,` + +- [ ] **Step 5: Run full suite, then commit** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +``` + +```bash +git add -A +git commit -m "feat!: Burn transaction with optional exactly-once redemption_ref + +RLP tag 7, permissionless. Redemptions carry hex(keccak256(intent_id)) +so the treasury payout worker matches burns to intents; plain burns +allowed. Burner pays amount + fee in one balance write; supply shrinks. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: `get_chain_info` RPC + +**Files:** +- Modify: `src/node/wss/websocket.rs` (match arm ~line 150 + handler) +- Test: `tests/chain_genesis.rs` already covers `Blockchain::get_chain_info`; this task adds the RPC surface + serialization test + +**Interfaces:** +- Consumes: `Blockchain::get_chain_info()` (Task 3). +- Produces JSON-RPC method `get_chain_info`, no params, result: + +```json +{ + "chain_id": 2077, "is_testnet": true, "tx_fee": 1000, + "ride_request_referrer_fee_bps": 200, "ride_offer_referrer_fee_bps": 200, + "mint_authority": "0x...", "total_supply": 1000000000000000, + "latest_block_index": 42 +} +``` + +- **Cross-repo contract:** Treasury reconciliation reads `total_supply` here; hub-api faucet reads `is_testnet`/`chain_id` to fail loudly on non-testnet chains. + +- [ ] **Step 1: Handler** — `websocket.rs`, add match arm after `get_block_by_index`: + +```rust + "get_chain_info" => { + Self::handle_get_chain_info(id, blockchain).await + } +``` + +and the handler (mirror `handle_get_account_balance`'s shape): + +```rust + async fn handle_get_chain_info( + id: serde_json::Value, + blockchain: &Arc>, + ) -> Option { + let blockchain = blockchain.lock().await; + let latest_index = match blockchain.get_latest_block() { + Ok(Some(b)) => b.index, + _ => 0, + }; + match blockchain.get_chain_info() { + Ok((params, total_supply)) => Some(json_rpc_success_response( + serde_json::json!({ + "chain_id": params.chain_id, + "is_testnet": params.is_testnet, + "tx_fee": params.tx_fee, + "ride_request_referrer_fee_bps": params.ride_request_referrer_fee_bps, + "ride_offer_referrer_fee_bps": params.ride_offer_referrer_fee_bps, + "mint_authority": params.mint_authority, + "total_supply": total_supply, + "latest_block_index": latest_index, + }), + id, + )), + Err(e) => { + let error_msg = format!("Failed to get chain info: {}", e); + error!("{}", error_msg); + Some(json_rpc_error_response(-32000, &error_msg, id)) + } + } + } +``` + +- [ ] **Step 2: Test** — append to `tests/chain_genesis.rs` (integration test crates are independent — the two `signed_*` helpers are duplicated here from `tests/mint_burn.rs` by design): + +```rust +#[test] +#[serial] +fn chain_info_supply_tracks_mint_and_burn() { + use clutch_node::node::transactions::burn::Burn; + use clutch_node::node::transactions::function_call::FunctionCall; + use clutch_node::node::transactions::mint::Mint; + use clutch_node::node::transactions::transaction::Transaction; + + const AUTHOR_SK: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; + const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; + const USER: &str = "0x4444444444444444444444444444444444444444"; + + let ci = test_chain_init(); // chain_id 2077, mint_authority = author, testnet + let mut chain = new_test_chain("test-supply-e2e", ci.clone()); + + let (_, supply_genesis) = chain.get_chain_info().unwrap(); + assert_eq!(supply_genesis, ci.faucet_allocation); + + // Mint block: +5_000_000 to USER. + let mut mint = Transaction::new_transaction( + ci.mint_authority.clone(), + 1, + ci.chain_id, + FunctionCall::Mint(Mint { + to: USER.to_string(), + amount: 5_000_000, + credit_ref: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + .to_string(), + }), + ); + mint.sign(AUTHOR_SK); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + + let (_, supply_after_mint) = chain.get_chain_info().unwrap(); + assert_eq!(supply_after_mint, supply_genesis + 5_000_000); + + // Burn block: faucet burns 2_000_000 (fee moves CLT, supply drops by burn only). + let mut burn = Transaction::new_transaction( + ci.faucet_address.clone(), + 1, + ci.chain_id, + FunctionCall::Burn(Burn { + amount: 2_000_000, + redemption_ref: Some( + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".to_string(), + ), + }), + ); + burn.sign(FAUCET_SK); + chain.add_transaction_to_pool(&burn).unwrap(); + chain.author_new_block().unwrap(); + + let (_, supply_after_burn) = chain.get_chain_info().unwrap(); + assert_eq!(supply_after_burn, supply_after_mint - 2_000_000); + + chain.shutdown_blockchain(); +} +``` + +- [ ] **Step 3: Run, commit** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +``` + +```bash +git add -A +git commit -m "feat: get_chain_info RPC exposing chain params and total supply + +Treasury reconciliation's on-chain supply source; hub faucet's +testnet-flag check. + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 9: Suite green, docs, and 3-node stack smoke test + +**Files:** +- Modify: `docs/state_keys.csv`, `CLAUDE.md` (this repo's), any straggling tests +- Verify: full suite + `docker-compose.yml` 3-node net + +- [ ] **Step 1: Sweep for stragglers** + +```bash +grep -rn "block_reward\|fee_percent\|referrer_fee_ceiling" src/ tests/ config/ --include="*.rs" --include="*.toml" +``` + +Expected: only the `block_reward: u64 = 0` compat literal in `websocket.rs` and historical mentions in CHANGELOG. Fix anything else. + +- [ ] **Step 2: Document state keys** — append to `docs/state_keys.csv` in its existing two-column `key,type` format: + +```csv +chain_params,ChainInit (JSON) +total_supply,u64 (JSON) +processed_ref_{64-hex},tx hash (exactly-once Mint/Burn ref marker) +``` + +- [ ] **Step 3: Update `CLAUDE.md`** — Transaction Types list (+Mint tag 6, +Burn tag 7, +ChainInit tag 9, genesis-only), RPC list (+`get_chain_info`), Config section (new keys, removed `block_reward_amount`, bps rename), and the tx-hash convention line (preimage now includes chain_id). + +- [ ] **Step 4: Full suite** + +```bash +docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +``` + +Expected: PASS, zero ignored failures. + +- [ ] **Step 5: 3-node stack smoke** — this repo's compose is **pull-only** (`image: ghcr.io/clutchprotocol/clutch-node:latest`, no `build:` directives) — `--build` is a no-op and would run the OLD published binary against the new TOMLs (crash-loop on the removed `block_reward_amount` key). Build the branch image locally and retag it over the compose image name first. `node2-docker.toml`/`node3-docker.toml` must carry the identical new chain params (Task 3) or the nodes will refuse to peer — that refusal is itself a feature test: + +```powershell +docker build -t ghcr.io/clutchprotocol/clutch-node:latest . +docker compose up -d +Start-Sleep -Seconds 30 +docker compose logs node1 --tail 20 # expect: blocks importing, no errors +docker compose logs node2 --tail 20 # expect: synced via handshake (same genesis hash) +``` + +Then verify `get_chain_info` over WebSocket (Node one-liner, no install needed inside the sdk repo's node_modules — or use any ws client): + +```powershell +node -e "const W=require('ws');const w=new W('ws://localhost:8081/ws');w.on('open',()=>w.send(JSON.stringify({jsonrpc:'2.0',id:1,method:'get_chain_info',params:null})));w.on('message',m=>{console.log(m.toString());process.exit(0)})" +``` + +Expected: JSON with `chain_id: 2077`, `total_supply: 1000000000000000`, `is_testnet: true`. + +Negative check (params commitment): temporarily set `chain_id = 9999` in `config/node/node3-docker.toml`, `docker compose up -d node3` (image already built above), expect node3's log to show handshake/genesis mismatch and no sync; revert. + +```powershell +docker compose down -v +``` + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "docs: state keys, CLAUDE.md, and stack smoke for treasury node break + +Co-Authored-By: Claude Fable 5 " +``` + +- [ ] **Step 7: STOP — user review.** Do not merge to `main`, do not push. Present the branch diff. Downstream repos break until their follow-up plans land: clutch-deploy TOML copies (new config keys), clutch-hub-api (chain_id in faucet + JSON tx), clutch-hub-sdk-js (8-item RLP, chain_id, GraphQL Int→String), clutch-explorer (block_reward=0, new effect kinds). + +--- + +## Cross-repo follow-ups (explicitly OUT of this plan) + +1. **clutch-deploy**: copy the new/renamed TOML keys into `clutch-deploy/config/node/*.toml` — the deploy stack mounts its own configs and will crash-loop on missing fields until then. +2. **clutch-hub-sdk-js**: chain_id in signing (8-item RLP, 4-item preimage), `$fare: Int!` → String scalars, bigint threading, quote verification before signing. +3. **clutch-hub-api**: faucet adds chain_id + queries `get_chain_info` to refuse non-testnet; GraphQL scalar change; unsigned-tx blob gains chain_id. +4. **clutch-explorer**: breaks at **genesis**, not eventually — every genesis block now contains a ChainInit tx (tag 9) and all txs carry chain_id in an 8-item wire format, so indexing-from-zero fails immediately if its transaction parser mirrors the node enum. Must tolerate/index Mint/Burn/ChainInit (tags 6/7/9), the chain_id field, and the new `Mint/Burn/TxFeePaid/TxFeeEarned` effect kinds; drop block_reward column eventually; index total_supply. +5. **clutch-hub-demo-app**: consumes the SDK via `file:../clutch-hub-sdk-js` — breaks on the next `predev` SDK build after follow-up 2 lands (fare scalar, bigint amounts). Needs its own pass (plus the top-up/redeem screens per the dossier). +6. **clutch-docs**: workspace convention — document the new tx types (Mint/Burn/ChainInit), `get_chain_info`, the chain_id-in-preimage signing change, and the fee model. +7. **clutch-treasury** (new repo): Plans B/C — service skeletons per the dossier. From 842474a1d8c841cf1b4882c21f144d1db5ebb68d Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 07:01:23 +0400 Subject: [PATCH 02/24] chore: gitignore subagent-driven-development scratch dir Co-Authored-By: Claude Fable 5 --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 27565d5..5fd4f6e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ *.db # Include monitoring configs (they should be in source control) -# monitoring/ \ No newline at end of file +# monitoring/ + +# subagent-driven-development scratch (ledger, briefs, review packages) +.superpowers/ From 46bc77818c88677cd0a909a5f961382bdd79e11a Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 07:36:20 +0400 Subject: [PATCH 03/24] =?UTF-8?q?docs:=20fix=20plan=20test=20invocation=20?= =?UTF-8?q?=E2=80=94=20named=20target=20volume,=20MSYS=20path=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind-mounted target/ on Docker Desktop Windows made a no-op incremental run exceed 10 minutes; a named volume keeps builds on the VM's own fs. MSYS_NO_PATHCONV stops Git Bash rewriting -w /app. Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-27-treasury-node-break.md | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/superpowers/plans/2026-07-27-treasury-node-break.md b/docs/superpowers/plans/2026-07-27-treasury-node-break.md index faa4278..50494c0 100644 --- a/docs/superpowers/plans/2026-07-27-treasury-node-break.md +++ b/docs/superpowers/plans/2026-07-27-treasury-node-break.md @@ -15,8 +15,18 @@ ## Global Constraints - **Peg (decided):** 1 USD = 1,000,000 CLT. CLT is the base unit (micro-USD). **Zero decimals. Keep `u64` balances / `i64` deltas.** No i128/u128 in stored state or RLP; wide-integer *intermediates* for overflow-safe arithmetic are fine and used deliberately (Task 1's u128 fee math, Task 6's i128 supply delta). Never floats. -- **Never run host `cargo build`/`cargo test`** (user convention). All test runs use the Docker image built in Task 1: - `docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test` +- **Never run host `cargo build`/`cargo test`** (user convention). All test runs use the Docker image built in Task 1, via this exact invocation (call it `RUNTEST` below): + +```bash +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test +``` + + Three details are load-bearing, learned the hard way: + - `MSYS_NO_PATHCONV=1` — without it Git Bash rewrites `-w /app` into `C:/Program Files/Git/app` and docker refuses to start. + - `-v clutch-node-target:/app/target` — a **named volume**, not the bind-mounted host `target/`. Compiling into the Windows bind mount is pathologically slow on Docker Desktop (a no-op incremental run exceeded 10 minutes); on the named volume it is seconds. Never drop this flag. + - Run it in the **foreground with a generous timeout (600000 ms)**. Do not launch it in the background and poll — polling burns turns and tells you nothing the exit code won't. + + The first run on a cold `clutch-node-target` volume compiles ~200 crates (~15 min). Every run after that is incremental. Narrow the run while iterating (`cargo test --lib ride_pay`, `cargo test --test chain_init`) and run the full suite once before committing. - All commands run from repo root `D:\source\clutch\clutch-node` on branch `treasury-break`. **Never push to `main`** — user reviews first (spec §11). - Breaking changes are expected (alpha/testnet, DBs get wiped) — no backward-compat shims for state or RLP formats. - RLP tags: Transfer=0, RideRequest=1, RideOffer=2, RideAcceptance=3, RidePay=4, RideCancel=5, **Mint=6, Burn=7**, RideRequestCancel=8, **ChainInit=9**. These must byte-match the JS SDK encoder in the follow-up SDK release. @@ -147,7 +157,7 @@ mod tests { - [ ] **Step 4: Run tests to verify they fail** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --lib ride_pay +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --lib ride_pay ``` Expected: FAIL — `referrer_fee_floor` not found. @@ -189,7 +199,7 @@ Also fix the positional `2, 2` percent literals passed to `Blockchain::new` in ` - [ ] **Step 7: Run full test suite** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test ``` Expected: PASS after these test updates: @@ -321,7 +331,7 @@ fn chain_init_mainnet_flag_zeroes_supply() { - [ ] **Step 2: Run to verify failure** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test chain_init +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --test chain_init ``` Expected: FAIL — module `chain_init` not found. @@ -507,7 +517,7 @@ pub enum BalanceEffectKind { - [ ] **Step 5: Run tests to verify pass** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test chain_init +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --test chain_init ``` Expected: PASS (4 tests). @@ -627,7 +637,7 @@ fn mainnet_with_faucet_allocation_fails_loudly() { - [ ] **Step 2: Run to verify failure** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test chain_genesis +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --test chain_genesis ``` Expected: FAIL — `Blockchain::new` arity / `get_chain_info` missing. @@ -836,7 +846,7 @@ Update every caller to the new signatures (tests use the `new_test_chain` helper - [ ] **Step 10: Run full suite** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test ``` Expected: PASS. @@ -896,7 +906,7 @@ Co-Authored-By: Claude Fable 5 " - [ ] **Step 2: Run to verify failure** — `new_transaction` arity error: ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --lib transactions::transaction +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --lib transactions::transaction ``` - [ ] **Step 3: Implement** @@ -1093,7 +1103,7 @@ fn wrong_chain_id_rejected_at_pool() { - [ ] **Step 6: Full suite** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test ``` Expected: PASS. @@ -1250,7 +1260,7 @@ fn exact_balance_without_fee_is_rejected() { - [ ] **Step 2: Run to verify failure** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test tx_fee +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --test tx_fee ``` Expected: FAIL — balances off by fee amounts / helpers missing. @@ -1563,7 +1573,7 @@ And the tx loop passes the author: `tx.state_transaction(&db, ¶ms, &block.au - [ ] **Step 7: Run full suite** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test ``` Expected: PASS after substantial test surgery — the fee rule breaks existing ride-flow tests structurally, not just numerically: @@ -1771,7 +1781,7 @@ fn author_own_tx_pays_no_fee() { - [ ] **Step 2: Run to verify failure** — module `mint` not found: ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test mint_burn +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --test mint_burn ``` - [ ] **Step 3: Create `src/node/transactions/mint.rs`** @@ -1922,7 +1932,7 @@ impl Decodable for Mint { - [ ] **Step 6: Run tests** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test mint_burn +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --test mint_burn ``` Expected: PASS (5 tests). @@ -2037,7 +2047,7 @@ fn plain_burn_without_ref_works() { - [ ] **Step 2: Run to verify failure** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test --test mint_burn +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test --test mint_burn ``` - [ ] **Step 3: Create `src/node/transactions/burn.rs`** @@ -2142,7 +2152,7 @@ impl Decodable for Burn { - [ ] **Step 5: Run full suite, then commit** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test ``` ```bash @@ -2288,7 +2298,7 @@ fn chain_info_supply_tracks_mint_and_burn() { - [ ] **Step 3: Run, commit** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test ``` ```bash @@ -2330,7 +2340,7 @@ processed_ref_{64-hex},tx hash (exactly-once Mint/Burn ref marker) - [ ] **Step 4: Full suite** ```bash -docker run --rm -v "${PWD}:/app" -v clutch-cargo-cache:/usr/local/cargo/registry -w /app clutch-node-test cargo test +MSYS_NO_PATHCONV=1 docker run --rm -v /d/source/clutch/clutch-node:/app -v clutch-cargo-cache:/usr/local/cargo/registry -v clutch-node-target:/app/target -w /app clutch-node-test cargo test ``` Expected: PASS, zero ignored failures. From 273972c6af1696931889150e169557d9d4183d34 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 07:55:04 +0400 Subject: [PATCH 04/24] feat!: referrer fees in basis points with floor rounding Replaces ceiling percent (2% of 3 rounded to 33%) with bps floor per treasury spec 4a. Driver share stays remainder-based; property test pins request+offer+driver == fare for all inputs. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 131 +++++++++++++++++++++------ Cargo.toml | 1 + Dockerfile.test | 4 + config/node/default.toml | 4 +- config/node/node1.toml | 4 +- config/node/node2-docker.toml | 4 +- config/node/node2.toml | 4 +- config/node/node3-docker.toml | 4 +- config/node/node3.toml | 4 +- src/main.rs | 4 +- src/node/blockchain.rs | 24 ++--- src/node/blocks/block.rs | 8 +- src/node/configuration.rs | 4 +- src/node/transactions/ride_pay.rs | 72 +++++++++------ src/node/transactions/transaction.rs | 8 +- tests/balance_effects.rs | 14 +-- tests/block_reward.rs | 4 +- tests/ride_sharing.rs | 4 +- 18 files changed, 198 insertions(+), 104 deletions(-) create mode 100644 Dockerfile.test diff --git a/Cargo.lock b/Cargo.lock index 7383815..0e9d250 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,7 +384,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.12.1", @@ -404,7 +404,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -416,6 +416,21 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -424,11 +439,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -642,6 +657,7 @@ dependencies = [ "lazy_static", "libp2p", "prometheus-client 0.22.3", + "proptest", "rand 0.8.6", "reqwest", "rlp", @@ -897,9 +913,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.3" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] @@ -1555,7 +1571,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -1773,7 +1789,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -2592,9 +2608,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.2" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-integer" @@ -2670,7 +2686,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -2977,6 +2993,31 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-protobuf" version = "0.8.1" @@ -3013,7 +3054,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.0", "thiserror 2.0.16", "tokio", "tracing", @@ -3050,7 +3091,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", "windows-sys 0.60.2", ] @@ -3129,6 +3170,15 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -3148,7 +3198,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -3267,7 +3317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", - "bitflags 2.9.4", + "bitflags 2.13.1", "serde", "serde_derive", ] @@ -3348,7 +3398,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3396,6 +3446,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "rw-stream-sink" version = "0.4.0" @@ -3474,7 +3536,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -3791,7 +3853,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation", "system-configuration-sys", ] @@ -3876,30 +3938,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -4078,7 +4140,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4218,6 +4280,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -4317,6 +4385,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 795d0eb..aaaee49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dev-dependencies] serial_test = "3.1.1" +proptest = "1.5" [dependencies] rocksdb = "0.22.0" diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..2ea4924 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,4 @@ +# Test-runner image: rocksdb (librocksdb-sys) needs clang/libclang for bindgen. +FROM rust:1.86-bookworm +RUN apt-get update && apt-get install -y clang libclang-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app diff --git a/config/node/default.toml b/config/node/default.toml index 36a34df..2c52fcb 100644 --- a/config/node/default.toml +++ b/config/node/default.toml @@ -13,8 +13,8 @@ authorities = [ ] block_authoring_enabled = true block_reward_amount = 50 -ride_request_referrer_fee_percent = 2 -ride_offer_referrer_fee_percent = 2 +ride_request_referrer_fee_bps = 200 +ride_offer_referrer_fee_bps = 200 sync_enabled = true serve_metric_enabled = true serve_metric_addr = "0.0.0.0:3001" diff --git a/config/node/node1.toml b/config/node/node1.toml index 8abb56b..8271652 100644 --- a/config/node/node1.toml +++ b/config/node/node1.toml @@ -13,8 +13,8 @@ authorities = [ ] block_authoring_enabled = true block_reward_amount = 50 -ride_request_referrer_fee_percent = 2 -ride_offer_referrer_fee_percent = 2 +ride_request_referrer_fee_bps = 200 +ride_offer_referrer_fee_bps = 200 sync_enabled = true serve_metric_enabled = true serve_metric_addr = "0.0.0.0:3001" diff --git a/config/node/node2-docker.toml b/config/node/node2-docker.toml index 0b7a40f..a882a17 100644 --- a/config/node/node2-docker.toml +++ b/config/node/node2-docker.toml @@ -15,8 +15,8 @@ authorities = [ ] block_authoring_enabled = true block_reward_amount = 50 -ride_request_referrer_fee_percent = 2 -ride_offer_referrer_fee_percent = 2 +ride_request_referrer_fee_bps = 200 +ride_offer_referrer_fee_bps = 200 sync_enabled = true serve_metric_enabled = true serve_metric_addr = "0.0.0.0:3002" diff --git a/config/node/node2.toml b/config/node/node2.toml index 48581fb..bc44083 100644 --- a/config/node/node2.toml +++ b/config/node/node2.toml @@ -15,8 +15,8 @@ authorities = [ ] block_authoring_enabled = true block_reward_amount = 50 -ride_request_referrer_fee_percent = 2 -ride_offer_referrer_fee_percent = 2 +ride_request_referrer_fee_bps = 200 +ride_offer_referrer_fee_bps = 200 sync_enabled = true serve_metric_enabled = true serve_metric_addr = "0.0.0.0:3002" diff --git a/config/node/node3-docker.toml b/config/node/node3-docker.toml index 94697b5..f9587c1 100644 --- a/config/node/node3-docker.toml +++ b/config/node/node3-docker.toml @@ -15,8 +15,8 @@ authorities = [ ] block_authoring_enabled = true block_reward_amount = 50 -ride_request_referrer_fee_percent = 2 -ride_offer_referrer_fee_percent = 2 +ride_request_referrer_fee_bps = 200 +ride_offer_referrer_fee_bps = 200 sync_enabled = true serve_metric_enabled = true serve_metric_addr = "0.0.0.0:3003" diff --git a/config/node/node3.toml b/config/node/node3.toml index 41180c2..ab673aa 100644 --- a/config/node/node3.toml +++ b/config/node/node3.toml @@ -15,8 +15,8 @@ authorities = [ ] block_authoring_enabled = true block_reward_amount = 50 -ride_request_referrer_fee_percent = 2 -ride_offer_referrer_fee_percent = 2 +ride_request_referrer_fee_bps = 200 +ride_offer_referrer_fee_bps = 200 sync_enabled = true serve_metric_enabled = true serve_metric_addr = "0.0.0.0:3003" diff --git a/src/main.rs b/src/main.rs index 20f03a6..abe7707 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,7 @@ fn initialize_blockchain(config: &AppConfig) -> Blockchain { config.developer_mode.clone(), config.authorities.clone(), config.block_reward_amount, - config.ride_request_referrer_fee_percent, - config.ride_offer_referrer_fee_percent, + config.ride_request_referrer_fee_bps, + config.ride_offer_referrer_fee_bps, ) } diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index 59a031a..971f3f2 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -24,8 +24,8 @@ pub struct Blockchain { author_public_key: String, author_secret_key: String, block_reward_amount: u64, - ride_request_referrer_fee_percent: u8, - ride_offer_referrer_fee_percent: u8, + ride_request_referrer_fee_bps: u16, + ride_offer_referrer_fee_bps: u16, } impl Blockchain { @@ -36,8 +36,8 @@ impl Blockchain { developer_mode: bool, authorities: Vec, block_reward_amount: u64, - ride_request_referrer_fee_percent: u8, - ride_offer_referrer_fee_percent: u8, + ride_request_referrer_fee_bps: u16, + ride_offer_referrer_fee_bps: u16, ) -> Blockchain { let db = Database::new_db(&name); let step_duration = 60 / authorities.len() as u64; @@ -49,8 +49,8 @@ impl Blockchain { author_public_key, author_secret_key, block_reward_amount, - ride_request_referrer_fee_percent, - ride_offer_referrer_fee_percent, + ride_request_referrer_fee_bps, + ride_offer_referrer_fee_bps, }; Block::genesis_import_block(&blockchain.db); @@ -120,8 +120,8 @@ impl Blockchain { &self.db, block, self.block_reward_amount, - self.ride_request_referrer_fee_percent, - self.ride_offer_referrer_fee_percent, + self.ride_request_referrer_fee_bps, + self.ride_offer_referrer_fee_bps, )?; Ok(()) @@ -148,12 +148,12 @@ impl Blockchain { self.block_reward_amount } - pub fn ride_request_referrer_fee_percent(&self) -> u8 { - self.ride_request_referrer_fee_percent + pub fn ride_request_referrer_fee_bps(&self) -> u16 { + self.ride_request_referrer_fee_bps } - pub fn ride_offer_referrer_fee_percent(&self) -> u8 { - self.ride_offer_referrer_fee_percent + pub fn ride_offer_referrer_fee_bps(&self) -> u16 { + self.ride_offer_referrer_fee_bps } #[allow(dead_code)] diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index b38a2ac..9f17dd0 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -297,8 +297,8 @@ impl Block { db: &Database, block: &Block, block_reward_amount: u64, - ride_request_referrer_fee_percent: u8, - ride_offer_referrer_fee_percent: u8, + ride_request_referrer_fee_bps: u16, + ride_offer_referrer_fee_bps: u16, ) -> Result<(), String> { // Storage for keys and values let mut cf_storage: Vec = Vec::new(); @@ -336,8 +336,8 @@ impl Block { for (tx_index, tx) in block.transactions.iter().enumerate() { let updates = tx.state_transaction( &db, - ride_request_referrer_fee_percent, - ride_offer_referrer_fee_percent, + ride_request_referrer_fee_bps, + ride_offer_referrer_fee_bps, ); let mut tx_effects = Vec::new(); diff --git a/src/node/configuration.rs b/src/node/configuration.rs index 9e12771..1dbd52a 100644 --- a/src/node/configuration.rs +++ b/src/node/configuration.rs @@ -17,8 +17,8 @@ pub struct AppConfig { pub bootstrap_nodes: Vec, pub block_authoring_enabled: bool, pub block_reward_amount: u64, - pub ride_request_referrer_fee_percent: u8, - pub ride_offer_referrer_fee_percent: u8, + pub ride_request_referrer_fee_bps: u16, + pub ride_offer_referrer_fee_bps: u16, pub sync_enabled: bool, pub serve_metric_enabled: bool, pub serve_metric_addr: String, diff --git a/src/node/transactions/ride_pay.rs b/src/node/transactions/ride_pay.rs index 8e00a7c..9574fac 100644 --- a/src/node/transactions/ride_pay.rs +++ b/src/node/transactions/ride_pay.rs @@ -13,12 +13,11 @@ use super::{ ride_request::RideRequest, }; -fn referrer_fee_ceiling(percent: u8, fare: u64) -> u64 { - if percent == 0 || fare == 0 { - return 0; - } - // saturating so an absurd fare can't overflow-panic (debug) or wrap (release). - ((percent as u64).saturating_mul(fare).saturating_add(99)) / 100 +/// Referrer fee in base units: floor(fare * bps / 10_000). Stored as basis points so +/// fractional percentages need no config migration (spec §4a). u128 intermediate — +/// the product can exceed u64 but the result never does (result <= fare). +fn referrer_fee_floor(bps: u16, fare: u64) -> u64 { + ((fare as u128 * bps as u128) / 10_000) as u64 } /// Split `fare` into (request-referrer fee, offer-referrer fee, driver remainder), @@ -30,6 +29,7 @@ fn split_fare(fare: u64, request_fee: u64, offer_fee: u64) -> (u64, u64, u64) { let request = request_fee.min(fare); let offer = offer_fee.min(fare - request); let driver = fare - request - offer; + debug_assert_eq!(request + offer + driver, fare, "fee split must sum exactly"); (request, offer, driver) } @@ -112,8 +112,8 @@ impl RidePay { &self, tx_hash: &String, db: &Database, - request_fee_percent: u8, - offer_fee_percent: u8, + request_fee_bps: u16, + offer_fee_bps: u16, passenger: &String, ) -> Vec { let ride_acceptance_tx_hash = &self.ride_acceptance_transaction_hash; @@ -168,11 +168,11 @@ impl RidePay { // Cap referrer fees so request + offer can never exceed the fare being paid; the // driver gets the remainder. Prevents the `fare - total_deducted` underflow. let request_fee = match &request_referrer { - Some(_) => referrer_fee_ceiling(request_fee_percent, self.fare), + Some(_) => referrer_fee_floor(request_fee_bps, self.fare), None => 0, }; let offer_fee = match &offer_referrer { - Some(_) => referrer_fee_ceiling(offer_fee_percent, self.fare), + Some(_) => referrer_fee_floor(offer_fee_bps, self.fare), None => 0, }; let (request_fee, offer_fee, driver_amount) = @@ -243,34 +243,46 @@ impl Decodable for RidePay { #[cfg(test)] mod tests { - use super::{referrer_fee_ceiling, split_fare}; + use super::{referrer_fee_floor, split_fare}; + use proptest::prelude::*; + + #[test] + fn referrer_fee_floor_bps() { + assert_eq!(referrer_fee_floor(0, 100), 0); + assert_eq!(referrer_fee_floor(200, 0), 0); + assert_eq!(referrer_fee_floor(200, 100), 2); // 2% of 100 + // Floor kills the old ceiling distortion (2% of 3 ceiling-rounded to 33%). + assert_eq!(referrer_fee_floor(200, 3), 0); + assert_eq!(referrer_fee_floor(200, 49), 0); + assert_eq!(referrer_fee_floor(200, 50), 1); + assert_eq!(referrer_fee_floor(10_000, u64::MAX), u64::MAX); // 100%, no overflow + assert_eq!(referrer_fee_floor(1, 10_000), 1); // 1 bp granularity + } #[test] fn split_fare_never_exceeds_fare() { - // Normal fares: fees fit, driver gets the rest. assert_eq!(split_fare(100, 2, 2), (2, 2, 96)); - // Ceiling overshoot on tiny fare: 2% of 1 rounds to 1 on each side (sum 2 > 1). - // Capped so the total stays 1 and the driver amount never underflows. assert_eq!(split_fare(1, 1, 1), (1, 0, 0)); - // Misconfigured fees summing to > 100%: still capped at the fare. assert_eq!(split_fare(10, 8, 8), (8, 2, 0)); - // No referrers: driver gets the whole fare. assert_eq!(split_fare(50, 0, 0), (0, 0, 50)); - // Invariant across a range: request + offer + driver == fare, no overflow. - for fare in [0u64, 1, 2, 3, 100, u64::MAX] { - let fee = referrer_fee_ceiling(60, fare); - let (r, o, d) = split_fare(fare, fee, fee); - assert_eq!(r + o + d, fare, "fare {}", fare); - assert!(r + o <= fare); - } } - #[test] - fn referrer_fee_ceiling_saturates() { - assert_eq!(referrer_fee_ceiling(0, 100), 0); - assert_eq!(referrer_fee_ceiling(2, 0), 0); - assert_eq!(referrer_fee_ceiling(2, 100), 2); - assert_eq!(referrer_fee_ceiling(2, 1), 1); // ceiling rounds up - let _ = referrer_fee_ceiling(100, u64::MAX); // must not overflow-panic + proptest! { + // Spec §4a: request + offer + driver == fare, exactly, for every input. + #[test] + fn fee_split_sums_exactly(fare in any::(), rbps in 0u16..=10_000, obps in 0u16..=10_000) { + let (r, o, d) = split_fare( + fare, + referrer_fee_floor(rbps, fare), + referrer_fee_floor(obps, fare), + ); + prop_assert!(r <= fare && o <= fare - r); + prop_assert_eq!(r + o + d, fare); + } + + #[test] + fn floor_fee_bounded_by_fare(fare in any::(), bps in 0u16..=10_000) { + prop_assert!(referrer_fee_floor(bps, fare) <= fare); + } } } diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index c270679..9ec5eed 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -233,8 +233,8 @@ impl Transaction { pub fn state_transaction( &self, db: &Database, - ride_request_referrer_fee_percent: u8, - ride_offer_referrer_fee_percent: u8, + ride_request_referrer_fee_bps: u16, + ride_offer_referrer_fee_bps: u16, ) -> Vec { let mut states = match &self.data { FunctionCall::Transfer(transfer) => transfer.state_transaction(&self.from, db), @@ -250,8 +250,8 @@ impl Transaction { FunctionCall::RidePay(ride_pay) => ride_pay.state_transaction( &self.hash, db, - ride_request_referrer_fee_percent, - ride_offer_referrer_fee_percent, + ride_request_referrer_fee_bps, + ride_offer_referrer_fee_bps, &self.from, ), FunctionCall::RideCancel(ride_cancel) => ride_cancel.state_transaction(&self.hash, db), diff --git a/tests/balance_effects.rs b/tests/balance_effects.rs index a4313e1..e57c049 100644 --- a/tests/balance_effects.rs +++ b/tests/balance_effects.rs @@ -14,7 +14,7 @@ use clutch_node::node::{ }; use serial_test::serial; -const REFERRER_FEE_PERCENT: u8 = 2; +const REFERRER_FEE_BPS: u16 = 200; const PASSENGER: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; const PASSENGER_SK: &str = @@ -107,7 +107,7 @@ fn ride_pay_emits_referrer_request_fee_effect() { ); } - let fare = 10u64; + let fare = 100u64; let ride_pay = RidePay { ride_acceptance_transaction_hash: ride_acceptance_hash, fare, @@ -122,8 +122,8 @@ fn ride_pay_emits_referrer_request_fee_effect() { let pay_updates = ride_pay.state_transaction( &ride_pay_hash, &db, - REFERRER_FEE_PERCENT, - REFERRER_FEE_PERCENT, + REFERRER_FEE_BPS, + REFERRER_FEE_BPS, &PASSENGER.to_string(), ); let mut effects = Vec::new(); @@ -154,18 +154,18 @@ fn ride_pay_emits_referrer_request_fee_effect() { .collect(); assert_eq!(referrer_effects.len(), 1); assert_eq!(referrer_effects[0].effect.address, REFERRER); - assert_eq!(referrer_effects[0].effect.delta, 1); + assert_eq!(referrer_effects[0].effect.delta, 2); let account_effects = get_account_balance_effects(&db, REFERRER, 20, 0); assert!( account_effects .iter() - .any(|e| e.effect.kind == BalanceEffectKind::ReferrerRequestFee && e.effect.delta == 1), + .any(|e| e.effect.kind == BalanceEffectKind::ReferrerRequestFee && e.effect.delta == 2), "expected referrer_request_fee in account effects" ); assert_eq!( AccountState::get_current_state(&REFERRER.to_string(), &db).balance, - 1 + 2 ); } diff --git a/tests/block_reward.rs b/tests/block_reward.rs index c74bc8c..610ef7a 100644 --- a/tests/block_reward.rs +++ b/tests/block_reward.rs @@ -19,8 +19,8 @@ fn new_blockchain(name: &str) -> Blockchain { true, vec![AUTHOR_PUBLIC_KEY.to_string()], BLOCK_REWARD_AMOUNT, - 2, - 2, + 200, + 200, ) } diff --git a/tests/ride_sharing.rs b/tests/ride_sharing.rs index 1ae8bca..ce41cb0 100644 --- a/tests/ride_sharing.rs +++ b/tests/ride_sharing.rs @@ -108,8 +108,8 @@ fn new_blockchain() -> Blockchain { true, authorities, BLOCK_REWARD_AMOUNT, - 2, - 2, + 200, + 200, ); blockchain } From fbe85b91a88cc1b359350af33adcd63359dde35f Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 07:59:26 +0400 Subject: [PATCH 05/24] fix: narrow referrer_fee_floor doc claim to the valid bps range The result <= fare guarantee holds only for bps <= 10_000; above that the u128 quotient can exceed u64 and truncate. split_fare's .min(fare) cap contains it, and a later task validates the range at boot. Co-Authored-By: Claude Fable 5 --- src/node/transactions/ride_pay.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/node/transactions/ride_pay.rs b/src/node/transactions/ride_pay.rs index 9574fac..6886357 100644 --- a/src/node/transactions/ride_pay.rs +++ b/src/node/transactions/ride_pay.rs @@ -15,9 +15,12 @@ use super::{ /// Referrer fee in base units: floor(fare * bps / 10_000). Stored as basis points so /// fractional percentages need no config migration (spec §4a). u128 intermediate — -/// the product can exceed u64 but the result never does (result <= fare). +/// the quotient can exceed u64 for bps > 10_000 (100%); split_fare's .min(fare) cap +/// contains any overflow, and a later task validates the bps range at boot. fn referrer_fee_floor(bps: u16, fare: u64) -> u64 { - ((fare as u128 * bps as u128) / 10_000) as u64 + let result = ((fare as u128 * bps as u128) / 10_000) as u64; + debug_assert!(result <= fare || bps > 10_000, "result > fare with valid bps (<=10k)"); + result } /// Split `fare` into (request-referrer fee, offer-referrer fee, driver remainder), From 63670d8fb61b78a7c1519187931586a50abba2a4 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 08:06:15 +0400 Subject: [PATCH 06/24] feat!: ChainInit genesis transaction carrying consensus parameters New RLP tag 9, genesis-only (verify_state always rejects). Writes chain_params + total_supply state keys and the testnet faucet credit. Adds Mint/Burn/TxFeePaid/TxFeeEarned balance-effect kinds. Co-Authored-By: Claude Fable 5 --- src/node/balance_effect.rs | 4 + src/node/rlp_encoding.rs | 10 +++ src/node/transactions/chain_init.rs | 109 +++++++++++++++++++++++++ src/node/transactions/function_call.rs | 3 + src/node/transactions/mod.rs | 1 + src/node/transactions/transaction.rs | 3 + tests/chain_init.rs | 84 +++++++++++++++++++ 7 files changed, 214 insertions(+) create mode 100644 src/node/transactions/chain_init.rs create mode 100644 tests/chain_init.rs diff --git a/src/node/balance_effect.rs b/src/node/balance_effect.rs index 261db29..f80e78a 100644 --- a/src/node/balance_effect.rs +++ b/src/node/balance_effect.rs @@ -13,6 +13,10 @@ pub enum BalanceEffectKind { ReferrerOfferFee, RideCancelRefund, BlockReward, + Mint, + Burn, + TxFeePaid, + TxFeeEarned, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/node/rlp_encoding.rs b/src/node/rlp_encoding.rs index 8b8adab..300893a 100644 --- a/src/node/rlp_encoding.rs +++ b/src/node/rlp_encoding.rs @@ -11,6 +11,7 @@ use super::blocks::block_headers::{BlockHeader, BlockHeaders}; use super::p2p_server::get_block_bodies::GetBlockBodies; use super::p2p_server::get_block_header::GetBlockHeaders; use super::p2p_server::handshake::Handshake; +use super::transactions::chain_init::ChainInit; use super::transactions::function_call::FunctionCall; use super::transactions::ride_acceptance::RideAcceptance; use super::transactions::ride_cancel::RideCancel; @@ -58,6 +59,11 @@ impl Encodable for FunctionCall { stream.append(&8u8); // Tag for RideRequestCancel stream.append(args); } + FunctionCall::ChainInit(args) => { + stream.begin_list(2); + stream.append(&9u8); // Tag for ChainInit (genesis-only) + stream.append(args); + } } } } @@ -99,6 +105,10 @@ impl Decodable for FunctionCall { let args: RideRequestCancel = rlp.val_at(1)?; Ok(FunctionCall::RideRequestCancel(args)) } + 9 => { + let args: ChainInit = rlp.val_at(1)?; + Ok(FunctionCall::ChainInit(args)) + } _ => Err(DecoderError::Custom("Unknown FunctionCall variant")), } } diff --git a/src/node/transactions/chain_init.rs b/src/node/transactions/chain_init.rs new file mode 100644 index 0000000..66777f1 --- /dev/null +++ b/src/node/transactions/chain_init.rs @@ -0,0 +1,109 @@ +use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; +use serde::{Deserialize, Serialize}; + +use crate::node::account_state::AccountState; +use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; +use crate::node::database::Database; + +pub const CHAIN_PARAMS_KEY: &[u8] = b"chain_params"; +pub const TOTAL_SUPPLY_KEY: &[u8] = b"total_supply"; + +/// Consensus parameters, committed to by the genesis hash: this struct rides in the +/// genesis block's single ChainInit transaction, whose hash feeds the block hash that +/// peers compare at p2p handshake. Runtime reads them from state via `get`, never from +/// per-node config — a node with different values gets a different genesis and cannot +/// peer. This closes the block_reward-style consensus-divergence bug class. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ChainInit { + pub chain_id: u64, + pub is_testnet: bool, + pub tx_fee: u64, + pub ride_request_referrer_fee_bps: u16, + pub ride_offer_referrer_fee_bps: u16, + pub mint_authority: String, + pub faucet_address: String, + pub faucet_allocation: u64, +} + +impl ChainInit { + pub fn get(db: &Database) -> Result { + match db.get("state", CHAIN_PARAMS_KEY) { + Ok(Some(v)) => serde_json::from_slice(&v) + .map_err(|e| format!("corrupt chain_params in state: {}", e)), + Ok(None) => Err("chain_params missing from state (genesis not imported?)".to_string()), + Err(e) => Err(format!("failed to read chain_params: {}", e)), + } + } + + pub fn get_total_supply(db: &Database) -> Result { + match db.get("state", TOTAL_SUPPLY_KEY) { + Ok(Some(v)) => serde_json::from_slice(&v) + .map_err(|e| format!("corrupt total_supply in state: {}", e)), + Ok(None) => Ok(0), + Err(e) => Err(format!("failed to read total_supply: {}", e)), + } + } + + pub fn verify_state(&self, _from: &String, _db: &Database) -> Result<(), String> { + // Genesis import bypasses validate_transaction entirely, so reaching this check + // means the tx arrived via the pool or a non-genesis block — always reject. + Err("ChainInit is only valid in the genesis block".to_string()) + } + + pub fn state_transaction(&self, db: &Database) -> Vec { + let initial_supply = if self.is_testnet { self.faucet_allocation } else { 0 }; + let mut updates = vec![ + StateUpdate::storage_only( + CHAIN_PARAMS_KEY.to_vec(), + serde_json::to_vec(self).expect("serialize chain params"), + ), + StateUpdate::storage_only( + TOTAL_SUPPLY_KEY.to_vec(), + serde_json::to_vec(&initial_supply).expect("serialize supply"), + ), + ]; + if initial_supply > 0 { + // faucet_allocation is validated <= i64::MAX at boot (Blockchain::new). + updates.push(AccountState::apply_balance_change( + &self.faucet_address, + initial_supply as i64, + BalanceEffectKind::Mint, + None, + db, + )); + } + updates + } +} + +impl Encodable for ChainInit { + fn rlp_append(&self, stream: &mut RlpStream) { + stream.begin_list(8); + stream.append(&self.chain_id); + stream.append(&(self.is_testnet as u8)); + stream.append(&self.tx_fee); + stream.append(&self.ride_request_referrer_fee_bps); + stream.append(&self.ride_offer_referrer_fee_bps); + stream.append(&self.mint_authority); + stream.append(&self.faucet_address); + stream.append(&self.faucet_allocation); + } +} + +impl Decodable for ChainInit { + fn decode(rlp: &Rlp) -> Result { + if !rlp.is_list() || rlp.item_count()? != 8 { + return Err(DecoderError::RlpIncorrectListLen); + } + Ok(ChainInit { + chain_id: rlp.val_at(0)?, + is_testnet: rlp.val_at::(1)? != 0, + tx_fee: rlp.val_at(2)?, + ride_request_referrer_fee_bps: rlp.val_at(3)?, + ride_offer_referrer_fee_bps: rlp.val_at(4)?, + mint_authority: rlp.val_at(5)?, + faucet_address: rlp.val_at(6)?, + faucet_allocation: rlp.val_at(7)?, + }) + } +} diff --git a/src/node/transactions/function_call.rs b/src/node/transactions/function_call.rs index b1b2d9c..a9b92ad 100644 --- a/src/node/transactions/function_call.rs +++ b/src/node/transactions/function_call.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; +use super::chain_init::ChainInit; use super::{ ride_acceptance::RideAcceptance, ride_cancel::RideCancel, ride_offer::RideOffer, ride_pay::RidePay, ride_request::RideRequest, ride_request_cancel::RideRequestCancel, @@ -17,6 +18,7 @@ pub enum FunctionCall { RidePay(RidePay), RideCancel(RideCancel), RideRequestCancel(RideRequestCancel), + ChainInit(ChainInit), } impl fmt::Display for FunctionCall { @@ -29,6 +31,7 @@ impl fmt::Display for FunctionCall { FunctionCall::RidePay(args) => write!(f, "RidePay: {:?}", args), FunctionCall::RideCancel(args) => write!(f, "RideCancel: {:?}", args), FunctionCall::RideRequestCancel(args) => write!(f, "RideRequestCancel: {:?}", args), + FunctionCall::ChainInit(args) => write!(f, "ChainInit: {:?}", args), } } } diff --git a/src/node/transactions/mod.rs b/src/node/transactions/mod.rs index 58f2bcd..1c55e29 100644 --- a/src/node/transactions/mod.rs +++ b/src/node/transactions/mod.rs @@ -1,4 +1,5 @@ pub mod address; +pub mod chain_init; pub mod function_call; pub mod passenger_concurrent; pub mod ride_acceptance; diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 9ec5eed..046c267 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -215,6 +215,7 @@ impl Transaction { FunctionCall::RideRequestCancel(ride_request_cancel) => { ride_request_cancel.verify_state(&self.from, db) } + FunctionCall::ChainInit(chain_init) => chain_init.verify_state(&self.from, db), } } @@ -227,6 +228,7 @@ impl Transaction { FunctionCall::RidePay(_) => "RidePay", FunctionCall::RideCancel(_) => "RideCancel", FunctionCall::RideRequestCancel(_) => "RideRequestCancel", + FunctionCall::ChainInit(_) => "ChainInit", } } @@ -258,6 +260,7 @@ impl Transaction { FunctionCall::RideRequestCancel(ride_request_cancel) => { ride_request_cancel.state_transaction(&self.hash, db) } + FunctionCall::ChainInit(chain_init) => chain_init.state_transaction(db), }; match AccountState::increase_account_nonce_key(&self.from, db) { diff --git a/tests/chain_init.rs b/tests/chain_init.rs new file mode 100644 index 0000000..8b92755 --- /dev/null +++ b/tests/chain_init.rs @@ -0,0 +1,84 @@ +use clutch_node::node::database::Database; +use clutch_node::node::transactions::chain_init::ChainInit; +use serial_test::serial; + +fn test_params() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + faucet_address: "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +#[test] +fn chain_init_rlp_roundtrip() { + let ci = test_params(); + let encoded = clutch_node::node::rlp_encoding::encode(&ci); + let decoded: ChainInit = clutch_node::node::rlp_encoding::decode(&encoded).unwrap(); + assert_eq!(ci, decoded); +} + +#[test] +#[serial] +fn chain_init_rejected_outside_genesis() { + let db = Database::new_db("test-chain-init-reject"); + let err = test_params() + .verify_state(&"0xanyone".to_string(), &db) + .unwrap_err(); + assert!(err.contains("genesis"), "got: {}", err); + drop(db); + let mut db = Database::new_db("test-chain-init-reject"); + db.close(); + db.delete_database("test-chain-init-reject").unwrap(); +} + +#[test] +#[serial] +fn chain_init_state_writes_params_supply_and_faucet() { + let name = "test-chain-init-state"; + let db = Database::new_db(name); + let ci = test_params(); + let updates = ci.state_transaction(&db); + // Apply the storage updates the way add_block_to_chain would. + let ops: Vec<(&str, &[u8], Option<&[u8]>)> = updates + .iter() + .filter_map(|u| u.storage.as_ref()) + .map(|(k, v)| ("state", k.as_slice(), Some(v.as_slice()))) + .collect(); + db.write(ops).unwrap(); + + assert_eq!(ChainInit::get(&db).unwrap(), ci); + assert_eq!(ChainInit::get_total_supply(&db).unwrap(), ci.faucet_allocation); + let faucet = clutch_node::node::account_state::AccountState::get_current_state( + &ci.faucet_address, &db, + ); + assert_eq!(faucet.balance, ci.faucet_allocation); + + let mut db = db; + db.close(); + db.delete_database(name).unwrap(); +} + +#[test] +#[serial] +fn chain_init_mainnet_flag_zeroes_supply() { + let name = "test-chain-init-mainnet"; + let db = Database::new_db(name); + let ci = ChainInit { is_testnet: false, faucet_allocation: 0, ..test_params() }; + let updates = ci.state_transaction(&db); + let ops: Vec<(&str, &[u8], Option<&[u8]>)> = updates + .iter() + .filter_map(|u| u.storage.as_ref()) + .map(|(k, v)| ("state", k.as_slice(), Some(v.as_slice()))) + .collect(); + db.write(ops).unwrap(); + assert_eq!(ChainInit::get_total_supply(&db).unwrap(), 0); + let mut db = db; + db.close(); + db.delete_database(name).unwrap(); +} From 66540f829dfc626c188184fa047e8f348fb37241 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 08:24:58 +0400 Subject: [PATCH 07/24] feat!: genesis carries ChainInit; params read from state; block rewards removed Genesis hash now commits to chain_id/fees/mint authority, so mismatched nodes cannot peer (fixes the config-divergence class block_reward had). Faucet allocation is testnet-gated and fails loudly on mainnet flags. Co-Authored-By: Claude Fable 5 --- config/node/default.toml | 7 ++- config/node/node1.toml | 7 ++- config/node/node2-docker.toml | 7 ++- config/node/node2.toml | 7 ++- config/node/node3-docker.toml | 7 ++- config/node/node3.toml | 7 ++- src/main.rs | 16 ++++-- src/node/blockchain.rs | 59 ++++++++++----------- src/node/blocks/block.rs | 76 ++++++++++------------------ src/node/configuration.rs | 7 ++- src/node/transactions/transaction.rs | 33 ++++-------- src/node/wss/websocket.rs | 7 +-- tests/author_block.rs | 30 ++++++++--- tests/balance_effects.rs | 20 ++++++-- tests/block_reward.rs | 62 ----------------------- tests/chain_genesis.rs | 75 +++++++++++++++++++++++++++ tests/p2p_server_tests.rs | 15 ++++-- tests/ride_sharing.rs | 24 ++++++--- tests/transfer.rs | 23 +++++++-- 19 files changed, 288 insertions(+), 201 deletions(-) delete mode 100644 tests/block_reward.rs create mode 100644 tests/chain_genesis.rs diff --git a/config/node/default.toml b/config/node/default.toml index 2c52fcb..44b4252 100644 --- a/config/node/default.toml +++ b/config/node/default.toml @@ -12,7 +12,12 @@ authorities = [ "0xc4f3f661a43e099aedb8e396d9de1a831a1b4adc", ] block_authoring_enabled = true -block_reward_amount = 50 +chain_id = 2077 +is_testnet = true +tx_fee = 1000 +mint_authority = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20" +faucet_address = "0xdeb4cfb63db134698e1879ea24904df074726cc0" +faucet_allocation = 1000000000000000 ride_request_referrer_fee_bps = 200 ride_offer_referrer_fee_bps = 200 sync_enabled = true diff --git a/config/node/node1.toml b/config/node/node1.toml index 8271652..d112d2c 100644 --- a/config/node/node1.toml +++ b/config/node/node1.toml @@ -12,7 +12,12 @@ authorities = [ "0xc4f3f661a43e099aedb8e396d9de1a831a1b4adc", ] block_authoring_enabled = true -block_reward_amount = 50 +chain_id = 2077 +is_testnet = true +tx_fee = 1000 +mint_authority = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20" +faucet_address = "0xdeb4cfb63db134698e1879ea24904df074726cc0" +faucet_allocation = 1000000000000000 ride_request_referrer_fee_bps = 200 ride_offer_referrer_fee_bps = 200 sync_enabled = true diff --git a/config/node/node2-docker.toml b/config/node/node2-docker.toml index a882a17..ff93fe4 100644 --- a/config/node/node2-docker.toml +++ b/config/node/node2-docker.toml @@ -14,7 +14,12 @@ authorities = [ "0xc4f3f661a43e099aedb8e396d9de1a831a1b4adc", ] block_authoring_enabled = true -block_reward_amount = 50 +chain_id = 2077 +is_testnet = true +tx_fee = 1000 +mint_authority = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20" +faucet_address = "0xdeb4cfb63db134698e1879ea24904df074726cc0" +faucet_allocation = 1000000000000000 ride_request_referrer_fee_bps = 200 ride_offer_referrer_fee_bps = 200 sync_enabled = true diff --git a/config/node/node2.toml b/config/node/node2.toml index bc44083..e337592 100644 --- a/config/node/node2.toml +++ b/config/node/node2.toml @@ -14,7 +14,12 @@ authorities = [ "0xc4f3f661a43e099aedb8e396d9de1a831a1b4adc", ] block_authoring_enabled = true -block_reward_amount = 50 +chain_id = 2077 +is_testnet = true +tx_fee = 1000 +mint_authority = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20" +faucet_address = "0xdeb4cfb63db134698e1879ea24904df074726cc0" +faucet_allocation = 1000000000000000 ride_request_referrer_fee_bps = 200 ride_offer_referrer_fee_bps = 200 sync_enabled = true diff --git a/config/node/node3-docker.toml b/config/node/node3-docker.toml index f9587c1..78b939f 100644 --- a/config/node/node3-docker.toml +++ b/config/node/node3-docker.toml @@ -14,7 +14,12 @@ authorities = [ "0xc4f3f661a43e099aedb8e396d9de1a831a1b4adc", ] block_authoring_enabled = true -block_reward_amount = 50 +chain_id = 2077 +is_testnet = true +tx_fee = 1000 +mint_authority = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20" +faucet_address = "0xdeb4cfb63db134698e1879ea24904df074726cc0" +faucet_allocation = 1000000000000000 ride_request_referrer_fee_bps = 200 ride_offer_referrer_fee_bps = 200 sync_enabled = true diff --git a/config/node/node3.toml b/config/node/node3.toml index ab673aa..3e723ef 100644 --- a/config/node/node3.toml +++ b/config/node/node3.toml @@ -14,7 +14,12 @@ authorities = [ "0xc4f3f661a43e099aedb8e396d9de1a831a1b4adc", ] block_authoring_enabled = true -block_reward_amount = 50 +chain_id = 2077 +is_testnet = true +tx_fee = 1000 +mint_authority = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20" +faucet_address = "0xdeb4cfb63db134698e1879ea24904df074726cc0" +faucet_allocation = 1000000000000000 ride_request_referrer_fee_bps = 200 ride_offer_referrer_fee_bps = 200 sync_enabled = true diff --git a/src/main.rs b/src/main.rs index abe7707..debaaf1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod node; use node::blockchain::Blockchain; use node::configuration::AppConfig; use node::tracing::setup_tracing; +use node::transactions::chain_init::ChainInit; #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] @@ -39,14 +40,23 @@ async fn main() -> Result<(), Box> { } fn initialize_blockchain(config: &AppConfig) -> Blockchain { + let chain_init = ChainInit { + chain_id: config.chain_id, + is_testnet: config.is_testnet, + tx_fee: config.tx_fee, + ride_request_referrer_fee_bps: config.ride_request_referrer_fee_bps, + ride_offer_referrer_fee_bps: config.ride_offer_referrer_fee_bps, + mint_authority: config.mint_authority.clone(), + faucet_address: config.faucet_address.clone(), + faucet_allocation: config.faucet_allocation, + }; + Blockchain::new( config.blockchain_name.clone(), config.author_public_key.clone(), config.author_secret_key.clone(), config.developer_mode.clone(), config.authorities.clone(), - config.block_reward_amount, - config.ride_request_referrer_fee_bps, - config.ride_offer_referrer_fee_bps, + chain_init, ) } diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index 971f3f2..11dd477 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -11,6 +11,7 @@ use crate::node::balance_effect::{get_account_balance_effects, load_block_effect use crate::node::database::Database; use crate::node::file_utils::write_to_file; use crate::node::node_services::NodeServices; +use crate::node::transactions::chain_init::ChainInit; use crate::node::transactions::ride_acceptance::{AvailableActiveTrip, AvailableRecentTrip, RideAcceptance}; use crate::node::transactions::ride_offer::{AvailableRideOffer, RideOffer}; use crate::node::transactions::ride_request::{AvailableRideRequest, MapBounds, RideRequest}; @@ -23,9 +24,7 @@ pub struct Blockchain { consensus: Aura, author_public_key: String, author_secret_key: String, - block_reward_amount: u64, - ride_request_referrer_fee_bps: u16, - ride_offer_referrer_fee_bps: u16, + chain_init: ChainInit, } impl Blockchain { @@ -35,10 +34,25 @@ impl Blockchain { author_secret_key: String, developer_mode: bool, authorities: Vec, - block_reward_amount: u64, - ride_request_referrer_fee_bps: u16, - ride_offer_referrer_fee_bps: u16, + chain_init: ChainInit, ) -> Blockchain { + // Fail loudly at boot on inconsistent economics — spec §4.5. Genesis must never + // be importable with a mainnet flag and a faucet pre-mint. + assert!( + chain_init.ride_request_referrer_fee_bps as u32 + + chain_init.ride_offer_referrer_fee_bps as u32 + <= 10_000, + "referrer fee bps sum exceeds 100%" + ); + assert!( + chain_init.faucet_allocation <= i64::MAX as u64, + "faucet_allocation exceeds i64::MAX (balance deltas are i64)" + ); + assert!( + chain_init.is_testnet || chain_init.faucet_allocation == 0, + "non-testnet chain must have zero faucet_allocation (a surviving faucet pre-mint destroys the peg)" + ); + let db = Database::new_db(&name); let step_duration = 60 / authorities.len() as u64; let blockchain = Blockchain { @@ -48,15 +62,20 @@ impl Blockchain { consensus: Aura::new(authorities, step_duration), author_public_key, author_secret_key, - block_reward_amount, - ride_request_referrer_fee_bps, - ride_offer_referrer_fee_bps, + chain_init, }; - Block::genesis_import_block(&blockchain.db); + Block::genesis_import_block(&blockchain.db, &blockchain.chain_init); blockchain } + /// Consensus params + total supply, read from state (post-genesis truth). + pub fn get_chain_info(&self) -> Result<(ChainInit, u64), String> { + let params = ChainInit::get(&self.db)?; + let supply = ChainInit::get_total_supply(&self.db)?; + Ok((params, supply)) + } + pub fn get_latest_block(&self) -> Result, String> { Block::get_latest_block(&self.db) } @@ -116,13 +135,7 @@ impl Blockchain { self.consensus.verify_block_author(&block)?; block.validate_block(&self.db)?; Transaction::validate_transactions(&self.db, &block.transactions)?; - Block::add_block_to_chain( - &self.db, - block, - self.block_reward_amount, - self.ride_request_referrer_fee_bps, - self.ride_offer_referrer_fee_bps, - )?; + Block::add_block_to_chain(&self.db, block)?; Ok(()) } @@ -144,18 +157,6 @@ impl Blockchain { Block::get_blocks_by_indexes(&self.db, indexes) } - pub fn block_reward_amount(&self) -> u64 { - self.block_reward_amount - } - - pub fn ride_request_referrer_fee_bps(&self) -> u16 { - self.ride_request_referrer_fee_bps - } - - pub fn ride_offer_referrer_fee_bps(&self) -> u16 { - self.ride_offer_referrer_fee_bps - } - #[allow(dead_code)] pub fn current_author(&self) -> &String { self.consensus.current_author() diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index 9f17dd0..b7d4446 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -4,10 +4,9 @@ use tracing::{error, info, warn}; use crate::node::database::Database; use crate::node::time_utils::get_current_timespan; -use crate::node::account_state::AccountState; -use crate::node::balance_effect::{ - persist_block_effects, persist_tx_effects, BalanceEffectKind, StateUpdate, -}; +use crate::node::balance_effect::{persist_block_effects, persist_tx_effects}; +use crate::node::transactions::chain_init::ChainInit; +use crate::node::transactions::function_call::FunctionCall; use crate::node::transactions::transaction::Transaction; use crate::node::transactions::transaction_pool::TransactionPool; use crate::node::{metric, signature_keys}; @@ -46,7 +45,7 @@ impl Block { format!("{:x}", result) } - pub fn new_genesis_block() -> Block { + pub fn new_genesis_block(params: &ChainInit) -> Block { let mut genesis_block = Block { author: String::new(), index: 0, @@ -59,7 +58,7 @@ impl Block { transactions: vec![], }; - genesis_block.transactions = Transaction::new_genesis_transactions(); + genesis_block.transactions = Transaction::new_genesis_transactions(params); genesis_block.hash = genesis_block.calculate_hash(); genesis_block } @@ -261,7 +260,7 @@ impl Block { Some((keys, values)) } - pub fn genesis_import_block(db: &Database) { + pub fn genesis_import_block(db: &Database, params: &ChainInit) { // ponytail: boot-time genesis. Fail-fast (panic) here is intentional — a node // that can't read or write its genesis block cannot run. Runtime paths return Result. match Self::get_genesis_block(db) { @@ -270,8 +269,8 @@ impl Block { } Ok(None) => { info!("Genesis block does not exist, creating new one..."); - let genesis_block = Self::new_genesis_block(); - if let Err(e) = Self::add_block_to_chain(db, &genesis_block, 0, 0, 0) { + let genesis_block = Self::new_genesis_block(params); + if let Err(e) = Self::add_block_to_chain(db, &genesis_block) { panic!("Failed to import genesis block: {}", e); } } @@ -293,13 +292,25 @@ impl Block { } } - pub fn add_block_to_chain( - db: &Database, - block: &Block, - block_reward_amount: u64, - ride_request_referrer_fee_bps: u16, - ride_offer_referrer_fee_bps: u16, - ) -> Result<(), String> { + /// Resolve consensus params: from state for normal blocks; from the block's own + /// ChainInit for the genesis import (its params aren't in state yet). + fn params_for_block(db: &Database, block: &Block) -> Result { + if block.index == 0 { + block + .transactions + .iter() + .find_map(|tx| match &tx.data { + FunctionCall::ChainInit(ci) => Some(ci.clone()), + _ => None, + }) + .ok_or_else(|| "genesis block missing ChainInit transaction".to_string()) + } else { + ChainInit::get(db) + } + } + + pub fn add_block_to_chain(db: &Database, block: &Block) -> Result<(), String> { + let params = Self::params_for_block(db, block)?; // Storage for keys and values let mut cf_storage: Vec = Vec::new(); let mut keys_storage: Vec> = Vec::new(); @@ -334,11 +345,7 @@ impl Block { // Handle transactions State for (tx_index, tx) in block.transactions.iter().enumerate() { - let updates = tx.state_transaction( - &db, - ride_request_referrer_fee_bps, - ride_offer_referrer_fee_bps, - ); + let updates = tx.state_transaction(&db, ¶ms); let mut tx_effects = Vec::new(); for update in updates { @@ -372,33 +379,6 @@ impl Block { tx_keys_to_delete.push(tx_key); } - // Mint reward for non-genesis block author. - if block.index > 0 && block_reward_amount > 0 { - let reward_update = AccountState::apply_balance_change( - &block.author, - block_reward_amount as i64, - BalanceEffectKind::BlockReward, - None, - &db, - ); - if let Some((author_reward_key, author_reward_value)) = reward_update.storage { - cf_storage.push("state".to_string()); - keys_storage.push(author_reward_key); - values_storage.push(author_reward_value); - } - if let Some(effect) = reward_update.effect { - for (key, value) in persist_block_effects( - block.index as u64, - block.timestamp, - std::slice::from_ref(&effect), - ) { - cf_storage.push("state".to_string()); - keys_storage.push(key); - values_storage.push(value); - } - } - } - // Prepare operations for database write for ((key, value), cf_name) in keys_storage .iter() diff --git a/src/node/configuration.rs b/src/node/configuration.rs index 1dbd52a..5460b3b 100644 --- a/src/node/configuration.rs +++ b/src/node/configuration.rs @@ -16,7 +16,12 @@ pub struct AppConfig { pub listen_addrs: Vec, pub bootstrap_nodes: Vec, pub block_authoring_enabled: bool, - pub block_reward_amount: u64, + pub chain_id: u64, + pub is_testnet: bool, + pub tx_fee: u64, + pub mint_authority: String, + pub faucet_address: String, + pub faucet_allocation: u64, pub ride_request_referrer_fee_bps: u16, pub ride_offer_referrer_fee_bps: u16, pub sync_enabled: bool, diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 046c267..3e45d3b 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -10,7 +10,10 @@ use serde::{Deserialize, Serialize}; use sha3::{Digest, Keccak256}; use std::vec; -use super::{function_call::FunctionCall, passenger_concurrent, transfer::Transfer}; +use super::chain_init::ChainInit; +use super::{function_call::FunctionCall, passenger_concurrent}; +#[cfg(test)] +use super::transfer::Transfer; const FROM_GENESIS: &str = "0xGENESIS"; @@ -40,21 +43,12 @@ impl Transaction { transaction } - pub fn new_genesis_transactions() -> Vec { - let tx1 = Self::new_transaction( + pub fn new_genesis_transactions(params: &ChainInit) -> Vec { + vec![Self::new_transaction( FROM_GENESIS.to_string(), 0, - FunctionCall::Transfer(Transfer { - to: "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), - // ponytail: i64::MAX, not u64::MAX. Balance deltas travel as i64 - // (transfer.rs `value as i64`), so funding u64::MAX only ever worked by - // two's-complement wrap. i64::MAX (~9.2e18) is still effectively infinite - // for a testnet faucet and keeps every balance representable in i64. - value: i64::MAX as u64, - }), - ); - - vec![tx1] + FunctionCall::ChainInit(params.clone()), + )] } /// Canonical transaction hash. MUST stay byte-for-byte in agreement with the client @@ -232,12 +226,7 @@ impl Transaction { } } - pub fn state_transaction( - &self, - db: &Database, - ride_request_referrer_fee_bps: u16, - ride_offer_referrer_fee_bps: u16, - ) -> Vec { + pub fn state_transaction(&self, db: &Database, params: &ChainInit) -> Vec { let mut states = match &self.data { FunctionCall::Transfer(transfer) => transfer.state_transaction(&self.from, db), FunctionCall::RideRequest(ride_request) => { @@ -252,8 +241,8 @@ impl Transaction { FunctionCall::RidePay(ride_pay) => ride_pay.state_transaction( &self.hash, db, - ride_request_referrer_fee_bps, - ride_offer_referrer_fee_bps, + params.ride_request_referrer_fee_bps, + params.ride_offer_referrer_fee_bps, &self.from, ), FunctionCall::RideCancel(ride_cancel) => ride_cancel.state_transaction(&self.hash, db), diff --git a/src/node/wss/websocket.rs b/src/node/wss/websocket.rs index 453889c..3fbad57 100644 --- a/src/node/wss/websocket.rs +++ b/src/node/wss/websocket.rs @@ -355,11 +355,8 @@ impl WebSocket { match blockchain.get_blocks_by_indexes(vec![params.index]) { Ok(blocks) => { if let Some(block) = blocks.into_iter().next() { - let block_reward = if block.index == 0 { - 0 - } else { - blockchain.block_reward_amount() - }; + // ponytail: block rewards removed; field kept as 0 until clutch-explorer drops it. + let block_reward: u64 = 0; let reward_recipient = block.author.clone(); let mut block_value = serde_json::to_value(&block).unwrap_or(serde_json::Value::Null); diff --git a/tests/author_block.rs b/tests/author_block.rs index 7598a76..bd8111f 100644 --- a/tests/author_block.rs +++ b/tests/author_block.rs @@ -1,6 +1,12 @@ use std::vec; -use clutch_node::node::{blockchain::Blockchain, transactions::{function_call::FunctionCall, transaction::Transaction, transfer::Transfer}}; +use clutch_node::node::{ + blockchain::Blockchain, + transactions::{ + chain_init::ChainInit, function_call::FunctionCall, transaction::Transaction, + transfer::Transfer, + }, +}; use ::tracing::info; const BLOCKCHAIN_NAME: &str = "clutch-node-transfer-test"; @@ -9,7 +15,19 @@ const FROM_SECRET_KEY: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df const TO_ADDRESS_KEY: &str = "0x8f19077627cde4848b090c53c83b12956837d5e9"; const AUTHOR_PUBLIC_KEY: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; const AUTHOR_SECRET_KEY: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; -const BLOCK_REWARD_AMOUNT: u64 = 50; + +fn ci() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 2, + ride_offer_referrer_fee_bps: 2, + mint_authority: AUTHOR_PUBLIC_KEY.to_string(), + faucet_address: FROM_ADDRESS_KEY.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} #[test] fn author_block() { @@ -20,9 +38,7 @@ fn author_block() { AUTHOR_SECRET_KEY.to_string(), true, authorities, - BLOCK_REWARD_AMOUNT, - 2, - 2, + ci(), ); let transfer_tx = transfer_transaction(1, 20); @@ -50,8 +66,8 @@ fn author_block() { let author_account_state = blockchain.get_account_state(&AUTHOR_PUBLIC_KEY.to_string()); assert_eq!( - author_account_state.balance, BLOCK_REWARD_AMOUNT, - "author should receive exactly one block reward", + author_account_state.balance, 0, + "block rewards are removed — author gets nothing for authoring", ); blockchain.shutdown_blockchain(); diff --git a/tests/balance_effects.rs b/tests/balance_effects.rs index e57c049..8cf75d3 100644 --- a/tests/balance_effects.rs +++ b/tests/balance_effects.rs @@ -8,8 +8,9 @@ use clutch_node::node::{ coordinate, database::Database, transactions::{ - function_call::FunctionCall, ride_acceptance::RideAcceptance, ride_offer::RideOffer, - ride_pay::RidePay, ride_request::RideRequest, transaction::Transaction, + chain_init::ChainInit, function_call::FunctionCall, ride_acceptance::RideAcceptance, + ride_offer::RideOffer, ride_pay::RidePay, ride_request::RideRequest, + transaction::Transaction, }, }; use serial_test::serial; @@ -23,6 +24,19 @@ const DRIVER: &str = "0x8f19077627cde4848b090c53c83b12956837d5e9"; const DRIVER_SK: &str = "e74e3f87268132c7b3ddb24600716fc362f4519bf9986a9436aa8a1be58c7150"; const REFERRER: &str = "0x0912514c7cc3eec2b2dab4e1d150c4b5eaee5a6f"; +fn ci() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: REFERRER_FEE_BPS, + ride_offer_referrer_fee_bps: REFERRER_FEE_BPS, + mint_authority: "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + faucet_address: PASSENGER.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + fn fresh_db() -> Database { let name = format!( "clutch-node-balance-effects-{}", @@ -46,7 +60,7 @@ fn apply_state_updates(db: &Database, updates: Vec) { #[serial] fn ride_pay_emits_referrer_request_fee_effect() { let db = fresh_db(); - Block::genesis_import_block(&db); + Block::genesis_import_block(&db, &ci()); let mut ride_request_tx = Transaction::new_transaction( PASSENGER.to_string(), diff --git a/tests/block_reward.rs b/tests/block_reward.rs deleted file mode 100644 index 610ef7a..0000000 --- a/tests/block_reward.rs +++ /dev/null @@ -1,62 +0,0 @@ -use clutch_node::node::{ - blockchain::Blockchain, - transactions::{function_call::FunctionCall, transaction::Transaction, transfer::Transfer}, -}; -use serial_test::serial; - -const BLOCK_REWARD_AMOUNT: u64 = 50; -const AUTHOR_PUBLIC_KEY: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; -const AUTHOR_SECRET_KEY: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; -const FROM_ADDRESS_KEY: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; -const FROM_SECRET_KEY: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; -const TO_ADDRESS_KEY: &str = "0x8f19077627cde4848b090c53c83b12956837d5e9"; - -fn new_blockchain(name: &str) -> Blockchain { - Blockchain::new( - name.to_string(), - AUTHOR_PUBLIC_KEY.to_string(), - AUTHOR_SECRET_KEY.to_string(), - true, - vec![AUTHOR_PUBLIC_KEY.to_string()], - BLOCK_REWARD_AMOUNT, - 200, - 200, - ) -} - -#[test] -#[serial] -fn author_gets_block_reward_on_authored_block() { - let mut blockchain = new_blockchain("clutch-node-block-reward-author-test"); - let mut transfer_transaction = Transaction::new_transaction( - FROM_ADDRESS_KEY.to_string(), - 1, - FunctionCall::Transfer(Transfer { - to: TO_ADDRESS_KEY.to_string(), - value: 1, - }), - ); - transfer_transaction.sign(FROM_SECRET_KEY); - - blockchain - .add_transaction_to_pool(&transfer_transaction) - .expect("failed to add tx to pool"); - - blockchain - .author_new_block() - .expect("failed to author block with reward"); - - let author_balance = blockchain.get_account_balance(&AUTHOR_PUBLIC_KEY.to_string()); - assert_eq!(author_balance, BLOCK_REWARD_AMOUNT); - - blockchain.shutdown_blockchain(); -} - -#[test] -#[serial] -fn genesis_block_does_not_mint_author_reward() { - let mut blockchain = new_blockchain("clutch-node-block-reward-genesis-test"); - let author_balance = blockchain.get_account_balance(&AUTHOR_PUBLIC_KEY.to_string()); - assert_eq!(author_balance, 0); - blockchain.shutdown_blockchain(); -} diff --git a/tests/chain_genesis.rs b/tests/chain_genesis.rs new file mode 100644 index 0000000..64bb161 --- /dev/null +++ b/tests/chain_genesis.rs @@ -0,0 +1,75 @@ +use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::transactions::chain_init::ChainInit; +use serial_test::serial; + +fn test_chain_init() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + faucet_address: "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +fn new_test_chain(name: &str, ci: ChainInit) -> Blockchain { + Blockchain::new( + name.to_string(), + "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509".to_string(), + true, // developer_mode: DB wiped on shutdown_blockchain + vec!["0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string()], + ci, + ) +} + +#[test] +#[serial] +fn genesis_funds_faucet_and_stores_params() { + let ci = test_chain_init(); + let mut chain = new_test_chain("test-genesis-testnet", ci.clone()); + assert_eq!(chain.get_account_balance(&ci.faucet_address), ci.faucet_allocation); + let (params, supply) = chain.get_chain_info().unwrap(); + assert_eq!(params, ci); + assert_eq!(supply, ci.faucet_allocation); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn genesis_hash_commits_to_chain_params() { + let mut a = new_test_chain("test-genesis-a", test_chain_init()); + let hash_a = a.get_genesis_block().unwrap().unwrap().hash; + a.shutdown_blockchain(); + + let mut b = new_test_chain( + "test-genesis-b", + ChainInit { chain_id: 1, ..test_chain_init() }, + ); + let hash_b = b.get_genesis_block().unwrap().unwrap().hash; + b.shutdown_blockchain(); + + assert_ne!(hash_a, hash_b, "different chain params must yield different genesis hashes"); +} + +#[test] +#[serial] +fn mainnet_genesis_has_zero_supply() { + let ci = ChainInit { is_testnet: false, faucet_allocation: 0, ..test_chain_init() }; + let mut chain = new_test_chain("test-genesis-mainnet", ci.clone()); + assert_eq!(chain.get_account_balance(&ci.faucet_address), 0); + let (_, supply) = chain.get_chain_info().unwrap(); + assert_eq!(supply, 0); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +#[should_panic(expected = "faucet")] +fn mainnet_with_faucet_allocation_fails_loudly() { + let ci = ChainInit { is_testnet: false, faucet_allocation: 1, ..test_chain_init() }; + let _ = new_test_chain("test-genesis-loud", ci); +} diff --git a/tests/p2p_server_tests.rs b/tests/p2p_server_tests.rs index de2aff5..c4d1c23 100644 --- a/tests/p2p_server_tests.rs +++ b/tests/p2p_server_tests.rs @@ -4,6 +4,7 @@ use clutch_node::node::p2p_server::get_block_bodies::GetBlockBodies; use clutch_node::node::p2p_server::get_block_header::GetBlockHeaders; use clutch_node::node::p2p_server::{GossipMessageType, P2PServer, P2PServerCommand}; use clutch_node::node::rlp_encoding::encode; +use clutch_node::node::transactions::chain_init::ChainInit; use tracing::info; use std::sync::Arc; use std::time::Duration; @@ -42,15 +43,23 @@ async fn setup_p2p_server( } fn initialize_blockchain(name: String) -> Blockchain { + let ci = ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 2, + ride_offer_referrer_fee_bps: 2, + mint_authority: "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), + faucet_address: "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + faucet_allocation: 1_000_000_000_000_000, + }; Blockchain::new( name, "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string(), "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509".to_string(), true, vec!["0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20".to_string()], - 50, - 2, - 2, + ci, ) } diff --git a/tests/ride_sharing.rs b/tests/ride_sharing.rs index ce41cb0..a4f3e7d 100644 --- a/tests/ride_sharing.rs +++ b/tests/ride_sharing.rs @@ -3,9 +3,9 @@ use clutch_node::node::{ blocks::block::Block, coordinate, transactions::{ - function_call::FunctionCall, ride_acceptance::RideAcceptance, ride_cancel::RideCancel, - ride_offer::RideOffer, ride_pay::RidePay, ride_request::RideRequest, - transaction::Transaction, + chain_init::ChainInit, function_call::FunctionCall, ride_acceptance::RideAcceptance, + ride_cancel::RideCancel, ride_offer::RideOffer, ride_pay::RidePay, + ride_request::RideRequest, transaction::Transaction, }, }; use serial_test::serial; @@ -38,7 +38,19 @@ const AUTHOR_2_SECRET_KEY: &str = const AUTHOR_3_PUBLIC_KEY: &str = "0xc4f3f661a43e099aedb8e396d9de1a831a1b4adc"; const AUTHOR_3_SECRET_KEY: &str = "2d75bdfabbbaa65d7a182968e579adf2566fbb6931411752dd834c56bbf092c9"; -const BLOCK_REWARD_AMOUNT: u64 = 50; + +fn ci() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: AUTHOR_1_PUBLIC_KEY.to_string(), + faucet_address: PASSENGER_ADDRESS_KEY.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} #[test] #[serial] @@ -107,9 +119,7 @@ fn new_blockchain() -> Blockchain { AUTHOR_1_SECRET_KEY.to_string(), true, authorities, - BLOCK_REWARD_AMOUNT, - 200, - 200, + ci(), ); blockchain } diff --git a/tests/transfer.rs b/tests/transfer.rs index 7d8d913..9f28f72 100644 --- a/tests/transfer.rs +++ b/tests/transfer.rs @@ -4,7 +4,10 @@ use ::tracing::{error, info}; use clutch_node::node::{ blockchain::Blockchain, blocks::block::Block, - transactions::{function_call::FunctionCall, transaction::Transaction, transfer::Transfer}, + transactions::{ + chain_init::ChainInit, function_call::FunctionCall, transaction::Transaction, + transfer::Transfer, + }, }; const BLOCKCHAIN_NAME: &str = "clutch-node-test"; @@ -13,7 +16,19 @@ const FROM_SECRET_KEY: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df const TO_ADDRESS_KEY: &str = "0x8f19077627cde4848b090c53c83b12956837d5e9"; const AUTHOR_PUBLIC_KEY: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; const AUTHOR_SECRET_KEY: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; -const BLOCK_REWARD_AMOUNT: u64 = 50; + +fn ci() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 2, + ride_offer_referrer_fee_bps: 2, + mint_authority: AUTHOR_PUBLIC_KEY.to_string(), + faucet_address: FROM_ADDRESS_KEY.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} #[test] fn transfer_founds() { @@ -24,9 +39,7 @@ fn transfer_founds() { AUTHOR_SECRET_KEY.to_string(), true, authorities, - BLOCK_REWARD_AMOUNT, - 2, - 2, + ci(), ); let blocks = [|| transfer_block(1, 1, 20)]; From 847921797757babf49f3b0993f879d86dd81b873 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 08:42:27 +0400 Subject: [PATCH 08/24] fix: enforce genesis-hash match at handshake; boot-check chain params The Handshake carried genesis_block_hash but never compared it, so mismatched-parameter nodes peered and failed later at block validation. Both handshake paths now refuse on mismatch. A pre-release database without chain_params now panics at boot instead of failing every block import silently. Adds boot-assert coverage and drops a stale import. Co-Authored-By: Claude Fable 5 --- src/node/balance_effect.rs | 1 + src/node/blockchain.rs | 13 ++++++++ src/node/blocks/block.rs | 2 +- .../p2p_server/request_response_handler.rs | 30 +++++++++++++++---- tests/chain_genesis.rs | 20 ++++++++++++- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/node/balance_effect.rs b/src/node/balance_effect.rs index f80e78a..be16565 100644 --- a/src/node/balance_effect.rs +++ b/src/node/balance_effect.rs @@ -150,6 +150,7 @@ pub fn persist_tx_effects( writes } +#[allow(dead_code)] // consumer: Task 5's fee credit re-adds the caller in block.rs pub fn persist_block_effects( block_height: u64, timestamp: u64, diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index 11dd477..4aad479 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -66,10 +66,23 @@ impl Blockchain { }; Block::genesis_import_block(&blockchain.db, &blockchain.chain_init); + + // A DB from before this release has a genesis block but no chain_params state key + // (genesis_import_block no-ops when a genesis block already exists). Every later + // add_block_to_chain would then fail quietly, forever. Fail loudly at boot instead. + if let Err(e) = ChainInit::get(&blockchain.db) { + panic!( + "chain_params missing from state after genesis import ({}); this database predates \ + the ChainInit release and must be wiped (delete the DB directory and restart)", + e + ); + } + blockchain } /// Consensus params + total supply, read from state (post-genesis truth). + #[allow(dead_code)] // consumer: Task 8's get_chain_info JSON-RPC method pub fn get_chain_info(&self) -> Result<(ChainInit, u64), String> { let params = ChainInit::get(&self.db)?; let supply = ChainInit::get_total_supply(&self.db)?; diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index b7d4446..4e1669b 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -4,7 +4,7 @@ use tracing::{error, info, warn}; use crate::node::database::Database; use crate::node::time_utils::get_current_timespan; -use crate::node::balance_effect::{persist_block_effects, persist_tx_effects}; +use crate::node::balance_effect::persist_tx_effects; use crate::node::transactions::chain_init::ChainInit; use crate::node::transactions::function_call::FunctionCall; use crate::node::transactions::transaction::Transaction; diff --git a/src/node/p2p_server/request_response_handler.rs b/src/node/p2p_server/request_response_handler.rs index 5d121f6..aa4e7d8 100644 --- a/src/node/p2p_server/request_response_handler.rs +++ b/src/node/p2p_server/request_response_handler.rs @@ -87,7 +87,7 @@ async fn handle_request_message( let payload = &request.message[1..]; let response_message = match message_type { - Some(DirectMessageType::Handshake) => handle_handshake_request(payload, blockchain).await, + Some(DirectMessageType::Handshake) => handle_handshake_request(peer, payload, blockchain).await, Some(DirectMessageType::GetBlockHeaders) => { handle_get_block_headers_request(payload, blockchain).await } @@ -177,11 +177,11 @@ fn send_response( } } -async fn handle_handshake_request(payload: &[u8], blockchain: &Arc>) -> Vec { +async fn handle_handshake_request(peer: libp2p::PeerId, payload: &[u8], blockchain: &Arc>) -> Vec { match decode::(payload) { Ok(handshake) => { debug!("Received and decoded handshake: {:?}", handshake); - handshake_response(&handshake, blockchain).await + handshake_response(peer, &handshake, blockchain).await } Err(e) => { error!("Failed to decode handshake: {:?}", e); @@ -238,13 +238,23 @@ async fn handle_handshake_response( Ok(handshake) => { debug!("Decoded Handshake: {:?}", handshake); let blockchain = blockchain.lock().await; - let current_block_index = match blockchain.handshake() { - Ok(handshake) => handshake.latest_block_index, + let our_handshake = match blockchain.handshake() { + Ok(our_handshake) => our_handshake, Err(e) => { error!("Failed to read local handshake state: {}", e); return; } }; + + if our_handshake.genesis_block_hash != handshake.genesis_block_hash { + error!( + "refusing peer {}: genesis hash mismatch (ours {}, theirs {}) — different chain parameters or a different network", + peer_id, our_handshake.genesis_block_hash, handshake.genesis_block_hash + ); + return; + } + + let current_block_index = our_handshake.latest_block_index; let received_block_index = handshake.latest_block_index; if current_block_index < received_block_index { @@ -320,12 +330,20 @@ async fn handle_block_bodies_response( } async fn handshake_response( - _handshake: &Handshake, + peer: libp2p::PeerId, + handshake: &Handshake, blockchain: &Arc>, ) -> Vec { let blockchain = blockchain.lock().await; match blockchain.handshake() { Ok(response_handshake) => { + if response_handshake.genesis_block_hash != handshake.genesis_block_hash { + error!( + "refusing peer {}: genesis hash mismatch (ours {}, theirs {}) — different chain parameters or a different network", + peer, response_handshake.genesis_block_hash, handshake.genesis_block_hash + ); + return Vec::new(); + } encode_message(DirectMessageType::Handshake, &response_handshake) } Err(e) => { diff --git a/tests/chain_genesis.rs b/tests/chain_genesis.rs index 64bb161..ec12c55 100644 --- a/tests/chain_genesis.rs +++ b/tests/chain_genesis.rs @@ -68,8 +68,26 @@ fn mainnet_genesis_has_zero_supply() { #[test] #[serial] -#[should_panic(expected = "faucet")] +#[should_panic(expected = "non-testnet chain must have zero faucet_allocation")] fn mainnet_with_faucet_allocation_fails_loudly() { let ci = ChainInit { is_testnet: false, faucet_allocation: 1, ..test_chain_init() }; let _ = new_test_chain("test-genesis-loud", ci); } + +#[test] +#[should_panic(expected = "referrer fee bps sum exceeds 100%")] +fn referrer_bps_over_100_percent_fails_loudly() { + let ci = ChainInit { + ride_request_referrer_fee_bps: 6000, + ride_offer_referrer_fee_bps: 6000, + ..test_chain_init() + }; + let _ = new_test_chain("test-genesis-bps-overflow", ci); +} + +#[test] +#[should_panic(expected = "faucet_allocation exceeds i64::MAX")] +fn faucet_allocation_over_i64_max_fails_loudly() { + let ci = ChainInit { faucet_allocation: u64::MAX, ..test_chain_init() }; + let _ = new_test_chain("test-genesis-faucet-overflow", ci); +} From 226991e495b9d13a64ef8c1cb699d966d0efba2f Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 08:51:47 +0400 Subject: [PATCH 09/24] feat!: chain_id in transaction hash preimage and wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signatures now commit to the chain — a testnet Mint can never replay on mainnet. Wire RLP is 8 items; preimage is [from, nonce, chain_id, data]. SDK and hub faucet must adopt the same format (coordinated release). Co-Authored-By: Claude Fable 5 --- src/node/blockchain.rs | 1 + src/node/rlp_encoding.rs | 32 ++++----- src/node/transactions/transaction.rs | 102 +++++++++++++++++++-------- tests/author_block.rs | 3 +- tests/balance_effects.rs | 4 ++ tests/chain_genesis.rs | 26 +++++++ tests/ride_sharing.rs | 5 ++ tests/rlp_decode_test.rs | 1 + tests/transfer.rs | 1 + 9 files changed, 130 insertions(+), 45 deletions(-) diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index 4aad479..b91ef1d 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -307,6 +307,7 @@ mod tests { Transaction::new_transaction( from.to_string(), nonce, + 2077, FunctionCall::Transfer(Transfer { to: to.to_string(), value: 1, diff --git a/src/node/rlp_encoding.rs b/src/node/rlp_encoding.rs index 300893a..b3af191 100644 --- a/src/node/rlp_encoding.rs +++ b/src/node/rlp_encoding.rs @@ -116,10 +116,10 @@ impl Decodable for FunctionCall { impl Encodable for Transaction { fn rlp_append(&self, stream: &mut RlpStream) { - stream.begin_list(7); - + stream.begin_list(8); stream.append(&self.from); stream.append(&self.nonce); + stream.append(&self.chain_id); stream.append(&self.signature_r); stream.append(&self.signature_s); let signature_v_as_u64 = self.signature_v as u64; @@ -131,39 +131,36 @@ impl Encodable for Transaction { impl Decodable for Transaction { fn decode(rlp: &Rlp) -> Result { - if !rlp.is_list() || rlp.item_count()? != 7 { + if !rlp.is_list() || rlp.item_count()? != 8 { return Err(DecoderError::RlpIncorrectListLen); - } - + } + // Handle 'from' field which may be encoded as binary data by JavaScript RLP library let from = { let from_item = rlp.at(0)?; let from_value = if let Ok(string_val) = from_item.as_val::() { - // Direct string decoding (from Rust-generated RLP) string_val } else if let Ok(bytes_val) = from_item.as_val::>() { - // Binary data decoding (from JavaScript RLP library) hex::encode(&bytes_val) } else { return Err(DecoderError::Custom("Unable to decode 'from' field as string or bytes")); }; - - // Ensure 'from' field has 0x prefix if from_value.starts_with("0x") { from_value } else { format!("0x{}", from_value) } }; - + Ok(Transaction { from, nonce: rlp.val_at(1)?, - signature_r: rlp.val_at(2)?, - signature_s: rlp.val_at(3)?, - signature_v: rlp.val_at::(4)? as i32, - hash: rlp.val_at(5)?, - data: rlp.val_at(6)?, + chain_id: rlp.val_at(2)?, + signature_r: rlp.val_at(3)?, + signature_s: rlp.val_at(4)?, + signature_v: rlp.val_at::(5)? as i32, + hash: rlp.val_at(6)?, + data: rlp.val_at(7)?, }) } } @@ -371,6 +368,7 @@ mod tests { from: "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), data: function_call, nonce: 1, + chain_id: 2077, signature_r: "3b0cb46ae73d852bb75653ed1f1710676b0b736cd33aefc0c96e6e11417a4c32" .to_string(), signature_s: "296086bdc703286c0727c59e07b727cadfc2fe7b9c061149e4a86e726ed23908" @@ -398,6 +396,7 @@ mod tests { value: 10, }), nonce: 1, + chain_id: 2077, signature_r: "3b0cb46ae73d852bb75653ed1f1710676b0b736cd33aefc0c96e6e11417a4c32" .to_string(), signature_s: "296086bdc703286c0727c59e07b727cadfc2fe7b9c061149e4a86e726ed23908" @@ -407,12 +406,13 @@ mod tests { }; let tx2 = Transaction { - from: "0xabc4cfb63db134698e1879ea24904df074726cc0".to_string(), + from: "0xabc4cfb63db134698e1879ea24904df074726cc0".to_string(), data: FunctionCall::Transfer(Transfer { to: "0x1f19077627cde4848b090c53c83b12956837d5e9".to_string(), value: 5, }), nonce: 2, + chain_id: 2077, signature_r: "2b0cb46ae73d852bb75653ed1f1710676b0b736cd33aefc0c96e6e11417a4c33" .to_string(), signature_s: "396086bdc703286c0727c59e07b727cadfc2fe7b9c061149e4a86e726ed23909" diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 3e45d3b..622f564 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -22,6 +22,7 @@ pub struct Transaction { pub from: String, pub data: FunctionCall, pub nonce: u64, + pub chain_id: u64, pub signature_r: String, pub signature_s: String, pub signature_v: i32, @@ -29,7 +30,12 @@ pub struct Transaction { } impl Transaction { - pub fn new_transaction(from: String, nonce: u64, function_call: FunctionCall) -> Transaction { + pub fn new_transaction( + from: String, + nonce: u64, + chain_id: u64, + function_call: FunctionCall, + ) -> Transaction { let mut transaction = Transaction { hash: String::new(), signature_r: String::new(), @@ -37,6 +43,7 @@ impl Transaction { signature_v: 0, from: from, nonce: nonce, + chain_id: chain_id, data: function_call, }; transaction.hash = transaction.calculate_hash(); @@ -47,21 +54,25 @@ impl Transaction { vec![Self::new_transaction( FROM_GENESIS.to_string(), 0, + params.chain_id, FunctionCall::ChainInit(params.clone()), )] } /// Canonical transaction hash. MUST stay byte-for-byte in agreement with the client /// hashing in clutch-hub-sdk-js (`signTransaction`) and clutch-hub-api's faucet: - /// Keccak-256 over RLP `[from (no 0x prefix), nonce, data]`. `from` is stripped of any - /// `0x` because the SDK RLP-encodes it without the prefix; the node's decoder re-adds the - /// prefix, so it must be removed again here for the hash to match. + /// Keccak-256 over RLP `[from (no 0x prefix), nonce, chain_id, data]`. `from` is stripped + /// of any `0x` because the SDK RLP-encodes it without the prefix; the node's decoder + /// re-adds the prefix, so it must be removed again here for the hash to match. `chain_id` + /// makes the signature network-specific — a transaction signed for one chain hashes (and + /// therefore verifies) differently on any other, closing a replay path across networks. fn calculate_hash(&self) -> String { let from_no_prefix = self.from.strip_prefix("0x").unwrap_or(&self.from); let mut stream = RlpStream::new(); - stream.begin_list(3); + stream.begin_list(4); stream.append(&from_no_prefix.to_string()); stream.append(&self.nonce); + stream.append(&self.chain_id); stream.append(&self.data); let rlp_bytes = stream.out(); @@ -162,9 +173,15 @@ impl Transaction { pub fn validate_transaction(&self, db: &Database) -> Result<(), String> { self.verify_hash()?; self.verify_signature()?; + let params = ChainInit::get(db)?; + if self.chain_id != params.chain_id { + return Err(format!( + "Verification failed: transaction chain_id {} does not match chain {}", + self.chain_id, params.chain_id + )); + } self.verify_nonce(db)?; self.verify_state(db)?; - Ok(()) } @@ -271,6 +288,7 @@ mod tests { Transaction::new_transaction( from.to_string(), nonce, + 2077, FunctionCall::Transfer(Transfer { to: to.to_string(), value: 1, @@ -278,6 +296,23 @@ mod tests { ) } + #[test] + fn hash_commits_to_chain_id() { + let a = Transaction::new_transaction( + "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + 1, + 2077, + FunctionCall::Transfer(Transfer { to: "0xA".to_string(), value: 10 }), + ); + let b = Transaction::new_transaction( + "0xdeb4cfb63db134698e1879ea24904df074726cc0".to_string(), + 1, + 1, + FunctionCall::Transfer(Transfer { to: "0xA".to_string(), value: 10 }), + ); + assert_ne!(a.hash, b.hash, "same tx on a different chain must hash differently"); + } + #[test] fn first_duplicate_sender_detects_repeat() { let a = tf("0xA", 1, "0xB"); @@ -297,21 +332,29 @@ mod tests { #[test] fn accepts_sdk_generated_ride_acceptance_hash() { - // Fixture: real clutch-hub-sdk-js signTransaction() output for a RideAcceptance. - // Pins node calculate_hash byte-for-byte against the SDK's Keccak/RLP encoding. - let raw = "f90138a83962366538616666663833323937343363616337336462656638336361336362663961373463323007b84034616630393332613765356263356435643662313065643333613638353861376437333330306131333536646664316137643733333936323932366132613366b840333634383362373936323562326566613037333337616633666638393430623163393936666133663463633035636533656535366434666433323136636436651cb84062643737323039366235663965313038333437316339313137633564653736336363623131666334386535333562366439633631336263306662323763393862f84503f842b84061626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162616261626162"; - let bytes = hex::decode(raw).expect("fixture hex"); - let tx: Transaction = - crate::node::rlp_encoding::decode(&bytes).expect("decode SDK tx"); + // TODO(sdk-v3): re-pin with real clutch-hub-sdk-js output once the SDK adds chain_id. + // The old pinned fixture predates chain_id and cannot be regenerated until the SDK + // is updated; this builds an equivalent RideAcceptance via sdk_style_tx instead. + let ride_offer_hash = "ab".repeat(32); + let mut args = RlpStream::new_list(1); + args.append(&ride_offer_hash); + let args = args.out(); + + let mut fc = RlpStream::new_list(2); + fc.append(&3u8); // RideAcceptance + fc.append_raw(args.as_ref(), 1); + + let tx = sdk_style_tx( + "9b6e8afff8329743cac73dbef83ca3cbf9a74c20", + 7, + 2077, + fc.out().as_ref(), + ); assert!( tx.verify_hash().is_ok(), - "node rejected a hash the SDK actually produced: {:?}", + "node rejected an SDK-style RideAcceptance hash: {:?}", tx.verify_hash() ); - assert_eq!( - tx.hash.strip_prefix("0x").unwrap_or(&tx.hash), - "bd772096b5f9e1083471c9117c5de763ccb11fc48e535b6d9c613bc0fb27c98b" - ); } #[test] @@ -333,18 +376,20 @@ mod tests { fc.append_raw(transfer_out.as_ref(), 1); let data_rlp = fc.out(); - let mut unsigned = RlpStream::new_list(3); + let mut unsigned = RlpStream::new_list(4); unsigned.append(&from_clean.to_string()); unsigned.append(&nonce); + unsigned.append(&2077u64); unsigned.append_raw(data_rlp.as_ref(), 1); let mut hasher = Keccak256::new(); hasher.update(unsigned.out().as_ref()); let hash_hex = hex::encode(hasher.finalize()); let dummy = "cd".repeat(32); - let mut full = RlpStream::new_list(7); + let mut full = RlpStream::new_list(8); full.append(&from_clean.to_string()); full.append(&nonce); + full.append(&2077u64); full.append(&dummy); full.append(&dummy); full.append(&28u64); @@ -361,23 +406,24 @@ mod tests { ); } - /// Builds a full signed tx for a `data` payload the way the SDK does: the hash is - /// Keccak-256 over the unsigned `[from (no 0x), nonce, data]` preimage, so these bytes are - /// self-consistent by construction. Anything the node's re-encode changes relative to the - /// wire bytes surfaces as a `verify_hash` mismatch. - fn sdk_style_tx(from_clean: &str, nonce: u64, data_rlp: &[u8]) -> Transaction { - let mut unsigned = RlpStream::new_list(3); + /// Builds a full signed tx for a `data` payload the way the SDK will in v3: the hash is + /// Keccak-256 over the unsigned `[from (no 0x), nonce, chain_id, data]` preimage, so these + /// bytes are self-consistent by construction. + fn sdk_style_tx(from_clean: &str, nonce: u64, chain_id: u64, data_rlp: &[u8]) -> Transaction { + let mut unsigned = RlpStream::new_list(4); unsigned.append(&from_clean.to_string()); unsigned.append(&nonce); + unsigned.append(&chain_id); unsigned.append_raw(data_rlp, 1); let mut hasher = Keccak256::new(); hasher.update(unsigned.out().as_ref()); let hash_hex = hex::encode(hasher.finalize()); let dummy = "cd".repeat(32); - let mut full = RlpStream::new_list(7); + let mut full = RlpStream::new_list(8); full.append(&from_clean.to_string()); full.append(&nonce); + full.append(&chain_id); full.append(&dummy); full.append(&dummy); full.append(&28u64); @@ -417,7 +463,7 @@ mod tests { fc.append(&1u8); // RideRequest fc.append_raw(args.as_ref(), 1); - let tx = sdk_style_tx(WIRE_FROM, 4, fc.out().as_ref()); + let tx = sdk_style_tx(WIRE_FROM, 4, 2077, fc.out().as_ref()); assert!( tx.verify_hash().is_ok(), "node rejected an SDK RideRequest carrying the Hub-API-injected referrer: {:?}", @@ -437,7 +483,7 @@ mod tests { fc.append(&2u8); // RideOffer fc.append_raw(args.as_ref(), 1); - let tx = sdk_style_tx(WIRE_FROM, 5, fc.out().as_ref()); + let tx = sdk_style_tx(WIRE_FROM, 5, 2077, fc.out().as_ref()); assert!( tx.verify_hash().is_ok(), "node rejected an SDK RideOffer carrying the Hub-API-injected referrer: {:?}", diff --git a/tests/author_block.rs b/tests/author_block.rs index bd8111f..09382b7 100644 --- a/tests/author_block.rs +++ b/tests/author_block.rs @@ -81,7 +81,8 @@ fn transfer_transaction(nonce: u64, transfer_value: u64) -> Transaction { let mut transfer_transaction = Transaction::new_transaction( FROM_ADDRESS_KEY.to_string(), - nonce, + nonce, + 2077, FunctionCall::Transfer(transfer), ); transfer_transaction.sign(FROM_SECRET_KEY); diff --git a/tests/balance_effects.rs b/tests/balance_effects.rs index 8cf75d3..29e4c42 100644 --- a/tests/balance_effects.rs +++ b/tests/balance_effects.rs @@ -65,6 +65,7 @@ fn ride_pay_emits_referrer_request_fee_effect() { let mut ride_request_tx = Transaction::new_transaction( PASSENGER.to_string(), 1, + 2077, FunctionCall::RideRequest(RideRequest { fare: 20, pickup_location: coordinate::Coordinates { @@ -90,6 +91,7 @@ fn ride_pay_emits_referrer_request_fee_effect() { let mut ride_offer_tx = Transaction::new_transaction( DRIVER.to_string(), 1, + 2077, FunctionCall::RideOffer(RideOffer { fare: 20, ride_request_transaction_hash: ride_request_hash.clone(), @@ -108,6 +110,7 @@ fn ride_pay_emits_referrer_request_fee_effect() { let mut ride_acceptance_tx = Transaction::new_transaction( PASSENGER.to_string(), 2, + 2077, FunctionCall::RideAcceptance(RideAcceptance { ride_offer_transaction_hash: ride_offer_hash.clone(), }), @@ -129,6 +132,7 @@ fn ride_pay_emits_referrer_request_fee_effect() { let ride_pay_hash = Transaction::new_transaction( PASSENGER.to_string(), 3, + 2077, FunctionCall::RidePay(ride_pay.clone()), ) .hash; diff --git a/tests/chain_genesis.rs b/tests/chain_genesis.rs index ec12c55..2981f1b 100644 --- a/tests/chain_genesis.rs +++ b/tests/chain_genesis.rs @@ -91,3 +91,29 @@ fn faucet_allocation_over_i64_max_fails_loudly() { let ci = ChainInit { faucet_allocation: u64::MAX, ..test_chain_init() }; let _ = new_test_chain("test-genesis-faucet-overflow", ci); } + +#[test] +#[serial] +fn wrong_chain_id_rejected_at_pool() { + use clutch_node::node::transactions::function_call::FunctionCall; + use clutch_node::node::transactions::transaction::Transaction; + use clutch_node::node::transactions::transfer::Transfer; + + let ci = test_chain_init(); // chain_id 2077 + let mut chain = new_test_chain("test-wrong-chain", ci.clone()); + + let mut tx = Transaction::new_transaction( + ci.faucet_address.clone(), + 1, + 1, // wrong chain + FunctionCall::Transfer(Transfer { + to: "0x1111111111111111111111111111111111111111".to_string(), + value: 1, + }), + ); + tx.sign("d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"); + + let err = chain.add_transaction_to_pool(&tx).unwrap_err(); + assert!(err.contains("chain_id"), "got: {}", err); + chain.shutdown_blockchain(); +} diff --git a/tests/ride_sharing.rs b/tests/ride_sharing.rs index a4f3e7d..4126ffe 100644 --- a/tests/ride_sharing.rs +++ b/tests/ride_sharing.rs @@ -183,6 +183,7 @@ fn ride_request_transcation(fare: u64, nonce: u64) -> Transaction { let mut ride_request_transcation = Transaction::new_transaction( PASSENGER_ADDRESS_KEY.to_string(), nonce, + 2077, FunctionCall::RideRequest(ride_request), ); @@ -205,6 +206,7 @@ fn ride_offer_transaction(fare: u64, nonce: u64) -> Transaction { let mut ride_offer_transaction = Transaction::new_transaction( DRIVER_ADDRESS_KEY.to_string(), nonce, + 2077, FunctionCall::RideOffer(ride_offer), ); ride_offer_transaction.sign(DRIVER_SECRET_KEY); @@ -224,6 +226,7 @@ fn ride_acceptance_transaction(nonce: u64) -> Transaction { let mut ride_acceptance_transaction = Transaction::new_transaction( PASSENGER_ADDRESS_KEY.to_string(), nonce, + 2077, FunctionCall::RideAcceptance(ride_acceptance), ); ride_acceptance_transaction.sign(PASSENGER_SECRET_KEY); @@ -244,6 +247,7 @@ fn ride_pay_transaction(fare: u64, nonce: u64) -> Transaction { let mut ride_pay_transaction = Transaction::new_transaction( PASSENGER_ADDRESS_KEY.to_string(), nonce, + 2077, FunctionCall::RidePay(ride_pay), ); ride_pay_transaction.sign(PASSENGER_SECRET_KEY); @@ -263,6 +267,7 @@ fn ride_cancel_transaction(nonce: u64) -> Transaction { let mut ride_cancel_transaction = Transaction::new_transaction( PASSENGER_ADDRESS_KEY.to_string(), nonce, + 2077, FunctionCall::RideCancel(ride_cancel), ); diff --git a/tests/rlp_decode_test.rs b/tests/rlp_decode_test.rs index 1e955a0..0b990a1 100644 --- a/tests/rlp_decode_test.rs +++ b/tests/rlp_decode_test.rs @@ -94,6 +94,7 @@ fn test_rlp_encode_ride_request_transaction() { let mut tx = Transaction::new_transaction( PASSENGER_ADDRESS_KEY.to_string(), 1, + 2077, FunctionCall::RideRequest(ride_request), ); // Sign with passenger's secret key diff --git a/tests/transfer.rs b/tests/transfer.rs index 9f28f72..545e925 100644 --- a/tests/transfer.rs +++ b/tests/transfer.rs @@ -82,6 +82,7 @@ fn transfer_block(index: usize, nonce: u64, transfer_value: u64) -> Block { let mut transfer_transaction = Transaction::new_transaction( FROM_ADDRESS_KEY.to_string(), nonce, + 2077, FunctionCall::Transfer(Transfer { to: TO_ADDRESS_KEY.to_string(), value: transfer_value, From 96e458ff1d6ec42d6e5180dc1872bd2ebbb844fe Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 09:03:54 +0400 Subject: [PATCH 10/24] docs+test: correct wire-format statements to the 8-item chain_id layout Task 4 changed the preimage and wire list but left the old shapes described in CLAUDE.md, in rlp_decode_test's printed field list, and in verify_hash's doc comment - the exact texts the SDK and hub plans read to build a matching encoder. Renames a fixture that no longer pins cross-language output, adds a raw-bytes `from` decode test, and tightens the wrong-chain assertion. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- src/node/rlp_encoding.rs | 33 ++++++++++ src/node/transactions/transaction.rs | 11 ++-- tests/chain_genesis.rs | 2 +- tests/rlp_decode_test.rs | 91 +++++++++++++++++++--------- 5 files changed, 105 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b9b6fb3..2f92538 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,6 @@ docker compose up -d # 3-node local net from ghcr image (this repo - Addresses: canonical form is `0x` + lowercase hex (`src/node/transactions/address.rs`); readers fall back to legacy no-prefix keys (`legacy_account_address_hex`) — preserve that dual-read when touching account state. - `Blockchain` is shared as `Arc>` (tokio Mutex) across the WS, p2p, authoring, and sync tasks; other tasks talk to the libp2p swarm only through `P2PServerCommand` over an mpsc channel. - Gossip payloads are `[1-byte GossipMessageType (0x01 tx, 0x02 block)] + RLP bytes` (`p2p_server/commands.rs`). -- Transaction hash = **Keccak-256** over RLP `[from (no 0x), nonce, data]` — byte-for-byte identical to clutch-hub-sdk-js `signTransaction` and the clutch-hub-api faucet (pinned by cross-language fixtures in `transaction.rs` tests). `validate_transaction` recomputes and rejects a mismatched `hash` (the hash doubles as a state key, so a forged one could shadow ride state). Block hash covers `(index, previous_hash, tx hashes)` via SHA-256 — timestamp/author are *not* hashed but the Aura author check uses `block.timestamp`. +- Transaction hash = **Keccak-256** over RLP `[from (no 0x), nonce, chain_id, data]`, meant to be byte-for-byte identical to clutch-hub-sdk-js `signTransaction` and the clutch-hub-api faucet. The wire format is the 8-item list `[from, nonce, chain_id, signature_r, signature_s, signature_v, hash, data]` — `chain_id` at index 2. No test currently pins this against externally-produced (real SDK) bytes: the old cross-language fixture in `transaction.rs` predates `chain_id` and was removed rather than left to assert something untrue; re-pinning is pending the SDK gaining `chain_id` support (see `TODO(sdk-v3)` in `transaction.rs`). `validate_transaction` recomputes and rejects a mismatched `hash` (the hash doubles as a state key, so a forged one could shadow ride state). Block hash covers `(index, previous_hash, tx hashes)` via SHA-256 — timestamp/author are *not* hashed but the Aura author check uses `block.timestamp`. - RLP decode of `from` accepts both string (Rust) and raw-bytes (JS SDK) encodings — keep compatibility when touching `rlp_encoding.rs`. - Stray `clutch-node-*.db` dirs and `output/*.json` at repo root are test/dev leftovers — safe to delete, don't commit new ones. diff --git a/src/node/rlp_encoding.rs b/src/node/rlp_encoding.rs index b3af191..37b3b58 100644 --- a/src/node/rlp_encoding.rs +++ b/src/node/rlp_encoding.rs @@ -139,8 +139,10 @@ impl Decodable for Transaction { let from = { let from_item = rlp.at(0)?; let from_value = if let Ok(string_val) = from_item.as_val::() { + // Rust-produced RLP: the address was appended as a hex string (40 ASCII chars). string_val } else if let Ok(bytes_val) = from_item.as_val::>() { + // JS-produced RLP: the SDK appends the address as raw 20 bytes, not a hex string. hex::encode(&bytes_val) } else { return Err(DecoderError::Custom("Unable to decode 'from' field as string or bytes")); @@ -357,6 +359,37 @@ mod tests { use super::*; + /// The JS SDK RLP-encodes `from` as raw 20 bytes rather than a 40-char hex string. Every + /// other decode fixture in this crate uses the string form, so this is the only test that + /// exercises the raw-bytes branch in `Decodable for Transaction` above. + #[test] + fn decode_transaction_with_raw_bytes_from() { + let from_hex = "9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; + let from_bytes = hex::decode(from_hex).expect("valid hex"); + + let function_call = FunctionCall::Transfer(Transfer { + to: "0x8f19077627cde4848b090c53c83b12956837d5e9".to_string(), + value: 10, + }); + let mut data_stream = RlpStream::new(); + function_call.rlp_append(&mut data_stream); + let data_rlp = data_stream.out(); + + let mut stream = RlpStream::new(); + stream.begin_list(8); + stream.append(&from_bytes); // raw bytes, not a hex string + stream.append(&1u64); + stream.append(&2077u64); + stream.append(&"r".to_string()); + stream.append(&"s".to_string()); + stream.append(&27u64); + stream.append(&"hash".to_string()); + stream.append_raw(data_rlp.as_ref(), 1); + + let decoded = decode::(&stream.out()).expect("decode raw-bytes from"); + assert_eq!(decoded.from, format!("0x{}", from_hex)); + } + #[test] fn test_encode_decode_transaction() { let function_call = FunctionCall::Transfer(Transfer { diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 622f564..9d027e5 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -82,7 +82,7 @@ impl Transaction { } /// Rejects a transaction whose `hash` field was not honestly derived from - /// `(from, nonce, data)`. Without this the hash is attacker-controlled and doubles as a + /// `(from, nonce, chain_id, data)`. Without this the hash is attacker-controlled and doubles as a /// storage key (`ride_request_{hash}`, etc.), letting a caller collide/shadow another /// ride's state. Comparison is 0x- and case-insensitive because the wire hash arrives /// without a `0x` prefix while node-built hashes carry one. @@ -331,10 +331,11 @@ mod tests { } #[test] - fn accepts_sdk_generated_ride_acceptance_hash() { - // TODO(sdk-v3): re-pin with real clutch-hub-sdk-js output once the SDK adds chain_id. - // The old pinned fixture predates chain_id and cannot be regenerated until the SDK - // is updated; this builds an equivalent RideAcceptance via sdk_style_tx instead. + fn accepts_sdk_style_ride_acceptance_hash() { + // TODO(sdk-v3): no test currently pins the node's hashing against externally-produced + // (real clutch-hub-sdk-js) bytes. The old pinned fixture predates chain_id and cannot + // be regenerated until the SDK adds chain_id; this builds an equivalent RideAcceptance + // via sdk_style_tx instead. Re-pin against real SDK output once the SDK supports chain_id. let ride_offer_hash = "ab".repeat(32); let mut args = RlpStream::new_list(1); args.append(&ride_offer_hash); diff --git a/tests/chain_genesis.rs b/tests/chain_genesis.rs index 2981f1b..d5921f6 100644 --- a/tests/chain_genesis.rs +++ b/tests/chain_genesis.rs @@ -114,6 +114,6 @@ fn wrong_chain_id_rejected_at_pool() { tx.sign("d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"); let err = chain.add_transaction_to_pool(&tx).unwrap_err(); - assert!(err.contains("chain_id"), "got: {}", err); + assert!(err.contains("does not match chain"), "got: {}", err); chain.shutdown_blockchain(); } diff --git a/tests/rlp_decode_test.rs b/tests/rlp_decode_test.rs index 0b990a1..519b0b7 100644 --- a/tests/rlp_decode_test.rs +++ b/tests/rlp_decode_test.rs @@ -2,30 +2,68 @@ mod tests { use clutch_node::node::transactions::function_call::FunctionCall; use clutch_node::node::transactions::ride_request::RideRequest; - use hex; + use hex; + use rlp::{Encodable, RlpStream}; + use sha3::{Digest, Keccak256}; use clutch_node::node::{coordinate, rlp_encoding}; use clutch_node::node::transactions::transaction::Transaction; use std::str::from_utf8; const PASSENGER_ADDRESS_KEY: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; - const PASSENGER_SECRET_KEY: &str ="d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; - + const PASSENGER_SECRET_KEY: &str ="d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; + #[test] fn decode_rlp_to_transaction_struct() { - // Example RLP-encoded transaction hex (replace with your actual test vector if needed) - let rlp_hex = "0xf9010994deb4cfb63db134698e1879ea24904df074726cc002b84065633261346332363133373836336564363330306361316236626666333266363063653562316530306631366661616337663063353738326536373963303166b840376132636365346234353637383865306535393933383533373361303036636263306433653135343064366264313664356561646262643638623733666230641cb84035333134653461653437656262653230663862663233356531353266363135366461636435666163616131303836656461396664633631663163356162393834eb01e9d288403b300b626d50c988404c2529f6b47e10d288403b35ac4197d81888404c2b187e7693508203e8"; - let rlp_bytes = hex::decode(rlp_hex.trim_start_matches("0x")).expect("Invalid hex"); + // Build an 8-item fixture the same way `sdk_style_tx` in transaction.rs does: a 4-item + // preimage `[from (no 0x), nonce, chain_id, data]`, Keccak-256 that, then the 8-item + // wire list `[from, nonce, chain_id, signature_r, signature_s, signature_v, hash, data]` + // with chain_id at index 2. This is the current wire contract (Task 4); the old 7-item + // fixture predated chain_id and could only ever decode to Err. + let from_clean = "deb4cfb63db134698e1879ea24904df074726cc0"; + let nonce: u64 = 1; + let chain_id: u64 = 2077; + + let function_call = FunctionCall::Transfer( + clutch_node::node::transactions::transfer::Transfer { + to: "0x8f19077627cde4848b090c53c83b12956837d5e9".to_string(), + value: 10, + }, + ); + let mut data_stream = RlpStream::new(); + function_call.rlp_append(&mut data_stream); + let data_rlp = data_stream.out(); + + let mut unsigned = RlpStream::new_list(4); + unsigned.append(&from_clean.to_string()); + unsigned.append(&nonce); + unsigned.append(&chain_id); + unsigned.append_raw(data_rlp.as_ref(), 1); + let mut hasher = Keccak256::new(); + hasher.update(unsigned.out().as_ref()); + let hash_hex = hex::encode(hasher.finalize()); + + let dummy = "cd".repeat(32); + let mut full = RlpStream::new_list(8); + full.append(&from_clean.to_string()); + full.append(&nonce); + full.append(&chain_id); + full.append(&dummy); + full.append(&dummy); + full.append(&27u64); + full.append(&hash_hex); + full.append_raw(data_rlp.as_ref(), 1); + let rlp_bytes = full.out().to_vec(); // Debug print: show each RLP field let rlp = rlp::Rlp::new(&rlp_bytes); println!("RLP item count: {}", rlp.item_count().unwrap_or(0)); - + // Enhanced debugging to understand the structure better println!("Top level is list: {}", rlp.is_list()); - + // Investigate each field to find any RLP structure issues for i in 0..rlp.item_count().unwrap_or(0) { let val = rlp.at(i).unwrap(); - + // Get the bytes directly if let Ok(data) = val.data() { if let Ok(str_val) = from_utf8(data) { @@ -35,16 +73,16 @@ mod tests { } } else if val.is_list() { println!("Field {}: List with {} items", i, val.item_count().unwrap_or(0)); - - // If this is field 6 (data field), print more details - if i == 6 { + + // If this is field 7 (data field), print more details + if i == 7 { println!(" Data field structure:"); // Check if it follows the expected structure [tag, args] if val.item_count().unwrap_or(0) >= 2 { if let Ok(tag) = val.at(0).unwrap().as_val::() { println!(" Tag: {}", tag); } - + let args = val.at(1).unwrap(); if args.is_list() { println!(" Args is a list with {} items", args.item_count().unwrap_or(0)); @@ -58,20 +96,19 @@ mod tests { } } - // Decode to Transaction struct - match rlp_encoding::decode::(&rlp_bytes) { - Ok(tx) => println!("Decoded Transaction: {:#?}", tx), - Err(e) => { - println!("Failed to decode RLP to Transaction: {:?}", e); - // Print more details about expected structure - println!("Expected RLP structure for Transaction:"); - println!("- 7 items in top-level list"); - println!("- Fields: [from, nonce, signature_r, signature_s, signature_v, hash, data]"); - println!("- 'data' should be a list [tag, args] where:"); - println!(" - tag is a u8 (0-5, 8) indicating function call type"); - println!(" - args varies depending on tag"); - }, - } + // Decode to Transaction struct and assert on the current 8-item, chain_id-bearing contract. + let tx = rlp_encoding::decode::(&rlp_bytes).unwrap_or_else(|e| { + panic!( + "Failed to decode RLP to Transaction: {:?}. Expected RLP structure: \ + 8 items [from, nonce, chain_id, signature_r, signature_s, signature_v, hash, data] \ + with chain_id at index 2 and 'data' a list [tag, args].", + e + ) + }); + println!("Decoded Transaction: {:#?}", tx); + assert_eq!(tx.chain_id, chain_id, "chain_id must round-trip through decode"); + assert_eq!(tx.from, format!("0x{}", from_clean), "from must round-trip through decode"); + assert_eq!(tx.nonce, nonce, "nonce must round-trip through decode"); } From f5c779e7217197c6f9007a97acb30cbc4a29c5b3 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 09:29:08 +0400 Subject: [PATCH 11/24] feat!: flat tx fee to block author replaces block reward Every non-exempt tx pays chain_params.tx_fee; validation requires balance >= direct debit + fee. Sender fee merges into one balance write; author credited once per block. Spam now has a price. Co-Authored-By: Claude Fable 5 --- src/node/account_state.rs | 40 +++++ src/node/balance_effect.rs | 1 - src/node/blocks/block.rs | 44 +++++- src/node/transactions/ride_acceptance.rs | 27 +++- src/node/transactions/ride_cancel.rs | 48 ++++-- src/node/transactions/transaction.rs | 82 ++++++++++- src/node/transactions/transfer.rs | 34 ++--- tests/author_block.rs | 5 +- tests/balance_effects.rs | 5 +- tests/ride_sharing.rs | 180 ++++++++++++++--------- tests/tx_fee.rs | 93 ++++++++++++ 11 files changed, 443 insertions(+), 116 deletions(-) create mode 100644 tests/tx_fee.rs diff --git a/src/node/account_state.rs b/src/node/account_state.rs index 7d06137..ccbb407 100644 --- a/src/node/account_state.rs +++ b/src/node/account_state.rs @@ -122,6 +122,46 @@ impl AccountState { } } + /// Sender leg with fee merged into ONE balance write and two audit effects. + /// Two separate apply_balance_change calls on the same account within a tx would + /// each read pre-block state and the deferred batch keeps only the last write — + /// silently dropping one debit. One write, split effects, no collision. + pub fn apply_balance_change_with_fee( + public_key: &String, + main_delta: i64, + fee: u64, + kind: BalanceEffectKind, + counterparty: Option, + db: &Database, + ) -> Vec { + if fee == 0 { + return vec![Self::apply_balance_change(public_key, main_delta, kind, counterparty, db)]; + } + let canonical = canonical_account_address(public_key); + let combined = main_delta - fee as i64; + let (key, value) = Self::update_account_state_key(public_key, combined, db); + vec![ + StateUpdate { + storage: Some((key, value)), + effect: Some(BalanceEffect { + address: canonical.clone(), + delta: main_delta, + kind, + counterparty, + }), + }, + StateUpdate { + storage: None, // effect-only: storage already carries the combined write + effect: Some(BalanceEffect { + address: canonical, + delta: -(fee as i64), + kind: BalanceEffectKind::TxFeePaid, + counterparty: None, + }), + }, + ] + } + fn load_nonce(canonical: &str, db: &Database) -> Result, String> { let canonical_key = Self::construct_account_nonce_key(canonical); if let Ok(Some(value)) = db.get("state", &canonical_key) { diff --git a/src/node/balance_effect.rs b/src/node/balance_effect.rs index be16565..f80e78a 100644 --- a/src/node/balance_effect.rs +++ b/src/node/balance_effect.rs @@ -150,7 +150,6 @@ pub fn persist_tx_effects( writes } -#[allow(dead_code)] // consumer: Task 5's fee credit re-adds the caller in block.rs pub fn persist_block_effects( block_height: u64, timestamp: u64, diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index 4e1669b..31dacb8 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -2,9 +2,10 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tracing::{error, info, warn}; +use crate::node::account_state::AccountState; use crate::node::database::Database; use crate::node::time_utils::get_current_timespan; -use crate::node::balance_effect::persist_tx_effects; +use crate::node::balance_effect::{persist_block_effects, persist_tx_effects, BalanceEffectKind}; use crate::node::transactions::chain_init::ChainInit; use crate::node::transactions::function_call::FunctionCall; use crate::node::transactions::transaction::Transaction; @@ -345,7 +346,7 @@ impl Block { // Handle transactions State for (tx_index, tx) in block.transactions.iter().enumerate() { - let updates = tx.state_transaction(&db, ¶ms); + let updates = tx.state_transaction(&db, ¶ms, &block.author); let mut tx_effects = Vec::new(); for update in updates { @@ -379,6 +380,45 @@ impl Block { tx_keys_to_delete.push(tx_key); } + // Fees replace block rewards: one aggregate author credit per block (single + // write — per-tx credits would collide in the deferred batch). Fee revenue is + // backed CLT changing hands, so the reserve invariant is untouched. + // ponytail: residual ceiling (pre-existing class, same as the old block reward): + // if any tx in a fee-paying block ALSO credits the author's balance (Transfer to + // author, Mint to author, author-as-driver RidePay), that credit collides with + // this write and is lost. Operational rule: validator accounts are not app + // accounts. Lift with incremental intra-block state. + let total_fees: u64 = block + .transactions + .iter() + .map(|tx| tx.effective_fee(&block.author, ¶ms)) + .sum(); + if block.index > 0 && total_fees > 0 { + let fee_update = AccountState::apply_balance_change( + &block.author, + total_fees as i64, + BalanceEffectKind::TxFeeEarned, + None, + &db, + ); + if let Some((key, value)) = fee_update.storage { + cf_storage.push("state".to_string()); + keys_storage.push(key); + values_storage.push(value); + } + if let Some(effect) = fee_update.effect { + for (key, value) in persist_block_effects( + block.index as u64, + block.timestamp, + std::slice::from_ref(&effect), + ) { + cf_storage.push("state".to_string()); + keys_storage.push(key); + values_storage.push(value); + } + } + } + // Prepare operations for database write for ((key, value), cf_name) in keys_storage .iter() diff --git a/src/node/transactions/ride_acceptance.rs b/src/node/transactions/ride_acceptance.rs index cb1032f..77c3b80 100644 --- a/src/node/transactions/ride_acceptance.rs +++ b/src/node/transactions/ride_acceptance.rs @@ -77,12 +77,17 @@ impl RideAcceptance { )); } + let tx_fee = crate::node::transactions::chain_init::ChainInit::get(db)?.tx_fee; + let required = ride_offer + .fare + .checked_add(tx_fee) + .ok_or("fare + fee overflows u64")?; let passenger_account_state = AccountState::get_current_state(from, db); - if &passenger_account_state.balance < &ride_offer.fare { + if passenger_account_state.balance < required { return Err(format!( - "The account balance is insufficient to cover the fare for the requested ride. \ - Account balance is: {}, fare: {}", - passenger_account_state.balance, ride_offer.fare + "The account balance is insufficient to cover the fare plus the transaction fee. \ + Account balance is: {}, fare: {}, fee: {}", + passenger_account_state.balance, ride_offer.fare, tx_fee )); } @@ -129,6 +134,7 @@ impl RideAcceptance { from: &String, tx_hash: &String, db: &Database, + fee: u64, ) -> Vec { let ride_acceptance_tx_hash = &tx_hash; let ride_offer_tx_hash = &self.ride_offer_transaction_hash; @@ -156,20 +162,25 @@ impl RideAcceptance { .unwrap(); let transfer_value: i64 = ride_offer.fare as i64; - let passenger_update = AccountState::apply_balance_change( + // ponytail: fee merged into the single escrow write — a separate TxFeePaid write + // on the same account would collide in the deferred batch. Lift with + // incremental intra-block state. + let passenger_updates = AccountState::apply_balance_change_with_fee( from, -transfer_value, + fee, BalanceEffectKind::RideAcceptanceDebit, None, db, ); - vec![ + let mut updates = vec![ StateUpdate::storage_only(ride_acceptance_key, ride_acceptance_value), StateUpdate::storage_only(ride_request_acceptance_key, ride_request_acceptance_value), StateUpdate::storage_only(ride_offer_acceptance_key, ride_offer_acceptance_value), - passenger_update, - ] + ]; + updates.extend(passenger_updates); + updates } pub fn get_ride_acceptance( diff --git a/src/node/transactions/ride_cancel.rs b/src/node/transactions/ride_cancel.rs index 62667d3..12684ff 100644 --- a/src/node/transactions/ride_cancel.rs +++ b/src/node/transactions/ride_cancel.rs @@ -94,8 +94,10 @@ impl RideCancel { pub fn state_transaction( &self, + from: &String, tx_hash: &String, db: &Database, + fee: u64, ) -> Vec { let ride_cancel_key = Self::construct_ride_cancel_key(&tx_hash); let ride_cancel_value = serde_json::to_string(&self) @@ -135,19 +137,45 @@ impl RideCancel { let remaining_amount = (ride_offer.fare as i64) - (fare_paid as i64); - let passenger_update = AccountState::apply_balance_change( - &passenger, - remaining_amount, - BalanceEffectKind::RideCancelRefund, - None, - db, - ); + use crate::node::transactions::address::canonical_account_address; + let sender_is_passenger = + canonical_account_address(from) == canonical_account_address(&passenger); - vec![ + // ponytail: when the passenger cancels, refund credit and fee debit hit the SAME + // account — merge into one write. Driver-cancel: driver's key is otherwise + // untouched, standalone fee debit is safe. + let mut updates = vec![ StateUpdate::storage_only(ride_cancel_key, ride_cancel_value), - passenger_update, StateUpdate::storage_only(ride_acceptance_cancel_key, ride_acceptance_cancel_value), - ] + ]; + if sender_is_passenger { + updates.extend(AccountState::apply_balance_change_with_fee( + &passenger, + remaining_amount, + fee, + BalanceEffectKind::RideCancelRefund, + None, + db, + )); + } else { + updates.push(AccountState::apply_balance_change( + &passenger, + remaining_amount, + BalanceEffectKind::RideCancelRefund, + None, + db, + )); + if fee > 0 { + updates.push(AccountState::apply_balance_change( + from, + -(fee as i64), + BalanceEffectKind::TxFeePaid, + None, + db, + )); + } + } + updates } pub fn construct_ride_cancel_key(tx_hash: &str) -> Vec { diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 9d027e5..2807fdc 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -1,6 +1,6 @@ use crate::node::{ account_state::AccountState, - balance_effect::StateUpdate, + balance_effect::{BalanceEffectKind, StateUpdate}, database::Database, signature_keys::{self, SignatureKeys}, }; @@ -180,11 +180,54 @@ impl Transaction { self.chain_id, params.chain_id )); } + if !self.fee_exempt() { + let required = self + .sender_direct_debit() + .checked_add(params.tx_fee) + .ok_or("Verification failed: amount + fee overflows u64")?; + let balance = AccountState::get_current_state(&self.from, db).balance; + if balance < required { + return Err(format!( + "Verification failed: insufficient balance for amount + fee. Required: {}, available: {}", + required, balance + )); + } + } self.verify_nonce(db)?; self.verify_state(db)?; Ok(()) } + /// Mint is exempt: the treasury authority mints TO users and may itself hold zero + /// balance. ChainInit is genesis-only. Everything else pays the flat fee. + fn fee_exempt(&self) -> bool { + matches!(&self.data, FunctionCall::ChainInit(_)) + // Task 6 extends this to: FunctionCall::Mint(_) | FunctionCall::ChainInit(_) + } + + /// CLT the sender's balance is directly debited by this tx (excluding the fee). + fn sender_direct_debit(&self) -> u64 { + match &self.data { + FunctionCall::Transfer(t) => t.value, + // Task 7 adds: FunctionCall::Burn(b) => b.amount, + _ => 0, + } + } + + /// ponytail: author's own tx nets zero fee — a debit and an aggregate credit on the + /// same account in one block collide in the deferred batch (last write wins), so we + /// skip both sides instead. Lift with incremental intra-block state. + pub fn effective_fee(&self, block_author: &str, params: &ChainInit) -> u64 { + use crate::node::transactions::address::canonical_account_address; + if self.fee_exempt() + || canonical_account_address(&self.from) == canonical_account_address(block_author) + { + 0 + } else { + params.tx_fee + } + } + fn verify_nonce(&self, db: &Database) -> Result { match AccountState::get_current_nonce(&self.from, db) { Ok(last_nonce) => { @@ -243,9 +286,15 @@ impl Transaction { } } - pub fn state_transaction(&self, db: &Database, params: &ChainInit) -> Vec { + pub fn state_transaction( + &self, + db: &Database, + params: &ChainInit, + block_author: &str, + ) -> Vec { + let fee = self.effective_fee(block_author, params); let mut states = match &self.data { - FunctionCall::Transfer(transfer) => transfer.state_transaction(&self.from, db), + FunctionCall::Transfer(transfer) => transfer.state_transaction(&self.from, db, fee), FunctionCall::RideRequest(ride_request) => { ride_request.state_transaction(&self.from, &self.hash, db) } @@ -253,7 +302,7 @@ impl Transaction { ride_offer.state_transaction(&self.from, &self.hash, db) } FunctionCall::RideAcceptance(ride_acceptance) => { - ride_acceptance.state_transaction(&self.from, &self.hash, db) + ride_acceptance.state_transaction(&self.from, &self.hash, db, fee) } FunctionCall::RidePay(ride_pay) => ride_pay.state_transaction( &self.hash, @@ -262,13 +311,36 @@ impl Transaction { params.ride_offer_referrer_fee_bps, &self.from, ), - FunctionCall::RideCancel(ride_cancel) => ride_cancel.state_transaction(&self.hash, db), + FunctionCall::RideCancel(ride_cancel) => { + ride_cancel.state_transaction(&self.from, &self.hash, db, fee) + } FunctionCall::RideRequestCancel(ride_request_cancel) => { ride_request_cancel.state_transaction(&self.hash, db) } FunctionCall::ChainInit(chain_init) => chain_init.state_transaction(db), }; + // Standalone fee debit ONLY for types that never write the sender's balance + // in-type (see routing table). Types that do (Transfer, Burn, RideAcceptance, + // RideCancel) merge the fee themselves — two writes to one account key in a tx + // collide in the deferred batch (last write wins). + let fee_handled_in_type = matches!( + &self.data, + FunctionCall::Transfer(_) + | FunctionCall::RideAcceptance(_) + | FunctionCall::RideCancel(_) + // Task 7 adds: | FunctionCall::Burn(_) + ); + if fee > 0 && !fee_handled_in_type { + states.push(AccountState::apply_balance_change( + &self.from, + -(fee as i64), + BalanceEffectKind::TxFeePaid, + None, + db, + )); + } + match AccountState::increase_account_nonce_key(&self.from, db) { Ok((nonce_key, nonce_serialized)) => { states.push(StateUpdate::storage_only(nonce_key, nonce_serialized)); diff --git a/src/node/transactions/transfer.rs b/src/node/transactions/transfer.rs index 1374a0d..1e8fa9c 100644 --- a/src/node/transactions/transfer.rs +++ b/src/node/transactions/transfer.rs @@ -25,26 +25,26 @@ impl Transfer { Ok(()) } - pub fn state_transaction(&self, from: &String, db: &Database) -> Vec { + pub fn state_transaction(&self, from: &String, db: &Database, fee: u64) -> Vec { let transfer_value: i64 = self.value as i64; let to = self.to.clone(); - vec![ - AccountState::apply_balance_change( - from, - -transfer_value, - BalanceEffectKind::TransferOut, - Some(to.clone()), - db, - ), - AccountState::apply_balance_change( - &to, - transfer_value, - BalanceEffectKind::TransferIn, - Some(from.clone()), - db, - ), - ] + let mut updates = AccountState::apply_balance_change_with_fee( + from, + -transfer_value, + fee, + BalanceEffectKind::TransferOut, + Some(to.clone()), + db, + ); + updates.push(AccountState::apply_balance_change( + &to, + transfer_value, + BalanceEffectKind::TransferIn, + Some(from.clone()), + db, + )); + updates } } diff --git a/tests/author_block.rs b/tests/author_block.rs index 09382b7..8e2c6f7 100644 --- a/tests/author_block.rs +++ b/tests/author_block.rs @@ -66,8 +66,9 @@ fn author_block() { let author_account_state = blockchain.get_account_state(&AUTHOR_PUBLIC_KEY.to_string()); assert_eq!( - author_account_state.balance, 0, - "block rewards are removed — author gets nothing for authoring", + author_account_state.balance, ci().tx_fee, + "block rewards are removed, but the author now earns the flat tx fee \ + from the one non-exempt Transfer authored in this block", ); blockchain.shutdown_blockchain(); diff --git a/tests/balance_effects.rs b/tests/balance_effects.rs index 29e4c42..a4a2a99 100644 --- a/tests/balance_effects.rs +++ b/tests/balance_effects.rs @@ -120,7 +120,10 @@ fn ride_pay_emits_referrer_request_fee_effect() { if let FunctionCall::RideAcceptance(ride_acceptance) = &ride_acceptance_tx.data { apply_state_updates( &db, - ride_acceptance.state_transaction(&PASSENGER.to_string(), &ride_acceptance_hash, &db), + // No fee flows through this test — it drives per-type state_transaction directly + // and asserts only the downstream RidePay referrer effect, not the passenger's + // post-acceptance balance. + ride_acceptance.state_transaction(&PASSENGER.to_string(), &ride_acceptance_hash, &db, 0), ); } diff --git a/tests/ride_sharing.rs b/tests/ride_sharing.rs index 4126ffe..b0c3052 100644 --- a/tests/ride_sharing.rs +++ b/tests/ride_sharing.rs @@ -5,12 +5,12 @@ use clutch_node::node::{ transactions::{ chain_init::ChainInit, function_call::FunctionCall, ride_acceptance::RideAcceptance, ride_cancel::RideCancel, ride_offer::RideOffer, ride_pay::RidePay, - ride_request::RideRequest, transaction::Transaction, + ride_request::RideRequest, transaction::Transaction, transfer::Transfer, }, }; use serial_test::serial; -use ::tracing::{error, info}; +use ::tracing::info; const BLOCKCHAIN_NAME: &str = "clutch-node-test"; @@ -21,12 +21,6 @@ const PASSENGER_SECRET_KEY: &str = const DRIVER_ADDRESS_KEY: &str = "0x8f19077627cde4848b090c53c83b12956837d5e9"; const DRIVER_SECRET_KEY: &str = "e74e3f87268132c7b3ddb24600716fc362f4519bf9986a9436aa8a1be58c7150"; -const RIDE_REQUEST_TX_HASH: &str = - "70d4cd23a2fc6c636ed1ac7744a7d58869ec95f7066d8441645821a0420f0164"; -const RIDE_OFFER_TX_HASH: &str = "c72839a57eeb93971409828845ef0b443ccb8f50a18ebf9559dba39c639633a7"; -const RIDE_ACCEPTANCE_TX_HASH: &str = - "856a5dae6fee5f249dbd144321ca28badd9297088d4927af27069e37a8cccdd9"; - const AUTHOR_1_PUBLIC_KEY: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; const AUTHOR_1_SECRET_KEY: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; @@ -64,47 +58,104 @@ fn test_ride_sharing_blockchain() { } fn import_blocks(blockchain: &mut Blockchain) { - let blocks = [ - || ride_request_block(1, 1, 20), - || ride_offer_block(2, 1, 30), - || ride_acceptance_block(3, 2), - || ride_pay_block(4, 3, 5), //5 - || ride_pay_block(5, 4, 10), // 5 + 10 = 15 - || ride_pay_block(6, 5, 10), // 15 + 10 = 25 - || ride_cancel_block(7, 6), - ]; + // Under the flat-fee rule a zero-balance sender fails validation, so the driver + // (who never receives anything otherwise in this flow) must be funded before its + // first RideOffer. This funding block shifts every later block's index +1 and the + // passenger's nonce +1 (it now consumes passenger nonce 1). Each downstream tx + // references its predecessor by that predecessor's REAL hash (captured off the + // built Transaction) rather than a hardcoded literal, since the hash commits to + // (from, nonce, chain_id, data) and nonces shifted. + let mut funding_block = faucet_to_driver_block(1, 1, 100_000); + import_block(blockchain, &mut funding_block).expect("block import failed: faucet->driver funding"); + + let ride_request_tx = ride_request_transcation(20, 2); + let ride_request_hash = ride_request_tx.hash.clone(); + let mut block = Block::new_block(2, String::new(), vec![ride_request_tx]); + import_block(blockchain, &mut block).expect("block import failed: ride request"); + + let ride_offer_tx = ride_offer_transaction(30, 1, &ride_request_hash); + let ride_offer_hash = ride_offer_tx.hash.clone(); + let mut block = Block::new_block(3, String::new(), vec![ride_offer_tx]); + // A swallowed import error here would let a dead flow (e.g. this RideOffer rejected + // for insufficient driver balance) report a passing test having executed nothing + // downstream. Fail hard instead. + import_block(blockchain, &mut block).expect("block import failed: ride offer"); + + let ride_acceptance_tx = ride_acceptance_transaction(3, &ride_offer_hash); + let ride_acceptance_hash = ride_acceptance_tx.hash.clone(); + let mut block = Block::new_block(4, String::new(), vec![ride_acceptance_tx]); + import_block(blockchain, &mut block).expect("block import failed: ride acceptance"); + + let mut block = Block::new_block( + 5, + String::new(), + vec![ride_pay_transaction(5, 4, &ride_acceptance_hash)], //5 + ); + import_block(blockchain, &mut block).expect("block import failed: ride pay 1"); - for block_creator in blocks.iter() { - let mut block = block_creator(); - if let Err(e) = import_block(blockchain, &mut block) { - error!("Error importing block: {}", e); - break; - } - } + let mut block = Block::new_block( + 6, + String::new(), + vec![ride_pay_transaction(10, 5, &ride_acceptance_hash)], // 5 + 10 = 15 + ); + import_block(blockchain, &mut block).expect("block import failed: ride pay 2"); + + let mut block = Block::new_block( + 7, + String::new(), + vec![ride_pay_transaction(10, 6, &ride_acceptance_hash)], // 15 + 10 = 25 + ); + import_block(blockchain, &mut block).expect("block import failed: ride pay 3"); + + let mut block = Block::new_block( + 8, + String::new(), + vec![ride_cancel_transaction(7, &ride_acceptance_hash)], + ); + import_block(blockchain, &mut block).expect("block import failed: ride cancel"); } fn author_blocks(blockchain: &mut Blockchain) { - let ride_request_tx = ride_request_transcation(1, 7); + let ride_request_tx = ride_request_transcation(1, 8); add_transaction_to_pool(&blockchain, ride_request_tx); - match blockchain.author_new_block() { - Ok(mut block) => match import_block(blockchain, &mut block) { - Ok(_) => info!("Successfully imported the new block."), - Err(e) => error!("Failed to import the new block: {}", e), - }, - Err(e) => error!("Failed to author new block: {}", e), + // author_new_block already imports the block internally (Blockchain::author_new_block + // calls self.import_block before returning) — importing it again here re-signs an + // already-committed block against whatever slot the wall clock has since rotated to, + // which always fails. The original swallowed-error pattern hid this dead second import + // AND hid a pre-existing, unrelated timing gap this task doesn't own: this fixture's + // node is fixed to AUTHOR_1's identity, but Aura picks the author by wall-clock slot + // across all 3 authorities (step_duration = 20s), so author_new_block only succeeds + // when AUTHOR_1's slot happens to be current. Poll for it instead of asserting on the + // first tick — bounded to just over one full rotation so it can't hang. + // ponytail: real-clock poll, not a proper test clock. Fine for an integration test; + // revisit if Aura ever grows an injectable clock. + let mut last_err = String::new(); + let mut authored = false; + for _ in 0..300 { + match blockchain.author_new_block() { + Ok(_) => { + authored = true; + break; + } + Err(e) => { + last_err = e; + std::thread::sleep(std::time::Duration::from_millis(200)); + } + } } + assert!(authored, "failed to author new block after polling for a full Aura rotation: {}", last_err); + info!("Successfully imported the new block."); } fn add_transaction_to_pool(blockchain: &Blockchain, ride_request_transcation: Transaction) { - match blockchain.add_transaction_to_pool(&ride_request_transcation) { - Ok(_) => { - info!("Successfully added transaction to transaction_pool"); - } - Err(e) => { - error!("Failed to add transaction to transaction_pool: {}", e); - } - } + // Same hard-failure principle as import_block: a swallowed error here would let + // author_new_block silently draft an empty block and the test would still pass + // having authored nothing. + blockchain + .add_transaction_to_pool(&ride_request_transcation) + .expect("failed to add transaction to transaction_pool"); + info!("Successfully added transaction to transaction_pool"); } fn new_blockchain() -> Blockchain { @@ -161,9 +212,18 @@ fn current_author_keys(blockchain: &Blockchain) -> Option<(&str, &str)> { None } -fn ride_request_block(index: usize, nonce: u64, fare: u64) -> Block { - let ride_request_transcation = ride_request_transcation(fare, nonce); - Block::new_block(index, String::new(), vec![ride_request_transcation]) +fn faucet_to_driver_block(index: usize, nonce: u64, value: u64) -> Block { + let mut transfer_transaction = Transaction::new_transaction( + PASSENGER_ADDRESS_KEY.to_string(), + nonce, + 2077, + FunctionCall::Transfer(Transfer { + to: DRIVER_ADDRESS_KEY.to_string(), + value, + }), + ); + transfer_transaction.sign(PASSENGER_SECRET_KEY); + Block::new_block(index, String::new(), vec![transfer_transaction]) } fn ride_request_transcation(fare: u64, nonce: u64) -> Transaction { @@ -191,15 +251,10 @@ fn ride_request_transcation(fare: u64, nonce: u64) -> Transaction { ride_request_transcation } -fn ride_offer_block(index: usize, nonce: u64, fare: u64) -> Block { - let ride_offer_transaction: Transaction = ride_offer_transaction(fare, nonce); - Block::new_block(index, String::new(), vec![ride_offer_transaction]) -} - -fn ride_offer_transaction(fare: u64, nonce: u64) -> Transaction { +fn ride_offer_transaction(fare: u64, nonce: u64, ride_request_tx_hash: &str) -> Transaction { let ride_offer = RideOffer { fare: fare, - ride_request_transaction_hash: RIDE_REQUEST_TX_HASH.to_string(), + ride_request_transaction_hash: ride_request_tx_hash.to_string(), referrer: None, }; @@ -213,14 +268,9 @@ fn ride_offer_transaction(fare: u64, nonce: u64) -> Transaction { ride_offer_transaction } -fn ride_acceptance_block(index: usize, nonce: u64) -> Block { - let ride_acceptance_transaction = ride_acceptance_transaction(nonce); - Block::new_block(index, String::new(), vec![ride_acceptance_transaction]) -} - -fn ride_acceptance_transaction(nonce: u64) -> Transaction { +fn ride_acceptance_transaction(nonce: u64, ride_offer_tx_hash: &str) -> Transaction { let ride_acceptance = RideAcceptance { - ride_offer_transaction_hash: RIDE_OFFER_TX_HASH.to_string(), + ride_offer_transaction_hash: ride_offer_tx_hash.to_string(), }; let mut ride_acceptance_transaction = Transaction::new_transaction( @@ -233,15 +283,10 @@ fn ride_acceptance_transaction(nonce: u64) -> Transaction { ride_acceptance_transaction } -fn ride_pay_block(index: usize, nonce: u64, fare: u64) -> Block { - let ride_pay_transaction = ride_pay_transaction(fare, nonce); - Block::new_block(index, String::new(), vec![ride_pay_transaction]) -} - -fn ride_pay_transaction(fare: u64, nonce: u64) -> Transaction { +fn ride_pay_transaction(fare: u64, nonce: u64, ride_acceptance_tx_hash: &str) -> Transaction { let ride_pay = RidePay { fare: fare, - ride_acceptance_transaction_hash: RIDE_ACCEPTANCE_TX_HASH.to_string(), + ride_acceptance_transaction_hash: ride_acceptance_tx_hash.to_string(), }; let mut ride_pay_transaction = Transaction::new_transaction( @@ -254,14 +299,9 @@ fn ride_pay_transaction(fare: u64, nonce: u64) -> Transaction { ride_pay_transaction } -fn ride_cancel_block(index: usize, nonce: u64) -> Block { - let ride_cancel_transaction = ride_cancel_transaction(nonce); - Block::new_block(index, String::new(), vec![ride_cancel_transaction]) -} - -fn ride_cancel_transaction(nonce: u64) -> Transaction { +fn ride_cancel_transaction(nonce: u64, ride_acceptance_tx_hash: &str) -> Transaction { let ride_cancel = RideCancel { - ride_acceptance_transaction_hash: RIDE_ACCEPTANCE_TX_HASH.to_string(), + ride_acceptance_transaction_hash: ride_acceptance_tx_hash.to_string(), }; let mut ride_cancel_transaction = Transaction::new_transaction( diff --git a/tests/tx_fee.rs b/tests/tx_fee.rs new file mode 100644 index 0000000..84ebb27 --- /dev/null +++ b/tests/tx_fee.rs @@ -0,0 +1,93 @@ +use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::transactions::chain_init::ChainInit; +use clutch_node::node::transactions::function_call::FunctionCall; +use clutch_node::node::transactions::transaction::Transaction; +use clutch_node::node::transactions::transfer::Transfer; +use serial_test::serial; + +const AUTHOR_PK: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; +const AUTHOR_SK: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; +const FAUCET_PK: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; +// Faucet secret is the committed dev key from clutch-hub-api config/default.toml:18. +const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; +const CHAIN_ID: u64 = 2077; +const TX_FEE: u64 = 1000; + +fn ci() -> ChainInit { + ChainInit { + chain_id: CHAIN_ID, + is_testnet: true, + tx_fee: TX_FEE, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + mint_authority: AUTHOR_PK.to_string(), + faucet_address: FAUCET_PK.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +fn chain(name: &str) -> Blockchain { + Blockchain::new( + name.to_string(), + AUTHOR_PK.to_string(), + AUTHOR_SK.to_string(), + true, + vec![AUTHOR_PK.to_string()], + ci(), + ) +} + +fn signed_transfer(from: &str, sk: &str, nonce: u64, to: &str, value: u64) -> Transaction { + let mut tx = Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Transfer(Transfer { to: to.to_string(), value }), + ); + tx.sign(sk); + tx +} + +#[test] +#[serial] +fn transfer_charges_fee_and_credits_author() { + let mut chain = chain("test-fee-basic"); + let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); + + let tx = signed_transfer(FAUCET_PK, FAUCET_SK, 1, "0x1111111111111111111111111111111111111111", 500); + chain.add_transaction_to_pool(&tx).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before - 500 - TX_FEE, + "sender pays value + fee" + ); + assert_eq!( + chain.get_account_balance(&"0x1111111111111111111111111111111111111111".to_string()), + 500 + ); + assert_eq!( + chain.get_account_balance(&AUTHOR_PK.to_string()), + TX_FEE, + "author earns the fee (no block reward anymore)" + ); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn exact_balance_without_fee_is_rejected() { + use clutch_node::node::signature_keys::SignatureKeys; + let mut chain = chain("test-fee-insufficient"); + // Fund a fresh account with exactly `value` (no headroom for the fee). + let poor = SignatureKeys::generate_new_keypair(); + let fund = signed_transfer(FAUCET_PK, FAUCET_SK, 1, &poor.address_key, 500); + chain.add_transaction_to_pool(&fund).unwrap(); + chain.author_new_block().unwrap(); + + let overspend = signed_transfer(&poor.address_key, &poor.secret_key, 1, FAUCET_PK, 500); + let err = chain.add_transaction_to_pool(&overspend).unwrap_err(); + assert!(err.to_lowercase().contains("fee") || err.to_lowercase().contains("insufficient"), "got: {}", err); + chain.shutdown_blockchain(); +} From a7d862af11624636f7c765a1ec495b954294707c Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 09:58:11 +0400 Subject: [PATCH 12/24] fix: eliminate two deferred-batch write collisions that broke supply The block-level author fee credit was appended after the tx loop, so an author's own transaction debit was overwritten and the value re-minted. RidePay took a standalone fee debit while also crediting driver and referrers, so a payer who was also the driver or a referrer lost that credit entirely. Both now emit one storage write per account with per-reason effects preserved. Also rejects self-transfer, saturates the fee subtraction, and covers the untested RideCancel driver branch. Co-Authored-By: Claude Fable 5 --- src/node/account_state.rs | 21 +- src/node/blocks/block.rs | 77 +++++--- src/node/transactions/ride_pay.rs | 94 ++++++--- src/node/transactions/transaction.rs | 7 +- src/node/transactions/transfer.rs | 12 ++ tests/balance_effects.rs | 3 + tests/ride_sharing.rs | 15 +- tests/tx_fee.rs | 277 +++++++++++++++++++++++++-- 8 files changed, 432 insertions(+), 74 deletions(-) diff --git a/src/node/account_state.rs b/src/node/account_state.rs index ccbb407..558196c 100644 --- a/src/node/account_state.rs +++ b/src/node/account_state.rs @@ -72,6 +72,22 @@ impl AccountState { format!("account_state_{}", public_key).into_bytes() } + /// The balance key a write for `public_key` lands on. Callers staging a deferred batch + /// use it to spot an already-pending write for the same account. + pub fn account_state_key(public_key: &str) -> Vec { + Self::construct_account_state_key(&canonical_account_address(public_key)) + } + + /// Fold `delta` into an account_state value already staged in the block's deferred + /// write batch. Pushing a second write for the same key would drop the staged one + /// (last write wins), so callers merge into it instead. `None` on corrupt bytes or + /// over/underflow — the caller decides what to do rather than silently clamping. + pub fn merge_pending_balance(serialized: &[u8], delta: i64) -> Option> { + let mut state: AccountState = serde_json::from_slice(serialized).ok()?; + state.balance = apply_delta(state.balance, delta)?; + serde_json::to_vec(&state).ok() + } + pub fn update_account_state_key( public_key: &String, balance_change: i64, @@ -138,7 +154,10 @@ impl AccountState { return vec![Self::apply_balance_change(public_key, main_delta, kind, counterparty, db)]; } let canonical = canonical_account_address(public_key); - let combined = main_delta - fee as i64; + // Saturating: an unchecked `-` panics in debug and wraps in release, and this is a + // Result-based codebase on a money path. Sufficiency is enforced upstream by + // validate_transaction, so saturation here can only follow an upstream bug. + let combined = main_delta.saturating_sub(fee as i64); let (key, value) = Self::update_account_state_key(public_key, combined, db); vec![ StateUpdate { diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index 31dacb8..3bdef37 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -5,7 +5,10 @@ use tracing::{error, info, warn}; use crate::node::account_state::AccountState; use crate::node::database::Database; use crate::node::time_utils::get_current_timespan; -use crate::node::balance_effect::{persist_block_effects, persist_tx_effects, BalanceEffectKind}; +use crate::node::balance_effect::{ + persist_block_effects, persist_tx_effects, BalanceEffect, BalanceEffectKind, +}; +use crate::node::transactions::address::canonical_account_address; use crate::node::transactions::chain_init::ChainInit; use crate::node::transactions::function_call::FunctionCall; use crate::node::transactions::transaction::Transaction; @@ -383,40 +386,66 @@ impl Block { // Fees replace block rewards: one aggregate author credit per block (single // write — per-tx credits would collide in the deferred batch). Fee revenue is // backed CLT changing hands, so the reserve invariant is untouched. - // ponytail: residual ceiling (pre-existing class, same as the old block reward): - // if any tx in a fee-paying block ALSO credits the author's balance (Transfer to - // author, Mint to author, author-as-driver RidePay), that credit collides with - // this write and is lost. Operational rule: validator accounts are not app - // accounts. Lift with incremental intra-block state. + // + // The batch commits only at the end of this function, so every write above was + // computed from pre-block state and a second write to a key discards the first. + // If the transaction loop already staged a write to the author's balance, this + // credit is folded INTO that staged value instead of being appended after it. + // That covers both directions: a transaction FROM the author (whose debit used to + // be overwritten and the value re-minted) and one crediting the author (a Transfer + // to them, an author-as-driver RidePay, whose credit used to be destroyed). let total_fees: u64 = block .transactions .iter() .map(|tx| tx.effective_fee(&block.author, ¶ms)) .sum(); if block.index > 0 && total_fees > 0 { - let fee_update = AccountState::apply_balance_change( - &block.author, - total_fees as i64, - BalanceEffectKind::TxFeeEarned, - None, - &db, - ); - if let Some((key, value)) = fee_update.storage { - cf_storage.push("state".to_string()); - keys_storage.push(key); - values_storage.push(value); - } - if let Some(effect) = fee_update.effect { - for (key, value) in persist_block_effects( - block.index as u64, - block.timestamp, - std::slice::from_ref(&effect), - ) { + let author_key = AccountState::account_state_key(&block.author); + // Fold into the LAST staged write for the key — that is the one that survives + // the batch. + let staged = keys_storage + .iter() + .zip(cf_storage.iter()) + .rposition(|(key, cf)| cf == "state" && *key == author_key); + match staged { + Some(i) => { + // Appending instead of merging would silently re-introduce the bug, so + // a failed merge (corrupt staged value / overflow) aborts the import. + values_storage[i] = + AccountState::merge_pending_balance(&values_storage[i], total_fees as i64) + .ok_or_else(|| { + format!( + "failed to merge block fee credit of {} into the staged balance for author {}", + total_fees, block.author + ) + })?; + } + None => { + let (key, value) = AccountState::update_account_state_key( + &block.author, + total_fees as i64, + &db, + ); cf_storage.push("state".to_string()); keys_storage.push(key); values_storage.push(value); } } + let effect = BalanceEffect { + address: canonical_account_address(&block.author), + delta: total_fees as i64, + kind: BalanceEffectKind::TxFeeEarned, + counterparty: None, + }; + for (key, value) in persist_block_effects( + block.index as u64, + block.timestamp, + std::slice::from_ref(&effect), + ) { + cf_storage.push("state".to_string()); + keys_storage.push(key); + values_storage.push(value); + } } // Prepare operations for database write diff --git a/src/node/transactions/ride_pay.rs b/src/node/transactions/ride_pay.rs index 6886357..5c8df07 100644 --- a/src/node/transactions/ride_pay.rs +++ b/src/node/transactions/ride_pay.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use tracing::error; use crate::node::account_state::AccountState; -use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; +use crate::node::balance_effect::{BalanceEffect, BalanceEffectKind, StateUpdate}; use crate::node::database::Database; use super::{ @@ -118,6 +118,7 @@ impl RidePay { request_fee_bps: u16, offer_fee_bps: u16, passenger: &String, + fee: u64, ) -> Vec { let ride_acceptance_tx_hash = &self.ride_acceptance_transaction_hash; @@ -183,37 +184,72 @@ impl RidePay { let passenger_cp = Some(passenger.clone()); - if request_fee > 0 { - if let Some(ref req_ref) = request_referrer { - updates.push(AccountState::apply_balance_change( - &canonical_account_address(req_ref), - request_fee as i64, - BalanceEffectKind::ReferrerRequestFee, - passenger_cp.clone(), - db, - )); - } + // Every balance movement this transaction makes, as (canonical address, delta, + // audit reason, counterparty). None of these four accounts is guaranteed distinct: + // `referrer` is a free-form Option with no self-referral check, and + // RideOffer::verify_state never rejects an offer from the passenger, so the payer + // can legitimately be the driver and/or a referrer. Two writes to one balance key + // collide in the block's deferred batch (last write wins), so the legs are netted + // per address below into exactly one write each. + let mut legs: Vec<(String, i64, BalanceEffectKind, Option)> = Vec::new(); + if let (true, Some(req_ref)) = (request_fee > 0, &request_referrer) { + legs.push(( + canonical_account_address(req_ref), + request_fee as i64, + BalanceEffectKind::ReferrerRequestFee, + passenger_cp.clone(), + )); } - - if offer_fee > 0 { - if let Some(ref off_ref) = offer_referrer { - updates.push(AccountState::apply_balance_change( - &canonical_account_address(off_ref), - offer_fee as i64, - BalanceEffectKind::ReferrerOfferFee, - passenger_cp.clone(), - db, - )); - } + if let (true, Some(off_ref)) = (offer_fee > 0, &offer_referrer) { + legs.push(( + canonical_account_address(off_ref), + offer_fee as i64, + BalanceEffectKind::ReferrerOfferFee, + passenger_cp.clone(), + )); + } + if driver_amount > 0 { + legs.push(( + canonical_account_address(&driver), + driver_amount as i64, + BalanceEffectKind::RidePayDriverCredit, + passenger_cp, + )); + } + if fee > 0 { + legs.push(( + canonical_account_address(passenger), + -(fee as i64), + BalanceEffectKind::TxFeePaid, + None, + )); } - updates.push(AccountState::apply_balance_change( - &driver, - driver_amount as i64, - BalanceEffectKind::RidePayDriverCredit, - passenger_cp, - db, - )); + // One storage write per distinct address carrying its net delta, attached to that + // address's first leg; every later leg on the same address is effect-only, so the + // audit trail keeps one record per reason (see + // AccountState::apply_balance_change_with_fee for the same shape). Leg order is + // fixed by the pushes above — deterministic, unlike HashMap iteration, which must + // never reach consensus bytes. + // ponytail: O(n^2) over at most four legs; a map would cost more than it saves. + for (i, (address, delta, kind, counterparty)) in legs.iter().enumerate() { + let first = !legs[..i].iter().any(|(a, ..)| a == address); + let net: i64 = legs + .iter() + .filter(|(a, ..)| a == address) + .map(|(_, d, ..)| d) + .sum(); + updates.push(StateUpdate { + storage: (first && net != 0) + .then(|| AccountState::update_account_state_key(address, net, db)), + effect: Some(BalanceEffect { + address: address.clone(), + delta: *delta, + kind: kind.clone(), + counterparty: counterparty.clone(), + }), + }); + } updates } diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 2807fdc..5e54ce8 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -310,6 +310,7 @@ impl Transaction { params.ride_request_referrer_fee_bps, params.ride_offer_referrer_fee_bps, &self.from, + fee, ), FunctionCall::RideCancel(ride_cancel) => { ride_cancel.state_transaction(&self.from, &self.hash, db, fee) @@ -322,13 +323,15 @@ impl Transaction { // Standalone fee debit ONLY for types that never write the sender's balance // in-type (see routing table). Types that do (Transfer, Burn, RideAcceptance, - // RideCancel) merge the fee themselves — two writes to one account key in a tx - // collide in the deferred batch (last write wins). + // RideCancel, RidePay) merge the fee themselves — two writes to one account key in + // a tx collide in the deferred batch (last write wins). RidePay belongs here + // because the payer can also be the driver or a referrer it credits. let fee_handled_in_type = matches!( &self.data, FunctionCall::Transfer(_) | FunctionCall::RideAcceptance(_) | FunctionCall::RideCancel(_) + | FunctionCall::RidePay(_) // Task 7 adds: | FunctionCall::Burn(_) ); if fee > 0 && !fee_handled_in_type { diff --git a/src/node/transactions/transfer.rs b/src/node/transactions/transfer.rs index 1e8fa9c..a625339 100644 --- a/src/node/transactions/transfer.rs +++ b/src/node/transactions/transfer.rs @@ -1,6 +1,7 @@ use crate::node::account_state::AccountState; use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; use crate::node::database::Database; +use crate::node::transactions::address::canonical_account_address; use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; use serde::{Deserialize, Serialize}; @@ -13,6 +14,17 @@ pub struct Transfer { impl Transfer { pub fn verify_state(&self, from: &String, db: &Database) -> Result<(), String> { + // A self-transfer moves nothing, but state_transaction would write the sender's + // balance key twice — the merged debit first, then the plain `+value` credit, which + // wins in the block's deferred batch. The fee vanishes while the block still credits + // the author, and the `+value` is minted outright. No legitimate meaning: reject. + if canonical_account_address(&self.to) == canonical_account_address(from) { + return Err(format!( + "Error: Transfer 'to' must differ from 'from' (self-transfer): {}", + canonical_account_address(from) + )); + } + let from_account_state = AccountState::get_current_state(from, db); if from_account_state.balance < self.value { diff --git a/tests/balance_effects.rs b/tests/balance_effects.rs index a4a2a99..1596f21 100644 --- a/tests/balance_effects.rs +++ b/tests/balance_effects.rs @@ -146,6 +146,9 @@ fn ride_pay_emits_referrer_request_fee_effect() { REFERRER_FEE_BPS, REFERRER_FEE_BPS, &PASSENGER.to_string(), + // Fee 0, same reason as the RideAcceptance call above: this test drives per-type + // state_transaction directly and asserts only the referrer effect. + 0, ); let mut effects = Vec::new(); for update in pay_updates { diff --git a/tests/ride_sharing.rs b/tests/ride_sharing.rs index b0c3052..4804f9d 100644 --- a/tests/ride_sharing.rs +++ b/tests/ride_sharing.rs @@ -117,6 +117,7 @@ fn import_blocks(blockchain: &mut Blockchain) { fn author_blocks(blockchain: &mut Blockchain) { let ride_request_tx = ride_request_transcation(1, 8); + let pooled_hash = ride_request_tx.hash.clone(); add_transaction_to_pool(&blockchain, ride_request_tx); // author_new_block already imports the block internally (Blockchain::author_new_block @@ -131,11 +132,11 @@ fn author_blocks(blockchain: &mut Blockchain) { // ponytail: real-clock poll, not a proper test clock. Fine for an integration test; // revisit if Aura ever grows an injectable clock. let mut last_err = String::new(); - let mut authored = false; + let mut authored = None; for _ in 0..300 { match blockchain.author_new_block() { - Ok(_) => { - authored = true; + Ok(block) => { + authored = Some(block); break; } Err(e) => { @@ -144,7 +145,13 @@ fn author_blocks(blockchain: &mut Blockchain) { } } } - assert!(authored, "failed to author new block after polling for a full Aura rotation: {}", last_err); + // Keep the block: discarding it let the loop pass while authoring an empty block, so a + // pool regression that never drained the pending tx would still report green. + let block = authored.unwrap_or_else(|| { + panic!("failed to author new block after polling for a full Aura rotation: {}", last_err) + }); + assert_eq!(block.transactions.len(), 1, "authored block must carry the pooled transaction"); + assert_eq!(block.transactions[0].hash, pooled_hash); info!("Successfully imported the new block."); } diff --git a/tests/tx_fee.rs b/tests/tx_fee.rs index 84ebb27..9575c86 100644 --- a/tests/tx_fee.rs +++ b/tests/tx_fee.rs @@ -1,6 +1,14 @@ +use clutch_node::node::balance_effect::BalanceEffectKind; use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::coordinate::Coordinates; +use clutch_node::node::signature_keys::SignatureKeys; use clutch_node::node::transactions::chain_init::ChainInit; use clutch_node::node::transactions::function_call::FunctionCall; +use clutch_node::node::transactions::ride_acceptance::RideAcceptance; +use clutch_node::node::transactions::ride_cancel::RideCancel; +use clutch_node::node::transactions::ride_offer::RideOffer; +use clutch_node::node::transactions::ride_pay::RidePay; +use clutch_node::node::transactions::ride_request::RideRequest; use clutch_node::node::transactions::transaction::Transaction; use clutch_node::node::transactions::transfer::Transfer; use serial_test::serial; @@ -13,7 +21,7 @@ const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f4 const CHAIN_ID: u64 = 2077; const TX_FEE: u64 = 1000; -fn ci() -> ChainInit { +fn ci_with_faucet(faucet: &str) -> ChainInit { ChainInit { chain_id: CHAIN_ID, is_testnet: true, @@ -21,33 +29,135 @@ fn ci() -> ChainInit { ride_request_referrer_fee_bps: 200, ride_offer_referrer_fee_bps: 200, mint_authority: AUTHOR_PK.to_string(), - faucet_address: FAUCET_PK.to_string(), + faucet_address: faucet.to_string(), faucet_allocation: 1_000_000_000_000_000, } } -fn chain(name: &str) -> Blockchain { +fn ci() -> ChainInit { + ci_with_faucet(FAUCET_PK) +} + +fn chain_with(name: &str, params: ChainInit) -> Blockchain { + // A panicking assertion never reaches shutdown_blockchain(), leaving the RocksDB dir + // behind — the next run would then start with the previous run's nonces and fail for + // the wrong reason. Start from a clean slate. + let _ = std::fs::remove_dir_all(format!("{}.db", name)); + // Single authority: every Aura slot maps to AUTHOR_PK, so author_new_block always + // succeeds and needs no slot polling. Blockchain::new( name.to_string(), AUTHOR_PK.to_string(), AUTHOR_SK.to_string(), true, vec![AUTHOR_PK.to_string()], - ci(), + params, ) } -fn signed_transfer(from: &str, sk: &str, nonce: u64, to: &str, value: u64) -> Transaction { - let mut tx = Transaction::new_transaction( - from.to_string(), - nonce, - CHAIN_ID, - FunctionCall::Transfer(Transfer { to: to.to_string(), value }), +fn chain(name: &str) -> Blockchain { + chain_with(name, ci()) +} + +/// Pool `txs` and author exactly one block out of them, asserting the block really carried +/// them all — a silently-dropped tx would otherwise make the balance assertions meaningless. +fn mine(chain: &Blockchain, txs: &[Transaction]) { + for tx in txs { + chain + .add_transaction_to_pool(tx) + .unwrap_or_else(|e| panic!("pool rejected {}: {}", tx.hash, e)); + } + let block = chain.author_new_block().expect("author_new_block"); + assert_eq!( + block.transactions.len(), + txs.len(), + "authored block must carry every pooled transaction" ); +} + +fn balances(chain: &Blockchain, addrs: &[&str]) -> Vec { + addrs + .iter() + .map(|a| chain.get_account_balance(&a.to_string())) + .collect() +} + +fn signed(from: &str, sk: &str, nonce: u64, call: FunctionCall) -> Transaction { + let mut tx = Transaction::new_transaction(from.to_string(), nonce, CHAIN_ID, call); tx.sign(sk); tx } +fn signed_transfer(from: &str, sk: &str, nonce: u64, to: &str, value: u64) -> Transaction { + signed( + from, + sk, + nonce, + FunctionCall::Transfer(Transfer { to: to.to_string(), value }), + ) +} + +fn signed_ride_request(from: &str, sk: &str, nonce: u64, fare: u64) -> Transaction { + signed( + from, + sk, + nonce, + FunctionCall::RideRequest(RideRequest { + fare, + pickup_location: Coordinates { latitude: 35.55, longitude: 51.23 }, + dropoff_location: Coordinates { latitude: 26.64, longitude: 55.85 }, + referrer: None, + }), + ) +} + +fn signed_ride_offer(from: &str, sk: &str, nonce: u64, request_hash: &str, fare: u64) -> Transaction { + signed( + from, + sk, + nonce, + FunctionCall::RideOffer(RideOffer { + fare, + ride_request_transaction_hash: request_hash.to_string(), + referrer: None, + }), + ) +} + +fn signed_ride_acceptance(from: &str, sk: &str, nonce: u64, offer_hash: &str) -> Transaction { + signed( + from, + sk, + nonce, + FunctionCall::RideAcceptance(RideAcceptance { + ride_offer_transaction_hash: offer_hash.to_string(), + }), + ) +} + +fn signed_ride_pay(from: &str, sk: &str, nonce: u64, acceptance_hash: &str, fare: u64) -> Transaction { + signed( + from, + sk, + nonce, + FunctionCall::RidePay(RidePay { + fare, + ride_acceptance_transaction_hash: acceptance_hash.to_string(), + }), + ) +} + +fn signed_ride_cancel(from: &str, sk: &str, nonce: u64, acceptance_hash: &str) -> Transaction { + signed( + from, + sk, + nonce, + FunctionCall::RideCancel(RideCancel { + ride_acceptance_transaction_hash: acceptance_hash.to_string(), + }), + ) +} + #[test] #[serial] fn transfer_charges_fee_and_credits_author() { @@ -78,16 +188,155 @@ fn transfer_charges_fee_and_credits_author() { #[test] #[serial] fn exact_balance_without_fee_is_rejected() { - use clutch_node::node::signature_keys::SignatureKeys; let mut chain = chain("test-fee-insufficient"); // Fund a fresh account with exactly `value` (no headroom for the fee). let poor = SignatureKeys::generate_new_keypair(); let fund = signed_transfer(FAUCET_PK, FAUCET_SK, 1, &poor.address_key, 500); - chain.add_transaction_to_pool(&fund).unwrap(); - chain.author_new_block().unwrap(); + mine(&chain, &[fund]); let overspend = signed_transfer(&poor.address_key, &poor.secret_key, 1, FAUCET_PK, 500); let err = chain.add_transaction_to_pool(&overspend).unwrap_err(); - assert!(err.to_lowercase().contains("fee") || err.to_lowercase().contains("insufficient"), "got: {}", err); + // Pin the amount+fee check specifically: a bare "insufficient balance" substring would + // also match the unrelated value-only check this rule replaced. + assert!( + err.contains("insufficient balance for amount + fee") + && err.contains(&format!("Required: {}", 500 + TX_FEE)) + && err.contains("available: 500"), + "got: {}", + err + ); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn author_own_transaction_survives_the_block_fee_credit() { + // Regression (money): the block-level author fee credit is computed from pre-block + // state. Appending it after the per-tx loop made it the LAST write to the author's + // balance key in the deferred batch, so when the author also sent a transaction in + // that block its own debit was overwritten — the recipient kept the value and the + // author was made whole. Net new unbacked CLT. + const SEED: u64 = 1_000_000; + const V: u64 = 5_000; // author -> dave + const W: u64 = 700; // bob -> erin + const DAVE: &str = "0x2222222222222222222222222222222222222222"; + const ERIN: &str = "0x3333333333333333333333333333333333333333"; + + // Author holds the genesis allocation so it can spend without first being credited. + let mut chain = chain_with("test-fee-author-selfsend", ci_with_faucet(AUTHOR_PK)); + let bob = SignatureKeys::generate_new_keypair(); + + // Block 1: only the author sends, so effective_fee is 0 and no block credit is emitted. + mine(&chain, &[signed_transfer(AUTHOR_PK, AUTHOR_SK, 1, &bob.address_key, SEED)]); + + let accounts = [AUTHOR_PK, bob.address_key.as_str(), DAVE, ERIN]; + let before = balances(&chain, &accounts); + + // Block 2: the author's own (fee-exempt) tx alongside a fee-paying tx from bob. + mine( + &chain, + &[ + signed_transfer(AUTHOR_PK, AUTHOR_SK, 2, DAVE, V), + signed_transfer(&bob.address_key, &bob.secret_key, 1, ERIN, W), + ], + ); + let after = balances(&chain, &accounts); + + assert_eq!( + after[0], + before[0] - V + TX_FEE, + "author must keep its own debit AND earn bob's fee" + ); + assert_eq!(after[1], before[1] - W - TX_FEE, "bob pays value + fee"); + assert_eq!(after[2], before[2] + V, "dave receives the author's transfer"); + assert_eq!(after[3], before[3] + W, "erin receives bob's transfer"); + assert_eq!( + after.iter().sum::(), + before.iter().sum::(), + "supply across the involved accounts must be conserved" + ); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn ride_pay_when_payer_is_also_the_driver_keeps_the_credit() { + // Regression (money): RidePay took the central standalone fee debit on `from` while + // also crediting the driver. Nothing stops a passenger from offering on their own + // request, so both writes hit one balance key and the deferred batch kept only the + // fee debit — the fare, already escrowed at RideAcceptance, was credited to nobody. + const FARE: u64 = 40_000; + let mut chain = chain("test-fee-ridepay-self-driver"); + + let req = signed_ride_request(FAUCET_PK, FAUCET_SK, 1, FARE); + mine(&chain, &[req.clone()]); + // Passenger offers on their own request: driver == passenger == payer. + let offer = signed_ride_offer(FAUCET_PK, FAUCET_SK, 2, &req.hash, FARE); + mine(&chain, &[offer.clone()]); + let acceptance = signed_ride_acceptance(FAUCET_PK, FAUCET_SK, 3, &offer.hash); + mine(&chain, &[acceptance.clone()]); + + let before = balances(&chain, &[FAUCET_PK, AUTHOR_PK]); + + let pay = signed_ride_pay(FAUCET_PK, FAUCET_SK, 4, &acceptance.hash, FARE); + mine(&chain, &[pay.clone()]); + let after = balances(&chain, &[FAUCET_PK, AUTHOR_PK]); + + assert_eq!( + after[0], + before[0] + FARE - TX_FEE, + "payer is also the driver: the fare credit must survive the fee debit" + ); + assert_eq!(after[1], before[1] + TX_FEE, "author earns the fee"); + assert_eq!( + after.iter().sum::(), + before.iter().sum::() + FARE, + "exactly the escrowed fare re-enters circulation, nothing more or less" + ); + + // One storage write, but the audit trail still records each reason separately. + let kinds: Vec = chain + .get_tx_balance_effects(&pay.hash) + .into_iter() + .map(|e| e.effect.kind) + .collect(); + assert!(kinds.contains(&BalanceEffectKind::RidePayDriverCredit), "{:?}", kinds); + assert!(kinds.contains(&BalanceEffectKind::TxFeePaid), "{:?}", kinds); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn ride_cancel_by_driver_refunds_passenger_and_debits_driver() { + // The driver branch of RideCancel takes a standalone fee debit while the passenger + // gets the refund; mis-branching here is a money bug, so pin both legs. + const FARE: u64 = 20_000; + let mut chain = chain("test-fee-cancel-driver"); + let driver = SignatureKeys::generate_new_keypair(); + + mine(&chain, &[signed_transfer(FAUCET_PK, FAUCET_SK, 1, &driver.address_key, 50_000)]); + let req = signed_ride_request(FAUCET_PK, FAUCET_SK, 2, FARE); + mine(&chain, &[req.clone()]); + let offer = signed_ride_offer(&driver.address_key, &driver.secret_key, 1, &req.hash, FARE); + mine(&chain, &[offer.clone()]); + let acceptance = signed_ride_acceptance(FAUCET_PK, FAUCET_SK, 3, &offer.hash); + mine(&chain, &[acceptance.clone()]); + + let accounts = [FAUCET_PK, driver.address_key.as_str(), AUTHOR_PK]; + let before = balances(&chain, &accounts); + + mine( + &chain, + &[signed_ride_cancel(&driver.address_key, &driver.secret_key, 2, &acceptance.hash)], + ); + let after = balances(&chain, &accounts); + + assert_eq!( + after[0], + before[0] + FARE, + "driver-cancel refunds the whole unpaid escrow to the passenger" + ); + assert_eq!(after[1], before[1] - TX_FEE, "the cancelling driver pays the fee"); + assert_eq!(after[2], before[2] + TX_FEE, "author earns the fee"); chain.shutdown_blockchain(); } From 8b3867dab0513cab3af469a0b573eefb413129cb Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 10:07:00 +0400 Subject: [PATCH 13/24] feat!: Mint transaction with authority check and exactly-once credit_ref RLP tag 6. Only chain_params.mint_authority may mint; credit_ref (64-hex, hash of the treasury intent id) is a write-once state marker, so a replayed or duplicated mint intent can never credit twice. total_supply updates once per block. Co-Authored-By: Claude Fable 5 --- src/node/blocks/block.rs | 29 ++++- src/node/rlp_encoding.rs | 10 ++ src/node/transactions/function_call.rs | 3 + src/node/transactions/mint.rs | 101 ++++++++++++++++ src/node/transactions/mod.rs | 1 + src/node/transactions/transaction.rs | 6 +- tests/mint_burn.rs | 160 +++++++++++++++++++++++++ 7 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 src/node/transactions/mint.rs create mode 100644 tests/mint_burn.rs diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index 3bdef37..c88f1a7 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -9,7 +9,7 @@ use crate::node::balance_effect::{ persist_block_effects, persist_tx_effects, BalanceEffect, BalanceEffectKind, }; use crate::node::transactions::address::canonical_account_address; -use crate::node::transactions::chain_init::ChainInit; +use crate::node::transactions::chain_init::{self, ChainInit}; use crate::node::transactions::function_call::FunctionCall; use crate::node::transactions::transaction::Transaction; use crate::node::transactions::transaction_pool::TransactionPool; @@ -383,6 +383,33 @@ impl Block { tx_keys_to_delete.push(tx_key); } + // Supply changes once per block: per-tx read-modify-writes of the single + // total_supply key would collide in the deferred batch (last write wins, + // e.g. two Burns in one block). Sum first, then one read + one write. + let mut supply_delta: i128 = 0; + for tx in &block.transactions { + match &tx.data { + FunctionCall::Mint(m) => supply_delta += m.amount as i128, + _ => {} + } + } + if block.index > 0 && supply_delta != 0 { + let current = ChainInit::get_total_supply(db)? as i128; + let next = current + supply_delta; + // Cap at i64::MAX, not u64::MAX: supply <= i64::MAX implies every balance + // <= i64::MAX, keeping all deltas representable in i64 (Transfer casts + // `value as i64` — a balance above i64::MAX would wrap negative on transfer). + if next < 0 || next > i64::MAX as i128 { + return Err(format!( + "total_supply out of range: {} + {} = {}", + current, supply_delta, next + )); + } + cf_storage.push("state".to_string()); + keys_storage.push(chain_init::TOTAL_SUPPLY_KEY.to_vec()); + values_storage.push(serde_json::to_vec(&(next as u64)).expect("serialize supply")); + } + // Fees replace block rewards: one aggregate author credit per block (single // write — per-tx credits would collide in the deferred batch). Fee revenue is // backed CLT changing hands, so the reserve invariant is untouched. diff --git a/src/node/rlp_encoding.rs b/src/node/rlp_encoding.rs index 37b3b58..dbea0b5 100644 --- a/src/node/rlp_encoding.rs +++ b/src/node/rlp_encoding.rs @@ -13,6 +13,7 @@ use super::p2p_server::get_block_header::GetBlockHeaders; use super::p2p_server::handshake::Handshake; use super::transactions::chain_init::ChainInit; use super::transactions::function_call::FunctionCall; +use super::transactions::mint::Mint; use super::transactions::ride_acceptance::RideAcceptance; use super::transactions::ride_cancel::RideCancel; use super::transactions::ride_offer::RideOffer; @@ -54,6 +55,11 @@ impl Encodable for FunctionCall { stream.append(&5u8); // Tag for RideCancel stream.append(args); } + FunctionCall::Mint(args) => { + stream.begin_list(2); + stream.append(&6u8); // Tag for Mint + stream.append(args); + } FunctionCall::RideRequestCancel(args) => { stream.begin_list(2); stream.append(&8u8); // Tag for RideRequestCancel @@ -101,6 +107,10 @@ impl Decodable for FunctionCall { let args: RideCancel = rlp.val_at(1)?; Ok(FunctionCall::RideCancel(args)) } + 6 => { + let args: Mint = rlp.val_at(1)?; + Ok(FunctionCall::Mint(args)) + } 8 => { let args: RideRequestCancel = rlp.val_at(1)?; Ok(FunctionCall::RideRequestCancel(args)) diff --git a/src/node/transactions/function_call.rs b/src/node/transactions/function_call.rs index a9b92ad..b092c20 100644 --- a/src/node/transactions/function_call.rs +++ b/src/node/transactions/function_call.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; use super::chain_init::ChainInit; +use super::mint::Mint; use super::{ ride_acceptance::RideAcceptance, ride_cancel::RideCancel, ride_offer::RideOffer, ride_pay::RidePay, ride_request::RideRequest, ride_request_cancel::RideRequestCancel, @@ -17,6 +18,7 @@ pub enum FunctionCall { RideAcceptance(RideAcceptance), RidePay(RidePay), RideCancel(RideCancel), + Mint(Mint), RideRequestCancel(RideRequestCancel), ChainInit(ChainInit), } @@ -30,6 +32,7 @@ impl fmt::Display for FunctionCall { FunctionCall::RideAcceptance(args) => write!(f, "RideAcceptance: {:?}", args), FunctionCall::RidePay(args) => write!(f, "RidePay: {:?}", args), FunctionCall::RideCancel(args) => write!(f, "RideCancel: {:?}", args), + FunctionCall::Mint(args) => write!(f, "Mint: {:?}", args), FunctionCall::RideRequestCancel(args) => write!(f, "RideRequestCancel: {:?}", args), FunctionCall::ChainInit(args) => write!(f, "ChainInit: {:?}", args), } diff --git a/src/node/transactions/mint.rs b/src/node/transactions/mint.rs new file mode 100644 index 0000000..5570b14 --- /dev/null +++ b/src/node/transactions/mint.rs @@ -0,0 +1,101 @@ +use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; +use serde::{Deserialize, Serialize}; + +use crate::node::account_state::AccountState; +use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; +use crate::node::database::Database; + +use super::address::canonical_account_address; +use super::chain_init::ChainInit; + +/// Exactly-once ref marker: `processed_ref_{64-hex}` in the state CF, value = tx hash. +/// Shared by Mint (credit_ref) and Burn (redemption_ref) — refs are keccak256 hashes of +/// treasury intent ids, so one namespace cannot collide across the two uses. +pub fn processed_ref_key(reference: &str) -> Vec { + format!("processed_ref_{}", reference).into_bytes() +} + +pub fn ref_is_valid(reference: &str) -> bool { + reference.len() == 64 && reference.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) +} + +pub fn ref_already_processed(db: &Database, reference: &str) -> Result { + match db.get("state", &processed_ref_key(reference)) { + Ok(Some(_)) => Ok(true), + Ok(None) => Ok(false), + Err(e) => Err(format!("failed to read processed ref: {}", e)), + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Mint { + pub to: String, + pub amount: u64, + pub credit_ref: String, +} + +impl Mint { + pub fn verify_state(&self, from: &String, db: &Database) -> Result<(), String> { + let params = ChainInit::get(db)?; + if canonical_account_address(from) != canonical_account_address(¶ms.mint_authority) { + return Err(format!( + "Mint rejected: '{}' is not the mint authority", + from + )); + } + if self.amount == 0 { + return Err("Mint rejected: amount must be positive".to_string()); + } + if self.amount > i64::MAX as u64 { + return Err("Mint rejected: amount exceeds i64::MAX (balance deltas are i64)".to_string()); + } + if !ref_is_valid(&self.credit_ref) { + return Err("Mint rejected: credit_ref must be 64 lowercase hex chars".to_string()); + } + if ref_already_processed(db, &self.credit_ref)? { + return Err(format!( + "Mint rejected: credit_ref '{}' already processed (exactly-once)", + self.credit_ref + )); + } + Ok(()) + } + + pub fn state_transaction(&self, tx_hash: &String, db: &Database) -> Vec { + vec![ + AccountState::apply_balance_change( + &self.to, + self.amount as i64, + BalanceEffectKind::Mint, + None, + db, + ), + StateUpdate::storage_only( + processed_ref_key(&self.credit_ref), + tx_hash.clone().into_bytes(), + ), + ] + } +} + +impl Encodable for Mint { + fn rlp_append(&self, stream: &mut RlpStream) { + stream.begin_list(3); + stream.append(&self.to); + stream.append(&self.amount); + stream.append(&self.credit_ref); + } +} + +impl Decodable for Mint { + fn decode(rlp: &Rlp) -> Result { + if !rlp.is_list() || rlp.item_count()? != 3 { + return Err(DecoderError::RlpIncorrectListLen); + } + Ok(Mint { + to: rlp.val_at(0)?, + amount: rlp.val_at(1)?, + credit_ref: rlp.val_at(2)?, + }) + } +} diff --git a/src/node/transactions/mod.rs b/src/node/transactions/mod.rs index 1c55e29..b72ba2d 100644 --- a/src/node/transactions/mod.rs +++ b/src/node/transactions/mod.rs @@ -1,6 +1,7 @@ pub mod address; pub mod chain_init; pub mod function_call; +pub mod mint; pub mod passenger_concurrent; pub mod ride_acceptance; pub mod ride_cancel; diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 5e54ce8..94e5c2e 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -201,8 +201,7 @@ impl Transaction { /// Mint is exempt: the treasury authority mints TO users and may itself hold zero /// balance. ChainInit is genesis-only. Everything else pays the flat fee. fn fee_exempt(&self) -> bool { - matches!(&self.data, FunctionCall::ChainInit(_)) - // Task 6 extends this to: FunctionCall::Mint(_) | FunctionCall::ChainInit(_) + matches!(&self.data, FunctionCall::Mint(_) | FunctionCall::ChainInit(_)) } /// CLT the sender's balance is directly debited by this tx (excluding the fee). @@ -266,6 +265,7 @@ impl Transaction { } FunctionCall::RidePay(ride_pay) => ride_pay.verify_state(&self.from, db), FunctionCall::RideCancel(ride_cancel) => ride_cancel.verify_state(&self.from, db), + FunctionCall::Mint(mint) => mint.verify_state(&self.from, db), FunctionCall::RideRequestCancel(ride_request_cancel) => { ride_request_cancel.verify_state(&self.from, db) } @@ -281,6 +281,7 @@ impl Transaction { FunctionCall::RideAcceptance(_) => "RideAcceptance", FunctionCall::RidePay(_) => "RidePay", FunctionCall::RideCancel(_) => "RideCancel", + FunctionCall::Mint(_) => "Mint", FunctionCall::RideRequestCancel(_) => "RideRequestCancel", FunctionCall::ChainInit(_) => "ChainInit", } @@ -315,6 +316,7 @@ impl Transaction { FunctionCall::RideCancel(ride_cancel) => { ride_cancel.state_transaction(&self.from, &self.hash, db, fee) } + FunctionCall::Mint(mint) => mint.state_transaction(&self.hash, db), FunctionCall::RideRequestCancel(ride_request_cancel) => { ride_request_cancel.state_transaction(&self.hash, db) } diff --git a/tests/mint_burn.rs b/tests/mint_burn.rs new file mode 100644 index 0000000..05d81bb --- /dev/null +++ b/tests/mint_burn.rs @@ -0,0 +1,160 @@ +use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::transactions::chain_init::ChainInit; +use clutch_node::node::transactions::function_call::FunctionCall; +use clutch_node::node::transactions::mint::Mint; +use clutch_node::node::transactions::transaction::Transaction; +use serial_test::serial; + +const AUTHOR_PK: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; +const AUTHOR_SK: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; +const FAUCET_PK: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; +// Same committed dev key as tests/tx_fee.rs (clutch-hub-api config/default.toml:18). +const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; +const CHAIN_ID: u64 = 2077; +const USER: &str = "0x4444444444444444444444444444444444444444"; +const REF_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn ci() -> ChainInit { + ChainInit { + chain_id: CHAIN_ID, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 200, + ride_offer_referrer_fee_bps: 200, + // Mint authority = node1 dev key so tests can sign mints. Prod: dedicated key. + mint_authority: AUTHOR_PK.to_string(), + faucet_address: FAUCET_PK.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +fn chain(name: &str) -> Blockchain { + // A panicking assertion never reaches shutdown_blockchain(), leaving the RocksDB dir + // behind — the next run would then start with the previous run's nonces and fail for + // the wrong reason. Start from a clean slate (same defensive pattern as tests/tx_fee.rs). + let _ = std::fs::remove_dir_all(format!("{}.db", name)); + Blockchain::new( + name.to_string(), + AUTHOR_PK.to_string(), + AUTHOR_SK.to_string(), + true, + vec![AUTHOR_PK.to_string()], + ci(), + ) +} + +fn signed_mint(sk: &str, from: &str, nonce: u64, to: &str, amount: u64, credit_ref: &str) -> Transaction { + let mut tx = Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Mint(Mint { + to: to.to_string(), + amount, + credit_ref: credit_ref.to_string(), + }), + ); + tx.sign(sk); + tx +} + +#[test] +#[serial] +fn authorized_mint_credits_and_grows_supply() { + let mut chain = chain("test-mint-ok"); + let (_, supply0) = chain.get_chain_info().unwrap(); + + let mint = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 5_000_000, REF_A); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!(chain.get_account_balance(&USER.to_string()), 5_000_000); + let (_, supply1) = chain.get_chain_info().unwrap(); + assert_eq!(supply1, supply0 + 5_000_000, "total_supply tracks the mint"); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn unauthorized_mint_rejected() { + let mut chain = chain("test-mint-unauth"); + // Faucet key is NOT the mint authority. + let mint = signed_mint(FAUCET_SK, FAUCET_PK, 1, USER, 100, REF_A); + let err = chain.add_transaction_to_pool(&mint).unwrap_err(); + assert!(err.contains("authority"), "got: {}", err); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn duplicate_credit_ref_rejected() { + let mut chain = chain("test-mint-dup"); + let m1 = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, REF_A); + chain.add_transaction_to_pool(&m1).unwrap(); + chain.author_new_block().unwrap(); + + let m2 = signed_mint(AUTHOR_SK, AUTHOR_PK, 2, USER, 100, REF_A); + let err = chain.add_transaction_to_pool(&m2).unwrap_err(); + assert!(err.contains("credit_ref"), "exactly-once minting: {}", err); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn mint_rejects_zero_and_bad_ref() { + let mut chain = chain("test-mint-bad"); + let zero = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 0, REF_A); + assert!(chain.add_transaction_to_pool(&zero).is_err()); + let bad_ref = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, "not-hex"); + assert!(chain.add_transaction_to_pool(&bad_ref).is_err()); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn mint_works_with_zero_treasury_balance() { + // Mint is fee-exempt: the authority holds no CLT at genesis and must still mint. + let mut chain = chain("test-mint-feeless"); + assert_eq!(chain.get_account_balance(&AUTHOR_PK.to_string()), 0); + let mint = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, REF_A); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + assert_eq!(chain.get_account_balance(&USER.to_string()), 100); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn author_own_tx_pays_no_fee() { + use clutch_node::node::transactions::transfer::Transfer; + // Fund the author via Mint (fee-exempt, single balance write — no deferred-batch + // collision), then the author sends a transfer in a block it authors itself. + let mut chain = chain("test-fee-author"); + let mint = signed_mint( + AUTHOR_SK, AUTHOR_PK, 1, AUTHOR_PK, 10_000, + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + assert_eq!(chain.get_account_balance(&AUTHOR_PK.to_string()), 10_000); + + let mut own = Transaction::new_transaction( + AUTHOR_PK.to_string(), + 2, + CHAIN_ID, + FunctionCall::Transfer(Transfer { + to: "0x3333333333333333333333333333333333333333".to_string(), + value: 100, + }), + ); + own.sign(AUTHOR_SK); + chain.add_transaction_to_pool(&own).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!( + chain.get_account_balance(&AUTHOR_PK.to_string()), + 10_000 - 100, + "author's own tx nets zero fee" + ); + chain.shutdown_blockchain(); +} From cefee0c23be57648fd044cd199ecce9012f03be0 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 10:26:57 +0400 Subject: [PATCH 14/24] harden: self-contained mint dedupe, RLP contract test, uncovered branches first_duplicate_sender now canonicalizes, so the same-block exactly-once guarantee for mints no longer depends on strict string equality in signature verification. Pins Mint's tag-6 wire contract with a round-trip test, covers the amount and supply-range rejections, validates the mint recipient, and tightens two bare is_err assertions. Co-Authored-By: Claude Fable 5 --- src/node/blocks/block.rs | 7 +++++ src/node/transactions/mint.rs | 23 +++++++++++++-- src/node/transactions/transaction.rs | 26 +++++++++++++++- tests/mint_burn.rs | 33 +++++++++++++++++++-- tests/rlp_decode_test.rs | 44 +++++++++++++++++++++++++++- 5 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index c88f1a7..404090f 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -386,10 +386,17 @@ impl Block { // Supply changes once per block: per-tx read-modify-writes of the single // total_supply key would collide in the deferred batch (last write wins, // e.g. two Burns in one block). Sum first, then one read + one write. + // + // Today at most one Mint can appear here: one authority key, and + // `first_duplicate_sender` allows only one tx per sender per block, so this loop + // accumulates over a single entry in practice. It becomes a real multi-entry sum + // in Task 7, when Burns from arbitrary users (many distinct senders) can share a + // block alongside a Mint. let mut supply_delta: i128 = 0; for tx in &block.transactions { match &tx.data { FunctionCall::Mint(m) => supply_delta += m.amount as i128, + // Task 7 adds: FunctionCall::Burn(b) => supply_delta -= b.amount as i128, _ => {} } } diff --git a/src/node/transactions/mint.rs b/src/node/transactions/mint.rs index 5570b14..29d7251 100644 --- a/src/node/transactions/mint.rs +++ b/src/node/transactions/mint.rs @@ -16,7 +16,7 @@ pub fn processed_ref_key(reference: &str) -> Vec { } pub fn ref_is_valid(reference: &str) -> bool { - reference.len() == 64 && reference.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + reference.len() == 64 && reference.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) } pub fn ref_already_processed(db: &Database, reference: &str) -> Result { @@ -27,6 +27,17 @@ pub fn ref_already_processed(db: &Database, reference: &str) -> Result bool { + let hex_part = addr + .strip_prefix("0x") + .or_else(|| addr.strip_prefix("0X")) + .unwrap_or(addr); + hex_part.len() == 40 && hex_part.chars().all(|c| c.is_ascii_hexdigit()) +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Mint { pub to: String, @@ -43,11 +54,19 @@ impl Mint { from )); } + if !is_valid_address(&self.to) { + return Err(format!( + "Mint rejected: 'to' must be a 20-byte-hex address, got '{}'", + self.to + )); + } if self.amount == 0 { return Err("Mint rejected: amount must be positive".to_string()); } if self.amount > i64::MAX as u64 { - return Err("Mint rejected: amount exceeds i64::MAX (balance deltas are i64)".to_string()); + return Err( + "Mint rejected: amount exceeds i64::MAX (balance deltas are i64)".to_string(), + ); } if !ref_is_valid(&self.credit_ref) { return Err("Mint rejected: credit_ref must be 64 lowercase hex chars".to_string()); diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 94e5c2e..dce26f8 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -160,10 +160,19 @@ impl Transaction { /// First account that appears more than once in `transactions`, if any. Reads only /// `from`, so it's pure/DB-free and unit-testable. + /// + /// This is the same-block exactly-once backstop for Mint: two Mints sharing a + /// `credit_ref` in one block are caught here, not by the ref marker (which only + /// exists in state *after* the block commits — `verify_state` for both sees + /// pre-block state). Canonicalizing (rather than comparing raw `from` strings) + /// keeps that guarantee self-contained instead of depending on `SignatureKeys::verify` + /// happening to reject case-variant signers elsewhere — a distant invariant, not a + /// nonce-ordering nicety. fn first_duplicate_sender(transactions: &[Transaction]) -> Option { + use super::address::canonical_account_address; let mut seen = std::collections::HashSet::new(); for tx in transactions { - if !seen.insert(tx.from.as_str()) { + if !seen.insert(canonical_account_address(&tx.from)) { return Some(tx.from.clone()); } } @@ -407,6 +416,21 @@ mod tests { ); } + #[test] + fn first_duplicate_sender_catches_case_variant() { + // Same-block exactly-once backstop for Mint: a case-variant `from` (e.g. two + // same-ref mints signed to look like `0xAB...` and `0xab...`) is still the same + // account canonically, and must be caught here independent of whatever + // `SignatureKeys::verify` happens to accept. Raw string comparison (the + // pre-fix behavior) would return `None` for this pair. + let a = tf("0xABCDEF", 1, "0x1"); + let a_variant = tf("0xabcdef", 1, "0x2"); + assert!( + Transaction::first_duplicate_sender(&[a, a_variant]).is_some(), + "case-variant senders must canonicalize to the same account" + ); + } + #[test] fn accepts_sdk_style_ride_acceptance_hash() { // TODO(sdk-v3): no test currently pins the node's hashing against externally-produced diff --git a/tests/mint_burn.rs b/tests/mint_burn.rs index 05d81bb..8cdf6a0 100644 --- a/tests/mint_burn.rs +++ b/tests/mint_burn.rs @@ -104,9 +104,38 @@ fn duplicate_credit_ref_rejected() { fn mint_rejects_zero_and_bad_ref() { let mut chain = chain("test-mint-bad"); let zero = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 0, REF_A); - assert!(chain.add_transaction_to_pool(&zero).is_err()); + let err = chain.add_transaction_to_pool(&zero).unwrap_err(); + assert!(err.contains("amount must be positive"), "got: {}", err); let bad_ref = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, "not-hex"); - assert!(chain.add_transaction_to_pool(&bad_ref).is_err()); + let err = chain.add_transaction_to_pool(&bad_ref).unwrap_err(); + assert!(err.contains("credit_ref must be 64 lowercase hex chars"), "got: {}", err); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn mint_rejects_amount_over_i64_max() { + let mut chain = chain("test-mint-overflow"); + let over = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, i64::MAX as u64 + 1, REF_A); + let err = chain.add_transaction_to_pool(&over).unwrap_err(); + assert!(err.contains("amount exceeds i64::MAX"), "got: {}", err); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn mint_rejects_supply_out_of_range() { + // Block-level `total_supply out of range` guard (block.rs) driven through a real + // block, not asserted directly: genesis's testnet faucet_allocation (1e15, see `ci()`) + // already makes total_supply > 0, so one Mint at the per-tx ceiling (`i64::MAX`, the + // largest amount `Mint::verify_state` allows through) pushes + // `supply0 + i64::MAX > i64::MAX` and trips the block-level guard on the same tx that + // passed per-tx `verify_state` — the two checks are independent and both are needed. + let mut chain = chain("test-mint-supply-range"); + let mint = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, i64::MAX as u64, REF_A); + chain.add_transaction_to_pool(&mint).unwrap(); + let err = chain.author_new_block().unwrap_err(); + assert!(err.contains("total_supply out of range"), "got: {}", err); chain.shutdown_blockchain(); } diff --git a/tests/rlp_decode_test.rs b/tests/rlp_decode_test.rs index 519b0b7..4f7326f 100644 --- a/tests/rlp_decode_test.rs +++ b/tests/rlp_decode_test.rs @@ -1,9 +1,10 @@ #[cfg(test)] mod tests { use clutch_node::node::transactions::function_call::FunctionCall; + use clutch_node::node::transactions::mint::Mint; use clutch_node::node::transactions::ride_request::RideRequest; use hex; - use rlp::{Encodable, RlpStream}; + use rlp::{Decodable, Encodable, Rlp, RlpStream}; use sha3::{Digest, Keccak256}; use clutch_node::node::{coordinate, rlp_encoding}; use clutch_node::node::transactions::transaction::Transaction; @@ -147,4 +148,45 @@ fn test_rlp_encode_ride_request_transaction() { Err(e) => println!("Failed to decode our own transaction: {:?}", e), } } + +#[test] +fn mint_rlp_round_trip_pins_wire_contract() { + // Mint's encoding is a cross-repo byte-match contract (Treasury Service + JS SDK): + // tag 6, 3-item arg list `[to, amount, credit_ref]`. Nothing else in the suite pins + // the tag/arity — Task 4 shipped a wrong contract statement precisely because no test + // did. Round-trip the fields, then decode the raw structure independently of the + // round-trip so a bug that happens to cancel out on both sides can't hide. + let mint = Mint { + to: "0x4444444444444444444444444444444444444444".to_string(), + amount: 5_000_000, + credit_ref: "aa".repeat(32), + }; + let function_call = FunctionCall::Mint(mint.clone()); + + let mut stream = RlpStream::new(); + function_call.rlp_append(&mut stream); + let encoded = stream.out(); + + // Structural check: [tag, args] with tag == 6 and args a 3-item list, read directly + // off the wire bytes rather than only through the round-trip below. + let rlp = Rlp::new(&encoded); + assert!(rlp.is_list(), "FunctionCall wire form must be a list"); + assert_eq!(rlp.item_count().unwrap(), 2, "FunctionCall wire form is [tag, args]"); + let tag: u8 = rlp.val_at(0).unwrap(); + assert_eq!(tag, 6, "Mint's RLP tag must be 6"); + let args = rlp.at(1).unwrap(); + assert!(args.is_list(), "Mint args must be a list"); + assert_eq!(args.item_count().unwrap(), 3, "Mint args must be [to, amount, credit_ref]"); + + // Round-trip check: decode back through FunctionCall and confirm all three fields survive. + let decoded = FunctionCall::decode(&Rlp::new(&encoded)).expect("decode Mint FunctionCall"); + match decoded { + FunctionCall::Mint(decoded_mint) => { + assert_eq!(decoded_mint.to, mint.to, "to must round-trip"); + assert_eq!(decoded_mint.amount, mint.amount, "amount must round-trip"); + assert_eq!(decoded_mint.credit_ref, mint.credit_ref, "credit_ref must round-trip"); + } + other => panic!("expected FunctionCall::Mint, got {:?}", other), + } +} } \ No newline at end of file From 466154b3ca487903a10f4c3db556ccc4604396cd Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 10:39:03 +0400 Subject: [PATCH 15/24] feat!: Burn transaction with optional exactly-once redemption_ref RLP tag 7, permissionless. Redemptions carry hex(keccak256(intent_id)) so the treasury payout worker matches burns to intents; plain burns allowed. Burner pays amount + fee in one balance write; supply shrinks. Co-Authored-By: Claude Fable 5 --- src/node/blocks/block.rs | 11 +- src/node/rlp_encoding.rs | 10 ++ src/node/transactions/burn.rs | 86 ++++++++++++++++ src/node/transactions/function_call.rs | 3 + src/node/transactions/mod.rs | 1 + src/node/transactions/transaction.rs | 7 +- tests/mint_burn.rs | 137 +++++++++++++++++++++++++ tests/rlp_decode_test.rs | 64 ++++++++++++ 8 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 src/node/transactions/burn.rs diff --git a/src/node/blocks/block.rs b/src/node/blocks/block.rs index 404090f..77d167d 100644 --- a/src/node/blocks/block.rs +++ b/src/node/blocks/block.rs @@ -387,16 +387,15 @@ impl Block { // total_supply key would collide in the deferred batch (last write wins, // e.g. two Burns in one block). Sum first, then one read + one write. // - // Today at most one Mint can appear here: one authority key, and - // `first_duplicate_sender` allows only one tx per sender per block, so this loop - // accumulates over a single entry in practice. It becomes a real multi-entry sum - // in Task 7, when Burns from arbitrary users (many distinct senders) can share a - // block alongside a Mint. + // Mint is limited to one per block (single authority key, and + // `first_duplicate_sender` allows only one tx per sender per block), but Burn is + // permissionless — several distinct senders can each burn in the same block. This + // loop is a real multi-entry sum in that case, alongside at most one Mint. let mut supply_delta: i128 = 0; for tx in &block.transactions { match &tx.data { FunctionCall::Mint(m) => supply_delta += m.amount as i128, - // Task 7 adds: FunctionCall::Burn(b) => supply_delta -= b.amount as i128, + FunctionCall::Burn(b) => supply_delta -= b.amount as i128, _ => {} } } diff --git a/src/node/rlp_encoding.rs b/src/node/rlp_encoding.rs index dbea0b5..7936ec5 100644 --- a/src/node/rlp_encoding.rs +++ b/src/node/rlp_encoding.rs @@ -11,6 +11,7 @@ use super::blocks::block_headers::{BlockHeader, BlockHeaders}; use super::p2p_server::get_block_bodies::GetBlockBodies; use super::p2p_server::get_block_header::GetBlockHeaders; use super::p2p_server::handshake::Handshake; +use super::transactions::burn::Burn; use super::transactions::chain_init::ChainInit; use super::transactions::function_call::FunctionCall; use super::transactions::mint::Mint; @@ -60,6 +61,11 @@ impl Encodable for FunctionCall { stream.append(&6u8); // Tag for Mint stream.append(args); } + FunctionCall::Burn(args) => { + stream.begin_list(2); + stream.append(&7u8); // Tag for Burn + stream.append(args); + } FunctionCall::RideRequestCancel(args) => { stream.begin_list(2); stream.append(&8u8); // Tag for RideRequestCancel @@ -111,6 +117,10 @@ impl Decodable for FunctionCall { let args: Mint = rlp.val_at(1)?; Ok(FunctionCall::Mint(args)) } + 7 => { + let args: Burn = rlp.val_at(1)?; + Ok(FunctionCall::Burn(args)) + } 8 => { let args: RideRequestCancel = rlp.val_at(1)?; Ok(FunctionCall::RideRequestCancel(args)) diff --git a/src/node/transactions/burn.rs b/src/node/transactions/burn.rs new file mode 100644 index 0000000..bd0f618 --- /dev/null +++ b/src/node/transactions/burn.rs @@ -0,0 +1,86 @@ +use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; +use serde::{Deserialize, Serialize}; + +use crate::node::account_state::AccountState; +use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; +use crate::node::database::Database; + +use super::mint::{processed_ref_key, ref_already_processed, ref_is_valid}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Burn { + pub amount: u64, + /// hex(keccak256(intent_id)) for treasury redemptions; None for a plain burn. + pub redemption_ref: Option, +} + +impl Burn { + pub fn verify_state(&self, _from: &String, db: &Database) -> Result<(), String> { + if self.amount == 0 { + return Err("Burn rejected: amount must be positive".to_string()); + } + if self.amount > i64::MAX as u64 { + return Err("Burn rejected: amount exceeds i64::MAX".to_string()); + } + if let Some(r) = &self.redemption_ref { + if !ref_is_valid(r) { + return Err("Burn rejected: redemption_ref must be 64 lowercase hex chars".to_string()); + } + if ref_already_processed(db, r)? { + return Err(format!( + "Burn rejected: redemption_ref '{}' already processed", + r + )); + } + } + // Balance sufficiency (amount + fee) is enforced centrally in validate_transaction. + Ok(()) + } + + pub fn state_transaction( + &self, + from: &String, + tx_hash: &String, + db: &Database, + fee: u64, + ) -> Vec { + let mut updates = AccountState::apply_balance_change_with_fee( + from, + -(self.amount as i64), + fee, + BalanceEffectKind::Burn, + None, + db, + ); + if let Some(r) = &self.redemption_ref { + updates.push(StateUpdate::storage_only( + processed_ref_key(r), + tx_hash.clone().into_bytes(), + )); + } + updates + } +} + +impl Encodable for Burn { + fn rlp_append(&self, stream: &mut RlpStream) { + stream.begin_list(2); + stream.append(&self.amount); + // Same optional-string convention as referrers: empty string = None. + let ref_str = self.redemption_ref.clone().unwrap_or_default(); + stream.append(&ref_str); + } +} + +impl Decodable for Burn { + fn decode(rlp: &Rlp) -> Result { + if !rlp.is_list() || rlp.item_count()? != 2 { + return Err(DecoderError::RlpIncorrectListLen); + } + let ref_str: String = rlp.val_at(1)?; + Ok(Burn { + amount: rlp.val_at(0)?, + redemption_ref: if ref_str.is_empty() { None } else { Some(ref_str) }, + }) + } +} diff --git a/src/node/transactions/function_call.rs b/src/node/transactions/function_call.rs index b092c20..3bcde72 100644 --- a/src/node/transactions/function_call.rs +++ b/src/node/transactions/function_call.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; +use super::burn::Burn; use super::chain_init::ChainInit; use super::mint::Mint; use super::{ @@ -19,6 +20,7 @@ pub enum FunctionCall { RidePay(RidePay), RideCancel(RideCancel), Mint(Mint), + Burn(Burn), RideRequestCancel(RideRequestCancel), ChainInit(ChainInit), } @@ -33,6 +35,7 @@ impl fmt::Display for FunctionCall { FunctionCall::RidePay(args) => write!(f, "RidePay: {:?}", args), FunctionCall::RideCancel(args) => write!(f, "RideCancel: {:?}", args), FunctionCall::Mint(args) => write!(f, "Mint: {:?}", args), + FunctionCall::Burn(args) => write!(f, "Burn: {:?}", args), FunctionCall::RideRequestCancel(args) => write!(f, "RideRequestCancel: {:?}", args), FunctionCall::ChainInit(args) => write!(f, "ChainInit: {:?}", args), } diff --git a/src/node/transactions/mod.rs b/src/node/transactions/mod.rs index b72ba2d..7f38ecf 100644 --- a/src/node/transactions/mod.rs +++ b/src/node/transactions/mod.rs @@ -1,4 +1,5 @@ pub mod address; +pub mod burn; pub mod chain_init; pub mod function_call; pub mod mint; diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index dce26f8..39a5e45 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -217,7 +217,7 @@ impl Transaction { fn sender_direct_debit(&self) -> u64 { match &self.data { FunctionCall::Transfer(t) => t.value, - // Task 7 adds: FunctionCall::Burn(b) => b.amount, + FunctionCall::Burn(b) => b.amount, _ => 0, } } @@ -275,6 +275,7 @@ impl Transaction { FunctionCall::RidePay(ride_pay) => ride_pay.verify_state(&self.from, db), FunctionCall::RideCancel(ride_cancel) => ride_cancel.verify_state(&self.from, db), FunctionCall::Mint(mint) => mint.verify_state(&self.from, db), + FunctionCall::Burn(burn) => burn.verify_state(&self.from, db), FunctionCall::RideRequestCancel(ride_request_cancel) => { ride_request_cancel.verify_state(&self.from, db) } @@ -291,6 +292,7 @@ impl Transaction { FunctionCall::RidePay(_) => "RidePay", FunctionCall::RideCancel(_) => "RideCancel", FunctionCall::Mint(_) => "Mint", + FunctionCall::Burn(_) => "Burn", FunctionCall::RideRequestCancel(_) => "RideRequestCancel", FunctionCall::ChainInit(_) => "ChainInit", } @@ -326,6 +328,7 @@ impl Transaction { ride_cancel.state_transaction(&self.from, &self.hash, db, fee) } FunctionCall::Mint(mint) => mint.state_transaction(&self.hash, db), + FunctionCall::Burn(burn) => burn.state_transaction(&self.from, &self.hash, db, fee), FunctionCall::RideRequestCancel(ride_request_cancel) => { ride_request_cancel.state_transaction(&self.hash, db) } @@ -343,7 +346,7 @@ impl Transaction { | FunctionCall::RideAcceptance(_) | FunctionCall::RideCancel(_) | FunctionCall::RidePay(_) - // Task 7 adds: | FunctionCall::Burn(_) + | FunctionCall::Burn(_) ); if fee > 0 && !fee_handled_in_type { states.push(AccountState::apply_balance_change( diff --git a/tests/mint_burn.rs b/tests/mint_burn.rs index 8cdf6a0..50b130b 100644 --- a/tests/mint_burn.rs +++ b/tests/mint_burn.rs @@ -1,4 +1,5 @@ use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::transactions::burn::Burn; use clutch_node::node::transactions::chain_init::ChainInit; use clutch_node::node::transactions::function_call::FunctionCall; use clutch_node::node::transactions::mint::Mint; @@ -187,3 +188,139 @@ fn author_own_tx_pays_no_fee() { ); chain.shutdown_blockchain(); } + +const REF_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const TX_FEE: u64 = 1000; + +fn signed_burn(sk: &str, from: &str, nonce: u64, amount: u64, redemption_ref: Option<&str>) -> Transaction { + let mut tx = Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Burn(Burn { + amount, + redemption_ref: redemption_ref.map(|s| s.to_string()), + }), + ); + tx.sign(sk); + tx +} + +#[test] +#[serial] +fn burn_reduces_balance_and_supply() { + let mut chain = chain("test-burn-ok"); + let (_, supply0) = chain.get_chain_info().unwrap(); + let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); + let author_before = chain.get_account_balance(&AUTHOR_PK.to_string()); + + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, 2_000_000, Some(REF_B)); + chain.add_transaction_to_pool(&burn).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before - 2_000_000 - TX_FEE, + "burner pays amount + fee" + ); + let (_, supply1) = chain.get_chain_info().unwrap(); + assert_eq!(supply1, supply0 - 2_000_000, "supply shrinks by burn amount only (fee just moves)"); + // The fee moves to the block author rather than being destroyed alongside the burn — + // this is what makes the supply delta above "amount only" instead of "amount + fee". + assert_eq!( + chain.get_account_balance(&AUTHOR_PK.to_string()), + author_before + TX_FEE, + "burn fee is credited to the block author, not destroyed" + ); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn duplicate_redemption_ref_rejected() { + let mut chain = chain("test-burn-dup"); + let b1 = signed_burn(FAUCET_SK, FAUCET_PK, 1, 100, Some(REF_B)); + chain.add_transaction_to_pool(&b1).unwrap(); + chain.author_new_block().unwrap(); + let b2 = signed_burn(FAUCET_SK, FAUCET_PK, 2, 100, Some(REF_B)); + assert!(chain.add_transaction_to_pool(&b2).is_err()); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn burn_more_than_balance_rejected() { + let mut chain = chain("test-burn-overdraw"); + let balance = chain.get_account_balance(&FAUCET_PK.to_string()); + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, balance, None); // no headroom for fee + assert!(chain.add_transaction_to_pool(&burn).is_err()); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn plain_burn_without_ref_works() { + let mut chain = chain("test-burn-plain"); + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, 100, None); + chain.add_transaction_to_pool(&burn).unwrap(); + chain.author_new_block().unwrap(); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn two_burns_from_different_senders_in_one_block_reduce_supply_by_sum() { + // The block-level supply accumulation loop (block.rs) has never actually accumulated: + // Mint is one-per-block (single authority, one tx per sender per block), but Burns come + // from arbitrary users, so several can share a block. This is the first real exercise of + // that loop with more than one entry — a wrong sign or a deferred-batch collision on + // either burner's balance write would show up here and nowhere else. + let mut chain = chain("test-burn-two-senders"); + let (_, supply0) = chain.get_chain_info().unwrap(); + + // Second sender: DRIVER, a genuinely distinct matched keypair already used elsewhere + // in this suite (tests/balance_effects.rs) — the address is derived from the secret + // key via secp256k1, so it can't be picked independently of it. + let second_user = "0x8f19077627cde4848b090c53c83b12956837d5e9"; + let second_sk = "e74e3f87268132c7b3ddb24600716fc362f4519bf9986a9436aa8a1be58c7150"; + + // Fund the second sender via Mint (fee-exempt authority credit, single balance write). + let fund = signed_mint( + AUTHOR_SK, AUTHOR_PK, 1, second_user, 1_000_000, + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + ); + chain.add_transaction_to_pool(&fund).unwrap(); + chain.author_new_block().unwrap(); + + let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); + let second_before = chain.get_account_balance(&second_user.to_string()); + assert_eq!(second_before, 1_000_000); + + // Two Burns from two distinct senders, same block. Both are first-ever txs from their + // sender in this fresh chain (the funding Mint above was sent by AUTHOR_PK, not either + // burner), so both start at nonce 1. + let burn_faucet = signed_burn(FAUCET_SK, FAUCET_PK, 1, 300_000, None); + let burn_second = signed_burn(second_sk, second_user, 1, 400_000, None); + chain.add_transaction_to_pool(&burn_faucet).unwrap(); + chain.add_transaction_to_pool(&burn_second).unwrap(); + chain.author_new_block().unwrap(); + + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before - 300_000 - TX_FEE, + "faucet burner pays amount + fee" + ); + assert_eq!( + chain.get_account_balance(&second_user.to_string()), + second_before - 400_000 - TX_FEE, + "second burner pays amount + fee" + ); + + let (_, supply1) = chain.get_chain_info().unwrap(); + assert_eq!( + supply1, + supply0 + 1_000_000 - 300_000 - 400_000, + "supply reflects the mint plus both burns summed, not last-write-wins" + ); + chain.shutdown_blockchain(); +} diff --git a/tests/rlp_decode_test.rs b/tests/rlp_decode_test.rs index 4f7326f..aee5bda 100644 --- a/tests/rlp_decode_test.rs +++ b/tests/rlp_decode_test.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod tests { + use clutch_node::node::transactions::burn::Burn; use clutch_node::node::transactions::function_call::FunctionCall; use clutch_node::node::transactions::mint::Mint; use clutch_node::node::transactions::ride_request::RideRequest; @@ -189,4 +190,67 @@ fn mint_rlp_round_trip_pins_wire_contract() { other => panic!("expected FunctionCall::Mint, got {:?}", other), } } + +#[test] +fn burn_rlp_round_trip_pins_wire_contract() { + // Burn's encoding is the same kind of cross-repo byte-match contract as Mint (Treasury + // Service + JS SDK): tag 7, 2-item arg list `[amount, redemption_ref-or-empty-string]`. + // Covers both the redemption case (ref present) and the plain-burn case (ref is None, + // which must be the empty string on the wire, not an absent/optional RLP item) so a + // sign or arity slip in either direction shows up here. + let burn_with_ref = Burn { + amount: 2_000_000, + redemption_ref: Some("bb".repeat(32)), + }; + let function_call = FunctionCall::Burn(burn_with_ref.clone()); + + let mut stream = RlpStream::new(); + function_call.rlp_append(&mut stream); + let encoded = stream.out(); + + let rlp = Rlp::new(&encoded); + assert!(rlp.is_list(), "FunctionCall wire form must be a list"); + assert_eq!(rlp.item_count().unwrap(), 2, "FunctionCall wire form is [tag, args]"); + let tag: u8 = rlp.val_at(0).unwrap(); + assert_eq!(tag, 7, "Burn's RLP tag must be 7"); + let args = rlp.at(1).unwrap(); + assert!(args.is_list(), "Burn args must be a list"); + assert_eq!(args.item_count().unwrap(), 2, "Burn args must be [amount, redemption_ref]"); + let ref_on_wire: String = args.val_at(1).unwrap(); + assert_eq!(ref_on_wire, "bb".repeat(32), "redemption_ref is written as a plain string, not wrapped"); + + let decoded = FunctionCall::decode(&Rlp::new(&encoded)).expect("decode Burn FunctionCall"); + match decoded { + FunctionCall::Burn(decoded_burn) => { + assert_eq!(decoded_burn.amount, burn_with_ref.amount, "amount must round-trip"); + assert_eq!( + decoded_burn.redemption_ref, burn_with_ref.redemption_ref, + "redemption_ref must round-trip" + ); + } + other => panic!("expected FunctionCall::Burn, got {:?}", other), + } + + // Plain burn: None must serialize as an empty string on the wire and decode back to None. + let plain_burn = Burn { + amount: 100, + redemption_ref: None, + }; + let mut stream = RlpStream::new(); + FunctionCall::Burn(plain_burn.clone()).rlp_append(&mut stream); + let encoded = stream.out(); + + let rlp = Rlp::new(&encoded); + let args = rlp.at(1).unwrap(); + let ref_on_wire: String = args.val_at(1).unwrap(); + assert_eq!(ref_on_wire, "", "None must encode as the empty string, following the referrer convention"); + + let decoded = FunctionCall::decode(&Rlp::new(&encoded)).expect("decode plain Burn FunctionCall"); + match decoded { + FunctionCall::Burn(decoded_burn) => { + assert_eq!(decoded_burn.redemption_ref, None, "empty string must decode back to None"); + } + other => panic!("expected FunctionCall::Burn, got {:?}", other), + } +} } \ No newline at end of file From ed9c2a587ed2e9466ffa671ff7cf1d19b6eb8796 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 10:59:21 +0400 Subject: [PATCH 16/24] fix: reject duplicate exactly-once refs within a block Mint's same-block ref protection relied on every Mint sharing the one authorized sender, so the duplicate-sender guard caught collisions. Burn is permissionless, so two senders could carry one redemption_ref into a block, both validate against pre-block state, and collapse the marker - letting an attacker claim a pending redemption for dust plus a fee. Block validation now rejects a repeated ref across Mint and Burn, authoring filters the loser out, and the stale comment documenting the old premise is corrected. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- src/node/blockchain.rs | 75 ++++++++-- src/node/transactions/transaction.rs | 130 +++++++++++++++-- tests/mint_burn.rs | 200 ++++++++++++++++++++++++--- 4 files changed, 365 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2f92538..1b0a3d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ docker compose up -d # 3-node local net from ghcr image (this repo ## Gotchas / Conventions - Error handling is `Result<_, String>` everywhere (no anyhow/thiserror); DB read/write failures on hot paths (`get_latest_block`, `add_block_to_chain`) propagate as `Err`, not `panic!`. -- **One transaction per account per block.** Block state is validated then applied as one deferred RocksDB batch (commit at end of `add_block_to_chain`), so a second tx from the same account would validate/apply against stale pre-block state — two Transfers from one account mint CLT via last-write-wins on the balance key. `validate_transactions` rejects any block with a duplicate sender; `Blockchain::one_tx_per_sender` enforces it at authoring time (extra txs wait for later blocks). Lift only once intra-block state is applied incrementally. +- **One transaction per account per block, and one claim per exactly-once ref per block.** Block state is validated then applied as one deferred RocksDB batch (commit at end of `add_block_to_chain`), so a second tx from the same account would validate/apply against stale pre-block state — two Transfers from one account mint CLT via last-write-wins on the balance key. The same staleness breaks exactly-once for the shared `processed_ref_{ref}` marker: two txs in one block bearing one ref (Mint's `credit_ref` or Burn's `redemption_ref`, one namespace) both see it unused and their two identical marker writes collapse. `validate_transactions` rejects a block with a duplicate sender (`first_duplicate_sender`) or a repeated ref (`first_duplicate_ref` — needed because Burn is permissionless, so the sender guard no longer implies ref uniqueness); `Blockchain::drop_intra_block_conflicts` enforces both at authoring time (losers wait for later blocks). Lift only once intra-block state is applied incrementally. - Logging via `tracing` macros; logs also ship to Seq (`seq_url`/`seq_api_key` in config). - State keys are string-prefixed in the `state` CF: `account_state_{addr}`, `account_nonce_{addr}`, `ride_request_{hash}`, `ride_request_{hash}:ride_acceptance`, `ride_acceptance_{hash}:fare_paid`, `tx_effects_{hash}`, `block_effects_{height}`, `account_effect_{addr}_{reverse_height}...` — see `docs/state_keys.csv` and `balance_effect.rs`. - Addresses: canonical form is `0x` + lowercase hex (`src/node/transactions/address.rs`); readers fall back to legacy no-prefix keys (`legacy_account_address_hex`) — preserve that dual-read when touching account state. diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index b91ef1d..6de8103 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -241,7 +241,7 @@ impl Blockchain { let index = latest_block.index + 1; let previous_hash = latest_block.hash; let transactions = match TransactionPool::get_transactions(&self.db) { - Ok(transactions) => Self::one_tx_per_sender(transactions), + Ok(transactions) => Self::drop_intra_block_conflicts(transactions), Err(e) => return Err(format!("Failed to get transactions from pool: {}", e)), }; @@ -251,16 +251,26 @@ impl Blockchain { Ok(new_block) } - /// Keep at most one pending tx per sender — the lowest nonce, tie-broken by hash for - /// determinism — so an authored block never contains two txs from the same account. - /// `Transaction::validate_transactions` rejects such blocks (deferred-batch staleness - /// mints CLT); without this the author would keep drafting a block the pool makes - /// invalid and never make progress. Extra same-account txs stay in the pool for later - /// blocks. ponytail: one tx/account/block; lift with incremental intra-block state. - fn one_tx_per_sender(mut transactions: Vec) -> Vec { + /// Authoring-time counterpart to the block-level guards in + /// `Transaction::validate_transactions`: drop pending txs that cannot legally share a + /// block, keeping at most one per sender (deferred-batch staleness on the balance/nonce + /// mints CLT) and at most one per exactly-once ref (two identical `processed_ref_{ref}` + /// writes collapse, breaking exactly-once across Mint and Burn). Without this the author + /// would keep drafting a block its own validation rejects and never make progress. + /// + /// Ordering is lowest nonce, tie-broken by hash, so every node keeps the same winner; + /// the losers stay in the pool for a later block. + /// ponytail: one tx/account/block; lift with incremental intra-block state. + fn drop_intra_block_conflicts(mut transactions: Vec) -> Vec { transactions.sort_by(|a, b| a.nonce.cmp(&b.nonce).then_with(|| a.hash.cmp(&b.hash))); - let mut seen = std::collections::HashSet::new(); - transactions.retain(|tx| seen.insert(tx.from.clone())); + let mut senders = std::collections::HashSet::new(); + let mut refs = std::collections::HashSet::new(); + transactions.retain(|tx| { + senders.insert(tx.from.clone()) + && tx + .exactly_once_ref() + .map_or(true, |r| refs.insert(r.to_string())) + }); transactions } @@ -315,9 +325,21 @@ mod tests { ) } + fn burn(from: &str, nonce: u64, redemption_ref: Option<&str>) -> Transaction { + Transaction::new_transaction( + from.to_string(), + nonce, + 2077, + FunctionCall::Burn(crate::node::transactions::burn::Burn { + amount: 1, + redemption_ref: redemption_ref.map(|r| r.to_string()), + }), + ) + } + #[test] - fn one_tx_per_sender_keeps_lowest_nonce() { - let kept = Blockchain::one_tx_per_sender(vec![ + fn drops_extra_tx_per_sender_keeping_lowest_nonce() { + let kept = Blockchain::drop_intra_block_conflicts(vec![ tf("0xA", 2, "0xC"), tf("0xB", 5, "0xA"), tf("0xA", 1, "0xB"), @@ -329,9 +351,34 @@ mod tests { } #[test] - fn one_tx_per_sender_collapses_duplicate_nonce_mint_vector() { + fn drops_duplicate_nonce_mint_vector() { // Same account, same nonce, different recipients — the double-spend/mint input. - let kept = Blockchain::one_tx_per_sender(vec![tf("0xA", 1, "0xB"), tf("0xA", 1, "0xC")]); + let kept = + Blockchain::drop_intra_block_conflicts(vec![tf("0xA", 1, "0xB"), tf("0xA", 1, "0xC")]); assert_eq!(kept.len(), 1, "only one tx per sender survives block building"); } + + #[test] + fn drops_second_claim_on_an_exactly_once_ref() { + // Two *different* senders, so the per-sender filter never fires — but one ref, whose + // marker write would collapse in the deferred batch. Without this the author drafts + // a block `validate_transactions` then rejects, and never makes progress. + let r = "a".repeat(64); + let kept = Blockchain::drop_intra_block_conflicts(vec![ + burn("0xA", 1, Some(&r)), + burn("0xB", 1, Some(&r)), + ]); + assert_eq!(kept.len(), 1, "one claim per ref survives block building"); + } + + #[test] + fn keeps_every_ref_less_burn() { + // `None` is the absence of a ref, not a shared one — collapsing these would break + // the plain-burn path. + let kept = Blockchain::drop_intra_block_conflicts(vec![ + burn("0xA", 1, None), + burn("0xB", 1, None), + ]); + assert_eq!(kept.len(), 2, "ref-less burns never conflict"); + } } diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index 39a5e45..e5312f3 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -142,7 +142,7 @@ impl Transaction { // the last-write-wins batch collapses the two debits into one while both credits // land — minting CLT. Until intra-block state is applied incrementally, one tx per // account per block is the safe ceiling (the author drains the rest into later - // blocks; see `Blockchain::one_tx_per_sender`). + // blocks; see `Blockchain::drop_intra_block_conflicts`). // ponytail: lift this cap once per-tx state is visible to the next tx in the block. if let Some(dup) = Self::first_duplicate_sender(transactions) { return Err(format!( @@ -151,6 +151,13 @@ impl Transaction { )); } + if let Some((reference, from)) = Self::first_duplicate_ref(transactions) { + return Err(format!( + "Block contains multiple transactions claiming the exactly-once ref '{}' (second from '{}'); a ref may be claimed once.", + reference, from + )); + } + for tx in transactions.iter() { tx.validate_transaction(&db)?; } @@ -161,13 +168,14 @@ impl Transaction { /// First account that appears more than once in `transactions`, if any. Reads only /// `from`, so it's pure/DB-free and unit-testable. /// - /// This is the same-block exactly-once backstop for Mint: two Mints sharing a - /// `credit_ref` in one block are caught here, not by the ref marker (which only - /// exists in state *after* the block commits — `verify_state` for both sees - /// pre-block state). Canonicalizing (rather than comparing raw `from` strings) - /// keeps that guarantee self-contained instead of depending on `SignatureKeys::verify` - /// happening to reject case-variant signers elsewhere — a distant invariant, not a - /// nonce-ordering nicety. + /// This guards balance/nonce staleness only. It is NOT the same-block exactly-once + /// backstop, though it was while Mint was the only ref-carrying type: every Mint is + /// signed by the single authorized authority, so two same-ref Mints in one block were + /// necessarily the same sender and got caught here. Burn is permissionless, so two + /// *different* senders can carry one `redemption_ref` — `first_duplicate_ref` is what + /// provides the guarantee now, for both types. Canonicalizing (rather than comparing + /// raw `from` strings) keeps the account cap self-contained instead of depending on + /// `SignatureKeys::verify` happening to reject case-variant signers elsewhere. fn first_duplicate_sender(transactions: &[Transaction]) -> Option { use super::address::canonical_account_address; let mut seen = std::collections::HashSet::new(); @@ -179,6 +187,44 @@ impl Transaction { None } + /// The exactly-once ref this transaction claims, if any: Mint's `credit_ref` (always + /// present) or Burn's `redemption_ref` (optional — `None` is the absence of a claim and + /// must never collide, including with another `None`). Both write the same + /// `processed_ref_{ref}` marker, one namespace across the two types by design. + pub(crate) fn exactly_once_ref(&self) -> Option<&str> { + match &self.data { + FunctionCall::Mint(m) => Some(m.credit_ref.as_str()), + FunctionCall::Burn(b) => b.redemption_ref.as_deref(), + _ => None, + } + } + + /// First ref claimed twice in `transactions`, as `(ref, the second claimant's `from`)`. + /// Pure/DB-free like `first_duplicate_sender`, and the same kind of backstop. + /// + /// `verify_state` rejects an already-marked ref, but it reads *committed* state and the + /// block's writes only commit at the end of `add_block_to_chain` — so two txs in one + /// block bearing one ref both see it unused, both apply, and their two identical marker + /// writes collapse to one in the deferred batch. Nothing is minted and the supply delta + /// stays right; what breaks is exactly-once, which is the whole point of the field: the + /// treasury would see two confirmed on-chain claims on a single off-chain intent. + /// + /// Comparison is exact, not case-folded, deliberately: the collision being prevented is + /// two equal `processed_ref_{ref}` keys, and those keys embed the raw string. A + /// case-variant ref is a different key (and is separately rejected by `ref_is_valid`, + /// which demands 64 lowercase hex chars), so equality here is precisely the invariant. + fn first_duplicate_ref(transactions: &[Transaction]) -> Option<(String, String)> { + let mut seen = std::collections::HashSet::new(); + for tx in transactions { + if let Some(reference) = tx.exactly_once_ref() { + if !seen.insert(reference) { + return Some((reference.to_string(), tx.from.clone())); + } + } + } + None + } + pub fn validate_transaction(&self, db: &Database) -> Result<(), String> { self.verify_hash()?; self.verify_signature()?; @@ -434,6 +480,74 @@ mod tests { ); } + fn burn_tx(from: &str, redemption_ref: Option<&str>) -> Transaction { + Transaction::new_transaction( + from.to_string(), + 1, + 2077, + FunctionCall::Burn(super::super::burn::Burn { + amount: 1, + redemption_ref: redemption_ref.map(|r| r.to_string()), + }), + ) + } + + fn mint_tx(from: &str, credit_ref: &str) -> Transaction { + Transaction::new_transaction( + from.to_string(), + 1, + 2077, + FunctionCall::Mint(super::super::mint::Mint { + to: "0x1".to_string(), + amount: 1, + credit_ref: credit_ref.to_string(), + }), + ) + } + + #[test] + fn first_duplicate_ref_catches_two_senders_claiming_one_ref() { + // The Burn attack: permissionless senders, so `first_duplicate_sender` returns None + // and this is the only thing standing between a pending redemption ref and a second + // on-chain claim on it. + let r = "a".repeat(64); + let alice = burn_tx("0xA", Some(&r)); + let attacker = burn_tx("0xB", Some(&r)); + assert_eq!(Transaction::first_duplicate_sender(&[alice.clone(), attacker.clone()]), None); + assert_eq!( + Transaction::first_duplicate_ref(&[alice, attacker]), + Some((r, "0xB".to_string())), + "the second claimant is named" + ); + } + + #[test] + fn first_duplicate_ref_spans_mint_and_burn() { + // One `processed_ref_{ref}` namespace across both types by design. + let r = "b".repeat(64); + assert!( + Transaction::first_duplicate_ref(&[mint_tx("0xA", &r), burn_tx("0xB", Some(&r))]) + .is_some(), + "a Mint and a Burn claiming one ref write the same marker key" + ); + } + + #[test] + fn first_duplicate_ref_ignores_absent_refs() { + // `None` is no claim at all — two of them must not read as a collision, or every + // multi-burn block dies. Distinct refs and non-ref types are likewise fine. + assert_eq!( + Transaction::first_duplicate_ref(&[ + burn_tx("0xA", None), + burn_tx("0xB", None), + tf("0xC", 1, "0xD"), + burn_tx("0xE", Some(&"a".repeat(64))), + burn_tx("0xF", Some(&"b".repeat(64))), + ]), + None + ); + } + #[test] fn accepts_sdk_style_ride_acceptance_hash() { // TODO(sdk-v3): no test currently pins the node's hashing against externally-produced diff --git a/tests/mint_burn.rs b/tests/mint_burn.rs index 50b130b..d6f8d73 100644 --- a/tests/mint_burn.rs +++ b/tests/mint_burn.rs @@ -1,4 +1,6 @@ use clutch_node::node::blockchain::Blockchain; +use clutch_node::node::blocks::block::Block; +use clutch_node::node::database::Database; use clutch_node::node::transactions::burn::Burn; use clutch_node::node::transactions::chain_init::ChainInit; use clutch_node::node::transactions::function_call::FunctionCall; @@ -190,7 +192,12 @@ fn author_own_tx_pays_no_fee() { } const REF_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -const TX_FEE: u64 = 1000; + +// Second sender for multi-sender block tests: DRIVER, a genuinely distinct matched keypair +// already used elsewhere in this suite (tests/balance_effects.rs) — the address is derived +// from the secret key via secp256k1, so it can't be picked independently of it. +const SECOND_PK: &str = "0x8f19077627cde4848b090c53c83b12956837d5e9"; +const SECOND_SK: &str = "e74e3f87268132c7b3ddb24600716fc362f4519bf9986a9436aa8a1be58c7150"; fn signed_burn(sk: &str, from: &str, nonce: u64, amount: u64, redemption_ref: Option<&str>) -> Transaction { let mut tx = Transaction::new_transaction( @@ -220,7 +227,7 @@ fn burn_reduces_balance_and_supply() { assert_eq!( chain.get_account_balance(&FAUCET_PK.to_string()), - faucet_before - 2_000_000 - TX_FEE, + faucet_before - 2_000_000 - ci().tx_fee, "burner pays amount + fee" ); let (_, supply1) = chain.get_chain_info().unwrap(); @@ -229,7 +236,7 @@ fn burn_reduces_balance_and_supply() { // this is what makes the supply delta above "amount only" instead of "amount + fee". assert_eq!( chain.get_account_balance(&AUTHOR_PK.to_string()), - author_before + TX_FEE, + author_before + ci().tx_fee, "burn fee is credited to the block author, not destroyed" ); chain.shutdown_blockchain(); @@ -243,7 +250,12 @@ fn duplicate_redemption_ref_rejected() { chain.add_transaction_to_pool(&b1).unwrap(); chain.author_new_block().unwrap(); let b2 = signed_burn(FAUCET_SK, FAUCET_PK, 2, 100, Some(REF_B)); - assert!(chain.add_transaction_to_pool(&b2).is_err()); + let err = chain.add_transaction_to_pool(&b2).unwrap_err(); + assert!( + err.contains("redemption_ref") && err.contains("already processed"), + "exactly-once burn: {}", + err + ); chain.shutdown_blockchain(); } @@ -253,10 +265,49 @@ fn burn_more_than_balance_rejected() { let mut chain = chain("test-burn-overdraw"); let balance = chain.get_account_balance(&FAUCET_PK.to_string()); let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, balance, None); // no headroom for fee - assert!(chain.add_transaction_to_pool(&burn).is_err()); + let err = chain.add_transaction_to_pool(&burn).unwrap_err(); + assert!( + err.contains("insufficient balance for amount + fee"), + "got: {}", + err + ); chain.shutdown_blockchain(); } +#[test] +#[serial] +fn burn_rejects_zero_and_bad_ref() { + let mut chain = chain("test-burn-bad"); + let zero = signed_burn(FAUCET_SK, FAUCET_PK, 1, 0, Some(REF_B)); + let err = chain.add_transaction_to_pool(&zero).unwrap_err(); + assert!(err.contains("amount must be positive"), "got: {}", err); + let bad_ref = signed_burn(FAUCET_SK, FAUCET_PK, 1, 100, Some("not-hex")); + let err = chain.add_transaction_to_pool(&bad_ref).unwrap_err(); + assert!(err.contains("redemption_ref must be 64 lowercase hex chars"), "got: {}", err); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn burn_rejects_amount_over_i64_max() { + // Unlike Mint (fee-exempt, so `verify_state` is the first thing to see the amount), an + // over-max Burn can never reach this guard through the pool: `validate_transaction` + // checks `amount + fee` against the balance first, and no balance can exceed the + // i64::MAX supply cap, so it is always rejected for insufficient balance. The guard + // still has to exist — `state_transaction` casts `-(amount as i64)`, which for + // amount > i64::MAX wraps to a *credit* — so pin it by calling `verify_state` directly + // (same direct-DB pattern as tests/chain_init.rs). + let name = "test-burn-overflow"; + let db = Database::new_db(name); + let err = Burn { amount: i64::MAX as u64 + 1, redemption_ref: None } + .verify_state(&FAUCET_PK.to_string(), &db) + .unwrap_err(); + assert!(err.contains("amount exceeds i64::MAX"), "got: {}", err); + let mut db = db; + db.close(); + db.delete_database(name).unwrap(); +} + #[test] #[serial] fn plain_burn_without_ref_works() { @@ -278,43 +329,56 @@ fn two_burns_from_different_senders_in_one_block_reduce_supply_by_sum() { let mut chain = chain("test-burn-two-senders"); let (_, supply0) = chain.get_chain_info().unwrap(); - // Second sender: DRIVER, a genuinely distinct matched keypair already used elsewhere - // in this suite (tests/balance_effects.rs) — the address is derived from the secret - // key via secp256k1, so it can't be picked independently of it. - let second_user = "0x8f19077627cde4848b090c53c83b12956837d5e9"; - let second_sk = "e74e3f87268132c7b3ddb24600716fc362f4519bf9986a9436aa8a1be58c7150"; - // Fund the second sender via Mint (fee-exempt authority credit, single balance write). let fund = signed_mint( - AUTHOR_SK, AUTHOR_PK, 1, second_user, 1_000_000, + AUTHOR_SK, AUTHOR_PK, 1, SECOND_PK, 1_000_000, "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", ); chain.add_transaction_to_pool(&fund).unwrap(); chain.author_new_block().unwrap(); let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); - let second_before = chain.get_account_balance(&second_user.to_string()); + let second_before = chain.get_account_balance(&SECOND_PK.to_string()); + let author_before = chain.get_account_balance(&AUTHOR_PK.to_string()); assert_eq!(second_before, 1_000_000); // Two Burns from two distinct senders, same block. Both are first-ever txs from their // sender in this fresh chain (the funding Mint above was sent by AUTHOR_PK, not either - // burner), so both start at nonce 1. + // burner), so both start at nonce 1. Neither carries a redemption_ref: `None` is the + // absence of a ref, so the block-level ref-uniqueness check must not treat two of them + // as a collision — this is also the plain-burn path's guard against that regression. let burn_faucet = signed_burn(FAUCET_SK, FAUCET_PK, 1, 300_000, None); - let burn_second = signed_burn(second_sk, second_user, 1, 400_000, None); + let burn_second = signed_burn(SECOND_SK, SECOND_PK, 1, 400_000, None); chain.add_transaction_to_pool(&burn_faucet).unwrap(); chain.add_transaction_to_pool(&burn_second).unwrap(); - chain.author_new_block().unwrap(); + let block = chain.author_new_block().unwrap(); + + // Without this the test would still pass in shapes where only one burn landed and the + // supply arithmetic happened to agree — the accumulation loop is only exercised at 2. + assert_eq!( + block.transactions.len(), + 2, + "both ref-less burns must share the block for the supply sum to mean anything" + ); assert_eq!( chain.get_account_balance(&FAUCET_PK.to_string()), - faucet_before - 300_000 - TX_FEE, + faucet_before - 300_000 - ci().tx_fee, "faucet burner pays amount + fee" ); assert_eq!( - chain.get_account_balance(&second_user.to_string()), - second_before - 400_000 - TX_FEE, + chain.get_account_balance(&SECOND_PK.to_string()), + second_before - 400_000 - ci().tx_fee, "second burner pays amount + fee" ); + // Two fees aggregate into the author's single folded balance write (block.rs) — this is + // the only test with more than one fee-paying tx in a block, so nothing else can catch + // a collapse to one fee. + assert_eq!( + chain.get_account_balance(&AUTHOR_PK.to_string()), + author_before + 2 * ci().tx_fee, + "author earns both fees, not just the last one folded in" + ); let (_, supply1) = chain.get_chain_info().unwrap(); assert_eq!( @@ -324,3 +388,101 @@ fn two_burns_from_different_senders_in_one_block_reduce_supply_by_sum() { ); chain.shutdown_blockchain(); } + +/// Signs a block the way `author_new_block` does, so `import_block` sees a well-formed block +/// whose only defect is its transaction set — the point being that block *validation*, not +/// the authoring filter, is what has to reject it (a hostile author skips the filter). +fn forged_block(chain: &Blockchain, transactions: Vec) -> Block { + let latest = chain.get_latest_block().unwrap().unwrap(); + let mut block = Block::new_block(latest.index + 1, latest.hash, transactions); + block.sign(AUTHOR_PK, AUTHOR_SK); + block +} + +#[test] +#[serial] +fn same_redemption_ref_from_two_senders_cannot_share_a_block() { + // Mint's same-block exactly-once property rode on the duplicate-sender guard: every + // Mint is signed by the one authority, so two Mints in a block are the same sender and + // get rejected there. Burn is permissionless, so two *different* senders can each carry + // the same redemption_ref: both validate against pre-block state (the marker only lands + // when the block commits) and their two identical `processed_ref_{ref}` writes collapse + // to one in the deferred batch. Pool txs are re-gossiped, so a pending ref is public — + // an attacker burns dust carrying Alice's ref and the treasury watcher sees two + // confirmed burns claiming one redemption intent, for the price of the dust plus a fee. + let mut chain = chain("test-burn-same-ref"); + let fund = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, SECOND_PK, 1_000_000, REF_A); + chain.add_transaction_to_pool(&fund).unwrap(); + chain.author_new_block().unwrap(); + + let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); + let (_, supply0) = chain.get_chain_info().unwrap(); + + // Alice's genuine redemption, and the attacker's 1-micro-dollar burn claiming her ref. + // Both are individually valid against committed state, so the pool accepts both. + let alice = signed_burn(FAUCET_SK, FAUCET_PK, 1, 300_000, Some(REF_B)); + let attacker = signed_burn(SECOND_SK, SECOND_PK, 1, 1, Some(REF_B)); + chain.add_transaction_to_pool(&alice).unwrap(); + chain.add_transaction_to_pool(&attacker).unwrap(); + + // Consensus layer: a block carrying both is invalid however it was assembled. + let forged = forged_block(&chain, vec![alice.clone(), attacker.clone()]); + let err = chain.import_block(&forged).unwrap_err(); + assert!( + err.contains(REF_B), + "block with two burns claiming one ref must be rejected, naming the ref: {}", + err + ); + + // Authoring layer: the loser is filtered out so the node still makes progress, and the + // winner is picked by nonce-then-hash order — identically on every node. + let block = chain.author_new_block().unwrap(); + assert_eq!(block.transactions.len(), 1, "only one claim on the ref may land"); + let winner = if alice.hash <= attacker.hash { &alice } else { &attacker }; + assert_eq!( + block.transactions[0].hash, winner.hash, + "deterministic winner: lowest nonce, tie-broken by hash" + ); + assert_eq!( + chain.get_transactions_from_pool().unwrap().len(), + 1, + "the loser stays pooled, it did not land" + ); + + // Exactly one of the two burns actually moved money. + let (_, supply1) = chain.get_chain_info().unwrap(); + let landed = match &winner.data { + FunctionCall::Burn(b) => b.amount, + _ => unreachable!(), + }; + assert_eq!(supply1, supply0 - landed, "one burn's worth of supply destroyed, not two"); + if winner.hash == attacker.hash { + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before, + "Alice's burn did not land, so her balance is untouched" + ); + } + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn mint_and_burn_sharing_a_ref_cannot_share_a_block() { + // `processed_ref_{ref}` is one namespace across both types by design, so a Mint and a + // Burn claiming the same ref collide exactly like two Burns — and being different + // senders (authority vs. user), the duplicate-sender guard never sees them. + let mut chain = chain("test-mint-burn-same-ref"); + let mint = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, 100, REF_A); + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, 100, Some(REF_A)); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.add_transaction_to_pool(&burn).unwrap(); + + let forged = forged_block(&chain, vec![mint.clone(), burn.clone()]); + let err = chain.import_block(&forged).unwrap_err(); + assert!(err.contains(REF_A), "cross-type ref collision must be rejected: {}", err); + + let block = chain.author_new_block().unwrap(); + assert_eq!(block.transactions.len(), 1, "authoring keeps one claim on the ref"); + chain.shutdown_blockchain(); +} From 0e260e10588081f8d3f91671dcc9bd5427d5d19d Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 11:07:58 +0400 Subject: [PATCH 17/24] feat: get_chain_info RPC exposing chain params and total supply Treasury reconciliation's on-chain supply source; hub faucet's testnet-flag check. Co-Authored-By: Claude Fable 5 --- src/node/blockchain.rs | 1 - src/node/wss/websocket.rs | 34 ++++++++++++++++++++++ tests/chain_genesis.rs | 59 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index 6de8103..e31c610 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -82,7 +82,6 @@ impl Blockchain { } /// Consensus params + total supply, read from state (post-genesis truth). - #[allow(dead_code)] // consumer: Task 8's get_chain_info JSON-RPC method pub fn get_chain_info(&self) -> Result<(ChainInit, u64), String> { let params = ChainInit::get(&self.db)?; let supply = ChainInit::get_total_supply(&self.db)?; diff --git a/src/node/wss/websocket.rs b/src/node/wss/websocket.rs index 3fbad57..e2d65d8 100644 --- a/src/node/wss/websocket.rs +++ b/src/node/wss/websocket.rs @@ -132,6 +132,9 @@ impl WebSocket { "get_block_by_index" => { Self::handle_get_block_by_index(params, id, blockchain).await } + "get_chain_info" => { + Self::handle_get_chain_info(id, blockchain).await + } "list_ride_requests" => { Self::handle_list_ride_requests(params, id, blockchain).await } @@ -420,6 +423,37 @@ impl WebSocket { } } + async fn handle_get_chain_info( + id: serde_json::Value, + blockchain: &Arc>, + ) -> Option { + let blockchain = blockchain.lock().await; + let latest_index = match blockchain.get_latest_block() { + Ok(Some(b)) => b.index, + _ => 0, + }; + match blockchain.get_chain_info() { + Ok((params, total_supply)) => Some(json_rpc_success_response( + serde_json::json!({ + "chain_id": params.chain_id, + "is_testnet": params.is_testnet, + "tx_fee": params.tx_fee, + "ride_request_referrer_fee_bps": params.ride_request_referrer_fee_bps, + "ride_offer_referrer_fee_bps": params.ride_offer_referrer_fee_bps, + "mint_authority": params.mint_authority, + "total_supply": total_supply, + "latest_block_index": latest_index, + }), + id, + )), + Err(e) => { + let error_msg = format!("Failed to get chain info: {}", e); + error!("{}", error_msg); + Some(json_rpc_error_response(-32000, &error_msg, id)) + } + } + } + async fn handle_list_ride_requests( params: serde_json::Value, id: serde_json::Value, diff --git a/tests/chain_genesis.rs b/tests/chain_genesis.rs index d5921f6..3da0929 100644 --- a/tests/chain_genesis.rs +++ b/tests/chain_genesis.rs @@ -117,3 +117,62 @@ fn wrong_chain_id_rejected_at_pool() { assert!(err.contains("does not match chain"), "got: {}", err); chain.shutdown_blockchain(); } + +#[test] +#[serial] +fn chain_info_supply_tracks_mint_and_burn() { + use clutch_node::node::transactions::burn::Burn; + use clutch_node::node::transactions::function_call::FunctionCall; + use clutch_node::node::transactions::mint::Mint; + use clutch_node::node::transactions::transaction::Transaction; + + const AUTHOR_SK: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; + const FAUCET_SK: &str = "d2c446110cfcecbdf05b2be528e72483de5b6f7ef9c7856df2f81f48e9f2748f"; + const USER: &str = "0x4444444444444444444444444444444444444444"; + + let ci = test_chain_init(); // chain_id 2077, mint_authority = author, testnet + let mut chain = new_test_chain("test-supply-e2e", ci.clone()); + + let (_, supply_genesis) = chain.get_chain_info().unwrap(); + assert_eq!(supply_genesis, ci.faucet_allocation); + + // Mint block: +5_000_000 to USER. + let mut mint = Transaction::new_transaction( + ci.mint_authority.clone(), + 1, + ci.chain_id, + FunctionCall::Mint(Mint { + to: USER.to_string(), + amount: 5_000_000, + credit_ref: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + .to_string(), + }), + ); + mint.sign(AUTHOR_SK); + chain.add_transaction_to_pool(&mint).unwrap(); + chain.author_new_block().unwrap(); + + let (_, supply_after_mint) = chain.get_chain_info().unwrap(); + assert_eq!(supply_after_mint, supply_genesis + 5_000_000); + + // Burn block: faucet burns 2_000_000 (fee moves CLT, supply drops by burn only). + let mut burn = Transaction::new_transaction( + ci.faucet_address.clone(), + 1, + ci.chain_id, + FunctionCall::Burn(Burn { + amount: 2_000_000, + redemption_ref: Some( + "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".to_string(), + ), + }), + ); + burn.sign(FAUCET_SK); + chain.add_transaction_to_pool(&burn).unwrap(); + chain.author_new_block().unwrap(); + + let (_, supply_after_burn) = chain.get_chain_info().unwrap(); + assert_eq!(supply_after_burn, supply_after_mint - 2_000_000); + + chain.shutdown_blockchain(); +} From 3ae4e31632b4339cff8034a9adf13d82174a00eb Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 11:17:59 +0400 Subject: [PATCH 18/24] fix: encode total_supply as a string; distinguish read error from empty chain total_supply is the one get_chain_info field that can exceed 2^53 (~$9B at this peg), and the treasury's reconciliation job treats a supply mismatch as a P1 - a silently rounded number would fabricate or mask one. The other numeric fields cannot approach that bound and stay bare. A failed latest-block read no longer reports as a fresh chain. Co-Authored-By: Claude Fable 5 --- src/node/wss/websocket.rs | 18 ++++++++++++++++-- tests/chain_genesis.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/node/wss/websocket.rs b/src/node/wss/websocket.rs index e2d65d8..8a80423 100644 --- a/src/node/wss/websocket.rs +++ b/src/node/wss/websocket.rs @@ -430,7 +430,14 @@ impl WebSocket { let blockchain = blockchain.lock().await; let latest_index = match blockchain.get_latest_block() { Ok(Some(b)) => b.index, - _ => 0, + // Genuinely empty chain: no block yet, 0 is correct. + Ok(None) => 0, + // A read failure is not a fresh chain: falling back to 0 here would report + // a corrupt/unreadable DB as a healthy freshly-initialized one to the treasury. + Err(e) => { + warn!("get_chain_info: failed to read latest block: {}", e); + 0 + } }; match blockchain.get_chain_info() { Ok((params, total_supply)) => Some(json_rpc_success_response( @@ -441,7 +448,14 @@ impl WebSocket { "ride_request_referrer_fee_bps": params.ride_request_referrer_fee_bps, "ride_offer_referrer_fee_bps": params.ride_offer_referrer_fee_bps, "mint_authority": params.mint_authority, - "total_supply": total_supply, + // Decimal string, not a bare number: total_supply is the one field here + // that can realistically exceed 2^53 (~9.007e15, ~$9B at this peg's 1 + // USD = 1,000,000 CLT), where a JSON number rounds silently and the + // treasury's daily reconciliation treats a supply mismatch as a P1 - a + // silent round would fabricate or mask one. The other fields (chain_id, + // tx_fee, both bps rates, latest_block_index) can't approach that bound + // (a block/sec needs ~285M years to get there), so they stay bare numbers. + "total_supply": total_supply.to_string(), "latest_block_index": latest_index, }), id, diff --git a/tests/chain_genesis.rs b/tests/chain_genesis.rs index 3da0929..4ffd780 100644 --- a/tests/chain_genesis.rs +++ b/tests/chain_genesis.rs @@ -176,3 +176,34 @@ fn chain_info_supply_tracks_mint_and_burn() { chain.shutdown_blockchain(); } + +/// Cross-repo contract pin: the get_chain_info RPC must encode `total_supply` as a +/// JSON string (it's the one field that can exceed 2^53 and the treasury reconciliation +/// job treats a rounded value as a P1), while `chain_id` and friends stay bare numbers. +/// This asserts the same serde_json::json! shape the WS handler builds, since nothing +/// else here pins the wire encoding and it is exactly the kind of detail that drifts +/// silently if the handler is ever "simplified" back to a bare number. +#[test] +#[serial] +fn chain_info_json_encodes_total_supply_as_string() { + let ci = test_chain_init(); + let mut chain = new_test_chain("test-genesis-json-shape", ci.clone()); + let (params, total_supply) = chain.get_chain_info().unwrap(); + + let response = serde_json::json!({ + "chain_id": params.chain_id, + "total_supply": total_supply.to_string(), + }); + + assert!( + response["total_supply"].is_string(), + "total_supply must be a JSON string to avoid precision loss past 2^53" + ); + assert_eq!(response["total_supply"], total_supply.to_string()); + assert!( + response["chain_id"].is_number(), + "chain_id cannot approach 2^53 and should stay a bare number" + ); + + chain.shutdown_blockchain(); +} From cc4e36b6cbe27ac59097bb2a427b0ac5b3364fdf Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 11:19:16 +0400 Subject: [PATCH 19/24] docs: correct plan's get_chain_info shape to stringify total_supply The plan specified a bare JSON number for total_supply, which is the precision-loss shape the field cannot afford: it is the one value here that passes 2^53 (~$9B at this peg) and a rounded supply either fabricates or masks a reconciliation P1. Other numeric fields stay bare. Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-27-treasury-node-break.md | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-07-27-treasury-node-break.md b/docs/superpowers/plans/2026-07-27-treasury-node-break.md index 50494c0..b36b93c 100644 --- a/docs/superpowers/plans/2026-07-27-treasury-node-break.md +++ b/docs/superpowers/plans/2026-07-27-treasury-node-break.md @@ -2182,12 +2182,16 @@ Co-Authored-By: Claude Fable 5 " { "chain_id": 2077, "is_testnet": true, "tx_fee": 1000, "ride_request_referrer_fee_bps": 200, "ride_offer_referrer_fee_bps": 200, - "mint_authority": "0x...", "total_supply": 1000000000000000, + "mint_authority": "0x...", "total_supply": "1000000000000000", "latest_block_index": 42 } ``` -- **Cross-repo contract:** Treasury reconciliation reads `total_supply` here; hub-api faucet reads `is_testnet`/`chain_id` to fail loudly on non-testnet chains. +**`total_supply` is a decimal STRING; every other numeric field stays a bare number.** Why, and why this is deliberately not uniform: `total_supply` is the only field that can realistically exceed 2^53 (≈9.007e15) — about $9B circulating at this peg. A JSON number rounds silently past that, and the treasury's reconciliation job treats a supply mismatch as a P1 incident, so a rounded value either fabricates an incident or masks a real one. `chain_id`, `tx_fee`, the bps rates, and `latest_block_index` cannot approach 2^53 (one block per second needs ~285 million years), so stringifying them buys nothing and costs every caller a parse. + +`get_account_balance` returns a bare-number `balance` and is deliberately left alone — it has working Rust consumers and changing it is outside this task — but it carries the same latent exposure once an account can hold more than ~$9B, and is tracked in the cross-repo follow-ups. + +- **Cross-repo contract:** Treasury reconciliation reads `total_supply` here (as a string — parse it, don't coerce); hub-api faucet reads `is_testnet`/`chain_id` to fail loudly on non-testnet chains. - [ ] **Step 1: Handler** — `websocket.rs`, add match arm after `get_block_by_index`: @@ -2207,7 +2211,13 @@ and the handler (mirror `handle_get_account_balance`'s shape): let blockchain = blockchain.lock().await; let latest_index = match blockchain.get_latest_block() { Ok(Some(b)) => b.index, - _ => 0, + // A read failure is not the same as a fresh chain; report 0 but say so, or a + // corrupt DB looks like a healthy empty one to the treasury. + Ok(None) => 0, + Err(e) => { + warn!("get_chain_info: failed to read latest block: {}", e); + 0 + } }; match blockchain.get_chain_info() { Ok((params, total_supply)) => Some(json_rpc_success_response( @@ -2218,7 +2228,9 @@ and the handler (mirror `handle_get_account_balance`'s shape): "ride_request_referrer_fee_bps": params.ride_request_referrer_fee_bps, "ride_offer_referrer_fee_bps": params.ride_offer_referrer_fee_bps, "mint_authority": params.mint_authority, - "total_supply": total_supply, + // Decimal string: this is the one field that can pass 2^53, and a + // reconciliation job treats a rounded supply as a P1. See the note above. + "total_supply": total_supply.to_string(), "latest_block_index": latest_index, }), id, From 70b71ca741139ab618b48ca2ce9c23727ca9f599 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 11:26:05 +0400 Subject: [PATCH 20/24] test: pin the real get_chain_info response shape The previous test built its own JSON and asserted a string it had just created was a string, so a handler regressing to a bare number would not have failed it. Extracts the response builder and asserts against that. Co-Authored-By: Claude Fable 5 --- src/node/wss/websocket.rs | 45 ++++++++++++++++++++------------ tests/chain_genesis.rs | 54 +++++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 27 deletions(-) diff --git a/src/node/wss/websocket.rs b/src/node/wss/websocket.rs index 8a80423..35acae8 100644 --- a/src/node/wss/websocket.rs +++ b/src/node/wss/websocket.rs @@ -441,23 +441,7 @@ impl WebSocket { }; match blockchain.get_chain_info() { Ok((params, total_supply)) => Some(json_rpc_success_response( - serde_json::json!({ - "chain_id": params.chain_id, - "is_testnet": params.is_testnet, - "tx_fee": params.tx_fee, - "ride_request_referrer_fee_bps": params.ride_request_referrer_fee_bps, - "ride_offer_referrer_fee_bps": params.ride_offer_referrer_fee_bps, - "mint_authority": params.mint_authority, - // Decimal string, not a bare number: total_supply is the one field here - // that can realistically exceed 2^53 (~9.007e15, ~$9B at this peg's 1 - // USD = 1,000,000 CLT), where a JSON number rounds silently and the - // treasury's daily reconciliation treats a supply mismatch as a P1 - a - // silent round would fabricate or mask one. The other fields (chain_id, - // tx_fee, both bps rates, latest_block_index) can't approach that bound - // (a block/sec needs ~285M years to get there), so they stay bare numbers. - "total_supply": total_supply.to_string(), - "latest_block_index": latest_index, - }), + build_chain_info_response(¶ms, total_supply, latest_index), id, )), Err(e) => { @@ -678,3 +662,30 @@ fn json_rpc_success_response(result: serde_json::Value, id: serde_json::Value) - }) .to_string() } + +// Builds the JSON response body for get_chain_info RPC, ensuring total_supply +// is encoded as a decimal string (never a bare number) to avoid precision loss +// past 2^53. Other fields stay as bare numbers. +pub fn build_chain_info_response( + params: &crate::node::transactions::chain_init::ChainInit, + total_supply: u64, + latest_block_index: usize, +) -> serde_json::Value { + serde_json::json!({ + "chain_id": params.chain_id, + "is_testnet": params.is_testnet, + "tx_fee": params.tx_fee, + "ride_request_referrer_fee_bps": params.ride_request_referrer_fee_bps, + "ride_offer_referrer_fee_bps": params.ride_offer_referrer_fee_bps, + "mint_authority": params.mint_authority, + // Decimal string, not a bare number: total_supply is the one field here + // that can realistically exceed 2^53 (~9.007e15, ~$9B at this peg's 1 + // USD = 1,000,000 CLT), where a JSON number rounds silently and the + // treasury's daily reconciliation treats a supply mismatch as a P1 - a + // silent round would fabricate or mask one. The other fields (chain_id, + // tx_fee, both bps rates, latest_block_index) can't approach that bound + // (a block/sec needs ~285M years to get there), so they stay bare numbers. + "total_supply": total_supply.to_string(), + "latest_block_index": latest_block_index, + }) +} diff --git a/tests/chain_genesis.rs b/tests/chain_genesis.rs index 4ffd780..2cf47b6 100644 --- a/tests/chain_genesis.rs +++ b/tests/chain_genesis.rs @@ -180,30 +180,64 @@ fn chain_info_supply_tracks_mint_and_burn() { /// Cross-repo contract pin: the get_chain_info RPC must encode `total_supply` as a /// JSON string (it's the one field that can exceed 2^53 and the treasury reconciliation /// job treats a rounded value as a P1), while `chain_id` and friends stay bare numbers. -/// This asserts the same serde_json::json! shape the WS handler builds, since nothing -/// else here pins the wire encoding and it is exactly the kind of detail that drifts -/// silently if the handler is ever "simplified" back to a bare number. +/// Tests the real builder that the WS handler calls, not a mock of it. #[test] #[serial] fn chain_info_json_encodes_total_supply_as_string() { + use clutch_node::node::wss::websocket::build_chain_info_response; + let ci = test_chain_init(); let mut chain = new_test_chain("test-genesis-json-shape", ci.clone()); let (params, total_supply) = chain.get_chain_info().unwrap(); + let latest_block_index = match chain.get_latest_block().unwrap() { + Some(b) => b.index, + None => 0, + }; - let response = serde_json::json!({ - "chain_id": params.chain_id, - "total_supply": total_supply.to_string(), - }); + let response = build_chain_info_response(¶ms, total_supply, latest_block_index); + // total_supply must be a JSON string to avoid precision loss past 2^53. assert!( response["total_supply"].is_string(), - "total_supply must be a JSON string to avoid precision loss past 2^53" + "total_supply must be a JSON string, got: {:?}", + response["total_supply"] ); - assert_eq!(response["total_supply"], total_supply.to_string()); + assert_eq!( + response["total_supply"].as_str().unwrap(), + &total_supply.to_string(), + "total_supply value mismatch" + ); + + // Other numeric fields must be bare JSON numbers. assert!( response["chain_id"].is_number(), - "chain_id cannot approach 2^53 and should stay a bare number" + "chain_id must be a bare JSON number, got: {:?}", + response["chain_id"] + ); + assert_eq!(response["chain_id"].as_u64().unwrap(), params.chain_id as u64); + + assert!( + response["is_testnet"].is_boolean(), + "is_testnet must be boolean" ); + assert_eq!(response["is_testnet"].as_bool().unwrap(), params.is_testnet); + + assert!( + response["tx_fee"].is_number(), + "tx_fee must be a bare JSON number" + ); + assert_eq!(response["tx_fee"].as_u64().unwrap(), params.tx_fee); + + assert!( + response["latest_block_index"].is_number(), + "latest_block_index must be a bare JSON number" + ); + assert_eq!(response["latest_block_index"].as_u64().unwrap(), latest_block_index as u64); + + // Ensure all expected fields are present. + assert!(response.get("mint_authority").is_some(), "mint_authority missing"); + assert!(response.get("ride_request_referrer_fee_bps").is_some(), "ride_request_referrer_fee_bps missing"); + assert!(response.get("ride_offer_referrer_fee_bps").is_some(), "ride_offer_referrer_fee_bps missing"); chain.shutdown_blockchain(); } From a379fdeddc6c95c27f684e729e8efefdf0c7db90 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 11:54:03 +0400 Subject: [PATCH 21/24] docs: state keys, CLAUDE.md, and stack smoke for treasury node break Registers chain_params, total_supply and processed_ref_{64-hex}. Updates the tx-type list (Mint 6, Burn 7, ChainInit 9 genesis-only), the RPC list (get_chain_info, with total_supply as a decimal string), and the config section (new consensus params, block_reward_amount removed, percent->bps). Documents the author fee-credit merge and corrects the stale claim that genesis funds the faucet with i64::MAX. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 15 +++++++++------ docs/state_keys.csv | 3 +++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1b0a3d1..fb60cda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,25 +28,27 @@ Rust node implementing Aura (Proof-of-Authority) consensus, custom RLP-encoded t 1. Signed tx arrives via WS RPC (`send_transaction` JSON or `send_raw_transaction` hex RLP) or via gossipsub (`gossipsub_handler.rs`). 2. `Blockchain::add_transaction_to_pool` → `Transaction::validate_transaction`: signature (recover & compare to `from`), nonce (`== last + 1`), then per-type `verify_state` (e.g. RideRequest checks balance ≥ fare and no concurrent open request via `passenger_concurrent.rs`). Valid txs land in the `tx_pool` CF and are re-gossiped. 3. Authoring loop (`node_services.rs::start_authoring_job`, every 1s) calls `author_new_block`: drains pool, builds+signs block, then `import_block`. Aura rejects it unless this node is the current slot's author, so most ticks are no-ops (`Err` logged at debug). -4. `import_block` = `verify_block_author` (Aura slot check) + `validate_block` (sig, index, prev_hash) + re-validate all txs + `Block::add_block_to_chain`, which batches into one `db.write()`: block, latest-block pointer, per-tx state updates (`state_transaction`), balance effects, block reward mint, tx_pool deletions. Accepted blocks are gossiped; peers import the same way. +4. `import_block` = `verify_block_author` (Aura slot check) + `validate_block` (sig, index, prev_hash) + re-validate all txs + `Block::add_block_to_chain`, which batches into one `db.write()`: block, latest-block pointer, per-tx state updates (`state_transaction`), balance effects, one aggregate `tx_fee` credit to the block author, `total_supply` delta from any Mint/Burn, tx_pool deletions. Accepted blocks are gossiped; peers import the same way. 5. Sync: on startup, a node sends an RLP `Handshake` to the first connected peer, then pulls `GetBlockHeaders`/`GetBlockBodies` over libp2p request-response. ## Transaction Types -`FunctionCall` enum in `src/node/transactions/function_call.rs`: Transfer, RideRequest, RideOffer, RideAcceptance, RidePay, RideCancel, RideRequestCancel. Each variant's struct file defines `verify_state` (validation) and `state_transaction` (state writes + balance effects). To add a type: new file + enum variant, wire `verify_state`/`state_transaction`/`function_call_type` matches in `transaction.rs`, and add RLP tag arms in `rlp_encoding.rs`. **RLP tags are not contiguous** — RideRequestCancel is tag `8` (6–7 skipped); tags must match the JS SDK's encoder exactly. +`FunctionCall` enum in `src/node/transactions/function_call.rs`: Transfer, RideRequest, RideOffer, RideAcceptance, RidePay, RideCancel, Mint, Burn, RideRequestCancel, ChainInit. Each variant's struct file defines `verify_state` (validation) and `state_transaction` (state writes + balance effects). To add a type: new file + enum variant, wire `verify_state`/`state_transaction`/`function_call_type` matches in `transaction.rs`, and add RLP tag arms in `rlp_encoding.rs`. **RLP tags are not contiguous**: RideRequestCancel is tag `8`, Mint is tag `6`, Burn is tag `7`; tags must match the JS SDK's encoder exactly. **ChainInit is tag `9` and genesis-only** — it carries consensus parameters (`chain_id`, `is_testnet`, `tx_fee`, `mint_authority`, faucet allocation, referrer-fee bps) into state at block 0 and is rejected by `verify_state` at any other height. ## RPC (WebSocket JSON-RPC 2.0) -All methods are matched by string in `WebSocket::handle_json_rpc_request` in `src/node/wss/websocket.rs`. Current methods: `send_transaction`, `send_raw_transaction`, `import_block`, `author_new_block`, `get_next_nonce`, `get_account_balance`, `get_account_balance_effects`, `get_block_by_index`, `list_ride_requests`, `list_ride_offers`, `list_active_trips`, `list_completed_trips`, `list_recent_trips`. To add one: write a `handle_*` fn (parse params with an inline serde struct, lock `blockchain`, return `json_rpc_success_response`/`json_rpc_error_response`), add a match arm, expose any new query on `Blockchain`, then update clutch-hub-api → SDK → docs per workspace convention. +All methods are matched by string in `WebSocket::handle_json_rpc_request` in `src/node/wss/websocket.rs`. Current methods: `send_transaction`, `send_raw_transaction`, `get_next_nonce`, `get_account_balance`, `get_account_balance_effects`, `get_block_by_index`, `get_chain_info`, `list_ride_requests`, `list_ride_offers`, `list_active_trips`, `list_completed_trips`, `list_recent_trips`. (`import_block`/`author_new_block` are `Blockchain` facade methods, not RPC-exposed strings — see Source Layout above.) `get_chain_info` returns the genesis-committed consensus params plus `total_supply` and `latest_block_index`; `total_supply` is serialized as a **decimal string**, not a bare JSON number — it's the one field that can exceed 2^53 and lose precision in JS. To add a new method: write a `handle_*` fn (parse params with an inline serde struct, lock `blockchain`, return `json_rpc_success_response`/`json_rpc_error_response`), add a match arm, expose any new query on `Blockchain`, then update clutch-hub-api → SDK → docs per workspace convention. ## Config - Files: `config/node/{default,node1,node2,node3}.toml`, selected by `--env ` (default `default`). Env overrides use `APP_` prefix (e.g. `APP_LOG_LEVEL`); `.env` is loaded via dotenv. Config path is **relative to cwd** — run from the repo root. - `default` ≈ node1 (authority 1, ws 8081, p2p 4001, metrics 3001, no bootstrap, local Seq). node2/node3 differ in: `blockchain_name` (separate DB dir), author keypair (authorities 2/3), ports (8082/4002/3002, 8083/4003/3003), and `bootstrap_nodes` — `/ip4/127.0.0.1/tcp/4001` (node1 on the same host; mdns also discovers local peers). - This repo's `docker-compose.yml` uses `node2-docker.toml`/`node3-docker.toml` (`--env node2-docker`), which bootstrap via `/dns4/node1/tcp/4001` — env override is not an option because `bootstrap_nodes` is a `Vec` and the config loader does no list parsing. clutch-deploy mounts its own config copies (`clutch-deploy/config/node/*.toml`, also `/dns4/node1/...`) and is unaffected by this repo's TOMLs. -- All three well-known authority keypairs (and the genesis-funded account `0xdeb4...6cc0` holding `i64::MAX`) are committed in configs/tests — dev-only keys. +- All three well-known authority keypairs (and the genesis-funded faucet account `0xdeb4...6cc0`, which now holds `faucet_allocation` — `1e15` base units, i.e. $1B at the 1 USD = 1,000,000 CLT peg — rather than the old `i64::MAX`) are committed in configs/tests — dev-only keys. - `developer_mode = true` deletes the RocksDB and dumps chain+pool JSON to `output/` on shutdown. - DB path: `{DB_PATH or cwd}/{blockchain_name}.db`. +- **Consensus params now live in config and are committed to state by the genesis `ChainInit` tx**, not hardcoded: `chain_id` (u64, e.g. `2077`), `is_testnet` (bool), `tx_fee` (u64, flat fee per tx paid to the block author), `mint_authority` (address allowed to sign `Mint`), `faucet_address`/`faucet_allocation` (genesis funding), `ride_request_referrer_fee_bps`/`ride_offer_referrer_fee_bps` (u16 basis points, floor-rounded — renamed from the old percent-based `fee_percent` fields). All three node configs must carry **identical** values for these or peers refuse the p2p handshake (genesis hash mismatch — see Gotchas). +- **`block_reward_amount` is removed** — block rewards no longer exist; the block author is now paid via `tx_fee` revenue instead (see Transaction Flow and the Gotchas fee-crediting note). ## Commands @@ -59,7 +61,7 @@ docker compose up -d # 3-node local net from ghcr image (this repo .\scripts\docker-build.ps1 # local image build ``` -- Tests in `tests/` (`ride_sharing.rs`, `block_reward.rs`, `balance_effects.rs`, `transfer.rs`, `referrer_account.rs`, `rlp_decode_test.rs`, `p2p_server_tests.rs`) hit **real RocksDB instances in the cwd**; DB-touching tests are `#[serial]` (serial_test crate) — keep that attribute on any new test that opens a database, and clean up via `blockchain.shutdown_blockchain()` (developer_mode). +- Tests in `tests/` (`ride_sharing.rs`, `author_block.rs`, `balance_effects.rs`, `transfer.rs`, `referrer_account.rs`, `rlp_decode_test.rs`, `p2p_server_tests.rs`, `chain_genesis.rs`, `chain_init.rs`, `mint_burn.rs`, `tx_fee.rs`, `db_error_handling.rs`) hit **real RocksDB instances in the cwd**; DB-touching tests are `#[serial]` (serial_test crate) — keep that attribute on any new test that opens a database, and clean up via `blockchain.shutdown_blockchain()` (developer_mode). - CI: `.github/workflows/docker-build-push.yml` builds multi-arch images to GHCR + Docker Hub on push to main / `v*` tags, then repository-dispatches `deploy-stage` to clutch-deploy. There is **no CI job running `cargo test`** — run tests locally before pushing. ## Gotchas / Conventions @@ -67,7 +69,8 @@ docker compose up -d # 3-node local net from ghcr image (this repo - Error handling is `Result<_, String>` everywhere (no anyhow/thiserror); DB read/write failures on hot paths (`get_latest_block`, `add_block_to_chain`) propagate as `Err`, not `panic!`. - **One transaction per account per block, and one claim per exactly-once ref per block.** Block state is validated then applied as one deferred RocksDB batch (commit at end of `add_block_to_chain`), so a second tx from the same account would validate/apply against stale pre-block state — two Transfers from one account mint CLT via last-write-wins on the balance key. The same staleness breaks exactly-once for the shared `processed_ref_{ref}` marker: two txs in one block bearing one ref (Mint's `credit_ref` or Burn's `redemption_ref`, one namespace) both see it unused and their two identical marker writes collapse. `validate_transactions` rejects a block with a duplicate sender (`first_duplicate_sender`) or a repeated ref (`first_duplicate_ref` — needed because Burn is permissionless, so the sender guard no longer implies ref uniqueness); `Blockchain::drop_intra_block_conflicts` enforces both at authoring time (losers wait for later blocks). Lift only once intra-block state is applied incrementally. - Logging via `tracing` macros; logs also ship to Seq (`seq_url`/`seq_api_key` in config). -- State keys are string-prefixed in the `state` CF: `account_state_{addr}`, `account_nonce_{addr}`, `ride_request_{hash}`, `ride_request_{hash}:ride_acceptance`, `ride_acceptance_{hash}:fare_paid`, `tx_effects_{hash}`, `block_effects_{height}`, `account_effect_{addr}_{reverse_height}...` — see `docs/state_keys.csv` and `balance_effect.rs`. +- State keys are string-prefixed in the `state` CF: `account_state_{addr}`, `account_nonce_{addr}`, `ride_request_{hash}`, `ride_request_{hash}:ride_acceptance`, `ride_acceptance_{hash}:fare_paid`, `tx_effects_{hash}`, `block_effects_{height}`, `account_effect_{addr}_{reverse_height}...`, plus three added by the treasury release: `chain_params` (the genesis `ChainInit`, the runtime's only source of consensus params), `total_supply` (u64, moved once per block by Mint/Burn), and `processed_ref_{64-hex}` (the exactly-once marker, one namespace shared by Mint and Burn). See `docs/state_keys.csv` and `balance_effect.rs`. +- **The block author's `tx_fee` credit merges into a pending write rather than being appended.** Because the batch is deferred, appending the aggregate credit after the per-tx loop made it the *last* write to the author's balance key — which silently discarded the author's own transaction in the same block (its debit vanished while the recipient kept the funds: unbacked CLT). `add_block_to_chain` therefore looks for an already-staged write to `account_state_{author}` and folds the fee into that value; a failed merge returns `Err` and aborts the import rather than falling back to appending. `effective_fee` separately returns 0 when the sender *is* the author, so no self-fee is charged. See `tests/tx_fee.rs::author_own_transaction_survives_the_block_fee_credit`. - Addresses: canonical form is `0x` + lowercase hex (`src/node/transactions/address.rs`); readers fall back to legacy no-prefix keys (`legacy_account_address_hex`) — preserve that dual-read when touching account state. - `Blockchain` is shared as `Arc>` (tokio Mutex) across the WS, p2p, authoring, and sync tasks; other tasks talk to the libp2p swarm only through `P2PServerCommand` over an mpsc channel. - Gossip payloads are `[1-byte GossipMessageType (0x01 tx, 0x02 block)] + RLP bytes` (`p2p_server/commands.rs`). diff --git a/docs/state_keys.csv b/docs/state_keys.csv index 7cf152d..7b0ce43 100644 --- a/docs/state_keys.csv +++ b/docs/state_keys.csv @@ -10,3 +10,6 @@ ride_acceptance_{}, ride_acceptance_{}:fare_paid, ride_acceptance_{}:cancel, ride_pay_{}, +chain_params,ChainInit (JSON) +total_supply,u64 (JSON) +processed_ref_{},tx hash (exactly-once Mint/Burn ref marker) From 25f2c78ba770c0a7d36318d1d193137928905a41 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 21:56:11 +0400 Subject: [PATCH 22/24] fix: reject blocks whose transactions write the same account Two txs from different senders writing one account collapsed to the last staged write. With Burn this was an attacker-profitable reserve drain: a burn plus a 1-unit transfer to the burner, ordered by nonce, left the burner holding the burned amount while total_supply still fell and the redemption ref committed - so the treasury paid out against a burn that never debited. Mint mirrors it, destroying the credit while supply rises. Block validation now rejects intersecting written-account sets and authoring defers the loser. Also moves the supply-range check to the mempool, validates Transfer recipients, surfaces a chain-info read error, and updates the README off the deleted block-reward model. Co-Authored-By: Claude Fable 5 --- README.md | 40 ++++-- src/node/blockchain.rs | 113 +++++++++++++---- src/node/transactions/address.rs | 13 ++ src/node/transactions/mint.rs | 26 ++-- src/node/transactions/transaction.rs | 127 +++++++++++++++++++ src/node/transactions/transfer.rs | 13 +- src/node/wss/websocket.rs | 11 +- tests/mint_burn.rs | 181 +++++++++++++++++++++++++-- tests/referrer_account.rs | 23 ---- 9 files changed, 463 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 9249519..b592406 100644 --- a/README.md +++ b/README.md @@ -128,24 +128,44 @@ To get started with Clutch-Node, follow these steps: cargo run -- --env node1 ``` -## Block Reward +## Transaction Fees -`clutch-node` supports a fixed author block reward configured per node environment file: +Block rewards are gone (`block_reward_amount` no longer exists). The block author is paid +out of transaction fees instead: a flat `tx_fee` per transaction, credited to `block.author` +once per block as a single aggregate. No CLT is created — fees are backed CLT changing hands. + +`Mint` is fee-exempt (the mint authority may hold no balance), as is the genesis `ChainInit`. +A transaction whose sender is the block author pays no fee to itself. + +## Consensus Parameters + +These live in `config/node/{env}.toml` and are committed to state by the genesis `ChainInit` +transaction, so they are part of the genesis hash: **all nodes on a network must carry +identical values or they cannot peer**. None of them is optional — a config missing any key +below fails to deserialize at boot. ```toml -block_reward_amount = 50 +chain_id = 2077 # signed into every tx hash; replay-isolates networks +is_testnet = true # false requires faucet_allocation = 0 +tx_fee = 1000 # flat fee per transaction, paid to the block author +mint_authority = "0x..." # the only address allowed to sign Mint +faucet_address = "0x..." # genesis-funded account (testnet only) +faucet_allocation = 1000000000000000 # its balance in base units, <= i64::MAX +ride_request_referrer_fee_bps = 200 # basis points (200 bps = 2%), floor-rounded +ride_offer_referrer_fee_bps = 200 # renamed from the old percent-based fields ``` -- The reward is minted on every accepted non-genesis block. -- The full reward (`100%`) is credited to the block author account (`block.author`). -- Genesis block does not mint any author reward. - ## CLT Economics -Ride payments and validator rewards are separate: +CLT is a redeemable token pegged at **1 USD = 1,000,000 CLT** (six decimals in base units), +so `total_supply` must always match the off-chain reserve: -- **RidePay** — referrer fees (default 2% request + 2% offer per installment); driver receives the remainder -- **Blocks** — `block_reward_amount` (default 50 CLT) minted to the block author each non-genesis block +- **Mint** — the mint authority credits an address against a paid-in reserve deposit. Carries + a `credit_ref` (`keccak256` of the treasury intent id) that may be claimed exactly once. +- **Burn** — permissionless; destroys the sender's CLT. An optional `redemption_ref` marks it + as a redemption claim the treasury pays out off-chain, also exactly once. +- **RidePay** — referrer fees (default 200 bps request + 200 bps offer per installment, + floor-rounded and capped so they can never exceed the fare); the driver takes the remainder. Full details: [docs.clutchprotocol.io/clutch-node/clt-economics](https://docs.clutchprotocol.io/clutch-node/clt-economics) diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index e31c610..b12a305 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -240,7 +240,7 @@ impl Blockchain { let index = latest_block.index + 1; let previous_hash = latest_block.hash; let transactions = match TransactionPool::get_transactions(&self.db) { - Ok(transactions) => Self::drop_intra_block_conflicts(transactions), + Ok(transactions) => Self::drop_intra_block_conflicts(&self.db, transactions), Err(e) => return Err(format!("Failed to get transactions from pool: {}", e)), }; @@ -253,22 +253,40 @@ impl Blockchain { /// Authoring-time counterpart to the block-level guards in /// `Transaction::validate_transactions`: drop pending txs that cannot legally share a /// block, keeping at most one per sender (deferred-batch staleness on the balance/nonce - /// mints CLT) and at most one per exactly-once ref (two identical `processed_ref_{ref}` - /// writes collapse, breaking exactly-once across Mint and Burn). Without this the author - /// would keep drafting a block its own validation rejects and never make progress. + /// mints CLT), at most one per exactly-once ref (two identical `processed_ref_{ref}` + /// writes collapse, breaking exactly-once across Mint and Burn), and at most one writer + /// per account balance (two txs from different senders writing one account collapse the + /// same way — the Burn reserve drain). Without this the author would keep drafting a block + /// its own validation rejects and never make progress. /// /// Ordering is lowest nonce, tie-broken by hash, so every node keeps the same winner; /// the losers stay in the pool for a later block. /// ponytail: one tx/account/block; lift with incremental intra-block state. - fn drop_intra_block_conflicts(mut transactions: Vec) -> Vec { + fn drop_intra_block_conflicts( + db: &Database, + mut transactions: Vec, + ) -> Vec { + use crate::node::transactions::address::canonical_account_address; transactions.sort_by(|a, b| a.nonce.cmp(&b.nonce).then_with(|| a.hash.cmp(&b.hash))); let mut senders = std::collections::HashSet::new(); let mut refs = std::collections::HashSet::new(); + let mut accounts = std::collections::HashSet::new(); transactions.retain(|tx| { - senders.insert(tx.from.clone()) - && tx - .exactly_once_ref() - .map_or(true, |r| refs.insert(r.to_string())) + // Claim the slots only when the tx is actually kept: a dropped tx that had + // reserved its sender or its accounts would cascade into dropping innocent txs. + let sender = canonical_account_address(&tx.from); + let written = tx.written_accounts(db); + let keep = !senders.contains(&sender) + && tx.exactly_once_ref().map_or(true, |r| !refs.contains(r)) + && written.iter().all(|a| !accounts.contains(a)); + if keep { + senders.insert(sender); + if let Some(r) = tx.exactly_once_ref() { + refs.insert(r.to_string()); + } + accounts.extend(written); + } + keep }); transactions } @@ -336,13 +354,30 @@ mod tests { ) } + /// The filter needs a `Database` to resolve RidePay/RideCancel counterparties. None of + /// these cases reads state, so any empty DB will do — one per test so they can still run + /// in parallel, deleted at the end so re-runs start clean. + fn scratch_db(name: &str) -> Database { + let _ = std::fs::remove_dir_all(format!("{}.db", name)); + Database::new_db(name) + } + + fn drop_scratch(mut db: Database, name: &str) { + db.close(); + db.delete_database(name).ok(); + } + #[test] fn drops_extra_tx_per_sender_keeping_lowest_nonce() { - let kept = Blockchain::drop_intra_block_conflicts(vec![ - tf("0xA", 2, "0xC"), - tf("0xB", 5, "0xA"), - tf("0xA", 1, "0xB"), - ]); + let name = "clutch-node-test-conflicts-sender"; + let db = scratch_db(name); + // Recipients are disjoint from every sender: two senders may share a block only if + // no account is written twice, which is a separate guard exercised below. + let kept = Blockchain::drop_intra_block_conflicts( + &db, + vec![tf("0xA", 2, "0xC"), tf("0xB", 5, "0xD"), tf("0xA", 1, "0xC")], + ); + drop_scratch(db, name); assert_eq!(kept.len(), 2); let a = kept.iter().find(|t| t.from == "0xA").unwrap(); assert_eq!(a.nonce, 1, "lowest-nonce tx kept per sender"); @@ -352,8 +387,13 @@ mod tests { #[test] fn drops_duplicate_nonce_mint_vector() { // Same account, same nonce, different recipients — the double-spend/mint input. - let kept = - Blockchain::drop_intra_block_conflicts(vec![tf("0xA", 1, "0xB"), tf("0xA", 1, "0xC")]); + let name = "clutch-node-test-conflicts-nonce"; + let db = scratch_db(name); + let kept = Blockchain::drop_intra_block_conflicts( + &db, + vec![tf("0xA", 1, "0xB"), tf("0xA", 1, "0xC")], + ); + drop_scratch(db, name); assert_eq!(kept.len(), 1, "only one tx per sender survives block building"); } @@ -362,22 +402,45 @@ mod tests { // Two *different* senders, so the per-sender filter never fires — but one ref, whose // marker write would collapse in the deferred batch. Without this the author drafts // a block `validate_transactions` then rejects, and never makes progress. + let name = "clutch-node-test-conflicts-ref"; + let db = scratch_db(name); let r = "a".repeat(64); - let kept = Blockchain::drop_intra_block_conflicts(vec![ - burn("0xA", 1, Some(&r)), - burn("0xB", 1, Some(&r)), - ]); + let kept = Blockchain::drop_intra_block_conflicts( + &db, + vec![burn("0xA", 1, Some(&r)), burn("0xB", 1, Some(&r))], + ); + drop_scratch(db, name); assert_eq!(kept.len(), 1, "one claim per ref survives block building"); } #[test] fn keeps_every_ref_less_burn() { // `None` is the absence of a ref, not a shared one — collapsing these would break - // the plain-burn path. - let kept = Blockchain::drop_intra_block_conflicts(vec![ - burn("0xA", 1, None), - burn("0xB", 1, None), - ]); + // the plain-burn path. Two burners only ever write their own balances, so the + // written-account guard must not fire either. + let name = "clutch-node-test-conflicts-plain-burn"; + let db = scratch_db(name); + let kept = Blockchain::drop_intra_block_conflicts( + &db, + vec![burn("0xA", 1, None), burn("0xB", 1, None)], + ); + drop_scratch(db, name); assert_eq!(kept.len(), 2, "ref-less burns never conflict"); } + + #[test] + fn defers_the_second_writer_of_one_account() { + // The reserve-drain shape, at the authoring layer: a Burn by 0xA and a Transfer TO + // 0xA from another sender both write `account_state_0xa`. The author must keep one + // and leave the other pooled, or it drafts a block its own validation rejects. + let name = "clutch-node-test-conflicts-shared-account"; + let db = scratch_db(name); + let kept = Blockchain::drop_intra_block_conflicts( + &db, + vec![burn("0xA", 1, None), tf("0xB", 2, "0xA")], + ); + drop_scratch(db, name); + assert_eq!(kept.len(), 1, "only one writer of 0xA may land"); + assert_eq!(kept[0].from, "0xA", "lowest nonce wins, deterministically"); + } } diff --git a/src/node/transactions/address.rs b/src/node/transactions/address.rs index 9309313..f46aaef 100644 --- a/src/node/transactions/address.rs +++ b/src/node/transactions/address.rs @@ -15,6 +15,19 @@ pub fn canonical_account_address(addr: &str) -> String { normalize_address_for_compare(addr) } +/// The validation half of `canonical_account_address`: 20-byte-hex, optional `0x`/`0X`. +/// Every type that credits or debits a caller-supplied address must check this first — the +/// address becomes a state key verbatim, so a malformed one writes `account_state_{garbage}` +/// that no key can ever spend. Under the redeemable-token model that permanently diverges +/// `total_supply` from circulating supply (Mint) or strands backed CLT (Transfer). +pub fn is_valid_address(addr: &str) -> bool { + let hex_part = addr + .strip_prefix("0x") + .or_else(|| addr.strip_prefix("0X")) + .unwrap_or(addr); + hex_part.len() == 40 && hex_part.chars().all(|c| c.is_ascii_hexdigit()) +} + /// Parse optional referrer from RLP (empty string → None, otherwise canonical `0x` form). pub fn optional_canonical_referrer(s: String) -> Option { if s.is_empty() { diff --git a/src/node/transactions/mint.rs b/src/node/transactions/mint.rs index 29d7251..9ba79fe 100644 --- a/src/node/transactions/mint.rs +++ b/src/node/transactions/mint.rs @@ -5,7 +5,7 @@ use crate::node::account_state::AccountState; use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; use crate::node::database::Database; -use super::address::canonical_account_address; +use super::address::{canonical_account_address, is_valid_address}; use super::chain_init::ChainInit; /// Exactly-once ref marker: `processed_ref_{64-hex}` in the state CF, value = tx hash. @@ -27,17 +27,6 @@ pub fn ref_already_processed(db: &Database, reference: &str) -> Result bool { - let hex_part = addr - .strip_prefix("0x") - .or_else(|| addr.strip_prefix("0X")) - .unwrap_or(addr); - hex_part.len() == 40 && hex_part.chars().all(|c| c.is_ascii_hexdigit()) -} - #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Mint { pub to: String, @@ -68,6 +57,19 @@ impl Mint { "Mint rejected: amount exceeds i64::MAX (balance deltas are i64)".to_string(), ); } + // Same ceiling `add_block_to_chain` enforces, checked here against committed supply + // so the *pool* refuses the mint. A mint that only failed at block application was a + // poison pill: pool deletions live in the same uncommitted batch as the state writes, + // so the tx survived the failed block, the authoring filter re-included it every + // tick, and `author_new_block` failed forever with no eviction path. The block-level + // check stays as the backstop for a hostile author. + let supply = ChainInit::get_total_supply(db)?; + if supply as u128 + self.amount as u128 > i64::MAX as u128 { + return Err(format!( + "Mint rejected: total_supply out of range: {} + {} exceeds i64::MAX", + supply, self.amount + )); + } if !ref_is_valid(&self.credit_ref) { return Err("Mint rejected: credit_ref must be 64 lowercase hex chars".to_string()); } diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index e5312f3..dc706fc 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -158,6 +158,35 @@ impl Transaction { )); } + // Both guards above key on the sender, so neither sees the same collapse between two + // transactions from *different* senders that write one account. Both compute their + // write from pre-block state and the deferred batch keeps the last one staged. + // + // With Burn that was attacker-profitable: a `Burn{amount, redemption_ref}` from B + // plus a `Transfer{to: B, value: 1}` from an accomplice at a higher nonce (blocks + // sort by nonce, so the attacker chooses the order) leaves the staged balance at + // `pre_B + 1` — B keeps the burned amount and its fee — while `total_supply` still + // falls, because the supply delta is summed from the transaction list and never + // consults whether the balance write survived, and `processed_ref_{ref}` commits. The + // off-chain treasury sees a confirmed, exactly-once burn and pays out against it. + // Mint mirrors it: a Transfer to the beneficiary destroys the mint credit while + // supply rises and the ref is consumed forever. RideCancel's passenger refund and + // RidePay's driver/referrer credits are the same shape. + // + // ponytail: fail-closed — rejecting is auditable, computing a merged state on a + // consensus path is not. The ceiling is real: the default config points every ride at + // one configured referrer address, so two RidePay transactions in one block now defer + // one of them, a genuine throughput cut on the most common flow. It is still strictly + // better than today, which silently destroys one of the two referrer fees. Lift it by + // merging staged writes per account inside `add_block_to_chain`'s transaction loop, + // generalizing the author-fee merge already there. + if let Some(account) = Self::first_shared_written_account(db, transactions) { + return Err(format!( + "Block contains two transactions writing the balance of account '{}'; only one writer per account per block is allowed.", + account + )); + } + for tx in transactions.iter() { tx.validate_transaction(&db)?; } @@ -225,6 +254,104 @@ impl Transaction { None } + /// Canonical addresses whose `account_state_{addr}` key this transaction writes. + /// + /// The sibling of `state_transaction`'s balance legs, and it must stay in step with them: + /// two transactions naming one account here cannot share a block. Every non-fee-exempt + /// type writes its sender (the flat fee — merged in-type for the types that already touch + /// the sender, appended by `state_transaction` otherwise); the rest is per-type, resolved + /// with the same state lookups the matching `state_transaction` performs. + /// + /// Deliberately over-approximate, never under: a counterparty is listed whenever it + /// exists, without re-deriving the fee split that decides whether its delta is nonzero. An + /// unresolvable hash yields a short list, which is sound rather than a hole — the same + /// lookup fails in `verify_state` a few lines later and rejects the whole block. + pub(crate) fn written_accounts(&self, db: &Database) -> Vec { + use super::address::canonical_account_address as canon; + use super::{ride_acceptance::RideAcceptance, ride_offer::RideOffer, ride_request::RideRequest}; + + let mut accounts = Vec::new(); + if !self.fee_exempt() { + accounts.push(canon(&self.from)); + } + match &self.data { + FunctionCall::Transfer(t) => accounts.push(canon(&t.to)), + FunctionCall::Mint(m) => accounts.push(canon(&m.to)), + FunctionCall::RidePay(p) => { + // Driver, request referrer and offer referrer — the accounts RidePay's + // `legs` credit, reached by the same acceptance -> offer -> request walk. + if let Ok(Some(acceptance)) = + RideAcceptance::get_ride_acceptance(&p.ride_acceptance_transaction_hash, db) + { + let offer_hash = &acceptance.ride_offer_transaction_hash; + if let Ok(Some(driver)) = RideOffer::get_from(offer_hash, db) { + accounts.push(canon(&driver)); + } + if let Ok(Some(offer)) = RideOffer::get_ride_offer(offer_hash, db) { + if let Some(referrer) = &offer.referrer { + accounts.push(canon(referrer)); + } + if let Ok(Some(request)) = RideRequest::get_ride_request( + &offer.ride_request_transaction_hash, + db, + ) { + if let Some(referrer) = &request.referrer { + accounts.push(canon(referrer)); + } + } + } + } + } + FunctionCall::RideCancel(c) => { + // The refund goes to the passenger, who may or may not be the sender. + if let Ok(Some(acceptance)) = + RideAcceptance::get_ride_acceptance(&c.ride_acceptance_transaction_hash, db) + { + if let Ok(Some(offer)) = + RideOffer::get_ride_offer(&acceptance.ride_offer_transaction_hash, db) + { + if let Ok(Some(passenger)) = + RideRequest::get_from(&offer.ride_request_transaction_hash, db) + { + accounts.push(canon(&passenger)); + } + } + } + } + // Genesis-only, and genesis carries exactly this one transaction. + FunctionCall::ChainInit(ci) => accounts.push(canon(&ci.faucet_address)), + // Sender only: Burn debits it, and the ride-setup types touch no other balance + // (RideAcceptance escrows the fare out of the sender's own balance; the other + // three write ride state plus the sender's fee and nothing more). + FunctionCall::Burn(_) + | FunctionCall::RideRequest(_) + | FunctionCall::RideOffer(_) + | FunctionCall::RideAcceptance(_) + | FunctionCall::RideRequestCancel(_) => {} + } + // Repeats within one transaction are legal — those writes are netted per address + // in-type — so only distinct accounts reach the cross-transaction check. + accounts.sort(); + accounts.dedup(); + accounts + } + + /// First account two transactions in `transactions` both write, if any. + fn first_shared_written_account( + db: &Database, + transactions: &[Transaction], + ) -> Option { + let mut seen = std::collections::HashSet::new(); + for tx in transactions { + for account in tx.written_accounts(db) { + if !seen.insert(account.clone()) { + return Some(account); + } + } + } + None + } + pub fn validate_transaction(&self, db: &Database) -> Result<(), String> { self.verify_hash()?; self.verify_signature()?; diff --git a/src/node/transactions/transfer.rs b/src/node/transactions/transfer.rs index a625339..d27e2f5 100644 --- a/src/node/transactions/transfer.rs +++ b/src/node/transactions/transfer.rs @@ -1,7 +1,7 @@ use crate::node::account_state::AccountState; use crate::node::balance_effect::{BalanceEffectKind, StateUpdate}; use crate::node::database::Database; -use crate::node::transactions::address::canonical_account_address; +use crate::node::transactions::address::{canonical_account_address, is_valid_address}; use rlp::{Decodable, DecoderError, Encodable, Rlp, RlpStream}; use serde::{Deserialize, Serialize}; @@ -14,6 +14,17 @@ pub struct Transfer { impl Transfer { pub fn verify_state(&self, from: &String, db: &Database) -> Result<(), String> { + // Same check Mint makes on its recipient, for the same reason: `to` is written into + // the state key verbatim, so a malformed one credits `account_state_{garbage}` that + // no key can spend. Backed CLT is stranded and circulating supply silently drifts + // below `total_supply`, with no recovery path. + if !is_valid_address(&self.to) { + return Err(format!( + "Error: Transfer 'to' must be a 20-byte-hex address, got '{}'", + self.to + )); + } + // A self-transfer moves nothing, but state_transaction would write the sender's // balance key twice — the merged debit first, then the plain `+value` credit, which // wins in the block's deferred batch. The fee vanishes while the block still credits diff --git a/src/node/wss/websocket.rs b/src/node/wss/websocket.rs index 35acae8..f61dfe2 100644 --- a/src/node/wss/websocket.rs +++ b/src/node/wss/websocket.rs @@ -432,11 +432,14 @@ impl WebSocket { Ok(Some(b)) => b.index, // Genuinely empty chain: no block yet, 0 is correct. Ok(None) => 0, - // A read failure is not a fresh chain: falling back to 0 here would report - // a corrupt/unreadable DB as a healthy freshly-initialized one to the treasury. + // A read failure is not a fresh chain: answering 0 with a 200 OK would report a + // corrupt/unreadable DB as a healthy freshly-initialized one, and a treasury + // reconciliation job would conclude the chain has no blocks. Surface the error, + // like the `get_chain_info()` arm below. Err(e) => { - warn!("get_chain_info: failed to read latest block: {}", e); - 0 + let error_msg = format!("Failed to read latest block: {}", e); + error!("get_chain_info: {}", error_msg); + return Some(json_rpc_error_response(-32000, &error_msg, id)); } }; match blockchain.get_chain_info() { diff --git a/tests/mint_burn.rs b/tests/mint_burn.rs index d6f8d73..652b8bb 100644 --- a/tests/mint_burn.rs +++ b/tests/mint_burn.rs @@ -6,6 +6,7 @@ use clutch_node::node::transactions::chain_init::ChainInit; use clutch_node::node::transactions::function_call::FunctionCall; use clutch_node::node::transactions::mint::Mint; use clutch_node::node::transactions::transaction::Transaction; +use clutch_node::node::transactions::transfer::Transfer; use serial_test::serial; const AUTHOR_PK: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; @@ -46,6 +47,17 @@ fn chain(name: &str) -> Blockchain { ) } +fn signed_transfer(sk: &str, from: &str, nonce: u64, to: &str, value: u64) -> Transaction { + let mut tx = Transaction::new_transaction( + from.to_string(), + nonce, + CHAIN_ID, + FunctionCall::Transfer(Transfer { to: to.to_string(), value }), + ); + tx.sign(sk); + tx +} + fn signed_mint(sk: &str, from: &str, nonce: u64, to: &str, amount: u64, credit_ref: &str) -> Transaction { let mut tx = Transaction::new_transaction( from.to_string(), @@ -128,17 +140,24 @@ fn mint_rejects_amount_over_i64_max() { #[test] #[serial] fn mint_rejects_supply_out_of_range() { - // Block-level `total_supply out of range` guard (block.rs) driven through a real - // block, not asserted directly: genesis's testnet faucet_allocation (1e15, see `ci()`) - // already makes total_supply > 0, so one Mint at the per-tx ceiling (`i64::MAX`, the - // largest amount `Mint::verify_state` allows through) pushes - // `supply0 + i64::MAX > i64::MAX` and trips the block-level guard on the same tx that - // passed per-tx `verify_state` — the two checks are independent and both are needed. + // Genesis's testnet faucet_allocation (1e15, see `ci()`) already makes total_supply > 0, + // so one Mint at the per-tx ceiling (`i64::MAX`, the largest amount the amount check + // allows) pushes `supply0 + i64::MAX > i64::MAX`. + // + // This must be refused at the POOL, not at block application. When it only failed in + // `add_block_to_chain`, the failing block's pool deletions died with its uncommitted + // batch: the mint stayed pooled, the authoring filter re-included it every tick, and + // `author_new_block` failed forever with no eviction path — one transaction halting + // authoring permanently. The block-level check remains as the backstop for a hostile + // author, unreachable through the pool now. let mut chain = chain("test-mint-supply-range"); let mint = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, USER, i64::MAX as u64, REF_A); - chain.add_transaction_to_pool(&mint).unwrap(); - let err = chain.author_new_block().unwrap_err(); + let err = chain.add_transaction_to_pool(&mint).unwrap_err(); assert!(err.contains("total_supply out of range"), "got: {}", err); + assert!( + chain.get_transactions_from_pool().unwrap().is_empty(), + "the poison mint must not survive in the pool" + ); chain.shutdown_blockchain(); } @@ -158,7 +177,6 @@ fn mint_works_with_zero_treasury_balance() { #[test] #[serial] fn author_own_tx_pays_no_fee() { - use clutch_node::node::transactions::transfer::Transfer; // Fund the author via Mint (fee-exempt, single balance write — no deferred-batch // collision), then the author sends a transfer in a block it authors itself. let mut chain = chain("test-fee-author"); @@ -486,3 +504,148 @@ fn mint_and_burn_sharing_a_ref_cannot_share_a_block() { assert_eq!(block.transactions.len(), 1, "authoring keeps one claim on the ref"); chain.shutdown_blockchain(); } + +#[test] +#[serial] +fn burn_plus_transfer_to_the_burner_cannot_share_a_block() { + // The reserve drain, end to end. Both existing guards key on the SENDER, so neither sees + // two different senders writing one account — and every `state_transaction` computes its + // writes from pre-block state, with the whole block committing as one deferred batch. + // + // tx0: `Burn{BURNED, redemption_ref}` signed by the burner, nonce 1. + // tx1: `Transfer{to: burner, value: 1}` signed by an accomplice, nonce 2. + // + // Blocks sort by nonce, so the attacker picks the accomplice's nonce to stage its write + // LAST: `account_state_burner` commits as `pre + 1`, discarding the burn's debit, while + // `total_supply` still falls by BURNED (that delta is summed from the transaction list and + // never consults whether the balance write survived) and `processed_ref_{ref}` commits. The + // off-chain treasury watcher sees a valid, confirmed, exactly-once burn and pays out + // BURNED in USDT. Cost to the attacker: two fees plus 1 CLT, repeatable every block. + const BURNED: u64 = 5_000_000; + let mut chain = chain("test-burn-drain"); + + // Fund the accomplice and spend its nonce 1, so its attack tx can sit at nonce 2 — above + // the burn's nonce 1, which is what buys the attacker the last write. + let fund = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, SECOND_PK, 1_000_000, REF_A); + chain.add_transaction_to_pool(&fund).unwrap(); + chain.author_new_block().unwrap(); + let warmup = signed_transfer(SECOND_SK, SECOND_PK, 1, USER, 1); + chain.add_transaction_to_pool(&warmup).unwrap(); + chain.author_new_block().unwrap(); + + let faucet_before = chain.get_account_balance(&FAUCET_PK.to_string()); + let (_, supply0) = chain.get_chain_info().unwrap(); + + // Both are individually valid against committed state, so the pool accepts both. + let burn = signed_burn(FAUCET_SK, FAUCET_PK, 1, BURNED, Some(REF_B)); + let accomplice = signed_transfer(SECOND_SK, SECOND_PK, 2, FAUCET_PK, 1); + chain.add_transaction_to_pool(&burn).unwrap(); + chain.add_transaction_to_pool(&accomplice).unwrap(); + + // Consensus layer: a block carrying both is invalid however it was assembled. + let forged = forged_block(&chain, vec![burn.clone(), accomplice.clone()]); + let err = chain.import_block(&forged).unwrap_err(); + assert!( + err.contains(FAUCET_PK), + "a block whose transactions write one account must be rejected, naming it: {}", + err + ); + + // Nothing from the rejected block was applied. + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before, + "the rejected block must not have moved the burner's balance" + ); + assert_eq!(chain.get_chain_info().unwrap().1, supply0, "nor total_supply"); + + // Pin the guard, not the arithmetic: authoring splits the two across blocks, and once both + // have landed the burner is down the full burned amount instead of holding it. If the + // guard ever stops firing, this is the assertion that catches the drain itself. + assert_eq!( + chain.author_new_block().unwrap().transactions.len(), + 1, + "the second writer of the account is deferred, not included" + ); + assert_eq!(chain.author_new_block().unwrap().transactions.len(), 1); + assert_eq!( + chain.get_account_balance(&FAUCET_PK.to_string()), + faucet_before - BURNED - ci().tx_fee + 1, + "burner paid the burn and its fee: it does not keep the burned amount" + ); + assert_eq!( + chain.get_chain_info().unwrap().1, + supply0 - BURNED, + "supply fell by exactly the CLT that actually left the burner" + ); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn two_transfers_to_one_recipient_cannot_share_a_block() { + // The benign intersection, same collapse: two senders crediting one recipient, both + // computed from pre-block state, so the deferred batch keeps one credit and destroys the + // other. Rejection has to be deterministic and the loser has to survive — a guard that + // silently dropped it would burn a user's transfer instead of delaying it. + const V1: u64 = 7_000; + const V2: u64 = 9_000; + let mut chain = chain("test-two-credits-one-account"); + + let fund = signed_mint(AUTHOR_SK, AUTHOR_PK, 1, SECOND_PK, 1_000_000, REF_A); + chain.add_transaction_to_pool(&fund).unwrap(); + chain.author_new_block().unwrap(); + + let t1 = signed_transfer(FAUCET_SK, FAUCET_PK, 1, USER, V1); + let t2 = signed_transfer(SECOND_SK, SECOND_PK, 1, USER, V2); + chain.add_transaction_to_pool(&t1).unwrap(); + chain.add_transaction_to_pool(&t2).unwrap(); + + let forged = forged_block(&chain, vec![t1.clone(), t2.clone()]); + let err = chain.import_block(&forged).unwrap_err(); + assert!( + err.contains(USER), + "two credits to one account must be rejected, naming it: {}", + err + ); + + // Authoring layer: one lands, chosen by nonce then hash — identically on every node. + let block = chain.author_new_block().unwrap(); + assert_eq!(block.transactions.len(), 1, "only one writer of the recipient may land"); + let winner = if t1.hash <= t2.hash { &t1 } else { &t2 }; + assert_eq!( + block.transactions[0].hash, winner.hash, + "deterministic winner: lowest nonce, tie-broken by hash" + ); + assert_eq!( + chain.get_transactions_from_pool().unwrap().len(), + 1, + "the loser is deferred, not dropped: it stays pooled" + ); + + // And it lands in the very next block, so neither credit is lost. + chain.author_new_block().unwrap(); + assert_eq!( + chain.get_account_balance(&USER.to_string()), + V1 + V2, + "both credits survive, one block later" + ); + chain.shutdown_blockchain(); +} + +#[test] +#[serial] +fn transfer_to_malformed_address_rejected() { + // `to` is written into the state key verbatim, exactly like Mint's — a malformed one + // credits `account_state_{garbage}` that no key can spend, stranding backed CLT and + // silently pushing circulating supply below total_supply with no recovery. + let mut chain = chain("test-transfer-bad-to"); + let bad = signed_transfer(FAUCET_SK, FAUCET_PK, 1, "not-an-address", 100); + let err = chain.add_transaction_to_pool(&bad).unwrap_err(); + assert!(err.contains("must be a 20-byte-hex address"), "got: {}", err); + // Truncated hex is the likelier client bug and must fail the same way. + let short = signed_transfer(FAUCET_SK, FAUCET_PK, 1, "0xdeb4cfb63db134698e1879ea24904df074726c", 100); + let err = chain.add_transaction_to_pool(&short).unwrap_err(); + assert!(err.contains("must be a 20-byte-hex address"), "got: {}", err); + chain.shutdown_blockchain(); +} diff --git a/tests/referrer_account.rs b/tests/referrer_account.rs index 701ba90..3346b7d 100644 --- a/tests/referrer_account.rs +++ b/tests/referrer_account.rs @@ -7,13 +7,6 @@ use clutch_node::node::{ const LEGACY_REFERRER: &str = "0912514c7cc3eec2b2dab4e1d150c4b5eaee5a6f"; const CANONICAL_REFERRER: &str = "0x0912514c7cc3eec2b2dab4e1d150c4b5eaee5a6f"; -fn referrer_fee_ceiling(percent: u8, fare: u64) -> u64 { - if percent == 0 || fare == 0 { - return 0; - } - (percent as u64 * fare + 99) / 100 -} - #[test] fn canonical_account_address_adds_prefix() { assert_eq!( @@ -31,12 +24,6 @@ fn optional_canonical_referrer_normalizes() { assert!(optional_canonical_referrer(String::new()).is_none()); } -#[test] -fn referrer_fee_ceiling_pays_on_small_fare() { - assert_eq!(referrer_fee_ceiling(2, 3), 1); - assert_eq!(referrer_fee_ceiling(2, 0), 0); -} - #[test] fn legacy_account_balance_readable_via_canonical_address() { let name = "clutch-node-test-referrer-legacy-read"; @@ -83,13 +70,3 @@ fn update_account_state_writes_canonical_key() { db.close(); db.delete_database(name).ok(); } - -#[test] -fn merged_referrer_ceiling_fees_for_same_address() { - let fare = 3u64; - let request_fee = referrer_fee_ceiling(2, fare); - let offer_fee = referrer_fee_ceiling(2, fare); - assert_eq!(request_fee, 1); - assert_eq!(offer_fee, 1); - assert_eq!(request_fee + offer_fee, 2); -} From 6f1ec03cb0ec17df5ff842aa76d2d7fb6664b062 Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Tue, 28 Jul 2026 22:12:57 +0400 Subject: [PATCH 23/24] test+docs: attribute the guard rejection; correct written_accounts rationale The drain test asserted only that the error named the account, which the nonce and insufficient-balance errors also do; it now also requires the guard's own phrase. The doc comment credited verify_state with catching an unresolvable RidePay lookup, which it never performs - state_transaction unwrapping the same lookup is what makes a short list sound. Co-Authored-By: Claude Fable 5 --- src/node/transactions/transaction.rs | 11 ++++++++--- tests/mint_burn.rs | 8 ++++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index dc706fc..fc50f97 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -263,9 +263,14 @@ impl Transaction { /// with the same state lookups the matching `state_transaction` performs. /// /// Deliberately over-approximate, never under: a counterparty is listed whenever it - /// exists, without re-deriving the fee split that decides whether its delta is nonzero. An - /// unresolvable hash yields a short list, which is sound rather than a hole — the same - /// lookup fails in `verify_state` a few lines later and rejects the whole block. + /// exists, without re-deriving the fee split that decides whether its delta is nonzero. + /// + /// An unresolvable hash yields a short list. That is not a hole, but not for the reason + /// you might assume — `RidePay::verify_state` never looks up the driver or the request + /// referrer, so it would not catch it. The reason is that `state_transaction` unwraps + /// these very same lookups, so an unresolvable hash aborts rather than quietly landing an + /// unguarded write. Reaching that needs a corrupt DB: each `ride_offer_{h}` / + /// `ride_offer_from_{h}` pair is written in one batch, as is the request pair. pub(crate) fn written_accounts(&self, db: &Database) -> Vec { use super::address::canonical_account_address as canon; use super::{ride_acceptance::RideAcceptance, ride_offer::RideOffer, ride_request::RideRequest}; diff --git a/tests/mint_burn.rs b/tests/mint_burn.rs index 652b8bb..c2a5971 100644 --- a/tests/mint_burn.rs +++ b/tests/mint_burn.rs @@ -546,8 +546,12 @@ fn burn_plus_transfer_to_the_burner_cannot_share_a_block() { let forged = forged_block(&chain, vec![burn.clone(), accomplice.clone()]); let err = chain.import_block(&forged).unwrap_err(); assert!( - err.contains(FAUCET_PK), - "a block whose transactions write one account must be rejected, naming it: {}", + // Both halves matter: the phrase attributes the rejection to THIS guard (FAUCET_PK + // alone also appears in the nonce and insufficient-balance errors), and the address + // proves it named the colliding account. + err.contains("writing the balance of account") && err.contains(FAUCET_PK), + "a block whose transactions write one account must be rejected by the written-account \ + guard, naming it: {}", err ); From 1c17e55ce5f92f6d3832223f855a1cba032d088a Mon Sep 17 00:00:00 2001 From: Mehran Mazhar Date: Thu, 30 Jul 2026 01:56:41 +0400 Subject: [PATCH 24/24] fix: idle chain must keep producing blocks; logger must never panic or amplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing defects, both reported earlier and left unfixed. 1. Idle chain stalled at genesis -------------------------------- validate_transactions rejected an empty transaction list, so author_new_block failed every slot when nothing was being submitted and the chain stopped at genesis. Not cosmetic. Confirmation depth is counted in blocks, so anything waiting on `confirmations` blocks of depth needed LATER blocks to exist — and later blocks needed more transactions. A single Mint on an otherwise-quiet chain therefore never reached confirmed depth and never got credited: the treasury's whole credit path stalled permanently on an idle chain. Emptiness is not a state question, so it no longer lives in a state validator. Whether an empty block is WANTED is an authoring decision, and author_new_block now makes it — emitting at most one empty block per slot via a new Consensus::block_is_in_current_slot. That bound is what makes this safe: the authoring loop ticks every second while a slot lasts step_duration seconds (60 with one authority), so allowing empty blocks without it would emit a block per second. Blocks WITH transactions stay unthrottled, since draining a busy pool across several blocks in one slot is how throughput is achieved at all under the one-tx-per-sender-per-block ceiling. An empty block credits no tx fee, so it cannot become a source of new CLT — asserted, not assumed. 2. Seq logger panicked per log line, and could amplify ----------------------------------------------------- on_event spawned a task per log event that did `.unwrap()` on the HTTP send, so an unreachable Seq host panicked one task per log line. The unwrap was the visible half. The worse half: a panic message re-entering tracing calls on_event again, which spawns another failing shipment, which panics again — a self-amplifying storm triggered by nothing worse than the log host being down. Failures now report via eprintln! (never tracing), and only on the down/up transition, so an outage costs one line instead of one per event and still never fails silently. Also fixed while in here, all same-cause: - No request timeout, so every shipment hung on reqwest's default of none and tasks accumulated for the length of the outage. Now 5s. - tokio::spawn panics with no runtime, and on_event can fire from a non-async context (startup). Now checks for a runtime and drops the shipment instead — the fmt layer has already put the event on stdout. - serde_json unwrap on a String->String map (cannot fail, but no path through a logger should be able to panic). - Dropped the Arc>: log_to_seq takes &self and reqwest::Client is already internally shared, so the lock was never needed — and it was held ACROSS the network await, serialising every log event in the process behind one Seq round-trip. Both fixes mutation-proven: restoring the empty-list rejection fails an_empty_block_imports_and_advances_the_chain; deleting the slot guard fails author_refuses_a_second_empty_block_in_the_same_slot. Full suite 128 passed, 0 failed. Co-Authored-By: Claude --- src/node/aura.rs | 19 +++++ src/node/blockchain.rs | 15 +++- src/node/consensus.rs | 6 ++ src/node/seq.rs | 119 +++++++++++++++++++++++--- src/node/tracing.rs | 6 +- src/node/transactions/transaction.rs | 14 ++- tests/empty_block.rs | 123 +++++++++++++++++++++++++++ 7 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 tests/empty_block.rs diff --git a/src/node/aura.rs b/src/node/aura.rs index 9cc28c4..0ed514f 100644 --- a/src/node/aura.rs +++ b/src/node/aura.rs @@ -65,6 +65,10 @@ impl Consensus for Aura { )) } } + + fn block_is_in_current_slot(&self, block: &Block) -> bool { + self.slot_at_time(block.timestamp) == self.current_slot() + } } #[cfg(test)] @@ -85,6 +89,21 @@ mod tests { assert_eq!(aura.current_author(), expected_author); } + #[test] + fn block_is_in_current_slot_distinguishes_now_from_long_ago() { + let aura = Aura::new(vec!["0xauthority".to_string()], 60); + + // A block authored right now is in the current slot — this is what stops the 1-second + // authoring loop from emitting an empty block on every tick. + let now_block = Block::new_block(1, "0".to_string(), vec![]); + assert!(aura.block_is_in_current_slot(&now_block)); + + // A block from epoch 0 is not, so the next slot is free to carry a fresh heartbeat. + let mut old_block = Block::new_block(1, "0".to_string(), vec![]); + old_block.timestamp = 0; + assert!(!aura.block_is_in_current_slot(&old_block)); + } + #[test] fn rejects_block_slot_far_in_future() { let aura = Aura::new(vec!["node_1".to_string(), "node_2".to_string()], 20); diff --git a/src/node/blockchain.rs b/src/node/blockchain.rs index b12a305..39eabbc 100644 --- a/src/node/blockchain.rs +++ b/src/node/blockchain.rs @@ -238,12 +238,25 @@ impl Blockchain { }; let index = latest_block.index + 1; - let previous_hash = latest_block.hash; + let previous_hash = latest_block.hash.clone(); let transactions = match TransactionPool::get_transactions(&self.db) { Ok(transactions) => Self::drop_intra_block_conflicts(&self.db, transactions), Err(e) => return Err(format!("Failed to get transactions from pool: {}", e)), }; + // Empty blocks are legal and necessary — confirmation depth is counted in blocks, so a + // chain that stops producing them when idle can never confirm what is already on it (see + // `Transaction::validate_transactions` for how that stalled the mint credit path). But + // they are a heartbeat, not throughput: this loop ticks every second while a slot lasts + // `step_duration` seconds, so emit at most ONE empty block per slot. + // + // Blocks WITH transactions are deliberately NOT rate-limited here — draining a busy pool + // across several blocks within one slot is how throughput is achieved at all, given the + // one-tx-per-sender-per-block ceiling. + if transactions.is_empty() && self.consensus.block_is_in_current_slot(&latest_block) { + return Err("Nothing to author: this slot already has a block".to_string()); + } + let mut new_block = Block::new_block(index, previous_hash, transactions); new_block.sign(&self.author_public_key, &self.author_secret_key); self.import_block(&new_block)?; diff --git a/src/node/consensus.rs b/src/node/consensus.rs index 78cfe3a..41752e4 100644 --- a/src/node/consensus.rs +++ b/src/node/consensus.rs @@ -4,4 +4,10 @@ use super::blocks::block::Block; pub trait Consensus { fn current_author(&self) -> &String; fn verify_block_author(&self, block: &Block) -> Result<(), String>; + /// Was `block` authored in the slot that is current right now? + /// + /// Needed to bound *empty* (heartbeat) blocks to one per slot. The authoring loop ticks + /// every second while a slot lasts `step_duration` seconds, so without this an idle chain + /// would emit a block every second instead of one per slot. + fn block_is_in_current_slot(&self, block: &Block) -> bool; } \ No newline at end of file diff --git a/src/node/seq.rs b/src/node/seq.rs index 566a30a..40d58ee 100644 --- a/src/node/seq.rs +++ b/src/node/seq.rs @@ -3,11 +3,24 @@ use reqwest::Client; use serde_json::json; use std::collections::HashMap; use std::error::Error; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use tokio::sync::Mutex; +use std::time::Duration; use tracing::{Event, Subscriber}; use tracing_subscriber::layer::{Context, Layer}; +/// Cap on how long a single log shipment may take. Without it an unreachable Seq host leaves every +/// spawned shipment hanging on reqwest's default (which is no timeout at all), so one task per log +/// line accumulates for as long as the outage lasts. +const SEND_TIMEOUT: Duration = Duration::from_secs(5); + +/// Whether the sink is currently believed to be down. Used only to decide whether to *print* about +/// it, so one outage produces one line instead of one per log event. +/// +/// A process-wide static rather than state on the layer: there is exactly one logging pipeline, and +/// the detached shipment tasks must be able to read it without holding anything. +static SINK_DOWN: AtomicBool = AtomicBool::new(false); + pub struct SeqLogger { seq_url: String, api_key: String, @@ -19,7 +32,10 @@ impl SeqLogger { SeqLogger { seq_url: seq_url.to_string(), api_key: api_key.to_string(), - client: Client::new(), + client: Client::builder() + .timeout(SEND_TIMEOUT) + .build() + .unwrap_or_else(|_| Client::new()), } } @@ -42,7 +58,7 @@ impl SeqLogger { } let seq_address = format!("{}/ingest/clef", self.seq_url); - let payload = format!("{}\n", event.to_string()); + let payload = format!("{}\n", event); let mut request = self .client .post(&seq_address) @@ -61,12 +77,30 @@ impl SeqLogger { } } +/// Report a shipment outcome WITHOUT going through `tracing`. +/// +/// That is the whole reason these are `eprintln!` and not `error!`: this code runs inside the +/// tracing pipeline. Reporting a failed shipment through `tracing` re-enters `on_event`, which +/// spawns another shipment, which fails, which reports again — a self-amplifying storm triggered by +/// nothing worse than the log host being down. Going straight to stderr breaks the cycle. +/// +/// Edge-triggered on the down/up transition, so an outage costs one line rather than one per log +/// event, while still never failing silently. +fn report_transition(now_down: bool, detail: &str) { + let was_down = SINK_DOWN.swap(now_down, Ordering::Relaxed); + if now_down && !was_down { + eprintln!("seq: log sink unreachable, dropping log shipments until it recovers ({detail})"); + } else if !now_down && was_down { + eprintln!("seq: log sink recovered, resuming log shipments"); + } +} + pub struct SeqLayer { - logger: Arc>, + logger: Arc, } impl SeqLayer { - pub fn new(logger: Arc>) -> Self { + pub fn new(logger: Arc) -> Self { Self { logger } } } @@ -88,20 +122,77 @@ where }, ); - // Serialize the fields map to a JSON object - let fields_json = serde_json::to_value(fields_map).unwrap(); + // A String->String map always serializes; fall back rather than unwrap, so that no path + // through the logger can panic the process. + let fields_json = serde_json::to_value(fields_map).unwrap_or_else(|_| json!({})); let message = format!("Log event: {}", event.metadata().target()); let level = event.metadata().level().as_str(); - // Spawn an asynchronous task to send the log to Seq + // Ship asynchronously and detached: getting logs out must never block or fail the caller's + // actual work. + // + // `spawn` needs a runtime, and `on_event` can fire from a non-async context (startup, or a + // plain `std::thread`) where `tokio::spawn` PANICS — panicking inside the logger is + // precisely what must not happen. Ship only when a runtime is present; otherwise drop the + // shipment, since the `fmt` layer has already put the event on stdout. + if tokio::runtime::Handle::try_current().is_err() { + return; + } tokio::spawn(async move { - logger - .lock() - .await - .log_to_seq(&message, level, &fields_json) - .await - .unwrap(); + // Never `unwrap` here. It used to, so one unreachable log host panicked a task per log + // line — and the panic output itself re-entered the logger. + match logger.log_to_seq(&message, level, &fields_json).await { + Ok(()) => report_transition(false, ""), + Err(e) => report_transition(true, &e.to_string()), + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The property that matters: a failing shipment returns an error instead of panicking. Points + /// at a closed port, exercising the real unreachable-host path with no fake server needed. + #[tokio::test] + async fn unreachable_sink_returns_err_and_never_panics() { + let logger = SeqLogger::new("http://127.0.0.1:1", "no-key"); + let result = logger.log_to_seq("msg", "INFO", &json!({"k": "v"})).await; + assert!(result.is_err(), "an unreachable sink must surface an error, not panic"); + } + + /// Only the down->up and up->down transitions print — this is what bounds an outage to one + /// line instead of one per log event. + #[test] + fn report_transition_is_edge_triggered() { + SINK_DOWN.store(false, Ordering::Relaxed); + + report_transition(true, "first failure"); + assert!(SINK_DOWN.load(Ordering::Relaxed), "first failure must latch the down state"); + + // Still down: no transition, so nothing further is printed. + report_transition(true, "same outage"); + assert!(SINK_DOWN.load(Ordering::Relaxed)); + + report_transition(false, ""); + assert!(!SINK_DOWN.load(Ordering::Relaxed), "a success must clear the down state"); + + // Leave the static as other tests expect to find it. + SINK_DOWN.store(false, Ordering::Relaxed); + } + + /// `on_event` must not panic when no tokio runtime is present. That is the startup path, and a + /// panic in the logger takes the node down before it can report why. + #[test] + fn layer_does_not_panic_without_a_runtime() { + use tracing_subscriber::layer::SubscriberExt; + + let layer = SeqLayer::new(Arc::new(SeqLogger::new("http://127.0.0.1:1", "k"))); + let subscriber = tracing_subscriber::registry().with(layer); + tracing::subscriber::with_default(subscriber, || { + tracing::info!(field = "value", "emitted with no runtime running"); }); } } diff --git a/src/node/tracing.rs b/src/node/tracing.rs index d54e447..1ded637 100644 --- a/src/node/tracing.rs +++ b/src/node/tracing.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use tokio::sync::Mutex; use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use super::seq::{SeqLayer, SeqLogger}; @@ -9,7 +8,10 @@ pub fn setup_tracing( seq_url: &str, seq_api_key: &str, ) -> Result<(), Box> { - let seq_logger = Arc::new(Mutex::new(SeqLogger::new(seq_url, seq_api_key))); + // No Mutex: `log_to_seq` takes `&self` and `reqwest::Client` is already internally shared, so + // the lock was never needed — and it was held across the network await, serialising every log + // event in the process behind one Seq round-trip. + let seq_logger = Arc::new(SeqLogger::new(seq_url, seq_api_key)); let seq_layer = SeqLayer::new(seq_logger); tracing_subscriber::registry() diff --git a/src/node/transactions/transaction.rs b/src/node/transactions/transaction.rs index fc50f97..2cfe9ad 100644 --- a/src/node/transactions/transaction.rs +++ b/src/node/transactions/transaction.rs @@ -130,8 +130,20 @@ impl Transaction { db: &Database, transactions: &Vec, ) -> Result<(), String> { + // An EMPTY transaction list is valid — an empty block is a legal Aura heartbeat, and + // rejecting it here stalled the chain at genesis whenever nothing was being submitted. + // + // That was not cosmetic. Confirmation depth is counted in blocks, so anything waiting on + // `confirmations` blocks of depth needed *later* blocks to exist — and later blocks + // needed more transactions. A single Mint on an otherwise-quiet chain therefore never + // reached confirmed depth and never got credited: the treasury's whole credit path + // stalled permanently on an idle chain. + // + // Emptiness is not a state question at all, which is why it does not belong in a state + // validator. Whether an empty block is *wanted* is an authoring decision, and it lives + // in `Blockchain::author_new_block`, which emits at most one per slot. if transactions.is_empty() { - return Err("No transactions to validate.".to_string()); + return Ok(()); } // Reject a block carrying more than one transaction from the same account. Block diff --git a/tests/empty_block.rs b/tests/empty_block.rs new file mode 100644 index 0000000..67a1c89 --- /dev/null +++ b/tests/empty_block.rs @@ -0,0 +1,123 @@ +//! An idle chain must keep producing blocks. +//! +//! `Transaction::validate_transactions` used to reject an empty transaction list outright, so a +//! chain with nothing being submitted stopped dead at genesis. That was not cosmetic: confirmation +//! depth is counted in blocks, so anything waiting for `confirmations` blocks of depth needed +//! *later* blocks to exist — and later blocks needed more transactions. A single Mint on an +//! otherwise-quiet chain therefore never reached confirmed depth and never got credited, stalling +//! the treasury's entire credit path. +//! +//! Emptiness is not a state question, so it does not belong in a state validator. Whether an empty +//! block is *wanted* is an authoring decision, and lives in `Blockchain::author_new_block`. + +use clutch_node::node::{ + blockchain::Blockchain, + blocks::block::Block, + transactions::chain_init::ChainInit, +}; +use serial_test::serial; + +const BLOCKCHAIN_NAME: &str = "clutch-node-empty-block-test"; +const FAUCET_ADDRESS: &str = "0xdeb4cfb63db134698e1879ea24904df074726cc0"; +const AUTHOR_PUBLIC_KEY: &str = "0x9b6e8afff8329743cac73dbef83ca3cbf9a74c20"; +const AUTHOR_SECRET_KEY: &str = "0883ddd3d07303b87c954b0c9383f7b78f45e002520fc03a8adc80595dbf6509"; + +fn ci() -> ChainInit { + ChainInit { + chain_id: 2077, + is_testnet: true, + tx_fee: 1000, + ride_request_referrer_fee_bps: 2, + ride_offer_referrer_fee_bps: 2, + mint_authority: AUTHOR_PUBLIC_KEY.to_string(), + faucet_address: FAUCET_ADDRESS.to_string(), + faucet_allocation: 1_000_000_000_000_000, + } +} + +fn chain(name: &str) -> Blockchain { + Blockchain::new( + name.to_string(), + AUTHOR_PUBLIC_KEY.to_string(), + AUTHOR_SECRET_KEY.to_string(), + true, + vec![AUTHOR_PUBLIC_KEY.to_string()], + ci(), + ) +} + +/// The defect fix, proven end to end: a block carrying NO transactions imports and advances the +/// chain. Goes through the real `import_block` (author check, block validation, +/// `validate_transactions`, `add_block_to_chain`) rather than calling the validator directly, so +/// the whole path is covered — an empty block used to be rejected partway through. +#[test] +#[serial] +fn an_empty_block_imports_and_advances_the_chain() { + let mut blockchain = chain(BLOCKCHAIN_NAME); + + let genesis = blockchain + .get_latest_block() + .expect("db read failed") + .expect("genesis must exist"); + + let mut empty_block = Block::new_block(genesis.index + 1, genesis.hash.clone(), vec![]); + empty_block.sign(&AUTHOR_PUBLIC_KEY.to_string(), &AUTHOR_SECRET_KEY.to_string()); + + blockchain + .import_block(&empty_block) + .expect("an empty block must be a valid Aura heartbeat, not an error"); + + let latest = blockchain + .get_latest_block() + .expect("db read failed") + .expect("latest must exist"); + assert_eq!(latest.index, genesis.index + 1, "the chain must advance on an empty block"); + assert!(latest.transactions.is_empty(), "the heartbeat block carries no transactions"); + + // No transactions means no fees, so the author must NOT be credited — an empty block cannot + // become a source of new CLT. + let author = blockchain.get_account_state(&AUTHOR_PUBLIC_KEY.to_string()); + assert_eq!(author.balance, 0, "an empty block pays no tx fee and must not mint anything"); + + blockchain.shutdown_blockchain(); +} + +/// The counterpart guard: empty blocks are a heartbeat, not throughput. The authoring loop ticks +/// every second while a slot lasts `step_duration` seconds (60 with a single authority), so a fresh +/// chain whose genesis sits in the current slot must REFUSE to author another empty block — +/// otherwise allowing empty blocks would emit one per second. +#[test] +#[serial] +fn author_refuses_a_second_empty_block_in_the_same_slot() { + let mut blockchain = chain("clutch-node-empty-block-slot-test"); + + // Measured, not assumed to be 0: a previous aborted run can leave this database behind + // (developer_mode only cleans up on a normal shutdown, which a panicking test skips). The + // property under test is "the height did not move", which holds from any starting height. + let before = blockchain + .get_latest_block() + .expect("db read failed") + .expect("a chain must exist") + .index; + + // The latest block was just written, so it is in the current slot, and the pool is empty. + let result = blockchain.author_new_block(); + + let after = blockchain + .get_latest_block() + .expect("db read failed") + .expect("a chain must exist") + .index; + // Assert the height first: it is the property with consequences, and checking it before + // unwrapping the error means a regression reports "it produced a block" rather than a + // confusing message about an unexpected Ok. + assert_eq!(after, before, "no block may be produced when this slot already has one"); + + let err = result.expect_err("a second empty block in one slot must be refused"); + assert!( + err.contains("this slot already has a block"), + "refusal must be the slot guard, not an unrelated failure: {err}" + ); + + blockchain.shutdown_blockchain(); +}