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