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.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-07-13 13:03:16 -07:00
1 parent 3e294180b3
commit cc246664ee
1 file changed
+459 -41
+459 -41
View File
@@ -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<Keypair> {
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<Keypair> {
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<String> {
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<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>,
@@ -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<SubstrateConfig>,
asset_id: u32,
) -> Result<u128> {
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?;
}