refactor(pq): unify quantum signing with the local-key path; alias algosdk to the PQ fork [PERA-4653] - #1037
refactor(pq): unify quantum signing with the local-key path; alias algosdk to the PQ fork [PERA-4653]#1037fmsouza wants to merge 25 commits into
Conversation
…RA-4653] Alias `algosdk` to Joe Polny's fork (3.7.0-beta.1) via a pnpm catalog override, so `pqsig` becomes representable in the type the whole codebase already imports as `algosdk`. Drops the separate `@joe-p/algosdk` dependency, updates quantumAdapter.ts and its spec to import from `algosdk`, externalizes `algosdk` in the blockchain vite build, and narrows the PQ library firewall to the Falcon libraries only (the fork no longer needs its own forbidden-specifier seam).
…dk alias [PERA-4653] Task 1's commit (algosdk installed under the official name via a pnpm alias) invalidated several statements in this doc: "two swap seams" (only the kms Falcon seam is still firewalled), quantumAdapter.ts being "the only module importing @joe-p/algosdk" (it now imports plain algosdk), the swap-back instructions telling a future engineer to edit that import (the real swap-back is the one-line pnpm-workspace.yaml change), and the claim that both seam files carry a // SWAP: marker (quantumAdapter.ts's was removed). Corrected each in place; unrelated stale content (PQ-006, PQ-020, Seam A WASM-only claim) is left for the task that owns it.
…c [PERA-4653] Task 1 reduced the PQ library firewall to one seam directory and narrowed its forbidden-specifier pattern to @joe-p/react-native-falcon and falcon-1024 only (algosdk/@joe-p/algosdk is deliberately no longer matched). The Enforcement section still said "two seam directories" and "@joe-p/*", and described a now-deleted second positive-control test in the plural. Corrected all three against the spec as it currently stands.
…smFalconProvider [PERA-4653] Task 2 removed the runtime branch from getPQProvider (build-time selection via Metro's .native.ts resolution), which left these two doc comments describing a conditional that no longer exists. Comments only, no code change.
…age.json formatting, cover compactSignedResults [PERA-4653] - Doc previously said the fork hashes the signature preimage internally and callers pass a raw (un-digested) signature - backwards after this task's digest fix, and dangerous for whoever wires the KMS signer into this seam next. Documents pqSigningDigest/assemblePQSignedTransaction as the current Seam B surface and states the digest contract explicitly. - Removes stale references to the deleted QuantumSignedTransaction carrier from the PQ-006 section and the on-device verification checklist. - Restores packages/blockchain/package.json to its original 2-space formatting/key order (was inadvertently reformatted); net diff against the parent commit is now the single added @noble/hashes dependency line. - Adds a direct unit test for compactSignedResults (order + identity preservation, all-null input), which lost its only coverage when its signature was retyped.
…RA-4653] Deletes the parallel quantum signing path (createQuantumStrategy, quantumSignerActor, useQuantumTransactionSigner, the 'quantum' ResolvedSignerType) and folds quantum accounts into the same local-key path algo25/HD already share. useKMS().getPQSigningInfo(keyPairId) is the single place that resolves the scheme; useLocalKeyTransactionSigner uses it to decide, per call, whether to sign pqSigningDigest(txn) (the correct SHA-512/256 digest) or the plain encoded transaction, and to assemble the result via assemblePQSignedTransaction or a plain SignedTransaction. This closes the live bug where production signed the un-digested "TX"-prefixed encoding for Falcon transactions. Also swaps the deleted PeraSignedTxnResult/QuantumSignedTransaction byte carrier for the now-unified PeraSignedTransaction across the signing pipeline (submission, transports, models) and its fee-delegation/ transactions consumers.
….md [PERA-4653] The §PQ-006 "Dedicated strategy"/"Machine routing"/"Submission stays gated" bullets still described the deleted createQuantumStrategy / quantumSignerActor / 'quantum' ResolvedSignerType path. Updated to describe the unified local-key path this task landed, and fixed a matching stale "carrier-aware encodeSignedTransaction" comment in submitAndAutoRefresh.ts (the carrier is gone; pqsig is just a field).
…countType [PERA-4653] getPQSigningInfo decided the payload scheme from the child's type while signTransactionsWithKey decided the signer from the seed's scheme — two independent oracles that could disagree (e.g. keyPairId resolving to the quantum seed id itself, a legacy convenience resolveSeedKey still accepts), silently re-creating the un-digested-signing bug via a null fallthrough to the ed25519 path. getPQSigningInfo now derives from the same seed-scheme oracle the signer uses, and throws on a seed/child-type mismatch instead of returning null. Also deletes SignerInfo.accountType: it had exactly one writer (createLocalKeyStrategy) and zero readers since PQ-019 removed the submission-boundary gate its doc comment described, and this round's own review added a test pinning that dead write. Renamed a duplicated inner describe block in useLocalKeyTransactionSigner.spec.ts.
…bview signability [PERA-4653]
…nArbitraryData docstring [PERA-4653]
…ERA-4653] The carrier-based quantum guard in the swap approve callback checked for a representation that no longer exists. Retarget it onto the real reason quantum swap is blocked: the swap fee comes from the backend's prepare-transactions response and can't be raised on-device without invalidating pre-signed group members' `grp` hash (PQ-024 / PERA-4705). Also add the same account-level guard to requestSwapProposal, closing a hole where a quantum proposer's multisig signature extraction silently returns null.
…ning path [PERA-4653] The suite's comments and one assertion still referenced the deleted quantumSignerActor/createQuantumStrategy carrier. Quantum accounts now sign through the ordinary local-key path and the result is a plain SignedTransaction with `pqsig` populated instead of `sig` — updated the comments and the delivered-payload assertion to match, preserving the same two behaviors: the confirmation screen shows the raised PQ fee + explainer, and a local quantum payment reaches the callback transport's approve() without ever hitting algod broadcast.
…A-4653] isQuantumAccount is a raw account.type === 'quantum' tag check. Both swap quantum guards were checking the selected account directly, so a standard or multisig account rekeyed on-chain to a quantum auth account sailed past both guards (its own type stays e.g. 'algo25') and got Falcon-signed at a fee the backend computed without the PQ minimum. Resolve the effective signer via useSignerFor (one rekey hop) in useSwapExecution and pass that into both requestSwapSignatures and requestSwapProposal, matching the same resolve-then-check pattern useTransactionConfirmationScreen already uses for its isQuantumFee check.
Adds tools/localnet-quantum-check.ts: an end-to-end check that derives a Falcon-1024 address, funds it, builds a PQ-fee-raised payment, and asserts the assembled signed bytes match algosdk's own PQ signer byte-for-byte before attempting broadcast. Since no public algod accepts `pqsig` yet (verified against both LocalNet 4.7.4-stable and rel/nightly build 2680), the script reports PENDING (exit 0) when broadcast is rejected specifically for the unknown `pqsig` field, and FAILs (non-zero) on any other rejection reason, so it becomes a true pass/fail e2e test unchanged once a pqsig-capable node ships. Also adds a --new-quantum flag to tools/localnet-fund.ts to mint and fund a throwaway quantum account, wires the new script into package.json, and documents both in README.md. Drops the "not yet landed" hedge in docs/QUANTUM_PQ_INTEGRATION.md's PQ-023 section now that this has landed.
…A-4653] sendRawTransaction resolving only means algod accepted the bytes into its mempool, not that the transaction landed on chain. Splits submit and confirm into separate try/catch blocks: PQSIG_UNSUPPORTED is now only ever tested against the submit rejection, so a confirmation-wait failure can never be misread as PENDING. Confirmation uses algosdk's waitForConfirmation, the same helper submitAndAutoRefresh.ts already uses, wrapped in a wall-clock timeout so a node that accepts but never confirms produces a bounded FAIL instead of a hang. Also fixes a numeric-separators-style lint violation (1_000 -> 1000) and converts the fail() helper from an arrow-function const to a function declaration, since TypeScript's unreachable-code-after-never-call narrowing does not apply to arrow-function expressions, which the split submit/confirm logic now depends on.
…k everywhere [PERA-4653] Four importers (background, onramp, shared, transactions) depend on @algorandfoundation/algokit-utils but never declared algosdk directly. Without an algosdk instance already in their own dependency graph, pnpm's peer-dependency auto-install ignored the root algosdk override and resolved algokit-utils's peer against stock algosdk@3.6.0 instead of the @joe-p/algosdk fork used everywhere else — reintroducing the exact two-copies-of-algosdk problem the fork alias exists to prevent, and breaking the onramp build (AlgoAmount type mismatch across the two algokit-utils instances). Add an explicit "algosdk": "catalog:" dependency to each of the four packages, matching the pattern already used in packages/blockchain, so their own dependency graph contains the aliased algosdk and algokit-utils' peer resolves to it. Also pruned stale node_modules/.pnpm entries for the orphaned stock algosdk/algokit-utils/algokit-client-generator variants.
Per-importer algosdk pinning (previous commit) fixes today's dependency graph but doesn't guard against a fifth package silently reintroducing a second algosdk resolution on a future install — pnpm's peer-dependency auto-install can bypass the workspace-level algosdk override for any importer with no direct algosdk edge, exactly as happened mid-branch between Task 1 and Task 3 here. Add tools/check-single-algosdk.mjs: parses pnpm-lock.yaml's importers section directly (no `pnpm list` shell-out), asserts exactly one resolved algosdk identity across the whole workspace, and on failure names every importer referencing each distinct identity found. Fails loudly (not silently passes) if the lockfile's expected sections can't be located or no algosdk resolution is found at all, per "a guard that passes when it can't parse is worse than no guard." Wired in as `pnpm check:single-algosdk` and as its own step in tools/pre-push, alongside the existing lint/format/copyright/i18n/secret checks. Deliberately broke the invariant (removed the `algosdk` pin from packages/onramp added in the previous commit, reinstalled) and confirmed the guard fails and names `packages/onramp` specifically, then restored and confirmed it passes again — see task-9-report.md for both verbatim outputs.
…unds-freeze warning [PERA-4653] - No algod available today accepts `pqsig` — not mainnet, not testnet, not LocalNet (4.7.4-stable) — so a quantum-signed transaction cannot be confirmed anywhere. Three docs claims and one production comment said otherwise; a future engineer would have read them, run `pnpm localnet:quantum-check`, seen the designed PENDING and been tempted to "fix" the tier logic that makes the script meaningful. - Document `pnpm localnet:quantum-check` as shipped (it is, on this branch) rather than as follow-up tooling. - Restore the accurate rekey-to-quantum warning: it is a one-way door that strands funds, because the rekey-BACK transaction also needs a `pqsig` and is rejected at submit. The replaced text called the feature-flag gate "rollout control, not a functional limitation", which invites turning the flag on for testers. - Remove the redundant `algosdk` `minimumReleaseAgeExclude` entry (the `@joe-p/algosdk` entry covers the alias by resolved name) and amend the swap-back procedure, which is not purely a one-specifier change. - Give quantum its own `Quantum` webview type instead of aliasing it to `Algo25`, which states something untrue about the key type. - Restore the lost `assemblePQSignedTransaction` argument assertion, add a multi-transaction quantum case covering `signatures[idx]` pairing, and restore fork byte-equality for the rekeyed case.
Both strings reach the user verbatim — useSwapExecution displays `e.message` for any non-rejection failure — so English prose defeated the stated purpose of these guards, which is that a blocked quantum swap fails loudly AND correctly attributed. The guards now reject with a `QuantumSwapBlockedError` carrying an i18n key; `useSwapExecution` is the display site that renders it through `t()`. `Error.message` stays English for logs. Test assertions tightened from a lucky /fee/i substring match to the exact key.
…f scheme-genericity [PERA-4653] 7a: `getPQSigningInfo` returned a hardcoded `schemeId: 'falcon1024'` while `PQSignatureProvider.scheme` — the generic source that already knows the answer — was read only by a test. It now returns the provider's `scheme`, and the provider contract widens from the literal to `PQSchemeId` (type-only import, erased at runtime, so the pure-crypto seam stays uncoupled). 7b: the "no new types, just add a registry entry" claim was only true for the transaction assembly and signing path. schemes.ts and the integration doc now say that explicitly and name the two remaining single-scheme assumptions (feeCalculator's one `pqMultiplier` + boolean `isPQSigner` derived where the scheme isn't retrievable; `quantumSignKeyId`'s one child per seed) so a future implementer isn't misled. Neither is fixed here — out of scope. 8: signGroupsBySignerAccount's comment named the deleted quantum actor; check-single-algosdk claimed workspace-wide coverage while inspecting only the lockfile's `importers:` section; pqImportSideEffects now also guards `getPQProvider.native` (the on-device entry point, previously covered by neither this guard nor the source scanners — verified non-vacuous by making the require eager and watching it fail); the extension shim explains why its bare `exports` reference is legitimate under Metro's module wrapper; the firewall spec records `tools/` as an accepted blind spot and the doc's Enforcement section no longer reads as a proof.
wjbeau
left a comment
There was a problem hiding this comment.
This looks much cleaner. @fmsouza my only concern is that we're pinning joe-p's algosdk which is the right way to go but if we merge this now it will leak it out into production which is a little suspect to me. Will the app build against the official algosdk and if so do you think we should make that the default and add a commented out joe-p pin that we can easily enable locally for testing until we have our official release out and also a more official algosdk pq supporting build? wdyt?
|
@wjbeau yes, that's exactly why we had the fork hidden under a different alias before with some duplication. The types are bisecting in some of the main branches to accomodate the changes to let the transaction type have the format from the fork which holds the pq signature as well. So to clean up the code we need to promote it to the official slot temporarily until the official algosdk release comes. I've tried to investigate an alternative in which the blast radius would be self-contained to PQ specific code without touching production code for the rest of the account types, but couldn't find a good way around it without needing to add a lot of type format manipulation to make sure the transaction signing pipeline would be able to sign quantum transactions as well. So I'd say we either keep it like this and potentially push next build to the beta testers using Joe's fork until PQ is available on the official algosdk lib, or maybe we hold this PR for a bit longer and only wrap up and merge once the official sdk is released. If you have different ideas or thoughts which could help mitigate this issue, I'm listening. |
|
What about, as an exception just creating a long running feature branch? We can build bitrise builds from any branch manually so we could always kick out a QA build from that branch until things are a bit more stable and then merge everything back to main? Not our ideal flow, but given the weirdness around the dependencies in this case I think it's a justifiable option? |
|
Yeah, I think that sounds fair. Should we use it as the feature branch in this case? I've just demoted this branch to a draft. |
# Conflicts: # package.json # pnpm-lock.yaml
# Conflicts: # package.json
|
Superseded by #1062. Changing strategy: rather than merging this into Why: this work requires Swapping the SDK underneath the whole production app to reach a flag-dark feature isn't a stability trade worth making on |
Description
Addresses the PQ-023 review feedback: quantum (post-quantum / Falcon-1024) accounts had an entirely separate signing route, when "falcon is just another key type". This collapses that route into the ordinary local-key path, models the PQ signature generically, installs Joe's fork under the official package name, and removes the remaining mocks.
txn.bytesToSign()un-digested, when the protocol requires the SHA-512/256 digest. The old adapter hid it behind a signer callback that ignored the bytes algosdk handed it, and because no algod verifiespqsigyet, nothing could catch it. Signing now goes throughpqSigningDigest(txn)=sha512_256(txn.bytesToSign()), pinned byte-for-byte against algosdk's own PQ signer by a differential test.Signing is unified — quantum is a
localKeysignerResolvedSignerTypeis back to'localKey' | 'hardware' | 'multisig'. Deleted: the machine'squantumstate,quantumSignerActor,createQuantumStrategy,useQuantumTransactionSigner, thesignQuantumTransactionsdependency, and the quantum branches indetermineSignerType/getSigningStrategy(including the ordering hazard that forced quantum to be tested beforehasSigningKeys).What remains is one branch, in
useLocalKeyTransactionSigner— same altitude as the existing algo25-vs-HD difference: which bytes to sign, and whether the signature lands insigorpqsig.Generic PQ signature model
PQSignature { schemeId, publicKey, signature }plus a scheme registry, replacing the Falcon-specificfalconSignaturefield. The opaqueQuantumSignedTransaction { txn, pqSignedBytes }byte carrier is gone — a PQ transaction is now a realSignedTransactionwith apqsig, soisQuantumSignedTransaction,PeraSignedTxnResult, theencodeSignedTransactioncarrier branch and thecompactSignedResultsnarrowing seam all disappear.SignerInfo.signaturescarries real values instead ofnull.Joe's fork, installed under the official name
algosdkresolves tonpm:@joe-p/algosdk@3.7.0-beta.1via catalog alias + global override, so application code imports onlyalgosdk. The fork is version 3.7.0-beta.1 — a future official release — so swap-back is a specifier change. Falcon itself has no official counterpart package (falcon-1024and@joe-p/react-native-falconare both Joe's), so it got build-time provider selection instead:getPQProvider.ts(WASM) /getPQProvider.native.ts(Nitro), replacing anif (navigator.product === 'ReactNative')runtime check.Bugs fixed alongside
unsupported account type quantum. It can only ever have failed.canSignViaParticipantsexcluded quantum;getLocalParticipantsandgetLocalUnsignedSigners(wired to the real Sign button) admitted it — so a Falcon signature could be produced for an Ed25519 slot. All three now agree, per algosdk's ownFALCON-1024 does not support multisig signing.'Unsignable', deferring to PQ-006 which shipped. It now reports a newQuantumtype rather than being aliased onto'Algo25', since a dApp told "Ed25519" would be handed a ~1.2 KB Falcon signature it cannot verify.grprecomputation that invalidates the backend's signatures. The guard now says so, resolves the effective signer (so rekeyed-to-quantum can't slip past), and covers the previously-unguarded propose path.Mocks removed
Dead
FALCON_PUBLIC_KEY_LENGTH/FALCON_SIGNATURE_LENGTH(one contradicted the documented ≤1232 B bound), the web shim'sFALCON_DET1024_PUBKEY_SIZE = 0silent-zero placeholder (now throws), and a batch of stale comments and docs.One "mock" is deliberately kept:
commitQuantumChildKeystill writes via the keystore's low-levelcommit(), because@algorandfoundation/keystore@1.0.0-canary.17ships generators for seed / ed25519 / XHD-root / secret-key only — there is no Falcon generator to call. It isn't really a mock (it stores a real Falcon public key in the same encrypted namespace); the misleadingMOCKlabel is gone and replaced with the upstream gap and its removal condition.Related Issues
prepare-transactions, or isolating the user's txn in its own partition) — PERA-4705 / PQ-024Checklist
Additional Notes
Reviewer note: no algod accepts
pqsigyetVerified directly — LocalNet (algod 4.7.4-stable) rejects it, and so does
algorand/algod:nightlybuild 2680, with the same error it gives an unknown field. Sopnpm localnet:quantum-checkreporting PENDING at exit 0 is the designed outcome, not a failure. It asserts everything up to submission (derivation, funding, the signing preimage, byte-equality vs algosdk's signer) and flips to a real end-to-end PASS unedited the day apqsignode ships. Please don't "fix" its tier logic — the narrowPQSIG_UNSUPPORTEDmatch is what makes it meaningful.pnpm check:single-algosdkexists for a reasonTwo algosdk copies break cross-boundary
instanceof Transaction/instanceof Addressand msgpack schema identity. This branch's alias silently drifted mid-development — four importers reverted to stockalgosdk@3.6.0via pnpm's peer auto-install, and only a full-tree build failure caught it. Everyalgokit-utilsdependent now pinsalgosdkdirectly, and a pre-push guard fails loudly (and fail-closed on an unparseable lockfile) if a second resolution ever appears.Known limitations, documented not fixed
pqsigalgod ships — the rekey-back transaction also needs apqsig. Gated behindenable_quantum_accounts(default off). The warning inpackages/accounts/src/utils.tsis deliberately blunt about this.pqMultiplier) andquantumSignKeyId's one-child-per-seed id are genuine second-scheme blockers, now named inschemes.tsand the integration doc rather than glossed.useProgramSigneradmits quantum for logicsig with the wrong PQ preimage (not reachable — card funding excludes quantum); fee delegation has no PQ fee adjustment;tools/sits outside lint and the PQ import firewall.Verification
Full suite green on a forced, uncached run: 103/103 turbo tasks, 604 test files, 4371 tests.
pnpm pre-push7/7. Integration suite 101 files / 347 tests, including 13 quantum flow tests.Design and plan:
docs/superpowers/specs/2026-07-28-pq-implementation-revisit-design.md,docs/superpowers/plans/2026-07-28-pq-implementation-revisit.md(gitignored locally).🤖 Generated with Claude Code