From a1555ce95295de7d38593686d0c6c56312d0fd7c Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 14 Jul 2026 14:08:38 -0700 Subject: [PATCH] Fix 3 real bugs in usdt-bridge's listener before trusting it at custody scale - show_status/show_balances/show_reserves checked the wrong account: load_wallet_addresses(&config.seed_path), the old, explicitly-retired single-operator key, never the multisig custody addresses. These commands were actively misleading, not just unfinished. - listen_deposits/listen_withdrawals ran as concurrent tasks sharing one BridgeDatabase JSON file with no locking and a non-atomic write - two events landing close together could produce a lost update, and a mid-write crash left a truncated file that hard-crashed the next start. Added a process-wide async mutex around every load-mutate-save critical section, and made save() write-temp-then-rename so a crash mid-write can never corrupt the file in place. load() now refuses to silently treat a corrupt file as empty (would erase replay-protection history) - only a genuinely missing file gets a fresh database. - The reserve-shortfall circuit breaker - the single most safety-critical signal this tool can produce - only called a local error!() log macro. Nobody was notified. Added real Telegram alerting (reusing PezbridgeBot's exact chats/send pattern rather than a second channel) for reserve shortfalls, max-single-tx-cap deposits/withdrawals needing manual review, and every new pending_multisig_approval row. Also removed ProcessDeposit/MintWusdt/TransferUsdt - dead-but-dangerous CLI subcommands that still loaded the retired operator key and attempted direct mint/transfer/burn calls that only happened to fail today because that key lost asset authority. A latent trap, not a safe no-op by design; production flow is listen-all -> pending_multisig_approval -> pwap-web signing, never a single-key path. cargo check: 0 errors, 0 warnings (both binaries). --- tools/usdt-bridge/bridge_config.json | 2 + tools/usdt-bridge/src/main.rs | 405 ++++++--------------------- 2 files changed, 91 insertions(+), 316 deletions(-) diff --git a/tools/usdt-bridge/bridge_config.json b/tools/usdt-bridge/bridge_config.json index 0cd6139a..9c72fc25 100644 --- a/tools/usdt-bridge/bridge_config.json +++ b/tools/usdt-bridge/bridge_config.json @@ -10,6 +10,8 @@ "db_path": "bridge_db.json", "custody_multisig_polkadot": "15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66", "custody_multisig_pezkuwi": "5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj", + "serok_chat_id": 5362991649, + "ops_chat_id": -5420200114, "_custody_migration_2026-07-13": { "note": "Custody is no longer a single hot key. wUSDT (asset 1000) owner/issuer/admin/freezer on Pezkuwi Asset Hub is now the 3-of-5 multisig below - seed_path/BRIDGE_SEED_MNEMONIC in this file is stale and must not be used to mint/burn/transfer_ownership going forward. listen_deposits/listen_withdrawals in src/main.rs were updated the same day: they now only detect events and record status=pending_multisig_approval - actual mint/burn/release requires 3 of the 5 signatories to approve via pwap-web's multisig UI (shared/lib/multisig.ts, shared/lib/usdt.ts).", "multisig_threshold": 3, diff --git a/tools/usdt-bridge/src/main.rs b/tools/usdt-bridge/src/main.rs index 7b65ea54..d0264bdf 100644 --- a/tools/usdt-bridge/src/main.rs +++ b/tools/usdt-bridge/src/main.rs @@ -31,9 +31,10 @@ use clap::{Parser, Subcommand}; use serde::{Deserialize, Serialize}; use sp_core::{crypto::Ss58Codec, sr25519, Pair}; use std::path::PathBuf; +use std::sync::LazyLock; use subxt::{OnlineClient, SubstrateConfig}; use subxt::dynamic::{At, Value}; -use subxt_signer::sr25519::Keypair; +use tokio::sync::Mutex as AsyncMutex; use tracing::{info, warn, error}; // ============================================================================ @@ -73,6 +74,14 @@ pub struct BridgeConfig { /// Same multisig account, Pezkuwi Asset Hub SS58 encoding (it owns wUSDT asset 1000). #[serde(default = "default_multisig_pezkuwi")] pub custody_multisig_pezkuwi: String, + /// Telegram chat to page on a reserve shortfall or similar safety-critical signal - same + /// chat PezbridgeBot already notifies (see src/bin/pezbridge_bot.rs), reused rather than + /// standing up a second notification channel. 0 = disabled. + #[serde(default)] + pub serok_chat_id: i64, + /// Same, for the ops group chat. + #[serde(default)] + pub ops_chat_id: i64, } fn default_max_single_tx() -> u128 { @@ -102,6 +111,8 @@ impl Default for BridgeConfig { max_single_tx: default_max_single_tx(), custody_multisig_polkadot: default_multisig_polkadot(), custody_multisig_pezkuwi: default_multisig_pezkuwi(), + serok_chat_id: 0, + ops_chat_id: 0, } } } @@ -146,19 +157,6 @@ enum Commands { /// Start both listeners concurrently - the normal way to run this in production ListenAll, - /// Process a single deposit manually - ProcessDeposit { - /// Polkadot tx hash - #[arg(long)] - tx_hash: String, - /// Sender address (Polkadot) - #[arg(long)] - sender: String, - /// Amount in USDT base units (6 decimals) - #[arg(long)] - amount: u128, - }, - /// Report pending/failed withdrawals recorded so far (read-only; live processing /// happens automatically via ListenWithdrawals/ListenAll) ProcessWithdrawals, @@ -166,26 +164,6 @@ enum Commands { /// Show custody reserves vs circulating wUSDT supply (proof-of-reserves check) Reserves, - /// Mint wUSDT on Pezkuwi (manual) - MintWusdt { - /// Recipient address (Pezkuwi) - #[arg(long)] - to: String, - /// Amount in USDT base units (6 decimals) - #[arg(long)] - amount: u128, - }, - - /// Transfer USDT on Polkadot (manual) - TransferUsdt { - /// Recipient address (Polkadot) - #[arg(long)] - to: String, - /// Amount in USDT base units (6 decimals) - #[arg(long)] - amount: u128, - }, - /// Show bridge status and balances Status, @@ -248,35 +226,6 @@ 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 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(&mnemonic_str) - .map_err(|e| anyhow!("Invalid mnemonic: {:?}", e))?; - - let keypair = Keypair::from_phrase(&mnemonic, None) - .map_err(|e| anyhow!("Failed to create keypair: {:?}", e))?; - - 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) - @@ -321,12 +270,6 @@ fn account_bytes_to_ss58(bytes: [u8; 32], prefix: u16) -> String { .to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(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")?; - Ok((wallet.polkadot_address, wallet.pezkuwi_address)) -} - // ============================================================================ // Database // ============================================================================ @@ -379,18 +322,26 @@ struct BridgeStats { } impl BridgeDatabase { + /// Loads the DB, refusing to silently treat a corrupt/truncated file as empty - that would + /// erase replay-protection history and risk re-processing everything already recorded. A + /// genuinely missing file (first run) is the only case that gets a fresh, empty database. fn load(path: &PathBuf) -> Result { if path.exists() { let content = std::fs::read_to_string(path)?; - Ok(serde_json::from_str(&content)?) + serde_json::from_str(&content) + .with_context(|| format!("{} exists but is not valid JSON - refusing to reinitialize (would erase replay-protection history); restore from backup", path.display())) } else { Ok(Self::default()) } } + /// Write-temp-then-rename so a mid-write crash can never leave a truncated/corrupt file in + /// place - `load()` would otherwise hard-fail on the next start. fn save(&self, path: &PathBuf) -> Result<()> { let json = serde_json::to_string_pretty(self)?; - std::fs::write(path, json)?; + let tmp_path = path.with_extension("json.tmp"); + std::fs::write(&tmp_path, json)?; + std::fs::rename(&tmp_path, path)?; Ok(()) } @@ -426,6 +377,13 @@ impl BridgeDatabase { } } +/// Guards every load-mutate-save critical section on `BridgeDatabase`. `listen_deposits` and +/// `listen_withdrawals` run as concurrent tasks under `listen_all` and share one JSON file with +/// no other locking - without this, two events landing close together could race (one task's +/// load-mutate-save clobbering the other's freshly-written record). A single process-wide lock +/// is sufficient here (this binary is the only writer to this file). +static DB_LOCK: LazyLock> = LazyLock::new(|| AsyncMutex::new(())); + fn init_db(db_path: &PathBuf) -> Result<()> { if db_path.exists() { println!("Database already exists at: {}", db_path.display()); @@ -514,111 +472,41 @@ async fn get_native_balance( Ok(0) } -/// Mint wUSDT on Pezkuwi Asset Hub -async fn mint_wusdt( - client: &OnlineClient, - keypair: &Keypair, - asset_id: u32, - to: &str, - amount: u128, -) -> Result { - let to_bytes = sp_core::crypto::AccountId32::from_ss58check(to) - .map_err(|e| anyhow!("Invalid recipient: {:?}", e))?; +// ============================================================================ +// Telegram alerting (reuses PezbridgeBot's exact send pattern/chats - see +// src/bin/pezbridge_bot.rs - rather than standing up a second notification channel) +// ============================================================================ - // Build Assets.mint call - let call = subxt::dynamic::tx( - "Assets", - "mint", - vec![ - Value::primitive(asset_id.into()), - Value::unnamed_variant("Id", [Value::from_bytes(>::as_ref(&to_bytes))]), - Value::primitive(amount.into()), - ], - ); - - info!("Submitting mint 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!("Mint successful! TX: {}", tx_hash); - Ok(tx_hash) +async fn tg_send_message(http: &reqwest::Client, token: &str, chat_id: i64, text: &str) -> Result<()> { + if chat_id == 0 { + return Ok(()); + } + let url = format!("https://api.telegram.org/bot{token}/sendMessage"); + let body = serde_json::json!({ "chat_id": chat_id, "text": text }); + let resp = http.post(&url).json(&body).send().await?; + let json: serde_json::Value = resp.json().await?; + if json["ok"].as_bool() != Some(true) { + return Err(anyhow!("sendMessage failed: {json}")); + } + Ok(()) } -/// 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, - keypair: &Keypair, - asset_id: u32, - to: &str, - amount: u128, -) -> Result { - let to_bytes = sp_core::crypto::AccountId32::from_ss58check(to) - .map_err(|e| anyhow!("Invalid recipient: {:?}", e))?; - - // Build Assets.transfer call - let call = subxt::dynamic::tx( - "Assets", - "transfer", - vec![ - Value::primitive(asset_id.into()), - Value::unnamed_variant("Id", [Value::from_bytes(>::as_ref(&to_bytes))]), - Value::primitive(amount.into()), - ], - ); - - info!("Submitting transfer 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!("Transfer successful! TX: {}", tx_hash); - Ok(tx_hash) +/// Pages both configured chats for a safety-critical signal (reserve shortfall, stuck pending +/// row). Errors are logged, not propagated - a Telegram outage must never block/crash the +/// listener itself, it should just mean the page didn't go out this one time. +async fn notify_telegram(config: &BridgeConfig, msg: &str) { + error!("[ALERT] {msg}"); + let Ok(token) = std::env::var("PEZBRIDGE_TELEGRAM_TOKEN") else { + warn!("PEZBRIDGE_TELEGRAM_TOKEN not set - cannot page anyone for: {msg}"); + return; + }; + let http = reqwest::Client::new(); + if let Err(e) = tg_send_message(&http, &token, config.serok_chat_id, msg).await { + error!("failed to notify Serok: {e:#}"); + } + if let Err(e) = tg_send_message(&http, &token, config.ops_chat_id, msg).await { + error!("failed to notify ops chat: {e:#}"); + } } // ============================================================================ @@ -640,15 +528,9 @@ async fn show_status(config: &BridgeConfig) -> Result<()> { println!(" Fee: {}%", config.fee_basis_points as f64 / 100.0); println!(); - // Load wallet - if config.seed_path.exists() { - let (polkadot_addr, pezkuwi_addr) = load_wallet_addresses(&config.seed_path)?; - println!("Bridge Wallet:"); - println!(" Polkadot: {}", polkadot_addr); - println!(" Pezkuwi: {}", pezkuwi_addr); - } else { - println!("WARNING: No bridge wallet found. Run 'generate-wallet' first."); - } + println!("Custody (3-of-5 multisig):"); + println!(" Polkadot: {}", config.custody_multisig_polkadot); + println!(" Pezkuwi: {}", config.custody_multisig_pezkuwi); // Load database stats if config.db_path.exists() { @@ -663,10 +545,11 @@ async fn show_status(config: &BridgeConfig) -> Result<()> { } async fn show_balances(config: &BridgeConfig) -> Result<()> { - let (polkadot_addr, pezkuwi_addr) = load_wallet_addresses(&config.seed_path)?; + let polkadot_addr = config.custody_multisig_polkadot.clone(); + let pezkuwi_addr = config.custody_multisig_pezkuwi.clone(); println!("╔══════════════════════════════════════════════════════════════╗"); - println!("║ BRIDGE WALLET BALANCES ║"); + println!("║ CUSTODY (3-OF-5 MULTISIG) BALANCES ║"); println!("╚══════════════════════════════════════════════════════════════╝\n"); // Connect to Polkadot Asset Hub @@ -745,8 +628,6 @@ async fn get_asset_total_supply( /// 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"); @@ -754,7 +635,7 @@ async fn show_reserves(config: &BridgeConfig) -> Result<()> { 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 real_reserve = get_asset_balance(&polkadot_client, config.polkadot_usdt_asset_id, &config.custody_multisig_polkadot).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); @@ -781,122 +662,6 @@ fn calculate_fee(amount: u128, fee_basis_points: u32) -> u128 { amount * fee_basis_points as u128 / 10_000 } -async fn process_deposit( - config: &BridgeConfig, - tx_hash: &str, - sender: &str, - amount: u128, -) -> Result<()> { - // Validate amount - if amount < config.min_deposit { - return Err(anyhow!( - "Amount {} below minimum deposit {}", - amount as f64 / 1_000_000.0, - config.min_deposit as f64 / 1_000_000.0 - )); - } - - 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; - - println!("╔══════════════════════════════════════════════════════════════╗"); - println!("║ PROCESSING DEPOSIT ║"); - println!("╚══════════════════════════════════════════════════════════════╝\n"); - - println!("Polkadot TX: {}", tx_hash); - println!("Sender: {}", sender); - println!("Amount: {} USDT", amount as f64 / 1_000_000.0); - println!("Fee ({}%): {} USDT", - config.fee_basis_points as f64 / 100.0, - fee as f64 / 1_000_000.0 - ); - println!("Net Amount: {} USDT", net_amount as f64 / 1_000_000.0); - println!(); - - // Load keypair - let keypair = load_keypair(&config.seed_path)?; - - // Connect to Pezkuwi and mint wUSDT - let pezkuwi_client = connect_to_chain(&config.pezkuwi_rpc).await?; - - // 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); - - let pezkuwi_tx = mint_wusdt( - &pezkuwi_client, - &keypair, - config.pezkuwi_wusdt_asset_id, - &pezkuwi_recipient, - net_amount, - ).await?; - - // Save to database - let deposit = DepositRecord { - id: db.next_deposit_id(), - polkadot_tx_hash: tx_hash.to_string(), - polkadot_block: 0, // Would need to fetch from chain - sender_address: sender.to_string(), - amount, - fee, - net_amount, - pezkuwi_tx_hash: Some(pezkuwi_tx.clone()), - status: "completed".to_string(), - created_at: chrono::Utc::now().to_rfc3339(), - processed_at: Some(chrono::Utc::now().to_rfc3339()), - }; - db.add_deposit(deposit); - db.save(&config.db_path)?; - - println!("\n✅ DEPOSIT PROCESSED SUCCESSFULLY!"); - println!(" Pezkuwi TX: {}", pezkuwi_tx); - - Ok(()) -} - -async fn cmd_mint_wusdt(config: &BridgeConfig, to: &str, amount: u128) -> Result<()> { - println!("Minting {} wUSDT to {}...", amount as f64 / 1_000_000.0, to); - - let keypair = load_keypair(&config.seed_path)?; - let client = connect_to_chain(&config.pezkuwi_rpc).await?; - - let tx_hash = mint_wusdt(&client, &keypair, config.pezkuwi_wusdt_asset_id, to, amount).await?; - - println!("\n✅ MINT SUCCESSFUL!"); - println!(" TX Hash: {}", tx_hash); - - Ok(()) -} - -async fn cmd_transfer_usdt(config: &BridgeConfig, to: &str, amount: u128) -> Result<()> { - println!("Transferring {} USDT to {}...", amount as f64 / 1_000_000.0, to); - - let keypair = load_keypair(&config.seed_path)?; - let client = connect_to_chain(&config.polkadot_rpc).await?; - - let tx_hash = transfer_usdt(&client, &keypair, config.polkadot_usdt_asset_id, to, amount).await?; - - println!("\n✅ TRANSFER SUCCESSFUL!"); - println!(" TX Hash: {}", tx_hash); - - Ok(()) -} - async fn listen_deposits(config: &BridgeConfig) -> Result<()> { let polkadot_addr = &config.custody_multisig_polkadot; @@ -967,6 +732,7 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> { info!(" From: {}", from_str); info!(" Amount: {} USDT", amount as f64 / 1_000_000.0); + let _guard = DB_LOCK.lock().await; let mut db = BridgeDatabase::load(&config.db_path)?; if db.is_deposit_processed(&tx_hash) { @@ -977,6 +743,10 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> { amount as f64 / 1_000_000.0, config.max_single_tx as f64 / 1_000_000.0 ); + notify_telegram(config, &format!( + "📥 Deposit {} USDT from {} exceeds max_single_tx ({} USDT) - needs manual multi-party review, not auto-recorded.", + amount as f64 / 1_000_000.0, from_str, config.max_single_tx as f64 / 1_000_000.0 + )).await; } else if amount >= config.min_deposit { let fee = calculate_fee(amount, config.fee_basis_points); let net_amount = amount - fee; @@ -1008,6 +778,10 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> { processed_at: None, }); db.save(&config.db_path)?; + notify_telegram(config, &format!( + "📥 New deposit pending approval: {} USDT from {} -> mint {} wUSDT (net of fee) to {}. Sign at pwap-web's /multisig/pending.", + amount as f64 / 1_000_000.0, from_str, net_amount as f64 / 1_000_000.0, pezkuwi_recipient + )).await; } Err(e) => { error!(" ❌ Could not derive Pezkuwi recipient from sender {}: {}", from_str, e); @@ -1094,6 +868,7 @@ async fn listen_withdrawals(config: &BridgeConfig) -> Result<()> { info!(" From: {}", from_str); info!(" Amount: {} USDT", amount as f64 / 1_000_000.0); + let _guard = DB_LOCK.lock().await; let mut db = BridgeDatabase::load(&config.db_path)?; if db.is_withdrawal_processed(&tx_hash) { @@ -1107,6 +882,10 @@ async fn listen_withdrawals(config: &BridgeConfig) -> Result<()> { amount as f64 / 1_000_000.0, config.max_single_tx as f64 / 1_000_000.0 ); + notify_telegram(config, &format!( + "📤 Withdrawal {} USDT from {} exceeds max_single_tx ({} USDT) - needs manual multi-party review, not auto-recorded.", + amount as f64 / 1_000_000.0, from_str, config.max_single_tx as f64 / 1_000_000.0 + )).await; continue; } @@ -1137,12 +916,15 @@ async fn listen_withdrawals(config: &BridgeConfig) -> Result<()> { ).await.unwrap_or(0); if reserve < net_amount { - error!( - " 🚨 RESERVE SHORTFALL: custody holds {} USDT, need {} USDT to honor this withdrawal. \ - This must be flagged to the signatories, not approved as-is.", + let shortfall_msg = format!( + "🚨 RESERVE SHORTFALL: custody holds {} USDT, need {} USDT to honor a pending {} USDT withdrawal from {}. \ + This must be reviewed by the signatories before any approval - do NOT approve as-is.", reserve as f64 / 1_000_000.0, - net_amount as f64 / 1_000_000.0 + net_amount as f64 / 1_000_000.0, + amount as f64 / 1_000_000.0, + from_str, ); + notify_telegram(config, &shortfall_msg).await; } match convert_ss58_prefix(&from_str, 0) { @@ -1266,21 +1048,12 @@ async fn main() -> Result<()> { 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?; - } - Commands::TransferUsdt { to, amount } => { - cmd_transfer_usdt(&config, &to, amount).await?; - } Commands::Status => { show_status(&config).await?; }