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_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 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 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 } 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 }
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 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 async fn get_erc20_balance_raw(
286 &self,
287 endpoints: &[String],
288 token_contract: &str,
289 address: &str,
290 ) -> Result<U256, PayError> {
291 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 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 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 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 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 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); }
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_onchain_memo(&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 reference_keys: None,
827 };
828 let _ = self.store.append_transaction_record(&record);
829 known_txids.insert(tx_hash);
830 stats.records_added = stats.records_added.saturating_add(1);
831 }
832 }
833
834 let mut tracked_tokens: Vec<(String, String)> = tokens::evm_known_tokens(ctx.chain_id)
835 .iter()
836 .map(|token| (token.symbol.to_string(), token.address.to_ascii_lowercase()))
837 .collect();
838 for ct in ctx.custom_tokens {
839 tracked_tokens.push((
840 ct.symbol.to_ascii_lowercase(),
841 ct.address.to_ascii_lowercase(),
842 ));
843 }
844 let mut seen_contracts = HashSet::new();
845 tracked_tokens.retain(|(_, contract)| seen_contracts.insert(contract.clone()));
846
847 for (symbol, contract) in tracked_tokens {
848 if stats.records_added >= scan_limit {
849 break;
850 }
851 let logs = self
852 .get_erc20_transfer_logs_to_address(
853 ctx.endpoints,
854 &contract,
855 start_block,
856 latest_block,
857 &normalized_wallet,
858 )
859 .await?;
860 stats.records_scanned = stats.records_scanned.saturating_add(logs.len());
861 for log in logs {
862 if stats.records_added >= scan_limit {
863 break;
864 }
865 let Some(tx_hash) = log.transaction_hash else {
866 continue;
867 };
868 if known_txids.contains(&tx_hash) {
869 continue;
870 }
871 let Some(data_hex) = log.data.as_deref() else {
872 continue;
873 };
874 let Some(amount_raw) = parse_hex_u256(data_hex) else {
875 continue;
876 };
877 if amount_raw.is_zero() {
878 continue;
879 }
880 let amount_value: u64 = amount_raw.try_into().unwrap_or(u64::MAX);
881 let block_number = log
882 .block_number
883 .as_deref()
884 .and_then(parse_hex_u64)
885 .unwrap_or(latest_block);
886 let block_timestamp = if let Some(ts) = block_ts_cache.get(&block_number) {
887 *ts
888 } else {
889 let ts = self
890 .get_block_timestamp_raw(ctx.endpoints, block_number)
891 .await?
892 .unwrap_or(now);
893 block_ts_cache.insert(block_number, ts);
894 ts
895 };
896 let memo = if let Some(cached) = memo_cache.get(&tx_hash) {
897 cached.clone()
898 } else {
899 let decoded = match self
900 .get_transaction_input_raw(ctx.endpoints, &tx_hash)
901 .await?
902 {
903 Some(input) => decode_onchain_memo(&input),
904 None => None,
905 };
906 memo_cache.insert(tx_hash.clone(), decoded.clone());
907 decoded
908 };
909 let remote_addr = log.topics.get(1).and_then(|t| topic_to_address(t));
910 let record = HistoryRecord {
911 transaction_id: tx_hash.clone(),
912 wallet: ctx.wallet_id.to_string(),
913 network: Network::Evm,
914 direction: Direction::Receive,
915 amount: Amount {
916 value: amount_value,
917 token: symbol.clone(),
918 },
919 status: TxStatus::Confirmed,
920 onchain_memo: memo,
921 local_memo: None,
922 remote_addr,
923 preimage: None,
924 created_at_epoch_s: block_timestamp,
925 confirmed_at_epoch_s: Some(block_timestamp),
926 fee: None,
927 reference_keys: None,
928 };
929 let _ = self.store.append_transaction_record(&record);
930 known_txids.insert(tx_hash);
931 stats.records_added = stats.records_added.saturating_add(1);
932 }
933 }
934
935 Ok(stats)
936 }
937}
938
939#[derive(Debug, serde::Deserialize)]
940struct EvmTxReceipt {
941 #[serde(default, rename = "blockNumber")]
942 block_number: Option<String>,
943 #[serde(default)]
944 status: Option<String>,
945 #[serde(default, rename = "gasUsed")]
946 gas_used: Option<String>,
947 #[serde(default, rename = "effectiveGasPrice")]
948 effective_gas_price: Option<String>,
949}
950
951#[derive(Debug, serde::Deserialize)]
952struct EvmTxByHash {
953 #[serde(default)]
954 input: Option<String>,
955}
956
957#[derive(Debug, serde::Deserialize)]
958struct EvmBlockByNumber {
959 #[serde(default)]
960 timestamp: Option<String>,
961 #[serde(default)]
962 transactions: Vec<EvmBlockTransaction>,
963}
964
965#[derive(Debug, serde::Deserialize)]
966struct EvmBlockHeader {
967 #[serde(default)]
968 timestamp: Option<String>,
969}
970
971#[derive(Debug, serde::Deserialize)]
972struct EvmBlockTransaction {
973 #[serde(default)]
974 hash: Option<String>,
975 #[serde(default)]
976 from: Option<String>,
977 #[serde(default)]
978 to: Option<String>,
979 #[serde(default)]
980 value: Option<String>,
981 #[serde(default)]
982 input: Option<String>,
983}
984
985#[derive(Debug, serde::Deserialize)]
986struct EvmLogEntry {
987 #[serde(default, rename = "transactionHash")]
988 transaction_hash: Option<String>,
989 #[serde(default, rename = "blockNumber")]
990 block_number: Option<String>,
991 #[serde(default)]
992 data: Option<String>,
993 #[serde(default)]
994 topics: Vec<String>,
995}
996
997struct ReceiveSyncContext<'a> {
998 wallet_id: &'a str,
999 endpoints: &'a [String],
1000 chain_id: u64,
1001 wallet_address: &'a str,
1002 custom_tokens: &'a [wallet::CustomToken],
1003 limit: usize,
1004}
1005
1006impl EvmTxReceipt {
1007 fn fee_gwei(&self) -> Option<u64> {
1009 let gas_used_hex = self.gas_used.as_deref()?;
1010 let gas_price_hex = self.effective_gas_price.as_deref()?;
1011 let gas_used =
1012 u128::from_str_radix(gas_used_hex.strip_prefix("0x").unwrap_or(gas_used_hex), 16)
1013 .ok()?;
1014 let gas_price = u128::from_str_radix(
1015 gas_price_hex.strip_prefix("0x").unwrap_or(gas_price_hex),
1016 16,
1017 )
1018 .ok()?;
1019 let fee_wei = gas_used.checked_mul(gas_price)?;
1021 Some((fee_wei / 1_000_000_000) as u64)
1022 }
1023}
1024
1025const ERC20_TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb];
1027const ERC20_TRANSFER_EVENT_TOPIC: &str =
1028 "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
1029
1030fn encode_erc20_transfer(to: Address, amount: U256) -> Vec<u8> {
1031 let mut data = Vec::with_capacity(68);
1032 data.extend_from_slice(&ERC20_TRANSFER_SELECTOR);
1033 data.extend_from_slice(&[0u8; 12]);
1035 data.extend_from_slice(to.as_slice());
1036 data.extend_from_slice(&amount.to_be_bytes::<32>());
1038 data
1039}
1040
1041fn normalize_onchain_memo(onchain_memo: Option<&str>) -> Result<Option<Vec<u8>>, PayError> {
1042 let Some(memo) = onchain_memo.map(str::trim).filter(|memo| !memo.is_empty()) else {
1043 return Ok(None);
1044 };
1045 let memo_bytes = memo.as_bytes();
1046 if memo_bytes.len() > 256 {
1047 return Err(PayError::InvalidAmount(
1048 "evm onchain-memo must be <= 256 bytes".to_string(),
1049 ));
1050 }
1051 Ok(Some(memo_bytes.to_vec()))
1052}
1053
1054fn append_memo_payload(mut data: Vec<u8>, memo_bytes: Option<&[u8]>) -> Vec<u8> {
1055 if let Some(memo) = memo_bytes {
1056 data.extend_from_slice(memo);
1057 }
1058 data
1059}
1060
1061fn decode_onchain_memo(input_data: &[u8]) -> Option<String> {
1062 let memo_slice = if input_data.starts_with(&ERC20_TRANSFER_SELECTOR) {
1063 if input_data.len() <= 68 {
1064 return None;
1065 }
1066 &input_data[68..]
1067 } else {
1068 input_data
1069 };
1070 if memo_slice.is_empty() {
1071 return None;
1072 }
1073 String::from_utf8(memo_slice.to_vec()).ok()
1074}
1075
1076fn decode_hex_data_bytes(raw: &str) -> Result<Vec<u8>, PayError> {
1077 let trimmed = raw.trim();
1078 let hex_data = trimmed.strip_prefix("0x").unwrap_or(trimmed);
1079 if hex_data.is_empty() {
1080 return Ok(Vec::new());
1081 }
1082 if !hex_data.len().is_multiple_of(2) {
1083 return Err(PayError::NetworkError(
1084 "invalid tx input hex length".to_string(),
1085 ));
1086 }
1087 hex::decode(hex_data).map_err(|e| PayError::NetworkError(format!("invalid tx input hex: {e}")))
1088}
1089
1090fn parse_hex_u64(raw: &str) -> Option<u64> {
1091 let hex = raw.strip_prefix("0x").unwrap_or(raw);
1092 u64::from_str_radix(hex, 16).ok()
1093}
1094
1095fn parse_hex_u256(raw: &str) -> Option<U256> {
1096 let hex = raw.strip_prefix("0x").unwrap_or(raw);
1097 U256::from_str_radix(hex, 16).ok()
1098}
1099
1100fn normalize_address(raw: &str) -> Option<String> {
1101 let trimmed = raw.trim();
1102 let body = trimmed
1103 .strip_prefix("0x")
1104 .or_else(|| trimmed.strip_prefix("0X"))?;
1105 if body.len() != 40 || !body.chars().all(|c| c.is_ascii_hexdigit()) {
1106 return None;
1107 }
1108 Some(format!("0x{}", body.to_ascii_lowercase()))
1109}
1110
1111fn address_topic(address: &str) -> Option<String> {
1112 let normalized = normalize_address(address)?;
1113 let body = normalized.strip_prefix("0x")?;
1114 Some(format!("0x{:0>64}", body))
1115}
1116
1117fn topic_to_address(topic: &str) -> Option<String> {
1118 let body = topic
1119 .strip_prefix("0x")
1120 .or_else(|| topic.strip_prefix("0X"))?;
1121 if body.len() != 64 || !body.chars().all(|c| c.is_ascii_hexdigit()) {
1122 return None;
1123 }
1124 normalize_address(&format!("0x{}", &body[24..]))
1125}
1126
1127fn receipt_status(receipt: &EvmTxReceipt) -> TxStatus {
1128 match receipt.status.as_deref() {
1129 Some("0x1") => TxStatus::Confirmed,
1130 Some("0x0") => TxStatus::Failed,
1131 _ => TxStatus::Pending,
1132 }
1133}
1134
1135fn receipt_confirmations(receipt: &EvmTxReceipt, current_block: u64) -> Option<u32> {
1136 let block_hex = receipt.block_number.as_deref()?;
1137 let block_num =
1138 u64::from_str_radix(block_hex.strip_prefix("0x").unwrap_or(block_hex), 16).ok()?;
1139 if current_block < block_num {
1140 return Some(0);
1141 }
1142 let depth = current_block.saturating_sub(block_num).saturating_add(1);
1143 Some(depth.min(u32::MAX as u64) as u32)
1144}
1145
1146#[async_trait]
1147impl PayProvider for EvmProvider {
1148 fn network(&self) -> Network {
1149 Network::Evm
1150 }
1151
1152 fn writes_locally(&self) -> bool {
1153 true
1154 }
1155
1156 async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
1157 if request.rpc_endpoints.is_empty() {
1158 return Err(PayError::InvalidAmount(
1159 "evm wallet requires --evm-rpc-endpoint (or rpc_endpoints in JSON)".to_string(),
1160 ));
1161 }
1162 let mut endpoints = Vec::new();
1163 for ep in &request.rpc_endpoints {
1164 let n = Self::normalize_rpc_endpoint(ep)?;
1165 if !endpoints.contains(&n) {
1166 endpoints.push(n);
1167 }
1168 }
1169 let chain_id = request.chain_id.unwrap_or(CHAIN_ID_BASE);
1170
1171 let mnemonic_str = if let Some(raw) = request.mnemonic_secret.as_deref() {
1172 let mnemonic: Mnemonic = raw.parse().map_err(|_| {
1173 PayError::InvalidAmount(
1174 "invalid mnemonic-secret for evm wallet: expected BIP39 words".to_string(),
1175 )
1176 })?;
1177 mnemonic.words().collect::<Vec<_>>().join(" ")
1178 } else {
1179 let mut entropy = [0u8; 16];
1180 getrandom::fill(&mut entropy)
1181 .map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
1182 let mnemonic = Mnemonic::from_entropy(&entropy)
1183 .map_err(|e| PayError::InternalError(format!("mnemonic gen: {e}")))?;
1184 mnemonic.words().collect::<Vec<_>>().join(" ")
1185 };
1186
1187 let signer = Self::signer_from_mnemonic(&mnemonic_str)?;
1188 let address = format!("{:?}", signer.address());
1189
1190 let wallet_id = wallet::generate_wallet_identifier()?;
1191 let normalized_label = {
1192 let trimmed = request.label.trim();
1193 if trimmed.is_empty() || trimmed == "default" {
1194 None
1195 } else {
1196 Some(trimmed.to_string())
1197 }
1198 };
1199
1200 let meta = WalletMetadata {
1201 id: wallet_id.clone(),
1202 network: Network::Evm,
1203 label: normalized_label.clone(),
1204 mint_url: None,
1205 sol_rpc_endpoints: None,
1206 evm_rpc_endpoints: Some(endpoints),
1207 evm_chain_id: Some(chain_id),
1208 seed_secret: Some(mnemonic_str.clone()),
1209 backend: None,
1210 btc_esplora_url: None,
1211 btc_network: None,
1212 btc_address_type: None,
1213 btc_core_url: None,
1214 btc_core_auth_secret: None,
1215 btc_electrum_url: None,
1216 custom_tokens: None,
1217 created_at_epoch_s: wallet::now_epoch_seconds(),
1218 error: None,
1219 };
1220 self.store.save_wallet_metadata(&meta)?;
1221
1222 Ok(WalletInfo {
1223 id: wallet_id,
1224 network: Network::Evm,
1225 address,
1226 label: normalized_label,
1227 mnemonic: None,
1228 })
1229 }
1230
1231 async fn close_wallet(&self, wallet_id: &str) -> Result<(), PayError> {
1232 let balance = self.balance(wallet_id).await?;
1233 let non_zero_components = balance.non_zero_components();
1234 if !non_zero_components.is_empty() {
1235 let component_list = non_zero_components
1236 .iter()
1237 .map(|(name, value)| format!("{name}={value}"))
1238 .collect::<Vec<_>>()
1239 .join(", ");
1240 return Err(PayError::InvalidAmount(format!(
1241 "wallet {wallet_id} has non-zero balance components ({component_list}); transfer funds first"
1242 )));
1243 }
1244 self.store.delete_wallet_metadata(wallet_id)
1245 }
1246
1247 async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
1248 let wallets = self.store.list_wallet_metadata(Some(Network::Evm))?;
1249 Ok(wallets
1250 .into_iter()
1251 .map(|meta| {
1252 let address = Self::wallet_address(&meta)
1253 .unwrap_or_else(|_| INVALID_EVM_WALLET_ADDRESS.to_string());
1254 evm_wallet_summary(meta, address)
1255 })
1256 .collect())
1257 }
1258
1259 async fn balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
1260 let resolved = self.resolve_wallet_id(wallet_id)?;
1261 let meta = self.load_evm_wallet(&resolved)?;
1262 let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1263 let address = Self::wallet_address(&meta)?;
1264 let chain_id = Self::chain_id_for_wallet(&meta);
1265 let custom_tokens = meta.custom_tokens.as_deref().unwrap_or_default();
1266 let balance_wei = self.get_balance_raw(&endpoints, &address).await?;
1267 let balance_gwei = balance_wei / U256::from(1_000_000_000u64);
1269 let gwei_u64: u64 = balance_gwei.try_into().unwrap_or(u64::MAX);
1270 let mut info = BalanceInfo::new(gwei_u64, 0, "gwei");
1271 self.enrich_with_token_balances(&endpoints, &address, chain_id, custom_tokens, &mut info)
1272 .await;
1273 Ok(info)
1274 }
1275
1276 async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
1277 let wallets = self.store.list_wallet_metadata(Some(Network::Evm))?;
1278 let mut items = Vec::with_capacity(wallets.len());
1279 for meta in wallets {
1280 let chain_id = Self::chain_id_for_wallet(&meta);
1281 let custom_tokens = meta.custom_tokens.as_deref().unwrap_or_default().to_vec();
1282 let endpoints = Self::rpc_endpoints_for_wallet(&meta);
1283 let address = Self::wallet_address(&meta);
1284 let result = match (endpoints, address) {
1285 (Ok(endpoints), Ok(address)) => {
1286 match self.get_balance_raw(&endpoints, &address).await {
1287 Ok(wei) => {
1288 let gwei = wei / U256::from(1_000_000_000u64);
1289 let gwei_u64: u64 = gwei.try_into().unwrap_or(u64::MAX);
1290 let mut info = BalanceInfo::new(gwei_u64, 0, "gwei");
1291 self.enrich_with_token_balances(
1292 &endpoints,
1293 &address,
1294 chain_id,
1295 &custom_tokens,
1296 &mut info,
1297 )
1298 .await;
1299 Ok(info)
1300 }
1301 Err(e) => Err(e),
1302 }
1303 }
1304 (Err(e), _) | (_, Err(e)) => Err(e),
1305 };
1306 let summary_address = Self::wallet_address(&meta)
1307 .unwrap_or_else(|_| INVALID_EVM_WALLET_ADDRESS.to_string());
1308 let summary = evm_wallet_summary(meta, summary_address);
1309 match result {
1310 Ok(info) => {
1311 items.push(WalletBalanceItem {
1312 wallet: summary,
1313 balance: Some(info),
1314 error: None,
1315 });
1316 }
1317 Err(error) => items.push(WalletBalanceItem {
1318 wallet: summary,
1319 balance: None,
1320 error: Some(error.to_string()),
1321 }),
1322 }
1323 }
1324 Ok(items)
1325 }
1326
1327 async fn receive_info(
1328 &self,
1329 wallet_id: &str,
1330 _amount: Option<Amount>,
1331 ) -> Result<ReceiveInfo, PayError> {
1332 let resolved = self.resolve_wallet_id(wallet_id)?;
1333 let meta = self.load_evm_wallet(&resolved)?;
1334 let _ = Self::rpc_endpoints_for_wallet(&meta)?;
1335 Ok(ReceiveInfo {
1336 address: Some(Self::wallet_address(&meta)?),
1337 invoice: None,
1338 quote_id: None,
1339 })
1340 }
1341
1342 async fn receive_claim(&self, _wallet: &str, _quote_id: &str) -> Result<u64, PayError> {
1343 Err(PayError::NotImplemented(
1344 "evm receive has no claim step".to_string(),
1345 ))
1346 }
1347
1348 async fn cashu_send(
1349 &self,
1350 _wallet: &str,
1351 _amount: Amount,
1352 _onchain_memo: Option<&str>,
1353 _mints: Option<&[String]>,
1354 ) -> Result<CashuSendResult, PayError> {
1355 Err(PayError::NotImplemented(
1356 "evm does not use cashu send".to_string(),
1357 ))
1358 }
1359
1360 async fn cashu_receive(
1361 &self,
1362 _wallet: &str,
1363 _token: &str,
1364 ) -> Result<CashuReceiveResult, PayError> {
1365 Err(PayError::NotImplemented(
1366 "evm does not use cashu receive".to_string(),
1367 ))
1368 }
1369
1370 async fn send(
1371 &self,
1372 wallet: &str,
1373 to: &str,
1374 onchain_memo: Option<&str>,
1375 _mints: Option<&[String]>,
1376 ) -> Result<SendResult, PayError> {
1377 let resolved_wallet_id = self.resolve_wallet_id(wallet)?;
1378 let meta = self.load_evm_wallet(&resolved_wallet_id)?;
1379 let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1380 let chain_id = Self::chain_id_for_wallet(&meta);
1381 let transfer_target = Self::parse_transfer_target(to, chain_id)?;
1382 let memo_bytes = normalize_onchain_memo(onchain_memo)?;
1383 let memo_payload = memo_bytes.as_deref();
1384
1385 let signer = Self::wallet_signer(&meta)?;
1386
1387 let mut last_error: Option<String> = None;
1388 let mut transaction_id: Option<String> = None;
1389
1390 for endpoint in &endpoints {
1391 let url: reqwest::Url = match endpoint.parse() {
1392 Ok(u) => u,
1393 Err(e) => {
1394 last_error = Some(format!("endpoint={endpoint} invalid url: {e}"));
1395 continue;
1396 }
1397 };
1398 let wallet = EthereumWallet::from(signer.clone());
1399 let provider = ProviderBuilder::new().wallet(wallet).connect_http(url);
1400
1401 let tx_result = if let Some(token_contract) = transfer_target.token_contract {
1402 let call_data = append_memo_payload(
1404 encode_erc20_transfer(
1405 transfer_target.recipient_address,
1406 transfer_target.amount_wei,
1407 ),
1408 memo_bytes.as_deref(),
1409 );
1410 let tx = alloy::rpc::types::TransactionRequest::default()
1411 .to(token_contract)
1412 .input(call_data.into());
1413 provider.send_transaction(tx).await
1414 } else {
1415 let mut tx = alloy::rpc::types::TransactionRequest::default()
1417 .to(transfer_target.recipient_address)
1418 .value(transfer_target.amount_wei);
1419 if let Some(memo) = memo_payload {
1420 tx = tx.input(memo.to_vec().into());
1421 }
1422 provider.send_transaction(tx).await
1423 };
1424
1425 match tx_result {
1426 Ok(pending) => {
1427 let tx_hash = format!("{:?}", pending.tx_hash());
1428 transaction_id = Some(tx_hash);
1429 break;
1430 }
1431 Err(err) => {
1432 last_error = Some(format!("endpoint={endpoint} sendTransaction: {err}"));
1433 }
1434 }
1435 }
1436
1437 let transaction_id = transaction_id.ok_or_else(|| {
1438 PayError::NetworkError(format!(
1439 "all evm rpc endpoints failed for withdraw: {}",
1440 last_error.unwrap_or_default()
1441 ))
1442 })?;
1443
1444 let (amount_value, amount_token) = if transfer_target.token_contract.is_some() {
1446 let val: u64 = transfer_target.amount_wei.try_into().unwrap_or(u64::MAX);
1448 (val, "token-units".to_string())
1449 } else {
1450 let gwei = transfer_target.amount_wei / U256::from(1_000_000_000u64);
1451 let val: u64 = gwei.try_into().unwrap_or(u64::MAX);
1452 (val, "gwei".to_string())
1453 };
1454
1455 let fee_amount = match self
1457 .get_transaction_receipt_raw(&endpoints, &transaction_id)
1458 .await
1459 {
1460 Ok(Some(receipt)) => receipt.fee_gwei().map(|g| Amount {
1461 value: g,
1462 token: "gwei".to_string(),
1463 }),
1464 _ => {
1465 self.estimate_fee_gwei(
1467 &endpoints,
1468 &format!("{:?}", signer.address()),
1469 &format!("{:?}", transfer_target.recipient_address),
1470 None,
1471 )
1472 .await
1473 .ok()
1474 .map(|g| Amount {
1475 value: g,
1476 token: "gwei".to_string(),
1477 })
1478 }
1479 };
1480
1481 let history = HistoryRecord {
1482 transaction_id: transaction_id.clone(),
1483 wallet: resolved_wallet_id.clone(),
1484 network: Network::Evm,
1485 direction: Direction::Send,
1486 amount: Amount {
1487 value: amount_value,
1488 token: amount_token.clone(),
1489 },
1490 status: TxStatus::Pending,
1491 onchain_memo: onchain_memo.map(|s| s.to_string()),
1492 local_memo: None,
1493 remote_addr: Some(format!("{:?}", transfer_target.recipient_address)),
1494 preimage: None,
1495 created_at_epoch_s: wallet::now_epoch_seconds(),
1496 confirmed_at_epoch_s: None,
1497 fee: fee_amount.clone(),
1498 reference_keys: None,
1499 };
1500 let _ = self.store.append_transaction_record(&history);
1501
1502 Ok(SendResult {
1503 wallet: resolved_wallet_id,
1504 transaction_id,
1505 amount: Amount {
1506 value: amount_value,
1507 token: amount_token,
1508 },
1509 fee: fee_amount,
1510 preimage: None,
1511 })
1512 }
1513
1514 async fn send_quote(
1515 &self,
1516 wallet: &str,
1517 to: &str,
1518 _mints: Option<&[String]>,
1519 ) -> Result<SendQuoteInfo, PayError> {
1520 let resolved = self.resolve_wallet_id(wallet)?;
1521 let meta = self.load_evm_wallet(&resolved)?;
1522 let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1523 let chain_id = Self::chain_id_for_wallet(&meta);
1524 let transfer_target = Self::parse_transfer_target(to, chain_id)?;
1525 let signer = Self::wallet_signer(&meta)?;
1526
1527 let (to_addr, data) = if let Some(token_contract) = transfer_target.token_contract {
1528 let call_data = encode_erc20_transfer(
1529 transfer_target.recipient_address,
1530 transfer_target.amount_wei,
1531 );
1532 (
1533 format!("{:?}", token_contract),
1534 Some(format!("0x{}", hex::encode(&call_data))),
1535 )
1536 } else {
1537 (format!("{:?}", transfer_target.recipient_address), None)
1538 };
1539
1540 let fee_gwei = self
1541 .estimate_fee_gwei(
1542 &endpoints,
1543 &format!("{:?}", signer.address()),
1544 &to_addr,
1545 data.as_deref(),
1546 )
1547 .await
1548 .unwrap_or(0);
1549
1550 let amount_native = if transfer_target.token_contract.is_some() {
1552 let val: u64 = transfer_target.amount_wei.try_into().unwrap_or(u64::MAX);
1553 val
1554 } else {
1555 let gwei = transfer_target.amount_wei / U256::from(1_000_000_000u64);
1556 gwei.try_into().unwrap_or(u64::MAX)
1557 };
1558
1559 Ok(SendQuoteInfo {
1560 wallet: resolved,
1561 amount_native,
1562 fee_estimate_native: fee_gwei,
1563 fee_unit: "gwei".to_string(),
1564 })
1565 }
1566
1567 async fn history_list(
1568 &self,
1569 wallet: &str,
1570 limit: usize,
1571 offset: usize,
1572 ) -> Result<Vec<HistoryRecord>, PayError> {
1573 let resolved = self.resolve_wallet_id(wallet)?;
1574 let _ = self.load_evm_wallet(&resolved)?;
1575 let all = self.store.load_wallet_transaction_records(&resolved)?;
1576 let total = all.len();
1577 let start = offset.min(total);
1578 let end = (start + limit).min(total);
1579 let mut slice = all[start..end].to_vec();
1581 slice.reverse();
1582 Ok(slice)
1583 }
1584
1585 async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
1586 let mut record = self.store.find_transaction_record_by_id(transaction_id)?;
1587 let Some(existing) = record.as_ref() else {
1588 return Err(PayError::WalletNotFound(format!(
1589 "transaction {transaction_id} not found"
1590 )));
1591 };
1592 if existing.network != Network::Evm {
1593 return Err(PayError::WalletNotFound(format!(
1594 "transaction {transaction_id} not found"
1595 )));
1596 }
1597
1598 let mut confirmations: Option<u32> = None;
1599 if let Ok(meta) = self.load_evm_wallet(&existing.wallet) {
1600 if let Ok(endpoints) = Self::rpc_endpoints_for_wallet(&meta) {
1601 if let Ok(Some(receipt)) = self
1602 .get_transaction_receipt_raw(&endpoints, transaction_id)
1603 .await
1604 {
1605 let status = receipt_status(&receipt);
1606 let current_block = if receipt.block_number.is_some() {
1607 self.get_block_number_raw(&endpoints).await.unwrap_or(0)
1608 } else {
1609 0
1610 };
1611 confirmations = receipt_confirmations(&receipt, current_block);
1612
1613 if let Some(rec) = record.as_mut() {
1614 let confirmed_at_epoch_s = if status == TxStatus::Confirmed {
1615 Some(
1616 rec.confirmed_at_epoch_s
1617 .unwrap_or_else(wallet::now_epoch_seconds),
1618 )
1619 } else {
1620 None
1621 };
1622 if rec.status != status || rec.confirmed_at_epoch_s != confirmed_at_epoch_s
1623 {
1624 let _ = self.store.update_transaction_record_status(
1625 transaction_id,
1626 status,
1627 confirmed_at_epoch_s,
1628 );
1629 rec.status = status;
1630 rec.confirmed_at_epoch_s = confirmed_at_epoch_s;
1631 }
1632
1633 if let Some(fee_gwei) = receipt.fee_gwei() {
1634 let update_fee = rec
1635 .fee
1636 .as_ref()
1637 .map(|f| f.token != "gwei" || f.value != fee_gwei)
1638 .unwrap_or(true);
1639 if update_fee {
1640 let _ = self.store.update_transaction_record_fee(
1641 transaction_id,
1642 fee_gwei,
1643 "gwei",
1644 );
1645 rec.fee = Some(Amount {
1646 value: fee_gwei,
1647 token: "gwei".to_string(),
1648 });
1649 }
1650 }
1651 }
1652 }
1653 }
1654 }
1655
1656 let record = record.ok_or_else(|| {
1657 PayError::WalletNotFound(format!("transaction {transaction_id} not found"))
1658 })?;
1659 Ok(HistoryStatusInfo {
1660 transaction_id: transaction_id.to_string(),
1661 status: record.status,
1662 confirmations,
1663 preimage: record.preimage.clone(),
1664 item: Some(record),
1665 })
1666 }
1667
1668 async fn history_onchain_memo(
1669 &self,
1670 wallet: &str,
1671 transaction_id: &str,
1672 ) -> Result<Option<String>, PayError> {
1673 let resolved = self.resolve_wallet_id(wallet)?;
1674 let meta = self.load_evm_wallet(&resolved)?;
1675 let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1676 let Some(input_data) = self
1677 .get_transaction_input_raw(&endpoints, transaction_id)
1678 .await?
1679 else {
1680 return Ok(None);
1681 };
1682 Ok(decode_onchain_memo(&input_data))
1683 }
1684
1685 async fn history_sync(&self, wallet: &str, limit: usize) -> Result<HistorySyncStats, PayError> {
1686 let resolved = self.resolve_wallet_id(wallet)?;
1687 let meta = self.load_evm_wallet(&resolved)?;
1688 let endpoints = Self::rpc_endpoints_for_wallet(&meta)?;
1689 let chain_id = Self::chain_id_for_wallet(&meta);
1690 let wallet_address = Self::wallet_address(&meta)?;
1691 let local_records = self.store.load_wallet_transaction_records(&resolved)?;
1692 let mut known_txids: HashSet<String> = local_records
1693 .iter()
1694 .filter(|record| record.network == Network::Evm)
1695 .map(|record| record.transaction_id.clone())
1696 .collect();
1697 let pending_ids: Vec<String> = local_records
1698 .iter()
1699 .filter(|record| record.network == Network::Evm && record.status == TxStatus::Pending)
1700 .map(|record| record.transaction_id.clone())
1701 .take(limit)
1702 .collect();
1703
1704 let mut stats = HistorySyncStats {
1705 records_scanned: pending_ids.len(),
1706 records_added: 0,
1707 records_updated: 0,
1708 };
1709
1710 for txid in pending_ids {
1711 let before = self.store.find_transaction_record_by_id(&txid)?;
1712 let status_info = self.history_status(&txid).await?;
1713 let after = status_info.item;
1714 if let (Some(before), Some(after)) = (before, after) {
1715 let fee_changed = match (before.fee.as_ref(), after.fee.as_ref()) {
1716 (Some(lhs), Some(rhs)) => lhs.value != rhs.value || lhs.token != rhs.token,
1717 (None, None) => false,
1718 _ => true,
1719 };
1720 if before.status != after.status
1721 || before.confirmed_at_epoch_s != after.confirmed_at_epoch_s
1722 || fee_changed
1723 {
1724 stats.records_updated = stats.records_updated.saturating_add(1);
1725 }
1726 }
1727 }
1728
1729 let incoming = self
1730 .sync_receive_records_from_chain(
1731 ReceiveSyncContext {
1732 wallet_id: &resolved,
1733 endpoints: &endpoints,
1734 chain_id,
1735 wallet_address: &wallet_address,
1736 custom_tokens: meta.custom_tokens.as_deref().unwrap_or_default(),
1737 limit,
1738 },
1739 &mut known_txids,
1740 )
1741 .await?;
1742 stats.records_scanned = stats
1743 .records_scanned
1744 .saturating_add(incoming.records_scanned);
1745 stats.records_added = stats.records_added.saturating_add(incoming.records_added);
1746 stats.records_updated = stats
1747 .records_updated
1748 .saturating_add(incoming.records_updated);
1749
1750 Ok(stats)
1751 }
1752}
1753
1754#[cfg(test)]
1755mod tests {
1756 use super::*;
1757
1758 #[test]
1759 fn parse_native_eth_transfer() {
1760 let target = EvmProvider::parse_transfer_target(
1761 "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=1000000000000000",
1762 CHAIN_ID_BASE,
1763 )
1764 .expect("parse native eth transfer");
1765 assert_eq!(
1766 target.recipient_address,
1767 "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1768 .parse::<Address>()
1769 .expect("parse address")
1770 );
1771 assert_eq!(target.amount_wei, U256::from(1_000_000_000_000_000u64));
1772 assert!(target.token_contract.is_none());
1773 }
1774
1775 #[test]
1776 fn parse_gwei_amount() {
1777 let target = EvmProvider::parse_transfer_target(
1778 "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-gwei=100000",
1779 CHAIN_ID_BASE,
1780 )
1781 .expect("parse gwei");
1782 assert_eq!(
1783 target.amount_wei,
1784 U256::from(100_000u64) * U256::from(1_000_000_000u64)
1785 );
1786 }
1787
1788 #[test]
1789 fn parse_usdc_transfer() {
1790 let target = EvmProvider::parse_transfer_target(
1791 "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=1000000&token=usdc",
1792 CHAIN_ID_BASE,
1793 )
1794 .expect("parse usdc transfer");
1795 assert!(target.token_contract.is_some());
1796 assert_eq!(target.amount_wei, U256::from(1_000_000u64));
1797 }
1798
1799 #[test]
1800 fn parse_empty_target_fails() {
1801 assert!(EvmProvider::parse_transfer_target("", CHAIN_ID_BASE).is_err());
1802 }
1803
1804 #[test]
1805 fn parse_missing_amount_fails() {
1806 assert!(EvmProvider::parse_transfer_target(
1807 "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
1808 CHAIN_ID_BASE,
1809 )
1810 .is_err());
1811 }
1812
1813 #[test]
1814 fn erc20_transfer_encoding_length() {
1815 let to: Address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1816 .parse()
1817 .expect("parse addr");
1818 let data = encode_erc20_transfer(to, U256::from(1_000_000u64));
1819 assert_eq!(data.len(), 68); assert_eq!(&data[..4], &ERC20_TRANSFER_SELECTOR);
1821 }
1822
1823 #[test]
1824 fn normalize_onchain_memo_trims_and_enforces_limit() {
1825 let memo = normalize_onchain_memo(Some(" hello ")).expect("memo should normalize");
1826 assert_eq!(memo, Some(b"hello".to_vec()));
1827
1828 let long_memo = "x".repeat(257);
1829 assert!(normalize_onchain_memo(Some(&long_memo)).is_err());
1830 }
1831
1832 #[test]
1833 fn append_memo_payload_appends_bytes() {
1834 let encoded = encode_erc20_transfer(
1835 "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1836 .parse()
1837 .expect("address"),
1838 U256::from(42u64),
1839 );
1840 let with_memo = append_memo_payload(encoded.clone(), Some(b"memo"));
1841 assert_eq!(with_memo.len(), encoded.len() + 4);
1842 assert!(with_memo.ends_with(b"memo"));
1843 }
1844
1845 #[test]
1846 fn decode_onchain_memo_supports_native_and_erc20_inputs() {
1847 let native = b"order:abc";
1848 assert_eq!(decode_onchain_memo(native), Some("order:abc".to_string()));
1849
1850 let erc20 = append_memo_payload(
1851 encode_erc20_transfer(
1852 "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1853 .parse()
1854 .expect("address"),
1855 U256::from(42u64),
1856 ),
1857 Some(b"order:def"),
1858 );
1859 assert_eq!(decode_onchain_memo(&erc20), Some("order:def".to_string()));
1860
1861 let legacy = append_memo_payload(
1862 encode_erc20_transfer(
1863 "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
1864 .parse()
1865 .expect("address"),
1866 U256::from(42u64),
1867 ),
1868 None,
1869 );
1870 assert_eq!(decode_onchain_memo(&legacy), None);
1871 }
1872
1873 #[test]
1874 fn receipt_confirmations_includes_inclusion_block() {
1875 let receipt = EvmTxReceipt {
1876 block_number: Some("0x10".to_string()),
1877 status: Some("0x1".to_string()),
1878 gas_used: None,
1879 effective_gas_price: None,
1880 };
1881 assert_eq!(receipt_confirmations(&receipt, 0x10), Some(1));
1882 assert_eq!(receipt_confirmations(&receipt, 0x12), Some(3));
1883 }
1884
1885 #[test]
1886 fn usdc_address_base() {
1887 let addr = usdc_contract_address(CHAIN_ID_BASE);
1888 assert!(addr.is_some());
1889 }
1890
1891 #[test]
1892 fn usdc_address_unknown_chain() {
1893 let addr = usdc_contract_address(999999);
1894 assert!(addr.is_none());
1895 }
1896
1897 #[test]
1898 fn erc20_balance_of_calldata_encoding() {
1899 let addr = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
1901 let addr_no_prefix = addr.strip_prefix("0x").unwrap();
1902 let calldata = format!("0x70a08231000000000000000000000000{addr_no_prefix}");
1903 assert_eq!(calldata.len(), 2 + 8 + 64);
1905 assert!(calldata.starts_with("0x70a08231"));
1906 }
1907
1908 #[test]
1909 fn parse_usdt_transfer_via_registry() {
1910 let target = EvmProvider::parse_transfer_target(
1911 "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=500000&token=usdt",
1912 CHAIN_ID_BASE,
1913 )
1914 .expect("parse usdt transfer");
1915 assert!(target.token_contract.is_some());
1916 assert_eq!(target.amount_wei, U256::from(500_000u64));
1917 }
1918
1919 #[test]
1920 fn parse_unknown_token_symbol_fails() {
1921 let err = EvmProvider::parse_transfer_target(
1922 "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=100&token=doge",
1923 CHAIN_ID_BASE,
1924 );
1925 assert!(err.is_err());
1926 assert!(err.unwrap_err().to_string().contains("unknown token"));
1927 }
1928
1929 #[test]
1930 fn parse_custom_contract_address_token() {
1931 let target = EvmProvider::parse_transfer_target(
1932 "ethereum:0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045?amount-wei=100&token=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
1933 CHAIN_ID_BASE,
1934 )
1935 .expect("parse custom token");
1936 assert!(target.token_contract.is_some());
1937 }
1938
1939 #[test]
1940 fn normalize_rpc_endpoints_adds_https() {
1941 let result = EvmProvider::normalize_rpc_endpoint("base-mainnet.g.alchemy.com/v2/key");
1942 assert!(result.is_ok());
1943 assert!(result.as_ref().is_ok_and(|s| s.starts_with("https://")));
1944 }
1945
1946 #[test]
1947 fn normalize_rpc_endpoints_empty_fails() {
1948 assert!(EvmProvider::normalize_rpc_endpoint("").is_err());
1949 }
1950
1951 #[test]
1952 fn chain_id_defaults_to_base() {
1953 let meta = WalletMetadata {
1954 id: "w_test".to_string(),
1955 network: Network::Evm,
1956 label: None,
1957 mint_url: None,
1958 sol_rpc_endpoints: None,
1959 evm_rpc_endpoints: Some(vec!["https://rpc.example".to_string()]),
1960 evm_chain_id: None,
1961 seed_secret: None,
1962 backend: None,
1963 btc_esplora_url: None,
1964 btc_network: None,
1965 btc_address_type: None,
1966 btc_core_url: None,
1967 btc_core_auth_secret: None,
1968 btc_electrum_url: None,
1969 custom_tokens: None,
1970 created_at_epoch_s: 0,
1971 error: None,
1972 };
1973 assert_eq!(EvmProvider::chain_id_for_wallet(&meta), CHAIN_ID_BASE);
1974 }
1975}