Files
wasm/packages/quantus-codec/src/rs/runtime.rs
rob thijssen 3a1611a92e feat: add @quantus/codec, driving encode and decode from runtime metadata
The extension has to build a signing payload, assemble an extrinsic and decode
a call well enough to show a user what they are approving. The obvious route was
@polkadot/api's codec. That is closed, and quantus/api#1 carries the tested
evidence:

  - @polkadot/types caps fixed arrays at 2048 bytes, and ML-DSA signatures are
    [u8;5261] and [u8;7219], so every Quantus extrinsic trips it
  - api.rpc.chain.getBlock throws on every block of this chain, at the timestamp
    inherent, because it reads the extrinsic preamble byte as a version when the
    top two bits are a type tag
  - it *guesses* that signed extensions it does not recognise contribute nothing
    to the signed payload

The third is why this is a package rather than a patch. The guess is right
today — the registry says ReversibleTransactionExtension and
WormholeProofRecorderExtension are empty on both halves — and it is right only
by luck. This chain's encoding has changed between runtimes, transactionVersion
has gone 2 -> 3 -> 6 across four upgrades, and when the guess stops holding the
wallet keeps signing: valid signatures over a payload missing bytes the runtime
put there, reported by the chain as BadProof, which is also what it reports for
a wrong key.

So nothing here names a pallet, a call, an extension or a signature scheme.
Every type id is read from metadata the node produced by running
Metadata_metadata against the runtime WASM in a given block's state, the same
oracle blackbeard.observer has been decoding against across four upgrade
boundaries. encode_extensions walks the declared extensions in order and refuses
to build a payload when one that encodes to something has no value supplied —
a wallet that cannot sign is a bug report, one that signs the wrong bytes is a
support case nobody diagnoses.

Proven end to end on Heisenberg at spec 148: a balances.transfer_keep_alive
built entirely here, signed by @quantus/crypto under QUANTUS_EXTRINSIC, included
at block 1050475 and read back from that block — inherent at index 0 included,
which is the block @polkadot/api cannot decode at all.

Two notes carried over from @quantus/crypto, both load-bearing: decode_checked
walks with scale_decode's IgnoreVisitor before scale_value touches the bytes,
because scale_value sizes a Vec from the length prefix before decoding an item
and an aborted allocation leaves no Err to catch; and the build needs binaryen
123, since 105 silently corrupts the output.

Closes #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-15 14:14:34 +03:00

231 lines
8.1 KiB
Rust

// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! The runtime's description of itself, and the handful of things this crate
//! needs to look up in it.
//!
//! Every type id here is *read* from the metadata. Nothing in this file names a
//! pallet, a call, a signed extension or a signature scheme, which is what lets
//! it keep working across a runtime upgrade that changes any of them.
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use frame_metadata::v14::RuntimeMetadataV14;
use frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed};
use parity_scale_codec::Decode;
/// Failures reading a runtime's description, or its data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodecError {
/// The blob is not SCALE-encoded prefixed metadata.
Malformed,
/// Metadata this crate does not read. v14 is what every Quantus runtime
/// observed so far emits; a chain that moves to v15/v16 needs this widened
/// deliberately rather than silently mis-read.
UnsupportedVersion(u8),
/// The runtime does not describe its extrinsic in the usual shape.
NoExtrinsicTypes,
/// No such pallet, or no such call in it.
NoSuchCall(String),
/// A value did not match the type the registry said it would.
Decode(String),
/// A value could not be encoded as the type the registry declares.
Encode(String),
/// A signed extension declares a non-empty type and the caller supplied no
/// value for it. Deliberately fatal — see [`crate::encode`].
MissingExtension(String),
}
impl core::fmt::Display for CodecError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Malformed => write!(f, "metadata did not decode"),
Self::UnsupportedVersion(v) => write!(f, "unsupported metadata version {v}"),
Self::NoExtrinsicTypes => write!(f, "runtime describes no extrinsic type"),
Self::NoSuchCall(s) => write!(f, "no such call: {s}"),
Self::Decode(s) => write!(f, "decoding against the registry failed: {s}"),
Self::Encode(s) => write!(f, "encoding against the registry failed: {s}"),
Self::MissingExtension(s) => write!(
f,
"signed extension {s} declares a non-empty type and no value was supplied"
),
}
}
}
/// The four type parameters of the extrinsic envelope.
#[derive(Debug, Clone, Copy)]
pub struct ExtrinsicTypes {
pub address: u32,
pub signature: u32,
pub extra: u32,
pub call: u32,
}
/// One signed extension, as the runtime declares it.
///
/// `ty` is what it contributes to the extrinsic; `additional` is what it
/// contributes to the signed payload but *not* to the extrinsic. Both are read
/// from the metadata, in the order the runtime applies them, because that order
/// is the payload's byte order.
#[derive(Debug, Clone)]
pub struct ExtensionDef {
pub identifier: String,
pub ty: u32,
pub additional: u32,
}
/// A runtime, as described by its own metadata.
pub struct Runtime {
pub(crate) metadata: RuntimeMetadataV14,
pub(crate) extrinsic: ExtrinsicTypes,
pub(crate) extensions: Vec<ExtensionDef>,
/// Call type id per pallet name, so `encode_call` need not walk the pallet
/// list for every argument.
pub(crate) calls: BTreeMap<String, u32>,
}
impl Runtime {
/// Parse metadata exactly as `state_getMetadata` returns it.
///
/// That RPC takes a block hash and makes the node run `Metadata_metadata`
/// against the runtime code in *that block's* state, so what arrives here is
/// the runtime WASM describing itself, executed by the node. It is the only
/// oracle on this chain that cannot go stale.
pub fn from_metadata(raw: &[u8]) -> Result<Self, CodecError> {
let prefixed =
RuntimeMetadataPrefixed::decode(&mut &raw[..]).map_err(|_| CodecError::Malformed)?;
let metadata = match prefixed.1 {
RuntimeMetadata::V14(v) => v,
other => return Err(CodecError::UnsupportedVersion(version_of(&other))),
};
// The envelope's four parameters, by the names `UncheckedExtrinsic`
// gives them. Read from the registry rather than assumed, which is the
// whole point: `Signature` here is
// `qp_dilithium_crypto::types::DilithiumSignatureScheme`, and no decoder
// written against vanilla Substrate would guess that.
let extrinsic = metadata
.types
.resolve(metadata.extrinsic.ty.id)
.and_then(|e| {
let param = |name: &str| {
e.type_params
.iter()
.find(|p| p.name == name)
.and_then(|p| p.ty)
.map(|t| t.id)
};
Some(ExtrinsicTypes {
address: param("Address")?,
signature: param("Signature")?,
extra: param("Extra")?,
call: param("Call")?,
})
})
.ok_or(CodecError::NoExtrinsicTypes)?;
let extensions = metadata
.extrinsic
.signed_extensions
.iter()
.map(|e| ExtensionDef {
identifier: e.identifier.to_string(),
ty: e.ty.id,
additional: e.additional_signed.id,
})
.collect();
let calls = metadata
.pallets
.iter()
.filter_map(|p| p.calls.as_ref().map(|c| (p.name.to_string(), c.ty.id)))
.collect();
Ok(Self {
metadata,
extrinsic,
extensions,
calls,
})
}
/// The extrinsic format version the metadata declares.
///
/// Not to be confused with the preamble byte of any particular extrinsic —
/// see [`crate::decode::decode_extrinsic`], which is where that distinction
/// has teeth.
pub fn extrinsic_version(&self) -> u8 {
self.metadata.extrinsic.version
}
/// The signed extensions, in the order the runtime applies them.
pub fn extensions(&self) -> &[ExtensionDef] {
&self.extensions
}
pub fn extrinsic_types(&self) -> ExtrinsicTypes {
self.extrinsic
}
pub(crate) fn types(&self) -> &scale_info::PortableRegistry {
&self.metadata.types
}
/// Whether a registry type encodes to nothing at all.
///
/// The question [`crate::encode`] asks of every signed extension: a
/// zero-sized one contributes no bytes and needs no value from the caller,
/// and anything else does. Answering it from the registry rather than from a
/// list of known extension names is the difference between this crate and
/// the thing it replaces.
pub(crate) fn is_empty_ty(&self, id: u32) -> bool {
match self.metadata.types.resolve(id).map(|t| &t.type_def) {
// The unit type, and a tuple of nothing, are the same thing here.
Some(scale_info::TypeDef::Tuple(t)) => {
t.fields.iter().all(|f| self.is_empty_ty(f.id))
}
Some(scale_info::TypeDef::Composite(c)) => {
c.fields.iter().all(|f| self.is_empty_ty(f.ty.id))
}
Some(scale_info::TypeDef::Array(a)) => {
a.len == 0 || self.is_empty_ty(a.type_param.id)
}
_ => false,
}
}
}
fn version_of(md: &RuntimeMetadata) -> u8 {
match md {
RuntimeMetadata::V14(_) => 14,
RuntimeMetadata::V15(_) => 15,
_ => 0,
}
}
impl Runtime {
/// [`Runtime::is_empty_ty`], for the bindings module.
pub fn is_empty_ty_pub(&self, id: u32) -> bool {
self.is_empty_ty(id)
}
}
impl Runtime {
/// [`Runtime::types`], for tests.
pub fn types_pub(&self) -> &scale_info::PortableRegistry {
self.types()
}
/// [`Runtime::json_to_value`], for tests.
pub fn json_to_value_pub(
&self,
json: &serde_json::Value,
ty: u32,
) -> Result<scale_value::Value<()>, CodecError> {
self.json_to_value(json, ty)
}
}