Skip to content

Commit e5a6823

Browse files
committed
feat: v0.4.0 - tx_hash, U256 support, concurrent RPC, configurable delays, exponential backoff
- Add tx_hash field to DepositResult across all chains (EVM, Solana, BTC) - Fix u128 overflow in format.rs: use num-bigint for 256-bit EVM amounts - Add concurrent RPC fetching via futures::stream::buffer_unordered - Make RPC delay configurable per ChainConfig (rpc_delay_ms) - Add max_concurrent config to ChainConfig for rate limit control - Implement exponential backoff retry in rpc.rs (up to 3x per URL) - Consolidate tip fetching into Scanner::get_tip static methods - Fix EVM topic padding verification (check exact 0x0000... prefix) - Refactor Solana scanner to use ScanCtx struct (clippy clean) - Update all tests with tx_hash assertions and new ChainConfig fields - Bump version to 0.4.0, update README with new output format and config docs
1 parent a32961b commit e5a6823

11 files changed

Lines changed: 511 additions & 343 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rustplorer"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
description = "High-performance multi-chain deposit detector using public RPC endpoints"
66
license = "MIT OR Apache-2.0"
@@ -30,6 +30,7 @@ csv = "1"
3030
hex = "0.4"
3131
axum = "0.7"
3232
rust_decimal = "1.33"
33+
num-bigint = "0.4"
3334

3435
[dev-dependencies]
3536
mockito = "1"

README.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ cargo install --path .
102102

103103
```toml
104104
[dependencies]
105-
rustplorer = "0.3"
105+
rustplorer = "0.4"
106106
```
107107

108108
## Quick Start
@@ -336,7 +336,8 @@ Options:
336336
"to_address": "0x71c7656ec7ab88b098defb751b7401b5f6d8976f",
337337
"amount_raw": "0x0000000000000000000000000000000000000000000000000000000002faf080",
338338
"amount_clean": "50",
339-
"block_number": 12000542
339+
"block_number": 12000542,
340+
"tx_hash": "0xabc123..."
340341
},
341342
{
342343
"chain": "eip155:1",
@@ -345,7 +346,8 @@ Options:
345346
"to_address": "0x01bf3a00a11a417eef11a8aa0aa341bd7aa010fa",
346347
"amount_raw": "0xde0b6b3a7640000",
347348
"amount_clean": "1",
348-
"block_number": 19000210
349+
"block_number": 19000210,
350+
"tx_hash": "0xdef456..."
349351
},
350352
{
351353
"chain": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
@@ -354,7 +356,8 @@ Options:
354356
"to_address": "3zCGKxMK3JHNUMtHbticPoDvoRbUgzY65ayoHMWZwZE2",
355357
"amount_raw": "2500000000",
356358
"amount_clean": "2.5",
357-
"block_number": 263
359+
"block_number": 263,
360+
"tx_hash": "5Gx..."
358361
},
359362
{
360363
"chain": "bip122:000000000019d6689c085ae165831e93",
@@ -363,19 +366,20 @@ Options:
363366
"to_address": "bc1qtargetaddress1234567890",
364367
"amount_raw": "150000000",
365368
"amount_clean": "1.5",
366-
"block_number": 830000
369+
"block_number": 830000,
370+
"tx_hash": "a1b2c3..."
367371
}
368372
]
369373
```
370374

371375
### CSV
372376

373377
```csv
374-
chain,token,from_address,to_address,amount_raw,amount_clean,block_number
375-
eip155:8453,USDC_BASE,0x20f3a60a...,0x71c7656e...,0x...02faf080,50,12000542
376-
eip155:1,Native,0xd8da6bf2...,0x01bf3a00...,0x0de0b6b3a7640000,1,19000210
377-
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp,Native,E45GKD1q...,3zCGKxMK...,2500000000,2.5,263
378-
bip122:000000000019d6689c085ae165831e93,Native,bc1qsender...,bc1qtarget...,150000000,1.5,830000
378+
chain,token,from_address,to_address,amount_raw,amount_clean,block_number,tx_hash
379+
eip155:8453,USDC_BASE,0x20f3a60a...,0x71c7656e...,0x...02faf080,50,12000542,0xabc123...
380+
eip155:1,Native,0xd8da6bf2...,0x01bf3a00...,0x0de0b6b3a7640000,1,19000210,0xdef456...
381+
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp,Native,E45GKD1q...,3zCGKxMK...,2500000000,2.5,263,5Gx...
382+
bip122:000000000019d6689c085ae165831e93,Native,bc1qsender...,bc1qtarget...,150000000,1.5,830000,a1b2c3...
379383
```
380384

381385
## Programmatic Usage
@@ -532,11 +536,13 @@ The E2E tests perform real transfers on local chains:
532536
### Chain (`[[chains]]`)
533537

534538
| Field | Type | Required | Description |
535-
|---|---|---|---|
539+
|---|---|---|---|---|
536540
| `caip2` | string | yes | CAIP-2 chain ID (e.g. `eip155:1`, `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`, `bip122:000000000019d6689c085ae165831e93`) |
537541
| `rpc` | string[] | yes | One or more public RPC URLs |
538542
| `start_block` | uint64 | no | First block/slot (defaults to `end_block - lookback`) |
539543
| `end_block` | uint64 | no | Last block/slot (defaults to node tip) |
544+
| `rpc_delay_ms` | uint64 | no | Delay between RPC chunks in ms (default: 100 EVM, 200 Solana, 100 BTC) |
545+
| `max_concurrent` | uint | no | Max concurrent RPC requests (default: 5). Set lower for free/public RPCs. |
540546

541547
### Asset (`[assets.NAME]`)
542548

src/btc.rs

Lines changed: 119 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::format::format_to_human;
22
use crate::rpc::execute_rpc;
33
use crate::{AssetConfig, DepositResult};
4+
use futures::StreamExt;
45
use hashbrown::HashSet;
56
use rust_decimal::prelude::*;
67
use serde_json::json;
@@ -9,15 +10,32 @@ use std::sync::Arc;
910
use tokio::sync::Mutex;
1011

1112
const BTC_DECIMALS: u32 = 8;
12-
const RPC_DELAY_MS: u64 = 100;
13+
const DEFAULT_RPC_DELAY_MS: u64 = 100;
14+
const DEFAULT_MAX_CONCURRENT: usize = 3;
1315

1416
pub struct BtcScanner {
1517
pub rpc_urls: Vec<String>,
1618
pub caip2: String,
1719
pub assets: Arc<HashMap<String, AssetConfig>>,
20+
pub rpc_delay_ms: Option<u64>,
21+
pub max_concurrent: usize,
1822
}
1923

2024
impl BtcScanner {
25+
pub async fn get_tip(
26+
client: &reqwest::Client,
27+
rpc_urls: &[String],
28+
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
29+
let payload = json!({
30+
"jsonrpc": "1.0",
31+
"id": "rustplorer",
32+
"method": "getblockcount",
33+
"params": []
34+
});
35+
let res = execute_rpc(client, rpc_urls, &payload).await?;
36+
Ok(res["result"].as_u64().unwrap_or(0))
37+
}
38+
2139
pub async fn scan(
2240
&self,
2341
client: reqwest::Client,
@@ -26,83 +44,117 @@ impl BtcScanner {
2644
targets: Arc<HashSet<String>>,
2745
results: Arc<Mutex<Vec<DepositResult>>>,
2846
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
29-
for block_num in start..=end {
30-
let hash_payload = json!({
31-
"jsonrpc": "1.0",
32-
"id": "rustplorer",
33-
"method": "getblockhash",
34-
"params": [block_num]
35-
});
36-
37-
let hash_res = execute_rpc(&client, &self.rpc_urls, &hash_payload).await?;
38-
let block_hash = match hash_res["result"].as_str() {
39-
Some(h) => h,
40-
None => continue,
41-
};
42-
43-
let block_payload = json!({
44-
"jsonrpc": "1.0",
45-
"id": "rustplorer",
46-
"method": "getblock",
47-
"params": [block_hash, 3]
48-
});
49-
50-
let block_res = execute_rpc(&client, &self.rpc_urls, &block_payload).await?;
51-
52-
if let Some(transactions) = block_res["result"]["tx"].as_array() {
53-
for tx in transactions {
54-
self.process_transaction(tx, block_num, &targets, &results)
55-
.await;
47+
let client = Arc::new(client);
48+
let delay_ms = self.rpc_delay_ms.unwrap_or(DEFAULT_RPC_DELAY_MS);
49+
let max_concurrent = if self.max_concurrent > 0 {
50+
self.max_concurrent
51+
} else {
52+
DEFAULT_MAX_CONCURRENT
53+
};
54+
55+
let block_range: Vec<u64> = (start..=end).collect();
56+
let fetches = futures::stream::iter(block_range.into_iter().map(|block_num| {
57+
let client = Arc::clone(&client);
58+
let rpc_urls = self.rpc_urls.clone();
59+
let caip2 = self.caip2.clone();
60+
let targets = Arc::clone(&targets);
61+
let results = Arc::clone(&results);
62+
async move {
63+
let hash_payload = json!({
64+
"jsonrpc": "1.0",
65+
"id": "rustplorer",
66+
"method": "getblockhash",
67+
"params": [block_num]
68+
});
69+
70+
let hash_res = match execute_rpc(&client, &rpc_urls, &hash_payload).await {
71+
Ok(r) => r,
72+
Err(e) => {
73+
eprintln!("[rustplorer] [{}] block {}: {}", caip2, block_num, e);
74+
return;
75+
}
76+
};
77+
let block_hash = match hash_res["result"].as_str() {
78+
Some(h) => h,
79+
None => return,
80+
};
81+
82+
let block_payload = json!({
83+
"jsonrpc": "1.0",
84+
"id": "rustplorer",
85+
"method": "getblock",
86+
"params": [block_hash, 3]
87+
});
88+
89+
let block_res = match execute_rpc(&client, &rpc_urls, &block_payload).await {
90+
Ok(r) => r,
91+
Err(e) => {
92+
eprintln!("[rustplorer] [{}] block {}: {}", caip2, block_num, e);
93+
return;
94+
}
95+
};
96+
97+
if let Some(transactions) = block_res["result"]["tx"].as_array() {
98+
for tx in transactions {
99+
process_transaction_static(tx, block_num, &caip2, &targets, &results).await;
100+
}
56101
}
57102
}
103+
}))
104+
.buffer_unordered(max_concurrent);
105+
106+
fetches.collect::<Vec<_>>().await;
107+
108+
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
58109

59-
tokio::time::sleep(tokio::time::Duration::from_millis(RPC_DELAY_MS)).await;
60-
}
61110
Ok(())
62111
}
112+
}
63113

64-
async fn process_transaction(
65-
&self,
66-
tx: &serde_json::Value,
67-
block_num: u64,
68-
targets: &Arc<HashSet<String>>,
69-
results: &Arc<Mutex<Vec<DepositResult>>>,
70-
) {
71-
let vouts = match tx["vout"].as_array() {
72-
Some(v) => v,
73-
None => return,
74-
};
114+
async fn process_transaction_static(
115+
tx: &serde_json::Value,
116+
block_num: u64,
117+
caip2: &str,
118+
targets: &Arc<HashSet<String>>,
119+
results: &Arc<Mutex<Vec<DepositResult>>>,
120+
) {
121+
let vouts = match tx["vout"].as_array() {
122+
Some(v) => v,
123+
None => return,
124+
};
75125

76-
let mut from_address = "unknown".to_string();
77-
if let Some(vins) = tx["vin"].as_array() {
78-
if let Some(first_vin) = vins.first() {
79-
if let Some(prevout) = first_vin.get("prevout") {
80-
if let Some(addr) = extract_btc_address(prevout) {
81-
from_address = addr;
82-
}
126+
let tx_hash = tx["txid"].as_str().unwrap_or("unknown").to_string();
127+
128+
let mut from_address = "unknown".to_string();
129+
if let Some(vins) = tx["vin"].as_array() {
130+
if let Some(first_vin) = vins.first() {
131+
if let Some(prevout) = first_vin.get("prevout") {
132+
if let Some(addr) = extract_btc_address(prevout) {
133+
from_address = addr;
83134
}
84135
}
85136
}
137+
}
86138

87-
for vout in vouts {
88-
if let Some(to_address) = extract_btc_address(vout) {
89-
if targets.contains(&to_address) {
90-
let btc_val_f64 = vout["value"].as_f64().unwrap_or(0.0);
91-
92-
let decimal_val = Decimal::from_f64(btc_val_f64).unwrap_or_default();
93-
let sats_decimal = decimal_val * Decimal::new(100_000_000, 0);
94-
let raw_amount = sats_decimal.trunc().to_string();
95-
96-
results.lock().await.push(DepositResult {
97-
chain: self.caip2.clone(),
98-
token: "Native".to_string(),
99-
from_address: from_address.clone(),
100-
to_address,
101-
amount_raw: raw_amount.clone(),
102-
amount_clean: format_to_human(&raw_amount, BTC_DECIMALS),
103-
block_number: block_num,
104-
});
105-
}
139+
for vout in vouts {
140+
if let Some(to_address) = extract_btc_address(vout) {
141+
if targets.contains(&to_address) {
142+
let btc_val_f64 = vout["value"].as_f64().unwrap_or(0.0);
143+
144+
let decimal_val = Decimal::from_f64(btc_val_f64).unwrap_or_default();
145+
let sats_decimal = decimal_val * Decimal::new(100_000_000, 0);
146+
let raw_amount = sats_decimal.trunc().to_string();
147+
148+
results.lock().await.push(DepositResult {
149+
chain: caip2.to_string(),
150+
token: "Native".to_string(),
151+
from_address: from_address.clone(),
152+
to_address,
153+
amount_raw: raw_amount.clone(),
154+
amount_clean: format_to_human(&raw_amount, BTC_DECIMALS),
155+
block_number: block_num,
156+
tx_hash: tx_hash.clone(),
157+
});
106158
}
107159
}
108160
}

0 commit comments

Comments
 (0)