Compare commits
5 Commits
7287d8ad11
...
6d6c18ae41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d6c18ae41 | ||
|
|
ec26379e63 | ||
|
|
0740f2af2e | ||
|
|
319c3f5339 | ||
|
|
067dd3d242 |
317
README.md
317
README.md
@ -1,10 +1,14 @@
|
||||
# @aere/sdk
|
||||
|
||||
Official AERE Network TypeScript SDK. Wraps `ethers v6` with typed contract clients for every deployed AERE L1 contract.
|
||||
Official AERE Network TypeScript SDK. Wraps `ethers v6` with typed clients for the
|
||||
live AERE L1 (chain ID 2800): the DeFi, agentic-settlement, compliance,
|
||||
account-abstraction and post-quantum contract surface, the seedless hybrid PQC
|
||||
wallet, and AERE's native NIST post-quantum precompiles.
|
||||
|
||||
**SDK version:** 0.3.0
|
||||
**Last updated:** 2026-05-31
|
||||
**Network state at this release:** 16 contracts live on chain ID 2800.
|
||||
**SDK version:** 0.16.12
|
||||
**Last updated:** 2026-07-16
|
||||
**Network state at this release:** 35+ typed contract clients over a canonical
|
||||
address book of 130+ live contracts on chain 2800.
|
||||
|
||||
---
|
||||
|
||||
@ -14,14 +18,33 @@ Official AERE Network TypeScript SDK. Wraps `ethers v6` with typed contract clie
|
||||
|---|---|
|
||||
| Chain name | AERE Network |
|
||||
| Chain ID | 2800 (`0xAF0`) |
|
||||
| Consensus | Hyperledger Besu QBFT, 1-second blocks, sub-second finality, 3 validators |
|
||||
| Consensus | Hyperledger Besu QBFT, classical secp256k1 ECDSA |
|
||||
| Validators | 7, Byzantine-fault tolerance f=2, commit quorum 5 of 7 |
|
||||
| Operator / client diversity | Single operator (Foundation), single client (Besu) today |
|
||||
| Block period | 0.5 s (500 ms) target, sub-second deterministic finality |
|
||||
| Native token | AERE (18 decimals) |
|
||||
| Max supply | 2,800,000,000 AERE (capped at genesis-v2, 2026-05-07) |
|
||||
| EVM ruleset | Pectra + Fusaka, full functional parity with Ethereum mainnet |
|
||||
| HTTP RPC | `https://rpc.aere.network` |
|
||||
| WebSocket RPC | `wss://wss.aere.network` |
|
||||
| Explorer (Blockscout) | `https://explorer.aere.network` |
|
||||
| Indexer REST | `https://api.aere.network` |
|
||||
|
||||
## Consensus and post-quantum boundary (read this)
|
||||
|
||||
AERE draws a hard line between consensus and the application layer, and this SDK
|
||||
keeps it:
|
||||
|
||||
- **Consensus is classical.** Blocks are proposed and sealed under Besu QBFT with
|
||||
secp256k1 ECDSA signatures. Consensus is **not** post-quantum. Any Falcon
|
||||
quorum-certificate or second-client work is isolated-testnet R&D and has never
|
||||
run on chain 2800.
|
||||
- **PQC lives at the application layer.** Five NIST post-quantum verification
|
||||
precompiles are live on mainnet (see below). Contracts and this SDK use them for
|
||||
application-layer signatures, attestations, key registries, agent DIDs, t-of-n
|
||||
custody and a seedless wallet. None of that makes mainnet consensus
|
||||
post-quantum, and the SDK never claims it does.
|
||||
|
||||
## Genesis allocation (2,800,000,000 AERE)
|
||||
|
||||
| Wallet | Allocation | Address |
|
||||
@ -33,26 +56,114 @@ Official AERE Network TypeScript SDK. Wraps `ethers v6` with typed contract clie
|
||||
| Team Reserve | 420,000,000 | `0x7968C438204a78B4e032fcFFd9A56Edb15fdCCdf` |
|
||||
| Airdrop Reserve | 140,000,000 | `0x261913fA73D6F109382F1aE98Ff6822ff03628B1` |
|
||||
|
||||
## Deployed contracts (16 — all owned by Foundation)
|
||||
## What the SDK covers
|
||||
|
||||
| Contract | Address | Purpose |
|
||||
|---|---|---|
|
||||
| WAERE | `0x7e84d7d66d5da4cfE46Da67CDEeB05B323e1f5e8` | Wrapped AERE (WETH9-style ERC-20) |
|
||||
| AereTreasury | `0x687933AE7ea4927867AC227F1b60d476003e6119` | Timelocked foundation treasury |
|
||||
| AereOracle | `0xf0A13823A4bFa86358Fe30aaf1f44A36AcbCf399` | Multi-reporter median price feed (BTC/USD + ETH/USD live) |
|
||||
| AereIdentity | `0x658dD2CD1F798AAb19fEc8FF69A270B2d192CaD1` | DID registry, revocable attestor claims (Sumsub-backed KYC) |
|
||||
| AereFaucet | `0xDdBe942aD9eB0F3E7C541BdCF7CC2cfA29d35aE4` | 0.05 AERE drip per 24h |
|
||||
| AereCardEscrow | `0xD1f7f12830AdCFd1B7676C8460B9e30602b1f059` | Bank28 card-settlement escrow |
|
||||
| AereSecurity | `0xaD305e4D91e0a9160Bd338Fd1ecb2Ee1645daC44` | Sanctions / pause module |
|
||||
| AereStaking | `0xAbDb01d9A4f41792129b2654Fb6DDB9689360DEc` | Delegated staking · 8% APY · 7-day unbonding |
|
||||
| AereConsensus | `0xF8bDDad4aDACF9d38711e8f9aFC8a2697aBF0d47` | QBFT validator bookkeeping |
|
||||
| AereSwapFactory | `0xf0a8df7BDc25721892475B21271e52D77B0e84DC` | V2 AMM factory · 0.3% fee (router pending) |
|
||||
| AereBridge | `0x7eDa66cd93baAE19530839Bbb28ee36aC8aFAd68` | Federated lock-and-release bridge (relayer pending) |
|
||||
| AereMiningSubscription | `0xDad25d2163187DF8AAEcf9EA31b6355315Bb69f1` | 4-tier mining subscriptions (10/50/200/100 AERE per 30 days) |
|
||||
| AereNFT | `0x3f9A9D9CAB005327869396C69bE226ef98039f1c` | ERC-721 + EIP-2981 royalties |
|
||||
| AereNFTMarketplace | `0x852e07F2619F7F4aD10d9f2aC681310301d99528` | On-chain NFT marketplace · 2.5% protocol fee |
|
||||
| AereGovernanceStaked | `0x8D77C888e439C4fADb2e23F1567a0A1965F80bCb` | Stake-weighted governance (reads voting power from AereStaking) |
|
||||
| AereLockedStaking | `0x21108c28A849b05aE6b7a3a5bc435C9Bc897E7Ad` | Fixed-term locks: 30d/10%, 90d/15%, 180d/22%, 365d/30% APY |
|
||||
The canonical address book (`AERE_MAINNET` in `src/addresses.ts`) maps 130+ live
|
||||
contracts on chain 2800. On top of it the SDK ships 35 typed client classes (34
|
||||
dedicated contract clients plus the `AereClient` facade, which itself wraps 8
|
||||
core contracts), plus function-based PQC, wallet and MPC modules, grouped by
|
||||
domain:
|
||||
|
||||
| Domain | Representative clients |
|
||||
|---|---|
|
||||
| Core L1 | `AereClient` (WAERE, locked + delegated staking V2, identity, faucet, AMM factory, bridge handles) |
|
||||
| Flywheel | `sAEREClient` (ERC-4626 receipt vault, V2), `AereSinkClient` (immutable 3-bucket router) |
|
||||
| DeFi | `LendingMarketClient`, `AereInsuranceFundClient`, `RWATransferAdapterClient`, `AereCoreBookClient` (native CLOB) |
|
||||
| Rollup / RaaS | `AereRaaSFactoryClient`, `AereRollupSettlementClient` |
|
||||
| Agentic (x402) | `AereAgentClient`, `AERE402SettlementClient`, `createAere402Middleware`, `AereAgentBondClient`, `AereAIReputationClient`, `AereInferNetClient`, `AereAgentMemoryVaultClient` |
|
||||
| Compliance | `AereSanctionsRegistryClient`, `ChainalysisOracleWrapperClient`, `AereTravelRuleHashRegistryClient`, `AereForensicEventRegistryClient`, `AereZKScreenClient`, `AereAIProofClient`, `AereCompliancePoolClient`, `AereAttestationGatewayClient` |
|
||||
| Payments | `AereStateChannelsClient` (bidirectional channels + HTLC), `BestExClient` (MiCA Article 78 receipts) |
|
||||
| Post-quantum | `AerePQCClient`, `AerePQCKeyRegistryClient`, `AereAgentDIDClient`, `AereCryptoRegistryClient`, `AereHybridAuthorizerClient` |
|
||||
| Account abstraction | `ModularAccountClient`, `SessionKeyClient`, `SocialRecoveryClient`, `AerePQCSocialRecoveryClient` (ERC-7579 modular accounts, bound to AereEntryPointV2) |
|
||||
| PQC custody | `AereThresholdAccountClient` (non-custodial t-of-n post-quantum ERC-4337 account) |
|
||||
| Seedless wallet | `wallet/` module (see below) |
|
||||
|
||||
Ownership is **not** uniform. The original 2026-05-07 core contracts (WAERE,
|
||||
treasury, identity, faucet, and similar) are owned by the Foundation key
|
||||
`0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3`. Many later corrected forks and
|
||||
frontier primitives (the `*V2` redeploys, the PQC wave-1 contracts, the MPC and
|
||||
interop primitives) are either **ownerless** or owned by the deployer key
|
||||
`0xbeB3…6465`, and several are deployed **inert** (holding no value, not wired
|
||||
live) pending a founder-supervised migration. Each entry in `src/addresses.ts`
|
||||
documents its own owner, status and any deprecation. Do not assume a single owner
|
||||
across the whole book.
|
||||
|
||||
## Post-quantum precompiles (5 live on mainnet)
|
||||
|
||||
Activated on chain 2800 at block 9,189,161. Address band `0x0AE1`..`0x0AE5`:
|
||||
|
||||
| Precompile | Scheme |
|
||||
|---|---|
|
||||
| `0x0AE1` | Falcon-512 (NIST) |
|
||||
| `0x0AE2` | Falcon-1024 (NIST) |
|
||||
| `0x0AE3` | ML-DSA-44 (Dilithium2, FIPS 204) |
|
||||
| `0x0AE4` | SLH-DSA-SHA2-128s (SPHINCS+, FIPS 205) |
|
||||
| `0x0AE5` | SHAKE256 |
|
||||
|
||||
`src/pqc` provides pure-JS keygen and internal-interface signing for all four
|
||||
signature schemes, proven interoperable against the live precompiles
|
||||
(`src/test/pqc-interop.test.ts`). Falcon-1024 and ML-DSA-44 signatures can be
|
||||
recorded on-chain through `AerePQCAttestation`
|
||||
(`0x465d9E3b476BF98Aa1393079e240Db5D2a9bEA6A`), which verifies each signature via
|
||||
the matching precompile before storing it. AERE also ships extended EIP-2935
|
||||
block-hash lookback (an 8191-block window) live on mainnet, used by the
|
||||
canonical-binding validity anchors.
|
||||
|
||||
## Seedless hybrid PQC wallet
|
||||
|
||||
`src/wallet` implements a wallet with **no seed phrase**: a WebAuthn passkey is
|
||||
the everyday factor and a NIST Falcon-512 co-owner is the quantum-durable root.
|
||||
|
||||
```ts
|
||||
import {
|
||||
createSeedlessAccount, generateFalconRoot, encryptFalconSecretKey,
|
||||
upgradeToPostQuantum, buildUpgradeAttestation, signPqcAccountUserOp,
|
||||
} from '@aere/sdk';
|
||||
|
||||
const descriptor = createSeedlessAccount({ passkey }); // pre-upgrade
|
||||
const root = generateFalconRoot(); // Falcon-512 keypair
|
||||
const enc = encryptFalconSecretKey(root, passphrase); // client-side-encrypted
|
||||
const upgraded = upgradeToPostQuantum(descriptor, root.publicKey); // one-tap PQC
|
||||
```
|
||||
|
||||
The Falcon root is verified by the on-chain `AereFalcon512Verifier` /
|
||||
`AerePQCAttestation` path; the secret key is encrypted client-side and never
|
||||
leaves the device unencrypted. See `docs/PQC-WALLET-DESIGN.md`.
|
||||
|
||||
## Post-quantum identity — one front door
|
||||
|
||||
The PQC pieces (Falcon root, key registry, agent DID, guardians) stitch into a
|
||||
single flow. Three ready-made entry points:
|
||||
|
||||
- **End-to-end example** — `examples/pqc-identity-e2e.mjs`. One runnable script
|
||||
that generates a Falcon-512 root, client-encrypts it, registers it with on-chain
|
||||
proof-of-possession, opens an `AereAgentDID` with a revocable session key, and
|
||||
sets a quantum-durable guardian committee. It is **read-only**: it reads live
|
||||
chain state, **cross-checks the PoP / issuance / action challenges against the
|
||||
on-chain views**, and prints the exact transactions each write flow would send —
|
||||
nothing is signed with a funded key. Run:
|
||||
|
||||
```bash
|
||||
npm run build && node examples/pqc-identity-e2e.mjs
|
||||
```
|
||||
|
||||
- **Reference wallet dApp** — `blocky-chain-forge/pqc-wallet/`. A single
|
||||
self-contained HTML page (no external CDN, all crypto bundled inline) that walks
|
||||
the same flow in the browser against the live read-only RPC. Build it with
|
||||
`node pqc-wallet/build.mjs`; the output is served at `/pqc-wallet.html`.
|
||||
|
||||
- **Project scaffolder** — `create-aere-pqc` (`aerenew/tools/create-aere-pqc`).
|
||||
Generates a wired-up wallet or agent project:
|
||||
|
||||
```bash
|
||||
node aerenew/tools/create-aere-pqc/index.mjs my-pqc-wallet # or --template agent
|
||||
cd my-pqc-wallet && npm install && npm run dev
|
||||
```
|
||||
|
||||
The `AerePQCSocialRecoveryClient` guardian committee (M-of-N NIST PQC keys, 48h
|
||||
timelock) uses the repo-shipped `AerePQCSocialRecoveryModule` contract; that module
|
||||
is **not yet deployed to mainnet**, so those flows construct calldata and derive the
|
||||
recovery challenge locally rather than reading a live module.
|
||||
|
||||
## Install
|
||||
|
||||
@ -84,42 +195,150 @@ waere.on('Transfer', (from, to, value) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Stake 100 AERE in the 90-day locked staking tier (15% APY)
|
||||
// Delegated stake 100 AERE via the canonical AereStakingV2 pool
|
||||
const signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
|
||||
const locked = new ethers.Contract(
|
||||
AERE_MAINNET.AereLockedStaking,
|
||||
['function stake(uint8 tier) payable returns (uint256)'],
|
||||
const staking = new ethers.Contract(
|
||||
AERE_MAINNET.AereStakingV2,
|
||||
['function delegate(address validator) payable'],
|
||||
signer,
|
||||
);
|
||||
const tx = await locked.stake(1, { value: ethers.parseEther('100') });
|
||||
const tx = await staking.delegate(validatorAddress, { value: ethers.parseEther('100') });
|
||||
await tx.wait();
|
||||
```
|
||||
|
||||
## Roadmap (not in this release)
|
||||
## CLI (`aere`)
|
||||
|
||||
These are referenced in the AERE whitepaper but **NOT yet deployed on chain 2800**:
|
||||
The SDK ships a small, read-only developer CLI. After `npm i @aere/sdk` it is on
|
||||
your path as `aere`; from a clone, build first (`npm run build`) and run
|
||||
`node dist/cli/aere-cli.js <command>`.
|
||||
|
||||
- DEX: `AereSwapRouter` + bridged stablecoin + initial WAERE liquidity pool
|
||||
- Bridge: cross-chain relayer + counterparty contracts on Ethereum / BSC / Polygon
|
||||
- Lending market (collateralised AERE / WAERE)
|
||||
- Yield farm (LP rewards)
|
||||
- AereVesting (on-chain vesting schedules for Team / Strategic allocations)
|
||||
- AereInsurancePool, AereLaunchpad, AereAirdrop, AereSubscriptions
|
||||
- AereNameService (`.aere` registry)
|
||||
- ERC-4337 account abstraction stack (EntryPoint, SmartAccountFactory, Paymaster)
|
||||
- Payment channels, Lightning channels, HTLC
|
||||
- ZK rollups, Plasma child chains
|
||||
- AereTravelRule (MiCA-compliant VASP transfer disclosures)
|
||||
Every command is **read-only or purely local**. The CLI never signs a real
|
||||
transaction and never accepts a private key. Values are read live from the RPC
|
||||
and printed verbatim; on an RPC or connection error it writes to stderr and exits
|
||||
non-zero.
|
||||
|
||||
Each of these has an existing front-end shell on `aere.network` displaying a "coming soon" notice until the underlying contract ships.
|
||||
```bash
|
||||
aere chain # chain id, block height, base fee, gas price, validators, peers
|
||||
aere block <number|latest> # block summary: hash, parent, timestamp, tx count, gas, proposer
|
||||
aere tx <hash> # transaction + receipt summary
|
||||
aere addr <address> # native balance, nonce, and code size
|
||||
aere pqc schemes # supported PQC schemes, precompiles, key/signature sizes
|
||||
aere pqc keygen --scheme <1-4> # generate a PQC keypair locally (public key only)
|
||||
```
|
||||
|
||||
## Architectural notes
|
||||
Options:
|
||||
|
||||
- **Non-custodial.** End users hold their own keys. Consumer apps (e.g. Bank28) can use Privy / Magic / Web3Auth to provision wallets via email/social, then pass that signer into the SDK.
|
||||
- **Fiat rails are out of scope.** AERE is the on-chain settlement layer; fiat IBANs / cards / SEPA come from a BaaS partner (Striga, Baanx, Kulipa, etc.).
|
||||
- **Oracle is single-reporter today.** BTC/USD and ETH/USD feeds are pushed every 90s by a single reporter container. A second reporter is on the roadmap for redundancy.
|
||||
- **3 validators today.** QBFT fault tolerance is `f=0` at this validator count — single failure halts the chain until the validator returns. A path to 5 validators (f=1) is staged.
|
||||
- **AereBridge is 1-of-1 signer.** Multi-sig threshold expansion happens before any value flows.
|
||||
| Option | Applies to | Meaning |
|
||||
|---|---|---|
|
||||
| `--rpc <url>` | all chain commands | RPC endpoint (default `https://rpc.aere.network`) |
|
||||
| `--scheme <1\|2\|3\|4>` | `pqc keygen` | 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-SHA2-128s |
|
||||
| `--show-secret` | `pqc keygen` | also print the secret key. Dangerous, off by default |
|
||||
| `--version`, `-V` | | print the SDK version |
|
||||
| `--help`, `-h` | any | usage (also `aere <command> --help`) |
|
||||
|
||||
```bash
|
||||
$ aere chain
|
||||
AERE chain — https://rpc.aere.network
|
||||
Chain ID 2800 (0xaf0)
|
||||
Block height 9520927
|
||||
Base fee 1.0 gwei
|
||||
Gas price 1.0 gwei
|
||||
Validators 7
|
||||
Peers 7
|
||||
```
|
||||
|
||||
## State window on the public RPC (read before pinning a historical block)
|
||||
|
||||
Both public endpoints are **pruning full nodes**, not archive nodes.
|
||||
|
||||
- Blocks, transactions, receipts, logs and `qbft_getValidatorsByBlockNumber` go back to **block 0**.
|
||||
- **World state is kept for only about the most recent 512 blocks**, roughly 4 minutes 25 seconds
|
||||
at the measured 517 ms block interval. That bounds `eth_getBalance`, `eth_getCode`,
|
||||
`eth_getStorageAt`, `eth_call`, `eth_getProof` and `eth_getTransactionCount`.
|
||||
|
||||
Outside the window most methods say so: `eth_getBalance` / `eth_getCode` / `eth_getStorageAt`
|
||||
return `null`, and `eth_getProof` returns `-32000 World state unavailable`.
|
||||
**`eth_getTransactionCount` does not.** It returns `0x0`, which is well formed and
|
||||
indistinguishable from an account that has never transacted. Reproduce it yourself: block
|
||||
8,236,382 contains a transaction from `0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465` with nonce
|
||||
123,063, and the endpoint still answers `0x0` for that address at that block.
|
||||
|
||||
`StateWindowReader` returns a value or throws. It never returns a placeholder.
|
||||
|
||||
```ts
|
||||
import { StateWindowReader, StateWindowError } from '@aere/sdk';
|
||||
|
||||
const reader = new StateWindowReader((m, p) => provider.send(m, p));
|
||||
|
||||
await reader.getTransactionCount(addr); // latest: fine
|
||||
await reader.getBalance(addr, head - 100); // inside the window: fine
|
||||
|
||||
try {
|
||||
await reader.getTransactionCount(addr, 8_236_382); // throws, never returns 0
|
||||
} catch (e) {
|
||||
if (e instanceof StateWindowError) console.log(e.detail.reason);
|
||||
}
|
||||
|
||||
// Has this account ever signed anything? Answered only where it can be answered.
|
||||
await reader.hasEverSigned(addr); // { signed, nonce, atBlock, method }
|
||||
|
||||
// Do not trust the 512 constant, measure it against the endpoint you are using.
|
||||
await reader.measureWindow(); // { head, deepestOkDepth: 511, firstFailDepth: 512 }
|
||||
```
|
||||
|
||||
Three rules if you are not using the reader: read state at `latest`; check the depth before
|
||||
pinning a historical block; and never test a nonce for zero at a historical block.
|
||||
|
||||
## Operational notes (honest state)
|
||||
|
||||
- **Non-custodial.** End users hold their own keys. Consumer apps (wallets, card programs)
|
||||
can use Privy / Magic / Web3Auth to provision wallets via email/social, then
|
||||
pass that signer into the SDK, or use the seedless PQC wallet above.
|
||||
- **Fiat rails are out of scope.** AERE is the on-chain settlement layer; fiat
|
||||
IBANs / cards / SEPA come from a BaaS partner (Striga, Baanx, Kulipa, etc.).
|
||||
- **Decentralization.** 7 QBFT validators give f=2 (the chain keeps producing
|
||||
blocks while up to 2 validators are down), commit quorum 5 of 7. All 7 are
|
||||
operated by the Foundation on a single client (Besu), so this is
|
||||
fault-tolerance, not yet operator or client diversity. Broader validator
|
||||
operators and a second client are roadmap.
|
||||
- **Foundation key, not a multisig.** `0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3`
|
||||
is a single-key EOA (zero bytecode on-chain), described here as a
|
||||
Foundation-controlled account. A real Safe-style threshold multisig is roadmap,
|
||||
not deployed. Governance contracts (Governor + Timelock + gAERE) are deployed
|
||||
but own nothing until an audit-gated ownership transfer runs.
|
||||
- **Price oracle.** `AereOracleV2` enforces a minimum fresh-reporter quorum
|
||||
(`getPrice` reverts below it) and supersedes the flawed V1, which returned a
|
||||
median even with a single fresh reporter. The V1 contract stays the live read
|
||||
source for existing consumers until a founder-supervised repoint. Treat the
|
||||
live feed as single-reporter-grade until that repoint lands.
|
||||
- **Cross-chain bridging is not yet value-carrying.** The legacy `AereBridge`
|
||||
reports `threshold()` == 1 on-chain and has no operational relayer. The current
|
||||
approach is the Hyperlane-based `AereWarpRouteV3`, whose AERE side is deployed
|
||||
but whose Ethereum leg awaits L1 bring-up. Do not route value across the bridge
|
||||
until threshold expansion and a relayer are live.
|
||||
- **Audit status.** Contracts have had an internal self-audit (Slither, Aderyn,
|
||||
Semgrep, Mythril, SMTChecker, Halmos, Medusa) and several confirmed findings
|
||||
were fixed via the `*V2` corrected forks documented in `src/addresses.ts`. An
|
||||
external third-party audit is still pending; do not read "audited" as external.
|
||||
- **Validity anchors.** The full-EVM validity anchors (`AereEVMValidityV2`,
|
||||
`AereEVMValidityBatchV2`) are deployed and canonical-bound but not yet
|
||||
exercised on mainnet (`provenCount()` reads 0 for both).
|
||||
|
||||
## Roadmap (deployed but inert, or not yet live)
|
||||
|
||||
Several contracts are on-chain but inert (holding no value, not wired live)
|
||||
pending a founder-supervised migration, and a few product surfaces are not yet
|
||||
deployed. Representative items:
|
||||
|
||||
- Cross-chain relayer + Ethereum-side counterparties for the Warp Route / bridge
|
||||
- A real Safe-style Foundation multisig, and the audit-gated governance ownership
|
||||
transfer
|
||||
- Multi-operator validator set and a second consensus client
|
||||
- External third-party security audit
|
||||
- Founder-supervised migrations for the `*V2` inert forks (sAEREv2, settlement
|
||||
hub V2, delegate-7702 V2, and others; runbooks live under `aerenew/docs/`)
|
||||
|
||||
Each contract's exact status is documented inline in `src/addresses.ts`.
|
||||
|
||||
## Source
|
||||
|
||||
|
||||
94
examples/agent-402-pqc-client.js
Normal file
94
examples/agent-402-pqc-client.js
Normal file
@ -0,0 +1,94 @@
|
||||
// agent-402-pqc-client.js
|
||||
//
|
||||
// Drop-in example: agent-side AERE402FacilitatorPQC client. The agent's spending
|
||||
// authority is rooted in a quantum-durable Falcon key (an AereAgentDID); each payment
|
||||
// is authorized by a cheap, short-lived, revocable secp256k1 SESSION key. Rotating or
|
||||
// revoking the Falcon root instantly halts every downstream payment.
|
||||
//
|
||||
// The agent calls a paid endpoint, receives 402 + a PQC quote, signs the DID action
|
||||
// digest with its SESSION key (a single ecrecover on-chain), and retries. The Falcon
|
||||
// root is never touched on the payment hot path — it only ever issues sessions.
|
||||
//
|
||||
// Run:
|
||||
// yarn add ethers
|
||||
// SESSION_PRIVATE_KEY=0x... ENDPOINT=http://localhost:3000/v1/inference node agent-402-pqc-client.js
|
||||
//
|
||||
// Prereq: an AereAgentDID agent (Falcon root) exists, a session is issued for
|
||||
// SESSION_PRIVATE_KEY's address (with scope + spend cap + expiry), and the agent's
|
||||
// AERE402FacilitatorPQC vault is funded. The server issues the 402 quote; the agent only
|
||||
// needs its session key here.
|
||||
|
||||
const { ethers } = require('ethers');
|
||||
|
||||
const SESSION_PK = process.env.SESSION_PRIVATE_KEY;
|
||||
const ENDPOINT = process.env.ENDPOINT ?? 'http://localhost:3000/v1/inference';
|
||||
|
||||
if (!SESSION_PK) {
|
||||
console.error('SESSION_PRIVATE_KEY env required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Domain tags — mirror the contracts exactly.
|
||||
const PAYMENT_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes('AERE402FacilitatorPQC.v1.payment'));
|
||||
const ACTION_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes('AereAgentDID.v1.action'));
|
||||
|
||||
// keccak256(abi.encode(PAYMENT_DOMAIN, chainId, facilitator, token, payee, resourceId, deadline))
|
||||
function paymentActionHash(q) {
|
||||
const enc = ethers.AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'address', 'address', 'bytes32', 'uint256'],
|
||||
[PAYMENT_DOMAIN, q.chainId, q.facilitator, q.token, q.payee, q.resourceId, BigInt(q.deadline)],
|
||||
);
|
||||
return ethers.keccak256(enc);
|
||||
}
|
||||
|
||||
// keccak256(abi.encode(ACTION_DOMAIN, chainId, did, sessionId, scope, actionHash, amount, actionNonce))
|
||||
function actionDigest(q, actionHash) {
|
||||
const enc = ethers.AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'uint256', 'bytes32', 'bytes32', 'uint256', 'uint64'],
|
||||
[ACTION_DOMAIN, q.chainId, q.did, BigInt(q.sessionId), q.scope, actionHash, BigInt(q.amount), BigInt(q.actionNonce)],
|
||||
);
|
||||
return ethers.keccak256(enc);
|
||||
}
|
||||
|
||||
const session = new ethers.SigningKey(SESSION_PK);
|
||||
|
||||
(async () => {
|
||||
console.log(`session ${ethers.computeAddress(session)} calling ${ENDPOINT}...`);
|
||||
|
||||
// 1. Unpaid request.
|
||||
const r1 = await fetch(ENDPOINT);
|
||||
if (r1.status !== 402) {
|
||||
console.log(`unexpected status ${r1.status} on first call`);
|
||||
console.log(await r1.text());
|
||||
return;
|
||||
}
|
||||
const quoteJson = r1.headers.get('x-aere402pqc-quote');
|
||||
if (!quoteJson) throw new Error('server did not return X-AERE402PQC-Quote');
|
||||
const quote = JSON.parse(quoteJson);
|
||||
// quote = { chainId, facilitator, did, sessionId, scope, token, payee, amount,
|
||||
// resourceId, deadline, actionNonce }
|
||||
console.log(' received quote:', quote);
|
||||
|
||||
// 2. Derive the actionHash (binds the payment to THIS facilitator + terms) and the DID
|
||||
// action digest the session key must sign.
|
||||
const actionHash = paymentActionHash(quote);
|
||||
const digest = actionDigest(quote, actionHash);
|
||||
|
||||
// 3. Sign the raw digest with the SESSION key (canonical low-s; matches on-chain ecrecover).
|
||||
const signature = session.sign(digest).serialized;
|
||||
console.log(` signed: ${signature.slice(0, 18)}...`);
|
||||
|
||||
// 4. Retry with the signature. The provider POSTs
|
||||
// settle(sessionId, scope, token, payee, amount, resourceId, deadline, signature)
|
||||
// to AERE402FacilitatorPQC, which enforces the Falcon root lifecycle + scope + spend
|
||||
// cap + this session signature + anti-replay in one call, then pays out.
|
||||
const r2 = await fetch(ENDPOINT, {
|
||||
headers: {
|
||||
'X-AERE402PQC-Sig': signature,
|
||||
'X-AERE402PQC-Quote': quoteJson,
|
||||
},
|
||||
});
|
||||
console.log(` retry status: ${r2.status}`);
|
||||
console.log(` tx hash: ${r2.headers.get('x-aere402pqc-txhash')}`);
|
||||
console.log(' body:', await r2.json());
|
||||
})();
|
||||
132
examples/modular-account-demo.mjs
Normal file
132
examples/modular-account-demo.mjs
Normal file
@ -0,0 +1,132 @@
|
||||
// AereModularAccount SDK demo — session keys + social recovery on AERE chain 2800.
|
||||
//
|
||||
// READ-ONLY / DRY-RUN by default: it queries the live chain via eth_call and
|
||||
// prints the calldata that write flows WOULD send, but sends no transactions.
|
||||
// Set AERE_SEND=1 to actually broadcast (you must also wire a signer/provider
|
||||
// that can sign; the default fetch provider below only serves eth_call).
|
||||
//
|
||||
// Run:
|
||||
// cd sdk-js && npm run build # produces ./dist
|
||||
// node examples/modular-account-demo.mjs
|
||||
//
|
||||
// The clients are dependency-free (no ethers/viem). This demo uses Node's global
|
||||
// fetch to satisfy the tiny EIP-1193 `request` surface.
|
||||
|
||||
import {
|
||||
ModularAccountClient, SessionKeyClient, SocialRecoveryClient,
|
||||
AERE_MODULAR_ADDRESSES, MODULE_TYPE_VALIDATOR, MODULE_TYPE_EXECUTOR,
|
||||
SELECTOR_NATIVE_TRANSFER, RECOVERY_DELAY_SECONDS,
|
||||
} from '../dist/account/index.js';
|
||||
|
||||
const RPC = process.env.AERE_RPC || 'https://rpc.aere.network';
|
||||
const DO_SEND = process.env.AERE_SEND === '1';
|
||||
|
||||
// Minimal EIP-1193 provider over JSON-RPC (read-only unless you add signing).
|
||||
let rpcId = 1;
|
||||
const provider = {
|
||||
async request({ method, params = [] }) {
|
||||
const res = await fetch(RPC, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method, params }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`);
|
||||
return j.result;
|
||||
},
|
||||
};
|
||||
|
||||
// The sample account proven live on 2026-07-10 (root owner = the deployer).
|
||||
const ROOT_OWNER = '0xbeB33D20dFBBD49eC7AC1F617667f1f02dfd6465';
|
||||
const SALT = 1783688121732n;
|
||||
const PING = '0x6191CC59961F2C652aE1879ff731aE1349986036'; // AerePingCounter
|
||||
const PING_SELECTOR = '0x5c36b186'; // ping()
|
||||
|
||||
const mac = new ModularAccountClient(provider, AERE_MODULAR_ADDRESSES);
|
||||
const sk = new SessionKeyClient(provider, AERE_MODULAR_ADDRESSES.sessionKeyValidator);
|
||||
const sr = new SocialRecoveryClient(provider, AERE_MODULAR_ADDRESSES.socialRecoveryModule);
|
||||
|
||||
const line = (s = '') => console.log(s);
|
||||
|
||||
// ───────────────────────────── Flow 1: Modular account ─────────────────────
|
||||
line('=== 1. Modular account (CREATE2 + reads + root userOp) ===');
|
||||
const account = mac.getAddress(ROOT_OWNER, SALT); // local CREATE2 mirror
|
||||
line(`counterfactual address : ${account}`);
|
||||
line(`on-chain factory says : ${await mac.getAddressOnChain(ROOT_OWNER, SALT)}`);
|
||||
line(`deployed? : ${await mac.isDeployed(account)}`);
|
||||
line(`rootOwner : ${await mac.rootOwnerOf(account)}`);
|
||||
line(`entryPoint : ${await mac.entryPointOf(account)}`);
|
||||
line(`EntryPoint deposit : ${await mac.depositOf(account)} wei`);
|
||||
|
||||
// Build + hash a root-owner userOp that calls ping() (no submit).
|
||||
const callData = mac.encodeExecute(PING, 0n, PING_SELECTOR);
|
||||
const rootOp = await mac.buildUserOp({ sender: account, callData });
|
||||
line(`root userOp nonce : ${rootOp.nonce}`);
|
||||
line(`userOpHash (local) : ${mac.userOpHash(rootOp)}`);
|
||||
line(`userOpHash (on-chain) : ${await mac.userOpHashOnChain(rootOp)} <- must match`);
|
||||
line('to submit: signRootUserOp(rootOp, eip1193PersonalSign(walletProvider, ROOT_OWNER)) then sendHandleOps(bundler, [op], bundler)');
|
||||
if (DO_SEND) line('(AERE_SEND set, but this demo provider cannot sign — wire a wallet provider first)');
|
||||
|
||||
// ───────────────────────────── Flow 2: Session keys ────────────────────────
|
||||
line();
|
||||
line('=== 2. Session key (scope + local check + on-chain read) ===');
|
||||
const sessionKey = '0x0EE48ec9e2EE095Ce81e351457893EAAf8CB2CEC';
|
||||
const scope = {
|
||||
key: sessionKey,
|
||||
validAfter: 0,
|
||||
validUntil: 1893456000, // 2030-01-01
|
||||
maxValuePerOp: 1_000_000_000_000_000n, // 0.001 AERE
|
||||
permissions: [{ target: PING, selector: PING_SELECTOR }],
|
||||
};
|
||||
line(`install initData : ${sk.encodeInstallInitData(scope).slice(0, 74)}...`);
|
||||
line(`enableSession calldata : ${sk.encodeEnableSession(scope).slice(0, 74)}...`);
|
||||
line(' (register by: account.installModule(1, validator, initData) OR');
|
||||
line(' account.execute(validator, 0, enableSessionCalldata) as the root owner)');
|
||||
|
||||
// Local scope check BEFORE paying to submit.
|
||||
const inScope = sk.checkCallLocally(scope, { target: PING, value: 0n, data: PING_SELECTOR });
|
||||
const badSel = sk.checkCallLocally(scope, { target: PING, value: 0n, data: '0xdeadbeef' });
|
||||
const badVal = sk.checkCallLocally(scope, { target: PING, value: 2n * scope.maxValuePerOp, data: PING_SELECTOR });
|
||||
line(`in-scope ping() : ${JSON.stringify(inScope)}`);
|
||||
line(`out-of-scope selector : ${JSON.stringify(badSel)}`);
|
||||
line(`over-value transfer : ${JSON.stringify(badVal)}`);
|
||||
line(`native-transfer sentinel selector = ${SELECTOR_NATIVE_TRANSFER}`);
|
||||
|
||||
const onChainSession = await sk.getSession(account, sessionKey);
|
||||
line(`on-chain session : ${JSON.stringify(onChainSession, (k, v) => (typeof v === 'bigint' ? v.toString() : v))}`);
|
||||
|
||||
// Build a session-signed userOp (asserts scope, then leaves 0x01 signature to attach).
|
||||
const { op: sessOp, userOpHash: sessHash } = await sk.buildSessionUserOp(
|
||||
mac, account, { target: PING, value: 0n, data: PING_SELECTOR }, { scope },
|
||||
);
|
||||
line(`session userOpHash : ${sessHash}`);
|
||||
line('to submit: sk.signSessionUserOp(mac, sessOp, sessionKeySigner) -> 0x01||validator||sig, then mac.sendHandleOps(bundler, [op], bundler)');
|
||||
void sessOp;
|
||||
|
||||
// ───────────────────────────── Flow 3: Social recovery ─────────────────────
|
||||
line();
|
||||
line('=== 3. Social recovery (M-of-N guardians, 48h timelock) ===');
|
||||
line(`recovery installed? : ${await mac.isModuleInstalled(account, MODULE_TYPE_EXECUTOR, AERE_MODULAR_ADDRESSES.socialRecoveryModule)}`);
|
||||
const guardians = await sr.guardiansOf(account);
|
||||
line(`guardians : ${JSON.stringify(guardians)}`);
|
||||
line(`threshold : ${await sr.thresholdOf(account)}`);
|
||||
line(`recovery delay : ${RECOVERY_DELAY_SECONDS}s (48h)`);
|
||||
const active = await sr.getActiveRecovery(account);
|
||||
line(`active recovery : ${JSON.stringify(active)}`);
|
||||
if (active.active) {
|
||||
const remaining = active.executeAfter - Math.floor(Date.now() / 1000);
|
||||
line(` -> executable in ~${Math.max(0, remaining)}s`);
|
||||
}
|
||||
|
||||
const NEW_OWNER = '0xFa1Eb227Bd1F05104bE0bB21a3f2094238E0d6D2';
|
||||
line('install initData : ' + sr.encodeInstallInitData(guardians, 2).slice(0, 74) + '...');
|
||||
line('guardian1 initiate : ' + sr.encodeInitiateRecovery(account, NEW_OWNER));
|
||||
line('guardian2 support : ' + sr.encodeSupportRecovery(account));
|
||||
line('any guardian execute : ' + sr.encodeExecuteRecovery(account) + ' (after threshold + 48h)');
|
||||
line('owner cancel : ' + sr.encodeCancelRecovery(account));
|
||||
line('add guardian (owner) : account.execute(module, 0, ' + sr.encodeAddGuardian(NEW_OWNER).slice(0, 42) + '...)');
|
||||
|
||||
if (DO_SEND) {
|
||||
line('\n[AERE_SEND] guardian/owner flows would broadcast now via sr.sendInitiateRecovery(...) etc.');
|
||||
} else {
|
||||
line('\nDRY-RUN complete — no transactions sent. Set AERE_SEND=1 (and a signing provider) to broadcast.');
|
||||
}
|
||||
165
examples/pqc-identity-e2e.mjs
Normal file
165
examples/pqc-identity-e2e.mjs
Normal file
@ -0,0 +1,165 @@
|
||||
// AERE post-quantum identity — end-to-end example (chain 2800).
|
||||
//
|
||||
// One runnable script that stitches the shipped PQC pieces into a single front
|
||||
// door and DRIVES it against the LIVE read-only RPC:
|
||||
//
|
||||
// 1. Generate a Falcon-512 quantum-durable ROOT keypair client-side, and
|
||||
// client-side-encrypt the secret under a passphrase (no seed phrase, the
|
||||
// secret never leaves this process unencrypted).
|
||||
// 2. Register the root public key in AerePQCKeyRegistry with on-chain
|
||||
// PROOF-OF-POSSESSION — derive the PoP challenge locally and CROSS-CHECK it
|
||||
// against the live `popChallenge` view (SDK<->contract parity, proven live).
|
||||
// 3. Open an AereAgentDID rooted in that Falcon key, then issue a short-lived,
|
||||
// revocable secp256k1 SESSION key (scope + spend cap + expiry), gated by a
|
||||
// Falcon proof-of-possession. Authorize an action with the session key.
|
||||
// 4. Set a quantum-durable M-of-N guardian committee (AerePQCSocialRecoveryModule)
|
||||
// and derive the recovery challenge a guardian would PQC-sign.
|
||||
//
|
||||
// READ-ONLY / DRY-RUN: every write flow is CONSTRUCTED and PRINTED (the exact
|
||||
// calldata a wallet would broadcast) but nothing is signed with a funded key and
|
||||
// nothing is sent. The live RPC is used only for eth_call reads and the parity
|
||||
// cross-checks. No private key is ever committed or transmitted.
|
||||
//
|
||||
// Run:
|
||||
// cd aerenew/sdk-js && npm run build
|
||||
// node examples/pqc-identity-e2e.mjs
|
||||
// AERE_RPC=https://rpc.aere.network node examples/pqc-identity-e2e.mjs
|
||||
|
||||
import {
|
||||
JsonRpcProvider, Wallet, keccak256, toUtf8Bytes, hexlify, getBytes,
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from '../dist/addresses.js';
|
||||
import { generateFalconRoot, encryptFalconSecretKey } from '../dist/wallet/falconKeystore.js';
|
||||
import { AerePQCKeyRegistryClient } from '../dist/pqc/AerePQCKeyRegistryClient.js';
|
||||
import { AereAgentDIDClient } from '../dist/pqc/AereAgentDIDClient.js';
|
||||
import { AerePQCSocialRecoveryClient, recoveryChallenge as deriveRecoveryChallenge } from '../dist/account/AerePQCSocialRecoveryClient.js';
|
||||
import { SCHEME, SCHEME_NAME, verifyLocal } from '../dist/pqc/index.js';
|
||||
|
||||
const RPC = process.env.AERE_RPC || AERE_MAINNET.rpc;
|
||||
const line = (s = '') => console.log(s);
|
||||
const short = (h, n = 10) => (h.length > 2 * n + 2 ? `${h.slice(0, n + 2)}…${h.slice(-n)}` : h);
|
||||
const ok = (b) => (b ? 'OK' : 'MISMATCH');
|
||||
|
||||
const provider = new JsonRpcProvider(RPC);
|
||||
const net = await provider.getNetwork();
|
||||
const block = await provider.getBlockNumber();
|
||||
line('AERE post-quantum identity — end-to-end (read-only)');
|
||||
line(`RPC ${RPC} · chainId ${net.chainId} · block ${block}`);
|
||||
line('This script sends NO transactions. It reads live state and prints the txs a wallet WOULD send.');
|
||||
|
||||
// The identity the keys bind to. Any address works for the read-only flow — the
|
||||
// PoP challenge commits to this address, and the account that pays gas is the owner.
|
||||
// Override with AERE_OWNER=0x... to bind to your own address.
|
||||
const OWNER = process.env.AERE_OWNER || Wallet.createRandom().address;
|
||||
line(`\nowner (identity) ${OWNER}`);
|
||||
|
||||
// ── 1. Falcon-512 root, generated + encrypted client-side ────────────────────
|
||||
line('\n=== 1. Falcon-512 quantum-durable root (client-side) ===');
|
||||
const root = generateFalconRoot();
|
||||
line(`scheme ${SCHEME_NAME[root.scheme]} (id ${root.scheme})`);
|
||||
line(`public key ${root.publicKey.length} bytes ${short(hexlify(root.publicKey))}`);
|
||||
const keystore = await encryptFalconSecretKey(root, 'correct horse battery staple');
|
||||
line(`encrypted keystore ${keystore.cipher} / ${keystore.kdf} (${keystore.kdfIterations} iters)`);
|
||||
line(` ciphertext ${short(keystore.ciphertext)} (secret key never stored in the clear)`);
|
||||
|
||||
// ── 2. Register the root key with on-chain proof-of-possession ───────────────
|
||||
line('\n=== 2. Register root key with proof-of-possession (AerePQCKeyRegistry) ===');
|
||||
const reg = new AerePQCKeyRegistryClient(provider);
|
||||
line(`registry ${reg.address}`);
|
||||
line(`keyCount [live] ${await reg.keyCount()}`);
|
||||
const idNonce = await reg.identityNonce(OWNER);
|
||||
line(`identityNonce(owner) ${idNonce}`);
|
||||
// Local PoP challenge, then cross-check it against the live contract view.
|
||||
const localPop = reg.derivePopChallenge(OWNER, root.scheme, root.publicKey, idNonce);
|
||||
const chainPop = await reg.popChallenge(OWNER, root.scheme, root.publicKey, idNonce);
|
||||
line(`PoP challenge (local) ${short(localPop)}`);
|
||||
line(`PoP challenge (on-chain)${short(chainPop)} parity: ${ok(localPop.toLowerCase() === chainPop.toLowerCase())}`);
|
||||
// Build + locally verify the PoP envelope, then show the registerKey tx.
|
||||
const pop = reg.buildRegisterKeyPoP(OWNER, root.scheme, root.secretKey, root.publicKey, idNonce);
|
||||
const popValid = verifyLocal(root.scheme, getBytes(pop.challenge), pop.signature, root.publicKey);
|
||||
line(`PoP signature ${pop.signature.length} bytes verifyLocal: ${ok(popValid)}`);
|
||||
const registerCalldata = reg.contract.interface.encodeFunctionData(
|
||||
'registerKey', [root.scheme, hexlify(root.publicKey), hexlify(pop.signature)],
|
||||
);
|
||||
line(`registerKey tx to ${reg.address}`);
|
||||
line(` calldata ${(registerCalldata.length - 2) / 2} bytes ${short(registerCalldata)}`);
|
||||
line(' (broadcast: reg.registerKeyWithPoP(signer, scheme, secretKey, pubKey) — signer is the owner)');
|
||||
|
||||
// The keyId this registration would take (registry is append-only from keyCount).
|
||||
const rootKeyId = await reg.keyCount();
|
||||
line(` -> would take keyId ${rootKeyId}`);
|
||||
|
||||
// ── 3. Agent DID: Falcon root + revocable secp256k1 session key ──────────────
|
||||
line('\n=== 3. Agent DID + session key (AereAgentDID) ===');
|
||||
const did = new AereAgentDIDClient(provider);
|
||||
line(`agent DID ${did.address}`);
|
||||
line(`sessionCount [live] ${await did.sessionCount()}`);
|
||||
line(`agentExists(keyId ${rootKeyId}) ${await did.agentExists(rootKeyId)}`);
|
||||
const createAgentCalldata = did.contract.interface.encodeFunctionData('createAgent', [rootKeyId]);
|
||||
line(`createAgent tx to ${did.address} calldata ${short(createAgentCalldata)}`);
|
||||
|
||||
// Fresh secp256k1 session key (hot key, disposable). Generated locally.
|
||||
const session = Wallet.createRandom();
|
||||
const sessionParams = {
|
||||
rootKeyId,
|
||||
sessionAddr: session.address,
|
||||
scopeHash: keccak256(toUtf8Bytes('aere.agent.session.v1:inference')),
|
||||
spendCap: 10n ** 18n, // 1 AERE cumulative
|
||||
expiry: BigInt(Math.floor(Date.now() / 1000) + 30 * 24 * 3600), // +30 days
|
||||
};
|
||||
line(`session key ${session.address} (secp256k1, revocable)`);
|
||||
line(` scope / cap / expiry ${short(sessionParams.scopeHash)} / 1 AERE / ${sessionParams.expiry}`);
|
||||
// Session issuance is Falcon-PoP-gated. Cross-check the issuance challenge (explicit nonce 0).
|
||||
const localSess = did.deriveSessionChallenge(sessionParams, 0n);
|
||||
const chainSess = await did.sessionChallenge(sessionParams, 0n);
|
||||
line(`issuance challenge local ${short(localSess)} parity: ${ok(localSess.toLowerCase() === chainSess.toLowerCase())}`);
|
||||
const issue = did.buildIssueSessionPoP(sessionParams, root.secretKey, root.scheme, 0n);
|
||||
line(`issueSession popSig ${issue.popSig.length} bytes (Falcon PoP by the root)`);
|
||||
const issueCalldata = did.contract.interface.encodeFunctionData('issueSession', [
|
||||
sessionParams.rootKeyId, sessionParams.sessionAddr, sessionParams.scopeHash,
|
||||
sessionParams.spendCap, sessionParams.expiry, hexlify(issue.popSig),
|
||||
]);
|
||||
line(`issueSession tx to ${did.address} calldata ${(issueCalldata.length - 2) / 2} bytes`);
|
||||
|
||||
// Authorize an action with the SESSION key (cheap ecrecover hot path).
|
||||
const sessionId = await did.sessionCount(); // append-only
|
||||
const actionHash = keccak256(toUtf8Bytes('POST /v1/inference {"model":"aere-1"}'));
|
||||
const amount = 5n * 10n ** 17n; // 0.5 AERE
|
||||
const localDigest = did.deriveActionDigest(sessionId, sessionParams.scopeHash, actionHash, amount, 0n);
|
||||
const chainDigest = await did.actionDigest(sessionId, sessionParams.scopeHash, actionHash, amount, 0n);
|
||||
line(`action digest local ${short(localDigest)} parity: ${ok(localDigest.toLowerCase() === chainDigest.toLowerCase())}`);
|
||||
const auth = did.buildAuthorization(sessionId, sessionParams.scopeHash, actionHash, amount, 0n, session.privateKey);
|
||||
line(`authorize sig ${short(auth.signature)} (secp256k1 over the raw digest)`);
|
||||
const authCalldata = did.contract.interface.encodeFunctionData('authorize', [
|
||||
sessionId, sessionParams.scopeHash, actionHash, amount, auth.signature,
|
||||
]);
|
||||
line(`authorize tx to ${did.address} calldata ${short(authCalldata)}`);
|
||||
|
||||
// ── 4. Quantum-durable M-of-N guardians (AerePQCSocialRecoveryModule) ────────
|
||||
line('\n=== 4. PQC social-recovery guardians (AerePQCSocialRecoveryModule) ===');
|
||||
// The module is a repo contract + client but is NOT yet deployed to mainnet, so
|
||||
// there is no live address to read; MODULE_ADDR is a placeholder for the calldata.
|
||||
const MODULE_ADDR = process.env.AERE_PQC_RECOVERY_MODULE || '0x' + '00'.repeat(20);
|
||||
const rec = new AerePQCSocialRecoveryClient(MODULE_ADDR, provider);
|
||||
// Three guardian keys, each a DISTINCT active AerePQCKeyRegistry key NOT owned by
|
||||
// the account (here illustrative keyIds; on-chain they must be registered first).
|
||||
const guardianKeyIds = [11n, 12n, 13n];
|
||||
const threshold = 2n;
|
||||
line(`module (undeployed) ${MODULE_ADDR}`);
|
||||
line(`committee ${threshold}-of-${guardianKeyIds.length} guardian keyIds [${guardianKeyIds.join(', ')}]`);
|
||||
const installData = rec.encodeOnInstall(guardianKeyIds, threshold);
|
||||
line(`onInstall tx calldata ${(installData.length - 2) / 2} bytes ${short(installData)}`);
|
||||
line(`addGuardianKey(14) tx ${short(rec.encodeAddGuardianKey(14n))}`);
|
||||
line(`setThreshold(3) tx ${short(rec.encodeSetThreshold(3n))}`);
|
||||
// A recovery: the challenge each guardian PQC-signs to swap the account's root owner.
|
||||
const account = OWNER;
|
||||
const newOwner = Wallet.createRandom().address;
|
||||
const round = 0n; // first round for a fresh account
|
||||
const chal = deriveRecoveryChallenge(MODULE_ADDR, account, newOwner, round, net.chainId);
|
||||
line(`recovery: newOwner ${newOwner} round ${round}`);
|
||||
line(`recovery challenge ${short(chal)} (each of ${threshold} guardians PQC-signs this)`);
|
||||
line(' legs are built with rec.buildLegs(account, newOwner, [{guardianIndex, scheme, secretKey}...])');
|
||||
line(' then submitted via rec.scheduleRecovery(...) — permissionless, 48h timelock, then executeRecovery');
|
||||
|
||||
line('\nDone. Live cross-checks (PoP / issuance / action digest) matched the on-chain views.');
|
||||
line('Nothing was signed with a funded key and no transaction was sent.');
|
||||
65
examples/threshold-account-demo.mjs
Normal file
65
examples/threshold-account-demo.mjs
Normal file
@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Runnable demo: node examples/threshold-account-demo.mjs (after `npm run build`)
|
||||
//
|
||||
// End-to-end proof that the AereThresholdAccount SDK produces cryptographically valid
|
||||
// authorizing legs for a non-custodial t-of-n POST-QUANTUM account, using REAL Falcon-512
|
||||
// signing/verification off-chain (the same envelope the live precompile verifies). It does not
|
||||
// touch a chain: it shows exactly what each committee member signs, that ANY t members authorize,
|
||||
// and that below-threshold or forged legs do not.
|
||||
import { getBytes } from 'ethers';
|
||||
import { keygen, verifyLocal, SCHEME, signLeg, encodeLegs, thresholdExecChallenge } from '../dist/index.js';
|
||||
|
||||
const line = (s = '') => console.log(s);
|
||||
const SCH = SCHEME.FALCON512;
|
||||
const N = 3, T = 2;
|
||||
|
||||
line('=== AERE non-custodial post-quantum threshold account — SDK demo (2-of-3 Falcon-512) ===\n');
|
||||
|
||||
// 1. A committee of 3 independent post-quantum keys (in production each lives on a separate device).
|
||||
const members = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const kp = keygen(SCH, getBytes('0x' + (i + 1).toString(16).padStart(96, '0'))); // 48-byte seed, deterministic for the demo
|
||||
members.push({ memberIndex: i, publicKey: kp.publicKey, secretKey: kp.secretKey });
|
||||
line(` member ${i}: Falcon-512 pubkey ${kp.publicKey.length} bytes`);
|
||||
}
|
||||
|
||||
// 2. The account + the call to authorize. (Addresses are placeholders — this demo is off-chain.)
|
||||
const account = '0x1111111111111111111111111111111111111111';
|
||||
const target = '0x2222222222222222222222222222222222222222';
|
||||
const value = 0n;
|
||||
const data = '0xa1e78d8b'; // some function selector the account would call
|
||||
const nonce = 0n;
|
||||
|
||||
// 3. The EXACT challenge every member must sign, derived byte-for-byte like the contract.
|
||||
const challenge = thresholdExecChallenge(account, nonce, target, value, data, 2800n);
|
||||
line(`\n exec challenge @ nonce ${nonce}: ${challenge}`);
|
||||
|
||||
// 4. TWO members (0 and 2) each PQC-sign the challenge -> authorizing legs.
|
||||
const signers = [members[0], members[2]];
|
||||
const legs = signers.map((m) => signLeg(SCH, { memberIndex: m.memberIndex, secretKey: m.secretKey }, challenge));
|
||||
|
||||
// 5. Verify every leg locally with REAL Falcon verification (what the precompile enforces on-chain).
|
||||
let ok = 0;
|
||||
for (const leg of legs) {
|
||||
const member = members[leg.memberIndex];
|
||||
const valid = verifyLocal(SCH, getBytes(challenge), getBytes(leg.signature), member.publicKey);
|
||||
line(` leg from member ${leg.memberIndex}: real Falcon verify = ${valid}`);
|
||||
if (valid) ok++;
|
||||
}
|
||||
line(`\n distinct valid signers = ${ok} / threshold ${T} -> ${ok >= T ? 'AUTHORIZED' : 'REJECTED'}`);
|
||||
|
||||
// 6. The abi-encoded blob / call args the account.executeThreshold(...) would receive.
|
||||
const blob = encodeLegs(legs);
|
||||
line(`\n encoded Leg[] (${(blob.length - 2) / 2} bytes) ready for executeThreshold(target,value,data,legs)`);
|
||||
|
||||
// 7. Negative cases: below threshold, and a forged leg.
|
||||
const oneLeg = [legs[0]];
|
||||
line(`\n below-threshold (1 leg): distinct valid ${oneLeg.length} < ${T} -> REJECTED (as expected)`);
|
||||
|
||||
const forged = getBytes(legs[1].signature);
|
||||
forged[41] ^= 0x01; // flip a byte inside the Falcon esig
|
||||
const forgedValid = verifyLocal(SCH, getBytes(challenge), forged, members[2].publicKey);
|
||||
line(` forged leg: real Falcon verify = ${forgedValid} -> not counted (as expected)`);
|
||||
|
||||
line('\n=== demo complete: any 2 of the 3 post-quantum keys authorize; 1 or a forgery does not. ===');
|
||||
63
package-lock.json
generated
63
package-lock.json
generated
@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "@aere/sdk",
|
||||
"version": "0.3.0",
|
||||
"version": "0.16.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@aere/sdk",
|
||||
"version": "0.3.0",
|
||||
"version": "0.16.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/post-quantum": "^0.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.0",
|
||||
"ethers": "^6.13.4",
|
||||
@ -24,6 +27,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz",
|
||||
"integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
@ -50,6 +65,50 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/post-quantum": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz",
|
||||
"integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "~2.2.0",
|
||||
"@noble/curves": "~2.2.0",
|
||||
"@noble/hashes": "~2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/post-quantum/node_modules/@noble/curves": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz",
|
||||
"integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/post-quantum/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||
|
||||
29
package.json
29
package.json
@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "@aere/sdk",
|
||||
"version": "0.16.1",
|
||||
"description": "Official AERE Network SDK — typed ethers v6 client for the 16 contracts deployed on AERE L1 (chain ID 2800). WAERE, oracle, identity, faucet, card-escrow, delegated + locked staking, governance, AMM factory, bridge, NFT + marketplace, mining subscriptions.",
|
||||
"version": "0.16.12",
|
||||
"description": "Official AERE Network SDK: typed ethers v6 clients for AERE L1 (chain ID 2800). 35+ typed clients over a 130+ contract address book, covering core DeFi, agentic x402 settlement, compliance, ERC-4337 account abstraction, t-of-n post-quantum custody, the 5 live NIST PQC precompiles (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s, SHAKE256), and a seedless hybrid PQC wallet.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"aere": "./dist/cli/aere-cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
@ -15,12 +18,25 @@
|
||||
"import": "./dist/addresses.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/test/*.js"
|
||||
"test": "node --test dist/test/*.js",
|
||||
"test:live": "node dist/test/state-window-live.js"
|
||||
},
|
||||
"keywords": ["aere", "blockchain", "ethereum", "evm", "layer-1", "qbft", "defi", "sdk"],
|
||||
"keywords": [
|
||||
"aere",
|
||||
"blockchain",
|
||||
"ethereum",
|
||||
"evm",
|
||||
"layer-1",
|
||||
"qbft",
|
||||
"defi",
|
||||
"sdk"
|
||||
],
|
||||
"homepage": "https://aere.network/docs.html",
|
||||
"repository": "https://git.aere.network/aere-network/sdk-js",
|
||||
"license": "MIT",
|
||||
@ -31,5 +47,8 @@
|
||||
"@types/node": "^22.5.0",
|
||||
"ethers": "^6.13.4",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/post-quantum": "^0.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
22
src/abis.ts
22
src/abis.ts
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Hand-curated ABI fragments — only the methods Bank28-class integrations actually call.
|
||||
* Hand-curated ABI fragments — only the methods consumer-wallet-class integrations actually call.
|
||||
* Full ABIs are at https://aere.network/abis/<ContractName>.json
|
||||
*/
|
||||
export const ERC20_ABI = [
|
||||
@ -39,6 +39,26 @@ export const STAKING_V1_ABI = [
|
||||
'function currentAPR() external view returns (uint256)', // basis points
|
||||
] as const;
|
||||
|
||||
// AereStakingV2 — delegated stake pool (native AERE). Bug-fix redeploy of AereStaking.
|
||||
// Accurate to the deployed contract 0x1D95eF6D17aeAB732dF914Ba2d018c270BC155FC.
|
||||
export const STAKING_DELEGATED_V2_ABI = [
|
||||
'function registerValidator(uint256 commissionBps) external payable',
|
||||
'function deregisterValidator() external',
|
||||
'function delegate(address validator) external payable',
|
||||
'function unbond(address validator, uint256 amount) external',
|
||||
'function claimUnbonded() external',
|
||||
'function claimRewards(address validator) external',
|
||||
'function claimCommission() external',
|
||||
'function pendingRewards(address validator, address delegator) external view returns (uint256)',
|
||||
'function rewardRateBps() external view returns (uint256)',
|
||||
'function setRewardRateBps(uint256 newRateBps) external',
|
||||
'function validatorCount() external view returns (uint256)',
|
||||
'function totalStaked() external view returns (uint256)',
|
||||
'function validators(address v) external view returns (bool active, bool exists, uint256 selfStake, uint256 totalDelegated, uint256 commissionBps, uint256 commissionAccrued)',
|
||||
'function delegations(address validator, address delegator) external view returns (uint256 amount, uint256 stakedAt, uint256 lastClaimTime, uint256 rewardAccrued)',
|
||||
'function unbondQueueLength(address account) external view returns (uint256)',
|
||||
] as const;
|
||||
|
||||
export const LENDING_ABI = [
|
||||
'function supply(uint256 amount) external payable',
|
||||
'function withdraw(uint256 amount) external',
|
||||
|
||||
282
src/account/AerePQCSocialRecoveryClient.ts
Normal file
282
src/account/AerePQCSocialRecoveryClient.ts
Normal file
@ -0,0 +1,282 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// AerePQCSocialRecoveryClient — orchestration client for AerePQCSocialRecoveryModule, the
|
||||
// quantum-durable M-of-N recovery module for AereModularAccount (ERC-7579 executor / module type 2).
|
||||
//
|
||||
// Where SocialRecoveryClient recovers via ECDSA guardian ADDRESSES, this client recovers via
|
||||
// POST-QUANTUM guardian KEYS: each guardian is a NIST PQC public key registered (with proof-of-
|
||||
// possession) in AerePQCKeyRegistry, and a recovery is authorized by >= threshold DISTINCT
|
||||
// guardians each PQC-signing a domain-separated challenge that binds (account, newOwner, round,
|
||||
// chainid, module). This client derives that challenge byte-for-byte like the contract, produces
|
||||
// each guardian's authorizing leg from their PQC secret key (reusing the audited pqc envelope path
|
||||
// in ../pqc/schemes), encodes the leg array, and submits scheduleRecovery / executeRecovery /
|
||||
// cancelRecovery. It never holds a key and never signs beyond the key material the caller supplies.
|
||||
//
|
||||
// The challenge derivation is cross-checked against the deployed contract in the repo's hardhat
|
||||
// suite (test/AerePQCSocialRecoveryModule.test.js, "SDK challenge derivation matches the contract")
|
||||
// so SDK<->contract parity is proven, not assumed.
|
||||
|
||||
import {
|
||||
Contract,
|
||||
AbiCoder,
|
||||
keccak256,
|
||||
toUtf8Bytes,
|
||||
getBytes,
|
||||
hexlify,
|
||||
type Signer,
|
||||
type Provider,
|
||||
type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { signInternal, type SchemeId } from '../pqc/schemes.js';
|
||||
|
||||
const coder = AbiCoder.defaultAbiCoder();
|
||||
|
||||
/** Domain separator — MUST match AerePQCSocialRecoveryModule.RECOVERY_DOMAIN exactly. */
|
||||
export const RECOVERY_DOMAIN = keccak256(toUtf8Bytes('AerePQCSocialRecoveryModule.v1.recovery'));
|
||||
|
||||
/** Default AERE chain id. */
|
||||
export const AERE_CHAIN_ID = 2800n;
|
||||
|
||||
/** 48-hour recovery timelock (AerePQCSocialRecoveryModule.RECOVERY_DELAY). */
|
||||
export const RECOVERY_DELAY_SECONDS = 48 * 3600;
|
||||
|
||||
export const AERE_PQC_SOCIAL_RECOVERY_ABI = [
|
||||
'function registry() view returns (address)',
|
||||
'function RECOVERY_DELAY() view returns (uint256)',
|
||||
'function thresholdOf(address account) view returns (uint256)',
|
||||
'function recoveryNonce(address account) view returns (uint256)',
|
||||
'function isGuardianKey(address account, uint256 keyId) view returns (bool)',
|
||||
'function guardianKeysOf(address account) view returns (uint256[])',
|
||||
'function guardianCount(address account) view returns (uint256)',
|
||||
'function committee(address account) view returns (uint256 threshold, uint256 size, uint256 round, bool pending)',
|
||||
'function activeRecovery(address account) view returns (address newOwner, uint64 executeAfter, uint256 round, bool active)',
|
||||
'function recoveryChallenge(address account, address newOwner, uint256 round) view returns (bytes32)',
|
||||
'function currentRecoveryChallenge(address account, address newOwner) view returns (bytes32)',
|
||||
'function isModuleType(uint256 moduleTypeId) pure returns (bool)',
|
||||
'function name() pure returns (string)',
|
||||
// guardian configuration — msg.sender MUST be the smart account being protected
|
||||
'function onInstall(bytes data)',
|
||||
'function onUninstall(bytes data)',
|
||||
'function addGuardianKey(uint256 keyId)',
|
||||
'function removeGuardianKey(uint256 keyId)',
|
||||
'function setThreshold(uint256 threshold)',
|
||||
// recovery flow
|
||||
'function scheduleRecovery(address account, address newOwner, tuple(uint8 guardianIndex, bytes signature)[] legs) returns (uint256 round)',
|
||||
'function executeRecovery(address account)',
|
||||
'function cancelRecovery(address account)',
|
||||
] as const;
|
||||
|
||||
/** ERC-7579 module type id for an executor module (AerePQCSocialRecoveryModule is type 2). */
|
||||
export const MODULE_TYPE_EXECUTOR = 2n;
|
||||
|
||||
/** One authorizing leg: which guardian (index into the account's guardian keyId array) and that
|
||||
* guardian's PQC signature envelope over the recovery challenge. */
|
||||
export interface Leg {
|
||||
guardianIndex: number;
|
||||
signature: string; // 0x-hex envelope in the guardian scheme's on-chain encoding
|
||||
}
|
||||
|
||||
/** A guardian's signing material for producing a leg off-chain. `guardianIndex` is the position of
|
||||
* this guardian's keyId in the account's guardian array; `scheme` must match the registered key. */
|
||||
export interface GuardianKey {
|
||||
guardianIndex: number;
|
||||
scheme: SchemeId;
|
||||
secretKey: Uint8Array;
|
||||
}
|
||||
|
||||
/** A pending recovery, as read from activeRecovery(account). */
|
||||
export interface PendingRecovery {
|
||||
newOwner: string;
|
||||
executeAfter: number; // unix seconds; earliest time executeRecovery succeeds
|
||||
round: bigint;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// ── pure challenge derivation (mirrors the contract) ─────────────────────────
|
||||
|
||||
/** The exact 32-byte challenge each guardian must PQC-sign to authorize replacing `account`'s root
|
||||
* owner with `newOwner` at `round`. Mirrors AerePQCSocialRecoveryModule.recoveryChallenge:
|
||||
* keccak256(abi.encode(RECOVERY_DOMAIN, chainId, module, account, newOwner, round)). */
|
||||
export function recoveryChallenge(
|
||||
module: string,
|
||||
account: string,
|
||||
newOwner: string,
|
||||
round: bigint,
|
||||
chainId: bigint = AERE_CHAIN_ID,
|
||||
): string {
|
||||
return keccak256(
|
||||
coder.encode(
|
||||
['bytes32', 'uint256', 'address', 'address', 'address', 'uint256'],
|
||||
[RECOVERY_DOMAIN, chainId, module, account, newOwner, round],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── guardian committee install data ──────────────────────────────────────────
|
||||
|
||||
/** ABI-encode the ERC-7579 `onInstall` initData for AerePQCSocialRecoveryModule:
|
||||
* abi.encode(uint256[] guardianKeyIds, uint256 threshold). Each keyId must be a distinct,
|
||||
* currently ACTIVE AerePQCKeyRegistry key NOT owned by the account itself. This is the exact
|
||||
* `data` the account passes to the module's onInstall (directly, or as the initData of an
|
||||
* ERC-7579 installModule(2, module, initData)). */
|
||||
export function encodeGuardianInstallData(guardianKeyIds: Array<bigint | number>, threshold: bigint | number): string {
|
||||
return coder.encode(['uint256[]', 'uint256'], [guardianKeyIds.map((k) => BigInt(k)), BigInt(threshold)]);
|
||||
}
|
||||
|
||||
// ── leg production + encoding ────────────────────────────────────────────────
|
||||
|
||||
/** Produce one guardian's authorizing leg by PQC-signing `challenge` with their secret key. The
|
||||
* envelope is exactly what the on-chain registry precompile verifies (via the audited pqc path). */
|
||||
export function signRecoveryLeg(guardian: GuardianKey, challenge: string): Leg {
|
||||
const sig = signInternal(guardian.scheme, getBytes(challenge), guardian.secretKey);
|
||||
return { guardianIndex: guardian.guardianIndex, signature: hexlify(sig) };
|
||||
}
|
||||
|
||||
/** ABI-encode the leg tuple array for scheduleRecovery. */
|
||||
export function encodeLegs(legs: Leg[]): Array<[number, string]> {
|
||||
return legs.map((l) => [l.guardianIndex, l.signature]);
|
||||
}
|
||||
|
||||
// ── client ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export class AerePQCSocialRecoveryClient {
|
||||
readonly contract: Contract;
|
||||
readonly moduleAddress: string;
|
||||
private readonly chainId: bigint;
|
||||
|
||||
constructor(moduleAddress: string, runner: Signer | Provider, chainId: bigint = AERE_CHAIN_ID) {
|
||||
this.moduleAddress = moduleAddress;
|
||||
this.contract = new Contract(moduleAddress, AERE_PQC_SOCIAL_RECOVERY_ABI, runner);
|
||||
this.chainId = chainId;
|
||||
}
|
||||
|
||||
// ---- reads ----------------------------------------------------------------
|
||||
|
||||
async committee(account: string): Promise<{ threshold: bigint; size: bigint; round: bigint; pending: boolean }> {
|
||||
const [threshold, size, round, pending] = await this.contract.committee(account);
|
||||
return { threshold, size, round, pending };
|
||||
}
|
||||
|
||||
async recoveryNonce(account: string): Promise<bigint> {
|
||||
return this.contract.recoveryNonce(account);
|
||||
}
|
||||
|
||||
async guardianKeysOf(account: string): Promise<bigint[]> {
|
||||
return this.contract.guardianKeysOf(account);
|
||||
}
|
||||
|
||||
async thresholdOf(account: string): Promise<bigint> {
|
||||
return this.contract.thresholdOf(account);
|
||||
}
|
||||
|
||||
async guardianCount(account: string): Promise<bigint> {
|
||||
return this.contract.guardianCount(account);
|
||||
}
|
||||
|
||||
async isGuardianKey(account: string, keyId: bigint | number): Promise<boolean> {
|
||||
return this.contract.isGuardianKey(account, keyId);
|
||||
}
|
||||
|
||||
/** The AerePQCKeyRegistry this module verifies guardian keys against. */
|
||||
async registry(): Promise<string> {
|
||||
return this.contract.registry();
|
||||
}
|
||||
|
||||
// ---- guardian committee configuration (calldata; msg.sender must be the account) ----
|
||||
|
||||
/** Calldata for the account to INSTALL its guardian committee in one call:
|
||||
* onInstall(abi.encode(guardianKeyIds, threshold)). The account sends this to the module
|
||||
* (msg.sender == the protected account), typically via its own execute() / installModule(). */
|
||||
encodeOnInstall(guardianKeyIds: Array<bigint | number>, threshold: bigint | number): string {
|
||||
return this.contract.interface.encodeFunctionData('onInstall', [encodeGuardianInstallData(guardianKeyIds, threshold)]);
|
||||
}
|
||||
|
||||
/** Calldata for the account to add one more guardian key to its committee. */
|
||||
encodeAddGuardianKey(keyId: bigint | number): string {
|
||||
return this.contract.interface.encodeFunctionData('addGuardianKey', [BigInt(keyId)]);
|
||||
}
|
||||
|
||||
/** Calldata for the account to drop a guardian key from its committee. */
|
||||
encodeRemoveGuardianKey(keyId: bigint | number): string {
|
||||
return this.contract.interface.encodeFunctionData('removeGuardianKey', [BigInt(keyId)]);
|
||||
}
|
||||
|
||||
/** Calldata for the account to change its M-of-N threshold. */
|
||||
encodeSetThreshold(threshold: bigint | number): string {
|
||||
return this.contract.interface.encodeFunctionData('setThreshold', [BigInt(threshold)]);
|
||||
}
|
||||
|
||||
/** Calldata for scheduleRecovery (any relayer may broadcast; the PQC legs are the authorization). */
|
||||
encodeScheduleRecovery(account: string, newOwner: string, legs: Leg[]): string {
|
||||
return this.contract.interface.encodeFunctionData('scheduleRecovery', [account, newOwner, encodeLegs(legs)]);
|
||||
}
|
||||
|
||||
/** Calldata for executeRecovery once the 48h timelock elapses (permissionless). */
|
||||
encodeExecuteRecovery(account: string): string {
|
||||
return this.contract.interface.encodeFunctionData('executeRecovery', [account]);
|
||||
}
|
||||
|
||||
/** Calldata for cancelRecovery (current root owner or the account itself only). */
|
||||
encodeCancelRecovery(account: string): string {
|
||||
return this.contract.interface.encodeFunctionData('cancelRecovery', [account]);
|
||||
}
|
||||
|
||||
async getActiveRecovery(account: string): Promise<PendingRecovery> {
|
||||
const [newOwner, executeAfter, round, active] = await this.contract.activeRecovery(account);
|
||||
return { newOwner, executeAfter: Number(executeAfter), round, active };
|
||||
}
|
||||
|
||||
/** Locally derive the recovery challenge for `newOwner` at the account's CURRENT round. */
|
||||
async currentChallenge(account: string, newOwner: string): Promise<{ round: bigint; challenge: string }> {
|
||||
const round = await this.recoveryNonce(account);
|
||||
return { round, challenge: recoveryChallenge(this.moduleAddress, account, newOwner, round, this.chainId) };
|
||||
}
|
||||
|
||||
// ---- leg building ---------------------------------------------------------
|
||||
|
||||
/** Build the authorizing legs for `newOwner` from the supplied guardian keys, signing the
|
||||
* challenge for the account's current round (or an explicit `round`). */
|
||||
async buildLegs(
|
||||
account: string,
|
||||
newOwner: string,
|
||||
guardians: GuardianKey[],
|
||||
round?: bigint,
|
||||
): Promise<Leg[]> {
|
||||
const r = round ?? (await this.recoveryNonce(account));
|
||||
const challenge = recoveryChallenge(this.moduleAddress, account, newOwner, r, this.chainId);
|
||||
return guardians.map((g) => signRecoveryLeg(g, challenge));
|
||||
}
|
||||
|
||||
// ---- writes ---------------------------------------------------------------
|
||||
|
||||
/** High-level: derive the current-round challenge, sign a leg with each supplied guardian key,
|
||||
* and submit scheduleRecovery. Permissionless — any relayer can send this; the PQC legs are the
|
||||
* authorization. Arms the owner swap for RECOVERY_DELAY seconds later. */
|
||||
async authorizeAndSchedule(
|
||||
account: string,
|
||||
newOwner: string,
|
||||
guardians: GuardianKey[],
|
||||
): Promise<ContractTransactionResponse> {
|
||||
const legs = await this.buildLegs(account, newOwner, guardians);
|
||||
return this.contract.scheduleRecovery(account, newOwner, encodeLegs(legs));
|
||||
}
|
||||
|
||||
/** Submit a pre-built leg array (e.g. legs collected from guardians out of band). */
|
||||
async scheduleRecovery(
|
||||
account: string,
|
||||
newOwner: string,
|
||||
legs: Leg[],
|
||||
): Promise<ContractTransactionResponse> {
|
||||
return this.contract.scheduleRecovery(account, newOwner, encodeLegs(legs));
|
||||
}
|
||||
|
||||
/** Execute a scheduled recovery once the 48h timelock elapses (permissionless). */
|
||||
async executeRecovery(account: string): Promise<ContractTransactionResponse> {
|
||||
return this.contract.executeRecovery(account);
|
||||
}
|
||||
|
||||
/** Cancel a pending recovery (current root owner or the account itself only). */
|
||||
async cancelRecovery(account: string): Promise<ContractTransactionResponse> {
|
||||
return this.contract.cancelRecovery(account);
|
||||
}
|
||||
}
|
||||
307
src/account/ModularAccountClient.ts
Normal file
307
src/account/ModularAccountClient.ts
Normal file
@ -0,0 +1,307 @@
|
||||
// ModularAccountClient — dependency-free EIP-1193 client for AereModularAccount
|
||||
// + AereModularAccountFactory, bound to AereEntryPointV2 on AERE chain 2800.
|
||||
//
|
||||
// Covers: counterfactual CREATE2 address (local mirror of the factory), account
|
||||
// creation, module install/uninstall, root-owner execute, userOp build + hash +
|
||||
// root-owner signing, and submission via EntryPointV2.handleOps.
|
||||
|
||||
import {
|
||||
SEL, encAddr, encUint, encodeExecute, encodeExecuteBatch, encodeModuleCall,
|
||||
encodeIsModuleInstalled, encodeHandleOps, encodeGetUserOpHash, computeUserOpHash,
|
||||
decodeAddress, decodeBool, decodeUint, type CallStruct,
|
||||
} from './abi.js';
|
||||
import { clean, keccak256Hex, keccak256Utf8 } from './hash.js';
|
||||
import {
|
||||
MODULE_TYPE_VALIDATOR, MODULE_TYPE_EXECUTOR,
|
||||
type Eip1193Provider, type PackedUserOp, type GasParams, type UserOpSigner,
|
||||
} from './types.js';
|
||||
|
||||
/** keccak256(type(AereModularAccount).creationCode) — from the live deployment. */
|
||||
export const AERE_ACCOUNT_INIT_CODE_HASH =
|
||||
'0x50a965c6dfd43567e955b468ecdbd48e38e84bde5154542fc70a426cdaf10e5e';
|
||||
|
||||
export interface ModularAccountAddresses {
|
||||
factory: string; // AereModularAccountFactory
|
||||
entryPoint: string; // AereEntryPointV2
|
||||
sessionKeyValidator?: string;
|
||||
socialRecoveryModule?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live AERE chain-2800 modular-account deployment (2026-07-10).
|
||||
* sessionKeyValidator points at AereSessionKeyValidatorV2 (the audited fix), NOT
|
||||
* the deprecated V1 0x6e03A3D7A4c90d6f8dD6F0BA1F6e8aB1F8990D26. V1 had a per-batch
|
||||
* value-cap bypass (a multi-call batch each == maxValuePerOp drained N x the per-op
|
||||
* cap) and an ownership-steal via a self-targeted setRootOwner selector; V2 is a
|
||||
* drop-in module (same install-by-address surface, module type 1) that enforces a
|
||||
* maxTotalValue cap across the batch and forbids self-targeted owner selectors.
|
||||
* See aerenew/contracts/V1-TO-V2-MANIFEST.md and test/audit-fix-v2-sessionkey-2026-07.test.js.
|
||||
*/
|
||||
export const AERE_MODULAR_ADDRESSES: ModularAccountAddresses = {
|
||||
factory: '0xE3f45Ed4a81f982fF25ad172A72456a1833a440E',
|
||||
entryPoint: '0x8D6f40598d552fF0Cb358b6012cF4227B86aF770',
|
||||
sessionKeyValidator: '0xC06EAe63Ed12307F56A1506917C48C027D8852ff', // AereSessionKeyValidatorV2 (audited); supersedes flawed V1 0x6e03A3D7…0D26
|
||||
socialRecoveryModule: '0x077514DB2a85F239145537e8334CC99d42c9D812',
|
||||
};
|
||||
|
||||
export interface ModularAccountClientOptions {
|
||||
chainId?: number; // default 2800
|
||||
initCodeHash?: string; // default AERE_ACCOUNT_INIT_CODE_HASH
|
||||
}
|
||||
|
||||
const DEFAULT_GAS: Required<Omit<GasParams, 'maxFeePerGas' | 'maxPriorityFeePerGas'>> = {
|
||||
verificationGasLimit: 500_000n,
|
||||
callGasLimit: 500_000n,
|
||||
preVerificationGas: 21_000n,
|
||||
};
|
||||
|
||||
/** EIP-55 checksum an address using the SDK's own keccak. */
|
||||
export function toChecksumAddress(addr: string): string {
|
||||
const lower = clean(addr).toLowerCase();
|
||||
if (lower.length !== 40) throw new Error(`bad address ${addr}`);
|
||||
const hash = clean(keccak256Utf8(lower));
|
||||
let out = '0x';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
out += parseInt(hash[i], 16) >= 8 ? lower[i].toUpperCase() : lower[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Root-owner signature envelope: 0x00 || 65-byte ECDSA. */
|
||||
export function routeRootSignature(sig65: string): string {
|
||||
const s = clean(sig65);
|
||||
if (s.length !== 130) throw new Error(`root signature must be 65 bytes, got ${s.length / 2}`);
|
||||
return '0x00' + s;
|
||||
}
|
||||
|
||||
/** Validator-route signature envelope: 0x01 || validator(20) || payload. */
|
||||
export function routeValidatorSignature(validator: string, payload: string): string {
|
||||
return '0x01' + clean(encAddr(validator).slice(24)) + clean(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* A UserOpSigner backed by an EIP-1193 wallet's personal_sign (good for a
|
||||
* root owner whose key lives in a browser wallet / MetaMask). For a raw
|
||||
* session-key private key, adapt your own signer (ethers:
|
||||
* `(h) => wallet.signMessage(getBytes(h))`, viem:
|
||||
* `(h) => account.signMessage({ message: { raw: h } })`).
|
||||
*/
|
||||
export function eip1193PersonalSign(provider: Eip1193Provider, signerAddress: string): UserOpSigner {
|
||||
return async (userOpHash: string) => {
|
||||
const sig = await provider.request({ method: 'personal_sign', params: [userOpHash, signerAddress] });
|
||||
return sig as string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ModularAccountClient {
|
||||
readonly provider: Eip1193Provider;
|
||||
readonly factory: string;
|
||||
readonly entryPoint: string;
|
||||
readonly chainId: number;
|
||||
readonly initCodeHash: string;
|
||||
|
||||
constructor(
|
||||
provider: Eip1193Provider,
|
||||
addresses: Pick<ModularAccountAddresses, 'factory' | 'entryPoint'> = AERE_MODULAR_ADDRESSES,
|
||||
opts: ModularAccountClientOptions = {},
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.factory = addresses.factory;
|
||||
this.entryPoint = addresses.entryPoint;
|
||||
this.chainId = opts.chainId ?? 2800;
|
||||
this.initCodeHash = opts.initCodeHash ?? AERE_ACCOUNT_INIT_CODE_HASH;
|
||||
}
|
||||
|
||||
// ---- CREATE2 counterfactual address (local mirror of the factory) --------
|
||||
|
||||
/** saltMix = keccak256(abi.encode(rootOwner, salt)). */
|
||||
saltMix(rootOwner: string, salt: bigint): string {
|
||||
return keccak256Hex(encAddr(rootOwner) + encUint(salt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic account address for (rootOwner, salt), computed entirely
|
||||
* off-chain. Mirrors AereModularAccountFactory.getAddress:
|
||||
* addr = keccak256(0xff || factory || saltMix || initCodeHash)[12:]
|
||||
*/
|
||||
getAddress(rootOwner: string, salt: bigint): string {
|
||||
const preimage =
|
||||
'ff' +
|
||||
clean(encAddr(this.factory).slice(24)) +
|
||||
clean(this.saltMix(rootOwner, salt)) +
|
||||
clean(this.initCodeHash);
|
||||
const h = clean(keccak256Hex(preimage));
|
||||
return toChecksumAddress(h.slice(24));
|
||||
}
|
||||
|
||||
/** Same address, but read from the deployed factory (cross-check). */
|
||||
async getAddressOnChain(rootOwner: string, salt: bigint): Promise<string> {
|
||||
const data = SEL.getAddress + encAddr(rootOwner) + encUint(salt);
|
||||
return toChecksumAddress(decodeAddress(await this.ethCall(this.factory, data)));
|
||||
}
|
||||
|
||||
async isDeployed(account: string): Promise<boolean> {
|
||||
const code = await this.provider.request({ method: 'eth_getCode', params: [account, 'latest'] });
|
||||
return typeof code === 'string' && clean(code).length > 0;
|
||||
}
|
||||
|
||||
// ---- Account reads --------------------------------------------------------
|
||||
|
||||
async rootOwnerOf(account: string): Promise<string> {
|
||||
return toChecksumAddress(decodeAddress(await this.ethCall(account, SEL.rootOwner)));
|
||||
}
|
||||
async entryPointOf(account: string): Promise<string> {
|
||||
return toChecksumAddress(decodeAddress(await this.ethCall(account, SEL.entryPoint)));
|
||||
}
|
||||
async isModuleInstalled(account: string, moduleTypeId: number, module: string): Promise<boolean> {
|
||||
return decodeBool(await this.ethCall(account, encodeIsModuleInstalled(moduleTypeId, module)));
|
||||
}
|
||||
async isValidatorInstalled(account: string, validator: string): Promise<boolean> {
|
||||
return this.isModuleInstalled(account, MODULE_TYPE_VALIDATOR, validator);
|
||||
}
|
||||
async isExecutorInstalled(account: string, module: string): Promise<boolean> {
|
||||
return this.isModuleInstalled(account, MODULE_TYPE_EXECUTOR, module);
|
||||
}
|
||||
/** EntryPoint deposit balance credited to the account. */
|
||||
async depositOf(account: string): Promise<bigint> {
|
||||
return decodeUint(await this.ethCall(this.entryPoint, SEL.balanceOf + encAddr(account)));
|
||||
}
|
||||
/** EntryPoint sequential nonce for the account. */
|
||||
async getNonce(account: string): Promise<bigint> {
|
||||
return decodeUint(await this.ethCall(this.entryPoint, SEL.getNonce + encAddr(account)));
|
||||
}
|
||||
|
||||
// ---- Calldata builders (pure) --------------------------------------------
|
||||
|
||||
encodeCreateAccount(rootOwner: string, salt: bigint): string {
|
||||
return SEL.createAccount + encAddr(rootOwner) + encUint(salt);
|
||||
}
|
||||
encodeExecute(target: string, value: bigint, data: string): string {
|
||||
return encodeExecute(target, value, data);
|
||||
}
|
||||
encodeExecuteBatch(calls: CallStruct[]): string {
|
||||
return encodeExecuteBatch(calls);
|
||||
}
|
||||
encodeInstallModule(moduleTypeId: number, module: string, initData: string): string {
|
||||
return encodeModuleCall(SEL.installModule, moduleTypeId, module, initData);
|
||||
}
|
||||
encodeUninstallModule(moduleTypeId: number, module: string, deInitData: string): string {
|
||||
return encodeModuleCall(SEL.uninstallModule, moduleTypeId, module, deInitData);
|
||||
}
|
||||
encodeSetRootOwner(newOwner: string): string {
|
||||
return SEL.setRootOwner + encAddr(newOwner);
|
||||
}
|
||||
|
||||
// ---- Writes (deploy agent / root-owner EOA only; no writes in build) -----
|
||||
|
||||
/** createAccount(rootOwner, salt) — idempotent on-chain. */
|
||||
async sendCreateAccount(from: string, rootOwner: string, salt: bigint): Promise<string> {
|
||||
return this.ethSend(from, this.factory, this.encodeCreateAccount(rootOwner, salt));
|
||||
}
|
||||
/** installModule directly as the root owner (allowed by onlyEntryPointSelfOrRoot). */
|
||||
async sendInstallModule(from: string, account: string, moduleTypeId: number, module: string, initData: string): Promise<string> {
|
||||
return this.ethSend(from, account, this.encodeInstallModule(moduleTypeId, module, initData));
|
||||
}
|
||||
async sendUninstallModule(from: string, account: string, moduleTypeId: number, module: string, deInitData: string): Promise<string> {
|
||||
return this.ethSend(from, account, this.encodeUninstallModule(moduleTypeId, module, deInitData));
|
||||
}
|
||||
/** execute() directly as the root owner. */
|
||||
async sendExecute(from: string, account: string, target: string, value: bigint, data: string): Promise<string> {
|
||||
return this.ethSend(from, account, this.encodeExecute(target, value, data));
|
||||
}
|
||||
/** Fund the account's EntryPoint deposit (depositTo(account), value in wei). */
|
||||
async sendDeposit(from: string, account: string, valueWei: bigint): Promise<string> {
|
||||
const data = SEL.depositTo + encAddr(account);
|
||||
return this.provider.request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ from, to: this.entryPoint, data, value: '0x' + valueWei.toString(16) }],
|
||||
}) as Promise<string>;
|
||||
}
|
||||
|
||||
// ---- UserOp build / hash / sign / submit ---------------------------------
|
||||
|
||||
private pack(hi: bigint, lo: bigint): string {
|
||||
return '0x' + encUint((hi << 128n) | lo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an unsigned PackedUserOp. Fetches the EntryPoint nonce and gas price
|
||||
* unless overridden. callData should be execute()/executeBatch() on `sender`.
|
||||
*/
|
||||
async buildUserOp(params: {
|
||||
sender: string;
|
||||
callData: string;
|
||||
gas?: GasParams;
|
||||
nonce?: bigint;
|
||||
initCode?: string;
|
||||
paymasterAndData?: string;
|
||||
}): Promise<PackedUserOp> {
|
||||
const nonce = params.nonce ?? (await this.getNonce(params.sender));
|
||||
const g = params.gas ?? {};
|
||||
const verif = g.verificationGasLimit ?? DEFAULT_GAS.verificationGasLimit;
|
||||
const call = g.callGasLimit ?? DEFAULT_GAS.callGasLimit;
|
||||
const preVerif = g.preVerificationGas ?? DEFAULT_GAS.preVerificationGas;
|
||||
const maxFee = g.maxFeePerGas ?? (await this.gasPrice());
|
||||
const maxPriority = g.maxPriorityFeePerGas ?? maxFee;
|
||||
return {
|
||||
sender: params.sender,
|
||||
nonce,
|
||||
initCode: params.initCode ?? '0x',
|
||||
callData: params.callData,
|
||||
accountGasLimits: this.pack(verif, call),
|
||||
preVerificationGas: preVerif,
|
||||
gasFees: this.pack(maxPriority, maxFee),
|
||||
paymasterAndData: params.paymasterAndData ?? '0x',
|
||||
signature: '0x',
|
||||
};
|
||||
}
|
||||
|
||||
/** Off-chain userOpHash (mirrors AereEntryPointV2.getUserOpHash). */
|
||||
userOpHash(op: PackedUserOp): string {
|
||||
return computeUserOpHash(op, this.entryPoint, this.chainId);
|
||||
}
|
||||
|
||||
/** On-chain userOpHash via eth_call (cross-check of the local computation). */
|
||||
async userOpHashOnChain(op: PackedUserOp): Promise<string> {
|
||||
const res = await this.ethCall(this.entryPoint, encodeGetUserOpHash(op));
|
||||
return '0x' + clean(res).slice(0, 64);
|
||||
}
|
||||
|
||||
/** Attach a root-owner (0x00) signature produced by `signer`. */
|
||||
async signRootUserOp(op: PackedUserOp, signer: UserOpSigner): Promise<PackedUserOp> {
|
||||
const sig = await signer(this.userOpHash(op));
|
||||
return { ...op, signature: routeRootSignature(sig) };
|
||||
}
|
||||
|
||||
encodeHandleOps(ops: PackedUserOp[], beneficiary: string): string {
|
||||
return encodeHandleOps(ops, beneficiary);
|
||||
}
|
||||
|
||||
/** Submit one or more ops through EntryPointV2.handleOps (from = bundler EOA). */
|
||||
async sendHandleOps(from: string, ops: PackedUserOp[], beneficiary: string): Promise<string> {
|
||||
return this.ethSend(from, this.entryPoint, encodeHandleOps(ops, beneficiary));
|
||||
}
|
||||
|
||||
// ---- low-level RPC --------------------------------------------------------
|
||||
|
||||
private async gasPrice(): Promise<bigint> {
|
||||
try {
|
||||
const p = await this.provider.request({ method: 'eth_gasPrice', params: [] });
|
||||
const v = BigInt(p as string);
|
||||
return v > 0n ? v : 1_000_000_000n;
|
||||
} catch {
|
||||
return 1_000_000_000n;
|
||||
}
|
||||
}
|
||||
private async ethCall(to: string, data: string): Promise<string> {
|
||||
const r = await this.provider.request({ method: 'eth_call', params: [{ to, data }, 'latest'] });
|
||||
if (typeof r !== 'string') throw new Error('ModularAccountClient: bad eth_call result');
|
||||
return r;
|
||||
}
|
||||
private async ethSend(from: string, to: string, data: string): Promise<string> {
|
||||
return this.provider.request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ from, to, data, value: '0x0' }],
|
||||
}) as Promise<string>;
|
||||
}
|
||||
}
|
||||
156
src/account/SessionKeyClient.ts
Normal file
156
src/account/SessionKeyClient.ts
Normal file
@ -0,0 +1,156 @@
|
||||
// SessionKeyClient — dependency-free EIP-1193 client for AereSessionKeyValidator
|
||||
// (ERC-7579 module type 1). Registers scoped session keys, builds session-signed
|
||||
// userOps (0x01-routed), and checks scope locally before submission.
|
||||
//
|
||||
// Registration model (from the contract): all mutating functions key state by
|
||||
// msg.sender = the smart account, so a session is created either
|
||||
// (a) at install time: installModule(1, validator, installInitData(scope)), or
|
||||
// (b) after install: account.execute(validator, 0, encodeEnableSession(scope)).
|
||||
// initiate/enable/revoke/permission calls therefore travel THROUGH the account.
|
||||
|
||||
import {
|
||||
SEL, encAddr, encUint, encBytes4, encodeSessionScope, decodeBool, decodeUint,
|
||||
} from './abi.js';
|
||||
import { clean } from './hash.js';
|
||||
import { routeValidatorSignature } from './ModularAccountClient.js';
|
||||
import type { ModularAccountClient } from './ModularAccountClient.js';
|
||||
import {
|
||||
SELECTOR_NATIVE_TRANSFER,
|
||||
type Eip1193Provider, type PackedUserOp, type SessionScope, type SessionInfo,
|
||||
type UserOpSigner, type GasParams,
|
||||
} from './types.js';
|
||||
|
||||
function scopeArrays(scope: SessionScope): { targets: string[]; selectors: string[] } {
|
||||
return {
|
||||
targets: scope.permissions.map((p) => p.target),
|
||||
selectors: scope.permissions.map((p) => p.selector),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScopeCheck { ok: boolean; reason?: string; }
|
||||
|
||||
export class SessionKeyClient {
|
||||
readonly provider: Eip1193Provider;
|
||||
readonly validator: string;
|
||||
|
||||
constructor(provider: Eip1193Provider, validator: string) {
|
||||
this.provider = provider;
|
||||
this.validator = validator;
|
||||
}
|
||||
|
||||
// ---- Encoders -------------------------------------------------------------
|
||||
|
||||
/** installModule(1, validator, initData) payload that pre-arms `scope`. */
|
||||
encodeInstallInitData(scope: SessionScope): string {
|
||||
const { targets, selectors } = scopeArrays(scope);
|
||||
return '0x' + encodeSessionScope(
|
||||
scope.key, scope.validAfter, scope.validUntil, scope.maxValuePerOp, targets, selectors,
|
||||
);
|
||||
}
|
||||
|
||||
/** validator.enableSession(...) calldata (wrap in account.execute or a userOp). */
|
||||
encodeEnableSession(scope: SessionScope): string {
|
||||
const { targets, selectors } = scopeArrays(scope);
|
||||
return SEL.enableSession + encodeSessionScope(
|
||||
scope.key, scope.validAfter, scope.validUntil, scope.maxValuePerOp, targets, selectors,
|
||||
);
|
||||
}
|
||||
|
||||
encodeRevokeSession(key: string): string {
|
||||
return SEL.revokeSession + encAddr(key);
|
||||
}
|
||||
encodeAddPermission(key: string, target: string, selector: string): string {
|
||||
return SEL.addPermission + encAddr(key) + encAddr(target) + encBytes4(selector);
|
||||
}
|
||||
encodeRemovePermission(key: string, target: string, selector: string): string {
|
||||
return SEL.removePermission + encAddr(key) + encAddr(target) + encBytes4(selector);
|
||||
}
|
||||
|
||||
// ---- Reads ----------------------------------------------------------------
|
||||
|
||||
/** sessions(account, key) -> (enabled, validAfter, validUntil, maxValuePerOp). */
|
||||
async getSession(account: string, key: string): Promise<SessionInfo> {
|
||||
const data = SEL.sessions + encAddr(account) + encAddr(key);
|
||||
const res = clean(await this.ethCall(data));
|
||||
return {
|
||||
enabled: decodeBool(res, 0),
|
||||
validAfter: Number(decodeUint(res, 1)),
|
||||
validUntil: Number(decodeUint(res, 2)),
|
||||
maxValuePerOp: decodeUint(res, 3),
|
||||
};
|
||||
}
|
||||
|
||||
/** allowedCall(account, key, target, selector) -> bool. */
|
||||
async isCallAllowed(account: string, key: string, target: string, selector: string): Promise<boolean> {
|
||||
const data = SEL.allowedCall + encAddr(account) + encAddr(key) + encAddr(target) + encBytes4(selector);
|
||||
return decodeBool(await this.ethCall(data));
|
||||
}
|
||||
|
||||
// ---- Local scope validation (mirror of the on-chain checks) --------------
|
||||
|
||||
/**
|
||||
* Verify a single intended call against `scope` WITHOUT touching the chain.
|
||||
* Mirrors AereSessionKeyValidator._checkSingle + the account's time-window
|
||||
* enforcement. `nowSec` defaults to the wall clock.
|
||||
*/
|
||||
checkCallLocally(
|
||||
scope: SessionScope,
|
||||
call: { target: string; value: bigint; data: string },
|
||||
nowSec: number = Math.floor(Date.now() / 1000),
|
||||
): ScopeCheck {
|
||||
if (scope.validAfter !== 0 && nowSec < scope.validAfter) return { ok: false, reason: 'session not yet active' };
|
||||
if (scope.validUntil !== 0 && nowSec > scope.validUntil) return { ok: false, reason: 'session expired' };
|
||||
if (call.value > scope.maxValuePerOp) return { ok: false, reason: 'value exceeds maxValuePerOp' };
|
||||
const d = clean(call.data);
|
||||
let selector: string;
|
||||
if (d.length === 0) selector = clean(SELECTOR_NATIVE_TRANSFER);
|
||||
else if (d.length >= 8) selector = d.slice(0, 8);
|
||||
else return { ok: false, reason: '1-3 byte calldata is never in scope' };
|
||||
const t = clean(call.target).toLowerCase();
|
||||
const allowed = scope.permissions.some(
|
||||
(p) => clean(p.target).toLowerCase() === t && clean(p.selector).toLowerCase() === selector,
|
||||
);
|
||||
return allowed ? { ok: true } : { ok: false, reason: `(target, 0x${selector}) not in scope` };
|
||||
}
|
||||
|
||||
// ---- Session userOp build + sign -----------------------------------------
|
||||
|
||||
/**
|
||||
* Build an unsigned userOp whose callData is account.execute(target,value,data),
|
||||
* and (optionally) assert the call is in `scope` first. Returns the op plus its
|
||||
* hash. Sign it with signSessionUserOp(...).
|
||||
*/
|
||||
async buildSessionUserOp(
|
||||
account: ModularAccountClient,
|
||||
sender: string,
|
||||
call: { target: string; value: bigint; data: string },
|
||||
opts: { scope?: SessionScope; gas?: GasParams; nonce?: bigint; assertScope?: boolean } = {},
|
||||
): Promise<{ op: PackedUserOp; userOpHash: string }> {
|
||||
if (opts.assertScope !== false && opts.scope) {
|
||||
const chk = this.checkCallLocally(opts.scope, call);
|
||||
if (!chk.ok) throw new Error(`SessionKeyClient: out-of-scope call — ${chk.reason}`);
|
||||
}
|
||||
const callData = account.encodeExecute(call.target, call.value, call.data);
|
||||
const op = await account.buildUserOp({ sender, callData, gas: opts.gas, nonce: opts.nonce });
|
||||
return { op, userOpHash: account.userOpHash(op) };
|
||||
}
|
||||
|
||||
/** 0x01 || validator || ECDSA(session key) over the personal-sign envelope. */
|
||||
buildSessionSignature(sessionSig65: string): string {
|
||||
return routeValidatorSignature(this.validator, sessionSig65);
|
||||
}
|
||||
|
||||
/** Attach a session-key signature to an op (signer signs op's userOpHash). */
|
||||
async signSessionUserOp(
|
||||
account: ModularAccountClient, op: PackedUserOp, sessionSigner: UserOpSigner,
|
||||
): Promise<PackedUserOp> {
|
||||
const sig = await sessionSigner(account.userOpHash(op));
|
||||
return { ...op, signature: this.buildSessionSignature(sig) };
|
||||
}
|
||||
|
||||
private async ethCall(data: string): Promise<string> {
|
||||
const r = await this.provider.request({ method: 'eth_call', params: [{ to: this.validator, data }, 'latest'] });
|
||||
if (typeof r !== 'string') throw new Error('SessionKeyClient: bad eth_call result');
|
||||
return r;
|
||||
}
|
||||
}
|
||||
130
src/account/SocialRecoveryClient.ts
Normal file
130
src/account/SocialRecoveryClient.ts
Normal file
@ -0,0 +1,130 @@
|
||||
// SocialRecoveryClient — dependency-free EIP-1193 client for
|
||||
// AereSocialRecoveryModule (ERC-7579 module type 2 / executor).
|
||||
//
|
||||
// Two audiences:
|
||||
// - The ACCOUNT OWNER manages config. install + addGuardian/removeGuardian/
|
||||
// setThreshold are keyed by msg.sender = the account, so they travel through
|
||||
// account.execute (or the module install path). Encoders are provided here;
|
||||
// wrap them with ModularAccountClient.encodeExecute / sendExecute.
|
||||
// - GUARDIANS (and the owner, for cancel) drive the recovery flow by calling
|
||||
// the module DIRECTLY. send* helpers below do exactly that.
|
||||
|
||||
import {
|
||||
SEL, encAddr, encUint, encodeRecoveryInit, decodeAddress, decodeUint, decodeBool,
|
||||
decodeAddressArray, word,
|
||||
} from './abi.js';
|
||||
import { clean } from './hash.js';
|
||||
import { toChecksumAddress } from './ModularAccountClient.js';
|
||||
import type { Eip1193Provider, ActiveRecovery } from './types.js';
|
||||
|
||||
/** 48-hour recovery timelock (AereSocialRecoveryModule.RECOVERY_DELAY). */
|
||||
export const RECOVERY_DELAY_SECONDS = 48 * 3600;
|
||||
|
||||
export class SocialRecoveryClient {
|
||||
readonly provider: Eip1193Provider;
|
||||
readonly module: string;
|
||||
|
||||
constructor(provider: Eip1193Provider, module: string) {
|
||||
this.provider = provider;
|
||||
this.module = module;
|
||||
}
|
||||
|
||||
// ---- Owner-side config encoders (wrap via account.execute) ----------------
|
||||
|
||||
/** installModule(2, module, initData) payload: guardians + M-of-N threshold. */
|
||||
encodeInstallInitData(guardians: string[], threshold: number | bigint): string {
|
||||
return '0x' + encodeRecoveryInit(guardians, threshold);
|
||||
}
|
||||
/** module.addGuardian(guardian) — call as account.execute(module, 0, this). */
|
||||
encodeAddGuardian(guardian: string): string {
|
||||
return SEL.addGuardian + encAddr(guardian);
|
||||
}
|
||||
encodeRemoveGuardian(guardian: string): string {
|
||||
return SEL.removeGuardian + encAddr(guardian);
|
||||
}
|
||||
encodeSetThreshold(threshold: number | bigint): string {
|
||||
return SEL.setThreshold + encUint(BigInt(threshold));
|
||||
}
|
||||
|
||||
// ---- Recovery-flow encoders (called directly by guardians / owner) --------
|
||||
|
||||
encodeInitiateRecovery(account: string, newOwner: string): string {
|
||||
return SEL.initiateRecovery + encAddr(account) + encAddr(newOwner);
|
||||
}
|
||||
encodeSupportRecovery(account: string): string {
|
||||
return SEL.supportRecovery + encAddr(account);
|
||||
}
|
||||
encodeExecuteRecovery(account: string): string {
|
||||
return SEL.executeRecovery + encAddr(account);
|
||||
}
|
||||
encodeCancelRecovery(account: string): string {
|
||||
return SEL.cancelRecovery + encAddr(account);
|
||||
}
|
||||
|
||||
// ---- Direct sends (guardian / owner EOA; no writes during build phase) ----
|
||||
|
||||
/** Guardian starts a recovery round proposing `newOwner`. */
|
||||
async sendInitiateRecovery(fromGuardian: string, account: string, newOwner: string): Promise<string> {
|
||||
return this.ethSend(fromGuardian, this.encodeInitiateRecovery(account, newOwner));
|
||||
}
|
||||
/** Another guardian approves the active round. */
|
||||
async sendSupportRecovery(fromGuardian: string, account: string): Promise<string> {
|
||||
return this.ethSend(fromGuardian, this.encodeSupportRecovery(account));
|
||||
}
|
||||
/** Any guardian executes once >= threshold approvals AND the 48h timelock pass. */
|
||||
async sendExecuteRecovery(fromGuardian: string, account: string): Promise<string> {
|
||||
return this.ethSend(fromGuardian, this.encodeExecuteRecovery(account));
|
||||
}
|
||||
/** Current owner (or the account) cancels a pending recovery. */
|
||||
async sendCancelRecovery(fromOwner: string, account: string): Promise<string> {
|
||||
return this.ethSend(fromOwner, this.encodeCancelRecovery(account));
|
||||
}
|
||||
|
||||
// ---- Reads ----------------------------------------------------------------
|
||||
|
||||
async guardiansOf(account: string): Promise<string[]> {
|
||||
const data = SEL.guardiansOf + encAddr(account);
|
||||
return decodeAddressArray(await this.ethCall(data)).map(toChecksumAddress);
|
||||
}
|
||||
async isGuardian(account: string, guardian: string): Promise<boolean> {
|
||||
const data = SEL.isGuardian + encAddr(account) + encAddr(guardian);
|
||||
return decodeBool(await this.ethCall(data));
|
||||
}
|
||||
async thresholdOf(account: string): Promise<bigint> {
|
||||
return decodeUint(await this.ethCall(SEL.thresholdOf + encAddr(account)));
|
||||
}
|
||||
async recoveryNonce(account: string): Promise<bigint> {
|
||||
return decodeUint(await this.ethCall(SEL.recoveryNonce + encAddr(account)));
|
||||
}
|
||||
async hasApproved(account: string, round: bigint, guardian: string): Promise<boolean> {
|
||||
const data = SEL.hasApproved + encAddr(account) + encUint(round) + encAddr(guardian);
|
||||
return decodeBool(await this.ethCall(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* activeRecovery(account) -> (address newOwner, uint64 executeAfter,
|
||||
* uint32 approvals, bool active).
|
||||
* When active, executeAfter is the earliest unix time executeRecovery succeeds.
|
||||
*/
|
||||
async getActiveRecovery(account: string): Promise<ActiveRecovery> {
|
||||
const res = clean(await this.ethCall(SEL.activeRecovery + encAddr(account)));
|
||||
return {
|
||||
newOwner: toChecksumAddress(decodeAddress(res, 0)),
|
||||
executeAfter: Number(BigInt('0x' + word(res, 1))),
|
||||
approvals: Number(BigInt('0x' + word(res, 2))),
|
||||
active: decodeBool(res, 3),
|
||||
};
|
||||
}
|
||||
|
||||
private async ethCall(data: string): Promise<string> {
|
||||
const r = await this.provider.request({ method: 'eth_call', params: [{ to: this.module, data }, 'latest'] });
|
||||
if (typeof r !== 'string') throw new Error('SocialRecoveryClient: bad eth_call result');
|
||||
return r;
|
||||
}
|
||||
private async ethSend(from: string, data: string): Promise<string> {
|
||||
return this.provider.request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ from, to: this.module, data, value: '0x0' }],
|
||||
}) as Promise<string>;
|
||||
}
|
||||
}
|
||||
254
src/account/abi.ts
Normal file
254
src/account/abi.ts
Normal file
@ -0,0 +1,254 @@
|
||||
// Manual ABI encode/decode for the AereModularAccount stack.
|
||||
// Same hand-rolled, dependency-free style as the other @aere/sdk clients
|
||||
// (precomputed 4-byte selectors; 32-byte word packing). No ethers/viem.
|
||||
|
||||
import { clean, keccak256Hex } from './hash.js';
|
||||
import type { PackedUserOp } from './types.js';
|
||||
|
||||
const UINT256_MAX = (1n << 256n) - 1n;
|
||||
|
||||
// ---- Precomputed 4-byte selectors -----------------------------------------
|
||||
// (verified against ethers.id(sig).slice(0,10); self-checkable via
|
||||
// keccak256Utf8(sig).slice(0,10) in tests.)
|
||||
export const SEL = {
|
||||
// AereModularAccount
|
||||
installModule: '0x9517e29f', // installModule(uint256,address,bytes)
|
||||
uninstallModule: '0xa71763a8', // uninstallModule(uint256,address,bytes)
|
||||
isModuleInstalled: '0x112d3a7d', // isModuleInstalled(uint256,address,bytes)
|
||||
setRootOwner: '0x73cd2fb0', // setRootOwner(address)
|
||||
rootOwner: '0x00ee220c', // rootOwner()
|
||||
entryPoint: '0xb0d691fe', // entryPoint()
|
||||
execute: '0xb61d27f6', // execute(address,uint256,bytes)
|
||||
executeBatch: '0x34fcd5be', // executeBatch((address,uint256,bytes)[])
|
||||
// AereModularAccountFactory
|
||||
getAddress: '0x8cb84e18', // getAddress(address,uint256)
|
||||
createAccount: '0x5fbfb9cf', // createAccount(address,uint256)
|
||||
initCodeHashView: '0xdc550e94', // ACCOUNT_INIT_CODE_HASH()
|
||||
// AereSessionKeyValidator
|
||||
enableSession: '0x6551af47', // enableSession(address,uint48,uint48,uint256,address[],bytes4[])
|
||||
revokeSession: '0x1fa5d6a4', // revokeSession(address)
|
||||
addPermission: '0xcfa8b4bf', // addPermission(address,address,bytes4)
|
||||
removePermission: '0xc31108b8', // removePermission(address,address,bytes4)
|
||||
sessions: '0x662a2bb9', // sessions(address,address)
|
||||
allowedCall: '0xe07c1298', // allowedCall(address,address,address,bytes4)
|
||||
// AereSocialRecoveryModule
|
||||
addGuardian: '0xa526d83b', // addGuardian(address)
|
||||
removeGuardian: '0x71404156', // removeGuardian(address)
|
||||
setThreshold: '0x960bfe04', // setThreshold(uint256)
|
||||
initiateRecovery: '0xa52d92cf', // initiateRecovery(address,address)
|
||||
supportRecovery: '0x57e685bc', // supportRecovery(address)
|
||||
executeRecovery: '0x3bc2c4a7', // executeRecovery(address)
|
||||
cancelRecovery: '0xc90db447', // cancelRecovery(address)
|
||||
guardiansOf: '0x82a44847', // guardiansOf(address)
|
||||
isGuardian: '0xd4ee9734', // isGuardian(address,address)
|
||||
thresholdOf: '0xdc6472cb', // thresholdOf(address)
|
||||
recoveryNonce: '0x49bcad0a', // recoveryNonce(address)
|
||||
activeRecovery: '0x27a61198', // activeRecovery(address)
|
||||
hasApproved: '0xc32a60a5', // hasApproved(address,uint256,address)
|
||||
// AereEntryPointV2
|
||||
getNonce: '0x2d0335ab', // getNonce(address)
|
||||
balanceOf: '0x70a08231', // balanceOf(address)
|
||||
depositTo: '0xb760faf9', // depositTo(address)
|
||||
handleOps: '0x765e827f', // handleOps(PackedUserOperation[],address)
|
||||
getUserOpHash: '0x22cdde4c', // getUserOpHash(PackedUserOperation)
|
||||
} as const;
|
||||
|
||||
// ---- Value encoders (all return 64-hex, no 0x prefix) ---------------------
|
||||
|
||||
export function pad32(hex: string): string {
|
||||
const h = clean(hex);
|
||||
if (h.length > 64) throw new Error(`abi: word overflow (${h.length} hex chars)`);
|
||||
return h.padStart(64, '0');
|
||||
}
|
||||
export function encUint(n: bigint | number): string {
|
||||
const v = BigInt(n);
|
||||
if (v < 0n || v > UINT256_MAX) throw new Error(`abi: uint out of range: ${v}`);
|
||||
return pad32(v.toString(16));
|
||||
}
|
||||
export function encAddr(addr: string): string {
|
||||
const h = clean(addr);
|
||||
if (h.length !== 40) throw new Error(`abi: bad address ${addr}`);
|
||||
return pad32(h);
|
||||
}
|
||||
export function encBytes32(b: string): string {
|
||||
return pad32(b);
|
||||
}
|
||||
/** bytes4 right-padded into a 32-byte word (ABI encoding of bytesN is left-aligned). */
|
||||
export function encBytes4(sel: string): string {
|
||||
const h = clean(sel);
|
||||
if (h.length !== 8) throw new Error(`abi: bad bytes4 ${sel}`);
|
||||
return (h + '0'.repeat(56));
|
||||
}
|
||||
/** Dynamic `bytes`: length word + right-padded data. */
|
||||
export function encBytesDyn(hex: string): string {
|
||||
const h = clean(hex);
|
||||
if (h.length % 2 !== 0) throw new Error('abi: odd-length bytes');
|
||||
const byteLen = h.length / 2;
|
||||
const padHex = ((32 - (byteLen % 32)) % 32) * 2;
|
||||
return encUint(BigInt(byteLen)) + h + '0'.repeat(padHex);
|
||||
}
|
||||
|
||||
// ---- Calldata builders -----------------------------------------------------
|
||||
|
||||
/** execute(address target, uint256 value, bytes data) */
|
||||
export function encodeExecute(target: string, value: bigint, data: string): string {
|
||||
const head = encAddr(target) + encUint(value) + encUint(0x60n);
|
||||
return SEL.execute + head + encBytesDyn(data);
|
||||
}
|
||||
|
||||
export interface CallStruct { target: string; value: bigint; data: string; }
|
||||
|
||||
/** executeBatch((address,uint256,bytes)[] calls) */
|
||||
export function encodeExecuteBatch(calls: CallStruct[]): string {
|
||||
// outer arg: one dynamic array -> offset 0x20
|
||||
const n = calls.length;
|
||||
const elems = calls.map((c) => {
|
||||
// tuple(address,uint256,bytes): head words [target, value, offset(0x60)] + dyn bytes
|
||||
return encAddr(c.target) + encUint(c.value) + encUint(0x60n) + encBytesDyn(c.data);
|
||||
});
|
||||
// array region: [len][off_0..off_{n-1}][elem_0..]
|
||||
// offsets are relative to the start of the offsets region (just after len word)
|
||||
let cursor = BigInt(n * 32);
|
||||
const offs: string[] = [];
|
||||
for (const e of elems) { offs.push(encUint(cursor)); cursor += BigInt(e.length / 2); }
|
||||
const arrayRegion = encUint(BigInt(n)) + offs.join('') + elems.join('');
|
||||
return SEL.executeBatch + encUint(0x20n) + arrayRegion;
|
||||
}
|
||||
|
||||
// ---- PackedUserOperation tuple encoding -----------------------------------
|
||||
// Tuple order (ERC-4337 v0.7 packed shape used by AereEntryPointV2):
|
||||
// address sender, uint256 nonce, bytes initCode, bytes callData,
|
||||
// bytes32 accountGasLimits, uint256 preVerificationGas, bytes32 gasFees,
|
||||
// bytes paymasterAndData, bytes signature
|
||||
// 4 dynamic members => 9 head words then the dynamic tails in member order.
|
||||
|
||||
export function encodePackedUserOpTuple(op: PackedUserOp): string {
|
||||
const dyn = [op.initCode, op.callData, op.paymasterAndData, op.signature].map(encBytesDyn);
|
||||
const HEAD = 9;
|
||||
let cursor = BigInt(HEAD * 32);
|
||||
const offInit = cursor; cursor += BigInt(dyn[0].length / 2);
|
||||
const offCall = cursor; cursor += BigInt(dyn[1].length / 2);
|
||||
const offPm = cursor; cursor += BigInt(dyn[2].length / 2);
|
||||
const offSig = cursor;
|
||||
const head =
|
||||
encAddr(op.sender) +
|
||||
encUint(op.nonce) +
|
||||
encUint(offInit) +
|
||||
encUint(offCall) +
|
||||
encBytes32(op.accountGasLimits) +
|
||||
encUint(op.preVerificationGas) +
|
||||
encBytes32(op.gasFees) +
|
||||
encUint(offPm) +
|
||||
encUint(offSig);
|
||||
return head + dyn.join('');
|
||||
}
|
||||
|
||||
/** getUserOpHash(PackedUserOperation op) calldata (single tuple arg -> offset 0x20). */
|
||||
export function encodeGetUserOpHash(op: PackedUserOp): string {
|
||||
return SEL.getUserOpHash + encUint(0x20n) + encodePackedUserOpTuple(op);
|
||||
}
|
||||
|
||||
/** handleOps(PackedUserOperation[] ops, address beneficiary) calldata. */
|
||||
export function encodeHandleOps(ops: PackedUserOp[], beneficiary: string): string {
|
||||
const elems = ops.map(encodePackedUserOpTuple);
|
||||
const n = ops.length;
|
||||
let cursor = BigInt(n * 32);
|
||||
const offs: string[] = [];
|
||||
for (const e of elems) { offs.push(encUint(cursor)); cursor += BigInt(e.length / 2); }
|
||||
const arrayRegion = encUint(BigInt(n)) + offs.join('') + elems.join('');
|
||||
// outer head: [offset to ops array (=0x40), beneficiary]
|
||||
return SEL.handleOps + encUint(0x40n) + encAddr(beneficiary) + arrayRegion;
|
||||
}
|
||||
|
||||
// ---- ERC-4337 v0.7 userOpHash (mirrors AereEntryPointV2.getUserOpHash) -----
|
||||
|
||||
export function computeUserOpHash(op: PackedUserOp, entryPoint: string, chainId: bigint | number): string {
|
||||
const inner = keccak256Hex(
|
||||
encAddr(op.sender) +
|
||||
encUint(op.nonce) +
|
||||
clean(keccak256Hex(op.initCode)) +
|
||||
clean(keccak256Hex(op.callData)) +
|
||||
encBytes32(op.accountGasLimits) +
|
||||
encUint(op.preVerificationGas) +
|
||||
encBytes32(op.gasFees) +
|
||||
clean(keccak256Hex(op.paymasterAndData)),
|
||||
);
|
||||
return keccak256Hex(clean(inner) + encAddr(entryPoint) + encUint(BigInt(chainId)));
|
||||
}
|
||||
|
||||
// ---- initData encoders (module install / session scope) --------------------
|
||||
|
||||
/**
|
||||
* AereSessionKeyValidator install/enable payload:
|
||||
* abi.encode(address key, uint48 validAfter, uint48 validUntil,
|
||||
* uint256 maxValuePerOp, address[] targets, bytes4[] selectors)
|
||||
*/
|
||||
export function encodeSessionScope(
|
||||
key: string, validAfter: number, validUntil: number, maxValuePerOp: bigint,
|
||||
targets: string[], selectors: string[],
|
||||
): string {
|
||||
if (targets.length !== selectors.length) throw new Error('abi: targets/selectors length mismatch');
|
||||
// 6 head words; two dynamic arrays (targets, selectors) live in the tail.
|
||||
const HEAD = 6;
|
||||
const targetsRegion = encUint(BigInt(targets.length)) + targets.map(encAddr).join('');
|
||||
const selectorsRegion = encUint(BigInt(selectors.length)) + selectors.map(encBytes4).join('');
|
||||
let cursor = BigInt(HEAD * 32);
|
||||
const offTargets = cursor; cursor += BigInt(targetsRegion.length / 2);
|
||||
const offSelectors = cursor;
|
||||
return (
|
||||
encAddr(key) +
|
||||
encUint(BigInt(validAfter)) +
|
||||
encUint(BigInt(validUntil)) +
|
||||
encUint(maxValuePerOp) +
|
||||
encUint(offTargets) +
|
||||
encUint(offSelectors) +
|
||||
targetsRegion +
|
||||
selectorsRegion
|
||||
);
|
||||
}
|
||||
|
||||
/** AereSocialRecoveryModule install payload: abi.encode(address[] guardians, uint256 threshold). */
|
||||
export function encodeRecoveryInit(guardians: string[], threshold: bigint | number): string {
|
||||
// head: [offset to guardians (0x40), threshold]; then guardians array.
|
||||
const guardiansRegion = encUint(BigInt(guardians.length)) + guardians.map(encAddr).join('');
|
||||
return encUint(0x40n) + encUint(BigInt(threshold)) + guardiansRegion;
|
||||
}
|
||||
|
||||
/** module-management calldata: installModule / uninstallModule with a bytes payload. */
|
||||
export function encodeModuleCall(selector: string, moduleTypeId: bigint | number, module: string, initData: string): string {
|
||||
const head = encUint(BigInt(moduleTypeId)) + encAddr(module) + encUint(0x60n);
|
||||
return selector + head + encBytesDyn(initData);
|
||||
}
|
||||
|
||||
/** isModuleInstalled(uint256,address,bytes) with empty trailing bytes. */
|
||||
export function encodeIsModuleInstalled(moduleTypeId: bigint | number, module: string): string {
|
||||
const head = encUint(BigInt(moduleTypeId)) + encAddr(module) + encUint(0x60n);
|
||||
return SEL.isModuleInstalled + head + encBytesDyn('0x');
|
||||
}
|
||||
|
||||
// ---- Decoders --------------------------------------------------------------
|
||||
|
||||
export function word(data: string, i: number): string {
|
||||
const h = clean(data);
|
||||
const w = h.slice(i * 64, i * 64 + 64);
|
||||
if (w.length !== 64) throw new Error('abi: short returndata');
|
||||
return w;
|
||||
}
|
||||
export function decodeUint(data: string, i = 0): bigint { return BigInt('0x' + word(data, i)); }
|
||||
export function decodeBool(data: string, i = 0): boolean { return decodeUint(data, i) !== 0n; }
|
||||
export function decodeAddress(data: string, i = 0): string { return '0x' + word(data, i).slice(24); }
|
||||
|
||||
/** Decode an ABI-encoded `address[]` return (single dynamic array). */
|
||||
export function decodeAddressArray(data: string): string[] {
|
||||
const h = clean(data);
|
||||
// word0 = offset (usually 0x20); read length at that offset.
|
||||
const off = Number(BigInt('0x' + h.slice(0, 64)));
|
||||
const lenPos = off * 2;
|
||||
const len = Number(BigInt('0x' + h.slice(lenPos, lenPos + 64)));
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const wpos = lenPos + 64 + i * 64;
|
||||
out.push('0x' + h.slice(wpos + 24, wpos + 64));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
111
src/account/hash.ts
Normal file
111
src/account/hash.ts
Normal file
@ -0,0 +1,111 @@
|
||||
// Dependency-free keccak-256 for the AereModularAccount SDK.
|
||||
//
|
||||
// Needed client-side for:
|
||||
// - CREATE2 counterfactual address = keccak256(0xff || factory || saltMix || initCodeHash)
|
||||
// with saltMix = keccak256(abi.encode(rootOwner, salt))
|
||||
// - ERC-4337 v0.7 userOpHash = keccak256(abi.encode(inner, entryPoint, chainid))
|
||||
//
|
||||
// This is the SAME vanilla Keccak-f[1600] used elsewhere in this SDK
|
||||
// (src/corebook/CoreBookClient.ts), verified against the canonical vector
|
||||
// keccak256("") = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
|
||||
// and against ethers.keccak256. No external dependency.
|
||||
|
||||
const KECCAK_RC: bigint[] = [
|
||||
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
|
||||
0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
|
||||
0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
||||
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
|
||||
0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
|
||||
0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
||||
];
|
||||
// rotation offsets r[x][y], flattened as [x + 5y]
|
||||
const KECCAK_ROT: number[] = [
|
||||
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,
|
||||
];
|
||||
const M64 = (1n << 64n) - 1n;
|
||||
|
||||
function rotl64(v: bigint, n: number): bigint {
|
||||
if (n === 0) return v;
|
||||
return ((v << BigInt(n)) | (v >> BigInt(64 - n))) & M64;
|
||||
}
|
||||
|
||||
function keccakF1600(s: bigint[]): void {
|
||||
for (let r = 0; r < 24; r++) {
|
||||
// theta
|
||||
const c: bigint[] = new Array(5);
|
||||
for (let x = 0; x < 5; x++) c[x] = s[x] ^ s[x + 5] ^ s[x + 10] ^ s[x + 15] ^ s[x + 20];
|
||||
for (let x = 0; x < 5; x++) {
|
||||
const d = c[(x + 4) % 5] ^ rotl64(c[(x + 1) % 5], 1);
|
||||
for (let y = 0; y < 25; y += 5) s[x + y] ^= d;
|
||||
}
|
||||
// rho + pi
|
||||
const b: bigint[] = new Array(25);
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(s[x + 5 * y], KECCAK_ROT[x + 5 * y]);
|
||||
}
|
||||
}
|
||||
// chi
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 25; y += 5) {
|
||||
s[x + y] = b[x + y] ^ ((~b[((x + 1) % 5) + y] & M64) & b[((x + 2) % 5) + y]);
|
||||
}
|
||||
}
|
||||
// iota
|
||||
s[0] ^= KECCAK_RC[r];
|
||||
}
|
||||
}
|
||||
|
||||
function keccak256Bytes(input: Uint8Array): Uint8Array {
|
||||
const rate = 136; // 1088-bit rate for keccak-256
|
||||
const s: bigint[] = new Array(25).fill(0n);
|
||||
const padLen = rate - (input.length % rate);
|
||||
const padded = new Uint8Array(input.length + padLen);
|
||||
padded.set(input);
|
||||
padded[input.length] = 0x01; // keccak (NOT sha3 0x06) domain padding
|
||||
padded[padded.length - 1] |= 0x80;
|
||||
for (let off = 0; off < padded.length; off += rate) {
|
||||
for (let i = 0; i < rate / 8; i++) {
|
||||
let lane = 0n;
|
||||
for (let b2 = 7; b2 >= 0; b2--) lane = (lane << 8n) | BigInt(padded[off + i * 8 + b2]);
|
||||
s[i] ^= lane;
|
||||
}
|
||||
keccakF1600(s);
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let lane = s[i];
|
||||
for (let b2 = 0; b2 < 8; b2++) { out[i * 8 + b2] = Number(lane & 0xffn); lane >>= 8n; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strip a leading 0x (if any) and lowercase. */
|
||||
export function clean(hex: string): string {
|
||||
return hex.toLowerCase().replace(/^0x/, '');
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const h = clean(hex);
|
||||
if (h.length % 2 !== 0) throw new Error('hexToBytes: odd-length hex');
|
||||
const out = new Uint8Array(h.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bytesToHex(b: Uint8Array): string {
|
||||
let s = '0x';
|
||||
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
/** keccak256 over the bytes of a hex string. Returns a 0x-prefixed 32-byte hash. */
|
||||
export function keccak256Hex(hex: string): string {
|
||||
return bytesToHex(keccak256Bytes(hexToBytes(hex)));
|
||||
}
|
||||
|
||||
/** keccak256 over a UTF-8 string (used for the 4-byte selector self-check). */
|
||||
export function keccak256Utf8(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
return bytesToHex(keccak256Bytes(bytes));
|
||||
}
|
||||
68
src/account/index.ts
Normal file
68
src/account/index.ts
Normal file
@ -0,0 +1,68 @@
|
||||
// AereModularAccount SDK — ERC-7579-style modular smart accounts on AERE
|
||||
// chain 2800: session keys + M-of-N social recovery, bound to AereEntryPointV2.
|
||||
//
|
||||
// Factory AereModularAccountFactory 0xE3f45Ed4a81f982fF25ad172A72456a1833a440E
|
||||
// Account AereModularAccount (CREATE2 per (rootOwner, salt))
|
||||
// Validator AereSessionKeyValidatorV2 0xC06EAe63Ed12307F56A1506917C48C027D8852ff (module type 1; audited, supersedes flawed V1 0x6e03A3D7…0D26)
|
||||
// Recovery AereSocialRecoveryModule 0x077514DB2a85F239145537e8334CC99d42c9D812 (module type 2)
|
||||
// EntryPoint AereEntryPointV2 0x8D6f40598d552fF0Cb358b6012cF4227B86aF770
|
||||
//
|
||||
// Signature routing enforced by AereModularAccount:
|
||||
// 0x00 || 65-byte ECDSA(personal_sign(userOpHash)) -> root owner
|
||||
// 0x01 || validator(20) || 65-byte ECDSA -> validator module
|
||||
|
||||
export {
|
||||
ModularAccountClient,
|
||||
AERE_MODULAR_ADDRESSES,
|
||||
AERE_ACCOUNT_INIT_CODE_HASH,
|
||||
toChecksumAddress,
|
||||
routeRootSignature,
|
||||
routeValidatorSignature,
|
||||
eip1193PersonalSign,
|
||||
type ModularAccountAddresses,
|
||||
type ModularAccountClientOptions,
|
||||
} from './ModularAccountClient.js';
|
||||
|
||||
export { SessionKeyClient, type ScopeCheck } from './SessionKeyClient.js';
|
||||
export { SocialRecoveryClient, RECOVERY_DELAY_SECONDS } from './SocialRecoveryClient.js';
|
||||
|
||||
export {
|
||||
AerePQCSocialRecoveryClient,
|
||||
AERE_PQC_SOCIAL_RECOVERY_ABI,
|
||||
RECOVERY_DOMAIN as PQC_RECOVERY_DOMAIN,
|
||||
RECOVERY_DELAY_SECONDS as PQC_RECOVERY_DELAY_SECONDS,
|
||||
MODULE_TYPE_EXECUTOR as PQC_MODULE_TYPE_EXECUTOR,
|
||||
recoveryChallenge as pqcRecoveryChallenge,
|
||||
signRecoveryLeg as signPqcRecoveryLeg,
|
||||
encodeLegs as encodePqcRecoveryLegs,
|
||||
encodeGuardianInstallData as encodePqcGuardianInstallData,
|
||||
type Leg as PQCRecoveryLeg,
|
||||
type GuardianKey as PQCGuardianKey,
|
||||
type PendingRecovery as PQCPendingRecovery,
|
||||
} from './AerePQCSocialRecoveryClient.js';
|
||||
|
||||
export {
|
||||
SEL as ACCOUNT_SELECTORS,
|
||||
encodeExecute,
|
||||
encodeExecuteBatch,
|
||||
encodeSessionScope,
|
||||
encodeRecoveryInit,
|
||||
computeUserOpHash,
|
||||
type CallStruct,
|
||||
} from './abi.js';
|
||||
|
||||
export { keccak256Hex, keccak256Utf8 } from './hash.js';
|
||||
|
||||
export {
|
||||
MODULE_TYPE_VALIDATOR,
|
||||
MODULE_TYPE_EXECUTOR,
|
||||
SELECTOR_NATIVE_TRANSFER,
|
||||
type Eip1193Provider,
|
||||
type PackedUserOp,
|
||||
type GasParams,
|
||||
type SessionScope,
|
||||
type SessionPermission,
|
||||
type SessionInfo,
|
||||
type ActiveRecovery,
|
||||
type UserOpSigner,
|
||||
} from './types.js';
|
||||
79
src/account/types.ts
Normal file
79
src/account/types.ts
Normal file
@ -0,0 +1,79 @@
|
||||
// Shared types for the AereModularAccount (ERC-7579-style) SDK.
|
||||
|
||||
/** Minimal EIP-1193 provider surface (same shape used across @aere/sdk). */
|
||||
export interface Eip1193Provider {
|
||||
request(args: { method: string; params?: unknown[] }): Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* ERC-4337 v0.7 packed user operation, exactly as AereEntryPointV2 and
|
||||
* AereModularAccount expect it.
|
||||
* accountGasLimits = bytes32( verificationGasLimit<<128 | callGasLimit )
|
||||
* gasFees = bytes32( maxPriorityFeePerGas<<128 | maxFeePerGas )
|
||||
*/
|
||||
export interface PackedUserOp {
|
||||
sender: string;
|
||||
nonce: bigint;
|
||||
initCode: string; // hex; '0x' when the account already exists
|
||||
callData: string; // hex; execute()/executeBatch() on the account
|
||||
accountGasLimits: string; // bytes32 hex
|
||||
preVerificationGas: bigint;
|
||||
gasFees: string; // bytes32 hex
|
||||
paymasterAndData: string; // hex; '0x' for self-paid
|
||||
signature: string; // hex; routed envelope (0x00 root / 0x01 validator)
|
||||
}
|
||||
|
||||
/** Gas parameters for buildUserOp; sensible defaults are filled in. */
|
||||
export interface GasParams {
|
||||
verificationGasLimit?: bigint; // default 500_000
|
||||
callGasLimit?: bigint; // default 500_000
|
||||
preVerificationGas?: bigint; // default 21_000
|
||||
maxFeePerGas?: bigint; // default: eth_gasPrice (fallback 1 gwei)
|
||||
maxPriorityFeePerGas?: bigint; // default: equal to maxFeePerGas
|
||||
}
|
||||
|
||||
/** One (target, selector) grant inside a session-key scope. */
|
||||
export interface SessionPermission {
|
||||
target: string;
|
||||
/** bytes4 selector; use SELECTOR_NATIVE_TRANSFER for empty-calldata transfers. */
|
||||
selector: string;
|
||||
}
|
||||
|
||||
/** Full scope of a session key (mirrors AereSessionKeyValidator.Session + allowlist). */
|
||||
export interface SessionScope {
|
||||
key: string; // session key EOA address
|
||||
validAfter: number; // unix seconds; 0 = no lower bound
|
||||
validUntil: number; // unix seconds; 0 = never expires
|
||||
maxValuePerOp: bigint; // wei cap per call
|
||||
permissions: SessionPermission[];
|
||||
}
|
||||
|
||||
/** On-chain session record read back from the validator. */
|
||||
export interface SessionInfo {
|
||||
enabled: boolean;
|
||||
validAfter: number;
|
||||
validUntil: number;
|
||||
maxValuePerOp: bigint;
|
||||
}
|
||||
|
||||
/** Pending recovery record read back from the social-recovery module. */
|
||||
export interface ActiveRecovery {
|
||||
newOwner: string;
|
||||
executeAfter: number; // unix seconds
|
||||
approvals: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A signer over a userOpHash. MUST return a 65-byte (r,s,v) ECDSA signature,
|
||||
* 0x-prefixed, over the EIP-191 personal-sign envelope of `userOpHash`
|
||||
* (i.e. sign(keccak256("\x19Ethereum Signed Message:\n32" || userOpHash))).
|
||||
* This matches AereModularAccount's ECDSA.toEthSignedMessageHash(userOpHash)
|
||||
* recovery for both the root-owner (0x00) and session-key (0x01) routes.
|
||||
*/
|
||||
export type UserOpSigner = (userOpHash: string) => Promise<string>;
|
||||
|
||||
export const MODULE_TYPE_VALIDATOR = 1;
|
||||
export const MODULE_TYPE_EXECUTOR = 2;
|
||||
/** Sentinel selector for native (empty-calldata) transfers in a session scope. */
|
||||
export const SELECTOR_NATIVE_TRANSFER = '0x00000000';
|
||||
562
src/addresses.ts
562
src/addresses.ts
File diff suppressed because one or more lines are too long
257
src/agentic/AERE402FacilitatorPQCClient.ts
Normal file
257
src/agentic/AERE402FacilitatorPQCClient.ts
Normal file
@ -0,0 +1,257 @@
|
||||
// AERE402FacilitatorPQCClient — ethers v6 wrapper over AERE402FacilitatorPQC.
|
||||
//
|
||||
// AERE402FacilitatorPQC settles machine (agent-to-service) payments whose spending
|
||||
// authority is rooted in a quantum-durable Falcon key, not a bare secp256k1 signer. The
|
||||
// payer is an AereAgentDID: an agent whose ROOT is a Falcon key in AerePQCKeyRegistry and
|
||||
// whose day-to-day work is signed by cheap, revocable secp256k1 SESSION keys. A payment is
|
||||
// authorized by the DID SESSION signature; the facilitator delegates ALL authorization to
|
||||
// AereAgentDID.authorize (root lifecycle + scope + cumulative spend cap + secp256k1 session
|
||||
// signature + anti-replay), then debits a prepaid vault keyed by the Falcon root and splits
|
||||
// the amount into (amount - 25 bps) to the payee and a 25-bps fee to AereSink.
|
||||
//
|
||||
// Rotating or revoking the Falcon root instantly halts every downstream payment.
|
||||
//
|
||||
// PAYMENT AUTHORIZATION (what the session key signs). The facilitator binds the payment
|
||||
// terms to itself with an actionHash, then the session key signs the DID action digest that
|
||||
// commits to (sessionId, scope, actionHash, amount, actionNonce):
|
||||
// actionHash = keccak256(abi.encode(
|
||||
// PAYMENT_DOMAIN, chainId, facilitator, token, payee, resourceId, deadline))
|
||||
// digest = AereAgentDID.actionDigest(sessionId, scope, actionHash, amount, actionNonce)
|
||||
// PAYMENT_DOMAIN = keccak256("AERE402FacilitatorPQC.v1.payment")
|
||||
// The DID action digest (ACTION_DOMAIN, chain- and DID-bound) is reused verbatim via
|
||||
// AereAgentDIDClient — never reimplemented. The session-key signature is a raw secp256k1
|
||||
// signature over the digest (ethers SigningKey), matching the contract's ecrecover — NOT an
|
||||
// eth_sign personal-message prefix.
|
||||
//
|
||||
// NOTE: AERE402FacilitatorPQC is a repo-only build (no mainnet deployment yet), so this
|
||||
// client has no default address — always pass `facilitator`.
|
||||
|
||||
import {
|
||||
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify, SigningKey, Signature,
|
||||
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from '../addresses.js';
|
||||
import { AereAgentDIDClient } from '../pqc/AereAgentDIDClient.js';
|
||||
|
||||
/** Minimal inline ABI — only the AERE402FacilitatorPQC surface this client uses. */
|
||||
export const AERE402_FACILITATOR_PQC_ABI = [
|
||||
// settle + funding
|
||||
'function settle(uint256 sessionId, bytes32 scope, address token, address payee, uint256 amount, bytes32 resourceId, uint256 deadline, bytes sessionSig)',
|
||||
'function topUp(uint256 rootKeyId, address token, uint256 amount)',
|
||||
'function withdraw(uint256 rootKeyId, address token, uint256 amount, address recipient)',
|
||||
'function setPaused(uint256 rootKeyId, bool value)',
|
||||
'function flushResidual(address token, uint256 amount)',
|
||||
// views
|
||||
'function balances(uint256 rootKeyId, address token) view returns (uint256)',
|
||||
'function paused(uint256 rootKeyId) view returns (bool)',
|
||||
'function paymentActionHash(address token, address payee, bytes32 resourceId, uint256 deadline) view returns (bytes32)',
|
||||
'function controllerOf(uint256 rootKeyId) view returns (address)',
|
||||
'function isController(uint256 rootKeyId, address account) view returns (bool)',
|
||||
'function isSessionValid(uint256 sessionId) view returns (bool)',
|
||||
'function DID() view returns (address)',
|
||||
'function KEY_REGISTRY() view returns (address)',
|
||||
'function SINK() view returns (address)',
|
||||
'function PROTOCOL_FEE_BPS() view returns (uint16)',
|
||||
'function PAYMENT_DOMAIN() view returns (bytes32)',
|
||||
// events
|
||||
'event SettledPQC(uint256 indexed rootKeyId, uint256 indexed sessionId, address indexed payee, address token, uint256 amount, uint256 protocolFee, bytes32 resourceId)',
|
||||
'event TopUp(uint256 indexed rootKeyId, address indexed token, address indexed funder, uint256 amount)',
|
||||
'event Withdrawn(uint256 indexed rootKeyId, address indexed token, address indexed recipient, uint256 amount)',
|
||||
'event PausedSet(uint256 indexed rootKeyId, bool paused, address by)',
|
||||
] as const;
|
||||
|
||||
/** keccak256("AERE402FacilitatorPQC.v1.payment"). */
|
||||
export const PAYMENT_DOMAIN = keccak256(toUtf8Bytes('AERE402FacilitatorPQC.v1.payment'));
|
||||
|
||||
export interface AERE402FacilitatorPQCClientOptions {
|
||||
/** AERE402FacilitatorPQC address (required — repo-only build, no mainnet default). */
|
||||
facilitator: string;
|
||||
/** AereAgentDID address the facilitator roots into. Default: AERE_MAINNET.AereAgentDID. */
|
||||
didAddress?: string;
|
||||
/** Chain id used for local actionHash / action-digest derivation. Default: 2800. */
|
||||
chainId?: number;
|
||||
}
|
||||
|
||||
/** The exact terms of a machine payment (what the session key authorizes). */
|
||||
export interface PQCPaymentParams {
|
||||
sessionId: bigint | number;
|
||||
/** The scope tag for this payment (must equal the session's locked scopeHash). */
|
||||
scope: BytesLike;
|
||||
token: string;
|
||||
payee: string;
|
||||
amount: bigint | number;
|
||||
/** Opaque resource tag, e.g. keccak256("GET /v1/inference"). bytes32. */
|
||||
resourceId: BytesLike;
|
||||
/** Unix seconds after which the payment auth is stale. */
|
||||
deadline: bigint | number;
|
||||
}
|
||||
|
||||
export interface PQCPaymentAuthorization {
|
||||
/** actionHash committed to THIS facilitator for these terms. */
|
||||
actionHash: string;
|
||||
/** The DID action digest the session key signed. */
|
||||
digest: string;
|
||||
/** The session action nonce the digest was derived at. */
|
||||
actionNonce: bigint;
|
||||
/** 65-byte secp256k1 signature (r||s||v) by the session key (pass as `sessionSig`). */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface SettleResult extends PQCPaymentAuthorization {
|
||||
tx: ContractTransactionResponse;
|
||||
}
|
||||
|
||||
function toBytes(v: BytesLike): Uint8Array {
|
||||
return v instanceof Uint8Array ? v : getBytes(v);
|
||||
}
|
||||
|
||||
function bytes32(v: BytesLike): string {
|
||||
const b = toBytes(v);
|
||||
if (b.length !== 32) throw new Error(`AERE402FacilitatorPQCClient: expected 32-byte value, got ${b.length}`);
|
||||
return hexlify(b);
|
||||
}
|
||||
|
||||
export class AERE402FacilitatorPQCClient {
|
||||
readonly address: string;
|
||||
readonly chainId: number;
|
||||
readonly contract: Contract;
|
||||
/** DID client used only for action-digest derivation and on-chain session reads. */
|
||||
readonly did: AereAgentDIDClient;
|
||||
|
||||
constructor(runner: ContractRunner, opts: AERE402FacilitatorPQCClientOptions) {
|
||||
if (!opts?.facilitator) throw new Error('AERE402FacilitatorPQCClient: `facilitator` address is required');
|
||||
this.address = opts.facilitator;
|
||||
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||
this.contract = new Contract(this.address, AERE402_FACILITATOR_PQC_ABI, runner);
|
||||
this.did = new AereAgentDIDClient(runner, { address: opts.didAddress, chainId: this.chainId });
|
||||
}
|
||||
|
||||
// ---- derivation -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Local mirror of AERE402FacilitatorPQC._paymentActionHash:
|
||||
* keccak256(abi.encode(PAYMENT_DOMAIN, chainId, facilitator, token, payee, resourceId, deadline))
|
||||
* Cross-check with {@link paymentActionHash} on-chain.
|
||||
*/
|
||||
deriveActionHash(token: string, payee: string, resourceId: BytesLike, deadline: bigint | number): string {
|
||||
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'address', 'address', 'bytes32', 'uint256'],
|
||||
[PAYMENT_DOMAIN, this.chainId, this.address, token, payee, bytes32(resourceId), deadline],
|
||||
);
|
||||
return keccak256(enc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session-key authorization for a payment at an explicit actionNonce, WITHOUT
|
||||
* sending a transaction. Derives the actionHash and the DID action digest locally and
|
||||
* signs the digest with the raw secp256k1 session private key (canonical low-s, v in
|
||||
* {27,28}). The DID action digest is derived by AereAgentDIDClient (reused, not reimplemented).
|
||||
*/
|
||||
buildAuthorization(p: PQCPaymentParams, actionNonce: bigint | number, sessionPrivateKey: BytesLike): PQCPaymentAuthorization {
|
||||
const actionHash = this.deriveActionHash(p.token, p.payee, p.resourceId, p.deadline);
|
||||
const digest = this.did.deriveActionDigest(p.sessionId, p.scope, actionHash, p.amount, actionNonce);
|
||||
const sk = new SigningKey(hexlify(toBytes(sessionPrivateKey)));
|
||||
const sig: Signature = sk.sign(digest);
|
||||
return { actionHash, digest, actionNonce: BigInt(actionNonce), signature: sig.serialized };
|
||||
}
|
||||
|
||||
// ---- views ----------------------------------------------------------------
|
||||
|
||||
/** On-chain actionHash for these payment terms (cross-check for {@link deriveActionHash}). */
|
||||
async paymentActionHash(token: string, payee: string, resourceId: BytesLike, deadline: bigint | number): Promise<string> {
|
||||
return this.contract.paymentActionHash(token, payee, bytes32(resourceId), deadline);
|
||||
}
|
||||
|
||||
/** Prepaid vault balance of an agent (Falcon root) in `token`. */
|
||||
async balanceOf(rootKeyId: bigint | number, token: string): Promise<bigint> {
|
||||
return this.contract.balances(rootKeyId, token);
|
||||
}
|
||||
|
||||
/** True iff the agent's settlement path is paused. */
|
||||
async isPaused(rootKeyId: bigint | number): Promise<boolean> {
|
||||
return this.contract.paused(rootKeyId);
|
||||
}
|
||||
|
||||
/** The agent's current controller (registry owner of the Falcon root), or zero. */
|
||||
async controllerOf(rootKeyId: bigint | number): Promise<string> {
|
||||
return this.contract.controllerOf(rootKeyId);
|
||||
}
|
||||
|
||||
/** True iff `account` is the agent's current controller. */
|
||||
async isController(rootKeyId: bigint | number, account: string): Promise<boolean> {
|
||||
return this.contract.isController(rootKeyId, account);
|
||||
}
|
||||
|
||||
/** True iff the session is live (root ACTIVE Falcon, not revoked, unexpired). */
|
||||
async isSessionValid(sessionId: bigint | number): Promise<boolean> {
|
||||
return this.contract.isSessionValid(sessionId);
|
||||
}
|
||||
|
||||
// ---- writes (require a Signer runner) -------------------------------------
|
||||
|
||||
/** Fund an agent's prepaid vault (permissionless). Requires prior token approval. */
|
||||
async topUp(rootKeyId: bigint | number, token: string, amount: bigint | number): Promise<ContractTransactionResponse> {
|
||||
return this.contract.topUp(rootKeyId, token, amount);
|
||||
}
|
||||
|
||||
/** Withdraw idle vault balance (agent controller only). */
|
||||
async withdraw(rootKeyId: bigint | number, token: string, amount: bigint | number, recipient: string): Promise<ContractTransactionResponse> {
|
||||
return this.contract.withdraw(rootKeyId, token, amount, recipient);
|
||||
}
|
||||
|
||||
/** Pause or unpause an agent's settlement path (agent controller only). */
|
||||
async setPaused(rootKeyId: bigint | number, value: boolean): Promise<ContractTransactionResponse> {
|
||||
return this.contract.setPaused(rootKeyId, value);
|
||||
}
|
||||
|
||||
/** Re-attempt the sink flush for a residual fee balance (permissionless). */
|
||||
async flushResidual(token: string, amount: bigint | number): Promise<ContractTransactionResponse> {
|
||||
return this.contract.flushResidual(token, amount);
|
||||
}
|
||||
|
||||
/** Submit settle with a pre-built session-key signature. Caller (runner) MUST be the payee. */
|
||||
async settle(p: PQCPaymentParams, sessionSig: BytesLike): Promise<ContractTransactionResponse> {
|
||||
return this.contract.settle(
|
||||
p.sessionId, bytes32(p.scope), p.token, p.payee, p.amount, bytes32(p.resourceId), p.deadline, hexlify(toBytes(sessionSig)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- high-level -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* High-level settle: read the session's current actionNonce on-chain, derive the
|
||||
* actionHash + DID action digest locally (optionally cross-checked on-chain), sign the
|
||||
* digest with the secp256k1 session key, and submit settle via `providerSigner` (the
|
||||
* payee, who also pays gas). The session private key never leaves this process.
|
||||
*
|
||||
* The `providerSigner` address MUST equal `p.payee` — the facilitator enforces
|
||||
* msg.sender == payee so a leaked signature cannot be redirected.
|
||||
*/
|
||||
async settleWithSessionKey(
|
||||
providerSigner: Signer,
|
||||
p: PQCPaymentParams,
|
||||
sessionPrivateKey: BytesLike,
|
||||
verifyOnChain = true,
|
||||
): Promise<SettleResult> {
|
||||
const session = await this.did.getSession(p.sessionId);
|
||||
const actionNonce = session.actionNonce;
|
||||
const built = this.buildAuthorization(p, actionNonce, sessionPrivateKey);
|
||||
|
||||
if (verifyOnChain) {
|
||||
const onChainActionHash = await this.paymentActionHash(p.token, p.payee, p.resourceId, p.deadline);
|
||||
if (onChainActionHash.toLowerCase() !== built.actionHash.toLowerCase()) {
|
||||
throw new Error(`AERE402FacilitatorPQCClient: local actionHash ${built.actionHash} != on-chain ${onChainActionHash}`);
|
||||
}
|
||||
const onChainDigest = await this.did.actionDigest(p.sessionId, p.scope, built.actionHash, p.amount, actionNonce);
|
||||
if (onChainDigest.toLowerCase() !== built.digest.toLowerCase()) {
|
||||
throw new Error(`AERE402FacilitatorPQCClient: local action digest ${built.digest} != on-chain ${onChainDigest}`);
|
||||
}
|
||||
}
|
||||
|
||||
const c = this.contract.connect(providerSigner) as Contract;
|
||||
const tx: ContractTransactionResponse = await c.settle(
|
||||
p.sessionId, bytes32(p.scope), p.token, p.payee, p.amount, bytes32(p.resourceId), p.deadline, built.signature,
|
||||
);
|
||||
return { tx, ...built };
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,15 @@
|
||||
export { AereAgentClient, AERE402SettlementClient, buildAuthTypedData } from './AereAgentClient.js';
|
||||
export type { Eip1193Provider, PaymentAuth as AgentPaymentAuth } from './AereAgentClient.js';
|
||||
|
||||
// AERE402FacilitatorPQC — HTTP 402 agentic settlement rooted in a Falcon (post-quantum)
|
||||
// identity: payments authorized by an AereAgentDID session; a rotated/revoked Falcon root
|
||||
// instantly halts all downstream payments. Repo-only build (no mainnet default address).
|
||||
export {
|
||||
AERE402FacilitatorPQCClient, AERE402_FACILITATOR_PQC_ABI, PAYMENT_DOMAIN,
|
||||
} from './AERE402FacilitatorPQCClient.js';
|
||||
export type {
|
||||
AERE402FacilitatorPQCClientOptions, PQCPaymentParams, PQCPaymentAuthorization, SettleResult,
|
||||
} from './AERE402FacilitatorPQCClient.js';
|
||||
export { createAere402Middleware, buildAere402TypedData } from './AERE402Middleware.js';
|
||||
export type { Aere402MiddlewareConfig, PaymentAuth } from './AERE402Middleware.js';
|
||||
|
||||
|
||||
353
src/cli/aere-cli.ts
Normal file
353
src/cli/aere-cli.ts
Normal file
@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env node
|
||||
// aere — the AERE Network developer CLI.
|
||||
//
|
||||
// A small, dependency-light read-only CLI over the @aere/sdk TypeScript SDK
|
||||
// (chain 2800). It reuses AereClient / the SDK's pqc module and ethers v6 —
|
||||
// no new runtime dependency, no arg-parsing framework (argv is parsed here).
|
||||
//
|
||||
// SAFETY: every command is READ-ONLY or purely LOCAL. The CLI never signs a
|
||||
// real transaction, never takes a private key, and never persists anything.
|
||||
// `pqc keygen` generates a keypair in-process and, by default, prints ONLY the
|
||||
// public key + sizes — the secret key is withheld unless --show-secret is passed.
|
||||
//
|
||||
// Honesty contract: values are read live from the RPC and printed verbatim.
|
||||
// Nothing is fabricated. On an RPC/connection error the CLI writes a clear
|
||||
// message to stderr and exits non-zero.
|
||||
//
|
||||
// node dist/cli/aere-cli.js <command> [args] [--rpc <url>]
|
||||
//
|
||||
// Commands: chain · block <n|latest> · tx <hash> · addr <address>
|
||||
// pqc schemes · pqc keygen --scheme <1|2|3|4> [--show-secret]
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { formatEther, formatUnits, isAddress, getAddress, hexlify, type JsonRpcProvider } from 'ethers';
|
||||
import { AereClient } from '../client.js';
|
||||
import {
|
||||
SCHEME, SCHEME_NAME, JS_SIGNABLE, LENGTHS, keygen, type SchemeId,
|
||||
} from '../pqc/index.js';
|
||||
|
||||
// ─────────────────────────── static scheme metadata ───────────────────────────
|
||||
|
||||
/**
|
||||
* Native PQC verification precompile per scheme, mirroring the on-chain PURE
|
||||
* function AerePQCAttestation.precompileFor(scheme) (documented in
|
||||
* src/pqc/AerePQCClient.ts). Deterministic: scheme 1→0x0aE1 … 4→0x0aE4.
|
||||
* Cross-checkable on-chain via `AerePQCClient.precompileFor`.
|
||||
*/
|
||||
const PRECOMPILE: Record<SchemeId, string> = {
|
||||
[SCHEME.FALCON512]: '0x0000000000000000000000000000000000000aE1',
|
||||
[SCHEME.FALCON1024]: '0x0000000000000000000000000000000000000aE2',
|
||||
[SCHEME.MLDSA44]: '0x0000000000000000000000000000000000000aE3',
|
||||
[SCHEME.SLHDSA128S]: '0x0000000000000000000000000000000000000aE4',
|
||||
};
|
||||
|
||||
/** Public-key byte length per scheme, sourced from the SDK's pqc LENGTHS table. */
|
||||
function pubKeySize(scheme: SchemeId): number {
|
||||
switch (scheme) {
|
||||
case SCHEME.FALCON512: return LENGTHS.falcon512.pubKey;
|
||||
case SCHEME.FALCON1024: return LENGTHS.falcon1024.pubKey;
|
||||
case SCHEME.MLDSA44: return LENGTHS.mldsa44.pubKey;
|
||||
case SCHEME.SLHDSA128S: return LENGTHS.slhdsa128s.pubKey;
|
||||
}
|
||||
}
|
||||
|
||||
/** Human description of the signature size per scheme (fixed for lattice/hash, variable for Falcon). */
|
||||
function sigSizeDesc(scheme: SchemeId): string {
|
||||
switch (scheme) {
|
||||
case SCHEME.FALCON512:
|
||||
case SCHEME.FALCON1024:
|
||||
return 'variable (compressed; envelope = 40B nonce + 1B header + body)';
|
||||
case SCHEME.MLDSA44: return `${LENGTHS.mldsa44.signature} bytes (fixed)`;
|
||||
case SCHEME.SLHDSA128S: return `${LENGTHS.slhdsa128s.signature} bytes (fixed)`;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────── tiny helpers ────────────────────────────────
|
||||
|
||||
const DEFAULT_RPC = 'https://rpc.aere.network';
|
||||
|
||||
interface Parsed {
|
||||
_: string[];
|
||||
rpc: string;
|
||||
scheme?: string;
|
||||
showSecret: boolean;
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Parsed {
|
||||
const p: Parsed = { _: [], rpc: DEFAULT_RPC, showSecret: false, help: false, version: false };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--help' || a === '-h') p.help = true;
|
||||
else if (a === '--version' || a === '-V') p.version = true;
|
||||
else if (a === '--rpc') p.rpc = req(args[++i], '--rpc');
|
||||
else if (a.startsWith('--rpc=')) p.rpc = a.slice('--rpc='.length);
|
||||
else if (a === '--scheme') p.scheme = req(args[++i], '--scheme');
|
||||
else if (a.startsWith('--scheme=')) p.scheme = a.slice('--scheme='.length);
|
||||
else if (a === '--show-secret') p.showSecret = true;
|
||||
else if (a.startsWith('-')) throw new UsageError(`unknown option: ${a}`);
|
||||
else p._.push(a);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
function req(v: string | undefined, flag: string): string {
|
||||
if (v === undefined) throw new UsageError(`option ${flag} requires a value`);
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Thrown for bad CLI usage (missing arg, unknown command). Prints usage + exits 2. */
|
||||
class UsageError extends Error {}
|
||||
|
||||
/** Fixed-width " label value" line. */
|
||||
function kv(label: string, value: string | number): string {
|
||||
return ` ${label.padEnd(16)}${value}`;
|
||||
}
|
||||
|
||||
function gwei(v: bigint | null | undefined): string {
|
||||
return v == null ? 'n/a' : `${formatUnits(v, 'gwei')} gwei (${v.toString()} wei)`;
|
||||
}
|
||||
|
||||
function readVersion(): string {
|
||||
try {
|
||||
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
||||
return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version ?? 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────── commands ────────────────────────────────────
|
||||
|
||||
async function cmdChain(p: Parsed): Promise<void> {
|
||||
const client = new AereClient({ rpcUrl: p.rpc });
|
||||
// AereClient always builds a JsonRpcProvider; cast to reach send() for the
|
||||
// raw JSON-RPC methods (eth_chainId, net_peerCount) the Provider interface omits.
|
||||
const provider = client.provider as JsonRpcProvider;
|
||||
|
||||
// Core reads — a failure here means we could not talk to the node; let it throw.
|
||||
const chainId = parseInt(await provider.send('eth_chainId', []), 16);
|
||||
const blockNumber = await client.getBlockNumber();
|
||||
const latest = await provider.getBlock('latest');
|
||||
|
||||
// Auxiliary reads — a node may not expose every method; report honestly, never fake.
|
||||
let gasPrice: bigint | null = null;
|
||||
try { gasPrice = (await provider.getFeeData()).gasPrice; } catch { /* leave null */ }
|
||||
|
||||
let validators: string[] | null = null;
|
||||
try { validators = await client.getValidators(); } catch { /* method unsupported */ }
|
||||
|
||||
let peerCount: number | null = null;
|
||||
try { peerCount = parseInt(await provider.send('net_peerCount', []), 16); } catch { /* unsupported */ }
|
||||
|
||||
console.log(`AERE chain — ${p.rpc}`);
|
||||
console.log(kv('Chain ID', `${chainId} (0x${chainId.toString(16)})`));
|
||||
console.log(kv('Block height', blockNumber));
|
||||
console.log(kv('Base fee', gwei(latest?.baseFeePerGas ?? null)));
|
||||
console.log(kv('Gas price', gwei(gasPrice)));
|
||||
console.log(kv('Validators', validators == null ? 'unavailable (qbft_getValidatorsByBlockNumber not supported)' : validators.length));
|
||||
if (validators && validators.length && validators.length <= 12) {
|
||||
for (const v of validators) console.log(` - ${v}`);
|
||||
}
|
||||
console.log(kv('Peers', peerCount == null ? 'unavailable (net_peerCount not supported)' : peerCount));
|
||||
}
|
||||
|
||||
async function cmdBlock(p: Parsed): Promise<void> {
|
||||
const tagArg = p._[1];
|
||||
if (!tagArg) throw new UsageError('block requires <number|latest>');
|
||||
const tag = tagArg === 'latest' ? 'latest' : Number(tagArg);
|
||||
if (tag !== 'latest' && !Number.isInteger(tag)) throw new UsageError(`invalid block: ${tagArg}`);
|
||||
|
||||
const client = new AereClient({ rpcUrl: p.rpc });
|
||||
const block = await client.provider.getBlock(tag);
|
||||
if (!block) {
|
||||
fail(`block ${tagArg} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Block ${block.number} — ${p.rpc}`);
|
||||
console.log(kv('Hash', block.hash ?? 'n/a'));
|
||||
console.log(kv('Parent', block.parentHash));
|
||||
console.log(kv('Timestamp', `${block.timestamp} (${new Date(block.timestamp * 1000).toISOString()})`));
|
||||
console.log(kv('Proposer', block.miner));
|
||||
console.log(kv('Tx count', block.transactions.length));
|
||||
console.log(kv('Gas used', `${block.gasUsed.toString()} / ${block.gasLimit.toString()}`));
|
||||
console.log(kv('Base fee', gwei(block.baseFeePerGas)));
|
||||
}
|
||||
|
||||
async function cmdTx(p: Parsed): Promise<void> {
|
||||
const hash = p._[1];
|
||||
if (!hash) throw new UsageError('tx requires <hash>');
|
||||
|
||||
const client = new AereClient({ rpcUrl: p.rpc });
|
||||
const provider = client.provider;
|
||||
const tx = await provider.getTransaction(hash);
|
||||
if (!tx) {
|
||||
fail(`transaction ${hash} not found`);
|
||||
return;
|
||||
}
|
||||
const receipt = await provider.getTransactionReceipt(hash);
|
||||
|
||||
console.log(`Transaction ${tx.hash} — ${p.rpc}`);
|
||||
console.log(kv('From', tx.from));
|
||||
console.log(kv('To', tx.to ?? '(contract creation)'));
|
||||
console.log(kv('Value', `${formatEther(tx.value)} AERE (${tx.value.toString()} wei)`));
|
||||
console.log(kv('Nonce', tx.nonce));
|
||||
console.log(kv('Type', tx.type ?? 0));
|
||||
console.log(kv('Gas limit', tx.gasLimit.toString()));
|
||||
console.log(kv('Max fee', gwei(tx.maxFeePerGas ?? tx.gasPrice)));
|
||||
console.log(kv('Input size', `${(tx.data.length - 2) / 2} bytes`));
|
||||
|
||||
if (!receipt) {
|
||||
console.log(kv('Status', 'pending (no receipt yet)'));
|
||||
return;
|
||||
}
|
||||
console.log(kv('Status', receipt.status === 1 ? 'success (1)' : receipt.status === 0 ? 'reverted (0)' : 'unknown'));
|
||||
console.log(kv('Block', receipt.blockNumber));
|
||||
console.log(kv('Gas used', receipt.gasUsed.toString()));
|
||||
console.log(kv('Eff. gas price', gwei(receipt.gasPrice)));
|
||||
console.log(kv('Logs', receipt.logs.length));
|
||||
if (receipt.contractAddress) console.log(kv('Deployed', receipt.contractAddress));
|
||||
}
|
||||
|
||||
async function cmdAddr(p: Parsed): Promise<void> {
|
||||
const raw = p._[1];
|
||||
if (!raw) throw new UsageError('addr requires <address>');
|
||||
if (!isAddress(raw)) throw new UsageError(`invalid address: ${raw}`);
|
||||
const addr = getAddress(raw);
|
||||
|
||||
const client = new AereClient({ rpcUrl: p.rpc });
|
||||
const provider = client.provider;
|
||||
const [balance, nonce, code] = await Promise.all([
|
||||
client.getNativeBalance(addr),
|
||||
provider.getTransactionCount(addr),
|
||||
provider.getCode(addr),
|
||||
]);
|
||||
const codeSize = (code.length - 2) / 2;
|
||||
|
||||
console.log(`Address ${addr} — ${p.rpc}`);
|
||||
console.log(kv('Balance', `${formatEther(balance)} AERE (${balance.toString()} wei)`));
|
||||
console.log(kv('Nonce', nonce));
|
||||
console.log(kv('Code size', `${codeSize} bytes`));
|
||||
console.log(kv('Type', codeSize > 0 ? 'contract' : 'EOA (no code)'));
|
||||
}
|
||||
|
||||
function cmdPqcSchemes(): void {
|
||||
console.log('AERE native PQC verification precompiles (chain 2800):');
|
||||
console.log('');
|
||||
const ids = [SCHEME.FALCON512, SCHEME.FALCON1024, SCHEME.MLDSA44, SCHEME.SLHDSA128S] as const;
|
||||
for (const id of ids) {
|
||||
console.log(` [${id}] ${SCHEME_NAME[id]}`);
|
||||
console.log(kv(' precompile', PRECOMPILE[id]));
|
||||
console.log(kv(' public key', `${pubKeySize(id)} bytes`));
|
||||
console.log(kv(' signature', sigSizeDesc(id)));
|
||||
console.log(kv(' js signing', JS_SIGNABLE[id] ? 'yes (pure-JS keygen + sign in this SDK)' : 'no'));
|
||||
console.log('');
|
||||
}
|
||||
console.log('Verify a signature on-chain for free via AerePQCClient.verifySignature (eth_call).');
|
||||
}
|
||||
|
||||
function cmdPqcKeygen(p: Parsed): void {
|
||||
if (p.scheme === undefined) throw new UsageError('pqc keygen requires --scheme <1|2|3|4>');
|
||||
const n = Number(p.scheme);
|
||||
if (![1, 2, 3, 4].includes(n)) throw new UsageError(`invalid --scheme ${p.scheme} (expected 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s)`);
|
||||
const scheme = n as SchemeId;
|
||||
|
||||
const kp = keygen(scheme);
|
||||
|
||||
console.log(`Generated ${SCHEME_NAME[scheme]} keypair (local, ephemeral — nothing persisted):`);
|
||||
console.log(kv('Scheme', `${scheme} (${SCHEME_NAME[scheme]})`));
|
||||
console.log(kv('Precompile', PRECOMPILE[scheme]));
|
||||
console.log(kv('Public size', `${kp.publicKey.length} bytes`));
|
||||
console.log(kv('Secret size', `${kp.secretKey.length} bytes`));
|
||||
console.log(kv('Public key', hexlify(kp.publicKey)));
|
||||
|
||||
if (p.showSecret) {
|
||||
process.stderr.write('WARNING: printing the SECRET key. Anyone who sees it can forge signatures for this key. Never share, log, or commit it.\n');
|
||||
console.log(kv('Secret key', hexlify(kp.secretKey)));
|
||||
} else {
|
||||
console.log(' [secret key withheld — pass --show-secret to reveal it (DANGER)]');
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── help / dispatch ───────────────────────────────
|
||||
|
||||
const USAGE = `aere — AERE Network developer CLI (read-only, chain 2800)
|
||||
|
||||
Usage:
|
||||
aere <command> [args] [options]
|
||||
|
||||
Commands:
|
||||
chain Chain id, block height, base fee, gas price, validators, peers
|
||||
block <number|latest> Block summary (hash, parent, timestamp, txs, gas, proposer)
|
||||
tx <hash> Transaction + receipt summary
|
||||
addr <address> Balance, nonce, and code size for an address
|
||||
pqc schemes List the supported PQC schemes, precompiles, and key/sig sizes
|
||||
pqc keygen --scheme <1-4> Generate a PQC keypair locally (public key only, unless --show-secret)
|
||||
|
||||
Options:
|
||||
--rpc <url> RPC endpoint (default: ${DEFAULT_RPC})
|
||||
--scheme <1|2|3|4> PQC scheme: 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s
|
||||
--show-secret (pqc keygen) also print the secret key — DANGEROUS
|
||||
--version, -V Print the SDK version
|
||||
--help, -h Show this help
|
||||
|
||||
All commands are read-only or purely local. The CLI never signs a real
|
||||
transaction and never accepts a private key.`;
|
||||
|
||||
function fail(msg: string): never {
|
||||
process.stderr.write(`aere: ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const p = parseArgs(process.argv.slice(2));
|
||||
const cmd = p._[0];
|
||||
|
||||
if (p.version && !cmd) { console.log(readVersion()); return; }
|
||||
if (p.help || !cmd || cmd === 'help') { console.log(USAGE); return; }
|
||||
|
||||
switch (cmd) {
|
||||
case 'chain':
|
||||
if (p.help) { console.log('aere chain [--rpc <url>] — chain id, height, base fee, gas price, validators, peers'); return; }
|
||||
await cmdChain(p);
|
||||
return;
|
||||
case 'block':
|
||||
if (p.help) { console.log('aere block <number|latest> [--rpc <url>] — block summary'); return; }
|
||||
await cmdBlock(p);
|
||||
return;
|
||||
case 'tx':
|
||||
if (p.help) { console.log('aere tx <hash> [--rpc <url>] — transaction + receipt summary'); return; }
|
||||
await cmdTx(p);
|
||||
return;
|
||||
case 'addr':
|
||||
if (p.help) { console.log('aere addr <address> [--rpc <url>] — balance, nonce, code size'); return; }
|
||||
await cmdAddr(p);
|
||||
return;
|
||||
case 'pqc': {
|
||||
const sub = p._[1];
|
||||
if (p.help || !sub) {
|
||||
console.log('aere pqc schemes — list PQC schemes/precompiles/sizes\naere pqc keygen --scheme <1|2|3|4> [--show-secret] — generate a keypair locally');
|
||||
return;
|
||||
}
|
||||
if (sub === 'schemes') { cmdPqcSchemes(); return; }
|
||||
if (sub === 'keygen') { cmdPqcKeygen(p); return; }
|
||||
throw new UsageError(`unknown pqc subcommand: ${sub}`);
|
||||
}
|
||||
default:
|
||||
throw new UsageError(`unknown command: ${cmd}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
if (err instanceof UsageError) {
|
||||
process.stderr.write(`aere: ${err.message}\n\n${USAGE}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`aere: ${msg}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -4,7 +4,7 @@ import {
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from './addresses.js';
|
||||
import {
|
||||
ERC20_ABI, WAERE_ABI, STAKING_V2_ABI, STAKING_V1_ABI,
|
||||
ERC20_ABI, WAERE_ABI, STAKING_V2_ABI, STAKING_V1_ABI, STAKING_DELEGATED_V2_ABI,
|
||||
IDENTITY_ABI, BRIDGE_ABI, FAUCET_ABI, SWAP_FACTORY_ABI, SWAP_PAIR_ABI,
|
||||
} from './abis.js';
|
||||
|
||||
@ -17,7 +17,7 @@ export interface AereClientOptions {
|
||||
|
||||
/**
|
||||
* Top-level AERE Network client. Provides a typed handle to every deployed contract
|
||||
* Bank28-class integrations need.
|
||||
* consumer-wallet-class integrations need.
|
||||
*
|
||||
* @example
|
||||
* const aere = new AereClient({ privateKey: process.env.PK });
|
||||
@ -77,7 +77,16 @@ export class AereClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** AereLockedStaking — fixed-term locks · 4 tiers (30/90/180/365 days, 10/15/22/30% APY) */
|
||||
/**
|
||||
* AereLockedStaking — fixed-term locks · 4 tiers (30/90/180/365 days, 10/15/22/30% APY).
|
||||
*
|
||||
* HONEST STATUS: those APY constants are real and on-chain, and the contract is
|
||||
* live and callable, but its reward reserve is UNFUNDED (rewardReserve() == 0),
|
||||
* so no yield can be paid today. While the reserve cannot cover principal +
|
||||
* reward, withdraw() at maturity reverts; principal stays recoverable via
|
||||
* earlyExit(), which forfeits the reward. Do not surface these rates to users
|
||||
* as an obtainable return without checking rewardReserve() first.
|
||||
*/
|
||||
get lockedStaking() {
|
||||
const c = this.contract(this.addresses.AereLockedStaking, STAKING_V2_ABI, true);
|
||||
return {
|
||||
@ -94,9 +103,13 @@ export class AereClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** AereStaking V1 — delegated 8% APY staking */
|
||||
/**
|
||||
* @deprecated AereStaking V1 (0xAbDb01…0DEc) has five confirmed bugs (reward drain on
|
||||
* top-up, locked validator self-stake, re-register unbond brick, ~5x APY underpay,
|
||||
* uncredited commission) and 0 usage. Use {@link stakingV2}. Kept only for reference.
|
||||
*/
|
||||
get stakingV1() {
|
||||
const c = this.contract(this.addresses.AereStaking, STAKING_V1_ABI, true);
|
||||
const c = this.contract(this.addresses.AereStaking_v1_DEPRECATED, STAKING_V1_ABI, true);
|
||||
return {
|
||||
contract: c,
|
||||
delegate: (validator: string, amountWei: bigint) => c.delegate(validator, { value: amountWei }),
|
||||
@ -108,6 +121,35 @@ export class AereClient {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AereStakingV2 (0x1D95eF6D…55FC) — CANONICAL delegated stake pool (native AERE).
|
||||
* Bug-fix redeploy of AereStaking: settle-on-top-up (no retroactive reward drain),
|
||||
* deregisterValidator/claimUnbonded to recover self-stake, re-register preserves
|
||||
* delegations, timestamp-based accrual at an owner-settable rewardRateBps (default
|
||||
* 800 = 8% APY), and validator commission credited via claimCommission.
|
||||
*/
|
||||
get stakingV2() {
|
||||
const c = this.contract(this.addresses.AereStakingV2, STAKING_DELEGATED_V2_ABI, true);
|
||||
return {
|
||||
contract: c,
|
||||
registerValidator: (commissionBps: number | bigint, selfStakeWei: bigint) =>
|
||||
c.registerValidator(commissionBps, { value: selfStakeWei }),
|
||||
deregisterValidator: () => c.deregisterValidator(),
|
||||
delegate: (validator: string, amountWei: bigint) => c.delegate(validator, { value: amountWei }),
|
||||
unbond: (validator: string, amount: bigint) => c.unbond(validator, amount),
|
||||
claimUnbonded: () => c.claimUnbonded(),
|
||||
claimRewards: (validator: string) => c.claimRewards(validator),
|
||||
claimCommission: () => c.claimCommission(),
|
||||
pendingRewards: (validator: string, delegator: string) =>
|
||||
c.pendingRewards(validator, delegator) as Promise<bigint>,
|
||||
rewardRateBps: () => c.rewardRateBps() as Promise<bigint>,
|
||||
validators: (v: string) => c.validators(v),
|
||||
delegations: (validator: string, delegator: string) => c.delegations(validator, delegator),
|
||||
validatorCount: () => c.validatorCount() as Promise<bigint>,
|
||||
totalStaked: () => c.totalStaked() as Promise<bigint>,
|
||||
};
|
||||
}
|
||||
|
||||
/** AereIdentity — DID / KYC attestation registry */
|
||||
get identity() {
|
||||
const c = this.contract(this.addresses.AereIdentity, IDENTITY_ABI, true);
|
||||
@ -138,7 +180,12 @@ export class AereClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** AereFaucet — 0.05 AERE drip per address per 24h */
|
||||
/**
|
||||
* AereFaucet — configured for a 0.05 AERE drip per address per 24h.
|
||||
*
|
||||
* HONEST STATUS: NOT FUNDED. The contract holds a zero balance, so claim()
|
||||
* always reverts. There is currently no public way to obtain AERE.
|
||||
*/
|
||||
get faucet() {
|
||||
const c = this.contract(this.addresses.AereFaucet, FAUCET_ABI, true);
|
||||
return {
|
||||
@ -176,7 +223,7 @@ export class AereClient {
|
||||
return tx.hash;
|
||||
}
|
||||
|
||||
/** Convenience: aggregate a Bank28-style multi-asset balance for a user (AERE + WAERE) */
|
||||
/** Convenience: aggregate a wallet-style multi-asset balance for a user (AERE + WAERE) */
|
||||
async getPortfolio(user: string): Promise<{
|
||||
aere: bigint;
|
||||
waere: bigint;
|
||||
|
||||
@ -153,6 +153,14 @@ export class AereZKScreenClient {
|
||||
}
|
||||
|
||||
/* ===================== AereAIProof ================================ */
|
||||
/* SIGNED ATTESTATIONS, NOT ZERO-KNOWLEDGE PROOFS. Despite the contract name, an
|
||||
* anchored record is an EIP-712 ECDSA signature: it establishes that the key
|
||||
* registered for `modelId` SAID a given inputHash produced a given outputHash. It
|
||||
* does NOT establish that any model ran, nor that the output is correct.
|
||||
* `registerModel` is permissionless and first-come, so a `modelId` is squattable
|
||||
* and carries no inherent provenance meaning; callers MUST bind a modelId to a
|
||||
* real provider out of band and re-check after any signer rotation. For an actual
|
||||
* proof of inference see AereZKMLVerifier. */
|
||||
|
||||
const AI_SEL = {
|
||||
models: '0x88e98b22', // models(bytes32)
|
||||
|
||||
219
src/crypto-agility/AereCryptoRegistryClient.ts
Normal file
219
src/crypto-agility/AereCryptoRegistryClient.ts
Normal file
@ -0,0 +1,219 @@
|
||||
// AereCryptoRegistryClient — ethers v6 wrapper over AereCryptoRegistry (chain 2800).
|
||||
//
|
||||
// AereCryptoRegistry (0xaE6fC596bb3eCcbf5c5D02D67B0Ef065b3Afbaa5) is AERE's
|
||||
// cryptographic-agility registry over the five live native precompiles
|
||||
// (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s, SHAKE256). It maps a governed
|
||||
// algorithmId -> { verifier, wire-format, status, gas, successor } so a consumer
|
||||
// integrates against ONE stable surface and follows scheme deprecations /
|
||||
// successors WITHOUT redeploying, by routing through resolveActive.
|
||||
//
|
||||
// This client exposes the fail-closed view surface (verify / resolveActive /
|
||||
// getAlgorithm / status queries / precompileFor) plus the owner-only write
|
||||
// methods (addAlgorithm / setStatus / setSuccessor / seedLiveSchemes), which
|
||||
// take a Signer.
|
||||
|
||||
import {
|
||||
Contract, getBytes, hexlify,
|
||||
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from '../addresses.js';
|
||||
|
||||
/** Minimal inline ABI — only the AereCryptoRegistry surface this client uses. */
|
||||
export const AERE_CRYPTO_REGISTRY_ABI = [
|
||||
// views
|
||||
'function verify(uint256 algorithmId, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||
'function resolveActive(uint256 algorithmId) view returns (uint256)',
|
||||
'function getAlgorithm(uint256 id) view returns (tuple(string name, uint8 scheme, address verifier, uint16 pubKeyLen, uint32 sigLen, uint8 esigHeader, uint8 status, uint256 gasEstimate, uint256 successorId, uint64 addedBlock, bool isSignature))',
|
||||
'function algorithmCount() view returns (uint256)',
|
||||
'function statusOf(uint256 id) view returns (uint8)',
|
||||
'function isActive(uint256 id) view returns (bool)',
|
||||
'function isUsable(uint256 id) view returns (bool)',
|
||||
'function precompileFor(uint8 scheme) view returns (address)',
|
||||
'function seeded() view returns (bool)',
|
||||
'function owner() view returns (address)',
|
||||
// owner writes
|
||||
'function addAlgorithm(string name, uint8 scheme, address verifier, uint16 pubKeyLen, uint32 sigLen, uint8 esigHeader, uint256 gasEstimate, bool isSignature) returns (uint256 id)',
|
||||
'function setStatus(uint256 id, uint8 newStatus)',
|
||||
'function setSuccessor(uint256 id, uint256 successorId)',
|
||||
'function seedLiveSchemes()',
|
||||
// events
|
||||
'event AlgorithmAdded(uint256 indexed id, uint8 indexed scheme, address indexed verifier, string name, bool isSignature)',
|
||||
'event StatusChanged(uint256 indexed id, uint8 oldStatus, uint8 newStatus)',
|
||||
'event SuccessorSet(uint256 indexed id, uint256 indexed successorId)',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Lifecycle status of a registered algorithm (mirrors AereCryptoRegistry.Status).
|
||||
* UNKNOWN=0 (never registered), ACTIVE=1 (recommended, verify runs),
|
||||
* DEPRECATED=2 (still verifies legacy sigs but not usable for new work),
|
||||
* REVOKED=3 (verify always false; terminal).
|
||||
*/
|
||||
export enum CryptoStatus {
|
||||
UNKNOWN = 0,
|
||||
ACTIVE = 1,
|
||||
DEPRECATED = 2,
|
||||
REVOKED = 3,
|
||||
}
|
||||
|
||||
/** A decoded algorithm row. */
|
||||
export interface AlgorithmRow {
|
||||
name: string;
|
||||
scheme: number;
|
||||
verifier: string;
|
||||
pubKeyLen: number;
|
||||
sigLen: number;
|
||||
esigHeader: number;
|
||||
status: CryptoStatus;
|
||||
gasEstimate: bigint;
|
||||
successorId: bigint;
|
||||
addedBlock: bigint;
|
||||
isSignature: boolean;
|
||||
}
|
||||
|
||||
export interface AereCryptoRegistryClientOptions {
|
||||
/** AereCryptoRegistry address. Default: AERE_MAINNET mainnet deployment. */
|
||||
address?: string;
|
||||
}
|
||||
|
||||
function toBytes(v: BytesLike): Uint8Array {
|
||||
return v instanceof Uint8Array ? v : getBytes(v);
|
||||
}
|
||||
|
||||
export class AereCryptoRegistryClient {
|
||||
readonly address: string;
|
||||
readonly contract: Contract;
|
||||
|
||||
constructor(runner: ContractRunner, opts: AereCryptoRegistryClientOptions = {}) {
|
||||
this.address = opts.address ?? AERE_MAINNET.AereCryptoRegistry;
|
||||
this.contract = new Contract(this.address, AERE_CRYPTO_REGISTRY_ABI, runner);
|
||||
}
|
||||
|
||||
// ---- views -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verify a signature under `algorithmId` via the routed verifier. FAIL-CLOSED:
|
||||
* returns false (never throws) for an unknown id, a hash-only row, an
|
||||
* UNKNOWN/REVOKED status, or malformed lengths. A DEPRECATED row still verifies
|
||||
* (legacy) — check {@link isUsable} before adopting a scheme for new work.
|
||||
*/
|
||||
async verify(
|
||||
algorithmId: bigint | number,
|
||||
pubKey: BytesLike,
|
||||
messageHash: BytesLike,
|
||||
signature: BytesLike,
|
||||
): Promise<boolean> {
|
||||
return this.contract.verify(
|
||||
algorithmId,
|
||||
hexlify(toBytes(pubKey)),
|
||||
hexlify(toBytes(messageHash)),
|
||||
hexlify(toBytes(signature)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow the successor chain from `algorithmId` to the first ACTIVE row and
|
||||
* return its id. Reverts on a dead-end (NoActiveSuccessor) or a cycle
|
||||
* (SuccessorCycle) — those are genuine registry conditions, not network errors.
|
||||
*/
|
||||
async resolveActive(algorithmId: bigint | number): Promise<bigint> {
|
||||
return this.contract.resolveActive(algorithmId);
|
||||
}
|
||||
|
||||
/** Read a full algorithm row. Reverts (UnknownAlgorithm) on an unregistered id. */
|
||||
async getAlgorithm(id: bigint | number): Promise<AlgorithmRow> {
|
||||
const r = await this.contract.getAlgorithm(id);
|
||||
return {
|
||||
name: r.name,
|
||||
scheme: Number(r.scheme),
|
||||
verifier: r.verifier,
|
||||
pubKeyLen: Number(r.pubKeyLen),
|
||||
sigLen: Number(r.sigLen),
|
||||
esigHeader: Number(r.esigHeader),
|
||||
status: Number(r.status) as CryptoStatus,
|
||||
gasEstimate: r.gasEstimate,
|
||||
successorId: r.successorId,
|
||||
addedBlock: r.addedBlock,
|
||||
isSignature: r.isSignature,
|
||||
};
|
||||
}
|
||||
|
||||
/** Number of registered algorithms. Ids run 1..algorithmCount(). */
|
||||
async algorithmCount(): Promise<bigint> {
|
||||
return this.contract.algorithmCount();
|
||||
}
|
||||
|
||||
/** Lifecycle status of `id` (UNKNOWN for a never-registered id). */
|
||||
async statusOf(id: bigint | number): Promise<CryptoStatus> {
|
||||
return Number(await this.contract.statusOf(id)) as CryptoStatus;
|
||||
}
|
||||
|
||||
/** True iff `id` exists and is ACTIVE. */
|
||||
async isActive(id: bigint | number): Promise<boolean> {
|
||||
return this.contract.isActive(id);
|
||||
}
|
||||
|
||||
/** True iff `id` is a signature scheme that is safe to adopt for NEW work (ACTIVE). */
|
||||
async isUsable(id: bigint | number): Promise<boolean> {
|
||||
return this.contract.isUsable(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verifier currently routing an ACTIVE algorithm for `scheme`
|
||||
* (1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s, 5=SHAKE256).
|
||||
* Reverts (UnknownScheme) if no ACTIVE row serves that scheme.
|
||||
*/
|
||||
async precompileFor(scheme: number): Promise<string> {
|
||||
return this.contract.precompileFor(scheme);
|
||||
}
|
||||
|
||||
/** Whether the one-shot seedLiveSchemes() has already run. */
|
||||
async seeded(): Promise<boolean> {
|
||||
return this.contract.seeded();
|
||||
}
|
||||
|
||||
/** The current owner (deployer now; Timelock later, founder-signed). */
|
||||
async owner(): Promise<string> {
|
||||
return this.contract.owner();
|
||||
}
|
||||
|
||||
// ---- writes (require a Signer runner, owner-gated on-chain) -----------------
|
||||
|
||||
/** Register a new algorithm row (owner only). The row is created ACTIVE. */
|
||||
async addAlgorithm(
|
||||
signer: Signer,
|
||||
args: {
|
||||
name: string;
|
||||
scheme: number;
|
||||
verifier: string;
|
||||
pubKeyLen: number;
|
||||
sigLen: number;
|
||||
esigHeader: number;
|
||||
gasEstimate: bigint | number;
|
||||
isSignature: boolean;
|
||||
},
|
||||
): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.addAlgorithm(
|
||||
args.name, args.scheme, args.verifier, args.pubKeyLen, args.sigLen,
|
||||
args.esigHeader, args.gasEstimate, args.isSignature,
|
||||
);
|
||||
}
|
||||
|
||||
/** Change an algorithm's lifecycle status (owner only). REVOKED is terminal. */
|
||||
async setStatus(signer: Signer, id: bigint | number, newStatus: CryptoStatus): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.setStatus(id, newStatus);
|
||||
}
|
||||
|
||||
/** Point `id`'s successor at `successorId` (owner only). */
|
||||
async setSuccessor(signer: Signer, id: bigint | number, successorId: bigint | number): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.setSuccessor(id, successorId);
|
||||
}
|
||||
|
||||
/** Seed the five live chain-2800 schemes (owner only, one-shot). */
|
||||
async seedLiveSchemes(signer: Signer): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.seedLiveSchemes();
|
||||
}
|
||||
}
|
||||
232
src/crypto-agility/AereHybridAuthorizerClient.ts
Normal file
232
src/crypto-agility/AereHybridAuthorizerClient.ts
Normal file
@ -0,0 +1,232 @@
|
||||
// AereHybridAuthorizerClient — ethers v6 wrapper over AereHybridAuthorizer (chain 2800).
|
||||
//
|
||||
// AereHybridAuthorizer is the CONSUMER that gives AereCryptoRegistry its value: instead
|
||||
// of pinning a scheme, an account/app authorizes against "whatever scheme is currently
|
||||
// ACTIVE". Every check first calls registry.resolveActive(id) to follow the successor
|
||||
// chain to the live ACTIVE row, then verifies through the registry's routed verifier.
|
||||
//
|
||||
// The payoff is a ZERO-REDEPLOY migration: when the Foundation retires a scheme on the
|
||||
// REGISTRY (addAlgorithm(new) -> setSuccessor(old,new) -> setStatus(old, DEPRECATED|
|
||||
// REVOKED)), every consumer routing through this authorizer starts accepting the NEW
|
||||
// scheme and rejecting the OLD one on the next block — no code change, no redeploy.
|
||||
//
|
||||
// HONEST SCOPE: application-layer authorization only. It does NOT change AERE consensus
|
||||
// (blocks are still Besu QBFT / classical ECDSA). All PQC verification is delegated to the
|
||||
// same live native precompiles the registry routes to. Holds no funds.
|
||||
//
|
||||
// This client exposes the fail-closed view surface (checkAuthorized / checkAuthorizedFor /
|
||||
// algorithmFor / resolvedAlgorithmFor / preferredAlgorithmOf / isProofAuthorized), the
|
||||
// self-service preference write (setPreferredAlgorithm), the owner-only governance writes
|
||||
// (setDefaultAlgorithm / migrate), and the state-changing authorize / authorizeWithPreferred.
|
||||
|
||||
import {
|
||||
Contract, getBytes, hexlify,
|
||||
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
|
||||
/** Minimal inline ABI — only the AereHybridAuthorizer surface this client uses. */
|
||||
export const AERE_HYBRID_AUTHORIZER_ABI = [
|
||||
// immutable / config views
|
||||
'function registry() view returns (address)',
|
||||
'function owner() view returns (address)',
|
||||
'function defaultAlgorithmId() view returns (uint256)',
|
||||
// preference views
|
||||
'function preferredAlgorithmOf(address account) view returns (uint256)',
|
||||
'function algorithmFor(address account) view returns (uint256)',
|
||||
'function resolvedAlgorithmFor(address account) view returns (uint256)',
|
||||
// authorization views (fail-closed)
|
||||
'function checkAuthorized(uint256 algorithmId, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||
'function checkAuthorizedFor(address account, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||
'function isProofAuthorized(bytes pubKey, bytes32 messageHash) view returns (bool)',
|
||||
// ledger views
|
||||
'function authorizationCount() view returns (uint256)',
|
||||
'function lastResolvedId() view returns (uint256)',
|
||||
'function lastMessageHash() view returns (bytes32)',
|
||||
// self-service write
|
||||
'function setPreferredAlgorithm(uint256 id)',
|
||||
// owner writes
|
||||
'function setDefaultAlgorithm(uint256 id)',
|
||||
'function migrate(uint256 oldId) returns (uint256 newId)',
|
||||
// state-changing authorize
|
||||
'function authorize(uint256 algorithmId, bytes pubKey, bytes32 messageHash, bytes signature) returns (uint256 resolvedId)',
|
||||
'function authorizeWithPreferred(bytes pubKey, bytes32 messageHash, bytes signature) returns (uint256 resolvedId)',
|
||||
// events
|
||||
'event DefaultAlgorithmSet(uint256 indexed id)',
|
||||
'event DefaultMigrated(uint256 indexed oldId, uint256 indexed newId)',
|
||||
'event PreferredAlgorithmSet(address indexed account, uint256 indexed id)',
|
||||
'event Authorized(address indexed caller, uint256 indexed requestedId, uint256 indexed resolvedId, bytes32 messageHash)',
|
||||
] as const;
|
||||
|
||||
export interface AereHybridAuthorizerClientOptions {
|
||||
/** AereHybridAuthorizer address. Required (no canonical deployment is pinned yet). */
|
||||
address: string;
|
||||
}
|
||||
|
||||
function toBytes(v: BytesLike): Uint8Array {
|
||||
return v instanceof Uint8Array ? v : getBytes(v);
|
||||
}
|
||||
|
||||
export class AereHybridAuthorizerClient {
|
||||
readonly address: string;
|
||||
readonly contract: Contract;
|
||||
|
||||
constructor(runner: ContractRunner, opts: AereHybridAuthorizerClientOptions) {
|
||||
if (!opts?.address) throw new Error('AereHybridAuthorizerClient: options.address is required');
|
||||
this.address = opts.address;
|
||||
this.contract = new Contract(this.address, AERE_HYBRID_AUTHORIZER_ABI, runner);
|
||||
}
|
||||
|
||||
// ---- config views ----------------------------------------------------------
|
||||
|
||||
/** The AereCryptoRegistry this authorizer routes through. */
|
||||
async registry(): Promise<string> {
|
||||
return this.contract.registry();
|
||||
}
|
||||
|
||||
/** The current owner (deployer now; Timelock later, founder-signed). */
|
||||
async owner(): Promise<string> {
|
||||
return this.contract.owner();
|
||||
}
|
||||
|
||||
/** The app-wide default algorithm id (stable reference; re-resolved on every check). */
|
||||
async defaultAlgorithmId(): Promise<bigint> {
|
||||
return this.contract.defaultAlgorithmId();
|
||||
}
|
||||
|
||||
// ---- preference views ------------------------------------------------------
|
||||
|
||||
/** The raw stored preference for `account` (0 == none / use the app default). */
|
||||
async preferredAlgorithmOf(account: string): Promise<bigint> {
|
||||
return this.contract.preferredAlgorithmOf(account);
|
||||
}
|
||||
|
||||
/** The effective (pre-resolution) algorithm id for `account`: preference if set, else default. */
|
||||
async algorithmFor(account: string): Promise<bigint> {
|
||||
return this.contract.algorithmFor(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACTIVE algorithm id `account` currently authorizes under, after walking the
|
||||
* successor chain. Reverts (NoActiveSuccessor / SuccessorCycle / UnknownAlgorithm) if
|
||||
* the chain has no ACTIVE row — those are genuine registry conditions, not net errors.
|
||||
*/
|
||||
async resolvedAlgorithmFor(account: string): Promise<bigint> {
|
||||
return this.contract.resolvedAlgorithmFor(account);
|
||||
}
|
||||
|
||||
// ---- authorization views (fail-closed) -------------------------------------
|
||||
|
||||
/**
|
||||
* Fail-closed check: is `signature` valid over `messageHash` by `pubKey` under the
|
||||
* currently-ACTIVE scheme reachable from `algorithmId`? Returns false (never throws)
|
||||
* for an unknown id, a dead-end/cyclic successor chain, a malformed input, or a failed
|
||||
* verification. Only an ACTIVE scheme is ever accepted (a REVOKED id is never used).
|
||||
*/
|
||||
async checkAuthorized(
|
||||
algorithmId: bigint | number,
|
||||
pubKey: BytesLike,
|
||||
messageHash: BytesLike,
|
||||
signature: BytesLike,
|
||||
): Promise<boolean> {
|
||||
return this.contract.checkAuthorized(
|
||||
algorithmId, hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Fail-closed check under `account`'s effective algorithm (its preference, else default). */
|
||||
async checkAuthorizedFor(
|
||||
account: string,
|
||||
pubKey: BytesLike,
|
||||
messageHash: BytesLike,
|
||||
signature: BytesLike,
|
||||
): Promise<boolean> {
|
||||
return this.contract.checkAuthorizedFor(
|
||||
account, hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether (pubKey, messageHash) was ever recorded by a successful authorize(). */
|
||||
async isProofAuthorized(pubKey: BytesLike, messageHash: BytesLike): Promise<boolean> {
|
||||
return this.contract.isProofAuthorized(hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)));
|
||||
}
|
||||
|
||||
// ---- ledger views ----------------------------------------------------------
|
||||
|
||||
/** Number of successful authorize() calls recorded. */
|
||||
async authorizationCount(): Promise<bigint> {
|
||||
return this.contract.authorizationCount();
|
||||
}
|
||||
|
||||
/** The ACTIVE algorithm id used by the most recent successful authorization. */
|
||||
async lastResolvedId(): Promise<bigint> {
|
||||
return this.contract.lastResolvedId();
|
||||
}
|
||||
|
||||
/** The message hash of the most recent successful authorization. */
|
||||
async lastMessageHash(): Promise<string> {
|
||||
return this.contract.lastMessageHash();
|
||||
}
|
||||
|
||||
// ---- self-service write ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Choose a personal preferred algorithm id for the signer. Pass 0 to clear the
|
||||
* preference and fall back to the app default. A non-zero id must resolve to a usable
|
||||
* ACTIVE signature scheme. Permissionless (any account, not owner-gated).
|
||||
*/
|
||||
async setPreferredAlgorithm(signer: Signer, id: bigint | number): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.setPreferredAlgorithm(id);
|
||||
}
|
||||
|
||||
// ---- owner writes ----------------------------------------------------------
|
||||
|
||||
/** Set the app-wide default algorithm id (owner only). Must resolve to a usable ACTIVE scheme. */
|
||||
async setDefaultAlgorithm(signer: Signer, id: bigint | number): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.setDefaultAlgorithm(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner helper: crystallize the app default onto the ACTIVE successor of `oldId` after a
|
||||
* registry swap. `oldId` must equal the current default. A no-op if the id is still
|
||||
* ACTIVE. Reverts if the chain has no ACTIVE successor.
|
||||
*/
|
||||
async migrate(signer: Signer, oldId: bigint | number): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.migrate(oldId);
|
||||
}
|
||||
|
||||
// ---- state-changing authorize ----------------------------------------------
|
||||
|
||||
/**
|
||||
* State-changing authorization under the ACTIVE scheme reachable from `algorithmId`.
|
||||
* Reverts if the successor chain has no ACTIVE row (surfacing the registry reason) or
|
||||
* AuthorizationFailed if the routed verifier returns false. Records the proof on success.
|
||||
*/
|
||||
async authorize(
|
||||
signer: Signer,
|
||||
algorithmId: bigint | number,
|
||||
pubKey: BytesLike,
|
||||
messageHash: BytesLike,
|
||||
signature: BytesLike,
|
||||
): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.authorize(
|
||||
algorithmId, hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||
);
|
||||
}
|
||||
|
||||
/** State-changing authorization under the signer's effective algorithm (preference, else default). */
|
||||
async authorizeWithPreferred(
|
||||
signer: Signer,
|
||||
pubKey: BytesLike,
|
||||
messageHash: BytesLike,
|
||||
signature: BytesLike,
|
||||
): Promise<ContractTransactionResponse> {
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
return c.authorizeWithPreferred(
|
||||
hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/crypto-agility/index.ts
Normal file
18
src/crypto-agility/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
// AERE cryptographic-agility surface — the governed registry over AERE's five live
|
||||
// native precompiles (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s, SHAKE256). Integrate
|
||||
// against this one stable surface and follow scheme deprecations / successors WITHOUT
|
||||
// redeploying, by routing through resolveActive.
|
||||
|
||||
export {
|
||||
AereCryptoRegistryClient, AERE_CRYPTO_REGISTRY_ABI, CryptoStatus,
|
||||
type AereCryptoRegistryClientOptions, type AlgorithmRow,
|
||||
} from './AereCryptoRegistryClient.js';
|
||||
|
||||
// AereHybridAuthorizer — the CONSUMER over the registry: authorize against "whatever
|
||||
// scheme is currently ACTIVE" (routes via resolveActive), so a Foundation-governed scheme
|
||||
// swap migrates every consumer with NO redeploy. Owner-governed default + per-account
|
||||
// preference with a safe default.
|
||||
export {
|
||||
AereHybridAuthorizerClient, AERE_HYBRID_AUTHORIZER_ABI,
|
||||
type AereHybridAuthorizerClientOptions,
|
||||
} from './AereHybridAuthorizerClient.js';
|
||||
116
src/index.ts
116
src/index.ts
@ -1,5 +1,21 @@
|
||||
export { AereClient, type AereClientOptions } from './client.js';
|
||||
export { AERE_MAINNET, AERE_COREBOOK, type AereContractName, type CoreBookMarketSymbol } from './addresses.js';
|
||||
|
||||
// State-window guard. The public endpoints serve a bounded window of world state
|
||||
// (measured 512 blocks, about 4m25s) and answer a pruned eth_getTransactionCount
|
||||
// with 0x0 rather than an error. Read historical state through StateWindowReader
|
||||
// and you get a value or an exception, never a placeholder.
|
||||
export {
|
||||
StateWindowReader,
|
||||
StateWindowError,
|
||||
assertStateServable,
|
||||
DEFAULT_STATE_WINDOW_BLOCKS,
|
||||
DEFAULT_SAFETY_MARGIN_BLOCKS,
|
||||
MEASURED_BLOCK_INTERVAL_SECONDS,
|
||||
} from './state-window.js';
|
||||
export type {
|
||||
JsonRpcSend, BlockTag, StateWindowReaderOptions, StateWindowErrorDetail, StateWindowReason,
|
||||
} from './state-window.js';
|
||||
export {
|
||||
ERC20_ABI, WAERE_ABI, STAKING_V2_ABI, STAKING_V1_ABI, LENDING_ABI, STABLE_ABI,
|
||||
IDENTITY_ABI, BRIDGE_ABI, FAUCET_ABI, SWAP_FACTORY_ABI, SWAP_PAIR_ABI, LIGHTNING_CHANNELS_ABI,
|
||||
@ -25,6 +41,13 @@ export { AereAgentClient, AERE402SettlementClient, buildAuthTypedData,
|
||||
createAere402Middleware, buildAere402TypedData } from './agentic/index.js';
|
||||
export type { Aere402MiddlewareConfig, PaymentAuth, AgentPaymentAuth, Eip1193Provider } from './agentic/index.js';
|
||||
|
||||
// AERE402FacilitatorPQC — HTTP 402 settlement rooted in a Falcon (post-quantum) identity;
|
||||
// payments authorized by an AereAgentDID session, root lifecycle enforced.
|
||||
export { AERE402FacilitatorPQCClient, AERE402_FACILITATOR_PQC_ABI,
|
||||
PAYMENT_DOMAIN as AERE402_PQC_PAYMENT_DOMAIN } from './agentic/index.js';
|
||||
export type { AERE402FacilitatorPQCClientOptions, PQCPaymentParams,
|
||||
PQCPaymentAuthorization, SettleResult as AERE402PQCSettleResult } from './agentic/index.js';
|
||||
|
||||
// Compliance primitives — AereProof v0 + ZKScreen + AIProof.
|
||||
export { AereSanctionsRegistryClient, ChainalysisOracleWrapperClient,
|
||||
AereTravelRuleHashRegistryClient, AereForensicEventRegistryClient,
|
||||
@ -55,3 +78,96 @@ export {
|
||||
AereAttestationGatewayClient,
|
||||
AereCompliancePoolClient,
|
||||
} from './compliance/index.js';
|
||||
|
||||
// PQC signing surface — keygen + internal-interface signing + envelope builders
|
||||
// + ethers v6 wrappers for AerePQCAttestation, AerePQCKeyRegistry (on-chain
|
||||
// proof-of-possession key registry) and AereAgentDID (Falcon-rooted agent DID),
|
||||
// driving AERE's LIVE native post-quantum precompiles (Falcon-512/1024, ML-DSA-44,
|
||||
// SLH-DSA-SHA2-128s) on chain 2800. All four schemes have full pure-JS signing,
|
||||
// proven interoperable against the live precompiles.
|
||||
export * from './pqc/index.js';
|
||||
|
||||
// Cryptographic-agility — AereCryptoRegistry: the governed algorithmId ->
|
||||
// {verifier, wire-format, status, gas, successor} table over the 5 live precompiles.
|
||||
export * from './crypto-agility/index.js';
|
||||
|
||||
// PQC-authorized intents — Falcon/ML-DSA-signed ERC-7683 cross-chain orders for
|
||||
// AereSpokePool.openForPQC: quantum-durable authorization routed through the
|
||||
// crypto-agility registry (scheme migration with no redeploy). Order-builder + digest.
|
||||
export * from './intents/index.js';
|
||||
|
||||
// Seedless hybrid PQC wallet core — passkey daily factor fused with a Falcon-512
|
||||
// quantum-durable root (AerePQCAccount), client-side-encrypted key custody, and
|
||||
// the one-tap "upgrade to post-quantum" attestation. Chain 2800.
|
||||
export * from './wallet/index.js';
|
||||
|
||||
// ERC-7579 modular accounts — session keys + M-of-N social recovery, bound to
|
||||
// AereEntryPointV2 (chain 2800). Deployed 2026-07-10.
|
||||
export {
|
||||
ModularAccountClient,
|
||||
SessionKeyClient,
|
||||
SocialRecoveryClient,
|
||||
AERE_MODULAR_ADDRESSES,
|
||||
AERE_ACCOUNT_INIT_CODE_HASH,
|
||||
RECOVERY_DELAY_SECONDS,
|
||||
MODULE_TYPE_VALIDATOR,
|
||||
MODULE_TYPE_EXECUTOR,
|
||||
SELECTOR_NATIVE_TRANSFER,
|
||||
ACCOUNT_SELECTORS,
|
||||
routeRootSignature,
|
||||
routeValidatorSignature,
|
||||
eip1193PersonalSign,
|
||||
toChecksumAddress,
|
||||
encodeExecute as encodeAccountExecute,
|
||||
encodeExecuteBatch as encodeAccountExecuteBatch,
|
||||
computeUserOpHash,
|
||||
} from './account/index.js';
|
||||
export type {
|
||||
ModularAccountAddresses,
|
||||
ModularAccountClientOptions,
|
||||
PackedUserOp,
|
||||
GasParams,
|
||||
SessionScope,
|
||||
SessionPermission,
|
||||
SessionInfo,
|
||||
ActiveRecovery,
|
||||
UserOpSigner,
|
||||
ScopeCheck,
|
||||
CallStruct,
|
||||
Eip1193Provider as AccountEip1193Provider,
|
||||
} from './account/index.js';
|
||||
|
||||
// AerePQCSocialRecoveryModule — quantum-durable M-of-N social recovery for AereModularAccount:
|
||||
// each guardian is a NIST PQC key in AerePQCKeyRegistry, and a recovery is authorized by
|
||||
// >= threshold DISTINCT guardians PQC-signing a domain-separated challenge. Repo contract +
|
||||
// client; the module is not yet deployed to mainnet (see src/addresses.ts).
|
||||
export {
|
||||
AerePQCSocialRecoveryClient,
|
||||
AERE_PQC_SOCIAL_RECOVERY_ABI,
|
||||
PQC_RECOVERY_DOMAIN,
|
||||
PQC_RECOVERY_DELAY_SECONDS,
|
||||
PQC_MODULE_TYPE_EXECUTOR,
|
||||
pqcRecoveryChallenge,
|
||||
signPqcRecoveryLeg,
|
||||
encodePqcRecoveryLegs,
|
||||
encodePqcGuardianInstallData,
|
||||
type PQCRecoveryLeg,
|
||||
type PQCGuardianKey,
|
||||
type PQCPendingRecovery,
|
||||
} from './account/index.js';
|
||||
|
||||
// Non-custodial post-quantum t-of-n ERC-4337 account (contracts/mpc/AereThresholdAccount.sol).
|
||||
export {
|
||||
AereThresholdAccountClient,
|
||||
createThresholdAccount,
|
||||
signLeg,
|
||||
encodeLegs,
|
||||
userOpChallenge as thresholdUserOpChallenge,
|
||||
execChallenge as thresholdExecChallenge,
|
||||
USEROP_DOMAIN as THRESHOLD_USEROP_DOMAIN,
|
||||
EXEC_DOMAIN as THRESHOLD_EXEC_DOMAIN,
|
||||
AERE_THRESHOLD_ACCOUNT_ABI,
|
||||
AERE_THRESHOLD_ACCOUNT_FACTORY_ABI,
|
||||
type Leg,
|
||||
type MemberKey,
|
||||
} from './mpc/AereThresholdAccountClient.js';
|
||||
|
||||
201
src/intents/AerePQCOrderBuilder.ts
Normal file
201
src/intents/AerePQCOrderBuilder.ts
Normal file
@ -0,0 +1,201 @@
|
||||
// AerePQCOrderBuilder — build and sign post-quantum-authorized ERC-7683 cross-chain
|
||||
// orders for AereSpokePool.openForPQC (chain 2800).
|
||||
//
|
||||
// A PQC order is the same ERC-7683 gasless-order shape as the ECDSA path, except the
|
||||
// authorization is a NIST post-quantum signature (Falcon-512/1024, ML-DSA-44,
|
||||
// SLH-DSA-128s) over the EIP-712 order digest, verified on-chain by AERE's live native
|
||||
// PQC precompiles routed through the governed AereCryptoRegistry. So the intent is
|
||||
// quantum-durable end to end, and a Foundation scheme swap migrates the accepted scheme
|
||||
// with NO redeploy (the on-chain resolveActive route does the migration).
|
||||
//
|
||||
// This module is PURE byte assembly + a signing call. The digest is computed byte-for-
|
||||
// byte the way AerePQCOrderAuthorizer.orderDigest does (EIP-712, domain "AerePQCOrder"
|
||||
// v1, verifyingContract = the SETTLER), so a signature this builder produces authorizes
|
||||
// exactly the order the pool will open. Signing reuses the audited src/pqc signer, which
|
||||
// returns the exact wire-format signature envelope the registry expects.
|
||||
|
||||
import {
|
||||
AbiCoder, concat, getBytes, hexlify, keccak256, toUtf8Bytes, zeroPadValue,
|
||||
type BytesLike,
|
||||
} from 'ethers';
|
||||
import { SCHEME, type SchemeId, signInternal, verifyLocal, keygen } from '../pqc/index.js';
|
||||
|
||||
const abi = AbiCoder.defaultAbiCoder();
|
||||
|
||||
/** The order-data discriminator AereSpokePool routes: keccak256("AereV3Order"). */
|
||||
export const AERE_ORDER_DATA_TYPE = keccak256(toUtf8Bytes('AereV3Order'));
|
||||
|
||||
/** EIP-712 constants, identical to AerePQCOrderAuthorizer. */
|
||||
const EIP712_DOMAIN_TYPEHASH = keccak256(
|
||||
toUtf8Bytes('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
|
||||
);
|
||||
const DOMAIN_NAME = keccak256(toUtf8Bytes('AerePQCOrder'));
|
||||
const DOMAIN_VERSION = keccak256(toUtf8Bytes('1'));
|
||||
export const PQC_ORDER_TYPEHASH = keccak256(
|
||||
toUtf8Bytes(
|
||||
'AerePQCOrder(address user,uint256 nonce,uint256 originChainId,uint32 openDeadline,uint32 fillDeadline,uint256 algorithmId,bytes32 orderDataType,bytes32 orderDataHash,bytes32 pubKeyHash)',
|
||||
),
|
||||
);
|
||||
|
||||
/** The AereV3Order intent payload carried in `orderData`. */
|
||||
export interface AereV3OrderData {
|
||||
inputToken: string;
|
||||
inputAmount: bigint;
|
||||
outputToken: string;
|
||||
outputAmount: bigint;
|
||||
/** 20-byte destination recipient address (encoded left-padded to bytes32). */
|
||||
recipient: string;
|
||||
destinationChainId: bigint | number;
|
||||
}
|
||||
|
||||
/** The PQCGaslessOrder the contract expects (struct field order preserved). */
|
||||
export interface PQCGaslessOrder {
|
||||
user: string;
|
||||
nonce: bigint;
|
||||
originChainId: bigint;
|
||||
openDeadline: number;
|
||||
fillDeadline: number;
|
||||
/** requested crypto-agility registry id; 0 means "use the authorizer default". */
|
||||
algorithmId: bigint;
|
||||
orderDataType: string;
|
||||
orderData: string;
|
||||
}
|
||||
|
||||
/** Inputs to assemble a PQCGaslessOrder. */
|
||||
export interface BuildOrderParams {
|
||||
user: string;
|
||||
nonce: bigint | number;
|
||||
originChainId: bigint | number;
|
||||
openDeadline: number;
|
||||
fillDeadline: number;
|
||||
algorithmId?: bigint | number; // default 0 => authorizer default
|
||||
order: AereV3OrderData;
|
||||
}
|
||||
|
||||
/** ABI-encode the AereV3Order `orderData` blob exactly as the pool decodes it. */
|
||||
export function encodeAereV3OrderData(o: AereV3OrderData): string {
|
||||
return abi.encode(
|
||||
['address', 'uint256', 'address', 'uint256', 'bytes32', 'uint64'],
|
||||
[o.inputToken, o.inputAmount, o.outputToken, o.outputAmount, zeroPadValue(o.recipient, 32), o.destinationChainId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Assemble a {@link PQCGaslessOrder} (no signing) from high-level inputs. */
|
||||
export function buildPQCGaslessOrder(p: BuildOrderParams): PQCGaslessOrder {
|
||||
return {
|
||||
user: p.user,
|
||||
nonce: BigInt(p.nonce),
|
||||
originChainId: BigInt(p.originChainId),
|
||||
openDeadline: p.openDeadline,
|
||||
fillDeadline: p.fillDeadline,
|
||||
algorithmId: BigInt(p.algorithmId ?? 0),
|
||||
orderDataType: AERE_ORDER_DATA_TYPE,
|
||||
orderData: encodeAereV3OrderData(p.order),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the EIP-712 order digest (the 32-byte message the PQC key signs), byte-for-
|
||||
* byte identical to AerePQCOrderAuthorizer.orderDigest.
|
||||
* @param settler the consuming AereSpokePool address (EIP-712 verifyingContract).
|
||||
* @param chainId the settler's chain id (block.chainid at verification time).
|
||||
* @param order the PQCGaslessOrder.
|
||||
* @param pubKey the authorized post-quantum public key.
|
||||
*/
|
||||
export function pqcOrderDigest(settler: string, chainId: bigint | number, order: PQCGaslessOrder, pubKey: BytesLike): string {
|
||||
const domainSeparator = keccak256(
|
||||
abi.encode(
|
||||
['bytes32', 'bytes32', 'bytes32', 'uint256', 'address'],
|
||||
[EIP712_DOMAIN_TYPEHASH, DOMAIN_NAME, DOMAIN_VERSION, BigInt(chainId), settler],
|
||||
),
|
||||
);
|
||||
const structHash = keccak256(
|
||||
abi.encode(
|
||||
['bytes32', 'address', 'uint256', 'uint256', 'uint32', 'uint32', 'uint256', 'bytes32', 'bytes32', 'bytes32'],
|
||||
[
|
||||
PQC_ORDER_TYPEHASH,
|
||||
order.user,
|
||||
order.nonce,
|
||||
order.originChainId,
|
||||
order.openDeadline,
|
||||
order.fillDeadline,
|
||||
order.algorithmId,
|
||||
order.orderDataType,
|
||||
keccak256(order.orderData),
|
||||
keccak256(hexlify(pubKey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return keccak256(concat(['0x1901', domainSeparator, structHash]));
|
||||
}
|
||||
|
||||
/** A signed PQC order, ready to submit to AereSpokePool.openForPQC(order, pubKey, signature). */
|
||||
export interface SignedPQCOrder {
|
||||
order: PQCGaslessOrder;
|
||||
/** hex public key (the funder must have bound this via bindPQCKey). */
|
||||
pubKey: string;
|
||||
/** hex signature envelope in the registry wire format for `scheme`. */
|
||||
signature: string;
|
||||
/** the order digest that was signed. */
|
||||
digest: string;
|
||||
/** the scheme the signature was produced under. */
|
||||
scheme: SchemeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pluggable signer over the order digest, for HSM / keystore custody. It receives the
|
||||
* 32-byte digest and must return the registry wire-format signature envelope.
|
||||
*/
|
||||
export type PQCDigestSigner = (digest: Uint8Array) => Uint8Array | Promise<Uint8Array>;
|
||||
|
||||
/**
|
||||
* Build AND sign a PQC order with a raw secret key using the audited src/pqc signer.
|
||||
* Returns everything openForPQC needs. Locally verifies the envelope before returning
|
||||
* (fail-fast: a malformed key or scheme mismatch throws here, not on-chain).
|
||||
*/
|
||||
export function buildAndSignPQCOrder(args: {
|
||||
settler: string;
|
||||
chainId: bigint | number;
|
||||
scheme: SchemeId;
|
||||
publicKey: BytesLike;
|
||||
secretKey: BytesLike;
|
||||
params: BuildOrderParams;
|
||||
}): SignedPQCOrder {
|
||||
const order = buildPQCGaslessOrder(args.params);
|
||||
const pubKey = hexlify(args.publicKey);
|
||||
const digest = pqcOrderDigest(args.settler, args.chainId, order, pubKey);
|
||||
const digestBytes = getBytes(digest);
|
||||
const sig = signInternal(args.scheme, digestBytes, getBytes(args.secretKey));
|
||||
if (!verifyLocal(args.scheme, digestBytes, sig, getBytes(args.publicKey))) {
|
||||
throw new Error('AerePQCOrderBuilder: locally-produced signature failed local verification');
|
||||
}
|
||||
return { order, pubKey, signature: hexlify(sig), digest, scheme: args.scheme };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PQC order and sign its digest with a pluggable {@link PQCDigestSigner}
|
||||
* (HSM / encrypted keystore). The signer must return a registry wire-format envelope.
|
||||
*/
|
||||
export async function buildAndSignPQCOrderWithSigner(args: {
|
||||
settler: string;
|
||||
chainId: bigint | number;
|
||||
scheme: SchemeId;
|
||||
publicKey: BytesLike;
|
||||
sign: PQCDigestSigner;
|
||||
params: BuildOrderParams;
|
||||
}): Promise<SignedPQCOrder> {
|
||||
const order = buildPQCGaslessOrder(args.params);
|
||||
const pubKey = hexlify(args.publicKey);
|
||||
const digest = pqcOrderDigest(args.settler, args.chainId, order, pubKey);
|
||||
const sig = await args.sign(getBytes(digest));
|
||||
return { order, pubKey, signature: hexlify(sig), digest, scheme: args.scheme };
|
||||
}
|
||||
|
||||
/** Convenience: a fresh keypair for `scheme` (deterministic from `seed` if given). */
|
||||
export function generateOrderKey(scheme: SchemeId, seed?: Uint8Array) {
|
||||
return keygen(scheme, seed);
|
||||
}
|
||||
|
||||
/** Re-export the scheme ids for callers building order-signing flows. */
|
||||
export { SCHEME as PQC_SCHEME };
|
||||
export type { SchemeId };
|
||||
15
src/intents/index.ts
Normal file
15
src/intents/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// AERE PQC-authorized intents — Falcon/ML-DSA-signed ERC-7683 cross-chain orders for
|
||||
// AereSpokePool.openForPQC. Quantum-durable authorization, verified on-chain by AERE's
|
||||
// live native PQC precompiles routed through the governed crypto-agility registry, so
|
||||
// scheme migration needs no redeploy. See AerePQCOrderBuilder for the digest + signer.
|
||||
|
||||
export {
|
||||
AERE_ORDER_DATA_TYPE, PQC_ORDER_TYPEHASH, PQC_SCHEME,
|
||||
encodeAereV3OrderData, buildPQCGaslessOrder, pqcOrderDigest,
|
||||
buildAndSignPQCOrder, buildAndSignPQCOrderWithSigner, generateOrderKey,
|
||||
} from './AerePQCOrderBuilder.js';
|
||||
|
||||
export type {
|
||||
AereV3OrderData, PQCGaslessOrder, BuildOrderParams,
|
||||
SignedPQCOrder, PQCDigestSigner,
|
||||
} from './AerePQCOrderBuilder.js';
|
||||
176
src/mpc/AereThresholdAccountClient.ts
Normal file
176
src/mpc/AereThresholdAccountClient.ts
Normal file
@ -0,0 +1,176 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// AereThresholdAccountClient — orchestration client for the on-chain AereThresholdAccount, a
|
||||
// non-custodial ERC-4337 account owned by a t-of-n POST-QUANTUM committee. This client makes the
|
||||
// account USABLE: it derives the exact domain-separated challenge the members must PQC-sign
|
||||
// (mirroring the contract byte-for-byte), produces each member's authorizing leg from their PQC
|
||||
// key (reusing the audited pqc envelope path), encodes the leg array, and submits either the
|
||||
// direct self-relay `executeThreshold` call or an ERC-4337 signature blob. It never holds a key
|
||||
// and never signs on a member's behalf beyond the key material the caller supplies.
|
||||
//
|
||||
// The challenge derivations here are cross-checked against the deployed contract in the repo's
|
||||
// hardhat test (test/AereThresholdAccount.test.js) so SDK<->contract parity is proven, not assumed.
|
||||
|
||||
import {
|
||||
Contract,
|
||||
AbiCoder,
|
||||
keccak256,
|
||||
toUtf8Bytes,
|
||||
getBytes,
|
||||
hexlify,
|
||||
type Signer,
|
||||
type Provider,
|
||||
type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { SCHEME, type SchemeId } from '../pqc/envelope.js';
|
||||
import { signInternal } from '../pqc/schemes.js';
|
||||
|
||||
const coder = AbiCoder.defaultAbiCoder();
|
||||
|
||||
/** Domain separators — MUST match AereThresholdAccount.sol exactly. Distinct domains guarantee a
|
||||
* signature made for the UserOp path can never authorize the direct-exec path and vice versa. */
|
||||
export const USEROP_DOMAIN = keccak256(toUtf8Bytes('AereThresholdAccount.v1.userop'));
|
||||
export const EXEC_DOMAIN = keccak256(toUtf8Bytes('AereThresholdAccount.v1.exec'));
|
||||
|
||||
/** Default AERE chain id. */
|
||||
export const AERE_CHAIN_ID = 2800n;
|
||||
|
||||
export const AERE_THRESHOLD_ACCOUNT_ABI = [
|
||||
'function entryPoint() view returns (address)',
|
||||
'function committee() view returns (uint8 scheme, uint8 threshold, uint8 size, bytes32 membersHash)',
|
||||
'function execNonce() view returns (uint64)',
|
||||
'function pubKeyAt(uint8 memberIndex) view returns (bytes)',
|
||||
'function userOpChallenge(bytes32 userOpHash) view returns (bytes32)',
|
||||
'function execChallenge(uint64 nonce, address target, uint256 value, bytes data) view returns (bytes32)',
|
||||
'function executeThreshold(address target, uint256 value, bytes data, tuple(uint8 memberIndex, bytes signature)[] legs) returns (uint64 usedNonce)',
|
||||
'function validateUserOp((address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,bytes signature) userOp, bytes32 userOpHash, uint256 missingAccountFunds) returns (uint256)',
|
||||
] as const;
|
||||
|
||||
export const AERE_THRESHOLD_ACCOUNT_FACTORY_ABI = [
|
||||
'function createAccount(address entryPoint, uint8 scheme, uint8 threshold, bytes[] pubKeys, bytes32 salt) returns (address account)',
|
||||
'function computeAddress(address entryPoint, uint8 scheme, uint8 threshold, bytes[] pubKeys, bytes32 salt) view returns (address account)',
|
||||
'event AccountCreated(address indexed account, uint8 scheme, uint8 threshold, uint8 size, bytes32 salt)',
|
||||
] as const;
|
||||
|
||||
/** One authorizing leg: which committee member, and their PQC signature envelope over the challenge. */
|
||||
export interface Leg {
|
||||
memberIndex: number;
|
||||
signature: string; // 0x-hex envelope in the scheme's on-chain encoding
|
||||
}
|
||||
|
||||
/** A committee member's signing material for producing a leg off-chain. */
|
||||
export interface MemberKey {
|
||||
memberIndex: number;
|
||||
secretKey: Uint8Array;
|
||||
}
|
||||
|
||||
// ── pure challenge derivations (mirror the contract) ────────────────────────
|
||||
|
||||
/** The exact 32-byte challenge each member must PQC-sign to authorize a UserOp. Mirrors
|
||||
* AereThresholdAccount.userOpChallenge: keccak256(abi.encode(USEROP_DOMAIN, chainId, account, userOpHash)). */
|
||||
export function userOpChallenge(account: string, userOpHash: string, chainId: bigint = AERE_CHAIN_ID): string {
|
||||
return keccak256(coder.encode(['bytes32', 'uint256', 'address', 'bytes32'], [USEROP_DOMAIN, chainId, account, userOpHash]));
|
||||
}
|
||||
|
||||
/** The exact 32-byte challenge each member must PQC-sign to authorize a direct call at `nonce`.
|
||||
* Mirrors AereThresholdAccount.execChallenge:
|
||||
* keccak256(abi.encode(EXEC_DOMAIN, chainId, account, nonce, target, value, keccak256(data))). */
|
||||
export function execChallenge(
|
||||
account: string,
|
||||
nonce: bigint,
|
||||
target: string,
|
||||
value: bigint,
|
||||
data: string,
|
||||
chainId: bigint = AERE_CHAIN_ID,
|
||||
): string {
|
||||
return keccak256(
|
||||
coder.encode(
|
||||
['bytes32', 'uint256', 'address', 'uint64', 'address', 'uint256', 'bytes32'],
|
||||
[EXEC_DOMAIN, chainId, account, nonce, target, value, keccak256(data)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── leg production + encoding ───────────────────────────────────────────────
|
||||
|
||||
/** Produce one member's authorizing leg by PQC-signing `challenge` with their secret key. The
|
||||
* produced envelope is exactly what the on-chain precompile verifies (via the audited pqc path). */
|
||||
export function signLeg(scheme: SchemeId, member: MemberKey, challenge: string): Leg {
|
||||
const sig = signInternal(scheme, getBytes(challenge), member.secretKey);
|
||||
return { memberIndex: member.memberIndex, signature: hexlify(sig) };
|
||||
}
|
||||
|
||||
/** ABI-encode a leg array into the ERC-4337 `signature` blob (tuple(uint8,bytes)[]). */
|
||||
export function encodeLegs(legs: Leg[]): string {
|
||||
return coder.encode(['tuple(uint8 memberIndex, bytes signature)[]'], [legs.map((l) => [l.memberIndex, l.signature])]);
|
||||
}
|
||||
|
||||
// ── client ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export class AereThresholdAccountClient {
|
||||
readonly contract: Contract;
|
||||
private readonly chainId: bigint;
|
||||
|
||||
constructor(address: string, runner: Signer | Provider, chainId: bigint = AERE_CHAIN_ID) {
|
||||
this.contract = new Contract(address, AERE_THRESHOLD_ACCOUNT_ABI, runner);
|
||||
this.chainId = chainId;
|
||||
}
|
||||
|
||||
get address(): Promise<string> {
|
||||
return this.contract.getAddress();
|
||||
}
|
||||
|
||||
async committee(): Promise<{ scheme: SchemeId; threshold: number; size: number; membersHash: string }> {
|
||||
const [scheme, threshold, size, membersHash] = await this.contract.committee();
|
||||
return { scheme: Number(scheme) as SchemeId, threshold: Number(threshold), size: Number(size), membersHash };
|
||||
}
|
||||
|
||||
async execNonce(): Promise<bigint> {
|
||||
return this.contract.execNonce();
|
||||
}
|
||||
|
||||
/** Locally derive the exec challenge for the given call at the account's CURRENT execNonce. */
|
||||
async execChallengeNow(target: string, value: bigint, data: string): Promise<{ nonce: bigint; challenge: string }> {
|
||||
const nonce = await this.execNonce();
|
||||
const account = await this.address;
|
||||
return { nonce, challenge: execChallenge(account, nonce, target, value, data, this.chainId) };
|
||||
}
|
||||
|
||||
/** High-level: authorize + execute a single call directly (no bundler). Derives the challenge,
|
||||
* signs it with each supplied member key, and submits `executeThreshold`. */
|
||||
async authorizeAndExecute(
|
||||
target: string,
|
||||
value: bigint,
|
||||
data: string,
|
||||
scheme: SchemeId,
|
||||
members: MemberKey[],
|
||||
): Promise<ContractTransactionResponse> {
|
||||
const { challenge } = await this.execChallengeNow(target, value, data);
|
||||
const legs = members.map((m) => signLeg(scheme, m, challenge));
|
||||
return this.contract.executeThreshold(target, value, data, legs.map((l) => [l.memberIndex, l.signature]));
|
||||
}
|
||||
|
||||
/** Build an ERC-4337 `signature` blob: derive the userOp challenge, sign with each member, encode. */
|
||||
buildUserOpSignature(account: string, userOpHash: string, scheme: SchemeId, members: MemberKey[]): string {
|
||||
const challenge = userOpChallenge(account, userOpHash, this.chainId);
|
||||
const legs = members.map((m) => signLeg(scheme, m, challenge));
|
||||
return encodeLegs(legs);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deploy (or fetch) a threshold account via the factory, returning its address. */
|
||||
export async function createThresholdAccount(
|
||||
factoryAddress: string,
|
||||
runner: Signer,
|
||||
entryPoint: string,
|
||||
scheme: SchemeId,
|
||||
threshold: number,
|
||||
pubKeys: string[],
|
||||
salt: string,
|
||||
): Promise<string> {
|
||||
const factory = new Contract(factoryAddress, AERE_THRESHOLD_ACCOUNT_FACTORY_ABI, runner);
|
||||
const predicted: string = await factory.computeAddress(entryPoint, scheme, threshold, pubKeys, salt);
|
||||
const tx: ContractTransactionResponse = await factory.createAccount(entryPoint, scheme, threshold, pubKeys, salt);
|
||||
await tx.wait();
|
||||
return predicted;
|
||||
}
|
||||
88
src/mpc/demo.ts
Normal file
88
src/mpc/demo.ts
Normal file
@ -0,0 +1,88 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Runnable demo: node sdk-js/src/mpc/demo.ts
|
||||
//
|
||||
// 1. Runs a real 2-of-3 Feldman VSS DKG (verifies every dealt share).
|
||||
// 2. Signs a message with a 2-of-3 threshold signature and recovers the signer
|
||||
// address locally to prove it equals the DKG group address.
|
||||
// 3. Shows that a below-threshold (1 share) combine recovers to the WRONG address.
|
||||
// 4. Prints the production-signer honesty status.
|
||||
|
||||
import { runDkg, verifyShareAgainstCommitments } from "./dkg.js";
|
||||
import { combineByReconstruction, productionSignerStatus } from "./sign.js";
|
||||
import { addressFromPubKey64, bytesToBigInt, ecdsaSignDigest, hex } from "./secp.js";
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { keccak_256 } from "@noble/hashes/sha3";
|
||||
|
||||
function recoverAddr(digest: Uint8Array, sig: Uint8Array): string {
|
||||
const r = sig.slice(0, 32);
|
||||
const s = sig.slice(32, 64);
|
||||
const v = sig[64];
|
||||
const recovery = v - 27;
|
||||
const rs = new Uint8Array(64);
|
||||
rs.set(r, 0);
|
||||
rs.set(s, 32);
|
||||
const sigObj = secp256k1.Signature.fromCompact(rs).addRecoveryBit(recovery);
|
||||
const pub = sigObj.recoverPublicKey(digest).toRawBytes(false).slice(1);
|
||||
return addressFromPubKey64(pub);
|
||||
}
|
||||
|
||||
function line(s = "") {
|
||||
console.log(s);
|
||||
}
|
||||
|
||||
const N_MEMBERS = 3;
|
||||
const T = 2;
|
||||
|
||||
line("=== AERE threshold-ECDSA reference demo (Feldman VSS DKG + demonstrator signer) ===");
|
||||
line(`config: t-of-n = ${T}-of-${N_MEMBERS}`);
|
||||
line("");
|
||||
|
||||
// 1) DKG
|
||||
const dkg = runDkg({ n: N_MEMBERS, t: T });
|
||||
line("[1] DKG complete. Every dealt share verified against Feldman commitments.");
|
||||
line(` group pubkey (64B): ${hex(dkg.groupPubKey64)}`);
|
||||
line(` group address : ${dkg.groupAddress}`);
|
||||
line(` shares held by parties (private in real life):`);
|
||||
for (const sh of dkg.shares) line(` party ${sh.index}: share=0x${sh.share.toString(16)}`);
|
||||
// independent re-verification of each share against the AGGREGATE commitments
|
||||
const allVerify = dkg.shares.every((s) => verifyShareAgainstCommitments(s.index, s.share, dkg.verificationCommitments));
|
||||
line(` re-verify all shares vs aggregate commitments: ${allVerify ? "OK" : "FAIL"}`);
|
||||
line("");
|
||||
|
||||
// 2) Threshold sign with parties {1,2}
|
||||
const msg = "AERE B28 vault withdrawal: 12.5 AERE -> 0xVault, nonce 7";
|
||||
const digest = keccak_256(new TextEncoder().encode(msg));
|
||||
line(`[2] Threshold sign with parties {1,2} (t=${T}).`);
|
||||
line(` message: "${msg}"`);
|
||||
line(` digest : ${hex(digest)}`);
|
||||
const signers = [dkg.shares[0], dkg.shares[1]];
|
||||
const { signature } = combineByReconstruction(digest, signers, T);
|
||||
line(` signature (r||s||v): ${hex(signature)}`);
|
||||
const rec = recoverAddr(digest, signature);
|
||||
line(` ecrecover -> ${rec}`);
|
||||
line(` matches group address: ${rec.toLowerCase() === dkg.groupAddress.toLowerCase() ? "YES (verifies on-chain)" : "NO"}`);
|
||||
line("");
|
||||
|
||||
// 2b) A different valid quorum {1,3} must recover to the SAME group address.
|
||||
const signers2 = [dkg.shares[0], dkg.shares[2]];
|
||||
const sig2 = combineByReconstruction(digest, signers2, T).signature;
|
||||
const rec2 = recoverAddr(digest, sig2);
|
||||
line(`[2b] Different quorum {1,3} -> ${rec2} (same group key: ${rec2.toLowerCase() === dkg.groupAddress.toLowerCase() ? "YES" : "NO"})`);
|
||||
line("");
|
||||
|
||||
// 3) Below threshold: 1 share only -> WRONG key -> fails to match group address
|
||||
line(`[3] Below-threshold attempt with only party {1} (need t=${T}).`);
|
||||
const bad = combineByReconstruction(digest, [dkg.shares[0]], T);
|
||||
const badAddr = recoverAddr(digest, bad.signature);
|
||||
line(` ecrecover -> ${badAddr}`);
|
||||
line(` matches group address: ${badAddr.toLowerCase() === dkg.groupAddress.toLowerCase() ? "YES (BUG!)" : "NO (correctly rejected on-chain)"}`);
|
||||
line("");
|
||||
|
||||
// 4) honest production status
|
||||
const st = productionSignerStatus();
|
||||
line("[4] Production non-reconstructing signer (GG20/DKLs) implemented here: " + st.implementedHere);
|
||||
line(" reason: " + st.reason);
|
||||
for (const o of st.auditedOptions) line(` - ${o.name} (${o.language}): ${o.note}`);
|
||||
line("");
|
||||
line("=== demo done ===");
|
||||
148
src/mpc/dkg.ts
Normal file
148
src/mpc/dkg.ts
Normal file
@ -0,0 +1,148 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Feldman Verifiable Secret Sharing (VSS) Distributed Key Generation for t-of-n
|
||||
// threshold secp256k1. This is the GENUINE, non-custodial part of the stack:
|
||||
//
|
||||
// * Every party i acts as a dealer: it samples a random degree-(t-1) polynomial
|
||||
// f_i with constant term a_i0 (its secret contribution), publishes Feldman
|
||||
// commitments C_i,k = a_i,k * G, and privately sends f_i(j) to every party j.
|
||||
// * Each party j verifies each received share against the dealer's commitments:
|
||||
// f_i(j) * G == sum_k C_i,k * j^k
|
||||
// A cheating dealer is caught here (the check fails).
|
||||
// * The group secret is x = sum_i a_i0 (the sum of all constant terms). NO party
|
||||
// ever learns x: each party only sums the shares it received into x_j = sum_i f_i(j),
|
||||
// which is that party's Shamir share of x on a degree-(t-1) polynomial.
|
||||
// * The group public key is X = x*G = sum_i C_i,0, computable from PUBLIC commitments
|
||||
// alone, so everyone agrees on X without anyone holding x.
|
||||
//
|
||||
// This module SIMULATES all n parties in one process to produce a reproducible demo
|
||||
// and test vectors. In a real deployment each party runs its own dealer step and only
|
||||
// its own share leaves the process; the math is identical. The security boundary of
|
||||
// this reference lib is the SIGNING step (see sign.ts), not the DKG.
|
||||
|
||||
import { N, Point, type PointT, modN, invModN, randScalar, pointToPubKey64, pointToAddress } from "./secp.js";
|
||||
|
||||
export interface DkgConfig {
|
||||
n: number; // total members
|
||||
t: number; // threshold (>=t signers required to sign)
|
||||
}
|
||||
|
||||
export interface PartyShare {
|
||||
index: number; // participant index i, 1..n (evaluation point, never 0)
|
||||
share: bigint; // x_i = f(i), the party's Shamir share of the group secret
|
||||
}
|
||||
|
||||
export interface DkgResult {
|
||||
n: number;
|
||||
t: number;
|
||||
groupPoint: PointT; // X = x*G
|
||||
groupPubKey64: Uint8Array; // 64-byte x||y
|
||||
groupAddress: string; // ecrecover target used by AereThresholdRegistry
|
||||
verificationCommitments: PointT[]; // aggregate commitments C_k = sum_i a_i,k * G, k=0..t-1
|
||||
shares: PartyShare[]; // one Shamir share per party (kept private in real life)
|
||||
}
|
||||
|
||||
/** Evaluate polynomial (given coeffs a0..a_{t-1}) at x, mod N. */
|
||||
function evalPoly(coeffs: bigint[], x: bigint): bigint {
|
||||
let acc = 0n;
|
||||
let xp = 1n;
|
||||
for (const a of coeffs) {
|
||||
acc = modN(acc + a * xp);
|
||||
xp = modN(xp * x);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** Verify a Shamir share against Feldman commitments C_k: share*G == sum_k C_k * i^k. */
|
||||
export function verifyShareAgainstCommitments(index: number, share: bigint, commitments: PointT[]): boolean {
|
||||
const s = modN(share);
|
||||
const lhs = s === 0n ? Point.ZERO : Point.BASE.multiply(s); // share*G
|
||||
// rhs = sum_k C_k * i^k (i in 1..n, so i^k mod N is always in 1..N-1)
|
||||
let rhs: PointT = Point.ZERO;
|
||||
let ip = 1n;
|
||||
const i = BigInt(index);
|
||||
for (const Ck of commitments) {
|
||||
rhs = rhs.add(Ck.multiply(ip)); // ip is never 0 for our indices
|
||||
ip = modN(ip * i);
|
||||
}
|
||||
return lhs.equals(rhs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a full t-of-n Feldman VSS DKG simulating all n parties.
|
||||
* Returns the group key plus each party's private Shamir share, and verifies every
|
||||
* dealt share against its dealer's commitments (throws if any dealer cheated).
|
||||
*/
|
||||
export function runDkg(cfg: DkgConfig): DkgResult {
|
||||
const { n, t } = cfg;
|
||||
if (!(t >= 1 && t <= n && n >= 1 && n <= 255)) throw new Error("require 1<=t<=n<=255");
|
||||
|
||||
// Each party i (0..n-1) samples a degree-(t-1) polynomial: t coefficients.
|
||||
const polys: bigint[][] = [];
|
||||
const commitmentsPerParty: PointT[][] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const coeffs: bigint[] = [];
|
||||
for (let k = 0; k < t; k++) coeffs.push(randScalar());
|
||||
polys.push(coeffs);
|
||||
commitmentsPerParty.push(coeffs.map((a) => Point.BASE.multiply(a)));
|
||||
}
|
||||
|
||||
// Deal + verify: party j receives f_i(j) from every dealer i and checks it.
|
||||
for (let dealer = 0; dealer < n; dealer++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const sh = evalPoly(polys[dealer], BigInt(j));
|
||||
if (!verifyShareAgainstCommitments(j, sh, commitmentsPerParty[dealer])) {
|
||||
throw new Error(`Feldman VSS check failed: dealer ${dealer} share to party ${j}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate: party j's group share x_j = sum_i f_i(j).
|
||||
const shares: PartyShare[] = [];
|
||||
for (let j = 1; j <= n; j++) {
|
||||
let xj = 0n;
|
||||
for (let dealer = 0; dealer < n; dealer++) xj = modN(xj + evalPoly(polys[dealer], BigInt(j)));
|
||||
shares.push({ index: j, share: xj });
|
||||
}
|
||||
|
||||
// Aggregate commitments C_k = sum_i C_i,k. Group key X = C_0.
|
||||
const aggCommit: PointT[] = [];
|
||||
for (let k = 0; k < t; k++) {
|
||||
let acc: PointT | null = null;
|
||||
for (let i = 0; i < n; i++) acc = acc === null ? commitmentsPerParty[i][k] : acc.add(commitmentsPerParty[i][k]);
|
||||
aggCommit.push(acc!);
|
||||
}
|
||||
const groupPoint = aggCommit[0];
|
||||
|
||||
// Sanity: every aggregate share must verify against the AGGREGATE commitments too.
|
||||
for (const s of shares) {
|
||||
if (!verifyShareAgainstCommitments(s.index, s.share, aggCommit)) {
|
||||
throw new Error(`aggregate share ${s.index} fails aggregate commitments`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
n,
|
||||
t,
|
||||
groupPoint,
|
||||
groupPubKey64: pointToPubKey64(groupPoint),
|
||||
groupAddress: pointToAddress(groupPoint),
|
||||
verificationCommitments: aggCommit,
|
||||
shares,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lagrange coefficient l_i(0) for participant set `indices`, evaluated at x=0, mod N. */
|
||||
export function lagrangeAtZero(indices: number[], i: number): bigint {
|
||||
let num = 1n;
|
||||
let den = 1n;
|
||||
const xi = BigInt(i);
|
||||
for (const jNum of indices) {
|
||||
if (jNum === i) continue;
|
||||
const xj = BigInt(jNum);
|
||||
num = modN(num * xj);
|
||||
den = modN(den * modN(xj - xi));
|
||||
}
|
||||
// num/den mod N
|
||||
return modN(num * invModN(den));
|
||||
}
|
||||
15
src/mpc/index.ts
Normal file
15
src/mpc/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// AERE MPC/TSS reference: t-of-n threshold ECDSA (Feldman VSS DKG + demonstrator signer)
|
||||
// producing signatures verifiable by contracts/mpc/AereThresholdRegistry.sol.
|
||||
//
|
||||
// Honest scope, in one line: the DKG is genuinely non-custodial; the SIGNER in this
|
||||
// reference reconstructs the secret at a combiner (demonstrator/test-vector generator).
|
||||
// Production non-reconstructing signing (GG20/DKLs) must come from an audited library.
|
||||
// Threshold PQC (ML-DSA/Falcon) is NOT here and is research-stage. See docs/THRESHOLD_PQC.md.
|
||||
|
||||
export * from "./secp.js";
|
||||
export * from "./dkg.js";
|
||||
export * from "./sign.js";
|
||||
// Non-custodial post-quantum t-of-n ERC-4337 account orchestration (contracts/mpc/AereThresholdAccount.sol).
|
||||
export * from "./AereThresholdAccountClient.js";
|
||||
107
src/mpc/secp.ts
Normal file
107
src/mpc/secp.ts
Normal file
@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Low-level secp256k1 helpers shared by the threshold-ECDSA DKG and signer.
|
||||
// Thin, dependency-light wrapper over @noble/curves (audited, maintained) so the
|
||||
// threshold logic reads clearly. Nothing here is AERE-specific; it is standard
|
||||
// secp256k1 + Ethereum address derivation.
|
||||
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { mod, invert } from "@noble/curves/abstract/modular";
|
||||
import { keccak_256 } from "@noble/hashes/sha3";
|
||||
|
||||
export const N: bigint = secp256k1.CURVE.n; // group order
|
||||
export const N_HALF: bigint = N >> 1n;
|
||||
export const Point = secp256k1.ProjectivePoint;
|
||||
export type PointT = InstanceType<typeof Point>;
|
||||
|
||||
/** A uniformly random non-zero scalar in [1, N-1]. */
|
||||
export function randScalar(): bigint {
|
||||
// randomPrivateKey() already returns a valid, in-range secp256k1 scalar.
|
||||
return bytesToBigInt(secp256k1.utils.randomPrivateKey());
|
||||
}
|
||||
|
||||
export function modN(x: bigint): bigint {
|
||||
return mod(x, N);
|
||||
}
|
||||
|
||||
export function invModN(x: bigint): bigint {
|
||||
return invert(mod(x, N), N);
|
||||
}
|
||||
|
||||
export function bytesToBigInt(b: Uint8Array): bigint {
|
||||
let x = 0n;
|
||||
for (const byte of b) x = (x << 8n) | BigInt(byte);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function bigIntTo32(x: bigint): Uint8Array {
|
||||
const out = new Uint8Array(32);
|
||||
let v = mod(x, 1n << 256n);
|
||||
for (let i = 31; i >= 0; i--) {
|
||||
out[i] = Number(v & 0xffn);
|
||||
v >>= 8n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hex(b: Uint8Array): string {
|
||||
return "0x" + Buffer.from(b).toString("hex");
|
||||
}
|
||||
|
||||
export function fromHex(s: string): Uint8Array {
|
||||
return Uint8Array.from(Buffer.from(s.replace(/^0x/, ""), "hex"));
|
||||
}
|
||||
|
||||
/** 64-byte uncompressed public key (x||y, no 0x04 prefix) for a point. */
|
||||
export function pointToPubKey64(P: PointT): Uint8Array {
|
||||
return P.toRawBytes(false).slice(1); // drop 0x04
|
||||
}
|
||||
|
||||
/** Ethereum address (0x + 40 hex) from a point's group public key. */
|
||||
export function pointToAddress(P: PointT): string {
|
||||
const h = keccak_256(pointToPubKey64(P));
|
||||
return "0x" + Buffer.from(h.slice(12)).toString("hex");
|
||||
}
|
||||
|
||||
export function addressFromPubKey64(pub64: Uint8Array): string {
|
||||
const h = keccak_256(pub64);
|
||||
return "0x" + Buffer.from(h.slice(12)).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic-free ECDSA sign of a 32-byte digest by a scalar private key,
|
||||
* returned as an Ethereum (r,s,v) 65-byte signature with s in the LOWER half and
|
||||
* v in {27,28}. This is an ordinary ECDSA signature; a threshold signature has the
|
||||
* exact same shape (see the threshold signer for how the same (r,s,v) is produced
|
||||
* collaboratively). `k` may be supplied to make signing deterministic in tests.
|
||||
*/
|
||||
export function ecdsaSignDigest(digest: Uint8Array, priv: bigint, k?: bigint): Uint8Array {
|
||||
const z = mod(bytesToBigInt(digest), N);
|
||||
for (let attempt = 0; attempt < 64; attempt++) {
|
||||
const kk = k ?? randScalar();
|
||||
const R = Point.BASE.multiply(kk);
|
||||
const Raff = R.toAffine();
|
||||
const r = mod(Raff.x, N);
|
||||
if (r === 0n) {
|
||||
if (k) throw new Error("bad supplied k (r=0)");
|
||||
continue;
|
||||
}
|
||||
let s = modN(invModN(kk) * modN(z + r * modN(priv)));
|
||||
if (s === 0n) {
|
||||
if (k) throw new Error("bad supplied k (s=0)");
|
||||
continue;
|
||||
}
|
||||
// recovery id parity from R.y; flip when we normalize s to low-half.
|
||||
let recovery = (Number(Raff.y & 1n) & 1) | (r !== Raff.x ? 2 : 0);
|
||||
if (s > N_HALF) {
|
||||
s = N - s;
|
||||
recovery ^= 1;
|
||||
}
|
||||
const sig = new Uint8Array(65);
|
||||
sig.set(bigIntTo32(r), 0);
|
||||
sig.set(bigIntTo32(s), 32);
|
||||
sig[64] = 27 + (recovery & 1);
|
||||
return sig;
|
||||
}
|
||||
throw new Error("ecdsa sign failed to find r,s");
|
||||
}
|
||||
95
src/mpc/sign.ts
Normal file
95
src/mpc/sign.ts
Normal file
@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Threshold-ECDSA SIGNING for the AERE reference stack.
|
||||
//
|
||||
// =========================== READ THIS, IT IS THE HONEST BOUNDARY ===========================
|
||||
// The DKG (dkg.ts) is genuinely non-custodial: no party ever holds the group secret x.
|
||||
// SIGNING standard ECDSA from Shamir shares WITHOUT reconstructing x is the hard part of
|
||||
// threshold ECDSA, because s = k^{-1}(z + r*x) requires multiplying two independently shared
|
||||
// secrets (k and x). Doing that securely needs a Multiplicative-to-Additive (MtA) sub-protocol
|
||||
// with Paillier or OT plus range/consistency ZK proofs (GG18/GG20/CGGMP21) or an OT-based
|
||||
// approach (DKLs18/DKLs23). Those are ~thousands of lines of audited protocol and are NOT
|
||||
// reimplemented here.
|
||||
//
|
||||
// This reference offers TWO signing functions with DIFFERENT, clearly-stated trust models:
|
||||
//
|
||||
// 1. combineByReconstruction(...) -- DEMONSTRATOR / TEST-VECTOR GENERATOR.
|
||||
// A designated combiner collects t shares, Lagrange-interpolates the group secret x at
|
||||
// the combiner, and signs a normal ECDSA signature. This TEMPORARILY reconstructs x at
|
||||
// ONE machine. It is perfect for: proving the DKG key is correct, generating signatures
|
||||
// the on-chain AereThresholdRegistry verifies, and demonstrating the t-of-n threshold
|
||||
// (t-1 shares interpolate to the WRONG x and the signature fails). It is NOT the
|
||||
// production non-custodial signing path -- do not run it in production custody.
|
||||
//
|
||||
// 2. productionSignerStatus() -- returns the honest "not implemented here" status and the
|
||||
// pointer to audited production libraries (see docs/THRESHOLD_PQC.md and the MPC notes).
|
||||
//
|
||||
// On-chain, the output of (1) is byte-for-byte identical to a real GG20 threshold signature:
|
||||
// both are (r,s,v) recovering to the group address. The registry cannot tell them apart, which
|
||||
// is exactly why the "t participated" guarantee must come from the off-chain protocol, not the
|
||||
// chain (documented on AereThresholdRegistry).
|
||||
// ============================================================================================
|
||||
|
||||
import { N, N_HALF, Point, modN, invModN, bigIntTo32, bytesToBigInt, ecdsaSignDigest } from "./secp.js";
|
||||
import { type PartyShare, lagrangeAtZero } from "./dkg.js";
|
||||
|
||||
/**
|
||||
* DEMONSTRATOR signer. Reconstruct the group secret from t shares (Lagrange at 0) and
|
||||
* produce a standard Ethereum ECDSA signature (r,s,v), low-s, over `digest`.
|
||||
*
|
||||
* @param digest 32-byte message digest (already domain-separated by the caller).
|
||||
* @param shares Exactly-or-more-than t PartyShare objects from the same DKG.
|
||||
* @param t The committee threshold.
|
||||
* @param k Optional fixed nonce for deterministic tests.
|
||||
* @returns { signature (65 bytes), reconstructedPriv } -- reconstructedPriv is exposed ONLY
|
||||
* because this is a demonstrator; production signing never yields it.
|
||||
*/
|
||||
export function combineByReconstruction(
|
||||
digest: Uint8Array,
|
||||
shares: PartyShare[],
|
||||
t: number,
|
||||
k?: bigint,
|
||||
): { signature: Uint8Array; reconstructedPriv: bigint } {
|
||||
// Interpolate over EXACTLY the shares provided. With >= t shares from the same DKG this
|
||||
// recovers the true group secret; with < t shares it recovers a DIFFERENT value (the
|
||||
// "below-threshold" negative case), whose signature will not recover to the group address.
|
||||
void t;
|
||||
const x = reconstructSecret(shares);
|
||||
const signature = ecdsaSignDigest(digest, x, k);
|
||||
return { signature, reconstructedPriv: x };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the group secret from a chosen subset (any size). Used by tests to show that
|
||||
* an UNDER-threshold subset interpolates to a different secret than the true t-of-n key.
|
||||
*/
|
||||
export function reconstructSecret(shares: PartyShare[]): bigint {
|
||||
const indices = shares.map((s) => s.index);
|
||||
let x = 0n;
|
||||
for (const s of shares) {
|
||||
x = modN(x + modN(s.share * lagrangeAtZero(indices, s.index)));
|
||||
}
|
||||
return x;
|
||||
}
|
||||
|
||||
export interface ProductionSignerStatus {
|
||||
implementedHere: false;
|
||||
reason: string;
|
||||
auditedOptions: { name: string; language: string; note: string }[];
|
||||
}
|
||||
|
||||
export function productionSignerStatus(): ProductionSignerStatus {
|
||||
return {
|
||||
implementedHere: false,
|
||||
reason:
|
||||
"Non-reconstructing threshold-ECDSA signing (GG20/CGGMP21 MtA or DKLs23 OT) is a large, " +
|
||||
"security-critical protocol and is intentionally not reimplemented in this reference. " +
|
||||
"Use an audited library and have this contract verify its (r,s,v) output.",
|
||||
auditedOptions: [
|
||||
{ name: "bnb-chain/tss-lib", language: "Go", note: "GG18/GG20 ECDSA + EdDSA, widely deployed." },
|
||||
{ name: "ZenGo-X/multi-party-ecdsa", language: "Rust", note: "GG18/GG20; also DKLs in silence-labs forks." },
|
||||
{ name: "silence-laboratories/dkls23", language: "Rust", note: "DKLs23 OT-based threshold ECDSA (2-round sign)." },
|
||||
{ name: "taurusgroup/multi-party-sig", language: "Go", note: "CMP/CGGMP21 with identifiable abort." },
|
||||
],
|
||||
};
|
||||
}
|
||||
327
src/pqc/AereAgentActionReceiptClient.ts
Normal file
327
src/pqc/AereAgentActionReceiptClient.ts
Normal file
@ -0,0 +1,327 @@
|
||||
// AereAgentActionReceiptClient — ethers v6 wrapper over AereAgentActionReceipt
|
||||
// (chain 2800): PQC-anchored, publicly verifiable AI provenance.
|
||||
//
|
||||
// An AI agent commits the HASH of an output it produced, signed by a short-lived
|
||||
// secp256k1 SESSION key that AereAgentDID issued under a quantum-durable Falcon
|
||||
// ROOT (Falcon PoP verified on-chain by AERE's live native precompile). The result
|
||||
// is an append-only provenance record anyone can re-verify in ONE read, cross-wired
|
||||
// to the agent's AereAgentBond stake + AereAIReputation score / slashing history.
|
||||
//
|
||||
// - emitReceipt(...): permissionless relay — the SESSION SIGNATURE is the authority.
|
||||
// The session key signs the receipt digest; msg.sender only pays gas.
|
||||
// - verifyReceipt / verifyReceiptOutput: re-check a stored receipt cryptographically.
|
||||
// - provenanceOf: identity + validity + live economic accountability in one call.
|
||||
//
|
||||
// Receipt digest (read straight from the contract; domain- and chain-bound):
|
||||
// receipt digest = keccak256(abi.encode(
|
||||
// RECEIPT_DOMAIN, chainid, address(this), agentId, sessionId, outputHash, scope, nonce))
|
||||
// RECEIPT_DOMAIN = keccak256("AereAgentActionReceipt.v1.receipt")
|
||||
//
|
||||
// The session-key signature is a raw secp256k1 signature over the receipt digest
|
||||
// (ethers SigningKey), matching the contract's ecrecover on the raw digest — NOT an
|
||||
// eth_sign personal-message prefix. This is the SAME session key AereAgentDID's
|
||||
// authorize() uses, so an agent signs both surfaces with one key.
|
||||
|
||||
import {
|
||||
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify, zeroPadValue, toBeHex,
|
||||
SigningKey, Signature,
|
||||
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from '../addresses.js';
|
||||
|
||||
/** Minimal inline ABI — only the AereAgentActionReceipt surface this client uses. */
|
||||
export const AERE_AGENT_ACTION_RECEIPT_ABI = [
|
||||
// write
|
||||
'function emitReceipt(uint256 sessionId, bytes32 scope, bytes32 outputHash, bytes sig) returns (uint256 receiptId)',
|
||||
// digest / cross-wire views
|
||||
'function receiptDigest(uint256 agentId, uint256 sessionId, bytes32 outputHash, bytes32 scope, uint64 nonce) view returns (bytes32)',
|
||||
'function receiptNonceOf(uint256 sessionId) view returns (uint64)',
|
||||
'function bondKeyOf(uint256 rootKeyId) pure returns (bytes32)',
|
||||
'function operatorOf(uint256 rootKeyId) view returns (address)',
|
||||
// verification views
|
||||
'function verifyReceipt(uint256 receiptId) view returns (bool sigValid, bool sessionLiveNow, uint256 agentId, address signer, bytes32 outputHash)',
|
||||
'function verifyReceiptOutput(uint256 receiptId, bytes32 outputHash) view returns (bool)',
|
||||
'function provenanceOf(uint256 receiptId) view returns (uint256 agentId, address operator, address signer, bytes32 outputHash, bytes32 scope, uint64 emittedAt, bool sigValid, bool sessionLiveNow, uint256 reputationScore, uint256 bondAmount, uint256 bondLifetimeSlashed, uint256 operatorTotalSlashed)',
|
||||
// record views
|
||||
'function receiptCount() view returns (uint256)',
|
||||
'function getReceipt(uint256 receiptId) view returns (uint256 agentId, uint256 sessionId, address signer, bytes32 outputHash, bytes32 scope, uint64 nonce, uint64 emittedBlock, uint64 emittedAt, bytes sig)',
|
||||
'function receiptsOf(uint256 rootKeyId) view returns (uint256[])',
|
||||
'function receiptsOfSession(uint256 sessionId) view returns (uint256[])',
|
||||
// wiring views
|
||||
'function did() view returns (address)',
|
||||
'function agentBond() view returns (address)',
|
||||
'function aiReputation() view returns (address)',
|
||||
// event
|
||||
'event ReceiptEmitted(uint256 indexed receiptId, uint256 indexed agentId, uint256 indexed sessionId, address signer, bytes32 outputHash, bytes32 scope, uint64 nonce)',
|
||||
] as const;
|
||||
|
||||
/** keccak256("AereAgentActionReceipt.v1.receipt"). */
|
||||
export const RECEIPT_DOMAIN = keccak256(toUtf8Bytes('AereAgentActionReceipt.v1.receipt'));
|
||||
|
||||
export interface AereAgentActionReceiptClientOptions {
|
||||
/** AereAgentActionReceipt address (required — not yet in the mainnet address book). */
|
||||
address: string;
|
||||
/** Chain id used in local digest derivation. Default: 2800. */
|
||||
chainId?: number;
|
||||
}
|
||||
|
||||
export interface ReceiptRecord {
|
||||
agentId: bigint;
|
||||
sessionId: bigint;
|
||||
signer: string;
|
||||
outputHash: string;
|
||||
scope: string;
|
||||
nonce: bigint;
|
||||
emittedBlock: bigint;
|
||||
emittedAt: bigint;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
export interface ReceiptVerification {
|
||||
sigValid: boolean;
|
||||
sessionLiveNow: boolean;
|
||||
agentId: bigint;
|
||||
signer: string;
|
||||
outputHash: string;
|
||||
}
|
||||
|
||||
export interface ReceiptProvenance {
|
||||
agentId: bigint;
|
||||
operator: string;
|
||||
signer: string;
|
||||
outputHash: string;
|
||||
scope: string;
|
||||
emittedAt: bigint;
|
||||
sigValid: boolean;
|
||||
sessionLiveNow: boolean;
|
||||
reputationScore: bigint;
|
||||
bondAmount: bigint;
|
||||
bondLifetimeSlashed: bigint;
|
||||
operatorTotalSlashed: bigint;
|
||||
}
|
||||
|
||||
export interface ReceiptSignature {
|
||||
/** The 32-byte receipt digest the session key signed. */
|
||||
digest: string;
|
||||
/** The per-session receipt nonce the digest was derived at. */
|
||||
nonce: bigint;
|
||||
/** 65-byte secp256k1 signature (r||s||v) by the session key (pass as `sig` to emitReceipt). */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface EmitReceiptResult extends ReceiptSignature {
|
||||
tx: ContractTransactionResponse;
|
||||
}
|
||||
|
||||
function toBytes(v: BytesLike): Uint8Array {
|
||||
return v instanceof Uint8Array ? v : getBytes(v);
|
||||
}
|
||||
|
||||
function bytes32(v: BytesLike): string {
|
||||
const b = toBytes(v);
|
||||
if (b.length !== 32) throw new Error(`AereAgentActionReceiptClient: expected 32-byte value, got ${b.length}`);
|
||||
return hexlify(b);
|
||||
}
|
||||
|
||||
export class AereAgentActionReceiptClient {
|
||||
readonly address: string;
|
||||
readonly chainId: number;
|
||||
readonly contract: Contract;
|
||||
|
||||
constructor(runner: ContractRunner, opts: AereAgentActionReceiptClientOptions) {
|
||||
if (!opts?.address) {
|
||||
throw new Error('AereAgentActionReceiptClient: an explicit `address` is required (contract is not in the mainnet address book).');
|
||||
}
|
||||
this.address = opts.address;
|
||||
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||
this.contract = new Contract(this.address, AERE_AGENT_ACTION_RECEIPT_ABI, runner);
|
||||
}
|
||||
|
||||
// ---- digest derivation ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Local mirror of AereAgentActionReceipt._receiptDigest — what a session key signs:
|
||||
* keccak256(abi.encode(RECEIPT_DOMAIN, chainid, address(this), agentId, sessionId,
|
||||
* outputHash, scope, nonce))
|
||||
* Cross-check with {@link receiptDigest} on-chain.
|
||||
*/
|
||||
deriveReceiptDigest(
|
||||
agentId: bigint | number,
|
||||
sessionId: bigint | number,
|
||||
outputHash: BytesLike,
|
||||
scope: BytesLike,
|
||||
nonce: bigint | number,
|
||||
): string {
|
||||
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'uint256', 'uint256', 'bytes32', 'bytes32', 'uint64'],
|
||||
[RECEIPT_DOMAIN, this.chainId, this.address, agentId, sessionId, bytes32(outputHash), bytes32(scope), nonce],
|
||||
);
|
||||
return keccak256(enc);
|
||||
}
|
||||
|
||||
/** The canonical AereAgentBond / AereAIReputation agentId for a DID rootKeyId
|
||||
* (bytes32(rootKeyId)). Pure mirror of the contract's {bondKeyOf} — no RPC. */
|
||||
static bondKeyOf(rootKeyId: bigint | number): string {
|
||||
return zeroPadValue(toBeHex(BigInt(rootKeyId)), 32);
|
||||
}
|
||||
|
||||
/** On-chain receipt digest for (agentId, sessionId, outputHash, scope, nonce). */
|
||||
async receiptDigest(
|
||||
agentId: bigint | number,
|
||||
sessionId: bigint | number,
|
||||
outputHash: BytesLike,
|
||||
scope: BytesLike,
|
||||
nonce: bigint | number,
|
||||
): Promise<string> {
|
||||
return this.contract.receiptDigest(agentId, sessionId, bytes32(outputHash), bytes32(scope), nonce);
|
||||
}
|
||||
|
||||
/** The next receipt nonce for a session (the value the next emit will sign). */
|
||||
async receiptNonceOf(sessionId: bigint | number): Promise<bigint> {
|
||||
return this.contract.receiptNonceOf(sessionId);
|
||||
}
|
||||
|
||||
/** The canonical bond/reputation agentId for a DID rootKeyId (on-chain view). */
|
||||
async bondKeyOf(rootKeyId: bigint | number): Promise<string> {
|
||||
return this.contract.bondKeyOf(rootKeyId);
|
||||
}
|
||||
|
||||
/** The operator (bond/reputation key) this contract reads for an agent (DID controller). */
|
||||
async operatorOf(rootKeyId: bigint | number): Promise<string> {
|
||||
return this.contract.operatorOf(rootKeyId);
|
||||
}
|
||||
|
||||
// ---- construction path (no tx) --------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the session-key signature for a receipt at an explicit nonce, WITHOUT
|
||||
* sending a transaction. Derives the receipt digest locally and signs it with the
|
||||
* raw secp256k1 session private key (SigningKey over the raw digest, matching the
|
||||
* contract's ecrecover). The serialized signature is canonical low-s, v in {27,28}.
|
||||
*/
|
||||
buildReceiptSignature(
|
||||
agentId: bigint | number,
|
||||
sessionId: bigint | number,
|
||||
outputHash: BytesLike,
|
||||
scope: BytesLike,
|
||||
nonce: bigint | number,
|
||||
sessionPrivateKey: BytesLike,
|
||||
): ReceiptSignature {
|
||||
const digest = this.deriveReceiptDigest(agentId, sessionId, outputHash, scope, nonce);
|
||||
const sk = new SigningKey(hexlify(toBytes(sessionPrivateKey)));
|
||||
const sig: Signature = sk.sign(digest);
|
||||
return { digest, nonce: BigInt(nonce), signature: sig.serialized };
|
||||
}
|
||||
|
||||
// ---- verification / record views ------------------------------------------
|
||||
|
||||
/** Total receipts ever recorded (receiptIds run 0..receiptCount-1). */
|
||||
async receiptCount(): Promise<bigint> {
|
||||
return this.contract.receiptCount();
|
||||
}
|
||||
|
||||
/** Read a receipt. Reverts (UnknownReceipt) on an out-of-range id. */
|
||||
async getReceipt(receiptId: bigint | number): Promise<ReceiptRecord> {
|
||||
const [agentId, sessionId, signer, outputHash, scope, nonce, emittedBlock, emittedAt, sig] =
|
||||
await this.contract.getReceipt(receiptId);
|
||||
return { agentId, sessionId, signer, outputHash, scope, nonce, emittedBlock, emittedAt, sig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Independently re-verify a stored receipt in one read: re-derives the digest from
|
||||
* the stored fields, recovers the signer from the stored signature, and reports
|
||||
* whether it matches the recorded session key (sigValid) and whether that session is
|
||||
* still live now (sessionLiveNow). A receipt stays genuine forever; sessionLiveNow
|
||||
* tells you if the agent has since been revoked / expired / rotated away.
|
||||
*/
|
||||
async verifyReceipt(receiptId: bigint | number): Promise<ReceiptVerification> {
|
||||
const [sigValid, sessionLiveNow, agentId, signer, outputHash] = await this.contract.verifyReceipt(receiptId);
|
||||
return { sigValid, sessionLiveNow, agentId, signer, outputHash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a receipt attests EXACTLY `outputHash` and its stored signature is genuine.
|
||||
* The tamper check at read time: pass the hash of the output you hold; a byte-different
|
||||
* output yields a different hash and returns false. Returns false (no revert) for an
|
||||
* out-of-range receiptId.
|
||||
*/
|
||||
async verifyReceiptOutput(receiptId: bigint | number, outputHash: BytesLike): Promise<boolean> {
|
||||
return this.contract.verifyReceiptOutput(receiptId, bytes32(outputHash));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full provenance in one call: identity + validity + the agent's live economic
|
||||
* accountability (AereAgentBond stake, AereAIReputation score, slashing history) read
|
||||
* at the canonical cross-wire key. Reverts (UnknownReceipt) on an out-of-range id.
|
||||
*/
|
||||
async provenanceOf(receiptId: bigint | number): Promise<ReceiptProvenance> {
|
||||
const [
|
||||
agentId, operator, signer, outputHash, scope, emittedAt, sigValid, sessionLiveNow,
|
||||
reputationScore, bondAmount, bondLifetimeSlashed, operatorTotalSlashed,
|
||||
] = await this.contract.provenanceOf(receiptId);
|
||||
return {
|
||||
agentId, operator, signer, outputHash, scope, emittedAt, sigValid, sessionLiveNow,
|
||||
reputationScore, bondAmount, bondLifetimeSlashed, operatorTotalSlashed,
|
||||
};
|
||||
}
|
||||
|
||||
/** All receiptIds authored by an agent (append-only, chronological). */
|
||||
async receiptsOf(rootKeyId: bigint | number): Promise<bigint[]> {
|
||||
return this.contract.receiptsOf(rootKeyId);
|
||||
}
|
||||
|
||||
/** All receiptIds recorded under a session (append-only, chronological). */
|
||||
async receiptsOfSession(sessionId: bigint | number): Promise<bigint[]> {
|
||||
return this.contract.receiptsOfSession(sessionId);
|
||||
}
|
||||
|
||||
/** The AereAgentDID this receipt contract roots session verification into. */
|
||||
async did(): Promise<string> {
|
||||
return this.contract.did();
|
||||
}
|
||||
|
||||
// ---- writes (require a Signer runner) --------------------------------------
|
||||
|
||||
/** Emit a receipt with a pre-built session-key signature. */
|
||||
async emitReceipt(
|
||||
sessionId: bigint | number,
|
||||
scope: BytesLike,
|
||||
outputHash: BytesLike,
|
||||
sig: BytesLike,
|
||||
): Promise<ContractTransactionResponse> {
|
||||
return this.contract.emitReceipt(sessionId, bytes32(scope), bytes32(outputHash), hexlify(toBytes(sig)));
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level: read the session's current receipt nonce on-chain, derive the receipt
|
||||
* digest locally (cross-checked against the on-chain view), sign it with the secp256k1
|
||||
* session key, and submit emitReceipt via `relaySigner`. `agentId` is the session's
|
||||
* Falcon rootKeyId (read it from AereAgentDID.getSession). The session key signs the
|
||||
* raw digest; the relayer only pays gas. `sessionPrivateKey` never leaves this process.
|
||||
*/
|
||||
async emitReceiptWithSessionKey(
|
||||
relaySigner: Signer,
|
||||
agentId: bigint | number,
|
||||
sessionId: bigint | number,
|
||||
scope: BytesLike,
|
||||
outputHash: BytesLike,
|
||||
sessionPrivateKey: BytesLike,
|
||||
verifyDigestOnChain = true,
|
||||
): Promise<EmitReceiptResult> {
|
||||
const nonce = await this.receiptNonceOf(sessionId);
|
||||
const built = this.buildReceiptSignature(agentId, sessionId, outputHash, scope, nonce, sessionPrivateKey);
|
||||
|
||||
if (verifyDigestOnChain) {
|
||||
const onChain = await this.receiptDigest(agentId, sessionId, outputHash, scope, nonce);
|
||||
if (onChain.toLowerCase() !== built.digest.toLowerCase()) {
|
||||
throw new Error(`AereAgentActionReceiptClient: local receipt digest ${built.digest} != on-chain ${onChain}`);
|
||||
}
|
||||
}
|
||||
|
||||
const c = this.contract.connect(relaySigner) as Contract;
|
||||
const tx: ContractTransactionResponse = await c.emitReceipt(
|
||||
sessionId, bytes32(scope), bytes32(outputHash), built.signature,
|
||||
);
|
||||
return { tx, digest: built.digest, nonce, signature: built.signature };
|
||||
}
|
||||
}
|
||||
365
src/pqc/AereAgentDIDClient.ts
Normal file
365
src/pqc/AereAgentDIDClient.ts
Normal file
@ -0,0 +1,365 @@
|
||||
// AereAgentDIDClient — ethers v6 wrapper over AereAgentDID (chain 2800).
|
||||
//
|
||||
// AereAgentDID (0xce641d7d7C10553D82b06B7C21d423550e7522C5) is a decentralized
|
||||
// identity for AI agents whose ROOT authority is a quantum-durable Falcon key held
|
||||
// in AerePQCKeyRegistry, and whose day-to-day work is signed by cheap, short-lived,
|
||||
// revocable secp256k1 SESSION keys.
|
||||
//
|
||||
// - createAgent(rootKeyId): open a DID for an ACTIVE Falcon root key (registry owner only).
|
||||
// - issueSession(...): Falcon-PoP-gated — a Falcon signature by the root key over the
|
||||
// session issuance challenge authorizes a new secp256k1 session (scope + spend cap + expiry).
|
||||
// - authorize(...): the hot path — a secp256k1 signature by the session key over the
|
||||
// action digest, enforced by ecrecover (~3k gas).
|
||||
//
|
||||
// Two challenge preimages (read straight from the contract), both domain- and
|
||||
// chain-bound:
|
||||
// session challenge = keccak256(abi.encode(
|
||||
// SESSION_DOMAIN, chainid, address(this), rootKeyId, nonce, sessionAddr,
|
||||
// scopeHash, spendCap, expiry))
|
||||
// action digest = keccak256(abi.encode(
|
||||
// ACTION_DOMAIN, chainid, address(this), sessionId, scope, actionHash, amount, actionNonce))
|
||||
// SESSION_DOMAIN = keccak256("AereAgentDID.v1.issueSession")
|
||||
// ACTION_DOMAIN = keccak256("AereAgentDID.v1.action")
|
||||
//
|
||||
// The Falcon proof-of-possession for issueSession is built with the src/pqc signer
|
||||
// (schemes.signInternal + envelope.ts) — REUSED, never reimplemented. The session-key
|
||||
// action signature is a raw secp256k1 signature over the action digest (ethers SigningKey).
|
||||
|
||||
import {
|
||||
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify, SigningKey, Signature,
|
||||
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from '../addresses.js';
|
||||
import { SCHEME, type SchemeId, signInternal } from './schemes.js';
|
||||
|
||||
/** Minimal inline ABI — only the AereAgentDID surface this client uses. */
|
||||
export const AERE_AGENT_DID_ABI = [
|
||||
// writes
|
||||
'function createAgent(uint256 rootKeyId)',
|
||||
'function issueSession(uint256 rootKeyId, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry, bytes popSig) returns (uint256 sessionId)',
|
||||
'function revokeSession(uint256 sessionId)',
|
||||
'function authorize(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, bytes sig) returns (bool)',
|
||||
// challenge / digest views
|
||||
'function sessionChallenge(uint256 rootKeyId, uint64 nonce, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry) view returns (bytes32)',
|
||||
'function currentSessionChallenge(uint256 rootKeyId, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry) view returns (bytes32)',
|
||||
'function actionDigest(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, uint64 actionNonce) view returns (bytes32)',
|
||||
// agent / session views
|
||||
'function getAgent(uint256 rootKeyId) view returns (address controller, uint64 createdBlock, uint64 sessionNonce, uint256 sessionCountForAgent)',
|
||||
'function agentExists(uint256 rootKeyId) view returns (bool)',
|
||||
'function sessionCount() view returns (uint256)',
|
||||
'function getSession(uint256 sessionId) view returns (uint256 rootKeyId, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint256 spent, uint64 issuedBlock, uint64 expiry, uint64 actionNonce, bool revoked)',
|
||||
'function isSessionValid(uint256 sessionId) view returns (bool)',
|
||||
'function remainingSpend(uint256 sessionId) view returns (uint256)',
|
||||
'function sessionsOf(uint256 rootKeyId) view returns (uint256[])',
|
||||
// wiring views
|
||||
'function keyRegistry() view returns (address)',
|
||||
'function agentBond() view returns (address)',
|
||||
'function aiReputation() view returns (address)',
|
||||
// events
|
||||
'event AgentCreated(uint256 indexed rootKeyId, address indexed controller, uint8 rootScheme)',
|
||||
'event SessionIssued(uint256 indexed sessionId, uint256 indexed rootKeyId, address indexed sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry)',
|
||||
'event SessionRevoked(uint256 indexed sessionId, uint256 indexed rootKeyId, address by)',
|
||||
'event ActionAuthorized(uint256 indexed sessionId, uint256 indexed rootKeyId, bytes32 indexed actionHash, uint256 amount, uint256 spent)',
|
||||
] as const;
|
||||
|
||||
/** keccak256("AereAgentDID.v1.issueSession"). */
|
||||
export const SESSION_DOMAIN = keccak256(toUtf8Bytes('AereAgentDID.v1.issueSession'));
|
||||
/** keccak256("AereAgentDID.v1.action"). */
|
||||
export const ACTION_DOMAIN = keccak256(toUtf8Bytes('AereAgentDID.v1.action'));
|
||||
|
||||
export interface AereAgentDIDClientOptions {
|
||||
/** AereAgentDID address. Default: AERE_MAINNET mainnet deployment. */
|
||||
address?: string;
|
||||
/** Chain id used in local challenge/digest derivation. Default: 2800. */
|
||||
chainId?: number;
|
||||
}
|
||||
|
||||
export interface AgentRecord {
|
||||
controller: string;
|
||||
createdBlock: bigint;
|
||||
sessionNonce: bigint;
|
||||
sessionCount: bigint;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
rootKeyId: bigint;
|
||||
sessionAddr: string;
|
||||
scopeHash: string;
|
||||
spendCap: bigint;
|
||||
spent: bigint;
|
||||
issuedBlock: bigint;
|
||||
expiry: bigint;
|
||||
actionNonce: bigint;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
export interface SessionParams {
|
||||
rootKeyId: bigint | number;
|
||||
sessionAddr: string;
|
||||
scopeHash: BytesLike;
|
||||
spendCap: bigint | number;
|
||||
expiry: bigint | number;
|
||||
}
|
||||
|
||||
export interface IssueSessionPoP {
|
||||
/** The 32-byte Falcon issuance challenge that was signed. */
|
||||
challenge: string;
|
||||
/** The session nonce used to derive the challenge. */
|
||||
nonce: bigint;
|
||||
/** The Falcon proof-of-possession envelope (pass as `popSig` to issueSession). */
|
||||
popSig: Uint8Array;
|
||||
}
|
||||
|
||||
export interface IssueSessionResult extends IssueSessionPoP {
|
||||
tx: ContractTransactionResponse;
|
||||
}
|
||||
|
||||
export interface ActionAuthorization {
|
||||
/** The 32-byte action digest the session key signed. */
|
||||
digest: string;
|
||||
/** The action nonce used to derive the digest. */
|
||||
actionNonce: bigint;
|
||||
/** 65-byte secp256k1 signature (r||s||v) by the session key (pass as `sig` to authorize). */
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface AuthorizeResult extends ActionAuthorization {
|
||||
tx: ContractTransactionResponse;
|
||||
}
|
||||
|
||||
function toBytes(v: BytesLike): Uint8Array {
|
||||
return v instanceof Uint8Array ? v : getBytes(v);
|
||||
}
|
||||
|
||||
function bytes32(v: BytesLike): string {
|
||||
const b = toBytes(v);
|
||||
if (b.length !== 32) throw new Error(`AereAgentDIDClient: expected 32-byte value, got ${b.length}`);
|
||||
return hexlify(b);
|
||||
}
|
||||
|
||||
export class AereAgentDIDClient {
|
||||
readonly address: string;
|
||||
readonly chainId: number;
|
||||
readonly contract: Contract;
|
||||
|
||||
constructor(runner: ContractRunner, opts: AereAgentDIDClientOptions = {}) {
|
||||
this.address = opts.address ?? AERE_MAINNET.AereAgentDID;
|
||||
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||
this.contract = new Contract(this.address, AERE_AGENT_DID_ABI, runner);
|
||||
}
|
||||
|
||||
// ---- challenge / digest derivation ----------------------------------------
|
||||
|
||||
/**
|
||||
* Local mirror of AereAgentDID._sessionChallenge — the Falcon issuance challenge:
|
||||
* keccak256(abi.encode(SESSION_DOMAIN, chainid, address(this), rootKeyId, nonce,
|
||||
* sessionAddr, scopeHash, spendCap, expiry))
|
||||
* Cross-check with {@link sessionChallenge} on-chain.
|
||||
*/
|
||||
deriveSessionChallenge(p: SessionParams, nonce: bigint | number): string {
|
||||
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'uint256', 'uint64', 'address', 'bytes32', 'uint256', 'uint64'],
|
||||
[SESSION_DOMAIN, this.chainId, this.address, p.rootKeyId, nonce, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry],
|
||||
);
|
||||
return keccak256(enc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local mirror of AereAgentDID._actionDigest — what a session key signs to authorize:
|
||||
* keccak256(abi.encode(ACTION_DOMAIN, chainid, address(this), sessionId, scope,
|
||||
* actionHash, amount, actionNonce))
|
||||
* Cross-check with {@link actionDigest} on-chain.
|
||||
*/
|
||||
deriveActionDigest(sessionId: bigint | number, scope: BytesLike, actionHash: BytesLike, amount: bigint | number, actionNonce: bigint | number): string {
|
||||
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'uint256', 'bytes32', 'bytes32', 'uint256', 'uint64'],
|
||||
[ACTION_DOMAIN, this.chainId, this.address, sessionId, bytes32(scope), bytes32(actionHash), amount, actionNonce],
|
||||
);
|
||||
return keccak256(enc);
|
||||
}
|
||||
|
||||
/** On-chain Falcon issuance challenge for a session's params at an explicit nonce. */
|
||||
async sessionChallenge(p: SessionParams, nonce: bigint | number): Promise<string> {
|
||||
return this.contract.sessionChallenge(p.rootKeyId, nonce, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry);
|
||||
}
|
||||
|
||||
/** On-chain current issuance challenge for an agent (uses its current sessionNonce). */
|
||||
async currentSessionChallenge(p: SessionParams): Promise<string> {
|
||||
return this.contract.currentSessionChallenge(p.rootKeyId, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry);
|
||||
}
|
||||
|
||||
/** On-chain action digest for (sessionId, scope, actionHash, amount, actionNonce). */
|
||||
async actionDigest(sessionId: bigint | number, scope: BytesLike, actionHash: BytesLike, amount: bigint | number, actionNonce: bigint | number): Promise<string> {
|
||||
return this.contract.actionDigest(sessionId, bytes32(scope), bytes32(actionHash), amount, actionNonce);
|
||||
}
|
||||
|
||||
// ---- agent / session views ------------------------------------------------
|
||||
|
||||
/** Read an agent (DID). Reverts (UnknownAgent) if it does not exist. */
|
||||
async getAgent(rootKeyId: bigint | number): Promise<AgentRecord> {
|
||||
const [controller, createdBlock, sessionNonce, sessionCountForAgent] = await this.contract.getAgent(rootKeyId);
|
||||
return { controller, createdBlock, sessionNonce, sessionCount: sessionCountForAgent };
|
||||
}
|
||||
|
||||
/** True iff a DID exists for `rootKeyId`. */
|
||||
async agentExists(rootKeyId: bigint | number): Promise<boolean> {
|
||||
return this.contract.agentExists(rootKeyId);
|
||||
}
|
||||
|
||||
/** Total sessions ever issued (sessionIds run 0..sessionCount-1). */
|
||||
async sessionCount(): Promise<bigint> {
|
||||
return this.contract.sessionCount();
|
||||
}
|
||||
|
||||
/** Read a session. Reverts (UnknownSession) on an out-of-range id. */
|
||||
async getSession(sessionId: bigint | number): Promise<SessionRecord> {
|
||||
const [rootKeyId, sessionAddr, scopeHash, spendCap, spent, issuedBlock, expiry, actionNonce, revoked] =
|
||||
await this.contract.getSession(sessionId);
|
||||
return { rootKeyId, sessionAddr, scopeHash, spendCap, spent, issuedBlock, expiry, actionNonce, revoked };
|
||||
}
|
||||
|
||||
/** True iff the session is live: exists, not revoked, unexpired, root still ACTIVE Falcon. */
|
||||
async isSessionValid(sessionId: bigint | number): Promise<boolean> {
|
||||
return this.contract.isSessionValid(sessionId);
|
||||
}
|
||||
|
||||
/** Remaining spend under a session (0 if invalid/exhausted). */
|
||||
async remainingSpend(sessionId: bigint | number): Promise<bigint> {
|
||||
return this.contract.remainingSpend(sessionId);
|
||||
}
|
||||
|
||||
/** All sessionIds ever issued for an agent (append-only, includes revoked). */
|
||||
async sessionsOf(rootKeyId: bigint | number): Promise<bigint[]> {
|
||||
return this.contract.sessionsOf(rootKeyId);
|
||||
}
|
||||
|
||||
/** The AerePQCKeyRegistry this DID roots into. */
|
||||
async keyRegistry(): Promise<string> {
|
||||
return this.contract.keyRegistry();
|
||||
}
|
||||
|
||||
// ---- construction path (no tx) --------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the Falcon proof-of-possession for issueSession at an explicit nonce,
|
||||
* WITHOUT sending a transaction. Derives the issuance challenge locally and signs
|
||||
* it with the root Falcon key via the src/pqc signer. `rootScheme` must be a
|
||||
* Falcon scheme (1 or 2) — the root of an agent DID is always Falcon.
|
||||
*/
|
||||
buildIssueSessionPoP(p: SessionParams, rootSecretKey: Uint8Array, rootScheme: SchemeId, nonce: bigint | number): IssueSessionPoP {
|
||||
if (rootScheme !== SCHEME.FALCON512 && rootScheme !== SCHEME.FALCON1024) {
|
||||
throw new Error(`AereAgentDIDClient: agent root must be a Falcon scheme (1 or 2), got ${rootScheme}`);
|
||||
}
|
||||
const challenge = this.deriveSessionChallenge(p, nonce);
|
||||
const popSig = signInternal(rootScheme, getBytes(challenge), rootSecretKey);
|
||||
return { challenge, nonce: BigInt(nonce), popSig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the secp256k1 authorization for an action at an explicit actionNonce,
|
||||
* WITHOUT sending a transaction. Derives the action digest locally and signs it
|
||||
* with the raw session private key (SigningKey over the raw digest, matching the
|
||||
* contract's ecrecover on the digest — NOT an eth_sign personal-message prefix).
|
||||
* The serialized signature is canonical low-s with v in {27,28}.
|
||||
*/
|
||||
buildAuthorization(
|
||||
sessionId: bigint | number,
|
||||
scope: BytesLike,
|
||||
actionHash: BytesLike,
|
||||
amount: bigint | number,
|
||||
actionNonce: bigint | number,
|
||||
sessionPrivateKey: BytesLike,
|
||||
): ActionAuthorization {
|
||||
const digest = this.deriveActionDigest(sessionId, scope, actionHash, amount, actionNonce);
|
||||
const sk = new SigningKey(hexlify(toBytes(sessionPrivateKey)));
|
||||
const sig: Signature = sk.sign(digest);
|
||||
return { digest, actionNonce: BigInt(actionNonce), signature: sig.serialized };
|
||||
}
|
||||
|
||||
// ---- writes (require a Signer runner) --------------------------------------
|
||||
|
||||
/** Open the DID for an ACTIVE Falcon root key (registry owner of that key only). */
|
||||
async createAgent(rootKeyId: bigint | number): Promise<ContractTransactionResponse> {
|
||||
return this.contract.createAgent(rootKeyId);
|
||||
}
|
||||
|
||||
/** Issue a session with a pre-built Falcon PoP envelope. */
|
||||
async issueSession(p: SessionParams, popSig: BytesLike): Promise<ContractTransactionResponse> {
|
||||
return this.contract.issueSession(p.rootKeyId, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry, hexlify(toBytes(popSig)));
|
||||
}
|
||||
|
||||
/** Revoke a session immediately (agent controller only). */
|
||||
async revokeSession(sessionId: bigint | number): Promise<ContractTransactionResponse> {
|
||||
return this.contract.revokeSession(sessionId);
|
||||
}
|
||||
|
||||
/** Authorize (and record) an action with a pre-built session-key signature. */
|
||||
async authorize(sessionId: bigint | number, scope: BytesLike, actionHash: BytesLike, amount: bigint | number, sig: BytesLike): Promise<ContractTransactionResponse> {
|
||||
return this.contract.authorize(sessionId, bytes32(scope), bytes32(actionHash), amount, hexlify(toBytes(sig)));
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level: read the agent's current sessionNonce on-chain, derive the issuance
|
||||
* challenge locally (cross-checked against currentSessionChallenge), sign it with
|
||||
* the root Falcon key (src/pqc), and submit issueSession. `signer` relays the tx
|
||||
* and pays gas (any account may relay — authorship is the Falcon PoP alone).
|
||||
* `rootSecretKey` never leaves this process.
|
||||
*/
|
||||
async issueSessionWithPoP(
|
||||
signer: Signer,
|
||||
p: SessionParams,
|
||||
rootSecretKey: Uint8Array,
|
||||
rootScheme: SchemeId,
|
||||
verifyChallengeOnChain = true,
|
||||
): Promise<IssueSessionResult> {
|
||||
const agent = await this.getAgent(p.rootKeyId);
|
||||
const nonce = agent.sessionNonce;
|
||||
const built = this.buildIssueSessionPoP(p, rootSecretKey, rootScheme, nonce);
|
||||
|
||||
if (verifyChallengeOnChain) {
|
||||
const onChain = await this.currentSessionChallenge(p);
|
||||
if (onChain.toLowerCase() !== built.challenge.toLowerCase()) {
|
||||
throw new Error(`AereAgentDIDClient: local session challenge ${built.challenge} != on-chain ${onChain}`);
|
||||
}
|
||||
}
|
||||
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
const tx: ContractTransactionResponse = await c.issueSession(
|
||||
p.rootKeyId, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry, hexlify(built.popSig),
|
||||
);
|
||||
return { tx, challenge: built.challenge, nonce, popSig: built.popSig };
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level: read the session's current actionNonce on-chain, derive the action
|
||||
* digest locally (cross-checked against actionDigest), sign it with the secp256k1
|
||||
* session key, and submit authorize via `relaySigner`. The session key signs the
|
||||
* raw digest; the relayer only pays gas.
|
||||
*/
|
||||
async authorizeWithSessionKey(
|
||||
relaySigner: Signer,
|
||||
sessionId: bigint | number,
|
||||
scope: BytesLike,
|
||||
actionHash: BytesLike,
|
||||
amount: bigint | number,
|
||||
sessionPrivateKey: BytesLike,
|
||||
verifyDigestOnChain = true,
|
||||
): Promise<AuthorizeResult> {
|
||||
const session = await this.getSession(sessionId);
|
||||
const actionNonce = session.actionNonce;
|
||||
const built = this.buildAuthorization(sessionId, scope, actionHash, amount, actionNonce, sessionPrivateKey);
|
||||
|
||||
if (verifyDigestOnChain) {
|
||||
const onChain = await this.actionDigest(sessionId, scope, actionHash, amount, actionNonce);
|
||||
if (onChain.toLowerCase() !== built.digest.toLowerCase()) {
|
||||
throw new Error(`AereAgentDIDClient: local action digest ${built.digest} != on-chain ${onChain}`);
|
||||
}
|
||||
}
|
||||
|
||||
const c = this.contract.connect(relaySigner) as Contract;
|
||||
const tx: ContractTransactionResponse = await c.authorize(
|
||||
sessionId, bytes32(scope), bytes32(actionHash), amount, built.signature,
|
||||
);
|
||||
return { tx, digest: built.digest, actionNonce, signature: built.signature };
|
||||
}
|
||||
}
|
||||
234
src/pqc/AerePQCClient.ts
Normal file
234
src/pqc/AerePQCClient.ts
Normal file
@ -0,0 +1,234 @@
|
||||
// AerePQCClient — ethers v6 wrapper over AerePQCAttestation (chain 2800).
|
||||
//
|
||||
// AerePQCAttestation (0x465d9E3b476BF98Aa1393079e240Db5D2a9bEA6A) turns AERE's
|
||||
// LIVE native post-quantum precompiles (0x0AE1 Falcon-512 / 0x0AE2 Falcon-1024 /
|
||||
// 0x0AE3 ML-DSA-44 / 0x0AE4 SLH-DSA-128s) into a usable primitive: register a
|
||||
// NIST PQC public key, then record attestations whose validity rests on a real
|
||||
// post-quantum signature verified on-chain by the matching precompile.
|
||||
//
|
||||
// This client is the missing SIGNING surface: keygen + internal-interface signing
|
||||
// (schemes.ts) + envelope building (envelope.ts) wired to the contract's methods,
|
||||
// including a one-call signAndAttest() that reads the per-key nonce, derives the
|
||||
// challenge, signs it, and submits attest().
|
||||
|
||||
import {
|
||||
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify,
|
||||
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from '../addresses.js';
|
||||
import { SCHEME, type SchemeId, keygen, signInternal, verifyLocal } from './schemes.js';
|
||||
|
||||
/** Minimal inline ABI — only the AerePQCAttestation surface this client uses. */
|
||||
export const AERE_PQC_ATTESTATION_ABI = [
|
||||
'function registerKey(uint8 scheme, bytes pubKey) returns (uint256 keyId)',
|
||||
'function attest(uint256 keyId, bytes32 messageHash, bytes signature)',
|
||||
'function verifySignature(uint8 scheme, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||
'function attestChallenge(uint256 keyId, uint64 nonce, bytes32 messageHash) view returns (bytes32)',
|
||||
'function attestationId(uint256 keyId, uint64 nonce) pure returns (bytes32)',
|
||||
'function nonceOf(uint256 keyId) view returns (uint64)',
|
||||
'function keyCount() view returns (uint256)',
|
||||
'function getKey(uint256 keyId) view returns (address owner, uint8 scheme, uint64 nonce, bytes pubKey)',
|
||||
'function getAttestation(uint256 keyId, uint64 nonce) view returns (bool exists, bytes32 messageHash, bytes32 challenge, uint256 blockNumber)',
|
||||
'function isValidAttestation(uint256 keyId, uint64 nonce, bytes32 messageHash) view returns (bool)',
|
||||
'function precompileFor(uint8 scheme) pure returns (address)',
|
||||
'function attestationCount() view returns (uint256)',
|
||||
'event KeyRegistered(uint256 indexed keyId, address indexed owner, uint8 indexed scheme, uint256 pubKeyLen)',
|
||||
'event Attested(uint256 indexed keyId, uint8 indexed scheme, uint64 indexed nonce, bytes32 messageHash, bytes32 challenge, uint256 blockNumber)',
|
||||
] as const;
|
||||
|
||||
/** keccak256("AerePQCAttestation.v1.challenge") — the challenge domain separator. */
|
||||
export const CHALLENGE_DOMAIN = keccak256(toUtf8Bytes('AerePQCAttestation.v1.challenge'));
|
||||
|
||||
export interface AerePQCClientOptions {
|
||||
/** AerePQCAttestation address. Default: AERE_MAINNET mainnet deployment. */
|
||||
address?: string;
|
||||
/** Chain id used in the challenge derivation. Default: 2800. */
|
||||
chainId?: number;
|
||||
}
|
||||
|
||||
export interface RegisteredKey {
|
||||
owner: string;
|
||||
scheme: number;
|
||||
nonce: bigint;
|
||||
pubKey: Uint8Array;
|
||||
}
|
||||
|
||||
export interface AttestationRecord {
|
||||
exists: boolean;
|
||||
messageHash: string;
|
||||
challenge: string;
|
||||
blockNumber: bigint;
|
||||
}
|
||||
|
||||
export interface SignAndAttestResult {
|
||||
tx: ContractTransactionResponse;
|
||||
keyId: bigint;
|
||||
nonce: bigint;
|
||||
messageHash: string;
|
||||
challenge: string;
|
||||
signature: Uint8Array;
|
||||
}
|
||||
|
||||
function toBytes(v: BytesLike): Uint8Array {
|
||||
return v instanceof Uint8Array ? v : getBytes(v);
|
||||
}
|
||||
|
||||
function bytes32(v: BytesLike): string {
|
||||
const b = toBytes(v);
|
||||
if (b.length !== 32) throw new Error(`AerePQCClient: expected 32-byte value, got ${b.length}`);
|
||||
return hexlify(b);
|
||||
}
|
||||
|
||||
export class AerePQCClient {
|
||||
readonly address: string;
|
||||
readonly chainId: number;
|
||||
readonly contract: Contract;
|
||||
private readonly runner: ContractRunner;
|
||||
|
||||
constructor(runner: ContractRunner, opts: AerePQCClientOptions = {}) {
|
||||
this.runner = runner;
|
||||
this.address = opts.address ?? AERE_MAINNET.AerePQCAttestation;
|
||||
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||
this.contract = new Contract(this.address, AERE_PQC_ATTESTATION_ABI, runner);
|
||||
}
|
||||
|
||||
// ---- keygen / signing helpers (re-exported for convenience) ----------------
|
||||
|
||||
/** Generate a keypair for a scheme (1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s). */
|
||||
static keygen(scheme: SchemeId, seed?: Uint8Array) {
|
||||
return keygen(scheme, seed);
|
||||
}
|
||||
/** Sign a 32-byte message with the scheme's internal interface; returns the contract envelope. */
|
||||
static sign(scheme: SchemeId, message: BytesLike, secretKey: Uint8Array): Uint8Array {
|
||||
return signInternal(scheme, toBytes(message), secretKey);
|
||||
}
|
||||
/** Off-chain mirror of the precompile check. */
|
||||
static verifyLocal(scheme: SchemeId, message: BytesLike, signature: Uint8Array, publicKey: Uint8Array): boolean {
|
||||
return verifyLocal(scheme, toBytes(message), signature, publicKey);
|
||||
}
|
||||
|
||||
// ---- challenge derivation --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Local mirror of AerePQCAttestation.attestChallenge:
|
||||
* keccak256(abi.encode(CHALLENGE_DOMAIN, chainid, address(this), keyId, nonce, messageHash))
|
||||
* Proven byte-identical to the on-chain view. Use `attestChallenge` to cross-check on-chain.
|
||||
*/
|
||||
deriveChallenge(keyId: bigint | number, nonce: bigint | number, messageHash: BytesLike): string {
|
||||
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'uint256', 'uint64', 'bytes32'],
|
||||
[CHALLENGE_DOMAIN, this.chainId, this.address, keyId, nonce, bytes32(messageHash)],
|
||||
);
|
||||
return keccak256(enc);
|
||||
}
|
||||
|
||||
// ---- views -----------------------------------------------------------------
|
||||
|
||||
/** Free PQC verify via the live precompile. Returns true iff the precompile accepts. */
|
||||
async verifySignature(scheme: SchemeId, pubKey: BytesLike, messageHash: BytesLike, signature: BytesLike): Promise<boolean> {
|
||||
return this.contract.verifySignature(scheme, hexlify(toBytes(pubKey)), bytes32(messageHash), hexlify(toBytes(signature)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Falcon-512 signature over an ARBITRARY-length message directly via the
|
||||
* live AereFalcon512Verifier (0x4E8e...D8fFC). Unlike the attestation path (which
|
||||
* takes a bytes32 messageHash), this accepts messages longer than 32 bytes, so it
|
||||
* mirrors the AerePQCAccount domain-separated spend check (message = domain||hash,
|
||||
* 64 bytes). Read-only.
|
||||
*/
|
||||
async verifyFalconRaw(pubKey: BytesLike, message: BytesLike, nonce: BytesLike, compSig: BytesLike): Promise<boolean> {
|
||||
const verifier = new Contract(
|
||||
AERE_MAINNET.AereFalcon512Verifier,
|
||||
['function verify(bytes pk, bytes message, bytes nonce, bytes compSig) view returns (bool)'],
|
||||
this.runner,
|
||||
);
|
||||
return verifier.verify(
|
||||
hexlify(toBytes(pubKey)),
|
||||
hexlify(toBytes(message)),
|
||||
hexlify(toBytes(nonce)),
|
||||
hexlify(toBytes(compSig)),
|
||||
);
|
||||
}
|
||||
|
||||
/** On-chain challenge for (keyId, nonce, messageHash). */
|
||||
async attestChallenge(keyId: bigint | number, nonce: bigint | number, messageHash: BytesLike): Promise<string> {
|
||||
return this.contract.attestChallenge(keyId, nonce, bytes32(messageHash));
|
||||
}
|
||||
|
||||
/** Deterministic id for the attestation recorded by (keyId, nonce). */
|
||||
async attestationId(keyId: bigint | number, nonce: bigint | number): Promise<string> {
|
||||
return this.contract.attestationId(keyId, nonce);
|
||||
}
|
||||
|
||||
/** Current (next unused) per-key nonce. */
|
||||
async nonceOf(keyId: bigint | number): Promise<bigint> {
|
||||
return this.contract.nonceOf(keyId);
|
||||
}
|
||||
|
||||
/** Number of registered keys (keyIds run 0..keyCount-1). */
|
||||
async keyCount(): Promise<bigint> {
|
||||
return this.contract.keyCount();
|
||||
}
|
||||
|
||||
/** Total attestations recorded across all keys. */
|
||||
async attestationCount(): Promise<bigint> {
|
||||
return this.contract.attestationCount();
|
||||
}
|
||||
|
||||
/** Read a registered key. */
|
||||
async getKey(keyId: bigint | number): Promise<RegisteredKey> {
|
||||
const [owner, scheme, nonce, pubKey] = await this.contract.getKey(keyId);
|
||||
return { owner, scheme: Number(scheme), nonce, pubKey: getBytes(pubKey) };
|
||||
}
|
||||
|
||||
/** Read a recorded attestation by (keyId, nonce). */
|
||||
async getAttestation(keyId: bigint | number, nonce: bigint | number): Promise<AttestationRecord> {
|
||||
const [exists, messageHash, challenge, blockNumber] = await this.contract.getAttestation(keyId, nonce);
|
||||
return { exists, messageHash, challenge, blockNumber };
|
||||
}
|
||||
|
||||
/** True iff the attestation at (keyId, nonce) exists and committed to `messageHash`. */
|
||||
async isValidAttestation(keyId: bigint | number, nonce: bigint | number, messageHash: BytesLike): Promise<boolean> {
|
||||
return this.contract.isValidAttestation(keyId, nonce, bytes32(messageHash));
|
||||
}
|
||||
|
||||
/** The live precompile address for a scheme. */
|
||||
async precompileFor(scheme: SchemeId): Promise<string> {
|
||||
return this.contract.precompileFor(scheme);
|
||||
}
|
||||
|
||||
// ---- writes (require a Signer runner) --------------------------------------
|
||||
|
||||
/** Register a PQC public key; returns the sent transaction (keyId is emitted / equals prior keyCount). */
|
||||
async registerKey(scheme: SchemeId, pubKey: BytesLike): Promise<ContractTransactionResponse> {
|
||||
return this.contract.registerKey(scheme, hexlify(toBytes(pubKey)));
|
||||
}
|
||||
|
||||
/** Record an attestation with a pre-built signature envelope over the current challenge. */
|
||||
async attest(keyId: bigint | number, messageHash: BytesLike, signature: BytesLike): Promise<ContractTransactionResponse> {
|
||||
return this.contract.attest(keyId, bytes32(messageHash), hexlify(toBytes(signature)));
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level: read the key's current nonce, derive the challenge, sign it with
|
||||
* the scheme's internal interface, build the envelope, and submit attest().
|
||||
*
|
||||
* `signer` funds the gas (any account may relay; authorship is the PQC signature
|
||||
* alone). `secretKey` never leaves this process.
|
||||
*/
|
||||
async signAndAttest(
|
||||
signer: Signer,
|
||||
keyId: bigint | number,
|
||||
messageHash: BytesLike,
|
||||
secretKey: Uint8Array,
|
||||
scheme: SchemeId,
|
||||
): Promise<SignAndAttestResult> {
|
||||
const msg = bytes32(messageHash);
|
||||
const nonce = await this.nonceOf(keyId);
|
||||
const challenge = this.deriveChallenge(keyId, nonce, msg);
|
||||
const signature = signInternal(scheme, getBytes(challenge), secretKey);
|
||||
const withSigner = this.contract.connect(signer) as Contract;
|
||||
const tx: ContractTransactionResponse = await withSigner.attest(keyId, msg, hexlify(signature));
|
||||
return { tx, keyId: BigInt(keyId), nonce, messageHash: msg, challenge, signature };
|
||||
}
|
||||
}
|
||||
275
src/pqc/AerePQCKeyRegistryClient.ts
Normal file
275
src/pqc/AerePQCKeyRegistryClient.ts
Normal file
@ -0,0 +1,275 @@
|
||||
// AerePQCKeyRegistryClient — ethers v6 wrapper over AerePQCKeyRegistry (chain 2800).
|
||||
//
|
||||
// AerePQCKeyRegistry (0x1eCa3c5ADcBD0b22636D8672b00faC6D89363691) is the
|
||||
// permissionless registry a wallet / agent / app uses to PUBLISH its post-quantum
|
||||
// public keys and prove, on-chain, that it holds the matching private key.
|
||||
// registerKey(scheme, pubKey, signature) stores a key only if `signature` is a
|
||||
// valid PQC signature by `pubKey` over a contract-bound PROOF-OF-POSSESSION
|
||||
// challenge, verified in full by AERE's live native precompile.
|
||||
//
|
||||
// The PoP challenge preimage is (read straight from the contract):
|
||||
// keccak256(abi.encode(
|
||||
// POP_DOMAIN, block.chainid, address(this), owner, scheme, keccak256(pubKey), nonce))
|
||||
// POP_DOMAIN = keccak256("AerePQCKeyRegistry.v1.pop")
|
||||
//
|
||||
// This client wraps the views + writes and adds registerKeyWithPoP(): it reads the
|
||||
// on-chain identity nonce, derives the challenge locally (cross-checked against the
|
||||
// contract's popChallenge view), signs it with the src/pqc signer (schemes.signInternal
|
||||
// + envelope.ts), and submits registerKey. The pqc signing/envelope code is REUSED,
|
||||
// never reimplemented.
|
||||
|
||||
import {
|
||||
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify,
|
||||
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||
} from 'ethers';
|
||||
import { AERE_MAINNET } from '../addresses.js';
|
||||
import { type SchemeId, signInternal } from './schemes.js';
|
||||
import { assertPubKey } from './envelope.js';
|
||||
|
||||
/** Minimal inline ABI — only the AerePQCKeyRegistry surface this client uses. */
|
||||
export const AERE_PQC_KEY_REGISTRY_ABI = [
|
||||
// writes
|
||||
'function registerKey(uint8 scheme, bytes pubKey, bytes signature) returns (uint256 keyId)',
|
||||
'function rotateKey(uint256 oldKeyId, uint8 newScheme, bytes newPubKey, bytes signature) returns (uint256 newKeyId)',
|
||||
'function revokeKey(uint256 keyId)',
|
||||
// verification views
|
||||
'function verify(uint8 scheme, bytes pubKey, bytes32 message, bytes signature) view returns (bool)',
|
||||
'function verifyWithKey(uint256 keyId, bytes32 message, bytes signature) view returns (bool)',
|
||||
// challenge views
|
||||
'function popChallenge(address owner, uint8 scheme, bytes pubKey, uint64 nonce) view returns (bytes32)',
|
||||
'function currentPopChallenge(address owner, uint8 scheme, bytes pubKey) view returns (bytes32)',
|
||||
'function identityNonce(address owner) view returns (uint64)',
|
||||
// key views
|
||||
'function getKey(uint256 keyId) view returns (address owner, uint8 scheme, uint8 status, uint64 registeredBlock, uint256 rotatedFromId, uint256 rotatedToId, bytes pubKey)',
|
||||
'function keyCount() view returns (uint256)',
|
||||
'function statusOf(uint256 keyId) view returns (uint8)',
|
||||
'function isActiveKey(uint256 keyId) view returns (bool)',
|
||||
'function schemeOf(uint256 keyId) view returns (uint8)',
|
||||
'function ownerOf(uint256 keyId) view returns (address)',
|
||||
'function keysOf(address owner) view returns (uint256[])',
|
||||
'function precompileFor(uint8 scheme) view returns (address)',
|
||||
// events
|
||||
'event KeyRegistered(uint256 indexed keyId, address indexed owner, uint8 indexed scheme, uint64 popNonce)',
|
||||
'event KeyRotated(uint256 indexed oldKeyId, uint256 indexed newKeyId, address indexed owner)',
|
||||
'event KeyRevoked(uint256 indexed keyId, address indexed owner)',
|
||||
] as const;
|
||||
|
||||
/** keccak256("AerePQCKeyRegistry.v1.pop") — the proof-of-possession domain separator. */
|
||||
export const POP_DOMAIN = keccak256(toUtf8Bytes('AerePQCKeyRegistry.v1.pop'));
|
||||
|
||||
/**
|
||||
* Key lifecycle status (mirrors AerePQCKeyRegistry STATUS_*).
|
||||
* NONE=0 (never registered), ACTIVE=1, ROTATED=2 (superseded), REVOKED=3 (terminal).
|
||||
* ACTIVE and ROTATED keys still verify cryptographically; REVOKED never does.
|
||||
*/
|
||||
export enum KeyStatus {
|
||||
NONE = 0,
|
||||
ACTIVE = 1,
|
||||
ROTATED = 2,
|
||||
REVOKED = 3,
|
||||
}
|
||||
|
||||
export interface AerePQCKeyRegistryClientOptions {
|
||||
/** AerePQCKeyRegistry address. Default: AERE_MAINNET mainnet deployment. */
|
||||
address?: string;
|
||||
/** Chain id used in the local PoP challenge derivation. Default: 2800. */
|
||||
chainId?: number;
|
||||
}
|
||||
|
||||
export interface KeyRecord {
|
||||
owner: string;
|
||||
scheme: number;
|
||||
status: KeyStatus;
|
||||
registeredBlock: bigint;
|
||||
rotatedFromId: bigint;
|
||||
rotatedToId: bigint;
|
||||
pubKey: Uint8Array;
|
||||
}
|
||||
|
||||
export interface RegisterKeyPoP {
|
||||
/** The 32-byte proof-of-possession challenge that was signed. */
|
||||
challenge: string;
|
||||
/** The identity nonce used to derive the challenge. */
|
||||
nonce: bigint;
|
||||
/** The PQC proof-of-possession envelope (pass as `signature` to registerKey). */
|
||||
signature: Uint8Array;
|
||||
}
|
||||
|
||||
export interface RegisterKeyResult extends RegisterKeyPoP {
|
||||
tx: ContractTransactionResponse;
|
||||
}
|
||||
|
||||
function toBytes(v: BytesLike): Uint8Array {
|
||||
return v instanceof Uint8Array ? v : getBytes(v);
|
||||
}
|
||||
|
||||
export class AerePQCKeyRegistryClient {
|
||||
readonly address: string;
|
||||
readonly chainId: number;
|
||||
readonly contract: Contract;
|
||||
|
||||
constructor(runner: ContractRunner, opts: AerePQCKeyRegistryClientOptions = {}) {
|
||||
this.address = opts.address ?? AERE_MAINNET.AerePQCKeyRegistry;
|
||||
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||
this.contract = new Contract(this.address, AERE_PQC_KEY_REGISTRY_ABI, runner);
|
||||
}
|
||||
|
||||
// ---- PoP challenge derivation ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Local mirror of AerePQCKeyRegistry._popChallenge:
|
||||
* keccak256(abi.encode(POP_DOMAIN, chainid, address(this), owner, scheme, keccak256(pubKey), nonce))
|
||||
* Proven byte-identical to the on-chain popChallenge view (see the live/offline
|
||||
* tests). Use {@link popChallenge} to cross-check on-chain.
|
||||
*/
|
||||
derivePopChallenge(owner: string, scheme: SchemeId, pubKey: BytesLike, nonce: bigint | number): string {
|
||||
const pubKeyHash = keccak256(hexlify(toBytes(pubKey)));
|
||||
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'address', 'uint8', 'bytes32', 'uint64'],
|
||||
[POP_DOMAIN, this.chainId, this.address, owner, scheme, pubKeyHash, nonce],
|
||||
);
|
||||
return keccak256(enc);
|
||||
}
|
||||
|
||||
/** On-chain PoP challenge for (owner, scheme, pubKey) at an explicit nonce. */
|
||||
async popChallenge(owner: string, scheme: SchemeId, pubKey: BytesLike, nonce: bigint | number): Promise<string> {
|
||||
return this.contract.popChallenge(owner, scheme, hexlify(toBytes(pubKey)), nonce);
|
||||
}
|
||||
|
||||
/** On-chain PoP challenge for `owner` to register `pubKey` right now (current nonce). */
|
||||
async currentPopChallenge(owner: string, scheme: SchemeId, pubKey: BytesLike): Promise<string> {
|
||||
return this.contract.currentPopChallenge(owner, scheme, hexlify(toBytes(pubKey)));
|
||||
}
|
||||
|
||||
/** The current per-identity registration nonce (advances on each register/rotate). */
|
||||
async identityNonce(owner: string): Promise<bigint> {
|
||||
return this.contract.identityNonce(owner);
|
||||
}
|
||||
|
||||
// ---- verification views ----------------------------------------------------
|
||||
|
||||
/** Stateless PQC verification over a raw 32-byte message. Fail-closed (never reverts). */
|
||||
async verify(scheme: SchemeId, pubKey: BytesLike, message: BytesLike, signature: BytesLike): Promise<boolean> {
|
||||
return this.contract.verify(scheme, hexlify(toBytes(pubKey)), hexlify(toBytes(message)), hexlify(toBytes(signature)));
|
||||
}
|
||||
|
||||
/** Verify a signature with a REGISTERED key. Fail-closed; REVOKED keys never verify. */
|
||||
async verifyWithKey(keyId: bigint | number, message: BytesLike, signature: BytesLike): Promise<boolean> {
|
||||
return this.contract.verifyWithKey(keyId, hexlify(toBytes(message)), hexlify(toBytes(signature)));
|
||||
}
|
||||
|
||||
// ---- key views -------------------------------------------------------------
|
||||
|
||||
/** Number of registered keys (keyIds run 0..keyCount-1). */
|
||||
async keyCount(): Promise<bigint> {
|
||||
return this.contract.keyCount();
|
||||
}
|
||||
|
||||
/** Read a registered key. Reverts (UnknownKey) on an out-of-range keyId. */
|
||||
async getKey(keyId: bigint | number): Promise<KeyRecord> {
|
||||
const [owner, scheme, status, registeredBlock, rotatedFromId, rotatedToId, pubKey] =
|
||||
await this.contract.getKey(keyId);
|
||||
return {
|
||||
owner,
|
||||
scheme: Number(scheme),
|
||||
status: Number(status) as KeyStatus,
|
||||
registeredBlock,
|
||||
rotatedFromId,
|
||||
rotatedToId,
|
||||
pubKey: getBytes(pubKey),
|
||||
};
|
||||
}
|
||||
|
||||
/** Lifecycle status of a key (NONE for a never-registered id). */
|
||||
async statusOf(keyId: bigint | number): Promise<KeyStatus> {
|
||||
return Number(await this.contract.statusOf(keyId)) as KeyStatus;
|
||||
}
|
||||
|
||||
/** True iff the key exists and is ACTIVE (current, not rotated/revoked). */
|
||||
async isActiveKey(keyId: bigint | number): Promise<boolean> {
|
||||
return this.contract.isActiveKey(keyId);
|
||||
}
|
||||
|
||||
/** All keyIds ever registered by `owner` (append-only, includes retired). */
|
||||
async keysOf(owner: string): Promise<bigint[]> {
|
||||
return this.contract.keysOf(owner);
|
||||
}
|
||||
|
||||
/** The live precompile address for a scheme. */
|
||||
async precompileFor(scheme: SchemeId): Promise<string> {
|
||||
return this.contract.precompileFor(scheme);
|
||||
}
|
||||
|
||||
// ---- construction path (no tx) --------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the proof-of-possession for a registerKey at an explicit nonce, WITHOUT
|
||||
* sending a transaction. Asserts the public key's length/header for the scheme,
|
||||
* derives the PoP challenge locally, and signs it with the src/pqc signer.
|
||||
* Returns the challenge + envelope so a caller can relay registerKey itself, or
|
||||
* unit-test the exact bytes registerKey would submit.
|
||||
*/
|
||||
buildRegisterKeyPoP(owner: string, scheme: SchemeId, secretKey: Uint8Array, pubKey: Uint8Array, nonce: bigint | number): RegisterKeyPoP {
|
||||
assertPubKey(scheme, pubKey);
|
||||
const challenge = this.derivePopChallenge(owner, scheme, pubKey, nonce);
|
||||
const signature = signInternal(scheme, getBytes(challenge), secretKey);
|
||||
return { challenge, nonce: BigInt(nonce), signature };
|
||||
}
|
||||
|
||||
// ---- writes (require a Signer runner) --------------------------------------
|
||||
|
||||
/** Register a pre-signed PQC public key. `signature` must be a valid PoP envelope. */
|
||||
async registerKey(scheme: SchemeId, pubKey: BytesLike, signature: BytesLike): Promise<ContractTransactionResponse> {
|
||||
return this.contract.registerKey(scheme, hexlify(toBytes(pubKey)), hexlify(toBytes(signature)));
|
||||
}
|
||||
|
||||
/** Rotate `oldKeyId` to a fresh successor key (owner only), proving possession of the NEW key. */
|
||||
async rotateKey(oldKeyId: bigint | number, newScheme: SchemeId, newPubKey: BytesLike, signature: BytesLike): Promise<ContractTransactionResponse> {
|
||||
return this.contract.rotateKey(oldKeyId, newScheme, hexlify(toBytes(newPubKey)), hexlify(toBytes(signature)));
|
||||
}
|
||||
|
||||
/** Permanently revoke a key you own (terminal). */
|
||||
async revokeKey(keyId: bigint | number): Promise<ContractTransactionResponse> {
|
||||
return this.contract.revokeKey(keyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level: read the signer's current identity nonce on-chain, derive the PoP
|
||||
* challenge locally (and cross-check it against the contract's popChallenge view),
|
||||
* sign it with the scheme's internal interface (src/pqc), and submit registerKey.
|
||||
*
|
||||
* `signer` is BOTH the tx sender AND the identity the key binds to — the PoP
|
||||
* challenge commits to msg.sender, so the account that pays gas is the owner.
|
||||
* `secretKey` never leaves this process.
|
||||
*
|
||||
* @param verifyChallengeOnChain when true (default) reads popChallenge from the
|
||||
* contract and asserts it equals the locally-derived value before signing,
|
||||
* so a client/contract encoding drift fails fast instead of on-chain.
|
||||
*/
|
||||
async registerKeyWithPoP(
|
||||
signer: Signer,
|
||||
scheme: SchemeId,
|
||||
secretKey: Uint8Array,
|
||||
pubKey: Uint8Array,
|
||||
verifyChallengeOnChain = true,
|
||||
): Promise<RegisterKeyResult> {
|
||||
assertPubKey(scheme, pubKey);
|
||||
const owner = await signer.getAddress();
|
||||
const nonce = await this.identityNonce(owner);
|
||||
const challenge = this.derivePopChallenge(owner, scheme, pubKey, nonce);
|
||||
|
||||
if (verifyChallengeOnChain) {
|
||||
const onChain = await this.popChallenge(owner, scheme, pubKey, nonce);
|
||||
if (onChain.toLowerCase() !== challenge.toLowerCase()) {
|
||||
throw new Error(
|
||||
`AerePQCKeyRegistryClient: local PoP challenge ${challenge} != on-chain ${onChain}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const signature = signInternal(scheme, getBytes(challenge), secretKey);
|
||||
const c = this.contract.connect(signer) as Contract;
|
||||
const tx: ContractTransactionResponse = await c.registerKey(scheme, hexlify(pubKey), hexlify(signature));
|
||||
return { tx, challenge, nonce, signature };
|
||||
}
|
||||
}
|
||||
152
src/pqc/envelope.ts
Normal file
152
src/pqc/envelope.ts
Normal file
@ -0,0 +1,152 @@
|
||||
// Envelope builders for AerePQCAttestation (chain 2800).
|
||||
//
|
||||
// Pure, dependency-free byte assembly. These functions convert a scheme-native
|
||||
// signature into the exact `signature` bytes that AerePQCAttestation.attest() /
|
||||
// verifySignature() expect, and back. They contain NO crypto — signing lives in
|
||||
// schemes.ts. Every builder asserts the lengths/headers the live contract (and
|
||||
// the native precompiles behind it) require, so a malformed envelope fails here
|
||||
// rather than silently on-chain.
|
||||
//
|
||||
// Envelope shapes the contract expects (from the AerePQCAttestation NatSpec):
|
||||
// Falcon-512 (1): signature = nonce(40) || esig, esig = 0x29 || compressedSig
|
||||
// Falcon-1024 (2): signature = nonce(40) || esig, esig = 0x2A || compressedSig
|
||||
// ML-DSA-44 (3): signature = sig, exactly 2420 bytes (raw FIPS-204 sig)
|
||||
// SLH-DSA-128s(4): signature = sig, exactly 7856 bytes (raw FIPS-205 sig)
|
||||
//
|
||||
// The precompiles behind Falcon use the exact same compressed-signature body
|
||||
// that @noble/post-quantum emits; the only transform is the 1-byte header. The
|
||||
// noble DETACHED Falcon signature is `0x30|logn || nonce(40) || compressedSig`,
|
||||
// so we swap the leading `0x30|logn` header for the contract's `0x20|logn` esig
|
||||
// header and drop it behind the nonce.
|
||||
|
||||
/** Numeric scheme ids used by AerePQCAttestation. */
|
||||
export const SCHEME = {
|
||||
FALCON512: 1,
|
||||
FALCON1024: 2,
|
||||
MLDSA44: 3,
|
||||
SLHDSA128S: 4,
|
||||
} as const;
|
||||
|
||||
export type SchemeId = (typeof SCHEME)[keyof typeof SCHEME];
|
||||
|
||||
/** Fixed byte lengths per scheme (public key + raw signature). */
|
||||
export const LENGTHS = {
|
||||
falcon512: { pubKey: 897, pkHeader: 0x09, logn: 9 },
|
||||
falcon1024: { pubKey: 1793, pkHeader: 0x0a, logn: 10 },
|
||||
mldsa44: { pubKey: 1312, signature: 2420 },
|
||||
slhdsa128s: { pubKey: 32, signature: 7856 },
|
||||
} as const;
|
||||
|
||||
/** Falcon randomized-hash nonce (salt) length, always 40 bytes. */
|
||||
export const FALCON_NONCE_LEN = 40;
|
||||
|
||||
/** noble detached Falcon header byte: 0x30 | logn (0x39 for 512, 0x3A for 1024). */
|
||||
export function falconDetachedHeader(scheme: SchemeId): number {
|
||||
if (scheme === SCHEME.FALCON512) return 0x30 | LENGTHS.falcon512.logn;
|
||||
if (scheme === SCHEME.FALCON1024) return 0x30 | LENGTHS.falcon1024.logn;
|
||||
throw new Error(`envelope: not a Falcon scheme: ${scheme}`);
|
||||
}
|
||||
|
||||
/** Contract esig header byte: 0x20 | logn (0x29 for 512, 0x2A for 1024). */
|
||||
export function falconEsigHeader(scheme: SchemeId): number {
|
||||
if (scheme === SCHEME.FALCON512) return 0x20 | LENGTHS.falcon512.logn;
|
||||
if (scheme === SCHEME.FALCON1024) return 0x20 | LENGTHS.falcon1024.logn;
|
||||
throw new Error(`envelope: not a Falcon scheme: ${scheme}`);
|
||||
}
|
||||
|
||||
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, off);
|
||||
off += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a noble DETACHED Falcon signature into the contract signature envelope.
|
||||
* detached = (0x30|logn) || nonce(40) || compressedSig
|
||||
* envelope = nonce(40) || (0x20|logn) || compressedSig
|
||||
*/
|
||||
export function falconDetachedToEnvelope(detached: Uint8Array, scheme: SchemeId): Uint8Array {
|
||||
const hdr = falconDetachedHeader(scheme);
|
||||
if (detached.length <= 1 + FALCON_NONCE_LEN) {
|
||||
throw new Error(`envelope: falcon detached signature too short (${detached.length})`);
|
||||
}
|
||||
if (detached[0] !== hdr) {
|
||||
throw new Error(
|
||||
`envelope: falcon detached header 0x${detached[0].toString(16)} != expected 0x${hdr.toString(16)}`,
|
||||
);
|
||||
}
|
||||
const nonce = detached.subarray(1, 1 + FALCON_NONCE_LEN);
|
||||
const compressed = detached.subarray(1 + FALCON_NONCE_LEN);
|
||||
const esig = concat(Uint8Array.of(falconEsigHeader(scheme)), compressed);
|
||||
return concat(nonce, esig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link falconDetachedToEnvelope}: recover the noble detached
|
||||
* signature from a contract envelope (for local verification).
|
||||
* envelope = nonce(40) || (0x20|logn) || compressedSig
|
||||
* detached = (0x30|logn) || nonce(40) || compressedSig
|
||||
*/
|
||||
export function falconEnvelopeToDetached(envelope: Uint8Array, scheme: SchemeId): Uint8Array {
|
||||
const esigHdr = falconEsigHeader(scheme);
|
||||
if (envelope.length <= FALCON_NONCE_LEN + 1) {
|
||||
throw new Error(`envelope: falcon envelope too short (${envelope.length})`);
|
||||
}
|
||||
if (envelope[FALCON_NONCE_LEN] !== esigHdr) {
|
||||
throw new Error(
|
||||
`envelope: falcon esig header 0x${envelope[FALCON_NONCE_LEN].toString(16)} != expected 0x${esigHdr.toString(16)}`,
|
||||
);
|
||||
}
|
||||
const nonce = envelope.subarray(0, FALCON_NONCE_LEN);
|
||||
const compressed = envelope.subarray(FALCON_NONCE_LEN + 1);
|
||||
return concat(Uint8Array.of(falconDetachedHeader(scheme)), nonce, compressed);
|
||||
}
|
||||
|
||||
/** ML-DSA-44 envelope is the raw 2420-byte FIPS-204 signature. Asserts length. */
|
||||
export function mldsaEnvelope(sig: Uint8Array): Uint8Array {
|
||||
if (sig.length !== LENGTHS.mldsa44.signature) {
|
||||
throw new Error(`envelope: ML-DSA-44 signature must be ${LENGTHS.mldsa44.signature} bytes, got ${sig.length}`);
|
||||
}
|
||||
return sig;
|
||||
}
|
||||
|
||||
/** SLH-DSA-128s envelope is the raw 7856-byte FIPS-205 signature. Asserts length. */
|
||||
export function slhdsaEnvelope(sig: Uint8Array): Uint8Array {
|
||||
if (sig.length !== LENGTHS.slhdsa128s.signature) {
|
||||
throw new Error(`envelope: SLH-DSA-128s signature must be ${LENGTHS.slhdsa128s.signature} bytes, got ${sig.length}`);
|
||||
}
|
||||
return sig;
|
||||
}
|
||||
|
||||
/** Assert a public key has the length (and, for Falcon, the header byte) its scheme requires. */
|
||||
export function assertPubKey(scheme: SchemeId, pubKey: Uint8Array): void {
|
||||
switch (scheme) {
|
||||
case SCHEME.FALCON512:
|
||||
if (pubKey.length !== LENGTHS.falcon512.pubKey || pubKey[0] !== LENGTHS.falcon512.pkHeader) {
|
||||
throw new Error(`envelope: Falcon-512 pubKey must be ${LENGTHS.falcon512.pubKey} bytes with header 0x09`);
|
||||
}
|
||||
return;
|
||||
case SCHEME.FALCON1024:
|
||||
if (pubKey.length !== LENGTHS.falcon1024.pubKey || pubKey[0] !== LENGTHS.falcon1024.pkHeader) {
|
||||
throw new Error(`envelope: Falcon-1024 pubKey must be ${LENGTHS.falcon1024.pubKey} bytes with header 0x0A`);
|
||||
}
|
||||
return;
|
||||
case SCHEME.MLDSA44:
|
||||
if (pubKey.length !== LENGTHS.mldsa44.pubKey) {
|
||||
throw new Error(`envelope: ML-DSA-44 pubKey must be ${LENGTHS.mldsa44.pubKey} bytes, got ${pubKey.length}`);
|
||||
}
|
||||
return;
|
||||
case SCHEME.SLHDSA128S:
|
||||
if (pubKey.length !== LENGTHS.slhdsa128s.pubKey) {
|
||||
throw new Error(`envelope: SLH-DSA-128s pubKey must be ${LENGTHS.slhdsa128s.pubKey} bytes, got ${pubKey.length}`);
|
||||
}
|
||||
return;
|
||||
default:
|
||||
throw new Error(`envelope: unknown scheme ${scheme}`);
|
||||
}
|
||||
}
|
||||
49
src/pqc/index.ts
Normal file
49
src/pqc/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
// AERE PQC signing surface — the SDK helper for AERE's LIVE native post-quantum
|
||||
// verification precompiles on chain 2800, via the AerePQCAttestation registry.
|
||||
//
|
||||
// Full pure-JS signing (keygen + internal-interface sign + local verify) for all
|
||||
// four schemes, proven interoperable against the live precompiles:
|
||||
// 1 = Falcon-512, 2 = Falcon-1024, 3 = ML-DSA-44, 4 = SLH-DSA-SHA2-128s.
|
||||
|
||||
export {
|
||||
SCHEME, SCHEME_NAME, JS_SIGNABLE, LENGTHS,
|
||||
keygen, signInternal, verifyLocal,
|
||||
type SchemeId, type PqcKeyPair,
|
||||
} from './schemes.js';
|
||||
|
||||
export {
|
||||
FALCON_NONCE_LEN,
|
||||
falconDetachedHeader, falconEsigHeader,
|
||||
falconDetachedToEnvelope, falconEnvelopeToDetached,
|
||||
mldsaEnvelope, slhdsaEnvelope, assertPubKey,
|
||||
} from './envelope.js';
|
||||
|
||||
export {
|
||||
AerePQCClient, AERE_PQC_ATTESTATION_ABI, CHALLENGE_DOMAIN,
|
||||
type AerePQCClientOptions, type RegisteredKey, type AttestationRecord, type SignAndAttestResult,
|
||||
} from './AerePQCClient.js';
|
||||
|
||||
// AerePQCKeyRegistry — permissionless PQC public-key registry with on-chain
|
||||
// proof-of-possession (registerKeyWithPoP reuses the src/pqc signer). Chain 2800.
|
||||
export {
|
||||
AerePQCKeyRegistryClient, AERE_PQC_KEY_REGISTRY_ABI, POP_DOMAIN, KeyStatus,
|
||||
type AerePQCKeyRegistryClientOptions, type KeyRecord,
|
||||
type RegisterKeyPoP, type RegisterKeyResult,
|
||||
} from './AerePQCKeyRegistryClient.js';
|
||||
|
||||
// AereAgentDID — Falcon-rooted AI-agent DID: Falcon-PoP-gated session issuance +
|
||||
// secp256k1 session-key authorization. Chain 2800.
|
||||
export {
|
||||
AereAgentDIDClient, AERE_AGENT_DID_ABI, SESSION_DOMAIN, ACTION_DOMAIN,
|
||||
type AereAgentDIDClientOptions, type AgentRecord, type SessionRecord, type SessionParams,
|
||||
type IssueSessionPoP, type IssueSessionResult, type ActionAuthorization, type AuthorizeResult,
|
||||
} from './AereAgentDIDClient.js';
|
||||
|
||||
// AereAgentActionReceipt — PQC-anchored, publicly verifiable AI provenance: an agent
|
||||
// commits an output hash signed by a DID session key, cross-wired to its AereAgentBond
|
||||
// stake + AereAIReputation score. Chain 2800.
|
||||
export {
|
||||
AereAgentActionReceiptClient, AERE_AGENT_ACTION_RECEIPT_ABI, RECEIPT_DOMAIN,
|
||||
type AereAgentActionReceiptClientOptions, type ReceiptRecord, type ReceiptVerification,
|
||||
type ReceiptProvenance, type ReceiptSignature, type EmitReceiptResult,
|
||||
} from './AereAgentActionReceiptClient.js';
|
||||
131
src/pqc/schemes.ts
Normal file
131
src/pqc/schemes.ts
Normal file
@ -0,0 +1,131 @@
|
||||
// PQC scheme helpers for AERE's live native verification precompiles (chain 2800).
|
||||
//
|
||||
// keygen + internal-interface signing + local verification for each NIST scheme
|
||||
// AerePQCAttestation supports. Signing is pure JS via @noble/post-quantum 0.6.x
|
||||
// (audited). Every helper below has been proven, key-and-signature, against the
|
||||
// LIVE on-chain precompiles: verifySignature(scheme, pubKey, msg, envelope) on
|
||||
// AerePQCAttestation returns true for a signature produced here (see
|
||||
// test/pqc-interop.test.ts).
|
||||
//
|
||||
// CRITICAL INTEROP NOTES (why this matches the precompiles byte-for-byte):
|
||||
// - ML-DSA-44 uses the FIPS-204 INTERNAL interface (ml_dsa44.internal.sign): the
|
||||
// 32-byte message is fed directly as M', with NO 0x00||len(ctx)||ctx external
|
||||
// prefix. The default ml_dsa44.sign (external) would prepend that and NOT match.
|
||||
// - SLH-DSA-128s is SLH-DSA-SHA2-128s (SPHINCS+-SHA2-128s-simple), INTERNAL
|
||||
// interface (slh_dsa_sha2_128s.internal.sign). The SHAKE variant is rejected by
|
||||
// the live precompile (confirmed), so we bind to SHA2 explicitly.
|
||||
// - Falcon-512/1024: noble emits the exact compressed-signature body the
|
||||
// precompile's BouncyCastle verifier accepts; the only transform is the 1-byte
|
||||
// header (0x30|logn detached -> 0x20|logn esig), handled in envelope.ts.
|
||||
|
||||
import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js';
|
||||
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
|
||||
import { falcon512, falcon1024 } from '@noble/post-quantum/falcon.js';
|
||||
import {
|
||||
SCHEME, type SchemeId, LENGTHS,
|
||||
falconDetachedToEnvelope, falconEnvelopeToDetached,
|
||||
mldsaEnvelope, slhdsaEnvelope,
|
||||
} from './envelope.js';
|
||||
|
||||
export { SCHEME, LENGTHS, type SchemeId } from './envelope.js';
|
||||
|
||||
/** Human-readable NIST name for a scheme id. */
|
||||
export const SCHEME_NAME: Record<SchemeId, string> = {
|
||||
[SCHEME.FALCON512]: 'Falcon-512',
|
||||
[SCHEME.FALCON1024]: 'Falcon-1024',
|
||||
[SCHEME.MLDSA44]: 'ML-DSA-44',
|
||||
[SCHEME.SLHDSA128S]: 'SLH-DSA-SHA2-128s',
|
||||
};
|
||||
|
||||
/** True iff every scheme id below has full pure-JS signing in this SDK. */
|
||||
export const JS_SIGNABLE: Record<SchemeId, boolean> = {
|
||||
[SCHEME.FALCON512]: true,
|
||||
[SCHEME.FALCON1024]: true,
|
||||
[SCHEME.MLDSA44]: true,
|
||||
[SCHEME.SLHDSA128S]: true,
|
||||
};
|
||||
|
||||
export interface PqcKeyPair {
|
||||
/** Raw NIST public key for the scheme (pass to registerKey / verifySignature). */
|
||||
publicKey: Uint8Array;
|
||||
/** Raw NIST secret key (keep private; used only by signInternal). */
|
||||
secretKey: Uint8Array;
|
||||
}
|
||||
|
||||
function ensure32(message: Uint8Array): Uint8Array {
|
||||
if (message.length !== 32) {
|
||||
throw new Error(`pqc: signed message must be exactly 32 bytes, got ${message.length}`);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Generate a fresh keypair for `scheme`. Optionally deterministic from a seed. */
|
||||
export function keygen(scheme: SchemeId, seed?: Uint8Array): PqcKeyPair {
|
||||
switch (scheme) {
|
||||
case SCHEME.FALCON512:
|
||||
return falcon512.keygen(seed);
|
||||
case SCHEME.FALCON1024:
|
||||
return falcon1024.keygen(seed);
|
||||
case SCHEME.MLDSA44:
|
||||
return ml_dsa44.keygen(seed);
|
||||
case SCHEME.SLHDSA128S:
|
||||
return slh_dsa_sha2_128s.keygen(seed);
|
||||
default:
|
||||
throw new Error(`pqc: unknown scheme ${scheme}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a 32-byte message with `secretKey` using the scheme's INTERNAL interface
|
||||
* and return the exact `signature` envelope AerePQCAttestation.attest() /
|
||||
* verifySignature() expect (NOT the raw scheme bytes for Falcon — the envelope).
|
||||
*
|
||||
* The message is used verbatim as the signed 32 bytes: for verifySignature() this
|
||||
* is the messageHash; for attest() this is the per-key challenge (build it with
|
||||
* AerePQCClient.deriveChallenge / attestChallenge, then pass it here).
|
||||
*/
|
||||
export function signInternal(scheme: SchemeId, message: Uint8Array, secretKey: Uint8Array): Uint8Array {
|
||||
const m = ensure32(message);
|
||||
switch (scheme) {
|
||||
case SCHEME.FALCON512:
|
||||
return falconDetachedToEnvelope(falcon512.sign(m, secretKey), SCHEME.FALCON512);
|
||||
case SCHEME.FALCON1024:
|
||||
return falconDetachedToEnvelope(falcon1024.sign(m, secretKey), SCHEME.FALCON1024);
|
||||
case SCHEME.MLDSA44:
|
||||
return mldsaEnvelope(ml_dsa44.internal.sign(m, secretKey));
|
||||
case SCHEME.SLHDSA128S:
|
||||
return slhdsaEnvelope(slh_dsa_sha2_128s.internal.sign(m, secretKey));
|
||||
default:
|
||||
throw new Error(`pqc: unknown scheme ${scheme}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an envelope locally (off-chain), mirroring what the live precompile does.
|
||||
* Useful as a fast pre-check before spending gas on attest(). `signature` is the
|
||||
* envelope produced by {@link signInternal}.
|
||||
*/
|
||||
export function verifyLocal(
|
||||
scheme: SchemeId,
|
||||
message: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
publicKey: Uint8Array,
|
||||
): boolean {
|
||||
const m = ensure32(message);
|
||||
try {
|
||||
switch (scheme) {
|
||||
case SCHEME.FALCON512:
|
||||
return falcon512.verify(falconEnvelopeToDetached(signature, SCHEME.FALCON512), m, publicKey);
|
||||
case SCHEME.FALCON1024:
|
||||
return falcon1024.verify(falconEnvelopeToDetached(signature, SCHEME.FALCON1024), m, publicKey);
|
||||
case SCHEME.MLDSA44:
|
||||
return ml_dsa44.internal.verify(signature, m, publicKey);
|
||||
case SCHEME.SLHDSA128S:
|
||||
return slh_dsa_sha2_128s.internal.verify(signature, m, publicKey);
|
||||
default:
|
||||
throw new Error(`pqc: unknown scheme ${scheme}`);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
392
src/state-window.ts
Normal file
392
src/state-window.ts
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* state-window.ts — refuse to answer a state question the node cannot answer.
|
||||
*
|
||||
* WHY THIS FILE EXISTS
|
||||
* --------------------
|
||||
* The public Aere Network RPC endpoints serve a BOUNDED window of historical
|
||||
* world state. Measured on 2026-08-01 against both https://rpc.aere.network and
|
||||
* https://rpc2.aere.network by binary search on depth:
|
||||
*
|
||||
* depth 511 -> answered
|
||||
* depth 512 -> not answered
|
||||
*
|
||||
* At the measured 517 ms block interval that is about 4 minutes 25 seconds of
|
||||
* history. Block bodies, receipts and logs go back to genesis; WORLD STATE does
|
||||
* not. This is normal for a pruning node and it is not a defect.
|
||||
*
|
||||
* The defect is in HOW the node says no. Measured at the same block, on the same
|
||||
* node, in the same second:
|
||||
*
|
||||
* eth_getProof -> error -32000 "World state unavailable" honest
|
||||
* eth_getBalance -> result: null honest
|
||||
* eth_getCode -> result: null honest
|
||||
* eth_getStorageAt -> result: null honest
|
||||
* eth_call -> error -32603 honest
|
||||
* eth_getTransactionCount -> result: "0x0" A LIE
|
||||
*
|
||||
* A nonce of zero is not a missing answer. It is a well formed, correctly typed,
|
||||
* non-null claim that reads "this address has never signed anything". A caller
|
||||
* cannot tell it apart from a brand new account without already knowing the depth
|
||||
* of the window, which is exactly the thing the caller is trying to avoid needing
|
||||
* to know.
|
||||
*
|
||||
* Proof that "0x0" is false rather than merely stale, taken from a block body so
|
||||
* that it does not itself depend on world state:
|
||||
*
|
||||
* block 8,236,382 contains tx
|
||||
* 0xbfd3620dc705cc1dbf5bf747c02f71f0d4f68227ab8b5ccafe3de15955587ce3
|
||||
* from 0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465 with nonce 0x1e0b7 = 123,063
|
||||
*
|
||||
* eth_getTransactionCount(0xbeb33d20…, 0x7dad5e) -> "0x0" on both endpoints
|
||||
*
|
||||
* WHAT THIS MODULE GUARANTEES
|
||||
* ---------------------------
|
||||
* Every read here either returns a value the node actually holds, or throws
|
||||
* StateWindowError. It never returns a placeholder. A tool that cannot measure
|
||||
* must say so; a wrong answer is worse than no answer.
|
||||
*
|
||||
* Three independent guards, because any one of them alone has a hole:
|
||||
*
|
||||
* 1. PRE-FLIGHT. Numeric block tags are compared against the head before the
|
||||
* call is made. Deeper than the window, the call is not even sent.
|
||||
* 2. POST-FLIGHT. A null / "0x" result is treated as "the node does not hold
|
||||
* this", never coerced to zero, empty or false.
|
||||
* 3. CORROBORATION. eth_getTransactionCount is never trusted on a numeric tag
|
||||
* on its own. A companion eth_getBalance is issued at the SAME tag; that
|
||||
* call is honest about missing state, so if it comes back null the nonce is
|
||||
* discarded. This is what turns the lie back into a refusal.
|
||||
*
|
||||
* The window edge is a RACE, not a fence: a read launched at nominal depth 511
|
||||
* has been observed to land outside the window because the head advanced between
|
||||
* eth_blockNumber and the read. Hence SAFETY_MARGIN_BLOCKS.
|
||||
*
|
||||
* Read-only. This module sends no transactions and holds no keys.
|
||||
*/
|
||||
|
||||
/** Measured 2026-08-01 on rpc.aere.network and rpc2.aere.network: 511 answers, 512 does not. */
|
||||
export const DEFAULT_STATE_WINDOW_BLOCKS = 512;
|
||||
|
||||
/**
|
||||
* Blocks held back from the nominal edge. The edge is racy: the head can advance
|
||||
* between reading it and issuing the state call, which is how a nominal depth of
|
||||
* 511 was measured falling outside the window.
|
||||
*/
|
||||
export const DEFAULT_SAFETY_MARGIN_BLOCKS = 8;
|
||||
|
||||
/** Measured 2026-08-01 over 2000 blocks: 0.5175 s. Used only for human-readable messages. */
|
||||
export const MEASURED_BLOCK_INTERVAL_SECONDS = 0.5175;
|
||||
|
||||
export type StateWindowReason =
|
||||
| 'requested-block-outside-window'
|
||||
| 'node-returned-null'
|
||||
| 'nonce-not-corroborated';
|
||||
|
||||
export interface StateWindowErrorDetail {
|
||||
reason: StateWindowReason;
|
||||
method: string;
|
||||
requestedBlock?: number;
|
||||
head?: number;
|
||||
depth?: number;
|
||||
windowBlocks: number;
|
||||
safetyMarginBlocks: number;
|
||||
/** What the node literally returned, when it returned something. Kept so callers can report it. */
|
||||
rawResult?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown instead of returning a placeholder. Catching this and substituting a
|
||||
* zero re-introduces exactly the bug this module exists to remove; if you need a
|
||||
* value for a deep block, use an archive node, not a default.
|
||||
*/
|
||||
export class StateWindowError extends Error {
|
||||
readonly code = 'STATE_OUTSIDE_WINDOW' as const;
|
||||
readonly detail: StateWindowErrorDetail;
|
||||
|
||||
constructor(detail: StateWindowErrorDetail) {
|
||||
super(StateWindowError.describe(detail));
|
||||
this.name = 'StateWindowError';
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
static describe(d: StateWindowErrorDetail): string {
|
||||
const windowSeconds = Math.round(d.windowBlocks * MEASURED_BLOCK_INTERVAL_SECONDS);
|
||||
const where =
|
||||
d.requestedBlock != null && d.head != null
|
||||
? ` Requested block ${d.requestedBlock} at depth ${d.depth} below head ${d.head}.`
|
||||
: '';
|
||||
const why = {
|
||||
'requested-block-outside-window':
|
||||
'The requested block is deeper than the world-state window this endpoint serves.',
|
||||
'node-returned-null':
|
||||
'The node returned no state for the requested block, which means it no longer holds it.',
|
||||
'nonce-not-corroborated':
|
||||
'eth_getTransactionCount returned a value, but a companion eth_getBalance at the same block returned null, ' +
|
||||
'so the node does not hold the state at that height and the nonce is not real. ' +
|
||||
'This endpoint answers 0x0 for a pruned nonce, which is indistinguishable from a never-used account.',
|
||||
}[d.reason];
|
||||
return (
|
||||
`${d.method}: state unavailable, refusing to guess.${where} ` +
|
||||
`This endpoint serves about ${d.windowBlocks} blocks of world state, roughly ${windowSeconds} seconds. ` +
|
||||
`${why} ` +
|
||||
`NOT MEASURED is the correct value here. Block bodies, receipts and logs remain available at any depth.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal JSON-RPC surface. An ethers v6 provider satisfies this via provider.send. */
|
||||
export type JsonRpcSend = (method: string, params: unknown[]) => Promise<unknown>;
|
||||
|
||||
export type BlockTag = number | bigint | 'latest' | 'pending' | 'earliest' | string;
|
||||
|
||||
export interface StateWindowReaderOptions {
|
||||
/** Blocks of world state the endpoint serves. Default 512 (measured). */
|
||||
windowBlocks?: number;
|
||||
/** Blocks held back from the edge to survive the head advancing mid-call. Default 8. */
|
||||
safetyMarginBlocks?: number;
|
||||
/**
|
||||
* Issue a companion eth_getBalance alongside eth_getTransactionCount on numeric
|
||||
* tags. Default true. Turning this off restores the false-zero hazard.
|
||||
*/
|
||||
corroborateNonce?: boolean;
|
||||
}
|
||||
|
||||
function isNumericTag(tag: BlockTag): tag is number | bigint | string {
|
||||
if (typeof tag === 'number' || typeof tag === 'bigint') return true;
|
||||
if (typeof tag !== 'string') return false;
|
||||
return /^0x[0-9a-fA-F]+$/.test(tag) || /^[0-9]+$/.test(tag);
|
||||
}
|
||||
|
||||
function tagToNumber(tag: number | bigint | string): number {
|
||||
if (typeof tag === 'number') return tag;
|
||||
if (typeof tag === 'bigint') return Number(tag);
|
||||
return tag.startsWith('0x') ? Number.parseInt(tag, 16) : Number.parseInt(tag, 10);
|
||||
}
|
||||
|
||||
function toHexTag(tag: BlockTag): string {
|
||||
if (isNumericTag(tag)) return '0x' + tagToNumber(tag).toString(16);
|
||||
return String(tag);
|
||||
}
|
||||
|
||||
/** A result the node gave us that means "I do not hold this", not "the value is zero". */
|
||||
function isAbsent(v: unknown): boolean {
|
||||
return v === null || v === undefined || v === '0x';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads world state, or refuses. Never returns a placeholder.
|
||||
*
|
||||
* @example
|
||||
* const reader = new StateWindowReader((m, p) => provider.send(m, p));
|
||||
* await reader.getTransactionCount(addr); // fine, tag defaults to latest
|
||||
* await reader.getTransactionCount(addr, 8_236_382); // throws StateWindowError
|
||||
*/
|
||||
export class StateWindowReader {
|
||||
readonly windowBlocks: number;
|
||||
readonly safetyMarginBlocks: number;
|
||||
readonly corroborateNonce: boolean;
|
||||
private readonly send: JsonRpcSend;
|
||||
|
||||
constructor(send: JsonRpcSend, opts: StateWindowReaderOptions = {}) {
|
||||
this.send = send;
|
||||
this.windowBlocks = opts.windowBlocks ?? DEFAULT_STATE_WINDOW_BLOCKS;
|
||||
this.safetyMarginBlocks = opts.safetyMarginBlocks ?? DEFAULT_SAFETY_MARGIN_BLOCKS;
|
||||
this.corroborateNonce = opts.corroborateNonce ?? true;
|
||||
}
|
||||
|
||||
/** Build from anything with an ethers-style .send(method, params). */
|
||||
static fromProvider(provider: { send: (m: string, p: unknown[]) => Promise<unknown> }, opts: StateWindowReaderOptions = {}): StateWindowReader {
|
||||
return new StateWindowReader((m, p) => provider.send(m, p), opts);
|
||||
}
|
||||
|
||||
async head(): Promise<number> {
|
||||
const h = await this.send('eth_blockNumber', []);
|
||||
return Number.parseInt(String(h), 16);
|
||||
}
|
||||
|
||||
/** The shallowest block this reader will accept right now. Useful for pinning a run. */
|
||||
async oldestServableBlock(): Promise<number> {
|
||||
const head = await this.head();
|
||||
return head - (this.windowBlocks - this.safetyMarginBlocks) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the tag is servable right now. Symbolic tags are always servable.
|
||||
* Racy by nature; the reader re-checks on every call, so prefer the reads.
|
||||
*/
|
||||
async isWithinWindow(tag: BlockTag): Promise<boolean> {
|
||||
if (!isNumericTag(tag)) return true;
|
||||
const head = await this.head();
|
||||
return head - tagToNumber(tag) < this.windowBlocks - this.safetyMarginBlocks;
|
||||
}
|
||||
|
||||
/** Guard 1: pre-flight. Throws before the call is sent. */
|
||||
private async assertServable(method: string, tag: BlockTag): Promise<void> {
|
||||
if (!isNumericTag(tag)) return;
|
||||
const n = tagToNumber(tag);
|
||||
const head = await this.head();
|
||||
const depth = head - n;
|
||||
if (depth >= this.windowBlocks - this.safetyMarginBlocks) {
|
||||
throw new StateWindowError({
|
||||
reason: 'requested-block-outside-window',
|
||||
method,
|
||||
requestedBlock: n,
|
||||
head,
|
||||
depth,
|
||||
windowBlocks: this.windowBlocks,
|
||||
safetyMarginBlocks: this.safetyMarginBlocks,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Guard 2: post-flight. A null is a refusal, never a zero. */
|
||||
private assertPresent(method: string, tag: BlockTag, raw: unknown): void {
|
||||
if (!isAbsent(raw)) return;
|
||||
throw new StateWindowError({
|
||||
reason: 'node-returned-null',
|
||||
method,
|
||||
requestedBlock: isNumericTag(tag) ? tagToNumber(tag) : undefined,
|
||||
windowBlocks: this.windowBlocks,
|
||||
safetyMarginBlocks: this.safetyMarginBlocks,
|
||||
rawResult: raw,
|
||||
});
|
||||
}
|
||||
|
||||
async getBalance(address: string, tag: BlockTag = 'latest'): Promise<bigint> {
|
||||
await this.assertServable('eth_getBalance', tag);
|
||||
const raw = await this.send('eth_getBalance', [address, toHexTag(tag)]);
|
||||
this.assertPresent('eth_getBalance', tag, raw);
|
||||
return BigInt(String(raw));
|
||||
}
|
||||
|
||||
async getCode(address: string, tag: BlockTag = 'latest'): Promise<string> {
|
||||
await this.assertServable('eth_getCode', tag);
|
||||
const raw = await this.send('eth_getCode', [address, toHexTag(tag)]);
|
||||
// "0x" is a legitimate answer here (an account with no code), so only null counts as absent.
|
||||
if (raw === null || raw === undefined) {
|
||||
this.assertPresent('eth_getCode', tag, raw);
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
async getStorageAt(address: string, slot: string, tag: BlockTag = 'latest'): Promise<string> {
|
||||
await this.assertServable('eth_getStorageAt', tag);
|
||||
const raw = await this.send('eth_getStorageAt', [address, slot, toHexTag(tag)]);
|
||||
this.assertPresent('eth_getStorageAt', tag, raw);
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
async call(tx: Record<string, unknown>, tag: BlockTag = 'latest'): Promise<string> {
|
||||
await this.assertServable('eth_call', tag);
|
||||
const raw = await this.send('eth_call', [tx, toHexTag(tag)]);
|
||||
this.assertPresent('eth_call', tag, raw);
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dangerous one. On a numeric tag this issues a companion eth_getBalance at
|
||||
* the same block and discards the nonce if that companion comes back null,
|
||||
* because this endpoint answers a pruned nonce with 0x0 rather than an error.
|
||||
*
|
||||
* Guard 3. Without it, a caller that happens to race past the pre-flight check
|
||||
* gets a well formed 0 and believes it.
|
||||
*/
|
||||
async getTransactionCount(address: string, tag: BlockTag = 'latest'): Promise<number> {
|
||||
await this.assertServable('eth_getTransactionCount', tag);
|
||||
const raw = await this.send('eth_getTransactionCount', [address, toHexTag(tag)]);
|
||||
this.assertPresent('eth_getTransactionCount', tag, raw);
|
||||
|
||||
if (this.corroborateNonce && isNumericTag(tag)) {
|
||||
const witness = await this.send('eth_getBalance', [address, toHexTag(tag)]);
|
||||
if (isAbsent(witness)) {
|
||||
throw new StateWindowError({
|
||||
reason: 'nonce-not-corroborated',
|
||||
method: 'eth_getTransactionCount',
|
||||
requestedBlock: tagToNumber(tag),
|
||||
windowBlocks: this.windowBlocks,
|
||||
safetyMarginBlocks: this.safetyMarginBlocks,
|
||||
rawResult: raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Number.parseInt(String(raw), 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* The question behind open finding D-001: has this address ever signed anything?
|
||||
*
|
||||
* Answered ONLY at latest, because that is the only height at which this
|
||||
* endpoint's nonce answer is trustworthy. Returns the evidence alongside the
|
||||
* verdict so the caller can quote it.
|
||||
*/
|
||||
async hasEverSigned(address: string): Promise<{
|
||||
signed: boolean;
|
||||
nonce: number;
|
||||
atBlock: number;
|
||||
method: string;
|
||||
}> {
|
||||
const atBlock = await this.head();
|
||||
const nonce = await this.getTransactionCount(address, 'latest');
|
||||
return {
|
||||
signed: nonce > 0,
|
||||
nonce,
|
||||
atBlock,
|
||||
method: 'eth_getTransactionCount at latest; deep tags are refused because this endpoint answers 0x0 for pruned state',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the real window instead of trusting the constant. Binary search on
|
||||
* depth using eth_getBalance, which is honest about missing state. Re-reads the
|
||||
* head at every step so the moving tip does not skew the result.
|
||||
*
|
||||
* Costs about 2 * log2(maxDepth) RPC calls. Read-only.
|
||||
*/
|
||||
async measureWindow(address = '0x0000000000000000000000000000000000000000', maxDepth = 1_000_000): Promise<{
|
||||
head: number;
|
||||
deepestOkDepth: number;
|
||||
firstFailDepth: number;
|
||||
}> {
|
||||
const probe = async (depth: number): Promise<{ ok: boolean; head: number }> => {
|
||||
const head = await this.head();
|
||||
const raw = await this.send('eth_getBalance', [address, '0x' + (head - depth).toString(16)]).catch(() => null);
|
||||
return { ok: !isAbsent(raw), head };
|
||||
};
|
||||
|
||||
let lo = 0;
|
||||
let hi = maxDepth;
|
||||
let head = await this.head();
|
||||
while (lo + 1 < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
const r = await probe(mid);
|
||||
head = r.head;
|
||||
if (r.ok) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return { head, deepestOkDepth: lo, firstFailDepth: hi };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free function for callers that already have a block number and a head and just
|
||||
* want the guard. Throws StateWindowError; returns void on success.
|
||||
*/
|
||||
export function assertStateServable(
|
||||
method: string,
|
||||
requestedBlock: number,
|
||||
head: number,
|
||||
windowBlocks = DEFAULT_STATE_WINDOW_BLOCKS,
|
||||
safetyMarginBlocks = DEFAULT_SAFETY_MARGIN_BLOCKS,
|
||||
): void {
|
||||
const depth = head - requestedBlock;
|
||||
if (depth >= windowBlocks - safetyMarginBlocks) {
|
||||
throw new StateWindowError({
|
||||
reason: 'requested-block-outside-window',
|
||||
method,
|
||||
requestedBlock,
|
||||
head,
|
||||
depth,
|
||||
windowBlocks,
|
||||
safetyMarginBlocks,
|
||||
});
|
||||
}
|
||||
}
|
||||
94
src/test/agent-action-receipt.test.ts
Normal file
94
src/test/agent-action-receipt.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
// Tests for AereAgentActionReceiptClient — PQC-anchored AI provenance.
|
||||
//
|
||||
// OFFLINE only: AereAgentActionReceipt is not yet in the mainnet address book, so there
|
||||
// is no live deployment to read. These tests pin the client's local digest derivation to
|
||||
// a KAT captured directly from the Solidity contract's receiptDigest() view (deployed on
|
||||
// the hardhat network at a deterministic address / chainId), and check that a session-key
|
||||
// signature over that digest recovers to the session address exactly as the contract's
|
||||
// ecrecover would. Because the Solidity side independently asserts receiptDigest ==
|
||||
// keccak256(abi.encode(...)) (see contracts test/AereAgentActionReceipt.test.js
|
||||
// "digest preimage"), matching this KAT proves the SDK encoding agrees with the contract.
|
||||
//
|
||||
// Run: npm run build && node --test dist/test/*.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getAddress, keccak256, toUtf8Bytes, computeAddress, recoverAddress } from 'ethers';
|
||||
import {
|
||||
AereAgentActionReceiptClient, RECEIPT_DOMAIN,
|
||||
} from '../pqc/AereAgentActionReceiptClient.js';
|
||||
|
||||
// ── Frozen KAT captured live from the AereAgentActionReceipt.receiptDigest view ──
|
||||
// (hardhat network: first-deployment address 0x5FbD…0aa3, chainId 31337).
|
||||
const KAT = {
|
||||
address: getAddress('0x5FbDB2315678afecb367f032d93F642f64180aa3'),
|
||||
chainId: 31337,
|
||||
agentId: 3n,
|
||||
sessionId: 5n,
|
||||
outputHash: '0x504366b090b0e2cbbb1a975cbe2558c789b418620876d3589df989a5dc6628af',
|
||||
scope: '0xd7a36943e51c07dfcfbaad7cf952d78d3d3ea8aede2622c66f0736c7a6fdae94',
|
||||
nonce: 7n,
|
||||
digest: '0x74f799f1d9d48b67aa4f0f87deb4795cf27ca9806b70a12dd54bcae8517be6eb',
|
||||
};
|
||||
|
||||
function client() {
|
||||
// A ContractRunner is only needed for on-chain calls; digest derivation is local.
|
||||
return new AereAgentActionReceiptClient({} as never, { address: KAT.address, chainId: KAT.chainId });
|
||||
}
|
||||
|
||||
test('OFFLINE: RECEIPT_DOMAIN matches its keccak256 preimage', () => {
|
||||
assert.equal(RECEIPT_DOMAIN, '0x600b1d777068fcfab8cf4103f2893f284e53c2bfb7ea423be179f47fcfd46c4a');
|
||||
assert.equal(RECEIPT_DOMAIN, keccak256(toUtf8Bytes('AereAgentActionReceipt.v1.receipt')));
|
||||
});
|
||||
|
||||
test('OFFLINE: deriveReceiptDigest matches the frozen on-chain KAT', () => {
|
||||
const c = client();
|
||||
const local = c.deriveReceiptDigest(KAT.agentId, KAT.sessionId, KAT.outputHash, KAT.scope, KAT.nonce);
|
||||
assert.equal(local, KAT.digest);
|
||||
});
|
||||
|
||||
test('OFFLINE: static bondKeyOf mirrors bytes32(rootKeyId)', () => {
|
||||
assert.equal(
|
||||
AereAgentActionReceiptClient.bondKeyOf(7),
|
||||
'0x0000000000000000000000000000000000000000000000000000000000000007',
|
||||
);
|
||||
assert.equal(
|
||||
AereAgentActionReceiptClient.bondKeyOf(0),
|
||||
'0x0000000000000000000000000000000000000000000000000000000000000000',
|
||||
);
|
||||
});
|
||||
|
||||
test('OFFLINE: buildReceiptSignature signs the receipt digest and recovers to the session address', () => {
|
||||
const c = client();
|
||||
const sessionPriv = '0x' + '11'.repeat(32);
|
||||
const sessionAddr = computeAddress(sessionPriv);
|
||||
|
||||
const built = c.buildReceiptSignature(
|
||||
KAT.agentId, KAT.sessionId, KAT.outputHash, KAT.scope, KAT.nonce, sessionPriv,
|
||||
);
|
||||
assert.equal(built.digest, KAT.digest, 'signature is over the receipt digest');
|
||||
assert.equal(built.nonce, KAT.nonce);
|
||||
|
||||
// The contract recovers the raw digest with ecrecover (no personal-message prefix).
|
||||
const recovered = recoverAddress(built.digest, built.signature);
|
||||
assert.equal(getAddress(recovered), getAddress(sessionAddr), 'signature must recover to the session key');
|
||||
|
||||
// 65-byte sig, v in {27,28} for the contract _recover guard.
|
||||
assert.equal((built.signature.length - 2) / 2, 65);
|
||||
const v = parseInt(built.signature.slice(-2), 16);
|
||||
assert.ok(v === 27 || v === 28, 'v must be 27 or 28');
|
||||
});
|
||||
|
||||
test('OFFLINE: a tampered output hash yields a different digest (tamper-evident)', () => {
|
||||
const c = client();
|
||||
const tamperedOutput = keccak256(toUtf8Bytes('a different output'));
|
||||
const digest2 = c.deriveReceiptDigest(KAT.agentId, KAT.sessionId, tamperedOutput, KAT.scope, KAT.nonce);
|
||||
assert.notEqual(digest2, KAT.digest, 'changing the output hash must change the signed digest');
|
||||
});
|
||||
|
||||
test('OFFLINE: constructor requires an explicit address', () => {
|
||||
assert.throws(
|
||||
() => new AereAgentActionReceiptClient({} as never, { address: '' }),
|
||||
/explicit `address` is required/,
|
||||
);
|
||||
});
|
||||
149
src/test/agent-did.test.ts
Normal file
149
src/test/agent-did.test.ts
Normal file
@ -0,0 +1,149 @@
|
||||
// Tests for AereAgentDIDClient (0xce64…22C5) on chain 2800.
|
||||
//
|
||||
// - OFFLINE: domain constants, locally-derived session challenge + action digest
|
||||
// == frozen KATs captured live from the contract views (keccak cross-check), the
|
||||
// Falcon issuance PoP construction path (precompile-valid), and the secp256k1
|
||||
// session-key authorization (recovers to the session address).
|
||||
// - LIVE: sessionCount / keyRegistry / agentExists + local vs on-chain challenge
|
||||
// & digest cross-checks. LIVE asserts SKIP if RPC is down.
|
||||
//
|
||||
// A live issueSession / authorize transaction is NOT attempted: issueSession needs a
|
||||
// created agent rooted in a live Falcon key, and both mutate chain state. The
|
||||
// construction paths (the exact bytes each write would submit) are unit-tested here;
|
||||
// the live write path is deferred to a funded signer + a registered Falcon root key.
|
||||
//
|
||||
// Run: npm run build && node --test dist/test/*.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JsonRpcProvider, getAddress, keccak256, toUtf8Bytes, computeAddress, recoverAddress } from 'ethers';
|
||||
import {
|
||||
AereAgentDIDClient, SESSION_DOMAIN, ACTION_DOMAIN, type SessionParams,
|
||||
} from '../pqc/AereAgentDIDClient.js';
|
||||
import { SCHEME, keygen, verifyLocal } from '../pqc/index.js';
|
||||
|
||||
const RPC = 'https://rpc.aere.network';
|
||||
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||
const did = new AereAgentDIDClient(provider);
|
||||
|
||||
// ── Frozen KAT samples (captured live from the AereAgentDID views) ──
|
||||
const KAT_SESSION: SessionParams = {
|
||||
rootKeyId: 0n,
|
||||
sessionAddr: getAddress('0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3'),
|
||||
scopeHash: '0x' + '22'.repeat(32),
|
||||
spendCap: 1000000n,
|
||||
expiry: 2000000000n,
|
||||
};
|
||||
const KAT_SESSION_NONCE = 5n;
|
||||
const KAT_SESSION_CHALLENGE = '0x764d44f606e5db855e134854f464599a2381df648ef68f4e6ebd20c39bff6fe5';
|
||||
|
||||
const KAT_ACTION = {
|
||||
sessionId: 0n,
|
||||
scope: '0x' + '22'.repeat(32),
|
||||
actionHash: '0x' + '33'.repeat(32),
|
||||
amount: 42n,
|
||||
actionNonce: 7n,
|
||||
};
|
||||
const KAT_ACTION_DIGEST = '0x9f7e02d766a826a1ec7a428673925053df423b2c58e90a079347d7513b252106';
|
||||
|
||||
function isNetworkError(e: unknown): boolean {
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||
}
|
||||
|
||||
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||
console.log(notice);
|
||||
t.skip(notice);
|
||||
return undefined;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── OFFLINE ───────────────────────────────
|
||||
|
||||
test('OFFLINE: SESSION_DOMAIN / ACTION_DOMAIN match their keccak256 preimages', () => {
|
||||
assert.equal(SESSION_DOMAIN, '0x4f931e62100bdfb0fc216124bfd281c0dc571560a1846b227ca3a384c5f75323');
|
||||
assert.equal(SESSION_DOMAIN, keccak256(toUtf8Bytes('AereAgentDID.v1.issueSession')));
|
||||
assert.equal(ACTION_DOMAIN, '0x107d0ecfddfd1223692cd8385f7be7d203565c50d5679fe4b1397cec4e0960a2');
|
||||
assert.equal(ACTION_DOMAIN, keccak256(toUtf8Bytes('AereAgentDID.v1.action')));
|
||||
});
|
||||
|
||||
test('OFFLINE: deriveSessionChallenge matches the frozen on-chain KAT', () => {
|
||||
const local = did.deriveSessionChallenge(KAT_SESSION, KAT_SESSION_NONCE);
|
||||
assert.equal(local, KAT_SESSION_CHALLENGE);
|
||||
});
|
||||
|
||||
test('OFFLINE: deriveActionDigest matches the frozen on-chain KAT', () => {
|
||||
const local = did.deriveActionDigest(
|
||||
KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce,
|
||||
);
|
||||
assert.equal(local, KAT_ACTION_DIGEST);
|
||||
});
|
||||
|
||||
test('OFFLINE: buildIssueSessionPoP produces a Falcon PoP the precompile logic accepts', () => {
|
||||
const { publicKey, secretKey } = keygen(SCHEME.FALCON512);
|
||||
const pop = did.buildIssueSessionPoP(KAT_SESSION, secretKey, SCHEME.FALCON512, KAT_SESSION_NONCE);
|
||||
assert.equal(pop.challenge, KAT_SESSION_CHALLENGE, 'PoP is over the issuance challenge');
|
||||
|
||||
const ok = verifyLocal(SCHEME.FALCON512, Buffer.from(pop.challenge.slice(2), 'hex'), pop.popSig, publicKey);
|
||||
assert.equal(ok, true, 'the Falcon PoP must verify against the issuance challenge');
|
||||
|
||||
// Non-Falcon roots are rejected (an agent DID root is always Falcon).
|
||||
assert.throws(() => did.buildIssueSessionPoP(KAT_SESSION, secretKey, SCHEME.MLDSA44, 0n), /must be a Falcon scheme/);
|
||||
});
|
||||
|
||||
test('OFFLINE: buildAuthorization signs the action digest and recovers to the session address', () => {
|
||||
const sessionPriv = '0x' + '11'.repeat(32);
|
||||
const sessionAddr = computeAddress(sessionPriv);
|
||||
|
||||
const auth = did.buildAuthorization(
|
||||
KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce, sessionPriv,
|
||||
);
|
||||
assert.equal(auth.digest, KAT_ACTION_DIGEST, 'authorization is over the action digest');
|
||||
|
||||
// The contract recovers the raw digest with ecrecover (no personal-message prefix).
|
||||
const recovered = recoverAddress(auth.digest, auth.signature);
|
||||
assert.equal(getAddress(recovered), getAddress(sessionAddr), 'signature must recover to the session key');
|
||||
|
||||
// 65-byte sig, v in {27,28}.
|
||||
assert.equal((auth.signature.length - 2) / 2, 65);
|
||||
const v = parseInt(auth.signature.slice(-2), 16);
|
||||
assert.ok(v === 27 || v === 28, 'v must be 27 or 28 for the contract _recover guard');
|
||||
});
|
||||
|
||||
// ──────────────────────────────── LIVE ─────────────────────────────────
|
||||
|
||||
test('LIVE: sessionCount readable, keyRegistry() wired, agentExists(random) false', async (t) => {
|
||||
const out = await onChain(t, async () => ({
|
||||
count: await did.sessionCount(),
|
||||
registry: await did.keyRegistry(),
|
||||
exists: await did.agentExists(123456789),
|
||||
}));
|
||||
if (out === undefined) return;
|
||||
assert.equal(typeof out.count, 'bigint');
|
||||
assert.equal(getAddress(out.registry), getAddress('0x1eCa3c5ADcBD0b22636D8672b00faC6D89363691'));
|
||||
assert.equal(out.exists, false);
|
||||
});
|
||||
|
||||
test('LIVE: local session challenge & action digest == the on-chain views', async (t) => {
|
||||
const out = await onChain(t, async () => ({
|
||||
sc: await did.sessionChallenge(KAT_SESSION, KAT_SESSION_NONCE),
|
||||
ad: await did.actionDigest(KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce),
|
||||
}));
|
||||
if (out === undefined) return;
|
||||
assert.equal(did.deriveSessionChallenge(KAT_SESSION, KAT_SESSION_NONCE), out.sc);
|
||||
assert.equal(out.sc, KAT_SESSION_CHALLENGE);
|
||||
assert.equal(
|
||||
did.deriveActionDigest(KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce),
|
||||
out.ad,
|
||||
);
|
||||
assert.equal(out.ad, KAT_ACTION_DIGEST);
|
||||
});
|
||||
132
src/test/crypto-agility-live.test.ts
Normal file
132
src/test/crypto-agility-live.test.ts
Normal file
@ -0,0 +1,132 @@
|
||||
// LIVE-CHAIN read tests for AereCryptoRegistry (0xaE6f…baa5) on chain 2800.
|
||||
// Read-only: every call is an eth_call. No transactions are sent.
|
||||
//
|
||||
// Run: npm run build && node --test dist/test/*.js
|
||||
//
|
||||
// If rpc.aere.network is unreachable (offline CI), the network-dependent asserts
|
||||
// SKIP with a clear notice rather than passing silently.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JsonRpcProvider, getAddress } from 'ethers';
|
||||
import { AereCryptoRegistryClient, CryptoStatus } from '../crypto-agility/index.js';
|
||||
|
||||
const RPC = 'https://rpc.aere.network';
|
||||
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||
const registry = new AereCryptoRegistryClient(provider);
|
||||
|
||||
// address(0x0AE1) — the live Falcon-512 precompile.
|
||||
const PRECOMPILE_FALCON512 = getAddress('0x0000000000000000000000000000000000000ae1');
|
||||
|
||||
function isNetworkError(e: unknown): boolean {
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||
}
|
||||
|
||||
/** Run an on-chain call; skip the test on a connectivity failure, rethrow real errors. */
|
||||
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||
console.log(notice);
|
||||
t.skip(notice);
|
||||
return undefined;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
test('LIVE: algorithmCount() == 5 (the seeded live schemes)', async (t) => {
|
||||
const n = await onChain(t, () => registry.algorithmCount());
|
||||
if (n === undefined) return;
|
||||
assert.equal(n, 5n, 'AereCryptoRegistry seeded exactly 5 live schemes');
|
||||
});
|
||||
|
||||
test('LIVE: getAlgorithm rows match the seeded live precompile table', async (t) => {
|
||||
const rows = await onChain(t, () => Promise.all([1, 2, 3, 4, 5].map((i) => registry.getAlgorithm(i))));
|
||||
if (rows === undefined) return;
|
||||
|
||||
const [f512, f1024, mldsa, slhdsa, shake] = rows;
|
||||
|
||||
assert.equal(f512.name, 'Falcon-512');
|
||||
assert.equal(f512.scheme, 1);
|
||||
assert.equal(getAddress(f512.verifier), PRECOMPILE_FALCON512);
|
||||
assert.equal(f512.pubKeyLen, 897);
|
||||
assert.equal(f512.esigHeader, 0x29);
|
||||
assert.equal(f512.isSignature, true);
|
||||
assert.equal(f512.status, CryptoStatus.ACTIVE);
|
||||
|
||||
assert.equal(f1024.name, 'Falcon-1024');
|
||||
assert.equal(f1024.pubKeyLen, 1793);
|
||||
assert.equal(f1024.esigHeader, 0x2a);
|
||||
|
||||
assert.equal(mldsa.name, 'ML-DSA-44');
|
||||
assert.equal(mldsa.sigLen, 2420);
|
||||
assert.equal(mldsa.esigHeader, 0);
|
||||
|
||||
assert.equal(slhdsa.name, 'SLH-DSA-128s');
|
||||
assert.equal(slhdsa.sigLen, 7856);
|
||||
|
||||
// SHAKE256 is a HASH entry, not a signature scheme.
|
||||
assert.equal(shake.name, 'SHAKE256');
|
||||
assert.equal(shake.isSignature, false);
|
||||
});
|
||||
|
||||
test('LIVE: isActive / isUsable / statusOf behave (signature vs hash-only)', async (t) => {
|
||||
const out = await onChain(t, async () => ({
|
||||
active1: await registry.isActive(1),
|
||||
usable1: await registry.isUsable(1),
|
||||
status1: await registry.statusOf(1),
|
||||
// SHAKE256 (id 5) is ACTIVE but hash-only, so NOT usable as a signature scheme.
|
||||
active5: await registry.isActive(5),
|
||||
usable5: await registry.isUsable(5),
|
||||
// an unregistered id is UNKNOWN / not active / not usable.
|
||||
active99: await registry.isActive(99),
|
||||
usable99: await registry.isUsable(99),
|
||||
status99: await registry.statusOf(99),
|
||||
}));
|
||||
if (out === undefined) return;
|
||||
|
||||
assert.equal(out.active1, true);
|
||||
assert.equal(out.usable1, true);
|
||||
assert.equal(out.status1, CryptoStatus.ACTIVE);
|
||||
|
||||
assert.equal(out.active5, true, 'SHAKE256 row is ACTIVE');
|
||||
assert.equal(out.usable5, false, 'SHAKE256 is hash-only, never usable as a signature scheme');
|
||||
|
||||
assert.equal(out.active99, false);
|
||||
assert.equal(out.usable99, false);
|
||||
assert.equal(out.status99, CryptoStatus.UNKNOWN);
|
||||
});
|
||||
|
||||
test('LIVE: resolveActive(1) == 1 and precompileFor(1) == 0x..0ae1', async (t) => {
|
||||
const out = await onChain(t, async () => ({
|
||||
resolved: await registry.resolveActive(1),
|
||||
precompile: await registry.precompileFor(1),
|
||||
seeded: await registry.seeded(),
|
||||
}));
|
||||
if (out === undefined) return;
|
||||
assert.equal(out.resolved, 1n, 'an ACTIVE row resolves to itself');
|
||||
assert.equal(getAddress(out.precompile), PRECOMPILE_FALCON512);
|
||||
assert.equal(out.seeded, true);
|
||||
});
|
||||
|
||||
test('LIVE: verify() is FAIL-CLOSED for garbage / unknown / hash-only inputs', async (t) => {
|
||||
const msg = '0x' + '11'.repeat(32);
|
||||
const out = await onChain(t, async () => ({
|
||||
// unknown algorithmId -> false
|
||||
unknownId: await registry.verify(999, '0x00', msg, '0xdead'),
|
||||
// hash-only entry (SHAKE256) -> refuses to route -> false
|
||||
hashOnly: await registry.verify(5, '0x', msg, '0x'),
|
||||
// valid signature id but wrong-length pubKey + garbage sig -> false
|
||||
garbage: await registry.verify(1, '0x1234', msg, '0xdeadbeef'),
|
||||
}));
|
||||
if (out === undefined) return;
|
||||
assert.equal(out.unknownId, false, 'verify(unknown id) must be false');
|
||||
assert.equal(out.hashOnly, false, 'verify(hash-only id) must be false');
|
||||
assert.equal(out.garbage, false, 'verify(garbage) must be false');
|
||||
});
|
||||
118
src/test/pqc-envelope.test.ts
Normal file
118
src/test/pqc-envelope.test.ts
Normal file
@ -0,0 +1,118 @@
|
||||
// Deterministic, offline unit tests for the PQC envelope builders + challenge
|
||||
// derivation. No network. Run: npm run build && node --test dist/test/*.js
|
||||
//
|
||||
// Covers:
|
||||
// - Falcon envelope byte layout (esig header 0x29 / 0x2A at offset 40) + round-trip.
|
||||
// - ML-DSA-44 / SLH-DSA-128s fixed-length envelope assertions (2420 / 7856).
|
||||
// - assertPubKey length + Falcon header checks.
|
||||
// - JS challenge derivation == the on-chain attestChallenge view (frozen KAT
|
||||
// captured live from AerePQCAttestation on chain 2800).
|
||||
// - sign -> verifyLocal round-trip for all four schemes (real crypto, offline).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JsonRpcProvider } from 'ethers';
|
||||
import {
|
||||
SCHEME, keygen, signInternal, verifyLocal,
|
||||
falconDetachedToEnvelope, falconEnvelopeToDetached, falconEsigHeader, falconDetachedHeader,
|
||||
mldsaEnvelope, slhdsaEnvelope, assertPubKey, FALCON_NONCE_LEN, LENGTHS,
|
||||
AerePQCClient, CHALLENGE_DOMAIN,
|
||||
} from '../pqc/index.js';
|
||||
|
||||
function seq(n: number, start = 0): Uint8Array {
|
||||
const a = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) a[i] = (start + i) & 0xff;
|
||||
return a;
|
||||
}
|
||||
|
||||
test('CHALLENGE_DOMAIN matches keccak256("AerePQCAttestation.v1.challenge")', () => {
|
||||
assert.equal(CHALLENGE_DOMAIN, '0x5b61a780c63cb4dd4519ba570ac6b1fa5c18cb9ceb6280042cce0d953d6b5856');
|
||||
});
|
||||
|
||||
test('Falcon-512 envelope: nonce(40) || 0x29 || compressed, offset-40 header, round-trip', () => {
|
||||
const nonce = seq(FALCON_NONCE_LEN, 1);
|
||||
const compressed = seq(120, 200);
|
||||
const detached = new Uint8Array(1 + nonce.length + compressed.length);
|
||||
detached[0] = falconDetachedHeader(SCHEME.FALCON512); // 0x39
|
||||
detached.set(nonce, 1);
|
||||
detached.set(compressed, 1 + nonce.length);
|
||||
|
||||
const env = falconDetachedToEnvelope(detached, SCHEME.FALCON512);
|
||||
assert.equal(env.length, FALCON_NONCE_LEN + 1 + compressed.length);
|
||||
assert.deepEqual(env.subarray(0, FALCON_NONCE_LEN), nonce, 'nonce preserved at offset 0');
|
||||
assert.equal(env[FALCON_NONCE_LEN], 0x29, 'esig header 0x29 at offset 40');
|
||||
assert.equal(falconEsigHeader(SCHEME.FALCON512), 0x29);
|
||||
assert.deepEqual(env.subarray(FALCON_NONCE_LEN + 1), compressed, 'compressed body preserved');
|
||||
|
||||
// round-trip back to noble detached form
|
||||
const back = falconEnvelopeToDetached(env, SCHEME.FALCON512);
|
||||
assert.deepEqual(back, detached);
|
||||
});
|
||||
|
||||
test('Falcon-1024 envelope: esig header 0x2A at offset 40', () => {
|
||||
const nonce = seq(FALCON_NONCE_LEN, 9);
|
||||
const compressed = seq(300, 7);
|
||||
const detached = new Uint8Array(1 + nonce.length + compressed.length);
|
||||
detached[0] = falconDetachedHeader(SCHEME.FALCON1024); // 0x3A
|
||||
detached.set(nonce, 1);
|
||||
detached.set(compressed, 1 + nonce.length);
|
||||
|
||||
const env = falconDetachedToEnvelope(detached, SCHEME.FALCON1024);
|
||||
assert.equal(env[FALCON_NONCE_LEN], 0x2a, 'esig header 0x2A at offset 40');
|
||||
assert.equal(falconEsigHeader(SCHEME.FALCON1024), 0x2a);
|
||||
assert.deepEqual(falconEnvelopeToDetached(env, SCHEME.FALCON1024), detached);
|
||||
});
|
||||
|
||||
test('Falcon envelope rejects a wrong detached header byte', () => {
|
||||
const bad = new Uint8Array(1 + FALCON_NONCE_LEN + 10);
|
||||
bad[0] = 0x00; // not 0x39
|
||||
assert.throws(() => falconDetachedToEnvelope(bad, SCHEME.FALCON512), /detached header/);
|
||||
});
|
||||
|
||||
test('ML-DSA-44 envelope asserts exactly 2420 bytes', () => {
|
||||
assert.equal(mldsaEnvelope(seq(2420)).length, 2420);
|
||||
assert.throws(() => mldsaEnvelope(seq(2419)), /2420 bytes/);
|
||||
assert.throws(() => mldsaEnvelope(seq(2421)), /2420 bytes/);
|
||||
});
|
||||
|
||||
test('SLH-DSA-128s envelope asserts exactly 7856 bytes', () => {
|
||||
assert.equal(slhdsaEnvelope(seq(7856)).length, 7856);
|
||||
assert.throws(() => slhdsaEnvelope(seq(7855)), /7856 bytes/);
|
||||
});
|
||||
|
||||
test('assertPubKey enforces per-scheme length + Falcon header', () => {
|
||||
const f512 = new Uint8Array(LENGTHS.falcon512.pubKey);
|
||||
f512[0] = 0x09;
|
||||
assert.doesNotThrow(() => assertPubKey(SCHEME.FALCON512, f512));
|
||||
f512[0] = 0x00;
|
||||
assert.throws(() => assertPubKey(SCHEME.FALCON512, f512), /header/);
|
||||
|
||||
const f1024 = new Uint8Array(LENGTHS.falcon1024.pubKey);
|
||||
f1024[0] = 0x0a;
|
||||
assert.doesNotThrow(() => assertPubKey(SCHEME.FALCON1024, f1024));
|
||||
|
||||
assert.doesNotThrow(() => assertPubKey(SCHEME.MLDSA44, new Uint8Array(1312)));
|
||||
assert.throws(() => assertPubKey(SCHEME.MLDSA44, new Uint8Array(1311)), /1312 bytes/);
|
||||
assert.doesNotThrow(() => assertPubKey(SCHEME.SLHDSA128S, new Uint8Array(32)));
|
||||
assert.throws(() => assertPubKey(SCHEME.SLHDSA128S, new Uint8Array(31)), /32 bytes/);
|
||||
});
|
||||
|
||||
test('deriveChallenge matches the on-chain attestChallenge KAT (frozen from chain 2800)', () => {
|
||||
// KAT captured live from AerePQCAttestation.attestChallenge(5, 9, 0x11..11).
|
||||
const client = new AerePQCClient(new JsonRpcProvider('https://rpc.aere.network')); // constructed, never called
|
||||
const messageHash = '0x' + '11'.repeat(32);
|
||||
const challenge = client.deriveChallenge(5, 9, messageHash);
|
||||
assert.equal(challenge, '0xd5c63545430ca962420f8fa3ba4167131dd9cc2fcef28040ff5d505556f281a2');
|
||||
});
|
||||
|
||||
test('sign -> verifyLocal round-trips offline for all four schemes', () => {
|
||||
const message = seq(32, 42);
|
||||
for (const scheme of [SCHEME.FALCON512, SCHEME.FALCON1024, SCHEME.MLDSA44, SCHEME.SLHDSA128S] as const) {
|
||||
const { publicKey, secretKey } = keygen(scheme);
|
||||
const env = signInternal(scheme, message, secretKey);
|
||||
assert.equal(verifyLocal(scheme, message, env, publicKey), true, `verifyLocal true for scheme ${scheme}`);
|
||||
// tamper the message -> must fail
|
||||
const bad = seq(32, 43);
|
||||
assert.equal(verifyLocal(scheme, bad, env, publicKey), false, `verifyLocal false on wrong msg, scheme ${scheme}`);
|
||||
}
|
||||
});
|
||||
78
src/test/pqc-interop.test.ts
Normal file
78
src/test/pqc-interop.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
// LIVE-CHAIN interop tests: prove that a signature produced by this SDK is
|
||||
// accepted by AERE's native PQC precompiles, via AerePQCAttestation.verifySignature
|
||||
// (a free eth_call) on chain 2800. No transactions are sent; no chain state changes.
|
||||
//
|
||||
// Run: npm run build && node --test dist/test/*.js
|
||||
//
|
||||
// If rpc.aere.network is unreachable (offline CI), each test SKIPS with a clear
|
||||
// notice rather than passing silently.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JsonRpcProvider, randomBytes, hexlify, getBytes } from 'ethers';
|
||||
import { AerePQCClient, SCHEME, SCHEME_NAME, keygen, signInternal, type SchemeId } from '../pqc/index.js';
|
||||
|
||||
const RPC = 'https://rpc.aere.network';
|
||||
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||
const client = new AerePQCClient(provider);
|
||||
|
||||
function isNetworkError(e: unknown): boolean {
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||
}
|
||||
|
||||
/** Run an on-chain call; skip the test on a connectivity failure, rethrow real errors. */
|
||||
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||
console.log(notice);
|
||||
t.skip(notice);
|
||||
return undefined;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
for (const scheme of [SCHEME.FALCON512, SCHEME.FALCON1024, SCHEME.MLDSA44, SCHEME.SLHDSA128S] as const) {
|
||||
test(`${SCHEME_NAME[scheme]} (scheme ${scheme}): JS-signed sig accepted by LIVE precompile`, async (t) => {
|
||||
const { publicKey, secretKey } = keygen(scheme);
|
||||
const messageHash = hexlify(randomBytes(32));
|
||||
const envelope = signInternal(scheme, getBytes(messageHash), secretKey);
|
||||
|
||||
const ok = await onChain(t, () => client.verifySignature(scheme as SchemeId, publicKey, messageHash, envelope));
|
||||
if (ok === undefined) return; // skipped
|
||||
assert.equal(ok, true, `LIVE verifySignature(${scheme}) must be true for a genuine SDK signature`);
|
||||
|
||||
// Negative control: a signature over a different message must be rejected on-chain.
|
||||
const otherHash = hexlify(randomBytes(32));
|
||||
const bad = await onChain(t, () => client.verifySignature(scheme as SchemeId, publicKey, otherHash, envelope));
|
||||
if (bad === undefined) return;
|
||||
assert.equal(bad, false, 'LIVE verifySignature must reject a signature over a different message');
|
||||
});
|
||||
}
|
||||
|
||||
test('signAndAttest path: sign the on-chain challenge, precompile accepts it (no tx sent)', async (t) => {
|
||||
// Exercises the exact bytes signAndAttest() would submit, but via the free view.
|
||||
const scheme = SCHEME.MLDSA44;
|
||||
const { publicKey, secretKey } = keygen(scheme);
|
||||
const keyId = 0n;
|
||||
const messageHash = hexlify(randomBytes(32));
|
||||
|
||||
// Cross-check local vs on-chain challenge derivation.
|
||||
const nonce = 0n; // fresh key would have nonce 0; attestChallenge is pure over its args.
|
||||
const local = client.deriveChallenge(keyId, nonce, messageHash);
|
||||
const chain = await onChain(t, () => client.attestChallenge(keyId, nonce, messageHash));
|
||||
if (chain === undefined) return;
|
||||
assert.equal(local, chain, 'local deriveChallenge must equal on-chain attestChallenge');
|
||||
|
||||
// Sign the challenge and verify the envelope the precompile would see in attest().
|
||||
const envelope = signInternal(scheme, getBytes(local), secretKey);
|
||||
const ok = await onChain(t, () => client.verifySignature(scheme, publicKey, local, envelope));
|
||||
if (ok === undefined) return;
|
||||
assert.equal(ok, true, 'precompile must accept a signature over the derived challenge');
|
||||
});
|
||||
139
src/test/pqc-keyregistry.test.ts
Normal file
139
src/test/pqc-keyregistry.test.ts
Normal file
@ -0,0 +1,139 @@
|
||||
// Tests for AerePQCKeyRegistryClient (0x1eCa…3691) on chain 2800.
|
||||
//
|
||||
// - OFFLINE: PoP domain constant + locally-derived PoP challenge == a frozen KAT
|
||||
// captured live from the contract's popChallenge view (keccak cross-check), and
|
||||
// the construction path (buildRegisterKeyPoP) produces a precompile-valid PoP.
|
||||
// - LIVE: keyCount / precompileFor / verify fail-closed + local PoP challenge ==
|
||||
// the on-chain popChallenge view for a sample. LIVE asserts SKIP if RPC is down.
|
||||
//
|
||||
// A live registerKey transaction is NOT attempted: it needs a funded signer and
|
||||
// mutates chain state. The construction path (the exact bytes registerKey would
|
||||
// submit) is unit-tested here and proven precompile-valid via the free verify view;
|
||||
// the live write path is deferred to a funded signer.
|
||||
//
|
||||
// Run: npm run build && node --test dist/test/*.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { JsonRpcProvider, getAddress, keccak256, toUtf8Bytes } from 'ethers';
|
||||
import { AerePQCKeyRegistryClient, POP_DOMAIN } from '../pqc/AerePQCKeyRegistryClient.js';
|
||||
import { SCHEME, keygen, verifyLocal } from '../pqc/index.js';
|
||||
|
||||
const RPC = 'https://rpc.aere.network';
|
||||
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||
const registry = new AerePQCKeyRegistryClient(provider);
|
||||
|
||||
const PRECOMPILE_FALCON512 = getAddress('0x0000000000000000000000000000000000000ae1');
|
||||
|
||||
// ── Frozen KAT sample (captured live from AerePQCKeyRegistry.popChallenge) ──
|
||||
// owner = Foundation, scheme = Falcon-512, nonce = 3, and a DETERMINISTIC fixed
|
||||
// pubKey pk[i] = (i*7+3) & 0xff with pk[0] = 0x09. popChallenge only keccaks the
|
||||
// pubKey bytes (no length check), so the KAT is fully reproducible offline.
|
||||
const KAT_OWNER = getAddress('0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3');
|
||||
const KAT_NONCE = 3n;
|
||||
const KAT_POP_CHALLENGE = '0x302d918e26c3b202cda2525acddfcc728abb40f52cef994a1eea84206cf390eb';
|
||||
|
||||
function katPubKey(): Uint8Array {
|
||||
const pk = new Uint8Array(897);
|
||||
for (let i = 0; i < 897; i++) pk[i] = (i * 7 + 3) & 0xff;
|
||||
pk[0] = 0x09;
|
||||
return pk;
|
||||
}
|
||||
|
||||
function isNetworkError(e: unknown): boolean {
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||
}
|
||||
|
||||
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||
console.log(notice);
|
||||
t.skip(notice);
|
||||
return undefined;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── OFFLINE ───────────────────────────────
|
||||
|
||||
test('OFFLINE: POP_DOMAIN == keccak256("AerePQCKeyRegistry.v1.pop")', () => {
|
||||
assert.equal(POP_DOMAIN, '0x04cc1fb241e197c3eb1534f0ee330640805821be948c47fa7704336db109be91');
|
||||
assert.equal(POP_DOMAIN, keccak256(toUtf8Bytes('AerePQCKeyRegistry.v1.pop')));
|
||||
});
|
||||
|
||||
test('OFFLINE: derivePopChallenge matches the frozen on-chain popChallenge KAT', () => {
|
||||
const local = registry.derivePopChallenge(KAT_OWNER, SCHEME.FALCON512, katPubKey(), KAT_NONCE);
|
||||
assert.equal(local, KAT_POP_CHALLENGE, 'local PoP challenge must equal the chain-captured KAT');
|
||||
});
|
||||
|
||||
test('OFFLINE: buildRegisterKeyPoP produces a PoP the precompile logic accepts (verifyLocal)', () => {
|
||||
// A real keypair; sign the PoP challenge for this owner/nonce and verify the
|
||||
// envelope off-chain (mirror of what registerKey does on-chain via the precompile).
|
||||
const { publicKey, secretKey } = keygen(SCHEME.FALCON512);
|
||||
const owner = getAddress('0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3');
|
||||
const nonce = 0n;
|
||||
|
||||
const pop = registry.buildRegisterKeyPoP(owner, SCHEME.FALCON512, secretKey, publicKey, nonce);
|
||||
assert.equal(pop.challenge, registry.derivePopChallenge(owner, SCHEME.FALCON512, publicKey, nonce));
|
||||
|
||||
// The signature is a genuine Falcon sig over the exact 32-byte PoP challenge.
|
||||
const ok = verifyLocal(SCHEME.FALCON512, Buffer.from(pop.challenge.slice(2), 'hex'), pop.signature, publicKey);
|
||||
assert.equal(ok, true, 'PoP envelope must verify against the challenge it was built for');
|
||||
|
||||
// Negative control: it must NOT verify against a different nonce's challenge.
|
||||
const otherChallenge = registry.derivePopChallenge(owner, SCHEME.FALCON512, publicKey, 1n);
|
||||
const bad = verifyLocal(SCHEME.FALCON512, Buffer.from(otherChallenge.slice(2), 'hex'), pop.signature, publicKey);
|
||||
assert.equal(bad, false, 'PoP must be bound to its nonce (replay-proof)');
|
||||
});
|
||||
|
||||
test('OFFLINE: buildRegisterKeyPoP rejects a malformed public key', () => {
|
||||
const { secretKey } = keygen(SCHEME.FALCON512);
|
||||
const badPk = new Uint8Array(10); // wrong length + wrong header
|
||||
assert.throws(
|
||||
() => registry.buildRegisterKeyPoP(KAT_OWNER, SCHEME.FALCON512, secretKey, badPk, 0n),
|
||||
/Falcon-512 pubKey/,
|
||||
);
|
||||
});
|
||||
|
||||
// ──────────────────────────────── LIVE ─────────────────────────────────
|
||||
|
||||
test('LIVE: keyCount() is readable and precompileFor(1..4) route to 0x0AE1..0x0AE4', async (t) => {
|
||||
const out = await onChain(t, async () => ({
|
||||
count: await registry.keyCount(),
|
||||
p1: await registry.precompileFor(SCHEME.FALCON512),
|
||||
p2: await registry.precompileFor(SCHEME.FALCON1024),
|
||||
p3: await registry.precompileFor(SCHEME.MLDSA44),
|
||||
p4: await registry.precompileFor(SCHEME.SLHDSA128S),
|
||||
}));
|
||||
if (out === undefined) return;
|
||||
assert.equal(typeof out.count, 'bigint');
|
||||
assert.ok(out.count >= 0n);
|
||||
assert.equal(getAddress(out.p1), PRECOMPILE_FALCON512);
|
||||
assert.equal(getAddress(out.p2), getAddress('0x0000000000000000000000000000000000000ae2'));
|
||||
assert.equal(getAddress(out.p3), getAddress('0x0000000000000000000000000000000000000ae3'));
|
||||
assert.equal(getAddress(out.p4), getAddress('0x0000000000000000000000000000000000000ae4'));
|
||||
});
|
||||
|
||||
test('LIVE: derivePopChallenge == on-chain popChallenge for the sample (keccak cross-check)', async (t) => {
|
||||
const pk = katPubKey();
|
||||
const chain = await onChain(t, () => registry.popChallenge(KAT_OWNER, SCHEME.FALCON512, pk, KAT_NONCE));
|
||||
if (chain === undefined) return;
|
||||
const local = registry.derivePopChallenge(KAT_OWNER, SCHEME.FALCON512, pk, KAT_NONCE);
|
||||
assert.equal(local, chain, 'local derivation must equal the on-chain popChallenge view');
|
||||
assert.equal(chain, KAT_POP_CHALLENGE, 'the on-chain value must still equal the frozen KAT');
|
||||
});
|
||||
|
||||
test('LIVE: verify() is fail-closed for a garbage signature', async (t) => {
|
||||
const { publicKey } = keygen(SCHEME.FALCON512);
|
||||
const msg = '0x' + '11'.repeat(32);
|
||||
const bad = await onChain(t, () => registry.verify(SCHEME.FALCON512, publicKey, msg, '0x' + '00'.repeat(50)));
|
||||
if (bad === undefined) return;
|
||||
assert.equal(bad, false, 'a garbage signature must fail the live precompile check');
|
||||
});
|
||||
112
src/test/pqc-order.test.ts
Normal file
112
src/test/pqc-order.test.ts
Normal file
@ -0,0 +1,112 @@
|
||||
// Unit tests for the PQC order-builder (AerePQCOrderBuilder).
|
||||
//
|
||||
// Pure / offline: no chain. Proves the builder produces a signature that verifies
|
||||
// LOCALLY over its own EIP-712 order digest (mirroring what the live precompile does),
|
||||
// that tampering breaks it, and that the digest is deterministic and field-sensitive.
|
||||
// The byte-for-byte agreement with the on-chain AerePQCOrderAuthorizer.orderDigest and
|
||||
// the end-to-end fill through the real Falcon verifier are proven in the contracts suite
|
||||
// (aerenew/contracts/test/pqc-intents.test.js).
|
||||
//
|
||||
// Run: npm run build && node --test dist/test/*.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getBytes, keccak256, toUtf8Bytes } from 'ethers';
|
||||
import { verifyLocal, SCHEME } from '../pqc/index.js';
|
||||
import {
|
||||
buildAndSignPQCOrder, buildPQCGaslessOrder, pqcOrderDigest, generateOrderKey,
|
||||
encodeAereV3OrderData, AERE_ORDER_DATA_TYPE, PQC_SCHEME,
|
||||
} from '../intents/index.js';
|
||||
|
||||
const SETTLER = '0xCAB1DBA5f6F06198000C20a974d675f1B3181AbD';
|
||||
const CHAIN = 2800n;
|
||||
const USER = '0x000000000000000000000000000000000000d00d';
|
||||
const TOKEN_IN = '0x0000000000000000000000000000000000000a11';
|
||||
const TOKEN_OUT = '0x0000000000000000000000000000000000000b22';
|
||||
const RECIPIENT = '0x0000000000000000000000000000000000000c33';
|
||||
|
||||
function seed48(tag: string): Uint8Array {
|
||||
return getBytes(keccak256(toUtf8Bytes(tag)) + keccak256(toUtf8Bytes(tag + '-2')).slice(2)).slice(0, 48);
|
||||
}
|
||||
function seed32(tag: string): Uint8Array {
|
||||
return getBytes(keccak256(toUtf8Bytes(tag))); // 32 bytes
|
||||
}
|
||||
|
||||
function baseParams(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
user: USER,
|
||||
nonce: 0,
|
||||
originChainId: CHAIN,
|
||||
openDeadline: 2_000_000_000,
|
||||
fillDeadline: 2_000_003_600,
|
||||
algorithmId: 1,
|
||||
order: {
|
||||
inputToken: TOKEN_IN,
|
||||
inputAmount: 1_000_000n,
|
||||
outputToken: TOKEN_OUT,
|
||||
outputAmount: 999_000n,
|
||||
recipient: RECIPIENT,
|
||||
destinationChainId: 1n,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('orderData uses the AereV3Order discriminator', () => {
|
||||
assert.equal(AERE_ORDER_DATA_TYPE, keccak256(toUtf8Bytes('AereV3Order')));
|
||||
const order = buildPQCGaslessOrder(baseParams());
|
||||
assert.equal(order.orderDataType, AERE_ORDER_DATA_TYPE);
|
||||
// orderData round-trips the same encoder.
|
||||
assert.equal(order.orderData, encodeAereV3OrderData(baseParams().order));
|
||||
});
|
||||
|
||||
test('Falcon-512: a built+signed order verifies locally over its own digest', () => {
|
||||
const kp = generateOrderKey(PQC_SCHEME.FALCON512, seed48('order-falcon'));
|
||||
const signed = buildAndSignPQCOrder({
|
||||
settler: SETTLER, chainId: CHAIN, scheme: SCHEME.FALCON512,
|
||||
publicKey: kp.publicKey, secretKey: kp.secretKey, params: baseParams(),
|
||||
});
|
||||
assert.equal(signed.scheme, SCHEME.FALCON512);
|
||||
// The digest the builder signed equals a fresh recomputation.
|
||||
assert.equal(signed.digest, pqcOrderDigest(SETTLER, CHAIN, signed.order, signed.pubKey));
|
||||
// The signature verifies locally over that digest.
|
||||
assert.equal(verifyLocal(SCHEME.FALCON512, getBytes(signed.digest), getBytes(signed.signature), kp.publicKey), true);
|
||||
});
|
||||
|
||||
test('ML-DSA-44: a built+signed order verifies locally over its own digest', () => {
|
||||
const kp = generateOrderKey(PQC_SCHEME.MLDSA44, seed32('order-mldsa'));
|
||||
const signed = buildAndSignPQCOrder({
|
||||
settler: SETTLER, chainId: CHAIN, scheme: SCHEME.MLDSA44,
|
||||
publicKey: kp.publicKey, secretKey: kp.secretKey, params: baseParams({ algorithmId: 3 }),
|
||||
});
|
||||
assert.equal(verifyLocal(SCHEME.MLDSA44, getBytes(signed.digest), getBytes(signed.signature), kp.publicKey), true);
|
||||
});
|
||||
|
||||
test('a tampered signature fails local verification', () => {
|
||||
const kp = generateOrderKey(PQC_SCHEME.FALCON512, seed48('order-tamper'));
|
||||
const signed = buildAndSignPQCOrder({
|
||||
settler: SETTLER, chainId: CHAIN, scheme: SCHEME.FALCON512,
|
||||
publicKey: kp.publicKey, secretKey: kp.secretKey, params: baseParams(),
|
||||
});
|
||||
const tampered = getBytes(signed.signature);
|
||||
tampered[60] ^= 0x01; // flip a bit deep in the compressed body
|
||||
assert.equal(verifyLocal(SCHEME.FALCON512, getBytes(signed.digest), tampered, kp.publicKey), false);
|
||||
});
|
||||
|
||||
test('the digest is deterministic and field-sensitive', () => {
|
||||
const kp = generateOrderKey(PQC_SCHEME.FALCON512, seed48('order-digest'));
|
||||
const a = buildPQCGaslessOrder(baseParams());
|
||||
const b = buildPQCGaslessOrder(baseParams());
|
||||
const dA = pqcOrderDigest(SETTLER, CHAIN, a, kp.publicKey);
|
||||
const dB = pqcOrderDigest(SETTLER, CHAIN, b, kp.publicKey);
|
||||
assert.equal(dA, dB); // deterministic
|
||||
|
||||
// A different nonce changes the digest.
|
||||
const c = buildPQCGaslessOrder(baseParams({ nonce: 1 }));
|
||||
assert.notEqual(pqcOrderDigest(SETTLER, CHAIN, c, kp.publicKey), dA);
|
||||
// A different settler changes the digest (per-pool domain separation).
|
||||
assert.notEqual(pqcOrderDigest('0x000000000000000000000000000000000000dEaD', CHAIN, a, kp.publicKey), dA);
|
||||
// A different amount changes the digest (payload binding).
|
||||
const e = buildPQCGaslessOrder(baseParams({ order: { ...baseParams().order, inputAmount: 2_000_000n } }));
|
||||
assert.notEqual(pqcOrderDigest(SETTLER, CHAIN, e, kp.publicKey), dA);
|
||||
});
|
||||
178
src/test/state-window-live.ts
Normal file
178
src/test/state-window-live.ts
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* state-window-live.ts — prove StateWindowReader against the REAL endpoint.
|
||||
*
|
||||
* The unit tests in state-window.test.ts run against a fake node. A fake node
|
||||
* proves the code does what its author expected; it does not prove the endpoint
|
||||
* still behaves the way the author measured. This file closes that gap by
|
||||
* pointing the guard at production and asserting on what comes back.
|
||||
*
|
||||
* It is deliberately NOT part of `npm test`: it needs the network, and a test
|
||||
* suite that fails when the internet is down teaches people to ignore red.
|
||||
*
|
||||
* node dist/test/state-window-live.js # rpc.aere.network
|
||||
* node dist/test/state-window-live.js https://rpc2.aere.network
|
||||
*
|
||||
* Exit 0 = the guard held and the hazard is still real.
|
||||
* Exit 1 = something changed. Read the output; do not silence it.
|
||||
*
|
||||
* Structure, in this order, because a guard that has never been shown to fail
|
||||
* cannot be trusted:
|
||||
*
|
||||
* STEP 1 PLANTED FAILURE. The raw call really does return a false zero.
|
||||
* STEP 2 NEGATIVE CONTROL. With the guards disabled, the reader hands that
|
||||
* false zero straight to the caller.
|
||||
* STEP 3 THE GUARD. With the guards on, the reader refuses.
|
||||
*
|
||||
* If step 1 or step 2 ever stops failing, this file must be revisited: either
|
||||
* the endpoint was fixed, or the measurement it rests on has gone stale.
|
||||
* Read-only. No keys, no transactions.
|
||||
*/
|
||||
import { StateWindowReader, StateWindowError } from '../state-window.js';
|
||||
|
||||
const RPC = process.argv[2] ?? 'https://rpc.aere.network';
|
||||
|
||||
/** Measured: this address signed with nonce 123,063 in block 8,236,382. */
|
||||
const SIGNER = '0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465';
|
||||
const TX_HASH = '0xbfd3620dc705cc1dbf5bf747c02f71f0d4f68227ab8b5ccafe3de15955587ce3';
|
||||
|
||||
let failures = 0;
|
||||
const ok = (label: string, detail: string) => console.log(` PASS ${label}\n ${detail}`);
|
||||
const bad = (label: string, detail: string) => {
|
||||
failures++;
|
||||
console.log(` FAIL ${label}\n ${detail}`);
|
||||
};
|
||||
|
||||
async function send(method: string, params: unknown[]): Promise<unknown> {
|
||||
const res = await fetch(RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
});
|
||||
const body = (await res.json()) as { result?: unknown; error?: { code: number; message: string } };
|
||||
if (body.error) throw new Error(`${body.error.code} ${body.error.message}`);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log(`state-window live proof against ${RPC}`);
|
||||
console.log(`run at ${new Date().toISOString()}\n`);
|
||||
|
||||
// Anchor the truth in a block BODY, which is retained at any depth and so does
|
||||
// not itself depend on the world state we are testing.
|
||||
const tx = (await send('eth_getTransactionByHash', [TX_HASH])) as {
|
||||
from: string; nonce: string; blockNumber: string;
|
||||
} | null;
|
||||
if (!tx) {
|
||||
bad('anchor', `the endpoint does not know transaction ${TX_HASH}; cannot proceed`);
|
||||
process.exit(1);
|
||||
}
|
||||
const deepBlock = Number.parseInt(tx.blockNumber, 16);
|
||||
const trueNonce = Number.parseInt(tx.nonce, 16);
|
||||
const tag = tx.blockNumber;
|
||||
console.log(`ANCHOR block ${deepBlock} body says ${tx.from} signed with nonce ${trueNonce}`);
|
||||
if (tx.from.toLowerCase() !== SIGNER) bad('anchor', `expected ${SIGNER}, block body says ${tx.from}`);
|
||||
console.log('');
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
console.log('STEP 1 PLANTED FAILURE: the raw call must still lie');
|
||||
// -------------------------------------------------------------------------
|
||||
const rawNonce = await send('eth_getTransactionCount', [SIGNER, tag]);
|
||||
if (rawNonce === '0x0') {
|
||||
ok('eth_getTransactionCount returns a false zero',
|
||||
`result "0x0" at block ${deepBlock}, where the block body proves ${trueNonce}`);
|
||||
} else {
|
||||
bad('eth_getTransactionCount returns a false zero',
|
||||
`expected "0x0", got ${JSON.stringify(rawNonce)}. If this endpoint now answers honestly, ` +
|
||||
`the published documentation must be updated, not this assertion.`);
|
||||
}
|
||||
|
||||
const rawBalance = await send('eth_getBalance', [SIGNER, tag]);
|
||||
if (rawBalance === null) {
|
||||
ok('eth_getBalance is honest at the same block', 'result null, which is a refusal and not a value');
|
||||
} else {
|
||||
bad('eth_getBalance is honest at the same block', `expected null, got ${JSON.stringify(rawBalance)}`);
|
||||
}
|
||||
|
||||
await send('eth_getProof', [SIGNER, [], tag]).then(
|
||||
() => bad('eth_getProof is honest at the same block', 'it answered; the state window may have changed'),
|
||||
(e: Error) => ok('eth_getProof is honest at the same block', `error ${e.message}`),
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
console.log('STEP 2 NEGATIVE CONTROL: with the guards off, the lie gets through');
|
||||
// -------------------------------------------------------------------------
|
||||
const unguarded = new StateWindowReader(send, {
|
||||
windowBlocks: 100_000_000, safetyMarginBlocks: 0, corroborateNonce: false,
|
||||
});
|
||||
const leaked = await unguarded.getTransactionCount(SIGNER, deepBlock);
|
||||
if (leaked === 0) {
|
||||
ok('the disabled guard returns 0', `getTransactionCount answered ${leaked}, which is wrong by ${trueNonce}`);
|
||||
} else {
|
||||
bad('the disabled guard returns 0', `expected 0, got ${leaked}; the negative control no longer controls anything`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
console.log('STEP 3 THE GUARD: it refuses instead of guessing');
|
||||
// -------------------------------------------------------------------------
|
||||
const reader = new StateWindowReader(send);
|
||||
|
||||
await reader.getTransactionCount(SIGNER, deepBlock).then(
|
||||
(v) => bad('pre-flight refuses the deep nonce', `expected a throw, got ${v}`),
|
||||
(e: unknown) =>
|
||||
e instanceof StateWindowError && e.detail.reason === 'requested-block-outside-window'
|
||||
? ok('pre-flight refuses the deep nonce', `StateWindowError ${e.detail.reason}, depth ${e.detail.depth}`)
|
||||
: bad('pre-flight refuses the deep nonce', `wrong error: ${e}`),
|
||||
);
|
||||
|
||||
// Corroboration is the guard that survives a caller who races past pre-flight.
|
||||
const racedPast = new StateWindowReader(send, { windowBlocks: 100_000_000, safetyMarginBlocks: 0 });
|
||||
await racedPast.getTransactionCount(SIGNER, deepBlock).then(
|
||||
(v) => bad('corroboration catches what pre-flight missed', `expected a throw, got ${v}`),
|
||||
(e: unknown) =>
|
||||
e instanceof StateWindowError && e.detail.reason === 'nonce-not-corroborated'
|
||||
? ok('corroboration catches what pre-flight missed', `StateWindowError ${e.detail.reason}, refused value ${e.detail.rawResult}`)
|
||||
: bad('corroboration catches what pre-flight missed', `wrong error: ${e}`),
|
||||
);
|
||||
|
||||
for (const [name, call] of [
|
||||
['getBalance', () => reader.getBalance(SIGNER, deepBlock)],
|
||||
['getCode', () => reader.getCode(SIGNER, deepBlock)],
|
||||
['getStorageAt', () => reader.getStorageAt(SIGNER, '0x0', deepBlock)],
|
||||
] as [string, () => Promise<unknown>][]) {
|
||||
await call().then(
|
||||
(v) => bad(`${name} refuses at depth`, `expected a throw, got ${JSON.stringify(v)}`),
|
||||
(e: unknown) =>
|
||||
e instanceof StateWindowError
|
||||
? ok(`${name} refuses at depth`, `StateWindowError ${e.detail.reason}`)
|
||||
: bad(`${name} refuses at depth`, `wrong error: ${e}`),
|
||||
);
|
||||
}
|
||||
|
||||
// And it still answers where the node can actually answer.
|
||||
const live = await reader.getTransactionCount(SIGNER, 'latest');
|
||||
ok('the reader still answers at latest', `nonce ${live}`);
|
||||
console.log('');
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
console.log('STEP 4 MEASURE the window rather than trusting the constant');
|
||||
// -------------------------------------------------------------------------
|
||||
const m = await reader.measureWindow(SIGNER, 100_000);
|
||||
console.log(` head ${m.head}, deepest depth answered ${m.deepestOkDepth}, first depth refused ${m.firstFailDepth}`);
|
||||
if (m.firstFailDepth === 512) {
|
||||
ok('the window is still 512 blocks', `511 answers, 512 does not, measured just now`);
|
||||
} else {
|
||||
bad('the window is still 512 blocks',
|
||||
`measured ${m.firstFailDepth}. This is not necessarily a bug, but every published ` +
|
||||
`number that says 512 is now wrong and must be corrected.`);
|
||||
}
|
||||
|
||||
console.log(`\n${failures === 0 ? 'RESULT: PASS' : `RESULT: FAIL, ${failures} check(s)`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('live proof aborted:', e);
|
||||
process.exit(2);
|
||||
});
|
||||
236
src/test/state-window.test.ts
Normal file
236
src/test/state-window.test.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* state-window.test.ts
|
||||
*
|
||||
* The fake node below is not invented. It reproduces behaviour measured on
|
||||
* 2026-08-01 against https://rpc.aere.network and https://rpc2.aere.network:
|
||||
*
|
||||
* depth < 512 every state call answers
|
||||
* depth >= 512 eth_getBalance -> null
|
||||
* eth_getCode -> null
|
||||
* eth_getStorageAt -> null
|
||||
* eth_getProof -> error -32000 "World state unavailable"
|
||||
* eth_call -> error -32603
|
||||
* eth_getTransactionCount -> "0x0" <-- the false zero
|
||||
*
|
||||
* The address and nonce used here are the measured ones: address
|
||||
* 0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465 signed a transaction with nonce
|
||||
* 123,063 in block 8,236,382, and both public endpoints answer 0x0 when asked
|
||||
* for its nonce at that block.
|
||||
*
|
||||
* Test 1 is the PLANTED FAILURE. It asserts that the naive call really does
|
||||
* return a well formed zero, so that the rest of the file is proving something
|
||||
* that is actually broken rather than guarding a hazard that never existed.
|
||||
*
|
||||
* Run: npm run build && node --test dist/test/state-window.test.js
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
StateWindowReader,
|
||||
StateWindowError,
|
||||
assertStateServable,
|
||||
DEFAULT_STATE_WINDOW_BLOCKS,
|
||||
} from '../state-window.js';
|
||||
|
||||
const HEAD = 11_804_000;
|
||||
const WINDOW = 512;
|
||||
|
||||
/** Measured: this address signed with nonce 123,063 in block 8,236,382. */
|
||||
const SIGNER = '0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465';
|
||||
const DEEP_BLOCK = 8_236_382;
|
||||
const TRUE_NONCE_AT_DEEP_BLOCK = 123_063;
|
||||
|
||||
interface FakeNode {
|
||||
send: (method: string, params: unknown[]) => Promise<unknown>;
|
||||
calls: string[];
|
||||
head: number;
|
||||
}
|
||||
|
||||
function fakeNode(opts: { head?: number; headAdvancesBy?: number } = {}): FakeNode {
|
||||
const node: FakeNode = { head: opts.head ?? HEAD, calls: [], send: null as never };
|
||||
|
||||
const depthOf = (tag: unknown): number | null => {
|
||||
const s = String(tag);
|
||||
if (!/^0x[0-9a-fA-F]+$/.test(s)) return null; // latest / pending / earliest
|
||||
return node.head - Number.parseInt(s, 16);
|
||||
};
|
||||
|
||||
node.send = async (method: string, params: unknown[]) => {
|
||||
node.calls.push(method);
|
||||
|
||||
if (method === 'eth_blockNumber') {
|
||||
const h = node.head;
|
||||
node.head += opts.headAdvancesBy ?? 0; // simulates the tip moving mid-run
|
||||
return '0x' + h.toString(16);
|
||||
}
|
||||
|
||||
const tagIndex = method === 'eth_getStorageAt' || method === 'eth_getProof' ? 2 : 1;
|
||||
const depth = depthOf(params[tagIndex]);
|
||||
const pruned = depth !== null && depth >= WINDOW;
|
||||
|
||||
switch (method) {
|
||||
case 'eth_getBalance':
|
||||
return pruned ? null : '0x1bc16d674ec80000';
|
||||
case 'eth_getCode':
|
||||
return pruned ? null : '0x60806040';
|
||||
case 'eth_getStorageAt':
|
||||
return pruned ? null : '0x' + '00'.repeat(31) + '01';
|
||||
case 'eth_call':
|
||||
if (pruned) throw new Error('-32603 Internal error');
|
||||
return '0x' + '00'.repeat(31) + '08';
|
||||
case 'eth_getProof':
|
||||
if (pruned) throw new Error('-32000 World state unavailable');
|
||||
return { accountProof: [] };
|
||||
case 'eth_getTransactionCount':
|
||||
// THE MEASURED LIE: a well formed zero instead of an error.
|
||||
return pruned ? '0x0' : '0x' + TRUE_NONCE_AT_DEEP_BLOCK.toString(16);
|
||||
default:
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. PLANTED FAILURE: prove the hazard is real before proving it is guarded.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('planted failure: a raw eth_getTransactionCount at depth returns a false zero', async () => {
|
||||
const node = fakeNode();
|
||||
const raw = await node.send('eth_getTransactionCount', [SIGNER, '0x' + DEEP_BLOCK.toString(16)]);
|
||||
|
||||
assert.equal(raw, '0x0', 'the endpoint under test must answer 0x0 for a pruned nonce');
|
||||
assert.equal(Number.parseInt(String(raw), 16), 0);
|
||||
assert.notEqual(
|
||||
Number.parseInt(String(raw), 16),
|
||||
TRUE_NONCE_AT_DEEP_BLOCK,
|
||||
'and that zero must be WRONG: the address signed with nonce 123063 in this very block',
|
||||
);
|
||||
|
||||
// Contrast: at the same block, the honest calls admit ignorance.
|
||||
assert.equal(await node.send('eth_getBalance', [SIGNER, '0x' + DEEP_BLOCK.toString(16)]), null);
|
||||
await assert.rejects(() => node.send('eth_getProof', [SIGNER, [], '0x' + DEEP_BLOCK.toString(16)]));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. The guard: a refusal, not a zero.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('getTransactionCount at a deep block throws instead of returning 0', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send);
|
||||
|
||||
await assert.rejects(
|
||||
() => reader.getTransactionCount(SIGNER, DEEP_BLOCK),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof StateWindowError, `expected StateWindowError, got ${e}`);
|
||||
assert.equal(e.code, 'STATE_OUTSIDE_WINDOW');
|
||||
assert.equal(e.detail.method, 'eth_getTransactionCount');
|
||||
assert.equal(e.detail.requestedBlock, DEEP_BLOCK);
|
||||
assert.match(e.message, /NOT MEASURED/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the deep call is never even sent: pre-flight rejects it', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send);
|
||||
await assert.rejects(() => reader.getTransactionCount(SIGNER, DEEP_BLOCK));
|
||||
assert.deepEqual(
|
||||
node.calls.filter((c) => c !== 'eth_blockNumber'),
|
||||
[],
|
||||
'no state call should reach a node that cannot answer it',
|
||||
);
|
||||
});
|
||||
|
||||
test('every other state read refuses at depth too, none returns a placeholder', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send);
|
||||
await assert.rejects(() => reader.getBalance(SIGNER, DEEP_BLOCK), StateWindowError);
|
||||
await assert.rejects(() => reader.getCode(SIGNER, DEEP_BLOCK), StateWindowError);
|
||||
await assert.rejects(() => reader.getStorageAt(SIGNER, '0x0', DEEP_BLOCK), StateWindowError);
|
||||
await assert.rejects(() => reader.call({ to: SIGNER, data: '0x' }, DEEP_BLOCK), StateWindowError);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Corroboration: the guard that survives a race past the pre-flight check.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('corroboration catches a false zero that slipped past the pre-flight check', async () => {
|
||||
// A reader configured with a window far larger than the node really serves.
|
||||
// Pre-flight waves the call through; only the companion eth_getBalance saves it.
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send, { windowBlocks: 10_000_000, safetyMarginBlocks: 0 });
|
||||
|
||||
await assert.rejects(
|
||||
() => reader.getTransactionCount(SIGNER, DEEP_BLOCK),
|
||||
(e: unknown) => {
|
||||
assert.ok(e instanceof StateWindowError);
|
||||
assert.equal(e.detail.reason, 'nonce-not-corroborated');
|
||||
assert.equal(e.detail.rawResult, '0x0', 'the refused value must be recorded, not hidden');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.ok(node.calls.includes('eth_getBalance'), 'the companion read must actually be issued');
|
||||
});
|
||||
|
||||
test('with corroboration disabled the false zero comes straight back: the guard is load-bearing', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send, {
|
||||
windowBlocks: 10_000_000, safetyMarginBlocks: 0, corroborateNonce: false,
|
||||
});
|
||||
assert.equal(await reader.getTransactionCount(SIGNER, DEEP_BLOCK), 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. The window edge is a race, and the margin is what covers it.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('a nominal depth of 511 is refused, because the head moves mid-call', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send); // margin 8
|
||||
await assert.rejects(() => reader.getBalance(SIGNER, HEAD - 511), StateWindowError);
|
||||
});
|
||||
|
||||
test('inside the margin the reader answers normally', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send);
|
||||
assert.equal(await reader.getBalance(SIGNER, HEAD - 100), 2000000000000000000n);
|
||||
assert.equal(await reader.getTransactionCount(SIGNER, HEAD - 100), TRUE_NONCE_AT_DEEP_BLOCK);
|
||||
});
|
||||
|
||||
test('symbolic tags are never blocked', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send);
|
||||
assert.equal(await reader.getTransactionCount(SIGNER), TRUE_NONCE_AT_DEEP_BLOCK);
|
||||
assert.equal(await reader.getBalance(SIGNER, 'pending'), 2000000000000000000n);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. hasEverSigned, the D-001 question, answered only where it can be answered.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('hasEverSigned answers at latest and reports the height it answered at', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send);
|
||||
const r = await reader.hasEverSigned(SIGNER);
|
||||
assert.equal(r.signed, true);
|
||||
assert.equal(r.nonce, TRUE_NONCE_AT_DEEP_BLOCK);
|
||||
assert.equal(r.atBlock, HEAD);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. measureWindow finds the real edge instead of trusting the constant.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('measureWindow recovers the 512-block edge from the node itself', async () => {
|
||||
const node = fakeNode();
|
||||
const reader = new StateWindowReader(node.send);
|
||||
const m = await reader.measureWindow(SIGNER, 100_000);
|
||||
assert.equal(m.deepestOkDepth, WINDOW - 1);
|
||||
assert.equal(m.firstFailDepth, WINDOW);
|
||||
assert.equal(DEFAULT_STATE_WINDOW_BLOCKS, WINDOW);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. The free-function guard, for callers that already hold head and block.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('assertStateServable throws on depth and stays quiet inside the window', () => {
|
||||
assert.throws(() => assertStateServable('eth_getTransactionCount', DEEP_BLOCK, HEAD), StateWindowError);
|
||||
assert.doesNotThrow(() => assertStateServable('eth_getBalance', HEAD - 10, HEAD));
|
||||
});
|
||||
278
src/test/wallet.test.ts
Normal file
278
src/test/wallet.test.ts
Normal file
@ -0,0 +1,278 @@
|
||||
// Unit + LIVE-CHAIN tests for the seedless hybrid PQC wallet core (chain 2800).
|
||||
//
|
||||
// Offline (always run): descriptor construction, client-side Falcon key encrypt/
|
||||
// decrypt round-trip, the one-tap upgrade fusion, CREATE2 address determinism, and
|
||||
// the Falcon proof-of-possession / attestation path INCLUDING the required
|
||||
// negative control (a signature bound to nonce N must FAIL against the challenge
|
||||
// for a different nonce).
|
||||
//
|
||||
// Live (skip if rpc.aere.network is unreachable): the CREATE2 prediction matches
|
||||
// the on-chain AerePQCAccountFactory, and the Falcon signatures verify true on the
|
||||
// LIVE precompile via AerePQCAttestation.verifySignature (a free eth_call) while a
|
||||
// wrong-nonce / wrong-message signature verifies false. No transaction is sent.
|
||||
//
|
||||
// Run: npm run build && node --test dist/test/wallet.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
JsonRpcProvider, Contract, getBytes, hexlify, randomBytes, zeroPadValue,
|
||||
} from 'ethers';
|
||||
import { AerePQCClient, SCHEME, verifyLocal } from '../pqc/index.js';
|
||||
import {
|
||||
createSeedlessAccount,
|
||||
generateFalconRoot,
|
||||
encryptFalconSecretKey,
|
||||
decryptFalconSecretKey,
|
||||
upgradeToPostQuantum,
|
||||
predictPqcAccountAddress,
|
||||
buildUpgradeAttestation,
|
||||
verifyUpgradeAttestationLive,
|
||||
signPqcAccountUserOp,
|
||||
verifyPqcAccountUserOpLocal,
|
||||
verifyUserOpFalconLegLive,
|
||||
AERE_WALLET_ADDRESSES,
|
||||
type PasskeyDailyFactor,
|
||||
} from '../wallet/index.js';
|
||||
|
||||
const RPC = 'https://rpc.aere.network';
|
||||
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||
const client = new AerePQCClient(provider);
|
||||
|
||||
// A stable passkey daily factor for construction (address is arbitrary but valid).
|
||||
const PASSKEY: PasskeyDailyFactor = {
|
||||
type: 'passkey',
|
||||
authorityAddress: '0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3',
|
||||
credentialId: 'AQIDBA', // base64url, illustrative
|
||||
label: 'Test device Face ID',
|
||||
};
|
||||
|
||||
// Deterministic Falcon key from a fixed 48-byte seed so tests are reproducible.
|
||||
const SEED = new Uint8Array(48).fill(7);
|
||||
|
||||
function isNetworkError(e: unknown): boolean {
|
||||
const code = (e as { code?: string })?.code;
|
||||
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||
}
|
||||
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (isNetworkError(e)) {
|
||||
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||
console.log(notice);
|
||||
t.skip(notice);
|
||||
return undefined;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── construction ───────────────────────────────────────────────────────────
|
||||
|
||||
test('createSeedlessAccount: passkey daily factor, no seed, not yet post-quantum', () => {
|
||||
const d = createSeedlessAccount(PASSKEY);
|
||||
assert.equal(d.chainId, 2800);
|
||||
assert.equal(d.daily.type, 'passkey');
|
||||
assert.equal(d.postQuantum, false);
|
||||
assert.equal(d.root, undefined);
|
||||
assert.equal(d.addresses.pqcAccountFactory, AERE_WALLET_ADDRESSES.pqcAccountFactory);
|
||||
});
|
||||
|
||||
test('generateFalconRoot: valid 897-byte NIST Falcon-512 public key', () => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
assert.equal(kp.scheme, SCHEME.FALCON512);
|
||||
assert.equal(kp.publicKey.length, 897);
|
||||
assert.equal(kp.publicKey[0], 0x09);
|
||||
});
|
||||
|
||||
// ─── client-side key custody ─────────────────────────────────────────────────
|
||||
|
||||
test('encrypt/decrypt Falcon secret key round-trips; wrong passphrase + tamper fail', async () => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const enc = await encryptFalconSecretKey(kp, 'correct horse battery', { iterations: 50_000 });
|
||||
assert.equal(enc.cipher, 'AES-256-GCM');
|
||||
assert.equal(enc.kdf, 'PBKDF2-SHA256');
|
||||
assert.equal(enc.publicKey.toLowerCase(), hexlify(kp.publicKey).toLowerCase());
|
||||
// ciphertext must not equal the plaintext secret key
|
||||
assert.notEqual(enc.ciphertext.toLowerCase(), hexlify(kp.secretKey).toLowerCase());
|
||||
|
||||
const back = await decryptFalconSecretKey(enc, 'correct horse battery');
|
||||
assert.deepEqual(Array.from(back.secretKey), Array.from(kp.secretKey));
|
||||
assert.deepEqual(Array.from(back.publicKey), Array.from(kp.publicKey));
|
||||
|
||||
await assert.rejects(() => decryptFalconSecretKey(enc, 'wrong passphrase'), /decryption failed/);
|
||||
|
||||
const tampered = { ...enc, ciphertext: enc.ciphertext.slice(0, -2) + (enc.ciphertext.endsWith('00') ? '11' : '00') };
|
||||
await assert.rejects(() => decryptFalconSecretKey(tampered, 'correct horse battery'), /decryption failed/);
|
||||
});
|
||||
|
||||
// ─── the one-tap upgrade ─────────────────────────────────────────────────────
|
||||
|
||||
test('upgradeToPostQuantum: fuses a Falcon root + counterfactual PQC account', async () => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const enc = await encryptFalconSecretKey(kp, 'passphrase-1234', { iterations: 50_000 });
|
||||
const d0 = createSeedlessAccount(PASSKEY);
|
||||
const d1 = upgradeToPostQuantum(d0, kp.publicKey, { encryptedKey: enc });
|
||||
|
||||
assert.equal(d1.postQuantum, true);
|
||||
assert.equal(d0.postQuantum, false, 'input descriptor must be untouched (immutably upgraded)');
|
||||
assert.ok(d1.root);
|
||||
assert.equal(d1.root!.custody, 'client-encrypted');
|
||||
assert.equal(d1.root!.scheme, SCHEME.FALCON512);
|
||||
assert.equal(d1.root!.publicKey.toLowerCase(), hexlify(kp.publicKey).toLowerCase());
|
||||
assert.match(d1.root!.pqcAccount, /^0x[0-9a-fA-F]{40}$/);
|
||||
assert.ok(d1.root!.encryptedKey, 'client-encrypted custody must embed the encrypted key');
|
||||
|
||||
// client-encrypted custody without a key blob must be rejected
|
||||
assert.throws(() => upgradeToPostQuantum(d0, kp.publicKey), /requires an encryptedKey/);
|
||||
// an encrypted blob for a DIFFERENT key must be rejected
|
||||
const other = generateFalconRoot(new Uint8Array(48).fill(9));
|
||||
const encOther = await encryptFalconSecretKey(other, 'passphrase-1234', { iterations: 50_000 });
|
||||
assert.throws(
|
||||
() => upgradeToPostQuantum(d0, kp.publicKey, { encryptedKey: encOther }),
|
||||
/does not match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('predictPqcAccountAddress is deterministic and salt-sensitive', () => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const a0 = predictPqcAccountAddress(kp.publicKey, 0n);
|
||||
const a0b = predictPqcAccountAddress(kp.publicKey, 0n);
|
||||
const a1 = predictPqcAccountAddress(kp.publicKey, 1n);
|
||||
assert.equal(a0, a0b);
|
||||
assert.notEqual(a0, a1);
|
||||
});
|
||||
|
||||
// ─── Falcon PoP / attestation path (offline) ─────────────────────────────────
|
||||
|
||||
test('upgrade attestation: Falcon PoP verifies locally; WRONG NONCE fails', () => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||
custody: 'secure-enclave', // no key blob needed for this custody
|
||||
});
|
||||
|
||||
const att = buildUpgradeAttestation(client, d, kp.secretKey, { keyId: 0, nonce: 0 });
|
||||
|
||||
// The genuine signature verifies against the challenge it was bound to.
|
||||
assert.equal(
|
||||
verifyLocal(SCHEME.FALCON512, getBytes(att.challenge), att.signature, kp.publicKey),
|
||||
true,
|
||||
'genuine Falcon PoP must verify over its own challenge',
|
||||
);
|
||||
|
||||
// NEGATIVE CONTROL: the same signature must FAIL against the challenge for a
|
||||
// different nonce (replay/rebind protection). This is the core PoP guarantee.
|
||||
const wrongNonceChallenge = client.deriveChallenge(0, 1, att.messageHash);
|
||||
assert.notEqual(wrongNonceChallenge, att.challenge);
|
||||
assert.equal(
|
||||
verifyLocal(SCHEME.FALCON512, getBytes(wrongNonceChallenge), att.signature, kp.publicKey),
|
||||
false,
|
||||
'Falcon PoP bound to nonce 0 must NOT verify against the nonce-1 challenge',
|
||||
);
|
||||
|
||||
// And a signature over a different message hash must fail too.
|
||||
const wrongMsgChallenge = client.deriveChallenge(0, 0, hexlify(randomBytes(32)));
|
||||
assert.equal(
|
||||
verifyLocal(SCHEME.FALCON512, getBytes(wrongMsgChallenge), att.signature, kp.publicKey),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('signPqcAccountUserOp: Falcon-signed userOp verifies locally; tamper fails', () => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||
custody: 'secure-enclave',
|
||||
});
|
||||
const op = {
|
||||
sender: d.root!.pqcAccount,
|
||||
nonce: 0n,
|
||||
initCode: '0x',
|
||||
callData: '0x',
|
||||
accountGasLimits: zeroPadValue('0x0f4240000000000000000000000f4240', 32).slice(0, 66),
|
||||
preVerificationGas: 21000n,
|
||||
gasFees: zeroPadValue('0x3b9aca00000000000000000000003b9aca00', 32).slice(0, 66),
|
||||
paymasterAndData: '0x',
|
||||
signature: '0x',
|
||||
};
|
||||
const sig = signPqcAccountUserOp(op, d.addresses.entryPoint, d.chainId, kp.secretKey);
|
||||
assert.match(sig.userOpHash, /^0x[0-9a-fA-F]{64}$/);
|
||||
assert.match(sig.accountSignature, /^0x[0-9a-fA-F]+$/);
|
||||
|
||||
assert.equal(verifyPqcAccountUserOpLocal(sig, kp.publicKey), true);
|
||||
|
||||
// A userOp signature is bound to the exact userOpHash: changing the op changes
|
||||
// the hash, so the same signature no longer verifies for the new op.
|
||||
const op2 = { ...op, nonce: 1n };
|
||||
const sig2 = signPqcAccountUserOp(op2, d.addresses.entryPoint, d.chainId, kp.secretKey);
|
||||
assert.notEqual(sig.userOpHash, sig2.userOpHash);
|
||||
// wrong pubkey also rejected
|
||||
const other = generateFalconRoot(new Uint8Array(48).fill(3));
|
||||
assert.equal(verifyPqcAccountUserOpLocal(sig, other.publicKey), false);
|
||||
});
|
||||
|
||||
// ─── LIVE CHAIN (skip if offline) ────────────────────────────────────────────
|
||||
|
||||
test('LIVE: CREATE2 prediction matches on-chain AerePQCAccountFactory', async (t) => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const salt = 42n;
|
||||
const local = predictPqcAccountAddress(kp.publicKey, salt);
|
||||
const factory = new Contract(
|
||||
AERE_WALLET_ADDRESSES.pqcAccountFactory,
|
||||
['function predictAddress(bytes,uint256) view returns (address)'],
|
||||
provider,
|
||||
);
|
||||
const onchain = await onChain(t, () => factory.predictAddress(hexlify(kp.publicKey), salt));
|
||||
if (onchain === undefined) return;
|
||||
assert.equal(local.toLowerCase(), (onchain as string).toLowerCase());
|
||||
});
|
||||
|
||||
test('LIVE: upgrade attestation verifies on the precompile; wrong nonce rejected', async (t) => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||
custody: 'secure-enclave',
|
||||
});
|
||||
const att = buildUpgradeAttestation(client, d, kp.secretKey, { keyId: 0, nonce: 0 });
|
||||
|
||||
const ok = await onChain(t, () => verifyUpgradeAttestationLive(client, d, att));
|
||||
if (ok === undefined) return;
|
||||
assert.equal(ok, true, 'LIVE precompile must accept the genuine upgrade attestation');
|
||||
|
||||
// Build a nonce-1 attestation but check the nonce-0 signature against it: the
|
||||
// on-chain challenge differs, so the precompile rejects.
|
||||
const attWrong = { ...att, nonce: 1n };
|
||||
const bad = await onChain(t, () => verifyUpgradeAttestationLive(client, d, attWrong));
|
||||
if (bad === undefined) return;
|
||||
assert.equal(bad, false, 'LIVE precompile must reject the nonce-0 signature under the nonce-1 challenge');
|
||||
});
|
||||
|
||||
test('LIVE: Falcon-signed userOp leg verifies on the precompile; wrong hash rejected', async (t) => {
|
||||
const kp = generateFalconRoot(SEED);
|
||||
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||
custody: 'secure-enclave',
|
||||
});
|
||||
const op = {
|
||||
sender: d.root!.pqcAccount,
|
||||
nonce: 5n,
|
||||
initCode: '0x',
|
||||
callData: '0x',
|
||||
accountGasLimits: zeroPadValue('0x0f4240000000000000000000000f4240', 32).slice(0, 66),
|
||||
preVerificationGas: 21000n,
|
||||
gasFees: zeroPadValue('0x3b9aca00000000000000000000003b9aca00', 32).slice(0, 66),
|
||||
paymasterAndData: '0x',
|
||||
signature: '0x',
|
||||
};
|
||||
const sig = signPqcAccountUserOp(op, d.addresses.entryPoint, d.chainId, kp.secretKey);
|
||||
|
||||
const ok = await onChain(t, () => verifyUserOpFalconLegLive(client, sig, kp.publicKey));
|
||||
if (ok === undefined) return;
|
||||
assert.equal(ok, true, 'LIVE precompile must accept the Falcon userOp leg');
|
||||
|
||||
const bad = await onChain(t, () =>
|
||||
client.verifySignature(SCHEME.FALCON512, kp.publicKey, hexlify(randomBytes(32)), sig.attestationEnvelope),
|
||||
);
|
||||
if (bad === undefined) return;
|
||||
assert.equal(bad, false, 'LIVE precompile must reject the Falcon leg over a different hash');
|
||||
});
|
||||
154
src/wallet/descriptor.ts
Normal file
154
src/wallet/descriptor.ts
Normal file
@ -0,0 +1,154 @@
|
||||
// Seedless account descriptor construction + the "upgrade to post-quantum" flow.
|
||||
//
|
||||
// createSeedlessAccount(passkey) -> descriptor with a passkey daily factor
|
||||
// upgradeToPostQuantum(descriptor, ...) -> fuses a Falcon-512 quantum-durable root
|
||||
// predictPqcAccountAddress(...) -> CREATE2 address of the Falcon-owned
|
||||
// AerePQCAccount (mirrors the live factory)
|
||||
//
|
||||
// The prediction is a pure mirror of AerePQCAccountFactory.predictAddress on chain
|
||||
// 2800 and is proven byte-identical to the on-chain view in wallet.test.ts.
|
||||
|
||||
import { keccak256, AbiCoder, concat, getBytes, hexlify, getAddress } from 'ethers';
|
||||
import { AERE_MAINNET } from '../addresses.js';
|
||||
import { SCHEME, LENGTHS } from '../pqc/index.js';
|
||||
import {
|
||||
WALLET_DESCRIPTOR_VERSION,
|
||||
type SeedlessAccountDescriptor,
|
||||
type PasskeyDailyFactor,
|
||||
type WalletAddresses,
|
||||
type CustodyMode,
|
||||
type FalconRoot,
|
||||
type EncryptedFalconKey,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* keccak256 of the AerePQCAccount creation bytecode — the CREATE2 init-code hash
|
||||
* every AerePQCAccountFactory account shares (the factory has no constructor
|
||||
* args; EntryPoint + verifier are factory immutables). Read live from
|
||||
* AerePQCAccountFactory.ACCOUNT_INIT_CODE_HASH() on chain 2800 (2026-07-12) and
|
||||
* re-verified against the on-chain predictAddress in wallet.test.ts.
|
||||
*/
|
||||
export const AERE_PQC_ACCOUNT_INIT_CODE_HASH =
|
||||
'0x05dd938d3980ee22354b9635703e916187d0323259d2c9468954382245bb10ce';
|
||||
|
||||
/** Default chain-2800 addresses the seedless wallet binds to. */
|
||||
export const AERE_WALLET_ADDRESSES: WalletAddresses = {
|
||||
entryPoint: AERE_MAINNET.AereEntryPointV2,
|
||||
passkeyFactory: AERE_MAINNET.AerePasskeyAccountFactoryV2Fixed,
|
||||
pqcAccountFactory: AERE_MAINNET.AerePQCAccountFactory,
|
||||
falconVerifier: AERE_MAINNET.AereFalcon512Verifier,
|
||||
pqcAttestation: AERE_MAINNET.AerePQCAttestation,
|
||||
pqcAccountInitCodeHash: AERE_PQC_ACCOUNT_INIT_CODE_HASH,
|
||||
};
|
||||
|
||||
export interface CreateSeedlessAccountOptions {
|
||||
chainId?: number;
|
||||
addresses?: WalletAddresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a seedless account descriptor from a passkey daily factor. This is the
|
||||
* pre-upgrade state: an everyday passkey authority and NO Falcon root yet.
|
||||
* No seed phrase is generated or required.
|
||||
*/
|
||||
export function createSeedlessAccount(
|
||||
passkey: PasskeyDailyFactor,
|
||||
opts: CreateSeedlessAccountOptions = {},
|
||||
): SeedlessAccountDescriptor {
|
||||
if (passkey.type !== 'passkey') throw new Error('descriptor: daily factor must be a passkey');
|
||||
getAddress(passkey.authorityAddress); // validate checksum/shape
|
||||
return {
|
||||
version: WALLET_DESCRIPTOR_VERSION,
|
||||
chainId: opts.chainId ?? AERE_MAINNET.chainId,
|
||||
daily: passkey,
|
||||
root: undefined,
|
||||
addresses: opts.addresses ?? AERE_WALLET_ADDRESSES,
|
||||
postQuantum: false,
|
||||
};
|
||||
}
|
||||
|
||||
function assertFalcon512PubKey(pubKey: Uint8Array): void {
|
||||
if (pubKey.length !== LENGTHS.falcon512.pubKey || pubKey[0] !== LENGTHS.falcon512.pkHeader) {
|
||||
throw new Error(
|
||||
`descriptor: Falcon-512 pubKey must be ${LENGTHS.falcon512.pubKey} bytes with 0x09 header`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure mirror of AerePQCAccountFactory.predictAddress(falconPubKey, salt):
|
||||
* saltMix = keccak256(abi.encode(bytes falconPubKey, uint256 salt))
|
||||
* addr = keccak256(0xff || factory || saltMix || initCodeHash)[12:]
|
||||
* Returns the counterfactual AerePQCAccount address (a checksummed address).
|
||||
*/
|
||||
export function predictPqcAccountAddress(
|
||||
falconPubKey: Uint8Array,
|
||||
salt: bigint,
|
||||
factory: string = AERE_WALLET_ADDRESSES.pqcAccountFactory,
|
||||
initCodeHash: string = AERE_PQC_ACCOUNT_INIT_CODE_HASH,
|
||||
): string {
|
||||
assertFalcon512PubKey(falconPubKey);
|
||||
const saltMix = keccak256(
|
||||
AbiCoder.defaultAbiCoder().encode(['bytes', 'uint256'], [hexlify(falconPubKey), salt]),
|
||||
);
|
||||
const digest = keccak256(concat(['0xff', getAddress(factory), saltMix, initCodeHash]));
|
||||
return getAddress('0x' + digest.slice(26));
|
||||
}
|
||||
|
||||
export interface UpgradeOptions {
|
||||
/** CREATE2 salt for the AerePQCAccount. Default 0. */
|
||||
salt?: bigint;
|
||||
/** How the Falcon secret key is custodied. Default 'client-encrypted'. */
|
||||
custody?: CustodyMode;
|
||||
/** Encrypted secret-key blob to embed when custody === 'client-encrypted'. */
|
||||
encryptedKey?: EncryptedFalconKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-tap "upgrade my account to post-quantum" flow: fuse a Falcon-512
|
||||
* quantum-durable root onto an existing seedless descriptor. Returns a NEW
|
||||
* descriptor (does not mutate the input) whose `root` binds:
|
||||
* - the Falcon-512 public key,
|
||||
* - the counterfactual AerePQCAccount it solely owns,
|
||||
* - the custody mode, and
|
||||
* - (for 'client-encrypted') the encrypted key blob.
|
||||
*
|
||||
* This performs NO transaction. Deploying the AerePQCAccount and recording the
|
||||
* upgrade attestation are separate on-chain steps (founder-gated); this builds
|
||||
* the material and the counterfactual address only.
|
||||
*/
|
||||
export function upgradeToPostQuantum(
|
||||
descriptor: SeedlessAccountDescriptor,
|
||||
falconPublicKey: Uint8Array,
|
||||
opts: UpgradeOptions = {},
|
||||
): SeedlessAccountDescriptor {
|
||||
assertFalcon512PubKey(falconPublicKey);
|
||||
const salt = opts.salt ?? 0n;
|
||||
const custody = opts.custody ?? 'client-encrypted';
|
||||
if (custody === 'client-encrypted') {
|
||||
if (!opts.encryptedKey) {
|
||||
throw new Error(
|
||||
"descriptor: custody 'client-encrypted' requires an encryptedKey blob " +
|
||||
'(encrypt the Falcon secret key with encryptFalconSecretKey first)',
|
||||
);
|
||||
}
|
||||
if (opts.encryptedKey.publicKey.toLowerCase() !== hexlify(falconPublicKey).toLowerCase()) {
|
||||
throw new Error('descriptor: encryptedKey.publicKey does not match the Falcon root public key');
|
||||
}
|
||||
}
|
||||
const pqcAccount = predictPqcAccountAddress(
|
||||
falconPublicKey,
|
||||
salt,
|
||||
descriptor.addresses.pqcAccountFactory,
|
||||
descriptor.addresses.pqcAccountInitCodeHash,
|
||||
);
|
||||
const root: FalconRoot = {
|
||||
scheme: SCHEME.FALCON512,
|
||||
publicKey: hexlify(falconPublicKey),
|
||||
pqcAccount,
|
||||
salt: salt.toString(),
|
||||
custody,
|
||||
...(custody === 'client-encrypted' ? { encryptedKey: opts.encryptedKey } : {}),
|
||||
};
|
||||
return { ...descriptor, root, postQuantum: true };
|
||||
}
|
||||
142
src/wallet/falconKeystore.ts
Normal file
142
src/wallet/falconKeystore.ts
Normal file
@ -0,0 +1,142 @@
|
||||
// Client-side Falcon-512 key generation + encryption for the seedless PQC wallet.
|
||||
//
|
||||
// The Falcon quantum-durable ROOT key is generated on the client (never on a
|
||||
// server) and, in the RECOMMENDED 'client-encrypted' custody mode, persisted ONLY
|
||||
// as an AES-256-GCM ciphertext unlocked by a user passphrase via PBKDF2-SHA256.
|
||||
// This is what makes the quantum-lock co-owner PORTABLE without a seed phrase:
|
||||
// the encrypted blob alone (plus the passphrase) restores the root on any device.
|
||||
//
|
||||
// Crypto is WebCrypto (globalThis.crypto.subtle) — zero extra dependencies,
|
||||
// identical in modern Node (>=20) and browsers. Falcon keygen is the audited
|
||||
// @noble/post-quantum implementation already used across the PQC SDK.
|
||||
|
||||
import { falcon512 } from '@noble/post-quantum/falcon.js';
|
||||
import { hexlify, getBytes, type BytesLike } from 'ethers';
|
||||
import { SCHEME } from '../pqc/index.js';
|
||||
import { LENGTHS } from '../pqc/index.js';
|
||||
import {
|
||||
WALLET_DESCRIPTOR_VERSION,
|
||||
type EncryptedFalconKey,
|
||||
type FalconKeypair,
|
||||
} from './types.js';
|
||||
|
||||
/** Default PBKDF2 work factor. 600k SHA-256 iterations (OWASP 2023 floor). */
|
||||
export const DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
function subtle(): SubtleCrypto {
|
||||
const c = (globalThis as { crypto?: Crypto }).crypto;
|
||||
if (!c || !c.subtle) {
|
||||
throw new Error('falconKeystore: WebCrypto (globalThis.crypto.subtle) is unavailable in this runtime');
|
||||
}
|
||||
return c.subtle;
|
||||
}
|
||||
|
||||
function randomBytes(n: number): Uint8Array {
|
||||
const c = (globalThis as { crypto?: Crypto }).crypto;
|
||||
if (!c || !c.getRandomValues) throw new Error('falconKeystore: no CSPRNG (crypto.getRandomValues)');
|
||||
return c.getRandomValues(new Uint8Array(n));
|
||||
}
|
||||
|
||||
/** Copy any Uint8Array into a fresh, plain-ArrayBuffer-backed buffer for WebCrypto. */
|
||||
function toArrayBuffer(u: Uint8Array): ArrayBuffer {
|
||||
const ab = new ArrayBuffer(u.byteLength);
|
||||
new Uint8Array(ab).set(u);
|
||||
return ab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh Falcon-512 keypair for a wallet root. Optionally deterministic
|
||||
* from a 48-byte seed (test vectors only — production keys use the CSPRNG).
|
||||
*/
|
||||
export function generateFalconRoot(seed?: Uint8Array): FalconKeypair {
|
||||
const kp = falcon512.keygen(seed);
|
||||
if (kp.publicKey.length !== LENGTHS.falcon512.pubKey || kp.publicKey[0] !== LENGTHS.falcon512.pkHeader) {
|
||||
throw new Error('falconKeystore: generated Falcon-512 pubkey has an unexpected shape');
|
||||
}
|
||||
return { scheme: SCHEME.FALCON512, publicKey: kp.publicKey, secretKey: kp.secretKey };
|
||||
}
|
||||
|
||||
async function deriveAesKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
|
||||
const material = await subtle().importKey(
|
||||
'raw',
|
||||
toArrayBuffer(new TextEncoder().encode(passphrase)),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return subtle().deriveKey(
|
||||
{ name: 'PBKDF2', salt: toArrayBuffer(salt), iterations, hash: 'SHA-256' },
|
||||
material,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export interface EncryptOptions {
|
||||
iterations?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side-encrypt a Falcon-512 secret key under `passphrase`. The public key
|
||||
* is stored in the clear (it is public); the secret key is only ever persisted as
|
||||
* the returned ciphertext. Reversible only by {@link decryptFalconSecretKey} with
|
||||
* the same passphrase.
|
||||
*/
|
||||
export async function encryptFalconSecretKey(
|
||||
keypair: FalconKeypair,
|
||||
passphrase: string,
|
||||
opts: EncryptOptions = {},
|
||||
): Promise<EncryptedFalconKey> {
|
||||
if (!passphrase || passphrase.length < 8) {
|
||||
throw new Error('falconKeystore: passphrase must be at least 8 characters');
|
||||
}
|
||||
const iterations = opts.iterations ?? DEFAULT_PBKDF2_ITERATIONS;
|
||||
const salt = randomBytes(16);
|
||||
const iv = randomBytes(12);
|
||||
const key = await deriveAesKey(passphrase, salt, iterations);
|
||||
const ct = new Uint8Array(
|
||||
await subtle().encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(keypair.secretKey)),
|
||||
);
|
||||
return {
|
||||
version: WALLET_DESCRIPTOR_VERSION,
|
||||
scheme: keypair.scheme,
|
||||
publicKey: hexlify(keypair.publicKey),
|
||||
kdf: 'PBKDF2-SHA256',
|
||||
kdfSalt: hexlify(salt),
|
||||
kdfIterations: iterations,
|
||||
cipher: 'AES-256-GCM',
|
||||
iv: hexlify(iv),
|
||||
ciphertext: hexlify(ct),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a {@link EncryptedFalconKey} back into a usable {@link FalconKeypair}.
|
||||
* Throws on a wrong passphrase or tampered ciphertext (GCM auth-tag failure).
|
||||
*/
|
||||
export async function decryptFalconSecretKey(
|
||||
enc: EncryptedFalconKey,
|
||||
passphrase: string,
|
||||
): Promise<FalconKeypair> {
|
||||
if (enc.kdf !== 'PBKDF2-SHA256' || enc.cipher !== 'AES-256-GCM') {
|
||||
throw new Error(`falconKeystore: unsupported kdf/cipher (${enc.kdf}/${enc.cipher})`);
|
||||
}
|
||||
const salt = getBytes(enc.kdfSalt);
|
||||
const iv = getBytes(enc.iv);
|
||||
const key = await deriveAesKey(passphrase, salt, enc.kdfIterations);
|
||||
let plain: Uint8Array;
|
||||
try {
|
||||
plain = new Uint8Array(
|
||||
await subtle().decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(getBytes(enc.ciphertext))),
|
||||
);
|
||||
} catch {
|
||||
throw new Error('falconKeystore: decryption failed (wrong passphrase or corrupted key blob)');
|
||||
}
|
||||
return { scheme: enc.scheme, publicKey: getBytes(enc.publicKey), secretKey: plain };
|
||||
}
|
||||
|
||||
/** Convenience: hex the public key of a keypair for descriptors / on-chain calls. */
|
||||
export function falconPubKeyHex(kp: FalconKeypair | { publicKey: BytesLike }): string {
|
||||
return hexlify(getBytes(kp.publicKey));
|
||||
}
|
||||
235
src/wallet/hybridAuth.ts
Normal file
235
src/wallet/hybridAuth.ts
Normal file
@ -0,0 +1,235 @@
|
||||
// Hybrid-auth surface for the seedless PQC wallet: the Falcon-512 root produces
|
||||
// (a) a one-tap "upgrade to post-quantum" ATTESTATION binding the quantum-durable
|
||||
// root to the passkey account, recordable via AerePQCAttestation, and (b) the
|
||||
// USEROP signature the Falcon-owned AerePQCAccount consumes. Both end in a REAL
|
||||
// Falcon-512 signature the chain verifies on-chain via the live precompile.
|
||||
//
|
||||
// HONEST SCOPE. The everyday spend path is the passkey daily factor: hot,
|
||||
// classical secp256k1-routed, fast. The Falcon root is the QUANTUM-DURABLE
|
||||
// authority for the high-assurance actions (the upgrade itself, recovery,
|
||||
// high-value / co-signed ops). Only the ROOT is post-quantum; the hot session is
|
||||
// not, by design. The AERE chain's own consensus remains classical QBFT/ECDSA —
|
||||
// this is application-layer PQC, not post-quantum consensus.
|
||||
|
||||
import {
|
||||
keccak256, AbiCoder, getBytes, hexlify, toUtf8Bytes, concat,
|
||||
type BytesLike,
|
||||
} from 'ethers';
|
||||
import { falcon512 } from '@noble/post-quantum/falcon.js';
|
||||
import {
|
||||
SCHEME, signInternal, verifyLocal, falconDetachedToEnvelope, FALCON_NONCE_LEN,
|
||||
type AerePQCClient,
|
||||
} from '../pqc/index.js';
|
||||
import { computeUserOpHash } from '../account/abi.js';
|
||||
import type { PackedUserOp } from '../account/types.js';
|
||||
import type { SeedlessAccountDescriptor } from './types.js';
|
||||
|
||||
/** keccak256("AereSeedlessPQCWallet.v1.upgrade") — the upgrade-attestation domain. */
|
||||
export const UPGRADE_ATTESTATION_DOMAIN = keccak256(
|
||||
toUtf8Bytes('AereSeedlessPQCWallet.v1.upgrade'),
|
||||
);
|
||||
|
||||
function bytes32(v: BytesLike): string {
|
||||
const b = getBytes(v);
|
||||
if (b.length !== 32) throw new Error(`hybridAuth: expected 32-byte value, got ${b.length}`);
|
||||
return hexlify(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* keccak256("AerePQCAccount.validateUserOp.v1") — the userOp signing DOMAIN. The
|
||||
* AerePQCAccount verifies validateUserOp over abi.encodePacked(domain, userOpHash)
|
||||
* (64 bytes), domain-separated from the raw 32-byte EIP-1271 login surface so a
|
||||
* signature phished through a dapp "login" can never be replayed as a spend. This
|
||||
* MUST match the AerePQCAccount.USEROP_DOMAIN constant. (Adversarial-review HIGH.)
|
||||
*/
|
||||
export const PQC_ACCOUNT_USEROP_DOMAIN = keccak256(
|
||||
toUtf8Bytes('AerePQCAccount.validateUserOp.v1'),
|
||||
);
|
||||
|
||||
/** The exact 64-byte message the AerePQCAccount Falcon-verifies for a userOp. */
|
||||
export function pqcAccountUserOpMessage(userOpHash: BytesLike): string {
|
||||
return concat([PQC_ACCOUNT_USEROP_DOMAIN, bytes32(userOpHash)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The message hash committing an "upgrade to post-quantum" of a specific seedless
|
||||
* account. Binds the Falcon root to the everyday passkey authority AND to the
|
||||
* Falcon-owned PQC account, so an attestation over it cannot be replayed onto a
|
||||
* different account or a different daily factor:
|
||||
*
|
||||
* keccak256(abi.encode(
|
||||
* UPGRADE_ATTESTATION_DOMAIN, chainId,
|
||||
* passkeyAuthority, keccak256(falconPubKey), pqcAccount))
|
||||
*/
|
||||
export function upgradeMessageHash(descriptor: SeedlessAccountDescriptor): string {
|
||||
if (!descriptor.root) {
|
||||
throw new Error('hybridAuth: descriptor has no Falcon root (call upgradeToPostQuantum first)');
|
||||
}
|
||||
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes32', 'uint256', 'address', 'bytes32', 'address'],
|
||||
[
|
||||
UPGRADE_ATTESTATION_DOMAIN,
|
||||
descriptor.chainId,
|
||||
descriptor.daily.authorityAddress,
|
||||
keccak256(descriptor.root.publicKey),
|
||||
descriptor.root.pqcAccount,
|
||||
],
|
||||
);
|
||||
return keccak256(enc);
|
||||
}
|
||||
|
||||
export interface UpgradeAttestation {
|
||||
/** The account-binding message hash (see {@link upgradeMessageHash}). */
|
||||
messageHash: string;
|
||||
/** The AerePQCAttestation per-key challenge the Falcon signature covers. */
|
||||
challenge: string;
|
||||
/** keyId used in the challenge derivation. */
|
||||
keyId: bigint;
|
||||
/** per-key nonce used in the challenge derivation. */
|
||||
nonce: bigint;
|
||||
/** The Falcon-512 signature envelope (AerePQCAttestation wire format). */
|
||||
signature: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one-tap upgrade attestation: derive the account-binding message hash,
|
||||
* derive the AerePQCAttestation challenge for (keyId, nonce, messageHash), and
|
||||
* sign that challenge with the Falcon-512 ROOT secret key. The resulting envelope
|
||||
* is exactly what AerePQCAttestation.attest()/verifySignature() consumes, and its
|
||||
* validity rests on a real Falcon-512 signature the live precompile verifies.
|
||||
*
|
||||
* `client` supplies the challenge derivation (chainId + attestation address);
|
||||
* it is used purely for the pure/local deriveChallenge — no network call here.
|
||||
* `secretKey` never leaves this process.
|
||||
*/
|
||||
export function buildUpgradeAttestation(
|
||||
client: AerePQCClient,
|
||||
descriptor: SeedlessAccountDescriptor,
|
||||
secretKey: Uint8Array,
|
||||
params: { keyId: bigint | number; nonce: bigint | number },
|
||||
): UpgradeAttestation {
|
||||
const messageHash = upgradeMessageHash(descriptor);
|
||||
const keyId = BigInt(params.keyId);
|
||||
const nonce = BigInt(params.nonce);
|
||||
const challenge = client.deriveChallenge(keyId, nonce, messageHash);
|
||||
const signature = signInternal(SCHEME.FALCON512, getBytes(challenge), secretKey);
|
||||
return { messageHash, challenge, keyId, nonce, signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* READ-ONLY on-chain verification of an upgrade attestation: re-derives the
|
||||
* challenge on-chain, then asks the live Falcon-512 precompile (via
|
||||
* AerePQCAttestation.verifySignature, a free eth_call) whether the envelope is a
|
||||
* valid Falcon signature over that challenge. No transaction; no state change.
|
||||
*/
|
||||
export async function verifyUpgradeAttestationLive(
|
||||
client: AerePQCClient,
|
||||
descriptor: SeedlessAccountDescriptor,
|
||||
att: UpgradeAttestation,
|
||||
): Promise<boolean> {
|
||||
if (!descriptor.root) throw new Error('hybridAuth: descriptor has no Falcon root');
|
||||
const onChainChallenge = await client.attestChallenge(att.keyId, att.nonce, att.messageHash);
|
||||
if (onChainChallenge.toLowerCase() !== att.challenge.toLowerCase()) return false;
|
||||
return client.verifySignature(
|
||||
SCHEME.FALCON512,
|
||||
getBytes(descriptor.root.publicKey),
|
||||
onChainChallenge,
|
||||
att.signature,
|
||||
);
|
||||
}
|
||||
|
||||
export interface PqcUserOpSignature {
|
||||
/** The ERC-4337 userOpHash (mirrors AereEntryPointV2.getUserOpHash). */
|
||||
userOpHash: string;
|
||||
/**
|
||||
* The AerePQCAccount signature bytes: abi.encode(bytes nonce, bytes compSig),
|
||||
* where compSig is the Falcon compressed body WITHOUT the 0x29 esig header.
|
||||
* Set this as userOp.signature for handleOps.
|
||||
*/
|
||||
accountSignature: string;
|
||||
/**
|
||||
* The SAME Falcon signature re-cast into the AerePQCAttestation wire envelope
|
||||
* (nonce(40) || 0x29 || compSig), for verifying the Falcon leg via the live
|
||||
* precompile (verifySignature) read-only.
|
||||
*/
|
||||
attestationEnvelope: Uint8Array;
|
||||
/** The raw noble detached signature (0x39 || nonce(40) || compSig). */
|
||||
detached: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an ERC-4337 UserOperation for the Falcon-owned AerePQCAccount with the
|
||||
* quantum-durable ROOT key. Produces both the account-consumable signature
|
||||
* (abi.encode(nonce, compSig)) and the AerePQCAttestation-format envelope for a
|
||||
* free on-chain verification of the Falcon leg.
|
||||
*
|
||||
* The account verifies validateUserOp over the DOMAIN-SEPARATED message
|
||||
* abi.encodePacked(USEROP_DOMAIN, userOpHash) via the SAME live AereFalcon512Verifier
|
||||
* the precompile wraps, so a `true` from {@link verifyUserOpFalconLegLive} is exactly
|
||||
* the check the account performs. (isValidSignature stays raw-hash for EIP-1271
|
||||
* integrators; the domain separation is what stops a login sig authorizing a spend.)
|
||||
*/
|
||||
export function signPqcAccountUserOp(
|
||||
op: PackedUserOp,
|
||||
entryPoint: string,
|
||||
chainId: bigint | number,
|
||||
secretKey: Uint8Array,
|
||||
): PqcUserOpSignature {
|
||||
const userOpHash = computeUserOpHash(op, entryPoint, chainId);
|
||||
const msg = getBytes(pqcAccountUserOpMessage(userOpHash));
|
||||
const detached = falcon512.sign(msg, secretKey);
|
||||
const expectedHdr = 0x30 | 9; // 0x39
|
||||
if (detached[0] !== expectedHdr || detached.length <= 1 + FALCON_NONCE_LEN) {
|
||||
throw new Error('hybridAuth: unexpected Falcon detached signature shape');
|
||||
}
|
||||
const nonce = detached.subarray(1, 1 + FALCON_NONCE_LEN);
|
||||
const compSig = detached.subarray(1 + FALCON_NONCE_LEN);
|
||||
const accountSignature = AbiCoder.defaultAbiCoder().encode(
|
||||
['bytes', 'bytes'],
|
||||
[hexlify(nonce), hexlify(compSig)],
|
||||
);
|
||||
const attestationEnvelope = falconDetachedToEnvelope(detached, SCHEME.FALCON512);
|
||||
return { userOpHash, accountSignature, attestationEnvelope, detached };
|
||||
}
|
||||
|
||||
/** Off-chain mirror of the AerePQCAccount Falcon check (fast pre-flight).
|
||||
* Uses the raw noble verify over the 64-byte domain-separated message (the SDK's
|
||||
* verifyLocal helper is locked to 32-byte messages); mirrors falcon512.sign in the
|
||||
* signer, and matches the on-chain AereFalcon512Verifier the account calls. */
|
||||
export function verifyPqcAccountUserOpLocal(
|
||||
sig: PqcUserOpSignature,
|
||||
falconPubKey: BytesLike,
|
||||
): boolean {
|
||||
try {
|
||||
return falcon512.verify(
|
||||
getBytes(sig.detached),
|
||||
getBytes(pqcAccountUserOpMessage(sig.userOpHash)),
|
||||
getBytes(falconPubKey),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* READ-ONLY on-chain verification of a UserOp's Falcon leg via the live precompile
|
||||
* (AerePQCAttestation.verifySignature). This is the exact lattice check the
|
||||
* AerePQCAccount runs in validateUserOp / isValidSignature — proven without
|
||||
* deploying the account or sending a transaction.
|
||||
*/
|
||||
export async function verifyUserOpFalconLegLive(
|
||||
client: AerePQCClient,
|
||||
sig: PqcUserOpSignature,
|
||||
falconPubKey: BytesLike,
|
||||
): Promise<boolean> {
|
||||
// The spend message is domain-separated (64 bytes), so it cannot go through the
|
||||
// 32-byte AerePQCAttestation path; verify it directly via the raw Falcon verifier,
|
||||
// exactly as AerePQCAccount.validateUserOp does.
|
||||
const [nonce, compSig] = AbiCoder.defaultAbiCoder().decode(['bytes', 'bytes'], sig.accountSignature);
|
||||
return client.verifyFalconRaw(
|
||||
falconPubKey,
|
||||
pqcAccountUserOpMessage(sig.userOpHash),
|
||||
nonce,
|
||||
compSig,
|
||||
);
|
||||
}
|
||||
58
src/wallet/index.ts
Normal file
58
src/wallet/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
// AERE seedless hybrid PQC wallet core (chain 2800).
|
||||
//
|
||||
// A wallet with NO seed phrase: a WebAuthn passkey is the everyday / daily factor
|
||||
// (behind the Universal-Login / AerePasskeyAccountFactoryV2Fixed stack) and a NIST
|
||||
// Falcon-512 co-owner is the quantum-durable root (an AerePQCAccount verified by
|
||||
// the live on-chain AereFalcon512Verifier / AerePQCAttestation precompile).
|
||||
//
|
||||
// createSeedlessAccount(passkey) -> descriptor (pre-upgrade)
|
||||
// generateFalconRoot() -> Falcon-512 keypair
|
||||
// encryptFalconSecretKey(kp, passphrase)-> client-side-encrypted root key
|
||||
// upgradeToPostQuantum(descriptor, pk) -> one-tap PQC upgrade (fuses the root)
|
||||
// buildUpgradeAttestation(...) -> real Falcon sig binding root->account
|
||||
// signPqcAccountUserOp(...) -> Falcon-signed ERC-4337 userOp
|
||||
// verify*Live(...) -> read-only precompile verification
|
||||
//
|
||||
// See docs/PQC-WALLET-DESIGN.md for the design + the custody recommendation.
|
||||
|
||||
export {
|
||||
WALLET_DESCRIPTOR_VERSION,
|
||||
type CustodyMode,
|
||||
type PasskeyDailyFactor,
|
||||
type FalconKeypair,
|
||||
type EncryptedFalconKey,
|
||||
type FalconRoot,
|
||||
type WalletAddresses,
|
||||
type SeedlessAccountDescriptor,
|
||||
} from './types.js';
|
||||
|
||||
export {
|
||||
DEFAULT_PBKDF2_ITERATIONS,
|
||||
generateFalconRoot,
|
||||
encryptFalconSecretKey,
|
||||
decryptFalconSecretKey,
|
||||
falconPubKeyHex,
|
||||
type EncryptOptions,
|
||||
} from './falconKeystore.js';
|
||||
|
||||
export {
|
||||
AERE_PQC_ACCOUNT_INIT_CODE_HASH,
|
||||
AERE_WALLET_ADDRESSES,
|
||||
createSeedlessAccount,
|
||||
predictPqcAccountAddress,
|
||||
upgradeToPostQuantum,
|
||||
type CreateSeedlessAccountOptions,
|
||||
type UpgradeOptions,
|
||||
} from './descriptor.js';
|
||||
|
||||
export {
|
||||
UPGRADE_ATTESTATION_DOMAIN,
|
||||
upgradeMessageHash,
|
||||
buildUpgradeAttestation,
|
||||
verifyUpgradeAttestationLive,
|
||||
signPqcAccountUserOp,
|
||||
verifyPqcAccountUserOpLocal,
|
||||
verifyUserOpFalconLegLive,
|
||||
type UpgradeAttestation,
|
||||
type PqcUserOpSignature,
|
||||
} from './hybridAuth.js';
|
||||
138
src/wallet/types.ts
Normal file
138
src/wallet/types.ts
Normal file
@ -0,0 +1,138 @@
|
||||
// Types for the AERE seedless hybrid PQC wallet core (chain 2800).
|
||||
//
|
||||
// The wallet fuses two authorities under ONE seedless account descriptor:
|
||||
// 1. a WebAuthn PASSKEY as the everyday / daily factor (hot secp256k1-routed
|
||||
// authority behind the AerePasskeyAccountFactoryV2Fixed / Universal-Login
|
||||
// stack) — fast, biometric, phishing-resistant, but CLASSICALLY secure; and
|
||||
// 2. a NIST Falcon-512 CO-OWNER as the quantum-durable root (an AerePQCAccount,
|
||||
// verified end-to-end by the live on-chain AereFalcon512Verifier /
|
||||
// AerePQCAttestation precompile).
|
||||
//
|
||||
// There is NO seed phrase anywhere. The passkey never leaves the device's
|
||||
// authenticator; the Falcon private key is protected by one of the CustodyMode
|
||||
// options below. See docs/PQC-WALLET-DESIGN.md for the full design + the custody
|
||||
// recommendation flagged for the founder.
|
||||
|
||||
import type { SchemeId } from '../pqc/index.js';
|
||||
|
||||
/** Envelope format version for descriptors + encrypted-key blobs. */
|
||||
export const WALLET_DESCRIPTOR_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* How the Falcon-512 quantum-durable ROOT private key is custodied.
|
||||
*
|
||||
* - 'client-encrypted' — the Falcon secret key is generated on the client and
|
||||
* stored ONLY as an AES-256-GCM ciphertext, unlocked by a user passphrase via
|
||||
* PBKDF2. Portable across devices, recoverable from the encrypted blob alone,
|
||||
* never touches a server in the clear. This is the RECOMMENDED default (see the
|
||||
* design doc): it makes the quantum-lock co-owner portable without a seed.
|
||||
*
|
||||
* - 'secure-enclave' — the Falcon key material is sealed to a device secure
|
||||
* enclave / TPM / StrongBox and never leaves it. Strongest exfiltration
|
||||
* resistance, but NOT portable and NOT recoverable if the device is lost, so it
|
||||
* must be paired with the passkey daily factor + a recovery path.
|
||||
*
|
||||
* - 'passkey-wrapped' — the Falcon key is wrapped by a symmetric key derived from
|
||||
* a PRF/largeBlob extension of the passkey authenticator, so "unlock the Falcon
|
||||
* key" == "present the passkey". One factor, best UX, but it collapses the
|
||||
* quantum-durable root's independence into the passkey's availability.
|
||||
*/
|
||||
export type CustodyMode = 'client-encrypted' | 'secure-enclave' | 'passkey-wrapped';
|
||||
|
||||
/**
|
||||
* The everyday / daily authority: a WebAuthn passkey behind a Universal-Login
|
||||
* smart account. In production the on-device credential is a P-256 passkey; the
|
||||
* on-chain owner word this maps to is the secp256k1/passkey owner the
|
||||
* AerePasskeyAccount / Universal-Login account routes to. This descriptor keeps
|
||||
* only PUBLIC material — no private key, no seed.
|
||||
*/
|
||||
export interface PasskeyDailyFactor {
|
||||
type: 'passkey';
|
||||
/** WebAuthn credential id (base64url), if known. Public, non-secret. */
|
||||
credentialId?: string;
|
||||
/**
|
||||
* The on-chain owner authority address this passkey maps to (the hot
|
||||
* secp256k1 / passkey owner word the Universal-Login account authorizes with).
|
||||
*/
|
||||
authorityAddress: string;
|
||||
/** Human label for the device / passkey (e.g. "iPhone Face ID"). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A generated Falcon-512 keypair (raw NIST bytes). Secret key is sensitive. */
|
||||
export interface FalconKeypair {
|
||||
scheme: SchemeId; // always SCHEME.FALCON512 for the wallet root today
|
||||
publicKey: Uint8Array; // 897-byte NIST Falcon-512 pubkey (0x09 header)
|
||||
secretKey: Uint8Array; // raw NIST secret key — keep private
|
||||
}
|
||||
|
||||
/**
|
||||
* A client-side-encrypted Falcon secret key. Fully serializable (all hex /
|
||||
* primitive fields), safe to persist or sync: it reveals nothing without the
|
||||
* passphrase. Decryptable ONLY by {@link decryptFalconSecretKey}.
|
||||
*/
|
||||
export interface EncryptedFalconKey {
|
||||
version: typeof WALLET_DESCRIPTOR_VERSION;
|
||||
scheme: SchemeId;
|
||||
/** 0x-hex of the 897-byte Falcon-512 public key (kept in clear; it is public). */
|
||||
publicKey: string;
|
||||
/** Key-derivation function; only PBKDF2-SHA256 today. */
|
||||
kdf: 'PBKDF2-SHA256';
|
||||
/** 0x-hex PBKDF2 salt. */
|
||||
kdfSalt: string;
|
||||
/** PBKDF2 iteration count. */
|
||||
kdfIterations: number;
|
||||
/** AEAD cipher; only AES-256-GCM today. */
|
||||
cipher: 'AES-256-GCM';
|
||||
/** 0x-hex 12-byte GCM IV / nonce. */
|
||||
iv: string;
|
||||
/** 0x-hex ciphertext (includes the GCM auth tag as produced by WebCrypto). */
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Falcon-512 quantum-durable ROOT of an upgraded account: the public key,
|
||||
* the counterfactual AerePQCAccount address it owns, its custody mode, and
|
||||
* (optionally) the encrypted secret-key blob when custody is 'client-encrypted'.
|
||||
*/
|
||||
export interface FalconRoot {
|
||||
scheme: SchemeId;
|
||||
/** 0x-hex of the 897-byte Falcon-512 public key. */
|
||||
publicKey: string;
|
||||
/** CREATE2 counterfactual AerePQCAccount address owned solely by this key. */
|
||||
pqcAccount: string;
|
||||
/** Salt used in the AerePQCAccountFactory CREATE2 derivation. */
|
||||
salt: string; // uint256 as decimal string
|
||||
custody: CustodyMode;
|
||||
/** Present iff custody === 'client-encrypted'. */
|
||||
encryptedKey?: EncryptedFalconKey;
|
||||
}
|
||||
|
||||
/** On-chain addresses the wallet binds to (chain 2800 defaults filled in). */
|
||||
export interface WalletAddresses {
|
||||
entryPoint: string;
|
||||
passkeyFactory: string;
|
||||
pqcAccountFactory: string;
|
||||
falconVerifier: string;
|
||||
pqcAttestation: string;
|
||||
/** keccak256 of the AerePQCAccount creation bytecode (factory constant). */
|
||||
pqcAccountInitCodeHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single seedless account descriptor. Before "upgrade to post-quantum" it
|
||||
* carries only the passkey daily factor (`root` undefined); after the one-tap
|
||||
* upgrade it also carries the Falcon quantum-durable root. Entirely PUBLIC +
|
||||
* serializable except for `root.encryptedKey`, which is a ciphertext.
|
||||
*/
|
||||
export interface SeedlessAccountDescriptor {
|
||||
version: typeof WALLET_DESCRIPTOR_VERSION;
|
||||
chainId: number;
|
||||
/** Everyday authority — the passkey. Always present. */
|
||||
daily: PasskeyDailyFactor;
|
||||
/** Quantum-durable root — the Falcon-512 co-owner. Present once upgraded. */
|
||||
root?: FalconRoot;
|
||||
addresses: WalletAddresses;
|
||||
/** True once upgradeToPostQuantum has fused a Falcon root. */
|
||||
postQuantum: boolean;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user