Skip to content

Commit 2a6ec45

Browse files
authored
Merge pull request #220 from BitGo/BTC-3161/fix-unsigned-extrinsic-format
fix: return signable_payload from to_bytes for unsigned transactions
2 parents b68c7fb + 9872edf commit 2a6ec45

2 files changed

Lines changed: 167 additions & 151 deletions

File tree

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

Lines changed: 4 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ use crate::error::WasmDotError;
1111
use crate::transaction::Transaction;
1212
use crate::types::{Era, Validity};
1313
use calls::encode_intent;
14-
use parity_scale_codec::{Compact, Encode};
15-
use subxt_core::metadata::Metadata;
1614
use types::{BuildContext, TransactionIntent};
1715

1816
/// Build a transaction from a business-level intent and context.
@@ -33,25 +31,12 @@ pub fn build_transaction(
3331
// Calculate era from validity
3432
let era = compute_era(&context.validity);
3533

36-
// Build unsigned extrinsic: compact(length) | 0x04 | call_data
37-
let unsigned_bytes = build_unsigned_extrinsic(
38-
&call_data,
39-
&era,
40-
context.nonce,
41-
context.tip as u128,
42-
&metadata,
43-
)?;
44-
45-
// Create transaction from bytes — pass metadata so parser uses metadata-aware decoding
46-
let mut tx = Transaction::from_bytes(&unsigned_bytes, None, Some(&metadata))?;
34+
// Create transaction directly from components (no extrinsic encoding needed).
35+
// to_bytes() on unsigned transactions returns signable_payload(), which is the
36+
// signing payload format: call_data | era | nonce | tip | extensions | additional_signed.
37+
let mut tx = Transaction::new(call_data, era, context.nonce, context.tip as u128);
4738
tx.set_context(context.material, context.validity, &context.reference_block)?;
4839

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-
5540
Ok(tx)
5641
}
5742

@@ -69,38 +54,6 @@ fn compute_era(validity: &Validity) -> Era {
6954
}
7055
}
7156

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.
79-
///
80-
/// This matches the format that polkadot-js, txwrapper, and all standard
81-
/// Substrate tools expect for unsigned extrinsics.
82-
fn build_unsigned_extrinsic(
83-
call_data: &[u8],
84-
_era: &Era,
85-
_nonce: u32,
86-
_tip: u128,
87-
_metadata: &Metadata,
88-
) -> Result<Vec<u8>, WasmDotError> {
89-
let mut body = Vec::new();
90-
91-
// Version byte: 0x04 = unsigned, version 4
92-
body.push(0x04);
93-
94-
// Call data immediately after version byte
95-
body.extend_from_slice(call_data);
96-
97-
// Length prefix (compact encoded)
98-
let mut result = Compact(body.len() as u32).encode();
99-
result.extend_from_slice(&body);
100-
101-
Ok(result)
102-
}
103-
10457
#[cfg(test)]
10558
mod tests {
10659
// Tests require real metadata - will be added with test fixtures

packages/wasm-dot/src/transaction.rs

Lines changed: 163 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ impl TransactionContext {
130130
}
131131

132132
impl Transaction {
133+
/// Create a new unsigned transaction from components.
134+
///
135+
/// Used by the builder when constructing transactions from intents.
136+
/// No raw extrinsic bytes are stored; `to_bytes()` returns `signable_payload()`.
137+
pub fn new(call_data: Vec<u8>, era: Era, nonce: u32, tip: u128) -> Self {
138+
Transaction {
139+
raw_bytes: Vec::new(),
140+
is_signed: false,
141+
signer: None,
142+
signature: None,
143+
era,
144+
nonce,
145+
tip,
146+
call_data,
147+
context: None,
148+
}
149+
}
150+
133151
/// Create a transaction from raw bytes
134152
///
135153
/// # Arguments
@@ -202,8 +220,9 @@ impl Transaction {
202220
// Fall back to manual serialization if no context
203221
self.to_bytes_manual()
204222
} else {
205-
// Unsigned: return raw bytes (preserves metadata-encoded extensions from builder)
206-
Ok(self.raw_bytes.clone())
223+
// Unsigned: return the signing payload (call_data + extensions + additional_signed).
224+
// This matches legacy toBroadcastFormat which returns construct.signingPayload().
225+
self.signable_payload()
207226
}
208227
}
209228

@@ -263,7 +282,12 @@ impl Transaction {
263282
let bytes = if self.is_signed && self.signature.is_some() {
264283
self.to_bytes().ok()?
265284
} else {
266-
self.raw_bytes.clone()
285+
// For unsigned, use signable_payload if context exists, otherwise raw_bytes
286+
if self.context.is_some() {
287+
self.signable_payload().ok()?
288+
} else {
289+
self.raw_bytes.clone()
290+
}
267291
};
268292

269293
if bytes.is_empty() {
@@ -460,107 +484,165 @@ type ParsedExtrinsic = (
460484
Vec<u8>,
461485
);
462486

463-
/// Parse a raw extrinsic
487+
/// Parse a raw extrinsic or signing payload.
488+
///
489+
/// Detects three formats:
490+
/// - **Signed extrinsic**: compact(length) | 0x84 | signer | signature | extensions | call_data
491+
/// - **Unsigned extrinsic (V4)**: compact(length) | 0x04 | call_data
492+
/// - **Signing payload**: call_data | era | nonce | tip | extensions | additional_signed
493+
/// (no length prefix, no version byte — produced by `signable_payload()`)
464494
fn parse_extrinsic(
465495
bytes: &[u8],
466496
metadata: Option<&Metadata>,
467497
) -> Result<ParsedExtrinsic, WasmDotError> {
468-
use parity_scale_codec::{Compact, Decode};
469-
470-
let mut cursor = 0;
498+
// Try to detect the format by decoding compact length + version byte.
499+
// If it looks like a standard extrinsic, parse it that way.
500+
// Otherwise, treat as signing payload.
501+
if let Some(result) = try_parse_extrinsic_format(bytes, metadata)? {
502+
return Ok(result);
503+
}
471504

472-
// Decode length prefix (compact)
473-
let mut input = &bytes[cursor..];
474-
let length = <Compact<u32>>::decode(&mut input)
475-
.map_err(|e| WasmDotError::InvalidTransaction(format!("Invalid length: {}", e)))?;
476-
cursor = bytes.len() - input.len();
505+
// Not a standard extrinsic format — try parsing as signing payload.
506+
// Signing payload format: call_data | signed_extensions | additional_signed
507+
// Requires metadata to determine where call_data ends.
508+
parse_signing_payload(bytes, metadata)
509+
}
477510

478-
let _extrinsic_length = length.0 as usize;
511+
/// Try to parse bytes as a standard extrinsic (compact length + version byte).
512+
/// Returns None if the bytes don't look like an extrinsic.
513+
fn try_parse_extrinsic_format(
514+
bytes: &[u8],
515+
metadata: Option<&Metadata>,
516+
) -> Result<Option<ParsedExtrinsic>, WasmDotError> {
517+
use parity_scale_codec::{Compact, Decode};
479518

480-
// Version byte
481-
if cursor >= bytes.len() {
519+
if bytes.is_empty() {
482520
return Err(WasmDotError::InvalidTransaction(
483-
"Missing version byte".to_string(),
521+
"Empty transaction".to_string(),
484522
));
485523
}
486-
let version = bytes[cursor];
487-
cursor += 1;
488524

489-
let is_signed = (version & 0x80) != 0;
490-
let _extrinsic_version = version & 0x7f;
525+
// Try decoding compact length prefix
526+
let mut input = bytes;
527+
let length = match <Compact<u32>>::decode(&mut input) {
528+
Ok(l) => l,
529+
Err(_) => return Ok(None), // Can't decode compact — not extrinsic format
530+
};
531+
let prefix_size = bytes.len() - input.len();
491532

492-
if is_signed {
493-
// Parse signed extrinsic
533+
// Check if compact length matches remaining bytes
534+
let remaining = bytes.len() - prefix_size;
535+
if length.0 as usize != remaining {
536+
return Ok(None); // Length doesn't match — not extrinsic format
537+
}
494538

495-
// Signer (MultiAddress)
496-
if cursor >= bytes.len() {
497-
return Err(WasmDotError::InvalidTransaction(
498-
"Missing signer".to_string(),
499-
));
500-
}
501-
let address_type = bytes[cursor];
502-
cursor += 1;
539+
// Check version byte
540+
if prefix_size >= bytes.len() {
541+
return Ok(None);
542+
}
543+
let version = bytes[prefix_size];
544+
545+
match version {
546+
0x84 => {
547+
// Signed extrinsic
548+
let mut cursor = prefix_size + 1;
503549

504-
let signer = if address_type == 0x00 {
505-
// Id variant - 32 byte account id
506-
if cursor + 32 > bytes.len() {
550+
// Signer (MultiAddress)
551+
if cursor >= bytes.len() {
507552
return Err(WasmDotError::InvalidTransaction(
508-
"Truncated signer".to_string(),
553+
"Missing signer".to_string(),
509554
));
510555
}
511-
let mut pk = [0u8; 32];
512-
pk.copy_from_slice(&bytes[cursor..cursor + 32]);
513-
cursor += 32;
514-
Some(pk)
515-
} else {
516-
return Err(WasmDotError::InvalidTransaction(format!(
517-
"Unsupported address type: {}",
518-
address_type
519-
)));
520-
};
521-
522-
// Signature (MultiSignature)
523-
if cursor >= bytes.len() {
524-
return Err(WasmDotError::InvalidTransaction(
525-
"Missing signature".to_string(),
526-
));
527-
}
528-
let sig_type = bytes[cursor];
529-
cursor += 1;
530-
531-
let signature = if sig_type == 0x00 {
532-
// Ed25519 - 64 bytes
533-
if cursor + 64 > bytes.len() {
556+
let address_type = bytes[cursor];
557+
cursor += 1;
558+
559+
let signer = if address_type == 0x00 {
560+
if cursor + 32 > bytes.len() {
561+
return Err(WasmDotError::InvalidTransaction(
562+
"Truncated signer".to_string(),
563+
));
564+
}
565+
let mut pk = [0u8; 32];
566+
pk.copy_from_slice(&bytes[cursor..cursor + 32]);
567+
cursor += 32;
568+
Some(pk)
569+
} else {
570+
return Err(WasmDotError::InvalidTransaction(format!(
571+
"Unsupported address type: {}",
572+
address_type
573+
)));
574+
};
575+
576+
// Signature (MultiSignature)
577+
if cursor >= bytes.len() {
534578
return Err(WasmDotError::InvalidTransaction(
535-
"Truncated signature".to_string(),
579+
"Missing signature".to_string(),
536580
));
537581
}
538-
let mut sig = [0u8; 64];
539-
sig.copy_from_slice(&bytes[cursor..cursor + 64]);
540-
cursor += 64;
541-
Some(sig)
542-
} else {
543-
return Err(WasmDotError::InvalidTransaction(format!(
544-
"Unsupported signature type: {}",
545-
sig_type
546-
)));
547-
};
582+
let sig_type = bytes[cursor];
583+
cursor += 1;
584+
585+
let signature = if sig_type == 0x00 {
586+
if cursor + 64 > bytes.len() {
587+
return Err(WasmDotError::InvalidTransaction(
588+
"Truncated signature".to_string(),
589+
));
590+
}
591+
let mut sig = [0u8; 64];
592+
sig.copy_from_slice(&bytes[cursor..cursor + 64]);
593+
cursor += 64;
594+
Some(sig)
595+
} else {
596+
return Err(WasmDotError::InvalidTransaction(format!(
597+
"Unsupported signature type: {}",
598+
sig_type
599+
)));
600+
};
601+
602+
// Parse signed extensions
603+
let (era, nonce, tip, ext_size) = parse_signed_extensions(&bytes[cursor..], metadata)?;
604+
cursor += ext_size;
605+
606+
// Remaining bytes are call data
607+
let call_data = bytes[cursor..].to_vec();
608+
609+
Ok(Some((true, signer, signature, era, nonce, tip, call_data)))
610+
}
611+
_ => {
612+
// Not a signed extrinsic — fall through to signing payload parser
613+
Ok(None)
614+
}
615+
}
616+
}
548617

549-
// Parse signed extensions (era, nonce, tip, and any extras)
550-
let (era, nonce, tip, ext_size) = parse_signed_extensions(&bytes[cursor..], metadata)?;
551-
cursor += ext_size;
618+
/// Parse bytes as a signing payload.
619+
///
620+
/// Format: call_data | signed_extensions (era, nonce, tip, ...) | additional_signed (spec, tx_ver, genesis, block_hash)
621+
///
622+
/// Requires metadata to determine where call_data ends (via the RuntimeCall type).
623+
fn parse_signing_payload(
624+
bytes: &[u8],
625+
metadata: Option<&Metadata>,
626+
) -> Result<ParsedExtrinsic, WasmDotError> {
627+
let md = metadata.ok_or_else(|| {
628+
WasmDotError::InvalidTransaction(
629+
"Metadata required to parse signing payload format".to_string(),
630+
)
631+
})?;
552632

553-
// Remaining bytes are call data
554-
let call_data = bytes[cursor..].to_vec();
633+
// Use the RuntimeCall type from metadata to skip over call_data
634+
let call_ty_id = md.outer_enums().call_enum_ty();
635+
let call_data_size = skip_type_bytes(bytes, call_ty_id, md)?;
636+
let call_data = bytes[..call_data_size].to_vec();
555637

556-
Ok((true, signer, signature, era, nonce, tip, call_data))
557-
} else {
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.
561-
let call_data = bytes[cursor..].to_vec();
562-
Ok((false, None, None, Era::Immortal, 0, 0, call_data))
563-
}
638+
// Parse signed extensions after call_data
639+
let ext_bytes = &bytes[call_data_size..];
640+
let (era, nonce, tip, _ext_size) = parse_signed_extensions(ext_bytes, Some(md))?;
641+
642+
// Remaining bytes after extensions are additional_signed (spec_version, tx_version,
643+
// genesis_hash, block_hash) — we don't need to parse those.
644+
645+
Ok((false, None, None, era, nonce, tip, call_data))
564646
}
565647

566648
/// Parse signed extensions from extrinsic bytes.
@@ -740,25 +822,6 @@ fn decode_era_bytes(bytes: &[u8]) -> Result<(Era, usize), WasmDotError> {
740822
mod tests {
741823
use super::*;
742824

743-
#[test]
744-
fn test_unsigned_tx_id_returns_blake2b_hash() {
745-
// Minimal unsigned extrinsic: compact length + version 0x04 + era(immortal) + nonce(0) + tip(0) + call_data
746-
// length=6 (compact 0x18), version=0x04, era=0x00, nonce=0x00, tip=0x00, call=0xFF
747-
let raw = vec![0x18, 0x04, 0x00, 0x00, 0x00, 0xFF];
748-
let tx = Transaction::from_bytes(&raw, None, None).unwrap();
749-
750-
assert!(!tx.is_signed());
751-
let id = tx.id();
752-
assert!(id.is_some(), "unsigned tx should have an id");
753-
let id = id.unwrap();
754-
assert!(id.starts_with("0x"), "id should be 0x-prefixed hex");
755-
assert_eq!(id.len(), 66, "blake2b-256 hash = 0x + 64 hex chars");
756-
757-
// Same bytes should produce the same hash
758-
let tx2 = Transaction::from_bytes(&raw, None, None).unwrap();
759-
assert_eq!(tx.id(), tx2.id(), "same bytes should produce same id");
760-
}
761-
762825
#[test]
763826
fn test_era_encoding_roundtrip() {
764827
let immortal = Era::Immortal;

0 commit comments

Comments
 (0)