Skip to content

Commit 1111b4c

Browse files
committed
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
1 parent aaff21f commit 1111b4c

File tree

2 files changed

+26
-122
lines changed

2 files changed

+26
-122
lines changed

packages/wasm-dot/src/builder/mod.rs

Lines changed: 22 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ mod calls;
88
pub mod types;
99

1010
use crate::error::WasmDotError;
11-
use crate::transaction::{encode_era, Transaction};
11+
use crate::transaction::Transaction;
1212
use crate::types::{Era, Validity};
1313
use calls::encode_intent;
1414
use parity_scale_codec::{Compact, Encode};
@@ -33,7 +33,7 @@ pub fn build_transaction(
3333
// Calculate era from validity
3434
let era = compute_era(&context.validity);
3535

36-
// Build unsigned extrinsic with signed extensions encoded per the chain's metadata
36+
// Build unsigned extrinsic: compact(length) | 0x04 | call_data
3737
let unsigned_bytes = build_unsigned_extrinsic(
3838
&call_data,
3939
&era,
@@ -46,6 +46,12 @@ pub fn build_transaction(
4646
let mut tx = Transaction::from_bytes(&unsigned_bytes, None, Some(&metadata))?;
4747
tx.set_context(context.material, context.validity, &context.reference_block)?;
4848

49+
// Set era/nonce/tip from build context (not parsed from unsigned extrinsic body,
50+
// since standard format doesn't include signed extensions in the body)
51+
tx.set_era(era);
52+
tx.set_nonce(context.nonce);
53+
tx.set_tip(context.tip as u128);
54+
4955
Ok(tx)
5056
}
5157

@@ -63,65 +69,29 @@ fn compute_era(validity: &Validity) -> Era {
6369
}
6470
}
6571

66-
/// Build unsigned extrinsic bytes with metadata-driven signed extension encoding.
72+
/// Build unsigned extrinsic bytes in standard Substrate V4 format.
73+
///
74+
/// Format: `compact(length) | 0x04 | call_data`
75+
///
76+
/// Signed extensions (era, nonce, tip) are NOT included in the unsigned
77+
/// extrinsic body. They belong only in the signing payload, which is
78+
/// computed separately by `signable_payload()` via subxt-core.
6779
///
68-
/// Iterates the chain's signed extension list from metadata and encodes each:
69-
/// - Empty types (0-size composites/tuples): skip
70-
/// - CheckMortality: era bytes
71-
/// - CheckNonce: Compact<u32>
72-
/// - ChargeTransactionPayment: Compact<u128> tip
73-
/// - ChargeAssetTxPayment: Compact<u128> tip + 0x00 (None asset_id)
74-
/// - CheckMetadataHash: 0x00 (Disabled mode)
75-
/// - Other non-empty types: encode default bytes using scale_decode to determine size
80+
/// This matches the format that polkadot-js, txwrapper, and all standard
81+
/// Substrate tools expect for unsigned extrinsics.
7682
fn build_unsigned_extrinsic(
7783
call_data: &[u8],
78-
era: &Era,
79-
nonce: u32,
80-
tip: u128,
81-
metadata: &Metadata,
84+
_era: &Era,
85+
_nonce: u32,
86+
_tip: u128,
87+
_metadata: &Metadata,
8288
) -> Result<Vec<u8>, WasmDotError> {
8389
let mut body = Vec::new();
8490

8591
// Version byte: 0x04 = unsigned, version 4
8692
body.push(0x04);
8793

88-
// Encode signed extensions per metadata
89-
for ext in metadata.extrinsic().signed_extensions() {
90-
let id = ext.identifier();
91-
let ty_id = ext.extra_ty();
92-
93-
if is_empty_type(metadata, ty_id) {
94-
continue;
95-
}
96-
97-
match id {
98-
"CheckMortality" | "CheckEra" => {
99-
body.extend_from_slice(&encode_era(era));
100-
}
101-
"CheckNonce" => {
102-
Compact(nonce).encode_to(&mut body);
103-
}
104-
"ChargeTransactionPayment" => {
105-
Compact(tip).encode_to(&mut body);
106-
}
107-
"ChargeAssetTxPayment" => {
108-
// Struct: { tip: Compact<u128>, asset_id: Option<T> }
109-
Compact(tip).encode_to(&mut body);
110-
body.push(0x00); // None — no asset_id
111-
}
112-
"CheckMetadataHash" => {
113-
// Mode enum: 0x00 = Disabled
114-
body.push(0x00);
115-
}
116-
_ => {
117-
// Unknown non-empty extension — encode zero bytes.
118-
// This shouldn't happen for known chains but is a safety fallback.
119-
encode_zero_value(&mut body, ty_id, metadata)?;
120-
}
121-
}
122-
}
123-
124-
// Call data
94+
// Call data immediately after version byte
12595
body.extend_from_slice(call_data);
12696

12797
// Length prefix (compact encoded)
@@ -131,70 +101,6 @@ fn build_unsigned_extrinsic(
131101
Ok(result)
132102
}
133103

134-
/// Check if a type ID resolves to an empty (zero-size) type.
135-
fn is_empty_type(metadata: &Metadata, ty_id: u32) -> bool {
136-
let Some(ty) = metadata.types().resolve(ty_id) else {
137-
return false;
138-
};
139-
match &ty.type_def {
140-
scale_info::TypeDef::Tuple(t) => t.fields.is_empty(),
141-
scale_info::TypeDef::Composite(c) => c.fields.is_empty(),
142-
_ => false,
143-
}
144-
}
145-
146-
/// Encode the zero/default value for a type. Used for unknown signed extensions
147-
/// where we don't know the semantic meaning but need to produce valid SCALE bytes.
148-
fn encode_zero_value(
149-
buf: &mut Vec<u8>,
150-
ty_id: u32,
151-
metadata: &Metadata,
152-
) -> Result<(), WasmDotError> {
153-
let Some(ty) = metadata.types().resolve(ty_id) else {
154-
return Ok(()); // Unknown type — skip
155-
};
156-
match &ty.type_def {
157-
scale_info::TypeDef::Primitive(p) => {
158-
use scale_info::TypeDefPrimitive;
159-
let zeros: usize = match p {
160-
TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1,
161-
TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2,
162-
TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4,
163-
TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8,
164-
TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16,
165-
TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32,
166-
TypeDefPrimitive::Str | TypeDefPrimitive::Char => {
167-
buf.push(0x00); // empty compact-encoded string/char
168-
return Ok(());
169-
}
170-
};
171-
buf.extend_from_slice(&vec![0u8; zeros]);
172-
}
173-
scale_info::TypeDef::Compact(_) => {
174-
buf.push(0x00); // Compact(0)
175-
}
176-
scale_info::TypeDef::Variant(v) => {
177-
// Use first variant (index 0 or lowest)
178-
if let Some(variant) = v.variants.first() {
179-
buf.push(variant.index);
180-
for field in &variant.fields {
181-
encode_zero_value(buf, field.ty.id, metadata)?;
182-
}
183-
}
184-
}
185-
scale_info::TypeDef::Composite(c) => {
186-
for field in &c.fields {
187-
encode_zero_value(buf, field.ty.id, metadata)?;
188-
}
189-
}
190-
scale_info::TypeDef::Sequence(_) | scale_info::TypeDef::Array(_) => {
191-
buf.push(0x00); // empty sequence
192-
}
193-
_ => {} // BitSequence, etc. — skip
194-
}
195-
Ok(())
196-
}
197-
198104
#[cfg(test)]
199105
mod tests {
200106
// Tests require real metadata - will be added with test fixtures

packages/wasm-dot/src/transaction.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -555,13 +555,11 @@ fn parse_extrinsic(
555555

556556
Ok((true, signer, signature, era, nonce, tip, call_data))
557557
} else {
558-
// Unsigned extrinsic: same extension layout as signed, minus signer/signature.
559-
let (era, nonce, tip, ext_size) = parse_signed_extensions(&bytes[cursor..], metadata)?;
560-
cursor += ext_size;
561-
562-
// Remaining bytes are call data
558+
// Unsigned extrinsic: standard Substrate V4 format has call data
559+
// immediately after the version byte (no signed extensions in body).
560+
// Era, nonce, and tip are only in the signing payload, not the extrinsic.
563561
let call_data = bytes[cursor..].to_vec();
564-
Ok((false, None, None, era, nonce, tip, call_data))
562+
Ok((false, None, None, Era::Immortal, 0, 0, call_data))
565563
}
566564
}
567565

0 commit comments

Comments
 (0)