From 1111b4cf22df2c1c959a8286688a2398e74efe26 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Wed, 18 Mar 2026 16:50:00 -0700 Subject: [PATCH] fix: use standard Substrate V4 format for unsigned extrinsics The unsigned extrinsic builder was encoding signed extensions (era, nonce, tip, CheckMetadataHash, ChargeAssetTxPayment) in the extrinsic body before the call data. Standard Substrate V4 unsigned format is: compact(length) | 0x04 | call_data Signed extensions belong only in the signing payload (computed by subxt-core's signer_payload()), not in the broadcast-format extrinsic. The non-standard format caused any tool decoding the unsigned extrinsic (polkadot-js createType, txwrapper decode, offline verification tools) to misread the extension bytes as the start of call data, producing invalid pallet/method indices. Also update the unsigned extrinsic parser to match: call data starts immediately after the version byte for unsigned transactions. BTC-3161 --- packages/wasm-dot/src/builder/mod.rs | 138 +++++---------------------- packages/wasm-dot/src/transaction.rs | 10 +- 2 files changed, 26 insertions(+), 122 deletions(-) diff --git a/packages/wasm-dot/src/builder/mod.rs b/packages/wasm-dot/src/builder/mod.rs index 784d151..1476611 100644 --- a/packages/wasm-dot/src/builder/mod.rs +++ b/packages/wasm-dot/src/builder/mod.rs @@ -8,7 +8,7 @@ mod calls; pub mod types; use crate::error::WasmDotError; -use crate::transaction::{encode_era, Transaction}; +use crate::transaction::Transaction; use crate::types::{Era, Validity}; use calls::encode_intent; use parity_scale_codec::{Compact, Encode}; @@ -33,7 +33,7 @@ pub fn build_transaction( // Calculate era from validity let era = compute_era(&context.validity); - // Build unsigned extrinsic with signed extensions encoded per the chain's metadata + // Build unsigned extrinsic: compact(length) | 0x04 | call_data let unsigned_bytes = build_unsigned_extrinsic( &call_data, &era, @@ -46,6 +46,12 @@ pub fn build_transaction( let mut tx = Transaction::from_bytes(&unsigned_bytes, None, Some(&metadata))?; tx.set_context(context.material, context.validity, &context.reference_block)?; + // Set era/nonce/tip from build context (not parsed from unsigned extrinsic body, + // since standard format doesn't include signed extensions in the body) + tx.set_era(era); + tx.set_nonce(context.nonce); + tx.set_tip(context.tip as u128); + Ok(tx) } @@ -63,65 +69,29 @@ fn compute_era(validity: &Validity) -> Era { } } -/// Build unsigned extrinsic bytes with metadata-driven signed extension encoding. +/// Build unsigned extrinsic bytes in standard Substrate V4 format. +/// +/// Format: `compact(length) | 0x04 | call_data` +/// +/// Signed extensions (era, nonce, tip) are NOT included in the unsigned +/// extrinsic body. They belong only in the signing payload, which is +/// computed separately by `signable_payload()` via subxt-core. /// -/// Iterates the chain's signed extension list from metadata and encodes each: -/// - Empty types (0-size composites/tuples): skip -/// - CheckMortality: era bytes -/// - CheckNonce: Compact -/// - ChargeTransactionPayment: Compact tip -/// - ChargeAssetTxPayment: Compact tip + 0x00 (None asset_id) -/// - CheckMetadataHash: 0x00 (Disabled mode) -/// - Other non-empty types: encode default bytes using scale_decode to determine size +/// This matches the format that polkadot-js, txwrapper, and all standard +/// Substrate tools expect for unsigned extrinsics. fn build_unsigned_extrinsic( call_data: &[u8], - era: &Era, - nonce: u32, - tip: u128, - metadata: &Metadata, + _era: &Era, + _nonce: u32, + _tip: u128, + _metadata: &Metadata, ) -> Result, WasmDotError> { let mut body = Vec::new(); // Version byte: 0x04 = unsigned, version 4 body.push(0x04); - // Encode signed extensions per metadata - for ext in metadata.extrinsic().signed_extensions() { - let id = ext.identifier(); - let ty_id = ext.extra_ty(); - - if is_empty_type(metadata, ty_id) { - continue; - } - - match id { - "CheckMortality" | "CheckEra" => { - body.extend_from_slice(&encode_era(era)); - } - "CheckNonce" => { - Compact(nonce).encode_to(&mut body); - } - "ChargeTransactionPayment" => { - Compact(tip).encode_to(&mut body); - } - "ChargeAssetTxPayment" => { - // Struct: { tip: Compact, asset_id: Option } - Compact(tip).encode_to(&mut body); - body.push(0x00); // None — no asset_id - } - "CheckMetadataHash" => { - // Mode enum: 0x00 = Disabled - body.push(0x00); - } - _ => { - // Unknown non-empty extension — encode zero bytes. - // This shouldn't happen for known chains but is a safety fallback. - encode_zero_value(&mut body, ty_id, metadata)?; - } - } - } - - // Call data + // Call data immediately after version byte body.extend_from_slice(call_data); // Length prefix (compact encoded) @@ -131,70 +101,6 @@ fn build_unsigned_extrinsic( Ok(result) } -/// Check if a type ID resolves to an empty (zero-size) type. -fn is_empty_type(metadata: &Metadata, ty_id: u32) -> bool { - let Some(ty) = metadata.types().resolve(ty_id) else { - return false; - }; - match &ty.type_def { - scale_info::TypeDef::Tuple(t) => t.fields.is_empty(), - scale_info::TypeDef::Composite(c) => c.fields.is_empty(), - _ => false, - } -} - -/// Encode the zero/default value for a type. Used for unknown signed extensions -/// where we don't know the semantic meaning but need to produce valid SCALE bytes. -fn encode_zero_value( - buf: &mut Vec, - ty_id: u32, - metadata: &Metadata, -) -> Result<(), WasmDotError> { - let Some(ty) = metadata.types().resolve(ty_id) else { - return Ok(()); // Unknown type — skip - }; - match &ty.type_def { - scale_info::TypeDef::Primitive(p) => { - use scale_info::TypeDefPrimitive; - let zeros: usize = match p { - TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, - TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, - TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, - TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, - TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, - TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, - TypeDefPrimitive::Str | TypeDefPrimitive::Char => { - buf.push(0x00); // empty compact-encoded string/char - return Ok(()); - } - }; - buf.extend_from_slice(&vec![0u8; zeros]); - } - scale_info::TypeDef::Compact(_) => { - buf.push(0x00); // Compact(0) - } - scale_info::TypeDef::Variant(v) => { - // Use first variant (index 0 or lowest) - if let Some(variant) = v.variants.first() { - buf.push(variant.index); - for field in &variant.fields { - encode_zero_value(buf, field.ty.id, metadata)?; - } - } - } - scale_info::TypeDef::Composite(c) => { - for field in &c.fields { - encode_zero_value(buf, field.ty.id, metadata)?; - } - } - scale_info::TypeDef::Sequence(_) | scale_info::TypeDef::Array(_) => { - buf.push(0x00); // empty sequence - } - _ => {} // BitSequence, etc. — skip - } - Ok(()) -} - #[cfg(test)] mod tests { // Tests require real metadata - will be added with test fixtures diff --git a/packages/wasm-dot/src/transaction.rs b/packages/wasm-dot/src/transaction.rs index cc2d37a..3f34057 100644 --- a/packages/wasm-dot/src/transaction.rs +++ b/packages/wasm-dot/src/transaction.rs @@ -555,13 +555,11 @@ fn parse_extrinsic( Ok((true, signer, signature, era, nonce, tip, call_data)) } else { - // Unsigned extrinsic: same extension layout as signed, minus signer/signature. - let (era, nonce, tip, ext_size) = parse_signed_extensions(&bytes[cursor..], metadata)?; - cursor += ext_size; - - // Remaining bytes are call data + // Unsigned extrinsic: standard Substrate V4 format has call data + // immediately after the version byte (no signed extensions in body). + // Era, nonce, and tip are only in the signing payload, not the extrinsic. let call_data = bytes[cursor..].to_vec(); - Ok((false, None, None, era, nonce, tip, call_data)) + Ok((false, None, None, Era::Immortal, 0, 0, call_data)) } }