From d9a714580b77714e04c4c8271162e9cc1bf1e915 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Mon, 24 Aug 2026 10:02:27 -0400 Subject: [PATCH 1/7] fix(node): drop isEthereum from testnet properties --- template/node/src/chain_spec/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/template/node/src/chain_spec/mod.rs b/template/node/src/chain_spec/mod.rs index e725f8f5..c8e9e030 100644 --- a/template/node/src/chain_spec/mod.rs +++ b/template/node/src/chain_spec/mod.rs @@ -81,6 +81,5 @@ pub(crate) fn properties() -> Properties { properties.insert("tokenSymbol".into(), "ORB".into()); properties.insert("tokenDecimals".into(), 18.into()); properties.insert("ss58Format".into(), SS58Prefix::get().into()); - properties.insert("isEthereum".into(), true.into()); properties } From 3212c05c4a4e277029b507193898abffe8c7eff2 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Mon, 24 Aug 2026 14:45:22 -0400 Subject: [PATCH 2/7] fix(rpc): derive state-override account keys the way the runtime does --- Cargo.lock | 2 + template/node/Cargo.toml | 2 + template/node/src/rpc/mod.rs | 7 +- template/node/src/rpc/storage_override.rs | 118 ++++++++++++++++++++++ ts-tests/tests/config.ts | 4 +- ts-tests/tests/test-state-override.ts | 49 +++++++-- 6 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 template/node/src/rpc/storage_override.rs diff --git a/Cargo.lock b/Cargo.lock index 5667c356..22c6f5b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7311,6 +7311,8 @@ dependencies = [ "sp-offchain", "sp-runtime", "sp-session", + "sp-state-machine", + "sp-storage", "sp-timestamp", "sp-transaction-pool", "substrate-build-script-utils", diff --git a/template/node/Cargo.toml b/template/node/Cargo.toml index 97ff5400..1ee0bbb3 100644 --- a/template/node/Cargo.toml +++ b/template/node/Cargo.toml @@ -54,6 +54,8 @@ sp-io = { workspace = true, features = ["default"] } sp-offchain = { workspace = true, features = ["default"] } sp-runtime = { workspace = true, features = ["default"] } sp-session = { workspace = true, features = ["default"] } +sp-state-machine = { workspace = true, features = ["default"] } +sp-storage = { workspace = true, features = ["default"] } sp-timestamp = { workspace = true, features = ["default"] } sp-transaction-pool = { workspace = true, features = ["default"] } # These dependencies are used for RPC diff --git a/template/node/src/rpc/mod.rs b/template/node/src/rpc/mod.rs index 11ed6c05..938c74c1 100644 --- a/template/node/src/rpc/mod.rs +++ b/template/node/src/rpc/mod.rs @@ -25,8 +25,10 @@ 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; /// Full client dependencies. pub struct FullDeps { @@ -51,8 +53,9 @@ where BE: Backend + 'static, { type EstimateGasAdapter = (); - type RuntimeStorageOverride = - fc_rpc::frontier_backend_client::SystemAccountId20StorageOverride; + // Frontier's stock overrides assume either AccountId20 or HashedAddressMapping; + // this runtime is AccountId32 with EeSuffixAddressMapping. + type RuntimeStorageOverride = EeSuffixStorageOverride; } /// Instantiate all Full RPC extensions. diff --git a/template/node/src/rpc/storage_override.rs b/template/node/src/rpc/storage_override.rs new file mode 100644 index 00000000..7e73d779 --- /dev/null +++ b/template/node/src/rpc/storage_override.rs @@ -0,0 +1,118 @@ +//! `eth_call` / `eth_estimateGas` state-override support for Orbinum's account model. +//! +//! Frontier ships two overrides, one per address model it supports: an +//! `AccountId20` runtime (`IdentityAddressMapping`) and an `AccountId32` runtime +//! using `HashedAddressMapping`. Orbinum is neither — it keeps `AccountId32` but +//! maps addresses with `EeSuffixAddressMapping` (`[H160 | 0x00 × 12]`), so both +//! stock overrides build a `System::Account` key that does not exist and the +//! caller's `state_overrides` are silently dropped. +//! +//! This override derives the key the way the runtime does. It must stay in step +//! with `EeSuffixAddressMapping`: a mismatch here does not fail loudly, it just +//! makes simulated balances and nonces disappear. + +use std::marker::PhantomData; + +use sc_client_api::{backend::Backend, StorageProvider}; +use scale_codec::Encode; +use sp_core::{H160, U256}; +use sp_io::hashing::{blake2_128, twox_128}; +use sp_runtime::traits::{Block as BlockT, HashingFor}; +use sp_state_machine::OverlayedChanges; +use sp_storage::StorageKey; + +/// `[H160 | 0x00 × 12]` — the layout `EeSuffixAddressMapping` gives an EVM +/// address in the runtime. +fn account_id_bytes(address: H160) -> Vec { + let mut bytes = [0u8; 32]; + bytes[..20].copy_from_slice(address.as_bytes()); + bytes.to_vec() +} + +/// Writes `System::Account` overrides for an `AccountId32` runtime that maps EVM +/// addresses as `[H160 | 0x00 × 12]`. +/// +/// Assumes the account layout `pallet_balances` gives `System::Account`: +/// `nonce: u32` at bytes 0..4 and `free: u128` at bytes 16..32. +pub struct EeSuffixStorageOverride(PhantomData<(B, C, BE)>); + +impl fp_rpc::RuntimeStorageOverride for EeSuffixStorageOverride +where + B: BlockT, + C: StorageProvider + Send + Sync, + BE: Backend, +{ + fn is_enabled() -> bool { + true + } + + fn set_overlayed_changes( + client: &C, + overlayed_changes: &mut OverlayedChanges>, + block: B::Hash, + _version: u32, + address: H160, + balance: Option, + nonce: Option, + ) { + let mut key = [twox_128(b"System"), twox_128(b"Account")] + .concat() + .to_vec(); + let account_id = Self::into_account_id_bytes(address); + key.extend(blake2_128(&account_id)); + key.extend(&account_id); + + // No entry means the account has never been touched on chain; there is + // nothing to splice the override into. + if let Ok(Some(item)) = client.storage(block, &StorageKey(key.clone())) { + let mut new_item = item.0; + + if let Some(nonce) = nonce { + new_item.splice(0..4, nonce.low_u32().encode()); + } + + if let Some(balance) = balance { + new_item.splice(16..32, balance.low_u128().encode()); + } + + overlayed_changes.set_storage(key, Some(new_item)); + } + } + + fn into_account_id_bytes(address: H160) -> Vec { + account_id_bytes(address) + } +} + +#[cfg(test)] +mod tests { + use orbinum_runtime::evm_h160_to_account_id; + + use super::*; + + /// The whole point of this file: the RPC-side derivation and the runtime's + /// `EeSuffixAddressMapping` must agree, or state overrides build a + /// `System::Account` key that does not exist and are dropped without an error. + #[test] + fn matches_the_runtime_address_mapping() { + for byte in [0x00u8, 0x01, 0x42, 0xAB, 0xFF] { + let address = H160::repeat_byte(byte); + let expected = evm_h160_to_account_id(address); + assert_eq!( + account_id_bytes(address), + AsRef::<[u8]>::as_ref(&expected).to_vec(), + "RPC override and runtime mapping disagree for {address:?}" + ); + } + } + + #[test] + fn layout_is_address_then_twelve_zeros() { + let address = H160::repeat_byte(0xAB); + let bytes = account_id_bytes(address); + + assert_eq!(bytes.len(), 32); + assert_eq!(&bytes[..20], address.as_bytes()); + assert_eq!(&bytes[20..], &[0u8; 12]); + } +} diff --git a/ts-tests/tests/config.ts b/ts-tests/tests/config.ts index 9d93d8ea..6f4ff2f3 100644 --- a/ts-tests/tests/config.ts +++ b/ts-tests/tests/config.ts @@ -1,6 +1,8 @@ export const GENESIS_ACCOUNT = "0x6be02d1d3665660d22ff9624b7be0551ee1ac91b"; export const GENESIS_ACCOUNT_PRIVATE_KEY = "0x99B3C12287537E38C90A9219D4CB074A89A16E9CDB20BF85728EBD97C343E342"; -export const GENESIS_ACCOUNT_BALANCE = "340282366920938463463374607431768211455"; +// DEV_BALANCE in the runtime's genesis preset: 10_000 * PLANCK, with PLANCK = 1e18. +// Frontier's template endows u128::MAX here; Orbinum's dev genesis does not. +export const GENESIS_ACCOUNT_BALANCE = "10000000000000000000000"; export const FIRST_CONTRACT_ADDRESS = "0xc2bf5f29a4384b1ab0c063e1c666f02121b6084a"; diff --git a/ts-tests/tests/test-state-override.ts b/ts-tests/tests/test-state-override.ts index b4ed61fc..d384c262 100644 --- a/ts-tests/tests/test-state-override.ts +++ b/ts-tests/tests/test-state-override.ts @@ -5,7 +5,7 @@ import { AbiItem } from "web3-utils"; import StateOverrideTest from "../build/contracts/StateOverrideTest.json"; import Test from "../build/contracts/Test.json"; -import { GENESIS_ACCOUNT, GENESIS_ACCOUNT_PRIVATE_KEY } from "./config"; +import { GENESIS_ACCOUNT, GENESIS_ACCOUNT_BALANCE, GENESIS_ACCOUNT_PRIVATE_KEY } from "./config"; import { createAndFinalizeBlock, customRequest, describeWithFrontier } from "./util"; chaiUse(chaiAsPromised); @@ -54,7 +54,9 @@ describeWithFrontier("Frontier RPC (StateOverride)", (context) => { await createAndFinalizeBlock(context.web3); }); - it("should have balance above 1000 tether without state override", async function () { + // The genesis account is endowed DEV_BALANCE (10_000 ORB), minus whatever the + // deploy in `before` spent on gas. + it("should report the real sender balance without state override", async function () { const { result } = await customRequest(context.web3, "eth_call", [ { from: GENESIS_ACCOUNT, @@ -62,13 +64,20 @@ describeWithFrontier("Frontier RPC (StateOverride)", (context) => { data: contract.methods.getSenderBalance().encodeABI(), }, ]); - const balance = Web3.utils.toBN( - Web3.utils.fromWei(Web3.utils.hexToNumberString(result), "tether").split(".")[0] - ); - expect(balance.gten(1000), "balance was not above 1000 tether").to.be.true; + const balance = Web3.utils.toBN(Web3.utils.hexToNumberString(result)); + const endowment = Web3.utils.toBN(GENESIS_ACCOUNT_BALANCE); + + expect(balance.lte(endowment), "balance exceeded the genesis endowment").to.be.true; + // A tenth of the endowment is far more than the deploy costs; anything + // below it would mean the account is not the endowed one. + expect(balance.gte(endowment.divn(10)), "balance was implausibly low").to.be.true; }); - it.skip("should have a balance of 5000 with state override", async function () { + // Balance and nonce overrides are the only ones that go through the runtime's + // RuntimeStorageOverride, which has to rebuild the System::Account key itself. + // Get the address derivation wrong and the key does not exist, so the override + // is dropped and the call silently returns real chain state. + it("should have a balance of 5000 with state override", async function () { const { result } = await customRequest(context.web3, "eth_call", [ { from: GENESIS_ACCOUNT, @@ -85,6 +94,32 @@ describeWithFrontier("Frontier RPC (StateOverride)", (context) => { expect(Web3.utils.hexToNumberString(result)).to.equal("5000"); }); + it("should override the sender balance", async function () { + const { result: real } = await customRequest(context.web3, "eth_call", [ + { + from: GENESIS_ACCOUNT, + to: contractAddress, + data: contract.methods.getSenderBalance().encodeABI(), + }, + ]); + expect(Web3.utils.hexToNumberString(real)).to.not.equal("1234"); + + const { result } = await customRequest(context.web3, "eth_call", [ + { + from: GENESIS_ACCOUNT, + to: contractAddress, + data: contract.methods.getSenderBalance().encodeABI(), + }, + "latest", + { + [GENESIS_ACCOUNT]: { + balance: Web3.utils.numberToHex(1234), + }, + }, + ]); + expect(Web3.utils.hexToNumberString(result)).to.equal("1234"); + }); + it("should have availableFunds of 100 without state override", async function () { const { result } = await customRequest(context.web3, "eth_call", [ { From 9e32354f5997d360a65a142db066ac08b7dc4fa9 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Mon, 24 Aug 2026 15:15:59 -0400 Subject: [PATCH 3/7] fix(dev): endow the EVM relay key so relaying works out of the box --- template/node/src/rpc/eth.rs | 2 +- .../src/genesis_config_preset/development.rs | 4 + ts-tests/tests/test-relay-rpc.ts | 379 ++++++++---------- 3 files changed, 175 insertions(+), 210 deletions(-) diff --git a/template/node/src/rpc/eth.rs b/template/node/src/rpc/eth.rs index b2947e5c..3ebe9313 100644 --- a/template/node/src/rpc/eth.rs +++ b/template/node/src/rpc/eth.rs @@ -239,7 +239,7 @@ where io.merge(OrbinumRelay::new(client.clone(), pool.clone(), signer).into_rpc())?; } Err(e) => { - log::error!(target: "rpc", "Invalid --evm-relayer-key: {e}"); + log::error!(target: "rpc", "Invalid EVM relay key: {e}"); } } } diff --git a/template/runtime/src/genesis_config_preset/development.rs b/template/runtime/src/genesis_config_preset/development.rs index d6377cb0..68083a2f 100644 --- a/template/runtime/src/genesis_config_preset/development.rs +++ b/template/runtime/src/genesis_config_preset/development.rs @@ -57,6 +57,10 @@ pub fn development() -> serde_json::Value { ethereum_to_account_id(hex!("6be02d1d3665660d22ff9624b7be0551ee1ac91b")), DEV_BALANCE, ), + ( + ethereum_to_account_id(hex!("e04cc55ebee1cbce552f250e85c57b70b2e2625b")), + DEV_BALANCE, + ), ], vec![], 42, diff --git a/ts-tests/tests/test-relay-rpc.ts b/ts-tests/tests/test-relay-rpc.ts index ebe41d85..444fdd46 100644 --- a/ts-tests/tests/test-relay-rpc.ts +++ b/ts-tests/tests/test-relay-rpc.ts @@ -1,27 +1,29 @@ import { assert } from "chai"; import { ethers } from "ethers"; -import { GENESIS_ACCOUNT_PRIVATE_KEY } from "./config"; import { createAndFinalizeBlock, customRequest, describeWithFrontier } from "./util"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -/// Minimum relay fee: 0.001 ORB (1e15 wei), mirrors MIN_RELAY_FEE_WEI in relay.rs -const MIN_RELAY_FEE = ethers.parseUnits("0.001", 18); +/// 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"; -/// EVM address derived from GENESIS_ACCOUNT_PRIVATE_KEY (lower‑case, with 0x) -const RELAYER_ADDRESS = "0x6be02d1d3665660d22ff9624b7be0551ee1ac91b"; +/// 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 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); @@ -44,25 +46,14 @@ const abiCoder = ethers.AbiCoder.defaultAbiCoder(); */ function buildUnshieldCalldata(fee: bigint): string { const encoded = abiCoder.encode( - [ - "bytes", - "bytes32", - "bytes32", - "uint32", - "uint256", - "bytes32", - "uint256", - "bytes32", - "bytes", - "uint32", - ], + ["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" + "00".repeat(32), // recipient (AccountId32 as bytes32) + "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) @@ -88,7 +79,7 @@ function buildPrivateTransferCalldata(fee: bigint): string { "0x" + "bb".repeat(32), // merkle root ["0x" + "cc".repeat(32)], // nullifiers[] ["0x" + "dd".repeat(32)], // output commitments[] - ["0x" + "ee".repeat(104)], // encrypted memos[] + ["0x" + "ee".repeat(180)], // encrypted memos[] (must be exactly 180 bytes) 0, // assetId fee, // relay fee 1, // circuit version @@ -98,200 +89,170 @@ function buildPrivateTransferCalldata(fee: bigint): string { } // --------------------------------------------------------------------------- -// Suite 1 — relay is NOT configured (no --evm-relayer-key) +// 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 – disabled)", (context) => { - it("orbinum_relayerStatus returns method-not-found when disabled", async () => { +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: enabled, correct address, positive minFee", async () => { const result = await customRequest(context.web3, "orbinum_relayerStatus", []); - assert.exists(result.error, "expected JSON-RPC error but got none"); - const errStr = JSON.stringify(result.error).toLowerCase(); + 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"); + // enabled tracks whether the relay can cover a transaction, so it doubles + // as the check that the dev genesis actually endowed the relay account. assert.isTrue( - errStr.includes("not found") || errStr.includes("-32601"), - `unexpected error: ${errStr}` + BigInt(status.balanceWei) > BigInt(0), + "relay account must be endowed in the development genesis" ); + assert.isTrue(status.enabled, "relayer should report enabled=true"); + assert.isTrue(BigInt(status.minFee) > BigInt(0), "minFee must be positive"); }); - it("orbinum_relayShieldedCall returns method-not-found when disabled", async () => { - const result = await customRequest(context.web3, "orbinum_relayShieldedCall", [ - "0x" + "00".repeat(228), - ]); - assert.exists(result.error, "expected JSON-RPC error but got none"); - const errStr = JSON.stringify(result.error).toLowerCase(); - assert.isTrue( - errStr.includes("not found") || errStr.includes("-32601"), - `unexpected error: ${errStr}` + // ── 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" ); }); -}); -// --------------------------------------------------------------------------- -// Suite 2 — relay IS configured with the genesis test key -// --------------------------------------------------------------------------- + // ── 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); + }); -describeWithFrontier( - "Frontier RPC (Relay – enabled)", - (context) => { - // ── orbinum_relayerStatus ────────────────────────────────────────── - - it("orbinum_relayerStatus: enabled, correct address, correct minFee", 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.isTrue(status.enabled, "relayer should report enabled=true"); - assert.equal(status.minFee, "1000000000000000", "minFee mismatch (expected 0.001 ORB)"); - assert.equal( - status.address.toLowerCase(), - RELAYER_ADDRESS, - "relayer address should match key derived from GENESIS_ACCOUNT_PRIVATE_KEY" - ); - }); - - // ── 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: valid calldata is accepted and tx hash is returned ─ - - it("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("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("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("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("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" - ); - }); - }, - undefined, // provider (default = http) - ["--evm-relayer-key", GENESIS_ACCOUNT_PRIVATE_KEY] -); + // ── 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"); + }); +}); From 0f38faba6f66c96a80513f9acdb8dfe632b6cebd Mon Sep 17 00:00:00 2001 From: nol4lej Date: Mon, 24 Aug 2026 15:22:22 -0400 Subject: [PATCH 4/7] test(web3api): read the runtime version instead of hardcoding it --- ts-tests/tests/config.ts | 2 -- ts-tests/tests/test-web3api.ts | 14 ++++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/ts-tests/tests/config.ts b/ts-tests/tests/config.ts index 6f4ff2f3..7346178d 100644 --- a/ts-tests/tests/config.ts +++ b/ts-tests/tests/config.ts @@ -9,8 +9,6 @@ export const FIRST_CONTRACT_ADDRESS = "0xc2bf5f29a4384b1ab0c063e1c666f02121b6084 export const NODE_BINARY_NAME = "orbinum-node"; export const RUNTIME_SPEC_NAME = "orbinum"; -export const RUNTIME_SPEC_VERSION = 1; -export const RUNTIME_IMPL_VERSION = 1; export const CHAIN_ID = 42; export const BLOCK_TIMESTAMP = 6; // 6 seconds per block diff --git a/ts-tests/tests/test-web3api.ts b/ts-tests/tests/test-web3api.ts index 8eaa4c21..29ee41e1 100644 --- a/ts-tests/tests/test-web3api.ts +++ b/ts-tests/tests/test-web3api.ts @@ -1,15 +1,21 @@ import { expect } from "chai"; import { step } from "mocha-steps"; -import { RUNTIME_SPEC_NAME, RUNTIME_SPEC_VERSION, RUNTIME_IMPL_VERSION } from "./config"; +import { RUNTIME_SPEC_NAME } from "./config"; import { describeWithFrontier, customRequest } from "./util"; describeWithFrontier("Frontier RPC (Web3Api)", (context) => { + // The client version embeds the runtime's spec/impl version, so hardcoding it + // here would break on every runtime upgrade. Read the live version instead and + // assert the shape. step("should get client version", async function () { + const runtime = await customRequest(context.web3, "state_getRuntimeVersion", []); + const { specName, specVersion, implVersion } = runtime.result; + + expect(specName).to.be.equal(RUNTIME_SPEC_NAME); + const version = await context.web3.eth.getNodeInfo(); - expect(version).to.be.equal( - `${RUNTIME_SPEC_NAME}/v${RUNTIME_SPEC_VERSION}.${RUNTIME_IMPL_VERSION}/fc-rpc-2.0.0-dev` - ); + expect(version).to.be.equal(`${specName}/v${specVersion}.${implVersion}/fc-rpc-2.0.0-dev`); }); step("should remote sha3", async function () { From fd11165c7687ab02c9a56beb2c2709a27176037d Mon Sep 17 00:00:00 2001 From: nol4lej Date: Mon, 24 Aug 2026 15:25:51 -0400 Subject: [PATCH 5/7] test(transaction-cost): sign the transaction so it reaches the check under test --- ts-tests/tests/test-transaction-cost.ts | 30 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/ts-tests/tests/test-transaction-cost.ts b/ts-tests/tests/test-transaction-cost.ts index bcde9388..b25e7edb 100644 --- a/ts-tests/tests/test-transaction-cost.ts +++ b/ts-tests/tests/test-transaction-cost.ts @@ -1,19 +1,33 @@ import { expect } from "chai"; +import { ethers } from "ethers"; import { step } from "mocha-steps"; +import { CHAIN_ID, GENESIS_ACCOUNT_PRIVATE_KEY } from "./config"; import { describeWithFrontier, customRequest } from "./util"; describeWithFrontier("Frontier RPC (Transaction cost)", (context) => { + // Signed here rather than pasted as a raw hex blob: a hardcoded transaction + // carries its own chain id and signature, so it silently stops testing what it + // claims the moment either changes. Signing with a chain id keeps it EIP-155 + // protected — unprotected legacy transactions are refused by RPC policy before + // they reach the pool, and the rejection under test would never be reached. + // + // ethers signs a zero gas limit; web3 rejects it client-side. step("should take transaction cost into account and not submit it to the pool", async function () { - // Simple transfer with gas limit 0 manually signed to prevent web3 from rejecting client-side. - const tx = await customRequest(context.web3, "eth_sendRawTransaction", [ - "0xf86180843b9aca00809412cb274aad8251c875c0bf6872b67d9983e53fdd01801ca00e28ba2dd3c5a3fd467\ - d4afd7aefb4a34b373314fff470bb9db743a84d674a0aa06e5994f2d07eafe1c37b4ce5471caecec29011f6f5b\ - f0b1a552c55ea348df35f", - ]); - let msg = "intrinsic gas too low"; + const wallet = new ethers.Wallet(GENESIS_ACCOUNT_PRIVATE_KEY); + const rawTransaction = await wallet.signTransaction({ + to: "0x12cb274aad8251c875c0bf6872b67d9983e53fdd", + value: 1, + gasPrice: "0x3B9ACA00", + gasLimit: 0, // below the 21000 intrinsic minimum + nonce: 0, + chainId: CHAIN_ID, + }); + + const tx = await customRequest(context.web3, "eth_sendRawTransaction", [rawTransaction]); + expect(tx.error).to.include({ - message: msg, + message: "intrinsic gas too low", }); }); }); From d30f4eff0bf5817458429b69b5dff48ff2a3dadd Mon Sep 17 00:00:00 2001 From: nol4lej Date: Thu, 3 Sep 2026 18:33:19 -0400 Subject: [PATCH 6/7] docs(ismp): correct the fee model, self-relay makes a zero relayer fee the right value --- frame/ismp-messaging/src/outbound.rs | 11 +++++++++-- template/runtime/src/configs/ismp/mod.rs | 9 +++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frame/ismp-messaging/src/outbound.rs b/frame/ismp-messaging/src/outbound.rs index 9a04b118..6598f89a 100644 --- a/frame/ismp-messaging/src/outbound.rs +++ b/frame/ismp-messaging/src/outbound.rs @@ -62,8 +62,15 @@ pub fn post( let commitment = pallet_ismp::Pallet::::default() .dispatch_request( DispatchRequest::Post(post), - // Zero fee: relayer fees are disabled runtime-wide. A non-zero value would - // escrow funds that only a timeout releases. + // Zero fee, deliberately: Orbinum self-relays, and Hyperbridge's docs call + // that the intended integration — the relayer is paid offchain in BRIDGE + // rather than per-message on-chain. `dispatch_request` skips the transfer + // entirely when the fee is zero, so nothing is escrowed and nobody is owed. + // + // A non-zero fee is what Hyperbridge's permissionless relayer network reads + // to decide whether a message is worth delivering. Setting one only makes + // sense alongside dropping self-relay, and `Currency` would have to stop + // being the native token first — an external relayer cannot sell it. FeeMetadata { payer: payer::(), fee: Default::default(), diff --git a/template/runtime/src/configs/ismp/mod.rs b/template/runtime/src/configs/ismp/mod.rs index be8d7b12..23de8299 100644 --- a/template/runtime/src/configs/ismp/mod.rs +++ b/template/runtime/src/configs/ismp/mod.rs @@ -103,8 +103,13 @@ impl pallet_ismp::Config for Runtime { type OffchainDB = (); - /// `POLICY = false` disables relayer fee charging: `on_executed` returns `Pays::No` - /// before touching balances. Switching it on is a mainnet-economics decision. + /// `POLICY = false` makes message *delivery* free for the submitter: `on_executed` + /// returns `Pays::No` before charging anyone (`fee_handler.rs:172`). + /// + /// This is the inbound side, and it is separate from the relayer fee an outbound + /// message carries — that one lives in `FeeMetadata` and is zero because Orbinum + /// self-relays. Turning `POLICY` on would bill whoever submits an inbound message + /// for its execution weight, which is a mainnet-economics decision. type FeeHandler = pallet_ismp::fee_handler::WeightFeeHandler< AccountId, Balances, From 56a3c3d710b55d07984da12ced43f257b76f9923 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Thu, 3 Sep 2026 18:34:45 -0400 Subject: [PATCH 7/7] fix(hyperbridge): add the slot_duration Tesseract requires to parse the Paseo config --- scripts/hyperbridge/relayer.paseo.toml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/hyperbridge/relayer.paseo.toml b/scripts/hyperbridge/relayer.paseo.toml index ae8c9d9c..e43f17e5 100644 --- a/scripts/hyperbridge/relayer.paseo.toml +++ b/scripts/hyperbridge/relayer.paseo.toml @@ -49,6 +49,12 @@ type = "grandpa" # Hyperbridge is a parachain: its GRANDPA finality comes from the RELAY chain # (Paseo), not from Hyperbridge itself. rpc = "wss://pas-rpc.stakeworld.io" # dwellir stopped resolving (2026-08-27) +# Required, not optional: `HostConfig` (tesseract/consensus/grandpa/src/lib.rs:85) +# has no default, so omitting it fails with "missing field `slot_duration`" before +# the relayer connects to anything. 6000 on Paseo; Polkadot mainnet would be 12000. +slot_duration = 6000 +# How often to exchange consensus proofs, per the upstream docs. +consensus_update_frequency = 60 # Hyperbridge's para id on Paseo. Listing it is what makes the relayer ship # proofs that verify Hyperbridge *through* its relay's GRANDPA. para_ids = [4009] @@ -68,6 +74,10 @@ consensus_state_id = "ORBI" type = "grandpa" # Orbinum is standalone, so this points at Orbinum itself. rpc = "wss://rpc-1.testnet.orbinum.io" +# Orbinum's own block time, and the value the runtime whitelists Hyperbridge with +# (`HYPERBRIDGE_SLOT_DURATION_MS`). Required, same as above. +slot_duration = 6000 +consensus_update_frequency = 60 # Empty: we are not a relay chain with parachains to prove. para_ids = []