feat: accept pre-encoded extension values, validated by round trip

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
2026-09-15 14:45:25 +03:00
parent 3a1611a92e
commit f1c51661df
6 changed files with 216 additions and 23 deletions

View File

@@ -15,7 +15,7 @@
},
"sideEffects": false,
"type": "module",
"version": "0.1.0",
"version": "0.2.0",
"main": "index.js",
"dependencies": {
"fflate": "^0.8.2",

View File

@@ -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<string, ExtensionValue>;
@@ -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 }

View File

@@ -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<String, serde_json::Value> =
serde_json::from_str(extensions).map_err(|e| JsError::new(&e.to_string()))?;
let values: BTreeMap<String, ExtensionValue> = parsed
.into_iter()
.map(|(k, v)| {
(
k,
ExtensionValue {
extra: v.get("extra").cloned(),
additional: v.get("additional").cloned(),
},
)
})
.collect();
let mut values: BTreeMap<String, ExtensionValue> = 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<Option<Supplied>, 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<Vec<u8>, _> = (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),
}
}

View File

@@ -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],

View File

@@ -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<u8>),
}
/// 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<serde_json::Value>,
pub additional: Option<serde_json::Value>,
pub extra: Option<Supplied>,
pub additional: Option<Supplied>,
}
/// 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<u8>,
) -> 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}")))

View File

@@ -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<String, ExtensionValue> = BTreeMap::new();
let fill = |values: &mut BTreeMap<String, ExtensionValue>, 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}"
);
}