From 4454622bc5f230f8547434f2cc178f351e409a63 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:38:58 +0300 Subject: [PATCH 1/6] test(wire): cover erc20 transfer encoding wire shape Add a round-trip test asserting the explicit JSON shape of the ERC-20 transfer encode request and response, matching the existing wire-shape tests for the other message types. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/wire/mod_tests.rs | 39 ++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/tinywallet-bus/src/wire/mod_tests.rs b/crates/tinywallet-bus/src/wire/mod_tests.rs index 2ea7655c..3a1bd010 100644 --- a/crates/tinywallet-bus/src/wire/mod_tests.rs +++ b/crates/tinywallet-bus/src/wire/mod_tests.rs @@ -13,9 +13,46 @@ use serde_json::json; use super::{ AttachRequest, GenerateMnemonicRequest, GenerateMnemonicResponse, PublicKey, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, TransactionSpec, UnsignedTransaction, Utxo, - WalletChain, Web3QuoteKind, Web3QuoteResponse, + WalletChain, Web3QuoteKind, Web3QuoteResponse, EncodeErc20TransferRequest, + EncodeErc20TransferResponse, }; +#[test] +fn erc20_encoding_wire_shape_is_explicit_and_round_trips() { + let request = EncodeErc20TransferRequest { + to_address: "0x1111111111111111111111111111111111111111".into(), + amount_raw: "1000000".into(), + }; + let encoded = serde_json::to_value(&request).unwrap(); + assert_eq!( + encoded, + json!({ + "to_address": "0x1111111111111111111111111111111111111111", + "amount_raw": "1000000" + }) + ); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + request + ); + + let response = EncodeErc20TransferResponse { + calldata: format!( + "0xa9059cbb{}{}{}{}", + "0".repeat(24), + "11".repeat(20), + "0".repeat(63), + "1" + ), + }; + let encoded = serde_json::to_value(&response).unwrap(); + assert_eq!(encoded["calldata"], response.calldata); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + response + ); +} + #[test] fn mnemonic_generation_wire_shape_is_explicit_and_secret_marked_by_member() { let request = GenerateMnemonicRequest { word_count: 24 }; From 298f3ec570a6bae3b051de33751fdd2d5ee2aa83 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:39:50 +0300 Subject: [PATCH 2/6] feat(bus): add EncodeErc20Transfer method Add a new bus method that encodes standard ERC-20 transfer calldata inside the module using the module-owned ABI implementation, exposing request and response wire types and bumping the contract version to 1.13. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/lib_tests.rs | 5 +++-- crates/tinywallet-bus/src/names/mod.rs | 3 +++ crates/tinywallet-bus/src/version/mod.rs | 2 +- crates/tinywallet-bus/src/wire/mod.rs | 3 ++- crates/tinywallet-bus/src/wire/wallet.rs | 18 ++++++++++++++++++ crates/tinywallet-module/src/service/mod.rs | 14 ++++++++++++++ 6 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/tinywallet-bus/src/lib_tests.rs b/crates/tinywallet-bus/src/lib_tests.rs index a66af68e..af4d989b 100644 --- a/crates/tinywallet-bus/src/lib_tests.rs +++ b/crates/tinywallet-bus/src/lib_tests.rs @@ -110,7 +110,7 @@ fn network_request_keeps_legacy_json_and_omits_unset_endpoint_url() { #[test] fn the_contract_accepts_its_own_version_and_newer_minors() { - assert_eq!(CONTRACT_VERSION, (1, 12)); + assert_eq!(CONTRACT_VERSION, (1, 13)); assert!(is_compatible(CONTRACT_VERSION)); assert!(is_compatible((CONTRACT_VERSION.0, CONTRACT_VERSION.1 + 1))); assert!(!is_compatible((CONTRACT_VERSION.0 + 1, 0))); @@ -143,6 +143,7 @@ fn the_method_list_is_sorted_complete_and_free_of_duplicates() { names::methods::CONSTRUCT_EVM_TRANSACTION, names::methods::DERIVE_ACCOUNT, names::methods::DERIVE_MNEMONIC_SEED, + names::methods::ENCODE_ERC20_TRANSFER, names::methods::EXPORT_KEY, names::methods::GENERATE_MNEMONIC, names::methods::SIGN_MESSAGE, @@ -279,7 +280,7 @@ fn native_wallet_members_are_additive_and_use_one_typed_argument() { methods::NATIVE_WALLET_TX_RECEIPT, methods::NATIVE_WALLET_LOOKUP_TX, ]; - assert_eq!(METHODS.len(), 37); + assert_eq!(METHODS.len(), 38); assert!(native_members.iter().all(|member| METHODS.contains(member))); assert_eq!(HOST_METHODS.len(), 6); assert!(HOST_METHODS.contains(&host_methods::AUTHORIZE_NATIVE_WALLET_EXECUTION)); diff --git a/crates/tinywallet-bus/src/names/mod.rs b/crates/tinywallet-bus/src/names/mod.rs index 58d7aa7e..71ac08b4 100644 --- a/crates/tinywallet-bus/src/names/mod.rs +++ b/crates/tinywallet-bus/src/names/mod.rs @@ -104,6 +104,8 @@ pub mod methods { pub const NATIVE_WALLET_LOOKUP_TX: &str = "NativeWalletLookupTx"; /// Parses a challenge, reserves budget and builds a confidential payment. pub const X402_PREPARE_PAYMENT: &str = "X402PreparePayment"; + /// Encodes ERC-20 transfer calldata using the module-owned ABI implementation. + pub const ENCODE_ERC20_TRANSFER: &str = "EncodeErc20Transfer"; /// Records a retry result and commits or releases its module reservation. pub const X402_RECORD_PAYMENT: &str = "X402RecordPayment"; /// Reports ledger totals and spending limits. @@ -127,6 +129,7 @@ pub const METHODS: &[&str] = &[ methods::CONSTRUCT_EVM_TRANSACTION, methods::DERIVE_ACCOUNT, methods::DERIVE_MNEMONIC_SEED, + methods::ENCODE_ERC20_TRANSFER, methods::EXPORT_KEY, methods::GENERATE_MNEMONIC, methods::NATIVE_WALLET_BALANCES, diff --git a/crates/tinywallet-bus/src/version/mod.rs b/crates/tinywallet-bus/src/version/mod.rs index c87a9d7f..a16b8ce7 100644 --- a/crates/tinywallet-bus/src/version/mod.rs +++ b/crates/tinywallet-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyWallet` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 12); +pub const CONTRACT_VERSION: (u32, u32) = (1, 13); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinywallet-bus/src/wire/mod.rs b/crates/tinywallet-bus/src/wire/mod.rs index 9d683c58..124895aa 100644 --- a/crates/tinywallet-bus/src/wire/mod.rs +++ b/crates/tinywallet-bus/src/wire/mod.rs @@ -55,7 +55,8 @@ pub use host::{ X402UpdateBudgetRequest, }; pub use wallet::{ - BalanceInfo, ChainStatus, DeriveMnemonicSeedRequest, DeriveMnemonicSeedResponse, EvmNetwork, + BalanceInfo, ChainStatus, DeriveMnemonicSeedRequest, DeriveMnemonicSeedResponse, + EncodeErc20TransferRequest, EncodeErc20TransferResponse, EvmNetwork, ExecutePreparedParams, ExecutionResult, GenerateMnemonicRequest, GenerateMnemonicResponse, HostEndpointReference, NativeWalletCloseRequest, NativeWalletConfigRequest, NativeWalletConfigResponse, NativeWalletExecuteRequest, NativeWalletExecutionIntent, diff --git a/crates/tinywallet-bus/src/wire/wallet.rs b/crates/tinywallet-bus/src/wire/wallet.rs index b45c7b92..0dcd1ffc 100644 --- a/crates/tinywallet-bus/src/wire/wallet.rs +++ b/crates/tinywallet-bus/src/wire/wallet.rs @@ -7,6 +7,24 @@ use crate::Chain; use super::{QuoteOwner, SecretMaterial, TransactionSpec}; +/// Requests standard ERC-20 `transfer(address,uint256)` calldata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EncodeErc20TransferRequest { + /// The 20-byte recipient address, with or without an `0x` prefix. + pub to_address: String, + /// The unsigned base-unit amount as a decimal string. + pub amount_raw: String, +} + +/// The module-encoded ERC-20 transfer calldata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EncodeErc20TransferResponse { + /// Hex calldata including the `0x` prefix. + pub calldata: String, +} + /// Requests a freshly generated BIP-39 recovery phrase. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] diff --git a/crates/tinywallet-module/src/service/mod.rs b/crates/tinywallet-module/src/service/mod.rs index 808e99be..ad6a19c9 100644 --- a/crates/tinywallet-module/src/service/mod.rs +++ b/crates/tinywallet-module/src/service/mod.rs @@ -96,6 +96,19 @@ mod construction; reason = "the generated TinyBus async trait requires async methods for synchronous cryptographic operations" )] impl Wallet { + /// Encode standard ERC-20 transfer calldata inside the module. + async fn encode_erc20_transfer( + &self, + request: tinywallet_bus::wire::EncodeErc20TransferRequest, + ) -> BusResult { + let calldata = tinywallet_x402::abi::encode_erc20_transfer( + &request.to_address, + &request.amount_raw, + ) + .map_err(|error| web3_operation_failed(error.to_string()))?; + Ok(tinywallet_bus::wire::EncodeErc20TransferResponse { calldata }) + } + /// List swap and bridge routes through the host's authenticated backend /// transport. Route parsing and all follow-on quote behavior stay here. async fn web3_routes(&self, _request: ()) -> BusResult { @@ -1499,6 +1512,7 @@ mod exports { "AttachSignature", "DeriveAccount", "DeriveMnemonicSeed", + "EncodeErc20Transfer", "GenerateMnemonic", "SignTransaction", "ExportKey", From d02c5872536fdbe81d4e23d1034b5951a0f6195e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:40:14 +0300 Subject: [PATCH 3/6] test(module): cover erc20 transfer encoding over the bus Add an end-to-end case that calls EncodeErc20Transfer on the loaded module and checks the calldata against the expected ABI encoding, so the algorithm is proven to run inside the artifact rather than via host-linked crypto. The same test asserts that an invalid recipient is rejected with Web3OperationFailed, and the method is added to the expected manifest list. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/tests/module_e2e.rs | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/tinywallet-module/tests/module_e2e.rs b/crates/tinywallet-module/tests/module_e2e.rs index a3c8b646..4264016a 100644 --- a/crates/tinywallet-module/tests/module_e2e.rs +++ b/crates/tinywallet-module/tests/module_e2e.rs @@ -26,6 +26,7 @@ use tinywallet::wire::{ UnsignedTransaction, }; use tinywallet::{Chain, tx}; +use tinywallet_bus::wire::{EncodeErc20TransferRequest, EncodeErc20TransferResponse}; use tinywallet_module::{BUS_NAME, OBJECT_PATH}; /// Every method the manifest must declare, in order. @@ -39,6 +40,7 @@ const EXPECTED_METHODS: &[&str] = &[ "AttachSignature", "DeriveAccount", "DeriveMnemonicSeed", + "EncodeErc20Transfer", "GenerateMnemonic", "SignTransaction", "ExportKey", @@ -97,6 +99,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { validates_addresses_inside_the_artifact(&proxy).await; constructs_evm_transactions_inside_the_artifact(&proxy).await; + encodes_erc20_transfers_inside_the_artifact(&proxy).await; signs_an_evm_transfer_identically_to_the_library(&proxy).await; signs_a_multi_input_bitcoin_spend(&proxy).await; signs_a_solana_transfer(&proxy).await; @@ -268,6 +271,40 @@ async fn signs_an_evm_transfer_identically_to_the_library(proxy: &tinybus::Proxy ); } +/// The calldata algorithm is executed by the loaded module, not host-linked crypto. +async fn encodes_erc20_transfers_inside_the_artifact(proxy: &tinybus::Proxy) { + let request = EncodeErc20TransferRequest { + to_address: "0x1111111111111111111111111111111111111111".into(), + amount_raw: "1".into(), + }; + let response: EncodeErc20TransferResponse = proxy + .call("EncodeErc20Transfer", (request,)) + .await + .expect("the module encodes valid ERC-20 transfer calldata"); + assert_eq!( + response.calldata, + format!( + "0xa9059cbb{}{}{}{}", + "0".repeat(24), + "11".repeat(20), + "0".repeat(63), + "1" + ) + ); + + let error = proxy + .call::( + "EncodeErc20Transfer", + (EncodeErc20TransferRequest { + to_address: "not-an-address".into(), + amount_raw: "1".into(), + },), + ) + .await + .expect_err("invalid recipient is rejected by the module"); + assert!(error.to_string().contains("Web3OperationFailed")); +} + /// Several inputs, so the multi-signature ordering crosses the bus for real. async fn signs_a_multi_input_bitcoin_spend(proxy: &tinybus::Proxy) { let derived = tinywallet::key::derive(Chain::Btc, VECTOR, "m/84'/0'/0'/0/0").unwrap(); From bdcf92597dc52909ad665868ef082af8f1b1e6c3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:40:36 +0300 Subject: [PATCH 4/6] feat(bus): add erc20 transfer encoding method Register the encode ERC20 transfer request and response types in the wire module and expose the corresponding wallet service method, bringing the dispatch table to 38 methods. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/lib_tests.rs | 2 +- crates/tinywallet-bus/src/wire/mod.rs | 17 ++++++++--------- crates/tinywallet-bus/src/wire/mod_tests.rs | 6 +++--- crates/tinywallet-module/src/service/mod.rs | 8 +++----- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/crates/tinywallet-bus/src/lib_tests.rs b/crates/tinywallet-bus/src/lib_tests.rs index af4d989b..870a7fa7 100644 --- a/crates/tinywallet-bus/src/lib_tests.rs +++ b/crates/tinywallet-bus/src/lib_tests.rs @@ -136,7 +136,7 @@ fn the_method_list_is_sorted_complete_and_free_of_duplicates() { METHODS, "sorted dispatch order, no repeats" ); - assert_eq!(METHODS.len(), 37); + assert_eq!(METHODS.len(), 38); for member in [ names::methods::ATTACH_SIGNATURE, names::methods::BUILD_UNSIGNED, diff --git a/crates/tinywallet-bus/src/wire/mod.rs b/crates/tinywallet-bus/src/wire/mod.rs index 124895aa..f63ee511 100644 --- a/crates/tinywallet-bus/src/wire/mod.rs +++ b/crates/tinywallet-bus/src/wire/mod.rs @@ -56,15 +56,14 @@ pub use host::{ }; pub use wallet::{ BalanceInfo, ChainStatus, DeriveMnemonicSeedRequest, DeriveMnemonicSeedResponse, - EncodeErc20TransferRequest, EncodeErc20TransferResponse, EvmNetwork, - ExecutePreparedParams, ExecutionResult, GenerateMnemonicRequest, GenerateMnemonicResponse, - HostEndpointReference, NativeWalletCloseRequest, NativeWalletConfigRequest, - NativeWalletConfigResponse, NativeWalletExecuteRequest, NativeWalletExecutionIntent, - NativeWalletPrepareTransferRequest, NativeWalletServiceHandle, NativeWalletServiceRequest, - NativeWalletTransactionRequest, PrepareTransferParams, PreparedKind, PreparedStatus, - PreparedTransaction, ProviderStatus, RpcEndpointKey, RpcSource, SolanaCluster, SupportedAsset, - TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, WalletAssetDefinition, WalletChain, - WalletNetworkDefaults, + EncodeErc20TransferRequest, EncodeErc20TransferResponse, EvmNetwork, ExecutePreparedParams, + ExecutionResult, GenerateMnemonicRequest, GenerateMnemonicResponse, HostEndpointReference, + NativeWalletCloseRequest, NativeWalletConfigRequest, NativeWalletConfigResponse, + NativeWalletExecuteRequest, NativeWalletExecutionIntent, NativeWalletPrepareTransferRequest, + NativeWalletServiceHandle, NativeWalletServiceRequest, NativeWalletTransactionRequest, + PrepareTransferParams, PreparedKind, PreparedStatus, PreparedTransaction, ProviderStatus, + RpcEndpointKey, RpcSource, SolanaCluster, SupportedAsset, TxLookupInfo, TxReceiptInfo, TxState, + TxStatusInfo, WalletAssetDefinition, WalletChain, WalletNetworkDefaults, }; use crate::Chain; diff --git a/crates/tinywallet-bus/src/wire/mod_tests.rs b/crates/tinywallet-bus/src/wire/mod_tests.rs index 3a1bd010..d3be6b2d 100644 --- a/crates/tinywallet-bus/src/wire/mod_tests.rs +++ b/crates/tinywallet-bus/src/wire/mod_tests.rs @@ -11,10 +11,10 @@ use serde_json::json; use super::{ - AttachRequest, GenerateMnemonicRequest, GenerateMnemonicResponse, PublicKey, Scheme, Signature, + AttachRequest, EncodeErc20TransferRequest, EncodeErc20TransferResponse, + GenerateMnemonicRequest, GenerateMnemonicResponse, PublicKey, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, TransactionSpec, UnsignedTransaction, Utxo, - WalletChain, Web3QuoteKind, Web3QuoteResponse, EncodeErc20TransferRequest, - EncodeErc20TransferResponse, + WalletChain, Web3QuoteKind, Web3QuoteResponse, }; #[test] diff --git a/crates/tinywallet-module/src/service/mod.rs b/crates/tinywallet-module/src/service/mod.rs index ad6a19c9..dd1fd2f4 100644 --- a/crates/tinywallet-module/src/service/mod.rs +++ b/crates/tinywallet-module/src/service/mod.rs @@ -101,11 +101,9 @@ impl Wallet { &self, request: tinywallet_bus::wire::EncodeErc20TransferRequest, ) -> BusResult { - let calldata = tinywallet_x402::abi::encode_erc20_transfer( - &request.to_address, - &request.amount_raw, - ) - .map_err(|error| web3_operation_failed(error.to_string()))?; + let calldata = + tinywallet_x402::abi::encode_erc20_transfer(&request.to_address, &request.amount_raw) + .map_err(|error| web3_operation_failed(error.to_string()))?; Ok(tinywallet_bus::wire::EncodeErc20TransferResponse { calldata }) } From f42bbde408a767a5a8a9f6a95a84cc6db92a41bd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:49:26 +0300 Subject: [PATCH 5/6] fix(tinywallet-module): report invalid input for bad erc20 transfer args ERC-20 transfer encoding failures now surface as InvalidInput instead of Web3OperationFailed, since malformed recipient or amount values are caller errors rather than upstream web3 faults. The end-to-end test was updated to expect the new error variant. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/src/service/mod.rs | 2 +- crates/tinywallet-module/tests/module_e2e.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinywallet-module/src/service/mod.rs b/crates/tinywallet-module/src/service/mod.rs index dd1fd2f4..ed28dba8 100644 --- a/crates/tinywallet-module/src/service/mod.rs +++ b/crates/tinywallet-module/src/service/mod.rs @@ -103,7 +103,7 @@ impl Wallet { ) -> BusResult { let calldata = tinywallet_x402::abi::encode_erc20_transfer(&request.to_address, &request.amount_raw) - .map_err(|error| web3_operation_failed(error.to_string()))?; + .map_err(|error| invalid_input(error.to_string()))?; Ok(tinywallet_bus::wire::EncodeErc20TransferResponse { calldata }) } diff --git a/crates/tinywallet-module/tests/module_e2e.rs b/crates/tinywallet-module/tests/module_e2e.rs index 4264016a..3346873e 100644 --- a/crates/tinywallet-module/tests/module_e2e.rs +++ b/crates/tinywallet-module/tests/module_e2e.rs @@ -302,7 +302,7 @@ async fn encodes_erc20_transfers_inside_the_artifact(proxy: &tinybus::Proxy) { ) .await .expect_err("invalid recipient is rejected by the module"); - assert!(error.to_string().contains("Web3OperationFailed")); + assert!(error.to_string().contains("InvalidInput")); } /// Several inputs, so the multi-signature ordering crosses the bus for real. From d1d034d8a37ad341d95b8c2d55921d6e9af0098b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 20:55:19 +0300 Subject: [PATCH 6/6] test(tinywallet-module): add EncodeErc20Transfer to expected methods Extend the lifecycle end-to-end test's expected method list to cover the EncodeErc20Transfer RPC, keeping the assertion in sync with the module's exposed interface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/tests/lifecycle_e2e.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index d53fda05..2c198b40 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -63,6 +63,7 @@ const EXPECTED_METHODS: &[&str] = &[ "AttachSignature", "DeriveAccount", "DeriveMnemonicSeed", + "EncodeErc20Transfer", "GenerateMnemonic", "SignTransaction", "ExportKey",