From 22480e2a6d97264e0d56027213d579373c3a16b9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 6 Nov 2025 20:33:54 +0100 Subject: [PATCH] build meteora swap and send it. It fails --- .env | 17 +++- Cargo.lock | 3 + Cargo.toml | 3 + src/engine.rs | 92 ++++++++++++++++++ src/lib.rs | 5 +- src/main.rs | 91 ++++++++++++++---- src/protocols.rs | 148 +++++++++++++++++++++++++++++ src/rpc.rs | 239 +++++++++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 19 +++- 9 files changed, 596 insertions(+), 21 deletions(-) create mode 100644 src/engine.rs create mode 100644 src/protocols.rs create mode 100644 src/rpc.rs diff --git a/.env b/.env index ac130c3..c5fcd52 100644 --- a/.env +++ b/.env @@ -1,10 +1,25 @@ YELLOWSTONE_ENDPOINT=https://solana-yellowstone-grpc.publicnode.com:443 YELLOWSTONE_COMMITMENT=processed +RPC_URL=https://solana-rpc.publicnode.com +RPC_COMMITMENT=processed +SIMULATE_FIRST=true + + BUNDLER=5jYaYv7HoiFVrY9bAcruj6dH8fCBseky4sBmnTFGSaeW RUST_LOG=info BIN_PATH=logs/frames/bin/1762440223791_slot378317940_4t6rKnrWTjmM_tx.bin # Wallet -WALLET_PATH=keys/wallet_01.json \ No newline at end of file +WALLET_PATH=keys/wallet_01.json + +# Compra +BUY_MODE=amount # amount | percent +BUY_AMOUNT_LAMPORTS=20000000 # 0.02 SOL en lamports (si BUY_MODE=amount) +BUY_WSOL=true # wrap SOL -> wSOL ATA (si hace falta) + +# Slippage y prioridad +MAX_SLIPPAGE_BPS=150 # 1.50% (bps = basis points) +PRIORITY_FEE_MICROLAMPORTS=0 # 0 = sin prioridad extra (ajústalo si hace falta) +COMPUTE_UNIT_LIMIT=1200000 # si quieres imponer tu CU (o deja las del bundler) \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 3e8ded0..0edb924 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2744,6 +2744,9 @@ name = "sniper-bot" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", + "base64 0.22.1", + "bincode", "bs58", "dotenvy", "futures", diff --git a/Cargo.toml b/Cargo.toml index 2a4469a..b8f4da1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # TLS / HTTP libs si las necesitas luego (reqwest usa rustls) reqwest = { version = "0.12", features = ["rustls-tls", "json"] } +async-trait = "0.1" +base64 = "0.22" +bincode = "1" # openssl si lo necesitas para otras cosas (dejamos vendored) openssl = { version = "0.10", features = ["vendored"] } diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..fc5d027 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,92 @@ +// src/engine.rs + +use anyhow::{anyhow, Result}; +use yellowstone_grpc_proto::geyser::SubscribeUpdateTransaction; +use solana_sdk::{ + instruction::CompiledInstruction, + message::{v0::LoadedAddresses, VersionedMessage}, + pubkey::Pubkey, +}; + + +#[derive(Clone, Debug)] +pub struct TxuSnapshot { + pub slot: u64, + pub sig_b58_short: String, + pub txu: SubscribeUpdateTransaction, +} + + +/// Plan neutral que debe devolver extract_plan +#[derive(Clone)] +pub struct SwapPlan { + /// El VersionedMessage original **(opcional)** si lo tienes; útil para rebuild. + pub original_message: Option, + + /// account_keys completos (en el mismo orden que el message) + pub account_keys: Vec, + + /// instrucciones tal cual (program_id_index, accounts indices, data) + pub instructions: Vec, + + /// si aplica: loaded address lookups (ALT) + pub loaded_addresses: Option>, + + /// cuentas que identificamos como 'pool PDAs' (no sustituir) + pub pool_accounts: Vec, + + /// cuentas identificadas como 'trader accounts' (ATAs/payer) que deberemos sustituir + pub trader_accounts: Vec, + + /// (Opcional) amounts / min_out extraídos por decodificar data + pub amounts: Option<(u64, u64)>, // ejemplo: (amount_in, min_out) +} + +/// Resultado: mensaje preparado (VersionedMessage) listo para firmar. +/// Si tu pipeline necesita devolver VersionedTransaction, puedes adaptarlo. +pub struct PreparedMessage { + pub message: VersionedMessage, +} + +pub trait SwapAdapter { + fn name(&self) -> &'static str; + fn probe(&self, snap: &TxuSnapshot) -> bool; + + /// Extrae el SwapPlan desde el txu (analiza message/ixs/data) + fn extract_plan(&self, snap: &TxuSnapshot) -> Result; + + /// Con un plan y tu owner (pubkey), construye el VersionedMessage listo para firmar. + fn build_message(&self, plan: SwapPlan, my_owner: &Pubkey) -> Result; +} + + +/// Helper: crea snapshot simple desde txu +pub fn snapshot_from_txu(txu: SubscribeUpdateTransaction) -> TxuSnapshot { + let slot = txu.slot; + let sig_b58_short = txu + .transaction + .as_ref() + .and_then(|t| (!t.signature.is_empty()).then(|| bs58::encode(&t.signature).into_string())) + .map(|s| s.chars().take(12).collect::()) + .unwrap_or_else(|| "nosig".to_string()); + + TxuSnapshot { slot, sig_b58_short, txu } +} + +/// Crea snapshot, selecciona adapter y devuelve el resultado. +pub fn prepare_and_build( + txu: SubscribeUpdateTransaction, + adapters: &[Box], + my_owner: &Pubkey, +) -> Result<(SwapPlan, PreparedMessage)> { + let snap = snapshot_from_txu(txu); + + let adapter = adapters + .iter() + .find(|a| a.probe(&snap)) + .ok_or_else(|| anyhow!("Ningún adapter reconoce este txu"))?; + + let plan = adapter.extract_plan(&snap)?; + let prepared = adapter.build_message(plan.clone(), my_owner)?; + Ok((plan, prepared)) +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 856792c..61135b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,5 @@ +pub mod utils; pub mod listener; -pub mod utils; \ No newline at end of file +pub mod engine; +pub mod protocols; +pub mod rpc; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 05f1082..cb756ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use tokio::sync::mpsc; use yellowstone_grpc_proto::geyser::SubscribeUpdateTransaction; +use yellowstone_grpc_proto::prost::Message; use bs58; use dotenvy::dotenv; -use tracing_subscriber::EnvFilter; +use std::fs; use sniper_bot::listener; use sniper_bot::listener::YellowstoneSource; @@ -11,8 +12,14 @@ use sniper_bot::listener::YellowstoneSource; use sniper_bot::utils::{ save_tx_update, load_keypair_and_pubkey_from_json, + init_tracing, }; +use sniper_bot::engine::prepare_and_build; +use sniper_bot::protocols::default_adapters; +use sniper_bot::rpc::{HttpRpc, Rpc, sign_versioned_tx}; + +use solana_sdk::{hash::Hash, message::VersionedMessage}; #[tokio::main] @@ -22,17 +29,69 @@ async fn main() -> Result<()> { // Load environment variables from .env file dotenv().ok(); let bundler = std::env::var("BUNDLER")?; - let wallet_path = std::env::var("WALLET_PATH")?; - - // Wallet - let (kp, my_owner) = load_keypair_and_pubkey_from_json(&wallet_path)?; - println!("🔑 Owner: {my_owner}"); + + // Testing conditions + // + let simulate_first = std::env::var("SIMULATE_FIRST").unwrap_or_else(|_| "true".to_string()) == "true"; + let bin_path = std::env::var("BIN_PATH").context("Falta BIN_PATH en .env (o variable de entorno)")?; + // + // Testing conditions + + // Load wallet + let (kp, kp_pub) = load_keypair_and_pubkey_from_json()?; + eprintln!("Keypair pubkey: {}", kp_pub); tracing::info!("[INFO] Bot initialited"); + // RPC client + let rpc = HttpRpc::new()?; + + // Testing conditions + // + // Cargar y decodificar el BIN a txu (protobuf) + let bytes = fs::read(&bin_path).with_context(|| format!("Leyendo binario: {bin_path}"))?; + let txu = SubscribeUpdateTransaction::decode(bytes.as_slice()).context("Decodificando prost: SubscribeUpdateTransaction")?; + // + // Testing conditions + + let adapters = default_adapters(); + + // === A partir de aquí: orquestar compra === + // 1) Plan + mensaje preparado (por ahora: replay 1:1) + let (_plan, prepared) = prepare_and_build(txu, &adapters, &kp_pub)?; + + // 2) Blockhash fresco + slot + let (new_bh, ctx_slot) = rpc.get_latest_blockhash().await?; + println!("Latest slot solana: {ctx_slot}"); + + // 3) Actualiza blockhash del mensaje + let vm = set_recent_blockhash(prepared.message, new_bh); + + // 4) Firma con tu keypair + let vtx = sign_versioned_tx(vm, &[&kp])?; + + // 5) Simula (opcional) + if simulate_first { + rpc.simulate(&vtx, Some(ctx_slot)).await?; + println!("🧪 simulateTransaction OK"); + } + + + // 6) Enviar + let sig = rpc.send_transaction(&vtx, Some(ctx_slot)).await?; + println!("🚀 Enviada: {}", sig); + + + // Testing conditions + // + return Ok(()); + // + // Testing conditions + + // Create a channel for transaction updates let (tx, mut rx) = mpsc::channel::(1024); @@ -77,15 +136,13 @@ async fn main() -> Result<()> { } -fn init_tracing() { - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("info")); - - // No peta si ya estaba inicializado (devuelve Err y lo ignoramos) - let _ = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(false) - .try_init(); -} + +fn set_recent_blockhash(mut vm: VersionedMessage, new_bh: Hash) -> VersionedMessage { + match &mut vm { + VersionedMessage::V0(m) => { m.recent_blockhash = new_bh; } + VersionedMessage::Legacy(m) => { m.recent_blockhash = new_bh; } + } + vm +} \ No newline at end of file diff --git a/src/protocols.rs b/src/protocols.rs new file mode 100644 index 0000000..983f377 --- /dev/null +++ b/src/protocols.rs @@ -0,0 +1,148 @@ +//! protocols.rs +//! Stubs de adapters. Hoy: Meteora “siempre sí” para probar el flujo. +//! Luego añades PumpFun/Bonk con su probe real y (más tarde) extract/build. + +use anyhow::{anyhow, Result}; +use solana_sdk::{ + instruction::CompiledInstruction, + message::{ + v0::{Message as V0Message, MessageAddressTableLookup as V0AddressTableLookup}, + MessageHeader, VersionedMessage, + }, + pubkey::Pubkey, + hash::Hash, +}; +use crate::engine::{PreparedMessage, SwapAdapter, SwapPlan, TxuSnapshot}; + +pub struct MeteoraPoolsAdapter; +impl MeteoraPoolsAdapter { pub fn new() -> Self { Self } } +impl SwapAdapter for MeteoraPoolsAdapter { + fn name(&self) -> &'static str { "MeteoraPools" } + + fn probe(&self, snap: &TxuSnapshot) -> bool { + // Aquí podrías filtrar por logs o por program_id de Meteora. + // Para no bloquear el flujo, seguimos aceptando. + let _ = snap; + true + } + + fn extract_plan(&self, snap: &TxuSnapshot) -> Result { + // 1) Acceder al Message del protobuf (tal como viene en tu txu) + let txi = snap.txu.transaction.as_ref() + .ok_or_else(|| anyhow!("txu.transaction = None"))?; + let msg_pb = txi.transaction.as_ref() + .and_then(|t| t.message.as_ref()) + .ok_or_else(|| anyhow!("txu.transaction.message = None"))?; + + // 2) Header + let header_pb = msg_pb.header.as_ref() + .ok_or_else(|| anyhow!("txu.message.header = None"))?; + let header = MessageHeader { + num_required_signatures: header_pb.num_required_signatures as u8, + num_readonly_signed_accounts: header_pb.num_readonly_signed_accounts as u8, + num_readonly_unsigned_accounts: header_pb.num_readonly_unsigned_accounts as u8, + }; + + // 3) account_keys: [[u8;32]] -> Vec + let account_keys: Vec = msg_pb.account_keys.iter().map(|k32| { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&k32[..]); + Pubkey::new_from_array(arr) + }).collect(); + + // 4) recent_blockhash: [u8;32] -> Hash + let mut bh = [0u8; 32]; + bh.copy_from_slice(&msg_pb.recent_blockhash[..]); + let recent_blockhash: Hash = bh.into(); + + // 5) instructions + let instructions: Vec = msg_pb.instructions.iter().map(|ix| { + CompiledInstruction { + program_id_index: ix.program_id_index as u8, + accounts: ix.accounts.iter().map(|&i| i as u8).collect(), + data: ix.data.clone(), + } + }).collect(); + + // 6) Address Lookup Tables (V0) + let address_table_lookups: Vec = msg_pb.address_table_lookups.iter().map(|l| { + let mut key = [0u8; 32]; + key.copy_from_slice(&l.account_key[..]); + V0AddressTableLookup { + account_key: Pubkey::new_from_array(key), + writable_indexes: l.writable_indexes.clone(), + readonly_indexes: l.readonly_indexes.clone(), + } + }).collect(); + + // 7) Mensaje V0 reconstruido + let message_v0 = V0Message { + header, + account_keys: account_keys.clone(), + recent_blockhash, + instructions: instructions.clone(), + address_table_lookups, + }; + let original_vm = VersionedMessage::V0(message_v0); + + + // 8) trader/pool accounts (queda pendiente hasta que metas tu heurística/ABI) + let trader_accounts: Vec = Vec::new(); + let pool_accounts: Vec = Vec::new(); + + // 9) Por ahora, pool_accounts vacío (identificar por ABI es un siguiente paso) + let pool_accounts: Vec = Vec::new(); + + Ok(SwapPlan { + original_message: Some(original_vm), + account_keys, + instructions, + loaded_addresses: None, // no hace falta aquí porque ya están en V0.address_table_lookups + pool_accounts, + trader_accounts, + amounts: None, + }) + } + + fn build_message(&self, mut plan: SwapPlan, my_owner: &Pubkey) -> Result { + // Debe existir el mensaje original (v0 o legacy) + let mut vm = plan + .original_message + .ok_or_else(|| anyhow!("No original_message in plan"))?; + + match &mut vm { + VersionedMessage::V0(m) => { + // Los primeros N account_keys son firmantes + let n = m.header.num_required_signatures as usize; + if n == 0 { + return Err(anyhow!("Mensaje sin firmantes (num_required_signatures=0)")); + } + // Reemplaza TODOS los firmantes por tu pubkey (lo normal es N=1) + for i in 0..n { + m.account_keys[i] = *my_owner; + } + // (opcional) si sospechas que había >1 firmante y tú solo vas a firmar con 1: + // m.header.num_required_signatures = 1; // y ajusta account_keys[0]=*my_owner + } + VersionedMessage::Legacy(m) => { + let n = m.header.num_required_signatures as usize; + if n == 0 { + return Err(anyhow!("Mensaje legacy sin firmantes (num_required_signatures=0)")); + } + for i in 0..n { + m.account_keys[i] = *my_owner; + } + // (opcional) m.header.num_required_signatures = 1; + } + } + + Ok(PreparedMessage { message: vm }) + } +} + + +pub fn default_adapters() -> Vec> { + vec![ + Box::new(MeteoraPoolsAdapter::new()) + ] +} \ No newline at end of file diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..06080e1 --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,239 @@ +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use bs58; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use bincode; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use solana_sdk::{ + hash::Hash, + signature::Keypair, // <- quitamos Signer import directo (no hace falta) + transaction::VersionedTransaction, + message::VersionedMessage, +}; + +#[async_trait] +pub trait Rpc { + async fn get_latest_blockhash(&self) -> Result<(Hash, u64)>; + async fn simulate(&self, tx: &VersionedTransaction, min_context_slot: Option) -> Result<()>; + async fn send_transaction(&self, tx: &VersionedTransaction, min_context_slot: Option) -> Result; +} + +#[derive(Deserialize)] +struct RpcContext { + context: RpcContextInfo, + value: T, +} + +#[derive(Deserialize)] +struct RpcContextInfo { + slot: u64, +} + + +#[derive(Clone)] +pub struct HttpRpc { + client: Client, + url: String, + commitment: String, +} + +impl HttpRpc { + pub fn new() -> Result { + let rpc_url = std::env::var("RPC_URL") + .context("Falta RPC_URL en .env")?; + + let commitment = std::env::var("RPC_COMMITMENT") + .unwrap_or_else(|_| "processed".to_string()); + + Ok(Self { + client: Client::new(), + url: rpc_url.into(), + commitment: commitment.into(), + }) + } +} + +/* ========= JSON-RPC payloads ========= */ + +#[derive(Serialize)] +struct RpcRequest<'a, T> { + jsonrpc: &'static str, + id: u64, + method: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + params: Option, +} + +#[derive(Deserialize, Debug)] +struct RpcResponse { + jsonrpc: String, + id: u64, + // OJO: no pongas #[serde(default)] en genéricos; Option ya deserializa a None sin T: Default + result: Option, + error: Option, +} + +#[derive(Deserialize, Debug)] +struct RpcError { + code: i64, + message: String, + #[serde(default)] + data: serde_json::Value, +} + +#[derive(Deserialize)] +struct GetLatestBlockhashResult { + value: GetLatestBlockhashValue, +} + +#[derive(Deserialize)] +struct GetLatestBlockhashValue { + blockhash: String, +} + +#[derive(Serialize)] +struct SimSendConfig<'a> { + encoding: &'a str, // "base64" + #[serde(rename = "sigVerify")] + sig_verify: bool, // false + #[serde(rename = "replaceRecentBlockhash")] + replace_recent_blockhash: bool, // true/false + #[serde(rename = "commitment")] + commitment: &'a str, + + #[serde(skip_serializing_if = "Option::is_none")] + preflight_commitment: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + skip_preflight: Option, + + #[serde(skip_serializing_if = "Option::is_none", rename = "minContextSlot")] + min_context_slot: Option, +} + + +#[async_trait] +impl Rpc for HttpRpc { + async fn get_latest_blockhash(&self) -> Result<(Hash, u64)> { + let req = RpcRequest { + jsonrpc: "2.0", + id: 1, + method: "getLatestBlockhash", + params: Some(vec![serde_json::json!({ + "commitment": self.commitment + })]), + }; + + let resp: RpcResponse> = self + .client + .post(&self.url) + .json(&req) + .send() + .await + .context("HTTP getLatestBlockhash")? + .json() + .await + .context("JSON getLatestBlockhash")?; + + if let Some(err) = resp.error { + return Err(anyhow!("RPC error getLatestBlockhash: {} {:?}", err.message, err.data)); + } + let r = resp.result.ok_or_else(|| anyhow!("RPC: sin result en getLatestBlockhash"))?; + let bh_bytes = bs58::decode(&r.value.blockhash) + .into_vec() + .context("decode base58 blockhash")?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&bh_bytes[..]); + let hash = Hash::new_from_array(arr); + let slot = r.context.slot; + Ok((hash, slot)) + } + + + async fn simulate(&self, tx: &VersionedTransaction, min_context_slot: Option) -> Result<()> { + let tx_b64 = serialize_tx_base64(tx)?; + let cfg = SimSendConfig { + encoding: "base64", + sig_verify: false, + replace_recent_blockhash: false, // usa el mismo blockhash que envías + commitment: &self.commitment, + preflight_commitment: Some(&self.commitment), + skip_preflight: None, + min_context_slot, + }; + + let req = RpcRequest { + jsonrpc: "2.0", + id: 1, + method: "simulateTransaction", + params: Some(vec![serde_json::json!(tx_b64), serde_json::to_value(cfg)?]), + }; + + let resp: RpcResponse = self + .client + .post(&self.url) + .json(&req) + .send() + .await + .context("HTTP simulateTransaction")? + .json() + .await + .context("JSON simulateTransaction")?; + + if let Some(err) = resp.error { + return Err(anyhow!("RPC error simulateTransaction: {} {:?}", err.message, err.data)); + } + Ok(()) + } + + async fn send_transaction(&self, tx: &VersionedTransaction, min_context_slot: Option) -> Result { + let tx_b64 = serialize_tx_base64(tx)?; + let cfg = SimSendConfig { + encoding: "base64", + sig_verify: false, + replace_recent_blockhash: false, + commitment: &self.commitment, + preflight_commitment: Some(&self.commitment), + skip_preflight: Some(false), + min_context_slot, // 👈 importante + }; + + let req = RpcRequest { + jsonrpc: "2.0", + id: 1, + method: "sendTransaction", + params: Some(vec![serde_json::json!(tx_b64), serde_json::to_value(cfg)?]), + }; + + let resp: RpcResponse = self + .client + .post(&self.url) + .json(&req) + .send() + .await + .context("HTTP sendTransaction")? + .json() + .await + .context("JSON sendTransaction")?; + + if let Some(err) = resp.error { + return Err(anyhow!("RPC error sendTransaction: {} {:?}", err.message, err.data)); + } + let sig_b58 = resp.result.ok_or_else(|| anyhow!("RPC: sin result en sendTransaction"))?; + Ok(sig_b58) + } +} + +/* ========= Helpers ========= */ + +fn serialize_tx_base64(tx: &VersionedTransaction) -> Result { + let bytes = bincode::serialize(tx).context("bincode serialize VersionedTransaction")?; + Ok(B64.encode(bytes)) +} + +/// Firma una VersionedTransaction con el/los keypair(s) dados. +pub fn sign_versioned_tx(message: VersionedMessage, signers: &[&Keypair]) -> Result { + let tx = VersionedTransaction::try_new(message, signers) + .map_err(|e| anyhow!("VersionedTransaction::try_new: {e}"))?; + Ok(tx) +} diff --git a/src/utils.rs b/src/utils.rs index 61d621e..88579b2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,8 @@ use std::fs as std_fs; use std::time::{SystemTime, UNIX_EPOCH}; use std::convert::TryFrom; +use tracing_subscriber::EnvFilter; + use anyhow::{anyhow, Context, Result}; use bs58; @@ -17,8 +19,9 @@ use solana_sdk::signature::{Keypair, Signer}; /// Carga un keypair desde un JSON (array de 64 u 32 enteros) y devuelve (Keypair, Pubkey). /// - 64 bytes: clave secreta completa (secret + public) -> `Keypair::from_bytes`. /// - 32 bytes: semilla ed25519 -> `Keypair::from_seed`. -pub fn load_keypair_and_pubkey_from_json>(path: P) -> Result<(Keypair, Pubkey)> { - let path_ref = path.as_ref(); +pub fn load_keypair_and_pubkey_from_json() -> Result<(Keypair, Pubkey)> { + let wallet_path = std::env::var("WALLET_PATH")?; + let path_ref = Path::new(&wallet_path); let data = std_fs::read_to_string(path_ref) .with_context(|| format!("Leyendo wallet JSON: {}", path_ref.display()))?; @@ -94,3 +97,15 @@ pub async fn save_tx_update(txu: &SubscribeUpdateTransaction) -> Result<(PathBuf Ok((bin_path, txt_path)) } + + +pub fn init_tracing() { + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")); + + // No peta si ya estaba inicializado (devuelve Err y lo ignoramos) + let _ = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .try_init(); +} \ No newline at end of file