diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5caec9f..ed44d05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,14 +61,17 @@ jobs: report="target/coverage.json" cargo llvm-cov --all-features --all-targets --json --output-path "$report" - below_threshold="$(jq -r ' + # Anchored at the workspace root: this repo is vendored inside other + # checkouts, so an unanchored "/vendor/" match would exclude the + # crate's own sources when it is built from one of those paths. + below_threshold="$(jq -r --arg root "$PWD" ' .data[0].files[] | select(.filename | contains("/src/")) # Vendored submodules carry code from another repository, whose # coverage is not this one to enforce. They only entered the report # when the module crate made tinybus part of the build graph. # (No apostrophes here: the whole jq program is single-quoted.) - | select(.filename | contains("/vendor/") | not) + | select(.filename | startswith($root + "/vendor/") | not) | select(.summary.lines.percent < 90) | "\(.filename): \(.summary.lines.percent)%" ' "$report")" diff --git a/Cargo.toml b/Cargo.toml index 9a2eb15..a09b82c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,7 +113,11 @@ btc = ["dep:bs58", "dep:bech32", "dep:ripemd", "dep:sha2"] # Serde derives on the crate's core types (today `Chain`). A named gate rather # than relying on `dep:serde` being implied, because `#[cfg(feature = "serde")]` # on the enum has to name something that actually exists. -serde = ["dep:serde"] +# `serde/derive` is required, not incidental: `Chain` uses the derive macros. +# A local `cargo check --features serde` passes without it only because the +# dev-dependency on `serde` with `derive` unifies the feature in — a downstream +# consumer taking this crate with `serde` alone gets no derive and fails. +serde = ["dep:serde", "serde/derive"] # EVM (Ethereum and compatible chains) address validation. Dependency-free: # an EVM address is 20 hex-encoded bytes, so the whole format is spelled out # in `src/address/evm.rs` rather than pulling in a chain client for it. @@ -150,7 +154,7 @@ x402 = ["dep:serde", "dep:serde_json", "serde/derive", "eip712"] # transaction building into a loadable module can take this crate with # `default-features = false`, share one definition of the contract, and link no # chain library at all. Same carve-out `tinydocs::spec` makes. -wire = ["serde", "serde/derive"] +wire = ["serde"] # Transaction building and signing (`tinywallet::tx`). This is the only gate # that pulls the `bitcoin` crate, for PSBT construction and secp256k1 # recoverable signing. Everything else — addresses, derivation, reference data diff --git a/crates/tinywallet-module/src/service/mod.rs b/crates/tinywallet-module/src/service/mod.rs index ba47068..bac68fc 100644 --- a/crates/tinywallet-module/src/service/mod.rs +++ b/crates/tinywallet-module/src/service/mod.rs @@ -38,11 +38,11 @@ //! into a rewrite loop over something that was already correct. use tinybus::{Connection, Error as BusError, Result as BusResult}; +use tinywallet::tx; use tinywallet::wire::{ AttachRequest, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, TransactionSpec, UnsignedTransaction, }; -use tinywallet::{Chain, tx}; /// Well-known name and interface exported by the `TinyWallet` module. pub const BUS_NAME: &str = "ai.tinyhumans.tinywallet.Wallet"; @@ -54,8 +54,10 @@ pub const OBJECT_PATH: &str = "/ai/tinyhumans/tinywallet/Wallet"; const INVALID_INPUT_ERROR: &str = "ai.tinyhumans.tinywallet.Error.InvalidInput"; /// Building or assembling the transaction failed. A caller cannot fix it. const BUILD_FAILED_ERROR: &str = "ai.tinyhumans.tinywallet.Error.BuildFailed"; -/// The chain named is not compiled into this module. -const UNSUPPORTED_CHAIN_ERROR: &str = "ai.tinyhumans.tinywallet.Error.UnsupportedChain"; +// There is no `UnsupportedChain` error name. Every chain this build can name +// is compiled in, and a chain it cannot name arrives as a `TransactionSpec` +// variant it does not recognise — which is `InvalidInput`, because the request +// is one this module cannot act on rather than a capability that is missing. /// The served object. Holds nothing: every call is self-contained. struct Wallet; @@ -87,8 +89,6 @@ enum Failure { InvalidInput(String), /// Building or assembling failed for a reason the caller did not cause. BuildFailed(String), - /// The chain is not in this build. - UnsupportedChain(Chain), } /// Map a failure onto the wire name a host matches on. @@ -96,10 +96,6 @@ fn into_bus_error(failure: Failure) -> BusError { let (name, message) = match failure { Failure::InvalidInput(message) => (INVALID_INPUT_ERROR, message), Failure::BuildFailed(message) => (BUILD_FAILED_ERROR, message), - Failure::UnsupportedChain(chain) => ( - UNSUPPORTED_CHAIN_ERROR, - format!("this build has no support for {chain}"), - ), }; BusError::MethodFailed { name: name.to_string(), @@ -109,32 +105,31 @@ fn into_bus_error(failure: Failure) -> BusError { /// Compute the signing payloads for `request`. fn build_unsigned(request: &SigningRequest) -> Result { - let payloads = match (&request.transaction, request.chain) { - ( - TransactionSpec::Btc { - from, - to, - amount_sat, - fee_rate_sat_vb, - utxos, - }, - Chain::Btc, - ) => { - let transfer = btc_transfer(from, to, *amount_sat, *fee_rate_sat_vb); + // `chain_of` runs first so an unrecognised shape is refused before any + // arm is tried; the chain itself then comes from the variant. + let payloads = match &request.transaction { + TransactionSpec::Btc { + from, + to, + amount_sat, + fee_sat, + utxos, + } => { + let transfer = btc_transfer(from, to, *amount_sat, *fee_sat); let public = compressed_public_key(&request.public_key.key_hex)?; let (_, digests) = transfer .sighashes(&btc_utxos(utxos), &public) .map_err(|e| build_failed(&e))?; digests.into_iter().map(secp256k1_payload).collect() } - (spec @ TransactionSpec::Evm { .. }, Chain::Evm) => { + spec @ TransactionSpec::Evm { .. } => { vec![secp256k1_payload( evm_transaction(spec)? .digest() .map_err(|e| build_failed(&e))?, )] } - (spec @ TransactionSpec::Solana { .. }, Chain::Solana) => { + spec @ TransactionSpec::Solana { .. } => { // ed25519 signs the message itself — there is nothing to pre-hash, // so this payload is the whole serialized message, not a digest. vec![SigningPayload { @@ -144,14 +139,11 @@ fn build_unsigned(request: &SigningRequest) -> Result { + TransactionSpec::Tron { + raw_data_hex, + expected_to, + expected_txid, + } => { // Tron's node builds the transaction, so the only defence against a // compromised endpoint is checking that what came back is what was // asked for — before signing it, which is here. @@ -161,25 +153,24 @@ fn build_unsigned(request: &SigningRequest) -> Result return Err(mismatched(spec, chain)), + // Required because `TransactionSpec` is `#[non_exhaustive]`: a shape + // added after this build must be refused, never guessed at. + _ => return Err(unknown_kind()), }; Ok(UnsignedTransaction { payloads }) } /// Assemble the signed transaction for `request`. fn attach_signature(request: &AttachRequest) -> Result { - match (&request.transaction, request.chain) { - ( - TransactionSpec::Btc { - from, - to, - amount_sat, - fee_rate_sat_vb, - utxos, - }, - Chain::Btc, - ) => { - let transfer = btc_transfer(from, to, *amount_sat, *fee_rate_sat_vb); + match &request.transaction { + TransactionSpec::Btc { + from, + to, + amount_sat, + fee_sat, + utxos, + } => { + let transfer = btc_transfer(from, to, *amount_sat, *fee_sat); let public = compressed_public_key(&request.public_key.key_hex)?; let signatures = request .signatures @@ -198,7 +189,7 @@ fn attach_signature(request: &AttachRequest) -> Result { + spec @ TransactionSpec::Evm { .. } => { let (rs, recovery) = single_secp256k1(&request.signatures)?; let signed = evm_transaction(spec)? .attach_signature(&rs, recovery) @@ -208,7 +199,7 @@ fn attach_signature(request: &AttachRequest) -> Result { + spec @ TransactionSpec::Solana { .. } => { let signature = single_ed25519(&request.signatures)?; let signed = solana_transfer(spec)? .attach_signature(&signature) @@ -221,14 +212,11 @@ fn attach_signature(request: &AttachRequest) -> Result { + TransactionSpec::Tron { + raw_data_hex, + expected_to, + expected_txid, + } => { // Verified again rather than trusted from the first call: the two // requests are independent, and a host could reach this one with // different bytes than the digest was computed over. @@ -242,34 +230,17 @@ fn attach_signature(request: &AttachRequest) -> Result Err(mismatched(spec, chain)), + _ => Err(unknown_kind()), } } -/// A `chain` tag that does not agree with the transaction it carries. -fn mismatched(spec: &TransactionSpec, chain: Chain) -> Failure { - let named = match spec { - TransactionSpec::Btc { .. } => Chain::Btc, - TransactionSpec::Evm { .. } => Chain::Evm, - TransactionSpec::Solana { .. } => Chain::Solana, - TransactionSpec::Tron { .. } => Chain::Tron, - // `TransactionSpec` is `#[non_exhaustive]`, so a variant added later - // must land here rather than failing to compile in a crate that cannot - // see it. Refusing is the safe direction: never sign an unknown shape. - _ => { - return Failure::InvalidInput( - "this build does not understand that transaction kind".to_string(), - ); - } - }; - if named == chain { - // Same chain on both sides, so the pairing failed for the only other - // reason: this build does not carry it. - return Failure::UnsupportedChain(chain); - } - Failure::InvalidInput(format!( - "the request names {chain} but carries a {named} transaction" - )) +/// The refusal for a transaction shape added after this build. +/// +/// `TransactionSpec` is `#[non_exhaustive]`, so a peer built against a newer +/// revision can send a variant this module cannot name. Refusing beats +/// guessing: the alternative is building some other chain's transaction. +fn unknown_kind() -> Failure { + Failure::InvalidInput("this build does not understand that transaction kind".to_string()) } /// Collapse a `tinywallet` build error, which is never the caller's fault by diff --git a/crates/tinywallet-module/src/service/test.rs b/crates/tinywallet-module/src/service/test.rs index c4e6532..034526b 100644 --- a/crates/tinywallet-module/src/service/test.rs +++ b/crates/tinywallet-module/src/service/test.rs @@ -75,7 +75,6 @@ fn the_split_path_reproduces_a_one_shot_signature() { let spec = evm_spec(); let unsigned = build_unsigned(&SigningRequest { - chain: Chain::Evm, transaction: spec.clone(), public_key: PublicKey { key_hex: compressed_public(&key), @@ -87,7 +86,6 @@ fn the_split_path_reproduces_a_one_shot_signature() { let signature = host_sign(&unsigned.payloads[0].bytes_hex, &key); let signed = attach_signature(&AttachRequest { - chain: Chain::Evm, transaction: spec, public_key: PublicKey { key_hex: compressed_public(&key), @@ -124,7 +122,7 @@ fn a_bitcoin_request_returns_one_payload_per_selected_input() { from: key.address().to_string(), to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), amount_sat: 150_000, - fee_rate_sat_vb: 2_000, + fee_sat: 2_000, utxos: vec![ Utxo { txid: "7f3b662ea8b6ff2e0e1a1f9bd0f1c39a6b8ba51e1b0f0e0d0c0b0a0908070605" @@ -148,7 +146,6 @@ fn a_bitcoin_request_returns_one_payload_per_selected_input() { }; let unsigned = build_unsigned(&SigningRequest { - chain: Chain::Btc, transaction: spec, public_key: PublicKey { key_hex: compressed_public(key.secret_bytes()), @@ -172,7 +169,6 @@ fn a_solana_payload_is_the_message_not_a_digest() { // the network rejects, so the scheme tag has to say so. let key = tinywallet::key::derive(Chain::Solana, VECTOR, "m/44'/501'/0'/0'").unwrap(); let unsigned = build_unsigned(&SigningRequest { - chain: Chain::Solana, transaction: TransactionSpec::Solana { from: key.address().to_string(), to: "11111111111111111111111111111111".to_string(), @@ -193,26 +189,53 @@ fn a_solana_payload_is_the_message_not_a_digest() { } #[test] -fn a_chain_tag_that_disagrees_with_its_transaction_is_refused() { - // The tag and the fields are independent on the wire, so the mismatch is - // reachable and must not be resolved by guessing which one is right. - let error = build_unsigned(&SigningRequest { - chain: Chain::Btc, - transaction: evm_spec(), - public_key: PublicKey { - key_hex: compressed_public(&evm_key()), - }, - }) - .unwrap_err(); +fn the_chain_comes_from_the_transaction_rather_than_a_separate_field() { + // This replaces a test that fed a request whose `chain` tag contradicted + // its transaction. That state is no longer expressible: the requests carry + // no `chain`, so `TransactionSpec` is the single source of truth and the + // disagreement cannot be constructed. What is worth pinning instead is + // that the mapping is right for every variant. + use tinywallet::wire::Utxo; - let rendered = format!("{error:?}"); - assert!(rendered.contains("InvalidInput"), "{rendered}"); + let cases = [ + ( + TransactionSpec::Btc { + from: String::new(), + to: String::new(), + amount_sat: 0, + fee_sat: 0, + utxos: Vec::::new(), + }, + Chain::Btc, + ), + (evm_spec(), Chain::Evm), + ( + TransactionSpec::Solana { + from: String::new(), + to: String::new(), + lamports: 0, + recent_blockhash: String::new(), + }, + Chain::Solana, + ), + ( + TransactionSpec::Tron { + raw_data_hex: String::new(), + expected_to: String::new(), + expected_txid: String::new(), + }, + Chain::Tron, + ), + ]; + + for (spec, expected) in cases { + assert_eq!(spec.chain(), expected); + } } #[test] fn an_ed25519_signature_is_refused_for_a_secp256k1_chain() { let error = attach_signature(&AttachRequest { - chain: Chain::Evm, transaction: evm_spec(), public_key: PublicKey { key_hex: compressed_public(&evm_key()), @@ -230,7 +253,6 @@ fn an_ed25519_signature_is_refused_for_a_secp256k1_chain() { #[test] fn a_wrong_signature_count_is_refused_rather_than_truncated() { let error = attach_signature(&AttachRequest { - chain: Chain::Evm, transaction: evm_spec(), public_key: PublicKey { key_hex: compressed_public(&evm_key()), @@ -248,7 +270,6 @@ fn a_tron_transaction_whose_txid_does_not_match_its_bytes_is_refused() { // The defence against a compromised node: it must not be possible to get a // signature over bytes whose recomputed id disagrees with what was claimed. let error = build_unsigned(&SigningRequest { - chain: Chain::Tron, transaction: TransactionSpec::Tron { raw_data_hex: "0a02b1f12208".to_string() + &"ab".repeat(64), expected_to: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(), diff --git a/crates/tinywallet-module/tests/module_e2e.rs b/crates/tinywallet-module/tests/module_e2e.rs index 39168ec..0edb813 100644 --- a/crates/tinywallet-module/tests/module_e2e.rs +++ b/crates/tinywallet-module/tests/module_e2e.rs @@ -52,7 +52,7 @@ async fn the_built_module_signs_every_chain_over_a_real_broker() { signs_an_evm_transfer_identically_to_the_library(&proxy).await; signs_a_multi_input_bitcoin_spend(&proxy).await; signs_a_solana_transfer(&proxy).await; - refuses_a_chain_tag_that_contradicts_its_transaction(&proxy).await; + refuses_a_request_the_module_cannot_build(&proxy).await; assert!(matches!(modules.list()[0].state, ModuleState::Ready)); broker_task.abort(); @@ -129,7 +129,7 @@ async fn signs_an_evm_transfer_identically_to_the_library(proxy: &tinybus::Proxy chain_id: 1, }; - let signed = round_trip(proxy, Chain::Evm, &spec, &secret).await; + let signed = round_trip(proxy, &spec, &secret).await; let expected = tx::evm::LegacyTransaction { nonce: 9, @@ -163,7 +163,7 @@ async fn signs_a_multi_input_bitcoin_spend(proxy: &tinybus::Proxy) { from: derived.address().to_string(), to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), amount_sat: 150_000, - fee_rate_sat_vb: 2_000, + fee_sat: 2_000, utxos: (0..3) .map(|vout| tinywallet::wire::Utxo { txid: txid.to_string(), @@ -173,7 +173,7 @@ async fn signs_a_multi_input_bitcoin_spend(proxy: &tinybus::Proxy) { .collect(), }; - let signed = round_trip(proxy, Chain::Btc, &spec, &secret).await; + let signed = round_trip(proxy, &spec, &secret).await; let expected = tx::btc::Transfer { from: derived.address().to_string(), @@ -215,7 +215,6 @@ async fn signs_a_solana_transfer(proxy: &tinybus::Proxy) { .call( "BuildUnsigned", (SigningRequest { - chain: Chain::Solana, transaction: spec.clone(), public_key: public.clone(), },), @@ -234,7 +233,6 @@ async fn signs_a_solana_transfer(proxy: &tinybus::Proxy) { .call( "AttachSignature", (AttachRequest { - chain: Chain::Solana, transaction: spec.clone(), public_key: public, signatures: vec![Signature::Ed25519 { @@ -258,17 +256,21 @@ async fn signs_a_solana_transfer(proxy: &tinybus::Proxy) { } /// A malformed request must come back as a named error, not a signature. -async fn refuses_a_chain_tag_that_contradicts_its_transaction(proxy: &tinybus::Proxy) { +/// +/// This used to send a request whose `chain` tag contradicted its transaction. +/// That is no longer expressible — the requests carry no `chain` and the spec +/// names its own — so the case is now a transaction the module can parse but +/// cannot build: a Tron payload whose recomputed `txID` disagrees with the one +/// claimed, which is the defence against a compromised node. +async fn refuses_a_request_the_module_cannot_build(proxy: &tinybus::Proxy) { let result: tinybus::Result = proxy .call( "BuildUnsigned", (SigningRequest { - chain: Chain::Btc, - transaction: TransactionSpec::Solana { - from: "11111111111111111111111111111112".to_string(), - to: "11111111111111111111111111111113".to_string(), - lamports: 1, - recent_blockhash: "11111111111111111111111111111114".to_string(), + transaction: TransactionSpec::Tron { + raw_data_hex: "0a02b1f12208".to_string() + &"ab".repeat(64), + expected_to: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".to_string(), + expected_txid: "00".repeat(32), }, public_key: PublicKey { key_hex: "02".repeat(33), @@ -277,7 +279,7 @@ async fn refuses_a_chain_tag_that_contradicts_its_transaction(proxy: &tinybus::P ) .await; - let error = result.expect_err("a contradictory request must not produce a signature"); + let error = result.expect_err("a tampered transaction must not produce a signature"); assert_eq!( error.wire_name(), "ai.tinyhumans.tinywallet.Error.InvalidInput", @@ -288,7 +290,6 @@ async fn refuses_a_chain_tag_that_contradicts_its_transaction(proxy: &tinybus::P /// Drive both calls for a secp256k1 chain, signing locally in between. async fn round_trip( proxy: &tinybus::Proxy, - chain: Chain, spec: &TransactionSpec, secret: &[u8], ) -> SignedTransaction { @@ -300,7 +301,6 @@ async fn round_trip( .call( "BuildUnsigned", (SigningRequest { - chain, transaction: spec.clone(), public_key: public_key.clone(), },), @@ -330,7 +330,6 @@ async fn round_trip( .call( "AttachSignature", (AttachRequest { - chain, transaction: spec.clone(), public_key, signatures, diff --git a/src/wire/mod.rs b/src/wire/mod.rs index 77f6946..24eb721 100644 --- a/src/wire/mod.rs +++ b/src/wire/mod.rs @@ -118,9 +118,12 @@ pub struct PublicKey { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SigningRequest { - /// Which chain's rules apply. - pub chain: Chain, - /// The transaction fields, in the shape that chain's builder expects. + /// The transaction to build, which names its own chain. + /// + /// There is deliberately no separate `chain` field. Carrying one alongside + /// this would let a request say `btc` while holding an EVM transaction — + /// a state the backend would have to detect and reject at runtime. Reading + /// the chain off the variant instead makes that disagreement unrepresentable. pub transaction: TransactionSpec, /// The public key that will sign. pub public_key: PublicKey, @@ -133,9 +136,9 @@ pub struct SigningRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AttachRequest { - /// Which chain's rules apply. - pub chain: Chain, /// The same fields passed to the matching [`SigningRequest`]. + /// + /// As there, the chain comes from the variant rather than a parallel field. pub transaction: TransactionSpec, /// The public key that signed. pub public_key: PublicKey, @@ -183,8 +186,12 @@ pub enum TransactionSpec { to: String, /// Amount in satoshis. amount_sat: u64, - /// Fee rate in satoshis per virtual byte. - fee_rate_sat_vb: u64, + /// Absolute fee in satoshis. + /// + /// Bitcoin's fee is implicit — `sum(inputs) - sum(outputs)` — so it is + /// stated here rather than derived from a rate. A caller that thinks + /// in sat/vB converts before sending. + fee_sat: u64, /// Every spendable output held by `from`. utxos: Vec, }, @@ -234,6 +241,28 @@ pub enum TransactionSpec { }, } +impl TransactionSpec { + /// Which chain this transaction belongs to. + /// + /// The single source of truth for the chain, which is why neither request + /// type carries it separately. + /// + /// Infallible, and deliberately so despite `#[non_exhaustive]`. That + /// attribute binds only *downstream* crates, and a downstream crate calls + /// this method rather than matching the enum itself — so there is no + /// wildcard arm to write here, and adding a variant is a compile error in + /// this file, which is where it should be caught. + #[must_use] + pub fn chain(&self) -> Chain { + match self { + Self::Btc { .. } => Chain::Btc, + Self::Evm { .. } => Chain::Evm, + Self::Solana { .. } => Chain::Solana, + Self::Tron { .. } => Chain::Tron, + } + } +} + /// One spendable Bitcoin output. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/src/wire/test.rs b/src/wire/test.rs index 11b82fd..ecb111a 100644 --- a/src/wire/test.rs +++ b/src/wire/test.rs @@ -14,12 +14,10 @@ use super::{ AttachRequest, PublicKey, Scheme, Signature, SignedTransaction, SigningPayload, SigningRequest, TransactionSpec, UnsignedTransaction, Utxo, }; -use crate::chain::Chain; #[test] fn a_signing_request_round_trips_through_json() { let request = SigningRequest { - chain: Chain::Evm, transaction: TransactionSpec::Evm { to: "0x1111111111111111111111111111111111111111".to_string(), value_wei: "1000".to_string(), @@ -136,12 +134,11 @@ fn an_attach_request_carries_one_signature_per_payload() { ], }; let attach = AttachRequest { - chain: Chain::Btc, transaction: TransactionSpec::Btc { from: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), to: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(), amount_sat: 1_000, - fee_rate_sat_vb: 5, + fee_sat: 5, utxos: vec![], }, public_key: PublicKey { @@ -179,3 +176,57 @@ fn a_signed_transaction_may_omit_a_locally_unknowable_txid() { signed ); } + +#[test] +fn every_transaction_names_its_own_chain() { + // `chain()` is the single source of truth now that the requests carry no + // `chain` field, so a wrong arm here would route a transaction to the + // wrong chain's builder — with a real key already loaded. + use crate::chain::Chain; + + let cases = [ + ( + TransactionSpec::Btc { + from: String::new(), + to: String::new(), + amount_sat: 0, + fee_sat: 0, + utxos: Vec::new(), + }, + Chain::Btc, + ), + ( + TransactionSpec::Evm { + to: String::new(), + value_wei: "0".to_string(), + data_hex: String::new(), + nonce: 0, + gas_limit: 0, + gas_price_wei: "0".to_string(), + chain_id: 1, + }, + Chain::Evm, + ), + ( + TransactionSpec::Solana { + from: String::new(), + to: String::new(), + lamports: 0, + recent_blockhash: String::new(), + }, + Chain::Solana, + ), + ( + TransactionSpec::Tron { + raw_data_hex: String::new(), + expected_to: String::new(), + expected_txid: String::new(), + }, + Chain::Tron, + ), + ]; + + for (spec, expected) in cases { + assert_eq!(spec.chain(), expected); + } +}