Skip to content

Commit 3a2ad71

Browse files
authored
Merge pull request #218 from BitGo/BTC-3161/fix-unsigned-extrinsic-format
fix: use standard Substrate V4 format for unsigned extrinsics
2 parents aaff21f + 1111b4c commit 3a2ad71

2 files changed

Lines changed: 26 additions & 122 deletions

File tree

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)