Skip to main content

agent_first_pay/provider/
evm.rs

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