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