Skip to content

refactor(pq): unify quantum signing with the local-key path; alias algosdk to the PQ fork [PERA-4653] - #1037

Closed
fmsouza wants to merge 25 commits into
mainfrom
fmsouza/pera-4653
Closed

refactor(pq): unify quantum signing with the local-key path; alias algosdk to the PQ fork [PERA-4653]#1037
fmsouza wants to merge 25 commits into
mainfrom
fmsouza/pera-4653

Conversation

@fmsouza

@fmsouza fmsouza commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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.

⚠️ This closes a live signing bug. Quantum transactions were being Falcon-signed over 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 verifies pqsig yet, nothing could catch it. Signing now goes through pqSigningDigest(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 localKey signer

ResolvedSignerType is back to 'localKey' | 'hardware' | 'multisig'. Deleted: the machine's quantum state, quantumSignerActor, createQuantumStrategy, useQuantumTransactionSigner, the signQuantumTransactions dependency, and the quantum branches in determineSignerType / getSigningStrategy (including the ordering hazard that forced quantum to be tested before hasSigningKeys).

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 in sig or pqsig.

Generic PQ signature model

PQSignature { schemeId, publicKey, signature } plus a scheme registry, replacing the Falcon-specific falconSignature field. The opaque QuantumSignedTransaction { txn, pqSignedBytes } byte carrier is gone — a PQ transaction is now a real SignedTransaction with a pqsig, so isQuantumSignedTransaction, PeraSignedTxnResult, the encodeSignedTransaction carrier branch and the compactSignedResults narrowing seam all disappear. SignerInfo.signatures carries real values instead of null.

Joe's fork, installed under the official name

algosdk resolves to npm:@joe-p/algosdk@3.7.0-beta.1 via catalog alias + global override, so application code imports only algosdk. 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-1024 and @joe-p/react-native-falcon are both Joe's), so it got build-time provider selection instead: getPQProvider.ts (WASM) / getPQProvider.native.ts (Nitro), replacing an if (navigator.product === 'ReactNative') runtime check.

Bugs fixed alongside

  • ARC-60 was an unreachable success path for quantum — the strategy routed quantum's arc60 case into a hook that then threw unsupported account type quantum. It can only ever have failed.
  • Multisig participation disagreed with itself across three code paths. canSignViaParticipants excluded quantum; getLocalParticipants and getLocalUnsignedSigners (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 own FALCON-1024 does not support multisig signing.
  • Stale webview gate — quantum reported as 'Unsignable', deferring to PQ-006 which shipped. It now reports a new Quantum type rather than being aliased onto 'Algo25', since a dApp told "Ed25519" would be handed a ~1.2 KB Falcon signature it cannot verify.
  • Swap guard retargeted onto its real cause. Quantum swap is blocked server-side: swap groups interleave backend pre-signed transactions with the user's, quantum needs a raised fee, and raising it forces a grp recomputation 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's FALCON_DET1024_PUBKEY_SIZE = 0 silent-zero placeholder (now throws), and a batch of stale comments and docs.

One "mock" is deliberately kept: commitQuantumChildKey still writes via the keystore's low-level commit(), because @algorandfoundation/keystore@1.0.0-canary.17 ships 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 misleading MOCK label is gone and replaced with the upstream gap and its removal condition.

Related Issues

Checklist

  • Have you tested your changes locally?
  • Have you reviewed the code for any potential issues?
  • Have you documented any necessary changes in the project's documentation?
  • Have you added any necessary tests for your changes?
  • Have you updated any relevant dependencies?

Additional Notes

Reviewer note: no algod accepts pqsig yet

Verified directly — LocalNet (algod 4.7.4-stable) rejects it, and so does algorand/algod:nightly build 2680, with the same error it gives an unknown field. So pnpm localnet:quantum-check reporting 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 a pqsig node ships. Please don't "fix" its tier logic — the narrow PQSIG_UNSUPPORTED match is what makes it meaningful.

pnpm check:single-algosdk exists for a reason

Two algosdk copies break cross-boundary instanceof Transaction / instanceof Address and msgpack schema identity. This branch's alias silently drifted mid-development — four importers reverted to stock algosdk@3.6.0 via pnpm's peer auto-install, and only a full-tree build failure caught it. Every algokit-utils dependent now pins algosdk directly, 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

  • Rekeying to a quantum account strands funds until a pqsig algod ships — the rekey-back transaction also needs a pqsig. Gated behind enable_quantum_accounts (default off). The warning in packages/accounts/src/utils.ts is deliberately blunt about this.
  • Scheme-genericity is real for the signing path, not everywhere. Transaction assembly and signing are scheme-agnostic; the fee-multiplier shape (one pqMultiplier) and quantumSignKeyId's one-child-per-seed id are genuine second-scheme blockers, now named in schemes.ts and the integration doc rather than glossed.
  • Quantum ARC-60 produces a signature no Ed25519-expecting verifier can check. Intentional — consistent with quantum being just another key type, and ARC-60 needs a PQ story regardless.
  • Pre-existing, untouched: useProgramSigner admits 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-push 7/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

fmsouza added 23 commits July 28, 2026 11:55
…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.
…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.
@fmsouza
fmsouza requested a review from a team as a code owner July 28, 2026 16:03

@wjbeau wjbeau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

@fmsouza

fmsouza commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@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.

@wjbeau

wjbeau commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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?

@fmsouza
fmsouza marked this pull request as draft July 28, 2026 18:40
@fmsouza

fmsouza commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

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
@fmsouza

fmsouza commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #1062.

Changing strategy: rather than merging this into main, the work moves to a long-lived feature branch (feat/quantum) and stays there until the blocker clears.

Why: this work requires algosdk to be aliased to the PQ fork tree-wide, because official algosdk has no pqsig field and so cannot represent a post-quantum signature at all. Narrowing the alias to quantum-only code was measured and doesn't work — the two copies have distinct class identities, so only bytes can cross the boundary, and official decodeSignedTransaction silently drops pqsig. Details in #1062.

Swapping the SDK underneath the whole production app to reach a flag-dark feature isn't a stability trade worth making on main. Same commits, no work lost — #1062 continues from here.

@fmsouza fmsouza closed this Jul 29, 2026
@fmsouza
fmsouza deleted the fmsouza/pera-4653 branch July 29, 2026 08:39
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.

2 participants