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:
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}")))
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user