From e52ed5e8aee870da79e01890e7e7ee02fdf67d18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 18:57:00 +0300 Subject: [PATCH 01/14] chore: files changed crates/tinywallet-bus/src/wire/host.rs,crates/tinywallet-module/src/service/mod Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/wire/host.rs | 4 ++++ crates/tinywallet-module/src/service/mod_tests.rs | 3 ++- .../src/service/x402_lifecycle_tests.rs | 15 ++++++++++----- .../tinywallet-module/src/service/x402_tests.rs | 12 ++++++++---- crates/tinywallet-module/tests/lifecycle_e2e.rs | 12 ++++++++---- 5 files changed, 32 insertions(+), 14 deletions(-) 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-module/src/service/mod_tests.rs b/crates/tinywallet-module/src/service/mod_tests.rs index 2e588118..8fc347f1 100644 --- a/crates/tinywallet-module/src/service/mod_tests.rs +++ b/crates/tinywallet-module/src/service/mod_tests.rs @@ -1607,11 +1607,12 @@ async fn user_scoped_operations_fail_closed_before_configuration() { ); assert!( wallet - .x402_prepare_payment(X402PreparePaymentRequest { + .x402_prepare_payment({ service: x402_handle.clone(), 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/x402_lifecycle_tests.rs b/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs index cd292c24..c4e5e95a 100644 --- a/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs +++ b/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs @@ -97,7 +97,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { let handle = replaced; let prepared = wallet - .x402_prepare_payment(X402PreparePaymentRequest { + .x402_prepare_payment({ service: handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), @@ -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 @@ -191,11 +192,12 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { assert_eq!(settled.daily_total_atomic, 100); assert_eq!(settled.reserved_atomic, 0); - let prepare_solana = || X402PreparePaymentRequest { + let prepare_solana = || { service: handle.clone(), 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(); @@ -250,11 +252,12 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { approved.store(false, Ordering::SeqCst); let denied = wallet - .x402_prepare_payment(X402PreparePaymentRequest { + .x402_prepare_payment({ service: handle.clone(), 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 @@ -278,11 +281,12 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { let pending_handle = handle.clone(); let task = tokio::spawn(async move { pending_wallet - .x402_prepare_payment(X402PreparePaymentRequest { + .x402_prepare_payment({ service: pending_handle, 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 @@ -333,11 +337,12 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { .unwrap(); let authorizations_before_unpersisted = authorizations.load(Ordering::SeqCst); let unpersisted = wallet - .x402_prepare_payment(X402PreparePaymentRequest { + .x402_prepare_payment({ service: blocked_handle.clone(), 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..c212f113 100644 --- a/crates/tinywallet-module/src/service/x402_tests.rs +++ b/crates/tinywallet-module/src/service/x402_tests.rs @@ -154,11 +154,12 @@ async fn ledger_lifecycle_rejects_stale_handles_and_malformed_challenges() { handle: "stale".to_string(), }; let invalid_service = runtime - .prepare(X402PreparePaymentRequest { + .prepare({ service: stale, 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 @@ -166,11 +167,12 @@ async fn ledger_lifecycle_rejects_stale_handles_and_malformed_challenges() { assert!(invalid_service.contains("handle is unavailable")); let invalid_header = runtime - .prepare(X402PreparePaymentRequest { + .prepare({ service: handle.clone(), 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 @@ -615,7 +617,7 @@ async fn x402_pending_capacity_is_reserved_before_authorization_and_released_on_ .await .unwrap(); - let prepare = || X402PreparePaymentRequest { + let prepare = || { service: handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), @@ -623,6 +625,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) { @@ -695,11 +698,12 @@ async fn assert_admission_is_held_through_durable_commit( ) { use tinywallet_bus::wire::X402PreparePaymentRequest; - let prepare = || X402PreparePaymentRequest { + let prepare = || { service: handle.clone(), 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..cf712b69 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -557,11 +557,12 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { let denied = proxy .call_confidential::( "X402PreparePayment", - (X402PreparePaymentRequest { + ({ service: x402_service.clone(), 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(), @@ -590,11 +591,12 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { let too_expensive = proxy .call_confidential::( "X402PreparePayment", - (X402PreparePaymentRequest { + ({ service: x402_service.clone(), 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(), @@ -628,11 +630,12 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { let payment: serde_json::Value = proxy .call_confidential( "X402PreparePayment", - (X402PreparePaymentRequest { + ({ service: x402_service.clone(), 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(), @@ -768,11 +771,12 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { prepare_proxy .call_confidential::( "X402PreparePayment", - (X402PreparePaymentRequest { + ({ service: pending_service, 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(), From ac960d8a17af79b6792a193af373c76471c0e9df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 18:57:47 +0300 Subject: [PATCH 02/14] chore: files changed crates/tinywallet-module/src/service/mod_tests.rs,crates/tinywallet-module/src/ Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/src/service/mod_tests.rs | 2 +- .../src/service/x402_lifecycle_tests.rs | 10 +++++----- crates/tinywallet-module/src/service/x402_tests.rs | 8 ++++---- crates/tinywallet-module/tests/lifecycle_e2e.rs | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/tinywallet-module/src/service/mod_tests.rs b/crates/tinywallet-module/src/service/mod_tests.rs index 8fc347f1..4978b7c8 100644 --- a/crates/tinywallet-module/src/service/mod_tests.rs +++ b/crates/tinywallet-module/src/service/mod_tests.rs @@ -1607,7 +1607,7 @@ async fn user_scoped_operations_fail_closed_before_configuration() { ); assert!( wallet - .x402_prepare_payment({ + .x402_prepare_payment(X402PreparePaymentRequest { service: x402_handle.clone(), url: "https://example.invalid".to_string(), payment_required: String::new(), diff --git a/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs b/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs index c4e5e95a..03a74124 100644 --- a/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs +++ b/crates/tinywallet-module/src/service/x402_lifecycle_tests.rs @@ -97,7 +97,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { let handle = replaced; let prepared = wallet - .x402_prepare_payment({ + .x402_prepare_payment(X402PreparePaymentRequest { service: handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), @@ -192,7 +192,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { assert_eq!(settled.daily_total_atomic, 100); assert_eq!(settled.reserved_atomic, 0); - let prepare_solana = || { + let prepare_solana = || X402PreparePaymentRequest { service: handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_solana_challenge_header(), @@ -252,7 +252,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { approved.store(false, Ordering::SeqCst); let denied = wallet - .x402_prepare_payment({ + .x402_prepare_payment(X402PreparePaymentRequest { service: handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), @@ -281,7 +281,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { let pending_handle = handle.clone(); let task = tokio::spawn(async move { pending_wallet - .x402_prepare_payment({ + .x402_prepare_payment(X402PreparePaymentRequest { service: pending_handle, url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), @@ -337,7 +337,7 @@ async fn x402_service_lifecycle_records_approval_denial_and_cancellation() { .unwrap(); let authorizations_before_unpersisted = authorizations.load(Ordering::SeqCst); let unpersisted = wallet - .x402_prepare_payment({ + .x402_prepare_payment(X402PreparePaymentRequest { service: blocked_handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), diff --git a/crates/tinywallet-module/src/service/x402_tests.rs b/crates/tinywallet-module/src/service/x402_tests.rs index c212f113..fa94005d 100644 --- a/crates/tinywallet-module/src/service/x402_tests.rs +++ b/crates/tinywallet-module/src/service/x402_tests.rs @@ -154,7 +154,7 @@ async fn ledger_lifecycle_rejects_stale_handles_and_malformed_challenges() { handle: "stale".to_string(), }; let invalid_service = runtime - .prepare({ + .prepare(X402PreparePaymentRequest { service: stale, url: "https://example.invalid".to_string(), payment_required: "bad\nheader".to_string(), @@ -167,7 +167,7 @@ async fn ledger_lifecycle_rejects_stale_handles_and_malformed_challenges() { assert!(invalid_service.contains("handle is unavailable")); let invalid_header = runtime - .prepare({ + .prepare(X402PreparePaymentRequest { service: handle.clone(), url: "https://example.invalid".to_string(), payment_required: "bad\nheader".to_string(), @@ -617,7 +617,7 @@ async fn x402_pending_capacity_is_reserved_before_authorization_and_released_on_ .await .unwrap(); - let prepare = || { + let prepare = || X402PreparePaymentRequest { service: handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), @@ -698,7 +698,7 @@ async fn assert_admission_is_held_through_durable_commit( ) { use tinywallet_bus::wire::X402PreparePaymentRequest; - let prepare = || { + let prepare = || X402PreparePaymentRequest { service: handle.clone(), url: "https://wallet.fixture.test/resource".to_string(), payment_required: valid_challenge_header(), diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index cf712b69..c0bf61b8 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -557,7 +557,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { let denied = proxy .call_confidential::( "X402PreparePayment", - ({ + (X402PreparePaymentRequest { service: x402_service.clone(), url: "https://fixture.test/resource".to_string(), payment_required: challenge.clone(), @@ -591,7 +591,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { let too_expensive = proxy .call_confidential::( "X402PreparePayment", - ({ + (X402PreparePaymentRequest { service: x402_service.clone(), url: "https://fixture.test/resource".to_string(), payment_required: expensive_challenge, @@ -630,7 +630,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { let payment: serde_json::Value = proxy .call_confidential( "X402PreparePayment", - ({ + (X402PreparePaymentRequest { service: x402_service.clone(), url: "https://fixture.test/resource".to_string(), payment_required: challenge.clone(), @@ -771,7 +771,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { prepare_proxy .call_confidential::( "X402PreparePayment", - ({ + (X402PreparePaymentRequest { service: pending_service, url: "https://fixture.test/resource".to_string(), payment_required: challenge, From 34a7664a73aac7ba1252d86e6566c931f37ff651 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 18:58:17 +0300 Subject: [PATCH 03/14] test(lifecycle): cover solana payment selection in the e2e flow Extend the end-to-end lifecycle test with a Solana x402 challenge so the module must pick the matching secret from the additional secrets and report the parsed chain through the approval callback. The test host now answers getLatestBlockhash so the Solana path can be exercised. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinywallet-module/tests/lifecycle_e2e.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index c0bf61b8..5b6f74db 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -658,6 +658,58 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { approved_payment.recipient, "0x0000000000000000000000000000000000000002" ); + + 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": "EPjFWdd5AufqSSqeM2qWw89b7aQf1nZ5V9u7vG3Gf6D", + "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::<()>( "X402RecordPayment", @@ -1139,6 +1191,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"), From ea9ca4536117b6a6ae3fca509b59bfe262a03d4e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 18:59:06 +0300 Subject: [PATCH 04/14] test(lifecycle): reorder solana payment checks after evm assertions Move the Solana challenge preparation and approval assertions later in the end-to-end test so the EVM payment flow is exercised first. The block is unchanged in content, only relocated to keep the chain coverage in a consistent order. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinywallet-module/tests/lifecycle_e2e.rs | 103 +++++++++--------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index 5b6f74db..eac867be 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -659,57 +659,6 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { "0x0000000000000000000000000000000000000002" ); - 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": "EPjFWdd5AufqSSqeM2qWw89b7aQf1nZ5V9u7vG3Gf6D", - "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::<()>( "X402RecordPayment", @@ -913,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": "EPjFWdd5AufqSSqeM2qWw89b7aQf1nZ5V9u7vG3Gf6D", + "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", From f039a3f95b1e814e0ccc3cb45e227f922b44319d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:00:18 +0300 Subject: [PATCH 05/14] test(wire): cover legacy x402 secret serialization Add a test asserting that an x402 prepare-payment request carrying only the legacy secret field deserializes with empty additional secrets and round-trips back to the original JSON, guarding the extension against breaking existing payloads. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/wire/mod_tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/tinywallet-bus/src/wire/mod_tests.rs b/crates/tinywallet-bus/src/wire/mod_tests.rs index 336d6e8b..86732dfc 100644 --- a/crates/tinywallet-bus/src/wire/mod_tests.rs +++ b/crates/tinywallet-bus/src/wire/mod_tests.rs @@ -344,3 +344,21 @@ 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!(request.additional_secrets.is_empty()); + assert_eq!(serde_json::to_value(request).unwrap(), legacy); +} From 2962fcc5f97c3b19b473b8fe15355c4b62e5ab14 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:04:19 +0300 Subject: [PATCH 06/14] test(tinywallet-module): correct usdc mint address in lifecycle e2e The Solana USDC mint used by the end-to-end signing test was wrong, so the fixture did not match the real token. It now uses the canonical mint address. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/tests/lifecycle_e2e.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index eac867be..d53fda05 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -870,7 +870,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { "scheme": "exact", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "amount": "100", - "asset": "EPjFWdd5AufqSSqeM2qWw89b7aQf1nZ5V9u7vG3Gf6D", + "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "payTo": "11111111111111111111111111111111", "maxTimeoutSeconds": 60, "extra": { From e8c9f5107b7b6ee1e6ab92f54734c0bcb5119911 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:04:48 +0300 Subject: [PATCH 07/14] feat(web3): scope x402 payment secrets per execution context Add a task-local slot holding the host-provided secrets for module-selected x402 payment rails, scoped alongside the quote owner, and expose a helper that clones only the secret matching the chain chosen by the parsed challenge. The slot zeroizes every mnemonic and derivation path on drop, and the existing single-secret slot now zeroizes its derivation path too. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/src/service/web3.rs | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/tinywallet-module/src/service/web3.rs b/crates/tinywallet-module/src/service/web3.rs index 6d7d11a5..9fe6da33 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,26 @@ 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, From 97ee86fdceffb84982ae2ae998ab9e3b5ef62f4d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:06:39 +0300 Subject: [PATCH 08/14] chore: files changed crates/tinywallet-module/src/service/mod.rs,crates/tinywallet-module/src/servic Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/src/service/mod.rs | 4 ++ crates/tinywallet-module/src/service/x402.rs | 43 ++++++++++++------- .../src/service/x402_tests.rs | 35 ++++++--------- 3 files changed, 45 insertions(+), 37 deletions(-) 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/x402.rs b/crates/tinywallet-module/src/service/x402.rs index 30f7875a..c6afaba0 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,25 @@ 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 = vec![request.secret]; + 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()); + } 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); } }; @@ -143,7 +153,7 @@ impl X402Runtime { let url = request.url.clone(); let callbacks = self.callbacks.clone(); let (proof, _admission) = - with_execution_context(request.owner, request.secret, None, async { + 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); @@ -421,15 +431,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 +461,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 +471,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 mut secret = super::web3::clone_x402_secret(expected_chain)?; let mut request = tinywallet_bus::wire::SignMessageRequest { secret, message_hex: hex::encode(message), @@ -502,6 +503,18 @@ 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); } #[cfg(test)] diff --git a/crates/tinywallet-module/src/service/x402_tests.rs b/crates/tinywallet-module/src/service/x402_tests.rs index fa94005d..b8a3e5dd 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 From 116a8363e9ceb2d1a15845ca374d2d35c5bc6a1f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:07:06 +0300 Subject: [PATCH 09/14] style: apply rustfmt formatting Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/wire/mod_tests.rs | 3 +- crates/tinywallet-module/src/service/web3.rs | 8 +- crates/tinywallet-module/src/service/x402.rs | 81 ++++++++++---------- 3 files changed, 47 insertions(+), 45 deletions(-) diff --git a/crates/tinywallet-bus/src/wire/mod_tests.rs b/crates/tinywallet-bus/src/wire/mod_tests.rs index 86732dfc..702d3e48 100644 --- a/crates/tinywallet-bus/src/wire/mod_tests.rs +++ b/crates/tinywallet-bus/src/wire/mod_tests.rs @@ -357,8 +357,7 @@ fn x402_chain_secret_extension_preserves_legacy_serialization() { "chain": "evm" } }); - let request: super::X402PreparePaymentRequest = - serde_json::from_value(legacy.clone()).unwrap(); + let request: super::X402PreparePaymentRequest = serde_json::from_value(legacy.clone()).unwrap(); assert!(request.additional_secrets.is_empty()); assert_eq!(serde_json::to_value(request).unwrap(), legacy); } diff --git a/crates/tinywallet-module/src/service/web3.rs b/crates/tinywallet-module/src/service/web3.rs index 9fe6da33..f594ce80 100644 --- a/crates/tinywallet-module/src/service/web3.rs +++ b/crates/tinywallet-module/src/service/web3.rs @@ -338,9 +338,13 @@ pub(super) fn clone_x402_secret(chain: tinywallet_bus::Chain) -> Result = 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) { @@ -435,7 +434,7 @@ impl PaymentSigner for ModulePaymentSigner { PaymentChain::Evm => tinywallet_bus::Chain::Evm, PaymentChain::Solana => tinywallet_bus::Chain::Solana, }; - let mut secret = super::web3::clone_x402_secret(expected_chain)?; + let 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?; From 3cb1ff6c4ed24ea233a6b936a4819c7ec26723dc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:10:20 +0300 Subject: [PATCH 10/14] feat(version): bump contract version to 1.12 Raise CONTRACT_VERSION to 1.12 to reflect the current wire contract, and update the compatibility test to expect the new minor version. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/lib_tests.rs | 2 +- crates/tinywallet-bus/src/version/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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`. /// From ac179ec96f8109b3321f041afdbfba2cd96e904c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:10:52 +0300 Subject: [PATCH 11/14] docs(tinywallet-bus): document x402 additional_secrets contract Document contract 1.12, which adds optional additional_secrets to the confidential X402PreparePayment request while preserving the prior JSON shape when it is omitted or empty. A test now covers rejection of duplicate secrets for the same chain. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/README.md | 5 +++++ crates/tinywallet-module/src/service/x402_tests.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+) 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-module/src/service/x402_tests.rs b/crates/tinywallet-module/src/service/x402_tests.rs index b8a3e5dd..777f19c4 100644 --- a/crates/tinywallet-module/src/service/x402_tests.rs +++ b/crates/tinywallet-module/src/service/x402_tests.rs @@ -141,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(), }; From 4e4362762c5261d9ac55db1d00c0d8d5bb1cdc4e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:12:28 +0300 Subject: [PATCH 12/14] fix(x402): correct mutability of cloned secrets The secret cloned for account derivation is now bound as mutable so it can be zeroized after use, while the secret passed into the signing request is no longer marked mutable since it is moved rather than wiped. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/src/service/x402.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinywallet-module/src/service/x402.rs b/crates/tinywallet-module/src/service/x402.rs index 1fa063e9..21b585df 100644 --- a/crates/tinywallet-module/src/service/x402.rs +++ b/crates/tinywallet-module/src/service/x402.rs @@ -434,7 +434,7 @@ impl PaymentSigner for ModulePaymentSigner { PaymentChain::Evm => tinywallet_bus::Chain::Evm, PaymentChain::Solana => tinywallet_bus::Chain::Solana, }; - let secret = super::web3::clone_x402_secret(expected_chain)?; + 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?; @@ -473,7 +473,7 @@ impl PaymentSigner for ModulePaymentSigner { return Err("x402 payment requested an unsupported signing scheme".to_string()); } }; - let mut secret = super::web3::clone_x402_secret(expected_chain)?; + let secret = super::web3::clone_x402_secret(expected_chain)?; let mut request = tinywallet_bus::wire::SignMessageRequest { secret, message_hex: hex::encode(message), From 3188fcd00a9fdf75087de20bcd777c4049d342d7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:18:54 +0300 Subject: [PATCH 13/14] refactor(x402): extract secret collection into helper Moved the duplicate-chain validation and secret gathering out of the prepare-payment path into a take_request_secrets helper that also drains the secrets from the request. This keeps the secrets owned by the caller so they can be wiped on every exit path instead of being left behind in the request struct. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-module/src/service/x402.rs | 34 ++++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/tinywallet-module/src/service/x402.rs b/crates/tinywallet-module/src/service/x402.rs index 21b585df..3c0a5b89 100644 --- a/crates/tinywallet-module/src/service/x402.rs +++ b/crates/tinywallet-module/src/service/x402.rs @@ -126,16 +126,7 @@ impl X402Runtime { wipe_request_secrets(&mut request); return Err(error); } - let mut secrets = vec![request.secret]; - 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()); - } + let 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 { @@ -516,6 +507,29 @@ fn wipe_request_secrets(request: &mut X402PreparePaymentRequest) { 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)] static LEDGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); From 35f2c9ec5706c36613d4055b85efa9c02ab53f05 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 19:19:21 +0300 Subject: [PATCH 14/14] test(x402): drop redundant assertion and mark secrets binding mutable The legacy serialization test no longer asserts that additional_secrets is empty, since the round-trip equality check already covers the field. The secrets binding in the x402 runtime is now mutable so it can be consumed later in the request flow. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinywallet-bus/src/wire/mod_tests.rs | 1 - crates/tinywallet-module/src/service/x402.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinywallet-bus/src/wire/mod_tests.rs b/crates/tinywallet-bus/src/wire/mod_tests.rs index 702d3e48..2ea7655c 100644 --- a/crates/tinywallet-bus/src/wire/mod_tests.rs +++ b/crates/tinywallet-bus/src/wire/mod_tests.rs @@ -358,6 +358,5 @@ fn x402_chain_secret_extension_preserves_legacy_serialization() { } }); let request: super::X402PreparePaymentRequest = serde_json::from_value(legacy.clone()).unwrap(); - assert!(request.additional_secrets.is_empty()); assert_eq!(serde_json::to_value(request).unwrap(), legacy); } diff --git a/crates/tinywallet-module/src/service/x402.rs b/crates/tinywallet-module/src/service/x402.rs index 3c0a5b89..514d7cfb 100644 --- a/crates/tinywallet-module/src/service/x402.rs +++ b/crates/tinywallet-module/src/service/x402.rs @@ -126,7 +126,7 @@ impl X402Runtime { wipe_request_secrets(&mut request); return Err(error); } - let secrets = take_request_secrets(&mut request)?; + 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 {