From 4f0f0caa078117ee9062cb244d25809ded6d512e Mon Sep 17 00:00:00 2001 From: nol4lej Date: Tue, 13 Oct 2026 13:15:23 -0300 Subject: [PATCH 1/2] feat(shielded-pool)!: unsigned-only spends, fee ledger credited to the block author and claimed with claim_fees; precompile keeps shield only --- .../evm/precompile/shielded-pool/CHANGELOG.md | 17 + frame/evm/precompile/shielded-pool/Cargo.toml | 3 +- .../shielded-pool/src/abi/dynamic.rs | 132 +---- .../precompile/shielded-pool/src/abi/guard.rs | 11 +- .../precompile/shielded-pool/src/abi/mod.rs | 8 +- .../shielded-pool/src/abi/scalar.rs | 8 +- .../src/calls/claim_relay_fees.rs | 49 -- .../shielded-pool/src/calls/commit_relay.rs | 50 -- .../precompile/shielded-pool/src/calls/mod.rs | 9 +- .../src/calls/private_transfer.rs | 145 ----- .../shielded-pool/src/calls/unshield.rs | 129 ----- .../shielded-pool/src/dispatch/mod.rs | 16 +- .../shielded-pool/src/dispatch/origin.rs | 38 +- frame/evm/precompile/shielded-pool/src/lib.rs | 98 +--- .../evm/precompile/shielded-pool/src/mock.rs | 116 +--- .../precompile/shielded-pool/src/tests/abi.rs | 154 +----- .../shielded-pool/src/tests/adversarial.rs | 185 ++----- .../shielded-pool/src/tests/flows.rs | 55 -- .../precompile/shielded-pool/src/tests/mod.rs | 181 +----- .../src/tests/private_transfer.rs | 389 ------------- .../shielded-pool/src/tests/relay.rs | 370 ------------- .../shielded-pool/src/tests/unshield.rs | 180 ------ frame/shielded-pool/CHANGELOG.md | 30 + frame/shielded-pool/Cargo.toml | 6 +- frame/shielded-pool/README.md | 23 +- frame/shielded-pool/runtime-api/Cargo.toml | 8 +- frame/shielded-pool/runtime-api/src/lib.rs | 40 +- frame/shielded-pool/src/benchmarking.rs | 134 +---- frame/shielded-pool/src/lib.rs | 224 ++++---- frame/shielded-pool/src/mock.rs | 92 +--- frame/shielded-pool/src/operations/fees.rs | 171 ++---- frame/shielded-pool/src/operations/mod.rs | 5 +- .../src/operations/private_transfer.rs | 11 +- .../shielded-pool/src/operations/unshield.rs | 18 +- frame/shielded-pool/src/origin.rs | 57 -- frame/shielded-pool/src/tests/commit_relay.rs | 193 ------- frame/shielded-pool/src/tests/fees.rs | 516 ++++-------------- frame/shielded-pool/src/tests/mod.rs | 24 +- frame/shielded-pool/src/tests/origin.rs | 66 --- .../src/tests/private_transfer.rs | 121 ++-- frame/shielded-pool/src/tests/unshield.rs | 182 ++---- .../src/validate_unsigned/codes.rs | 6 +- .../src/validate_unsigned/tests.rs | 22 +- .../src/validate_unsigned/transfer.rs | 2 +- frame/shielded-pool/src/weights.rs | 157 ++---- 45 files changed, 626 insertions(+), 3825 deletions(-) delete mode 100644 frame/evm/precompile/shielded-pool/src/calls/claim_relay_fees.rs delete mode 100644 frame/evm/precompile/shielded-pool/src/calls/commit_relay.rs delete mode 100644 frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs delete mode 100644 frame/evm/precompile/shielded-pool/src/calls/unshield.rs delete mode 100644 frame/evm/precompile/shielded-pool/src/tests/flows.rs delete mode 100644 frame/evm/precompile/shielded-pool/src/tests/private_transfer.rs delete mode 100644 frame/evm/precompile/shielded-pool/src/tests/relay.rs delete mode 100644 frame/evm/precompile/shielded-pool/src/tests/unshield.rs delete mode 100644 frame/shielded-pool/src/origin.rs delete mode 100644 frame/shielded-pool/src/tests/commit_relay.rs delete mode 100644 frame/shielded-pool/src/tests/origin.rs diff --git a/frame/evm/precompile/shielded-pool/CHANGELOG.md b/frame/evm/precompile/shielded-pool/CHANGELOG.md index 1a4dca24..bb21fb64 100644 --- a/frame/evm/precompile/shielded-pool/CHANGELOG.md +++ b/frame/evm/precompile/shielded-pool/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to `pallet-evm-precompile-shielded-pool` will be documented ## [Unreleased] +## [0.10.0] - 2026-10-13 + +**Breaking** — only `shield` remains. Requires the matching +`pallet-shielded-pool`. + +### Removed + +- `privateTransfer` (`0x63d0b9a0`), `unshield` (`0x4e505348`), `commitRelay` + (`0xc9b235ff`) and `claimRelayFees` (`0x2a3274dd`) revert with + `retired in spec 19: submit spends unsigned via Substrate`. Spends are + unsigned Substrate extrinsics and fees are claimed with `claim_fees`. +- `decode_relayable_call`, `selectors`, `MAX_RELAY_COMMITS_PER_CALL`, + `MAX_SPEND_INPUTS`, the relayed dispatch and the `pallet-relayer` + dev-dependency. +- The runtime no longer needs `RuntimeOrigin: From` + or `AccountId: From<[u8; 32]>` for the precompile. + ## [0.9.0] - 2026-10-08 ### Changed diff --git a/frame/evm/precompile/shielded-pool/Cargo.toml b/frame/evm/precompile/shielded-pool/Cargo.toml index 8e4651aa..28126680 100644 --- a/frame/evm/precompile/shielded-pool/Cargo.toml +++ b/frame/evm/precompile/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-evm-precompile-shielded-pool" -version = "0.9.0" +version = "0.10.0" authors = { workspace = true } edition = "2021" description = "EVM Precompile for Orbinum Shielded Pool Pallet." @@ -41,7 +41,6 @@ std = [ [dev-dependencies] frame-system = { workspace = true, features = ["default"] } pallet-balances = { workspace = true, features = ["default"] } -pallet-relayer = { workspace = true, features = ["default"] } pallet-timestamp = { workspace = true, features = ["default"] } pallet-zk-verifier = { workspace = true, features = ["default"] } scale-codec = { workspace = true, features = ["derive", "std"] } diff --git a/frame/evm/precompile/shielded-pool/src/abi/dynamic.rs b/frame/evm/precompile/shielded-pool/src/abi/dynamic.rs index 0cabf488..148ba275 100644 --- a/frame/evm/precompile/shielded-pool/src/abi/dynamic.rs +++ b/frame/evm/precompile/shielded-pool/src/abi/dynamic.rs @@ -1,6 +1,6 @@ -//! Dynamic ABI decoders: `bytes`, `bytes32[]`, `bytes[]`. +//! Dynamic ABI decoder: `bytes`. //! -//! Each reads an offset pointer from the head, then a length/count word at that +//! Reads an offset pointer from the head, then a length word at that //! offset, then the data region — every step bounds-checked through [`super::guard`]. use alloc::vec::Vec; @@ -8,8 +8,7 @@ use alloc::vec::Vec; use fp_evm::PrecompileFailure; use sp_core::U256; -use super::guard::{checked_add, checked_mul, checked_range, word_to_usize}; -use crate::revert; +use super::guard::{checked_add, checked_range, word_to_usize}; /// Decodes a dynamic `bytes` value. /// @@ -27,81 +26,6 @@ pub fn decode_bytes_at_slot( Ok(params[range].to_vec()) } -/// Decodes a `bytes32[]` of at most `max_count` items whose offset pointer lives -/// at `slot_start` in `params`. -pub fn decode_bytes32_array_at_slot( - params: &[u8], - slot_start: usize, - max_count: usize, -) -> Result, PrecompileFailure> { - let offset = read_offset(params, slot_start)?; - let count = read_length(params, offset)?; - if count > max_count { - return Err(revert("bytes32[] has too many items")); - } - let data_start = checked_add(offset, 32, "bytes32[] offset overflows")?; - // `count * 32` is the whole span. Bounds-checking it here rejects an oversized - // array before `with_capacity` reserves for a count that comes from calldata. - let span = checked_mul(count, 32, "bytes32[] count overflows")?; - let range = checked_range(data_start, span, params.len(), "bytes32[]")?; - let data_start = range.start; - let mut items = Vec::with_capacity(count); - for i in 0..count { - let s = data_start + i * 32; // bounded by the checked span above - let elem: [u8; 32] = params[s..s + 32] - .try_into() - .map_err(|_| revert("bytes32[] element copy failed"))?; - items.push(elem); - } - Ok(items) -} - -/// Decodes a `bytes[]` of at most `max_count` items of at most `max_len` bytes -/// each, whose offset pointer lives at `slot_start` in `params`. -/// -/// Both bounds are checked before anything is copied. Element pointers may all -/// point at the same data, so without them `count × length` bytes — quadratic in -/// the calldata — would be allocated before any gas is charged. -pub fn decode_bytes_array_at_slot( - params: &[u8], - slot_start: usize, - max_count: usize, - max_len: usize, -) -> Result>, PrecompileFailure> { - let array_offset = read_offset(params, slot_start)?; - let count = read_length(params, array_offset)?; - if count > max_count { - return Err(revert("bytes[] has too many items")); - } - // Each element has a relative offset pointer from the start of the array-data - // region (= array_offset + 32, right after the length word). - let data_base = checked_add(array_offset, 32, "bytes[] offset overflows")?; - - // Each element needs a 32-byte pointer. Bounds-checking the pointer table here - // keeps a declared count of 2^30 from reserving gigabytes in `with_capacity` - // on input that could never decode. - let pointers = checked_mul(count, 32, "bytes[] count overflows")?; - checked_range(data_base, pointers, params.len(), "bytes[] pointer table")?; - - let mut result = Vec::with_capacity(count); - for i in 0..count { - let rel_offset_pos = data_base + i * 32; // bounded by the pointer table above - let rel_offset = word_to_usize( - U256::from_big_endian(¶ms[rel_offset_pos..rel_offset_pos + 32]), - "bytes[] element offset does not fit in usize", - )?; - let abs_offset = checked_add(data_base, rel_offset, "bytes[] element offset overflows")?; - let elem_len = read_length(params, abs_offset)?; - if elem_len > max_len { - return Err(revert("bytes[] item is too long")); - } - let elem_start = checked_add(abs_offset, 32, "bytes[] element start overflows")?; - let range = checked_range(elem_start, elem_len, params.len(), "bytes[] element data")?; - result.push(params[range].to_vec()); - } - Ok(result) -} - /// Reads an ABI offset from a 32-byte slot at `slot_start` in `params`. fn read_offset(params: &[u8], slot_start: usize) -> Result { let range = checked_range(slot_start, 32, params.len(), "offset slot")?; @@ -178,29 +102,6 @@ mod tests { assert!(decode_bytes_at_slot(¶ms, 0).is_err()); } - /// `count * 32` wraps the same way. 2^59 elements multiplies past usize on a - /// 64-bit host and lands back inside the buffer. - #[test] - fn an_element_count_that_would_wrap_is_refused() { - let mut params = word(U256::from(32u64)).to_vec(); - params.extend_from_slice(&word(U256::from(1u64 << 59))); - params.resize(256, 0); - - assert!(decode_bytes32_array_at_slot(¶ms, 0, usize::MAX).is_err()); - } - - /// An element count far past what the buffer can hold must be refused - /// *before* `Vec::with_capacity` reserves for it. - #[test] - fn an_element_count_larger_than_the_buffer_is_refused_early() { - let mut params = word(U256::from(32u64)).to_vec(); - params.extend_from_slice(&word(U256::from(1u64 << 30))); // ~1e9 elements - params.resize(256, 0); - - assert!(decode_bytes_array_at_slot(¶ms, 0, usize::MAX, usize::MAX).is_err()); - assert!(decode_bytes32_array_at_slot(¶ms, 0, usize::MAX).is_err()); - } - // ─── Well-formed input still decodes ───────────────────────────────────── /// The guards must not reject real calldata, or they trade one bug for a @@ -215,31 +116,4 @@ mod tests { assert_eq!(decode_bytes_at_slot(¶ms, 0).unwrap(), payload.to_vec()); } - - #[test] - fn a_well_formed_bytes32_array_still_decodes() { - let mut params = word(U256::from(32u64)).to_vec(); // offset - params.extend_from_slice(&word(U256::from(2u64))); // count - params.extend_from_slice(&[0x11u8; 32]); - params.extend_from_slice(&[0x22u8; 32]); - - let out = decode_bytes32_array_at_slot(¶ms, 0, usize::MAX).unwrap(); - assert_eq!(out, alloc::vec![[0x11u8; 32], [0x22u8; 32]]); - } - - /// An empty dynamic array is valid ABI and must survive the count checks. - #[test] - fn an_empty_array_still_decodes() { - let mut params = word(U256::from(32u64)).to_vec(); - params.extend_from_slice(&word(U256::zero())); - - assert!(decode_bytes32_array_at_slot(¶ms, 0, usize::MAX) - .unwrap() - .is_empty()); - assert!( - decode_bytes_array_at_slot(¶ms, 0, usize::MAX, usize::MAX) - .unwrap() - .is_empty() - ); - } } diff --git a/frame/evm/precompile/shielded-pool/src/abi/guard.rs b/frame/evm/precompile/shielded-pool/src/abi/guard.rs index 7c819507..6c1838ac 100644 --- a/frame/evm/precompile/shielded-pool/src/abi/guard.rs +++ b/frame/evm/precompile/shielded-pool/src/abi/guard.rs @@ -1,6 +1,6 @@ //! Bounds- and overflow-checked arithmetic over attacker-chosen calldata. //! -//! Offsets, lengths and counts all come from calldata, so every `+`/`*` on them +//! Offsets and lengths come from calldata, so every `+` on them //! is checked: release builds do not enable `overflow-checks`, and a wrapped sum //! passes the very bounds check meant to reject it. This module is the only place //! that arithmetic lives, and the only place that reads a 256-bit word as a `usize`. @@ -19,15 +19,6 @@ pub(super) fn checked_add( a.checked_add(b).ok_or_else(|| revert(what)) } -/// Checked `a * b`, mapping overflow to an ABI error labelled `what`. -pub(super) fn checked_mul( - a: usize, - b: usize, - what: &'static str, -) -> Result { - a.checked_mul(b).ok_or_else(|| revert(what)) -} - /// The range `start..start + span`, verified to fit inside a buffer of `params_len` /// without wrapping. `what` labels the error on both the overflow and the /// out-of-bounds path. diff --git a/frame/evm/precompile/shielded-pool/src/abi/mod.rs b/frame/evm/precompile/shielded-pool/src/abi/mod.rs index f4919c92..c1e1a63b 100644 --- a/frame/evm/precompile/shielded-pool/src/abi/mod.rs +++ b/frame/evm/precompile/shielded-pool/src/abi/mod.rs @@ -12,12 +12,12 @@ //! //! Split by responsibility: //! - [`guard`] — the checked arithmetic and `usize` conversion every decoder relies on. -//! - [`scalar`] — fixed-width types (`uint32`, `uint256`, `bytes32`). -//! - [`dynamic`] — offset/length-driven types (`bytes`, `bytes32[]`, `bytes[]`). +//! - [`scalar`] — fixed-width types (`uint32`, `bytes32`). +//! - [`dynamic`] — offset/length-driven types (`bytes`). mod dynamic; mod guard; mod scalar; -pub use dynamic::{decode_bytes32_array_at_slot, decode_bytes_array_at_slot, decode_bytes_at_slot}; -pub use scalar::{read_bytes32, read_u256, read_u32}; +pub use dynamic::decode_bytes_at_slot; +pub use scalar::{read_bytes32, read_u32}; diff --git a/frame/evm/precompile/shielded-pool/src/abi/scalar.rs b/frame/evm/precompile/shielded-pool/src/abi/scalar.rs index 5ecb0e85..a5d97e27 100644 --- a/frame/evm/precompile/shielded-pool/src/abi/scalar.rs +++ b/frame/evm/precompile/shielded-pool/src/abi/scalar.rs @@ -1,4 +1,4 @@ -//! Fixed-width ABI decoders: `uint32`, `uint256` and `bytes32`. +//! Fixed-width ABI decoders: `uint32` and `bytes32`. use fp_evm::PrecompileFailure; use sp_core::U256; @@ -22,12 +22,6 @@ pub fn read_u32(params: &[u8], slot_start: usize) -> Result Result { - let range = checked_range(slot_start, 32, params.len(), "uint256 slot")?; - Ok(U256::from_big_endian(¶ms[range])) -} - /// Reads the 32-byte value at `params[slot_start..slot_start+32]` verbatim. pub fn read_bytes32(params: &[u8], slot_start: usize) -> Result<[u8; 32], PrecompileFailure> { let range = checked_range(slot_start, 32, params.len(), "bytes32 slot")?; diff --git a/frame/evm/precompile/shielded-pool/src/calls/claim_relay_fees.rs b/frame/evm/precompile/shielded-pool/src/calls/claim_relay_fees.rs deleted file mode 100644 index 65c7592e..00000000 --- a/frame/evm/precompile/shielded-pool/src/calls/claim_relay_fees.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! ABI decoding and call construction for `claimRelayFees(uint32,uint256)`. -//! -//! ## Selector -//! `keccak256("claimRelayFees(uint32,uint256)")[0..4]` = `0x2a3274dd` -//! -//! ## ABI layout (`input[4..]`) -//! | Slot (bytes) | Type | Field | -//! |--------------|-----------|------------| -//! | 0..32 | `uint32` | `asset_id` | -//! | 32..64 | `uint256` | `amount` | -//! -//! ## Notes -//! The claimant is the EVM caller, carried by the dispatch origin: it spends the -//! pending fees of the account registered to that address and receives them in -//! its own mirror account. - -use fp_evm::PrecompileFailure; - -use super::{balance, params}; -use crate::{abi, revert}; - -/// Selector for the signature in this module's header. -pub const SELECTOR: [u8; 4] = [0x2a, 0x32, 0x74, 0xdd]; - -/// Decodes `input` into a ready-to-dispatch `claim_relay_fees` call. -pub fn decode(input: &[u8]) -> Result, PrecompileFailure> -where - T: pallet_shielded_pool::Config, - pallet_shielded_pool::BalanceOf: TryFrom, -{ - // Step 1: require the two-slot head. - let params = params(input, 64, "claimRelayFees: input too short")?; - - // Step 2: asset_id. - let asset_id = abi::read_u32(params, 0)?; - - // Step 3: amount. A zero claim would move nothing. - let amount_u256 = abi::read_u256(params, 32)?; - if amount_u256.is_zero() { - return Err(revert("claimRelayFees: amount must be non-zero")); - } - let amount = balance::( - amount_u256, - "claimRelayFees: amount overflow", - "claimRelayFees: amount conversion failed", - )?; - - Ok(pallet_shielded_pool::Call::::claim_relay_fees { asset_id, amount }) -} diff --git a/frame/evm/precompile/shielded-pool/src/calls/commit_relay.rs b/frame/evm/precompile/shielded-pool/src/calls/commit_relay.rs deleted file mode 100644 index bfbeea77..00000000 --- a/frame/evm/precompile/shielded-pool/src/calls/commit_relay.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! ABI decoding and call construction for `commitRelay(bytes32[])`. -//! -//! ## Selector -//! `keccak256("commitRelay(bytes32[])")[0..4]` = `0xc9b235ff` -//! -//! ## ABI layout (`input[4..]`) -//! | Slot (bytes) | Type | Field | -//! |--------------|-----------|---------------------| -//! | 0..32 | `uint256` | offset → `commits` | -//! -//! ## Notes -//! Each commit is `pallet_relayer::relay_commit_hash(op_hash, relayer)`. The -//! relayer is the EVM caller, carried by the dispatch origin — it must be a -//! registered relay address. - -use fp_evm::PrecompileFailure; -use frame_support::{traits::ConstU32, BoundedVec}; -use pallet_shielded_pool::MAX_RELAY_COMMITS_PER_CALL; -use sp_core::H256; - -use super::params; -use crate::{abi, revert}; - -/// Selector for the signature in this module's header. -pub const SELECTOR: [u8; 4] = [0xc9, 0xb2, 0x35, 0xff]; - -/// Decodes `input` into a ready-to-dispatch `commit_relay` call. -pub fn decode(input: &[u8]) -> Result, PrecompileFailure> -where - T: pallet_shielded_pool::Config, -{ - // Step 1: require the one-slot head. - let params = params(input, 32, "commitRelay: input too short")?; - - // Step 2: commits — dynamic, offset at slot 0. The decoder bounds the count, - // so the conversion cannot fail. - let commits: BoundedVec> = - abi::decode_bytes32_array_at_slot(params, 0, MAX_RELAY_COMMITS_PER_CALL as usize)? - .into_iter() - .map(H256) - .collect::>() - .try_into() - .map_err(|_| revert("commitRelay: too many commits"))?; - - if commits.is_empty() { - return Err(revert("commitRelay: at least one commit required")); - } - - Ok(pallet_shielded_pool::Call::::commit_relay { commits }) -} diff --git a/frame/evm/precompile/shielded-pool/src/calls/mod.rs b/frame/evm/precompile/shielded-pool/src/calls/mod.rs index 997ae23a..9388d593 100644 --- a/frame/evm/precompile/shielded-pool/src/calls/mod.rs +++ b/frame/evm/precompile/shielded-pool/src/calls/mod.rs @@ -14,14 +14,11 @@ //! amount, a burn address, a memo without which a note could never be spent — //! rather than leaving it to the pallet. //! -//! The helpers below are the steps several decoders share. Each takes the -//! revert messages of its caller, so every rejection still names its function. +//! The helpers below are the decoding steps a call shares with the ABI layer. +//! Each takes the revert messages of its caller, so every rejection still names +//! its function. -pub mod claim_relay_fees; -pub mod commit_relay; -pub mod private_transfer; pub mod shield; -pub mod unshield; use fp_evm::PrecompileFailure; use pallet_shielded_pool::{BalanceOf, Proof}; diff --git a/frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs b/frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs deleted file mode 100644 index 36c93a85..00000000 --- a/frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! ABI decoding and call construction for -//! `privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)`. -//! -//! ## Selector -//! `keccak256("privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)")[0..4]` -//! = `0x63d0b9a0` -//! -//! ## ABI layout (`input[4..]`) -//! | Slot (bytes) | Type | Field | -//! |--------------|-----------|------------------------| -//! | 0..32 | `uint256` | offset → `proof` | -//! | 32..64 | `uint256` | offset → `merkle_roots`| -//! | 64..96 | `uint256` | offset → `nullifiers` | -//! | 96..128 | `uint256` | offset → `commitments`| -//! | 128..160 | `uint256` | offset → `memos` | -//! | 160..192 | `uint32` | `asset_id` | -//! | 192..224 | `uint256` | `fee` | -//! | 224..256 | `uint32` | `circuit_version` | -//! -//! ## Notes -//! `merkle_roots` holds exactly two roots, one per input note in input order; -//! they differ when the notes come from different trees. -//! -//! The three other arrays are parallel: `commitments[i]` and `memos[i]` describe the -//! output note paid for by `nullifiers[i]`, so all three must have equal length. -//! -//! The relay fee recipient is not in the ABI: it is the relayer that recorded a -//! relay commit for this spend (`commitRelay`), whoever submits it. - -use alloc::vec::Vec; - -use fp_evm::PrecompileFailure; -use frame_support::{traits::ConstU32, BoundedVec}; -use pallet_shielded_pool::{ - types::MAX_ENCRYPTED_MEMO_SIZE, Commitment, FrameEncryptedMemo, Nullifier, -}; - -use super::{balance, params, proof}; -use crate::{abi, revert}; - -/// Selector for the signature in this module's header. -pub const SELECTOR: [u8; 4] = [0x63, 0xd0, 0xb9, 0xa0]; - -/// Maximum number of input nullifiers / output commitments in a single transfer. -pub const MAX_NOTES: u32 = 2; - -/// Decodes `input` into a ready-to-dispatch `private_transfer` call. -/// -/// Beyond the ABI itself, this enforces the structural invariant the proof does -/// not cover: at least one input note, and the three arrays equal in length. -pub fn decode(input: &[u8]) -> Result, PrecompileFailure> -where - T: pallet_shielded_pool::Config, - pallet_shielded_pool::BalanceOf: TryFrom, -{ - // Step 1: require all eight head slots. - let params = params(input, 256, "privateTransfer: input too short")?; - - // Step 2: proof — dynamic, offset at slot 0. - let proof = proof( - params, - 0, - "privateTransfer: proof too long", - "privateTransfer: proof must be non-empty", - )?; - - // Step 3: one root per input note, in input order. The circuit always has two - // inputs (a dummy pads a one-note spend), so exactly two roots. - let merkle_roots: [pallet_shielded_pool::Hash; 2] = - abi::decode_bytes32_array_at_slot(params, 32, 2)? - .try_into() - .map_err(|_| revert("privateTransfer: exactly two merkle roots required"))?; - - // Step 4: the three parallel arrays — nullifiers spent, commitments created, - // and the memo carrying each new note's secrets. The decoders bound count and - // item size, so the conversions below cannot fail. - let nullifiers: BoundedVec> = - abi::decode_bytes32_array_at_slot(params, 64, MAX_NOTES as usize)? - .into_iter() - .map(Nullifier::from) - .collect::>() - .try_into() - .map_err(|_| revert("privateTransfer: too many nullifiers"))?; - - let commitments: BoundedVec> = - abi::decode_bytes32_array_at_slot(params, 96, MAX_NOTES as usize)? - .into_iter() - .map(Commitment::from) - .collect::>() - .try_into() - .map_err(|_| revert("privateTransfer: too many commitments"))?; - - let encrypted_memos: BoundedVec> = - abi::decode_bytes_array_at_slot( - params, - 128, - MAX_NOTES as usize, - MAX_ENCRYPTED_MEMO_SIZE as usize, - )? - .into_iter() - .map(|m| FrameEncryptedMemo::new(m).map_err(|_| revert("privateTransfer: memo too long"))) - .collect::, _>>()? - .try_into() - .map_err(|_| revert("privateTransfer: too many memos"))?; - - // Step 5: the arrays must line up. A length mismatch is malformed input, not a - // balance question, and the proof cannot catch it — it constrains values, not - // how many memos were attached, so a short memo array would silently drop the - // secrets for an output note that still gets created. - if nullifiers.is_empty() { - return Err(revert("privateTransfer: at least one nullifier required")); - } - if nullifiers.len() != commitments.len() { - return Err(revert( - "privateTransfer: nullifier/commitment count mismatch", - )); - } - if commitments.len() != encrypted_memos.len() { - return Err(revert("privateTransfer: commitment/memo count mismatch")); - } - - // Step 6: asset_id. - let asset_id = abi::read_u32(params, 160)?; - - // Step 7: fee paid to the relayer. - let fee = balance::( - abi::read_u256(params, 192)?, - "privateTransfer: fee overflow", - "privateTransfer: fee conversion failed", - )?; - - // Step 8: circuit_version, selecting the VK the proof is checked against. - let circuit_version = abi::read_u32(params, 224)?; - - Ok(pallet_shielded_pool::Call::::private_transfer { - proof, - merkle_roots, - nullifiers, - commitments, - encrypted_memos, - asset_id, - fee, - circuit_version, - }) -} diff --git a/frame/evm/precompile/shielded-pool/src/calls/unshield.rs b/frame/evm/precompile/shielded-pool/src/calls/unshield.rs deleted file mode 100644 index d91e358f..00000000 --- a/frame/evm/precompile/shielded-pool/src/calls/unshield.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! ABI decoding and call construction for -//! `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)`. -//! -//! ## Selector -//! `keccak256("unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)")[0..4]` -//! = `0x4e505348` -//! -//! ## ABI layout (`input[4..]`) -//! | Slot (bytes) | Type | Field | -//! |--------------|-----------|----------------------------------| -//! | 0..32 | `uint256` | offset → `proof` | -//! | 32..64 | `bytes32` | `merkle_root` | -//! | 64..96 | `bytes32` | `nullifier` | -//! | 96..128 | `uint32` | `asset_id` | -//! | 128..160 | `uint256` | `amount` | -//! | 160..192 | `bytes32` | `recipient` (AccountId32) | -//! | 192..224 | `uint256` | `fee` | -//! | 224..256 | `bytes32` | `change_commitment` | -//! | 256..288 | `uint256` | offset → `change_encrypted_memo` | -//! | 288..320 | `uint32` | `circuit_version` | -//! -//! ## Notes -//! `recipient` is an `AccountId32` in a `bytes32` slot — either a Substrate-native -//! account or one derived from an H160 (`H160 ++ [0x00; 12]`). -//! -//! `change_commitment` is all-zero for a total unshield. For a partial one it is -//! `NoteCommitment(change_value, asset_id, change_owner_pk, change_blinding)`, and -//! `change_encrypted_memo` then holds `nonce(12) || ciphertext(132) || ephPk(32)`. -//! -//! The relay fee recipient is not in the ABI: it is the relayer that recorded a -//! relay commit for this spend (`commitRelay`), whoever submits it. - -use fp_evm::PrecompileFailure; -use pallet_shielded_pool::types::EncryptedMemo; - -use super::{balance, params, proof}; -use crate::{abi, revert}; - -/// Selector for the signature in this module's header. -pub const SELECTOR: [u8; 4] = [0x4e, 0x50, 0x53, 0x48]; - -/// Decodes `input` into a ready-to-dispatch `unshield` call. -/// -/// Rejects anything the pallet would have to reject anyway, plus the inputs that -/// are irrecoverable rather than merely invalid: a zero amount, the zero -/// recipient, and a malformed change memo. -pub fn decode(input: &[u8]) -> Result, PrecompileFailure> -where - T: pallet_shielded_pool::Config, - pallet_shielded_pool::BalanceOf: TryFrom, - ::AccountId: From<[u8; 32]>, -{ - // Step 1: require the whole 10-slot head. - let params = params(input, 320, "unshield: input too short")?; - - // Step 2: proof — dynamic, offset at slot 0. - let proof = proof( - params, - 0, - "unshield: proof too long", - "unshield: proof must be non-empty", - )?; - - // Step 3: merkle_root and the nullifier of the note being spent. - let merkle_root: pallet_shielded_pool::Hash = abi::read_bytes32(params, 32)?; - let nullifier = pallet_shielded_pool::Nullifier::from(abi::read_bytes32(params, 64)?); - - // Step 4: asset_id. - let asset_id = abi::read_u32(params, 96)?; - - // Step 5: amount. Zero is rejected here as well as in the pallet — it is a - // no-op that still burns the nullifier, destroying the note it spends. - let amount_u256 = abi::read_u256(params, 128)?; - if amount_u256.is_zero() { - return Err(revert("unshield: amount must be non-zero")); - } - let amount = balance::( - amount_u256, - "unshield: amount overflow", - "unshield: amount conversion failed", - )?; - - // Step 6: recipient. The all-zero AccountId32 has no known private key, so - // unshielding to it destroys the funds with no possibility of recovery. - let recipient_bytes = abi::read_bytes32(params, 160)?; - if recipient_bytes == [0u8; 32] { - return Err(revert("unshield: recipient must not be the zero address")); - } - let recipient: ::AccountId = recipient_bytes.into(); - - // Step 7: fee paid to the relayer. - let fee = balance::( - abi::read_u256(params, 192)?, - "unshield: fee overflow", - "unshield: fee conversion failed", - )?; - - // Step 8: change_commitment. All-zero means a total unshield, which leaves no - // change note behind. - let change_commitment: pallet_shielded_pool::Hash = abi::read_bytes32(params, 224)?; - - // Step 9: change_encrypted_memo — dynamic, offset at slot 256. Always - // encoded: a total unshield carries an empty one. The pallet then requires - // exactly 180 bytes when there is a change note, and none otherwise. - let change_encrypted_memo_bytes = abi::decode_bytes_at_slot(params, 256) - .map_err(|_| revert("unshield: malformed change_encrypted_memo"))?; - let change_encrypted_memo = if change_encrypted_memo_bytes.is_empty() { - EncryptedMemo::default() - } else { - EncryptedMemo::new(change_encrypted_memo_bytes) - .map_err(|_| revert("unshield: invalid change_encrypted_memo"))? - }; - - // Step 10: circuit_version. - let circuit_version = abi::read_u32(params, 288)?; - - Ok(pallet_shielded_pool::Call::::unshield { - proof, - merkle_root, - nullifier, - asset_id, - amount, - recipient, - fee, - change_commitment, - change_encrypted_memo, - circuit_version, - }) -} diff --git a/frame/evm/precompile/shielded-pool/src/dispatch/mod.rs b/frame/evm/precompile/shielded-pool/src/dispatch/mod.rs index 0230e6d0..18216adb 100644 --- a/frame/evm/precompile/shielded-pool/src/dispatch/mod.rs +++ b/frame/evm/precompile/shielded-pool/src/dispatch/mod.rs @@ -1,16 +1,13 @@ //! Dispatch helpers for the shielded-pool precompile. //! -//! Two dispatch modes mirror the pallet's extrinsic origin checks. They differ -//! only in the origin they build; everything else — converting the pallet call to -//! a runtime call, charging gas from its weight, mapping the dispatch result to a -//! precompile outcome — is shared here and reached through [`record_and_dispatch`]. -//! -//! - [`origin::from_self`] — the precompile's own address as signed origin. -//! - [`origin::relayed`] — carries the EVM caller as the relaying address. +//! [`origin::from_self`] builds the origin (the precompile's own address, +//! signed); converting the pallet call to a runtime call, charging gas from its +//! weight and mapping the dispatch result to a precompile outcome live here, in +//! [`record_and_dispatch`]. mod origin; -pub use origin::{from_self, relayed}; +pub use origin::from_self; use alloc::format; @@ -25,8 +22,7 @@ use crate::revert; type RuntimeOriginOf = <::RuntimeCall as Dispatchable>::RuntimeOrigin; /// Converts the pallet call to a runtime call, charges its gas, dispatches it with -/// the origin from `build_origin`, and maps the result to a precompile outcome. Each -/// mode in [`origin`] just builds its origin and hands it here — the rest is shared. +/// the origin from `build_origin`, and maps the result to a precompile outcome. fn record_and_dispatch( handle: &mut impl PrecompileHandle, call: pallet_shielded_pool::Call, diff --git a/frame/evm/precompile/shielded-pool/src/dispatch/origin.rs b/frame/evm/precompile/shielded-pool/src/dispatch/origin.rs index 781483b3..041f04bd 100644 --- a/frame/evm/precompile/shielded-pool/src/dispatch/origin.rs +++ b/frame/evm/precompile/shielded-pool/src/dispatch/origin.rs @@ -1,10 +1,9 @@ -//! The two dispatch modes, one per pallet origin check. Each builds its origin -//! and defers the shared work to [`super::record_and_dispatch`]. +//! The dispatch mode: builds the origin and defers the shared work to +//! [`super::record_and_dispatch`]. use fp_evm::{PrecompileHandle, PrecompileResult}; use frame_support::dispatch::{GetDispatchInfo, PostDispatchInfo}; use pallet_evm::AddressMapping; -use sp_core::U256; use sp_runtime::traits::Dispatchable; use super::{record_and_dispatch, RuntimeOriginOf}; @@ -34,36 +33,3 @@ where RuntimeOriginOf::::from(Some(account)) }) } - -/// Dispatches `call` carrying the **EVM caller** as the relaying address. -/// -/// Used for every call but `shield`. The EVM executor sets `caller` from the -/// transaction signature and debited its gas, so it cannot be forged by calldata. -/// It names the relayer for `commit_relay` and the claimant for -/// `claim_relay_fees`; for spends it only gates the origin, since the fee follows -/// the relay commit rather than whoever submits. -/// -/// None of these calls is payable, and the value the executor already moved to -/// the precompile address would stay there: no code sends it on, and a revert -/// does not undo a transfer the executor made before this ran. Refusing it here -/// is what makes the revert give the caller its funds back. -pub fn relayed( - handle: &mut impl PrecompileHandle, - call: pallet_shielded_pool::Call, -) -> PrecompileResult -where - T: pallet_evm::Config + pallet_shielded_pool::Config, - ::RuntimeCall: Dispatchable - + GetDispatchInfo - + From>, - RuntimeOriginOf: From, - <::RuntimeCall as Dispatchable>::PostInfo: core::fmt::Debug, -{ - if handle.context().apparent_value > U256::zero() { - return Err(crate::revert("call is not payable")); - } - let caller = handle.context().caller; - record_and_dispatch(handle, call, || { - RuntimeOriginOf::::from(pallet_shielded_pool::Origin::Relayed(caller)) - }) -} diff --git a/frame/evm/precompile/shielded-pool/src/lib.rs b/frame/evm/precompile/shielded-pool/src/lib.rs index a0f2668a..9e6ca786 100644 --- a/frame/evm/precompile/shielded-pool/src/lib.rs +++ b/frame/evm/precompile/shielded-pool/src/lib.rs @@ -17,37 +17,29 @@ use fp_evm::{Precompile, PrecompileFailure, PrecompileHandle, PrecompileResult}; use frame_support::dispatch::GetDispatchInfo; use sp_runtime::traits::Dispatchable; -/// The ABI selectors this precompile answers to. -/// -/// Exported so the relay whitelist can be pinned against them in a test rather -/// than kept in sync by hand. A hand-kept copy drifting from this one fails -/// silently: a wrong selector still yields "unknown selector", so the -/// rejection tests stay green while the accept path quietly stops working. -pub mod selectors { - pub use crate::calls::claim_relay_fees::SELECTOR as CLAIM_RELAY_FEES; - pub use crate::calls::commit_relay::SELECTOR as COMMIT_RELAY; - pub use crate::calls::private_transfer::SELECTOR as PRIVATE_TRANSFER; - pub use crate::calls::shield::SELECTOR as SHIELD; - pub use crate::calls::unshield::SELECTOR as UNSHIELD; -} - -/// Most commits one `commitRelay` call carries. -pub use pallet_shielded_pool::MAX_RELAY_COMMITS_PER_CALL; - -/// Most notes one spend consumes. -pub use calls::private_transfer::MAX_NOTES as MAX_SPEND_INPUTS; +/// Selectors of the spend and relay-fee functions retired in spec 19. Spends are +/// submitted unsigned through Substrate and fees are claimed there; calldata for +/// these reverts with [`RETIRED`]. +const RETIRED_SELECTORS: [[u8; 4]; 4] = [ + [0x63, 0xd0, 0xb9, 0xa0], // privateTransfer + [0x4e, 0x50, 0x53, 0x48], // unshield + [0xc9, 0xb2, 0x35, 0xff], // commitRelay + [0x2a, 0x32, 0x74, 0xdd], // claimRelayFees +]; + +/// Revert reason for a retired selector. +const RETIRED: &str = "retired in spec 19: submit spends unsigned via Substrate"; /// EVM precompile that bridges Solidity calls into `pallet_shielded_pool` extrinsics. /// -/// Five functions are exposed, each identified by a 4-byte ABI selector: +/// One function is exposed: /// -/// | Selector | Solidity signature | -/// |--------------|---------------------------------------------------------------------------------------| -/// | `0xf25897e0` | `shield(uint32,bytes32,bytes,bytes,uint32)` — payable, amount = `msg.value` | -/// | `0x63d0b9a0` | `privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)` | -/// | `0x4e505348` | `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)` | -/// | `0xc9b235ff` | `commitRelay(bytes32[])` — registered relayers | -/// | `0x2a3274dd` | `claimRelayFees(uint32,uint256)` — registered relayers | +/// | Selector | Solidity signature | +/// |--------------|-----------------------------------------------------------------------------| +/// | `0xf25897e0` | `shield(uint32,bytes32,bytes,bytes,uint32)` — payable, amount = `msg.value` | +/// +/// `privateTransfer`, `unshield`, `commitRelay` and `claimRelayFees` are retired +/// and revert: spends are unsigned Substrate extrinsics. /// /// Selector computation: `bytes4(keccak256("functionName(argTypes)"))`. /// Verify with: `node -e "const {ethers}=require('ethers'); console.log(ethers.id('sig').slice(0,10))"` @@ -60,11 +52,10 @@ where + GetDispatchInfo + From>, <::RuntimeCall as Dispatchable>::RuntimeOrigin: - From::AccountId>> + From, + From::AccountId>>, <::RuntimeCall as Dispatchable>::PostInfo: core::fmt::Debug, pallet_evm::AccountIdOf: Into<::AccountId>, pallet_shielded_pool::BalanceOf: TryFrom, - ::AccountId: From<[u8; 32]>, { fn execute(handle: &mut impl PrecompileHandle) -> PrecompileResult { let input = handle.input().to_vec(); @@ -73,10 +64,9 @@ where return Err(revert("input too short: missing selector")); } - // Every call acts for the account that called the precompile. Under - // DELEGATECALL `caller` is the delegating contract's caller — any contract - // a relayer calls could spend its commit quota or claim in its name — and - // under STATICCALL nothing may change state. Refuse both. + // `shield` moves the value sent to this precompile's own address. Under + // DELEGATECALL that address is the delegating contract's, and under + // STATICCALL nothing may change state. Refuse both. if handle.is_static() || handle.code_address() != handle.context().address { return Err(revert("static or delegated calls are not allowed")); } @@ -88,22 +78,7 @@ where let call = calls::shield::decode::(handle, &input)?; dispatch::from_self::(handle, call) } - calls::private_transfer::SELECTOR => { - let call = calls::private_transfer::decode::(&input)?; - dispatch::relayed::(handle, call) - } - calls::unshield::SELECTOR => { - let call = calls::unshield::decode::(&input)?; - dispatch::relayed::(handle, call) - } - calls::commit_relay::SELECTOR => { - let call = calls::commit_relay::decode::(&input)?; - dispatch::relayed::(handle, call) - } - calls::claim_relay_fees::SELECTOR => { - let call = calls::claim_relay_fees::decode::(&input)?; - dispatch::relayed::(handle, call) - } + _ if RETIRED_SELECTORS.contains(&selector) => Err(revert(RETIRED)), // A revert, not an error: an unknown selector must not burn the caller's // gas limit. _ => Err(revert("unknown selector")), @@ -111,30 +86,11 @@ where } } -/// Decodes the calldata of a relayable spend (`unshield` / `privateTransfer`) -/// exactly as `execute` does; `None` for anything else or malformed input. -/// -/// Lets the runtime derive the relay commit for calldata a node is about to -/// relay from the same decoder the precompile will run on it. -pub fn decode_relayable_call(input: &[u8]) -> Option> -where - T: pallet_shielded_pool::Config, - pallet_shielded_pool::BalanceOf: TryFrom, - ::AccountId: From<[u8; 32]>, -{ - let selector: [u8; 4] = input.get(0..4)?.try_into().ok()?; - match selector { - calls::unshield::SELECTOR => calls::unshield::decode::(input).ok(), - calls::private_transfer::SELECTOR => calls::private_transfer::decode::(input).ok(), - _ => None, - } -} - /// A revert carrying `msg` as a Solidity `Error(string)`: the caller gets its /// unused gas back and tools decode the reason. An `Error` exit would burn the -/// whole gas limit, so a relayer submitting a spend that fails (already spent, -/// say) would pay for all of it. Every failure of this precompile goes through -/// here, from a malformed selector to a pallet error. +/// whole gas limit, so a caller whose shield fails would pay for all of it. +/// Every failure of this precompile goes through here, from a malformed selector +/// to a pallet error. pub(crate) fn revert(msg: &str) -> PrecompileFailure { precompile_utils::prelude::revert(msg) } diff --git a/frame/evm/precompile/shielded-pool/src/mock.rs b/frame/evm/precompile/shielded-pool/src/mock.rs index dd1fadf5..fba08c63 100644 --- a/frame/evm/precompile/shielded-pool/src/mock.rs +++ b/frame/evm/precompile/shielded-pool/src/mock.rs @@ -1,7 +1,7 @@ //! Test mock for `pallet-evm-precompile-shielded-pool`. use fp_evm::{ExitError, ExitReason, Transfer}; -use frame_support::{derive_impl, parameter_types, traits::Get, weights::Weight, PalletId}; +use frame_support::{derive_impl, parameter_types, weights::Weight, PalletId}; use pallet_evm::{ AddressMapping, Context, EnsureAddressNever, EnsureAddressRoot, FeeCalculator, PrecompileHandle, }; @@ -169,111 +169,8 @@ fn non_empty(proof: &[u8]) -> Result { Ok(true) } -/// The pallet's EVM mirror account: the same mapping as [`H160ToAccountId32Mapping`]. -pub struct MirrorAccount; - -impl sp_runtime::traits::Convert for MirrorAccount { - fn convert(address: H160) -> AccountId32 { - H160ToAccountId32Mapping::into_account_id(address) - } -} - -pub struct MockBlockAuthor; -impl frame_support::traits::Get> for MockBlockAuthor { - fn get() -> Option { - Some(AccountId32::from([1u8; 32])) - } -} - -pub struct MockRelayer; - -/// The one relay address registered in the mock. -pub fn registered_relayer() -> H160 { - H160::repeat_byte(0x77) -} - -/// The validator account [`registered_relayer`] is registered to. -pub fn registered_relayer_account() -> AccountId32 { - AccountId32::from([0x77u8; 32]) -} - -std::thread_local! { - /// Pending relay fees of `registered_relayer_account()`. - static PENDING_RELAY_FEES: std::cell::Cell = const { std::cell::Cell::new(0) }; - /// Commits recorded through `record_relay_commits`, for assertions. - static RECORDED_COMMITS: std::cell::RefCell> = - const { std::cell::RefCell::new(Vec::new()) }; -} - -pub fn set_pending_relay_fees(amount: u128) { - PENDING_RELAY_FEES.with(|c| c.set(amount)); -} - -pub fn pending_relay_fees() -> u128 { - PENDING_RELAY_FEES.with(|c| c.get()) -} - -pub fn recorded_commits() -> Vec { - RECORDED_COMMITS.with(|c| c.borrow().clone()) -} - -impl pallet_relayer::RelayerInterface for MockRelayer { - type AccountId = AccountId32; - - fn resolve_relayer(evm_address: &sp_core::H160) -> Option { - (*evm_address == registered_relayer()).then(registered_relayer_account) - } - - fn min_relay_fee() -> u128 { - 0 - } - - fn allowed_selectors() -> sp_std::vec::Vec<[u8; 4]> { - sp_std::vec![] - } - - fn block_author() -> Option { - MockBlockAuthor::get() - } - - fn accumulate_relay_fee(_author: &AccountId32, _asset_id: u32, _amount: u128) {} - - fn pending_relay_fees(_who: &AccountId32, _asset_id: u32) -> u128 { - pending_relay_fees() - } - - fn consume_relay_fee( - who: &AccountId32, - _asset_id: u32, - amount: u128, - ) -> frame_support::dispatch::DispatchResult { - if *who != registered_relayer_account() || pending_relay_fees() < amount { - return Err(sp_runtime::DispatchError::Other( - "insufficient pending fees", - )); - } - PENDING_RELAY_FEES.with(|c| c.set(c.get() - amount)); - Ok(()) - } - - fn registered_evm_address(who: &AccountId32) -> Option { - (*who == registered_relayer_account()).then(registered_relayer) - } - - fn record_relay_commits( - relayer: &sp_core::H160, - commits: &[sp_core::H256], - ) -> frame_support::dispatch::DispatchResult { - if *relayer != registered_relayer() { - return Err(sp_runtime::DispatchError::Other("not registered")); - } - RECORDED_COMMITS.with(|c| c.borrow_mut().extend_from_slice(commits)); - Ok(()) - } - - fn take_committed_relayer(_op_hash: &[u8; 32]) -> Option { - None - } +parameter_types! { + pub BlockAuthor: Option = Some(AccountId32::from([1u8; 32])); } impl pallet_shielded_pool::Config for Test { @@ -286,8 +183,8 @@ impl pallet_shielded_pool::Config for Test { type SealedTreePrunedBelowLevel = SealedTreePrunedBelowLevel; type MaxLeavesPerTree = MaxLeavesPerTree; type WeightInfo = (); - type Relayer = MockRelayer; - type EvmAccount = MirrorAccount; + type BlockAuthor = BlockAuthor; + type MinSpendFee = frame_support::traits::ConstU128<0>; } /// The EVM caller of every `MockHandle`. @@ -310,9 +207,6 @@ pub fn precompile_account() -> AccountId32 { } pub fn new_test_ext() -> sp_io::TestExternalities { - // Thread-locals outlive externalities; reset so tests do not leak into each other. - set_pending_relay_fees(0); - RECORDED_COMMITS.with(|c| c.borrow_mut().clear()); let mut t = frame_system::GenesisConfig::::default() .build_storage() .unwrap(); diff --git a/frame/evm/precompile/shielded-pool/src/tests/abi.rs b/frame/evm/precompile/shielded-pool/src/tests/abi.rs index 05dba8e6..9d76dc7d 100644 --- a/frame/evm/precompile/shielded-pool/src/tests/abi.rs +++ b/frame/evm/precompile/shielded-pool/src/tests/abi.rs @@ -42,6 +42,30 @@ fn an_unknown_selector_reverts() { }); } +/// The spend and relay-fee functions are retired: each reverts with the same +/// reason, well-formed calldata or not. +#[test] +fn retired_selectors_revert() { + new_test_ext().execute_with(|| { + for selector in [ + [0x63, 0xd0, 0xb9, 0xa0], // privateTransfer + [0x4e, 0x50, 0x53, 0x48], // unshield + [0xc9, 0xb2, 0x35, 0xff], // commitRelay + [0x2a, 0x32, 0x74, 0xdd], // claimRelayFees + ] { + for tail in [vec![], vec![0u8; 320]] { + let mut input = selector.to_vec(); + input.extend_from_slice(&tail); + let mut h = MockHandle::new(input); + expect_error_msg( + ShieldedPoolPrecompile::::execute(&mut h), + "retired in spec 19: submit spends unsigned via Substrate", + ); + } + } + }); +} + // ─── Fixed-width decoders ──────────────────────────────────────────────────── #[test] @@ -83,14 +107,6 @@ fn abi_read_u32_reads_the_slot_at_its_offset() { expect_error(lift(crate::abi::read_u32(¶ms, 33))); } -#[test] -fn abi_read_u256_reads_the_full_word() { - let mut params = [0u8; 64]; - params[32..64].copy_from_slice(&[0xFF; 32]); - assert_eq!(crate::abi::read_u256(¶ms, 32).unwrap(), U256::MAX); - expect_error(lift(crate::abi::read_u256(¶ms, 33))); -} - #[test] fn abi_read_bytes32_copies_all_bytes() { let mut params = [0u8; 64]; @@ -145,125 +161,3 @@ fn abi_decode_bytes_at_slot_empty_payload() { params[31] = 32; // offset assert_eq!(crate::abi::decode_bytes_at_slot(¶ms, 0).unwrap(), b""); } - -#[test] -fn abi_decode_bytes32_array_works() { - let mut params = vec![0u8; 160]; - params[31] = 32; // offset - params[63] = 2; // count - params[64..96].copy_from_slice(&[0xAAu8; 32]); - params[96..128].copy_from_slice(&[0xBBu8; 32]); - let out = crate::abi::decode_bytes32_array_at_slot(¶ms, 0, usize::MAX).unwrap(); - assert_eq!(out.len(), 2); - assert_eq!(out[0], [0xAAu8; 32]); - assert_eq!(out[1], [0xBBu8; 32]); -} - -#[test] -fn abi_decode_bytes32_array_empty() { - // count=0; the count word is left zeroed. - let mut params = vec![0u8; 64]; - params[31] = 32; // offset - let out = crate::abi::decode_bytes32_array_at_slot(¶ms, 0, usize::MAX).unwrap(); - assert!(out.is_empty()); -} - -#[test] -fn abi_decode_bytes32_array_rejects_truncated() { - let mut params = vec![0u8; 96]; - params[31] = 32; - params[63] = 3; // asks for 3×32=96 bytes but only 32 available - expect_error(lift(crate::abi::decode_bytes32_array_at_slot( - ¶ms, - 0, - usize::MAX, - ))); -} - -#[test] -fn abi_decode_bytes_array_works() { - let mut params = vec![0u8; 288]; - params[31] = 32; // outer offset - params[63] = 2; // count - params[95] = 64; // rel offset element 0 - params[127] = 128; // rel offset element 1 - // element 0: len=1, data=0xAA - params[159] = 1; - params[160] = 0xAA; - // element 1: len=2, data=0xBB 0xCC - params[223] = 2; - params[224] = 0xBB; - params[225] = 0xCC; - let out = crate::abi::decode_bytes_array_at_slot(¶ms, 0, usize::MAX, usize::MAX).unwrap(); - assert_eq!(out, vec![vec![0xAAu8], vec![0xBBu8, 0xCC]]); -} - -#[test] -fn abi_decode_bytes_array_rejects_invalid_rel_offset() { - let mut params = vec![0u8; 96]; - params[31] = 32; - params[63] = 1; - params[95] = 200; // rel offset way out of bounds - expect_error(lift(crate::abi::decode_bytes_array_at_slot( - ¶ms, - 0, - usize::MAX, - usize::MAX, - ))); -} - -/// `bytes[]` whose pointers all alias one blob: each element would copy it -/// again. The count bound rejects it before a single byte is copied. -#[test] -fn abi_decode_bytes_array_rejects_aliased_elements_past_the_count_bound() { - let count = 1_000usize; - let blob_len = 4_096usize; - // head(32) | count(32) | count pointers | blob len(32) | blob - let blob_at = 32 * count; // relative to the data base - let mut params = vec![0u8; 64 + 32 * count + 32 + blob_len]; - params[31] = 32; // array offset - params[32..64].copy_from_slice(&u256_word(count)); - for i in 0..count { - let p = 64 + 32 * i; - params[p..p + 32].copy_from_slice(&u256_word(blob_at)); - } - let len_at = 64 + blob_at; - params[len_at..len_at + 32].copy_from_slice(&u256_word(blob_len)); - - expect_error(lift(crate::abi::decode_bytes_array_at_slot( - ¶ms, 0, 2, 180, - ))); - // Unbounded, the same calldata decodes into count × blob_len bytes. - let unbounded = - crate::abi::decode_bytes_array_at_slot(¶ms, 0, usize::MAX, usize::MAX).unwrap(); - assert_eq!(unbounded.len() * unbounded[0].len(), count * blob_len); -} - -#[test] -fn abi_decode_bytes_array_rejects_an_item_past_the_length_bound() { - let mut params = vec![0u8; 32 + 32 + 32 + 32 + 181]; - params[31] = 32; // array offset - params[63] = 1; // count - params[95] = 32; // element 0 relative offset - params[96..128].copy_from_slice(&u256_word(181)); - expect_error(lift(crate::abi::decode_bytes_array_at_slot( - ¶ms, 0, 2, 180, - ))); - assert!(crate::abi::decode_bytes_array_at_slot(¶ms, 0, 2, 181).is_ok()); -} - -#[test] -fn abi_decode_bytes32_array_rejects_past_the_count_bound() { - let items = [[0x11u8; 32], [0x22u8; 32], [0x33u8; 32]]; - let mut params = u256_word(32).to_vec(); - params.extend_from_slice(&encode_bytes32_array(&items)); - expect_error(lift(crate::abi::decode_bytes32_array_at_slot( - ¶ms, 0, 2, - ))); - assert_eq!( - crate::abi::decode_bytes32_array_at_slot(¶ms, 0, 3) - .unwrap() - .len(), - 3 - ); -} diff --git a/frame/evm/precompile/shielded-pool/src/tests/adversarial.rs b/frame/evm/precompile/shielded-pool/src/tests/adversarial.rs index 882e9728..0e9a164a 100644 --- a/frame/evm/precompile/shielded-pool/src/tests/adversarial.rs +++ b/frame/evm/precompile/shielded-pool/src/tests/adversarial.rs @@ -2,28 +2,30 @@ //! //! This is the only surface where untrusted bytes reach the node directly: an //! EVM caller can send arbitrary calldata to the precompile address. Each test -//! below is an attempt to make the decoder panic, over-allocate, or read out of -//! bounds. A panic here is a node crash, not a rejected transaction. +//! below is an attempt to make the `shield` decoder panic, over-allocate, or read +//! out of bounds. A panic here is a node crash, not a rejected transaction. use super::*; +/// Well-formed `shield` calldata, the base every attack mutates. +fn base() -> Vec { + encode_shield(0, canon(0x0C), &[0xAB; 180]) +} + +/// Runs `input` with a non-zero `msg.value`, so decoding gets past the amount +/// check and reaches the offsets under attack. +fn run(input: Vec) -> Result { + let mut h = MockHandle::with_value(input, 1_000); + ShieldedPoolPrecompile::::execute(&mut h) +} + #[test] fn attack_truncation_at_every_offset_never_panics() { new_test_ext().execute_with(|| { - let full = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); - for len in 0..full.len().min(600) { - let mut h = MockHandle::new(full[..len].to_vec()); + let full = base(); + for len in 0..full.len() { // Must not panic. Any Result is acceptable. - let _ = ShieldedPoolPrecompile::::execute(&mut h); + let _ = run(full[..len].to_vec()); } }); } @@ -33,20 +35,11 @@ fn attack_truncation_at_every_offset_never_panics() { #[test] fn attack_self_referential_offset_is_refused() { new_test_ext().execute_with(|| { - let mut input = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); - // Point the proof offset at slot 0 of the head (offset 0 → itself). - input[4..36].copy_from_slice(&[0u8; 32]); - let mut h = MockHandle::new(input); - let _ = ShieldedPoolPrecompile::::execute(&mut h); + let mut input = base(); + // Point the memo offset at slot 0 of the head: its length word is the + // asset id, zero, so the memo decodes empty and is refused. + input[4 + 64..4 + 96].copy_from_slice(&[0u8; 32]); + expect_error(run(input)); }); } @@ -55,20 +48,10 @@ fn attack_self_referential_offset_is_refused() { #[test] fn attack_max_u256_offsets_are_refused_not_truncated() { new_test_ext().execute_with(|| { - for slot in [0usize, 64, 96, 128, 256] { - let mut input = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); + for slot in [64usize, 96] { + let mut input = base(); input[4 + slot..4 + slot + 32].copy_from_slice(&[0xFFu8; 32]); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + expect_error(run(input)); } }); } @@ -79,73 +62,25 @@ fn attack_max_u256_offsets_are_refused_not_truncated() { #[test] fn attack_offset_above_u32_that_looks_benign_is_refused() { new_test_ext().execute_with(|| { - let mut input = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); - // 2^32 + 288 — low 32 bits read as 288, a perfectly plausible offset. - let sneaky = U256::from(1u64 << 32) + U256::from(288u64); - let word = sneaky.to_big_endian(); - input[4..36].copy_from_slice(&word); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -/// A declared array count of ~1e9 must be refused BEFORE Vec::with_capacity -/// reserves for it — otherwise one call OOMs the node. -#[test] -fn attack_huge_array_count_does_not_allocate() { - new_test_ext().execute_with(|| { - let mut input = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); - // Find the nullifiers array offset and overwrite its count word. - let off = U256::from_big_endian(&input[4 + 64..4 + 96]).as_usize(); - let count_at = 4 + off; - if count_at + 32 <= input.len() { - let huge = U256::from(1u64 << 30).to_big_endian(); - input[count_at..count_at + 32].copy_from_slice(&huge); + for slot in [64usize, 96] { + let mut input = base(); + let valid = U256::from_big_endian(&input[4 + slot..4 + slot + 32]); + let sneaky = U256::from(1u64 << 32) + valid; + input[4 + slot..4 + slot + 32].copy_from_slice(&sneaky.to_big_endian()); + expect_error(run(input)); } - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); }); } -/// A fee word of u256::MAX must not panic converting to u128 — it must be -/// rejected as an overflow. +/// A length word near usize::MAX must be refused before anything is copied. #[test] -fn attack_max_fee_word_is_refused_not_panicking() { +fn attack_huge_length_does_not_allocate() { new_test_ext().execute_with(|| { - let mut input = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); - input[4 + 192..4 + 224].copy_from_slice(&[0xFFu8; 32]); - let mut h = MockHandle::new(input); - expect_error_msg( - ShieldedPoolPrecompile::::execute(&mut h), - "fee overflow", - ); + let mut input = base(); + let off = U256::from_big_endian(&input[4 + 96..4 + 128]).as_usize(); + let len_at = 4 + off; + input[len_at..len_at + 32].copy_from_slice(&U256::from(1u64 << 30).to_big_endian()); + expect_error(run(input)); }); } @@ -154,22 +89,12 @@ fn attack_max_fee_word_is_refused_not_panicking() { #[test] fn attack_oversized_u32_slots_are_refused_not_truncated() { new_test_ext().execute_with(|| { - for slot in [160usize, 224] { - let mut input = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); + for slot in [0usize, 128] { + let mut input = base(); // 2^32 exactly: truncates to 0 if the decoder uses low_u32. let word = U256::from(1u64 << 32).to_big_endian(); input[4 + slot..4 + slot + 32].copy_from_slice(&word); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + expect_error_msg(run(input), "uint32 slot exceeds u32"); } }); } @@ -179,16 +104,7 @@ fn attack_oversized_u32_slots_are_refused_not_truncated() { #[test] fn attack_byte_fuzz_never_panics() { new_test_ext().execute_with(|| { - let base = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); + let base = base(); let mut seed: u64 = 0x2545F4914F6CDD1D; for _ in 0..3000 { let mut input = base.clone(); @@ -207,9 +123,8 @@ fn attack_byte_fuzz_never_panics() { .wrapping_add(1442695040888963407); input[pos] = (seed >> 40) as u8; } - let mut h = MockHandle::new(input); // Only requirement: no panic. - let _ = ShieldedPoolPrecompile::::execute(&mut h); + let _ = run(input); } }); } @@ -219,16 +134,7 @@ fn attack_byte_fuzz_never_panics() { #[test] fn attack_truncated_fuzz_never_panics() { new_test_ext().execute_with(|| { - let base = encode_private_transfer( - &[0x01u8; 72], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &[vec![0x01u8; 180]], - 0, - 0, - 1, - ); + let base = base(); let mut seed: u64 = 0x9E3779B97F4A7C15; for _ in 0..2000 { seed = seed @@ -243,8 +149,7 @@ fn attack_truncated_fuzz_never_panics() { let pos = (seed >> 33) as usize % input.len(); input[pos] = (seed >> 40) as u8; } - let mut h = MockHandle::new(input); - let _ = ShieldedPoolPrecompile::::execute(&mut h); + let _ = run(input); } }); } diff --git a/frame/evm/precompile/shielded-pool/src/tests/flows.rs b/frame/evm/precompile/shielded-pool/src/tests/flows.rs deleted file mode 100644 index 08de1f24..00000000 --- a/frame/evm/precompile/shielded-pool/src/tests/flows.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Multi-step flows through the precompile. - -use super::*; - -#[test] -fn full_lifecycle_shield_transfer_unshield() { - new_test_ext().execute_with(|| { - // 1. Shield - do_shield(canon(0xAA), 10_000); - assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 1); - - // 2. Private transfer - let root_1 = current_root(); - let nullifier_in = canon(0xBB); - let commitment_out_1 = canon(0xCC); - let commitment_out_2 = canon(0xDD); - - let pt_input = encode_private_transfer( - &[0x01], - root_1, - &[nullifier_in, [0x00; 32]], - &[commitment_out_1, commitment_out_2], - &[vec![0xAA; 180], vec![0xBB; 180]], - 0, - 0, - 1, - ); - let mut h_pt = MockHandle::new(pt_input); - assert_success(ShieldedPoolPrecompile::::execute(&mut h_pt)); - assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 3); - - // 3. Unshield one of the outputs - let root_2 = current_root(); - let nullifier_out = canon(0xEE); - let unshield_input = encode_unshield( - &[0x02], - root_2, - nullifier_out, - 0, - 500, - recipient_bytes(), - 0, - [0u8; 32], - &[], - 1, - ); - let mut h_us = MockHandle::new(unshield_input); - assert_success(ShieldedPoolPrecompile::::execute(&mut h_us)); - - assert_eq!( - pallet_shielded_pool::PoolBalancePerAsset::::get(0), - 9_500 - ); - }); -} diff --git a/frame/evm/precompile/shielded-pool/src/tests/mod.rs b/frame/evm/precompile/shielded-pool/src/tests/mod.rs index 956b38f4..3113680c 100644 --- a/frame/evm/precompile/shielded-pool/src/tests/mod.rs +++ b/frame/evm/precompile/shielded-pool/src/tests/mod.rs @@ -1,23 +1,16 @@ -//! Precompile tests, split by call and concern, plus the shared helpers and ABI encoders. +//! Precompile tests, split by concern, plus the shared helpers and ABI encoders. use fp_evm::{Precompile, PrecompileFailure}; use sp_core::U256; use crate::{ - mock::{ - new_test_ext, pending_relay_fees, recorded_commits, registered_relayer, - set_pending_relay_fees, MockHandle, Test, - }, + mock::{new_test_ext, MockHandle, Test}, ShieldedPoolPrecompile, }; mod abi; mod adversarial; -mod flows; -mod private_transfer; -mod relay; mod shield; -mod unshield; /// A distinct, canonical 32-byte field value for `seed`. /// @@ -82,11 +75,6 @@ fn u256_word(value: usize) -> [u8; 32] { U256::from(value).to_big_endian() } -/// [`u256_word`] for a `u128`. -fn u256_word_u128(value: u128) -> [u8; 32] { - U256::from(value).to_big_endian() -} - /// Encodes a single `bytes` value: `uint256(length) ++ data ++ zero-padding`. fn encode_bytes(data: &[u8]) -> Vec { let padded = (data.len() + 31) & !31; @@ -96,37 +84,6 @@ fn encode_bytes(data: &[u8]) -> Vec { out } -/// Encodes a `bytes32[]`: `uint256(count) ++ items`. -fn encode_bytes32_array(items: &[[u8; 32]]) -> Vec { - let mut out = vec![0u8; 32 + items.len() * 32]; - out[..32].copy_from_slice(&u256_word(items.len())); - for (i, item) in items.iter().enumerate() { - out[32 + i * 32..64 + i * 32].copy_from_slice(item); - } - out -} - -/// Encodes a `bytes[]` using head/tail ABI layout. -fn encode_bytes_array(items: &[Vec]) -> Vec { - let count = items.len(); - let mut heads = vec![0u8; count * 32]; - let mut tails = Vec::new(); - let mut cursor = count * 32; - - for (i, item) in items.iter().enumerate() { - heads[i * 32..(i + 1) * 32].copy_from_slice(&u256_word(cursor)); - let enc = encode_bytes(item); - cursor += enc.len(); - tails.extend_from_slice(&enc); - } - - let mut out = Vec::with_capacity(32 + heads.len() + tails.len()); - out.extend_from_slice(&u256_word(count)); - out.extend_from_slice(&heads); - out.extend_from_slice(&tails); - out -} - // ─── Call encoders ─────────────────────────────────────────────────────────── /// `shield(uint32,bytes32,bytes,bytes,uint32)` with a well-formed proof at version 1. @@ -158,133 +115,6 @@ fn encode_shield_with( input } -/// `privateTransfer` with both inputs proven against `merkle_root`, as a -/// same-tree spend submits it. -#[allow(clippy::too_many_arguments)] -fn encode_private_transfer( - proof: &[u8], - merkle_root: [u8; 32], - nullifiers: &[[u8; 32]], - commitments: &[[u8; 32]], - memos: &[Vec], - asset_id: u32, - fee: u128, - circuit_version: u32, -) -> Vec { - encode_private_transfer_roots( - proof, - &[merkle_root, merkle_root], - nullifiers, - commitments, - memos, - asset_id, - fee, - circuit_version, - ) -} - -/// `privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)`, -/// selector `0x63d0b9a0`, with any number of roots so malformed counts can be built. -#[allow(clippy::too_many_arguments)] -fn encode_private_transfer_roots( - proof: &[u8], - merkle_roots: &[[u8; 32]], - nullifiers: &[[u8; 32]], - commitments: &[[u8; 32]], - memos: &[Vec], - asset_id: u32, - fee: u128, - circuit_version: u32, -) -> Vec { - let proof_enc = encode_bytes(proof); - let roots_enc = encode_bytes32_array(merkle_roots); - let nullifiers_enc = encode_bytes32_array(nullifiers); - let commitments_enc = encode_bytes32_array(commitments); - let memos_enc = encode_bytes_array(memos); - - // head: 8 slots × 32 = 256 bytes - let head_size = 256usize; - let off_proof = head_size; - let off_roots = off_proof + proof_enc.len(); - let off_nullifiers = off_roots + roots_enc.len(); - let off_commitments = off_nullifiers + nullifiers_enc.len(); - let off_memos = off_commitments + commitments_enc.len(); - - let mut input = crate::calls::private_transfer::SELECTOR.to_vec(); - let mut head = vec![0u8; head_size]; - head[0..32].copy_from_slice(&u256_word(off_proof)); - head[32..64].copy_from_slice(&u256_word(off_roots)); - head[64..96].copy_from_slice(&u256_word(off_nullifiers)); - head[96..128].copy_from_slice(&u256_word(off_commitments)); - head[128..160].copy_from_slice(&u256_word(off_memos)); - head[188..192].copy_from_slice(&asset_id.to_be_bytes()); - head[192..224].copy_from_slice(&u256_word_u128(fee)); - head[252..256].copy_from_slice(&circuit_version.to_be_bytes()); - - input.extend_from_slice(&head); - input.extend_from_slice(&proof_enc); - input.extend_from_slice(&roots_enc); - input.extend_from_slice(&nullifiers_enc); - input.extend_from_slice(&commitments_enc); - input.extend_from_slice(&memos_enc); - input -} - -/// `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)`, -/// selector `0x4e505348`. -#[allow(clippy::too_many_arguments)] -fn encode_unshield( - proof: &[u8], - merkle_root: [u8; 32], - nullifier: [u8; 32], - asset_id: u32, - amount: u128, - recipient: [u8; 32], - fee: u128, - change_commitment: [u8; 32], - change_encrypted_memo: &[u8], - circuit_version: u32, -) -> Vec { - // head: 10 slots × 32 = 320 bytes; tails (proof, memo) appended after. - let mut input = vec![0x4e, 0x50, 0x53, 0x48]; - let mut head = vec![0u8; 320]; - let proof_offset = 320usize; - let memo_offset = proof_offset + encode_bytes(proof).len(); - - head[0..32].copy_from_slice(&u256_word(proof_offset)); - head[32..64].copy_from_slice(&merkle_root); - head[64..96].copy_from_slice(&nullifier); - head[124..128].copy_from_slice(&asset_id.to_be_bytes()); - head[128..160].copy_from_slice(&u256_word_u128(amount)); - head[160..192].copy_from_slice(&recipient); - head[192..224].copy_from_slice(&u256_word_u128(fee)); - head[224..256].copy_from_slice(&change_commitment); - head[256..288].copy_from_slice(&u256_word(memo_offset)); - head[316..320].copy_from_slice(&circuit_version.to_be_bytes()); - input.extend_from_slice(&head); - input.extend_from_slice(&encode_bytes(proof)); - input.extend_from_slice(&encode_bytes(change_encrypted_memo)); - input -} - -/// `commitRelay(bytes32[])`, selector `0xc9b235ff`. -fn encode_commit_relay(commits: &[[u8; 32]]) -> Vec { - let mut input = vec![0xc9, 0xb2, 0x35, 0xff]; - input.extend_from_slice(&u256_word(32)); - input.extend_from_slice(&encode_bytes32_array(commits)); - input -} - -/// `claimRelayFees(uint32,uint256)`, selector `0x2a3274dd`. -fn encode_claim_relay_fees(asset_id: u32, amount: u128) -> Vec { - let mut input = vec![0x2a, 0x32, 0x74, 0xdd]; - let mut asset = [0u8; 32]; - asset[28..32].copy_from_slice(&asset_id.to_be_bytes()); - input.extend_from_slice(&asset); - input.extend_from_slice(&u256_word_u128(amount)); - input -} - // ─── Pool state ────────────────────────────────────────────────────────────── /// Shields `value` of asset 0 into `commitment` through the precompile. @@ -298,10 +128,3 @@ fn do_shield(commitment: [u8; 32], value: u128) { fn current_root() -> [u8; 32] { pallet_shielded_pool::Pallet::::poseidon_root() } - -/// The mock EVM caller's account, as an `unshield` recipient. -fn recipient_bytes() -> [u8; 32] { - let mut r = [0u8; 32]; - r.copy_from_slice(crate::mock::caller_account().as_ref()); - r -} diff --git a/frame/evm/precompile/shielded-pool/src/tests/private_transfer.rs b/frame/evm/precompile/shielded-pool/src/tests/private_transfer.rs deleted file mode 100644 index 14b8b257..00000000 --- a/frame/evm/precompile/shielded-pool/src/tests/private_transfer.rs +++ /dev/null @@ -1,389 +0,0 @@ -//! `privateTransfer`. - -use super::*; - -#[test] -fn private_transfer_rejects_truncated_input() { - new_test_ext().execute_with(|| { - // The real selector, taken from the decoder. A wrong literal here would - // still make this test pass — a wrong selector is rejected as - // "unknown selector" — while testing nothing about truncation. - let mut h = MockHandle::new(crate::calls::private_transfer::SELECTOR.to_vec()); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn private_transfer_selector_matches_signature() { - // The constant must be derived from the ABI signature — this is the guard - // against a hand-written selector that never matches the code. - let sig = b"privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)"; - let hash = sp_io::hashing::keccak_256(sig); - assert_eq!(hash[..4], crate::calls::private_transfer::SELECTOR); -} - -/// Two roots decode into the call in input order, so a spend across two trees -/// reaches the pallet with each input's own root. -#[test] -fn private_transfer_decodes_one_root_per_input() { - let input = super::encode_private_transfer_roots( - &[0x01], - &[[0x0A; 32], [0x0B; 32]], - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0u8; 180], vec![0u8; 180]], - 0, - 0, - 3, - ); - let Some(pallet_shielded_pool::Call::private_transfer { merkle_roots, .. }) = - crate::decode_relayable_call::(&input) - else { - panic!("privateTransfer must decode"); - }; - assert_eq!(merkle_roots, [[0x0A; 32], [0x0B; 32]]); -} - -/// The circuit has two inputs, so anything but exactly two roots is malformed. -#[test] -fn private_transfer_rejects_any_root_count_but_two() { - new_test_ext().execute_with(|| { - for roots in [vec![], vec![[0x0A; 32]], vec![[0x0A; 32]; 3]] { - let input = super::encode_private_transfer_roots( - &[0x01], - &roots, - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0u8; 180], vec![0u8; 180]], - 0, - 0, - 3, - ); - assert!(crate::decode_relayable_call::(&input).is_none()); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - } - }); -} - -/// Hostile `merkle_roots` pointers: an offset past the calldata or at -/// `u256::MAX`, and a length of `u256::MAX` or one that runs past the end, each -/// revert before anything is allocated or read out of bounds. -#[test] -fn private_transfer_rejects_hostile_root_offsets_and_lengths() { - new_test_ext().execute_with(|| { - let valid = super::encode_private_transfer_roots( - &[0x01], - &[[0x0A; 32], [0x0B; 32]], - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0u8; 180], vec![0u8; 180]], - 0, - 0, - 3, - ); - // `input[4..]` is the params; slot 1 (bytes 32..64) holds the roots offset. - let offset_low: [u8; 8] = valid[4 + 56..4 + 64].try_into().unwrap(); - let roots_at = 4 + u64::from_be_bytes(offset_low) as usize; - let mut cases = Vec::new(); - for offset in [vec![0xFF; 32], super::u256_word(valid.len() + 64).to_vec()] { - let mut input = valid.clone(); - input[4 + 32..4 + 64].copy_from_slice(&offset); - cases.push(input); - } - for length in [vec![0xFF; 32], super::u256_word(1 << 20).to_vec()] { - let mut input = valid.clone(); - input[roots_at..roots_at + 32].copy_from_slice(&length); - cases.push(input); - } - for input in cases { - assert!(crate::decode_relayable_call::(&input).is_none()); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - } - }); -} - -/// ABI offsets may alias: pointing `merkle_roots` at the nullifiers array -/// decodes the nullifiers as roots. Harmless: the pallet then needs them to be -/// known roots and the proof to bind them. -#[test] -fn an_aliased_roots_pointer_reads_the_aliased_words_as_roots() { - let mut input = super::encode_private_transfer_roots( - &[0x01], - &[[0x0A; 32], [0x0B; 32]], - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0u8; 180], vec![0u8; 180]], - 0, - 0, - 3, - ); - let nullifiers_offset = input[4 + 64..4 + 96].to_vec(); - input[4 + 32..4 + 64].copy_from_slice(&nullifiers_offset); - let Some(pallet_shielded_pool::Call::private_transfer { merkle_roots, .. }) = - crate::decode_relayable_call::(&input) - else { - panic!("an aliased pointer is still well-formed ABI"); - }; - assert_eq!(merkle_roots, [[0x11; 32], [0x22; 32]]); -} - -/// The single-root ABI is gone: its selector is no longer a call. -#[test] -fn the_single_root_selector_is_refused() { - new_test_ext().execute_with(|| { - let mut input = super::encode_private_transfer( - &[0x01], - canon(0x0A), - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0u8; 180], vec![0u8; 180]], - 0, - 0, - 2, - ); - input[..4].copy_from_slice(&[0x66, 0xed, 0x2c, 0xd4]); - assert!(crate::decode_relayable_call::(&input).is_none()); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn private_transfer_rejects_empty_proof() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - // empty proof → MockZkVerifier returns Err - let input = encode_private_transfer( - &[], - root, - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0xAA; 180], vec![0xBB; 180]], - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn private_transfer_rejects_zero_nullifiers() { - // Calling with an empty nullifier array must be rejected at the precompile - // boundary before touching the pallet. - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let input = encode_private_transfer( - &[0x01], - root, - &[], // 0 nullifiers - &[], // 0 commitments - &[], // 0 memos - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn private_transfer_rejects_mismatched_nullifier_commitment_count() { - // 2 nullifiers but 1 commitment — structurally inconsistent. - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let input = encode_private_transfer( - &[0x01], - root, - &[[0x11; 32], [0x22; 32]], // 2 nullifiers - &[canon(0x33)], // 1 commitment - &[vec![0xAA; 180]], // 1 memo - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn private_transfer_rejects_mismatched_commitment_memo_count() { - // 2 commitments but 1 memo — structurally inconsistent. - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let input = encode_private_transfer( - &[0x01], - root, - &[[0x11; 32], [0x22; 32]], // 2 nullifiers - &[canon(0x33), canon(0x44)], // 2 commitments - &[vec![0xAA; 180]], // 1 memo — mismatch - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -/// Two notes from two trees end to end: one root a sealed tree's, the other the -/// active tree's. Both reach the pallet in order and the spend settles. -#[test] -fn private_transfer_across_two_trees_executes() { - new_test_ext().execute_with(|| { - do_shield(canon(0x56), 5_000); - let sealed = canon(0x5E); - pallet_shielded_pool::SealedRootIndex::::insert(sealed, 0); - let active = current_root(); - let nullifiers = [[0x13; 32], [0x24; 32]]; - - let input = super::encode_private_transfer_roots( - &[0x01, 0x02, 0x03], - &[sealed, active], - &nullifiers, - &[canon(0x35), canon(0x46)], - &[vec![0xAA; 180], vec![0xBB; 180]], - 0, - 0, - 3, - ); - let mut h = MockHandle::new(input); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - for n in nullifiers { - assert!(pallet_shielded_pool::NullifierSet::::get( - pallet_shielded_pool::Nullifier::from(n) - ) - .is_some()); - } - }); -} - -#[test] -fn private_transfer_happy_path() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let nullifier_1 = [0x11; 32]; - let nullifier_2 = [0x22; 32]; - let commitment_1 = canon(0x33); - let commitment_2 = canon(0x44); - - let input = encode_private_transfer( - &[0x01, 0x02, 0x03], - root, - &[nullifier_1, nullifier_2], - &[commitment_1, commitment_2], - &[vec![0xAA; 180], vec![0xBB; 180]], - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - - // Both input nullifiers must be spent. - assert!(pallet_shielded_pool::NullifierSet::::get( - pallet_shielded_pool::Nullifier::from(nullifier_1) - ) - .is_some()); - assert!(pallet_shielded_pool::NullifierSet::::get( - pallet_shielded_pool::Nullifier::from(nullifier_2) - ) - .is_some()); - - // Both output commitments must land in the tree (indices 1 and 2). - assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 3); - assert_eq!( - pallet_shielded_pool::MerkleLeaves::::get(1).unwrap(), - pallet_shielded_pool::Commitment::from(commitment_1) - ); - assert_eq!( - pallet_shielded_pool::MerkleLeaves::::get(2).unwrap(), - pallet_shielded_pool::Commitment::from(commitment_2) - ); - }); -} - -#[test] -fn private_transfer_rejects_double_spend() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let nullifier = canon(0xDE); - - let input = encode_private_transfer( - &[0x01], - root, - &[nullifier, [0x02; 32]], - &[[0x03; 32], [0x04; 32]], - &[vec![0xAA; 180], vec![0xBB; 180]], - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input.clone()); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - - // Second call reuses the same nullifier — must fail. - let mut h2 = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h2)); - }); -} - -#[test] -fn private_transfer_root_updates_after_outputs() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root_before = current_root(); - - let input = encode_private_transfer( - &[0x01], - root_before, - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0xAA; 180], vec![0xBB; 180]], - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - - assert_ne!( - current_root(), - root_before, - "root must change after private_transfer" - ); - }); -} - -#[test] -fn a_memo_longer_than_the_maximum_is_rejected_by_execute() { - new_test_ext().execute_with(|| { - let oversized = pallet_shielded_pool::types::MAX_ENCRYPTED_MEMO_SIZE as usize + 1; - let input = encode_private_transfer( - &[0x01, 0x02, 0x03], - current_root(), - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0xAA; oversized], vec![0xBB; 180]], - 0, - 0, - 1, - ); - let mut h = MockHandle::new(input); - expect_error_msg( - ShieldedPoolPrecompile::::execute(&mut h), - "bytes[] item is too long", - ); - }); -} diff --git a/frame/evm/precompile/shielded-pool/src/tests/relay.rs b/frame/evm/precompile/shielded-pool/src/tests/relay.rs deleted file mode 100644 index 38a02438..00000000 --- a/frame/evm/precompile/shielded-pool/src/tests/relay.rs +++ /dev/null @@ -1,370 +0,0 @@ -//! Relay commits and public fee claims. - -use super::*; - -/// A handle whose EVM caller is the mock's registered relay address. -fn from_relayer(input: Vec) -> MockHandle { - let mut h = MockHandle::new(input); - h.context.caller = registered_relayer(); - h -} - -#[test] -fn relay_selectors_match_their_signatures() { - use sp_io::hashing::keccak_256; - assert_eq!( - &keccak_256(b"commitRelay(bytes32[])")[..4], - &crate::selectors::COMMIT_RELAY - ); - assert_eq!( - &keccak_256(b"claimRelayFees(uint32,uint256)")[..4], - &crate::selectors::CLAIM_RELAY_FEES - ); -} - -#[test] -fn registered_relayer_can_commit() { - new_test_ext().execute_with(|| { - let commits = [[0x11u8; 32], [0x22u8; 32]]; - let mut h = from_relayer(encode_commit_relay(&commits)); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - assert_eq!( - recorded_commits(), - commits - .iter() - .map(|c| sp_core::H256(*c)) - .collect::>() - ); - }); -} - -#[test] -fn unregistered_caller_cannot_commit() { - new_test_ext().execute_with(|| { - let mut h = MockHandle::new(encode_commit_relay(&[[0x11u8; 32]])); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - assert!(recorded_commits().is_empty()); - }); -} - -#[test] -fn commit_relay_rejects_empty_and_oversized_batches() { - new_test_ext().execute_with(|| { - let mut h = from_relayer(encode_commit_relay(&[])); - expect_error_msg( - ShieldedPoolPrecompile::::execute(&mut h), - "at least one", - ); - - let too_many = - vec![[0x11u8; 32]; pallet_shielded_pool::MAX_RELAY_COMMITS_PER_CALL as usize + 1]; - let mut h = from_relayer(encode_commit_relay(&too_many)); - expect_error_msg( - ShieldedPoolPrecompile::::execute(&mut h), - "too many items", - ); - }); -} - -#[test] -fn registered_relayer_claims_into_its_mirror() { - new_test_ext().execute_with(|| { - do_shield(canon(0x41), 1_000); - set_pending_relay_fees(300); - - let mut h = from_relayer(encode_claim_relay_fees(0, 200)); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - - assert_eq!(pending_relay_fees(), 100); - let mirror = >::convert( - registered_relayer(), - ); - assert_eq!(pallet_balances::Pallet::::free_balance(mirror), 200); - }); -} - -#[test] -fn unregistered_caller_cannot_claim() { - new_test_ext().execute_with(|| { - do_shield(canon(0x42), 1_000); - set_pending_relay_fees(300); - let mut h = MockHandle::new(encode_claim_relay_fees(0, 100)); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - assert_eq!(pending_relay_fees(), 300); - }); -} - -#[test] -fn claim_relay_fees_rejects_zero_and_short_input() { - new_test_ext().execute_with(|| { - let mut h = from_relayer(encode_claim_relay_fees(0, 0)); - expect_error_msg(ShieldedPoolPrecompile::::execute(&mut h), "non-zero"); - let mut short = encode_claim_relay_fees(0, 1); - short.truncate(40); - let mut h = from_relayer(short); - expect_error_msg(ShieldedPoolPrecompile::::execute(&mut h), "too short"); - }); -} - -/// The relay commit a node derives from calldata identifies the same -/// spend the pallet credits when it executes that calldata. -#[test] -fn relayable_calldata_hashes_like_the_extrinsic() { - let input = encode_unshield( - &[0x01u8; 72], - canon(0xBB), - canon(1), - 0, - 500, - recipient_bytes(), - 7, - [0u8; 32], - &[], - 1, - ); - let call = crate::decode_relayable_call::(&input).expect("unshield decodes"); - let expected = pallet_shielded_pool::operations::unshield::UnshieldRequest:: { - merkle_root: canon(0xBB), - nullifier: pallet_shielded_pool::Nullifier(canon(1)), - asset_id: 0, - amount: 500, - recipient: crate::mock::caller_account(), - fee: 7, - change_commitment: [0u8; 32], - change_memo: Default::default(), - circuit_version: 1, - } - .op_hash() - .unwrap(); - assert_eq!( - pallet_shielded_pool::operations::fees::relay_op_hash(&call), - Some(expected) - ); - - // Only spends are relayable. - assert!(crate::decode_relayable_call::(&encode_claim_relay_fees(0, 1)).is_none()); - assert!(crate::decode_relayable_call::(&[0x4e]).is_none()); -} - -#[test] -fn relayable_transfer_calldata_hashes_like_the_extrinsic() { - let memos = vec![vec![0x5A; 180]]; - let input = encode_private_transfer( - &[0xAB; 128], - canon(0xBB), - &[canon(1)], - &[canon(2)], - &memos, - 0, - 7, - 2, - ); - let call = crate::decode_relayable_call::(&input).expect("privateTransfer decodes"); - let expected = pallet_shielded_pool::operations::private_transfer::TransferRequest:: { - merkle_roots: [canon(0xBB); 2], - nullifiers: vec![pallet_shielded_pool::Nullifier(canon(1))] - .try_into() - .unwrap(), - commitments: vec![pallet_shielded_pool::Commitment(canon(2))] - .try_into() - .unwrap(), - memos: vec![pallet_shielded_pool::types::EncryptedMemo::from_bytes(&memos[0]).unwrap()] - .try_into() - .unwrap(), - asset_id: 0, - fee: 7, - circuit_version: 2, - } - .op_hash(); - assert_eq!( - pallet_shielded_pool::operations::fees::relay_op_hash(&call), - Some(expected) - ); -} - -#[test] -fn claim_relay_fees_rejects_an_amount_above_u128() { - new_test_ext().execute_with(|| { - let mut input = encode_claim_relay_fees(0, 0); - input[4 + 32..4 + 48].copy_from_slice(&[0xFF; 16]); // high half of the amount word - let mut h = from_relayer(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn commit_relay_rejects_an_offset_past_the_input() { - new_test_ext().execute_with(|| { - let mut input = encode_commit_relay(&[[0x11; 32]]); - input[4..36].copy_from_slice(&u256_word(4096)); - let mut h = from_relayer(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - assert!(recorded_commits().is_empty()); - }); -} - -#[test] -fn delegated_calls_are_refused() { - new_test_ext().execute_with(|| { - let mut h = from_relayer(encode_commit_relay(&[[0x11; 32]])); - h.code_address = sp_core::H160::repeat_byte(0x08); // DELEGATECALL: code ≠ context - expect_error_msg(ShieldedPoolPrecompile::::execute(&mut h), "delegated"); - assert!(recorded_commits().is_empty()); - }); -} - -#[test] -fn static_calls_are_refused() { - new_test_ext().execute_with(|| { - let mut h = from_relayer(encode_claim_relay_fees(0, 1)); - h.is_static = true; - expect_error_msg(ShieldedPoolPrecompile::::execute(&mut h), "static"); - }); -} - -/// A pallet error reverts (unused gas returned) with the reason as `Error(string)`. -#[test] -fn a_pallet_error_reverts_with_its_reason() { - new_test_ext().execute_with(|| { - let mut h = MockHandle::new(encode_commit_relay(&[[0x11; 32]])); - match ShieldedPoolPrecompile::::execute(&mut h) { - Err(fp_evm::PrecompileFailure::Revert { output, .. }) => { - assert_eq!(&output[..4], &[0x08, 0xc3, 0x79, 0xa0]); - let reason_len = u64::from_be_bytes(output[60..68].try_into().unwrap()) as usize; - assert!( - reason_len > 0 && output.len() >= 68 + reason_len, - "reason is carried" - ); - } - other => panic!("expected a revert, got {other:?}"), - } - }); -} - -#[test] -fn claim_relay_fees_above_pending_reverts_and_moves_nothing() { - new_test_ext().execute_with(|| { - do_shield(canon(0x43), 1_000); - set_pending_relay_fees(100); - let mut h = from_relayer(encode_claim_relay_fees(0, 101)); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - - assert_eq!(pending_relay_fees(), 100); - let mirror = >::convert( - registered_relayer(), - ); - assert_eq!(pallet_balances::Pallet::::free_balance(mirror), 0); - }); -} - -#[test] -fn claim_relay_fees_rejects_an_asset_id_above_u32() { - new_test_ext().execute_with(|| { - set_pending_relay_fees(100); - let mut input = encode_claim_relay_fees(0, 1); - input[4 + 27] = 0x01; // the byte above the uint32 in the asset_id word - let mut h = from_relayer(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - assert_eq!(pending_relay_fees(), 100); - }); -} - -#[test] -fn a_delegated_shield_is_refused() { - new_test_ext().execute_with(|| { - let mut h = MockHandle::with_value(encode_shield(0, canon(0x44), &[0x5A; 180]), 1_000); - h.code_address = sp_core::H160::repeat_byte(0x08); // DELEGATECALL: code ≠ context - expect_error_msg(ShieldedPoolPrecompile::::execute(&mut h), "delegated"); - }); -} - -/// Only `shield` is payable. The executor moves `msg.value` to the precompile -/// before this code runs, and nothing would send it back out, so every other -/// call refuses it rather than stranding the caller's funds at the address. -#[test] -fn the_non_payable_calls_refuse_msg_value() { - new_test_ext().execute_with(|| { - do_shield(canon(0x51), 1_000); - set_pending_relay_fees(300); - let root = current_root(); - let calls: Vec<(&str, Vec)> = vec![ - ("commitRelay", encode_commit_relay(&[[0x11; 32]])), - ("claimRelayFees", encode_claim_relay_fees(0, 100)), - ( - "unshield", - encode_unshield( - &[0x01u8; 72], - root, - canon(1), - 0, - 500, - recipient_bytes(), - 0, - [0u8; 32], - &[], - 1, - ), - ), - ( - "privateTransfer", - encode_private_transfer( - &[0x01, 0x02, 0x03], - root, - &[[0x11; 32], [0x22; 32]], - &[canon(0x33), canon(0x44)], - &[vec![0xAA; 180], vec![0xBB; 180]], - 0, - 0, - 1, - ), - ), - ]; - for (name, input) in calls { - let mut h = MockHandle::with_value(input, 1_000); - h.context.caller = registered_relayer(); - expect_error_msg( - ShieldedPoolPrecompile::::execute(&mut h), - "not payable", - ); - assert_eq!(pending_relay_fees(), 300, "{name} moved a pending fee"); - } - }); -} - -#[test] -fn truncated_spend_calldata_is_not_relayable() { - let unshield = encode_unshield( - &[0x01u8; 72], - canon(0xBB), - canon(1), - 0, - 500, - recipient_bytes(), - 7, - [0u8; 32], - &[], - 1, - ); - let transfer = encode_private_transfer( - &[0xAB; 128], - canon(0xBB), - &[canon(1), [0u8; 32]], - &[canon(2), canon(3)], - &[vec![0x5A; 180], vec![0x5B; 180]], - 0, - 7, - 2, - ); - for input in [unshield, transfer] { - assert!(crate::decode_relayable_call::(&input).is_some()); - // Cut inside the head, and inside the last tail's data (not just its padding). - for len in [4 + 64, input.len() - 32] { - assert!( - crate::decode_relayable_call::(&input[..len]).is_none(), - "selector {:02x?} decoded at {len}/{}", - &input[..4], - input.len() - ); - } - } -} diff --git a/frame/evm/precompile/shielded-pool/src/tests/unshield.rs b/frame/evm/precompile/shielded-pool/src/tests/unshield.rs deleted file mode 100644 index 07549531..00000000 --- a/frame/evm/precompile/shielded-pool/src/tests/unshield.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! `unshield`. - -use super::*; - -#[test] -fn unshield_rejects_truncated_input() { - new_test_ext().execute_with(|| { - // The real selector, so the head-length check is what rejects it. - let mut h = MockHandle::new(crate::calls::unshield::SELECTOR.to_vec()); - expect_error_msg( - ShieldedPoolPrecompile::::execute(&mut h), - "unshield: input too short", - ); - }); -} - -#[test] -fn unshield_selector_matches_signature() { - // A hand-written selector that matches no signature is rejected as unknown, - // so only this pins the accept path. - let sig = - b"unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)"; - let hash = sp_io::hashing::keccak_256(sig); - assert_eq!(hash[..4], crate::calls::unshield::SELECTOR); -} - -#[test] -fn unshield_rejects_empty_proof() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let input = encode_unshield( - &[], - root, - canon(0x77), - 0, - 100, - recipient_bytes(), - 0, - [0u8; 32], - &[], - 1, - ); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn unshield_happy_path() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let nullifier = canon(0x77); - - let input = encode_unshield( - &[0x09, 0x09], - root, - nullifier, - 0, - 100, - recipient_bytes(), - 0, - [0u8; 32], - &[], - 1, - ); - let mut h = MockHandle::new(input); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - - // Pool balance decreases by the withdrawn amount. - assert_eq!( - pallet_shielded_pool::PoolBalancePerAsset::::get(0), - 4_900 - ); - // Nullifier is marked spent. - assert!(pallet_shielded_pool::NullifierSet::::get( - pallet_shielded_pool::Nullifier::from(nullifier) - ) - .is_some()); - }); -} - -#[test] -fn unshield_rejects_double_spend() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let nullifier = canon(0x77); - - let input = encode_unshield( - &[0x09, 0x09], - root, - nullifier, - 0, - 100, - recipient_bytes(), - 0, - [0u8; 32], - &[], - 1, - ); - let mut h = MockHandle::new(input.clone()); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - - let mut h2 = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h2)); - }); -} - -#[test] -fn unshield_full_balance() { - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 1_000); - let root = current_root(); - let input = encode_unshield( - &[0x01], - root, - canon(0x99), - 0, - 1_000, - recipient_bytes(), - 0, - [0u8; 32], - &[], - 1, - ); - let mut h = MockHandle::new(input); - assert_success(ShieldedPoolPrecompile::::execute(&mut h)); - assert_eq!(pallet_shielded_pool::PoolBalancePerAsset::::get(0), 0); - }); -} - -#[test] -fn unshield_rejects_zero_recipient() { - // AccountId32 of all zeros is a permanent burn address. The precompile - // must reject it before dispatching to avoid silent token destruction. - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let input = encode_unshield( - &[0x09, 0x09], - root, - canon(0x77), - 0, - 100, - [0u8; 32], // zero AccountId32 - 0, - [0u8; 32], - &[], - 1, - ); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} - -#[test] -fn unshield_rejects_zero_amount() { - // amount = 0 is semantically invalid and must be rejected at the precompile - // level before dispatch. - new_test_ext().execute_with(|| { - do_shield(canon(0x55), 5_000); - let root = current_root(); - let input = encode_unshield( - &[0x09, 0x09], - root, - canon(0x77), - 0, - 0, // zero amount - recipient_bytes(), - 0, - [0u8; 32], - &[], - 1, - ); - let mut h = MockHandle::new(input); - expect_error(ShieldedPoolPrecompile::::execute(&mut h)); - }); -} diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index bf8f62d2..ade5ca63 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. ## [Unreleased] +## [0.25.0] - 2026-10-13 + +**Breaking** — the relayer layer is gone. Requires the matching runtime and +`pallet-evm-precompile-shielded-pool`. + +### Changed + +- `private_transfer` and `unshield` accept only unsigned origins; signed and + Root fail with `BadOrigin`. +- A spend's fee is credited to the block author's `PendingFees` (new storage, + same layout as `pallet_relayer::PendingRelayerFees`) with a `FeeCredited` + event. +- `claim_relay_fees` (call index 19) is now `claim_fees(asset_id, amount)`: + signed only, pays the caller's own pending fees to the caller. Event + `RelayFeesClaimed` is replaced in place by `FeesClaimed`; error + `RelayerNotRegistered` by `InsufficientPendingFees`. +- `Config`: `Relayer` and `EvmAccount` are replaced by `BlockAuthor` and the + constant `MinSpendFee` (the fee floor, formerly `pallet_relayer`'s + `MinRelayFee`). +- `ShieldedPoolRuntimeApi` v5: `relay_config`, `RelayConfig` and + `relay_commit_hash` are removed. +- `try_state` checks that native pending fees never exceed the pool ledger. + +### Removed + +- `commit_relay` (call index 18, retired), the `Relayed` origin + (`RawOrigin`/`Origin`), `ensure_spend_origin`, `ensure_relay_caller`, + `RelayCaller`, `MAX_RELAY_COMMITS_PER_CALL`, the relay op hashes and the + `pallet-relayer` dependency. + ## [0.24.0] - 2026-10-09 ### Added diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index 74c0c012..1497fbae 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.24.0" +version = "0.25.0" description = "Shielded pool pallet for private transactions using ZK proofs" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" @@ -33,7 +33,6 @@ frame-system = { version = "48.0.0", default-features = false } # Local dependencies orbinum-zk-core = { path = "../../primitives/zk-core", default-features = false } orbinum-zk-verifier = { path = "../../primitives/zk-verifier", default-features = false } -pallet-relayer = { path = "../relayer", default-features = false } pallet-zk-verifier = { path = "../zk-verifier", default-features = false } # Arkworks dependencies (required for Poseidon hashing) @@ -61,7 +60,6 @@ std = [ "frame-support/std", "frame-system/std", "frame-benchmarking?/std", - "pallet-relayer/std", "orbinum-zk-verifier/std", "orbinum-zk-core/std", "pallet-zk-verifier/std", @@ -75,12 +73,10 @@ runtime-benchmarks = [ "frame-benchmarking/runtime-benchmarks", "frame-support/runtime-benchmarks", "frame-system/runtime-benchmarks", - "pallet-relayer/runtime-benchmarks", "pallet-zk-verifier/runtime-benchmarks", ] skip-proof-verification = ["pallet-zk-verifier/skip-proof-verification"] try-runtime = [ "frame-support/try-runtime", "frame-system/try-runtime", - "pallet-relayer/try-runtime", ] diff --git a/frame/shielded-pool/README.md b/frame/shielded-pool/README.md index b553459f..242f1385 100644 --- a/frame/shielded-pool/README.md +++ b/frame/shielded-pool/README.md @@ -4,7 +4,7 @@ FRAME pallet for privacy-preserving transactions in Orbinum using ZK-SNARKs. ## Status -MVP in active development. Core shield / transfer / unshield flows are functional, including partial unshield with automatic change note handling. Relay fees are attributed by relay commit and claimed publicly (no proof). +MVP in active development. Core shield / transfer / unshield flows are functional, including partial unshield with automatic change note handling. Spends are unsigned only; each spend's fee is credited to the block author and claimed publicly (no proof). ## What this pallet does @@ -33,10 +33,9 @@ This ensures change note commitments are **not linkable** to other notes belongi |-----------|--------|-------------| | `shield` | Signed | Deposit tokens; insert one commitment into the Merkle tree | | `shield_batch` | Signed | Deposit and insert multiple commitments in one call | -| `private_transfer` | Unsigned / Signed / Relayed | ZK-proven private transfer between notes. Fee goes to the committed relayer, else the block author | -| `unshield` | Unsigned / Signed / Relayed | ZK-proven withdrawal to a public account. Accepts a `change_commitment` and a 180-byte `change_encrypted_memo` for partial unshield with stealth change notes (empty for a total unshield) | -| `commit_relay` | Signed / Relayed (registered relayer) | Record relay commits for spends about to be submitted | -| `claim_relay_fees` | Signed / Relayed | Pay the caller's pending relay fees out of the pool to its EVM mirror (no proof) | +| `private_transfer` | Unsigned | ZK-proven private transfer between notes. Fee is credited to the block author | +| `unshield` | Unsigned | ZK-proven withdrawal to a public account. Accepts a `change_commitment` and a 180-byte `change_encrypted_memo` for partial unshield with stealth change notes (empty for a total unshield) | +| `claim_fees` | Signed | Pay the caller's pending fees out of the pool to the caller (no proof) | | `register_asset` | Signed | Register a new asset for multi-asset support | | `verify_asset` | Root | Mark a registered asset as verified (enables shielding) | | `unverify_asset` | Root | Remove verified status from an asset | @@ -55,12 +54,21 @@ This ensures change note commitments are **not linkable** to other notes belongi | `Assets` | Registered asset metadata | | `NextAssetId` | Auto-increment for asset IDs | | `PoolBalancePerAsset` | Total shielded balance per asset | +| `PendingFees` | Spend fees credited to a validator and not yet claimed, per asset | + +## Fees + +A spend's fee stays in the pool. It is added to `PendingFees` of the block +author (`Config::BlockAuthor`, the validator account); a spend with a non-zero +fee fails with `FeeRecipientUnavailable` when the block has no known author. +`claim_fees` pays pending fees out to the validator. Every spend's fee must be at +least `Config::MinSpendFee`, a constant changed by runtime upgrade. ## Module layout ``` src/ - lib.rs — Config, Storage, Events, Errors, extrinsics, origin helpers + lib.rs — Config, Storage, Events, Errors, extrinsics types/ — Commitment, Nullifier, Hash, EncryptedMemo and aliases merkle/ — Poseidon Merkle forest (pure tree logic + MerkleTreeService) operations/ @@ -69,7 +77,7 @@ src/ private_transfer.rs — TransferRequest + PrivateTransferOperation unshield.rs — UnshieldRequest + UnshieldOperation (partial and total) statement.rs — pool values → verifier statement (memo digest, recipient bytes) - fees.rs — relay op hashes, fee attribution by commit, public claim + fees.rs — fee crediting to the block author, public claim assets.rs — register / verify / unverify asset storage/ — one Repository per storage domain validate_unsigned/ — mempool anti-spam checks @@ -101,7 +109,6 @@ These are design properties of the current MVP. No formal security audit has bee ## Dependencies - `pallet-zk-verifier`: proof verification via `ZkVerifierPort`. -- `pallet-relayer`: relay fee accounting via `RelayerInterface`. - `orbinum-zk-core`: Poseidon hash, commitment and nullifier types. - FRAME: `frame-support`, `frame-system`, `sp-runtime`. diff --git a/frame/shielded-pool/runtime-api/Cargo.toml b/frame/shielded-pool/runtime-api/Cargo.toml index 2a352e25..37c8e2db 100644 --- a/frame/shielded-pool/runtime-api/Cargo.toml +++ b/frame/shielded-pool/runtime-api/Cargo.toml @@ -1,27 +1,21 @@ [package] name = "pallet-shielded-pool-runtime-api" -version = "0.3.0" +version = "0.4.0" authors = ["Orbinum Team"] edition = "2024" license = "GPL-3.0-or-later" [dependencies] pallet-shielded-pool = { path = "..", default-features = false } -parity-scale-codec = { version = "3.6", default-features = false, features = ["derive"] } -scale-info = { version = "2.11", default-features = false, features = ["derive"] } sp-api = { version = "43.0.0", default-features = false } sp-core = { version = "43.0.0", default-features = false } sp-runtime = { version = "48.0.0", default-features = false } -sp-std = { version = "14.0.0", default-features = false } [features] default = ["std"] std = [ - "parity-scale-codec/std", - "scale-info/std", "pallet-shielded-pool/std", "sp-api/std", - "sp-std/std", "sp-core/std", "sp-runtime/std", ] diff --git a/frame/shielded-pool/runtime-api/src/lib.rs b/frame/shielded-pool/runtime-api/src/lib.rs index ef21101e..4d15cc46 100644 --- a/frame/shielded-pool/runtime-api/src/lib.rs +++ b/frame/shielded-pool/runtime-api/src/lib.rs @@ -2,31 +2,8 @@ use pallet_shielded_pool::{DefaultMerklePath, Hash, SubtreeRoots}; -/// Configuration exposed by the runtime for the node-native EVM relay. -/// -/// Returned by `ShieldedPoolRuntimeApi::relay_config()`. The relay reads this on every -/// call instead of using hardcoded constants — when governance or a forkless upgrade -/// changes the minimum relay fee or the accepted selectors, the running node picks -/// up the new values automatically without recompilation. -#[derive( - parity_scale_codec::Encode, - parity_scale_codec::Decode, - scale_info::TypeInfo, - Debug, - Clone, - PartialEq -)] -pub struct RelayConfig { - /// Minimum fee (in native token planck / wei) that must be embedded in the calldata. - /// Corresponds to `pallet_relayer`'s `min_relay_fee()` in the runtime. - pub min_fee_planck: u128, - /// ABI selectors of operations the runtime currently accepts via unsigned dispatch. - /// The relay uses this to validate the calldata selector whitelist. - pub allowed_selectors: sp_std::vec::Vec<[u8; 4]>, -} - sp_api::decl_runtime_apis! { - #[api_version(3)] + #[api_version(5)] pub trait ShieldedPoolRuntimeApi { /// Get the Merkle tree information (root, size, depth) fn get_merkle_tree_info() -> (Hash, u32, u32); @@ -47,24 +24,9 @@ sp_api::decl_runtime_apis! { /// for the active tree. None if the leaf does not exist. fn get_root_for_leaf(leaf_index: u32) -> Option<(Hash, u32)>; - /// Return relay configuration sourced from the runtime. - /// - /// Called by the node-native EVM relay on every `relay_shielded_call` to obtain - /// the current minimum relay fee and the set of accepted selectors. This ensures - /// that forkless runtime upgrades are reflected immediately without restarting - /// or recompiling the node. - fn relay_config() -> RelayConfig; - - /// The relay commit `relayer` must record (`commit_relay`) before relaying - /// `calldata`, decoded exactly as the precompile will decode it. `None` for - /// calldata that is not a relayable spend. - #[api_version(3)] - fn relay_commit_hash(calldata: sp_std::vec::Vec, relayer: [u8; 20]) -> Option<[u8; 32]>; - /// Up to `count` level-6 subtree roots of `tree_id` from `start`, with the /// root the tree anchors to, read from one state, so a wallet can build /// its own Merkle paths. None for a tree that does not exist yet. - #[api_version(4)] fn get_subtree_roots(tree_id: u32, start: u32, count: u32) -> Option; } } diff --git a/frame/shielded-pool/src/benchmarking.rs b/frame/shielded-pool/src/benchmarking.rs index 29c2773c..b066b5f7 100644 --- a/frame/shielded-pool/src/benchmarking.rs +++ b/frame/shielded-pool/src/benchmarking.rs @@ -19,56 +19,22 @@ use alloc::vec; #[benchmarks( where - T: pallet_zk_verifier::Config + pallet_relayer::Config, - // The precompile's relayed origin is exercised directly. - ::RuntimeOrigin: From, + T: pallet_zk_verifier::Config, )] mod benchmarks { use super::*; use crate::FrameEncryptedMemo; - use crate::pallet::{Assets, NextAssetId, PoolBalancePerAsset}; - use pallet_relayer::RelayerInterface; - use sp_core::H160; + use crate::pallet::{Assets, NextAssetId, PendingFees, PoolBalancePerAsset}; use sp_std::vec::Vec; - /// Relayers the registry can hold at most: one per validator, the runtime's - /// `MaxValidators`. - const MAX_RELAYERS: u32 = 32; - - fn setup_relayer() -> H160 { - register_relayer::(0) - } - - fn relayer_address(i: u32) -> H160 { - H160::from_low_u64_be(0xAA00 + i as u64) - } - - fn register_relayer(i: u32) -> H160 { - let addr = relayer_address(i); - let relayer: T::AccountId = account("relayer", i, 0); - pallet_relayer::RelayerRegistry::::insert(addr, relayer.clone()); - pallet_relayer::RelayerByAccount::::insert(relayer, addr); - addr - } - - /// Worst case for fee attribution: a full registry, every relayer holding a - /// commit from an earlier block for the spend, so the lookup reads and - /// removes all of them. Returns the first relayer, which submits the spend. - fn commit_all_relayers(op_hash: &[u8; 32]) -> H160 { - let recorded_at: frame_system::pallet_prelude::BlockNumberFor = 1u32.into(); - let commit = pallet_relayer::RelayCommit { - recorded_at, - expires_at: recorded_at + ::CommitTtl::get(), - }; - for i in 0..MAX_RELAYERS { - let addr = register_relayer::(i); - pallet_relayer::RelayCommits::::insert( - pallet_relayer::relay_commit_hash(op_hash, &addr), - commit, - ); - } - frame_system::Pallet::::set_block_number(2u32.into()); - relayer_address(0) + /// A spend with a fee credits the block author, so the benchmarked block + /// must have one: the runtime's `BlockAuthor` has to return `Some` under + /// `runtime-benchmarks`. + fn ensure_block_author() { + assert!( + T::BlockAuthor::get().is_some(), + "spend benchmarks need `BlockAuthor` to return an account" + ); } fn setup_benchmark_env() -> (T::AccountId, u32) { @@ -102,14 +68,14 @@ mod benchmarks { (caller, asset_id) } - /// A value large enough to survive a relay fee being deducted from it. + /// A value large enough to survive a spend fee being deducted from it. /// - /// `unshield` pays the relayer out of `amount`, so a flat literal breaks the - /// moment a runtime's `min_relay_fee` exceeds it — the production fee is - /// 1e15 planck, which dwarfs any hand-picked constant. Deriving it from the - /// configured fee keeps the benchmarks working across every runtime. + /// A flat literal breaks the moment a runtime's `MinSpendFee` exceeds it — + /// the production floor is 1e15 planck, which dwarfs any hand-picked + /// constant. Deriving it from the configured fee keeps the benchmarks + /// working across every runtime. fn bench_amount() -> BalanceOf { - let fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); + let fee: BalanceOf = T::MinSpendFee::get().saturated_into(); let scaled = fee * 1_000u32.into(); let floor: BalanceOf = 1_000_000u32.into(); if scaled > floor { scaled } else { floor } @@ -206,23 +172,13 @@ mod benchmarks { vec![memo(), memo()].try_into().unwrap(); let asset_id = 0u32; - // Must be >= T::Relayer::min_relay_fee() to pass the FeeTooLow check. - let fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); - let op_hash = crate::operations::private_transfer::TransferRequest:: { - merkle_roots, - nullifiers: nullifiers.clone(), - commitments: commitments.clone(), - memos: encrypted_memos.clone(), - asset_id, - fee, - circuit_version: 1, - } - .op_hash(); - let relayer = commit_all_relayers::(&op_hash); + // Non-zero, so the fee is credited, and >= MinSpendFee to pass FeeTooLow. + let fee: BalanceOf = T::MinSpendFee::get().max(1).saturated_into(); + ensure_block_author::(); #[extrinsic_call] private_transfer( - crate::RawOrigin::Relayed(relayer), + RawOrigin::None, proof, merkle_roots, nullifiers, @@ -253,26 +209,13 @@ mod benchmarks { let proof: Proof = vec![0u8; 128].try_into().unwrap(); let nullifier = Nullifier(canonical_bytes(4)); - // Must be >= T::Relayer::min_relay_fee() to pass the FeeTooLow check. - let fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); - let op_hash = crate::operations::unshield::UnshieldRequest:: { - merkle_root, - nullifier, - asset_id, - amount, - recipient: recipient.clone(), - fee, - change_commitment: Hash::default(), - change_memo: Default::default(), - circuit_version: 1, - } - .op_hash() - .expect("benchmark recipient is 32 bytes"); - let relayer = commit_all_relayers::(&op_hash); + // Non-zero, so the fee is credited, and >= MinSpendFee to pass FeeTooLow. + let fee: BalanceOf = T::MinSpendFee::get().max(1).saturated_into(); + ensure_block_author::(); #[extrinsic_call] unshield( - crate::RawOrigin::Relayed(relayer), + RawOrigin::None, proof, merkle_root, nullifier, @@ -319,31 +262,14 @@ mod benchmarks { unverify_asset(RawOrigin::Root, asset_id); } + /// Worst case: the validator's account does not exist yet, so the payout + /// creates it. #[benchmark] - fn commit_relay(n: Linear<1, { crate::pallet::MAX_RELAY_COMMITS_PER_CALL }>) { - let relayer = setup_relayer::(); - let commits: BoundedVec< - sp_core::H256, - ConstU32<{ crate::pallet::MAX_RELAY_COMMITS_PER_CALL }>, - > = (0..n) - .map(|i| sp_core::H256::from_low_u64_be(i as u64 + 1)) - .collect::>() - .try_into() - .unwrap(); - - #[extrinsic_call] - commit_relay(crate::RawOrigin::Relayed(relayer), commits); - } - - /// Worst case: registered claimant, so the destination is its EVM mirror — - /// an account that does not exist yet. - #[benchmark] - fn claim_relay_fees() { + fn claim_fees() { let (_caller, asset_id) = setup_benchmark_env::(); let amount: BalanceOf = bench_amount::(); - let claimant: T::AccountId = account("relayer", 0, 0); - setup_relayer::(); - T::Relayer::accumulate_relay_fee(&claimant, asset_id, amount.saturated_into()); + let validator: T::AccountId = account("validator", 0, 0); + PendingFees::::insert(&validator, asset_id, amount.saturated_into::()); PoolBalancePerAsset::::insert(asset_id, amount * 2u32.into()); let _ = >::make_free_balance_be( &Pallet::::pool_account_id(), @@ -351,7 +277,7 @@ mod benchmarks { ); #[extrinsic_call] - claim_relay_fees(RawOrigin::Signed(claimant), asset_id, amount); + claim_fees(RawOrigin::Signed(validator), asset_id, amount); } /// Cost of one sweep that probes `n` sealed-tree nodes. diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 0b52147a..a4fc5ff7 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -10,8 +10,13 @@ //! - **Shield**: Deposit public tokens into the private pool //! - **Private Transfer**: Transfer privately within the pool using ZK proofs //! - **Unshield**: Withdraw tokens from the pool to a public account -//! - **Relay fees**: Relayers commit to spends (`commit_relay`) and claim the -//! fees credited to them (`claim_relay_fees`) +//! - **Spend fees**: Each spend's fee is credited to the block author, which +//! claims it with `claim_fees` +//! +//! Spends (`private_transfer`, `unshield`) are unsigned only: the ZK proof is +//! their authorization, and no account is linked to them. Their fee stays in the +//! pool, recorded as pending for the validator that authored the block, until +//! that validator claims it. //! //! ## Architecture //! @@ -46,15 +51,18 @@ //! //! // Transfer privately //! ShieldedPool::private_transfer( -//! origin, proof, merkle_root, nullifiers, commitments, encrypted_memos, +//! RuntimeOrigin::none(), proof, merkle_root, nullifiers, commitments, encrypted_memos, //! asset_id, fee, circuit_version, //! )?; //! //! // Withdraw from the pool //! ShieldedPool::unshield( -//! origin, proof, merkle_root, nullifier, asset_id, amount, recipient, fee, +//! RuntimeOrigin::none(), proof, merkle_root, nullifier, asset_id, amount, recipient, fee, //! change_commitment, change_encrypted_memo, circuit_version, //! )?; +//! +//! // Collect the spend fees credited to a validator +//! ShieldedPool::claim_fees(RuntimeOrigin::signed(validator), asset_id, amount)?; //! ``` #![cfg_attr(not(feature = "std"), no_std)] @@ -65,7 +73,6 @@ pub mod genesis; pub mod helpers; pub mod merkle; pub mod operations; -pub mod origin; pub mod storage; pub mod types; pub mod validate_unsigned; @@ -80,7 +87,6 @@ mod mock; #[cfg(test)] mod tests; -pub use origin::{RelayCaller, ensure_relay_caller, ensure_spend_origin}; pub use pallet::*; pub use types::{ AssetId, AssetMetadata, Commitment, DEFAULT_TREE_DEPTH, DefaultMerklePath, @@ -90,39 +96,6 @@ pub use types::{ }; pub use weights::WeightInfo; -use frame_support::pallet_prelude::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; -use scale_info::TypeInfo; - -/// Who submitted a call through the EVM precompile, as established by the -/// dispatch path itself. -/// -/// An origin cannot be forged — the EVM executor sets it from the transaction -/// signature. It identifies the relayer for `commit_relay` and the claimant for -/// `claim_relay_fees`. It does NOT decide who a spend's fee is credited to: -/// that is the relay commit, since a spend can be copied and resubmitted by -/// anyone. -#[derive( - PartialEq, - Eq, - Clone, - Debug, - Encode, - Decode, - DecodeWithMemTracking, - TypeInfo, - MaxEncodedLen -)] -pub enum RawOrigin { - /// Submitted through the EVM precompile by this address, which signed the - /// transaction and paid its gas. - /// - /// The only variant: an unrelayed submission arrives as `frame_system`'s - /// `None` origin, so there is nothing for this enum to say about it. A second - /// variant meaning "nobody" would be constructible by anyone able to build an - /// origin, and would then be indistinguishable from the real thing. - Relayed(sp_core::H160), -} - #[frame_support::pallet] #[allow(clippy::too_many_arguments)] pub mod pallet { @@ -168,43 +141,26 @@ pub mod pallet { /// a sealed 1 M-node tree in roughly 2 048 blocks (~3.4 hours at 6 s). pub(crate) const PRUNED_NODES_PER_BLOCK: u32 = 512; - /// Most relay commits a single `commit_relay` call carries. Only bounds the - /// call's size; the per-block quota is - /// `pallet_relayer::Config::MaxCommitsPerRelayerPerBlock`. - pub const MAX_RELAY_COMMITS_PER_CALL: u32 = 64; - #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); - #[pallet::origin] - pub type Origin = RawOrigin; - /// Configuration trait for the pallet #[pallet::config] - pub trait Config: - frame_system::Config< - RuntimeEvent: From>, - // Spends and relayer calls read the precompile's `Relayed` origin, so - // the outer origin must convert back to this pallet's own variant. - RuntimeOrigin: Into::RuntimeOrigin>>, - > - { + pub trait Config: frame_system::Config>> { /// The currency mechanism type Currency: Currency + ReservableCurrency; /// ZK proof verifier (domain port) type ZkVerifier: ZkVerifierPort; - /// Relay configuration, relay commits, fee accounting and block-author lookup. - /// - /// In production: `pallet_relayer::Pallet`. - /// In tests: a lightweight mock struct in `mock.rs`. - type Relayer: pallet_relayer::RelayerInterface; + /// The validator account that authored the current block; spend fees are + /// credited to it. `None` fails any spend with a non-zero fee. + type BlockAuthor: Get>; - /// The account an EVM address controls. Relay fees claimed by an EVM - /// relayer are paid there. Must match the runtime's EVM address mapping. - type EvmAccount: sp_runtime::traits::Convert; + /// Floor on a spend's fee, in planck. Changed by runtime upgrade. + #[pallet::constant] + type MinSpendFee: Get; /// The pallet's ID, used for deriving the pool account #[pallet::constant] @@ -441,6 +397,22 @@ pub mod pallet { #[pallet::storage] pub type TotalCommitmentsInserted = StorageValue<_, u64, ValueQuery>; + /// Spend fees credited to a validator and not yet claimed: + /// `(validator, asset_id) -> planck`. + /// + /// The tokens stay in the pool (and in `PoolBalancePerAsset`) until + /// `claim_fees` pays them out. + #[pallet::storage] + pub type PendingFees = StorageDoubleMap< + _, + Blake2_128Concat, + T::AccountId, + Blake2_128Concat, + u32, // asset_id + u128, // planck + ValueQuery, + >; + /// Total number of nullifiers ever spent (notes consumed). /// /// Monotonically increasing counter. Incremented once per @@ -556,7 +528,8 @@ pub mod pallet { /// Ledger-solvency invariant: the tracked native-asset pool balance must /// equal the pool account's physical free balance. Fees stay physical in - /// the pool until `claim_relay_fees` pays them out, so both move together. + /// the pool until `claim_fees` pays them out, so both move together, and + /// the pending fees never exceed the ledger that backs them. /// Only the native asset (0) is backed by `Currency`; other assets live in /// external backends — TODO: extend when a per-asset balance reader exists. #[cfg(feature = "try-runtime")] @@ -570,6 +543,15 @@ pub mod pallet { "shielded-pool native ledger drifted from physical pool balance" ) ); + let pending = PendingFees::::iter() + .filter(|(_, asset_id, _)| *asset_id == 0) + .fold(0u128, |sum, (_, _, amount)| sum.saturating_add(amount)); + frame_support::ensure!( + pending <= sp_runtime::SaturatedConversion::saturated_into::(tracked), + sp_runtime::TryRuntimeError::Other( + "shielded-pool pending fees exceed the native pool ledger" + ) + ); // Forest invariants: the active root must always be provable // against; one sealed root per completed tree; the sealed maps @@ -694,14 +676,22 @@ pub mod pallet { asset_id: u32, }, - /// A relayer claimed its pending relay fees out of the pool. - RelayFeesClaimed { - /// Account whose pending fees were spent - who: T::AccountId, - /// Account that received them - to: T::AccountId, + /// A validator claimed its pending spend fees out of the pool. + FeesClaimed { + /// Validator whose pending fees were paid to it + validator: T::AccountId, asset_id: u32, - amount: BalanceOf, + /// Planck paid out + amount: u128, + }, + + /// A spend's fee was credited to the block author's pending fees. + FeeCredited { + /// Validator that authored the block + validator: T::AccountId, + asset_id: u32, + /// Planck credited + amount: u128, }, } @@ -738,14 +728,14 @@ pub mod pallet { AssetNotVerified, /// Recipient address is zero (burn address) InvalidRecipient, - /// Gasless fee is below the required minimum + /// The spend's fee is below `MinSpendFee` FeeTooLow, /// A public value is not a canonical field element InvalidPublicSignals, - /// The caller has no registered relay address. - RelayerNotRegistered, - /// A non-zero fee could not be attributed to any recipient (no relay - /// commit and no block author). The fee tokens would otherwise be stranded. + /// The claim exceeds the caller's pending fees for the asset. + InsufficientPendingFees, + /// A non-zero fee could not be credited: the block has no known author. + /// The fee tokens would otherwise be stranded. FeeRecipientUnavailable, /// Batch operation submitted with no operations. EmptyBatch, @@ -853,11 +843,10 @@ pub mod pallet { /// This spends existing notes (via nullifiers) and creates new notes /// (via commitments). A ZK proof verifies the transfer is valid without /// revealing amounts or participants. The fee is embedded in the ZK proof - /// (input_sum == output_sum + fee) and credited to the relayer that - /// committed to this transfer (see `commit_relay`), else the block author. + /// (input_sum == output_sum + fee) and credited to the block author. /// /// # Arguments - /// * `origin` - Unsigned, signed, or the precompile's relayed origin + /// * `origin` - Unsigned only; signed and Root fail with `BadOrigin` /// * `proof` - The ZK proof of valid transfer /// * `merkle_roots` - The root each input is proven against, in input order. /// Equal unless the notes come from different trees (circuit v3); a @@ -866,7 +855,7 @@ pub mod pallet { /// * `commitments` - Commitments for new notes being created /// * `encrypted_memos` - Encrypted metadata for each new note /// * `asset_id` - Asset being transferred (public input of the proof) - /// * `fee` - Gasless fee (must match proof's fee public input) + /// * `fee` - Spend fee, credited to the block author (must match the proof's fee public input) /// * `circuit_version` - Version whose key the proof is verified against /// /// # Errors @@ -874,7 +863,8 @@ pub mod pallet { /// * `UnknownMerkleRoot` - Any root is not a known one (active, recent or sealed) /// * `NullifierAlreadyUsed` - Double-spend attempt /// * `ProofVerificationFailed` - ZK proof verification failed - /// * `FeeTooLow` - Fee is below `T::Relayer::min_relay_fee()` + /// * `FeeTooLow` - Fee is below `T::MinSpendFee` + /// * `FeeRecipientUnavailable` - Non-zero fee and no block author /// * `InvalidMemoSize` - Any encrypted memo is not exactly 180 bytes /// * `MemoCommitmentMismatch` - Number of memos does not match number of commitments #[pallet::call_index(1)] @@ -893,7 +883,7 @@ pub mod pallet { fee: BalanceOf, circuit_version: u32, ) -> DispatchResult { - ensure_spend_origin::(origin)?; + ensure_none(origin)?; use crate::operations::private_transfer::{PrivateTransferOperation, TransferRequest}; PrivateTransferOperation::execute::( &proof, @@ -914,18 +904,17 @@ pub mod pallet { /// This spends a private note and transfers the tokens to a public recipient. /// A ZK proof verifies ownership of the note without revealing which note. /// The fee is embedded in the ZK proof (note_value == amount + fee) and - /// credited to the relayer that committed to this unshield (see - /// `commit_relay`), else the block author. + /// credited to the block author. /// /// # Arguments - /// * `origin` - Unsigned, signed, or the precompile's relayed origin + /// * `origin` - Unsigned only; signed and Root fail with `BadOrigin` /// * `proof` - The ZK proof of valid withdrawal /// * `merkle_root` - The Merkle root the proof was computed against /// * `nullifier` - Nullifier for the note being spent /// * `asset_id` - Asset being unshielded /// * `amount` - Net amount to withdraw (recipient receives this) /// * `recipient` - Public account to receive tokens - /// * `fee` - Gasless fee (must match proof's fee public input) + /// * `fee` - Spend fee, credited to the block author (must match the proof's fee public input) /// * `change_commitment` - Commitment of the change note (empty [0u8; 32] for total unshield) /// * `change_encrypted_memo` - Empty for a total unshield; exactly 180 bytes for a partial one /// * `circuit_version` - Version whose key the proof is verified against @@ -935,7 +924,8 @@ pub mod pallet { /// * `NullifierAlreadyUsed` - Double-spend attempt /// * `ProofVerificationFailed` - ZK proof verification failed /// * `InsufficientPoolBalance` - Pool doesn't have enough tokens - /// * `FeeTooLow` - Fee is below `T::Relayer::min_relay_fee()` + /// * `FeeTooLow` - Fee is below `T::MinSpendFee` + /// * `FeeRecipientUnavailable` - Non-zero fee and no block author /// * `InvalidMemoSize` - Memo present on a total unshield, or not exactly 180 bytes on a partial one #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::unshield().saturating_add(T::ZkVerifier::verification_weight()))] @@ -959,7 +949,7 @@ pub mod pallet { // verified against this version's VK (not merely the active one). circuit_version: u32, ) -> DispatchResult { - ensure_spend_origin::(origin)?; + ensure_none(origin)?; use crate::operations::unshield::{UnshieldOperation, UnshieldRequest}; UnshieldOperation::execute::( &proof, @@ -1064,58 +1054,32 @@ pub mod pallet { crate::operations::assets::AssetOperation::unverify::(asset_id) } - /// Record relay commits for spends the caller is about to submit. - /// - /// Each commit is `pallet_relayer::relay_commit_hash(op_hash, relayer)`, - /// with `op_hash` from `operations::fees::relay_op_hash`. A spend included - /// in a later block credits its fee to the relayer that committed to it, - /// whoever submits the spend. The caller must have a registered relay - /// address; commits expire after `pallet_relayer::Config::CommitTtl`. - /// - /// # Errors - /// * `BadOrigin` - Unsigned or Root - /// * `EmptyBatch` - No commits - /// * `RelayerNotRegistered` - Signer has no registered relay address - /// * `pallet_relayer::Error::NotRegistered` - EVM caller is not registered - /// * `pallet_relayer::Error::TooManyCommits` - Per-block quota exhausted - #[pallet::call_index(18)] - #[pallet::weight(T::WeightInfo::commit_relay(commits.len() as u32))] - pub fn commit_relay( - origin: OriginFor, - commits: BoundedVec>, - ) -> DispatchResult { - crate::operations::fees::FeeOperation::commit::( - ensure_relay_caller::(origin)?, - &commits, - ) - } + // Call index 18 (`commit_relay`) is retired and must not be reused. - /// Pay the caller's pending relay fees out of the pool, publicly. + /// Pay `amount` of the caller's pending spend fees out of the pool to the + /// caller, publicly. /// - /// | Origin | Fees spent | Paid to | - /// |---|---|---| - /// | Signed(who) | `who`'s | mirror of `who`'s registered address, else `who` | - /// | `Relayed(addr)` (precompile) | those of the account registered to `addr` | mirror of `addr` | - /// - /// Bounded by the claimant's own pending balance; no proof is needed - /// because no note is created. + /// Bounded by the caller's own `PendingFees`; no proof is needed because + /// no note is created. /// /// # Errors /// * `BadOrigin` - Unsigned or Root - /// * `RelayerNotRegistered` - EVM caller has no registration - /// * `InvalidAssetId` / `InvalidAmount` / `InsufficientPoolBalance` - /// * `pallet_relayer::Error::InsufficientPendingFees` - `amount` exceeds pending fees + /// * `InvalidAssetId` / `AssetNotVerified` / `AssetNotSupported` - Asset cannot move + /// * `InvalidAmount` - `amount` is zero + /// * `InsufficientPoolBalance` - The pool ledger holds less than `amount` + /// * `InsufficientPendingFees` - `amount` exceeds the caller's pending fees + /// + /// # Events + /// * `FeesClaimed` #[pallet::call_index(19)] - #[pallet::weight(T::WeightInfo::claim_relay_fees())] - pub fn claim_relay_fees( + #[pallet::weight(T::WeightInfo::claim_fees())] + pub fn claim_fees( origin: OriginFor, asset_id: u32, amount: BalanceOf, ) -> DispatchResult { - use crate::operations::fees::FeeOperation; - let (claimant, to) = - FeeOperation::claimant_and_payee::(ensure_relay_caller::(origin)?)?; - FeeOperation::claim::(claimant, to, asset_id, amount) + let who = ensure_signed(origin)?; + crate::operations::fees::FeeOperation::claim::(who, asset_id, amount) } } diff --git a/frame/shielded-pool/src/mock.rs b/frame/shielded-pool/src/mock.rs index 15a35936..117b8137 100644 --- a/frame/shielded-pool/src/mock.rs +++ b/frame/shielded-pool/src/mock.rs @@ -1,11 +1,7 @@ //! Mock runtime for testing pallet-shielded-pool use crate as pallet_shielded_pool; -use frame_support::{ - PalletId, derive_impl, parameter_types, - traits::{ConstU32, ConstU64, ConstU128}, -}; -use frame_system::EnsureRoot; +use frame_support::{PalletId, derive_impl, parameter_types, traits::ConstU128}; use pallet_zk_verifier::{ShieldStatement, TransferStatement, UnshieldStatement, ZkVerifierPort}; use sp_runtime::{AccountId32, BuildStorage, traits::IdentityLookup}; @@ -23,7 +19,6 @@ frame_support::construct_runtime!( System: frame_system, Balances: pallet_balances, ZkVerifier: pallet_zk_verifier, - Relayer: pallet_relayer, ShieldedPool: pallet_shielded_pool, } ); @@ -144,42 +139,12 @@ impl ZkVerifierPort for MockZkVerifier { } } -/// `H160 ++ [0u8; 12]`, the runtime's EVM address mapping. -pub struct MirrorAccount; - -impl sp_runtime::traits::Convert for MirrorAccount { - fn convert(address: sp_core::H160) -> AccountId { - let mut bytes = [0u8; 32]; - bytes[..20].copy_from_slice(address.as_bytes()); - AccountId::from(bytes) - } -} - -/// Block-author provider for the real `pallet-relayer` in tests. -/// -/// Returns `None` when the raw `b"mock:no_author"` flag is set (see -/// `mock_clear_block_author`), otherwise `Some(acc(1))`. -pub struct MockBlockAuthor; -impl frame_support::traits::Get> for MockBlockAuthor { - fn get() -> Option { - if sp_io::storage::get(b"mock:no_author").is_some() { - None - } else { - Some(acc(1)) - } - } -} - -impl pallet_relayer::Config for Test { - type BlockAuthor = MockBlockAuthor; - type DefaultMinRelayFee = ConstU128<0>; - type MaxMinRelayFee = ConstU128<{ u128::MAX }>; - type ManageOrigin = EnsureRoot; - type MaxAllowedSelectors = ConstU32<16>; - type ValidatorSet = (); - type CommitTtl = ConstU64<10>; - type MaxCommitsPerRelayerPerBlock = ConstU32<64>; - type WeightInfo = (); +parameter_types! { + /// The block author spend fees are credited to; `None` models a block with + /// no known author. + pub static BlockAuthorAccount: Option = Some(acc(1)); + /// Floor on a spend's fee. Zero unless a test raises it. + pub static MinSpendFee: u128 = 0; } impl pallet_shielded_pool::Config for Test { @@ -192,52 +157,39 @@ impl pallet_shielded_pool::Config for Test { type SealedTreePrunedBelowLevel = SealedTreePrunedBelowLevel; type MaxLeavesPerTree = MaxLeavesPerTree; type WeightInfo = (); - type Relayer = pallet_relayer::Pallet; - type EvmAccount = MirrorAccount; + type BlockAuthor = BlockAuthorAccount; + type MinSpendFee = MinSpendFee; } -// ── Relayer test helpers ───────────────────────────────────────────────────── -// -// These drive the REAL `pallet-relayer` storage so `mock::Test` exercises the -// same code path as the runtime. +// ── Fee test helpers ───────────────────────────────────────────────────────── -/// Read a pending-fee balance from `pallet-relayer`. +/// Read a validator's pending fees. pub fn mock_pending_fees_get(who: AccountId, asset_id: u32) -> u128 { - pallet_relayer::PendingRelayerFees::::get(who, asset_id) + crate::PendingFees::::get(who, asset_id) } -/// Write a pending-fee balance into `pallet-relayer`. +/// Write a validator's pending fees. pub fn mock_pending_fees_set(who: AccountId, asset_id: u32, amount: u128) { - pallet_relayer::PendingRelayerFees::::insert(who, asset_id, amount); -} - -/// Set the minimum relay fee in `pallet-relayer`. -/// Defaults to 0 (`DefaultMinRelayFee = ConstU128<0>`); call this to raise the floor. -pub fn mock_set_min_relay_fee(fee: u128) { - pallet_relayer::MinRelayFee::::put(fee); + crate::PendingFees::::insert(who, asset_id, amount); } -/// Register an EVM address → account mapping so `resolve_relayer` returns `Some`. -/// Writes directly into `pallet-relayer`'s registry for tests. -pub fn mock_register_relayer(who: AccountId, addr: sp_core::H160) { - pallet_relayer::RelayerRegistry::::insert(addr, who.clone()); - // Both indexes: fee crediting and EVM claims resolve H160 -> account, while - // signed relayer calls resolve account -> H160. `register_relayer` writes - // both, so a helper that wrote only one would let a test pass against state - // the runtime cannot produce. - pallet_relayer::RelayerByAccount::::insert(who, addr); +/// Raise the spend-fee floor (default 0). +pub fn mock_set_min_spend_fee(fee: u128) { + MinSpendFee::set(fee); } -/// Force the relayer's `block_author()` to return `None` (default is `Some(acc(1))`), -/// to test the no-fee-recipient path. Read by `MockBlockAuthor`. +/// Make the block author unknown (default `Some(acc(1))`), to test the +/// no-fee-recipient path. pub fn mock_clear_block_author() { - sp_io::storage::set(b"mock:no_author", &[1u8]); + BlockAuthorAccount::set(None); } /// Build genesis storage for testing pub fn new_test_ext() -> sp_io::TestExternalities { VERIFIED.with(|v| v.borrow_mut().clear()); PROOF_VALID.with(|v| v.set(true)); + BlockAuthorAccount::set(Some(acc(1))); + MinSpendFee::set(0); let mut t = frame_system::GenesisConfig::::default() .build_storage() .unwrap(); diff --git a/frame/shielded-pool/src/operations/fees.rs b/frame/shielded-pool/src/operations/fees.rs index 7a369f6b..519e0724 100644 --- a/frame/shielded-pool/src/operations/fees.rs +++ b/frame/shielded-pool/src/operations/fees.rs @@ -1,13 +1,12 @@ -//! Relay fees: which relayer a spend credits, and paying fees out. +//! Spend fees: crediting them to the block author and paying them out. //! -//! A spend's fee goes to the relayer that recorded a relay commit for it in an -//! earlier block (see `pallet_relayer::relay_commit_hash`), whoever submits the -//! spend; without one it goes to the block author. +//! A spend's fee never leaves the pool when the spend executes: it is recorded +//! in `PendingFees` for the validator that authored the block, and leaves the +//! pool only when that validator claims it. use crate::{ - operations::{SpendRequest, assets::AssetOperation}, - origin::RelayCaller, - pallet::{BalanceOf, Call, Config, Error, Event, Pallet}, + operations::assets::AssetOperation, + pallet::{BalanceOf, Config, Error, Event, Pallet, PendingFees}, storage::PoolBalanceRepository, }; use frame_support::{ @@ -15,137 +14,40 @@ use frame_support::{ pallet_prelude::*, traits::{Currency, ExistenceRequirement}, }; -use pallet_relayer::RelayerInterface as _; -use pallet_zk_verifier::{TransferStatement, UnshieldStatement}; -use parity_scale_codec::Encode; -use sp_runtime::{ - SaturatedConversion, - traits::{Convert, Zero}, -}; - -const UNSHIELD_OP_DOMAIN: &[u8] = b"orbinum/relay-op/unshield"; -const TRANSFER_OP_DOMAIN: &[u8] = b"orbinum/relay-op/transfer"; +use sp_runtime::{SaturatedConversion, traits::Zero}; -/// Identify an unshield for relay commits. +/// Credit `fee` to the block author's pending fees. Both spends route through +/// here so they cannot drift apart. /// -/// Hashes the statement's public values plus `circuit_version`, and nothing -/// else: not the proof bytes, which anyone can re-randomise without the -/// witness, and not the memo, which a v1 proof does not bind. Any copy that -/// verifies hashes the same under a memo-bound version, which binds the memo -/// and the full recipient, so a copier cannot dodge the original's commit. A v1 -/// copy can still alias the recipient (see `pallet_zk_verifier::encoding`). -pub fn unshield_op_hash(s: &UnshieldStatement, circuit_version: u32) -> [u8; 32] { - sp_io::hashing::blake2_256( - &( - UNSHIELD_OP_DOMAIN, - s.merkle_root, - s.nullifier, - s.asset_id, - s.amount, - s.recipient, - s.fee, - s.change_commitment, - circuit_version, - ) - .encode(), - ) -} - -/// Identify a private transfer for relay commits. Same scope as -/// [`unshield_op_hash`]: public values and circuit version only. -pub fn transfer_op_hash(s: &TransferStatement, circuit_version: u32) -> [u8; 32] { - sp_io::hashing::blake2_256( - &( - TRANSFER_OP_DOMAIN, - s.merkle_roots, - &s.nullifiers, - &s.commitments, - s.asset_id, - s.fee, - circuit_version, - ) - .encode(), - ) -} - -/// The operation hash of a relayable call, `None` for any other call. -/// -/// What a relayer commits to before submitting. It builds the same request the -/// extrinsic executes, so the two cannot disagree on what identifies a spend. -pub fn relay_op_hash(call: &Call) -> Option<[u8; 32]> { - match SpendRequest::from_call(call)?.1 { - SpendRequest::Transfer(req) => Some(req.op_hash()), - SpendRequest::Unshield(req) => req.op_hash().ok(), - } -} - -/// Credit `fee` to the relayer that committed to `op_hash`, else the block -/// author. Both spends route through here so they cannot drift apart. -pub fn credit_relay_fee( - op_hash: &[u8; 32], - asset_id: u32, - fee: u128, -) -> Result<(), Error> { - let recipient = match T::Relayer::take_committed_relayer(op_hash) { - Some(relayer) => relayer, - None => T::Relayer::block_author().ok_or(Error::::FeeRecipientUnavailable)?, - }; - T::Relayer::accumulate_relay_fee(&recipient, asset_id, fee); +/// The caller must have resolved `asset_id` already: this writes a +/// `PendingFees` entry unconditionally. +pub fn credit_fee(asset_id: u32, fee: u128) -> Result<(), Error> { + let validator = T::BlockAuthor::get().ok_or(Error::::FeeRecipientUnavailable)?; + PendingFees::::mutate(&validator, asset_id, |pending| { + *pending = pending.saturating_add(fee) + }); + Pallet::::deposit_event(Event::FeeCredited { + validator, + asset_id, + amount: fee, + }); Ok(()) } pub struct FeeOperation; impl FeeOperation { - /// Record `commits` for the caller's registered relay address. - pub fn commit( - caller: RelayCaller, - commits: &[sp_core::H256], - ) -> DispatchResult { - ensure!(!commits.is_empty(), Error::::EmptyBatch); - let relayer = match caller { - RelayCaller::Evm(address) => address, - RelayCaller::Signed(who) => { - T::Relayer::registered_evm_address(&who).ok_or(Error::::RelayerNotRegistered)? - } - }; - T::Relayer::record_relay_commits(&relayer, commits) - } - - /// Whose pending fees a claim spends, and where it pays them. + /// Pay `amount` of `validator`'s pending fees out of the pool to it. /// - /// | Caller | Fees spent | Paid to | - /// |---|---|---| - /// | `Signed(who)` | `who`'s | the account of `who`'s registered address, else `who` | - /// | `Evm(addr)` | those of the account registered to `addr` | the account of `addr` | - pub fn claimant_and_payee( - caller: RelayCaller, - ) -> Result<(T::AccountId, T::AccountId), Error> { - Ok(match caller { - RelayCaller::Evm(address) => ( - T::Relayer::resolve_relayer(&address).ok_or(Error::::RelayerNotRegistered)?, - T::EvmAccount::convert(address), - ), - RelayCaller::Signed(who) => { - let to = T::Relayer::registered_evm_address(&who) - .map_or_else(|| who.clone(), T::EvmAccount::convert); - (who, to) - } - }) - } - - /// Pay `amount` of `claimant`'s pending relay fees out of the pool to `to`. - /// - /// No proof: the transfer is public and bounded by the claimant's own - /// pending balance, which `consume_relay_fee` enforces. The fee tokens stayed - /// in the pool when they were credited, so the ledger drops by `amount` now. + /// No proof: the transfer is public and bounded by the validator's own + /// pending balance. The fee tokens stayed in the pool when they were + /// credited, so the ledger drops by `amount` now. pub fn claim( - claimant: T::AccountId, - to: T::AccountId, + validator: T::AccountId, asset_id: u32, amount: BalanceOf, ) -> DispatchResult { - // An unverified asset is frozen: no outflow at all, relay fees included. + // An unverified asset is frozen: no outflow at all, fees included. AssetOperation::ensure_movable::(asset_id)?; ensure!(!amount.is_zero(), Error::::InvalidAmount); ensure!( @@ -154,20 +56,27 @@ impl FeeOperation { ); let amount_u128: u128 = amount.saturated_into(); - T::Relayer::consume_relay_fee(&claimant, asset_id, amount_u128)?; + let pending = PendingFees::::get(&validator, asset_id); + ensure!(pending >= amount_u128, Error::::InsufficientPendingFees); + let rest = pending - amount_u128; + if rest.is_zero() { + PendingFees::::remove(&validator, asset_id); + } else { + PendingFees::::insert(&validator, asset_id, rest); + } + T::Currency::transfer( &Pallet::::pool_account_id(), - &to, + &validator, amount, ExistenceRequirement::AllowDeath, )?; PoolBalanceRepository::decrease_balance::(asset_id, amount); - Pallet::::deposit_event(Event::RelayFeesClaimed { - who: claimant, - to, + Pallet::::deposit_event(Event::FeesClaimed { + validator, asset_id, - amount, + amount: amount_u128, }); Ok(()) } diff --git a/frame/shielded-pool/src/operations/mod.rs b/frame/shielded-pool/src/operations/mod.rs index 9d29fdc4..498e3751 100644 --- a/frame/shielded-pool/src/operations/mod.rs +++ b/frame/shielded-pool/src/operations/mod.rs @@ -17,9 +17,8 @@ use private_transfer::TransferRequest; use sp_std::vec::Vec; use unshield::UnshieldRequest; -/// A spend call, unpacked. Built by everything that reads a spend from its call -/// rather than its arguments — pool admission and relay commits — so they all -/// see the same request the extrinsic executes. +/// A spend call, unpacked. Pool admission builds it from the call, so it sees +/// the same request the extrinsic executes. pub enum SpendRequest { Transfer(TransferRequest), Unshield(UnshieldRequest), diff --git a/frame/shielded-pool/src/operations/private_transfer.rs b/frame/shielded-pool/src/operations/private_transfer.rs index 367fd974..7d976b15 100644 --- a/frame/shielded-pool/src/operations/private_transfer.rs +++ b/frame/shielded-pool/src/operations/private_transfer.rs @@ -10,7 +10,6 @@ use crate::{ use frame_support::{ BoundedVec, CloneNoBound, DebugNoBound, EqNoBound, PartialEqNoBound, pallet_prelude::*, }; -use pallet_relayer::RelayerInterface as _; use pallet_zk_verifier::{TransferStatement, ZkVerifierPort as _}; use sp_runtime::{SaturatedConversion, traits::Zero}; @@ -48,11 +47,6 @@ impl TransferRequest { } } - /// The identity a relayer commits to (see [`fees::transfer_op_hash`]). - pub fn op_hash(&self) -> [u8; 32] { - fees::transfer_op_hash(&self.statement(), self.circuit_version) - } - /// The inputs that spend a real note: every nullifier but the zero dummy. pub(crate) fn real_nullifiers(&self) -> impl Iterator { self.nullifiers.iter().filter(|n| n.0 != [0u8; 32]) @@ -90,8 +84,7 @@ impl PrivateTransferOperation { let leaf_indices = Self::settle(&req)?; if !req.fee.is_zero() { - let op_hash = fees::transfer_op_hash(&statement, req.circuit_version); - fees::credit_relay_fee::(&op_hash, req.asset_id, req.fee.saturated_into())?; + fees::credit_fee::(req.asset_id, req.fee.saturated_into())?; } Pallet::::deposit_event(Event::NullifiersSpent { @@ -115,7 +108,7 @@ impl PrivateTransferOperation { Error::::TooManyInputsOrOutputs ); ensure!( - req.fee >= T::Relayer::min_relay_fee().saturated_into(), + req.fee >= T::MinSpendFee::get().saturated_into(), Error::::FeeTooLow ); req.ensure_roots()?; diff --git a/frame/shielded-pool/src/operations/unshield.rs b/frame/shielded-pool/src/operations/unshield.rs index 377e7455..3cdee9cf 100644 --- a/frame/shielded-pool/src/operations/unshield.rs +++ b/frame/shielded-pool/src/operations/unshield.rs @@ -12,7 +12,6 @@ use frame_support::{ pallet_prelude::*, traits::{Currency, ExistenceRequirement}, }; -use pallet_relayer::RelayerInterface as _; use pallet_zk_verifier::{UnshieldStatement, ZkVerifierPort as _}; use sp_runtime::{SaturatedConversion, traits::Zero}; @@ -26,7 +25,7 @@ pub struct UnshieldRequest { /// Net amount the recipient receives. pub amount: BalanceOf, pub recipient: T::AccountId, - /// Relay fee, paid on top of `amount` from the spent note. + /// Spend fee, paid on top of `amount` from the spent note. pub fee: BalanceOf, /// Zero for a total unshield. pub change_commitment: [u8; 32], @@ -54,14 +53,6 @@ impl UnshieldRequest { memo_digest: statement::memo_digest(core::slice::from_ref(&self.change_memo)), }) } - - /// The identity a relayer commits to (see [`fees::unshield_op_hash`]). - pub fn op_hash(&self) -> Result<[u8; 32], Error> { - Ok(fees::unshield_op_hash( - &self.statement()?, - self.circuit_version, - )) - } } pub struct UnshieldOperation; @@ -76,8 +67,7 @@ impl UnshieldOperation { let change_leaf_index = Self::settle(&req)?; if !req.fee.is_zero() { - let op_hash = fees::unshield_op_hash(&statement, req.circuit_version); - fees::credit_relay_fee::(&op_hash, req.asset_id, req.fee.saturated_into())?; + fees::credit_fee::(req.asset_id, req.fee.saturated_into())?; } let has_change = req.has_change(); @@ -97,7 +87,7 @@ impl UnshieldOperation { /// verified; it maps each error to a rejection code. pub(crate) fn validate(req: &UnshieldRequest) -> Result<(), Error> { ensure!( - req.fee >= T::Relayer::min_relay_fee().saturated_into(), + req.fee >= T::MinSpendFee::get().saturated_into(), Error::::FeeTooLow ); ensure!( @@ -161,7 +151,7 @@ impl UnshieldOperation { ExistenceRequirement::AllowDeath, )?; // Only `amount` leaves the pool: the fee stays as backing for the - // relayer's pending fee until it is claimed. Sound only because + // block author's pending fee until it is claimed. Sound only because // `validate` required a balance of `amount + fee`. PoolBalanceRepository::decrease_balance::(req.asset_id, req.amount); diff --git a/frame/shielded-pool/src/origin.rs b/frame/shielded-pool/src/origin.rs deleted file mode 100644 index d5408884..00000000 --- a/frame/shielded-pool/src/origin.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Origin gates for the pallet's calls. -//! -//! | Calls | Accepted origins | Refused | -//! |---|---|---| -//! | spends (`private_transfer`, `unshield`) | unsigned, signed, `Relayed` | Root | -//! | relayer calls (`commit_relay`, `claim_relay_fees`) | signed, `Relayed` | unsigned, Root | -//! -//! Who submits a spend never decides who earns its fee; the relay commit does. - -use crate::{ - RawOrigin, - pallet::{Config, Origin}, -}; -use sp_runtime::traits::BadOrigin; - -/// Gate a spend's origin: unsigned, signed, or the precompile's `Relayed`. -/// Root is refused, so `sudo` cannot submit spends. -pub fn ensure_spend_origin(o: OuterOrigin) -> Result<(), BadOrigin> -where - OuterOrigin: Into> - + Into, OuterOrigin>>, -{ - match Into::>::into(o) { - Ok(RawOrigin::Relayed(_)) => Ok(()), - Err(other) => match other.into() { - Ok(frame_system::RawOrigin::None | frame_system::RawOrigin::Signed(_)) => Ok(()), - _ => Err(BadOrigin), - }, - } -} - -/// An authenticated caller of the relayer calls (`commit_relay`, -/// `claim_relay_fees`). -#[derive(Clone, PartialEq, Eq, Debug)] -pub enum RelayCaller { - /// The precompile's origin: this EVM address signed the transaction. - Evm(sp_core::H160), - /// A signed Substrate extrinsic. - Signed(AccountId), -} - -/// The relayer calls' caller. Unsigned and Root are refused. -pub fn ensure_relay_caller( - o: OuterOrigin, -) -> Result, BadOrigin> -where - OuterOrigin: Into> - + Into, OuterOrigin>>, -{ - match Into::>::into(o) { - Ok(RawOrigin::Relayed(address)) => Ok(RelayCaller::Evm(address)), - Err(other) => match other.into() { - Ok(frame_system::RawOrigin::Signed(who)) => Ok(RelayCaller::Signed(who)), - _ => Err(BadOrigin), - }, - } -} diff --git a/frame/shielded-pool/src/tests/commit_relay.rs b/frame/shielded-pool/src/tests/commit_relay.rs deleted file mode 100644 index 35f069cc..00000000 --- a/frame/shielded-pool/src/tests/commit_relay.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! The `commit_relay` extrinsic: who may record commits, and for which address. - -use super::{KNOWN_ROOT, evm, fund_pool, nullifier, proof, setup_asset}; -use crate::{ - MAX_RELAY_COMMITS_PER_CALL, RawOrigin, - mock::{RuntimeOrigin, ShieldedPool, Test, acc, mock_register_relayer, new_test_ext}, - pallet::Error, -}; -use frame_support::{ - BoundedVec, assert_noop, assert_ok, pallet_prelude::ConstU32, sp_runtime::traits::BadOrigin, -}; -use sp_core::H256; - -fn commits(seeds: &[u8]) -> BoundedVec> { - seeds - .iter() - .map(|s| H256::repeat_byte(*s)) - .collect::>() - .try_into() - .unwrap() -} - -fn recorded(commit: u8) -> bool { - pallet_relayer::RelayCommits::::contains_key(H256::repeat_byte(commit)) -} - -#[test] -fn a_registered_signer_commits_under_its_registered_address() { - new_test_ext().execute_with(|| { - mock_register_relayer(acc(7), evm(0xB7)); - assert_ok!(ShieldedPool::commit_relay( - RuntimeOrigin::signed(acc(7)), - commits(&[1, 2]) - )); - assert!(recorded(1) && recorded(2)); - let index = pallet_relayer::CommitsByRelayer::::iter_values() - .next() - .unwrap(); - assert_eq!(index.len(), 2); - }); -} - -#[test] -fn an_unregistered_signer_is_rejected() { - new_test_ext().execute_with(|| { - assert_noop!( - ShieldedPool::commit_relay(RuntimeOrigin::signed(acc(8)), commits(&[1])), - Error::::RelayerNotRegistered - ); - }); -} - -#[test] -fn a_registered_evm_caller_commits_through_the_precompile_origin() { - new_test_ext().execute_with(|| { - mock_register_relayer(acc(7), evm(0xB7)); - assert_ok!(ShieldedPool::commit_relay( - RawOrigin::Relayed(evm(0xB7)).into(), - commits(&[3]) - )); - assert!(recorded(3)); - }); -} - -#[test] -fn an_unregistered_evm_caller_is_rejected_by_the_relayer_pallet() { - new_test_ext().execute_with(|| { - assert_noop!( - ShieldedPool::commit_relay(RawOrigin::Relayed(evm(0xEE)).into(), commits(&[4])), - pallet_relayer::Error::::NotRegistered - ); - }); -} - -#[test] -fn unsigned_and_root_are_bad_origins() { - new_test_ext().execute_with(|| { - assert_noop!( - ShieldedPool::commit_relay(RuntimeOrigin::none(), commits(&[5])), - BadOrigin - ); - assert_noop!( - ShieldedPool::commit_relay(RuntimeOrigin::root(), commits(&[5])), - BadOrigin - ); - }); -} - -#[test] -fn an_empty_batch_is_rejected() { - new_test_ext().execute_with(|| { - mock_register_relayer(acc(7), evm(0xB7)); - assert_noop!( - ShieldedPool::commit_relay(RuntimeOrigin::signed(acc(7)), commits(&[])), - Error::::EmptyBatch - ); - }); -} - -#[test] -fn past_the_per_block_quota_nothing_is_written() { - new_test_ext().execute_with(|| { - mock_register_relayer(acc(7), evm(0xB7)); - let full: Vec = (1..=64).collect(); - assert_ok!(ShieldedPool::commit_relay( - RuntimeOrigin::signed(acc(7)), - commits(&full) - )); - assert_noop!( - ShieldedPool::commit_relay(RuntimeOrigin::signed(acc(7)), commits(&[200, 201])), - pallet_relayer::Error::::TooManyCommits - ); - assert!(!recorded(200) && !recorded(201)); - }); -} - -/// The whole path through extrinsics only: commit in one block, an unsigned -/// unshield in the next credits the committed relayer, and its claim pays out, -/// with the pool ledger and the pool's balance moving together. -#[test] -fn commit_then_spend_then_claim() { - use crate::{ - mock::{Balances, MirrorAccount, System}, - operations::unshield::UnshieldRequest, - pallet::PoolBalancePerAsset, - storage::MerkleRepository, - types::EncryptedMemo, - }; - use sp_runtime::traits::Convert; - new_test_ext().execute_with(|| { - let asset_id = setup_asset(); - let pool = crate::Pallet::::pool_account_id(); - fund_pool(asset_id, 1_000); - let root = KNOWN_ROOT; - MerkleRepository::add_historic_poseidon_root::(root); - mock_register_relayer(acc(7), evm(0xB7)); - - let request = UnshieldRequest:: { - merkle_root: root, - nullifier: nullifier(0x4E), - asset_id, - amount: 100, - recipient: acc(9), - fee: 30, - change_commitment: [0u8; 32], - change_memo: EncryptedMemo::default(), - circuit_version: 1, - }; - let commit = pallet_relayer::relay_commit_hash(&request.op_hash().unwrap(), &evm(0xB7)); - System::set_block_number(1); - assert_ok!(ShieldedPool::commit_relay( - RawOrigin::Relayed(evm(0xB7)).into(), - vec![commit].try_into().unwrap() - )); - - System::set_block_number(2); - assert_ok!(ShieldedPool::unshield( - RuntimeOrigin::none(), - proof(), - root, - request.nullifier, - asset_id, - 100, - acc(9), - 30, - [0u8; 32], - Default::default(), - 1, - )); - assert_eq!( - pallet_relayer::PendingRelayerFees::::get(acc(7), asset_id), - 30 - ); - assert_eq!(PoolBalancePerAsset::::get(asset_id), 900); - assert_eq!(Balances::free_balance(&pool), 900); - - assert_ok!(ShieldedPool::claim_relay_fees( - RawOrigin::Relayed(evm(0xB7)).into(), - asset_id, - 30 - )); - assert_eq!( - pallet_relayer::PendingRelayerFees::::get(acc(7), asset_id), - 0 - ); - assert_eq!( - Balances::free_balance(MirrorAccount::convert(evm(0xB7))), - 30 - ); - assert_eq!(PoolBalancePerAsset::::get(asset_id), 870); - assert_eq!(Balances::free_balance(&pool), 870); - }); -} diff --git a/frame/shielded-pool/src/tests/fees.rs b/frame/shielded-pool/src/tests/fees.rs index 50e1a883..b32e6bde 100644 --- a/frame/shielded-pool/src/tests/fees.rs +++ b/frame/shielded-pool/src/tests/fees.rs @@ -1,336 +1,120 @@ -//! Relay fees: op hashes, crediting the committed relayer, and claims. +//! Spend fees: crediting the block author, the fee floor, and claims. -use super::{commit, evm, fund_pool, register_asset, setup_asset}; +use super::{KNOWN_ROOT, fund_pool, nullifier, proof, register_asset, setup_asset}; use crate::{ - Call, Commitment, Error, Nullifier, RawOrigin, + Error, mock::{ - Balances, MirrorAccount, RuntimeOrigin, ShieldedPool, System, Test, acc, - mock_clear_block_author, mock_pending_fees_get, mock_pending_fees_set, - mock_register_relayer, new_test_ext, + Balances, RuntimeOrigin, ShieldedPool, System, Test, acc, mock_clear_block_author, + mock_pending_fees_get, mock_pending_fees_set, mock_set_min_spend_fee, new_test_ext, }, - operations::{ - assets::AssetOperation, fees::*, private_transfer::TransferRequest, - unshield::UnshieldRequest, - }, - origin::RelayCaller, - pallet::{Event as PalletEvent, PoolBalancePerAsset}, + operations::{assets::AssetOperation, fees::*}, + pallet::{Event as PalletEvent, PendingFees, PoolBalancePerAsset}, + storage::MerkleRepository, types::EncryptedMemo, }; -use frame_support::{assert_noop, assert_ok}; -use pallet_zk_verifier::{TransferStatement, UnshieldStatement}; -use sp_runtime::traits::Convert; - -const ROOT: [u8; 32] = [0x01; 32]; -const NULL: [u8; 32] = [0x02; 32]; -const CHANGE: [u8; 32] = [0x03; 32]; - -fn mirror(b: u8) -> crate::mock::AccountId { - MirrorAccount::convert(evm(b)) -} - -fn unshield() -> UnshieldStatement { - UnshieldStatement { - merkle_root: ROOT, - nullifier: NULL, - amount: 100, - recipient: acc(9).into(), - asset_id: 0, - fee: 5, - change_commitment: CHANGE, - memo_digest: [0xEE; 32], - } -} +use frame_support::{assert_noop, assert_ok, traits::UnfilteredDispatchable}; -fn transfer() -> TransferStatement { - TransferStatement { - merkle_roots: [ROOT; 2], - nullifiers: vec![NULL], - commitments: vec![CHANGE], - asset_id: 0, - fee: 5, - memo_digest: [0xEE; 32], - } +fn pool_physical() -> u128 { + Balances::free_balance(crate::Pallet::::pool_account_id()) } -// ── operation hashes ───────────────────────────────────────────────────────── +// ── credit_fee ─────────────────────────────────────────────────────────────── #[test] -fn every_public_value_changes_the_unshield_hash() { - let base = unshield_op_hash(&unshield(), 1); - assert_eq!(base, unshield_op_hash(&unshield(), 1)); - let variants = [ - UnshieldStatement { - merkle_root: [0xFF; 32], - ..unshield() - }, - UnshieldStatement { - nullifier: [0xFF; 32], - ..unshield() - }, - UnshieldStatement { - asset_id: 1, - ..unshield() - }, - UnshieldStatement { - amount: 101, - ..unshield() - }, - UnshieldStatement { - recipient: acc(8).into(), - ..unshield() - }, - UnshieldStatement { - fee: 6, - ..unshield() - }, - UnshieldStatement { - change_commitment: [0xFF; 32], - ..unshield() - }, - ]; - for v in variants { - assert_ne!(unshield_op_hash(&v, 1), base, "{v:?}"); - } - assert_ne!(unshield_op_hash(&unshield(), 2), base, "circuit version"); -} - -/// The roots are bound in input order: a commit to one order is not a commit -/// to the swapped one, so a relayer cannot claim a spend it did not commit to -/// by reordering its roots. -#[test] -fn the_transfer_hash_binds_the_order_of_the_roots() { - let ab = TransferStatement { - merkle_roots: [[0x0A; 32], [0x0B; 32]], - ..transfer() - }; - let ba = TransferStatement { - merkle_roots: [[0x0B; 32], [0x0A; 32]], - ..transfer() - }; - assert_ne!(transfer_op_hash(&ab, 3), transfer_op_hash(&ba, 3)); -} - -#[test] -fn every_public_value_changes_the_transfer_hash() { - let base = transfer_op_hash(&transfer(), 1); - let variants = [ - TransferStatement { - merkle_roots: [[0xFF; 32], ROOT], - ..transfer() - }, - TransferStatement { - merkle_roots: [ROOT, [0xFF; 32]], - ..transfer() - }, - TransferStatement { - nullifiers: vec![[0xFF; 32]], - ..transfer() - }, - TransferStatement { - commitments: vec![[0xFF; 32]], - ..transfer() - }, - TransferStatement { - nullifiers: vec![NULL, NULL], - commitments: vec![CHANGE, CHANGE], - ..transfer() - }, - TransferStatement { - asset_id: 1, - ..transfer() - }, - TransferStatement { - fee: 6, - ..transfer() - }, - ]; - for v in variants { - assert_ne!(transfer_op_hash(&v, 1), base, "{v:?}"); - } - assert_ne!(transfer_op_hash(&transfer(), 2), base, "circuit version"); -} - -/// Memos stay out of the identity: a v1 proof does not bind them, and a copier -/// swapping them must not escape the original relayer's commit. -#[test] -fn memos_do_not_change_either_hash() { - assert_eq!( - unshield_op_hash( - &UnshieldStatement { - memo_digest: [0x11; 32], - ..unshield() - }, - 1 - ), - unshield_op_hash(&unshield(), 1) - ); - assert_eq!( - transfer_op_hash( - &TransferStatement { - memo_digest: [0x11; 32], - ..transfer() - }, - 1 - ), - transfer_op_hash(&transfer(), 1) - ); -} - -fn unshield_call(proof_byte: u8, memo: EncryptedMemo) -> Call { - Call::::unshield { - proof: vec![proof_byte; 64].try_into().unwrap(), - merkle_root: ROOT, - nullifier: Nullifier(NULL), - asset_id: 0, - amount: 100, - recipient: acc(9), - fee: 5, - change_commitment: CHANGE, - change_encrypted_memo: memo, - circuit_version: 1, - } -} - -#[test] -fn relay_op_hash_is_the_extrinsics_own_hash() { - let request = UnshieldRequest:: { - merkle_root: ROOT, - nullifier: Nullifier(NULL), - asset_id: 0, - amount: 100, - recipient: acc(9), - fee: 5, - change_commitment: CHANGE, - change_memo: Default::default(), - circuit_version: 1, - }; - let expected = request.op_hash().unwrap(); - assert_eq!(expected, unshield_op_hash(&request.statement().unwrap(), 1)); - assert_eq!( - relay_op_hash(&unshield_call(0xAB, Default::default())), - Some(expected) - ); - // Neither a re-randomised proof nor another memo escapes the commit. - assert_eq!( - relay_op_hash(&unshield_call(0xCD, Default::default())), - Some(expected) - ); - let other_memo = EncryptedMemo::from_bytes(&[7; 180]).unwrap(); - assert_eq!( - relay_op_hash(&unshield_call(0xAB, other_memo)), - Some(expected) - ); - - let transfer_call = Call::::private_transfer { - proof: vec![0xAB; 64].try_into().unwrap(), - merkle_roots: [ROOT; 2], - nullifiers: vec![Nullifier(NULL)].try_into().unwrap(), - commitments: vec![Commitment(CHANGE)].try_into().unwrap(), - encrypted_memos: vec![Default::default()].try_into().unwrap(), - asset_id: 0, - fee: 5, - circuit_version: 1, - }; - let request = TransferRequest:: { - merkle_roots: [ROOT; 2], - nullifiers: vec![Nullifier(NULL)].try_into().unwrap(), - commitments: vec![Commitment(CHANGE)].try_into().unwrap(), - memos: vec![Default::default()].try_into().unwrap(), - asset_id: 0, - fee: 5, - circuit_version: 1, - }; - assert_eq!(relay_op_hash(&transfer_call), Some(request.op_hash())); -} - -#[test] -fn non_relayable_calls_have_no_op_hash() { - assert_eq!( - relay_op_hash(&Call::::verify_asset { asset_id: 0 }), - None - ); -} - -// ── credit_relay_fee ───────────────────────────────────────────────────────── - -#[test] -fn fee_goes_to_the_committed_relayer() { +fn fee_is_credited_to_the_block_author() { new_test_ext().execute_with(|| { - mock_register_relayer(acc(7), evm(0xAA)); - let op = unshield_op_hash(&unshield(), 1); - commit(evm(0xAA), &op); - System::set_block_number(2); - - assert_ok!(credit_relay_fee::(&op, 0, 30)); - assert_eq!(mock_pending_fees_get(acc(7), 0), 30); - assert_eq!(mock_pending_fees_get(acc(1), 0), 0); + assert_ok!(credit_fee::(0, 30)); + assert_ok!(credit_fee::(0, 12)); + assert_eq!(mock_pending_fees_get(acc(1), 0), 42); + System::assert_last_event( + PalletEvent::FeeCredited { + validator: acc(1), + asset_id: 0, + amount: 12, + } + .into(), + ); }); } #[test] -fn fee_without_a_commit_goes_to_the_block_author() { +fn fee_follows_the_author_of_each_block() { new_test_ext().execute_with(|| { - assert_ok!(credit_relay_fee::( - &unshield_op_hash(&unshield(), 1), - 0, - 30 - )); + assert_ok!(credit_fee::(0, 30)); + crate::mock::BlockAuthorAccount::set(Some(acc(2))); + assert_ok!(credit_fee::(0, 20)); assert_eq!(mock_pending_fees_get(acc(1), 0), 30); + assert_eq!(mock_pending_fees_get(acc(2), 0), 20); }); } #[test] -fn fee_with_no_recipient_at_all_is_rejected() { +fn fee_without_a_block_author_is_rejected() { new_test_ext().execute_with(|| { mock_clear_block_author(); assert!(matches!( - credit_relay_fee::(&unshield_op_hash(&unshield(), 1), 0, 30), + credit_fee::(0, 30), Err(Error::::FeeRecipientUnavailable) )); + assert!(PendingFees::::iter().next().is_none()); }); } -/// A commit lives `CommitTtl` blocks: pruned at expiry, it earns nothing and -/// the fee falls back to the block author. +// ── MinSpendFee ────────────────────────────────────────────────────────────── + +fn unshield_call(fee: u128) -> crate::Call { + crate::Call::::unshield { + proof: proof(), + merkle_root: KNOWN_ROOT, + nullifier: nullifier(0x41), + asset_id: 0, + amount: 500, + recipient: acc(2), + fee, + change_commitment: [0u8; 32], + change_encrypted_memo: EncryptedMemo::default(), + circuit_version: 1, + } +} + #[test] -fn an_expired_commit_earns_nothing() { - use frame_support::traits::{Get, Hooks}; +fn a_spend_below_the_fee_floor_is_refused() { new_test_ext().execute_with(|| { - mock_register_relayer(acc(7), evm(0xAA)); - let op = unshield_op_hash(&unshield(), 1); - commit(evm(0xAA), &op); - let expiry: u64 = 1 + <::CommitTtl as Get>::get(); - System::set_block_number(expiry); - pallet_relayer::Pallet::::on_initialize(expiry); + fund_pool(setup_asset(), 1_000); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + mock_set_min_spend_fee(50); - assert_ok!(credit_relay_fee::(&op, 0, 30)); - assert_eq!(mock_pending_fees_get(acc(7), 0), 0); - assert_eq!(mock_pending_fees_get(acc(1), 0), 30); + assert_noop!( + unshield_call(49).dispatch_bypass_filter(RuntimeOrigin::none()), + Error::::FeeTooLow + ); + assert_ok!(unshield_call(50).dispatch_bypass_filter(RuntimeOrigin::none())); + assert_eq!(mock_pending_fees_get(acc(1), 0), 50); }); } -// ── FeeOperation::claim ────────────────────────────────────────────────────── +// ── claim_fees ─────────────────────────────────────────────────────────────── #[test] -fn claim_pays_the_destination_and_updates_every_ledger() { +fn claim_pays_the_validator_and_updates_every_ledger() { new_test_ext().execute_with(|| { let asset_id = setup_asset(); fund_pool(asset_id, 500); mock_pending_fees_set(acc(7), asset_id, 200); - assert_ok!(FeeOperation::claim::(acc(7), acc(9), asset_id, 150)); + assert_ok!(ShieldedPool::claim_fees( + RuntimeOrigin::signed(acc(7)), + asset_id, + 150 + )); - assert_eq!(Balances::free_balance(acc(9)), 150); + assert_eq!(Balances::free_balance(acc(7)), 150); assert_eq!(PoolBalancePerAsset::::get(asset_id), 350); - assert_eq!( - Balances::free_balance(crate::Pallet::::pool_account_id()), - 350 - ); + assert_eq!(pool_physical(), 350); assert_eq!(mock_pending_fees_get(acc(7), asset_id), 50); System::assert_last_event( - PalletEvent::RelayFeesClaimed { - who: acc(7), - to: acc(9), + PalletEvent::FeesClaimed { + validator: acc(7), asset_id, amount: 150, } @@ -340,7 +124,23 @@ fn claim_pays_the_destination_and_updates_every_ledger() { } #[test] -fn claim_is_bounded_by_the_claimants_own_pending_fees() { +fn a_full_claim_clears_the_pending_entry() { + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + fund_pool(asset_id, 500); + mock_pending_fees_set(acc(7), asset_id, 200); + + assert_ok!(ShieldedPool::claim_fees( + RuntimeOrigin::signed(acc(7)), + asset_id, + 200 + )); + assert!(!PendingFees::::contains_key(acc(7), asset_id)); + }); +} + +#[test] +fn claim_is_bounded_by_the_callers_own_pending_fees() { new_test_ext().execute_with(|| { let asset_id = setup_asset(); fund_pool(asset_id, 500); @@ -348,10 +148,14 @@ fn claim_is_bounded_by_the_claimants_own_pending_fees() { mock_pending_fees_set(acc(8), asset_id, 400); assert_noop!( - FeeOperation::claim::(acc(7), acc(7), asset_id, 101), - pallet_relayer::Error::::InsufficientPendingFees + ShieldedPool::claim_fees(RuntimeOrigin::signed(acc(7)), asset_id, 101), + Error::::InsufficientPendingFees ); // Another account's balance is not reachable. + assert_noop!( + ShieldedPool::claim_fees(RuntimeOrigin::signed(acc(9)), asset_id, 1), + Error::::InsufficientPendingFees + ); assert_eq!(mock_pending_fees_get(acc(8), asset_id), 400); }); } @@ -364,15 +168,15 @@ fn claim_rejects_zero_unknown_asset_and_an_underfunded_ledger() { mock_pending_fees_set(acc(7), asset_id, 100); assert_noop!( - FeeOperation::claim::(acc(7), acc(7), asset_id, 0), + FeeOperation::claim::(acc(7), asset_id, 0), Error::::InvalidAmount ); assert_noop!( - FeeOperation::claim::(acc(7), acc(7), 999, 10), + FeeOperation::claim::(acc(7), 999, 10), Error::::InvalidAssetId ); assert_noop!( - FeeOperation::claim::(acc(7), acc(7), asset_id, 60), + FeeOperation::claim::(acc(7), asset_id, 60), Error::::InsufficientPoolBalance ); }); @@ -386,125 +190,25 @@ fn claim_reverts_the_pending_debit_when_the_payout_fails() { PoolBalancePerAsset::::insert(asset_id, 500); mock_pending_fees_set(acc(7), asset_id, 200); - assert!( - ShieldedPool::claim_relay_fees(RuntimeOrigin::signed(acc(7)), asset_id, 100).is_err() - ); + assert!(ShieldedPool::claim_fees(RuntimeOrigin::signed(acc(7)), asset_id, 100).is_err()); assert_eq!(mock_pending_fees_get(acc(7), asset_id), 200); }); } -// ── claim_relay_fees: who pays whom ────────────────────────────────────────── - -#[test] -fn signed_claim_pays_the_registered_evm_mirror() { - new_test_ext().execute_with(|| { - let asset_id = setup_asset(); - fund_pool(asset_id, 500); - mock_register_relayer(acc(7), evm(0xAA)); - mock_pending_fees_set(acc(7), asset_id, 100); - - assert_ok!(ShieldedPool::claim_relay_fees( - RuntimeOrigin::signed(acc(7)), - asset_id, - 100 - )); - assert_eq!(Balances::free_balance(mirror(0xAA)), 100); - }); -} - -#[test] -fn signed_claim_without_registration_pays_the_account_itself() { - new_test_ext().execute_with(|| { - let asset_id = setup_asset(); - fund_pool(asset_id, 500); - mock_pending_fees_set(acc(4), asset_id, 100); - - assert_ok!(ShieldedPool::claim_relay_fees( - RuntimeOrigin::signed(acc(4)), - asset_id, - 100 - )); - assert_eq!(Balances::free_balance(acc(4)), 100); - }); -} - -#[test] -fn evm_claim_spends_the_registered_accounts_fees_into_the_callers_mirror() { - new_test_ext().execute_with(|| { - let asset_id = setup_asset(); - fund_pool(asset_id, 500); - mock_register_relayer(acc(7), evm(0xAA)); - mock_pending_fees_set(acc(7), asset_id, 100); - - assert_ok!(ShieldedPool::claim_relay_fees( - RawOrigin::Relayed(evm(0xAA)).into(), - asset_id, - 100 - )); - assert_eq!(mock_pending_fees_get(acc(7), asset_id), 0); - assert_eq!(Balances::free_balance(mirror(0xAA)), 100); - }); -} - -#[test] -fn evm_claim_is_bounded_by_the_pool_ledger() { - new_test_ext().execute_with(|| { - let asset_id = setup_asset(); - fund_pool(asset_id, 50); - mock_register_relayer(acc(7), evm(0xAA)); - mock_pending_fees_set(acc(7), asset_id, 100); - assert_noop!( - ShieldedPool::claim_relay_fees(RawOrigin::Relayed(evm(0xAA)).into(), asset_id, 100), - Error::::InsufficientPoolBalance - ); - }); -} - -#[test] -fn evm_claim_from_an_unregistered_address_is_rejected() { - new_test_ext().execute_with(|| { - let asset_id = setup_asset(); - fund_pool(asset_id, 500); - assert_noop!( - ShieldedPool::claim_relay_fees(RawOrigin::Relayed(evm(0xBB)).into(), asset_id, 100), - Error::::RelayerNotRegistered - ); - }); -} - #[test] -fn unsigned_claim_is_rejected() { +fn unsigned_and_root_claims_are_rejected() { new_test_ext().execute_with(|| { let asset_id = setup_asset(); - assert_noop!( - ShieldedPool::claim_relay_fees(RuntimeOrigin::none(), asset_id, 1), - sp_runtime::traits::BadOrigin - ); - }); -} - -#[test] -fn claimant_and_payee_follow_the_caller() { - new_test_ext().execute_with(|| { - mock_register_relayer(acc(7), evm(0xAA)); - let who = |c| FeeOperation::claimant_and_payee::(c); - assert_eq!( - who(RelayCaller::Evm(evm(0xAA))).unwrap(), - (acc(7), mirror(0xAA)) - ); - assert_eq!( - who(RelayCaller::Signed(acc(7))).unwrap(), - (acc(7), mirror(0xAA)) - ); - assert_eq!(who(RelayCaller::Signed(acc(4))).unwrap(), (acc(4), acc(4))); - assert!(matches!( - who(RelayCaller::Evm(evm(0xBB))), - Err(Error::::RelayerNotRegistered) - )); + for origin in [RuntimeOrigin::none(), RuntimeOrigin::root()] { + assert_noop!( + ShieldedPool::claim_fees(origin, asset_id, 1), + sp_runtime::traits::BadOrigin + ); + } }); } -/// Unverifying an asset freezes every outflow, relay fees included. +/// Unverifying an asset freezes every outflow, fees included. #[test] fn claims_of_a_frozen_asset_are_refused() { new_test_ext().execute_with(|| { @@ -513,11 +217,15 @@ fn claims_of_a_frozen_asset_are_refused() { mock_pending_fees_set(acc(7), asset_id, 100); AssetOperation::unverify::(asset_id).unwrap(); assert_noop!( - FeeOperation::claim::(acc(7), acc(7), asset_id, 10), + ShieldedPool::claim_fees(RuntimeOrigin::signed(acc(7)), asset_id, 10), Error::::AssetNotVerified ); AssetOperation::verify::(asset_id).unwrap(); - assert_ok!(FeeOperation::claim::(acc(7), acc(7), asset_id, 10)); + assert_ok!(ShieldedPool::claim_fees( + RuntimeOrigin::signed(acc(7)), + asset_id, + 10 + )); }); } @@ -530,7 +238,7 @@ fn claims_of_a_non_native_asset_are_refused() { fund_pool(asset_id, 500); mock_pending_fees_set(acc(7), asset_id, 100); assert_noop!( - FeeOperation::claim::(acc(7), acc(7), asset_id, 10), + ShieldedPool::claim_fees(RuntimeOrigin::signed(acc(7)), asset_id, 10), Error::::AssetNotSupported ); }); diff --git a/frame/shielded-pool/src/tests/mod.rs b/frame/shielded-pool/src/tests/mod.rs index 6fc2d8b9..6e158959 100644 --- a/frame/shielded-pool/src/tests/mod.rs +++ b/frame/shielded-pool/src/tests/mod.rs @@ -1,18 +1,14 @@ //! Unit tests for the pallet's extrinsics and operations, one file per concern, //! and the fixtures they share. //! -//! - [`origin`] — which origins may spend and call the relayer extrinsics -//! - [`unshield`] — `UnshieldOperation`: validation, settlement, fee routing +//! - [`unshield`] — `UnshieldOperation`: validation, settlement, fee crediting //! - [`private_transfer`] — `PrivateTransferOperation`: the same for transfers -//! - [`fees`] — relay op hashes, fee crediting and `claim_relay_fees` -//! - [`commit_relay`] — the `commit_relay` extrinsic +//! - [`fees`] — fee crediting to the block author and `claim_fees` //! //! Lower-level modules (merkle, storage, validate_unsigned, …) keep their tests //! next to their code and use the fixtures below too. -mod commit_relay; mod fees; -mod origin; mod private_transfer; mod unshield; @@ -22,9 +18,7 @@ use crate::{ storage::PoolBalanceRepository, types::{Commitment, EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE, Nullifier}, }; -use frame_support::{BoundedVec, assert_ok, pallet_prelude::ConstU32, traits::Currency}; -use pallet_relayer::RelayerInterface as _; -use sp_core::H160; +use frame_support::{BoundedVec, pallet_prelude::ConstU32, traits::Currency}; /// A root the tests register as known before spending against it. pub(crate) const KNOWN_ROOT: [u8; 32] = [0xAA; 32]; @@ -107,10 +101,6 @@ pub(crate) fn field_twin(x: [u8; 32]) -> [u8; 32] { out } -pub(crate) fn evm(byte: u8) -> H160 { - H160::repeat_byte(byte) -} - /// Register an asset without verifying it, returning its id. pub(crate) fn register_asset() -> u32 { let name = BoundedVec::try_from(b"Orbinum".to_vec()).unwrap(); @@ -128,11 +118,3 @@ pub(crate) fn fund_pool(asset_id: u32, amount: u128) { Balances::make_free_balance_be(&crate::Pallet::::pool_account_id(), amount); PoolBalanceRepository::set_asset_balance::(asset_id, amount); } - -/// Record `relayer`'s relay commit for `op_hash`, as `commit_relay` would. -pub(crate) fn commit(relayer: H160, op_hash: &[u8; 32]) { - assert_ok!(pallet_relayer::Pallet::::record_relay_commits( - &relayer, - &[pallet_relayer::relay_commit_hash(op_hash, &relayer)], - )); -} diff --git a/frame/shielded-pool/src/tests/origin.rs b/frame/shielded-pool/src/tests/origin.rs deleted file mode 100644 index 1d2d3290..00000000 --- a/frame/shielded-pool/src/tests/origin.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! Origins: who may submit a spend, and who a relayer extrinsic acts for. - -use super::evm; -use crate::{ - RawOrigin, - mock::{RuntimeOrigin, Test, acc, new_test_ext}, - origin::{RelayCaller, ensure_relay_caller, ensure_spend_origin}, -}; - -fn signed(n: u8) -> RuntimeOrigin { - frame_system::RawOrigin::Signed(acc(n)).into() -} - -fn relayed(byte: u8) -> RuntimeOrigin { - RawOrigin::Relayed(evm(byte)).into() -} - -// ── ensure_spend_origin ────────────────────────────────────────────────────── - -#[test] -fn spends_accept_unsigned_signed_and_relayed_origins() { - new_test_ext().execute_with(|| { - assert!(ensure_spend_origin::(RuntimeOrigin::none()).is_ok()); - assert!(ensure_spend_origin::(signed(9)).is_ok()); - assert!(ensure_spend_origin::(relayed(0xAA)).is_ok()); - }); -} - -/// Sudo dispatches as Root; a spend submitted that way would dodge the -/// unsigned-transaction checks for no benefit. -#[test] -fn spends_refuse_root() { - new_test_ext().execute_with(|| { - assert!(ensure_spend_origin::(RuntimeOrigin::root()).is_err()); - }); -} - -// ── ensure_relay_caller ────────────────────────────────────────────────────── - -#[test] -fn a_relayed_origin_is_its_evm_address_verbatim() { - new_test_ext().execute_with(|| { - assert_eq!( - ensure_relay_caller::(relayed(0xAA)).unwrap(), - RelayCaller::Evm(evm(0xAA)) - ); - }); -} - -#[test] -fn a_signed_origin_is_its_account_registered_or_not() { - new_test_ext().execute_with(|| { - assert_eq!( - ensure_relay_caller::(signed(9)).unwrap(), - RelayCaller::Signed(acc(9)) - ); - }); -} - -#[test] -fn relayer_calls_refuse_unsigned_and_root() { - new_test_ext().execute_with(|| { - assert!(ensure_relay_caller::(RuntimeOrigin::none()).is_err()); - assert!(ensure_relay_caller::(RuntimeOrigin::root()).is_err()); - }); -} diff --git a/frame/shielded-pool/src/tests/private_transfer.rs b/frame/shielded-pool/src/tests/private_transfer.rs index bbad5fc5..7d495c40 100644 --- a/frame/shielded-pool/src/tests/private_transfer.rs +++ b/frame/shielded-pool/src/tests/private_transfer.rs @@ -1,9 +1,9 @@ -//! `PrivateTransferOperation`: validation, settlement, the ledger and relay-fee -//! routing, plus an adversarial battery against its non-cryptographic checks. +//! `PrivateTransferOperation`: validation, settlement, the ledger and fee +//! crediting, plus an adversarial battery against its non-cryptographic checks. use super::{ - KNOWN_ROOT, canonical_bytes, commit, commitment, commitments_of, evm, fund_pool, memo, - nullifier, nullifiers_of, proof, short_memo, + KNOWN_ROOT, canonical_bytes, commitment, commitments_of, fund_pool, memo, nullifier, + nullifiers_of, proof, short_memo, }; use crate::{ mock::{System, Test, acc, new_test_ext}, @@ -13,7 +13,6 @@ use crate::{ types::{Commitment, EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE, Nullifier}, }; use frame_support::{BoundedVec, assert_err, assert_noop, assert_ok, pallet_prelude::ConstU32}; -use pallet_relayer::RelayerInterface as _; // ── helpers ────────────────────────────────────────────────────────────────── @@ -483,7 +482,7 @@ fn execute_accumulates_fee_to_block_author_when_nonzero() { }, )); - // MockRelayer block_author = Some(1) + // The mock's block author is acc(1). let pending = crate::mock::mock_pending_fees_get(acc(1), 0u32); assert_eq!(pending, fee); }); @@ -714,64 +713,7 @@ fn transfer_preserves_pool_ledger() { }); } -// ── relay-fee attribution (relay commits) ──────────────────────────────────── - -/// The relayer that committed to the transfer in an earlier block receives -/// its fee; without a commit the block author does. -#[test] -fn transfer_fee_follows_the_relay_commit() { - new_test_ext().execute_with(|| { - MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); - let relayer = evm(0xAA); - crate::mock::mock_register_relayer(acc(7), relayer); - - let nullifiers = one_input(0x70); - let commitments = two_outputs(0x71); - let op_hash = TransferRequest:: { - merkle_roots: [KNOWN_ROOT, KNOWN_ROOT], - nullifiers: nullifiers.clone(), - commitments: commitments.clone(), - memos: memos_of(2), - asset_id: 0, - fee: 30, - circuit_version: 1, - } - .op_hash(); - commit(relayer, &op_hash); - System::set_block_number(2); - - assert_ok!(PrivateTransferOperation::execute::( - &proof(), - TransferRequest { - merkle_roots: [KNOWN_ROOT, KNOWN_ROOT], - nullifiers, - commitments, - memos: memos_of(2), - asset_id: 0u32, - fee: 30u128, - circuit_version: 1, - }, - )); - assert_eq!(crate::mock::mock_pending_fees_get(acc(7), 0u32), 30); - - // Uncommitted transfer → block author. - assert_ok!(PrivateTransferOperation::execute::( - &proof(), - TransferRequest { - merkle_roots: [KNOWN_ROOT, KNOWN_ROOT], - nullifiers: one_input(0x72), - commitments: two_outputs(0x73), - memos: memos_of(2), - asset_id: 0u32, - fee: 20u128, - circuit_version: 1, - }, - )); - assert_eq!(crate::mock::mock_pending_fees_get(acc(1), 0u32), 20); - }); -} - -/// A non-zero fee with no resolvable recipient errors, mirroring unshield. +/// A non-zero fee with no block author errors, mirroring unshield. #[test] fn transfer_nonzero_fee_without_recipient_errors() { new_test_ext().execute_with(|| { @@ -1163,16 +1105,14 @@ fn attack_zero_commitment_is_refused() { }); } -/// A fee below the relay minimum must be refused BEFORE any state changes — +/// A fee below `MinSpendFee` must be refused BEFORE any state changes — /// otherwise the pool subsidizes the spam it is meant to price out. #[test] fn attack_fee_below_minimum_is_refused_without_spending_the_nullifier() { new_test_ext().execute_with(|| { MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); - let min = ::Relayer::min_relay_fee(); - if min == 0 { - return; // mock has no minimum; nothing to prove here - } + crate::mock::mock_set_min_spend_fee(100); + let min = <::MinSpendFee as frame_support::traits::Get>::get(); let n = nullifier(0xC9); assert_err!( @@ -1289,7 +1229,7 @@ fn attack_memo_contents_never_gate_admission() { }); } -// ── the statement handed to the verifier, and copies of a relayed transfer ─── +// ── the statement handed to the verifier, and the extrinsic's origin ───────── fn request(seeds: [u8; 2]) -> TransferRequest { TransferRequest { @@ -1317,37 +1257,42 @@ fn transfer_call(req: &TransferRequest) -> crate::Call { } #[test] -fn a_copied_transfer_still_credits_the_committed_relayer() { +fn an_unsigned_transfer_credits_the_block_author() { use frame_support::traits::UnfilteredDispatchable; new_test_ext().execute_with(|| { MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); - let (honest, copier) = (evm(0xAA), evm(0xBB)); - crate::mock::mock_register_relayer(acc(7), honest); - crate::mock::mock_register_relayer(acc(8), copier); + crate::mock::BlockAuthorAccount::set(Some(acc(7))); let req = request([0xC4, 0xD4]); - commit(honest, &req.op_hash()); - System::set_block_number(2); - assert_ok!( - transfer_call(&req).dispatch_bypass_filter(crate::RawOrigin::Relayed(copier).into()) - ); + assert_ok!(transfer_call(&req).dispatch_bypass_filter(crate::mock::RuntimeOrigin::none())); assert_eq!(crate::mock::mock_pending_fees_get(acc(7), 0), 30); - assert_eq!(crate::mock::mock_pending_fees_get(acc(8), 0), 0); + System::assert_has_event( + PalletEvent::FeeCredited { + validator: acc(7), + asset_id: 0, + amount: 30, + } + .into(), + ); }); } +/// Signed and Root submissions are refused: a spend is unsigned only. #[test] -fn a_same_block_commit_does_not_capture_the_transfer_fee() { +fn a_signed_or_root_transfer_is_refused() { + use frame_support::traits::UnfilteredDispatchable; new_test_ext().execute_with(|| { MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); - let relayer = evm(0xBB); - crate::mock::mock_register_relayer(acc(8), relayer); let req = request([0xC8, 0xD8]); - commit(relayer, &req.op_hash()); - - assert_ok!(PrivateTransferOperation::execute::(&proof(), req)); - assert_eq!(crate::mock::mock_pending_fees_get(acc(8), 0), 0); - assert_eq!(crate::mock::mock_pending_fees_get(acc(1), 0), 30); + for origin in [ + crate::mock::RuntimeOrigin::signed(acc(2)), + crate::mock::RuntimeOrigin::root(), + ] { + assert_noop!( + transfer_call(&req).dispatch_bypass_filter(origin), + sp_runtime::traits::BadOrigin + ); + } }); } diff --git a/frame/shielded-pool/src/tests/unshield.rs b/frame/shielded-pool/src/tests/unshield.rs index 95139548..3cad3cf7 100644 --- a/frame/shielded-pool/src/tests/unshield.rs +++ b/frame/shielded-pool/src/tests/unshield.rs @@ -1,8 +1,7 @@ -//! `UnshieldOperation`: validation, settlement, the ledger and relay-fee routing. +//! `UnshieldOperation`: validation, settlement, the ledger and fee crediting. use super::{ - KNOWN_ROOT, canonical_bytes, commit, evm, fund_pool, memo, nullifier, proof, register_asset, - setup_asset, + KNOWN_ROOT, canonical_bytes, fund_pool, memo, nullifier, proof, register_asset, setup_asset, }; use crate::{ mock::{System, Test, acc, new_test_ext}, @@ -368,7 +367,7 @@ fn execute_emits_unshielded_event() { } #[test] -fn execute_accumulates_relay_fee_to_block_author() { +fn execute_credits_the_fee_to_the_block_author() { new_test_ext().execute_with(|| { let asset_id = setup_asset(); let amount = 500u128; @@ -391,9 +390,16 @@ fn execute_accumulates_relay_fee_to_block_author() { }, )); - // MockRelayer block_author returns Some(1); fee should be accumulated there - let pending = crate::mock::mock_pending_fees_get(acc(1), asset_id); - assert_eq!(pending, fee); + // The mock's block author is acc(1). + assert_eq!(crate::mock::mock_pending_fees_get(acc(1), asset_id), fee); + System::assert_has_event( + PalletEvent::FeeCredited { + validator: acc(1), + asset_id, + amount: fee, + } + .into(), + ); }); } @@ -566,7 +572,7 @@ fn is_merkle_root_known_returns_correct_values() { // ── ledger-solvency invariant ──────────────────────────────────────────────── // PoolBalancePerAsset[a] == Currency::free_balance(pool) for the native asset. -// A spend's fee stays in the pool until the relayer claims it, so the tracked +// A spend's fee stays in the pool until the validator claims it, so the tracked // and physical balances always move together. fn pool_physical() -> u128 { @@ -661,7 +667,7 @@ fn unshield_guard_requires_amount_plus_fee() { } /// The full fee lifecycle keeps ledger == physical at every step: -/// shield → unshield(with fee) → claim_relay_fees (fee paid out publicly). +/// shield → unshield(with fee) → claim_fees (fee paid out publicly). #[test] fn fee_lifecycle_preserves_ledger_invariant() { use crate::operations::{ @@ -713,45 +719,19 @@ fn fee_lifecycle_preserves_ledger_invariant() { assert_eq!(crate::mock::mock_pending_fees_get(acc(1), asset_id), 50); // Step 3: claim the 50 fee publicly. Ledger and physical both −50. - assert_ok!(FeeOperation::claim::( - acc(1), - acc(3), - asset_id, - 50u128 - )); + assert_ok!(FeeOperation::claim::(acc(1), asset_id, 50u128)); assert_eq!(tracked(asset_id), 250); assert_eq!(tracked(asset_id), pool_physical()); assert_eq!(crate::mock::mock_pending_fees_get(acc(1), asset_id), 0); }); } -// ── relay-fee attribution (relay commits) ──────────────────────────────────── -// The fee follows the relay commit, never the submitter: a copy of a spend -// submitted by anyone still credits the relayer that committed to it. - -fn block_author() -> sp_runtime::AccountId32 { - acc(1) -} +// ── fee crediting through the extrinsic ────────────────────────────────────── +// The fee goes to the block author; the spend itself names no account. const AMOUNT: u128 = 500; const FEE: u128 = 50; -fn spend_hash(seed: u8, asset_id: u32) -> [u8; 32] { - UnshieldRequest:: { - merkle_root: KNOWN_ROOT, - nullifier: nullifier(seed), - asset_id, - amount: AMOUNT, - recipient: acc(2), - fee: FEE, - change_commitment: [0u8; 32], - change_memo: EncryptedMemo::default(), - circuit_version: 1, - } - .op_hash() - .unwrap() -} - fn unshield_call(seed: u8, asset_id: u32) -> crate::Call { crate::Call::::unshield { proof: proof(), @@ -774,134 +754,42 @@ fn setup_spend() -> u32 { asset_id } -/// The relayer that committed in an earlier block receives the fee. -#[test] -fn unshield_fee_lands_at_the_committed_relayer() { - new_test_ext().execute_with(|| { - let asset_id = setup_spend(); - crate::mock::mock_register_relayer(acc(7), evm(0xAA)); - commit(evm(0xAA), &spend_hash(0x51, asset_id)); - System::set_block_number(2); - - assert_ok!(UnshieldOperation::execute::( - &proof(), - UnshieldRequest { - merkle_root: KNOWN_ROOT, - nullifier: nullifier(0x51), - asset_id, - amount: AMOUNT, - recipient: acc(2), - fee: FEE, - change_commitment: [0u8; 32], - change_memo: EncryptedMemo::default(), - circuit_version: 1, - }, - )); - - assert_eq!(crate::mock::mock_pending_fees_get(acc(7), asset_id), FEE); - assert_eq!( - crate::mock::mock_pending_fees_get(block_author(), asset_id), - 0 - ); - }); -} - -/// Another relayer copies the spend and submits it under its own key: -/// the fee still goes to the relayer that committed. #[test] -fn a_copied_spend_still_credits_the_committed_relayer() { +fn an_unsigned_unshield_credits_the_block_author() { use frame_support::traits::UnfilteredDispatchable; new_test_ext().execute_with(|| { let asset_id = setup_spend(); - crate::mock::mock_register_relayer(acc(7), evm(0xAA)); - crate::mock::mock_register_relayer(acc(8), evm(0xBB)); - commit(evm(0xAA), &spend_hash(0x52, asset_id)); - System::set_block_number(2); + crate::mock::BlockAuthorAccount::set(Some(acc(7))); assert_ok!( - unshield_call(0x52, asset_id) - .dispatch_bypass_filter(crate::RawOrigin::Relayed(evm(0xBB)).into()) + unshield_call(0x51, asset_id) + .dispatch_bypass_filter(crate::mock::RuntimeOrigin::none()) ); assert_eq!(crate::mock::mock_pending_fees_get(acc(7), asset_id), FEE); - assert_eq!(crate::mock::mock_pending_fees_get(acc(8), asset_id), 0); - }); -} - -/// A commit placed in the same block as the spend is ignored, so an -/// author cannot commit after seeing a spend and include both. -#[test] -fn a_same_block_commit_does_not_capture_the_fee() { - use frame_support::traits::UnfilteredDispatchable; - new_test_ext().execute_with(|| { - let asset_id = setup_spend(); - crate::mock::mock_register_relayer(acc(8), evm(0xBB)); - commit(evm(0xBB), &spend_hash(0x53, asset_id)); - - assert_ok!( - unshield_call(0x53, asset_id) - .dispatch_bypass_filter(crate::RawOrigin::Relayed(evm(0xBB)).into()) - ); - - assert_eq!(crate::mock::mock_pending_fees_get(acc(8), asset_id), 0); - assert_eq!( - crate::mock::mock_pending_fees_get(block_author(), asset_id), - FEE - ); + assert_eq!(crate::mock::mock_pending_fees_get(acc(1), asset_id), 0); }); } -/// With no commit, the fee goes to the block author, whoever submits. +/// Signed and Root submissions are refused: a spend is unsigned only. #[test] -fn an_uncommitted_spend_pays_the_block_author() { +fn a_signed_or_root_unshield_is_refused() { use frame_support::traits::UnfilteredDispatchable; new_test_ext().execute_with(|| { let asset_id = setup_spend(); - crate::mock::mock_register_relayer(acc(8), evm(0xBB)); - - assert_ok!( - unshield_call(0x54, asset_id) - .dispatch_bypass_filter(crate::RawOrigin::Relayed(evm(0xBB)).into()) - ); - - assert_eq!(crate::mock::mock_pending_fees_get(acc(8), asset_id), 0); - assert_eq!( - crate::mock::mock_pending_fees_get(block_author(), asset_id), - FEE - ); - }); -} - -/// A commit covers one exact spend: a different spend of the same note -/// (here, another fee) is not credited to it. -#[test] -fn a_commit_does_not_cover_a_different_spend_of_the_same_note() { - new_test_ext().execute_with(|| { - let asset_id = setup_spend(); - crate::mock::mock_register_relayer(acc(7), evm(0xAA)); - commit(evm(0xAA), &spend_hash(0x55, asset_id)); - System::set_block_number(2); - - assert_ok!(UnshieldOperation::execute::( - &proof(), - UnshieldRequest { - merkle_root: KNOWN_ROOT, - nullifier: nullifier(0x55), - asset_id, - amount: AMOUNT - 10, - recipient: acc(2), - fee: FEE + 10, - change_commitment: [0u8; 32], - change_memo: EncryptedMemo::default(), - circuit_version: 1, - }, - )); - - assert_eq!(crate::mock::mock_pending_fees_get(acc(7), asset_id), 0); + for origin in [ + crate::mock::RuntimeOrigin::signed(acc(2)), + crate::mock::RuntimeOrigin::root(), + ] { + assert_noop!( + unshield_call(0x52, asset_id).dispatch_bypass_filter(origin), + sp_runtime::traits::BadOrigin + ); + } }); } -/// A non-zero fee with no resolvable recipient (no relayer, no block author) +/// A non-zero fee with no block author /// errors instead of stranding the fee tokens in the pool. #[test] fn unshield_nonzero_fee_without_recipient_errors() { diff --git a/frame/shielded-pool/src/validate_unsigned/codes.rs b/frame/shielded-pool/src/validate_unsigned/codes.rs index 9596341c..5aed573a 100644 --- a/frame/shielded-pool/src/validate_unsigned/codes.rs +++ b/frame/shielded-pool/src/validate_unsigned/codes.rs @@ -5,9 +5,9 @@ //! validators from drifting apart and makes a rejection diagnosable from a log //! line alone. //! -//! Codes are part of the observable interface: a wallet or relayer may branch on -//! them, so **never reuse a number for a different meaning** — retire it and add -//! a new one instead. +//! Codes are part of the observable interface: a wallet may branch on them, so +//! **never reuse a number for a different meaning** — retire it and add a new one +//! instead. use sp_runtime::transaction_validity::InvalidTransaction; diff --git a/frame/shielded-pool/src/validate_unsigned/tests.rs b/frame/shielded-pool/src/validate_unsigned/tests.rs index 16d361e4..f9ca72c8 100644 --- a/frame/shielded-pool/src/validate_unsigned/tests.rs +++ b/frame/shielded-pool/src/validate_unsigned/tests.rs @@ -100,7 +100,7 @@ fn validate_unshield( super::validate_unshield(Some(&proof()), &req) } -/// Rejection codes reach wallets and relayers as bare `Custom(N)`, so they are +/// Rejection codes reach wallets as bare `Custom(N)`, so they are /// part of the observable interface: pin them here, and retire a number rather /// than reuse it for a new meaning. #[test] @@ -341,16 +341,16 @@ fn unshield_exact_pool_balance_accepted() { // ── fee floor (anti-spam) ──────────────────────────────────────────────────── // // Both validate_private_transfer and validate_unshield enforce the minimum -// relay fee set by T::Relayer::min_relay_fee(). -// The mock returns 0 by default; mock_set_min_relay_fee lets individual +// spend fee set by T::MinSpendFee. +// The mock returns 0 by default; mock_set_min_spend_fee lets individual // tests raise the floor to exercise the Payment rejection path. #[test] fn private_transfer_fee_below_minimum_rejected() { new_test_ext().execute_with(|| { - crate::mock::mock_set_min_relay_fee(100); + crate::mock::mock_set_min_spend_fee(100); MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); - // fee=50 < min_relay_fee=100 → InvalidTransaction::Payment + // fee=50 < MinSpendFee=100 → InvalidTransaction::Payment let result = validate_private_transfer(&KNOWN_ROOT, &nullifiers_of(&[0x01]), &50u128, 1); assert!(result.is_err(), "fee below minimum must be rejected"); assert_eq!( @@ -365,9 +365,9 @@ fn private_transfer_fee_below_minimum_rejected() { #[test] fn private_transfer_fee_at_minimum_accepted() { new_test_ext().execute_with(|| { - crate::mock::mock_set_min_relay_fee(100); + crate::mock::mock_set_min_spend_fee(100); MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); - // fee == min_relay_fee → accept + // fee == MinSpendFee → accept let result = validate_private_transfer(&KNOWN_ROOT, &nullifiers_of(&[0x01]), &100u128, 1); assert!(result.is_ok(), "fee equal to minimum must be accepted"); }); @@ -376,10 +376,10 @@ fn private_transfer_fee_at_minimum_accepted() { #[test] fn unshield_fee_below_minimum_rejected() { new_test_ext().execute_with(|| { - crate::mock::mock_set_min_relay_fee(200); + crate::mock::mock_set_min_spend_fee(200); MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); PoolBalanceRepository::set_asset_balance::(0, 10_000u128); - // fee=50 < min_relay_fee=200 → InvalidTransaction::Payment + // fee=50 < MinSpendFee=200 → InvalidTransaction::Payment let result = validate_unshield(&KNOWN_ROOT, &nullifier(0x60), &0u32, &100u128, &50u128, 1); assert!(result.is_err(), "fee below minimum must be rejected"); assert_eq!( @@ -394,10 +394,10 @@ fn unshield_fee_below_minimum_rejected() { #[test] fn unshield_fee_at_minimum_accepted() { new_test_ext().execute_with(|| { - crate::mock::mock_set_min_relay_fee(200); + crate::mock::mock_set_min_spend_fee(200); MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); PoolBalanceRepository::set_asset_balance::(0, 10_000u128); - // fee == min_relay_fee → accept (pool has enough for amount+fee) + // fee == MinSpendFee → accept (pool has enough for amount+fee) let result = validate_unshield(&KNOWN_ROOT, &nullifier(0x60), &0u32, &100u128, &200u128, 1); assert!(result.is_ok(), "fee equal to minimum must be accepted"); }); diff --git a/frame/shielded-pool/src/validate_unsigned/transfer.rs b/frame/shielded-pool/src/validate_unsigned/transfer.rs index a3c1c52b..11b99146 100644 --- a/frame/shielded-pool/src/validate_unsigned/transfer.rs +++ b/frame/shielded-pool/src/validate_unsigned/transfer.rs @@ -10,7 +10,7 @@ //! Step 2 IS the dispatchable's validation, so admission can never reject less //! than execution. Its checks run cheapest first — shape, fee floor, root, //! nullifiers, memos, then the rest — and each failure maps to the rejection a -//! wallet or relayer can act on (see [`admission_error`]). +//! wallet can act on (see [`admission_error`]). use super::{ TX_LONGEVITY, diff --git a/frame/shielded-pool/src/weights.rs b/frame/shielded-pool/src/weights.rs index 1ccb0df0..f84cd5ef 100644 --- a/frame/shielded-pool/src/weights.rs +++ b/frame/shielded-pool/src/weights.rs @@ -6,6 +6,10 @@ //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `ubuntu-32gb-fsn1-1`, CPU: `AMD EPYC-Genoa Processor` //! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 +//! +//! Spec 19, by hand until re-benchmarked: `private_transfer`, `unshield` and +//! `claim_fees` drop the removed relayer storage and add the block-author lookup; +//! their time and proof-size terms still include the relayer scan, so they over-estimate. // Executed Command: // ./target/release/orbinum-node @@ -48,8 +52,7 @@ pub trait WeightInfo { fn register_asset() -> Weight; fn verify_asset() -> Weight; fn unverify_asset() -> Weight; - fn commit_relay(n: u32, ) -> Weight; - fn claim_relay_fees() -> Weight; + fn claim_fees() -> Weight; fn prune_sealed_nodes(n: u32, ) -> Weight; } @@ -160,8 +163,6 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2475).saturating_mul(n.into())) } - /// Storage: `Relayer::MinRelayFee` (r:1 w:0) - /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `Measured`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:3 w:2) @@ -194,12 +195,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::Events` (r:1 w:1) /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:33 w:0) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:32 w:32) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) - /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `ShieldedPool::PendingFees` (r:1 w:1) + /// Proof: `ShieldedPool::PendingFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `Authorship::Author` (r:1 w:1) + /// Storage: `System::Digest` (r:1 w:0) + /// Storage: `Aura::Authorities` (r:1 w:0) + /// Storage: `Session::Validators` (r:1 w:0) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `Measured`) /// Storage: `ShieldedPool::CommitmentMemos` (r:0 w:2) @@ -212,11 +213,9 @@ impl WeightInfo for SubstrateWeight { // Estimated: `88323` // Minimum execution time: 1_789_349_000 picoseconds. Weight::from_parts(1_838_779_000, 88323) - .saturating_add(T::DbWeight::get().reads(87_u64)) - .saturating_add(T::DbWeight::get().writes(73_u64)) + .saturating_add(T::DbWeight::get().reads(25_u64)) + .saturating_add(T::DbWeight::get().writes(42_u64)) } - /// Storage: `Relayer::MinRelayFee` (r:1 w:0) - /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:0) /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `Measured`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:0) @@ -241,20 +240,20 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `ShieldedPool::TotalNullifiersSpent` (r:1 w:1) /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:33 w:0) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:32 w:32) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) - /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `ShieldedPool::PendingFees` (r:1 w:1) + /// Proof: `ShieldedPool::PendingFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `Authorship::Author` (r:1 w:1) + /// Storage: `System::Digest` (r:1 w:0) + /// Storage: `Aura::Authorities` (r:1 w:0) + /// Storage: `Session::Validators` (r:1 w:0) fn unshield() -> Weight { // Proof Size summary in bytes: // Measured: `5372` // Estimated: `88037` // Minimum execution time: 369_480_000 picoseconds. Weight::from_parts(389_860_000, 88037) - .saturating_add(T::DbWeight::get().reads(80_u64)) - .saturating_add(T::DbWeight::get().writes(40_u64)) + .saturating_add(T::DbWeight::get().reads(18_u64)) + .saturating_add(T::DbWeight::get().writes(9_u64)) } /// Storage: `ShieldedPool::NextAssetId` (r:1 w:1) /// Proof: `ShieldedPool::NextAssetId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) @@ -315,43 +314,12 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } - /// Storage: `Relayer::RelayerRegistry` (r:1 w:0) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:64 w:64) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// Storage: `Relayer::CommitsByRelayer` (r:1 w:1) - /// Proof: `Relayer::CommitsByRelayer` (`max_values`: None, `max_size`: Some(2110), added: 4585, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// The range of component `n` is `[1, 64]`. - fn commit_relay(n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `223` - // Estimated: `3688 + n * (2475 ±0)` - // Minimum execution time: 18_830_000 picoseconds. - Weight::from_parts(9_672_340, 3688) - // Standard Error: 2_426 - .saturating_add(Weight::from_parts(4_300_644, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(6_u64)) - .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(T::DbWeight::get().writes(3_u64)) - .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(n.into()))) - .saturating_add(Weight::from_parts(0, 2475).saturating_mul(n.into())) - } - /// Storage: `Relayer::RelayerByAccount` (r:1 w:0) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `Measured`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `Measured`) - /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) - /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `ShieldedPool::PendingFees` (r:1 w:1) + /// Proof: `ShieldedPool::PendingFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) /// Storage: `System::Number` (r:1 w:0) /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::ExecutionPhase` (r:1 w:0) @@ -364,13 +332,13 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `Measured`) - fn claim_relay_fees() -> Weight { + fn claim_fees() -> Weight { // Proof Size summary in bytes: // Measured: `1050` // Estimated: `6990` // Minimum execution time: 80_530_000 picoseconds. Weight::from_parts(84_920_000, 6990) - .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().reads(10_u64)) .saturating_add(T::DbWeight::get().writes(6_u64)) } /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:0) @@ -504,8 +472,6 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2475).saturating_mul(n.into())) } - /// Storage: `Relayer::MinRelayFee` (r:1 w:0) - /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `Measured`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:3 w:2) @@ -538,12 +504,12 @@ impl WeightInfo for () { /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::Events` (r:1 w:1) /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:33 w:0) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:32 w:32) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) - /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `ShieldedPool::PendingFees` (r:1 w:1) + /// Proof: `ShieldedPool::PendingFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `Authorship::Author` (r:1 w:1) + /// Storage: `System::Digest` (r:1 w:0) + /// Storage: `Aura::Authorities` (r:1 w:0) + /// Storage: `Session::Validators` (r:1 w:0) /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `Measured`) /// Storage: `ShieldedPool::CommitmentMemos` (r:0 w:2) @@ -556,11 +522,9 @@ impl WeightInfo for () { // Estimated: `88323` // Minimum execution time: 1_789_349_000 picoseconds. Weight::from_parts(1_838_779_000, 88323) - .saturating_add(RocksDbWeight::get().reads(87_u64)) - .saturating_add(RocksDbWeight::get().writes(73_u64)) + .saturating_add(RocksDbWeight::get().reads(25_u64)) + .saturating_add(RocksDbWeight::get().writes(42_u64)) } - /// Storage: `Relayer::MinRelayFee` (r:1 w:0) - /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:0) /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `Measured`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:0) @@ -585,20 +549,20 @@ impl WeightInfo for () { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `ShieldedPool::TotalNullifiersSpent` (r:1 w:1) /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:33 w:0) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:32 w:32) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) - /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `ShieldedPool::PendingFees` (r:1 w:1) + /// Proof: `ShieldedPool::PendingFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `Authorship::Author` (r:1 w:1) + /// Storage: `System::Digest` (r:1 w:0) + /// Storage: `Aura::Authorities` (r:1 w:0) + /// Storage: `Session::Validators` (r:1 w:0) fn unshield() -> Weight { // Proof Size summary in bytes: // Measured: `5372` // Estimated: `88037` // Minimum execution time: 369_480_000 picoseconds. Weight::from_parts(389_860_000, 88037) - .saturating_add(RocksDbWeight::get().reads(80_u64)) - .saturating_add(RocksDbWeight::get().writes(40_u64)) + .saturating_add(RocksDbWeight::get().reads(18_u64)) + .saturating_add(RocksDbWeight::get().writes(9_u64)) } /// Storage: `ShieldedPool::NextAssetId` (r:1 w:1) /// Proof: `ShieldedPool::NextAssetId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) @@ -659,43 +623,12 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } - /// Storage: `Relayer::RelayerRegistry` (r:1 w:0) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:64 w:64) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// Storage: `Relayer::CommitsByRelayer` (r:1 w:1) - /// Proof: `Relayer::CommitsByRelayer` (`max_values`: None, `max_size`: Some(2110), added: 4585, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// The range of component `n` is `[1, 64]`. - fn commit_relay(n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `223` - // Estimated: `3688 + n * (2475 ±0)` - // Minimum execution time: 18_830_000 picoseconds. - Weight::from_parts(9_672_340, 3688) - // Standard Error: 2_426 - .saturating_add(Weight::from_parts(4_300_644, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(6_u64)) - .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(RocksDbWeight::get().writes(3_u64)) - .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(n.into()))) - .saturating_add(Weight::from_parts(0, 2475).saturating_mul(n.into())) - } - /// Storage: `Relayer::RelayerByAccount` (r:1 w:0) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `Measured`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `Measured`) - /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) - /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) + /// Storage: `ShieldedPool::PendingFees` (r:1 w:1) + /// Proof: `ShieldedPool::PendingFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `Measured`) /// Storage: `System::Number` (r:1 w:0) /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::ExecutionPhase` (r:1 w:0) @@ -708,13 +641,13 @@ impl WeightInfo for () { /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `Measured`) - fn claim_relay_fees() -> Weight { + fn claim_fees() -> Weight { // Proof Size summary in bytes: // Measured: `1050` // Estimated: `6990` // Minimum execution time: 80_530_000 picoseconds. Weight::from_parts(84_920_000, 6990) - .saturating_add(RocksDbWeight::get().reads(11_u64)) + .saturating_add(RocksDbWeight::get().reads(10_u64)) .saturating_add(RocksDbWeight::get().writes(6_u64)) } /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:0) From 888b49bebbf0dffba440d28a4bf190d08fe054c0 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Tue, 13 Oct 2026 13:29:07 -0300 Subject: [PATCH 2/2] feat(runtime)!: remove pallet-relayer and the node relay RPC; spend fees and EVM tips to the block author's validator account, migrations for fees, stranded tips and relayer storage (spec 19, tx 7, node 0.5.0) --- Cargo.lock | 71 +- Cargo.toml | 5 - README.md | 2 +- client/rpc-v2/src/README.md | 2 +- client/rpc/CHANGELOG.md | 8 +- client/rpc/Cargo.toml | 7 +- client/rpc/src/lib.rs | 4 +- client/rpc/src/relay/config.rs | 90 --- client/rpc/src/relay/guard.rs | 197 ------ client/rpc/src/relay/mod.rs | 43 -- client/rpc/src/relay/operations.rs | 137 ---- client/rpc/src/relay/rpc/commit.rs | 122 ---- client/rpc/src/relay/rpc/mod.rs | 422 ------------ client/rpc/src/relay/tests/adversarial.rs | 400 ------------ client/rpc/src/relay/tests/guard.rs | 243 ------- client/rpc/src/relay/tests/mod.rs | 11 - client/rpc/src/relay/tests/validation.rs | 432 ------------- client/rpc/src/relay/types.rs | 43 -- client/rpc/src/relay/validation.rs | 106 --- client/rpc/src/signer.rs | 226 ------- docs/BENCHMARKING_STEPS.md | 2 +- frame/ismp-messaging/src/lib.rs | 2 +- frame/relayer/CHANGELOG.md | 327 ---------- frame/relayer/Cargo.toml | 59 -- frame/relayer/README.md | 257 -------- frame/relayer/rpc/Cargo.toml | 16 - frame/relayer/rpc/README.md | 85 --- frame/relayer/rpc/src/lib.rs | 164 ----- frame/relayer/runtime-api/Cargo.toml | 30 - frame/relayer/runtime-api/README.md | 48 -- frame/relayer/runtime-api/src/lib.rs | 147 ----- frame/relayer/src/benchmarking.rs | 133 ---- frame/relayer/src/evm_proof.rs | 352 ---------- frame/relayer/src/lib.rs | 610 ------------------ frame/relayer/src/mock.rs | 133 ---- frame/relayer/src/test_signing.rs | 49 -- frame/relayer/src/tests/cleanup_tests.rs | 109 ---- frame/relayer/src/tests/commit_tests.rs | 407 ------------ frame/relayer/src/tests/config_tests.rs | 174 ----- .../relayer/src/tests/dispatch_info_tests.rs | 59 -- frame/relayer/src/tests/fees_tests.rs | 161 ----- frame/relayer/src/tests/mod.rs | 18 - .../src/tests/ownership_proof_tests.rs | 176 ----- frame/relayer/src/tests/registry_tests.rs | 288 --------- frame/relayer/src/traits.rs | 96 --- frame/relayer/src/weights.rs | 278 -------- frame/validator-set/CHANGELOG.md | 8 + frame/validator-set/Cargo.toml | 2 +- frame/validator-set/src/lib.rs | 4 +- frame/validator-set/src/tests/mod.rs | 7 +- frame/validator-set/src/traits.rs | 12 +- frame/validator-set/src/weights.rs | 20 +- scripts/benchmarks/run_benchmarks.sh | 9 +- template/node/Cargo.toml | 8 +- template/node/src/evm_relay_key.rs | 314 --------- template/node/src/main.rs | 1 - template/node/src/rpc/eth.rs | 30 +- template/node/src/rpc/mod.rs | 10 - template/node/src/rpc/relayer_author.rs | 137 ---- template/node/src/service.rs | 9 - template/runtime/Cargo.toml | 8 +- template/runtime/RUNTIME_VERSIONS.md | 69 +- template/runtime/src/configs/consensus.rs | 23 +- template/runtime/src/configs/evm.rs | 58 +- template/runtime/src/configs/ismp/mod.rs | 9 +- template/runtime/src/configs/privacy.rs | 57 +- template/runtime/src/lib.rs | 93 +-- template/runtime/src/migrations.rs | 241 +++++-- template/runtime/src/precompiles.rs | 3 +- template/runtime/src/runtime_tests.rs | 100 ++- ts-tests/node/no-bond-no-min-shield.test.cjs | 25 - ts-tests/tests/test-relay-rpc.ts | 259 -------- 72 files changed, 456 insertions(+), 7811 deletions(-) delete mode 100644 client/rpc/src/relay/config.rs delete mode 100644 client/rpc/src/relay/guard.rs delete mode 100644 client/rpc/src/relay/mod.rs delete mode 100644 client/rpc/src/relay/operations.rs delete mode 100644 client/rpc/src/relay/rpc/commit.rs delete mode 100644 client/rpc/src/relay/rpc/mod.rs delete mode 100644 client/rpc/src/relay/tests/adversarial.rs delete mode 100644 client/rpc/src/relay/tests/guard.rs delete mode 100644 client/rpc/src/relay/tests/mod.rs delete mode 100644 client/rpc/src/relay/tests/validation.rs delete mode 100644 client/rpc/src/relay/types.rs delete mode 100644 client/rpc/src/relay/validation.rs delete mode 100644 frame/relayer/CHANGELOG.md delete mode 100644 frame/relayer/Cargo.toml delete mode 100644 frame/relayer/README.md delete mode 100644 frame/relayer/rpc/Cargo.toml delete mode 100644 frame/relayer/rpc/README.md delete mode 100644 frame/relayer/rpc/src/lib.rs delete mode 100644 frame/relayer/runtime-api/Cargo.toml delete mode 100644 frame/relayer/runtime-api/README.md delete mode 100644 frame/relayer/runtime-api/src/lib.rs delete mode 100644 frame/relayer/src/benchmarking.rs delete mode 100644 frame/relayer/src/evm_proof.rs delete mode 100644 frame/relayer/src/lib.rs delete mode 100644 frame/relayer/src/mock.rs delete mode 100644 frame/relayer/src/test_signing.rs delete mode 100644 frame/relayer/src/tests/cleanup_tests.rs delete mode 100644 frame/relayer/src/tests/commit_tests.rs delete mode 100644 frame/relayer/src/tests/config_tests.rs delete mode 100644 frame/relayer/src/tests/dispatch_info_tests.rs delete mode 100644 frame/relayer/src/tests/fees_tests.rs delete mode 100644 frame/relayer/src/tests/mod.rs delete mode 100644 frame/relayer/src/tests/ownership_proof_tests.rs delete mode 100644 frame/relayer/src/tests/registry_tests.rs delete mode 100644 frame/relayer/src/traits.rs delete mode 100644 frame/relayer/src/weights.rs delete mode 100644 template/node/src/evm_relay_key.rs delete mode 100644 template/node/src/rpc/relayer_author.rs delete mode 100644 ts-tests/tests/test-relay-rpc.ts diff --git a/Cargo.lock b/Cargo.lock index b5f26e48..98163772 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5577,9 +5577,6 @@ dependencies = [ "libsecp256k1", "log", "pallet-evm", - "pallet-evm-precompile-shielded-pool", - "pallet-relayer-runtime-api", - "pallet-shielded-pool-runtime-api", "parity-scale-codec", "prometheus", "rand 0.9.5", @@ -9911,7 +9908,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbinum-node" -version = "0.4.0" +version = "0.5.0" dependencies = [ "async-trait", "clap", @@ -9933,18 +9930,13 @@ dependencies = [ "frame-system", "frame-system-rpc-runtime-api", "futures", - "hex", "jsonrpsee", - "libsecp256k1", "log", "orbinum-runtime", "orbinum-zk-core", "orbinum-zk-verifier", "pallet-ismp-rpc", "pallet-ismp-runtime-api", - "pallet-relayer", - "pallet-relayer-rpc", - "pallet-relayer-runtime-api", "pallet-shielded-pool-runtime-api", "pallet-transaction-payment", "pallet-transaction-payment-rpc", @@ -9981,7 +9973,6 @@ dependencies = [ "sp-core", "sp-inherents", "sp-io", - "sp-keystore", "sp-offchain", "sp-runtime", "sp-session", @@ -9996,7 +9987,7 @@ dependencies = [ [[package]] name = "orbinum-runtime" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "ethereum", @@ -10037,8 +10028,6 @@ dependencies = [ "pallet-ismp", "pallet-ismp-messaging", "pallet-ismp-runtime-api", - "pallet-relayer", - "pallet-relayer-runtime-api", "pallet-session", "pallet-shielded-pool", "pallet-shielded-pool-runtime-api", @@ -11343,14 +11332,13 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-shielded-pool" -version = "0.9.0" +version = "0.10.0" dependencies = [ "fp-evm", "frame-support", "frame-system", "pallet-balances", "pallet-evm", - "pallet-relayer", "pallet-shielded-pool", "pallet-timestamp", "pallet-zk-verifier", @@ -12120,49 +12108,6 @@ dependencies = [ "sp-runtime", ] -[[package]] -name = "pallet-relayer" -version = "0.6.0" -dependencies = [ - "frame-benchmarking", - "frame-support", - "frame-system", - "libsecp256k1", - "pallet-validator-set", - "parity-scale-codec", - "scale-info", - "sp-core", - "sp-io", - "sp-runtime", - "sp-std", -] - -[[package]] -name = "pallet-relayer-rpc" -version = "0.1.0" -dependencies = [ - "hex", - "jsonrpsee", - "pallet-relayer-runtime-api", - "serde", - "sp-api", - "sp-blockchain", - "sp-core", - "sp-runtime", -] - -[[package]] -name = "pallet-relayer-runtime-api" -version = "0.2.0" -dependencies = [ - "parity-scale-codec", - "scale-info", - "sp-api", - "sp-core", - "sp-runtime", - "sp-std", -] - [[package]] name = "pallet-remark" version = "49.0.0" @@ -12412,7 +12357,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.24.0" +version = "0.25.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", @@ -12423,7 +12368,6 @@ dependencies = [ "orbinum-zk-core", "orbinum-zk-verifier", "pallet-balances", - "pallet-relayer", "pallet-zk-verifier", "parity-scale-codec", "scale-info", @@ -12435,15 +12379,12 @@ dependencies = [ [[package]] name = "pallet-shielded-pool-runtime-api" -version = "0.3.0" +version = "0.4.0" dependencies = [ "pallet-shielded-pool", - "parity-scale-codec", - "scale-info", "sp-api", "sp-core", "sp-runtime", - "sp-std", ] [[package]] @@ -12802,7 +12743,7 @@ dependencies = [ [[package]] name = "pallet-validator-set" -version = "0.4.0" +version = "0.4.1" dependencies = [ "async-trait", "frame-benchmarking", diff --git a/Cargo.toml b/Cargo.toml index ebe495de..049c220b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,6 @@ members = [ "frame/evm-polkavm/uapi", "frame/hotfix-sufficients", "frame/ismp-messaging", - "frame/relayer/runtime-api", - "frame/relayer/rpc", "frame/zk-verifier/runtime-api", "frame/zk-verifier/rpc", "client/api", @@ -267,9 +265,6 @@ pallet-evm-precompile-shielded-pool = { path = "frame/evm/precompile/shielded-po pallet-evm-precompile-simple = { path = "frame/evm/precompile/simple", default-features = false } pallet-evm-test-vector-support = { path = "frame/evm/test-vector-support" } pallet-hotfix-sufficients = { path = "frame/hotfix-sufficients", default-features = false } -pallet-relayer = { path = "frame/relayer", default-features = false } -pallet-relayer-rpc = { path = "frame/relayer/rpc" } -pallet-relayer-runtime-api = { path = "frame/relayer/runtime-api", default-features = false } pallet-shielded-pool = { path = "frame/shielded-pool", default-features = false } pallet-shielded-pool-runtime-api = { path = "frame/shielded-pool/runtime-api", default-features = false } pallet-validator-set = { path = "frame/validator-set", default-features = false } diff --git a/README.md b/README.md index ad977cbf..bf081b7f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Orbinum combines the transparency benefits of blockchain technology with advance Orbinum is built using Substrate's FRAME framework and implements Clean Architecture principles across all components: -- **Pallets**: Modular runtime components (`pallet-shielded-pool`, `pallet-zk-verifier`, `pallet-relayer`) +- **Pallets**: Modular runtime components (`pallet-shielded-pool`, `pallet-zk-verifier`, `pallet-validator-set`) - **Primitives**: Core cryptographic libraries (`zk-core`, `zk-verifier`) - **Client**: RPC layer and blockchain infrastructure - **Circuits**: Circom zero-knowledge circuits (`transfer`, `unshield`) diff --git a/client/rpc-v2/src/README.md b/client/rpc-v2/src/README.md index d44722fc..eb7b8216 100644 --- a/client/rpc-v2/src/README.md +++ b/client/rpc-v2/src/README.md @@ -45,7 +45,7 @@ io.merge(ChainRpc::new(client.clone()).into_rpc())?; Returns `true` if the given SS58-encoded account is an active Aura block author. -Reads `pallet_aura::Authorities` directly from storage — no runtime API call is made. Intended for UIs that need to gate actions (e.g. relayer registration) on validator eligibility without waiting for an extrinsic to fail. +Reads `pallet_aura::Authorities` directly from storage — no runtime API call is made. Intended for UIs that need to gate actions on validator eligibility without waiting for an extrinsic to fail. **Parameters** diff --git a/client/rpc/CHANGELOG.md b/client/rpc/CHANGELOG.md index a45ea10d..f8e63ea9 100644 --- a/client/rpc/CHANGELOG.md +++ b/client/rpc/CHANGELOG.md @@ -2,13 +2,7 @@ ## Unreleased -* Relay: `orbinum_relayShieldedCall` records a relay commit (`commitRelay`, batched) and waits for it to land before submitting the spend, so the fee is credited to this relayer. Skipped on runtimes without `ShieldedPoolRuntimeApi` v3 and `RelayerRuntimeApi` v2. -* Relay: an unregistered relay address fails fast instead of timing out, and a failed `commitRelay` submission is reported to every caller in its batch. -* Relay: one relay per spend — a request whose nullifiers are already in flight is refused, so N copies no longer cost the relayer N× gas. The dry-run is repeated after the commit lands; a commit already on-chain is not sent again; a spend another relayer's commit outranks — earlier block, or same block and lower hash, checked before and after our commit lands — is refused (that commit takes the fee); commit batches are sent from a task a disconnecting caller cannot cancel, one chunk per block, each only after the previous one is recorded, since the quota applies to the block a chunk lands in. -* Relay: a spend's claim holds while its request runs and lapses only once submitted; an error or a disconnecting caller releases it. Calldata is dry-run before claiming, so junk naming someone else's nullifier cannot block their relay. Waiting for a commit batch times out. -* Relay: the nonce recovers from a submitted transaction that never lands (the confirmed nonce is reused after `NONCE_STALL_BLOCKS`), and the chain is read under the submit lock. -* Relay: `orbinum_relayerStatus.enabled` requires registration (on runtimes with relay commits) and a balance for the worst case of a commit plus a spend. -* Relay (internal): commit batching lives in `relay/rpc/commit.rs` and the request's nullifier claim (`ClaimGuard`) in `relay/guard.rs`. +* Removed the node-native EVM relay: `orbinum_relayShieldedCall`, `orbinum_relayerStatus` and `EthValidatorSigner` are gone. Private spends are submitted unsigned. * `eth_getFilterChanges`: the first poll of a log filter scans its range, and every later poll skips journal logs the scan covered, so a block journaled late is not returned twice; a reorg entry is skipped on the first poll (the scan read the new chain) and passes through after it. A result over `max_past_logs`, from the scan or the journal, drops the filter instead of failing the same way on every poll. * Fix `estimate_gas`: ensure that provided gas limit it never larger than current block's gas limit * `EthPubSubApi::new` takes an additional `overrides` parameter. diff --git a/client/rpc/Cargo.toml b/client/rpc/Cargo.toml index 1bfa43cc..84554375 100644 --- a/client/rpc/Cargo.toml +++ b/client/rpc/Cargo.toml @@ -24,7 +24,7 @@ rand = "0.9" rlp = { workspace = true } scale-codec = { workspace = true } schnellru = "0.2.4" -serde = { workspace = true, features = ["derive"] } +serde = { workspace = true, optional = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["sync", "time"] } @@ -61,9 +61,6 @@ fp-evm = { workspace = true, features = ["default"] } fp-rpc = { workspace = true, features = ["default"] } fp-storage = { workspace = true, features = ["default"] } pallet-evm = { workspace = true, features = ["default"] } -pallet-evm-precompile-shielded-pool = { workspace = true, features = ["std"] } -pallet-relayer-runtime-api = { workspace = true, features = ["std"] } -pallet-shielded-pool-runtime-api = { workspace = true, features = ["std"] } [dev-dependencies] # Substrate @@ -80,5 +77,5 @@ rocksdb = [ "sc-service/rocksdb", "fc-mapping-sync/rocksdb", ] -txpool = ["fc-rpc-core/txpool"] +txpool = ["fc-rpc-core/txpool", "serde"] rpc-binary-search-estimate = [] diff --git a/client/rpc/src/lib.rs b/client/rpc/src/lib.rs index dcdd888a..062edd8c 100644 --- a/client/rpc/src/lib.rs +++ b/client/rpc/src/lib.rs @@ -32,7 +32,6 @@ mod eth; mod eth_pubsub; mod logs_journal; mod net; -pub mod relay; mod signer; #[cfg(feature = "txpool")] mod txpool; @@ -47,8 +46,7 @@ pub use self::{ eth_pubsub::{EthPubSub, EthereumSubIdProvider}, logs_journal::{LogsJournal, LogsJournalConfig, LogsJournalEntry, LogsJournalError}, net::Net, - relay::{OrbinumRelay, OrbinumRelayApiServer, RelayerStatus}, - signer::{EthDevSigner, EthSigner, EthValidatorSigner}, + signer::{EthDevSigner, EthSigner}, web3::Web3, }; pub use ethereum::TransactionV3 as EthereumTransaction; diff --git a/client/rpc/src/relay/config.rs b/client/rpc/src/relay/config.rs deleted file mode 100644 index f52e5a83..00000000 --- a/client/rpc/src/relay/config.rs +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Relay constants, grouped by what they govern. -//! -//! Three roles are kept apart because they answer to different authorities: the -//! admission limits are the relay's own policy, the transaction parameters are -//! what it signs and pays for, and the fallbacks are last-resort copies of state -//! that normally lives on-chain. -//! -//! `RELAY_GAS_LIMIT` deliberately spans two of them — it bounds the transaction -//! the relay signs and, through the 2× gas floor, the fee it demands in return. - -use super::operations::{SELECTOR_PRIVATE_TRANSFER, SELECTOR_UNSHIELD}; - -// --------------------------------------------------------------------------- -// Target -// --------------------------------------------------------------------------- - -/// ShieldedPool precompile: `0x0000000000000000000000000000000000000801`. -/// -/// The only address the relay will call. Anything else is rejected before the -/// selector is even read. -pub(crate) const SHIELDED_POOL_PRECOMPILE: [u8; 20] = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x08, 0x01, -]; - -// --------------------------------------------------------------------------- -// Admission limits -// --------------------------------------------------------------------------- - -/// Maximum calldata size accepted by the relay (32 KB). -/// -/// A realistic shielded-pool calldata is under 1 KB: a 256 B Groth16 proof, the -/// ABI head and two 180 B memos. The cap prevents an attacker from passing the -/// selector and fee checks with megabytes of data the relayer would then pay -/// calldata gas for. -pub(crate) const MAX_CALLDATA_BYTES: usize = 32_768; - -// --------------------------------------------------------------------------- -// Transaction parameters -// --------------------------------------------------------------------------- - -/// Maximum fee per gas paid by the relay tx (10 gwei). -pub(crate) const MAX_FEE_PER_GAS_WEI: u64 = 10_000_000_000; - -/// Gas limit used for relay transactions, and the basis of the 2× gas floor in -/// [`super::validation::compute_effective_min_fee`]. -pub(crate) const RELAY_GAS_LIMIT: u64 = 2_000_000; - -// --------------------------------------------------------------------------- -// Relay commits -// --------------------------------------------------------------------------- - -/// How long a relay call waits for others before sending the queued commits, -/// so calls arriving together share one `commitRelay` transaction. -pub(crate) const COMMIT_BATCH_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); - -/// How often the best block is checked for the commit. -pub(crate) const COMMIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500); - -/// Give up on a commit after this long. Well under `CommitTtl` (20 blocks, -/// ~2 min), so a late commit never leaves the spend with an expired one. -pub(crate) const COMMIT_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); - -/// How long a submitted spend's nullifiers stay claimed: past it, a spend that -/// never landed may be relayed again. -pub(crate) const IN_FLIGHT_TTL: std::time::Duration = std::time::Duration::from_secs(120); - -/// Blocks the confirmed nonce may stay behind the relay's own before the relay -/// treats the gap as a dropped transaction and signs from the confirmed nonce. -pub(crate) const NONCE_STALL_BLOCKS: u64 = 10; - -// --------------------------------------------------------------------------- -// Runtime API fallbacks -// --------------------------------------------------------------------------- - -/// Last-resort minimum fee, used ONLY when the `relay_config()` Runtime API call -/// fails entirely — that is, on a node running a pre-API runtime. -/// -/// The authoritative value lives in `pallet-relayer::MinRelayFee` and is -/// governance-modifiable via `set_min_relay_fee`. This matches -/// `pallet-relayer::DefaultMinRelayFee` (0.001 ORB) so all three sources agree -/// out of the box. -pub(crate) const MIN_RELAY_FEE_FALLBACK: u128 = 1_000_000_000_000_000; // 0.001 ORB in planck - -/// Selector whitelist used on the same fallback path. -/// -/// Built from the operation constants — themselves re-exported from the -/// precompile — so this list cannot drift from what the decoder accepts. -pub(crate) const SELECTORS_FALLBACK: [[u8; 4]; 2] = [SELECTOR_UNSHIELD, SELECTOR_PRIVATE_TRANSFER]; diff --git a/client/rpc/src/relay/guard.rs b/client/rpc/src/relay/guard.rs deleted file mode 100644 index 4d65dd7d..00000000 --- a/client/rpc/src/relay/guard.rs +++ /dev/null @@ -1,197 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Bookkeeping that keeps the relay from paying for a spend twice, or for one -//! another relayer is paid for. -//! -//! - [`InFlight`] — one relay per spend. The dry-run reads the best block, which -//! does not see the pool: until a spend is included, every copy of it passes. -//! [`ClaimGuard`] holds a request's claim and releases it unless submitted. -//! - [`rival_wins`] — the chain's rule for which relay commit takes the fee. -//! - [`NonceTracker`] — nonces for several submissions within one block, and -//! recovery when a submitted transaction never lands. - -use std::{ - collections::HashMap, - time::{Duration, Instant}, -}; - -use ethereum_types::U256; -use pallet_evm_precompile_shielded_pool::MAX_SPEND_INPUTS; - -use super::{ - config::IN_FLIGHT_TTL, - operations::{SELECTOR_PRIVATE_TRANSFER, SELECTOR_UNSHIELD}, -}; - -/// The nullifiers a relayable spend consumes, read from its calldata; `None` -/// when the calldata is not a well-formed spend. Zero nullifiers (dummy inputs) -/// are left out: they identify nothing. -/// -/// `unshield` holds its nullifier in head slot 2; `privateTransfer` holds the -/// offset of its `bytes32[]` there. -pub(crate) fn spend_nullifiers(calldata: &[u8]) -> Option> { - let selector: [u8; 4] = calldata.get(..4)?.try_into().ok()?; - let params = &calldata[4..]; - let word = |at: usize| params.get(at..at.checked_add(32)?); - let nullifiers: Vec<[u8; 32]> = if selector == SELECTOR_UNSHIELD { - vec![word(64)?.try_into().ok()?] - } else if selector == SELECTOR_PRIVATE_TRANSFER { - let offset = usize::try_from(U256::from_big_endian(word(64)?)).ok()?; - let count = usize::try_from(U256::from_big_endian(word(offset)?)).ok()?; - if count == 0 || count > MAX_SPEND_INPUTS as usize { - return None; - } - (0..count) - .map(|i| word(offset + 32 + 32 * i)?.try_into().ok()) - .collect::>()? - } else { - return None; - }; - let real: Vec<[u8; 32]> = nullifiers.into_iter().filter(|n| n != &[0u8; 32]).collect(); - (!real.is_empty()).then_some(real) -} - -#[derive(Clone, Copy)] -enum Claim { - /// A request is working on the spend; it releases or submits the claim. - InProgress, - /// The spend was submitted at this instant. - Submitted(Instant), -} - -/// Nullifiers the relay is working on or has submitted. -/// -/// A claim in progress never lapses: the request holding it releases it, or -/// marks it submitted. A submitted one lapses after a TTL, so a spend that -/// never lands can be relayed again. -#[derive(Default)] -pub(crate) struct InFlight { - held: HashMap<[u8; 32], Claim>, -} - -impl InFlight { - /// Claim every nullifier, or none: `false` if any is already held and live. - pub(crate) fn try_claim( - &mut self, - nullifiers: &[[u8; 32]], - now: Instant, - ttl: Duration, - ) -> bool { - self.held.retain(|_, claim| match claim { - Claim::InProgress => true, - Claim::Submitted(at) => now.duration_since(*at) < ttl, - }); - if nullifiers.iter().any(|n| self.held.contains_key(n)) { - return false; - } - for n in nullifiers { - self.held.insert(*n, Claim::InProgress); - } - true - } - - /// The spend was submitted: from here its claim lapses after the TTL. - pub(crate) fn mark_submitted(&mut self, nullifiers: &[[u8; 32]], now: Instant) { - for n in nullifiers { - if let Some(claim) = self.held.get_mut(n) { - *claim = Claim::Submitted(now); - } - } - } - - /// Give the nullifiers back, when the relay did not submit their spend. - pub(crate) fn release(&mut self, nullifiers: &[[u8; 32]]) { - for n in nullifiers { - self.held.remove(n); - } - } -} - -/// A spend's claim on its nullifiers for the life of one relay request. Dropped -/// without [`ClaimGuard::submitted`] — an error, or the caller disconnecting -/// mid-request — it releases them. -pub(crate) struct ClaimGuard { - in_flight: std::sync::Arc>, - nullifiers: Vec<[u8; 32]>, - submitted: bool, -} - -impl ClaimGuard { - pub(crate) fn take( - in_flight: &std::sync::Arc>, - nullifiers: Vec<[u8; 32]>, - ) -> Option { - let claimed = - lock(in_flight).try_claim(&nullifiers, std::time::Instant::now(), IN_FLIGHT_TTL); - claimed.then(|| Self { - in_flight: in_flight.clone(), - nullifiers, - submitted: false, - }) - } - - /// The spend is in the pool: keep the claim until it lapses. - pub(crate) fn submitted(mut self) { - lock(&self.in_flight).mark_submitted(&self.nullifiers, std::time::Instant::now()); - self.submitted = true; - } -} - -impl Drop for ClaimGuard { - fn drop(&mut self) { - if !self.submitted { - lock(&self.in_flight).release(&self.nullifiers); - } - } -} - -fn lock(in_flight: &std::sync::Mutex) -> std::sync::MutexGuard<'_, InFlight> { - in_flight - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -/// Where a relay commit ranks: its recording block, then its hash. The chain -/// credits the earliest block, and a same-block tie to the lowest hash. -pub(crate) type CommitRank = (u32, [u8; 32]); - -/// Whether a rival's live commit takes the fee from ours. Before ours is -/// recorded (`None`), any rival commit does. -pub(crate) fn rival_wins(ours: Option, rival: CommitRank) -> bool { - ours.is_none_or(|ours| rival < ours) -} - -/// The nonce for the next submission. -/// -/// Several submissions within one block take N, N+1, … from memory while the -/// confirmed nonce lags. A transaction that never lands leaves a gap nothing -/// fills, so once the confirmed nonce has not moved for `stall` blocks while -/// memory is ahead of it, memory is dropped and the confirmed nonce is reused. -#[derive(Default)] -pub(crate) struct NonceTracker { - next: Option, - /// The confirmed nonce last seen, and the block it was first seen at. - confirmed: Option<(U256, u64)>, -} - -impl NonceTracker { - pub(crate) fn nonce(&mut self, confirmed: U256, best: u64, stall: u64) -> U256 { - match self.confirmed { - Some((seen, _)) if seen == confirmed => {} - _ => self.confirmed = Some((confirmed, best)), - } - let since = self.confirmed.map_or(best, |(_, at)| at); - match self.next { - Some(next) if next > confirmed && best.saturating_sub(since) < stall => next, - _ => { - self.next = None; - confirmed - } - } - } - - /// `nonce` was accepted by the pool. - pub(crate) fn submitted(&mut self, nonce: U256) { - self.next = Some(nonce + U256::one()); - } -} diff --git a/client/rpc/src/relay/mod.rs b/client/rpc/src/relay/mod.rs deleted file mode 100644 index f0f5101d..00000000 --- a/client/rpc/src/relay/mod.rs +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Node-native EVM relay RPC. -//! -//! When the node is started with `--evm-relayer-key `, it exposes: -//! -//! - `orbinum_relayShieldedCall(calldata: "0x...")` → `txHash` -//! - `orbinum_relayerStatus()` → `{ address, minFee, balanceWei, enabled, isRegistered }` -//! -//! The relay pays gas on a user's behalf, so it will only call the ShieldedPool -//! precompile (`0x…0801`), only with a whitelisted selector, and only when the -//! fee in ABI slot 6 covers what the transaction will cost it. The whitelist and -//! fee floor come from `pallet-relayer` via Runtime API, read on every call so -//! governance changes apply without a node restart. -//! -//! # Module layout -//! -//! The split is by what each part needs in order to run: -//! -//! | Sub-module | Responsibility | -//! |----------------|-----------------------------------------------------------| -//! | [`config`] | Constants: the target, admission limits, tx parameters, and Runtime API fallbacks | -//! | [`types`] | `OrbinumRelayApi` RPC trait + `RelayerStatus` response | -//! | [`operations`] | Per-operation selector, length, and fee extraction | -//! | [`validation`] | Pure calldata checks — bytes in, verdict out; no chain state | -//! | [`guard`] | One relay per spend, the commit ranking, nonce tracking | -//! | [`rpc`] | `OrbinumRelay`: dry run, relay commit, signing, submission | -//! -//! Keeping [`validation`] free of chain state is what lets `tests/adversarial.rs` -//! throw hostile calldata at it without a node. - -pub mod config; -pub(crate) mod guard; -pub mod operations; -pub mod rpc; -pub mod types; -pub mod validation; - -#[cfg(test)] -mod tests; - -pub use rpc::OrbinumRelay; -pub use types::{OrbinumRelayApiServer, RelayerStatus}; diff --git a/client/rpc/src/relay/operations.rs b/client/rpc/src/relay/operations.rs deleted file mode 100644 index 4524e227..00000000 --- a/client/rpc/src/relay/operations.rs +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Generic `RelayableOperation` trait and built-in implementations. -//! -//! Each operation describes how to validate calldata for a specific on-chain call: -//! its ABI selector, the minimum calldata length required, and where to find the -//! embedded relay fee. Adding support for a new relayable precompile call only -//! requires implementing this trait and registering the struct in -//! [`default_operations`]. - -use ethereum_types::U256; - -/// Selectors the relay accepts, re-exported from the precompile that decodes -/// them. Aliases rather than literals: the whitelist and the decoder cannot -/// disagree if there is only one definition. A hand-kept copy drifting from the -/// decoder fails silently — a wrong selector is merely "unsupported", so the -/// rejection tests stay green while the accept path stops working. -pub(crate) use pallet_evm_precompile_shielded_pool::selectors::{ - COMMIT_RELAY as SELECTOR_COMMIT_RELAY, PRIVATE_TRANSFER as SELECTOR_PRIVATE_TRANSFER, - UNSHIELD as SELECTOR_UNSHIELD, -}; - -/// Calldata for `commitRelay(bytes32[] commits)`: selector, the offset of the -/// array (one head slot), then its length and items. -pub(crate) fn commit_relay_calldata(commits: &[ethereum_types::H256]) -> Vec { - let mut data = Vec::with_capacity(4 + 64 + commits.len() * 32); - data.extend_from_slice(&SELECTOR_COMMIT_RELAY); - data.extend_from_slice(&U256::from(32).to_big_endian()); - data.extend_from_slice(&U256::from(commits.len()).to_big_endian()); - for commit in commits { - data.extend_from_slice(commit.as_bytes()); - } - data -} - -/// Describes how to validate calldata for a specific relayable on-chain operation. -/// -/// Implementing this trait for a new operation allows the relay to accept it -/// without modifying [`validate_relay_calldata`]. -/// -/// [`validate_relay_calldata`]: super::validation::validate_relay_calldata -pub(crate) trait RelayableOperation: Send + Sync { - /// 4-byte ABI selector identifying this operation. - fn selector(&self) -> [u8; 4]; - - /// Human-readable name (used in log messages and RPC responses). - fn name(&self) -> &'static str; - - /// Minimum required calldata length (bytes) for this operation's ABI layout. - fn min_calldata_len(&self) -> usize; - - /// Extract the relay fee (planck/wei) from validated calldata. - /// - /// Only called after `data.len() >= min_calldata_len()` is confirmed. - fn extract_fee(&self, calldata: &[u8]) -> u128; -} - -/// Reads the relay fee from ABI slot 6 (`calldata[196..228]`), the position both -/// operations share. -/// -/// Saturates instead of panicking on a value above `u128::MAX`. Calldata reaches -/// this from an unauthenticated RPC call, and `U256::as_u128` panics outright on -/// anything wider — one crafted 32-byte word would take down the handler. -/// Saturating is safe because the result is only ever compared against the fee -/// floor: an absurd fee clears it here and is then rejected by the EVM dry-run, -/// which is what would have happened anyway. -/// -/// The slice is bounds-checked by the caller's `min_calldata_len()` gate (260 or -/// 324, both well past 228). -fn fee_at_slot_6(calldata: &[u8]) -> u128 { - let Ok(bytes) = <[u8; 32]>::try_from(&calldata[196..228]) else { - return 0; // unreachable behind the length gate; a zero fee fails the floor - }; - U256::from_big_endian(&bytes) - .try_into() - .unwrap_or(u128::MAX) -} - -/// `unshield(proof, root, nullifier, asset_id, amount, recipient, fee, -/// change_commitment, change_encrypted_memo, circuit_version)` — `0x4e505348` -/// -/// Fee is in ABI slot 6: `calldata[196..228]`. The head is 10 slots (320 bytes) -/// plus the 4-byte selector = 324 minimum. -pub(crate) struct UnshieldOp; - -impl RelayableOperation for UnshieldOp { - fn selector(&self) -> [u8; 4] { - SELECTOR_UNSHIELD - } - - fn name(&self) -> &'static str { - "unshield" - } - - fn min_calldata_len(&self) -> usize { - 324 - } - - fn extract_fee(&self, calldata: &[u8]) -> u128 { - fee_at_slot_6(calldata) - } -} - -/// `privateTransfer(proof, merkle_roots, nullifiers, commitments, memos, asset_id, -/// fee, circuit_version)` — `0x63d0b9a0` -/// -/// Fee is in ABI slot 6: `calldata[196..228]`. The head is 8 slots (256 bytes) -/// plus the 4-byte selector = 260 minimum. -pub(crate) struct PrivateTransferOp; - -impl RelayableOperation for PrivateTransferOp { - fn selector(&self) -> [u8; 4] { - SELECTOR_PRIVATE_TRANSFER - } - - fn name(&self) -> &'static str { - "privateTransfer" - } - - fn min_calldata_len(&self) -> usize { - 260 - } - - fn extract_fee(&self, calldata: &[u8]) -> u128 { - fee_at_slot_6(calldata) - } -} - -/// All operations the relay supports out of the box. -/// -/// New operations can be registered by adding them here and implementing -/// [`RelayableOperation`]. The governance `AllowedSelectors` whitelist acts as -/// an additional filter — only operations whose selectors are in the whitelist -/// are accepted at runtime. -pub(crate) fn default_operations() -> Vec> { - vec![Box::new(UnshieldOp), Box::new(PrivateTransferOp)] -} diff --git a/client/rpc/src/relay/rpc/commit.rs b/client/rpc/src/relay/rpc/commit.rs deleted file mode 100644 index 4188573d..00000000 --- a/client/rpc/src/relay/rpc/commit.rs +++ /dev/null @@ -1,122 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Relay commits: queued per call, sent in batches, awaited until recorded. -//! -//! A spend's fee goes to the relayer whose commit for it sits in an earlier -//! block, so each relay call records its commit and waits for it to land before -//! the spend goes out. Calls arriving together share one `commitRelay` -//! transaction. - -use ethereum_types::H256; -use fp_rpc::{ConvertTransactionRuntimeApi, EthereumRuntimeRPCApi}; -use jsonrpsee::core::RpcResult; -use pallet_relayer_runtime_api::RelayerRuntimeApi; -use pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi; -use sc_client_api::backend::{Backend, StorageProvider}; -use sc_transaction_pool_api::TransactionPool; -use sp_api::ProvideRuntimeApi; -use sp_blockchain::HeaderBackend; -use sp_runtime::traits::Block as BlockT; - -use crate::internal_err; - -use super::OrbinumRelay; -use crate::relay::{ - config::{COMMIT_BATCH_WINDOW, COMMIT_POLL_INTERVAL, COMMIT_WAIT_TIMEOUT}, - operations::commit_relay_calldata, -}; - -/// A queued relay commit and the channel its caller waits on for the result -/// of submitting it. -pub(super) type PendingCommit = (H256, tokio::sync::oneshot::Sender>); - -impl OrbinumRelay -where - B: BlockT, - C: ProvideRuntimeApi + HeaderBackend + StorageProvider + 'static, - C::Api: EthereumRuntimeRPCApi - + ConvertTransactionRuntimeApi - + ShieldedPoolRuntimeApi - + RelayerRuntimeApi, - BE: Backend + 'static, - P: TransactionPool + 'static, -{ - /// Record `commit` on-chain and wait until it is in the best chain, so the - /// spend sent afterwards lands in a later block — the only kind that counts. - /// - /// Commits queued within `COMMIT_BATCH_WINDOW` of each other go out together: - /// the first call to wake sends the whole queue and tells every queued caller - /// how its chunk fared, so a failed submission is reported to all of them - /// rather than surfacing later as a timeout. - pub(super) async fn commit(&self, commit: H256) -> RpcResult<()> { - let (sent, submitted) = tokio::sync::oneshot::channel(); - self.pending_commits.lock().await.push((commit, sent)); - tokio::time::sleep(COMMIT_BATCH_WINDOW).await; - - // Sent from a task of its own: if this caller disconnects, the batch it - // drained still goes out for everyone else in it. - let batch = std::mem::take(&mut *self.pending_commits.lock().await); - if !batch.is_empty() { - let relay = self.clone(); - tokio::spawn(async move { relay.send_commits(batch).await }); - } - tokio::time::timeout(COMMIT_WAIT_TIMEOUT, submitted) - .await - .map_err(|_| internal_err("relay commit was not submitted in time"))? - .map_err(|_| internal_err("relay commit batch was dropped"))? - .map_err(internal_err)?; - - if self.wait_recorded(commit).await? { - Ok(()) - } else { - Err(internal_err("relay commit was not included in time")) - } - } - - /// Poll the best block until `commit` is recorded; `false` on timeout. - async fn wait_recorded(&self, commit: H256) -> RpcResult { - let deadline = tokio::time::Instant::now() + COMMIT_WAIT_TIMEOUT; - loop { - if self - .commit_rank(self.client.info().best_hash, commit)? - .is_some() - { - return Ok(true); - } - if tokio::time::Instant::now() >= deadline { - return Ok(false); - } - tokio::time::sleep(COMMIT_POLL_INTERVAL).await; - } - } - - /// Submit `batch` in `commitRelay` calls of at most the per-block quota - /// (`MAX_RELAY_COMMITS_PER_CALL` equals `MaxCommitsPerRelayerPerBlock`), - /// reporting each chunk's result to its callers. - /// - /// The quota applies to the block a chunk lands in, not the one it was sent - /// at, so the next chunk goes out only once the previous one is recorded; - /// one sender at a time keeps concurrent batches from sharing a block. - async fn send_commits(&self, batch: Vec) { - let per_block = pallet_evm_precompile_shielded_pool::MAX_RELAY_COMMITS_PER_CALL as usize; - let _sender = self.commit_sender.lock().await; - let mut batch = batch.into_iter().peekable(); - while batch.peek().is_some() { - let chunk: Vec = batch.by_ref().take(per_block).collect(); - let commits: Vec = chunk.iter().map(|(c, _)| *c).collect(); - let result = self - .sign_and_submit(commit_relay_calldata(&commits)) - .await - .map(|_| ()) - .map_err(|e| e.to_string()); - for (_, caller) in chunk { - let _ = caller.send(result.clone()); - } - if result.is_ok() && batch.peek().is_some() { - if let Some(last) = commits.last() { - let _ = self.wait_recorded(*last).await; - } - } - } - } -} diff --git a/client/rpc/src/relay/rpc/mod.rs b/client/rpc/src/relay/rpc/mod.rs deleted file mode 100644 index bae360ac..00000000 --- a/client/rpc/src/relay/rpc/mod.rs +++ /dev/null @@ -1,422 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! The RPC server itself: everything that needs chain state. -//! -//! [`super::validation`] decides whether calldata is admissible from the bytes -//! alone. What is left here is the part that cannot be answered offline — -//! querying governance config, simulating the call, recording the relay commit, -//! allocating a nonce, signing, and submitting to the pool. -//! -//! The relay commit is what makes the fee ours: the spend's fee goes to the -//! relayer whose commit for it sits in an earlier block, whoever submits the -//! spend. So the commit goes out first, and the spend only once it is included -//! and still outranks every rival commit. -//! -//! Both handlers read `relay_config()` on every call rather than caching it, so -//! a governance change takes effect without restarting the node. -//! -//! [`commit`] batches relay commits into `commitRelay` transactions. - -use std::sync::Arc; - -use ethereum::TransactionAction; -use ethereum_types::{H160, H256, U256}; -use jsonrpsee::core::RpcResult; -// Substrate -use sc_client_api::backend::{Backend, StorageProvider}; -use sc_transaction_pool_api::{TransactionPool, TransactionSource}; -use sp_api::ApiExt; -use sp_api::ProvideRuntimeApi; -use sp_blockchain::HeaderBackend; -use sp_runtime::traits::Block as BlockT; -// Frontier -use fc_rpc_core::types::{Bytes, TransactionMessage}; -use fp_rpc::{ConvertTransactionRuntimeApi, EthereumRuntimeRPCApi}; -// Orbinum -use pallet_relayer_runtime_api::RelayerRuntimeApi; -use pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi; - -use crate::{internal_err, signer::EthValidatorSigner}; - -mod commit; -use commit::PendingCommit; - -use super::{ - config::{ - MAX_FEE_PER_GAS_WEI, MIN_RELAY_FEE_FALLBACK, NONCE_STALL_BLOCKS, RELAY_GAS_LIMIT, - SELECTORS_FALLBACK, SHIELDED_POOL_PRECOMPILE, - }, - guard::{rival_wins, spend_nullifiers, ClaimGuard, CommitRank, InFlight, NonceTracker}, - types::{OrbinumRelayApiServer, RelayerStatus}, - validation::{check_dry_run_exit, compute_effective_min_fee, validate_relay_calldata}, -}; - -// --------------------------------------------------------------------------- -// Server struct -// --------------------------------------------------------------------------- - -pub struct OrbinumRelay { - client: Arc, - pool: Arc

, - signer: Arc, - /// Serializes signing and submission, and tracks the nonce across them. - submit_lock: Arc>, - /// Relay commits waiting to be sent in the next `commitRelay` transaction, - /// each with the channel its caller waits on for the submission result. - pending_commits: Arc>>, - /// One commit sender at a time, so chunks land in successive blocks. - commit_sender: Arc>, - /// Nullifiers of spends in progress or submitted: one relay per spend. - in_flight: Arc>, - _phantom: std::marker::PhantomData<(B, BE)>, -} - -// Manual: every field is shared, so a clone is a second handle on the same relay, -// which sends a commit batch from a task no caller can cancel. -impl Clone for OrbinumRelay { - fn clone(&self) -> Self { - Self { - client: self.client.clone(), - pool: self.pool.clone(), - signer: self.signer.clone(), - submit_lock: self.submit_lock.clone(), - pending_commits: self.pending_commits.clone(), - commit_sender: self.commit_sender.clone(), - in_flight: self.in_flight.clone(), - _phantom: Default::default(), - } - } -} - -impl OrbinumRelay -where - B: BlockT, -{ - pub fn new(client: Arc, pool: Arc

, signer: EthValidatorSigner) -> Self { - Self { - client, - pool, - signer: Arc::new(signer), - submit_lock: Default::default(), - pending_commits: Default::default(), - commit_sender: Default::default(), - in_flight: Default::default(), - _phantom: Default::default(), - } - } -} - -// --------------------------------------------------------------------------- -// RPC handlers -// --------------------------------------------------------------------------- - -#[jsonrpsee::core::async_trait] -impl OrbinumRelayApiServer for OrbinumRelay -where - B: BlockT, - C: ProvideRuntimeApi + HeaderBackend + StorageProvider + 'static, - C::Api: EthereumRuntimeRPCApi - + ConvertTransactionRuntimeApi - + ShieldedPoolRuntimeApi - + RelayerRuntimeApi, - BE: Backend + 'static, - P: TransactionPool + 'static, -{ - async fn relay_shielded_call(&self, calldata: Bytes) -> RpcResult { - let data = calldata.into_vec(); - - let best_hash = self.client.info().best_hash; - let (effective_min_fee, allowed_selectors) = self.fee_policy(best_hash); - - if let Err(e) = validate_relay_calldata(&data, effective_min_fee, &allowed_selectors) { - log::warn!( - target: "orbinum-relay", - "relay rejected: {e} (effective minimum fee {effective_min_fee})" - ); - return Err(internal_err(e)); - } - let nullifiers = spend_nullifiers(&data) - .ok_or_else(|| internal_err("calldata does not name the notes it spends"))?; - // Before claiming: calldata that cannot execute must not hold its notes' - // claim, or junk naming someone else's nullifier could block their relay. - self.dry_run(best_hash, &data)?; - - // One relay per spend: copies sent before the first lands would each pass - // the dry-run (it cannot see the pool) and each cost the relayer its gas. - let claim = ClaimGuard::take(&self.in_flight, nullifiers) - .ok_or_else(|| internal_err("this spend is already being relayed"))?; - let tx_hash = self.relay(data).await?; - claim.submitted(); - Ok(tx_hash) - } - - async fn relayer_status(&self) -> RpcResult { - let best_hash = self.client.info().best_hash; - let (min_fee, _) = self.fee_policy(best_hash); - let api = self.client.runtime_api(); - let balance = api - .account_basic(best_hash, self.signer.address()) - .map_err(|e| internal_err(format!("account_basic: {e}")))? - .balance; - // Only a registered address can record relay commits. - let is_registered = api - .is_relayer_evm(best_hash, self.signer.address().0) - .unwrap_or(false); - let needs_registration = self.relay_commits_supported(best_hash)?; - // A relay sends a commit and then the spend, each paying up to - // `RELAY_GAS_LIMIT × MAX_FEE_PER_GAS_WEI`; the pool refuses less. - let worst_case = U256::from(RELAY_GAS_LIMIT) * U256::from(MAX_FEE_PER_GAS_WEI) * 2; - Ok(RelayerStatus { - address: self.signer.address(), - min_fee: format!("{min_fee}"), - balance_wei: format!("{balance}"), - enabled: (is_registered || !needs_registration) && balance >= worst_case, - is_registered, - }) - } -} - -// --------------------------------------------------------------------------- -// Relay flow: chain reads, dry-run, rival check, submission -// --------------------------------------------------------------------------- - -impl OrbinumRelay -where - B: BlockT, - C: ProvideRuntimeApi + HeaderBackend + StorageProvider + 'static, - C::Api: EthereumRuntimeRPCApi - + ConvertTransactionRuntimeApi - + ShieldedPoolRuntimeApi - + RelayerRuntimeApi, - BE: Backend + 'static, - P: TransactionPool + 'static, -{ - /// The effective minimum fee and the selector whitelist, read on every call - /// so a governance change applies without a restart. The fallbacks cover - /// only a runtime without the API. - fn fee_policy(&self, at: B::Hash) -> (u128, Vec<[u8; 4]>) { - let api = self.client.runtime_api(); - let (min_fee_planck, allowed_selectors) = match api.relay_config(at) { - Ok(cfg) => (cfg.min_fee_planck, cfg.allowed_selectors), - Err(e) => { - log::warn!( - target: "orbinum-relay", - "relay_config Runtime API unavailable, using fallback: {e}" - ); - (MIN_RELAY_FEE_FALLBACK, SELECTORS_FALLBACK.to_vec()) - } - }; - // The relay must earn at least twice what it spends on gas (1 wei = 1 - // planck). Saturate: `as_u128()` would panic on a gas price ≥ 2^128. - let base_fee_wei: u128 = api - .gas_price(at) - .map(|p| p.try_into().unwrap_or(u128::MAX)) - .unwrap_or(0); - ( - compute_effective_min_fee(min_fee_planck, base_fee_wei), - allowed_selectors, - ) - } - - /// Commit, dry-run again, check no rival outranks our commit, submit. The - /// second dry-run runs against the block the commit landed in: the spend may - /// have been included, or its note spent, while the commit was waiting. - async fn relay(&self, data: Vec) -> RpcResult { - let at = self.client.info().best_hash; - if let Some(commit) = self.relay_commit_for(at, &data)? { - let ours = self.commit_rank(at, commit)?; - self.ensure_no_rival_commit(at, &data, ours)?; - if ours.is_none() { - self.commit(commit).await?; - } - let at = self.client.info().best_hash; - self.dry_run(at, &data)?; - // A rival recorded in our commit's block with a lower hash takes the fee. - self.ensure_no_rival_commit(at, &data, self.commit_rank(at, commit)?)?; - } - self.sign_and_submit(data).await - } - - /// Where `commit` ranks, if it is recorded and live at `at`. - fn commit_rank(&self, at: B::Hash, commit: H256) -> RpcResult> { - Ok(self - .client - .runtime_api() - .relay_commit_block(at, commit.0) - .map_err(|e| internal_err(format!("relay_commit_block: {e}")))? - .map(|block| (block, commit.0))) - } - - /// Simulate the EVM call without broadcasting a transaction. Catches invalid - /// proofs, spent nullifiers and any other on-chain rejection BEFORE the - /// relayer signs and pays gas. - fn dry_run(&self, at: B::Hash, data: &[u8]) -> RpcResult<()> { - let dry_result = self.client.runtime_api().call( - at, - self.signer.address(), - H160::from(SHIELDED_POOL_PRECOMPILE), - data.to_vec(), - U256::zero(), - U256::from(RELAY_GAS_LIMIT), - Some(U256::from(MAX_FEE_PER_GAS_WEI)), - Some(U256::from(1_000_000_000u64)), - None, // nonce — not needed for simulation - false, // estimate = false: real execution semantics - None, // access_list - None, // authorization_list - ); - match dry_result { - Err(e) => { - log::warn!(target: "orbinum-relay", "dry-run Runtime API error: {e}"); - Err(internal_err(format!("dry-run runtime error: {e}"))) - } - Ok(Err(dispatch_err)) => { - log::warn!(target: "orbinum-relay", "dry-run dispatch error: {dispatch_err:?}"); - Err(internal_err(format!( - "calldata rejected by runtime: {dispatch_err:?}" - ))) - } - Ok(Ok(info)) => check_dry_run_exit(&info.exit_reason).map_err(|e| { - log::warn!( - target: "orbinum-relay", - "dry-run EVM execution failed — exit={:?} revert_data={:?}", - info.exit_reason, - info.value - ); - internal_err(e) - }), - } - } - - /// Refuse a spend whose fee a rival's live commit would take (see - /// [`rival_wins`]): relaying it would pay this relayer's gas for their fee. - /// The caller can retry once that commit expires (`CommitTtl` blocks). - fn ensure_no_rival_commit( - &self, - at: B::Hash, - data: &[u8], - ours: Option, - ) -> RpcResult<()> { - let api = self.client.runtime_api(); - let own = self.signer.address().0; - let relayers = api - .get_active_relayers(at) - .map_err(|e| internal_err(format!("get_active_relayers: {e}")))?; - for (address, _) in relayers.into_iter().filter(|(a, _)| *a != own) { - let Some(commit) = api - .relay_commit_hash(at, data.to_vec(), address) - .map_err(|e| internal_err(format!("relay_commit_hash: {e}")))? - else { - continue; - }; - if let Some(rival) = self.commit_rank(at, H256(commit))? { - if rival_wins(ours, rival) { - return Err(internal_err(format!( - "relayer {:?} committed to this spend first; retry after its commit expires", - H160::from(address) - ))); - } - } - } - Ok(()) - } - - /// Whether the runtime attributes fees by relay commit: `ShieldedPoolRuntimeApi` - /// ≥ 3 and `RelayerRuntimeApi` ≥ 2. An older one attributes them by origin — - /// the node binary ships before the runtime upgrade. - fn relay_commits_supported(&self, at: B::Hash) -> RpcResult { - let api = self.client.runtime_api(); - let pool = api - .api_version::>(at) - .map_err(|e| internal_err(format!("api_version: {e}")))? - .unwrap_or(0); - let relayer = api - .api_version::>(at) - .map_err(|e| internal_err(format!("api_version: {e}")))? - .unwrap_or(0); - Ok(pool >= 3 && relayer >= 2) - } - - /// The relay commit for `calldata`, or `None` on a runtime without relay - /// commits. - fn relay_commit_for(&self, at: B::Hash, calldata: &[u8]) -> RpcResult> { - if !self.relay_commits_supported(at)? { - return Ok(None); - } - let api = self.client.runtime_api(); - // Fail fast: an unregistered address cannot record the commit, and the - // caller would otherwise only see it time out. - if !api - .is_relayer_evm(at, self.signer.address().0) - .unwrap_or(false) - { - return Err(internal_err("relay address is not registered on-chain")); - } - let commit = api - .relay_commit_hash(at, calldata.to_vec(), self.signer.address().0) - .map_err(|e| internal_err(format!("relay_commit_hash: {e}")))? - .ok_or_else(|| internal_err("calldata is not a relayable spend"))?; - Ok(Some(H256(commit))) - } - - /// Sign `input` as a call to the ShieldedPool precompile and submit it. - async fn sign_and_submit(&self, input: Vec) -> RpcResult { - use sp_runtime::traits::UniqueSaturatedInto; - // Held for the whole sequence, and the chain read under it, so no two - // submissions sign with the same nonce. - let mut nonces = self.submit_lock.lock().await; - let info = self.client.info(); - let best_hash = info.best_hash; - let best: u64 = info.best_number.unique_saturated_into(); - - let relayer_addr = self.signer.address(); - - let (chain_id, nonce) = { - let api = self.client.runtime_api(); - let chain_id = api - .chain_id(best_hash) - .map_err(|e| internal_err(format!("chain_id: {e}")))?; - let confirmed = api - .account_basic(best_hash, relayer_addr) - .map_err(|e| internal_err(format!("account_basic: {e}")))? - .nonce; - (chain_id, nonces.nonce(confirmed, best, NONCE_STALL_BLOCKS)) - }; - - let message = TransactionMessage::EIP1559(ethereum::EIP1559TransactionMessage { - chain_id, - nonce, - max_priority_fee_per_gas: U256::from(1_000_000_000u64), - max_fee_per_gas: U256::from(MAX_FEE_PER_GAS_WEI), - gas_limit: U256::from(RELAY_GAS_LIMIT), - action: TransactionAction::Call(H160::from(SHIELDED_POOL_PRECOMPILE)), - value: U256::zero(), - input, - access_list: vec![], - }); - - use crate::signer::EthSigner as _; - let transaction = self.signer.sign(message, &relayer_addr)?; - let tx_hash = transaction.hash(); - - let extrinsic = { - let api = self.client.runtime_api(); - api.convert_transaction(best_hash, transaction) - .map_err(|e| internal_err(format!("convert_transaction: {e}")))? - }; - - let submit_result = self - .pool - .submit_one(best_hash, TransactionSource::Local, extrinsic) - .await - .map(|_| tx_hash) - .map_err(|e| internal_err(format!("pool submit: {e}"))); - - // Only a submission the pool accepted takes the nonce. - if submit_result.is_ok() { - nonces.submitted(nonce); - } - - submit_result - } -} diff --git a/client/rpc/src/relay/tests/adversarial.rs b/client/rpc/src/relay/tests/adversarial.rs deleted file mode 100644 index 92fb706a..00000000 --- a/client/rpc/src/relay/tests/adversarial.rs +++ /dev/null @@ -1,400 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Adversarial tests: malformed, hostile, and boundary calldata. -//! -//! These assert the relay rejects rather than panics. Every input here is one a -//! caller can send over the unauthenticated RPC. - -use crate::relay::{ - config::*, - operations::{SELECTOR_PRIVATE_TRANSFER, SELECTOR_UNSHIELD}, - validation::*, -}; - -/// Well-formed base calldata for privateTransfer, long enough to pass the -/// length gates so the later checks are actually reached. -fn base_private_transfer(fee: u128) -> Vec { - let mut d = SELECTOR_PRIVATE_TRANSFER.to_vec(); - d.resize(4 + 256, 0); - let mut fee_word = [0u8; 32]; - fee_word[16..32].copy_from_slice(&fee.to_be_bytes()); - d[4 + 192..4 + 224].copy_from_slice(&fee_word); - d -} - -/// Every length from 0 to just past the minimum: no panic, and the boundary -/// must be exact (227 rejected, 228 reaches the selector check). -#[test] -fn attack_every_calldata_length_is_handled_without_panic() { - for len in 0..400usize { - let data = vec![0xAAu8; len]; - let _ = validate_relay_calldata(&data, 0, &SELECTORS_FALLBACK); - } - // Boundary is exact. - assert_eq!( - validate_relay_calldata(&vec![0u8; 227], 0, &SELECTORS_FALLBACK), - Err("calldata too short") - ); - // 228 bytes of zeros passes the length gate and dies on the selector. - assert_eq!( - validate_relay_calldata(&vec![0u8; 228], 0, &SELECTORS_FALLBACK), - Err("unsupported selector") - ); -} - -/// A valid selector with calldata between the global 228 gate and the -/// operation's own minimum must be refused by the per-op gate, not read -/// past its end. -#[test] -fn attack_length_between_global_and_per_op_minimum_is_refused() { - for len in 228..260usize { - let mut d = SELECTOR_PRIVATE_TRANSFER.to_vec(); - d.resize(len, 0); - assert_eq!( - validate_relay_calldata(&d, 0, &SELECTORS_FALLBACK), - Err("calldata too short"), - "privateTransfer at {len} bytes must be refused" - ); - } - for len in 228..324usize { - let mut d = SELECTOR_UNSHIELD.to_vec(); - d.resize(len, 0); - assert_eq!( - validate_relay_calldata(&d, 0, &SELECTORS_FALLBACK), - Err("calldata too short"), - "unshield at {len} bytes must be refused" - ); - } -} - -/// The calldata cap must hold exactly: one byte over is refused, and the -/// oversized buffer must never be walked. -#[test] -fn attack_oversized_calldata_is_refused_at_the_exact_boundary() { - let mut ok = base_private_transfer(0); - ok.resize(MAX_CALLDATA_BYTES, 0); - // At the cap: passes the size gate (fails later or succeeds, but not "too large"). - assert_ne!( - validate_relay_calldata(&ok, 0, &SELECTORS_FALLBACK), - Err("calldata too large") - ); - - let mut over = base_private_transfer(0); - over.resize(MAX_CALLDATA_BYTES + 1, 0); - assert_eq!( - validate_relay_calldata(&over, 0, &SELECTORS_FALLBACK), - Err("calldata too large") - ); -} - -/// A fee word of all 0xFF (u256::MAX) must saturate, never panic, and must -/// COMPARE as above any minimum — a panic here is remote node death. -#[test] -fn attack_max_fee_word_saturates_and_passes_the_floor() { - let mut d = base_private_transfer(0); - d[4 + 192..4 + 224].copy_from_slice(&[0xFFu8; 32]); - assert_eq!( - validate_relay_calldata(&d, u128::MAX, &SELECTORS_FALLBACK), - Ok(()), - "a saturated fee must clear even the maximum floor" - ); -} - -/// A fee one planck below the floor must be refused; exactly at the floor -/// must pass. Off-by-one here is free money for the attacker or a broken relay. -#[test] -fn attack_fee_floor_boundary_is_exact() { - let floor = 1_000_000_000_000_000u128; - assert_eq!( - validate_relay_calldata( - &base_private_transfer(floor - 1), - floor, - &SELECTORS_FALLBACK - ), - Err("fee below minimum") - ); - assert_eq!( - validate_relay_calldata(&base_private_transfer(floor), floor, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -/// An empty whitelist must reject everything — a governance misconfiguration -/// must fail closed, never open. -#[test] -fn attack_empty_whitelist_fails_closed() { - assert_eq!( - validate_relay_calldata(&base_private_transfer(0), 0, &[]), - Err("unsupported selector") - ); -} - -/// A selector the governance whitelist allows but the node does not -/// implement must be refused, not dispatched to a wrong decoder. -#[test] -fn attack_whitelisted_but_unimplemented_selector_is_refused() { - let mut d = base_private_transfer(0); - d[..4].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); - assert_eq!( - validate_relay_calldata(&d, 0, &[[0xDE, 0xAD, 0xBE, 0xEF]]), - Err("unsupported selector"), - "a selector with no registered operation must fail closed" - ); -} - -/// The gas floor must saturate rather than overflow: base_fee near u128::MAX -/// multiplied by 2×gas_limit would wrap and produce a floor of ~0, letting -/// every transfer through for free. -#[test] -fn attack_gas_floor_saturates_instead_of_wrapping() { - let floor = compute_effective_min_fee(1, u128::MAX); - assert_eq!( - floor, - u128::MAX, - "a wrapped multiplication would collapse the floor to near zero" - ); - // And a realistic value still behaves. - let normal = compute_effective_min_fee(1_000_000_000_000_000, 1_000_000_000); - assert!(normal >= 1_000_000_000_000_000); -} - -/// Deterministic byte fuzz over the whole calldata: the only requirement is -/// that no input, however malformed, panics the validator. -#[test] -fn attack_calldata_fuzz_never_panics() { - let base = base_private_transfer(1_000_000_000_000_000); - let mut seed: u64 = 0xDEADBEEFCAFEBABE; - for _ in 0..20_000 { - let mut data = base.clone(); - seed = seed - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - let muts = 1 + (seed >> 60) as usize % 12; - for _ in 0..muts { - seed = seed - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - let pos = (seed >> 33) as usize % data.len(); - seed = seed - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - data[pos] = (seed >> 40) as u8; - } - // Sometimes truncate too. - seed = seed - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - if seed.is_multiple_of(3) { - let cut = (seed >> 33) as usize % data.len().max(1); - data.truncate(cut); - } - let _ = validate_relay_calldata(&data, 1_000_000_000_000_000, &SELECTORS_FALLBACK); - } -} - -/// Fuzz the fee slot specifically with full-width random words — this is the -/// field that would panic through `U256::as_u128()`. -#[test] -fn attack_fee_slot_fuzz_never_panics() { - let mut seed: u64 = 0x1234_5678_9ABC_DEF0; - for _ in 0..20_000 { - let mut d = base_private_transfer(0); - for byte in d[4 + 192..4 + 224].iter_mut() { - seed = seed - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - *byte = (seed >> 40) as u8; - } - let _ = validate_relay_calldata(&d, 1_000_000_000_000_000, &SELECTORS_FALLBACK); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Whitelist integrity -// -// `allowed_selectors` comes from governance storage over a Runtime API. These -// cover what happens when that list is itself hostile or malformed, which the -// node cannot prevent — only survive. -// ───────────────────────────────────────────────────────────────────────────── - -/// A whitelist entry for an operation the node does not implement must not -/// promote the call: the selector matches the whitelist but no decoder claims -/// it, so `default_operations()` finds nothing and the call is refused. -/// -/// This is the forward-compatibility path — governance enabling an operation -/// before the node ships it. Failing open here would relay calldata whose fee -/// slot has never been located, i.e. an unpriced call. -#[test] -fn attack_whitelist_full_of_unimplemented_selectors_fails_closed() { - let hostile: Vec<[u8; 4]> = (0u8..64).map(|i| [i, i, i, i]).collect(); - for sel in &hostile { - let mut data = base_private_transfer(u128::MAX); - data[..4].copy_from_slice(sel); - assert_eq!( - validate_relay_calldata(&data, 1, &hostile), - Err("unsupported selector"), - "selector {sel:?} is whitelisted but unimplemented — must not relay" - ); - } -} - -/// A whitelist containing a real selector many times over must behave exactly -/// as if it appeared once. Guards the `contains` lookup against a governance -/// list padded to provoke quadratic scanning or an early-exit mistake. -#[test] -fn attack_whitelist_with_duplicate_entries_behaves_identically() { - let padded = vec![SELECTOR_PRIVATE_TRANSFER; 4096]; - let data = base_private_transfer(u128::MAX); - assert_eq!(validate_relay_calldata(&data, 1, &padded), Ok(())); - assert_eq!( - validate_relay_calldata(&data, u128::MAX, &padded), - Ok(()), - "a saturated fee word clears any floor, duplicates or not" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Fee floor arithmetic -// -// `compute_effective_min_fee` multiplies runtime-sourced values. Wrapping here -// would invert the comparison and let a zero-fee call through. -// ───────────────────────────────────────────────────────────────────────────── - -/// The 2× gas floor must saturate, never wrap. A wrapped product would come out -/// *small*, and a small floor is one an attacker can clear with a nominal fee -/// while the relay pays real gas. -#[test] -fn attack_gas_floor_never_wraps_below_governance_minimum() { - for base_fee in [ - u128::MAX, - u128::MAX / 2, - u128::MAX / RELAY_GAS_LIMIT as u128, - 1 << 127, - ] { - let floor = compute_effective_min_fee(MIN_RELAY_FEE_FALLBACK, base_fee); - assert!( - floor >= MIN_RELAY_FEE_FALLBACK, - "floor {floor} fell below governance minimum at base_fee={base_fee}" - ); - } -} - -/// Governance setting `min_fee_planck` to zero must not disable the gas floor: -/// the relay still has to earn back the gas it spends. -#[test] -fn attack_zero_governance_fee_still_charges_the_gas_floor() { - let floor = compute_effective_min_fee(0, 1_000_000_000); - assert_eq!(floor, 2 * RELAY_GAS_LIMIT as u128 * 1_000_000_000); - assert!( - floor > 0, - "a zero governance fee must not mean a free relay" - ); - - // And with no gas price either, the floor is genuinely zero — documenting - // that the free-relay case requires BOTH to be zero. - assert_eq!(compute_effective_min_fee(0, 0), 0); -} - -/// A fee exactly one planck below the floor is refused; exactly at it passes. -/// Pins the comparison as `<` rather than `<=`, at a boundary an attacker -/// controls precisely. -#[test] -fn attack_fee_one_below_floor_is_refused() { - let floor = 1_000_000u128; - let at = base_private_transfer(floor); - let below = base_private_transfer(floor - 1); - - assert_eq!( - validate_relay_calldata(&at, floor, &SELECTORS_FALLBACK), - Ok(()) - ); - assert_eq!( - validate_relay_calldata(&below, floor, &SELECTORS_FALLBACK), - Err("fee below minimum") - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Selector confusion -// -// The two operations share a head up to slot 6 but diverge past it. A call -// must be measured against the length of the operation it claims to be. -// ───────────────────────────────────────────────────────────────────────────── - -/// privateTransfer-length calldata (260) carrying the unshield selector must be -/// refused: unshield needs 324. Otherwise the shorter layout would be read -/// against the longer one's expectations. -#[test] -fn attack_unshield_selector_on_private_transfer_length_is_refused() { - let mut data = base_private_transfer(u128::MAX); - data[..4].copy_from_slice(&SELECTOR_UNSHIELD); - assert_eq!(data.len(), 260); - assert_eq!( - validate_relay_calldata(&data, 1, &SELECTORS_FALLBACK), - Err("calldata too short"), - "260 bytes is valid for privateTransfer but 64 short for unshield" - ); -} - -/// The fee slot must be read from the same offset regardless of which selector -/// is claimed — both layouts agree up to slot 6, and that agreement is what -/// makes a single `fee_at_slot_6` correct. -#[test] -fn attack_fee_slot_is_stable_across_both_selectors() { - let fee = 12_345_678u128; - let mut pt = base_private_transfer(fee); - let mut un = pt.clone(); - un.resize(324, 0); - un[..4].copy_from_slice(&SELECTOR_UNSHIELD); - pt[..4].copy_from_slice(&SELECTOR_PRIVATE_TRANSFER); - - // Both must accept at exactly `fee` and refuse at `fee + 1`. - for data in [&pt, &un] { - assert_eq!( - validate_relay_calldata(data, fee, &SELECTORS_FALLBACK), - Ok(()) - ); - assert_eq!( - validate_relay_calldata(data, fee + 1, &SELECTORS_FALLBACK), - Err("fee below minimum") - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Size boundary -// ───────────────────────────────────────────────────────────────────────────── - -/// Calldata at exactly the cap is accepted, one byte over is refused, and -/// neither allocates proportionally to the claimed size. Pins `>` rather than -/// `>=` at the one boundary an attacker pays nothing to probe. -#[test] -fn attack_calldata_cap_boundary_is_exact() { - let mut at_cap = base_private_transfer(u128::MAX); - at_cap.resize(MAX_CALLDATA_BYTES, 0); - assert_eq!( - validate_relay_calldata(&at_cap, 1, &SELECTORS_FALLBACK), - Ok(()), - "exactly at the cap must be accepted" - ); - - let mut over = at_cap.clone(); - over.push(0); - assert_eq!( - validate_relay_calldata(&over, 1, &SELECTORS_FALLBACK), - Err("calldata too large") - ); -} - -/// The size check must come before any per-operation work: an oversized body -/// is refused for its size even when its selector is unknown, so a huge -/// unknown-selector call cannot be used to force extra scanning. -#[test] -fn attack_oversized_unknown_selector_is_refused_on_size_first() { - let mut data = vec![0xFFu8; MAX_CALLDATA_BYTES + 1]; - data[..4].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); - assert_eq!( - validate_relay_calldata(&data, 1, &SELECTORS_FALLBACK), - Err("calldata too large") - ); -} diff --git a/client/rpc/src/relay/tests/guard.rs b/client/rpc/src/relay/tests/guard.rs deleted file mode 100644 index 6e2336ec..00000000 --- a/client/rpc/src/relay/tests/guard.rs +++ /dev/null @@ -1,243 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! One relay per spend, the commit ranking, and nonces across submissions. - -use std::time::{Duration, Instant}; - -use ethereum_types::U256; - -use crate::relay::{ - guard::{rival_wins, spend_nullifiers, ClaimGuard, InFlight, NonceTracker}, - operations::{SELECTOR_PRIVATE_TRANSFER, SELECTOR_UNSHIELD}, -}; - -fn word(v: usize) -> [u8; 32] { - U256::from(v).to_big_endian() -} - -fn unshield(nullifier: [u8; 32]) -> Vec { - let mut data = SELECTOR_UNSHIELD.to_vec(); - data.extend_from_slice(&word(320)); // proof offset - data.extend_from_slice(&[0x11; 32]); // root - data.extend_from_slice(&nullifier); - data.resize(4 + 320, 0); - data -} - -fn transfer(nullifiers: &[[u8; 32]]) -> Vec { - let mut data = SELECTOR_PRIVATE_TRANSFER.to_vec(); - data.extend_from_slice(&word(256)); // proof offset - data.extend_from_slice(&word(256 + 32 + 32 * nullifiers.len())); // roots offset - data.extend_from_slice(&word(256)); // nullifiers offset - data.resize(4 + 256, 0); - data.extend_from_slice(&word(nullifiers.len())); - for n in nullifiers { - data.extend_from_slice(n); - } - data.extend_from_slice(&word(2)); - data.extend_from_slice(&[[0x11; 32], [0x12; 32]].concat()); - data -} - -// ── spend_nullifiers ───────────────────────────────────────────────────────── - -#[test] -fn reads_the_unshield_nullifier() { - assert_eq!( - spend_nullifiers(&unshield([0x22; 32])), - Some(vec![[0x22; 32]]) - ); -} - -#[test] -fn reads_the_transfer_nullifiers_without_dummies() { - assert_eq!( - spend_nullifiers(&transfer(&[[0x33; 32], [0x44; 32]])), - Some(vec![[0x33; 32], [0x44; 32]]) - ); - assert_eq!( - spend_nullifiers(&transfer(&[[0x33; 32], [0; 32]])), - Some(vec![[0x33; 32]]) - ); -} - -#[test] -fn refuses_calldata_that_names_no_note() { - assert_eq!( - spend_nullifiers(&unshield([0; 32])), - None, - "a zero nullifier identifies nothing" - ); - assert_eq!(spend_nullifiers(&transfer(&[])), None); - assert_eq!( - spend_nullifiers(&transfer(&[[1; 32]; 3])), - None, - "more inputs than the circuit" - ); - assert_eq!( - spend_nullifiers(&[0xde, 0xad, 0xbe, 0xef]), - None, - "unknown selector" - ); - assert_eq!( - spend_nullifiers(&unshield([0x22; 32])[..40]), - None, - "truncated" - ); - let mut bad_offset = transfer(&[[0x33; 32]]); - bad_offset[4 + 64..4 + 96].copy_from_slice(&[0xff; 32]); - assert_eq!(spend_nullifiers(&bad_offset), None, "offset past the input"); -} - -/// Offsets and lengths at the edge of `usize` read nothing and never overflow: -/// the guard runs on calldata from anyone, before the dry-run. -#[test] -fn hostile_offsets_and_lengths_read_nothing() { - let near_max = U256::from(usize::MAX - 16).to_big_endian(); - let mut offset = transfer(&[[0x33; 32]]); - offset[4 + 64..4 + 96].copy_from_slice(&near_max); - assert_eq!(spend_nullifiers(&offset), None); - - let mut length = transfer(&[[0x33; 32]]); - length[4 + 256..4 + 288].copy_from_slice(&near_max); - assert_eq!(spend_nullifiers(&length), None); - - let mut short = transfer(&[[0x33; 32], [0x44; 32]]); - short.truncate(4 + 256 + 32 + 32); - assert_eq!(spend_nullifiers(&short), None, "second nullifier missing"); -} - -/// The nullifiers come from head slot 2 whatever slot 1 holds: in the -/// two-root layout slot 1 is the roots' offset, never a nullifier. -#[test] -fn the_roots_array_is_never_read_as_nullifiers() { - let mut data = transfer(&[[0x33; 32], [0x44; 32]]); - // Point the roots at the nullifiers and the nullifiers at the roots. - let roots = data[4 + 32..4 + 64].to_vec(); - let nullifiers = data[4 + 64..4 + 96].to_vec(); - data[4 + 32..4 + 64].copy_from_slice(&nullifiers); - data[4 + 64..4 + 96].copy_from_slice(&roots); - assert_eq!( - spend_nullifiers(&data), - Some(vec![[0x11; 32], [0x12; 32]]), - "slot 2 decides, as it does for the precompile" - ); -} - -// ── InFlight ───────────────────────────────────────────────────────────────── - -const TTL: Duration = Duration::from_secs(120); - -#[test] -fn a_spend_is_relayed_once_while_in_flight() { - let mut in_flight = InFlight::default(); - let now = Instant::now(); - assert!(in_flight.try_claim(&[[1; 32]], now, TTL)); - assert!( - !in_flight.try_claim(&[[1; 32]], now, TTL), - "a copy is refused" - ); - // A transfer sharing one note with it is refused too, and claims nothing. - assert!(!in_flight.try_claim(&[[2; 32], [1; 32]], now, TTL)); - assert!( - in_flight.try_claim(&[[2; 32]], now, TTL), - "the refused claim took nothing" - ); -} - -#[test] -fn a_released_claim_frees_the_spend() { - let mut in_flight = InFlight::default(); - let now = Instant::now(); - assert!(in_flight.try_claim(&[[1; 32]], now, TTL)); - in_flight.release(&[[1; 32]]); - assert!( - in_flight.try_claim(&[[1; 32]], now, TTL), - "released after a failed relay" - ); -} - -/// A request still working on a spend (waiting for its commit, say) keeps the -/// claim however long it takes; only a submitted spend's claim lapses. -#[test] -fn only_a_submitted_claim_lapses() { - let mut in_flight = InFlight::default(); - let now = Instant::now(); - assert!(in_flight.try_claim(&[[1; 32]], now, TTL)); - assert!( - !in_flight.try_claim(&[[1; 32]], now + TTL * 10, TTL), - "in progress: never lapses" - ); - in_flight.mark_submitted(&[[1; 32]], now); - assert!(!in_flight.try_claim(&[[1; 32]], now + TTL / 2, TTL)); - assert!( - in_flight.try_claim(&[[1; 32]], now + TTL, TTL), - "submitted: lapses after the TTL" - ); -} - -// ── ClaimGuard ─────────────────────────────────────────────────────────────── - -/// A request that errors, or whose caller disconnects, drops its guard: the -/// claim goes back. A submitted one keeps it until it lapses. -#[test] -fn a_dropped_claim_is_released_and_a_submitted_one_kept() { - let in_flight = std::sync::Arc::new(std::sync::Mutex::new(InFlight::default())); - let claim = ClaimGuard::take(&in_flight, vec![[1; 32]]).expect("free"); - assert!( - ClaimGuard::take(&in_flight, vec![[1; 32]]).is_none(), - "held" - ); - drop(claim); - let claim = ClaimGuard::take(&in_flight, vec![[1; 32]]).expect("released on drop"); - claim.submitted(); - assert!( - ClaimGuard::take(&in_flight, vec![[1; 32]]).is_none(), - "a submitted spend stays claimed" - ); -} - -// ── rival_wins ─────────────────────────────────────────────────────────────── - -#[test] -fn a_rival_commit_wins_by_the_chains_rule() { - let ours = Some((10, [0x50; 32])); - assert!(rival_wins(ours, (9, [0xFF; 32])), "earlier block wins"); - assert!(!rival_wins(ours, (11, [0x00; 32])), "later block loses"); - assert!( - rival_wins(ours, (10, [0x40; 32])), - "same block, lower hash wins" - ); - assert!( - !rival_wins(ours, (10, [0x60; 32])), - "same block, higher hash loses" - ); - assert!( - rival_wins(None, (99, [0xFF; 32])), - "before ours is recorded, any rival wins" - ); -} - -// ── NonceTracker ───────────────────────────────────────────────────────────── - -#[test] -fn submissions_in_one_block_take_successive_nonces() { - let mut nonces = NonceTracker::default(); - let n = nonces.nonce(5.into(), 100, 10); - assert_eq!(n, 5.into()); - nonces.submitted(n); - assert_eq!(nonces.nonce(5.into(), 100, 10), 6.into()); - // The chain catches up: confirmed wins again. - assert_eq!(nonces.nonce(6.into(), 101, 10), 6.into()); -} - -/// A submitted transaction that never lands leaves a gap: once the confirmed -/// nonce has not moved for the stall window, signing restarts from it. -#[test] -fn a_dropped_transaction_does_not_wedge_the_nonce() { - let mut nonces = NonceTracker::default(); - nonces.nonce(5.into(), 100, 10); - nonces.submitted(5.into()); // never lands - assert_eq!(nonces.nonce(5.into(), 109, 10), 6.into(), "still waiting"); - assert_eq!(nonces.nonce(5.into(), 110, 10), 5.into(), "gap abandoned"); -} diff --git a/client/rpc/src/relay/tests/mod.rs b/client/rpc/src/relay/tests/mod.rs deleted file mode 100644 index ecac1e67..00000000 --- a/client/rpc/src/relay/tests/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Tests for the relay's admission gate. -//! -//! Split by intent: [`validation`] covers the branches a well-formed request -//! takes, [`adversarial`] covers what a hostile caller can send over the -//! unauthenticated RPC and asserts the relay rejects rather than panics. - -mod adversarial; -mod guard; -mod validation; diff --git a/client/rpc/src/relay/tests/validation.rs b/client/rpc/src/relay/tests/validation.rs deleted file mode 100644 index 5ad9c082..00000000 --- a/client/rpc/src/relay/tests/validation.rs +++ /dev/null @@ -1,432 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Unit tests for the pure validation functions — no runtime, no async. -//! -//! Covers every branch of `validate_relay_calldata` and every fee-floor case. - -use ethereum_types::U256; -use fp_evm::ExitReason; - -use crate::relay::{ - config::*, - operations::{ - commit_relay_calldata, SELECTOR_COMMIT_RELAY, SELECTOR_PRIVATE_TRANSFER, SELECTOR_UNSHIELD, - }, - validation::*, -}; - -/// Build minimal valid calldata for the given selector and fee. -/// -/// Head layout (first 228 bytes; the buffer is padded to the largest op's head): -/// ```text -/// [0..4] selector -/// [4..36] slot 0 — proof offset: 0xE0 (= 7×32 = 224, past all head slots) -/// [36..68] slot 1 — bytes32 zeroes (root) -/// [68..100] slot 2 — bytes32 zeroes (nullifier / nullifiers offset) -/// [100..132] slot 3 — uint32 zeroes (assetId / commitments offset) -/// [132..164] slot 4 — uint256 zeroes (amount / memos offset) -/// [164..196] slot 5 — bytes32 zeroes (recipient / assetId) -/// [196..228] slot 6 — uint256 fee -/// ``` -fn build_calldata(selector: [u8; 4], fee_wei: u128) -> Vec { - // unshield's head is 10 slots (4 + 320 = 324 bytes); privateTransfer's is - // 8 slots (260). Build the larger of the two — extra zero head slots are - // harmless, the fee stays at slot 6 either way. - let mut data = vec![0u8; 324]; - data[..4].copy_from_slice(&selector); - // Proof-bytes offset: 7×32 = 224 = 0xE0 (big-endian U256 → only last byte set) - data[35] = 0xE0; - // Fee at slot 6 = data[196..228] - data[196..228].copy_from_slice(&U256::from(fee_wei).to_big_endian()); - data -} - -// ── Length checks ────────────────────────────────────────────────────── - -#[test] -fn rejects_empty_calldata() { - assert_eq!( - validate_relay_calldata(&[], MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("calldata too short") - ); -} - -#[test] -fn rejects_calldata_227_bytes() { - assert_eq!( - validate_relay_calldata(&[0u8; 227], MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("calldata too short") - ); -} - -#[test] -fn rejects_calldata_that_ends_before_the_fee_slot() { - // 196 bytes stops short of the fee slot (slot 6 ends at 4 + 7 × 32 = 228). - let data = vec![0u8; 196]; - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("calldata too short") - ); -} - -// ── Selector checks ──────────────────────────────────────────────────── - -/// The whitelist re-exports the decoder's constants, so the two cannot -/// disagree. What still needs proving is that those constants are keccak of -/// the signatures the client encodes against — a decoder renamed in lockstep -/// with this file would satisfy equality while rejecting every real call. -#[test] -fn selectors_are_keccak_of_the_abi_signatures() { - let pt = sp_io::hashing::keccak_256( - b"privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)", - ); - let un = sp_io::hashing::keccak_256( - b"unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)", - ); - assert_eq!(pt[..4], SELECTOR_PRIVATE_TRANSFER); - assert_eq!(un[..4], SELECTOR_UNSHIELD); -} - -/// The runtime fallback list must agree with the client fallback list. -#[test] -fn fallback_selectors_contain_both_operations() { - assert!(SELECTORS_FALLBACK.contains(&SELECTOR_UNSHIELD)); - assert!(SELECTORS_FALLBACK.contains(&SELECTOR_PRIVATE_TRANSFER)); -} - -/// privateTransfer calldata shorter than its 8-slot head (260 bytes) is -/// rejected even though it clears the global 228-byte minimum. -#[test] -fn rejects_private_transfer_calldata_between_228_and_260() { - let mut data = build_calldata(SELECTOR_PRIVATE_TRANSFER, MIN_RELAY_FEE_FALLBACK); - data.truncate(259); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("calldata too short") - ); -} - -#[test] -fn rejects_unknown_selector() { - let mut data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK); - data[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("unsupported selector") - ); -} - -#[test] -fn rejects_shield_selector() { - // shield = 0x781442b9 — NOT in relay whitelist - let mut data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK); - data[..4].copy_from_slice(&[0x78, 0x14, 0x42, 0xb9]); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("unsupported selector") - ); -} - -// ── Fee checks ───────────────────────────────────────────────────────── - -#[test] -fn rejects_zero_fee() { - let data = build_calldata(SELECTOR_UNSHIELD, 0); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("fee below minimum") - ); -} - -#[test] -fn rejects_fee_one_wei_below_minimum() { - let data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK - 1); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("fee below minimum") - ); -} - -#[test] -fn rejects_fee_one_wei_below_minimum_private_transfer() { - let data = build_calldata(SELECTOR_PRIVATE_TRANSFER, MIN_RELAY_FEE_FALLBACK - 1); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("fee below minimum") - ); -} - -// ── Valid calldata ───────────────────────────────────────────────────── - -#[test] -fn accepts_unshield_with_exact_minimum_fee() { - let data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -#[test] -fn accepts_private_transfer_with_exact_minimum_fee() { - let data = build_calldata(SELECTOR_PRIVATE_TRANSFER, MIN_RELAY_FEE_FALLBACK); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -#[test] -fn accepts_large_fee() { - let data = build_calldata(SELECTOR_UNSHIELD, u128::MAX); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -#[test] -fn accepts_calldata_longer_than_228_bytes() { - let mut data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK); - // Append tail bytes (proof data and dynamic arrays) - data.extend_from_slice(&[0xaa; 128]); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -// ── Fee is read from the correct position ────────────────────────────── - -#[test] -fn fee_at_slot_5_is_not_read_as_fee() { - // Put a value >= MIN_RELAY_FEE_FALLBACK in slot 5 (data[164..196]) but zero in slot 6 - let mut data = build_calldata(SELECTOR_UNSHIELD, 0); - // Overwrite slot 5 with MIN_RELAY_FEE_FALLBACK (this is recipient in unshield — NOT the fee) - data[164..196].copy_from_slice(&U256::from(MIN_RELAY_FEE_FALLBACK).to_big_endian()); - // Fee (slot 6, data[196..228]) is still zero → should reject - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("fee below minimum") - ); -} - -#[test] -fn fee_at_slot_6_is_correctly_read() { - // Slot 5 = zero, slot 6 = MIN_RELAY_FEE_FALLBACK → should accept - let data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -// ── Calldata size upper bound ────────────────────────────────────────── - -#[test] -fn rejects_calldata_above_max_size() { - let mut data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK); - data.resize(MAX_CALLDATA_BYTES + 1, 0u8); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Err("calldata too large") - ); -} - -#[test] -fn accepts_calldata_at_exact_max_size() { - let mut data = build_calldata(SELECTOR_UNSHIELD, MIN_RELAY_FEE_FALLBACK); - data.resize(MAX_CALLDATA_BYTES, 0u8); - assert_eq!( - validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -// ── compute_effective_min_fee ────────────────────────────────────────── - -/// At zero base_fee the governance floor must win. -#[test] -fn effective_min_fee_zero_base_fee_returns_governance_floor() { - assert_eq!( - compute_effective_min_fee(MIN_RELAY_FEE_FALLBACK, 0), - MIN_RELAY_FEE_FALLBACK - ); -} - -/// At 10 gwei the 2× gas floor (40_000_000_000_000_000) must beat governance (0.001 ORB). -#[test] -fn effective_min_fee_gas_floor_dominates_at_10_gwei() { - let base_fee = 10_000_000_000u128; // 10 gwei - let expected = (RELAY_GAS_LIMIT as u128) * 2 * base_fee; - assert!( - expected > MIN_RELAY_FEE_FALLBACK, - "test precondition: gas floor must exceed governance" - ); - assert_eq!( - compute_effective_min_fee(MIN_RELAY_FEE_FALLBACK, base_fee), - expected - ); -} - -/// At 1 wei base_fee the gas floor (4_000_000) is far below governance (0.001 ORB). -#[test] -fn effective_min_fee_governance_floor_dominates_at_negligible_base_fee() { - let base_fee = 1u128; - let gas_floor = (RELAY_GAS_LIMIT as u128) * 2 * base_fee; - assert!( - MIN_RELAY_FEE_FALLBACK > gas_floor, - "test precondition: governance must exceed gas floor" - ); - assert_eq!( - compute_effective_min_fee(MIN_RELAY_FEE_FALLBACK, base_fee), - MIN_RELAY_FEE_FALLBACK - ); -} - -/// The crossover point is at base_fee = governance / (gas_limit × 2) = 250_000_000 (0.25 gwei). -/// Below this threshold governance wins; at or above, the gas floor wins. -#[test] -fn effective_min_fee_crossover_at_250_mwei() { - // threshold = 1_000_000_000_000_000 / (2_000_000 × 2) = 250_000_000 - let threshold = MIN_RELAY_FEE_FALLBACK / (RELAY_GAS_LIMIT as u128 * 2); - // At threshold: 2×gas == governance, max returns governance. - let at = compute_effective_min_fee(MIN_RELAY_FEE_FALLBACK, threshold); - // One wei above: 2×gas > governance. - let above = compute_effective_min_fee(MIN_RELAY_FEE_FALLBACK, threshold + 1); - assert_eq!(at, MIN_RELAY_FEE_FALLBACK); - assert!(above > MIN_RELAY_FEE_FALLBACK); -} - -/// A governance floor higher than the gas floor must win regardless of base_fee. -#[test] -fn effective_min_fee_custom_high_governance_beats_gas_floor() { - let governance = 100_000_000_000_000_000u128; // 0.1 ORB - let base_fee = 1_000_000_000u128; // 1 gwei - let gas_floor = (RELAY_GAS_LIMIT as u128) * 2 * base_fee; - assert!( - governance > gas_floor, - "test precondition: governance must exceed gas floor" - ); - assert_eq!(compute_effective_min_fee(governance, base_fee), governance); -} - -/// Saturating arithmetic must not panic on u128::MAX inputs. -#[test] -fn effective_min_fee_saturates_without_panic() { - let result = compute_effective_min_fee(u128::MAX, u128::MAX); - assert_eq!(result, u128::MAX); -} - -/// The calldata fee must be validated against `effective_min_fee`, not raw `min_fee_planck`. -/// Simulates the scenario where the gas floor is the active minimum. -#[test] -fn validate_calldata_rejects_fee_below_gas_floor_even_above_governance() { - let governance = MIN_RELAY_FEE_FALLBACK; - let base_fee = 10_000_000_000u128; // 10 gwei - let gas_floor = (RELAY_GAS_LIMIT as u128) * 2 * base_fee; // 40_000_000_000_000_000 - assert!(gas_floor > governance); - let effective = compute_effective_min_fee(governance, base_fee); - // A fee that clears governance but not the gas floor is rejected. - let below_gas_floor = gas_floor - 1; - let data = build_calldata(SELECTOR_UNSHIELD, below_gas_floor); - assert_eq!( - validate_relay_calldata(&data, effective, &SELECTORS_FALLBACK), - Err("fee below minimum") - ); -} - -/// Fee that exactly equals the gas floor (when it dominates) must be accepted. -#[test] -fn validate_calldata_accepts_exact_gas_floor() { - let governance = MIN_RELAY_FEE_FALLBACK; - let base_fee = 10_000_000_000u128; // 10 gwei - let effective = compute_effective_min_fee(governance, base_fee); - let data = build_calldata(SELECTOR_UNSHIELD, effective); - assert_eq!( - validate_relay_calldata(&data, effective, &SELECTORS_FALLBACK), - Ok(()) - ); -} - -// ── check_dry_run_exit ───────────────────────────────────────────────── - -/// A successful EVM execution must pass the dry-run check. -#[test] -fn dry_run_exit_succeed_returns_ok() { - use evm::ExitSucceed; - assert!(check_dry_run_exit(&ExitReason::Succeed(ExitSucceed::Returned)).is_ok()); - assert!(check_dry_run_exit(&ExitReason::Succeed(ExitSucceed::Stopped)).is_ok()); - assert!(check_dry_run_exit(&ExitReason::Succeed(ExitSucceed::Suicided)).is_ok()); -} - -/// A revert (invalid ZK proof, double-spend nullifier, etc.) must be rejected. -#[test] -fn dry_run_exit_revert_returns_err() { - use evm::ExitRevert; - let result = check_dry_run_exit(&ExitReason::Revert(ExitRevert::Reverted)); - assert!(result.is_err()); - let msg = result.unwrap_err(); - assert!( - msg.contains("calldata would fail on-chain"), - "expected 'calldata would fail on-chain' in: {msg}" - ); - assert!(msg.contains("Revert"), "expected 'Revert' in: {msg}"); -} - -/// An EVM error (OutOfGas, etc.) must be rejected. -#[test] -fn dry_run_exit_error_out_of_gas_returns_err() { - use evm::ExitError; - let result = check_dry_run_exit(&ExitReason::Error(ExitError::OutOfGas)); - assert!(result.is_err()); - let msg = result.unwrap_err(); - assert!(msg.contains("calldata would fail on-chain"), "{msg}"); - assert!(msg.contains("OutOfGas"), "expected 'OutOfGas' in: {msg}"); -} - -/// A call-too-deep EVM error (stack overflow) must be rejected. -#[test] -fn dry_run_exit_error_call_too_deep_returns_err() { - use evm::ExitError; - let result = check_dry_run_exit(&ExitReason::Error(ExitError::CallTooDeep)); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("CallTooDeep")); -} - -/// A fatal EVM error must be rejected. -#[test] -fn dry_run_exit_fatal_returns_err() { - use evm::ExitFatal; - let result = check_dry_run_exit(&ExitReason::Fatal(ExitFatal::NotSupported)); - assert!(result.is_err()); - let msg = result.unwrap_err(); - assert!(msg.contains("calldata would fail on-chain"), "{msg}"); - assert!(msg.contains("Fatal"), "expected 'Fatal' in: {msg}"); -} - -/// The error message must embed the exit reason so callers can surface it to users. -#[test] -fn dry_run_exit_error_message_includes_reason() { - use evm::ExitError; - let reason = ExitReason::Error(ExitError::OutOfFund); - let err = check_dry_run_exit(&reason).unwrap_err(); - // The message should contain both the prefix and the specific variant. - assert!(err.starts_with("calldata would fail on-chain:"), "{err}"); - assert!(err.contains("OutOfFund"), "{err}"); -} - -// ─── commitRelay calldata ───────────────────────────────────────────────────── - -#[test] -fn commit_relay_calldata_is_abi_encoded() { - use ethereum_types::H256; - let commits = [H256::repeat_byte(0x11), H256::repeat_byte(0x22)]; - let data = commit_relay_calldata(&commits); - assert_eq!(data[..4], SELECTOR_COMMIT_RELAY); - assert_eq!(U256::from_big_endian(&data[4..36]), U256::from(32)); - assert_eq!(U256::from_big_endian(&data[36..68]), U256::from(2)); - assert_eq!(&data[68..100], commits[0].as_bytes()); - assert_eq!(&data[100..132], commits[1].as_bytes()); - assert_eq!(data.len(), 132); -} diff --git a/client/rpc/src/relay/types.rs b/client/rpc/src/relay/types.rs deleted file mode 100644 index 7afe10d8..00000000 --- a/client/rpc/src/relay/types.rs +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! RPC trait definition and response types for the Orbinum relay. - -use ethereum_types::{H160, H256}; -use fc_rpc_core::types::Bytes; -use jsonrpsee::{core::RpcResult, proc_macros::rpc}; -use serde::{Deserialize, Serialize}; - -#[rpc(server)] -pub trait OrbinumRelayApi { - /// Relay a shielded-pool call (unshield or privateTransfer) on behalf of a user. - /// - /// `calldata` must be ABI-encoded EVM calldata for the ShieldedPool precompile, - /// including the 4-byte selector. Its fee must reach the effective minimum: - /// the governance fee or twice the relay's gas cost, whichever is higher. - /// - /// Returns the Ethereum transaction hash. - #[method(name = "orbinum_relayShieldedCall")] - async fn relay_shielded_call(&self, calldata: Bytes) -> RpcResult; - - /// Returns the relay status: EVM address, minimum fee, current balance, and whether - /// the relay has sufficient funds to process at least one transaction. - #[method(name = "orbinum_relayerStatus")] - async fn relayer_status(&self) -> RpcResult; -} - -/// Relay status returned by `orbinum_relayerStatus`. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RelayerStatus { - pub address: H160, - pub min_fee: String, - /// Current EVM balance of the relay wallet (wei). Lets callers verify the relay is funded. - pub balance_wei: String, - /// True when the relay can relay: registered (on a runtime with relay - /// commits) and funded for the worst case of a commit plus a spend. - pub enabled: bool, - /// True when this relay's EVM address is registered on-chain via `register_relayer`. - /// Relaying needs it: only a registered address can record the relay commit - /// that earns a spend's fee. - pub is_registered: bool, -} diff --git a/client/rpc/src/relay/validation.rs b/client/rpc/src/relay/validation.rs deleted file mode 100644 index 7a803a51..00000000 --- a/client/rpc/src/relay/validation.rs +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0 - -//! Pure calldata validation — no runtime, no async, no I/O. -//! -//! This is the relay's admission gate: everything it can decide about a request -//! by looking at the bytes alone. Anything needing chain state (the dry run, the -//! nonce, the signature) lives in [`super::rpc`], and the limits these functions -//! enforce live in [`super::config`]. -//! -//! Being free of those dependencies is what makes the hostile cases in -//! `tests/adversarial.rs` cheap to write: no node, no async runtime, just bytes. - -use fp_evm::ExitReason; - -use super::{ - config::{MAX_CALLDATA_BYTES, RELAY_GAS_LIMIT}, - operations::default_operations, -}; - -/// Computes the effective minimum fee the user must include in their calldata. -/// -/// The relay must earn at least twice what it spends on EVM gas, so the floor is: -/// `2 × RELAY_GAS_LIMIT × base_fee_per_gas` -/// -/// The governance-set `min_fee_planck` is the absolute lower bound; the 2× gas floor -/// applies on top whenever network gas prices are high enough to make it exceed governance. -/// Both values are in planck (= wei in Orbinum's 1:1 mapping). -pub(crate) fn compute_effective_min_fee(min_fee_planck: u128, base_fee_wei: u128) -> u128 { - let two_x_gas_floor = (RELAY_GAS_LIMIT as u128) - .saturating_mul(2) - .saturating_mul(base_fee_wei); - min_fee_planck.max(two_x_gas_floor) -} - -/// Validates relay calldata against a runtime-provided minimum fee and selector whitelist. -/// -/// `min_fee_wei` — from `ShieldedPoolRuntimeApi::relay_config().min_fee_planck` -/// (or `MIN_RELAY_FEE_FALLBACK` if the API is unavailable). -/// `allowed_selectors` — from `relay_config().allowed_selectors`. -/// -/// Both `unshield` and `privateTransfer` agree up to slot 6, which is all this -/// function reads: -/// ```text -/// bytes [0..4] selector -/// bytes [4..36] slot 0 — offset pointer for proof (bytes/dynamic) -/// bytes [36..68] slot 1 — bytes32 root / bytes32[] roots offset -/// bytes [68..100] slot 2 — bytes32 nullifier / bytes32[] nullifiers offset -/// bytes [100..132] slot 3 — uint32 asset_id / bytes32[] commits offset -/// bytes [132..164] slot 4 — uint256 amount / bytes[] memos offset -/// bytes [164..196] slot 5 — bytes32 recipient / uint32 asset_id -/// bytes [196..228] slot 6 — uint256 fee ← checked here -/// ``` -/// Past slot 6 the layouts diverge, so the 228-byte minimum above is only a -/// cheap first gate: each operation declares its own `min_calldata_len()` -/// (unshield 324, privateTransfer 260), checked after selector dispatch. -pub(crate) fn validate_relay_calldata( - data: &[u8], - min_fee_wei: u128, - allowed_selectors: &[[u8; 4]], -) -> Result<(), &'static str> { - // Global minimum: selector (4 B) + head slots 0–6 (7 × 32 B) = 228 B. - if data.len() < 228 { - return Err("calldata too short"); - } - if data.len() > MAX_CALLDATA_BYTES { - return Err("calldata too large"); - } - - let selector: [u8; 4] = data[..4].try_into().unwrap(); - if !allowed_selectors.contains(&selector) { - return Err("unsupported selector"); - } - - // Dispatch to the registered RelayableOperation for per-op fee extraction. - // If the governance whitelist contains a selector the node does not implement, - // the relay rejects it — requiring a node update to support new operations. - let op = default_operations() - .into_iter() - .find(|op| op.selector() == selector) - .ok_or("unsupported selector")?; - - log::trace!(target: "orbinum-relay", "validating {} calldata ({} bytes)", op.name(), data.len()); - - if data.len() < op.min_calldata_len() { - return Err("calldata too short"); - } - - if op.extract_fee(data) < min_fee_wei { - return Err("fee below minimum"); - } - - Ok(()) -} - -/// Interprets the `exit_reason` from an EVM dry-run and returns whether the call -/// would succeed on-chain. -/// -/// Returns `Ok(())` only when `exit_reason` is `ExitReason::Succeed(_)`. -/// All other outcomes (revert, error, fatal) return `Err` with a human-readable -/// description so callers can surface it to the user without paying gas. -pub(crate) fn check_dry_run_exit(exit_reason: &ExitReason) -> Result<(), String> { - match exit_reason { - ExitReason::Succeed(_) => Ok(()), - other => Err(format!("calldata would fail on-chain: {other:?}")), - } -} diff --git a/client/rpc/src/signer.rs b/client/rpc/src/signer.rs index 8fef6fae..80ba8c5e 100644 --- a/client/rpc/src/signer.rs +++ b/client/rpc/src/signer.rs @@ -197,229 +197,3 @@ impl EthSigner for EthDevSigner { transaction.ok_or_else(|| internal_err("signer not available")) } } - -/// A signer that uses a single externally-configured key (e.g. from `--evm-relayer-key`). -pub struct EthValidatorSigner { - key: libsecp256k1::SecretKey, -} - -impl EthValidatorSigner { - /// Parse a 32-byte hex-encoded private key (with or without `0x` prefix). - pub fn from_hex(hex: &str) -> Result { - let hex = hex.trim_start_matches("0x"); - let bytes = hex::decode(hex).map_err(|e| format!("invalid hex key: {e}"))?; - let key = libsecp256k1::SecretKey::parse_slice(&bytes) - .map_err(|e| format!("invalid secp256k1 key: {e:?}"))?; - Ok(Self { key }) - } - - /// Return the EVM address derived from this key. - pub fn address(&self) -> H160 { - secret_key_address(&self.key) - } -} - -impl EthSigner for EthValidatorSigner { - fn accounts(&self) -> Vec { - vec![secret_key_address(&self.key)] - } - - fn sign( - &self, - message: TransactionMessage, - address: &H160, - ) -> Result { - if &secret_key_address(&self.key) != address { - return Err(internal_err("signer not available")); - } - let secret = &self.key; - let transaction = match message { - TransactionMessage::Legacy(m) => { - let signing_message = libsecp256k1::Message::parse_slice(&m.hash()[..]) - .map_err(|_| internal_err("invalid signing message"))?; - let (signature, recid) = libsecp256k1::sign(&signing_message, secret); - let v = match m.chain_id { - None => 27 + recid.serialize() as u64, - Some(chain_id) => 2 * chain_id + 35 + recid.serialize() as u64, - }; - let rs = signature.serialize(); - let r = H256::from_slice(&rs[0..32]); - let s = H256::from_slice(&rs[32..64]); - EthereumTransaction::Legacy(ethereum::LegacyTransaction { - nonce: m.nonce, - gas_price: m.gas_price, - gas_limit: m.gas_limit, - action: m.action, - value: m.value, - input: m.input, - signature: legacy::TransactionSignature::new(v, r, s) - .ok_or_else(|| internal_err("signer generated invalid signature"))?, - }) - } - TransactionMessage::EIP2930(m) => { - let signing_message = libsecp256k1::Message::parse_slice(&m.hash()[..]) - .map_err(|_| internal_err("invalid signing message"))?; - let (signature, recid) = libsecp256k1::sign(&signing_message, secret); - let rs = signature.serialize(); - let r = H256::from_slice(&rs[0..32]); - let s = H256::from_slice(&rs[32..64]); - EthereumTransaction::EIP2930(ethereum::EIP2930Transaction { - chain_id: m.chain_id, - nonce: m.nonce, - gas_price: m.gas_price, - gas_limit: m.gas_limit, - action: m.action, - value: m.value, - input: m.input.clone(), - access_list: m.access_list, - signature: eip2930::TransactionSignature::new(recid.serialize() != 0, r, s) - .ok_or(internal_err("Invalid transaction signature format"))?, - }) - } - TransactionMessage::EIP1559(m) => { - let signing_message = libsecp256k1::Message::parse_slice(&m.hash()[..]) - .map_err(|_| internal_err("invalid signing message"))?; - let (signature, recid) = libsecp256k1::sign(&signing_message, secret); - let rs = signature.serialize(); - let r = H256::from_slice(&rs[0..32]); - let s = H256::from_slice(&rs[32..64]); - EthereumTransaction::EIP1559(ethereum::EIP1559Transaction { - chain_id: m.chain_id, - nonce: m.nonce, - max_priority_fee_per_gas: m.max_priority_fee_per_gas, - max_fee_per_gas: m.max_fee_per_gas, - gas_limit: m.gas_limit, - action: m.action, - value: m.value, - input: m.input.clone(), - access_list: m.access_list, - signature: eip2930::TransactionSignature::new(recid.serialize() != 0, r, s) - .ok_or(internal_err("Invalid transaction signature format"))?, - }) - } - _ => { - return Err(internal_err( - "unsupported transaction type for relay signer", - )) - } - }; - Ok(transaction) - } -} - -// --------------------------------------------------------------------------- -// Unit tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - /// Private key for the genesis test account. - /// Address: 0x6be02d1d3665660d22ff9624b7be0551ee1ac91b - const GENESIS_KEY: &str = "99b3c12287537e38c90a9219d4cb074a89a16e9cdb20bf85728ebd97c343e342"; - const GENESIS_ADDR: &str = "6be02d1d3665660d22ff9624b7be0551ee1ac91b"; - - fn expected_addr() -> H160 { - GENESIS_ADDR.parse().expect("static hex address is valid") - } - - // ── from_hex ──────────────────────────────────────────────────────────── - - #[test] - fn from_hex_without_prefix_derives_correct_address() { - let signer = EthValidatorSigner::from_hex(GENESIS_KEY).unwrap(); - assert_eq!(signer.address(), expected_addr()); - } - - #[test] - fn from_hex_with_0x_prefix_derives_correct_address() { - let key = format!("0x{GENESIS_KEY}"); - let signer = EthValidatorSigner::from_hex(&key).unwrap(); - assert_eq!(signer.address(), expected_addr()); - } - - #[test] - fn from_hex_uppercase_hex_chars_accepted() { - let upper = GENESIS_KEY.to_uppercase(); - let signer = EthValidatorSigner::from_hex(&upper).unwrap(); - assert_eq!(signer.address(), expected_addr()); - } - - #[test] - fn from_hex_invalid_characters_returns_err() { - assert!( - EthValidatorSigner::from_hex("xyz_not_valid_hex_zzzzzzzzzzzzzzzzzzzzzzzz").is_err() - ); - } - - #[test] - fn from_hex_too_short_returns_err() { - assert!(EthValidatorSigner::from_hex("aabb").is_err()); - } - - #[test] - fn from_hex_all_zeros_rejected_as_invalid_secp256k1_key() { - // The all-zero scalar is not a valid secp256k1 private key - assert!(EthValidatorSigner::from_hex(&"00".repeat(32)).is_err()); - } - - #[test] - fn from_hex_empty_returns_err() { - assert!(EthValidatorSigner::from_hex("").is_err()); - } - - // ── address / accounts ────────────────────────────────────────────────── - - #[test] - fn address_matches_accounts_first_entry() { - let signer = EthValidatorSigner::from_hex(GENESIS_KEY).unwrap(); - let accounts = signer.accounts(); - assert_eq!(accounts.len(), 1); - assert_eq!(accounts[0], signer.address()); - } - - #[test] - fn accounts_returns_exactly_one_entry() { - let signer = EthValidatorSigner::from_hex(GENESIS_KEY).unwrap(); - assert_eq!(signer.accounts().len(), 1); - } - - // ── sign ──────────────────────────────────────────────────────────────── - - #[test] - fn sign_rejects_wrong_address() { - let signer = EthValidatorSigner::from_hex(GENESIS_KEY).unwrap(); - let wrong: H160 = "0000000000000000000000000000000000000001".parse().unwrap(); - let msg = TransactionMessage::EIP1559(ethereum::EIP1559TransactionMessage { - chain_id: 42, - nonce: ethereum_types::U256::zero(), - max_priority_fee_per_gas: ethereum_types::U256::zero(), - max_fee_per_gas: ethereum_types::U256::from(10_000_000_000u64), - gas_limit: ethereum_types::U256::from(21_000u64), - action: ethereum::TransactionAction::Call(wrong), - value: ethereum_types::U256::zero(), - input: vec![], - access_list: vec![], - }); - assert!(signer.sign(msg, &wrong).is_err()); - } - - #[test] - fn sign_succeeds_for_correct_address() { - let signer = EthValidatorSigner::from_hex(GENESIS_KEY).unwrap(); - let addr = signer.address(); - let msg = TransactionMessage::EIP1559(ethereum::EIP1559TransactionMessage { - chain_id: 42, - nonce: ethereum_types::U256::zero(), - max_priority_fee_per_gas: ethereum_types::U256::zero(), - max_fee_per_gas: ethereum_types::U256::from(10_000_000_000u64), - gas_limit: ethereum_types::U256::from(21_000u64), - action: ethereum::TransactionAction::Call(addr), - value: ethereum_types::U256::zero(), - input: vec![], - access_list: vec![], - }); - assert!(signer.sign(msg, &addr).is_ok()); - } -} diff --git a/docs/BENCHMARKING_STEPS.md b/docs/BENCHMARKING_STEPS.md index 455455fe..d1609b78 100644 --- a/docs/BENCHMARKING_STEPS.md +++ b/docs/BENCHMARKING_STEPS.md @@ -82,7 +82,7 @@ This overwrites the four committed `weights.rs` files: |--------|--------| | `pallet_zk_verifier` | `frame/zk-verifier/src/weights.rs` | | `pallet_shielded_pool` | `frame/shielded-pool/src/weights.rs` | -| `pallet_relayer` | `frame/relayer/src/weights.rs` | +| `pallet_validator_set` | `frame/validator-set/src/weights.rs` | Auxiliary weights for standard Substrate pallets go to a scratch dir and are not committed. diff --git a/frame/ismp-messaging/src/lib.rs b/frame/ismp-messaging/src/lib.rs index 6386fd59..a364a511 100644 --- a/frame/ismp-messaging/src/lib.rs +++ b/frame/ismp-messaging/src/lib.rs @@ -180,7 +180,7 @@ pub mod pallet { /// /// Adjustable rather than constant because the right price tracks what ORB is worth, /// and a constant in the WASM cannot: correcting it would otherwise cost a build and a - /// `setCode`. Same reasoning, and the same shape, as `pallet_relayer`'s `MinRelayFee`. + /// `setCode`. #[pallet::storage] pub type MessageFee = StorageValue<_, ::Balance, ValueQuery, DefaultMessageFeeValue>; diff --git a/frame/relayer/CHANGELOG.md b/frame/relayer/CHANGELOG.md deleted file mode 100644 index 2ae76a18..00000000 --- a/frame/relayer/CHANGELOG.md +++ /dev/null @@ -1,327 +0,0 @@ -# Changelog - -All notable changes to `pallet-relayer` will be documented in this file. - -## [0.6.0] - 2026-09-29 - -Relay commits: a spend's fee is credited to the relayer that committed to it in an -earlier block, whoever submits the spend. **Breaking** — `Config` gains -`CommitTtl` and `MaxCommitsPerRelayerPerBlock`, `RelayerInterface` gains two -methods, and the runtime API moves to version 2. - -### Added - -#### Relay commits -A relayer records `relay_commit_hash(op_hash, its H160)` — domain-separated -blake2 over the spend's identity and its address — before submitting. -`take_committed_relayer(op_hash)` returns the registered relayer with the -earliest commit from a **previous** block and consumes every commit for that -spend; `record_relay_commits` writes them for a registered address, keeping the -earlier block of a commit already present. - -The commit stores only its block, never who wrote it: the credited relayer is -the one inside the preimage, so writing someone else's commit first only -credits them. Commits from the spending block are ignored, so an author cannot -commit after seeing a spend and include both. - -- Storage `RelayCommits` (commit → `RelayCommit { recorded_at, expires_at }`) - and `CommitsByRelayer` ((expiry block, validator account) → commits), pruned in - `on_initialize` at the expiry block. Keyed by expiry, so a `CommitTtl` change - applies to new commits and leaves no orphans. -- The quota is per validator per block, so one relayer cannot lock the others - out, and rotating the relay address does not buy a fresh one. A batch is all - or nothing, only new commits (deduplicated) count, and a batch with nothing - new writes no index entry and emits no event. -- Event `RelayCommitted` (counts new commits only), error `TooManyCommits` (both - appended). -- A same-block tie goes to the lowest commit hash, which changes per spend, - instead of the registry's storage order, which an operator can grind by - choosing its address. -- `integrity_test` requires `CommitTtl ≥ 2` (0 never prunes, 1 prunes before use). -- Runtime API `relay_commit_block(commit) -> Option`, `#[api_version(2)]`. -- `WeightInfo::prune_relay_commits(r, n)` — `r` index entries drained, `n` - commits pruned, so an idle block is not charged for a full registry. - **Provisional**, to be regenerated on the reference machine. - -### Internal - -- Module layout and test sections reorganised; mock helpers deduplicated. Comments in present tense. - -## [0.5.0] - 2026-08-21 - -Two guards on relay configuration. **Breaking** — `Config::MaxMinRelayFee` is a -new required associated type, so any runtime implementing `Config` must add it. - -### Changed - -#### `set_min_relay_fee` is bounded -`ManageOrigin` could previously set the minimum relay fee to any `u128`. A value -large enough would make the EVM relay unusable: every shielded call would fail -`FeeTooLow`, and the node's relay would reject the calldata before it reached the -pool — with no way back until the next runtime upgrade, since the call that would -lower it again has to run on the broken runtime. - -New `Config::MaxMinRelayFee` (1 ORB in the runtime, a thousand times the default) -and error `MinRelayFeeTooHigh`. This is not a defence against an attacker — -governance is already trusted — but against a typo in a governance call. - -#### `get_active_relayers` is capped -The runtime API collected the whole `RelayerRegistry` with no bound, and a -runtime API runs inside its caller's block or RPC budget. - -Capped at 256 entries via `.take()`. Nothing can hit that today: the method has -no callers — the RPC trait exposes only the three O(1) methods — and -registration is gated on the validator set, so the registry cannot exceed -`MaxValidators`. The cap is headroom for whoever wires this up later, when -neither of those may still hold. - -## [0.4.0] - 2026-08-20 - -EVM relay registration becomes self-service, gated on validator-set membership -and on a signature proving the caller holds the EVM key. **Breaking** — -`register_relayer` changes both its origin and its signature, so -`transaction_version` must move. Requires `pallet-validator-set` 0.3.0 for the -`ValidatorSetInterface` bound. - -### Changed - -#### `register_relayer` is now self-service -The call drops its `who` parameter and moves from `ManageOrigin` to `Signed`: an -operator registers their own EVM relay address rather than asking sudo to do it. -Since the owner is the signer, no account can bind an address on another's -behalf. - -`ManageOrigin` did two jobs: it kept arbitrary accounts out, and it let -governance verify the operator actually owned the address. The new -`ValidatorSet` gate — only accounts in the approved validator set may register — -replaces the first. The second needed the ownership proof below; the gate alone -would have left a rival's public address claimable. The `AlreadyRegistered` and -`AccountAlreadyRegistered` guards are unchanged. - -**Breaking:** `register_relayer(who, evm_address)` becomes -`register_relayer(evm_address, signature)`, signed by the owner. Callers using -the sudo path must invert the order: sudo approves the validator first, then the -operator registers, supplying the proof from `relayer_getRelayInfo`. - -### Added - -#### Proof of control over the EVM address -`register_relayer` now takes a second argument: a secp256k1 signature over a -digest binding the domain tag, the chain's genesis hash, the registering -`AccountId` and the address being claimed. - -Without it the account-set gate alone was not enough. A relay address is -**public** — it is the `caller` of every relay transaction — and the registry is -first-come-first-served with no override. Any approved validator could therefore -register a rival's address, divert its relay fees, and lock the rightful owner -out permanently via `AlreadyRegistered`. The old `ManageOrigin` design blocked -this because governance reviewed each pair; the validator-set gate replaced only the -anti-Sybil half of that review, not the ownership half. The signature replaces -the other half. - -The genesis hash makes a signature non-replayable across chains, and binding the -`AccountId` stops a signature observed on-chain from being reused by a different -account. No nonce is needed: a given `(account, address)` pair can only be -registered once. - -`relayer_getRelayInfo` produces the signature from the node's `evmr` key, so -operators never assemble the digest by hand. See `evm_proof.rs`. - -- `Config::ValidatorSet` — gate for `register_relayer`, bound to - `pallet_validator_set::ValidatorSetInterface`. Its `()` impl denies everything - on purpose: a permissive default would silently disable the gate in a runtime - that forgot to wire it. (Previously a `ValidatorCheck` trait defined here; it - now lives in `pallet-validator-set` as a provider trait, matching how - `RelayerInterface` is defined by this pallet and consumed by shielded-pool.) -- `Error::NotValidator`, `Error::BadEvmSignature`, `Error::InvalidEvmAddress`. -- Rejection of the zero address and the reserved precompile range - (`0x0..=0xffff`) — those "callers" originate inside the runtime, so no key - could sign for them. -- `Pallet::clear_relayer(who)` — infallible, idempotent removal of both registry - indices. The runtime calls it when an account leaves the validator set, so a - relay binding cannot outlive the membership that authorised it. Emits - `RelayerUnregistered`, so that event now has two sources. - -### Node-side change (breaks running validators) - -The node no longer derives its EVM relay key from the Aura mnemonic. It reads a -key of type `evmr` from the keystore instead, so the operator chooses their EVM -identity independently of their consensus identity — which is what makes the -freely-chosen address in `register_relayer` meaningful. - -`evm_key_from_keystore` in `template/node/src/service.rs` now scans for `evmr` -rather than `aura`, and `template/node/src/relayer_register.rs` is deleted: it -derived the address from the Aura key and told the operator to ask sudo, both of -which are now wrong. Its startup log is replaced by a line reporting the relay -address, or a warning when no key is present. - -**Every running validator is affected.** Without an `evmr` key the node still -authors blocks — only relaying stops. To keep the EVM address already registered -on-chain, recover the previously-derived key with -`scripts/vk/derive-legacy-evm-key.cjs` and insert it with `author_insertKey`. - -### Migration - -**None.** The new rules — registration requires validator-set membership, leaving -the set clears the binding — do not reach backwards: a binding created under the -old sudo-gated call could belong to any account, and would keep resolving in the -fee path forever. - -That gap is empty on this network. The testnet runs 5 validators, all -operator-owned, all in the approved set, so every existing binding already -satisfies the invariant. Same argument as the `PendingValidators` check and the -0.2.0 bond removal: verified rather than assumed. - -**A chain where that does not hold needs a migration** that iterates -`RelayerByAccount` and calls `clear_relayer` for any holder outside -`ApprovedValidators`. Verify before upgrading: - -``` -api.query.relayer.relayerByAccount.entries() // every holder -api.query.validatorSet.approvedValidators() // must be a superset -``` - -### Internal - -Structure only, no behaviour change: - -- **`evm_proof.rs` owns the whole proof scheme.** The genesis-hash lookup moved - out of the pallet, and the EVM address derivation — which had drifted into - three copies across the pallet, the node RPC and the test helper — is now the - single `evm_address_from_uncompressed`. Three copies of a five-line crypto - expression is how two of them silently stop agreeing. -- **`is_usable_relay_address`** replaces the inline address policy in `lib.rs`; - the pallet keeps only the translation to `Error::InvalidEvmAddress`. -- **`benchmarking_support` renamed to `test_signing`** — 17 of its 18 callers are - tests, so the old name advertised the minority case and its own doc comment had - to correct it. -- **`registry_tests.rs` split** (563 lines) into `registry_tests`, - `ownership_proof_tests` and `cleanup_tests`, matching the one-concern-per-file - shape the other test modules already had. -- Dead code removed: the unused `ValidatorSetInterface` re-export, the `addr` - mock module (hardcoded addresses can no longer be registered), and a - single-use benchmark helper. - -Node side: - -- **`template/node/src/evm_relay_key.rs`** now owns the question of which key - this node relays with — keystore scan, dev-key refusal, dev-mode fallback and - the startup report. `service.rs` calls `resolve()` and `report_identity()`; - it drops from 982 to 822 lines and no longer knows the keystore file format. - The module carries 10 tests of its own, exercising a real temp keystore: a - missing directory, an empty one, an `aura`-only entry, a dev key (refused), a - real mnemonic (loaded deterministically) and a corrupt file (skipped, not - fatal). None of that had test coverage before. -- **Dead hex parsing deleted from `rpc/relayer_author.rs`** — `parse_h160` and - `hex_nibble` had no callers anywhere in the repo, yet carried 12 tests between - them: 165 of the file's 303 lines testing something unreachable, while - `get_relay_info` had none. - -### Notes -- `PendingRelayerFees` is deliberately left untouched by `clear_relayer`. Those - fees were already earned and stay claimable through `claim_shielded_fees`; - clearing a binding is not confiscation. -- `RelayerRegistry` and `RelayerByAccount` keep their types, so existing entries - survive the upgrade without migration. They do not survive the account later - being removed from the validator set — that now clears the binding. -- The relay fee fallback to the block author is unchanged: an unregistered - relayer still credits the block author rather than failing the transaction. -- `register_relayer`'s weight changed in substance: it loses the root origin - check and gains an `ApprovedValidators` read (r:1 w:0, max_size 1025), now - visible in the regenerated proof size. **Weights were regenerated on a dev - machine (macOS ARM), not the usual benchmark host (AMD EPYC-Genoa, 32 GB) — - rerun `scripts/benchmarks/run_benchmarks.sh` there before release.** -- `scripts/benchmarks/run_benchmarks.sh` gains `pallet_validator_set`, which was registered - in `define_benchmarks!` but missing from the runner, so its weights were never - regenerated. - -## [0.3.1] - 2026-07-09 - -### Changed - -- Weights regenerated on the benchmark host (AMD EPYC-Genoa, 32 GB). No logic - change — refreshed alongside the per-note circuit-version release. - `set_allowed_selectors` no longer scales with its component (`n` → `_n`); the - measured cost did not vary with the selector count. - -## [0.3.0] - 2026-06-03 - -### Changed - -- **`register_relayer` is now sudo/governance-only** (`ManageOrigin`). Previously it was - a self-service call restricted to validator nodes via `T::IsValidator`. The new signature - adds an explicit `who: T::AccountId` argument so the privileged origin can register any - account: - ``` - // before: register_relayer(origin, evm_address) — signed by validator - // after: register_relayer(origin, who, evm_address) — ManageOrigin (sudo/gov) - ``` -- **Benchmark** for `register_relayer` updated to use `#[extrinsic_call]` with - `RawOrigin::Root` instead of the previous manual `#[block]` storage-insert approach. - Measured weights now reflect the real extrinsic dispatch path. -- Event doc comments updated: `RelayerRegistered` and `RelayerUnregistered` now note - sudo/governance as the acting origin. -- Module-level doc updated: registry is "managed exclusively by sudo/governance". - -### Removed - -- `T::IsValidator: Contains` — config trait no longer needed. Validator gating - is handled upstream by `pallet-validator-set`. -- `Error::NotValidator` — removed alongside `IsValidator`. -- `MockValidators` from `mock.rs` — no longer meaningful. - -### Migration - -Update runtime `Config` impl and any `register_relayer` call sites: -- Remove `type IsValidator = ...` from the `pallet_relayer::Config` impl. -- Change origin from a signed validator to `ManageOrigin` (e.g. `sudo`). -- Add `who: AccountId` as the first call argument before `evm_address`. - ---- - -## [0.2.2] - 2026-06-01 - -### Changed - -- Updated FRAME benchmark weights measured on Hetzner CCX33 (AMD EPYC-Milan, 8 vCPU dedicated, 32 GB RAM). Steps: `50`, Repeat: `20`. All extrinsics refreshed. - -## [0.2.1] - 2026-05-22 - -### Changed - -- Updated FRAME benchmark weights measured on Hetzner CCX33 (AMD EPYC-Milan, 8 vCPU dedicated, 32 GB RAM). Steps: `50`, Repeat: `20`. All extrinsics refreshed. - -## [0.2.0] - 2026-05-14 - -### Added -- **`get_active_relayers()`** — new Runtime API method returning all `(evm_address, substrate_account)` - pairs in `RelayerRegistry`. Allows clients and the RPC relay server to discover which validator - nodes have relay active (Capa 2). -- **`is_relayer_evm(evm_address)`** — new Runtime API method returning `bool`. Used by - `orbinum_relayerStatus` to surface on-chain registration state to relay operators (Capa 2). - -### Removed -- **`claim_relay_fees` extrinsic** (call_index 4) — removed. Validators must use - `pallet-shielded-pool::claim_shielded_fees` instead, which inserts a private note into the - Merkle tree and requires a `value_proof` ZK proof. -- Corresponding benchmark (`fn claim_relay_fees`) and weight entry removed. -- Unit tests `claim_relay_fees_works`, `claim_relay_fees_emits_event`, - `claim_relay_fees_insufficient_fails` removed. - -### Changed -- README: updated `claim_shielded_fees` description — ZK proof requirement changed from - *disclosure circuit* to *value_proof circuit* (CircuitId 6). -- README: comparison table updated (`ZK proof required: Yes (value_proof circuit)`). - -## [0.1.0] - 2026-04-? - -### Added -- Initial release: `pallet-relayer` with full relay infrastructure. -- **Storage**: `PendingRelayerFees` (`(AccountId, asset_id) → u128`) and - `RelayerRegistry` (`H160 → AccountId32`). -- **Events**: `RelayFeeAccumulated`, `RelayFeeConsumed`, `RelayerRegistered`, `RelayerUnregistered`. -- **Errors**: `InsufficientPendingFees`, `AlreadyRegistered`, `NotRegistered`. -- **Extrinsics**: `register_relayer`, `unregister_relayer`. -- **`RelayerInterface` trait**: `accumulate_relay_fee`, `pending_relay_fees`, `consume_relay_fee`. -- Runtime API `RelayerRuntimeApi` with initial methods. -- Full benchmark and weight coverage. diff --git a/frame/relayer/Cargo.toml b/frame/relayer/Cargo.toml deleted file mode 100644 index d1818ea5..00000000 --- a/frame/relayer/Cargo.toml +++ /dev/null @@ -1,59 +0,0 @@ -[package] -name = "pallet-relayer" -version = "0.6.0" -description = "On-chain relay configuration, relayer registry and fee accounting." -authors = { workspace = true } -license = "GPL-3.0-or-later" -edition = "2024" -repository = "https://github.com/orbinum/node" -readme = "README.md" - -[package.metadata.docs.rs] -targets = ["x86_64-unknown-linux-gnu"] - -[dependencies] -scale-codec = { workspace = true } -scale-info = { workspace = true } -# Substrate -frame-benchmarking = { workspace = true, optional = true } -frame-support = { workspace = true } -frame-system = { workspace = true } -libsecp256k1 = { workspace = true, optional = true, features = ["static-context", "hmac"] } -pallet-validator-set = { workspace = true } -sp-core = { workspace = true } -sp-io = { workspace = true } -sp-runtime = { workspace = true } -sp-std = { workspace = true } - -[dev-dependencies] -libsecp256k1 = { workspace = true, features = ["std", "static-context", "hmac"] } -pallet-validator-set = { workspace = true, features = ["std"] } -sp-io = { workspace = true, features = ["default"] } - -[features] -default = ["std"] -std = [ - "scale-codec/std", - "scale-info/std", - "frame-benchmarking?/std", - "frame-support/std", - "frame-system/std", - "libsecp256k1?/std", - "pallet-validator-set/std", - "sp-core/std", - "sp-io/std", - "sp-runtime/std", - "sp-std/std", -] -runtime-benchmarks = [ - "dep:libsecp256k1", - "frame-benchmarking/runtime-benchmarks", - "frame-support/runtime-benchmarks", - "frame-system/runtime-benchmarks", - "pallet-validator-set/runtime-benchmarks", -] -try-runtime = [ - "frame-support/try-runtime", - "frame-system/try-runtime", - "pallet-validator-set/try-runtime", -] diff --git a/frame/relayer/README.md b/frame/relayer/README.md deleted file mode 100644 index d9845b1e..00000000 --- a/frame/relayer/README.md +++ /dev/null @@ -1,257 +0,0 @@ -# pallet-relayer - -Centralizes all on-chain relay concerns for Orbinum's gasless EVM relay system. -Other pallets (primarily `pallet-shielded-pool`) delegate to this pallet through the `RelayerInterface` trait instead of managing relay configuration, registry, or fee accounting directly. - ---- - -## Background: what is the relayer? - -Orbinum allows users to execute private operations (`unshield`, `private_transfer`) from an EVM wallet **without holding native tokens** to pay gas. The flow is: - -1. The user builds EVM calldata (ZK proof + embedded fee) and sends it to the node via JSON-RPC (`orbinum_relayShieldedCall`). -2. The node-relayer (`client/rpc/src/relay.rs`) validates the calldata, signs the EVM transaction with its own key, and submits it to the mempool. -3. The EVM precompile converts it into an unsigned Substrate extrinsic that reaches `pallet-shielded-pool`. -4. `pallet-shielded-pool` verifies the ZK proof, executes the operation, and credits the fee through this pallet to the relayer that **committed** to the spend in an earlier block (the block author if none did). - ---- - -## Module layout - -| File | Responsibility | -|------|----------------| -| `src/traits.rs` | `RelayerInterface` — public port consumed by other pallets | -| `src/weights.rs` | `WeightInfo` trait + `SubstrateWeight` (generated by benchmark CLI) | -| `src/benchmarking.rs` | FRAME benchmarks (`runtime-benchmarks` feature) | -| `src/lib.rs` | FRAME pallet: Config, Storage, Events, Errors, Extrinsics, `RelayerInterface` impl | -| `src/mock.rs` | Minimal test runtime (`#[cfg(test)]`) | -| `src/tests/` | Integration tests split by area of concern | - ---- - -## Storage - -### `MinRelayFee` — `StorageValue` -Minimum fee in planck that any relayed operation must include. -Initialized from `T::DefaultMinRelayFee` (set in the runtime). Updatable via `set_min_relay_fee` using `ManageOrigin`. - -### `AllowedSelectors` — `StorageValue>` -Whitelist of EVM ABI selectors accepted by the relay. -Empty = the Runtime API falls back to built-in defaults (`unshield` + `privateTransfer`). -Updatable via `set_allowed_selectors`. - -### `RelayerRegistry` — `StorageMap` -On-chain registry: EVM address → substrate AccountId. -Allows unambiguous fee attribution even when the validator's EVM key differs from its session key. - -### `RelayerByAccount` — `StorageMap` -Reverse index: AccountId → EVM address. -Enables `unregister_relayer` in O(1) without scanning the entire map. - -### `PendingRelayerFees` — `StorageDoubleMap` -Fees accrued per `(validator, asset_id)` in planck. -Incremented by `accumulate_relay_fee` (called from `pallet-shielded-pool`). -Decremented by `consume_relay_fee` when the validator claims their fees. - ---- - -## Extrinsics - -### `set_min_relay_fee(fee: u128)` — origin: `ManageOrigin` -Updates the minimum relay fee. Takes effect immediately for all subsequent operations. -Emits: `MinRelayFeeUpdated { new_fee }`. - -### `set_allowed_selectors(selectors: Vec<[u8; 4]>)` — origin: `ManageOrigin` -Replaces the ABI selector whitelist. Pass `vec![]` to restore the Runtime API defaults. -Bounded by `T::MaxAllowedSelectors`. Emits: `AllowedSelectorsUpdated { count }`. - -### `register_relayer(evm_address: H160, signature)` — origin: `Signed` (active validator) -Binds the caller's EVM relay address. The caller must be in the active validator set and `signature` must prove it holds the EVM key (see `evm_proof`). -Fails with `NotValidator`, `BadEvmSignature`, `AlreadyRegistered` or `AccountAlreadyRegistered`. -Emits: `RelayerRegistered { evm_address, account }`. - -### `unregister_relayer()` — origin: `Signed` -Removes the caller's EVM binding. Clears both forward and reverse maps. -Fails if the caller has no binding (`NotRegistered`). -Emits: `RelayerUnregistered { evm_address, account }`. - -> **Committing and claiming** happen through `pallet-shielded-pool` (`commit_relay`, `claim_relay_fees`), which call into this pallet via `RelayerInterface`. - ---- - -## Relay fee lifecycle - -Each node has **two identities**, linked by `register_relayer`: - -| Identity | Type | Key | Purpose | -|---|---|---|---| -| `AccountId` (sr25519/Aura) | Substrate | Aura key | Accumulates fees in `PendingRelayerFees` | -| `H160` (ECDSA) | EVM | `--evm-relayer-key` | Signs relay transactions and commits, pays gas, receives claimed fees | - -A spend's fee goes to the relayer that **committed** to it in an earlier block, never to whoever submits it. Copying a relayer's spend — or an author including its own copy — therefore credits the original relayer. - -``` -1. commit_relay([relay_commit_hash(op_hash, H160)]) block N [shielded-pool, call 18] - └─ RelayCommits[commit] = { recorded_at: N, expires_at: N + CommitTtl } - (no author stored; the relayer is in the preimage) - -2. unshield(amount=900, fee=100) submitted by anyone block > N - ├─ ZK proof verified - ├─ pool → recipient 900; PoolBalance -= 900 (fee stays in the pool) - └─ take_committed_relayer(op_hash) → AccountId of H160 (else the block author) - └─ PendingRelayerFees[AccountId][0] += 100 - -3. claim_relay_fees(asset_id=0, amount=100) [shielded-pool, call 19] - ├─ consume_relay_fee(claimant, 0, 100) (own balance only) - ├─ pool → mirror(H160) 100 (H160 ++ [0x00; 12]) - └─ PoolBalance -= 100 -``` - -- `op_hash` covers the spend's public inputs and circuit version — not the proof bytes, which can be re-randomised without the witness. -- Commits from the spending block are ignored; the earliest earlier commit wins. -- Commits expire after `CommitTtl` blocks; the quota is `MaxCommitsPerRelayerPerBlock` per relayer. -- The node's relay RPC (`orbinum_relayShieldedCall`) does steps 1–2 automatically. - ---- - -## `RelayerInterface` trait - -Defined in `src/traits.rs`. Other pallets depend on this trait only — never on the concrete `Pallet` type. This decouples `pallet-shielded-pool` from the relay implementation and makes mocking straightforward in tests. - -```rust -pub trait RelayerInterface { - type AccountId: Clone; - - fn min_relay_fee() -> u128; - fn allowed_selectors() -> Vec<[u8; 4]>; - fn block_author() -> Option; - fn resolve_relayer(evm_address: &H160) -> Option; - fn accumulate_relay_fee(author: &Self::AccountId, asset_id: u32, amount: u128); - fn pending_relay_fees(who: &Self::AccountId, asset_id: u32) -> u128; - fn consume_relay_fee(who: &Self::AccountId, asset_id: u32, amount: u128) -> DispatchResult; - fn registered_evm_address(who: &Self::AccountId) -> Option; - fn record_relay_commits(relayer: &H160, commits: &[H256]) -> DispatchResult; - fn take_committed_relayer(op_hash: &[u8; 32]) -> Option; -} -``` - -`registered_evm_address` is the reverse lookup of `resolve_relayer`: given an `AccountId` it returns the registered `H160`. Used by `pallet-shielded-pool::claim_relay_fees` to derive the mirror account that receives the funds. - -In production: `type Relayer = pallet_relayer::Pallet`. -In `pallet-shielded-pool` unit tests: a lightweight mock struct in `mock.rs` backed by `sp_io::storage`. - ---- - -## Validator registration flow - -`register_relayer` is self-service, but gated: only an account already in the -approved validator set may bind an EVM address, and the signer is always the -owner. The flow is: - -1. **Insert an EVM relay key** into the node keystore. You choose it — it is - independent of the Aura key, so your consensus identity does not dictate your - EVM identity: - ```bash - curl -s -X POST http://localhost:9944 -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"author_insertKey", - "params":["evmr","",""]}' - ``` -2. **Restart the node.** It logs the address it will sign relay transactions - with: - ``` - EVM relay address: 0xd43593c7… — register it with relayer.registerRelayer(evmAddress) - ``` - Without an `evmr` key the node warns and relaying stays disabled; block - production is unaffected. -3. **Register session keys and get added to the set:** - ``` - session → setKeys(aura + grandpa keys, proof) - validatorSet → addValidator(who) // sudo; fails with NoSessionKeys otherwise - ``` -4. **The validator registers its own address**, proving it holds the key: - ``` - relayer_getRelayInfo // node returns address + signature - relayer → registerRelayer(evmAddress, signature) - ``` - Signed by the validator. Fails with `NotValidator` outside the set, - `BadEvmSignature` if the proof does not match, `InvalidEvmAddress` for the - zero address or the reserved precompile range. - - The proof matters because a relay address is public — it is the `caller` of - every relay transaction. Without it, any approved validator could register a - rival's address, divert its fees, and lock the owner out permanently, since - `AlreadyRegistered` has no override. - -Until step 4 completes the node cannot record relay commits, so its relay RPC -refuses to relay: a spend without a commit would pay the block author. - -Leaving the validator set (`removeValidator` or `deregisterValidator`) clears the -binding, since the membership that authorised it is gone. Accrued -`PendingRelayerFees` are untouched and stay claimable. - ---- - -## Runtime integration - -```rust -// template/runtime/src/lib.rs - -impl pallet_relayer::Config for Runtime { - type BlockAuthor = RelayerBlockAuthor; // wraps pallet_authorship - type DefaultMinRelayFee = ConstU128<1_000_000_000_000_000>; // 1e15 planck = 0.001 ORB - type ManageOrigin = EnsureRoot; // sudo/governance only - type MaxAllowedSelectors = ConstU32<16>; - type WeightInfo = pallet_relayer::weights::SubstrateWeight; - // Note: IsValidator removed in v0.3.0 — validator gating is handled by pallet-validator-set -} - -impl pallet_shielded_pool::Config for Runtime { - // ... - type Relayer = pallet_relayer::Pallet; -} -``` - ---- - -## Benchmarks and weights - -Weights in `src/weights.rs` were generated with Substrate Benchmark CLI v49.1.0 -(2026-04-14, steps=50, repeat=20, WASM execution: Compiled): - -| Extrinsic | Ref time | Proof size | -|-----------|----------|------------| -| `set_min_relay_fee` | ~6 ms | 1.5 KB | -| `set_allowed_selectors(n)` | linear in `n` | — | -| `register_relayer` | ~10 ms | 2.5 KB | -| `unregister_relayer` | ~10 ms | 2.5 KB | - -To regenerate after modifying the pallet: - -```bash -cargo build --release --features runtime-benchmarks - -./target/release/orbinum-node benchmark pallet \ - --chain=dev \ - --pallet=pallet_relayer \ - --extrinsic='*' \ - --steps=50 \ - --repeat=20 \ - --output=frame/relayer/src/weights.rs \ - --template=./scripts/benchmarks/frame-weight-template.hbs -``` - ---- - -## Tests - -``` -src/tests/ -├── config_tests.rs — MinRelayFee and AllowedSelectors (governance gating, storage, events, capacity limits) -├── registry_tests.rs — register/unregister + registered_evm_address (duplicate guard, reverse index, sudo-only, reverse lookup) -└── dispatch_info_tests.rs — call dispatch info (Pays::Yes, DispatchClass::Normal for register_relayer) - fees_tests.rs — accumulate, pending query, consume, claim (saturation, insufficient, events, per-account isolation) -``` - -```bash -cargo test -p pallet-relayer -``` diff --git a/frame/relayer/rpc/Cargo.toml b/frame/relayer/rpc/Cargo.toml deleted file mode 100644 index 9c4811c2..00000000 --- a/frame/relayer/rpc/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "pallet-relayer-rpc" -version = "0.1.0" -authors = ["Orbinum Team"] -edition = "2024" -license = "GPL-3.0-or-later" - -[dependencies] -hex = "0.4" -jsonrpsee = { version = "0.24.9", features = ["server", "macros", "client"] } -pallet-relayer-runtime-api = { path = "../runtime-api" } -serde = { version = "1.0", features = ["derive"] } -sp-api = { version = "43.0.0" } -sp-blockchain = { version = "46.0.0" } -sp-core = { version = "43.0.0" } -sp-runtime = { version = "48.0.0" } diff --git a/frame/relayer/rpc/README.md b/frame/relayer/rpc/README.md deleted file mode 100644 index d3bd2330..00000000 --- a/frame/relayer/rpc/README.md +++ /dev/null @@ -1,85 +0,0 @@ -# pallet-relayer-rpc - -JSON-RPC server for querying pallet-relayer state from external clients (wallets, indexers, dashboards). - -## Exposed Endpoints - -### `relayer_isRelayer` - -Returns `true` if the given SS58 address is a registered relayer. - -```json -// Request -{ "method": "relayer_isRelayer", "params": ["5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"] } - -// Response -{ "result": true } -``` - ---- - -### `relayer_pendingFees` - -Returns unclaimed fees in planck (as a decimal string) for a given account and asset. - -```json -// Request -{ "method": "relayer_pendingFees", "params": ["5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", 0] } - -// Response — string to preserve u128 precision -{ "result": "1500000000000000000" } -``` - ---- - -### `relayer_registeredEvmAddress` - -Returns the EVM address (0x-prefixed hex) associated with the relayer, or `null` if none is registered. - -```json -// Request -{ "method": "relayer_registeredEvmAddress", "params": ["5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"] } - -// Response -{ "result": "0xd43593c715fdd31c61141abd04a99fd6822c8558" } -``` - -## Node Integration - -### `template/node/src/rpc/mod.rs` - -```rust -use pallet_relayer_rpc::{Relayer, RelayerApiServer}; - -// In create_full, add to the where clause: -// C::Api: pallet_relayer_runtime_api::RelayerRuntimeApi, - -io.merge(Relayer::new(client.clone()).into_rpc())?; -``` - -### `template/node/src/service.rs` - -The bound must also be propagated to `new_full`: - -```rust -where - RA::RuntimeApi: pallet_relayer_runtime_api::RelayerRuntimeApi, - // ... other bounds -``` - -## Parameters - -- `account` — SS58 address as a string. Decoded internally to `AccountId32`. -- `asset_id` — Numeric asset identifier (u32). `0` = native ORB. - -## Errors - -| Code | Description | -|---|---| -| `1` | Invalid SS58 address or runtime error. | - -## Dependencies - -- `jsonrpsee 0.24.9` -- `pallet-relayer-runtime-api` (sibling crate) -- `sp-api`, `sp-blockchain`, `sp-core`, `sp-runtime` (polkadot-sdk `stable2506`) diff --git a/frame/relayer/rpc/src/lib.rs b/frame/relayer/rpc/src/lib.rs deleted file mode 100644 index 48daa6a9..00000000 --- a/frame/relayer/rpc/src/lib.rs +++ /dev/null @@ -1,164 +0,0 @@ -use jsonrpsee::{core::RpcResult, proc_macros::rpc, types::ErrorObjectOwned}; -use pallet_relayer_runtime_api::RelayerRuntimeApi; -use sp_api::ProvideRuntimeApi; -use sp_blockchain::HeaderBackend; -use sp_core::crypto::Ss58Codec; -use sp_runtime::{AccountId32, traits::Block as BlockT}; -use std::sync::Arc; - -#[rpc(client, server)] -pub trait RelayerApi { - /// Returns true if the given SS58 address is a registered relayer. - #[method(name = "relayer_isRelayer")] - fn is_relayer(&self, account: String) -> RpcResult; - - /// Returns the pending fees (as decimal string) for the given account and asset. - #[method(name = "relayer_pendingFees")] - fn pending_fees(&self, account: String, asset_id: u32) -> RpcResult; - - /// Returns the registered EVM address (0x-prefixed hex) for the relayer, if any. - #[method(name = "relayer_registeredEvmAddress")] - fn registered_evm_address(&self, account: String) -> RpcResult>; -} - -pub struct Relayer { - client: Arc, - _marker: std::marker::PhantomData, -} - -impl Relayer { - pub fn new(client: Arc) -> Self { - Self { - client, - _marker: Default::default(), - } - } -} - -fn parse_account(s: &str) -> Result { - AccountId32::from_ss58check(s) - .map_err(|e| ErrorObjectOwned::owned(1, format!("Invalid SS58 address: {e}"), None::<()>)) -} - -impl RelayerApiServer for Relayer -where - C: ProvideRuntimeApi + HeaderBackend + 'static, - C::Api: RelayerRuntimeApi, - B: BlockT, -{ - fn is_relayer(&self, account: String) -> RpcResult { - let account_id = parse_account(&account)?; - let api = self.client.runtime_api(); - let best = self.client.info().best_hash; - api.is_relayer(best, account_id) - .map_err(|e| ErrorObjectOwned::owned(1, format!("Runtime error: {e}"), None::<()>)) - } - - fn pending_fees(&self, account: String, asset_id: u32) -> RpcResult { - let account_id = parse_account(&account)?; - let api = self.client.runtime_api(); - let best = self.client.info().best_hash; - let amount = api - .pending_fees(best, account_id, asset_id) - .map_err(|e| ErrorObjectOwned::owned(1, format!("Runtime error: {e}"), None::<()>))?; - Ok(amount.to_string()) - } - - fn registered_evm_address(&self, account: String) -> RpcResult> { - let account_id = parse_account(&account)?; - let api = self.client.runtime_api(); - let best = self.client.info().best_hash; - let maybe_addr = api - .registered_evm_address(best, account_id) - .map_err(|e| ErrorObjectOwned::owned(1, format!("Runtime error: {e}"), None::<()>))?; - Ok(maybe_addr.map(|bytes| format!("0x{}", hex::encode(bytes)))) - } -} - -#[cfg(test)] -mod tests { - use super::parse_account; - - // Alice's well-known SS58 address on network 42 (Substrate default). - const ALICE_SS58: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; - - // ── parse_account ──────────────────────────────────────────────────────── - - #[test] - fn parse_account_accepts_valid_ss58() { - assert!(parse_account(ALICE_SS58).is_ok()); - } - - #[test] - fn parse_account_rejects_invalid_string() { - assert!(parse_account("not-a-valid-ss58").is_err()); - } - - #[test] - fn parse_account_rejects_empty_string() { - assert!(parse_account("").is_err()); - } - - #[test] - fn parse_account_error_code_is_1() { - let err = parse_account("bad-input").unwrap_err(); - assert_eq!(err.code(), 1); - } - - #[test] - fn parse_account_roundtrip_matches_known_bytes() { - use sp_core::crypto::Ss58Codec; - use sp_runtime::AccountId32; - - // Parse Alice's address and re-encode to verify round-trip consistency. - let account = parse_account(ALICE_SS58).unwrap(); - let re_encoded = AccountId32::to_ss58check(&account); - assert_eq!(re_encoded, ALICE_SS58); - } - - // ── registered_evm_address formatting ─────────────────────────────────── - - #[test] - fn evm_address_formats_as_0x_lowercase_hex() { - let bytes: [u8; 20] = [ - 0xd4, 0x35, 0x93, 0xc7, 0x15, 0xfd, 0xd3, 0x1c, 0x61, 0x14, 0x1a, 0xbd, 0x04, 0xa9, - 0x9f, 0xd6, 0x82, 0x2c, 0x85, 0x58, - ]; - let formatted = format!("0x{}", hex::encode(bytes)); - assert_eq!(formatted, "0xd43593c715fdd31c61141abd04a99fd6822c8558"); - } - - #[test] - fn evm_address_all_zeros_formats_correctly() { - let bytes = [0u8; 20]; - let formatted = format!("0x{}", hex::encode(bytes)); - assert_eq!(formatted, "0x0000000000000000000000000000000000000000"); - } - - #[test] - fn evm_address_all_ff_formats_correctly() { - let bytes = [0xffu8; 20]; - let formatted = format!("0x{}", hex::encode(bytes)); - assert_eq!(formatted, "0xffffffffffffffffffffffffffffffffffffffff"); - } - - // ── pending_fees serialization ─────────────────────────────────────────── - - #[test] - fn pending_fees_zero_formats_as_string() { - assert_eq!((0u128).to_string(), "0"); - } - - #[test] - fn pending_fees_one_orb_formats_correctly() { - // 1 ORB = 10^18 planck. - let one_orb: u128 = 1_000_000_000_000_000_000; - assert_eq!(one_orb.to_string(), "1000000000000000000"); - } - - #[test] - fn pending_fees_u128_max_does_not_overflow() { - let s = u128::MAX.to_string(); - assert_eq!(s, "340282366920938463463374607431768211455"); - } -} diff --git a/frame/relayer/runtime-api/Cargo.toml b/frame/relayer/runtime-api/Cargo.toml deleted file mode 100644 index 460d1a25..00000000 --- a/frame/relayer/runtime-api/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "pallet-relayer-runtime-api" -version = "0.2.0" -description = "Runtime API for pallet-relayer: query relayer status and pending fees." -authors = { workspace = true } -license = "GPL-3.0-or-later" -edition = "2021" -repository = { workspace = true } - -[package.metadata.docs.rs] -targets = ["x86_64-unknown-linux-gnu"] - -[dependencies] -scale-codec = { workspace = true } -scale-info = { workspace = true } -sp-api = { workspace = true } -sp-core = { workspace = true } -sp-runtime = { workspace = true } -sp-std = { workspace = true } - -[features] -default = ["std"] -std = [ - "scale-codec/std", - "scale-info/std", - "sp-api/std", - "sp-core/std", - "sp-runtime/std", - "sp-std/std", -] diff --git a/frame/relayer/runtime-api/README.md b/frame/relayer/runtime-api/README.md deleted file mode 100644 index e5ed265f..00000000 --- a/frame/relayer/runtime-api/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# pallet-relayer-runtime-api - -Substrate Runtime API for querying pallet-relayer state from outside the runtime (node, RPC, clients). - -## Exposed Methods - -| Method | Parameters | Return | Description | -|---|---|---|---| -| `is_relayer` | `account: AccountId32` | `bool` | Returns whether the account is a registered active relayer. | -| `pending_fees` | `account: AccountId32`, `asset_id: u32` | `u128` | Unclaimed fees in planck for the given asset. | -| `registered_evm_address` | `account: AccountId32` | `Option<[u8; 20]>` | EVM address (H160) associated with the relayer, if any. | - -## Integration - -### Runtime (`template/runtime/src/lib.rs`) - -```rust -impl pallet_relayer_runtime_api::RelayerRuntimeApi for Runtime { - fn is_relayer(account: AccountId32) -> bool { - RelayerByAccount::::contains_key(&account) - } - fn pending_fees(account: AccountId32, asset_id: u32) -> u128 { - PendingRelayerFees::::get(&account, asset_id) - } - fn registered_evm_address(account: AccountId32) -> Option<[u8; 20]> { - RelayerByAccount::::get(&account).map(|h| h.0) - } -} -``` - -### Runtime Cargo.toml - -```toml -[dependencies] -pallet-relayer-runtime-api = { workspace = true } - -[features] -std = [ - "pallet-relayer-runtime-api/std", - # ... -] -``` - -## Technical Notes - -- Compatible with `#![no_std]`. Requires `extern crate alloc` to satisfy the `decl_runtime_apis!` macro expansion. -- Trait version: `1`. Breaking changes must increment the version and keep a legacy impl. -- Fees are always expressed in planck (1 ORB = 10¹⁸ planck). diff --git a/frame/relayer/runtime-api/src/lib.rs b/frame/relayer/runtime-api/src/lib.rs deleted file mode 100644 index 0d7d49a8..00000000 --- a/frame/relayer/runtime-api/src/lib.rs +++ /dev/null @@ -1,147 +0,0 @@ -#![cfg_attr(not(feature = "std"), no_std)] - -extern crate alloc; - -sp_api::decl_runtime_apis! { - /// Runtime API for querying relayer status and pending fees. - #[api_version(2)] - pub trait RelayerRuntimeApi { - /// Returns true if the given account is a registered relayer. - fn is_relayer(account: sp_runtime::AccountId32) -> bool; - - /// Returns the pending fees (in planck) for the given account and asset. - fn pending_fees(account: sp_runtime::AccountId32, asset_id: u32) -> u128; - - /// Returns the registered EVM address for the relayer, if any. - fn registered_evm_address(account: sp_runtime::AccountId32) -> Option<[u8; 20]>; - - /// Returns all (evm_address, substrate_account) pairs in the relay registry. - /// - /// Used by clients to discover which validator nodes have relay active. - fn get_active_relayers() -> alloc::vec::Vec<([u8; 20], sp_runtime::AccountId32)>; - - /// Returns true if the given EVM address is registered as a relayer on-chain. - /// - /// Used by `orbinum_relayerStatus` and before recording a relay commit. - fn is_relayer_evm(evm_address: [u8; 20]) -> bool; - - /// Block a relay commit was recorded in, while it is still live. A relayer - /// submits the spend only once this is below the current block. - #[api_version(2)] - fn relay_commit_block(commit: [u8; 32]) -> Option; - } -} - -#[cfg(test)] -mod tests { - use scale_codec::{Decode, Encode}; - use sp_runtime::AccountId32; - - // ── AccountId32 SCALE codec ─────────────────────────────────────────────── - - #[test] - fn account_id32_encode_decode_roundtrip() { - let bytes = [0x42u8; 32]; - let original = AccountId32::from(bytes); - let encoded = original.encode(); - let decoded = AccountId32::decode(&mut &encoded[..]).expect("decode must succeed"); - assert_eq!(original, decoded); - } - - #[test] - fn account_id32_encodes_to_32_bytes() { - let account = AccountId32::from([0u8; 32]); - assert_eq!(account.encode().len(), 32); - } - - #[test] - fn account_id32_all_zeros_roundtrip() { - let account = AccountId32::from([0u8; 32]); - let decoded = AccountId32::decode(&mut &account.encode()[..]).expect("decode must succeed"); - assert_eq!(account, decoded); - } - - #[test] - fn account_id32_all_ff_roundtrip() { - let account = AccountId32::from([0xffu8; 32]); - let decoded = AccountId32::decode(&mut &account.encode()[..]).expect("decode must succeed"); - assert_eq!(account, decoded); - } - - #[test] - fn account_id32_preserves_byte_order() { - let mut bytes = [0u8; 32]; - bytes[0] = 0xaa; - bytes[31] = 0xbb; - let account = AccountId32::from(bytes); - let raw: &[u8; 32] = account.as_ref(); - assert_eq!(raw[0], 0xaa); - assert_eq!(raw[31], 0xbb); - } - - // ── Option<[u8; 20]> SCALE codec (registered_evm_address return type) ─── - - #[test] - fn option_evm_address_some_roundtrip() { - let addr: Option<[u8; 20]> = Some([0xd4u8; 20]); - let decoded = - Option::<[u8; 20]>::decode(&mut &addr.encode()[..]).expect("decode must succeed"); - assert_eq!(addr, decoded); - } - - #[test] - fn option_evm_address_none_roundtrip() { - let addr: Option<[u8; 20]> = None; - let decoded = - Option::<[u8; 20]>::decode(&mut &addr.encode()[..]).expect("decode must succeed"); - assert_eq!(addr, decoded); - } - - #[test] - fn option_evm_address_some_encodes_with_prefix_byte() { - // SCALE: None = 0x00, Some(x) = 0x01 ++ encode(x) - let addr: Option<[u8; 20]> = Some([0u8; 20]); - let encoded = addr.encode(); - assert_eq!(encoded[0], 0x01); - assert_eq!(encoded.len(), 21); - } - - #[test] - fn option_evm_address_none_encodes_to_single_zero_byte() { - let addr: Option<[u8; 20]> = None; - assert_eq!(addr.encode(), vec![0x00]); - } - - // ── u128 SCALE codec (pending_fees return type) ────────────────────────── - - #[test] - fn u128_zero_roundtrip() { - let val: u128 = 0; - let decoded = u128::decode(&mut &val.encode()[..]).expect("decode must succeed"); - assert_eq!(val, decoded); - } - - #[test] - fn u128_one_orb_roundtrip() { - let val: u128 = 1_000_000_000_000_000_000; - let decoded = u128::decode(&mut &val.encode()[..]).expect("decode must succeed"); - assert_eq!(val, decoded); - } - - #[test] - fn u128_max_roundtrip() { - let val = u128::MAX; - let decoded = u128::decode(&mut &val.encode()[..]).expect("decode must succeed"); - assert_eq!(val, decoded); - } - - #[test] - fn u128_encodes_to_16_bytes_little_endian() { - // SCALE uses little-endian fixed-width encoding for primitives. - let val: u128 = 1u128; - let encoded = val.encode(); - assert_eq!(encoded.len(), 16); - assert_eq!(encoded[0], 0x01); - assert!(encoded[1..].iter().all(|&b| b == 0x00)); - } -} diff --git a/frame/relayer/src/benchmarking.rs b/frame/relayer/src/benchmarking.rs deleted file mode 100644 index c2853a37..00000000 --- a/frame/relayer/src/benchmarking.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Benchmarks for pallet-relayer. -//! -//! Run with: -//! ```bash -//! cargo build --release --features runtime-benchmarks -//! ./target/release/orbinum-node benchmark pallet \ -//! --chain=dev \ -//! --pallet=pallet_relayer \ -//! --extrinsic='*' \ -//! --steps=50 \ -//! --repeat=20 \ -//! --output=frame/relayer/src/weights.rs \ -//! --template=./scripts/frame-weight-template.hbs -//! ``` - -use super::*; -use frame_benchmarking::v2::*; -use frame_support::traits::Get; -use frame_system::{RawOrigin, pallet_prelude::BlockNumberFor}; -use pallet_validator_set::ValidatorSetInterface; -use sp_core::H160; - -#[benchmarks] -mod benchmarks { - use super::*; - - // ── set_min_relay_fee ──────────────────────────────────────────────────── - - /// Worst case: single storage write + event deposit. - #[benchmark] - fn set_min_relay_fee() { - #[extrinsic_call] - set_min_relay_fee(RawOrigin::Root, 999_999u128); - - assert_eq!(MinRelayFee::::get(), 999_999u128); - } - - // ── set_allowed_selectors ──────────────────────────────────────────────── - - /// Worst case: full list of `MaxAllowedSelectors` entries. - #[benchmark] - fn set_allowed_selectors(n: Linear<0, { T::MaxAllowedSelectors::get() }>) { - let selectors: sp_std::vec::Vec<[u8; 4]> = (0..n).map(|i| i.to_le_bytes()).collect(); - - #[extrinsic_call] - set_allowed_selectors(RawOrigin::Root, selectors); - - assert_eq!(AllowedSelectors::::get().len() as u32, n); - } - - // ── register_relayer ───────────────────────────────────────────────────── - - /// Worst case: validator-set lookup + two storage writes + event deposit. - /// - /// `register_relayer` is gated on membership of the active validator set, - /// so the caller has to be put there first. - #[benchmark] - fn register_relayer() { - let who: T::AccountId = whitelisted_caller(); - T::ValidatorSet::setup_validator(&who); - // A fixed key whose signature over the binding digest is recomputed here, - // so the benchmark exercises the real ECDSA recovery path. - let (evm, signature) = crate::test_signing::signed_binding::(&who); - - #[extrinsic_call] - register_relayer(RawOrigin::Signed(who.clone()), evm, signature); - - assert_eq!(RelayerRegistry::::get(evm), Some(who)); - } - - // ── unregister_relayer ─────────────────────────────────────────────────── - - /// Worst case: two storage removals + event. - /// Pre-condition: caller has a registered EVM address. - #[benchmark] - fn unregister_relayer() { - let caller: T::AccountId = whitelisted_caller(); - let evm = H160::from_low_u64_be(0xA11CE); - - // Pre-register so unregister has real work to do. - RelayerRegistry::::insert(evm, caller.clone()); - RelayerByAccount::::insert(caller.clone(), evm); - - #[extrinsic_call] - unregister_relayer(RawOrigin::Signed(caller.clone())); - - assert!(!RelayerRegistry::::contains_key(evm)); - assert!(!RelayerByAccount::::contains_key(&caller)); - } - - // ── on_initialize: prune_relay_commits ─────────────────────────────────── - - /// `r` validators with an index entry expiring now, `n` commits spread - /// over them. Ranges are the runtime's `MaxValidators` (32) and 32 × - /// `MaxCommitsPerRelayerPerBlock` (64); a benchmark range must be a literal. - #[benchmark] - fn prune_relay_commits(r: Linear<0, 32>, n: Linear<0, 2048>) { - let recorded_at: BlockNumberFor = 1u32.into(); - let expires_at = recorded_at + T::CommitTtl::get(); - let validators: sp_std::vec::Vec = - (0..r.max(1)).map(|i| account("validator", i, 0)).collect(); - for who in validators.iter().take(r as usize) { - CommitsByRelayer::::insert(expires_at, who, frame_support::BoundedVec::default()); - } - for i in 0..n { - let commit = sp_core::H256::from_low_u64_be(i as u64 + 1); - RelayCommits::::insert( - commit, - RelayCommit { - recorded_at, - expires_at, - }, - ); - let who = &validators[(i % r.max(1)) as usize]; - CommitsByRelayer::::mutate(expires_at, who, |index| { - let _ = index.try_push(commit); - }); - } - - #[block] - { - as frame_support::traits::Hooks>>::on_initialize( - expires_at, - ); - } - - assert_eq!(CommitsByRelayer::::iter_prefix(expires_at).count(), 0); - } - - // ── Benchmark test suite ───────────────────────────────────────────────── - - impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test,); -} diff --git a/frame/relayer/src/evm_proof.rs b/frame/relayer/src/evm_proof.rs deleted file mode 100644 index f722d815..00000000 --- a/frame/relayer/src/evm_proof.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Proof that a substrate account controls an EVM address. -//! -//! `register_relayer` is self-service, so the account-set gate alone would let one -//! approved validator claim another's EVM address — the address is public (it is -//! the `caller` of every relay transaction), and the registry is -//! first-come-first-served. Squatting a rival's address would divert its relay -//! fees and, because `AlreadyRegistered` is permanent, lock the rightful owner -//! out entirely. -//! -//! Requiring a signature from the EVM key closes that: only the holder of the -//! private key can produce one. -//! -//! The signed message binds three things: -//! -//! - a domain tag, so a signature cannot be lifted from another protocol; -//! - the genesis hash, so a signature cannot be replayed onto a different chain; -//! - the registering `AccountId`, so a signature observed on-chain cannot be -//! replayed by a *different* account for the same address. -//! -//! Deliberately absent: a nonce. The `(account, address)` pair can only be -//! registered once — `AccountAlreadyRegistered` and `AlreadyRegistered` reject a -//! second attempt — so a captured signature buys an attacker nothing. -//! -//! ## Contents -//! -//! Everything the scheme needs lives here, so it can be audited in one file: -//! -//! | Item | Role | -//! |------|------| -//! | [`binding_digest`] | the exact bytes a claimant must sign | -//! | [`genesis_hash`] | the chain-binding input to that digest | -//! | [`recover_evm_address`] | who signed it | -//! | [`evm_address_from_uncompressed`] | the one place a key becomes an address | -//! | [`is_usable_relay_address`] | which addresses may be claimed at all | -//! -//! The pallet contributes only the policy wrapper: it turns a `false` from -//! [`is_usable_relay_address`] into `Error::InvalidEvmAddress` and a mismatched -//! recovery into `Error::BadEvmSignature`. - -use frame_support::pallet_prelude::*; -use sp_core::{H160, H256}; -use sp_io::hashing::keccak_256; - -/// Domain separator. Any change invalidates every previously issued signature. -pub const BINDING_DOMAIN: &[u8] = b"orbinum-relayer-bind:v1:"; - -/// A 65-byte recoverable secp256k1 signature (`r || s || v`), as produced by -/// `personal_sign` / `eth_sign` and by `EthValidatorSigner`. -#[derive(Clone, Encode, Decode, DecodeWithMemTracking, PartialEq, Eq, TypeInfo)] -pub struct EvmSignature(pub [u8; 65]); - -impl core::fmt::Debug for EvmSignature { - fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { - // Signatures are public data, but full bytes add nothing to a log line. - write!(f, "EvmSignature(0x{:02x}{:02x}…)", self.0[0], self.0[1]) - } -} - -/// The genesis hash, which binds a signature to one chain. -/// -/// Lives here rather than in the pallet because it is an input to the digest, -/// and the whole scheme is easier to audit when every part of it is in one file. -pub fn genesis_hash() -> H256 { - let genesis = frame_system::BlockHash::::get(>::from(0u32)); - H256::from_slice(genesis.as_ref()) -} - -/// Build the 32-byte digest the EVM key must sign. -/// -/// Wrapped in the EIP-191 `personal_sign` envelope so an operator can produce it -/// with any standard wallet, and so the bytes can never be mistaken for a -/// serialized Ethereum transaction. -pub fn binding_digest(account: &[u8], evm_address: &H160, genesis: &H256) -> [u8; 32] { - let mut payload = sp_std::vec::Vec::with_capacity( - BINDING_DOMAIN.len() + 1 + account.len() + H160::len_bytes() + H256::len_bytes(), - ); - payload.extend_from_slice(BINDING_DOMAIN); - payload.extend_from_slice(genesis.as_bytes()); - // Length-prefix the account: it is the only variable-length field, and - // without this a runtime whose AccountId is not 32 bytes could produce the - // same payload from two different (account, address) pairs. - payload.push(account.len() as u8); - payload.extend_from_slice(account); - payload.extend_from_slice(evm_address.as_bytes()); - - // EIP-191: "\x19Ethereum Signed Message:\n" || len(payload) || payload - let mut prefixed = sp_std::vec::Vec::new(); - prefixed.extend_from_slice(b"\x19Ethereum Signed Message:\n"); - prefixed.extend_from_slice(int_to_ascii(payload.len()).as_slice()); - prefixed.extend_from_slice(&payload); - keccak_256(&prefixed) -} - -/// Decimal ASCII for the EIP-191 length field, without `alloc::format!`. -fn int_to_ascii(mut n: usize) -> sp_std::vec::Vec { - if n == 0 { - return sp_std::vec![b'0']; - } - let mut out = sp_std::vec::Vec::new(); - while n > 0 { - out.push(b'0' + (n % 10) as u8); - n /= 10; - } - out.reverse(); - out -} - -/// Highest address in the range reserved for runtime-provided contracts. -/// -/// Frontier places precompiles at low addresses (`0x1`..`0x5` for the Ethereum -/// standard set, `0x400`+ and `0x800`+ for chain-specific ones). Treating the -/// whole `0x0..=0xffff` block as reserved is deliberately broader than the set -/// actually in use, so adding a precompile later cannot collide with an address -/// somebody already registered. -const RESERVED_ADDRESS_CEILING: u64 = 0xffff; - -/// Whether an address can legitimately identify a relayer. -/// -/// Rejects the zero address and the reserved precompile range: those are never -/// the `caller` of a real EVM transaction, because their "calls" originate inside -/// the runtime rather than from a key anybody holds. Neither could produce a -/// valid ownership proof either — checking up front turns a puzzling signature -/// failure into a clear one. -pub fn is_usable_relay_address(evm_address: &H160) -> bool { - let bytes = evm_address.to_fixed_bytes(); - let high_bytes_clear = bytes[..12].iter().all(|b| *b == 0); - if !high_bytes_clear { - return true; - } - let low = u64::from_be_bytes( - bytes[12..20] - .try_into() - .expect("H160 is 20 bytes; slice [12..20] is 8; qed"), - ); - low > RESERVED_ADDRESS_CEILING -} - -/// Recover the signer of `digest` and return its EVM address. -/// -/// Returns `None` when the signature is malformed or recovery fails. -pub fn recover_evm_address(signature: &EvmSignature, digest: &[u8; 32]) -> Option { - // secp256k1_ecdsa_recover wants v in {0,1}; wallets emit 27/28. - let mut sig = signature.0; - match sig[64] { - 27 | 28 => sig[64] -= 27, - 0 | 1 => {} - _ => return None, - } - - let pubkey = sp_io::crypto::secp256k1_ecdsa_recover(&sig, digest).ok()?; - Some(evm_address_from_uncompressed(&pubkey)) -} - -/// The Ethereum address of a secp256k1 public key: the low 20 bytes of the -/// keccak hash of its 64-byte uncompressed form. -/// -/// `pubkey` must be those 64 bytes **without** the `0x04` tag that SEC1 -/// serialisation prepends — which is exactly what `secp256k1_ecdsa_recover` -/// returns. Callers holding a tagged 65-byte key pass `&tagged[1..]`. -/// -/// Every derivation of an EVM address from a key goes through here — the -/// pallet, the node RPC and the test helper alike. Separate copies could -/// diverge, and that would only surface as a signature that mysteriously fails -/// to verify. -pub fn evm_address_from_uncompressed(pubkey: &[u8; 64]) -> H160 { - H160::from_slice(&keccak_256(pubkey)[12..]) -} - -#[cfg(test)] -mod tests { - extern crate alloc; - - use super::*; - /// Reference vector, reproduced independently in JavaScript by the operator - /// tooling. If this value changes, every previously issued signature stops - /// verifying — so a change must be deliberate and paired with a new domain - /// tag in [`BINDING_DOMAIN`]. - const REFERENCE_DIGEST_HEX: &str = - "188f4aff785e76ccb05f7c733a0fcf92937ab6aa792a7590d301d8fc36dbd469"; - - /// The same digest signed by `@noble/curves` with key `0xb0..b0`, pinning - /// interoperability with non-Rust signers. - const REFERENCE_SIGNATURE_HEX: &str = "b6715dae2294272a89df32dc7bb42aa76acafa7ac812231bc2d51f7e015476ef\ - 0b7ddd1349e42b4929468bfebff107cbf09a164c53a0b6d06eb130b5322b3f2d00"; - - /// The EVM address of that key. - const REFERENCE_ADDRESS_HEX: &str = "af295d3c842bc1145e818d7fef2c929726625620"; - - fn from_hex(s: &str) -> [u8; N] { - let clean: sp_std::vec::Vec = s.chars().filter(|c| !c.is_whitespace()).collect(); - let mut out = [0u8; N]; - for (i, byte) in out.iter_mut().enumerate() { - let hi = clean[i * 2].to_digit(16).unwrap() as u8; - let lo = clean[i * 2 + 1].to_digit(16).unwrap() as u8; - *byte = (hi << 4) | lo; - } - out - } - - /// Pins the digest against an independent implementation. The operator's - /// tooling reproduces these bytes in JavaScript; if this value changes, every - /// previously issued signature stops verifying, so a change must be - /// deliberate and paired with a new domain tag. - #[test] - fn digest_matches_the_reference_vector() { - let account: [u8; 32] = - from_hex("8eaf04151687736326c9fea17e25fc5287613693c912909cb226aa4794f26a48"); - let digest = binding_digest(&account, &H160::repeat_byte(0xbb), &H256::zero()); - assert_eq!(digest, from_hex::<32>(REFERENCE_DIGEST_HEX)); - } - - /// An account of a different length must not be able to collide with the - /// reference, which is what the length prefix buys. - #[test] - fn accounts_of_different_lengths_cannot_collide() { - let short = [0xaau8; 4]; - let long = [0xaau8; 8]; - assert_ne!( - binding_digest(&short, &H160::zero(), &H256::zero()), - binding_digest(&long, &H160::zero(), &H256::zero()), - ); - } - - /// Recovery must interoperate with non-Rust signers: this signature was - /// produced by @noble/curves over the digest above. - #[test] - fn recovers_an_externally_produced_signature() { - let digest: [u8; 32] = from_hex(REFERENCE_DIGEST_HEX); - let sig: [u8; 65] = from_hex(REFERENCE_SIGNATURE_HEX); - assert_eq!( - recover_evm_address(&EvmSignature(sig), &digest), - Some(H160(from_hex(REFERENCE_ADDRESS_HEX))), - ); - } - - #[test] - fn a_wallet_style_recovery_id_is_normalised() { - let digest: [u8; 32] = from_hex(REFERENCE_DIGEST_HEX); - let mut sig: [u8; 65] = from_hex(REFERENCE_SIGNATURE_HEX); - sig[64] += 27; // 0 -> 27, the form wallets emit - assert_eq!( - recover_evm_address(&EvmSignature(sig), &digest), - Some(H160(from_hex(REFERENCE_ADDRESS_HEX))), - ); - } - - #[test] - fn an_invalid_recovery_id_is_rejected_without_panicking() { - let digest = [0u8; 32]; - let mut sig = [0u8; 65]; - sig[64] = 200; - assert_eq!(recover_evm_address(&EvmSignature(sig), &digest), None); - } - - // ── is_usable_relay_address ────────────────────────────────────────────── - - #[test] - fn the_zero_address_is_not_usable() { - assert!(!is_usable_relay_address(&H160::zero())); - } - - #[test] - fn the_reserved_precompile_range_is_not_usable() { - // Boundaries and a sample of the addresses actually in use. - for raw in [1u64, 5, 0x400, 0x801, 0x802, RESERVED_ADDRESS_CEILING] { - assert!( - !is_usable_relay_address(&H160::from_low_u64_be(raw)), - "{raw:#x} must be rejected", - ); - } - } - - #[test] - fn the_first_address_past_the_reserved_range_is_usable() { - assert!(is_usable_relay_address(&H160::from_low_u64_be( - RESERVED_ADDRESS_CEILING + 1 - ))); - } - - #[test] - fn a_high_address_whose_low_bytes_look_reserved_is_usable() { - // Only the whole 20-byte value is reserved, not its low 8 bytes: a real - // address ending in 0x…01 must not be mistaken for precompile 0x1. - let mut bytes = [0u8; 20]; - bytes[0] = 0x01; - bytes[19] = 0x01; - assert!(is_usable_relay_address(&H160(bytes))); - } - - #[test] - fn a_realistic_address_is_usable() { - assert!(is_usable_relay_address(&H160(from_hex( - REFERENCE_ADDRESS_HEX - )))); - } - - // ── evm_address_from_uncompressed ──────────────────────────────────────── - - #[test] - fn address_derivation_takes_the_low_20_bytes_of_the_keccak_hash() { - let pubkey = [0x11u8; 64]; - let expected = &sp_io::hashing::keccak_256(&pubkey)[12..]; - assert_eq!(evm_address_from_uncompressed(&pubkey).as_bytes(), expected,); - } - - #[test] - fn address_derivation_agrees_with_recovery() { - // The two paths that produce an address must never disagree, or a proof - // would verify against an address the signer does not think it owns. - let digest: [u8; 32] = from_hex(REFERENCE_DIGEST_HEX); - let sig: [u8; 65] = from_hex(REFERENCE_SIGNATURE_HEX); - let recovered = recover_evm_address(&EvmSignature(sig), &digest).unwrap(); - assert_eq!(recovered, H160(from_hex(REFERENCE_ADDRESS_HEX))); - } - - // ── EIP-191 length encoding ────────────────────────────────────────────── - - #[test] - fn int_to_ascii_matches_decimal_formatting() { - for n in [0usize, 1, 9, 10, 99, 100, 108, 255, 1000] { - assert_eq!( - int_to_ascii(n), - alloc::format!("{n}").into_bytes(), - "decimal encoding of {n}", - ); - } - } - - // ── binding_digest ─────────────────────────────────────────────────────── - - #[test] - fn the_digest_is_bound_to_account_address_and_chain() { - let base: [u8; 32] = [1u8; 32]; - let addr = H160::repeat_byte(0xaa); - let genesis = H256::repeat_byte(0x11); - let reference = binding_digest(&base, &addr, &genesis); - - // Changing any one input must change the digest, or a signature could be - // replayed across accounts, addresses or chains. - assert_ne!(reference, binding_digest(&[2u8; 32], &addr, &genesis)); - assert_ne!( - reference, - binding_digest(&base, &H160::repeat_byte(0xab), &genesis) - ); - assert_ne!( - reference, - binding_digest(&base, &addr, &H256::repeat_byte(0x12)) - ); - } -} diff --git a/frame/relayer/src/lib.rs b/frame/relayer/src/lib.rs deleted file mode 100644 index 816f5268..00000000 --- a/frame/relayer/src/lib.rs +++ /dev/null @@ -1,610 +0,0 @@ -#![cfg_attr(not(feature = "std"), no_std)] - -//! # Pallet Relayer -//! -//! Centralizes all on-chain relay concerns: -//! -//! - **Configuration**: `MinRelayFee` and `AllowedSelectors` — updatable by -//! governance, consumed by the node-native EVM relay via -//! `ShieldedPoolRuntimeApi::relay_config()`. -//! - **Registry**: EVM address → AccountId binding so fee attribution is -//! unambiguous even when the EVM and substrate keys differ. -//! Self-service: an approved validator registers its own address via -//! `register_relayer`, and the binding is cleared when it leaves the set. -//! - **Relay commits**: a relayer records `relay_commit_hash(op_hash, its H160)` -//! at least one block before submitting a spend. The fee of that spend is then -//! credited to the committed relayer no matter who submits it, so copying a -//! relayer's transaction does not redirect its fee. -//! - **Fee accounting**: `PendingRelayerFees` tracks accrued relay fees per -//! (AccountId, asset_id). Other pallets (pallet-shielded-pool) call -//! `T::Relayer::accumulate_relay_fee()` and `T::Relayer::consume_relay_fee()` -//! via the [`RelayerInterface`] trait instead of touching storage directly. -//! -//! ## Module layout -//! -//! | File | Responsibility | -//! |------|----------------| -//! | `lib.rs` | FRAME pallet: Config, Storage, Events, Errors, Extrinsics, relay commits | -//! | `traits.rs` | `RelayerInterface` — public port consumed by other pallets | -//! | `evm_proof.rs` | Proof of EVM key control checked by `register_relayer` | -//! | `weights.rs` | `WeightInfo` trait + `SubstrateWeight` + unit (`()`) impl | -//! | `benchmarking.rs` | FRAME benchmarks (`runtime-benchmarks` feature) | -//! | `test_signing.rs` | Real ownership proofs for tests and benchmarks | -//! | `mock.rs` | Test runtime (`#[cfg(test)]`) | -//! | `tests/` | Integration tests split by concern | -//! -//! ## Integration with pallet-shielded-pool -//! -//! ```text -//! pallet-shielded-pool::Config { -//! type Relayer: pallet_relayer::RelayerInterface; -//! } -//! // In runtime: -//! type Relayer = pallet_relayer::Pallet; -//! ``` - -pub mod evm_proof; -#[cfg(any(test, feature = "runtime-benchmarks"))] -pub mod test_signing; -pub mod traits; -pub mod weights; - -#[cfg(feature = "runtime-benchmarks")] -mod benchmarking; -#[cfg(test)] -mod mock; -#[cfg(test)] -mod tests; - -pub use evm_proof::EvmSignature; -pub use pallet::*; -pub use traits::RelayerInterface; -pub use weights::WeightInfo; - -/// Domain tag of [`relay_commit_hash`], so a commit can never collide with -/// another hash the protocol computes over the same bytes. -pub const RELAY_COMMIT_DOMAIN: &[u8] = b"orbinum/relay-commit"; - -/// The commit a relayer records before submitting the spend identified by -/// `op_hash`. -/// -/// The relayer's address is inside the preimage, so the relayer credited is -/// derived from the hash itself: whoever writes a copy of this commit to storage -/// only credits `relayer`. Without knowing `op_hash` — which stays private -/// until the spend is broadcast — nobody can compute a commit for themselves. -pub fn relay_commit_hash(op_hash: &[u8; 32], relayer: &sp_core::H160) -> sp_core::H256 { - let mut preimage = sp_std::vec::Vec::with_capacity(RELAY_COMMIT_DOMAIN.len() + 32 + 20); - preimage.extend_from_slice(RELAY_COMMIT_DOMAIN); - preimage.extend_from_slice(op_hash); - preimage.extend_from_slice(relayer.as_bytes()); - sp_core::H256(sp_io::hashing::blake2_256(&preimage)) -} - -// ───────────────────────────────────────────────────────────────────────────── -// FRAME pallet -// ───────────────────────────────────────────────────────────────────────────── - -#[frame_support::pallet] -pub mod pallet { - use super::{ - RelayerInterface, WeightInfo, - evm_proof::{self, EvmSignature}, - relay_commit_hash, - }; - use frame_support::{dispatch::DispatchResult, pallet_prelude::*}; - use frame_system::pallet_prelude::*; - use pallet_validator_set::ValidatorSetInterface; - use sp_core::{H160, H256}; - use sp_runtime::Saturating; - use sp_std::vec::Vec; - - // ── Config ──────────────────────────────────────────────────────────────── - - #[pallet::config] - pub trait Config: frame_system::Config>> { - /// Provides the current block author (Aura / BABE author). - type BlockAuthor: Get>; - - /// Initial value for `MinRelayFee` storage. - /// Overridable at runtime by `set_min_relay_fee` (governance/sudo). - #[pallet::constant] - type DefaultMinRelayFee: Get; - - /// Ceiling for `set_min_relay_fee`. - /// - /// Without it `ManageOrigin` can set `u128::MAX`, which bricks EVM relay - /// outright: every shielded call would fail `FeeTooLow`, and the node - /// relay would reject the calldata before it ever reached the pool. That - /// is a governance foot-gun, not an attack — but a single mistyped - /// extrinsic should not take relaying down until a runtime upgrade. - #[pallet::constant] - type MaxMinRelayFee: Get; - - /// Origin allowed to update relay configuration (fee, selectors). - /// Use `EnsureRoot` for testnets; a governance pallet for mainnet. - type ManageOrigin: EnsureOrigin; - - /// Maximum number of ABI selectors in the whitelist. - #[pallet::constant] - type MaxAllowedSelectors: Get; - - /// Gate for `register_relayer`: only accounts in the active validator - /// set may bind an EVM relay address. - type ValidatorSet: ValidatorSetInterface; - - /// Blocks a relay commit lives. A commit recorded in block `N` credits - /// spends included in blocks `N + 1 ..= N + CommitTtl - 1` and is pruned - /// at `N + CommitTtl`. A change applies to commits recorded afterwards. - /// - /// Short on purpose: a relayer that commits and never submits keeps the - /// fee of that exact spend reserved to itself until this runs out. - #[pallet::constant] - type CommitTtl: Get>; - - /// Commits a single relayer may record per block. Per relayer, not global, - /// so one relayer cannot fill the quota and lock the others out. - #[pallet::constant] - type MaxCommitsPerRelayerPerBlock: Get; - - type WeightInfo: WeightInfo; - } - - // ── Default values ──────────────────────────────────────────────────────── - - #[pallet::type_value] - pub fn DefaultMinRelayFeeValue() -> u128 { - T::DefaultMinRelayFee::get() - } - - // ── Types ───────────────────────────────────────────────────────────────── - - /// A stored relay commit. - #[derive( - Clone, - Copy, - PartialEq, - Eq, - Debug, - Encode, - Decode, - DecodeWithMemTracking, - MaxEncodedLen, - TypeInfo - )] - pub struct RelayCommit { - /// Block it was recorded in; only a spend in a later block is credited. - pub recorded_at: BlockNumber, - /// Block it is pruned in. - pub expires_at: BlockNumber, - } - - // ── Storage ─────────────────────────────────────────────────────────────── - - /// Minimum relay fee (planck). Initialised from `T::DefaultMinRelayFee`; - /// updatable by `ManageOrigin` via `set_min_relay_fee`. - #[pallet::storage] - pub type MinRelayFee = StorageValue<_, u128, ValueQuery, DefaultMinRelayFeeValue>; - - /// ABI selector whitelist. Empty = use built-in defaults (resolved in - /// the Runtime API impl so the relay always has a non-empty list). - #[pallet::storage] - pub type AllowedSelectors = - StorageValue<_, BoundedVec<[u8; 4], T::MaxAllowedSelectors>, ValueQuery>; - - /// On-chain registry: EVM address → substrate AccountId. - #[pallet::storage] - pub type RelayerRegistry = - StorageMap<_, Blake2_128Concat, H160, T::AccountId, OptionQuery>; - - /// Reverse index: AccountId → registered EVM address. - #[pallet::storage] - pub type RelayerByAccount = - StorageMap<_, Blake2_128Concat, T::AccountId, H160, OptionQuery>; - - /// Accumulated relay fees per (AccountId, asset_id) in planck. - #[pallet::storage] - pub type PendingRelayerFees = StorageDoubleMap< - _, - Blake2_128Concat, - T::AccountId, - Blake2_128Concat, - u32, // asset_id - u128, // amount in planck - ValueQuery, - >; - - /// Relay commits: [`relay_commit_hash`] → when it was recorded and expires. - /// - /// Stores no author on purpose. The credited relayer is the one whose - /// address is in the preimage, so writing someone else's commit first - /// gains nothing. A commit already present keeps its (earlier) block. - #[pallet::storage] - pub type RelayCommits = - StorageMap<_, Blake2_128Concat, H256, RelayCommit>, OptionQuery>; - - /// Expiry index: (expiry block, validator account) → the commits its relayer - /// recorded that expire then. Keyed by expiry, not by recording block, so - /// pruning stays exact when `CommitTtl` changes; keyed by account, not by - /// relay address, so rotating the address does not buy a fresh quota. At - /// most one entry per validator per expiry block. - #[pallet::storage] - pub type CommitsByRelayer = StorageDoubleMap< - _, - Twox64Concat, - BlockNumberFor, - Blake2_128Concat, - T::AccountId, - BoundedVec, - ValueQuery, - >; - - // ── Events ──────────────────────────────────────────────────────────────── - - #[pallet::event] - #[pallet::generate_deposit(pub(crate) fn deposit_event)] - pub enum Event { - /// `ManageOrigin` updated the minimum relay fee. - MinRelayFeeUpdated { new_fee: u128 }, - /// `ManageOrigin` updated the allowed selector whitelist. - AllowedSelectorsUpdated { count: u32 }, - /// A validator registered its EVM relay address. - RelayerRegistered { - evm_address: H160, - account: T::AccountId, - }, - /// A relayer mapping was removed, either by the owner calling - /// `unregister_relayer` or by the account leaving the validator set. - RelayerUnregistered { - evm_address: H160, - account: T::AccountId, - }, - /// A relay fee accrued to `relayer`: the committed relayer, or the block - /// author when none committed. - RelayFeeAccumulated { - relayer: T::AccountId, - asset_id: u32, - amount: u128, - }, - /// Relay fees were marked as consumed (by `claim_relay_fees`). - RelayFeesConsumed { - relayer: T::AccountId, - asset_id: u32, - amount: u128, - }, - /// A relayer recorded `count` new relay commits (already-present ones - /// are not counted). - RelayCommitted { relayer: H160, count: u32 }, - } - - // ── Errors ──────────────────────────────────────────────────────────────── - - #[pallet::error] - pub enum Error { - /// No EVM address registered for this account. - NotRegistered, - /// The EVM address already has a registered AccountId. - AlreadyRegistered, - /// The calling account already has an active EVM registration. - /// Call `unregister_relayer` first. - AccountAlreadyRegistered, - /// Requested amount exceeds pending relay fees. - InsufficientPendingFees, - /// Selector list exceeds `MaxAllowedSelectors`. - TooManySelectors, - /// The requested minimum relay fee exceeds `MaxMinRelayFee`. - MinRelayFeeTooHigh, - /// Only accounts in the active validator set may register a relay address. - NotValidator, - /// The EVM address is not usable as a relay identity (zero, or reserved). - InvalidEvmAddress, - /// The signature does not prove control of the EVM address being claimed. - BadEvmSignature, - /// The relayer already recorded `MaxCommitsPerRelayerPerBlock` commits in - /// this block. - TooManyCommits, - } - - // ── Pallet core ─────────────────────────────────────────────────────────── - - #[pallet::pallet] - pub struct Pallet(_); - - #[pallet::hooks] - impl Hooks> for Pallet { - fn integrity_test() { - // 0 never expires a commit; 1 prunes it before any later block could use it. - assert!( - T::CommitTtl::get() >= 2u32.into(), - "CommitTtl must be at least 2 blocks" - ); - } - - /// Prune the commits that expire in this block. Bounded: the index holds - /// at most one entry per validator per block. - fn on_initialize(now: BlockNumberFor) -> Weight { - let (mut keys, mut pruned) = (0u32, 0u32); - for (_, commits) in CommitsByRelayer::::drain_prefix(now) { - keys = keys.saturating_add(1); - for commit in commits { - // A consumed commit is gone, and one consumed and recorded again - // has a later expiry of its own; leave both alone. - if RelayCommits::::get(commit).is_some_and(|c| c.expires_at == now) { - RelayCommits::::remove(commit); - } - pruned = pruned.saturating_add(1); - } - } - T::WeightInfo::prune_relay_commits(keys, pruned) - } - } - - // ── Extrinsics ──────────────────────────────────────────────────────────── - - #[pallet::call] - impl Pallet { - /// Update the minimum relay fee. - /// - /// Requires `ManageOrigin`. The new value takes effect immediately and is - /// capped at `MaxMinRelayFee`, so a mistake cannot brick relaying. - #[pallet::call_index(0)] - #[pallet::weight(T::WeightInfo::set_min_relay_fee())] - pub fn set_min_relay_fee(origin: OriginFor, fee: u128) -> DispatchResult { - T::ManageOrigin::ensure_origin(origin)?; - ensure!( - fee <= T::MaxMinRelayFee::get(), - Error::::MinRelayFeeTooHigh - ); - MinRelayFee::::put(fee); - Self::deposit_event(Event::MinRelayFeeUpdated { new_fee: fee }); - Ok(()) - } - - /// Replace the allowed ABI selector whitelist. - /// - /// Pass an empty `Vec` to fall back to the Runtime API built-in defaults. - /// Requires `ManageOrigin`. - #[pallet::call_index(1)] - #[pallet::weight(T::WeightInfo::set_allowed_selectors(selectors.len() as u32))] - pub fn set_allowed_selectors( - origin: OriginFor, - selectors: Vec<[u8; 4]>, - ) -> DispatchResult { - T::ManageOrigin::ensure_origin(origin)?; - let bounded: BoundedVec<[u8; 4], T::MaxAllowedSelectors> = selectors - .try_into() - .map_err(|_| Error::::TooManySelectors)?; - let count = bounded.len() as u32; - AllowedSelectors::::put(bounded); - Self::deposit_event(Event::AllowedSelectorsUpdated { count }); - Ok(()) - } - - /// Register the caller's EVM relay address. - /// - /// Three things must hold: - /// - /// 1. the caller is in the active validator set (`NotValidator`); - /// 2. `signature` proves the caller holds the EVM private key - /// (`BadEvmSignature`) — without this an approved validator could claim - /// a rival's public relay address and divert its fees; - /// 3. the address and the account are both unclaimed (`AlreadyRegistered`, - /// `AccountAlreadyRegistered`). - /// - /// The address is chosen freely by the operator; it must match the key the - /// node signs relay transactions with (keystore type `evmr`). - /// - /// See [`crate::evm_proof`] for the exact bytes to sign. - #[pallet::call_index(2)] - #[pallet::weight(T::WeightInfo::register_relayer())] - pub fn register_relayer( - origin: OriginFor, - evm_address: H160, - signature: EvmSignature, - ) -> DispatchResult { - let who = ensure_signed(origin)?; - ensure!( - T::ValidatorSet::is_active_validator(&who), - Error::::NotValidator - ); - ensure!( - evm_proof::is_usable_relay_address(&evm_address), - Error::::InvalidEvmAddress - ); - ensure!( - Self::verify_evm_ownership(&who, &evm_address, &signature), - Error::::BadEvmSignature - ); - ensure!( - !RelayerRegistry::::contains_key(evm_address), - Error::::AlreadyRegistered - ); - ensure!( - !RelayerByAccount::::contains_key(&who), - Error::::AccountAlreadyRegistered - ); - RelayerRegistry::::insert(evm_address, who.clone()); - RelayerByAccount::::insert(who.clone(), evm_address); - Self::deposit_event(Event::RelayerRegistered { - evm_address, - account: who, - }); - Ok(()) - } - - /// Remove an EVM relay address mapping. - /// - /// The caller (account owner) may remove their own registration. - #[pallet::call_index(3)] - #[pallet::weight(T::WeightInfo::unregister_relayer())] - pub fn unregister_relayer(origin: OriginFor) -> DispatchResult { - let who = ensure_signed(origin)?; - Self::clear_relayer(&who).ok_or(Error::::NotRegistered)?; - Ok(()) - } - } - - // ── Internal helpers ────────────────────────────────────────────────────── - - impl Pallet { - /// Drop any EVM binding held by `who`, in both directions, returning the - /// address that was removed. `None` when the account had no registration. - /// - /// Infallible and idempotent: the runtime calls it when a validator leaves - /// the set, and removal from the set must never be blocked by cleanup. - /// - /// `PendingRelayerFees` is deliberately left untouched — those fees were - /// already earned and stay claimable via `claim_relay_fees`. - pub fn clear_relayer(who: &T::AccountId) -> Option { - let evm_address = RelayerByAccount::::take(who)?; - RelayerRegistry::::remove(evm_address); - Self::deposit_event(Event::RelayerUnregistered { - evm_address, - account: who.clone(), - }); - Some(evm_address) - } - - /// Check that `signature` was produced by the key behind `evm_address` - /// over the binding message for `who` on this chain. - fn verify_evm_ownership( - who: &T::AccountId, - evm_address: &H160, - signature: &EvmSignature, - ) -> bool { - let digest = evm_proof::binding_digest( - who.encode().as_slice(), - evm_address, - &evm_proof::genesis_hash::(), - ); - evm_proof::recover_evm_address(signature, &digest) == Some(*evm_address) - } - } - - // ── RelayerInterface implementation ─────────────────────────────────────── - - impl RelayerInterface for Pallet { - type AccountId = T::AccountId; - - fn resolve_relayer(evm_address: &sp_core::H160) -> Option { - RelayerRegistry::::get(evm_address) - } - - fn min_relay_fee() -> u128 { - MinRelayFee::::get() - } - - fn allowed_selectors() -> Vec<[u8; 4]> { - AllowedSelectors::::get().into_inner() - } - - fn block_author() -> Option { - T::BlockAuthor::get() - } - - fn accumulate_relay_fee(author: &T::AccountId, asset_id: u32, amount: u128) { - PendingRelayerFees::::mutate(author, asset_id, |b| { - *b = b.saturating_add(amount); - }); - Self::deposit_event(Event::RelayFeeAccumulated { - relayer: author.clone(), - asset_id, - amount, - }); - } - - fn pending_relay_fees(who: &T::AccountId, asset_id: u32) -> u128 { - PendingRelayerFees::::get(who, asset_id) - } - - fn consume_relay_fee( - who: &T::AccountId, - asset_id: u32, - amount: u128, - ) -> frame_support::dispatch::DispatchResult { - let pending = PendingRelayerFees::::get(who, asset_id); - ensure!(pending >= amount, Error::::InsufficientPendingFees); - PendingRelayerFees::::mutate(who, asset_id, |b| { - *b = b.saturating_sub(amount); - }); - Self::deposit_event(Event::RelayFeesConsumed { - relayer: who.clone(), - asset_id, - amount, - }); - Ok(()) - } - - fn registered_evm_address(who: &T::AccountId) -> Option { - RelayerByAccount::::get(who) - } - - fn record_relay_commits(relayer: &H160, commits: &[H256]) -> DispatchResult { - let who = RelayerRegistry::::get(relayer).ok_or(Error::::NotRegistered)?; - let recorded_at = frame_system::Pallet::::block_number(); - let expires_at = recorded_at.saturating_add(T::CommitTtl::get()); - - // Only new commits count against the quota, each once. - let mut fresh: Vec = Vec::with_capacity(commits.len()); - for commit in commits { - if !RelayCommits::::contains_key(commit) && !fresh.contains(commit) { - fresh.push(*commit); - } - } - // Nothing new: no index entry, no event. - if fresh.is_empty() { - return Ok(()); - } - // Index first: past the quota nothing at all is written. - CommitsByRelayer::::try_mutate(expires_at, &who, |index| -> DispatchResult { - for commit in &fresh { - index - .try_push(*commit) - .map_err(|_| Error::::TooManyCommits)?; - } - Ok(()) - })?; - for commit in &fresh { - RelayCommits::::insert( - commit, - RelayCommit { - recorded_at, - expires_at, - }, - ); - } - - Self::deposit_event(Event::RelayCommitted { - relayer: *relayer, - count: fresh.len() as u32, - }); - Ok(()) - } - - fn take_committed_relayer(op_hash: &[u8; 32]) -> Option { - let now = frame_system::Pallet::::block_number(); - // (commit block, commit hash) of the best candidate so far, and its account. - type Rank = (N, H256); - let mut winner: Option<(Rank>, T::AccountId)> = None; - for (evm_address, account) in RelayerRegistry::::iter() { - let hash = relay_commit_hash(op_hash, &evm_address); - let Some(commit) = RelayCommits::::take(hash) else { - continue; - }; - // A commit from this block may have been written after the spend was - // seen, so only earlier blocks count. - let at = commit.recorded_at; - if at >= now { - continue; - } - // Earliest wins. A same-block tie goes to the lowest commit hash: - // it changes with every spend and cannot be ground in advance, unlike - // the registry's storage order, which an operator picks by address. - let rank = (at, hash); - if winner.as_ref().is_none_or(|(best, _)| rank < *best) { - winner = Some((rank, account)); - } - } - winner.map(|(_, account)| account) - } - } -} diff --git a/frame/relayer/src/mock.rs b/frame/relayer/src/mock.rs deleted file mode 100644 index 8a135019..00000000 --- a/frame/relayer/src/mock.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Mock runtime for pallet-relayer tests. - -use std::cell::RefCell; - -use crate as pallet_relayer; -use frame_support::{derive_impl, parameter_types}; -use sp_runtime::BuildStorage; - -type Block = frame_system::mocking::MockBlock; - -frame_support::construct_runtime!( - pub enum Test { - System: frame_system, - Relayer: pallet_relayer, - } -); - -#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] -impl frame_system::Config for Test { - type Block = Block; -} - -parameter_types! { - pub const DefaultMinRelayFee: u128 = 1_000_000_000_000_000u128; // 1e15 planck - pub const MaxAllowedSelectors: u32 = 8; - pub const MaxMinRelayFee: u128 = 1_000_000_000_000_000_000u128; // 1 ORB - pub const MaxCommitsPerRelayerPerBlock: u32 = 4; - /// Mutable so tests can change it between recording and pruning. - pub storage CommitTtl: u64 = 5; -} - -/// Static block author — always Alice (account 1). -pub struct MockBlockAuthor; -impl frame_support::traits::Get> for MockBlockAuthor { - fn get() -> Option { - Some(1u64) - } -} - -// ── Validator gate mock ─────────────────────────────────────────────────────── -// -// Tests declare which accounts count as active validators. Empty by default, so -// a test that forgets to opt in sees the gate reject — the same way a runtime -// that forgets to wire `Config::ValidatorSet` does. - -thread_local! { - static MOCK_VALIDATORS: RefCell> = const { RefCell::new(Vec::new()) }; -} - -/// Mark `who` as an active validator for the rest of the test. -pub fn set_mock_validator(who: u64) { - MOCK_VALIDATORS.with(|v| v.borrow_mut().push(who)); -} - -/// Remove `who` from the mock validator set, so tests can exercise what happens -/// to an account that was approved and then removed. -pub fn clear_mock_validator(who: u64) { - MOCK_VALIDATORS.with(|v| v.borrow_mut().retain(|x| *x != who)); -} - -pub struct MockValidatorSet; -impl pallet_validator_set::ValidatorSetInterface for MockValidatorSet { - fn is_active_validator(who: &u64) -> bool { - MOCK_VALIDATORS.with(|v| v.borrow().contains(who)) - } - - #[cfg(feature = "runtime-benchmarks")] - fn setup_validator(who: &u64) { - set_mock_validator(*who); - } -} - -impl pallet_relayer::Config for Test { - type BlockAuthor = MockBlockAuthor; - type DefaultMinRelayFee = DefaultMinRelayFee; - type MaxMinRelayFee = MaxMinRelayFee; - type ManageOrigin = frame_system::EnsureRoot; - type MaxAllowedSelectors = MaxAllowedSelectors; - type ValidatorSet = MockValidatorSet; - type CommitTtl = CommitTtl; - type MaxCommitsPerRelayerPerBlock = MaxCommitsPerRelayerPerBlock; - type WeightInfo = (); -} - -pub fn new_test_ext() -> sp_io::TestExternalities { - // Thread-locals outlive externalities; reset so tests do not leak into each other. - MOCK_VALIDATORS.with(|v| v.borrow_mut().clear()); - let mut ext: sp_io::TestExternalities = frame_system::GenesisConfig::::default() - .build_storage() - .unwrap() - .into(); - // Events are not registered at block 0 — advance to block 1. - ext.execute_with(|| System::set_block_number(1)); - ext -} - -/// Register `who`'s relay address with a valid ownership proof. -/// -/// Tests that are not about the proof itself use this so the signature never -/// obscures what they are actually asserting. The address is derived from the -/// signing key, so each distinct `seed` yields a distinct address. -pub fn register_with_proof( - who: u64, - seed: &[u8; 32], -) -> (sp_core::H160, frame_support::pallet_prelude::DispatchResult) { - let (evm_address, signature) = proof_for(who, seed); - let result = Relayer::register_relayer(RuntimeOrigin::signed(who), evm_address, signature); - (evm_address, result) -} - -/// The EVM address `seed` controls, without registering anything. -pub fn address_for_seed(who: u64, seed: &[u8; 32]) -> sp_core::H160 { - proof_for(who, seed).0 -} - -/// The address `seed` controls, plus a signature proving it, bound to `who`. -/// -/// Returned as a pair so a test can deliberately mismatch them — signing with -/// one key while claiming another's address is the attack the proof exists to -/// stop, and it needs to be expressible. -pub fn proof_for(who: u64, seed: &[u8; 32]) -> (sp_core::H160, crate::EvmSignature) { - crate::test_signing::signed_binding_with::(&who, seed) -} - -/// Distinct signing keys, so accounts can hold distinct EVM addresses. -/// -/// Named after the account that conventionally uses them; nothing enforces the -/// pairing, and a test that needs a key nobody owns can pass any other seed. -pub mod seeds { - pub const ALICE: &[u8; 32] = &[0xb0; 32]; - pub const BOB: &[u8; 32] = &[0xb1; 32]; - pub const CAROL: &[u8; 32] = &[0xb2; 32]; -} diff --git a/frame/relayer/src/test_signing.rs b/frame/relayer/src/test_signing.rs deleted file mode 100644 index 48dc8eef..00000000 --- a/frame/relayer/src/test_signing.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Produces valid EVM ownership proofs for tests and benchmarks. -//! -//! `register_relayer` requires a signature from the EVM key, so both callers need -//! to sign the binding digest for real — a hard-coded signature would silently -//! rot the moment the digest format changes, and the tests would keep passing -//! against a format nobody uses. -//! -//! Compiled only under `test` or `runtime-benchmarks`, never in a plain build. - -use crate::{Config, EvmSignature, evm_proof}; -use frame_support::pallet_prelude::*; -use sp_core::H160; - -/// Deterministic 32-byte secret, so the derived address is stable across runs. -/// Not a secret in any real sense — it is committed in plain sight. -/// -/// Tests that need several distinct addresses pass their own seeds; see -/// `mock::seeds`. -pub const DEFAULT_SEED: [u8; 32] = [0xb0; 32]; - -/// Produce `(evm_address, signature)` proving control of that address for `who` -/// on the current chain, using `seed` as the secret key. -/// -/// Uses `libsecp256k1` rather than `sp_core::ecdsa::Pair`, whose -/// `sign_prehashed` is std-only — benchmarks run in WASM. -pub fn signed_binding_with(who: &T::AccountId, seed: &[u8; 32]) -> (H160, EvmSignature) { - let secret = libsecp256k1::SecretKey::parse(seed).expect("static seed is a valid key; qed"); - // serialize() is 65 bytes with a 0x04 tag; the derivation wants the 64 after it. - let tagged = libsecp256k1::PublicKey::from_secret_key(&secret).serialize(); - let untagged: [u8; 64] = tagged[1..].try_into().expect("65 - 1 == 64; qed"); - let evm_address = evm_proof::evm_address_from_uncompressed(&untagged); - - let digest = evm_proof::binding_digest( - who.encode().as_slice(), - &evm_address, - &evm_proof::genesis_hash::(), - ); - let (signature, recovery) = libsecp256k1::sign(&libsecp256k1::Message::parse(&digest), &secret); - - let mut raw = [0u8; 65]; - raw[..64].copy_from_slice(&signature.serialize()); - raw[64] = recovery.serialize(); - (evm_address, EvmSignature(raw)) -} - -/// [`signed_binding_with`] using [`DEFAULT_SEED`]. -pub fn signed_binding(who: &T::AccountId) -> (H160, EvmSignature) { - signed_binding_with::(who, &DEFAULT_SEED) -} diff --git a/frame/relayer/src/tests/cleanup_tests.rs b/frame/relayer/src/tests/cleanup_tests.rs deleted file mode 100644 index 40b45454..00000000 --- a/frame/relayer/src/tests/cleanup_tests.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! `clear_relayer`: the cleanup the runtime runs when a validator leaves the set. -//! -//! Must be infallible and idempotent — removal from the validator set can never -//! be blocked by cleanup failing. - -use crate::{ - Event, PendingRelayerFees, RelayerByAccount, RelayerRegistry, mock::*, traits::RelayerInterface, -}; -use frame_support::assert_ok; -use sp_core::H160; - -// ─── clear_relayer (runtime hook for validators leaving the set) ───────────── - -#[test] -fn clear_relayer_removes_both_indices() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - - assert_eq!(crate::Pallet::::clear_relayer(&1u64), Some(evm)); - - assert!(!RelayerRegistry::::contains_key(evm)); - assert!(!RelayerByAccount::::contains_key(1u64)); - }); -} - -#[test] -fn clear_relayer_emits_unregistered_event() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - - crate::Pallet::::clear_relayer(&1u64); - - System::assert_last_event( - Event::RelayerUnregistered { - evm_address: evm, - account: 1u64, - } - .into(), - ); - }); -} - -#[test] -fn clear_relayer_is_idempotent_and_silent_when_unregistered() { - new_test_ext().execute_with(|| { - // Never registered — must not panic and must not emit. - assert_eq!(crate::Pallet::::clear_relayer(&1u64), None); - assert!(System::events().is_empty()); - - set_mock_validator(1); - assert_ok!(register_with_proof(1, seeds::ALICE).1); - assert!(crate::Pallet::::clear_relayer(&1u64).is_some()); - - let after_first = System::events().len(); - // Second call has nothing left to do, so it must not emit either. - assert_eq!(crate::Pallet::::clear_relayer(&1u64), None); - assert_eq!(System::events().len(), after_first); - assert!(!RelayerByAccount::::contains_key(1u64)); - }); -} - -#[test] -fn clear_relayer_preserves_pending_fees() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - assert_ok!(register_with_proof(1, seeds::ALICE).1); - // Fees already earned must survive: clearing the binding is not confiscation. - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 5_000u128); - - crate::Pallet::::clear_relayer(&1u64); - - assert_eq!(PendingRelayerFees::::get(1u64, 0), 5_000u128); - }); -} - -#[test] -fn clear_relayer_frees_the_address_for_its_owner_to_reclaim() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - - crate::Pallet::::clear_relayer(&1u64); - - // Re-added to the set later, the same operator reclaims the same address. - assert_ok!(register_with_proof(1, seeds::ALICE).1); - assert_eq!(RelayerRegistry::::get(evm), Some(1u64)); - }); -} - -#[test] -fn clear_relayer_tolerates_a_half_written_registry() { - // No code path produces this, but the helper must not misbehave if one ever - // does: a forward entry with no reverse entry is left alone, not panicked on. - new_test_ext().execute_with(|| { - let orphan = H160::repeat_byte(0x7f); - RelayerRegistry::::insert(orphan, 1u64); - - assert_eq!(crate::Pallet::::clear_relayer(&1u64), None); - - // Pivoting on RelayerByAccount means the orphan survives — documented - // here so a future change to the pivot is a deliberate one. - assert_eq!(RelayerRegistry::::get(orphan), Some(1u64)); - }); -} diff --git a/frame/relayer/src/tests/commit_tests.rs b/frame/relayer/src/tests/commit_tests.rs deleted file mode 100644 index 2fd0f337..00000000 --- a/frame/relayer/src/tests/commit_tests.rs +++ /dev/null @@ -1,407 +0,0 @@ -//! Tests for relay commits. -//! -//! Covers: -//! - `record_relay_commits` — registration gate, per-relayer quota, first write -//! wins, all-or-nothing batches -//! - `take_committed_relayer` — earlier-block rule, earliest wins, ties, copied commits -//! - `on_initialize` — expiry after `CommitTtl`, also across a TTL change -//! - `integrity_test` — the `CommitTtl` lower bound - -use crate::traits::RelayerInterface; -use crate::{ - CommitsByRelayer, Error, Event, RelayCommit, RelayCommits, mock::*, relay_commit_hash, -}; -use frame_support::{assert_noop, assert_ok, traits::Hooks}; -use sp_core::{H160, H256}; - -const OP: [u8; 32] = [0x11; 32]; -const OTHER_OP: [u8; 32] = [0x22; 32]; - -/// Register accounts 1 and 2 as relayers; returns their EVM addresses. -fn two_relayers() -> (H160, H160) { - set_mock_validator(1); - set_mock_validator(2); - let (a, ra) = register_with_proof(1, seeds::ALICE); - let (b, rb) = register_with_proof(2, seeds::BOB); - assert_ok!(ra); - assert_ok!(rb); - (a, b) -} - -/// Records `commits` for `relayer` through the interface other pallets use. -fn record(relayer: &H160, commits: &[H256]) -> frame_support::dispatch::DispatchResult { - crate::Pallet::::record_relay_commits(relayer, commits) -} - -/// The account credited for the spend `op`, consuming its commits. -fn take(op: &[u8; 32]) -> Option { - crate::Pallet::::take_committed_relayer(op) -} - -/// The block `commit` was recorded in, if it is stored. -fn recorded(commit: H256) -> Option { - RelayCommits::::get(commit).map(|c: RelayCommit| c.recorded_at) -} - -/// Distinct placeholder commits, one per value in `range`. -fn commits(range: core::ops::Range) -> Vec { - range.map(H256::from_low_u64_be).collect() -} - -/// Moves to block `n` and runs its `on_initialize`. -fn run_to(n: u64) { - System::set_block_number(n); - crate::Pallet::::on_initialize(n); -} - -// ─── record_relay_commits ──────────────────────────────────────────────────── - -#[test] -fn unregistered_address_cannot_commit() { - new_test_ext().execute_with(|| { - let stranger = H160::repeat_byte(0x99); - assert_noop!( - record(&stranger, &[relay_commit_hash(&OP, &stranger)]), - Error::::NotRegistered - ); - }); -} - -#[test] -fn commit_is_recorded_with_its_block_and_event() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - let commit = relay_commit_hash(&OP, &a); - assert_ok!(record(&a, &[commit])); - assert_eq!(recorded(commit), Some(1)); - assert_eq!( - CommitsByRelayer::::get(1 + CommitTtl::get(), 1).into_inner(), - vec![commit] - ); - System::assert_last_event( - Event::RelayCommitted { - relayer: a, - count: 1, - } - .into(), - ); - }); -} - -#[test] -fn existing_commit_keeps_its_earlier_block() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - let commit = relay_commit_hash(&OP, &a); - assert_ok!(record(&a, &[commit])); - run_to(2); - assert_ok!(record(&a, &[commit])); - assert_eq!(recorded(commit), Some(1)); - assert!(CommitsByRelayer::::get(2 + CommitTtl::get(), 1).is_empty()); - }); -} - -#[test] -fn quota_is_per_relayer() { - new_test_ext().execute_with(|| { - let (a, b) = two_relayers(); - assert_ok!(record(&a, &commits(0..4))); - assert_noop!( - record(&a, &[H256::from_low_u64_be(100)]), - Error::::TooManyCommits - ); - // A full quota for one relayer does not lock the other out. - assert_ok!(record(&b, &[H256::from_low_u64_be(200)])); - }); -} - -#[test] -fn a_batch_with_nothing_new_writes_no_index_entry_and_no_event() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &commits(0..2))); - run_to(2); - System::reset_events(); - assert_ok!(record(&a, &commits(0..2))); - assert_ok!(record(&a, &[])); - assert!(!CommitsByRelayer::::contains_key( - 2 + CommitTtl::get(), - 1 - )); - assert!(System::events().is_empty()); - }); -} - -/// The quota belongs to the validator: rotating its relay address in the same -/// block does not reset it. -#[test] -fn rotating_the_relay_address_keeps_the_quota() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &commits(0..4))); - assert_ok!(crate::Pallet::::unregister_relayer( - RuntimeOrigin::signed(1) - )); - let (c, registered) = register_with_proof(1, seeds::CAROL); - assert_ok!(registered); - assert_noop!(record(&c, &commits(10..11)), Error::::TooManyCommits); - }); -} - -#[test] -fn a_batch_past_the_quota_writes_nothing() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_noop!(record(&a, &commits(0..5)), Error::::TooManyCommits); - assert!(commits(0..5).iter().all(|c| recorded(*c).is_none())); - }); -} - -#[test] -fn duplicates_and_present_commits_take_no_quota_and_are_not_counted() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &commits(0..2))); - // Two present, one repeated: only 2 and 3 are new, within the quota of 4. - let batch = [commits(0..4), commits(2..3)].concat(); - assert_ok!(record(&a, &batch)); - System::assert_last_event( - Event::RelayCommitted { - relayer: a, - count: 2, - } - .into(), - ); - assert_eq!( - CommitsByRelayer::::get(1 + CommitTtl::get(), 1).len(), - 4 - ); - }); -} - -// ─── take_committed_relayer ────────────────────────────────────────────────── - -#[test] -fn commit_from_earlier_block_credits_the_relayer_and_is_consumed() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - let commit = relay_commit_hash(&OP, &a); - assert_ok!(record(&a, &[commit])); - run_to(2); - assert_eq!(take(&OP), Some(1)); - assert!(!RelayCommits::::contains_key(commit)); - assert_eq!(take(&OP), None); - }); -} - -#[test] -fn commit_from_the_same_block_is_ignored() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &[relay_commit_hash(&OP, &a)])); - // Written in the block that spends: could have been placed after seeing it. - assert_eq!(take(&OP), None); - }); -} - -#[test] -fn copied_commit_credits_the_relayer_inside_the_hash() { - new_test_ext().execute_with(|| { - let (a, b) = two_relayers(); - // B front-runs A by writing A's commit first: it still names A. - assert_ok!(record(&b, &[relay_commit_hash(&OP, &a)])); - run_to(2); - assert_eq!(take(&OP), Some(1)); - }); -} - -#[test] -fn earliest_commit_wins() { - new_test_ext().execute_with(|| { - let (a, b) = two_relayers(); - assert_ok!(record(&a, &[relay_commit_hash(&OP, &a)])); - run_to(2); - assert_ok!(record(&b, &[relay_commit_hash(&OP, &b)])); - run_to(3); - assert_eq!(take(&OP), Some(1)); - // The loser's commit is consumed too: the spend runs only once. - assert!(!RelayCommits::::contains_key(relay_commit_hash( - &OP, &b - ))); - }); -} - -#[test] -fn commit_is_bound_to_its_operation() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &[relay_commit_hash(&OP, &a)])); - run_to(2); - assert_eq!(take(&OTHER_OP), None); - }); -} - -#[test] -fn unregistered_relayer_is_not_credited() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &[relay_commit_hash(&OP, &a)])); - assert!(crate::Pallet::::clear_relayer(&1).is_some()); - run_to(2); - assert_eq!(take(&OP), None); - }); -} - -#[test] -fn a_same_block_tie_credits_one_of_them_and_consumes_both() { - new_test_ext().execute_with(|| { - let (a, b) = two_relayers(); - assert_ok!(record(&a, &[relay_commit_hash(&OP, &a)])); - assert_ok!(record(&b, &[relay_commit_hash(&OP, &b)])); - run_to(2); - // The lowest commit hash wins, whatever the registry's storage order. - let (ha, hb) = (relay_commit_hash(&OP, &a), relay_commit_hash(&OP, &b)); - let expected = if ha < hb { 1 } else { 2 }; - assert_eq!(take(&OP), Some(expected)); - assert_eq!(recorded(relay_commit_hash(&OP, &a)), None); - assert_eq!(recorded(relay_commit_hash(&OP, &b)), None); - }); -} - -#[test] -fn the_tie_break_changes_with_the_spend() { - new_test_ext().execute_with(|| { - let (a, b) = two_relayers(); - // Across many spends each relayer wins some ties: no address wins them all. - // The quota is 4 per relayer per block: 8 spends over two blocks. - let mut wins = [0u32; 2]; - for i in 0..8u8 { - if i == 4 { - run_to(2); - } - let op = [i; 32]; - assert_ok!(record(&a, &[relay_commit_hash(&op, &a)])); - assert_ok!(record(&b, &[relay_commit_hash(&op, &b)])); - } - run_to(3); - for i in 0..8u8 { - match take(&[i; 32]) { - Some(1) => wins[0] += 1, - Some(2) => wins[1] += 1, - other => panic!("unexpected winner {other:?}"), - } - } - assert!(wins[0] > 0 && wins[1] > 0, "{wins:?}"); - }); -} - -// ─── on_initialize (expiry) ────────────────────────────────────────────────── - -#[test] -fn an_idle_block_is_weighed_as_pruning_nothing() { - use crate::weights::WeightInfo; - new_test_ext().execute_with(|| { - two_relayers(); - assert_eq!( - crate::Pallet::::on_initialize(1), - ::WeightInfo::prune_relay_commits(0, 0) - ); - }); -} - -#[test] -fn commit_expires_after_ttl() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - let commit = relay_commit_hash(&OP, &a); - assert_ok!(record(&a, &[commit])); - // Still usable in the last block before expiry (1 + CommitTtl - 1). - run_to(1 + CommitTtl::get() - 1); - assert!(RelayCommits::::contains_key(commit)); - run_to(1 + CommitTtl::get()); - assert!(!RelayCommits::::contains_key(commit)); - assert!(CommitsByRelayer::::get(1 + CommitTtl::get(), 1).is_empty()); - assert_eq!(take(&OP), None); - }); -} - -#[test] -fn pruning_skips_commits_already_consumed() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - let commit = relay_commit_hash(&OP, &a); - assert_ok!(record(&a, &[commit])); - run_to(2); - assert_eq!(take(&OP), Some(1)); - // Re-recorded later: the old index entry must not delete the new commit. - assert_ok!(record(&a, &[commit])); - run_to(1 + CommitTtl::get()); - assert_eq!(recorded(commit), Some(2)); - }); -} - -#[test] -fn nothing_is_pruned_before_the_first_expiry() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &commits(0..2))); - for n in 2..(1 + CommitTtl::get()) { - run_to(n); - } - assert!(commits(0..2).iter().all(|c| recorded(*c) == Some(1))); - }); -} - -#[test] -fn one_pass_prunes_every_relayer_that_expires() { - new_test_ext().execute_with(|| { - let (a, b) = two_relayers(); - assert_ok!(record(&a, &commits(0..3))); - assert_ok!(record(&b, &commits(10..14))); - let weight = crate::Pallet::::on_initialize(1 + CommitTtl::get()); - assert_eq!(weight, <() as crate::WeightInfo>::prune_relay_commits(2, 7)); - assert!( - commits(0..3) - .iter() - .chain(&commits(10..14)) - .all(|c| recorded(*c).is_none()) - ); - }); -} - -#[test] -fn a_ttl_change_leaves_no_orphans() { - new_test_ext().execute_with(|| { - let (a, _) = two_relayers(); - assert_ok!(record(&a, &commits(0..1))); // expires at 1 + 5 - CommitTtl::set(&2); - run_to(2); - assert_ok!(record(&a, &commits(1..2))); // expires at 2 + 2 - - run_to(4); - assert_eq!( - recorded(H256::from_low_u64_be(1)), - None, - "shorter TTL applies to new commits" - ); - assert_eq!( - recorded(H256::from_low_u64_be(0)), - Some(1), - "old commit keeps its expiry" - ); - run_to(6); - assert_eq!(recorded(H256::from_low_u64_be(0)), None); - assert_eq!(CommitsByRelayer::::iter().count(), 0); - }); -} - -// ─── integrity_test ────────────────────────────────────────────────────────── - -#[test] -#[should_panic(expected = "CommitTtl must be at least 2 blocks")] -fn integrity_test_refuses_a_ttl_below_two() { - new_test_ext().execute_with(|| { - CommitTtl::set(&1); - as Hooks>::integrity_test(); - }); -} diff --git a/frame/relayer/src/tests/config_tests.rs b/frame/relayer/src/tests/config_tests.rs deleted file mode 100644 index 073ea3f1..00000000 --- a/frame/relayer/src/tests/config_tests.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Tests for relay configuration management. -//! -//! Covers: -//! - `set_min_relay_fee` — governance gating, storage update, event emission, -//! and the `MaxMinRelayFee` ceiling that keeps a typo from bricking relaying -//! - `set_allowed_selectors` — governance gating, update, clear, overflow guard -//! - Default value wiring (`T::DefaultMinRelayFee`) - -use crate::{AllowedSelectors, Error, Event, MinRelayFee, mock::*}; -use frame_support::{assert_noop, assert_ok}; - -// ─── MinRelayFee ───────────────────────────────────────────────────────────── - -#[test] -fn min_relay_fee_defaults_to_constant() { - new_test_ext().execute_with(|| { - assert_eq!(MinRelayFee::::get(), 1_000_000_000_000_000u128); - }); -} - -#[test] -fn set_min_relay_fee_updates_storage() { - new_test_ext().execute_with(|| { - assert_ok!(Relayer::set_min_relay_fee(RuntimeOrigin::root(), 42u128)); - assert_eq!(MinRelayFee::::get(), 42u128); - }); -} - -#[test] -fn set_min_relay_fee_emits_event() { - new_test_ext().execute_with(|| { - assert_ok!(Relayer::set_min_relay_fee(RuntimeOrigin::root(), 99u128)); - System::assert_last_event(Event::MinRelayFeeUpdated { new_fee: 99u128 }.into()); - }); -} - -#[test] -fn set_min_relay_fee_requires_manage_origin() { - new_test_ext().execute_with(|| { - assert_noop!( - Relayer::set_min_relay_fee(RuntimeOrigin::signed(1), 0u128), - sp_runtime::traits::BadOrigin, - ); - }); -} - -#[test] -fn set_min_relay_fee_allows_zero() { - new_test_ext().execute_with(|| { - assert_ok!(Relayer::set_min_relay_fee(RuntimeOrigin::root(), 0u128)); - assert_eq!(MinRelayFee::::get(), 0u128); - }); -} - -// ─── AllowedSelectors ──────────────────────────────────────────────────────── - -#[test] -fn allowed_selectors_empty_by_default() { - new_test_ext().execute_with(|| { - assert!(AllowedSelectors::::get().is_empty()); - }); -} - -#[test] -fn set_allowed_selectors_updates_storage() { - new_test_ext().execute_with(|| { - let sels = vec![[0xaa, 0xbb, 0xcc, 0xdd], [0x11, 0x22, 0x33, 0x44]]; - assert_ok!(Relayer::set_allowed_selectors( - RuntimeOrigin::root(), - sels.clone() - )); - assert_eq!(AllowedSelectors::::get().to_vec(), sels); - }); -} - -#[test] -fn set_allowed_selectors_emits_event() { - new_test_ext().execute_with(|| { - let sels = vec![[0xde, 0xad, 0xbe, 0xef]]; - assert_ok!(Relayer::set_allowed_selectors(RuntimeOrigin::root(), sels)); - System::assert_last_event(Event::AllowedSelectorsUpdated { count: 1 }.into()); - }); -} - -#[test] -fn set_allowed_selectors_empty_clears_list() { - new_test_ext().execute_with(|| { - assert_ok!(Relayer::set_allowed_selectors( - RuntimeOrigin::root(), - vec![[0xaa, 0xbb, 0xcc, 0xdd]] - )); - assert_ok!(Relayer::set_allowed_selectors( - RuntimeOrigin::root(), - vec![] - )); - assert!(AllowedSelectors::::get().is_empty()); - System::assert_last_event(Event::AllowedSelectorsUpdated { count: 0 }.into()); - }); -} - -#[test] -fn set_allowed_selectors_rejects_too_many() { - new_test_ext().execute_with(|| { - // MaxAllowedSelectors = 8 in the mock - let sels: Vec<[u8; 4]> = (0u32..9).map(|i| i.to_le_bytes()).collect(); - assert_noop!( - Relayer::set_allowed_selectors(RuntimeOrigin::root(), sels), - Error::::TooManySelectors, - ); - }); -} - -#[test] -fn set_allowed_selectors_requires_manage_origin() { - new_test_ext().execute_with(|| { - assert_noop!( - Relayer::set_allowed_selectors(RuntimeOrigin::signed(1), vec![]), - sp_runtime::traits::BadOrigin, - ); - }); -} - -#[test] -fn set_allowed_selectors_accepts_max_capacity() { - new_test_ext().execute_with(|| { - // Exactly MaxAllowedSelectors (8) should succeed - let sels: Vec<[u8; 4]> = (0u32..8).map(|i| i.to_le_bytes()).collect(); - assert_ok!(Relayer::set_allowed_selectors( - RuntimeOrigin::root(), - sels.clone() - )); - assert_eq!(AllowedSelectors::::get().len(), 8); - }); -} - -// ─── set_min_relay_fee upper bound ─────────────────────────────────────────── - -#[test] -fn the_fee_ceiling_is_accepted() { - new_test_ext().execute_with(|| { - let ceiling = ::MaxMinRelayFee::get(); - assert_ok!(Relayer::set_min_relay_fee(RuntimeOrigin::root(), ceiling)); - assert_eq!(crate::MinRelayFee::::get(), ceiling); - }); -} - -#[test] -fn a_fee_above_the_ceiling_is_rejected() { - // Without the cap, governance could set u128::MAX and brick EVM relaying: - // every shielded call would fail FeeTooLow until a runtime upgrade. - new_test_ext().execute_with(|| { - let ceiling = ::MaxMinRelayFee::get(); - assert_noop!( - Relayer::set_min_relay_fee(RuntimeOrigin::root(), ceiling + 1), - Error::::MinRelayFeeTooHigh, - ); - assert_noop!( - Relayer::set_min_relay_fee(RuntimeOrigin::root(), u128::MAX), - Error::::MinRelayFeeTooHigh, - ); - }); -} - -#[test] -fn a_rejected_fee_leaves_the_previous_value_intact() { - new_test_ext().execute_with(|| { - assert_ok!(Relayer::set_min_relay_fee(RuntimeOrigin::root(), 42)); - assert_noop!( - Relayer::set_min_relay_fee(RuntimeOrigin::root(), u128::MAX), - Error::::MinRelayFeeTooHigh, - ); - assert_eq!(crate::MinRelayFee::::get(), 42); - }); -} diff --git a/frame/relayer/src/tests/dispatch_info_tests.rs b/frame/relayer/src/tests/dispatch_info_tests.rs deleted file mode 100644 index 99641f66..00000000 --- a/frame/relayer/src/tests/dispatch_info_tests.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Tests that `register_relayer` carries correct dispatch metadata. -//! -//! `register_relayer` is a signed, validator-gated call with normal weight and -//! default fee behaviour (`Pays::Yes`, `DispatchClass::Normal`). - -use crate::mock::*; -use frame_support::{ - dispatch::{DispatchClass, GetDispatchInfo, Pays}, - weights::Weight, -}; -use sp_core::H160; - -// ─── dispatch-info helpers ─────────────────────────────────────────────────── - -/// Build the `register_relayer` call so we can inspect its `DispatchInfo`. -fn register_relayer_call() -> crate::Call { - crate::Call::register_relayer { - evm_address: H160::zero(), - signature: crate::EvmSignature([0u8; 65]), - } -} - -// ─── Pays::Yes ─────────────────────────────────────────────────────────────── - -#[test] -fn register_relayer_pays_fee() { - let call = register_relayer_call(); - let info = call.get_dispatch_info(); - assert_eq!( - info.pays_fee, - Pays::Yes, - "register_relayer must be Pays::Yes — it is a user-submitted extrinsic" - ); -} - -// ─── DispatchClass::Normal ─────────────────────────────────────────────────── - -#[test] -fn register_relayer_is_normal_class() { - let call = register_relayer_call(); - let info = call.get_dispatch_info(); - assert_eq!( - info.class, - DispatchClass::Normal, - "register_relayer must be DispatchClass::Normal" - ); -} - -// ─── Weight is non-zero ────────────────────────────────────────────────────── - -#[test] -fn register_relayer_has_non_zero_weight() { - let call = register_relayer_call(); - let info = call.get_dispatch_info(); - assert!( - info.call_weight != Weight::zero(), - "register_relayer should declare a non-zero weight" - ); -} diff --git a/frame/relayer/src/tests/fees_tests.rs b/frame/relayer/src/tests/fees_tests.rs deleted file mode 100644 index bf4e27e5..00000000 --- a/frame/relayer/src/tests/fees_tests.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Tests for relay fee accounting. -//! -//! Covers: -//! - `accumulate_relay_fee` — storage update, saturation, event -//! - `pending_relay_fees` — zero for unknown, correct after accumulation -//! - `consume_relay_fee` — happy path, partial, insufficient, event - -use crate::traits::RelayerInterface; -use crate::{Error, Event, PendingRelayerFees, mock::*}; -use frame_support::{assert_noop, assert_ok}; - -// ─── accumulate_relay_fee (via RelayerInterface) ───────────────────────────── - -#[test] -fn accumulate_relay_fee_increments_balance() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 500); - assert_eq!(PendingRelayerFees::::get(1u64, 0), 500u128); - }); -} - -#[test] -fn accumulate_relay_fee_sums_multiple_calls() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 300); - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 200); - assert_eq!(PendingRelayerFees::::get(1u64, 0), 500u128); - }); -} - -#[test] -fn accumulate_relay_fee_saturates_on_overflow() { - new_test_ext().execute_with(|| { - let max = u128::MAX; - crate::Pallet::::accumulate_relay_fee(&1u64, 0, max); - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 1); - // saturating_add must not panic, result stays at u128::MAX - assert_eq!(PendingRelayerFees::::get(1u64, 0), u128::MAX); - }); -} - -#[test] -fn accumulate_relay_fee_emits_event() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 100); - System::assert_last_event( - Event::RelayFeeAccumulated { - relayer: 1u64, - asset_id: 0, - amount: 100, - } - .into(), - ); - }); -} - -#[test] -fn accumulate_independently_per_asset() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 100); - crate::Pallet::::accumulate_relay_fee(&1u64, 1, 200); - assert_eq!(PendingRelayerFees::::get(1u64, 0), 100u128); - assert_eq!(PendingRelayerFees::::get(1u64, 1), 200u128); - }); -} - -// ─── pending_relay_fees (via RelayerInterface) ─────────────────────────────── - -#[test] -fn pending_relay_fees_is_zero_by_default() { - new_test_ext().execute_with(|| { - assert_eq!(crate::Pallet::::pending_relay_fees(&99u64, 0), 0u128); - }); -} - -#[test] -fn pending_relay_fees_reflects_accumulated_amount() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&2u64, 5, 777); - assert_eq!(crate::Pallet::::pending_relay_fees(&2u64, 5), 777u128); - }); -} - -// ─── consume_relay_fee (via RelayerInterface) ──────────────────────────────── - -#[test] -fn consume_relay_fee_decrements_balance() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 1000); - assert_ok!(crate::Pallet::::consume_relay_fee(&1u64, 0, 400)); - assert_eq!(PendingRelayerFees::::get(1u64, 0), 600u128); - }); -} - -#[test] -fn consume_relay_fee_drains_to_zero() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 500); - assert_ok!(crate::Pallet::::consume_relay_fee(&1u64, 0, 500)); - assert_eq!(PendingRelayerFees::::get(1u64, 0), 0u128); - }); -} - -#[test] -fn consume_relay_fee_emits_event() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 300); - assert_ok!(crate::Pallet::::consume_relay_fee(&1u64, 0, 100)); - System::assert_last_event( - Event::RelayFeesConsumed { - relayer: 1u64, - asset_id: 0, - amount: 100, - } - .into(), - ); - }); -} - -#[test] -fn consume_relay_fee_fails_if_insufficient() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 50); - assert_noop!( - crate::Pallet::::consume_relay_fee(&1u64, 0, 51), - Error::::InsufficientPendingFees, - ); - // Storage unchanged after failure - assert_eq!(PendingRelayerFees::::get(1u64, 0), 50u128); - }); -} - -#[test] -fn consume_relay_fee_fails_for_unknown_account() { - new_test_ext().execute_with(|| { - assert_noop!( - crate::Pallet::::consume_relay_fee(&99u64, 0, 1), - Error::::InsufficientPendingFees, - ); - }); -} - -// ─── Cross-account isolation ───────────────────────────────────────────────── - -/// `consume_relay_fee` (called internally by claim_relay_fees) also isolates -/// by AccountId — passing account 1's fees to account 2 is impossible. -#[test] -fn consume_relay_fee_cannot_drain_another_accounts_fees() { - new_test_ext().execute_with(|| { - crate::Pallet::::accumulate_relay_fee(&1u64, 0, 500); - - // Attempting to consume on behalf of account 2 fails (its balance is 0) - assert_noop!( - crate::Pallet::::consume_relay_fee(&2u64, 0, 1), - Error::::InsufficientPendingFees, - ); - - // Account 1's balance is untouched - assert_eq!(PendingRelayerFees::::get(1u64, 0), 500u128); - }); -} diff --git a/frame/relayer/src/tests/mod.rs b/frame/relayer/src/tests/mod.rs deleted file mode 100644 index 573fbc24..00000000 --- a/frame/relayer/src/tests/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Tests for pallet-relayer. -//! -//! Modules: -//! - `config_tests` — MinRelayFee and AllowedSelectors governance -//! - `dispatch_info_tests` — Pays::Yes + DispatchClass::Normal on register_relayer -//! - `registry_tests` — EVM address ↔ AccountId binding lifecycle -//! - `ownership_proof_tests` — proof of key control, and which addresses count -//! - `cleanup_tests` — `clear_relayer`, the validator-exit hook -//! - `commit_tests` — relay commits: recording, attribution, expiry -//! - `fees_tests` — relay fee accrual and consumption via RelayerInterface - -mod cleanup_tests; -mod commit_tests; -mod config_tests; -mod dispatch_info_tests; -mod fees_tests; -mod ownership_proof_tests; -mod registry_tests; diff --git a/frame/relayer/src/tests/ownership_proof_tests.rs b/frame/relayer/src/tests/ownership_proof_tests.rs deleted file mode 100644 index 921c53f3..00000000 --- a/frame/relayer/src/tests/ownership_proof_tests.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Proof that the caller controls the EVM address it is claiming. -//! -//! A relay address is public — it is the `caller` of every relay transaction — -//! and the registry is first-come-first-served. Without these checks one approved -//! validator could claim another's address, divert its fees, and lock the owner -//! out permanently via `AlreadyRegistered`. - -use crate::{Error, EvmSignature, RelayerRegistry, mock::*}; -use frame_support::{assert_noop, assert_ok}; -use sp_core::H160; - -// ─── Proof of control over the EVM key ─────────────────────────────────────── - -#[test] -fn cannot_claim_an_address_without_its_key() { - // The attack the proof exists to stop: validator 2 tries to squat the address - // validator 1 controls, so that 1's relay fees would accrue to 2. - new_test_ext().execute_with(|| { - set_mock_validator(1); - set_mock_validator(2); - let victim_address = address_for_seed(1, seeds::ALICE); - - // Account 2 signs with its OWN key but names the victim's address. - let (_, own_signature) = proof_for(2, seeds::BOB); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(2), victim_address, own_signature), - Error::::BadEvmSignature, - ); - - // The victim can still register. - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - assert_eq!(evm, victim_address); - assert_eq!(RelayerRegistry::::get(victim_address), Some(1u64)); - }); -} - -#[test] -fn a_signature_bound_to_another_account_is_rejected() { - // Replay resistance: the proof account 1 published on-chain must not let - // account 2 register the same address. - new_test_ext().execute_with(|| { - set_mock_validator(1); - set_mock_validator(2); - let (evm, signature_for_1) = proof_for(1, seeds::ALICE); - - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(2), evm, signature_for_1), - Error::::BadEvmSignature, - ); - }); -} - -#[test] -fn a_malformed_signature_is_rejected() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let evm = address_for_seed(1, seeds::ALICE); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(1), evm, EvmSignature([0u8; 65])), - Error::::BadEvmSignature, - ); - }); -} - -#[test] -fn a_tampered_signature_is_rejected() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, mut sig) = proof_for(1, seeds::ALICE); - sig.0[0] ^= 0xff; - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(1), evm, sig), - Error::::BadEvmSignature, - ); - }); -} - -#[test] -fn an_out_of_range_recovery_id_is_rejected() { - // v must be 0/1 or 27/28; anything else must fail cleanly rather than panic. - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, mut sig) = proof_for(1, seeds::ALICE); - sig.0[64] = 42; - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(1), evm, sig), - Error::::BadEvmSignature, - ); - }); -} - -#[test] -fn a_wallet_style_recovery_id_is_accepted() { - // Wallets emit v as 27/28; the pallet normalises it. - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, mut sig) = proof_for(1, seeds::ALICE); - sig.0[64] += 27; - assert_ok!(Relayer::register_relayer( - RuntimeOrigin::signed(1), - evm, - sig - )); - assert_eq!(RelayerRegistry::::get(evm), Some(1u64)); - }); -} - -// ─── Reserved EVM addresses ────────────────────────────────────────────────── - -#[test] -fn the_zero_address_is_rejected() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (_, sig) = proof_for(1, seeds::ALICE); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(1), H160::zero(), sig), - Error::::InvalidEvmAddress, - ); - }); -} - -#[test] -fn precompile_addresses_are_rejected() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - // 0x0801 is the shielded-pool precompile in the runtime; the whole low - // range is reserved because those "callers" are the runtime itself. - for raw in [1u64, 5, 0x400, 0x801, 0xffff] { - let (_, sig) = proof_for(1, seeds::ALICE); - assert_noop!( - Relayer::register_relayer( - RuntimeOrigin::signed(1), - H160::from_low_u64_be(raw), - sig - ), - Error::::InvalidEvmAddress, - ); - } - }); -} - -#[test] -fn the_first_address_above_the_reserved_range_is_allowed() { - // Boundary: 0x10000 is one past the precompile ceiling. - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (_, sig) = proof_for(1, seeds::ALICE); - // Signature will not match, but the address check must pass first. - assert_noop!( - Relayer::register_relayer( - RuntimeOrigin::signed(1), - H160::from_low_u64_be(0x10000), - sig - ), - Error::::BadEvmSignature, - ); - }); -} - -#[test] -fn a_high_address_sharing_the_low_bytes_is_allowed() { - // Only the whole 20-byte value counts as reserved, not its low 8 bytes. - new_test_ext().execute_with(|| { - set_mock_validator(1); - let mut bytes = [0u8; 20]; - bytes[0] = 0x01; // high byte set -> not in the reserved range - bytes[19] = 0x01; // low bytes look like precompile 0x1 - let (_, sig) = proof_for(1, seeds::ALICE); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(1), H160(bytes), sig), - Error::::BadEvmSignature, - ); - }); -} diff --git a/frame/relayer/src/tests/registry_tests.rs b/frame/relayer/src/tests/registry_tests.rs deleted file mode 100644 index c7515ceb..00000000 --- a/frame/relayer/src/tests/registry_tests.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Registration lifecycle of the EVM address ↔ AccountId binding. -//! -//! Covers `register_relayer`'s non-cryptographic guards, `unregister_relayer`, -//! and the `RelayerInterface` reverse lookup. The ownership proof has its own -//! file ([`super::ownership_proof_tests`]), as does the cleanup hook -//! ([`super::cleanup_tests`]). - -use crate::{Error, Event, RelayerByAccount, RelayerRegistry, mock::*, traits::RelayerInterface}; -use frame_support::{assert_noop, assert_ok}; - -// ─── register_relayer ──────────────────────────────────────────────────────── - -#[test] -fn approved_validator_can_register_relayer() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - assert_eq!(RelayerRegistry::::get(evm), Some(1u64)); - assert_eq!(RelayerByAccount::::get(1u64), Some(evm)); - }); -} - -#[test] -fn register_emits_event() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - System::assert_last_event( - Event::RelayerRegistered { - evm_address: evm, - account: 1u64, - } - .into(), - ); - }); -} - -#[test] -fn register_relayer_fails_when_not_a_validator() { - new_test_ext().execute_with(|| { - // Account 1 is not in the validator set — this is what stops an arbitrary - // account from claiming an EVM address, since the call is self-service. - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_noop!(result, Error::::NotValidator); - assert!(!RelayerRegistry::::contains_key(evm)); - }); -} - -#[test] -fn register_relayer_fails_once_removed_from_the_validator_set() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - clear_mock_validator(1); - let (_, result) = register_with_proof(1, seeds::ALICE); - assert_noop!(result, Error::::NotValidator); - }); -} - -#[test] -fn register_relayer_requires_signed() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, sig) = proof_for(1, seeds::ALICE); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::none(), evm, sig.clone()), - sp_runtime::traits::BadOrigin, - ); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::root(), evm, sig), - sp_runtime::traits::BadOrigin, - ); - }); -} - -// ─── Duplicate guards ──────────────────────────────────────────────────────── - -#[test] -fn register_fails_if_evm_already_taken() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - set_mock_validator(2); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - // Account 2 would need account 1's key to even produce this signature; the - // test writes it directly to isolate the AlreadyRegistered guard. - let (_, sig_for_2) = proof_for(2, seeds::ALICE); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(2), evm, sig_for_2), - Error::::AlreadyRegistered, - ); - }); -} - -#[test] -fn a_failed_duplicate_registration_leaves_the_reverse_index_clean() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - set_mock_validator(2); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - - let (_, sig_for_2) = proof_for(2, seeds::ALICE); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(2), evm, sig_for_2), - Error::::AlreadyRegistered, - ); - - // Neither index may have moved. - assert!(!RelayerByAccount::::contains_key(2u64)); - assert_eq!(RelayerRegistry::::get(evm), Some(1u64)); - }); -} - -#[test] -fn register_fails_if_account_already_registered() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - assert_ok!(register_with_proof(1, seeds::ALICE).1); - let (second, result) = register_with_proof(1, seeds::BOB); - assert_noop!(result, Error::::AccountAlreadyRegistered); - // The second address must not have been claimed. - assert!(!RelayerRegistry::::contains_key(second)); - }); -} - -#[test] -fn two_validators_can_register_different_evm_addresses() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - set_mock_validator(2); - let (evm1, r1) = register_with_proof(1, seeds::ALICE); - let (evm2, r2) = register_with_proof(2, seeds::BOB); - assert_ok!(r1); - assert_ok!(r2); - assert_ne!(evm1, evm2); - assert_eq!(RelayerRegistry::::get(evm1), Some(1u64)); - assert_eq!(RelayerRegistry::::get(evm2), Some(2u64)); - }); -} - -#[test] -fn registration_works_at_the_top_of_the_validator_set() { - // The gate scans the whole set; the last member must still get through. - new_test_ext().execute_with(|| { - for who in 1..=32u64 { - set_mock_validator(who); - } - let (evm, result) = register_with_proof(32, seeds::CAROL); - assert_ok!(result); - assert_eq!(RelayerRegistry::::get(evm), Some(32u64)); - }); -} - -// ─── unregister_relayer ────────────────────────────────────────────────────── - -#[test] -fn unregister_works() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(1))); - assert!(!RelayerRegistry::::contains_key(evm)); - assert!(!RelayerByAccount::::contains_key(1u64)); - }); -} - -#[test] -fn unregister_emits_event() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(1))); - System::assert_last_event( - Event::RelayerUnregistered { - evm_address: evm, - account: 1u64, - } - .into(), - ); - }); -} - -#[test] -fn unregister_fails_if_not_registered() { - new_test_ext().execute_with(|| { - assert_noop!( - Relayer::unregister_relayer(RuntimeOrigin::signed(1)), - Error::::NotRegistered, - ); - }); -} - -#[test] -fn unregister_requires_signed() { - new_test_ext().execute_with(|| { - assert_noop!( - Relayer::unregister_relayer(RuntimeOrigin::none()), - sp_runtime::traits::BadOrigin, - ); - }); -} - -#[test] -fn can_re_register_after_unregister() { - new_test_ext().execute_with(|| { - set_mock_validator(1); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(1))); - // Unregistering frees both the account and the address. - assert_ok!(register_with_proof(1, seeds::ALICE).1); - assert_eq!(RelayerRegistry::::get(evm), Some(1u64)); - }); -} - -#[test] -fn unregister_frees_the_address_for_its_owner_only() { - // Another validator still cannot take it — they lack the key. - new_test_ext().execute_with(|| { - set_mock_validator(1); - set_mock_validator(2); - let (evm, result) = register_with_proof(1, seeds::ALICE); - assert_ok!(result); - assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(1))); - - let (_, sig_for_2) = proof_for(2, seeds::BOB); - assert_noop!( - Relayer::register_relayer(RuntimeOrigin::signed(2), evm, sig_for_2), - Error::::BadEvmSignature, - ); - }); -} - -// ─── registered_evm_address (RelayerInterface reverse lookup) ──────────────── - -#[test] -fn registered_evm_address_returns_none_for_unknown_account() { - new_test_ext().execute_with(|| { - assert_eq!(crate::Pallet::::registered_evm_address(&1u64), None); - }); -} - -#[test] -fn registered_evm_address_returns_correct_address() { - new_test_ext().execute_with(|| { - set_mock_validator(100); - let (evm, result) = register_with_proof(100, seeds::ALICE); - assert_ok!(result); - assert_eq!( - crate::Pallet::::registered_evm_address(&100u64), - Some(evm), - ); - }); -} - -#[test] -fn registered_evm_address_returns_none_after_unregister() { - new_test_ext().execute_with(|| { - set_mock_validator(100); - assert_ok!(register_with_proof(100, seeds::ALICE).1); - assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(100))); - assert_eq!(crate::Pallet::::registered_evm_address(&100u64), None); - }); -} - -#[test] -fn registered_evm_address_is_account_specific() { - new_test_ext().execute_with(|| { - set_mock_validator(100); - set_mock_validator(101); - let (evm_a, r1) = register_with_proof(100, seeds::ALICE); - let (evm_b, r2) = register_with_proof(101, seeds::BOB); - assert_ok!(r1); - assert_ok!(r2); - assert_eq!( - crate::Pallet::::registered_evm_address(&100u64), - Some(evm_a), - ); - assert_eq!( - crate::Pallet::::registered_evm_address(&101u64), - Some(evm_b), - ); - }); -} diff --git a/frame/relayer/src/traits.rs b/frame/relayer/src/traits.rs deleted file mode 100644 index 126ebadc..00000000 --- a/frame/relayer/src/traits.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Public traits exposed by pallet-relayer. -//! -//! Other pallets (e.g. `pallet-shielded-pool`) depend only on these traits, -//! never on the concrete `Pallet` type. Tests supply lightweight mock -//! implementations directly in their own `mock.rs`. - -/// All relay-related behaviour that external pallets need. -/// -/// Production impl: `pallet_relayer::Pallet`. -/// Test impl: a lightweight struct in each dependent pallet's `mock.rs`. -pub trait RelayerInterface { - type AccountId: Clone; - - /// Resolve an EVM address to a registered substrate AccountId. - /// - /// Returns `Some(account)` when the EVM address was registered via - /// `register_relayer`. Returns `None` when not registered. - /// - /// Used by `pallet-shielded-pool` to find whose fees an EVM caller of - /// `claim_relay_fees` is claiming. - fn resolve_relayer(evm_address: &sp_core::H160) -> Option; - - /// Minimum fee (planck / wei) that must be embedded in relay calldata. - fn min_relay_fee() -> u128; - - /// ABI selectors currently accepted by the relay whitelist. - /// - /// An empty `Vec` means "use built-in defaults" (resolved by the Runtime - /// API impl so the relay always has a non-empty list). - fn allowed_selectors() -> sp_std::vec::Vec<[u8; 4]>; - - /// Current block author: the fee recipient of a spend no relayer committed to. - /// - /// Returns `None` when unavailable (e.g. first block or no author pallet - /// configured). - fn block_author() -> Option; - - /// Credit `amount` planck of relay fee for `asset_id` to `author`. - /// - /// Called by pallet-shielded-pool after a successful `private_transfer` - /// or `unshield` unsigned extrinsic. - /// - /// **Caller contract:** `asset_id` must already be known to exist. This - /// creates a `PendingRelayerFees` entry unconditionally, so an unchecked - /// caller could bloat storage with rows for assets that were never - /// registered. Both current callers validate first — `unshield` and - /// `private_transfer` resolve the asset and reject `InvalidAssetId` before - /// any fee path runs — and a new caller must do the same rather than - /// rely on a check here, which would duplicate a lookup the caller has - /// already performed. - fn accumulate_relay_fee(author: &Self::AccountId, asset_id: u32, amount: u128); - - /// Return the total pending relay fees for (`who`, `asset_id`) in planck. - fn pending_relay_fees(who: &Self::AccountId, asset_id: u32) -> u128; - - /// Deduct `amount` from the pending relay fees for (`who`, `asset_id`). - /// - /// Returns `Err` when the pending balance is insufficient. Called by - /// pallet-shielded-pool's `claim_relay_fees` before paying the fees out. - fn consume_relay_fee( - who: &Self::AccountId, - asset_id: u32, - amount: u128, - ) -> frame_support::dispatch::DispatchResult; - - /// Return the EVM address registered for a substrate account, if any. - /// - /// Reverse lookup of `resolve_relayer`. Used by `pallet-shielded-pool` - /// to derive the H160 mirror AccountId when paying relay fees directly - /// to the EVM account. - fn registered_evm_address(who: &Self::AccountId) -> Option; - - /// Record relay commits (see [`crate::relay_commit_hash`]) on behalf of the - /// registered `relayer`. - /// - /// A commit already present is left as is, so it keeps its earlier block; - /// only new ones count against the per-block quota, which belongs to the - /// validator behind `relayer`. A batch with nothing new writes nothing. All - /// or nothing: fails with `NotRegistered` for an unknown address and - /// `TooManyCommits` past the quota, writing none of the batch. - fn record_relay_commits( - relayer: &sp_core::H160, - commits: &[sp_core::H256], - ) -> frame_support::dispatch::DispatchResult; - - /// The relayer to credit for the spend identified by `op_hash`: the - /// registered relayer with the earliest commit for it from a previous block. - /// - /// Removes every commit found for `op_hash`, since the spend can only execute - /// once. `None` when no relayer committed in time. Commits from the same - /// block tie; the lowest commit hash wins. Scans the registry, so the cost - /// grows with the number of registered relayers (one per validator). A commit - /// made under an address its validator has since replaced does not match, so - /// it earns nothing. - fn take_committed_relayer(op_hash: &[u8; 32]) -> Option; -} diff --git a/frame/relayer/src/weights.rs b/frame/relayer/src/weights.rs deleted file mode 100644 index f2ae7e29..00000000 --- a/frame/relayer/src/weights.rs +++ /dev/null @@ -1,278 +0,0 @@ - -//! Autogenerated weights for pallet_relayer -//! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 58.0.1 -//! DATE: 2026-10-06, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` -//! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `ubuntu-32gb-fsn1-1`, CPU: `AMD EPYC-Genoa Processor` -//! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 - -// Executed Command: -// ./target/release/orbinum-node -// benchmark -// pallet -// --chain -// dev -// --pallet -// pallet_relayer -// --extrinsic -// * -// --steps -// 50 -// --repeat -// 20 -// --wasm-execution=compiled -// --heap-pages=4096 -// --default-pov-mode -// measured -// --no-median-slopes -// --output -// ./frame/relayer/src/weights.rs -// --template -// ./scripts/benchmarks/frame-weight-template.hbs - -#![cfg_attr(rustfmt, rustfmt_skip)] -#![allow(unused_parens)] -#![allow(unused_imports)] -#![allow(missing_docs)] - -use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; -use core::marker::PhantomData; - -/// Weight functions needed for pallet_relayer. -pub trait WeightInfo { - fn set_min_relay_fee() -> Weight; - fn set_allowed_selectors(n: u32, ) -> Weight; - fn register_relayer() -> Weight; - fn unregister_relayer() -> Weight; - fn prune_relay_commits(r: u32, n: u32, ) -> Weight; -} - -/// Weights for pallet_relayer using the Substrate node and recommended hardware. -pub struct SubstrateWeight(PhantomData); -impl WeightInfo for SubstrateWeight { - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::MinRelayFee` (r:0 w:1) - /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) - fn set_min_relay_fee() -> Weight { - // Proof Size summary in bytes: - // Measured: `19` - // Estimated: `1504` - // Minimum execution time: 6_310_000 picoseconds. - Weight::from_parts(6_820_000, 1504) - .saturating_add(T::DbWeight::get().reads(4_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) - } - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::AllowedSelectors` (r:0 w:1) - /// Proof: `Relayer::AllowedSelectors` (`max_values`: Some(1), `max_size`: Some(65), added: 560, mode: `Measured`) - /// The range of component `n` is `[0, 16]`. - fn set_allowed_selectors(n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `19` - // Estimated: `1504` - // Minimum execution time: 6_570_000 picoseconds. - Weight::from_parts(7_120_033, 1504) - // Standard Error: 97 - .saturating_add(Weight::from_parts(5_956, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(4_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) - } - /// Storage: `ValidatorSet::ApprovedValidators` (r:1 w:0) - /// Proof: `ValidatorSet::ApprovedValidators` (`max_values`: Some(1), `max_size`: Some(1025), added: 1520, mode: `Measured`) - /// Storage: `System::BlockHash` (r:1 w:0) - /// Proof: `System::BlockHash` (`max_values`: None, `max_size`: Some(44), added: 2519, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:1 w:1) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn register_relayer() -> Weight { - // Proof Size summary in bytes: - // Measured: `283` - // Estimated: `3748` - // Minimum execution time: 57_060_000 picoseconds. - Weight::from_parts(59_120_000, 3748) - .saturating_add(T::DbWeight::get().reads(8_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) - } - /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:0 w:1) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - fn unregister_relayer() -> Weight { - // Proof Size summary in bytes: - // Measured: `223` - // Estimated: `3688` - // Minimum execution time: 13_280_000 picoseconds. - Weight::from_parts(14_110_000, 3688) - .saturating_add(T::DbWeight::get().reads(5_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) - } - /// Storage: `Relayer::CommitsByRelayer` (r:33 w:32) - /// Proof: `Relayer::CommitsByRelayer` (`max_values`: None, `max_size`: Some(2110), added: 4585, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:2048 w:2048) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// The range of component `r` is `[0, 32]`. - /// The range of component `n` is `[0, 2048]`. - fn prune_relay_commits(r: u32, n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `0 + n * (93 ±0) + r * (4652 ±0)` - // Estimated: `108330 + n * (852 ±3) + r * (59076 ±208)` - // Minimum execution time: 131_250_000 picoseconds. - Weight::from_parts(134_830_000, 108330) - // Standard Error: 4_742_414 - .saturating_add(Weight::from_parts(154_221_135, 0).saturating_mul(r.into())) - // Standard Error: 73_715 - .saturating_add(Weight::from_parts(2_195_591, 0).saturating_mul(n.into())) - .saturating_add(T::DbWeight::get().reads(33_u64)) - .saturating_add(T::DbWeight::get().reads((24_u64).saturating_mul(r.into()))) - .saturating_add(T::DbWeight::get().writes(32_u64)) - .saturating_add(T::DbWeight::get().writes((24_u64).saturating_mul(r.into()))) - .saturating_add(Weight::from_parts(0, 852).saturating_mul(n.into())) - .saturating_add(Weight::from_parts(0, 59076).saturating_mul(r.into())) - } -} - -// For backwards compatibility and tests -impl WeightInfo for () { - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::MinRelayFee` (r:0 w:1) - /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `Measured`) - fn set_min_relay_fee() -> Weight { - // Proof Size summary in bytes: - // Measured: `19` - // Estimated: `1504` - // Minimum execution time: 6_310_000 picoseconds. - Weight::from_parts(6_820_000, 1504) - .saturating_add(RocksDbWeight::get().reads(4_u64)) - .saturating_add(RocksDbWeight::get().writes(3_u64)) - } - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::AllowedSelectors` (r:0 w:1) - /// Proof: `Relayer::AllowedSelectors` (`max_values`: Some(1), `max_size`: Some(65), added: 560, mode: `Measured`) - /// The range of component `n` is `[0, 16]`. - fn set_allowed_selectors(n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `19` - // Estimated: `1504` - // Minimum execution time: 6_570_000 picoseconds. - Weight::from_parts(7_120_033, 1504) - // Standard Error: 97 - .saturating_add(Weight::from_parts(5_956, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(4_u64)) - .saturating_add(RocksDbWeight::get().writes(3_u64)) - } - /// Storage: `ValidatorSet::ApprovedValidators` (r:1 w:0) - /// Proof: `ValidatorSet::ApprovedValidators` (`max_values`: Some(1), `max_size`: Some(1025), added: 1520, mode: `Measured`) - /// Storage: `System::BlockHash` (r:1 w:0) - /// Proof: `System::BlockHash` (`max_values`: None, `max_size`: Some(44), added: 2519, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:1 w:1) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - fn register_relayer() -> Weight { - // Proof Size summary in bytes: - // Measured: `283` - // Estimated: `3748` - // Minimum execution time: 57_060_000 picoseconds. - Weight::from_parts(59_120_000, 3748) - .saturating_add(RocksDbWeight::get().reads(8_u64)) - .saturating_add(RocksDbWeight::get().writes(4_u64)) - } - /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::ExecutionPhase` (r:1 w:0) - /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `Measured`) - /// Storage: `System::EventCount` (r:1 w:1) - /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) - /// Storage: `System::Events` (r:1 w:1) - /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:0 w:1) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) - fn unregister_relayer() -> Weight { - // Proof Size summary in bytes: - // Measured: `223` - // Estimated: `3688` - // Minimum execution time: 13_280_000 picoseconds. - Weight::from_parts(14_110_000, 3688) - .saturating_add(RocksDbWeight::get().reads(5_u64)) - .saturating_add(RocksDbWeight::get().writes(4_u64)) - } - /// Storage: `Relayer::CommitsByRelayer` (r:33 w:32) - /// Proof: `Relayer::CommitsByRelayer` (`max_values`: None, `max_size`: Some(2110), added: 4585, mode: `Measured`) - /// Storage: `Relayer::RelayCommits` (r:2048 w:2048) - /// Proof: `Relayer::RelayCommits` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `Measured`) - /// The range of component `r` is `[0, 32]`. - /// The range of component `n` is `[0, 2048]`. - fn prune_relay_commits(r: u32, n: u32, ) -> Weight { - // Proof Size summary in bytes: - // Measured: `0 + n * (93 ±0) + r * (4652 ±0)` - // Estimated: `108330 + n * (852 ±3) + r * (59076 ±208)` - // Minimum execution time: 131_250_000 picoseconds. - Weight::from_parts(134_830_000, 108330) - // Standard Error: 4_742_414 - .saturating_add(Weight::from_parts(154_221_135, 0).saturating_mul(r.into())) - // Standard Error: 73_715 - .saturating_add(Weight::from_parts(2_195_591, 0).saturating_mul(n.into())) - .saturating_add(RocksDbWeight::get().reads(33_u64)) - .saturating_add(RocksDbWeight::get().reads((24_u64).saturating_mul(r.into()))) - .saturating_add(RocksDbWeight::get().writes(32_u64)) - .saturating_add(RocksDbWeight::get().writes((24_u64).saturating_mul(r.into()))) - .saturating_add(Weight::from_parts(0, 852).saturating_mul(n.into())) - .saturating_add(Weight::from_parts(0, 59076).saturating_mul(r.into())) - } -} diff --git a/frame/validator-set/CHANGELOG.md b/frame/validator-set/CHANGELOG.md index ebb7a703..c495f29d 100644 --- a/frame/validator-set/CHANGELOG.md +++ b/frame/validator-set/CHANGELOG.md @@ -7,6 +7,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +--- + +## [0.4.1] — 2026-10-13 + +Docs only; ships with runtime spec 19. + ### Fixed - `Config::FindAuthor` is documented as returning the author's validator account @@ -15,6 +21,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). account was never recorded in `LastAuthorVersion` and the quorum for `set_min_author_version` could not be reached; runtime spec 19 maps the Aura authority index to its session validator instead. +- Docs and tests no longer refer to `pallet-relayer`, removed in spec 19; + `ValidatorSetInterface` and `OnValidatorRemoved` stay as generic ports. --- diff --git a/frame/validator-set/Cargo.toml b/frame/validator-set/Cargo.toml index 84574170..84bb8c42 100644 --- a/frame/validator-set/Cargo.toml +++ b/frame/validator-set/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-validator-set" -version = "0.4.0" +version = "0.4.1" description = "Sudo-controlled validator set for Orbinum. Validators can only join via governance." authors = { workspace = true } license = "GPL-3.0-or-later" diff --git a/frame/validator-set/src/lib.rs b/frame/validator-set/src/lib.rs index 35560fff..7ac67bc1 100644 --- a/frame/validator-set/src/lib.rs +++ b/frame/validator-set/src/lib.rs @@ -10,8 +10,6 @@ //! 2. `AddRemoveOrigin` (sudo / governance) calls //! [`add_validator`][Pallet::add_validator] with the operator's `AccountId`. //! The account joins the approved set and produces blocks from the next session. -//! 3. Once approved, the operator may register an EVM relay address via -//! `relayer.register_relayer` (gated on membership of the approved set). //! //! ## Leaving //! @@ -30,7 +28,7 @@ //! - `add_validator` requires the account to have session keys already registered: //! an approved account without keys would occupy a slot without authoring blocks. //! - Leaving the set (by either path) notifies [`Config::OnValidatorRemoved`] so -//! dependent state, such as the EVM relay binding, is cleaned up. +//! state that depends on membership can be cleaned up. //! //! ## Minimum author version //! diff --git a/frame/validator-set/src/tests/mod.rs b/frame/validator-set/src/tests/mod.rs index 9421bc2a..13ccf9e0 100644 --- a/frame/validator-set/src/tests/mod.rs +++ b/frame/validator-set/src/tests/mod.rs @@ -289,7 +289,7 @@ fn deregister_does_not_touch_balances() { }); } -// ── ValidatorSetInterface (consumed by pallet-relayer) ──────────────────────── +// ── ValidatorSetInterface ───────────────────────────────────────────────────── #[test] fn interface_reports_membership() { @@ -306,9 +306,8 @@ fn interface_reports_membership() { #[test] fn interface_tracks_additions_and_removals() { - // This is the gate pallet-relayer depends on: an account must stop being an - // active validator the moment it leaves the set, or its EVM binding could - // outlive the membership that authorised it. + // A consumer gating on membership relies on this: an account stops being an + // active validator the moment it leaves the set. use crate::ValidatorSetInterface; ExtBuilder::default() diff --git a/frame/validator-set/src/traits.rs b/frame/validator-set/src/traits.rs index 53ed701b..66d76a1b 100644 --- a/frame/validator-set/src/traits.rs +++ b/frame/validator-set/src/traits.rs @@ -1,14 +1,12 @@ //! Public traits exposed by pallet-validator-set. //! -//! Mirrors the port pattern used by `pallet_relayer::RelayerInterface`: other -//! pallets depend on these traits, never on concrete storage. Tests supply +//! Other pallets depend on these traits, never on concrete storage. Tests supply //! lightweight mock implementations in their own `mock.rs`. /// Read access to the approved validator set. /// -/// Production impl: `pallet_validator_set::Pallet`. -/// Consumed by `pallet-relayer` to gate EVM relay address registration on -/// membership of the active set. +/// Production impl: `pallet_validator_set::Pallet`. For a pallet that gates an +/// action on membership of the active set. pub trait ValidatorSetInterface { /// Returns `true` when `who` is in the approved (active) validator set. fn is_active_validator(who: &AccountId) -> bool; @@ -55,7 +53,7 @@ pub trait ValidatorPrerequisites { /// Notified when an account leaves the approved validator set. /// /// Lets the runtime clean up state that is only valid while the account is an -/// active validator — for instance the EVM relay binding in `pallet-relayer`. +/// active validator. /// /// Deliberately infallible: leaving the set must always succeed, so a failure to /// clean up dependent state can never block removal. A no-op is the safe default @@ -77,7 +75,7 @@ pub trait OnValidatorRemoved { } /// Tuple impl, so a runtime can subscribe several consumers: -/// `type OnValidatorRemoved = (RelayerCleanup, SomethingElse);` +/// `type OnValidatorRemoved = (FirstCleanup, SecondCleanup);` /// /// This also covers `()` — the empty tuple is the no-op default, which is safe /// because dependent cleanup is opt-in. diff --git a/frame/validator-set/src/weights.rs b/frame/validator-set/src/weights.rs index 8f48e3d6..5f6697e6 100644 --- a/frame/validator-set/src/weights.rs +++ b/frame/validator-set/src/weights.rs @@ -6,6 +6,10 @@ //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `ubuntu-32gb-fsn1-1`, CPU: `AMD EPYC-Genoa Processor` //! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 +//! +//! Spec 19, by hand until re-benchmarked: `remove_validator` drops the relay +//! binding cleanup (the runtime's `OnValidatorRemoved` is now `()`); its time and +//! proof-size terms still include it, so it over-estimates. // Executed Command: // ./target/release/orbinum-node @@ -73,8 +77,6 @@ impl WeightInfo for SubstrateWeight { } /// Storage: `ValidatorSet::ApprovedValidators` (r:1 w:1) /// Proof: `ValidatorSet::ApprovedValidators` (`max_values`: Some(1), `max_size`: Some(1025), added: 1520, mode: `Measured`) - /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) /// Storage: `System::Number` (r:1 w:0) /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::ExecutionPhase` (r:1 w:0) @@ -83,16 +85,14 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::Events` (r:1 w:1) /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:0 w:1) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) fn remove_validator() -> Weight { // Proof Size summary in bytes: // Measured: `1317` // Estimated: `4782` // Minimum execution time: 20_200_000 picoseconds. Weight::from_parts(21_290_000, 4782) - .saturating_add(T::DbWeight::get().reads(6_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) } /// Estimated, not yet benchmarked on the reference hardware: one read of the set, /// one `LastAuthorVersion` read per validator (`MaxValidators` = 32), one write. @@ -135,8 +135,6 @@ impl WeightInfo for () { } /// Storage: `ValidatorSet::ApprovedValidators` (r:1 w:1) /// Proof: `ValidatorSet::ApprovedValidators` (`max_values`: Some(1), `max_size`: Some(1025), added: 1520, mode: `Measured`) - /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) - /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) /// Storage: `System::Number` (r:1 w:0) /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::ExecutionPhase` (r:1 w:0) @@ -145,16 +143,14 @@ impl WeightInfo for () { /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `Measured`) /// Storage: `System::Events` (r:1 w:1) /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `Relayer::RelayerRegistry` (r:0 w:1) - /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `Measured`) fn remove_validator() -> Weight { // Proof Size summary in bytes: // Measured: `1317` // Estimated: `4782` // Minimum execution time: 20_200_000 picoseconds. Weight::from_parts(21_290_000, 4782) - .saturating_add(RocksDbWeight::get().reads(6_u64)) - .saturating_add(RocksDbWeight::get().writes(5_u64)) + .saturating_add(RocksDbWeight::get().reads(5_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) } /// Estimated, not yet benchmarked on the reference hardware: one read of the set, /// one `LastAuthorVersion` read per validator (`MaxValidators` = 32), one write. diff --git a/scripts/benchmarks/run_benchmarks.sh b/scripts/benchmarks/run_benchmarks.sh index 5b142413..b3bac93b 100755 --- a/scripts/benchmarks/run_benchmarks.sh +++ b/scripts/benchmarks/run_benchmarks.sh @@ -16,16 +16,16 @@ # # `--no-median-slopes` keeps memory flat. The median-slopes analysis is only printed — # weights come from min-squares (`--output-analysis` default) — but in the 2606 CLI it -# grows with repeats on any benchmark with a component: `pallet_relayer` and -# `pallet_ismp_messaging` passed 31 GB and were OOM-killed on the CPX62. Without it, -# `set_allowed_selectors` at 50/20 peaks at 0.4 GB instead of several GB. +# grows with repeats on any benchmark with a component: `pallet_ismp_messaging` passed +# 31 GB and was OOM-killed on the CPX62. Without it, a component benchmark at 50/20 +# peaks at 0.4 GB instead of several GB. # # Publishable weights need the defaults (50/20). Lowering --steps/--repeat gives numbers # good for shape, not for committing. # # `--default-pov-mode measured` records the storage footprint actually observed instead # of the theoretical maximum. It is a no-op for a pallet whose maps declare their size -# (verified: relayer, validator-set and zk-verifier produce identical proof sizes either +# (verified: validator-set and zk-verifier produce identical proof sizes either # way), but it is what keeps `pallet_ismp_messaging::dispatch_post` honest: that call # touches ~50 `RequestCommitments` keys owned by `pallet-ismp`, which declares no # `max_size`, so the default `max-encoded-len` mode assumes --map-size (1,000,000) per @@ -91,7 +91,6 @@ PALLETS=( # pallet:output:group "pallet_zk_verifier:./frame/zk-verifier/src/weights.rs:core" "pallet_shielded_pool:./frame/shielded-pool/src/weights.rs:core" - "pallet_relayer:./frame/relayer/src/weights.rs:core" "pallet_validator_set:./frame/validator-set/src/weights.rs:core" # ISMP crates we do not own: the WeightInfo trait belongs to the upstream crate, so # the generated impl lives runtime-side rather than in the pallet. Every output under diff --git a/template/node/Cargo.toml b/template/node/Cargo.toml index f4de9419..7f6ca764 100644 --- a/template/node/Cargo.toml +++ b/template/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-node" -version = "0.4.0" +version = "0.5.0" license = "Unlicense" build = "build.rs" description = "Orbinum Network Node - Privacy-focused blockchain with ZK proofs and EVM compatibility." @@ -17,9 +17,7 @@ targets = ["x86_64-unknown-linux-gnu"] async-trait = { workspace = true } clap = { workspace = true } futures = { workspace = true } -hex = { workspace = true, features = ["std"] } jsonrpsee = { workspace = true, features = ["server", "macros"] } -libsecp256k1 = { workspace = true, features = ["std", "static-context"] } log = { workspace = true } scale-codec = { workspace = true } serde_json = { workspace = true, features = ["arbitrary_precision"] } @@ -63,12 +61,9 @@ sp-timestamp = { workspace = true, features = ["default"] } sp-transaction-pool = { workspace = true, features = ["default"] } # These dependencies are used for RPC frame-system-rpc-runtime-api = { workspace = true } -pallet-relayer = { workspace = true, features = ["std"] } -pallet-relayer-rpc = { workspace = true } # ISMP RPC: the read path relayers use to discover our outbound messages. pallet-ismp-rpc = { workspace = true } pallet-ismp-runtime-api = { workspace = true, default-features = false, features = ["std"] } -pallet-relayer-runtime-api = { workspace = true } pallet-shielded-pool-runtime-api = { workspace = true } pallet-transaction-payment-rpc = { workspace = true } pallet-transaction-payment-rpc-runtime-api = { workspace = true } @@ -82,7 +77,6 @@ frame-benchmarking-cli = { workspace = true } frame-metadata-hash-extension = { workspace = true } frame-system = { workspace = true } pallet-transaction-payment = { workspace = true } -sp-keystore = { workspace = true } # Frontier fc-api = { workspace = true } diff --git a/template/node/src/evm_relay_key.rs b/template/node/src/evm_relay_key.rs deleted file mode 100644 index 513f40e3..00000000 --- a/template/node/src/evm_relay_key.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! The node's EVM relay identity. -//! -//! A validator signs relay transactions with an ECDSA key of keystore type -//! `evmr`, chosen freely by the operator and independent of the Aura key — so a -//! validator's consensus identity does not dictate its EVM identity. -//! -//! This module owns the whole question of *which key that is*: -//! -//! - [`resolve`] decides where the key comes from (dev seed or keystore); -//! - [`report_identity`] tells the operator which address to register on-chain. -//! -//! Absent a key the node still authors blocks; only relaying stops. That is -//! deliberate — a misconfigured relay must not take a validator out of -//! consensus. - -use std::path::Path; - -use sc_chain_spec::ChainType; -use sc_service::{config::KeystoreConfig, Configuration}; - -/// Keystore key type for the EVM relay key, inserted via `author_insertKey`. -pub const EVM_RELAY_KEY_TYPE: sp_core::crypto::KeyTypeId = sp_core::crypto::KeyTypeId(*b"evmr"); - -/// Filename prefix the LocalKeystore gives `evmr` entries: `hex(b"evmr")`. -const EVM_RELAY_FILE_PREFIX: &str = "65766d72"; - -/// Read the EVM relay key from the on-disk keystore. -/// -/// The operator chooses this key freely and inserts it with -/// `author_insertKey("evmr", , )`. It is independent of the Aura -/// key, so a validator's consensus identity does not dictate its EVM identity. -/// -/// This bypasses `sp_keystore::Keystore` on purpose: that API never exposes -/// secret material, and `EthValidatorSigner` needs the raw seed to sign relay -/// transactions locally. Reading the LocalKeystore file format is the only way -/// to obtain it. -/// -/// TODO: signing through `Keystore::ecdsa_sign_prehashed` instead would keep the -/// key inside the keystore and work with remote/HSM backends, at the cost of -/// reworking `EthValidatorSigner`. -/// -/// Files are named `{hex(key_type)}{hex(pubkey)}` and hold a JSON-encoded secret -/// phrase. When the node runs with a keystore password the phrase on disk still -/// looks ordinary, but the password takes part in key derivation — so it must be -/// threaded through, exactly as `LocalKeystore` does, or the derived address -/// silently differs from the one registered on-chain. -fn from_keystore(keystore_path: &Path, password: Option<&str>) -> Option { - debug_assert_eq!(EVM_RELAY_FILE_PREFIX, hex::encode(EVM_RELAY_KEY_TYPE.0)); - - let entries = match std::fs::read_dir(keystore_path) { - Ok(entries) => entries, - Err(e) => { - log::warn!( - target: "orbinum-relay", - "cannot read keystore directory {}: {e}", keystore_path.display() - ); - return None; - } - }; - - // Collect and sort: read_dir order is filesystem-dependent, and silently - // picking a different key across restarts would change the node's relay - // identity while only one address is registered on-chain. - let mut candidates: Vec<_> = entries - .flatten() - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|name| name.starts_with(EVM_RELAY_FILE_PREFIX)) - .collect(); - candidates.sort(); - - if candidates.len() > 1 { - log::warn!( - target: "orbinum-relay", - "{} keys of type \"evmr\" in the keystore; using {}. Remove the others \ - so the relay identity cannot change across restarts.", - candidates.len(), - candidates[0], - ); - } - - for name in candidates { - let raw = match std::fs::read_to_string(keystore_path.join(&name)) { - Ok(raw) => raw, - Err(e) => { - log::warn!(target: "orbinum-relay", "cannot read keystore entry {name}: {e}"); - continue; - } - }; - let phrase = match serde_json::from_str::(&raw) { - Ok(phrase) => phrase, - Err(e) => { - log::warn!( - target: "orbinum-relay", - "keystore entry {name} is not a JSON-encoded phrase: {e}; skipping" - ); - continue; - } - }; - - // `from_string_with_seed` accepts full SURIs, so a dev account would load - // silently and its key is public knowledge. - if is_well_known_dev_secret(&phrase) { - log::error!( - target: "orbinum-relay", - "keystore entry {name} holds a well-known development key. Its private \ - key is public, so any relay fees sent to it are stealable. Refusing to load." - ); - continue; - } - - use sp_core::Pair as _; - let pair = match sp_core::ecdsa::Pair::from_string_with_seed(&phrase, password) { - Ok((pair, _)) => pair, - Err(e) => { - log::warn!( - target: "orbinum-relay", - "keystore entry {name} is not a valid ECDSA key: {e:?}; skipping" - ); - continue; - } - }; - - log::info!(target: "orbinum-relay", "Loaded EVM relay key from keystore entry {name}"); - let seed: [u8; 32] = pair.seed(); - return Some(format!("0x{}", hex::encode(seed))); - } - - None -} - -/// Whether a secret URI resolves to a publicly-known development account. -/// -/// `//Alice` and friends are derived from a published mnemonic, so a key that -/// starts with `//` — or is the well-known dev phrase — is not a secret. -fn is_well_known_dev_secret(phrase: &str) -> bool { - const DEV_PHRASE: &str = - "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; - let trimmed = phrase.trim(); - trimmed.starts_with("//") || trimmed.starts_with(DEV_PHRASE) -} -// ── Selection and reporting ────────────────────────────────────────────────── - -/// Alice's ECDSA seed, used only on `--dev` so the relay works out of the box. -/// -/// Public and well known; `is_well_known_dev_secret` rejects the equivalent on -/// any other chain type. -const ALICE_ECDSA_SEED: &str = "0xcb6df9de1efca7a3998a8ead4e02159d5fa99c3e0d4fd6432667390bb4726854"; - -/// Decide which EVM relay key this node signs with, if any. -/// -/// Dev chains get Alice's key injected so relaying works without setup; every -/// other chain reads keystore type `evmr`. Returns the 32-byte seed as `0x…`. -pub fn resolve(config: &Configuration) -> Option { - if matches!(config.chain_spec.chain_type(), ChainType::Development) { - log::info!( - target: "orbinum-relay", - "🔑 Dev mode: using Alice's ECDSA key as EVM relay key" - ); - return Some(ALICE_ECDSA_SEED.to_string()); - } - - let KeystoreConfig::Path { path, password } = &config.keystore else { - log::warn!( - target: "orbinum-relay", - "keystore is not on disk; relaying is disabled" - ); - return None; - }; - - use sp_core::crypto::ExposeSecret as _; - from_keystore(path, password.as_ref().map(|p| p.expose_secret().as_str())) -} - -/// Log the address the operator has to register on-chain, or why there is none. -/// -/// Registration is manual and self-service (`relayer.registerRelayer`), so the -/// address has to reach a human somehow; the startup log is that channel. -pub fn report_identity(evm_key: Option<&str>) { - match evm_key { - Some(key_hex) => match fc_rpc::EthValidatorSigner::from_hex(key_hex) { - Ok(signer) => log::info!( - target: "orbinum-relay", - "EVM relay address: {:?} — register it with relayer.registerRelayer(evmAddress)", - signer.address() - ), - Err(e) => log::error!( - target: "orbinum-relay", - "invalid EVM relay key in keystore: {e}" - ), - }, - None => log::warn!( - target: "orbinum-relay", - "no EVM relay key in keystore (type \"evmr\"); relaying is disabled. \ - Insert one with author_insertKey to enable it." - ), - } -} - -// ── Tests ──────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_file_prefix_matches_the_key_type() { - // The scan matches filenames, so a drift between the two would make the - // node silently find no key at all. - assert_eq!(EVM_RELAY_FILE_PREFIX, hex::encode(EVM_RELAY_KEY_TYPE.0)); - } - - #[test] - fn dev_suris_are_recognised_as_public() { - for phrase in ["//Alice", "//Bob", " //Alice ", "//Alice//stash"] { - assert!( - is_well_known_dev_secret(phrase), - "{phrase:?} must be treated as public", - ); - } - } - - #[test] - fn the_published_dev_phrase_is_recognised() { - let dev = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; - assert!(is_well_known_dev_secret(dev)); - // Also with a derivation appended, which still resolves to a known key. - assert!(is_well_known_dev_secret(&format!("{dev}//Alice"))); - } - - #[test] - fn a_real_mnemonic_is_not_flagged() { - for phrase in [ - "legal winner thank year wave sausage worth useful legal winner thank yellow", - "0xcb6df9de1efca7a3998a8ead4e02159d5fa99c3e0d4fd6432667390bb4726854", - ] { - assert!( - !is_well_known_dev_secret(phrase), - "{phrase:?} must not be flagged", - ); - } - } - - #[test] - fn a_missing_keystore_directory_yields_no_key() { - let missing = Path::new("/nonexistent/orbinum/keystore"); - assert_eq!(from_keystore(missing, None), None); - } - - #[test] - fn an_empty_keystore_yields_no_key() { - let dir = std::env::temp_dir().join("orbinum-evmr-empty"); - std::fs::create_dir_all(&dir).unwrap(); - assert_eq!(from_keystore(&dir, None), None); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_key_of_another_type_is_ignored() { - // An `aura` entry must not be mistaken for a relay key — that confusion - // is exactly what this module was written to end. - let dir = std::env::temp_dir().join("orbinum-evmr-aura-only"); - std::fs::create_dir_all(&dir).unwrap(); - let aura_prefix = hex::encode(b"aura"); - std::fs::write(dir.join(format!("{aura_prefix}00")), "\"//Alice\"").unwrap(); - - assert_eq!(from_keystore(&dir, None), None); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_dev_key_in_the_keystore_is_refused() { - let dir = std::env::temp_dir().join("orbinum-evmr-dev-key"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join(format!("{EVM_RELAY_FILE_PREFIX}00")), - "\"//Alice\"", - ) - .unwrap(); - - // Loading it would route relay fees to a key whose secret is published. - assert_eq!(from_keystore(&dir, None), None); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_real_key_is_loaded_as_a_hex_seed() { - let dir = std::env::temp_dir().join("orbinum-evmr-real-key"); - std::fs::create_dir_all(&dir).unwrap(); - let phrase = "legal winner thank year wave sausage worth useful legal winner thank yellow"; - std::fs::write( - dir.join(format!("{EVM_RELAY_FILE_PREFIX}00")), - format!("\"{phrase}\""), - ) - .unwrap(); - - let seed = from_keystore(&dir, None).expect("a valid mnemonic must load"); - assert!(seed.starts_with("0x")); - assert_eq!(seed.len(), 2 + 64, "32-byte seed as hex"); - - // Deterministic: the same phrase always yields the same relay identity. - assert_eq!(from_keystore(&dir, None).as_deref(), Some(seed.as_str())); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_corrupt_entry_is_skipped_rather_than_fatal() { - let dir = std::env::temp_dir().join("orbinum-evmr-corrupt"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(format!("{EVM_RELAY_FILE_PREFIX}00")), "not json").unwrap(); - - assert_eq!(from_keystore(&dir, None), None); - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/template/node/src/main.rs b/template/node/src/main.rs index 2adef322..be32071b 100644 --- a/template/node/src/main.rs +++ b/template/node/src/main.rs @@ -17,7 +17,6 @@ mod cli; mod client; mod command; mod eth; -mod evm_relay_key; mod rpc; mod service; diff --git a/template/node/src/rpc/eth.rs b/template/node/src/rpc/eth.rs index 3ebe9313..be40479d 100644 --- a/template/node/src/rpc/eth.rs +++ b/template/node/src/rpc/eth.rs @@ -23,8 +23,6 @@ pub use fc_rpc::{EthBlockDataCacheTask, EthConfig, LogsJournalConfig}; pub use fc_rpc_core::types::{FeeHistoryCache, FeeHistoryCacheLimit, FilterPool}; use fc_storage::StorageOverride; use fp_rpc::{ConvertTransaction, ConvertTransactionRuntimeApi, EthereumRuntimeRPCApi}; -use pallet_relayer_runtime_api::RelayerRuntimeApi; -use pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi; /// Extra dependencies for Ethereum compatibility. pub struct EthDeps { @@ -38,8 +36,6 @@ pub struct EthDeps { pub is_authority: bool, /// Whether to enable dev signer pub enable_dev_signer: bool, - /// Optional EVM private key (hex) for the node-native relay. - pub evm_key: Option, /// Network service pub network: Arc, /// Chain syncing service @@ -90,9 +86,7 @@ where C::Api: AuraApi + BlockBuilderApi + ConvertTransactionRuntimeApi - + EthereumRuntimeRPCApi - + RelayerRuntimeApi - + ShieldedPoolRuntimeApi, + + EthereumRuntimeRPCApi, C: HeaderBackend + HeaderMetadata, C: BlockchainEvents + AuxStore + UsageProvider + StorageProvider + 'static, BE: Backend + 'static, @@ -115,7 +109,6 @@ where converter, is_authority, enable_dev_signer, - evm_key, network, sync, frontier_backend, @@ -223,26 +216,5 @@ where #[cfg(feature = "txpool")] io.merge(TxPool::new(client.clone(), pool.clone()).into_rpc())?; - // Orbinum EVM relay RPC — key is set automatically (Alice in dev, keystore in testnet) - if let Some(ref key_hex) = evm_key { - use fc_rpc::{EthValidatorSigner, OrbinumRelay, OrbinumRelayApiServer}; - match EthValidatorSigner::from_hex(key_hex) { - Ok(signer) => { - static LOGGED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); - LOGGED.get_or_init(|| { - log::info!( - target: "rpc", - "🔑 EVM relay signer active: {}", - signer.address() - ); - }); - io.merge(OrbinumRelay::new(client.clone(), pool.clone(), signer).into_rpc())?; - } - Err(e) => { - log::error!(target: "rpc", "Invalid EVM relay key: {e}"); - } - } - } - Ok(io) } diff --git a/template/node/src/rpc/mod.rs b/template/node/src/rpc/mod.rs index 51c27337..f6d61d74 100644 --- a/template/node/src/rpc/mod.rs +++ b/template/node/src/rpc/mod.rs @@ -18,16 +18,13 @@ use sp_blockchain::{Error as BlockChainError, HeaderBackend, HeaderMetadata}; use sp_consensus_aura::sr25519::AuthorityId as AuraId; use sp_core::H256; use sp_inherents::CreateInherentDataProviders; -use sp_keystore::Keystore; use sp_runtime::traits::{Block as BlockT, NumberFor}; // Runtime use orbinum_runtime::{AccountId, Balance, Hash, Nonce}; mod eth; -mod relayer_author; mod storage_override; pub use self::eth::{create_eth, EthDeps, LogsJournalConfig}; -use self::relayer_author::{RelayerAuthor, RelayerAuthorApiServer}; use self::storage_override::EeSuffixStorageOverride; /// GRANDPA-specific dependencies. @@ -64,8 +61,6 @@ pub struct FullDeps { pub backend: Arc, /// Manual seal command sink pub command_sink: Option>>, - /// Keystore — holds the node's session keys and its `evmr` relay key. - pub keystore: Arc, /// Ethereum-compatibility specific dependencies. pub eth: EthDeps, /// GRANDPA finality RPC dependencies. @@ -107,7 +102,6 @@ where C::Api: fp_rpc::EthereumRuntimeRPCApi, C::Api: pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi, C::Api: pallet_zk_verifier_runtime_api::ZkVerifierRuntimeApi, - C::Api: pallet_relayer_runtime_api::RelayerRuntimeApi, C::Api: pallet_ismp_runtime_api::IsmpRuntimeApi, C: sc_client_api::ProofProvider + sc_client_api::BlockBackend, C::Api: sp_api::Core, @@ -122,7 +116,6 @@ where NumberFor: sc_consensus_grandpa::BlockNumberOps, { use pallet_ismp_rpc::{IsmpApiServer, IsmpRpcHandler}; - use pallet_relayer_rpc::{Relayer, RelayerApiServer}; use pallet_transaction_payment_rpc::{TransactionPayment, TransactionPaymentApiServer}; use pallet_zk_verifier_rpc::{ZkVerifier, ZkVerifierApiServer}; use sc_consensus_grandpa_rpc::{Grandpa, GrandpaApiServer}; @@ -138,7 +131,6 @@ where pool, backend, command_sink, - keystore, eth, grandpa, } = deps; @@ -156,8 +148,6 @@ where )?; io.merge(TransactionPayment::new(client.clone()).into_rpc())?; io.merge(ZkVerifier::new(client.clone()).into_rpc())?; - io.merge(Relayer::new(client.clone()).into_rpc())?; - io.merge(RelayerAuthor::new(client.clone(), keystore).into_rpc())?; // Exposes the `ismp_query*` methods. `new` fails if the backend has no offchain // storage — the misconfiguration that leaves relayers unable to see our messages. io.merge(IsmpRpcHandler::new(client.clone(), backend.clone())?.into_rpc())?; diff --git a/template/node/src/rpc/relayer_author.rs b/template/node/src/rpc/relayer_author.rs deleted file mode 100644 index 5a4fba2a..00000000 --- a/template/node/src/rpc/relayer_author.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! `relayer_getRelayInfo` RPC — onboarding helper for the validator operator. -//! -//! Returns the node's Substrate `AccountId`, its EVM relay address, and a -//! signature proving the node holds that address's private key — the three -//! things `relayer.registerRelayer(evmAddress, signature)` needs. -//! -//! The signature is what stops one approved validator from claiming another's -//! public relay address, so it cannot be produced by hand: only the holder of -//! the `evmr` key can sign the binding digest. This RPC exists so the operator -//! does not have to. -//! -//! No extrinsic is submitted; this is a pure read-only query. The signature it -//! returns is only usable to register this node's own address, and becomes -//! public the moment that extrinsic lands, so the method is not gated. - -use std::sync::Arc; - -use async_trait::async_trait; -use jsonrpsee::{core::RpcResult, proc_macros::rpc, types::ErrorObjectOwned}; -use sp_api::ProvideRuntimeApi; -use sp_blockchain::HeaderBackend; -use sp_core::{crypto::KeyTypeId, H160, H256}; -use sp_keystore::Keystore; -use sp_runtime::traits::Block as BlockT; - -use orbinum_runtime::AccountId; - -const AURA_KEY_TYPE: KeyTypeId = KeyTypeId(*b"aura"); - -/// Imported rather than redeclared: two copies of the key type could drift and -/// the node would sign with a key the RPC never reports. -use crate::evm_relay_key::EVM_RELAY_KEY_TYPE; - -// ── RPC trait ──────────────────────────────────────────────────────────────── - -#[rpc(client, server)] -pub trait RelayerAuthorApi { - /// Returns everything needed to call `relayer.registerRelayer`. - /// - /// Requires an Aura session key (for the account) and an `evmr` key (for the - /// address and the proof) in the local keystore. - #[method(name = "relayer_getRelayInfo")] - async fn get_relay_info(&self) -> RpcResult; -} - -// ── Handler struct ─────────────────────────────────────────────────────────── - -pub struct RelayerAuthor { - client: Arc, - keystore: Arc, - _block: std::marker::PhantomData, -} - -impl RelayerAuthor { - pub fn new(client: Arc, keystore: Arc) -> Self { - Self { - client, - keystore, - _block: std::marker::PhantomData, - } - } -} - -// ── Implementation ─────────────────────────────────────────────────────────── - -#[async_trait] -impl RelayerAuthorApiServer for RelayerAuthor -where - B: BlockT, - C: ProvideRuntimeApi + HeaderBackend + Send + Sync + 'static, -{ - async fn get_relay_info(&self) -> RpcResult { - let aura_keys = self.keystore.sr25519_public_keys(AURA_KEY_TYPE); - let pub_key = aura_keys.first().copied().ok_or_else(|| { - rpc_err("No Aura key in keystore — is this node configured as a validator?") - })?; - let account_id: AccountId = pub_key.0.into(); - - let evm_keys = self.keystore.ecdsa_public_keys(EVM_RELAY_KEY_TYPE); - let evm_pub = evm_keys.first().copied().ok_or_else(|| { - rpc_err( - "No \"evmr\" key in keystore — insert one with \ - author_insertKey(\"evmr\", , ) and restart", - ) - })?; - let evm_addr = evm_address_of(&evm_pub)?; - - let genesis = self - .client - .hash(0u32.into()) - .map_err(|e| rpc_err(format!("cannot read genesis hash: {e}")))? - .ok_or_else(|| rpc_err("genesis block missing"))?; - - let digest = pallet_relayer::evm_proof::binding_digest( - scale_codec::Encode::encode(&account_id).as_slice(), - &evm_addr, - &genesis, - ); - let signature = self - .keystore - .ecdsa_sign_prehashed(EVM_RELAY_KEY_TYPE, &evm_pub, &digest) - .map_err(|e| rpc_err(format!("signing failed: {e}")))? - .ok_or_else(|| rpc_err("keystore refused to sign with the \"evmr\" key"))?; - - Ok(serde_json::json!({ - "substrate_account": to_hex(>::as_ref(&account_id)), - "evm_address": format!("{evm_addr:#x}"), - "signature": to_hex(&signature.0), - "call": "relayer.registerRelayer(evm_address, signature)", - })) - } -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/// Ethereum address for a compressed secp256k1 public key. -/// -/// Delegates the actual derivation to `pallet_relayer::evm_proof` so the node and -/// the runtime can never disagree about which address a key owns. -fn evm_address_of(pubkey: &sp_core::ecdsa::Public) -> Result { - let tagged = libsecp256k1::PublicKey::parse_compressed(&pubkey.0) - .map_err(|e| rpc_err(format!("malformed evmr public key: {e:?}")))? - .serialize(); - // serialize() is 65 bytes with a 0x04 tag; the derivation wants the 64 after it. - let untagged: [u8; 64] = tagged[1..].try_into().expect("65 - 1 == 64; qed"); - Ok(pallet_relayer::evm_proof::evm_address_from_uncompressed( - &untagged, - )) -} - -fn to_hex(bytes: &[u8]) -> String { - format!("0x{}", hex::encode(bytes)) -} - -fn rpc_err(msg: impl Into) -> ErrorObjectOwned { - ErrorObjectOwned::owned(-32000, msg.into(), None::<()>) -} diff --git a/template/node/src/service.rs b/template/node/src/service.rs index 98bf36d7..65050393 100644 --- a/template/node/src/service.rs +++ b/template/node/src/service.rs @@ -321,7 +321,6 @@ where RA: Send + Sync + 'static, RA::RuntimeApi: RuntimeApiCollection, RA::RuntimeApi: pallet_zk_verifier_runtime_api::ZkVerifierRuntimeApi, - RA::RuntimeApi: pallet_relayer_runtime_api::RelayerRuntimeApi, RA::RuntimeApi: pallet_ismp_runtime_api::IsmpRuntimeApi::Hash>, // The ISMP RPC converts block numbers to u64 when answering height queries. u64: From>, @@ -463,8 +462,6 @@ where // for ethereum-compatibility rpc. config.rpc.id_provider = Some(Box::new(fc_rpc::EthereumSubIdProvider)); - let evm_key = crate::evm_relay_key::resolve(&config); - // GRANDPA pieces for the RPC, pulled out before `grandpa_link` is moved into the // voter below. `shared_voter_state` is created here rather than at the voter so both // sides hold the same one — a detached state leaves `grandpa_roundState` reporting @@ -489,11 +486,9 @@ where let pool = transaction_pool.clone(); let network = network.clone(); let sync_service = sync_service.clone(); - let keystore = keystore_container.keystore(); let is_authority = role.is_authority(); let enable_dev_signer = eth_config.enable_dev_signer; - let evm_key = evm_key.clone(); let max_past_logs = eth_config.max_past_logs; let max_block_range = eth_config.max_block_range; let rpc_allow_unprotected_txs = eth_config.rpc_allow_unprotected_txs; @@ -537,7 +532,6 @@ where converter: Some(TransactionConverter::::default()), is_authority, enable_dev_signer, - evm_key: evm_key.clone(), network: network.clone(), sync: sync_service.clone(), frontier_backend: match &*frontier_backend { @@ -568,7 +562,6 @@ where } else { None }, - keystore: keystore.clone(), eth: eth_deps, grandpa: crate::rpc::GrandpaDeps { shared_voter_state: shared_voter_state.clone(), @@ -627,8 +620,6 @@ where ) .await; - crate::evm_relay_key::report_identity(evm_key.as_deref()); - if let Some(hwbench) = hwbench { sc_sysinfo::print_hwbench(&hwbench); match SUBSTRATE_REFERENCE_HARDWARE.check_hardware(&hwbench, false) { diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index b0a1366f..da0720ca 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-runtime" -version = "0.4.0" +version = "0.5.0" license = "Apache-2.0 OR GPL-3.0-or-later" description = "Orbinum Privacy Chain Runtime" publish = false @@ -85,8 +85,6 @@ pallet-evm-precompile-shielded-pool = { workspace = true } pallet-evm-precompile-simple = { workspace = true } # Orbinum Privacy -pallet-relayer = { workspace = true } -pallet-relayer-runtime-api = { workspace = true } pallet-shielded-pool = { workspace = true } pallet-shielded-pool-runtime-api = { workspace = true } pallet-zk-verifier = { workspace = true } @@ -185,8 +183,6 @@ std = [ "pallet-evm-precompile-shielded-pool/std", # Orbinum Privacy "pallet-zk-verifier/std", - "pallet-relayer/std", - "pallet-relayer-runtime-api/std", "pallet-shielded-pool/std", "pallet-shielded-pool-runtime-api/std", "pallet-zk-verifier-runtime-api/std", @@ -208,7 +204,6 @@ runtime-benchmarks = [ "pallet-evm-precompile-sha3fips-benchmarking/runtime-benchmarks", "pallet-zk-verifier/runtime-benchmarks", "pallet-shielded-pool/runtime-benchmarks", - "pallet-relayer/runtime-benchmarks", "pallet-validator-set/runtime-benchmarks", "pallet-ismp/runtime-benchmarks", "pallet-ismp-messaging/runtime-benchmarks", @@ -236,7 +231,6 @@ try-runtime = [ "pallet-evm/try-runtime", "pallet-evm-chain-id/try-runtime", "pallet-grandpa/try-runtime", - "pallet-relayer/try-runtime", "pallet-session/try-runtime", "pallet-shielded-pool/try-runtime", "pallet-sudo/try-runtime", diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index b35c6dca..32ba9d93 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -20,24 +20,57 @@ to `spec_version` / `transaction_version` must add a row here in the same PR. The genesis reset (`69d1b837`) set `spec_version` back to 1 and `transaction_version` to 1 for the public testnet launch. -### spec 19 — tx 6 — [Unreleased] - -The block author is the validator account at the Aura authority index, not the -Aura key read as an account. No call or encoding changes. - -- **Author lookup.** `pallet_validator_set` and `pallet_authorship` take - `FindAuthor = pallet_session::FindAccountFromAuthorIndex`. - Reading the Aura key as an `AccountId` matched only validators that registered - their key as their own account: on testnet 9 of 14 had set their keys from - another account, so their `note_author_version` declarations were dropped (the - quorum for `set_min_author_version` was unreachable) and their fallback relay - fees were credited to their Aura keys. -- **Migration `MoveAuraKeyRelayFees`.** Each `PendingRelayerFees` balance on an - Aura key owned by a current validator (`pallet_session::KeyOwner`) moves to - that validator's account; keys no validator owns are left alone. Idempotent. - try-runtime on testnet state: 9 balances moved, 0.27% of block weight. -- `pallet_zk_verifier::migrations::v2::MigrateToV2` is no longer run: every - live chain is at storage version 2. +### spec 19 — tx 7 — [Unreleased] + +A validator is its account and session keys, nothing else: the EVM relayer layer +is removed. Private spends are submitted only unsigned, and every spend's fee and +every EVM priority tip goes to the validator that authored the block. +`transaction_version` moves: pallet index 16 and `shielded_pool` call 18 are +gone, and call 19 is renamed. Ships with `orbinum-runtime` / `orbinum-node` +0.5.0, `pallet-shielded-pool` 0.25.0, `pallet-shielded-pool-runtime-api` 0.4.0, +`pallet-evm-precompile-shielded-pool` 0.10.0 and `pallet-validator-set` 0.4.1. + +#### 1 · Block author is the session validator + +`pallet_validator_set` and `pallet_authorship` take +`FindAuthor = pallet_session::FindAccountFromAuthorIndex`. Reading +the Aura key as an `AccountId` matched only validators that registered their key +as their own account: on testnet 9 of 14 had set their keys from another account, +so their `note_author_version` declarations were dropped (the quorum for +`set_min_author_version` was unreachable) and their fees were credited to their +Aura keys. + +#### 2 · No relayers + +- `pallet_relayer` (index 16) is removed: relay address registration, relay + commits, allowed selectors and `MinRelayFee`. Runtime APIs `RelayerRuntimeApi`, + `relay_config` and `relay_commit_hash` go with it (`ShieldedPoolRuntimeApi` v5). +- `shielded_pool`: `private_transfer` and `unshield` accept only an unsigned + origin (a signed spend linked the signer to the spend's nullifiers). Call 18 + `commit_relay` is retired; call 19 `claim_relay_fees` becomes + `claim_fees(asset_id, amount)`, signed by the validator and paid to it. +- The fee ledger lives in the pool: `PendingFees(validator, asset)`, credited to + the block author on every spend (`FeeCredited`), drained by `claim_fees` + (`FeesClaimed`). The floor is the constant `MinSpendFee` (0.001 ORB). +- Precompile `0x…0801` keeps `shield`; `privateTransfer`, `unshield`, + `commitRelay` and `claimRelayFees` revert. + +#### 3 · EVM tips + +A transaction's priority tip goes to the block author's validator account +(`TipToValidator`); `block.coinbase` is the zero address. It used to be bytes +4..24 of the Aura key, whose mapped account no key controls. + +#### Migrations + +- `RelayerFeesToPool` — drains `Relayer::PendingRelayerFees` into + `ShieldedPool::PendingFees`, re-keying a balance held by an Aura key to the + validator that owns it (`pallet_session::KeyOwner`). +- `RemoveRelayerPallet` — clears the rest of the `Relayer` prefix. +- `MoveStrandedEvmTips` — moves the balance of each Aura-derived coinbase account + to the validator owning the key; a key no validator owns is left alone. +- `pallet_zk_verifier::migrations::v2::MigrateToV2` is no longer run: every live + chain is at storage version 2. ### spec 18 — tx 6 — 2026-10-12 (`v0.4.0`) diff --git a/template/runtime/src/configs/consensus.rs b/template/runtime/src/configs/consensus.rs index 656e0c5e..4b39c00a 100644 --- a/template/runtime/src/configs/consensus.rs +++ b/template/runtime/src/configs/consensus.rs @@ -75,32 +75,11 @@ impl pallet_validator_set::ValidatorPrerequisites for ValidatorPrereq } } -/// Clears the EVM relay binding of an account that leaves the validator set. -/// -/// Registering a relay address requires an active validator, so the binding must -/// not outlive the membership that authorised it. -pub struct RelayerCleanup; - -impl pallet_validator_set::OnValidatorRemoved for RelayerCleanup { - fn on_validator_removed(who: &AccountId) { - pallet_relayer::Pallet::::clear_relayer(who); - } - - #[cfg(feature = "runtime-benchmarks")] - fn setup_removal_state(who: &AccountId) { - // Give `remove_validator` a binding to tear down, so its benchmark - // measures the two cleanup writes instead of the empty branch. - let evm_address = sp_core::H160::repeat_byte(0x99); - pallet_relayer::RelayerRegistry::::insert(evm_address, who.clone()); - pallet_relayer::RelayerByAccount::::insert(who.clone(), evm_address); - } -} - impl pallet_validator_set::Config for Runtime { type AddRemoveOrigin = frame_system::EnsureRoot; type MaxValidators = ConstU32<32>; type Prerequisites = ValidatorPrerequisiteChecker; - type OnValidatorRemoved = RelayerCleanup; + type OnValidatorRemoved = (); type FindAuthor = ValidatorAuthor; type QuorumWindow = Period; type WeightInfo = pallet_validator_set::weights::SubstrateWeight; diff --git a/template/runtime/src/configs/evm.rs b/template/runtime/src/configs/evm.rs index a2b2656c..500ff498 100644 --- a/template/runtime/src/configs/evm.rs +++ b/template/runtime/src/configs/evm.rs @@ -1,25 +1,51 @@ //! Frontier EVM layer: EVM, Ethereum, fee models, and dev-only manual seal. //! -//! `FindAuthorTruncated` bridges Substrate's 32-byte author to the 20-byte -//! address the EVM expects, which is what lets `block.coinbase` work. +//! A transaction's priority tip goes to the validator that authored the block +//! (`TipToValidator`). `block.coinbase` is the zero address: no H160 maps onto a +//! validator's sr25519 account, so any other value would name an account nobody +//! controls. use crate::*; -use frame_support::parameter_types; +use frame_support::{ + parameter_types, + traits::fungible::{Balanced, Credit}, +}; impl pallet_evm_chain_id::Config for Runtime {} -pub struct FindAuthorTruncated(PhantomData); -impl> FindAuthor for FindAuthorTruncated { - fn find_author<'a, I>(digests: I) -> Option - where - I: 'a + IntoIterator, - { - if let Some(author_index) = F::find_author(digests) { - let authority_id = - pallet_aura::Authorities::::get()[author_index as usize].clone(); - return Some(H160::from_slice(&authority_id.to_raw_vec()[4..24])); +/// Pays an EVM transaction's priority tip to the validator that authored the +/// block; the base fee is burned. Without an author (an RPC simulation) the tip +/// is burned too. +pub struct TipToValidator; + +impl pallet_evm::OnChargeEVMTransaction for TipToValidator { + type LiquidityInfo = Option>; + + fn withdraw_fee( + who: &H160, + fee: U256, + ) -> Result> { + as pallet_evm::OnChargeEVMTransaction< + Runtime, + >>::withdraw_fee(who, fee) + } + + fn correct_and_deposit_fee( + who: &H160, + corrected_fee: U256, + base_fee: U256, + already_withdrawn: Self::LiquidityInfo, + ) -> Self::LiquidityInfo { + as pallet_evm::OnChargeEVMTransaction< + Runtime, + >>::correct_and_deposit_fee(who, corrected_fee, base_fee, already_withdrawn) + } + + fn pay_priority_fee(tip: Self::LiquidityInfo) { + if let (Some(tip), Some(author)) = (tip, pallet_authorship::Pallet::::author()) { + // A refused deposit drops the credit, which burns it. + let _ = >::resolve(&author, tip); } - None } } @@ -51,9 +77,9 @@ impl pallet_evm::Config for Runtime { type ChainId = EVMChainId; type BlockGasLimit = BlockGasLimit; type Runner = pallet_evm::runner::stack::Runner; - type OnChargeTransaction = (); + type OnChargeTransaction = TipToValidator; type OnCreate = (); - type FindAuthor = FindAuthorTruncated; + type FindAuthor = (); type GasLimitPovSizeRatio = GasLimitPovSizeRatio; type GasLimitStorageGrowthRatio = GasLimitStorageGrowthRatio; type Timestamp = Timestamp; diff --git a/template/runtime/src/configs/ismp/mod.rs b/template/runtime/src/configs/ismp/mod.rs index 95674f4d..53c577a0 100644 --- a/template/runtime/src/configs/ismp/mod.rs +++ b/template/runtime/src/configs/ismp/mod.rs @@ -36,10 +36,9 @@ parameter_types! { /// 0.01 ORB per signed dispatch, plus a per-byte term, paid to the ISMP treasury and /// never refunded. Starting values only — Root moves both with `set_message_fee`. /// - /// Ten times `pallet_relayer`'s 0.001 ORB anti-spam floor, because a cross-chain - /// dispatch costs this chain real gas on the far side where a shielded relay does not, - /// and a hundredth of the 1 ORB ceiling that the same pallet already settled on as - /// "room to react to price swings" (`configs/privacy.rs:23-24`). + /// Ten times the shielded pool's 0.001 ORB spend fee floor (`MinSpendFee`), because a + /// cross-chain dispatch costs this chain real gas on the far side where a spend does + /// not, and a hundredth of the 1 ORB ceiling below. /// /// Not escrowed in `FeeMetadata`, deliberately — a refundable anti-spam charge is not a /// charge, because it can be recycled every timeout. See the pallet's @@ -49,7 +48,7 @@ parameter_types! { /// of the largest message this chain will carry. Hyperbridge prices its own outbound /// traffic per byte for the same reason (`pallet-bandwidth`). pub const IsmpDefaultMessageByteFee: Balance = 1_000_000_000_000; - /// 1 ORB, the same ceiling `pallet_relayer` uses for `MaxMinRelayFee`: far enough above + /// 1 ORB: far enough above /// the default to absorb a token repricing, far below where a typo would price dispatch /// out of reach until the next runtime upgrade. pub const IsmpMaxMessageFee: Balance = 1_000_000_000_000_000_000; diff --git a/template/runtime/src/configs/privacy.rs b/template/runtime/src/configs/privacy.rs index b5be8629..42cac59a 100644 --- a/template/runtime/src/configs/privacy.rs +++ b/template/runtime/src/configs/privacy.rs @@ -1,4 +1,4 @@ -//! Orbinum privacy stack: ZK verifier, relayer, and the shielded pool. +//! Orbinum privacy stack: ZK verifier and the shielded pool. //! //! One `///` line per associated type: what it is wired to, or what the value //! means. The reasoning behind each bound lives on the pallet's `Config`. @@ -17,50 +17,23 @@ impl pallet_zk_verifier::Config for Runtime { type WeightInfo = pallet_zk_verifier::weights::SubstrateWeight; } -// ─── Relayer ────────────────────────────────────────────────────────────────── - -/// The current block's author, as `pallet_authorship` reports it. -pub struct RelayerBlockAuthor; - -impl frame_support::traits::Get> for RelayerBlockAuthor { - fn get() -> Option { - pallet_authorship::Pallet::::author() - } -} - -impl pallet_relayer::Config for Runtime { - /// Who earns a spend's fee when no relayer committed to it. - type BlockAuthor = RelayerBlockAuthor; - /// The validator set relayers are registered against. - type ValidatorSet = ValidatorSet; - /// Root sets the fee floor and the allowed selectors. - type ManageOrigin = frame_system::EnsureRoot; - /// Fee floor until Root sets one: 0.001 ORB, against spam. - type DefaultMinRelayFee = ConstU128<1_000_000_000_000_000>; - /// Highest floor Root may set: 1 ORB, room for price swings but not for typos. - type MaxMinRelayFee = ConstU128<1_000_000_000_000_000_000>; - /// Precompile selectors the relay may be allowed to call. - type MaxAllowedSelectors = ConstU32<16>; - /// A commit credits spends for the next 19 blocks (~2 min), then expires. - type CommitTtl = ConstU32<20>; - /// Commits one relayer may record per block. - type MaxCommitsPerRelayerPerBlock = ConstU32<64>; - /// No benchmarked weights yet: the pallet's defaults. - type WeightInfo = (); -} - // ─── Shielded pool ──────────────────────────────────────────────────────────── parameter_types! { pub const ShieldedPoolPalletId: PalletId = PalletId(*b"shld/pol"); } -/// The account an EVM address controls, per the runtime's address mapping. -pub struct EvmAccount; +/// The validator account that authored the current block, as `pallet_authorship` +/// reports it through `ValidatorAuthor`. +pub struct ValidatorAccountAuthor; -impl sp_runtime::traits::Convert for EvmAccount { - fn convert(address: sp_core::H160) -> AccountId { - crate::evm_account::evm_h160_to_account_id(address) +impl frame_support::traits::Get> for ValidatorAccountAuthor { + fn get() -> Option { + let author = pallet_authorship::Pallet::::author(); + // A benchmark block carries no Aura pre-digest, so it has no author. + #[cfg(feature = "runtime-benchmarks")] + let author = author.or(Some(AccountId::from([0xbe; 32]))); + author } } @@ -69,10 +42,10 @@ impl pallet_shielded_pool::Config for Runtime { type Currency = Balances; /// Verifies every shield, transfer and unshield proof. type ZkVerifier = ZkVerifier; - /// Fee floor, relay commits and fee crediting. - type Relayer = pallet_relayer::Pallet; - /// Maps an EVM caller to the account it pays from or is paid to. - type EvmAccount = EvmAccount; + /// Earns each spend's fee: the validator that authored the block. + type BlockAuthor = ValidatorAccountAuthor; + /// Spend fee floor: 0.001 ORB, against spam. + type MinSpendFee = ConstU128<1_000_000_000_000_000>; /// Derives the pool's account, which holds every shielded deposit. type PalletId = ShieldedPoolPalletId; /// Tree depth, fixed by the circuits (`integrity_test` pins it). diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 84407907..1d2301e7 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -37,7 +37,7 @@ use frame_support::weights::constants::RocksDbWeight as RuntimeDbWeight; use frame_support::{ genesis_builder_helper::build_state, parameter_types, - traits::{ConstBool, ConstU32, ConstU64, ConstU8, FindAuthor, OnFinalize, OnTimestampSet}, + traits::{ConstBool, ConstU32, ConstU64, ConstU8, OnFinalize, OnTimestampSet}, weights::{constants::WEIGHT_REF_TIME_PER_MILLIS, IdentityFee, Weight}, PalletId, }; @@ -49,10 +49,7 @@ use scale_codec::{Decode, Encode}; use sp_api::impl_runtime_apis; use sp_consensus_aura::sr25519::AuthorityId as AuraId; use sp_consensus_grandpa::{AuthorityId as GrandpaId, AuthorityList as GrandpaAuthorityList}; -use sp_core::{ - crypto::{ByteArray, KeyTypeId}, - ConstU128, OpaqueMetadata, H160, H256, U256, -}; +use sp_core::{crypto::KeyTypeId, ConstU128, OpaqueMetadata, H160, H256, U256}; use sp_genesis_builder::PresetId; use sp_runtime::{ generic, impl_opaque_keys, @@ -61,7 +58,7 @@ use sp_runtime::{ NumberFor, OpaqueKeys, PostDispatchInfoOf, UniqueSaturatedInto, }, transaction_validity::{TransactionSource, TransactionValidity, TransactionValidityError}, - ApplyExtrinsicResult, ConsensusEngineId, ExtrinsicInclusionMode, Perbill, Permill, + ApplyExtrinsicResult, ExtrinsicInclusionMode, Perbill, Permill, }; use sp_version::RuntimeVersion; @@ -120,7 +117,11 @@ pub type SignedPayload = generic::SignedPayload; /// Runs on upgrade, oldest first. Drop an entry once every live chain has passed its /// version: a migration that can no longer run could be re-armed by mistake. -pub type Migrations = (migrations::MoveAuraKeyRelayFees,); +pub type Migrations = ( + migrations::RelayerFeesToPool, + migrations::RemoveRelayerPallet, + migrations::MoveStrandedEvmTips, +); pub type Executive = frame_executive::Executive< Runtime, @@ -164,7 +165,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_version: 19, impl_version: 1, apis: RUNTIME_API_VERSIONS, - transaction_version: 6, + transaction_version: 7, system_version: 1, }; @@ -308,8 +309,7 @@ mod runtime { #[runtime::pallet_index(15)] pub type Authorship = pallet_authorship; - #[runtime::pallet_index(16)] - pub type Relayer = pallet_relayer; + // Index 16 is retired (`pallet_relayer`, removed in spec 19). Do not reassign. #[runtime::pallet_index(17)] pub type ValidatorSet = pallet_validator_set; @@ -423,7 +423,6 @@ mod benches { [pallet_evm_precompile_sha3fips, EVMPrecompileSha3FIPSBench::] [pallet_zk_verifier, ZkVerifier] [pallet_shielded_pool, ShieldedPool] - [pallet_relayer, Relayer] [pallet_validator_set, ValidatorSet] [ismp_grandpa, IsmpGrandpa] [pallet_ismp_messaging, IsmpMessaging] @@ -926,7 +925,7 @@ impl_runtime_apis! { } // ShieldedPool Runtime API implementation - #[api_version(4)] + #[api_version(5)] impl pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi for Runtime { fn get_merkle_tree_info() -> (pallet_shielded_pool::Hash, u32, u32) { ShieldedPool::get_merkle_tree_info() @@ -958,38 +957,6 @@ impl_runtime_apis! { ShieldedPool::get_subtree_roots(tree_id, start, count) } - fn relay_config() -> pallet_shielded_pool_runtime_api::RelayConfig { - use pallet_relayer::RelayerInterface; - let min_fee_planck = pallet_relayer::Pallet::::min_relay_fee(); - // Fallback selectors for day-0 operation: when the chain launches the - // AllowedSelectors storage is empty, so we return the canonical defaults - // (unshield + privateTransfer) derived from keccak256(sig)[0..4]. - // Once governance calls `set_allowed_selectors`, the stored list takes - // precedence and these hardcoded values are never reached again. - let allowed_selectors = { - let stored = pallet_relayer::Pallet::::allowed_selectors(); - if stored.is_empty() { - // Taken from the precompile itself rather than copied: a - // literal here that drifts from the decoder fails silently, - // since a wrong selector is simply "unsupported". - sp_std::vec![ - pallet_evm_precompile_shielded_pool::selectors::UNSHIELD, - pallet_evm_precompile_shielded_pool::selectors::PRIVATE_TRANSFER, - ] - } else { - stored - } - }; - pallet_shielded_pool_runtime_api::RelayConfig { min_fee_planck, allowed_selectors } - } - - fn relay_commit_hash(calldata: Vec, relayer: [u8; 20]) -> Option<[u8; 32]> { - // Same decoder the precompile runs and same hash the extrinsic computes, - // so the commit a node records is the one the spend will look up. - let call = pallet_evm_precompile_shielded_pool::decode_relayable_call::(&calldata)?; - let op_hash = pallet_shielded_pool::operations::fees::relay_op_hash::(&call)?; - Some(pallet_relayer::relay_commit_hash(&op_hash, &sp_core::H160(relayer)).0) - } } impl pallet_zk_verifier_runtime_api::ZkVerifierRuntimeApi for Runtime { @@ -1071,44 +1038,6 @@ impl_runtime_apis! { } } - impl pallet_relayer_runtime_api::RelayerRuntimeApi for Runtime { - fn is_relayer(account: sp_runtime::AccountId32) -> bool { - pallet_relayer::RelayerByAccount::::contains_key(&account) - } - - fn pending_fees(account: sp_runtime::AccountId32, asset_id: u32) -> u128 { - pallet_relayer::PendingRelayerFees::::get(&account, asset_id) - } - - fn registered_evm_address(account: sp_runtime::AccountId32) -> Option<[u8; 20]> { - pallet_relayer::RelayerByAccount::::get(&account).map(|h| h.0) - } - - fn get_active_relayers() -> sp_std::vec::Vec<([u8; 20], sp_runtime::AccountId32)> { - // Capped: this iterates a map with no inherent bound, and a runtime - // API runs inside the caller's block/RPC budget. Registration is - // gated on the validator set, so the live count cannot exceed - // `MaxValidators` — the cap is headroom against a future path that - // registers without that gate, not against today's storage. - const MAX_RELAYERS_RETURNED: usize = 256; - pallet_relayer::RelayerRegistry::::iter() - .take(MAX_RELAYERS_RETURNED) - .map(|(h160, account)| (h160.0, account)) - .collect() - } - - fn is_relayer_evm(evm_address: [u8; 20]) -> bool { - pallet_relayer::RelayerRegistry::::contains_key( - sp_core::H160::from(evm_address), - ) - } - - fn relay_commit_block(commit: [u8; 32]) -> Option { - pallet_relayer::RelayCommits::::get(sp_core::H256(commit)) - .map(|c| c.recorded_at) - } - } - #[cfg(feature = "runtime-benchmarks")] impl frame_benchmarking::Benchmark for Runtime { fn benchmark_metadata(extra: bool) -> ( diff --git a/template/runtime/src/migrations.rs b/template/runtime/src/migrations.rs index 0f836be9..51a8acef 100644 --- a/template/runtime/src/migrations.rs +++ b/template/runtime/src/migrations.rs @@ -1,55 +1,205 @@ //! Runtime-level storage migrations, run by `Executive` on upgrade (`Migrations`). +//! +//! Spec 19 removes `pallet_relayer` (index 16): its fee ledger moves into the +//! shielded pool, the rest of its storage is cleared, and EVM tips stranded on +//! accounts derived from Aura keys move to their validators. -use crate::{AccountId, Runtime}; -use frame_support::{traits::OnRuntimeUpgrade, weights::Weight}; -use sp_core::crypto::key_types::AURA; +use crate::{AccountId, Balances, Runtime}; +use frame_support::{ + parameter_types, + traits::{ + fungible::{Inspect, Mutate}, + tokens::{Fortitude, Preservation}, + OnRuntimeUpgrade, + }, + weights::Weight, +}; +use sp_core::{crypto::key_types::AURA, H160}; #[cfg(feature = "try-runtime")] use alloc::vec::Vec; -/// Moves relay fees credited to a validator's Aura key, read as an account, to -/// the validator's own account (spec 19). +/// `pallet_relayer` storage, read once more before its prefix is cleared. +pub(crate) mod relayer { + use crate::AccountId; + use frame_support::{storage_alias, Blake2_128Concat}; + + #[storage_alias] + pub type PendingRelayerFees = StorageDoubleMap< + Relayer, + Blake2_128Concat, + AccountId, + Blake2_128Concat, + u32, + u128, + frame_support::pallet_prelude::ValueQuery, + >; + + #[cfg(feature = "try-runtime")] + #[storage_alias] + pub type MinRelayFee = StorageValue; +} + +/// The validator that owns `key` as its Aura session key, if any. +fn aura_key_owner(key: &[u8]) -> Option { + pallet_session::Pallet::::key_owner(AURA, key) +} + +fn db_weight(reads: u64, writes: u64) -> Weight { + ::DbWeight::get().reads_writes(reads, writes) +} + +/// Moves every unclaimed fee from `pallet_relayer::PendingRelayerFees` to +/// `pallet_shielded_pool::PendingFees`. +/// +/// Until spec 19 the block author was the Aura key read as an account, so a +/// validator whose account is not its Aura key earned fees on that key. Such a +/// balance is credited to the validator owning the key +/// (`pallet_session::KeyOwner`); any other holder keeps its own. Idempotent: the +/// old map is drained. +pub struct RelayerFeesToPool; + +impl OnRuntimeUpgrade for RelayerFeesToPool { + fn on_runtime_upgrade() -> Weight { + let mut moved = 0u64; + let mut rekeyed = 0u64; + for (holder, asset_id, amount) in relayer::PendingRelayerFees::drain() { + let key: &[u8] = holder.as_ref(); + let validator = aura_key_owner(key).unwrap_or(holder.clone()); + if validator != holder { + rekeyed += 1; + } + pallet_shielded_pool::PendingFees::::mutate(&validator, asset_id, |b| { + *b = b.saturating_add(amount); + }); + moved += 1; + } + log::info!( + target: "runtime::migrations", + "moved {moved} fee balance(s) to the shielded pool, {rekeyed} from Aura keys to their validators", + ); + // Per entry: the drained read and write, a key-owner read, and the new + // balance's read and write. + db_weight( + moved.saturating_mul(3).saturating_add(1), + moved.saturating_mul(2), + ) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + use frame_support::traits::Get; + use scale_codec::Encode; + let floor = ::MinSpendFee::get(); + if let Some(set) = relayer::MinRelayFee::get() { + frame_support::ensure!( + set == floor, + "MinRelayFee was changed on chain; MinSpendFee would revert it" + ); + } + let old: u128 = relayer::PendingRelayerFees::iter_values().sum(); + let new: u128 = pallet_shielded_pool::PendingFees::::iter_values().sum(); + Ok(old.saturating_add(new).encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + use scale_codec::Decode; + let before = u128::decode(&mut &state[..]) + .map_err(|_| sp_runtime::TryRuntimeError::Other("undecodable pre-upgrade state"))?; + let after: u128 = pallet_shielded_pool::PendingFees::::iter_values().sum(); + frame_support::ensure!(before == after, "fee total changed"); + frame_support::ensure!( + relayer::PendingRelayerFees::iter().next().is_none(), + "pallet_relayer still holds fees" + ); + frame_support::ensure!( + pallet_shielded_pool::PendingFees::::iter_keys().all(|(who, _)| { + let key: &[u8] = who.as_ref(); + aura_key_owner(key).is_none_or(|owner| owner == who) + }), + "a fee balance is still on an Aura key" + ); + Ok(()) + } +} + +parameter_types! { + pub const RelayerPalletName: &'static str = "Relayer"; +} + +/// Clears what is left of `pallet_relayer`: the relay registry, commits, +/// allowed selectors and fee floor. Runs after [`RelayerFeesToPool`]. +pub type RemoveRelayerPallet = frame_support::migrations::RemovePallet< + RelayerPalletName, + ::DbWeight, +>; + +/// The account that collected EVM priority tips for an Aura key before spec 19: +/// the H160 taken from bytes 4..24 of the key, under the runtime's address mapping. +fn stranded_tip_account(aura_key: &[u8]) -> Option { + let bytes = aura_key.get(4..24)?; + Some(crate::evm_account::evm_h160_to_account_id( + H160::from_slice(bytes), + )) +} + +/// Moves EVM priority tips paid to Aura-derived coinbase accounts to the +/// validators owning those keys. /// -/// Until spec 19 the block author was the Aura key taken as an `AccountId`, so a -/// validator whose account is not its Aura key earned its fallback relay fees on -/// an account nobody claims from. The Aura key's owner is the validator that -/// registered it (`pallet_session::KeyOwner`); a key no current validator owns is -/// left where it is. Idempotent: once moved, no balance sits on an owned key. -pub struct MoveAuraKeyRelayFees; - -impl MoveAuraKeyRelayFees { - /// `(aura_key_account, asset_id, amount, validator)` for every balance to move. - fn moves() -> alloc::vec::Vec<(AccountId, u32, u128, AccountId)> { - pallet_relayer::PendingRelayerFees::::iter() - .filter_map(|(holder, asset_id, amount)| { - let key: &[u8] = holder.as_ref(); - let owner = pallet_session::Pallet::::key_owner(AURA, key)?; - (owner != holder).then_some((holder, asset_id, amount, owner)) +/// Until spec 19 `block.coinbase` was 20 bytes of the author's Aura key, and the +/// tip went to the account that address maps to, which no key controls. Only keys +/// a current validator owns are moved; a rotated key's account is left. Idempotent: +/// a moved account holds nothing. +pub struct MoveStrandedEvmTips; + +impl MoveStrandedEvmTips { + /// `(stranded_account, validator, amount)` for every balance to move. + fn moves() -> alloc::vec::Vec<(AccountId, AccountId, u128)> { + pallet_session::KeyOwner::::iter() + .filter(|((key_type, _), _)| *key_type == AURA) + .filter_map(|((_, key), validator)| { + let source = stranded_tip_account(&key)?; + let amount = >::reducible_balance( + &source, + Preservation::Expendable, + Fortitude::Polite, + ); + (amount > 0 && source != validator).then_some((source, validator, amount)) }) .collect() } } -impl OnRuntimeUpgrade for MoveAuraKeyRelayFees { +impl OnRuntimeUpgrade for MoveStrandedEvmTips { fn on_runtime_upgrade() -> Weight { - let entries = pallet_relayer::PendingRelayerFees::::iter().count() as u64; + let owners = pallet_session::KeyOwner::::iter().count() as u64; let moves = Self::moves(); - for (holder, asset_id, amount, validator) in &moves { - pallet_relayer::PendingRelayerFees::::remove(holder, asset_id); - pallet_relayer::PendingRelayerFees::::mutate(validator, asset_id, |b| { - *b = b.saturating_add(*amount); - }); + let mut moved = 0u64; + for (source, validator, amount) in &moves { + match >::transfer( + source, + validator, + *amount, + Preservation::Expendable, + ) { + Ok(_) => moved += 1, + Err(e) => log::warn!( + target: "runtime::migrations", + "could not move a stranded EVM tip balance: {e:?}", + ), + } } log::info!( target: "runtime::migrations", - "moved {} relay fee balance(s) from Aura keys to their validators", - moves.len(), + "moved {moved} stranded EVM tip balance(s) to their validators", ); - let moved = moves.len() as u64; - // Two passes over the map, a key-owner read per entry, and per move a - // read and two writes. - ::DbWeight::get().reads_writes( - entries.saturating_mul(3).saturating_add(moved), + // A key-owner read and a balance read per owned key; a transfer reads and + // writes both accounts. + db_weight( + owners + .saturating_mul(2) + .saturating_add(moved.saturating_mul(2)), moved.saturating_mul(2), ) } @@ -57,22 +207,27 @@ impl OnRuntimeUpgrade for MoveAuraKeyRelayFees { #[cfg(feature = "try-runtime")] fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { use scale_codec::Encode; - let total: u128 = pallet_relayer::PendingRelayerFees::::iter_values().sum(); - Ok((total, Self::moves().len() as u32).encode()) + let issuance = >::total_issuance(); + Ok((Self::moves(), issuance).encode()) } #[cfg(feature = "try-runtime")] fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { use scale_codec::Decode; - let (before, to_move) = <(u128, u32)>::decode(&mut &state[..]) - .map_err(|_| sp_runtime::TryRuntimeError::Other("undecodable pre-upgrade state"))?; - let after: u128 = pallet_relayer::PendingRelayerFees::::iter_values().sum(); - frame_support::ensure!(before == after, "relay fee total changed"); + let (moves, issuance) = + <(alloc::vec::Vec<(AccountId, AccountId, u128)>, u128)>::decode(&mut &state[..]) + .map_err(|_| sp_runtime::TryRuntimeError::Other("undecodable pre-upgrade state"))?; frame_support::ensure!( - Self::moves().is_empty(), - "a fee balance is still on an Aura key" + >::total_issuance() == issuance, + "total issuance changed" + ); + frame_support::ensure!(Self::moves().is_empty(), "a stranded tip balance is left"); + let total: u128 = moves.iter().map(|(_, _, amount)| amount).sum(); + log::info!( + target: "runtime::migrations", + "{} stranded tip balance(s) moved, {total} planck", + moves.len(), ); - log::info!(target: "runtime::migrations", "{to_move} balance(s) moved, total unchanged"); Ok(()) } } diff --git a/template/runtime/src/precompiles.rs b/template/runtime/src/precompiles.rs index f9ac09bd..261f73a8 100644 --- a/template/runtime/src/precompiles.rs +++ b/template/runtime/src/precompiles.rs @@ -46,8 +46,7 @@ where + frame_support::dispatch::GetDispatchInfo + From>, <::RuntimeCall as sp_runtime::traits::Dispatchable>::RuntimeOrigin: - From::AccountId>> - + From, + From::AccountId>>, <::RuntimeCall as sp_runtime::traits::Dispatchable>::PostInfo: core::fmt::Debug, ::AccountId: From<[u8; 32]>, diff --git a/template/runtime/src/runtime_tests.rs b/template/runtime/src/runtime_tests.rs index d505d690..13663f72 100644 --- a/template/runtime/src/runtime_tests.rs +++ b/template/runtime/src/runtime_tests.rs @@ -706,38 +706,110 @@ mod block_author { } #[test] - fn fallback_relay_fees_go_to_the_validator_account() { + fn spend_fees_go_to_the_validator_account() { + use frame_support::traits::Get; super::with_ext(|| { let set = validator_set(); frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); assert_eq!( pallet_authorship::Pallet::::author(), + Some(set.bob.clone()) + ); + assert_eq!( + crate::configs::privacy::ValidatorAccountAuthor::get(), Some(set.bob) ); }); } #[test] - fn the_migration_moves_fees_off_aura_keys_once() { - use pallet_relayer::PendingRelayerFees as Fees; + fn relayer_fees_move_to_the_pool_and_off_aura_keys_once() { + use crate::migrations::{relayer::PendingRelayerFees as Old, RelayerFeesToPool}; + use pallet_shielded_pool::PendingFees as New; super::with_ext(|| { let set = validator_set(); let bob_key = as_account(&set.bob_aura); let stranger: AccountId = [0x5c; 32].into(); - Fees::::insert(&bob_key, 0, 10u128); - Fees::::insert(&set.bob, 0, 5u128); - Fees::::insert(&set.alice, 0, 7u128); // her key is her account - Fees::::insert(&stranger, 0, 3u128); // no validator owns it + Old::insert(&bob_key, 0, 10u128); + Old::insert(&set.bob, 0, 5u128); + Old::insert(&set.alice, 0, 7u128); // her key is her account + Old::insert(&stranger, 0, 3u128); // no validator owns it + + RelayerFeesToPool::on_runtime_upgrade(); + + assert_eq!(New::::get(&set.bob, 0), 15); + assert!(!New::::contains_key(&bob_key, 0)); + assert_eq!(New::::get(&set.alice, 0), 7); + assert_eq!(New::::get(&stranger, 0), 3); + assert!(Old::iter().next().is_none()); - crate::migrations::MoveAuraKeyRelayFees::on_runtime_upgrade(); + RelayerFeesToPool::on_runtime_upgrade(); + assert_eq!(New::::get(&set.bob, 0), 15); + }); + } - assert_eq!(Fees::::get(&set.bob, 0), 15); - assert!(!Fees::::contains_key(&bob_key, 0)); - assert_eq!(Fees::::get(&set.alice, 0), 7); - assert_eq!(Fees::::get(&stranger, 0), 3); + #[test] + fn the_relayer_prefix_is_cleared() { + super::with_ext(|| { + let prefix = sp_io::hashing::twox_128(b"Relayer"); + let mut key = prefix.to_vec(); + key.extend_from_slice(&sp_io::hashing::twox_128(b"RelayerRegistry")); + key.extend_from_slice(&[0x11; 20]); + sp_io::storage::set(&key, &[1]); + + crate::migrations::RemoveRelayerPallet::on_runtime_upgrade(); + + assert!(sp_io::storage::next_key(&prefix) + .filter(|k| k.starts_with(&prefix)) + .is_none()); + }); + } + + /// The account the pre-spec-19 coinbase paid an Aura key's tips to. + fn stranded(key: &AuraId) -> AccountId { + let raw: &[u8] = key.as_ref(); + crate::evm_account::evm_h160_to_account_id(sp_core::H160::from_slice(&raw[4..24])) + } - crate::migrations::MoveAuraKeyRelayFees::on_runtime_upgrade(); - assert_eq!(Fees::::get(&set.bob, 0), 15); + #[test] + fn stranded_evm_tips_move_to_their_validators_once() { + use frame_support::traits::fungible::{Inspect, Mutate}; + type B = crate::Balances; + super::with_ext(|| { + let set = validator_set(); + let orphan = aura_key("//Rotated"); // no validator owns it any more + B::mint_into(&stranded(&set.bob_aura), 40).unwrap(); + B::mint_into(&stranded(&orphan), 9).unwrap(); + let issuance = B::total_issuance(); + + crate::migrations::MoveStrandedEvmTips::on_runtime_upgrade(); + + assert_eq!(B::balance(&set.bob), 40); + assert_eq!(B::balance(&stranded(&set.bob_aura)), 0); + assert_eq!(B::balance(&stranded(&orphan)), 9); + assert_eq!(B::total_issuance(), issuance); + + crate::migrations::MoveStrandedEvmTips::on_runtime_upgrade(); + assert_eq!(B::balance(&set.bob), 40); + }); + } + + #[test] + fn an_evm_tip_goes_to_the_validator_and_burns_without_one() { + use frame_support::traits::fungible::{Balanced, Inspect}; + use pallet_evm::OnChargeEVMTransaction; + type B = crate::Balances; + type Tips = crate::configs::evm::TipToValidator; + super::with_ext(|| { + let set = validator_set(); + // No pre-digest: no author, so the tip is burned. + let before = B::total_issuance(); + Tips::pay_priority_fee(Some(B::issue(6))); + assert_eq!(B::total_issuance(), before); + + frame_system::Pallet::::initialize(&1, &Default::default(), &digest(1)); + Tips::pay_priority_fee(Some(B::issue(25))); + assert_eq!(B::balance(&set.bob), 25); }); } } diff --git a/ts-tests/node/no-bond-no-min-shield.test.cjs b/ts-tests/node/no-bond-no-min-shield.test.cjs index 98aef70d..8d7c0c3e 100644 --- a/ts-tests/node/no-bond-no-min-shield.test.cjs +++ b/ts-tests/node/no-bond-no-min-shield.test.cjs @@ -178,31 +178,6 @@ function freshCommitment() { assert.strictEqual(after.reserved.toBigInt(), 0n, 'addValidator reserved funds'); }); - await test('registerRelayer rejects an account outside the validator set', async () => { - const poor = keyring.addFromUri('//PoorValidator'); - // The set gate is checked before the signature, so a dummy proof suffices - // to show a non-validator gets no further. - await assert.rejects( - () => - submit( - api.tx.relayer.registerRelayer('0x' + '11'.repeat(20), '0x' + '00'.repeat(65)), - poor, - ), - (e) => e.message === 'relayer.NotValidator', - 'a non-validator must not be able to claim an EVM relay address', - ); - }); - - await test('registerRelayer takes an ownership proof', async () => { - // Two arguments: without the signature an approved validator could claim a - // rival's public relay address and divert its fees. - assert.strictEqual( - api.tx.relayer.registerRelayer.meta.args.length, - 2, - 'registerRelayer must take (evmAddress, signature)', - ); - }); - await test('no account on chain holds a reserved validator bond', async () => { const entries = await api.query.system.account.entries(); const reserved = entries.filter(([, v]) => v.data.reserved.toBigInt() > 0n); diff --git a/ts-tests/tests/test-relay-rpc.ts b/ts-tests/tests/test-relay-rpc.ts deleted file mode 100644 index da412d1a..00000000 --- a/ts-tests/tests/test-relay-rpc.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { assert } from "chai"; -import { ethers } from "ethers"; - -import { createAndFinalizeBlock, customRequest, describeWithFrontier } from "./util"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/// The relay identity `evm_relay_key::resolve` injects on dev chains: Alice's -/// ECDSA key. It is endowed in the development genesis so relaying can pay gas. -const RELAYER_ADDRESS = "0xe04cc55ebee1cbce552f250e85c57b70b2e2625b"; - -/// Minimum relay fee, read from the node at suite start. -/// -/// The effective floor is derived from the current base fee, so it is not a -/// constant: hardcoding one makes every "just below the minimum" case either -/// vacuous or wrong the moment fees move. -let MIN_RELAY_FEE: bigint; - -/// Function selectors, derived below from the ABI signatures rather than -/// hardcoded. A stale copy here fails silently: the tests keep passing because -/// a wrong selector still produces "unsupported selector", so the negative -/// cases go green while the positive ones silently test nothing. -const SIG_UNSHIELD = "unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)"; -const SIG_PRIVATE_TRANSFER = "privateTransfer(bytes,bytes32[],bytes32[],bytes32[],bytes[],uint32,uint256,uint32)"; - -const SEL_UNSHIELD = ethers.id(SIG_UNSHIELD).slice(2, 10); -const SEL_PRIVATE_TRANSFER = ethers.id(SIG_PRIVATE_TRANSFER).slice(2, 10); - -// --------------------------------------------------------------------------- -// Calldata builders -// --------------------------------------------------------------------------- - -const abiCoder = ethers.AbiCoder.defaultAbiCoder(); - -/** - * Build ABI-encoded calldata for `unshield(...)` with the given relay fee. - * - * The argument list mirrors SIG_UNSHIELD exactly — the head is 10 slots, and the - * relay's `min_calldata_len` (324 = 4 + 10×32) is derived from that. Encoding a - * shorter argument list here would build calldata the relay rightly refuses. - * - * ABI head layout after prepending the selector: - * data[196..228] = slot 6 = uint256 fee ← the value relay.rs reads - */ -function buildUnshieldCalldata(fee: bigint): string { - const encoded = abiCoder.encode( - ["bytes", "bytes32", "bytes32", "uint32", "uint256", "bytes32", "uint256", "bytes32", "bytes", "uint32"], - [ - "0x" + "aa".repeat(32), // proof (32 dummy bytes) - "0x" + "bb".repeat(32), // merkle root - "0x" + "cc".repeat(32), // nullifier - 0, // assetId - ethers.parseEther("1"), // amount - "0x" + "11".repeat(32), // recipient (AccountId32; must not be zero) - fee, // relay fee - "0x" + "00".repeat(32), // change commitment (total unshield → zero) - "0x", // change encrypted memo (empty for total unshield) - 1, // circuit version - ] - ); - return "0x" + SEL_UNSHIELD + encoded.slice(2); -} - -/** - * Build ABI-encoded calldata for `privateTransfer(...)` with the given relay fee. - * - * Mirrors SIG_PRIVATE_TRANSFER: 8 head slots, so `min_calldata_len` is - * 260 = 4 + 8×32. - * - * ABI head layout: data[196..228] = slot 6 = uint256 fee - */ -function buildPrivateTransferCalldata(fee: bigint): string { - const encoded = abiCoder.encode( - ["bytes", "bytes32[]", "bytes32[]", "bytes32[]", "bytes[]", "uint32", "uint256", "uint32"], - [ - "0x" + "aa".repeat(32), // proof - ["0x" + "bb".repeat(32), "0x" + "bb".repeat(32)], // merkle roots, one per input - ["0x" + "cc".repeat(32)], // nullifiers[] - ["0x" + "dd".repeat(32)], // output commitments[] - ["0x" + "ee".repeat(180)], // encrypted memos[] (must be exactly 180 bytes) - 0, // assetId - fee, // relay fee - 1, // circuit version - ] - ); - return "0x" + SEL_PRIVATE_TRANSFER + encoded.slice(2); -} - -// --------------------------------------------------------------------------- -// Relay RPC -// -// Dev chains get a relay key injected automatically, so there is no "relay -// disabled" case to cover here — that only happens on a non-dev chain with no -// `evmr` key in its keystore. -// --------------------------------------------------------------------------- - -describeWithFrontier("Frontier RPC (Relay)", (context) => { - before("read the effective minimum relay fee", async () => { - const result = await customRequest(context.web3, "orbinum_relayerStatus", []); - assert.notExists(result.error, `unexpected RPC error: ${JSON.stringify(result.error)}`); - MIN_RELAY_FEE = BigInt(result.result.minFee); - }); - - // ── orbinum_relayerStatus ────────────────────────────────────────── - - it("orbinum_relayerStatus: correct address, endowed, not enabled until registered", async () => { - const result = await customRequest(context.web3, "orbinum_relayerStatus", []); - assert.notExists(result.error, `unexpected RPC error: ${JSON.stringify(result.error)}`); - - const status = result.result; - assert.equal(status.address.toLowerCase(), RELAYER_ADDRESS, "relayer address should be the dev relay identity"); - assert.isTrue( - BigInt(status.balanceWei) > BigInt(0), - "relay account must be endowed in the development genesis" - ); - // Relaying needs a relay commit, which only a registered address can record: - // on a fresh dev chain nothing has called `register_relayer` yet. - assert.isFalse(status.isRegistered, "a fresh dev chain has no registered relayer"); - assert.isFalse(status.enabled, "an unregistered relay is not enabled"); - assert.isTrue(BigInt(status.minFee) > BigInt(0), "minFee must be positive"); - }); - - // ── Validation errors ────────────────────────────────────────────── - - it("rejects empty calldata (too short)", async () => { - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", ["0x"]); - assert.exists(result.error, "expected error for empty calldata"); - assert.include(JSON.stringify(result.error), "calldata too short"); - }); - - it("rejects calldata shorter than 228 bytes", async () => { - const short = "0x" + "00".repeat(100); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [short]); - assert.exists(result.error, "expected error for short calldata"); - assert.include(JSON.stringify(result.error), "calldata too short"); - }); - - it("rejects calldata of exactly 227 bytes (one byte short of 228)", async () => { - const data = "0x" + "00".repeat(227); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.exists(result.error, "expected error for 227-byte calldata"); - assert.include(JSON.stringify(result.error), "calldata too short"); - }); - - it("rejects unknown function selector", async () => { - // 0xdeadbeef is not in the relay whitelist - const data = "0xdeadbeef" + "00".repeat(224); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.exists(result.error, "expected error for unknown selector"); - assert.include(JSON.stringify(result.error), "unsupported selector"); - }); - - it("rejects fee = 0 (slot 6 is zero)", async () => { - const data = buildUnshieldCalldata(BigInt(0)); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.exists(result.error, "expected error for zero fee"); - assert.include(JSON.stringify(result.error), "fee below minimum"); - }); - - it("rejects fee 1 wei below the minimum", async () => { - const data = buildUnshieldCalldata(MIN_RELAY_FEE - BigInt(1)); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.exists(result.error, "expected error for sub-minimum fee"); - assert.include(JSON.stringify(result.error), "fee below minimum"); - }); - - it("rejects privateTransfer with fee 1 wei below the minimum", async () => { - const data = buildPrivateTransferCalldata(MIN_RELAY_FEE - BigInt(1)); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.exists(result.error, "expected error for sub-minimum fee"); - assert.include(JSON.stringify(result.error), "fee below minimum"); - }); - - // ── Fee is read from slot 6 (data[196..228]), NOT slot 5 (data[164..196]) ── - - it("does NOT read fee from slot 5 (regression: old wrong position)", async () => { - // Build calldata with exact min fee in the correct position (slot 6) - // but verify we're not fooled by a large value in slot 5 (recipient). - // If relay incorrectly read slot 5, it would accept zero-fee calldata - // where slot 5 happened to be large. - const data = buildUnshieldCalldata(BigInt(0)); // fee = 0 at slot 6 - // Overwrite slot 5 (data[164..196]) with a large value - const dataBytes = Buffer.from(data.slice(2), "hex"); - const largeFee = ethers.toBeHex(MIN_RELAY_FEE, 32).slice(2); - dataBytes.set(Buffer.from(largeFee, "hex"), 164); - const tampered = "0x" + dataBytes.toString("hex"); - // Slot 6 is still zero → relay must reject as "fee below minimum" - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [tampered]); - assert.exists(result.error, "relay must not accept zero-fee calldata"); - assert.include( - JSON.stringify(result.error), - "fee below minimum", - "fee regression: relay must read from slot 6, not slot 5" - ); - }); - - // ── Happy path ───────────────────────────────────────────────────── - // - // Skipped, not broken. The relay dry-runs the call against the EVM before it - // will sign anything, so accepting calldata means the whole shielded operation - // must succeed: a merkle root the pool knows, an unspent nullifier, and a - // proof that verifies. Dummy calldata cannot clear that, and these cases have - // never been able to pass. Covering them needs a funded pool with a real note, - // which is what ts-tests/e2e-relay-*.cjs does against a seeded chain. - - it.skip("accepts valid unshield calldata with exact minimum fee → returns H256 txHash", async () => { - const data = buildUnshieldCalldata(MIN_RELAY_FEE); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.notExists(result.error, `unexpected error: ${JSON.stringify(result.error)}`); - assert.match(result.result, /^0x[0-9a-fA-F]{64}$/, "result must be a 0x-prefixed 32-byte hex hash"); - await createAndFinalizeBlock(context.web3); // mine to advance relayer nonce - }); - - it.skip("accepts valid privateTransfer calldata with minimum fee → returns H256 txHash", async () => { - const data = buildPrivateTransferCalldata(MIN_RELAY_FEE); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.notExists(result.error, `unexpected error: ${JSON.stringify(result.error)}`); - assert.match(result.result, /^0x[0-9a-fA-F]{64}$/, "result must be a 0x-prefixed 32-byte hex hash"); - await createAndFinalizeBlock(context.web3); - }); - - it.skip("accepts fee larger than minimum → returns H256 txHash", async () => { - const data = buildUnshieldCalldata(MIN_RELAY_FEE * BigInt(10)); - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.notExists(result.error, `unexpected error: ${JSON.stringify(result.error)}`); - assert.match(result.result, /^0x[0-9a-fA-F]{64}$/); - await createAndFinalizeBlock(context.web3); - }); - - // ── Tx lifecycle ─────────────────────────────────────────────────── - - it.skip("relayed tx is visible in pending pool before block is mined", async () => { - const data = buildUnshieldCalldata(MIN_RELAY_FEE); - const relayResult = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.notExists(relayResult.error, `unexpected error: ${JSON.stringify(relayResult.error)}`); - const txHash: string = relayResult.result; - - const pending = await customRequest(context.web3, "eth_getTransactionByHash", [txHash]); - assert.isNotNull(pending.result, "relayed tx should be in pending pool immediately"); - assert.equal(pending.result.hash.toLowerCase(), txHash.toLowerCase(), "hash in pool must match returned hash"); - - await createAndFinalizeBlock(context.web3); // clean up pool - }); - - it.skip("relayed tx has a receipt after block is finalized", async () => { - const data = buildUnshieldCalldata(MIN_RELAY_FEE); - const relayResult = await customRequest(context.web3, "orbinum_relayShieldedCall", [data]); - assert.notExists(relayResult.error, `unexpected error: ${JSON.stringify(relayResult.error)}`); - const txHash: string = relayResult.result; - - await createAndFinalizeBlock(context.web3); - - const receipt = await context.web3.eth.getTransactionReceipt(txHash); - assert.isNotNull(receipt, "receipt must exist after block is finalized"); - assert.equal(receipt.transactionHash.toLowerCase(), txHash.toLowerCase(), "receipt txHash must match"); - }); -});