feat: render account ids as SS58 when a prefix is set

A decoded call named its recipient as 32 bytes of hex. That is a correct
description of the value and the one form nobody reads — and the only moment in
a wallet where reading the recipient matters is the screen asking somebody to
approve sending them money.

`set_ss58_format` turns it on. Off by default, and deliberately: the prefix is a
property of the chain a caller is talking to rather than of the metadata, so
inferring one would put a plausible, wrong address in front of that same person.

Account types are found by their **registry path**, not by length. A block hash
is also 32 bytes, and rendering one as an address would be a lie a reader cannot
catch — there is a test that `System::BlockHash` stays hex with a prefix set.
`scale_value` carries each value's type id as its context, so the check is on
what the runtime declared.

The vector is crystal_bob on Heisenberg, taken from the chain rather than
computed here, which also pins the two-byte prefix form — 189 needs it, and
getting it wrong yields an address that looks right and belongs to nobody.

Refs #3, quantus/extension#6

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:26:11 +03:00
parent e6ff57a334
commit 09a96b64bf
13 changed files with 326 additions and 70 deletions

View File

@@ -26,6 +26,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -248,6 +257,7 @@ name = "quantus_codec"
version = "0.0.0"
dependencies = [
"blake2",
"bs58",
"frame-metadata",
"parity-scale-codec",
"scale-decode",
@@ -456,6 +466,21 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "tinyvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"

View File

@@ -48,6 +48,9 @@ wasm-bindgen = "0.2"
# 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"] }
# SS58. An account id rendered as 32 bytes of hex is a correct description of the
# value and unreadable to the person being asked to approve it.
bs58 = { version = "0.5", default-features = false, features = ["alloc"] }
[profile.release]
codegen-units = 1

View File

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

View File

@@ -111,6 +111,19 @@ export class Runtime {
return new Runtime(new QuantusRuntime(metadata));
}
/**
* Render account ids as SS58 at this prefix when decoding.
*
* Off until set. The prefix belongs to the chain a caller is talking to, not
* to the metadata, so this is not something the package can infer — and a
* guessed one would put a plausible, wrong address in front of somebody about
* to approve a transfer. Account ids are found by their **registry path**, so
* a block hash, which is also 32 bytes, still renders as hex.
*/
setSs58Format (prefix: number): void {
this.#inner.setSs58Format(prefix);
}
/** The extrinsic format version the metadata declares. */
get extrinsicVersion (): number {
return this.#inner.extrinsicVersion();

View File

@@ -40,6 +40,14 @@ export class QuantusRuntime {
* Parse metadata as `state_getMetadata` returns it.
*/
constructor(metadata: Uint8Array);
/**
* Render account ids as SS58 at this prefix when decoding.
*
* Off until set. The prefix is a property of the chain a caller is talking
* to rather than of the metadata, and a guessed one would put a plausible,
* wrong address in front of somebody about to approve a transfer.
*/
setSs58Format(prefix: number): void;
/**
* Every signed extension, in order, as
* `[{ identifier, needsExtra, needsAdditional }]`.
@@ -82,6 +90,7 @@ export interface InitOutput {
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];
readonly quantusruntime_extrinsicVersion: (a: number) => number;
readonly quantusruntime_new: (a: number, b: number) => [number, number, number];
readonly quantusruntime_setSs58Format: (a: number, b: number) => void;
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];

View File

@@ -177,6 +177,17 @@ export class QuantusRuntime {
QuantusRuntimeFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Render account ids as SS58 at this prefix when decoding.
*
* Off until set. The prefix is a property of the chain a caller is talking
* to rather than of the metadata, and a guessed one would put a plausible,
* wrong address in front of somebody about to approve a transfer.
* @param {number} prefix
*/
setSs58Format(prefix) {
wasm.quantusruntime_setSs58Format(this.__wbg_ptr, prefix);
}
/**
* Every signed extension, in order, as
* `[{ identifier, needsExtra, needsAdditional }]`.

View File

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

View File

@@ -36,6 +36,16 @@ impl QuantusRuntime {
.map_err(|e| JsError::new(&e.to_string()))
}
/// Render account ids as SS58 at this prefix when decoding.
///
/// Off until set. The prefix is a property of the chain a caller is talking
/// to rather than of the metadata, and a guessed one would put a plausible,
/// wrong address in front of somebody about to approve a transfer.
#[wasm_bindgen(js_name = setSs58Format)]
pub fn set_ss58_format(&mut self, prefix: u16) {
self.inner.set_ss58_format(prefix);
}
/// The extrinsic format version this runtime declares.
#[wasm_bindgen(js_name = extrinsicVersion)]
pub fn extrinsic_version(&self) -> u8 {

View File

@@ -145,7 +145,7 @@ impl Runtime {
.decode_checked(ty, cursor)
.map_err(|e| CodecError::Decode(format!("{what}: {e}")))?;
Ok(render(&value))
Ok(self.render(&value))
}
/// Decode one registry type, walking the bytes first without building
@@ -181,73 +181,88 @@ impl Runtime {
}
}
/// Render a decoded value as JSON, for the boundary to JavaScript.
///
/// 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.
pub(crate) fn render(value: &scale_value::Value<u32>) -> serde_json::Value {
use scale_value::{Composite, Primitive, ValueDef};
impl Runtime {
/// Render a decoded value as JSON, for the boundary to JavaScript.
///
/// 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.
///
/// Account ids become SS58 when a prefix has been set. `scale_value` carries
/// each value's registry type id as its context, so the check is on the type
/// the runtime declared and not on the shape of the bytes — a block hash is
/// also 32 bytes, and rendering one as an address would be a lie.
pub(crate) fn render(&self, value: &scale_value::Value<u32>) -> serde_json::Value {
use scale_value::{Composite, Primitive, ValueDef};
match &value.value {
ValueDef::Primitive(p) => match p {
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()),
// **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)),
},
ValueDef::Composite(Composite::Named(fields)) => serde_json::Value::Object(
fields
.iter()
.map(|(k, v)| (k.clone(), render(v)))
.collect(),
),
ValueDef::Composite(Composite::Unnamed(values)) => {
if let Some(bytes) = as_bytes(values) {
serde_json::Value::String(hex(&bytes))
} else if values.len() == 1 {
// A newtype wrapper is noise; unwrap it so `Compact<u64>` reads
// as a number rather than a one-element array.
render(&values[0])
} else {
serde_json::Value::Array(values.iter().map(render).collect())
}
}
ValueDef::Variant(v) => {
let inner = render(&scale_value::Value {
value: ValueDef::Composite(v.values.clone()),
context: value.context,
});
// `Era::Immortal` and friends carry nothing; render them as the name
// alone rather than `{"Immortal": []}`.
match &inner {
serde_json::Value::Array(a) if a.is_empty() => {
serde_json::Value::String(v.name.clone())
}
serde_json::Value::Object(o) if o.is_empty() => {
serde_json::Value::String(v.name.clone())
}
_ => {
let mut map = serde_json::Map::new();
map.insert(v.name.clone(), inner);
serde_json::Value::Object(map)
if let Some(prefix) = self.ss58_format {
if self.account_tys.contains(&value.context) {
if let Some(bytes) = account_bytes(value) {
return serde_json::Value::String(crate::ss58::encode(prefix, &bytes));
}
}
}
ValueDef::BitSequence(bits) => {
serde_json::Value::Array(bits.iter().map(serde_json::Value::Bool).collect())
match &value.value {
ValueDef::Primitive(p) => match p {
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()),
// **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)),
},
ValueDef::Composite(Composite::Named(fields)) => serde_json::Value::Object(
fields
.iter()
.map(|(k, v)| (k.clone(), self.render(v)))
.collect(),
),
ValueDef::Composite(Composite::Unnamed(values)) => {
if let Some(bytes) = as_bytes(values) {
serde_json::Value::String(hex(&bytes))
} else if values.len() == 1 {
// A newtype wrapper is noise; unwrap it so `Compact<u64>` reads
// as a number rather than a one-element array.
self.render(&values[0])
} else {
serde_json::Value::Array(values.iter().map(|v| self.render(v)).collect())
}
}
ValueDef::Variant(v) => {
let inner = self.render(&scale_value::Value {
value: ValueDef::Composite(v.values.clone()),
context: value.context,
});
// `Era::Immortal` and friends carry nothing; render them as the name
// alone rather than `{"Immortal": []}`.
match &inner {
serde_json::Value::Array(a) if a.is_empty() => {
serde_json::Value::String(v.name.clone())
}
serde_json::Value::Object(o) if o.is_empty() => {
serde_json::Value::String(v.name.clone())
}
_ => {
let mut map = serde_json::Map::new();
map.insert(v.name.clone(), inner);
serde_json::Value::Object(map)
}
}
}
ValueDef::BitSequence(bits) => {
serde_json::Value::Array(bits.iter().map(serde_json::Value::Bool).collect())
}
}
}
}
@@ -280,3 +295,27 @@ fn hex(bytes: &[u8]) -> String {
s
}
/// The 32 bytes behind an account id, whatever wrapper the registry put round it.
///
/// `AccountId32` is a newtype over `[u8; 32]`, so the decoded value is a
/// composite of a composite of primitives; a registry could also describe it
/// flat. Anything that is not exactly 32 bytes is not an account id and is left
/// to render as itself.
fn account_bytes(value: &scale_value::Value<u32>) -> Option<Vec<u8>> {
use scale_value::{Composite, ValueDef};
match &value.value {
ValueDef::Composite(Composite::Unnamed(values)) => {
if values.len() == 1 {
return account_bytes(&values[0]);
}
as_bytes(values).filter(|b| b.len() == 32)
}
ValueDef::Composite(Composite::Named(fields)) if fields.len() == 1 => {
account_bytes(&fields[0].1)
}
_ => None,
}
}

View File

@@ -8,7 +8,7 @@
//! 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::collections::{BTreeMap, BTreeSet};
use alloc::string::{String, ToString};
use alloc::vec::Vec;
@@ -88,6 +88,15 @@ pub struct Runtime {
/// Call type id per pallet name, so `encode_call` need not walk the pallet
/// list for every argument.
pub(crate) calls: BTreeMap<String, u32>,
/// Registry types that are account ids.
///
/// Found by their **path**, not their length: a block hash is also 32 bytes,
/// and rendering one as an address would be a lie. Held so that decoding can
/// turn them into something a person can check against what they expected to
/// see, rather than 32 bytes of hex nobody reads.
pub(crate) account_tys: BTreeSet<u32>,
/// The SS58 prefix to render account ids at, when one has been set.
pub(crate) ss58_format: Option<u16>,
}
impl Runtime {
@@ -147,14 +156,38 @@ impl Runtime {
.filter_map(|p| p.calls.as_ref().map(|c| (p.name.to_string(), c.ty.id)))
.collect();
let account_tys = metadata
.types
.types
.iter()
.filter(|t| {
t.ty.path
.segments
.last()
.is_some_and(|s| s == "AccountId32")
})
.map(|t| t.id)
.collect();
Ok(Self {
metadata,
extrinsic,
extensions,
account_tys,
calls,
extensions,
extrinsic,
metadata,
ss58_format: None,
})
}
/// Render account ids as SS58 at this prefix when decoding.
///
/// Off until set, because the prefix is a property of the chain a caller is
/// talking to rather than of the metadata, and guessing it would put a
/// plausible, wrong address in front of somebody about to approve a transfer.
pub fn set_ss58_format(&mut self, prefix: u16) {
self.ss58_format = Some(prefix);
}
/// The extrinsic format version the metadata declares.
///
/// Not to be confused with the preamble byte of any particular extrinsic —

View File

@@ -0,0 +1,51 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! SS58, so a decoded call names an address a person can recognise.
//!
//! An account id is 32 bytes. Printed as hex it is a correct description of the
//! value and useless to somebody being asked to check who they are paying —
//! which is the only moment in a wallet where reading the recipient matters.
use alloc::string::String;
use alloc::vec::Vec;
use blake2::digest::consts::U64;
use blake2::{Blake2b, Digest};
/// The domain separator Substrate hashes into every SS58 checksum.
const PREFIX: &[u8] = b"SS58PRE";
/// Encode an account id at a network prefix.
///
/// Prefixes below 64 are one byte; the rest are two, with the low six bits of
/// the first byte and the high two bits arranged as Substrate specifies. Quantus
/// is 189, so it takes the two-byte form — getting that wrong yields an address
/// that looks right and belongs to nobody.
pub fn encode(prefix: u16, account: &[u8]) -> String {
let mut body = match prefix {
0..=63 => alloc::vec![prefix as u8],
64..=16_383 => {
let low = ((prefix & 0b0000_0000_1111_1100) as u8) >> 2;
let high = (((prefix >> 8) as u8) | ((prefix & 0b0000_0000_0000_0011) as u8) << 6) as u8;
alloc::vec![low | 0b0100_0000, high]
}
// Reserved by the specification; nothing should ask for one.
_ => alloc::vec![0b0100_0000, 0],
};
body.extend_from_slice(account);
let mut hasher = Blake2b::<U64>::new();
hasher.update(PREFIX);
hasher.update(&body);
let checksum = hasher.finalize();
let mut out: Vec<u8> = body;
out.extend_from_slice(&checksum[..2]);
bs58::encode(out).into_string()
}

View File

@@ -156,7 +156,7 @@ impl Runtime {
)));
}
Ok(crate::decode::render(&value))
Ok(self.render(&value))
}
}

View File

@@ -267,3 +267,62 @@ fn a_storage_entry_refuses_the_wrong_number_of_keys() {
assert!(rt.storage_target("System", "Number", &[serde_json::json!(1)]).is_err());
assert!(rt.storage_target("System", "NoSuchThing", &[]).is_err());
}
/// The recipient of a transfer must render as an address somebody can check
/// against what they meant to type. 32 bytes of hex is a correct description of
/// the value and the one thing nobody reads.
///
/// The vector is crystal_bob on Heisenberg, taken from the chain rather than
/// computed here.
#[test]
fn an_account_id_in_a_call_renders_as_ss58() {
let mut rt = heisenberg();
let bob_id = "0x300bb607ba60e89461d2f9005668231ceb30237b33db53a614164b8590965519";
const BOB: &str = "qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y";
let call = rt
.encode_call(
"Balances",
"transfer_keep_alive",
&serde_json::json!({ "dest": { "Id": bob_id }, "value": "1000000000" }),
)
.expect("a transfer to bob");
// Until a prefix is set, hex. The prefix belongs to the chain a caller is
// talking to, not to the metadata, and a guessed one puts a plausible wrong
// address in front of somebody about to approve a transfer.
let raw = rt.decode_call(&call).expect("decodes");
assert_eq!(
raw["Balances"]["transfer_keep_alive"]["dest"]["Id"],
serde_json::json!(bob_id)
);
rt.set_ss58_format(189);
let decoded = rt.decode_call(&call).expect("decodes again");
assert_eq!(
decoded["Balances"]["transfer_keep_alive"]["dest"]["Id"],
serde_json::json!(BOB)
);
}
/// Found by registry path, not by length. A block hash is 32 bytes too, and
/// rendering one as an address would be a lie a reader cannot catch.
#[test]
fn a_32_byte_value_that_is_not_an_account_stays_hex() {
let mut rt = heisenberg();
rt.set_ss58_format(189);
let target = rt
.storage_target("System", "BlockHash", &[serde_json::json!(0)])
.expect("System::BlockHash is a map over block number");
let hash = [0x11u8; 32];
let decoded = rt
.decode_storage_value(target.value_ty, &hash)
.expect("a block hash decodes");
assert_eq!(decoded, serde_json::json!("0x".to_string() + &"11".repeat(32)));
}