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