Skip to main content

walletsuite_tx_compiler/
validate.rs

1//! Runtime validation of raw prepared-transaction JSON into a typed
2//! [`PreparedTransaction`].
3//!
4//! The validator enforces the canonical `PreparedTransaction` invariants
5//! so any payload it accepts can be safely passed to `compile()` without
6//! triggering panics or partial encodes. Numeric fields that may arrive
7//! as JSON `number` are coerced to canonical decimal strings.
8
9use serde_json::{Map, Value};
10
11use crate::constants::{ERC20_TRANSFER_CALLDATA_HEX_LEN, ERC20_TRANSFER_SELECTOR_HEX};
12use crate::error::{TxCompilerError, TxCompilerErrorCode};
13use crate::tron_address::tron_address_to_bytes;
14use crate::types::{Chain, FeeMode, FeeParams, PreparedTransaction, TronBlockHeader, TxType};
15
16const EVM_ADDRESS_LEN: usize = 42;
17/// JavaScript safe integer ceiling (`2^53 − 1`), used for EVM nonce bounds.
18const MAX_SAFE_JS_INT: u64 = (1 << 53) - 1;
19/// Upper bound on the informational Tron block-header fields
20/// (`p` / `r` / `w`). 128 bytes comfortably covers any real hex hash
21/// or base58 address; anything longer is payload abuse.
22const MAX_INFO_FIELD_LEN: usize = 128;
23
24/// Validate an arbitrary JSON payload into a [`PreparedTransaction`].
25///
26/// Rejects malformed payloads with stable error codes before any
27/// irreversible work reaches `compile()`.
28pub fn validate(input: &Value) -> Result<PreparedTransaction, TxCompilerError> {
29    let obj = input
30        .as_object()
31        .ok_or_else(|| invalid_payload("Expected a non-null object"))?;
32
33    let chain = require_chain(obj)?;
34    let tx_type = require_tx_type(obj)?;
35    let from = require_non_empty_string(obj, "from")?;
36    let to = require_non_empty_string(obj, "to")?;
37    let value_wei = require_int_string(obj, "valueWei")?;
38    let data = optional_string(obj, "data");
39    let token_contract = optional_string(obj, "tokenContract");
40    let chain_id = optional_number(obj, "chainId");
41    let nonce = optional_int_string(obj, "nonce")?;
42    let fee = validate_fee(obj.get("fee"), chain)?;
43
44    match chain {
45        Chain::Ethereum => validate_evm_fields(
46            chain_id,
47            nonce.as_deref(),
48            &from,
49            &to,
50            token_contract.as_deref(),
51        )?,
52        Chain::Tron => validate_tron_fields(&from, &to, token_contract.as_deref())?,
53    }
54
55    if tx_type == TxType::TransferNative && chain == Chain::Ethereum {
56        if let Some(ref d) = data {
57            if !d.is_empty() && d != "0x" {
58                return Err(TxCompilerError::with_details(
59                    TxCompilerErrorCode::InvalidPayload,
60                    "EVM TRANSFER_NATIVE must not carry calldata",
61                    serde_json::json!({ "dataLength": d.len() }),
62                ));
63            }
64        }
65    }
66
67    if tx_type == TxType::TransferToken {
68        let token = token_contract.as_deref().ok_or_else(|| {
69            TxCompilerError::new(
70                TxCompilerErrorCode::InvalidPayload,
71                "TRANSFER_TOKEN requires tokenContract",
72            )
73        })?;
74
75        if chain == Chain::Ethereum {
76            validate_evm_token_transfer(data.as_deref(), &to, token, &value_wei)?;
77        }
78    }
79
80    Ok(PreparedTransaction {
81        chain,
82        chain_id,
83        from,
84        to,
85        value_wei,
86        data,
87        tx_type,
88        token_contract,
89        nonce,
90        fee,
91    })
92}
93
94// ---------------------------------------------------------------------------
95// Top-level enum helpers
96// ---------------------------------------------------------------------------
97
98fn require_chain(obj: &Map<String, Value>) -> Result<Chain, TxCompilerError> {
99    match obj.get("chain").and_then(Value::as_str) {
100        Some("ethereum") => Ok(Chain::Ethereum),
101        Some("tron") => Ok(Chain::Tron),
102        other => Err(TxCompilerError::new(
103            TxCompilerErrorCode::UnsupportedChain,
104            format!("Invalid chain: {}", display_value(other)),
105        )),
106    }
107}
108
109fn require_tx_type(obj: &Map<String, Value>) -> Result<TxType, TxCompilerError> {
110    match obj.get("txType").and_then(Value::as_str) {
111        Some("TRANSFER_NATIVE") => Ok(TxType::TransferNative),
112        Some("TRANSFER_TOKEN") => Ok(TxType::TransferToken),
113        other => Err(TxCompilerError::new(
114            TxCompilerErrorCode::UnsupportedTxType,
115            format!("Invalid txType: {}", display_value(other)),
116        )),
117    }
118}
119
120fn require_fee_mode(obj: &Map<String, Value>) -> Result<FeeMode, TxCompilerError> {
121    match obj.get("mode").and_then(Value::as_str) {
122        Some("EIP1559") => Ok(FeeMode::Eip1559),
123        Some("LEGACY") => Ok(FeeMode::Legacy),
124        Some("TRON") => Ok(FeeMode::Tron),
125        other => Err(TxCompilerError::new(
126            TxCompilerErrorCode::UnsupportedFeeMode,
127            format!("Invalid mode: {}", display_value(other)),
128        )),
129    }
130}
131
132fn display_value(v: Option<&str>) -> String {
133    v.map_or_else(|| "(missing)".to_string(), ToString::to_string)
134}
135
136// ---------------------------------------------------------------------------
137// Chain-specific address validation
138// ---------------------------------------------------------------------------
139
140fn validate_evm_fields(
141    chain_id: Option<i64>,
142    nonce: Option<&str>,
143    from: &str,
144    to: &str,
145    token_contract: Option<&str>,
146) -> Result<(), TxCompilerError> {
147    match chain_id {
148        Some(id) if id >= 1 => {}
149        other => {
150            return Err(TxCompilerError::with_details(
151                TxCompilerErrorCode::InvalidPayload,
152                "EVM chainId must be a positive integer",
153                serde_json::json!({ "chainId": other }),
154            ));
155        }
156    }
157
158    let nonce = nonce.ok_or_else(|| {
159        TxCompilerError::new(
160            TxCompilerErrorCode::InvalidPayload,
161            "EVM transactions require nonce",
162        )
163    })?;
164    match nonce.parse::<u64>() {
165        Ok(n) if n <= MAX_SAFE_JS_INT => {}
166        _ => {
167            return Err(TxCompilerError::with_details(
168                TxCompilerErrorCode::InvalidPayload,
169                "EVM nonce exceeds safe integer range (max 2^53 - 1)",
170                serde_json::json!({ "nonce": nonce }),
171            ));
172        }
173    }
174
175    validate_evm_address(from, "from")?;
176    validate_evm_address(to, "to")?;
177    if let Some(tc) = token_contract {
178        validate_evm_address(tc, "tokenContract")?;
179    }
180    Ok(())
181}
182
183fn validate_tron_fields(
184    from: &str,
185    to: &str,
186    token_contract: Option<&str>,
187) -> Result<(), TxCompilerError> {
188    validate_tron_address(from, "from")?;
189    validate_tron_address(to, "to")?;
190    if let Some(tc) = token_contract {
191        validate_tron_address(tc, "tokenContract")?;
192    }
193    Ok(())
194}
195
196fn validate_evm_address(address: &str, field: &str) -> Result<(), TxCompilerError> {
197    if address.len() != EVM_ADDRESS_LEN {
198        return Err(invalid_address_for(field, address));
199    }
200    if !address.starts_with("0x") {
201        return Err(invalid_address_for(field, address));
202    }
203    if !address[2..].bytes().all(|b| b.is_ascii_hexdigit()) {
204        return Err(invalid_address_for(field, address));
205    }
206    Ok(())
207}
208
209fn validate_tron_address(address: &str, field: &str) -> Result<(), TxCompilerError> {
210    tron_address_to_bytes(address)
211        .map(|_| ())
212        .map_err(|_| invalid_address_for(field, address))
213}
214
215fn invalid_address_for(field: &str, address: &str) -> TxCompilerError {
216    // Keep the raw address out of the human-readable message text; put
217    // it in `details` only, where log redaction is callers' choice.
218    TxCompilerError::with_details(
219        TxCompilerErrorCode::InvalidAddress,
220        format!("Invalid address in '{field}'"),
221        serde_json::json!({ "field": field, "address": address }),
222    )
223}
224
225// ---------------------------------------------------------------------------
226// EVM TRANSFER_TOKEN shape validation
227// ---------------------------------------------------------------------------
228
229fn validate_evm_token_transfer(
230    data: Option<&str>,
231    to: &str,
232    token_contract: &str,
233    value_wei: &str,
234) -> Result<(), TxCompilerError> {
235    let data = match data {
236        Some(d) if !d.is_empty() && d != "0x" => d,
237        _ => {
238            return Err(TxCompilerError::new(
239                TxCompilerErrorCode::InvalidPayload,
240                "EVM TRANSFER_TOKEN requires calldata",
241            ));
242        }
243    };
244
245    let hex = data.strip_prefix("0x").unwrap_or(data);
246
247    if !is_hex(hex) {
248        return Err(TxCompilerError::new(
249            TxCompilerErrorCode::InvalidCalldata,
250            "EVM TRANSFER_TOKEN calldata contains non-hex characters",
251        ));
252    }
253
254    if hex.len() != ERC20_TRANSFER_CALLDATA_HEX_LEN {
255        return Err(TxCompilerError::with_details(
256            TxCompilerErrorCode::InvalidCalldata,
257            format!(
258                "EVM TRANSFER_TOKEN calldata must be exactly 68 bytes (got {})",
259                hex.len() / 2
260            ),
261            serde_json::json!({
262                "length": hex.len(),
263                "expected": ERC20_TRANSFER_CALLDATA_HEX_LEN
264            }),
265        ));
266    }
267
268    let selector = hex[0..8].to_ascii_lowercase();
269    if selector != ERC20_TRANSFER_SELECTOR_HEX {
270        return Err(TxCompilerError::new(
271            TxCompilerErrorCode::InvalidCalldata,
272            format!(
273                "Expected ERC-20 transfer selector {ERC20_TRANSFER_SELECTOR_HEX}, got {selector}"
274            ),
275        ));
276    }
277
278    // Validate the 20-byte embedded recipient. An ERC-20 transfer
279    // calldata word layout is:
280    //   [0..8]   selector
281    //   [8..72]  32-byte zero-padded address (leading 24 hex chars must be 0)
282    //   [72..136] 32-byte big-endian amount
283    // The first 24 chars of the address word must be zero; the last 40
284    // chars are the actual 20-byte address.
285    let address_word = &hex[8..72];
286    if !address_word[..24].bytes().all(|b| b == b'0') {
287        return Err(TxCompilerError::new(
288            TxCompilerErrorCode::InvalidCalldata,
289            "ERC-20 transfer recipient word must be left-zero-padded",
290        ));
291    }
292    let embedded_address = format!("0x{}", &address_word[24..]);
293    validate_evm_address(&embedded_address, "data.recipient")?;
294
295    if !to.eq_ignore_ascii_case(token_contract) {
296        return Err(TxCompilerError::with_details(
297            TxCompilerErrorCode::InvalidPayload,
298            "EVM TRANSFER_TOKEN: to must equal tokenContract",
299            serde_json::json!({ "to": to, "tokenContract": token_contract }),
300        ));
301    }
302
303    if value_wei != "0" {
304        return Err(TxCompilerError::with_details(
305            TxCompilerErrorCode::InvalidPayload,
306            "EVM TRANSFER_TOKEN: valueWei must be 0",
307            serde_json::json!({ "valueWei": value_wei }),
308        ));
309    }
310
311    Ok(())
312}
313
314// ---------------------------------------------------------------------------
315// Fee validation
316// ---------------------------------------------------------------------------
317
318fn validate_fee(raw: Option<&Value>, chain: Chain) -> Result<FeeParams, TxCompilerError> {
319    let obj = raw.and_then(Value::as_object).ok_or_else(|| {
320        TxCompilerError::new(TxCompilerErrorCode::MissingFeeParams, "Missing fee object")
321    })?;
322
323    let mode = require_fee_mode(obj)?;
324
325    match (chain, mode) {
326        (Chain::Ethereum, FeeMode::Tron) => {
327            return Err(TxCompilerError::new(
328                TxCompilerErrorCode::UnsupportedFeeMode,
329                "Fee mode 'TRON' is not valid on Ethereum; use 'EIP1559' or 'LEGACY'",
330            ));
331        }
332        (Chain::Tron, FeeMode::Eip1559 | FeeMode::Legacy) => {
333            return Err(TxCompilerError::new(
334                TxCompilerErrorCode::UnsupportedFeeMode,
335                format!(
336                    "Fee mode '{}' is not valid on Tron; use 'TRON'",
337                    mode.as_str(),
338                ),
339            ));
340        }
341        _ => {}
342    }
343
344    let gas_limit = optional_int_string(obj, "gasLimit")?;
345    let base_fee_per_gas = optional_int_string(obj, "baseFeePerGas")?;
346    let max_priority_fee_per_gas = optional_int_string(obj, "maxPriorityFeePerGas")?;
347    let max_fee_per_gas = optional_int_string(obj, "maxFeePerGas")?;
348    let gas_price = optional_int_string(obj, "gasPrice")?;
349    let el = optional_int_string(obj, "el")?;
350    let rp = optional_block_header(obj.get("rp"))?;
351
352    match mode {
353        FeeMode::Eip1559 => {
354            if gas_limit.is_none() {
355                return Err(TxCompilerError::new(
356                    TxCompilerErrorCode::MissingFeeParams,
357                    "EIP-1559 requires gasLimit",
358                ));
359            }
360            if max_fee_per_gas.is_none() || max_priority_fee_per_gas.is_none() {
361                return Err(TxCompilerError::new(
362                    TxCompilerErrorCode::MissingFeeParams,
363                    "EIP-1559 requires maxFeePerGas and maxPriorityFeePerGas",
364                ));
365            }
366        }
367        FeeMode::Legacy => {
368            if gas_limit.is_none() {
369                return Err(TxCompilerError::new(
370                    TxCompilerErrorCode::MissingFeeParams,
371                    "LEGACY requires gasLimit",
372                ));
373            }
374            if gas_price.is_none() && max_fee_per_gas.is_none() {
375                return Err(TxCompilerError::new(
376                    TxCompilerErrorCode::MissingFeeParams,
377                    "LEGACY requires gasPrice or maxFeePerGas",
378                ));
379            }
380        }
381        FeeMode::Tron => {
382            if rp.is_none() {
383                return Err(TxCompilerError::new(
384                    TxCompilerErrorCode::InvalidBlockHeader,
385                    "Fee mode 'TRON' requires the block-header field 'fee.rp'",
386                ));
387            }
388        }
389    }
390
391    Ok(FeeParams {
392        mode,
393        gas_limit,
394        base_fee_per_gas,
395        max_priority_fee_per_gas,
396        max_fee_per_gas,
397        gas_price,
398        el,
399        rp,
400    })
401}
402
403// The single-letter field names (`h`, `n`, `t`, `v`, `p`, `r`, `w`) match
404// the Tron block-header wire format, so we preserve them verbatim.
405#[allow(clippy::many_single_char_names)]
406fn optional_block_header(raw: Option<&Value>) -> Result<Option<TronBlockHeader>, TxCompilerError> {
407    let Some(value) = raw else { return Ok(None) };
408    if value.is_null() {
409        return Ok(None);
410    }
411
412    let obj = value.as_object().ok_or_else(|| {
413        TxCompilerError::new(
414            TxCompilerErrorCode::InvalidBlockHeader,
415            "Block header must be an object",
416        )
417    })?;
418
419    let h = require_non_empty_string_with(obj, "h", || {
420        TxCompilerError::new(
421            TxCompilerErrorCode::InvalidBlockHeader,
422            "Block header is missing required field 'h' (block hash)",
423        )
424    })?;
425
426    let n = require_positive_int(obj, "n")?;
427    let t = require_positive_int(obj, "t")?;
428    let v = require_non_neg_int(obj, "v")?;
429
430    // Tron block IDs are always exactly 32 bytes (64 hex chars).
431    // Rejecting anything else here prevents crafted payloads from
432    // driving `hex::decode` to allocate arbitrary amounts downstream.
433    if h.len() != 64 {
434        return Err(TxCompilerError::new(
435            TxCompilerErrorCode::InvalidBlockHeader,
436            "Block ID (h) must be exactly 64 hex characters (32 bytes)",
437        ));
438    }
439    if !is_hex(&h) {
440        return Err(TxCompilerError::new(
441            TxCompilerErrorCode::InvalidBlockHeader,
442            "Block ID (h) must contain only hex characters",
443        ));
444    }
445
446    // `p` (parent hash), `r` (tx trie root), `w` (witness address) are
447    // informational fields — not used in compilation, but stored on
448    // `PreparedTransaction` and surfaced in `review()`. Cap them at a
449    // conservative 128 chars (see `MAX_INFO_FIELD_LEN`) so a crafted
450    // payload cannot silently park a megabyte of data in process memory.
451    let p = optional_bounded_string(obj, "p", MAX_INFO_FIELD_LEN)?;
452    let r = optional_bounded_string(obj, "r", MAX_INFO_FIELD_LEN)?;
453    let w = optional_bounded_string(obj, "w", MAX_INFO_FIELD_LEN)?;
454
455    Ok(Some(TronBlockHeader {
456        h,
457        n,
458        t,
459        p,
460        r,
461        w,
462        v,
463    }))
464}
465
466// ---------------------------------------------------------------------------
467// Field helpers
468// ---------------------------------------------------------------------------
469
470fn require_non_empty_string(
471    obj: &Map<String, Value>,
472    field: &str,
473) -> Result<String, TxCompilerError> {
474    let value = require_non_empty_string_with(obj, field, || {
475        TxCompilerError::new(
476            TxCompilerErrorCode::InvalidPayload,
477            format!("Missing or empty string field: {field}"),
478        )
479    })?;
480    if value.trim().is_empty() {
481        return Err(TxCompilerError::new(
482            TxCompilerErrorCode::InvalidPayload,
483            format!("Field '{field}' must not be blank or whitespace-only"),
484        ));
485    }
486    Ok(value)
487}
488
489fn require_non_empty_string_with<F>(
490    obj: &Map<String, Value>,
491    field: &str,
492    err: F,
493) -> Result<String, TxCompilerError>
494where
495    F: FnOnce() -> TxCompilerError,
496{
497    match obj.get(field).and_then(Value::as_str) {
498        Some(s) if !s.is_empty() => Ok(s.to_string()),
499        _ => Err(err()),
500    }
501}
502
503fn optional_string(obj: &Map<String, Value>, field: &str) -> Option<String> {
504    match obj.get(field) {
505        Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
506        _ => None,
507    }
508}
509
510fn optional_number(obj: &Map<String, Value>, field: &str) -> Option<i64> {
511    let v = obj.get(field)?;
512    if v.is_null() {
513        return None;
514    }
515    if let Some(i) = v.as_i64() {
516        return Some(i);
517    }
518    if let Some(u) = v.as_u64() {
519        return i64::try_from(u).ok();
520    }
521    // Not an integer JSON number (float or out of i64 range).
522    None
523}
524
525fn require_int_string(obj: &Map<String, Value>, field: &str) -> Result<String, TxCompilerError> {
526    let raw = obj.get(field);
527    let s = raw.and_then(coerce_to_string).filter(|s| is_non_neg_int(s));
528    s.ok_or_else(|| {
529        TxCompilerError::with_details(
530            TxCompilerErrorCode::InvalidAmount,
531            format!("{field} must be a non-negative integer"),
532            serde_json::json!({ "field": field, "value": raw }),
533        )
534    })
535}
536
537fn optional_int_string(
538    obj: &Map<String, Value>,
539    field: &str,
540) -> Result<Option<String>, TxCompilerError> {
541    let Some(raw) = obj.get(field) else {
542        return Ok(None);
543    };
544    if raw.is_null() {
545        return Ok(None);
546    }
547    coerce_to_string(raw)
548        .filter(|s| is_non_neg_int(s))
549        .map_or_else(
550            || {
551                Err(TxCompilerError::with_details(
552                    TxCompilerErrorCode::InvalidAmount,
553                    format!("{field} must be a non-negative integer"),
554                    serde_json::json!({ "field": field, "value": raw }),
555                ))
556            },
557            |s| Ok(Some(s)),
558        )
559}
560
561fn coerce_to_string(value: &Value) -> Option<String> {
562    match value {
563        Value::String(s) => Some(s.clone()),
564        Value::Number(n) => {
565            if let Some(u) = n.as_u64() {
566                return Some(u.to_string());
567            }
568            // Reject negative integers and any float representation.
569            // Floats lose precision above 2^53 and would silently
570            // corrupt large wei / SUN amounts. Integers arrive as
571            // exact u64 via `as_u64`; anything else is not an
572            // acceptable non-negative integer.
573            None
574        }
575        _ => None,
576    }
577}
578
579fn require_positive_int(obj: &Map<String, Value>, field: &str) -> Result<u64, TxCompilerError> {
580    let invalid = || {
581        TxCompilerError::new(
582            TxCompilerErrorCode::InvalidBlockHeader,
583            format!("{field} must be a positive integer"),
584        )
585    };
586    let v = obj.get(field).ok_or_else(invalid)?;
587    // Only accept exact u64 values. Any positive i64 that fits in u64
588    // is already reachable via `as_u64`, so the `as_i64` branch was
589    // dead; rejecting anything else (floats, negatives) here closes
590    // the silent-precision-loss path.
591    v.as_u64().filter(|u| *u > 0).ok_or_else(invalid)
592}
593
594fn require_non_neg_int(obj: &Map<String, Value>, field: &str) -> Result<u32, TxCompilerError> {
595    let invalid = || {
596        TxCompilerError::new(
597            TxCompilerErrorCode::InvalidBlockHeader,
598            format!("{field} must be a non-negative integer"),
599        )
600    };
601    let v = obj.get(field).ok_or_else(invalid)?;
602    // Accept only exact non-negative integers; the `as_i64` branch is
603    // unreachable because any `i64 >= 0` is representable via `as_u64`.
604    v.as_u64().map_or_else(
605        || Err(invalid()),
606        |u| u32::try_from(u).map_err(|_| invalid()),
607    )
608}
609
610fn is_hex(s: &str) -> bool {
611    !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
612}
613
614fn optional_bounded_string(
615    obj: &Map<String, Value>,
616    field: &str,
617    max_len: usize,
618) -> Result<Option<String>, TxCompilerError> {
619    let Some(value) = obj.get(field).and_then(Value::as_str) else {
620        return Ok(None);
621    };
622    if value.len() > max_len {
623        return Err(TxCompilerError::with_details(
624            TxCompilerErrorCode::InvalidBlockHeader,
625            format!("Block header field '{field}' exceeds {max_len}-char limit"),
626            serde_json::json!({ "field": field, "len": value.len(), "limit": max_len }),
627        ));
628    }
629    Ok(Some(value.to_string()))
630}
631
632/// Upper bound on decimal-encoded unsigned integers accepted by the
633/// validator. 78 digits is the width of `U256::MAX`
634/// (115 792 089 237 316 195 423 570 985 008 687 907 853 269 984 665
635/// 640 564 039 457 584 007 913 129 639 935). Any numeric payload field
636/// longer than this cannot fit in a U256 and is always invalid; the cap
637/// prevents a caller from forcing linear-cost parses on crafted
638/// megabyte-sized strings.
639const MAX_DECIMAL_LEN: usize = 78;
640
641fn is_non_neg_int(s: &str) -> bool {
642    if s.is_empty() || s.len() > MAX_DECIMAL_LEN {
643        return false;
644    }
645    if s == "0" {
646        return true;
647    }
648    let mut bytes = s.bytes();
649    match bytes.next() {
650        Some(b'1'..=b'9') => {}
651        _ => return false,
652    }
653    bytes.all(|b| b.is_ascii_digit())
654}
655
656fn invalid_payload(msg: &str) -> TxCompilerError {
657    TxCompilerError::new(TxCompilerErrorCode::InvalidPayload, msg)
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    fn base_evm() -> Value {
665        serde_json::json!({
666            "chain": "ethereum",
667            "chainId": 1,
668            "from": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
669            "to": "0x0000000000000000000000000000000000000001",
670            "valueWei": "1000000000000000000",
671            "data": null,
672            "txType": "TRANSFER_NATIVE",
673            "tokenContract": null,
674            "nonce": "1649",
675            "fee": {
676                "mode": "EIP1559",
677                "gasLimit": "24338",
678                "maxPriorityFeePerGas": "1000000000",
679                "maxFeePerGas": "1214529816"
680            }
681        })
682    }
683
684    #[test]
685    fn accepts_minimal_evm_payload() {
686        let prepared = validate(&base_evm()).unwrap();
687        assert_eq!(prepared.chain, Chain::Ethereum);
688        assert_eq!(prepared.tx_type, TxType::TransferNative);
689    }
690
691    #[test]
692    fn rejects_null_input() {
693        let err = validate(&Value::Null).unwrap_err();
694        assert_eq!(err.code, TxCompilerErrorCode::InvalidPayload);
695    }
696
697    #[test]
698    fn rejects_unknown_chain() {
699        let mut v = base_evm();
700        v["chain"] = Value::from("solana");
701        let err = validate(&v).unwrap_err();
702        assert_eq!(err.code, TxCompilerErrorCode::UnsupportedChain);
703    }
704
705    #[test]
706    fn rejects_negative_chain_id() {
707        let mut v = base_evm();
708        v["chainId"] = Value::from(-1);
709        let err = validate(&v).unwrap_err();
710        assert_eq!(err.code, TxCompilerErrorCode::InvalidPayload);
711    }
712
713    #[test]
714    fn rejects_missing_nonce_for_evm() {
715        let mut v = base_evm();
716        v["nonce"] = Value::Null;
717        let err = validate(&v).unwrap_err();
718        assert_eq!(err.code, TxCompilerErrorCode::InvalidPayload);
719    }
720
721    #[test]
722    fn rejects_oversized_nonce() {
723        let mut v = base_evm();
724        v["nonce"] = Value::String("9007199254740992".into()); // 2^53
725        let err = validate(&v).unwrap_err();
726        assert_eq!(err.code, TxCompilerErrorCode::InvalidPayload);
727    }
728
729    #[test]
730    fn rejects_invalid_evm_address() {
731        let mut v = base_evm();
732        v["from"] = Value::String("0xZZ".into());
733        let err = validate(&v).unwrap_err();
734        assert_eq!(err.code, TxCompilerErrorCode::InvalidAddress);
735    }
736
737    #[test]
738    fn rejects_native_with_calldata() {
739        let mut v = base_evm();
740        v["data"] = Value::String("0xdeadbeef".into());
741        let err = validate(&v).unwrap_err();
742        assert_eq!(err.code, TxCompilerErrorCode::InvalidPayload);
743    }
744
745    #[test]
746    fn rejects_tron_mode_for_evm() {
747        let mut v = base_evm();
748        v["fee"]["mode"] = Value::from("TRON");
749        let err = validate(&v).unwrap_err();
750        assert_eq!(err.code, TxCompilerErrorCode::UnsupportedFeeMode);
751    }
752
753    #[test]
754    fn rejects_eip1559_without_max_fee() {
755        let mut v = base_evm();
756        v["fee"].as_object_mut().unwrap().remove("maxFeePerGas");
757        let err = validate(&v).unwrap_err();
758        assert_eq!(err.code, TxCompilerErrorCode::MissingFeeParams);
759    }
760
761    #[test]
762    fn accepts_legacy_with_gas_price() {
763        let mut v = base_evm();
764        v["fee"] = serde_json::json!({
765            "mode": "LEGACY",
766            "gasLimit": "21000",
767            "gasPrice": "5000000000"
768        });
769        let prepared = validate(&v).unwrap();
770        assert_eq!(prepared.fee.mode, FeeMode::Legacy);
771    }
772
773    #[test]
774    fn rejects_token_without_contract() {
775        let mut v = base_evm();
776        v["txType"] = Value::from("TRANSFER_TOKEN");
777        v["valueWei"] = Value::from("0");
778        let err = validate(&v).unwrap_err();
779        assert_eq!(err.code, TxCompilerErrorCode::InvalidPayload);
780    }
781
782    #[test]
783    fn rejects_token_calldata_wrong_selector() {
784        let mut v = base_evm();
785        v["txType"] = Value::from("TRANSFER_TOKEN");
786        v["valueWei"] = Value::from("0");
787        v["tokenContract"] = Value::from("0x0000000000000000000000000000000000000001");
788        v["to"] = Value::from("0x0000000000000000000000000000000000000001");
789        v["data"] = Value::from(format!("0x{}", "ab".repeat(68)));
790        let err = validate(&v).unwrap_err();
791        assert_eq!(err.code, TxCompilerErrorCode::InvalidCalldata);
792    }
793
794    #[test]
795    fn accepts_tron_native_payload() {
796        let payload = serde_json::json!({
797            "chain": "tron",
798            "chainId": null,
799            "from": "41d8da6bf26964af9d7eed9e03e53415d37aa96045",
800            "to": "410000000000000000000000000000000000000001",
801            "valueWei": "5000000",
802            "data": null,
803            "txType": "TRANSFER_NATIVE",
804            "tokenContract": null,
805            "nonce": null,
806            "fee": {
807                "mode": "TRON",
808                "rp": {
809                    "h": "0000000003b8e4b2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4",
810                    "n": 62_522_546,
811                    "t": 1_710_000_000_000_u64,
812                    "v": 30
813                }
814            }
815        });
816        let prepared = validate(&payload).unwrap();
817        assert_eq!(prepared.chain, Chain::Tron);
818    }
819
820    #[test]
821    fn coerces_numeric_nonce() {
822        let mut v = base_evm();
823        v["nonce"] = Value::from(42);
824        let prepared = validate(&v).unwrap();
825        assert_eq!(prepared.nonce.as_deref(), Some("42"));
826    }
827
828    #[test]
829    fn rejects_fractional_value_wei() {
830        let mut v = base_evm();
831        v["valueWei"] = serde_json::json!(1.5);
832        let err = validate(&v).unwrap_err();
833        assert_eq!(err.code, TxCompilerErrorCode::InvalidAmount);
834    }
835
836    #[test]
837    fn rejects_value_wei_as_large_float() {
838        // A JSON number of `1e18` arrives as f64; rejecting prevents
839        // silent precision loss for values that round under 2^53.
840        let mut v = base_evm();
841        v["valueWei"] = serde_json::json!(1e18);
842        let err = validate(&v).unwrap_err();
843        assert_eq!(err.code, TxCompilerErrorCode::InvalidAmount);
844    }
845
846    #[test]
847    fn rejects_value_wei_exceeding_u256_width() {
848        // 79-digit decimal: one character past U256::MAX width.
849        let mut v = base_evm();
850        v["valueWei"] = Value::from("1".to_string() + &"0".repeat(78));
851        let err = validate(&v).unwrap_err();
852        assert_eq!(err.code, TxCompilerErrorCode::InvalidAmount);
853    }
854
855    #[test]
856    fn rejects_whitespace_only_from_address() {
857        let mut v = base_evm();
858        v["from"] = Value::from("   ");
859        let err = validate(&v).unwrap_err();
860        assert_eq!(err.code, TxCompilerErrorCode::InvalidPayload);
861    }
862
863    #[test]
864    fn rejects_tron_block_id_not_exactly_64_chars() {
865        let mut v = serde_json::json!({
866            "chain": "tron",
867            "chainId": null,
868            "from": "41d8da6bf26964af9d7eed9e03e53415d37aa96045",
869            "to": "410000000000000000000000000000000000000001",
870            "valueWei": "5000000",
871            "data": null,
872            "txType": "TRANSFER_NATIVE",
873            "tokenContract": null,
874            "nonce": null,
875            "fee": {
876                "mode": "TRON",
877                "rp": {
878                    // 63 hex chars (one short)
879                    "h": "0000000003b8e4b2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f",
880                    "n": 62_522_546,
881                    "t": 1_710_000_000_000_u64,
882                    "v": 30
883                }
884            }
885        });
886        let err = validate(&v).unwrap_err();
887        assert_eq!(err.code, TxCompilerErrorCode::InvalidBlockHeader);
888
889        // 66 hex chars (two too many)
890        v["fee"]["rp"]["h"] =
891            Value::from("0000000003b8e4b2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4aa");
892        let err = validate(&v).unwrap_err();
893        assert_eq!(err.code, TxCompilerErrorCode::InvalidBlockHeader);
894    }
895
896    #[test]
897    fn rejects_erc20_calldata_with_non_zero_pad() {
898        // Valid selector + non-zero bytes in the left pad of the address
899        // word must be rejected — an attacker could hide a 32-byte "high"
900        // address value in the pad that a naive consumer might trust.
901        let mut v = base_evm();
902        v["txType"] = Value::from("TRANSFER_TOKEN");
903        v["tokenContract"] = Value::from("0x0000000000000000000000000000000000000001");
904        v["to"] = Value::from("0x0000000000000000000000000000000000000001");
905        v["valueWei"] = Value::from("0");
906        // selector + junk in high bytes of address word + valid address + amount
907        let mut data = String::from("0xa9059cbb");
908        data.push_str(&"ff".repeat(12)); // left pad: should be zero, is 0xff
909        data.push_str(&"00".repeat(20)); // the actual 20-byte address
910        data.push_str(&"00".repeat(32)); // amount
911        v["data"] = Value::from(data);
912        let err = validate(&v).unwrap_err();
913        assert_eq!(err.code, TxCompilerErrorCode::InvalidCalldata);
914    }
915}