diff --git a/Cargo.toml b/Cargo.toml index 823a365..fb030a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,12 +155,26 @@ x402 = ["dep:serde", "dep:serde_json", "serde/derive", "eip712"] # `default-features = false`, share one definition of the contract, and link no # chain library at all. Same carve-out `tinydocs::spec` makes. wire = ["serde"] +# The half of `tinywallet::tx` that reads and checks bytes rather than +# producing signatures: `tx::proto` (the structural protobuf reader) and the +# verification half of `tx::tron` — `recompute_txid`, `verify_transfer`, +# `verify_contract`, `attach_signature`, `signature_hex`, `digest`. +# +# Split out because those are exactly what a host needs when signing itself +# lives somewhere else. A host that has moved transaction *building* into a +# loadable module still has to verify what a Tron node handed back before it +# signs — and verification is `&[u8]` walking plus sha2, with no reason to +# drag in `bitcoin` and a native secp256k1 build to reach it. +# +# Implied by `tx`, so nothing that took `tx` before sees a change. +tx-codec = ["dep:sha2"] # 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 # — is deliberately reachable without it, so a host that has moved signing into # a loadable module sheds `bitcoin` and its native build entirely. tx = [ + "tx-codec", "btc", "evm", "keccak", diff --git a/src/lib.rs b/src/lib.rs index 669f180..5799c57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,7 +69,7 @@ pub mod eip712; pub mod key; #[cfg(feature = "net")] pub mod rpc; -#[cfg(feature = "tx")] +#[cfg(feature = "tx-codec")] pub mod tx; #[cfg(feature = "wire")] pub mod wire; diff --git a/src/tx/mod.rs b/src/tx/mod.rs index 109b771..9c0f7a9 100644 --- a/src/tx/mod.rs +++ b/src/tx/mod.rs @@ -12,11 +12,15 @@ //! well-formed transaction that moves the wrong funds, or one that is valid on //! a chain the user did not intend. -#[cfg(feature = "btc")] +#[cfg(all(feature = "tx", feature = "btc"))] pub mod btc; +#[cfg(feature = "tx")] pub mod evm; +#[cfg(feature = "tron")] +pub mod proto; +#[cfg(feature = "tx")] mod rlp; -#[cfg(feature = "solana")] +#[cfg(all(feature = "tx", feature = "solana"))] pub mod solana; #[cfg(feature = "tron")] pub mod tron; diff --git a/src/tx/proto.rs b/src/tx/proto.rs new file mode 100644 index 0000000..2d47a5f --- /dev/null +++ b/src/tx/proto.rs @@ -0,0 +1,212 @@ +//! The sliver of protobuf wire format a Tron transaction actually needs. +//! +//! Tron has the node build a transaction and hand it back, so a client that +//! signs what it is given signs whatever a compromised endpoint chose to +//! return. Checking it means reading the `raw_data` bytes — and `raw_data` is +//! protobuf. +//! +//! A full protobuf implementation is a schema compiler plus a runtime. What is +//! needed here is a reader for four wire types over a message whose shape is +//! already known, which is why this is ~120 lines of `&[u8]` walking rather +//! than a `prost` dependency and a `build.rs`. The parser is deliberately +//! *structural*: it recovers field numbers and their raw values and stops +//! there, leaving the meaning of field 11 to [`super::tron`]. +//! +//! It borrows throughout — [`Value::Bytes`] points into the caller's buffer — +//! so parsing a nested message costs no allocation beyond the field vector. +//! +//! # Strictness +//! +//! Every accessor here is *singular*: it refuses a field that repeats rather +//! than taking the first or the last. That is not pedantry about the spec, +//! which does permit repetition. It is that "last one wins" is exactly how a +//! malicious node smuggles a second recipient past a checker that reads the +//! first — so a repeated singular field is treated as the attack it would be, +//! not as a value to disambiguate. + +use super::{Error, Result}; + +/// A field's value, as far as the wire format alone can tell. +#[derive(Debug)] +pub enum Value<'a> { + /// Wire type 0: a base-128 varint. + Varint(u64), + /// Wire type 2: a length-delimited byte run, borrowed from the input. + Bytes(&'a [u8]), + /// Wire types 1 and 5: fixed-width, skipped rather than decoded. + /// + /// Nothing this crate reads out of a Tron transaction is fixed-width, so + /// decoding them would be unused code on a security-relevant path. They + /// are still *consumed* correctly, because the parser has to stay in sync + /// with the byte stream to read the fields that do matter. + Other, +} + +/// One field of a protobuf message: its number, and its value. +#[derive(Debug)] +pub struct Field<'a> { + /// The field number, as declared in the `.proto`. + pub number: u64, + /// The decoded value. + pub value: Value<'a>, +} + +fn invalid(reason: impl Into) -> Error { + Error::InvalidField { + field: "protobuf", + reason: reason.into(), + } +} + +/// Encode a `u64` as a base-128 varint. +/// +/// The inverse of [`take_varint`], and the only writer here: it is needed to +/// re-encode a field when checking a node's transaction byte-for-byte. +#[must_use] +pub fn encode_varint(mut value: u64) -> Vec { + let mut encoded = Vec::new(); + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + encoded.push(byte); + if value == 0 { + return encoded; + } + } +} + +/// Parse a flat protobuf message into its fields. +/// +/// Does not recurse: a nested message arrives as [`Value::Bytes`] and is +/// parsed by calling this again on it. That keeps the borrow flat and lets the +/// caller decide how deep to go. +/// +/// # Errors +/// +/// [`Error::InvalidField`] if the input is truncated, carries field number +/// zero, uses a wire type this parser does not implement (3 and 4, the +/// deprecated groups), or contains a varint that overruns 64 bits. +pub fn parse_fields(mut input: &[u8]) -> Result>> { + let mut fields = Vec::new(); + while !input.is_empty() { + let key = take_varint(&mut input)?; + let number = key >> 3; + if number == 0 { + return Err(invalid("contains field zero")); + } + let value = match key & 0x07 { + 0 => Value::Varint(take_varint(&mut input)?), + 1 => { + take_exact(&mut input, 8)?; + Value::Other + } + 2 => { + let length = usize::try_from(take_varint(&mut input)?) + .map_err(|_| invalid("field length is too large"))?; + Value::Bytes(take_exact(&mut input, length)?) + } + 5 => { + take_exact(&mut input, 4)?; + Value::Other + } + wire => return Err(invalid(format!("unsupported wire type {wire}"))), + }; + fields.push(Field { number, value }); + } + Ok(fields) +} + +/// Read exactly one length-delimited field. +/// +/// # Errors +/// +/// [`Error::InvalidField`] if the field is absent, repeated, or not +/// length-delimited. +pub fn one_bytes<'a>(fields: &[Field<'a>], number: u64, name: &str) -> Result<&'a [u8]> { + match single(fields, number, name)? { + Value::Bytes(value) => Ok(value), + _ => Err(invalid(format!("field {name} has the wrong wire type"))), + } +} + +/// Read exactly one varint field. +/// +/// # Errors +/// +/// [`Error::InvalidField`] if the field is absent, repeated, or not a varint. +pub fn one_varint(fields: &[Field<'_>], number: u64, name: &str) -> Result { + optional_varint(fields, number, name)?.ok_or_else(|| invalid(format!("is missing {name}"))) +} + +/// Read at most one varint field. +/// +/// `Ok(None)` means absent, which for an optional protobuf field is a value +/// rather than an error — but a *repeated* one is still refused. +/// +/// # Errors +/// +/// [`Error::InvalidField`] if the field repeats or is not a varint. +pub fn optional_varint(fields: &[Field<'_>], number: u64, name: &str) -> Result> { + let mut matches = fields.iter().filter(|field| field.number == number); + let Some(field) = matches.next() else { + return Ok(None); + }; + if matches.next().is_some() { + return Err(invalid(format!("repeats singular field {name}"))); + } + match field.value { + Value::Varint(value) => Ok(Some(value)), + _ => Err(invalid(format!("field {name} has the wrong wire type"))), + } +} + +/// The shared "exactly one, or refuse" lookup behind the accessors above. +fn single<'f, 'a>(fields: &'f [Field<'a>], number: u64, name: &str) -> Result<&'f Value<'a>> { + let mut matches = fields.iter().filter(|field| field.number == number); + let Some(field) = matches.next() else { + return Err(invalid(format!("is missing {name}"))); + }; + if matches.next().is_some() { + return Err(invalid(format!("repeats singular field {name}"))); + } + Ok(&field.value) +} + +/// Decode one base-128 varint, advancing `input` past it. +fn take_varint(input: &mut &[u8]) -> Result { + let mut value = 0u64; + for shift in (0..=63).step_by(7) { + let (&byte, rest) = input + .split_first() + .ok_or_else(|| invalid("truncated varint"))?; + *input = rest; + let part = u64::from(byte & 0x7f); + // At shift 63 only one bit is left, so a part above 1 would silently + // discard the high bits rather than overflow. + if shift == 63 && part > 1 { + return Err(invalid("varint overflows u64")); + } + value |= part << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err(invalid("varint is too long")) +} + +/// Split exactly `length` bytes off the front of `input`. +fn take_exact<'a>(input: &mut &'a [u8], length: usize) -> Result<&'a [u8]> { + if input.len() < length { + return Err(invalid("truncated field")); + } + let (value, rest) = input.split_at(length); + *input = rest; + Ok(value) +} + +#[cfg(test)] +#[path = "proto/test.rs"] +mod test; diff --git a/src/tx/proto/test.rs b/src/tx/proto/test.rs new file mode 100644 index 0000000..032dcec --- /dev/null +++ b/src/tx/proto/test.rs @@ -0,0 +1,172 @@ +//! Tests for the Tron protobuf reader. +//! +//! The parser exists to catch a node that returned something other than what +//! was asked for, so most of these are malformed or adversarial inputs rather +//! than happy-path decodes. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::{Value, encode_varint, one_bytes, one_varint, optional_varint, parse_fields}; + +/// Build a `(number, wire_type)` protobuf key byte-sequence. +fn key(number: u64, wire: u64) -> Vec { + encode_varint((number << 3) | wire) +} + +/// Build one length-delimited field. +fn bytes_field(number: u64, payload: &[u8]) -> Vec { + let mut out = key(number, 2); + out.extend(encode_varint(payload.len() as u64)); + out.extend(payload); + out +} + +/// Build one varint field. +fn varint_field(number: u64, value: u64) -> Vec { + let mut out = key(number, 0); + out.extend(encode_varint(value)); + out +} + +#[test] +fn varint_round_trips_across_the_encoding_boundaries() { + // 127/128 and 16383/16384 are where the continuation bit turns on. + for value in [0, 1, 127, 128, 16383, 16384, u64::MAX] { + let encoded = encode_varint(value); + let field = { + let mut out = key(1, 0); + out.extend(&encoded); + out + }; + let fields = parse_fields(&field).unwrap(); + assert_eq!(one_varint(&fields, 1, "f").unwrap(), value); + } +} + +#[test] +fn parses_a_flat_message_of_mixed_wire_types() { + let mut input = varint_field(3, 42); + input.extend(bytes_field(2, b"hello")); + let fields = parse_fields(&input).unwrap(); + + assert_eq!(fields.len(), 2); + assert_eq!(one_varint(&fields, 3, "amount").unwrap(), 42); + assert_eq!(one_bytes(&fields, 2, "to").unwrap(), b"hello"); +} + +#[test] +fn nested_messages_are_parsed_by_recursing_on_the_borrowed_bytes() { + let inner = varint_field(1, 7); + let outer = bytes_field(11, &inner); + + let outer_fields = parse_fields(&outer).unwrap(); + let inner_bytes = one_bytes(&outer_fields, 11, "contract").unwrap(); + let inner_fields = parse_fields(inner_bytes).unwrap(); + + assert_eq!(one_varint(&inner_fields, 1, "type").unwrap(), 7); +} + +#[test] +fn fixed_width_wire_types_are_skipped_but_stay_in_sync() { + // A fixed64 and a fixed32 the parser does not decode, followed by a field + // it must still read correctly — the point being that miscounting either + // width would desynchronise the stream and corrupt what follows. + let mut input = key(1, 1); + input.extend([0u8; 8]); + input.extend(key(2, 5)); + input.extend([0u8; 4]); + input.extend(varint_field(3, 99)); + + let fields = parse_fields(&input).unwrap(); + assert!(matches!(fields[0].value, Value::Other)); + assert!(matches!(fields[1].value, Value::Other)); + assert_eq!(one_varint(&fields, 3, "after").unwrap(), 99); +} + +#[test] +fn a_repeated_singular_field_is_refused_rather_than_disambiguated() { + // The attack this guards: a node appends a second recipient, betting the + // checker reads one occurrence and the chain reads the other. + let mut input = bytes_field(2, b"first"); + input.extend(bytes_field(2, b"second")); + + let fields = parse_fields(&input).unwrap(); + assert!(one_bytes(&fields, 2, "to_address").is_err()); + + let mut varints = varint_field(3, 1); + varints.extend(varint_field(3, 2)); + let fields = parse_fields(&varints).unwrap(); + assert!(one_varint(&fields, 3, "amount").is_err()); + // Optional reads refuse repetition too — absent is a value, repeated is not. + assert!(optional_varint(&fields, 3, "amount").is_err()); +} + +#[test] +fn an_absent_optional_field_is_none_but_an_absent_required_one_is_an_error() { + let input = varint_field(1, 5); + let fields = parse_fields(&input).unwrap(); + assert_eq!(optional_varint(&fields, 18, "fee_limit").unwrap(), None); + assert!(one_varint(&fields, 18, "fee_limit").is_err()); + assert!(one_bytes(&fields, 18, "data").is_err()); +} + +#[test] +fn a_field_read_at_the_wrong_wire_type_is_refused() { + let varint = varint_field(3, 42); + let fields = parse_fields(&varint).unwrap(); + assert!(one_bytes(&fields, 3, "amount").is_err()); + + let bytes = bytes_field(2, b"x"); + let fields = parse_fields(&bytes).unwrap(); + assert!(one_varint(&fields, 2, "to").is_err()); + assert!(optional_varint(&fields, 2, "to").is_err()); +} + +#[test] +fn field_number_zero_is_refused() { + // Field 0 is illegal in protobuf; accepting it would let a crafted key + // byte introduce a field no schema can name. + assert!(parse_fields(&varint_field(0, 1)).is_err()); +} + +#[test] +fn unsupported_wire_types_are_refused_rather_than_skipped() { + // Wire types 3 and 4 are the deprecated start/end-group markers. Skipping + // an unknown type is impossible without knowing its width, so guessing + // would desynchronise the stream. + for wire in [3, 4, 6, 7] { + assert!(parse_fields(&key(1, wire)).is_err(), "wire type {wire}"); + } +} + +#[test] +fn truncated_input_is_refused_at_every_stage() { + // Truncated varint: continuation bit set, nothing follows. + assert!(parse_fields(&[0x08, 0x80]).is_err()); + // Truncated length-delimited payload: claims 5 bytes, supplies 2. + assert!(parse_fields(&[0x12, 0x05, b'a', b'b']).is_err()); + // Truncated fixed64: claims 8 bytes, supplies 3. + assert!(parse_fields(&[0x09, 0, 0, 0]).is_err()); +} + +#[test] +fn a_varint_that_overruns_64_bits_is_refused() { + // Ten continuation bytes then a final byte above 1 — the tenth group + // carries a single usable bit, so anything larger would be discarded + // silently rather than overflow. + let mut overlong = vec![0x08]; + overlong.extend(std::iter::repeat_n(0xff, 9)); + overlong.push(0x02); + assert!(parse_fields(&overlong).is_err()); + + // Eleven groups is too long regardless of the values. + let mut too_long = vec![0x08]; + too_long.extend(std::iter::repeat_n(0x80, 11)); + too_long.push(0x00); + assert!(parse_fields(&too_long).is_err()); +} + +#[test] +fn an_empty_message_parses_to_no_fields() { + assert!(parse_fields(&[]).unwrap().is_empty()); +} diff --git a/src/tx/tron.rs b/src/tx/tron.rs index 7a9767e..0633e66 100644 --- a/src/tx/tron.rs +++ b/src/tx/tron.rs @@ -8,6 +8,8 @@ //! That means this module never serialises a transaction — there is no //! protobuf encoder here, and deliberately so, because reimplementing Tron's //! `raw_data` schema would be a large surface that the node already owns. +//! Reading one is a different matter, and [`super::proto`] does exactly as +//! much of it as verification needs. //! //! ## But it does mean the node's answer must be verified //! @@ -16,16 +18,28 @@ //! return a `raw_data` paying a different address, and a client that signs //! blind would authorise it. //! -//! [`recompute_txid`] is the defence: the `txID` is `sha256(raw_data)`, so a -//! client can confirm the id it signs actually matches the bytes it was given. -//! That catches a tampered or corrupted response, though it cannot by itself -//! prove the *contents* match the request — [`verify_transfer`] does that, by -//! checking the recipient and amount appear in the returned bytes. +//! There are two checks, and the difference between them matters: +//! +//! - [`recompute_txid`] confirms the `txID` is `sha256(raw_data)`, so the id +//! being signed matches the bytes that were received. That catches a +//! tampered or corrupted response but says nothing about the contents. +//! - [`verify_contract`] parses `raw_data` and checks the contract type, the +//! recipient at its declared field number, the amount, and — for TRC-20 — +//! the calldata and `call_value`. **This is the one to use.** +//! +//! [`verify_transfer`] predates it and only scans the hex for the recipient's +//! bytes. A substring match is weaker than it looks: the address appearing +//! *somewhere* does not make it the `to_address` being signed, and the amount +//! is not checked at all. Two tests below pin exactly that gap — a decoy field +//! and a substituted amount both pass `verify_transfer` and fail +//! `verify_contract`. Prefer the latter wherever the caller knows what it +//! asked for. +#[cfg(feature = "tx")] use bitcoin::secp256k1::{Message, Secp256k1, SecretKey}; use sha2::{Digest, Sha256}; -use super::{Error, Result}; +use super::{Error, Result, proto}; /// The 65-byte signature Tron expects: `r || s || recovery_id`. /// @@ -77,6 +91,175 @@ pub fn verify_transfer(raw_data_hex: &str, to: &str, txid: &str) -> Result<()> { Ok(()) } +/// What transfer the node was asked to build, for [`verify_contract`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Transfer { + /// A native TRX transfer of `amount_sun`. + Native { + /// The amount, in SUN. + amount_sun: u64, + }, + /// A TRC-20 `transfer(address,uint256)` call. + Trc20 { + /// The ABI-encoded call parameters, hex, without the selector. + parameter_hex: String, + /// The `fee_limit` the request specified, in SUN, if it set one. + fee_limit_sun: Option, + }, +} + +/// `keccak256("transfer(address,uint256)")[..4]`, as hex. +const TRC20_TRANSFER_SELECTOR_HEX: &str = "a9059cbb"; + +/// Tron's `ContractType` for a native transfer. +const CONTRACT_TYPE_TRANSFER: u64 = 1; +/// Tron's `ContractType` for a smart-contract call. +const CONTRACT_TYPE_TRIGGER_SMART_CONTRACT: u64 = 31; + +fn untrusted(reason: impl Into) -> Error { + Error::UntrustedResponse { + reason: reason.into(), + } +} + +/// Verify a node-built transaction by **parsing** its `raw_data`. +/// +/// [`verify_transfer`] confirms the `txID` matches the bytes and that the +/// recipient's hex appears somewhere in them. That is a substring scan, and a +/// substring scan is weaker than it looks: the recipient appearing *somewhere* +/// does not mean it is the `to_address` of the contract being signed. It could +/// be in an unrelated field, or the real recipient could be a second, later +/// occurrence. +/// +/// This reads the protobuf structurally instead — contract type, the +/// recipient at its declared field number, the amount, and for TRC-20 the full +/// calldata including the selector — and refuses a message whose singular +/// fields repeat. Prefer it wherever the caller knows what it asked for. +/// +/// # Errors +/// +/// [`Error::Address`] if `to` is not a valid Tron address, +/// [`Error::InvalidField`] if `raw_data_hex` is not valid hex or not +/// well-formed protobuf, and [`Error::UntrustedResponse`] if the transaction +/// does not encode the transfer described by `transfer`. +pub fn verify_contract( + raw_data_hex: &str, + to: &str, + txid: &str, + transfer: &Transfer, +) -> Result<()> { + let expected_id = recompute_txid(raw_data_hex)?; + if !expected_id.eq_ignore_ascii_case(txid.trim()) { + return Err(untrusted( + "txID does not match sha256(raw_data); the response was altered", + )); + } + + let raw = decode_hex(raw_data_hex)?; + let expected_recipient = + decode_hex(&crate::address::tron::to_hex(to).map_err(Error::Address)?)?; + + let raw_fields = proto::parse_fields(&raw)?; + let contract = parse_single_contract(&raw_fields)?; + + match transfer { + Transfer::Native { amount_sun } => { + if contract.kind != CONTRACT_TYPE_TRANSFER + || !contract.type_url.ends_with(".TransferContract") + { + return Err(untrusted("the transaction is not a native transfer")); + } + let payload = proto::parse_fields(contract.payload)?; + if proto::one_bytes(&payload, 2, "TransferContract.to_address")? != expected_recipient { + return Err(untrusted( + "the transaction does not pay the requested recipient", + )); + } + if proto::one_varint(&payload, 3, "TransferContract.amount")? != *amount_sun { + return Err(untrusted("the transaction has a different native amount")); + } + } + Transfer::Trc20 { + parameter_hex, + fee_limit_sun, + } => { + if contract.kind != CONTRACT_TYPE_TRIGGER_SMART_CONTRACT + || !contract.type_url.ends_with(".TriggerSmartContract") + { + return Err(untrusted("the transaction is not a smart-contract trigger")); + } + let payload = proto::parse_fields(contract.payload)?; + if proto::one_bytes(&payload, 2, "TriggerSmartContract.contract_address")? + != expected_recipient + { + return Err(untrusted("the transaction targets a different contract")); + } + // A TRC-20 transfer moves no TRX. A non-zero call_value would send + // native funds alongside the token transfer that was requested. + let call_value = + proto::optional_varint(&payload, 3, "TriggerSmartContract.call_value")? + .unwrap_or(0); + if call_value != 0 { + return Err(untrusted( + "the transaction has a non-zero TRC-20 call_value", + )); + } + if let (Some(expected), Some(actual)) = ( + *fee_limit_sun, + proto::optional_varint(&raw_fields, 18, "Transaction.raw.fee_limit")?, + ) && actual != expected + { + return Err(untrusted("the transaction has a different fee_limit")); + } + + let mut expected_data = decode_hex(TRC20_TRANSFER_SELECTOR_HEX)?; + expected_data.extend(decode_hex(parameter_hex)?); + if proto::one_bytes(&payload, 4, "TriggerSmartContract.data")? != expected_data { + return Err(untrusted( + "the transaction has different TRC-20 transfer data", + )); + } + } + } + + Ok(()) +} + +/// The one contract carried by a Tron transaction, unwrapped from its `Any`. +struct ParsedContract<'a> { + kind: u64, + type_url: &'a str, + payload: &'a [u8], +} + +/// Unwrap `Transaction.raw.contract[0]` and its `google.protobuf.Any`. +/// +/// Tron's schema makes `contract` repeated, but a transaction has only ever +/// carried one — and [`proto::one_bytes`] refusing a second is the point: two +/// contracts would mean signing something beyond what was checked. +fn parse_single_contract<'a>(raw_fields: &[proto::Field<'a>]) -> Result> { + let contract_bytes = proto::one_bytes(raw_fields, 11, "Transaction.raw.contract")?; + let contract_fields = proto::parse_fields(contract_bytes)?; + let kind = proto::one_varint(&contract_fields, 1, "Transaction.Contract.type")?; + let any_bytes = proto::one_bytes(&contract_fields, 2, "Transaction.Contract.parameter")?; + let any_fields = proto::parse_fields(any_bytes)?; + let type_url = + std::str::from_utf8(proto::one_bytes(&any_fields, 1, "Any.type_url")?).map_err(|_| { + Error::InvalidField { + field: "Any.type_url", + reason: "is not UTF-8".to_string(), + } + })?; + let payload = proto::one_bytes(&any_fields, 2, "Any.value")?; + Ok(ParsedContract { + kind, + type_url, + payload, + }) +} + +#[cfg(feature = "tx")] /// Sign a Tron `raw_data` payload. /// /// Signs `sha256(raw_data)` — the same value as the `txID`. @@ -172,7 +355,10 @@ fn hex_lower(bytes: &[u8]) -> String { mod test { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use super::{recompute_txid, sign, signature_hex, verify_transfer}; + use super::{ + CONTRACT_TYPE_TRANSFER, CONTRACT_TYPE_TRIGGER_SMART_CONTRACT, TRC20_TRANSFER_SELECTOR_HEX, + hex_lower, recompute_txid, sign, signature_hex, verify_transfer, + }; use crate::tx::Error; const VECTOR: &str = "abandon abandon abandon abandon abandon abandon \ @@ -240,6 +426,288 @@ mod test { assert!(verify_transfer(&raw, TO, &id).is_ok()); } + // ---- verify_contract: the structural check ----------------------------- + + use super::{Transfer, 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 = Transfer::Native { + amount_sun: 1_000_000, + }; + assert!(verify_contract(&raw, TO, &id, &transfer).is_ok()); + } + + #[test] + fn a_native_transfer_for_a_different_amount_is_rejected() { + // verify_transfer cannot see this at all: the amount is a varint it + // never locates, so only the structural check catches a node that + // built the right recipient with the wrong value. + let raw = native_raw(TO, 1_000_000); + let id = recompute_txid(&raw).unwrap(); + + assert!( + verify_transfer(&raw, TO, &id).is_ok(), + "the weak check passes" + ); + + let transfer = Transfer::Native { amount_sun: 42 }; + match verify_contract(&raw, TO, &id, &transfer).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(); + + assert!( + verify_transfer(&raw, TO, &id).is_ok(), + "the weak check is fooled by the decoy" + ); + + let transfer = Transfer::Native { + amount_sun: 1_000_000, + }; + match verify_contract(&raw, TO, &id, &transfer).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 = Transfer::Native { amount_sun: 5 }; + match verify_contract(&raw, TO, &id, &transfer).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 = Transfer::Trc20 { + parameter_hex: param, + fee_limit_sun: Some(150_000_000), + }; + assert!(verify_contract(&raw, TO, &id, &transfer).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 = Transfer::Trc20 { + parameter_hex: trc20_parameter(TO, 9_999), + fee_limit_sun: None, + }; + match verify_contract(&raw, TO, &id, &transfer).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("different TRC-20 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 = Transfer::Trc20 { + parameter_hex: param, + fee_limit_sun: None, + }; + match verify_contract(&raw, TO, &id, &transfer).unwrap_err() { + Error::UntrustedResponse { reason } => { + assert!(reason.contains("non-zero TRC-20 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 = Transfer::Trc20 { + parameter_hex: param, + fee_limit_sun: Some(150_000_000), + }; + match verify_contract(&raw, TO, &id, &transfer).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 = Transfer::Native { + amount_sun: 1_000_000, + }; + assert!(verify_contract(&raw, TO, &id, &transfer).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 = Transfer::Native { + amount_sun: 1_000_000, + }; + match verify_contract(&tampered, TO, &id, &transfer).unwrap_err() { + Error::UntrustedResponse { reason } => assert!(reason.contains("altered")), + other => panic!("expected UntrustedResponse, got {other:?}"), + } + } + #[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.