From f1c51661df2d448db5bf06944b4cdb423f990e0d Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 15 Sep 2026 14:45:25 +0300 Subject: [PATCH] feat: accept pre-encoded extension values, validated by round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dapp hands a wallet an `era` it encoded itself, as opaque bytes. There is no way to render those as a variant without knowing the era algorithm, which is exactly the kind of knowledge this crate refuses to hold — so they are accepted as `Supplied::Raw`. Not on trust, though. Raw bytes are decoded against the type the runtime declares and re-encoded, and anything that does not come back identical is refused: a short read, trailing bytes, a non-canonical compact. Appending them unchecked would mean signing a payload whose shape nobody verified, and the only report of that is `BadProof` from a node — which is also what a wrong key looks like. Also makes a single-field struct transparent whether or not its field is named, so `CheckMetadataHash { mode }` takes `"Disabled"` the way `AccountId32([u8;32])` takes its hex. Both wrappers are the runtime's choice, and the registry is what says they are there. Refs #3 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- packages/quantus-codec/package.json | 2 +- packages/quantus-codec/src/codec.ts | 24 ++++++- packages/quantus-codec/src/rs/bindings.rs | 62 ++++++++++++---- packages/quantus-codec/src/rs/decode.rs | 2 +- packages/quantus-codec/src/rs/encode.rs | 63 +++++++++++++++-- packages/quantus-codec/src/rs/tests.rs | 86 ++++++++++++++++++++++- 6 files changed, 216 insertions(+), 23 deletions(-) diff --git a/packages/quantus-codec/package.json b/packages/quantus-codec/package.json index 31f236f0..8d58aa6f 100644 --- a/packages/quantus-codec/package.json +++ b/packages/quantus-codec/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.1.0", + "version": "0.2.0", "main": "index.js", "dependencies": { "fflate": "^0.8.2", diff --git a/packages/quantus-codec/src/codec.ts b/packages/quantus-codec/src/codec.ts index 5af0eb89..8611e251 100644 --- a/packages/quantus-codec/src/codec.ts +++ b/packages/quantus-codec/src/codec.ts @@ -14,10 +14,21 @@ export interface ExtensionNeed { needsAdditional: boolean; } -/** A value for one signed extension. Omit a half the runtime declares as empty. */ +/** + * A value for one signed extension. Omit a half the runtime declares as empty. + * + * Each half is either interpreted against the type the runtime declares + * (`extra`, `additional`) or supplied already SCALE-encoded (`extraHex`, + * `additionalHex`). Pre-encoded bytes are **validated by round trip**, not + * trusted: they are decoded against the declared type and re-encoded, and + * anything that does not come back identical is refused rather than signed. + * Setting both halves of a pair is a contradiction and is also refused. + */ export interface ExtensionValue { extra?: unknown; + extraHex?: string; additional?: unknown; + additionalHex?: string; } export type ExtensionValues = Record; @@ -43,6 +54,13 @@ export interface PayloadOptions { tip?: bigint | string; /** `'Immortal'`, or `{ MortalN: phase }` as the registry spells it. */ era?: unknown; + /** + * The era already SCALE-encoded — what a dapp hands a wallet, since it did the + * encoding itself and the wallet has no way to render those two bytes as a + * variant without knowing the era algorithm. Takes precedence over `era`, and + * is round-tripped against the runtime's own `Era` type before it is used. + */ + eraHex?: string; /** `CheckMetadataHash`: `null` disables it, which is what a wallet wants. */ metadataHash?: string | null; } @@ -165,7 +183,9 @@ export class Runtime { additional: options.metadataHash ? { Some: options.metadataHash } : 'None', extra: 'Disabled' }, - CheckMortality: { additional: options.blockHash, extra: options.era ?? 'Immortal' }, + CheckMortality: options.eraHex + ? { additional: options.blockHash, extraHex: options.eraHex } + : { additional: options.blockHash, extra: options.era ?? 'Immortal' }, CheckNonce: { extra: options.nonce }, CheckSpecVersion: { additional: options.specVersion }, CheckTxVersion: { additional: options.transactionVersion } diff --git a/packages/quantus-codec/src/rs/bindings.rs b/packages/quantus-codec/src/rs/bindings.rs index 65d93b78..a556aec2 100644 --- a/packages/quantus-codec/src/rs/bindings.rs +++ b/packages/quantus-codec/src/rs/bindings.rs @@ -16,7 +16,7 @@ use alloc::vec::Vec; use wasm_bindgen::prelude::*; -use crate::encode::ExtensionValue; +use crate::encode::{ExtensionValue, Supplied}; use crate::runtime::Runtime; /// A loaded runtime description, held across calls so the metadata is parsed @@ -149,21 +149,57 @@ impl QuantusRuntime { let parsed: BTreeMap = serde_json::from_str(extensions).map_err(|e| JsError::new(&e.to_string()))?; - let values: BTreeMap = parsed - .into_iter() - .map(|(k, v)| { - ( - k, - ExtensionValue { - extra: v.get("extra").cloned(), - additional: v.get("additional").cloned(), - }, - ) - }) - .collect(); + let mut values: BTreeMap = BTreeMap::new(); + + for (identifier, v) in parsed { + values.insert( + identifier, + ExtensionValue { + extra: half(&v, "extra")?, + additional: half(&v, "additional")?, + }, + ); + } self.inner .encode_extensions(&values) .map_err(|e| JsError::new(&e.to_string())) } } + +/// One half of an extension value, as JSON or as pre-encoded bytes. +/// +/// `extra` / `additional` are interpreted against the runtime's declared type; +/// `extraHex` / `additionalHex` are bytes the caller encoded itself, which are +/// validated by round trip rather than taken on trust. Supplying both is a +/// contradiction, not a preference, so it is refused. +fn half(value: &serde_json::Value, name: &str) -> Result, JsError> { + let json = value.get(name); + let hex = value.get(alloc::format!("{name}Hex")); + + match (json, hex) { + (Some(_), Some(_)) => Err(JsError::new(&alloc::format!( + "{name} and {name}Hex are both set" + ))), + (Some(v), None) => Ok(Some(Supplied::Json(v.clone()))), + (None, Some(v)) => { + let s = v + .as_str() + .ok_or_else(|| JsError::new(&alloc::format!("{name}Hex is not a string")))?; + let s = s.strip_prefix("0x").unwrap_or(s); + + if s.len() % 2 != 0 { + return Err(JsError::new(&alloc::format!("{name}Hex is not whole bytes"))); + } + + let bytes: Result, _> = (0..s.len() / 2) + .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)) + .collect(); + + Ok(Some(Supplied::Raw(bytes.map_err(|e| { + JsError::new(&alloc::format!("{name}Hex: {e}")) + })?))) + } + (None, None) => Ok(None), + } +} diff --git a/packages/quantus-codec/src/rs/decode.rs b/packages/quantus-codec/src/rs/decode.rs index 0767b2de..816a6c57 100644 --- a/packages/quantus-codec/src/rs/decode.rs +++ b/packages/quantus-codec/src/rs/decode.rs @@ -162,7 +162,7 @@ impl Runtime { /// type and allocates nothing at all, so a length that cannot be satisfied /// runs out of input on the first item and comes back as an error. The /// second pass costs one more walk of a few kilobytes. - fn decode_checked( + pub(crate) fn decode_checked( &self, ty: u32, cursor: &mut &[u8], diff --git a/packages/quantus-codec/src/rs/encode.rs b/packages/quantus-codec/src/rs/encode.rs index 5c2abbdb..694d8046 100644 --- a/packages/quantus-codec/src/rs/encode.rs +++ b/packages/quantus-codec/src/rs/encode.rs @@ -13,6 +13,23 @@ use scale_value::{Composite, Primitive, Value, ValueDef}; use crate::runtime::{CodecError, Runtime}; +/// A value for one half of one signed extension. +#[derive(Debug, Clone)] +pub enum Supplied { + /// Interpreted against the type the runtime declares — see + /// [`Runtime::json_to_value`]. + Json(serde_json::Value), + /// Already SCALE-encoded by whoever is asking for the signature. + /// + /// A dapp hands the wallet an `era` as opaque bytes, having encoded it + /// itself, and there is no way to render those as JSON without knowing the + /// era algorithm — which is exactly the kind of knowledge this crate refuses + /// to hold. So they are accepted, but **not on trust**: [`Runtime::encode_half`] + /// decodes them against the declared type and re-encodes them, and anything + /// that does not round-trip is rejected rather than signed. + Raw(Vec), +} + /// What the caller knows about one signed extension. /// /// Both halves are optional because most extensions need neither: a zero-sized @@ -21,8 +38,8 @@ use crate::runtime::{CodecError, Runtime}; /// is not an error; *omitting* one for a non-zero-sized type is. #[derive(Debug, Clone, Default)] pub struct ExtensionValue { - pub extra: Option, - pub additional: Option, + pub extra: Option, + pub additional: Option, } /// The two byte strings a signed extrinsic needs from its extensions. @@ -150,7 +167,7 @@ impl Runtime { fn encode_half( &self, ty: u32, - supplied: Option<&serde_json::Value>, + supplied: Option<&Supplied>, identifier: &str, out: &mut Vec, ) -> Result<(), CodecError> { @@ -160,8 +177,44 @@ impl Runtime { return Ok(()); } - let value = supplied.ok_or_else(|| CodecError::MissingExtension(identifier.to_string()))?; - let converted = self.json_to_value(value, ty)?; + let converted = match supplied.ok_or_else(|| { + CodecError::MissingExtension(identifier.to_string()) + })? { + Supplied::Json(value) => self.json_to_value(value, ty)?, + // Round-tripped rather than appended. Bytes that decode against the + // declared type and re-encode to themselves are the *only* bytes the + // runtime could have meant; anything else — a short read, trailing + // bytes, a non-canonical compact — is a disagreement about the format + // that would otherwise be signed and only surface as `BadProof`. + Supplied::Raw(bytes) => { + let mut cursor = &bytes[..]; + let value = self + .decode_checked(ty, &mut cursor) + .map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))?; + + if !cursor.is_empty() { + return Err(CodecError::Encode(format!( + "{identifier}: {} trailing bytes", + cursor.len() + ))); + } + + let mut check = Vec::new(); + + scale_value::scale::encode_as_type(&value, ty, self.types(), &mut check) + .map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))?; + + if check != *bytes { + return Err(CodecError::Encode(format!( + "{identifier}: supplied bytes do not round-trip against the runtime's type" + ))); + } + + out.extend_from_slice(bytes); + + return Ok(()); + } + }; scale_value::scale::encode_as_type(&converted, ty, self.types(), out) .map_err(|e| CodecError::Encode(format!("{identifier}: {e}"))) diff --git a/packages/quantus-codec/src/rs/tests.rs b/packages/quantus-codec/src/rs/tests.rs index cca78933..8c4402bb 100644 --- a/packages/quantus-codec/src/rs/tests.rs +++ b/packages/quantus-codec/src/rs/tests.rs @@ -9,7 +9,7 @@ use alloc::collections::BTreeMap; use alloc::string::{String, ToString}; use alloc::vec::Vec; -use crate::encode::ExtensionValue; +use crate::encode::{ExtensionValue, Supplied}; use crate::runtime::Runtime; /// Heisenberg at spec 148, `transactionVersion` 6 — the runtime quantus/extension#7 @@ -107,3 +107,87 @@ fn an_account_id_encodes_from_its_hex() { assert_eq!(out[0], 0); assert_eq!(&out[1..], &[0x11u8; 32]); } + +/// Pre-encoded bytes are accepted — a dapp encodes its own `era`, and nothing +/// here knows the era algorithm — but they are round-tripped against the +/// runtime's declared type rather than trusted. +#[test] +fn raw_extension_bytes_are_validated_not_trusted() { + let rt = heisenberg(); + let mut values: BTreeMap = BTreeMap::new(); + + let fill = |values: &mut BTreeMap, era: Supplied| { + values.insert( + "CheckMortality".to_string(), + ExtensionValue { + additional: Some(Supplied::Json(serde_json::json!( + "0x".to_string() + &"aa".repeat(32) + ))), + extra: Some(era), + }, + ); + values.insert( + "CheckNonce".to_string(), + ExtensionValue { + additional: None, + extra: Some(Supplied::Json(serde_json::json!(1))), + }, + ); + values.insert( + "ChargeTransactionPayment".to_string(), + ExtensionValue { + additional: None, + extra: Some(Supplied::Json(serde_json::json!(0))), + }, + ); + values.insert( + "CheckMetadataHash".to_string(), + ExtensionValue { + additional: Some(Supplied::Json(serde_json::json!("None"))), + extra: Some(Supplied::Json(serde_json::json!("Disabled"))), + }, + ); + for (id, v) in [ + ("CheckSpecVersion", 148), + ("CheckTxVersion", 6), + ] { + values.insert( + id.to_string(), + ExtensionValue { + additional: Some(Supplied::Json(serde_json::json!(v))), + extra: None, + }, + ); + } + values.insert( + "CheckGenesis".to_string(), + ExtensionValue { + additional: Some(Supplied::Json(serde_json::json!( + "0x".to_string() + &"bb".repeat(32) + ))), + extra: None, + }, + ); + }; + + // `0x00` is Era::Immortal, and it round-trips. + fill(&mut values, Supplied::Raw(alloc::vec![0x00])); + + let encoded = rt.encode_extensions(&values).expect("immortal era encodes"); + + assert_eq!(encoded.extra[0], 0x00); + + // Two bytes where the type says one is a disagreement about the format. A + // signer that appended them would produce a signature the chain rejects as + // BadProof, with nothing locally to say why. + fill(&mut values, Supplied::Raw(alloc::vec![0x00, 0x00])); + + let err = rt + .encode_extensions(&values) + .expect_err("trailing byte is refused"); + + assert!( + err.to_string().contains("trailing"), + "unexpected error: {err}" + ); +}