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