Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c1b50ce
fix(btc): handle missing chain parameter in address derivation
senamakel Sep 30, 2026
b01c969
fix(test): update test to reflect new protocol behavior
senamakel Sep 30, 2026
8b2dc46
fix(btc): correct test assertion for address derivation
senamakel Sep 30, 2026
292ecba
fix(ledger): correct test assertion for insufficient balance
senamakel Sep 30, 2026
fc09f68
fix(btc): handle missing chain parameter in address derivation
senamakel Sep 30, 2026
8e0d427
fix(btc): handle missing chain parameter in address derivation
senamakel Sep 30, 2026
1b72303
feat(ledger): add reservation support for atomic balance checks
senamakel Sep 30, 2026
02e0c12
chore(btc): remove duplicate SpendPlan struct
senamakel Sep 30, 2026
e6c7fb8
fix(protocol): handle missing payment header in client response
senamakel Sep 30, 2026
83968ca
fix(test): avoid u64 overflow in test values
senamakel Sep 30, 2026
d2b53d3
feat(ledger): add reservation system for atomic balance transfers
senamakel Sep 30, 2026
87ab3a3
fix(execution): handle empty query result in balance lookup
senamakel Sep 30, 2026
3454813
feat(x402): add session management and test utilities
senamakel Sep 30, 2026
a67d0b3
test: consolidate chain status rejection tests and add Solana assertion
senamakel Sep 30, 2026
23ec623
fix(tools): handle missing x402 header in request validation
senamakel Sep 30, 2026
f9572d7
x402: reserve budget atomically before signing; stamp payments with t…
senamakel Sep 30, 2026
35c0e8f
fix(execution): handle missing account in probe query
senamakel Sep 30, 2026
da8fc11
fix(protocol): correct test assertion for payment header parsing
senamakel Sep 30, 2026
5fadbcb
feat(x402): add error handling and test for protocol module
senamakel Sep 30, 2026
9e3f944
fix(web3): remove unused imports across multiple chain modules
senamakel Sep 30, 2026
97c0181
fix(engine): handle missing wallet state on restore
senamakel Sep 30, 2026
b40ec45
fix(test): update Solana and defaults test expectations
senamakel Sep 30, 2026
b1b770a
chore: files changed crates/tinywallet-x402/src/protocol/test.rs,crat…
senamakel Sep 30, 2026
ca7e08a
chore: files changed crates/tinywallet-web3/src/crypto/defaults/catal…
senamakel Sep 30, 2026
7004d24
chore: files changed crates/tinywallet-web3/README.md,crates/tinywall…
senamakel Sep 30, 2026
c511719
x402: allowlist USDC networks and refuse non-replayable bodies before…
senamakel Sep 30, 2026
1af9e34
chore: files changed crates/tinywallet-x402/README.md,crates/tinywall…
senamakel Sep 30, 2026
3c529b0
fix(thread): remove unused thread module
senamakel Sep 30, 2026
e1da45c
docs(tinywallet-x402): add README with usage and configuration details
senamakel Sep 30, 2026
ca1279e
chore: reorder imports and simplify assertion formatting
senamakel Sep 30, 2026
8aa9a98
fix(tools): use method reference in test assertion
senamakel Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions crates/tinywallet-web3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sig>?cluster=devnet`.
`explorer_tx_url` takes the cluster for that reason.

## Features

| Feature | Default | Gates |
Expand Down
102 changes: 81 additions & 21 deletions crates/tinywallet-web3/src/crypto/chains/btc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,29 @@ 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,
};
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;
Comment on lines +31 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Size fees for the recipient's actual output type

execute_btc_quote accepts any valid mainnet recipient through validate_btc_address, including P2TR/P2WSH addresses, but every output is priced as a 31-vbyte P2WPKH output here. Those output scripts require 43 vbytes, so such transfers receive an absolute fee below the configured 20 sat/vB rate and can be rejected or delayed when the mempool minimum is near that target. Determine the destination output size from its address type, or conservatively price the largest supported output.

Useful? React with 👍 / 👎.

/// 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)]
Expand All @@ -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<String, String>) -> &'static str {
Expand Down Expand Up @@ -112,17 +122,46 @@ async fn broadcast_raw_hex(engine: &WalletEngine, tx_hex: &str) -> Result<String
.await
}

/// Select UTXOs to cover `amount_sats + fee_sats`, returning the selected set
/// and the change. Greedy, largest-first.
pub(crate) fn select_utxos(
/// The coins chosen for a transfer, the fee they pay, and the change they leave.
#[derive(Debug, Clone)]
pub(crate) struct SpendPlan {
/// The UTXOs to spend, largest first.
pub(crate) selected: Vec<EsploraUtxo>,
/// 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<u64, String> {
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<EsploraUtxo>, u64), String> {
fee_rate_sat_vb: u64,
) -> Result<SpendPlan, String> {
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();
Expand All @@ -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})"
))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading