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