Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/wasm-dot/js/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface Material {
export interface Validity {
/** Block number when transaction becomes valid */
firstValid: number;
/** Maximum duration in blocks (default: 2400, ~4 hours) */
/** Maximum duration in blocks (0 for immortal, 4..=65,536 for mortal; default: 2400) */
maxDuration: number;
}

Expand Down
26 changes: 4 additions & 22 deletions packages/wasm-dot/src/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ mod calls;
pub mod types;

use crate::error::WasmDotError;
use crate::transaction::Transaction;
use crate::types::{Era, Validity};
use crate::transaction::{compute_era, Transaction};
use calls::encode_intent;
use types::{BuildContext, TransactionIntent};

Expand All @@ -22,15 +21,15 @@ pub fn build_transaction(
intent: TransactionIntent,
context: BuildContext,
) -> Result<Transaction, WasmDotError> {
// Validate and normalize validity before any transaction construction.
let era = compute_era(&context.validity)?;

// Decode metadata once
let metadata = decode_metadata(&context.material.metadata)?;

// Compose intent into calls and encode (batching if needed)
let call_data = encode_intent(&intent, &context.sender, &metadata)?;

// Calculate era from validity
let era = compute_era(&context.validity);

// Create transaction directly from components (no extrinsic encoding needed).
// to_bytes() on unsigned transactions returns signable_payload(), which is the
// signing payload format: call_data | era | nonce | tip | extensions | additional_signed.
Expand All @@ -42,20 +41,3 @@ pub fn build_transaction(

// Re-use the central decode_metadata from transaction.rs
use crate::transaction::decode_metadata;

/// Compute era from validity window
fn compute_era(validity: &Validity) -> Era {
if validity.max_duration == 0 {
Era::Immortal
} else {
let period = validity.max_duration.next_power_of_two().clamp(4, 65536);
let phase = validity.first_valid % period;
Era::Mortal { period, phase }
}
}

#[cfg(test)]
mod tests {
// Tests require real metadata - will be added with test fixtures
// For now, unit tests are in calls.rs for the encoding logic
}
169 changes: 129 additions & 40 deletions packages/wasm-dot/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
#[derive(Debug, Clone)]
pub struct TransactionContext {
pub material: Material,
pub validity: Validity,
first_valid: u32,
pub reference_block: [u8; 32],
/// Decoded metadata (cached for performance)
metadata: Option<Metadata>,
Expand Down Expand Up @@ -103,6 +103,7 @@
/// Returns the params type expected by `tx::create_partial_signed`.
fn to_extrinsic_params(
&self,
era: &Era,
nonce: u32,
tip: u128,
) -> <<PolkadotConfig as Config>::ExtrinsicParams as ExtrinsicParams<PolkadotConfig>>::Params
Expand All @@ -111,20 +112,17 @@
.nonce(nonce as u64)
.tip(tip);

// Set mortality - default is immortal if max_duration is 0
if self.validity.max_duration == 0 {
// Immortal - just build with defaults (no mortal call)
builder.build()
} else {
// Mortal transaction
// mortal_unchecked(from_block_number, from_block_hash, for_n_blocks)
builder
// The transaction's normalized era is the source of truth for signing.
// Keep its period aligned with the first-valid block and reference hash.
match era {
Era::Immortal => builder.build(),
Era::Mortal { period, .. } => builder
.mortal_unchecked(
self.validity.first_valid as u64,
self.first_valid as u64,
H256::from(self.reference_block),
self.validity.max_duration as u64,
*period as u64,
)
.build()
.build(),
}
}
}
Expand Down Expand Up @@ -170,7 +168,7 @@

let tx_context = context.map(|ctx| TransactionContext {
material: ctx.material,
validity: Validity::default(),
first_valid: 0,
reference_block: [0u8; 32], // Unknown from bytes alone
metadata: None,
});
Expand All @@ -196,7 +194,7 @@
// Use subxt-core to create signed extrinsic if we have context
if let Some(ref ctx) = self.context {
let client_state = ctx.to_client_state()?;
let params = ctx.to_extrinsic_params(self.nonce, self.tip);
let params = ctx.to_extrinsic_params(&self.era, self.nonce, self.tip);

// Create payload from pre-encoded call data
let call = PreEncodedPayload(self.call_data.clone());
Expand Down Expand Up @@ -317,7 +315,7 @@
.ok_or_else(|| WasmDotError::MissingContext("No context set for transaction".into()))?;

let client_state = context.to_client_state()?;
let params = context.to_extrinsic_params(self.nonce, self.tip);
let params = context.to_extrinsic_params(&self.era, self.nonce, self.tip);

// Create payload from pre-encoded call data
let call = PreEncodedPayload(self.call_data.clone());
Expand Down Expand Up @@ -407,10 +405,16 @@
validity: Validity,
reference_block: &str,
) -> Result<(), WasmDotError> {
if compute_era(&validity)? != self.era {
return Err(WasmDotError::InvalidTransaction(
"validity does not match transaction era".to_string(),
));
}

let block_hash = parse_hex_hash(reference_block)?;
self.context = Some(TransactionContext {
material,
validity,
first_valid: validity.first_valid,
reference_block: block_hash,
metadata: None,
});
Expand Down Expand Up @@ -465,17 +469,43 @@
Ok(result)
}

/// Normalize a validity window into the single era used for inspection and signing.
pub(crate) fn compute_era(validity: &Validity) -> Result<Era, WasmDotError> {
if validity.max_duration == 0 {
return Ok(Era::Immortal);
}

if !(4..=65_536).contains(&validity.max_duration) {
return Err(WasmDotError::InvalidTransaction("invalid era".to_string()));
}

let period = validity
.max_duration
.checked_next_power_of_two()
.ok_or_else(|| WasmDotError::InvalidTransaction("invalid era".to_string()))?;
let SubxtEra::Mortal { period, phase } =
SubxtEra::mortal(period as u64, validity.first_valid as u64)
else {
return Err(WasmDotError::InvalidTransaction("invalid era".to_string()));
};

Ok(Era::Mortal {
period: period as u32,
phase: phase as u32,
})
}

/// Encode era using subxt-core's Era type
pub(crate) fn encode_era(era: &Era) -> Vec<u8> {
use parity_scale_codec::Encode;

match era {
Era::Immortal => SubxtEra::Immortal.encode(),
Era::Mortal { period, phase } => {
let period = (*period).next_power_of_two().clamp(4, 65536) as u64;
let phase = (*phase as u64) % period;
SubxtEra::Mortal { period, phase }.encode()
Era::Mortal { period, phase } => SubxtEra::Mortal {
period: *period as u64,
phase: *phase as u64,
}
.encode(),
}
}

Expand Down Expand Up @@ -709,7 +739,7 @@
let extensions = metadata.extrinsic().signed_extensions();
let mut cursor = 0;
let mut era = Era::Immortal;
let mut nonce: u32 = 0;

Check failure

Code scanning / CodeQL

Hard-coded cryptographic value Critical

This hard-coded value is used as
a nonce
.
This hard-coded value is used as
a nonce
.
let mut tip: u128 = 0;

for ext in extensions {
Expand Down Expand Up @@ -808,26 +838,32 @@

/// Decode era from bytes
fn decode_era_bytes(bytes: &[u8]) -> Result<(Era, usize), WasmDotError> {
use parity_scale_codec::Decode;

if bytes.is_empty() {
return Err(WasmDotError::ScaleDecodeError(
"Empty era encoding".to_string(),
));
}

if bytes[0] == 0x00 {
Ok((Era::Immortal, 1))
} else {
if bytes.len() < 2 {
return Err(WasmDotError::ScaleDecodeError(
"Truncated mortal era".to_string(),
));
}
let encoded = u16::from_le_bytes([bytes[0], bytes[1]]);
let period = 2u32 << (encoded as u32 & 0xf);
let quantize_factor = (period / 4).max(1);
let phase = ((encoded >> 4) as u32) * quantize_factor;
Ok((Era::Mortal { period, phase }, 2))
if bytes[0] != 0x00 && bytes.len() < 2 {
return Err(WasmDotError::ScaleDecodeError(
"Truncated mortal era".to_string(),
));
}

let mut input = bytes;
let era = SubxtEra::decode(&mut input)?;
let consumed = bytes.len() - input.len();
let era = match era {
SubxtEra::Immortal => Era::Immortal,
SubxtEra::Mortal { period, phase } => Era::Mortal {
period: period as u32,
phase: phase as u32,
},
};

Ok((era, consumed))
}

#[cfg(test)]
Expand All @@ -841,12 +877,65 @@
let (decoded, _) = decode_era_bytes(&immortal_bytes).unwrap();
assert!(decoded.is_immortal());

let mortal = Era::Mortal {
period: 64,
phase: 0,
};
let mortal_bytes = encode_era(&mortal);
let (decoded, _) = decode_era_bytes(&mortal_bytes).unwrap();
assert!(!decoded.is_immortal());
for mortal in [
Era::Mortal {
period: 64,
phase: 0,
},
Era::Mortal {
period: 4_096,
phase: 1_000,
},
Era::Mortal {
period: 65_536,
phase: 992,
},
] {
let mortal_bytes = encode_era(&mortal);
let (decoded, consumed) = decode_era_bytes(&mortal_bytes).unwrap();
assert_eq!(decoded, mortal);
assert_eq!(consumed, mortal_bytes.len());
}
}

#[test]
fn test_compute_era_accepts_supported_boundaries() {
assert_eq!(
compute_era(&Validity {
first_valid: 1000,
max_duration: 0,
})
.unwrap(),
Era::Immortal
);

for (max_duration, period, phase) in [
(4, 4, 0),
(5, 8, 0),
(2_400, 4_096, 1_000),
(65_536, 65_536, 992),
] {
assert_eq!(
compute_era(&Validity {
first_valid: 1000,
max_duration,
})
.unwrap(),
Era::Mortal { period, phase },
"max_duration={max_duration}"
);
}
}

#[test]
fn test_compute_era_rejects_unsupported_durations_without_panicking() {
for max_duration in [1, 2, 3, 65_537, 1 << 31, (1 << 31) + 1, u32::MAX] {
let error = compute_era(&Validity {
first_valid: 1000,
max_duration,
})
.unwrap_err();
assert_eq!(error.to_string(), "Invalid transaction: invalid era");
}
}
}
2 changes: 1 addition & 1 deletion packages/wasm-dot/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub struct Material {
pub struct Validity {
/// Block number when transaction becomes valid
pub first_valid: u32,
/// Maximum duration in blocks (default: 2400, ~4 hours)
/// Maximum duration in blocks (0 for immortal, 4..=65,536 for mortal; default: 2400)
#[serde(default = "default_max_duration")]
pub max_duration: u32,
}
Expand Down
Loading
Loading