diff --git a/packages/quantus-codec/Cargo.lock b/packages/quantus-codec/Cargo.lock index 36870f36..df138a5e 100644 --- a/packages/quantus-codec/Cargo.lock +++ b/packages/quantus-codec/Cargo.lock @@ -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" diff --git a/packages/quantus-codec/Cargo.toml b/packages/quantus-codec/Cargo.toml index e9e6739d..c545a98f 100644 --- a/packages/quantus-codec/Cargo.toml +++ b/packages/quantus-codec/Cargo.toml @@ -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 diff --git a/packages/quantus-codec/package.json b/packages/quantus-codec/package.json index 8d58aa6f..d782ab31 100644 --- a/packages/quantus-codec/package.json +++ b/packages/quantus-codec/package.json @@ -15,7 +15,7 @@ }, "sideEffects": false, "type": "module", - "version": "0.2.0", + "version": "0.4.0", "main": "index.js", "dependencies": { "fflate": "^0.8.2", diff --git a/packages/quantus-codec/src/codec.ts b/packages/quantus-codec/src/codec.ts index 8611e251..c378a8c2 100644 --- a/packages/quantus-codec/src/codec.ts +++ b/packages/quantus-codec/src/codec.ts @@ -33,6 +33,22 @@ export interface ExtensionValue { export type ExtensionValues = Record; +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. diff --git a/packages/quantus-codec/src/generated/quantus_codec.d.ts b/packages/quantus-codec/src/generated/quantus_codec.d.ts index b8d81c1b..e238cbdb 100644 --- a/packages/quantus-codec/src/generated/quantus_codec.d.ts +++ b/packages/quantus-codec/src/generated/quantus_codec.d.ts @@ -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; diff --git a/packages/quantus-codec/src/generated/quantus_codec.js b/packages/quantus-codec/src/generated/quantus_codec.js index a6fd94b7..f847a8d1 100644 --- a/packages/quantus-codec/src/generated/quantus_codec.js +++ b/packages/quantus-codec/src/generated/quantus_codec.js @@ -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() { diff --git a/packages/quantus-codec/src/index.ts b/packages/quantus-codec/src/index.ts index 25e8c1f0..9de6bcb7 100644 --- a/packages/quantus-codec/src/index.ts +++ b/packages/quantus-codec/src/index.ts @@ -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'; diff --git a/packages/quantus-codec/src/lib.rs b/packages/quantus-codec/src/lib.rs index 09c5aab3..0b0717cd 100644 --- a/packages/quantus-codec/src/lib.rs +++ b/packages/quantus-codec/src/lib.rs @@ -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; diff --git a/packages/quantus-codec/src/rs/bindings.rs b/packages/quantus-codec/src/rs/bindings.rs index a556aec2..09592b78 100644 --- a/packages/quantus-codec/src/rs/bindings.rs +++ b/packages/quantus-codec/src/rs/bindings.rs @@ -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 { + let keys: Vec = + 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 { + 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 { diff --git a/packages/quantus-codec/src/rs/decode.rs b/packages/quantus-codec/src/rs/decode.rs index 816a6c57..aedbe21a 100644 --- a/packages/quantus-codec/src/rs/decode.rs +++ b/packages/quantus-codec/src/rs/decode.rs @@ -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) -> serde_json::Value { +pub(crate) fn render(value: &scale_value::Value) -> serde_json::Value { use scale_value::{Composite, Primitive, ValueDef}; match &value.value { @@ -194,8 +194,15 @@ fn render(value: &scale_value::Value) -> 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)), diff --git a/packages/quantus-codec/src/rs/encode.rs b/packages/quantus-codec/src/rs/encode.rs index 694d8046..e089eb44 100644 --- a/packages/quantus-codec/src/rs/encode.rs +++ b/packages/quantus-codec/src/rs/encode.rs @@ -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`, 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)?]) } diff --git a/packages/quantus-codec/src/rs/runtime.rs b/packages/quantus-codec/src/rs/runtime.rs index af0956d1..512b8db4 100644 --- a/packages/quantus-codec/src/rs/runtime.rs +++ b/packages/quantus-codec/src/rs/runtime.rs @@ -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" diff --git a/packages/quantus-codec/src/rs/storage.rs b/packages/quantus-codec/src/rs/storage.rs new file mode 100644 index 00000000..cd6acb65 --- /dev/null +++ b/packages/quantus-codec/src/rs/storage.rs @@ -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, + /// 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>, +} + +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 { + 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 = 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 { + 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 { + use blake2::digest::consts::{U16, U32}; + use blake2::{Blake2b, Digest}; + use twox_hash::XxHash64; + + match hasher { + StorageHasher::Blake2_128 => Blake2b::::digest(encoded).to_vec(), + StorageHasher::Blake2_256 => Blake2b::::digest(encoded).to_vec(), + StorageHasher::Blake2_128Concat => { + let mut v = Blake2b::::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 +} diff --git a/packages/quantus-codec/src/rs/tests.rs b/packages/quantus-codec/src/rs/tests.rs index 8c4402bb..4b0adaa5 100644 --- a/packages/quantus-codec/src/rs/tests.rs +++ b/packages/quantus-codec/src/rs/tests.rs @@ -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`, 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()); +}