Skip to main content

agent_first_pay/provider/
evm.rs

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