From 2eae972965e85eafa451244d84f10181ec3d0590 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 1 Oct 2026 14:16:18 +0300 Subject: [PATCH 1/5] test: move inline test modules into *_tests.rs files Extract each inline `#[cfg(test)] mod` into a sibling `_tests.rs` declared with `#[path]`, and record the rule in the repo guidance. Co-authored-by: Medulla --- AGENTS.md | 41 +- crates/tinywallet-crypto/src/tx/tron.rs | 427 +----------------- .../src/tx/tron_test_tests.rs | 420 +++++++++++++++++ crates/tinywallet-x402/src/wire/types.rs | 171 +------ .../src/wire/types_test_tests.rs | 167 +++++++ src/tx/btc.rs | 289 +----------- src/tx/btc_test_tests.rs | 285 ++++++++++++ src/tx/rlp.rs | 99 +--- src/tx/rlp_test_tests.rs | 95 ++++ src/tx/solana.rs | 190 +------- src/tx/solana_test_tests.rs | 185 ++++++++ src/tx/tron.rs | 63 +-- src/tx/tron_test_tests.rs | 59 +++ 13 files changed, 1257 insertions(+), 1234 deletions(-) create mode 100644 crates/tinywallet-crypto/src/tx/tron_test_tests.rs create mode 100644 crates/tinywallet-x402/src/wire/types_test_tests.rs create mode 100644 src/tx/btc_test_tests.rs create mode 100644 src/tx/rlp_test_tests.rs create mode 100644 src/tx/solana_test_tests.rs create mode 100644 src/tx/tron_test_tests.rs diff --git a/AGENTS.md b/AGENTS.md index 0cf34ba..4ec096c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Do this once, in a single commit, before writing feature code: - [ ] Rename the crate references in `README.md`, `src/lib.rs`, `examples/`, and `tests/` (search for `rust_template` and `rust-template`). - [ ] Replace the placeholder `greeting` module with the first real feature - area, keeping the `mod.rs` / `types.rs` / `test.rs` layout. + area, keeping the `mod.rs` / `types.rs` / `mod_tests.rs` layout. - [ ] Confirm `license` and `LICENSE` match the project's intended license. - [ ] Update the security contact in `SECURITY.md`. - [ ] Replace `ROADMAP.md` with the real plan, or delete it. @@ -36,7 +36,7 @@ src/ └── / # one directory per feature area ├── mod.rs # module docs, wiring, smallest useful public API ├── types.rs # substantial type definitions - └── test.rs # module-local unit tests + └── mod_tests.rs # module-local unit tests tests/ # integration tests against the public API only examples/ # runnable, compiled-in-CI usage examples vendor/tinybus/ # pinned TinyBus source; optional until wired by a project @@ -66,15 +66,17 @@ minor release; do not add new ones. CI fails if crypto, x402, web3 or the bus ga Each feature area belongs in a focused module directory under `src/`. A module root explains the module, wires its pieces together, and exposes the smallest useful API. Move substantial type definitions into `types.rs` and put -module-local unit tests in a dedicated `test.rs`, wired from the bottom of the +module-local unit tests in a sibling `_tests.rs`, wired from the bottom of the module root with: ```rust #[cfg(test)] -mod test; +#[path = "mod_tests.rs"] +mod tests; ``` -Do not accumulate inline `mod tests` blocks in implementation files, and do not +Do not write inline `mod tests` blocks in implementation files, do not name a test +file `test.rs`, `tests.rs` or `_test.rs`, and do not let a general-purpose `utils.rs` or `helpers.rs` grow — those are a symptom of a missing module. Prefer many small modules that each do one thing well over few broad ones. @@ -171,7 +173,7 @@ on every generated crate. ## Testing -- Module-local unit tests live in `src//test.rs` and may touch private +- Module-local unit tests live in `src//mod_tests.rs` and may touch private items. - Integration tests live in `tests/` and exercise only the public API — they are the regression suite for the crate's contract. @@ -197,7 +199,7 @@ Write documentation for the reader who has never seen the code. - Every public item gets a rustdoc comment. `missing_docs` is a warning that CI treats as an error. -- Start every `mod.rs` and `test.rs` with a concise module-level `//!` +- Start every `mod.rs` and `*_tests.rs` with a concise module-level `//!` description. - `src/lib.rs` carries the crate-level overview: what the crate does, the primary entry points, and a short runnable example. @@ -283,3 +285,28 @@ For automated contributors specifically: credentials, and never paste them into a pull request or issue. 7. **Ask only when blocked.** Make routine judgment calls yourself; escalate only irreversible decisions or genuine forks with no clear default. + +## Tests live in `*_tests.rs` files + +- Unit tests are never inline. Do not write a `#[cfg(test)] mod tests { ... }` + block in a source file. Put the tests in a sibling `_tests.rs` + (`mod_tests.rs` beside a `mod.rs`, `lib_tests.rs` beside `lib.rs`) and declare + it at the bottom of the module: + + ```rust + #[cfg(test)] + #[path = "foo_tests.rs"] + mod tests; + ``` + +- The test file starts with `use super::*;` and carries no `#[cfg(test)]` of its + own. It is still a child module, so it reaches private items exactly as an + inline module did. +- Name test files `_tests.rs`; a second group for the same module is + `__tests.rs`. Never `test.rs`, `tests.rs` or `_test.rs`. +- Integration tests stay in the crate's `tests/` directory. +- OpenHuman's `scripts/externalize-inline-tests.mjs --write` moves + inline test modules out mechanically; without `--write` it only reports. +- Existing `test.rs` and `_test.rs` files predate this rule. Rename each + to `_tests.rs` (keep its `mod` name, add the `#[path]` attribute) the + next time you touch it. diff --git a/crates/tinywallet-crypto/src/tx/tron.rs b/crates/tinywallet-crypto/src/tx/tron.rs index 49ccdc2..031f38e 100644 --- a/crates/tinywallet-crypto/src/tx/tron.rs +++ b/crates/tinywallet-crypto/src/tx/tron.rs @@ -317,428 +317,5 @@ fn hex_lower(bytes: &[u8]) -> String { } #[cfg(test)] -mod test { - #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - - use super::{ - CONTRACT_TYPE_TRANSFER, CONTRACT_TYPE_TRIGGER_SMART_CONTRACT, TRC20_TRANSFER_SELECTOR_HEX, - attach_signature, digest, hex_lower, recompute_txid, signature_hex, verify_transfer, - }; - use crate::TronTransfer; - use crate::tx::Error; - - const TO: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; - - /// A `raw_data`-shaped hex blob embedding the recipient's hex address. - /// - /// Not a real protobuf — `verify_transfer` deliberately does not parse - /// one, it checks the recipient's bytes are present, so a representative - /// blob is enough and avoids pinning a schema the node owns. - fn raw_data() -> String { - let to_hex = crate::address::tron::to_hex(TO).unwrap(); - format!("0a02b1f42208{to_hex}5a0f") - } - - #[test] - fn the_txid_is_sha256_of_the_raw_data() { - let raw = raw_data(); - let id = recompute_txid(&raw).unwrap(); - assert_eq!(id.len(), 64, "sha256 is 32 bytes of hex"); - // Deterministic. - assert_eq!(id, recompute_txid(&raw).unwrap()); - } - - #[test] - fn a_tampered_raw_data_no_longer_matches_its_txid() { - // The defence against signing whatever a node hands back. - let raw = raw_data(); - let id = recompute_txid(&raw).unwrap(); - let tampered = raw.replace("0a02", "0a03"); - assert_ne!(tampered, raw); - - match verify_transfer(&tampered, TO, &id, &TronTransfer::Native { amount_sun: 15 }) - .unwrap_err() - { - Error::UntrustedResponse { reason } => assert!(reason.contains("altered")), - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_transaction_paying_someone_else_is_rejected() { - // A node that substituted the recipient must not get a signature. - let raw = raw_data(); - let id = recompute_txid(&raw).unwrap(); - let other = "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH"; - match verify_transfer(&raw, other, &id, &TronTransfer::Native { amount_sun: 15 }) - .unwrap_err() - { - Error::UntrustedResponse { reason } => { - assert!(reason.contains("does not pay the requested recipient")); - } - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_well_formed_transaction_verifies() { - let raw = raw_data(); - let id = recompute_txid(&raw).unwrap(); - assert!(verify_transfer(&raw, TO, &id, &TronTransfer::Native { amount_sun: 15 }).is_ok()); - } - - #[test] - fn a_native_transfer_with_the_wrong_amount_is_rejected() { - let raw = raw_data(); - let id = recompute_txid(&raw).unwrap(); - let error = - verify_transfer(&raw, TO, &id, &TronTransfer::Native { amount_sun: 16 }).unwrap_err(); - assert!(format!("{error:?}").contains("requested amount")); - } - - #[test] - fn a_trc20_transfer_must_contain_the_exact_parameter() { - let to_hex = crate::address::tron::to_hex(TO).unwrap(); - let parameter = format!("{}{}", "00".repeat(11), &to_hex[2..]); - let raw = format!("0a02b1f42208{to_hex}5a{parameter}"); - let id = recompute_txid(&raw).unwrap(); - assert!( - verify_transfer( - &raw, - TO, - &id, - &TronTransfer::Trc20 { - parameter_hex: parameter.clone(), - } - ) - .is_ok() - ); - let error = verify_transfer( - &raw, - TO, - &id, - &TronTransfer::Trc20 { - parameter_hex: format!("{parameter}00"), - }, - ) - .unwrap_err(); - assert!(format!("{error:?}").contains("TRC20 transfer parameter")); - } - - #[test] - fn malformed_hex_is_rejected() { - assert!(matches!( - recompute_txid("abc").unwrap_err(), - Error::InvalidField { .. } - )); - } - #[test] - fn a_signature_is_r_s_and_a_bare_recovery_id() { - // The assembly half of signing lives here even though producing the - // 64 bytes does not: a host that signs elsewhere still has to put the - // 65-byte value together, and getting the trailing byte wrong yields a - // signature Tron rejects rather than one that fails to build. - let signature = attach_signature(&[7u8; 64], 1).unwrap(); - assert_eq!(signature.len(), 65); - assert_eq!(signature[64], 1, "a bare recovery id, not EIP-155's v"); - assert_eq!(signature_hex(&signature).len(), 130); - - assert!(matches!( - attach_signature(&[7u8; 64], 4).unwrap_err(), - Error::Signing { .. } - )); - } - - #[test] - fn the_digest_is_the_txid_bytes() { - // `digest` and `recompute_txid` must not drift: the id a caller checks - // against the node's answer is exactly the value it then signs. - let raw = raw_data(); - assert_eq!( - hex_lower(&digest(&raw).unwrap()), - recompute_txid(&raw).unwrap() - ); - assert!(matches!( - digest("abc").unwrap_err(), - Error::InvalidField { .. } - )); - } - - // ---- verify_contract: the structural check ----------------------------- - - use super::verify_contract; - use crate::tx::proto::encode_varint; - - fn field(number: u64, wire: u64) -> Vec { - encode_varint((number << 3) | wire) - } - - fn bytes_field(number: u64, payload: &[u8]) -> Vec { - let mut out = field(number, 2); - out.extend(encode_varint(payload.len() as u64)); - out.extend(payload); - out - } - - fn varint_field(number: u64, value: u64) -> Vec { - let mut out = field(number, 0); - out.extend(encode_varint(value)); - out - } - - fn to_bytes(address: &str) -> Vec { - hex_decode(&crate::address::tron::to_hex(address).unwrap()) - } - - fn hex_decode(value: &str) -> Vec { - (0..value.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()) - .collect() - } - - /// Wrap a contract payload in `Transaction.raw` → `contract` → `Any`. - fn wrap(kind: u64, type_url: &str, payload: &[u8], extra: &[u8]) -> String { - let mut any = bytes_field(1, type_url.as_bytes()); - any.extend(bytes_field(2, payload)); - - let mut contract = varint_field(1, kind); - contract.extend(bytes_field(2, &any)); - - let mut raw = bytes_field(11, &contract); - raw.extend(extra); - hex_lower(&raw) - } - - fn native_raw(to: &str, amount_sun: u64) -> String { - let mut payload = bytes_field(2, &to_bytes(to)); - payload.extend(varint_field(3, amount_sun)); - wrap( - CONTRACT_TYPE_TRANSFER, - "type.googleapis.com/protocol.TransferContract", - &payload, - &[], - ) - } - - fn trc20_raw(contract_address: &str, parameter_hex: &str, fee_limit: Option) -> String { - let mut data = hex_decode(TRC20_TRANSFER_SELECTOR_HEX); - data.extend(hex_decode(parameter_hex)); - - let mut payload = bytes_field(2, &to_bytes(contract_address)); - payload.extend(bytes_field(4, &data)); - - let extra = fee_limit - .map(|limit| varint_field(18, limit)) - .unwrap_or_default(); - wrap( - CONTRACT_TYPE_TRIGGER_SMART_CONTRACT, - "type.googleapis.com/protocol.TriggerSmartContract", - &payload, - &extra, - ) - } - - /// 32-byte-padded recipient and amount, the ERC-20 `transfer` parameters. - fn trc20_parameter(to: &str, amount: u64) -> String { - let recipient = to_bytes(to); - let mut param = vec![0u8; 32]; - // Tron's 21-byte address drops its 0x41 prefix in ABI encoding. - param[12..32].copy_from_slice(&recipient[1..21]); - let mut amount_word = vec![0u8; 32]; - amount_word[24..32].copy_from_slice(&amount.to_be_bytes()); - param.extend(amount_word); - hex_lower(¶m) - } - - #[test] - fn a_well_formed_native_transfer_verifies_structurally() { - let raw = native_raw(TO, 1_000_000); - let id = recompute_txid(&raw).unwrap(); - let transfer = TronTransfer::Native { - amount_sun: 1_000_000, - }; - assert!(verify_contract(&raw, TO, &id, &transfer, None).is_ok()); - } - - #[test] - fn a_native_transfer_for_a_different_amount_is_rejected() { - // The amount is read from `TransferContract.amount` rather than found - // anywhere in the bytes. `verify_transfer` also rejects this one — it - // searches for the amount's varint as a byte run — but it rejects it - // for a reason that happens to coincide, not because it looked at the - // field. The decoy test below is where the two answers diverge. - let raw = native_raw(TO, 1_000_000); - let id = recompute_txid(&raw).unwrap(); - - let transfer = TronTransfer::Native { amount_sun: 42 }; - match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { - Error::UntrustedResponse { reason } => { - assert!(reason.contains("different native amount")); - } - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_recipient_present_but_not_as_the_to_address_is_rejected() { - // The substring scan's blind spot, made concrete: the requested - // address appears in the bytes — as an unrelated trailing field — - // while `to_address` pays someone else entirely. - let other = "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH"; - let mut payload = bytes_field(2, &to_bytes(other)); - payload.extend(varint_field(3, 1_000_000)); - // Smuggle the requested recipient in somewhere harmless. - let decoy = bytes_field(99, &to_bytes(TO)); - let raw = wrap( - CONTRACT_TYPE_TRANSFER, - "type.googleapis.com/protocol.TransferContract", - &payload, - &decoy, - ); - let id = recompute_txid(&raw).unwrap(); - let transfer = TronTransfer::Native { - amount_sun: 1_000_000, - }; - - // Both of `verify_transfer`'s checks are satisfied: the requested - // address is present (in the decoy) and so is the amount's varint. - // Neither is the field that will execute. - assert!( - verify_transfer(&raw, TO, &id, &transfer).is_ok(), - "the positional-blind check is fooled by the decoy" - ); - - match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { - Error::UntrustedResponse { reason } => { - assert!(reason.contains("does not pay the requested recipient")); - } - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_trc20_call_dressed_as_a_native_transfer_is_rejected() { - // Contract type is checked, so a token trigger cannot pass as TRX. - let param = trc20_parameter(TO, 5); - let raw = trc20_raw(TO, ¶m, None); - let id = recompute_txid(&raw).unwrap(); - - let transfer = TronTransfer::Native { amount_sun: 5 }; - match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { - Error::UntrustedResponse { reason } => { - assert!(reason.contains("not a native transfer")); - } - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_well_formed_trc20_transfer_verifies_structurally() { - let param = trc20_parameter(TO, 5); - let raw = trc20_raw(TO, ¶m, Some(150_000_000)); - let id = recompute_txid(&raw).unwrap(); - let transfer = TronTransfer::Trc20 { - parameter_hex: param, - }; - assert!(verify_contract(&raw, TO, &id, &transfer, Some(150_000_000)).is_ok()); - } - - #[test] - fn trc20_calldata_that_does_not_match_the_request_is_rejected() { - let raw = trc20_raw(TO, &trc20_parameter(TO, 5), None); - let id = recompute_txid(&raw).unwrap(); - // Same recipient, different amount inside the ABI parameters. - let transfer = TronTransfer::Trc20 { - parameter_hex: trc20_parameter(TO, 9_999), - }; - match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { - Error::UntrustedResponse { reason } => { - assert!(reason.contains("different TRC20 transfer data")); - } - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_trc20_call_smuggling_native_value_is_rejected() { - // call_value is field 3 of TriggerSmartContract. A token transfer - // moves no TRX, so a non-zero value here is TRX leaving the wallet - // alongside the transfer that was actually requested. - let param = trc20_parameter(TO, 5); - let mut data = hex_decode(TRC20_TRANSFER_SELECTOR_HEX); - data.extend(hex_decode(¶m)); - - let mut payload = bytes_field(2, &to_bytes(TO)); - payload.extend(varint_field(3, 1_000_000)); // call_value - payload.extend(bytes_field(4, &data)); - let raw = wrap( - CONTRACT_TYPE_TRIGGER_SMART_CONTRACT, - "type.googleapis.com/protocol.TriggerSmartContract", - &payload, - &[], - ); - let id = recompute_txid(&raw).unwrap(); - - let transfer = TronTransfer::Trc20 { - parameter_hex: param, - }; - match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { - Error::UntrustedResponse { reason } => { - assert!(reason.contains("non-zero TRC20 call_value")); - } - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_raised_fee_limit_is_rejected_when_the_request_pinned_one() { - let param = trc20_parameter(TO, 5); - let raw = trc20_raw(TO, ¶m, Some(9_000_000_000)); - let id = recompute_txid(&raw).unwrap(); - - let transfer = TronTransfer::Trc20 { - parameter_hex: param, - }; - match verify_contract(&raw, TO, &id, &transfer, Some(150_000_000)).unwrap_err() { - Error::UntrustedResponse { reason } => assert!(reason.contains("different fee_limit")), - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } - - #[test] - fn a_second_contract_is_refused_rather_than_checked_once() { - // Two contracts would mean signing something beyond what was verified, - // so the singular read refuses the message outright. - let mut payload = bytes_field(2, &to_bytes(TO)); - payload.extend(varint_field(3, 1_000_000)); - let mut any = bytes_field(1, b"type.googleapis.com/protocol.TransferContract"); - any.extend(bytes_field(2, &payload)); - let mut contract = varint_field(1, CONTRACT_TYPE_TRANSFER); - contract.extend(bytes_field(2, &any)); - - let mut raw = bytes_field(11, &contract); - raw.extend(bytes_field(11, &contract)); - let raw = hex_lower(&raw); - let id = recompute_txid(&raw).unwrap(); - - let transfer = TronTransfer::Native { - amount_sun: 1_000_000, - }; - assert!(verify_contract(&raw, TO, &id, &transfer, None).is_err()); - } - - #[test] - fn a_tampered_raw_data_fails_the_structural_check_too() { - let raw = native_raw(TO, 1_000_000); - let id = recompute_txid(&raw).unwrap(); - let tampered = native_raw(TO, 1_000_001); - let transfer = TronTransfer::Native { - amount_sun: 1_000_000, - }; - match verify_contract(&tampered, TO, &id, &transfer, None).unwrap_err() { - Error::UntrustedResponse { reason } => assert!(reason.contains("altered")), - other => panic!("expected UntrustedResponse, got {other:?}"), - } - } -} +#[path = "tron_test_tests.rs"] +mod test; diff --git a/crates/tinywallet-crypto/src/tx/tron_test_tests.rs b/crates/tinywallet-crypto/src/tx/tron_test_tests.rs new file mode 100644 index 0000000..76533cf --- /dev/null +++ b/crates/tinywallet-crypto/src/tx/tron_test_tests.rs @@ -0,0 +1,420 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{ + CONTRACT_TYPE_TRANSFER, CONTRACT_TYPE_TRIGGER_SMART_CONTRACT, TRC20_TRANSFER_SELECTOR_HEX, + attach_signature, digest, hex_lower, recompute_txid, signature_hex, verify_transfer, +}; +use crate::TronTransfer; +use crate::tx::Error; + +const TO: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +/// A `raw_data`-shaped hex blob embedding the recipient's hex address. +/// +/// Not a real protobuf — `verify_transfer` deliberately does not parse +/// one, it checks the recipient's bytes are present, so a representative +/// blob is enough and avoids pinning a schema the node owns. +fn raw_data() -> String { + let to_hex = crate::address::tron::to_hex(TO).unwrap(); + format!("0a02b1f42208{to_hex}5a0f") +} + +#[test] +fn the_txid_is_sha256_of_the_raw_data() { + let raw = raw_data(); + let id = recompute_txid(&raw).unwrap(); + assert_eq!(id.len(), 64, "sha256 is 32 bytes of hex"); + // Deterministic. + assert_eq!(id, recompute_txid(&raw).unwrap()); +} + +#[test] +fn a_tampered_raw_data_no_longer_matches_its_txid() { + // The defence against signing whatever a node hands back. + let raw = raw_data(); + let id = recompute_txid(&raw).unwrap(); + let tampered = raw.replace("0a02", "0a03"); + assert_ne!(tampered, raw); + + match verify_transfer(&tampered, TO, &id, &TronTransfer::Native { amount_sun: 15 }).unwrap_err() + { + Error::UntrustedResponse { reason } => assert!(reason.contains("altered")), + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_transaction_paying_someone_else_is_rejected() { + // A node that substituted the recipient must not get a signature. + let raw = raw_data(); + let id = recompute_txid(&raw).unwrap(); + let other = "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH"; + match verify_transfer(&raw, other, &id, &TronTransfer::Native { amount_sun: 15 }).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("does not pay the requested recipient")); + } + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_well_formed_transaction_verifies() { + let raw = raw_data(); + let id = recompute_txid(&raw).unwrap(); + assert!(verify_transfer(&raw, TO, &id, &TronTransfer::Native { amount_sun: 15 }).is_ok()); +} + +#[test] +fn a_native_transfer_with_the_wrong_amount_is_rejected() { + let raw = raw_data(); + let id = recompute_txid(&raw).unwrap(); + let error = + verify_transfer(&raw, TO, &id, &TronTransfer::Native { amount_sun: 16 }).unwrap_err(); + assert!(format!("{error:?}").contains("requested amount")); +} + +#[test] +fn a_trc20_transfer_must_contain_the_exact_parameter() { + let to_hex = crate::address::tron::to_hex(TO).unwrap(); + let parameter = format!("{}{}", "00".repeat(11), &to_hex[2..]); + let raw = format!("0a02b1f42208{to_hex}5a{parameter}"); + let id = recompute_txid(&raw).unwrap(); + assert!( + verify_transfer( + &raw, + TO, + &id, + &TronTransfer::Trc20 { + parameter_hex: parameter.clone(), + } + ) + .is_ok() + ); + let error = verify_transfer( + &raw, + TO, + &id, + &TronTransfer::Trc20 { + parameter_hex: format!("{parameter}00"), + }, + ) + .unwrap_err(); + assert!(format!("{error:?}").contains("TRC20 transfer parameter")); +} + +#[test] +fn malformed_hex_is_rejected() { + assert!(matches!( + recompute_txid("abc").unwrap_err(), + Error::InvalidField { .. } + )); +} +#[test] +fn a_signature_is_r_s_and_a_bare_recovery_id() { + // The assembly half of signing lives here even though producing the + // 64 bytes does not: a host that signs elsewhere still has to put the + // 65-byte value together, and getting the trailing byte wrong yields a + // signature Tron rejects rather than one that fails to build. + let signature = attach_signature(&[7u8; 64], 1).unwrap(); + assert_eq!(signature.len(), 65); + assert_eq!(signature[64], 1, "a bare recovery id, not EIP-155's v"); + assert_eq!(signature_hex(&signature).len(), 130); + + assert!(matches!( + attach_signature(&[7u8; 64], 4).unwrap_err(), + Error::Signing { .. } + )); +} + +#[test] +fn the_digest_is_the_txid_bytes() { + // `digest` and `recompute_txid` must not drift: the id a caller checks + // against the node's answer is exactly the value it then signs. + let raw = raw_data(); + assert_eq!( + hex_lower(&digest(&raw).unwrap()), + recompute_txid(&raw).unwrap() + ); + assert!(matches!( + digest("abc").unwrap_err(), + Error::InvalidField { .. } + )); +} + +// ---- verify_contract: the structural check ----------------------------- + +use super::verify_contract; +use crate::tx::proto::encode_varint; + +fn field(number: u64, wire: u64) -> Vec { + encode_varint((number << 3) | wire) +} + +fn bytes_field(number: u64, payload: &[u8]) -> Vec { + let mut out = field(number, 2); + out.extend(encode_varint(payload.len() as u64)); + out.extend(payload); + out +} + +fn varint_field(number: u64, value: u64) -> Vec { + let mut out = field(number, 0); + out.extend(encode_varint(value)); + out +} + +fn to_bytes(address: &str) -> Vec { + hex_decode(&crate::address::tron::to_hex(address).unwrap()) +} + +fn hex_decode(value: &str) -> Vec { + (0..value.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&value[i..i + 2], 16).unwrap()) + .collect() +} + +/// Wrap a contract payload in `Transaction.raw` → `contract` → `Any`. +fn wrap(kind: u64, type_url: &str, payload: &[u8], extra: &[u8]) -> String { + let mut any = bytes_field(1, type_url.as_bytes()); + any.extend(bytes_field(2, payload)); + + let mut contract = varint_field(1, kind); + contract.extend(bytes_field(2, &any)); + + let mut raw = bytes_field(11, &contract); + raw.extend(extra); + hex_lower(&raw) +} + +fn native_raw(to: &str, amount_sun: u64) -> String { + let mut payload = bytes_field(2, &to_bytes(to)); + payload.extend(varint_field(3, amount_sun)); + wrap( + CONTRACT_TYPE_TRANSFER, + "type.googleapis.com/protocol.TransferContract", + &payload, + &[], + ) +} + +fn trc20_raw(contract_address: &str, parameter_hex: &str, fee_limit: Option) -> String { + let mut data = hex_decode(TRC20_TRANSFER_SELECTOR_HEX); + data.extend(hex_decode(parameter_hex)); + + let mut payload = bytes_field(2, &to_bytes(contract_address)); + payload.extend(bytes_field(4, &data)); + + let extra = fee_limit + .map(|limit| varint_field(18, limit)) + .unwrap_or_default(); + wrap( + CONTRACT_TYPE_TRIGGER_SMART_CONTRACT, + "type.googleapis.com/protocol.TriggerSmartContract", + &payload, + &extra, + ) +} + +/// 32-byte-padded recipient and amount, the ERC-20 `transfer` parameters. +fn trc20_parameter(to: &str, amount: u64) -> String { + let recipient = to_bytes(to); + let mut param = vec![0u8; 32]; + // Tron's 21-byte address drops its 0x41 prefix in ABI encoding. + param[12..32].copy_from_slice(&recipient[1..21]); + let mut amount_word = vec![0u8; 32]; + amount_word[24..32].copy_from_slice(&amount.to_be_bytes()); + param.extend(amount_word); + hex_lower(¶m) +} + +#[test] +fn a_well_formed_native_transfer_verifies_structurally() { + let raw = native_raw(TO, 1_000_000); + let id = recompute_txid(&raw).unwrap(); + let transfer = TronTransfer::Native { + amount_sun: 1_000_000, + }; + assert!(verify_contract(&raw, TO, &id, &transfer, None).is_ok()); +} + +#[test] +fn a_native_transfer_for_a_different_amount_is_rejected() { + // The amount is read from `TransferContract.amount` rather than found + // anywhere in the bytes. `verify_transfer` also rejects this one — it + // searches for the amount's varint as a byte run — but it rejects it + // for a reason that happens to coincide, not because it looked at the + // field. The decoy test below is where the two answers diverge. + let raw = native_raw(TO, 1_000_000); + let id = recompute_txid(&raw).unwrap(); + + let transfer = TronTransfer::Native { amount_sun: 42 }; + match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("different native amount")); + } + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_recipient_present_but_not_as_the_to_address_is_rejected() { + // The substring scan's blind spot, made concrete: the requested + // address appears in the bytes — as an unrelated trailing field — + // while `to_address` pays someone else entirely. + let other = "TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH"; + let mut payload = bytes_field(2, &to_bytes(other)); + payload.extend(varint_field(3, 1_000_000)); + // Smuggle the requested recipient in somewhere harmless. + let decoy = bytes_field(99, &to_bytes(TO)); + let raw = wrap( + CONTRACT_TYPE_TRANSFER, + "type.googleapis.com/protocol.TransferContract", + &payload, + &decoy, + ); + let id = recompute_txid(&raw).unwrap(); + let transfer = TronTransfer::Native { + amount_sun: 1_000_000, + }; + + // Both of `verify_transfer`'s checks are satisfied: the requested + // address is present (in the decoy) and so is the amount's varint. + // Neither is the field that will execute. + assert!( + verify_transfer(&raw, TO, &id, &transfer).is_ok(), + "the positional-blind check is fooled by the decoy" + ); + + match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("does not pay the requested recipient")); + } + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_trc20_call_dressed_as_a_native_transfer_is_rejected() { + // Contract type is checked, so a token trigger cannot pass as TRX. + let param = trc20_parameter(TO, 5); + let raw = trc20_raw(TO, ¶m, None); + let id = recompute_txid(&raw).unwrap(); + + let transfer = TronTransfer::Native { amount_sun: 5 }; + match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("not a native transfer")); + } + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_well_formed_trc20_transfer_verifies_structurally() { + let param = trc20_parameter(TO, 5); + let raw = trc20_raw(TO, ¶m, Some(150_000_000)); + let id = recompute_txid(&raw).unwrap(); + let transfer = TronTransfer::Trc20 { + parameter_hex: param, + }; + assert!(verify_contract(&raw, TO, &id, &transfer, Some(150_000_000)).is_ok()); +} + +#[test] +fn trc20_calldata_that_does_not_match_the_request_is_rejected() { + let raw = trc20_raw(TO, &trc20_parameter(TO, 5), None); + let id = recompute_txid(&raw).unwrap(); + // Same recipient, different amount inside the ABI parameters. + let transfer = TronTransfer::Trc20 { + parameter_hex: trc20_parameter(TO, 9_999), + }; + match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("different TRC20 transfer data")); + } + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_trc20_call_smuggling_native_value_is_rejected() { + // call_value is field 3 of TriggerSmartContract. A token transfer + // moves no TRX, so a non-zero value here is TRX leaving the wallet + // alongside the transfer that was actually requested. + let param = trc20_parameter(TO, 5); + let mut data = hex_decode(TRC20_TRANSFER_SELECTOR_HEX); + data.extend(hex_decode(¶m)); + + let mut payload = bytes_field(2, &to_bytes(TO)); + payload.extend(varint_field(3, 1_000_000)); // call_value + payload.extend(bytes_field(4, &data)); + let raw = wrap( + CONTRACT_TYPE_TRIGGER_SMART_CONTRACT, + "type.googleapis.com/protocol.TriggerSmartContract", + &payload, + &[], + ); + let id = recompute_txid(&raw).unwrap(); + + let transfer = TronTransfer::Trc20 { + parameter_hex: param, + }; + match verify_contract(&raw, TO, &id, &transfer, None).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("non-zero TRC20 call_value")); + } + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_raised_fee_limit_is_rejected_when_the_request_pinned_one() { + let param = trc20_parameter(TO, 5); + let raw = trc20_raw(TO, ¶m, Some(9_000_000_000)); + let id = recompute_txid(&raw).unwrap(); + + let transfer = TronTransfer::Trc20 { + parameter_hex: param, + }; + match verify_contract(&raw, TO, &id, &transfer, Some(150_000_000)).unwrap_err() { + Error::UntrustedResponse { reason } => assert!(reason.contains("different fee_limit")), + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} + +#[test] +fn a_second_contract_is_refused_rather_than_checked_once() { + // Two contracts would mean signing something beyond what was verified, + // so the singular read refuses the message outright. + let mut payload = bytes_field(2, &to_bytes(TO)); + payload.extend(varint_field(3, 1_000_000)); + let mut any = bytes_field(1, b"type.googleapis.com/protocol.TransferContract"); + any.extend(bytes_field(2, &payload)); + let mut contract = varint_field(1, CONTRACT_TYPE_TRANSFER); + contract.extend(bytes_field(2, &any)); + + let mut raw = bytes_field(11, &contract); + raw.extend(bytes_field(11, &contract)); + let raw = hex_lower(&raw); + let id = recompute_txid(&raw).unwrap(); + + let transfer = TronTransfer::Native { + amount_sun: 1_000_000, + }; + assert!(verify_contract(&raw, TO, &id, &transfer, None).is_err()); +} + +#[test] +fn a_tampered_raw_data_fails_the_structural_check_too() { + let raw = native_raw(TO, 1_000_000); + let id = recompute_txid(&raw).unwrap(); + let tampered = native_raw(TO, 1_000_001); + let transfer = TronTransfer::Native { + amount_sun: 1_000_000, + }; + match verify_contract(&tampered, TO, &id, &transfer, None).unwrap_err() { + Error::UntrustedResponse { reason } => assert!(reason.contains("altered")), + other => panic!("expected UntrustedResponse, got {other:?}"), + } +} diff --git a/crates/tinywallet-x402/src/wire/types.rs b/crates/tinywallet-x402/src/wire/types.rs index 2ee370e..4e274af 100644 --- a/crates/tinywallet-x402/src/wire/types.rs +++ b/crates/tinywallet-x402/src/wire/types.rs @@ -342,172 +342,5 @@ impl PaymentRequirements { } #[cfg(test)] -mod test { - #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - - use super::{ - BASE_MAINNET_CAIP2, PaymentChain, PaymentRequired, PaymentRequirements, - SOLANA_MAINNET_CAIP2, X402_VERSION, - }; - - fn requirement(scheme: &str, network: &str) -> PaymentRequirements { - PaymentRequirements { - scheme: scheme.to_string(), - network: network.to_string(), - amount: "1000000".to_string(), - asset: super::USDC_MINT_MAINNET.to_string(), - pay_to: "11111111111111111111111111111111".to_string(), - max_timeout_seconds: 60, - extra: None, - } - } - - fn challenge(accepts: Vec) -> PaymentRequired { - PaymentRequired { - x402_version: X402_VERSION, - error: None, - resource: super::ResourceInfo { - url: "https://example.test/thing".to_string(), - description: None, - mime_type: None, - }, - accepts, - extensions: serde_json::Map::new(), - } - } - - #[test] - fn only_the_exact_scheme_is_selected() { - // A server may offer schemes this crate cannot pay; picking one of - // those would produce a proof the facilitator rejects. - let c = challenge(vec![requirement("upto", SOLANA_MAINNET_CAIP2)]); - assert!(c.solana_exact_requirement().is_none()); - assert!(c.best_exact_requirement().is_none()); - } - - #[test] - fn requirements_are_matched_by_network_prefix_not_exact_string() { - // CAIP-2 names a specific chain, so devnet and mainnet differ — but - // both are Solana, and the selector must accept either. - let c = challenge(vec![requirement("exact", super::SOLANA_DEVNET_CAIP2)]); - assert!(c.solana_exact_requirement().is_some()); - - let c = challenge(vec![requirement("exact", super::BASE_SEPOLIA_CAIP2)]); - assert!(c.evm_exact_requirement().is_some()); - } - - #[test] - fn solana_is_preferred_when_both_are_offered() { - // Pinning the documented order: which chain a payer spends from is - // observable behaviour, not an implementation detail. - let c = challenge(vec![ - requirement("exact", BASE_MAINNET_CAIP2), - requirement("exact", SOLANA_MAINNET_CAIP2), - ]); - let (_, chain) = c.best_exact_requirement().unwrap(); - assert_eq!(chain, PaymentChain::Solana); - } - - #[test] - fn evm_is_used_when_it_is_the_only_option() { - let c = challenge(vec![requirement("exact", BASE_MAINNET_CAIP2)]); - let (req, chain) = c.best_exact_requirement().unwrap(); - assert_eq!(chain, PaymentChain::Evm); - assert_eq!(req.evm_chain_id(), Some(8453)); - } - - #[test] - fn the_evm_chain_id_is_parsed_from_the_caip2_network() { - assert_eq!( - requirement("exact", "eip155:1").evm_chain_id(), - Some(1), - "ethereum mainnet" - ); - assert_eq!( - requirement("exact", SOLANA_MAINNET_CAIP2).evm_chain_id(), - None, - "a Solana network has no EVM chain id" - ); - assert_eq!( - requirement("exact", "eip155:notanumber").evm_chain_id(), - None - ); - } - - #[test] - fn amounts_stay_strings_through_a_json_round_trip() { - // The reason the protocol uses strings: a u64 amount through a - // double-based JSON parser can come back as a different number. - let mut req = requirement("exact", SOLANA_MAINNET_CAIP2); - req.amount = "18446744073709551615".to_string(); // u64::MAX - let json = serde_json::to_string(&req).unwrap(); - let back: PaymentRequirements = serde_json::from_str(&json).unwrap(); - assert_eq!(back.amount, "18446744073709551615"); - } - - #[test] - fn the_wire_shape_is_camel_case() { - // The header payload is read by facilitators in other languages, so - // the field names are part of the contract. - let json = serde_json::to_string(&requirement("exact", SOLANA_MAINNET_CAIP2)).unwrap(); - assert!(json.contains("\"payTo\""), "{json}"); - assert!(json.contains("\"maxTimeoutSeconds\""), "{json}"); - assert!(!json.contains("pay_to"), "{json}"); - } - - #[test] - fn a_payment_proof_serialises_untagged() { - // The facilitator sees the chain-specific object directly, with no - // enum discriminant wrapping it. - let solana = super::PaymentProof::Solana(super::SolanaPaymentProof { - transaction: "base64tx".to_string(), - }); - let json = serde_json::to_string(&solana).unwrap(); - assert_eq!(json, r#"{"transaction":"base64tx"}"#); - - let evm = super::PaymentProof::Evm(super::EvmPaymentProof { - signature: "0xsig".to_string(), - authorization: super::EvmAuthorization { - from: "0xa".to_string(), - to: "0xb".to_string(), - value: "1".to_string(), - valid_after: "0".to_string(), - valid_before: "99".to_string(), - nonce: "0xn".to_string(), - }, - }); - let json = serde_json::to_string(&evm).unwrap(); - assert!(json.starts_with(r#"{"signature":"0xsig""#), "{json}"); - assert!(json.contains("\"validBefore\""), "{json}"); - } - - #[test] - fn optional_fields_are_omitted_rather_than_sent_as_null() { - let json = serde_json::to_string(&requirement("exact", SOLANA_MAINNET_CAIP2)).unwrap(); - assert!(!json.contains("extra"), "absent extras are omitted: {json}"); - } - - #[test] - fn a_challenge_round_trips() { - let c = challenge(vec![requirement("exact", SOLANA_MAINNET_CAIP2)]); - let json = serde_json::to_string(&c).unwrap(); - let back: PaymentRequired = serde_json::from_str(&json).unwrap(); - assert_eq!(back.x402_version, X402_VERSION); - assert_eq!(back.accepts.len(), 1); - assert_eq!(back.resource.url, "https://example.test/thing"); - } - - #[test] - fn unknown_extension_fields_are_preserved_not_rejected() { - // Unlike the document spec, this is a protocol other implementations - // extend, so an unknown key must not fail the parse. - let json = r#"{ - "x402Version": 2, - "resource": { "url": "https://example.test" }, - "accepts": [], - "extensions": { "somethingNew": true } - }"#; - let parsed: PaymentRequired = serde_json::from_str(json).unwrap(); - assert!(parsed.extensions.contains_key("somethingNew")); - } -} +#[path = "types_test_tests.rs"] +mod test; diff --git a/crates/tinywallet-x402/src/wire/types_test_tests.rs b/crates/tinywallet-x402/src/wire/types_test_tests.rs new file mode 100644 index 0000000..02c7a12 --- /dev/null +++ b/crates/tinywallet-x402/src/wire/types_test_tests.rs @@ -0,0 +1,167 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{ + BASE_MAINNET_CAIP2, PaymentChain, PaymentRequired, PaymentRequirements, SOLANA_MAINNET_CAIP2, + X402_VERSION, +}; + +fn requirement(scheme: &str, network: &str) -> PaymentRequirements { + PaymentRequirements { + scheme: scheme.to_string(), + network: network.to_string(), + amount: "1000000".to_string(), + asset: super::USDC_MINT_MAINNET.to_string(), + pay_to: "11111111111111111111111111111111".to_string(), + max_timeout_seconds: 60, + extra: None, + } +} + +fn challenge(accepts: Vec) -> PaymentRequired { + PaymentRequired { + x402_version: X402_VERSION, + error: None, + resource: super::ResourceInfo { + url: "https://example.test/thing".to_string(), + description: None, + mime_type: None, + }, + accepts, + extensions: serde_json::Map::new(), + } +} + +#[test] +fn only_the_exact_scheme_is_selected() { + // A server may offer schemes this crate cannot pay; picking one of + // those would produce a proof the facilitator rejects. + let c = challenge(vec![requirement("upto", SOLANA_MAINNET_CAIP2)]); + assert!(c.solana_exact_requirement().is_none()); + assert!(c.best_exact_requirement().is_none()); +} + +#[test] +fn requirements_are_matched_by_network_prefix_not_exact_string() { + // CAIP-2 names a specific chain, so devnet and mainnet differ — but + // both are Solana, and the selector must accept either. + let c = challenge(vec![requirement("exact", super::SOLANA_DEVNET_CAIP2)]); + assert!(c.solana_exact_requirement().is_some()); + + let c = challenge(vec![requirement("exact", super::BASE_SEPOLIA_CAIP2)]); + assert!(c.evm_exact_requirement().is_some()); +} + +#[test] +fn solana_is_preferred_when_both_are_offered() { + // Pinning the documented order: which chain a payer spends from is + // observable behaviour, not an implementation detail. + let c = challenge(vec![ + requirement("exact", BASE_MAINNET_CAIP2), + requirement("exact", SOLANA_MAINNET_CAIP2), + ]); + let (_, chain) = c.best_exact_requirement().unwrap(); + assert_eq!(chain, PaymentChain::Solana); +} + +#[test] +fn evm_is_used_when_it_is_the_only_option() { + let c = challenge(vec![requirement("exact", BASE_MAINNET_CAIP2)]); + let (req, chain) = c.best_exact_requirement().unwrap(); + assert_eq!(chain, PaymentChain::Evm); + assert_eq!(req.evm_chain_id(), Some(8453)); +} + +#[test] +fn the_evm_chain_id_is_parsed_from_the_caip2_network() { + assert_eq!( + requirement("exact", "eip155:1").evm_chain_id(), + Some(1), + "ethereum mainnet" + ); + assert_eq!( + requirement("exact", SOLANA_MAINNET_CAIP2).evm_chain_id(), + None, + "a Solana network has no EVM chain id" + ); + assert_eq!( + requirement("exact", "eip155:notanumber").evm_chain_id(), + None + ); +} + +#[test] +fn amounts_stay_strings_through_a_json_round_trip() { + // The reason the protocol uses strings: a u64 amount through a + // double-based JSON parser can come back as a different number. + let mut req = requirement("exact", SOLANA_MAINNET_CAIP2); + req.amount = "18446744073709551615".to_string(); // u64::MAX + let json = serde_json::to_string(&req).unwrap(); + let back: PaymentRequirements = serde_json::from_str(&json).unwrap(); + assert_eq!(back.amount, "18446744073709551615"); +} + +#[test] +fn the_wire_shape_is_camel_case() { + // The header payload is read by facilitators in other languages, so + // the field names are part of the contract. + let json = serde_json::to_string(&requirement("exact", SOLANA_MAINNET_CAIP2)).unwrap(); + assert!(json.contains("\"payTo\""), "{json}"); + assert!(json.contains("\"maxTimeoutSeconds\""), "{json}"); + assert!(!json.contains("pay_to"), "{json}"); +} + +#[test] +fn a_payment_proof_serialises_untagged() { + // The facilitator sees the chain-specific object directly, with no + // enum discriminant wrapping it. + let solana = super::PaymentProof::Solana(super::SolanaPaymentProof { + transaction: "base64tx".to_string(), + }); + let json = serde_json::to_string(&solana).unwrap(); + assert_eq!(json, r#"{"transaction":"base64tx"}"#); + + let evm = super::PaymentProof::Evm(super::EvmPaymentProof { + signature: "0xsig".to_string(), + authorization: super::EvmAuthorization { + from: "0xa".to_string(), + to: "0xb".to_string(), + value: "1".to_string(), + valid_after: "0".to_string(), + valid_before: "99".to_string(), + nonce: "0xn".to_string(), + }, + }); + let json = serde_json::to_string(&evm).unwrap(); + assert!(json.starts_with(r#"{"signature":"0xsig""#), "{json}"); + assert!(json.contains("\"validBefore\""), "{json}"); +} + +#[test] +fn optional_fields_are_omitted_rather_than_sent_as_null() { + let json = serde_json::to_string(&requirement("exact", SOLANA_MAINNET_CAIP2)).unwrap(); + assert!(!json.contains("extra"), "absent extras are omitted: {json}"); +} + +#[test] +fn a_challenge_round_trips() { + let c = challenge(vec![requirement("exact", SOLANA_MAINNET_CAIP2)]); + let json = serde_json::to_string(&c).unwrap(); + let back: PaymentRequired = serde_json::from_str(&json).unwrap(); + assert_eq!(back.x402_version, X402_VERSION); + assert_eq!(back.accepts.len(), 1); + assert_eq!(back.resource.url, "https://example.test/thing"); +} + +#[test] +fn unknown_extension_fields_are_preserved_not_rejected() { + // Unlike the document spec, this is a protocol other implementations + // extend, so an unknown key must not fail the parse. + let json = r#"{ + "x402Version": 2, + "resource": { "url": "https://example.test" }, + "accepts": [], + "extensions": { "somethingNew": true } + }"#; + let parsed: PaymentRequired = serde_json::from_str(json).unwrap(); + assert!(parsed.extensions.contains_key("somethingNew")); +} diff --git a/src/tx/btc.rs b/src/tx/btc.rs index 022cc87..feda4e0 100644 --- a/src/tx/btc.rs +++ b/src/tx/btc.rs @@ -363,290 +363,5 @@ fn script_pubkey(address: &str) -> Result { } #[cfg(test)] -mod test { - #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - - use super::{DUST_THRESHOLD, Transfer, Utxo, select_coins}; - use crate::tx::Error; - - const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ - abandon abandon abandon abandon abandon about"; - const PATH: &str = "m/84'/0'/0'/0/0"; - /// The BIP-84 vector address, which the key below controls. - const FROM: &str = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"; - const TO: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; - const TXID: &str = "7f3b662ea8b6ff2e0e1a1f9bd0f1c39a6b8ba51e1b0f0e0d0c0b0a0908070605"; - - fn key() -> Vec { - crate::key::derive(crate::Chain::Btc, VECTOR, PATH) - .unwrap() - .secret_bytes() - .to_vec() - } - - fn utxo(value: u64, vout: u32) -> Utxo { - Utxo { - txid: TXID.to_string(), - vout, - value, - } - } - - fn transfer(amount: u64, fee: u64) -> Transfer { - Transfer { - from: FROM.to_string(), - to: TO.to_string(), - amount, - fee, - } - } - - #[test] - fn selection_takes_the_largest_coins_first() { - let utxos = [utxo(1_000, 0), utxo(50_000, 1), utxo(10_000, 2)]; - let selection = select_coins(&utxos, 40_000).unwrap(); - assert_eq!(selection.inputs.len(), 1, "one big coin suffices"); - assert_eq!(selection.inputs[0].value, 50_000); - assert_eq!(selection.change, 10_000); - } - - #[test] - fn selection_accumulates_until_the_target_is_met() { - let utxos = [utxo(10_000, 0), utxo(10_000, 1), utxo(10_000, 2)]; - let selection = select_coins(&utxos, 25_000).unwrap(); - assert_eq!(selection.inputs.len(), 3); - assert_eq!(selection.change, 5_000); - } - - #[test] - fn dust_change_is_folded_into_the_fee_not_emitted() { - // A sub-dust output is unspendable and makes the transaction - // unrelayable, so it must not be created. - let utxos = [utxo(10_000 + DUST_THRESHOLD, 0)]; - let selection = select_coins(&utxos, 10_000).unwrap(); - assert_eq!(selection.change, 0, "dust surplus goes to the fee"); - - let (tx, _) = transfer(9_000, 1_000 + DUST_THRESHOLD) - .build(&utxos) - .unwrap(); - assert_eq!(tx.output.len(), 1, "no dust change output"); - } - - #[test] - fn change_above_the_dust_threshold_is_emitted() { - let utxos = [utxo(100_000, 0)]; - let (tx, selection) = transfer(50_000, 1_000).build(&utxos).unwrap(); - assert_eq!(selection.change, 49_000); - assert_eq!(tx.output.len(), 2, "recipient plus change"); - assert_eq!(tx.output[1].value.to_sat(), 49_000); - } - - #[test] - fn insufficient_funds_reports_both_sides() { - // The one failure a caller can act on, so it names the numbers. - match select_coins(&[utxo(1_000, 0)], 5_000).unwrap_err() { - Error::InsufficientFunds { - available, - required, - } => { - assert_eq!(available, 1_000); - assert_eq!(required, 5_000); - } - other => panic!("expected InsufficientFunds, got {other:?}"), - } - assert!(matches!( - select_coins(&[], 1).unwrap_err(), - Error::InsufficientFunds { available: 0, .. } - )); - } - - #[test] - fn the_fee_is_exactly_inputs_minus_outputs() { - // Bitcoin's fee is implicit, so this is the invariant that stops a - // forgotten change output paying the balance to miners. - let utxos = [utxo(100_000, 0)]; - let (tx, _) = transfer(30_000, 2_000).build(&utxos).unwrap(); - let out: u64 = tx.output.iter().map(|o| o.value.to_sat()).sum(); - assert_eq!( - 100_000 - out, - 2_000, - "implicit fee must equal the stated fee" - ); - } - - #[test] - fn every_input_is_signed_with_a_witness() { - let utxos = [utxo(60_000, 0), utxo(60_000, 1)]; - let hex = transfer(100_000, 1_000).sign(&utxos, &key()).unwrap(); - assert!(!hex.is_empty()); - // Segwit marker and flag follow the 4-byte version in the serialised - // form: 02000000 then 0001. - assert!(hex.starts_with("020000000001"), "{hex}"); - } - - #[test] - fn the_transaction_opts_into_replace_by_fee() { - // A transfer stuck at a low fee should be bumpable rather than left - // to sit in the mempool. - let (tx, _) = transfer(10_000, 500).build(&[utxo(50_000, 0)]).unwrap(); - assert!(tx.input[0].sequence.is_rbf()); - } - - #[test] - fn a_key_that_does_not_control_the_sender_is_rejected() { - let other = crate::key::derive(crate::Chain::Btc, VECTOR, "m/84'/0'/0'/0/1") - .unwrap() - .secret_bytes() - .to_vec(); - match transfer(10_000, 500) - .sign(&[utxo(50_000, 0)], &other) - .unwrap_err() - { - Error::Signing { reason } => assert!(reason.contains("does not control")), - other => panic!("expected Signing, got {other:?}"), - } - } - - #[test] - fn a_non_p2wpkh_sender_is_rejected() { - // The signing path only implements P2WPKH; a legacy sender would fail - // much later, after a transaction had been assembled. - let legacy = Transfer { - from: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2".to_string(), - ..transfer(1_000, 100) - }; - assert!(matches!( - legacy.build(&[utxo(50_000, 0)]), - Err(Error::Address(_)) - )); - } - - #[test] - fn any_address_type_is_accepted_as_a_recipient() { - // Paying to P2PKH, P2SH or P2TR is the same operation. - for to in [ - "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", - "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", - "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", - ] { - let t = Transfer { - to: to.to_string(), - ..transfer(10_000, 500) - }; - assert!( - t.build(&[utxo(50_000, 0)]).is_ok(), - "{to} should be payable" - ); - } - } - - #[test] - fn a_malformed_txid_is_rejected() { - let bad = Utxo { - txid: "not-a-txid".to_string(), - vout: 0, - value: 50_000, - }; - match transfer(1_000, 100).build(&[bad]).unwrap_err() { - Error::InvalidField { field, .. } => assert_eq!(field, "utxo.txid"), - other => panic!("expected InvalidField, got {other:?}"), - } - } - - #[test] - fn signing_is_deterministic() { - let utxos = [utxo(50_000, 0)]; - let t = transfer(10_000, 500); - assert_eq!( - t.sign(&utxos, &key()).unwrap(), - t.sign(&utxos, &key()).unwrap() - ); - } - - #[test] - fn changing_an_input_value_changes_the_signature() { - // BIP-143 commits to each input's value, which is what stopped the - // fee-inflation attack legacy sighash allowed. - let t = transfer(10_000, 500); - let a = t.sign(&[utxo(50_000, 0)], &key()).unwrap(); - let b = t.sign(&[utxo(60_000, 0)], &key()).unwrap(); - assert_ne!(a, b, "the input value must reach the sighash"); - } - - /// The compressed public key for the test mnemonic's P2WPKH account. - fn public_key() -> [u8; 33] { - use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; - let secret = SecretKey::from_slice(&key()).unwrap(); - PublicKey::from_secret_key(&Secp256k1::new(), &secret).serialize() - } - - #[test] - fn split_signing_matches_one_shot_signing_across_several_inputs() { - // Several inputs on purpose: Bitcoin is the only chain here needing - // more than one signature, and the split contract is that they come - // back in input order. A transposition would still produce a - // well-formed transaction — just an unspendable one — so the two - // paths are compared byte-for-byte. - use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; - - let utxos = [utxo(60_000, 0), utxo(70_000, 1), utxo(80_000, 2)]; - let transfer = transfer(150_000, 2_000); - let public = public_key(); - - let one_shot = transfer.sign(&utxos, &key()).unwrap(); - - let (selection, digests) = transfer.sighashes(&utxos, &public).unwrap(); - assert!( - digests.len() > 1, - "the fixture must actually select several inputs" - ); - assert_eq!(digests.len(), selection.inputs.len()); - - let secret = SecretKey::from_slice(&key()).unwrap(); - let secp = Secp256k1::signing_only(); - let signatures: Vec<[u8; 64]> = digests - .into_iter() - .map(|digest| { - secp.sign_ecdsa(&Message::from_digest(digest), &secret) - .serialize_compact() - }) - .collect(); - - let split = transfer - .attach_signatures(&utxos, &public, &signatures) - .unwrap(); - - assert_eq!(split, one_shot); - } - - #[test] - fn a_public_key_that_does_not_control_the_sender_is_refused() { - // Both halves must refuse, not just the first: a host could call - // `attach_signatures` without ever calling `sighashes`. - let utxos = [utxo(100_000, 0)]; - let transfer = transfer(50_000, 1_000); - let wrong = [0x02u8; 33]; - - assert!(matches!( - transfer.sighashes(&utxos, &wrong), - Err(Error::Signing { .. }) - )); - assert!(matches!( - transfer.attach_signatures(&utxos, &wrong, &[[0u8; 64]]), - Err(Error::Signing { .. }) - )); - } - - #[test] - fn a_signature_count_that_does_not_match_the_inputs_is_refused() { - // Silently zipping would leave later inputs with an empty witness and - // broadcast an unspendable transaction, paying the fee for nothing. - let utxos = [utxo(60_000, 0), utxo(70_000, 1), utxo(80_000, 2)]; - let transfer = transfer(150_000, 2_000); - - let error = transfer - .attach_signatures(&utxos, &public_key(), &[[0x11; 64]]) - .unwrap_err(); - assert!(matches!(error, Error::Signing { .. }), "{error:?}"); - } -} +#[path = "btc_test_tests.rs"] +mod test; diff --git a/src/tx/btc_test_tests.rs b/src/tx/btc_test_tests.rs new file mode 100644 index 0000000..25d1c3f --- /dev/null +++ b/src/tx/btc_test_tests.rs @@ -0,0 +1,285 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{DUST_THRESHOLD, Transfer, Utxo, select_coins}; +use crate::tx::Error; + +const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; +const PATH: &str = "m/84'/0'/0'/0/0"; +/// The BIP-84 vector address, which the key below controls. +const FROM: &str = "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"; +const TO: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; +const TXID: &str = "7f3b662ea8b6ff2e0e1a1f9bd0f1c39a6b8ba51e1b0f0e0d0c0b0a0908070605"; + +fn key() -> Vec { + crate::key::derive(crate::Chain::Btc, VECTOR, PATH) + .unwrap() + .secret_bytes() + .to_vec() +} + +fn utxo(value: u64, vout: u32) -> Utxo { + Utxo { + txid: TXID.to_string(), + vout, + value, + } +} + +fn transfer(amount: u64, fee: u64) -> Transfer { + Transfer { + from: FROM.to_string(), + to: TO.to_string(), + amount, + fee, + } +} + +#[test] +fn selection_takes_the_largest_coins_first() { + let utxos = [utxo(1_000, 0), utxo(50_000, 1), utxo(10_000, 2)]; + let selection = select_coins(&utxos, 40_000).unwrap(); + assert_eq!(selection.inputs.len(), 1, "one big coin suffices"); + assert_eq!(selection.inputs[0].value, 50_000); + assert_eq!(selection.change, 10_000); +} + +#[test] +fn selection_accumulates_until_the_target_is_met() { + let utxos = [utxo(10_000, 0), utxo(10_000, 1), utxo(10_000, 2)]; + let selection = select_coins(&utxos, 25_000).unwrap(); + assert_eq!(selection.inputs.len(), 3); + assert_eq!(selection.change, 5_000); +} + +#[test] +fn dust_change_is_folded_into_the_fee_not_emitted() { + // A sub-dust output is unspendable and makes the transaction + // unrelayable, so it must not be created. + let utxos = [utxo(10_000 + DUST_THRESHOLD, 0)]; + let selection = select_coins(&utxos, 10_000).unwrap(); + assert_eq!(selection.change, 0, "dust surplus goes to the fee"); + + let (tx, _) = transfer(9_000, 1_000 + DUST_THRESHOLD) + .build(&utxos) + .unwrap(); + assert_eq!(tx.output.len(), 1, "no dust change output"); +} + +#[test] +fn change_above_the_dust_threshold_is_emitted() { + let utxos = [utxo(100_000, 0)]; + let (tx, selection) = transfer(50_000, 1_000).build(&utxos).unwrap(); + assert_eq!(selection.change, 49_000); + assert_eq!(tx.output.len(), 2, "recipient plus change"); + assert_eq!(tx.output[1].value.to_sat(), 49_000); +} + +#[test] +fn insufficient_funds_reports_both_sides() { + // The one failure a caller can act on, so it names the numbers. + match select_coins(&[utxo(1_000, 0)], 5_000).unwrap_err() { + Error::InsufficientFunds { + available, + required, + } => { + assert_eq!(available, 1_000); + assert_eq!(required, 5_000); + } + other => panic!("expected InsufficientFunds, got {other:?}"), + } + assert!(matches!( + select_coins(&[], 1).unwrap_err(), + Error::InsufficientFunds { available: 0, .. } + )); +} + +#[test] +fn the_fee_is_exactly_inputs_minus_outputs() { + // Bitcoin's fee is implicit, so this is the invariant that stops a + // forgotten change output paying the balance to miners. + let utxos = [utxo(100_000, 0)]; + let (tx, _) = transfer(30_000, 2_000).build(&utxos).unwrap(); + let out: u64 = tx.output.iter().map(|o| o.value.to_sat()).sum(); + assert_eq!( + 100_000 - out, + 2_000, + "implicit fee must equal the stated fee" + ); +} + +#[test] +fn every_input_is_signed_with_a_witness() { + let utxos = [utxo(60_000, 0), utxo(60_000, 1)]; + let hex = transfer(100_000, 1_000).sign(&utxos, &key()).unwrap(); + assert!(!hex.is_empty()); + // Segwit marker and flag follow the 4-byte version in the serialised + // form: 02000000 then 0001. + assert!(hex.starts_with("020000000001"), "{hex}"); +} + +#[test] +fn the_transaction_opts_into_replace_by_fee() { + // A transfer stuck at a low fee should be bumpable rather than left + // to sit in the mempool. + let (tx, _) = transfer(10_000, 500).build(&[utxo(50_000, 0)]).unwrap(); + assert!(tx.input[0].sequence.is_rbf()); +} + +#[test] +fn a_key_that_does_not_control_the_sender_is_rejected() { + let other = crate::key::derive(crate::Chain::Btc, VECTOR, "m/84'/0'/0'/0/1") + .unwrap() + .secret_bytes() + .to_vec(); + match transfer(10_000, 500) + .sign(&[utxo(50_000, 0)], &other) + .unwrap_err() + { + Error::Signing { reason } => assert!(reason.contains("does not control")), + other => panic!("expected Signing, got {other:?}"), + } +} + +#[test] +fn a_non_p2wpkh_sender_is_rejected() { + // The signing path only implements P2WPKH; a legacy sender would fail + // much later, after a transaction had been assembled. + let legacy = Transfer { + from: "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2".to_string(), + ..transfer(1_000, 100) + }; + assert!(matches!( + legacy.build(&[utxo(50_000, 0)]), + Err(Error::Address(_)) + )); +} + +#[test] +fn any_address_type_is_accepted_as_a_recipient() { + // Paying to P2PKH, P2SH or P2TR is the same operation. + for to in [ + "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + ] { + let t = Transfer { + to: to.to_string(), + ..transfer(10_000, 500) + }; + assert!( + t.build(&[utxo(50_000, 0)]).is_ok(), + "{to} should be payable" + ); + } +} + +#[test] +fn a_malformed_txid_is_rejected() { + let bad = Utxo { + txid: "not-a-txid".to_string(), + vout: 0, + value: 50_000, + }; + match transfer(1_000, 100).build(&[bad]).unwrap_err() { + Error::InvalidField { field, .. } => assert_eq!(field, "utxo.txid"), + other => panic!("expected InvalidField, got {other:?}"), + } +} + +#[test] +fn signing_is_deterministic() { + let utxos = [utxo(50_000, 0)]; + let t = transfer(10_000, 500); + assert_eq!( + t.sign(&utxos, &key()).unwrap(), + t.sign(&utxos, &key()).unwrap() + ); +} + +#[test] +fn changing_an_input_value_changes_the_signature() { + // BIP-143 commits to each input's value, which is what stopped the + // fee-inflation attack legacy sighash allowed. + let t = transfer(10_000, 500); + let a = t.sign(&[utxo(50_000, 0)], &key()).unwrap(); + let b = t.sign(&[utxo(60_000, 0)], &key()).unwrap(); + assert_ne!(a, b, "the input value must reach the sighash"); +} + +/// The compressed public key for the test mnemonic's P2WPKH account. +fn public_key() -> [u8; 33] { + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + let secret = SecretKey::from_slice(&key()).unwrap(); + PublicKey::from_secret_key(&Secp256k1::new(), &secret).serialize() +} + +#[test] +fn split_signing_matches_one_shot_signing_across_several_inputs() { + // Several inputs on purpose: Bitcoin is the only chain here needing + // more than one signature, and the split contract is that they come + // back in input order. A transposition would still produce a + // well-formed transaction — just an unspendable one — so the two + // paths are compared byte-for-byte. + use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; + + let utxos = [utxo(60_000, 0), utxo(70_000, 1), utxo(80_000, 2)]; + let transfer = transfer(150_000, 2_000); + let public = public_key(); + + let one_shot = transfer.sign(&utxos, &key()).unwrap(); + + let (selection, digests) = transfer.sighashes(&utxos, &public).unwrap(); + assert!( + digests.len() > 1, + "the fixture must actually select several inputs" + ); + assert_eq!(digests.len(), selection.inputs.len()); + + let secret = SecretKey::from_slice(&key()).unwrap(); + let secp = Secp256k1::signing_only(); + let signatures: Vec<[u8; 64]> = digests + .into_iter() + .map(|digest| { + secp.sign_ecdsa(&Message::from_digest(digest), &secret) + .serialize_compact() + }) + .collect(); + + let split = transfer + .attach_signatures(&utxos, &public, &signatures) + .unwrap(); + + assert_eq!(split, one_shot); +} + +#[test] +fn a_public_key_that_does_not_control_the_sender_is_refused() { + // Both halves must refuse, not just the first: a host could call + // `attach_signatures` without ever calling `sighashes`. + let utxos = [utxo(100_000, 0)]; + let transfer = transfer(50_000, 1_000); + let wrong = [0x02u8; 33]; + + assert!(matches!( + transfer.sighashes(&utxos, &wrong), + Err(Error::Signing { .. }) + )); + assert!(matches!( + transfer.attach_signatures(&utxos, &wrong, &[[0u8; 64]]), + Err(Error::Signing { .. }) + )); +} + +#[test] +fn a_signature_count_that_does_not_match_the_inputs_is_refused() { + // Silently zipping would leave later inputs with an empty witness and + // broadcast an unspendable transaction, paying the fee for nothing. + let utxos = [utxo(60_000, 0), utxo(70_000, 1), utxo(80_000, 2)]; + let transfer = transfer(150_000, 2_000); + + let error = transfer + .attach_signatures(&utxos, &public_key(), &[[0x11; 64]]) + .unwrap_err(); + assert!(matches!(error, Error::Signing { .. }), "{error:?}"); +} diff --git a/src/tx/rlp.rs b/src/tx/rlp.rs index 14a31bb..542cf3c 100644 --- a/src/tx/rlp.rs +++ b/src/tx/rlp.rs @@ -89,100 +89,5 @@ fn encode_length(len: usize, offset: u8) -> Vec { } #[cfg(test)] -mod test { - #![allow(clippy::unwrap_used, clippy::panic)] - - use super::{encode_bytes, encode_list, encode_uint, encode_uint_bytes}; - - #[test] - fn a_single_low_byte_is_itself() { - assert_eq!(encode_bytes(&[0x00]), vec![0x00]); - assert_eq!(encode_bytes(&[0x7f]), vec![0x7f]); - } - - #[test] - fn a_single_high_byte_takes_a_prefix() { - // 0x80 is not below 0x80, so it is a one-byte string, not a bare byte. - assert_eq!(encode_bytes(&[0x80]), vec![0x81, 0x80]); - } - - #[test] - fn an_empty_string_is_0x80() { - assert_eq!(encode_bytes(&[]), vec![0x80]); - } - - #[test] - fn short_strings_take_a_single_length_prefix() { - // "dog" — the specification's own example. - assert_eq!(encode_bytes(b"dog"), vec![0x83, b'd', b'o', b'g']); - } - - #[test] - fn strings_longer_than_55_bytes_take_a_length_of_length() { - let payload = vec![0xaa_u8; 56]; - let encoded = encode_bytes(&payload); - assert_eq!(encoded[0], 0xb8, "0xb7 + 1 length byte"); - assert_eq!(encoded[1], 56); - assert_eq!(encoded.len(), 58); - - let long = vec![0xbb_u8; 1024]; - let encoded = encode_bytes(&long); - assert_eq!(encoded[0], 0xb9, "0xb7 + 2 length bytes"); - assert_eq!(&encoded[1..3], &[0x04, 0x00]); - } - - #[test] - fn zero_encodes_as_the_empty_string_not_as_a_zero_byte() { - // The rule that silently changes what a signature commits to. - assert_eq!(encode_uint(0), vec![0x80]); - assert_ne!(encode_uint(0), vec![0x00]); - } - - #[test] - fn integers_carry_no_leading_zeros() { - assert_eq!(encode_uint(1), vec![0x01]); - assert_eq!(encode_uint(127), vec![0x7f]); - assert_eq!(encode_uint(128), vec![0x81, 0x80]); - assert_eq!(encode_uint(1024), vec![0x82, 0x04, 0x00]); - // 20 gwei, from the EIP-155 vector. - assert_eq!( - encode_uint(20_000_000_000), - vec![0x85, 0x04, 0xa8, 0x17, 0xc8, 0x00] - ); - } - - #[test] - fn integer_byte_slices_are_stripped_the_same_way() { - let mut padded = [0u8; 32]; - padded[31] = 1; - assert_eq!(encode_uint_bytes(&padded), vec![0x01]); - assert_eq!( - encode_uint_bytes(&[0u8; 32]), - vec![0x80], - "all-zero is empty" - ); - } - - #[test] - fn an_empty_list_is_0xc0() { - assert_eq!(encode_list(&[]), vec![0xc0]); - } - - #[test] - fn a_list_prefixes_the_concatenated_encodings() { - // ["cat", "dog"] from the specification. - let items = vec![encode_bytes(b"cat"), encode_bytes(b"dog")]; - assert_eq!( - encode_list(&items), - vec![0xc8, 0x83, b'c', b'a', b't', 0x83, b'd', b'o', b'g'] - ); - } - - #[test] - fn a_long_list_takes_a_length_of_length() { - let items: Vec> = (0..30).map(|_| encode_bytes(&[0xcc_u8; 2])).collect(); - let encoded = encode_list(&items); - assert_eq!(encoded[0], 0xf8, "0xf7 + 1 length byte"); - assert_eq!(encoded[1], 90, "30 items x 3 bytes each"); - } -} +#[path = "rlp_test_tests.rs"] +mod test; diff --git a/src/tx/rlp_test_tests.rs b/src/tx/rlp_test_tests.rs new file mode 100644 index 0000000..1ce70c5 --- /dev/null +++ b/src/tx/rlp_test_tests.rs @@ -0,0 +1,95 @@ +#![allow(clippy::unwrap_used, clippy::panic)] + +use super::{encode_bytes, encode_list, encode_uint, encode_uint_bytes}; + +#[test] +fn a_single_low_byte_is_itself() { + assert_eq!(encode_bytes(&[0x00]), vec![0x00]); + assert_eq!(encode_bytes(&[0x7f]), vec![0x7f]); +} + +#[test] +fn a_single_high_byte_takes_a_prefix() { + // 0x80 is not below 0x80, so it is a one-byte string, not a bare byte. + assert_eq!(encode_bytes(&[0x80]), vec![0x81, 0x80]); +} + +#[test] +fn an_empty_string_is_0x80() { + assert_eq!(encode_bytes(&[]), vec![0x80]); +} + +#[test] +fn short_strings_take_a_single_length_prefix() { + // "dog" — the specification's own example. + assert_eq!(encode_bytes(b"dog"), vec![0x83, b'd', b'o', b'g']); +} + +#[test] +fn strings_longer_than_55_bytes_take_a_length_of_length() { + let payload = vec![0xaa_u8; 56]; + let encoded = encode_bytes(&payload); + assert_eq!(encoded[0], 0xb8, "0xb7 + 1 length byte"); + assert_eq!(encoded[1], 56); + assert_eq!(encoded.len(), 58); + + let long = vec![0xbb_u8; 1024]; + let encoded = encode_bytes(&long); + assert_eq!(encoded[0], 0xb9, "0xb7 + 2 length bytes"); + assert_eq!(&encoded[1..3], &[0x04, 0x00]); +} + +#[test] +fn zero_encodes_as_the_empty_string_not_as_a_zero_byte() { + // The rule that silently changes what a signature commits to. + assert_eq!(encode_uint(0), vec![0x80]); + assert_ne!(encode_uint(0), vec![0x00]); +} + +#[test] +fn integers_carry_no_leading_zeros() { + assert_eq!(encode_uint(1), vec![0x01]); + assert_eq!(encode_uint(127), vec![0x7f]); + assert_eq!(encode_uint(128), vec![0x81, 0x80]); + assert_eq!(encode_uint(1024), vec![0x82, 0x04, 0x00]); + // 20 gwei, from the EIP-155 vector. + assert_eq!( + encode_uint(20_000_000_000), + vec![0x85, 0x04, 0xa8, 0x17, 0xc8, 0x00] + ); +} + +#[test] +fn integer_byte_slices_are_stripped_the_same_way() { + let mut padded = [0u8; 32]; + padded[31] = 1; + assert_eq!(encode_uint_bytes(&padded), vec![0x01]); + assert_eq!( + encode_uint_bytes(&[0u8; 32]), + vec![0x80], + "all-zero is empty" + ); +} + +#[test] +fn an_empty_list_is_0xc0() { + assert_eq!(encode_list(&[]), vec![0xc0]); +} + +#[test] +fn a_list_prefixes_the_concatenated_encodings() { + // ["cat", "dog"] from the specification. + let items = vec![encode_bytes(b"cat"), encode_bytes(b"dog")]; + assert_eq!( + encode_list(&items), + vec![0xc8, 0x83, b'c', b'a', b't', 0x83, b'd', b'o', b'g'] + ); +} + +#[test] +fn a_long_list_takes_a_length_of_length() { + let items: Vec> = (0..30).map(|_| encode_bytes(&[0xcc_u8; 2])).collect(); + let encoded = encode_list(&items); + assert_eq!(encoded[0], 0xf8, "0xf7 + 1 length byte"); + assert_eq!(encoded[1], 90, "30 items x 3 bytes each"); +} diff --git a/src/tx/solana.rs b/src/tx/solana.rs index bce9f14..30235bf 100644 --- a/src/tx/solana.rs +++ b/src/tx/solana.rs @@ -192,191 +192,5 @@ fn encode_shortvec(value: u16) -> Vec { } #[cfg(test)] -mod test { - #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - - use super::{NativeTransfer, encode_shortvec}; - use crate::tx::Error; - - /// Derived from the BIP-39 vector mnemonic at the standard Solana path. - const FROM: &str = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"; - const TO: &str = "11111111111111111111111111111111"; - const BLOCKHASH: &str = "11111111111111111111111111111111"; - - fn key() -> Vec { - crate::key::derive(crate::Chain::Solana, VECTOR, "m/44'/501'/0'/0'") - .unwrap() - .secret_bytes() - .to_vec() - } - - const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ - abandon abandon abandon abandon abandon about"; - - fn transfer() -> NativeTransfer { - NativeTransfer { - from: FROM.to_string(), - to: TO.to_string(), - lamports: 1_000_000_000, - recent_blockhash: BLOCKHASH.to_string(), - } - } - - #[test] - fn shortvec_encodes_small_lengths_in_one_byte() { - assert_eq!(encode_shortvec(0), vec![0]); - assert_eq!(encode_shortvec(1), vec![1]); - assert_eq!(encode_shortvec(127), vec![127]); - } - - #[test] - fn shortvec_continues_past_127() { - // 128 = 0x80 0x01: low seven bits with the continuation bit, then the - // remainder. - assert_eq!(encode_shortvec(128), vec![0x80, 0x01]); - assert_eq!(encode_shortvec(256), vec![0x80, 0x02]); - assert_eq!(encode_shortvec(u16::MAX), vec![0xff, 0xff, 0x03]); - } - - #[test] - fn the_message_has_the_documented_layout() { - let message = transfer().message().unwrap(); - - // Header: 1 signer, 0 read-only signed, 1 read-only unsigned. - assert_eq!(&message[0..3], &[1, 0, 1]); - // Three accounts. - assert_eq!(message[3], 3); - // Sender first — the header says the first account signs. - let from = crate::address::solana::decode(FROM).unwrap(); - assert_eq!(&message[4..36], &from[..]); - // System program last, as the read-only unsigned account. - assert_eq!(&message[68..100], &[0u8; 32]); - } - - #[test] - fn the_instruction_encodes_transfer_and_the_lamports_little_endian() { - let message = transfer().message().unwrap(); - let data = &message[message.len() - 12..]; - // System instruction index 2 = Transfer, u32 little-endian. - assert_eq!(&data[0..4], &[2, 0, 0, 0]); - // Lamports, u64 little-endian. - assert_eq!(&data[4..12], &1_000_000_000u64.to_le_bytes()); - } - - #[test] - fn the_signed_transaction_carries_one_signature_then_the_message() { - let tx = transfer().sign(&key()).unwrap(); - assert_eq!(tx[0], 1, "shortvec count of one signature"); - let message = transfer().message().unwrap(); - assert_eq!(&tx[65..], &message[..], "message follows the signature"); - assert_eq!(tx.len(), 1 + 64 + message.len()); - } - - #[test] - fn the_signature_verifies_against_the_sender() { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - - let tx = transfer().sign(&key()).unwrap(); - let message = transfer().message().unwrap(); - let signature = Signature::from_slice(&tx[1..65]).unwrap(); - let public = - VerifyingKey::from_bytes(&crate::address::solana::decode(FROM).unwrap()).unwrap(); - public - .verify(&message, &signature) - .expect("signature must verify against the sender's key"); - } - - #[test] - fn a_key_that_does_not_control_the_sender_is_rejected() { - // Structurally valid but wrong — caught here rather than on-chain. - let other = crate::key::derive(crate::Chain::Solana, VECTOR, "m/44'/501'/1'/0'") - .unwrap() - .secret_bytes() - .to_vec(); - match transfer().sign(&other).unwrap_err() { - Error::Signing { reason } => assert!(reason.contains("does not control")), - other => panic!("expected Signing, got {other:?}"), - } - } - - #[test] - fn a_wrong_length_key_is_rejected() { - assert!(matches!( - transfer().sign(&[0u8; 16]).unwrap_err(), - Error::Signing { .. } - )); - } - - #[test] - fn an_invalid_address_or_blockhash_is_rejected() { - let bad_to = NativeTransfer { - to: "0OIl".to_string(), - ..transfer() - }; - assert!(matches!(bad_to.message(), Err(Error::Address(_)))); - - let bad_hash = NativeTransfer { - recent_blockhash: "tooShort".to_string(), - ..transfer() - }; - match bad_hash.message().unwrap_err() { - Error::InvalidField { field, .. } => assert_eq!(field, "recent_blockhash"), - other => panic!("expected InvalidField, got {other:?}"), - } - } - - #[test] - fn a_different_blockhash_changes_the_signature() { - // The blockhash is what makes a transaction non-replayable, so it must - // reach the signed bytes. - let a = transfer().sign(&key()).unwrap(); - let b = NativeTransfer { - recent_blockhash: "So11111111111111111111111111111111111111112".to_string(), - ..transfer() - } - .sign(&key()) - .unwrap(); - assert_ne!(a, b); - } - - #[test] - fn signing_is_deterministic() { - // ed25519 signatures are deterministic by construction. - assert_eq!( - transfer().sign(&key()).unwrap(), - transfer().sign(&key()).unwrap() - ); - } - - #[test] - fn split_signing_matches_one_shot_signing() { - // The host holds the ed25519 key and signs the message; this crate - // assembles. Both paths must produce identical wire bytes, or the - // split has silently changed what gets broadcast. - use ed25519_dalek::{Signer as _, SigningKey}; - - let transfer = transfer(); - let secret = key(); - let one_shot = transfer.sign(&secret).unwrap(); - - let bytes: [u8; 32] = secret.as_slice().try_into().unwrap(); - let signing = SigningKey::from_bytes(&bytes); - let signature = signing.sign(&transfer.message().unwrap()).to_bytes(); - let split = transfer.attach_signature(&signature).unwrap(); - - assert_eq!(split, one_shot); - } - - #[test] - fn the_signed_payload_is_the_message_itself_not_a_digest() { - // ed25519 hashes internally. A host that pre-hashes the message and - // signs the digest produces a signature the network rejects, so the - // distinction is worth pinning. - let transfer = transfer(); - let message = transfer.message().unwrap(); - assert!( - message.len() > 32, - "a Solana message is the full serialized transaction, not a 32-byte digest" - ); - } -} +#[path = "solana_test_tests.rs"] +mod test; diff --git a/src/tx/solana_test_tests.rs b/src/tx/solana_test_tests.rs new file mode 100644 index 0000000..bab43d7 --- /dev/null +++ b/src/tx/solana_test_tests.rs @@ -0,0 +1,185 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{NativeTransfer, encode_shortvec}; +use crate::tx::Error; + +/// Derived from the BIP-39 vector mnemonic at the standard Solana path. +const FROM: &str = "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk"; +const TO: &str = "11111111111111111111111111111111"; +const BLOCKHASH: &str = "11111111111111111111111111111111"; + +fn key() -> Vec { + crate::key::derive(crate::Chain::Solana, VECTOR, "m/44'/501'/0'/0'") + .unwrap() + .secret_bytes() + .to_vec() +} + +const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + +fn transfer() -> NativeTransfer { + NativeTransfer { + from: FROM.to_string(), + to: TO.to_string(), + lamports: 1_000_000_000, + recent_blockhash: BLOCKHASH.to_string(), + } +} + +#[test] +fn shortvec_encodes_small_lengths_in_one_byte() { + assert_eq!(encode_shortvec(0), vec![0]); + assert_eq!(encode_shortvec(1), vec![1]); + assert_eq!(encode_shortvec(127), vec![127]); +} + +#[test] +fn shortvec_continues_past_127() { + // 128 = 0x80 0x01: low seven bits with the continuation bit, then the + // remainder. + assert_eq!(encode_shortvec(128), vec![0x80, 0x01]); + assert_eq!(encode_shortvec(256), vec![0x80, 0x02]); + assert_eq!(encode_shortvec(u16::MAX), vec![0xff, 0xff, 0x03]); +} + +#[test] +fn the_message_has_the_documented_layout() { + let message = transfer().message().unwrap(); + + // Header: 1 signer, 0 read-only signed, 1 read-only unsigned. + assert_eq!(&message[0..3], &[1, 0, 1]); + // Three accounts. + assert_eq!(message[3], 3); + // Sender first — the header says the first account signs. + let from = crate::address::solana::decode(FROM).unwrap(); + assert_eq!(&message[4..36], &from[..]); + // System program last, as the read-only unsigned account. + assert_eq!(&message[68..100], &[0u8; 32]); +} + +#[test] +fn the_instruction_encodes_transfer_and_the_lamports_little_endian() { + let message = transfer().message().unwrap(); + let data = &message[message.len() - 12..]; + // System instruction index 2 = Transfer, u32 little-endian. + assert_eq!(&data[0..4], &[2, 0, 0, 0]); + // Lamports, u64 little-endian. + assert_eq!(&data[4..12], &1_000_000_000u64.to_le_bytes()); +} + +#[test] +fn the_signed_transaction_carries_one_signature_then_the_message() { + let tx = transfer().sign(&key()).unwrap(); + assert_eq!(tx[0], 1, "shortvec count of one signature"); + let message = transfer().message().unwrap(); + assert_eq!(&tx[65..], &message[..], "message follows the signature"); + assert_eq!(tx.len(), 1 + 64 + message.len()); +} + +#[test] +fn the_signature_verifies_against_the_sender() { + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + let tx = transfer().sign(&key()).unwrap(); + let message = transfer().message().unwrap(); + let signature = Signature::from_slice(&tx[1..65]).unwrap(); + let public = VerifyingKey::from_bytes(&crate::address::solana::decode(FROM).unwrap()).unwrap(); + public + .verify(&message, &signature) + .expect("signature must verify against the sender's key"); +} + +#[test] +fn a_key_that_does_not_control_the_sender_is_rejected() { + // Structurally valid but wrong — caught here rather than on-chain. + let other = crate::key::derive(crate::Chain::Solana, VECTOR, "m/44'/501'/1'/0'") + .unwrap() + .secret_bytes() + .to_vec(); + match transfer().sign(&other).unwrap_err() { + Error::Signing { reason } => assert!(reason.contains("does not control")), + other => panic!("expected Signing, got {other:?}"), + } +} + +#[test] +fn a_wrong_length_key_is_rejected() { + assert!(matches!( + transfer().sign(&[0u8; 16]).unwrap_err(), + Error::Signing { .. } + )); +} + +#[test] +fn an_invalid_address_or_blockhash_is_rejected() { + let bad_to = NativeTransfer { + to: "0OIl".to_string(), + ..transfer() + }; + assert!(matches!(bad_to.message(), Err(Error::Address(_)))); + + let bad_hash = NativeTransfer { + recent_blockhash: "tooShort".to_string(), + ..transfer() + }; + match bad_hash.message().unwrap_err() { + Error::InvalidField { field, .. } => assert_eq!(field, "recent_blockhash"), + other => panic!("expected InvalidField, got {other:?}"), + } +} + +#[test] +fn a_different_blockhash_changes_the_signature() { + // The blockhash is what makes a transaction non-replayable, so it must + // reach the signed bytes. + let a = transfer().sign(&key()).unwrap(); + let b = NativeTransfer { + recent_blockhash: "So11111111111111111111111111111111111111112".to_string(), + ..transfer() + } + .sign(&key()) + .unwrap(); + assert_ne!(a, b); +} + +#[test] +fn signing_is_deterministic() { + // ed25519 signatures are deterministic by construction. + assert_eq!( + transfer().sign(&key()).unwrap(), + transfer().sign(&key()).unwrap() + ); +} + +#[test] +fn split_signing_matches_one_shot_signing() { + // The host holds the ed25519 key and signs the message; this crate + // assembles. Both paths must produce identical wire bytes, or the + // split has silently changed what gets broadcast. + use ed25519_dalek::{Signer as _, SigningKey}; + + let transfer = transfer(); + let secret = key(); + let one_shot = transfer.sign(&secret).unwrap(); + + let bytes: [u8; 32] = secret.as_slice().try_into().unwrap(); + let signing = SigningKey::from_bytes(&bytes); + let signature = signing.sign(&transfer.message().unwrap()).to_bytes(); + let split = transfer.attach_signature(&signature).unwrap(); + + assert_eq!(split, one_shot); +} + +#[test] +fn the_signed_payload_is_the_message_itself_not_a_digest() { + // ed25519 hashes internally. A host that pre-hashes the message and + // signs the digest produces a signature the network rejects, so the + // distinction is worth pinning. + let transfer = transfer(); + let message = transfer.message().unwrap(); + assert!( + message.len() > 32, + "a Solana message is the full serialized transaction, not a 32-byte digest" + ); +} diff --git a/src/tx/tron.rs b/src/tx/tron.rs index 48afec1..ff728dd 100644 --- a/src/tx/tron.rs +++ b/src/tx/tron.rs @@ -55,64 +55,5 @@ pub fn sign(raw_data_hex: &str, secret_key: &[u8]) -> Result { } #[cfg(test)] -mod test { - #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - - use super::{sign, signature_hex}; - use crate::tx::Error; - - const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ - abandon abandon abandon abandon abandon about"; - const TO: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; - - fn key() -> Vec { - crate::key::derive(crate::Chain::Tron, VECTOR, "m/44'/195'/0'/0/0") - .unwrap() - .secret_bytes() - .to_vec() - } - - /// A `raw_data`-shaped hex blob embedding the recipient's hex address. - fn raw_data() -> String { - let to_hex = crate::address::tron::to_hex(TO).unwrap(); - format!("0a02b1f42208{to_hex}5a0f") - } - - #[test] - fn the_signature_is_65_bytes_ending_in_a_bare_recovery_id() { - // Tron borrowed Ethereum's addresses but not EIP-155's v encoding. - let signature = sign(&raw_data(), &key()).unwrap(); - assert_eq!(signature.len(), 65); - assert!(signature[64] <= 3, "recovery id, not a v value"); - assert_eq!(signature_hex(&signature).len(), 130); - } - - #[test] - fn signing_is_deterministic() { - let raw = raw_data(); - assert_eq!(sign(&raw, &key()).unwrap(), sign(&raw, &key()).unwrap()); - } - - #[test] - fn different_raw_data_produces_a_different_signature() { - let a = sign(&raw_data(), &key()).unwrap(); - let b = sign(&raw_data().replace("0a02", "0a03"), &key()).unwrap(); - assert_ne!(a, b); - } - - #[test] - fn malformed_hex_is_rejected() { - assert!(matches!( - sign("zz", &key()).unwrap_err(), - Error::InvalidField { .. } - )); - } - - #[test] - fn an_invalid_key_is_rejected() { - assert!(matches!( - sign(&raw_data(), &[0u8; 32]).unwrap_err(), - Error::Signing { .. } - )); - } -} +#[path = "tron_test_tests.rs"] +mod test; diff --git a/src/tx/tron_test_tests.rs b/src/tx/tron_test_tests.rs new file mode 100644 index 0000000..7aa6a06 --- /dev/null +++ b/src/tx/tron_test_tests.rs @@ -0,0 +1,59 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{sign, signature_hex}; +use crate::tx::Error; + +const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; +const TO: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + +fn key() -> Vec { + crate::key::derive(crate::Chain::Tron, VECTOR, "m/44'/195'/0'/0/0") + .unwrap() + .secret_bytes() + .to_vec() +} + +/// A `raw_data`-shaped hex blob embedding the recipient's hex address. +fn raw_data() -> String { + let to_hex = crate::address::tron::to_hex(TO).unwrap(); + format!("0a02b1f42208{to_hex}5a0f") +} + +#[test] +fn the_signature_is_65_bytes_ending_in_a_bare_recovery_id() { + // Tron borrowed Ethereum's addresses but not EIP-155's v encoding. + let signature = sign(&raw_data(), &key()).unwrap(); + assert_eq!(signature.len(), 65); + assert!(signature[64] <= 3, "recovery id, not a v value"); + assert_eq!(signature_hex(&signature).len(), 130); +} + +#[test] +fn signing_is_deterministic() { + let raw = raw_data(); + assert_eq!(sign(&raw, &key()).unwrap(), sign(&raw, &key()).unwrap()); +} + +#[test] +fn different_raw_data_produces_a_different_signature() { + let a = sign(&raw_data(), &key()).unwrap(); + let b = sign(&raw_data().replace("0a02", "0a03"), &key()).unwrap(); + assert_ne!(a, b); +} + +#[test] +fn malformed_hex_is_rejected() { + assert!(matches!( + sign("zz", &key()).unwrap_err(), + Error::InvalidField { .. } + )); +} + +#[test] +fn an_invalid_key_is_rejected() { + assert!(matches!( + sign(&raw_data(), &[0u8; 32]).unwrap_err(), + Error::Signing { .. } + )); +} From f2254013cf4489a450f6e4d8341689d76ad3cebf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 1 Oct 2026 19:37:28 +0300 Subject: [PATCH 2/5] test: replace `.is_empty()` with explicit `.len()` comparisons Replace all uses of `.is_empty()` in test assertions with explicit `.len() == 0` or `.len() != 0` comparisons for consistency and to avoid potential ambiguity with boolean coercion. Auto-committed-on: dragonfly --- .../src/crypto/chains/btc/test.rs | 2 +- .../src/crypto/chains/evm/test.rs | 2 +- .../src/crypto/chains/solana/test.rs | 2 +- .../src/crypto/chains/tron/test.rs | 20 ++++--------------- .../src/crypto/service/ops/test.rs | 4 ++-- .../tinywallet-web3/src/tools/wallet/test.rs | 2 +- crates/tinywallet-web3/src/tools/web3/test.rs | 2 +- crates/tinywallet-x402/src/tools/test.rs | 2 +- src/client/test.rs | 4 ++-- src/tx/btc_test_tests.rs | 2 +- vendor/tinybus | 2 +- 11 files changed, 16 insertions(+), 28 deletions(-) diff --git a/crates/tinywallet-web3/src/crypto/chains/btc/test.rs b/crates/tinywallet-web3/src/crypto/chains/btc/test.rs index f45f7ee..7ced362 100644 --- a/crates/tinywallet-web3/src/crypto/chains/btc/test.rs +++ b/crates/tinywallet-web3/src/crypto/chains/btc/test.rs @@ -299,7 +299,7 @@ async fn execute_refuses_when_there_are_no_spendable_utxos() { .await .unwrap_err(); assert!(err.contains("no spendable UTXOs"), "got: {err}"); - assert!(rig.signer.transactions().is_empty()); + assert_eq!(rig.signer.transactions().len(), 0); } #[tokio::test] diff --git a/crates/tinywallet-web3/src/crypto/chains/evm/test.rs b/crates/tinywallet-web3/src/crypto/chains/evm/test.rs index 80e3534..eeeb527 100644 --- a/crates/tinywallet-web3/src/crypto/chains/evm/test.rs +++ b/crates/tinywallet-web3/src/crypto/chains/evm/test.rs @@ -252,7 +252,7 @@ async fn sign_and_broadcast_validates_before_touching_the_node() { .await .unwrap_err(); assert!(err.starts_with("invalid native value 'lots'"), "{err}"); - assert!(rig.transport.calls().is_empty()); + assert_eq!(rig.transport.calls().len(), 0); } #[tokio::test] diff --git a/crates/tinywallet-web3/src/crypto/chains/solana/test.rs b/crates/tinywallet-web3/src/crypto/chains/solana/test.rs index 9c1ef31..d621b33 100644 --- a/crates/tinywallet-web3/src/crypto/chains/solana/test.rs +++ b/crates/tinywallet-web3/src/crypto/chains/solana/test.rs @@ -225,7 +225,7 @@ async fn a_mismatched_derived_key_is_refused_before_any_rpc() { err.starts_with("Solana key derivation mismatch: derived Vote"), "{err}" ); - assert!(rig.transport.calls().is_empty()); + assert_eq!(rig.transport.calls().len(), 0); } #[tokio::test] diff --git a/crates/tinywallet-web3/src/crypto/chains/tron/test.rs b/crates/tinywallet-web3/src/crypto/chains/tron/test.rs index 97c0879..d304a5d 100644 --- a/crates/tinywallet-web3/src/crypto/chains/tron/test.rs +++ b/crates/tinywallet-web3/src/crypto/chains/tron/test.rs @@ -362,11 +362,7 @@ async fn a_native_transfer_is_built_verified_signed_and_broadcast() { tron_address_to_hex(RECIPIENT).unwrap() ); assert_eq!(create["amount"], 1_000_000); - assert!( - rig.transport - .posts_to("wallet/triggersmartcontract") - .is_empty() - ); + assert_eq!( rig.transport .posts_to("wallet/triggersmartcontract") .len(), 0); // The signer got a verified spec, and its signature is what was broadcast. assert!( @@ -406,11 +402,7 @@ async fn a_trc20_transfer_pays_the_contract_and_carries_the_recipient_in_the_par assert_eq!(trigger["parameter"].as_str().unwrap().len(), 128); assert_eq!(trigger["fee_limit"], TRC20_FEE_LIMIT_SUN); assert_eq!(trigger["call_value"], 0); - assert!( - rig.transport - .posts_to("wallet/createtransaction") - .is_empty() - ); + assert_eq!( rig.transport .posts_to("wallet/createtransaction") .len(), 0); // Without a `txid` in the reply, the node-built transaction's id is used. assert_eq!(result.transaction_hash, recompute_txid(&raw).unwrap()); } @@ -448,11 +440,7 @@ async fn a_tampering_node_is_caught_before_the_signer_sees_anything() { rig.signer.transactions().is_empty(), "the signer never saw the decoy" ); - assert!( - rig.transport - .posts_to("wallet/broadcasttransaction") - .is_empty() - ); + assert_eq!( rig.transport .posts_to("wallet/broadcasttransaction") .len(), 0); } #[tokio::test] @@ -469,7 +457,7 @@ async fn the_account_the_signer_derives_must_match_the_quote() { sample_address(WalletChain::Tron) ) ); - assert!(rig.transport.calls().is_empty()); + assert_eq!(rig.transport.calls().len(), 0); assert!( rig.signer .calls() diff --git a/crates/tinywallet-web3/src/crypto/service/ops/test.rs b/crates/tinywallet-web3/src/crypto/service/ops/test.rs index 85530e9..2929ed9 100644 --- a/crates/tinywallet-web3/src/crypto/service/ops/test.rs +++ b/crates/tinywallet-web3/src/crypto/service/ops/test.rs @@ -157,7 +157,7 @@ async fn a_swap_on_an_unsignable_chain_is_rejected_before_the_backend() { let rig = ServiceRig::new(); let err = rig.service.quote_swap(swap(999_999)).await.unwrap_err(); assert!(err.contains("not signable"), "got: {err}"); - assert!(rig.backend.requests().is_empty()); + assert_eq!(rig.backend.requests().len(), 0); } #[tokio::test] @@ -206,7 +206,7 @@ async fn a_same_chain_bridge_is_rejected() { err.contains("different source and destination"), "got: {err}" ); - assert!(rig.backend.requests().is_empty()); + assert_eq!(rig.backend.requests().len(), 0); } #[tokio::test] diff --git a/crates/tinywallet-web3/src/tools/wallet/test.rs b/crates/tinywallet-web3/src/tools/wallet/test.rs index 68e558d..3bafeef 100644 --- a/crates/tinywallet-web3/src/tools/wallet/test.rs +++ b/crates/tinywallet-web3/src/tools/wallet/test.rs @@ -50,7 +50,7 @@ fn every_wallet_tool_is_deferred_and_named_as_documented() { for (tool, name) in &tools { assert_eq!(tool.name(), *name); assert!(matches!(tool.exposure(), ToolExposure::Deferred), "{name}"); - assert!(!tool.description().is_empty()); + assert_ne!(tool.description().len(), 0); let schema = tool.parameters_schema(); assert_eq!(schema["type"], "object"); assert_eq!( diff --git a/crates/tinywallet-web3/src/tools/web3/test.rs b/crates/tinywallet-web3/src/tools/web3/test.rs index 3cb4425..1cd98a7 100644 --- a/crates/tinywallet-web3/src/tools/web3/test.rs +++ b/crates/tinywallet-web3/src/tools/web3/test.rs @@ -50,7 +50,7 @@ fn every_web3_tool_is_deferred_and_named_as_documented() { for (tool, name) in &tools { assert_eq!(tool.name(), *name); assert!(matches!(tool.exposure(), ToolExposure::Deferred), "{name}"); - assert!(!tool.description().is_empty()); + assert_ne!(tool.description().len(), 0); assert_eq!( tool.parameters_schema()["additionalProperties"], false, diff --git a/crates/tinywallet-x402/src/tools/test.rs b/crates/tinywallet-x402/src/tools/test.rs index 0b190b3..b8dfa10 100644 --- a/crates/tinywallet-x402/src/tools/test.rs +++ b/crates/tinywallet-x402/src/tools/test.rs @@ -375,7 +375,7 @@ async fn a_payment_the_budget_refuses_is_reported_and_not_sent() { "x402 payment failed: x402 amount 2500 exceeds per-request cap 100" ); assert_eq!(server.seen().len(), 1); - assert!(records().is_empty()); + assert_eq!(records().len(), 0); ledger::reset_global(); } diff --git a/src/client/test.rs b/src/client/test.rs index 624b8b8..8e1684c 100644 --- a/src/client/test.rs +++ b/src/client/test.rs @@ -267,7 +267,7 @@ async fn an_address_from_the_wrong_chain_is_rejected() { let err = balance(&transport, network, wrong).await.unwrap_err(); assert!(matches!(err, Error::Address(_)), "{network}: {err:?}"); } - assert!(transport.calls().is_empty()); + assert_eq!(transport.calls().len(), 0); } #[tokio::test] @@ -541,7 +541,7 @@ async fn send_evm_rejects_a_bad_address_before_any_request() { .unwrap_err(), Error::Address(_) )); - assert!(transport.methods().is_empty()); + assert_eq!(transport.methods().len(), 0); } #[tokio::test] diff --git a/src/tx/btc_test_tests.rs b/src/tx/btc_test_tests.rs index 25d1c3f..98bf0d4 100644 --- a/src/tx/btc_test_tests.rs +++ b/src/tx/btc_test_tests.rs @@ -112,7 +112,7 @@ fn the_fee_is_exactly_inputs_minus_outputs() { fn every_input_is_signed_with_a_witness() { let utxos = [utxo(60_000, 0), utxo(60_000, 1)]; let hex = transfer(100_000, 1_000).sign(&utxos, &key()).unwrap(); - assert!(!hex.is_empty()); + assert_ne!(hex.len(), 0); // Segwit marker and flag follow the 4-byte version in the serialised // form: 02000000 then 0001. assert!(hex.starts_with("020000000001"), "{hex}"); diff --git a/vendor/tinybus b/vendor/tinybus index df6f990..dc8c92f 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit df6f990cec3b130db12d6cd0c8e24f11c30b28e6 +Subproject commit dc8c92f58b5dcb8b8af4a5b3a81aafe3333fa1e4 From 04a815d877b08191dd0e8343f59b76aecf2444ae Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 1 Oct 2026 19:37:34 +0300 Subject: [PATCH 3/5] test(tron): fix formatting in test assertions Reformatted three assert_eq! calls in the Tron test file to improve readability by removing inconsistent spacing around the rig.transport calls and breaking long lines across multiple lines for better code style consistency. Auto-committed-on: dragonfly --- .../tinywallet-web3/src/crypto/chains/tron/test.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/tinywallet-web3/src/crypto/chains/tron/test.rs b/crates/tinywallet-web3/src/crypto/chains/tron/test.rs index d304a5d..cb65387 100644 --- a/crates/tinywallet-web3/src/crypto/chains/tron/test.rs +++ b/crates/tinywallet-web3/src/crypto/chains/tron/test.rs @@ -362,7 +362,10 @@ async fn a_native_transfer_is_built_verified_signed_and_broadcast() { tron_address_to_hex(RECIPIENT).unwrap() ); assert_eq!(create["amount"], 1_000_000); - assert_eq!( rig.transport .posts_to("wallet/triggersmartcontract") .len(), 0); + assert_eq!( + rig.transport.posts_to("wallet/triggersmartcontract").len(), + 0 + ); // The signer got a verified spec, and its signature is what was broadcast. assert!( @@ -402,7 +405,7 @@ async fn a_trc20_transfer_pays_the_contract_and_carries_the_recipient_in_the_par assert_eq!(trigger["parameter"].as_str().unwrap().len(), 128); assert_eq!(trigger["fee_limit"], TRC20_FEE_LIMIT_SUN); assert_eq!(trigger["call_value"], 0); - assert_eq!( rig.transport .posts_to("wallet/createtransaction") .len(), 0); + assert_eq!(rig.transport.posts_to("wallet/createtransaction").len(), 0); // Without a `txid` in the reply, the node-built transaction's id is used. assert_eq!(result.transaction_hash, recompute_txid(&raw).unwrap()); } @@ -440,7 +443,10 @@ async fn a_tampering_node_is_caught_before_the_signer_sees_anything() { rig.signer.transactions().is_empty(), "the signer never saw the decoy" ); - assert_eq!( rig.transport .posts_to("wallet/broadcasttransaction") .len(), 0); + assert_eq!( + rig.transport.posts_to("wallet/broadcasttransaction").len(), + 0 + ); } #[tokio::test] From 17a9c2286aec050f63d52943bb504f6894ba469e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 1 Oct 2026 19:39:29 +0300 Subject: [PATCH 4/5] test(tx): add overflow and edge-case tests for btc and tron Add tests covering overflow scenarios in Bitcoin coin selection and transfer, invalid recipient addresses, malformed keys and signatures, and a recovery id outside one byte in Tron signing. Extract the recovery id narrowing into a helper function to improve testability. Auto-committed-on: dragonfly --- src/tx/btc_test_tests.rs | 66 ++++++++++++++++++++++++++++++++++++++- src/tx/tron.rs | 11 +++++-- src/tx/tron_test_tests.rs | 10 +++++- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/tx/btc_test_tests.rs b/src/tx/btc_test_tests.rs index 98bf0d4..d1a482a 100644 --- a/src/tx/btc_test_tests.rs +++ b/src/tx/btc_test_tests.rs @@ -1,6 +1,6 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use super::{DUST_THRESHOLD, Transfer, Utxo, select_coins}; +use super::{DUST_THRESHOLD, Transfer, Utxo, script_pubkey, select_coins}; use crate::tx::Error; const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ @@ -283,3 +283,67 @@ fn a_signature_count_that_does_not_match_the_inputs_is_refused() { .unwrap_err(); assert!(matches!(error, Error::Signing { .. }), "{error:?}"); } + +#[test] +fn a_utxo_total_that_overflows_is_an_invalid_field() { + let utxos = [utxo(u64::MAX, 0), utxo(1, 1)]; + match select_coins(&utxos, u64::MAX).unwrap_err() { + Error::InvalidField { field, .. } => assert_eq!(field, "utxos"), + other => panic!("expected InvalidField, got {other:?}"), + } +} + +#[test] +fn an_amount_plus_fee_that_overflows_is_an_invalid_field() { + match transfer(u64::MAX, 1).build(&[utxo(50_000, 0)]).unwrap_err() { + Error::InvalidField { field, .. } => assert_eq!(field, "amount"), + other => panic!("expected InvalidField, got {other:?}"), + } +} + +#[test] +fn an_invalid_recipient_is_an_address_error_on_every_entry_point() { + let mut bad = transfer(1_000, 100); + bad.to = "not-an-address".to_string(); + let utxos = [utxo(50_000, 0)]; + assert!(matches!(bad.build(&utxos), Err(Error::Address(_)))); + assert!(matches!( + bad.attach_signatures(&utxos, &public_key(), &[[0x11; 64]]), + Err(Error::Address(_)) + )); +} + +#[test] +fn a_secret_key_of_the_wrong_length_is_a_signing_error() { + let error = transfer(1_000, 100) + .sign(&[utxo(50_000, 0)], &[0u8; 5]) + .unwrap_err(); + assert!(matches!(error, Error::Signing { .. }), "{error:?}"); +} + +#[test] +fn a_public_key_that_is_not_on_the_curve_is_refused() { + let utxos = [utxo(50_000, 0)]; + let invalid = [0u8; 33]; + assert!(matches!( + transfer(1_000, 100).sighashes(&utxos, &invalid), + Err(Error::Signing { .. }) + )); +} + +#[test] +fn a_signature_that_is_not_a_valid_pair_is_refused() { + let utxos = [utxo(50_000, 0)]; + let error = transfer(1_000, 100) + .attach_signatures(&utxos, &public_key(), &[[0u8; 64]]) + .unwrap_err(); + assert!(matches!(error, Error::Signing { .. }), "{error:?}"); +} + +#[test] +fn a_script_for_a_malformed_address_is_an_invalid_field() { + match script_pubkey("garbage").unwrap_err() { + Error::InvalidField { field, .. } => assert_eq!(field, "address"), + other => panic!("expected InvalidField, got {other:?}"), + } +} diff --git a/src/tx/tron.rs b/src/tx/tron.rs index ff728dd..96e05e8 100644 --- a/src/tx/tron.rs +++ b/src/tx/tron.rs @@ -48,12 +48,17 @@ pub fn sign(raw_data_hex: &str, secret_key: &[u8]) -> Result { let recoverable = secp.sign_ecdsa_recoverable(&message, &secret); let (recovery_id, compact) = recoverable.serialize_compact(); - let recovery = u8::try_from(recovery_id.to_i32()).map_err(|_| Error::Signing { - reason: "unexpected recovery id".to_string(), - })?; + let recovery = recovery_byte(recovery_id.to_i32())?; attach_signature(&compact, recovery) } +/// Narrow a secp256k1 recovery id to the single byte Tron appends. +fn recovery_byte(id: i32) -> Result { + u8::try_from(id).map_err(|_| Error::Signing { + reason: "unexpected recovery id".to_string(), + }) +} + #[cfg(test)] #[path = "tron_test_tests.rs"] mod test; diff --git a/src/tx/tron_test_tests.rs b/src/tx/tron_test_tests.rs index 7aa6a06..ce1d656 100644 --- a/src/tx/tron_test_tests.rs +++ b/src/tx/tron_test_tests.rs @@ -1,6 +1,6 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use super::{sign, signature_hex}; +use super::{recovery_byte, sign, signature_hex}; use crate::tx::Error; const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ @@ -57,3 +57,11 @@ fn an_invalid_key_is_rejected() { Error::Signing { .. } )); } + +#[test] +fn a_recovery_id_outside_one_byte_is_a_signing_error() { + assert_eq!(recovery_byte(0).unwrap(), 0); + assert_eq!(recovery_byte(3).unwrap(), 3); + assert!(matches!(recovery_byte(-1), Err(Error::Signing { .. }))); + assert!(matches!(recovery_byte(256), Err(Error::Signing { .. }))); +} From d69621e83bdf93495afef42bebccfba5391a5376 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 1 Oct 2026 19:39:42 +0300 Subject: [PATCH 5/5] fix(test): use non-overflowing utxo values and valid signature bytes The overflow test was using u64::MAX and 1 as utxo values, which caused an actual overflow rather than testing the invalid field error. The values are now split into two halves that sum to u64::MAX without overflowing. The invalid signature test was using zero bytes which could be misinterpreted; using 0xff ensures the signature is clearly invalid. Auto-committed-on: dragonfly --- src/tx/btc_test_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tx/btc_test_tests.rs b/src/tx/btc_test_tests.rs index d1a482a..b4ef9cd 100644 --- a/src/tx/btc_test_tests.rs +++ b/src/tx/btc_test_tests.rs @@ -286,7 +286,8 @@ fn a_signature_count_that_does_not_match_the_inputs_is_refused() { #[test] fn a_utxo_total_that_overflows_is_an_invalid_field() { - let utxos = [utxo(u64::MAX, 0), utxo(1, 1)]; + let half = u64::MAX / 2 + 1; + let utxos = [utxo(half, 0), utxo(half, 1)]; match select_coins(&utxos, u64::MAX).unwrap_err() { Error::InvalidField { field, .. } => assert_eq!(field, "utxos"), other => panic!("expected InvalidField, got {other:?}"), @@ -335,7 +336,7 @@ fn a_public_key_that_is_not_on_the_curve_is_refused() { fn a_signature_that_is_not_a_valid_pair_is_refused() { let utxos = [utxo(50_000, 0)]; let error = transfer(1_000, 100) - .attach_signatures(&utxos, &public_key(), &[[0u8; 64]]) + .attach_signatures(&utxos, &public_key(), &[[0xff; 64]]) .unwrap_err(); assert!(matches!(error, Error::Signing { .. }), "{error:?}"); }