From 6d6c18ae41a9b0fafee2f87caeeb749349eefb55 Mon Sep 17 00:00:00 2001 From: Liviu Date: Sat, 15 Aug 2026 14:00:46 +0300 Subject: [PATCH] SDK 0.16.12: the address book and typed clients catch up with the live chain, and client mentions get the same discretion as everywhere else The published repository served the 0.3.0 line from May while the install page already pointed here. This carries the uncommitted line: 35+ typed contract clients over the canonical address book, the compliance surface, and the hybrid PQC wallet helpers. --- README.md | 317 +++++++++++--- examples/agent-402-pqc-client.js | 94 +++++ examples/modular-account-demo.mjs | 132 ++++++ examples/pqc-identity-e2e.mjs | 165 ++++++++ examples/threshold-account-demo.mjs | 65 +++ package-lock.json | 63 ++- src/abis.ts | 22 +- src/account/AerePQCSocialRecoveryClient.ts | 282 +++++++++++++ src/account/ModularAccountClient.ts | 307 ++++++++++++++ src/account/SessionKeyClient.ts | 156 +++++++ src/account/SocialRecoveryClient.ts | 130 ++++++ src/account/abi.ts | 254 ++++++++++++ src/account/hash.ts | 111 +++++ src/account/index.ts | 68 +++ src/account/types.ts | 79 ++++ src/agentic/AERE402FacilitatorPQCClient.ts | 257 ++++++++++++ src/agentic/index.ts | 10 + src/cli/aere-cli.ts | 353 ++++++++++++++++ src/client.ts | 61 ++- src/compliance/index.ts | 8 + .../AereCryptoRegistryClient.ts | 219 ++++++++++ .../AereHybridAuthorizerClient.ts | 232 +++++++++++ src/crypto-agility/index.ts | 18 + src/index.ts | 116 ++++++ src/intents/AerePQCOrderBuilder.ts | 201 +++++++++ src/intents/index.ts | 15 + src/mpc/AereThresholdAccountClient.ts | 176 ++++++++ src/mpc/demo.ts | 88 ++++ src/mpc/dkg.ts | 148 +++++++ src/mpc/index.ts | 15 + src/mpc/secp.ts | 107 +++++ src/mpc/sign.ts | 95 +++++ src/pqc/AereAgentActionReceiptClient.ts | 327 +++++++++++++++ src/pqc/AereAgentDIDClient.ts | 365 ++++++++++++++++ src/pqc/AerePQCClient.ts | 234 +++++++++++ src/pqc/AerePQCKeyRegistryClient.ts | 275 ++++++++++++ src/pqc/envelope.ts | 152 +++++++ src/pqc/index.ts | 49 +++ src/pqc/schemes.ts | 131 ++++++ src/state-window.ts | 392 ++++++++++++++++++ src/test/agent-action-receipt.test.ts | 94 +++++ src/test/agent-did.test.ts | 149 +++++++ src/test/crypto-agility-live.test.ts | 132 ++++++ src/test/pqc-envelope.test.ts | 118 ++++++ src/test/pqc-interop.test.ts | 78 ++++ src/test/pqc-keyregistry.test.ts | 139 +++++++ src/test/pqc-order.test.ts | 112 +++++ src/test/state-window.test.ts | 236 +++++++++++ src/test/wallet.test.ts | 278 +++++++++++++ src/wallet/descriptor.ts | 154 +++++++ src/wallet/falconKeystore.ts | 142 +++++++ src/wallet/hybridAuth.ts | 235 +++++++++++ src/wallet/index.ts | 58 +++ src/wallet/types.ts | 138 ++++++ 54 files changed, 8293 insertions(+), 59 deletions(-) create mode 100644 examples/agent-402-pqc-client.js create mode 100644 examples/modular-account-demo.mjs create mode 100644 examples/pqc-identity-e2e.mjs create mode 100644 examples/threshold-account-demo.mjs create mode 100644 src/account/AerePQCSocialRecoveryClient.ts create mode 100644 src/account/ModularAccountClient.ts create mode 100644 src/account/SessionKeyClient.ts create mode 100644 src/account/SocialRecoveryClient.ts create mode 100644 src/account/abi.ts create mode 100644 src/account/hash.ts create mode 100644 src/account/index.ts create mode 100644 src/account/types.ts create mode 100644 src/agentic/AERE402FacilitatorPQCClient.ts create mode 100644 src/cli/aere-cli.ts create mode 100644 src/crypto-agility/AereCryptoRegistryClient.ts create mode 100644 src/crypto-agility/AereHybridAuthorizerClient.ts create mode 100644 src/crypto-agility/index.ts create mode 100644 src/intents/AerePQCOrderBuilder.ts create mode 100644 src/intents/index.ts create mode 100644 src/mpc/AereThresholdAccountClient.ts create mode 100644 src/mpc/demo.ts create mode 100644 src/mpc/dkg.ts create mode 100644 src/mpc/index.ts create mode 100644 src/mpc/secp.ts create mode 100644 src/mpc/sign.ts create mode 100644 src/pqc/AereAgentActionReceiptClient.ts create mode 100644 src/pqc/AereAgentDIDClient.ts create mode 100644 src/pqc/AerePQCClient.ts create mode 100644 src/pqc/AerePQCKeyRegistryClient.ts create mode 100644 src/pqc/envelope.ts create mode 100644 src/pqc/index.ts create mode 100644 src/pqc/schemes.ts create mode 100644 src/state-window.ts create mode 100644 src/test/agent-action-receipt.test.ts create mode 100644 src/test/agent-did.test.ts create mode 100644 src/test/crypto-agility-live.test.ts create mode 100644 src/test/pqc-envelope.test.ts create mode 100644 src/test/pqc-interop.test.ts create mode 100644 src/test/pqc-keyregistry.test.ts create mode 100644 src/test/pqc-order.test.ts create mode 100644 src/test/state-window.test.ts create mode 100644 src/test/wallet.test.ts create mode 100644 src/wallet/descriptor.ts create mode 100644 src/wallet/falconKeystore.ts create mode 100644 src/wallet/hybridAuth.ts create mode 100644 src/wallet/index.ts create mode 100644 src/wallet/types.ts diff --git a/README.md b/README.md index 8e18548..67909b4 100644 --- a/README.md +++ b/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 `. -- 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 # block summary: hash, parent, timestamp, tx count, gas, proposer +aere tx # transaction + receipt summary +aere addr
# 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 ` | 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 --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 diff --git a/examples/agent-402-pqc-client.js b/examples/agent-402-pqc-client.js new file mode 100644 index 0000000..7f2534c --- /dev/null +++ b/examples/agent-402-pqc-client.js @@ -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()); +})(); diff --git a/examples/modular-account-demo.mjs b/examples/modular-account-demo.mjs new file mode 100644 index 0000000..6554ccd --- /dev/null +++ b/examples/modular-account-demo.mjs @@ -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.'); +} diff --git a/examples/pqc-identity-e2e.mjs b/examples/pqc-identity-e2e.mjs new file mode 100644 index 0000000..2c95b46 --- /dev/null +++ b/examples/pqc-identity-e2e.mjs @@ -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.'); diff --git a/examples/threshold-account-demo.mjs b/examples/threshold-account-demo.mjs new file mode 100644 index 0000000..b82886c --- /dev/null +++ b/examples/threshold-account-demo.mjs @@ -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. ==='); diff --git a/package-lock.json b/package-lock.json index 6f5e57f..0d07668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/abis.ts b/src/abis.ts index ed674c9..84810ce 100644 --- a/src/abis.ts +++ b/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/.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', diff --git a/src/account/AerePQCSocialRecoveryClient.ts b/src/account/AerePQCSocialRecoveryClient.ts new file mode 100644 index 0000000..8d2eccf --- /dev/null +++ b/src/account/AerePQCSocialRecoveryClient.ts @@ -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, 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 { + return this.contract.recoveryNonce(account); + } + + async guardianKeysOf(account: string): Promise { + return this.contract.guardianKeysOf(account); + } + + async thresholdOf(account: string): Promise { + return this.contract.thresholdOf(account); + } + + async guardianCount(account: string): Promise { + return this.contract.guardianCount(account); + } + + async isGuardianKey(account: string, keyId: bigint | number): Promise { + return this.contract.isGuardianKey(account, keyId); + } + + /** The AerePQCKeyRegistry this module verifies guardian keys against. */ + async registry(): Promise { + 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, 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 { + 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 { + 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 { + 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 { + return this.contract.scheduleRecovery(account, newOwner, encodeLegs(legs)); + } + + /** Execute a scheduled recovery once the 48h timelock elapses (permissionless). */ + async executeRecovery(account: string): Promise { + return this.contract.executeRecovery(account); + } + + /** Cancel a pending recovery (current root owner or the account itself only). */ + async cancelRecovery(account: string): Promise { + return this.contract.cancelRecovery(account); + } +} diff --git a/src/account/ModularAccountClient.ts b/src/account/ModularAccountClient.ts new file mode 100644 index 0000000..df2e96a --- /dev/null +++ b/src/account/ModularAccountClient.ts @@ -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> = { + 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 = 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 { + const data = SEL.getAddress + encAddr(rootOwner) + encUint(salt); + return toChecksumAddress(decodeAddress(await this.ethCall(this.factory, data))); + } + + async isDeployed(account: string): Promise { + 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 { + return toChecksumAddress(decodeAddress(await this.ethCall(account, SEL.rootOwner))); + } + async entryPointOf(account: string): Promise { + return toChecksumAddress(decodeAddress(await this.ethCall(account, SEL.entryPoint))); + } + async isModuleInstalled(account: string, moduleTypeId: number, module: string): Promise { + return decodeBool(await this.ethCall(account, encodeIsModuleInstalled(moduleTypeId, module))); + } + async isValidatorInstalled(account: string, validator: string): Promise { + return this.isModuleInstalled(account, MODULE_TYPE_VALIDATOR, validator); + } + async isExecutorInstalled(account: string, module: string): Promise { + return this.isModuleInstalled(account, MODULE_TYPE_EXECUTOR, module); + } + /** EntryPoint deposit balance credited to the account. */ + async depositOf(account: string): Promise { + return decodeUint(await this.ethCall(this.entryPoint, SEL.balanceOf + encAddr(account))); + } + /** EntryPoint sequential nonce for the account. */ + async getNonce(account: string): Promise { + 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 { + 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 { + return this.ethSend(from, account, this.encodeInstallModule(moduleTypeId, module, initData)); + } + async sendUninstallModule(from: string, account: string, moduleTypeId: number, module: string, deInitData: string): Promise { + 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 { + 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 { + 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; + } + + // ---- 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 { + 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 { + 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 { + 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 { + return this.ethSend(from, this.entryPoint, encodeHandleOps(ops, beneficiary)); + } + + // ---- low-level RPC -------------------------------------------------------- + + private async gasPrice(): Promise { + 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 { + 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 { + return this.provider.request({ + method: 'eth_sendTransaction', + params: [{ from, to, data, value: '0x0' }], + }) as Promise; + } +} diff --git a/src/account/SessionKeyClient.ts b/src/account/SessionKeyClient.ts new file mode 100644 index 0000000..18394ba --- /dev/null +++ b/src/account/SessionKeyClient.ts @@ -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 { + 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 { + 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 { + const sig = await sessionSigner(account.userOpHash(op)); + return { ...op, signature: this.buildSessionSignature(sig) }; + } + + private async ethCall(data: string): Promise { + 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; + } +} diff --git a/src/account/SocialRecoveryClient.ts b/src/account/SocialRecoveryClient.ts new file mode 100644 index 0000000..7d0d61f --- /dev/null +++ b/src/account/SocialRecoveryClient.ts @@ -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 { + return this.ethSend(fromGuardian, this.encodeInitiateRecovery(account, newOwner)); + } + /** Another guardian approves the active round. */ + async sendSupportRecovery(fromGuardian: string, account: string): Promise { + 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 { + return this.ethSend(fromGuardian, this.encodeExecuteRecovery(account)); + } + /** Current owner (or the account) cancels a pending recovery. */ + async sendCancelRecovery(fromOwner: string, account: string): Promise { + return this.ethSend(fromOwner, this.encodeCancelRecovery(account)); + } + + // ---- Reads ---------------------------------------------------------------- + + async guardiansOf(account: string): Promise { + const data = SEL.guardiansOf + encAddr(account); + return decodeAddressArray(await this.ethCall(data)).map(toChecksumAddress); + } + async isGuardian(account: string, guardian: string): Promise { + const data = SEL.isGuardian + encAddr(account) + encAddr(guardian); + return decodeBool(await this.ethCall(data)); + } + async thresholdOf(account: string): Promise { + return decodeUint(await this.ethCall(SEL.thresholdOf + encAddr(account))); + } + async recoveryNonce(account: string): Promise { + return decodeUint(await this.ethCall(SEL.recoveryNonce + encAddr(account))); + } + async hasApproved(account: string, round: bigint, guardian: string): Promise { + 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 { + 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 { + 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 { + return this.provider.request({ + method: 'eth_sendTransaction', + params: [{ from, to: this.module, data, value: '0x0' }], + }) as Promise; + } +} diff --git a/src/account/abi.ts b/src/account/abi.ts new file mode 100644 index 0000000..0dc4a97 --- /dev/null +++ b/src/account/abi.ts @@ -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; +} diff --git a/src/account/hash.ts b/src/account/hash.ts new file mode 100644 index 0000000..1b67a21 --- /dev/null +++ b/src/account/hash.ts @@ -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)); +} diff --git a/src/account/index.ts b/src/account/index.ts new file mode 100644 index 0000000..e3383d4 --- /dev/null +++ b/src/account/index.ts @@ -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'; diff --git a/src/account/types.ts b/src/account/types.ts new file mode 100644 index 0000000..e012ff4 --- /dev/null +++ b/src/account/types.ts @@ -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; +} + +/** + * 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; + +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'; diff --git a/src/agentic/AERE402FacilitatorPQCClient.ts b/src/agentic/AERE402FacilitatorPQCClient.ts new file mode 100644 index 0000000..0a02db6 --- /dev/null +++ b/src/agentic/AERE402FacilitatorPQCClient.ts @@ -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 { + 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 { + return this.contract.balances(rootKeyId, token); + } + + /** True iff the agent's settlement path is paused. */ + async isPaused(rootKeyId: bigint | number): Promise { + return this.contract.paused(rootKeyId); + } + + /** The agent's current controller (registry owner of the Falcon root), or zero. */ + async controllerOf(rootKeyId: bigint | number): Promise { + return this.contract.controllerOf(rootKeyId); + } + + /** True iff `account` is the agent's current controller. */ + async isController(rootKeyId: bigint | number, account: string): Promise { + return this.contract.isController(rootKeyId, account); + } + + /** True iff the session is live (root ACTIVE Falcon, not revoked, unexpired). */ + async isSessionValid(sessionId: bigint | number): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/agentic/index.ts b/src/agentic/index.ts index 9be63be..e884409 100644 --- a/src/agentic/index.ts +++ b/src/agentic/index.ts @@ -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'; diff --git a/src/cli/aere-cli.ts b/src/cli/aere-cli.ts new file mode 100644 index 0000000..afbf84f --- /dev/null +++ b/src/cli/aere-cli.ts @@ -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 [args] [--rpc ] +// +// Commands: chain · block · tx · addr
+// 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 = { + [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 { + 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 { + const tagArg = p._[1]; + if (!tagArg) throw new UsageError('block requires '); + 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 { + const hash = p._[1]; + if (!hash) throw new UsageError('tx requires '); + + 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 { + const raw = p._[1]; + if (!raw) throw new UsageError('addr requires
'); + 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 [args] [options] + +Commands: + chain Chain id, block height, base fee, gas price, validators, peers + block Block summary (hash, parent, timestamp, txs, gas, proposer) + tx Transaction + receipt summary + addr
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 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 { + 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 ] — chain id, height, base fee, gas price, validators, peers'); return; } + await cmdChain(p); + return; + case 'block': + if (p.help) { console.log('aere block [--rpc ] — block summary'); return; } + await cmdBlock(p); + return; + case 'tx': + if (p.help) { console.log('aere tx [--rpc ] — transaction + receipt summary'); return; } + await cmdTx(p); + return; + case 'addr': + if (p.help) { console.log('aere addr
[--rpc ] — 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); +}); diff --git a/src/client.ts b/src/client.ts index 04d1d2d..c4e8153 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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, + rewardRateBps: () => c.rewardRateBps() as Promise, + validators: (v: string) => c.validators(v), + delegations: (validator: string, delegator: string) => c.delegations(validator, delegator), + validatorCount: () => c.validatorCount() as Promise, + totalStaked: () => c.totalStaked() as Promise, + }; + } + /** 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; diff --git a/src/compliance/index.ts b/src/compliance/index.ts index 452baa6..e5b878b 100644 --- a/src/compliance/index.ts +++ b/src/compliance/index.ts @@ -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) diff --git a/src/crypto-agility/AereCryptoRegistryClient.ts b/src/crypto-agility/AereCryptoRegistryClient.ts new file mode 100644 index 0000000..637ad79 --- /dev/null +++ b/src/crypto-agility/AereCryptoRegistryClient.ts @@ -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 { + 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 { + return this.contract.resolveActive(algorithmId); + } + + /** Read a full algorithm row. Reverts (UnknownAlgorithm) on an unregistered id. */ + async getAlgorithm(id: bigint | number): Promise { + 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 { + return this.contract.algorithmCount(); + } + + /** Lifecycle status of `id` (UNKNOWN for a never-registered id). */ + async statusOf(id: bigint | number): Promise { + return Number(await this.contract.statusOf(id)) as CryptoStatus; + } + + /** True iff `id` exists and is ACTIVE. */ + async isActive(id: bigint | number): Promise { + 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 { + 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 { + return this.contract.precompileFor(scheme); + } + + /** Whether the one-shot seedLiveSchemes() has already run. */ + async seeded(): Promise { + return this.contract.seeded(); + } + + /** The current owner (deployer now; Timelock later, founder-signed). */ + async owner(): Promise { + 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 { + 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 { + 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 { + 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 { + const c = this.contract.connect(signer) as Contract; + return c.seedLiveSchemes(); + } +} diff --git a/src/crypto-agility/AereHybridAuthorizerClient.ts b/src/crypto-agility/AereHybridAuthorizerClient.ts new file mode 100644 index 0000000..b5633b1 --- /dev/null +++ b/src/crypto-agility/AereHybridAuthorizerClient.ts @@ -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 { + return this.contract.registry(); + } + + /** The current owner (deployer now; Timelock later, founder-signed). */ + async owner(): Promise { + return this.contract.owner(); + } + + /** The app-wide default algorithm id (stable reference; re-resolved on every check). */ + async defaultAlgorithmId(): Promise { + return this.contract.defaultAlgorithmId(); + } + + // ---- preference views ------------------------------------------------------ + + /** The raw stored preference for `account` (0 == none / use the app default). */ + async preferredAlgorithmOf(account: string): Promise { + return this.contract.preferredAlgorithmOf(account); + } + + /** The effective (pre-resolution) algorithm id for `account`: preference if set, else default. */ + async algorithmFor(account: string): Promise { + 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 { + 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 { + 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 { + 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 { + return this.contract.isProofAuthorized(hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash))); + } + + // ---- ledger views ---------------------------------------------------------- + + /** Number of successful authorize() calls recorded. */ + async authorizationCount(): Promise { + return this.contract.authorizationCount(); + } + + /** The ACTIVE algorithm id used by the most recent successful authorization. */ + async lastResolvedId(): Promise { + return this.contract.lastResolvedId(); + } + + /** The message hash of the most recent successful authorization. */ + async lastMessageHash(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const c = this.contract.connect(signer) as Contract; + return c.authorizeWithPreferred( + hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)), + ); + } +} diff --git a/src/crypto-agility/index.ts b/src/crypto-agility/index.ts new file mode 100644 index 0000000..e1d4370 --- /dev/null +++ b/src/crypto-agility/index.ts @@ -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'; diff --git a/src/index.ts b/src/index.ts index ff1d26f..54762c0 100644 --- a/src/index.ts +++ b/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'; diff --git a/src/intents/AerePQCOrderBuilder.ts b/src/intents/AerePQCOrderBuilder.ts new file mode 100644 index 0000000..177b87c --- /dev/null +++ b/src/intents/AerePQCOrderBuilder.ts @@ -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; + +/** + * 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 { + 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 }; diff --git a/src/intents/index.ts b/src/intents/index.ts new file mode 100644 index 0000000..4491a76 --- /dev/null +++ b/src/intents/index.ts @@ -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'; diff --git a/src/mpc/AereThresholdAccountClient.ts b/src/mpc/AereThresholdAccountClient.ts new file mode 100644 index 0000000..1ef9bb9 --- /dev/null +++ b/src/mpc/AereThresholdAccountClient.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/mpc/demo.ts b/src/mpc/demo.ts new file mode 100644 index 0000000..505488f --- /dev/null +++ b/src/mpc/demo.ts @@ -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 ==="); diff --git a/src/mpc/dkg.ts b/src/mpc/dkg.ts new file mode 100644 index 0000000..3ca897a --- /dev/null +++ b/src/mpc/dkg.ts @@ -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)); +} diff --git a/src/mpc/index.ts b/src/mpc/index.ts new file mode 100644 index 0000000..4526ab2 --- /dev/null +++ b/src/mpc/index.ts @@ -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"; diff --git a/src/mpc/secp.ts b/src/mpc/secp.ts new file mode 100644 index 0000000..02c802f --- /dev/null +++ b/src/mpc/secp.ts @@ -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; + +/** 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"); +} diff --git a/src/mpc/sign.ts b/src/mpc/sign.ts new file mode 100644 index 0000000..5fcbf26 --- /dev/null +++ b/src/mpc/sign.ts @@ -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." }, + ], + }; +} diff --git a/src/pqc/AereAgentActionReceiptClient.ts b/src/pqc/AereAgentActionReceiptClient.ts new file mode 100644 index 0000000..2262a47 --- /dev/null +++ b/src/pqc/AereAgentActionReceiptClient.ts @@ -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 { + 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 { + return this.contract.receiptNonceOf(sessionId); + } + + /** The canonical bond/reputation agentId for a DID rootKeyId (on-chain view). */ + async bondKeyOf(rootKeyId: bigint | number): Promise { + return this.contract.bondKeyOf(rootKeyId); + } + + /** The operator (bond/reputation key) this contract reads for an agent (DID controller). */ + async operatorOf(rootKeyId: bigint | number): Promise { + 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 { + return this.contract.receiptCount(); + } + + /** Read a receipt. Reverts (UnknownReceipt) on an out-of-range id. */ + async getReceipt(receiptId: bigint | number): Promise { + 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 { + 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 { + 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 { + 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 { + return this.contract.receiptsOf(rootKeyId); + } + + /** All receiptIds recorded under a session (append-only, chronological). */ + async receiptsOfSession(sessionId: bigint | number): Promise { + return this.contract.receiptsOfSession(sessionId); + } + + /** The AereAgentDID this receipt contract roots session verification into. */ + async did(): Promise { + 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 { + 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 { + 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 }; + } +} diff --git a/src/pqc/AereAgentDIDClient.ts b/src/pqc/AereAgentDIDClient.ts new file mode 100644 index 0000000..6eb74f7 --- /dev/null +++ b/src/pqc/AereAgentDIDClient.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return this.contract.agentExists(rootKeyId); + } + + /** Total sessions ever issued (sessionIds run 0..sessionCount-1). */ + async sessionCount(): Promise { + return this.contract.sessionCount(); + } + + /** Read a session. Reverts (UnknownSession) on an out-of-range id. */ + async getSession(sessionId: bigint | number): Promise { + 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 { + return this.contract.isSessionValid(sessionId); + } + + /** Remaining spend under a session (0 if invalid/exhausted). */ + async remainingSpend(sessionId: bigint | number): Promise { + return this.contract.remainingSpend(sessionId); + } + + /** All sessionIds ever issued for an agent (append-only, includes revoked). */ + async sessionsOf(rootKeyId: bigint | number): Promise { + return this.contract.sessionsOf(rootKeyId); + } + + /** The AerePQCKeyRegistry this DID roots into. */ + async keyRegistry(): Promise { + 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 { + return this.contract.createAgent(rootKeyId); + } + + /** Issue a session with a pre-built Falcon PoP envelope. */ + async issueSession(p: SessionParams, popSig: BytesLike): Promise { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/pqc/AerePQCClient.ts b/src/pqc/AerePQCClient.ts new file mode 100644 index 0000000..d043f9a --- /dev/null +++ b/src/pqc/AerePQCClient.ts @@ -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 { + 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 { + 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 { + 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 { + return this.contract.attestationId(keyId, nonce); + } + + /** Current (next unused) per-key nonce. */ + async nonceOf(keyId: bigint | number): Promise { + return this.contract.nonceOf(keyId); + } + + /** Number of registered keys (keyIds run 0..keyCount-1). */ + async keyCount(): Promise { + return this.contract.keyCount(); + } + + /** Total attestations recorded across all keys. */ + async attestationCount(): Promise { + return this.contract.attestationCount(); + } + + /** Read a registered key. */ + async getKey(keyId: bigint | number): Promise { + 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 { + 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 { + return this.contract.isValidAttestation(keyId, nonce, bytes32(messageHash)); + } + + /** The live precompile address for a scheme. */ + async precompileFor(scheme: SchemeId): Promise { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/pqc/AerePQCKeyRegistryClient.ts b/src/pqc/AerePQCKeyRegistryClient.ts new file mode 100644 index 0000000..3bcb306 --- /dev/null +++ b/src/pqc/AerePQCKeyRegistryClient.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.contract.keyCount(); + } + + /** Read a registered key. Reverts (UnknownKey) on an out-of-range keyId. */ + async getKey(keyId: bigint | number): Promise { + 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 { + 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 { + return this.contract.isActiveKey(keyId); + } + + /** All keyIds ever registered by `owner` (append-only, includes retired). */ + async keysOf(owner: string): Promise { + return this.contract.keysOf(owner); + } + + /** The live precompile address for a scheme. */ + async precompileFor(scheme: SchemeId): Promise { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/pqc/envelope.ts b/src/pqc/envelope.ts new file mode 100644 index 0000000..72f8bbf --- /dev/null +++ b/src/pqc/envelope.ts @@ -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}`); + } +} diff --git a/src/pqc/index.ts b/src/pqc/index.ts new file mode 100644 index 0000000..daa304a --- /dev/null +++ b/src/pqc/index.ts @@ -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'; diff --git a/src/pqc/schemes.ts b/src/pqc/schemes.ts new file mode 100644 index 0000000..ae370a3 --- /dev/null +++ b/src/pqc/schemes.ts @@ -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 = { + [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 = { + [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; + } +} diff --git a/src/state-window.ts b/src/state-window.ts new file mode 100644 index 0000000..aa8df28 --- /dev/null +++ b/src/state-window.ts @@ -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; + +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 }, opts: StateWindowReaderOptions = {}): StateWindowReader { + return new StateWindowReader((m, p) => provider.send(m, p), opts); + } + + async head(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, tag: BlockTag = 'latest'): Promise { + 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 { + 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, + }); + } +} diff --git a/src/test/agent-action-receipt.test.ts b/src/test/agent-action-receipt.test.ts new file mode 100644 index 0000000..c191bf8 --- /dev/null +++ b/src/test/agent-action-receipt.test.ts @@ -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/, + ); +}); diff --git a/src/test/agent-did.test.ts b/src/test/agent-did.test.ts new file mode 100644 index 0000000..cc9e6b5 --- /dev/null +++ b/src/test/agent-did.test.ts @@ -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: { skip: (m?: string) => void }, fn: () => Promise): Promise { + 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); +}); diff --git a/src/test/crypto-agility-live.test.ts b/src/test/crypto-agility-live.test.ts new file mode 100644 index 0000000..3b8c182 --- /dev/null +++ b/src/test/crypto-agility-live.test.ts @@ -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: { skip: (m?: string) => void }, fn: () => Promise): Promise { + 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'); +}); diff --git a/src/test/pqc-envelope.test.ts b/src/test/pqc-envelope.test.ts new file mode 100644 index 0000000..10d952f --- /dev/null +++ b/src/test/pqc-envelope.test.ts @@ -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}`); + } +}); diff --git a/src/test/pqc-interop.test.ts b/src/test/pqc-interop.test.ts new file mode 100644 index 0000000..427ed87 --- /dev/null +++ b/src/test/pqc-interop.test.ts @@ -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: { skip: (m?: string) => void }, fn: () => Promise): Promise { + 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'); +}); diff --git a/src/test/pqc-keyregistry.test.ts b/src/test/pqc-keyregistry.test.ts new file mode 100644 index 0000000..7f06345 --- /dev/null +++ b/src/test/pqc-keyregistry.test.ts @@ -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: { skip: (m?: string) => void }, fn: () => Promise): Promise { + 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'); +}); diff --git a/src/test/pqc-order.test.ts b/src/test/pqc-order.test.ts new file mode 100644 index 0000000..e01baba --- /dev/null +++ b/src/test/pqc-order.test.ts @@ -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 = {}) { + 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); +}); diff --git a/src/test/state-window.test.ts b/src/test/state-window.test.ts new file mode 100644 index 0000000..ef13317 --- /dev/null +++ b/src/test/state-window.test.ts @@ -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; + 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)); +}); diff --git a/src/test/wallet.test.ts b/src/test/wallet.test.ts new file mode 100644 index 0000000..20efee1 --- /dev/null +++ b/src/test/wallet.test.ts @@ -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: { skip: (m?: string) => void }, fn: () => Promise): Promise { + 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'); +}); diff --git a/src/wallet/descriptor.ts b/src/wallet/descriptor.ts new file mode 100644 index 0000000..8576261 --- /dev/null +++ b/src/wallet/descriptor.ts @@ -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 }; +} diff --git a/src/wallet/falconKeystore.ts b/src/wallet/falconKeystore.ts new file mode 100644 index 0000000..43d0848 --- /dev/null +++ b/src/wallet/falconKeystore.ts @@ -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 { + 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 { + 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 { + 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)); +} diff --git a/src/wallet/hybridAuth.ts b/src/wallet/hybridAuth.ts new file mode 100644 index 0000000..8ccb27e --- /dev/null +++ b/src/wallet/hybridAuth.ts @@ -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 { + 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 { + // 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, + ); +} diff --git a/src/wallet/index.ts b/src/wallet/index.ts new file mode 100644 index 0000000..69ea70b --- /dev/null +++ b/src/wallet/index.ts @@ -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'; diff --git a/src/wallet/types.ts b/src/wallet/types.ts new file mode 100644 index 0000000..d4bd01b --- /dev/null +++ b/src/wallet/types.ts @@ -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; +}