mirror of
https://gitlab.com/pezkuwichain/pezkuwi-sdk.git
synced 2026-11-01 16:31:12 +00:00
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:
1 parent
3e294180b3
commit
cc246664ee
1 file changed
+459
-41
+459
-41
@@ -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?;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user