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
231 lines
8.1 KiB
Rust
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)
|
|
}
|
|
}
|