diff --git a/Cargo.lock b/Cargo.lock index 63a120b5..7f854664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2057,7 +2057,7 @@ dependencies = [ "sp-inherents", "sp-runtime", "sp-state-machine", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "tracing", ] @@ -2086,7 +2086,7 @@ dependencies = [ "scale-info", "sp-consensus-babe", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-inherents", "sp-io", "sp-runtime", @@ -2165,8 +2165,8 @@ name = "cumulus-primitives-proof-size-hostfunction" version = "0.13.0" source = "git+https://github.com/paritytech/polkadot-sdk?branch=stable2506#f61fa5b989147a944b5421206729ed39d88ddf69" dependencies = [ - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", + "sp-runtime-interface", "sp-trie", ] @@ -3269,6 +3269,7 @@ dependencies = [ "libsecp256k1", "log", "pallet-evm", + "pallet-shielded-pool-runtime-api", "parity-scale-codec", "prometheus", "rand 0.9.2", @@ -3291,12 +3292,12 @@ dependencies = [ "sp-consensus", "sp-consensus-aura", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-inherents", "sp-io", "sp-runtime", "sp-state-machine", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "sp-timestamp", "sp-trie", "substrate-prometheus-endpoint", @@ -3369,7 +3370,7 @@ dependencies = [ "sp-api", "sp-io", "sp-runtime", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", ] [[package]] @@ -3667,8 +3668,8 @@ dependencies = [ "sp-core", "sp-io", "sp-runtime", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-runtime-interface", + "sp-storage", "static_assertions", ] @@ -3717,20 +3718,20 @@ dependencies = [ "sp-blockchain", "sp-core", "sp-database", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-genesis-builder", "sp-inherents", "sp-io", "sp-keystore", "sp-runtime", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-runtime-interface", "sp-state-machine", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "sp-timestamp", "sp-transaction-pool", "sp-trie", "sp-version", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-wasm-interface", "subxt", "subxt-signer", "thiserror 1.0.69", @@ -3794,7 +3795,7 @@ dependencies = [ "sp-core", "sp-io", "sp-runtime", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", ] [[package]] @@ -3888,7 +3889,7 @@ dependencies = [ "sp-arithmetic", "sp-core", "sp-crypto-hashing-proc-macro", - "sp-debug-derive 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-debug-derive", "sp-genesis-builder", "sp-inherents", "sp-io", @@ -3897,7 +3898,7 @@ dependencies = [ "sp-staking", "sp-state-machine", "sp-std", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", "sp-trie", "sp-weights", "tt-call", @@ -7200,9 +7201,12 @@ dependencies = [ "jsonrpsee", "log", "orbinum-runtime", - "orbinum-zk-core 0.5.0", + "orbinum-zk-core", "pallet-account-mapping-rpc", "pallet-account-mapping-runtime-api", + "pallet-relayer", + "pallet-relayer-rpc", + "pallet-relayer-runtime-api", "pallet-shielded-pool-rpc", "pallet-shielded-pool-runtime-api", "pallet-transaction-payment", @@ -7237,6 +7241,7 @@ dependencies = [ "sp-core", "sp-inherents", "sp-io", + "sp-keystore", "sp-offchain", "sp-runtime", "sp-session", @@ -7245,6 +7250,7 @@ dependencies = [ "substrate-build-script-utils", "substrate-frame-rpc-system", "substrate-prometheus-endpoint", + "tokio", ] [[package]] @@ -7266,6 +7272,7 @@ dependencies = [ "pallet-account-mapping", "pallet-account-mapping-runtime-api", "pallet-aura", + "pallet-authorship", "pallet-balances", "pallet-base-fee", "pallet-dynamic-fee", @@ -7280,6 +7287,8 @@ dependencies = [ "pallet-evm-precompile-sha3fips-benchmarking", "pallet-evm-precompile-simple", "pallet-grandpa", + "pallet-relayer", + "pallet-relayer-runtime-api", "pallet-shielded-pool", "pallet-shielded-pool-runtime-api", "pallet-sudo", @@ -7359,7 +7368,7 @@ dependencies = [ "ark-snark", "ark-std 0.5.0", "num-bigint", - "orbinum-zk-core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "orbinum-zk-core", "parity-scale-codec", "scale-info", "sha2 0.10.9", @@ -7405,7 +7414,7 @@ dependencies = [ "frame-benchmarking", "frame-support", "frame-system", - "orbinum-zk-core 0.5.0", + "orbinum-zk-core", "pallet-balances", "parity-scale-codec", "scale-info", @@ -8011,6 +8020,47 @@ dependencies = [ "sp-mmr-primitives", ] +[[package]] +name = "pallet-relayer" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "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.1.0" +dependencies = [ + "parity-scale-codec", + "scale-info", + "sp-api", + "sp-core", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-session" version = "41.0.0" @@ -8046,6 +8096,7 @@ dependencies = [ "orbinum-zk-core 0.5.0", "orbinum-zk-verifier", "pallet-balances", + "pallet-relayer", "pallet-zk-verifier", "parity-scale-codec", "scale-info", @@ -8145,7 +8196,7 @@ dependencies = [ "scale-info", "sp-inherents", "sp-runtime", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "sp-timestamp", ] @@ -8859,7 +8910,7 @@ dependencies = [ "frame-benchmarking", "parity-scale-codec", "polkadot-primitives", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", ] [[package]] @@ -8940,7 +8991,7 @@ dependencies = [ "sp-offchain", "sp-runtime", "sp-session", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "sp-transaction-pool", "sp-version", ] @@ -10176,9 +10227,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring 0.17.14", "rustls-pki-types", @@ -10252,7 +10303,7 @@ source = "git+https://github.com/paritytech/polkadot-sdk?branch=stable2506#f61fa dependencies = [ "log", "sp-core", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-wasm-interface", "thiserror 1.0.69", ] @@ -10346,7 +10397,7 @@ dependencies = [ "sp-io", "sp-runtime", "sp-state-machine", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", ] [[package]] @@ -10420,10 +10471,10 @@ dependencies = [ "sp-consensus", "sp-core", "sp-database", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-runtime", "sp-state-machine", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "sp-trie", "substrate-prometheus-endpoint", ] @@ -10672,13 +10723,13 @@ dependencies = [ "schnellru", "sp-api", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-io", "sp-panic-handler", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-runtime-interface", "sp-trie", "sp-version", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-wasm-interface", "tracing", ] @@ -10690,7 +10741,7 @@ dependencies = [ "polkavm 0.24.0", "sc-allocator", "sp-maybe-compressed-blob", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-wasm-interface", "thiserror 1.0.69", "wasm-instrument", ] @@ -10703,7 +10754,7 @@ dependencies = [ "log", "polkavm 0.24.0", "sc-executor-common", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-wasm-interface", ] [[package]] @@ -10717,8 +10768,8 @@ dependencies = [ "rustix 0.36.17", "sc-allocator", "sc-executor-common", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-runtime-interface", + "sp-wasm-interface", "wasmtime", ] @@ -10981,7 +11032,7 @@ dependencies = [ "sc-utils", "sp-api", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-keystore", "sp-offchain", "sp-runtime", @@ -11118,7 +11169,7 @@ dependencies = [ "sp-core", "sp-crypto-hashing 0.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", "sp-state-machine", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-wasm-interface", "thiserror 1.0.69", ] @@ -11167,12 +11218,12 @@ dependencies = [ "sp-blockchain", "sp-consensus", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-keystore", "sp-runtime", "sp-session", "sp-state-machine", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "sp-transaction-pool", "sp-transaction-storage-proof", "sp-trie", @@ -11258,7 +11309,7 @@ dependencies = [ "sp-core", "sp-rpc", "sp-runtime", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", "sp-trie", "thiserror 1.0.69", "tracing", @@ -11299,7 +11350,7 @@ dependencies = [ "sp-core", "sp-crypto-hashing 0.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", "sp-runtime", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", "sp-transaction-pool", "substrate-prometheus-endpoint", "thiserror 1.0.69", @@ -12147,10 +12198,10 @@ dependencies = [ "scale-info", "sp-api-proc-macro", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-metadata-ir", "sp-runtime", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-runtime-interface", "sp-state-machine", "sp-trie", "sp-version", @@ -12349,11 +12400,11 @@ dependencies = [ "serde", "sha2 0.10.9", "sp-crypto-hashing 0.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-debug-derive 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-debug-derive", + "sp-externalities", + "sp-runtime-interface", "sp-std", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", "ss58-registry", "substrate-bip39", "thiserror 1.0.69", @@ -12408,17 +12459,6 @@ dependencies = [ "parking_lot 0.12.5", ] -[[package]] -name = "sp-debug-derive" -version = "14.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d09fa0a5f7299fb81ee25ae3853d26200f7a348148aed6de76be905c007dbe" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.116", -] - [[package]] name = "sp-debug-derive" version = "14.0.0" @@ -12429,17 +12469,6 @@ dependencies = [ "syn 2.0.116", ] -[[package]] -name = "sp-externalities" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cbf059dce180a8bf8b6c8b08b6290fa3d1c7f069a60f1df038ab5dd5fc0ba6" -dependencies = [ - "environmental", - "parity-scale-codec", - "sp-storage 22.0.0 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "sp-externalities" version = "0.30.0" @@ -12447,7 +12476,7 @@ source = "git+https://github.com/paritytech/polkadot-sdk?branch=stable2506#f61fa dependencies = [ "environmental", "parity-scale-codec", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", ] [[package]] @@ -12491,11 +12520,11 @@ dependencies = [ "secp256k1 0.28.2", "sp-core", "sp-crypto-hashing 0.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-keystore", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-runtime-interface", "sp-state-machine", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", "sp-trie", "tracing", "tracing-core", @@ -12519,7 +12548,7 @@ dependencies = [ "parity-scale-codec", "parking_lot 0.12.5", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", ] [[package]] @@ -12564,7 +12593,7 @@ dependencies = [ "serde", "sp-api", "sp-core", - "sp-debug-derive 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-debug-derive", "sp-runtime", "thiserror 1.0.69", ] @@ -12640,26 +12669,6 @@ dependencies = [ "tuplex", ] -[[package]] -name = "sp-runtime-interface" -version = "30.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fcd9c219da8c85d45d5ae1ce80e73863a872ac27424880322903c6ac893c06e" -dependencies = [ - "bytes", - "impl-trait-for-tuples", - "parity-scale-codec", - "polkavm-derive 0.24.0", - "primitive-types", - "sp-externalities 0.30.0 (registry+https://github.com/rust-lang/crates.io-index)", - "sp-runtime-interface-proc-macro 19.0.0 (registry+https://github.com/rust-lang/crates.io-index)", - "sp-std", - "sp-storage 22.0.0 (registry+https://github.com/rust-lang/crates.io-index)", - "sp-tracing 17.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "sp-wasm-interface 22.0.0 (registry+https://github.com/rust-lang/crates.io-index)", - "static_assertions", -] - [[package]] name = "sp-runtime-interface" version = "30.0.0" @@ -12670,29 +12679,15 @@ dependencies = [ "parity-scale-codec", "polkavm-derive 0.24.0", "primitive-types", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-runtime-interface-proc-macro 19.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", + "sp-runtime-interface-proc-macro", "sp-std", - "sp-storage 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-wasm-interface 22.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-storage", + "sp-tracing", + "sp-wasm-interface", "static_assertions", ] -[[package]] -name = "sp-runtime-interface-proc-macro" -version = "19.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca35431af10a450787ebfdcb6d7a91c23fa91eafe73a3f9d37db05c9ab36154b" -dependencies = [ - "Inflector", - "expander", - "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", - "syn 2.0.116", -] - [[package]] name = "sp-runtime-interface-proc-macro" version = "19.0.0" @@ -12745,7 +12740,7 @@ dependencies = [ "rand 0.8.5", "smallvec", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-panic-handler", "sp-trie", "thiserror 1.0.69", @@ -12770,9 +12765,9 @@ dependencies = [ "sp-application-crypto", "sp-core", "sp-crypto-hashing 0.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "sp-runtime", - "sp-runtime-interface 30.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-runtime-interface", "thiserror 1.0.69", "x25519-dalek", ] @@ -12782,19 +12777,6 @@ name = "sp-std" version = "14.0.0" source = "git+https://github.com/paritytech/polkadot-sdk?branch=stable2506#f61fa5b989147a944b5421206729ed39d88ddf69" -[[package]] -name = "sp-storage" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3b70ca340e41cde9d2e069d354508a6e37a6573d66f7cc38f11549002f64ec" -dependencies = [ - "impl-serde", - "parity-scale-codec", - "ref-cast", - "serde", - "sp-debug-derive 14.0.0 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "sp-storage" version = "22.0.0" @@ -12804,7 +12786,7 @@ dependencies = [ "parity-scale-codec", "ref-cast", "serde", - "sp-debug-derive 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-debug-derive", ] [[package]] @@ -12819,18 +12801,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "sp-tracing" -version = "17.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6147a5b8c98b9ed4bf99dc033fab97a468b4645515460974c8784daeb7c35433" -dependencies = [ - "parity-scale-codec", - "tracing", - "tracing-core", - "tracing-subscriber 0.3.22", -] - [[package]] name = "sp-tracing" version = "17.1.0" @@ -12882,7 +12852,7 @@ dependencies = [ "scale-info", "schnellru", "sp-core", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-externalities", "substrate-prometheus-endpoint", "thiserror 1.0.69", "tracing", @@ -12919,18 +12889,6 @@ dependencies = [ "syn 2.0.116", ] -[[package]] -name = "sp-wasm-interface" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdbc579c72fc03263894a0077383f543a093020d75741092511bb05a440ada6" -dependencies = [ - "anyhow", - "impl-trait-for-tuples", - "log", - "parity-scale-codec", -] - [[package]] name = "sp-wasm-interface" version = "22.0.0" @@ -12954,7 +12912,7 @@ dependencies = [ "serde", "smallvec", "sp-arithmetic", - "sp-debug-derive 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-debug-derive", ] [[package]] @@ -13390,8 +13348,8 @@ dependencies = [ "sp-consensus-grandpa", "sp-core", "sp-crypto-hashing 0.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-debug-derive 14.0.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", - "sp-externalities 0.30.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-debug-derive", + "sp-externalities", "sp-genesis-builder", "sp-inherents", "sp-io", @@ -13447,7 +13405,7 @@ dependencies = [ "sp-core", "sp-io", "sp-maybe-compressed-blob", - "sp-tracing 17.1.0 (git+https://github.com/paritytech/polkadot-sdk?branch=stable2506)", + "sp-tracing", "sp-version", "strum 0.26.3", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 28b28e35..a8003a64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ members = [ "frame/account-mapping", "frame/account-mapping/runtime-api", "frame/account-mapping/rpc", + "frame/relayer/runtime-api", + "frame/relayer/rpc", "frame/zk-verifier/runtime-api", "frame/zk-verifier/rpc", "frame/evm/precompile/account-mapping", @@ -162,6 +164,7 @@ frame-system = { git = "https://github.com/paritytech/polkadot-sdk", branch = "s frame-system-benchmarking = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506", default-features = false } frame-system-rpc-runtime-api = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506", default-features = false } pallet-aura = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506", default-features = false } +pallet-authorship = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506", default-features = false } pallet-balances = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506", default-features = false } pallet-grandpa = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506", default-features = false } pallet-sudo = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506", default-features = false } @@ -248,6 +251,9 @@ pallet-evm-precompile-sha3fips-benchmarking = { path = "frame/evm/precompile/sha 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-rpc = { path = "frame/shielded-pool/rpc" } pallet-shielded-pool-runtime-api = { path = "frame/shielded-pool/runtime-api", default-features = false } diff --git a/client/rpc/Cargo.toml b/client/rpc/Cargo.toml index 2ccf7360..222dde7f 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, optional = true } +serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["sync"] } @@ -61,6 +61,7 @@ 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-shielded-pool-runtime-api = { workspace = true, features = ["std"] } [dev-dependencies] tempfile = "3.21.0" @@ -84,5 +85,5 @@ rocksdb = [ "fc-db/rocksdb", "fc-mapping-sync/rocksdb", ] -txpool = ["fc-rpc-core/txpool", "serde"] +txpool = ["fc-rpc-core/txpool"] rpc-binary-search-estimate = [] diff --git a/client/rpc/src/lib.rs b/client/rpc/src/lib.rs index 06255596..8f980669 100644 --- a/client/rpc/src/lib.rs +++ b/client/rpc/src/lib.rs @@ -31,6 +31,7 @@ mod debug; mod eth; mod eth_pubsub; mod net; +pub mod relay; mod signer; #[cfg(feature = "txpool")] mod txpool; @@ -44,7 +45,8 @@ pub use self::{ eth::{format, pending, EstimateGasAdapter, Eth, EthConfig, EthFilter}, eth_pubsub::{EthPubSub, EthereumSubIdProvider}, net::Net, - signer::{EthDevSigner, EthSigner}, + relay::{OrbinumRelay, OrbinumRelayApiServer, RelayerStatus}, + signer::{EthDevSigner, EthSigner, EthValidatorSigner}, web3::Web3, }; pub use ethereum::TransactionV3 as EthereumTransaction; diff --git a/client/rpc/src/relay.rs b/client/rpc/src/relay.rs new file mode 100644 index 00000000..ca959c30 --- /dev/null +++ b/client/rpc/src/relay.rs @@ -0,0 +1,798 @@ +// 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 }` +//! +//! The relay only accepts calls to the ShieldedPool precompile +//! (`0x0000000000000000000000000000000000000801`) with selector +//! `0x47fc44a2` (unshield) or `0x8c0f5d24` (privateTransfer). +//! It checks the fee embedded in ABI slot 6 is ≥ the current `min_relay_fee` from +//! `pallet-relayer` (queried dynamically via Runtime API so forkless upgrades take effect immediately). + +use std::sync::Arc; + +use ethereum::TransactionAction; +use ethereum_types::{H160, H256, U256}; +use jsonrpsee::core::RpcResult; +use jsonrpsee::proc_macros::rpc; +use serde::{Deserialize, Serialize}; +// Substrate +use sc_client_api::backend::{Backend, StorageProvider}; +use sc_transaction_pool_api::{TransactionPool, TransactionSource}; +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_evm::ExitReason; +use fp_rpc::{ConvertTransactionRuntimeApi, EthereumRuntimeRPCApi}; +// Orbinum +use pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi; + +use crate::{internal_err, signer::EthValidatorSigner}; + +/// Maximum fee per gas paid by the relay tx (10 gwei). +/// Used when building the EIP-1559 transaction. +const MAX_FEE_PER_GAS_WEI: u64 = 10_000_000_000; + +/// Gas limit used for relay transactions. +const RELAY_GAS_LIMIT: u64 = 2_000_000; + +/// Last-resort fallback minimum fee used ONLY when the Runtime API call fails entirely. +/// +/// The authoritative value lives in `pallet-relayer::MinRelayFee` storage and is +/// modifiable by governance via `set_min_relay_fee`. This constant is never used in +/// normal operation — it only kicks in if the node runs a pre-API runtime that does +/// not expose `relay_config()`. +/// +/// Set to the same default as `pallet-relayer::DefaultMinRelayFee` (0.001 ORB) so all +/// three sources are identical out of the box. +const MIN_RELAY_FEE_FALLBACK: u128 = 1_000_000_000_000_000; // 0.001 ORB in planck + +/// Static fallback selector whitelist for when the Runtime API is unavailable. +const SELECTORS_FALLBACK: [[u8; 4]; 2] = [ + [0x47, 0xfc, 0x44, 0xa2], // unshield + [0x8c, 0x0f, 0x5d, 0x24], // privateTransfer +]; + +/// Maximum calldata size accepted by the relay (32 KB). +/// +/// A realistic shielded-pool calldata is ~2–5 KB (256 B Groth16 proof + ABI head + Merkle path). +/// This cap prevents DoS: an attacker could craft calldata that passes selector/fee checks +/// but carries megabytes of data the relayer would have to pay calldata gas for. +const MAX_CALLDATA_BYTES: usize = 32_768; + +/// ShieldedPool precompile: 0x0000000000000000000000000000000000000801 +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, +]; + +// Keep the static selector names as test helpers (used in build_calldata). +#[cfg(test)] +const SELECTOR_UNSHIELD: [u8; 4] = [0x47, 0xfc, 0x44, 0xa2]; +#[cfg(test)] +const SELECTOR_PRIVATE_TRANSFER: [u8; 4] = [0x8c, 0x0f, 0x5d, 0x24]; + +// --------------------------------------------------------------------------- +// Calldata validation (pure, no runtime deps — unit-testable) +// --------------------------------------------------------------------------- + +/// 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` share the same ABI head layout: +/// ```text +/// bytes [0..4] selector +/// bytes [4..36] slot 0 — offset pointer for proof (bytes/dynamic) +/// bytes [36..68] slot 1 — bytes32 root +/// 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 +/// ``` +/// Minimum head size = 4 + 7 × 32 = 228 bytes. +/// 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 plank (= 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) +} + +pub(crate) fn validate_relay_calldata( + data: &[u8], + min_fee_wei: u128, + allowed_selectors: &[[u8; 4]], +) -> Result<(), &'static str> { + 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"); + } + + // Fee is in ABI slot 6 (7th param, 0-indexed): data[196..228]. + let fee_bytes: [u8; 32] = data[196..228].try_into().unwrap(); + let fee = U256::from_big_endian(&fee_bytes); + if fee < U256::from(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:?}")), + } +} + +// --------------------------------------------------------------------------- +// RPC trait +// --------------------------------------------------------------------------- + +#[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. The fee in ABI slot index 6 must be ≥ MIN_RELAY_FEE_WEI. + /// + /// 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; +} + +#[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 only when balance ≥ MIN_RELAY_FEE_WEI (enough to cover at least one relay tx). + pub enabled: bool, +} + +// --------------------------------------------------------------------------- +// Server struct +// --------------------------------------------------------------------------- + +pub struct OrbinumRelay { + client: Arc, + pool: Arc

, + signer: EthValidatorSigner, + /// Serializes relay submissions AND tracks the optimistic next-nonce. + /// + /// Why `Option` instead of `()`: + /// + /// Each submission is: (1) read confirmed nonce, (2) sign, (3) submit. With a plain + /// Mutex<()> two requests in the same block both read `confirmed = N`, sign with N, + /// and the second submission fails with "nonce already used", burning gas for nothing. + /// + /// By storing the last submitted nonce we can compute: + /// actual_nonce = max(confirmed_nonce, memory_nonce) + /// which allows multiple submissions within the same block to use N, N+1, N+2… + /// On the next block `confirmed_nonce` catches up and `memory_nonce` resets naturally. + /// + /// `None` = no submission has been made yet; read from runtime. + submit_lock: Arc>>, + _phantom: std::marker::PhantomData<(B, BE)>, +} + +impl OrbinumRelay +where + B: BlockT, +{ + pub fn new(client: Arc, pool: Arc

, signer: EthValidatorSigner) -> Self { + Self { + client, + pool, + signer, + submit_lock: Arc::new(tokio::sync::Mutex::new(None)), + _phantom: Default::default(), + } + } +} + +#[jsonrpsee::core::async_trait] +impl OrbinumRelayApiServer for OrbinumRelay +where + B: BlockT, + C: ProvideRuntimeApi + HeaderBackend + StorageProvider + 'static, + C::Api: EthereumRuntimeRPCApi + ConvertTransactionRuntimeApi + ShieldedPoolRuntimeApi, + BE: Backend + 'static, + P: TransactionPool + 'static, +{ + async fn relay_shielded_call(&self, calldata: Bytes) -> RpcResult { + let data = calldata.into_vec(); + + // Load fee/selector config from the runtime on every call so governance changes + // (set_min_relay_fee extrinsic) take immediate effect without a node restart. + // MIN_RELAY_FEE_FALLBACK is only used when the Runtime API call fails entirely. + let best_hash = self.client.info().best_hash; + let (min_fee_planck, allowed_selectors) = { + match self.client.runtime_api().relay_config(best_hash) { + 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()) + } + } + }; + + // Compute 2× gas floor: the relay must earn at least twice what it spends on EVM gas. + // 1 wei == 1 plank in Orbinum, so no unit conversion is required. + let base_fee_wei: u128 = self + .client + .runtime_api() + .gas_price(best_hash) + .map(|p| p.as_u128()) + .unwrap_or(0); + let effective_min_fee = compute_effective_min_fee(min_fee_planck, base_fee_wei); + + if let Err(e) = validate_relay_calldata(&data, effective_min_fee, &allowed_selectors) { + if e == "fee below minimum" { + // Extract the fee from slot 6 for the log (validated length already). + let provided_fee = if data.len() >= 228 { + U256::from_big_endian(&data[196..228]) + } else { + U256::zero() + }; + log::warn!( + target: "orbinum-relay", + "relay rejected: fee below minimum — provided={provided_fee} required={effective_min_fee} (base_fee={base_fee_wei} governance={min_fee_planck})", + ); + } + return Err(internal_err(e)); + } + + // Dry-run: simulate the EVM call without broadcasting a transaction. + // This catches invalid ZK proofs, already-spent nullifiers, and any other + // on-chain rejection BEFORE the relayer signs and pays gas. + { + let relayer_addr = self.signer.address(); + let dry_result = self.client.runtime_api().call( + best_hash, + relayer_addr, + H160::from(SHIELDED_POOL_PRECOMPILE), + data.clone(), + 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}" + ); + return Err(internal_err(format!("dry-run runtime error: {e}"))); + } + Ok(Err(dispatch_err)) => { + log::warn!( + target: "orbinum-relay", + "dry-run dispatch error: {dispatch_err:?}" + ); + return Err(internal_err(format!( + "calldata rejected by runtime: {dispatch_err:?}" + ))); + } + Ok(Ok(info)) => { + if let Err(e) = check_dry_run_exit(&info.exit_reason) { + log::warn!( + target: "orbinum-relay", + "dry-run EVM execution failed — exit={:?} revert_data={:?}", + info.exit_reason, + info.value + ); + return Err(internal_err(e)); + } + } + } + } + + // Hold the lock for the full sign-and-submit sequence. + // The lock also carries the optimistic next-nonce so multiple submissions + // within the same block don't collide on the same confirmed nonce. + let mut nonce_guard = self.submit_lock.lock().await; + + 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_nonce = api + .account_basic(best_hash, relayer_addr) + .map_err(|e| internal_err(format!("account_basic: {e}")))? + .nonce; + // Use the in-memory nonce when it's ahead of the confirmed one. + // This lets us submit N txs within a single block using nonces N, N+1, N+2… + // Once the block is imported the confirmed nonce catches up naturally. + let nonce = match *nonce_guard { + Some(mem) if mem > confirmed_nonce => mem, + _ => confirmed_nonce, + }; + (chain_id, nonce) + }; + + 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: data, + 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}"))); + + // Advance the in-memory nonce only after a successful submit. + // On failure the nonce slot is still free and the next call will retry with + // the same (or a freshly confirmed) nonce. + if submit_result.is_ok() { + *nonce_guard = Some(nonce + U256::one()); + } + + submit_result + } + + async fn relayer_status(&self) -> RpcResult { + let best_hash = self.client.info().best_hash; + let api = self.client.runtime_api(); + + let min_fee_planck = api + .relay_config(best_hash) + .map(|cfg| cfg.min_fee_planck) + .unwrap_or(MIN_RELAY_FEE_FALLBACK); + + let base_fee_wei: u128 = api.gas_price(best_hash).map(|p| p.as_u128()).unwrap_or(0); + let min_fee = compute_effective_min_fee(min_fee_planck, base_fee_wei); + + let balance = { + api.account_basic(best_hash, self.signer.address()) + .map_err(|e| internal_err(format!("account_basic: {e}")))? + .balance + }; + // Consider relay operational when it can cover at least one worst-case tx. + let min_operational = U256::from(min_fee); + Ok(RelayerStatus { + address: self.signer.address(), + min_fee: format!("{min_fee}"), + balance_wei: format!("{balance}"), + enabled: balance >= min_operational, + }) + } +} + +// --------------------------------------------------------------------------- +// Unit tests — pure validation logic, no runtime required +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Build minimal valid calldata for the given selector and fee. + /// + /// Head layout (228 bytes total): + /// ```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 { + let mut data = vec![0u8; 228]; + 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_196_bytes_old_wrong_limit() { + // Ensure the old (incorrect) limit of 196 is no longer accepted + let data = vec![0u8; 196]; + assert_eq!( + validate_relay_calldata(&data, MIN_RELAY_FEE_FALLBACK, &SELECTORS_FALLBACK), + Err("calldata too short") + ); + } + + // ── Selector checks ──────────────────────────────────────────────────── + + #[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}"); + } +} diff --git a/client/rpc/src/signer.rs b/client/rpc/src/signer.rs index fcecc931..f0f4e788 100644 --- a/client/rpc/src/signer.rs +++ b/client/rpc/src/signer.rs @@ -66,6 +66,10 @@ fn public_key_address(public: &libsecp256k1::PublicKey) -> H160 { H160::from(H256::from(keccak_256(&res))) } +// --------------------------------------------------------------------------- +// Dev signer — hardcoded key `0x1111…` +// --------------------------------------------------------------------------- + impl EthSigner for EthDevSigner { fn accounts(&self) -> Vec { self.keys.iter().map(secret_key_address).collect() @@ -193,3 +197,229 @@ 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/deny.toml b/deny.toml index 7bd66c27..b02ce687 100644 --- a/deny.toml +++ b/deny.toml @@ -34,6 +34,7 @@ ignore = [ # Unmaintained (transitive) "RUSTSEC-2025-0161", # libsecp256k1 0.7.2 unmaintained (from fc-rpc, fp-account/pallet-evm) + "RUSTSEC-2026-0105", # core2 0.4.0 unmaintained, all versions yanked (from litep2p → cid, no upgrade path) # Allowed warnings (8 total) "RUSTSEC-2024-0388", # derivative 2.2.0 unmaintained (from ark-r1cs-std) diff --git a/frame/relayer/Cargo.toml b/frame/relayer/Cargo.toml new file mode 100644 index 00000000..0dee567c --- /dev/null +++ b/frame/relayer/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "pallet-relayer" +version = "0.1.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/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 } +sp-core = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +[dev-dependencies] +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", + "sp-core/std", + "sp-runtime/std", + "sp-std/std", +] +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", +] diff --git a/frame/relayer/README.md b/frame/relayer/README.md new file mode 100644 index 00000000..01853c70 --- /dev/null +++ b/frame/relayer/README.md @@ -0,0 +1,227 @@ +# 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 to the current block author** through this pallet. + +--- + +## 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)` — origin: `Signed` +Binds the caller's EVM address to their substrate AccountId. +Fails if the EVM address is already claimed by another account (`AlreadyRegistered`). +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 }`. + +### `claim_relay_fees(asset_id: u32, amount: u128)` — origin: `Signed` +Decrements `amount` from the caller's pending fee balance (accounting only — no token transfer). +Emits: `RelayFeesConsumed { relayer, asset_id, amount }`. + +> **Note:** This extrinsic only updates the `PendingRelayerFees` counter. To receive real tokens, the validator must call one of the two claim paths in `pallet-shielded-pool`: +> - `claim_shielded_fees` — receives the fee as a private ZK note (requires a disclosure proof) +> - `claim_relay_fees_to_evm` — transfers public ORB directly to the H160 mirror AccountId (no proof required; ideal for refilling the relayer's EVM gas wallet) + +--- + +## Relay fee lifecycle + +Each node has **two separate identities**: + +| Identity | Type | Key | Purpose | +|---|---|---|---| +| `AccountId` (sr25519/Aura) | Substrate | Aura key | Receives and accumulates fees in `PendingRelayerFees` | +| `H160` (ECDSA) | EVM | `--evm-relayer-key` | Signs EVM transactions, pays gas | + +Both are linked at startup via `register_relayer(evm_address)`, which writes both indexes (`RelayerRegistry` and `RelayerByAccount`). + +``` +1. shield(1000) + └─ PoolBalance[asset=0] += 1000 + (tokens physically enter pool_account_id) + +2. unshield(amount=900, fee=100, relayer=H160) + ├─ ZK proof verified + ├─ T::Currency::transfer(pool → recipient, 900) + ├─ PoolBalance[asset=0] -= 900 (amount only; fee remains in the pool) + └─ T::Relayer::accumulate_relay_fee(resolve_relayer(H160), asset=0, 100) + └─ PendingRelayerFees[AccountId][0] += 100 + +3a. claim_shielded_fees(commitment, amount=100, ...) [pallet-shielded-pool, call_index 16] + ├─ Verifies ZK disclosure proof + ├─ consume_relay_fee(validator, 0, 100) + │ └─ PendingRelayerFees[validator][0] -= 100 + └─ MerkleTree ← commitment(100) + (validator receives a private ZK note — full privacy) + +3b. claim_relay_fees_to_evm(asset_id=0, amount=100) [pallet-shielded-pool, call_index 17] + ├─ registered_evm_address(validator) → H160 + ├─ mirror = H160[0..20] ++ [0x00; 12] (EeSuffixAddressMapping) + ├─ consume_relay_fee(validator, 0, 100) + │ └─ PendingRelayerFees[validator][0] -= 100 + ├─ T::Currency::transfer(pool → mirror, 100) + └─ PoolBalance[asset=0] -= 100 + (H160 EVM balance updated immediately → can pay gas) +``` + +**Key difference between 3a and 3b:** + +| | `claim_shielded_fees` | `claim_relay_fees_to_evm` | +|---|---|---| +| Result | Private note in Merkle tree | Public ORB on H160 EVM | +| Privacy | Full (ZK UTXO) | Public (visible transfer) | +| ZK proof required | Yes (disclosure circuit) | No | +| Typical use | Accumulate private funds | Refill relayer gas wallet | + +--- + +## `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; +} +``` + +`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_evm` to derive the mirror AccountId of the H160 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`. + +--- + +## 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; + type MaxAllowedSelectors = ConstU32<16>; + type WeightInfo = pallet_relayer::weights::SubstrateWeight; +} + +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 | +| `claim_relay_fees` | ~8 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/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, signed-only, reverse lookup) +└── 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 new file mode 100644 index 00000000..66ea7856 --- /dev/null +++ b/frame/relayer/rpc/Cargo.toml @@ -0,0 +1,16 @@ +[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 = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506" } +sp-blockchain = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506" } +sp-core = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506" } +sp-runtime = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2506" } diff --git a/frame/relayer/rpc/README.md b/frame/relayer/rpc/README.md new file mode 100644 index 00000000..d3bd2330 --- /dev/null +++ b/frame/relayer/rpc/README.md @@ -0,0 +1,85 @@ +# 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 new file mode 100644 index 00000000..48daa6a9 --- /dev/null +++ b/frame/relayer/rpc/src/lib.rs @@ -0,0 +1,164 @@ +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 new file mode 100644 index 00000000..a88c034e --- /dev/null +++ b/frame/relayer/runtime-api/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "pallet-relayer-runtime-api" +version = "0.1.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 new file mode 100644 index 00000000..e5ed265f --- /dev/null +++ b/frame/relayer/runtime-api/README.md @@ -0,0 +1,48 @@ +# 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 new file mode 100644 index 00000000..69447dcc --- /dev/null +++ b/frame/relayer/runtime-api/src/lib.rs @@ -0,0 +1,131 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +sp_api::decl_runtime_apis! { + /// Runtime API for querying relayer status and pending fees. + 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]>; + } +} + +#[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 new file mode 100644 index 00000000..5445c90a --- /dev/null +++ b/frame/relayer/src/benchmarking.rs @@ -0,0 +1,124 @@ +//! 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; +use sp_core::H160; + +#[benchmarks] +mod benchmarks { + use super::*; + + // ── Helpers ────────────────────────────────────────────────────────────── + + fn evm_address_for(seed: u64) -> H160 { + H160::from_low_u64_be(seed) + } + + // ── 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 ───────────────────────────────────────────────────── + + /// Benchmark the storage write path for relayer registration. + /// + /// `register_relayer` is gated by `T::IsValidator`, which is a runtime + /// trait that cannot be generically seeded in benchmarks. We measure the + /// two storage inserts + event deposit directly — the validator check is + /// a cheap in-memory read and does not contribute meaningful weight. + #[benchmark] + fn register_relayer() { + let caller: T::AccountId = whitelisted_caller(); + let evm = evm_address_for(0xA11CE); + + #[block] + { + RelayerRegistry::::insert(evm, caller.clone()); + RelayerByAccount::::insert(caller.clone(), evm); + Pallet::::deposit_event(Event::RelayerRegistered { + evm_address: evm, + account: caller.clone(), + }); + } + + assert_eq!(RelayerRegistry::::get(evm), Some(caller)); + } + + // ── 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 = evm_address_for(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)); + } + + // ── claim_relay_fees ───────────────────────────────────────────────────── + + /// Worst case: one double-map read + mutate + event. + /// Pre-condition: caller has sufficient pending fees. + #[benchmark] + fn claim_relay_fees() { + let caller: T::AccountId = whitelisted_caller(); + let asset_id = 0u32; + let balance = 1_000_000_000_000_000u128; + + // Seed pending fees directly into storage. + PendingRelayerFees::::insert(&caller, asset_id, balance); + + #[extrinsic_call] + claim_relay_fees(RawOrigin::Signed(caller.clone()), asset_id, balance / 2); + + assert_eq!(PendingRelayerFees::::get(&caller, asset_id), balance / 2); + } + + // ── Benchmark test suite ───────────────────────────────────────────────── + + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test,); +} diff --git a/frame/relayer/src/lib.rs b/frame/relayer/src/lib.rs new file mode 100644 index 00000000..21239c41 --- /dev/null +++ b/frame/relayer/src/lib.rs @@ -0,0 +1,359 @@ +#![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. +//! Only validator nodes (as determined by `T::IsValidator`) may register. +//! - **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 | +//! |------|----------------| +//! | `traits.rs` | `RelayerInterface` — public port consumed by other pallets | +//! | `weights.rs` | `WeightInfo` trait + `SubstrateWeight` + unit (`()`) impl | +//! | `benchmarking.rs` | FRAME benchmarks (`runtime-benchmarks` feature) | +//! | `lib.rs` | FRAME pallet: Config, Storage, Events, Errors, Extrinsics | +//! | `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 traits; +pub mod weights; + +pub use pallet::*; +pub use traits::RelayerInterface; +pub use weights::WeightInfo; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +// ───────────────────────────────────────────────────────────────────────────── +// FRAME pallet +// ───────────────────────────────────────────────────────────────────────────── + +#[frame_support::pallet] +pub mod pallet { + use super::{RelayerInterface, WeightInfo}; + use frame_support::{dispatch::DispatchResult, pallet_prelude::*, traits::Contains}; + use frame_system::pallet_prelude::*; + use sp_core::H160; + 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; + + /// Returns whether an account is currently a validator node. + /// + /// Only accounts recognised as validators may call `register_relayer`. + /// Wire this to a session-validator or Aura-authority check in the runtime. + type IsValidator: frame_support::traits::Contains; + + /// 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; + + type WeightInfo: WeightInfo; + } + + // ── Default values ──────────────────────────────────────────────────────── + + #[pallet::type_value] + pub fn DefaultMinRelayFeeValue() -> u128 { + T::DefaultMinRelayFee::get() + } + + // ── 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. + /// Only validator nodes may have entries here. + #[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, + >; + + // ── 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 node registered an EVM address. + RelayerRegistered { + evm_address: H160, + account: T::AccountId, + }, + /// A validator node unregistered its EVM address. + RelayerUnregistered { + evm_address: H160, + account: T::AccountId, + }, + /// Relay fee accrued for a block author during extrinsic processing. + RelayFeeAccumulated { + relayer: T::AccountId, + asset_id: u32, + amount: u128, + }, + /// Relay fees were marked as consumed (by `claim_shielded_fees`). + RelayFeesConsumed { + relayer: T::AccountId, + asset_id: u32, + amount: u128, + }, + } + + // ── Errors ──────────────────────────────────────────────────────────────── + + #[pallet::error] + pub enum Error { + /// Caller is not a validator node. Only validators may register as relayers. + NotValidator, + /// 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, + } + + // ── Pallet core ─────────────────────────────────────────────────────────── + + #[pallet::pallet] + pub struct Pallet(_); + + // ── Extrinsics ──────────────────────────────────────────────────────────── + + #[pallet::call] + impl Pallet { + /// Update the minimum relay fee. + /// + /// Requires `ManageOrigin`. The new value takes effect immediately. + #[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)?; + 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 a substrate account as the owner of an EVM relay address. + /// + /// Only validator nodes (as determined by `T::IsValidator`) may call this. + /// Non-validator accounts are rejected with `NotValidator`. + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::register_relayer())] + pub fn register_relayer(origin: OriginFor, evm_address: H160) -> DispatchResult { + let who = ensure_signed(origin)?; + ensure!(T::IsValidator::contains(&who), Error::::NotValidator); + 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 the caller's EVM address from the relay registry. + /// + /// The caller must be a registered validator node. + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::unregister_relayer())] + pub fn unregister_relayer(origin: OriginFor) -> DispatchResult { + let who = ensure_signed(origin)?; + let evm_address = RelayerByAccount::::get(&who).ok_or(Error::::NotRegistered)?; + RelayerRegistry::::remove(evm_address); + RelayerByAccount::::remove(&who); + Self::deposit_event(Event::RelayerUnregistered { + evm_address, + account: who, + }); + Ok(()) + } + + /// Standalone claim for accrued relay fees (planck accounting only). + /// + /// Decrements `PendingRelayerFees` without performing the actual token + /// transfer. Validators should prefer + /// `pallet-shielded-pool::claim_shielded_fees`, which inserts a private + /// note into the Merkle tree. + #[pallet::call_index(4)] + #[pallet::weight(T::WeightInfo::claim_relay_fees())] + pub fn claim_relay_fees( + origin: OriginFor, + asset_id: u32, + amount: u128, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + 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, + asset_id, + amount, + }); + Ok(()) + } + } + + // ── 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) + } + } +} diff --git a/frame/relayer/src/mock.rs b/frame/relayer/src/mock.rs new file mode 100644 index 00000000..3e120538 --- /dev/null +++ b/frame/relayer/src/mock.rs @@ -0,0 +1,72 @@ +//! Mock runtime for pallet-relayer tests. + +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; +} + +/// Static block author — always Alice (account 1). +pub struct MockBlockAuthor; +impl frame_support::traits::Get> for MockBlockAuthor { + fn get() -> Option { + Some(1u64) + } +} + +/// Accounts with ID >= 100 are treated as validator nodes in tests. +pub struct MockValidators; +impl frame_support::traits::Contains for MockValidators { + fn contains(who: &u64) -> bool { + *who >= 100 + } +} + +impl pallet_relayer::Config for Test { + type BlockAuthor = MockBlockAuthor; + type DefaultMinRelayFee = DefaultMinRelayFee; + type IsValidator = MockValidators; + type ManageOrigin = frame_system::EnsureRoot; + type MaxAllowedSelectors = MaxAllowedSelectors; + type WeightInfo = (); +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + 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 +} + +/// EVM address helpers for tests. +pub mod addr { + use sp_core::H160; + + pub fn alice_evm() -> H160 { + H160::from_low_u64_be(0xA11CE) + } + + pub fn bob_evm() -> H160 { + H160::from_low_u64_be(0xB0B) + } +} diff --git a/frame/relayer/src/tests/config_tests.rs b/frame/relayer/src/tests/config_tests.rs new file mode 100644 index 00000000..3245f9e6 --- /dev/null +++ b/frame/relayer/src/tests/config_tests.rs @@ -0,0 +1,133 @@ +//! Tests for relay configuration management. +//! +//! Covers: +//! - `set_min_relay_fee` — governance gating, storage update, event emission +//! - `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), + frame_support::error::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![]), + frame_support::error::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); + }); +} diff --git a/frame/relayer/src/tests/fees_tests.rs b/frame/relayer/src/tests/fees_tests.rs new file mode 100644 index 00000000..9d2cc8a8 --- /dev/null +++ b/frame/relayer/src/tests/fees_tests.rs @@ -0,0 +1,248 @@ +//! 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 +//! - `claim_relay_fees` — extrinsic path, signed guard, insufficient guard + +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, + ); + }); +} + +// ─── claim_relay_fees (extrinsic) ──────────────────────────────────────────── + +#[test] +fn claim_relay_fees_works() { + new_test_ext().execute_with(|| { + crate::Pallet::::accumulate_relay_fee(&1u64, 0, 800); + assert_ok!(Relayer::claim_relay_fees(RuntimeOrigin::signed(1), 0, 300)); + assert_eq!(PendingRelayerFees::::get(1u64, 0), 500u128); + }); +} + +#[test] +fn claim_relay_fees_emits_event() { + new_test_ext().execute_with(|| { + crate::Pallet::::accumulate_relay_fee(&1u64, 0, 500); + assert_ok!(Relayer::claim_relay_fees(RuntimeOrigin::signed(1), 0, 200)); + System::assert_last_event( + Event::RelayFeesConsumed { + relayer: 1u64, + asset_id: 0, + amount: 200, + } + .into(), + ); + }); +} + +#[test] +fn claim_relay_fees_drains_fully() { + new_test_ext().execute_with(|| { + crate::Pallet::::accumulate_relay_fee(&1u64, 0, 100); + assert_ok!(Relayer::claim_relay_fees(RuntimeOrigin::signed(1), 0, 100)); + assert_eq!(PendingRelayerFees::::get(1u64, 0), 0u128); + }); +} + +#[test] +fn claim_relay_fees_fails_if_insufficient() { + new_test_ext().execute_with(|| { + crate::Pallet::::accumulate_relay_fee(&1u64, 0, 50); + assert_noop!( + Relayer::claim_relay_fees(RuntimeOrigin::signed(1), 0, 51), + Error::::InsufficientPendingFees, + ); + }); +} + +#[test] +fn claim_relay_fees_requires_signed() { + new_test_ext().execute_with(|| { + assert_noop!( + Relayer::claim_relay_fees(RuntimeOrigin::none(), 0, 1), + frame_support::error::BadOrigin, + ); + }); +} + +// ─── Cross-account isolation ────────────────────────────────────────────────── + +/// Account 2 cannot drain fees that were accrued for account 1. +/// The storage map is keyed by the signed origin — there is no parameter that +/// lets a caller specify a different beneficiary. +#[test] +fn claim_relay_fees_cannot_drain_another_accounts_fees() { + new_test_ext().execute_with(|| { + // Only account 1 has fees + crate::Pallet::::accumulate_relay_fee(&1u64, 0, 1000); + + // Account 2 tries to claim — its own pending balance is 0 → InsufficientPendingFees + assert_noop!( + Relayer::claim_relay_fees(RuntimeOrigin::signed(2), 0, 1), + Error::::InsufficientPendingFees, + ); + + // Account 1's fees are untouched + assert_eq!(PendingRelayerFees::::get(1u64, 0), 1000u128); + }); +} + +/// `consume_relay_fee` (called internally by claim_shielded_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); + }); +} + +#[test] +fn claim_relay_fees_zero_amount_succeeds() { + new_test_ext().execute_with(|| { + // Claiming 0 is valid (no-op debit, event still emitted) + assert_ok!(Relayer::claim_relay_fees(RuntimeOrigin::signed(1), 0, 0)); + assert_eq!(PendingRelayerFees::::get(1u64, 0), 0u128); + }); +} diff --git a/frame/relayer/src/tests/mod.rs b/frame/relayer/src/tests/mod.rs new file mode 100644 index 00000000..830e9e20 --- /dev/null +++ b/frame/relayer/src/tests/mod.rs @@ -0,0 +1,13 @@ +//! Tests for pallet-relayer. +//! +//! Modules: +//! - `config_tests` — MinRelayFee and AllowedSelectors governance +//! - `registry_tests` — EVM address ↔ AccountId binding +//! - `fees_tests` — relay fee accumulation, querying and consumption + +#[cfg(test)] +pub mod config_tests; +#[cfg(test)] +pub mod fees_tests; +#[cfg(test)] +pub mod registry_tests; diff --git a/frame/relayer/src/tests/registry_tests.rs b/frame/relayer/src/tests/registry_tests.rs new file mode 100644 index 00000000..f12072a7 --- /dev/null +++ b/frame/relayer/src/tests/registry_tests.rs @@ -0,0 +1,246 @@ +//! Tests for the EVM address ↔ AccountId registry. +//! +//! ## Account conventions (see mock.rs `MockValidators`) +//! - Accounts 1–99: **non-validators** — rejected with `NotValidator`. +//! - Accounts 100+: **validator nodes** — may register freely. +//! +//! ## Covers +//! - `register_relayer` — only validators accepted +//! - `unregister_relayer` — cleans up registry +//! - `registered_evm_address` — reverse lookup via RelayerInterface + +use crate::{Error, Event, RelayerByAccount, RelayerRegistry, mock::*, traits::RelayerInterface}; +use frame_support::{assert_noop, assert_ok}; + +// ─── register_relayer ──────────────────────────────────────────────────────── + +#[test] +fn validator_can_register() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_eq!( + RelayerRegistry::::get(addr::alice_evm()), + Some(100u64) + ); + assert_eq!( + RelayerByAccount::::get(100u64), + Some(addr::alice_evm()) + ); + }); +} + +#[test] +fn validator_register_emits_event() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + System::assert_last_event( + Event::RelayerRegistered { + evm_address: addr::alice_evm(), + account: 100u64, + } + .into(), + ); + }); +} + +#[test] +fn non_validator_cannot_register() { + new_test_ext().execute_with(|| { + assert_noop!( + Relayer::register_relayer(RuntimeOrigin::signed(1), addr::alice_evm()), + Error::::NotValidator, + ); + }); +} + +#[test] +fn register_relayer_requires_signed() { + new_test_ext().execute_with(|| { + assert_noop!( + Relayer::register_relayer(RuntimeOrigin::none(), addr::alice_evm()), + frame_support::error::BadOrigin, + ); + }); +} + +#[test] +fn register_fails_if_evm_already_taken() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_noop!( + Relayer::register_relayer(RuntimeOrigin::signed(101), addr::alice_evm()), + Error::::AlreadyRegistered, + ); + }); +} + +#[test] +fn register_fails_if_account_already_registered() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_noop!( + Relayer::register_relayer(RuntimeOrigin::signed(100), addr::bob_evm()), + Error::::AccountAlreadyRegistered, + ); + }); +} + +#[test] +fn two_validators_can_register_different_evm_addresses() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(101), + addr::bob_evm() + )); + assert_eq!( + RelayerRegistry::::get(addr::alice_evm()), + Some(100u64) + ); + assert_eq!(RelayerRegistry::::get(addr::bob_evm()), Some(101u64)); + }); +} + +// ─── unregister_relayer ────────────────────────────────────────────────────── + +#[test] +fn unregister_works() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(100))); + assert!(!RelayerRegistry::::contains_key(addr::alice_evm())); + assert!(!RelayerByAccount::::contains_key(100u64)); + }); +} + +#[test] +fn unregister_emits_event() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(100))); + System::assert_last_event( + Event::RelayerUnregistered { + evm_address: addr::alice_evm(), + account: 100u64, + } + .into(), + ); + }); +} + +#[test] +fn unregister_fails_if_not_registered() { + new_test_ext().execute_with(|| { + assert_noop!( + Relayer::unregister_relayer(RuntimeOrigin::signed(100)), + Error::::NotRegistered, + ); + }); +} + +#[test] +fn unregister_requires_signed() { + new_test_ext().execute_with(|| { + assert_noop!( + Relayer::unregister_relayer(RuntimeOrigin::none()), + frame_support::error::BadOrigin, + ); + }); +} + +#[test] +fn can_re_register_after_unregister() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_ok!(Relayer::unregister_relayer(RuntimeOrigin::signed(100))); + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_eq!( + RelayerRegistry::::get(addr::alice_evm()), + Some(100u64) + ); + }); +} + +// ─── 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(&100u64), None); + }); +} + +#[test] +fn registered_evm_address_returns_correct_address() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_eq!( + crate::Pallet::::registered_evm_address(&100u64), + Some(addr::alice_evm()), + ); + }); +} + +#[test] +fn registered_evm_address_returns_none_after_unregister() { + new_test_ext().execute_with(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + 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(|| { + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(100), + addr::alice_evm() + )); + assert_ok!(Relayer::register_relayer( + RuntimeOrigin::signed(101), + addr::bob_evm() + )); + assert_eq!( + crate::Pallet::::registered_evm_address(&100u64), + Some(addr::alice_evm()), + ); + assert_eq!( + crate::Pallet::::registered_evm_address(&101u64), + Some(addr::bob_evm()), + ); + }); +} diff --git a/frame/relayer/src/traits.rs b/frame/relayer/src/traits.rs new file mode 100644 index 00000000..ad77f36d --- /dev/null +++ b/frame/relayer/src/traits.rs @@ -0,0 +1,64 @@ +//! 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 attribute relay fees to the node + /// that signed the EVM transaction instead of the block author. + 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 (relay fee recipient). + /// + /// 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. + 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_shielded_fees` before inserting the fee + /// commitment into the Merkle tree. + 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; +} diff --git a/frame/relayer/src/weights.rs b/frame/relayer/src/weights.rs new file mode 100644 index 00000000..4deb74ee --- /dev/null +++ b/frame/relayer/src/weights.rs @@ -0,0 +1,246 @@ + +//! Autogenerated weights for pallet_relayer +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2026-04-14, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `MacBook-Air-de-Nolasco.local`, CPU: `` +//! 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 +// --output=frame/relayer/src/weights.rs +// --template=./scripts/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 claim_relay_fees() -> 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: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// 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: `MaxEncodedLen`) + fn set_min_relay_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `19` + // Estimated: `1504` + // Minimum execution time: 5_000_000 picoseconds. + Weight::from_parts(6_000_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: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// 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: `MaxEncodedLen`) + /// 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: 5_000_000 picoseconds. + Weight::from_parts(6_021_147, 1504) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `Relayer::RelayerRegistry` (r:1 w:1) + /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) + /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// 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: `61` + // Estimated: `3533` + // Minimum execution time: 9_000_000 picoseconds. + Weight::from_parts(9_000_000, 3533) + .saturating_add(T::DbWeight::get().reads(4_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: `MaxEncodedLen`) + /// Storage: `Relayer::RelayerRegistry` (r:0 w:1) + /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::Events` (r:1 w:1) + /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn unregister_relayer() -> Weight { + // Proof Size summary in bytes: + // Measured: `189` + // Estimated: `3533` + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(12_000_000, 3533) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) + /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::Events` (r:1 w:1) + /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn claim_relay_fees() -> Weight { + // Proof Size summary in bytes: + // Measured: `171` + // Estimated: `3549` + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(12_000_000, 3549) + .saturating_add(T::DbWeight::get().reads(5_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } +} + +// 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: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// 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: `MaxEncodedLen`) + fn set_min_relay_fee() -> Weight { + // Proof Size summary in bytes: + // Measured: `19` + // Estimated: `1504` + // Minimum execution time: 5_000_000 picoseconds. + Weight::from_parts(6_000_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: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// 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: `MaxEncodedLen`) + /// 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: 5_000_000 picoseconds. + Weight::from_parts(6_021_147, 1504) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: `Relayer::RelayerRegistry` (r:1 w:1) + /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `Relayer::RelayerByAccount` (r:1 w:1) + /// Proof: `Relayer::RelayerByAccount` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// 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: `61` + // Estimated: `3533` + // Minimum execution time: 9_000_000 picoseconds. + Weight::from_parts(9_000_000, 3533) + .saturating_add(RocksDbWeight::get().reads(4_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: `MaxEncodedLen`) + /// Storage: `Relayer::RelayerRegistry` (r:0 w:1) + /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::Events` (r:1 w:1) + /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn unregister_relayer() -> Weight { + // Proof Size summary in bytes: + // Measured: `189` + // Estimated: `3533` + // Minimum execution time: 12_000_000 picoseconds. + Weight::from_parts(12_000_000, 3533) + .saturating_add(RocksDbWeight::get().reads(5_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) + } + /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) + /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::ExecutionPhase` (r:1 w:0) + /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) + /// Storage: `System::EventCount` (r:1 w:1) + /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `System::Events` (r:1 w:1) + /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + fn claim_relay_fees() -> Weight { + // Proof Size summary in bytes: + // Measured: `171` + // Estimated: `3549` + // Minimum execution time: 11_000_000 picoseconds. + Weight::from_parts(12_000_000, 3549) + .saturating_add(RocksDbWeight::get().reads(5_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } +} diff --git a/frame/shielded-pool/runtime-api/src/lib.rs b/frame/shielded-pool/runtime-api/src/lib.rs index c3165408..79b00817 100644 --- a/frame/shielded-pool/runtime-api/src/lib.rs +++ b/frame/shielded-pool/runtime-api/src/lib.rs @@ -2,6 +2,29 @@ use pallet_shielded_pool::{DefaultMerklePath, Hash}; +/// 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 the runtime performs a forkless +/// upgrade that changes `MinGaslessFee` or `RELAY_GAS_LIMIT`, 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 `T::MinGaslessFee::get()` 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! { pub trait ShieldedPoolRuntimeApi { /// Get the Merkle tree information (root, size, depth) @@ -13,5 +36,13 @@ sp_api::decl_runtime_apis! { /// Get the Merkle proof for a given commitment /// (This requires scanning the leaves in the runtime, which is expensive but convenient) fn get_merkle_proof_for_commitment(commitment: Hash) -> Option<(u32, DefaultMerklePath)>; + + /// Return relay configuration sourced from the runtime. + /// + /// Called by the node-native EVM relay on every `relay_shielded_call` to obtain + /// the current `MinGaslessFee` 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; } } diff --git a/template/node/Cargo.toml b/template/node/Cargo.toml index 8d38209d..33056cc3 100644 --- a/template/node/Cargo.toml +++ b/template/node/Cargo.toml @@ -59,6 +59,8 @@ sp-transaction-pool = { workspace = true, features = ["default"] } frame-system-rpc-runtime-api = { workspace = true } pallet-account-mapping-rpc = { workspace = true } pallet-account-mapping-runtime-api = { workspace = true } +pallet-relayer-rpc = { workspace = true } +pallet-relayer-runtime-api = { workspace = true } pallet-shielded-pool-rpc = { workspace = true } pallet-shielded-pool-runtime-api = { workspace = true } pallet-transaction-payment-rpc = { workspace = true } @@ -71,7 +73,10 @@ substrate-frame-rpc-system = { workspace = true } frame-benchmarking = { workspace = true } frame-benchmarking-cli = { workspace = true } frame-system = { workspace = true } +pallet-relayer = { workspace = true } pallet-transaction-payment = { workspace = true } +sp-keystore = { workspace = true } +tokio = { workspace = true, features = ["time"] } # Frontier fc-api = { workspace = true } diff --git a/template/node/src/eth.rs b/template/node/src/eth.rs index b48e8d73..6f57e9aa 100644 --- a/template/node/src/eth.rs +++ b/template/node/src/eth.rs @@ -53,6 +53,12 @@ pub struct EthConfiguration { #[arg(long)] pub enable_dev_signer: bool, + /// EVM private key (hex, with or without 0x prefix) used by this node to relay + /// shielded-pool calls on behalf of users (gasless relay). + /// When set, the node exposes the `orbinum_relayShieldedCall` RPC endpoint. + #[arg(long, value_name = "HEX_KEY")] + pub evm_relayer_key: Option, + /// The dynamic-fee pallet target gas price set by block author #[arg(long, default_value = "1")] pub target_gas_price: u64, diff --git a/template/node/src/main.rs b/template/node/src/main.rs index cd918cbf..b0a01f4b 100644 --- a/template/node/src/main.rs +++ b/template/node/src/main.rs @@ -16,6 +16,7 @@ mod cli; mod client; mod command; mod eth; +mod relayer_register; mod rpc; mod service; diff --git a/template/node/src/relayer_register.rs b/template/node/src/relayer_register.rs new file mode 100644 index 00000000..fc1b7916 --- /dev/null +++ b/template/node/src/relayer_register.rs @@ -0,0 +1,325 @@ +//! Automatic registration of the EVM relay key with pallet-relayer at node startup. +//! +//! When a validator starts with `--evm-relayer-key`, this module submits a +//! `pallet_relayer::Call::register_relayer(evm_address)` extrinsic signed by the +//! node's first Aura (sr25519) key. The registration is skipped if the account is +//! already present in `pallet-relayer::RelayerByAccount` storage. + +use std::sync::Arc; + +use sc_client_api::StorageProvider; +use sc_transaction_pool_api::{TransactionPool, TransactionSource}; +use sp_api::{Core, ProvideRuntimeApi}; +use sp_blockchain::HeaderBackend; +use sp_core::{crypto::KeyTypeId, storage::StorageKey, H256}; +use sp_keystore::Keystore; +use sp_runtime::{ + codec::{Decode, Encode}, + generic::Era, + traits::{Block as BlockT, Zero}, + MultiSignature, +}; + +use frame_system_rpc_runtime_api::AccountNonceApi; +use orbinum_runtime::{AccountId, Nonce, SignedExtra, SignedPayload, UncheckedExtrinsic}; + +use crate::client::FullBackend; + +/// Aura key type identifier: `KeyTypeId(*b"aura")`. +const AURA_KEY_TYPE: KeyTypeId = KeyTypeId(*b"aura"); + +// --------------------------------------------------------------------------- +// Storage check +// --------------------------------------------------------------------------- + +/// Builds the full storage key for `pallet-relayer::RelayerByAccount[account_id]`. +/// +/// Key layout (Blake2_128Concat map): +/// `Twox128("Relayer") ++ Twox128("RelayerByAccount") ++ Blake2_128(account_id) ++ encode(account_id)` +/// +/// Total: 16 + 16 + 16 + 32 = 80 bytes. +pub(crate) fn build_relayer_by_account_key(account_id: &AccountId) -> Vec { + let pallet_prefix = sp_io::hashing::twox_128(b"Relayer"); + let item_prefix = sp_io::hashing::twox_128(b"RelayerByAccount"); + let encoded_id = account_id.encode(); + let key_hash = sp_io::hashing::blake2_128(&encoded_id); + + let mut full_key = Vec::with_capacity(32 + encoded_id.len() + 16); + full_key.extend_from_slice(&pallet_prefix); + full_key.extend_from_slice(&item_prefix); + full_key.extend_from_slice(&key_hash); + full_key.extend_from_slice(&encoded_id); + full_key +} + +/// Returns `true` if `account_id` already has an entry in +/// `pallet-relayer::RelayerByAccount` storage. +fn is_registered(client: &C, best_hash: B::Hash, account_id: &AccountId) -> bool +where + B: BlockT, + C: StorageProvider>, +{ + let full_key = build_relayer_by_account_key(account_id); + client + .storage(best_hash, &StorageKey(full_key)) + .ok() + .flatten() + .is_some() +} + +// --------------------------------------------------------------------------- +// Auto-registration task +// --------------------------------------------------------------------------- + +/// Waits for at least block #1 to be imported, then submits a +/// `register_relayer(evm_address)` extrinsic signed by the node's Aura key. +/// +/// Idempotent: exits early (with an info log) if already registered. +pub async fn auto_register( + client: Arc, + pool: Arc

, + keystore: Arc, + evm_address: sp_core::H160, +) where + B: BlockT, + C: ProvideRuntimeApi + + HeaderBackend + + StorageProvider> + + Send + + Sync + + 'static, + C::Api: frame_system_rpc_runtime_api::AccountNonceApi + Core, + P: TransactionPool + 'static, +{ + // Wait until block #1 is imported so state is ready (up to 30 s). + for _ in 0u32..60 { + if !client.info().best_number.is_zero() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + + // Resolve the node identity from the Aura keystore. + let aura_keys = keystore.sr25519_public_keys(AURA_KEY_TYPE); + let pub_key = match aura_keys.first() { + Some(k) => *k, + None => { + log::warn!( + target: "orbinum-relay", + "auto-register: no Aura key in keystore — skipping relayer registration" + ); + return; + } + }; + + let account_id: AccountId = pub_key.0.into(); + let best_hash = client.info().best_hash; + let genesis_hash = client.info().genesis_hash; + + // Early-exit if already registered to avoid a wasted fee. + if is_registered::(&client, best_hash, &account_id) { + log::info!( + target: "orbinum-relay", + "🔑 Relayer already registered: EVM={evm_address:?} ↔ substrate={account_id:?}" + ); + return; + } + + // ── Runtime state ───────────────────────────────────────────────────── + let api = client.runtime_api(); + + let nonce = match api.account_nonce(best_hash, account_id.clone()) { + Ok(n) => n, + Err(e) => { + log::error!(target: "orbinum-relay", "auto-register: account_nonce error: {e}"); + return; + } + }; + + let version = match api.version(best_hash) { + Ok(v) => v, + Err(e) => { + log::error!(target: "orbinum-relay", "auto-register: runtime version error: {e}"); + return; + } + }; + + // ── Build extrinsic ─────────────────────────────────────────────────── + let call = orbinum_runtime::RuntimeCall::Relayer(pallet_relayer::Call::register_relayer { + evm_address, + }); + + // Type annotation drives generic inference for all CheckXxx parameters. + let extra: SignedExtra = ( + frame_system::CheckNonZeroSender::new(), + frame_system::CheckSpecVersion::new(), + frame_system::CheckTxVersion::new(), + frame_system::CheckGenesis::new(), + frame_system::CheckEra::from(Era::Immortal), + frame_system::CheckNonce::from(nonce), + frame_system::CheckWeight::new(), + pallet_transaction_payment::ChargeTransactionPayment::from(0u128), + ); + + // additional_signed mirrors each extension's AdditionalSigned type: + // ((), spec_version, tx_version, genesis_hash, genesis_hash, (), (), ()) + // For immortal transactions CheckEra uses the genesis hash as the block checkpoint. + let additional_signed = ( + (), + version.spec_version, + version.transaction_version, + genesis_hash, + genesis_hash, + (), + (), + (), + ); + + let payload = SignedPayload::from_raw(call.clone(), extra.clone(), additional_signed); + + let to_sign = payload.using_encoded(|bytes| { + if bytes.len() > 256 { + sp_io::hashing::blake2_256(bytes).to_vec() + } else { + bytes.to_vec() + } + }); + + let signature = match keystore.sr25519_sign(AURA_KEY_TYPE, &pub_key, &to_sign) { + Ok(Some(sig)) => sig, + Ok(None) => { + log::error!( + target: "orbinum-relay", + "auto-register: keystore has no key for {pub_key:?}" + ); + return; + } + Err(e) => { + log::error!(target: "orbinum-relay", "auto-register: sign error: {e:?}"); + return; + } + }; + + let extrinsic = UncheckedExtrinsic::new_signed( + call, + account_id.clone(), + MultiSignature::Sr25519(signature), + extra, + ); + + // Encode the typed extrinsic, then re-decode as the pool's opaque extrinsic type. + // OpaqueExtrinsic decodes from the SCALE length-prefixed bytes that UncheckedExtrinsic + // emits, so the round-trip is lossless. + let encoded = extrinsic.encode(); + let pool_ext = match B::Extrinsic::decode(&mut encoded.as_slice()) { + Ok(e) => e, + Err(e) => { + log::error!(target: "orbinum-relay", "auto-register: extrinsic decode failed: {e}"); + return; + } + }; + + match pool + .submit_one(best_hash, TransactionSource::Local, pool_ext) + .await + { + Ok(hash) => { + log::info!( + target: "orbinum-relay", + "🔑 Relayer auto-registration submitted: EVM={evm_address:?} ↔ substrate={account_id:?}, tx={hash:?}" + ); + } + Err(e) => { + log::error!( + target: "orbinum-relay", + "auto-register: submit failed (already registered by another account?): {e}" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Unit tests — pure key-derivation logic, no runtime required +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use sp_runtime::codec::Encode; + + use super::*; + + /// Build a deterministic test AccountId from a single seed byte. + fn account(seed: u8) -> AccountId { + AccountId::from([seed; 32]) + } + + // ── Key length ──────────────────────────────────────────────────────── + + /// Blake2_128Concat layout: 16 (pallet) + 16 (item) + 16 (hash) + 32 (encoded) = 80 bytes. + #[test] + fn storage_key_has_correct_length() { + let key = build_relayer_by_account_key(&account(1)); + assert_eq!(key.len(), 80); + } + + // ── Key structure ───────────────────────────────────────────────────── + + /// Bytes [0..16] must be Twox128("Relayer"). + #[test] + fn storage_key_pallet_prefix_is_twox128_relayer() { + let key = build_relayer_by_account_key(&account(1)); + let expected = sp_io::hashing::twox_128(b"Relayer"); + assert_eq!(&key[0..16], &expected); + } + + /// Bytes [16..32] must be Twox128("RelayerByAccount"). + #[test] + fn storage_key_item_prefix_is_twox128_relayer_by_account() { + let key = build_relayer_by_account_key(&account(1)); + let expected = sp_io::hashing::twox_128(b"RelayerByAccount"); + assert_eq!(&key[16..32], &expected); + } + + /// Bytes [32..48] must be Blake2_128(encoded_account_id). + #[test] + fn storage_key_blake2_128_at_offset_32() { + let acc = account(7); + let key = build_relayer_by_account_key(&acc); + let expected_hash = sp_io::hashing::blake2_128(&acc.encode()); + assert_eq!(&key[32..48], &expected_hash); + } + + /// Bytes [48..80] must be the raw SCALE-encoded AccountId32 (32 bytes). + #[test] + fn storage_key_ends_with_raw_encoded_account_id() { + let acc = account(42); + let key = build_relayer_by_account_key(&acc); + assert_eq!(&key[48..80], acc.encode().as_slice()); + } + + // ── Cross-account uniqueness ────────────────────────────────────────── + + /// Two different accounts must produce different storage keys. + #[test] + fn different_accounts_produce_different_keys() { + let k1 = build_relayer_by_account_key(&account(1)); + let k2 = build_relayer_by_account_key(&account(2)); + assert_ne!(k1, k2); + } + + /// The same account must always produce the same key (determinism). + #[test] + fn same_account_is_deterministic() { + let k1 = build_relayer_by_account_key(&account(99)); + let k2 = build_relayer_by_account_key(&account(99)); + assert_eq!(k1, k2); + } + + /// Zero-seed and max-seed accounts must both be handled without collision. + #[test] + fn zero_and_max_seed_produce_different_keys() { + let k_zero = build_relayer_by_account_key(&account(0x00)); + let k_max = build_relayer_by_account_key(&account(0xff)); + assert_ne!(k_zero, k_max); + } +} diff --git a/template/node/src/rpc/eth.rs b/template/node/src/rpc/eth.rs index 1fbb0db3..9dd99e43 100644 --- a/template/node/src/rpc/eth.rs +++ b/template/node/src/rpc/eth.rs @@ -23,6 +23,7 @@ pub use fc_rpc::{EthBlockDataCacheTask, EthConfig}; pub use fc_rpc_core::types::{FeeHistoryCache, FeeHistoryCacheLimit, FilterPool}; use fc_storage::StorageOverride; use fp_rpc::{ConvertTransaction, ConvertTransactionRuntimeApi, EthereumRuntimeRPCApi}; +use pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi; /// Extra dependencies for Ethereum compatibility. pub struct EthDeps { @@ -36,6 +37,8 @@ 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_relayer_key: Option, /// Network service pub network: Arc, /// Chain syncing service @@ -80,7 +83,8 @@ where C::Api: AuraApi + BlockBuilderApi + ConvertTransactionRuntimeApi - + EthereumRuntimeRPCApi, + + EthereumRuntimeRPCApi + + ShieldedPoolRuntimeApi, C: HeaderBackend + HeaderMetadata, C: BlockchainEvents + AuxStore + UsageProvider + StorageProvider + 'static, BE: Backend + 'static, @@ -103,6 +107,7 @@ where converter, is_authority, enable_dev_signer, + evm_relayer_key, network, sync, frontier_backend, @@ -194,7 +199,28 @@ where )?; #[cfg(feature = "txpool")] - io.merge(TxPool::new(client, pool).into_rpc())?; + io.merge(TxPool::new(client.clone(), pool.clone()).into_rpc())?; + + // Orbinum EVM relay RPC (requires --evm-relayer-key) + if let Some(ref key_hex) = evm_relayer_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-relayer-key: {e}"); + } + } + } Ok(io) } diff --git a/template/node/src/rpc/mod.rs b/template/node/src/rpc/mod.rs index 334f83b3..dbfe394f 100644 --- a/template/node/src/rpc/mod.rs +++ b/template/node/src/rpc/mod.rs @@ -71,6 +71,7 @@ where C::Api: pallet_account_mapping_runtime_api::AccountMappingRuntimeApi, C::Api: pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi, C::Api: pallet_zk_verifier_runtime_api::ZkVerifierRuntimeApi, + C::Api: pallet_relayer_runtime_api::RelayerRuntimeApi, C: HeaderBackend + HeaderMetadata + 'static, C: BlockchainEvents + AuxStore + UsageProvider + StorageProvider, BE: Backend + 'static, @@ -79,6 +80,7 @@ where CT: fp_rpc::ConvertTransaction<::Extrinsic> + Send + Sync + 'static, { use pallet_account_mapping_rpc::{AccountMapping, AccountMappingApiServer}; + use pallet_relayer_rpc::{Relayer, RelayerApiServer}; use pallet_shielded_pool_rpc::{ShieldedPool, ShieldedPoolApiServer}; use pallet_transaction_payment_rpc::{TransactionPayment, TransactionPaymentApiServer}; use pallet_zk_verifier_rpc::{ZkVerifier, ZkVerifierApiServer}; @@ -101,6 +103,7 @@ where io.merge(AccountMapping::new(client.clone()).into_rpc())?; io.merge(ShieldedPool::new(client.clone()).into_rpc())?; io.merge(ZkVerifier::new(client.clone()).into_rpc())?; + io.merge(Relayer::new(client.clone()).into_rpc())?; // Orbinum Privacy RPC let privacy_adapter = SubstrateStorageAdapter::new(client.clone()); diff --git a/template/node/src/service.rs b/template/node/src/service.rs index 06fb809e..64e11bf2 100644 --- a/template/node/src/service.rs +++ b/template/node/src/service.rs @@ -307,6 +307,7 @@ where RA::RuntimeApi: pallet_account_mapping_runtime_api::AccountMappingRuntimeApi, RA::RuntimeApi: pallet_zk_verifier_runtime_api::ZkVerifierRuntimeApi, + RA::RuntimeApi: pallet_relayer_runtime_api::RelayerRuntimeApi, HF: HostFunctionsT + 'static, NB: sc_network::NetworkBackend::Hash>, { @@ -437,6 +438,7 @@ where let is_authority = role.is_authority(); let enable_dev_signer = eth_config.enable_dev_signer; + let evm_relayer_key = eth_config.evm_relayer_key.clone(); let max_past_logs = eth_config.max_past_logs; let execute_gas_limit_multiplier = eth_config.execute_gas_limit_multiplier; let filter_pool = filter_pool.clone(); @@ -473,6 +475,7 @@ where converter: Some(TransactionConverter::::default()), is_authority, enable_dev_signer, + evm_relayer_key: evm_relayer_key.clone(), network: network.clone(), sync: sync_service.clone(), frontier_backend: match &*frontier_backend { @@ -537,6 +540,33 @@ where ) .await; + // If an EVM relay key is configured, auto-register it with pallet-relayer using + // the node's first Aura (sr25519) key as the Substrate identity. + if let Some(key_hex) = eth_config.evm_relayer_key.as_deref() { + match fc_rpc::EthValidatorSigner::from_hex(key_hex) { + Ok(signer) => { + let evm_address = signer.address(); + let client_r = client.clone(); + let pool_r = transaction_pool.clone(); + let keystore_r = keystore_container.keystore(); + task_manager.spawn_handle().spawn( + "relayer-auto-register", + Some("relayer"), + crate::relayer_register::auto_register( + client_r, + pool_r, + keystore_r, + evm_address, + ) + .boxed(), + ); + } + Err(e) => { + log::error!(target: "orbinum-relay", "auto-register: invalid --evm-relayer-key: {e}"); + } + } + } + if role.is_authority() { // manual-seal authorship if let Some(sealing) = sealing { diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index d177703d..a7a3ce6c 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -45,6 +45,7 @@ frame-system-rpc-runtime-api = { workspace = true } pallet-account-mapping = { workspace = true } pallet-account-mapping-runtime-api = { workspace = true } pallet-aura = { workspace = true } +pallet-authorship = { workspace = true } pallet-balances = { workspace = true, features = ["insecure_zero_ed"] } pallet-grandpa = { workspace = true } pallet-sudo = { workspace = true } @@ -71,6 +72,8 @@ pallet-evm-precompile-sha3fips-benchmarking = { 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 } @@ -122,6 +125,7 @@ std = [ "frame-system-rpc-runtime-api/std", "frame-system-benchmarking?/std", "pallet-aura/std", + "pallet-authorship/std", "pallet-account-mapping/std", "pallet-account-mapping-runtime-api/std", "pallet-balances/std", @@ -149,6 +153,8 @@ std = [ "pallet-evm-precompile-account-mapping/std", # Orbinum Privacy "pallet-zk-verifier/std", + "pallet-relayer/std", + "pallet-relayer-runtime-api/std", "pallet-shielded-pool/std", "pallet-shielded-pool/poseidon-native", "pallet-shielded-pool-runtime-api/std", @@ -174,5 +180,6 @@ runtime-benchmarks = [ "pallet-evm-precompile-sha3fips-benchmarking/runtime-benchmarks", "pallet-zk-verifier/runtime-benchmarks", "pallet-shielded-pool/runtime-benchmarks", + "pallet-relayer/runtime-benchmarks", "polkadot-runtime-common/runtime-benchmarks", ] diff --git a/template/runtime/src/genesis_config_preset.rs b/template/runtime/src/genesis_config_preset.rs index 9b5c23e0..3d39832d 100644 --- a/template/runtime/src/genesis_config_preset.rs +++ b/template/runtime/src/genesis_config_preset.rs @@ -4,8 +4,8 @@ mod mainnet; mod testnet; use crate::{ - AccountId, BalancesConfig, EVMChainIdConfig, EVMConfig, EthereumConfig, ManualSealConfig, - RuntimeGenesisConfig, SudoConfig, + AccountId, BalancesConfig, BaseFeeConfig, EVMChainIdConfig, EVMConfig, EthereumConfig, + ManualSealConfig, RuntimeGenesisConfig, SudoConfig, }; use hex_literal::hex; use sp_consensus_aura::sr25519::AuthorityId as AuraId; @@ -46,6 +46,7 @@ pub(super) fn build_genesis( _initial_authorities: Vec<(AuraId, GrandpaId)>, chain_id: u64, enable_manual_seal: bool, + base_fee_per_gas: u64, ) -> serde_json::Value { let evm_accounts = { let mut map = sp_std::collections::btree_map::BTreeMap::new(); @@ -65,7 +66,10 @@ pub(super) fn build_genesis( let config = RuntimeGenesisConfig { system: Default::default(), aura: Default::default(), - base_fee: Default::default(), + base_fee: BaseFeeConfig { + base_fee_per_gas: U256::from(base_fee_per_gas), + ..Default::default() + }, grandpa: Default::default(), balances: BalancesConfig { balances: endowed_accounts.clone(), diff --git a/template/runtime/src/genesis_config_preset/development.rs b/template/runtime/src/genesis_config_preset/development.rs index 355ca1a0..d6377cb0 100644 --- a/template/runtime/src/genesis_config_preset/development.rs +++ b/template/runtime/src/genesis_config_preset/development.rs @@ -61,5 +61,6 @@ pub fn development() -> serde_json::Value { vec![], 42, false, + 1_000_000_000, // 1 gwei — dev: low cost, visible in wallets ) } diff --git a/template/runtime/src/genesis_config_preset/local.rs b/template/runtime/src/genesis_config_preset/local.rs index 0259fdbb..23513b09 100644 --- a/template/runtime/src/genesis_config_preset/local.rs +++ b/template/runtime/src/genesis_config_preset/local.rs @@ -61,5 +61,6 @@ pub fn local_testnet() -> serde_json::Value { vec![], 2700, false, + 1_000_000_000, // 1 gwei — local testnet: same as dev ) } diff --git a/template/runtime/src/genesis_config_preset/mainnet.rs b/template/runtime/src/genesis_config_preset/mainnet.rs index cfdfda81..9ceb3830 100644 --- a/template/runtime/src/genesis_config_preset/mainnet.rs +++ b/template/runtime/src/genesis_config_preset/mainnet.rs @@ -3,5 +3,12 @@ use crate::AccountId; use sp_std::vec; pub fn mainnet() -> serde_json::Value { - build_genesis(AccountId::from([0u8; 32]), vec![], vec![], 270, false) + build_genesis( + AccountId::from([0u8; 32]), + vec![], + vec![], + 270, + false, + 1_000_000_000, // 1 gwei — economical starting point; EIP-1559 adjusts upward with traffic + ) } diff --git a/template/runtime/src/genesis_config_preset/testnet.rs b/template/runtime/src/genesis_config_preset/testnet.rs index d2a12357..11204ee0 100644 --- a/template/runtime/src/genesis_config_preset/testnet.rs +++ b/template/runtime/src/genesis_config_preset/testnet.rs @@ -35,5 +35,6 @@ pub fn testnet() -> serde_json::Value { vec![], 2700, false, + 1_000_000_000, // 1 gwei — economical; EIP-1559 adjusts upward with traffic ) } diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 06823524..a2bd6e62 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -274,6 +274,30 @@ impl pallet_aura::Config for Runtime { type SlotDuration = pallet_aura::MinimumPeriodTimesTwo; } +impl pallet_authorship::Config for Runtime { + type FindAuthor = FindAuthorAccountId; + type EventHandler = (); +} + +/// Maps an Aura authority index to its `AccountId32`. +/// +/// `pallet_aura::AuraAuthorId` implements `FindAuthor` (authority index). +/// This wrapper looks up the AuraId at that index and converts its 32-byte +/// sr25519 public key into an `AccountId32`. +pub struct FindAuthorAccountId; +impl FindAuthor for FindAuthorAccountId { + fn find_author<'a, I>(digests: I) -> Option + where + I: 'a + IntoIterator, + { + if let Some(aura_id) = pallet_aura::AuraAuthorId::::find_author(digests) { + let raw: [u8; 32] = aura_id.to_raw_vec().try_into().unwrap_or([0u8; 32]); + return Some(AccountId::from(raw)); + } + None + } +} + impl pallet_grandpa::Config for Runtime { type RuntimeEvent = RuntimeEvent; type WeightInfo = (); @@ -448,14 +472,12 @@ impl pallet_dynamic_fee::Config for Runtime { parameter_types! { // ORB uses 18 decimals (1 ORB = 1e18 wei), aligned with Ethereum tooling. - // Keeping DefaultBaseFeePerGas at 1_000_000 wei/gas yields low dev/test costs: - // 1e6 / 1e18 = 1e-12 ORB per gas - // → ~1e-7 ORB for 100k gas - // → ~3e-7 ORB for 300k gas - // - // This keeps EVM execution costs in a practical range while preserving - // sufficient granularity for fee market adjustment under EIP-1559. - pub DefaultBaseFeePerGas: U256 = U256::from(1_000_000); + // DefaultBaseFeePerGas is used as the runtime-level fallback (e.g. storage migration). + // Actual genesis values are set per-chain-spec in genesis_config_preset/: + // all networks → 1_000_000_000 wei/gas (1 gwei → ~0.00001 ORB / transfer) + // The floor (empty blocks) = DefaultBaseFeePerGas / 2 = 0.5 gwei. + // EIP-1559 adjusts the fee upward automatically as the network gains traffic. + pub DefaultBaseFeePerGas: U256 = U256::from(1_000_000_000u64); // 1 gwei pub DefaultElasticity: Permill = Permill::from_parts(125_000); } @@ -514,6 +536,48 @@ impl pallet_zk_verifier::Config for Runtime { type WeightInfo = pallet_zk_verifier::weights::SubstrateWeight; } +// ──────────────────────────────────────────────────────────────────────────── +// pallet-relayer +// ──────────────────────────────────────────────────────────────────────────── + +/// Provides the current block's author (Aura validator) for relay fee attribution. +pub struct RelayerBlockAuthor; +impl frame_support::traits::Get> for RelayerBlockAuthor { + fn get() -> Option { + pallet_authorship::Pallet::::author() + } +} + +/// Identifies validator nodes by checking if their AccountId corresponds +/// to a current Aura authority. +/// +/// In Substrate, a validator's AccountId IS their sr25519 public key (32 bytes), +/// which is the same byte representation as AuraId. We compare raw bytes. +pub struct AuraValidatorSet; +impl frame_support::traits::Contains for AuraValidatorSet { + fn contains(who: &AccountId) -> bool { + let who_bytes: &[u8; 32] = who.as_ref(); + pallet_aura::Authorities::::get() + .iter() + .any(|auth| <_ as AsRef<[u8]>>::as_ref(auth) == who_bytes) + } +} + +impl pallet_relayer::Config for Runtime { + /// Block author for relay fee attribution. + type BlockAuthor = RelayerBlockAuthor; + /// Default minimum relay fee: 0.001 ORB = 1e15 planck (anti-spam). + /// Overridable at runtime via `set_min_relay_fee` (governance/sudo). + type DefaultMinRelayFee = ConstU128<1_000_000_000_000_000>; + /// Only validator nodes (Aura authorities) may register as relayers. + type IsValidator = AuraValidatorSet; + /// Only sudo/governance can update relay configuration. + type ManageOrigin = frame_system::EnsureRoot; + /// Allow up to 16 ABI selectors in the whitelist. + type MaxAllowedSelectors = ConstU32<16>; + type WeightInfo = (); +} + parameter_types! { /// Pool account that holds all shielded tokens pub const ShieldedPoolPalletId: PalletId = PalletId(*b"shld/pol"); @@ -524,6 +588,8 @@ impl pallet_shielded_pool::Config for Runtime { type Currency = Balances; /// Groth16 proof verifier for unshield/transfer operations type ZkVerifier = ZkVerifier; + /// Relay config, fee accumulation and block-author — delegated to pallet-relayer. + // type Relayer = pallet_relayer::Pallet; /// PalletId for the pool account type PalletId = ShieldedPoolPalletId; /// Merkle tree depth: 2^20 = 1M notes max (see MERKLE_TREE_SCALABILITY.md) @@ -533,6 +599,7 @@ impl pallet_shielded_pool::Config for Runtime { /// Minimum shield amount: prevents spam, 1 ORB = 1e18 wei type MinShieldAmount = ConstU128<1_000_000_000_000_000_000>; type WeightInfo = pallet_shielded_pool::weights::SubstrateWeight; + // type RequestExpiration = ConstU32<14400>; /// Disclosure requests expire after 14400 blocks (~1 day at 6s/block) } // Create the runtime by composing the FRAME pallets that were previously configured. @@ -596,6 +663,12 @@ mod runtime { #[runtime::pallet_index(14)] pub type AccountMapping = pallet_account_mapping; + + #[runtime::pallet_index(15)] + pub type Authorship = pallet_authorship; + + #[runtime::pallet_index(16)] + pub type Relayer = pallet_relayer; } #[derive(Clone)] @@ -693,6 +766,7 @@ mod benches { [pallet_zk_verifier, ZkVerifier] [pallet_shielded_pool, ShieldedPool] [pallet_account_mapping, AccountMapping] + [pallet_relayer, Relayer] ); } @@ -1143,6 +1217,28 @@ impl_runtime_apis! { ) -> Option<(u32, pallet_shielded_pool::DefaultMerklePath)> { ShieldedPool::get_merkle_proof_for_commitment(commitment) } + + 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() { + sp_std::vec![ + [0x47, 0xfc, 0x44, 0xa2], // unshield + [0x8c, 0x0f, 0x5d, 0x24], // privateTransfer + ] + } else { + stored + } + }; + pallet_shielded_pool_runtime_api::RelayConfig { min_fee_planck, allowed_selectors } + } } impl pallet_zk_verifier_runtime_api::ZkVerifierRuntimeApi for Runtime { @@ -1293,6 +1389,21 @@ impl_runtime_apis! { } } + // Relayer Runtime API implementation + 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) + } + } + #[cfg(feature = "runtime-benchmarks")] impl frame_benchmarking::Benchmark for Runtime { fn benchmark_metadata(extra: bool) -> ( diff --git a/ts-tests/tests/test-relay-rpc.ts b/ts-tests/tests/test-relay-rpc.ts new file mode 100644 index 00000000..afb92c73 --- /dev/null +++ b/ts-tests/tests/test-relay-rpc.ts @@ -0,0 +1,270 @@ +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); + +/// EVM address derived from GENESIS_ACCOUNT_PRIVATE_KEY (lower‑case, with 0x) +const RELAYER_ADDRESS = "0x6be02d1d3665660d22ff9624b7be0551ee1ac91b"; + +/// Verified function selectors (keccak256 of ABI signature, first 4 bytes) +const SEL_UNSHIELD = "47fc44a2"; +const SEL_PRIVATE_TRANSFER = "8c0f5d24"; + +// --------------------------------------------------------------------------- +// Calldata builders +// --------------------------------------------------------------------------- + +const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + +/** + * Build ABI-encoded calldata for `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256)` + * with the given relay fee inserted as the 7th argument. + * + * 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"], + [ + "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) + fee, // relay fee + ] + ); + return "0x" + SEL_UNSHIELD + encoded.slice(2); +} + +/** + * Build ABI-encoded calldata for + * `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256)` + * with the given relay fee as the 7th argument. + * + * 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"], + [ + "0x" + "aa".repeat(32), // proof + "0x" + "bb".repeat(32), // merkle root + ["0x" + "cc".repeat(32)], // nullifiers[] + ["0x" + "dd".repeat(32)], // output commitments[] + ["0x" + "ee".repeat(104)], // encrypted memos[] + 0, // assetId + fee, // relay fee + ] + ); + return "0x" + SEL_PRIVATE_TRANSFER + encoded.slice(2); +} + +// --------------------------------------------------------------------------- +// Suite 1 — relay is NOT configured (no --evm-relayer-key) +// --------------------------------------------------------------------------- + +describeWithFrontier("Frontier RPC (Relay – disabled)", (context) => { + it("orbinum_relayerStatus returns method-not-found when disabled", 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.isTrue( + errStr.includes("not found") || errStr.includes("-32601"), + `unexpected error: ${errStr}` + ); + }); + + 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}` + ); + }); +}); + +// --------------------------------------------------------------------------- +// Suite 2 — relay IS configured with the genesis test key +// --------------------------------------------------------------------------- + +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] +);