diff --git a/crates/tinywallet-bus/src/lib_tests.rs b/crates/tinywallet-bus/src/lib_tests.rs index a66af68e..870a7fa7 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))); @@ -136,13 +136,14 @@ 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, 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..f63ee511 100644 --- a/crates/tinywallet-bus/src/wire/mod.rs +++ b/crates/tinywallet-bus/src/wire/mod.rs @@ -55,15 +55,15 @@ pub use host::{ X402UpdateBudgetRequest, }; pub use wallet::{ - BalanceInfo, ChainStatus, DeriveMnemonicSeedRequest, DeriveMnemonicSeedResponse, 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, + 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, }; use crate::Chain; diff --git a/crates/tinywallet-bus/src/wire/mod_tests.rs b/crates/tinywallet-bus/src/wire/mod_tests.rs index 2ea7655c..d3be6b2d 100644 --- a/crates/tinywallet-bus/src/wire/mod_tests.rs +++ b/crates/tinywallet-bus/src/wire/mod_tests.rs @@ -11,11 +11,48 @@ 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, }; +#[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 }; 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..ed28dba8 100644 --- a/crates/tinywallet-module/src/service/mod.rs +++ b/crates/tinywallet-module/src/service/mod.rs @@ -96,6 +96,17 @@ 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| invalid_input(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 +1510,7 @@ mod exports { "AttachSignature", "DeriveAccount", "DeriveMnemonicSeed", + "EncodeErc20Transfer", "GenerateMnemonic", "SignTransaction", "ExportKey", 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", diff --git a/crates/tinywallet-module/tests/module_e2e.rs b/crates/tinywallet-module/tests/module_e2e.rs index a3c8b646..3346873e 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("InvalidInput")); +} + /// 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();