From 77f9e3731c0344b33afc5d411fad431301c35b58 Mon Sep 17 00:00:00 2001 From: unarbos Date: Sat, 29 Aug 2026 09:48:06 -0300 Subject: [PATCH 1/5] Require matching precompile execution frames. Co-authored-by: Cursor --- precompiles/src/lib.rs | 71 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index fe098221e0..2cda5f4b4a 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -247,7 +247,16 @@ where <::Lookup as StaticLookup>::Source: From, { fn execute(&self, handle: &mut impl PrecompileHandle) -> Option { - match handle.code_address() { + let code_address = handle.code_address(); + if !accepts_foreign_frame(code_address) && code_address != handle.context().address { + return Some(Err(PrecompileFailure::Error { + exit_status: ExitError::Other( + "Cannot be called with DELEGATECALL or CALLCODE".into(), + ), + })); + } + + match code_address { // Ethereum precompiles : a if a == hash(1) => Some(ECRecover::execute(handle)), a if a == hash(2) => Some(Sha256::execute(handle)), @@ -356,6 +365,15 @@ fn hash(a: u64) -> H160 { H160::from_low_u64_be(a) } +/// Stateless cryptographic precompiles may run in another contract's frame. +/// All other precompiles require a direct call (`code_address == context.address`). +fn accepts_foreign_frame(address: H160) -> bool { + const PURE_MATH: &[u64] = &[1, 2, 3, 4, 5, 7, 8, 9, 1024, 1025]; + PURE_MATH.iter().any(|&index| address == hash(index)) + || address == hash(Ed25519Verify::<[u8; 32]>::INDEX) + || address == hash(Sr25519Verify::<[u8; 32]>::INDEX) +} + /* * * This is used to parse a slice from bytes with PrecompileFailure as Error @@ -381,9 +399,58 @@ fn parse_slice(data: &[u8], from: usize, to: usize) -> Result<&[u8], PrecompileF #[cfg(test)] mod address_and_selector_tests { use super::*; - use crate::mock::{Runtime, selector_u32}; + use crate::mock::{Runtime, execute_precompile, new_test_ext, selector_u32}; use alloc::collections::BTreeSet; use codec::Encode; + use fp_evm::Context; + use precompile_utils::testing::MockHandle; + + #[test] + fn precompile_set_rejects_mismatched_frame() { + new_test_ext().execute_with(|| { + let code_address = hash(6); + let caller = H160::from_low_u64_be(0xBEEF); + let frame_address = H160::from_low_u64_be(0xDEAD); + let mut handle = MockHandle::new( + code_address, + Context { + address: frame_address, + caller, + apparent_value: U256::zero(), + }, + ); + + assert_eq!( + Precompiles::::new().execute(&mut handle), + Some(Err(PrecompileFailure::Error { + exit_status: ExitError::Other( + "Cannot be called with DELEGATECALL or CALLCODE".into(), + ), + })) + ); + }); + } + + #[test] + fn precompile_set_accepts_matching_frame() { + new_test_ext().execute_with(|| { + let result = execute_precompile( + &Precompiles::::new(), + hash(6), + H160::from_low_u64_be(0xBEEF), + alloc::vec::Vec::new(), + U256::zero(), + ); + assert_ne!( + result, + Some(Err(PrecompileFailure::Error { + exit_status: ExitError::Other( + "Cannot be called with DELEGATECALL or CALLCODE".into(), + ), + })) + ); + }); + } #[test] fn precompile_addresses_are_unique_and_new_addresses_are_locked() { From 1415b5385e488cb61e3e4756740702ef0589bc05 Mon Sep 17 00:00:00 2001 From: unarbos Date: Sat, 29 Aug 2026 09:52:09 -0300 Subject: [PATCH 2/5] Return None for addresses outside the precompile set. Co-authored-by: Cursor --- precompiles/src/lib.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 2cda5f4b4a..6ae34b4729 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -248,6 +248,9 @@ where { fn execute(&self, handle: &mut impl PrecompileHandle) -> Option { let code_address = handle.code_address(); + if !Self::used_addresses().contains(&code_address) { + return None; + } if !accepts_foreign_frame(code_address) && code_address != handle.context().address { return Some(Err(PrecompileFailure::Error { exit_status: ExitError::Other( @@ -452,6 +455,24 @@ mod address_and_selector_tests { }); } + #[test] + fn precompile_set_returns_none_for_unknown_address() { + new_test_ext().execute_with(|| { + let unknown = H160::from_low_u64_be(0x1111); + let frame_address = H160::from_low_u64_be(0xDEAD); + let mut handle = MockHandle::new( + unknown, + Context { + address: frame_address, + caller: H160::from_low_u64_be(0xBEEF), + apparent_value: U256::zero(), + }, + ); + + assert_eq!(Precompiles::::new().execute(&mut handle), None); + }); + } + #[test] fn precompile_addresses_are_unique_and_new_addresses_are_locked() { let addresses = Precompiles::::used_addresses(); From b7403c5661bdecc754d36d323afe78a000d23bc5 Mon Sep 17 00:00:00 2001 From: unarbos Date: Sat, 29 Aug 2026 10:05:55 -0300 Subject: [PATCH 3/5] Limit the direct-call requirement to value-moving precompiles. Co-authored-by: Cursor --- precompiles/src/lib.rs | 45 ++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 6ae34b4729..8e118d8990 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -165,6 +165,15 @@ where Self(Default::default()) } + /// Precompiles that sign pallet calls as `context.caller` and can move + /// native balance or stake. Direct CALL only. + fn requires_direct_call(address: H160) -> bool { + address == hash(6) + || address == hash(BalanceTransferPrecompile::::INDEX) + || address == hash(StakingPrecompile::::INDEX) + || address == hash(StakingPrecompileV2::::INDEX) + } + pub fn used_addresses() -> [H160; 33] { [ hash(1), @@ -251,7 +260,7 @@ where if !Self::used_addresses().contains(&code_address) { return None; } - if !accepts_foreign_frame(code_address) && code_address != handle.context().address { + if Self::requires_direct_call(code_address) && code_address != handle.context().address { return Some(Err(PrecompileFailure::Error { exit_status: ExitError::Other( "Cannot be called with DELEGATECALL or CALLCODE".into(), @@ -368,15 +377,6 @@ fn hash(a: u64) -> H160 { H160::from_low_u64_be(a) } -/// Stateless cryptographic precompiles may run in another contract's frame. -/// All other precompiles require a direct call (`code_address == context.address`). -fn accepts_foreign_frame(address: H160) -> bool { - const PURE_MATH: &[u64] = &[1, 2, 3, 4, 5, 7, 8, 9, 1024, 1025]; - PURE_MATH.iter().any(|&index| address == hash(index)) - || address == hash(Ed25519Verify::<[u8; 32]>::INDEX) - || address == hash(Sr25519Verify::<[u8; 32]>::INDEX) -} - /* * * This is used to parse a slice from bytes with PrecompileFailure as Error @@ -455,6 +455,31 @@ mod address_and_selector_tests { }); } + #[test] + fn precompile_set_allows_foreign_frame_for_view_precompile() { + new_test_ext().execute_with(|| { + let code_address = hash(TimestampPrecompile::::INDEX); + let mut handle = MockHandle::new( + code_address, + Context { + address: H160::from_low_u64_be(0xDEAD), + caller: H160::from_low_u64_be(0xBEEF), + apparent_value: U256::zero(), + }, + ); + let result = Precompiles::::new().execute(&mut handle); + assert_ne!( + result, + Some(Err(PrecompileFailure::Error { + exit_status: ExitError::Other( + "Cannot be called with DELEGATECALL or CALLCODE".into(), + ), + })) + ); + assert!(result.is_some()); + }); + } + #[test] fn precompile_set_returns_none_for_unknown_address() { new_test_ext().execute_with(|| { From d073bd404ce000503a8483913de517e2098e2d9f Mon Sep 17 00:00:00 2001 From: unarbos Date: Sat, 29 Aug 2026 10:08:44 -0300 Subject: [PATCH 4/5] Require a matching frame only for the Dispatch precompile. Co-authored-by: Cursor --- precompiles/src/lib.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 8e118d8990..e9a383b111 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -165,13 +165,9 @@ where Self(Default::default()) } - /// Precompiles that sign pallet calls as `context.caller` and can move - /// native balance or stake. Direct CALL only. + /// Dispatch (`0x06`) signs pallet calls as `context.caller`. Direct CALL only. fn requires_direct_call(address: H160) -> bool { address == hash(6) - || address == hash(BalanceTransferPrecompile::::INDEX) - || address == hash(StakingPrecompile::::INDEX) - || address == hash(StakingPrecompileV2::::INDEX) } pub fn used_addresses() -> [H160; 33] { From 864aee5240c713bb0d4b6cade522585189a6ab27 Mon Sep 17 00:00:00 2001 From: unarbos Date: Sat, 29 Aug 2026 10:12:16 -0300 Subject: [PATCH 5/5] Require a matching frame for every signed-dispatch precompile. Co-authored-by: Cursor --- precompiles/src/lib.rs | 160 ++++++++++++++---- .../01-contract-deploy-call.test.ts | 29 +--- 2 files changed, 132 insertions(+), 57 deletions(-) diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index e9a383b111..f52343b2b6 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -165,9 +165,30 @@ where Self(Default::default()) } - /// Dispatch (`0x06`) signs pallet calls as `context.caller`. Direct CALL only. - fn requires_direct_call(address: H160) -> bool { - address == hash(6) + /// Read-only and cryptographic precompiles may run in a borrowed frame. + /// Every other address in this set signs as `context.caller`. + fn accepts_foreign_frame(address: H160) -> bool { + address == hash(1) + || address == hash(2) + || address == hash(3) + || address == hash(4) + || address == hash(5) + || address == hash(7) + || address == hash(8) + || address == hash(9) + || address == hash(1024) + || address == hash(1025) + || address == hash(Ed25519Verify::::INDEX) + || address == hash(Sr25519Verify::::INDEX) + || address == hash(MetagraphPrecompile::::INDEX) + || address == hash(UidLookupPrecompile::::INDEX) + || address == hash(StorageQueryPrecompile::::INDEX) + || address == hash(AddressMappingPrecompile::::INDEX) + || address == hash(SchedulerPrecompile::::INDEX) + || address == hash(DrandPrecompile::::INDEX) + || address == hash(TimestampPrecompile::::INDEX) + || address == hash(RuntimeConfigurationPrecompile::::INDEX) + || address == hash(PrecompileRegistry::::INDEX) } pub fn used_addresses() -> [H160; 33] { @@ -256,7 +277,7 @@ where if !Self::used_addresses().contains(&code_address) { return None; } - if Self::requires_direct_call(code_address) && code_address != handle.context().address { + if !Self::accepts_foreign_frame(code_address) && code_address != handle.context().address { return Some(Err(PrecompileFailure::Error { exit_status: ExitError::Other( "Cannot be called with DELEGATECALL or CALLCODE".into(), @@ -398,11 +419,41 @@ fn parse_slice(data: &[u8], from: usize, to: usize) -> Result<&[u8], PrecompileF #[cfg(test)] mod address_and_selector_tests { use super::*; - use crate::mock::{Runtime, execute_precompile, new_test_ext, selector_u32}; + use crate::mock::{ + AccountId, Runtime, abi_word, execute_precompile, new_test_ext, selector_u32, + }; use alloc::collections::BTreeSet; use codec::Encode; - use fp_evm::Context; + use fp_evm::{Context, ExitSucceed, PrecompileOutput}; + use precompile_utils::solidity::encode_with_selector; use precompile_utils::testing::MockHandle; + use sp_core::{H256, Pair, ed25519}; + + const FRAME_ERROR: &str = "Cannot be called with DELEGATECALL or CALLCODE"; + const SHA256_EMPTY: [u8; 32] = [ + 0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f, 0xb9, + 0x24, 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b, 0x78, 0x52, + 0xb8, 0x55, + ]; + + fn execute_with_foreign_frame(code_address: H160, input: Vec) -> Option { + let mut handle = MockHandle::new( + code_address, + Context { + address: H160::from_low_u64_be(0xDEAD), + caller: H160::from_low_u64_be(0xBEEF), + apparent_value: U256::zero(), + }, + ); + handle.input = input; + Precompiles::::new().execute(&mut handle) + } + + fn frame_error() -> Option { + Some(Err(PrecompileFailure::Error { + exit_status: ExitError::Other(FRAME_ERROR.into()), + })) + } #[test] fn precompile_set_rejects_mismatched_frame() { @@ -421,15 +472,37 @@ mod address_and_selector_tests { assert_eq!( Precompiles::::new().execute(&mut handle), - Some(Err(PrecompileFailure::Error { - exit_status: ExitError::Other( - "Cannot be called with DELEGATECALL or CALLCODE".into(), - ), - })) + frame_error() ); }); } + #[test] + fn precompile_set_rejects_foreign_frame_for_signed_dispatch_precompiles() { + new_test_ext().execute_with(|| { + for address in [ + hash(6), + hash(BalanceTransferPrecompile::::INDEX), + hash(StakingPrecompile::::INDEX), + hash(StakingPrecompileV2::::INDEX), + hash(SubnetPrecompile::::INDEX), + hash(NeuronPrecompile::::INDEX), + hash(AlphaPrecompile::::INDEX), + hash(CrowdloanPrecompile::::INDEX), + hash(LeasingPrecompile::::INDEX), + hash(VotingPowerPrecompile::::INDEX), + hash(ProxyPrecompile::::INDEX), + hash(BalancePrecompile::::INDEX), + ] { + assert_eq!( + execute_with_foreign_frame(address, alloc::vec::Vec::new()), + frame_error(), + "signed-dispatch precompile {address:?} must reject a foreign frame" + ); + } + }); + } + #[test] fn precompile_set_accepts_matching_frame() { new_test_ext().execute_with(|| { @@ -440,39 +513,58 @@ mod address_and_selector_tests { alloc::vec::Vec::new(), U256::zero(), ); - assert_ne!( - result, - Some(Err(PrecompileFailure::Error { - exit_status: ExitError::Other( - "Cannot be called with DELEGATECALL or CALLCODE".into(), - ), + assert_ne!(result, frame_error()); + }); + } + + #[test] + fn precompile_set_allows_foreign_frame_for_view_precompile() { + new_test_ext().execute_with(|| { + let result = execute_with_foreign_frame( + hash(TimestampPrecompile::::INDEX), + alloc::vec::Vec::new(), + ); + assert_ne!(result, frame_error()); + assert!(result.is_some()); + }); + } + + #[test] + fn precompile_set_executes_sha256_with_foreign_frame() { + new_test_ext().execute_with(|| { + assert_eq!( + execute_with_foreign_frame(hash(2), alloc::vec::Vec::new()), + Some(Ok(PrecompileOutput { + exit_status: ExitSucceed::Returned, + output: SHA256_EMPTY.to_vec(), })) ); }); } #[test] - fn precompile_set_allows_foreign_frame_for_view_precompile() { + fn precompile_set_executes_ed25519_verify_with_foreign_frame() { new_test_ext().execute_with(|| { - let code_address = hash(TimestampPrecompile::::INDEX); - let mut handle = MockHandle::new( - code_address, - Context { - address: H160::from_low_u64_be(0xDEAD), - caller: H160::from_low_u64_be(0xBEEF), - apparent_value: U256::zero(), - }, + let pair = ed25519::Pair::from_seed(&[1u8; 32]); + let message = [7u8; 32]; + let signature = pair.sign(&message); + let input = encode_with_selector( + selector_u32("verify(bytes32,bytes32,bytes32,bytes32)"), + ( + H256::from(message), + H256::from(pair.public().0), + H256::from_slice(&signature.0[..32]), + H256::from_slice(&signature.0[32..]), + ), ); - let result = Precompiles::::new().execute(&mut handle); - assert_ne!( - result, - Some(Err(PrecompileFailure::Error { - exit_status: ExitError::Other( - "Cannot be called with DELEGATECALL or CALLCODE".into(), - ), + + assert_eq!( + execute_with_foreign_frame(hash(Ed25519Verify::::INDEX), input), + Some(Ok(PrecompileOutput { + exit_status: ExitSucceed::Returned, + output: abi_word(U256::one()), })) ); - assert!(result.is_some()); }); } diff --git a/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts b/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts index ad2af9bc32..de0e2f2315 100644 --- a/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts +++ b/ts-tests/suites/zombienet_evm/01-contract-deploy-call.test.ts @@ -33,7 +33,6 @@ import { sudoSetLockReductionInterval, tao, waitForFinalizedBlocks, - waitUntilBlockFinalized, } from "../../utils"; const DEPLOYED_BYTECODE_PREFIX = "0x60806040523480156"; @@ -295,29 +294,13 @@ describeSuite({ const alphaInPool = await contractForCall.getContractStake(netuid); expect(alphaInPool).toEqual(BigInt(0)); - const depositAlphaTx = await contractForCall.depositAlpha(netuid, tao(10).toString(), hotkey.publicKey); - const depositReceipt = await depositAlphaTx.wait(); - if (!depositReceipt) throw new Error("Missing depositAlpha receipt"); - expect(depositReceipt.status).toEqual(1); - // Wait for the deposit's own block to finalize rather than a - // fixed block count: when GRANDPA lags best by more than 2 - // blocks, the finalized-state stake reads below see the - // pre-deposit stake and the toBeLessThan assertion flakes. - await waitUntilBlockFinalized(api, depositReceipt.blockNumber); - - const stakeAfterDeposit = await getStake(api, hotkeySs58, walletSs58, netuid); - expect(stakeAfterDeposit).toBeLessThan(stakeBeforeDeposit); - - const contractStake = await getStake(api, hotkeySs58, convertH160ToSS58(contractAddress), netuid); - expect(contractStake).toBeGreaterThan(BigInt(0)); - - const alphaBalanceOnContract = await contractForCall.alphaBalance(ethWallet.address, netuid); - expect(tao(10) - alphaBalanceOnContract).toBeLessThan(BigInt(1000)); - - const stakeFromContract = BigInt( - await stakingPrecompile.getStake(hotkey.publicKey, contractPublicKey, netuid) + await expect( + contractForCall.depositAlpha(netuid, tao(10).toString(), hotkey.publicKey) + ).rejects.toThrow(/user deposit alpha call failed/); + expect(await getStake(api, hotkeySs58, walletSs58, netuid)).toEqual(stakeBeforeDeposit); + expect(await stakingPrecompile.getStake(hotkey.publicKey, contractPublicKey, netuid)).toEqual( + BigInt(0) ); - expect(stakeFromContract).toEqual(await contractForCall.getContractStake(netuid)); }, });