Skip to content

[FEATURE BRANCH] Post-quantum accounts — unified signing + PQ algosdk fork (do not merge) - #1062

Draft
fmsouza wants to merge 29 commits into
mainfrom
feat/quantum
Draft

[FEATURE BRANCH] Post-quantum accounts — unified signing + PQ algosdk fork (do not merge)#1062
fmsouza wants to merge 29 commits into
mainfrom
feat/quantum

Conversation

@fmsouza

@fmsouza fmsouza commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

🚧 Long-lived feature branch — do not merge yet

This is a draft integration branch for the post-quantum (Falcon-1024) account feature, not a normal PR. It is deliberately kept out of main and will accumulate the remaining quantum work until the blocker below clears.

Why it exists exceptionally: the work depends on algosdk being aliased to Joe Polny's fork (npm:@joe-p/algosdk@3.7.0-beta.1) tree-wide, because official algosdk cannot represent a post-quantum signature at all — it has no pqsig field. We do not want that alias on main: it swaps the SDK underneath the entire production app to reach a feature that is flag-dark, and that is not a trade worth making for stability.

See Why the alias can't be narrowed below — bisecting the fork to quantum-only code was measured and does not work.

Description

Addresses the PQ-023 review feedback: quantum 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, 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.

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.
  • 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.
  • Stale webview gate — quantum reported as 'Unsignable'. 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, and the guard now resolves the effective signer so rekeyed-to-quantum can't slip past.

Other changes

Build-time Falcon provider selection (getPQProvider.ts / .native.ts) replacing an if (navigator.product === 'ReactNative') runtime check. Dead FALCON_*_LENGTH constants removed. pnpm localnet:quantum-check added. A check:single-algosdk pre-push guard added, because the alias silently drifted mid-development and only a full-tree build caught it.

Why the alias can't be narrowed

The obvious question is: keep official algosdk for the app, use the fork only in quantum code. I measured this — it does not work. Two copies of algosdk means two class identities:

official Address instanceof fork.Address : false
fork Address instanceof official.Address : false
string forms identical                  : true

Handing an official-built Transaction to the fork's PQ signer fails outright:

Invalid address: (object) RUDMUNIXKWZEICAYZRVNRXHJCCCNVYOGBGDRW66V273TIZDJIICMX2GVPQ

So only bytes can cross the boundary — which is precisely why the old code passed unsignedTxnBytes and returned Uint8Array. That byte carrier is what forced the separate signing path this PR removes. And it now carries a worse hazard:

official decodeSignedTransaction(pqsig) : OK; pqsig visible = false

Official algosdk silently drops the pqsig field — no error, just a transaction that has quietly lost its signature. decodeSignedTransaction is live in app code (the swap flow decodes backend pre-signed transactions with it).

Conclusion: unified signing and the tree-wide alias are the same decision. You cannot have one without the other while official algosdk lacks pqsig. Hence this branch rather than a narrower change.

For the record, the measured production delta

Diffed the fork against official 3.6.0: 3 new PQ-only files; 13 shared files changed (~655 lines, predominantly additive); transaction.ts is JSDoc-only; old APIs deprecated, not replaced; export surface is a superset (nothing removed — signBytes/verifyBytes/signTransaction just moved main.tssigning.ts); runtime deps identical except @noble/curves, already in our tree.

So the delta is small and additive — the objection isn't that the fork looks dangerous, it's that swapping the app's SDK for a flag-dark feature isn't a risk worth taking on main. That reasoning stands regardless of the diff.

Exit criteria — what makes this mergeable

  1. Official algosdk@3.7.0 ships with pqsig. Then the alias becomes ^3.7.0 — one specifier, plus dropping two workspace-config entries (documented inline and in docs/QUANTUM_PQ_INTEGRATION.md). A guard test fails loudly if anyone points it at a pre-3.7 official release. Official is currently 3.6.0; 3.7.0 does not exist yet.
  2. A pqsig-capable algod exists, so quantum is verifiable end to end. Today none does — LocalNet (4.7.4-stable) rejects pqsig, and so does algorand/algod:nightly build 2680. pnpm localnet:quantum-check therefore reports PENDING at exit 0 by design, and flips to a real PASS unedited when a node ships.
  3. Quantum swap backend support — PERA-4705 / PQ-024. Blocked server-side: swap groups interleave backend pre-signed transactions with the user's, quantum needs a raised fee, and raising a fee forces a grp recomputation that invalidates the backend's signatures.

Maintaining this branch

  • Keep it merged with main regularly. It conflicts on nearly every Dependabot batch: our falcon-1024 devDep line sits directly above fallow in package.json, which two separate batches bumped in a single day (3.3.0 → 3.7.0 → 3.7.1). Each is a trivial union resolution.
  • Never hand-merge pnpm-lock.yaml. Take main's and re-run pnpm install to replay this branch's spec delta.
  • pnpm check:single-algosdk must stay green. Two algosdk copies break cross-boundary instanceof and msgpack schema identity; the alias already drifted once via pnpm's peer auto-install, so every algokit-utils dependent pins algosdk directly.

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

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.
  • 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, named in schemes.ts and the integration doc.
  • Quantum ARC-60 produces a signature no Ed25519-expecting verifier can check — intentional, consistent with quantum being just another key type.
  • Pre-existing, untouched: useProgramSigner admits quantum for logicsig with the wrong PQ preimage (unreachable — card funding excludes quantum); fee delegation has no PQ fee adjustment; tools/ sits outside lint and the PQ import firewall.

Open follow-up not yet on this branch

A conformance suite asserting the fork is byte-identical to official 3.6.0 for every ed25519 transaction type the app builds. That would turn "is the fork safe for the whole app" from a judgement call into a gate, and is the strongest single de-risking step if this branch ever needs to merge before official 3.7.0 lands.

Verification

Full suite green on the merged tree: 103/103 turbo tasks, 607 test files, 4388 tests. pnpm pre-push 7/7. pnpm audit --prod clean. Integration suite includes 13 quantum flow tests.

🤖 Generated with Claude Code

fmsouza added 25 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.
# Conflicts:
#	package.json
#	pnpm-lock.yaml
fmsouza added 4 commits July 30, 2026 12:41
Brings in 12 commits, notably #1071 (betanet + runtime-configurable custom
network) and #1058 / PERA-4670 (migrated device-id telemetry).

Three textual conflicts, plus one silent semantic break that auto-merged
cleanly and would have shipped red:

1. `packages/device/src/hooks/useDevice.ts` — a genuine feature collision, not
   a textual one. Both sides rewrote the same file from the same base:
   feat/quantum migrated registration to Device API v3 (PERA-4705, #1078) and
   main added migrated-id origin tracking plus orphaning telemetry (PERA-4670,
   #1058). Kept v3's architecture and ported PERA-4670's telemetry onto its
   recreate path. Two deliberate adaptations, both commented in place:
     - PERA-4670 guarded the emit on an in-flight attempt id to be sure the
       write was still current. v3 does not need it: every registration is
       chained through `enqueueRegistration`, so one task runs per network and
       a recreate cannot be superseded mid-flight.
     - `reason` is now always `not_found`. v3's `shouldRecreateDevice` matches
       404 only, because a 400 is a push-token race that retries with the SAME
       id rather than re-creating, so `device_already_exists` can no longer
       replace an id and is no longer a replacement reason.

2. `packages/device/src/hooks/__tests__/useDevice.test.ts` — kept v3's suite
   and ported PERA-4670's telemetry tests onto v3's mocks. The two tests
   asserting `device_already_exists` as a replacement reason were dropped as
   unreachable under v3 (see above), and one was added in their place
   asserting the inverse: a push-token race leaves a migrated id and its
   origin untouched.

3. `README.md` — main removed `localnet:use`/`localnet:unset`,
   `tools/use-localnet.sh` and `.env.localnet`, replacing the env-overlay flow
   with the runtime custom-network UI. Took main's snippet, kept the quantum
   funding line (`localnet:fund --new-quantum` still exists), and rewrote the
   quantum-verification section, which still told the reader to run the
   now-deleted `pnpm localnet:use`.

4. `packages/shared/src/api/__tests__/query-client.test.ts` — NOT flagged as a
   conflict; git combined both sides' edits into a file that no longer worked.
   #1071 made client construction lazy ("NEVER at module import time", see
   `ensureClientsBuilt`) while PERA-4705 had strengthened this test to read the
   real `setStandardHeaders` off `ky.create.mock.calls[0]`. Post-merge nothing
   was created at import, so `calls[0]` was undefined. Fixed by triggering a
   request first and locating the pera client by prefix instead of call order —
   keeping the strong header assertions rather than reverting to main's
   `expect(mockKy).toHaveBeenCalled()`.

Verified on the merged tree: `turbo run build` 53/53 (incl. mobile
`tsc --noEmit`), and `turbo run test:unit --force` 100/100 uncached with zero
failures.
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