Compare commits

...

20 Commits
v7.5.3 ... main

Author SHA1 Message Date
dc295dc3f5 Merge branch 'quantus-crypto' into quantus-codec
Some checks failed
Lock Threads / lock (push) Has been cancelled
2026-09-16 18:14:06 +03:00
4e5b479db4 feat(quantus-crypto): wormhole nullifiers
A deposit to a wormhole address is spent when its nullifier,
poseidon2(poseidon2(salt || secret || transfer_count)), is in
Wormhole::UsedNullifiers. Working out a wormhole balance means computing one
for each deposit. That needs the address's secret, which never leaves WASM.

wormholeNullifiers(mnemonic, password, account, branch, start, addresses,
first, count) returns them for a run of addresses and a run of transfer
counts. The BIP39 seed is stretched once per call; 40 addresses x 256 counts
takes 194 ms in node. A call is capped at 100,000 nullifiers.

This is ported onto qp-poseidon-core, not qp-wormhole-circuit, which would
bring plonky2 into the WASM. The circuit crate is a dev-dependency only, as
the reference: a known-answer test compares the port with
Nullifier::from_preimage across secrets at and above the Goldilocks-prime
limb edge and transfer counts across both 32-bit limbs, and asserts that
enough cases were actually compared rather than skipped.

The doc comments say to check nullifiers against a local copy of the spent
set, never by key: exits publish nullifiers, so a lookup names the exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 18:13:57 +03:00
d61e886359 Merge branch 'quantus-crypto' into quantus-codec 2026-09-16 15:58:30 +03:00
0b1cfe35b3 feat(quantus-crypto): derive wormhole addresses
A wormhole address is not a key. A path under coin type 189189189 yields a
32-byte secret, and the address is poseidon(poseidon(salt || secret)). Funds
leave only through a ZK proof of that secret. The extension needs the
addresses to show a wallet's wormhole account; it has no use for the secrets.

wormholeAddresses(mnemonic, password, account, branch, start, count) returns
32-byte account ids for m/44'/189189189'/<account>'/<branch>'/<index>', the
mobile wallet's paths. The secrets and first hashes are derived and wiped
inside WASM.

The BIP39 seed is stretched once per call rather than once per address, since
a gap-limit window is dozens of addresses and each stretch is 2048 PBKDF2
rounds. A call is capped at 1000 addresses, and indices must stay below 2^31.

Pinned to the chain node's own vector
(node/src/tests/data/quantus_key_test_data.rs): TEST_MNEMONIC at
m/44'/189189189'/0'/0'/0' is TEST_WORMHOLE_ADDRESS, the same pair the mobile
wallet's SDK tests. Checked in Rust and through the packed package's consumer
test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 15:58:13 +03:00
09a96b64bf 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
2026-09-15 15:26:11 +03:00
e6ff57a334 feat: storage addressing, nested calls, and a stated integer convention
Three things the tier-1 case matrix needed (quantus/extension#7).

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

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

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

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

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

Closes #4. Refs #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-15 15:15:35 +03:00
f1c51661df feat: accept pre-encoded extension values, validated by round trip
A dapp hands a wallet an `era` it encoded itself, as opaque bytes. There is no
way to render those as a variant without knowing the era algorithm, which is
exactly the kind of knowledge this crate refuses to hold — so they are accepted
as `Supplied::Raw`.

Not on trust, though. Raw bytes are decoded against the type the runtime
declares and re-encoded, and anything that does not come back identical is
refused: a short read, trailing bytes, a non-canonical compact. Appending them
unchecked would mean signing a payload whose shape nobody verified, and the only
report of that is `BadProof` from a node — which is also what a wrong key looks
like.

Also makes a single-field struct transparent whether or not its field is named,
so `CheckMetadataHash { mode }` takes `"Disabled"` the way `AccountId32([u8;32])`
takes its hex. Both wrappers are the runtime's choice, and the registry is what
says they are there.

Refs #3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-15 14:45:25 +03:00
3a1611a92e 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
2026-09-15 14:14:34 +03:00
rob thijssen
e8bf9e20c7 fix(quantus-crypto): strip wasm-bindgen's fetch-based init from the shipped glue
The package could not be bundled. Every webpack consumer failed with:

  Module not found: Error: Can't resolve 'quantus_crypto_bg.wasm'
    in node_modules/@quantus/crypto/generated

wasm-bindgen's async `__wbg_init` contains

  module_or_path = new URL('quantus_crypto_bg.wasm', import.meta.url);

and webpack resolves `new URL(..., import.meta.url)` statically, at build time,
whether or not the branch can run. The file is not in the package — the wasm
ships base64'd in bytes.js, which is the entire point of this package — so the
build failed on a code path we never call.

node never sees it, which is why ten Rust tests, twelve consumer assertions and a
browser probe all passed while the package was unusable in a bundler. It took a
real extension build to surface, and that is the useful lesson: this package's
consumers bundle, and nothing in its own test suite does.

So the dead init is removed after bindgen runs. Shipping a second copy of the
wasm to satisfy a path we do not use would be the wide fix; deleting generated
code we never call is the narrow one.

The stripper asserts the shape it expects and throws if wasm-bindgen changes it,
rather than silently no-opping — a build that quietly stopped stripping would
ship the broken package again. It also re-checks that no reference to the .wasm
filename survives.

Published as 0.1.1.

Refs quantus/wasm#1, quantus/extension#2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-15 11:53:18 +03:00
rob thijssen
a6d3685c59 refactor(quantus-crypto): drop the @polkadot/wasm-util dependency
It cost more than it saved. Two problems, the second only visible once
quantus/common tried to consume this package:

Its index re-exports packageDetect, whose only job is a side effect registering
with @polkadot/util — a peer dependency inherited for nothing. Deep imports
(/base64, /fflate) avoided that.

But it is a workspace package, so a symlinked consumer resolves its dependencies
through *this* repo's node_modules, where @polkadot/wasm-util points at the
package source rather than its build and carries no exports map. Node follows
symlinks to their realpath, so `@polkadot/wasm-util/base64` failed to resolve
from quantus/common no matter which yarn protocol was used — portal: and link:
behave the same once the realpath is taken.

So: fflate directly for zlib inflate, and fifteen lines for base64 rather than a
dependency at all. 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 package is now self-contained apart from fflate, which resolves normally from
any checkout. Size is unchanged at 234,292 raw / 109,649 zlib / 146,200 base64.

Refs quantus/wasm#1, quantus/common#2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-10 18:39:54 +03:00
rob thijssen
8323e442d9 test(quantus-crypto): browser probes for the constraints node cannot test
cargo test and the consumer test both run in node, which is neither a browser nor
a service worker. Two probes cover the rest.

The automated one is a module Worker served under the exact extension_pages CSP
from both manifests. A module Worker has no window and no document, which is the
property that matters — an MV3 service worker has neither either. On Firefox:

  hasDOM:   false   hasWindow: false
  initWasm: ok  (9.0 ms cold)
  keygen:   1.0 ms
  account:  matches quantus-cli
  sign:     3.0 ms (4627 bytes)
  verify:   1.0 ms  ok
  ctx sep:  ok (rejected under spec-147 ctx)

9 ms to base64-decode 146 KB, inflate it to 234 KB and instantiate. That is the
number the MV3 lifetime question turns on — a worker killed between messages pays
it on every wake — and it settles the cold-start concern raised in quantus/wasm#1.
Those are ML-DSA-87 timings, the larger parameter set, so 65 is cheaper still.

The CSP is enforced, not merely declared: an earlier version of the page used an
inline script and Firefox blocked it, which is why main.js is a separate file.

The manual one is a real MV3 extension whose service worker imports the package
at module scope. Loading an unpacked extension needs an OS file dialog, so it
cannot be driven from here and is documented for a human to load. It covers
chrome.runtime messaging and the real kill-and-restart lifecycle rather than a
stand-in for it.

Refs quantus/wasm#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-10 14:20:47 +03:00
rob thijssen
b882f914e7 build(quantus-crypto): buildable, installable and tested as a package
The JS build now runs end to end and the built package has been consumed the way
quantus/common will consume it. Four things had to be worked out.

polkadot-dev-build-ts will not build this package. It returns early for any name
not starting with @polkadot/, in both buildJs and when collecting locals for
import rewriting. Renaming into someone else's scope to satisfy a string check
would be worse than not using the tool, and nothing is lost: this package needs
no deno variant, no rollup bundle, no cross-package import rewriting. A plain tsc
build lives in scripts/build-quantus-js.sh, which also keeps `yarn build:js`
byte-identical to upstream's behaviour.

binaryen 105 silently breaks the wasm. Upstream pins version_105 (2021), which
predates the externref tables wasm-bindgen 0.2.128 emits; wasm-opt "optimises"
the table into something that fails at instantiation with `WebAssembly.Table.
grow(): failed to grow table by 4`. The wasm is valid before wasm-opt and broken
after, every cargo test still passes, and it only surfaces when a consumer tries
to init. install-build-deps.sh now fetches binaryen 123 alongside, exactly as it
does a second wasm-bindgen.

The wasm-util dependency is imported deeply. Its package index re-exports
packageDetect, whose only job is a side effect registering with @polkadot/util —
a peer dependency we would inherit for nothing. base64 and fflate are pure
functions with no dependencies, so the deep paths are both lighter and honest.

ESM only, and the CJS scaffolding is removed. The consumers are ESM and the
wasm-bindgen glue is ESM-only, so a CJS variant would mean a second generated
glue or hand-written marshalling. Revisit if quantus/common's CJS build needs it.

Also: the pack step must run after tsc, which clears build/; the checked-in
bindings are refreshed by the build so they cannot drift; and both test suites
are wired into the repo's test script, which previously ran wasm-crypto's only.

The consumer test stages a real node_modules layout rather than testing in place,
because in this repo node_modules/@polkadot/wasm-util symlinks to the package
source, which carries no exports map — so a deep import resolves for a real
consumer and fails here for reasons that have nothing to do with our package.
Staging tests module resolution too, which is half of what can break in a
published package. It is also what caught the binaryen fault.

Post-wasm-opt: 234,292 raw / 109,649 zlib / 146,200 base64 — smaller than
upstream's entire wasm-crypto blob (335,277 / 168,782 / 225,044).

Refs quantus/wasm#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-10 14:17:15 +03:00
rob thijssen
02a3f004d7 chore: ignore the second bindgen download
install-build-deps.sh fetches wasm-bindgen 0.2.128 into bindgen-quantus/ for
packages/quantus-crypto, which the existing bindgen/ rule does not match.

Refs quantus/wasm#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-10 13:58:35 +03:00
rob thijssen
1f1f5729ab feat(quantus-crypto): JS surface, build scripts and CSP-safe sync init
Wraps the crate for JS consumers and adds the build that produces it.

Init deliberately avoids fetch and avoids @polkadot/wasm-bridge. The consumer is
an MV3 service worker under `script-src 'self' 'wasm-unsafe-eval'`, which can
compile WASM but not usefully fetch it, and which can be cold-started between any
two messages; callers like pair.sign() are synchronous and have no await to give.
So the WASM is zlib-compressed and base64'd into bytes.js at build time and
instantiated with wasm-bindgen's initSync. Bridge is not usable here regardless:
it implements the 0.2.79 JS-heap ABI and this crate builds with 0.2.128, which
uses externref tables.

build-quantus.sh is separate from build-wasm.sh rather than folded into it,
because that script drives the nightly-2022-06-24 + xargo build wasm-crypto
needs. install-build-deps.sh gains a second wasm-bindgen for the same reason —
the two ABIs cannot share a binary. No asm.js step: wasm2js over ML-DSA would be
enormous and slow, and every context we ship into permits wasm.

bytes.js is emitted in both module systems, with the CJS copy under a directory
carrying its own {"type":"commonjs"} — the package is "type": "module" and node
otherwise refuses to load an exports.-style file from it.

Proven end to end against the real build output: base64 -> inflate -> initSync
with no fetch, crystal_alice's account id matching the CLI through the JS path,
sig||pk matching the runtime's fixed-array size, and JsError surfacing as a JS
exception across the boundary.

Sizes are read from the crate rather than exposed as constants to copy. They are
consensus-critical and a drifted JS constant would re-frame every byte after the
signature while looking entirely healthy.

Refs quantus/wasm#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-10 13:58:25 +03:00
rob thijssen
6eb04f63ac feat(quantus-crypto): ML-DSA, Poseidon2 and HD derivation as a separate crate
Wraps the chain's own crypto crates for the browser: qp-rusty-crystals-dilithium
(ML-DSA-65 and ML-DSA-87), qp-poseidon-core for the account-id hash, and
qp-rusty-crystals-hdwallet for BIP44 derivation. Nothing is reimplemented — a
browser wallet that disagreed with the chain about a key or a signature would
emit well-formed output the chain rejects, with nothing on this side able to
tell.

A separate crate rather than more files in wasm-crypto, because the two cannot
share a Cargo graph. wasm-crypto builds with nightly-2022-06-24 against a
2019-era dependency set; the ML-DSA crates use inline `const {}` blocks that
need Rust >= 1.79. Bumping the older one would mean rewriting upstream's
sr25519/ed25519 build, which is the thing most worth leaving alone so rebases
stay boring. wasm-crypto is untouched here.

The scheme selector is the chain's own signature-enum variant index (0 for
ML-DSA-87, 1 for ML-DSA-65), so the number threaded through this API is the
byte that ends up on the wire and there is no mapping to get backwards. Key and
signature sizes are exported rather than left for JS to hardcode: they are
consensus-critical and a drifted constant would mis-frame every byte after the
signature while looking healthy.

Logic is split from the #[wasm_bindgen] wrappers because JsError cannot be
constructed off-wasm, which made every error path untestable by cargo test —
and the error paths are what most needs testing.

Verified against the `quantus` CLI 2.2.2 as an independent oracle, not against
our own output: the three dev-genesis account ids, and HD derivation at both
schemes' default paths from the public Substrate dev phrase. Context separation
is pinned too — a signature made under QUANTUS_EXTRINSIC must not verify under
the empty context, which is what makes the spec-148 boundary detectable rather
than a silent chain rejection.

Refs quantus/wasm#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-10 13:54:25 +03:00
Tarik Gul
65286fb3ec Set headers to 2026 (#609)
Some checks failed
Lock Threads / lock (push) Has been cancelled
2026-03-13 01:32:10 +02:00
github-actions[bot]
f55a3e75a2 [CI Skip] release/stable 7.5.4
skip-checks: true
2025-12-09 10:03:06 +00:00
rajk93
6c8b0afd8d 7.5.4 (#606) 2025-12-09 15:27:27 +05:30
github-actions[bot]
9c6611087c [CI Skip] bump/beta 7.5.4-0-x
skip-checks: true
2025-12-09 09:23:51 +00:00
rajk93
dc34df3b54 chore: bump polkadot dependencies (#605) 2025-12-09 14:48:23 +05:30
144 changed files with 7607 additions and 169 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,10 @@
binaryen/
binaryen-quantus/
bindgen/
bindgen-quantus/
build/
build-*/
build-test/
bytes/
coverage/
node_modules/

View File

@@ -1,4 +1,4 @@
// Copyright 2017-2025 @polkadot/wasm-crypto authors & contributors
// Copyright 2017-2026 @polkadot/wasm-crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
module.exports = require('@polkadot/dev/config/prettier.cjs');

View File

@@ -1,5 +1,12 @@
# CHANGELOG
## 7.5.4 Dec 9, 2025
Changes:
- Bump polkadot-js dependencies ([#605](https://github.com/polkadot-js/wasm/pull/605))
## 7.5.3 Nov 24, 2025
Changes:

View File

@@ -1,7 +1,7 @@
618 Jaco 2024 (#559)
19 Tarik Gul Bump dev to 0.83.2 (#578)
12 Valentin Fernandez 7.5.1 (#596)
8 rajk93 7.5.3 (#604)
10 rajk93 7.5.4 (#606)
1 Evgeny Fixed type (#121)
1 Shunfan Zhou Support sr25519 agreement (#209)
1 Steve Degosserie Expose Schnorrkel's VRF capabilities (#170)

View File

@@ -1,4 +1,4 @@
// Copyright 2017-2025 @polkadot/wasm-crypto authors & contributors
// Copyright 2017-2026 @polkadot/wasm-crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
import baseConfig from '@polkadot/dev/config/eslint';

View File

@@ -14,10 +14,10 @@
},
"sideEffects": false,
"type": "module",
"version": "7.5.3",
"version": "7.5.4",
"versions": {
"git": "7.5.3",
"npm": "7.5.3"
"git": "7.5.4",
"npm": "7.5.4"
},
"workspaces": [
"packages/*"
@@ -25,6 +25,8 @@
"scripts": {
"build": "yarn build:wasm",
"build:js": "./scripts/build-js.sh",
"build:quantus": "./scripts/build-quantus.sh",
"build:quantus:js": "./scripts/build-quantus-js.sh",
"build:release": "polkadot-ci-ghact-build",
"build:rollup": "polkadot-exec-rollup --config",
"build:wasm": "./scripts/build.sh",
@@ -34,8 +36,10 @@
"deno:check": "deno check --import-map=import_map.json mod.ts",
"lint": "polkadot-dev-run-lint",
"postinstall": "polkadot-dev-yarn-only",
"test": "yarn test:wasm-crypto:rust",
"test": "yarn test:wasm-crypto:rust && yarn test:quantus-crypto:rust",
"test:js": "yarn test:wasm-crypto:js",
"test:quantus-crypto:js": "./scripts/test-quantus-js.sh",
"test:quantus-crypto:rust": "cd packages/quantus-crypto && RUST_BACKTRACE=full cargo test --release",
"test:wasm-crypto:deno": "deno test --allow-read --import-map=import_map.json packages/wasm-crypto/test/deno.ts",
"test:wasm-crypto:js": "yarn test:wasm-crypto:js:jest && yarn test:wasm-crypto:js:node",
"test:wasm-crypto:js:jest": "polkadot-dev-run-test --env node --loader ./packages/wasm-crypto/test/loader-build.js",
@@ -44,7 +48,7 @@
},
"devDependencies": {
"@polkadot/dev": "^0.83.3",
"@polkadot/util": "^13.5.8",
"@polkadot/util": "^14.0.1",
"@types/node": "^20.16.1",
"fflate": "^0.8.2"
},

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

@@ -0,0 +1,602 @@
# 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 = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "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"
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 = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "derive_more"
version = "1.0.0"
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 = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "either"
version = "1.18.0"
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 = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
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 = [
"blake2",
"bs58",
"frame-metadata",
"parity-scale-codec",
"scale-decode",
"scale-info",
"scale-value",
"serde_json",
"twox-hash",
"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 = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[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 = "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"
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 = "twox-hash"
version = "2.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
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 = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.128"
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,63 @@
# 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"
# Storage keys. Substrate hashes a pallet prefix and an item name with twox128
# and each map key with whatever hasher the metadata declares for it.
blake2 = { version = "0.10", default-features = false }
twox-hash = { version = "2", default-features = false, features = ["xxhash64"] }
# 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
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.5.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,242 @@
// 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.
*
* Each half is either interpreted against the type the runtime declares
* (`extra`, `additional`) or supplied already SCALE-encoded (`extraHex`,
* `additionalHex`). Pre-encoded bytes are **validated by round trip**, not
* trusted: they are decoded against the declared type and re-encoded, and
* anything that does not come back identical is refused rather than signed.
* Setting both halves of a pair is a contradiction and is also refused.
*/
export interface ExtensionValue {
extra?: unknown;
extraHex?: string;
additional?: unknown;
additionalHex?: string;
}
export type ExtensionValues = Record<string, ExtensionValue>;
export interface StorageTarget {
/** The full key, ready for `state_getStorage`. */
key: string;
/** The registry type its value decodes as — pass to `decodeStorage`. */
valueTy: number;
/**
* What the chain means when `state_getStorage` returns nothing.
*
* Hex for a `Default` entry — an unfunded account reads as a zero balance —
* and `null` for an `Optional` one, where nothing means nothing. A wallet that
* conflated the two would report a failure for an account that simply has no
* money in it.
*/
default: string | null;
}
export interface DecodedExtrinsic {
/** The preamble byte's low six bits — **not** the byte. See `decodeExtrinsic`. */
version: number;
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;
/**
* The era already SCALE-encoded — what a dapp hands a wallet, since it did the
* encoding itself and the wallet has no way to render those two bytes as a
* variant without knowing the era algorithm. Takes precedence over `era`, and
* is round-tripped against the runtime's own `Era` type before it is used.
*/
eraHex?: string;
/** `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));
}
/**
* 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();
}
/**
* 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;
}
/**
* Where a storage value lives, and what it decodes as.
*
* `keys` are interpreted against the key types the runtime declares, so an
* `AccountId32` is the hex string its inner array accepts. The hashers come
* from the metadata too — nothing here knows that `System::Account` is
* `Blake2_128Concat`.
*/
storageTarget (pallet: string, item: string, keys: unknown[] = []): StorageTarget {
return JSON.parse(this.#inner.storageTarget(pallet, item, JSON.stringify(keys))) as StorageTarget;
}
/** Decode a storage value against the `valueTy` that `storageTarget` reported. */
decodeStorage (valueTy: number, bytes: Uint8Array): unknown {
return JSON.parse(this.#inner.decodeStorage(valueTy, bytes)) as unknown;
}
/**
* Fill in the signed extensions that Substrate itself defines, from one
* options object.
*
* 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: options.eraHex
? { additional: options.blockHash, extraHex: options.eraHex }
: { 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,125 @@
/* 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;
/**
* Decode a storage value against the type id `storageTarget` reported.
*/
decodeStorage(value_ty: number, bytes: Uint8Array): string;
/**
* Encode a call by name. `args` is a JSON object keyed by argument name.
*/
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);
/**
* 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 }]`.
*
* 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;
/**
* Where a storage value lives, and what it decodes as.
*
* Returns `{ key, valueTy, default }` — `key` ready for `state_getStorage`,
* and `default` the bytes the chain means when it returns nothing, or null
* for an entry where nothing means nothing. An unfunded account reads as a
* zero balance through the first and as an error through the second, so the
* distinction is not a detail.
*/
storageTarget(pallet: string, item: string, keys: string): string;
}
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
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_decodeStorage: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly quantusruntime_encodeCall: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
readonly quantusruntime_encodeExtra: (a: number, b: number, c: number) => [number, number, number, number];
readonly quantusruntime_encodeExtrinsic: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number, number, number];
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];
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,475 @@
/* @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);
}
}
/**
* Decode a storage value against the type id `storageTarget` reported.
* @param {number} value_ty
* @param {Uint8Array} bytes
* @returns {string}
*/
decodeStorage(value_ty, bytes) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(bytes, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_decodeStorage(this.__wbg_ptr, value_ty, ptr0, len0);
var ptr2 = ret[0];
var len2 = ret[1];
if (ret[3]) {
ptr2 = 0; len2 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
}
}
/**
* Encode a call by name. `args` is a JSON object keyed by argument name.
* @param {string} pallet
* @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;
}
/**
* 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 }]`.
*
* 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;
}
/**
* Where a storage value lives, and what it decodes as.
*
* Returns `{ key, valueTy, default }` — `key` ready for `state_getStorage`,
* and `default` the bytes the chain means when it returns nothing, or null
* for an entry where nothing means nothing. An unfunded account reads as a
* zero balance through the first and as an error through the second, so the
* distinction is not a detail.
* @param {string} pallet
* @param {string} item
* @param {string} keys
* @returns {string}
*/
storageTarget(pallet, item, keys) {
let deferred5_0;
let deferred5_1;
try {
const ptr0 = passStringToWasm0(pallet, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(item, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passStringToWasm0(keys, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.quantusruntime_storageTarget(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
var ptr4 = ret[0];
var len4 = ret[1];
if (ret[3]) {
ptr4 = 0; len4 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred5_0 = ptr4;
deferred5_1 = len4;
return getStringFromWasm0(ptr4, len4);
} finally {
wasm.__wbindgen_free(deferred5_0, deferred5_1, 1);
}
}
}
if (Symbol.dispose) QuantusRuntime.prototype[Symbol.dispose] = QuantusRuntime.prototype.free;
function __wbg_get_imports() {
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, StorageTarget } 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,40 @@
// 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/storage.rs"]
pub mod storage;
#[path = "rs/ss58.rs"]
pub mod ss58;
#[path = "rs/bindings.rs"]
mod bindings;
#[cfg(test)]
#[path = "rs/tests.rs"]
mod tests;

View File

@@ -0,0 +1,251 @@
// 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, Supplied};
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()))
}
/// 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 {
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()))
}
/// Where a storage value lives, and what it decodes as.
///
/// Returns `{ key, valueTy, default }` — `key` ready for `state_getStorage`,
/// and `default` the bytes the chain means when it returns nothing, or null
/// for an entry where nothing means nothing. An unfunded account reads as a
/// zero balance through the first and as an error through the second, so the
/// distinction is not a detail.
#[wasm_bindgen(js_name = storageTarget)]
pub fn storage_target(&self, pallet: &str, item: &str, keys: &str) -> Result<String, JsError> {
let keys: Vec<serde_json::Value> =
serde_json::from_str(keys).map_err(|e| JsError::new(&e.to_string()))?;
let target = self
.inner
.storage_target(pallet, item, &keys)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&serde_json::json!({
"default": target.default.as_deref().map(crate::storage::hex),
"key": crate::storage::hex(&target.key),
"valueTy": target.value_ty
}))
.map_err(|e| JsError::new(&e.to_string()))
}
/// Decode a storage value against the type id `storageTarget` reported.
#[wasm_bindgen(js_name = decodeStorage)]
pub fn decode_storage(&self, value_ty: u32, bytes: &[u8]) -> Result<String, JsError> {
let value = self
.inner
.decode_storage_value(value_ty, bytes)
.map_err(|e| JsError::new(&e.to_string()))?;
serde_json::to_string(&value).map_err(|e| JsError::new(&e.to_string()))
}
/// Decode a bare call — what an approval screen shows the user.
#[wasm_bindgen(js_name = decodeCall)]
pub fn decode_call(&self, bytes: &[u8]) -> Result<String, JsError> {
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 mut values: BTreeMap<String, ExtensionValue> = BTreeMap::new();
for (identifier, v) in parsed {
values.insert(
identifier,
ExtensionValue {
extra: half(&v, "extra")?,
additional: half(&v, "additional")?,
},
);
}
self.inner
.encode_extensions(&values)
.map_err(|e| JsError::new(&e.to_string()))
}
}
/// One half of an extension value, as JSON or as pre-encoded bytes.
///
/// `extra` / `additional` are interpreted against the runtime's declared type;
/// `extraHex` / `additionalHex` are bytes the caller encoded itself, which are
/// validated by round trip rather than taken on trust. Supplying both is a
/// contradiction, not a preference, so it is refused.
fn half(value: &serde_json::Value, name: &str) -> Result<Option<Supplied>, JsError> {
let json = value.get(name);
let hex = value.get(alloc::format!("{name}Hex"));
match (json, hex) {
(Some(_), Some(_)) => Err(JsError::new(&alloc::format!(
"{name} and {name}Hex are both set"
))),
(Some(v), None) => Ok(Some(Supplied::Json(v.clone()))),
(None, Some(v)) => {
let s = v
.as_str()
.ok_or_else(|| JsError::new(&alloc::format!("{name}Hex is not a string")))?;
let s = s.strip_prefix("0x").unwrap_or(s);
if s.len() % 2 != 0 {
return Err(JsError::new(&alloc::format!("{name}Hex is not whole bytes")));
}
let bytes: Result<Vec<u8>, _> = (0..s.len() / 2)
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16))
.collect();
Ok(Some(Supplied::Raw(bytes.map_err(|e| {
JsError::new(&alloc::format!("{name}Hex: {e}"))
})?)))
}
(None, None) => Ok(None),
}
}

View File

@@ -0,0 +1,321 @@
// 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(self.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.
pub(crate) 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())
}
}
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};
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));
}
}
}
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())
}
}
}
}
/// 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
}
/// 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

@@ -0,0 +1,578 @@
// 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};
/// A value for one half of one signed extension.
#[derive(Debug, Clone)]
pub enum Supplied {
/// Interpreted against the type the runtime declares — see
/// [`Runtime::json_to_value`].
Json(serde_json::Value),
/// Already SCALE-encoded by whoever is asking for the signature.
///
/// A dapp hands the wallet an `era` as opaque bytes, having encoded it
/// itself, and there is no way to render those as JSON without knowing the
/// era algorithm — which is exactly the kind of knowledge this crate refuses
/// to hold. So they are accepted, but **not on trust**: [`Runtime::encode_half`]
/// decodes them against the declared type and re-encodes them, and anything
/// that does not round-trip is rejected rather than signed.
Raw(Vec<u8>),
}
/// 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<Supplied>,
pub additional: Option<Supplied>,
}
/// 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<&Supplied>,
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 converted = match supplied.ok_or_else(|| {
CodecError::MissingExtension(identifier.to_string())
})? {
Supplied::Json(value) => self.json_to_value(value, ty)?,
// Round-tripped rather than appended. Bytes that decode against the
// declared type and re-encode to themselves are the *only* bytes the
// runtime could have meant; anything else — a short read, trailing
// bytes, a non-canonical compact — is a disagreement about the format
// that would otherwise be signed and only surface as `BadProof`.
Supplied::Raw(bytes) => {
let mut cursor = &bytes[..];
let value = self
.decode_checked(ty, &mut cursor)
.map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))?;
if !cursor.is_empty() {
return Err(CodecError::Encode(format!(
"{identifier}: {} trailing bytes",
cursor.len()
)));
}
let mut check = Vec::new();
scale_value::scale::encode_as_type(&value, ty, self.types(), &mut check)
.map_err(|e| CodecError::Encode(format!("{identifier}: {e}")))?;
if check != *bytes {
return Err(CodecError::Encode(format!(
"{identifier}: supplied bytes do not round-trip against the runtime's type"
)));
}
out.extend_from_slice(bytes);
return Ok(());
}
};
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 named = variant.fields.iter().all(|f| f.name.is_some());
let composite = match (payload, variant.fields.len()) {
(_, 0) => Composite::Unnamed(Vec::new()),
// A call's arguments arrive as an object keyed by the names
// the runtime gives them, at any depth — a `Utility.batch_all`
// carries whole calls in a `Vec<RuntimeCall>`, and each of
// those is this same shape again. Before this, only the
// outermost call could be spelled that way and anything
// nested had to be a positional array.
(Some(serde_json::Value::Object(map)), _) if named => {
let mut fields = Vec::new();
for f in &variant.fields {
let field = f.name.clone().unwrap_or_default();
let v = map.get(&field).ok_or_else(|| {
CodecError::Encode(format!("{name}: no value for {field}"))
})?;
fields.push((field, self.json_to_value(v, f.ty.id)?));
}
Composite::Named(fields)
}
(Some(p), 1) => {
Composite::Unnamed(alloc::vec![self.json_to_value(p, variant.fields[0].ty.id)?])
}
(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,266 @@
// 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, BTreeSet};
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),
/// The runtime declares no such storage entry, or not in that shape.
NoStorageEntry(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::NoStorageEntry(s) => write!(f, "no such storage entry: {s}"),
Self::MissingExtension(s) => write!(
f,
"signed extension {s} declares a non-empty type and no value was supplied"
),
}
}
}
/// 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>,
/// 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 {
/// 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();
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 {
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 —
/// 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,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

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

View File

@@ -0,0 +1,328 @@
// 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, Supplied};
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]);
}
/// Pre-encoded bytes are accepted — a dapp encodes its own `era`, and nothing
/// here knows the era algorithm — but they are round-tripped against the
/// runtime's declared type rather than trusted.
#[test]
fn raw_extension_bytes_are_validated_not_trusted() {
let rt = heisenberg();
let mut values: BTreeMap<String, ExtensionValue> = BTreeMap::new();
let fill = |values: &mut BTreeMap<String, ExtensionValue>, era: Supplied| {
values.insert(
"CheckMortality".to_string(),
ExtensionValue {
additional: Some(Supplied::Json(serde_json::json!(
"0x".to_string() + &"aa".repeat(32)
))),
extra: Some(era),
},
);
values.insert(
"CheckNonce".to_string(),
ExtensionValue {
additional: None,
extra: Some(Supplied::Json(serde_json::json!(1))),
},
);
values.insert(
"ChargeTransactionPayment".to_string(),
ExtensionValue {
additional: None,
extra: Some(Supplied::Json(serde_json::json!(0))),
},
);
values.insert(
"CheckMetadataHash".to_string(),
ExtensionValue {
additional: Some(Supplied::Json(serde_json::json!("None"))),
extra: Some(Supplied::Json(serde_json::json!("Disabled"))),
},
);
for (id, v) in [
("CheckSpecVersion", 148),
("CheckTxVersion", 6),
] {
values.insert(
id.to_string(),
ExtensionValue {
additional: Some(Supplied::Json(serde_json::json!(v))),
extra: None,
},
);
}
values.insert(
"CheckGenesis".to_string(),
ExtensionValue {
additional: Some(Supplied::Json(serde_json::json!(
"0x".to_string() + &"bb".repeat(32)
))),
extra: None,
},
);
};
// `0x00` is Era::Immortal, and it round-trips.
fill(&mut values, Supplied::Raw(alloc::vec![0x00]));
let encoded = rt.encode_extensions(&values).expect("immortal era encodes");
assert_eq!(encoded.extra[0], 0x00);
// Two bytes where the type says one is a disagreement about the format. A
// signer that appended them would produce a signature the chain rejects as
// BadProof, with nothing locally to say why.
fill(&mut values, Supplied::Raw(alloc::vec![0x00, 0x00]));
let err = rt
.encode_extensions(&values)
.expect_err("trailing byte is refused");
assert!(
err.to_string().contains("trailing"),
"unexpected error: {err}"
);
}
/// A call nested inside another call — `Utility.batch_all` carries a
/// `Vec<RuntimeCall>`, so each element is a call spelled exactly as a top-level
/// one. Anything less than that and batching has to be written positionally,
/// which is unreadable and silently order-dependent.
#[test]
fn a_call_nests_inside_another_call() {
let rt = heisenberg();
let dest = "0x".to_string() + &"22".repeat(32);
let one = serde_json::json!({
"Balances": { "transfer_keep_alive": { "dest": { "Id": dest }, "value": "1000000000" } }
});
let encoded = rt
.encode_call(
"Utility",
"batch_all",
&serde_json::json!({ "calls": [one.clone(), one] }),
)
.expect("batch_all of two transfers encodes");
// Over 256 bytes, which is where Substrate's signing rule switches to
// BLAKE2b — the branch quantus/extension#7 wants exercised.
assert!(encoded.len() > 80, "unexpectedly short: {}", encoded.len());
let decoded = rt.decode_call(&encoded).expect("and decodes again");
let calls = decoded["Utility"]["batch_all"]["calls"]
.as_array()
.expect("calls is a list");
assert_eq!(calls.len(), 2);
assert_eq!(
calls[0]["Balances"]["transfer_keep_alive"]["value"],
serde_json::json!("1000000000")
);
}
/// `System::Account` is the entry every wallet needs first, and it is a map with
/// a `Blake2_128Concat` hasher over an `AccountId32`. Nothing here says so — the
/// hasher and both types come out of the metadata.
#[test]
fn a_storage_key_is_built_from_the_declared_hasher() {
let rt = heisenberg();
let who = "0x".to_string() + &"11".repeat(32);
let target = rt
.storage_target("System", "Account", &[serde_json::json!(who)])
.expect("System::Account is a map over AccountId32");
// twox128(prefix) ++ twox128(item) ++ blake2_128(key) ++ key
assert_eq!(target.key.len(), 16 + 16 + 16 + 32);
assert_eq!(&target.key[48..], &[0x11u8; 32]);
// AccountInfo is a `Default` entry: an account nobody has ever funded reads
// as a zero balance, not as a missing value. A wallet that treated the two
// alike would report a failure for an empty account.
let default = target.default.expect("AccountInfo is a Default entry");
let decoded = rt
.decode_storage_value(target.value_ty, &default)
.expect("the declared default decodes as the declared type");
// Every integer renders as a decimal string, whatever its width — see
// `decode::render` for why the width is not available to switch on.
assert_eq!(decoded["nonce"], serde_json::json!("0"));
assert_eq!(decoded["data"]["free"], serde_json::json!("0"));
}
/// The number of keys is the runtime's to state, not the caller's to assume.
#[test]
fn a_storage_entry_refuses_the_wrong_number_of_keys() {
let rt = heisenberg();
assert!(rt.storage_target("System", "Account", &[]).is_err());
assert!(rt.storage_target("System", "Number", &[serde_json::json!(1)]).is_err());
assert!(rt.storage_target("System", "NoSuchThing", &[]).is_err());
}
/// 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)));
}

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": []
}

955
packages/quantus-crypto/Cargo.lock generated Normal file
View File

@@ -0,0 +1,955 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bip39"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
dependencies = [
"bitcoin_hashes",
"zeroize",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
dependencies = [
"hex-conservative",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "fixed-hash"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcf0ed7fe52a17a03854ec54a9f76d6d84508d1c0e66bc1793301c73fc8493c"
dependencies = [
"static_assertions",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"serde",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08"
dependencies = [
"arrayvec",
]
[[package]]
name = "hex-literal"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "keccak-hash"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2bd4c29270e724d3eaadf7bdc8700af4221fc0ed771b855eadcd1b98d52851"
dependencies = [
"primitive-types",
"tiny-keccak",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plonky2_maybe_rayon"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1e554181dc95243b8d9948ae7bae5759c7fb2502fed28f671f95ef38079406"
[[package]]
name = "plonky2_util"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c32c137808ca984ab2458b612b7eb0462d853ee041a3136e83d54b96074c7610"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "primitive-types"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05e4722c697a58a99d5d06a08c30821d7c082a4632198de1eaa5a6c22ef42373"
dependencies = [
"fixed-hash",
"uint",
]
[[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 = "qp-plonky2"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd331d489a309f88e2d0e35a2b996932c7d92038b91ccc656a0a8e6b11b6977"
dependencies = [
"ahash",
"anyhow",
"critical-section",
"hashbrown",
"itertools",
"keccak-hash",
"log",
"num",
"once_cell",
"plonky2_maybe_rayon",
"plonky2_util",
"qp-plonky2-core",
"qp-plonky2-field",
"qp-plonky2-verifier",
"qp-poseidon-core",
"rand 0.10.1",
"serde",
"static_assertions",
"unroll",
]
[[package]]
name = "qp-plonky2-core"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b81a3a9fce99f7bd45b8578f8d9b6a33507d34c2eb2c47c969b464db1ad601d3"
dependencies = [
"ahash",
"anyhow",
"hashbrown",
"itertools",
"keccak-hash",
"log",
"num",
"plonky2_util",
"qp-plonky2-field",
"serde",
"static_assertions",
"unroll",
]
[[package]]
name = "qp-plonky2-field"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1630d418ddce9feba18d3364596711d07074851757301350de313eef0a31af4f"
dependencies = [
"anyhow",
"itertools",
"num",
"plonky2_util",
"rustc_version",
"serde",
"static_assertions",
"unroll",
]
[[package]]
name = "qp-plonky2-verifier"
version = "1.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "944da5dec21ee476d561f6c38caddf45e829f3cc5fccbc76f6ece03660378dbe"
dependencies = [
"ahash",
"anyhow",
"critical-section",
"hashbrown",
"itertools",
"keccak-hash",
"log",
"num",
"once_cell",
"plonky2_util",
"qp-plonky2-core",
"qp-plonky2-field",
"qp-poseidon-core",
"serde",
"static_assertions",
"unroll",
]
[[package]]
name = "qp-poseidon-core"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5872607e25ea4ee5fb37e64bf1462168e1a36a4e719cdc8a105533c708253918"
[[package]]
name = "qp-rusty-crystals-dilithium"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "789877c169226a35d2ea686bbd9d506becc693f7bb0ee91acc03f74491e80c0f"
dependencies = [
"zeroize",
]
[[package]]
name = "qp-rusty-crystals-hdwallet"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51ec6c3db4055c217a503c45d0c101cf3c10d4fc1e562f0588aa55dda4f60a4d"
dependencies = [
"bip39",
"getrandom 0.2.17",
"hex",
"hex-literal",
"qp-poseidon-core",
"qp-rusty-crystals-dilithium",
"serde",
"serde_json",
"sha2",
"thiserror",
"unicode-normalization",
"zeroize",
]
[[package]]
name = "qp-wormhole-circuit"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55167daf965a3616b74184171148b94c64b3a48771d6433a1a43377a6e46c6e8"
dependencies = [
"anyhow",
"hex",
"qp-plonky2",
"qp-wormhole-inputs",
"qp-zk-circuits-common",
"zeroize",
]
[[package]]
name = "qp-wormhole-inputs"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c07863a2211a17b46319289c5ef22e5670a3fb8653386bb366c418193254793"
dependencies = [
"anyhow",
]
[[package]]
name = "qp-zk-circuits-common"
version = "4.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec45510701f160fdb730bdb622d6cdd0a62547e607c9b1ecb19558202797f81"
dependencies = [
"anyhow",
"qp-plonky2",
"qp-poseidon-core",
"qp-wormhole-inputs",
"rand 0.8.6",
"serde",
"serde_json",
]
[[package]]
name = "quantus_crypto"
version = "0.0.0"
dependencies = [
"qp-poseidon-core",
"qp-rusty-crystals-dilithium",
"qp-rusty-crystals-hdwallet",
"qp-wormhole-circuit",
"qp-zk-circuits-common",
"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 = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[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.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[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 = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uint"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52"
dependencies = [
"byteorder",
"crunchy",
"hex",
"static_assertions",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unroll"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad948c1cb799b1a70f836077721a92a35ac177d4daddf4c20a633786d4cf618"
dependencies = [
"quote",
"syn 1.0.109",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[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 = "zerocopy"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View File

@@ -0,0 +1,47 @@
# Quantus post-quantum crypto, compiled to WASM for the browser.
#
# Deliberately a separate crate from `wasm-crypto` rather than more files inside
# it: that package is built with `nightly-2022-06-24` (see scripts/rust-version.sh)
# against a 2019-era dependency set, and the ML-DSA crates use inline `const {}`
# blocks that need Rust >= 1.79. The two cannot share a Cargo graph, and bumping
# the older one would mean rewriting upstream's sr25519/ed25519 build — which is
# the thing we most want to leave alone so rebases stay boring. See quantus/wasm#1.
[package]
authors = ["Quantus Network Developers <hello@quantus.com>"]
description = "WASM bindings to the Quantus chain's post-quantum crypto crates."
edition = "2021"
license = "Apache-2.0"
name = "quantus_crypto"
publish = false
repository = "https://git.lair.cafe/quantus/wasm"
resolver = "2"
version = "0.0.0"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# The crates the runtime itself uses. Versions match quantus-apps/quantus_sdk's
# rust bridge, which is the other non-Rust consumer of exactly this surface.
qp-poseidon-core = "3.1.0"
qp-rusty-crystals-dilithium = { version = "4.1.1", default-features = false, features = ["ml-dsa-65", "ml-dsa-87"] }
qp-rusty-crystals-hdwallet = { version = "4.1.1", default-features = false, features = ["ml-dsa-65", "ml-dsa-87"] }
wasm-bindgen = "0.2"
[dev-dependencies]
# The chain's own nullifier, for known-answer tests only. It pulls in plonky2,
# which has no place in the shipped WASM: the port in rs/hdwallet.rs has to
# agree with it, and these tests are how that is shown rather than asserted.
qp-wormhole-circuit = { version = "=4.3.0", default-features = false, features = ["std"] }
qp-zk-circuits-common = { version = "=4.3.0" }
[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,76 @@
# @quantus/crypto
Quantus post-quantum crypto for the browser: ML-DSA-65 and ML-DSA-87 signatures,
Poseidon2-over-Goldilocks account-id hashing, and hardened BIP44 key derivation.
Every function delegates to the crates the Quantus runtime itself uses —
`qp-rusty-crystals-dilithium`, `qp-poseidon-core`, `qp-rusty-crystals-hdwallet`
rather than reimplementing them. A browser wallet that disagreed with the chain
about a key or a signature would produce perfectly well-formed output that the
chain rejects, and nothing on this side could tell.
## Why a separate package from `@polkadot/wasm-crypto`
They cannot share a Cargo build. `wasm-crypto` is compiled with
`nightly-2022-06-24` against a 2019-era dependency set; the ML-DSA crates use
inline `const {}` blocks that require Rust >= 1.79. Modernising the older build
would mean rewriting upstream's sr25519/ed25519 crypto, which is the thing most
worth leaving untouched so rebases onto upstream stay boring.
What *is* shared is the packaging: the WASM is zlib-compressed and base64'd into
the JS at build time, so nothing is fetched at runtime. That matters because the
consumer is an MV3 service worker under `script-src 'self' 'wasm-unsafe-eval'`,
which can compile WASM but cannot usefully fetch it, and because callers like
`pair.sign()` are synchronous and have no `await` to give.
`@polkadot/wasm-bridge` is deliberately not used: its `Bridge` implements
wasm-bindgen 0.2.79's JS-heap ABI, while this crate builds with 0.2.128, which
uses externref tables. wasm-bindgen's own generated glue plus `initSync` is both
smaller and correct.
The only runtime dependency is `fflate`, for zlib inflate. Base64 decoding is
fifteen lines here rather than a dependency. Both were originally taken from
`@polkadot/wasm-util`, which turned out to cost more than it saved: its 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 this package was consumed by symlink from another
checkout — which is exactly how `quantus/common` consumes it during development.
## Scheme selector
`Scheme.MlDsa87 = 0`, `Scheme.MlDsa65 = 1` — these are the chain's own
`DilithiumSignatureScheme` variant indices, so the number threaded through this
API is the byte that ends up on the wire. New accounts use ML-DSA-65; ML-DSA-87
is legacy and must be supported but never chosen.
## Signing context
ML-DSA hashes a context into the signature. Quantus extrinsics on spec >= 148 are
verified under `QUANTUS_EXTRINSIC`, earlier specs under the empty context. A
signature made under the wrong one is cryptographically valid, rejected by the
chain, and indistinguishable locally — so use `contextForSpec(specVersion)`
rather than picking one by hand. Nothing here guesses on your behalf.
## Sizes come from the crate
`sizes(scheme)` returns the public/secret/signature lengths rather than exposing
constants to copy. They are consensus-critical — the runtime decodes a fixed-size
array with no compact length prefix — and a JS constant that drifted would
re-frame every byte after the signature while looking entirely healthy.
## Building
```sh
yarn install-build-deps # downloads wasm-bindgen 0.2.128 and binaryen
./scripts/build-quantus.sh
```
The Rust toolchain is pinned in `rust-toolchain.toml` to the same channel the
chain builds its runtime with.
## Tests
`cargo test` runs conformance tests whose expected values come from the `quantus`
CLI, not from this crate — the dev-genesis account ids, HD derivation at both
schemes' default paths, and context separation. A test that pinned our own output
would keep passing through exactly the drift they exist to catch.

View File

@@ -0,0 +1,24 @@
{
"author": "Quantus Network Developers <hello@quantus.com>",
"bugs": "https://git.lair.cafe/quantus/wasm/issues",
"description": "Quantus post-quantum crypto (ML-DSA, Poseidon2, HD derivation) for the browser",
"engines": {
"node": ">=18"
},
"homepage": "https://git.lair.cafe/quantus/wasm/src/branch/main/packages/quantus-crypto#readme",
"license": "Apache-2.0",
"name": "@quantus/crypto",
"repository": {
"directory": "packages/quantus-crypto",
"type": "git",
"url": "https://git.lair.cafe/quantus/wasm.git"
},
"sideEffects": false,
"type": "module",
"version": "0.3.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;
}

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,210 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { ext_mldsa_derive, ext_mldsa_from_seed, ext_mldsa_is_scheme, ext_mldsa_sign, ext_mldsa_sizes, ext_mldsa_verify, ext_poseidon_hash, ext_wormhole_addresses, ext_wormhole_nullifiers } from './generated/quantus_crypto.js';
import { initWasm } from './init.js';
import { Scheme } from './scheme.js';
export interface Keypair {
publicKey: Uint8Array;
secretKey: Uint8Array;
}
export interface Sizes {
/** Public key length: 1952 for ML-DSA-65, 2592 for ML-DSA-87. */
publicKey: number;
/** Secret key length: 4032 / 4896. */
secretKey: number;
/** Signature length: 3309 / 4627. */
signature: number;
/** `signature ‖ publicKey`, the runtime's wire form: 5261 / 7219. */
signatureWithPublicKey: number;
}
function ready (): void {
const error = initWasm();
if (error) {
throw new Error(`@quantus/crypto: WASM unavailable: ${error}`);
}
}
/**
* Key and signature sizes for a scheme, read from the crate rather than
* hardcoded here.
*
* These are consensus-critical — the runtime decodes a fixed-size array off the
* wire — so a constant that drifted from the crate would re-frame every byte
* after the signature while looking entirely healthy. Ask, don't assume.
*/
export function sizes (scheme: Scheme): Sizes {
ready();
const raw = new DataView(ext_mldsa_sizes(scheme).buffer);
return {
publicKey: raw.getUint32(0, true),
secretKey: raw.getUint32(4, true),
signature: raw.getUint32(8, true),
signatureWithPublicKey: raw.getUint32(12, true)
};
}
/** Whether this build supports `scheme`. */
export function isScheme (scheme: number): scheme is Scheme {
ready();
return ext_mldsa_is_scheme(scheme);
}
/**
* The account id for a public key — Poseidon2 over Goldilocks, 32 bytes out.
*
* This is the step that has no Substrate equivalent. There, an `AccountId32`
* *is* the public key; here it is a one-way hash of it, which is why a Quantus
* signature has to carry its public key inside itself and why nothing can
* recover a key from an address.
*/
export function accountFromPublicKey (publicKey: Uint8Array): Uint8Array {
ready();
return ext_poseidon_hash(publicKey);
}
/** A keypair from 32 bytes of entropy — FIPS 204 `ML-DSA.KeyGen_internal`. */
export function keypairFromSeed (seed: Uint8Array, scheme: Scheme): Keypair {
ready();
return split(ext_mldsa_from_seed(seed, scheme), scheme);
}
/**
* A keypair from a BIP39 mnemonic at a hardened Quantus derivation path.
*
* `path` must be hardened at every level — lattice keys have no public
* derivability, so there is no soft-junction equivalent and one is rejected
* rather than reinterpreted.
*/
export function keypairFromMnemonic (mnemonic: string, password: string, path: string, scheme: Scheme): Keypair {
ready();
return split(ext_mldsa_derive(mnemonic, password, path, scheme), scheme);
}
/** Which wormhole branch: where deposits arrive, or where a send returns its change. */
export enum WormholeBranch {
Receive = 0,
Change = 1
}
/**
* Consecutive wormhole addresses for one account and branch, as 32-byte account
* ids.
*
* Paths are `m/44'/189189189'/<account>'/<branch>'/<index>'`, the mobile
* wallet's. A wormhole address has no key: it is a double Poseidon hash of a
* secret the path yields, and funds leave it only through a ZK proof of that
* secret. Only the addresses cross into JavaScript; the secrets are derived and
* wiped inside WASM.
*
* The recovery phrase is stretched once per call, so ask for a window of
* addresses at once rather than looping over single ones. At most 1000 per call.
*/
export function wormholeAddresses (mnemonic: string, password: string, account: number, branch: WormholeBranch, start: number, count: number): Uint8Array[] {
ready();
const flat = ext_wormhole_addresses(mnemonic, password, account, branch, start, count);
const out: Uint8Array[] = [];
for (let i = 0; i < count; i++) {
out.push(flat.slice(i * 32, (i + 1) * 32));
}
return out;
}
/**
* Nullifiers for a run of wormhole addresses, one per deposit transfer count.
*
* A deposit to a wormhole address is spent when its nullifier is in
* `Wormhole::UsedNullifiers`. For each address `start..start + addresses` on
* `branch` of `account`, this returns the nullifiers for transfer counts
* `first..first + count`: `result[a][c]` belongs to address `start + a` and
* transfer count `first + c`.
*
* Check them locally, against a copy of the whole spent set. Never look one up
* by key or send it to a service: exits publish their nullifiers, so whoever
* sees yours can name your exits. At most 100,000 per call; the recovery phrase
* is stretched once per call.
*/
export function wormholeNullifiers (mnemonic: string, password: string, account: number, branch: WormholeBranch, start: number, addresses: number, first: number, count: number): Uint8Array[][] {
ready();
const flat = ext_wormhole_nullifiers(mnemonic, password, account, branch, start, addresses, BigInt(first), count);
const out: Uint8Array[][] = [];
for (let a = 0; a < addresses; a++) {
const row: Uint8Array[] = [];
for (let c = 0; c < count; c++) {
const at = (a * count + c) * 32;
row.push(flat.slice(at, at + 32));
}
out.push(row);
}
return out;
}
/**
* Sign under a FIPS 204 context.
*
* `context` is not optional in spirit: extrinsics on spec >= 148 verify under
* `QUANTUS_EXTRINSIC` and earlier ones under the empty context, and the wrong
* choice yields a valid signature that the chain rejects with nothing locally
* able to tell. Use `contextForSpec` rather than picking one by hand.
*
* Returns the bare signature. The runtime's wire form is `signature ‖ publicKey`
* — see {@link signatureWithPublicKey} — but only the caller knows which it
* wants.
*/
export function sign (message: Uint8Array, { publicKey, secretKey }: Keypair, context: Uint8Array, scheme: Scheme): Uint8Array {
ready();
return ext_mldsa_sign(secretKey, publicKey, message, context, scheme);
}
/** Verify a bare signature under a context. */
export function verify (message: Uint8Array, signature: Uint8Array, publicKey: Uint8Array, context: Uint8Array, scheme: Scheme): boolean {
ready();
return ext_mldsa_verify(publicKey, message, signature, context, scheme);
}
/**
* `signature ‖ publicKey` — what a signed extrinsic actually carries.
*
* The runtime encodes this as a fixed-size array with **no compact length
* prefix**, preceded by the scheme's enum variant byte. Getting that framing
* wrong re-frames every byte after it into something that still decodes.
*/
export function signatureWithPublicKey (signature: Uint8Array, publicKey: Uint8Array): Uint8Array {
const out = new Uint8Array(signature.length + publicKey.length);
out.set(signature);
out.set(publicKey, signature.length);
return out;
}
/** The crate returns `secretKey ‖ publicKey`, matching `ext_ed_from_seed`. */
function split (pair: Uint8Array, scheme: Scheme): Keypair {
const { secretKey } = sizes(scheme);
return {
publicKey: pair.subarray(secretKey),
secretKey: pair.subarray(0, secretKey)
};
}

View File

@@ -0,0 +1,226 @@
/* tslint:disable */
/* eslint-disable */
/**
* Derive a keypair from a BIP39 mnemonic at a hardened derivation path.
*
* Lattice keys have no public derivability, so there is no soft-junction
* equivalent and the crate rejects any unhardened path outright. The Quantus
* convention is:
*
* ```text
* m/44'/189189'/<account>'/0'/<0 for ML-DSA-87 | 1 for ML-DSA-65>'
* ```
*
* with the account index at the third level and the *scheme* carried in the
* trailing index. That is unusual, and it is what `quantus-cli` and the mobile
* wallet already use — deriving anything else produces addresses no other
* Quantus tool can find.
*
* The seeding matters as much as the path. This goes mnemonic → 64-byte BIP39
* seed → HMAC-SHA512 chain keyed with the literal string `"Dilithium seed"`.
* Substrate's own `mnemonicToMiniSecret` is a *different* derivation and is the
* default reach in the polkadot-js codebase; using it here would yield a
* well-formed key for an account nobody owns.
*
* * mnemonic: BIP39 phrase, 12/15/18/21/24 words
* * password: BIP39 passphrase; empty string for none
* * path: hardened derivation path, e.g. `m/44'/189189'/0'/0'/1'`
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is the secret key followed by the public key, as
* `ext_mldsa_from_seed` returns.
*/
export function ext_mldsa_derive(mnemonic: string, password: string, path: string, scheme: number): Uint8Array;
/**
* Generate a keypair from 32 bytes of entropy.
*
* This is FIPS 204 `ML-DSA.KeyGen_internal` with no Quantus-specific step: the
* crate expands the seed as `SHAKE256(seed ‖ k ‖ )`, so the parameter set is
* absorbed into the expansion and the same 32 bytes yield independent keys per
* scheme. That is why the dev accounts (`[0u8; 32]`, `[1u8; 32]`, `[2u8; 32]`)
* and HD-derived accounts can share this one entry point.
*
* * seed: UIntArray with 32 elements
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is the secret key followed by the public key, matching the
* ordering `ext_ed_from_seed` uses. Split it at the secret length from
* `ext_mldsa_sizes`.
*/
export function ext_mldsa_from_seed(seed: Uint8Array, scheme: number): Uint8Array;
/**
* Whether `scheme` names a parameter set this build supports.
*
* `dispatch!` falls back to ML-DSA-87 for anything unrecognised, which is the
* right default but a poor way to discover a typo. Callers that accept a scheme
* from storage or from a user should check here first.
*/
export function ext_mldsa_is_scheme(scheme: number): boolean;
/**
* Sign a message under a FIPS 204 context.
*
* Signing is deterministic — no hedging randomness — because that is what the
* runtime does (`hedge: None`), and a wallet that hedged would produce a
* different signature each time for the same input, which makes the
* byte-for-byte agreement tests in quantus/wasm#2 impossible to write.
*
* `ctx` is domain separation and it is **not** optional in practice: extrinsics
* on spec >= 148 are verified under `QUANTUS_EXTRINSIC`, earlier specs under the
* empty context, and a signature made under the wrong one is valid, rejected by
* the chain, and indistinguishable locally. The caller chooses; this function
* does not guess.
*
* * secret: UIntArray, secret-key length for the scheme
* * public: UIntArray, public-key length for the scheme
* * message: arbitrary length UIntArray
* * ctx: UIntArray, at most 255 elements; empty for no context
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is the signature alone. The runtime's wire format is
* `signature ‖ public`; concatenating is the caller's job because only the
* caller knows whether it wants the wire form or the bare signature.
*/
export function ext_mldsa_sign(secret: Uint8Array, _public: Uint8Array, message: Uint8Array, ctx: Uint8Array, scheme: number): Uint8Array;
/**
* Key and signature sizes for a parameter set, as
* `[public, secret, signature, signature_with_public]`.
*
* Exported so that nothing on the JS side has to hardcode 1952/4032/3309/5261 or
* 2592/4896/4627/7219. Those numbers are consensus-critical — the runtime reads a
* fixed-size array off the wire — and a JS constant that drifted from the crate
* would mis-frame every byte after the signature while looking entirely healthy.
* Ask the crate instead.
*
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is four u32 lengths, little-endian, 16 bytes total.
*/
export function ext_mldsa_sizes(scheme: number): Uint8Array;
/**
* Verify a signature against a message and public key under a context.
*
* * public: UIntArray, public-key length for the scheme
* * message: arbitrary length UIntArray
* * signature: UIntArray, signature length for the scheme
* * ctx: UIntArray, at most 255 elements; empty for no context
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*/
export function ext_mldsa_verify(_public: Uint8Array, message: Uint8Array, signature: Uint8Array, ctx: Uint8Array, scheme: number): boolean;
/**
* Poseidon2-over-Goldilocks hash of arbitrary bytes.
*
* This is the account-id derivation. On Substrate an `AccountId32` *is* the
* public key; on Quantus it is `hash_bytes(public_key)`, which is why a Quantus
* signature has to carry its public key along — the address cannot give it back.
*
* `qp_poseidon_core::hash_bytes` is `IdentifyAccount for DilithiumSigner` in the
* runtime, so this is the same function the chain uses to decide who signed
* something, reached through the same crate rather than a port of it.
*
* * data: arbitrary length UIntArray
*
* * returned vector is 32 bytes.
*/
export function ext_poseidon_hash(data: Uint8Array): Uint8Array;
/**
* Derive consecutive wormhole addresses for one account and branch.
*
* A wormhole address is not a key. The path yields a 32-byte **secret**, and
* the address is `poseidon(poseidon(salt ‖ secret))`; funds leave it only
* through a ZK proof of knowing that secret. So this returns the addresses and
* nothing else. The secrets and the intermediate `first_hash` stay inside this
* call and are wiped on drop by the crate's sensitive types.
*
* Paths are `m/44'/189189189'/<account>'/<change>'/<index>'`, as the mobile
* wallet derives them: change `0` is the receive branch, `1` the change branch.
*
* The BIP39 seed is stretched once for the whole batch. Stretching per address
* is PBKDF2 with 2048 rounds each time, and a gap-limit window is dozens of
* addresses.
*
* * mnemonic: BIP39 phrase
* * password: BIP39 passphrase; empty string for none
* * account, change, start, count: which addresses; count <= WORMHOLE_MAX_BATCH
*
* * returned vector is `count` 32-byte account ids, concatenated
*/
export function ext_wormhole_addresses(mnemonic: string, password: string, account: number, change: number, start: number, count: number): Uint8Array;
/**
* Nullifiers for a run of wormhole addresses' deposits, by transfer count.
*
* A deposit to a wormhole address is spent when its nullifier is in
* `Wormhole::UsedNullifiers`. The nullifier is
*
* ```text
* poseidon2(poseidon2(salt("~nullif~") ‖ secret ‖ transfer_count))
* ```
*
* where `transfer_count` is the address's counter when the deposit landed. It
* needs the address's secret, which is why this takes the recovery phrase and
* why a nullifier should never be sent anywhere to be checked: exits publish
* nullifiers, so whoever sees yours can name your exits.
*
* For addresses `m/44'/189189189'/<account>'/<change>'/<index>'` with index in
* `start..start + addresses`, and transfer counts `first..first + count` for
* each. The BIP39 seed is stretched once for the whole run.
*
* Ported onto `qp-poseidon-core` rather than calling `qp-wormhole-circuit`,
* which would bring plonky2 into the WASM. The port is pinned to the circuit
* crate's own `Nullifier::from_preimage` by the tests.
*
* * returned vector is `addresses * count` 32-byte nullifiers: address by
* address, and by transfer count within each
*/
export function ext_wormhole_nullifiers(mnemonic: string, password: string, account: number, change: number, start: number, addresses: number, first: bigint, count: number): Uint8Array;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly ext_mldsa_derive: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number];
readonly ext_mldsa_from_seed: (a: number, b: number, c: number) => [number, number, number, number];
readonly ext_mldsa_is_scheme: (a: number) => number;
readonly ext_mldsa_sign: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => [number, number, number, number];
readonly ext_mldsa_sizes: (a: number) => [number, number];
readonly ext_mldsa_verify: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number) => number;
readonly ext_poseidon_hash: (a: number, b: number) => [number, number];
readonly ext_wormhole_addresses: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number, number, number];
readonly ext_wormhole_nullifiers: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: bigint, j: number) => [number, number, number, number];
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
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,503 @@
/* @ts-self-types="./quantus_crypto.d.ts" */
/**
* Derive a keypair from a BIP39 mnemonic at a hardened derivation path.
*
* Lattice keys have no public derivability, so there is no soft-junction
* equivalent and the crate rejects any unhardened path outright. The Quantus
* convention is:
*
* ```text
* m/44'/189189'/<account>'/0'/<0 for ML-DSA-87 | 1 for ML-DSA-65>'
* ```
*
* with the account index at the third level and the *scheme* carried in the
* trailing index. That is unusual, and it is what `quantus-cli` and the mobile
* wallet already use — deriving anything else produces addresses no other
* Quantus tool can find.
*
* The seeding matters as much as the path. This goes mnemonic → 64-byte BIP39
* seed → HMAC-SHA512 chain keyed with the literal string `"Dilithium seed"`.
* Substrate's own `mnemonicToMiniSecret` is a *different* derivation and is the
* default reach in the polkadot-js codebase; using it here would yield a
* well-formed key for an account nobody owns.
*
* * mnemonic: BIP39 phrase, 12/15/18/21/24 words
* * password: BIP39 passphrase; empty string for none
* * path: hardened derivation path, e.g. `m/44'/189189'/0'/0'/1'`
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is the secret key followed by the public key, as
* `ext_mldsa_from_seed` returns.
* @param {string} mnemonic
* @param {string} password
* @param {string} path
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_derive(mnemonic, password, path, scheme) {
const ptr0 = passStringToWasm0(mnemonic, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passStringToWasm0(path, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_derive(ptr0, len0, ptr1, len1, ptr2, len2, scheme);
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;
}
/**
* Generate a keypair from 32 bytes of entropy.
*
* This is FIPS 204 `ML-DSA.KeyGen_internal` with no Quantus-specific step: the
* crate expands the seed as `SHAKE256(seed ‖ k ‖ )`, so the parameter set is
* absorbed into the expansion and the same 32 bytes yield independent keys per
* scheme. That is why the dev accounts (`[0u8; 32]`, `[1u8; 32]`, `[2u8; 32]`)
* and HD-derived accounts can share this one entry point.
*
* * seed: UIntArray with 32 elements
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is the secret key followed by the public key, matching the
* ordering `ext_ed_from_seed` uses. Split it at the secret length from
* `ext_mldsa_sizes`.
* @param {Uint8Array} seed
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_from_seed(seed, scheme) {
const ptr0 = passArray8ToWasm0(seed, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_from_seed(ptr0, len0, scheme);
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;
}
/**
* Whether `scheme` names a parameter set this build supports.
*
* `dispatch!` falls back to ML-DSA-87 for anything unrecognised, which is the
* right default but a poor way to discover a typo. Callers that accept a scheme
* from storage or from a user should check here first.
* @param {number} scheme
* @returns {boolean}
*/
export function ext_mldsa_is_scheme(scheme) {
const ret = wasm.ext_mldsa_is_scheme(scheme);
return ret !== 0;
}
/**
* Sign a message under a FIPS 204 context.
*
* Signing is deterministic — no hedging randomness — because that is what the
* runtime does (`hedge: None`), and a wallet that hedged would produce a
* different signature each time for the same input, which makes the
* byte-for-byte agreement tests in quantus/wasm#2 impossible to write.
*
* `ctx` is domain separation and it is **not** optional in practice: extrinsics
* on spec >= 148 are verified under `QUANTUS_EXTRINSIC`, earlier specs under the
* empty context, and a signature made under the wrong one is valid, rejected by
* the chain, and indistinguishable locally. The caller chooses; this function
* does not guess.
*
* * secret: UIntArray, secret-key length for the scheme
* * public: UIntArray, public-key length for the scheme
* * message: arbitrary length UIntArray
* * ctx: UIntArray, at most 255 elements; empty for no context
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is the signature alone. The runtime's wire format is
* `signature ‖ public`; concatenating is the caller's job because only the
* caller knows whether it wants the wire form or the bare signature.
* @param {Uint8Array} secret
* @param {Uint8Array} _public
* @param {Uint8Array} message
* @param {Uint8Array} ctx
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_sign(secret, _public, message, ctx, scheme) {
const ptr0 = passArray8ToWasm0(secret, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(_public, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
const len2 = WASM_VECTOR_LEN;
const ptr3 = passArray8ToWasm0(ctx, wasm.__wbindgen_malloc);
const len3 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_sign(ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3, scheme);
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;
}
/**
* Key and signature sizes for a parameter set, as
* `[public, secret, signature, signature_with_public]`.
*
* Exported so that nothing on the JS side has to hardcode 1952/4032/3309/5261 or
* 2592/4896/4627/7219. Those numbers are consensus-critical — the runtime reads a
* fixed-size array off the wire — and a JS constant that drifted from the crate
* would mis-frame every byte after the signature while looking entirely healthy.
* Ask the crate instead.
*
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
*
* * returned vector is four u32 lengths, little-endian, 16 bytes total.
* @param {number} scheme
* @returns {Uint8Array}
*/
export function ext_mldsa_sizes(scheme) {
const ret = wasm.ext_mldsa_sizes(scheme);
var v1 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v1;
}
/**
* Verify a signature against a message and public key under a context.
*
* * public: UIntArray, public-key length for the scheme
* * message: arbitrary length UIntArray
* * signature: UIntArray, signature length for the scheme
* * ctx: UIntArray, at most 255 elements; empty for no context
* * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
* @param {Uint8Array} _public
* @param {Uint8Array} message
* @param {Uint8Array} signature
* @param {Uint8Array} ctx
* @param {number} scheme
* @returns {boolean}
*/
export function ext_mldsa_verify(_public, message, signature, ctx, scheme) {
const ptr0 = passArray8ToWasm0(_public, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passArray8ToWasm0(signature, wasm.__wbindgen_malloc);
const len2 = WASM_VECTOR_LEN;
const ptr3 = passArray8ToWasm0(ctx, wasm.__wbindgen_malloc);
const len3 = WASM_VECTOR_LEN;
const ret = wasm.ext_mldsa_verify(ptr0, len0, ptr1, len1, ptr2, len2, ptr3, len3, scheme);
return ret !== 0;
}
/**
* Poseidon2-over-Goldilocks hash of arbitrary bytes.
*
* This is the account-id derivation. On Substrate an `AccountId32` *is* the
* public key; on Quantus it is `hash_bytes(public_key)`, which is why a Quantus
* signature has to carry its public key along — the address cannot give it back.
*
* `qp_poseidon_core::hash_bytes` is `IdentifyAccount for DilithiumSigner` in the
* runtime, so this is the same function the chain uses to decide who signed
* something, reached through the same crate rather than a port of it.
*
* * data: arbitrary length UIntArray
*
* * returned vector is 32 bytes.
* @param {Uint8Array} data
* @returns {Uint8Array}
*/
export function ext_poseidon_hash(data) {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.ext_poseidon_hash(ptr0, len0);
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* Derive consecutive wormhole addresses for one account and branch.
*
* A wormhole address is not a key. The path yields a 32-byte **secret**, and
* the address is `poseidon(poseidon(salt ‖ secret))`; funds leave it only
* through a ZK proof of knowing that secret. So this returns the addresses and
* nothing else. The secrets and the intermediate `first_hash` stay inside this
* call and are wiped on drop by the crate's sensitive types.
*
* Paths are `m/44'/189189189'/<account>'/<change>'/<index>'`, as the mobile
* wallet derives them: change `0` is the receive branch, `1` the change branch.
*
* The BIP39 seed is stretched once for the whole batch. Stretching per address
* is PBKDF2 with 2048 rounds each time, and a gap-limit window is dozens of
* addresses.
*
* * mnemonic: BIP39 phrase
* * password: BIP39 passphrase; empty string for none
* * account, change, start, count: which addresses; count <= WORMHOLE_MAX_BATCH
*
* * returned vector is `count` 32-byte account ids, concatenated
* @param {string} mnemonic
* @param {string} password
* @param {number} account
* @param {number} change
* @param {number} start
* @param {number} count
* @returns {Uint8Array}
*/
export function ext_wormhole_addresses(mnemonic, password, account, change, start, count) {
const ptr0 = passStringToWasm0(mnemonic, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.ext_wormhole_addresses(ptr0, len0, ptr1, len1, account, change, start, count);
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;
}
/**
* Nullifiers for a run of wormhole addresses' deposits, by transfer count.
*
* A deposit to a wormhole address is spent when its nullifier is in
* `Wormhole::UsedNullifiers`. The nullifier is
*
* ```text
* poseidon2(poseidon2(salt("~nullif~") ‖ secret ‖ transfer_count))
* ```
*
* where `transfer_count` is the address's counter when the deposit landed. It
* needs the address's secret, which is why this takes the recovery phrase and
* why a nullifier should never be sent anywhere to be checked: exits publish
* nullifiers, so whoever sees yours can name your exits.
*
* For addresses `m/44'/189189189'/<account>'/<change>'/<index>'` with index in
* `start..start + addresses`, and transfer counts `first..first + count` for
* each. The BIP39 seed is stretched once for the whole run.
*
* Ported onto `qp-poseidon-core` rather than calling `qp-wormhole-circuit`,
* which would bring plonky2 into the WASM. The port is pinned to the circuit
* crate's own `Nullifier::from_preimage` by the tests.
*
* * returned vector is `addresses * count` 32-byte nullifiers: address by
* address, and by transfer count within each
* @param {string} mnemonic
* @param {string} password
* @param {number} account
* @param {number} change
* @param {number} start
* @param {number} addresses
* @param {bigint} first
* @param {number} count
* @returns {Uint8Array}
*/
export function ext_wormhole_nullifiers(mnemonic, password, account, change, start, addresses, first, count) {
const ptr0 = passStringToWasm0(mnemonic, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.ext_wormhole_nullifiers(ptr0, len0, ptr1, len1, account, change, start, addresses, first, count);
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;
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg_Error_67e7344beaa85059: function(arg0, arg1) {
const ret = Error(getStringFromWasm0(arg0, arg1));
return ret;
},
__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_crypto_bg.js": import0,
};
}
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,7 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
export { accountFromPublicKey, isScheme, keypairFromMnemonic, keypairFromSeed, sign, signatureWithPublicKey, sizes, verify, WormholeBranch, wormholeAddresses, wormholeNullifiers } from './crypto.js';
export type { Keypair, Sizes } from './crypto.js';
export { initWasm, isReady } from './init.js';
export { contextForSpec, EXTRINSIC_CONTEXT, EXTRINSIC_MIN_SPEC, Scheme, SCHEME_NAME } from './scheme.js';

View File

@@ -0,0 +1,61 @@
// Copyright 2026 @quantus/crypto 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_crypto.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,27 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Quantus post-quantum crypto for the browser.
//!
//! Every function here delegates to the crates the Quantus runtime itself uses
//! (`qp-rusty-crystals-dilithium`, `qp-poseidon-core`, `qp-rusty-crystals-hdwallet`)
//! rather than reimplementing anything. That is the whole point: a browser wallet
//! that disagreed with the chain about a key or a signature would produce
//! perfectly well-formed output that the chain rejects, and nothing on this side
//! could tell.
#[path = "rs/hdwallet.rs"]
pub mod hdwallet;
#[path = "rs/mldsa.rs"]
pub mod mldsa;
#[path = "rs/poseidon.rs"]
pub mod poseidon;
#[path = "rs/scheme.rs"]
pub mod scheme;
#[cfg(test)]
#[path = "rs/tests.rs"]
mod tests;

View File

@@ -0,0 +1,231 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
use wasm_bindgen::prelude::*;
use crate::scheme::dispatch;
/// Derive a keypair from a BIP39 mnemonic at a hardened derivation path.
///
/// Lattice keys have no public derivability, so there is no soft-junction
/// equivalent and the crate rejects any unhardened path outright. The Quantus
/// convention is:
///
/// ```text
/// m/44'/189189'/<account>'/0'/<0 for ML-DSA-87 | 1 for ML-DSA-65>'
/// ```
///
/// with the account index at the third level and the *scheme* carried in the
/// trailing index. That is unusual, and it is what `quantus-cli` and the mobile
/// wallet already use — deriving anything else produces addresses no other
/// Quantus tool can find.
///
/// The seeding matters as much as the path. This goes mnemonic → 64-byte BIP39
/// seed → HMAC-SHA512 chain keyed with the literal string `"Dilithium seed"`.
/// Substrate's own `mnemonicToMiniSecret` is a *different* derivation and is the
/// default reach in the polkadot-js codebase; using it here would yield a
/// well-formed key for an account nobody owns.
///
/// * mnemonic: BIP39 phrase, 12/15/18/21/24 words
/// * password: BIP39 passphrase; empty string for none
/// * path: hardened derivation path, e.g. `m/44'/189189'/0'/0'/1'`
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is the secret key followed by the public key, as
/// `ext_mldsa_from_seed` returns.
#[wasm_bindgen]
pub fn ext_mldsa_derive(mnemonic: &str, password: &str, path: &str, scheme: u32) -> Result<Vec<u8>, JsError> {
mldsa_derive(mnemonic, password, path, scheme).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_mldsa_derive`]. See [`crate::mldsa::mldsa_from_seed`] for why
/// this is split from its binding.
pub fn mldsa_derive(mnemonic: &str, password: &str, path: &str, scheme: u32) -> Result<Vec<u8>, String> {
// An empty passphrase and no passphrase are the same thing in BIP39, but the
// crate distinguishes `None` from `Some("")` in its signature, so normalise
// here rather than leaving each caller to pick one.
let password = if password.is_empty() {
None
} else {
Some(password)
};
dispatch!(scheme, _dsa, hd, {
let pair = hd::derive_key_from_mnemonic(mnemonic, password, path)
.map_err(alloc_error)?;
let mut out = pair.secret().to_bytes().to_vec();
out.extend_from_slice(&pair.public().to_bytes());
Ok(out)
})
}
/// Render a derivation failure as a string.
///
/// Kept separate so the error text stays whatever the crate said — a bad
/// mnemonic, an unhardened path and a path that is too deep are different
/// mistakes and a user can only fix the one they made.
fn alloc_error(e: qp_rusty_crystals_hdwallet::HDLatticeError) -> String {
format!("{e}")
}
/// The largest number of wormhole addresses one call derives. Each is a Poseidon
/// hash over an HMAC-SHA512 chain; the bound keeps a caller from asking for a
/// million of them and locking the page.
pub const WORMHOLE_MAX_BATCH: u32 = 1000;
/// Derive consecutive wormhole addresses for one account and branch.
///
/// A wormhole address is not a key. The path yields a 32-byte **secret**, and
/// the address is `poseidon(poseidon(salt ‖ secret))`; funds leave it only
/// through a ZK proof of knowing that secret. So this returns the addresses and
/// nothing else. The secrets and the intermediate `first_hash` stay inside this
/// call and are wiped on drop by the crate's sensitive types.
///
/// Paths are `m/44'/189189189'/<account>'/<change>'/<index>'`, as the mobile
/// wallet derives them: change `0` is the receive branch, `1` the change branch.
///
/// The BIP39 seed is stretched once for the whole batch. Stretching per address
/// is PBKDF2 with 2048 rounds each time, and a gap-limit window is dozens of
/// addresses.
///
/// * mnemonic: BIP39 phrase
/// * password: BIP39 passphrase; empty string for none
/// * account, change, start, count: which addresses; count <= WORMHOLE_MAX_BATCH
///
/// * returned vector is `count` 32-byte account ids, concatenated
#[wasm_bindgen]
pub fn ext_wormhole_addresses(mnemonic: &str, password: &str, account: u32, change: u32, start: u32, count: u32) -> Result<Vec<u8>, JsError> {
wormhole_addresses(mnemonic, password, account, change, start, count).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_wormhole_addresses`], split from its binding for the same
/// reason as [`mldsa_derive`].
pub fn wormhole_addresses(mnemonic: &str, password: &str, account: u32, change: u32, start: u32, count: u32) -> Result<Vec<u8>, String> {
use qp_rusty_crystals_hdwallet::{generate_wormhole_from_seed, mnemonic_to_seed, SensitiveBytes64};
if count > WORMHOLE_MAX_BATCH {
return Err(format!("At most {WORMHOLE_MAX_BATCH} wormhole addresses per call, asked for {count}"));
}
if start.checked_add(count).is_none_or(|end| end > 0x8000_0000) {
return Err(format!("Wormhole address indices must stay below 2^31, asked for {start} + {count}"));
}
let password = if password.is_empty() {
None
} else {
Some(password)
};
let mut seed = SensitiveBytes64::zeroed();
mnemonic_to_seed(mnemonic.to_string(), password, &mut seed).map_err(alloc_error)?;
let mut out = Vec::with_capacity(count as usize * 32);
for index in start..start + count {
let path = format!("m/44'/189189189'/{account}'/{change}'/{index}'");
let pair = generate_wormhole_from_seed(&seed, &path).map_err(alloc_error)?;
out.extend_from_slice(pair.address());
}
Ok(out)
}
/// Salt the chain's nullifier derivation starts from: `NULLIFIER_SALT` in
/// `qp-wormhole-circuit`.
const NULLIFIER_SALT: &str = "~nullif~";
/// The largest number of nullifiers one call computes.
pub const NULLIFIER_MAX_BATCH: u32 = 100_000;
/// Nullifiers for a run of wormhole addresses' deposits, by transfer count.
///
/// A deposit to a wormhole address is spent when its nullifier is in
/// `Wormhole::UsedNullifiers`. The nullifier is
///
/// ```text
/// poseidon2(poseidon2(salt("~nullif~") ‖ secret ‖ transfer_count))
/// ```
///
/// where `transfer_count` is the address's counter when the deposit landed. It
/// needs the address's secret, which is why this takes the recovery phrase and
/// why a nullifier should never be sent anywhere to be checked: exits publish
/// nullifiers, so whoever sees yours can name your exits.
///
/// For addresses `m/44'/189189189'/<account>'/<change>'/<index>'` with index in
/// `start..start + addresses`, and transfer counts `first..first + count` for
/// each. The BIP39 seed is stretched once for the whole run.
///
/// Ported onto `qp-poseidon-core` rather than calling `qp-wormhole-circuit`,
/// which would bring plonky2 into the WASM. The port is pinned to the circuit
/// crate's own `Nullifier::from_preimage` by the tests.
///
/// * returned vector is `addresses * count` 32-byte nullifiers: address by
/// address, and by transfer count within each
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn ext_wormhole_nullifiers(mnemonic: &str, password: &str, account: u32, change: u32, start: u32, addresses: u32, first: u64, count: u32) -> Result<Vec<u8>, JsError> {
wormhole_nullifiers(mnemonic, password, account, change, start, addresses, first, count).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_wormhole_nullifiers`].
#[allow(clippy::too_many_arguments)]
pub fn wormhole_nullifiers(mnemonic: &str, password: &str, account: u32, change: u32, start: u32, addresses: u32, first: u64, count: u32) -> Result<Vec<u8>, String> {
use qp_rusty_crystals_hdwallet::{generate_wormhole_from_seed, mnemonic_to_seed, SensitiveBytes64};
let total = addresses as u64 * count as u64;
if total > NULLIFIER_MAX_BATCH as u64 {
return Err(format!("At most {NULLIFIER_MAX_BATCH} nullifiers per call, asked for {total}"));
}
if start.checked_add(addresses).is_none_or(|end| end > 0x8000_0000) {
return Err(format!("Wormhole address indices must stay below 2^31, asked for {start} + {addresses}"));
}
first.checked_add(count as u64).ok_or("Transfer counts overflow")?;
let password = if password.is_empty() {
None
} else {
Some(password)
};
let mut seed = SensitiveBytes64::zeroed();
mnemonic_to_seed(mnemonic.to_string(), password, &mut seed).map_err(alloc_error)?;
let mut out = Vec::with_capacity(total as usize * 32);
for index in start..start + addresses {
let path = format!("m/44'/189189189'/{account}'/{change}'/{index}'");
let pair = generate_wormhole_from_seed(&seed, &path).map_err(alloc_error)?;
for transfer_count in first..first + count as u64 {
out.extend_from_slice(&nullifier(pair.secret().as_bytes(), transfer_count));
}
}
Ok(out)
}
/// One nullifier from a wormhole secret and a transfer count.
pub fn nullifier(secret: &[u8; 32], transfer_count: u64) -> [u8; 32] {
use qp_poseidon_core::{
hash_twice,
serialization::{bytes_to_digest_lossy, string_to_felts, u64_to_felts},
};
let salt = string_to_felts(NULLIFIER_SALT);
let secret_felts = bytes_to_digest_lossy(secret);
let count_felts = u64_to_felts(transfer_count);
let mut preimage = Vec::with_capacity(salt.len() + secret_felts.len() + count_felts.len());
preimage.extend_from_slice(&salt);
preimage.extend_from_slice(&secret_felts);
preimage.extend_from_slice(&count_felts);
hash_twice(&preimage)
}

View File

@@ -0,0 +1,164 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
use wasm_bindgen::prelude::*;
use crate::scheme::dispatch;
/// Key and signature sizes for a parameter set, as
/// `[public, secret, signature, signature_with_public]`.
///
/// Exported so that nothing on the JS side has to hardcode 1952/4032/3309/5261 or
/// 2592/4896/4627/7219. Those numbers are consensus-critical — the runtime reads a
/// fixed-size array off the wire — and a JS constant that drifted from the crate
/// would mis-frame every byte after the signature while looking entirely healthy.
/// Ask the crate instead.
///
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is four u32 lengths, little-endian, 16 bytes total.
#[wasm_bindgen]
pub fn ext_mldsa_sizes(scheme: u32) -> Vec<u8> {
dispatch!(scheme, dsa, _hd, {
let sizes: [u32; 4] = [
dsa::PUBLICKEYBYTES as u32,
dsa::SECRETKEYBYTES as u32,
dsa::SIGNBYTES as u32,
(dsa::SIGNBYTES + dsa::PUBLICKEYBYTES) as u32,
];
sizes.iter().flat_map(|n| n.to_le_bytes()).collect()
})
}
/// Whether `scheme` names a parameter set this build supports.
///
/// `dispatch!` falls back to ML-DSA-87 for anything unrecognised, which is the
/// right default but a poor way to discover a typo. Callers that accept a scheme
/// from storage or from a user should check here first.
#[wasm_bindgen]
pub fn ext_mldsa_is_scheme(scheme: u32) -> bool {
scheme == crate::scheme::ML_DSA_87 || scheme == crate::scheme::ML_DSA_65
}
/// Generate a keypair from 32 bytes of entropy.
///
/// This is FIPS 204 `ML-DSA.KeyGen_internal` with no Quantus-specific step: the
/// crate expands the seed as `SHAKE256(seed ‖ k ‖ )`, so the parameter set is
/// absorbed into the expansion and the same 32 bytes yield independent keys per
/// scheme. That is why the dev accounts (`[0u8; 32]`, `[1u8; 32]`, `[2u8; 32]`)
/// and HD-derived accounts can share this one entry point.
///
/// * seed: UIntArray with 32 elements
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is the secret key followed by the public key, matching the
/// ordering `ext_ed_from_seed` uses. Split it at the secret length from
/// `ext_mldsa_sizes`.
#[wasm_bindgen]
pub fn ext_mldsa_from_seed(seed: &[u8], scheme: u32) -> Result<Vec<u8>, JsError> {
mldsa_from_seed(seed, scheme).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_mldsa_from_seed`], without the binding layer.
///
/// Split out because `JsError` cannot be constructed on a non-wasm target — it
/// panics with "cannot call wasm-bindgen imported functions on non-wasm targets" —
/// so anything that returns one is untestable by `cargo test`. The error paths are
/// exactly what most needs testing, so the logic lives here and the exported
/// wrapper does nothing but translate.
pub fn mldsa_from_seed(seed: &[u8], scheme: u32) -> Result<Vec<u8>, String> {
if seed.len() != 32 {
return Err("expected a 32 byte seed".into());
}
// `SensitiveBytes32::from` takes the buffer mutably and the crate zeroes it
// after use, so the copy we hand it is destroyed rather than left on the
// stack. Do not replace this with a by-value clone of `seed`.
let mut entropy = [0u8; 32];
entropy.copy_from_slice(seed);
let mut entropy = qp_rusty_crystals_dilithium::SensitiveBytes32::from(&mut entropy);
dispatch!(scheme, dsa, _hd, {
let pair = dsa::Keypair::generate(&mut entropy);
let mut out = pair.secret().to_bytes().to_vec();
out.extend_from_slice(&pair.public().to_bytes());
Ok(out)
})
}
/// Sign a message under a FIPS 204 context.
///
/// Signing is deterministic — no hedging randomness — because that is what the
/// runtime does (`hedge: None`), and a wallet that hedged would produce a
/// different signature each time for the same input, which makes the
/// byte-for-byte agreement tests in quantus/wasm#2 impossible to write.
///
/// `ctx` is domain separation and it is **not** optional in practice: extrinsics
/// on spec >= 148 are verified under `QUANTUS_EXTRINSIC`, earlier specs under the
/// empty context, and a signature made under the wrong one is valid, rejected by
/// the chain, and indistinguishable locally. The caller chooses; this function
/// does not guess.
///
/// * secret: UIntArray, secret-key length for the scheme
/// * public: UIntArray, public-key length for the scheme
/// * message: arbitrary length UIntArray
/// * ctx: UIntArray, at most 255 elements; empty for no context
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
///
/// * returned vector is the signature alone. The runtime's wire format is
/// `signature ‖ public`; concatenating is the caller's job because only the
/// caller knows whether it wants the wire form or the bare signature.
#[wasm_bindgen]
pub fn ext_mldsa_sign(secret: &[u8], public: &[u8], message: &[u8], ctx: &[u8], scheme: u32) -> Result<Vec<u8>, JsError> {
mldsa_sign(secret, public, message, ctx, scheme).map_err(|e| JsError::new(&e))
}
/// The body of [`ext_mldsa_sign`]. See [`mldsa_from_seed`] for why this is split.
pub fn mldsa_sign(secret: &[u8], public: &[u8], message: &[u8], ctx: &[u8], scheme: u32) -> Result<Vec<u8>, String> {
if ctx.len() > 255 {
return Err("context must be at most 255 bytes".into());
}
dispatch!(scheme, dsa, _hd, {
// `from_parts` re-derives the public key from the secret and rejects a
// mismatch, so a corrupted or mixed-up pair fails here rather than
// producing a signature that silently will not verify.
let secret = dsa::SecretKey::from_bytes(secret)
.map_err(|_| "invalid secret key".to_string())?;
let public = dsa::PublicKey::from_bytes(public)
.map_err(|_| "invalid public key".to_string())?;
let pair = dsa::Keypair::from_parts(secret, public)
.map_err(|_| "secret and public key do not correspond".to_string())?;
pair
.sign(message, Some(ctx), None)
.map(|sig| sig.to_vec())
.map_err(|_| "signing failed".to_string())
})
}
/// Verify a signature against a message and public key under a context.
///
/// * public: UIntArray, public-key length for the scheme
/// * message: arbitrary length UIntArray
/// * signature: UIntArray, signature length for the scheme
/// * ctx: UIntArray, at most 255 elements; empty for no context
/// * scheme: 0 for ML-DSA-87, 1 for ML-DSA-65
#[wasm_bindgen]
pub fn ext_mldsa_verify(public: &[u8], message: &[u8], signature: &[u8], ctx: &[u8], scheme: u32) -> bool {
if ctx.len() > 255 {
return false;
}
dispatch!(scheme, dsa, _hd, {
match dsa::PublicKey::from_bytes(public) {
Ok(public) => public.verify(message, signature, Some(ctx)),
Err(_) => false
}
})
}

View File

@@ -0,0 +1,22 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
use wasm_bindgen::prelude::*;
/// Poseidon2-over-Goldilocks hash of arbitrary bytes.
///
/// This is the account-id derivation. On Substrate an `AccountId32` *is* the
/// public key; on Quantus it is `hash_bytes(public_key)`, which is why a Quantus
/// signature has to carry its public key along — the address cannot give it back.
///
/// `qp_poseidon_core::hash_bytes` is `IdentifyAccount for DilithiumSigner` in the
/// runtime, so this is the same function the chain uses to decide who signed
/// something, reached through the same crate rather than a port of it.
///
/// * data: arbitrary length UIntArray
///
/// * returned vector is 32 bytes.
#[wasm_bindgen]
pub fn ext_poseidon_hash(data: &[u8]) -> Vec<u8> {
qp_poseidon_core::hash_bytes(data).to_vec()
}

View File

@@ -0,0 +1,47 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Which ML-DSA parameter set a call refers to.
//!
//! The selector is the chain's own signature-enum variant index, not a private
//! numbering: `DilithiumSignatureScheme::Dilithium87` is variant 0 and
//! `Dilithium65` is variant 1, and that byte is what a signed extrinsic carries
//! on the wire. Reusing it here means the number threaded through this API is
//! the number that ends up in the extrinsic, so there is no mapping table to get
//! backwards between here and `TYPE_PREFIX` in the keyring.
/// ML-DSA-87 — the legacy scheme, used by accounts created before the scheme was
/// recorded, and by the dev-genesis accounts.
pub const ML_DSA_87: u32 = 0;
/// ML-DSA-65 — what new accounts use.
pub const ML_DSA_65: u32 = 1;
/// Runs `$body` with `$dsa` and `$hd` bound to the parameter-set modules named by
/// `$scheme`.
///
/// An unrecognised selector resolves to ML-DSA-87 rather than panicking: 0 is the
/// legacy scheme and the safest thing an out-of-range value can mean. Callers
/// that care validate first — see `ext_mldsa_is_scheme`.
macro_rules! dispatch {
($scheme:expr, $dsa:ident, $hd:ident, $body:block) => {
match $scheme {
$crate::scheme::ML_DSA_65 => {
#[allow(unused_imports)]
use qp_rusty_crystals_dilithium::ml_dsa_65 as $dsa;
#[allow(unused_imports)]
use qp_rusty_crystals_hdwallet::ml_dsa_65 as $hd;
$body
},
_ => {
#[allow(unused_imports)]
use qp_rusty_crystals_dilithium::ml_dsa_87 as $dsa;
#[allow(unused_imports)]
use qp_rusty_crystals_hdwallet::ml_dsa_87 as $hd;
$body
},
}
};
}
pub(crate) use dispatch;

View File

@@ -0,0 +1,303 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//! Conformance against the chain, not against ourselves.
//!
//! Every expected value here came from an independent implementation — the
//! `quantus` CLI 2.2.2 — and not from running this crate and writing down what
//! it said. A test that pins our own output would keep passing through exactly
//! the kind of drift these exist to catch.
//!
//! Addresses are pinned as raw account-id bytes rather than SS58 strings so this
//! file needs no base58 dependency; the SS58 rendering at prefix 189 is pinned on
//! the JS side, which is where it is actually used.
use crate::{hdwallet::{mldsa_derive, wormhole_addresses}, mldsa::*, poseidon::*, scheme::*};
/// FIPS 204 context for on-chain extrinsic signatures, spec >= 148.
/// `chain:primitives/dilithium-crypto/src/signing_context.rs`.
const EXTRINSIC: &[u8] = b"QUANTUS_EXTRINSIC";
fn account_of(seed_fill: u8, scheme: u32) -> Vec<u8> {
let pair = mldsa_from_seed(&[seed_fill; 32], scheme).expect("keygen");
let secret_len = secret_len(scheme);
ext_poseidon_hash(&pair[secret_len..])
}
fn secret_len(scheme: u32) -> usize {
let sizes = ext_mldsa_sizes(scheme);
u32::from_le_bytes(sizes[4..8].try_into().unwrap()) as usize
}
#[test]
fn sizes_match_the_parameter_sets() {
// [public, secret, signature, signature_with_public]
let s87: Vec<u32> = ext_mldsa_sizes(ML_DSA_87).chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect();
let s65: Vec<u32> = ext_mldsa_sizes(ML_DSA_65).chunks(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect();
assert_eq!(s87, vec![2592, 4896, 4627, 7219]);
assert_eq!(s65, vec![1952, 4032, 3309, 5261]);
}
/// The dev accounts endowed at genesis, from `chain:primitives/dilithium-crypto/src/pair.rs`.
///
/// Expected values are the account ids behind the SS58 addresses that
/// `quantus developer create-test-wallets` prints:
///
/// ```text
/// crystal_alice qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7
/// dilithium_bob qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y
/// crystal_charlie qzntBpmqHZF1jxC8KJKpuxcYuHST892jyXBqRctpAxd1WQ9BL
/// ```
///
/// They are ML-DSA-87 and come from the seed directly with no HD derivation, so
/// this pins the legacy scheme and the raw-seed path in one go.
#[test]
fn dev_account_ids_match_the_cli() {
assert_eq!(
hex(&account_of(0, ML_DSA_87)),
"1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333",
"crystal_alice"
);
assert_eq!(
hex(&account_of(1, ML_DSA_87)),
"300bb607ba60e89461d2f9005668231ceb30237b33db53a614164b8590965519",
"dilithium_bob"
);
assert_eq!(
hex(&account_of(2, ML_DSA_87)),
"97bc5f2db1efa23fb71f6737fcb26e41e448aff07447011369df81ce43555465",
"crystal_charlie"
);
}
/// The same 32 bytes must give different keys per parameter set — FIPS 204
/// absorbs `(k, )` into the seed expansion. If these ever collided it would mean
/// the scheme selector was being ignored somewhere.
#[test]
fn schemes_are_independent_for_the_same_seed() {
assert_ne!(account_of(0, ML_DSA_87), account_of(0, ML_DSA_65));
}
#[test]
fn signs_and_verifies_under_the_extrinsic_context() {
for scheme in [ML_DSA_87, ML_DSA_65] {
let pair = mldsa_from_seed(&[7u8; 32], scheme).expect("keygen");
let (secret, public) = pair.split_at(secret_len(scheme));
let message = b"the payload the chain will see";
let signature = mldsa_sign(secret, public, message, EXTRINSIC, scheme).expect("sign");
assert!(ext_mldsa_verify(public, message, &signature, EXTRINSIC, scheme));
// The whole point of the context. A signature made for an extrinsic must
// not verify as anything else, and vice versa — this is what makes the
// spec-148 boundary detectable instead of a silent chain rejection.
assert!(!ext_mldsa_verify(public, message, &signature, b"", scheme));
assert!(!ext_mldsa_verify(public, b"tampered", &signature, EXTRINSIC, scheme));
}
}
/// The runtime signs with `hedge: None`. If this crate ever introduced hedging
/// randomness the golden vectors in quantus/wasm#2 would become unwritable, and
/// nothing else would notice.
#[test]
fn signing_is_deterministic() {
let pair = mldsa_from_seed(&[9u8; 32], ML_DSA_65).expect("keygen");
let (secret, public) = pair.split_at(secret_len(ML_DSA_65));
let once = mldsa_sign(secret, public, b"m", EXTRINSIC, ML_DSA_65).expect("sign");
let twice = mldsa_sign(secret, public, b"m", EXTRINSIC, ML_DSA_65).expect("sign");
assert_eq!(once, twice);
}
#[test]
fn rejects_bad_input() {
assert!(mldsa_from_seed(&[0u8; 16], ML_DSA_65).is_err(), "short seed");
let pair = mldsa_from_seed(&[1u8; 32], ML_DSA_65).expect("keygen");
let (secret, public) = pair.split_at(secret_len(ML_DSA_65));
assert!(mldsa_sign(secret, public, b"m", &[0u8; 256], ML_DSA_65).is_err(), "context > 255");
assert!(mldsa_sign(&secret[1..], public, b"m", EXTRINSIC, ML_DSA_65).is_err(), "truncated secret");
// A pair whose halves do not correspond must fail at import rather than
// produce a signature that silently will not verify.
let other = mldsa_from_seed(&[2u8; 32], ML_DSA_65).expect("keygen");
let other_public = &other[secret_len(ML_DSA_65)..];
assert!(mldsa_sign(secret, other_public, b"m", EXTRINSIC, ML_DSA_65).is_err(), "mismatched pair");
}
/// The well-known Substrate development phrase. Public by design — it is in
/// polkadot-sdk, in polkadot-js, and in every tutorial — so pinning it here
/// commits no secret. Any account it derives is assumed compromised.
const DEV_PHRASE: &str = "bottom drive obey lake curtain smoke basket hold race lonely fit walk";
/// HD derivation at the Quantus BIP44 path, cross-checked against
/// `quantus wallet import --mnemonic-file <DEV_PHRASE> --scheme <s>`, which
/// printed:
///
/// ```text
/// ml-dsa-65 m/44'/189189'/0'/0'/1' qzq29m9WvneDAeXbtgueKCREtNe1rVVs6bXSMLmjr6shqvwq6
/// ml-dsa-87 m/44'/189189'/0'/0'/0' qzjrYTUnnE5NduTZKxe9dESCMTZg7nTueKM3bwhnkRdD1iYV4
/// ```
///
/// This pins the whole derivation chain at once: BIP39 to a 64-byte seed (*not*
/// Substrate's `mnemonicToMiniSecret`), the HMAC-SHA512 walk keyed with
/// "Dilithium seed", the trailing hardened index carrying the scheme, and the
/// Poseidon2 account-id hash on the end.
#[test]
fn hd_derivation_matches_the_cli() {
let cases = [
(ML_DSA_65, "m/44'/189189'/0'/0'/1'", "f647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00"),
(ML_DSA_87, "m/44'/189189'/0'/0'/0'", "11c6a314e003cdee3dc51cf6569175360141578d054c38d7a70840a65cc0e990")
];
for (scheme, path, expected) in cases {
let pair = mldsa_derive(DEV_PHRASE, "", path, scheme).expect("derive");
let account = ext_poseidon_hash(&pair[secret_len(scheme)..]);
assert_eq!(hex(&account), expected, "{path}");
}
}
/// Lattice keys have no public derivability, so the crate rejects unhardened
/// paths outright rather than inventing a meaning for them. A wallet that
/// silently hardened a soft path would put funds at an address the user did not
/// ask for.
#[test]
fn derivation_rejects_bad_input() {
assert!(mldsa_derive(DEV_PHRASE, "", "m/44'/189189'/0'/0'/1", ML_DSA_65).is_err(), "unhardened");
assert!(mldsa_derive("not a mnemonic at all", "", "m/44'/189189'/0'/0'/1'", ML_DSA_65).is_err(), "bad phrase");
assert!(mldsa_derive(DEV_PHRASE, "", "not a path", ML_DSA_65).is_err(), "bad path");
}
/// A BIP39 passphrase must change the result, and an empty string must mean
/// "no passphrase" rather than "a passphrase that happens to be empty" — the
/// two are the same in BIP39 but the crate's signature distinguishes them, and
/// normalising in the wrong direction would silently fork every address.
#[test]
fn passphrase_is_honoured_and_empty_means_none() {
let path = "m/44'/189189'/0'/0'/1'";
let none = mldsa_derive(DEV_PHRASE, "", path, ML_DSA_65).expect("derive");
let with = mldsa_derive(DEV_PHRASE, "hunter2", path, ML_DSA_65).expect("derive");
assert_ne!(none, with);
}
#[test]
fn scheme_validation() {
assert!(ext_mldsa_is_scheme(ML_DSA_87));
assert!(ext_mldsa_is_scheme(ML_DSA_65));
assert!(!ext_mldsa_is_scheme(2));
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// The chain node's own wormhole vector, `node/src/tests/data/quantus_key_test_data.rs`:
/// `TEST_MNEMONIC` at `m/44'/189189189'/0'/0'/0'` is `TEST_WORMHOLE_ADDRESS`,
/// `qzpWh4AEtsgCyEbv4WBgFWnB9bcdF2L2jVDuyjXP9mSTyBaeU`. The mobile wallet's SDK
/// pins the same pair (`generate_keys_test.dart`), so three implementations agree.
#[test]
fn wormhole_address_matches_the_node() {
const MNEMONIC: &str = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven";
let batch = wormhole_addresses(MNEMONIC, "", 0, 0, 0, 3).expect("derive");
assert_eq!(batch.len(), 96);
assert_eq!(hex(&batch[..32]), "dfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e");
// A batch is the same as asking for each index on its own: the seed is
// stretched once, but every address still gets its own path.
for i in 0..3u32 {
let one = wormhole_addresses(MNEMONIC, "", 0, 0, i, 1).expect("derive one");
assert_eq!(one, batch[(i as usize * 32)..(i as usize + 1) * 32].to_vec());
}
// Receive and change branches, and account indices, are different addresses.
assert_ne!(wormhole_addresses(MNEMONIC, "", 0, 1, 0, 1).unwrap(), batch[..32].to_vec());
assert_ne!(wormhole_addresses(MNEMONIC, "", 1, 0, 0, 1).unwrap(), batch[..32].to_vec());
}
#[test]
fn wormhole_addresses_refuse_unbounded_requests() {
const MNEMONIC: &str = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven";
assert!(wormhole_addresses(MNEMONIC, "", 0, 0, 0, 1001).is_err());
assert!(wormhole_addresses(MNEMONIC, "", 0, 0, 0x7fff_ffff, 2).is_err());
assert!(wormhole_addresses("not a mnemonic", "", 0, 0, 0, 1).is_err());
}
/// The port of the nullifier agrees with the chain's circuit crate.
///
/// `qp_wormhole_circuit::nullifier::Nullifier::from_preimage` is what the proof
/// commits to and what `Wormhole::UsedNullifiers` records, so it is the
/// reference. Secrets span the edge the lossy 8-bytes-per-felt encoding cares
/// about (limbs at and above the Goldilocks prime), and transfer counts span
/// both 32-bit limbs.
#[test]
fn nullifier_matches_the_circuit() {
use crate::hdwallet::nullifier;
use qp_wormhole_circuit::nullifier::Nullifier;
use qp_zk_circuits_common::utils::{digest_to_bytes, BytesDigest};
let secrets: Vec<[u8; 32]> = vec![
[0u8; 32],
[0xff; 32],
core::array::from_fn(|i| i as u8),
core::array::from_fn(|i| (i as u8).wrapping_mul(97).wrapping_add(13)),
// every limb is the Goldilocks prime 2^64 - 2^32 + 1, big-endian
[0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x01].repeat(4).try_into().unwrap(),
];
let counts = [0u64, 1, 41_683, u32::MAX as u64, 1u64 << 32, u64::MAX];
let mut compared = 0;
for secret in &secrets {
for &count in &counts {
let digest = BytesDigest::try_from(*secret);
// The circuit's BytesDigest refuses a non-canonical limb; where it
// does, the chain can never produce that secret's nullifier either.
let Ok(digest) = digest else { continue };
let expected = digest_to_bytes(Nullifier::from_preimage(digest, count).hash);
assert_eq!(hex(&nullifier(secret, count)), hex(expected.as_ref()), "secret {} count {count}", hex(secret));
compared += 1;
}
}
// Skipping is for the non-canonical edge only; a test that compared
// nothing would pass just as well.
assert!(compared >= 18, "only {compared} cases compared");
}
#[test]
fn wormhole_nullifiers_follow_each_address_secret() {
use crate::hdwallet::{nullifier, wormhole_nullifiers};
use qp_rusty_crystals_hdwallet::derive_wormhole_from_mnemonic;
const MNEMONIC: &str = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven";
// addresses 2 and 3 on the change branch, transfer counts 5..8
let batch = wormhole_nullifiers(MNEMONIC, "", 0, 1, 2, 2, 5, 3).unwrap();
assert_eq!(batch.len(), 2 * 3 * 32);
for (a, index) in [2u32, 3].into_iter().enumerate() {
let pair = derive_wormhole_from_mnemonic(MNEMONIC, None, &format!("m/44'/189189189'/0'/1'/{index}'")).unwrap();
for c in 0..3usize {
let at = (a * 3 + c) * 32;
assert_eq!(batch[at..at + 32].to_vec(), nullifier(pair.secret().as_bytes(), 5 + c as u64).to_vec(), "address {index} count {}", 5 + c);
}
}
assert!(wormhole_nullifiers(MNEMONIC, "", 0, 0, 0, 40, 0, 2_501).is_err());
assert!(wormhole_nullifiers(MNEMONIC, "", 0, 0, 0, 1, u64::MAX, 2).is_err());
assert!(wormhole_nullifiers(MNEMONIC, "", 0, 0, 0x7fff_ffff, 2, 0, 1).is_err());
}

View File

@@ -0,0 +1,46 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
/**
* Which ML-DSA parameter set a call refers to.
*
* These are the chain's own `DilithiumSignatureScheme` variant indices, not a
* private numbering — the value here is the byte a signed extrinsic carries on
* the wire. Keeping them identical means the keyring's `TYPE_PREFIX` is the
* identity function on a scheme, with no table to get backwards.
*/
export enum Scheme {
/** ML-DSA-87. Legacy: accounts created before the scheme was recorded, and the dev-genesis accounts. */
MlDsa87 = 0,
/** ML-DSA-65. What new accounts use. */
MlDsa65 = 1
}
/**
* The name each scheme is stored under, matching `quantus-cli` and the mobile
* wallet so a wallet exported from one tool imports into another.
*/
export const SCHEME_NAME: Record<Scheme, string> = {
[Scheme.MlDsa87]: 'ml-dsa-87',
[Scheme.MlDsa65]: 'ml-dsa-65'
};
/**
* FIPS 204 context for on-chain extrinsic signatures.
*
* Only from spec 148 onward — earlier runtimes verify under the empty context,
* and a signature made under the wrong one is valid, rejected by the chain, and
* indistinguishable locally. Callers pass the spec version and get the right
* answer from {@link contextForSpec}; nothing here guesses.
*/
export const EXTRINSIC_CONTEXT = new TextEncoder().encode('QUANTUS_EXTRINSIC');
/** First spec version that verifies extrinsics under {@link EXTRINSIC_CONTEXT}. */
export const EXTRINSIC_MIN_SPEC = 148;
/** The signing context a runtime at `specVersion` expects. */
export function contextForSpec (specVersion: number): Uint8Array {
return specVersion >= EXTRINSIC_MIN_SPEC
? EXTRINSIC_CONTEXT
: new Uint8Array();
}

View File

@@ -0,0 +1,76 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//
// Consumes the *built* package exactly as quantus/common will — a plain import of
// build output, nothing reaching into src or poking the wasm by hand. Run after
// ./scripts/build-quantus.sh.
//
// This exists because the unit tests in src/rs/tests.rs cannot catch packaging
// faults. A wasm that is valid before `wasm-opt` and broken after it passes every
// cargo test and fails here, which is exactly how binaryen 105's mishandling of
// externref tables was found.
import {
accountFromPublicKey, contextForSpec, EXTRINSIC_MIN_SPEC, initWasm,
isReady, keypairFromMnemonic, keypairFromSeed, Scheme, SCHEME_NAME,
sign, signatureWithPublicKey, sizes, verify, WormholeBranch, wormholeAddresses, wormholeNullifiers
} from '@quantus/crypto';
let fail = 0;
const eq = (l, g, w) => { const ok = String(g) === String(w); if (!ok) fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${l}`); if (!ok) console.log(` got ${g}\n want ${w}`); };
eq('initWasm() returns no error', initWasm(), 'null');
eq('isReady()', isReady(), true);
const s65 = sizes(Scheme.MlDsa65);
eq('ML-DSA-65 sizes', JSON.stringify(s65), '{"publicKey":1952,"secretKey":4032,"signature":3309,"signatureWithPublicKey":5261}');
eq('scheme name', SCHEME_NAME[Scheme.MlDsa65], 'ml-dsa-65');
eq('variant byte is the enum value', Scheme.MlDsa87, 0);
// crystal_alice, via the public API only
const pair87 = keypairFromSeed(new Uint8Array(32), Scheme.MlDsa87);
eq('crystal_alice account id',
Buffer.from(accountFromPublicKey(pair87.publicKey)).toString('hex'),
'1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333');
// HD derivation, dev phrase, ML-DSA-65 default path
const DEV = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
const hd = keypairFromMnemonic(DEV, '', "m/44'/189189'/0'/0'/1'", Scheme.MlDsa65);
eq('dev phrase account id (ML-DSA-65)',
Buffer.from(accountFromPublicKey(hd.publicKey)).toString('hex'),
'f647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00');
// the signing-context boundary
const msg = new TextEncoder().encode('extrinsic payload');
const ctx = contextForSpec(EXTRINSIC_MIN_SPEC);
const sig = sign(msg, hd, ctx, Scheme.MlDsa65);
eq('signature length', sig.length, s65.signature);
eq('verifies at spec 148', verify(msg, sig, hd.publicKey, ctx, Scheme.MlDsa65), true);
eq('does NOT verify at spec 147', verify(msg, sig, hd.publicKey, contextForSpec(147), Scheme.MlDsa65), false);
eq('contextForSpec(147) is empty', contextForSpec(147).length, 0);
// the wire form
eq('sig || pk length', signatureWithPublicKey(sig, hd.publicKey).length, s65.signatureWithPublicKey);
// wormhole: the chain node's TEST_WORMHOLE_ADDRESS (qzpWh4AEtsgCyEbv4WBgFWnB9bcdF2L2jVDuyjXP9mSTyBaeU)
const NODE_PHRASE = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven';
const wh = wormholeAddresses(NODE_PHRASE, '', 0, WormholeBranch.Receive, 0, 2);
eq('wormhole address count', wh.length, 2);
eq('wormhole receive 0 is the node test address',
Buffer.from(wh[0]).toString('hex'),
'dfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e');
eq('change branch differs', Buffer.from(wormholeAddresses(NODE_PHRASE, '', 0, WormholeBranch.Change, 0, 1)[0]).toString('hex') !== Buffer.from(wh[0]).toString('hex'), true);
// nullifiers: shape, determinism, and the cost of a wallet account's precompute
const n = wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Receive, 0, 2, 7, 3);
eq('nullifier shape', `${n.length}x${n[0].length}x${n[0][0].length}`, '2x3x32');
eq('nullifiers differ by count', Buffer.from(n[0][0]).equals(Buffer.from(n[0][1])), false);
eq('nullifiers differ by address', Buffer.from(n[0][0]).equals(Buffer.from(n[1][0])), false);
eq('a sub-range agrees', Buffer.from(wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Receive, 1, 1, 8, 1)[0][0]).toString('hex'), Buffer.from(n[1][1]).toString('hex'));
const t0 = performance.now();
wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Receive, 0, 20, 0, 256);
wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Change, 0, 20, 0, 256);
console.log(` 40 addresses x 256 counts: ${Math.round(performance.now() - t0)} ms`);
process.exit(fail ? 1 : 0);

View File

@@ -0,0 +1 @@
vendor/

View File

@@ -0,0 +1,69 @@
# Browser probes
`cargo test` and the consumer test both run in node, and node is neither a
browser nor a service worker. These two probes cover what node cannot: does the
WASM instantiate with **no DOM**, under the **extension's own CSP**, and what does
a cold start cost.
Build first with `./scripts/build-quantus.sh`, stage `vendor/` (below), then run
`node serve.mjs` from this directory.
## `index.html` + `worker.js` — automated
A module Worker, served with the exact `extension_pages` CSP from both extension
manifests:
```
script-src 'self' 'wasm-unsafe-eval'; object-src 'self'
```
A module Worker has no `window` and no `document`, which is the property that
matters — an MV3 service worker has neither either. Result on Firefox:
```
hasDOM: false hasWindow: false
initWasm: ok (9.0 ms cold)
keygen: 1.0 ms
account: matches quantus-cli
sign: 3.0 ms (4627 bytes)
verify: 1.0 ms ok
ctx sep: ok (rejected under spec-147 ctx)
```
9 ms to base64-decode 146 KB, zlib-inflate it to 234 KB and instantiate. That is
the number the MV3 lifetime question turns on: a service worker killed between
messages pays this on every wake, and 9 ms is not a problem. Those are ML-DSA-87
timings — the larger parameter set — so ML-DSA-65 is cheaper still.
The CSP is genuinely enforced here, not merely declared: an earlier version of
this page used an inline `<script>` and Firefox blocked it, which is why
`main.js` exists as a separate file.
## `manifest.json` + `sw.js` — manual
The real thing: an MV3 extension whose background service worker imports the
package at module scope and signs once. Loading an unpacked extension needs an OS
file dialog, so this cannot be driven from a script — load it by hand via
`chrome://extensions` → Developer mode → Load unpacked, then click the toolbar
icon.
It covers what the Worker probe cannot: `chrome.runtime` messaging, and the
actual MV3 kill-and-restart lifecycle rather than a stand-in for it.
## Staging `vendor/`
Both probes import from `./vendor/`, which is not checked in. Populate it from a
build:
```sh
mkdir -p vendor
cp -r ../../build vendor/quantus-crypto
cp -r ../../../wasm-util/build vendor/wasm-util
sed -i "s|from '@polkadot/wasm-util/base64'|from '../wasm-util/base64.js'|; \
s|from '@polkadot/wasm-util/fflate'|from '../wasm-util/fflate.js'|" \
vendor/quantus-crypto/init.js
```
The rewrite is needed because a browser cannot resolve bare specifiers. A real
extension build does this with a bundler; here it is one `sed` rather than a
build step, because the probe exists to test the WASM, not the bundler.

View File

@@ -0,0 +1,5 @@
<!doctype html><meta charset="utf-8"><title>quantus-crypto worker probe</title>
<body style="font:13px ui-monospace,monospace;padding:16px;background:#111;color:#ddd">
<h3 style="font:600 14px system-ui">@quantus/crypto in a module Worker under the extension CSP</h3>
<pre id="out">running…</pre>
<script type="module" src="./main.js"></script>

View File

@@ -0,0 +1,3 @@
const w = new Worker('./worker.js', { type: 'module' });
w.onmessage = (e) => { document.getElementById('out').textContent = e.data; };
w.onerror = (e) => { document.getElementById('out').textContent = 'worker error: ' + (e.message || 'see console'); };

View File

@@ -0,0 +1,11 @@
{
"manifest_version": 3,
"name": "quantus-crypto MV3 probe",
"version": "0.0.1",
"description": "Loads @quantus/crypto in an MV3 service worker and signs once.",
"background": { "service_worker": "sw.js", "type": "module" },
"action": { "default_title": "probe", "default_popup": "popup.html" },
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}

View File

@@ -0,0 +1,4 @@
<!doctype html><meta charset="utf-8"><title>quantus-crypto probe</title>
<body style="font:13px system-ui;padding:12px;min-width:380px">
<pre id="out">running…</pre>
<script type="module" src="popup.js"></script>

View File

@@ -0,0 +1,4 @@
const out = document.getElementById('out');
chrome.runtime.sendMessage({ probe: true }, (r) => {
out.textContent = r ? r.text : `no response: ${chrome.runtime.lastError?.message}`;
});

View File

@@ -0,0 +1,15 @@
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
const types = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.wasm': 'application/wasm' };
http.createServer((req, res) => {
const p = path.join(process.cwd(), decodeURIComponent(req.url.split('?')[0]));
const f = fs.existsSync(p) && fs.statSync(p).isDirectory() ? path.join(p, 'index.html') : p;
if (!fs.existsSync(f)) { res.writeHead(404); return res.end('nope'); }
res.writeHead(200, {
'Content-Type': types[path.extname(f)] || 'application/octet-stream',
// exactly the extension_pages CSP from both manifests
'Content-Security-Policy': "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
});
fs.createReadStream(f).pipe(res);
}).listen(8731, () => console.log('probe on http://127.0.0.1:8731'));

View File

@@ -0,0 +1,52 @@
// MV3 service worker. Imports @quantus/crypto at module scope — i.e. on every
// cold start, which is what actually needs proving: the worker is killed between
// messages and must re-instantiate a 146 KB inlined base64 wasm each time.
import {
accountFromPublicKey, contextForSpec, initWasm, keypairFromSeed,
Scheme, sign, verify
} from './vendor/quantus-crypto/index.js';
const hex = (u8) => [...u8].map((b) => b.toString(16).padStart(2, '0')).join('');
function probe () {
const lines = [];
const t0 = performance.now();
const err = initWasm();
const tInit = performance.now() - t0;
lines.push(`context: ${typeof window === 'undefined' ? 'service worker (no DOM)' : 'page'}`);
lines.push(`initWasm: ${err === null ? 'ok' : 'FAILED — ' + err} (${tInit.toFixed(1)} ms)`);
if (err) return lines.join('\n');
const t1 = performance.now();
const pair = keypairFromSeed(new Uint8Array(32), Scheme.MlDsa87);
const tKeygen = performance.now() - t1;
const account = hex(accountFromPublicKey(pair.publicKey));
const expected = '1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333';
lines.push(`keygen: ${tKeygen.toFixed(1)} ms`);
lines.push(`account: ${account.slice(0, 24)}${account === expected ? 'matches quantus-cli' : 'MISMATCH'}`);
const msg = new TextEncoder().encode('extrinsic payload');
const ctx = contextForSpec(148);
const t2 = performance.now();
const sig = sign(msg, pair, ctx, Scheme.MlDsa87);
const tSign = performance.now() - t2;
lines.push(`sign: ${tSign.toFixed(1)} ms (${sig.length} bytes)`);
lines.push(`verify: ${verify(msg, sig, pair.publicKey, ctx, Scheme.MlDsa87) ? 'ok' : 'FAILED'}`);
lines.push(`ctx sep: ${verify(msg, sig, pair.publicKey, contextForSpec(147), Scheme.MlDsa87) ? 'FAILED (verified under wrong ctx)' : 'ok (rejected under spec 147 ctx)'}`);
return lines.join('\n');
}
chrome.runtime.onMessage.addListener((_m, _s, respond) => {
try {
respond({ text: probe() });
} catch (e) {
respond({ text: `threw: ${e && e.message ? e.message : e}\n${e && e.stack ? e.stack : ''}` });
}
return true;
});

View File

@@ -0,0 +1,32 @@
// A module Worker: no DOM, no window, same CSP as the extension pages.
import { accountFromPublicKey, contextForSpec, initWasm, keypairFromSeed, Scheme, sign, verify } from './vendor/quantus-crypto/index.js';
const hex = (u8) => [...u8].map((b) => b.toString(16).padStart(2, '0')).join('');
const lines = [];
const t0 = performance.now();
const err = initWasm();
const tInit = performance.now() - t0;
lines.push(`hasDOM: ${typeof document !== 'undefined'} hasWindow: ${typeof window !== 'undefined'}`);
lines.push(`initWasm: ${err === null ? 'ok' : 'FAILED - ' + err} (${tInit.toFixed(1)} ms cold)`);
if (err === null) {
const t1 = performance.now();
const pair = keypairFromSeed(new Uint8Array(32), Scheme.MlDsa87);
const tKeygen = performance.now() - t1;
const account = hex(accountFromPublicKey(pair.publicKey));
lines.push(`keygen: ${tKeygen.toFixed(1)} ms`);
lines.push(`account: ${account === '1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333' ? 'matches quantus-cli' : 'MISMATCH ' + account}`);
const msg = new TextEncoder().encode('extrinsic payload');
const ctx = contextForSpec(148);
const t2 = performance.now();
const sig = sign(msg, pair, ctx, Scheme.MlDsa87);
const tSign = performance.now() - t2;
const t3 = performance.now();
const ok = verify(msg, sig, pair.publicKey, ctx, Scheme.MlDsa87);
const tVerify = performance.now() - t3;
lines.push(`sign: ${tSign.toFixed(1)} ms (${sig.length} bytes)`);
lines.push(`verify: ${tVerify.toFixed(1)} ms ${ok ? 'ok' : 'FAILED'}`);
lines.push(`ctx sep: ${verify(msg, sig, pair.publicKey, contextForSpec(147), Scheme.MlDsa87) ? 'FAILED' : 'ok (rejected under spec-147 ctx)'}`);
}
postMessage(lines.join('\n'));

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

@@ -18,15 +18,15 @@
"./packageDetect.cjs"
],
"type": "module",
"version": "7.5.3",
"version": "7.5.4",
"main": "index.js",
"dependencies": {
"@polkadot/wasm-util": "7.5.3",
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
},
"devDependencies": {
"@polkadot/util": "^13.5.8",
"@polkadot/x-randomvalues": "^13.5.8"
"@polkadot/util": "^14.0.1",
"@polkadot/x-randomvalues": "^14.0.1"
},
"peerDependencies": {
"@polkadot/util": "*",

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2019-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
// A number of functions are "unsafe" and purposefully so - it is

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2019-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './bridge.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2019-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
import './packageDetect.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2019-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { InitFn, InitPromise, InitResult, WasmBaseInstance, WasmImports } from './types.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2019-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './index.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2017-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2017-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev

View File

@@ -1,6 +1,6 @@
// Copyright 2017-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2017-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev
export const packageInfo = { name: '@polkadot/wasm-bridge', path: 'auto', type: 'auto', version: '7.5.3' };
export const packageInfo = { name: '@polkadot/wasm-bridge', path: 'auto', type: 'auto', version: '7.5.4' };

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2019-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Use non-strong types instead of WasmImports which may not

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-bridge authors & contributors
// Copyright 2019-2026 @polkadot/wasm-bridge authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BridgeBase, WasmBaseInstance } from './types.js';

View File

@@ -18,13 +18,13 @@
"./packageDetect.cjs"
],
"type": "module",
"version": "7.5.3",
"version": "7.5.4",
"main": "index.js",
"dependencies": {
"tslib": "^2.7.0"
},
"devDependencies": {
"@polkadot/util": "^13.5.8"
"@polkadot/util": "^14.0.1"
},
"peerDependencies": {
"@polkadot/util": "*"

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
export { asmJsInit } from './cjs/data.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
const data = require('../data.js');

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const asmJsInit: null;

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
const asmJsInit = null;

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const asmJsInit: null;

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
export const asmJsInit = null;

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
import './packageDetect.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './index.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2017-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2017-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev

View File

@@ -1,6 +1,6 @@
// Copyright 2017-2025 @polkadot/wasm-crypto-asmjs authors & contributors
// Copyright 2017-2026 @polkadot/wasm-crypto-asmjs authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev
export const packageInfo = { name: '@polkadot/wasm-crypto-asmjs', path: 'auto', type: 'auto', version: '7.5.3' };
export const packageInfo = { name: '@polkadot/wasm-crypto-asmjs', path: 'auto', type: 'auto', version: '7.5.4' };

View File

@@ -18,20 +18,20 @@
"./packageDetect.cjs"
],
"type": "module",
"version": "7.5.3",
"version": "7.5.4",
"browser": "wasm.js",
"main": "wasm.js",
"react-native": "asm.js",
"dependencies": {
"@polkadot/wasm-bridge": "7.5.3",
"@polkadot/wasm-crypto-asmjs": "7.5.3",
"@polkadot/wasm-crypto-wasm": "7.5.3",
"@polkadot/wasm-util": "7.5.3",
"@polkadot/wasm-bridge": "7.5.4",
"@polkadot/wasm-crypto-asmjs": "7.5.4",
"@polkadot/wasm-crypto-wasm": "7.5.4",
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
},
"devDependencies": {
"@polkadot/util": "^13.5.8",
"@polkadot/x-randomvalues": "^13.5.8"
"@polkadot/util": "^14.0.1",
"@polkadot/x-randomvalues": "^14.0.1"
},
"peerDependencies": {
"@polkadot/util": "*",

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { InitFn } from '@polkadot/wasm-bridge/types';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { InitFn } from '@polkadot/wasm-bridge/types';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './wasm.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
export * from './wasm.js';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { InitFn } from '@polkadot/wasm-bridge/types';

View File

@@ -1,4 +1,4 @@
// Copyright 2017-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2017-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev

View File

@@ -1,6 +1,6 @@
// Copyright 2017-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2017-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Do not edit, auto-generated by @polkadot/dev
export const packageInfo = { name: '@polkadot/wasm-crypto-init', path: 'auto', type: 'auto', version: '7.5.3' };
export const packageInfo = { name: '@polkadot/wasm-crypto-init', path: 'auto', type: 'auto', version: '7.5.4' };

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { WasmBaseInstance } from '@polkadot/wasm-bridge/types';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-init authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-init authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { InitFn } from '@polkadot/wasm-bridge/types';

View File

@@ -18,14 +18,14 @@
"./packageDetect.cjs"
],
"type": "module",
"version": "7.5.3",
"version": "7.5.4",
"main": "index.js",
"dependencies": {
"@polkadot/wasm-util": "7.5.3",
"@polkadot/wasm-util": "7.5.4",
"tslib": "^2.7.0"
},
"devDependencies": {
"@polkadot/util": "^13.5.8"
"@polkadot/util": "^14.0.1"
},
"peerDependencies": {
"@polkadot/util": "*"

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { base64Decode, unzlibSync } from '@polkadot/wasm-util';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const bytes: string;

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
exports.lenIn = 0;

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
const bytes = require('../bytes.js');

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const bytes: string;

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
export const bytes = '';

View File

@@ -1,4 +1,4 @@
// Copyright 2019-2025 @polkadot/wasm-crypto-wasm authors & contributors
// Copyright 2019-2026 @polkadot/wasm-crypto-wasm authors & contributors
// SPDX-License-Identifier: Apache-2.0
import './packageDetect.js';

Some files were not shown because too many files have changed in this diff Show More