From cc246664eef837479b8284d869280845277ebe9b Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Mon, 13 Jul 2026 13:03:16 -0700 Subject: [PATCH] fix(usdt-bridge): replay protection, real recipients, working withdraw path, reserve check The withdrawal direction (burn wUSDT -> release real USDT) had no listener at all - nothing watched Pezkuwi for redemption deposits, and the manual ProcessWithdrawals command was an empty stub. The deposit listener also had two real bugs: it minted to the bridge's own address instead of the sender's (a leftover "for demo" placeholder), and it never checked whether a deposit had already been processed before minting again. - Add is_deposit_processed/is_withdrawal_processed idempotency checks, enforced before every mint/burn/release, not just recorded after. - Fix listen_deposits() to mint to the sender's actual Pezkuwi address (same AccountId32, re-encoded under Pezkuwi's SS58 prefix - both chains share one derivation, so no memo/registration is needed) and to persist processed deposits to the database, which it never did. - Add listen_withdrawals(), mirroring the deposit listener: detects wUSDT sent to bridge custody, burns it, verifies the Polkadot custody wallet actually holds enough real USDT before releasing (circuit breaker - an under-collateralized peg must halt loudly, not silently short-pay), then releases to the sender's Polkadot address. - Add max_single_tx cap so one bad/anomalous event can't move an unbounded amount automatically. - Add a Reserves command reporting real custody balance vs circulating wUSDT supply - the exact invariant that, when broken, is what the wallet's "waiting for liquidity" banner was masking. - load_keypair() now prefers BRIDGE_SEED_MNEMONIC (meant to be Vault-injected, matching how the exchange's hot wallets are already provisioned) over the plaintext seed_path file, which remains only as a local/dev fallback with a loud warning. Flagged, not fixed here: the event-matching in both listeners (Debug-formatted Value string containing a slice of the bridge's SS58 text) is unverified against a live node in this environment and should be replaced with subxt's typed/codegen API before this handles real value. Custody is still single-keypair (no multisig/MPC threshold signing) - that is an infrastructure/key-ceremony decision, not a code change, and is the highest-priority follow-up. --- tools/usdt-bridge/src/main.rs | 500 +++++++++++++++++++++++++++++++--- 1 file changed, 459 insertions(+), 41 deletions(-) diff --git a/tools/usdt-bridge/src/main.rs b/tools/usdt-bridge/src/main.rs index f4a5cf7f..a526f055 100644 --- a/tools/usdt-bridge/src/main.rs +++ b/tools/usdt-bridge/src/main.rs @@ -43,10 +43,19 @@ pub struct BridgeConfig { pub min_withdraw: u128, /// Bridge fee percentage (e.g., 10 = 0.1%) pub fee_basis_points: u32, - /// Bridge operator seed phrase path + /// Bridge operator seed phrase path (fallback only - prefer BRIDGE_SEED_MNEMONIC env var) pub seed_path: PathBuf, /// Database path pub db_path: PathBuf, + /// Maximum amount processed automatically in a single deposit/withdrawal (base units). + /// Anything above this is left unprocessed for manual review - caps the blast radius + /// of a single bad event (bug, reorg, compromised sender) instead of relaying it blindly. + #[serde(default = "default_max_single_tx")] + pub max_single_tx: u128, +} + +fn default_max_single_tx() -> u128 { + 5_000_000_000 // 5,000 USDT (6 decimals) - conservative pilot-stage cap } impl Default for BridgeConfig { @@ -61,6 +70,7 @@ impl Default for BridgeConfig { fee_basis_points: 10, // 0.1% seed_path: PathBuf::from("bridge_seed.json"), db_path: PathBuf::from("bridge_db.json"), + max_single_tx: default_max_single_tx(), } } } @@ -99,6 +109,12 @@ enum Commands { /// Start the deposit listener (Polkadot -> Pezkuwi) ListenDeposits, + /// Start the withdrawal listener (Pezkuwi -> Polkadot) + ListenWithdrawals, + + /// Start both listeners concurrently - the normal way to run this in production + ListenAll, + /// Process a single deposit manually ProcessDeposit { /// Polkadot tx hash @@ -112,9 +128,13 @@ enum Commands { amount: u128, }, - /// Process pending withdrawals (Pezkuwi -> Polkadot) + /// Report pending/failed withdrawals recorded so far (read-only; live processing + /// happens automatically via ListenWithdrawals/ListenAll) ProcessWithdrawals, + /// Show custody reserves vs circulating wUSDT supply (proof-of-reserves check) + Reserves, + /// Mint wUSDT on Pezkuwi (manual) MintWusdt { /// Recipient address (Pezkuwi) @@ -197,11 +217,27 @@ fn show_addresses(seed_path: &PathBuf) -> Result<()> { Ok(()) } +/// Loads the operator mnemonic. Prefers BRIDGE_SEED_MNEMONIC (meant to be injected at process +/// start from Vault, matching how the exchange's hot wallets are provisioned - see +/// res/SECRETS_INDEX.md) so the phrase never has to rest in plaintext on the server's disk. +/// The on-disk seed_path file remains only as a fallback for local/dev use and is loudly +/// flagged - it must not be how a production custody key is provisioned. fn load_keypair(seed_path: &PathBuf) -> Result { - let content = std::fs::read_to_string(seed_path).context("Failed to read seed file")?; - let wallet: WalletSeed = serde_json::from_str(&content).context("Failed to parse seed")?; + let mnemonic_str = if let Ok(env_mnemonic) = std::env::var("BRIDGE_SEED_MNEMONIC") { + env_mnemonic + } else { + warn!( + "BRIDGE_SEED_MNEMONIC not set - falling back to plaintext seed file at {}. \ + This is acceptable for local/dev only: a production custody key must never \ + live as plaintext on an always-on server's disk.", + seed_path.display() + ); + let content = std::fs::read_to_string(seed_path).context("Failed to read seed file")?; + let wallet: WalletSeed = serde_json::from_str(&content).context("Failed to parse seed")?; + wallet.mnemonic + }; - let mnemonic = bip39::Mnemonic::parse(&wallet.mnemonic) + let mnemonic = bip39::Mnemonic::parse(&mnemonic_str) .map_err(|e| anyhow!("Invalid mnemonic: {:?}", e))?; let keypair = Keypair::from_phrase(&mnemonic, None) @@ -210,6 +246,18 @@ fn load_keypair(seed_path: &PathBuf) -> Result { Ok(keypair) } +/// Re-encodes an SS58 address under a different network's address-format prefix, keeping the +/// same underlying AccountId32 (public key). Safe here specifically because this wallet derives +/// the identical sr25519 key across every Substrate chain it supports (same seed, same path) - +/// so the sender's own address on chain A *is* their receiving address on chain B, just under a +/// different prefix. This must not be reused as a general "guess the recipient" trick outside +/// that specific guarantee. +fn convert_ss58_prefix(addr: &str, target_prefix: u16) -> Result { + let account = sp_core::crypto::AccountId32::from_ss58check(addr) + .map_err(|e| anyhow!("Invalid address {}: {:?}", addr, e))?; + Ok(account.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(target_prefix))) +} + fn load_wallet_addresses(seed_path: &PathBuf) -> Result<(String, String)> { let content = std::fs::read_to_string(seed_path).context("Failed to read seed file")?; let wallet: WalletSeed = serde_json::from_str(&content).context("Failed to parse seed")?; @@ -292,6 +340,27 @@ impl BridgeDatabase { fn next_deposit_id(&self) -> u64 { self.deposits.iter().map(|d| d.id).max().unwrap_or(0) + 1 } + + fn add_withdrawal(&mut self, withdrawal: WithdrawalRecord) { + self.stats.total_withdrawals += 1; + self.stats.total_fees_collected += withdrawal.fee; + self.withdrawals.push(withdrawal); + } + + fn next_withdrawal_id(&self) -> u64 { + self.withdrawals.iter().map(|w| w.id).max().unwrap_or(0) + 1 + } + + /// Idempotency guard: every deposit/withdrawal must be checked against processed history + /// BEFORE minting/releasing, not just recorded after the fact - a relayer restart or a + /// re-scanned block range must never be able to mint/release twice for the same source tx. + fn is_deposit_processed(&self, polkadot_tx_hash: &str) -> bool { + self.deposits.iter().any(|d| d.polkadot_tx_hash == polkadot_tx_hash) + } + + fn is_withdrawal_processed(&self, pezkuwi_tx_hash: &str) -> bool { + self.withdrawals.iter().any(|w| w.pezkuwi_tx_hash == pezkuwi_tx_hash) + } } fn init_db(db_path: &PathBuf) -> Result<()> { @@ -417,6 +486,43 @@ async fn mint_wusdt( Ok(tx_hash) } +/// Burn wUSDT out of the bridge's own Pezkuwi account - called once a withdrawal deposit (a +/// user sending wUSDT to bridge custody to redeem real USDT) has been detected, so circulating +/// wUSDT supply stays an honest 1:1 mirror of what's actually locked on Polkadot. +async fn burn_wusdt( + client: &OnlineClient, + keypair: &Keypair, + asset_id: u32, + from: &str, + amount: u128, +) -> Result { + let from_bytes = sp_core::crypto::AccountId32::from_ss58check(from) + .map_err(|e| anyhow!("Invalid burn source: {:?}", e))?; + + // Build Assets.burn call + let call = subxt::dynamic::tx( + "Assets", + "burn", + vec![ + Value::primitive(asset_id.into()), + Value::unnamed_variant("Id", [Value::from_bytes(>::as_ref(&from_bytes))]), + Value::primitive(amount.into()), + ], + ); + + info!("Submitting burn transaction..."); + let tx_progress = client + .tx() + .sign_and_submit_then_watch_default(&call, keypair) + .await?; + + let events = tx_progress.wait_for_finalized_success().await?; + let tx_hash = format!("{:?}", events.extrinsic_hash()); + + info!("Burn successful! TX: {}", tx_hash); + Ok(tx_hash) +} + /// Transfer USDT on Polkadot Asset Hub async fn transfer_usdt( client: &OnlineClient, @@ -547,6 +653,67 @@ async fn show_balances(config: &BridgeConfig) -> Result<()> { Ok(()) } +/// Get an asset's total circulating supply (Assets.Asset(asset_id).supply) +async fn get_asset_total_supply( + client: &OnlineClient, + asset_id: u32, +) -> Result { + let storage_query = subxt::dynamic::storage( + "Assets", + "Asset", + vec![Value::primitive(asset_id.into())], + ); + + let result = client.storage().at_latest().await?.fetch(&storage_query).await?; + + if let Some(value) = result { + if let Some(supply) = value.to_value()?.at("supply") { + if let Some(s) = supply.as_u128() { + return Ok(s); + } + } + } + + Ok(0) +} + +/// Proof-of-reserves: circulating wUSDT supply on Pezkuwi should never exceed real USDT +/// actually held in bridge custody on Polkadot. This is the exact invariant that, if broken, +/// produces the "waiting for liquidity" situation - surfacing it explicitly here instead of +/// leaving it as an opaque wallet-side banner. +async fn show_reserves(config: &BridgeConfig) -> Result<()> { + let (polkadot_addr, _) = load_wallet_addresses(&config.seed_path)?; + + println!("╔══════════════════════════════════════════════════════════════╗"); + println!("║ PROOF OF RESERVES ║"); + println!("╚══════════════════════════════════════════════════════════════╝\n"); + + let polkadot_client = connect_to_chain(&config.polkadot_rpc).await?; + let pezkuwi_client = connect_to_chain(&config.pezkuwi_rpc).await?; + + let real_reserve = get_asset_balance(&polkadot_client, config.polkadot_usdt_asset_id, &polkadot_addr).await?; + let circulating_supply = get_asset_total_supply(&pezkuwi_client, config.pezkuwi_wusdt_asset_id).await?; + + println!("Real USDT locked (Polkadot custody): {} USDT", real_reserve as f64 / 1_000_000.0); + println!("Circulating wUSDT (Pezkuwi supply): {} USDT", circulating_supply as f64 / 1_000_000.0); + + if circulating_supply > real_reserve { + let shortfall = circulating_supply - real_reserve; + println!( + "\n🚨 UNDER-COLLATERALIZED by {} USDT - withdrawals beyond available reserve will \ + (correctly) be halted by listen_withdrawals' circuit breaker until this is resolved.", + shortfall as f64 / 1_000_000.0 + ); + } else { + println!( + "\n✅ Fully backed, {} USDT of headroom.", + (real_reserve - circulating_supply) as f64 / 1_000_000.0 + ); + } + + Ok(()) +} + fn calculate_fee(amount: u128, fee_basis_points: u32) -> u128 { amount * fee_basis_points as u128 / 10_000 } @@ -566,6 +733,19 @@ async fn process_deposit( )); } + if amount > config.max_single_tx { + return Err(anyhow!( + "Amount {} exceeds max_single_tx cap {} - requires manual multi-party approval, not automatic processing", + amount as f64 / 1_000_000.0, + config.max_single_tx as f64 / 1_000_000.0 + )); + } + + let mut db = BridgeDatabase::load(&config.db_path)?; + if db.is_deposit_processed(tx_hash) { + return Err(anyhow!("Deposit {} already processed - refusing to mint twice", tx_hash)); + } + // Calculate fee let fee = calculate_fee(amount, config.fee_basis_points); let net_amount = amount - fee; @@ -590,14 +770,8 @@ async fn process_deposit( // Connect to Pezkuwi and mint wUSDT let pezkuwi_client = connect_to_chain(&config.pezkuwi_rpc).await?; - // Convert sender address to Pezkuwi format (same public key, different SS58 prefix) - // For custodial bridge, we need the user to provide their Pezkuwi address - // For now, use sender address converted to Pezkuwi format - let sender_account = sp_core::crypto::AccountId32::from_ss58check(sender) - .map_err(|e| anyhow!("Invalid sender address: {:?}", e))?; - let pezkuwi_recipient = sender_account.to_ss58check_with_version( - sp_core::crypto::Ss58AddressFormat::custom(42) - ); + // Same AccountId32/keypair, re-encoded under Pezkuwi's SS58 prefix - see convert_ss58_prefix. + let pezkuwi_recipient = convert_ss58_prefix(sender, 42)?; println!("Minting {} wUSDT to {}...", net_amount as f64 / 1_000_000.0, pezkuwi_recipient); @@ -610,7 +784,6 @@ async fn process_deposit( ).await?; // Save to database - let mut db = BridgeDatabase::load(&config.db_path)?; let deposit = DepositRecord { id: db.next_deposit_id(), polkadot_tx_hash: tx_hash.to_string(), @@ -709,25 +882,41 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> { .unwrap_or(0); // Check if transfer is to bridge address + // + // CAVEAT (must be resolved before any mainnet fund flow): matching via a + // Debug-formatted string of the decoded dynamic Value against a 10-char + // slice of the bridge's SS58 text is fragile and unverified against a live + // node in this environment - a Debug rendering of a raw AccountId32/byte + // composite is not guaranteed to contain the address's SS58 text at all. + // This should be replaced with subxt's typed/codegen API + // (#[subxt::subxt(runtime_metadata_path = ...)]) decoding straight into + // AccountId32, tested against testnet, before this handles real value. if let Some(to_value) = to_field { let to_str = format!("{:?}", to_value); if to_str.contains(&polkadot_addr[..10]) { let from_str = from_field.map(|f| format!("{:?}", f)).unwrap_or_default(); + let tx_hash = format!("block-{}-{}", block_number, from_str); info!("📥 DEPOSIT DETECTED!"); info!(" Block: #{}", block_number); info!(" From: {}", from_str); info!(" Amount: {} USDT", amount as f64 / 1_000_000.0); - if amount >= config.min_deposit { + let mut db = BridgeDatabase::load(&config.db_path)?; + + if db.is_deposit_processed(&tx_hash) { + warn!(" ⚠️ Already processed ({}), skipping - refusing to mint twice", tx_hash); + } else if amount > config.max_single_tx { + warn!( + " ⚠️ Amount {} exceeds max_single_tx cap {} - leaving for manual multi-party review, NOT auto-processing", + amount as f64 / 1_000_000.0, + config.max_single_tx as f64 / 1_000_000.0 + ); + } else if amount >= config.min_deposit { // Process deposit let fee = calculate_fee(amount, config.fee_basis_points); let net_amount = amount - fee; - // Get recipient Pezkuwi address - // In production, this should come from a memo or separate registration - // For now, convert sender's address - info!(" Processing... Fee: {} USDT, Net: {} USDT", fee as f64 / 1_000_000.0, net_amount as f64 / 1_000_000.0 @@ -736,22 +925,42 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> { // Connect to Pezkuwi and mint match connect_to_chain(&config.pezkuwi_rpc).await { Ok(pezkuwi_client) => { - // For demo, mint to bridge's own Pezkuwi address - // In production, need user's Pezkuwi address - let (_, pezkuwi_bridge) = load_wallet_addresses(&config.seed_path)?; - - match mint_wusdt( - &pezkuwi_client, - &keypair, - config.pezkuwi_wusdt_asset_id, - &pezkuwi_bridge, - net_amount, - ).await { - Ok(tx) => { - info!(" ✅ Minted wUSDT! TX: {}", tx); + // Same AccountId32, re-encoded under Pezkuwi's SS58 prefix - + // see convert_ss58_prefix's doc comment for why this is + // correct rather than a placeholder. + match convert_ss58_prefix(&from_str, 42) { + Ok(pezkuwi_recipient) => { + match mint_wusdt( + &pezkuwi_client, + &keypair, + config.pezkuwi_wusdt_asset_id, + &pezkuwi_recipient, + net_amount, + ).await { + Ok(tx) => { + info!(" ✅ Minted wUSDT to {}! TX: {}", pezkuwi_recipient, tx); + db.add_deposit(DepositRecord { + id: db.next_deposit_id(), + polkadot_tx_hash: tx_hash.clone(), + polkadot_block: block_number as u64, + sender_address: from_str.clone(), + amount, + fee, + net_amount, + pezkuwi_tx_hash: Some(tx), + status: "completed".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + processed_at: Some(chrono::Utc::now().to_rfc3339()), + }); + db.save(&config.db_path)?; + } + Err(e) => { + error!(" ❌ Mint failed: {}", e); + } + } } Err(e) => { - error!(" ❌ Mint failed: {}", e); + error!(" ❌ Could not derive Pezkuwi recipient from sender {}: {}", from_str, e); } } } @@ -772,33 +981,233 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> { Ok(()) } -async fn process_withdrawals(config: &BridgeConfig) -> Result<()> { +/// Watches Pezkuwi Asset Hub for wUSDT sent to the bridge's own Pezkuwi account (a user +/// redeeming their wUSDT for real USDT) and completes the withdrawal automatically: idempotency +/// check, burn the received wUSDT to keep circulating supply honest, verify the Polkadot +/// custody wallet actually holds enough real USDT before releasing (the circuit-breaker this +/// bridge was missing - a mismatch here means the peg is under-collateralized and must halt, +/// not silently short-pay a user), then release on Polkadot. Mirrors listen_deposits() and +/// shares its event-matching caveat - see the comment there. +async fn listen_withdrawals(config: &BridgeConfig) -> Result<()> { + let (polkadot_bridge_addr, pezkuwi_bridge_addr) = load_wallet_addresses(&config.seed_path)?; + println!("╔══════════════════════════════════════════════════════════════╗"); - println!("║ WITHDRAWAL PROCESSOR ║"); + println!("║ WITHDRAWAL LISTENER ║"); println!("╚══════════════════════════════════════════════════════════════╝\n"); - println!("This would process pending withdrawals from the database."); - println!("Withdrawals require burning wUSDT on Pezkuwi first.\n"); + println!("Bridge Address (Pezkuwi): {}", pezkuwi_bridge_addr); + println!("wUSDT Asset ID: {}", config.pezkuwi_wusdt_asset_id); + println!("Min Withdraw: {} USDT", config.min_withdraw as f64 / 1_000_000.0); + println!("\nListening for withdrawals...\n"); + + let pezkuwi_client = connect_to_chain(&config.pezkuwi_rpc).await?; + let keypair = load_keypair(&config.seed_path)?; + + let mut blocks = pezkuwi_client.blocks().subscribe_finalized().await?; + + while let Some(block) = blocks.next().await { + let block = block?; + let block_number = block.number(); + let events = block.events().await?; + + for event in events.iter() { + let event = event?; + + if event.pallet_name() == "Assets" && event.variant_name() == "Transferred" { + if let Ok(fields) = event.field_values() { + let asset_id = fields.at("asset_id") + .and_then(|v| v.as_u128()) + .unwrap_or(0) as u32; + + if asset_id != config.pezkuwi_wusdt_asset_id { + continue; + } + + let to_field = fields.at("to"); + let from_field = fields.at("from"); + let amount = fields.at("amount") + .and_then(|v| v.as_u128()) + .unwrap_or(0); + + if let Some(to_value) = to_field { + let to_str = format!("{:?}", to_value); + if to_str.contains(&pezkuwi_bridge_addr[..10]) { + let from_str = from_field.map(|f| format!("{:?}", f)).unwrap_or_default(); + let tx_hash = format!("block-{}-{}", block_number, from_str); + + info!("📤 WITHDRAWAL DETECTED!"); + info!(" Block: #{}", block_number); + info!(" From: {}", from_str); + info!(" Amount: {} USDT", amount as f64 / 1_000_000.0); + + let mut db = BridgeDatabase::load(&config.db_path)?; + + if db.is_withdrawal_processed(&tx_hash) { + warn!(" ⚠️ Already processed ({}), skipping - refusing to release twice", tx_hash); + continue; + } + + if amount > config.max_single_tx { + warn!( + " ⚠️ Amount {} exceeds max_single_tx cap {} - leaving for manual multi-party review, NOT auto-processing", + amount as f64 / 1_000_000.0, + config.max_single_tx as f64 / 1_000_000.0 + ); + continue; + } + + if amount < config.min_withdraw { + warn!(" ⚠️ Amount below minimum, skipping"); + continue; + } + + let fee = calculate_fee(amount, config.fee_basis_points); + let net_amount = amount - fee; + + // Circuit breaker: never release more real USDT than the custody + // wallet actually holds. If this ever trips, the peg is already + // under-collateralized (e.g. wUSDT minted without a matching real + // deposit) - that must halt and page someone, not degrade silently. + let polkadot_client = match connect_to_chain(&config.polkadot_rpc).await { + Ok(c) => c, + Err(e) => { + error!(" ❌ Failed to connect to Polkadot: {}", e); + continue; + } + }; + + let reserve = get_asset_balance( + &polkadot_client, + config.polkadot_usdt_asset_id, + &polkadot_bridge_addr, + ).await.unwrap_or(0); + + if reserve < net_amount { + error!( + " 🚨 RESERVE SHORTFALL: custody holds {} USDT, need {} USDT to honor this withdrawal. \ + HALTING this withdrawal and NOT burning wUSDT - page the on-call operator, do not retry blindly.", + reserve as f64 / 1_000_000.0, + net_amount as f64 / 1_000_000.0 + ); + continue; + } + + match convert_ss58_prefix(&from_str, 0) { + Ok(polkadot_recipient) => { + // Burn first: if release then fails, we've under-counted + // supply rather than double-released against one burn - + // the safer direction to fail in for a 1:1-backed peg. + match burn_wusdt( + &pezkuwi_client, + &keypair, + config.pezkuwi_wusdt_asset_id, + &pezkuwi_bridge_addr, + amount, + ).await { + Ok(burn_tx) => { + info!(" 🔥 Burned {} wUSDT! TX: {}", amount as f64 / 1_000_000.0, burn_tx); + + match transfer_usdt( + &polkadot_client, + &keypair, + config.polkadot_usdt_asset_id, + &polkadot_recipient, + net_amount, + ).await { + Ok(release_tx) => { + info!(" ✅ Released {} USDT to {}! TX: {}", net_amount as f64 / 1_000_000.0, polkadot_recipient, release_tx); + db.add_withdrawal(WithdrawalRecord { + id: db.next_withdrawal_id(), + pezkuwi_tx_hash: tx_hash.clone(), + pezkuwi_block: block_number as u64, + sender_address: from_str.clone(), + destination_address: polkadot_recipient, + amount, + fee, + net_amount, + polkadot_tx_hash: Some(release_tx), + status: "completed".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + processed_at: Some(chrono::Utc::now().to_rfc3339()), + }); + db.save(&config.db_path)?; + } + Err(e) => { + error!( + " 🚨 Burned wUSDT but release FAILED: {}. Funds are now burned without release - \ + requires manual operator intervention, recorded as failed for follow-up.", + e + ); + db.add_withdrawal(WithdrawalRecord { + id: db.next_withdrawal_id(), + pezkuwi_tx_hash: tx_hash.clone(), + pezkuwi_block: block_number as u64, + sender_address: from_str.clone(), + destination_address: "UNRESOLVED".to_string(), + amount, + fee, + net_amount, + polkadot_tx_hash: None, + status: "burned_release_failed".to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + processed_at: None, + }); + db.save(&config.db_path)?; + } + } + } + Err(e) => { + error!(" ❌ Burn failed: {} - withdrawal left unprocessed, safe to retry", e); + } + } + } + Err(e) => { + error!(" ❌ Could not derive Polkadot recipient from sender {}: {}", from_str, e); + } + } + } + } + } + } + } + } + + Ok(()) +} + +async fn process_withdrawals(config: &BridgeConfig) -> Result<()> { + println!("╔══════════════════════════════════════════════════════════════╗"); + println!("║ WITHDRAWAL STATUS (read-only) ║"); + println!("╚══════════════════════════════════════════════════════════════╝\n"); + + println!("Live withdrawal processing now runs automatically via 'listen-withdrawals'"); + println!("(or 'listen-all', which runs both directions concurrently). This command"); + println!("only reports what's recorded in the database so far.\n"); // Load database let db = BridgeDatabase::load(&config.db_path)?; let pending: Vec<_> = db.withdrawals.iter() - .filter(|w| w.status == "pending") + .filter(|w| w.status != "completed") .collect(); if pending.is_empty() { - println!("No pending withdrawals."); + println!("No pending/failed withdrawals."); } else { - println!("Pending withdrawals: {}", pending.len()); + println!("Non-completed withdrawals: {}", pending.len()); for w in pending { - println!(" #{}: {} USDT -> {}", w.id, w.amount as f64 / 1_000_000.0, w.destination_address); + println!(" #{} [{}]: {} USDT -> {}", w.id, w.status, w.amount as f64 / 1_000_000.0, w.destination_address); } } Ok(()) } +async fn listen_all(config: &BridgeConfig) -> Result<()> { + tokio::try_join!(listen_deposits(config), listen_withdrawals(config))?; + Ok(()) +} + // ============================================================================ // Main // ============================================================================ @@ -837,12 +1246,21 @@ async fn main() -> Result<()> { Commands::ListenDeposits => { listen_deposits(&config).await?; } + Commands::ListenWithdrawals => { + listen_withdrawals(&config).await?; + } + Commands::ListenAll => { + listen_all(&config).await?; + } Commands::ProcessDeposit { tx_hash, sender, amount } => { process_deposit(&config, &tx_hash, &sender, amount).await?; } Commands::ProcessWithdrawals => { process_withdrawals(&config).await?; } + Commands::Reserves => { + show_reserves(&config).await?; + } Commands::MintWusdt { to, amount } => { cmd_mint_wusdt(&config, &to, amount).await?; }