Repository navigation
Hedge every signature, default to transfer_keep_alive, document mortal eras - #11
Conversation
…l eras Deterministic ML-DSA plus an immortal era produced byte-identical extrinsics (same hash) when a reaped and re-funded account re-sent the same transfer, and left the first extrinsic replayable. Signatures now mix in 32 bytes of fresh platform randomness, signTransfer builds balances.transfer_keep_alive unless keepAlive: false, omitting period logs a one-time immortal warning, and the README explains reaping, replay and why hedging only fixes hash uniqueness.
Earlier versions always built transfer_allow_death, so a default of transfer_keep_alive would change existing callers silently. Omitting the field is now a TypeScript error and a runtime TypeError.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT-6.1 Sol
Verdict (advisory): Request changes
Reviewed head: 844efcaaf5a9cd77f9b21774d44ca80a6127a67c.
[P2] Fetch the canonical block hash for the mortality checkpoint. In examples/wallet.mjs:144, header.hash is computed locally by polkadot.js using its default Blake2 hasher. Quantus computes block hashes with Poseidon, so this is not the hash that CheckMortality adds to the runtime's signing payload. Switching the wallet example to mortal signing therefore makes its send transactions fail signature verification (BadProof). The new README example at line 150 has the same defect.
Confirmed with read-only RPC calls to the example's default node: at block 1233521, header.hash.toHex() returned 0x9304ed948a0e7fecbe7191864545faa476f5755a5645557c4baf9cec2479aaf8, while api.rpc.chain.getBlockHash(header.number) returned 0x1bb1bf45ad108fa7f1daaf9289cffe7fd2a4686ac0e49fbeac0b9aa92d0802aa. Fetch the canonical hash from chain_getBlockHash and pass that as blockHash in both examples; add coverage that distinguishes the RPC block hash from the codec's computed hash.
Validation passed at the reviewed head: git diff --check, cargo test --locked (10 tests), npm run build, npm run test:js (18 tests, including package contents), and npm run example. Additional CSPRNG checks confirmed fresh 32-byte randomness and hard errors on RNG failure across all four public signing APIs and both schemes; the Node crypto backend also passed. GitHub CI is successful. The local build used a temporary macOS ARM wasm-pack binary because the installed binary had the wrong architecture.
Quantus hashes block headers with Poseidon. polkadot.js computes `header.hash` locally with Blake2, so the wallet example and the README signCall snippet anchored mortal eras to a hash the runtime never saw, and every `send` would have failed with BadProof. Both now call chain_getBlockHash() and fetch that block's header for the number. The wallet does this through examples/chain-context.mjs. A new test pins a real Quantus header and shows polkadot.js's computed hash differs from the node's, and checks chainContext returns the node's. Docs in README, index.ts and usage.js say where blockHash must come from.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT-6.1 Sol
Verdict (advisory): Approve
Reviewed head: a21a092f2cb2d427880c5640cea975131b8fc257 against base c459d40da40041935ef047d030f82efe959edee0.
No blocking findings. The previous mortality-checkpoint issue is resolved: the wallet and README fetch the canonical hash from chain_getBlockHash, then fetch the corresponding header for its number. The new regression tests distinguish that hash from polkadot.js's locally computed header.hash. Hedging reaches every public signing entry point, and the required keepAlive choice is enforced in both the TypeScript wrapper and WASM parameter decoding.
Validation passed:
git diff --check;cargo test --locked(10 tests, including runtime verification for both signature schemes).npm run build;npm run test:js(20 tests, including package contents);npm run example.- Additional CSPRNG checks on Web Crypto and Node crypto: fresh 32-byte requests, distinct signatures with unchanged envelopes, and hard errors on injected RNG failure across all four public signing APIs and both schemes, including the long-payload hashing path.
- Read-only RPC checks against the example's default node confirmed the canonical mortality anchor at block 1233606 and metadata call indices
0203/0200for keep-alive / allow-death.
GitHub CI is successful at this head. The local build used a checksum-verified macOS ARM wasm-pack v0.15.0 inside the temporary worktree because the installed binary has the wrong CPU architecture.
Why
An integrator hit duplicate extrinsic hashes: an account sent its whole balance, was reaped (nonce reset to 0), got re-funded, and sent the same amount again. With an immortal era and deterministic ML-DSA the second extrinsic was byte-identical to the first, so it had the same hash. The same combination also makes the first extrinsic replayable after the re-fund.
This package signed deterministically (
hedge: None), defaulted to immortal whenperiodwas omitted, and only builttransfer_allow_death, i.e. exactly that combination.What changes
getrandom,crypto.getRandomValues/node:cryptoon wasm). Two signings of the same inputs now differ in the signature bytes only, so no two signed extrinsics share a hash. RNG failure is a hard error (RngFailed). The deterministic path survives only as an internal entry point for the golden-vector tests.keepAliveparameter onsignTransfer(API change).truebuildsbalances.transfer_keep_alive(call index 3) so a sender cannot be reaped by accident;falsebuildstransfer_allow_death(what every earlier version built). There is deliberately no default: a default of keep-alive would have changed existing callers silently, so omitting the field is now a TypeScript compile error and a runtimeTypeError. Ignored for asset transfers (the assets pallet is not currently on mainnet; noted in the docs).periodstill signs immortal but logs a one-timeconsole.warn;period: 0is the explicit, silent opt-in. README gains a "Mortality, reaping and replay" section explaining the two hazards and that hedging fixes hash uniqueness while only mortality + keep-alive address replay. Docs now say to anchor the era at the current best block (the "era boundary" restriction only applies for periods above 4096).usage.js,wallet.mjs) and the READMEsignCallsnippet use mortal eras andtransferKeepAlive.header.hashis a local Blake2 digest, so an extrinsic anchored to it fails withBadProof. The wallet example and the READMEsignCallsnippet now callchain_getBlockHash()and fetch that block's header for the number (the wallet via the newexamples/chain-context.mjs). README,index.tsandusage.jssay whereblockHashmust come from.Tests
sp_core::Pair::signand the frozen golden vectors now go through the unhedged internal path; newhedged_signatures_are_unique_and_verifychecks the public path produces distinct signatures that verify and leave every other extrinsic byte unchanged;pallet_indicescovers both balances call indices;signed_transfer_signature_verifiesverifies a hedged extrinsic through the runtime'sVerifyimpl.test/block-hash.test.jspins a real Quantus header and checks polkadot.js's computedheader.hashdiffers from the node's block hash, and thatchainContextreturns the node's hash; frozen whole-extrinsic SHA-256 digests replaced by envelope comparisons (everything except the signature bytes); new tests for hedging, bothkeepAlivevalues, the error whenkeepAliveis missing or not a boolean, and the one-time immortal warning.cargo test(10 passed),npm run build && npm run test:js(20 passed),npm run exampleall green locally.Not in this PR
assetIdpath still encodes pallet index 17 (pallet_assets), which the current runtime no longer has (the index is kept vacant). Worth removing in a follow-up.quantus-clistill signs deterministically and sends withtransfer_allow_death; separate PR.