diff --git a/Cargo.lock b/Cargo.lock index ac749cb..4e272b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9232,6 +9232,7 @@ dependencies = [ "reqwest-retry", "serde", "serde_json", + "sha2 0.10.9", "solana-client", "solana-sdk", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index dbae5d6..ec08244 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ bs58 = "0.5" bincode = "1" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } +sha2 = "0.10" # OS keyring for wallet secrets. # Linux uses the pure-Rust Secret Service backend (zbus + RustCrypto) rather diff --git a/README.md b/README.md index 39ec49c..2b90d1f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ cargo install --path . ## Features - **4 APIs**: EVM Swap (Allowance Holder), Gasless Swap, Solana Swap, Cross-Chain -- **20 chains**: Ethereum, Base, Arbitrum, Optimism, Polygon, BSC, Avalanche, Linea, Scroll, Blast, Mantle, Berachain, Sonic, Unichain, World Chain, Abstract, Ink, Monad, HyperEVM, Solana +- **21 chains**: Ethereum, Base, Arbitrum, Optimism, Polygon, BSC, Avalanche, Linea, Scroll, Blast, Mantle, Berachain, Sonic, Unichain, World Chain, Abstract, Ink, Monad, HyperEVM, Solana, Tron - **Agent-first**: Auto-detect non-TTY for JSON output, structured error codes, stable exit codes, inline `RESPONSE:` schemas in every `--help` - **Safe by default**: OS keyring for wallet secrets, transaction simulation before every execution, `--dry-run` mode, exact token approvals - **Rich UX**: Colored tables, progress spinners, interactive confirmation, shell completions @@ -118,6 +118,9 @@ By default, `wallet.evm` and `wallet.solana` (when given key material rather tha | `0x config set wallet.solana /path/to/file.json` | `~/.0x-config/config.toml` (it's a path) | | `0x config set wallet.solana ` | OS keyring | | `ZEROX_EVM_PRIVATE_KEY` / `ZEROX_SOLANA_KEYPAIR` env var | Read directly, never persisted | +| `0x config set wallet.tron ` | OS keyring | +| `0x config set wallet.tron --plaintext` | `~/.0x-config/config.toml` | +| `ZEROX_TRON_PRIVATE_KEY` env var | Read directly, never persisted | `0x config show` reports keyring-stored wallets as ``. If the OS keyring is unavailable (e.g. headless Linux with no DBus), use `--plaintext` or the env vars. @@ -130,6 +133,7 @@ Environment variables always take precedence over config file values. | `ZEROX_API_KEY` | 0x API key | | `ZEROX_EVM_PRIVATE_KEY` | EVM private key (hex) | | `ZEROX_SOLANA_KEYPAIR` | Solana keypair file path or base58 | +| `ZEROX_TRON_PRIVATE_KEY` | Tron private key (hex) | | `ZEROX_DEFAULT_CHAIN` | Default chain name or ID | | `ZEROX_RPC_URL` | Override RPC URL for any chain | | `ZEROX_TELEMETRY` | Set falsy (`0`/`false`/`off`) to disable usage telemetry | @@ -233,6 +237,8 @@ No gas fees required. The 0x protocol handles gas on your behalf. ### Cross-Chain Swap +> **Note:** Tron is supported for bridging only — it is not available in `swap`, `price`, or `gasless`. Use `--from tron` or `--to tron` with `cross-chain`. + ```bash # Interactive (shows quote table, lets you pick) 0x cross-chain \ @@ -440,6 +446,7 @@ Every interactive prompt has a flag equivalent: | 81457 | blast | Blast | ETH | | 534352 | scroll | Scroll | ETH | | solana | solana | Solana | SOL | +| tron | tron | Tron | TRX | ## Security @@ -447,7 +454,7 @@ Every interactive prompt has a flag equivalent: - **Config file**: Created with `0600` permissions (owner read/write only) - **Config directory**: Created with `0700` permissions - **Redaction**: `0x config show` and `0x config get` never reveal secret material. Wallets stored in the keyring show as ``; plaintext wallets show as `***redacted***`; Solana file paths show verbatim because the path itself isn't sensitive. -- **Transaction simulation**: Every transaction is simulated via `eth_call` (EVM) or `simulate_transaction` (Solana) before submission +- **Transaction simulation**: EVM and Solana transactions are simulated via `eth_call` or `simulate_transaction` before submission. Tron cross-chain transactions are not pre-simulated. - **Approval strategy**: Default is `exact` (only approve the needed amount). Use `--approval unlimited` for max approval. - **Environment variables**: Sensitive values like private keys can be set via env vars (`ZEROX_EVM_PRIVATE_KEY`, `ZEROX_SOLANA_KEYPAIR`) to avoid persisting them at all — read-once, never written to disk or keyring. diff --git a/skills/0x-trade/references/config.md b/skills/0x-trade/references/config.md index ddd48d6..af56aee 100644 --- a/skills/0x-trade/references/config.md +++ b/skills/0x-trade/references/config.md @@ -29,6 +29,8 @@ Non-interactive (agent-driven) setup: | `rpc.` | Custom RPC URL per chain, e.g. `rpc.base` | | `wallet.evm` | EVM private key (hex) — secret → keyring | | `wallet.solana` | Keypair file path (→ config) or base58/JSON-array secret (→ keyring) | +| `wallet.tron` | Tron private key (hex) — secret → keyring | +| `rpc.tron` | Custom Tron full-node RPC URL (default: `https://api.trongrid.io`) | | `active_profile` | Profile applied when --profile isn't passed | | `profiles..base_url` | Override the API base URL for a profile | | `profiles..api_key` | API key for a profile | @@ -46,6 +48,7 @@ Non-interactive (agent-driven) setup: | `ZEROX_API_KEY` | `api_key` | | `ZEROX_EVM_PRIVATE_KEY` | `wallet.evm` | | `ZEROX_SOLANA_KEYPAIR` | `wallet.solana` (path or base58) | +| `ZEROX_TRON_PRIVATE_KEY` | `wallet.tron` | | `ZEROX_DEFAULT_CHAIN` | `defaults.chain` | | `ZEROX_RPC_URL` | RPC for the current command | | `ZEROX_OUTPUT` | `-o/--output` format | diff --git a/skills/0x-trade/references/cross-chain.md b/skills/0x-trade/references/cross-chain.md index 6e115cb..2f1f0ff 100644 --- a/skills/0x-trade/references/cross-chain.md +++ b/skills/0x-trade/references/cross-chain.md @@ -1,6 +1,8 @@ # Cross-chain swaps (bridging) -Swap a token on one chain for a token on another in a single command. Supports EVM↔EVM, EVM↔Solana. The CLI fetches multiple bridge quotes, executes the origin-chain transaction, and can track the bridge until funds land on the destination. +Swap a token on one chain for a token on another in a single command. Supports EVM↔EVM, EVM↔Solana, and EVM↔Tron. The CLI fetches multiple bridge quotes, executes the origin-chain transaction, and can track the bridge until funds land on the destination. + +**Tron:** Tron addresses are base58check (`T…`). A Tron wallet (`wallet.tron` / `ZEROX_TRON_PRIVATE_KEY`) is required to bridge from or to Tron. Tron is bridging-only — it is not available in `swap`, `price`, or `gasless`. ## Quote + execute diff --git a/src/api/cross_chain.rs b/src/api/cross_chain.rs index 5e49ed7..130bdd0 100644 --- a/src/api/cross_chain.rs +++ b/src/api/cross_chain.rs @@ -54,7 +54,7 @@ pub struct CrossChainStep { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CrossChainTransaction { - pub chain_type: String, // "evm" or "svm" + pub chain_type: String, // "evm", "svm", or "tvm" pub details: CrossChainTxDetails, } @@ -70,6 +70,9 @@ pub struct CrossChainTxDetails { // SVM fields pub serialized_transaction: Option, + + // TVM (Tron) field + pub owner_address: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 8a93d2d..c1615da 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -1,6 +1,7 @@ pub mod evm; pub mod retry; pub mod solana; +pub mod tron; use crate::error::CliError; use crate::output::human::DataTable; @@ -30,6 +31,7 @@ pub struct ChainInfo { pub enum ChainType { Evm, Svm, + Tvm, } /// Chain identifier that supports both numeric IDs and the special "solana" @@ -42,6 +44,7 @@ pub enum ChainType { pub enum ChainId { Numeric(u64), Solana, + Tron, } impl std::fmt::Display for ChainId { @@ -49,6 +52,7 @@ impl std::fmt::Display for ChainId { match self { ChainId::Numeric(id) => write!(f, "{id}"), ChainId::Solana => write!(f, "solana"), + ChainId::Tron => write!(f, "tron"), } } } @@ -58,6 +62,7 @@ impl Serialize for ChainId { match self { ChainId::Numeric(id) => serializer.serialize_u64(*id), ChainId::Solana => serializer.serialize_str("solana"), + ChainId::Tron => serializer.serialize_str("tron"), } } } @@ -71,8 +76,16 @@ impl ChainInfo { self.chain_type == ChainType::Evm } + pub fn is_tron(&self) -> bool { + self.chain_type == ChainType::Tvm + } + pub fn explorer_tx_url(&self, tx_hash: &str) -> String { - format!("{}/tx/{}", self.explorer_url, tx_hash) + if self.chain_type == ChainType::Tvm { + format!("{}/#/transaction/{}", self.explorer_url, tx_hash) + } else { + format!("{}/tx/{}", self.explorer_url, tx_hash) + } } /// Get the numeric chain ID (for API calls). Returns None for Solana. @@ -80,6 +93,7 @@ impl ChainInfo { match self.id { ChainId::Numeric(id) => Some(id), ChainId::Solana => None, + ChainId::Tron => None, } } @@ -99,12 +113,30 @@ impl ChainInfo { } /// Get the chain identifier for 0x API calls. - /// For EVM: numeric string. For Solana: "solana". + /// For EVM: numeric string. For Solana: "solana". For Tron: "tron". pub fn api_chain_id(&self) -> String { match self.id { ChainId::Numeric(id) => id.to_string(), ChainId::Solana => "solana".to_string(), + ChainId::Tron => "tron".to_string(), + } + } + + /// Error returned when a Tron chain is used on a command that only the + /// cross-chain API supports (swap/price/gasless). + pub fn reject_if_tron(&self, command: &str) -> Result<(), CliError> { + if self.is_tron() { + return Err(CliError::Api { + code: crate::error::ErrorCode::InputInvalid, + message: format!("Tron is not supported by '{command}'"), + status: None, + details: None, + suggestion: Some( + "Tron is supported for bridging only — use '0x cross-chain --from/--to tron'".into(), + ), + }); } + Ok(()) } } @@ -292,6 +324,15 @@ const CHAINS: &[ChainInfo] = &[ chain_type: ChainType::Svm, default_rpc_url: Some("https://api.mainnet-beta.solana.com"), }, + ChainInfo { + id: ChainId::Tron, + name: "tron", + display_name: "Tron", + native_token: "TRX", + explorer_url: "https://tronscan.org", + chain_type: ChainType::Tvm, + default_rpc_url: Some("https://api.trongrid.io"), + }, ]; /// Read-only view of every supported chain. Consumers iterate this for @@ -362,6 +403,16 @@ pub fn validate_token_address(token: &str, chain_info: &ChainInfo) -> Result<(), ), }); } + } else if chain_info.is_tron() && !crate::chain::tron::is_valid_tron_address(token) { + return Err(CliError::Api { + code: crate::error::ErrorCode::InputInvalid, + message: format!("'{token}' is not a valid Tron token address"), + status: None, + details: None, + suggestion: Some( + "Use the base58check TRC20 address, e.g. TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t for USDT on Tron".into(), + ), + }); } Ok(()) } @@ -527,4 +578,39 @@ mod tests { "\"solana\"" ); } + + #[test] + fn test_resolve_tron() { + let chain = resolve_chain("tron").unwrap(); + assert!(chain.is_tron()); + assert!(!chain.is_evm()); + assert!(!chain.is_solana()); + assert_eq!(chain.numeric_id(), None); + assert_eq!(chain.api_chain_id(), "tron"); + } + + #[test] + fn test_chain_id_tron_serializes_as_string() { + assert_eq!( + serde_json::to_string(&ChainId::Tron).unwrap(), + "\"tron\"" + ); + } + + #[test] + fn test_tron_explorer_tx_url() { + let chain = resolve_chain("tron").unwrap(); + assert_eq!( + chain.explorer_tx_url("abc123"), + "https://tronscan.org/#/transaction/abc123" + ); + } + + #[test] + fn test_validate_tron_token_address() { + let tron = resolve_chain("tron").unwrap(); + assert!(validate_token_address("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", tron).is_ok()); + assert!(validate_token_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", tron).is_err()); + assert!(validate_token_address("not-an-address", tron).is_err()); + } } diff --git a/src/chain/tron.rs b/src/chain/tron.rs new file mode 100644 index 0000000..b5fc6bb --- /dev/null +++ b/src/chain/tron.rs @@ -0,0 +1,353 @@ +//! Tron (TVM) address codec, transaction building, signing, and broadcast. +//! Tron is supported in cross-chain swaps only. + +use crate::error::{CliError, ErrorCode}; +use sha2::{Digest, Sha256}; + +/// Tron mainnet address version byte. Every base58check Tron address decodes +/// to `0x41 ++ 20-byte-address ++ 4-byte-checksum`. +const TRON_VERSION_BYTE: u8 = 0x41; + +fn sha256d(bytes: &[u8]) -> [u8; 32] { + let first = Sha256::digest(bytes); + let second = Sha256::digest(first); + let mut out = [0u8; 32]; + out.copy_from_slice(&second); + out +} + +fn invalid(msg: impl Into) -> CliError { + CliError::Api { + code: ErrorCode::InputInvalid, + message: msg.into(), + status: None, + details: None, + suggestion: Some( + "Use a base58check Tron address starting with 'T', e.g. TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".into(), + ), + } +} + +/// Decode a base58check Tron address (`T…`) into its 21-byte `0x41`-prefixed +/// form. Validates length, version byte, and the 4-byte double-SHA256 checksum. +pub fn base58check_to_21(addr: &str) -> Result<[u8; 21], CliError> { + let raw = bs58::decode(addr) + .into_vec() + .map_err(|_| invalid(format!("'{addr}' is not valid base58")))?; + if raw.len() != 25 { + return Err(invalid(format!("'{addr}' is not a 25-byte Tron address"))); + } + let (payload, checksum) = raw.split_at(21); + if payload[0] != TRON_VERSION_BYTE { + return Err(invalid(format!("'{addr}' has wrong Tron version byte"))); + } + let expected = &sha256d(payload)[..4]; + if expected != checksum { + return Err(invalid(format!("'{addr}' has an invalid checksum"))); + } + let mut out = [0u8; 21]; + out.copy_from_slice(payload); + Ok(out) +} + +/// 100 TRX, in sun. Tron contract calls burn TRX for energy/bandwidth; this +/// caps how much a single origin transaction may spend. +pub const DEFAULT_FEE_LIMIT_SUN: u64 = 100_000_000; + +const TRIGGER_SMART_CONTRACT_TYPE: u64 = 31; +const TRIGGER_TYPE_URL: &str = "type.googleapis.com/protocol.TriggerSmartContract"; + +// --- minimal protobuf writer (only what TriggerSmartContract needs) --- + +fn write_varint(buf: &mut Vec, mut v: u64) { + loop { + let mut byte = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + byte |= 0x80; + } + buf.push(byte); + if v == 0 { + break; + } + } +} + +fn write_tag(buf: &mut Vec, field: u64, wire_type: u64) { + write_varint(buf, (field << 3) | wire_type); +} + +fn write_len_delimited(buf: &mut Vec, field: u64, bytes: &[u8]) { + write_tag(buf, field, 2); + write_varint(buf, bytes.len() as u64); + buf.extend_from_slice(bytes); +} + +fn write_varint_field(buf: &mut Vec, field: u64, v: u64) { + write_tag(buf, field, 0); + write_varint(buf, v); +} + +fn encode_trigger_smart_contract(owner: &[u8], contract: &[u8], call_value: u64, data: &[u8]) -> Vec { + let mut b = Vec::new(); + write_len_delimited(&mut b, 1, owner); // owner_address + write_len_delimited(&mut b, 2, contract); // contract_address + if call_value != 0 { + write_varint_field(&mut b, 3, call_value); // call_value + } + write_len_delimited(&mut b, 4, data); // data + b +} + +fn encode_any(type_url: &str, value: &[u8]) -> Vec { + let mut b = Vec::new(); + write_len_delimited(&mut b, 1, type_url.as_bytes()); + write_len_delimited(&mut b, 2, value); + b +} + +fn encode_contract(parameter_any: &[u8]) -> Vec { + let mut b = Vec::new(); + write_varint_field(&mut b, 1, TRIGGER_SMART_CONTRACT_TYPE); // Contract.type + write_len_delimited(&mut b, 2, parameter_any); // Contract.parameter (Any) + b +} + +pub(crate) struct EncodeParams<'a> { + pub owner: &'a [u8], + pub contract: &'a [u8], + pub data: &'a [u8], + pub call_value: u64, + pub ref_block_bytes: [u8; 2], + pub ref_block_hash: [u8; 8], + pub expiration: u64, + pub timestamp: u64, + pub fee_limit: u64, +} + +/// Encode a Transaction.raw (the bytes that get hashed into the txID). +pub(crate) fn encode_raw_data(p: EncodeParams) -> Vec { + let tsc = encode_trigger_smart_contract(p.owner, p.contract, p.call_value, p.data); + let any = encode_any(TRIGGER_TYPE_URL, &tsc); + let contract = encode_contract(&any); + + let mut raw = Vec::new(); + write_len_delimited(&mut raw, 1, &p.ref_block_bytes); // ref_block_bytes + write_len_delimited(&mut raw, 4, &p.ref_block_hash); // ref_block_hash + write_varint_field(&mut raw, 8, p.expiration); // expiration + write_len_delimited(&mut raw, 11, &contract); // contract (repeated, one entry) + write_varint_field(&mut raw, 14, p.timestamp); // timestamp + write_varint_field(&mut raw, 18, p.fee_limit); // fee_limit + raw +} + +pub(crate) fn txid(raw_data: &[u8]) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(&Sha256::digest(raw_data)); + out +} + +pub(crate) fn ref_block_bytes_from_number(number: u64) -> [u8; 2] { + let be = number.to_be_bytes(); + [be[6], be[7]] +} + +// --- ref-block fetch + broadcast over the TronGrid full-node HTTP API --- + +#[derive(serde::Deserialize)] +struct NowBlock { + #[serde(rename = "blockID")] + block_id: String, + block_header: BlockHeader, +} +#[derive(serde::Deserialize)] +struct BlockHeader { + raw_data: BlockHeaderRaw, +} +#[derive(serde::Deserialize)] +struct BlockHeaderRaw { + number: u64, + timestamp: u64, +} + +fn rpc_error(msg: impl Into) -> CliError { + CliError::Transaction { + code: ErrorCode::RpcError, + message: msg.into(), + tx_hash: None, + suggestion: Some("Check the Tron RPC URL (config set rpc.tron ) or try again.".into()), + } +} + +/// Build, sign, and broadcast a Tron TriggerSmartContract transaction. +/// Returns the broadcast txID (hex). `data_hex` / `to_b58` / `owner_b58` / +/// `value_sun` come straight from the cross-chain quote's `transaction.details`. +pub async fn build_sign_broadcast( + rpc_url: &str, + signer: &crate::wallet::tron::TronSigner, + to_b58: &str, + owner_b58: &str, + data_hex: &str, + value_sun: u64, + fee_limit_sun: u64, +) -> Result { + let owner = base58check_to_21(owner_b58)?; + let contract = base58check_to_21(to_b58)?; + let data = hex::decode(data_hex.strip_prefix("0x").unwrap_or(data_hex)) + .map_err(|e| rpc_error(format!("Quote returned non-hex Tron calldata: {e}")))?; + + let client = reqwest::Client::new(); + let now: NowBlock = client + .post(format!("{}/wallet/getnowblock", rpc_url.trim_end_matches('/'))) + .send() + .await + .map_err(|e| rpc_error(format!("getnowblock request failed: {e}")))? + .json() + .await + .map_err(|e| rpc_error(format!("getnowblock parse failed: {e}")))?; + + let block_hash = hex::decode(&now.block_id) + .map_err(|e| rpc_error(format!("bad blockID hex: {e}")))?; + if block_hash.len() < 16 { + return Err(rpc_error("blockID shorter than 16 bytes")); + } + let mut ref_block_hash = [0u8; 8]; + ref_block_hash.copy_from_slice(&block_hash[8..16]); + + let raw = encode_raw_data(EncodeParams { + owner: &owner, + contract: &contract, + data: &data, + call_value: value_sun, + ref_block_bytes: ref_block_bytes_from_number(now.block_header.raw_data.number), + ref_block_hash, + // Tron expiration must be after the latest block's time; +60s window. + expiration: now.block_header.raw_data.timestamp + 60_000, + timestamp: now.block_header.raw_data.timestamp, + fee_limit: fee_limit_sun, + }); + + let id = txid(&raw); + let signature = signer.sign_txid(&id)?; + + // Full signed Transaction protobuf: field 1 = raw_data, field 2 = signature. + let mut signed = Vec::new(); + write_len_delimited(&mut signed, 1, &raw); + write_len_delimited(&mut signed, 2, &signature); + + #[derive(serde::Serialize)] + struct BroadcastHex { + transaction: String, + } + #[derive(serde::Deserialize)] + struct BroadcastResult { + #[serde(default)] + result: bool, + #[serde(default)] + message: Option, + #[serde(default, rename = "txid")] + txid: Option, + } + + let res: BroadcastResult = client + .post(format!("{}/wallet/broadcasthex", rpc_url.trim_end_matches('/'))) + .json(&BroadcastHex { transaction: hex::encode(&signed) }) + .send() + .await + .map_err(|e| rpc_error(format!("broadcast request failed: {e}")))? + .json() + .await + .map_err(|e| rpc_error(format!("broadcast parse failed: {e}")))?; + + if !res.result { + let detail = res + .message + .map(|m| String::from_utf8(hex::decode(&m).unwrap_or_default()).unwrap_or(m)) + .unwrap_or_else(|| "unknown error".into()); + return Err(rpc_error(format!("Tron broadcast rejected: {detail}"))); + } + + Ok(res.txid.unwrap_or_else(|| hex::encode(id))) +} + +/// Encode a 21-byte `0x41`-prefixed address back to a base58check `T…` string. +pub fn addr21_to_base58check(addr: &[u8; 21]) -> String { + let checksum = &sha256d(addr)[..4]; + let mut full = Vec::with_capacity(25); + full.extend_from_slice(addr); + full.extend_from_slice(checksum); + bs58::encode(full).into_string() +} + +/// True iff `addr` is a structurally valid base58check Tron address. +pub fn is_valid_tron_address(addr: &str) -> bool { + base58check_to_21(addr).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + // USDT-TRC20 contract address — a known-good base58check vector. + const USDT: &str = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"; + + #[test] + fn test_base58check_roundtrip() { + let bytes = base58check_to_21(USDT).expect("decode"); + assert_eq!(bytes[0], 0x41, "version byte must be 0x41"); + assert_eq!(addr21_to_base58check(&bytes), USDT); + } + + #[test] + fn test_rejects_bad_checksum() { + // Flip the last character to corrupt the checksum. + let bad = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6X"; + assert!(base58check_to_21(bad).is_err()); + assert!(!is_valid_tron_address(bad)); + } + + #[test] + fn test_rejects_evm_shaped() { + assert!(!is_valid_tron_address("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")); + } + + #[test] + fn test_is_valid_true() { + assert!(is_valid_tron_address(USDT)); + } + + #[test] + fn test_encode_raw_data_golden() { + // Fixed inputs → deterministic raw_data bytes (ref-block + timestamps + // are passed in, so the encoding is fully reproducible). + let owner = base58check_to_21("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t").unwrap(); + let to = base58check_to_21("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t").unwrap(); + let data = hex::decode("a9059cbb").unwrap(); + let raw = encode_raw_data(EncodeParams { + owner: &owner, + contract: &to, + data: &data, + call_value: 0, + ref_block_bytes: [0x4f, 0x15], + ref_block_hash: [0u8; 8], + expiration: 1_700_000_060_000, + timestamp: 1_700_000_000_000, + fee_limit: DEFAULT_FEE_LIMIT_SUN, + }); + // txID is sha256(raw_data); pin its length and determinism. + let id1 = txid(&raw); + let id2 = txid(&raw); + assert_eq!(id1, id2); + assert_eq!(id1.len(), 32); + // Encoding must be non-empty and start with the ref_block_bytes field tag (0x0A). + assert_eq!(raw[0], 0x0A); + } + + #[test] + fn test_ref_block_from_block_number() { + // ref_block_bytes is bytes [6..8] of the big-endian 8-byte block number. + let n: u64 = 83709717; + let be = n.to_be_bytes(); + assert_eq!(ref_block_bytes_from_number(n), [be[6], be[7]]); + } +} diff --git a/src/commands/cross_chain/mod.rs b/src/commands/cross_chain/mod.rs index 66a7b8b..2690a07 100644 --- a/src/commands/cross_chain/mod.rs +++ b/src/commands/cross_chain/mod.rs @@ -328,6 +328,48 @@ pub async fn run( })?; sig.to_string() + } else if selected.transaction.chain_type == "tvm" { + // Tron origin: build, sign, and broadcast a TriggerSmartContract tx. + let signer = origin_wallet.tron_signer().ok_or_else(|| CliError::Api { + code: ErrorCode::ApiError, + message: "Quote returned a Tron transaction but the origin chain isn't Tron".into(), + status: None, + details: None, + suggestion: None, + })?; + let details = &selected.transaction.details; + let to = details.to.as_deref().ok_or_else(|| CliError::Api { + code: ErrorCode::ApiError, + message: "Tron quote missing 'to' address".into(), + status: None, + details: None, + suggestion: None, + })?; + let data = details.data.as_deref().unwrap_or_default(); + let owner = details.owner_address.as_deref().unwrap_or(&origin_address); + let value_sun: u64 = match details.value.as_deref() { + None => 0, + Some(v) => v.parse().map_err(|_| CliError::Api { + code: ErrorCode::ApiError, + message: format!("Tron quote returned a non-numeric value: '{v}'"), + status: None, + details: None, + suggestion: None, + })?, + }; + + let rpc = config::resolve_rpc(global.rpc_url.as_deref(), &config, origin)?; + spinner.set_message("Building and broadcasting Tron transaction...".to_string()); + crate::chain::tron::build_sign_broadcast( + &rpc.url, + signer, + to, + owner, + data, + value_sun, + crate::chain::tron::DEFAULT_FEE_LIMIT_SUN, + ) + .await? } else { return Err(CliError::Api { code: ErrorCode::ApiError, @@ -398,12 +440,13 @@ pub async fn run( Ok(output.emit_success("cross-chain", &result, metadata, warnings, exit_code)) } -/// A loaded origin wallet — exactly one of EVM or Solana, depending on the -/// origin chain. Held for the lifetime of a cross-chain swap so we don't +/// A loaded origin wallet — exactly one of EVM, Solana, or Tron, depending on +/// the origin chain. Held for the lifetime of a cross-chain swap so we don't /// re-load (and re-prompt the OS keyring) on every step. enum OriginWallet { Evm(alloy::signers::local::PrivateKeySigner), Solana(solana_sdk::signer::keypair::Keypair), + Tron(crate::wallet::tron::TronSigner), } impl OriginWallet { @@ -412,7 +455,10 @@ impl OriginWallet { config: &config::types::AppConfig, cli_wallet: Option<&str>, ) -> Result { - if origin.is_solana() { + if origin.is_tron() { + let s = crate::wallet::tron::load_tron_signer(config, cli_wallet)?; + Ok(OriginWallet::Tron(s)) + } else if origin.is_solana() { let kp = crate::wallet::solana::load_solana_keypair(config, cli_wallet)?; Ok(OriginWallet::Solana(kp)) } else { @@ -425,6 +471,7 @@ impl OriginWallet { match self { OriginWallet::Evm(s) => format!("{:?}", s.address()), OriginWallet::Solana(kp) => crate::wallet::solana::pubkey_string(kp), + OriginWallet::Tron(s) => s.address().to_string(), } } @@ -441,6 +488,13 @@ impl OriginWallet { _ => None, } } + + fn tron_signer(&self) -> Option<&crate::wallet::tron::TronSigner> { + match self { + OriginWallet::Tron(s) => Some(s), + _ => None, + } + } } /// Resolve the address that will receive the bridged tokens on @@ -457,10 +511,23 @@ fn resolve_destination_address( destination: &chain::ChainInfo, config: &config::types::AppConfig, ) -> Result { - if origin.is_evm() == destination.is_evm() { + // Same VM → the origin wallet's own address receives. Compare chain TYPE, + // not is_evm(): Solana and Tron are both non-EVM but are NOT the same VM. + if origin.chain_type == destination.chain_type { return Ok(origin_wallet.address()); } - if destination.is_solana() { + if destination.is_tron() { + let s = crate::wallet::tron::load_tron_signer(config, None).map_err(|e| match e { + CliError::Wallet { code, message } => CliError::Wallet { + code, + message: format!( + "Cross-chain into Tron needs a Tron wallet to receive into. {message}" + ), + }, + other => other, + })?; + Ok(s.address().to_string()) + } else if destination.is_solana() { let kp = crate::wallet::solana::load_solana_keypair(config, None).map_err(|e| { // Surface a clearer message: the user wants to bridge INTO Solana // but has no Solana wallet configured. We need at least its @@ -524,3 +591,16 @@ async fn resolve_one_evm( } result } + +#[cfg(test)] +mod tron_wiring_tests { + use super::*; + + #[test] + fn test_same_vm_check_uses_chain_type_not_is_evm() { + // Solana and Tron are both non-EVM; they must NOT be treated as same-VM. + let solana = chain::resolve_chain("solana").unwrap(); + let tron = chain::resolve_chain("tron").unwrap(); + assert_ne!(solana.chain_type, tron.chain_type); + } +} diff --git a/src/commands/cross_chain/output.rs b/src/commands/cross_chain/output.rs index df21f77..597d61b 100644 --- a/src/commands/cross_chain/output.rs +++ b/src/commands/cross_chain/output.rs @@ -431,6 +431,7 @@ mod tests { gas_price: None, value: None, serialized_transaction: None, + owner_address: None, }, }, gas_costs: None, diff --git a/src/commands/gasless/mod.rs b/src/commands/gasless/mod.rs index f72f0e1..aaad9cb 100644 --- a/src/commands/gasless/mod.rs +++ b/src/commands/gasless/mod.rs @@ -30,6 +30,7 @@ pub async fn run_gasless( ) -> Result { let config = config::load_config()?; let chain_info = chain::resolve_chain(&args.chain)?; + chain_info.reject_if_tron("gasless")?; let chain_id = chain_info.numeric_id().ok_or_else(|| CliError::Api { code: ErrorCode::InputInvalid, message: "Gasless swaps are only supported on EVM chains".into(), diff --git a/src/commands/price.rs b/src/commands/price.rs index 2dc2357..460b492 100644 --- a/src/commands/price.rs +++ b/src/commands/price.rs @@ -108,6 +108,7 @@ pub async fn run( // Resolve chain let chain_info = chain::resolve_chain(&args.chain)?; + chain_info.reject_if_tron("price")?; // Validate token addresses chain::validate_token_address(&args.sell, chain_info)?; diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 4c44ca7..cb8114d 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -205,6 +205,7 @@ pub async fn run( ) -> Result { let config = config::load_config()?; let chain_info = chain::resolve_chain(&args.chain)?; + chain_info.reject_if_tron("swap")?; chain::validate_token_address(&args.sell, chain_info)?; chain::validate_token_address(&args.buy, chain_info)?; diff --git a/src/config/mod.rs b/src/config/mod.rs index 9d5e810..59d2965 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -417,6 +417,12 @@ pub fn set_config_value( plaintext, |v| config.wallet.evm = v, ), + "wallet.tron" => set_wallet_secret( + crate::wallet::keyring_store::keys::WALLET_TRON, + value, + plaintext, + |v| config.wallet.tron = v, + ), "wallet.solana" => { // File paths are not secret — keep them in the config file. if crate::config::types::is_path_like(value) { @@ -568,6 +574,10 @@ pub fn unset_config_value(config: &mut AppConfig, key: &str) -> Result { + changed |= config.wallet.tron.take().is_some(); + changed |= unset_keyring(crate::wallet::keyring_store::keys::WALLET_TRON); + } "wallet.solana" => { changed |= config.wallet.solana.take().is_some(); changed |= unset_keyring(crate::wallet::keyring_store::keys::WALLET_SOLANA); @@ -649,6 +659,13 @@ pub fn get_config_value(config: &AppConfig, key: &str) -> Result None, }, + "wallet.tron" => match config.wallet.tron { + Some(_) => Some("***redacted***".to_string()), + None if keyring_has(crate::wallet::keyring_store::keys::WALLET_TRON) => { + Some("".to_string()) + } + None => None, + }, "wallet.solana" => match config.wallet.solana { Some(ref s) if crate::config::types::is_path_like(s) => Some(s.clone()), Some(_) => Some("***redacted***".to_string()), @@ -961,6 +978,20 @@ mod tests { assert!(config.wallet.evm.is_some()); } + #[test] + fn test_set_get_unset_wallet_tron_plaintext() { + let mut config = AppConfig::default(); + // --plaintext path keeps it in the config file (no keyring dependency in tests). + set_config_value(&mut config, "wallet.tron", "0xdeadbeef", true).unwrap(); + assert_eq!(config.wallet.tron.as_deref(), Some("0xdeadbeef")); + assert_eq!( + get_config_value(&config, "wallet.tron").unwrap(), + "***redacted***" + ); + unset_config_value(&mut config, "wallet.tron").unwrap(); + assert!(config.wallet.tron.is_none()); + } + #[test] fn test_profile_config_keys() { let mut config = AppConfig::default(); diff --git a/src/config/types.rs b/src/config/types.rs index 4cc1482..56c6f2b 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -113,6 +113,10 @@ pub struct WalletConfig { /// Solana keypair file path or base58 string #[serde(skip_serializing_if = "Option::is_none")] pub solana: Option, + + /// Tron private key (hex string) + #[serde(skip_serializing_if = "Option::is_none")] + pub tron: Option, } /// Anonymous usage telemetry settings. Telemetry is opt-out (default on) but @@ -178,6 +182,14 @@ impl AppConfig { None => None, }; + copy.wallet.tron = match copy.wallet.tron { + Some(_) => Some("***redacted***".to_string()), + None if keyring_has(crate::wallet::keyring_store::keys::WALLET_TRON) => { + Some("".to_string()) + } + None => None, + }; + for profile in copy.profiles.values_mut() { if let Some(key) = profile.api_key.as_deref() { profile.api_key = Some(redact_string(key)); @@ -256,6 +268,7 @@ mod tests { wallet: WalletConfig { evm: Some("0xdeadbeef".to_string()), solana: Some("/home/user/.config/solana/id.json".to_string()), + tron: None, }, ..Default::default() }; @@ -291,6 +304,7 @@ mod tests { wallet: WalletConfig { evm: Some("0xdeadbeef".to_string()), solana: None, + tron: None, }, profiles: HashMap::new(), telemetry: TelemetryConfig::default(), diff --git a/src/wallet/keyring_store.rs b/src/wallet/keyring_store.rs index ea2c6b4..1973c18 100644 --- a/src/wallet/keyring_store.rs +++ b/src/wallet/keyring_store.rs @@ -16,6 +16,7 @@ use crate::error::CliError; pub mod keys { pub const WALLET_EVM: &str = "wallet.evm"; pub const WALLET_SOLANA: &str = "wallet.solana"; + pub const WALLET_TRON: &str = "wallet.tron"; } #[cfg(not(test))] diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4ce8c58..26151d7 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1,6 +1,7 @@ pub mod evm; pub mod keyring_store; pub mod solana; +pub mod tron; // Wallet management: loading keys from config/env/keyring, signing transactions. // Implementation details in evm.rs and solana.rs. diff --git a/src/wallet/tron.rs b/src/wallet/tron.rs new file mode 100644 index 0000000..aa7c5d2 --- /dev/null +++ b/src/wallet/tron.rs @@ -0,0 +1,127 @@ +use crate::config::types::AppConfig; +use crate::error::{CliError, ErrorCode}; +use alloy::primitives::keccak256; +use alloy::signers::k256::ecdsa::SigningKey; + +/// A loaded Tron signer: a secp256k1 key plus its derived base58check address. +pub struct TronSigner { + signing_key: SigningKey, + address: String, +} + +impl TronSigner { + pub fn address(&self) -> &str { + &self.address + } + + /// Sign a 32-byte Tron txID, returning a 65-byte `r ‖ s ‖ recovery_id` + /// signature (recovery id is 0/1, NOT the EVM 27/28 convention). + pub fn sign_txid(&self, txid: &[u8; 32]) -> Result<[u8; 65], CliError> { + let (sig, recid) = self + .signing_key + .sign_prehash_recoverable(txid) + .map_err(|e| CliError::Wallet { + code: ErrorCode::WalletInvalid, + message: format!("Failed to sign Tron transaction: {e}"), + })?; + let mut out = [0u8; 65]; + out[..64].copy_from_slice(&sig.to_bytes()); + out[64] = recid.to_byte(); + Ok(out) + } +} + +/// Derive the base58check Tron address from a secp256k1 signing key: +/// `base58check(0x41 ++ keccak256(uncompressed_pubkey[1..])[12..])`. +fn derive_address(signing_key: &SigningKey) -> String { + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); // 0x04 ++ X(32) ++ Y(32) + let hash = keccak256(&point.as_bytes()[1..]); + let mut addr21 = [0u8; 21]; + addr21[0] = 0x41; + addr21[1..].copy_from_slice(&hash[12..]); + crate::chain::tron::addr21_to_base58check(&addr21) +} + +/// Load a Tron signer from CLI flag, env var, OS keyring, or config file. +/// +/// Priority: `--wallet` flag → `ZEROX_TRON_PRIVATE_KEY` env → OS keyring → +/// `config.wallet.tron` (config-file plaintext). +pub fn load_tron_signer( + config: &AppConfig, + cli_wallet: Option<&str>, +) -> Result { + let key = if let Some(wallet_arg) = cli_wallet { + wallet_arg.to_string() + } else if let Ok(env_key) = std::env::var("ZEROX_TRON_PRIVATE_KEY") { + env_key + } else if let Some(keyring_key) = + crate::wallet::keyring_store::get(crate::wallet::keyring_store::keys::WALLET_TRON) + .unwrap_or(None) + { + keyring_key + } else if let Some(ref config_key) = config.wallet.tron { + config_key.clone() + } else { + return Err(CliError::Wallet { + code: ErrorCode::WalletNotFound, + message: "No Tron wallet configured. Set via --wallet, ZEROX_TRON_PRIVATE_KEY env var, or 'config set wallet.tron '".into(), + }); + }; + + let hex_str = key.strip_prefix("0x").or_else(|| key.strip_prefix("0X")).unwrap_or(&key); + let bytes = hex::decode(hex_str).map_err(|e| CliError::Wallet { + code: ErrorCode::WalletInvalid, + message: format!("Invalid Tron private key (expected hex): {e}"), + })?; + let signing_key = SigningKey::from_slice(&bytes).map_err(|e| CliError::Wallet { + code: ErrorCode::WalletInvalid, + message: format!("Invalid Tron private key: {e}"), + })?; + let address = derive_address(&signing_key); + Ok(TronSigner { signing_key, address }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::AppConfig; + + // Hardhat account #0 private key (also a valid secp256k1 key for Tron). + const PK: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + #[test] + fn test_load_and_derive_address() { + let signer = load_tron_signer(&AppConfig::default(), Some(PK)).unwrap(); + // Known vector: Hardhat #0 key → EVM 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + // → Tron base58check(0x41 ‖ those 20 bytes), computed independently. + assert_eq!(signer.address(), "TYBNgWfhGuNzdLtjKtxXTfskAhTbMcqbaG"); + } + + #[test] + fn test_sign_txid_is_65_bytes() { + let signer = load_tron_signer(&AppConfig::default(), Some(PK)).unwrap(); + let sig = signer.sign_txid(&[7u8; 32]).unwrap(); + assert_eq!(sig.len(), 65); + assert!(sig[64] == 0 || sig[64] == 1, "recovery id must be 0 or 1"); + } + + #[test] + fn test_no_wallet_errors() { + assert!(load_tron_signer(&AppConfig::default(), None).is_err()); + } + + #[test] + fn test_config_file_fallback() { + use crate::config::types::WalletConfig; + let config = AppConfig { + wallet: WalletConfig { + tron: Some(PK.to_string()), + ..Default::default() + }, + ..Default::default() + }; + let signer = load_tron_signer(&config, None).unwrap(); + assert_eq!(signer.address(), "TYBNgWfhGuNzdLtjKtxXTfskAhTbMcqbaG"); + } +} diff --git a/tests/cli_output.rs b/tests/cli_output.rs index 453af82..971916c 100644 --- a/tests/cli_output.rs +++ b/tests/cli_output.rs @@ -635,3 +635,27 @@ fn test_dry_run_flag_accepted() { // reached the wallet-load step without an arg-parse failure. assert_eq!(json["code"], "WALLET_NOT_FOUND"); } + +#[test] +fn chains_list_includes_tron() { + let mut cmd = assert_cmd::Command::cargo_bin("0x").unwrap(); + cmd.args(["chains", "-o", "json"]); + let out = cmd.output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("\"tron\""), "chains output should list tron: {stdout}"); + assert!(stdout.contains("\"tvm\""), "chains output should show tvm chain_type: {stdout}"); +} + +#[test] +fn swap_rejects_tron_with_cross_chain_hint() { + let mut cmd = assert_cmd::Command::cargo_bin("0x").unwrap(); + cmd.args([ + "swap", "--chain", "tron", + "--sell", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "--buy", "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "--amount", "1000000", "-o", "json-envelope", + ]); + let out = cmd.output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("cross-chain"), "expected cross-chain hint, got: {stdout}"); +}