From 970661de8aa97e9c5e07d6e34029d1c45b311390 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 24 Sep 2026 01:07:43 +0000 Subject: [PATCH 1/4] fix(wasm-dot): normalize DOT mortality era Reject unsupported durations and derive inspected and signed mortality from the same checked era. This prevents overflowed contexts from presenting a short expiry while producing a longer signed lifetime. Ticket: WCN-1895 Session-Id: 0f2b8446-5dbb-4890-b028-cb4bc6308053 Task-Id: a2ce8264-e5be-4cb6-bb8b-d0e02e82160a --- packages/wasm-dot/js/types.ts | 2 +- packages/wasm-dot/src/builder/mod.rs | 24 +----- packages/wasm-dot/src/transaction.rs | 111 ++++++++++++++++++++++----- packages/wasm-dot/src/types.rs | 2 +- packages/wasm-dot/test/builder.ts | 64 ++++++++++++++- 5 files changed, 160 insertions(+), 43 deletions(-) diff --git a/packages/wasm-dot/js/types.ts b/packages/wasm-dot/js/types.ts index 149b093413a..b6922f4797a 100644 --- a/packages/wasm-dot/js/types.ts +++ b/packages/wasm-dot/js/types.ts @@ -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; } diff --git a/packages/wasm-dot/src/builder/mod.rs b/packages/wasm-dot/src/builder/mod.rs index 0d92300030a..33224d9e2d5 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::Transaction; +use crate::transaction::{compute_era, Transaction}; use crate::types::{Era, Validity}; use calls::encode_intent; use types::{BuildContext, TransactionIntent}; @@ -22,15 +22,15 @@ pub fn build_transaction( intent: TransactionIntent, context: BuildContext, ) -> Result { + // 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. @@ -43,19 +43,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 -} diff --git a/packages/wasm-dot/src/transaction.rs b/packages/wasm-dot/src/transaction.rs index ee215373039..3ee2967096e 100644 --- a/packages/wasm-dot/src/transaction.rs +++ b/packages/wasm-dot/src/transaction.rs @@ -68,7 +68,7 @@ pub struct Transaction { #[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, @@ -103,6 +103,7 @@ impl TransactionContext { /// Returns the params type expected by `tx::create_partial_signed`. fn to_extrinsic_params( &self, + era: &Era, nonce: u32, tip: u128, ) -> <::ExtrinsicParams as ExtrinsicParams>::Params @@ -111,20 +112,17 @@ impl TransactionContext { .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(), } } } @@ -170,7 +168,7 @@ impl Transaction { 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, }); @@ -196,7 +194,7 @@ impl Transaction { // 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()); @@ -317,7 +315,7 @@ impl Transaction { .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()); @@ -407,10 +405,16 @@ impl Transaction { 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, }); @@ -465,17 +469,43 @@ fn parse_hex_hash(hex_str: &str) -> Result<[u8; 32], WasmDotError> { Ok(result) } +/// Normalize a validity window into the single era used for inspection and signing. +pub(crate) fn compute_era(validity: &Validity) -> Result { + 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 { 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(), } } @@ -849,4 +879,45 @@ mod tests { let (decoded, _) = decode_era_bytes(&mortal_bytes).unwrap(); assert!(!decoded.is_immortal()); } + + #[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"); + } + } } diff --git a/packages/wasm-dot/src/types.rs b/packages/wasm-dot/src/types.rs index caac6bfaf53..2d4ea4d9955 100644 --- a/packages/wasm-dot/src/types.rs +++ b/packages/wasm-dot/src/types.rs @@ -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, } diff --git a/packages/wasm-dot/test/builder.ts b/packages/wasm-dot/test/builder.ts index 34aa1ad2b93..efb38347164 100644 --- a/packages/wasm-dot/test/builder.ts +++ b/packages/wasm-dot/test/builder.ts @@ -1,5 +1,10 @@ import * as assert from "assert"; -import { buildTransaction, type TransactionIntent, type BuildContext } from "../js/index.js"; +import { + buildTransaction, + DotTransaction, + type TransactionIntent, + type BuildContext, +} from "../js/index.js"; import { getWestendMetadata } from "./resources/westend.js"; /** Convert Uint8Array to hex string (no 0x prefix) */ @@ -36,6 +41,63 @@ describe("buildTransaction", () => { referenceBlock: REFERENCE_BLOCK, }); + describe("mortality era", () => { + const intent: TransactionIntent = { + type: "payment", + to: RECIPIENT, + amount: 1000000000000n, + }; + + it("uses one normalized era for inspection, payload, and signed bytes", () => { + for (const [maxDuration, expected] of [ + [0, { type: "immortal" }], + [4, { type: "mortal", period: 4, phase: 0 }], + [5, { type: "mortal", period: 8, phase: 0 }], + [2400, { type: "mortal", period: 4096, phase: 1000 }], + [65536, { type: "mortal", period: 65536, phase: 992 }], + ] as const) { + const context = testContext(); + context.validity.maxDuration = maxDuration; + const tx = buildTransaction(intent, context); + + assert.deepStrictEqual(tx.era, expected); + assert.deepStrictEqual( + DotTransaction.fromBytes(tx.signablePayload(), WESTEND_MATERIAL).era, + expected, + `signable payload for maxDuration=${maxDuration}`, + ); + + tx.addSignature(new Uint8Array(64), new Uint8Array(32)); + assert.deepStrictEqual( + DotTransaction.fromBytes(tx.toBytes(), WESTEND_MATERIAL).era, + expected, + `signed extrinsic for maxDuration=${maxDuration}`, + ); + } + }); + + it("rejects unsupported durations with an invalid-era error", () => { + for (const maxDuration of [1, 2, 3, 65537, 2 ** 31, 2 ** 31 + 1, 0xffffffff]) { + const context = testContext(); + context.validity.maxDuration = maxDuration; + + assert.throws( + () => buildTransaction(intent, context), + /invalid transaction: invalid era/i, + `maxDuration=${maxDuration}`, + ); + } + }); + + it("rejects signing context with a different normalized era", () => { + const tx = buildTransaction(intent, testContext()); + assert.throws( + () => tx.setContext(WESTEND_MATERIAL, { firstValid: 1000, maxDuration: 65536 }, REFERENCE_BLOCK), + /validity does not match transaction era/i, + ); + }); + }); + describe("payment", () => { it("should build a payment transaction (transferKeepAlive)", () => { const intent: TransactionIntent = { From 5e1c382f0ca472ea60b889ea131cf89cec11a62a Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 24 Sep 2026 01:08:31 +0000 Subject: [PATCH 2/4] fix(wasm-dot): clean up era regression patch Drop builder imports made obsolete by moving era normalization into transaction.rs and format the new regression assertion. This keeps the focused patch warning-free and aligned with repository style. Ticket: WCN-1895 Session-Id: 0f2b8446-5dbb-4890-b028-cb4bc6308053 Task-Id: a2ce8264-e5be-4cb6-bb8b-d0e02e82160a --- packages/wasm-dot/src/builder/mod.rs | 1 - packages/wasm-dot/test/builder.ts | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/wasm-dot/src/builder/mod.rs b/packages/wasm-dot/src/builder/mod.rs index 33224d9e2d5..233cc28cfcb 100644 --- a/packages/wasm-dot/src/builder/mod.rs +++ b/packages/wasm-dot/src/builder/mod.rs @@ -9,7 +9,6 @@ pub mod types; use crate::error::WasmDotError; use crate::transaction::{compute_era, Transaction}; -use crate::types::{Era, Validity}; use calls::encode_intent; use types::{BuildContext, TransactionIntent}; diff --git a/packages/wasm-dot/test/builder.ts b/packages/wasm-dot/test/builder.ts index efb38347164..9d348bebb49 100644 --- a/packages/wasm-dot/test/builder.ts +++ b/packages/wasm-dot/test/builder.ts @@ -92,7 +92,12 @@ describe("buildTransaction", () => { it("rejects signing context with a different normalized era", () => { const tx = buildTransaction(intent, testContext()); assert.throws( - () => tx.setContext(WESTEND_MATERIAL, { firstValid: 1000, maxDuration: 65536 }, REFERENCE_BLOCK), + () => + tx.setContext( + WESTEND_MATERIAL, + { firstValid: 1000, maxDuration: 65536 }, + REFERENCE_BLOCK, + ), /validity does not match transaction era/i, ); }); From 72700ad3ff195ece4c2700010286c1eac51001ba Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 24 Sep 2026 01:09:07 +0000 Subject: [PATCH 3/4] style(wasm-dot): remove trailing blank line Remove the extra end-of-file blank line left after moving era normalization out of the builder. This keeps the patch clean under the repository's whitespace check. Ticket: WCN-1895 Session-Id: 0f2b8446-5dbb-4890-b028-cb4bc6308053 Task-Id: a2ce8264-e5be-4cb6-bb8b-d0e02e82160a --- packages/wasm-dot/src/builder/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/wasm-dot/src/builder/mod.rs b/packages/wasm-dot/src/builder/mod.rs index 233cc28cfcb..0b9d3ff7047 100644 --- a/packages/wasm-dot/src/builder/mod.rs +++ b/packages/wasm-dot/src/builder/mod.rs @@ -41,4 +41,3 @@ pub fn build_transaction( // Re-use the central decode_metadata from transaction.rs use crate::transaction::decode_metadata; - From 7350a4b7996b3e66277e6f749a92f0216d3c655b Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Thu, 24 Sep 2026 01:11:04 +0000 Subject: [PATCH 4/4] fix(wasm-dot): decode canonical mortal era phases Decode mortality extensions through Subxt's Era implementation and round-trip phases at periods 4,096 and 65,536. This keeps inspection aligned with the quantized era used in signing. Ticket: WCN-1895 Session-Id: 0f2b8446-5dbb-4890-b028-cb4bc6308053 Task-Id: a2ce8264-e5be-4cb6-bb8b-d0e02e82160a --- packages/wasm-dot/src/transaction.rs | 58 ++++++++++++++++++---------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/wasm-dot/src/transaction.rs b/packages/wasm-dot/src/transaction.rs index 3ee2967096e..087229f0b36 100644 --- a/packages/wasm-dot/src/transaction.rs +++ b/packages/wasm-dot/src/transaction.rs @@ -838,26 +838,32 @@ fn skip_type_bytes(bytes: &[u8], ty_id: u32, metadata: &Metadata) -> Result 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)] @@ -871,13 +877,25 @@ mod tests { 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]