feat: add @quantus/codec, driving encode and decode from runtime metadata

The extension has to build a signing payload, assemble an extrinsic and decode
a call well enough to show a user what they are approving. The obvious route was
@polkadot/api's codec. That is closed, and quantus/api#1 carries the tested
evidence:

  - @polkadot/types caps fixed arrays at 2048 bytes, and ML-DSA signatures are
    [u8;5261] and [u8;7219], so every Quantus extrinsic trips it
  - api.rpc.chain.getBlock throws on every block of this chain, at the timestamp
    inherent, because it reads the extrinsic preamble byte as a version when the
    top two bits are a type tag
  - it *guesses* that signed extensions it does not recognise contribute nothing
    to the signed payload

The third is why this is a package rather than a patch. The guess is right
today — the registry says ReversibleTransactionExtension and
WormholeProofRecorderExtension are empty on both halves — and it is right only
by luck. This chain's encoding has changed between runtimes, transactionVersion
has gone 2 -> 3 -> 6 across four upgrades, and when the guess stops holding the
wallet keeps signing: valid signatures over a payload missing bytes the runtime
put there, reported by the chain as BadProof, which is also what it reports for
a wrong key.

So nothing here names a pallet, a call, an extension or a signature scheme.
Every type id is read from metadata the node produced by running
Metadata_metadata against the runtime WASM in a given block's state, the same
oracle blackbeard.observer has been decoding against across four upgrade
boundaries. encode_extensions walks the declared extensions in order and refuses
to build a payload when one that encodes to something has no value supplied —
a wallet that cannot sign is a bug report, one that signs the wrong bytes is a
support case nobody diagnoses.

Proven end to end on Heisenberg at spec 148: a balances.transfer_keep_alive
built entirely here, signed by @quantus/crypto under QUANTUS_EXTRINSIC, included
at block 1050475 and read back from that block — inherent at index 0 included,
which is the block @polkadot/api cannot decode at all.

Two notes carried over from @quantus/crypto, both load-bearing: decode_checked
walks with scale_decode's IgnoreVisitor before scale_value touches the bytes,
because scale_value sizes a Vec from the length prefix before decoding an item
and an aborted allocation leaves no Err to catch; and the build needs binaryen
123, since 105 silently corrupts the output.

Closes #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
2026-09-15 14:14:34 +03:00
parent e8bf9e20c7
commit 3a1611a92e
23 changed files with 2839 additions and 6 deletions

502
packages/quantus-codec/Cargo.lock generated Normal file
View File

@@ -0,0 +1,502 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byte-slice-cast"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "const_format"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
dependencies = [
"const_format_proc_macros",
"konst",
]
[[package]]
name = "const_format_proc_macros"
version = "0.2.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
dependencies = [
"proc-macro2",
"quote",
"unicode-xid",
]
[[package]]
name = "derive_more"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "frame-metadata"
version = "23.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ba5be0edbdb824843a0f9c6f0906ecfc66c5316218d74457003218b24909ed0"
dependencies = [
"cfg-if",
"parity-scale-codec",
"scale-info",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "impl-trait-for-tuples"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "konst"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
dependencies = [
"konst_macro_rules",
]
[[package]]
name = "konst_macro_rules"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parity-scale-codec"
version = "3.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa"
dependencies = [
"arrayvec",
"byte-slice-cast",
"const_format",
"impl-trait-for-tuples",
"parity-scale-codec-derive",
"rustversion",
]
[[package]]
name = "parity-scale-codec-derive"
version = "3.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quantus_codec"
version = "0.0.0"
dependencies = [
"frame-metadata",
"parity-scale-codec",
"scale-decode",
"scale-info",
"scale-value",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scale-bits"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27243ab0d2d6235072b017839c5f0cd1a3b1ce45c0f7a715363b0c7d36c76c94"
dependencies = [
"parity-scale-codec",
"scale-info",
"scale-type-resolver",
"serde",
]
[[package]]
name = "scale-decode"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d6ed61699ad4d54101ab5a817169259b5b0efc08152f8632e61482d8a27ca3d"
dependencies = [
"parity-scale-codec",
"scale-bits",
"scale-type-resolver",
"smallvec",
"thiserror",
]
[[package]]
name = "scale-encode"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2a976d73564a59e482b74fd5d95f7518b79ca8c8ca5865398a4d629dd15ee50"
dependencies = [
"parity-scale-codec",
"scale-bits",
"scale-type-resolver",
"smallvec",
"thiserror",
]
[[package]]
name = "scale-info"
version = "2.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b"
dependencies = [
"cfg-if",
"derive_more",
"parity-scale-codec",
"scale-info-derive",
]
[[package]]
name = "scale-info-derive"
version = "2.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6630024bf739e2179b91fb424b28898baf819414262c5d376677dbff1fe7ebf"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "scale-type-resolver"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0cded6518aa0bd6c1be2b88ac81bf7044992f0f154bfbabd5ad34f43512abcb"
dependencies = [
"scale-info",
"smallvec",
]
[[package]]
name = "scale-value"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b64809a541e8d5a59f7a9d67cc700cdf5d7f907932a83a0afdedc90db07ccb"
dependencies = [
"either",
"parity-scale-codec",
"scale-bits",
"scale-decode",
"scale-encode",
"scale-type-resolver",
"thiserror",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap",
"toml_datetime",
"toml_parser",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.5",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winnow"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View File

@@ -0,0 +1,56 @@
# Metadata-driven SCALE encode/decode for the Quantus chain, compiled to WASM.
#
# A separate crate from `quantus-crypto` for the same reason that one is separate
# from `wasm-crypto`: different dependency graphs, built independently. They ship
# as sibling packages and the extension uses both — this one decides *what bytes*
# get signed, that one signs them.
#
# Why this exists at all rather than `@polkadot/api`: quantus/api#1. In short,
# polkadot-js cannot decode a Quantus block (it reads the extrinsic preamble byte
# as a version when the top two bits are a type tag), it refuses fixed arrays
# longer than 2048 (ML-DSA signatures are 5261 and 7219 bytes), and — the part
# that matters after those are patched — it *guesses* that signed extensions it
# does not recognise contribute nothing to the signed payload. On a chain whose
# encoding has already changed between runtimes, a guess like that produces a
# valid signature over the wrong bytes, which arrives as `BadProof` and looks
# exactly like a wrong key. See quantus/wasm#3.
[package]
authors = ["Quantus Network Developers <hello@quantus.com>"]
description = "Metadata-driven SCALE codec for the Quantus chain, as WASM bindings."
edition = "2021"
license = "Apache-2.0"
name = "quantus_codec"
publish = false
repository = "https://git.lair.cafe/quantus/wasm"
resolver = "2"
version = "0.0.0"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# Versions match blackbeard.observer's, which is the other consumer decoding this
# chain against its own metadata and the reference implementation for this crate.
frame-metadata = { version = "23", default-features = false, features = ["current", "decode"] }
parity-scale-codec = { version = "3", default-features = false, features = ["derive"] }
scale-info = { version = "2", default-features = false }
scale-value = { version = "0.18", default-features = false }
# Only for `IgnoreVisitor`. `scale_value` sizes a sequence's Vec from the length
# prefix *before* decoding an item, so a blob that disagrees with the registry can
# ask for an allocation of any size and abort the process — there is no Err to
# catch. Pinned to the version `scale-value` itself resolves so both see one
# registry. blackbeard.observer took a 76 GiB allocation to find this.
scale-decode = { version = "0.16", default-features = false }
serde_json = "1"
wasm-bindgen = "0.2"
[profile.release]
codegen-units = 1
debug = false
debug-assertions = false
incremental = false
lto = true
opt-level = "z"
panic = "abort"
rpath = false

View File

@@ -0,0 +1,88 @@
# @quantus/codec
Metadata-driven SCALE encode and decode for the [Quantus](https://quantus.com)
chain, compiled to WASM.
Nothing in this package names a pallet, a call, a signed extension or a signature
scheme. Everything is read from the metadata the node produced by running
`Metadata_metadata` against the runtime WASM in a given block's state, which
makes the runtime the oracle rather than this package's author.
## Why not `@polkadot/api`
Three reasons, in increasing order of importance — the evidence is on
[quantus/api#1](https://git.lair.cafe/quantus/api/issues/1).
1. `@polkadot/types` refuses fixed arrays longer than 2048 bytes. ML-DSA
signatures are `[u8;5261]` and `[u8;7219]`, so every Quantus extrinsic trips
it.
2. `api.rpc.chain.getBlock` throws on **every block of this chain**, at the
timestamp inherent. The extrinsic preamble byte's top two bits are a type tag
(`0b00` bare, `0b10` signed, `0b01` general) and the low six are the version;
Quantus emits `0x84` — signed, v4 — and `0x05` — bare, v5 — in the same block
while the metadata declares version 4. polkadot-js reads that byte as a
version.
3. It **guesses** that signed extensions it does not recognise contribute nothing
to the signed payload, logging `Unknown signed extensions … treating them as
no-effect`.
The third is why this package exists rather than a patch. The guess is correct
only while every unrecognised extension happens to be zero-sized. This chain's
encoding has already changed between runtimes — `transactionVersion` has gone
2 → 3 → 6 across four upgrades, each an extrinsic-format change — and when the
guess stops being correct the wallet keeps signing. Those signatures are
cryptographically valid, over a payload missing bytes the runtime put there, and
the chain reports them as `BadProof`, which is also what it reports for a wrong
key. Silent, remote, and indistinguishable from the one thing it is not.
Here the registry decides. An extension whose declared type encodes to nothing
contributes nothing; anything else must be supplied by the caller or no payload
is produced at all.
## Use
```ts
import { Runtime } from '@quantus/codec';
const runtime = Runtime.fromMetadata(await fetchMetadata()); // state_getMetadata
const call = runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: '0x…' },
value: '1000000000'
});
const values = runtime.standardExtensions({
blockHash: genesisHash, // immortal era
genesisHash,
nonce,
specVersion,
transactionVersion
});
const payload = runtime.signerPayload(call, values);
// sign `payload` with @quantus/crypto under the QUANTUS_EXTRINSIC context,
// hashing it first with BLAKE2b-256 if it is longer than 256 bytes
const extrinsic = runtime.encodeExtrinsic(
{ Id: accountId },
signature,
runtime.encodeExtra(values),
call
);
```
`standardExtensions` fills in the extensions Substrate itself defines. Anything
else this runtime declares as non-empty is refused by name — see above for why
that is the desired behaviour rather than a limitation.
## Build
```
./scripts/build-quantus.sh quantus-codec
```
Same constraints as `@quantus/crypto`: a modern toolchain (separate from
`wasm-crypto`'s 2022 nightly), `initSync` over base64+zlib for the MV3 CSP,
wasm-bindgen's own glue rather than `@polkadot/wasm-bridge`, and **binaryen 123**
— version 105 silently corrupts the output. See
[quantus/wasm#1](https://git.lair.cafe/quantus/wasm/issues/1) and
[#3](https://git.lair.cafe/quantus/wasm/issues/3).

View File

@@ -0,0 +1,24 @@
{
"author": "Quantus Network Developers <hello@quantus.com>",
"bugs": "https://git.lair.cafe/quantus/wasm/issues",
"description": "Metadata-driven SCALE encode/decode for the Quantus chain",
"engines": {
"node": ">=18"
},
"homepage": "https://git.lair.cafe/quantus/wasm/src/branch/main/packages/quantus-codec#readme",
"license": "Apache-2.0",
"name": "@quantus/codec",
"repository": {
"directory": "packages/quantus-codec",
"type": "git",
"url": "https://git.lair.cafe/quantus/wasm.git"
},
"sideEffects": false,
"type": "module",
"version": "0.1.0",
"main": "index.js",
"dependencies": {
"fflate": "^0.8.2",
"tslib": "^2.7.0"
}
}

View File

@@ -0,0 +1,8 @@
# Matches the chain's toolchain (chain/rust-toolchain), so this crate is built by
# the same compiler that builds the runtime it has to agree with. Upstream's
# `wasm-crypto` keeps its own nightly-2022-06-24 pin; the two builds are separate
# on purpose. See quantus/wasm#1.
[toolchain]
channel = "1.93.0"
targets = ["wasm32-unknown-unknown"]
profile = "minimal"

View File

@@ -0,0 +1,51 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
// An array indexer rather than a Map: the input is ASCII by construction, so it
// cannot overflow, and array access is measurably faster on the hot loop.
const MAP = new Array<number>(256);
for (let i = 0; i < CHARS.length; i++) {
MAP[CHARS.charCodeAt(i)] = i;
}
/**
* Decode base64 into a caller-supplied buffer.
*
* Deliberately not `atob` or `Buffer.from`: the first is browser-only, the second
* node-only, and this runs in an MV3 service worker, a Worker, node tests and a
* bundled extension page. The output length is known at build time, so the
* caller provides the buffer and there is no growth or reallocation.
*
* This is a reimplementation of `@polkadot/wasm-util`'s base64Decode, which was
* the dependency it replaced. That package's index re-exports `packageDetect`,
* dragging in a `@polkadot/util` peer dependency for a side effect we do not
* want, and being a workspace package it resolved through its own repo's
* node_modules when consumed by symlink from another checkout. Fifteen lines is
* cheaper than either problem.
*/
export function base64Decode (data: string, out: Uint8Array): Uint8Array {
let byte = 0;
let bits = 0;
let pos = 0;
for (let i = 0; i < data.length && pos < out.length; i++) {
const value = MAP[data.charCodeAt(i)];
if (value === undefined) {
continue;
}
byte = (byte << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[pos++] = (byte >>> bits) & 0xff;
}
}
return out;
}

6
packages/quantus-codec/src/bytes.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const lenIn: number;
export declare const lenOut: number;
export declare const bytes: string;

View File

@@ -0,0 +1,10 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Generated as part of the build, do not edit
export const lenIn = 0;
export const lenOut = 0;
export const bytes = '';

View File

@@ -0,0 +1,176 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { initWasm } from './init.js';
import { QuantusRuntime } from './generated/quantus_codec.js';
/** What one signed extension needs from the caller, as the runtime declares it. */
export interface ExtensionNeed {
identifier: string;
/** Whether its `ty` encodes to anything — i.e. whether it goes on the wire. */
needsExtra: boolean;
/** Whether its `additional_signed` encodes to anything. */
needsAdditional: boolean;
}
/** A value for one signed extension. Omit a half the runtime declares as empty. */
export interface ExtensionValue {
extra?: unknown;
additional?: unknown;
}
export type ExtensionValues = Record<string, ExtensionValue>;
export interface DecodedExtrinsic {
/** The preamble byte's low six bits — **not** the byte. See `decodeExtrinsic`. */
version: number;
signed: boolean;
address: unknown;
signature: unknown;
extra: unknown;
call: unknown;
}
/** Everything needed to fill in the signed extensions Substrate itself defines. */
export interface PayloadOptions {
specVersion: number;
transactionVersion: number;
genesisHash: string;
/** The era's birth block. For an immortal era this is the genesis hash. */
blockHash: string;
nonce: number;
tip?: bigint | string;
/** `'Immortal'`, or `{ MortalN: phase }` as the registry spells it. */
era?: unknown;
/** `CheckMetadataHash`: `null` disables it, which is what a wallet wants. */
metadataHash?: string | null;
}
/**
* A runtime, loaded from the metadata it produced about itself.
*
* Construct one per spec version and keep it: parsing metadata is the expensive
* part, and the blob does not change until the chain upgrades.
*/
export class Runtime {
readonly #inner: QuantusRuntime;
private constructor (inner: QuantusRuntime) {
this.#inner = inner;
}
/**
* Parse metadata exactly as `state_getMetadata` returns it.
*
* That RPC takes a block hash and makes the node run `Metadata_metadata`
* against the runtime code in *that block's* state — so this is the runtime
* describing itself, and it is the only description that cannot go stale.
*/
static fromMetadata (metadata: Uint8Array): Runtime {
const failed = initWasm();
if (failed) {
throw new Error(`@quantus/codec: WASM unavailable: ${failed}`);
}
return new Runtime(new QuantusRuntime(metadata));
}
/** The extrinsic format version the metadata declares. */
get extrinsicVersion (): number {
return this.#inner.extrinsicVersion();
}
/**
* Every signed extension, in the order the runtime applies them — which is the
* order their bytes appear in the payload.
*/
signedExtensions (): ExtensionNeed[] {
return JSON.parse(this.#inner.signedExtensions()) as ExtensionNeed[];
}
/** Encode a call by name. `args` is keyed by the runtime's own argument names. */
encodeCall (pallet: string, call: string, args: Record<string, unknown>): Uint8Array {
return this.#inner.encodeCall(pallet, call, JSON.stringify(args));
}
/** The `extra`: what the extensions contribute to the extrinsic itself. */
encodeExtra (values: ExtensionValues): Uint8Array {
return this.#inner.encodeExtra(JSON.stringify(values));
}
/**
* The bytes to sign: `call ‖ extra ‖ additional`.
*
* Substrate's rule that a payload over 256 bytes is signed as its BLAKE2b-256
* hash is **not** applied here — that belongs with the signing code, which also
* chooses the FIPS 204 context. Splitting one rule across two packages is how
* the halves drift apart.
*/
signerPayload (call: Uint8Array, values: ExtensionValues): Uint8Array {
return this.#inner.signerPayload(call, JSON.stringify(values));
}
/**
* Assemble a signed extrinsic, ready for `author_submitAndWatchExtrinsic`.
*
* `signature` is the encoded `Signature` type with its variant byte already in
* place: the signer knows which ML-DSA scheme its key is, and re-deriving that
* here from the byte length would be a second source of truth.
*/
encodeExtrinsic (address: unknown, signature: Uint8Array, extra: Uint8Array, call: Uint8Array): Uint8Array {
return this.#inner.encodeExtrinsic(JSON.stringify(address), signature, extra, call);
}
/**
* Decode one extrinsic, length prefix and all.
*
* The returned `version` is the preamble byte's low six bits. The top two are a
* type tag — `0b00` bare, `0b10` signed, `0b01` general — so Quantus emits
* `0x84` (signed, v4) and `0x05` (bare, v5) in the same block while the
* metadata declares version 4. Three numbers, all correct. Reading that byte as
* a version is why `@polkadot/api` cannot decode a single block of this chain.
*/
decodeExtrinsic (blob: Uint8Array): DecodedExtrinsic {
return JSON.parse(this.#inner.decodeExtrinsic(blob)) as DecodedExtrinsic;
}
/** Decode a bare call — what an approval screen shows the user. */
decodeCall (bytes: Uint8Array): unknown {
return JSON.parse(this.#inner.decodeCall(bytes)) as unknown;
}
/**
* Fill in the signed extensions that Substrate itself defines, from one
* options object.
*
* This covers the extensions whose meaning is fixed by Substrate. It
* deliberately does **not** try to cover every extension a runtime might
* declare: anything else that needs a value will be refused by
* `signerPayload` with the extension's name, which is the correct outcome —
* a wallet that cannot sign is a bug report, and one that signs a payload
* missing bytes the runtime put there is a `BadProof` nobody can diagnose.
*
* Pass the result, extended with whatever else this runtime asks for, to
* `signerPayload` and `encodeExtra`.
*/
standardExtensions (options: PayloadOptions): ExtensionValues {
const values: ExtensionValues = {
ChargeTransactionPayment: { extra: (options.tip ?? 0n).toString() },
CheckGenesis: { additional: options.genesisHash },
CheckMetadataHash: {
// `Mode::Disabled`, and `None`. Enabling it would mean shipping a
// metadata hash this package has no way to compute.
additional: options.metadataHash ? { Some: options.metadataHash } : 'None',
extra: 'Disabled'
},
CheckMortality: { additional: options.blockHash, extra: options.era ?? 'Immortal' },
CheckNonce: { extra: options.nonce },
CheckSpecVersion: { additional: options.specVersion },
CheckTxVersion: { additional: options.transactionVersion }
};
return values;
}
}

View File

@@ -0,0 +1,100 @@
/* tslint:disable */
/* eslint-disable */
/**
* A loaded runtime description, held across calls so the metadata is parsed
* once per spec version rather than once per signature.
*/
export class QuantusRuntime {
free(): void;
[Symbol.dispose](): void;
/**
* Decode a bare call — what an approval screen shows the user.
*/
decodeCall(bytes: Uint8Array): string;
/**
* Decode one extrinsic as this runtime describes it, as JSON.
*/
decodeExtrinsic(blob: Uint8Array): string;
/**
* Encode a call by name. `args` is a JSON object keyed by argument name.
*/
encodeCall(pallet: string, call: string, args: string): Uint8Array;
/**
* The `extra` alone, which the extrinsic carries and the payload repeats.
*/
encodeExtra(extensions: string): Uint8Array;
/**
* Assemble a signed extrinsic, ready for `author_submitAndWatchExtrinsic`.
*/
encodeExtrinsic(address: string, signature: Uint8Array, extra: Uint8Array, call: Uint8Array): Uint8Array;
/**
* The extrinsic format version this runtime declares.
*/
extrinsicVersion(): number;
/**
* Parse metadata as `state_getMetadata` returns it.
*/
constructor(metadata: Uint8Array);
/**
* Every signed extension, in order, as
* `[{ identifier, needsExtra, needsAdditional }]`.
*
* The two booleans are what a caller has to satisfy, read from the
* registry. A caller that ignores them gets an error rather than a short
* payload.
*/
signedExtensions(): string;
/**
* The bytes to sign, given an encoded call and the extension values.
*
* `extensions` is a JSON object keyed by extension identifier, each value
* `{ extra?, additional? }`. Omitting one the runtime declares as non-empty
* is an error — see [`Runtime::encode_extensions`].
*/
signerPayload(call: Uint8Array, extensions: string): Uint8Array;
}
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
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_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];
readonly quantusruntime_extrinsicVersion: (a: number) => number;
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 __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

View File

@@ -0,0 +1,401 @@
/* @ts-self-types="./quantus_codec.d.ts" */
/**
* A loaded runtime description, held across calls so the metadata is parsed
* once per spec version rather than once per signature.
*/
export class QuantusRuntime {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
QuantusRuntimeFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_quantusruntime_free(ptr, 0);
}
/**
* Decode a bare call — what an approval screen shows the user.
* @param {Uint8Array} bytes
* @returns {string}
*/
decodeCall(bytes) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_decodeCall(this.__wbg_ptr, 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);
}
}
/**
* Decode one extrinsic as this runtime describes it, as JSON.
* @param {Uint8Array} blob
* @returns {string}
*/
decodeExtrinsic(blob) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(blob, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_decodeExtrinsic(this.__wbg_ptr, 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
* @param {string} call
* @param {string} args
* @returns {Uint8Array}
*/
encodeCall(pallet, call, args) {
const ptr0 = passStringToWasm0(pallet, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(call, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passStringToWasm0(args, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_encodeCall(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v4 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v4;
}
/**
* The `extra` alone, which the extrinsic carries and the payload repeats.
* @param {string} extensions
* @returns {Uint8Array}
*/
encodeExtra(extensions) {
const ptr0 = passStringToWasm0(extensions, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_encodeExtra(this.__wbg_ptr, ptr0, len0);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* Assemble a signed extrinsic, ready for `author_submitAndWatchExtrinsic`.
* @param {string} address
* @param {Uint8Array} signature
* @param {Uint8Array} extra
* @param {Uint8Array} call
* @returns {Uint8Array}
*/
encodeExtrinsic(address, signature, extra, call) {
const ptr0 = passStringToWasm0(address, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(signature, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passArray8ToWasm0(extra, wasm.__wbindgen_malloc);
const len2 = WASM_VECTOR_LEN;
const ptr3 = passArray8ToWasm0(call, wasm.__wbindgen_malloc);
const len3 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_encodeExtrinsic(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v5 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v5;
}
/**
* The extrinsic format version this runtime declares.
* @returns {number}
*/
extrinsicVersion() {
const ret = wasm.quantusruntime_extrinsicVersion(this.__wbg_ptr);
return ret;
}
/**
* Parse metadata as `state_getMetadata` returns it.
* @param {Uint8Array} metadata
*/
constructor(metadata) {
const ptr0 = passArray8ToWasm0(metadata, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_new(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
this.__wbg_ptr = ret[0];
QuantusRuntimeFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Every signed extension, in order, as
* `[{ identifier, needsExtra, needsAdditional }]`.
*
* The two booleans are what a caller has to satisfy, read from the
* registry. A caller that ignores them gets an error rather than a short
* payload.
* @returns {string}
*/
signedExtensions() {
let deferred2_0;
let deferred2_1;
try {
const ret = wasm.quantusruntime_signedExtensions(this.__wbg_ptr);
var ptr1 = ret[0];
var len1 = ret[1];
if (ret[3]) {
ptr1 = 0; len1 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred2_0 = ptr1;
deferred2_1 = len1;
return getStringFromWasm0(ptr1, len1);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* The bytes to sign, given an encoded call and the extension values.
*
* `extensions` is a JSON object keyed by extension identifier, each value
* `{ extra?, additional? }`. Omitting one the runtime declares as non-empty
* is an error — see [`Runtime::encode_extensions`].
* @param {Uint8Array} call
* @param {string} extensions
* @returns {Uint8Array}
*/
signerPayload(call, extensions) {
const ptr0 = passArray8ToWasm0(call, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(extensions, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_signerPayload(this.__wbg_ptr, ptr0, len0, ptr1, len1);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v3;
}
}
if (Symbol.dispose) QuantusRuntime.prototype[Symbol.dispose] = QuantusRuntime.prototype.free;
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg_Error_67e7344beaa85059: function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
},
__wbg___wbindgen_throw_5d9e815e6fdf150f: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./quantus_codec_bg.js": import0,
};
}
const QuantusRuntimeFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_quantusruntime_free(ptr, 1));
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (!module.ok) {
throw new Error(`failed to fetch Wasm: ${module.status} ${module.statusText} fetching '${module.url}'`);
}
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync };

View File

@@ -0,0 +1,6 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
export { Runtime } from './codec.js';
export type { DecodedExtrinsic, ExtensionNeed, ExtensionValue, ExtensionValues, PayloadOptions } from './codec.js';
export { initWasm, isReady } from './init.js';

View File

@@ -0,0 +1,61 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { unzlibSync } from 'fflate';
import { base64Decode } from './base64.js';
import { bytes, lenOut } from './bytes.js';
import { initSync } from './generated/quantus_codec.js';
/**
* Instantiate the WASM, synchronously, from bytes compiled into this file.
*
* Three constraints shape this, and all three rule out the obvious approach:
*
* - the background context is an **MV3 service worker**, so there is no DOM, no
* reliable `fetch` of extension-relative URLs at arbitrary times, and the
* worker can be killed and cold-started between any two messages
* - the extension CSP is `script-src 'self' 'wasm-unsafe-eval'`, which permits
* compiling WASM but not fetching it from anywhere interesting
* - callers are synchronous — `pair.sign()` in the keyring has no `await` to give
*
* So the WASM is zlib-compressed, base64'd into `bytes.js` at build time, and
* instantiated here with wasm-bindgen's `initSync`. Nothing is fetched, and the
* whole module is ready before the first call returns.
*
* Deliberately *not* using `@polkadot/wasm-bridge`: its `Bridge` implements
* wasm-bindgen 0.2.79's JS-heap ABI, and this crate is built with 0.2.128, which
* uses externref tables. See quantus/wasm#1.
*/
let initialised = false;
let initError: string | null = null;
/**
* Ensure the WASM is instantiated. Idempotent and cheap after the first call.
*
* Returns `null` on success, or the failure reason. It does not throw: a caller
* deciding whether to offer a Quantus account at all wants to ask, and an
* exception thrown from module scope in a service worker is hard to attribute.
*/
export function initWasm (): string | null {
if (initialised) {
return initError;
}
initialised = true;
try {
initSync({ module: unzlibSync(base64Decode(bytes, new Uint8Array(lenOut))) });
} catch (error) {
initError = (error as Error).message;
}
return initError;
}
/** Whether the WASM is available. Callers that can fall back should ask first. */
export function isReady (): boolean {
return initWasm() === null;
}

View File

@@ -0,0 +1,34 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Metadata-driven SCALE encode and decode for the Quantus chain.
//!
//! Nothing here names a pallet, a call, a signed extension or a signature
//! scheme. Everything is read from the metadata the node produced by running
//! `Metadata_metadata` against the runtime WASM in a given block's state, which
//! makes the runtime the oracle rather than this crate's author.
//!
//! That is not fastidiousness. This chain's encoding has changed between
//! runtimes — `transactionVersion` has gone 2 → 3 → 6 across four upgrades, and
//! each of those is an extrinsic-format change. A signer holding a hand-written
//! idea of the format keeps producing signatures after such an upgrade; they are
//! cryptographically valid, over the wrong bytes, and the chain reports them as
//! `BadProof`, which is what it also reports for a wrong key. See quantus/wasm#3.
extern crate alloc;
#[path = "rs/runtime.rs"]
pub mod runtime;
#[path = "rs/decode.rs"]
pub mod decode;
#[path = "rs/encode.rs"]
pub mod encode;
#[path = "rs/bindings.rs"]
mod bindings;
#[cfg(test)]
#[path = "rs/tests.rs"]
mod tests;

View File

@@ -0,0 +1,169 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! The `wasm_bindgen` surface.
//!
//! Deliberately thin: every one of these is a parse of the JS argument, a call
//! into a module that knows nothing about JS, and a serialisation back. The
//! logic lives in [`crate::runtime`], [`crate::decode`] and [`crate::encode`]
//! because `JsError` cannot be constructed outside a wasm target, so anything
//! built on it is untestable by `cargo test` — a lesson from quantus/wasm#1,
//! where the error paths were the ones that turned out to be wrong.
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use wasm_bindgen::prelude::*;
use crate::encode::ExtensionValue;
use crate::runtime::Runtime;
/// A loaded runtime description, held across calls so the metadata is parsed
/// once per spec version rather than once per signature.
#[wasm_bindgen]
pub struct QuantusRuntime {
inner: Runtime,
}
#[wasm_bindgen]
impl QuantusRuntime {
/// Parse metadata as `state_getMetadata` returns it.
#[wasm_bindgen(constructor)]
pub fn new(metadata: &[u8]) -> Result<QuantusRuntime, JsError> {
Runtime::from_metadata(metadata)
.map(|inner| QuantusRuntime { inner })
.map_err(|e| JsError::new(&e.to_string()))
}
/// The extrinsic format version this runtime declares.
#[wasm_bindgen(js_name = extrinsicVersion)]
pub fn extrinsic_version(&self) -> u8 {
self.inner.extrinsic_version()
}
/// Every signed extension, in order, as
/// `[{ identifier, needsExtra, needsAdditional }]`.
///
/// The two booleans are what a caller has to satisfy, read from the
/// registry. A caller that ignores them gets an error rather than a short
/// payload.
#[wasm_bindgen(js_name = signedExtensions)]
pub fn signed_extensions(&self) -> Result<String, JsError> {
let described: Vec<serde_json::Value> = self
.inner
.extensions()
.iter()
.map(|e| {
serde_json::json!({
"identifier": e.identifier,
"needsExtra": !self.inner.is_empty_ty_pub(e.ty),
"needsAdditional": !self.inner.is_empty_ty_pub(e.additional)
})
})
.collect();
serde_json::to_string(&described).map_err(|e| JsError::new(&e.to_string()))
}
/// Encode a call by name. `args` is a JSON object keyed by argument name.
#[wasm_bindgen(js_name = encodeCall)]
pub fn encode_call(&self, pallet: &str, call: &str, args: &str) -> Result<Vec<u8>, JsError> {
let args: serde_json::Value =
serde_json::from_str(args).map_err(|e| JsError::new(&e.to_string()))?;
self.inner
.encode_call(pallet, call, &args)
.map_err(|e| JsError::new(&e.to_string()))
}
/// The bytes to sign, given an encoded call and the extension values.
///
/// `extensions` is a JSON object keyed by extension identifier, each value
/// `{ extra?, additional? }`. Omitting one the runtime declares as non-empty
/// is an error — see [`Runtime::encode_extensions`].
#[wasm_bindgen(js_name = signerPayload)]
pub fn signer_payload(&self, call: &[u8], extensions: &str) -> Result<Vec<u8>, JsError> {
let encoded = self.encoded_extensions(extensions)?;
Ok(self.inner.signer_payload(call, &encoded))
}
/// The `extra` alone, which the extrinsic carries and the payload repeats.
#[wasm_bindgen(js_name = encodeExtra)]
pub fn encode_extra(&self, extensions: &str) -> Result<Vec<u8>, JsError> {
Ok(self.encoded_extensions(extensions)?.extra)
}
/// Assemble a signed extrinsic, ready for `author_submitAndWatchExtrinsic`.
#[wasm_bindgen(js_name = encodeExtrinsic)]
pub fn encode_extrinsic(
&self,
address: &str,
signature: &[u8],
extra: &[u8],
call: &[u8],
) -> Result<Vec<u8>, JsError> {
let address: serde_json::Value =
serde_json::from_str(address).map_err(|e| JsError::new(&e.to_string()))?;
self.inner
.encode_extrinsic(&address, signature, extra, call)
.map_err(|e| JsError::new(&e.to_string()))
}
/// Decode one extrinsic as this runtime describes it, as JSON.
#[wasm_bindgen(js_name = decodeExtrinsic)]
pub fn decode_extrinsic(&self, blob: &[u8]) -> Result<String, JsError> {
let xt = self
.inner
.decode_extrinsic(blob)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&serde_json::json!({
"version": xt.version,
"signed": xt.signed,
"address": xt.address,
"signature": xt.signature,
"extra": xt.extra,
"call": xt.call
}))
.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> {
let call = self
.inner
.decode_call(bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&call).map_err(|e| JsError::new(&e.to_string()))
}
fn encoded_extensions(
&self,
extensions: &str,
) -> Result<crate::encode::EncodedExtensions, JsError> {
let parsed: BTreeMap<String, serde_json::Value> =
serde_json::from_str(extensions).map_err(|e| JsError::new(&e.to_string()))?;
let values: BTreeMap<String, ExtensionValue> = parsed
.into_iter()
.map(|(k, v)| {
(
k,
ExtensionValue {
extra: v.get("extra").cloned(),
additional: v.get("additional").cloned(),
},
)
})
.collect();
self.inner
.encode_extensions(&values)
.map_err(|e| JsError::new(&e.to_string()))
}
}

View File

@@ -0,0 +1,275 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Reading a chain's own data using the chain's own description of it.
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use parity_scale_codec::Decode;
use crate::runtime::{CodecError, Runtime};
/// One decoded extrinsic, named as the runtime names it.
#[derive(Debug, Clone, PartialEq)]
pub struct DecodedExtrinsic {
/// The **version** from the preamble byte — its low six bits, not the byte.
pub version: u8,
/// Whether the preamble's type tag says signed.
pub signed: bool,
/// The address as the runtime's `Address` type decodes, rendered as JSON.
pub address: serde_json::Value,
/// The signature, rendered as JSON. For Quantus this is a
/// `DilithiumSignatureScheme` variant carrying `sig ‖ pk`.
pub signature: serde_json::Value,
/// The signed extensions as submitted — mortality, nonce, tip, and whatever
/// else this runtime declares.
pub extra: serde_json::Value,
/// The `extra` exactly as it appeared on the wire. Kept because it is half
/// of the signed payload, and re-encoding it from `extra` would be a second
/// implementation that could disagree.
pub extra_bytes: Vec<u8>,
/// The call, SCALE-encoded, as it appeared on the wire.
pub call_bytes: Vec<u8>,
/// The call, decoded.
pub call: serde_json::Value,
}
impl Runtime {
/// Decode one extrinsic, exactly as this runtime describes it.
///
/// ## The preamble byte is not the version
///
/// The **top two bits are a type tag** and the low six are the version:
/// `0b00` bare, `0b10` signed, `0b01` general. Quantus emits `0x84` —
/// signed, v4 — and `0x05` — bare, v**5** — in the same block, while the
/// metadata declares extrinsic version 4. Three different numbers, all
/// correct.
///
/// A decoder that reads the byte as a version and checks it against the
/// metadata rejects every timestamp inherent on the chain. `@polkadot/api`
/// does exactly that, which is why it cannot read a single Quantus block
/// (quantus/api#1), and it is the first thing to break when someone
/// "simplifies" this function.
///
/// `blob` is the extrinsic as the node hands it over: its own length prefix
/// first. A partial read is refused — trailing bytes mean the metadata does
/// not match these bytes, and a decoder that shrugs at that is how a block
/// gets silently mis-read after an upgrade.
pub fn decode_extrinsic(&self, blob: &[u8]) -> Result<DecodedExtrinsic, CodecError> {
let mut cursor = blob;
let declared = <parity_scale_codec::Compact<u64>>::decode(&mut cursor)
.map_err(|e| CodecError::Decode(format!("no length prefix: {e}")))?
.0 as usize;
if cursor.len() != declared {
return Err(CodecError::Decode(format!(
"declared {declared} bytes, {} present",
cursor.len()
)));
}
let preamble = *cursor
.first()
.ok_or_else(|| CodecError::Decode("empty extrinsic".to_string()))?;
cursor = &cursor[1..];
let signed = preamble & 0b1100_0000 == 0b1000_0000;
let version = preamble & 0b0011_1111;
let tys = self.extrinsic;
let (address, signature, extra, extra_bytes) = if signed {
let address = self.decode_at(tys.address, &mut cursor, "address")?;
let signature = self.decode_at(tys.signature, &mut cursor, "signature")?;
let before = cursor;
let extra = self.decode_at(tys.extra, &mut cursor, "signed extensions")?;
let extra_bytes = before[..before.len() - cursor.len()].to_vec();
(address, signature, extra, extra_bytes)
} else {
(
serde_json::Value::Null,
serde_json::Value::Null,
serde_json::Value::Null,
Vec::new(),
)
};
let call_bytes = cursor.to_vec();
let call = self.decode_at(tys.call, &mut cursor, "call")?;
if !cursor.is_empty() {
return Err(CodecError::Decode(format!(
"{} trailing bytes; metadata does not match this extrinsic",
cursor.len()
)));
}
Ok(DecodedExtrinsic {
version,
signed,
address,
signature,
extra,
extra_bytes,
call_bytes: call_bytes[..call_bytes.len() - cursor.len()].to_vec(),
call,
})
}
/// Decode a bare call — what an approval screen needs to say what is about
/// to be authorised.
pub fn decode_call(&self, bytes: &[u8]) -> Result<serde_json::Value, CodecError> {
let mut cursor = bytes;
let call = self.decode_at(self.extrinsic.call, &mut cursor, "call")?;
if !cursor.is_empty() {
return Err(CodecError::Decode(format!(
"{} trailing bytes after call",
cursor.len()
)));
}
Ok(call)
}
fn decode_at(
&self,
ty: u32,
cursor: &mut &[u8],
what: &str,
) -> Result<serde_json::Value, CodecError> {
let value = self
.decode_checked(ty, cursor)
.map_err(|e| CodecError::Decode(format!("{what}: {e}")))?;
Ok(render(&value))
}
/// Decode one registry type, walking the bytes first without building
/// anything from them.
///
/// **`scale_value` sizes a sequence's `Vec` from the length prefix before it
/// decodes a single item.** A blob that disagrees with the registry can
/// therefore ask for an allocation of any size at all, and Rust aborts on a
/// failed one — so there is no `Err` for a caller to catch, and `.ok()` at
/// the call site cannot help. blackbeard.observer found this in production
/// as a 76 GiB request that took the daemon down every two minutes.
///
/// `scale_decode`'s `IgnoreVisitor` walks the same bytes against the same
/// type and allocates nothing at all, so a length that cannot be satisfied
/// runs out of input on the first item and comes back as an error. The
/// second pass costs one more walk of a few kilobytes.
fn decode_checked(
&self,
ty: u32,
cursor: &mut &[u8],
) -> Result<scale_value::Value<u32>, String> {
let mut probe: &[u8] = cursor;
scale_decode::visitor::decode_with_visitor(
&mut probe,
ty,
self.types(),
scale_decode::visitor::IgnoreVisitor::<scale_info::PortableRegistry>::new(),
)
.map_err(|e| e.to_string())?;
scale_value::scale::decode_as_type(cursor, ty, self.types()).map_err(|e| e.to_string())
}
}
/// 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.
fn render(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()),
// 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.
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)
}
}
}
ValueDef::BitSequence(bits) => {
serde_json::Value::Array(bits.iter().map(serde_json::Value::Bool).collect())
}
}
}
/// A sequence of `u8` primitives, if that is what this is.
fn as_bytes(values: &[scale_value::Value<u32>]) -> Option<Vec<u8>> {
use scale_value::{Primitive, ValueDef};
if values.is_empty() {
return None;
}
values
.iter()
.map(|v| match &v.value {
ValueDef::Primitive(Primitive::U128(n)) if *n < 256 => Some(*n as u8),
_ => None,
})
.collect()
}
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).unwrap());
s.push(char::from_digit((b & 0x0f) as u32, 16).unwrap());
}
s
}

View File

@@ -0,0 +1,503 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Building the bytes that get signed, and the extrinsic that carries them.
use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use parity_scale_codec::Encode;
use scale_value::{Composite, Primitive, Value, ValueDef};
use crate::runtime::{CodecError, Runtime};
/// What the caller knows about one signed extension.
///
/// Both halves are optional because most extensions need neither: a zero-sized
/// `ty` contributes nothing to the extrinsic, and a zero-sized `additional`
/// contributes nothing to the payload. Supplying a value for a zero-sized type
/// is not an error; *omitting* one for a non-zero-sized type is.
#[derive(Debug, Clone, Default)]
pub struct ExtensionValue {
pub extra: Option<serde_json::Value>,
pub additional: Option<serde_json::Value>,
}
/// The two byte strings a signed extrinsic needs from its extensions.
#[derive(Debug, Clone)]
pub struct EncodedExtensions {
/// Goes into the extrinsic, after the signature.
pub extra: Vec<u8>,
/// Goes into the signed payload only — never on the wire.
pub additional: Vec<u8>,
}
impl Runtime {
/// Encode a call by name, against this runtime's own call type.
pub fn encode_call(
&self,
pallet: &str,
call: &str,
args: &serde_json::Value,
) -> Result<Vec<u8>, CodecError> {
// The outer `Call` enum is a variant per pallet, and each of those
// carries that pallet's own call enum. So a call is two nested variants
// by name, and the indices — which are what actually go on the wire —
// come from the registry rather than from a table in this crate.
let pallet_ty = self.extrinsic.call;
let inner = Value::variant(
call.to_string(),
self.composite_for_call(pallet, call, args)?,
);
let outer = Value::variant(
pallet.to_string(),
Composite::Unnamed(alloc::vec![inner]),
);
let mut out = Vec::new();
scale_value::scale::encode_as_type(&outer, pallet_ty, self.types(), &mut out)
.map_err(|e| CodecError::Encode(format!("{pallet}.{call}: {e}")))?;
Ok(out)
}
/// Build the argument composite for one call, interpreting `args` against
/// the types the runtime declares for it.
fn composite_for_call(
&self,
pallet: &str,
call: &str,
args: &serde_json::Value,
) -> Result<Composite<()>, CodecError> {
let calls_ty = self
.calls
.get(pallet)
.copied()
.ok_or_else(|| CodecError::NoSuchCall(pallet.to_string()))?;
let variant = match self.types().resolve(calls_ty).map(|t| &t.type_def) {
Some(scale_info::TypeDef::Variant(v)) => v
.variants
.iter()
.find(|v| v.name == call)
.ok_or_else(|| CodecError::NoSuchCall(format!("{pallet}.{call}")))?,
_ => return Err(CodecError::NoSuchCall(format!("{pallet}.{call}"))),
};
let mut fields = Vec::new();
for field in &variant.fields {
let name = field.name.clone().unwrap_or_default();
let supplied = args.get(&name).ok_or_else(|| {
CodecError::Encode(format!("{pallet}.{call}: no value for argument {name}"))
})?;
fields.push((name, self.json_to_value(supplied, field.ty.id)?));
}
Ok(Composite::Named(fields))
}
/// Encode every signed extension this runtime declares, in order.
///
/// ## Why an unsupplied extension is fatal
///
/// `@polkadot/api` logs `Unknown signed extensions … treating them as
/// no-effect` and writes zero bytes for anything it does not recognise. That
/// guess is correct only while every unrecognised extension happens to be
/// zero-sized, and when it stops being correct the wallet keeps signing —
/// valid signatures over a payload that is missing bytes the runtime put
/// there. The chain calls that `BadProof`, which is also what it calls a
/// wrong key, so the failure is silent, remote and indistinguishable from
/// the one thing it is not.
///
/// Here the registry decides. An extension whose declared type encodes to
/// nothing contributes nothing and needs no value; anything else must be
/// supplied by the caller or this refuses to build a payload at all. A
/// wallet that cannot sign is a bug report; a wallet that signs the wrong
/// bytes is a support case that never gets diagnosed.
pub fn encode_extensions(
&self,
values: &BTreeMap<String, ExtensionValue>,
) -> Result<EncodedExtensions, CodecError> {
let mut extra = Vec::new();
let mut additional = Vec::new();
for def in &self.extensions {
let supplied = values.get(&def.identifier);
self.encode_half(
def.ty,
supplied.and_then(|v| v.extra.as_ref()),
&def.identifier,
&mut extra,
)?;
self.encode_half(
def.additional,
supplied.and_then(|v| v.additional.as_ref()),
&def.identifier,
&mut additional,
)?;
}
Ok(EncodedExtensions { extra, additional })
}
fn encode_half(
&self,
ty: u32,
supplied: Option<&serde_json::Value>,
identifier: &str,
out: &mut Vec<u8>,
) -> Result<(), CodecError> {
if self.is_empty_ty(ty) {
// Encodes to nothing whether or not a value was supplied. Writing
// nothing here is a reading of the registry, not an assumption.
return Ok(());
}
let value = supplied.ok_or_else(|| CodecError::MissingExtension(identifier.to_string()))?;
let converted = self.json_to_value(value, ty)?;
scale_value::scale::encode_as_type(&converted, ty, self.types(), out)
.map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))
}
/// The bytes a signer signs: `call ‖ extra ‖ additional`.
///
/// Substrate's own rule from `unchecked_extrinsic.rs` — a payload longer than
/// 256 bytes is signed as its BLAKE2b-256 hash — is **not** applied here.
/// That is the caller's, because the hash belongs with the signing code that
/// also chooses the FIPS 204 context, and splitting one rule across two
/// packages is how the halves drift apart.
pub fn signer_payload(
&self,
call: &[u8],
extensions: &EncodedExtensions,
) -> Vec<u8> {
let mut out = Vec::with_capacity(call.len() + extensions.extra.len() + extensions.additional.len());
out.extend_from_slice(call);
out.extend_from_slice(&extensions.extra);
out.extend_from_slice(&extensions.additional);
out
}
/// Assemble a signed extrinsic.
///
/// The preamble is `0b10 << 6 | version`: the type tag says signed and the
/// low six bits carry the version the metadata declares. Not a hard-coded
/// `0x84` — if this runtime ever declares a different extrinsic version, the
/// byte follows it.
///
/// `signature` is the already-encoded `Signature` type, variant byte
/// included: the signing side knows which ML-DSA scheme the key is, and
/// re-deriving it here from the byte length would be a second source of
/// truth. It is written raw — a fixed-size array takes **no compact length
/// prefix**, which is the detail that a `Vec<u8>`-shaped assumption gets
/// wrong by exactly two bytes.
pub fn encode_extrinsic(
&self,
address: &serde_json::Value,
signature: &[u8],
extra: &[u8],
call: &[u8],
) -> Result<Vec<u8>, CodecError> {
let mut body = Vec::new();
body.push(0b1000_0000 | (self.extrinsic_version() & 0b0011_1111));
let addr = self.json_to_value(address, self.extrinsic.address)?;
scale_value::scale::encode_as_type(&addr, self.extrinsic.address, self.types(), &mut body)
.map_err(|e| CodecError::Encode(format!("address: {e}")))?;
body.extend_from_slice(signature);
body.extend_from_slice(extra);
body.extend_from_slice(call);
// The node expects the extrinsic length-prefixed.
let mut out = parity_scale_codec::Compact(body.len() as u64).encode();
out.extend_from_slice(&body);
Ok(out)
}
/// Interpret a JSON value as a particular registry type.
///
/// Type-directed on purpose. The same JSON string `"0xa5aa…"` is an
/// `AccountId32`, an `H256` or a `Vec<u8>` depending only on what the runtime
/// says goes there, and JSON carries no way to tell them apart. Asking the
/// registry is the only way that stays right across an upgrade.
pub(crate) fn json_to_value(
&self,
json: &serde_json::Value,
ty: u32,
) -> Result<Value<()>, CodecError> {
use scale_info::TypeDef;
let def = self
.types()
.resolve(ty)
.map(|t| &t.type_def)
.ok_or_else(|| CodecError::Encode(format!("no registry type {ty}")))?;
match def {
TypeDef::Compact(c) => self.json_to_value(json, c.type_param.id),
TypeDef::Primitive(p) => primitive(json, p),
TypeDef::Array(a) => {
let inner = a.type_param.id;
let want = a.len as usize;
if let Some(bytes) = hex_bytes(json) {
if bytes.len() != want {
return Err(CodecError::Encode(format!(
"expected {want} bytes, got {}",
bytes.len()
)));
}
return Ok(byte_composite(&bytes));
}
self.unnamed(json, |_| inner)
}
TypeDef::Sequence(s) => {
if let Some(bytes) = hex_bytes(json) {
return Ok(byte_composite(&bytes));
}
self.unnamed(json, |_| s.type_param.id)
}
TypeDef::Tuple(t) => {
// An empty tuple is the unit type; JSON `null` and an empty
// array both mean it, and so does anything else, since it
// encodes to no bytes either way.
if t.fields.is_empty() {
return Ok(Value::unnamed_composite([]));
}
let ids: Vec<u32> = t.fields.iter().map(|f| f.id).collect();
self.unnamed(json, move |i| ids[i.min(ids.len() - 1)])
}
TypeDef::Composite(c) => {
// A single-field struct is transparent, named or not, unless the
// caller actually spelled the field out. `AccountId32(pub [u8;
// 32])` should take the hex string its inner array takes, and
// `CheckMetadataHash { mode }` should take `"Disabled"` — neither
// wrapper is something a caller should have to know about, and
// both are wrappers the *runtime* chose, so the registry is what
// tells us they are there.
if c.fields.len() == 1 {
let name = c.fields[0].name.clone();
let spelled_out = name
.as_ref()
.zip(json.as_object())
.is_some_and(|(n, map)| map.contains_key(n.as_str()));
if !spelled_out {
let inner = self.json_to_value(json, c.fields[0].ty.id)?;
return Ok(match name {
Some(n) => Value {
value: ValueDef::Composite(Composite::Named(alloc::vec![(n, inner)])),
context: (),
},
None => Value::unnamed_composite([inner]),
});
}
}
match json {
serde_json::Value::Object(map) => {
let mut fields = Vec::new();
for f in &c.fields {
let name = f.name.clone().unwrap_or_default();
let v = map.get(&name).ok_or_else(|| {
CodecError::Encode(format!("no value for field {name}"))
})?;
fields.push((name, self.json_to_value(v, f.ty.id)?));
}
Ok(Value {
value: ValueDef::Composite(Composite::Named(fields)),
context: (),
})
}
_ => {
let ids: Vec<u32> = c.fields.iter().map(|f| f.ty.id).collect();
self.unnamed(json, move |i| ids[i.min(ids.len().saturating_sub(1))])
}
}
}
TypeDef::Variant(v) => {
// Two spellings, both unambiguous: `"Immortal"` for a variant
// that carries nothing, `{"Id": "0x…"}` for one that does.
let (name, payload) = match json {
serde_json::Value::String(s) => (s.clone(), None),
serde_json::Value::Null => ("None".to_string(), None),
serde_json::Value::Object(map) if map.len() == 1 => {
let (k, v) = map.iter().next().expect("len == 1");
(k.clone(), Some(v))
}
_ => {
return Err(CodecError::Encode(format!(
"cannot read {json} as a variant"
)))
}
};
let variant = v
.variants
.iter()
.find(|x| x.name == name)
.ok_or_else(|| CodecError::Encode(format!("no variant {name}")))?;
let composite = match (payload, variant.fields.len()) {
(_, 0) => Composite::Unnamed(Vec::new()),
(Some(p), 1) => {
Composite::Unnamed(alloc::vec![self.json_to_value(p, variant.fields[0].ty.id)?])
}
(Some(p), _) => {
let ids: Vec<u32> = variant.fields.iter().map(|f| f.ty.id).collect();
match self.unnamed(p, move |i| ids[i.min(ids.len() - 1)])?.value {
ValueDef::Composite(c) => c,
_ => unreachable!("unnamed always returns a composite"),
}
}
(None, _) => {
return Err(CodecError::Encode(format!(
"variant {name} needs a payload"
)))
}
};
Ok(Value::variant(name, composite))
}
TypeDef::BitSequence(_) => Err(CodecError::Encode(
"encoding a bit sequence is not supported".to_string(),
)),
}
}
fn unnamed(
&self,
json: &serde_json::Value,
ty_at: impl Fn(usize) -> u32,
) -> Result<Value<()>, CodecError> {
let items = match json {
serde_json::Value::Array(a) => a,
_ => {
return Err(CodecError::Encode(format!(
"expected an array, got {json}"
)))
}
};
let mut out = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
out.push(self.json_to_value(item, ty_at(i))?);
}
Ok(Value::unnamed_composite(out))
}
}
/// Read a JSON value as a SCALE primitive.
///
/// Numbers arrive as JSON numbers when they fit and as **decimal strings** when
/// they do not: a `u128` balance loses precision above 2^53 in JSON, and this
/// chain's balances are 12 decimal places, so that boundary is reached by
/// ordinary amounts rather than exotic ones.
fn primitive(
json: &serde_json::Value,
p: &scale_info::TypeDefPrimitive,
) -> Result<Value<()>, CodecError> {
use scale_info::TypeDefPrimitive as P;
let as_u128 = || -> Result<u128, CodecError> {
match json {
serde_json::Value::Number(n) => n
.as_u64()
.map(u128::from)
.ok_or_else(|| CodecError::Encode(format!("{n} is not a whole number"))),
serde_json::Value::String(s) => {
let s = s.trim();
if let Some(h) = s.strip_prefix("0x") {
u128::from_str_radix(h, 16)
} else {
s.parse::<u128>()
}
.map_err(|e| CodecError::Encode(format!("{s} is not a number: {e}")))
}
_ => Err(CodecError::Encode(format!("{json} is not a number"))),
}
};
Ok(match p {
P::Bool => Value {
value: ValueDef::Primitive(Primitive::Bool(json.as_bool().ok_or_else(|| {
CodecError::Encode(format!("{json} is not a boolean"))
})?)),
context: (),
},
P::Str => Value {
value: ValueDef::Primitive(Primitive::String(
json.as_str()
.ok_or_else(|| CodecError::Encode(format!("{json} is not a string")))?
.to_string(),
)),
context: (),
},
P::U8 | P::U16 | P::U32 | P::U64 | P::U128 | P::U256 => Value {
value: ValueDef::Primitive(Primitive::U128(as_u128()?)),
context: (),
},
P::I8 | P::I16 | P::I32 | P::I64 | P::I128 | P::I256 => Value {
value: ValueDef::Primitive(Primitive::I128(as_u128()? as i128)),
context: (),
},
P::Char => Err(CodecError::Encode("char is not encodable".to_string()))?,
})
}
/// `0x…` as bytes, if this is a hex string.
fn hex_bytes(json: &serde_json::Value) -> Option<Vec<u8>> {
let s = json.as_str()?.strip_prefix("0x")?;
if s.len() % 2 != 0 {
return None;
}
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok())
.collect()
}
fn byte_composite(bytes: &[u8]) -> Value<()> {
Value::unnamed_composite(bytes.iter().map(|b| Value {
value: ValueDef::Primitive(Primitive::U128(u128::from(*b))),
context: (),
}))
}

View File

@@ -0,0 +1,230 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! The runtime's description of itself, and the handful of things this crate
//! needs to look up in it.
//!
//! Every type id here is *read* from the metadata. Nothing in this file names a
//! pallet, a call, a signed extension or a signature scheme, which is what lets
//! it keep working across a runtime upgrade that changes any of them.
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use frame_metadata::v14::RuntimeMetadataV14;
use frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed};
use parity_scale_codec::Decode;
/// Failures reading a runtime's description, or its data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodecError {
/// The blob is not SCALE-encoded prefixed metadata.
Malformed,
/// Metadata this crate does not read. v14 is what every Quantus runtime
/// observed so far emits; a chain that moves to v15/v16 needs this widened
/// deliberately rather than silently mis-read.
UnsupportedVersion(u8),
/// The runtime does not describe its extrinsic in the usual shape.
NoExtrinsicTypes,
/// No such pallet, or no such call in it.
NoSuchCall(String),
/// A value did not match the type the registry said it would.
Decode(String),
/// A value could not be encoded as the type the registry declares.
Encode(String),
/// A signed extension declares a non-empty type and the caller supplied no
/// value for it. Deliberately fatal — see [`crate::encode`].
MissingExtension(String),
}
impl core::fmt::Display for CodecError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Malformed => write!(f, "metadata did not decode"),
Self::UnsupportedVersion(v) => write!(f, "unsupported metadata version {v}"),
Self::NoExtrinsicTypes => write!(f, "runtime describes no extrinsic type"),
Self::NoSuchCall(s) => write!(f, "no such call: {s}"),
Self::Decode(s) => write!(f, "decoding against the registry failed: {s}"),
Self::Encode(s) => write!(f, "encoding against the registry failed: {s}"),
Self::MissingExtension(s) => write!(
f,
"signed extension {s} declares a non-empty type and no value was supplied"
),
}
}
}
/// The four type parameters of the extrinsic envelope.
#[derive(Debug, Clone, Copy)]
pub struct ExtrinsicTypes {
pub address: u32,
pub signature: u32,
pub extra: u32,
pub call: u32,
}
/// One signed extension, as the runtime declares it.
///
/// `ty` is what it contributes to the extrinsic; `additional` is what it
/// contributes to the signed payload but *not* to the extrinsic. Both are read
/// from the metadata, in the order the runtime applies them, because that order
/// is the payload's byte order.
#[derive(Debug, Clone)]
pub struct ExtensionDef {
pub identifier: String,
pub ty: u32,
pub additional: u32,
}
/// A runtime, as described by its own metadata.
pub struct Runtime {
pub(crate) metadata: RuntimeMetadataV14,
pub(crate) extrinsic: ExtrinsicTypes,
pub(crate) extensions: Vec<ExtensionDef>,
/// Call type id per pallet name, so `encode_call` need not walk the pallet
/// list for every argument.
pub(crate) calls: BTreeMap<String, u32>,
}
impl Runtime {
/// Parse metadata exactly as `state_getMetadata` returns it.
///
/// That RPC takes a block hash and makes the node run `Metadata_metadata`
/// against the runtime code in *that block's* state, so what arrives here is
/// the runtime WASM describing itself, executed by the node. It is the only
/// oracle on this chain that cannot go stale.
pub fn from_metadata(raw: &[u8]) -> Result<Self, CodecError> {
let prefixed =
RuntimeMetadataPrefixed::decode(&mut &raw[..]).map_err(|_| CodecError::Malformed)?;
let metadata = match prefixed.1 {
RuntimeMetadata::V14(v) => v,
other => return Err(CodecError::UnsupportedVersion(version_of(&other))),
};
// The envelope's four parameters, by the names `UncheckedExtrinsic`
// gives them. Read from the registry rather than assumed, which is the
// whole point: `Signature` here is
// `qp_dilithium_crypto::types::DilithiumSignatureScheme`, and no decoder
// written against vanilla Substrate would guess that.
let extrinsic = metadata
.types
.resolve(metadata.extrinsic.ty.id)
.and_then(|e| {
let param = |name: &str| {
e.type_params
.iter()
.find(|p| p.name == name)
.and_then(|p| p.ty)
.map(|t| t.id)
};
Some(ExtrinsicTypes {
address: param("Address")?,
signature: param("Signature")?,
extra: param("Extra")?,
call: param("Call")?,
})
})
.ok_or(CodecError::NoExtrinsicTypes)?;
let extensions = metadata
.extrinsic
.signed_extensions
.iter()
.map(|e| ExtensionDef {
identifier: e.identifier.to_string(),
ty: e.ty.id,
additional: e.additional_signed.id,
})
.collect();
let calls = metadata
.pallets
.iter()
.filter_map(|p| p.calls.as_ref().map(|c| (p.name.to_string(), c.ty.id)))
.collect();
Ok(Self {
metadata,
extrinsic,
extensions,
calls,
})
}
/// The extrinsic format version the metadata declares.
///
/// Not to be confused with the preamble byte of any particular extrinsic —
/// see [`crate::decode::decode_extrinsic`], which is where that distinction
/// has teeth.
pub fn extrinsic_version(&self) -> u8 {
self.metadata.extrinsic.version
}
/// The signed extensions, in the order the runtime applies them.
pub fn extensions(&self) -> &[ExtensionDef] {
&self.extensions
}
pub fn extrinsic_types(&self) -> ExtrinsicTypes {
self.extrinsic
}
pub(crate) fn types(&self) -> &scale_info::PortableRegistry {
&self.metadata.types
}
/// Whether a registry type encodes to nothing at all.
///
/// The question [`crate::encode`] asks of every signed extension: a
/// zero-sized one contributes no bytes and needs no value from the caller,
/// and anything else does. Answering it from the registry rather than from a
/// list of known extension names is the difference between this crate and
/// the thing it replaces.
pub(crate) fn is_empty_ty(&self, id: u32) -> bool {
match self.metadata.types.resolve(id).map(|t| &t.type_def) {
// The unit type, and a tuple of nothing, are the same thing here.
Some(scale_info::TypeDef::Tuple(t)) => {
t.fields.iter().all(|f| self.is_empty_ty(f.id))
}
Some(scale_info::TypeDef::Composite(c)) => {
c.fields.iter().all(|f| self.is_empty_ty(f.ty.id))
}
Some(scale_info::TypeDef::Array(a)) => {
a.len == 0 || self.is_empty_ty(a.type_param.id)
}
_ => false,
}
}
}
fn version_of(md: &RuntimeMetadata) -> u8 {
match md {
RuntimeMetadata::V14(_) => 14,
RuntimeMetadata::V15(_) => 15,
_ => 0,
}
}
impl Runtime {
/// [`Runtime::is_empty_ty`], for the bindings module.
pub fn is_empty_ty_pub(&self, id: u32) -> bool {
self.is_empty_ty(id)
}
}
impl Runtime {
/// [`Runtime::types`], for tests.
pub fn types_pub(&self) -> &scale_info::PortableRegistry {
self.types()
}
/// [`Runtime::json_to_value`], for tests.
pub fn json_to_value_pub(
&self,
json: &serde_json::Value,
ty: u32,
) -> Result<scale_value::Value<()>, CodecError> {
self.json_to_value(json, ty)
}
}

View File

@@ -0,0 +1,109 @@
// Copyright 2026 @quantus/codec authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Tested against metadata captured from the chain, not against a fixture this
//! crate wrote. The point of the package is agreeing with a runtime; a test that
//! agrees with itself proves nothing.
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::encode::ExtensionValue;
use crate::runtime::Runtime;
/// Heisenberg at spec 148, `transactionVersion` 6 — the runtime quantus/extension#7
/// tier 1 submits to.
const HEISENBERG_V148: &str = include_str!("../../tests/heisenberg-v148.metadata.hex");
fn unhex(s: &str) -> Vec<u8> {
let s = s.trim();
let s = s.strip_prefix("0x").unwrap_or(s);
(0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).expect("fixture is hex"))
.collect()
}
fn heisenberg() -> Runtime {
Runtime::from_metadata(&unhex(HEISENBERG_V148)).expect("fixture is v14 metadata")
}
#[test]
fn metadata_loads_and_describes_its_extrinsic() {
let rt = heisenberg();
assert_eq!(rt.extrinsic_version(), 4);
}
/// The listing that `@polkadot/api` cannot produce, and the reason this crate
/// exists. Two of these extensions are Quantus-only and polkadot-js writes zero
/// bytes for both halves of them by assumption; here the answer comes from the
/// registry.
#[test]
fn every_signed_extension_is_read_from_the_registry() {
let rt = heisenberg();
let names: Vec<&str> = rt
.extensions()
.iter()
.map(|e| e.identifier.as_str())
.collect();
for e in rt.extensions() {
println!(
"{:<40} extra={:<5} additional={}",
e.identifier,
!rt.is_empty_ty_pub(e.ty),
!rt.is_empty_ty_pub(e.additional)
);
}
// Not an exhaustive list on purpose — asserting the whole tuple would make
// this test a second copy of the runtime, which is the mistake the crate is
// here to avoid. These two are asserted because they are the ones no
// Substrate-shaped decoder knows about.
assert!(names.contains(&"ReversibleTransactionExtension"));
assert!(names.contains(&"WormholeProofRecorderExtension"));
}
/// The guarantee in [`Runtime::encode_extensions`]: a declared, non-empty
/// extension with no supplied value refuses to produce a payload.
#[test]
fn a_missing_extension_value_is_an_error_not_a_short_payload() {
let rt = heisenberg();
let empty: BTreeMap<String, ExtensionValue> = BTreeMap::new();
let err = rt
.encode_extensions(&empty)
.expect_err("CheckSpecVersion declares a u32 additional; nothing supplied it");
assert!(
err.to_string().contains("no value was supplied"),
"unexpected error: {err}"
);
}
/// A `MultiAddress::Id` is a variant carrying a newtype around `[u8; 32]`, and
/// the caller should be able to say so with a hex string and a variant name
/// without knowing about either wrapper.
#[test]
fn an_account_id_encodes_from_its_hex() {
let rt = heisenberg();
let tys = rt.extrinsic_types();
let id = "0x".to_string() + &"11".repeat(32);
let json = serde_json::json!({ "Id": id });
let value = rt
.json_to_value_pub(&json, tys.address)
.expect("MultiAddress::Id from hex");
let mut out = Vec::new();
scale_value::scale::encode_as_type(&value, tys.address, rt.types_pub(), &mut out)
.expect("encodes");
// Variant index 0 for `Id`, then 32 raw bytes with no length prefix.
assert_eq!(out.len(), 33);
assert_eq!(out[0], 0);
assert_eq!(&out[1..], &[0x11u8; 32]);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"composite": false,
"declaration": true,
"outDir": "./build",
"rootDir": "./src",
"emitDeclarationOnly": false
},
"exclude": [
"**/*.spec.ts"
],
"include": [
"src/**/*.ts"
],
"references": []
}

View File

@@ -21,10 +21,11 @@
set -e
PKG=packages/quantus-crypto
CRATE=quantus_crypto
NAME=${1:-quantus-crypto}
PKG=packages/$NAME
CRATE=$(echo "$NAME" | tr '-' '_')
echo "*** Building @quantus/crypto"
echo "*** Building $NAME"
rm -rf $PKG/build-tsc
yarn polkadot-exec-tsc --outDir $PKG/build-tsc --project $PKG/tsconfig.build.json

View File

@@ -14,8 +14,12 @@
set -e
PKG=quantus-crypto
CRATE=quantus_crypto
# Which package to build. Two now — quantus-crypto and quantus-codec — built
# the same way from the same toolchain, so the script takes the name rather than
# being copied. `quantus_codec` is the crate name for `quantus-codec`: cargo
# wants underscores, npm wants hyphens.
PKG=${1:-quantus-crypto}
CRATE=$(echo "$PKG" | tr '-' '_')
BINDGEN_VER=0.2.128
WASM=packages/$PKG/build-wasm/${CRATE}_bg.wasm
@@ -53,7 +57,7 @@ echo "*** Optimising WASM output"
./binaryen-quantus/bin/wasm-opt $WASM -Oz -o $OPT
# Must come before packing: tsc clears build/, which is where bytes.js lands.
./scripts/build-quantus-js.sh
./scripts/build-quantus-js.sh "$PKG"
echo "*** Packing WASM into baseX"
PKG_NAME=$PKG CRATE_NAME=$CRATE node ./scripts/pack-quantus-base.mjs