Aere PQC migration toolkit: measure an address quantum exposure from its bytecode
Zero dependencies, no account, no API. Every command takes --rpc, so it runs against a node you operate and never needs ours. That is the point: an exposure measurement you cannot re-run is an opinion with a logo on it. Ships with two self-checks meant to be run by you, not just by us. The second, proba-vocabular.mjs, exists because of a real defect found on 2026-08-16: the scanner emits RED / YELLOW / GREEN, two downstream files each wrote their own copy of that list, and both wrote AMBER, so a hybrid contract, exactly a client who has already started migrating, was reported as unmeasurable. Both sides were self-consistent, so nothing we had could see it. The vocabulary now has one exported source, and the test walks the whole path with a negative control that makes it able to fail. The same scanner returns RED about our own contracts. See VERIFY-US.md.
This commit is contained in:
commit
de9a79298c
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Aere Network
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
346
README.md
Normal file
346
README.md
Normal file
@ -0,0 +1,346 @@
|
||||
# Aere Quantum Migration Toolkit
|
||||
|
||||
A starting toolkit to assess and migrate EVM accounts toward post-quantum
|
||||
readiness on Aere Network (chain 2800), or on any EVM chain reachable by RPC.
|
||||
|
||||
Aere Network is the post-quantum EVM L1: it ships five live NIST post-quantum
|
||||
verification precompiles on mainnet (Falcon-512, Falcon-1024, ML-DSA-44,
|
||||
SLH-DSA-128s, SHAKE256, at `0x0AE1..0x0AE5`, activated at block 9,189,161), plus
|
||||
a live Falcon-512-owned smart account and factory. That makes Aere the natural
|
||||
home for tooling that helps others measure their quantum exposure and move to a
|
||||
post-quantum account. This toolkit extends Aere's internal account-migration
|
||||
work outward into something anyone can run.
|
||||
|
||||
It is a real product with one unified CLI and zero runtime dependencies (pure
|
||||
Node built-ins, Node 18+):
|
||||
|
||||
1. `scan.js` / `aere-pqc scan`, a real PQC-readiness scanner (EOA vs contract,
|
||||
and which signature primitives a contract uses).
|
||||
2. `lib/migrator.js` / `aere-pqc build-migration`, the transaction-CONSTRUCTION
|
||||
SDK: given a classical ECDSA EOA and a target Falcon-512 public key it derives
|
||||
the counterfactual post-quantum account, enumerates the assets to move
|
||||
(read-only), and emits the exact sequence of UNSIGNED transactions a holder
|
||||
would sign. It never signs, deploys, or moves funds.
|
||||
3. `lib/readiness.js` / `aere-pqc readiness` + `simulate.js` / `aere-pqc
|
||||
simulate`, the quantum-readiness cost simulator: one-time migration gas plus
|
||||
ongoing per-authorization cost, from the measured PQC verify gas.
|
||||
|
||||
`aere-pqc` is the single entry point that wraps `scan`, `simulate`,
|
||||
`build-migration`, and `readiness`. The lower-level `scan.js`, `plan.js`,
|
||||
`simulate.js` CLIs remain for direct use.
|
||||
|
||||
## The scope boundary (this never moves)
|
||||
|
||||
Migrating an account's authentication to a post-quantum scheme changes how that
|
||||
account authorizes. It does NOT make Aere consensus post-quantum. Mainnet 2800
|
||||
still seals blocks with classical secp256k1 QBFT, and the on-chain ZK verifiers
|
||||
(BN254 Groth16) are classical. Nothing in this toolkit claims otherwise, and the
|
||||
scanner and simulator both restate the boundary in their output.
|
||||
|
||||
## Honest status
|
||||
|
||||
This is a starting toolkit, not a finished product. What is real and verified is
|
||||
listed under "What actually runs" below. What is heuristic, out of scope, or not
|
||||
yet deployed is flagged inline with `[VERIFY]` and `[MEASURE]`, in the code and
|
||||
in the reports the tools print.
|
||||
|
||||
## 1. The readiness scanner: `scan.js`
|
||||
|
||||
Given an EVM address, the scanner reports quantum exposure as a colour:
|
||||
|
||||
- **RED**, classical-only. An EOA (secp256k1 ECDSA authenticated, so
|
||||
Shor-breakable and unable to hold verification code), or a contract with no
|
||||
detectable post-quantum precompile usage.
|
||||
- **YELLOW**, hybrid. A contract that uses both a classical signature primitive
|
||||
(ecrecover at `0x01`, or P-256 at `0x100`) and Aere's live PQC precompile band.
|
||||
- **GREEN**, PQC-capable. A contract that uses Aere's live native PQC precompiles
|
||||
(`0x0AE1..0x0AE5`).
|
||||
|
||||
```bash
|
||||
# live scan over JSON-RPC (this is the only part that needs network access)
|
||||
node scan.js 0x465d9E3b476BF98Aa1393079e240Db5D2a9bEA6A --rpc https://rpc.aere.network
|
||||
|
||||
# offline: scan a bundled real-bytecode fixture
|
||||
node scan.js --fixture AerePQCAttestation
|
||||
|
||||
# offline: scan bytecode you already have
|
||||
node scan.js 0xYourContract --bytecode 0x60806040...
|
||||
|
||||
# machine-readable
|
||||
node scan.js 0x465d... --rpc https://rpc.aere.network --json
|
||||
```
|
||||
|
||||
### How it detects, and why it is honest
|
||||
|
||||
The scanner does not grep the hex. A naive search for `610ae1` (PUSH2 0x0AE1)
|
||||
false-positives, because those bytes can sit inside another instruction's push
|
||||
data. Two techniques make the detection real:
|
||||
|
||||
1. **Opcode-aware decoding.** The bytecode is walked as an instruction stream,
|
||||
skipping each PUSH's immediate bytes, so a matched constant is a genuine push.
|
||||
2. **Local stack simulation to resolve CALL targets.** For every
|
||||
CALL/STATICCALL/DELEGATECALL/CALLCODE the scanner recovers the address
|
||||
argument (the second stack item) by simulating the stack per basic block. That
|
||||
distinguishes an actual call to `0x0AE1` from the bare number `0x0AE5` used as
|
||||
a memory length near an unrelated staticcall, which is precisely the false
|
||||
positive a proximity heuristic hits. (An earlier proximity-only version of
|
||||
this tool misread `AereHybridAuth` as hybrid for exactly that reason; the
|
||||
stack-simulation version reads it correctly.)
|
||||
|
||||
The signature-verify addresses `0x0AE1..0x0AE4` (2785..2788) are not round
|
||||
numbers, so a genuine opcode-aligned push of one is a medium-confidence signal
|
||||
even when the call target is staged through memory and cannot be resolved
|
||||
locally. The common literals `0x01`, `0x100`, and `0x0AE5` are never used as a
|
||||
fallback signal; they count only when stack simulation resolves them as an actual
|
||||
call target.
|
||||
|
||||
### Scanner limitations (read these)
|
||||
|
||||
- Bytecode scanning only sees NATIVE precompile calls. A contract that verifies
|
||||
Falcon through the SOLIDITY `AereFalcon512Verifier` (`0x4E8e...D8fFC`, the path
|
||||
the deployed `AerePQCAccount` and `AereHybridAuth` actually take) delegates to
|
||||
a normal contract address, so it reads as RED here even though it is
|
||||
PQC-capable. The bundled `AereHybridAuth` fixture demonstrates this on purpose.
|
||||
Confirm an auth path against source when the scanner says RED.
|
||||
- `ecrecover` (`0x01`) detection is high confidence only because stack simulation
|
||||
resolves the call target to the exact address `1`. A contract that never calls
|
||||
a signature precompile (authorization is by owner or role) also reads RED,
|
||||
which is correct: it has no quantum-vulnerable signature surface to migrate.
|
||||
- The scanner classifies signature-verification exposure. It does not audit key
|
||||
management, upgradeability, or business logic.
|
||||
|
||||
## 2. The migration SDK: `lib/migrator.js` (transaction construction)
|
||||
|
||||
Given a classical ECDSA EOA and a target NIST Falcon-512 public key, this module
|
||||
builds the exact sequence of UNSIGNED transactions a holder would sign to move
|
||||
their assets onto a post-quantum `AerePQCAccount`. It is the concrete,
|
||||
signable-artifact layer on top of the older `lib/migrate.js` plan builder.
|
||||
|
||||
```bash
|
||||
# demo (uses the live sample account's real Falcon key; demo EOA 0x..dEaD)
|
||||
node aere-pqc.js build-migration
|
||||
|
||||
# real build: derive the target, enumerate a real EOA's assets read-only, and
|
||||
# attach a live gas estimate to each unsigned tx
|
||||
node aere-pqc.js build-migration --eoa 0xYourEoa --falcon 0x09... \
|
||||
--rpc https://rpc.aere.network --native 1000000000000000 --estimate
|
||||
|
||||
# atomic sweep via the (audit-gated, not-yet-deployed) AereAccountMigrator
|
||||
node aere-pqc.js build-migration --eoa 0xYourEoa --path migrator \
|
||||
--migrator 0xVerifiedMigrator --tokens 0xTokenA,0xTokenB --amounts 1000,2000
|
||||
|
||||
# machine-readable
|
||||
node aere-pqc.js build-migration --eoa 0xYourEoa --rpc https://rpc.aere.network --json
|
||||
```
|
||||
|
||||
Key functions (`import ... from '@aere/pqc-migration-toolkit/migrator'`):
|
||||
|
||||
- `predictTargetAddress({ falconPubKey, salt })`: the counterfactual
|
||||
`AerePQCAccount` address, pure CREATE2 math, no RPC. Mirrors
|
||||
`AerePQCAccountFactory.predictAddress` and is verified in `selftest.js` against
|
||||
the live on-chain sample account.
|
||||
- `enumerateAssets({ rpcUrl, owner, tokens })`: READ-ONLY. `eth_getBalance` for
|
||||
native AERE and `eth_call balanceOf(owner)` per ERC-20 candidate; returns which
|
||||
assets have a non-zero balance and therefore need moving. Signs nothing.
|
||||
- `buildMigrationTransactions({ path, eoa, falconPubKey, tokens, nativeWei, ... })`:
|
||||
the core. Returns an array of UNSIGNED tx objects, each stamped `signed:false`,
|
||||
shaped `{ to, from, value, data, chainId }` (nonce/gas left for the signer).
|
||||
Two paths:
|
||||
- `direct` (works today, no extra contract): per-asset `ERC-20 transfer` +
|
||||
native send straight to the target account. Not atomic.
|
||||
- `migrator` (atomic, all-or-nothing): `approve(migrator, amount)` per token
|
||||
then one `AereAccountMigrator.migrateToPqcAccount(...)` that binds the
|
||||
destination to the Falcon key on-chain. REQUIRES an explicit `--migrator`
|
||||
address, because `AereAccountMigrator` is repo source in the audit-gated
|
||||
`notDeployedHeld` set and has NO live mainnet address (see below).
|
||||
- `estimateMigrationGas(plan, rpcUrl)`: attaches a read-only `eth_estimateGas`
|
||||
result to each unsigned tx (or an error string when a step cannot be simulated
|
||||
pre-deploy). Broadcasts nothing.
|
||||
- `encodeMigrateCalldata`, `encodeMigrateToPqcAccountCalldata`,
|
||||
`encodeApproveCalldata`, `encodeTransferCalldata`, `encodeCreateAccountCalldata`:
|
||||
the individual calldata builders, each verified byte-for-byte against ethers v6
|
||||
(see "What actually runs").
|
||||
|
||||
The calldata encoder (`lib/abi.js`) is a zero-dependency Solidity ABI encoder for
|
||||
exactly the types the migrator needs (`uint256, address, bool, bytes, address[],
|
||||
uint256[]`), validated against ethers 6.16.0.
|
||||
|
||||
### The migration paths
|
||||
|
||||
**PQC account (Falcon-512 owned).** Derive the counterfactual account
|
||||
(step 1, off-chain), deploy it through the live factory with
|
||||
`createAccount(falconPubKey, salt)` (step 2, permissionless and idempotent), then
|
||||
move assets and roles off the old EOA into it (step 3). After the move, spending
|
||||
authority is the Falcon-512 key only. There is no ECDSA fallback, so register the
|
||||
key in `AerePQCKeyRegistry` (proof-of-possession) and set PQC social-recovery
|
||||
guardians before moving material value.
|
||||
|
||||
**Hybrid account (ECDSA + Falcon-512).** Register a hybrid identity in
|
||||
`AereHybridAuth` binding your existing classical address to a Falcon-512 key,
|
||||
then authorize with both legs during the transition. An attacker must break both
|
||||
secp256k1 AND Falcon-512.
|
||||
|
||||
**EIP-7702 delegation.** Aere supports EIP-7702, so an EOA can temporarily
|
||||
delegate execution to account code to batch a migration move in a single EOA
|
||||
transaction.
|
||||
|
||||
### Founder-gated caveat, and what works today
|
||||
|
||||
The `AerePQCAccountFactory` (`0xd5315Ea7...CE58`) is live, and `createAccount` is
|
||||
permissionless, so deploying a PQC account and moving assets into it with the
|
||||
`direct` path is possible TODAY with no special authority: the whole `direct`
|
||||
sequence is plain factory + ERC-20 + native transactions the holder signs.
|
||||
|
||||
What is NOT deployed:
|
||||
|
||||
- `AereAccountMigrator` (the atomic `migrate` / `migrateToPqcAccount` conduit)
|
||||
is repo source (`contracts/pqc/AereAccountMigrator.sol`) in the audit-gated
|
||||
`notDeployedHeld.externalAuditGated_fundFlow` set. It has NO mainnet address.
|
||||
The `migrator` path therefore refuses to build unless you pass an explicit
|
||||
`--migrator` address, and it prints a `[VERIFY]` warning telling you to confirm
|
||||
that address holds the exact audited bytecode before granting it any approval.
|
||||
- `AereHybridAuth` is repo source with no mainnet address, so the hybrid path's
|
||||
deploy step is a founder/deployer step, flagged `[VERIFY]`.
|
||||
|
||||
Deploying NEW contracts or a corrected V2 redeploy is signed by the
|
||||
Foundation/deployer key, never by this toolkit.
|
||||
|
||||
## 3. The cost simulator: `aere-pqc readiness` and `simulate.js`
|
||||
|
||||
Two views on cost, both over the MEASURED PQC verify gas from
|
||||
`aerenew/docs/AERE-BENCHMARK-REPORT.md`:
|
||||
|
||||
- `aere-pqc readiness <address> --rpc <url>` is the per-account report: it scans
|
||||
the account, enumerates its movable assets read-only, and prints the one-time
|
||||
migration gas (account deploy + asset moves) plus the ongoing
|
||||
per-authorization cost. Asset-move gas is a clearly-labeled `[VERIFY]` planning
|
||||
estimate anchored to the one asset-move figure measured live this session (a
|
||||
native AERE send at 21,246 gas via `eth_estimateGas`); PQC auth gas is `[CITED]`.
|
||||
- `simulate.js` / `aere-pqc simulate` is the fleet projection across many
|
||||
accounts.
|
||||
|
||||
```bash
|
||||
aere-pqc readiness 0xYourEoa --rpc https://rpc.aere.network --tokens 0xTokenA
|
||||
node simulate.js --count 100 --scheme hybrid --auths 5
|
||||
node simulate.js --accounts accounts.json --gwei 1
|
||||
node simulate.js --schemes # list modeled schemes
|
||||
```
|
||||
|
||||
Modeled per-authorization gas (native precompile verify-and-record receipt,
|
||||
`[CITED: AERE-BENCHMARK-REPORT.md Part C.1]`):
|
||||
|
||||
| Scheme | Marginal verify gas | Verify-and-record tx gas |
|
||||
|---|--:|--:|
|
||||
| Falcon-512 | 40,000 | 86,336 |
|
||||
| Hybrid (ECDSA + Falcon-512) | 43,000 | 89,336 |
|
||||
| Falcon-1024 | 75,000 | 145,496 |
|
||||
| ML-DSA-44 | 55,000 | 351,050 |
|
||||
| SLH-DSA-128s | 350,000 | 558,276 |
|
||||
|
||||
The `falcon512_solidity` scheme instead models the CURRENTLY-DEPLOYED
|
||||
`AerePQCAccount`, which verifies Falcon in the EVM via the Solidity verifier: one
|
||||
authorization measured 10,278,313 gas on-chain `[CITED: pqc-account.json]`, under
|
||||
the EIP-7825 2^24 per-tx cap. These two Falcon paths (native precompile ~86k vs
|
||||
deployed-account Solidity ~10.5M) must not be conflated; the simulator keeps them
|
||||
distinct. Account CREATE2 deploy gas is 1,493,084 `[CITED: pqc-account.json]`.
|
||||
|
||||
## What actually runs (verified, not asserted)
|
||||
|
||||
`node selftest.js` runs with no network and checks all of this (21 assertions,
|
||||
all PASS):
|
||||
|
||||
- `keccak256("")` equals the known Ethereum/Keccak vector (the zero-dependency
|
||||
keccak is correct).
|
||||
- `predictPqcAccountAddress` re-derives the LIVE sample `AerePQCAccount` address
|
||||
`0xa42a5e7F72E46BadC11367650Ec34D676194326f` from its real 897-byte Falcon-512
|
||||
key (proves the CREATE2 derivation is byte-correct against a real deployment).
|
||||
- The scanner classifies the four bundled REAL bytecode fixtures correctly:
|
||||
`AerePQCAttestation` GREEN, `AerePQCTxAccount` GREEN, `AereHybridAuth` RED (the
|
||||
Solidity-verifier scope limit), `AereFalcon512Verifier` RED (its `0x0100`
|
||||
constants are literals, not P-256). A synthetic hybrid reads YELLOW; empty code
|
||||
reads RED (EOA).
|
||||
- The migration-SDK calldata builders (`migrate`, `approve`, `transfer`,
|
||||
`balanceOf`) are byte-identical to values independently generated by ethers
|
||||
6.16.0 (the encoder in `lib/abi.js` is a correct Solidity ABI encoder; the
|
||||
ground truth was generated with `aerenew/contracts/node_modules/ethers` and
|
||||
hardcoded into `selftest.js` so the check stays zero-dependency).
|
||||
- `buildMigrationTransactions` returns UNSIGNED objects only: every tx is stamped
|
||||
`signed:false`, none carries an `r`/`s`/`v`/`signature` field, and the target
|
||||
matches the CREATE2 derivation. The `migrator` path refuses to build without an
|
||||
explicit migrator address (because it is not deployed).
|
||||
- The readiness report composes the CITED deployed-account auth figure
|
||||
(10,278,313 gas) and counts movable assets correctly.
|
||||
|
||||
Additionally verified live against `https://rpc.aere.network` (chain 2800,
|
||||
~block 10,416,783, 2026-07-19):
|
||||
|
||||
- `aere-pqc scan` fetched real bytecode and classified the live
|
||||
`AerePQCAttestation` (`0x465d...`) as GREEN (4402 bytes) and the live deployer
|
||||
EOA (`0xbeB3...`) as RED (0 bytes of code).
|
||||
- `aere-pqc build-migration --eoa 0xbeB3... --native 1000000000000000 --estimate`
|
||||
produced two unsigned txs and attached REAL `eth_estimateGas` results:
|
||||
`createAccount` 59,064 gas (low because the salt-0 sample account is ALREADY
|
||||
deployed, so the factory's idempotent `createAccount` short-circuits; a FIRST
|
||||
deploy is the cited 1,493,084), and the native send 21,246 gas.
|
||||
- Honest hazard surfaced by the live estimate: auto-draining the FULL native
|
||||
balance of an ACTIVE account fails, because the sender must reserve gas AND the
|
||||
balance can move between the read and the estimate. The tool now reserves a gas
|
||||
buffer by default and supports `--native <wei>` for a fixed, race-free amount.
|
||||
- `aere-pqc readiness 0xbeB3... --rpc ...` reported a one-time migration cost of
|
||||
1,514,330 gas (1,493,084 deploy + 21,246 native) = 0.001514330 AERE at 1 Gwei,
|
||||
and an ongoing 10,278,313 gas/auth (deployed reality) vs an 86,336 gas/auth
|
||||
native-precompile projection.
|
||||
|
||||
Nothing above broadcasts a transaction. Every network call is a read
|
||||
(`eth_getCode`, `eth_getBalance`, `eth_call`, `eth_estimateGas`, `eth_gasPrice`).
|
||||
|
||||
## Addresses used (chain 2800)
|
||||
|
||||
| Name | Address | Status |
|
||||
|---|---|---|
|
||||
| PQC precompile band | `0x0AE1..0x0AE5` | live (block 9,189,161) |
|
||||
| P-256 (RIP-7212/7951) | `0x100` | live (Osaka) |
|
||||
| ecrecover | `0x01` | protocol constant |
|
||||
| AerePQCAccountFactory | `0xd5315Ea7caa60d320c4f34b1bEd70dd9cc02CE58` | live |
|
||||
| AerePQCAccount (sample) | `0xa42a5e7F72E46BadC11367650Ec34D676194326f` | live |
|
||||
| AereFalcon512Verifier (Solidity) | `0x4E8e9682329e646784fB3bd01430aA4bA54D8fFC` | live |
|
||||
| AerePQCKeyRegistry | `0x1eCa3c5ADcBD0b22636D8672b00faC6D89363691` | live |
|
||||
| AereEntryPointV2 | `0x8D6f40598d552fF0Cb358b6012cF4227B86aF770` | live |
|
||||
| AereHybridAuth | (repo source) | not deployed `[VERIFY]` |
|
||||
| AereAccountMigrator | (repo source) | not deployed, audit-gated `[VERIFY]` |
|
||||
|
||||
Testnet-only PQC precompiles `0x0AE6..0x0AE8` are NOT on mainnet 2800; a mainnet
|
||||
call to them returns empty. The scanner names them and flags `[VERIFY]` if seen.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
aere-pqc.js unified CLI: scan | simulate | build-migration | readiness
|
||||
scan.js readiness scanner CLI (also aere-pqc scan)
|
||||
plan.js migration-plan CLI (older step-by-step plan builder)
|
||||
simulate.js migration cost simulator CLI (also aere-pqc simulate)
|
||||
selftest.js no-network proof: keccak + CREATE2 + fixtures + calldata + txs
|
||||
lib/keccak.js zero-dependency keccak256 + hex/checksum helpers
|
||||
lib/abi.js zero-dependency Solidity ABI encoder (validated vs ethers v6)
|
||||
lib/bytecode.js opcode-aware decoder + stack-simulation CALL-target resolver
|
||||
lib/scanner.js account classifier (EOA vs contract, RED/YELLOW/GREEN)
|
||||
lib/migrate.js plan builder + CREATE2 derivation (the original SDK)
|
||||
lib/migrator.js transaction-construction SDK (unsigned txs, asset enumeration)
|
||||
lib/readiness.js per-account quantum-readiness + migration-cost report
|
||||
lib/simulate.js fleet cost model over the measured PQC verify gas
|
||||
lib/precompiles.js address bands + measured gas, single source of truth
|
||||
lib/rpc.js minimal read-only JSON-RPC client (global fetch)
|
||||
fixtures/ real deployed bytecode extracted from this repo's artifacts
|
||||
```
|
||||
|
||||
## The one hard invariant
|
||||
|
||||
This toolkit CONSTRUCTS and SIMULATES. It never signs, deploys, or moves funds.
|
||||
There is no private key anywhere in it. `build-migration` hands you inert,
|
||||
unsigned transaction objects; you review and sign them in your own wallet. Every
|
||||
network call is read-only. Executing a migration is user-gated (and, for the
|
||||
migrator/hybrid contracts, audit-and-founder-gated).
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
48
VERIFY-US.md
Normal file
48
VERIFY-US.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Check us, do not trust us
|
||||
|
||||
This toolkit exists so that nothing we say about quantum exposure has to be taken on faith,
|
||||
including what we say about our own chain.
|
||||
|
||||
## Run it against a node we do not control
|
||||
|
||||
node scan.js <address> --rpc https://your-own-node
|
||||
|
||||
Every command here takes `--rpc`. None of them requires our endpoint, our API, or an account with
|
||||
us. If your number differs from ours, that difference is the interesting part and we want to hear
|
||||
about it.
|
||||
|
||||
## Run it against us, and expect it to be unkind
|
||||
|
||||
The same scanner that produces a client report produces our own findings, and it returns RED about
|
||||
our own contracts. Measured 2026-08-16 on three of our live addresses: two GREEN, one RED. A vendor
|
||||
whose tool has never returned a red verdict about the vendor is selling a brochure.
|
||||
|
||||
## Prove that the tool can fail
|
||||
|
||||
Two self-checks ship with it, and both are meant to be run by you, not just by us:
|
||||
|
||||
node selftest.js the analysis fixtures, including cases the scanner used to get wrong
|
||||
node proba-vocabular.mjs every verdict survives the whole path from scanner to client wording
|
||||
|
||||
The second one exists because of a real defect. The scanner emits RED / YELLOW / GREEN, and two
|
||||
downstream files each wrote their own list of those words, and both wrote AMBER. So a hybrid
|
||||
contract, which is exactly a client who has already started migrating, was being reported as
|
||||
"we could not measure this address". Both sides were self-consistent, so no test we had could see
|
||||
it. The fix was to give the vocabulary one exported source that the consumers call.
|
||||
|
||||
That test carries a negative control on purpose: an input with no verdict must still come out as
|
||||
NOT MEASURED. Without it, the test would pass even if the reader returned a constant, and would
|
||||
prove nothing. You can verify that claim yourself: change `VERDICTE` in `lib/bytecode.js` to
|
||||
contain AMBER instead of YELLOW, run `node proba-vocabular.mjs`, and watch it exit non-zero.
|
||||
|
||||
## What the tool does not do
|
||||
|
||||
It reads bytecode and what that bytecode can reach. It does not read your repositories, your TLS
|
||||
configuration, your dependencies, your key custody or your HSMs. When it cannot settle a question
|
||||
from bytecode alone, it says so in the output with a `[VERIFY]` or `[MEASURE]` marker, and every
|
||||
report that uses it carries those markers through word for word instead of smoothing them away.
|
||||
|
||||
A live post-quantum verifier reachable from a contract does **not** make that contract
|
||||
quantum-safe. If the transaction that calls it is authorised with ECDSA, an adversary forges the
|
||||
outer transaction and the verifier is irrelevant. Anyone selling you the opposite can be taken
|
||||
apart in five minutes.
|
||||
247
aere-pqc.js
Normal file
247
aere-pqc.js
Normal file
@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
// aere-pqc.js, the unified Aere Quantum Migration Toolkit CLI. One entry point
|
||||
// wrapping the whole product: scan an account, simulate fleet migration cost,
|
||||
// build an unsigned migration transaction sequence, and print a per-account
|
||||
// quantum-readiness report.
|
||||
//
|
||||
// aere-pqc scan <address> --rpc <url>
|
||||
// aere-pqc simulate --count 100 --scheme hybrid --auths 5
|
||||
// aere-pqc build-migration --eoa 0x.. --falcon 0x09.. [--path direct|migrator]
|
||||
// aere-pqc readiness <address> --rpc <url> [--tokens 0x..,0x..] [--scheme ..]
|
||||
//
|
||||
// Nothing in this CLI signs, deploys, or broadcasts. The only network calls are
|
||||
// read-only JSON-RPC (eth_getCode/getBalance/call/estimateGas/gasPrice).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { scanLive, scanBytecode, isValidAddress } from './lib/scanner.js';
|
||||
import { simulateMigration, formatSimulation, schemeList } from './lib/simulate.js';
|
||||
import {
|
||||
buildMigrationTransactions, enumerateAssets, estimateMigrationGas, predictTargetAddress,
|
||||
} from './lib/migrator.js';
|
||||
import { buildReadinessReport, formatReadinessReport, readinessSchemeList } from './lib/readiness.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_RPC = 'https://rpc.aere.network';
|
||||
|
||||
const isTTY = process.stdout.isTTY;
|
||||
const C = isTTY
|
||||
? { b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m` }
|
||||
: { b: (s) => s, dim: (s) => s, g: (s) => s, y: (s) => s };
|
||||
|
||||
function loadSampleKey() {
|
||||
const p = join(__dirname, '..', 'contracts', 'deployments', 'pqc-account.json');
|
||||
return JSON.parse(readFileSync(p, 'utf8')).sampleAccount.falconPubKey;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const t = argv[i];
|
||||
if (t.startsWith('--')) {
|
||||
const key = t.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) { a[key] = true; }
|
||||
else { a[key] = next; i++; }
|
||||
} else a._.push(t);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function topUsage(code = 0) {
|
||||
console.log(`${C.b('Aere Quantum Migration Toolkit')} (chain 2800)
|
||||
|
||||
Commands
|
||||
${C.b('scan')} <address> --rpc <url> classify quantum exposure (RED/YELLOW/GREEN)
|
||||
${C.b('simulate')} --count <n> [--scheme <s>] fleet migration-cost projection
|
||||
${C.b('build-migration')} --eoa 0x.. --falcon 0x09.. build UNSIGNED migration txs
|
||||
${C.b('readiness')} <address> --rpc <url> one-account readiness + cost report
|
||||
|
||||
Global
|
||||
--rpc <url> JSON-RPC endpoint (default ${DEFAULT_RPC})
|
||||
--json machine-readable output
|
||||
--help this help; or "<command> --help"
|
||||
|
||||
Scope: this classifies and CONSTRUCTS/SIMULATES migration of ACCOUNT/APPLICATION
|
||||
quantum exposure. It never signs, deploys, or moves funds, and it does NOT make
|
||||
Aere consensus post-quantum (2800 seals with classical secp256k1 QBFT).`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
// ── scan ─────────────────────────────────────────────────────────────────────
|
||||
async function cmdScan(args) {
|
||||
const addr = args._[0];
|
||||
const rpc = args.rpc === true ? DEFAULT_RPC : (args.rpc || DEFAULT_RPC);
|
||||
let report;
|
||||
if (args.fixture) {
|
||||
const path = join(__dirname, 'fixtures', args.fixture + '.hex');
|
||||
report = scanBytecode(`fixture:${args.fixture}`, readFileSync(path, 'utf8').trim());
|
||||
} else if (args.bytecode) {
|
||||
report = scanBytecode(addr || '(provided bytecode)', args.bytecode);
|
||||
} else if (addr && isValidAddress(addr)) {
|
||||
report = await scanLive(addr, rpc);
|
||||
} else {
|
||||
console.error('scan needs <address> --rpc <url>, or --fixture <name>, or --bytecode 0x..');
|
||||
process.exit(1);
|
||||
}
|
||||
if (args.json) { console.log(JSON.stringify(report, (_k, v) => (typeof v === 'bigint' ? '0x' + v.toString(16) : v), 2)); return; }
|
||||
const badge = report.readiness === 'GREEN' ? C.g(' GREEN ') : report.readiness === 'YELLOW' ? C.y(' YELLOW ') : ' RED ';
|
||||
console.log(`\n[${badge}] ${C.b(report.address)}`);
|
||||
console.log(` kind: ${report.kind}${report.live ? ` chainId: ${report.live.chainId} code: ${report.live.codeBytes} bytes` : ''}`);
|
||||
if (report.reason) console.log(` ${C.dim(report.reason)}`);
|
||||
if (report.migration) console.log(` ${C.b('migration')}: ${report.migration}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ── simulate ─────────────────────────────────────────────────────────────────
|
||||
function cmdSimulate(args) {
|
||||
if (args.schemes) { console.log(schemeList().join('\n')); return; }
|
||||
let accounts;
|
||||
if (args.accounts) {
|
||||
accounts = JSON.parse(readFileSync(args.accounts, 'utf8'));
|
||||
} else {
|
||||
const count = parseInt(args.count, 10) || 1;
|
||||
accounts = Array.from({ length: count }, () => ({
|
||||
scheme: args.scheme || 'falcon512',
|
||||
authsPerAccount: args.auths ? parseInt(args.auths, 10) : 1,
|
||||
deployAccount: !args['no-deploy'],
|
||||
}));
|
||||
}
|
||||
const sim = simulateMigration(accounts, { gweiPrice: args.gwei ? parseFloat(args.gwei) : undefined });
|
||||
if (args.json) { console.log(JSON.stringify(sim, null, 2)); return; }
|
||||
console.log('\n' + formatSimulation(sim) + '\n');
|
||||
}
|
||||
|
||||
// ── build-migration ──────────────────────────────────────────────────────────
|
||||
async function cmdBuildMigration(args) {
|
||||
const falcon = args.falcon && args.falcon !== true ? args.falcon : loadSampleKey();
|
||||
const usingSample = !(args.falcon && args.falcon !== true);
|
||||
const eoa = args.eoa && args.eoa !== true ? args.eoa : '0x000000000000000000000000000000000000dEaD';
|
||||
const usingDemoEoa = !(args.eoa && args.eoa !== true);
|
||||
const salt = args.salt ? parseInt(args.salt, 10) : 0;
|
||||
const path = args.path && args.path !== true ? args.path : 'direct';
|
||||
const rpc = args.rpc === true ? DEFAULT_RPC : args.rpc;
|
||||
|
||||
// asset selection: explicit --tokens list (with optional --amounts) or, if
|
||||
// --rpc is given, enumerate real balances read-only.
|
||||
let tokens = [];
|
||||
let nativeWei = 0n;
|
||||
const tokenAddrs = args.tokens && args.tokens !== true ? String(args.tokens).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
let enumerated = null;
|
||||
// When moving native AERE from the SAME EOA that pays gas, you cannot move the
|
||||
// ENTIRE balance: the tx itself needs gas. Reserve a buffer (default 0.01 AERE,
|
||||
// override with --native-reserve <wei>) so the native move can actually settle.
|
||||
const nativeReserve = args['native-reserve'] && args['native-reserve'] !== true
|
||||
? BigInt(args['native-reserve']) : 10000000000000000n; // 1e16 wei = 0.01 AERE
|
||||
let reservedNote = null;
|
||||
const explicitNative = args.native && args.native !== true ? BigInt(args.native) : null;
|
||||
|
||||
// native amount: explicit --native wins everywhere; else --no-native = 0; else
|
||||
// (only with live enumeration) auto-drain balance minus a gas reserve.
|
||||
if (explicitNative != null) nativeWei = explicitNative;
|
||||
else if (args['no-native']) nativeWei = 0n;
|
||||
|
||||
if (rpc && !usingDemoEoa) {
|
||||
enumerated = await enumerateAssets({ rpcUrl: rpc, owner: eoa, tokens: tokenAddrs });
|
||||
const full = BigInt(enumerated.native.balanceWei);
|
||||
if (explicitNative != null) {
|
||||
reservedNote = `native move = explicit --native ${nativeWei} wei (current balance ${full} wei).`;
|
||||
} else if (!args['no-native']) {
|
||||
if (path === 'direct') {
|
||||
nativeWei = full > nativeReserve ? full - nativeReserve : 0n;
|
||||
if (full > 0n) reservedNote = `native move = balance ${full} minus a ${nativeReserve} wei gas reserve = ${nativeWei} wei. NOTE: draining an ACTIVE account races its own spending; prefer --native <wei> for a fixed amount.`;
|
||||
} else {
|
||||
nativeWei = full; // migrator forwards msg.value; the EOA still pays gas, so leave headroom beyond this
|
||||
if (full > 0n) reservedNote = `native move = full balance ${full} wei; ensure the migrate tx has gas headroom beyond this (migrator forwards msg.value).`;
|
||||
}
|
||||
}
|
||||
tokens = enumerated.tokens.filter((t) => t.needsMove).map((t) => ({ token: t.token, amountWei: t.balance }));
|
||||
} else if (tokenAddrs.length && args.amounts && args.amounts !== true) {
|
||||
const amts = String(args.amounts).split(',').map((s) => s.trim());
|
||||
tokens = tokenAddrs.map((t, i) => ({ token: t, amountWei: BigInt(amts[i] || '0') }));
|
||||
} else {
|
||||
tokens = tokenAddrs.map((t) => ({ token: t, amountWei: 1n })); // placeholder amount; user sets real value
|
||||
}
|
||||
|
||||
const plan = buildMigrationTransactions({
|
||||
path, eoa, falconPubKey: falcon, salt,
|
||||
tokens, nativeWei,
|
||||
deployAccount: !args['no-deploy'],
|
||||
migrator: args.migrator && args.migrator !== true ? args.migrator : undefined,
|
||||
bindFalcon: !args['no-bind'],
|
||||
});
|
||||
|
||||
if (rpc && args.estimate) await estimateMigrationGas(plan, rpc);
|
||||
|
||||
if (args.json) { console.log(JSON.stringify(plan, (_k, v) => (typeof v === 'bigint' ? v.toString() : v), 2)); return; }
|
||||
|
||||
console.log(`\n${C.b('Aere post-quantum migration')} (path: ${plan.path})`);
|
||||
if (usingSample) console.log(C.dim(" (demo: target derived from the live sample account's real Falcon-512 key)"));
|
||||
if (usingDemoEoa) console.log(C.dim(' (demo EOA 0x..dEaD; pass --eoa 0xYourAccount for a real build)'));
|
||||
console.log(` from EOA: ${plan.eoa}`);
|
||||
console.log(` factory (live): ${plan.factory}`);
|
||||
console.log(` target account: ${C.b(plan.target)}`);
|
||||
if (enumerated) console.log(` enumerated: ${enumerated.movable} movable asset(s) via ${rpc}`);
|
||||
if (reservedNote) console.log(` ${C.dim(reservedNote)}`);
|
||||
console.log('');
|
||||
console.log(` ${C.b('Unsigned transactions')} (${plan.transactions.length}). Sign each in your own wallet, in order:`);
|
||||
for (const item of plan.transactions) {
|
||||
console.log(`\n Step ${item.step}. [${item.kind}] ${C.dim('(signed: ' + item.signed + ')')}`);
|
||||
console.log(` ${item.description}`);
|
||||
console.log(` to: ${item.tx.to}`);
|
||||
console.log(` value: ${item.tx.value}`);
|
||||
console.log(` data: ${item.tx.data.length > 50 ? item.tx.data.slice(0, 50) + '..' : item.tx.data} (${(item.tx.data.length - 2) / 2} bytes)`);
|
||||
if (item.gasEstimate != null) console.log(` gas (live estimate): ${item.gasEstimate.toLocaleString()}`);
|
||||
if (item.gasEstimateError) console.log(` gas: ${C.dim('estimate n/a (' + item.gasEstimateError + ')')}`);
|
||||
for (const w of item.warnings || []) console.log(` ${C.y('warn')}: ${w}`);
|
||||
}
|
||||
if (plan.warnings.length) {
|
||||
console.log(`\n ${C.b('Warnings')}:`);
|
||||
for (const w of plan.warnings) console.log(` - ${w}`);
|
||||
}
|
||||
console.log(`\n ${C.dim(plan.scope)}\n`);
|
||||
}
|
||||
|
||||
// ── readiness ────────────────────────────────────────────────────────────────
|
||||
async function cmdReadiness(args) {
|
||||
const addr = args._[0];
|
||||
if (!addr || !isValidAddress(addr)) { console.error('readiness needs <address>'); process.exit(1); }
|
||||
const rpc = args.rpc === true ? DEFAULT_RPC : (args.rpc || DEFAULT_RPC);
|
||||
const scan = await scanLive(addr, rpc);
|
||||
const tokenAddrs = args.tokens && args.tokens !== true ? String(args.tokens).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const assets = await enumerateAssets({ rpcUrl: rpc, owner: addr, tokens: tokenAddrs });
|
||||
const report = buildReadinessReport({
|
||||
scan, assets,
|
||||
scheme: args.scheme && args.scheme !== true ? args.scheme : 'falcon512_solidity',
|
||||
path: args.path && args.path !== true ? args.path : 'direct',
|
||||
projectedAuths: args.auths ? parseInt(args.auths, 10) : 1,
|
||||
gwei: args.gwei ? parseFloat(args.gwei) : undefined,
|
||||
});
|
||||
if (args.json) { console.log(JSON.stringify(report, null, 2)); return; }
|
||||
console.log(formatReadinessReport(report));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0];
|
||||
if (!cmd || cmd === '--help' || cmd === '-h') topUsage(0);
|
||||
const args = parseArgs(argv.slice(1));
|
||||
if (args.help) {
|
||||
console.log(`See: aere-pqc ${cmd} ... (schemes: sim=${schemeList().join(',')} ; readiness=${readinessSchemeList().join(',')})`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (cmd === 'scan') await cmdScan(args);
|
||||
else if (cmd === 'simulate') cmdSimulate(args);
|
||||
else if (cmd === 'build-migration') await cmdBuildMigration(args);
|
||||
else if (cmd === 'readiness') await cmdReadiness(args);
|
||||
else { console.error(`unknown command: ${cmd}`); topUsage(1); }
|
||||
} catch (e) {
|
||||
console.error(`error: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
1
fixtures/AereFalcon512Verifier.hex
Normal file
1
fixtures/AereFalcon512Verifier.hex
Normal file
File diff suppressed because one or more lines are too long
1
fixtures/AereHybridAuth.hex
Normal file
1
fixtures/AereHybridAuth.hex
Normal file
File diff suppressed because one or more lines are too long
1
fixtures/AerePQCAttestation.hex
Normal file
1
fixtures/AerePQCAttestation.hex
Normal file
File diff suppressed because one or more lines are too long
1
fixtures/AerePQCTxAccount.hex
Normal file
1
fixtures/AerePQCTxAccount.hex
Normal file
File diff suppressed because one or more lines are too long
25
fixtures/index.json
Normal file
25
fixtures/index.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"note": "Real deployed runtime bytecode extracted from this repo's compiled Hardhat artifacts (aerenew/contracts/artifacts/contracts/pqc/*). Used by the scanner's offline path and by selftest.js. Extracted 2026-07-19.",
|
||||
"fixtures": {
|
||||
"AerePQCAttestation": {
|
||||
"source": "contracts/artifacts/contracts/pqc/AerePQCAttestation.sol/AerePQCAttestation.json",
|
||||
"expectedReadiness": "GREEN",
|
||||
"why": "Directly staticcalls the live native PQC precompiles 0x0AE1..0x0AE4."
|
||||
},
|
||||
"AerePQCTxAccount": {
|
||||
"source": "contracts/artifacts/contracts/pqc/AerePQCTxAccount.sol/AerePQCTxAccount.json",
|
||||
"expectedReadiness": "GREEN",
|
||||
"why": "ERC-4337 sender auth via a direct staticcall to 0x0AE1..0x0AE4."
|
||||
},
|
||||
"AereHybridAuth": {
|
||||
"source": "contracts/artifacts/contracts/pqc/AereHybridAuth.sol/AereHybridAuth.json",
|
||||
"expectedReadiness": "RED",
|
||||
"why": "Semantically ECDSA + Falcon hybrid, BUT verifies Falcon via the SOLIDITY AereFalcon512Verifier contract (0x4E8e...D8fFC), not the native precompile band, so bytecode scanning cannot see the PQC leg. This is the scanner's documented scope limit, kept as a fixture so it cannot silently regress."
|
||||
},
|
||||
"AereFalcon512Verifier": {
|
||||
"source": "contracts/artifacts/contracts/pqc/AereFalcon512Verifier.sol/AereFalcon512Verifier.json",
|
||||
"expectedReadiness": "RED",
|
||||
"why": "The heavy in-EVM Falcon verifier. Its 0x0100 PUSH2 constants are lattice-math literals (the number 256), not P-256 precompile calls, so the call-proximity gate correctly does not flag P-256."
|
||||
}
|
||||
}
|
||||
}
|
||||
107
lib/abi.js
Normal file
107
lib/abi.js
Normal file
@ -0,0 +1,107 @@
|
||||
// abi.js, a small, zero-dependency ABI encoder covering exactly the type set the
|
||||
// migration SDK needs to build AereAccountMigrator and ERC-20 calldata:
|
||||
//
|
||||
// uint256, address, bool, bytes, address[], uint256[]
|
||||
//
|
||||
// It implements the standard Solidity head/tail layout (dynamic parameters get a
|
||||
// 32-byte offset in the head and their bytes in the tail). It is validated
|
||||
// byte-for-byte against ethers v6 in selftest.js, so the calldata this toolkit
|
||||
// hands a user to sign is provably identical to what a full ABI library emits.
|
||||
//
|
||||
// This file ENCODES calldata only. It never signs, deploys, or broadcasts.
|
||||
|
||||
import { keccak256 } from './keccak.js';
|
||||
|
||||
function stripHex(h) {
|
||||
return h.startsWith('0x') || h.startsWith('0X') ? h.slice(2) : h;
|
||||
}
|
||||
|
||||
function uint256Hex(value) {
|
||||
const v = BigInt(value);
|
||||
if (v < 0n) throw new Error('uint256 cannot be negative');
|
||||
const hex = v.toString(16);
|
||||
if (hex.length > 64) throw new Error('uint256 overflow');
|
||||
return hex.padStart(64, '0');
|
||||
}
|
||||
|
||||
function addressHex(addr) {
|
||||
const a = stripHex(addr).toLowerCase();
|
||||
if (a.length !== 40 || /[^0-9a-f]/.test(a)) throw new Error(`invalid address: ${addr}`);
|
||||
return a.padStart(64, '0');
|
||||
}
|
||||
|
||||
function boolHex(b) {
|
||||
return (b ? 1n : 0n).toString(16).padStart(64, '0');
|
||||
}
|
||||
|
||||
function isDynamic(type) {
|
||||
return type === 'bytes' || type.endsWith('[]');
|
||||
}
|
||||
|
||||
// Encode a single dynamic value to hex (no 0x), including its own length word.
|
||||
function encodeDynamic(type, value) {
|
||||
if (type === 'bytes') {
|
||||
const h = stripHex(value).toLowerCase();
|
||||
if (h.length % 2 !== 0) throw new Error('bytes must be an even-length hex string');
|
||||
const byteLen = h.length / 2;
|
||||
const padded = h.padEnd(Math.ceil(h.length / 64) * 64, '0');
|
||||
return uint256Hex(byteLen) + padded;
|
||||
}
|
||||
if (type === 'address[]') {
|
||||
if (!Array.isArray(value)) throw new Error('address[] expects an array');
|
||||
return uint256Hex(value.length) + value.map(addressHex).join('');
|
||||
}
|
||||
if (type === 'uint256[]') {
|
||||
if (!Array.isArray(value)) throw new Error('uint256[] expects an array');
|
||||
return uint256Hex(value.length) + value.map(uint256Hex).join('');
|
||||
}
|
||||
throw new Error(`unsupported dynamic type: ${type}`);
|
||||
}
|
||||
|
||||
function encodeStatic(type, value) {
|
||||
if (type === 'uint256') return uint256Hex(value);
|
||||
if (type === 'address') return addressHex(value);
|
||||
if (type === 'bool') return boolHex(value);
|
||||
throw new Error(`unsupported static type: ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ABI-encode a parameter tuple. Returns 0x-prefixed hex.
|
||||
* @param {string[]} types
|
||||
* @param {any[]} values
|
||||
*/
|
||||
export function encodeParameters(types, values) {
|
||||
if (types.length !== values.length) throw new Error('types/values length mismatch');
|
||||
const head = [];
|
||||
const tail = [];
|
||||
let tailOffset = types.length * 32; // bytes; every head slot is 32 bytes
|
||||
for (let i = 0; i < types.length; i++) {
|
||||
const t = types[i];
|
||||
if (isDynamic(t)) {
|
||||
head.push(uint256Hex(tailOffset));
|
||||
const enc = encodeDynamic(t, values[i]);
|
||||
tail.push(enc);
|
||||
tailOffset += enc.length / 2;
|
||||
} else {
|
||||
head.push(encodeStatic(t, values[i]));
|
||||
}
|
||||
}
|
||||
return '0x' + head.join('') + tail.join('');
|
||||
}
|
||||
|
||||
/** 4-byte selector (0x + 8 hex chars) for a canonical function signature. */
|
||||
export function functionSelector(signature) {
|
||||
const h = keccak256(new TextEncoder().encode(signature));
|
||||
let s = '0x';
|
||||
for (let i = 0; i < 4; i++) s += h[i].toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full calldata: selector(signature) ++ encodeParameters(types, values).
|
||||
* `signature` must be the canonical form, e.g. "migrate(address,address[],uint256[],bool)".
|
||||
* @returns {string} 0x-prefixed calldata
|
||||
*/
|
||||
export function encodeFunctionData(signature, types, values) {
|
||||
return functionSelector(signature) + encodeParameters(types, values).slice(2);
|
||||
}
|
||||
257
lib/bytecode.js
Normal file
257
lib/bytecode.js
Normal file
@ -0,0 +1,257 @@
|
||||
// bytecode.js, opcode-aware static analysis of EVM runtime bytecode for
|
||||
// quantum exposure. Zero dependencies.
|
||||
//
|
||||
// Two techniques make this honest rather than a grep:
|
||||
//
|
||||
// 1. Opcode-aware decoding. A naive search for "610ae1" (PUSH2 0x0AE1) in the
|
||||
// hex produces false positives, because those bytes can appear inside
|
||||
// another PUSH's immediate data or straddle an instruction boundary. The
|
||||
// decoder walks the instruction stream, skipping each PUSH's immediate, so a
|
||||
// matched constant is a genuine PUSH of that value.
|
||||
//
|
||||
// 2. Local stack simulation to resolve CALL targets. The real question is not
|
||||
// "does the constant 0x0AE1 appear" but "is a CALL/STATICCALL actually made
|
||||
// TO that address". We simulate the stack per basic block (reset at
|
||||
// JUMPDEST / after JUMP,JUMPI / after terminators) tracking constant PUSH
|
||||
// values, and at every CALL-family opcode we read the address argument (the
|
||||
// 2nd stack item). This distinguishes a precompile call to 0x0AE1 from the
|
||||
// bare number 0x0AE5 used as a memory length near an unrelated staticcall
|
||||
// (the false positive a proximity heuristic would hit).
|
||||
//
|
||||
// Fallback: the constants 0x0AE1..0x0AE4 (2785..2788) are not round numbers or
|
||||
// common offsets, so a genuine opcode-aligned PUSH of one is a medium-confidence
|
||||
// signal even when the CALL target is loaded from memory and cannot be resolved
|
||||
// locally. The common literals 0x01, 0x100 and 0x0AE5 are NEVER used as a
|
||||
// fallback signal; they only count when stack simulation resolves them as an
|
||||
// actual CALL target.
|
||||
|
||||
import { CLASSICAL, PQC, knownAddressTable } from './precompiles.js';
|
||||
|
||||
const CALL_NAME = { 0xf1: 'CALL', 0xf2: 'CALLCODE', 0xf4: 'DELEGATECALL', 0xfa: 'STATICCALL' };
|
||||
const CALL_OPS = new Set([0xf1, 0xf2, 0xf4, 0xfa]);
|
||||
// pops before the address arg is consumed: CALL/CALLCODE take (gas,addr,value,..)
|
||||
// STATICCALL/DELEGATECALL take (gas,addr,..); in all four addr is the 2nd item.
|
||||
const CALL_ARITY = { 0xf1: 7, 0xf2: 7, 0xf4: 6, 0xfa: 6 };
|
||||
const HALTS = new Set([0x00, 0xf3, 0xfd, 0xfe, 0xff]); // STOP RETURN REVERT INVALID SELFDESTRUCT
|
||||
|
||||
// (pops, pushes) for opcodes whose stack effect we model to keep alignment.
|
||||
// PUSH/DUP/SWAP/CALL-family are handled specially and are not in this table.
|
||||
const ARITY = {
|
||||
0x01: [2, 1], 0x02: [2, 1], 0x03: [2, 1], 0x04: [2, 1], 0x05: [2, 1], 0x06: [2, 1], 0x07: [2, 1],
|
||||
0x08: [3, 1], 0x09: [3, 1], 0x0a: [2, 1], 0x0b: [2, 1],
|
||||
0x10: [2, 1], 0x11: [2, 1], 0x12: [2, 1], 0x13: [2, 1], 0x14: [2, 1], 0x15: [1, 1],
|
||||
0x16: [2, 1], 0x17: [2, 1], 0x18: [2, 1], 0x19: [1, 1], 0x1a: [2, 1], 0x1b: [2, 1], 0x1c: [2, 1], 0x1d: [2, 1],
|
||||
0x20: [2, 1],
|
||||
0x30: [0, 1], 0x31: [1, 1], 0x32: [0, 1], 0x33: [0, 1], 0x34: [0, 1], 0x35: [1, 1], 0x36: [0, 1],
|
||||
0x37: [3, 0], 0x38: [0, 1], 0x39: [3, 0], 0x3a: [0, 1], 0x3b: [1, 1], 0x3c: [4, 0], 0x3d: [0, 1],
|
||||
0x3e: [3, 0], 0x3f: [1, 1],
|
||||
0x40: [1, 1], 0x41: [0, 1], 0x42: [0, 1], 0x43: [0, 1], 0x44: [0, 1], 0x45: [0, 1], 0x46: [0, 1],
|
||||
0x47: [0, 1], 0x48: [0, 1], 0x49: [1, 1], 0x4a: [0, 1],
|
||||
0x50: [1, 0], 0x51: [1, 1], 0x52: [2, 0], 0x53: [2, 0], 0x54: [1, 1], 0x55: [2, 0],
|
||||
0x56: [1, 0], 0x57: [2, 0], 0x58: [0, 1], 0x59: [0, 1], 0x5a: [0, 1], 0x5b: [0, 0],
|
||||
0x5c: [1, 1], 0x5d: [2, 0], 0x5e: [3, 0],
|
||||
0xa0: [2, 0], 0xa1: [3, 0], 0xa2: [4, 0], 0xa3: [5, 0], 0xa4: [6, 0],
|
||||
0xf0: [3, 1], 0xf5: [4, 1],
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode runtime bytecode into a flat instruction list.
|
||||
* @param {Uint8Array} code
|
||||
*/
|
||||
export function decode(code) {
|
||||
const ops = [];
|
||||
let pc = 0;
|
||||
let i = 0;
|
||||
while (pc < code.length) {
|
||||
const op = code[pc];
|
||||
if (op >= 0x60 && op <= 0x7f) {
|
||||
const n = op - 0x5f;
|
||||
let val = 0n;
|
||||
for (let k = 1; k <= n && pc + k < code.length; k++) val = (val << 8n) | BigInt(code[pc + k]);
|
||||
ops.push({ i: i++, pc, op, push: val, pushLen: n });
|
||||
pc += 1 + n;
|
||||
} else {
|
||||
ops.push({ i: i++, pc, op });
|
||||
pc += 1;
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve CALL-family targets via per-basic-block local stack simulation.
|
||||
* @returns {Array<{op:number, name:string, pc:number, target:bigint|null}>}
|
||||
*/
|
||||
export function resolveCallTargets(ops) {
|
||||
const calls = [];
|
||||
let stack = []; // items: bigint (known) or null (unknown)
|
||||
|
||||
const pop = () => (stack.length ? stack.pop() : null);
|
||||
|
||||
for (const o of ops) {
|
||||
const op = o.op;
|
||||
|
||||
if (op === 0x5b) { stack = []; continue; } // JUMPDEST starts a new block
|
||||
|
||||
if (op >= 0x60 && op <= 0x7f) { stack.push(o.push); continue; } // PUSHn (0x5f PUSH0 handled below)
|
||||
if (op === 0x5f) { stack.push(0n); continue; } // PUSH0
|
||||
|
||||
if (op >= 0x80 && op <= 0x8f) { // DUPk
|
||||
const k = op - 0x80 + 1;
|
||||
stack.push(stack.length >= k ? stack[stack.length - k] : null);
|
||||
continue;
|
||||
}
|
||||
if (op >= 0x90 && op <= 0x9f) { // SWAPk
|
||||
const k = op - 0x90 + 1;
|
||||
const idx = stack.length - 1 - k;
|
||||
if (stack.length >= 1 && idx >= 0) {
|
||||
const top = stack.length - 1;
|
||||
const tmp = stack[top]; stack[top] = stack[idx]; stack[idx] = tmp;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CALL_OPS.has(op)) {
|
||||
// address is the 2nd stack item from the top (below gas)
|
||||
const target = stack.length >= 2 ? stack[stack.length - 2] : null;
|
||||
calls.push({ op, name: CALL_NAME[op], pc: o.pc, target });
|
||||
for (let k = 0; k < CALL_ARITY[op]; k++) pop();
|
||||
stack.push(null); // success flag
|
||||
if (op === 0x56 || op === 0x57) stack = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op === 0x56 || op === 0x57) { // JUMP / JUMPI
|
||||
const arity = ARITY[op];
|
||||
for (let k = 0; k < arity[0]; k++) pop();
|
||||
stack = []; // next instruction is a new block
|
||||
continue;
|
||||
}
|
||||
|
||||
if (HALTS.has(op)) {
|
||||
const a = ARITY[op];
|
||||
if (a) for (let k = 0; k < a[0]; k++) pop();
|
||||
stack = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const a = ARITY[op];
|
||||
if (a) {
|
||||
for (let k = 0; k < a[0]; k++) pop();
|
||||
for (let k = 0; k < a[1]; k++) stack.push(null);
|
||||
} else {
|
||||
// unknown opcode: be conservative, drop the block alignment
|
||||
stack = [];
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze runtime bytecode for classical and post-quantum precompile usage.
|
||||
* @param {Uint8Array} code runtime bytecode (empty => caller treats as EOA)
|
||||
*/
|
||||
// SINGURA SURSA a vocabularului de verdicte. Exportata anume, fiindca doua consumatoare si-au
|
||||
// scris fiecare propria lista si una a scris AMBER acolo unde scanerul scrie YELLOW: un contract
|
||||
// hibrid, adica exact clientul care a inceput deja migrarea si e cel mai probabil sa plateasca,
|
||||
// cadea in NOT MEASURED atat in raportul platit cat si in scanul gratuit. Masurat 2026-08-16.
|
||||
// Cine adauga un verdict il adauga AICI, si consumatoarele il primesc fara sa fie atinse.
|
||||
export const VERDICTE = Object.freeze(['GREEN', 'YELLOW', 'RED']);
|
||||
|
||||
// Cum se citeste verdictul dintr-o iesire de text. Consumatoarele cheama ASTA, nu isi scriu
|
||||
// propriul tipar: un tipar copiat e a doua sursa a aceluiasi adevar, si exact asta a fost
|
||||
// defectul. Nu foloseste expresii regulate, ca sa nu existe nici macar un sir de escapat gresit.
|
||||
// Intoarce numele verdictului, sau null daca textul nu poarta niciunul.
|
||||
export function citesteVerdict(text) {
|
||||
for (const linie of String(text).split(/\r?\n/)) {
|
||||
const cuvant = linie.trim().split(/[^A-Z]+/).filter(Boolean)[0];
|
||||
if (cuvant && VERDICTE.includes(cuvant)) return cuvant;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function analyzeBytecode(code) {
|
||||
const ops = decode(code);
|
||||
const table = knownAddressTable();
|
||||
const calls = resolveCallTargets(ops);
|
||||
|
||||
// Resolved CALL targets that land on a known band address (definitive).
|
||||
const resolved = calls
|
||||
.filter((c) => c.target !== null && table.has(c.target))
|
||||
.map((c) => ({ ...table.get(c.target), address: '0x' + c.target.toString(16), callOpcode: c.name, pc: c.pc }));
|
||||
|
||||
const pqcResolved = resolved.filter((r) => r.band === 'pqc-live');
|
||||
const pqcTestnetResolved = resolved.filter((r) => r.band === 'pqc-testnet');
|
||||
const ecrecoverResolved = resolved.filter((r) => r.key === 'ecrecover');
|
||||
const p256Resolved = resolved.filter((r) => r.key === 'p256');
|
||||
|
||||
// Fallback presence signal: an opcode-aligned PUSH of a SIGNATURE-verify PQC
|
||||
// address 0x0AE1..0x0AE4 (not 0x0AE5, not the common literals). Medium
|
||||
// confidence: the constant is genuinely present even if we could not resolve
|
||||
// the CALL target locally (e.g. it was staged through memory).
|
||||
const sigPqcAddrs = new Set([PQC.falcon512.address, PQC.falcon1024.address, PQC.mldsa44.address, PQC.slhdsa128s.address]);
|
||||
const pqcConstPresent = [];
|
||||
for (const o of ops) {
|
||||
if (o.push !== undefined && sigPqcAddrs.has(o.push)) {
|
||||
const d = table.get(o.push);
|
||||
pqcConstPresent.push({ address: '0x' + o.push.toString(16), name: d.name, pc: o.pc });
|
||||
}
|
||||
}
|
||||
|
||||
const uniqByAddr = (arr) => {
|
||||
const m = new Map();
|
||||
for (const x of arr) if (!m.has(x.address)) m.set(x.address, x);
|
||||
return [...m.values()];
|
||||
};
|
||||
|
||||
const pqcResolvedU = uniqByAddr(pqcResolved);
|
||||
const pqcConstU = uniqByAddr(pqcConstPresent).filter((c) => !pqcResolvedU.find((r) => r.address === c.address));
|
||||
|
||||
const pqcUsed = pqcResolvedU.length > 0 || pqcConstU.length > 0;
|
||||
const classicalUsed = ecrecoverResolved.length > 0 || p256Resolved.length > 0;
|
||||
|
||||
let readiness;
|
||||
if (pqcUsed && classicalUsed) readiness = 'YELLOW';
|
||||
else if (pqcUsed) readiness = 'GREEN';
|
||||
else readiness = 'RED';
|
||||
|
||||
const pqcLivePrecompiles = [
|
||||
...pqcResolvedU.map((r) => ({ address: r.address, name: r.name, callProximate: true, confidence: 'high' })),
|
||||
...pqcConstU.map((c) => ({ address: c.address, name: c.name, callProximate: false, confidence: 'medium' })),
|
||||
];
|
||||
|
||||
return {
|
||||
kind: 'contract',
|
||||
readiness,
|
||||
bytecodeLength: code.length,
|
||||
signals: {
|
||||
pqcLivePrecompiles,
|
||||
pqcTestnetPrecompiles: uniqByAddr(pqcTestnetResolved).map((r) => ({ address: r.address, name: r.name })),
|
||||
p256: p256Resolved.length > 0
|
||||
? { address: '0x100', name: CLASSICAL.p256.name, count: p256Resolved.length, confidence: 'high' }
|
||||
: null,
|
||||
ecrecover: ecrecoverResolved.length > 0
|
||||
? { address: '0x1', name: CLASSICAL.ecrecover.name, count: ecrecoverResolved.length, confidence: 'high' }
|
||||
: null,
|
||||
},
|
||||
flags: buildFlags({ pqcResolvedU, pqcConstU, pqcTestnetResolved, pqcUsed }),
|
||||
detail: {
|
||||
callsAnalyzed: calls.length,
|
||||
resolvedBandCalls: resolved.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildFlags({ pqcResolvedU, pqcConstU, pqcTestnetResolved, pqcUsed }) {
|
||||
const flags = [];
|
||||
if (pqcConstU.length > 0 && pqcResolvedU.length === 0) {
|
||||
flags.push('[VERIFY] A live PQC precompile address (0x0AE1..0x0AE4) is pushed but no CALL to it was resolved by local stack analysis; the call target may be staged through memory. Confirm against source or a call trace.');
|
||||
}
|
||||
if (pqcTestnetResolved.length > 0) {
|
||||
flags.push('[VERIFY] Call to a TESTNET-only PQC precompile (0x0AE6..0x0AE8); these are NOT live on mainnet 2800 and return empty there.');
|
||||
}
|
||||
if (!pqcUsed) {
|
||||
flags.push('Bytecode scanning only sees NATIVE precompile calls (0x0AE1..0x0AE5). A contract that verifies Falcon via the SOLIDITY AereFalcon512Verifier (0x4E8e...D8fFC) delegates to a normal contract address and will read as RED here even though it is PQC-capable. Confirm the auth path against source.');
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
142
lib/keccak.js
Normal file
142
lib/keccak.js
Normal file
@ -0,0 +1,142 @@
|
||||
// keccak.js, a compact, zero-dependency keccak256 (Ethereum's Keccak, 0x01
|
||||
// domain padding, NOT FIPS-202 SHA3). Pure Node built-ins, BigInt lanes.
|
||||
//
|
||||
// This exists so the migration SDK can derive CREATE2 account addresses and
|
||||
// EIP-55 checksums with no runtime dependencies, matching the create-aere-pqc
|
||||
// tool's "zero runtime dependencies" style. Correctness is proven two ways in
|
||||
// selftest.js: the empty-string vector, and a real on-chain CREATE2 address.
|
||||
|
||||
const MASK64 = (1n << 64n) - 1n;
|
||||
|
||||
const ROUND_CONSTANTS = [
|
||||
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
|
||||
0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
|
||||
0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
||||
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
|
||||
0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
|
||||
0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
||||
];
|
||||
|
||||
// Rho rotation offsets, flat-indexed by (x + 5*y).
|
||||
const ROT = [
|
||||
0, 1, 62, 28, 27,
|
||||
36, 44, 6, 55, 20,
|
||||
3, 10, 43, 25, 39,
|
||||
41, 45, 15, 21, 8,
|
||||
18, 2, 61, 56, 14,
|
||||
];
|
||||
|
||||
function rotl64(x, n) {
|
||||
const b = BigInt(n) % 64n;
|
||||
if (b === 0n) return x & MASK64;
|
||||
return ((x << b) | (x >> (64n - b))) & MASK64;
|
||||
}
|
||||
|
||||
function keccakF(state) {
|
||||
for (let round = 0; round < 24; round++) {
|
||||
// theta
|
||||
const C = new Array(5);
|
||||
for (let x = 0; x < 5; x++) {
|
||||
C[x] = state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20];
|
||||
}
|
||||
const D = new Array(5);
|
||||
for (let x = 0; x < 5; x++) {
|
||||
D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1n);
|
||||
}
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) state[x + 5 * y] ^= D[x];
|
||||
}
|
||||
// rho + pi
|
||||
const B = new Array(25).fill(0n);
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(state[x + 5 * y], ROT[x + 5 * y]);
|
||||
}
|
||||
}
|
||||
// chi
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
state[x + 5 * y] = B[x + 5 * y] ^ ((~B[((x + 1) % 5) + 5 * y] & MASK64) & B[((x + 2) % 5) + 5 * y]);
|
||||
}
|
||||
}
|
||||
// iota
|
||||
state[0] ^= ROUND_CONSTANTS[round];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* keccak256 over a Uint8Array, returning a 32-byte Uint8Array.
|
||||
* @param {Uint8Array} bytes
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function keccak256(bytes) {
|
||||
const RATE = 136; // 1088-bit rate for keccak256
|
||||
const state = new Array(25).fill(0n);
|
||||
|
||||
// pad10*1 with Ethereum/Keccak domain byte 0x01
|
||||
const padLen = RATE - (bytes.length % RATE);
|
||||
const padded = new Uint8Array(bytes.length + padLen);
|
||||
padded.set(bytes, 0);
|
||||
padded[bytes.length] |= 0x01;
|
||||
padded[padded.length - 1] |= 0x80;
|
||||
|
||||
for (let off = 0; off < padded.length; off += RATE) {
|
||||
for (let i = 0; i < RATE; i++) {
|
||||
const lane = i >> 3;
|
||||
const shift = BigInt((i & 7) * 8);
|
||||
state[lane] ^= BigInt(padded[off + i]) << shift;
|
||||
}
|
||||
keccakF(state);
|
||||
}
|
||||
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const lane = i >> 3;
|
||||
const shift = BigInt((i & 7) * 8);
|
||||
out[i] = Number((state[lane] >> shift) & 0xffn);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── small hex helpers (no dependency on ethers) ──────────────────────────────
|
||||
|
||||
/** Convert a hex string (with or without 0x) to a Uint8Array. */
|
||||
export function hexToBytes(hex) {
|
||||
let h = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex;
|
||||
if (h.length % 2 !== 0) h = '0' + h;
|
||||
const out = new Uint8Array(h.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a Uint8Array to a 0x-prefixed hex string. */
|
||||
export function bytesToHex(bytes) {
|
||||
let s = '0x';
|
||||
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
/** keccak256 of a hex string input, returning a 0x-prefixed 32-byte hex string. */
|
||||
export function keccak256Hex(hexInput) {
|
||||
return bytesToHex(keccak256(hexToBytes(hexInput)));
|
||||
}
|
||||
|
||||
/**
|
||||
* EIP-55 mixed-case checksum of a 20-byte address hex string.
|
||||
* @param {string} address 0x-prefixed 20-byte address (any case)
|
||||
* @returns {string} checksummed address
|
||||
*/
|
||||
export function toChecksumAddress(address) {
|
||||
const addr = (address.startsWith('0x') ? address.slice(2) : address).toLowerCase();
|
||||
const hashBytes = keccak256(new TextEncoder().encode(addr));
|
||||
// hex of the hash, one nibble per address char
|
||||
let hashHex = '';
|
||||
for (const b of hashBytes) hashHex += b.toString(16).padStart(2, '0');
|
||||
let out = '0x';
|
||||
for (let i = 0; i < addr.length; i++) {
|
||||
const c = addr[i];
|
||||
if (c >= '0' && c <= '9') out += c;
|
||||
else out += parseInt(hashHex[i], 16) >= 8 ? c.toUpperCase() : c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
245
lib/migrate.js
Normal file
245
lib/migrate.js
Normal file
@ -0,0 +1,245 @@
|
||||
// migrate.js, the migration SDK. Zero-dependency helpers to move an
|
||||
// ECDSA-controlled account toward a post-quantum account using Aere's EXISTING,
|
||||
// live primitives:
|
||||
//
|
||||
// AerePQCAccountFactory (CREATE2 factory, LIVE) 0xd5315Ea7...CE58
|
||||
// -> AerePQCAccount (Falcon-512 owned ERC-4337 account, LIVE sample)
|
||||
// AereHybridAuth (ECDSA + Falcon-512, repo source; NOT yet on mainnet)
|
||||
// AereFalcon512Verifier (Solidity verifier the two accounts delegate to, LIVE)
|
||||
// AerePQCKeyRegistry (proof-of-possession key registry, LIVE)
|
||||
// EIP-7702 (EOA delegation, supported at protocol level)
|
||||
//
|
||||
// This file derives counterfactual account addresses, builds the calldata for
|
||||
// the on-chain steps, and returns an honest, step-by-step migration plan. It
|
||||
// does NOT sign or broadcast anything.
|
||||
|
||||
import { keccak256, hexToBytes, bytesToHex, toChecksumAddress } from './keccak.js';
|
||||
|
||||
// ── canonical addresses (chain 2800) ─────────────────────────────────────────
|
||||
// live=true means confirmed on mainnet 2800 (address book / deployment json).
|
||||
// live=false means the contract exists in the repo but has no mainnet address
|
||||
// in the address book; deploying it is a separate, deployer/founder-gated step.
|
||||
// [CITED: aerenew/sdk-js/src/addresses.ts; aerenew/contracts/deployments/pqc-account.json]
|
||||
export const ADDRESSES = {
|
||||
AerePQCAccountFactory: { address: '0xd5315Ea7caa60d320c4f34b1bEd70dd9cc02CE58', live: true },
|
||||
AerePQCAccount_sample: { address: '0xa42a5e7F72E46BadC11367650Ec34D676194326f', live: true },
|
||||
AereEntryPointV2: { address: '0x8D6f40598d552fF0Cb358b6012cF4227B86aF770', live: true },
|
||||
AereFalcon512Verifier: { address: '0x4E8e9682329e646784fB3bd01430aA4bA54D8fFC', live: true },
|
||||
AerePQCKeyRegistry: { address: '0x1eCa3c5ADcBD0b22636D8672b00faC6D89363691', live: true },
|
||||
AereHybridAuthorizer: { address: '0x168F2A6a3071e7654CF1784a6f5d7BC8e1a582E0', live: true },
|
||||
// Repo source only: no mainnet address in the address book as of 2026-07-19.
|
||||
AereHybridAuth: { address: null, live: false, note: 'repo source (contracts/pqc/AereHybridAuth.sol); not deployed to mainnet. [VERIFY]' },
|
||||
};
|
||||
|
||||
// CREATE2 init-code hash of AerePQCAccount (no constructor args), read from the
|
||||
// live factory deploy record. [CITED: contracts/deployments/pqc-account.json]
|
||||
export const ACCOUNT_INIT_CODE_HASH = '0x05dd938d3980ee22354b9635703e916187d0323259d2c9468954382245bb10ce';
|
||||
|
||||
export const FALCON512_PK_LEN = 897;
|
||||
export const FALCON512_PK_HEADER = 0x09;
|
||||
|
||||
// ── minimal ABI encoder (only the types this SDK needs) ──────────────────────
|
||||
// Supports: 'uint256', 'address', 'bytes32', 'bytes'. Matches Solidity abi.encode.
|
||||
function encode32Uint(value) {
|
||||
let hex = BigInt(value).toString(16);
|
||||
return hex.padStart(64, '0');
|
||||
}
|
||||
function encode32Address(addr) {
|
||||
const a = (addr.startsWith('0x') ? addr.slice(2) : addr).toLowerCase();
|
||||
return a.padStart(64, '0');
|
||||
}
|
||||
function encode32Bytes32(b) {
|
||||
const h = (b.startsWith('0x') ? b.slice(2) : b).toLowerCase();
|
||||
if (h.length !== 64) throw new Error('bytes32 must be 32 bytes');
|
||||
return h;
|
||||
}
|
||||
function encodeDynBytes(hex) {
|
||||
const h = (hex.startsWith('0x') ? hex.slice(2) : hex).toLowerCase();
|
||||
const byteLen = h.length / 2;
|
||||
const lenWord = encode32Uint(byteLen);
|
||||
const padded = h.padEnd(Math.ceil(h.length / 64) * 64, '0');
|
||||
return lenWord + padded;
|
||||
}
|
||||
|
||||
/**
|
||||
* abi.encode(types, values) for the supported type set. Returns 0x-hex.
|
||||
*/
|
||||
export function abiEncode(types, values) {
|
||||
const isDynamic = (t) => t === 'bytes';
|
||||
const headParts = [];
|
||||
const tailParts = [];
|
||||
let tailOffset = types.length * 32;
|
||||
for (let k = 0; k < types.length; k++) {
|
||||
const t = types[k];
|
||||
const v = values[k];
|
||||
if (isDynamic(t)) {
|
||||
headParts.push(encode32Uint(tailOffset));
|
||||
const enc = encodeDynBytes(v);
|
||||
tailParts.push(enc);
|
||||
tailOffset += enc.length / 2;
|
||||
} else if (t === 'uint256') headParts.push(encode32Uint(v));
|
||||
else if (t === 'address') headParts.push(encode32Address(v));
|
||||
else if (t === 'bytes32') headParts.push(encode32Bytes32(v));
|
||||
else throw new Error(`unsupported abi type: ${t}`);
|
||||
}
|
||||
return '0x' + headParts.join('') + tailParts.join('');
|
||||
}
|
||||
|
||||
/** 4-byte function selector (8 hex chars, NO 0x prefix) from a signature string. */
|
||||
export function selector(signature) {
|
||||
const h = keccak256(new TextEncoder().encode(signature));
|
||||
return bytesToHex(h.slice(0, 4)).slice(2);
|
||||
}
|
||||
|
||||
/** Build 0x-prefixed calldata: selector(sig) ++ abiEncode(types, values). */
|
||||
export function encodeCall(signature, types, values) {
|
||||
const sel = selector(signature);
|
||||
const args = abiEncode(types, values).slice(2);
|
||||
return '0x' + sel + args;
|
||||
}
|
||||
|
||||
// ── Falcon public-key validation ─────────────────────────────────────────────
|
||||
export function validateFalconPubKey(pubKeyHex) {
|
||||
const bytes = hexToBytes(pubKeyHex);
|
||||
if (bytes.length !== FALCON512_PK_LEN) {
|
||||
throw new Error(`Falcon-512 public key must be ${FALCON512_PK_LEN} bytes, got ${bytes.length}`);
|
||||
}
|
||||
if (bytes[0] !== FALCON512_PK_HEADER) {
|
||||
throw new Error(`Falcon-512 public key must start with header byte 0x09, got 0x${bytes[0].toString(16)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── CREATE2 account-address derivation ───────────────────────────────────────
|
||||
// Mirrors AerePQCAccountFactory.predictAddress exactly:
|
||||
// saltMix = keccak256(abi.encode(falconPubKey, salt))
|
||||
// account = keccak256(0xff ++ factory ++ saltMix ++ ACCOUNT_INIT_CODE_HASH)[12:]
|
||||
|
||||
/** saltMix = keccak256(abi.encode(bytes falconPubKey, uint256 salt)). */
|
||||
export function computeSaltMix(falconPubKeyHex, salt) {
|
||||
const encoded = abiEncode(['bytes', 'uint256'], [falconPubKeyHex, salt]);
|
||||
return bytesToHex(keccak256(hexToBytes(encoded)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict the counterfactual AerePQCAccount address for (falconPubKey, salt).
|
||||
* @param {object} p
|
||||
* @param {string} p.falconPubKey 897-byte Falcon-512 public key (0x-hex, 0x09 header)
|
||||
* @param {number|bigint} p.salt CREATE2 salt
|
||||
* @param {string} [p.factory] factory address (defaults to the live factory)
|
||||
* @param {string} [p.initCodeHash] account init-code hash (defaults to the live one)
|
||||
* @returns {string} checksummed account address
|
||||
*/
|
||||
export function predictPqcAccountAddress({ falconPubKey, salt = 0, factory, initCodeHash } = {}) {
|
||||
validateFalconPubKey(falconPubKey);
|
||||
const fac = factory || ADDRESSES.AerePQCAccountFactory.address;
|
||||
const ich = initCodeHash || ACCOUNT_INIT_CODE_HASH;
|
||||
const saltMix = computeSaltMix(falconPubKey, salt);
|
||||
const pre = new Uint8Array(1 + 20 + 32 + 32);
|
||||
pre[0] = 0xff;
|
||||
pre.set(hexToBytes(fac), 1);
|
||||
pre.set(hexToBytes(saltMix), 21);
|
||||
pre.set(hexToBytes(ich), 53);
|
||||
const hash = keccak256(pre);
|
||||
return toChecksumAddress(bytesToHex(hash.slice(12)));
|
||||
}
|
||||
|
||||
// ── on-chain step calldata builders ──────────────────────────────────────────
|
||||
|
||||
/** AerePQCAccountFactory.createAccount(bytes falconPubKey, uint256 salt). */
|
||||
export function encodeCreateAccount(falconPubKeyHex, salt = 0) {
|
||||
validateFalconPubKey(falconPubKeyHex);
|
||||
return encodeCall('createAccount(bytes,uint256)', ['bytes', 'uint256'], [falconPubKeyHex, salt]);
|
||||
}
|
||||
|
||||
/** AereHybridAuth.registerIdentity(bytes32 identityId, address ecdsaSigner, bytes falconPubKey). */
|
||||
export function encodeHybridRegisterIdentity(identityId, ecdsaSigner, falconPubKeyHex) {
|
||||
validateFalconPubKey(falconPubKeyHex);
|
||||
return encodeCall(
|
||||
'registerIdentity(bytes32,address,bytes)',
|
||||
['bytes32', 'address', 'bytes'],
|
||||
[identityId, ecdsaSigner, falconPubKeyHex],
|
||||
);
|
||||
}
|
||||
|
||||
/** AerePQCKeyRegistry.registerKey(uint8 scheme, bytes pubKey, bytes signature) selector. */
|
||||
export function encodeRegisterKeySelector() {
|
||||
return '0x' + selector('registerKey(uint8,bytes,bytes)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a documented, honest migration plan for moving an ECDSA-controlled
|
||||
* account to a post-quantum account. Returns structured steps; nothing is
|
||||
* signed or broadcast.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {'pqc'|'hybrid'} opts.mode 'pqc' = Falcon-512-only account; 'hybrid' = ECDSA + Falcon.
|
||||
* @param {string} opts.falconPubKey 897-byte Falcon-512 public key (0x-hex).
|
||||
* @param {string} [opts.ecdsaSigner] the classical address keeping a leg (hybrid mode).
|
||||
* @param {number|bigint} [opts.salt] CREATE2 salt (pqc mode).
|
||||
*/
|
||||
export function buildMigrationPlan({ mode = 'pqc', falconPubKey, ecdsaSigner, salt = 0 } = {}) {
|
||||
validateFalconPubKey(falconPubKey);
|
||||
const steps = [];
|
||||
const caveats = [
|
||||
'SCOPE: this migrates ACCOUNT AUTHENTICATION to a post-quantum scheme. It does NOT make Aere consensus post-quantum; mainnet 2800 still seals blocks with classical secp256k1 QBFT.',
|
||||
'The deployed AerePQCAccount / AereHybridAuth verify Falcon via the SOLIDITY AereFalcon512Verifier (0x4E8e...D8fFC), so one authorization costs ~10.5M gas today (measured 10,278,313 in the live userOp receipt), under the EIP-7825 2^24 per-tx cap. A native SHAKE precompile would cut this sharply (separate roadmap item).',
|
||||
];
|
||||
|
||||
if (mode === 'pqc') {
|
||||
const predicted = predictPqcAccountAddress({ falconPubKey, salt });
|
||||
steps.push({
|
||||
n: 1,
|
||||
title: 'Derive the counterfactual PQC account address (off-chain, free)',
|
||||
how: 'predictPqcAccountAddress({ falconPubKey, salt }), pure CREATE2 math, no RPC.',
|
||||
result: predicted,
|
||||
});
|
||||
steps.push({
|
||||
n: 2,
|
||||
title: 'Deploy the PQC account via the LIVE factory (permissionless)',
|
||||
how: 'Send AerePQCAccountFactory.createAccount(falconPubKey, salt) to ' + ADDRESSES.AerePQCAccountFactory.address + '.',
|
||||
calldata: encodeCreateAccount(falconPubKey, salt),
|
||||
note: 'createAccount is permissionless and idempotent; anyone can deploy the counterfactual account, and re-deploying returns the existing one. Sample deploy cost was ~1,493,084 gas. [CITED: pqc-account.json createGasUsed]',
|
||||
});
|
||||
steps.push({
|
||||
n: 3,
|
||||
title: 'Move control: fund the PQC account, then move assets off the old EOA into it',
|
||||
how: 'From the old ECDSA EOA, transfer AERE / tokens / roles to the PQC account address. After this, spending authority is the Falcon-512 key only (no ECDSA fallback).',
|
||||
note: 'Optionally use EIP-7702 (supported on Aere) to have the EOA temporarily delegate execution to account code during the transition, so a single EOA transaction can batch the move.',
|
||||
});
|
||||
caveats.push('The AerePQCAccount is Falcon-512-ONLY (no ECDSA fallback). Losing the Falcon key loses the account. Register the key in AerePQCKeyRegistry (proof-of-possession) and set PQC social-recovery guardians BEFORE moving material value.');
|
||||
} else if (mode === 'hybrid') {
|
||||
if (!ecdsaSigner) throw new Error('hybrid mode requires ecdsaSigner');
|
||||
const identityId = bytesToHex(keccak256(hexToBytes(encodeHybridIdentitySeed(ecdsaSigner, falconPubKey))));
|
||||
steps.push({
|
||||
n: 1,
|
||||
title: 'Deploy AereHybridAuth (deployer/founder-gated)',
|
||||
how: 'AereHybridAuth is repo source with no mainnet address yet. Deploying it (constructor arg = AereFalcon512Verifier ' + ADDRESSES.AereFalcon512Verifier.address + ') is a one-time deploy tx.',
|
||||
note: 'FOUNDER-GATED: a fresh mainnet deploy (or any corrected V2 redeploy of an account factory) is signed by the Foundation/deployer key, not by this toolkit. [VERIFY] AereHybridAuth not currently deployed to 2800.',
|
||||
});
|
||||
steps.push({
|
||||
n: 2,
|
||||
title: 'Register the hybrid identity (permissionless, append-only)',
|
||||
how: 'AereHybridAuth.registerIdentity(identityId, ecdsaSigner, falconPubKey).',
|
||||
identityId,
|
||||
calldata: encodeHybridRegisterIdentity(identityId, ecdsaSigner, falconPubKey),
|
||||
note: 'Binds your existing classical address to a Falcon-512 key. An identityId can be registered exactly once.',
|
||||
});
|
||||
steps.push({
|
||||
n: 3,
|
||||
title: 'Authorize under both legs during the transition window',
|
||||
how: 'AereHybridAuth.authorize(identityId, messageHash, ecdsaSig, falconNonce, falconCompSig) requires BOTH a valid ECDSA and a valid Falcon-512 signature over the same 32-byte hash.',
|
||||
note: 'Defense-in-depth: an attacker must break BOTH secp256k1 AND Falcon-512. This is the transition-era primitive; once you trust the PQC leg alone, move to a Falcon-only AerePQCAccount.',
|
||||
});
|
||||
caveats.push('Hybrid marginal verify cost is ~43,000 gas at the native-precompile level (Falcon-512 40,000 + ecrecover 3,000); the deployed AereHybridAuth uses the Solidity Falcon verifier, so its authorize() is dominated by the ~10.5M Solidity Falcon path.');
|
||||
} else {
|
||||
throw new Error(`unknown mode: ${mode}`);
|
||||
}
|
||||
|
||||
return { mode, falconPubKey: '0x' + hexToBytes(falconPubKey).length + '-byte key', steps, caveats };
|
||||
}
|
||||
|
||||
// Deterministic identity seed for the hybrid example (not consensus-critical).
|
||||
function encodeHybridIdentitySeed(ecdsaSigner, falconPubKeyHex) {
|
||||
return abiEncode(['address', 'bytes'], [ecdsaSigner, falconPubKeyHex]);
|
||||
}
|
||||
354
lib/migrator.js
Normal file
354
lib/migrator.js
Normal file
@ -0,0 +1,354 @@
|
||||
// migrator.js, the transaction-CONSTRUCTION module of the migration SDK.
|
||||
//
|
||||
// Given a classical ECDSA EOA and a target NIST Falcon-512 public key, this
|
||||
// builds the exact sequence of UNSIGNED transaction objects a holder would sign
|
||||
// to move their assets onto a post-quantum AerePQCAccount, using:
|
||||
//
|
||||
// - AerePQCAccountFactory.predictAddress (LIVE, 0xd5315Ea7...CE58): pure
|
||||
// CREATE2 math for the counterfactual target account address.
|
||||
// - AerePQCAccountFactory.createAccount(bytes,uint256): the permissionless
|
||||
// deploy of that account.
|
||||
// - AereAccountMigrator (repo source; NOT yet deployed to 2800): the atomic,
|
||||
// no-custody conduit migrate() / migrateToPqcAccount() path.
|
||||
// - Plain ERC-20 transfer + native send: the DIRECT path that needs no
|
||||
// migrator contract and works today.
|
||||
//
|
||||
// HARD INVARIANT: this module NEVER signs, deploys, or broadcasts, and never
|
||||
// moves a single wei. Every function returns plain data (addresses, calldata, or
|
||||
// unsigned tx objects). A returned tx object is inert until a human signs it in
|
||||
// their own wallet. There is no private key anywhere in this toolkit.
|
||||
|
||||
import { encodeFunctionData } from './abi.js';
|
||||
import { predictPqcAccountAddress, validateFalconPubKey, ADDRESSES } from './migrate.js';
|
||||
import { toChecksumAddress } from './keccak.js';
|
||||
import { getBalance, ethCall, getTransactionCount, estimateGas } from './rpc.js';
|
||||
|
||||
export const CHAIN_ID = 2800;
|
||||
|
||||
// AereAccountMigrator canonical signatures + selectors. The migrator is repo
|
||||
// source (contracts/pqc/AereAccountMigrator.sol) and is in the audit-gated,
|
||||
// not-yet-deployed set (deployments/wave-a-...json notDeployedHeld), so it has
|
||||
// NO mainnet address. A migrator-path build therefore REQUIRES the caller to
|
||||
// pass the migrator address explicitly; there is no baked-in "live" default.
|
||||
// Selectors below are verified byte-for-byte against ethers v6 in selftest.js.
|
||||
export const MIGRATOR_SIG = {
|
||||
migrate: 'migrate(address,address[],uint256[],bool)',
|
||||
migrateToPqcAccount: 'migrateToPqcAccount(address,bytes,uint256,address,address[],uint256[],bool)',
|
||||
predictPqcAccount: 'predictPqcAccount(address,bytes,uint256)',
|
||||
};
|
||||
|
||||
// Minimal ERC-20 pieces the migration path needs.
|
||||
export const ERC20_SIG = {
|
||||
approve: 'approve(address,uint256)',
|
||||
transfer: 'transfer(address,uint256)',
|
||||
balanceOf: 'balanceOf(address)',
|
||||
decimals: 'decimals()',
|
||||
symbol: 'symbol()',
|
||||
};
|
||||
|
||||
const ZERO = '0x0000000000000000000000000000000000000000';
|
||||
|
||||
function requireAddress(a, label) {
|
||||
if (typeof a !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(a)) {
|
||||
throw new Error(`${label} must be a 20-byte 0x address, got ${a}`);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function toWeiHex(v) {
|
||||
return '0x' + BigInt(v).toString(16);
|
||||
}
|
||||
|
||||
// ── low-level calldata builders (pure) ───────────────────────────────────────
|
||||
|
||||
/** AerePQCAccountFactory.createAccount(bytes,uint256) calldata. */
|
||||
export function encodeCreateAccountCalldata(falconPubKey, salt = 0) {
|
||||
validateFalconPubKey(falconPubKey);
|
||||
return encodeFunctionData(
|
||||
'createAccount(bytes,uint256)',
|
||||
['bytes', 'uint256'], [falconPubKey, salt],
|
||||
);
|
||||
}
|
||||
|
||||
/** ERC-20 approve(spender, amount) calldata. */
|
||||
export function encodeApproveCalldata(spender, amount) {
|
||||
requireAddress(spender, 'spender');
|
||||
return encodeFunctionData(ERC20_SIG.approve, ['address', 'uint256'], [spender, amount]);
|
||||
}
|
||||
|
||||
/** ERC-20 transfer(to, amount) calldata. */
|
||||
export function encodeTransferCalldata(to, amount) {
|
||||
requireAddress(to, 'to');
|
||||
return encodeFunctionData(ERC20_SIG.transfer, ['address', 'uint256'], [to, amount]);
|
||||
}
|
||||
|
||||
/** ERC-20 balanceOf(owner) calldata (for read-only eth_call). */
|
||||
export function encodeBalanceOfCalldata(owner) {
|
||||
requireAddress(owner, 'owner');
|
||||
return encodeFunctionData(ERC20_SIG.balanceOf, ['address'], [owner]);
|
||||
}
|
||||
|
||||
/** AereAccountMigrator.migrate(destination, tokens, amounts, moveNative) calldata. */
|
||||
export function encodeMigrateCalldata({ destination, tokens = [], amounts = [], moveNative = false }) {
|
||||
requireAddress(destination, 'destination');
|
||||
if (tokens.length !== amounts.length) throw new Error('tokens/amounts length mismatch');
|
||||
return encodeFunctionData(
|
||||
MIGRATOR_SIG.migrate,
|
||||
['address', 'address[]', 'uint256[]', 'bool'],
|
||||
[destination, tokens, amounts, moveNative],
|
||||
);
|
||||
}
|
||||
|
||||
/** AereAccountMigrator.migrateToPqcAccount(...) calldata (binds dest to Falcon key). */
|
||||
export function encodeMigrateToPqcAccountCalldata({
|
||||
factory, falconPubKey, salt = 0, expectedDestination = ZERO, tokens = [], amounts = [], moveNative = false,
|
||||
}) {
|
||||
requireAddress(factory, 'factory');
|
||||
validateFalconPubKey(falconPubKey);
|
||||
if (tokens.length !== amounts.length) throw new Error('tokens/amounts length mismatch');
|
||||
return encodeFunctionData(
|
||||
MIGRATOR_SIG.migrateToPqcAccount,
|
||||
['address', 'bytes', 'uint256', 'address', 'address[]', 'uint256[]', 'bool'],
|
||||
[factory, falconPubKey, salt, expectedDestination, tokens, amounts, moveNative],
|
||||
);
|
||||
}
|
||||
|
||||
// ── unsigned tx object factory ───────────────────────────────────────────────
|
||||
// A returned object is a standard EIP-1559-shaped unsigned tx: enough for any
|
||||
// wallet or signer to fill nonce/gas and sign. It carries no signature and is
|
||||
// inert. `signed:false` is stamped on every one so it can never be mistaken for
|
||||
// a broadcastable artifact.
|
||||
|
||||
function unsignedTx({ from, to, data = '0x', valueWei = 0n, kind, step, description, warnings = [] }) {
|
||||
return {
|
||||
signed: false,
|
||||
kind,
|
||||
step,
|
||||
description,
|
||||
tx: {
|
||||
from: from ? toChecksumAddress(from) : undefined,
|
||||
to: to ? toChecksumAddress(to) : undefined,
|
||||
value: toWeiHex(valueWei),
|
||||
data,
|
||||
chainId: CHAIN_ID,
|
||||
// nonce / gas / maxFeePerGas intentionally omitted: the signer fills them.
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// ── target-address prediction ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Predict the counterfactual post-quantum account address for a Falcon key.
|
||||
* Pure CREATE2 math (no RPC), mirrors AerePQCAccountFactory.predictAddress.
|
||||
*/
|
||||
export function predictTargetAddress({ falconPubKey, salt = 0, factory, initCodeHash } = {}) {
|
||||
return predictPqcAccountAddress({ falconPubKey, salt, factory, initCodeHash });
|
||||
}
|
||||
|
||||
// ── asset enumeration (read-only) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enumerate which assets an EOA holds and therefore needs to move. READ-ONLY:
|
||||
* eth_getBalance for native AERE and eth_call balanceOf(owner) per ERC-20. It
|
||||
* never approves, transfers, or signs. Token metadata (symbol/decimals) is
|
||||
* best-effort and non-fatal.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {string} p.rpcUrl
|
||||
* @param {string} p.owner the EOA to inspect
|
||||
* @param {string[]} [p.tokens] candidate ERC-20 addresses to check balances of
|
||||
* @returns {Promise<{native, tokens:Array, movable:number}>}
|
||||
*/
|
||||
export async function enumerateAssets({ rpcUrl, owner, tokens = [], timeoutMs } = {}) {
|
||||
requireAddress(owner, 'owner');
|
||||
if (!rpcUrl) throw new Error('enumerateAssets requires rpcUrl (read-only)');
|
||||
|
||||
const nativeWei = await getBalance(rpcUrl, owner, 'latest', timeoutMs);
|
||||
const native = { asset: 'native', symbol: 'AERE', balanceWei: nativeWei.toString(), needsMove: nativeWei > 0n };
|
||||
|
||||
const tokenRows = [];
|
||||
for (const token of tokens) {
|
||||
requireAddress(token, 'token');
|
||||
const row = { token: toChecksumAddress(token), balance: null, symbol: null, decimals: null, needsMove: false, note: null };
|
||||
try {
|
||||
const ret = await ethCall(rpcUrl, { to: token, data: encodeBalanceOfCalldata(owner) }, 'latest', timeoutMs);
|
||||
if (ret && ret !== '0x') {
|
||||
const bal = BigInt(ret);
|
||||
row.balance = bal.toString();
|
||||
row.needsMove = bal > 0n;
|
||||
} else {
|
||||
row.note = 'balanceOf returned empty (not an ERC-20 at this address, or no code) [VERIFY]';
|
||||
}
|
||||
} catch (e) {
|
||||
row.note = `balanceOf read failed: ${e.message} [VERIFY]`;
|
||||
}
|
||||
// best-effort symbol (string) and decimals (uint8); tolerate non-standard tokens
|
||||
try {
|
||||
const symRet = await ethCall(rpcUrl, { to: token, data: encodeFunctionData(ERC20_SIG.symbol, [], []) }, 'latest', timeoutMs);
|
||||
if (symRet && symRet.length > 130) {
|
||||
// decode a dynamic string return: [offset][len][data]
|
||||
const len = parseInt(symRet.slice(66, 130), 16);
|
||||
const strHex = symRet.slice(130, 130 + len * 2);
|
||||
row.symbol = Buffer.from(strHex, 'hex').toString('utf8').replace(/\u0000+$/, '') || null;
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
tokenRows.push(row);
|
||||
}
|
||||
|
||||
const movable = (native.needsMove ? 1 : 0) + tokenRows.filter((t) => t.needsMove).length;
|
||||
return { owner: toChecksumAddress(owner), native, tokens: tokenRows, movable };
|
||||
}
|
||||
|
||||
// ── full migration transaction plan ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the complete sequence of UNSIGNED transactions to migrate an EOA's
|
||||
* assets to its post-quantum account. Returns inert objects only; signs nothing.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {'direct'|'migrator'} [p.path] 'direct' (per-asset transfer, works
|
||||
* today, no migrator contract) or 'migrator' (atomic approve+migrate via
|
||||
* AereAccountMigrator; REQUIRES p.migrator because it is not yet deployed).
|
||||
* @param {string} p.eoa the old classical EOA (the `from` of every tx).
|
||||
* @param {string} p.falconPubKey the target Falcon-512 public key (897 bytes).
|
||||
* @param {number|bigint} [p.salt] CREATE2 salt for the target account.
|
||||
* @param {string} [p.factory] factory address (defaults to the live factory).
|
||||
* @param {Array<{token:string, amountWei:string|bigint}>} [p.tokens] ERC-20s to move.
|
||||
* @param {string|bigint} [p.nativeWei] native AERE to move (0 = none).
|
||||
* @param {boolean} [p.deployAccount] include the createAccount deploy tx (default true).
|
||||
* @param {string} [p.migrator] AereAccountMigrator address (migrator path only).
|
||||
* @param {boolean} [p.bindFalcon] migrator path: use migrateToPqcAccount so the
|
||||
* destination is cryptographically derived from the Falcon key on-chain.
|
||||
* @returns {{path, target, eoa, transactions:Array, warnings:string[], scope:string}}
|
||||
*/
|
||||
export function buildMigrationTransactions({
|
||||
path = 'direct', eoa, falconPubKey, salt = 0, factory, tokens = [], nativeWei = 0n,
|
||||
deployAccount = true, migrator, bindFalcon = true,
|
||||
} = {}) {
|
||||
requireAddress(eoa, 'eoa');
|
||||
validateFalconPubKey(falconPubKey);
|
||||
const fac = factory || ADDRESSES.AerePQCAccountFactory.address;
|
||||
const target = predictTargetAddress({ falconPubKey, salt, factory: fac });
|
||||
const nWei = BigInt(nativeWei || 0n);
|
||||
const tokenList = tokens.map((t) => ({ token: requireAddress(t.token, 'token'), amountWei: BigInt(t.amountWei) }));
|
||||
|
||||
const transactions = [];
|
||||
const warnings = [];
|
||||
let step = 0;
|
||||
|
||||
// Step: deploy the counterfactual PQC account through the LIVE factory.
|
||||
if (deployAccount) {
|
||||
transactions.push(unsignedTx({
|
||||
from: eoa,
|
||||
to: fac,
|
||||
data: encodeCreateAccountCalldata(falconPubKey, salt),
|
||||
kind: 'createAccount',
|
||||
step: ++step,
|
||||
description: `Deploy the target AerePQCAccount at ${target} via the live factory (permissionless, idempotent).`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (path === 'direct') {
|
||||
// Per-asset transfer straight from the EOA to the target account.
|
||||
for (const { token, amountWei } of tokenList) {
|
||||
transactions.push(unsignedTx({
|
||||
from: eoa,
|
||||
to: token,
|
||||
data: encodeTransferCalldata(target, amountWei),
|
||||
kind: 'erc20Transfer',
|
||||
step: ++step,
|
||||
description: `ERC-20 transfer ${amountWei} (base units) of ${token} to ${target}.`,
|
||||
}));
|
||||
}
|
||||
if (nWei > 0n) {
|
||||
transactions.push(unsignedTx({
|
||||
from: eoa,
|
||||
to: target,
|
||||
valueWei: nWei,
|
||||
kind: 'nativeTransfer',
|
||||
step: ++step,
|
||||
description: `Send ${nWei} wei of native AERE to ${target}.`,
|
||||
}));
|
||||
}
|
||||
warnings.push('DIRECT path is NOT atomic: each transfer is a separate tx. If interrupted, some assets move and some do not. The migrator path is all-or-nothing but needs the (audit-gated) AereAccountMigrator deployed.');
|
||||
} else if (path === 'migrator') {
|
||||
if (!migrator) {
|
||||
throw new Error('migrator path requires an explicit `migrator` address. AereAccountMigrator is repo source in the audit-gated notDeployedHeld set and has NO live mainnet address, so there is no safe default. [VERIFY]');
|
||||
}
|
||||
requireAddress(migrator, 'migrator');
|
||||
warnings.push('AereAccountMigrator is NOT deployed on mainnet 2800 (contracts/pqc/AereAccountMigrator.sol, in notDeployedHeld.externalAuditGated_fundFlow). The supplied migrator address MUST be verified to hold that exact audited bytecode before any approval is granted to it. [VERIFY]');
|
||||
// One approve(migrator, amount) per ERC-20, then a single atomic migrate().
|
||||
for (const { token, amountWei } of tokenList) {
|
||||
transactions.push(unsignedTx({
|
||||
from: eoa,
|
||||
to: token,
|
||||
data: encodeApproveCalldata(migrator, amountWei),
|
||||
kind: 'erc20Approve',
|
||||
step: ++step,
|
||||
description: `Approve the migrator ${migrator} to move ${amountWei} (base units) of ${token}.`,
|
||||
}));
|
||||
}
|
||||
const moveNative = nWei > 0n;
|
||||
const migrateData = bindFalcon
|
||||
? encodeMigrateToPqcAccountCalldata({
|
||||
factory: fac, falconPubKey, salt, expectedDestination: target,
|
||||
tokens: tokenList.map((t) => t.token), amounts: tokenList.map((t) => t.amountWei), moveNative,
|
||||
})
|
||||
: encodeMigrateCalldata({
|
||||
destination: target, tokens: tokenList.map((t) => t.token), amounts: tokenList.map((t) => t.amountWei), moveNative,
|
||||
});
|
||||
transactions.push(unsignedTx({
|
||||
from: eoa,
|
||||
to: migrator,
|
||||
data: migrateData,
|
||||
valueWei: moveNative ? nWei : 0n,
|
||||
kind: bindFalcon ? 'migrateToPqcAccount' : 'migrate',
|
||||
step: ++step,
|
||||
description: bindFalcon
|
||||
? `Atomically sweep all approved ERC-20s and native AERE to the Falcon-derived account (migrateToPqcAccount binds the destination to the Falcon key on-chain; reverts on address mismatch).`
|
||||
: `Atomically sweep all approved ERC-20s and native AERE to ${target} (migrate).`,
|
||||
}));
|
||||
} else {
|
||||
throw new Error(`unknown path "${path}". Use "direct" or "migrator".`);
|
||||
}
|
||||
|
||||
if (tokenList.length === 0 && nWei === 0n) {
|
||||
warnings.push('No assets specified to move. Run enumerateAssets first, or pass tokens / nativeWei.');
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
target,
|
||||
eoa: toChecksumAddress(eoa),
|
||||
factory: fac,
|
||||
transactions,
|
||||
warnings,
|
||||
signed: false,
|
||||
scope: 'This migrates ACCOUNT AUTHENTICATION to a post-quantum (Falcon-512) key. It does NOT make Aere consensus post-quantum; mainnet 2800 still seals blocks with classical secp256k1 QBFT. These are UNSIGNED transactions; nothing here signs, deploys, or moves funds.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read-only gas estimate for each unsigned tx (eth_estimateGas).
|
||||
* Attaches `.gasEstimate` (number) or `.gasEstimateError` (string) per tx. Some
|
||||
* steps legitimately cannot be estimated pre-deploy (e.g. a migrate() call to a
|
||||
* migrator that is not deployed, or a transfer that needs a prior approval), and
|
||||
* those return an error string rather than throwing. Never broadcasts.
|
||||
*/
|
||||
export async function estimateMigrationGas(plan, rpcUrl, timeoutMs) {
|
||||
if (!rpcUrl) throw new Error('estimateMigrationGas requires rpcUrl (read-only)');
|
||||
for (const item of plan.transactions) {
|
||||
const t = item.tx;
|
||||
const res = await estimateGas(rpcUrl, { from: t.from, to: t.to, value: t.value, data: t.data }, timeoutMs);
|
||||
if (res.gas != null) item.gasEstimate = res.gas;
|
||||
else item.gasEstimateError = res.error;
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Read-only helper: the EOA's current pending nonce, if a signer wants it. */
|
||||
export async function fetchNonce(rpcUrl, eoa, timeoutMs) {
|
||||
return getTransactionCount(rpcUrl, eoa, 'pending', timeoutMs);
|
||||
}
|
||||
110
lib/precompiles.js
Normal file
110
lib/precompiles.js
Normal file
@ -0,0 +1,110 @@
|
||||
// precompiles.js, the single source of truth for the address bands and the
|
||||
// measured gas this toolkit reasons about. Every value here is sourced from a
|
||||
// committed repo artifact; the citations are in the comments and in README.md.
|
||||
//
|
||||
// SCOPE BOUNDARY (never moves): these are signature/hash verification
|
||||
// precompiles on the EVM/account path. NONE of them make Aere consensus
|
||||
// post-quantum. Aere mainnet (chain 2800) seals blocks with classical
|
||||
// secp256k1 QBFT. Migrating account authentication to a PQC scheme changes how
|
||||
// an account authorizes, not how the chain reaches consensus.
|
||||
|
||||
// ── Classical, quantum-vulnerable primitives ─────────────────────────────────
|
||||
// Broken by a large-scale quantum computer via Shor's algorithm.
|
||||
export const CLASSICAL = {
|
||||
ecrecover: {
|
||||
address: 0x01n,
|
||||
name: 'ECDSA ecrecover (secp256k1)',
|
||||
quantum: 'VULNERABLE',
|
||||
// ecrecover is a fixed EVM protocol constant, not Aere-specific.
|
||||
// [VERIFY: G_ecrecover = 3000 is a fixed EVM constant.]
|
||||
marginalGas: 3000,
|
||||
},
|
||||
p256: {
|
||||
address: 0x100n,
|
||||
name: 'P-256 / secp256r1 verify (RIP-7212 / RIP-7951, passkeys)',
|
||||
quantum: 'VULNERABLE',
|
||||
// Shared with Ethereum's Osaka; standards-aligned, not Aere-proprietary.
|
||||
// [CITED: AERE-EIP-COMPATIBILITY-MATRIX.md 0x100 row, ~3,450 gas.]
|
||||
marginalGas: 3450,
|
||||
},
|
||||
};
|
||||
|
||||
// ── Aere's live native post-quantum precompiles (chain 2800) ─────────────────
|
||||
// Band 0x0AE1..0x0AE5, activated at the AerePQC fork, block 9,189,161.
|
||||
// [CITED: AERE-PROTOCOL-SPECIFICATION.md section 4; AERE-EIP-COMPATIBILITY-MATRIX.md;
|
||||
// AERE-BENCHMARK-REPORT.md Part C.1 (marginal gas + verify-and-record tx gasUsed).]
|
||||
export const PQC = {
|
||||
falcon512: {
|
||||
address: 0x0ae1n,
|
||||
name: 'Falcon-512 verify (NIST round-3)',
|
||||
nistLevel: 1,
|
||||
marginalGas: 40000,
|
||||
verifyAndRecordTxGas: 86336,
|
||||
live: true,
|
||||
},
|
||||
falcon1024: {
|
||||
address: 0x0ae2n,
|
||||
name: 'Falcon-1024 verify (NIST round-3)',
|
||||
nistLevel: 5,
|
||||
marginalGas: 75000,
|
||||
verifyAndRecordTxGas: 145496,
|
||||
live: true,
|
||||
},
|
||||
mldsa44: {
|
||||
address: 0x0ae3n,
|
||||
name: 'ML-DSA-44 verify (FIPS 204, Dilithium2)',
|
||||
nistLevel: 2,
|
||||
marginalGas: 55000,
|
||||
verifyAndRecordTxGas: 351050,
|
||||
live: true,
|
||||
},
|
||||
slhdsa128s: {
|
||||
address: 0x0ae4n,
|
||||
name: 'SLH-DSA-SHA2-128s verify (FIPS 205, SPHINCS+)',
|
||||
nistLevel: 1,
|
||||
marginalGas: 350000,
|
||||
verifyAndRecordTxGas: 558276,
|
||||
live: true,
|
||||
},
|
||||
shake256: {
|
||||
address: 0x0ae5n,
|
||||
name: 'SHAKE256 (FIPS 202 XOF)',
|
||||
marginalGas: 72, // 60 base + 12/word; a 32-byte input is one word -> 72
|
||||
verifyAndRecordTxGas: 21470,
|
||||
live: true,
|
||||
hashOnly: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Testnet-only PQC precompiles, NOT on mainnet 2800. A mainnet staticcall to
|
||||
// these hits an empty account and returns empty. Listed so the scanner can name
|
||||
// them if it ever sees them, and so no one mistakes them for live mainnet.
|
||||
// [CITED: AERE-EIP-COMPATIBILITY-MATRIX.md 0x0AE6..0x0AE8 rows.]
|
||||
export const PQC_TESTNET = {
|
||||
mlkem768: { address: 0x0ae6n, name: 'ML-KEM-768 encapsulation (FIPS 203)', marginalGas: 60000, live: false },
|
||||
falconHashToPoint: { address: 0x0ae7n, name: 'Falcon HashToPoint (SHAKE256 sampler)', live: false },
|
||||
sp1StarkVerify: { address: 0x0ae8n, name: 'SP1 inner STARK verify (reference skeleton)', live: false },
|
||||
};
|
||||
|
||||
// Hybrid cost: one Falcon-512 native verify (40,000) plus one ecrecover (3,000).
|
||||
// [Derived arithmetic from the two cited marginal-gas constants.]
|
||||
export const HYBRID_MARGINAL_GAS = PQC.falcon512.marginalGas + CLASSICAL.ecrecover.marginalGas; // 43,000
|
||||
|
||||
// Two Falcon-512 verify paths exist and MUST NOT be conflated:
|
||||
// - NATIVE precompile 0x0AE1: ~40,000 marginal / 86,336 verify-and-record tx.
|
||||
// - SOLIDITY AereFalcon512Verifier (0x4E8e...D8fFC): ~10.5M gas (SHAKE256 run
|
||||
// in-EVM). AerePQCAccount / AereHybridAuth currently delegate to this
|
||||
// Solidity verifier, so a *deployed* AerePQCAccount authorization is ~10.5M
|
||||
// gas today, NOT 86k. [CITED: contracts/deployments/pqc-account.json
|
||||
// handleOpsGasUsed 10,278,313; AerePQCAccount.sol GAS/EIP-7825 note.]
|
||||
export const FALCON512_SOLIDITY_VERIFIER_GAS = 10500000;
|
||||
export const AERE_PQC_ACCOUNT_MEASURED_USEROP_GAS = 10278313; // real on-chain receipt
|
||||
|
||||
// Build a lookup of every known band address -> descriptor.
|
||||
export function knownAddressTable() {
|
||||
const t = new Map();
|
||||
for (const [k, v] of Object.entries(CLASSICAL)) t.set(v.address, { key: k, band: 'classical', ...v });
|
||||
for (const [k, v] of Object.entries(PQC)) t.set(v.address, { key: k, band: 'pqc-live', ...v });
|
||||
for (const [k, v] of Object.entries(PQC_TESTNET)) t.set(v.address, { key: k, band: 'pqc-testnet', ...v });
|
||||
return t;
|
||||
}
|
||||
198
lib/readiness.js
Normal file
198
lib/readiness.js
Normal file
@ -0,0 +1,198 @@
|
||||
// readiness.js, the quantum-readiness cost simulator. Given an account (its scan
|
||||
// result and, optionally, its enumerated assets) and a target post-quantum
|
||||
// scheme, it produces a readiness REPORT: the color, which assets need moving,
|
||||
// the one-time migration gas, and the ongoing per-authorization cost.
|
||||
//
|
||||
// SOURCING DISCIPLINE (load-bearing, not decoration):
|
||||
// - PQC verify / auth / account-deploy gas is CITED from committed artifacts
|
||||
// (AERE-BENCHMARK-REPORT.md Part C.1, pqc-account.json). Never invented.
|
||||
// - Asset-MOVE gas (ERC-20 transfer/approve) is NOT in any Aere benchmark and
|
||||
// is NOT measured here, so it is a clearly-labeled [VERIFY] planning
|
||||
// estimate, anchored to the one asset-move number this session DID measure
|
||||
// live: a native AERE send estimated at 21,246 gas via eth_estimateGas on
|
||||
// https://rpc.aere.network (chain 2800, ~block 10,416,783, 2026-07-19).
|
||||
// - Gas price is the live 1 Gwei base-fee floor on 2800.
|
||||
|
||||
import { PQC, CLASSICAL, FALCON512_SOLIDITY_VERIFIER_GAS, AERE_PQC_ACCOUNT_MEASURED_USEROP_GAS } from './precompiles.js';
|
||||
import { ACCOUNT_DEPLOY_GAS, GAS_PRICE_GWEI } from './simulate.js';
|
||||
|
||||
// Native AERE transfer. 21,000 is the fixed EVM base cost for a value-only tx;
|
||||
// the live estimate came back 21,246 (a touch above 21,000 for warm-account
|
||||
// bookkeeping). We use the measured live figure as the planning number.
|
||||
export const NATIVE_SEND_GAS = 21246; // [MEASURED-FRESH] eth_estimateGas, rpc.aere.network, 2026-07-19
|
||||
export const NATIVE_SEND_GAS_SOURCE =
|
||||
'[MEASURED-FRESH] eth_estimateGas for a native AERE send on https://rpc.aere.network (chain 2800), 2026-07-19 => 21,246 gas (21,000 protocol base + warm-account overhead).';
|
||||
|
||||
// ERC-20 move gas: NOT measured on Aere, NOT in the benchmark report. Planning
|
||||
// estimates only, flagged [VERIFY]. Refine per token with eth_estimateGas.
|
||||
export const ERC20_TRANSFER_GAS = 65000; // [VERIFY] cold-recipient transfer, planning estimate
|
||||
export const ERC20_APPROVE_GAS = 46000; // [VERIFY] approve, planning estimate
|
||||
export const MIGRATOR_PER_TOKEN_GAS = 40000; // [VERIFY] one safeTransferFrom hop inside migrate()
|
||||
export const MIGRATOR_BASE_GAS = 30000; // [VERIFY] migrate() call overhead (checks, event)
|
||||
export const ERC20_MOVE_GAS_SOURCE =
|
||||
'[VERIFY] ERC-20 move gas is a planning estimate, NOT measured on Aere and NOT in AERE-BENCHMARK-REPORT.md. Refine with eth_estimateGas against the real token+allowance.';
|
||||
|
||||
// Per-scheme ongoing authorization gas. verifyTxGas is the native-precompile
|
||||
// verify-and-record receipt [CITED: AERE-BENCHMARK-REPORT.md Part C.1].
|
||||
const SCHEME_AUTH = {
|
||||
falcon512: { label: 'Falcon-512 (native precompile)', authGas: PQC.falcon512.verifyAndRecordTxGas, cited: 'AERE-BENCHMARK-REPORT.md Part C.1 (86,336)' },
|
||||
falcon1024: { label: 'Falcon-1024 (native precompile)', authGas: PQC.falcon1024.verifyAndRecordTxGas, cited: 'AERE-BENCHMARK-REPORT.md Part C.1 (145,496)' },
|
||||
mldsa44: { label: 'ML-DSA-44 (native precompile)', authGas: PQC.mldsa44.verifyAndRecordTxGas, cited: 'AERE-BENCHMARK-REPORT.md Part C.1 (351,050)' },
|
||||
slhdsa128s: { label: 'SLH-DSA-128s (native precompile)', authGas: PQC.slhdsa128s.verifyAndRecordTxGas, cited: 'AERE-BENCHMARK-REPORT.md Part C.1 (558,276)' },
|
||||
hybrid: { label: 'Hybrid ECDSA + Falcon-512 (native)', authGas: PQC.falcon512.verifyAndRecordTxGas + CLASSICAL.ecrecover.marginalGas, cited: 'Part C.1 Falcon-512 86,336 + ecrecover 3,000' },
|
||||
// The path a currently-DEPLOYED AerePQCAccount actually takes (Solidity Falcon
|
||||
// verifier), measured on-chain. This is the honest default: it is what a
|
||||
// migration TODAY costs per auth, ~122x the native projection.
|
||||
falcon512_solidity: { label: 'Falcon-512 (deployed AerePQCAccount, Solidity verifier)', authGas: AERE_PQC_ACCOUNT_MEASURED_USEROP_GAS, cited: 'pqc-account.json handleOpsGasUsed 10,278,313 (real on-chain receipt)' },
|
||||
};
|
||||
|
||||
export function readinessSchemeList() {
|
||||
return Object.keys(SCHEME_AUTH);
|
||||
}
|
||||
|
||||
function gasToAere(gas, gwei = GAS_PRICE_GWEI) {
|
||||
return gas * gwei * 1e-9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the one-time asset-move gas for a set of assets on a given path.
|
||||
* @param {{nativeNeedsMove:boolean, erc20Count:number}} assets
|
||||
* @param {'direct'|'migrator'} path
|
||||
*/
|
||||
export function assetMoveGas(assets, path = 'direct') {
|
||||
const { nativeNeedsMove = false, erc20Count = 0 } = assets;
|
||||
const nativeGas = nativeNeedsMove ? NATIVE_SEND_GAS : 0;
|
||||
let erc20Gas = 0;
|
||||
const breakdown = [];
|
||||
if (nativeNeedsMove) breakdown.push({ item: 'native AERE send', gas: NATIVE_SEND_GAS, source: NATIVE_SEND_GAS_SOURCE });
|
||||
if (path === 'direct') {
|
||||
erc20Gas = erc20Count * ERC20_TRANSFER_GAS;
|
||||
if (erc20Count) breakdown.push({ item: `${erc20Count} x ERC-20 transfer`, gas: erc20Gas, source: ERC20_MOVE_GAS_SOURCE });
|
||||
} else if (path === 'migrator') {
|
||||
const approveGas = erc20Count * ERC20_APPROVE_GAS;
|
||||
const migrateGas = erc20Count > 0 || nativeNeedsMove ? MIGRATOR_BASE_GAS + erc20Count * MIGRATOR_PER_TOKEN_GAS : 0;
|
||||
erc20Gas = approveGas + migrateGas;
|
||||
if (erc20Count) breakdown.push({ item: `${erc20Count} x ERC-20 approve`, gas: approveGas, source: ERC20_MOVE_GAS_SOURCE });
|
||||
if (erc20Count > 0 || nativeNeedsMove) breakdown.push({ item: 'atomic migrate() sweep', gas: migrateGas, source: ERC20_MOVE_GAS_SOURCE });
|
||||
} else {
|
||||
throw new Error(`unknown path "${path}"`);
|
||||
}
|
||||
return { total: nativeGas + erc20Gas, nativeGas, erc20Gas, breakdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a quantum-readiness report for ONE account.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {object} p.scan a scan report ({ address, kind, readiness, ... }).
|
||||
* @param {object} [p.assets] enumerateAssets() output (native + tokens + movable).
|
||||
* @param {string} [p.scheme] target scheme (default falcon512_solidity, the
|
||||
* honest deployed-reality path). Native projection also shown.
|
||||
* @param {'direct'|'migrator'} [p.path] asset-move path (default direct).
|
||||
* @param {boolean} [p.deployAccount] count the CREATE2 deploy (default: only if
|
||||
* the account is an EOA, since a contract may already be PQC-capable).
|
||||
* @param {number} [p.projectedAuths] ongoing auths to project the running cost.
|
||||
* @param {number} [p.gwei] gas price (default 1).
|
||||
*/
|
||||
export function buildReadinessReport({
|
||||
scan, assets, scheme = 'falcon512_solidity', path = 'direct', deployAccount, projectedAuths = 1, gwei = GAS_PRICE_GWEI,
|
||||
} = {}) {
|
||||
if (!scan) throw new Error('buildReadinessReport requires a scan report');
|
||||
const model = SCHEME_AUTH[scheme];
|
||||
if (!model) throw new Error(`unknown scheme "${scheme}". Known: ${readinessSchemeList().join(', ')}`);
|
||||
|
||||
const isEoa = scan.kind === 'eoa';
|
||||
const needDeploy = deployAccount ?? isEoa;
|
||||
|
||||
// asset picture
|
||||
let nativeNeedsMove = false;
|
||||
let erc20ToMove = [];
|
||||
if (assets) {
|
||||
nativeNeedsMove = !!(assets.native && assets.native.needsMove);
|
||||
erc20ToMove = (assets.tokens || []).filter((t) => t.needsMove);
|
||||
}
|
||||
const erc20Count = erc20ToMove.length;
|
||||
|
||||
const deployGas = needDeploy ? ACCOUNT_DEPLOY_GAS : 0;
|
||||
const move = assetMoveGas({ nativeNeedsMove, erc20Count }, path);
|
||||
const oneTimeGas = deployGas + move.total;
|
||||
const ongoingAuthGas = model.authGas * projectedAuths;
|
||||
|
||||
// native-precompile projection for the same scheme (context, not the deployed cost)
|
||||
const nativeProjection = scheme === 'falcon512_solidity'
|
||||
? { label: SCHEME_AUTH.falcon512.label, authGas: SCHEME_AUTH.falcon512.authGas, cited: SCHEME_AUTH.falcon512.cited }
|
||||
: null;
|
||||
|
||||
return {
|
||||
address: scan.address,
|
||||
kind: scan.kind,
|
||||
readiness: scan.readiness,
|
||||
readinessReason: scan.reason,
|
||||
assetsToMove: {
|
||||
native: nativeNeedsMove ? { symbol: assets.native.symbol, balanceWei: assets.native.balanceWei } : null,
|
||||
erc20: erc20ToMove.map((t) => ({ token: t.token, symbol: t.symbol, balance: t.balance })),
|
||||
count: (nativeNeedsMove ? 1 : 0) + erc20Count,
|
||||
note: assets ? null : 'No asset enumeration supplied. Pass --rpc (and optional --tokens) to enumerate movable assets.',
|
||||
},
|
||||
scheme: { key: scheme, ...model },
|
||||
nativeProjection,
|
||||
gas: {
|
||||
deploy: deployGas,
|
||||
deploySource: needDeploy ? 'pqc-account.json createGasUsed 1,493,084 [CITED]' : 'no deploy (contract already exists / not an EOA)',
|
||||
assetMove: move.total,
|
||||
assetMoveBreakdown: move.breakdown,
|
||||
oneTimeTotal: oneTimeGas,
|
||||
perAuth: model.authGas,
|
||||
perAuthSource: '[CITED: ' + model.cited + ']',
|
||||
projectedAuths,
|
||||
ongoingAuthTotal: ongoingAuthGas,
|
||||
},
|
||||
cost: {
|
||||
gwei,
|
||||
oneTimeAere: gasToAere(oneTimeGas, gwei),
|
||||
ongoingAuthAere: gasToAere(ongoingAuthGas, gwei),
|
||||
},
|
||||
scope: 'ACCOUNT/APPLICATION quantum-readiness. This does not, and cannot, make Aere consensus post-quantum (chain 2800 seals with classical secp256k1 QBFT). BN254 ZK verifiers remain classical.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Render a readiness report as plain text for the CLI. */
|
||||
export function formatReadinessReport(r) {
|
||||
const L = [];
|
||||
const aere = (n) => n.toFixed(9) + ' AERE';
|
||||
L.push('');
|
||||
L.push(`Quantum-readiness report ${r.address}`);
|
||||
L.push(` status: ${r.readiness} (${r.kind})`);
|
||||
if (r.readinessReason) L.push(` reason: ${r.readinessReason}`);
|
||||
L.push('');
|
||||
L.push(' Assets to move:');
|
||||
if (r.assetsToMove.note) {
|
||||
L.push(` - ${r.assetsToMove.note}`);
|
||||
} else if (r.assetsToMove.count === 0) {
|
||||
L.push(' - none detected (nothing to migrate)');
|
||||
} else {
|
||||
if (r.assetsToMove.native) L.push(` - native AERE: ${r.assetsToMove.native.balanceWei} wei`);
|
||||
for (const t of r.assetsToMove.erc20) L.push(` - ERC-20 ${t.symbol || ''} ${t.token}: ${t.balance} (base units)`);
|
||||
}
|
||||
L.push('');
|
||||
L.push(' One-time migration gas:');
|
||||
L.push(` deploy target account: ${r.gas.deploy.toLocaleString().padStart(12)} (${r.gas.deploySource})`);
|
||||
for (const b of r.gas.assetMoveBreakdown) L.push(` ${(b.item + ':').padEnd(24)}${b.gas.toLocaleString().padStart(12)}`);
|
||||
L.push(` one-time TOTAL: ${r.gas.oneTimeTotal.toLocaleString().padStart(12)} = ${aere(r.cost.oneTimeAere)} at ${r.cost.gwei} Gwei`);
|
||||
L.push('');
|
||||
L.push(' Ongoing per-authorization cost (post-migration):');
|
||||
L.push(` scheme: ${r.scheme.label}`);
|
||||
L.push(` per auth: ${r.gas.perAuth.toLocaleString()} gas ${r.gas.perAuthSource}`);
|
||||
L.push(` ${r.gas.projectedAuths} projected auth(s): ${r.gas.ongoingAuthTotal.toLocaleString()} gas = ${aere(r.cost.ongoingAuthAere)}`);
|
||||
if (r.nativeProjection) {
|
||||
L.push(` (native-precompile projection for the same scheme: ${r.nativeProjection.authGas.toLocaleString()} gas/auth [CITED: ${r.nativeProjection.cited}], available once the account verifies Falcon via the native 0x0AE1 precompile instead of the Solidity verifier.)`);
|
||||
}
|
||||
L.push('');
|
||||
const hasErc20Estimate = r.gas.assetMoveBreakdown.some((b) => b.source && b.source.startsWith('[VERIFY]'));
|
||||
L.push(hasErc20Estimate
|
||||
? ' Note: ERC-20 move gas figures are [VERIFY] planning estimates, not measured on Aere; native-send gas is a fresh live measurement (21,246). PQC auth gas is [CITED].'
|
||||
: ' Note: PQC auth gas is [CITED]; native-send gas is a fresh live measurement (21,246).');
|
||||
L.push(` Scope: ${r.scope}`);
|
||||
L.push('');
|
||||
return L.join('\n');
|
||||
}
|
||||
88
lib/rpc.js
Normal file
88
lib/rpc.js
Normal file
@ -0,0 +1,88 @@
|
||||
// rpc.js, minimal JSON-RPC client using Node's global fetch (Node 18+).
|
||||
// Zero dependencies. Used only for eth_getCode and eth_chainId in the scanner.
|
||||
|
||||
let _id = 0;
|
||||
|
||||
/**
|
||||
* Single JSON-RPC call.
|
||||
* @param {string} url RPC endpoint
|
||||
* @param {string} method
|
||||
* @param {Array} params
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
export async function rpcCall(url, method, params = [], timeoutMs = 15000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: ++_id, method, params }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
if (body.error) throw new Error(`RPC error ${body.error.code}: ${body.error.message}`);
|
||||
return body.result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** eth_getCode at latest. Returns 0x-prefixed hex ('0x' for an EOA). */
|
||||
export async function getCode(url, address, timeoutMs) {
|
||||
return rpcCall(url, 'eth_getCode', [address, 'latest'], timeoutMs);
|
||||
}
|
||||
|
||||
/** eth_chainId as a decimal number, or null on failure. */
|
||||
export async function getChainId(url, timeoutMs) {
|
||||
try {
|
||||
const hex = await rpcCall(url, 'eth_chainId', [], timeoutMs);
|
||||
return parseInt(hex, 16);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── read-only getters used by the migration SDK ──────────────────────────────
|
||||
// All of these are READ methods (eth_call / eth_getBalance / eth_estimateGas /
|
||||
// eth_getTransactionCount / eth_gasPrice). None of them broadcast a transaction,
|
||||
// none of them can move funds. eth_estimateGas simulates against pending state
|
||||
// and returns a gas number; it does not submit anything.
|
||||
|
||||
/** eth_getBalance at `block` (default latest). Returns a BigInt of wei. */
|
||||
export async function getBalance(url, address, block = 'latest', timeoutMs) {
|
||||
const hex = await rpcCall(url, 'eth_getBalance', [address, block], timeoutMs);
|
||||
return BigInt(hex);
|
||||
}
|
||||
|
||||
/** eth_call (read-only). `tx` = { to, data, from? }. Returns 0x-hex return data. */
|
||||
export async function ethCall(url, tx, block = 'latest', timeoutMs) {
|
||||
return rpcCall(url, 'eth_call', [tx, block], timeoutMs);
|
||||
}
|
||||
|
||||
/** eth_getTransactionCount at `block` (default pending) => account nonce (number). */
|
||||
export async function getTransactionCount(url, address, block = 'pending', timeoutMs) {
|
||||
const hex = await rpcCall(url, 'eth_getTransactionCount', [address, block], timeoutMs);
|
||||
return parseInt(hex, 16);
|
||||
}
|
||||
|
||||
/** eth_gasPrice => BigInt wei. */
|
||||
export async function getGasPrice(url, timeoutMs) {
|
||||
const hex = await rpcCall(url, 'eth_gasPrice', [], timeoutMs);
|
||||
return BigInt(hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* eth_estimateGas for an unsigned tx (read-only simulation, no broadcast).
|
||||
* Returns { gas } on success or { error } if the node reverts the simulation
|
||||
* (e.g. a token with no allowance yet). Never throws for a revert.
|
||||
*/
|
||||
export async function estimateGas(url, tx, timeoutMs) {
|
||||
try {
|
||||
const hex = await rpcCall(url, 'eth_estimateGas', [tx], timeoutMs);
|
||||
return { gas: parseInt(hex, 16) };
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
62
lib/scanner.js
Normal file
62
lib/scanner.js
Normal file
@ -0,0 +1,62 @@
|
||||
// scanner.js, the account-level readiness classifier. Ties the RPC fetch and
|
||||
// the bytecode analysis together and produces the RED / YELLOW / GREEN report.
|
||||
// Zero dependencies.
|
||||
|
||||
import { analyzeBytecode } from './bytecode.js';
|
||||
import { hexToBytes } from './keccak.js';
|
||||
import { getCode, getChainId } from './rpc.js';
|
||||
|
||||
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
||||
|
||||
export function isValidAddress(a) {
|
||||
return typeof a === 'string' && ADDR_RE.test(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an EOA. An externally owned account is authenticated by a
|
||||
* secp256k1 ECDSA key: quantum-vulnerable, and (unlike a contract) it CANNOT
|
||||
* host post-quantum verification logic. Always RED. The migration path for an
|
||||
* EOA is to move control to a PQC or hybrid smart account (see the migration
|
||||
* SDK), optionally via EIP-7702 delegation.
|
||||
*/
|
||||
export function classifyEoa(address) {
|
||||
return {
|
||||
address,
|
||||
kind: 'eoa',
|
||||
readiness: 'RED',
|
||||
reason: 'Externally owned account: authenticated by a secp256k1 ECDSA key, which Shor\'s algorithm breaks. An EOA holds no code, so it cannot verify a post-quantum signature itself.',
|
||||
signals: {
|
||||
pqcLivePrecompiles: [],
|
||||
pqcTestnetPrecompiles: [],
|
||||
p256: null,
|
||||
ecrecover: { address: '0x1', name: 'ECDSA secp256k1 (implicit EOA authentication)', confidence: 'certain' },
|
||||
},
|
||||
flags: [],
|
||||
migration: 'Move control to a PQC smart account (AerePQCAccount, Falcon-512 owned) or a hybrid account (AereHybridAuth, ECDSA + Falcon-512). See the migration SDK.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze contract bytecode already in hand (offline path). `code` is a
|
||||
* 0x-prefixed hex string.
|
||||
*/
|
||||
export function scanBytecode(address, codeHex) {
|
||||
const bytes = hexToBytes(codeHex);
|
||||
if (bytes.length === 0) return classifyEoa(address);
|
||||
const analysis = analyzeBytecode(bytes);
|
||||
return { address, ...analysis };
|
||||
}
|
||||
|
||||
/**
|
||||
* Live scan: fetch code over RPC, then classify. [MEASURE] path, requires a
|
||||
* reachable RPC. Falls back to a clear error the CLI can present.
|
||||
* @returns {Promise<object>} report with a `.live` block
|
||||
*/
|
||||
export async function scanLive(address, rpcUrl, { timeoutMs } = {}) {
|
||||
if (!isValidAddress(address)) throw new Error(`invalid address: ${address}`);
|
||||
const chainId = await getChainId(rpcUrl, timeoutMs);
|
||||
const code = await getCode(rpcUrl, address, timeoutMs);
|
||||
const report = scanBytecode(address, code);
|
||||
report.live = { rpcUrl, chainId, codeBytes: (code.length - 2) / 2 };
|
||||
return report;
|
||||
}
|
||||
127
lib/simulate.js
Normal file
127
lib/simulate.js
Normal file
@ -0,0 +1,127 @@
|
||||
// simulate.js, migration cost estimator. Given a set of accounts and a target
|
||||
// post-quantum scheme, estimate the gas to migrate and to authorize, using the
|
||||
// MEASURED PQC verify costs from the Aere benchmark report. Zero dependencies.
|
||||
//
|
||||
// All per-scheme gas is [CITED: AERE-BENCHMARK-REPORT.md Part C.1] (native
|
||||
// precompile marginal gas + the verify-and-record tx gasUsed receipt). The
|
||||
// deployed-account Falcon path is [CITED: pqc-account.json] (Solidity verifier,
|
||||
// ~10.5M gas). This estimator does not invent numbers; it composes the cited
|
||||
// ones and is explicit about which path each figure represents.
|
||||
|
||||
import {
|
||||
PQC, HYBRID_MARGINAL_GAS, CLASSICAL,
|
||||
AERE_PQC_ACCOUNT_MEASURED_USEROP_GAS, FALCON512_SOLIDITY_VERIFIER_GAS,
|
||||
} from './precompiles.js';
|
||||
|
||||
// One-time CREATE2 deploy of an AerePQCAccount. [CITED: pqc-account.json createGasUsed]
|
||||
export const ACCOUNT_DEPLOY_GAS = 1493084;
|
||||
|
||||
// The live 1 Gwei base-fee floor on chain 2800. [CITED: EIP-COMPATIBILITY-MATRIX EIP-1559 row]
|
||||
export const GAS_PRICE_GWEI = 1;
|
||||
|
||||
// Per-scheme model. `verifyTxGas` is the realistic on-chain verify-and-record
|
||||
// receipt via the NATIVE precompile. `note` records path caveats.
|
||||
const SCHEME_MODEL = {
|
||||
falcon512: { label: 'Falcon-512 (native precompile)', verifyTxGas: PQC.falcon512.verifyAndRecordTxGas, marginal: PQC.falcon512.marginalGas },
|
||||
falcon1024: { label: 'Falcon-1024 (native precompile)', verifyTxGas: PQC.falcon1024.verifyAndRecordTxGas, marginal: PQC.falcon1024.marginalGas },
|
||||
mldsa44: { label: 'ML-DSA-44 (native precompile)', verifyTxGas: PQC.mldsa44.verifyAndRecordTxGas, marginal: PQC.mldsa44.marginalGas },
|
||||
slhdsa128s: { label: 'SLH-DSA-128s (native precompile)', verifyTxGas: PQC.slhdsa128s.verifyAndRecordTxGas, marginal: PQC.slhdsa128s.marginalGas },
|
||||
hybrid: { label: 'Hybrid ECDSA + Falcon-512 (native)', verifyTxGas: PQC.falcon512.verifyAndRecordTxGas + CLASSICAL.ecrecover.marginalGas, marginal: HYBRID_MARGINAL_GAS },
|
||||
// The path an ACTUALLY-DEPLOYED AerePQCAccount takes today (Solidity verifier).
|
||||
falcon512_solidity: { label: 'Falcon-512 (deployed AerePQCAccount, Solidity verifier)', verifyTxGas: AERE_PQC_ACCOUNT_MEASURED_USEROP_GAS, marginal: FALCON512_SOLIDITY_VERIFIER_GAS },
|
||||
};
|
||||
|
||||
export function schemeList() {
|
||||
return Object.keys(SCHEME_MODEL);
|
||||
}
|
||||
|
||||
function gasToAere(gas, gweiPrice = GAS_PRICE_GWEI) {
|
||||
// gas * price(Gwei) * 1e-9 AERE/Gwei-gas
|
||||
return gas * gweiPrice * 1e-9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate migration cost for a set of accounts.
|
||||
* @param {Array<{address?:string, scheme?:string, deployAccount?:boolean, authsPerAccount?:number}>} accounts
|
||||
* @param {object} opts
|
||||
* @param {string} opts.defaultScheme default scheme when an account omits one
|
||||
* @param {number} opts.defaultAuths default authorizations/account to project
|
||||
* @param {number} opts.gweiPrice gas price in Gwei (default 1, the live floor)
|
||||
*/
|
||||
export function simulateMigration(accounts, opts = {}) {
|
||||
const defaultScheme = opts.defaultScheme || 'falcon512';
|
||||
const defaultAuths = opts.defaultAuths ?? 1;
|
||||
const gweiPrice = opts.gweiPrice ?? GAS_PRICE_GWEI;
|
||||
|
||||
const rows = [];
|
||||
let totalDeployGas = 0;
|
||||
let totalAuthGas = 0;
|
||||
|
||||
for (const acc of accounts) {
|
||||
const scheme = acc.scheme || defaultScheme;
|
||||
const model = SCHEME_MODEL[scheme];
|
||||
if (!model) throw new Error(`unknown scheme "${scheme}". Known: ${schemeList().join(', ')}`);
|
||||
const deployAccount = acc.deployAccount !== false; // default: deploy a smart account
|
||||
const auths = acc.authsPerAccount ?? defaultAuths;
|
||||
|
||||
const deployGas = deployAccount ? ACCOUNT_DEPLOY_GAS : 0;
|
||||
const authGas = model.verifyTxGas * auths;
|
||||
totalDeployGas += deployGas;
|
||||
totalAuthGas += authGas;
|
||||
|
||||
rows.push({
|
||||
address: acc.address || '(counterfactual)',
|
||||
scheme,
|
||||
schemeLabel: model.label,
|
||||
deployGas,
|
||||
authsProjected: auths,
|
||||
perAuthGas: model.verifyTxGas,
|
||||
authGas,
|
||||
totalGas: deployGas + authGas,
|
||||
});
|
||||
}
|
||||
|
||||
const totalGas = totalDeployGas + totalAuthGas;
|
||||
return {
|
||||
accounts: rows,
|
||||
totals: {
|
||||
count: accounts.length,
|
||||
totalDeployGas,
|
||||
totalAuthGas,
|
||||
totalGas,
|
||||
gweiPrice,
|
||||
estimatedAere: gasToAere(totalGas, gweiPrice),
|
||||
},
|
||||
assumptions: [
|
||||
`Account deploy gas = ${ACCOUNT_DEPLOY_GAS.toLocaleString()} (CREATE2 AerePQCAccount, [CITED: pqc-account.json]).`,
|
||||
`Per-auth gas is the native-precompile verify-and-record receipt [CITED: AERE-BENCHMARK-REPORT.md Part C.1]; the scheme "falcon512_solidity" instead models the CURRENTLY-DEPLOYED AerePQCAccount path (~10.5M gas, [CITED: pqc-account.json]).`,
|
||||
`Gas price = ${gweiPrice} Gwei (the live 1 Gwei base-fee floor on chain 2800).`,
|
||||
`SCOPE: these costs are for ACCOUNT/APPLICATION authentication. They do not make Aere consensus post-quantum.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Render a simulation result as a plain-text table for the CLI. */
|
||||
export function formatSimulation(sim) {
|
||||
const lines = [];
|
||||
const pad = (s, n) => String(s).padEnd(n);
|
||||
const padL = (s, n) => String(s).padStart(n);
|
||||
lines.push(pad('ADDRESS', 26) + pad('SCHEME', 20) + padL('DEPLOY', 12) + padL('AUTHS', 7) + padL('PER-AUTH', 12) + padL('TOTAL', 14));
|
||||
lines.push('-'.repeat(91));
|
||||
for (const r of sim.accounts) {
|
||||
const a = r.address.length > 24 ? r.address.slice(0, 10) + '..' + r.address.slice(-8) : r.address;
|
||||
lines.push(
|
||||
pad(a, 26) + pad(r.scheme, 20) + padL(r.deployGas.toLocaleString(), 12) +
|
||||
padL(r.authsProjected, 7) + padL(r.perAuthGas.toLocaleString(), 12) + padL(r.totalGas.toLocaleString(), 14),
|
||||
);
|
||||
}
|
||||
lines.push('-'.repeat(91));
|
||||
const t = sim.totals;
|
||||
lines.push(pad(`TOTAL (${t.count} accounts)`, 46) + padL(t.totalDeployGas.toLocaleString(), 12) + padL('', 7) + padL(t.totalAuthGas.toLocaleString(), 12) + padL(t.totalGas.toLocaleString(), 14));
|
||||
lines.push('');
|
||||
lines.push(`Estimated cost at ${t.gweiPrice} Gwei: ${t.estimatedAere.toFixed(6)} AERE total.`);
|
||||
lines.push('');
|
||||
lines.push('Assumptions:');
|
||||
for (const a of sim.assumptions) lines.push(' - ' + a);
|
||||
return lines.join('\n');
|
||||
}
|
||||
35
package.json
Normal file
35
package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@aere/pqc-migration-toolkit",
|
||||
"version": "0.1.0",
|
||||
"description": "Assess and migrate EVM accounts to post-quantum readiness on Aere Network (chain 2800). A PQC-readiness scanner, a migration SDK over Aere's live PQC primitives, and a migration cost simulator. Zero runtime dependencies.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"aere-pqc": "./aere-pqc.js",
|
||||
"aere-pqc-scan": "./scan.js",
|
||||
"aere-pqc-simulate": "./simulate.js",
|
||||
"aere-pqc-plan": "./plan.js"
|
||||
},
|
||||
"exports": {
|
||||
"./scanner": "./lib/scanner.js",
|
||||
"./bytecode": "./lib/bytecode.js",
|
||||
"./migrate": "./lib/migrate.js",
|
||||
"./migrator": "./lib/migrator.js",
|
||||
"./abi": "./lib/abi.js",
|
||||
"./readiness": "./lib/readiness.js",
|
||||
"./simulate": "./lib/simulate.js",
|
||||
"./precompiles": "./lib/precompiles.js",
|
||||
"./rpc": "./lib/rpc.js",
|
||||
"./keccak": "./lib/keccak.js"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node selftest.js",
|
||||
"cli": "node aere-pqc.js",
|
||||
"scan": "node scan.js",
|
||||
"simulate": "node simulate.js",
|
||||
"plan": "node plan.js"
|
||||
},
|
||||
"keywords": ["aere", "post-quantum", "pqc", "falcon", "migration", "evm", "quantum", "ecdsa", "readiness"],
|
||||
"engines": { "node": ">=18" },
|
||||
"homepage": "https://aere.network",
|
||||
"license": "MIT"
|
||||
}
|
||||
84
plan.js
Normal file
84
plan.js
Normal file
@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// plan.js, migration-plan CLI. Demonstrates the migration SDK (lib/migrate.js):
|
||||
// derive a counterfactual PQC account address and print a step-by-step,
|
||||
// honestly-caveated migration plan. Signs and broadcasts NOTHING.
|
||||
//
|
||||
// node plan.js demo (uses the sample Falcon key)
|
||||
// node plan.js --mode pqc --falcon 0x09.. --salt 0
|
||||
// node plan.js --mode hybrid --falcon 0x09.. --ecdsa 0xYourEoa
|
||||
// node plan.js --json
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { buildMigrationPlan, predictPqcAccountAddress, ADDRESSES } from './lib/migrate.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadSampleKey() {
|
||||
// The real Falcon-512 public key of the live sample account.
|
||||
// [CITED: aerenew/contracts/deployments/pqc-account.json sampleAccount.falconPubKey]
|
||||
const p = join(__dirname, '..', 'contracts', 'deployments', 'pqc-account.json');
|
||||
return JSON.parse(readFileSync(p, 'utf8')).sampleAccount.falconPubKey;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = { mode: 'pqc', salt: 0 };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const t = argv[i];
|
||||
if (t === '--mode') a.mode = argv[++i];
|
||||
else if (t === '--falcon') a.falcon = argv[++i];
|
||||
else if (t === '--ecdsa') a.ecdsa = argv[++i];
|
||||
else if (t === '--salt') a.salt = parseInt(argv[++i], 10);
|
||||
else if (t === '--json') a.json = true;
|
||||
else if (t === '--help' || t === '-h') a.help = true;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
console.log('node plan.js [--mode pqc|hybrid] [--falcon 0x09..] [--ecdsa 0x..] [--salt N] [--json]');
|
||||
return;
|
||||
}
|
||||
const falcon = args.falcon || loadSampleKey();
|
||||
const usingSample = !args.falcon;
|
||||
|
||||
const plan = buildMigrationPlan({
|
||||
mode: args.mode,
|
||||
falconPubKey: falcon,
|
||||
ecdsaSigner: args.ecdsa,
|
||||
salt: args.salt,
|
||||
});
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`Aere post-quantum migration plan (mode: ${plan.mode})`);
|
||||
if (usingSample) console.log(' (demo: using the live sample account\'s real Falcon-512 key)');
|
||||
console.log('');
|
||||
if (args.mode === 'pqc') {
|
||||
const predicted = predictPqcAccountAddress({ falconPubKey: falcon, salt: args.salt });
|
||||
console.log(` factory: ${ADDRESSES.AerePQCAccountFactory.address} (live)`);
|
||||
console.log(` derived account: ${predicted}`);
|
||||
console.log('');
|
||||
}
|
||||
for (const step of plan.steps) {
|
||||
console.log(` Step ${step.n}. ${step.title}`);
|
||||
console.log(` how: ${step.how}`);
|
||||
if (step.result) console.log(` result: ${step.result}`);
|
||||
if (step.identityId) console.log(` identityId: ${step.identityId}`);
|
||||
if (step.calldata) console.log(` calldata: ${step.calldata.slice(0, 42)}... (${(step.calldata.length - 2) / 2} bytes)`);
|
||||
if (step.note) console.log(` note: ${step.note}`);
|
||||
console.log('');
|
||||
}
|
||||
console.log(' Honest caveats:');
|
||||
for (const c of plan.caveats) console.log(` - ${c}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main();
|
||||
95
proba-vocabular.mjs
Normal file
95
proba-vocabular.mjs
Normal file
@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/*
|
||||
* proba-vocabular.mjs - proves that every verdict the scanner can emit survives the whole way
|
||||
* to the client, and that a genuinely unmeasurable input still comes out as NOT MEASURED.
|
||||
*
|
||||
* WHY IT EXISTS. The scanner emitted RED / YELLOW / GREEN. Two downstream files each wrote their
|
||||
* own list, and both wrote AMBER. So a hybrid contract, which is exactly the client who has
|
||||
* already started migrating and is the most likely to pay, was reported as "we could not measure
|
||||
* this address" in the free scan and as NOT MEASURED in the paid report. Measured 2026-08-16.
|
||||
*
|
||||
* The defect was invisible to every test we had, because each side was self-consistent. Only a
|
||||
* test that walks the WHOLE path, scanner output to client wording, can see it. That is this file.
|
||||
*
|
||||
* CONTROL POZITIV: each of the three verdicts must survive end to end.
|
||||
* CONTROL NEGATIV: an input that carries no verdict at all must still degrade to NOT MEASURED,
|
||||
* otherwise this test would pass even if the reader returned a constant, and would prove nothing.
|
||||
*
|
||||
* node proba-vocabular.mjs exit 0 all good, 1 something drifted
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { VERDICTE, citesteVerdict } from './lib/bytecode.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const linii = [];
|
||||
const spune = (ok, ce, obs) => { linii.push({ ok, ce, obs }); console.log(' ' + (ok ? 'PASS' : 'FAIL') + ' ' + ce + (obs ? ' ' + obs : '')); };
|
||||
|
||||
// Bytecode fixtures chosen so the scanner reaches each of its three branches offline, with no RPC.
|
||||
// hibrid : uses a PQ precompile AND a classical one -> YELLOW
|
||||
// doar PQ : uses only a PQ precompile -> GREEN
|
||||
// clasic : uses only ecrecover -> RED
|
||||
// Nota platita la prima rulare a acestei probe: fixtura pentru GREEN continea PUSH1 0x01 ca
|
||||
// argument de CALL, iar scanerul l-a citit, corect, ca adresa lui ecrecover, deci fixtura iesea
|
||||
// YELLOW si verdictul GREEN nu era atins de nimic. Proba a semnalat-o singura, si de aia are linia
|
||||
// care cere ca FIECARE verdict din vocabular sa fie atins de cel putin o fixtura.
|
||||
const FIXTURI = {
|
||||
YELLOW: '0x602060006080600060006001610ae1f150600160006080600060006001610001f15000',
|
||||
GREEN: '0x60206000602060006000610ae1611388f15000',
|
||||
RED: '0x602060006080600060006001610001f15000',
|
||||
};
|
||||
|
||||
function scan(bytecode) {
|
||||
try {
|
||||
return execFileSync(process.execPath,
|
||||
['scan.js', '0x1111111111111111111111111111111111111111', '--bytecode', bytecode],
|
||||
{ cwd: __dirname, encoding: 'utf8', timeout: 60000 }).trim();
|
||||
} catch (e) { return String((e.stdout || '') + (e.stderr || e.message)).trim(); }
|
||||
}
|
||||
|
||||
console.log('proba de vocabular: fiecare verdict al scanerului ajunge intreg la client\n');
|
||||
console.log('vocabularul, din singura lui sursa: ' + VERDICTE.join(', ') + '\n');
|
||||
|
||||
// ── control pozitiv: fiecare verdict produs de scaner e citit ca atare ───────────────────────
|
||||
console.log('CONTROL POZITIV, fiecare verdict pe tot drumul:');
|
||||
const vazute = new Set();
|
||||
for (const [asteptat, bytecode] of Object.entries(FIXTURI)) {
|
||||
const text = scan(bytecode);
|
||||
const emis = (text.match(/^\s*([A-Z]{3,14})\b/m) || [, '(niciunul)'])[1];
|
||||
const citit = citesteVerdict(text) || 'NOT MEASURED';
|
||||
vazute.add(emis);
|
||||
spune(citit === emis && citit !== 'NOT MEASURED',
|
||||
'scanerul emite ' + emis + ', consumatorul citeste ' + citit,
|
||||
emis === asteptat ? '' : '(fixtura viza ' + asteptat + ', dar ce conteaza e ca ce s-a emis a fost citit)');
|
||||
}
|
||||
|
||||
// Every verdict the vocabulary declares must actually be reachable by some fixture, otherwise a
|
||||
// branch could rot unnoticed exactly the way YELLOW did.
|
||||
for (const v of VERDICTE) {
|
||||
spune(vazute.has(v), 'verdictul ' + v + ' e atins de cel putin o fixtura', vazute.has(v) ? '' : 'nicio fixtura nu il produce, deci nimeni nu l-ar fi prins daca se strica');
|
||||
}
|
||||
|
||||
// ── control negativ: fara el, proba de mai sus ar trece si daca cititorul minte ──────────────
|
||||
console.log('\nCONTROL NEGATIV, ca proba de mai sus sa insemne ceva:');
|
||||
const fara = citesteVerdict('acest text nu poarta niciun verdict\nnicio linie nu incepe cu unul\n');
|
||||
spune(fara === null, 'un text fara verdict da null, deci NOT MEASURED la consumator', 'a dat ' + (fara === null ? 'null' : fara));
|
||||
|
||||
const gol = citesteVerdict('');
|
||||
spune(gol === null, 'textul gol da null', 'a dat ' + (gol === null ? 'null' : gol));
|
||||
|
||||
// A word that merely CONTAINS a verdict must not be mistaken for one.
|
||||
const aproape = citesteVerdict(' GREENISH pasture\n REDACTED line\n');
|
||||
spune(aproape === null, 'un cuvant care doar contine un verdict nu e luat drept verdict', 'a dat ' + (aproape === null ? 'null' : aproape));
|
||||
|
||||
// ── verdict ─────────────────────────────────────────────────────────────────────────────────
|
||||
const rele = linii.filter((l) => !l.ok).length;
|
||||
console.log('\n' + (linii.length - rele) + ' din ' + linii.length + ' verificari trec');
|
||||
if (rele) {
|
||||
console.log('VERDICT: vocabularul a divergat undeva pe drum. Verdictele nu ajung intregi la client.');
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('VERDICT: fiecare verdict al scanerului ajunge intreg la client, si un text fara verdict');
|
||||
console.log('ramane NOT MEASURED. Proba poate deveni rosie, deci verdele ei inseamna ceva.');
|
||||
}
|
||||
191
raport.js
Normal file
191
raport.js
Normal file
@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/*
|
||||
* raport.js - produces an Aere Quantum Exposure Report from live on-chain measurement.
|
||||
*
|
||||
* WHY THIS FILE EXISTS AND WHAT MAKES IT WORTH PAYING FOR. Anyone can write a PDF that says
|
||||
* "you are exposed to quantum risk". What a buyer cannot get elsewhere is a report that:
|
||||
*
|
||||
* - names, for every address, what was MEASURED and what was NOT, and never blurs the two;
|
||||
* - carries the exact command that reproduces it, so the buyer can re-run it next quarter,
|
||||
* on a node they run themselves, and get the same answer or a documented difference;
|
||||
* - carries a manifest of digests so a changed report is detectable;
|
||||
* - answers the questions a supervisor actually asks, in the wording of the published
|
||||
* criteria, instead of inventing a scoring scheme.
|
||||
*
|
||||
* A report that cannot be reproduced is an opinion. This one is a measurement with a receipt.
|
||||
*
|
||||
* node raport.js 0xAddr [0xAddr...] --rpc https://... [--client "Name"] [--out dir]
|
||||
* node raport.js --fixture fixtures/x.json --out dir # offline, for demos
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { citesteVerdict } from './lib/bytecode.js';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
||||
const has = (n) => argv.includes(n);
|
||||
const ADRESE = argv.filter((a) => /^0x[0-9a-fA-F]{40}$/.test(a));
|
||||
const RPC = arg('--rpc', null);
|
||||
const CLIENT = arg('--client', 'the holder of this report');
|
||||
const OUT = arg('--out', 'raport-' + new Date().toISOString().slice(0, 10).replace(/-/g, ''));
|
||||
const FIXTURE = arg('--fixture', null);
|
||||
|
||||
if (!ADRESE.length && !FIXTURE) {
|
||||
console.error('folosire: node raport.js 0xAddr [0xAddr...] --rpc URL [--client "Nume"] [--out dir]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
||||
|
||||
function scaneaza(adr) {
|
||||
const a = ['scan.js', adr];
|
||||
if (RPC) a.push('--rpc', RPC);
|
||||
try {
|
||||
const out = execFileSync(process.execPath, a, { cwd: __dirname, encoding: 'utf8', timeout: 120000 });
|
||||
return { adr, ok: true, text: out.trim() };
|
||||
} catch (e) {
|
||||
return { adr, ok: false, text: String((e.stdout || '') + (e.stderr || e.message)).trim() };
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the scan text back into the two things a buyer pays for: what was measured, and what the
|
||||
// tool itself says it could not settle. The tool marks the latter with [VERIFY] or [MEASURE], and
|
||||
// those lines are carried through verbatim rather than summarised away.
|
||||
function citeste(text) {
|
||||
// Verdictul se citeste prin functia comuna din lib/bytecode.js, NU printr-un tipar scris aici.
|
||||
// Aici a fost defectul: acest fisier cauta AMBER, scanerul scrie YELLOW, deci fiecare contract
|
||||
// hibrid, adica exact clientul care a inceput deja migrarea, cadea in NOT MEASURED.
|
||||
const stare = citesteVerdict(text) || 'NOT MEASURED';
|
||||
const precomp = [...text.matchAll(/^\s*-\s*(0x[0-9a-f]+)\s+(.+?)\s*(\[[a-z]+\])?$/gim)].map((m) => m[1] + ' ' + m[2].trim());
|
||||
const nemasurat = [...text.matchAll(/^\s*\*\s*(\[(?:VERIFY|MEASURE)\][^\n]*)/gim)].map((m) => m[1].trim());
|
||||
const cod = (text.match(/code:\s*(\d+)\s*bytes/) || [])[1] || null;
|
||||
return { stare, precomp, nemasurat, cod };
|
||||
}
|
||||
|
||||
const rezultate = ADRESE.map((a) => { const r = scaneaza(a); return { ...r, ...citeste(r.text) }; });
|
||||
|
||||
const acum = new Date().toISOString().replace('T', ' ').slice(0, 16) + 'Z';
|
||||
const comanda = 'node raport.js ' + ADRESE.join(' ') + (RPC ? ' --rpc ' + RPC : '');
|
||||
const nrRosu = rezultate.filter((r) => r.stare === 'RED').length;
|
||||
const nrGalben = rezultate.filter((r) => r.stare === 'YELLOW').length;
|
||||
const nrVerde = rezultate.filter((r) => r.stare === 'GREEN').length;
|
||||
const nrNem = rezultate.filter((r) => r.stare === 'NOT MEASURED').length;
|
||||
const totalNem = rezultate.reduce((s, r) => s + r.nemasurat.length, 0);
|
||||
|
||||
let md = `# Aere Quantum Exposure Report
|
||||
|
||||
**Prepared for:** ${CLIENT}
|
||||
**Measured:** ${acum}
|
||||
**Scope:** ${ADRESE.length} on-chain address${ADRESE.length === 1 ? '' : 'es'}${RPC ? ', read from ' + RPC : ', offline fixture'}
|
||||
|
||||
## Read this page first
|
||||
|
||||
This report states what was **measured** and what was **not**. Those are different, and mixing
|
||||
them is how exposure reports become useless. Every line below is one or the other, and the
|
||||
not-measured lines say why.
|
||||
|
||||
**It is reproducible.** Run this and compare:
|
||||
|
||||
${comanda}
|
||||
|
||||
Point \`--rpc\` at a node you run yourself. Nothing here depends on trusting the party that wrote
|
||||
this report, and that is the point: an exposure report you cannot re-run is an opinion with a
|
||||
logo on it.
|
||||
|
||||
## What was found
|
||||
|
||||
| address | verdict | code | live PQ verifiers reachable | open questions |
|
||||
|---|---|---|---|---|
|
||||
${rezultate.map((r) => `| \`${r.adr}\` | **${r.stare}** | ${r.cod ? r.cod + ' B' : 'n/a'} | ${r.precomp.length} | ${r.nemasurat.length} |`).join('\n')}
|
||||
|
||||
Totals: ${nrRosu} RED, ${nrGalben} YELLOW, ${nrVerde} GREEN, ${nrNem} not measured.
|
||||
**${totalNem} question${totalNem === 1 ? '' : 's'} the tool refused to settle on its own** are listed per address below.
|
||||
A tool that never says "I could not settle this" is not being careful, it is being quiet.
|
||||
|
||||
## Per address
|
||||
|
||||
${rezultate.map((r) => `### \`${r.adr}\` - ${r.stare}
|
||||
|
||||
${r.precomp.length ? 'Post-quantum verifiers reachable from this bytecode:\n' + r.precomp.map((p) => '- ' + p).join('\n') : 'No live post-quantum verifier was resolved from this bytecode.'}
|
||||
|
||||
${r.nemasurat.length ? '**Not settled by measurement, carried through verbatim:**\n' + r.nemasurat.map((n) => '- ' + n).join('\n') : '_Nothing was left unsettled for this address._'}
|
||||
|
||||
<details><summary>raw tool output</summary>
|
||||
|
||||
\`\`\`
|
||||
${r.text}
|
||||
\`\`\`
|
||||
</details>
|
||||
`).join('\n')}
|
||||
|
||||
## Answers to the questions a supervisor asks
|
||||
|
||||
These are written against **published criteria**, quoted as criteria and not as a certification.
|
||||
This report is not an accredited audit and does not claim to be one.
|
||||
|
||||
**"Which of your on-chain components already depend on quantum-vulnerable signatures?"**
|
||||
Every address in scope authorises through ECDSA secp256k1, because that is what an EVM account
|
||||
is. The measured question is a different one: whether a component can *also* verify a
|
||||
post-quantum signature, and ${rezultate.filter((r) => r.precomp.length).length} of ${ADRESE.length}
|
||||
address${ADRESE.length === 1 ? '' : 'es'} in scope reach a live NIST post-quantum verifier.
|
||||
|
||||
**"Do you have a migration path that does not require redeploying everything?"**
|
||||
Measured per address above. Where a component routes verification through a registry rather than
|
||||
a hardcoded address, an algorithm can be added or retired without redeploying the component. That
|
||||
property is visible in bytecode and is reported as such, not assumed.
|
||||
|
||||
**"What is the cost, and who pays it?"**
|
||||
The toolkit's \`plan.js\` produces per-account gas figures from measured deployed-account costs.
|
||||
Cost is quoted from measurement, never from a vendor estimate.
|
||||
|
||||
**"How would an auditor check your claim next year, without you?"**
|
||||
By running the command at the top of this report against their own node. That is the entire
|
||||
answer, and it is the reason this report has a command in it.
|
||||
|
||||
## What this report does NOT say
|
||||
|
||||
- It is **not** an accredited audit, and we are **not** an accredited audit firm. It measures the
|
||||
on-chain slice: bytecode and what it can reach. Repositories, TLS, dependencies, key custody,
|
||||
HSMs and off-chain services are **out of scope** and unmeasured here.
|
||||
- It does **not** say "compliant with NIST". It says "measured against the criteria published in
|
||||
NIST IR 8547". Compliance is a statement only an accredited body can make.
|
||||
- It does **not** claim that a live post-quantum verifier makes an account quantum-safe. A
|
||||
post-quantum verifier called from a transaction that is itself authorised with ECDSA gives no
|
||||
post-quantum security: the adversary forges the outer transaction. Anyone selling you the
|
||||
opposite can be taken apart in five minutes.
|
||||
- It does **not** promise legal effect. What a timestamp or an attestation means in court depends
|
||||
on jurisdiction and is not asserted here.
|
||||
- "harvest now, decrypt later" does **not** apply to signatures: a signature is public and is not
|
||||
harvested. The threat that applies to a chain is retroactive rewriting of history with keys
|
||||
recovered later, which is a different and more serious problem.
|
||||
|
||||
## Integrity
|
||||
|
||||
Digests of every file in this report are in \`MANIFEST.sha256\`. Verify with:
|
||||
|
||||
sha256sum -c MANIFEST.sha256
|
||||
`;
|
||||
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
fs.writeFileSync(path.join(OUT, 'raport.md'), md);
|
||||
fs.writeFileSync(path.join(OUT, 'raport.json'), JSON.stringify({
|
||||
format: 'aere-quantum-exposure/1', client: CLIENT, measuredAt: acum, rpc: RPC || null,
|
||||
command: comanda, addresses: rezultate.map((r) => ({ address: r.adr, verdict: r.stare, codeBytes: r.cod ? Number(r.cod) : null, pqVerifiers: r.precomp, notSettled: r.nemasurat })),
|
||||
totals: { red: nrRosu, amber: nrGalben, green: nrVerde, notMeasured: nrNem, openQuestions: totalNem },
|
||||
}, null, 2));
|
||||
|
||||
const fisiere = fs.readdirSync(OUT).filter((f) => f !== 'MANIFEST.sha256');
|
||||
fs.writeFileSync(path.join(OUT, 'MANIFEST.sha256'),
|
||||
fisiere.map((f) => sha(fs.readFileSync(path.join(OUT, f))) + ' ' + f).join('\n') + '\n');
|
||||
|
||||
console.log('Aere Quantum Exposure Report');
|
||||
console.log(' adrese masurate : ' + ADRESE.length + ' RED ' + nrRosu + ', YELLOW ' + nrGalben + ', GREEN ' + nrVerde + ', NEMASURAT ' + nrNem);
|
||||
console.log(' intrebari pe care unealta a refuzat sa le inchida singura: ' + totalNem);
|
||||
console.log(' scris in : ' + OUT + '/ (raport.md, raport.json, MANIFEST.sha256)');
|
||||
console.log(' reproductibil : ' + comanda);
|
||||
process.exit(0);
|
||||
104
scan-gratuit.mjs
Normal file
104
scan-gratuit.mjs
Normal file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
/*
|
||||
* scan-gratuit.mjs - the free scan, produced as the reply a prospect actually receives.
|
||||
*
|
||||
* WHY THIS EXISTS. The paid report is worth 149 EUR because it is reproducible. The free scan has
|
||||
* to earn the reply, not just tease it: it must tell the reader something true and useful about
|
||||
* THEIR address, in language they can forward to their boss, and it must be honest about what it
|
||||
* did not measure. A free scan that hides the finding behind a paywall teaches the reader that we
|
||||
* hide things, which is the opposite of what we are selling.
|
||||
*
|
||||
* So this prints a short, plain answer, plus the exact command they can run themselves. If they
|
||||
* never pay us, they still got something real, and they can check that we did not lie.
|
||||
*
|
||||
* node scan-gratuit.mjs 0xAddr --rpc https://rpc.aere.network
|
||||
* node scan-gratuit.mjs 0xAddr --rpc URL --email # formatted as an email reply
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { citesteVerdict } from './lib/bytecode.js';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
|
||||
const ADRESA = argv.find((a) => /^0x[0-9a-fA-F]{40}$/.test(a));
|
||||
const RPC = arg('--rpc', 'https://rpc.aere.network');
|
||||
const EMAIL = argv.includes('--email');
|
||||
|
||||
if (!ADRESA) { console.error('folosire: node scan-gratuit.mjs 0xAdresa [--rpc URL] [--email]'); process.exitCode = 2; }
|
||||
else {
|
||||
let text = '';
|
||||
try {
|
||||
text = execFileSync(process.execPath, ['scan.js', ADRESA, '--rpc', RPC], { cwd: __dirname, encoding: 'utf8', timeout: 120000 }).trim();
|
||||
} catch (e) { text = String((e.stdout || '') + (e.stderr || e.message)).trim(); }
|
||||
|
||||
// Verdictul vine din functia comuna din lib/bytecode.js. Un tipar scris aici ar fi a doua sursa
|
||||
// a aceluiasi adevar, si exact asa a ajuns un contract hibrid, adica clientul care a inceput deja
|
||||
// migrarea, sa primeasca "nu am putut masura adresa".
|
||||
const stare = citesteVerdict(text);
|
||||
const cod = (text.match(/code:\s*(\d+)\s*bytes/) || [])[1] || null;
|
||||
const verif = [...text.matchAll(/^\s*-\s*(0x[0-9a-f]+)\s+(.+?)\s*(\[[a-z]+\])?$/gim)].map((m) => m[2].trim());
|
||||
const deschise = [...text.matchAll(/^\s*\*\s*(\[(?:VERIFY|MEASURE)\][^\n]*)/gim)].map((m) => m[1].trim());
|
||||
|
||||
// The one sentence that has to be true and has to be useful.
|
||||
let verdict;
|
||||
if (!stare) verdict = 'We could not measure this address from the endpoint we used. That is a result about our reach, not about your contract, and we are not going to dress it up as a finding.';
|
||||
else if (!cod || cod === '0') verdict = 'This address holds no contract code, so there is nothing on chain to migrate here. Whatever authorises it is an ordinary account key, and account keys are the harder half of the problem.';
|
||||
else if (verif.length) {
|
||||
// The scanner finds the verifier ADDRESSES in the bytecode. Whether the contract actually
|
||||
// CALLS them is a separate question the scanner often cannot settle from bytecode alone, and
|
||||
// it says so in its own [VERIFY] note. Claiming "can reach" in the sales hook when the tool
|
||||
// itself refused to settle it would be selling a maybe as a yes, in the first sentence a
|
||||
// prospect reads. So the wording follows the measurement, and the caveat travels with it.
|
||||
const nesigur = deschise.some((d) => /no CALL to it was resolved|may be staged/i.test(d));
|
||||
verdict = 'The bytecode of this contract references ' + verif.length + ' live post-quantum verifier'
|
||||
+ (verif.length === 1 ? '' : 's') + ' (' + verif.slice(0, 3).join(', ') + (verif.length > 3 ? ', and more' : '') + '). '
|
||||
+ (nesigur
|
||||
? 'We could NOT confirm from bytecode alone that it calls them: the addresses are pushed, but the call target may be assembled at runtime. So this is a promising sign, not a proven capability, and settling it needs the source or a call trace.'
|
||||
: 'The calls to them resolved from the bytecode, so the verification half of a migration is already reachable in this contract.');
|
||||
}
|
||||
else verdict = 'This contract reaches no post-quantum verifier. Everything it authorises today rests on ECDSA secp256k1, which is exactly the primitive the published migration timelines are about. That is the normal situation in 2026, not a criticism.';
|
||||
|
||||
const linii = [];
|
||||
if (EMAIL) linii.push('Subject: Free quantum exposure scan for ' + ADRESA.slice(0, 10) + '...', '');
|
||||
linii.push('AERE free quantum exposure scan');
|
||||
linii.push('address : ' + ADRESA);
|
||||
linii.push('measured: ' + new Date().toISOString().slice(0, 16).replace('T', ' ') + 'Z, chain 2800, read from ' + RPC);
|
||||
linii.push('');
|
||||
linii.push('WHAT WE FOUND');
|
||||
linii.push(' ' + verdict);
|
||||
if (stare) linii.push(' Verdict from the scanner: ' + stare + (cod ? ', ' + cod + ' bytes of code' : ''));
|
||||
linii.push('');
|
||||
if (deschise.length) {
|
||||
linii.push('WHAT WE COULD NOT SETTLE, carried through word for word');
|
||||
for (const d of deschise) linii.push(' - ' + d);
|
||||
linii.push(' These are the questions the tool refuses to answer from bytecode alone. A tool that');
|
||||
linii.push(' never says this is not being careful, it is being quiet.');
|
||||
linii.push('');
|
||||
}
|
||||
linii.push('CHECK US, DO NOT TRUST US');
|
||||
linii.push(' git clone the toolkit and run the same thing against a node you operate:');
|
||||
linii.push(' node scan.js ' + ADRESA + ' --rpc https://your-own-node');
|
||||
linii.push(' If your number differs from ours, that difference is the interesting part and we');
|
||||
linii.push(' want to hear about it.');
|
||||
linii.push('');
|
||||
linii.push('WHAT THE PAID REPORT ADDS, so you can decide honestly');
|
||||
linii.push(' - every address in your estate, not one, with the same treatment');
|
||||
linii.push(' - the answers written against the published criteria (NIST IR 8547, CNSA 2.0), in the');
|
||||
linii.push(' wording a supervisor uses, as criteria and never as a certification');
|
||||
linii.push(' - a digest manifest, so an edited report is detectable');
|
||||
linii.push(' - the report digest anchored on chain under a post-quantum validator certificate,');
|
||||
linii.push(' with a public verifier you run yourself');
|
||||
linii.push(' 149 EUR, one address portfolio up to 50 for 490. Details: https://aere.network/quantum-exposure-report.html');
|
||||
linii.push('');
|
||||
linii.push('WHAT WE ARE NOT');
|
||||
linii.push(' Not an accredited audit firm, and this is not an audit. We measure the on-chain slice:');
|
||||
linii.push(' bytecode and what it can reach. Repos, TLS, dependencies and key custody are out of');
|
||||
linii.push(' scope. A post-quantum verifier called from a transaction authorised with ECDSA gives');
|
||||
linii.push(' no post-quantum security, and anyone telling you otherwise can be taken apart in five');
|
||||
linii.push(' minutes. Our own chain has one operator and no external audit yet; we publish that.');
|
||||
|
||||
console.log(linii.join('\n'));
|
||||
}
|
||||
148
scan.js
Normal file
148
scan.js
Normal file
@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
// scan.js, Aere PQC-readiness scanner CLI.
|
||||
//
|
||||
// node scan.js <address> --rpc <url> live scan over JSON-RPC [MEASURE]
|
||||
// node scan.js <address> --bytecode 0x.. offline scan of given code
|
||||
// node scan.js --fixture <name> offline scan of a bundled fixture
|
||||
// node scan.js --list-fixtures list bundled fixtures
|
||||
//
|
||||
// Reports RED (classical-only) / YELLOW (hybrid) / GREEN (PQC-capable) for an
|
||||
// EVM address on Aere (chain 2800) or any EVM chain reachable by RPC.
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { scanLive, scanBytecode, isValidAddress } from './lib/scanner.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_DIR = join(__dirname, 'fixtures');
|
||||
|
||||
const C = process.stdout.isTTY
|
||||
? { red: (s) => `\x1b[31m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m` }
|
||||
: { red: (s) => s, yellow: (s) => s, green: (s) => s, dim: (s) => s, b: (s) => s };
|
||||
|
||||
function usage(code = 0) {
|
||||
console.log(`Aere PQC-readiness scanner
|
||||
|
||||
Usage
|
||||
node scan.js <address> --rpc <url> live scan over JSON-RPC [MEASURE]
|
||||
node scan.js <address> --bytecode 0x.. offline scan of given runtime code
|
||||
node scan.js --fixture <name> offline scan of a bundled fixture
|
||||
node scan.js --list-fixtures list bundled fixtures
|
||||
node scan.js <address> --rpc <url> --json machine-readable output
|
||||
|
||||
Readiness
|
||||
RED classical-only (EOA, or a contract with no PQC precompile usage)
|
||||
YELLOW hybrid (uses both a classical primitive and the PQC precompile band)
|
||||
GREEN PQC-capable (uses Aere's live native PQC precompiles 0x0AE1..0x0AE5)
|
||||
|
||||
Scope: this classifies ACCOUNT / APPLICATION quantum exposure. It does not, and
|
||||
cannot, make Aere consensus post-quantum (chain 2800 seals with secp256k1 QBFT).`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const t = argv[i];
|
||||
if (t === '--rpc') a.rpc = argv[++i];
|
||||
else if (t === '--bytecode') a.bytecode = argv[++i];
|
||||
else if (t === '--fixture') a.fixture = argv[++i];
|
||||
else if (t === '--list-fixtures') a.listFixtures = true;
|
||||
else if (t === '--json') a.json = true;
|
||||
else if (t === '--help' || t === '-h') a.help = true;
|
||||
else a._.push(t);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function listFixtures() {
|
||||
return readdirSync(FIXTURE_DIR).filter((f) => f.endsWith('.hex')).map((f) => f.replace(/\.hex$/, ''));
|
||||
}
|
||||
|
||||
function badge(readiness) {
|
||||
if (readiness === 'GREEN') return C.green(C.b(' GREEN '));
|
||||
if (readiness === 'YELLOW') return C.yellow(C.b(' YELLOW '));
|
||||
return C.red(C.b(' RED '));
|
||||
}
|
||||
|
||||
function printReport(r) {
|
||||
console.log('');
|
||||
console.log(`${badge(r.readiness)} ${C.b(r.address)}`);
|
||||
console.log(` kind: ${r.kind}${r.live ? ` chainId: ${r.live.chainId ?? 'unknown'} code: ${r.live.codeBytes} bytes` : ''}`);
|
||||
if (r.kind === 'contract') console.log(` bytecode: ${r.bytecodeLength} bytes`);
|
||||
if (r.reason) console.log(` ${C.dim(r.reason)}`);
|
||||
|
||||
const s = r.signals || {};
|
||||
const pqc = s.pqcLivePrecompiles || [];
|
||||
if (pqc.length) {
|
||||
console.log(' post-quantum precompiles (live band 0x0AE1..0x0AE5):');
|
||||
for (const p of pqc) console.log(` - ${p.address} ${p.name} ${C.dim('[' + p.confidence + (p.callProximate ? ', call-proximate' : '') + ']')}`);
|
||||
}
|
||||
if (s.p256) console.log(` classical: ${s.p256.address} ${s.p256.name} ${C.dim('[' + s.p256.confidence + ']')}`);
|
||||
if (s.ecrecover) console.log(` classical: ${s.ecrecover.address} ${s.ecrecover.name} ${C.dim('[' + s.ecrecover.confidence + ']')}`);
|
||||
if ((s.pqcTestnetPrecompiles || []).length) {
|
||||
for (const p of s.pqcTestnetPrecompiles) console.log(` ${C.yellow('testnet-only')}: ${p.address} ${p.name}`);
|
||||
}
|
||||
if (!pqc.length && !s.p256 && !s.ecrecover) console.log(' no signature-verification precompile usage detected in bytecode.');
|
||||
|
||||
if (r.migration) console.log(` ${C.b('migration')}: ${r.migration}`);
|
||||
if ((r.flags || []).length) {
|
||||
console.log(' notes:');
|
||||
for (const f of r.flags) console.log(` ${C.dim('*')} ${f}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) usage(0);
|
||||
|
||||
if (args.listFixtures) {
|
||||
console.log(listFixtures().join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
let report;
|
||||
if (args.fixture) {
|
||||
const path = join(FIXTURE_DIR, args.fixture + '.hex');
|
||||
let code;
|
||||
try {
|
||||
code = readFileSync(path, 'utf8').trim();
|
||||
} catch {
|
||||
console.error(`fixture not found: ${args.fixture}. Available: ${listFixtures().join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
report = scanBytecode(`fixture:${args.fixture}`, code);
|
||||
} else if (args.bytecode) {
|
||||
const addr = args._[0] || '(provided bytecode)';
|
||||
report = scanBytecode(addr, args.bytecode);
|
||||
} else if (args._[0]) {
|
||||
const addr = args._[0];
|
||||
if (!isValidAddress(addr)) {
|
||||
console.error(`invalid address: ${addr}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.rpc) {
|
||||
console.error('a live address scan requires --rpc <url>. [MEASURE] Provide an RPC, or use --bytecode / --fixture for an offline scan.');
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
report = await scanLive(addr, args.rpc);
|
||||
} catch (e) {
|
||||
console.error(`[MEASURE] live scan failed against ${args.rpc}: ${e.message}`);
|
||||
console.error('The RPC path is the only part of this tool that needs network access; offline --bytecode / --fixture scans still work.');
|
||||
process.exit(2);
|
||||
}
|
||||
} else {
|
||||
usage(1);
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(report, (_k, v) => (typeof v === 'bigint' ? '0x' + v.toString(16) : v), 2));
|
||||
} else {
|
||||
printReport(report);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
157
selftest.js
Normal file
157
selftest.js
Normal file
@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// selftest.js, the runnable proof that this toolkit really works, with NO live
|
||||
// RPC required. It:
|
||||
// 1. checks keccak256 against the empty-string NIST/Ethereum vector,
|
||||
// 2. re-derives the LIVE sample AerePQCAccount address from its real Falcon
|
||||
// key and asserts it equals the on-chain address (proves CREATE2 math),
|
||||
// 3. runs the scanner against the bundled REAL bytecode fixtures and asserts
|
||||
// the RED / YELLOW / GREEN classifications,
|
||||
// 4. runs a small migration-cost simulation.
|
||||
// Exits non-zero on any failure.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { keccak256Hex, bytesToHex } from './lib/keccak.js';
|
||||
import { predictPqcAccountAddress } from './lib/migrate.js';
|
||||
import { scanBytecode } from './lib/scanner.js';
|
||||
import { simulateMigration } from './lib/simulate.js';
|
||||
import {
|
||||
encodeMigrateCalldata, encodeApproveCalldata, encodeTransferCalldata, encodeBalanceOfCalldata,
|
||||
buildMigrationTransactions,
|
||||
} from './lib/migrator.js';
|
||||
import { buildReadinessReport } from './lib/readiness.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
let failures = 0;
|
||||
const ok = (name, cond, detail = '') => {
|
||||
console.log(` ${cond ? 'PASS' : 'FAIL'} ${name}${detail ? ' ' + detail : ''}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
console.log('Aere PQC migration toolkit, self test\n');
|
||||
|
||||
// 1. keccak256 correctness
|
||||
console.log('[1] keccak256 zero-dependency implementation');
|
||||
const empty = keccak256Hex('0x');
|
||||
ok('keccak256("") == c5d2460186f7233c...', empty === '0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470', empty);
|
||||
|
||||
// 2. CREATE2 address re-derivation against the LIVE sample account
|
||||
console.log('\n[2] CREATE2 account-address derivation (vs the live on-chain sample)');
|
||||
const dep = JSON.parse(readFileSync(join(__dirname, '..', 'contracts', 'deployments', 'pqc-account.json'), 'utf8'));
|
||||
const sampleKey = dep.sampleAccount.falconPubKey;
|
||||
const expected = dep.sampleAccount.address;
|
||||
const derived = predictPqcAccountAddress({
|
||||
falconPubKey: sampleKey,
|
||||
salt: dep.sampleAccount.salt,
|
||||
factory: dep.factory,
|
||||
initCodeHash: dep.accountInitCodeHash,
|
||||
});
|
||||
ok(`predictPqcAccountAddress == ${expected}`, derived.toLowerCase() === expected.toLowerCase(), derived);
|
||||
|
||||
// 3. scanner classifications against REAL bytecode fixtures
|
||||
console.log('\n[3] scanner on bundled REAL bytecode fixtures');
|
||||
const fx = (name) => readFileSync(join(__dirname, 'fixtures', name + '.hex'), 'utf8').trim();
|
||||
|
||||
const attest = scanBytecode('fixture:AerePQCAttestation', fx('AerePQCAttestation'));
|
||||
ok('AerePQCAttestation => GREEN (native PQC precompiles)', attest.readiness === 'GREEN',
|
||||
attest.readiness + ' [' + attest.signals.pqcLivePrecompiles.map((p) => p.address).join(',') + ']');
|
||||
|
||||
const txAcct = scanBytecode('fixture:AerePQCTxAccount', fx('AerePQCTxAccount'));
|
||||
ok('AerePQCTxAccount => GREEN (native PQC precompiles)', txAcct.readiness === 'GREEN',
|
||||
txAcct.readiness + ' [' + txAcct.signals.pqcLivePrecompiles.map((p) => p.address).join(',') + ']');
|
||||
|
||||
// AereHybridAuth uses ecrecover but delegates Falcon to the SOLIDITY verifier
|
||||
// (a contract, not the native precompile), so bytecode scanning reads it RED.
|
||||
// This is the documented honest limitation, asserted here so it cannot regress.
|
||||
const hybrid = scanBytecode('fixture:AereHybridAuth', fx('AereHybridAuth'));
|
||||
ok('AereHybridAuth => RED (delegates Falcon to Solidity verifier; scope limit)', hybrid.readiness === 'RED', hybrid.readiness);
|
||||
|
||||
const falconV = scanBytecode('fixture:AereFalcon512Verifier', fx('AereFalcon512Verifier'));
|
||||
ok('AereFalcon512Verifier => RED (heavy math; 0x0100 constants are literals, not P-256)',
|
||||
falconV.readiness === 'RED' && !falconV.signals.p256, falconV.readiness);
|
||||
|
||||
// synthetic hybrid: real EVM ops calling BOTH 0x01 (ecrecover) and 0x0AE1
|
||||
// (Falcon-512) via STATICCALL. Proves the YELLOW path deterministically.
|
||||
// Sequence per call: PUSH1 retSize, PUSH1 retOff, PUSH1 argsSize, PUSH1 argsOff,
|
||||
// PUSH2/PUSH1 addr, GAS, STATICCALL, POP.
|
||||
const synthetic =
|
||||
'0x' +
|
||||
'6020' + '6000' + '6040' + '6000' + '6001' + '5a' + 'fa' + '50' + // staticcall 0x01 (ecrecover)
|
||||
'6020' + '6000' + '6040' + '6000' + '610ae1' + '5a' + 'fa' + '50'; // staticcall 0x0ae1 (Falcon-512)
|
||||
const synth = scanBytecode('synthetic:hybrid', synthetic);
|
||||
ok('synthetic 0x01 + 0x0AE1 => YELLOW (hybrid)', synth.readiness === 'YELLOW',
|
||||
synth.readiness + ' pqc=' + synth.signals.pqcLivePrecompiles.length + ' ecrecover=' + (synth.signals.ecrecover ? 'yes' : 'no'));
|
||||
|
||||
// EOA path
|
||||
const eoa = scanBytecode('0x0000000000000000000000000000000000000001', '0x');
|
||||
ok('empty code => RED (EOA)', eoa.readiness === 'RED' && eoa.kind === 'eoa', eoa.kind);
|
||||
|
||||
// 4. simulation sanity
|
||||
console.log('\n[4] migration cost simulation');
|
||||
const sim = simulateMigration(
|
||||
[{ scheme: 'falcon512', authsPerAccount: 1 }, { scheme: 'hybrid', authsPerAccount: 2 }],
|
||||
{ gweiPrice: 1 },
|
||||
);
|
||||
ok('simulation totals are positive and finite',
|
||||
sim.totals.totalGas > 0 && Number.isFinite(sim.totals.estimatedAere),
|
||||
`${sim.totals.totalGas.toLocaleString()} gas, ${sim.totals.estimatedAere.toFixed(6)} AERE`);
|
||||
|
||||
// 5. ABI calldata builders vs ethers v6 ground truth (hardcoded expected values,
|
||||
// each independently generated by ethers 6.16.0; see the toolkit README). This
|
||||
// proves the zero-dependency encoder emits byte-identical calldata to a full ABI
|
||||
// library, so what a user is handed to sign is correct.
|
||||
console.log('\n[5] migration SDK calldata (vs ethers v6 ground truth)');
|
||||
const dest = '0xa42a5e7F72E46BadC11367650Ec34D676194326f';
|
||||
const tokenA = '0x1111111111111111111111111111111111111111';
|
||||
const tokenB = '0x2222222222222222222222222222222222222222';
|
||||
const EXP = {
|
||||
migrate: '0xe015b041000000000000000000000000a42a5e7f72e46badc11367650ec34d676194326f000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000200000000000000000000000011111111111111111111111111111111111111110000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000007d0',
|
||||
approve: '0x095ea7b3000000000000000000000000a42a5e7f72e46badc11367650ec34d676194326f00000000000000000000000000000000000000000000000000000000000003e8',
|
||||
transfer: '0xa9059cbb000000000000000000000000a42a5e7f72e46badc11367650ec34d676194326f00000000000000000000000000000000000000000000000000000000000003e8',
|
||||
balanceOf: '0x70a08231000000000000000000000000a42a5e7f72e46badc11367650ec34d676194326f',
|
||||
};
|
||||
const gotMigrate = encodeMigrateCalldata({ destination: dest, tokens: [tokenA, tokenB], amounts: [1000n, 2000n], moveNative: true });
|
||||
ok('encodeMigrateCalldata == ethers migrate(...)', gotMigrate.toLowerCase() === EXP.migrate, `${(gotMigrate.length - 2) / 2} bytes`);
|
||||
ok('encodeApproveCalldata == ethers approve(...)', encodeApproveCalldata(dest, 1000n).toLowerCase() === EXP.approve);
|
||||
ok('encodeTransferCalldata == ethers transfer(...)', encodeTransferCalldata(dest, 1000n).toLowerCase() === EXP.transfer);
|
||||
ok('encodeBalanceOfCalldata == ethers balanceOf(...)', encodeBalanceOfCalldata(dest).toLowerCase() === EXP.balanceOf);
|
||||
|
||||
// 6. buildMigrationTransactions: unsigned, correct target, no signature anywhere.
|
||||
console.log('\n[6] unsigned migration transaction plan');
|
||||
const plan = buildMigrationTransactions({
|
||||
path: 'direct', eoa: '0x000000000000000000000000000000000000dEaD', falconPubKey: sampleKey,
|
||||
tokens: [{ token: tokenA, amountWei: 5n }], nativeWei: 100n,
|
||||
});
|
||||
ok('plan.signed === false (nothing signed)', plan.signed === false);
|
||||
ok('plan.target matches CREATE2 derivation', plan.target.toLowerCase() === expected.toLowerCase(), plan.target);
|
||||
ok('every tx object is unsigned (signed:false, no r/s/v/signature field)',
|
||||
plan.transactions.length > 0 && plan.transactions.every((t) => t.signed === false && !('signature' in t.tx) && !('r' in t.tx)),
|
||||
`${plan.transactions.length} txs`);
|
||||
ok('direct path builds deploy + erc20 transfer + native send (3 steps)',
|
||||
plan.transactions.length === 3 &&
|
||||
plan.transactions[0].kind === 'createAccount' &&
|
||||
plan.transactions[1].kind === 'erc20Transfer' &&
|
||||
plan.transactions[2].kind === 'nativeTransfer',
|
||||
plan.transactions.map((t) => t.kind).join(','));
|
||||
let migratorThrew = false;
|
||||
try { buildMigrationTransactions({ path: 'migrator', eoa: '0x000000000000000000000000000000000000dEaD', falconPubKey: sampleKey, tokens: [{ token: tokenA, amountWei: 5n }] }); }
|
||||
catch { migratorThrew = true; }
|
||||
ok('migrator path without an explicit migrator address is refused (not deployed)', migratorThrew);
|
||||
|
||||
// 7. readiness report composes cited gas + honest asset-move estimate.
|
||||
console.log('\n[7] quantum-readiness report');
|
||||
const rr = buildReadinessReport({
|
||||
scan: { address: dest, kind: 'eoa', readiness: 'RED', reason: 'EOA' },
|
||||
assets: { native: { symbol: 'AERE', balanceWei: '100', needsMove: true }, tokens: [{ token: tokenA, symbol: 'TKA', balance: '5', needsMove: true }], movable: 2 },
|
||||
scheme: 'falcon512_solidity', path: 'direct', projectedAuths: 1, gwei: 1,
|
||||
});
|
||||
ok('readiness one-time gas = deploy + native + erc20 (positive, finite)',
|
||||
rr.gas.oneTimeTotal > rr.gas.deploy && Number.isFinite(rr.cost.oneTimeAere),
|
||||
`${rr.gas.oneTimeTotal.toLocaleString()} gas one-time`);
|
||||
ok('readiness ongoing per-auth uses the CITED deployed-account figure (10,278,313)',
|
||||
rr.gas.perAuth === 10278313, `${rr.gas.perAuth.toLocaleString()} gas/auth`);
|
||||
ok('readiness counts 2 movable assets', rr.assetsToMove.count === 2);
|
||||
|
||||
console.log(`\n${failures === 0 ? 'ALL PASS' : failures + ' FAILURE(S)'}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
81
simulate.js
Normal file
81
simulate.js
Normal file
@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
// simulate.js, migration cost simulator CLI.
|
||||
//
|
||||
// node simulate.js --count 100 --scheme falcon512 --auths 5
|
||||
// node simulate.js --accounts accounts.json
|
||||
// node simulate.js --schemes list the modeled schemes
|
||||
//
|
||||
// Estimates the gas to migrate a set of accounts to a post-quantum scheme,
|
||||
// using the MEASURED PQC verify costs from AERE-BENCHMARK-REPORT.md.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { simulateMigration, formatSimulation, schemeList, ACCOUNT_DEPLOY_GAS } from './lib/simulate.js';
|
||||
|
||||
function usage(code = 0) {
|
||||
console.log(`Aere PQC migration cost simulator
|
||||
|
||||
Usage
|
||||
node simulate.js --count <n> [--scheme <s>] [--auths <k>] [--no-deploy] [--gwei <g>]
|
||||
node simulate.js --accounts <file.json> [--gwei <g>]
|
||||
node simulate.js --schemes list modeled schemes
|
||||
node simulate.js --json machine-readable output
|
||||
|
||||
Schemes: ${schemeList().join(', ')}
|
||||
falcon512_solidity models the CURRENTLY-DEPLOYED AerePQCAccount (~10.5M gas).
|
||||
|
||||
accounts.json shape:
|
||||
[ { "address": "0x..", "scheme": "hybrid", "authsPerAccount": 3, "deployAccount": true }, ... ]`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const a = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const t = argv[i];
|
||||
if (t === '--count') a.count = parseInt(argv[++i], 10);
|
||||
else if (t === '--scheme') a.scheme = argv[++i];
|
||||
else if (t === '--auths') a.auths = parseInt(argv[++i], 10);
|
||||
else if (t === '--gwei') a.gwei = parseFloat(argv[++i]);
|
||||
else if (t === '--no-deploy') a.noDeploy = true;
|
||||
else if (t === '--accounts') a.accounts = argv[++i];
|
||||
else if (t === '--schemes') a.schemes = true;
|
||||
else if (t === '--json') a.json = true;
|
||||
else if (t === '--help' || t === '-h') a.help = true;
|
||||
else a._.push(t);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) usage(0);
|
||||
if (args.schemes) {
|
||||
console.log(schemeList().join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
let accounts;
|
||||
if (args.accounts) {
|
||||
accounts = JSON.parse(readFileSync(args.accounts, 'utf8'));
|
||||
if (!Array.isArray(accounts)) { console.error('accounts file must be a JSON array'); process.exit(1); }
|
||||
} else if (args.count) {
|
||||
accounts = Array.from({ length: args.count }, () => ({
|
||||
scheme: args.scheme || 'falcon512',
|
||||
authsPerAccount: args.auths ?? 1,
|
||||
deployAccount: !args.noDeploy,
|
||||
}));
|
||||
} else {
|
||||
usage(1);
|
||||
}
|
||||
|
||||
const sim = simulateMigration(accounts, { gweiPrice: args.gwei });
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(sim, null, 2));
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(formatSimulation(sim));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in New Issue
Block a user