Files
pezkuwi-sdk/tools/usdt-bridge/examples/migrate_wusdt_to_multisig.rs
T
Satoshi Qazi Muhammed 45e4b26068 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.
2026-07-13 16:59:20 -07:00

146 lines
5.4 KiB
Rust

//! 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(())
}