Skip to main content

agent_first_pay/provider/
evm.rs

1use crate::provider::{HistorySyncStats, PayError, PayProvider};
2use crate::spend::tokens;
3use crate::store::wallet::{self, WalletMetadata};
4use crate::store::{PayStore, StorageBackend};
5use crate::types::*;
6use alloy::network::EthereumWallet;
7use alloy::primitives::{Address, U256};
8use alloy::providers::{Provider, ProviderBuilder};
9use alloy::signers::local::{coins_bip39::English, MnemonicBuilder, PrivateKeySigner};
10use async_trait::async_trait;
11use bip39::Mnemonic;
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14
15fn evm_wallet_summary(meta: WalletMetadata, address: String) -> WalletSummary {
16    WalletSummary {
17        id: meta.id,
18        network: Network::Evm,
19        label: meta.label,
20        address,
21        backend: None,
22        mint_url: None,
23        rpc_endpoints: meta.evm_rpc_endpoints,
24        chain_id: meta.evm_chain_id,
25        created_at_epoch_s: meta.created_at_epoch_s,
26    }
27}
28
29pub struct EvmProvider {
30    _data_dir: String,
31    http_client: reqwest::Client,
32    store: Arc<StorageBackend>,
33}
34
35const INVALID_EVM_WALLET_ADDRESS: &str = "invalid:evm-wallet-secret";
36
37// Well-known chain IDs
38const CHAIN_ID_BASE: u64 = 8453;
39
40// Legacy USDC contract address resolver — kept for backward-compat tests.
41// New code uses tokens::resolve_evm_token().
42#[cfg(test)]
43fn usdc_contract_address(chain_id: u64) -> Option<Address> {
44    tokens::resolve_evm_token(chain_id, "usdc").and_then(|t| t.address.parse().ok())
45}
46
47#[derive(Debug, Clone)]
48struct EvmTransferTarget {
49    recipient_address: Address,
50    amount_wei: U256,
51    /// Spend-limit asset label: "native" for ETH/gas, symbol or contract for ERC-20.
52    token_label: String,
53    /// If set, this is an ERC-20 token transfer instead of the chain's native token.
54    token_contract: Option<Address>,
55}
56
57impl EvmProvider {
58    pub fn new(data_dir: &str, store: Arc<StorageBackend>) -> Self {
59        Self {
60            _data_dir: data_dir.to_string(),
61            http_client: reqwest::Client::new(),
62            store,
63        }
64    }
65
66    fn normalize_rpc_endpoint(raw: &str) -> Result<String, PayError> {
67        let trimmed = raw.trim();
68        if trimmed.is_empty() {
69            return Err(PayError::InvalidAmount(
70                "evm wallet requires --evm-rpc-endpoint".to_string(),
71            ));
72        }
73        let endpoint = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
74            trimmed.to_string()
75        } else {
76            format!("https://{trimmed}")
77        };
78        reqwest::Url::parse(&endpoint)
79            .map_err(|e| PayError::InvalidAmount(format!("invalid --evm-rpc-endpoint: {e}")))?;
80        Ok(endpoint)
81    }
82
83    fn signer_from_mnemonic(mnemonic_str: &str) -> Result<PrivateKeySigner, PayError> {
84        // Derive EVM key from BIP39 mnemonic using BIP44 path m/44'/60'/0'/0/0
85        MnemonicBuilder::<English>::default()
86            .phrase(mnemonic_str)
87            .index(0u32)
88            .map_err(|e| PayError::InternalError(format!("evm derivation index: {e}")))?
89            .build()
90            .map_err(|e| PayError::InternalError(format!("build evm signer from mnemonic: {e}")))
91    }
92
93    fn wallet_signer(meta: &WalletMetadata) -> Result<PrivateKeySigner, PayError> {
94        let seed_secret = meta.seed_secret.as_deref().ok_or_else(|| {
95            PayError::InternalError(format!("wallet {} missing evm secret", meta.id))
96        })?;
97        Self::signer_from_mnemonic(seed_secret)
98    }
99
100    fn wallet_address(meta: &WalletMetadata) -> Result<String, PayError> {
101        Ok(format!("{:?}", Self::wallet_signer(meta)?.address()))
102    }
103
104    fn rpc_endpoints_for_wallet(meta: &WalletMetadata) -> Result<Vec<String>, PayError> {
105        meta.evm_rpc_endpoints
106            .as_ref()
107            .filter(|v| !v.is_empty())
108            .cloned()
109            .ok_or_else(|| {
110                PayError::InternalError(format!(
111                    "wallet {} missing evm rpc endpoints; re-create with --evm-rpc-endpoint",
112                    meta.id
113                ))
114            })
115    }
116
117    fn chain_id_for_wallet(meta: &WalletMetadata) -> u64 {
118        meta.evm_chain_id.unwrap_or(CHAIN_ID_BASE)
119    }
120
121    fn load_evm_wallet(&self, wallet_id: &str) -> Result<WalletMetadata, PayError> {
122        let meta = self.store.load_wallet_metadata(wallet_id)?;
123        if meta.network != Network::Evm {
124            return Err(PayError::WalletNotFound(format!(
125                "wallet {wallet_id} is not an evm wallet"
126            )));
127        }
128        Ok(meta)
129    }
130
131    fn resolve_wallet_id(&self, wallet_id: &str) -> Result<String, PayError> {
132        if wallet_id.is_empty() {
133            let wallets = self.store.list_wallet_metadata(Some(Network::Evm))?;
134            if wallets.len() == 1 {
135                return Ok(wallets[0].id.clone());
136            }
137            return Err(PayError::InvalidAmount(
138                "multiple evm wallets exist; specify --wallet".to_string(),
139            ));
140        }
141        Ok(wallet_id.to_string())
142    }
143
144    fn parse_transfer_target(to: &str, chain_id: u64) -> Result<EvmTransferTarget, PayError> {
145        let trimmed = to.trim();
146        if trimmed.is_empty() {
147            return Err(PayError::InvalidAmount(
148                "evm send target is empty".to_string(),
149            ));
150        }
151        // Format: ethereum:<address>?amount=<amount>&token=native
152        // or:     ethereum:<address>?amount-wei=<amount> (legacy alias)
153        // or:     ethereum:<address>?amount-gwei=<amount> (legacy alias, gwei×1e9→wei)
154        let no_scheme = trimmed.strip_prefix("ethereum:").unwrap_or(trimmed);
155        let (recipient_str, query) = match no_scheme.split_once('?') {
156            Some(parts) => parts,
157            None => (no_scheme, ""),
158        };
159        let recipient_address: Address = recipient_str
160            .trim()
161            .parse()
162            .map_err(|e| PayError::InvalidAmount(format!("invalid evm recipient address: {e}")))?;
163
164        let mut amount_wei: Option<U256> = None;
165        let mut token_label = "native".to_string();
166        let mut token_contract: Option<Address> = None;
167
168        for pair in query.split('&') {
169            if pair.is_empty() {
170                continue;
171            }
172            let (key, value) = pair
173                .split_once('=')
174                .ok_or_else(|| PayError::InvalidAmount(format!("invalid query pair: {pair}")))?;
175            match key {
176                "amount" | "amount-wei" => {
177                    amount_wei =
178                        Some(value.parse::<U256>().map_err(|e| {
179                            PayError::InvalidAmount(format!("invalid amount: {e}"))
180                        })?);
181                }
182                "amount-gwei" => {
183                    let gwei: u64 = value.parse().map_err(|e| {
184                        PayError::InvalidAmount(format!("invalid amount-gwei: {e}"))
185                    })?;
186                    amount_wei = Some(U256::from(gwei) * U256::from(1_000_000_000u64));
187                }
188                "token" => {
189                    if value == "native" {
190                        token_label = "native".to_string();
191                        // Explicit native token — no ERC-20 contract
192                    } else if let Some(known) = tokens::resolve_evm_token(chain_id, value) {
193                        token_label = value.to_ascii_lowercase();
194                        token_contract = known.address.parse().ok();
195                        if token_contract.is_none() {
196                            return Err(PayError::InvalidAmount(format!(
197                                "failed to parse known token address for {value}"
198                            )));
199                        }
200                    } else if value.starts_with("0x") || value.starts_with("0X") {
201                        token_label = value.to_ascii_lowercase();
202                        token_contract = Some(value.parse().map_err(|e| {
203                            PayError::InvalidAmount(format!("invalid token contract address: {e}"))
204                        })?);
205                    } else {
206                        return Err(PayError::InvalidAmount(format!(
207                            "unknown token '{value}' on chain_id {chain_id}; use a known symbol (native, usdc, usdt) or contract address"
208                        )));
209                    }
210                }
211                _ => {
212                    // ignore unknown query params
213                }
214            }
215        }
216
217        let amount_wei = amount_wei.ok_or_else(|| {
218            PayError::InvalidAmount(
219                "evm send target missing amount; use ethereum:<address>?amount=<u64>&token=native"
220                    .to_string(),
221            )
222        })?;
223
224        Ok(EvmTransferTarget {
225            recipient_address,
226            amount_wei,
227            token_label,
228            token_contract,
229        })
230    }
231
232    fn spend_debits_for_target(target: &EvmTransferTarget, fee_gwei: u64) -> Vec<SpendDebit> {
233        let fee_wei = gwei_to_wei_saturating(fee_gwei);
234        let amount_wei = u256_to_u64_saturating(target.amount_wei);
235        if target.token_contract.is_some() {
236            vec![
237                SpendDebit {
238                    amount_native: amount_wei,
239                    token: Some(target.token_label.clone()),
240                },
241                SpendDebit {
242                    amount_native: fee_wei,
243                    token: Some("native".to_string()),
244                },
245            ]
246        } else {
247            vec![SpendDebit {
248                amount_native: amount_wei.saturating_add(fee_wei),
249                token: Some("native".to_string()),
250            }]
251        }
252    }
253
254    // Provider is built inline in withdraw() to avoid complex generic return types.
255
256    /// Get ETH balance for an address via JSON-RPC (raw reqwest, no alloy provider needed).
257    async fn get_balance_raw(&self, endpoints: &[String], address: &str) -> Result<U256, PayError> {
258        let mut last_error: Option<String> = None;
259        for endpoint in endpoints {
260            let body = serde_json::json!({
261                "jsonrpc": "2.0",
262                "method": "eth_getBalance",
263                "params": [address, "latest"],
264                "id": 1
265            });
266            match self.http_client.post(endpoint).json(&body).send().await {
267                Ok(resp) => {
268                    let status = resp.status();
269                    let text = resp.text().await.unwrap_or_default();
270                    if !status.is_success() {
271                        last_error =
272                            Some(format!("endpoint={endpoint} status={status} body={text}"));
273                        continue;
274                    }
275                    let parsed: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
276                        PayError::NetworkError(format!("endpoint={endpoint} invalid json: {e}"))
277                    })?;
278                    if let Some(err) = parsed.get("error") {
279                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
280                        continue;
281                    }
282                    let result_hex =
283                        parsed
284                            .get("result")
285                            .and_then(|v| v.as_str())
286                            .ok_or_else(|| {
287                                PayError::NetworkError(format!(
288                                    "endpoint={endpoint} missing result in response"
289                                ))
290                            })?;
291                    let balance = U256::from_str_radix(
292                        result_hex.strip_prefix("0x").unwrap_or(result_hex),
293                        16,
294                    )
295                    .map_err(|e| {
296                        PayError::NetworkError(format!(
297                            "endpoint={endpoint} invalid balance hex: {e}"
298                        ))
299                    })?;
300                    return Ok(balance);
301                }
302                Err(e) => {
303                    last_error = Some(format!("endpoint={endpoint} request failed: {e}"));
304                }
305            }
306        }
307        Err(PayError::NetworkError(format!(
308            "all evm rpc endpoints failed: {}",
309            last_error.unwrap_or_default()
310        )))
311    }
312
313    /// Get ERC-20 token balance for an address via `eth_call` (balanceOf).
314    async fn get_erc20_balance_raw(
315        &self,
316        endpoints: &[String],
317        token_contract: &str,
318        address: &str,
319    ) -> Result<U256, PayError> {
320        // balanceOf(address) selector: 0x70a08231
321        let addr_no_prefix = address.strip_prefix("0x").unwrap_or(address);
322        let calldata = format!("0x70a08231000000000000000000000000{addr_no_prefix}");
323        let mut last_error: Option<String> = None;
324        for endpoint in endpoints {
325            let body = serde_json::json!({
326                "jsonrpc": "2.0",
327                "method": "eth_call",
328                "params": [
329                    {"to": token_contract, "data": calldata},
330                    "latest"
331                ],
332                "id": 1
333            });
334            match self.http_client.post(endpoint).json(&body).send().await {
335                Ok(resp) => {
336                    let status = resp.status();
337                    let text = resp.text().await.unwrap_or_default();
338                    if !status.is_success() {
339                        last_error =
340                            Some(format!("endpoint={endpoint} status={status} body={text}"));
341                        continue;
342                    }
343                    let parsed: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
344                        PayError::NetworkError(format!("endpoint={endpoint} invalid json: {e}"))
345                    })?;
346                    if let Some(err) = parsed.get("error") {
347                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
348                        continue;
349                    }
350                    let result_hex = parsed
351                        .get("result")
352                        .and_then(|v| v.as_str())
353                        .unwrap_or("0x0");
354                    let balance = U256::from_str_radix(
355                        result_hex.strip_prefix("0x").unwrap_or(result_hex),
356                        16,
357                    )
358                    .map_err(|e| {
359                        PayError::NetworkError(format!(
360                            "endpoint={endpoint} invalid balanceOf hex: {e}"
361                        ))
362                    })?;
363                    return Ok(balance);
364                }
365                Err(e) => {
366                    last_error = Some(format!("endpoint={endpoint} request failed: {e}"));
367                }
368            }
369        }
370        Err(PayError::NetworkError(format!(
371            "all evm rpc endpoints failed for balanceOf: {}",
372            last_error.unwrap_or_default()
373        )))
374    }
375
376    /// Query token balances for known tokens and custom tokens, adding to BalanceInfo.additional.
377    async fn enrich_with_token_balances(
378        &self,
379        endpoints: &[String],
380        address: &str,
381        chain_id: u64,
382        custom_tokens: &[wallet::CustomToken],
383        balance: &mut BalanceInfo,
384    ) {
385        for known in tokens::evm_known_tokens(chain_id) {
386            if let Ok(raw) = self
387                .get_erc20_balance_raw(endpoints, known.address, address)
388                .await
389            {
390                let val: u64 = raw.try_into().unwrap_or(u64::MAX);
391                if val > 0 {
392                    balance
393                        .additional
394                        .insert(format!("{}_base_units", known.symbol), val);
395                    balance
396                        .additional
397                        .insert(format!("{}_decimals", known.symbol), known.decimals as u64);
398                }
399            }
400        }
401        for ct in custom_tokens {
402            if let Ok(raw) = self
403                .get_erc20_balance_raw(endpoints, &ct.address, address)
404                .await
405            {
406                let val: u64 = raw.try_into().unwrap_or(u64::MAX);
407                if val > 0 {
408                    balance
409                        .additional
410                        .insert(format!("{}_base_units", ct.symbol), val);
411                    balance
412                        .additional
413                        .insert(format!("{}_decimals", ct.symbol), ct.decimals as u64);
414                }
415            }
416        }
417    }
418
419    /// Make a single JSON-RPC call, returning the hex string result.
420    async fn json_rpc_hex(
421        &self,
422        endpoint: &str,
423        method: &str,
424        params: serde_json::Value,
425    ) -> Result<String, String> {
426        let body = serde_json::json!({
427            "jsonrpc": "2.0",
428            "method": method,
429            "params": params,
430            "id": 1
431        });
432        let resp = self
433            .http_client
434            .post(endpoint)
435            .json(&body)
436            .send()
437            .await
438            .map_err(|e| format!("endpoint={endpoint} {method}: {e}"))?;
439        let text = resp.text().await.unwrap_or_default();
440        let parsed: serde_json::Value =
441            serde_json::from_str(&text).map_err(|e| format!("invalid json: {e}"))?;
442        if let Some(err) = parsed.get("error") {
443            return Err(format!("endpoint={endpoint} {method} rpc error: {err}"));
444        }
445        parsed
446            .get("result")
447            .and_then(|v| v.as_str())
448            .map(|s| s.to_string())
449            .ok_or_else(|| format!("endpoint={endpoint} {method}: missing result"))
450    }
451
452    /// Estimate gas fee in gwei using eth_estimateGas + eth_gasPrice.
453    async fn estimate_fee_gwei(
454        &self,
455        endpoints: &[String],
456        from: &str,
457        to_addr: &str,
458        data: Option<&str>,
459    ) -> Result<u64, PayError> {
460        let mut last_error: Option<String> = None;
461        for endpoint in endpoints {
462            let tx_obj = if let Some(d) = data {
463                serde_json::json!({ "from": from, "to": to_addr, "data": d })
464            } else {
465                serde_json::json!({ "from": from, "to": to_addr })
466            };
467            let gas_hex = match self
468                .json_rpc_hex(endpoint, "eth_estimateGas", serde_json::json!([tx_obj]))
469                .await
470            {
471                Ok(h) => h,
472                Err(e) => {
473                    last_error = Some(e);
474                    continue;
475                }
476            };
477            let price_hex = match self
478                .json_rpc_hex(endpoint, "eth_gasPrice", serde_json::json!([]))
479                .await
480            {
481                Ok(h) => h,
482                Err(e) => {
483                    last_error = Some(e);
484                    continue;
485                }
486            };
487            let gas = u128::from_str_radix(gas_hex.strip_prefix("0x").unwrap_or(&gas_hex), 16)
488                .unwrap_or(21000);
489            let price =
490                u128::from_str_radix(price_hex.strip_prefix("0x").unwrap_or(&price_hex), 16)
491                    .unwrap_or(0);
492            let fee_wei = gas.saturating_mul(price);
493            return Ok((fee_wei / 1_000_000_000) as u64);
494        }
495        Err(PayError::NetworkError(format!(
496            "estimate_fee failed: {}",
497            last_error.unwrap_or_default()
498        )))
499    }
500
501    /// Get the current block number.
502    async fn get_block_number_raw(&self, endpoints: &[String]) -> Result<u64, PayError> {
503        let mut last_error: Option<String> = None;
504        for endpoint in endpoints {
505            let body = serde_json::json!({
506                "jsonrpc": "2.0",
507                "method": "eth_blockNumber",
508                "params": [],
509                "id": 1
510            });
511            match self.http_client.post(endpoint).json(&body).send().await {
512                Ok(resp) => {
513                    let text = resp.text().await.unwrap_or_default();
514                    let parsed: serde_json::Value = serde_json::from_str(&text)
515                        .map_err(|e| PayError::NetworkError(format!("invalid json: {e}")))?;
516                    if let Some(err) = parsed.get("error") {
517                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
518                        continue;
519                    }
520                    let hex = parsed
521                        .get("result")
522                        .and_then(|v| v.as_str())
523                        .unwrap_or("0x0");
524                    let num =
525                        u64::from_str_radix(hex.strip_prefix("0x").unwrap_or(hex), 16).unwrap_or(0);
526                    return Ok(num);
527                }
528                Err(e) => {
529                    last_error = Some(format!("endpoint={endpoint}: {e}"));
530                }
531            }
532        }
533        Err(PayError::NetworkError(format!(
534            "eth_blockNumber failed: {}",
535            last_error.unwrap_or_default()
536        )))
537    }
538
539    /// Get transaction receipt to check confirmation status.
540    async fn get_transaction_receipt_raw(
541        &self,
542        endpoints: &[String],
543        tx_hash: &str,
544    ) -> Result<Option<EvmTxReceipt>, PayError> {
545        let mut last_error: Option<String> = None;
546        for endpoint in endpoints {
547            let body = serde_json::json!({
548                "jsonrpc": "2.0",
549                "method": "eth_getTransactionReceipt",
550                "params": [tx_hash],
551                "id": 1
552            });
553            match self.http_client.post(endpoint).json(&body).send().await {
554                Ok(resp) => {
555                    let text = resp.text().await.unwrap_or_default();
556                    let parsed: serde_json::Value = serde_json::from_str(&text)
557                        .map_err(|e| PayError::NetworkError(format!("invalid json: {e}")))?;
558                    if let Some(err) = parsed.get("error") {
559                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
560                        continue;
561                    }
562                    let result = parsed.get("result");
563                    if result.is_none() || result == Some(&serde_json::Value::Null) {
564                        return Ok(None); // pending
565                    }
566                    let receipt: EvmTxReceipt =
567                        serde_json::from_value(result.cloned().unwrap_or_default())
568                            .map_err(|e| PayError::NetworkError(format!("parse receipt: {e}")))?;
569                    return Ok(Some(receipt));
570                }
571                Err(e) => {
572                    last_error = Some(format!("endpoint={endpoint}: {e}"));
573                }
574            }
575        }
576        Err(PayError::NetworkError(format!(
577            "eth_getTransactionReceipt failed: {}",
578            last_error.unwrap_or_default()
579        )))
580    }
581
582    async fn get_transaction_input_raw(
583        &self,
584        endpoints: &[String],
585        tx_hash: &str,
586    ) -> Result<Option<Vec<u8>>, PayError> {
587        let mut last_error: Option<String> = None;
588        for endpoint in endpoints {
589            let body = serde_json::json!({
590                "jsonrpc": "2.0",
591                "method": "eth_getTransactionByHash",
592                "params": [tx_hash],
593                "id": 1
594            });
595            match self.http_client.post(endpoint).json(&body).send().await {
596                Ok(resp) => {
597                    let text = resp.text().await.unwrap_or_default();
598                    let parsed: serde_json::Value = serde_json::from_str(&text)
599                        .map_err(|e| PayError::NetworkError(format!("invalid json: {e}")))?;
600                    if let Some(err) = parsed.get("error") {
601                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
602                        continue;
603                    }
604                    let result = parsed.get("result");
605                    if result.is_none() || result == Some(&serde_json::Value::Null) {
606                        return Ok(None);
607                    }
608                    let tx: EvmTxByHash = serde_json::from_value(
609                        result.cloned().unwrap_or_default(),
610                    )
611                    .map_err(|e| PayError::NetworkError(format!("parse transaction: {e}")))?;
612                    let input = tx.input.as_deref().unwrap_or("0x");
613                    return Ok(Some(decode_hex_data_bytes(input)?));
614                }
615                Err(e) => {
616                    last_error = Some(format!("endpoint={endpoint}: {e}"));
617                }
618            }
619        }
620        Err(PayError::NetworkError(format!(
621            "eth_getTransactionByHash failed: {}",
622            last_error.unwrap_or_default()
623        )))
624    }
625
626    async fn get_block_with_transactions_raw(
627        &self,
628        endpoints: &[String],
629        block_number: u64,
630    ) -> Result<Option<EvmBlockByNumber>, PayError> {
631        let block_hex = format!("0x{block_number:x}");
632        let mut last_error: Option<String> = None;
633        for endpoint in endpoints {
634            let body = serde_json::json!({
635                "jsonrpc": "2.0",
636                "method": "eth_getBlockByNumber",
637                "params": [block_hex, true],
638                "id": 1
639            });
640            match self.http_client.post(endpoint).json(&body).send().await {
641                Ok(resp) => {
642                    let text = resp.text().await.unwrap_or_default();
643                    let parsed: serde_json::Value = serde_json::from_str(&text)
644                        .map_err(|e| PayError::NetworkError(format!("invalid json: {e}")))?;
645                    if let Some(err) = parsed.get("error") {
646                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
647                        continue;
648                    }
649                    let result = parsed.get("result");
650                    if result.is_none() || result == Some(&serde_json::Value::Null) {
651                        return Ok(None);
652                    }
653                    let block: EvmBlockByNumber =
654                        serde_json::from_value(result.cloned().unwrap_or_default())
655                            .map_err(|e| PayError::NetworkError(format!("parse block: {e}")))?;
656                    return Ok(Some(block));
657                }
658                Err(e) => {
659                    last_error = Some(format!("endpoint={endpoint}: {e}"));
660                }
661            }
662        }
663        Err(PayError::NetworkError(format!(
664            "eth_getBlockByNumber failed: {}",
665            last_error.unwrap_or_default()
666        )))
667    }
668
669    async fn get_block_timestamp_raw(
670        &self,
671        endpoints: &[String],
672        block_number: u64,
673    ) -> Result<Option<u64>, PayError> {
674        let block_hex = format!("0x{block_number:x}");
675        let mut last_error: Option<String> = None;
676        for endpoint in endpoints {
677            let body = serde_json::json!({
678                "jsonrpc": "2.0",
679                "method": "eth_getBlockByNumber",
680                "params": [block_hex, false],
681                "id": 1
682            });
683            match self.http_client.post(endpoint).json(&body).send().await {
684                Ok(resp) => {
685                    let text = resp.text().await.unwrap_or_default();
686                    let parsed: serde_json::Value = serde_json::from_str(&text)
687                        .map_err(|e| PayError::NetworkError(format!("invalid json: {e}")))?;
688                    if let Some(err) = parsed.get("error") {
689                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
690                        continue;
691                    }
692                    let result = parsed.get("result");
693                    if result.is_none() || result == Some(&serde_json::Value::Null) {
694                        return Ok(None);
695                    }
696                    let header: EvmBlockHeader = serde_json::from_value(
697                        result.cloned().unwrap_or_default(),
698                    )
699                    .map_err(|e| PayError::NetworkError(format!("parse block header: {e}")))?;
700                    return Ok(header.timestamp.as_deref().and_then(parse_hex_u64));
701                }
702                Err(e) => {
703                    last_error = Some(format!("endpoint={endpoint}: {e}"));
704                }
705            }
706        }
707        Err(PayError::NetworkError(format!(
708            "eth_getBlockByNumber(timestamp) failed: {}",
709            last_error.unwrap_or_default()
710        )))
711    }
712
713    async fn get_erc20_transfer_logs_to_address(
714        &self,
715        endpoints: &[String],
716        token_contract: &str,
717        from_block: u64,
718        to_block: u64,
719        recipient: &str,
720    ) -> Result<Vec<EvmLogEntry>, PayError> {
721        if from_block > to_block {
722            return Ok(vec![]);
723        }
724        let recipient_topic = address_topic(recipient)
725            .ok_or_else(|| PayError::InvalidAmount("invalid evm recipient address".to_string()))?;
726        let from_hex = format!("0x{from_block:x}");
727        let to_hex = format!("0x{to_block:x}");
728
729        let mut last_error: Option<String> = None;
730        for endpoint in endpoints {
731            let body = serde_json::json!({
732                "jsonrpc": "2.0",
733                "method": "eth_getLogs",
734                "params": [{
735                    "fromBlock": from_hex,
736                    "toBlock": to_hex,
737                    "address": token_contract,
738                    "topics": [
739                        ERC20_TRANSFER_EVENT_TOPIC,
740                        serde_json::Value::Null,
741                        recipient_topic
742                    ]
743                }],
744                "id": 1
745            });
746            match self.http_client.post(endpoint).json(&body).send().await {
747                Ok(resp) => {
748                    let text = resp.text().await.unwrap_or_default();
749                    let parsed: serde_json::Value = serde_json::from_str(&text)
750                        .map_err(|e| PayError::NetworkError(format!("invalid json: {e}")))?;
751                    if let Some(err) = parsed.get("error") {
752                        last_error = Some(format!("endpoint={endpoint} rpc error: {err}"));
753                        continue;
754                    }
755                    let result = parsed.get("result").cloned().unwrap_or_default();
756                    let logs: Vec<EvmLogEntry> = serde_json::from_value(result)
757                        .map_err(|e| PayError::NetworkError(format!("parse logs: {e}")))?;
758                    return Ok(logs);
759                }
760                Err(e) => {
761                    last_error = Some(format!("endpoint={endpoint}: {e}"));
762                }
763            }
764        }
765        Err(PayError::NetworkError(format!(
766            "eth_getLogs failed: {}",
767            last_error.unwrap_or_default()
768        )))
769    }
770
771    async fn sync_receive_records_from_chain(
772        &self,
773        ctx: ReceiveSyncContext<'_>,
774        known_txids: &mut HashSet<String>,
775    ) -> Result<HistorySyncStats, PayError> {
776        let mut stats = HistorySyncStats::default();
777        let scan_limit = ctx.limit.max(1);
778        let latest_block = self.get_block_number_raw(ctx.endpoints).await?;
779        let lookback_blocks = (scan_limit as u64).saturating_mul(4).clamp(32, 2048);
780        let start_block = latest_block.saturating_sub(lookback_blocks.saturating_sub(1));
781        let now = wallet::now_epoch_seconds();
782        let normalized_wallet = normalize_address(ctx.wallet_address)
783            .ok_or_else(|| PayError::InvalidAmount("invalid evm wallet address".to_string()))?;
784        let mut memo_cache: HashMap<String, Option<String>> = HashMap::new();
785        let mut block_ts_cache: HashMap<u64, u64> = HashMap::new();
786
787        for block_number in (start_block..=latest_block).rev() {
788            if stats.records_added >= scan_limit {
789                break;
790            }
791            let Some(block) = self
792                .get_block_with_transactions_raw(ctx.endpoints, block_number)
793                .await?
794            else {
795                continue;
796            };
797            let block_timestamp = block
798                .timestamp
799                .as_deref()
800                .and_then(parse_hex_u64)
801                .unwrap_or(now);
802            block_ts_cache.insert(block_number, block_timestamp);
803
804            for tx in block.transactions {
805                stats.records_scanned = stats.records_scanned.saturating_add(1);
806                if stats.records_added >= scan_limit {
807                    break;
808                }
809                let Some(tx_hash) = tx.hash else {
810                    continue;
811                };
812                if known_txids.contains(&tx_hash) {
813                    continue;
814                }
815                let Some(to_addr) = tx.to.as_deref().and_then(normalize_address) else {
816                    continue;
817                };
818                if to_addr != normalized_wallet {
819                    continue;
820                }
821                let Some(value_wei) = tx.value.as_deref().and_then(parse_hex_u256) else {
822                    continue;
823                };
824                if value_wei.is_zero() {
825                    continue;
826                }
827                let amount_gwei: u64 = (value_wei / U256::from(1_000_000_000u64))
828                    .try_into()
829                    .unwrap_or(u64::MAX);
830                if amount_gwei == 0 {
831                    continue;
832                }
833                let memo = tx
834                    .input
835                    .as_deref()
836                    .and_then(|input| decode_hex_data_bytes(input).ok())
837                    .and_then(|input| decode_onchain_memo(&input));
838                let record = HistoryRecord {
839                    transaction_id: tx_hash.clone(),
840                    wallet: ctx.wallet_id.to_string(),
841                    network: Network::Evm,
842                    direction: Direction::Receive,
843                    amount: Amount {
844                        value: amount_gwei,
845                        token: "gwei".to_string(),
846                    },
847                    status: TxStatus::Confirmed,
848                    onchain_memo: memo,
849                    local_memo: None,
850                    remote_addr: tx.from.as_deref().and_then(normalize_address),
851                    preimage: None,
852                    created_at_epoch_s: block_timestamp,
853                    confirmed_at_epoch_s: Some(block_timestamp),
854                    fee: None,
855                    reference_keys: None,
856                };
857                let _ = self.store.append_transaction_record(&record);
858                known_txids.insert(tx_hash);
859                stats.records_added = stats.records_added.saturating_add(1);
860            }
861        }
862
863        let mut tracked_tokens: Vec<(String, String)> = tokens::evm_known_tokens(ctx.chain_id)
864            .iter()
865            .map(|token| (token.symbol.to_string(), token.address.to_ascii_lowercase()))
866            .collect();
867        for ct in ctx.custom_tokens {
868            tracked_tokens.push((
869                ct.symbol.to_ascii_lowercase(),
870                ct.address.to_ascii_lowercase(),
871            ));
872        }
873        let mut seen_contracts = HashSet::new();
874        tracked_tokens.retain(|(_, contract)| seen_contracts.insert(contract.clone()));
875
876        for (symbol, contract) in tracked_tokens {
877            if stats.records_added >= scan_limit {
878                break;
879            }
880            let logs = self
881                .get_erc20_transfer_logs_to_address(
882                    ctx.endpoints,
883                    &contract,
884                    start_block,
885                    latest_block,
886                    &normalized_wallet,
887                )
888                .await?;
889            stats.records_scanned = stats.records_scanned.saturating_add(logs.len());
890            for log in logs {
891                if stats.records_added >= scan_limit {
892                    break;
893                }
894                let Some(tx_hash) = log.transaction_hash else {
895                    continue;
896                };
897                if known_txids.contains(&tx_hash) {
898                    continue;
899                }
900                let Some(data_hex) = log.data.as_deref() else {
901                    continue;
902                };
903                let Some(amount_raw) = parse_hex_u256(data_hex) else {
904                    continue;
905                };
906                if amount_raw.is_zero() {
907                    continue;
908                }
909                let amount_value: u64 = amount_raw.try_into().unwrap_or(u64::MAX);
910                let block_number = log
911                    .block_number
912                    .as_deref()
913                    .and_then(parse_hex_u64)
914                    .unwrap_or(latest_block);
915                let block_timestamp = if let Some(ts) = block_ts_cache.get(&block_number) {
916                    *ts
917                } else {
918                    let ts = self
919                        .get_block_timestamp_raw(ctx.endpoints, block_number)
920                        .await?
921                        .unwrap_or(now);
922                    block_ts_cache.insert(block_number, ts);
923                    ts
924                };
925                let memo = if let Some(cached) = memo_cache.get(&tx_hash) {
926                    cached.clone()
927                } else {
928                    let decoded = match self
929                        .get_transaction_input_raw(ctx.endpoints, &tx_hash)
930                        .await?
931                    {
932                        Some(input) => decode_onchain_memo(&input),
933                        None => None,
934                    };
935                    memo_cache.insert(tx_hash.clone(), decoded.clone());
936                    decoded
937                };
938                let remote_addr = log.topics.get(1).and_then(|t| topic_to_address(t));
939                let record = HistoryRecord {
940                    transaction_id: tx_hash.clone(),
941                    wallet: ctx.wallet_id.to_string(),
942                    network: Network::Evm,
943                    direction: Direction::Receive,
944                    amount: Amount {
945                        value: amount_value,
946                        token: symbol.clone(),
947                    },
948                    status: TxStatus::Confirmed,
949                    onchain_memo: memo,
950                    local_memo: None,
951                    remote_addr,
952                    preimage: None,
953                    created_at_epoch_s: block_timestamp,
954                    confirmed_at_epoch_s: Some(block_timestamp),
955                    fee: None,
956                    reference_keys: None,
957                };
958                let _ = self.store.append_transaction_record(&record);
959                known_txids.insert(tx_hash);
960                stats.records_added = stats.records_added.saturating_add(1);
961            }
962        }
963
964        Ok(stats)
965    }
966}
967
968#[derive(Debug, serde::Deserialize)]
969struct EvmTxReceipt {
970    #[serde(default, rename = "blockNumber")]
971    block_number: Option<String>,
972    #[serde(default)]
973    status: Option<String>,
974    #[serde(default, rename = "gasUsed")]
975    gas_used: Option<String>,
976    #[serde(default, rename = "effectiveGasPrice")]
977    effective_gas_price: Option<String>,
978}
979
980#[derive(Debug, serde::Deserialize)]
981struct EvmTxByHash {
982    #[serde(default)]
983    input: Option<String>,
984}
985
986#[derive(Debug, serde::Deserialize)]
987struct EvmBlockByNumber {
988    #[serde(default)]
989    timestamp: Option<String>,
990    #[serde(default)]
991    transactions: Vec<EvmBlockTransaction>,
992}
993
994#[derive(Debug, serde::Deserialize)]
995struct EvmBlockHeader {
996    #[serde(default)]
997    timestamp: Option<String>,
998}
999
1000#[derive(Debug, serde::Deserialize)]
1001struct EvmBlockTransaction {
1002    #[serde(default)]
1003    hash: Option<String>,
1004    #[serde(default)]
1005    from: Option<String>,
1006    #[serde(default)]
1007    to: Option<String>,
1008    #[serde(default)]
1009    value: Option<String>,
1010    #[serde(default)]
1011    input: Option<String>,
1012}
1013
1014#[derive(Debug, serde::Deserialize)]
1015struct EvmLogEntry {
1016    #[serde(default, rename = "transactionHash")]
1017    transaction_hash: Option<String>,
1018    #[serde(default, rename = "blockNumber")]
1019    block_number: Option<String>,
1020    #[serde(default)]
1021    data: Option<String>,
1022    #[serde(default)]
1023    topics: Vec<String>,
1024}
1025
1026struct ReceiveSyncContext<'a> {
1027    wallet_id: &'a str,
1028    endpoints: &'a [String],
1029    chain_id: u64,
1030    wallet_address: &'a str,
1031    custom_tokens: &'a [wallet::CustomToken],
1032    limit: usize,
1033}
1034
1035impl EvmTxReceipt {
1036    /// Calculate fee in gwei from gasUsed * effectiveGasPrice.
1037    fn fee_gwei(&self) -> Option<u64> {
1038        let gas_used_hex = self.gas_used.as_deref()?;
1039        let gas_price_hex = self.effective_gas_price.as_deref()?;
1040        let gas_used =
1041            u128::from_str_radix(gas_used_hex.strip_prefix("0x").unwrap_or(gas_used_hex), 16)
1042                .ok()?;
1043        let gas_price = u128::from_str_radix(
1044            gas_price_hex.strip_prefix("0x").unwrap_or(gas_price_hex),
1045            16,
1046        )
1047        .ok()?;
1048        // fee_wei = gasUsed * effectiveGasPrice; convert to gwei
1049        let fee_wei = gas_used.checked_mul(gas_price)?;
1050        Some((fee_wei / 1_000_000_000) as u64)
1051    }
1052}
1053
1054// ERC-20 transfer(address,uint256) function selector
1055const ERC20_TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb];
1056const ERC20_TRANSFER_EVENT_TOPIC: &str =
1057    "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
1058
1059fn encode_erc20_transfer(to: Address, amount: U256) -> Vec<u8> {
1060    let mut data = Vec::with_capacity(68);
1061    data.extend_from_slice(&ERC20_TRANSFER_SELECTOR);
1062    // pad address to 32 bytes
1063    data.extend_from_slice(&[0u8; 12]);
1064    data.extend_from_slice(to.as_slice());
1065    // amount as 32 bytes big-endian
1066    data.extend_from_slice(&amount.to_be_bytes::<32>());
1067    data
1068}
1069
1070fn normalize_onchain_memo(onchain_memo: Option<&str>) -> Result<Option<Vec<u8>>, PayError> {
1071    let Some(memo) = onchain_memo.map(str::trim).filter(|memo| !memo.is_empty()) else {
1072        return Ok(None);
1073    };
1074    let memo_bytes = memo.as_bytes();
1075    if memo_bytes.len() > 256 {
1076        return Err(PayError::InvalidAmount(
1077            "evm onchain-memo must be <= 256 bytes".to_string(),
1078        ));
1079    }
1080    Ok(Some(memo_bytes.to_vec()))
1081}
1082
1083fn append_memo_payload(mut data: Vec<u8>, memo_bytes: Option<&[u8]>) -> Vec<u8> {
1084    if let Some(memo) = memo_bytes {
1085        data.extend_from_slice(memo);
1086    }
1087    data
1088}
1089
1090fn decode_onchain_memo(input_data: &[u8]) -> Option<String> {
1091    let memo_slice = if input_data.starts_with(&ERC20_TRANSFER_SELECTOR) {
1092        if input_data.len() <= 68 {
1093            return None;
1094        }
1095        &input_data[68..]
1096    } else {
1097        input_data
1098    };
1099    if memo_slice.is_empty() {
1100        return None;
1101    }
1102    String::from_utf8(memo_slice.to_vec()).ok()
1103}
1104
1105fn decode_hex_data_bytes(raw: &str) -> Result<Vec<u8>, PayError> {
1106    let trimmed = raw.trim();
1107    let hex_data = trimmed.strip_prefix("0x").unwrap_or(trimmed);
1108    if hex_data.is_empty() {
1109        return Ok(Vec::new());
1110    }
1111    if !hex_data.len().is_multiple_of(2) {
1112        return Err(PayError::NetworkError(
1113            "invalid tx input hex length".to_string(),
1114        ));
1115    }
1116    hex::decode(hex_data).map_err(|e| PayError::NetworkError(format!("invalid tx input hex: {e}")))
1117}
1118
1119fn parse_hex_u64(raw: &str) -> Option<u64> {
1120    let hex = raw.strip_prefix("0x").unwrap_or(raw);
1121    u64::from_str_radix(hex, 16).ok()
1122}
1123
1124fn parse_hex_u256(raw: &str) -> Option<U256> {
1125    let hex = raw.strip_prefix("0x").unwrap_or(raw);
1126    U256::from_str_radix(hex, 16).ok()
1127}
1128
1129fn u256_to_u64_saturating(value: U256) -> u64 {
1130    value.try_into().unwrap_or(u64::MAX)
1131}
1132
1133fn gwei_to_wei_saturating(value: u64) -> u64 {
1134    value.saturating_mul(1_000_000_000)
1135}
1136
1137fn normalize_address(raw: &str) -> Option<String> {
1138    let trimmed = raw.trim();
1139    let body = trimmed
1140        .strip_prefix("0x")
1141        .or_else(|| trimmed.strip_prefix("0X"))?;
1142    if body.len() != 40 || !body.chars().all(|c| c.is_ascii_hexdigit()) {
1143        return None;
1144    }
1145    Some(format!("0x{}", body.to_ascii_lowercase()))
1146}
1147
1148fn address_topic(address: &str) -> Option<String> {
1149    let normalized = normalize_address(address)?;
1150    let body = normalized.strip_prefix("0x")?;
1151    Some(format!("0x{:0>64}", body))
1152}
1153
1154fn topic_to_address(topic: &str) -> Option<String> {
1155    let body = topic
1156        .strip_prefix("0x")
1157        .or_else(|| topic.strip_prefix("0X"))?;
1158    if body.len() != 64 || !body.chars().all(|c| c.is_ascii_hexdigit()) {
1159        return None;
1160    }
1161    normalize_address(&format!("0x{}", &body[24..]))
1162}
1163
1164fn receipt_status(receipt: &EvmTxReceipt) -> TxStatus {
1165    match receipt.status.as_deref() {
1166        Some("0x1") => TxStatus::Confirmed,
1167        Some("0x0") => TxStatus::Failed,
1168        _ => TxStatus::Pending,
1169    }
1170}
1171
1172fn receipt_confirmations(receipt: &EvmTxReceipt, current_block: u64) -> Option<u32> {
1173    let block_hex = receipt.block_number.as_deref()?;
1174    let block_num =
1175        u64::from_str_radix(block_hex.strip_prefix("0x").unwrap_or(block_hex), 16).ok()?;
1176    if current_block < block_num {
1177        return Some(0);
1178    }
1179    let depth = current_block.saturating_sub(block_num).saturating_add(1);
1180    Some(depth.min(u32::MAX as u64) as u32)
1181}
1182
1183#[async_trait]
1184impl PayProvider for EvmProvider {
1185    fn network(&self) -> Network {
1186        Network::Evm
1187    }
1188
1189    fn writes_locally(&self) -> bool {
1190        true
1191    }
1192
1193    async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
1194        if request.rpc_endpoints.is_empty() {
1195            return Err(PayError::InvalidAmount(
1196                "evm wallet requires --evm-rpc-endpoint (or rpc_endpoints in JSON)".to_string(),
1197            ));
1198        }
1199        let mut endpoints = Vec::new();
1200        for ep in &request.rpc_endpoints {
1201            let n = Self::normalize_rpc_endpoint(ep)?;
1202            if !endpoints.contains(&n) {
1203                endpoints.push(n);
1204            }
1205        }
1206        let chain_id = request.chain_id.unwrap_or(CHAIN_ID_BASE);
1207
1208        let mnemonic_str = if let Some(raw) = request.mnemonic_secret.as_deref() {
1209            let mnemonic: Mnemonic = raw.parse().map_err(|_| {
1210                PayError::InvalidAmount(
1211                    "invalid mnemonic-secret for evm wallet: expected BIP39 words".to_string(),
1212                )
1213            })?;
1214            mnemonic.words().collect::<Vec<_>>().join(" ")
1215        } else {
1216            let mut entropy = [0u8; 16];
1217            getrandom::fill(&mut entropy)
1218                .map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
1219            let mnemonic = Mnemonic::from_entropy(&entropy)
1220                .map_err(|e| PayError::InternalError(format!("mnemonic gen: {e}")))?;
1221            mnemonic.words().collect::<Vec<_>>().join(" ")
1222        };
1223
1224        let signer = Self::signer_from_mnemonic(&mnemonic_str)?;
1225        let address = format!("{:?}", signer.address());
1226
1227        let wallet_id = wallet::generate_wallet_identifier()?;
1228        let normalized_label = {
1229            let trimmed = request.label.trim();
1230            if trimmed.is_empty() || trimmed == "default" {
1231                None
1232            } else {
1233                Some(trimmed.to_string())
1234            }
1235        };
1236
1237        let meta = WalletMetadata {
1238            id: wallet_id.clone(),
1239            network: Network::Evm,
1240            label: normalized_label.clone(),
1241            mint_url: None,
1242            sol_rpc_endpoints: None,
1243            evm_rpc_endpoints: Some(endpoints),
1244            evm_chain_id: Some(chain_id),
1245            seed_secret: Some(mnemonic_str.clone()),
1246            backend: None,
1247            btc_esplora_url: None,
1248            btc_network: None,
1249            btc_address_type: None,
1250            btc_core_url: None,
1251            btc_core_auth_secret: None,
1252            btc_electrum_url: None,
1253            custom_tokens: None,
1254            created_at_epoch_s: wallet::now_epoch_seconds(),
1255            error: None,
1256        };
1257        self.store.save_wallet_metadata(&meta)?;
1258
1259        Ok(WalletInfo {
1260            id: wallet_id,
1261            network: Network::Evm,
1262            address,
1263            label: normalized_label,
1264            mnemonic: None,
1265        })
1266    }
1267
1268    async fn close_wallet(&self, wallet_id: &str) -> Result<(), PayError> {
1269        let balance = self.balance(wallet_id).await?;
1270        let non_zero_components = balance.non_zero_components();
1271        if !non_zero_components.is_empty() {
1272            let component_list = non_zero_components
1273                .iter()
1274                .map(|(name, value)| format!("{name}={value}"))
1275                .collect::<Vec<_>>()
1276                .join(", ");
1277            return Err(PayError::InvalidAmount(format!(
1278                "wallet {wallet_id} has non-zero balance components ({component_list}); transfer funds first"
1279            )));
1280        }
1281        self.store.delete_wallet_metadata(wallet_id)
1282    }
1283
1284    async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
1285        let wallets = self.store.list_wallet_metadata(Some(Network::Evm))?;
1286        Ok(wallets
1287            .into_iter()
1288            .map(|meta| {
1289                let address = Self::wallet_address(&meta)
1290                    .unwrap_or_else(|_| INVALID_EVM_WALLET_ADDRESS.to_string());
1291                evm_wallet_summary(meta, address)
1292            })
1293            .collect())
1294    }
1295
1296    async fn balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
1297        let resolved = self.resolve_wallet_id(wallet_id)?;
1298        let meta = self.load_evm_wallet(&resolved)?;
1299        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1300        let address = Self::wallet_address(&meta)?;
1301        let chain_id = Self::chain_id_for_wallet(&meta);
1302        let custom_tokens = meta.custom_tokens.as_deref().unwrap_or_default();
1303        let balance_wei = self.get_balance_raw(&endpoints, &address).await?;
1304        // Convert to gwei for the additional field
1305        let balance_gwei = balance_wei / U256::from(1_000_000_000u64);
1306        let gwei_u64: u64 = balance_gwei.try_into().unwrap_or(u64::MAX);
1307        let mut info = BalanceInfo::new(gwei_u64, 0, "gwei");
1308        self.enrich_with_token_balances(&endpoints, &address, chain_id, custom_tokens, &mut info)
1309            .await;
1310        Ok(info)
1311    }
1312
1313    async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
1314        let wallets = self.store.list_wallet_metadata(Some(Network::Evm))?;
1315        let mut items = Vec::with_capacity(wallets.len());
1316        for meta in wallets {
1317            let chain_id = Self::chain_id_for_wallet(&meta);
1318            let custom_tokens = meta.custom_tokens.as_deref().unwrap_or_default().to_vec();
1319            let endpoints = Self::rpc_endpoints_for_wallet(&meta);
1320            let address = Self::wallet_address(&meta);
1321            let result = match (endpoints, address) {
1322                (Ok(endpoints), Ok(address)) => {
1323                    match self.get_balance_raw(&endpoints, &address).await {
1324                        Ok(wei) => {
1325                            let gwei = wei / U256::from(1_000_000_000u64);
1326                            let gwei_u64: u64 = gwei.try_into().unwrap_or(u64::MAX);
1327                            let mut info = BalanceInfo::new(gwei_u64, 0, "gwei");
1328                            self.enrich_with_token_balances(
1329                                &endpoints,
1330                                &address,
1331                                chain_id,
1332                                &custom_tokens,
1333                                &mut info,
1334                            )
1335                            .await;
1336                            Ok(info)
1337                        }
1338                        Err(e) => Err(e),
1339                    }
1340                }
1341                (Err(e), _) | (_, Err(e)) => Err(e),
1342            };
1343            let summary_address = Self::wallet_address(&meta)
1344                .unwrap_or_else(|_| INVALID_EVM_WALLET_ADDRESS.to_string());
1345            let summary = evm_wallet_summary(meta, summary_address);
1346            match result {
1347                Ok(info) => {
1348                    items.push(WalletBalanceItem {
1349                        wallet: summary,
1350                        balance: Some(info),
1351                        error: None,
1352                    });
1353                }
1354                Err(error) => items.push(WalletBalanceItem {
1355                    wallet: summary,
1356                    balance: None,
1357                    error: Some(error.to_string()),
1358                }),
1359            }
1360        }
1361        Ok(items)
1362    }
1363
1364    async fn receive_info(
1365        &self,
1366        wallet_id: &str,
1367        _amount: Option<Amount>,
1368    ) -> Result<ReceiveInfo, PayError> {
1369        let resolved = self.resolve_wallet_id(wallet_id)?;
1370        let meta = self.load_evm_wallet(&resolved)?;
1371        let _ = Self::rpc_endpoints_for_wallet(&meta)?;
1372        Ok(ReceiveInfo {
1373            address: Some(Self::wallet_address(&meta)?),
1374            invoice: None,
1375            quote_id: None,
1376        })
1377    }
1378
1379    async fn receive_claim(&self, _wallet: &str, _quote_id: &str) -> Result<u64, PayError> {
1380        Err(PayError::NotImplemented(
1381            "evm receive has no claim step".to_string(),
1382        ))
1383    }
1384
1385    async fn cashu_send(
1386        &self,
1387        _wallet: &str,
1388        _amount: Amount,
1389        _onchain_memo: Option<&str>,
1390        _mints: Option<&[String]>,
1391    ) -> Result<CashuSendResult, PayError> {
1392        Err(PayError::NotImplemented(
1393            "evm does not use cashu send".to_string(),
1394        ))
1395    }
1396
1397    async fn cashu_receive(
1398        &self,
1399        _wallet: &str,
1400        _token: &str,
1401    ) -> Result<CashuReceiveResult, PayError> {
1402        Err(PayError::NotImplemented(
1403            "evm does not use cashu receive".to_string(),
1404        ))
1405    }
1406
1407    async fn send(
1408        &self,
1409        wallet: &str,
1410        to: &str,
1411        onchain_memo: Option<&str>,
1412        _mints: Option<&[String]>,
1413    ) -> Result<SendResult, PayError> {
1414        let resolved_wallet_id = self.resolve_wallet_id(wallet)?;
1415        let meta = self.load_evm_wallet(&resolved_wallet_id)?;
1416        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1417        let chain_id = Self::chain_id_for_wallet(&meta);
1418        let transfer_target = Self::parse_transfer_target(to, chain_id)?;
1419        let memo_bytes = normalize_onchain_memo(onchain_memo)?;
1420        let memo_payload = memo_bytes.as_deref();
1421
1422        let signer = Self::wallet_signer(&meta)?;
1423
1424        let mut last_error: Option<String> = None;
1425        let mut transaction_id: Option<String> = None;
1426
1427        for endpoint in &endpoints {
1428            let url: reqwest::Url = match endpoint.parse() {
1429                Ok(u) => u,
1430                Err(e) => {
1431                    last_error = Some(format!("endpoint={endpoint} invalid url: {e}"));
1432                    continue;
1433                }
1434            };
1435            let wallet = EthereumWallet::from(signer.clone());
1436            let provider = ProviderBuilder::new().wallet(wallet).connect_http(url);
1437
1438            let tx_result = if let Some(token_contract) = transfer_target.token_contract {
1439                // ERC-20 transfer
1440                let call_data = append_memo_payload(
1441                    encode_erc20_transfer(
1442                        transfer_target.recipient_address,
1443                        transfer_target.amount_wei,
1444                    ),
1445                    memo_bytes.as_deref(),
1446                );
1447                let tx = alloy::rpc::types::TransactionRequest::default()
1448                    .to(token_contract)
1449                    .input(call_data.into());
1450                provider.send_transaction(tx).await
1451            } else {
1452                // Native ETH transfer (memo as raw calldata bytes)
1453                let mut tx = alloy::rpc::types::TransactionRequest::default()
1454                    .to(transfer_target.recipient_address)
1455                    .value(transfer_target.amount_wei);
1456                if let Some(memo) = memo_payload {
1457                    tx = tx.input(memo.to_vec().into());
1458                }
1459                provider.send_transaction(tx).await
1460            };
1461
1462            match tx_result {
1463                Ok(pending) => {
1464                    let tx_hash = format!("{:?}", pending.tx_hash());
1465                    transaction_id = Some(tx_hash);
1466                    break;
1467                }
1468                Err(err) => {
1469                    last_error = Some(format!("endpoint={endpoint} sendTransaction: {err}"));
1470                }
1471            }
1472        }
1473
1474        let transaction_id = transaction_id.ok_or_else(|| {
1475            PayError::NetworkError(format!(
1476                "all evm rpc endpoints failed for withdraw: {}",
1477                last_error.unwrap_or_default()
1478            ))
1479        })?;
1480
1481        // Determine amount unit based on whether it's a token or native transfer
1482        let (amount_value, amount_token) = if transfer_target.token_contract.is_some() {
1483            // For USDC (6 decimals), the raw value is in micro-units
1484            let val: u64 = transfer_target.amount_wei.try_into().unwrap_or(u64::MAX);
1485            (val, "token-units".to_string())
1486        } else {
1487            let gwei = transfer_target.amount_wei / U256::from(1_000_000_000u64);
1488            let val: u64 = gwei.try_into().unwrap_or(u64::MAX);
1489            (val, "gwei".to_string())
1490        };
1491
1492        // Try to get fee from receipt (may be pending)
1493        let fee_amount = match self
1494            .get_transaction_receipt_raw(&endpoints, &transaction_id)
1495            .await
1496        {
1497            Ok(Some(receipt)) => receipt.fee_gwei().map(|g| Amount {
1498                value: g,
1499                token: "gwei".to_string(),
1500            }),
1501            _ => {
1502                // Fallback: estimate fee
1503                self.estimate_fee_gwei(
1504                    &endpoints,
1505                    &format!("{:?}", signer.address()),
1506                    &format!("{:?}", transfer_target.recipient_address),
1507                    None,
1508                )
1509                .await
1510                .ok()
1511                .map(|g| Amount {
1512                    value: g,
1513                    token: "gwei".to_string(),
1514                })
1515            }
1516        };
1517
1518        let history = HistoryRecord {
1519            transaction_id: transaction_id.clone(),
1520            wallet: resolved_wallet_id.clone(),
1521            network: Network::Evm,
1522            direction: Direction::Send,
1523            amount: Amount {
1524                value: amount_value,
1525                token: amount_token.clone(),
1526            },
1527            status: TxStatus::Pending,
1528            onchain_memo: onchain_memo.map(|s| s.to_string()),
1529            local_memo: None,
1530            remote_addr: Some(format!("{:?}", transfer_target.recipient_address)),
1531            preimage: None,
1532            created_at_epoch_s: wallet::now_epoch_seconds(),
1533            confirmed_at_epoch_s: None,
1534            fee: fee_amount.clone(),
1535            reference_keys: None,
1536        };
1537        let _ = self.store.append_transaction_record(&history);
1538
1539        Ok(SendResult {
1540            wallet: resolved_wallet_id,
1541            transaction_id,
1542            amount: Amount {
1543                value: amount_value,
1544                token: amount_token,
1545            },
1546            fee: fee_amount,
1547            preimage: None,
1548        })
1549    }
1550
1551    async fn send_quote(
1552        &self,
1553        wallet: &str,
1554        to: &str,
1555        _mints: Option<&[String]>,
1556    ) -> Result<SendQuoteInfo, PayError> {
1557        let resolved = self.resolve_wallet_id(wallet)?;
1558        let meta = self.load_evm_wallet(&resolved)?;
1559        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1560        let chain_id = Self::chain_id_for_wallet(&meta);
1561        let transfer_target = Self::parse_transfer_target(to, chain_id)?;
1562        let signer = Self::wallet_signer(&meta)?;
1563
1564        let (to_addr, data) = if let Some(token_contract) = transfer_target.token_contract {
1565            let call_data = encode_erc20_transfer(
1566                transfer_target.recipient_address,
1567                transfer_target.amount_wei,
1568            );
1569            (
1570                format!("{:?}", token_contract),
1571                Some(format!("0x{}", hex::encode(&call_data))),
1572            )
1573        } else {
1574            (format!("{:?}", transfer_target.recipient_address), None)
1575        };
1576
1577        let fee_gwei = self
1578            .estimate_fee_gwei(
1579                &endpoints,
1580                &format!("{:?}", signer.address()),
1581                &to_addr,
1582                data.as_deref(),
1583            )
1584            .await
1585            .unwrap_or(0);
1586
1587        let amount_wei_u64 = u256_to_u64_saturating(transfer_target.amount_wei);
1588        let spend_debits = Self::spend_debits_for_target(&transfer_target, fee_gwei);
1589
1590        // amount_native in the same unit as the transfer
1591        let amount_native = if transfer_target.token_contract.is_some() {
1592            amount_wei_u64
1593        } else {
1594            let gwei = transfer_target.amount_wei / U256::from(1_000_000_000u64);
1595            gwei.try_into().unwrap_or(u64::MAX)
1596        };
1597
1598        Ok(SendQuoteInfo {
1599            wallet: resolved,
1600            amount_native,
1601            fee_estimate_native: fee_gwei,
1602            fee_unit: "gwei".to_string(),
1603            spend_debits,
1604        })
1605    }
1606
1607    async fn history_list(
1608        &self,
1609        wallet: &str,
1610        limit: usize,
1611        offset: usize,
1612    ) -> Result<Vec<HistoryRecord>, PayError> {
1613        let resolved = self.resolve_wallet_id(wallet)?;
1614        let _ = self.load_evm_wallet(&resolved)?;
1615        let all = self.store.load_wallet_transaction_records(&resolved)?;
1616        let total = all.len();
1617        let start = offset.min(total);
1618        let end = (start + limit).min(total);
1619        // Return newest first
1620        let mut slice = all[start..end].to_vec();
1621        slice.reverse();
1622        Ok(slice)
1623    }
1624
1625    async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
1626        let mut record = self.store.find_transaction_record_by_id(transaction_id)?;
1627        let Some(existing) = record.as_ref() else {
1628            return Err(PayError::WalletNotFound(format!(
1629                "transaction {transaction_id} not found"
1630            )));
1631        };
1632        if existing.network != Network::Evm {
1633            return Err(PayError::WalletNotFound(format!(
1634                "transaction {transaction_id} not found"
1635            )));
1636        }
1637
1638        let mut confirmations: Option<u32> = None;
1639        if let Ok(meta) = self.load_evm_wallet(&existing.wallet) {
1640            if let Ok(endpoints) = Self::rpc_endpoints_for_wallet(&meta) {
1641                if let Ok(Some(receipt)) = self
1642                    .get_transaction_receipt_raw(&endpoints, transaction_id)
1643                    .await
1644                {
1645                    let status = receipt_status(&receipt);
1646                    let current_block = if receipt.block_number.is_some() {
1647                        self.get_block_number_raw(&endpoints).await.unwrap_or(0)
1648                    } else {
1649                        0
1650                    };
1651                    confirmations = receipt_confirmations(&receipt, current_block);
1652
1653                    if let Some(rec) = record.as_mut() {
1654                        let confirmed_at_epoch_s = if status == TxStatus::Confirmed {
1655                            Some(
1656                                rec.confirmed_at_epoch_s
1657                                    .unwrap_or_else(wallet::now_epoch_seconds),
1658                            )
1659                        } else {
1660                            None
1661                        };
1662                        if rec.status != status || rec.confirmed_at_epoch_s != confirmed_at_epoch_s
1663                        {
1664                            let _ = self.store.update_transaction_record_status(
1665                                transaction_id,
1666                                status,
1667                                confirmed_at_epoch_s,
1668                            );
1669                            rec.status = status;
1670                            rec.confirmed_at_epoch_s = confirmed_at_epoch_s;
1671                        }
1672
1673                        if let Some(fee_gwei) = receipt.fee_gwei() {
1674                            let update_fee = rec
1675                                .fee
1676                                .as_ref()
1677                                .map(|f| f.token != "gwei" || f.value != fee_gwei)
1678                                .unwrap_or(true);
1679                            if update_fee {
1680                                let _ = self.store.update_transaction_record_fee(
1681                                    transaction_id,
1682                                    fee_gwei,
1683                                    "gwei",
1684                                );
1685                                rec.fee = Some(Amount {
1686                                    value: fee_gwei,
1687                                    token: "gwei".to_string(),
1688                                });
1689                            }
1690                        }
1691                    }
1692                }
1693            }
1694        }
1695
1696        let record = record.ok_or_else(|| {
1697            PayError::WalletNotFound(format!("transaction {transaction_id} not found"))
1698        })?;
1699        Ok(HistoryStatusInfo {
1700            transaction_id: transaction_id.to_string(),
1701            status: record.status,
1702            confirmations,
1703            preimage: record.preimage.clone(),
1704            item: Some(record),
1705        })
1706    }
1707
1708    async fn history_onchain_memo(
1709        &self,
1710        wallet: &str,
1711        transaction_id: &str,
1712    ) -> Result<Option<String>, PayError> {
1713        let resolved = self.resolve_wallet_id(wallet)?;
1714        let meta = self.load_evm_wallet(&resolved)?;
1715        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1716        let Some(input_data) = self
1717            .get_transaction_input_raw(&endpoints, transaction_id)
1718            .await?
1719        else {
1720            return Ok(None);
1721        };
1722        Ok(decode_onchain_memo(&input_data))
1723    }
1724
1725    async fn history_sync(&self, wallet: &str, limit: usize) -> Result<HistorySyncStats, PayError> {
1726        let resolved = self.resolve_wallet_id(wallet)?;
1727        let meta = self.load_evm_wallet(&resolved)?;
1728        let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1729        let chain_id = Self::chain_id_for_wallet(&meta);
1730        let wallet_address = Self::wallet_address(&meta)?;
1731        let local_records = self.store.load_wallet_transaction_records(&resolved)?;
1732        let mut known_txids: HashSet<String> = local_records
1733            .iter()
1734            .filter(|record| record.network == Network::Evm)
1735            .map(|record| record.transaction_id.clone())
1736            .collect();
1737        let pending_ids: Vec<String> = local_records
1738            .iter()
1739            .filter(|record| record.network == Network::Evm && record.status == TxStatus::Pending)
1740            .map(|record| record.transaction_id.clone())
1741            .take(limit)
1742            .collect();
1743
1744        let mut stats = HistorySyncStats {
1745            records_scanned: pending_ids.len(),
1746            records_added: 0,
1747            records_updated: 0,
1748        };
1749
1750        for txid in pending_ids {
1751            let before = self.store.find_transaction_record_by_id(&txid)?;
1752            let status_info = self.history_status(&txid).await?;
1753            let after = status_info.item;
1754            if let (Some(before), Some(after)) = (before, after) {
1755                let fee_changed = match (before.fee.as_ref(), after.fee.as_ref()) {
1756                    (Some(lhs), Some(rhs)) => lhs.value != rhs.value || lhs.token != rhs.token,
1757                    (None, None) => false,
1758                    _ => true,
1759                };
1760                if before.status != after.status
1761                    || before.confirmed_at_epoch_s != after.confirmed_at_epoch_s
1762                    || fee_changed
1763                {
1764                    stats.records_updated = stats.records_updated.saturating_add(1);
1765                }
1766            }
1767        }
1768
1769        let incoming = self
1770            .sync_receive_records_from_chain(
1771                ReceiveSyncContext {
1772                    wallet_id: &resolved,
1773                    endpoints: &endpoints,
1774                    chain_id,
1775                    wallet_address: &wallet_address,
1776                    custom_tokens: meta.custom_tokens.as_deref().unwrap_or_default(),
1777                    limit,
1778                },
1779                &mut known_txids,
1780            )
1781            .await?;
1782        stats.records_scanned = stats
1783            .records_scanned
1784            .saturating_add(incoming.records_scanned);
1785        stats.records_added = stats.records_added.saturating_add(incoming.records_added);
1786        stats.records_updated = stats
1787            .records_updated
1788            .saturating_add(incoming.records_updated);
1789
1790        Ok(stats)
1791    }
1792}
1793
1794#[cfg(test)]
1795#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1796mod tests {
1797    use super::*;
1798
1799    #[test]
1800    fn parse_native_eth_transfer() {
1801        let target = EvmProvider::parse_transfer_target(
1802            "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=1000000000000000",
1803            CHAIN_ID_BASE,
1804        )
1805        .expect("parse native eth transfer");
1806        assert_eq!(
1807            target.recipient_address,
1808            "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1809                .parse::<Address>()
1810                .expect("parse address")
1811        );
1812        assert_eq!(target.amount_wei, U256::from(1_000_000_000_000_000u64));
1813        assert!(target.token_contract.is_none());
1814    }
1815
1816    #[test]
1817    fn parse_gwei_amount() {
1818        let target = EvmProvider::parse_transfer_target(
1819            "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-gwei=100000",
1820            CHAIN_ID_BASE,
1821        )
1822        .expect("parse gwei");
1823        assert_eq!(
1824            target.amount_wei,
1825            U256::from(100_000u64) * U256::from(1_000_000_000u64)
1826        );
1827    }
1828
1829    #[test]
1830    fn parse_usdc_transfer() {
1831        let target = EvmProvider::parse_transfer_target(
1832            "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=1000000&token=usdc",
1833            CHAIN_ID_BASE,
1834        )
1835        .expect("parse usdc transfer");
1836        assert!(target.token_contract.is_some());
1837        assert_eq!(target.amount_wei, U256::from(1_000_000u64));
1838    }
1839
1840    #[test]
1841    fn spend_debits_keep_erc20_amount_and_native_gas_separate() {
1842        let target = EvmProvider::parse_transfer_target(
1843            "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=1000000&token=usdc",
1844            CHAIN_ID_BASE,
1845        )
1846        .expect("parse usdc transfer");
1847        let debits = EvmProvider::spend_debits_for_target(&target, 21);
1848        assert_eq!(debits.len(), 2);
1849        assert_eq!(debits[0].amount_native, 1_000_000);
1850        assert_eq!(debits[0].token.as_deref(), Some("usdc"));
1851        assert_eq!(debits[1].amount_native, 21_000_000_000);
1852        assert_eq!(debits[1].token.as_deref(), Some("native"));
1853    }
1854
1855    #[test]
1856    fn spend_debits_include_native_eth_in_one_wei_debit() {
1857        let target = EvmProvider::parse_transfer_target(
1858            "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=1000000000000000",
1859            CHAIN_ID_BASE,
1860        )
1861        .expect("parse native eth transfer");
1862        let debits = EvmProvider::spend_debits_for_target(&target, 21);
1863        assert_eq!(debits.len(), 1);
1864        assert_eq!(debits[0].amount_native, 1_000_021_000_000_000);
1865        assert_eq!(debits[0].token.as_deref(), Some("native"));
1866    }
1867
1868    #[test]
1869    fn parse_empty_target_fails() {
1870        assert!(EvmProvider::parse_transfer_target("", CHAIN_ID_BASE).is_err());
1871    }
1872
1873    #[test]
1874    fn parse_missing_amount_fails() {
1875        assert!(EvmProvider::parse_transfer_target(
1876            "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
1877            CHAIN_ID_BASE,
1878        )
1879        .is_err());
1880    }
1881
1882    #[test]
1883    fn erc20_transfer_encoding_length() {
1884        let to: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1885            .parse()
1886            .expect("parse addr");
1887        let data = encode_erc20_transfer(to, U256::from(1_000_000u64));
1888        assert_eq!(data.len(), 68); // 4 selector + 32 address + 32 amount
1889        assert_eq!(&data[..4], &ERC20_TRANSFER_SELECTOR);
1890    }
1891
1892    #[test]
1893    fn normalize_onchain_memo_trims_and_enforces_limit() {
1894        let memo = normalize_onchain_memo(Some("  hello  ")).expect("memo should normalize");
1895        assert_eq!(memo, Some(b"hello".to_vec()));
1896
1897        let long_memo = "x".repeat(257);
1898        assert!(normalize_onchain_memo(Some(&long_memo)).is_err());
1899    }
1900
1901    #[test]
1902    fn append_memo_payload_appends_bytes() {
1903        let encoded = encode_erc20_transfer(
1904            "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1905                .parse()
1906                .expect("address"),
1907            U256::from(42u64),
1908        );
1909        let with_memo = append_memo_payload(encoded.clone(), Some(b"memo"));
1910        assert_eq!(with_memo.len(), encoded.len() + 4);
1911        assert!(with_memo.ends_with(b"memo"));
1912    }
1913
1914    #[test]
1915    fn decode_onchain_memo_supports_native_and_erc20_inputs() {
1916        let native = b"order:abc";
1917        assert_eq!(decode_onchain_memo(native), Some("order:abc".to_string()));
1918
1919        let erc20 = append_memo_payload(
1920            encode_erc20_transfer(
1921                "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1922                    .parse()
1923                    .expect("address"),
1924                U256::from(42u64),
1925            ),
1926            Some(b"order:def"),
1927        );
1928        assert_eq!(decode_onchain_memo(&erc20), Some("order:def".to_string()));
1929
1930        let legacy = append_memo_payload(
1931            encode_erc20_transfer(
1932                "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1933                    .parse()
1934                    .expect("address"),
1935                U256::from(42u64),
1936            ),
1937            None,
1938        );
1939        assert_eq!(decode_onchain_memo(&legacy), None);
1940    }
1941
1942    #[test]
1943    fn receipt_confirmations_includes_inclusion_block() {
1944        let receipt = EvmTxReceipt {
1945            block_number: Some("0x10".to_string()),
1946            status: Some("0x1".to_string()),
1947            gas_used: None,
1948            effective_gas_price: None,
1949        };
1950        assert_eq!(receipt_confirmations(&receipt, 0x10), Some(1));
1951        assert_eq!(receipt_confirmations(&receipt, 0x12), Some(3));
1952    }
1953
1954    #[test]
1955    fn usdc_address_base() {
1956        let addr = usdc_contract_address(CHAIN_ID_BASE);
1957        assert!(addr.is_some());
1958    }
1959
1960    #[test]
1961    fn usdc_address_unknown_chain() {
1962        let addr = usdc_contract_address(999999);
1963        assert!(addr.is_none());
1964    }
1965
1966    #[test]
1967    fn erc20_balance_of_calldata_encoding() {
1968        // balanceOf(address) selector: 0x70a08231
1969        let addr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
1970        let addr_no_prefix = addr.strip_prefix("0x").unwrap();
1971        let calldata = format!("0x70a08231000000000000000000000000{addr_no_prefix}");
1972        // Selector (10 chars) + 64 hex chars for padded address = 74 chars + 0x prefix
1973        assert_eq!(calldata.len(), 2 + 8 + 64);
1974        assert!(calldata.starts_with("0x70a08231"));
1975    }
1976
1977    #[test]
1978    fn parse_usdt_transfer_via_registry() {
1979        let target = EvmProvider::parse_transfer_target(
1980            "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=500000&token=usdt",
1981            CHAIN_ID_BASE,
1982        )
1983        .expect("parse usdt transfer");
1984        assert!(target.token_contract.is_some());
1985        assert_eq!(target.amount_wei, U256::from(500_000u64));
1986    }
1987
1988    #[test]
1989    fn parse_unknown_token_symbol_fails() {
1990        let err = EvmProvider::parse_transfer_target(
1991            "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=100&token=doge",
1992            CHAIN_ID_BASE,
1993        );
1994        assert!(err.is_err());
1995        assert!(err.unwrap_err().to_string().contains("unknown token"));
1996    }
1997
1998    #[test]
1999    fn parse_custom_contract_address_token() {
2000        let target = EvmProvider::parse_transfer_target(
2001            "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=100&token=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
2002            CHAIN_ID_BASE,
2003        )
2004        .expect("parse custom token");
2005        assert!(target.token_contract.is_some());
2006    }
2007
2008    #[test]
2009    fn normalize_rpc_endpoints_adds_https() {
2010        let result = EvmProvider::normalize_rpc_endpoint("base-mainnet.g.alchemy.com/v2/key");
2011        assert!(result.is_ok());
2012        assert!(result.as_ref().is_ok_and(|s| s.starts_with("https://")));
2013    }
2014
2015    #[test]
2016    fn normalize_rpc_endpoints_empty_fails() {
2017        assert!(EvmProvider::normalize_rpc_endpoint("").is_err());
2018    }
2019
2020    #[test]
2021    fn chain_id_defaults_to_base() {
2022        let meta = WalletMetadata {
2023            id: "w_test".to_string(),
2024            network: Network::Evm,
2025            label: None,
2026            mint_url: None,
2027            sol_rpc_endpoints: None,
2028            evm_rpc_endpoints: Some(vec!["https://rpc.example".to_string()]),
2029            evm_chain_id: None,
2030            seed_secret: None,
2031            backend: None,
2032            btc_esplora_url: None,
2033            btc_network: None,
2034            btc_address_type: None,
2035            btc_core_url: None,
2036            btc_core_auth_secret: None,
2037            btc_electrum_url: None,
2038            custom_tokens: None,
2039            created_at_epoch_s: 0,
2040            error: None,
2041        };
2042        assert_eq!(EvmProvider::chain_id_for_wallet(&meta), CHAIN_ID_BASE);
2043    }
2044}