mirror of
https://gitlab.com/pezkuwichain/pezkuwi-sdk.git
synced 2026-10-20 18:56:23 +00:00
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:
1 parent
cc246664ee
commit
45e4b26068
6 files changed
+1224
-7021
No files matched your search
Generated
+750
-6892
File diff suppressed because it is too large.
Load diff
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user