Skip to content

Hedge every signature, default to transfer_keep_alive, document mortal eras - #11

Merged
n13 merged 4 commits into
mainfrom
n13/hedged-signing-keep-alive
Oct 9, 2026
Merged

n13 merged 4 commits into
mainfrom
n13/hedged-signing-keep-alive

Conversation

@n13

@n13 n13 commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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 when period was omitted, and only built transfer_allow_death, i.e. exactly that combination.

What changes

  • Hedged signing, always on. Every signature mixes in 32 bytes of fresh platform randomness (getrandom, crypto.getRandomValues / node:crypto on 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.
  • Required keepAlive parameter on signTransfer (API change). true builds balances.transfer_keep_alive (call index 3) so a sender cannot be reaped by accident; false builds transfer_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 runtime TypeError. Ignored for asset transfers (the assets pallet is not currently on mainnet; noted in the docs).
  • Mortal era documented as the expected mode. Omitting period still signs immortal but logs a one-time console.warn; period: 0 is 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).
  • Examples (usage.js, wallet.mjs) and the README signCall snippet use mortal eras and transferKeepAlive.
  • The mortal-era anchor comes from the node. Quantus hashes blocks with Poseidon, and polkadot.js's header.hash is a local Blake2 digest, so an extrinsic anchored to it fails with BadProof. The wallet example and the README signCall snippet now call chain_getBlockHash() and fetch that block's header for the number (the wallet via the new examples/chain-context.mjs). README, index.ts and usage.js say where blockHash must come from.

Tests

  • Rust: canonical byte comparison against sp_core::Pair::sign and the frozen golden vectors now go through the unhedged internal path; new hedged_signatures_are_unique_and_verify checks the public path produces distinct signatures that verify and leave every other extrinsic byte unchanged; pallet_indices covers both balances call indices; signed_transfer_signature_verifies verifies a hedged extrinsic through the runtime's Verify impl.
  • JS: test/block-hash.test.js pins a real Quantus header and checks polkadot.js's computed header.hash differs from the node's block hash, and that chainContext returns the node's hash; frozen whole-extrinsic SHA-256 digests replaced by envelope comparisons (everything except the signature bytes); new tests for hedging, both keepAlive values, the error when keepAlive is 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 example all green locally.

Not in this PR

  • The assetId path 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-cli still signs deterministically and sends with transfer_allow_death; separate PR.

…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.
@n13 n13 added the bot-review label Oct 9, 2026
n13 added 2 commits October 9, 2026 23:12
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 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@n13 n13 removed the bot-review label Oct 9, 2026
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 n13 added the bot-review label Oct 9, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / 0200 for 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.

@n13 n13 removed the bot-review label Oct 9, 2026
@n13
n13 merged commit 63d8025 into main Oct 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant