diff --git a/crates/tinywallet-bus/README.md b/crates/tinywallet-bus/README.md index 313af633..c0da6f3d 100644 --- a/crates/tinywallet-bus/README.md +++ b/crates/tinywallet-bus/README.md @@ -71,3 +71,8 @@ Contract 1.2 adds stateless ConstructEvmTransaction for native/ERC-20/contract actions, exact signing payloads and host approval facts. Secret fields are rejected; fee/calldata/request bounds execute in the module. See [construction specification](../../docs/specs/evm-module-construction.md). + +Contract 1.12 adds optional `additional_secrets` to confidential +`X402PreparePayment`. When omitted or empty, the request keeps its prior JSON +shape. The module parses the challenge, selects the payment chain, and uses +only the matching confidential secret; hosts do not parse payment challenges. diff --git a/crates/tinywallet-bus/src/lib_tests.rs b/crates/tinywallet-bus/src/lib_tests.rs index ed72074d..a66af68e 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, 11)); + assert_eq!(CONTRACT_VERSION, (1, 12)); assert!(is_compatible(CONTRACT_VERSION)); assert!(is_compatible((CONTRACT_VERSION.0, CONTRACT_VERSION.1 + 1))); assert!(!is_compatible((CONTRACT_VERSION.0 + 1, 0))); diff --git a/crates/tinywallet-bus/src/version/mod.rs b/crates/tinywallet-bus/src/version/mod.rs index 8ef759fd..c87a9d7f 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, 11); +pub const CONTRACT_VERSION: (u32, u32) = (1, 12); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinywallet-bus/src/wire/host.rs b/crates/tinywallet-bus/src/wire/host.rs index 8dd4268a..384fb9e0 100644 --- a/crates/tinywallet-bus/src/wire/host.rs +++ b/crates/tinywallet-bus/src/wire/host.rs @@ -383,6 +383,10 @@ pub struct X402PreparePaymentRequest { pub owner: Option, /// Wallet material consumed only inside the confidential module call. pub secret: SecretMaterial, + /// Additional chain-specific wallet material for module-selected rails. + /// Older hosts omit this field and keep sending only `secret`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub additional_secrets: Vec, } /// Payment proof and pending-ledger handle returned to the host for retry. diff --git a/crates/tinywallet-bus/src/wire/mod_tests.rs b/crates/tinywallet-bus/src/wire/mod_tests.rs index 336d6e8b..2ea7655c 100644 --- a/crates/tinywallet-bus/src/wire/mod_tests.rs +++ b/crates/tinywallet-bus/src/wire/mod_tests.rs @@ -344,3 +344,19 @@ fn address_validation_rejects_misspelled_sender_and_preserves_default_recipient( serde_json::from_value(serde_json::json!({"chain":"btc", "address":"fixture"})).unwrap(); assert!(!recipient.sender); } + +#[test] +fn x402_chain_secret_extension_preserves_legacy_serialization() { + let legacy = json!({ + "service": {"handle": "fixture"}, + "url": "https://fixture.invalid/resource", + "payment_required": "encoded-challenge", + "secret": { + "mnemonic": "secret phrase", + "derivation_path": "m/44'/60'/0'/0/0", + "chain": "evm" + } + }); + let request: super::X402PreparePaymentRequest = serde_json::from_value(legacy.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), legacy); +} diff --git a/crates/tinywallet-module/src/service/mod.rs b/crates/tinywallet-module/src/service/mod.rs index 18f152c7..808e99be 100644 --- a/crates/tinywallet-module/src/service/mod.rs +++ b/crates/tinywallet-module/src/service/mod.rs @@ -284,6 +284,9 @@ impl Wallet { let Some(x402) = x402.as_ref() else { let mut request = request; wipe(&mut request.secret); + for secret in &mut request.additional_secrets { + wipe(secret); + } return Err(host_unavailable()); }; x402.prepare(request).await.map_err(x402_operation_failed) @@ -813,6 +816,7 @@ impl Wallet { fn wipe(secret: &mut SecretMaterial) { use zeroize::Zeroize; secret.mnemonic.zeroize(); + secret.derivation_path.zeroize(); } /// Derive the key for `secret`, wiping it when the guard drops. diff --git a/crates/tinywallet-module/src/service/mod_tests.rs b/crates/tinywallet-module/src/service/mod_tests.rs index 2e588118..4978b7c8 100644 --- a/crates/tinywallet-module/src/service/mod_tests.rs +++ b/crates/tinywallet-module/src/service/mod_tests.rs @@ -1612,6 +1612,7 @@ async fn user_scoped_operations_fail_closed_before_configuration() { url: "https://example.invalid".to_string(), payment_required: String::new(), owner: None, + additional_secrets: Vec::new(), secret: secret(Chain::Evm, "m/44'/60'/0'/0/0"), }) .await diff --git a/crates/tinywallet-module/src/service/web3.rs b/crates/tinywallet-module/src/service/web3.rs index 6d7d11a5..f594ce80 100644 --- a/crates/tinywallet-module/src/service/web3.rs +++ b/crates/tinywallet-module/src/service/web3.rs @@ -26,6 +26,7 @@ use super::host::HostCallbacks; tokio::task_local! { static ACTIVE_QUOTE_OWNER: Option; static ACTIVE_SECRET: Arc; + static ACTIVE_X402_SECRETS: Arc; static ACTIVE_WEB3_APPROVAL: Option; static ACTIVE_NATIVE_APPROVAL: Option; } @@ -45,12 +46,28 @@ pub(crate) struct NativeApprovalContext { struct SecretSlot(Mutex>); +struct X402SecretSlot(Mutex>>); + impl Drop for SecretSlot { fn drop(&mut self) { if let Ok(slot) = self.0.get_mut() && let Some(secret) = slot.as_mut() { zeroize::Zeroize::zeroize(&mut secret.mnemonic); + zeroize::Zeroize::zeroize(&mut secret.derivation_path); + } + } +} + +impl Drop for X402SecretSlot { + fn drop(&mut self) { + if let Ok(slot) = self.0.get_mut() + && let Some(secrets) = slot.as_mut() + { + for secret in secrets { + zeroize::Zeroize::zeroize(&mut secret.mnemonic); + zeroize::Zeroize::zeroize(&mut secret.derivation_path); + } } } } @@ -261,6 +278,18 @@ pub(crate) async fn with_execution_context( .await } +/// Scope the host-provided secrets for module-selected x402 payment rails. +pub(crate) async fn with_x402_execution_context( + owner: Option, + secrets: Vec, + future: F, +) -> F::Output { + let slot = Arc::new(X402SecretSlot(Mutex::new(Some(secrets)))); + ACTIVE_X402_SECRETS + .scope(slot, with_quote_owner(owner, future)) + .await +} + /// Scope the exact prepared native quote across the module-owned signer call. pub(crate) async fn with_native_approval( approval: NativeApprovalContext, @@ -294,6 +323,30 @@ pub(super) fn clone_secret() -> Result { .map_err(|_| "wallet secret is unavailable outside a confidential payment".to_string())? } +/// Clone only the secret matching the chain selected by the parsed challenge. +pub(super) fn clone_x402_secret(chain: tinywallet_bus::Chain) -> Result { + ACTIVE_X402_SECRETS + .try_with(|slot| { + let secrets = slot + .0 + .lock() + .map_err(|_| "x402 wallet secret slot is unavailable".to_string())?; + let secrets = secrets + .as_ref() + .ok_or_else(|| "x402 wallet secrets were already consumed".to_string())?; + secrets + .iter() + .find(|secret| secret.chain == chain) + .cloned() + .ok_or_else(|| { + "x402 wallet secret does not match the selected payment chain".to_string() + }) + }) + .map_err(|_| { + "x402 wallet secrets are unavailable outside a confidential payment".to_string() + })? +} + /// Run a quote operation with the owner that arrived in the typed bus request. pub(crate) async fn with_quote_owner( owner: Option, diff --git a/crates/tinywallet-module/src/service/x402.rs b/crates/tinywallet-module/src/service/x402.rs index 30f7875a..514d7cfb 100644 --- a/crates/tinywallet-module/src/service/x402.rs +++ b/crates/tinywallet-module/src/service/x402.rs @@ -17,7 +17,7 @@ use tinywallet_x402::protocol::{handle_402_and_pay_authorized, parse_settlement_ use tinywallet_x402::wire::PaymentChain; use super::host::HostCallbacks; -use super::web3::{HostTransport, with_execution_context}; +use super::web3::{HostTransport, with_x402_execution_context}; const MAX_PENDING: usize = 64; @@ -123,15 +123,16 @@ impl X402Runtime { mut request: X402PreparePaymentRequest, ) -> Result { if let Err(error) = self.require_handle(&request.service) { - wipe(&mut request.secret); + wipe_request_secrets(&mut request); return Err(error); } + let mut secrets = take_request_secrets(&mut request)?; let header = HeaderValue::from_str(&request.payment_required) .map_err(|error| format!("invalid payment challenge header: {error}")); let header = match header { Ok(header) => header, Err(error) => { - wipe(&mut request.secret); + wipe_secrets(&mut secrets); return Err(error); } }; @@ -142,48 +143,47 @@ impl X402Runtime { let owner = request.owner.clone(); let url = request.url.clone(); let callbacks = self.callbacks.clone(); - let (proof, _admission) = - with_execution_context(request.owner, request.secret, None, async { - let signer: Arc = Arc::new(ModulePaymentSigner); - let transport = Arc::new(HostTransport::new(self.callbacks.clone())); - let builder = CryptoPayments::new(signer, transport); - // Bound concurrent chain refreshes before the first network await. - // Capacity is checked again after refresh so finalized expiry - // evidence can free slots before new authorizations are admitted. - let admission = self.begin_pending()?; - tinywallet_x402::protocol::refresh_solana_authorizations(&builder) - .await - .map_err(|error| error.to_string())?; - admission.ensure_capacity()?; - let proof = handle_402_and_pay_authorized( - &builder, - &headers, - &url, - move |url, requirement, _chain, amount_atomic| async move { - let authorization = callbacks - .authorize_x402_payment(AuthorizeX402PaymentRequest { - url, - network: requirement.network, - asset: requirement.asset, - recipient: requirement.pay_to, - amount_atomic, - owner: owner.clone(), - }) - .await?; - if authorization.approved { - Ok(()) - } else { - Err(authorization - .reason - .unwrap_or_else(|| "x402 payment was not approved".to_string())) - } - }, - ) + let (proof, _admission) = with_x402_execution_context(request.owner, secrets, async { + let signer: Arc = Arc::new(ModulePaymentSigner); + let transport = Arc::new(HostTransport::new(self.callbacks.clone())); + let builder = CryptoPayments::new(signer, transport); + // Bound concurrent chain refreshes before the first network await. + // Capacity is checked again after refresh so finalized expiry + // evidence can free slots before new authorizations are admitted. + let admission = self.begin_pending()?; + tinywallet_x402::protocol::refresh_solana_authorizations(&builder) .await .map_err(|error| error.to_string())?; - Ok::<_, String>((proof, admission)) - }) - .await?; + admission.ensure_capacity()?; + let proof = handle_402_and_pay_authorized( + &builder, + &headers, + &url, + move |url, requirement, _chain, amount_atomic| async move { + let authorization = callbacks + .authorize_x402_payment(AuthorizeX402PaymentRequest { + url, + network: requirement.network, + asset: requirement.asset, + recipient: requirement.pay_to, + amount_atomic, + owner: owner.clone(), + }) + .await?; + if authorization.approved { + Ok(()) + } else { + Err(authorization + .reason + .unwrap_or_else(|| "x402 payment was not approved".to_string())) + } + }, + ) + .await + .map_err(|error| error.to_string())?; + Ok::<_, String>((proof, admission)) + }) + .await?; #[cfg(test)] if let Some((pause_once, reached, release)) = &self.commit_handoff { if pause_once.swap(false, std::sync::atomic::Ordering::SeqCst) { @@ -421,15 +421,11 @@ struct ModulePaymentSigner; #[async_trait] impl PaymentSigner for ModulePaymentSigner { async fn account(&self, chain: PaymentChain) -> Result { - let mut secret = super::web3::clone_secret()?; let expected_chain = match chain { PaymentChain::Evm => tinywallet_bus::Chain::Evm, PaymentChain::Solana => tinywallet_bus::Chain::Solana, }; - if secret.chain != expected_chain { - wipe(&mut secret); - return Err("x402 wallet secret does not match the selected payment chain".to_string()); - } + let mut secret = super::web3::clone_x402_secret(expected_chain)?; let derived = super::derive_account(&secret).map_err(super::failure_message); wipe(&mut secret); let derived = derived?; @@ -455,7 +451,6 @@ impl PaymentSigner for ModulePaymentSigner { message: &[u8], scheme: SignScheme, ) -> Result, String> { - let mut secret = super::web3::clone_secret()?; let (expected_chain, scheme) = match (chain, scheme) { (PaymentChain::Evm, SignScheme::Secp256k1Digest) => ( tinywallet_bus::Chain::Evm, @@ -466,14 +461,10 @@ impl PaymentSigner for ModulePaymentSigner { tinywallet_bus::wire::Scheme::Ed25519, ), _ => { - wipe(&mut secret); return Err("x402 payment requested an unsupported signing scheme".to_string()); } }; - if secret.chain != expected_chain { - wipe(&mut secret); - return Err("x402 wallet secret does not match the selected payment chain".to_string()); - } + let secret = super::web3::clone_x402_secret(expected_chain)?; let mut request = tinywallet_bus::wire::SignMessageRequest { secret, message_hex: hex::encode(message), @@ -502,6 +493,41 @@ impl PaymentSigner for ModulePaymentSigner { fn wipe(secret: &mut SecretMaterial) { zeroize::Zeroize::zeroize(&mut secret.mnemonic); + zeroize::Zeroize::zeroize(&mut secret.derivation_path); +} + +fn wipe_secrets(secrets: &mut [SecretMaterial]) { + for secret in secrets { + wipe(secret); + } +} + +fn wipe_request_secrets(request: &mut X402PreparePaymentRequest) { + wipe(&mut request.secret); + wipe_secrets(&mut request.additional_secrets); +} + +fn take_request_secrets( + request: &mut X402PreparePaymentRequest, +) -> Result, String> { + let mut secrets = vec![std::mem::replace( + &mut request.secret, + SecretMaterial { + mnemonic: String::new(), + derivation_path: String::new(), + chain: tinywallet_bus::Chain::Evm, + }, + )]; + secrets.append(&mut request.additional_secrets); + if secrets.iter().enumerate().any(|(index, secret)| { + secrets[..index] + .iter() + .any(|prior| prior.chain == secret.chain) + }) { + wipe_secrets(&mut secrets); + return Err("x402 request contains duplicate secrets for one chain".to_string()); + } + Ok(secrets) } #[cfg(test)] diff --git a/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs b/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs index cd292c24..03a74124 100644 --- a/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs +++ b/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs @@ -105,6 +105,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { thread_id: "fixture-thread".to_string(), client_id: "fixture-client".to_string(), }), + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }) .await @@ -196,6 +197,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_solana_challenge_header(), owner: None, + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Solana), }; let first_solana = wallet.x402_prepare_payment(prepare_solana()).await.unwrap(); @@ -255,6 +257,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), owner: None, + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }) .await @@ -283,6 +286,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), owner: None, + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }) .await @@ -338,6 +342,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), owner: None, + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }) .await; diff --git a/crates/tinywallet-module/src/service/x402_tests.rs b/crates/tinywallet-module/src/service/x402_tests.rs index 38b028f0..777f19c4 100644 --- a/crates/tinywallet-module/src/service/x402_tests.rs +++ b/crates/tinywallet-module/src/service/x402_tests.rs @@ -55,34 +55,26 @@ pub(crate) fn secret(chain: tinywallet_bus::Chain) -> SecretMaterial { async fn payment_signer_derives_and_signs_for_both_supported_rails() { let signer = ModulePaymentSigner; assert!(signer.account(PaymentChain::Evm).await.is_err()); - assert!(crate::service::web3::clone_secret().is_err()); let (evm, evm_signature, solana, solana_signature) = - crate::service::web3::with_execution_context( - None, - secret(tinywallet_bus::Chain::Evm), + crate::service::web3::with_x402_execution_context( None, + vec![ + secret(tinywallet_bus::Chain::Evm), + secret(tinywallet_bus::Chain::Solana), + ], async { let evm = signer.account(PaymentChain::Evm).await.unwrap(); let evm_signature = signer .sign(PaymentChain::Evm, &[0x42; 32], SignScheme::Secp256k1Digest) .await .unwrap(); - let solana = crate::service::web3::with_execution_context( - None, - secret(tinywallet_bus::Chain::Solana), - None, - async { - let solana = signer.account(PaymentChain::Solana).await.unwrap(); - let solana_signature = signer - .sign(PaymentChain::Solana, b"fixture", SignScheme::Ed25519) - .await - .unwrap(); - (solana, solana_signature) - }, - ) - .await; - (evm, evm_signature, solana.0, solana.1) + let solana = signer.account(PaymentChain::Solana).await.unwrap(); + let solana_signature = signer + .sign(PaymentChain::Solana, b"fixture", SignScheme::Ed25519) + .await + .unwrap(); + (evm, evm_signature, solana, solana_signature) }, ) .await; @@ -98,10 +90,9 @@ async fn payment_signer_derives_and_signs_for_both_supported_rails() { #[tokio::test] async fn payment_signer_rejects_chain_and_scheme_mismatches() { let signer = ModulePaymentSigner; - let (wrong_chain, unsupported_scheme) = crate::service::web3::with_execution_context( - None, - secret(tinywallet_bus::Chain::Solana), + let (wrong_chain, unsupported_scheme) = crate::service::web3::with_x402_execution_context( None, + vec![secret(tinywallet_bus::Chain::Solana)], async { let wrong_chain = signer.account(PaymentChain::Evm).await.unwrap_err(); let unsupported_scheme = signer @@ -150,6 +141,19 @@ async fn ledger_lifecycle_rejects_stale_handles_and_malformed_challenges() { ); assert!(format!("{runtime:?}").contains("X402Runtime")); let handle = runtime.handle(); + let duplicate_chain_secrets = runtime + .prepare(X402PreparePaymentRequest { + service: handle.clone(), + url: "https://example.invalid".to_string(), + payment_required: "unused".to_string(), + owner: None, + secret: secret(tinywallet_bus::Chain::Evm), + additional_secrets: vec![secret(tinywallet_bus::Chain::Evm)], + }) + .await + .unwrap_err(); + assert!(duplicate_chain_secrets.contains("duplicate secrets for one chain")); + let stale = X402ServiceHandle { handle: "stale".to_string(), }; @@ -159,6 +163,7 @@ async fn ledger_lifecycle_rejects_stale_handles_and_malformed_challenges() { url: "https://example.invalid".to_string(), payment_required: "bad\nheader".to_string(), owner: None, + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }) .await @@ -171,6 +176,7 @@ async fn ledger_lifecycle_rejects_stale_handles_and_malformed_challenges() { url: "https://example.invalid".to_string(), payment_required: "bad\nheader".to_string(), owner: None, + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }) .await @@ -623,6 +629,7 @@ async fn x402_pending_capacity_is_reserved_before_authorization_and_released_on_ thread_id: "thread-attributed".to_string(), client_id: "client-attributed".to_string(), }), + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }; for _ in 0..(MAX_PENDING - 1) { @@ -700,6 +707,7 @@ async fn assert_admission_is_held_through_durable_commit( url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), owner: None, + additional_secrets: Vec::new(), secret: secret(tinywallet_bus::Chain::Evm), }; let commit_reached = Arc::new(tokio::sync::Notify::new()); diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index d66fb3f1..d53fda05 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -562,6 +562,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { url: "https://fixture.test/resource".to_string(), payment_required: challenge.clone(), owner: None, + additional_secrets: Vec::new(), secret: SecretMaterial { mnemonic: VECTOR.to_string(), derivation_path: "m/44'/60'/0'/0/0".to_string(), @@ -595,6 +596,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { url: "https://fixture.test/resource".to_string(), payment_required: expensive_challenge, owner: None, + additional_secrets: Vec::new(), secret: SecretMaterial { mnemonic: VECTOR.to_string(), derivation_path: "m/44'/60'/0'/0/0".to_string(), @@ -633,6 +635,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { url: "https://fixture.test/resource".to_string(), payment_required: challenge.clone(), owner: None, + additional_secrets: Vec::new(), secret: SecretMaterial { mnemonic: VECTOR.to_string(), derivation_path: "m/44'/60'/0'/0/0".to_string(), @@ -655,6 +658,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { approved_payment.recipient, "0x0000000000000000000000000000000000000002" ); + proxy .call::<()>( "X402RecordPayment", @@ -773,6 +777,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { url: "https://fixture.test/resource".to_string(), payment_required: challenge, owner: None, + additional_secrets: Vec::new(), secret: SecretMaterial { mnemonic: VECTOR.to_string(), derivation_path: "m/44'/60'/0'/0/0".to_string(), @@ -857,6 +862,58 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { "0x0000000000000000000000000000000000000001" ); + let solana_challenge = BASE64.encode( + serde_json::to_vec(&serde_json::json!({ + "x402Version": 2, + "resource": {"url": "https://fixture.test/solana-resource"}, + "accepts": [{ + "scheme": "exact", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "amount": "100", + "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "payTo": "11111111111111111111111111111111", + "maxTimeoutSeconds": 60, + "extra": { + "feePayer": "EwWqGE4ZFKLofuestmU4LDdK7XM1N4ALgdZccwYugwGd", + "memo": "module-chain-secret-fixture" + } + }], + "extensions": {} + })) + .unwrap(), + ); + let selected_payment: serde_json::Value = proxy + .call_confidential( + "X402PreparePayment", + (X402PreparePaymentRequest { + service: x402_service.clone(), + url: "https://fixture.test/solana-resource".to_string(), + payment_required: solana_challenge, + owner: None, + secret: SecretMaterial { + mnemonic: VECTOR.to_string(), + derivation_path: "m/44'/60'/0'/0/0".to_string(), + chain: Chain::Evm, + }, + additional_secrets: vec![SecretMaterial { + mnemonic: VECTOR.to_string(), + derivation_path: "m/44'/501'/0'/0'".to_string(), + chain: Chain::Solana, + }], + },), + ) + .await + .expect("module must select the Solana secret after parsing the challenge"); + assert_eq!( + selected_payment["network"], + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + ); + assert_eq!( + approved_x402.lock().unwrap().as_ref().unwrap().network, + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "the actual parsed chain must reach the normal approval callback" + ); + proxy .call::<()>( "X402Close", @@ -1135,6 +1192,12 @@ impl TestHost { let result = match request { ChainTransportRequest::JsonRpc { method, params, .. } => { let value = match method.as_str() { + "getLatestBlockhash" => serde_json::json!({ + "value": { + "blockhash": "11111111111111111111111111111111", + "lastValidBlockHeight": 100 + } + }), "eth_chainId" | "eth_gasPrice" => serde_json::json!("0x1"), "eth_getTransactionCount" => serde_json::json!("0x0"), "eth_estimateGas" => serde_json::json!("0x5208"),