feat: storage addressing, nested calls, and a stated integer convention

Three things the tier-1 case matrix needed (quantus/extension#7).

**Storage keys and values (#4).** `storage_target` resolves a pallet and item to
`twox128(prefix) ‖ twox128(item)` plus each map key hashed by the hasher the
entry declares, and reports the value type and the entry's default.
`decode_storage_value` reads the result. Nothing here knows that `System::Account`
is a `Blake2_128Concat` map over an `AccountId32`; the hashers, both types and
the default all come out of the metadata.

The `Default` versus `Optional` distinction is carried deliberately. A `Default`
entry that the node returns nothing for means the declared default — an account
nobody has funded reads as a zero balance — where an `Optional` one means
nothing. A wallet that conflated them would report a failure for an account that
simply has no money in it.

**A call nests inside a call.** A multi-field variant with named fields only
accepted a positional array, so `Utility.batch_all` — whose `Vec<RuntimeCall>`
holds calls spelled exactly like top-level ones — could not be encoded at all. It
now takes the same object form at any depth.

**Every integer renders as a decimal string**, whatever its width, and that is
now stated rather than incidental. A u128 balance does not survive a JSON number
(12 decimal places puts ordinary amounts past 2^53) and `scale_value` widens
every unsigned integer to u128, so the width is not available to switch on.
Emitting a number when it happens to fit and a string when it does not would make
a consumer handle both shapes for the same field depending on the value.

All of it proven against Heisenberg: an ML-DSA-65 account funded by a `batch_all`
whose payload crossed the 256-byte BLAKE2b threshold, then signing and being
included itself.

Closes #4. 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 15:15:35 +03:00
parent f1c51661df
commit e6ff57a334
14 changed files with 568 additions and 5 deletions

View File

@@ -8,6 +8,24 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -47,6 +65,16 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "derive_more"
version = "1.0.0"
@@ -67,6 +95,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "either"
version = "1.18.0"
@@ -90,6 +129,16 @@ dependencies = [
"scale-info",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -198,12 +247,14 @@ dependencies = [
name = "quantus_codec"
version = "0.0.0"
dependencies = [
"blake2",
"frame-metadata",
"parity-scale-codec",
"scale-decode",
"scale-info",
"scale-value",
"serde_json",
"twox-hash",
"wasm-bindgen",
]
@@ -357,6 +408,12 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
@@ -429,6 +486,18 @@ dependencies = [
"winnow",
]
[[package]]
name = "twox-hash"
version = "2.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -441,6 +510,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"

View File

@@ -44,6 +44,10 @@ scale-value = { version = "0.18", default-features = false }
scale-decode = { version = "0.16", default-features = false }
serde_json = "1"
wasm-bindgen = "0.2"
# Storage keys. Substrate hashes a pallet prefix and an item name with twox128
# and each map key with whatever hasher the metadata declares for it.
blake2 = { version = "0.10", default-features = false }
twox-hash = { version = "2", default-features = false, features = ["xxhash64"] }
[profile.release]
codegen-units = 1

View File

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

View File

@@ -33,6 +33,22 @@ export interface ExtensionValue {
export type ExtensionValues = Record<string, ExtensionValue>;
export interface StorageTarget {
/** The full key, ready for `state_getStorage`. */
key: string;
/** The registry type its value decodes as — pass to `decodeStorage`. */
valueTy: number;
/**
* What the chain means when `state_getStorage` returns nothing.
*
* Hex for a `Default` entry — an unfunded account reads as a zero balance —
* and `null` for an `Optional` one, where nothing means nothing. A wallet that
* conflated the two would report a failure for an account that simply has no
* money in it.
*/
default: string | null;
}
export interface DecodedExtrinsic {
/** The preamble byte's low six bits — **not** the byte. See `decodeExtrinsic`. */
version: number;
@@ -159,6 +175,23 @@ export class Runtime {
return JSON.parse(this.#inner.decodeCall(bytes)) as unknown;
}
/**
* Where a storage value lives, and what it decodes as.
*
* `keys` are interpreted against the key types the runtime declares, so an
* `AccountId32` is the hex string its inner array accepts. The hashers come
* from the metadata too — nothing here knows that `System::Account` is
* `Blake2_128Concat`.
*/
storageTarget (pallet: string, item: string, keys: unknown[] = []): StorageTarget {
return JSON.parse(this.#inner.storageTarget(pallet, item, JSON.stringify(keys))) as StorageTarget;
}
/** Decode a storage value against the `valueTy` that `storageTarget` reported. */
decodeStorage (valueTy: number, bytes: Uint8Array): unknown {
return JSON.parse(this.#inner.decodeStorage(valueTy, bytes)) as unknown;
}
/**
* Fill in the signed extensions that Substrate itself defines, from one
* options object.

View File

@@ -16,6 +16,10 @@ export class QuantusRuntime {
* Decode one extrinsic as this runtime describes it, as JSON.
*/
decodeExtrinsic(blob: Uint8Array): string;
/**
* Decode a storage value against the type id `storageTarget` reported.
*/
decodeStorage(value_ty: number, bytes: Uint8Array): string;
/**
* Encode a call by name. `args` is a JSON object keyed by argument name.
*/
@@ -53,6 +57,16 @@ export class QuantusRuntime {
* is an error — see [`Runtime::encode_extensions`].
*/
signerPayload(call: Uint8Array, extensions: string): Uint8Array;
/**
* Where a storage value lives, and what it decodes as.
*
* Returns `{ key, valueTy, default }` — `key` ready for `state_getStorage`,
* and `default` the bytes the chain means when it returns nothing, or null
* for an entry where nothing means nothing. An unfunded account reads as a
* zero balance through the first and as an error through the second, so the
* distinction is not a detail.
*/
storageTarget(pallet: string, item: string, keys: string): string;
}
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
@@ -62,6 +76,7 @@ export interface InitOutput {
readonly __wbg_quantusruntime_free: (a: number, b: number) => void;
readonly quantusruntime_decodeCall: (a: number, b: number, c: number) => [number, number, number, number];
readonly quantusruntime_decodeExtrinsic: (a: number, b: number, c: number) => [number, number, number, number];
readonly quantusruntime_decodeStorage: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly quantusruntime_encodeCall: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
readonly quantusruntime_encodeExtra: (a: number, b: number, c: number) => [number, number, number, number];
readonly quantusruntime_encodeExtrinsic: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number, number, number];
@@ -69,6 +84,7 @@ export interface InitOutput {
readonly quantusruntime_new: (a: number, b: number) => [number, number, number];
readonly quantusruntime_signedExtensions: (a: number) => [number, number, number, number];
readonly quantusruntime_signerPayload: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number];
readonly quantusruntime_storageTarget: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __externref_table_dealloc: (a: number) => void;

View File

@@ -65,6 +65,32 @@ export class QuantusRuntime {
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
}
}
/**
* Decode a storage value against the type id `storageTarget` reported.
* @param {number} value_ty
* @param {Uint8Array} bytes
* @returns {string}
*/
decodeStorage(value_ty, bytes) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_decodeStorage(this.__wbg_ptr, value_ty, ptr0, len0);
var ptr2 = ret[0];
var len2 = ret[1];
if (ret[3]) {
ptr2 = 0; len2 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
}
}
/**
* Encode a call by name. `args` is a JSON object keyed by argument name.
* @param {string} pallet
@@ -201,6 +227,43 @@ export class QuantusRuntime {
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v3;
}
/**
* Where a storage value lives, and what it decodes as.
*
* Returns `{ key, valueTy, default }` — `key` ready for `state_getStorage`,
* and `default` the bytes the chain means when it returns nothing, or null
* for an entry where nothing means nothing. An unfunded account reads as a
* zero balance through the first and as an error through the second, so the
* distinction is not a detail.
* @param {string} pallet
* @param {string} item
* @param {string} keys
* @returns {string}
*/
storageTarget(pallet, item, keys) {
let deferred5_0;
let deferred5_1;
try {
const ptr0 = passStringToWasm0(pallet, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(item, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passStringToWasm0(keys, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_storageTarget(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
var ptr4 = ret[0];
var len4 = ret[1];
if (ret[3]) {
ptr4 = 0; len4 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred5_0 = ptr4;
deferred5_1 = len4;
return getStringFromWasm0(ptr4, len4);
} finally {
wasm.__wbindgen_free(deferred5_0, deferred5_1, 1);
}
}
}
if (Symbol.dispose) QuantusRuntime.prototype[Symbol.dispose] = QuantusRuntime.prototype.free;
function __wbg_get_imports() {

View File

@@ -2,5 +2,5 @@
// SPDX-License-Identifier: Apache-2.0
export { Runtime } from './codec.js';
export type { DecodedExtrinsic, ExtensionNeed, ExtensionValue, ExtensionValues, PayloadOptions } from './codec.js';
export type { DecodedExtrinsic, ExtensionNeed, ExtensionValue, ExtensionValues, PayloadOptions, StorageTarget } from './codec.js';
export { initWasm, isReady } from './init.js';

View File

@@ -26,6 +26,9 @@ pub mod decode;
#[path = "rs/encode.rs"]
pub mod encode;
#[path = "rs/storage.rs"]
pub mod storage;
#[path = "rs/bindings.rs"]
mod bindings;

View File

@@ -131,6 +131,42 @@ impl QuantusRuntime {
.map_err(|e| JsError::new(&e.to_string()))
}
/// Where a storage value lives, and what it decodes as.
///
/// Returns `{ key, valueTy, default }` — `key` ready for `state_getStorage`,
/// and `default` the bytes the chain means when it returns nothing, or null
/// for an entry where nothing means nothing. An unfunded account reads as a
/// zero balance through the first and as an error through the second, so the
/// distinction is not a detail.
#[wasm_bindgen(js_name = storageTarget)]
pub fn storage_target(&self, pallet: &str, item: &str, keys: &str) -> Result<String, JsError> {
let keys: Vec<serde_json::Value> =
serde_json::from_str(keys).map_err(|e| JsError::new(&e.to_string()))?;
let target = self
.inner
.storage_target(pallet, item, &keys)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&serde_json::json!({
"default": target.default.as_deref().map(crate::storage::hex),
"key": crate::storage::hex(&target.key),
"valueTy": target.value_ty
}))
.map_err(|e| JsError::new(&e.to_string()))
}
/// Decode a storage value against the type id `storageTarget` reported.
#[wasm_bindgen(js_name = decodeStorage)]
pub fn decode_storage(&self, value_ty: u32, bytes: &[u8]) -> Result<String, JsError> {
let value = self
.inner
.decode_storage_value(value_ty, bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&value).map_err(|e| JsError::new(&e.to_string()))
}
/// Decode a bare call — what an approval screen shows the user.
#[wasm_bindgen(js_name = decodeCall)]
pub fn decode_call(&self, bytes: &[u8]) -> Result<String, JsError> {

View File

@@ -186,7 +186,7 @@ impl Runtime {
/// Byte sequences become `0x…` hex rather than arrays of numbers: an account id
/// as 32 JSON integers is technically the same information and useless to every
/// consumer, and a 7219-byte signature as an array is 30 KiB of JSON.
fn render(value: &scale_value::Value<u32>) -> serde_json::Value {
pub(crate) fn render(value: &scale_value::Value<u32>) -> serde_json::Value {
use scale_value::{Composite, Primitive, ValueDef};
match &value.value {
@@ -194,8 +194,15 @@ fn render(value: &scale_value::Value<u32>) -> serde_json::Value {
Primitive::Bool(b) => serde_json::Value::Bool(*b),
Primitive::Char(c) => serde_json::Value::String(c.to_string()),
Primitive::String(s) => serde_json::Value::String(s.clone()),
// u128/i128 do not survive a JSON number. Strings keep every digit,
// and every consumer of a balance on this chain needs all of them.
// **Every** integer renders as a decimal string, whatever its width.
//
// A u128 balance does not survive a JSON number — this chain has 12
// decimal places, so ordinary amounts pass 2^53 — and `scale_value`
// widens every unsigned integer to u128 anyway, so the width is not
// available here to switch on. Emitting a number when it happens to
// fit and a string when it does not would make a consumer handle both
// shapes for the same field depending on the value, which is worse
// than either. Strings, always, and the caller parses what it knows.
Primitive::U128(n) => serde_json::Value::String(n.to_string()),
Primitive::I128(n) => serde_json::Value::String(n.to_string()),
Primitive::U256(b) | Primitive::I256(b) => serde_json::Value::String(hex(b)),

View File

@@ -423,8 +423,30 @@ impl Runtime {
.find(|x| x.name == name)
.ok_or_else(|| CodecError::Encode(format!("no variant {name}")))?;
let named = variant.fields.iter().all(|f| f.name.is_some());
let composite = match (payload, variant.fields.len()) {
(_, 0) => Composite::Unnamed(Vec::new()),
// A call's arguments arrive as an object keyed by the names
// the runtime gives them, at any depth — a `Utility.batch_all`
// carries whole calls in a `Vec<RuntimeCall>`, and each of
// those is this same shape again. Before this, only the
// outermost call could be spelled that way and anything
// nested had to be a positional array.
(Some(serde_json::Value::Object(map)), _) if named => {
let mut fields = Vec::new();
for f in &variant.fields {
let field = f.name.clone().unwrap_or_default();
let v = map.get(&field).ok_or_else(|| {
CodecError::Encode(format!("{name}: no value for {field}"))
})?;
fields.push((field, self.json_to_value(v, f.ty.id)?));
}
Composite::Named(fields)
}
(Some(p), 1) => {
Composite::Unnamed(alloc::vec![self.json_to_value(p, variant.fields[0].ty.id)?])
}

View File

@@ -36,6 +36,8 @@ pub enum CodecError {
/// A signed extension declares a non-empty type and the caller supplied no
/// value for it. Deliberately fatal — see [`crate::encode`].
MissingExtension(String),
/// The runtime declares no such storage entry, or not in that shape.
NoStorageEntry(String),
}
impl core::fmt::Display for CodecError {
@@ -47,6 +49,7 @@ impl core::fmt::Display for CodecError {
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::NoStorageEntry(s) => write!(f, "no such storage entry: {s}"),
Self::MissingExtension(s) => write!(
f,
"signed extension {s} declares a non-empty type and no value was supplied"

View File

@@ -0,0 +1,225 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Addressing chain state, using the runtime's own description of where it lives.
//!
//! A storage key is `twox128(pallet) ‖ twox128(item)`, then each map key hashed
//! by the hasher the entry declares. None of those choices are known here:
//! the pallet prefix, the item name, the hashers, the key type and the value
//! type all come out of the metadata, so a runtime upgrade that re-hashes a map
//! or changes a value's shape is followed rather than mis-read.
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use frame_metadata::v14::{StorageEntryType, StorageHasher};
use crate::runtime::{CodecError, Runtime};
/// Where a storage value lives and what it decodes as.
#[derive(Debug, Clone)]
pub struct StorageTarget {
/// The full key, ready for `state_getStorage`.
pub key: Vec<u8>,
/// The registry type its value decodes as.
pub value_ty: u32,
/// What the chain means when it returns nothing.
///
/// `Some(bytes)` for a `Default` entry — an unfunded account reads as a zero
/// balance, not as an error. `None` for an `Optional` entry, where nothing
/// means nothing. Conflating the two is how a wallet reports "failed to load"
/// for an account that simply has no money in it.
pub default: Option<Vec<u8>>,
}
impl Runtime {
/// Resolve a storage entry, hashing any map keys as the runtime declares.
///
/// `keys` are JSON, interpreted against the key types the metadata gives —
/// so an `AccountId32` is the hex string its inner array accepts, and a
/// double map takes two values in the order the entry lists its hashers.
pub fn storage_target(
&self,
pallet: &str,
item: &str,
keys: &[serde_json::Value],
) -> Result<StorageTarget, CodecError> {
let entry = self
.metadata
.pallets
.iter()
.find(|p| p.name == pallet)
.and_then(|p| p.storage.as_ref())
.and_then(|s| s.entries.iter().find(|e| e.name == item))
.ok_or_else(|| CodecError::NoStorageEntry(format!("{pallet}::{item}")))?;
let prefix = self
.metadata
.pallets
.iter()
.find(|p| p.name == pallet)
.and_then(|p| p.storage.as_ref())
.map(|s| s.prefix.clone())
.unwrap_or_else(|| pallet.to_string());
let mut key = twox_128(prefix.as_bytes()).to_vec();
key.extend_from_slice(&twox_128(item.as_bytes()));
let value_ty = match &entry.ty {
StorageEntryType::Plain(ty) => {
if !keys.is_empty() {
return Err(CodecError::NoStorageEntry(format!(
"{pallet}::{item} takes no keys"
)));
}
ty.id
}
StorageEntryType::Map {
hashers,
key: key_ty,
value,
} => {
if hashers.len() != keys.len() {
return Err(CodecError::NoStorageEntry(format!(
"{pallet}::{item} takes {} key(s), {} given",
hashers.len(),
keys.len()
)));
}
// One hasher means the declared key type *is* the key. More than
// one means it is a tuple, one element per hasher, and the
// elements are hashed separately rather than as a unit.
let key_tys: Vec<u32> = if hashers.len() == 1 {
alloc::vec![key_ty.id]
} else {
match self.types().resolve(key_ty.id).map(|t| &t.type_def) {
Some(scale_info::TypeDef::Tuple(t)) => {
t.fields.iter().map(|f| f.id).collect()
}
_ => {
return Err(CodecError::NoStorageEntry(format!(
"{pallet}::{item} has {} hashers and a non-tuple key",
hashers.len()
)))
}
}
};
for ((supplied, ty), hasher) in keys.iter().zip(key_tys).zip(hashers.iter()) {
let value = self.json_to_value(supplied, ty)?;
let mut encoded = Vec::new();
scale_value::scale::encode_as_type(&value, ty, self.types(), &mut encoded)
.map_err(|e| CodecError::Encode(format!("{pallet}::{item} key: {e}")))?;
key.extend_from_slice(&hash_key(hasher, &encoded));
}
value.id
}
};
let default = match &entry.modifier {
frame_metadata::v14::StorageEntryModifier::Default => Some(entry.default.clone()),
frame_metadata::v14::StorageEntryModifier::Optional => None,
};
Ok(StorageTarget {
default,
key,
value_ty,
})
}
/// Decode a storage value against the type its entry declares.
///
/// `bytes` is what `state_getStorage` returned, or the entry's default when
/// it returned nothing.
pub fn decode_storage_value(
&self,
value_ty: u32,
bytes: &[u8],
) -> Result<serde_json::Value, CodecError> {
let mut cursor = bytes;
let value = self
.decode_checked(value_ty, &mut cursor)
.map_err(|e| CodecError::Decode(e))?;
if !cursor.is_empty() {
return Err(CodecError::Decode(format!(
"{} trailing bytes after storage value",
cursor.len()
)));
}
Ok(crate::decode::render(&value))
}
}
/// `twox128`, as Substrate uses it for pallet and item prefixes.
fn twox_128(input: &[u8]) -> [u8; 16] {
use twox_hash::XxHash64;
let mut out = [0u8; 16];
out[..8].copy_from_slice(&XxHash64::oneshot(0, input).to_le_bytes());
out[8..].copy_from_slice(&XxHash64::oneshot(1, input).to_le_bytes());
out
}
/// Hash one map key the way its entry declares.
///
/// The `Concat` variants keep the key after its hash, which is what makes a map
/// enumerable. The plain variants do not.
fn hash_key(hasher: &StorageHasher, encoded: &[u8]) -> Vec<u8> {
use blake2::digest::consts::{U16, U32};
use blake2::{Blake2b, Digest};
use twox_hash::XxHash64;
match hasher {
StorageHasher::Blake2_128 => Blake2b::<U16>::digest(encoded).to_vec(),
StorageHasher::Blake2_256 => Blake2b::<U32>::digest(encoded).to_vec(),
StorageHasher::Blake2_128Concat => {
let mut v = Blake2b::<U16>::digest(encoded).to_vec();
v.extend_from_slice(encoded);
v
}
StorageHasher::Twox128 => twox_128(encoded).to_vec(),
StorageHasher::Twox256 => {
let mut v = Vec::with_capacity(32);
for seed in 0..4u64 {
v.extend_from_slice(&XxHash64::oneshot(seed, encoded).to_le_bytes());
}
v
}
StorageHasher::Twox64Concat => {
let mut v = XxHash64::oneshot(0, encoded).to_le_bytes().to_vec();
v.extend_from_slice(encoded);
v
}
StorageHasher::Identity => encoded.to_vec(),
}
}
/// Hex, for the JSON boundary.
pub(crate) fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(2 + bytes.len() * 2);
s.push_str("0x");
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).expect("nibble"));
s.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble"));
}
s
}

View File

@@ -191,3 +191,79 @@ fn raw_extension_bytes_are_validated_not_trusted() {
"unexpected error: {err}"
);
}
/// A call nested inside another call — `Utility.batch_all` carries a
/// `Vec<RuntimeCall>`, so each element is a call spelled exactly as a top-level
/// one. Anything less than that and batching has to be written positionally,
/// which is unreadable and silently order-dependent.
#[test]
fn a_call_nests_inside_another_call() {
let rt = heisenberg();
let dest = "0x".to_string() + &"22".repeat(32);
let one = serde_json::json!({
"Balances": { "transfer_keep_alive": { "dest": { "Id": dest }, "value": "1000000000" } }
});
let encoded = rt
.encode_call(
"Utility",
"batch_all",
&serde_json::json!({ "calls": [one.clone(), one] }),
)
.expect("batch_all of two transfers encodes");
// Over 256 bytes, which is where Substrate's signing rule switches to
// BLAKE2b — the branch quantus/extension#7 wants exercised.
assert!(encoded.len() > 80, "unexpectedly short: {}", encoded.len());
let decoded = rt.decode_call(&encoded).expect("and decodes again");
let calls = decoded["Utility"]["batch_all"]["calls"]
.as_array()
.expect("calls is a list");
assert_eq!(calls.len(), 2);
assert_eq!(
calls[0]["Balances"]["transfer_keep_alive"]["value"],
serde_json::json!("1000000000")
);
}
/// `System::Account` is the entry every wallet needs first, and it is a map with
/// a `Blake2_128Concat` hasher over an `AccountId32`. Nothing here says so — the
/// hasher and both types come out of the metadata.
#[test]
fn a_storage_key_is_built_from_the_declared_hasher() {
let rt = heisenberg();
let who = "0x".to_string() + &"11".repeat(32);
let target = rt
.storage_target("System", "Account", &[serde_json::json!(who)])
.expect("System::Account is a map over AccountId32");
// twox128(prefix) ++ twox128(item) ++ blake2_128(key) ++ key
assert_eq!(target.key.len(), 16 + 16 + 16 + 32);
assert_eq!(&target.key[48..], &[0x11u8; 32]);
// AccountInfo is a `Default` entry: an account nobody has ever funded reads
// as a zero balance, not as a missing value. A wallet that treated the two
// alike would report a failure for an empty account.
let default = target.default.expect("AccountInfo is a Default entry");
let decoded = rt
.decode_storage_value(target.value_ty, &default)
.expect("the declared default decodes as the declared type");
// Every integer renders as a decimal string, whatever its width — see
// `decode::render` for why the width is not available to switch on.
assert_eq!(decoded["nonce"], serde_json::json!("0"));
assert_eq!(decoded["data"]["free"], serde_json::json!("0"));
}
/// The number of keys is the runtime's to state, not the caller's to assume.
#[test]
fn a_storage_entry_refuses_the_wrong_number_of_keys() {
let rt = heisenberg();
assert!(rt.storage_target("System", "Account", &[]).is_err());
assert!(rt.storage_target("System", "Number", &[serde_json::json!(1)]).is_err());
assert!(rt.storage_target("System", "NoSuchThing", &[]).is_err());
}