diff --git a/Cargo.lock b/Cargo.lock index 35408c87..70834cc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8107,7 +8107,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.6.0" +version = "0.6.1" dependencies = [ "ark-bn254", "ark-ff 0.5.0", diff --git a/Cargo.toml b/Cargo.toml index 3391a4c4..b8b9f9ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -244,6 +244,7 @@ pallet-evm-precompile-curve25519-benchmarking = { path = "frame/evm/precompile/c pallet-evm-precompile-modexp = { path = "frame/evm/precompile/modexp", default-features = false } pallet-evm-precompile-sha3fips = { path = "frame/evm/precompile/sha3fips", default-features = false } pallet-evm-precompile-sha3fips-benchmarking = { path = "frame/evm/precompile/sha3fips/benchmarking", default-features = false } +pallet-evm-precompile-shielded-pool = { path = "frame/evm/precompile/shielded-pool", default-features = false } 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 } diff --git a/frame/evm/precompile/account-mapping/Cargo.toml b/frame/evm/precompile/account-mapping/Cargo.toml index ec2c8e62..4f7999ab 100644 --- a/frame/evm/precompile/account-mapping/Cargo.toml +++ b/frame/evm/precompile/account-mapping/Cargo.toml @@ -24,6 +24,16 @@ precompile-utils = { workspace = true } # Orbinum Pallets pallet-account-mapping = { workspace = true } +[dev-dependencies] +frame-system = { workspace = true, features = ["default"] } +pallet-balances = { workspace = true, features = ["default"] } +pallet-timestamp = { workspace = true, features = ["default"] } +scale-codec = { workspace = true, features = ["derive", "std"] } +scale-info = { workspace = true, features = ["derive", "std"] } +sp-core = { workspace = true, features = ["default"] } +sp-io = { workspace = true, features = ["default"] } +sp-runtime = { workspace = true, features = ["default"] } + [features] default = ["std"] std = [ diff --git a/frame/evm/precompile/account-mapping/src/lib.rs b/frame/evm/precompile/account-mapping/src/lib.rs index ae90c459..88d6c2cd 100644 --- a/frame/evm/precompile/account-mapping/src/lib.rs +++ b/frame/evm/precompile/account-mapping/src/lib.rs @@ -2,15 +2,21 @@ extern crate alloc; -use alloc::{format, vec::Vec}; +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +use alloc::{format, vec, vec::Vec}; use core::marker::PhantomData; + use fp_evm::{ ExitError, ExitSucceed, Precompile, PrecompileFailure, PrecompileHandle, PrecompileOutput, PrecompileResult, }; -use frame_support::dispatch::GetDispatchInfo; +use frame_support::{dispatch::GetDispatchInfo, traits::ConstU32, BoundedVec}; use pallet_evm::{AddressMapping, GasWeightMapping}; -use sp_core::U256; +use sp_core::{H160, U256}; use sp_runtime::traits::Dispatchable; // ───────────────────────────────────────────────────────────────────────────── @@ -19,15 +25,46 @@ use sp_runtime::traits::Dispatchable; // Computed as: bytes4(keccak256("functionName(argTypes)")) // Verify with: cast sig "functionName(argTypes)" (Foundry) // -// registerAlias(string) → cast sig "registerAlias(string)" -// resolveAlias(string) → cast sig "resolveAlias(string)" -// getAliasOf(address) → cast sig "getAliasOf(address)" -// hasPrivateLink(string,bytes32)→ cast sig "hasPrivateLink(string,bytes32)" +// ─── Read-only ─────────────────────────────────────────────────────────────── +// resolveAlias(string) → 0xd03149ab +const SEL_RESOLVE_ALIAS: [u8; 4] = [0xd0, 0x31, 0x49, 0xab]; +// getAliasOf(address) → 0x7a0ed62c +const SEL_GET_ALIAS_OF: [u8; 4] = [0x7a, 0x0e, 0xd6, 0x2c]; +// hasPrivateLink(string,bytes32) → 0x47e05c6c +const SEL_HAS_PRIVATE_LINK: [u8; 4] = [0x47, 0xe0, 0x5c, 0x6c]; +// +// ─── State-changing: no arguments ──────────────────────────────────────────── +// mapAccount() → 0xdca49d0e +const SEL_MAP_ACCOUNT: [u8; 4] = [0xdc, 0xa4, 0x9d, 0x0e]; +// unmapAccount() → 0x08f57367 +const SEL_UNMAP_ACCOUNT: [u8; 4] = [0x08, 0xf5, 0x73, 0x67]; +// releaseAlias() → 0x7fac359e +const SEL_RELEASE_ALIAS: [u8; 4] = [0x7f, 0xac, 0x35, 0x9e]; +// cancelSale() → 0x4d023ab9 +const SEL_CANCEL_SALE: [u8; 4] = [0x4d, 0x02, 0x3a, 0xb9]; +// +// ─── State-changing: with arguments ────────────────────────────────────────── +// registerAlias(string) → 0x2f8839c3 +const SEL_REGISTER_ALIAS: [u8; 4] = [0x2f, 0x88, 0x39, 0xc3]; +// transferAlias(address) → 0x5ac998e7 +const SEL_TRANSFER_ALIAS: [u8; 4] = [0x5a, 0xc9, 0x98, 0xe7]; +// buyAlias(string) → 0x1625df3a +const SEL_BUY_ALIAS: [u8; 4] = [0x16, 0x25, 0xdf, 0x3a]; +// putAliasOnSale(uint256,address[]) → 0x32091192 +const SEL_PUT_ALIAS_ON_SALE: [u8; 4] = [0x32, 0x09, 0x11, 0x92]; +// removeChainLink(uint32) → 0x6f579c0c +const SEL_REMOVE_CHAIN_LINK: [u8; 4] = [0x6f, 0x57, 0x9c, 0x0c]; +// addChainLink(uint32,bytes,bytes) → 0x5f3e837c +const SEL_ADD_CHAIN_LINK: [u8; 4] = [0x5f, 0x3e, 0x83, 0x7c]; +// registerPrivateLink(uint32,bytes32) → 0xc04e98f4 +const SEL_REGISTER_PRIVATE_LINK: [u8; 4] = [0xc0, 0x4e, 0x98, 0xf4]; +// removePrivateLink(bytes32) → 0xdfd8b57e +const SEL_REMOVE_PRIVATE_LINK: [u8; 4] = [0xdf, 0xd8, 0xb5, 0x7e]; +// revealPrivateLink(bytes32,bytes,bytes32,bytes)→ 0x4df1f33d +const SEL_REVEAL_PRIVATE_LINK: [u8; 4] = [0x4d, 0xf1, 0xf3, 0x3d]; +// setAccountMetadata(bytes,bytes,bytes) → 0x776cf9ff +const SEL_SET_METADATA: [u8; 4] = [0x77, 0x6c, 0xf9, 0xff]; // ───────────────────────────────────────────────────────────────────────────── -const SEL_REGISTER_ALIAS: [u8; 4] = [0x2f, 0x88, 0x39, 0xc3]; // registerAlias(string) -const SEL_RESOLVE_ALIAS: [u8; 4] = [0xd0, 0x31, 0x49, 0xab]; // resolveAlias(string) -const SEL_GET_ALIAS_OF: [u8; 4] = [0x7a, 0x0e, 0xd6, 0x2c]; // getAliasOf(address) -const SEL_HAS_PRIVATE_LINK: [u8; 4] = [0x47, 0xe0, 0x5c, 0x6c]; // hasPrivateLink(string,bytes32) pub struct AccountMappingPrecompile(PhantomData); @@ -41,6 +78,7 @@ where From::AccountId>>, <::RuntimeCall as Dispatchable>::PostInfo: core::fmt::Debug, pallet_evm::AccountIdOf: Into<::AccountId>, + pallet_account_mapping::BalanceOf: TryFrom, { fn execute(handle: &mut impl PrecompileHandle) -> PrecompileResult { let input = handle.input().to_vec(); @@ -54,10 +92,37 @@ where let selector: [u8; 4] = input[0..4].try_into().unwrap(); match selector { - SEL_REGISTER_ALIAS => Self::register_alias(handle, &input), + // ─── Read-only ─────────────────────────────────────────────────── SEL_RESOLVE_ALIAS => Self::resolve_alias(&input), SEL_GET_ALIAS_OF => Self::get_alias_of(&input), SEL_HAS_PRIVATE_LINK => Self::has_private_link(&input), + + // ─── No-arg dispatches ─────────────────────────────────────────── + SEL_MAP_ACCOUNT => { + Self::dispatch_call(handle, pallet_account_mapping::Call::::map_account {}) + } + SEL_UNMAP_ACCOUNT => { + Self::dispatch_call(handle, pallet_account_mapping::Call::::unmap_account {}) + } + SEL_RELEASE_ALIAS => { + Self::dispatch_call(handle, pallet_account_mapping::Call::::release_alias {}) + } + SEL_CANCEL_SALE => { + Self::dispatch_call(handle, pallet_account_mapping::Call::::cancel_sale {}) + } + + // ─── With-arg dispatches ───────────────────────────────────────── + SEL_REGISTER_ALIAS => Self::register_alias(handle, &input), + SEL_TRANSFER_ALIAS => Self::transfer_alias(handle, &input), + SEL_BUY_ALIAS => Self::buy_alias(handle, &input), + SEL_PUT_ALIAS_ON_SALE => Self::put_alias_on_sale(handle, &input), + SEL_REMOVE_CHAIN_LINK => Self::remove_chain_link(handle, &input), + SEL_ADD_CHAIN_LINK => Self::add_chain_link(handle, &input), + SEL_REGISTER_PRIVATE_LINK => Self::register_private_link(handle, &input), + SEL_REMOVE_PRIVATE_LINK => Self::remove_private_link(handle, &input), + SEL_REVEAL_PRIVATE_LINK => Self::reveal_private_link(handle, &input), + SEL_SET_METADATA => Self::set_account_metadata(handle, &input), + _ => Err(PrecompileFailure::Error { exit_status: ExitError::Other("unknown selector".into()), }), @@ -75,99 +140,353 @@ where From::AccountId>>, <::RuntimeCall as Dispatchable>::PostInfo: core::fmt::Debug, pallet_evm::AccountIdOf: Into<::AccountId>, + pallet_account_mapping::BalanceOf: TryFrom, { - // ─── registerAlias(string alias) ───────────────────────────────────────── - // - // Dispatches the `register_alias` extrinsic on behalf of the EVM caller. - // ABI calldata (after the 4-byte selector): - // [0..32] = uint256 offset (relative to the start of params, typically 0x20) - // at offset: uint256 length + UTF-8 bytes of the alias + // ───────────────────────────────────────────────────────────────────────── + // Dispatch helper + // ───────────────────────────────────────────────────────────────────────── + + /// Dispatches any `pallet_account_mapping::Call` on behalf of the EVM caller. + /// Records gas cost from the call's weight info before dispatching. + fn dispatch_call( + handle: &mut impl PrecompileHandle, + call: pallet_account_mapping::Call, + ) -> PrecompileResult { + let runtime_call = <::RuntimeCall as From< + pallet_account_mapping::Call, + >>::from(call); + let info = runtime_call.get_dispatch_info(); + let gas_cost = T::GasWeightMapping::weight_to_gas(info.total_weight()); + handle.record_cost(gas_cost)?; + + let caller_account: ::AccountId = + T::AddressMapping::into_account_id(handle.context().caller).into(); + let origin = + <::RuntimeCall as Dispatchable>::RuntimeOrigin::from(Some( + caller_account, + )); + + match runtime_call.dispatch(origin) { + Ok(_) => Ok(PrecompileOutput { + exit_status: ExitSucceed::Stopped, + output: Default::default(), + }), + Err(e) => Err(PrecompileFailure::Error { + exit_status: ExitError::Other(format!("{e:?}").into()), + }), + } + } + + // ───────────────────────────────────────────────────────────────────────── + // State-changing: with arguments + // ───────────────────────────────────────────────────────────────────────── + + /// registerAlias(string alias) + /// + /// ABI params (input[4..]): + /// [0..32] uint256 offset → string data (dynamic) + /// at offset: uint256 length + UTF-8 bytes of the alias fn register_alias(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { - let caller = handle.context().caller; - let params = &input[4..]; // strip selector + let alias_bytes = Self::decode_abi_string(&input[4..])?; + let bounded: pallet_account_mapping::AliasOf = + alias_bytes + .try_into() + .map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other("alias too long".into()), + })?; + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::register_alias { alias: bounded }, + ) + } - if params.len() < 64 { + /// transferAlias(address newOwner) + /// + /// ABI params (input[4..]): + /// [0..32] address (20 bytes, right-justified in 32-byte slot) + fn transfer_alias(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let params = &input[4..]; + if params.len() < 32 { return Err(PrecompileFailure::Error { - exit_status: ExitError::Other("ABI input too short".into()), + exit_status: ExitError::Other("transferAlias: input too short".into()), }); } + let h160 = H160::from_slice(¶ms[12..32]); + let new_owner: ::AccountId = + T::AddressMapping::into_account_id(h160).into(); + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::transfer_alias { new_owner }, + ) + } - let offset = U256::from_big_endian(¶ms[0..32]).low_u32() as usize; - let abs_len_pos = offset; + /// buyAlias(string alias) + /// + /// Same ABI layout as registerAlias(string). + fn buy_alias(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let alias_bytes = Self::decode_abi_string(&input[4..])?; + let bounded: pallet_account_mapping::AliasOf = + alias_bytes + .try_into() + .map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other("alias too long".into()), + })?; + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::buy_alias { alias: bounded }, + ) + } - if abs_len_pos + 32 > params.len() { + /// putAliasOnSale(uint256 price, address[] allowedBuyers) + /// + /// ABI params (input[4..]): + /// [0..32] uint256 price (static) + /// [32..64] uint256 offset → address[] (dynamic) + /// at offset: uint256 array_length, then N × 32-byte address slots + /// + /// Empty array → public listing (no whitelist, allowed_buyers = None). + fn put_alias_on_sale(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let params = &input[4..]; + if params.len() < 64 { return Err(PrecompileFailure::Error { - exit_status: ExitError::Other("ABI offset out of bounds".into()), + exit_status: ExitError::Other("putAliasOnSale: input too short".into()), }); } - let length = - U256::from_big_endian(¶ms[abs_len_pos..abs_len_pos + 32]).low_u32() as usize; - let abs_data_start = abs_len_pos + 32; + // price + let price_u128 = U256::from_big_endian(¶ms[0..32]).low_u128(); + let price = pallet_account_mapping::BalanceOf::::try_from(price_u128).map_err(|_| { + PrecompileFailure::Error { + exit_status: ExitError::Other("putAliasOnSale: price overflow".into()), + } + })?; + + // address[] whitelist + let arr_offset = U256::from_big_endian(¶ms[32..64]).low_u32() as usize; + let buyers: Option::AccountId>> = + if arr_offset + 32 > params.len() { + None + } else { + let arr_len = + U256::from_big_endian(¶ms[arr_offset..arr_offset + 32]).low_u32() as usize; + if arr_len == 0 { + None + } else { + let data_start = arr_offset + 32; + if data_start + arr_len * 32 > params.len() { + return Err(PrecompileFailure::Error { + exit_status: ExitError::Other( + "putAliasOnSale: whitelist out of bounds".into(), + ), + }); + } + let mut list = vec![]; + for i in 0..arr_len { + let slot = data_start + i * 32; + let addr = H160::from_slice(¶ms[slot + 12..slot + 32]); + let account: ::AccountId = + T::AddressMapping::into_account_id(addr).into(); + list.push(account); + } + Some(list) + } + }; + + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::put_alias_on_sale { + price, + allowed_buyers: buyers, + }, + ) + } + + /// removeChainLink(uint32 chainId) + /// + /// ABI params (input[4..]): + /// [0..32] uint32 (value in last 4 bytes of slot) + fn remove_chain_link(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let chain_id = Self::decode_u32(&input[4..])?; + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::remove_chain_link { chain_id }, + ) + } - if abs_data_start + length > params.len() { + /// addChainLink(uint32 chainId, bytes externalAddr, bytes signature) + /// + /// ABI params (input[4..]) — mixed static + dynamic: + /// [0..32] uint32 chainId (static) + /// [32..64] uint256 offset → bytes externalAddr (dynamic) + /// [64..96] uint256 offset → bytes signature (dynamic) + /// at each offset: uint256 length + raw bytes (zero-padded to 32-byte multiple) + /// + /// Note: the signature scheme (Eip191 / Ed25519) is NOT passed here; + /// the pallet looks it up from `SupportedChains` storage by chain_id. + fn add_chain_link(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let params = &input[4..]; + if params.len() < 96 { return Err(PrecompileFailure::Error { - exit_status: ExitError::Other("ABI string data out of bounds".into()), + exit_status: ExitError::Other("addChainLink: input too short".into()), }); } + let chain_id = U256::from_big_endian(¶ms[0..32]).low_u32(); + let address = Self::decode_bytes_at_slot(params, 32)?; + let signature = Self::decode_bytes_at_slot(params, 64)?; + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::add_chain_link { + chain_id, + address, + signature, + }, + ) + } - let alias_bytes = params[abs_data_start..abs_data_start + length].to_vec(); - - let bounded_alias: pallet_account_mapping::AliasOf = - alias_bytes - .try_into() - .map_err(|_| PrecompileFailure::Error { - exit_status: ExitError::Other("alias too long".into()), - })?; + /// registerPrivateLink(uint32 chainId, bytes32 commitment) + /// + /// ABI params (input[4..]): + /// [0..32] uint32 chain_id (padded) + /// [32..64] bytes32 commitment (static) + fn register_private_link(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let params = &input[4..]; + if params.len() < 64 { + return Err(PrecompileFailure::Error { + exit_status: ExitError::Other("registerPrivateLink: input too short".into()), + }); + } + let chain_id = U256::from_big_endian(¶ms[0..32]).low_u32(); + let commitment: [u8; 32] = params[32..64].try_into().unwrap(); + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::register_private_link { + chain_id, + commitment, + }, + ) + } - let call = <::RuntimeCall as From< - pallet_account_mapping::Call, - >>::from(pallet_account_mapping::Call::::register_alias { - alias: bounded_alias, - }); - let info = call.get_dispatch_info(); - let gas_cost = T::GasWeightMapping::weight_to_gas(info.total_weight()); - handle.record_cost(gas_cost)?; + /// removePrivateLink(bytes32 commitment) + /// + /// ABI params (input[4..]): + /// [0..32] bytes32 commitment (static) + fn remove_private_link(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let params = &input[4..]; + if params.len() < 32 { + return Err(PrecompileFailure::Error { + exit_status: ExitError::Other("removePrivateLink: input too short".into()), + }); + } + let commitment: [u8; 32] = params[0..32].try_into().unwrap(); + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::remove_private_link { commitment }, + ) + } - let origin: ::AccountId = - T::AddressMapping::into_account_id(caller).into(); - let dispatch_origin = - <::RuntimeCall as Dispatchable>::RuntimeOrigin::from(Some( - origin, - )); + /// revealPrivateLink(bytes32 commitment, bytes address, bytes32 blinding, bytes signature) + /// + /// ABI params (input[4..]) — mixed static + dynamic: + /// [0..32] bytes32 commitment (static) + /// [32..64] uint256 offset → bytes address (dynamic) + /// [64..96] bytes32 blinding (static) + /// [96..128] uint256 offset → bytes signature (dynamic) + /// at each offset: uint256 length + raw bytes + fn reveal_private_link(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let params = &input[4..]; + if params.len() < 128 { + return Err(PrecompileFailure::Error { + exit_status: ExitError::Other("revealPrivateLink: input too short".into()), + }); + } + let commitment: [u8; 32] = params[0..32].try_into().unwrap(); + let address = Self::decode_bytes_at_slot(params, 32)?; + let blinding: [u8; 32] = params[64..96].try_into().unwrap(); + let signature = Self::decode_bytes_at_slot(params, 96)?; + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::reveal_private_link { + commitment, + address, + blinding, + signature, + }, + ) + } - match call.dispatch(dispatch_origin) { - Ok(_) => Ok(PrecompileOutput { - exit_status: ExitSucceed::Returned, - output: Default::default(), - }), - Err(e) => Err(PrecompileFailure::Error { - exit_status: ExitError::Other(format!("dispatch failed: {e:?}").into()), - }), + /// setAccountMetadata(bytes displayName, bytes bio, bytes avatar) + /// + /// ABI params (input[4..]) — all dynamic: + /// [0..32] uint256 offset → bytes displayName + /// [32..64] uint256 offset → bytes bio + /// [64..96] uint256 offset → bytes avatar + /// at each offset: uint256 length + UTF-8 bytes + /// + /// Empty bytes → field not changed (None). Max 128 bytes each. + fn set_account_metadata(handle: &mut impl PrecompileHandle, input: &[u8]) -> PrecompileResult { + let params = &input[4..]; + if params.len() < 96 { + return Err(PrecompileFailure::Error { + exit_status: ExitError::Other("setAccountMetadata: input too short".into()), + }); } + + let raw_display = Self::decode_bytes_at_slot(params, 0)?; + let raw_bio = Self::decode_bytes_at_slot(params, 32)?; + let raw_avatar = Self::decode_bytes_at_slot(params, 64)?; + + // Converts raw bytes → Option>> + // Empty bytes → None (field unchanged). + let to_opt_bounded = + |v: Vec| -> Result>>, PrecompileFailure> { + if v.is_empty() { + Ok(None) + } else { + v.try_into() + .map(Some) + .map_err(|_| PrecompileFailure::Error { + exit_status: ExitError::Other( + "metadata field too long (max 128 bytes)".into(), + ), + }) + } + }; + + let display_name = to_opt_bounded(raw_display)?; + let bio = to_opt_bounded(raw_bio)?; + let avatar = to_opt_bounded(raw_avatar)?; + + Self::dispatch_call( + handle, + pallet_account_mapping::Call::::set_account_metadata { + display_name, + bio, + avatar, + }, + ) } - // ─── resolveAlias(string alias) returns (address owner, address evmAddress) ─ - // - // Read-only query. No state modification. - // Returns ABI-encoded (address, address): - // bytes [0..32]: owner (20 bytes right-justified, 12 leading zeros) - // bytes [32..64]: evmAddress (same encoding) - // Returns 64 zero bytes if the alias does not exist. + // ───────────────────────────────────────────────────────────────────────── + // Read-only queries + // ───────────────────────────────────────────────────────────────────────── + + /// resolveAlias(string alias) returns (address owner, address evmAddress) + /// + /// ABI return: two 32-byte address slots. + /// Returns 64 zero bytes if the alias does not exist. fn resolve_alias(input: &[u8]) -> PrecompileResult { let alias_bytes = Self::decode_abi_string(&input[4..])?; let (owner_h160, evm_h160) = if let Some(record) = pallet_account_mapping::Pallet::::runtime_api_resolve_alias(&alias_bytes) { - let owner_h160 = record.evm_address.unwrap_or(sp_core::H160::zero()); - (owner_h160, owner_h160) + let h = record.evm_address.unwrap_or(H160::zero()); + (h, h) } else { - (sp_core::H160::zero(), sp_core::H160::zero()) + (H160::zero(), H160::zero()) }; - // ABI encode: two addresses (32 bytes each, address occupies the last 20 bytes) - let mut output = alloc::vec![0u8; 64]; + let mut output = vec![0u8; 64]; output[12..32].copy_from_slice(owner_h160.as_bytes()); output[44..64].copy_from_slice(evm_h160.as_bytes()); @@ -177,14 +496,10 @@ where }) } - // ─── getAliasOf(address evm) returns (bytes alias) ─────────────────────── - // - // Read-only query. Returns the registered alias for an EVM address. - // ABI return type `bytes` (dynamic): - // bytes [0..32]: offset = 0x20 - // bytes [32..64]: alias length in bytes - // bytes [64..N]: alias bytes, zero-padded to a 32-byte multiple - // Returns empty bytes if the address has no alias. + /// getAliasOf(address evm) returns (bytes alias) + /// + /// ABI return: offset (0x20) + length + alias bytes, zero-padded. + /// Returns empty bytes if no alias is registered for the address. fn get_alias_of(input: &[u8]) -> PrecompileResult { let params = &input[4..]; if params.len() < 32 { @@ -193,23 +508,19 @@ where }); } - // ABI address: 32-byte slot, address occupies the last 20 bytes - let evm_addr = sp_core::H160::from_slice(¶ms[12..32]); + let evm_addr = H160::from_slice(¶ms[12..32]); let account: ::AccountId = T::AddressMapping::into_account_id(evm_addr).into(); - let alias: Vec = pallet_account_mapping::Pallet::::runtime_api_get_alias_of(account) + let alias = pallet_account_mapping::Pallet::::runtime_api_get_alias_of(account) .unwrap_or_default(); - // ABI encode `bytes`: offset (32) + length + data zero-padded to 32-byte multiple + // ABI bytes: offset(32) + length + data (zero-padded to 32-byte multiple) let padded_len = (alias.len() + 31) & !31; - let mut output = alloc::vec![0u8; 64 + padded_len]; - // offset = 0x20 - output[31] = 0x20; - // length + let mut output = vec![0u8; 64 + padded_len]; + output[31] = 0x20; // offset = 0x20 let len_bytes = (alias.len() as u64).to_be_bytes(); output[56..64].copy_from_slice(&len_bytes); - // data output[64..64 + alias.len()].copy_from_slice(&alias); Ok(PrecompileOutput { @@ -218,32 +529,22 @@ where }) } - // ─── hasPrivateLink(string alias, bytes32 commitment) returns (bool) ───── - // - // Read-only query. Checks whether the alias has the given commitment - // registered in its private links list (`PrivateChainLinks` storage). - // - // ABI input for `(string, bytes32)` — params = input[4..]: - // [0..32] : offset pointer to the string (dynamic), relative to the start of params - // [32..64] : bytes32 commitment (static, direct value) - // at offset: uint256 length + UTF-8 bytes of the alias - // - // ABI return `bool`: - // 32-byte slot, last byte = 1 (true) or 0 (false) + /// hasPrivateLink(string alias, bytes32 commitment) returns (bool) + /// + /// ABI params (input[4..]): + /// [0..32] uint256 offset → string alias (dynamic) + /// [32..64] bytes32 commitment (static) + /// + /// Returns ABI-encoded bool (32-byte slot, last byte = 1 or 0). fn has_private_link(input: &[u8]) -> PrecompileResult { let params = &input[4..]; - - // We need at least 2 header slots (64 bytes) if params.len() < 64 { return Err(PrecompileFailure::Error { exit_status: ExitError::Other("hasPrivateLink: input too short".into()), }); } - // bytes32 commitment is in the static slot [32..64] let commitment: [u8; 32] = params[32..64].try_into().unwrap(); - - // string alias — decode from the dynamic area indicated by the offset in [0..32] let alias_bytes = Self::decode_abi_string(params)?; let found = pallet_account_mapping::Pallet::::runtime_api_has_private_link( @@ -251,41 +552,65 @@ where commitment, ); - // ABI encode bool: 32-byte slot - let mut output = alloc::vec![0u8; 32]; + let mut output = vec![0u8; 32]; if found { output[31] = 1; } - Ok(PrecompileOutput { exit_status: ExitSucceed::Returned, output, }) } - // ─── Helpers ───────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── + // ABI decode helpers + // ───────────────────────────────────────────────────────────────────────── - /// Decodes an ABI-encoded `string` into a `Vec`. - /// Expects the calldata WITHOUT the 4-byte selector. - fn decode_abi_string(params: &[u8]) -> Result, PrecompileFailure> { - if params.len() < 64 { + /// Decodes a `uint32` from the first 32-byte slot of `params`. + fn decode_u32(params: &[u8]) -> Result { + if params.len() < 32 { + return Err(PrecompileFailure::Error { + exit_status: ExitError::Other("ABI: uint32 slot too short".into()), + }); + } + Ok(U256::from_big_endian(¶ms[0..32]).low_u32()) + } + + /// Decodes an ABI `bytes` (or `string`) value whose **offset pointer** lives + /// at `slot_start` bytes within `params`. + /// + /// Layout: + /// `params[slot_start..slot_start+32]` → absolute offset (relative to params start) + /// `params[offset..offset+32]` → uint256 byte length + /// `params[offset+32..]` → raw bytes (zero-padded to 32-byte multiple) + fn decode_bytes_at_slot( + params: &[u8], + slot_start: usize, + ) -> Result, PrecompileFailure> { + if slot_start + 32 > params.len() { return Err(PrecompileFailure::Error { - exit_status: ExitError::Other("ABI: params too short".into()), + exit_status: ExitError::Other("ABI: slot out of bounds".into()), }); } - let offset = U256::from_big_endian(¶ms[0..32]).low_u32() as usize; + let offset = U256::from_big_endian(¶ms[slot_start..slot_start + 32]).low_u32() as usize; if offset + 32 > params.len() { return Err(PrecompileFailure::Error { - exit_status: ExitError::Other("ABI: offset out of bounds".into()), + exit_status: ExitError::Other("ABI: dynamic offset out of bounds".into()), }); } let length = U256::from_big_endian(¶ms[offset..offset + 32]).low_u32() as usize; let data_start = offset + 32; if data_start + length > params.len() { return Err(PrecompileFailure::Error { - exit_status: ExitError::Other("ABI: string data out of bounds".into()), + exit_status: ExitError::Other("ABI: bytes data out of bounds".into()), }); } Ok(params[data_start..data_start + length].to_vec()) } + + /// Decodes a top-level ABI-encoded `string` (or `bytes`) value. + /// The offset pointer lives at slot 0 of `params` (i.e. input stripped of selector). + fn decode_abi_string(params: &[u8]) -> Result, PrecompileFailure> { + Self::decode_bytes_at_slot(params, 0) + } } diff --git a/frame/evm/precompile/account-mapping/src/mock.rs b/frame/evm/precompile/account-mapping/src/mock.rs new file mode 100644 index 00000000..3367a0f2 --- /dev/null +++ b/frame/evm/precompile/account-mapping/src/mock.rs @@ -0,0 +1,282 @@ +//! Test mock for `pallet-evm-precompile-account-mapping`. +//! +//! Uses `AccountId = H160` so that `IdentityAddressMapping` works directly +//! (EVM caller H160 → substrate AccountId H160 without any conversion). +//! Uses `Balance = u128` so that `BalanceOf: TryFrom` is trivially +//! satisfied. + +use frame_support::{parameter_types, weights::Weight}; +use pallet_account_mapping::PrivateLinkVerifierPort; +use pallet_evm::{ + Context, EnsureAddressNever, EnsureAddressRoot, FeeCalculator, IdentityAddressMapping, + PrecompileHandle, +}; +use sp_core::{H160, H256, U256}; +use sp_runtime::{ + traits::{BlakeTwo256, Convert, IdentityLookup}, + BuildStorage, +}; + +use fp_evm::{ExitError, ExitReason, Transfer}; + +pub(crate) type Balance = u128; + +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test { + System: frame_system, + Balances: pallet_balances, + Timestamp: pallet_timestamp, + EVM: pallet_evm, + AccountMapping: pallet_account_mapping, + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; +} + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type RuntimeTask = RuntimeTask; + type Nonce = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = H160; + type Lookup = IdentityLookup; + type Block = Block; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = BlockHashCount; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; + type ExtensionsWeightInfo = (); + type SingleBlockMigrations = (); + type MultiBlockMigrator = (); + type PreInherents = (); + type PostInherents = (); + type PostTransactions = (); +} + +parameter_types! { + pub const ExistentialDeposit: Balance = 1; +} + +impl pallet_balances::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeHoldReason = RuntimeHoldReason; + type RuntimeFreezeReason = RuntimeFreezeReason; + type WeightInfo = (); + type Balance = Balance; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = RuntimeFreezeReason; + type MaxLocks = (); + type MaxReserves = (); + type MaxFreezes = (); + type DoneSlashHandler = (); +} + +parameter_types! { + pub const MinimumPeriod: u64 = 1000; +} + +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; + type WeightInfo = (); +} + +pub struct FixedGasPrice; +impl FeeCalculator for FixedGasPrice { + fn min_gas_price() -> (U256, Weight) { + (1_000_000_000u128.into(), Weight::from_parts(7u64, 0)) + } +} + +parameter_types! { + pub BlockGasLimit: U256 = U256::max_value(); + pub WeightPerGas: Weight = Weight::from_parts(20_000, 0); +} + +impl pallet_evm::Config for Test { + type AccountProvider = pallet_evm::FrameSystemAccountProvider; + type FeeCalculator = FixedGasPrice; + type GasWeightMapping = pallet_evm::FixedGasWeightMapping; + type WeightPerGas = WeightPerGas; + type BlockHashMapping = pallet_evm::SubstrateBlockHashMapping; + type CallOrigin = EnsureAddressRoot; + type WithdrawOrigin = EnsureAddressNever; + type AddressMapping = IdentityAddressMapping; + type Currency = Balances; + type PrecompilesType = (); + type PrecompilesValue = (); + type ChainId = (); + type BlockGasLimit = BlockGasLimit; + type Runner = pallet_evm::runner::stack::Runner; + type OnChargeTransaction = (); + type OnCreate = (); + type FindAuthor = (); + type GasLimitPovSizeRatio = (); + type GasLimitStorageGrowthRatio = (); + type Timestamp = Timestamp; + type CreateInnerOriginFilter = (); + type CreateOriginFilter = (); + type WeightInfo = (); +} + +/// Identity converter: each substrate H160 account maps to itself as EVM address. +pub struct IdentityEvmAddress; +impl Convert> for IdentityEvmAddress { + fn convert(account: H160) -> Option { + Some(account) + } +} + +parameter_types! { + pub const TestAliasDeposit: Balance = 100; + pub const TestMaxAliasLength: u32 = 32; +} + +/// Accepts a proof if its first byte is `0x01`. +pub struct MockPrivateLinkVerifier; +impl PrivateLinkVerifierPort for MockPrivateLinkVerifier { + fn verify(_commitment: &[u8; 32], _call_hash: &[u8; 32], proof: &[u8]) -> bool { + proof.first().copied() == Some(0x01) + } +} + +impl pallet_account_mapping::Config for Test { + type RuntimeCall = RuntimeCall; + type Currency = Balances; + type AccountIdToEvmAddress = IdentityEvmAddress; + type AliasDeposit = TestAliasDeposit; + type MaxAliasLength = TestMaxAliasLength; + type WeightInfo = pallet_account_mapping::weights::SubstrateWeight; + type PrivateLinkVerifier = MockPrivateLinkVerifier; +} + +/// Caller address used in tests: `0x0000…0001`. +pub fn caller() -> H160 { + H160::from_low_u64_be(1) +} + +/// Builds test externalities and funds `caller()` with 1 000 000 units. +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap(); + + pallet_balances::GenesisConfig:: { + balances: vec![(caller(), 1_000_000)], + dev_accounts: None, + } + .assimilate_storage(&mut t) + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +// ───────────────────────────────────────────────────────────────────────────── +// MockHandle +// ───────────────────────────────────────────────────────────────────────────── + +pub(crate) struct MockHandle { + pub input: Vec, + pub context: Context, +} + +impl MockHandle { + pub fn new(input: Vec) -> Self { + Self { + input, + context: Context { + address: H160::zero(), + caller: caller(), + apparent_value: U256::zero(), + }, + } + } +} + +impl PrecompileHandle for MockHandle { + fn call( + &mut self, + _: H160, + _: Option, + _: Vec, + _: Option, + _: bool, + _: &Context, + ) -> (ExitReason, Vec) { + unimplemented!() + } + + fn record_cost(&mut self, _: u64) -> Result<(), ExitError> { + Ok(()) + } + + fn record_external_cost( + &mut self, + _ref_time: Option, + _proof_size: Option, + _storage_growth: Option, + ) -> Result<(), ExitError> { + Ok(()) + } + + fn refund_external_cost(&mut self, _ref_time: Option, _proof_size: Option) {} + + fn remaining_gas(&self) -> u64 { + u64::MAX + } + + fn log(&mut self, _: H160, _: Vec, _: Vec) -> Result<(), ExitError> { + Ok(()) + } + + fn code_address(&self) -> H160 { + H160::zero() + } + + fn input(&self) -> &[u8] { + &self.input + } + + fn context(&self) -> &Context { + &self.context + } + + fn origin(&self) -> H160 { + self.context.caller + } + + fn is_static(&self) -> bool { + false + } + + fn gas_limit(&self) -> Option { + None + } + + fn is_contract_being_constructed(&self, _address: H160) -> bool { + false + } +} diff --git a/frame/evm/precompile/account-mapping/src/tests.rs b/frame/evm/precompile/account-mapping/src/tests.rs new file mode 100644 index 00000000..b6ad0fa3 --- /dev/null +++ b/frame/evm/precompile/account-mapping/src/tests.rs @@ -0,0 +1,421 @@ +//! Unit tests for `pallet-evm-precompile-account-mapping`. +//! +//! Each test exercises the precompile through `MockHandle` so that no EVM runner +//! is involved. Storage interactions use the in-memory `TestExternalities` built +//! by `mock::new_test_ext()`. + +use fp_evm::{ExitError, ExitSucceed, Precompile, PrecompileFailure}; +use sp_core::H160; + +use crate::{ + mock::{new_test_ext, MockHandle}, + AccountMappingPrecompile, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Builds ABI-encoded `string` / `bytes` argument (offset pointer + length + data). +/// The encoded slice is suitable for appending after a function selector. +/// +/// Layout (relative to the start of params, i.e. after the 4-byte selector): +/// [0..32] offset = 0x20 (string data starts at byte 32 of params) +/// [32..64] length +/// [64..] UTF-8 bytes zero-padded to the next 32-byte boundary +fn abi_encode_string(s: &[u8]) -> Vec { + let padded_len = (s.len() + 31) & !31; + let mut out = vec![0u8; 64 + padded_len]; + // offset = 0x20 = 32 + out[31] = 0x20; + // length + let len_bytes = (s.len() as u64).to_be_bytes(); + out[56..64].copy_from_slice(&len_bytes); + // data + out[64..64 + s.len()].copy_from_slice(s); + out +} + +/// Builds params for `hasPrivateLink(string alias, bytes32 commitment)`. +/// +/// ABI layout (after selector): +/// [0..32] offset to string = 0x40 (64) +/// [32..64] bytes32 commitment (static) +/// [64..96] string length +/// [96..] string bytes zero-padded to 32-byte boundary +fn abi_encode_has_private_link(alias: &[u8], commitment: &[u8; 32]) -> Vec { + let padded_len = (alias.len() + 31) & !31; + let mut params = vec![0u8; 96 + padded_len]; + // offset to string = 64 (0x40) + params[31] = 0x40; + // commitment at [32..64] + params[32..64].copy_from_slice(commitment); + // string length at [64..96] + let len_bytes = (alias.len() as u64).to_be_bytes(); + params[88..96].copy_from_slice(&len_bytes); + // string data + params[96..96 + alias.len()].copy_from_slice(alias); + params +} + +// ───────────────────────────────────────────────────────────────────────────── +// Selector routing +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn selector_too_short_returns_error() { + new_test_ext().execute_with(|| { + // Only 3 bytes ─ selector requires at least 4 + let mut handle = MockHandle::new(vec![0xd0, 0x31, 0x49]); + let result = AccountMappingPrecompile::::execute(&mut handle); + assert!( + matches!( + result, + Err(PrecompileFailure::Error { + exit_status: ExitError::Other(_) + }) + ), + "expected error for short input" + ); + }); +} + +#[test] +fn unknown_selector_returns_error() { + new_test_ext().execute_with(|| { + let mut handle = MockHandle::new(vec![0xff, 0xff, 0xff, 0xff]); + let result = AccountMappingPrecompile::::execute(&mut handle); + assert!( + matches!( + result, + Err(PrecompileFailure::Error { + exit_status: ExitError::Other(_) + }) + ), + "expected error for unknown selector" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Read-only: resolveAlias +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn resolve_alias_not_found_returns_zero_addresses() { + new_test_ext().execute_with(|| { + // resolveAlias("noexist") + let mut input = vec![0xd0, 0x31, 0x49, 0xab]; + input.extend_from_slice(&abi_encode_string(b"noexist")); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + let out = result.expect("resolveAlias must succeed"); + assert_eq!(out.exit_status, ExitSucceed::Returned); + // Two H160 zero addresses → 64 zero bytes + assert_eq!(out.output.len(), 64); + assert_eq!(out.output, vec![0u8; 64]); + }); +} + +#[test] +fn resolve_alias_abi_too_short_returns_error() { + new_test_ext().execute_with(|| { + // Selector only — no ABI params at all + let mut handle = MockHandle::new(vec![0xd0, 0x31, 0x49, 0xab]); + let result = AccountMappingPrecompile::::execute(&mut handle); + assert!(result.is_err(), "resolveAlias with no params must fail"); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Read-only: getAliasOf +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn get_alias_of_no_alias_returns_empty_bytes() { + new_test_ext().execute_with(|| { + // getAliasOf(H160::zero()) ─ address not registered + let mut input = vec![0x7a, 0x0e, 0xd6, 0x2c]; + input.extend_from_slice(&[0u8; 32]); // zero-padded H160 + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + let out = result.expect("getAliasOf must succeed"); + assert_eq!(out.exit_status, ExitSucceed::Returned); + assert_eq!(out.output.len(), 64, "empty ABI bytes encoding is 64 bytes"); + // Offset slot = 0x20 + assert_eq!(out.output[31], 0x20); + // Length = 0 + let length = u64::from_be_bytes(out.output[56..64].try_into().unwrap()); + assert_eq!(length, 0); + }); +} + +#[test] +fn get_alias_of_abi_too_short_returns_error() { + new_test_ext().execute_with(|| { + // Only 20 bytes of params instead of 32 + let mut input = vec![0x7a, 0x0e, 0xd6, 0x2c]; + input.extend_from_slice(&[0u8; 20]); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + assert!(result.is_err(), "getAliasOf with short params must fail"); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Read-only: hasPrivateLink +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn has_private_link_returns_false_when_not_registered() { + new_test_ext().execute_with(|| { + // hasPrivateLink("alice", [0;32]) + let commitment = [0u8; 32]; + let mut input = vec![0x47, 0xe0, 0x5c, 0x6c]; + input.extend_from_slice(&abi_encode_has_private_link(b"alice", &commitment)); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + let out = result.expect("hasPrivateLink must succeed"); + assert_eq!(out.exit_status, ExitSucceed::Returned); + assert_eq!(out.output.len(), 32); + assert_eq!(out.output[31], 0, "expected false"); + }); +} + +#[test] +fn has_private_link_abi_too_short_returns_error() { + new_test_ext().execute_with(|| { + // Only 30 bytes after selector → fails "hasPrivateLink: input too short" + let mut input = vec![0x47, 0xe0, 0x5c, 0x6c]; + input.extend_from_slice(&[0u8; 30]); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + assert!( + result.is_err(), + "hasPrivateLink with short params must fail" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// State-changing: mapAccount +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn map_account_dispatches_call() { + new_test_ext().execute_with(|| { + // mapAccount() — no ABI params, just the 4-byte selector + let mut handle = MockHandle::new(vec![0xdc, 0xa4, 0x9d, 0x0e]); + let result = AccountMappingPrecompile::::execute(&mut handle); + + // The precompile must reach the dispatch stage; it must NOT fail with an + // ABI decode or routing error. + let failed_abi = match &result { + Err(PrecompileFailure::Error { + exit_status: ExitError::Other(msg), + }) => { + msg.starts_with("input too short") + || msg.starts_with("ABI:") + || msg.starts_with("unknown selector") + } + _ => false, + }; + assert!( + !failed_abi, + "map_account should not fail with ABI/selector error" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// State-changing: registerAlias +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn register_alias_ok() { + new_test_ext().execute_with(|| { + // registerAlias("alice") — caller has 1_000_000 ≥ AliasDeposit (100) + let mut input = vec![0x2f, 0x88, 0x39, 0xc3]; + input.extend_from_slice(&abi_encode_string(b"alice")); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + assert!( + result.is_ok(), + "registerAlias with funded account must succeed: {result:?}" + ); + assert_eq!(result.unwrap().exit_status, ExitSucceed::Stopped); + }); +} + +#[test] +fn register_alias_abi_too_short_returns_error() { + new_test_ext().execute_with(|| { + // Only 10 bytes after selector — ABI slot occupies 32 bytes minimum + let mut input = vec![0x2f, 0x88, 0x39, 0xc3]; + input.extend_from_slice(&[0u8; 10]); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + assert!( + result.is_err(), + "registerAlias with truncated ABI must fail" + ); + }); +} + +#[test] +fn register_alias_then_resolve_returns_correct_address() { + new_test_ext().execute_with(|| { + let caller_h160 = crate::mock::caller(); + + // 1. Register "bob" for caller() + let mut input = vec![0x2f, 0x88, 0x39, 0xc3]; + input.extend_from_slice(&abi_encode_string(b"bob")); + let mut handle = MockHandle::new(input); + AccountMappingPrecompile::::execute(&mut handle) + .expect("registerAlias must succeed"); + + // 2. Resolve "bob" + let mut input = vec![0xd0, 0x31, 0x49, 0xab]; + input.extend_from_slice(&abi_encode_string(b"bob")); + let mut handle = MockHandle::new(input); + let out = AccountMappingPrecompile::::execute(&mut handle) + .expect("resolveAlias must succeed"); + + // First 32-byte slot encodes the owner H160 (bytes 12..32) + let resolved = H160::from_slice(&out.output[12..32]); + assert_eq!( + resolved, caller_h160, + "resolved address must match the registrant" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// State-changing: registerPrivateLink / removePrivateLink +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn register_private_link_ok() { + new_test_ext().execute_with(|| { + // First register an alias (required by the pallet) + let mut input = vec![0x2f, 0x88, 0x39, 0xc3]; + input.extend_from_slice(&abi_encode_string(b"link_test")); + let mut handle = MockHandle::new(input); + AccountMappingPrecompile::::execute(&mut handle) + .expect("registerAlias must succeed"); + + // registerPrivateLink(chainId=1, commitment=[0;32]) + let mut input = vec![0xc0, 0x4e, 0x98, 0xf4]; + let mut params = vec![0u8; 64]; + // chainId = 1 in last 4 bytes of first slot + params[28..32].copy_from_slice(&1u32.to_be_bytes()); + // commitment = [0;32] (already zero) + input.extend_from_slice(¶ms); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + assert!( + result.is_ok(), + "registerPrivateLink must succeed: {result:?}" + ); + }); +} + +#[test] +fn remove_private_link_ok() { + new_test_ext().execute_with(|| { + let commitment = [0u8; 32]; + + // 0. Register an alias first (required by the pallet) + let mut input = vec![0x2f, 0x88, 0x39, 0xc3]; + input.extend_from_slice(&abi_encode_string(b"rm_link_test")); + let mut handle = MockHandle::new(input); + AccountMappingPrecompile::::execute(&mut handle) + .expect("registerAlias must succeed"); + + // 1. Register the private link + let mut input = vec![0xc0, 0x4e, 0x98, 0xf4]; + let mut params = vec![0u8; 64]; + params[28..32].copy_from_slice(&1u32.to_be_bytes()); + input.extend_from_slice(¶ms); + let mut handle = MockHandle::new(input); + AccountMappingPrecompile::::execute(&mut handle) + .expect("registerPrivateLink must succeed"); + + // 2. Now remove it + let mut input = vec![0xdf, 0xd8, 0xb5, 0x7e]; + input.extend_from_slice(&commitment); + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + assert!(result.is_ok(), "removePrivateLink must succeed: {result:?}"); + }); +} + +#[test] +fn remove_private_link_abi_too_short_returns_error() { + new_test_ext().execute_with(|| { + // 20 bytes after selector instead of 32 + let mut input = vec![0xdf, 0xd8, 0xb5, 0x7e]; + input.extend_from_slice(&[0u8; 20]); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + assert!( + result.is_err(), + "removePrivateLink with short params must fail" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// State-changing: putAliasOnSale — price zero is rejected by pallet +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn put_alias_on_sale_no_alias_returns_dispatch_error() { + new_test_ext().execute_with(|| { + // putAliasOnSale(price=1, allowedBuyers=[]) without having an alias first + let mut input = vec![0x32, 0x09, 0x11, 0x92]; + let mut params = vec![0u8; 96]; + // price = 1 + params[31] = 0x01; + // offset to address[] = 0x40 = 64 + params[63] = 0x40; + // array length = 0 (at params[64..96]) + input.extend_from_slice(¶ms); + + let mut handle = MockHandle::new(input); + let result = AccountMappingPrecompile::::execute(&mut handle); + + // Must fail with a dispatch error (caller has no alias), NOT an ABI error + match &result { + Err(PrecompileFailure::Error { + exit_status: ExitError::Other(msg), + }) => { + let is_abi_error = msg.starts_with("input too short") + || msg.starts_with("ABI:") + || msg.starts_with("unknown selector"); + assert!( + !is_abi_error, + "expected dispatch error, got ABI error: {msg}" + ); + } + Ok(_) => panic!("expected dispatch error, but got Ok"), + Err(other) => panic!("unexpected failure variant: {other:?}"), + } + }); +} diff --git a/frame/evm/precompile/shielded-pool/Cargo.toml b/frame/evm/precompile/shielded-pool/Cargo.toml new file mode 100644 index 00000000..5174570d --- /dev/null +++ b/frame/evm/precompile/shielded-pool/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "pallet-evm-precompile-shielded-pool" +version = "0.1.0" +authors = { workspace = true } +edition = "2021" +description = "EVM Precompile for Orbinum Shielded Pool Pallet." +license = "Apache-2.0" + +[dependencies] +# Substrate CORE +sp-core = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +# Substrate FRAME +frame-support = { workspace = true } +frame-system = { workspace = true } + +# Frontier EVM +fp-evm = { workspace = true } +pallet-evm = { workspace = true } +precompile-utils = { workspace = true } + +# Orbinum Pallets +pallet-shielded-pool = { workspace = true } + +[features] +default = ["std"] +std = [ + "sp-core/std", + "sp-std/std", + "sp-runtime/std", + "frame-support/std", + "frame-system/std", + "fp-evm/std", + "pallet-evm/std", + "precompile-utils/std", + "pallet-shielded-pool/std", +] + +[dev-dependencies] +frame-system = { workspace = true, features = ["default"] } +pallet-balances = { workspace = true, features = ["default"] } +pallet-relayer = { workspace = true, features = ["default"] } +pallet-timestamp = { workspace = true, features = ["default"] } +pallet-zk-verifier = { workspace = true, features = ["default"] } +scale-codec = { workspace = true, features = ["derive", "std"] } +scale-info = { workspace = true, features = ["derive", "std"] } +sp-core = { workspace = true, features = ["default"] } +sp-io = { workspace = true, features = ["default"] } +sp-runtime = { workspace = true, features = ["default"] } diff --git a/frame/evm/precompile/shielded-pool/src/abi.rs b/frame/evm/precompile/shielded-pool/src/abi.rs new file mode 100644 index 00000000..591cf078 --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/abi.rs @@ -0,0 +1,129 @@ +//! Pure ABI decoding helpers for the shielded-pool precompile. +//! +//! All functions are stateless free functions — no FRAME generics, no pallet types. +//! They operate directly on raw ABI-encoded byte slices and return typed Rust values. + +use alloc::vec::Vec; + +use fp_evm::{ExitError, PrecompileFailure}; +use sp_core::U256; + +// ───────────────────────────────────────────────────────────────────────────── +// Scalar decoders +// ───────────────────────────────────────────────────────────────────────────── + +/// Decodes a `uint32` from a 32-byte ABI slot (big-endian, right-aligned). +pub fn decode_u32(slot: &[u8]) -> Result { + if slot.len() < 32 { + return Err(abi_error("uint32 slot too short")); + } + Ok(U256::from_big_endian(&slot[0..32]).low_u32()) +} + +/// Reads the 32-byte value at `params[slot_start..slot_start+32]` verbatim. +pub fn read_bytes32(params: &[u8], slot_start: usize) -> Result<[u8; 32], PrecompileFailure> { + if slot_start + 32 > params.len() { + return Err(abi_error("bytes32 slot out of bounds")); + } + params[slot_start..slot_start + 32] + .try_into() + .map_err(|_| abi_error("bytes32 copy failed")) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Dynamic-type decoders +// ───────────────────────────────────────────────────────────────────────────── + +/// Decodes a dynamic `bytes` value. +/// +/// `slot_start` is the byte offset inside `params` where the 32-byte offset +/// pointer for the bytes value lives (standard ABI head encoding). +pub fn decode_bytes_at_slot( + params: &[u8], + slot_start: usize, +) -> Result, PrecompileFailure> { + let offset = read_offset(params, slot_start)?; + let length = read_length(params, offset)?; + let data_start = offset + 32; + if data_start + length > params.len() { + return Err(abi_error("bytes data out of bounds")); + } + Ok(params[data_start..data_start + length].to_vec()) +} + +/// Decodes a `bytes32[]` whose offset pointer lives at `slot_start` in `params`. +pub fn decode_bytes32_array_at_slot( + params: &[u8], + slot_start: usize, +) -> Result, PrecompileFailure> { + let offset = read_offset(params, slot_start)?; + let count = read_length(params, offset)?; + let data_start = offset + 32; + if data_start + count * 32 > params.len() { + return Err(abi_error("bytes32[] data out of bounds")); + } + let mut items = Vec::with_capacity(count); + for i in 0..count { + let s = data_start + i * 32; + let elem: [u8; 32] = params[s..s + 32].try_into().unwrap(); + items.push(elem); + } + Ok(items) +} + +/// Decodes a `bytes[]` whose offset pointer lives at `slot_start` in `params`. +pub fn decode_bytes_array_at_slot( + params: &[u8], + slot_start: usize, +) -> Result>, PrecompileFailure> { + let array_offset = read_offset(params, slot_start)?; + let count = read_length(params, array_offset)?; + // Each element has a relative offset pointer from the start of the array-data region + // (= array_offset + 32, which is right after the length word). + let data_base = array_offset + 32; + + let mut result = Vec::with_capacity(count); + for i in 0..count { + let rel_offset_pos = data_base + i * 32; + if rel_offset_pos + 32 > params.len() { + return Err(abi_error("bytes[] element offset out of bounds")); + } + let rel_offset = + U256::from_big_endian(¶ms[rel_offset_pos..rel_offset_pos + 32]).low_u32() as usize; + let abs_offset = data_base + rel_offset; + let elem_len = read_length(params, abs_offset)?; + let elem_start = abs_offset + 32; + if elem_start + elem_len > params.len() { + return Err(abi_error("bytes[] element data out of bounds")); + } + result.push(params[elem_start..elem_start + elem_len].to_vec()); + } + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Reads an ABI offset (usize) from a 32-byte slot at `slot_start` in `params`. +fn read_offset(params: &[u8], slot_start: usize) -> Result { + if slot_start + 32 > params.len() { + return Err(abi_error("slot out of bounds")); + } + Ok(U256::from_big_endian(¶ms[slot_start..slot_start + 32]).low_u32() as usize) +} + +/// Reads an ABI length word (usize) from `params[offset..offset+32]`. +fn read_length(params: &[u8], offset: usize) -> Result { + if offset + 32 > params.len() { + return Err(abi_error("length word out of bounds")); + } + Ok(U256::from_big_endian(¶ms[offset..offset + 32]).low_u32() as usize) +} + +/// Constructs a `PrecompileFailure::Error` with the given message. +fn abi_error(msg: &'static str) -> PrecompileFailure { + PrecompileFailure::Error { + exit_status: ExitError::Other(msg.into()), + } +} diff --git a/frame/evm/precompile/shielded-pool/src/calls/mod.rs b/frame/evm/precompile/shielded-pool/src/calls/mod.rs new file mode 100644 index 00000000..d0f4e00f --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/calls/mod.rs @@ -0,0 +1,13 @@ +//! Per-function call decoders for the shielded-pool precompile. +//! +//! Each sub-module owns: +//! - the **ABI selector** for the corresponding Solidity function, +//! - the **`decode`** function that turns raw `input` bytes into a +//! `pallet_shielded_pool::Call`. +//! +//! The precompile router in `lib.rs` only needs to match on `SELECTOR`s and +//! forward to the appropriate `decode`, then hand the call to `dispatch`. + +pub mod private_transfer; +pub mod shield; +pub mod unshield; diff --git a/frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs b/frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs new file mode 100644 index 00000000..837b314b --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/calls/private_transfer.rs @@ -0,0 +1,141 @@ +//! ABI decoding and call construction for +//! `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256)`. +//! +//! ## Selector +//! `keccak256("privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256)")[0..4]` +//! = `0x8c0f5d24` +//! +//! ## ABI layout (`input[4..]`) — standard head/tail encoding +//! | Slot (bytes) | Type | Field | +//! |-------------|-------------|--------------------| +//! | 0..32 | `uint256` | offset → `proof` | +//! | 32..64 | `bytes32` | `merkle_root` | +//! | 64..96 | `uint256` | offset → nullifiers| +//! | 96..128 | `uint256` | offset → commitments| +//! | 128..160 | `uint256` | offset → memos | +//! | 160..192 | `uint32` | `asset_id` | +//! | 192..224 | `uint256` | `fee` | +//! +//! `relayer` is derived from `handle.context().caller` — not part of the ABI. + +use fp_evm::{ExitError, PrecompileFailure, PrecompileHandle}; +use frame_support::BoundedVec; +use sp_core::U256; + +use crate::abi; + +/// `keccak256("privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256)")[0..4]` +pub const SELECTOR: [u8; 4] = [0x8c, 0x0f, 0x5d, 0x24]; + +/// Maximum byte length of a serialised Groth16 proof accepted by the pallet. +const MAX_PROOF_LEN: u32 = 512; +/// Maximum number of input nullifiers / output commitments in a single transfer. +const MAX_NOTES: u32 = 2; + +/// Decodes the ABI-encoded `input` and returns a ready-to-dispatch +/// `private_transfer` call. +/// +/// `handle.context().caller` is forwarded as the `relayer` field so +/// `pallet-relayer` can route fees to the registered Substrate account. +pub fn decode( + handle: &impl PrecompileHandle, + input: &[u8], +) -> Result, PrecompileFailure> +where + T: pallet_shielded_pool::Config, + pallet_shielded_pool::BalanceOf: TryFrom, +{ + let params = &input[4..]; + if params.len() < 224 { + return Err(err("privateTransfer: input too short")); + } + + let proof: BoundedVec> = + abi::decode_bytes_at_slot(params, 0)? + .try_into() + .map_err(|_| err("privateTransfer: proof too long"))?; + + if proof.is_empty() { + return Err(err("privateTransfer: proof must be non-empty")); + } + + let merkle_root: pallet_shielded_pool::Hash = abi::read_bytes32(params, 32)?; + + let nullifiers: BoundedVec< + pallet_shielded_pool::Nullifier, + frame_support::traits::ConstU32, + > = abi::decode_bytes32_array_at_slot(params, 64)? + .into_iter() + .map(pallet_shielded_pool::Nullifier::from) + .collect::>() + .try_into() + .map_err(|_| err("privateTransfer: too many nullifiers"))?; + + let commitments: BoundedVec< + pallet_shielded_pool::Commitment, + frame_support::traits::ConstU32, + > = abi::decode_bytes32_array_at_slot(params, 96)? + .into_iter() + .map(pallet_shielded_pool::Commitment::from) + .collect::>() + .try_into() + .map_err(|_| err("privateTransfer: too many commitments"))?; + + let encrypted_memos: BoundedVec< + pallet_shielded_pool::FrameEncryptedMemo, + frame_support::traits::ConstU32, + > = abi::decode_bytes_array_at_slot(params, 128)? + .into_iter() + .map(|m| { + pallet_shielded_pool::FrameEncryptedMemo::new(m) + .map_err(|_| err("privateTransfer: memo too long")) + }) + .collect::, _>>()? + .try_into() + .map_err(|_| err("privateTransfer: too many memos"))?; + + // Structural consistency: at least one real input note is required, and the three + // parallel arrays must have the same length. The ZK proof enforces value balance, + // but mismatched array lengths would produce a nonsensical call that reaches the + // pallet unnecessarily. + if nullifiers.is_empty() { + return Err(err("privateTransfer: at least one nullifier required")); + } + if nullifiers.len() != commitments.len() { + return Err(err("privateTransfer: nullifier/commitment count mismatch")); + } + if commitments.len() != encrypted_memos.len() { + return Err(err("privateTransfer: commitment/memo count mismatch")); + } + + let asset_id = abi::decode_u32(¶ms[160..192])?; + + let fee: pallet_shielded_pool::BalanceOf = { + let raw: u128 = U256::from_big_endian(¶ms[192..224]) + .try_into() + .map_err(|_| err("privateTransfer: fee overflow"))?; + raw.try_into() + .map_err(|_| err("privateTransfer: fee conversion failed"))? + }; + + let relayer = Some(handle.context().caller); + + Ok(pallet_shielded_pool::Call::::private_transfer { + proof, + merkle_root, + nullifiers, + commitments, + encrypted_memos, + asset_id, + fee, + relayer, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── + +fn err(msg: &'static str) -> PrecompileFailure { + PrecompileFailure::Error { + exit_status: ExitError::Other(msg.into()), + } +} diff --git a/frame/evm/precompile/shielded-pool/src/calls/shield.rs b/frame/evm/precompile/shielded-pool/src/calls/shield.rs new file mode 100644 index 00000000..def93fb5 --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/calls/shield.rs @@ -0,0 +1,78 @@ +//! ABI decoding and call construction for `shield(uint32,bytes32,bytes)`. +//! +//! ## Selector +//! `keccak256("shield(uint32,bytes32,bytes)")[0..4]` = `0x9feb22ea` +//! +//! ## ABI layout (`input[4..]`) +//! | Slot (bytes) | Type | Field | +//! |-------------|-----------|-----------------| +//! | 0..32 | `uint32` | `asset_id` | +//! | 32..64 | `bytes32` | `commitment` | +//! | 64..96 | `uint256` | offset → memo | +//! | at offset | `bytes` | `encrypted_memo`| +//! +//! The token **amount** is read from `msg.value` — the EVM executor transfers it to +//! the precompile's address before `execute` runs, so no explicit amount slot is needed. + +use fp_evm::{ExitError, PrecompileFailure, PrecompileHandle}; + +use crate::abi; + +/// `keccak256("shield(uint32,bytes32,bytes)")[0..4]` +pub const SELECTOR: [u8; 4] = [0x9f, 0xeb, 0x22, 0xea]; + +/// Decodes the ABI-encoded `input` and returns a ready-to-dispatch `shield` call. +/// +/// `handle` is consulted only for `apparent_value` (the `msg.value` ETH amount). +pub fn decode( + handle: &impl PrecompileHandle, + input: &[u8], +) -> Result, PrecompileFailure> +where + T: pallet_shielded_pool::Config, + pallet_shielded_pool::BalanceOf: TryFrom, +{ + let params = &input[4..]; + if params.len() < 96 { + return Err(err("shield: input too short")); + } + + let asset_id = abi::decode_u32(¶ms[0..32])?; + + // Reject zero-value calls at the precompile boundary (defense-in-depth; + // the pallet also rejects via MinShieldAmount, but this produces a cleaner + // error before reaching the dispatch layer). + let apparent_value = handle.context().apparent_value; + if apparent_value.is_zero() { + return Err(err("shield: amount must be non-zero")); + } + + let amount: pallet_shielded_pool::BalanceOf = { + let raw: u128 = apparent_value + .try_into() + .map_err(|_| err("shield: msg.value overflow"))?; + raw.try_into() + .map_err(|_| err("shield: amount conversion failed"))? + }; + + let commitment = pallet_shielded_pool::Commitment::from(abi::read_bytes32(params, 32)?); + + let memo_bytes = abi::decode_bytes_at_slot(params, 64)?; + let encrypted_memo = pallet_shielded_pool::FrameEncryptedMemo::new(memo_bytes) + .map_err(|_| err("shield: memo too long or wrong size"))?; + + Ok(pallet_shielded_pool::Call::::shield { + asset_id, + amount, + commitment, + encrypted_memo, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── + +fn err(msg: &'static str) -> PrecompileFailure { + PrecompileFailure::Error { + exit_status: ExitError::Other(msg.into()), + } +} diff --git a/frame/evm/precompile/shielded-pool/src/calls/unshield.rs b/frame/evm/precompile/shielded-pool/src/calls/unshield.rs new file mode 100644 index 00000000..05b1b04e --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/calls/unshield.rs @@ -0,0 +1,126 @@ +//! ABI decoding and call construction for +//! `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32)`. +//! +//! ## Selector +//! `keccak256("unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32)")[0..4]` +//! = `0xd21d9a79` +//! +//! ## ABI layout (`input[4..]`) +//! | Slot (bytes) | Type | Field | +//! |-------------|-----------|-----------------| +//! | 0..32 | `uint256` | offset → `proof`| +//! | 32..64 | `bytes32` | `merkle_root` | +//! | 64..96 | `bytes32` | `nullifier` | +//! | 96..128 | `uint32` | `asset_id` | +//! | 128..160 | `uint256` | `amount` | +//! | 160..192 | `bytes32` | `recipient` (AccountId32) | +//! | 192..224 | `uint256` | `fee` | +//! | 224..256 | `bytes32` | `change_commitment` | +//! +//! `recipient` is an `AccountId32` encoded as a 32-byte ABI `bytes32` slot. +//! This can be a Substrate-native account or the `AccountId32` derived from +//! an H160 address (`H160 ++ [0x00; 12]`). +//! +//! `change_commitment` is `[0u8; 32]` for a total unshield (no change note). +//! For a partial unshield it is `NoteCommitment(change_value, asset_id, change_owner_pk, change_blinding)`. +//! +//! `relayer` is derived from `handle.context().caller` — not part of the ABI. + +use fp_evm::{ExitError, PrecompileFailure, PrecompileHandle}; +use frame_support::BoundedVec; +use sp_core::U256; + +use crate::abi; + +/// `keccak256("unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32)")[0..4]` +pub const SELECTOR: [u8; 4] = [0xd2, 0x1d, 0x9a, 0x79]; + +/// Maximum byte length of a serialised Groth16 proof accepted by the pallet. +const MAX_PROOF_LEN: u32 = 512; + +/// Decodes the ABI-encoded `input` and returns a ready-to-dispatch `unshield` call. +/// +/// `handle.context().caller` is forwarded as the `relayer` field so +/// `pallet-relayer` can route fees to the registered Substrate account. +pub fn decode( + handle: &impl PrecompileHandle, + input: &[u8], +) -> Result, PrecompileFailure> +where + T: pallet_shielded_pool::Config, + pallet_shielded_pool::BalanceOf: TryFrom, + ::AccountId: From<[u8; 32]>, +{ + let params = &input[4..]; + if params.len() < 256 { + return Err(err("unshield: input too short")); + } + + let proof: BoundedVec> = + abi::decode_bytes_at_slot(params, 0)? + .try_into() + .map_err(|_| err("unshield: proof too long"))?; + + if proof.is_empty() { + return Err(err("unshield: proof must be non-empty")); + } + + let merkle_root: pallet_shielded_pool::Hash = abi::read_bytes32(params, 32)?; + + let nullifier = pallet_shielded_pool::Nullifier::from(abi::read_bytes32(params, 64)?); + + let asset_id = abi::decode_u32(¶ms[96..128])?; + + // Reject zero-amount unshield early (defense-in-depth before dispatch). + let amount_u256 = U256::from_big_endian(¶ms[128..160]); + if amount_u256.is_zero() { + return Err(err("unshield: amount must be non-zero")); + } + let amount: pallet_shielded_pool::BalanceOf = { + let raw: u128 = amount_u256 + .try_into() + .map_err(|_| err("unshield: amount overflow"))?; + raw.try_into() + .map_err(|_| err("unshield: amount conversion failed"))? + }; + + // Reject the zero AccountId32 (all-zeros): transferring to this address + // permanently destroys tokens with no possibility of recovery. + let recipient_bytes = abi::read_bytes32(params, 160)?; + if recipient_bytes == [0u8; 32] { + return Err(err("unshield: recipient must not be the zero address")); + } + let recipient: ::AccountId = recipient_bytes.into(); + + let fee: pallet_shielded_pool::BalanceOf = { + let raw: u128 = U256::from_big_endian(¶ms[192..224]) + .try_into() + .map_err(|_| err("unshield: fee overflow"))?; + raw.try_into() + .map_err(|_| err("unshield: fee conversion failed"))? + }; + + let change_commitment: pallet_shielded_pool::Hash = abi::read_bytes32(params, 224)?; + + let relayer = Some(handle.context().caller); + + Ok(pallet_shielded_pool::Call::::unshield { + proof, + merkle_root, + nullifier, + asset_id, + amount, + recipient, + fee, + change_commitment, + relayer, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── + +fn err(msg: &'static str) -> PrecompileFailure { + PrecompileFailure::Error { + exit_status: ExitError::Other(msg.into()), + } +} diff --git a/frame/evm/precompile/shielded-pool/src/dispatch.rs b/frame/evm/precompile/shielded-pool/src/dispatch.rs new file mode 100644 index 00000000..275175ef --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/dispatch.rs @@ -0,0 +1,103 @@ +//! Dispatch helpers for the shielded-pool precompile. +//! +//! Two dispatch modes mirror the two pallet extrinsic origin checks: +//! +//! - [`from_self`] — dispatches with the **precompile's own address** as signed origin. +//! Used for `shield` (payable): the EVM executor already transferred `msg.value` from +//! the caller to the precompile address, so the pallet moves those funds from the +//! precompile account to the pool without touching the caller a second time. +//! +//! - [`unsigned`] — dispatches with `None` origin (`ensure_none`). +//! Used for `private_transfer` and `unshield`, where a ZK proof authenticates the +//! operation and no transaction signer is needed. + +use alloc::format; + +use fp_evm::{ + ExitError, ExitSucceed, PrecompileFailure, PrecompileHandle, PrecompileOutput, PrecompileResult, +}; +use frame_support::dispatch::GetDispatchInfo; +use pallet_evm::{AddressMapping, GasWeightMapping}; +use sp_runtime::traits::Dispatchable; + +/// Dispatches `call` with the **precompile's own address** as signed origin. +/// +/// Gas cost is derived from the call's dispatch weight via [`GasWeightMapping`]. +pub fn from_self( + handle: &mut impl PrecompileHandle, + call: pallet_shielded_pool::Call, +) -> PrecompileResult +where + T: pallet_evm::Config + pallet_shielded_pool::Config, + ::RuntimeCall: Dispatchable + + GetDispatchInfo + + From>, + <::RuntimeCall as Dispatchable>::RuntimeOrigin: + From::AccountId>>, + <::RuntimeCall as Dispatchable>::PostInfo: core::fmt::Debug, + pallet_evm::AccountIdOf: Into<::AccountId>, +{ + let runtime_call = <::RuntimeCall as From< + pallet_shielded_pool::Call, + >>::from(call); + let gas_cost = + T::GasWeightMapping::weight_to_gas(runtime_call.get_dispatch_info().total_weight()); + handle.record_cost(gas_cost)?; + + let self_account: ::AccountId = + T::AddressMapping::into_account_id(handle.context().address).into(); + let origin = <::RuntimeCall as Dispatchable>::RuntimeOrigin::from( + Some(self_account), + ); + + dispatch(runtime_call, origin) +} + +/// Dispatches `call` with `None` origin (`ensure_none`). +/// +/// Gas cost is derived from the call's dispatch weight via [`GasWeightMapping`]. +pub fn unsigned( + handle: &mut impl PrecompileHandle, + call: pallet_shielded_pool::Call, +) -> PrecompileResult +where + T: pallet_evm::Config + pallet_shielded_pool::Config, + ::RuntimeCall: Dispatchable + + GetDispatchInfo + + From>, + <::RuntimeCall as Dispatchable>::RuntimeOrigin: + From::AccountId>>, + <::RuntimeCall as Dispatchable>::PostInfo: core::fmt::Debug, +{ + let runtime_call = <::RuntimeCall as From< + pallet_shielded_pool::Call, + >>::from(call); + let gas_cost = + T::GasWeightMapping::weight_to_gas(runtime_call.get_dispatch_info().total_weight()); + handle.record_cost(gas_cost)?; + + let origin = + <::RuntimeCall as Dispatchable>::RuntimeOrigin::from(None); + + dispatch(runtime_call, origin) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal +// ───────────────────────────────────────────────────────────────────────────── + +fn dispatch(call: C, origin: C::RuntimeOrigin) -> PrecompileResult +where + C: Dispatchable, + C::PostInfo: core::fmt::Debug, +{ + match call.dispatch(origin) { + Ok(_) => Ok(PrecompileOutput { + exit_status: ExitSucceed::Stopped, + output: Default::default(), + }), + Err(e) => Err(PrecompileFailure::Error { + exit_status: ExitError::Other(format!("{e:?}").into()), + }), + } +} diff --git a/frame/evm/precompile/shielded-pool/src/lib.rs b/frame/evm/precompile/shielded-pool/src/lib.rs new file mode 100644 index 00000000..556583a4 --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/lib.rs @@ -0,0 +1,77 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +pub(crate) mod abi; +pub(crate) mod calls; +pub(crate) mod dispatch; + +use core::marker::PhantomData; + +use fp_evm::{ExitError, Precompile, PrecompileFailure, PrecompileHandle, PrecompileResult}; +use frame_support::dispatch::GetDispatchInfo; +use sp_runtime::traits::Dispatchable; + +/// EVM precompile that bridges Solidity calls into `pallet_shielded_pool` extrinsics. +/// +/// Three functions are exposed, each identified by a 4-byte ABI selector: +/// +/// | Selector | Solidity signature | +/// |-------------|---------------------------------------------------------------------------| +/// | `0x9feb22ea` | `shield(uint32,bytes32,bytes)` — payable, amount = `msg.value` | +/// | `0x8c0f5d24` | `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256)` | +/// | `0x47fc44a2` | `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256)` | +/// +/// Selector computation: `bytes4(keccak256("functionName(argTypes)"))`. +/// Verify with: `node -e "const {ethers}=require('ethers'); console.log(ethers.id('sig').slice(0,10))"` +pub struct ShieldedPoolPrecompile(PhantomData); + +impl Precompile for ShieldedPoolPrecompile +where + T: pallet_evm::Config + pallet_shielded_pool::Config, + ::RuntimeCall: Dispatchable + + GetDispatchInfo + + From>, + <::RuntimeCall as Dispatchable>::RuntimeOrigin: + From::AccountId>>, + <::RuntimeCall as Dispatchable>::PostInfo: core::fmt::Debug, + pallet_evm::AccountIdOf: Into<::AccountId>, + pallet_shielded_pool::BalanceOf: TryFrom, + ::AccountId: From<[u8; 32]>, +{ + fn execute(handle: &mut impl PrecompileHandle) -> PrecompileResult { + let input = handle.input().to_vec(); + + if input.len() < 4 { + return Err(PrecompileFailure::Error { + exit_status: ExitError::Other("input too short: missing selector".into()), + }); + } + + let selector: [u8; 4] = input[0..4].try_into().unwrap(); + + match selector { + calls::shield::SELECTOR => { + let call = calls::shield::decode::(handle, &input)?; + dispatch::from_self::(handle, call) + } + calls::private_transfer::SELECTOR => { + let call = calls::private_transfer::decode::(handle, &input)?; + dispatch::unsigned::(handle, call) + } + calls::unshield::SELECTOR => { + let call = calls::unshield::decode::(handle, &input)?; + dispatch::unsigned::(handle, call) + } + _ => Err(PrecompileFailure::Error { + exit_status: ExitError::Other("unknown selector".into()), + }), + } + } +} + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; diff --git a/frame/evm/precompile/shielded-pool/src/mock.rs b/frame/evm/precompile/shielded-pool/src/mock.rs new file mode 100644 index 00000000..8bf90412 --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/mock.rs @@ -0,0 +1,404 @@ +//! Test mock for `pallet-evm-precompile-shielded-pool`. + +use frame_support::{derive_impl, parameter_types, traits::Get, weights::Weight, PalletId}; +use pallet_evm::{ + AddressMapping, Context, EnsureAddressNever, EnsureAddressRoot, FeeCalculator, PrecompileHandle, +}; +use pallet_zk_verifier::ZkVerifierPort; +use sp_core::{H160, H256, U256}; +use sp_runtime::{ + traits::{BlakeTwo256, IdentityLookup}, + AccountId32, BuildStorage, +}; + +use fp_evm::{ExitError, ExitReason, Transfer}; + +pub(crate) type Balance = u128; + +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test { + System: frame_system, + Balances: pallet_balances, + Timestamp: pallet_timestamp, + EVM: pallet_evm, + ShieldedPool: pallet_shielded_pool, + } +); + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = Block; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId32; + type Lookup = IdentityLookup; + type AccountData = pallet_balances::AccountData; +} + +parameter_types! { + pub const ExistentialDeposit: Balance = 1; +} + +impl pallet_balances::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeHoldReason = RuntimeHoldReason; + type RuntimeFreezeReason = RuntimeFreezeReason; + type WeightInfo = (); + type Balance = Balance; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = RuntimeFreezeReason; + type MaxLocks = (); + type MaxReserves = (); + type MaxFreezes = (); + type DoneSlashHandler = (); +} + +parameter_types! { + pub const MinimumPeriod: u64 = 1000; +} + +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; + type WeightInfo = (); +} + +pub struct FixedGasPrice; +impl FeeCalculator for FixedGasPrice { + fn min_gas_price() -> (U256, Weight) { + (1_000_000_000u128.into(), Weight::from_parts(7u64, 0)) + } +} + +parameter_types! { + pub BlockGasLimit: U256 = U256::max_value(); + pub WeightPerGas: Weight = Weight::from_parts(20_000, 0); +} + +pub struct H160ToAccountId32Mapping; +impl AddressMapping for H160ToAccountId32Mapping { + fn into_account_id(address: H160) -> AccountId32 { + let mut bytes = [0u8; 32]; + bytes[..20].copy_from_slice(address.as_bytes()); + AccountId32::from(bytes) + } +} + +impl pallet_evm::Config for Test { + type AccountProvider = pallet_evm::FrameSystemAccountProvider; + type FeeCalculator = FixedGasPrice; + type GasWeightMapping = pallet_evm::FixedGasWeightMapping; + type WeightPerGas = WeightPerGas; + type BlockHashMapping = pallet_evm::SubstrateBlockHashMapping; + type CallOrigin = EnsureAddressRoot; + type WithdrawOrigin = EnsureAddressNever; + type AddressMapping = H160ToAccountId32Mapping; + type Currency = Balances; + type PrecompilesType = (); + type PrecompilesValue = (); + type ChainId = (); + type BlockGasLimit = BlockGasLimit; + type Runner = pallet_evm::runner::stack::Runner; + type OnChargeTransaction = (); + type OnCreate = (); + type FindAuthor = (); + type GasLimitPovSizeRatio = (); + type GasLimitStorageGrowthRatio = (); + type Timestamp = Timestamp; + type CreateInnerOriginFilter = (); + type CreateOriginFilter = (); + type WeightInfo = (); +} + +parameter_types! { + pub const ShieldedPoolPalletId: PalletId = PalletId(*b"shldpool"); + pub const MaxTreeDepth: u32 = 32; + pub const MaxHistoricRoots: u32 = 100; + pub const MinShieldAmount: u128 = 100; + pub const RequestExpiration: u64 = 1000; +} + +pub struct MockZkVerifier; + +impl ZkVerifierPort for MockZkVerifier { + fn verify_transfer_proof( + proof: &[u8], + _merkle_root: &[u8; 32], + _nullifiers: &[[u8; 32]], + _commitments: &[[u8; 32]], + _asset_id: u32, + _fee: u128, + _version: Option, + ) -> Result { + if proof.is_empty() { + return Err(sp_runtime::DispatchError::Other("Empty proof")); + } + Ok(true) + } + + fn verify_unshield_proof( + proof: &[u8], + _merkle_root: &[u8; 32], + _nullifier: &[u8; 32], + _amount: u128, + _recipient: &[u8; 32], + _asset_id: u32, + _fee: u128, + _change_commitment: &[u8; 32], + _version: Option, + ) -> Result { + if proof.is_empty() { + return Err(sp_runtime::DispatchError::Other("Empty proof")); + } + Ok(true) + } + + fn verify_disclosure_proof( + proof: &[u8], + public_signals: &[u8], + _version: Option, + ) -> Result { + if proof.is_empty() { + return Err(sp_runtime::DispatchError::Other("Empty proof")); + } + if public_signals.len() != 76 { + return Err(sp_runtime::DispatchError::Other( + "Invalid public signals length", + )); + } + Ok(true) + } + + fn batch_verify_disclosure_proofs( + proofs: &[sp_std::vec::Vec], + public_signals: &[sp_std::vec::Vec], + _version: Option, + ) -> Result { + if proofs.len() != public_signals.len() { + return Err(sp_runtime::DispatchError::Other("Mismatched array lengths")); + } + Ok(true) + } + + fn verify_private_link_proof( + proof: &[u8], + _commitment: &[u8; 32], + _call_hash_fe: &[u8; 32], + _version: Option, + ) -> Result { + if proof.is_empty() { + return Err(sp_runtime::DispatchError::Other("Empty proof")); + } + Ok(true) + } +} + +pub struct MockBlockAuthor; +impl frame_support::traits::Get> for MockBlockAuthor { + fn get() -> Option { + Some(AccountId32::from([1u8; 32])) + } +} + +pub struct MockRelayer; +impl pallet_relayer::RelayerInterface for MockRelayer { + type AccountId = AccountId32; + + fn resolve_relayer(_evm_address: &sp_core::H160) -> Option { + None + } + + fn min_relay_fee() -> u128 { + 0 + } + + fn allowed_selectors() -> sp_std::vec::Vec<[u8; 4]> { + sp_std::vec![] + } + + fn block_author() -> Option { + MockBlockAuthor::get() + } + + fn accumulate_relay_fee(_author: &AccountId32, _asset_id: u32, _amount: u128) {} + + fn pending_relay_fees(_who: &AccountId32, _asset_id: u32) -> u128 { + 0 + } + + fn consume_relay_fee( + _who: &AccountId32, + _asset_id: u32, + _amount: u128, + ) -> frame_support::dispatch::DispatchResult { + Ok(()) + } + + fn registered_evm_address(_who: &AccountId32) -> Option { + None + } +} + +impl pallet_shielded_pool::Config for Test { + type Currency = Balances; + type ZkVerifier = MockZkVerifier; + type PalletId = ShieldedPoolPalletId; + type MaxTreeDepth = MaxTreeDepth; + type MaxHistoricRoots = MaxHistoricRoots; + type MinShieldAmount = MinShieldAmount; + type WeightInfo = (); + type RequestExpiration = RequestExpiration; + type Relayer = MockRelayer; +} + +pub fn caller() -> H160 { + H160::from_low_u64_be(1) +} + +pub fn caller_account() -> AccountId32 { + H160ToAccountId32Mapping::into_account_id(caller()) +} + +/// Account mapped from `H160::zero()` — used as the precompile's own address in +/// `MockHandle` (`context.address = H160::zero()`). +/// +/// In real EVM execution the EVM transfers `msg.value` from the caller to the +/// precompile's account before `execute` is called. The mock doesn't run the EVM +/// machinery, so `new_test_ext` funds this account directly to replicate that effect. +pub fn precompile_account() -> AccountId32 { + H160ToAccountId32Mapping::into_account_id(H160::zero()) +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default() + .build_storage() + .unwrap(); + + pallet_balances::GenesisConfig:: { + // Fund both the EVM caller and the precompile's own account. + // The precompile account must have balance because `dispatch_call_from_self` + // dispatches `shield` with the precompile as origin, and the pallet transfers + // from that account to the pool. In real EVM the engine moves `msg.value` + // there before calling execute; here we fund it in genesis instead. + balances: vec![ + (caller_account(), 1_000_000_000_000_000u128), + (precompile_account(), 1_000_000_000_000_000u128), + ], + dev_accounts: None, + } + .assimilate_storage(&mut t) + .unwrap(); + + pallet_shielded_pool::GenesisConfig:: { + initial_root: [0u8; 32], + _phantom: Default::default(), + } + .assimilate_storage(&mut t) + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +pub(crate) struct MockHandle { + pub input: Vec, + pub context: Context, +} + +impl MockHandle { + pub fn new(input: Vec) -> Self { + Self { + input, + context: Context { + address: H160::zero(), + caller: caller(), + apparent_value: U256::zero(), + }, + } + } + + /// Creates a `MockHandle` with a non-zero `apparent_value` (msg.value). + /// Used for payable calls such as `shield`. + pub fn with_value(input: Vec, value: u128) -> Self { + Self { + input, + context: Context { + address: H160::zero(), + caller: caller(), + apparent_value: U256::from(value), + }, + } + } +} + +impl PrecompileHandle for MockHandle { + fn call( + &mut self, + _: H160, + _: Option, + _: Vec, + _: Option, + _: bool, + _: &Context, + ) -> (ExitReason, Vec) { + unimplemented!() + } + + fn record_cost(&mut self, _: u64) -> Result<(), ExitError> { + Ok(()) + } + + fn record_external_cost( + &mut self, + _ref_time: Option, + _proof_size: Option, + _storage_growth: Option, + ) -> Result<(), ExitError> { + Ok(()) + } + + fn refund_external_cost(&mut self, _ref_time: Option, _proof_size: Option) {} + + fn remaining_gas(&self) -> u64 { + u64::MAX + } + + fn log(&mut self, _: H160, _: Vec, _: Vec) -> Result<(), ExitError> { + Ok(()) + } + + fn code_address(&self) -> H160 { + H160::zero() + } + + fn input(&self) -> &[u8] { + &self.input + } + + fn context(&self) -> &Context { + &self.context + } + + fn origin(&self) -> H160 { + self.context.caller + } + + fn is_static(&self) -> bool { + false + } + + fn gas_limit(&self) -> Option { + None + } + + fn is_contract_being_constructed(&self, _address: H160) -> bool { + false + } +} diff --git a/frame/evm/precompile/shielded-pool/src/tests.rs b/frame/evm/precompile/shielded-pool/src/tests.rs new file mode 100644 index 00000000..5eb1f2f1 --- /dev/null +++ b/frame/evm/precompile/shielded-pool/src/tests.rs @@ -0,0 +1,843 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Test helpers and ABI encoders +// ───────────────────────────────────────────────────────────────────────────── + +use fp_evm::{ExitError, Precompile, PrecompileFailure}; +use sp_core::U256; + +use crate::{ + mock::{new_test_ext, MockHandle, Test}, + ShieldedPoolPrecompile, +}; + +// ─── Assertion helpers ─────────────────────────────────────────────────────── + +fn expect_error(result: Result) { + assert!( + matches!( + result, + Err(PrecompileFailure::Error { + exit_status: ExitError::Other(_) + }) + ), + "expected PrecompileFailure::Error(ExitError::Other(_)), got: {result:?}" + ); +} + +fn assert_success(result: Result) { + match result { + Ok(out) => assert_eq!(out.exit_status, fp_evm::ExitSucceed::Stopped), + Err(e) => panic!("expected successful dispatch, got error: {e:?}"), + } +} + +/// Wraps an `Ok(())` into a fake `PrecompileOutput` so `expect_error` can accept +/// results from abi helpers that return `Result`. +fn lift(r: Result) -> Result { + r.map(|_| fp_evm::PrecompileOutput { + exit_status: fp_evm::ExitSucceed::Stopped, + output: vec![], + }) +} + +// ─── Low-level ABI encoding ────────────────────────────────────────────────── + +fn u256_word(value: usize) -> [u8; 32] { + U256::from(value).to_big_endian() +} + +fn u256_word_u128(value: u128) -> [u8; 32] { + U256::from(value).to_big_endian() +} + +/// Encodes a single `bytes` value: `uint256(length) ++ data ++ zero-padding`. +fn encode_bytes(data: &[u8]) -> Vec { + let padded = (data.len() + 31) & !31; + let mut out = vec![0u8; 32 + padded]; + out[..32].copy_from_slice(&u256_word(data.len())); + out[32..32 + data.len()].copy_from_slice(data); + out +} + +/// Encodes a `bytes32[]`: `uint256(count) ++ items`. +fn encode_bytes32_array(items: &[[u8; 32]]) -> Vec { + let mut out = vec![0u8; 32 + items.len() * 32]; + out[..32].copy_from_slice(&u256_word(items.len())); + for (i, item) in items.iter().enumerate() { + out[32 + i * 32..64 + i * 32].copy_from_slice(item); + } + out +} + +/// Encodes a `bytes[]` using head/tail ABI layout. +fn encode_bytes_array(items: &[Vec]) -> Vec { + let count = items.len(); + let mut heads = vec![0u8; count * 32]; + let mut tails = Vec::new(); + let mut cursor = count * 32; + + for (i, item) in items.iter().enumerate() { + heads[i * 32..(i + 1) * 32].copy_from_slice(&u256_word(cursor)); + let enc = encode_bytes(item); + cursor += enc.len(); + tails.extend_from_slice(&enc); + } + + let mut out = Vec::with_capacity(32 + heads.len() + tails.len()); + out.extend_from_slice(&u256_word(count)); + out.extend_from_slice(&heads); + out.extend_from_slice(&tails); + out +} + +// ─── Call encoders ─────────────────────────────────────────────────────────── + +/// `shield(uint32 asset_id, bytes32 commitment, bytes encrypted_memo)` selector `0x9feb22ea` +fn encode_shield(asset_id: u32, commitment: [u8; 32], memo: &[u8]) -> Vec { + let mut input = vec![0x9f, 0xeb, 0x22, 0xea]; + let mut head = vec![0u8; 96]; + head[28..32].copy_from_slice(&asset_id.to_be_bytes()); + head[32..64].copy_from_slice(&commitment); + head[64..96].copy_from_slice(&u256_word(96)); + input.extend_from_slice(&head); + input.extend_from_slice(&encode_bytes(memo)); + input +} + +/// `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256)` selector `0x8c0f5d24` +fn encode_private_transfer( + proof: &[u8], + merkle_root: [u8; 32], + nullifiers: &[[u8; 32]], + commitments: &[[u8; 32]], + memos: &[Vec], + asset_id: u32, + fee: u128, +) -> Vec { + let proof_enc = encode_bytes(proof); + let nullifiers_enc = encode_bytes32_array(nullifiers); + let commitments_enc = encode_bytes32_array(commitments); + let memos_enc = encode_bytes_array(memos); + + // head: 7 slots × 32 = 224 bytes + let head_size = 224usize; + let off_proof = head_size; + let off_nullifiers = off_proof + proof_enc.len(); + let off_commitments = off_nullifiers + nullifiers_enc.len(); + let off_memos = off_commitments + commitments_enc.len(); + + let mut input = vec![0x8c, 0x0f, 0x5d, 0x24]; + let mut head = vec![0u8; head_size]; + head[0..32].copy_from_slice(&u256_word(off_proof)); + head[32..64].copy_from_slice(&merkle_root); + head[64..96].copy_from_slice(&u256_word(off_nullifiers)); + head[96..128].copy_from_slice(&u256_word(off_commitments)); + head[128..160].copy_from_slice(&u256_word(off_memos)); + head[188..192].copy_from_slice(&asset_id.to_be_bytes()); + head[192..224].copy_from_slice(&u256_word_u128(fee)); + + input.extend_from_slice(&head); + input.extend_from_slice(&proof_enc); + input.extend_from_slice(&nullifiers_enc); + input.extend_from_slice(&commitments_enc); + input.extend_from_slice(&memos_enc); + input +} + +/// `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32)` selector `0xd21d9a79` +#[allow(clippy::too_many_arguments)] +fn encode_unshield( + proof: &[u8], + merkle_root: [u8; 32], + nullifier: [u8; 32], + asset_id: u32, + amount: u128, + recipient: [u8; 32], + fee: u128, + change_commitment: [u8; 32], +) -> Vec { + // head: 8 slots × 32 = 256 bytes; proof tail appended after + let mut input = vec![0xd2, 0x1d, 0x9a, 0x79]; + let mut head = vec![0u8; 256]; + head[0..32].copy_from_slice(&u256_word(256)); + head[32..64].copy_from_slice(&merkle_root); + head[64..96].copy_from_slice(&nullifier); + head[124..128].copy_from_slice(&asset_id.to_be_bytes()); + head[128..160].copy_from_slice(&u256_word_u128(amount)); + head[160..192].copy_from_slice(&recipient); + head[192..224].copy_from_slice(&u256_word_u128(fee)); + head[224..256].copy_from_slice(&change_commitment); + input.extend_from_slice(&head); + input.extend_from_slice(&encode_bytes(proof)); + input +} + +// ─── Convenience: shield then return the current Merkle root ───────────────── + +fn do_shield(commitment: [u8; 32], value: u128) { + let input = encode_shield(0, commitment, &[0xAB; 168]); + let mut h = MockHandle::with_value(input, value); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); +} + +fn current_root() -> [u8; 32] { + pallet_shielded_pool::Pallet::::poseidon_root() +} + +fn recipient_bytes() -> [u8; 32] { + let mut r = [0u8; 32]; + r.copy_from_slice(crate::mock::caller_account().as_ref()); + r +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: ABI router +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn router_rejects_empty_input() { + new_test_ext().execute_with(|| { + let mut h = MockHandle::new(vec![]); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn router_rejects_3_byte_selector() { + new_test_ext().execute_with(|| { + let mut h = MockHandle::new(vec![0x9f, 0xeb, 0x22]); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn router_rejects_unknown_selector() { + new_test_ext().execute_with(|| { + let mut h = MockHandle::new(vec![0xff, 0xff, 0xff, 0xff]); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: ABI decoders (unit tests — no pallet state) +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn abi_decode_u32_max_value() { + let mut slot = [0u8; 32]; + slot[28..32].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(crate::abi::decode_u32(&slot).unwrap(), u32::MAX); +} + +#[test] +fn abi_decode_u32_zero() { + assert_eq!(crate::abi::decode_u32(&[0u8; 32]).unwrap(), 0u32); +} + +#[test] +fn abi_decode_u32_rejects_short_slot() { + expect_error(lift(crate::abi::decode_u32(&[0u8; 31]))); +} + +#[test] +fn abi_read_bytes32_copies_all_bytes() { + let mut params = [0u8; 64]; + for i in 0..32 { + params[32 + i] = i as u8; + } + let out = crate::abi::read_bytes32(¶ms, 32).unwrap(); + for i in 0..32u8 { + assert_eq!(out[i as usize], i); + } +} + +#[test] +fn abi_read_bytes32_rejects_out_of_bounds() { + expect_error(lift(crate::abi::read_bytes32(&[0u8; 31], 0))); +} + +#[test] +fn abi_decode_bytes_at_slot_works() { + let mut params = vec![0u8; 128]; + params[31] = 32; // offset pointer + params[63] = 3; // length + params[64..67].copy_from_slice(b"abc"); + assert_eq!( + crate::abi::decode_bytes_at_slot(¶ms, 0).unwrap(), + b"abc" + ); +} + +#[test] +fn abi_decode_bytes_at_slot_rejects_invalid_offset() { + let mut params = vec![0u8; 64]; + params[31] = 200; // offset beyond params + expect_error(lift(crate::abi::decode_bytes_at_slot(¶ms, 0))); +} + +#[test] +fn abi_decode_bytes_at_slot_rejects_truncated_data() { + // offset=32, length=100, but only 32 bytes of data follow + let mut params = vec![0u8; 96]; + params[31] = 32; + params[63] = 100; + expect_error(lift(crate::abi::decode_bytes_at_slot(¶ms, 0))); +} + +#[test] +fn abi_decode_bytes_at_slot_empty_payload() { + // length=0 is valid + let mut params = vec![0u8; 64]; + params[31] = 32; // offset + // length word is 0 (already zeroed) + assert_eq!(crate::abi::decode_bytes_at_slot(¶ms, 0).unwrap(), b""); +} + +#[test] +fn abi_decode_bytes32_array_works() { + let mut params = vec![0u8; 160]; + params[31] = 32; // offset + params[63] = 2; // count + params[64..96].copy_from_slice(&[0xAAu8; 32]); + params[96..128].copy_from_slice(&[0xBBu8; 32]); + let out = crate::abi::decode_bytes32_array_at_slot(¶ms, 0).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0], [0xAAu8; 32]); + assert_eq!(out[1], [0xBBu8; 32]); +} + +#[test] +fn abi_decode_bytes32_array_empty() { + let mut params = vec![0u8; 64]; + params[31] = 32; // offset + // count = 0 + let out = crate::abi::decode_bytes32_array_at_slot(¶ms, 0).unwrap(); + assert!(out.is_empty()); +} + +#[test] +fn abi_decode_bytes32_array_rejects_truncated() { + let mut params = vec![0u8; 96]; + params[31] = 32; + params[63] = 3; // asks for 3×32=96 bytes but only 32 available + expect_error(lift(crate::abi::decode_bytes32_array_at_slot(¶ms, 0))); +} + +#[test] +fn abi_decode_bytes_array_works() { + let mut params = vec![0u8; 288]; + params[31] = 32; // outer offset + params[63] = 2; // count + params[95] = 64; // rel offset element 0 + params[127] = 128; // rel offset element 1 + // element 0: len=1, data=0xAA + params[159] = 1; + params[160] = 0xAA; + // element 1: len=2, data=0xBB 0xCC + params[223] = 2; + params[224] = 0xBB; + params[225] = 0xCC; + let out = crate::abi::decode_bytes_array_at_slot(¶ms, 0).unwrap(); + assert_eq!(out, vec![vec![0xAAu8], vec![0xBBu8, 0xCC]]); +} + +#[test] +fn abi_decode_bytes_array_rejects_invalid_rel_offset() { + let mut params = vec![0u8; 96]; + params[31] = 32; + params[63] = 1; + params[95] = 200; // rel offset way out of bounds + expect_error(lift(crate::abi::decode_bytes_array_at_slot(¶ms, 0))); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: shield +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn shield_rejects_truncated_input() { + new_test_ext().execute_with(|| { + // selector only — params missing + let mut h = MockHandle::new(vec![0x9f, 0xeb, 0x22, 0xea]); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn shield_rejects_below_min_amount() { + // MinShieldAmount = 100; sending value = 1 should be rejected by the pallet. + new_test_ext().execute_with(|| { + let input = encode_shield(0, [0x11; 32], &[0xAB; 168]); + let mut h = MockHandle::with_value(input, 1); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn shield_stores_commitment_and_updates_balance() { + new_test_ext().execute_with(|| { + let commitment = [0x11; 32]; + let input = encode_shield(0, commitment, &[0xAB; 168]); + let mut h = MockHandle::with_value(input, 1_000); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); + + assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 1); + assert_eq!( + pallet_shielded_pool::PoolBalancePerAsset::::get(0), + 1_000 + ); + let leaf = pallet_shielded_pool::MerkleLeaves::::get(0).unwrap(); + assert_eq!(leaf, pallet_shielded_pool::Commitment::from(commitment)); + }); +} + +#[test] +fn shield_multiple_commitments_are_all_stored() { + new_test_ext().execute_with(|| { + for (i, byte) in [0x11u8, 0x22, 0x33].iter().enumerate() { + do_shield([*byte; 32], 500); + assert_eq!( + pallet_shielded_pool::MerkleTreeSize::::get(), + (i + 1) as u32 + ); + } + assert_eq!( + pallet_shielded_pool::PoolBalancePerAsset::::get(0), + 1_500 + ); + }); +} + +#[test] +fn shield_updates_merkle_root_after_each_insertion() { + new_test_ext().execute_with(|| { + let root_before = current_root(); + do_shield([0x42; 32], 1_000); + let root_after = current_root(); + assert_ne!(root_before, root_after, "root must change after shield"); + }); +} + +#[test] +fn shield_with_zero_value_rejected() { + new_test_ext().execute_with(|| { + let input = encode_shield(0, [0xAA; 32], &[0x00; 168]); + let mut h = MockHandle::with_value(input, 0); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: private_transfer +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn private_transfer_rejects_truncated_input() { + new_test_ext().execute_with(|| { + let mut h = MockHandle::new(vec![0x8c, 0x0f, 0x5d, 0x24]); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn private_transfer_rejects_empty_proof() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + // empty proof → MockZkVerifier returns Err + let input = encode_private_transfer( + &[], + root, + &[[0x11; 32], [0x22; 32]], + &[[0x33; 32], [0x44; 32]], + &[vec![0xAA; 168], vec![0xBB; 168]], + 0, + 0, + ); + let mut h = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn private_transfer_rejects_zero_nullifiers() { + // Calling with an empty nullifier array must be rejected at the precompile + // boundary before touching the pallet. + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let input = encode_private_transfer( + &[0x01], + root, + &[], // 0 nullifiers + &[], // 0 commitments + &[], // 0 memos + 0, + 0, + ); + let mut h = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn private_transfer_rejects_mismatched_nullifier_commitment_count() { + // 2 nullifiers but 1 commitment — structurally inconsistent. + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let input = encode_private_transfer( + &[0x01], + root, + &[[0x11; 32], [0x22; 32]], // 2 nullifiers + &[[0x33; 32]], // 1 commitment + &[vec![0xAA; 168]], // 1 memo + 0, + 0, + ); + let mut h = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn private_transfer_rejects_mismatched_commitment_memo_count() { + // 2 commitments but 1 memo — structurally inconsistent. + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let input = encode_private_transfer( + &[0x01], + root, + &[[0x11; 32], [0x22; 32]], // 2 nullifiers + &[[0x33; 32], [0x44; 32]], // 2 commitments + &[vec![0xAA; 168]], // 1 memo — mismatch + 0, + 0, + ); + let mut h = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn private_transfer_happy_path() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let nullifier_1 = [0x11; 32]; + let nullifier_2 = [0x22; 32]; + let commitment_1 = [0x33; 32]; + let commitment_2 = [0x44; 32]; + + let input = encode_private_transfer( + &[0x01, 0x02, 0x03], + root, + &[nullifier_1, nullifier_2], + &[commitment_1, commitment_2], + &[vec![0xAA; 168], vec![0xBB; 168]], + 0, + 0, + ); + let mut h = MockHandle::new(input); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); + + // Both input nullifiers must be spent. + assert!(pallet_shielded_pool::NullifierSet::::get( + pallet_shielded_pool::Nullifier::from(nullifier_1) + ) + .is_some()); + assert!(pallet_shielded_pool::NullifierSet::::get( + pallet_shielded_pool::Nullifier::from(nullifier_2) + ) + .is_some()); + + // Both output commitments must land in the tree (indices 1 and 2). + assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 3); + assert_eq!( + pallet_shielded_pool::MerkleLeaves::::get(1).unwrap(), + pallet_shielded_pool::Commitment::from(commitment_1) + ); + assert_eq!( + pallet_shielded_pool::MerkleLeaves::::get(2).unwrap(), + pallet_shielded_pool::Commitment::from(commitment_2) + ); + }); +} + +#[test] +fn private_transfer_rejects_double_spend() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let nullifier = [0xDE; 32]; + + let input = encode_private_transfer( + &[0x01], + root, + &[nullifier, [0x02; 32]], + &[[0x03; 32], [0x04; 32]], + &[vec![0xAA; 168], vec![0xBB; 168]], + 0, + 0, + ); + let mut h = MockHandle::new(input.clone()); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); + + // Second call reuses the same nullifier — must fail. + let mut h2 = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h2)); + }); +} + +#[test] +fn private_transfer_root_updates_after_outputs() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root_before = current_root(); + + let input = encode_private_transfer( + &[0x01], + root_before, + &[[0x11; 32], [0x22; 32]], + &[[0x33; 32], [0x44; 32]], + &[vec![0xAA; 168], vec![0xBB; 168]], + 0, + 0, + ); + let mut h = MockHandle::new(input); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); + + assert_ne!( + current_root(), + root_before, + "root must change after private_transfer" + ); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: unshield +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn unshield_rejects_truncated_input() { + new_test_ext().execute_with(|| { + let mut h = MockHandle::new(vec![0xd2, 0x1d, 0x9a, 0x79]); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn unshield_rejects_empty_proof() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let input = encode_unshield( + &[], + root, + [0x77; 32], + 0, + 100, + recipient_bytes(), + 0, + [0u8; 32], + ); + let mut h = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn unshield_happy_path() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let nullifier = [0x77; 32]; + + let input = encode_unshield( + &[0x09, 0x09], + root, + nullifier, + 0, + 100, + recipient_bytes(), + 0, + [0u8; 32], + ); + let mut h = MockHandle::new(input); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); + + // Pool balance decreases by the withdrawn amount. + assert_eq!( + pallet_shielded_pool::PoolBalancePerAsset::::get(0), + 4_900 + ); + // Nullifier is marked spent. + assert!(pallet_shielded_pool::NullifierSet::::get( + pallet_shielded_pool::Nullifier::from(nullifier) + ) + .is_some()); + }); +} + +#[test] +fn unshield_rejects_double_spend() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let nullifier = [0x77; 32]; + + let input = encode_unshield( + &[0x09, 0x09], + root, + nullifier, + 0, + 100, + recipient_bytes(), + 0, + [0u8; 32], + ); + let mut h = MockHandle::new(input.clone()); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); + + let mut h2 = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h2)); + }); +} + +#[test] +fn unshield_full_balance() { + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 1_000); + let root = current_root(); + let input = encode_unshield( + &[0x01], + root, + [0x99; 32], + 0, + 1_000, + recipient_bytes(), + 0, + [0u8; 32], + ); + let mut h = MockHandle::new(input); + assert_success(ShieldedPoolPrecompile::::execute(&mut h)); + assert_eq!(pallet_shielded_pool::PoolBalancePerAsset::::get(0), 0); + }); +} + +#[test] +fn unshield_rejects_zero_recipient() { + // AccountId32 of all zeros is a permanent burn address. The precompile + // must reject it before dispatching to avoid silent token destruction. + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let input = encode_unshield( + &[0x09, 0x09], + root, + [0x77; 32], + 0, + 100, + [0u8; 32], // zero AccountId32 + 0, + [0u8; 32], + ); + let mut h = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +#[test] +fn unshield_rejects_zero_amount() { + // amount = 0 is semantically invalid and must be rejected at the precompile + // level before dispatch. + new_test_ext().execute_with(|| { + do_shield([0x55; 32], 5_000); + let root = current_root(); + let input = encode_unshield( + &[0x09, 0x09], + root, + [0x77; 32], + 0, + 0, // zero amount + recipient_bytes(), + 0, + [0u8; 32], + ); + let mut h = MockHandle::new(input); + expect_error(ShieldedPoolPrecompile::::execute(&mut h)); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: multi-step flows +// ───────────────────────────────────────────────────────────────────────────── + +/// Shield → private_transfer → unshield in one test to exercise the full lifecycle. +#[test] +fn full_lifecycle_shield_transfer_unshield() { + new_test_ext().execute_with(|| { + // 1. Shield + do_shield([0xAA; 32], 10_000); + assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 1); + + // 2. Private transfer + let root_1 = current_root(); + let nullifier_in = [0xBB; 32]; + let commitment_out_1 = [0xCC; 32]; + let commitment_out_2 = [0xDD; 32]; + + let pt_input = encode_private_transfer( + &[0x01], + root_1, + &[nullifier_in, [0x00; 32]], + &[commitment_out_1, commitment_out_2], + &[vec![0xAA; 168], vec![0xBB; 168]], + 0, + 0, + ); + let mut h_pt = MockHandle::new(pt_input); + assert_success(ShieldedPoolPrecompile::::execute(&mut h_pt)); + assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 3); + + // 3. Unshield one of the outputs + let root_2 = current_root(); + let nullifier_out = [0xEE; 32]; + let unshield_input = encode_unshield( + &[0x02], + root_2, + nullifier_out, + 0, + 500, + recipient_bytes(), + 0, + [0u8; 32], + ); + let mut h_us = MockHandle::new(unshield_input); + assert_success(ShieldedPoolPrecompile::::execute(&mut h_us)); + + assert_eq!( + pallet_shielded_pool::PoolBalancePerAsset::::get(0), + 9_500 + ); + }); +} + +/// Ensures state is independent across multiple shield operations with different asset_id slots. +/// The mock only has asset 0 registered so multiple asset_ids will result in dispatch errors, +/// but the ABI decoding of asset_id must always round-trip correctly. +#[test] +fn shield_asset_id_round_trips_through_abi() { + // We test ABI encoding/decoding in isolation via the abi module. + for &id in &[0u32, 1, 42, u32::MAX] { + let mut slot = [0u8; 32]; + slot[28..32].copy_from_slice(&id.to_be_bytes()); + assert_eq!( + crate::abi::decode_u32(&slot).unwrap(), + id, + "asset_id {id} must round-trip" + ); + } +} diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index a7a3ce6c..86d7b0a4 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -69,6 +69,7 @@ pallet-evm-precompile-curve25519-benchmarking = { workspace = true } pallet-evm-precompile-modexp = { workspace = true } pallet-evm-precompile-sha3fips = { workspace = true } pallet-evm-precompile-sha3fips-benchmarking = { workspace = true } +pallet-evm-precompile-shielded-pool = { workspace = true } pallet-evm-precompile-simple = { workspace = true } # Orbinum Privacy @@ -151,6 +152,7 @@ std = [ "pallet-evm-precompile-curve25519/std", "pallet-evm-precompile-curve25519-benchmarking/std", "pallet-evm-precompile-account-mapping/std", + "pallet-evm-precompile-shielded-pool/std", # Orbinum Privacy "pallet-zk-verifier/std", "pallet-relayer/std", diff --git a/template/runtime/src/precompiles.rs b/template/runtime/src/precompiles.rs index a35100f9..d880e172 100644 --- a/template/runtime/src/precompiles.rs +++ b/template/runtime/src/precompiles.rs @@ -8,6 +8,7 @@ use pallet_evm_precompile_account_mapping::AccountMappingPrecompile; use pallet_evm_precompile_curve25519 as curve25519_precompile; use pallet_evm_precompile_modexp::Modexp; use pallet_evm_precompile_sha3fips::Sha3FIPS256; +use pallet_evm_precompile_shielded_pool::ShieldedPoolPrecompile; use pallet_evm_precompile_simple::{ECRecover, ECRecoverPublicKey, Identity, Ripemd160, Sha256}; pub struct FrontierPrecompiles(PhantomData); @@ -19,7 +20,7 @@ where pub fn new() -> Self { Self(Default::default()) } - pub fn used_addresses() -> [H160; 10] { + pub fn used_addresses() -> [H160; 11] { [ hash(1), hash(2), @@ -31,20 +32,27 @@ where hash(1026), hash(1027), hash(2048), + hash(2049), ] } } impl PrecompileSet for FrontierPrecompiles where - R: pallet_evm::Config + frame_system::Config + pallet_account_mapping::Config, + R: pallet_evm::Config + + frame_system::Config + + pallet_account_mapping::Config + + pallet_shielded_pool::Config, ::RuntimeCall: sp_runtime::traits::Dispatchable + frame_support::dispatch::GetDispatchInfo - + From>, + + From> + + From>, <::RuntimeCall as sp_runtime::traits::Dispatchable>::RuntimeOrigin: From::AccountId>>, <::RuntimeCall as sp_runtime::traits::Dispatchable>::PostInfo: core::fmt::Debug, + ::AccountId: From<[u8; 32]>, pallet_evm::AccountIdOf: Into<::AccountId>, + pallet_shielded_pool::BalanceOf: TryFrom, { fn execute(&self, handle: &mut impl PrecompileHandle) -> Option { match handle.code_address() { @@ -71,6 +79,7 @@ where >::execute(handle)), // Orbinum precompiles a if a == hash(2048) => Some(AccountMappingPrecompile::::execute(handle)), + a if a == hash(2049) => Some(ShieldedPoolPrecompile::::execute(handle)), _ => None, } } diff --git a/ts-tests/tests/test-precompiles-shielded-pool.ts b/ts-tests/tests/test-precompiles-shielded-pool.ts new file mode 100644 index 00000000..08358dec --- /dev/null +++ b/ts-tests/tests/test-precompiles-shielded-pool.ts @@ -0,0 +1,241 @@ +import { assert } from "chai"; +import { AbiCoder, ethers } from "ethers"; + +import { GENESIS_ACCOUNT, GENESIS_ACCOUNT_PRIVATE_KEY } from "./config"; +import { createAndFinalizeBlock, customRequest, describeWithFrontier } from "./util"; + +// ─── Precompile address ──────────────────────────────────────────────────────── +// hash(2049) = H160::from_low_u64_be(2049) = 0x…0801 +const SHIELDED_POOL_PRECOMPILE = "0x0000000000000000000000000000000000000801"; + +// ─── Function selectors ──────────────────────────────────────────────────────── +// keccak256("shield(uint32,bytes32,bytes)")[0..4] — payable, amount = msg.value +const SEL_SHIELD = "9feb22ea"; +// keccak256("privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[])")[0..4] +const SEL_PRIVATE_TRANSFER = "dcd5b898"; +// keccak256("unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32)")[0..4] +const SEL_UNSHIELD = "dcf1bff2"; + +const abiCoder = AbiCoder.defaultAbiCoder(); + +// ─── Test data factories ─────────────────────────────────────────────────────── + +/** 32-byte commitment (fake Poseidon output, deterministic). */ +function fakeCommitment(): string { + return "0x" + "ab".repeat(32); // 32 bytes +} + +/** 32-byte nullifier. */ +function fakeNullifier(): string { + return "0x" + "cd".repeat(32); // 32 bytes +} + +/** 32-byte Merkle root. */ +function fakeMerkleRoot(): string { + return "0x" + "ef".repeat(31) + "01"; // 32 bytes +} + +/** + * Minimal well-formed Groth16 proof (256 bytes). + * The ZK verifier will reject it, but the calldata will parse correctly. + */ +function fakeProof(): Uint8Array { + return new Uint8Array(256).fill(0xaa); +} + +/** 104-byte encrypted memo (MAX_MEMO_SIZE). */ +function fakeMemo(): Uint8Array { + return new Uint8Array(104).fill(0x01); +} + +/** Encode shield(assetId, commitment, encryptedMemo) calldata. Amount is sent as msg.value. */ +function encodeShield(assetId: number, commitment: string, memo: Uint8Array): string { + const encoded = abiCoder.encode(["uint32", "bytes32", "bytes"], [assetId, commitment, memo]); + return "0x" + SEL_SHIELD + encoded.slice(2); +} + +/** Encode privateTransfer(proof, root, nullifiers[], commitments[], memos[]) calldata. */ +function encodePrivateTransfer( + proof: Uint8Array, + root: string, + nullifiers: string[], + commitments: string[], + memos: Uint8Array[] +): string { + const encoded = abiCoder.encode( + ["bytes", "bytes32", "bytes32[]", "bytes32[]", "bytes[]"], + [proof, root, nullifiers, commitments, memos] + ); + return "0x" + SEL_PRIVATE_TRANSFER + encoded.slice(2); +} + +/** Encode unshield(proof, root, nullifier, assetId, amount, recipient) calldata. */ +function encodeUnshield( + proof: Uint8Array, + root: string, + nullifier: string, + assetId: number, + amount: bigint, + recipient: string +): string { + const encoded = abiCoder.encode( + ["bytes", "bytes32", "bytes32", "uint32", "uint256", "bytes32"], + [proof, root, nullifier, assetId, amount, recipient] + ); + return "0x" + SEL_UNSHIELD + encoded.slice(2); +} + +/** Send a raw EVM transaction and mine a block. Returns the transaction hash. */ +async function sendCall(web3: any, data: string, gasLimit = "0x500000", value = "0x00"): Promise { + const tx = await web3.eth.accounts.signTransaction( + { + from: GENESIS_ACCOUNT, + to: SHIELDED_POOL_PRECOMPILE, + data, + value, + gasPrice: "0x3B9ACA00", + gas: gasLimit, + }, + GENESIS_ACCOUNT_PRIVATE_KEY + ); + + const result = await customRequest(web3, "eth_sendRawTransaction", [tx.rawTransaction]); + await createAndFinalizeBlock(web3); + return result.result as string; +} + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describeWithFrontier("Frontier RPC (Precompile: ShieldedPool – routing)", (context) => { + // ── 1. Precompile is registered ────────────────────────────────────────── + it("should return a non-empty response for any call to 0x0801", async () => { + // eth_call with empty data: the precompile should reply (even if it reverts) + // rather than silently returning 0x (which would mean "not a precompile"). + const result = await customRequest(context.web3, "eth_call", [ + { + from: GENESIS_ACCOUNT, + to: SHIELDED_POOL_PRECOMPILE, + data: "0x", + gas: "0x100000", + }, + "latest", + ]); + // A precompile always returns a non-null result (may be an error, but not null). + assert.isNotNull(result, "eth_call to 0x0801 returned null"); + }); + + // ── 2. Unknown selector is rejected ───────────────────────────────────── + it("should reject an unknown function selector", async () => { + const txHash = await sendCall(context.web3, "0xdeadbeef"); + + const receipt = await context.web3.eth.getTransactionReceipt(txHash); + assert.isNotNull(receipt, "no receipt for unknown-selector call"); + assert.equal(receipt.status, false, "unknown selector should make the tx fail (status=0)"); + }); + + // ── 3. Malformed calldata for shield ───────────────────────────────────── + it("should reject shield calldata that is too short to decode", async () => { + // Valid selector + only 3 bytes of data (not enough to decode uint32) + const data = "0x" + SEL_SHIELD + "aabbcc"; + const txHash = await sendCall(context.web3, data); + const receipt = await context.web3.eth.getTransactionReceipt(txHash); + assert.equal(receipt.status, false, "malformed calldata should fail"); + }); + + // ── 4. shield() – selector is routed; fails at pallet level ────────────── + it("shield: call reaches pallet (fails with pallet error, not routing error)", async () => { + // Asset 0 may not be registered; the call will fail in the pallet, but + // the precompile selector routing and ABI decoding succeed. + // Amount is passed as msg.value (1 ORB in wei). + const data = encodeShield( + 0, // assetId + fakeCommitment(), + fakeMemo() + ); + const value = "0x" + ethers.parseEther("1").toString(16); + const txHash = await sendCall(context.web3, data, "0x800000", value); + const receipt = await context.web3.eth.getTransactionReceipt(txHash); + + assert.isNotNull(receipt, "shield call produced no receipt"); + // Status=false is OK here: pallet rejects unknown asset / fake commitment. + // What we want to confirm is that the tx was *processed*, not dropped. + assert.exists(receipt.blockNumber, "shield call not included in a block"); + }); + + // ── 5. unshield() – selector is routed; fails at ZK verifier ───────────── + it("unshield: call reaches ZK verifier (fails proof verification, not routing)", async () => { + const recipient = + "0x6be02d1d3665660d22ff9624b7be0551ee1ac91b000000000000000000000000"; + const data = encodeUnshield( + fakeProof(), + fakeMerkleRoot(), + fakeNullifier(), + 0, // assetId + ethers.parseEther("1"), + recipient + ); + const txHash = await sendCall(context.web3, data, "0x800000"); + const receipt = await context.web3.eth.getTransactionReceipt(txHash); + + assert.isNotNull(receipt, "unshield call produced no receipt"); + assert.exists(receipt.blockNumber, "unshield call not included in a block"); + }); + + // ── 6. privateTransfer() – selector is routed; fails at ZK verifier ────── + it("privateTransfer: call reaches ZK verifier (fails proof verification, not routing)", async () => { + // 2 inputs, 2 outputs (standard private transfer) + const data = encodePrivateTransfer( + fakeProof(), + fakeMerkleRoot(), + [fakeNullifier(), fakeNullifier()], // input nullifiers + [fakeCommitment(), fakeCommitment()], // output commitments + [fakeMemo(), fakeMemo()] // encrypted memos for each output + ); + const txHash = await sendCall(context.web3, data, "0x800000"); + const receipt = await context.web3.eth.getTransactionReceipt(txHash); + + assert.isNotNull(receipt, "privateTransfer call produced no receipt"); + assert.exists(receipt.blockNumber, "privateTransfer call not included in a block"); + }); +}); + +describeWithFrontier("Frontier RPC (Precompile: ShieldedPool – used_addresses)", (context) => { + // ── 7. Precompile address appears in the set of known precompiles ───────── + it("should include 0x0801 in the precompile address list via eth_getCode", async () => { + // Precompile addresses return empty bytecode (0x) when queried with eth_getCode. + // This is a standard Frontier property: EOAs also return 0x, but what matters + // is that calls succeed (tested above). Here we just confirm the address is + // reachable and not confused with a contract deployment. + const code = await context.web3.eth.getCode(SHIELDED_POOL_PRECOMPILE); + // Frontier precompiles typically return "0x" for code. + assert.equal(code, "0x", "precompile address should report empty bytecode"); + }); + + // ── 8. Smoke-test: ABI-encoded data roundtrips correctly ───────────────── + it("shield ABI encoding produces correct selector prefix", () => { + const data = encodeShield(42, fakeCommitment(), fakeMemo()); + assert.isTrue(data.startsWith("0x" + SEL_SHIELD), "shield calldata has wrong selector"); + }); + + it("unshield ABI encoding produces correct selector prefix", () => { + const data = encodeUnshield( + fakeProof(), + fakeMerkleRoot(), + fakeNullifier(), + 0, + BigInt("1000000000000000000"), + "0x" + "00".repeat(32) + ); + assert.isTrue(data.startsWith("0x" + SEL_UNSHIELD), "unshield calldata has wrong selector"); + }); + + it("privateTransfer ABI encoding produces correct selector prefix", () => { + const data = encodePrivateTransfer(fakeProof(), fakeMerkleRoot(), [fakeNullifier()], [fakeCommitment()], [ + fakeMemo(), + ]); + assert.isTrue( + data.startsWith("0x" + SEL_PRIVATE_TRANSFER), + "privateTransfer calldata has wrong selector" + ); + }); +});