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).
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-07-14 14:08:38 -07:00
1 parent eb821b2ff5
commit a1555ce952
2 files changed
+91 -316

No files matched your search

+2
View File
@@ -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,
+89 -316
View File
@@ -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<Keypair> {
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<Self> {
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<AsyncMutex<()>> = 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<SubstrateConfig>,
keypair: &Keypair,
asset_id: u32,
to: &str,
amount: u128,
) -> Result<String> {
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(<sp_core::crypto::AccountId32 as AsRef<[u8; 32]>>::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<SubstrateConfig>,
keypair: &Keypair,
asset_id: u32,
from: &str,
amount: u128,
) -> Result<String> {
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(<sp_core::crypto::AccountId32 as AsRef<[u8; 32]>>::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<SubstrateConfig>,
keypair: &Keypair,
asset_id: u32,
to: &str,
amount: u128,
) -> Result<String> {
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(<sp_core::crypto::AccountId32 as AsRef<[u8; 32]>>::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?;
}