1use 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;
17const MAX_SAFE_JS_INT: u64 = (1 << 53) - 1;
19const MAX_INFO_FIELD_LEN: usize = 128;
23
24pub 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
94fn 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
136fn 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 TxCompilerError::with_details(
219 TxCompilerErrorCode::InvalidAddress,
220 format!("Invalid address in '{field}'"),
221 serde_json::json!({ "field": field, "address": address }),
222 )
223}
224
225fn 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 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
314fn 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#[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 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 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
466fn 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 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 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 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 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
632const 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()); 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 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 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 "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 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 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 let mut data = String::from("0xa9059cbb");
908 data.push_str(&"ff".repeat(12)); data.push_str(&"00".repeat(20)); data.push_str(&"00".repeat(32)); v["data"] = Value::from(data);
912 let err = validate(&v).unwrap_err();
913 assert_eq!(err.code, TxCompilerErrorCode::InvalidCalldata);
914 }
915}