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:
75
packages/quantus-codec/Cargo.lock
generated
75
packages/quantus-codec/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)?])
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
225
packages/quantus-codec/src/rs/storage.rs
Normal file
225
packages/quantus-codec/src/rs/storage.rs
Normal 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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user