From 11bb7b7c72a73d6bccceb15fe78f22ca9d0e6e14 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Sat, 28 Feb 2026 14:28:28 +0100 Subject: [PATCH] design multi signature scheme system --- bin/testapp/Cargo.toml | 1 + bin/testapp/tests/mempool_e2e.rs | 385 +++++++++++++++- crates/app/sdk/stf_traits/src/lib.rs | 13 + crates/app/stf/src/lib.rs | 1 + crates/app/tx/eth/src/eoa_registry.rs | 34 +- crates/app/tx/eth/src/error.rs | 5 + crates/app/tx/eth/src/gateway.rs | 47 +- crates/app/tx/eth/src/lib.rs | 8 +- crates/app/tx/eth/src/mempool.rs | 499 +++++++++++++++++---- crates/app/tx/eth/src/payload.rs | 35 ++ crates/app/tx/eth/src/sender_type.rs | 10 + crates/app/tx/eth/src/verifier/mod.rs | 2 +- crates/app/tx/eth/src/verifier/registry.rs | 106 +++-- crates/rpc/chain-index/src/provider.rs | 98 +++- crates/rpc/evnode/src/service.rs | 10 +- docker-compose.testapp.yml | 29 ++ docker/evd/Dockerfile | 5 + 17 files changed, 1149 insertions(+), 139 deletions(-) create mode 100644 crates/app/tx/eth/src/payload.rs create mode 100644 crates/app/tx/eth/src/sender_type.rs create mode 100644 docker-compose.testapp.yml diff --git a/bin/testapp/Cargo.toml b/bin/testapp/Cargo.toml index 50c97f8..cb4a22e 100644 --- a/bin/testapp/Cargo.toml +++ b/bin/testapp/Cargo.toml @@ -58,6 +58,7 @@ alloy-primitives = { workspace = true } async-trait = { workspace = true } evolve_mempool = { workspace = true } evolve_tx_eth = { workspace = true } +ed25519-consensus = "2.1.0" hex = "0.4" [[bench]] diff --git a/bin/testapp/tests/mempool_e2e.rs b/bin/testapp/tests/mempool_e2e.rs index 4d1d37e..c20453b 100644 --- a/bin/testapp/tests/mempool_e2e.rs +++ b/bin/testapp/tests/mempool_e2e.rs @@ -10,17 +10,26 @@ use alloy_consensus::{SignableTransaction, TxEip1559}; use alloy_primitives::{Address, Bytes, PrimitiveSignature, TxKind, B256, U256}; use async_trait::async_trait; -use evolve_core::{AccountId, ErrorCode, ReadonlyKV}; +use borsh::{BorshDeserialize, BorshSerialize}; +use ed25519_consensus::{SigningKey as Ed25519SigningKey, VerificationKey}; +use evolve_core::encoding::Encodable; +use evolve_core::{AccountId, ErrorCode, Message, ReadonlyKV, SdkResult}; +use evolve_mempool::MempoolTx; use evolve_node::{build_dev_node_with_mempool, DevNodeMempoolHandles}; use evolve_server::DevConfig; use evolve_simulator::{generate_signing_key, SimConfig, Simulator}; +use evolve_stf_traits::WritableAccountsCodeStorage; use evolve_storage::{CommitHash, Operation}; use evolve_testapp::{ build_mempool_stf, default_gas_config, do_eth_genesis, install_account_codes, EthGenesisAccounts, MempoolStf, }; use evolve_testing::server_mocks::AccountStorageMock; -use evolve_tx_eth::{derive_runtime_contract_address, EthGateway, TxContext}; +use evolve_tx_eth::{ + derive_runtime_contract_address, sender_types, EthGateway, EthIntentPayload, + SignatureVerifierDyn, SignatureVerifierRegistry, TxContext, TxEnvelope, TxPayload, + TypedTransaction, +}; use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey, VerifyingKey}; use std::collections::BTreeMap; use std::sync::RwLock; @@ -29,6 +38,130 @@ use tiny_keccak::{Hasher, Keccak}; type TestNodeHandles = DevNodeMempoolHandles; +#[derive(Clone, BorshSerialize, BorshDeserialize)] +struct Ed25519AuthPayload { + nonce: u64, + message_digest: [u8; 32], + signature: [u8; 64], +} + +#[derive(Clone, BorshSerialize, BorshDeserialize)] +struct Ed25519EthIntentProof { + public_key: [u8; 32], + signature: [u8; 64], +} + +struct Ed25519PayloadVerifier; + +impl SignatureVerifierDyn for Ed25519PayloadVerifier { + fn verify(&self, payload: &TxPayload) -> SdkResult<()> { + let TxPayload::Custom(bytes) = payload else { + return Err(ErrorCode::new(0x75)); + }; + let intent: EthIntentPayload = + borsh::from_slice(bytes).map_err(|_| ErrorCode::new(0x75))?; + let decoded: Ed25519EthIntentProof = + borsh::from_slice(&intent.auth_proof).map_err(|_| ErrorCode::new(0x75))?; + let envelope = intent.decode_envelope().map_err(|_| ErrorCode::new(0x75))?; + let invoke_request = envelope + .to_invoke_requests() + .into_iter() + .next() + .ok_or_else(|| ErrorCode::new(0x75))?; + let request_digest = keccak256(&invoke_request.encode().map_err(|_| ErrorCode::new(0x75))?); + let public_key = + VerificationKey::try_from(decoded.public_key).map_err(|_| ErrorCode::new(0x76))?; + let signature = ed25519_consensus::Signature::from(decoded.signature); + public_key + .verify(&signature, &request_digest) + .map_err(|_| ErrorCode::new(0x77)) + } +} + +#[evolve_core::account_impl(Ed25519AuthAccount)] +mod ed25519_auth_account { + use super::Ed25519AuthPayload; + use core::convert::TryFrom; + use ed25519_consensus::{Signature, VerificationKey}; + use evolve_authentication::auth_interface::AuthenticationInterface; + use evolve_collections::item::Item; + use evolve_core::{Environment, ErrorCode, Message, SdkResult}; + use evolve_macros::{exec, init, query}; + + fn err_invalid_auth_payload() -> ErrorCode { + ErrorCode::new(0x71) + } + + fn err_nonce_mismatch() -> ErrorCode { + ErrorCode::new(0x72) + } + + fn err_invalid_public_key() -> ErrorCode { + ErrorCode::new(0x73) + } + + fn err_invalid_signature() -> ErrorCode { + ErrorCode::new(0x74) + } + + pub struct Ed25519AuthAccount { + pub nonce: Item, + pub public_key: Item<[u8; 32]>, + } + + impl Default for Ed25519AuthAccount { + fn default() -> Self { + Self::new() + } + } + + impl Ed25519AuthAccount { + pub const fn new() -> Self { + Self { + nonce: Item::new(0), + public_key: Item::new(1), + } + } + + #[init] + pub fn initialize(&self, public_key: [u8; 32], env: &mut dyn Environment) -> SdkResult<()> { + self.nonce.set(&0, env)?; + self.public_key.set(&public_key, env)?; + Ok(()) + } + + #[query] + pub fn get_nonce(&self, env: &mut dyn evolve_core::EnvironmentQuery) -> SdkResult { + Ok(self.nonce.may_get(env)?.unwrap_or(0)) + } + } + + impl AuthenticationInterface for Ed25519AuthAccount { + #[exec] + fn authenticate(&self, tx: Message, env: &mut dyn Environment) -> SdkResult<()> { + let payload: Ed25519AuthPayload = tx.get().map_err(|_| err_invalid_auth_payload())?; + let current_nonce = self.nonce.may_get(env)?.unwrap_or(0); + if payload.nonce != current_nonce { + return Err(err_nonce_mismatch()); + } + + let pubkey_bytes = self + .public_key + .may_get(env)? + .ok_or(err_invalid_public_key())?; + let verify_key = + VerificationKey::try_from(pubkey_bytes).map_err(|_| err_invalid_public_key())?; + let signature = Signature::from(payload.signature); + verify_key + .verify(&signature, &payload.message_digest) + .map_err(|_| err_invalid_signature())?; + + self.nonce.set(&(current_nonce + 1), env)?; + Ok(()) + } + } +} + // ============================================================================ // Test Infrastructure // ============================================================================ @@ -111,6 +244,31 @@ impl AsyncMockStorage { let addr_value = Message::new(ð_address).unwrap().into_bytes().unwrap(); self.data.write().unwrap().insert(addr_key, addr_value); } + + /// Initialize an Ed25519AuthAccount's storage (nonce and public key). + fn init_ed25519_auth_storage(&self, account_id: AccountId, public_key: [u8; 32]) { + // Storage keys are: account_id + prefix (u8) + // Item::new(0) = nonce, Item::new(1) = public key + + let mut nonce_key = account_id.as_bytes().to_vec(); + nonce_key.push(0u8); + let nonce_value = Message::new(&0u64).unwrap().into_bytes().unwrap(); + self.data.write().unwrap().insert(nonce_key, nonce_value); + + let mut key_key = account_id.as_bytes().to_vec(); + key_key.push(1u8); + let key_value = Message::new(&public_key).unwrap().into_bytes().unwrap(); + self.data.write().unwrap().insert(key_key, key_value); + } + + /// Set token balance directly in storage for a specific account. + fn set_token_balance(&self, token_account_id: AccountId, account_id: AccountId, balance: u128) { + let mut key = token_account_id.as_bytes().to_vec(); + key.push(1u8); // Token::balances storage prefix + key.extend(account_id.encode().expect("encode account id")); + let value = Message::new(&balance).unwrap().into_bytes().unwrap(); + self.data.write().unwrap().insert(key, value); + } } impl Clone for AsyncMockStorage { @@ -305,6 +463,64 @@ fn setup_genesis( (handles, genesis_accounts, alice_account_id, bob_account_id) } +fn setup_genesis_with_ed25519_sender( + chain_id: u64, + alice_address: Address, + bob_address: Address, + sender_account_id: AccountId, + sender_public_key: [u8; 32], + sender_initial_balance: u128, +) -> (TestNodeHandles, EthGenesisAccounts, AccountId, AccountId) { + let mut codes = AccountStorageMock::new(); + install_account_codes(&mut codes); + codes + .add_code(ed25519_auth_account::Ed25519AuthAccount::new()) + .expect("install ed25519 auth account code"); + + let init_storage = AsyncMockStorage::new(); + let gas_config = default_gas_config(); + let stf = build_mempool_stf(gas_config.clone(), AccountId::from_u64(0)); + + let (genesis_state, genesis_accounts) = do_eth_genesis( + &stf, + &codes, + &init_storage, + alice_address.into(), + bob_address.into(), + ) + .expect("genesis should succeed"); + + let genesis_changes = genesis_state.into_changes().expect("get changes"); + init_storage.apply_changes(genesis_changes); + init_storage.register_account_code(sender_account_id, "Ed25519AuthAccount"); + init_storage.init_ed25519_auth_storage(sender_account_id, sender_public_key); + init_storage.set_token_balance( + genesis_accounts.evolve, + sender_account_id, + sender_initial_balance, + ); + + let stf = build_mempool_stf(gas_config, genesis_accounts.scheduler); + let config = DevConfig { + block_interval: None, + gas_limit: 30_000_000, + initial_height: 1, + chain_id, + }; + let handles = build_dev_node_with_mempool(stf, init_storage, codes, config); + + let alice_account_id = + evolve_tx_eth::lookup_account_id_in_storage(handles.dev.storage(), alice_address) + .expect("lookup alice id") + .expect("alice id exists"); + let bob_account_id = + evolve_tx_eth::lookup_account_id_in_storage(handles.dev.storage(), bob_address) + .expect("lookup bob id") + .expect("bob id exists"); + + (handles, genesis_accounts, alice_account_id, bob_account_id) +} + fn build_transfer_tx( alice_key: &SigningKey, chain_id: u64, @@ -328,12 +544,69 @@ fn build_transfer_tx( ) } -async fn submit_and_produce_block(handles: &TestNodeHandles, chain_id: u64, raw_tx: &[u8]) -> B256 { +struct Ed25519CustomTxBuildInput<'a> { + tx_template_signer: &'a SigningKey, + auth_signer: &'a Ed25519SigningKey, + chain_id: u64, + token_address: Address, + recipient_account_id: AccountId, + transfer_amount: u128, + sender_account_id: AccountId, +} + +fn build_ed25519_custom_tx_context(input: Ed25519CustomTxBuildInput<'_>) -> TxContext { + let template_raw_tx = build_transfer_tx( + input.tx_template_signer, + input.chain_id, + input.token_address, + input.recipient_account_id, + input.transfer_amount, + ); + let envelope = TxEnvelope::decode(&template_raw_tx).expect("decode transfer tx envelope"); + let invoke_request = envelope + .to_invoke_requests() + .into_iter() + .next() + .expect("expected transfer invoke request"); + let request_digest = keccak256( + &invoke_request + .encode() + .expect("encode invoke request for digest"), + ); + let signature = input.auth_signer.sign(&request_digest).to_bytes(); + let auth_payload = Ed25519AuthPayload { + nonce: 0, + message_digest: request_digest, + signature, + }; + let proof = Ed25519EthIntentProof { + public_key: input.auth_signer.verification_key().to_bytes(), + signature, + }; + let intent = EthIntentPayload { + envelope: template_raw_tx, + auth_proof: borsh::to_vec(&proof).expect("encode intent proof"), + }; + + TxContext::from_eth_intent( + sender_types::CUSTOM, + intent, + input.sender_account_id, + input.sender_account_id.as_bytes().to_vec(), + Message::new(&auth_payload).expect("encode auth payload"), + 0, + ) + .expect("construct custom tx context from eth intent") +} + +async fn submit_context_and_produce_block( + handles: &TestNodeHandles, + tx_context: TxContext, +) -> B256 { let tx_hash = { - let gateway = EthGateway::new(chain_id); - let tx_context = gateway.decode_and_verify(raw_tx).expect("decode tx"); + let tx_id = tx_context.tx_id(); let mut pool = handles.mempool.write().await; - let tx_id = pool.add(tx_context).expect("add tx to mempool"); + pool.add(tx_context).expect("add tx to mempool"); B256::from(tx_id) }; @@ -367,6 +640,14 @@ async fn submit_and_produce_block(handles: &TestNodeHandles, chain_id: u64, raw_ tx_hash } +async fn submit_and_produce_block(handles: &TestNodeHandles, chain_id: u64, raw_tx: &[u8]) -> B256 { + let tx_context = { + let gateway = EthGateway::new(chain_id); + gateway.decode_and_verify(raw_tx).expect("decode tx") + }; + submit_context_and_produce_block(handles, tx_context).await +} + fn assert_post_block_state( handles: &TestNodeHandles, genesis_accounts: &EthGenesisAccounts, @@ -461,3 +742,95 @@ async fn test_token_transfer_e2e() { bob_balance_before, ); } + +#[tokio::test] +async fn test_custom_sender_ed25519_transfer_e2e() { + let chain_id = 1338u64; + let transfer_amount = 75u128; + let sender_account_id = AccountId::from_u64(900_001); + let sender_initial_balance = 500u128; + + let (alice_key, bob_key) = deterministic_signing_keys(); + let alice_address = get_address(&alice_key); + let bob_address = get_address(&bob_key); + + let auth_signer = Ed25519SigningKey::from([0x42; 32]); + let sender_public_key = auth_signer.verification_key().to_bytes(); + + let (handles, genesis_accounts, _alice_account_id, bob_account_id) = + setup_genesis_with_ed25519_sender( + chain_id, + alice_address, + bob_address, + sender_account_id, + sender_public_key, + sender_initial_balance, + ); + + let sender_nonce_before = read_nonce(handles.dev.storage(), sender_account_id); + let sender_balance_before = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + sender_account_id, + ); + let bob_balance_before = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + bob_account_id, + ); + + assert_eq!( + sender_nonce_before, 0, + "custom sender nonce should start at 0" + ); + assert_eq!( + sender_balance_before, sender_initial_balance, + "custom sender should start funded" + ); + + let token_address = derive_runtime_contract_address(genesis_accounts.evolve); + let tx_context = build_ed25519_custom_tx_context(Ed25519CustomTxBuildInput { + tx_template_signer: &alice_key, + auth_signer: &auth_signer, + chain_id, + token_address, + recipient_account_id: bob_account_id, + transfer_amount, + sender_account_id, + }); + + let mut registry = SignatureVerifierRegistry::new(); + registry.register_dyn(sender_types::CUSTOM, Ed25519PayloadVerifier); + registry + .verify_payload(sender_types::CUSTOM, tx_context.payload()) + .expect("custom payload should pass ed25519 verifier"); + + let _tx_hash = submit_context_and_produce_block(&handles, tx_context).await; + + let sender_nonce_after = read_nonce(handles.dev.storage(), sender_account_id); + let sender_balance_after = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + sender_account_id, + ); + let bob_balance_after = read_token_balance( + handles.dev.storage(), + genesis_accounts.evolve, + bob_account_id, + ); + + assert_eq!( + sender_nonce_after, 1, + "ed25519 sender nonce should increment after auth" + ); + assert_eq!( + sender_balance_after, + sender_balance_before - transfer_amount, + "custom sender balance should decrease by transfer amount" + ); + assert_eq!( + bob_balance_after, + bob_balance_before + transfer_amount, + "recipient balance should increase by transfer amount" + ); +} diff --git a/crates/app/sdk/stf_traits/src/lib.rs b/crates/app/sdk/stf_traits/src/lib.rs index 71abfd9..8c05073 100644 --- a/crates/app/sdk/stf_traits/src/lib.rs +++ b/crates/app/sdk/stf_traits/src/lib.rs @@ -44,6 +44,19 @@ pub trait Transaction { None } + /// Optional hook executed immediately after sender bootstrap registration. + /// + /// This is useful for transaction types that need to atomically maintain + /// auxiliary sender indexes (for example address-to-account registries) + /// once bootstrap account creation has completed. + fn after_sender_bootstrap( + &self, + _resolved_sender: AccountId, + _env: &mut dyn Environment, + ) -> SdkResult<()> { + Ok(()) + } + /// Optional original 20-byte sender address (for ETH-compatible indexing). fn sender_eth_address(&self) -> Option<[u8; 20]> { None diff --git a/crates/app/stf/src/lib.rs b/crates/app/stf/src/lib.rs index b988fbc..70bab39 100644 --- a/crates/app/stf/src/lib.rs +++ b/crates/app/stf/src/lib.rs @@ -1292,6 +1292,7 @@ where bootstrap.account_code_id, bootstrap.init_message, )?; + tx.after_sender_bootstrap(resolved_sender, &mut reg_ctx)?; drop(reg_ctx); state.pop_events(); Ok(()) diff --git a/crates/app/tx/eth/src/eoa_registry.rs b/crates/app/tx/eth/src/eoa_registry.rs index 0115cad..4a2ea47 100644 --- a/crates/app/tx/eth/src/eoa_registry.rs +++ b/crates/app/tx/eth/src/eoa_registry.rs @@ -13,7 +13,7 @@ const EOA_ADDR_TO_ID_PREFIX: &[u8] = b"registry/eoa/eth/a2i/"; const EOA_ID_TO_ADDR_PREFIX: &[u8] = b"registry/eoa/eth/i2a/"; const CONTRACT_ADDR_TO_ID_PREFIX: &[u8] = b"registry/contract/runtime/a2i/"; const CONTRACT_ID_TO_ADDR_PREFIX: &[u8] = b"registry/contract/runtime/i2a/"; -const ETH_EOA_CODE_ID: &str = "EthEoaAccount"; +pub const ETH_EOA_CODE_ID: &str = "EthEoaAccount"; fn addr_to_id_key(address: Address) -> Vec { let mut key = Vec::with_capacity(EOA_ADDR_TO_ID_PREFIX.len() + 20); @@ -182,6 +182,36 @@ fn set_mapping( Ok(()) } +/// Ensure address/account mapping exists and is conflict-free. +/// +/// This is idempotent for already consistent mappings. +pub fn ensure_eoa_mapping( + address: Address, + account_id: AccountId, + env: &mut dyn Environment, +) -> SdkResult<()> { + if let Some(existing) = lookup_account_id_in_env(address, env)? { + if existing != account_id { + return Err(ERR_ADDRESS_ACCOUNT_CONFLICT); + } + if let Some(existing_addr) = lookup_address_in_env(account_id, env)? { + if existing_addr != address { + return Err(ERR_ADDRESS_ACCOUNT_CONFLICT); + } + return Ok(()); + } + } + + if let Some(existing_addr) = lookup_address_in_env(account_id, env)? { + if existing_addr != address { + return Err(ERR_ADDRESS_ACCOUNT_CONFLICT); + } + return set_mapping(address, account_id, env); + } + + set_mapping(address, account_id, env) +} + pub fn resolve_or_create_eoa_account( address: Address, env: &mut dyn Environment, @@ -198,7 +228,7 @@ pub fn resolve_or_create_eoa_account( env, )?; - set_mapping(address, account_id, env)?; + ensure_eoa_mapping(address, account_id, env)?; Ok(account_id) } diff --git a/crates/app/tx/eth/src/error.rs b/crates/app/tx/eth/src/error.rs index 0ceb3c4..5b56d19 100644 --- a/crates/app/tx/eth/src/error.rs +++ b/crates/app/tx/eth/src/error.rs @@ -18,6 +18,11 @@ define_error!( define_error!(ERR_TX_DECODE, 0x14, "failed to decode transaction"); define_error!(ERR_EMPTY_INPUT, 0x15, "empty transaction input"); define_error!(ERR_INVALID_TX_HASH, 0x16, "transaction hash mismatch"); +define_error!( + ERR_UNSUPPORTED_SENDER_TYPE, + 0x17, + "unsupported sender type {arg}" +); // Nonce errors (0x18-0x1B range) define_error!(ERR_NONCE_TOO_LOW, 0x18, "nonce too low"); diff --git a/crates/app/tx/eth/src/gateway.rs b/crates/app/tx/eth/src/gateway.rs index 8a947fa..4cc7bea 100644 --- a/crates/app/tx/eth/src/gateway.rs +++ b/crates/app/tx/eth/src/gateway.rs @@ -5,13 +5,16 @@ //! 2. Verifying signatures and chain ID //! 3. Producing verified `TxContext` ready for mempool insertion +use evolve_core::encoding::Decodable; use evolve_stf_traits::TxDecoder; use crate::decoder::TypedTxDecoder; use crate::envelope::TxEnvelope; use crate::mempool::TxContext; +use crate::payload::TxPayload; +use crate::sender_type; use crate::traits::TypedTransaction; -use crate::verifier::SignatureVerifierRegistry; +use crate::verifier::{SignatureVerifierDyn, SignatureVerifierRegistry}; /// Error type for gateway operations. #[derive(Debug, Clone)] @@ -101,10 +104,30 @@ impl EthGateway { self.base_fee = base_fee; } + /// Register a payload verifier for a sender type. + pub fn register_payload_verifier( + &mut self, + sender_type: u16, + verifier: impl SignatureVerifierDyn + 'static, + ) { + self.verifier.register_dyn(sender_type, verifier); + } + + /// Check whether a sender type is supported by ingress verification. + pub fn supports_sender_type(&self, sender_type: u16) -> bool { + self.verifier.supports(sender_type) + } + /// Decode and verify a raw transaction. /// /// Returns a verified `TxContext` ready for mempool insertion. pub fn decode_and_verify(&self, raw: &[u8]) -> Result { + if TxContext::is_wire_encoded(raw) { + let context = TxContext::decode(raw) + .map_err(|e| GatewayError::DecodeFailed(format!("{:?}", e)))?; + return self.verify_context(context); + } + // Decode the transaction with type filtering let mut input = raw; let envelope = self @@ -119,11 +142,31 @@ impl EthGateway { self.verify_envelope(envelope) } + /// Verify sender-type payload for a decoded context. + pub fn verify_context(&self, context: TxContext) -> Result { + if let Some(id) = context.chain_id() { + if id != self.chain_id { + return Err(GatewayError::InvalidChainId { + expected: self.chain_id, + actual: Some(id), + }); + } + } + + self.verifier + .verify_payload(context.sender_type(), context.payload()) + .map_err(|_| GatewayError::InvalidSignature)?; + + Ok(context) + } + /// Verify an already-decoded envelope and create a TxContext. pub fn verify_envelope(&self, envelope: TxEnvelope) -> Result { + let payload = TxPayload::Eoa(Box::new(envelope.clone())); + // Verify chain ID and signature self.verifier - .verify(&envelope) + .verify_payload(sender_type::EOA_SECP256K1, &payload) .map_err(|_| match envelope.chain_id() { Some(id) if id != self.chain_id => GatewayError::InvalidChainId { expected: self.chain_id, diff --git a/crates/app/tx/eth/src/lib.rs b/crates/app/tx/eth/src/lib.rs index af88ccc..765af25 100644 --- a/crates/app/tx/eth/src/lib.rs +++ b/crates/app/tx/eth/src/lib.rs @@ -40,6 +40,8 @@ pub mod error; pub mod ethereum; pub mod gateway; pub mod mempool; +pub mod payload; +pub mod sender_type; pub mod traits; pub mod verifier; @@ -55,9 +57,11 @@ pub use eoa_registry::{ pub use error::*; pub use ethereum::{SignedEip1559Tx, SignedLegacyTx}; pub use gateway::{EthGateway, GatewayError}; -pub use mempool::TxContext; +pub use mempool::{TxContext, TxContextMeta}; +pub use payload::{EthIntentPayload, TxPayload}; +pub use sender_type as sender_types; pub use traits::{ derive_eth_eoa_account_id, derive_runtime_contract_account_id, derive_runtime_contract_address, derive_system_account_id, TypedTransaction, }; -pub use verifier::{EcdsaVerifier, SignatureVerifierRegistry}; +pub use verifier::{EcdsaVerifier, SignatureVerifierDyn, SignatureVerifierRegistry}; diff --git a/crates/app/tx/eth/src/mempool.rs b/crates/app/tx/eth/src/mempool.rs index d602fbc..ef2b07f 100644 --- a/crates/app/tx/eth/src/mempool.rs +++ b/crates/app/tx/eth/src/mempool.rs @@ -1,93 +1,289 @@ -//! Mempool transaction wrapper for Ethereum transactions. -//! -//! Wraps an Ethereum `TxEnvelope` and implements the `MempoolTx` trait -//! for use with the generic mempool. +//! Mempool transaction context for Ethereum and custom sender payloads. //! //! # Function Routing //! -//! Routing is defined by `evolve_tx_eth` and derived from Ethereum calldata. -//! See `TxEnvelope::to_invoke_requests()` for the canonical mapping. -//! -//! Example: A CLOB account can expose `place_order`, `cancel_order`, etc., each with -//! their own Ethereum selector computed as `keccak256(signature)[0..4]`. +//! Routing for Ethereum EOAs is derived from calldata via +//! `TxEnvelope::to_invoke_requests()`. -use alloy_primitives::{Address, B256}; +use alloy_primitives::{keccak256, Address, B256}; +use borsh::{BorshDeserialize, BorshSerialize}; use evolve_core::encoding::{Decodable, Encodable}; use evolve_core::{AccountId, Environment, FungibleAsset, InvokeRequest, Message, SdkResult}; use evolve_mempool::{GasPriceOrdering, MempoolTx, SenderKey}; -use evolve_stf_traits::{AuthenticationPayload, Transaction}; +use evolve_stf_traits::{AuthenticationPayload, SenderBootstrap, Transaction}; use crate::envelope::TxEnvelope; use crate::eoa_registry::{ - lookup_account_id_in_env, lookup_contract_account_id_in_env, resolve_or_create_eoa_account, + ensure_eoa_mapping, lookup_account_id_in_env, lookup_contract_account_id_in_env, + resolve_or_create_eoa_account, ETH_EOA_CODE_ID, }; -use crate::error::ERR_RECIPIENT_REQUIRED; -use crate::traits::TypedTransaction; +use crate::error::{ERR_RECIPIENT_REQUIRED, ERR_TX_DECODE}; +use crate::payload::{EthIntentPayload, TxPayload}; +use crate::sender_type; +use crate::traits::{derive_eth_eoa_account_id, TypedTransaction}; + +const TX_CONTEXT_WIRE_MAGIC: [u8; 4] = *b"ctx1"; + +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +enum TxPayloadWire { + Eoa(Vec), + Custom(Vec), +} + +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +struct TxContextWireV1 { + sender_type: u16, + payload: TxPayloadWire, + tx_hash: [u8; 32], + gas_limit: u64, + nonce: u64, + chain_id: Option, + effective_gas_price: u128, + invoke_request: InvokeRequest, + funds: Vec, + sender_account: AccountId, + recipient_account: Option, + sender_key: Vec, + authentication_payload: Message, + sender_eth_address: Option<[u8; 20]>, + recipient_eth_address: Option<[u8; 20]>, +} + +#[derive(Clone, Debug)] +enum SenderResolution { + EoaAddress(Address), + Account(AccountId), +} + +#[derive(Clone, Debug)] +enum RecipientResolution { + EoaAddress(Address), + Account(AccountId), + None, +} + +/// Metadata required to construct a context from a non-EOA payload. +#[derive(Clone, Debug)] +pub struct TxContextMeta { + pub tx_hash: B256, + pub gas_limit: u64, + pub nonce: u64, + pub chain_id: Option, + pub effective_gas_price: u128, + pub invoke_request: InvokeRequest, + pub funds: Vec, + pub sender_account: AccountId, + pub recipient_account: Option, + pub sender_key: Vec, + pub authentication_payload: Message, + pub sender_eth_address: Option<[u8; 20]>, + pub recipient_eth_address: Option<[u8; 20]>, +} /// A verified transaction ready for mempool storage. -/// -/// Wraps an Ethereum `TxEnvelope` and caches derived values for efficient -/// access during block production. #[derive(Clone, Debug)] pub struct TxContext { - /// The original Ethereum transaction envelope. - envelope: TxEnvelope, - /// The invoke request to execute (derived by evolve_tx). + payload: TxPayload, + sender_type: u16, invoke_request: InvokeRequest, - /// Gas price for ordering (effective gas price). effective_gas_price: u128, + tx_hash: B256, + gas_limit: u64, + nonce: u64, + chain_id: Option, + sender_resolution: SenderResolution, + recipient_resolution: RecipientResolution, + sender_key: Vec, + authentication_payload: Message, + sender_eth_address: Option<[u8; 20]>, + recipient_eth_address: Option<[u8; 20]>, + funds: Vec, } impl TxContext { - /// Create a new mempool transaction from an Ethereum envelope. + /// Create a new context from an Ethereum EOA envelope. /// - /// Returns `None` if the transaction has no recipient (contract creation). + /// Returns `None` for contract creation transactions (`to == None`). pub fn new(envelope: TxEnvelope, base_fee: u128) -> Option { - // TODO(vm): when EVM contract creation is supported, allow `to == None` - // and route create-transactions through deployment execution instead of - // rejecting them at mempool decode time. - envelope.to()?; - + let to = envelope.to()?; + let sender = envelope.sender(); let invoke_request = envelope.to_invoke_requests().into_iter().next()?; - - // Calculate effective gas price for ordering - let effective_gas_price = calculate_effective_gas_price(&envelope, base_fee); + let authentication_payload = Message::new(&sender.into_array()).ok()?; Some(Self { - envelope, + sender_type: sender_type::EOA_SECP256K1, + payload: TxPayload::Eoa(Box::new(envelope.clone())), invoke_request, - effective_gas_price, + effective_gas_price: calculate_effective_gas_price(&envelope, base_fee), + tx_hash: envelope.tx_hash(), + gas_limit: envelope.gas_limit(), + nonce: envelope.nonce(), + chain_id: envelope.chain_id(), + sender_resolution: SenderResolution::EoaAddress(sender), + recipient_resolution: RecipientResolution::EoaAddress(to), + sender_key: sender.as_slice().to_vec(), + authentication_payload, + sender_eth_address: Some(sender.into()), + recipient_eth_address: Some(to.into()), + funds: Vec::new(), }) } + /// Create a context from an arbitrary payload and explicit metadata. + pub fn from_payload(payload: TxPayload, sender_type: u16, meta: TxContextMeta) -> Option { + let sender_resolution = if sender_type == sender_type::EOA_SECP256K1 { + let addr = Address::from(meta.sender_eth_address?); + SenderResolution::EoaAddress(addr) + } else { + SenderResolution::Account(meta.sender_account) + }; + + let recipient_resolution = if let Some(account) = meta.recipient_account { + RecipientResolution::Account(account) + } else if let Some(addr) = meta.recipient_eth_address { + RecipientResolution::EoaAddress(Address::from(addr)) + } else { + RecipientResolution::None + }; + + let sender_key = if meta.sender_key.is_empty() { + match sender_resolution { + SenderResolution::EoaAddress(addr) => addr.as_slice().to_vec(), + SenderResolution::Account(account) => account.as_bytes().to_vec(), + } + } else { + meta.sender_key + }; + + let _ = SenderKey::new(&sender_key)?; + + Some(Self { + payload, + sender_type, + invoke_request: meta.invoke_request, + effective_gas_price: meta.effective_gas_price, + tx_hash: meta.tx_hash, + gas_limit: meta.gas_limit, + nonce: meta.nonce, + chain_id: meta.chain_id, + sender_resolution, + recipient_resolution, + sender_key, + authentication_payload: meta.authentication_payload, + sender_eth_address: meta.sender_eth_address, + recipient_eth_address: meta.recipient_eth_address, + funds: meta.funds, + }) + } + + /// Create a custom-sender context that reuses Ethereum transaction fields. + /// + /// The `intent` envelope provides Ethereum execution semantics (`to`, calldata, + /// nonce, gas, and chain id), while authentication is delegated to `sender_type` + /// via `auth_proof` and `authentication_payload`. + pub fn from_eth_intent( + sender_type: u16, + intent: EthIntentPayload, + sender_account: AccountId, + sender_key: Vec, + authentication_payload: Message, + base_fee: u128, + ) -> Option { + let envelope = intent.decode_envelope().ok()?; + let to = envelope.to()?; + let invoke_request = envelope.to_invoke_requests().into_iter().next()?; + let payload_bytes = borsh::to_vec(&intent).ok()?; + let tx_hash = B256::from(keccak256(&payload_bytes)); + + Self::from_payload( + TxPayload::Custom(payload_bytes), + sender_type, + TxContextMeta { + tx_hash, + gas_limit: envelope.gas_limit(), + nonce: envelope.nonce(), + chain_id: envelope.chain_id(), + effective_gas_price: calculate_effective_gas_price(&envelope, base_fee), + invoke_request, + funds: Vec::new(), + sender_account, + recipient_account: None, + sender_key, + authentication_payload, + sender_eth_address: None, + recipient_eth_address: Some(to.into()), + }, + ) + } + + /// Returns true when bytes are encoded with the custom `TxContext` wire format. + pub fn is_wire_encoded(bytes: &[u8]) -> bool { + bytes.len() >= TX_CONTEXT_WIRE_MAGIC.len() + && bytes[..TX_CONTEXT_WIRE_MAGIC.len()] == TX_CONTEXT_WIRE_MAGIC + } + /// Get the transaction hash. pub fn hash(&self) -> B256 { - self.envelope.tx_hash() + self.tx_hash + } + + /// Get sender type. + pub fn sender_type(&self) -> u16 { + self.sender_type } - /// Get the sender address. + /// Get sender address for EOA payloads. pub fn sender_address(&self) -> Address { - self.envelope.sender() + match self.sender_resolution { + SenderResolution::EoaAddress(address) => address, + SenderResolution::Account(_) => { + panic!("sender_address() is only available for EOA sender types") + } + } } - /// Get the nonce. + /// Get sender address when available. + pub fn sender_address_opt(&self) -> Option
{ + match self.sender_resolution { + SenderResolution::EoaAddress(address) => Some(address), + SenderResolution::Account(_) => None, + } + } + + /// Get nonce. pub fn nonce(&self) -> u64 { - self.envelope.nonce() + self.nonce } - /// Get the effective gas price for ordering. + /// Get effective gas price. pub fn effective_gas_price(&self) -> u128 { self.effective_gas_price } - /// Get the underlying envelope. + /// Get payload. + pub fn payload(&self) -> &TxPayload { + &self.payload + } + + /// Get envelope for EOA payloads. pub fn envelope(&self) -> &TxEnvelope { - &self.envelope + match &self.payload { + TxPayload::Eoa(envelope) => envelope.as_ref(), + TxPayload::Custom(_) => panic!("envelope() is only available for EOA payloads"), + } } - /// Get the chain ID. + /// Get envelope when available. + pub fn envelope_opt(&self) -> Option<&TxEnvelope> { + match &self.payload { + TxPayload::Eoa(envelope) => Some(envelope.as_ref()), + TxPayload::Custom(_) => None, + } + } + + /// Get chain ID. pub fn chain_id(&self) -> Option { - self.envelope.chain_id() + self.chain_id } } @@ -95,47 +291,64 @@ impl MempoolTx for TxContext { type OrderingKey = GasPriceOrdering; fn tx_id(&self) -> [u8; 32] { - self.envelope.tx_hash().0 + self.tx_hash.0 } fn ordering_key(&self) -> Self::OrderingKey { - GasPriceOrdering::new(self.effective_gas_price, self.nonce()) + GasPriceOrdering::new(self.effective_gas_price, self.nonce) } fn sender_key(&self) -> Option { - SenderKey::new(&self.envelope.sender().0 .0) + SenderKey::new(&self.sender_key) } fn gas_limit(&self) -> u64 { - self.envelope.gas_limit() + self.gas_limit } } impl Transaction for TxContext { fn sender(&self) -> AccountId { - AccountId::invalid() + match self.sender_resolution { + SenderResolution::Account(account) => account, + SenderResolution::EoaAddress(address) => derive_eth_eoa_account_id(address), + } } fn resolve_sender_account(&self, env: &mut dyn Environment) -> SdkResult { - resolve_or_create_eoa_account(self.sender_address(), env) + match self.sender_resolution { + SenderResolution::Account(account) => Ok(account), + SenderResolution::EoaAddress(address) => { + if let Some(account_id) = lookup_account_id_in_env(address, env)? { + return Ok(account_id); + } + Ok(derive_eth_eoa_account_id(address)) + } + } } fn recipient(&self) -> AccountId { - AccountId::invalid() + match self.recipient_resolution { + RecipientResolution::Account(account) => account, + RecipientResolution::EoaAddress(address) => derive_eth_eoa_account_id(address), + RecipientResolution::None => AccountId::invalid(), + } } fn resolve_recipient_account(&self, env: &mut dyn Environment) -> SdkResult { - // TODO(vm): contract creation currently has no recipient and is rejected. - // Once VM deployment is supported, this branch should route to creation - // logic instead of returning recipient-required. - let to = self.envelope.to().ok_or(ERR_RECIPIENT_REQUIRED)?; - if let Some(account_id) = lookup_account_id_in_env(to, env)? { - return Ok(account_id); - } - if let Some(account_id) = lookup_contract_account_id_in_env(to, env)? { - return Ok(account_id); + match self.recipient_resolution { + RecipientResolution::Account(account) => Ok(account), + RecipientResolution::EoaAddress(to) => { + if let Some(account_id) = lookup_account_id_in_env(to, env)? { + return Ok(account_id); + } + if let Some(account_id) = lookup_contract_account_id_in_env(to, env)? { + return Ok(account_id); + } + resolve_or_create_eoa_account(to, env) + } + RecipientResolution::None => Err(ERR_RECIPIENT_REQUIRED), } - resolve_or_create_eoa_account(to, env) } fn request(&self) -> &InvokeRequest { @@ -143,45 +356,147 @@ impl Transaction for TxContext { } fn gas_limit(&self) -> u64 { - self.envelope.gas_limit() + self.gas_limit } fn funds(&self) -> &[FungibleAsset] { - // TODO: Convert value transfer to FungibleAsset when native token is defined - &[] + &self.funds } fn compute_identifier(&self) -> [u8; 32] { - self.envelope.tx_hash().0 + self.tx_hash.0 + } + + fn sender_bootstrap(&self) -> Option { + if self.sender_type != sender_type::EOA_SECP256K1 { + return None; + } + let address = self.sender_address_opt()?; + let init_message = Message::new(&address.into_array()).ok()?; + Some(SenderBootstrap { + account_code_id: ETH_EOA_CODE_ID, + init_message, + }) + } + + fn after_sender_bootstrap( + &self, + resolved_sender: AccountId, + env: &mut dyn Environment, + ) -> SdkResult<()> { + if self.sender_type != sender_type::EOA_SECP256K1 { + return Ok(()); + } + let Some(address) = self.sender_address_opt() else { + return Err(ERR_TX_DECODE); + }; + ensure_eoa_mapping(address, resolved_sender, env) } fn sender_eth_address(&self) -> Option<[u8; 20]> { - Some(self.sender_address().into()) + self.sender_eth_address } fn recipient_eth_address(&self) -> Option<[u8; 20]> { - self.envelope.to().map(Into::into) + self.recipient_eth_address } } impl AuthenticationPayload for TxContext { fn authentication_payload(&self) -> SdkResult { - let sender: [u8; 20] = self.sender_address().into(); - Message::new(&sender) + Ok(self.authentication_payload.clone()) } } impl Encodable for TxContext { fn encode(&self) -> SdkResult> { - Ok(self.envelope.encode()) + if let TxPayload::Eoa(envelope) = &self.payload { + return Ok(envelope.encode()); + } + + let sender_account = self.sender(); + let recipient_account = match self.recipient_resolution { + RecipientResolution::Account(account) => Some(account), + RecipientResolution::EoaAddress(_) | RecipientResolution::None => None, + }; + let payload = match &self.payload { + TxPayload::Eoa(envelope) => TxPayloadWire::Eoa(envelope.encode()), + TxPayload::Custom(bytes) => TxPayloadWire::Custom(bytes.clone()), + }; + let wire = TxContextWireV1 { + sender_type: self.sender_type, + payload, + tx_hash: self.tx_hash.0, + gas_limit: self.gas_limit, + nonce: self.nonce, + chain_id: self.chain_id, + effective_gas_price: self.effective_gas_price, + invoke_request: self.invoke_request.clone(), + funds: self.funds.clone(), + sender_account, + recipient_account, + sender_key: self.sender_key.clone(), + authentication_payload: self.authentication_payload.clone(), + sender_eth_address: self.sender_eth_address, + recipient_eth_address: self.recipient_eth_address, + }; + + let mut encoded = TX_CONTEXT_WIRE_MAGIC.to_vec(); + encoded.extend(borsh::to_vec(&wire).map_err(|_| ERR_TX_DECODE)?); + Ok(encoded) } } impl Decodable for TxContext { fn decode(bytes: &[u8]) -> SdkResult { + if Self::is_wire_encoded(bytes) { + let wire: TxContextWireV1 = borsh::from_slice(&bytes[TX_CONTEXT_WIRE_MAGIC.len()..]) + .map_err(|_| ERR_TX_DECODE)?; + + if SenderKey::new(&wire.sender_key).is_none() { + return Err(ERR_TX_DECODE); + } + + let payload = match wire.payload { + TxPayloadWire::Eoa(raw) => TxPayload::Eoa(Box::new(TxEnvelope::decode(&raw)?)), + TxPayloadWire::Custom(raw) => TxPayload::Custom(raw), + }; + + let sender_resolution = if wire.sender_type == sender_type::EOA_SECP256K1 { + let address = wire.sender_eth_address.ok_or(ERR_TX_DECODE)?; + SenderResolution::EoaAddress(Address::from(address)) + } else { + SenderResolution::Account(wire.sender_account) + }; + + let recipient_resolution = if let Some(account) = wire.recipient_account { + RecipientResolution::Account(account) + } else if let Some(address) = wire.recipient_eth_address { + RecipientResolution::EoaAddress(Address::from(address)) + } else { + RecipientResolution::None + }; + + return Ok(Self { + payload, + sender_type: wire.sender_type, + invoke_request: wire.invoke_request, + effective_gas_price: wire.effective_gas_price, + tx_hash: B256::from(wire.tx_hash), + gas_limit: wire.gas_limit, + nonce: wire.nonce, + chain_id: wire.chain_id, + sender_resolution, + recipient_resolution, + sender_key: wire.sender_key, + authentication_payload: wire.authentication_payload, + sender_eth_address: wire.sender_eth_address, + recipient_eth_address: wire.recipient_eth_address, + funds: wire.funds, + }); + } + let envelope = TxEnvelope::decode(bytes)?; - // Use base_fee of 0 for decoding - the effective gas price will be - // recalculated if needed when the transaction is added to a mempool TxContext::new(envelope, 0).ok_or(ERR_RECIPIENT_REQUIRED) } } @@ -192,14 +507,10 @@ impl Decodable for TxContext { /// - EIP-1559: min(max_fee_per_gas, base_fee + max_priority_fee_per_gas) fn calculate_effective_gas_price(envelope: &TxEnvelope, base_fee: u128) -> u128 { match envelope { - TxEnvelope::Legacy(tx) => { - // Legacy transactions have a fixed gas price - tx.tx().gas_price - } + TxEnvelope::Legacy(tx) => tx.tx().gas_price, TxEnvelope::Eip1559(tx) => { let max_fee = tx.max_fee_per_gas(); let priority_fee = tx.max_priority_fee_per_gas(); - // Effective = min(max_fee, base_fee + priority_fee) max_fee.min(base_fee.saturating_add(priority_fee)) } } @@ -221,8 +532,7 @@ mod tests { STORAGE_ACCOUNT_ID, }; use evolve_core::{ - BlockContext, EnvironmentQuery, FungibleAsset, InvokableMessage, InvokeResponse, - ERR_UNKNOWN_FUNCTION, + BlockContext, EnvironmentQuery, InvokableMessage, InvokeResponse, ERR_UNKNOWN_FUNCTION, }; use k256::ecdsa::{signature::hazmat::PrehashSigner, SigningKey}; use rand::rngs::OsRng; @@ -379,4 +689,37 @@ mod tests { .expect("resolve recipient"); assert_eq!(resolved, contract_id); } + + #[test] + fn roundtrip_custom_payload_context() { + let request = + InvokeRequest::new_from_message("custom.execute", 7, Message::new(&42u64).unwrap()); + let context = TxContext::from_payload( + TxPayload::Custom(vec![1, 2, 3, 4]), + sender_type::CUSTOM, + TxContextMeta { + tx_hash: B256::from([9u8; 32]), + gas_limit: 120_000, + nonce: 11, + chain_id: None, + effective_gas_price: 33, + invoke_request: request, + funds: vec![], + sender_account: AccountId::from_u64(77), + recipient_account: Some(AccountId::from_u64(88)), + sender_key: vec![0xCC; 32], + authentication_payload: Message::new(&AccountId::from_u64(77)).unwrap(), + sender_eth_address: None, + recipient_eth_address: None, + }, + ) + .expect("construct custom context"); + + let encoded = context.encode().expect("encode custom context"); + let decoded = TxContext::decode(&encoded).expect("decode custom context"); + assert_eq!(decoded.sender_type(), sender_type::CUSTOM); + assert!(matches!(decoded.payload(), TxPayload::Custom(data) if data == &vec![1, 2, 3, 4])); + assert_eq!(decoded.nonce(), 11); + assert_eq!(MempoolTx::gas_limit(&decoded), 120_000); + } } diff --git a/crates/app/tx/eth/src/payload.rs b/crates/app/tx/eth/src/payload.rs new file mode 100644 index 0000000..52192d8 --- /dev/null +++ b/crates/app/tx/eth/src/payload.rs @@ -0,0 +1,35 @@ +//! Transaction payload variants. + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::envelope::TxEnvelope; + +/// Payload for a mempool transaction context. +/// +/// - `Eoa`: Ethereum EOA transaction envelope. +/// - `Custom`: chain-specific payload bytes interpreted by a custom sender type. +#[derive(Clone, Debug)] +pub enum TxPayload { + Eoa(Box), + Custom(Vec), +} + +/// ETH transaction intent payload for custom sender schemes. +/// +/// This preserves Ethereum transaction semantics (`to`, `data`, `value`, `nonce`, +/// gas fields, and chain ID) while decoupling sender authentication from secp256k1. +/// +/// `auth_proof` is sender-type-specific material (e.g. Ed25519 signature package). +#[derive(Clone, Debug, BorshSerialize, BorshDeserialize)] +pub struct EthIntentPayload { + /// Raw encoded Ethereum transaction envelope bytes. + pub envelope: Vec, + /// Signature/auth proof bytes interpreted by the sender-type verifier. + pub auth_proof: Vec, +} + +impl EthIntentPayload { + pub fn decode_envelope(&self) -> evolve_core::SdkResult { + TxEnvelope::decode(&self.envelope) + } +} diff --git a/crates/app/tx/eth/src/sender_type.rs b/crates/app/tx/eth/src/sender_type.rs new file mode 100644 index 0000000..abbe3cb --- /dev/null +++ b/crates/app/tx/eth/src/sender_type.rs @@ -0,0 +1,10 @@ +//! Sender type constants for signature verifier dispatch. +//! +//! Sender types are intentionally decoupled from Ethereum tx envelope types. +//! Multiple tx envelope formats can use the same sender authentication scheme. + +/// Sender type identifier for Ethereum EOA secp256k1 signatures. +pub const EOA_SECP256K1: u16 = 0x0001; + +/// Sender type identifier for custom account-authenticated payloads. +pub const CUSTOM: u16 = 0x8000; diff --git a/crates/app/tx/eth/src/verifier/mod.rs b/crates/app/tx/eth/src/verifier/mod.rs index 791c345..c8b4e8d 100644 --- a/crates/app/tx/eth/src/verifier/mod.rs +++ b/crates/app/tx/eth/src/verifier/mod.rs @@ -8,4 +8,4 @@ mod ecdsa; mod registry; pub use ecdsa::EcdsaVerifier; -pub use registry::SignatureVerifierRegistry; +pub use registry::{SignatureVerifierDyn, SignatureVerifierRegistry}; diff --git a/crates/app/tx/eth/src/verifier/registry.rs b/crates/app/tx/eth/src/verifier/registry.rs index 5244107..0ba10ad 100644 --- a/crates/app/tx/eth/src/verifier/registry.rs +++ b/crates/app/tx/eth/src/verifier/registry.rs @@ -1,33 +1,50 @@ -//! Registry for per-transaction-type signature verification. +//! Registry for sender-type-based signature verification. use std::collections::BTreeMap; use evolve_core::SdkResult; use evolve_stf_traits::SignatureVerifier; -use crate::envelope::{tx_type, TxEnvelope}; -use crate::error::ERR_UNSUPPORTED_TX_TYPE; +use crate::envelope::TxEnvelope; +use crate::error::{ERR_INVALID_SIGNATURE, ERR_UNSUPPORTED_SENDER_TYPE}; +use crate::payload::TxPayload; +use crate::sender_type; use crate::verifier::EcdsaVerifier; -/// Trait object for dynamic dispatch of signature verification. +/// Trait object for dynamic dispatch of signature verification by payload. pub trait SignatureVerifierDyn: Send + Sync { - /// Verify the signature of a transaction envelope. - fn verify(&self, tx: &TxEnvelope) -> SdkResult<()>; + /// Verify the signature/auth proof for a payload. + fn verify(&self, payload: &TxPayload) -> SdkResult<()>; } -/// Blanket implementation for any SignatureVerifier. -impl + Send + Sync> SignatureVerifierDyn for T { - fn verify(&self, tx: &TxEnvelope) -> SdkResult<()> { - self.verify_signature(tx) +/// Adapter for verifiers that operate on Ethereum envelopes. +struct EoaPayloadVerifier { + inner: V, +} + +impl EoaPayloadVerifier { + fn new(inner: V) -> Self { + Self { inner } } } -/// Registry that maps transaction types to their signature verifiers. +impl SignatureVerifierDyn for EoaPayloadVerifier +where + V: SignatureVerifier + Send + Sync, +{ + fn verify(&self, payload: &TxPayload) -> SdkResult<()> { + match payload { + TxPayload::Eoa(tx) => self.inner.verify_signature(tx.as_ref()), + TxPayload::Custom(_) => Err(ERR_INVALID_SIGNATURE), + } + } +} + +/// Registry that maps sender types to signature verifiers. /// -/// This allows different transaction types to have different verification -/// logic while providing a unified interface. +/// This allows decoupling sender authentication from tx envelope types. pub struct SignatureVerifierRegistry { - verifiers: BTreeMap>, + verifiers: BTreeMap>, } impl SignatureVerifierRegistry { @@ -38,42 +55,49 @@ impl SignatureVerifierRegistry { } } - /// Create a registry pre-configured for Ethereum transaction types. - /// - /// Registers ECDSA verification for legacy (0x00) and EIP-1559 (0x02) types. + /// Create a registry pre-configured for Ethereum EOA sender authentication. pub fn ethereum(chain_id: u64) -> Self { let mut registry = Self::new(); - let verifier = EcdsaVerifier::new(chain_id); - - // All Ethereum types use ECDSA - registry.register(tx_type::LEGACY, verifier.clone()); - registry.register(tx_type::EIP1559, verifier); - + registry.register_eoa(sender_type::EOA_SECP256K1, EcdsaVerifier::new(chain_id)); registry } - /// Register a verifier for a specific transaction type. - pub fn register + Send + Sync + 'static>( + /// Register a payload verifier for a sender type. + pub fn register_dyn( &mut self, - tx_type: u8, - verifier: V, + sender_type: u16, + verifier: impl SignatureVerifierDyn + 'static, ) { - self.verifiers.insert(tx_type, Box::new(verifier)); + self.verifiers.insert(sender_type, Box::new(verifier)); } - /// Verify a transaction's signature using the appropriate verifier. - pub fn verify(&self, tx: &TxEnvelope) -> SdkResult<()> { - let tx_type = tx.tx_type(); - let verifier = self.verifiers.get(&tx_type).ok_or_else(|| { - evolve_core::ErrorCode::new_with_arg(ERR_UNSUPPORTED_TX_TYPE.id, tx_type as u16) + /// Register an envelope-based verifier for an EOA sender type. + pub fn register_eoa(&mut self, sender_type: u16, verifier: V) + where + V: SignatureVerifier + Send + Sync + 'static, + { + self.register_dyn(sender_type, EoaPayloadVerifier::new(verifier)); + } + + /// Verify a payload using the verifier configured for `sender_type`. + pub fn verify_payload(&self, sender_type: u16, payload: &TxPayload) -> SdkResult<()> { + let verifier = self.verifiers.get(&sender_type).ok_or_else(|| { + evolve_core::ErrorCode::new_with_arg(ERR_UNSUPPORTED_SENDER_TYPE.id, sender_type) })?; + verifier.verify(payload) + } - verifier.verify(tx) + /// Backward-compatible verification entrypoint for Ethereum EOA envelopes. + pub fn verify(&self, tx: &TxEnvelope) -> SdkResult<()> { + self.verify_payload( + sender_type::EOA_SECP256K1, + &TxPayload::Eoa(Box::new(tx.clone())), + ) } - /// Check if a transaction type is supported. - pub fn supports(&self, tx_type: u8) -> bool { - self.verifiers.contains_key(&tx_type) + /// Check if a sender type is supported. + pub fn supports(&self, sender_type: u16) -> bool { + self.verifiers.contains_key(&sender_type) } } @@ -96,15 +120,13 @@ mod tests { #[test] fn test_registry_supports() { let registry = SignatureVerifierRegistry::ethereum(1); - - assert!(registry.supports(tx_type::LEGACY)); - assert!(registry.supports(tx_type::EIP1559)); - assert!(!registry.supports(tx_type::EIP2930)); // Not registered + assert!(registry.supports(sender_type::EOA_SECP256K1)); + assert!(!registry.supports(sender_type::CUSTOM)); } #[test] fn test_empty_registry() { let registry = SignatureVerifierRegistry::new(); - assert!(!registry.supports(tx_type::LEGACY)); + assert!(!registry.supports(sender_type::EOA_SECP256K1)); } } diff --git a/crates/rpc/chain-index/src/provider.rs b/crates/rpc/chain-index/src/provider.rs index 2bde5c5..f9c9d66 100644 --- a/crates/rpc/chain-index/src/provider.rs +++ b/crates/rpc/chain-index/src/provider.rs @@ -205,6 +205,21 @@ impl ChainStateProvider, mempool: SharedMempool>, + ) -> Self { + let gateway = EthGateway::new(config.chain_id); + Self::with_mempool_and_gateway(index, config, account_codes, mempool, gateway) + } + + /// Create a new chain state provider with account codes, mempool, and a preconfigured gateway. + /// + /// This is the extension point for sender-type ingress verification beyond + /// default Ethereum secp256k1 envelopes. + pub fn with_mempool_and_gateway( + index: Arc, + config: ChainStateProviderConfig, + account_codes: Arc, + mempool: SharedMempool>, + gateway: EthGateway, ) -> Self { let default_parallelism = std::thread::available_parallelism() .map(|n| n.get()) @@ -214,7 +229,7 @@ impl ChainStateProvider().ok()) .filter(|v| *v > 0) .unwrap_or(default_parallelism); - let gateway = Arc::new(EthGateway::new(config.chain_id)); + let gateway = Arc::new(gateway); let queue_capacity = std::env::var("EVOLVE_RPC_VERIFY_QUEUE_CAPACITY") .ok() .and_then(|v| v.parse::().ok()) @@ -673,8 +688,12 @@ mod tests { use std::sync::Mutex; use crate::types::{StoredBlock, StoredLog, StoredReceipt, StoredTransaction, TxLocation}; + use borsh::{BorshDeserialize, BorshSerialize}; + use evolve_core::encoding::Encodable; + use evolve_core::{AccountId, ErrorCode, InvokableMessage, InvokeRequest, Message, SdkResult}; use evolve_mempool::new_shared_mempool; use evolve_rpc_types::block::BlockTransactions; + use evolve_tx_eth::{sender_types, SignatureVerifierDyn, TxContextMeta, TxPayload}; #[derive(Default)] struct MockChainIndex { @@ -880,6 +899,59 @@ mod tests { "620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83" ); + #[derive(Clone, BorshSerialize, BorshDeserialize)] + struct TestInvoke { + value: u8, + } + + impl InvokableMessage for TestInvoke { + const FUNCTION_IDENTIFIER: u64 = 99_001; + const FUNCTION_IDENTIFIER_NAME: &'static str = "test_custom"; + } + + struct TestCustomVerifier; + + impl SignatureVerifierDyn for TestCustomVerifier { + fn verify(&self, payload: &TxPayload) -> SdkResult<()> { + match payload { + TxPayload::Custom(bytes) if bytes == b"ok" => Ok(()), + _ => Err(ErrorCode::new(0x99)), + } + } + } + + fn custom_wire_tx_bytes(sender_type: u16, payload: Vec) -> Vec { + let sender = AccountId::from_u64(9_001); + let recipient = AccountId::from_u64(9_002); + let request = + InvokeRequest::new(&TestInvoke { value: 7 }).expect("invoke request should encode"); + let auth_payload = + Message::new(&[0x11u8; 4]).expect("authentication payload should encode"); + + let tx = TxContext::from_payload( + TxPayload::Custom(payload), + sender_type, + TxContextMeta { + tx_hash: B256::repeat_byte(0xAB), + gas_limit: 21_000, + nonce: 1, + chain_id: Some(1), + effective_gas_price: 2, + invoke_request: request, + funds: vec![], + sender_account: sender, + recipient_account: Some(recipient), + sender_key: sender.as_bytes().to_vec(), + authentication_payload: auth_payload, + sender_eth_address: None, + recipient_eth_address: None, + }, + ) + .expect("custom tx context should build"); + + tx.encode().expect("custom tx context should encode") + } + #[tokio::test] async fn send_raw_transaction_without_mempool_is_not_implemented() { let provider = default_provider(Arc::new(MockChainIndex::default())); @@ -922,6 +994,30 @@ mod tests { assert!(mempool.read().await.contains(&hash.0)); } + #[tokio::test] + async fn send_raw_transaction_custom_wire_payload_enters_mempool_with_custom_gateway() { + let mempool = new_shared_mempool::(); + let mut gateway = EthGateway::new(provider_config().chain_id); + gateway.register_payload_verifier(sender_types::CUSTOM, TestCustomVerifier); + let provider = ChainStateProvider::with_mempool_and_gateway( + Arc::new(MockChainIndex::default()), + provider_config(), + Arc::new(NoopAccountCodes), + mempool.clone(), + gateway, + ); + + let raw = custom_wire_tx_bytes(sender_types::CUSTOM, b"ok".to_vec()); + let hash = provider + .send_raw_transaction(&raw) + .await + .expect("custom wire tx should be accepted by configured gateway"); + + assert_eq!(hash, B256::repeat_byte(0xAB)); + assert_eq!(mempool.read().await.len(), 1); + assert!(mempool.read().await.contains(&hash.0)); + } + #[tokio::test] async fn send_raw_transaction_invalid_payload_maps_to_invalid_transaction() { let mempool = new_shared_mempool::(); diff --git a/crates/rpc/evnode/src/service.rs b/crates/rpc/evnode/src/service.rs index c1f951f..1ca4236 100644 --- a/crates/rpc/evnode/src/service.rs +++ b/crates/rpc/evnode/src/service.rs @@ -10,11 +10,11 @@ use alloy_primitives::B256; use async_trait::async_trait; use evolve_core::encoding::{Decodable, Encodable}; use evolve_core::ReadonlyKV; -use evolve_mempool::{Mempool, SharedMempool}; +use evolve_mempool::{Mempool, MempoolTx, SharedMempool}; use evolve_stf::execution_state::ExecutionState; use evolve_stf::results::BlockResult; use evolve_stf_traits::{AccountsCodeStorage, StateChange}; -use evolve_tx_eth::{TxContext, TypedTransaction}; +use evolve_tx_eth::TxContext; use prost_types::Timestamp; use tokio::sync::RwLock; use tonic::{Request, Response, Status}; @@ -300,7 +300,7 @@ where /// Estimate gas for a transaction. fn estimate_tx_gas(tx: &TxContext) -> u64 { - tx.envelope().gas_limit() + tx.gas_limit() } /// Handle produced state changes. @@ -704,7 +704,7 @@ mod tests { .iter() .map(|tx| TxResult { events: vec![], - gas_used: tx.envelope().gas_limit(), + gas_used: tx.gas_limit(), response: Ok(InvokeResponse::new(&()).expect("unit response should encode")), }) .collect(); @@ -1123,7 +1123,7 @@ mod tests { async fn get_txs_filter_limits_and_finalize_interact_consistently() { let tx_bytes = sample_legacy_tx_bytes(); let tx = TxContext::decode(&tx_bytes).expect("sample tx should decode"); - let tx_gas = tx.envelope().gas_limit(); + let tx_gas = tx.gas_limit(); let tx_len = tx_bytes.len() as u64; let mut pool = Mempool::::new(); diff --git a/docker-compose.testapp.yml b/docker-compose.testapp.yml new file mode 100644 index 0000000..6375415 --- /dev/null +++ b/docker-compose.testapp.yml @@ -0,0 +1,29 @@ +services: + testapp: + build: + context: . + dockerfile: docker/testapp/Dockerfile + image: evolve/testapp:local + command: + - run + - --grpc-addr + - 0.0.0.0:50051 + - --rpc-addr + - 0.0.0.0:8545 + - --data-dir + - /var/lib/evolve/data + - --chain-id + - "1337" + environment: + EVOLVE_BLOCK_INTERVAL_MS: 100 + # Faucet account for x402 demo (Hardhat #0) + GENESIS_ALICE_ETH_ADDRESS: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + GENESIS_ALICE_TOKEN_BALANCE: "1000000000" + ports: + - "8545:8545" + - "50051:50051" + volumes: + - testapp-data:/var/lib/evolve/data + +volumes: + testapp-data: diff --git a/docker/evd/Dockerfile b/docker/evd/Dockerfile index 0d390ca..4d127fd 100644 --- a/docker/evd/Dockerfile +++ b/docker/evd/Dockerfile @@ -6,6 +6,11 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev \ && rm -rf /var/lib/apt/lists/* +# Install the exact nightly toolchain before copying source so this layer is +# cached independently and the download does not race with the build. +COPY rust-toolchain.toml ./rust-toolchain.toml +RUN rustup show active-toolchain || rustup toolchain install + COPY . . RUN cargo build -p evd --release