Init project
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
CRYPTOHUNTER_DATABASE_PASSWORD=Passw0rd
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
|
||||
/snapshots
|
||||
.env
|
||||
config.yaml
|
||||
2306
Cargo.lock
generated
Normal file
2306
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "cryptohunter"
|
||||
version = "0.1.7"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
postgres = "0.19"
|
||||
rand = "0.8"
|
||||
bitcoin = { version = "0.30", features = ["rand"] }
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
csv = "1.1"
|
||||
crossbeam-channel = "0.5"
|
||||
log = "0.4"
|
||||
simple_logger = "4.0"
|
||||
lazy_static = "1.4"
|
||||
indicatif = "0.17"
|
||||
rayon = "1.10.0"
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM rust:1.87.0-slim-bullseye AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY ./src ./src
|
||||
RUN cargo build --release
|
||||
|
||||
|
||||
FROM debian:bullseye-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpq5 \
|
||||
openssl \
|
||||
ca-certificates \
|
||||
&& update-ca-certificates --fresh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/cryptohunter /app/cryptohunter
|
||||
|
||||
ENTRYPOINT ["/app/cryptohunter"]
|
||||
29
docker-compose.yaml
Normal file
29
docker-compose.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
- RUST_LOG=info
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml
|
||||
- ./snapshots:/snapshots
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
command: search bitcoin run
|
||||
|
||||
database:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: cryptohunter
|
||||
POSTGRES_PASSWORD: ${CRYPTOHUNTER_DATABASE_PASSWORD:-12345678}
|
||||
POSTGRES_DB: cryptohunter
|
||||
volumes:
|
||||
- database-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U cryptohunter -d cryptohunter"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
database-data:
|
||||
41
src/cli.rs
Normal file
41
src/cli.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
#[arg(short, long, default_value = "config.yaml")]
|
||||
pub config: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Search for wallets
|
||||
Search {
|
||||
network: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: SearchSubcommand,
|
||||
},
|
||||
/// Manage snapshots
|
||||
Snapshots {
|
||||
network: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: SnapshotSubcommand,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SearchSubcommand {
|
||||
/// Run search process
|
||||
Run,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum SnapshotSubcommand {
|
||||
/// Load snapshot into database
|
||||
Load { path: String },
|
||||
}
|
||||
35
src/config.rs
Normal file
35
src/config.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub networks: HashMap<String, NetworkConfig>,
|
||||
pub notifications: NotificationConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct NetworkConfig {
|
||||
pub key_generator_tasks: usize,
|
||||
pub balance_checker_tasks: usize,
|
||||
#[serde(flatten)]
|
||||
pub params: HashMap<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct NotificationConfig {
|
||||
pub telegram_bot_token: String,
|
||||
pub telegram_user_id: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let config: Config = serde_yaml::from_str(&contents)?;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
176
src/main.rs
Normal file
176
src/main.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod networks;
|
||||
mod notification;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::Cli;
|
||||
use config::Config;
|
||||
use crossbeam_channel::{bounded, unbounded};
|
||||
use lazy_static::lazy_static;
|
||||
use networks::create_network;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
simple_logger::init_with_level(log::Level::Info)?;
|
||||
let cli = Cli::parse();
|
||||
let config = Config::from_file(&cli.config)?;
|
||||
|
||||
match &cli.command {
|
||||
cli::Commands::Snapshots { network, command } => {
|
||||
let network_config = config
|
||||
.networks
|
||||
.get(network)
|
||||
.ok_or(format!("Config for network {} not found", network))?;
|
||||
let network_obj = create_network(network, &network_config.params)?;
|
||||
|
||||
match command {
|
||||
cli::SnapshotSubcommand::Load { path } => {
|
||||
network_obj.load_snapshot(path)?;
|
||||
log::info!("Snapshot loaded successfully for {}", network);
|
||||
}
|
||||
}
|
||||
}
|
||||
cli::Commands::Search { network, command } => {
|
||||
match command {
|
||||
cli::SearchSubcommand::Run => {
|
||||
let networks_to_run: HashMap<_, _> = match network {
|
||||
Some(net) => {
|
||||
let cfg = config.networks.get(net).ok_or("Network not found")?;
|
||||
[(net.as_str(), cfg)].into()
|
||||
}
|
||||
None => config
|
||||
.networks
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v))
|
||||
.collect(),
|
||||
};
|
||||
|
||||
for (name, net_cfg) in networks_to_run {
|
||||
run_network_pipeline(name, net_cfg, &config.notifications)?;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_network_pipeline(
|
||||
network_name: &str,
|
||||
config: &config::NetworkConfig,
|
||||
notification_cfg: &config::NotificationConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::info!("Starting pipeline for {}", network_name);
|
||||
|
||||
let network = Arc::new(create_network(network_name, &config.params)?);
|
||||
let (keypair_sender, keypair_receiver) = bounded(100);
|
||||
let (notification_sender, notification_receiver) = unbounded();
|
||||
|
||||
// Клонируем имя сети для использования в замыканиях
|
||||
let network_name_str = network_name.to_string();
|
||||
|
||||
// Key generation workers
|
||||
for _ in 0..config.key_generator_tasks {
|
||||
let sender = keypair_sender.clone();
|
||||
let net = network.clone();
|
||||
thread::spawn(move || loop {
|
||||
let keypair = net.generate_keypair();
|
||||
if let Err(e) = sender.send(keypair) {
|
||||
log::error!("Keypair send error: {}", e);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Balance checker workers
|
||||
for _ in 0..config.balance_checker_tasks {
|
||||
let receiver = keypair_receiver.clone();
|
||||
let sender = notification_sender.clone();
|
||||
let net = network.clone();
|
||||
let name = network_name_str.clone(); // Клонируем String
|
||||
thread::spawn(move || loop {
|
||||
match receiver.recv() {
|
||||
Ok((private_key, address)) => {
|
||||
match net.check_balance(&address) {
|
||||
Ok(balance) if balance > 0 => {
|
||||
let formatted_balance = format_balance(balance);
|
||||
let message = format!(
|
||||
"💰 *Balance found!*\n\n*Network:* {}\n*Address:* `{}`\n*Private Key:* `{}`\n*Balance:* {}",
|
||||
name, address, private_key, formatted_balance,
|
||||
);
|
||||
if let Err(e) = sender.send(message) {
|
||||
log::error!("Notification send error: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // Баланс нулевой, ничего не делаем
|
||||
Err(e) => log::error!("Balance check error: {}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Keypair receive error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notification worker
|
||||
let notifier_cfg = notification_cfg.clone();
|
||||
thread::spawn(move || {
|
||||
for message in notification_receiver {
|
||||
if let Err(e) = notification::send_telegram_message(
|
||||
¬ifier_cfg.telegram_bot_token,
|
||||
¬ifier_cfg.telegram_user_id,
|
||||
&message,
|
||||
) {
|
||||
log::error!("Telegram send error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log::info!("Pipeline started for {}", network_name);
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref THRESHOLDS: Vec<(u64, &'static str)> = vec![
|
||||
(1_000_000_000_000_000_000u64, " E"),
|
||||
(1_000_000_000_000_000u64, " P"),
|
||||
(1_000_000_000_000u64, " T"),
|
||||
(1_000_000_000u64, " G"),
|
||||
(1_000_000u64, " M"),
|
||||
(1_000u64, " k"),
|
||||
];
|
||||
}
|
||||
|
||||
fn format_balance(balance: i64) -> String {
|
||||
let abs_balance = balance.abs() as u64;
|
||||
|
||||
let mut divider = 1;
|
||||
let mut suffix = " sat";
|
||||
|
||||
for (thresh, s) in THRESHOLDS.iter() {
|
||||
if abs_balance >= *thresh {
|
||||
divider = thresh.clone();
|
||||
suffix = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let quotient = abs_balance / divider;
|
||||
let remainder = abs_balance % divider;
|
||||
|
||||
if remainder == 0 {
|
||||
format!("{}{}", quotient, suffix)
|
||||
} else {
|
||||
let fractional = remainder as f64 / divider as f64;
|
||||
format!("{:.4}{}", quotient as f64 + fractional, suffix)
|
||||
}
|
||||
}
|
||||
187
src/networks/bitcoin.rs
Normal file
187
src/networks/bitcoin.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use bitcoin::{
|
||||
secp256k1, Address, Network, PrivateKey, PublicKey,
|
||||
};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use postgres::{Client, NoTls};
|
||||
use serde_yaml::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct Bitcoin {
|
||||
database_url: String,
|
||||
database_table: String,
|
||||
}
|
||||
|
||||
impl Bitcoin {
|
||||
pub fn new(params: &HashMap<String, Value>) -> Result<Self, Box<dyn Error>> {
|
||||
let database_url = params
|
||||
.get("database_url")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Missing database_url for Bitcoin")?
|
||||
.to_string();
|
||||
|
||||
let database_table = params
|
||||
.get("database_table")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Missing database_table for Bitcoin")?
|
||||
.to_string();
|
||||
|
||||
Ok(Self {
|
||||
database_url,
|
||||
database_table,
|
||||
})
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<Client, Box<dyn Error>> {
|
||||
let client = Client::connect(&self.database_url, NoTls)?;
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Network for Bitcoin {
|
||||
fn generate_keypair(&self) -> (String, String) {
|
||||
let secp = secp256k1::Secp256k1::new();
|
||||
let private_key = PrivateKey::new(secp256k1::SecretKey::new(&mut rand::thread_rng()), Network::Bitcoin);
|
||||
let public_key = PublicKey::from_private_key(&secp, &private_key);
|
||||
let address = Address::p2pkh(&public_key, Network::Bitcoin).to_string();
|
||||
|
||||
(private_key.to_wif(), address)
|
||||
}
|
||||
|
||||
fn check_balance(&self, address: &str) -> Result<i64, Box<dyn Error>> {
|
||||
let mut client = self.connect()?;
|
||||
let row = client.query_opt(
|
||||
&format!(
|
||||
"SELECT balance FROM {} WHERE address = $1",
|
||||
self.database_table
|
||||
),
|
||||
&[&address],
|
||||
)?;
|
||||
|
||||
Ok(row.map(|r| r.get(0)).unwrap_or(0))
|
||||
}
|
||||
|
||||
fn load_snapshot(&self, snapshot_path: &str) -> Result<(), Box<dyn Error>> {
|
||||
let start_time = Instant::now();
|
||||
let mut client = self.connect()?;
|
||||
let file = File::open(snapshot_path)?;
|
||||
let file_size = file.metadata()?.len();
|
||||
let index_name = format!("{}__address__ix", self.database_table);
|
||||
let multi_progress = MultiProgress::new();
|
||||
|
||||
// 1. Preparing database
|
||||
let prep_pb = multi_progress.add(ProgressBar::new_spinner());
|
||||
prep_pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
|
||||
.template("{spinner} {msg}").unwrap()
|
||||
);
|
||||
prep_pb.set_message("⚙️ Preparing database...");
|
||||
prep_pb.enable_steady_tick(Duration::from_millis(100));
|
||||
|
||||
client.execute("SET synchronous_commit = off", &[])?;
|
||||
client.execute("SET maintenance_work_mem = '4GB'", &[])?;
|
||||
client.execute("SET work_mem = '2GB'", &[])?;
|
||||
client.execute(
|
||||
&format!("DROP TABLE IF EXISTS {}", self.database_table),
|
||||
&[],
|
||||
)?;
|
||||
client.execute(
|
||||
&format!("DROP INDEX IF EXISTS {}", index_name),
|
||||
&[],
|
||||
)?;
|
||||
client.execute(
|
||||
&format!(
|
||||
"CREATE TABLE {} (address TEXT, balance BIGINT)",
|
||||
self.database_table
|
||||
),
|
||||
&[],
|
||||
)?;
|
||||
|
||||
prep_pb.finish_with_message("✅ Database prepared");
|
||||
|
||||
// 2. Copy data to database
|
||||
let copy_pb = multi_progress.add(ProgressBar::new(file_size));
|
||||
copy_pb.set_style(ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta}) | {binary_bytes_per_sec}")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"));
|
||||
copy_pb.set_message("️📥 Copy data to database...");
|
||||
|
||||
let copy_stmt = format!(
|
||||
"COPY {} FROM STDIN WITH (FORMAT csv, DELIMITER E'\t', HEADER)",
|
||||
self.database_table,
|
||||
);
|
||||
let mut writer = client.copy_in(©_stmt)?;
|
||||
|
||||
let file = File::open(snapshot_path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut buffer = [0u8; 65536]; // 64KB буфер
|
||||
let mut total_bytes = 0;
|
||||
|
||||
loop {
|
||||
let bytes_read = reader.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
writer.write_all(&buffer[..bytes_read])?;
|
||||
total_bytes += bytes_read as u64;
|
||||
copy_pb.set_position(total_bytes);
|
||||
}
|
||||
|
||||
writer.finish()?;
|
||||
copy_pb.set_position(file_size);
|
||||
copy_pb.finish_with_message("✅ Data copied to database");
|
||||
|
||||
// 3. Creating index
|
||||
let index_pb = multi_progress.add(ProgressBar::new_spinner());
|
||||
index_pb.set_message("🔀 Creating index...");
|
||||
index_pb.enable_steady_tick(Duration::from_millis(100));
|
||||
|
||||
client.execute(
|
||||
&format!(
|
||||
"CREATE INDEX {} ON {} USING HASH (address)",
|
||||
index_name,
|
||||
self.database_table,
|
||||
),
|
||||
&[],
|
||||
)?;
|
||||
|
||||
index_pb.finish_with_message("✅ Index created");
|
||||
|
||||
// 4. Reseting temporary settings
|
||||
let final_pb = multi_progress.add(ProgressBar::new_spinner());
|
||||
final_pb.set_message("🔁 Reset database settings");
|
||||
final_pb.enable_steady_tick(Duration::from_millis(100));
|
||||
|
||||
client.execute("RESET synchronous_commit", &[])?;
|
||||
client.execute("RESET maintenance_work_mem", &[])?;
|
||||
client.execute("RESET work_mem", &[])?;
|
||||
|
||||
final_pb.finish_with_message("✅ Database settings reseted");
|
||||
|
||||
// 5. Final
|
||||
multi_progress.clear()?;
|
||||
|
||||
let records_count: i64 = client.query_one(
|
||||
&format!(
|
||||
"SELECT COUNT(*) FROM {}",
|
||||
self.database_table,
|
||||
),
|
||||
&[],
|
||||
).map(|row| row.get(0))?;
|
||||
|
||||
let data_size: f64 = file_size as f64 / (1024 * 1024 * 1024) as f64;
|
||||
println!("\n🎉 Snapshot loaded successfully!");
|
||||
println!("📊 Statistics:");
|
||||
println!(" Total records processed: {}", records_count);
|
||||
println!(" Data size: {:.2} GB", data_size);
|
||||
println!(" Execution time: {:?}", start_time.elapsed());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
35
src/networks/mod.rs
Normal file
35
src/networks/mod.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
pub mod bitcoin;
|
||||
|
||||
use serde_yaml::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
|
||||
pub trait Network: Send + Sync {
|
||||
fn generate_keypair(&self) -> (String, String);
|
||||
fn check_balance(&self, address: &str) -> Result<i64, Box<dyn Error>>;
|
||||
// TODO: Remove
|
||||
fn load_snapshot(&self, snapshot_path: &str) -> Result<(), Box<dyn Error>>;
|
||||
}
|
||||
|
||||
pub trait SnapshotLoader {
|
||||
fn load_snapshot(&self, snapshot_path: &str, format: Option<&str>) -> Result<(), Box<dyn Error>>;
|
||||
}
|
||||
|
||||
pub fn create_network(
|
||||
name: &str,
|
||||
params: &HashMap<String, Value>,
|
||||
) -> Result<Box<dyn Network>, Box<dyn Error>> {
|
||||
match name {
|
||||
"bitcoin" => Ok(Box::new(bitcoin::Bitcoin::new(params)?)),
|
||||
_ => Err(format!("Unsupported network: {}", name).into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_snapshot_loader(
|
||||
name: &str,
|
||||
params: &HashMap<String, Value>,
|
||||
) -> Result<Box<dyn SnapshotLoader>, Box<dyn Error>> {
|
||||
match name {
|
||||
_ => Err(format!("Unsupported network: {}", name).into()),
|
||||
}
|
||||
}
|
||||
27
src/notification.rs
Normal file
27
src/notification.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use reqwest::blocking::Client;
|
||||
use std::error::Error;
|
||||
|
||||
pub fn send_telegram_message(
|
||||
bot_token: &str,
|
||||
user_id: &str,
|
||||
message: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendMessage",
|
||||
bot_token
|
||||
);
|
||||
let params = [
|
||||
("chat_id", user_id),
|
||||
("text", message),
|
||||
("parse_mode", "Markdown"),
|
||||
];
|
||||
|
||||
let client = Client::new();
|
||||
let response = client.post(&url).form(¶ms).send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Telegram API error: {}", response.text()?).into())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user