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; +}