Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/tinywallet-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion crates/tinywallet-bus/src/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
2 changes: 1 addition & 1 deletion crates/tinywallet-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
@@ -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`.
///
Expand Down
4 changes: 4 additions & 0 deletions crates/tinywallet-bus/src/wire/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ pub struct X402PreparePaymentRequest {
pub owner: Option<QuoteOwner>,
/// 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<SecretMaterial>,
}

/// Payment proof and pending-ledger handle returned to the host for retry.
Expand Down
16 changes: 16 additions & 0 deletions crates/tinywallet-bus/src/wire/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 4 additions & 0 deletions crates/tinywallet-module/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/tinywallet-module/src/service/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions crates/tinywallet-module/src/service/web3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use super::host::HostCallbacks;
tokio::task_local! {
static ACTIVE_QUOTE_OWNER: Option<LibraryQuoteOwner>;
static ACTIVE_SECRET: Arc<SecretSlot>;
static ACTIVE_X402_SECRETS: Arc<X402SecretSlot>;
static ACTIVE_WEB3_APPROVAL: Option<Web3ApprovalContext>;
static ACTIVE_NATIVE_APPROVAL: Option<NativeApprovalContext>;
}
Expand All @@ -45,12 +46,28 @@ pub(crate) struct NativeApprovalContext {

struct SecretSlot(Mutex<Option<SecretMaterial>>);

struct X402SecretSlot(Mutex<Option<Vec<SecretMaterial>>>);

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);
}
}
}
}
Expand Down Expand Up @@ -261,6 +278,18 @@ pub(crate) async fn with_execution_context<F: Future>(
.await
}

/// Scope the host-provided secrets for module-selected x402 payment rails.
pub(crate) async fn with_x402_execution_context<F: Future>(
owner: Option<tinywallet_bus::wire::QuoteOwner>,
secrets: Vec<SecretMaterial>,
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<F: Future>(
approval: NativeApprovalContext,
Expand Down Expand Up @@ -294,6 +323,30 @@ pub(super) fn clone_secret() -> Result<SecretMaterial, String> {
.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<SecretMaterial, String> {
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<F: Future>(
owner: Option<tinywallet_bus::wire::QuoteOwner>,
Expand Down
134 changes: 80 additions & 54 deletions crates/tinywallet-module/src/service/x402.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -123,15 +123,16 @@ impl X402Runtime {
mut request: X402PreparePaymentRequest,
) -> Result<X402PreparePaymentResponse, String> {
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);
}
};
Expand All @@ -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<dyn PaymentSigner> = 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<dyn PaymentSigner> = 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) {
Expand Down Expand Up @@ -421,15 +421,11 @@ struct ModulePaymentSigner;
#[async_trait]
impl PaymentSigner for ModulePaymentSigner {
async fn account(&self, chain: PaymentChain) -> Result<PaymentAccount, String> {
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?;
Expand All @@ -455,7 +451,6 @@ impl PaymentSigner for ModulePaymentSigner {
message: &[u8],
scheme: SignScheme,
) -> Result<Vec<u8>, String> {
let mut secret = super::web3::clone_secret()?;
let (expected_chain, scheme) = match (chain, scheme) {
(PaymentChain::Evm, SignScheme::Secp256k1Digest) => (
tinywallet_bus::Chain::Evm,
Expand All @@ -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),
Expand Down Expand Up @@ -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<Vec<SecretMaterial>, 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)]
Expand Down
Loading
Loading