fix(usdt-bridge): replace fragile Debug-string event matching with real byte decoding

listen_deposits/listen_withdrawals matched the bridge address by
checking whether a Debug-formatted string of the decoded dynamic Value
contained a 10-char slice of the address's SS58 text - flagged earlier
this session as unverified and provably broken (that same Debug string
was also fed into convert_ss58_prefix, which only parses real SS58
text and could never have succeeded on it).

Added account_id_from_dynamic(), which decodes an AccountId32's actual
32 bytes out of the event field (peeling the single-item composite
wrapper subxt's dynamic API adds, same nesting seen in Tiki storage
this session) and account_bytes_to_ss58() to re-encode them properly.
Both listeners now compare raw bytes against the target address
instead of matching strings, and derive the sender's real SS58 address
for logging and convert_ss58_prefix() instead of a Debug string.

Verified against a live event, not just read: sent a real 1 wUSDT
transfer from Treasury_1 to the multisig and confirmed the decoder
correctly extracted and matched the multisig's AccountId32 bytes from
the resulting Assets.Transferred event.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-07-13 17:43:16 -07:00
1 parent 45e4b26068
commit 042e1df314
1 file changed
+57 -18
+57 -18
View File
@@ -289,6 +289,38 @@ fn convert_ss58_prefix(addr: &str, target_prefix: u16) -> Result<String> {
Ok(account.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(target_prefix))) Ok(account.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(target_prefix)))
} }
/// Decodes an AccountId32 out of a dynamic event field, replacing the previous
/// Debug-string-contains-substring matching (never verified against a live node, and provably
/// wrong: it fed a `{:?}`-formatted value into convert_ss58_prefix, which can only parse real
/// SS58 text - that call could never have succeeded). AccountId32 fields decode as a composite
/// of 32 byte-sized primitives, sometimes wrapped in one extra single-item composite layer (the
/// same nesting observed in Tiki storage this session) - this peels exactly that.
fn account_id_from_dynamic(value: &subxt::ext::scale_value::Value<u32>) -> Option<[u8; 32]> {
use subxt::ext::scale_value::{Composite, ValueDef};
let mut current = value;
loop {
match &current.value {
ValueDef::Composite(Composite::Unnamed(items)) => {
if items.len() == 32 && items.iter().all(|v| v.as_u128().is_some()) {
let bytes: Vec<u8> =
items.iter().map(|v| v.as_u128().unwrap() as u8).collect();
return bytes.try_into().ok();
} else if items.len() == 1 {
current = &items[0];
continue;
}
return None;
}
_ => return None,
}
}
}
fn account_bytes_to_ss58(bytes: [u8; 32], prefix: u16) -> String {
sp_core::crypto::AccountId32::from(bytes)
.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(prefix))
}
fn load_wallet_addresses(seed_path: &PathBuf) -> Result<(String, String)> { 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 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 wallet: WalletSeed = serde_json::from_str(&content).context("Failed to parse seed")?;
@@ -914,20 +946,20 @@ async fn listen_deposits(config: &BridgeConfig) -> Result<()> {
.and_then(|v| v.as_u128()) .and_then(|v| v.as_u128())
.unwrap_or(0); .unwrap_or(0);
// Check if transfer is to bridge address // Check if transfer is to bridge address - real byte comparison, not string
// // matching (see account_id_from_dynamic's doc comment for why the previous
// CAVEAT (must be resolved before any mainnet fund flow): matching via a // Debug-string approach was not just fragile but provably broken).
// Debug-formatted string of the decoded dynamic Value against a 10-char let target_bytes = sp_core::crypto::AccountId32::from_ss58check(polkadot_addr)
// slice of the bridge's SS58 text is fragile and unverified against a live .map(|a| Into::<[u8; 32]>::into(a))
// node in this environment - a Debug rendering of a raw AccountId32/byte .ok();
// composite is not guaranteed to contain the address's SS58 text at all. let to_bytes = to_field.and_then(account_id_from_dynamic);
// This should be replaced with subxt's typed/codegen API
// (#[subxt::subxt(runtime_metadata_path = ...)]) decoding straight into if to_bytes.is_some() && to_bytes == target_bytes {
// AccountId32, tested against testnet, before this handles real value. {
if let Some(to_value) = to_field { let from_str = from_field
let to_str = format!("{:?}", to_value); .and_then(account_id_from_dynamic)
if to_str.contains(&polkadot_addr[..10]) { .map(|b| account_bytes_to_ss58(b, 0))
let from_str = from_field.map(|f| format!("{:?}", f)).unwrap_or_default(); .unwrap_or_default();
let tx_hash = format!("block-{}-{}", block_number, from_str); let tx_hash = format!("block-{}-{}", block_number, from_str);
info!("📥 DEPOSIT DETECTED!"); info!("📥 DEPOSIT DETECTED!");
@@ -1044,10 +1076,17 @@ async fn listen_withdrawals(config: &BridgeConfig) -> Result<()> {
.and_then(|v| v.as_u128()) .and_then(|v| v.as_u128())
.unwrap_or(0); .unwrap_or(0);
if let Some(to_value) = to_field { let target_bytes = sp_core::crypto::AccountId32::from_ss58check(pezkuwi_bridge_addr)
let to_str = format!("{:?}", to_value); .map(|a| Into::<[u8; 32]>::into(a))
if to_str.contains(&pezkuwi_bridge_addr[..10]) { .ok();
let from_str = from_field.map(|f| format!("{:?}", f)).unwrap_or_default(); let to_bytes = to_field.and_then(account_id_from_dynamic);
if to_bytes.is_some() && to_bytes == target_bytes {
{
let from_str = from_field
.and_then(account_id_from_dynamic)
.map(|b| account_bytes_to_ss58(b, 42))
.unwrap_or_default();
let tx_hash = format!("block-{}-{}", block_number, from_str); let tx_hash = format!("block-{}-{}", block_number, from_str);
info!("📤 WITHDRAWAL DETECTED!"); info!("📤 WITHDRAWAL DETECTED!");