diff --git a/Cargo.lock b/Cargo.lock index 627a6f2..0ae5d32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2062,6 +2062,7 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "hex", + "http-body-util", "k256", "log", "reqwest", diff --git a/crates/tinywallet-web3/README.md b/crates/tinywallet-web3/README.md index de4f0f9..55af197 100644 --- a/crates/tinywallet-web3/README.md +++ b/crates/tinywallet-web3/README.md @@ -56,6 +56,36 @@ before acting, so two concurrent confirmations cannot double-submit, and puts it back with a fresh lifetime if signing or broadcast fails. A caller of the wrong thread gets exactly the not-found text, so a leaked quote id gives no oracle. +## Bitcoin fees + +The fee is sized from the transaction actually built: 11 vB of overhead +(10.5, rounded up), 68 vB per P2WPKH input and 31 vB per output, at a fixed +20 sat/vB. Selection is largest-first and grows one input at a time, re-pricing +after each, so the fee always covers the inputs it added. A change output is +planned for first; if it would be dust (546 sats or less, the signer's own +threshold) it is dropped and the whole surplus becomes the fee. The fee handed to +the signer is the one it will see, so both sides agree on whether there is change. + +## Chain status + +`WalletEngine::chain_status` probes the endpoint of every chain the wallet has an +account for, through `Transport`: `eth_blockNumber` per EVM network, `getHealth` +on Solana, Esplora's `blocks/tip/height` on Bitcoin and `wallet/getnowblock` on +Tron. An endpoint that answers is `ready`. One that fails, or answers with +something that is not a tip, is `missing` and its row carries the failure in +`error`. A chain with no account is `missing` without being contacted. `error` is +omitted from a healthy row, so its wire shape is unchanged, and no new +`providerStatus` value exists. + +## Solana cluster + +`RpcEndpoints::solana_cluster()` drives more than the endpoint. `network_defaults` +labels the Solana row `solana-devnet` (not `solana-mainnet-beta`), lists the +devnet USDC mint, and, because a devnet link needs text after the hash, reports +`explorer_tx_url_suffix` (`?cluster=devnet`; absent on mainnet). Executed +transfers link to `https://solscan.io/tx/?cluster=devnet`. +`explorer_tx_url` takes the cluster for that reason. + ## Features | Feature | Default | Gates | diff --git a/crates/tinywallet-web3/src/crypto/chains/btc/mod.rs b/crates/tinywallet-web3/src/crypto/chains/btc/mod.rs index 91baab2..0e572d9 100644 --- a/crates/tinywallet-web3/src/crypto/chains/btc/mod.rs +++ b/crates/tinywallet-web3/src/crypto/chains/btc/mod.rs @@ -12,7 +12,6 @@ use serde::Deserialize; use serde_json::Value; use tinywallet_bus::wire::{TransactionSpec, Utxo}; -use crate::crypto::defaults::explorer_tx_url; use crate::crypto::execution::{ ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, @@ -20,12 +19,22 @@ use crate::crypto::execution::{ use crate::crypto::wallet::{WalletChain, WalletEngine}; const LOG_PREFIX: &str = "[wallet::btc]"; -/// Hardcoded fee rate (sat/vbyte) used to estimate fees for prepared quotes and -/// to size the change output. Conservative: mempools cap out around 50 sat/vB -/// during congestion; 20 keeps us in range without burning sats in quiet times. +/// Hardcoded fee rate (sat/vbyte) used to size the fee of an executed transfer. +/// Conservative: mempools cap out around 50 sat/vB during congestion; 20 keeps +/// us in range without burning sats in quiet times. const DEFAULT_FEE_RATE_SAT_VB: u64 = 20; -/// Approximate vbytes of a 1-input, 2-output P2WPKH transaction. -const TYPICAL_TX_VBYTES: u64 = 141; +/// Fixed vbytes of a segwit transaction: version, locktime, counts and the +/// witness marker, 10.5 vB, rounded up. +const TX_OVERHEAD_VBYTES: u64 = 11; +/// vbytes one P2WPKH input adds (outpoint, sequence and the discounted witness). +const P2WPKH_INPUT_VBYTES: u64 = 68; +/// vbytes one P2WPKH output adds. +const P2WPKH_OUTPUT_VBYTES: u64 = 31; +/// A change output at or below this many satoshis is dropped and folded into +/// the fee. It is the signer's own threshold (its change is emitted only when +/// the surplus is strictly above it), so the two sides agree on whether the +/// transaction has a change output. +const DUST_THRESHOLD_SATS: u64 = 546; /// One spendable output as Esplora reports it. #[derive(Debug, Deserialize, Clone)] @@ -47,9 +56,10 @@ struct EsploraAddressStats { spent_txo_sum: u64, } -/// The flat fee estimate, in satoshis. -pub(crate) const fn estimated_btc_fee_sats() -> u64 { - DEFAULT_FEE_RATE_SAT_VB * TYPICAL_TX_VBYTES +/// The vbytes of a P2WPKH transaction with `inputs` inputs and `outputs` +/// outputs. +pub(crate) const fn estimated_vbytes(inputs: u64, outputs: u64) -> u64 { + TX_OVERHEAD_VBYTES + inputs * P2WPKH_INPUT_VBYTES + outputs * P2WPKH_OUTPUT_VBYTES } fn accepted(result: &Result) -> &'static str { @@ -112,17 +122,46 @@ async fn broadcast_raw_hex(engine: &WalletEngine, tx_hex: &str) -> Result, + /// The fee, in satoshis. When the change would be dust this is everything + /// the inputs hold beyond the amount, so the two sides agree on it. + pub(crate) fee_sats: u64, + /// The change returned to the sender, in satoshis; zero when there is no + /// change output. + pub(crate) change_sats: u64, +} + +/// Price a transaction of `inputs` inputs and `outputs` outputs at +/// `fee_rate_sat_vb`. +fn fee_for(fee_rate_sat_vb: u64, inputs: u64, outputs: u64) -> Result { + fee_rate_sat_vb + .checked_mul(estimated_vbytes(inputs, outputs)) + .ok_or_else(|| "amount + fee overflow".to_string()) +} + +/// Select UTXOs, largest first, to cover `amount_sats` plus a fee sized for the +/// transaction those inputs make. +/// +/// Every input adds weight and so fee, which the next input may or may not +/// cover; the selection therefore grows one input at a time and re-prices the +/// transaction after each. A change output is planned for first (two outputs); +/// if what is left over would be dust, it is dropped (one output) and the whole +/// surplus goes to the fee. +pub(crate) fn plan_spend( utxos: &[EsploraUtxo], amount_sats: u64, - fee_sats: u64, -) -> Result<(Vec, u64), String> { + fee_rate_sat_vb: u64, +) -> Result { let mut sorted = utxos.to_vec(); sorted.sort_by_key(|item| std::cmp::Reverse(item.value)); - let target = amount_sats - .checked_add(fee_sats) + // The cheapest a spend can be: no inputs beyond the first, no change. It + // fails the whole call when even that overflows. + fee_for(fee_rate_sat_vb, 1, 1)? + .checked_add(amount_sats) .ok_or_else(|| "amount + fee overflow".to_string())?; let mut total: u64 = 0; let mut chosen = Vec::new(); @@ -131,10 +170,28 @@ pub(crate) fn select_utxos( .checked_add(utxo.value) .ok_or_else(|| "utxo sum overflow".to_string())?; chosen.push(utxo); - if total >= target { - return Ok((chosen, total - target)); + let inputs = chosen.len() as u64; + let fee_with_change = fee_for(fee_rate_sat_vb, inputs, 2)?; + let fee_without_change = fee_for(fee_rate_sat_vb, inputs, 1)?; + let with_change = amount_sats.saturating_add(fee_with_change); + if total >= with_change && total - with_change > DUST_THRESHOLD_SATS { + return Ok(SpendPlan { + selected: chosen, + fee_sats: fee_with_change, + change_sats: total - with_change, + }); + } + if total >= amount_sats.saturating_add(fee_without_change) { + return Ok(SpendPlan { + selected: chosen, + fee_sats: total - amount_sats, + change_sats: 0, + }); } } + let inputs = chosen.len() as u64; + let fee_sats = fee_for(fee_rate_sat_vb, inputs, 1)?; + let target = amount_sats.saturating_add(fee_sats); Err(format!( "insufficient BTC: have {total} sats, need {target} (amount {amount_sats} + fee {fee_sats})" )) @@ -164,8 +221,11 @@ pub(crate) async fn execute_btc_quote( if utxos.is_empty() { return Err(format!("no spendable UTXOs for {from_addr}")); } - let fee_sats = estimated_btc_fee_sats(); - let (selected, change_sats) = select_utxos(&utxos, amount_sats, fee_sats)?; + let SpendPlan { + selected, + fee_sats, + change_sats, + } = plan_spend(&utxos, amount_sats, DEFAULT_FEE_RATE_SAT_VB)?; // Selection stays here (this crate knows the fee policy and the UTXO // source), but the transaction itself is encoded by the signer, which also @@ -199,7 +259,7 @@ pub(crate) async fn execute_btc_quote( "{LOG_PREFIX} broadcast quote_id={} txid={} amount_sats={} change_sats={}", quote.quote_id, txid_hex, amount_sats, change_sats ); - let explorer_url = explorer_tx_url(WalletChain::Btc, &txid_hex); + let explorer_url = engine.explorer_url(WalletChain::Btc, &txid_hex); Ok(ExecutionResult { quote_id: quote.quote_id.clone(), status: PreparedStatus::Broadcasted, diff --git a/crates/tinywallet-web3/src/crypto/chains/btc/test.rs b/crates/tinywallet-web3/src/crypto/chains/btc/test.rs index 20f3ca8..f45f7ee 100644 --- a/crates/tinywallet-web3/src/crypto/chains/btc/test.rs +++ b/crates/tinywallet-web3/src/crypto/chains/btc/test.rs @@ -6,8 +6,8 @@ use serde_json::json; use tinywallet_bus::wire::TransactionSpec; use super::{ - EsploraUtxo, estimated_btc_fee_sats, execute_btc_quote, lookup_tx, native_balance, - select_utxos, tx_receipt, tx_status, validate_btc_address, validate_btc_sender_address, + EsploraUtxo, estimated_vbytes, execute_btc_quote, lookup_tx, native_balance, plan_spend, + tx_receipt, tx_status, validate_btc_address, validate_btc_sender_address, }; use crate::crypto::execution::{PreparedKind, PreparedStatus, TxState}; use crate::crypto::wallet::WalletChain; @@ -61,49 +61,121 @@ fn a_p2tr_address_is_a_valid_recipient_but_not_a_valid_sender() { ); } -// ── UTXO selection ─────────────────────────────────────────────────────── +// ── fee sizing and UTXO selection ──────────────────────────────────────── + +const RATE: u64 = 20; + +#[test] +fn vbytes_follow_the_input_and_output_counts() { + // 10.5 vB of overhead (rounded up), 68 vB per P2WPKH input, 31 vB per output. + assert_eq!(estimated_vbytes(1, 2), 141, "the classic 1-in 2-out size"); + assert_eq!(estimated_vbytes(1, 1), 110); + assert_eq!(estimated_vbytes(3, 2), 277); + assert_eq!(estimated_vbytes(5, 1), 382); +} #[test] fn selection_is_largest_first_and_returns_change() { let utxos = vec![utxo("a", 5000), utxo("b", 10_000), utxo("c", 1_000)]; - let (chosen, change) = select_utxos(&utxos, 6_000, 2_000).unwrap(); - assert_eq!(chosen.len(), 1); - assert_eq!(chosen[0].txid, "b"); - assert_eq!(change, 2_000); + let plan = plan_spend(&utxos, 6_000, RATE).unwrap(); + assert_eq!(plan.selected.len(), 1); + assert_eq!(plan.selected[0].txid, "b"); + assert_eq!(plan.fee_sats, 20 * 141); + assert_eq!(plan.change_sats, 10_000 - 6_000 - 2_820); +} + +#[test] +fn a_single_input_pays_the_classic_fee() { + let plan = plan_spend(&[utxo("a", 100_000)], 50_000, RATE).unwrap(); + assert_eq!(plan.fee_sats, 2_820); + assert_eq!(plan.change_sats, 47_180); +} + +#[test] +fn selecting_several_inputs_pays_a_larger_fee_than_one() { + let utxos: Vec<_> = ["a", "b", "c", "d", "e"] + .into_iter() + .map(|id| utxo(id, 30_000)) + .collect(); + let single = plan_spend(&utxos, 20_000, RATE).unwrap(); + let multi = plan_spend(&utxos, 70_000, RATE).unwrap(); + assert_eq!(single.selected.len(), 1); + assert_eq!(multi.selected.len(), 3); + assert_eq!(multi.fee_sats, RATE * estimated_vbytes(3, 2)); + assert!(multi.fee_sats > single.fee_sats); + // Everything spent is accounted for: amount, fee and change. + assert_eq!(70_000 + multi.fee_sats + multi.change_sats, 90_000); +} + +#[test] +fn selection_adds_an_input_when_the_first_cannot_cover_the_fee_it_needs() { + // 10_000 covers the 9_000 amount but not the 1-in fee on top of it, so a + // second input is pulled in, and the fee is sized for both. + let plan = plan_spend(&[utxo("a", 10_000), utxo("b", 3_000)], 9_000, RATE).unwrap(); + assert_eq!(plan.selected.len(), 2); + assert!(plan.fee_sats >= RATE * estimated_vbytes(2, 1)); + assert_eq!(9_000 + plan.fee_sats + plan.change_sats, 13_000); +} + +#[test] +fn dust_change_is_dropped_and_folded_into_the_fee() { + // The 1-in 2-out fee is 2_820. Leave 500 sats of change: below dust. + let plan = plan_spend(&[utxo("a", 50_000)], 50_000 - 2_820 - 500, RATE).unwrap(); + assert_eq!(plan.change_sats, 0, "no change output for dust"); + assert_eq!(plan.fee_sats, 2_820 + 500, "the dust goes to the miner"); } #[test] -fn selection_combines_outputs_when_one_is_not_enough() { - let utxos = vec![utxo("a", 5000), utxo("b", 5000), utxo("c", 5000)]; - let (chosen, change) = select_utxos(&utxos, 11_000, 1_000).unwrap(); - assert_eq!(chosen.len(), 3); - assert_eq!(change, 3_000); +fn change_is_kept_only_above_the_dust_threshold() { + let at_dust = plan_spend(&[utxo("a", 50_000)], 50_000 - 2_820 - 546, RATE).unwrap(); + assert_eq!(at_dust.change_sats, 0, "546 sats is still dust"); + let above = plan_spend(&[utxo("a", 50_000)], 50_000 - 2_820 - 547, RATE).unwrap(); + assert_eq!(above.change_sats, 547); + assert_eq!(above.fee_sats, 2_820); +} + +#[test] +fn a_change_free_spend_that_only_covers_the_one_output_fee_is_accepted() { + // Total covers amount + the 1-output fee (2_200) but not the 2-output fee. + let plan = plan_spend(&[utxo("a", 50_000)], 50_000 - 2_200, RATE).unwrap(); + assert_eq!(plan.selected.len(), 1); + assert_eq!(plan.change_sats, 0); + assert_eq!(plan.fee_sats, 2_200); } #[test] fn selection_errors_when_funds_are_insufficient() { - let err = select_utxos(&[utxo("a", 1_000)], 5_000, 1_000).unwrap_err(); + let err = plan_spend(&[utxo("a", 1_000)], 5_000, RATE).unwrap_err(); assert_eq!( err, - "insufficient BTC: have 1000 sats, need 6000 (amount 5000 + fee 1000)" + "insufficient BTC: have 1000 sats, need 7200 (amount 5000 + fee 2200)" ); } #[test] -fn selection_reports_overflow_rather_than_wrapping() { +fn the_insufficient_error_prices_every_input_it_would_need_to_spend() { + let err = plan_spend(&[utxo("a", 3_000), utxo("b", 3_000)], 10_000, RATE).unwrap_err(); + // Two inputs, one output: 20 * 178 = 3_560. assert_eq!( - select_utxos(&[], u64::MAX, 1).unwrap_err(), - "amount + fee overflow" + err, + "insufficient BTC: have 6000 sats, need 13560 (amount 10000 + fee 3560)" ); - // Two huge outputs that together exceed u64 before reaching a target of u64::MAX. - let big = u64::MAX - 10; - let err = select_utxos(&[utxo("a", big), utxo("b", big)], u64::MAX - 1, 1).unwrap_err(); - assert_eq!(err, "utxo sum overflow"); } #[test] -fn the_fee_estimate_is_rate_times_typical_size() { - assert_eq!(estimated_btc_fee_sats(), 20 * 141); +fn selection_reports_overflow_rather_than_wrapping() { + assert_eq!( + plan_spend(&[], u64::MAX, RATE).unwrap_err(), + "amount + fee overflow" + ); + assert_eq!( + plan_spend(&[], 1, u64::MAX).unwrap_err(), + "amount + fee overflow" + ); + // Two outputs that together exceed u64 before reaching the target. + let half = 1u64 << 63; + let err = plan_spend(&[utxo("a", half), utxo("b", half)], u64::MAX - 1_000, 1).unwrap_err(); + assert_eq!(err, "utxo sum overflow"); } // ── balance ────────────────────────────────────────────────────────────── @@ -175,6 +247,50 @@ async fn execute_selects_utxos_hands_the_spec_to_the_signer_and_broadcasts() { ))); } +#[tokio::test] +async fn execute_sizes_the_fee_for_every_input_it_spends() { + let rig = Rig::new(); + let utxos: Vec<_> = (0u32..3) + .map(|vout| json!({"txid": TXID, "vout": vout, "value": 30_000u64})) + .collect(); + rig.transport + .on_get(&utxo_path(), &json!(utxos).to_string()); + rig.transport.on_post("tx", TXID); + + let result = execute_btc_quote(&rig.engine, btc_quote("70000")) + .await + .unwrap(); + + // 3 inputs and 2 outputs: 277 vB at 20 sat/vB. + assert_eq!(result.transaction.estimated_fee_raw, "5540"); + match &rig.signer.transactions()[0] { + TransactionSpec::Btc { fee_sat, utxos, .. } => { + assert_eq!(*fee_sat, 5_540); + assert_eq!(utxos.len(), 3); + } + other => panic!("expected a BTC spec, got {other:?}"), + } +} + +#[tokio::test] +async fn execute_hands_the_signer_the_dust_folded_fee() { + let rig = Rig::new(); + rig.transport.on_get( + &utxo_path(), + &json!([{"txid": TXID, "vout": 0, "value": 50_000u64}]).to_string(), + ); + rig.transport.on_post("tx", TXID); + // 50_000 - 47_000 = 3_000 left, 2_820 of it fee and 180 of it dust change. + let result = execute_btc_quote(&rig.engine, btc_quote("47000")) + .await + .unwrap(); + assert_eq!(result.transaction.estimated_fee_raw, "3000"); + match &rig.signer.transactions()[0] { + TransactionSpec::Btc { fee_sat, .. } => assert_eq!(*fee_sat, 3_000), + other => panic!("expected a BTC spec, got {other:?}"), + } +} + #[tokio::test] async fn execute_refuses_when_there_are_no_spendable_utxos() { let rig = Rig::new(); diff --git a/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs b/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs index 04e4f21..c930737 100644 --- a/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs +++ b/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs @@ -22,7 +22,6 @@ use serde::Deserialize; use serde_json::json; use tinywallet_bus::wire::{Scheme, Signature}; -use crate::crypto::defaults::explorer_tx_url; use crate::crypto::execution::{ ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, }; @@ -234,7 +233,7 @@ pub(crate) async fn execute_solana_quote( "{LOG_PREFIX} broadcast quote_id={} sig={tx_sig} kind={:?}", quote.quote_id, quote.kind ); - let explorer_url = explorer_tx_url(WalletChain::Solana, &tx_sig); + let explorer_url = engine.explorer_url(WalletChain::Solana, &tx_sig); Ok(ExecutionResult { quote_id: quote.quote_id.clone(), status: PreparedStatus::Broadcasted, diff --git a/crates/tinywallet-web3/src/crypto/chains/solana/test.rs b/crates/tinywallet-web3/src/crypto/chains/solana/test.rs index 595c3c8..9c1ef31 100644 --- a/crates/tinywallet-web3/src/crypto/chains/solana/test.rs +++ b/crates/tinywallet-web3/src/crypto/chains/solana/test.rs @@ -14,6 +14,7 @@ use super::{ execute_solana_quote, lookup_tx, native_balance, sign_and_broadcast_versioned, tx_receipt, tx_status, validate_solana_address, }; +use crate::crypto::defaults::SolanaCluster; use crate::crypto::execution::{PreparedKind, PreparedStatus, TxState}; use crate::crypto::wallet::WalletChain; use crate::test_support::{FakeSigner, Rig, SignerCall, prepared_quote, sample_address}; @@ -127,6 +128,20 @@ async fn a_native_transfer_is_signed_by_the_wallet_and_broadcast() { ); } +#[tokio::test] +async fn a_devnet_transfer_links_to_the_devnet_explorer() { + let rig = Rig::new(); + rig.endpoints.set_cluster(SolanaCluster::Devnet); + script_node(&rig); + let result = execute_solana_quote(&rig.engine, sol_quote(PreparedKind::NativeTransfer)) + .await + .unwrap(); + assert_eq!( + result.explorer_url.as_deref(), + Some(format!("https://solscan.io/tx/{SIG}?cluster=devnet").as_str()) + ); +} + #[tokio::test] async fn an_spl_transfer_carries_the_token_program_and_checks_the_destination_ata() { let rig = Rig::new(); @@ -353,6 +368,22 @@ async fn a_versioned_transaction_gets_our_signature_in_our_slot() { assert_eq!(&sent[65..], &wire[65..], "the message is untouched"); } +#[tokio::test] +async fn a_devnet_versioned_transaction_links_to_the_devnet_explorer() { + let rig = Rig::new(); + rig.endpoints.set_cluster(SolanaCluster::Devnet); + script_node(&rig); + let signer = b58_to_pubkey(sample_address(WalletChain::Solana)).unwrap(); + let wire = unsigned_legacy(&signer); + let result = sign_and_broadcast_versioned(&rig.engine, &format!("0x{}", hex::encode(&wire))) + .await + .unwrap(); + assert_eq!( + result.explorer_url.as_deref(), + Some(format!("https://solscan.io/tx/{SIG}?cluster=devnet").as_str()) + ); +} + #[tokio::test] async fn a_versioned_transaction_we_are_not_a_signer_of_is_refused() { let rig = Rig::new(); diff --git a/crates/tinywallet-web3/src/crypto/chains/solana/versioned.rs b/crates/tinywallet-web3/src/crypto/chains/solana/versioned.rs index bf7d11b..a82404e 100644 --- a/crates/tinywallet-web3/src/crypto/chains/solana/versioned.rs +++ b/crates/tinywallet-web3/src/crypto/chains/solana/versioned.rs @@ -9,7 +9,6 @@ use log::debug; -use crate::crypto::defaults::explorer_tx_url; use crate::crypto::execution::RawBroadcastResult; use crate::crypto::wallet::{WalletChain, WalletEngine}; @@ -109,7 +108,7 @@ pub(crate) async fn sign_and_broadcast_versioned( debug!("{LOG_PREFIX} sign_and_broadcast_versioned sig={tx_sig}"); Ok(RawBroadcastResult { transaction_hash: tx_sig.clone(), - explorer_url: explorer_tx_url(WalletChain::Solana, &tx_sig), + explorer_url: engine.explorer_url(WalletChain::Solana, &tx_sig), // Solana fees are dynamic (base plus priority) and only known once the // tx is confirmed: leave unset rather than misreporting a free // transfer. diff --git a/crates/tinywallet-web3/src/crypto/chains/tron/mod.rs b/crates/tinywallet-web3/src/crypto/chains/tron/mod.rs index f611416..e4b9f9f 100644 --- a/crates/tinywallet-web3/src/crypto/chains/tron/mod.rs +++ b/crates/tinywallet-web3/src/crypto/chains/tron/mod.rs @@ -16,7 +16,6 @@ use serde_json::{Map, Value, json}; use tinywallet_bus::wire::TransactionSpec; use tinywallet_crypto::TronTransfer; -use crate::crypto::defaults::explorer_tx_url; use crate::crypto::execution::{ ExecutionResult, PreparedKind, PreparedStatus, PreparedTransaction, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, @@ -336,7 +335,7 @@ pub(crate) async fn execute_tron_quote( "{LOG_PREFIX} broadcast quote_id={} txid={txid} kind={:?}", quote.quote_id, quote.kind ); - let explorer_url = explorer_tx_url(WalletChain::Tron, &txid); + let explorer_url = engine.explorer_url(WalletChain::Tron, &txid); Ok(ExecutionResult { quote_id: quote.quote_id.clone(), status: PreparedStatus::Broadcasted, diff --git a/crates/tinywallet-web3/src/crypto/defaults/catalog.rs b/crates/tinywallet-web3/src/crypto/defaults/catalog.rs index a92a9ff..0beb884 100644 --- a/crates/tinywallet-web3/src/crypto/defaults/catalog.rs +++ b/crates/tinywallet-web3/src/crypto/defaults/catalog.rs @@ -28,15 +28,22 @@ pub const fn default_rpc_url(chain: WalletChain, cluster: SolanaCluster) -> &'st } /// The explorer link for a transaction on `chain` (Ethereum mainnet for EVM). +/// +/// `cluster` only matters for Solana, whose link then carries the cluster +/// (`?cluster=devnet`) so it opens on the network the transaction went to. #[must_use] -pub fn explorer_tx_url(chain: WalletChain, tx_hash: &str) -> Option { - let base = match chain { - WalletChain::Evm => EvmNetwork::EthereumMainnet.explorer_tx_base(), - WalletChain::Btc => BLOCKSTREAM_TX_BASE, - WalletChain::Solana => SOLSCAN_TX_BASE, - WalletChain::Tron => TRONSCAN_TX_BASE, +pub fn explorer_tx_url( + chain: WalletChain, + cluster: SolanaCluster, + tx_hash: &str, +) -> Option { + let (base, suffix) = match chain { + WalletChain::Evm => (EvmNetwork::EthereumMainnet.explorer_tx_base(), ""), + WalletChain::Btc => (BLOCKSTREAM_TX_BASE, ""), + WalletChain::Solana => (SOLSCAN_TX_BASE, cluster.explorer_tx_suffix()), + WalletChain::Tron => (TRONSCAN_TX_BASE, ""), }; - Some(format!("{base}{tx_hash}")) + Some(format!("{base}{tx_hash}{suffix}")) } /// The explorer link for a transaction on a specific EVM network. @@ -186,16 +193,22 @@ pub fn network_defaults(endpoints: &dyn RpcEndpoints) -> Vec Vec super::WalletNetworkDefaults { + let endpoints = FakeRpcEndpoints::new(); + endpoints.set_cluster(cluster); + network_defaults(&endpoints) + .into_iter() + .find(|d| d.chain == WalletChain::Solana) + .unwrap() +} + +#[test] +fn mainnet_network_defaults_describe_mainnet_beta() { + let row = solana_row(MAINNET); + assert_eq!(row.network, "solana-mainnet-beta"); + assert_eq!(row.chain_id, None); + assert_eq!(row.explorer_tx_url_base, "https://solscan.io/tx/"); + assert_eq!(row.explorer_tx_url_suffix, None); + let usdc = row.assets.iter().find(|a| a.symbol == "USDC").unwrap(); + assert_eq!(usdc.contract_address.as_deref(), Some(MAINNET.usdc_mint())); + let json = serde_json::to_value(&row).unwrap(); + assert!( + json.get("explorerTxUrlSuffix").is_none(), + "the mainnet row serializes as it always has: {json}" + ); +} + +#[test] +fn devnet_network_defaults_describe_devnet() { + let devnet = SolanaCluster::Devnet; + let row = solana_row(devnet); + assert_eq!(row.network, "solana-devnet"); + assert_eq!(row.explorer_tx_url_base, "https://solscan.io/tx/"); + assert_eq!( + row.explorer_tx_url_suffix.as_deref(), + Some("?cluster=devnet") + ); + let usdc = row.assets.iter().find(|a| a.symbol == "USDC").unwrap(); + assert_eq!(usdc.contract_address.as_deref(), Some(devnet.usdc_mint())); + assert_ne!(usdc.contract_address.as_deref(), Some(MAINNET.usdc_mint())); + // Base plus hash plus suffix is the same link `explorer_tx_url` builds. + assert_eq!( + format!( + "{}sig{}", + row.explorer_tx_url_base, + row.explorer_tx_url_suffix.as_deref().unwrap() + ), + explorer_tx_url(WalletChain::Solana, devnet, "sig").unwrap() + ); + let json = serde_json::to_value(&row).unwrap(); + assert_eq!(json["explorerTxUrlSuffix"], "?cluster=devnet"); +} + +#[test] +fn the_cluster_only_changes_the_solana_row() { + let endpoints = FakeRpcEndpoints::new(); + let main = network_defaults(&endpoints); + endpoints.set_cluster(SolanaCluster::Devnet); + let dev = network_defaults(&endpoints); + for (a, b) in main.iter().zip(&dev) { + if a.chain == WalletChain::Solana { + assert_ne!(a.network, b.network); + } else { + assert_eq!(a.network, b.network); + assert_eq!(a.explorer_tx_url_suffix, None); + assert_eq!(b.explorer_tx_url_suffix, None); + } + } +} diff --git a/crates/tinywallet-web3/src/crypto/defaults/types.rs b/crates/tinywallet-web3/src/crypto/defaults/types.rs index 706cd83..7662b3c 100644 --- a/crates/tinywallet-web3/src/crypto/defaults/types.rs +++ b/crates/tinywallet-web3/src/crypto/defaults/types.rs @@ -12,6 +12,8 @@ const OPTIMISTIC_TX_BASE: &str = "https://optimistic.etherscan.io/tx/"; const POLYGONSCAN_TX_BASE: &str = "https://polygonscan.com/tx/"; const BSCSCAN_TX_BASE: &str = "https://bscscan.com/tx/"; +const SOLSCAN_DEVNET_SUFFIX: &str = "?cluster=devnet"; + const DEFAULT_SOLANA_RPC_URL: &str = "https://api.mainnet-beta.solana.com"; const DEVNET_SOLANA_RPC_URL: &str = "https://api.devnet.solana.com"; @@ -22,9 +24,9 @@ const SOLANA_USDC_MINT_DEVNET: &str = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDn /// The Solana cluster the wallet broadcasts to. /// -/// Drives **both** the default Solana RPC endpoint and the USDC SPL mint, so a -/// devnet x402 payment challenge's on-chain transfer lands on devnet with the -/// devnet mint rather than mainnet. +/// Drives the default Solana RPC endpoint, the USDC SPL mint and the explorer +/// link, so a devnet x402 payment challenge's on-chain transfer lands on devnet +/// with the devnet mint rather than mainnet, and its link opens on devnet. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SolanaCluster { /// Mainnet-beta, the default. @@ -43,6 +45,27 @@ impl SolanaCluster { } } + /// The kebab-case label used in summaries, matching + /// [`EvmNetwork::network_label`]. + #[must_use] + pub const fn network_label(self) -> &'static str { + match self { + Self::Mainnet => "solana-mainnet-beta", + Self::Devnet => "solana-devnet", + } + } + + /// The text that follows a transaction hash in an explorer link, so it + /// opens on this cluster. Empty for mainnet, which is the explorer's + /// default. + #[must_use] + pub const fn explorer_tx_suffix(self) -> &'static str { + match self { + Self::Mainnet => "", + Self::Devnet => SOLSCAN_DEVNET_SUFFIX, + } + } + /// USDC SPL-token mint address for the cluster. #[must_use] pub const fn usdc_mint(self) -> &'static str { @@ -200,6 +223,10 @@ pub struct WalletNetworkDefaults { pub rpc_source: RpcSource, /// Explorer transaction-URL prefix. pub explorer_tx_url_base: String, + /// Text that follows the transaction hash in an explorer link, when the + /// network needs one (Solana devnet's `?cluster=devnet`). + #[serde(skip_serializing_if = "Option::is_none")] + pub explorer_tx_url_suffix: Option, /// Whether the wallet can broadcast here. pub supports_broadcast: bool, /// Whether token transfers are supported. diff --git a/crates/tinywallet-web3/src/crypto/execution/broadcast.rs b/crates/tinywallet-web3/src/crypto/execution/broadcast.rs index b379d75..a7f3ffd 100644 --- a/crates/tinywallet-web3/src/crypto/execution/broadcast.rs +++ b/crates/tinywallet-web3/src/crypto/execution/broadcast.rs @@ -5,7 +5,7 @@ use log::warn; use crate::crypto::chains::{btc, evm, solana, tron}; -use crate::crypto::defaults::{EvmNetwork, explorer_tx_url}; +use crate::crypto::defaults::EvmNetwork; use crate::crypto::wallet::{WalletChain, WalletEngine}; use super::LOG_PREFIX; @@ -91,7 +91,7 @@ impl WalletEngine { } }; if final_result.explorer_url.is_none() { - final_result.explorer_url = explorer_tx_url(chain, &final_result.transaction_hash); + final_result.explorer_url = self.explorer_url(chain, &final_result.transaction_hash); } Ok(final_result) } diff --git a/crates/tinywallet-web3/src/crypto/execution/mod.rs b/crates/tinywallet-web3/src/crypto/execution/mod.rs index 635bd4a..8c6f22a 100644 --- a/crates/tinywallet-web3/src/crypto/execution/mod.rs +++ b/crates/tinywallet-web3/src/crypto/execution/mod.rs @@ -17,6 +17,7 @@ //! - `validate` — address/amount/calldata validation, formatting, hex. //! - `accounts` — resolving a derived account for a chain. //! - `queries` — the read-only surface. +//! - `probe` — the per-chain tip call `chain_status` uses to test an endpoint. //! - `transfer` — preparing a transfer quote. //! - `tx_lookup` — transaction status, receipt and raw lookup. //! - `broadcast` — `execute_prepared` and the raw sign-and-broadcast @@ -24,6 +25,7 @@ mod accounts; mod broadcast; +mod probe; mod queries; mod transfer; mod tx_lookup; diff --git a/crates/tinywallet-web3/src/crypto/execution/probe.rs b/crates/tinywallet-web3/src/crypto/execution/probe.rs new file mode 100644 index 0000000..c7eb988 --- /dev/null +++ b/crates/tinywallet-web3/src/crypto/execution/probe.rs @@ -0,0 +1,76 @@ +//! The cheap per-chain call `chain_status` makes to learn whether an endpoint +//! answers. +//! +//! Each probe asks for the chain tip through the +//! [`Transport`](tinywallet_crypto::rpc::Transport) seam, the smallest read +//! every provider serves, and checks the reply looks like one: an endpoint that +//! answers with something else (a captive portal, a stub) is not ready. + +use log::debug; +use serde_json::{Value, json}; + +use crate::crypto::defaults::EvmNetwork; +use crate::crypto::wallet::{WalletChain, WalletEngine}; + +use super::LOG_PREFIX; +use super::validate::hex_to_u128; + +impl WalletEngine { + /// Ask the endpoint serving `chain` (and, for EVM, `network`) for its tip. + /// + /// EVM calls `eth_blockNumber`, Solana `getHealth`, Bitcoin reads Esplora's + /// `blocks/tip/height` and Tron posts `wallet/getnowblock`. + /// + /// # Errors + /// + /// The transport's message when the endpoint cannot be reached or answers + /// with an error, or a description of the reply when it is not a tip. + pub(super) async fn probe_provider( + &self, + chain: WalletChain, + network: Option, + ) -> Result<(), String> { + let result = match chain { + WalletChain::Evm => { + let network = network.unwrap_or(EvmNetwork::EthereumMainnet); + self.evm_rpc_call::(network, "eth_blockNumber", json!([])) + .await + .and_then(|height| { + hex_to_u128(&height).map(drop).map_err(|e| { + format!("eth_blockNumber did not return a block number: {e}") + }) + }) + } + WalletChain::Solana => self + .rpc_call::(WalletChain::Solana, "getHealth", json!([])) + .await + .map(drop), + WalletChain::Btc => self + .rest_get_text(WalletChain::Btc, "blocks/tip/height") + .await + .and_then(|body| { + body.trim() + .parse::() + .map(drop) + .map_err(|_| "BTC chain tip height was not a number".to_string()) + }), + WalletChain::Tron => self + .rest_post_json::(WalletChain::Tron, "wallet/getnowblock", &json!({})) + .await + .and_then(|block| { + block + .get("blockID") + .and_then(Value::as_str) + .map(drop) + .ok_or_else(|| "Tron getnowblock returned no block".to_string()) + }), + }; + debug!( + "{LOG_PREFIX} probe chain={} network={} ok={}", + chain.as_str(), + network.map_or("-", EvmNetwork::as_str), + result.is_ok() + ); + result + } +} diff --git a/crates/tinywallet-web3/src/crypto/execution/queries.rs b/crates/tinywallet-web3/src/crypto/execution/queries.rs index 34e145c..52a02a3 100644 --- a/crates/tinywallet-web3/src/crypto/execution/queries.rs +++ b/crates/tinywallet-web3/src/crypto/execution/queries.rs @@ -36,14 +36,6 @@ fn asset_to_supported(asset: WalletAssetDefinition) -> SupportedAsset { } } -fn provider_status(has_account: bool) -> ProviderStatus { - if has_account { - ProviderStatus::Ready - } else { - ProviderStatus::Missing - } -} - impl WalletEngine { /// The default row for every supported network, with the endpoints the /// host resolved. @@ -77,39 +69,67 @@ impl WalletEngine { assets } - /// Which chains have an account and a provider. + /// Which chains have an account and a provider that answers. + /// + /// A chain with an account has its endpoint probed (see + /// `WalletEngine::probe_provider`): [`ProviderStatus::Ready`] when it + /// answers, [`ProviderStatus::Missing`] with the failure in + /// [`ChainStatus::error`] when it does not. A chain with no account is + /// `Missing` and is not contacted. /// /// # Errors /// - /// The host's error if the wallet status cannot be read. + /// The host's error if the wallet status cannot be read. An endpoint that + /// fails its probe is reported in its row, not as an error. pub async fn chain_status(&self) -> Result, String> { let status = self.accounts.status().await?; let has = |chain: WalletChain| status.accounts.iter().any(|a| a.chain == chain); let mut rows = Vec::new(); for network in EvmNetwork::ALL { - let has_account = has(WalletChain::Evm); - rows.push(ChainStatus { - chain: WalletChain::Evm, - evm_network: Some(network), - configured: has_account, - provider_status: provider_status(has_account), - rpc_url: self.endpoints.url(WalletChain::Evm, Some(network)), - }); + rows.push( + self.chain_status_row(WalletChain::Evm, Some(network), has(WalletChain::Evm)) + .await, + ); } for chain in [WalletChain::Btc, WalletChain::Solana, WalletChain::Tron] { - let has_account = has(chain); - rows.push(ChainStatus { - chain, - evm_network: None, - configured: has_account, - provider_status: provider_status(has_account), - rpc_url: self.endpoints.url(chain, None), - }); + rows.push(self.chain_status_row(chain, None, has(chain)).await); } debug!("{LOG_PREFIX} chain_status reported chains={}", rows.len()); Ok(rows) } + /// One chain's status row, probing its endpoint when it has an account. + async fn chain_status_row( + &self, + chain: WalletChain, + network: Option, + has_account: bool, + ) -> ChainStatus { + let (provider_status, error) = if has_account { + match self.probe_provider(chain, network).await { + Ok(()) => (ProviderStatus::Ready, None), + Err(error) => { + warn!( + "{LOG_PREFIX} chain_status chain={} network={} probe failed: {error}", + chain.as_str(), + network.map_or("-", EvmNetwork::as_str) + ); + (ProviderStatus::Missing, Some(error)) + } + } + } else { + (ProviderStatus::Missing, None) + }; + ChainStatus { + chain, + evm_network: network, + configured: has_account, + provider_status, + rpc_url: self.endpoints.url(chain, network), + error, + } + } + /// The native-asset definition for a non-EVM chain. fn native_asset_for(&self, chain: WalletChain) -> Result { asset_catalog(chain, self.endpoints.solana_cluster()) diff --git a/crates/tinywallet-web3/src/crypto/execution/test.rs b/crates/tinywallet-web3/src/crypto/execution/test.rs index 1250d7e..e0d2158 100644 --- a/crates/tinywallet-web3/src/crypto/execution/test.rs +++ b/crates/tinywallet-web3/src/crypto/execution/test.rs @@ -5,6 +5,7 @@ use serde_json::{Value, json}; use tinywallet_bus::wire::TransactionSpec; +use tinywallet_crypto::rpc::NetworkId; use super::{ ExecutePreparedParams, PrepareTransferParams, PreparedKind, PreparedStatus, ProviderStatus, @@ -13,7 +14,7 @@ use crate::crypto::defaults::EvmNetwork; use crate::crypto::wallet::WalletChain; use crate::quote::WALLET_NOT_CONFIGURED_MESSAGE; use crate::test_support::{ - FakeWalletAccounts, Rig, SignerCall, configured_status, owner_a, owner_b, prepared_quote, + Call, FakeWalletAccounts, Rig, SignerCall, configured_status, owner_a, owner_b, prepared_quote, sample_address, }; @@ -73,28 +74,191 @@ fn the_supported_solana_usdc_follows_the_cluster() { ); } +/// Script every chain's probe endpoint to answer healthily. +fn script_healthy_chains(rig: &Rig) { + rig.transport + .on_rpc("eth_blockNumber", json!("0x12d687")) + .on_rpc("getHealth", json!("ok")) + .on_get("blocks/tip/height", "850000\n") + .on_post( + "wallet/getnowblock", + &json!({"blockID": "00ab", "block_header": {}}).to_string(), + ); +} + +fn row(rows: &[super::ChainStatus], chain: WalletChain) -> &super::ChainStatus { + rows.iter().find(|r| r.chain == chain).unwrap() +} + #[tokio::test] async fn chain_status_reports_missing_providers_without_accounts() { let rig = Rig::new(); + script_healthy_chains(&rig); let mut status = configured_status(); status.accounts.retain(|a| a.chain != WalletChain::Btc); rig.accounts.set(Ok(status)); let rows = rig.engine.chain_status().await.unwrap(); assert_eq!(rows.len(), EvmNetwork::ALL.len() + 3); - let btc = rows.iter().find(|r| r.chain == WalletChain::Btc).unwrap(); + let btc = row(&rows, WalletChain::Btc); assert!(!btc.configured); assert_eq!(btc.provider_status, ProviderStatus::Missing); + assert_eq!(btc.error, None, "no account is not an endpoint failure"); let base = rows .iter() .find(|r| r.evm_network == Some(EvmNetwork::BaseMainnet)) .unwrap(); assert!(base.configured); assert_eq!(base.rpc_url, "https://rpc.test/base_mainnet"); - let sol = rows + assert_eq!( + row(&rows, WalletChain::Solana).provider_status, + ProviderStatus::Ready + ); + // A chain with no account is not probed. + assert!( + !rig.transport + .calls() + .iter() + .any(|c| matches!(c, Call::RestGet { .. })), + "the BTC endpoint must not be contacted without an account" + ); +} + +#[tokio::test] +async fn chain_status_is_ready_only_once_every_endpoint_answers_its_probe() { + let rig = Rig::new(); + script_healthy_chains(&rig); + let rows = rig.engine.chain_status().await.unwrap(); + assert!( + rows.iter() + .all(|r| r.provider_status == ProviderStatus::Ready) + ); + assert!(rows.iter().all(|r| r.error.is_none()), "{rows:?}"); + + // One cheap call per row, on that row's own network. + let calls = rig.transport.calls(); + for network in EvmNetwork::ALL { + assert!( + calls.iter().any(|c| matches!( + c, + Call::JsonRpc { network: n, method, .. } + if method == "eth_blockNumber" + && *n == NetworkId::evm(network.chain_id()) + )), + "no eth_blockNumber probe for {network:?}" + ); + } + assert!(calls.iter().any(|c| matches!( + c, + Call::JsonRpc { method, .. } if method == "getHealth" + ))); + assert!(calls.iter().any(|c| matches!( + c, + Call::RestGet { path, .. } if path == "blocks/tip/height" + ))); + assert!(calls.iter().any(|c| matches!( + c, + Call::RestPost { path, .. } if path == "wallet/getnowblock" + ))); +} + +#[tokio::test] +async fn chain_status_reports_an_unreachable_endpoint_with_its_error() { + let rig = Rig::new(); + rig.transport + .on_rpc("eth_blockNumber", json!("0x1")) + .on_rpc_error("getHealth", "node is behind by 42 slots") + .on_get_error( + "blocks/tip/height", + "wallet REST GET transport failed: refused", + ) + .on_post_unreachable( + "wallet/getnowblock", + "wallet REST POST transport failed: timeout", + ); + let rows = rig.engine.chain_status().await.unwrap(); + + let unhealthy = [ + (WalletChain::Solana, "node is behind by 42 slots"), + ( + WalletChain::Btc, + "wallet REST GET transport failed: refused", + ), + ( + WalletChain::Tron, + "wallet REST POST transport failed: timeout", + ), + ]; + for (chain, message) in unhealthy { + let r = row(&rows, chain); + assert!(r.configured, "the account is still there"); + assert_eq!(r.provider_status, ProviderStatus::Missing, "{chain:?}"); + assert_eq!(r.error.as_deref(), Some(message), "{chain:?}"); + } + let evm: Vec<_> = rows .iter() - .find(|r| r.chain == WalletChain::Solana) - .unwrap(); - assert_eq!(sol.provider_status, ProviderStatus::Ready); + .filter(|r| r.chain == WalletChain::Evm) + .collect(); + assert!( + evm.iter() + .all(|r| r.provider_status == ProviderStatus::Ready) + ); +} + +#[tokio::test] +async fn chain_status_rejects_an_answer_that_is_not_a_chain_tip() { + // Reachable, but not saying what a tip says: a stub or a captive portal. + let rig = Rig::new(); + rig.transport + .on_rpc("eth_blockNumber", json!({"oops": true})) + .on_rpc("getHealth", json!("ok")) + .on_get("blocks/tip/height", "") + .on_post( + "wallet/getnowblock", + &json!({"Error": "no block"}).to_string(), + ); + let rows = rig.engine.chain_status().await.unwrap(); + + let expected = [ + (WalletChain::Evm, "eth_blockNumber"), + (WalletChain::Btc, "tip height"), + (WalletChain::Tron, "getnowblock"), + ]; + for (chain, needle) in expected { + let r = row(&rows, chain); + assert_eq!(r.provider_status, ProviderStatus::Missing, "{chain:?}"); + assert!(r.error.as_deref().unwrap().contains(needle), "{r:?}"); + } + assert_eq!( + row(&rows, WalletChain::Solana).provider_status, + ProviderStatus::Ready + ); +} + +#[test] +fn a_healthy_chain_status_row_serializes_without_an_error_member() { + let healthy = super::ChainStatus { + chain: WalletChain::Btc, + evm_network: None, + configured: true, + provider_status: ProviderStatus::Ready, + rpc_url: "https://rpc.test/btc".to_string(), + error: None, + }; + assert_eq!( + serde_json::to_value(&healthy).unwrap(), + json!({ + "chain": "btc", "configured": true, "providerStatus": "ready", + "rpcUrl": "https://rpc.test/btc" + }) + ); + let failed = super::ChainStatus { + provider_status: ProviderStatus::Missing, + error: Some("refused".to_string()), + ..healthy + }; + let value = serde_json::to_value(&failed).unwrap(); + assert_eq!(value["providerStatus"], "missing"); + assert_eq!(value["error"], "refused"); } #[tokio::test] diff --git a/crates/tinywallet-web3/src/crypto/execution/types.rs b/crates/tinywallet-web3/src/crypto/execution/types.rs index 8ba29b5..3d345e5 100644 --- a/crates/tinywallet-web3/src/crypto/execution/types.rs +++ b/crates/tinywallet-web3/src/crypto/execution/types.rs @@ -23,15 +23,23 @@ pub struct ChainStatus { pub provider_status: ProviderStatus, /// The endpoint the host resolved. pub rpc_url: String, + /// Why the provider is not ready, when the wallet has an account but the + /// endpoint did not answer a probe. Absent otherwise, so a healthy row + /// serializes exactly as it always has. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, } /// Whether a provider answered. +/// +/// A chain status row is `Ready` only when its endpoint answered a probe. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ProviderStatus { /// Ready. Ready, - /// Missing or unreachable. + /// Missing or unreachable: the wallet has no account for the chain, or its + /// endpoint did not answer (the reason is in the row's `error`). Missing, } diff --git a/crates/tinywallet-web3/src/crypto/wallet/engine.rs b/crates/tinywallet-web3/src/crypto/wallet/engine.rs index 941a576..235a3e3 100644 --- a/crates/tinywallet-web3/src/crypto/wallet/engine.rs +++ b/crates/tinywallet-web3/src/crypto/wallet/engine.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use tinywallet_crypto::rpc::{NetworkId, Transport, TransportError}; -use crate::crypto::defaults::EvmNetwork; +use crate::crypto::defaults::{EvmNetwork, explorer_tx_url}; use crate::crypto::execution::PreparedTransaction; use crate::crypto::seams::{RpcEndpoints, WalletAccounts, WalletSigner}; use crate::quote::QuoteStore; @@ -85,6 +85,12 @@ impl WalletEngine { self.quotes.live() } + /// The explorer link for `tx_hash` on `chain`, on the Solana cluster the + /// host is configured for. + pub(crate) fn explorer_url(&self, chain: WalletChain, tx_hash: &str) -> Option { + explorer_tx_url(chain, self.endpoints.solana_cluster(), tx_hash) + } + /// The [`NetworkId`] a request for `chain` (and, for EVM, `network`) is /// bound for. pub(crate) fn network_id(chain: WalletChain, network: Option) -> NetworkId { diff --git a/crates/tinywallet-x402/Cargo.toml b/crates/tinywallet-x402/Cargo.toml index 634571d..52da430 100644 --- a/crates/tinywallet-x402/Cargo.toml +++ b/crates/tinywallet-x402/Cargo.toml @@ -72,6 +72,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "net", # A loopback HTTP server so the 402 client and the tool are exercised against a # real socket, without network access. axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] } +# `Full` is a `Sync` HTTP body; wrapped with `reqwest::Body::wrap` it is a +# streaming body that cannot be cloned, which the replay tests need. +http-body-util = "0.1" # Scratch directories for the ledger's JSONL file. tempfile = "3" # The root crate's `key` gate derives a known account from a BIP-39 mnemonic, so diff --git a/crates/tinywallet-x402/README.md b/crates/tinywallet-x402/README.md index 64ca20b..aee21a1 100644 --- a/crates/tinywallet-x402/README.md +++ b/crates/tinywallet-x402/README.md @@ -11,7 +11,8 @@ This crate is everything about that flow that is not specific to one host. | --- | --- | --- | --- | | `wire` | `wire` (default) | neutral | Header payload types, CAIP-2 and asset constants, challenge selection. | | `eip712`, `abi` | `eip712`, `abi` | crypto | EIP-712 / EIP-3009 hashing and ERC-20 `transfer` calldata. Pure hashing, no signer. | -| `ledger` | `ledger` | neutral | `PaymentRecord`, `SpendingBudget`, the append-only JSONL `PaymentLedger`, and the process-wide handle (`init_global`, `with_ledger`, `with_ledger_mut`). Imports no chain type. | +| `ledger` | `ledger` | neutral | `PaymentRecord`, `SpendingBudget`, the append-only JSONL `PaymentLedger`, budget `Reservation`s (`reserve`), and the process-wide handle (`init_global`, `with_ledger`, `with_ledger_mut`). Imports no chain type. | +| `thread` | `ledger` | neutral | The `ThreadScope` seam (which conversation thread is running this call) and its `NoThread` default. | | `protocol` | `pay` | neutral | Header codec, `X402Error`, `X402Client`, `handle_402`, `handle_402_and_pay`, and the `PaymentBuilder` and `ProxyPolicy` seams. | | `crypto` | `pay` | crypto | `CryptoPayments` (the `PaymentBuilder` for the crypto rail), EVM EIP-3009 and Solana SPL payment construction, and the `PaymentSigner` seam. | | `tools` | `tools` | neutral | `X402RequestTool`, the `x402_request` agent tool (`tinytools::Tool`). | @@ -22,7 +23,7 @@ signature, so another payment rail can reuse it by implementing ## Seams -The host implements three traits; the crate holds no wallet, endpoint or proxy +The host implements these traits; the crate holds no wallet, endpoint or proxy configuration of its own. - `crypto::PaymentSigner`: `account(chain)` returns an address, and @@ -32,6 +33,50 @@ configuration of its own. - `tinywallet_crypto::rpc::Transport`: reads the Solana blockhash. - `protocol::ProxyPolicy`: applies the host's proxy rules to a `reqwest::ClientBuilder`. +- `thread::ThreadScope` (optional): `current_thread()` names the conversation + thread (chat thread, job) running the tool call. It is synchronous and is + called on the tool's own task, so a host can answer from a task-local. Install + it with `X402RequestTool::with_thread_scope`; the default, `NoThread`, reports + none. + +## Payment safety + +- **Budget reservation.** `handle_402_and_pay` checks the daily and monthly caps + and holds the amount (`ledger::reserve`) in one critical section *before* + signing, so concurrent payments cannot together overspend. The hold is a + `Reservation` on `X402PaymentResult::reservation`: `commit(record)` records the + outcome and ends the hold in one step; dropping it releases the hold. A hold + counts toward the caps like a settled payment and is not persisted. +- **Allowlist.** Only USDC on the networks of `wire::SUPPORTED_USDC` (Solana + mainnet and devnet, Base mainnet and Sepolia, Ethereum mainnet) is payable. An + option outside it is skipped during selection; if nothing else is payable the + payment fails with `UnsupportedNetwork` or `UnsupportedAsset` before anything + is signed. `pay_challenge_header` applies the same check. +- **Replayable bodies.** `X402Client::try_paid_request` clones the request before + sending it. If the body is a stream (`try_clone` is `None`), a 402 is answered + with `NonReplayableBody` before the challenge is read or anything is signed. + The paid retry is that clone plus `PAYMENT-SIGNATURE`. +- **Session and thread ids.** `PaymentRecord.session_id` is always the ledger's + own session (`PaymentLedger::session_id()`), so `session_total` counts every + payment the process made, tool payments included. `PaymentRecord.thread_id` + (`Option`, omitted from the file when absent, so older ledgers still load) is + the host's `ThreadScope::current_thread()`, for attribution. + +## Changes in 0.6.1 / 0.7.0 (API) + +- `X402PaymentResult` gains `reservation` and is no longer `Clone`, `PartialEq` or + `Eq`. +- `X402Error` gains `NonReplayableBody`, `UnsupportedNetwork` and + `UnsupportedAsset` (the enum is `non_exhaustive`). +- New: `ledger::{reserve, Reservation, ReservationId, BudgetRefusal}`, + `PaymentLedger::{reserve, release, commit_reservation, reserved_atomic, + session_id}`; `check_budget` now counts held reservations. `PaymentRecord` + gains `thread_id: Option` (serde default; struct literals need it). +- New: `wire::{check_usdc, AssetCheck, SUPPORTED_USDC}`; `thread::{ThreadScope, + NoThread}`; `X402RequestTool::with_thread_scope`. +- `handle_402` and `X402Client` no longer select a requirement outside the + allowlist. `PaymentRequired::best_exact_requirement` and its siblings still do + not consult it. ## Constraints diff --git a/crates/tinywallet-x402/src/ledger/mod.rs b/crates/tinywallet-x402/src/ledger/mod.rs index b18c682..0029f00 100644 --- a/crates/tinywallet-x402/src/ledger/mod.rs +++ b/crates/tinywallet-x402/src/ledger/mod.rs @@ -16,8 +16,18 @@ //! # Budgets //! //! [`PaymentLedger::check_budget`] enforces a per-request cap and daily and -//! monthly totals over *settled* payments only, so a failed or denied attempt -//! does not eat the budget. +//! monthly totals over *settled* payments plus outstanding reservations, so a +//! failed or denied attempt does not eat the budget. +//! +//! # Reservations +//! +//! A payment is checked, signed, sent and only then recorded, and other payments +//! run in between. Checking alone would let two concurrent payments that each fit +//! together overspend. [`reserve`] (or [`PaymentLedger::reserve`]) therefore +//! checks and holds the amount in one critical section, before anything is +//! signed. The hold is a [`Reservation`]: dropped, it releases; committed with +//! [`Reservation::commit`], it turns into the recorded payment atomically. +//! Holds live in memory only. //! //! # The process-wide handle //! @@ -26,14 +36,19 @@ //! this crate. mod global; +mod reservation; mod store; mod types; #[cfg(test)] pub(crate) use global::{TEST_LOCK, reset_global}; pub use global::{init_global, with_ledger, with_ledger_mut}; +pub use reservation::{Reservation, reserve}; pub use store::PaymentLedger; -pub use types::{BudgetCheck, PaymentRecord, PaymentStatus, SpendingBudget, SpendingSummary}; +pub use types::{ + BudgetCheck, BudgetRefusal, PaymentRecord, PaymentStatus, ReservationId, SpendingBudget, + SpendingSummary, +}; #[cfg(test)] mod test; diff --git a/crates/tinywallet-x402/src/ledger/reservation.rs b/crates/tinywallet-x402/src/ledger/reservation.rs new file mode 100644 index 0000000..d22f97e --- /dev/null +++ b/crates/tinywallet-x402/src/ledger/reservation.rs @@ -0,0 +1,66 @@ +//! [`Reservation`]: a hold on the process-wide ledger's budget that cannot leak. + +use log::debug; + +use super::global::with_ledger_mut; +use super::types::{BudgetRefusal, PaymentRecord, ReservationId}; + +const LOG_PREFIX: &str = "[x402::store]"; + +/// Budget held for one payment between the budget check and the moment its +/// outcome is recorded. +/// +/// Dropping a `Reservation` releases the hold, so an error, an early return or a +/// cancelled future cannot leave part of the budget locked. To keep the amount +/// counted, [`commit`](Self::commit) the outcome instead: the record and the +/// release happen under one lock, so the amount is never missing from the +/// totals and never counted twice. +#[derive(Debug)] +pub struct Reservation { + id: Option, + amount: u64, +} + +impl Reservation { + /// The amount held, in atomic units. + #[must_use] + pub fn amount(&self) -> u64 { + self.amount + } + + /// Record the payment's outcome and end the hold in one critical section. + pub fn commit(mut self, record: PaymentRecord) { + if let Some(id) = self.id.take() { + if with_ledger_mut(|l| l.commit_reservation(id, record)).is_err() { + debug!("{LOG_PREFIX} commit skipped: ledger gone"); + } + } + } + + /// End the hold without recording anything. The same as dropping it. + pub fn release(self) {} +} + +impl Drop for Reservation { + fn drop(&mut self) { + if let Some(id) = self.id.take() { + // A ledger that was replaced or never set up holds nothing to free. + let _ = with_ledger_mut(|l| l.release(id)); + } + } +} + +/// Check `amount` against the process-wide ledger's budget and hold it, atomically. +/// +/// # Errors +/// +/// The outer error is `"x402 payment ledger not initialized"`; the inner one is +/// the [`BudgetRefusal`] that stopped the amount. +pub fn reserve(amount: u64) -> Result, String> { + with_ledger_mut(|l| { + l.reserve(amount).map(|id| Reservation { + id: Some(id), + amount, + }) + }) +} diff --git a/crates/tinywallet-x402/src/ledger/store.rs b/crates/tinywallet-x402/src/ledger/store.rs index b6b0545..f8afd16 100644 --- a/crates/tinywallet-x402/src/ledger/store.rs +++ b/crates/tinywallet-x402/src/ledger/store.rs @@ -1,16 +1,26 @@ //! The ledger itself: an in-memory record list backed by a JSONL file. +use std::collections::HashMap; use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use chrono::{DateTime, Datelike, Utc}; use log::{debug, warn}; -use super::types::{BudgetCheck, PaymentRecord, PaymentStatus, SpendingBudget, SpendingSummary}; +use super::types::{ + BudgetCheck, BudgetRefusal, PaymentRecord, PaymentStatus, ReservationId, SpendingBudget, + SpendingSummary, +}; const LOG_PREFIX: &str = "[x402::store]"; +/// Source of [`ReservationId`]s. Process-wide rather than per ledger, so a hold +/// taken on a ledger that was later replaced can never release a hold on its +/// successor. +static NEXT_RESERVATION: AtomicU64 = AtomicU64::new(1); + /// Append-only payment history with budget enforcement. #[derive(Debug)] pub struct PaymentLedger { @@ -18,6 +28,9 @@ pub struct PaymentLedger { file_path: PathBuf, budget: SpendingBudget, session_id: String, + /// Amounts held for payments that are signed but not yet recorded. Memory + /// only: a hold that outlives its process protects nothing. + reservations: HashMap, } impl PaymentLedger { @@ -39,10 +52,21 @@ impl PaymentLedger { file_path, budget, session_id: session_id.to_string(), + reservations: HashMap::new(), } } + /// The session this ledger was opened for: the one + /// [`SpendingSummary::session_total_atomic`] counts. + #[must_use] + pub fn session_id(&self) -> &str { + &self.session_id + } + /// Check `amount` against the per-request, daily and monthly limits. + /// + /// Settled payments and outstanding [reservations](Self::reserve) both count + /// toward the daily and monthly totals. #[must_use] pub fn check_budget(&self, amount: u64) -> BudgetCheck { self.check_budget_at(Utc::now(), amount) @@ -60,13 +84,15 @@ impl PaymentLedger { let today = now.date_naive(); let this_month = (now.year(), now.month()); + let reserved = self.reserved_atomic(); let daily: u64 = self .records .iter() .filter(|r| r.status == PaymentStatus::Settled && r.timestamp.date_naive() == today) .map(|r| r.amount_atomic) - .sum(); + .sum::() + .saturating_add(reserved); if daily.saturating_add(amount) > self.budget.daily_max_atomic { return BudgetCheck::ExceedsDailyBudget { current: daily, @@ -82,7 +108,8 @@ impl PaymentLedger { && (r.timestamp.year(), r.timestamp.month()) == this_month }) .map(|r| r.amount_atomic) - .sum(); + .sum::() + .saturating_add(reserved); if monthly.saturating_add(amount) > self.budget.monthly_max_atomic { return BudgetCheck::ExceedsMonthlyBudget { current: monthly, @@ -93,6 +120,60 @@ impl PaymentLedger { BudgetCheck::Allowed } + /// Check `amount` against the limits and, if it fits, hold it, all under + /// the caller's exclusive borrow. + /// + /// This is the atomic form of [`check_budget`](Self::check_budget) followed + /// by signing: two payments that each fit alone cannot both be admitted when + /// together they exceed a cap, because the second check sees the first hold. + /// The hold ends with [`release`](Self::release) or + /// [`commit_reservation`](Self::commit_reservation). + /// + /// # Errors + /// + /// The [`BudgetRefusal`] that stopped `amount`; nothing is held then. + pub fn reserve(&mut self, amount: u64) -> Result { + self.reserve_at(Utc::now(), amount) + } + + /// [`reserve`](Self::reserve) as of `now`. + pub(crate) fn reserve_at( + &mut self, + now: DateTime, + amount: u64, + ) -> Result { + if let Some(refusal) = self.check_budget_at(now, amount).refusal() { + return Err(refusal); + } + let id = ReservationId(NEXT_RESERVATION.fetch_add(1, Ordering::Relaxed)); + self.reservations.insert(id, amount); + debug!("{LOG_PREFIX} reserved {amount} atomic as {id:?}"); + Ok(id) + } + + /// Drop a hold without recording a payment. Releasing a hold that is + /// already gone does nothing. + pub fn release(&mut self, id: ReservationId) { + if self.reservations.remove(&id).is_some() { + debug!("{LOG_PREFIX} released {id:?}"); + } + } + + /// Record `record` and drop the hold `id` in one step, so the amount counts + /// exactly once: as held before, as `record` after. + pub fn commit_reservation(&mut self, id: ReservationId, record: PaymentRecord) { + self.record_payment(record); + self.release(id); + } + + /// The total currently held by reservations, in atomic units. + #[must_use] + pub fn reserved_atomic(&self) -> u64 { + self.reservations + .values() + .fold(0, |total, amount| total.saturating_add(*amount)) + } + /// Append `record` to the file and to memory. /// /// A write failure is logged and the record is still kept in memory: the diff --git a/crates/tinywallet-x402/src/ledger/test.rs b/crates/tinywallet-x402/src/ledger/test.rs index f5481c5..b517eda 100644 --- a/crates/tinywallet-x402/src/ledger/test.rs +++ b/crates/tinywallet-x402/src/ledger/test.rs @@ -41,6 +41,7 @@ fn record( status, timestamp, session_id: session.into(), + thread_id: None, } } @@ -330,3 +331,226 @@ fn a_poisoned_global_lock_is_recovered() { assert!(with_ledger(|l| l.recent_payments(1).len()).is_ok()); reset_global(); } + +// --------------------------------------------------------------------------- +// Reservations: check and hold in one critical section +// --------------------------------------------------------------------------- + +#[test] +fn a_reservation_counts_against_the_daily_budget_until_released() { + let dir = tempfile::tempdir().unwrap(); + let mut ledger = ledger_in(&dir); + // The day allows 2_000_000: four holds of 500_000 fit, a fifth does not. + let ids: Vec<_> = (0..4) + .map(|_| ledger.reserve_at(now(), 500_000).unwrap()) + .collect(); + assert_eq!(ledger.reserved_atomic(), 2_000_000); + assert_eq!( + ledger.reserve_at(now(), 1).unwrap_err(), + BudgetRefusal::Daily { + current: 2_000_000, + cap: 2_000_000 + } + ); + + ledger.release(ids[0]); + assert_eq!(ledger.reserved_atomic(), 1_500_000); + assert!(ledger.reserve_at(now(), 500_000).is_ok()); +} + +#[test] +fn a_reservation_counts_against_the_monthly_budget() { + let dir = tempfile::tempdir().unwrap(); + let mut ledger = PaymentLedger::new( + dir.path(), + SESSION, + SpendingBudget { + per_request_max_atomic: 500_000, + daily_max_atomic: 10_000_000, + monthly_max_atomic: 600_000, + }, + ); + ledger.reserve_at(now(), 500_000).unwrap(); + assert_eq!( + ledger.reserve_at(now(), 500_000).unwrap_err(), + BudgetRefusal::Monthly { + current: 500_000, + cap: 600_000 + } + ); +} + +#[test] +fn a_refused_reservation_holds_nothing() { + let dir = tempfile::tempdir().unwrap(); + let mut ledger = ledger_in(&dir); + assert_eq!( + ledger.reserve_at(now(), 600_000).unwrap_err(), + BudgetRefusal::PerRequest { + requested: 600_000, + cap: 500_000 + } + ); + assert_eq!(ledger.reserved_atomic(), 0); +} + +#[test] +fn releasing_an_unknown_reservation_is_harmless() { + let dir = tempfile::tempdir().unwrap(); + let mut ledger = ledger_in(&dir); + let id = ledger.reserve_at(now(), 1_000).unwrap(); + ledger.release(id); + ledger.release(id); + assert_eq!(ledger.reserved_atomic(), 0); +} + +#[test] +fn committing_a_reservation_records_the_payment_and_frees_the_hold() { + let dir = tempfile::tempdir().unwrap(); + let mut ledger = ledger_in(&dir); + let id = ledger.reserve(400_000).unwrap(); + + ledger.commit_reservation( + id, + record(400_000, PaymentStatus::Settled, Utc::now(), SESSION), + ); + + assert_eq!(ledger.reserved_atomic(), 0); + let summary = ledger.summary(); + assert_eq!( + summary.daily_total_atomic, 400_000, + "counted once, as settled" + ); + assert_eq!(ledger.recent_payments(5).len(), 1); +} + +#[test] +fn the_ledger_reports_its_own_session_id() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(ledger_in(&dir).session_id(), SESSION); +} + +#[test] +fn a_global_reservation_is_released_when_dropped() { + let _guard = TEST_LOCK.blocking_lock(); + let dir = tempfile::tempdir().unwrap(); + init_global(dir.path(), SESSION, budget()); + + let held = reserve(500_000).unwrap().unwrap(); + assert_eq!(held.amount(), 500_000); + assert_eq!( + with_ledger(PaymentLedger::reserved_atomic).unwrap(), + 500_000 + ); + drop(held); + assert_eq!(with_ledger(PaymentLedger::reserved_atomic).unwrap(), 0); + reset_global(); +} + +#[test] +fn a_global_reservation_can_be_released_explicitly() { + let _guard = TEST_LOCK.blocking_lock(); + let dir = tempfile::tempdir().unwrap(); + init_global(dir.path(), SESSION, budget()); + + let held = reserve(500_000).unwrap().unwrap(); + held.release(); + assert_eq!(with_ledger(PaymentLedger::reserved_atomic).unwrap(), 0); + reset_global(); +} + +#[test] +fn a_global_reservation_committed_is_recorded_once() { + let _guard = TEST_LOCK.blocking_lock(); + let dir = tempfile::tempdir().unwrap(); + init_global(dir.path(), SESSION, budget()); + + let held = reserve(500_000).unwrap().unwrap(); + held.commit(record(500_000, PaymentStatus::Settled, Utc::now(), SESSION)); + + assert_eq!(with_ledger(PaymentLedger::reserved_atomic).unwrap(), 0); + assert_eq!( + with_ledger(|l| l.summary().daily_total_atomic).unwrap(), + 500_000 + ); + reset_global(); +} + +#[test] +fn a_global_reservation_is_refused_over_budget_and_needs_a_ledger() { + let _guard = TEST_LOCK.blocking_lock(); + reset_global(); + assert_eq!( + reserve(1).unwrap_err(), + "x402 payment ledger not initialized" + ); + + let dir = tempfile::tempdir().unwrap(); + init_global(dir.path(), SESSION, budget()); + assert_eq!( + reserve(600_000).unwrap().unwrap_err(), + BudgetRefusal::PerRequest { + requested: 600_000, + cap: 500_000 + } + ); + reset_global(); +} + +#[test] +fn dropping_a_reservation_after_the_ledger_is_gone_is_harmless() { + let _guard = TEST_LOCK.blocking_lock(); + let dir = tempfile::tempdir().unwrap(); + init_global(dir.path(), SESSION, budget()); + let held = reserve(500_000).unwrap().unwrap(); + reset_global(); + drop(held); + + // A new ledger never inherits an old hold, even if ids were reused. + init_global(dir.path(), SESSION, budget()); + assert_eq!(with_ledger(PaymentLedger::reserved_atomic).unwrap(), 0); + reset_global(); +} + +#[test] +fn a_verdict_converts_to_its_refusal() { + assert_eq!(BudgetCheck::Allowed.refusal(), None); + assert_eq!( + BudgetCheck::ExceedsPerRequest { + requested: 2, + cap: 1 + } + .refusal(), + Some(BudgetRefusal::PerRequest { + requested: 2, + cap: 1 + }) + ); +} + +#[test] +fn a_line_written_before_threads_existed_still_loads() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("x402"); + fs::create_dir_all(&path).unwrap(); + let old = r#"{"id":"old","url":"https://x","asset":"USDC","amountAtomic":5,"amountDisplay":"5","recipient":"r","network":"n","txSignature":null,"status":"settled","timestamp":"2026-03-15T12:00:00Z","sessionId":"session-a"}"#; + fs::write(path.join("payments.jsonl"), format!("{old}\n")).unwrap(); + + let ledger = ledger_in(&dir); + + let records = ledger.recent_payments(5); + assert_eq!(records.len(), 1); + assert_eq!(records[0].thread_id, None); + assert_eq!(records[0].session_id, "session-a"); +} + +#[test] +fn a_thread_is_written_only_when_there_is_one() { + let mut with = record(1, PaymentStatus::Settled, now(), SESSION); + assert!(!serde_json::to_string(&with).unwrap().contains("threadId")); + with.thread_id = Some("thread-1".into()); + let json = serde_json::to_value(&with).unwrap(); + assert_eq!(json["threadId"], "thread-1"); + let back: PaymentRecord = serde_json::from_value(json).unwrap(); + assert_eq!(back.thread_id.as_deref(), Some("thread-1")); +} diff --git a/crates/tinywallet-x402/src/ledger/types.rs b/crates/tinywallet-x402/src/ledger/types.rs index 6587ffb..ccde1e2 100644 --- a/crates/tinywallet-x402/src/ledger/types.rs +++ b/crates/tinywallet-x402/src/ledger/types.rs @@ -27,8 +27,13 @@ pub struct PaymentRecord { pub status: PaymentStatus, /// When this line was written. pub timestamp: DateTime, - /// The session that made the payment. + /// The ledger session that made the payment: the process that opened the + /// ledger. [`SpendingSummary::session_total_atomic`] counts by this. pub session_id: String, + /// The conversation thread that asked for the payment, when the host + /// reported one. Absent from older ledger files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_id: Option, } /// Where a payment attempt stands. @@ -115,3 +120,56 @@ pub enum BudgetCheck { cap: u64, }, } + +/// A hold on part of the budget, taken by +/// [`PaymentLedger::reserve`](super::PaymentLedger::reserve). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ReservationId(pub(super) u64); + +/// Why [`PaymentLedger::reserve`](super::PaymentLedger::reserve) refused: the +/// refusing cases of [`BudgetCheck`], for callers that have no use for +/// "allowed". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetRefusal { + /// The request alone is over the per-request cap. + PerRequest { + /// The amount asked for. + requested: u64, + /// The cap it exceeded. + cap: u64, + }, + /// Held and settled today plus this request is over the daily cap. + Daily { + /// Settled and held so far today. + current: u64, + /// The daily cap. + cap: u64, + }, + /// Held and settled this month plus this request is over the monthly cap. + Monthly { + /// Settled and held so far this month. + current: u64, + /// The monthly cap. + cap: u64, + }, +} + +impl BudgetCheck { + /// The refusal this verdict amounts to, or `None` when it is + /// [`Allowed`](Self::Allowed). + #[must_use] + pub fn refusal(self) -> Option { + match self { + Self::Allowed => None, + Self::ExceedsPerRequest { requested, cap } => { + Some(BudgetRefusal::PerRequest { requested, cap }) + } + Self::ExceedsDailyBudget { current, cap } => { + Some(BudgetRefusal::Daily { current, cap }) + } + Self::ExceedsMonthlyBudget { current, cap } => { + Some(BudgetRefusal::Monthly { current, cap }) + } + } + } +} diff --git a/crates/tinywallet-x402/src/lib.rs b/crates/tinywallet-x402/src/lib.rs index 1bae0c3..96fd843 100644 --- a/crates/tinywallet-x402/src/lib.rs +++ b/crates/tinywallet-x402/src/lib.rs @@ -7,6 +7,8 @@ //! x402 signs. Nothing here signs; it returns the bytes to sign. //! - [`abi`] — ERC-20 `transfer` calldata. //! - [`ledger`] — the append-only spending ledger and its budgets. +//! - [`thread`] — the [`ThreadScope`](thread::ThreadScope) seam that tells +//! the ledger which thread a payment belongs to. //! - [`protocol`] — reading a 402 challenge, the client that pays and retries, //! and the [`ProxyPolicy`](protocol::ProxyPolicy) seam. //! - [`crypto`] — building the on-chain payment (EVM EIP-3009, Solana SPL) @@ -37,7 +39,7 @@ //! | `wire` | on | the x402 header payload types ([`wire`]) | //! | `eip712` | off | EIP-712 hashing ([`eip712`]) | //! | `abi` | off | ERC-20 `transfer` calldata ([`abi`]) | -//! | `ledger` | off | the spending ledger ([`ledger`]) | +//! | `ledger` | off | the spending ledger ([`ledger`]) and the thread seam ([`thread`]) | //! | `pay` | off | the 402 client and the payment builders ([`protocol`], [`crypto`]) | //! | `tools` | off | the `x402_request` tool ([`tools`]) | @@ -51,6 +53,8 @@ pub mod eip712; pub mod ledger; #[cfg(feature = "pay")] pub mod protocol; +#[cfg(feature = "ledger")] +pub mod thread; #[cfg(feature = "tools")] pub mod tools; #[cfg(feature = "wire")] diff --git a/crates/tinywallet-x402/src/protocol/client.rs b/crates/tinywallet-x402/src/protocol/client.rs index 22111c8..ee1a4c7 100644 --- a/crates/tinywallet-x402/src/protocol/client.rs +++ b/crates/tinywallet-x402/src/protocol/client.rs @@ -2,14 +2,15 @@ //! `handle_402*` entry points the HTTP tool layer drives directly. use log::{debug, warn}; -use reqwest::header::HeaderMap; +use reqwest::header::{HeaderMap, HeaderValue}; use std::sync::Arc; use super::LOG_PREFIX; use super::builder::PaymentBuilder; use super::error::X402Error; use super::headers::{encode_payment, parse_402_headers, parse_settlement_response}; -use crate::ledger::{self, BudgetCheck}; +use super::select::{ensure_payable, select_requirement}; +use crate::ledger::{self, BudgetRefusal, Reservation}; use crate::wire::{ HEADER_PAYMENT_RESPONSE, HEADER_PAYMENT_SIGNATURE, PaymentChain, PaymentRequired, PaymentRequirements, @@ -52,13 +53,12 @@ impl X402Client { request: reqwest::Request, max_amount: Option, ) -> Result { + // The paid retry is a second copy of the request. A streaming body cannot + // be copied, and the copy is taken before the first send consumes the + // request, so the answer is known up front. + let replay = request.try_clone(); let method = request.method().clone(); let url = request.url().clone(); - let headers = request.headers().clone(); - let body_bytes = request - .body() - .and_then(|b| b.as_bytes()) - .map(<[u8]>::to_vec); debug!("{LOG_PREFIX} initial request {method} {url}"); let response = self @@ -71,6 +71,13 @@ impl X402Client { return Ok(response); } + // Refuse before reading the challenge, let alone signing: paying for a + // request that cannot be replayed would spend money for nothing. + let Some(mut retry_req) = replay else { + warn!("{LOG_PREFIX} 402 for a request with a streaming body; refusing to pay {url}"); + return Err(X402Error::NonReplayableBody); + }; + let challenge = parse_402_headers(response.headers())?; debug!( "{LOG_PREFIX} got 402 challenge version={} accepts={}", @@ -78,9 +85,8 @@ impl X402Client { challenge.accepts.len() ); - let (requirement, chain) = challenge - .best_exact_requirement() - .ok_or(X402Error::NoPaymentOption)?; + let (index, chain) = select_requirement(&challenge)?; + let requirement = &challenge.accepts[index]; let amount = parse_amount(requirement)?; if let Some(cap) = max_amount { @@ -104,17 +110,19 @@ impl X402Client { let payment = self.builder.build(&challenge, requirement, chain).await?; let encoded = encode_payment(&payment)?; - let mut retry_req = self.http.request(method, url); - for (key, value) in &headers { - retry_req = retry_req.header(key, value); - } - retry_req = retry_req.header(HEADER_PAYMENT_SIGNATURE, &encoded); - if let Some(body) = body_bytes { - retry_req = retry_req.body(body); - } + let proof = HeaderValue::from_str(&encoded).map_err(|e| { + X402Error::Protocol(format!("payment proof is not a valid header: {e}")) + })?; + retry_req + .headers_mut() + .insert(HEADER_PAYMENT_SIGNATURE, proof); debug!("{LOG_PREFIX} retrying with payment proof"); - let paid_response = retry_req.send().await.map_err(X402Error::Transport)?; + let paid_response = self + .http + .execute(retry_req) + .await + .map_err(X402Error::Transport)?; if let Some(receipt_header) = paid_response.headers().get(HEADER_PAYMENT_RESPONSE) { match parse_settlement_response(receipt_header.to_str().unwrap_or("")) { @@ -137,31 +145,20 @@ impl X402Client { /// Parse a 402 response's headers and return the challenge with the index of the /// best payment option and its chain family. /// -/// Solana is preferred (lower fees, faster finality); EVM is the fallback. +/// Solana is preferred (lower fees, faster finality); EVM is the fallback. Only +/// USDC on the networks in [`crate::wire::SUPPORTED_USDC`] is payable. /// /// # Errors /// /// [`X402Error::NoPaymentHeader`] / [`X402Error::Protocol`] from reading the -/// header, and [`X402Error::NoPaymentOption`] when nothing in it is payable. +/// header, [`X402Error::NoPaymentOption`] when it offers no `exact` option on a +/// Solana or EVM network, and [`X402Error::UnsupportedNetwork`] / +/// [`X402Error::UnsupportedAsset`] when none of those is payable. pub fn handle_402( headers: &HeaderMap, ) -> Result<(PaymentRequired, usize, PaymentChain), X402Error> { let challenge = parse_402_headers(headers)?; - let (idx, chain) = challenge - .accepts - .iter() - .enumerate() - .find(|(_, r)| r.scheme == "exact" && r.network.starts_with("solana:")) - .map(|(i, _)| (i, PaymentChain::Solana)) - .or_else(|| { - challenge - .accepts - .iter() - .enumerate() - .find(|(_, r)| r.scheme == "exact" && r.network.starts_with("eip155:")) - .map(|(i, _)| (i, PaymentChain::Evm)) - }) - .ok_or(X402Error::NoPaymentOption)?; + let (idx, chain) = select_requirement(&challenge)?; Ok((challenge, idx, chain)) } @@ -179,6 +176,7 @@ pub async fn pay_challenge_header( challenge: &PaymentRequired, requirement: &PaymentRequirements, ) -> Result { + ensure_payable(requirement)?; let chain = if requirement.network.starts_with("eip155:") { PaymentChain::Evm } else { @@ -190,7 +188,10 @@ pub async fn pay_challenge_header( /// Result of a successful x402 payment: the header value to attach and the /// metadata for the ledger. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// Not `Clone`: it owns the [`Reservation`] that keeps the payment's amount +/// counted against the budget. +#[derive(Debug)] pub struct X402PaymentResult { /// The `PAYMENT-SIGNATURE` header value. pub header_value: String, @@ -204,19 +205,27 @@ pub struct X402PaymentResult { pub network: String, /// The URL the payment is for. pub url: String, + /// The budget held for this payment. Keep it alive until the outcome is + /// recorded, then [`commit`](Reservation::commit) it with the final record; + /// dropping it releases the hold without recording anything. + pub reservation: Reservation, } /// End-to-end 402 handler for the HTTP tool layer. Given a 402 response's /// headers and the original URL: /// /// 1. parses the `PAYMENT-REQUIRED` challenge, -/// 2. checks the spending budget in the process-wide ledger, +/// 2. checks the spending budget in the process-wide ledger and reserves the +/// amount in the same critical section, so concurrent payments cannot +/// overspend, /// 3. has `builder` construct and sign the payment (Solana preferred, EVM /// fallback), and /// 4. returns the encoded `PAYMENT-SIGNATURE` header value. /// /// The caller retries the original request with this header attached and -/// records the payment outcome in the ledger. +/// records the payment outcome in the ledger, committing the +/// [`X402PaymentResult::reservation`] with that record. If signing fails the +/// reservation is released before this returns. /// /// # Errors /// @@ -232,26 +241,29 @@ pub async fn handle_402_and_pay( let requirement = &challenge.accepts[idx]; let amount = parse_amount(requirement)?; - match ledger::with_ledger(|l| l.check_budget(amount)).map_err(X402Error::Wallet)? { - BudgetCheck::Allowed => {} - BudgetCheck::ExceedsPerRequest { requested, cap } => { + // Check the budget and hold the amount in one critical section, before + // anything is signed: a check on its own would let concurrent payments that + // each fit overspend together. + let reservation = match ledger::reserve(amount).map_err(X402Error::Wallet)? { + Ok(reservation) => reservation, + Err(BudgetRefusal::PerRequest { requested, cap }) => { return Err(X402Error::AmountExceedsCap { requested, cap }); } - BudgetCheck::ExceedsDailyBudget { current, cap } => { + Err(BudgetRefusal::Daily { current, cap }) => { return Err(X402Error::BudgetExceeded { period: "daily", current, cap, }); } - BudgetCheck::ExceedsMonthlyBudget { current, cap } => { + Err(BudgetRefusal::Monthly { current, cap }) => { return Err(X402Error::BudgetExceeded { period: "monthly", current, cap, }); } - } + }; debug!( "{LOG_PREFIX} paying {} atomic {} to {} for {} chain={:?}", @@ -268,6 +280,7 @@ pub async fn handle_402_and_pay( recipient: requirement.pay_to.clone(), network: requirement.network.clone(), url: request_url.to_string(), + reservation, }) } diff --git a/crates/tinywallet-x402/src/protocol/error.rs b/crates/tinywallet-x402/src/protocol/error.rs index 33ffa52..12ed8bb 100644 --- a/crates/tinywallet-x402/src/protocol/error.rs +++ b/crates/tinywallet-x402/src/protocol/error.rs @@ -34,6 +34,25 @@ pub enum X402Error { /// The period's cap. cap: u64, }, + /// The challenge names a network this crate does not pay on. + #[error( + "x402 network {network} is not supported; payments are accepted only in USDC on known networks" + )] + UnsupportedNetwork { + /// The CAIP-2 network the challenge asked for. + network: String, + }, + /// The challenge asks for an asset other than the network's USDC. + #[error("x402 asset {asset} is not the USDC accepted on {network}")] + UnsupportedAsset { + /// The CAIP-2 network the challenge asked for. + network: String, + /// The mint or contract it asked to be paid in. + asset: String, + }, + /// The request body is a stream, so it cannot be sent again with the payment. + #[error("x402 request body cannot be replayed for the paid retry; use a buffered body")] + NonReplayableBody, /// The challenge or a header could not be understood. #[error("x402 protocol: {0}")] Protocol(String), diff --git a/crates/tinywallet-x402/src/protocol/mod.rs b/crates/tinywallet-x402/src/protocol/mod.rs index 9ab14b1..52315bc 100644 --- a/crates/tinywallet-x402/src/protocol/mod.rs +++ b/crates/tinywallet-x402/src/protocol/mod.rs @@ -11,6 +11,7 @@ //! - `headers` — parsing the challenge and settlement headers and encoding the //! proof. //! - `builder` — the [`PaymentBuilder`] seam. +//! - `select` — picking the requirement to pay, and the network/asset allowlist. //! - `proxy` — the [`ProxyPolicy`] seam for outbound HTTP. //! - `client` — [`X402Client`] and the `handle_402*` entry points. @@ -19,6 +20,7 @@ mod client; mod error; mod headers; mod proxy; +mod select; pub use builder::PaymentBuilder; pub use client::{ diff --git a/crates/tinywallet-x402/src/protocol/select.rs b/crates/tinywallet-x402/src/protocol/select.rs new file mode 100644 index 0000000..9c4b9ab --- /dev/null +++ b/crates/tinywallet-x402/src/protocol/select.rs @@ -0,0 +1,71 @@ +//! Choosing which requirement of a challenge to pay, and refusing the rest. + +use super::error::X402Error; +use crate::wire::{AssetCheck, PaymentChain, PaymentRequired, PaymentRequirements, check_usdc}; + +/// The chain family a network belongs to, from its CAIP-2 prefix. +fn chain_of(network: &str) -> Option { + if network.starts_with("solana:") { + Some(PaymentChain::Solana) + } else if network.starts_with("eip155:") { + Some(PaymentChain::Evm) + } else { + None + } +} + +/// Whether `requirement` asks for USDC on a network this crate pays on. +/// +/// # Errors +/// +/// [`X402Error::UnsupportedNetwork`] or [`X402Error::UnsupportedAsset`]. +pub(crate) fn ensure_payable(requirement: &PaymentRequirements) -> Result<(), X402Error> { + match check_usdc(&requirement.network, &requirement.asset) { + AssetCheck::Allowed => Ok(()), + AssetCheck::UnknownNetwork => Err(X402Error::UnsupportedNetwork { + network: requirement.network.clone(), + }), + AssetCheck::WrongAsset => Err(X402Error::UnsupportedAsset { + network: requirement.network.clone(), + asset: requirement.asset.clone(), + }), + } +} + +/// The index and chain of the requirement to pay: the first payable Solana +/// `exact` option, else the first payable EVM one. +/// +/// Options on networks or in assets outside the allowlist are skipped, so a +/// server that also offers a payable one is still paid. +/// +/// # Errors +/// +/// [`X402Error::NoPaymentOption`] when the challenge has no `exact` option on a +/// Solana or EVM network at all; otherwise the refusal for the first such option +/// when none of them is payable. +pub(crate) fn select_requirement( + challenge: &PaymentRequired, +) -> Result<(usize, PaymentChain), X402Error> { + let mut first_refusal = None; + let mut evm = None; + for (index, requirement) in challenge.accepts.iter().enumerate() { + let Some(chain) = chain_of(&requirement.network).filter(|_| requirement.scheme == "exact") + else { + continue; + }; + match ensure_payable(requirement) { + Ok(()) if chain == PaymentChain::Solana => return Ok((index, chain)), + Ok(()) => { + evm.get_or_insert((index, chain)); + } + Err(refusal) => { + first_refusal.get_or_insert(refusal); + } + } + } + match (evm, first_refusal) { + (Some(choice), _) => Ok(choice), + (None, Some(refusal)) => Err(refusal), + (None, None) => Err(X402Error::NoPaymentOption), + } +} diff --git a/crates/tinywallet-x402/src/protocol/test.rs b/crates/tinywallet-x402/src/protocol/test.rs index 2efd993..acfcaec 100644 --- a/crates/tinywallet-x402/src/protocol/test.rs +++ b/crates/tinywallet-x402/src/protocol/test.rs @@ -117,6 +117,7 @@ fn settled(amount: u64) -> PaymentRecord { status: PaymentStatus::Settled, timestamp: chrono::Utc::now(), session_id: "test-session".into(), + thread_id: None, } } @@ -431,6 +432,113 @@ async fn each_budget_limit_refuses_with_its_own_error() { ledger::reset_global(); } +/// A builder that takes a moment to sign, as a real wallet does, so concurrent +/// payments overlap between the budget check and the signature. +struct SlowBuilder(StubBuilder); + +#[async_trait] +impl PaymentBuilder for SlowBuilder { + async fn build( + &self, + challenge: &PaymentRequired, + requirement: &PaymentRequirements, + chain: PaymentChain, + ) -> Result { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + self.0.build(challenge, requirement, chain).await + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn parallel_payments_cannot_overspend_the_daily_cap() { + let _guard = ledger::TEST_LOCK.lock().await; + // Each Solana challenge costs 10_000; the day allows exactly three. + let _dir = init_ledger(SpendingBudget { + per_request_max_atomic: 10_000, + daily_max_atomic: 30_000, + monthly_max_atomic: 1_000_000, + }); + let builder = Arc::new(SlowBuilder(StubBuilder::default())); + let headers = challenge_headers(&challenge(vec![solana_requirement()])); + + let handles: Vec<_> = (0..12) + .map(|_| { + let builder = Arc::clone(&builder); + let headers = headers.clone(); + tokio::spawn(async move { handle_402_and_pay(&*builder, &headers, "u").await }) + }) + .collect(); + let mut results = Vec::new(); + for handle in handles { + results.push(handle.await.unwrap()); + } + + let paid = results.iter().filter(|r| r.is_ok()).count(); + assert_eq!(paid, 3, "exactly the payments the cap allows may be signed"); + assert_eq!(builder.0.chains.lock().unwrap().len(), 3, "and only those"); + for refused in results.iter().filter_map(|r| r.as_ref().err()) { + assert!( + matches!( + refused, + X402Error::BudgetExceeded { + period: "daily", + .. + } + ), + "{refused}" + ); + } + ledger::reset_global(); +} + +#[tokio::test] +async fn a_payment_holds_its_amount_until_its_result_is_dropped() { + let _guard = ledger::TEST_LOCK.lock().await; + let _dir = init_ledger(SpendingBudget { + per_request_max_atomic: 10_000, + daily_max_atomic: 10_000, + monthly_max_atomic: 1_000_000, + }); + let headers = challenge_headers(&challenge(vec![solana_requirement()])); + let builder = StubBuilder::default(); + + let first = handle_402_and_pay(&builder, &headers, "u").await.unwrap(); + assert_eq!(first.reservation.amount(), 10_000); + let err = handle_402_and_pay(&builder, &headers, "u") + .await + .unwrap_err(); + assert_eq!( + err.to_string(), + "x402 daily budget exceeded: 10000/10000 atomic units", + "the first payment's hold counts even before it is recorded" + ); + + drop(first); + assert!(handle_402_and_pay(&builder, &headers, "u").await.is_ok()); + ledger::reset_global(); +} + +#[tokio::test] +async fn a_failed_signature_releases_the_hold() { + let _guard = ledger::TEST_LOCK.lock().await; + let _dir = init_ledger(SpendingBudget::default()); + let headers = challenge_headers(&challenge(vec![solana_requirement()])); + let failing = StubBuilder { + fail_with: Some("locked".into()), + ..StubBuilder::default() + }; + + handle_402_and_pay(&failing, &headers, "u") + .await + .unwrap_err(); + + assert_eq!( + ledger::with_ledger(ledger::PaymentLedger::reserved_atomic).unwrap(), + 0 + ); + ledger::reset_global(); +} + #[tokio::test] async fn a_wallet_failure_is_passed_through() { let _guard = ledger::TEST_LOCK.lock().await; @@ -533,6 +641,53 @@ async fn a_challenge_above_the_cap_is_refused_before_paying() { assert_eq!(server.seen().len(), 1, "no retry was sent"); } +/// A request whose body is a stream: it cannot be cloned, so it cannot be sent +/// a second time. +fn streaming_post(url: &str) -> reqwest::Request { + reqwest::Client::new() + .post(url) + .body(reqwest::Body::wrap(http_body_util::Full::new( + axum::body::Bytes::from("streamed"), + ))) + .build() + .unwrap() +} + +#[tokio::test] +async fn a_streaming_body_is_refused_before_anything_is_paid() { + let server = TestServer::start(paid_config(vec![solana_requirement()])).await; + let builder = Arc::new(StubBuilder::default()); + let client = client_with(builder.clone()); + + let err = client + .try_paid_request(streaming_post(&server.url), None) + .await + .unwrap_err(); + + assert!(matches!(err, X402Error::NonReplayableBody), "{err}"); + assert_eq!( + err.to_string(), + "x402 request body cannot be replayed for the paid retry; use a buffered body" + ); + assert!( + builder.chains.lock().unwrap().is_empty(), + "nothing was signed" + ); + assert_eq!(server.seen().len(), 1, "no paid retry was sent"); +} + +#[tokio::test] +async fn a_streaming_body_is_fine_when_no_payment_is_asked_for() { + let server = TestServer::start(ServerConfig::default()).await; + let client = client_with(Arc::new(StubBuilder::default())); + let response = client + .try_paid_request(streaming_post(&server.url), None) + .await + .unwrap(); + assert_eq!(response.text().await.unwrap(), "content"); + assert_eq!(server.seen()[0].body, "streamed"); +} + #[tokio::test] async fn a_402_without_a_payable_option_or_header_is_an_error() { let mut upto = evm_requirement(); @@ -659,3 +814,112 @@ fn the_client_and_a_payment_result_are_debuggable() { let client = client_with(Arc::new(StubBuilder::default())); assert!(format!("{client:?}").starts_with("X402Client")); } + +// --------------------------------------------------------------------------- +// The network / asset allowlist +// --------------------------------------------------------------------------- + +fn requirement_on(network: &str, asset: &str) -> PaymentRequirements { + let mut requirement = evm_requirement(); + requirement.network = network.into(); + requirement.asset = asset.into(); + requirement +} + +#[test] +fn handle_402_accepts_usdc_on_a_known_testnet() { + let c = challenge(vec![requirement_on( + crate::wire::BASE_SEPOLIA_CAIP2, + crate::wire::USDC_BASE_SEPOLIA, + )]); + let (_, idx, chain) = handle_402(&challenge_headers(&c)).unwrap(); + assert_eq!((idx, chain), (0, PaymentChain::Evm)); +} + +#[test] +fn handle_402_skips_an_unsupported_option_for_a_supported_one() { + let bogus = requirement_on("eip155:137", "0xdeadbeef"); + let c = challenge(vec![bogus, evm_requirement()]); + let (_, idx, _) = handle_402(&challenge_headers(&c)).unwrap(); + assert_eq!(idx, 1); +} + +#[test] +fn handle_402_refuses_an_unknown_network() { + let c = challenge(vec![requirement_on("eip155:137", "0xdeadbeef")]); + let err = handle_402(&challenge_headers(&c)).unwrap_err(); + assert!( + matches!(&err, X402Error::UnsupportedNetwork { network } if network == "eip155:137"), + "{err}" + ); + assert_eq!( + err.to_string(), + "x402 network eip155:137 is not supported; payments are accepted only in USDC on known networks" + ); +} + +#[test] +fn handle_402_refuses_an_asset_that_is_not_usdc() { + let c = challenge(vec![requirement_on( + crate::wire::SOLANA_MAINNET_CAIP2, + "NotUsdcMint1111111111111111111111111111111", + )]); + let err = handle_402(&challenge_headers(&c)).unwrap_err(); + assert!(matches!(err, X402Error::UnsupportedAsset { .. }), "{err}"); + assert_eq!( + err.to_string(), + "x402 asset NotUsdcMint1111111111111111111111111111111 is not the USDC accepted on \ + solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + ); +} + +#[tokio::test] +async fn an_unsupported_challenge_reserves_and_signs_nothing() { + let _guard = ledger::TEST_LOCK.lock().await; + let _dir = init_ledger(SpendingBudget::default()); + let builder = StubBuilder::default(); + let headers = challenge_headers(&challenge(vec![requirement_on("eip155:137", "0xbad")])); + + let err = handle_402_and_pay(&builder, &headers, "u") + .await + .unwrap_err(); + + assert!(matches!(err, X402Error::UnsupportedNetwork { .. })); + assert!(builder.chains.lock().unwrap().is_empty()); + assert_eq!( + ledger::with_ledger(ledger::PaymentLedger::reserved_atomic).unwrap(), + 0 + ); + ledger::reset_global(); +} + +#[tokio::test] +async fn the_client_refuses_to_pay_an_unsupported_asset() { + let server = TestServer::start(paid_config(vec![requirement_on( + crate::wire::BASE_MAINNET_CAIP2, + "0x0000000000000000000000000000000000000001", + )])) + .await; + let builder = Arc::new(StubBuilder::default()); + let client = client_with(builder.clone()); + + let err = client + .try_paid_request(get(&client, &server.url), None) + .await + .unwrap_err(); + + assert!(matches!(err, X402Error::UnsupportedAsset { .. }), "{err}"); + assert!(builder.chains.lock().unwrap().is_empty()); + assert_eq!(server.seen().len(), 1, "no paid retry was sent"); +} + +#[tokio::test] +async fn a_bare_requirement_is_checked_before_it_is_signed() { + let builder = StubBuilder::default(); + let c = challenge(vec![]); + let err = pay_challenge_header(&builder, &c, &requirement_on("cosmos:hub", "uatom")) + .await + .unwrap_err(); + assert!(matches!(err, X402Error::UnsupportedNetwork { .. }), "{err}"); + assert!(builder.chains.lock().unwrap().is_empty()); +} diff --git a/crates/tinywallet-x402/src/thread/mod.rs b/crates/tinywallet-x402/src/thread/mod.rs new file mode 100644 index 0000000..ba32f94 --- /dev/null +++ b/crates/tinywallet-x402/src/thread/mod.rs @@ -0,0 +1,34 @@ +//! Which conversation thread a payment belongs to: the seam between the host's +//! notion of a conversation and the ledger's `thread_id`. +//! +//! Rail-neutral. The ledger's `session_id` is its own (one per process, set at +//! [`init_global`](crate::ledger::init_global)) and never comes from here. A +//! thread is finer attribution the host may add: which chat thread, job or +//! conversation asked for the payment. This crate holds no state about it; the +//! host implements [`ThreadScope`] and hands it to the tool. +//! +//! The lookup is synchronous on purpose: it is called on the tool's own task, so +//! a host can answer from a task-local without any `.await` or lock. + +/// The host's answer to "which thread is running this tool call?". +/// +/// Called once per payment, on the task that runs the tool. Implementations must +/// not block. +pub trait ThreadScope: Send + Sync { + /// The active thread's id, or `None` when the call runs outside any thread + /// (a CLI call, a background job). A payment made then has no `thread_id`. + fn current_thread(&self) -> Option; +} + +/// The default scope: there is never an active thread. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoThread; + +impl ThreadScope for NoThread { + fn current_thread(&self) -> Option { + None + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinywallet-x402/src/thread/test.rs b/crates/tinywallet-x402/src/thread/test.rs new file mode 100644 index 0000000..db3aa94 --- /dev/null +++ b/crates/tinywallet-x402/src/thread/test.rs @@ -0,0 +1,15 @@ +//! Tests for the thread seam. + +use super::*; + +#[test] +fn the_default_scope_has_no_active_thread() { + assert_eq!(NoThread.current_thread(), None); +} + +#[test] +fn a_scope_is_usable_as_a_shared_trait_object() { + let scope: std::sync::Arc = std::sync::Arc::new(NoThread); + assert_eq!(scope.current_thread(), None); + assert!(format!("{NoThread:?}").starts_with("NoThread")); +} diff --git a/crates/tinywallet-x402/src/tools/request.rs b/crates/tinywallet-x402/src/tools/request.rs index b0d8e4c..2bceb1e 100644 --- a/crates/tinywallet-x402/src/tools/request.rs +++ b/crates/tinywallet-x402/src/tools/request.rs @@ -14,6 +14,7 @@ use tinywallet_crypto::rpc::Transport; use crate::crypto::{CryptoPayments, PaymentSigner}; use crate::ledger::{self, PaymentRecord, PaymentStatus}; use crate::protocol::{PaymentBuilder, ProxyPolicy, handle_402_and_pay}; +use crate::thread::{NoThread, ThreadScope}; use crate::wire::{ HEADER_PAYMENT_REQUIRED, HEADER_PAYMENT_REQUIRED_V1, HEADER_PAYMENT_RESPONSE, HEADER_PAYMENT_SIGNATURE, SettlementResponse, @@ -31,6 +32,7 @@ const MAX_BODY_BYTES: usize = 50_000; pub struct X402RequestTool { payments: Arc, proxy: Arc, + thread: Arc, } impl std::fmt::Debug for X402RequestTool { @@ -54,7 +56,22 @@ impl X402RequestTool { /// A tool that pays through any [`PaymentBuilder`]. #[must_use] pub fn with_builder(payments: Arc, proxy: Arc) -> Self { - Self { payments, proxy } + Self { + payments, + proxy, + thread: Arc::new(NoThread), + } + } + + /// Record the thread `thread` reports as active in each payment's + /// `thread_id`. + /// + /// Without this, or when the scope reports no thread, `thread_id` is empty. + /// Either way `session_id` is the ledger's own session. + #[must_use] + pub fn with_thread_scope(mut self, thread: Arc) -> Self { + self.thread = thread; + self } fn build_client(&self) -> Result { @@ -228,12 +245,20 @@ impl X402RequestTool { Ok(r) => r, Err(e) => return ToolResult::error(format!("x402 payment failed: {e}")), }; + // The hold on the budget, kept until the outcome is recorded below. + let reservation = payment.reservation; let amount_display = format_usdc(payment.amount_atomic); debug!( "{LOG_PREFIX} payment built: {amount_display} to {} on {} for {url}", payment.recipient, payment.network ); + // `session_id` is always the ledger's own, so the session total counts + // the payment; `thread_id` is the host's finer attribution. Both are read + // here, on the tool's own task, where a host's task-local is in scope. + let session_id = ledger::with_ledger(|l| l.session_id().to_string()).unwrap_or_default(); + let thread_id = self.thread.current_thread(); + // Record the pending payment. Every later state is a new line with the // same id. let record_id = uuid::Uuid::new_v4().to_string(); @@ -248,7 +273,8 @@ impl X402RequestTool { tx_signature, status, timestamp: chrono::Utc::now(), - session_id: String::new(), + session_id: session_id.clone(), + thread_id: thread_id.clone(), }; let _ = ledger::with_ledger_mut(|l| l.record_payment(record(PaymentStatus::Pending, None))); @@ -262,9 +288,7 @@ impl X402RequestTool { { Ok(r) => r, Err(e) => { - let _ = ledger::with_ledger_mut(|l| { - l.record_payment(record(PaymentStatus::Failed, None)); - }); + reservation.commit(record(PaymentStatus::Failed, None)); return ToolResult::error(format!("x402 retry request failed: {e}")); } }; @@ -282,9 +306,9 @@ impl X402RequestTool { .and_then(|b64| B64.decode(b64).ok()) .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .and_then(|r| (r.success && !r.transaction.is_empty()).then_some(r.transaction)); - let _ = ledger::with_ledger_mut(|l| { - l.record_payment(record(settled_status, tx_sig.clone())); - }); + // Recording the outcome and ending the budget hold are one step, so the + // amount is counted exactly once at every instant. + reservation.commit(record(settled_status, tx_sig.clone())); if settled_status == PaymentStatus::Settled { debug!("{LOG_PREFIX} payment settled for {url} tx={tx_sig:?} amount={amount_display}"); diff --git a/crates/tinywallet-x402/src/tools/test.rs b/crates/tinywallet-x402/src/tools/test.rs index 9f73919..0b190b3 100644 --- a/crates/tinywallet-x402/src/tools/test.rs +++ b/crates/tinywallet-x402/src/tools/test.rs @@ -16,6 +16,7 @@ use crate::test_support::{ FakePaymentSigner, FakeProxyPolicy, FakeTransport, ServerConfig, TestServer, challenge, challenge_header, evm_requirement, solana_requirement, }; +use crate::thread::ThreadScope; use crate::wire::{PaymentRequirements, SettlementResponse}; fn tool_with(proxy: Arc) -> X402RequestTool { @@ -245,6 +246,64 @@ async fn an_evm_402_is_paid_recorded_and_reported() { ledger::reset_global(); } +/// A host whose active thread is fixed, or absent. +struct FakeScope(Option<&'static str>); + +impl ThreadScope for FakeScope { + fn current_thread(&self) -> Option { + self.0.map(String::from) + } +} + +#[tokio::test] +async fn every_record_of_a_payment_names_the_hosts_thread_and_the_ledgers_session() { + let _guard = ledger::TEST_LOCK.lock().await; + let _dir = init_ledger(); + let server = TestServer::start(paid_config(evm_requirement())).await; + let tool = tool().with_thread_scope(Arc::new(FakeScope(Some("thread-7")))); + + let result = run(&tool, json!({"url": server.url})).await; + + assert!(!result.is_error, "{}", text(&result)); + let records = records(); + assert_eq!(records.len(), 2); + for record in &records { + assert_eq!(record.thread_id.as_deref(), Some("thread-7")); + assert_eq!(record.session_id, "tool-test", "the ledger's own session"); + } + ledger::reset_global(); +} + +#[tokio::test] +async fn a_tool_payment_counts_toward_the_session_total_with_or_without_a_thread() { + let _guard = ledger::TEST_LOCK.lock().await; + let _dir = init_ledger(); + let server = TestServer::start(paid_config(evm_requirement())).await; + + // No scope installed, then a scope with nothing active, then one thread. + run(&tool(), json!({"url": server.url})).await; + let quiet = tool().with_thread_scope(Arc::new(FakeScope(None))); + run(&quiet, json!({"url": server.url})).await; + let threaded = tool().with_thread_scope(Arc::new(FakeScope(Some("thread-7")))); + run(&threaded, json!({"url": server.url})).await; + + let records = records(); + assert_eq!(records.len(), 6); + assert_eq!( + records + .iter() + .filter(|r| r.status == PaymentStatus::Settled) + .map(|r| r.thread_id.as_deref()) + .collect::>(), + vec![None, None, Some("thread-7")] + ); + assert!(records.iter().all(|r| r.session_id == "tool-test")); + let summary = ledger::with_ledger(ledger::PaymentLedger::summary).unwrap(); + assert_eq!(summary.session_total_atomic, 7_500); + assert_eq!(summary.session_count, 3); + ledger::reset_global(); +} + #[tokio::test] async fn a_solana_402_is_labelled_solana() { let _guard = ledger::TEST_LOCK.lock().await; diff --git a/crates/tinywallet-x402/src/wire/assets.rs b/crates/tinywallet-x402/src/wire/assets.rs new file mode 100644 index 0000000..87ba41c --- /dev/null +++ b/crates/tinywallet-x402/src/wire/assets.rs @@ -0,0 +1,53 @@ +//! The allowlist of networks and the one asset payable on each. +//! +//! x402 lets the *server* name the network and the asset it wants paid, so +//! without a list a hostile endpoint could ask for any token on any chain. This +//! crate pays only in USDC, on the networks whose USDC it knows; everything else +//! is refused before a requirement is selected or signed. + +use super::types::{ + BASE_MAINNET_CAIP2, BASE_SEPOLIA_CAIP2, ETHEREUM_MAINNET_CAIP2, SOLANA_DEVNET_CAIP2, + SOLANA_MAINNET_CAIP2, USDC_BASE_MAINNET, USDC_BASE_SEPOLIA, USDC_ETHEREUM_MAINNET, + USDC_MINT_DEVNET, USDC_MINT_MAINNET, +}; + +/// Every payable network (CAIP-2) with its USDC mint or contract. +pub const SUPPORTED_USDC: [(&str, &str); 5] = [ + (SOLANA_MAINNET_CAIP2, USDC_MINT_MAINNET), + (SOLANA_DEVNET_CAIP2, USDC_MINT_DEVNET), + (BASE_MAINNET_CAIP2, USDC_BASE_MAINNET), + (BASE_SEPOLIA_CAIP2, USDC_BASE_SEPOLIA), + (ETHEREUM_MAINNET_CAIP2, USDC_ETHEREUM_MAINNET), +]; + +/// The verdict of [`check_usdc`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssetCheck { + /// The asset is the USDC of that network. + Allowed, + /// The network is not one this crate pays on. + UnknownNetwork, + /// The network is known, but the asset is not its USDC. + WrongAsset, +} + +/// Whether `asset` is the USDC of `network`. +/// +/// EVM contract addresses are compared without regard to case (EIP-55 +/// checksumming is presentation); Solana mints are base58 and compared exactly. +#[must_use] +pub fn check_usdc(network: &str, asset: &str) -> AssetCheck { + let Some((_, usdc)) = SUPPORTED_USDC.iter().find(|(n, _)| *n == network) else { + return AssetCheck::UnknownNetwork; + }; + let same = if network.starts_with("eip155:") { + usdc.eq_ignore_ascii_case(asset) + } else { + *usdc == asset + }; + if same { + AssetCheck::Allowed + } else { + AssetCheck::WrongAsset + } +} diff --git a/crates/tinywallet-x402/src/wire/mod.rs b/crates/tinywallet-x402/src/wire/mod.rs index 3c1aa6b..bfe632e 100644 --- a/crates/tinywallet-x402/src/wire/mod.rs +++ b/crates/tinywallet-x402/src/wire/mod.rs @@ -19,6 +19,13 @@ //! The protocol carries them as decimal strings for that reason, and so does //! this module. //! +//! ## Which networks and assets are payable +//! +//! The server names the network and asset it wants, so [`check_usdc`] holds the +//! allowlist ([`SUPPORTED_USDC`]): USDC on the networks this crate knows, nothing +//! else. The selection helpers on [`PaymentRequired`] do not consult it; the +//! `protocol` module does before it selects or signs. +//! //! ## The client signs an authorisation; the facilitator broadcasts //! //! In both supported schemes the payer never broadcasts. On Solana it hands @@ -31,10 +38,12 @@ //! //! [CAIP-2]: https://chainagnostic.org/CAIPs/caip-2 +mod assets; #[cfg(test)] mod test; mod types; +pub use assets::{AssetCheck, SUPPORTED_USDC, check_usdc}; pub use types::{ BASE_MAINNET_CAIP2, BASE_SEPOLIA_CAIP2, COMPUTE_BUDGET_PROGRAM, ETHEREUM_MAINNET_CAIP2, EvmAuthorization, EvmPaymentProof, HEADER_PAYMENT_REQUIRED, HEADER_PAYMENT_REQUIRED_V1, diff --git a/crates/tinywallet-x402/src/wire/test.rs b/crates/tinywallet-x402/src/wire/test.rs index fdecd0d..d2534a9 100644 --- a/crates/tinywallet-x402/src/wire/test.rs +++ b/crates/tinywallet-x402/src/wire/test.rs @@ -7,10 +7,10 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use crate::wire::{ - BASE_MAINNET_CAIP2, EvmAuthorization, EvmPaymentProof, PaymentExtra, PaymentPayload, - PaymentProof, PaymentRequired, PaymentRequirements, ResourceInfo, SOLANA_MAINNET_CAIP2, - SettlementResponse, SolanaPaymentProof, USDC_BASE_MAINNET, USDC_ETHEREUM_MAINNET, - USDC_MINT_MAINNET, + AssetCheck, BASE_MAINNET_CAIP2, EvmAuthorization, EvmPaymentProof, PaymentExtra, + PaymentPayload, PaymentProof, PaymentRequired, PaymentRequirements, ResourceInfo, + SOLANA_MAINNET_CAIP2, SUPPORTED_USDC, SettlementResponse, SolanaPaymentProof, + USDC_BASE_MAINNET, USDC_ETHEREUM_MAINNET, USDC_MINT_MAINNET, check_usdc, }; fn requirement(network: &str, asset: &str, extra: Option) -> PaymentRequirements { @@ -199,3 +199,70 @@ fn a_solana_payload_carries_the_transaction_but_no_signature() { "an absent resource is omitted: {json}" ); } + +// --------------------------------------------------------------------------- +// The network -> USDC allowlist +// --------------------------------------------------------------------------- + +#[test] +fn usdc_is_accepted_on_every_known_network() { + use crate::wire::{ + BASE_SEPOLIA_CAIP2, ETHEREUM_MAINNET_CAIP2, SOLANA_DEVNET_CAIP2, USDC_BASE_SEPOLIA, + USDC_MINT_DEVNET, + }; + let pairs = [ + (SOLANA_MAINNET_CAIP2, USDC_MINT_MAINNET), + (SOLANA_DEVNET_CAIP2, USDC_MINT_DEVNET), + (BASE_MAINNET_CAIP2, USDC_BASE_MAINNET), + (BASE_SEPOLIA_CAIP2, USDC_BASE_SEPOLIA), + (ETHEREUM_MAINNET_CAIP2, USDC_ETHEREUM_MAINNET), + ]; + assert_eq!(pairs.len(), SUPPORTED_USDC.len()); + for (network, asset) in pairs { + assert_eq!(check_usdc(network, asset), AssetCheck::Allowed, "{network}"); + } +} + +#[test] +fn an_evm_asset_is_compared_without_regard_to_case() { + assert_eq!( + check_usdc(BASE_MAINNET_CAIP2, &USDC_BASE_MAINNET.to_lowercase()), + AssetCheck::Allowed + ); +} + +#[test] +fn a_solana_mint_is_compared_exactly() { + assert_eq!( + check_usdc(SOLANA_MAINNET_CAIP2, &USDC_MINT_MAINNET.to_lowercase()), + AssetCheck::WrongAsset + ); +} + +#[test] +fn an_unknown_network_is_not_accepted_even_with_a_known_asset() { + assert_eq!( + check_usdc("eip155:137", USDC_BASE_MAINNET), + AssetCheck::UnknownNetwork + ); + assert_eq!( + check_usdc("solana:someOtherCluster", USDC_MINT_MAINNET), + AssetCheck::UnknownNetwork + ); +} + +#[test] +fn another_asset_on_a_known_network_is_not_accepted() { + // USDC's Base contract is not USDC on Ethereum, and neither is a made-up token. + assert_eq!( + check_usdc(BASE_MAINNET_CAIP2, USDC_ETHEREUM_MAINNET), + AssetCheck::WrongAsset + ); + assert_eq!( + check_usdc( + SOLANA_MAINNET_CAIP2, + "NotUsdcMint1111111111111111111111111111111" + ), + AssetCheck::WrongAsset + ); +}