feat(usdt-bridge): migrate custody from single hot key to 3-of-5 multisig

The single-key custodial model (see prior commit's own doc comment:
"Custodial: A single keypair controls both sides") was replaced with
a 3-of-5 multisig spanning the Serok/SerokiMeclise/Xezinedar/Berdevk/
Noter tiki role-holders (full record in res/validators-tiki.md,
outside this repo). Discovered along the way: wUSDT's ~100M supply
had zero real USDT backing on Polkadot Asset Hub - Treasury_1 (the
actual asset owner, not the seed_path wallet this tool assumed) held
0 USDT/DOT there, with ~99M of the 100M supply sitting undistributed
in Treasury_1's own account. Migrated Pezkuwi Asset Hub custody in one
atomic batch (transfer 1M wUSDT to the multisig, set_team, then
transfer_ownership last) signed by Treasury_1 - tx
0xd5dc550c5cf04f323e5c1d9c13f2056acafc9f56404c657b9743c15ff3a3aacb,
verified on-chain.

listen_deposits/listen_withdrawals no longer sign and submit
mint/burn/transfer - no key this process holds has authority over the
multisig account. They now only detect on-chain events and record
status=pending_multisig_approval with the exact call parameters an
operator needs; actual approval happens through pwap-web's existing
multisig UI (shared/lib/multisig.ts, shared/lib/usdt.ts), not this
relayer. BridgeConfig gained custody_multisig_polkadot/_pezkuwi
fields.

Added pezkuwi-subxt/pezkuwi-subxt-signer alongside the existing
vanilla subxt dependency: vanilla subxt's codegen hardcodes the
`sp_runtime` module path and its default signed-extension handling
produced "bad signature" on submission against this chain's actual
metadata - pezkuwi-subxt (following vendor/pezkuwi-subxt's own
grant_noter_tiki.rs example: PezkuwiConfig, XCM v3, sudo_unchecked_weight)
is what actually signs and submits successfully. Also used to grant
the Xezinedar and Berdevk tiki roles (previously missing signatories
for the multisig) to two validators via a relay-chain sudo XCM Transact,
since the People chain has no local Sudo pallet.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-07-13 16:59:20 -07:00
1 parent cc246664ee
commit 45e4b26068
6 files changed
+1224 -7021

No files matched your search

+750 -6892
View File
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -17,12 +17,19 @@ path = "src/main.rs"
tokio = { version = "1.36", features = ["full"] }
# Subxt for blockchain interaction
subxt = { version = "0.38", features = ["substrate-compat"] }
subxt = { version = "0.38" }
subxt-signer = { version = "0.38", features = ["sr25519", "subxt"] }
# Pezkuwi's own subxt fork - required for signing/submitting real transactions against
# Pezkuwi chains (vanilla subxt's default extrinsic params don't match this chain's actual
# signed-extension/hasher setup and produce "bad signature" errors on submission).
pezkuwi-subxt = "0.44.0"
pezkuwi-subxt-signer = { version = "0.44.0", features = ["sr25519"] }
# Crypto
sp-core = { version = "34.0.0", default-features = false }
sp-keyring = { version = "39.0.0" }
sp-runtime = { version = "39.0.5", default-features = false }
# Serialization
serde = { version = "1.0", features = ["derive"] }
+13 -1
View File
@@ -7,5 +7,17 @@
"min_withdraw": 10000000,
"fee_basis_points": 10,
"seed_path": "bridge_seed.json",
"db_path": "bridge_db.json"
"db_path": "bridge_db.json",
"custody_multisig_polkadot": "15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66",
"custody_multisig_pezkuwi": "5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj",
"_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,
"multisig_signatories": 5,
"multisig_account_pezkuwi_prefix42": "5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj",
"multisig_account_polkadot_prefix0": "15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66",
"pezkuwi_side_status": "live - owns wUSDT asset 1000, seeded with 1,000,000 wUSDT (tx 0xd5dc550c5cf04f323e5c1d9c13f2056acafc9f56404c657b9743c15ff3a3aacb)",
"polkadot_side_status": "address computed, zero balance - no real USDT has ever been deposited by this ecosystem on Polkadot Asset Hub; needs at least existential DOT + real USDT deposits to become useful",
"details_doc": "/home/myhez/res/validators-tiki.md"
}
}
@@ -0,0 +1,206 @@
//! Grant Xezinedar (Validator_01) and Berdevk (Validator_02) tiki via XCM Transact,
//! following the exact proven pattern from vendor/pezkuwi-subxt/subxt/examples/grant_noter_tiki.rs
//! (People Chain has no local Sudo pallet - Root must arrive via XCM from the relay chain).
#![allow(missing_docs, dead_code)]
use pezkuwi_subxt::dynamic::Value;
use pezkuwi_subxt::utils::AccountId32;
use pezkuwi_subxt::{OnlineClient, PezkuwiConfig};
use pezkuwi_subxt_signer::bip39::Mnemonic;
use pezkuwi_subxt_signer::sr25519::Keypair;
use std::str::FromStr;
const PEOPLE_CHAIN_PARA_ID: u32 = 1004;
// People Chain pallet/call indices - independently verified against live metadata this session.
const UTILITY_PALLET: u8 = 40;
const TIKI_PALLET: u8 = 61;
const GRANT_TIKI_CALL: u8 = 0;
const BATCH_ALL_CALL: u8 = 2;
// Tiki enum values - verified against live metadata (Tiki::pezpallet::Tiki declaration order).
const TIKI_XEZINEDAR: u8 = 10;
const TIKI_BERDEVK: u8 = 16;
fn encode_compact(value: usize) -> Vec<u8> {
if value < 64 {
vec![(value as u8) << 2]
} else if value < 16384 {
let v = ((value as u16) << 2) | 0x01;
v.to_le_bytes().to_vec()
} else {
panic!("Value too large for compact encoding: {}", value);
}
}
fn encode_grant_tiki(account_id: &[u8; 32], tiki: u8) -> Vec<u8> {
let mut encoded = Vec::with_capacity(36);
encoded.push(TIKI_PALLET);
encoded.push(GRANT_TIKI_CALL);
encoded.push(0x00); // MultiAddress::Id variant
encoded.extend_from_slice(account_id);
encoded.push(tiki);
encoded
}
fn encode_batch_all(calls: Vec<Vec<u8>>) -> Vec<u8> {
let mut encoded = vec![UTILITY_PALLET, BATCH_ALL_CALL];
encoded.extend(encode_compact(calls.len()));
for call in calls {
encoded.extend(call);
}
encoded
}
fn build_xcm_values(encoded_call: &[u8]) -> (Value, Value) {
let dest = Value::unnamed_variant(
"V3",
vec![Value::named_composite([
("parents", Value::u128(0)),
(
"interior",
Value::unnamed_variant(
"X1",
vec![Value::unnamed_variant(
"Teyrchain",
vec![Value::u128(PEOPLE_CHAIN_PARA_ID as u128)],
)],
),
),
])],
);
let message = Value::unnamed_variant(
"V3",
vec![Value::unnamed_composite(vec![
Value::named_variant(
"UnpaidExecution",
[
("weight_limit", Value::unnamed_variant("Unlimited", vec![])),
("check_origin", Value::unnamed_variant("None", vec![])),
],
),
Value::named_variant(
"Transact",
[
("origin_kind", Value::unnamed_variant("Superuser", vec![])),
(
"require_weight_at_most",
Value::named_composite([
("ref_time", Value::u128(50_000_000_000u128)),
("proof_size", Value::u128(5_000_000u128)),
]),
),
("call", Value::from_bytes(encoded_call)),
],
),
])],
);
(dest, message)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== GRANT XEZINEDAR + BERDEVK TIKI ===\n");
let relay_url =
std::env::var("RPC_URL").unwrap_or_else(|_| "ws://217.77.6.126:9944".to_string());
let validator_01: AccountId32 = "5GipBJs2uNWTCazyZQ2vG3DEqLz4tXNmNZtBAT1Mtm1orZ5i".parse()?;
let validator_02: AccountId32 = "5HWFZbhkZuTUySXu6ZXYKrTHBnWXHvWRKLozE22zhnwXGGxk".parse()?;
let call_xezinedar = encode_grant_tiki(&validator_01.0, TIKI_XEZINEDAR);
let call_berdevk = encode_grant_tiki(&validator_02.0, TIKI_BERDEVK);
println!("grant_tiki(Validator_01, Xezinedar): {} bytes", call_xezinedar.len());
println!("grant_tiki(Validator_02, Berdevk): {} bytes", call_berdevk.len());
let batch_call = encode_batch_all(vec![call_xezinedar, call_berdevk]);
println!("Batch call: {} bytes\n", batch_call.len());
let api = OnlineClient::<PezkuwiConfig>::from_insecure_url(&relay_url).await?;
println!("Connected to {}! specVersion: {}\n", relay_url, api.runtime_version().spec_version);
let seed_content = std::fs::read_to_string("/home/myhez/res/sudo.json")?;
let seed_json: serde_json::Value = serde_json::from_str(&seed_content)?;
let mnemonic_str = seed_json["mnemonic"].as_str().expect("mnemonic field present");
let mnemonic = Mnemonic::from_str(mnemonic_str)?;
let sudo_keypair = Keypair::from_phrase(&mnemonic, None)?;
println!("Sudo: {}\n", sudo_keypair.public_key().to_account_id());
let (dest, message) = build_xcm_values(&batch_call);
let xcm_send = pezkuwi_subxt::dynamic::tx("XcmPallet", "send", vec![dest, message]);
let sudo_call = pezkuwi_subxt::dynamic::tx(
"Sudo",
"sudo_unchecked_weight",
vec![
xcm_send.into_value(),
Value::named_composite([
("ref_time", Value::u128(1u128)),
("proof_size", Value::u128(1u128)),
]),
],
);
println!("Submitting sudo(xcm.send(batch_all(grant_tiki x2)))...");
use pezkuwi_subxt::tx::TxStatus;
let tx_progress =
api.tx().sign_and_submit_then_watch_default(&sudo_call, &sudo_keypair).await?;
println!("TX: 0x{}", hex::encode(tx_progress.extrinsic_hash().as_ref()));
let mut progress = tx_progress;
loop {
match progress.next().await {
Some(Ok(TxStatus::InBestBlock(details))) => {
match details.wait_for_success().await {
Ok(events) => {
let mut has_sudid = false;
let mut has_sent = false;
for ev in events.iter().flatten() {
println!(" event: {}::{}", ev.pallet_name(), ev.variant_name());
if ev.pallet_name() == "Sudo" && ev.variant_name() == "Sudid" {
has_sudid = true;
}
if ev.pallet_name() == "XcmPallet" && ev.variant_name() == "Sent" {
has_sent = true;
}
}
if has_sudid && has_sent {
println!("\nSUCCESS! XCM batch sent to People Chain.");
} else {
println!("\nWARNING: expected Sudo::Sudid + XcmPallet::Sent");
}
}
Err(e) => println!("DISPATCH ERROR: {}", e),
}
break;
}
Some(Ok(TxStatus::Error { message })) => {
println!("TX ERROR: {}", message);
break;
}
Some(Ok(TxStatus::Invalid { message })) => {
println!("TX INVALID: {}", message);
break;
}
Some(Ok(TxStatus::Dropped { message })) => {
println!("TX DROPPED: {}", message);
break;
}
Some(Err(e)) => {
println!("STREAM ERROR: {}", e);
break;
}
None => {
println!("STREAM ENDED");
break;
}
_ => {}
}
}
Ok(())
}
@@ -0,0 +1,145 @@
//! Move wUSDT bridge custody from Treasury_1 to the new 3-of-5 multisig, in one atomic batch:
//! 1. Transfer 1,000,000 wUSDT from Treasury_1 to the multisig (seed liquidity pool)
//! 2. set_team(issuer=admin=freezer=multisig)
//! 3. transfer_ownership(owner=multisig) <- must be last: revokes Treasury_1's owner rights
//!
//! Signed by Treasury_1 (verified match against the known Treasury_1 address before running).
#![allow(missing_docs, dead_code)]
use pezkuwi_subxt::dynamic::Value;
use pezkuwi_subxt::utils::AccountId32;
use pezkuwi_subxt::{OnlineClient, PezkuwiConfig};
use pezkuwi_subxt_signer::bip39::Mnemonic;
use pezkuwi_subxt_signer::sr25519::Keypair;
use std::str::FromStr;
const WUSDT_ASSET_ID: u32 = 1000;
const MULTISIG_ADDR: &str = "5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj";
const SEED_AMOUNT: u128 = 1_000_000_000_000; // 1,000,000 wUSDT (6 decimals)
fn multi_address_id(account: &AccountId32) -> Value {
Value::unnamed_variant("Id", [Value::from_bytes(account.0)])
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== MIGRATE wUSDT CUSTODY: Treasury_1 -> 3-of-5 multisig ===\n");
let rpc_url = std::env::var("RPC_URL")
.unwrap_or_else(|_| "wss://asset-hub-rpc.pezkuwichain.io".to_string());
let multisig: AccountId32 = MULTISIG_ADDR.parse()?;
println!("Multisig target: {}", MULTISIG_ADDR);
println!("Seed amount: {} wUSDT\n", SEED_AMOUNT as f64 / 1_000_000.0);
let api = OnlineClient::<PezkuwiConfig>::from_insecure_url(&rpc_url).await?;
println!("Connected to {}! specVersion: {}\n", rpc_url, api.runtime_version().spec_version);
let seed_content = std::fs::read_to_string("/home/myhez/res/treasury.json")?;
let seed_json: serde_json::Value = serde_json::from_str(&seed_content)?;
let mnemonic_str = seed_json["mnemonic"].as_str().expect("mnemonic field present");
let mnemonic = Mnemonic::from_str(mnemonic_str)?;
let treasury_keypair = Keypair::from_phrase(&mnemonic, None)?;
let treasury_account = treasury_keypair.public_key().to_account_id();
println!("Signer (Treasury_1): {}", treasury_account);
assert_eq!(
treasury_account.to_string(),
"5EhCpn82QtdU53MF6PoNFrKHgSrsfcAxFTMwrn3JYf9dioQw",
"Refusing to proceed: signer does not match known Treasury_1 address"
);
let transfer_call = pezkuwi_subxt::dynamic::tx(
"Assets",
"transfer",
vec![
Value::primitive(WUSDT_ASSET_ID.into()),
multi_address_id(&multisig),
Value::u128(SEED_AMOUNT),
],
);
let set_team_call = pezkuwi_subxt::dynamic::tx(
"Assets",
"set_team",
vec![
Value::primitive(WUSDT_ASSET_ID.into()),
multi_address_id(&multisig),
multi_address_id(&multisig),
multi_address_id(&multisig),
],
);
let transfer_ownership_call = pezkuwi_subxt::dynamic::tx(
"Assets",
"transfer_ownership",
vec![Value::primitive(WUSDT_ASSET_ID.into()), multi_address_id(&multisig)],
);
let batch_call = pezkuwi_subxt::dynamic::tx(
"Utility",
"batch_all",
vec![Value::unnamed_composite(vec![
transfer_call.into_value(),
set_team_call.into_value(),
transfer_ownership_call.into_value(),
])],
);
println!("Submitting batch_all(transfer 1M wUSDT, set_team, transfer_ownership)...");
use pezkuwi_subxt::tx::TxStatus;
let tx_progress =
api.tx().sign_and_submit_then_watch_default(&batch_call, &treasury_keypair).await?;
println!("TX: 0x{}", hex::encode(tx_progress.extrinsic_hash().as_ref()));
let mut progress = tx_progress;
loop {
match progress.next().await {
Some(Ok(TxStatus::InBestBlock(details))) => {
match details.wait_for_success().await {
Ok(events) => {
let mut has_batch_completed = false;
for ev in events.iter().flatten() {
println!(" event: {}::{}", ev.pallet_name(), ev.variant_name());
if ev.pallet_name() == "Utility" && ev.variant_name() == "BatchCompleted"
{
has_batch_completed = true;
}
}
if has_batch_completed {
println!("\nSUCCESS! wUSDT custody migrated to the multisig.");
} else {
println!("\nWARNING: expected Utility::BatchCompleted - check events above.");
}
}
Err(e) => println!("DISPATCH ERROR: {}", e),
}
break;
}
Some(Ok(TxStatus::Error { message })) => {
println!("TX ERROR: {}", message);
break;
}
Some(Ok(TxStatus::Invalid { message })) => {
println!("TX INVALID: {}", message);
break;
}
Some(Ok(TxStatus::Dropped { message })) => {
println!("TX DROPPED: {}", message);
break;
}
Some(Err(e)) => {
println!("STREAM ERROR: {}", e);
break;
}
None => {
println!("STREAM ENDED");
break;
}
_ => {}
}
}
Ok(())
}
+102 -127
View File
@@ -7,8 +7,22 @@
//! Backing:
//! - 1:1 backed by real USDT on Polkadot Asset Hub
//!
//! Architecture:
//! - Custodial: A single keypair controls both sides
//! Architecture (updated 2026-07-13 - see /home/myhez/res/validators-tiki.md for the full
//! migration record):
//! - Custody is a 3-of-5 multisig, NOT a single keypair. On Pezkuwi Asset Hub the multisig
//! (5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj) now owns wUSDT (asset 1000) - it holds
//! a seed pool of 1,000,000 wUSDT and is the sole issuer/admin/freezer. On Polkadot Asset Hub
//! the same account (15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66) is the designated
//! deposit address, though it currently holds no real USDT - nobody has deposited real value
//! into this bridge yet.
//! - Because of this, `listen_deposits`/`listen_withdrawals` below can no longer sign and
//! submit mint/transfer calls automatically - no key this process holds has authority over
//! the multisig account. They now only DETECT on-chain events and record them as
//! pending_multisig_approval; an actual release requires 3 of the 5 signatories (Serok,
//! SerokiMeclise, Xezinedar, Berdevk, Noter - see res/validators-tiki.md) to approve via
//! `Multisig.approveAsMulti`/`asMulti`, which is expected to happen through pwap-web's
//! existing multisig UI (shared/lib/multisig.ts, shared/lib/usdt.ts, MultisigMembers.tsx),
//! not this relayer.
//! - Minimum deposit/withdraw: 10 USDT
//! - Fees: Configurable (default 0.1%)
@@ -52,12 +66,27 @@ pub struct BridgeConfig {
/// 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,
/// The 3-of-5 multisig custody account, Polkadot Asset Hub SS58 encoding. Deposits are
/// watched for transfers TO this address - it replaced the old single-key custody address.
#[serde(default = "default_multisig_polkadot")]
pub custody_multisig_polkadot: String,
/// Same multisig account, Pezkuwi Asset Hub SS58 encoding (it owns wUSDT asset 1000).
#[serde(default = "default_multisig_pezkuwi")]
pub custody_multisig_pezkuwi: String,
}
fn default_max_single_tx() -> u128 {
5_000_000_000 // 5,000 USDT (6 decimals) - conservative pilot-stage cap
}
fn default_multisig_polkadot() -> String {
"15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66".to_string()
}
fn default_multisig_pezkuwi() -> String {
"5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj".to_string()
}
impl Default for BridgeConfig {
fn default() -> Self {
Self {
@@ -71,6 +100,8 @@ impl Default for BridgeConfig {
seed_path: PathBuf::from("bridge_seed.json"),
db_path: PathBuf::from("bridge_db.json"),
max_single_tx: default_max_single_tx(),
custody_multisig_polkadot: default_multisig_polkadot(),
custody_multisig_pezkuwi: default_multisig_pezkuwi(),
}
}
}
@@ -835,19 +866,21 @@ async fn cmd_transfer_usdt(config: &BridgeConfig, to: &str, amount: u128) -> Res
}
async fn listen_deposits(config: &BridgeConfig) -> Result<()> {
let (polkadot_addr, _) = load_wallet_addresses(&config.seed_path)?;
let polkadot_addr = &config.custody_multisig_polkadot;
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ DEPOSIT LISTENER ║");
println!("║ DEPOSIT LISTENER (detect-only) ║");
println!("╚══════════════════════════════════════════════════════════════╝\n");
println!("Bridge Address (Polkadot): {}", polkadot_addr);
println!("Bridge Address (Polkadot, 3-of-5 multisig): {}", polkadot_addr);
println!("USDT Asset ID: {}", config.polkadot_usdt_asset_id);
println!("Min Deposit: {} USDT", config.min_deposit as f64 / 1_000_000.0);
println!("\nListening for deposits...\n");
println!("\nNo key held by this process can mint on the multisig's behalf -");
println!("detected deposits are recorded as pending_multisig_approval. An actual");
println!("mint requires 3 of the 5 signatories to approve via pwap-web.\n");
println!("Listening for deposits...\n");
let client = connect_to_chain(&config.polkadot_rpc).await?;
let keypair = load_keypair(&config.seed_path)?;
// Subscribe to new blocks
let mut blocks = client.blocks().subscribe_finalized().await?;
@@ -913,59 +946,39 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> {
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;
info!(" Processing... Fee: {} USDT, Net: {} USDT",
fee as f64 / 1_000_000.0,
net_amount as f64 / 1_000_000.0
);
// 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) => {
info!(" ⏳ AWAITING MULTISIG APPROVAL - not auto-minting.");
info!(
" 3 of 5 signatories must approve: Assets.transfer({}, {}, {}) from the multisig's seed pool",
config.pezkuwi_wusdt_asset_id, pezkuwi_recipient, net_amount
);
info!(" (fee: {} USDT, net: {} USDT) - use pwap-web's multisig UI.",
fee as f64 / 1_000_000.0, net_amount as f64 / 1_000_000.0);
// Connect to Pezkuwi and mint
match connect_to_chain(&config.pezkuwi_rpc).await {
Ok(pezkuwi_client) => {
// 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!(" ❌ Could not derive Pezkuwi recipient from sender {}: {}", from_str, e);
}
}
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: None,
status: "pending_multisig_approval".to_string(),
created_at: chrono::Utc::now().to_rfc3339(),
processed_at: None,
});
db.save(&config.db_path)?;
}
Err(e) => {
error!(" ❌ Failed to connect to Pezkuwi: {}", e);
error!(" ❌ Could not derive Pezkuwi recipient from sender {}: {}", from_str, e);
}
}
} else {
@@ -989,19 +1002,21 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> {
/// 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)?;
let polkadot_bridge_addr = &config.custody_multisig_polkadot;
let pezkuwi_bridge_addr = &config.custody_multisig_pezkuwi;
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ WITHDRAWAL LISTENER ║");
println!("║ WITHDRAWAL LISTENER (detect-only) ║");
println!("╚══════════════════════════════════════════════════════════════╝\n");
println!("Bridge Address (Pezkuwi): {}", pezkuwi_bridge_addr);
println!("Bridge Address (Pezkuwi, 3-of-5 multisig): {}", 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");
println!("\nNo key held by this process can burn/release on the multisig's behalf -");
println!("detected withdrawal requests are recorded as pending_multisig_approval.\n");
println!("Listening 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?;
@@ -1079,87 +1094,47 @@ async fn listen_withdrawals(config: &BridgeConfig) -> Result<()> {
let reserve = get_asset_balance(
&polkadot_client,
config.polkadot_usdt_asset_id,
&polkadot_bridge_addr,
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.",
This must be flagged to the signatories, not approved as-is.",
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);
info!(" ⏳ AWAITING MULTISIG APPROVAL - not auto-burning/releasing.");
info!(
" 3 of 5 signatories must approve: burn {} wUSDT, then Assets.transfer({}, {}, {}) on Polkadot",
amount as f64 / 1_000_000.0, config.polkadot_usdt_asset_id, polkadot_recipient, net_amount
);
info!(" Reserve check at detection time: {} USDT available. Use pwap-web's multisig UI.",
reserve as f64 / 1_000_000.0);
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);
}
}
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: None,
status: if reserve < net_amount {
"pending_multisig_approval_reserve_shortfall".to_string()
} else {
"pending_multisig_approval".to_string()
},
created_at: chrono::Utc::now().to_rfc3339(),
processed_at: None,
});
db.save(&config.db_path)?;
}
Err(e) => {
error!(" ❌ Could not derive Polkadot recipient from sender {}: {}", from_str, e);