Compare commits
5 Commits
7287d8ad11
...
6d6c18ae41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d6c18ae41 | ||
|
|
ec26379e63 | ||
|
|
0740f2af2e | ||
|
|
319c3f5339 | ||
|
|
067dd3d242 |
317
README.md
317
README.md
@ -1,10 +1,14 @@
|
|||||||
# @aere/sdk
|
# @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
|
**SDK version:** 0.16.12
|
||||||
**Last updated:** 2026-05-31
|
**Last updated:** 2026-07-16
|
||||||
**Network state at this release:** 16 contracts live on chain ID 2800.
|
**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 name | AERE Network |
|
||||||
| Chain ID | 2800 (`0xAF0`) |
|
| 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) |
|
| Native token | AERE (18 decimals) |
|
||||||
| Max supply | 2,800,000,000 AERE (capped at genesis-v2, 2026-05-07) |
|
| 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` |
|
| HTTP RPC | `https://rpc.aere.network` |
|
||||||
| WebSocket RPC | `wss://wss.aere.network` |
|
| WebSocket RPC | `wss://wss.aere.network` |
|
||||||
| Explorer (Blockscout) | `https://explorer.aere.network` |
|
| Explorer (Blockscout) | `https://explorer.aere.network` |
|
||||||
| Indexer REST | `https://api.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)
|
## Genesis allocation (2,800,000,000 AERE)
|
||||||
|
|
||||||
| Wallet | Allocation | Address |
|
| 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` |
|
| Team Reserve | 420,000,000 | `0x7968C438204a78B4e032fcFFd9A56Edb15fdCCdf` |
|
||||||
| Airdrop Reserve | 140,000,000 | `0x261913fA73D6F109382F1aE98Ff6822ff03628B1` |
|
| Airdrop Reserve | 140,000,000 | `0x261913fA73D6F109382F1aE98Ff6822ff03628B1` |
|
||||||
|
|
||||||
## Deployed contracts (16 — all owned by Foundation)
|
## What the SDK covers
|
||||||
|
|
||||||
| Contract | Address | Purpose |
|
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
|
||||||
| WAERE | `0x7e84d7d66d5da4cfE46Da67CDEeB05B323e1f5e8` | Wrapped AERE (WETH9-style ERC-20) |
|
dedicated contract clients plus the `AereClient` facade, which itself wraps 8
|
||||||
| AereTreasury | `0x687933AE7ea4927867AC227F1b60d476003e6119` | Timelocked foundation treasury |
|
core contracts), plus function-based PQC, wallet and MPC modules, grouped by
|
||||||
| AereOracle | `0xf0A13823A4bFa86358Fe30aaf1f44A36AcbCf399` | Multi-reporter median price feed (BTC/USD + ETH/USD live) |
|
domain:
|
||||||
| AereIdentity | `0x658dD2CD1F798AAb19fEc8FF69A270B2d192CaD1` | DID registry, revocable attestor claims (Sumsub-backed KYC) |
|
|
||||||
| AereFaucet | `0xDdBe942aD9eB0F3E7C541BdCF7CC2cfA29d35aE4` | 0.05 AERE drip per 24h |
|
| Domain | Representative clients |
|
||||||
| AereCardEscrow | `0xD1f7f12830AdCFd1B7676C8460B9e30602b1f059` | Bank28 card-settlement escrow |
|
|---|---|
|
||||||
| AereSecurity | `0xaD305e4D91e0a9160Bd338Fd1ecb2Ee1645daC44` | Sanctions / pause module |
|
| Core L1 | `AereClient` (WAERE, locked + delegated staking V2, identity, faucet, AMM factory, bridge handles) |
|
||||||
| AereStaking | `0xAbDb01d9A4f41792129b2654Fb6DDB9689360DEc` | Delegated staking · 8% APY · 7-day unbonding |
|
| Flywheel | `sAEREClient` (ERC-4626 receipt vault, V2), `AereSinkClient` (immutable 3-bucket router) |
|
||||||
| AereConsensus | `0xF8bDDad4aDACF9d38711e8f9aFC8a2697aBF0d47` | QBFT validator bookkeeping |
|
| DeFi | `LendingMarketClient`, `AereInsuranceFundClient`, `RWATransferAdapterClient`, `AereCoreBookClient` (native CLOB) |
|
||||||
| AereSwapFactory | `0xf0a8df7BDc25721892475B21271e52D77B0e84DC` | V2 AMM factory · 0.3% fee (router pending) |
|
| Rollup / RaaS | `AereRaaSFactoryClient`, `AereRollupSettlementClient` |
|
||||||
| AereBridge | `0x7eDa66cd93baAE19530839Bbb28ee36aC8aFAd68` | Federated lock-and-release bridge (relayer pending) |
|
| Agentic (x402) | `AereAgentClient`, `AERE402SettlementClient`, `createAere402Middleware`, `AereAgentBondClient`, `AereAIReputationClient`, `AereInferNetClient`, `AereAgentMemoryVaultClient` |
|
||||||
| AereMiningSubscription | `0xDad25d2163187DF8AAEcf9EA31b6355315Bb69f1` | 4-tier mining subscriptions (10/50/200/100 AERE per 30 days) |
|
| Compliance | `AereSanctionsRegistryClient`, `ChainalysisOracleWrapperClient`, `AereTravelRuleHashRegistryClient`, `AereForensicEventRegistryClient`, `AereZKScreenClient`, `AereAIProofClient`, `AereCompliancePoolClient`, `AereAttestationGatewayClient` |
|
||||||
| AereNFT | `0x3f9A9D9CAB005327869396C69bE226ef98039f1c` | ERC-721 + EIP-2981 royalties |
|
| Payments | `AereStateChannelsClient` (bidirectional channels + HTLC), `BestExClient` (MiCA Article 78 receipts) |
|
||||||
| AereNFTMarketplace | `0x852e07F2619F7F4aD10d9f2aC681310301d99528` | On-chain NFT marketplace · 2.5% protocol fee |
|
| Post-quantum | `AerePQCClient`, `AerePQCKeyRegistryClient`, `AereAgentDIDClient`, `AereCryptoRegistryClient`, `AereHybridAuthorizerClient` |
|
||||||
| AereGovernanceStaked | `0x8D77C888e439C4fADb2e23F1567a0A1965F80bCb` | Stake-weighted governance (reads voting power from AereStaking) |
|
| Account abstraction | `ModularAccountClient`, `SessionKeyClient`, `SocialRecoveryClient`, `AerePQCSocialRecoveryClient` (ERC-7579 modular accounts, bound to AereEntryPointV2) |
|
||||||
| AereLockedStaking | `0x21108c28A849b05aE6b7a3a5bc435C9Bc897E7Ad` | Fixed-term locks: 30d/10%, 90d/15%, 180d/22%, 365d/30% APY |
|
| 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
|
## 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 signer = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
|
||||||
const locked = new ethers.Contract(
|
const staking = new ethers.Contract(
|
||||||
AERE_MAINNET.AereLockedStaking,
|
AERE_MAINNET.AereStakingV2,
|
||||||
['function stake(uint8 tier) payable returns (uint256)'],
|
['function delegate(address validator) payable'],
|
||||||
signer,
|
signer,
|
||||||
);
|
);
|
||||||
const tx = await locked.stake(1, { value: ethers.parseEther('100') });
|
const tx = await staking.delegate(validatorAddress, { value: ethers.parseEther('100') });
|
||||||
await tx.wait();
|
await tx.wait();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap (not in this release)
|
## CLI (`aere`)
|
||||||
|
|
||||||
These are referenced in the AERE whitepaper but **NOT yet deployed on chain 2800**:
|
The SDK ships a small, read-only developer CLI. After `npm i @aere/sdk` it is on
|
||||||
|
your path as `aere`; from a clone, build first (`npm run build`) and run
|
||||||
|
`node dist/cli/aere-cli.js <command>`.
|
||||||
|
|
||||||
- DEX: `AereSwapRouter` + bridged stablecoin + initial WAERE liquidity pool
|
Every command is **read-only or purely local**. The CLI never signs a real
|
||||||
- Bridge: cross-chain relayer + counterparty contracts on Ethereum / BSC / Polygon
|
transaction and never accepts a private key. Values are read live from the RPC
|
||||||
- Lending market (collateralised AERE / WAERE)
|
and printed verbatim; on an RPC or connection error it writes to stderr and exits
|
||||||
- Yield farm (LP rewards)
|
non-zero.
|
||||||
- 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)
|
|
||||||
|
|
||||||
Each of these has an existing front-end shell on `aere.network` displaying a "coming soon" notice until the underlying contract ships.
|
```bash
|
||||||
|
aere chain # chain id, block height, base fee, gas price, validators, peers
|
||||||
|
aere block <number|latest> # block summary: hash, parent, timestamp, tx count, gas, proposer
|
||||||
|
aere tx <hash> # transaction + receipt summary
|
||||||
|
aere addr <address> # native balance, nonce, and code size
|
||||||
|
aere pqc schemes # supported PQC schemes, precompiles, key/signature sizes
|
||||||
|
aere pqc keygen --scheme <1-4> # generate a PQC keypair locally (public key only)
|
||||||
|
```
|
||||||
|
|
||||||
## Architectural notes
|
Options:
|
||||||
|
|
||||||
- **Non-custodial.** End users hold their own keys. Consumer apps (e.g. Bank28) can use Privy / Magic / Web3Auth to provision wallets via email/social, then pass that signer into the SDK.
|
| Option | Applies to | Meaning |
|
||||||
- **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.
|
| `--rpc <url>` | all chain commands | RPC endpoint (default `https://rpc.aere.network`) |
|
||||||
- **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.
|
| `--scheme <1\|2\|3\|4>` | `pqc keygen` | 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-SHA2-128s |
|
||||||
- **AereBridge is 1-of-1 signer.** Multi-sig threshold expansion happens before any value flows.
|
| `--show-secret` | `pqc keygen` | also print the secret key. Dangerous, off by default |
|
||||||
|
| `--version`, `-V` | | print the SDK version |
|
||||||
|
| `--help`, `-h` | any | usage (also `aere <command> --help`) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ aere chain
|
||||||
|
AERE chain — https://rpc.aere.network
|
||||||
|
Chain ID 2800 (0xaf0)
|
||||||
|
Block height 9520927
|
||||||
|
Base fee 1.0 gwei
|
||||||
|
Gas price 1.0 gwei
|
||||||
|
Validators 7
|
||||||
|
Peers 7
|
||||||
|
```
|
||||||
|
|
||||||
|
## State window on the public RPC (read before pinning a historical block)
|
||||||
|
|
||||||
|
Both public endpoints are **pruning full nodes**, not archive nodes.
|
||||||
|
|
||||||
|
- Blocks, transactions, receipts, logs and `qbft_getValidatorsByBlockNumber` go back to **block 0**.
|
||||||
|
- **World state is kept for only about the most recent 512 blocks**, roughly 4 minutes 25 seconds
|
||||||
|
at the measured 517 ms block interval. That bounds `eth_getBalance`, `eth_getCode`,
|
||||||
|
`eth_getStorageAt`, `eth_call`, `eth_getProof` and `eth_getTransactionCount`.
|
||||||
|
|
||||||
|
Outside the window most methods say so: `eth_getBalance` / `eth_getCode` / `eth_getStorageAt`
|
||||||
|
return `null`, and `eth_getProof` returns `-32000 World state unavailable`.
|
||||||
|
**`eth_getTransactionCount` does not.** It returns `0x0`, which is well formed and
|
||||||
|
indistinguishable from an account that has never transacted. Reproduce it yourself: block
|
||||||
|
8,236,382 contains a transaction from `0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465` with nonce
|
||||||
|
123,063, and the endpoint still answers `0x0` for that address at that block.
|
||||||
|
|
||||||
|
`StateWindowReader` returns a value or throws. It never returns a placeholder.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { StateWindowReader, StateWindowError } from '@aere/sdk';
|
||||||
|
|
||||||
|
const reader = new StateWindowReader((m, p) => provider.send(m, p));
|
||||||
|
|
||||||
|
await reader.getTransactionCount(addr); // latest: fine
|
||||||
|
await reader.getBalance(addr, head - 100); // inside the window: fine
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reader.getTransactionCount(addr, 8_236_382); // throws, never returns 0
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof StateWindowError) console.log(e.detail.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has this account ever signed anything? Answered only where it can be answered.
|
||||||
|
await reader.hasEverSigned(addr); // { signed, nonce, atBlock, method }
|
||||||
|
|
||||||
|
// Do not trust the 512 constant, measure it against the endpoint you are using.
|
||||||
|
await reader.measureWindow(); // { head, deepestOkDepth: 511, firstFailDepth: 512 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Three rules if you are not using the reader: read state at `latest`; check the depth before
|
||||||
|
pinning a historical block; and never test a nonce for zero at a historical block.
|
||||||
|
|
||||||
|
## Operational notes (honest state)
|
||||||
|
|
||||||
|
- **Non-custodial.** End users hold their own keys. Consumer apps (wallets, card programs)
|
||||||
|
can use Privy / Magic / Web3Auth to provision wallets via email/social, then
|
||||||
|
pass that signer into the SDK, or use the seedless PQC wallet above.
|
||||||
|
- **Fiat rails are out of scope.** AERE is the on-chain settlement layer; fiat
|
||||||
|
IBANs / cards / SEPA come from a BaaS partner (Striga, Baanx, Kulipa, etc.).
|
||||||
|
- **Decentralization.** 7 QBFT validators give f=2 (the chain keeps producing
|
||||||
|
blocks while up to 2 validators are down), commit quorum 5 of 7. All 7 are
|
||||||
|
operated by the Foundation on a single client (Besu), so this is
|
||||||
|
fault-tolerance, not yet operator or client diversity. Broader validator
|
||||||
|
operators and a second client are roadmap.
|
||||||
|
- **Foundation key, not a multisig.** `0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3`
|
||||||
|
is a single-key EOA (zero bytecode on-chain), described here as a
|
||||||
|
Foundation-controlled account. A real Safe-style threshold multisig is roadmap,
|
||||||
|
not deployed. Governance contracts (Governor + Timelock + gAERE) are deployed
|
||||||
|
but own nothing until an audit-gated ownership transfer runs.
|
||||||
|
- **Price oracle.** `AereOracleV2` enforces a minimum fresh-reporter quorum
|
||||||
|
(`getPrice` reverts below it) and supersedes the flawed V1, which returned a
|
||||||
|
median even with a single fresh reporter. The V1 contract stays the live read
|
||||||
|
source for existing consumers until a founder-supervised repoint. Treat the
|
||||||
|
live feed as single-reporter-grade until that repoint lands.
|
||||||
|
- **Cross-chain bridging is not yet value-carrying.** The legacy `AereBridge`
|
||||||
|
reports `threshold()` == 1 on-chain and has no operational relayer. The current
|
||||||
|
approach is the Hyperlane-based `AereWarpRouteV3`, whose AERE side is deployed
|
||||||
|
but whose Ethereum leg awaits L1 bring-up. Do not route value across the bridge
|
||||||
|
until threshold expansion and a relayer are live.
|
||||||
|
- **Audit status.** Contracts have had an internal self-audit (Slither, Aderyn,
|
||||||
|
Semgrep, Mythril, SMTChecker, Halmos, Medusa) and several confirmed findings
|
||||||
|
were fixed via the `*V2` corrected forks documented in `src/addresses.ts`. An
|
||||||
|
external third-party audit is still pending; do not read "audited" as external.
|
||||||
|
- **Validity anchors.** The full-EVM validity anchors (`AereEVMValidityV2`,
|
||||||
|
`AereEVMValidityBatchV2`) are deployed and canonical-bound but not yet
|
||||||
|
exercised on mainnet (`provenCount()` reads 0 for both).
|
||||||
|
|
||||||
|
## Roadmap (deployed but inert, or not yet live)
|
||||||
|
|
||||||
|
Several contracts are on-chain but inert (holding no value, not wired live)
|
||||||
|
pending a founder-supervised migration, and a few product surfaces are not yet
|
||||||
|
deployed. Representative items:
|
||||||
|
|
||||||
|
- Cross-chain relayer + Ethereum-side counterparties for the Warp Route / bridge
|
||||||
|
- A real Safe-style Foundation multisig, and the audit-gated governance ownership
|
||||||
|
transfer
|
||||||
|
- Multi-operator validator set and a second consensus client
|
||||||
|
- External third-party security audit
|
||||||
|
- Founder-supervised migrations for the `*V2` inert forks (sAEREv2, settlement
|
||||||
|
hub V2, delegate-7702 V2, and others; runbooks live under `aerenew/docs/`)
|
||||||
|
|
||||||
|
Each contract's exact status is documented inline in `src/addresses.ts`.
|
||||||
|
|
||||||
## Source
|
## Source
|
||||||
|
|
||||||
|
|||||||
94
examples/agent-402-pqc-client.js
Normal file
94
examples/agent-402-pqc-client.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// agent-402-pqc-client.js
|
||||||
|
//
|
||||||
|
// Drop-in example: agent-side AERE402FacilitatorPQC client. The agent's spending
|
||||||
|
// authority is rooted in a quantum-durable Falcon key (an AereAgentDID); each payment
|
||||||
|
// is authorized by a cheap, short-lived, revocable secp256k1 SESSION key. Rotating or
|
||||||
|
// revoking the Falcon root instantly halts every downstream payment.
|
||||||
|
//
|
||||||
|
// The agent calls a paid endpoint, receives 402 + a PQC quote, signs the DID action
|
||||||
|
// digest with its SESSION key (a single ecrecover on-chain), and retries. The Falcon
|
||||||
|
// root is never touched on the payment hot path — it only ever issues sessions.
|
||||||
|
//
|
||||||
|
// Run:
|
||||||
|
// yarn add ethers
|
||||||
|
// SESSION_PRIVATE_KEY=0x... ENDPOINT=http://localhost:3000/v1/inference node agent-402-pqc-client.js
|
||||||
|
//
|
||||||
|
// Prereq: an AereAgentDID agent (Falcon root) exists, a session is issued for
|
||||||
|
// SESSION_PRIVATE_KEY's address (with scope + spend cap + expiry), and the agent's
|
||||||
|
// AERE402FacilitatorPQC vault is funded. The server issues the 402 quote; the agent only
|
||||||
|
// needs its session key here.
|
||||||
|
|
||||||
|
const { ethers } = require('ethers');
|
||||||
|
|
||||||
|
const SESSION_PK = process.env.SESSION_PRIVATE_KEY;
|
||||||
|
const ENDPOINT = process.env.ENDPOINT ?? 'http://localhost:3000/v1/inference';
|
||||||
|
|
||||||
|
if (!SESSION_PK) {
|
||||||
|
console.error('SESSION_PRIVATE_KEY env required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain tags — mirror the contracts exactly.
|
||||||
|
const PAYMENT_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes('AERE402FacilitatorPQC.v1.payment'));
|
||||||
|
const ACTION_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes('AereAgentDID.v1.action'));
|
||||||
|
|
||||||
|
// keccak256(abi.encode(PAYMENT_DOMAIN, chainId, facilitator, token, payee, resourceId, deadline))
|
||||||
|
function paymentActionHash(q) {
|
||||||
|
const enc = ethers.AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'address', 'address', 'bytes32', 'uint256'],
|
||||||
|
[PAYMENT_DOMAIN, q.chainId, q.facilitator, q.token, q.payee, q.resourceId, BigInt(q.deadline)],
|
||||||
|
);
|
||||||
|
return ethers.keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// keccak256(abi.encode(ACTION_DOMAIN, chainId, did, sessionId, scope, actionHash, amount, actionNonce))
|
||||||
|
function actionDigest(q, actionHash) {
|
||||||
|
const enc = ethers.AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'uint256', 'bytes32', 'bytes32', 'uint256', 'uint64'],
|
||||||
|
[ACTION_DOMAIN, q.chainId, q.did, BigInt(q.sessionId), q.scope, actionHash, BigInt(q.amount), BigInt(q.actionNonce)],
|
||||||
|
);
|
||||||
|
return ethers.keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new ethers.SigningKey(SESSION_PK);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log(`session ${ethers.computeAddress(session)} calling ${ENDPOINT}...`);
|
||||||
|
|
||||||
|
// 1. Unpaid request.
|
||||||
|
const r1 = await fetch(ENDPOINT);
|
||||||
|
if (r1.status !== 402) {
|
||||||
|
console.log(`unexpected status ${r1.status} on first call`);
|
||||||
|
console.log(await r1.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const quoteJson = r1.headers.get('x-aere402pqc-quote');
|
||||||
|
if (!quoteJson) throw new Error('server did not return X-AERE402PQC-Quote');
|
||||||
|
const quote = JSON.parse(quoteJson);
|
||||||
|
// quote = { chainId, facilitator, did, sessionId, scope, token, payee, amount,
|
||||||
|
// resourceId, deadline, actionNonce }
|
||||||
|
console.log(' received quote:', quote);
|
||||||
|
|
||||||
|
// 2. Derive the actionHash (binds the payment to THIS facilitator + terms) and the DID
|
||||||
|
// action digest the session key must sign.
|
||||||
|
const actionHash = paymentActionHash(quote);
|
||||||
|
const digest = actionDigest(quote, actionHash);
|
||||||
|
|
||||||
|
// 3. Sign the raw digest with the SESSION key (canonical low-s; matches on-chain ecrecover).
|
||||||
|
const signature = session.sign(digest).serialized;
|
||||||
|
console.log(` signed: ${signature.slice(0, 18)}...`);
|
||||||
|
|
||||||
|
// 4. Retry with the signature. The provider POSTs
|
||||||
|
// settle(sessionId, scope, token, payee, amount, resourceId, deadline, signature)
|
||||||
|
// to AERE402FacilitatorPQC, which enforces the Falcon root lifecycle + scope + spend
|
||||||
|
// cap + this session signature + anti-replay in one call, then pays out.
|
||||||
|
const r2 = await fetch(ENDPOINT, {
|
||||||
|
headers: {
|
||||||
|
'X-AERE402PQC-Sig': signature,
|
||||||
|
'X-AERE402PQC-Quote': quoteJson,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` retry status: ${r2.status}`);
|
||||||
|
console.log(` tx hash: ${r2.headers.get('x-aere402pqc-txhash')}`);
|
||||||
|
console.log(' body:', await r2.json());
|
||||||
|
})();
|
||||||
132
examples/modular-account-demo.mjs
Normal file
132
examples/modular-account-demo.mjs
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
// AereModularAccount SDK demo — session keys + social recovery on AERE chain 2800.
|
||||||
|
//
|
||||||
|
// READ-ONLY / DRY-RUN by default: it queries the live chain via eth_call and
|
||||||
|
// prints the calldata that write flows WOULD send, but sends no transactions.
|
||||||
|
// Set AERE_SEND=1 to actually broadcast (you must also wire a signer/provider
|
||||||
|
// that can sign; the default fetch provider below only serves eth_call).
|
||||||
|
//
|
||||||
|
// Run:
|
||||||
|
// cd sdk-js && npm run build # produces ./dist
|
||||||
|
// node examples/modular-account-demo.mjs
|
||||||
|
//
|
||||||
|
// The clients are dependency-free (no ethers/viem). This demo uses Node's global
|
||||||
|
// fetch to satisfy the tiny EIP-1193 `request` surface.
|
||||||
|
|
||||||
|
import {
|
||||||
|
ModularAccountClient, SessionKeyClient, SocialRecoveryClient,
|
||||||
|
AERE_MODULAR_ADDRESSES, MODULE_TYPE_VALIDATOR, MODULE_TYPE_EXECUTOR,
|
||||||
|
SELECTOR_NATIVE_TRANSFER, RECOVERY_DELAY_SECONDS,
|
||||||
|
} from '../dist/account/index.js';
|
||||||
|
|
||||||
|
const RPC = process.env.AERE_RPC || 'https://rpc.aere.network';
|
||||||
|
const DO_SEND = process.env.AERE_SEND === '1';
|
||||||
|
|
||||||
|
// Minimal EIP-1193 provider over JSON-RPC (read-only unless you add signing).
|
||||||
|
let rpcId = 1;
|
||||||
|
const provider = {
|
||||||
|
async request({ method, params = [] }) {
|
||||||
|
const res = await fetch(RPC, {
|
||||||
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: rpcId++, method, params }),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`);
|
||||||
|
return j.result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The sample account proven live on 2026-07-10 (root owner = the deployer).
|
||||||
|
const ROOT_OWNER = '0xbeB33D20dFBBD49eC7AC1F617667f1f02dfd6465';
|
||||||
|
const SALT = 1783688121732n;
|
||||||
|
const PING = '0x6191CC59961F2C652aE1879ff731aE1349986036'; // AerePingCounter
|
||||||
|
const PING_SELECTOR = '0x5c36b186'; // ping()
|
||||||
|
|
||||||
|
const mac = new ModularAccountClient(provider, AERE_MODULAR_ADDRESSES);
|
||||||
|
const sk = new SessionKeyClient(provider, AERE_MODULAR_ADDRESSES.sessionKeyValidator);
|
||||||
|
const sr = new SocialRecoveryClient(provider, AERE_MODULAR_ADDRESSES.socialRecoveryModule);
|
||||||
|
|
||||||
|
const line = (s = '') => console.log(s);
|
||||||
|
|
||||||
|
// ───────────────────────────── Flow 1: Modular account ─────────────────────
|
||||||
|
line('=== 1. Modular account (CREATE2 + reads + root userOp) ===');
|
||||||
|
const account = mac.getAddress(ROOT_OWNER, SALT); // local CREATE2 mirror
|
||||||
|
line(`counterfactual address : ${account}`);
|
||||||
|
line(`on-chain factory says : ${await mac.getAddressOnChain(ROOT_OWNER, SALT)}`);
|
||||||
|
line(`deployed? : ${await mac.isDeployed(account)}`);
|
||||||
|
line(`rootOwner : ${await mac.rootOwnerOf(account)}`);
|
||||||
|
line(`entryPoint : ${await mac.entryPointOf(account)}`);
|
||||||
|
line(`EntryPoint deposit : ${await mac.depositOf(account)} wei`);
|
||||||
|
|
||||||
|
// Build + hash a root-owner userOp that calls ping() (no submit).
|
||||||
|
const callData = mac.encodeExecute(PING, 0n, PING_SELECTOR);
|
||||||
|
const rootOp = await mac.buildUserOp({ sender: account, callData });
|
||||||
|
line(`root userOp nonce : ${rootOp.nonce}`);
|
||||||
|
line(`userOpHash (local) : ${mac.userOpHash(rootOp)}`);
|
||||||
|
line(`userOpHash (on-chain) : ${await mac.userOpHashOnChain(rootOp)} <- must match`);
|
||||||
|
line('to submit: signRootUserOp(rootOp, eip1193PersonalSign(walletProvider, ROOT_OWNER)) then sendHandleOps(bundler, [op], bundler)');
|
||||||
|
if (DO_SEND) line('(AERE_SEND set, but this demo provider cannot sign — wire a wallet provider first)');
|
||||||
|
|
||||||
|
// ───────────────────────────── Flow 2: Session keys ────────────────────────
|
||||||
|
line();
|
||||||
|
line('=== 2. Session key (scope + local check + on-chain read) ===');
|
||||||
|
const sessionKey = '0x0EE48ec9e2EE095Ce81e351457893EAAf8CB2CEC';
|
||||||
|
const scope = {
|
||||||
|
key: sessionKey,
|
||||||
|
validAfter: 0,
|
||||||
|
validUntil: 1893456000, // 2030-01-01
|
||||||
|
maxValuePerOp: 1_000_000_000_000_000n, // 0.001 AERE
|
||||||
|
permissions: [{ target: PING, selector: PING_SELECTOR }],
|
||||||
|
};
|
||||||
|
line(`install initData : ${sk.encodeInstallInitData(scope).slice(0, 74)}...`);
|
||||||
|
line(`enableSession calldata : ${sk.encodeEnableSession(scope).slice(0, 74)}...`);
|
||||||
|
line(' (register by: account.installModule(1, validator, initData) OR');
|
||||||
|
line(' account.execute(validator, 0, enableSessionCalldata) as the root owner)');
|
||||||
|
|
||||||
|
// Local scope check BEFORE paying to submit.
|
||||||
|
const inScope = sk.checkCallLocally(scope, { target: PING, value: 0n, data: PING_SELECTOR });
|
||||||
|
const badSel = sk.checkCallLocally(scope, { target: PING, value: 0n, data: '0xdeadbeef' });
|
||||||
|
const badVal = sk.checkCallLocally(scope, { target: PING, value: 2n * scope.maxValuePerOp, data: PING_SELECTOR });
|
||||||
|
line(`in-scope ping() : ${JSON.stringify(inScope)}`);
|
||||||
|
line(`out-of-scope selector : ${JSON.stringify(badSel)}`);
|
||||||
|
line(`over-value transfer : ${JSON.stringify(badVal)}`);
|
||||||
|
line(`native-transfer sentinel selector = ${SELECTOR_NATIVE_TRANSFER}`);
|
||||||
|
|
||||||
|
const onChainSession = await sk.getSession(account, sessionKey);
|
||||||
|
line(`on-chain session : ${JSON.stringify(onChainSession, (k, v) => (typeof v === 'bigint' ? v.toString() : v))}`);
|
||||||
|
|
||||||
|
// Build a session-signed userOp (asserts scope, then leaves 0x01 signature to attach).
|
||||||
|
const { op: sessOp, userOpHash: sessHash } = await sk.buildSessionUserOp(
|
||||||
|
mac, account, { target: PING, value: 0n, data: PING_SELECTOR }, { scope },
|
||||||
|
);
|
||||||
|
line(`session userOpHash : ${sessHash}`);
|
||||||
|
line('to submit: sk.signSessionUserOp(mac, sessOp, sessionKeySigner) -> 0x01||validator||sig, then mac.sendHandleOps(bundler, [op], bundler)');
|
||||||
|
void sessOp;
|
||||||
|
|
||||||
|
// ───────────────────────────── Flow 3: Social recovery ─────────────────────
|
||||||
|
line();
|
||||||
|
line('=== 3. Social recovery (M-of-N guardians, 48h timelock) ===');
|
||||||
|
line(`recovery installed? : ${await mac.isModuleInstalled(account, MODULE_TYPE_EXECUTOR, AERE_MODULAR_ADDRESSES.socialRecoveryModule)}`);
|
||||||
|
const guardians = await sr.guardiansOf(account);
|
||||||
|
line(`guardians : ${JSON.stringify(guardians)}`);
|
||||||
|
line(`threshold : ${await sr.thresholdOf(account)}`);
|
||||||
|
line(`recovery delay : ${RECOVERY_DELAY_SECONDS}s (48h)`);
|
||||||
|
const active = await sr.getActiveRecovery(account);
|
||||||
|
line(`active recovery : ${JSON.stringify(active)}`);
|
||||||
|
if (active.active) {
|
||||||
|
const remaining = active.executeAfter - Math.floor(Date.now() / 1000);
|
||||||
|
line(` -> executable in ~${Math.max(0, remaining)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEW_OWNER = '0xFa1Eb227Bd1F05104bE0bB21a3f2094238E0d6D2';
|
||||||
|
line('install initData : ' + sr.encodeInstallInitData(guardians, 2).slice(0, 74) + '...');
|
||||||
|
line('guardian1 initiate : ' + sr.encodeInitiateRecovery(account, NEW_OWNER));
|
||||||
|
line('guardian2 support : ' + sr.encodeSupportRecovery(account));
|
||||||
|
line('any guardian execute : ' + sr.encodeExecuteRecovery(account) + ' (after threshold + 48h)');
|
||||||
|
line('owner cancel : ' + sr.encodeCancelRecovery(account));
|
||||||
|
line('add guardian (owner) : account.execute(module, 0, ' + sr.encodeAddGuardian(NEW_OWNER).slice(0, 42) + '...)');
|
||||||
|
|
||||||
|
if (DO_SEND) {
|
||||||
|
line('\n[AERE_SEND] guardian/owner flows would broadcast now via sr.sendInitiateRecovery(...) etc.');
|
||||||
|
} else {
|
||||||
|
line('\nDRY-RUN complete — no transactions sent. Set AERE_SEND=1 (and a signing provider) to broadcast.');
|
||||||
|
}
|
||||||
165
examples/pqc-identity-e2e.mjs
Normal file
165
examples/pqc-identity-e2e.mjs
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
// AERE post-quantum identity — end-to-end example (chain 2800).
|
||||||
|
//
|
||||||
|
// One runnable script that stitches the shipped PQC pieces into a single front
|
||||||
|
// door and DRIVES it against the LIVE read-only RPC:
|
||||||
|
//
|
||||||
|
// 1. Generate a Falcon-512 quantum-durable ROOT keypair client-side, and
|
||||||
|
// client-side-encrypt the secret under a passphrase (no seed phrase, the
|
||||||
|
// secret never leaves this process unencrypted).
|
||||||
|
// 2. Register the root public key in AerePQCKeyRegistry with on-chain
|
||||||
|
// PROOF-OF-POSSESSION — derive the PoP challenge locally and CROSS-CHECK it
|
||||||
|
// against the live `popChallenge` view (SDK<->contract parity, proven live).
|
||||||
|
// 3. Open an AereAgentDID rooted in that Falcon key, then issue a short-lived,
|
||||||
|
// revocable secp256k1 SESSION key (scope + spend cap + expiry), gated by a
|
||||||
|
// Falcon proof-of-possession. Authorize an action with the session key.
|
||||||
|
// 4. Set a quantum-durable M-of-N guardian committee (AerePQCSocialRecoveryModule)
|
||||||
|
// and derive the recovery challenge a guardian would PQC-sign.
|
||||||
|
//
|
||||||
|
// READ-ONLY / DRY-RUN: every write flow is CONSTRUCTED and PRINTED (the exact
|
||||||
|
// calldata a wallet would broadcast) but nothing is signed with a funded key and
|
||||||
|
// nothing is sent. The live RPC is used only for eth_call reads and the parity
|
||||||
|
// cross-checks. No private key is ever committed or transmitted.
|
||||||
|
//
|
||||||
|
// Run:
|
||||||
|
// cd aerenew/sdk-js && npm run build
|
||||||
|
// node examples/pqc-identity-e2e.mjs
|
||||||
|
// AERE_RPC=https://rpc.aere.network node examples/pqc-identity-e2e.mjs
|
||||||
|
|
||||||
|
import {
|
||||||
|
JsonRpcProvider, Wallet, keccak256, toUtf8Bytes, hexlify, getBytes,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../dist/addresses.js';
|
||||||
|
import { generateFalconRoot, encryptFalconSecretKey } from '../dist/wallet/falconKeystore.js';
|
||||||
|
import { AerePQCKeyRegistryClient } from '../dist/pqc/AerePQCKeyRegistryClient.js';
|
||||||
|
import { AereAgentDIDClient } from '../dist/pqc/AereAgentDIDClient.js';
|
||||||
|
import { AerePQCSocialRecoveryClient, recoveryChallenge as deriveRecoveryChallenge } from '../dist/account/AerePQCSocialRecoveryClient.js';
|
||||||
|
import { SCHEME, SCHEME_NAME, verifyLocal } from '../dist/pqc/index.js';
|
||||||
|
|
||||||
|
const RPC = process.env.AERE_RPC || AERE_MAINNET.rpc;
|
||||||
|
const line = (s = '') => console.log(s);
|
||||||
|
const short = (h, n = 10) => (h.length > 2 * n + 2 ? `${h.slice(0, n + 2)}…${h.slice(-n)}` : h);
|
||||||
|
const ok = (b) => (b ? 'OK' : 'MISMATCH');
|
||||||
|
|
||||||
|
const provider = new JsonRpcProvider(RPC);
|
||||||
|
const net = await provider.getNetwork();
|
||||||
|
const block = await provider.getBlockNumber();
|
||||||
|
line('AERE post-quantum identity — end-to-end (read-only)');
|
||||||
|
line(`RPC ${RPC} · chainId ${net.chainId} · block ${block}`);
|
||||||
|
line('This script sends NO transactions. It reads live state and prints the txs a wallet WOULD send.');
|
||||||
|
|
||||||
|
// The identity the keys bind to. Any address works for the read-only flow — the
|
||||||
|
// PoP challenge commits to this address, and the account that pays gas is the owner.
|
||||||
|
// Override with AERE_OWNER=0x... to bind to your own address.
|
||||||
|
const OWNER = process.env.AERE_OWNER || Wallet.createRandom().address;
|
||||||
|
line(`\nowner (identity) ${OWNER}`);
|
||||||
|
|
||||||
|
// ── 1. Falcon-512 root, generated + encrypted client-side ────────────────────
|
||||||
|
line('\n=== 1. Falcon-512 quantum-durable root (client-side) ===');
|
||||||
|
const root = generateFalconRoot();
|
||||||
|
line(`scheme ${SCHEME_NAME[root.scheme]} (id ${root.scheme})`);
|
||||||
|
line(`public key ${root.publicKey.length} bytes ${short(hexlify(root.publicKey))}`);
|
||||||
|
const keystore = await encryptFalconSecretKey(root, 'correct horse battery staple');
|
||||||
|
line(`encrypted keystore ${keystore.cipher} / ${keystore.kdf} (${keystore.kdfIterations} iters)`);
|
||||||
|
line(` ciphertext ${short(keystore.ciphertext)} (secret key never stored in the clear)`);
|
||||||
|
|
||||||
|
// ── 2. Register the root key with on-chain proof-of-possession ───────────────
|
||||||
|
line('\n=== 2. Register root key with proof-of-possession (AerePQCKeyRegistry) ===');
|
||||||
|
const reg = new AerePQCKeyRegistryClient(provider);
|
||||||
|
line(`registry ${reg.address}`);
|
||||||
|
line(`keyCount [live] ${await reg.keyCount()}`);
|
||||||
|
const idNonce = await reg.identityNonce(OWNER);
|
||||||
|
line(`identityNonce(owner) ${idNonce}`);
|
||||||
|
// Local PoP challenge, then cross-check it against the live contract view.
|
||||||
|
const localPop = reg.derivePopChallenge(OWNER, root.scheme, root.publicKey, idNonce);
|
||||||
|
const chainPop = await reg.popChallenge(OWNER, root.scheme, root.publicKey, idNonce);
|
||||||
|
line(`PoP challenge (local) ${short(localPop)}`);
|
||||||
|
line(`PoP challenge (on-chain)${short(chainPop)} parity: ${ok(localPop.toLowerCase() === chainPop.toLowerCase())}`);
|
||||||
|
// Build + locally verify the PoP envelope, then show the registerKey tx.
|
||||||
|
const pop = reg.buildRegisterKeyPoP(OWNER, root.scheme, root.secretKey, root.publicKey, idNonce);
|
||||||
|
const popValid = verifyLocal(root.scheme, getBytes(pop.challenge), pop.signature, root.publicKey);
|
||||||
|
line(`PoP signature ${pop.signature.length} bytes verifyLocal: ${ok(popValid)}`);
|
||||||
|
const registerCalldata = reg.contract.interface.encodeFunctionData(
|
||||||
|
'registerKey', [root.scheme, hexlify(root.publicKey), hexlify(pop.signature)],
|
||||||
|
);
|
||||||
|
line(`registerKey tx to ${reg.address}`);
|
||||||
|
line(` calldata ${(registerCalldata.length - 2) / 2} bytes ${short(registerCalldata)}`);
|
||||||
|
line(' (broadcast: reg.registerKeyWithPoP(signer, scheme, secretKey, pubKey) — signer is the owner)');
|
||||||
|
|
||||||
|
// The keyId this registration would take (registry is append-only from keyCount).
|
||||||
|
const rootKeyId = await reg.keyCount();
|
||||||
|
line(` -> would take keyId ${rootKeyId}`);
|
||||||
|
|
||||||
|
// ── 3. Agent DID: Falcon root + revocable secp256k1 session key ──────────────
|
||||||
|
line('\n=== 3. Agent DID + session key (AereAgentDID) ===');
|
||||||
|
const did = new AereAgentDIDClient(provider);
|
||||||
|
line(`agent DID ${did.address}`);
|
||||||
|
line(`sessionCount [live] ${await did.sessionCount()}`);
|
||||||
|
line(`agentExists(keyId ${rootKeyId}) ${await did.agentExists(rootKeyId)}`);
|
||||||
|
const createAgentCalldata = did.contract.interface.encodeFunctionData('createAgent', [rootKeyId]);
|
||||||
|
line(`createAgent tx to ${did.address} calldata ${short(createAgentCalldata)}`);
|
||||||
|
|
||||||
|
// Fresh secp256k1 session key (hot key, disposable). Generated locally.
|
||||||
|
const session = Wallet.createRandom();
|
||||||
|
const sessionParams = {
|
||||||
|
rootKeyId,
|
||||||
|
sessionAddr: session.address,
|
||||||
|
scopeHash: keccak256(toUtf8Bytes('aere.agent.session.v1:inference')),
|
||||||
|
spendCap: 10n ** 18n, // 1 AERE cumulative
|
||||||
|
expiry: BigInt(Math.floor(Date.now() / 1000) + 30 * 24 * 3600), // +30 days
|
||||||
|
};
|
||||||
|
line(`session key ${session.address} (secp256k1, revocable)`);
|
||||||
|
line(` scope / cap / expiry ${short(sessionParams.scopeHash)} / 1 AERE / ${sessionParams.expiry}`);
|
||||||
|
// Session issuance is Falcon-PoP-gated. Cross-check the issuance challenge (explicit nonce 0).
|
||||||
|
const localSess = did.deriveSessionChallenge(sessionParams, 0n);
|
||||||
|
const chainSess = await did.sessionChallenge(sessionParams, 0n);
|
||||||
|
line(`issuance challenge local ${short(localSess)} parity: ${ok(localSess.toLowerCase() === chainSess.toLowerCase())}`);
|
||||||
|
const issue = did.buildIssueSessionPoP(sessionParams, root.secretKey, root.scheme, 0n);
|
||||||
|
line(`issueSession popSig ${issue.popSig.length} bytes (Falcon PoP by the root)`);
|
||||||
|
const issueCalldata = did.contract.interface.encodeFunctionData('issueSession', [
|
||||||
|
sessionParams.rootKeyId, sessionParams.sessionAddr, sessionParams.scopeHash,
|
||||||
|
sessionParams.spendCap, sessionParams.expiry, hexlify(issue.popSig),
|
||||||
|
]);
|
||||||
|
line(`issueSession tx to ${did.address} calldata ${(issueCalldata.length - 2) / 2} bytes`);
|
||||||
|
|
||||||
|
// Authorize an action with the SESSION key (cheap ecrecover hot path).
|
||||||
|
const sessionId = await did.sessionCount(); // append-only
|
||||||
|
const actionHash = keccak256(toUtf8Bytes('POST /v1/inference {"model":"aere-1"}'));
|
||||||
|
const amount = 5n * 10n ** 17n; // 0.5 AERE
|
||||||
|
const localDigest = did.deriveActionDigest(sessionId, sessionParams.scopeHash, actionHash, amount, 0n);
|
||||||
|
const chainDigest = await did.actionDigest(sessionId, sessionParams.scopeHash, actionHash, amount, 0n);
|
||||||
|
line(`action digest local ${short(localDigest)} parity: ${ok(localDigest.toLowerCase() === chainDigest.toLowerCase())}`);
|
||||||
|
const auth = did.buildAuthorization(sessionId, sessionParams.scopeHash, actionHash, amount, 0n, session.privateKey);
|
||||||
|
line(`authorize sig ${short(auth.signature)} (secp256k1 over the raw digest)`);
|
||||||
|
const authCalldata = did.contract.interface.encodeFunctionData('authorize', [
|
||||||
|
sessionId, sessionParams.scopeHash, actionHash, amount, auth.signature,
|
||||||
|
]);
|
||||||
|
line(`authorize tx to ${did.address} calldata ${short(authCalldata)}`);
|
||||||
|
|
||||||
|
// ── 4. Quantum-durable M-of-N guardians (AerePQCSocialRecoveryModule) ────────
|
||||||
|
line('\n=== 4. PQC social-recovery guardians (AerePQCSocialRecoveryModule) ===');
|
||||||
|
// The module is a repo contract + client but is NOT yet deployed to mainnet, so
|
||||||
|
// there is no live address to read; MODULE_ADDR is a placeholder for the calldata.
|
||||||
|
const MODULE_ADDR = process.env.AERE_PQC_RECOVERY_MODULE || '0x' + '00'.repeat(20);
|
||||||
|
const rec = new AerePQCSocialRecoveryClient(MODULE_ADDR, provider);
|
||||||
|
// Three guardian keys, each a DISTINCT active AerePQCKeyRegistry key NOT owned by
|
||||||
|
// the account (here illustrative keyIds; on-chain they must be registered first).
|
||||||
|
const guardianKeyIds = [11n, 12n, 13n];
|
||||||
|
const threshold = 2n;
|
||||||
|
line(`module (undeployed) ${MODULE_ADDR}`);
|
||||||
|
line(`committee ${threshold}-of-${guardianKeyIds.length} guardian keyIds [${guardianKeyIds.join(', ')}]`);
|
||||||
|
const installData = rec.encodeOnInstall(guardianKeyIds, threshold);
|
||||||
|
line(`onInstall tx calldata ${(installData.length - 2) / 2} bytes ${short(installData)}`);
|
||||||
|
line(`addGuardianKey(14) tx ${short(rec.encodeAddGuardianKey(14n))}`);
|
||||||
|
line(`setThreshold(3) tx ${short(rec.encodeSetThreshold(3n))}`);
|
||||||
|
// A recovery: the challenge each guardian PQC-signs to swap the account's root owner.
|
||||||
|
const account = OWNER;
|
||||||
|
const newOwner = Wallet.createRandom().address;
|
||||||
|
const round = 0n; // first round for a fresh account
|
||||||
|
const chal = deriveRecoveryChallenge(MODULE_ADDR, account, newOwner, round, net.chainId);
|
||||||
|
line(`recovery: newOwner ${newOwner} round ${round}`);
|
||||||
|
line(`recovery challenge ${short(chal)} (each of ${threshold} guardians PQC-signs this)`);
|
||||||
|
line(' legs are built with rec.buildLegs(account, newOwner, [{guardianIndex, scheme, secretKey}...])');
|
||||||
|
line(' then submitted via rec.scheduleRecovery(...) — permissionless, 48h timelock, then executeRecovery');
|
||||||
|
|
||||||
|
line('\nDone. Live cross-checks (PoP / issuance / action digest) matched the on-chain views.');
|
||||||
|
line('Nothing was signed with a funded key and no transaction was sent.');
|
||||||
65
examples/threshold-account-demo.mjs
Normal file
65
examples/threshold-account-demo.mjs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// Runnable demo: node examples/threshold-account-demo.mjs (after `npm run build`)
|
||||||
|
//
|
||||||
|
// End-to-end proof that the AereThresholdAccount SDK produces cryptographically valid
|
||||||
|
// authorizing legs for a non-custodial t-of-n POST-QUANTUM account, using REAL Falcon-512
|
||||||
|
// signing/verification off-chain (the same envelope the live precompile verifies). It does not
|
||||||
|
// touch a chain: it shows exactly what each committee member signs, that ANY t members authorize,
|
||||||
|
// and that below-threshold or forged legs do not.
|
||||||
|
import { getBytes } from 'ethers';
|
||||||
|
import { keygen, verifyLocal, SCHEME, signLeg, encodeLegs, thresholdExecChallenge } from '../dist/index.js';
|
||||||
|
|
||||||
|
const line = (s = '') => console.log(s);
|
||||||
|
const SCH = SCHEME.FALCON512;
|
||||||
|
const N = 3, T = 2;
|
||||||
|
|
||||||
|
line('=== AERE non-custodial post-quantum threshold account — SDK demo (2-of-3 Falcon-512) ===\n');
|
||||||
|
|
||||||
|
// 1. A committee of 3 independent post-quantum keys (in production each lives on a separate device).
|
||||||
|
const members = [];
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const kp = keygen(SCH, getBytes('0x' + (i + 1).toString(16).padStart(96, '0'))); // 48-byte seed, deterministic for the demo
|
||||||
|
members.push({ memberIndex: i, publicKey: kp.publicKey, secretKey: kp.secretKey });
|
||||||
|
line(` member ${i}: Falcon-512 pubkey ${kp.publicKey.length} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The account + the call to authorize. (Addresses are placeholders — this demo is off-chain.)
|
||||||
|
const account = '0x1111111111111111111111111111111111111111';
|
||||||
|
const target = '0x2222222222222222222222222222222222222222';
|
||||||
|
const value = 0n;
|
||||||
|
const data = '0xa1e78d8b'; // some function selector the account would call
|
||||||
|
const nonce = 0n;
|
||||||
|
|
||||||
|
// 3. The EXACT challenge every member must sign, derived byte-for-byte like the contract.
|
||||||
|
const challenge = thresholdExecChallenge(account, nonce, target, value, data, 2800n);
|
||||||
|
line(`\n exec challenge @ nonce ${nonce}: ${challenge}`);
|
||||||
|
|
||||||
|
// 4. TWO members (0 and 2) each PQC-sign the challenge -> authorizing legs.
|
||||||
|
const signers = [members[0], members[2]];
|
||||||
|
const legs = signers.map((m) => signLeg(SCH, { memberIndex: m.memberIndex, secretKey: m.secretKey }, challenge));
|
||||||
|
|
||||||
|
// 5. Verify every leg locally with REAL Falcon verification (what the precompile enforces on-chain).
|
||||||
|
let ok = 0;
|
||||||
|
for (const leg of legs) {
|
||||||
|
const member = members[leg.memberIndex];
|
||||||
|
const valid = verifyLocal(SCH, getBytes(challenge), getBytes(leg.signature), member.publicKey);
|
||||||
|
line(` leg from member ${leg.memberIndex}: real Falcon verify = ${valid}`);
|
||||||
|
if (valid) ok++;
|
||||||
|
}
|
||||||
|
line(`\n distinct valid signers = ${ok} / threshold ${T} -> ${ok >= T ? 'AUTHORIZED' : 'REJECTED'}`);
|
||||||
|
|
||||||
|
// 6. The abi-encoded blob / call args the account.executeThreshold(...) would receive.
|
||||||
|
const blob = encodeLegs(legs);
|
||||||
|
line(`\n encoded Leg[] (${(blob.length - 2) / 2} bytes) ready for executeThreshold(target,value,data,legs)`);
|
||||||
|
|
||||||
|
// 7. Negative cases: below threshold, and a forged leg.
|
||||||
|
const oneLeg = [legs[0]];
|
||||||
|
line(`\n below-threshold (1 leg): distinct valid ${oneLeg.length} < ${T} -> REJECTED (as expected)`);
|
||||||
|
|
||||||
|
const forged = getBytes(legs[1].signature);
|
||||||
|
forged[41] ^= 0x01; // flip a byte inside the Falcon esig
|
||||||
|
const forgedValid = verifyLocal(SCH, getBytes(challenge), forged, members[2].publicKey);
|
||||||
|
line(` forged leg: real Falcon verify = ${forgedValid} -> not counted (as expected)`);
|
||||||
|
|
||||||
|
line('\n=== demo complete: any 2 of the 3 post-quantum keys authorize; 1 or a forgery does not. ===');
|
||||||
63
package-lock.json
generated
63
package-lock.json
generated
@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "@aere/sdk",
|
"name": "@aere/sdk",
|
||||||
"version": "0.3.0",
|
"version": "0.16.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@aere/sdk",
|
"name": "@aere/sdk",
|
||||||
"version": "0.3.0",
|
"version": "0.16.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/post-quantum": "^0.6.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.5.0",
|
"@types/node": "^22.5.0",
|
||||||
"ethers": "^6.13.4",
|
"ethers": "^6.13.4",
|
||||||
@ -24,6 +27,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@noble/curves": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||||
@ -50,6 +65,50 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.17",
|
"version": "22.19.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
|
||||||
|
|||||||
29
package.json
29
package.json
@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "@aere/sdk",
|
"name": "@aere/sdk",
|
||||||
"version": "0.16.1",
|
"version": "0.16.12",
|
||||||
"description": "Official AERE Network SDK — typed ethers v6 client for the 16 contracts deployed on AERE L1 (chain ID 2800). WAERE, oracle, identity, faucet, card-escrow, delegated + locked staking, governance, AMM factory, bridge, NFT + marketplace, mining subscriptions.",
|
"description": "Official AERE Network SDK: typed ethers v6 clients for AERE L1 (chain ID 2800). 35+ typed clients over a 130+ contract address book, covering core DeFi, agentic x402 settlement, compliance, ERC-4337 account abstraction, t-of-n post-quantum custody, the 5 live NIST PQC precompiles (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s, SHAKE256), and a seedless hybrid PQC wallet.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"aere": "./dist/cli/aere-cli.js"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@ -15,12 +18,25 @@
|
|||||||
"import": "./dist/addresses.js"
|
"import": "./dist/addresses.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": ["dist", "README.md"],
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "node --test dist/test/*.js"
|
"test": "node --test dist/test/*.js",
|
||||||
|
"test:live": "node dist/test/state-window-live.js"
|
||||||
},
|
},
|
||||||
"keywords": ["aere", "blockchain", "ethereum", "evm", "layer-1", "qbft", "defi", "sdk"],
|
"keywords": [
|
||||||
|
"aere",
|
||||||
|
"blockchain",
|
||||||
|
"ethereum",
|
||||||
|
"evm",
|
||||||
|
"layer-1",
|
||||||
|
"qbft",
|
||||||
|
"defi",
|
||||||
|
"sdk"
|
||||||
|
],
|
||||||
"homepage": "https://aere.network/docs.html",
|
"homepage": "https://aere.network/docs.html",
|
||||||
"repository": "https://git.aere.network/aere-network/sdk-js",
|
"repository": "https://git.aere.network/aere-network/sdk-js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@ -31,5 +47,8 @@
|
|||||||
"@types/node": "^22.5.0",
|
"@types/node": "^22.5.0",
|
||||||
"ethers": "^6.13.4",
|
"ethers": "^6.13.4",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/post-quantum": "^0.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/abis.ts
22
src/abis.ts
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Hand-curated ABI fragments — only the methods Bank28-class integrations actually call.
|
* Hand-curated ABI fragments — only the methods consumer-wallet-class integrations actually call.
|
||||||
* Full ABIs are at https://aere.network/abis/<ContractName>.json
|
* Full ABIs are at https://aere.network/abis/<ContractName>.json
|
||||||
*/
|
*/
|
||||||
export const ERC20_ABI = [
|
export const ERC20_ABI = [
|
||||||
@ -39,6 +39,26 @@ export const STAKING_V1_ABI = [
|
|||||||
'function currentAPR() external view returns (uint256)', // basis points
|
'function currentAPR() external view returns (uint256)', // basis points
|
||||||
] as const;
|
] 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 = [
|
export const LENDING_ABI = [
|
||||||
'function supply(uint256 amount) external payable',
|
'function supply(uint256 amount) external payable',
|
||||||
'function withdraw(uint256 amount) external',
|
'function withdraw(uint256 amount) external',
|
||||||
|
|||||||
282
src/account/AerePQCSocialRecoveryClient.ts
Normal file
282
src/account/AerePQCSocialRecoveryClient.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// AerePQCSocialRecoveryClient — orchestration client for AerePQCSocialRecoveryModule, the
|
||||||
|
// quantum-durable M-of-N recovery module for AereModularAccount (ERC-7579 executor / module type 2).
|
||||||
|
//
|
||||||
|
// Where SocialRecoveryClient recovers via ECDSA guardian ADDRESSES, this client recovers via
|
||||||
|
// POST-QUANTUM guardian KEYS: each guardian is a NIST PQC public key registered (with proof-of-
|
||||||
|
// possession) in AerePQCKeyRegistry, and a recovery is authorized by >= threshold DISTINCT
|
||||||
|
// guardians each PQC-signing a domain-separated challenge that binds (account, newOwner, round,
|
||||||
|
// chainid, module). This client derives that challenge byte-for-byte like the contract, produces
|
||||||
|
// each guardian's authorizing leg from their PQC secret key (reusing the audited pqc envelope path
|
||||||
|
// in ../pqc/schemes), encodes the leg array, and submits scheduleRecovery / executeRecovery /
|
||||||
|
// cancelRecovery. It never holds a key and never signs beyond the key material the caller supplies.
|
||||||
|
//
|
||||||
|
// The challenge derivation is cross-checked against the deployed contract in the repo's hardhat
|
||||||
|
// suite (test/AerePQCSocialRecoveryModule.test.js, "SDK challenge derivation matches the contract")
|
||||||
|
// so SDK<->contract parity is proven, not assumed.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract,
|
||||||
|
AbiCoder,
|
||||||
|
keccak256,
|
||||||
|
toUtf8Bytes,
|
||||||
|
getBytes,
|
||||||
|
hexlify,
|
||||||
|
type Signer,
|
||||||
|
type Provider,
|
||||||
|
type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { signInternal, type SchemeId } from '../pqc/schemes.js';
|
||||||
|
|
||||||
|
const coder = AbiCoder.defaultAbiCoder();
|
||||||
|
|
||||||
|
/** Domain separator — MUST match AerePQCSocialRecoveryModule.RECOVERY_DOMAIN exactly. */
|
||||||
|
export const RECOVERY_DOMAIN = keccak256(toUtf8Bytes('AerePQCSocialRecoveryModule.v1.recovery'));
|
||||||
|
|
||||||
|
/** Default AERE chain id. */
|
||||||
|
export const AERE_CHAIN_ID = 2800n;
|
||||||
|
|
||||||
|
/** 48-hour recovery timelock (AerePQCSocialRecoveryModule.RECOVERY_DELAY). */
|
||||||
|
export const RECOVERY_DELAY_SECONDS = 48 * 3600;
|
||||||
|
|
||||||
|
export const AERE_PQC_SOCIAL_RECOVERY_ABI = [
|
||||||
|
'function registry() view returns (address)',
|
||||||
|
'function RECOVERY_DELAY() view returns (uint256)',
|
||||||
|
'function thresholdOf(address account) view returns (uint256)',
|
||||||
|
'function recoveryNonce(address account) view returns (uint256)',
|
||||||
|
'function isGuardianKey(address account, uint256 keyId) view returns (bool)',
|
||||||
|
'function guardianKeysOf(address account) view returns (uint256[])',
|
||||||
|
'function guardianCount(address account) view returns (uint256)',
|
||||||
|
'function committee(address account) view returns (uint256 threshold, uint256 size, uint256 round, bool pending)',
|
||||||
|
'function activeRecovery(address account) view returns (address newOwner, uint64 executeAfter, uint256 round, bool active)',
|
||||||
|
'function recoveryChallenge(address account, address newOwner, uint256 round) view returns (bytes32)',
|
||||||
|
'function currentRecoveryChallenge(address account, address newOwner) view returns (bytes32)',
|
||||||
|
'function isModuleType(uint256 moduleTypeId) pure returns (bool)',
|
||||||
|
'function name() pure returns (string)',
|
||||||
|
// guardian configuration — msg.sender MUST be the smart account being protected
|
||||||
|
'function onInstall(bytes data)',
|
||||||
|
'function onUninstall(bytes data)',
|
||||||
|
'function addGuardianKey(uint256 keyId)',
|
||||||
|
'function removeGuardianKey(uint256 keyId)',
|
||||||
|
'function setThreshold(uint256 threshold)',
|
||||||
|
// recovery flow
|
||||||
|
'function scheduleRecovery(address account, address newOwner, tuple(uint8 guardianIndex, bytes signature)[] legs) returns (uint256 round)',
|
||||||
|
'function executeRecovery(address account)',
|
||||||
|
'function cancelRecovery(address account)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** ERC-7579 module type id for an executor module (AerePQCSocialRecoveryModule is type 2). */
|
||||||
|
export const MODULE_TYPE_EXECUTOR = 2n;
|
||||||
|
|
||||||
|
/** One authorizing leg: which guardian (index into the account's guardian keyId array) and that
|
||||||
|
* guardian's PQC signature envelope over the recovery challenge. */
|
||||||
|
export interface Leg {
|
||||||
|
guardianIndex: number;
|
||||||
|
signature: string; // 0x-hex envelope in the guardian scheme's on-chain encoding
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A guardian's signing material for producing a leg off-chain. `guardianIndex` is the position of
|
||||||
|
* this guardian's keyId in the account's guardian array; `scheme` must match the registered key. */
|
||||||
|
export interface GuardianKey {
|
||||||
|
guardianIndex: number;
|
||||||
|
scheme: SchemeId;
|
||||||
|
secretKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending recovery, as read from activeRecovery(account). */
|
||||||
|
export interface PendingRecovery {
|
||||||
|
newOwner: string;
|
||||||
|
executeAfter: number; // unix seconds; earliest time executeRecovery succeeds
|
||||||
|
round: bigint;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pure challenge derivation (mirrors the contract) ─────────────────────────
|
||||||
|
|
||||||
|
/** The exact 32-byte challenge each guardian must PQC-sign to authorize replacing `account`'s root
|
||||||
|
* owner with `newOwner` at `round`. Mirrors AerePQCSocialRecoveryModule.recoveryChallenge:
|
||||||
|
* keccak256(abi.encode(RECOVERY_DOMAIN, chainId, module, account, newOwner, round)). */
|
||||||
|
export function recoveryChallenge(
|
||||||
|
module: string,
|
||||||
|
account: string,
|
||||||
|
newOwner: string,
|
||||||
|
round: bigint,
|
||||||
|
chainId: bigint = AERE_CHAIN_ID,
|
||||||
|
): string {
|
||||||
|
return keccak256(
|
||||||
|
coder.encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'address', 'address', 'uint256'],
|
||||||
|
[RECOVERY_DOMAIN, chainId, module, account, newOwner, round],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── guardian committee install data ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** ABI-encode the ERC-7579 `onInstall` initData for AerePQCSocialRecoveryModule:
|
||||||
|
* abi.encode(uint256[] guardianKeyIds, uint256 threshold). Each keyId must be a distinct,
|
||||||
|
* currently ACTIVE AerePQCKeyRegistry key NOT owned by the account itself. This is the exact
|
||||||
|
* `data` the account passes to the module's onInstall (directly, or as the initData of an
|
||||||
|
* ERC-7579 installModule(2, module, initData)). */
|
||||||
|
export function encodeGuardianInstallData(guardianKeyIds: Array<bigint | number>, threshold: bigint | number): string {
|
||||||
|
return coder.encode(['uint256[]', 'uint256'], [guardianKeyIds.map((k) => BigInt(k)), BigInt(threshold)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── leg production + encoding ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Produce one guardian's authorizing leg by PQC-signing `challenge` with their secret key. The
|
||||||
|
* envelope is exactly what the on-chain registry precompile verifies (via the audited pqc path). */
|
||||||
|
export function signRecoveryLeg(guardian: GuardianKey, challenge: string): Leg {
|
||||||
|
const sig = signInternal(guardian.scheme, getBytes(challenge), guardian.secretKey);
|
||||||
|
return { guardianIndex: guardian.guardianIndex, signature: hexlify(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ABI-encode the leg tuple array for scheduleRecovery. */
|
||||||
|
export function encodeLegs(legs: Leg[]): Array<[number, string]> {
|
||||||
|
return legs.map((l) => [l.guardianIndex, l.signature]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── client ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class AerePQCSocialRecoveryClient {
|
||||||
|
readonly contract: Contract;
|
||||||
|
readonly moduleAddress: string;
|
||||||
|
private readonly chainId: bigint;
|
||||||
|
|
||||||
|
constructor(moduleAddress: string, runner: Signer | Provider, chainId: bigint = AERE_CHAIN_ID) {
|
||||||
|
this.moduleAddress = moduleAddress;
|
||||||
|
this.contract = new Contract(moduleAddress, AERE_PQC_SOCIAL_RECOVERY_ABI, runner);
|
||||||
|
this.chainId = chainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async committee(account: string): Promise<{ threshold: bigint; size: bigint; round: bigint; pending: boolean }> {
|
||||||
|
const [threshold, size, round, pending] = await this.contract.committee(account);
|
||||||
|
return { threshold, size, round, pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
async recoveryNonce(account: string): Promise<bigint> {
|
||||||
|
return this.contract.recoveryNonce(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async guardianKeysOf(account: string): Promise<bigint[]> {
|
||||||
|
return this.contract.guardianKeysOf(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async thresholdOf(account: string): Promise<bigint> {
|
||||||
|
return this.contract.thresholdOf(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async guardianCount(account: string): Promise<bigint> {
|
||||||
|
return this.contract.guardianCount(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isGuardianKey(account: string, keyId: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.isGuardianKey(account, keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The AerePQCKeyRegistry this module verifies guardian keys against. */
|
||||||
|
async registry(): Promise<string> {
|
||||||
|
return this.contract.registry();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- guardian committee configuration (calldata; msg.sender must be the account) ----
|
||||||
|
|
||||||
|
/** Calldata for the account to INSTALL its guardian committee in one call:
|
||||||
|
* onInstall(abi.encode(guardianKeyIds, threshold)). The account sends this to the module
|
||||||
|
* (msg.sender == the protected account), typically via its own execute() / installModule(). */
|
||||||
|
encodeOnInstall(guardianKeyIds: Array<bigint | number>, threshold: bigint | number): string {
|
||||||
|
return this.contract.interface.encodeFunctionData('onInstall', [encodeGuardianInstallData(guardianKeyIds, threshold)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calldata for the account to add one more guardian key to its committee. */
|
||||||
|
encodeAddGuardianKey(keyId: bigint | number): string {
|
||||||
|
return this.contract.interface.encodeFunctionData('addGuardianKey', [BigInt(keyId)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calldata for the account to drop a guardian key from its committee. */
|
||||||
|
encodeRemoveGuardianKey(keyId: bigint | number): string {
|
||||||
|
return this.contract.interface.encodeFunctionData('removeGuardianKey', [BigInt(keyId)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calldata for the account to change its M-of-N threshold. */
|
||||||
|
encodeSetThreshold(threshold: bigint | number): string {
|
||||||
|
return this.contract.interface.encodeFunctionData('setThreshold', [BigInt(threshold)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calldata for scheduleRecovery (any relayer may broadcast; the PQC legs are the authorization). */
|
||||||
|
encodeScheduleRecovery(account: string, newOwner: string, legs: Leg[]): string {
|
||||||
|
return this.contract.interface.encodeFunctionData('scheduleRecovery', [account, newOwner, encodeLegs(legs)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calldata for executeRecovery once the 48h timelock elapses (permissionless). */
|
||||||
|
encodeExecuteRecovery(account: string): string {
|
||||||
|
return this.contract.interface.encodeFunctionData('executeRecovery', [account]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calldata for cancelRecovery (current root owner or the account itself only). */
|
||||||
|
encodeCancelRecovery(account: string): string {
|
||||||
|
return this.contract.interface.encodeFunctionData('cancelRecovery', [account]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveRecovery(account: string): Promise<PendingRecovery> {
|
||||||
|
const [newOwner, executeAfter, round, active] = await this.contract.activeRecovery(account);
|
||||||
|
return { newOwner, executeAfter: Number(executeAfter), round, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locally derive the recovery challenge for `newOwner` at the account's CURRENT round. */
|
||||||
|
async currentChallenge(account: string, newOwner: string): Promise<{ round: bigint; challenge: string }> {
|
||||||
|
const round = await this.recoveryNonce(account);
|
||||||
|
return { round, challenge: recoveryChallenge(this.moduleAddress, account, newOwner, round, this.chainId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- leg building ---------------------------------------------------------
|
||||||
|
|
||||||
|
/** Build the authorizing legs for `newOwner` from the supplied guardian keys, signing the
|
||||||
|
* challenge for the account's current round (or an explicit `round`). */
|
||||||
|
async buildLegs(
|
||||||
|
account: string,
|
||||||
|
newOwner: string,
|
||||||
|
guardians: GuardianKey[],
|
||||||
|
round?: bigint,
|
||||||
|
): Promise<Leg[]> {
|
||||||
|
const r = round ?? (await this.recoveryNonce(account));
|
||||||
|
const challenge = recoveryChallenge(this.moduleAddress, account, newOwner, r, this.chainId);
|
||||||
|
return guardians.map((g) => signRecoveryLeg(g, challenge));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** High-level: derive the current-round challenge, sign a leg with each supplied guardian key,
|
||||||
|
* and submit scheduleRecovery. Permissionless — any relayer can send this; the PQC legs are the
|
||||||
|
* authorization. Arms the owner swap for RECOVERY_DELAY seconds later. */
|
||||||
|
async authorizeAndSchedule(
|
||||||
|
account: string,
|
||||||
|
newOwner: string,
|
||||||
|
guardians: GuardianKey[],
|
||||||
|
): Promise<ContractTransactionResponse> {
|
||||||
|
const legs = await this.buildLegs(account, newOwner, guardians);
|
||||||
|
return this.contract.scheduleRecovery(account, newOwner, encodeLegs(legs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submit a pre-built leg array (e.g. legs collected from guardians out of band). */
|
||||||
|
async scheduleRecovery(
|
||||||
|
account: string,
|
||||||
|
newOwner: string,
|
||||||
|
legs: Leg[],
|
||||||
|
): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.scheduleRecovery(account, newOwner, encodeLegs(legs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute a scheduled recovery once the 48h timelock elapses (permissionless). */
|
||||||
|
async executeRecovery(account: string): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.executeRecovery(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a pending recovery (current root owner or the account itself only). */
|
||||||
|
async cancelRecovery(account: string): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.cancelRecovery(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
307
src/account/ModularAccountClient.ts
Normal file
307
src/account/ModularAccountClient.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
// ModularAccountClient — dependency-free EIP-1193 client for AereModularAccount
|
||||||
|
// + AereModularAccountFactory, bound to AereEntryPointV2 on AERE chain 2800.
|
||||||
|
//
|
||||||
|
// Covers: counterfactual CREATE2 address (local mirror of the factory), account
|
||||||
|
// creation, module install/uninstall, root-owner execute, userOp build + hash +
|
||||||
|
// root-owner signing, and submission via EntryPointV2.handleOps.
|
||||||
|
|
||||||
|
import {
|
||||||
|
SEL, encAddr, encUint, encodeExecute, encodeExecuteBatch, encodeModuleCall,
|
||||||
|
encodeIsModuleInstalled, encodeHandleOps, encodeGetUserOpHash, computeUserOpHash,
|
||||||
|
decodeAddress, decodeBool, decodeUint, type CallStruct,
|
||||||
|
} from './abi.js';
|
||||||
|
import { clean, keccak256Hex, keccak256Utf8 } from './hash.js';
|
||||||
|
import {
|
||||||
|
MODULE_TYPE_VALIDATOR, MODULE_TYPE_EXECUTOR,
|
||||||
|
type Eip1193Provider, type PackedUserOp, type GasParams, type UserOpSigner,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/** keccak256(type(AereModularAccount).creationCode) — from the live deployment. */
|
||||||
|
export const AERE_ACCOUNT_INIT_CODE_HASH =
|
||||||
|
'0x50a965c6dfd43567e955b468ecdbd48e38e84bde5154542fc70a426cdaf10e5e';
|
||||||
|
|
||||||
|
export interface ModularAccountAddresses {
|
||||||
|
factory: string; // AereModularAccountFactory
|
||||||
|
entryPoint: string; // AereEntryPointV2
|
||||||
|
sessionKeyValidator?: string;
|
||||||
|
socialRecoveryModule?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live AERE chain-2800 modular-account deployment (2026-07-10).
|
||||||
|
* sessionKeyValidator points at AereSessionKeyValidatorV2 (the audited fix), NOT
|
||||||
|
* the deprecated V1 0x6e03A3D7A4c90d6f8dD6F0BA1F6e8aB1F8990D26. V1 had a per-batch
|
||||||
|
* value-cap bypass (a multi-call batch each == maxValuePerOp drained N x the per-op
|
||||||
|
* cap) and an ownership-steal via a self-targeted setRootOwner selector; V2 is a
|
||||||
|
* drop-in module (same install-by-address surface, module type 1) that enforces a
|
||||||
|
* maxTotalValue cap across the batch and forbids self-targeted owner selectors.
|
||||||
|
* See aerenew/contracts/V1-TO-V2-MANIFEST.md and test/audit-fix-v2-sessionkey-2026-07.test.js.
|
||||||
|
*/
|
||||||
|
export const AERE_MODULAR_ADDRESSES: ModularAccountAddresses = {
|
||||||
|
factory: '0xE3f45Ed4a81f982fF25ad172A72456a1833a440E',
|
||||||
|
entryPoint: '0x8D6f40598d552fF0Cb358b6012cF4227B86aF770',
|
||||||
|
sessionKeyValidator: '0xC06EAe63Ed12307F56A1506917C48C027D8852ff', // AereSessionKeyValidatorV2 (audited); supersedes flawed V1 0x6e03A3D7…0D26
|
||||||
|
socialRecoveryModule: '0x077514DB2a85F239145537e8334CC99d42c9D812',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ModularAccountClientOptions {
|
||||||
|
chainId?: number; // default 2800
|
||||||
|
initCodeHash?: string; // default AERE_ACCOUNT_INIT_CODE_HASH
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_GAS: Required<Omit<GasParams, 'maxFeePerGas' | 'maxPriorityFeePerGas'>> = {
|
||||||
|
verificationGasLimit: 500_000n,
|
||||||
|
callGasLimit: 500_000n,
|
||||||
|
preVerificationGas: 21_000n,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** EIP-55 checksum an address using the SDK's own keccak. */
|
||||||
|
export function toChecksumAddress(addr: string): string {
|
||||||
|
const lower = clean(addr).toLowerCase();
|
||||||
|
if (lower.length !== 40) throw new Error(`bad address ${addr}`);
|
||||||
|
const hash = clean(keccak256Utf8(lower));
|
||||||
|
let out = '0x';
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
out += parseInt(hash[i], 16) >= 8 ? lower[i].toUpperCase() : lower[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root-owner signature envelope: 0x00 || 65-byte ECDSA. */
|
||||||
|
export function routeRootSignature(sig65: string): string {
|
||||||
|
const s = clean(sig65);
|
||||||
|
if (s.length !== 130) throw new Error(`root signature must be 65 bytes, got ${s.length / 2}`);
|
||||||
|
return '0x00' + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validator-route signature envelope: 0x01 || validator(20) || payload. */
|
||||||
|
export function routeValidatorSignature(validator: string, payload: string): string {
|
||||||
|
return '0x01' + clean(encAddr(validator).slice(24)) + clean(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A UserOpSigner backed by an EIP-1193 wallet's personal_sign (good for a
|
||||||
|
* root owner whose key lives in a browser wallet / MetaMask). For a raw
|
||||||
|
* session-key private key, adapt your own signer (ethers:
|
||||||
|
* `(h) => wallet.signMessage(getBytes(h))`, viem:
|
||||||
|
* `(h) => account.signMessage({ message: { raw: h } })`).
|
||||||
|
*/
|
||||||
|
export function eip1193PersonalSign(provider: Eip1193Provider, signerAddress: string): UserOpSigner {
|
||||||
|
return async (userOpHash: string) => {
|
||||||
|
const sig = await provider.request({ method: 'personal_sign', params: [userOpHash, signerAddress] });
|
||||||
|
return sig as string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModularAccountClient {
|
||||||
|
readonly provider: Eip1193Provider;
|
||||||
|
readonly factory: string;
|
||||||
|
readonly entryPoint: string;
|
||||||
|
readonly chainId: number;
|
||||||
|
readonly initCodeHash: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
provider: Eip1193Provider,
|
||||||
|
addresses: Pick<ModularAccountAddresses, 'factory' | 'entryPoint'> = AERE_MODULAR_ADDRESSES,
|
||||||
|
opts: ModularAccountClientOptions = {},
|
||||||
|
) {
|
||||||
|
this.provider = provider;
|
||||||
|
this.factory = addresses.factory;
|
||||||
|
this.entryPoint = addresses.entryPoint;
|
||||||
|
this.chainId = opts.chainId ?? 2800;
|
||||||
|
this.initCodeHash = opts.initCodeHash ?? AERE_ACCOUNT_INIT_CODE_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CREATE2 counterfactual address (local mirror of the factory) --------
|
||||||
|
|
||||||
|
/** saltMix = keccak256(abi.encode(rootOwner, salt)). */
|
||||||
|
saltMix(rootOwner: string, salt: bigint): string {
|
||||||
|
return keccak256Hex(encAddr(rootOwner) + encUint(salt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic account address for (rootOwner, salt), computed entirely
|
||||||
|
* off-chain. Mirrors AereModularAccountFactory.getAddress:
|
||||||
|
* addr = keccak256(0xff || factory || saltMix || initCodeHash)[12:]
|
||||||
|
*/
|
||||||
|
getAddress(rootOwner: string, salt: bigint): string {
|
||||||
|
const preimage =
|
||||||
|
'ff' +
|
||||||
|
clean(encAddr(this.factory).slice(24)) +
|
||||||
|
clean(this.saltMix(rootOwner, salt)) +
|
||||||
|
clean(this.initCodeHash);
|
||||||
|
const h = clean(keccak256Hex(preimage));
|
||||||
|
return toChecksumAddress(h.slice(24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same address, but read from the deployed factory (cross-check). */
|
||||||
|
async getAddressOnChain(rootOwner: string, salt: bigint): Promise<string> {
|
||||||
|
const data = SEL.getAddress + encAddr(rootOwner) + encUint(salt);
|
||||||
|
return toChecksumAddress(decodeAddress(await this.ethCall(this.factory, data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async isDeployed(account: string): Promise<boolean> {
|
||||||
|
const code = await this.provider.request({ method: 'eth_getCode', params: [account, 'latest'] });
|
||||||
|
return typeof code === 'string' && clean(code).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Account reads --------------------------------------------------------
|
||||||
|
|
||||||
|
async rootOwnerOf(account: string): Promise<string> {
|
||||||
|
return toChecksumAddress(decodeAddress(await this.ethCall(account, SEL.rootOwner)));
|
||||||
|
}
|
||||||
|
async entryPointOf(account: string): Promise<string> {
|
||||||
|
return toChecksumAddress(decodeAddress(await this.ethCall(account, SEL.entryPoint)));
|
||||||
|
}
|
||||||
|
async isModuleInstalled(account: string, moduleTypeId: number, module: string): Promise<boolean> {
|
||||||
|
return decodeBool(await this.ethCall(account, encodeIsModuleInstalled(moduleTypeId, module)));
|
||||||
|
}
|
||||||
|
async isValidatorInstalled(account: string, validator: string): Promise<boolean> {
|
||||||
|
return this.isModuleInstalled(account, MODULE_TYPE_VALIDATOR, validator);
|
||||||
|
}
|
||||||
|
async isExecutorInstalled(account: string, module: string): Promise<boolean> {
|
||||||
|
return this.isModuleInstalled(account, MODULE_TYPE_EXECUTOR, module);
|
||||||
|
}
|
||||||
|
/** EntryPoint deposit balance credited to the account. */
|
||||||
|
async depositOf(account: string): Promise<bigint> {
|
||||||
|
return decodeUint(await this.ethCall(this.entryPoint, SEL.balanceOf + encAddr(account)));
|
||||||
|
}
|
||||||
|
/** EntryPoint sequential nonce for the account. */
|
||||||
|
async getNonce(account: string): Promise<bigint> {
|
||||||
|
return decodeUint(await this.ethCall(this.entryPoint, SEL.getNonce + encAddr(account)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Calldata builders (pure) --------------------------------------------
|
||||||
|
|
||||||
|
encodeCreateAccount(rootOwner: string, salt: bigint): string {
|
||||||
|
return SEL.createAccount + encAddr(rootOwner) + encUint(salt);
|
||||||
|
}
|
||||||
|
encodeExecute(target: string, value: bigint, data: string): string {
|
||||||
|
return encodeExecute(target, value, data);
|
||||||
|
}
|
||||||
|
encodeExecuteBatch(calls: CallStruct[]): string {
|
||||||
|
return encodeExecuteBatch(calls);
|
||||||
|
}
|
||||||
|
encodeInstallModule(moduleTypeId: number, module: string, initData: string): string {
|
||||||
|
return encodeModuleCall(SEL.installModule, moduleTypeId, module, initData);
|
||||||
|
}
|
||||||
|
encodeUninstallModule(moduleTypeId: number, module: string, deInitData: string): string {
|
||||||
|
return encodeModuleCall(SEL.uninstallModule, moduleTypeId, module, deInitData);
|
||||||
|
}
|
||||||
|
encodeSetRootOwner(newOwner: string): string {
|
||||||
|
return SEL.setRootOwner + encAddr(newOwner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Writes (deploy agent / root-owner EOA only; no writes in build) -----
|
||||||
|
|
||||||
|
/** createAccount(rootOwner, salt) — idempotent on-chain. */
|
||||||
|
async sendCreateAccount(from: string, rootOwner: string, salt: bigint): Promise<string> {
|
||||||
|
return this.ethSend(from, this.factory, this.encodeCreateAccount(rootOwner, salt));
|
||||||
|
}
|
||||||
|
/** installModule directly as the root owner (allowed by onlyEntryPointSelfOrRoot). */
|
||||||
|
async sendInstallModule(from: string, account: string, moduleTypeId: number, module: string, initData: string): Promise<string> {
|
||||||
|
return this.ethSend(from, account, this.encodeInstallModule(moduleTypeId, module, initData));
|
||||||
|
}
|
||||||
|
async sendUninstallModule(from: string, account: string, moduleTypeId: number, module: string, deInitData: string): Promise<string> {
|
||||||
|
return this.ethSend(from, account, this.encodeUninstallModule(moduleTypeId, module, deInitData));
|
||||||
|
}
|
||||||
|
/** execute() directly as the root owner. */
|
||||||
|
async sendExecute(from: string, account: string, target: string, value: bigint, data: string): Promise<string> {
|
||||||
|
return this.ethSend(from, account, this.encodeExecute(target, value, data));
|
||||||
|
}
|
||||||
|
/** Fund the account's EntryPoint deposit (depositTo(account), value in wei). */
|
||||||
|
async sendDeposit(from: string, account: string, valueWei: bigint): Promise<string> {
|
||||||
|
const data = SEL.depositTo + encAddr(account);
|
||||||
|
return this.provider.request({
|
||||||
|
method: 'eth_sendTransaction',
|
||||||
|
params: [{ from, to: this.entryPoint, data, value: '0x' + valueWei.toString(16) }],
|
||||||
|
}) as Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UserOp build / hash / sign / submit ---------------------------------
|
||||||
|
|
||||||
|
private pack(hi: bigint, lo: bigint): string {
|
||||||
|
return '0x' + encUint((hi << 128n) | lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an unsigned PackedUserOp. Fetches the EntryPoint nonce and gas price
|
||||||
|
* unless overridden. callData should be execute()/executeBatch() on `sender`.
|
||||||
|
*/
|
||||||
|
async buildUserOp(params: {
|
||||||
|
sender: string;
|
||||||
|
callData: string;
|
||||||
|
gas?: GasParams;
|
||||||
|
nonce?: bigint;
|
||||||
|
initCode?: string;
|
||||||
|
paymasterAndData?: string;
|
||||||
|
}): Promise<PackedUserOp> {
|
||||||
|
const nonce = params.nonce ?? (await this.getNonce(params.sender));
|
||||||
|
const g = params.gas ?? {};
|
||||||
|
const verif = g.verificationGasLimit ?? DEFAULT_GAS.verificationGasLimit;
|
||||||
|
const call = g.callGasLimit ?? DEFAULT_GAS.callGasLimit;
|
||||||
|
const preVerif = g.preVerificationGas ?? DEFAULT_GAS.preVerificationGas;
|
||||||
|
const maxFee = g.maxFeePerGas ?? (await this.gasPrice());
|
||||||
|
const maxPriority = g.maxPriorityFeePerGas ?? maxFee;
|
||||||
|
return {
|
||||||
|
sender: params.sender,
|
||||||
|
nonce,
|
||||||
|
initCode: params.initCode ?? '0x',
|
||||||
|
callData: params.callData,
|
||||||
|
accountGasLimits: this.pack(verif, call),
|
||||||
|
preVerificationGas: preVerif,
|
||||||
|
gasFees: this.pack(maxPriority, maxFee),
|
||||||
|
paymasterAndData: params.paymasterAndData ?? '0x',
|
||||||
|
signature: '0x',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Off-chain userOpHash (mirrors AereEntryPointV2.getUserOpHash). */
|
||||||
|
userOpHash(op: PackedUserOp): string {
|
||||||
|
return computeUserOpHash(op, this.entryPoint, this.chainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain userOpHash via eth_call (cross-check of the local computation). */
|
||||||
|
async userOpHashOnChain(op: PackedUserOp): Promise<string> {
|
||||||
|
const res = await this.ethCall(this.entryPoint, encodeGetUserOpHash(op));
|
||||||
|
return '0x' + clean(res).slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach a root-owner (0x00) signature produced by `signer`. */
|
||||||
|
async signRootUserOp(op: PackedUserOp, signer: UserOpSigner): Promise<PackedUserOp> {
|
||||||
|
const sig = await signer(this.userOpHash(op));
|
||||||
|
return { ...op, signature: routeRootSignature(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeHandleOps(ops: PackedUserOp[], beneficiary: string): string {
|
||||||
|
return encodeHandleOps(ops, beneficiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submit one or more ops through EntryPointV2.handleOps (from = bundler EOA). */
|
||||||
|
async sendHandleOps(from: string, ops: PackedUserOp[], beneficiary: string): Promise<string> {
|
||||||
|
return this.ethSend(from, this.entryPoint, encodeHandleOps(ops, beneficiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- low-level RPC --------------------------------------------------------
|
||||||
|
|
||||||
|
private async gasPrice(): Promise<bigint> {
|
||||||
|
try {
|
||||||
|
const p = await this.provider.request({ method: 'eth_gasPrice', params: [] });
|
||||||
|
const v = BigInt(p as string);
|
||||||
|
return v > 0n ? v : 1_000_000_000n;
|
||||||
|
} catch {
|
||||||
|
return 1_000_000_000n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async ethCall(to: string, data: string): Promise<string> {
|
||||||
|
const r = await this.provider.request({ method: 'eth_call', params: [{ to, data }, 'latest'] });
|
||||||
|
if (typeof r !== 'string') throw new Error('ModularAccountClient: bad eth_call result');
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
private async ethSend(from: string, to: string, data: string): Promise<string> {
|
||||||
|
return this.provider.request({
|
||||||
|
method: 'eth_sendTransaction',
|
||||||
|
params: [{ from, to, data, value: '0x0' }],
|
||||||
|
}) as Promise<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/account/SessionKeyClient.ts
Normal file
156
src/account/SessionKeyClient.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// SessionKeyClient — dependency-free EIP-1193 client for AereSessionKeyValidator
|
||||||
|
// (ERC-7579 module type 1). Registers scoped session keys, builds session-signed
|
||||||
|
// userOps (0x01-routed), and checks scope locally before submission.
|
||||||
|
//
|
||||||
|
// Registration model (from the contract): all mutating functions key state by
|
||||||
|
// msg.sender = the smart account, so a session is created either
|
||||||
|
// (a) at install time: installModule(1, validator, installInitData(scope)), or
|
||||||
|
// (b) after install: account.execute(validator, 0, encodeEnableSession(scope)).
|
||||||
|
// initiate/enable/revoke/permission calls therefore travel THROUGH the account.
|
||||||
|
|
||||||
|
import {
|
||||||
|
SEL, encAddr, encUint, encBytes4, encodeSessionScope, decodeBool, decodeUint,
|
||||||
|
} from './abi.js';
|
||||||
|
import { clean } from './hash.js';
|
||||||
|
import { routeValidatorSignature } from './ModularAccountClient.js';
|
||||||
|
import type { ModularAccountClient } from './ModularAccountClient.js';
|
||||||
|
import {
|
||||||
|
SELECTOR_NATIVE_TRANSFER,
|
||||||
|
type Eip1193Provider, type PackedUserOp, type SessionScope, type SessionInfo,
|
||||||
|
type UserOpSigner, type GasParams,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
function scopeArrays(scope: SessionScope): { targets: string[]; selectors: string[] } {
|
||||||
|
return {
|
||||||
|
targets: scope.permissions.map((p) => p.target),
|
||||||
|
selectors: scope.permissions.map((p) => p.selector),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeCheck { ok: boolean; reason?: string; }
|
||||||
|
|
||||||
|
export class SessionKeyClient {
|
||||||
|
readonly provider: Eip1193Provider;
|
||||||
|
readonly validator: string;
|
||||||
|
|
||||||
|
constructor(provider: Eip1193Provider, validator: string) {
|
||||||
|
this.provider = provider;
|
||||||
|
this.validator = validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Encoders -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** installModule(1, validator, initData) payload that pre-arms `scope`. */
|
||||||
|
encodeInstallInitData(scope: SessionScope): string {
|
||||||
|
const { targets, selectors } = scopeArrays(scope);
|
||||||
|
return '0x' + encodeSessionScope(
|
||||||
|
scope.key, scope.validAfter, scope.validUntil, scope.maxValuePerOp, targets, selectors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** validator.enableSession(...) calldata (wrap in account.execute or a userOp). */
|
||||||
|
encodeEnableSession(scope: SessionScope): string {
|
||||||
|
const { targets, selectors } = scopeArrays(scope);
|
||||||
|
return SEL.enableSession + encodeSessionScope(
|
||||||
|
scope.key, scope.validAfter, scope.validUntil, scope.maxValuePerOp, targets, selectors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
encodeRevokeSession(key: string): string {
|
||||||
|
return SEL.revokeSession + encAddr(key);
|
||||||
|
}
|
||||||
|
encodeAddPermission(key: string, target: string, selector: string): string {
|
||||||
|
return SEL.addPermission + encAddr(key) + encAddr(target) + encBytes4(selector);
|
||||||
|
}
|
||||||
|
encodeRemovePermission(key: string, target: string, selector: string): string {
|
||||||
|
return SEL.removePermission + encAddr(key) + encAddr(target) + encBytes4(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** sessions(account, key) -> (enabled, validAfter, validUntil, maxValuePerOp). */
|
||||||
|
async getSession(account: string, key: string): Promise<SessionInfo> {
|
||||||
|
const data = SEL.sessions + encAddr(account) + encAddr(key);
|
||||||
|
const res = clean(await this.ethCall(data));
|
||||||
|
return {
|
||||||
|
enabled: decodeBool(res, 0),
|
||||||
|
validAfter: Number(decodeUint(res, 1)),
|
||||||
|
validUntil: Number(decodeUint(res, 2)),
|
||||||
|
maxValuePerOp: decodeUint(res, 3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** allowedCall(account, key, target, selector) -> bool. */
|
||||||
|
async isCallAllowed(account: string, key: string, target: string, selector: string): Promise<boolean> {
|
||||||
|
const data = SEL.allowedCall + encAddr(account) + encAddr(key) + encAddr(target) + encBytes4(selector);
|
||||||
|
return decodeBool(await this.ethCall(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Local scope validation (mirror of the on-chain checks) --------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a single intended call against `scope` WITHOUT touching the chain.
|
||||||
|
* Mirrors AereSessionKeyValidator._checkSingle + the account's time-window
|
||||||
|
* enforcement. `nowSec` defaults to the wall clock.
|
||||||
|
*/
|
||||||
|
checkCallLocally(
|
||||||
|
scope: SessionScope,
|
||||||
|
call: { target: string; value: bigint; data: string },
|
||||||
|
nowSec: number = Math.floor(Date.now() / 1000),
|
||||||
|
): ScopeCheck {
|
||||||
|
if (scope.validAfter !== 0 && nowSec < scope.validAfter) return { ok: false, reason: 'session not yet active' };
|
||||||
|
if (scope.validUntil !== 0 && nowSec > scope.validUntil) return { ok: false, reason: 'session expired' };
|
||||||
|
if (call.value > scope.maxValuePerOp) return { ok: false, reason: 'value exceeds maxValuePerOp' };
|
||||||
|
const d = clean(call.data);
|
||||||
|
let selector: string;
|
||||||
|
if (d.length === 0) selector = clean(SELECTOR_NATIVE_TRANSFER);
|
||||||
|
else if (d.length >= 8) selector = d.slice(0, 8);
|
||||||
|
else return { ok: false, reason: '1-3 byte calldata is never in scope' };
|
||||||
|
const t = clean(call.target).toLowerCase();
|
||||||
|
const allowed = scope.permissions.some(
|
||||||
|
(p) => clean(p.target).toLowerCase() === t && clean(p.selector).toLowerCase() === selector,
|
||||||
|
);
|
||||||
|
return allowed ? { ok: true } : { ok: false, reason: `(target, 0x${selector}) not in scope` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Session userOp build + sign -----------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an unsigned userOp whose callData is account.execute(target,value,data),
|
||||||
|
* and (optionally) assert the call is in `scope` first. Returns the op plus its
|
||||||
|
* hash. Sign it with signSessionUserOp(...).
|
||||||
|
*/
|
||||||
|
async buildSessionUserOp(
|
||||||
|
account: ModularAccountClient,
|
||||||
|
sender: string,
|
||||||
|
call: { target: string; value: bigint; data: string },
|
||||||
|
opts: { scope?: SessionScope; gas?: GasParams; nonce?: bigint; assertScope?: boolean } = {},
|
||||||
|
): Promise<{ op: PackedUserOp; userOpHash: string }> {
|
||||||
|
if (opts.assertScope !== false && opts.scope) {
|
||||||
|
const chk = this.checkCallLocally(opts.scope, call);
|
||||||
|
if (!chk.ok) throw new Error(`SessionKeyClient: out-of-scope call — ${chk.reason}`);
|
||||||
|
}
|
||||||
|
const callData = account.encodeExecute(call.target, call.value, call.data);
|
||||||
|
const op = await account.buildUserOp({ sender, callData, gas: opts.gas, nonce: opts.nonce });
|
||||||
|
return { op, userOpHash: account.userOpHash(op) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0x01 || validator || ECDSA(session key) over the personal-sign envelope. */
|
||||||
|
buildSessionSignature(sessionSig65: string): string {
|
||||||
|
return routeValidatorSignature(this.validator, sessionSig65);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach a session-key signature to an op (signer signs op's userOpHash). */
|
||||||
|
async signSessionUserOp(
|
||||||
|
account: ModularAccountClient, op: PackedUserOp, sessionSigner: UserOpSigner,
|
||||||
|
): Promise<PackedUserOp> {
|
||||||
|
const sig = await sessionSigner(account.userOpHash(op));
|
||||||
|
return { ...op, signature: this.buildSessionSignature(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ethCall(data: string): Promise<string> {
|
||||||
|
const r = await this.provider.request({ method: 'eth_call', params: [{ to: this.validator, data }, 'latest'] });
|
||||||
|
if (typeof r !== 'string') throw new Error('SessionKeyClient: bad eth_call result');
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/account/SocialRecoveryClient.ts
Normal file
130
src/account/SocialRecoveryClient.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// SocialRecoveryClient — dependency-free EIP-1193 client for
|
||||||
|
// AereSocialRecoveryModule (ERC-7579 module type 2 / executor).
|
||||||
|
//
|
||||||
|
// Two audiences:
|
||||||
|
// - The ACCOUNT OWNER manages config. install + addGuardian/removeGuardian/
|
||||||
|
// setThreshold are keyed by msg.sender = the account, so they travel through
|
||||||
|
// account.execute (or the module install path). Encoders are provided here;
|
||||||
|
// wrap them with ModularAccountClient.encodeExecute / sendExecute.
|
||||||
|
// - GUARDIANS (and the owner, for cancel) drive the recovery flow by calling
|
||||||
|
// the module DIRECTLY. send* helpers below do exactly that.
|
||||||
|
|
||||||
|
import {
|
||||||
|
SEL, encAddr, encUint, encodeRecoveryInit, decodeAddress, decodeUint, decodeBool,
|
||||||
|
decodeAddressArray, word,
|
||||||
|
} from './abi.js';
|
||||||
|
import { clean } from './hash.js';
|
||||||
|
import { toChecksumAddress } from './ModularAccountClient.js';
|
||||||
|
import type { Eip1193Provider, ActiveRecovery } from './types.js';
|
||||||
|
|
||||||
|
/** 48-hour recovery timelock (AereSocialRecoveryModule.RECOVERY_DELAY). */
|
||||||
|
export const RECOVERY_DELAY_SECONDS = 48 * 3600;
|
||||||
|
|
||||||
|
export class SocialRecoveryClient {
|
||||||
|
readonly provider: Eip1193Provider;
|
||||||
|
readonly module: string;
|
||||||
|
|
||||||
|
constructor(provider: Eip1193Provider, module: string) {
|
||||||
|
this.provider = provider;
|
||||||
|
this.module = module;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Owner-side config encoders (wrap via account.execute) ----------------
|
||||||
|
|
||||||
|
/** installModule(2, module, initData) payload: guardians + M-of-N threshold. */
|
||||||
|
encodeInstallInitData(guardians: string[], threshold: number | bigint): string {
|
||||||
|
return '0x' + encodeRecoveryInit(guardians, threshold);
|
||||||
|
}
|
||||||
|
/** module.addGuardian(guardian) — call as account.execute(module, 0, this). */
|
||||||
|
encodeAddGuardian(guardian: string): string {
|
||||||
|
return SEL.addGuardian + encAddr(guardian);
|
||||||
|
}
|
||||||
|
encodeRemoveGuardian(guardian: string): string {
|
||||||
|
return SEL.removeGuardian + encAddr(guardian);
|
||||||
|
}
|
||||||
|
encodeSetThreshold(threshold: number | bigint): string {
|
||||||
|
return SEL.setThreshold + encUint(BigInt(threshold));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Recovery-flow encoders (called directly by guardians / owner) --------
|
||||||
|
|
||||||
|
encodeInitiateRecovery(account: string, newOwner: string): string {
|
||||||
|
return SEL.initiateRecovery + encAddr(account) + encAddr(newOwner);
|
||||||
|
}
|
||||||
|
encodeSupportRecovery(account: string): string {
|
||||||
|
return SEL.supportRecovery + encAddr(account);
|
||||||
|
}
|
||||||
|
encodeExecuteRecovery(account: string): string {
|
||||||
|
return SEL.executeRecovery + encAddr(account);
|
||||||
|
}
|
||||||
|
encodeCancelRecovery(account: string): string {
|
||||||
|
return SEL.cancelRecovery + encAddr(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Direct sends (guardian / owner EOA; no writes during build phase) ----
|
||||||
|
|
||||||
|
/** Guardian starts a recovery round proposing `newOwner`. */
|
||||||
|
async sendInitiateRecovery(fromGuardian: string, account: string, newOwner: string): Promise<string> {
|
||||||
|
return this.ethSend(fromGuardian, this.encodeInitiateRecovery(account, newOwner));
|
||||||
|
}
|
||||||
|
/** Another guardian approves the active round. */
|
||||||
|
async sendSupportRecovery(fromGuardian: string, account: string): Promise<string> {
|
||||||
|
return this.ethSend(fromGuardian, this.encodeSupportRecovery(account));
|
||||||
|
}
|
||||||
|
/** Any guardian executes once >= threshold approvals AND the 48h timelock pass. */
|
||||||
|
async sendExecuteRecovery(fromGuardian: string, account: string): Promise<string> {
|
||||||
|
return this.ethSend(fromGuardian, this.encodeExecuteRecovery(account));
|
||||||
|
}
|
||||||
|
/** Current owner (or the account) cancels a pending recovery. */
|
||||||
|
async sendCancelRecovery(fromOwner: string, account: string): Promise<string> {
|
||||||
|
return this.ethSend(fromOwner, this.encodeCancelRecovery(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async guardiansOf(account: string): Promise<string[]> {
|
||||||
|
const data = SEL.guardiansOf + encAddr(account);
|
||||||
|
return decodeAddressArray(await this.ethCall(data)).map(toChecksumAddress);
|
||||||
|
}
|
||||||
|
async isGuardian(account: string, guardian: string): Promise<boolean> {
|
||||||
|
const data = SEL.isGuardian + encAddr(account) + encAddr(guardian);
|
||||||
|
return decodeBool(await this.ethCall(data));
|
||||||
|
}
|
||||||
|
async thresholdOf(account: string): Promise<bigint> {
|
||||||
|
return decodeUint(await this.ethCall(SEL.thresholdOf + encAddr(account)));
|
||||||
|
}
|
||||||
|
async recoveryNonce(account: string): Promise<bigint> {
|
||||||
|
return decodeUint(await this.ethCall(SEL.recoveryNonce + encAddr(account)));
|
||||||
|
}
|
||||||
|
async hasApproved(account: string, round: bigint, guardian: string): Promise<boolean> {
|
||||||
|
const data = SEL.hasApproved + encAddr(account) + encUint(round) + encAddr(guardian);
|
||||||
|
return decodeBool(await this.ethCall(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* activeRecovery(account) -> (address newOwner, uint64 executeAfter,
|
||||||
|
* uint32 approvals, bool active).
|
||||||
|
* When active, executeAfter is the earliest unix time executeRecovery succeeds.
|
||||||
|
*/
|
||||||
|
async getActiveRecovery(account: string): Promise<ActiveRecovery> {
|
||||||
|
const res = clean(await this.ethCall(SEL.activeRecovery + encAddr(account)));
|
||||||
|
return {
|
||||||
|
newOwner: toChecksumAddress(decodeAddress(res, 0)),
|
||||||
|
executeAfter: Number(BigInt('0x' + word(res, 1))),
|
||||||
|
approvals: Number(BigInt('0x' + word(res, 2))),
|
||||||
|
active: decodeBool(res, 3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ethCall(data: string): Promise<string> {
|
||||||
|
const r = await this.provider.request({ method: 'eth_call', params: [{ to: this.module, data }, 'latest'] });
|
||||||
|
if (typeof r !== 'string') throw new Error('SocialRecoveryClient: bad eth_call result');
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
private async ethSend(from: string, data: string): Promise<string> {
|
||||||
|
return this.provider.request({
|
||||||
|
method: 'eth_sendTransaction',
|
||||||
|
params: [{ from, to: this.module, data, value: '0x0' }],
|
||||||
|
}) as Promise<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
254
src/account/abi.ts
Normal file
254
src/account/abi.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
// Manual ABI encode/decode for the AereModularAccount stack.
|
||||||
|
// Same hand-rolled, dependency-free style as the other @aere/sdk clients
|
||||||
|
// (precomputed 4-byte selectors; 32-byte word packing). No ethers/viem.
|
||||||
|
|
||||||
|
import { clean, keccak256Hex } from './hash.js';
|
||||||
|
import type { PackedUserOp } from './types.js';
|
||||||
|
|
||||||
|
const UINT256_MAX = (1n << 256n) - 1n;
|
||||||
|
|
||||||
|
// ---- Precomputed 4-byte selectors -----------------------------------------
|
||||||
|
// (verified against ethers.id(sig).slice(0,10); self-checkable via
|
||||||
|
// keccak256Utf8(sig).slice(0,10) in tests.)
|
||||||
|
export const SEL = {
|
||||||
|
// AereModularAccount
|
||||||
|
installModule: '0x9517e29f', // installModule(uint256,address,bytes)
|
||||||
|
uninstallModule: '0xa71763a8', // uninstallModule(uint256,address,bytes)
|
||||||
|
isModuleInstalled: '0x112d3a7d', // isModuleInstalled(uint256,address,bytes)
|
||||||
|
setRootOwner: '0x73cd2fb0', // setRootOwner(address)
|
||||||
|
rootOwner: '0x00ee220c', // rootOwner()
|
||||||
|
entryPoint: '0xb0d691fe', // entryPoint()
|
||||||
|
execute: '0xb61d27f6', // execute(address,uint256,bytes)
|
||||||
|
executeBatch: '0x34fcd5be', // executeBatch((address,uint256,bytes)[])
|
||||||
|
// AereModularAccountFactory
|
||||||
|
getAddress: '0x8cb84e18', // getAddress(address,uint256)
|
||||||
|
createAccount: '0x5fbfb9cf', // createAccount(address,uint256)
|
||||||
|
initCodeHashView: '0xdc550e94', // ACCOUNT_INIT_CODE_HASH()
|
||||||
|
// AereSessionKeyValidator
|
||||||
|
enableSession: '0x6551af47', // enableSession(address,uint48,uint48,uint256,address[],bytes4[])
|
||||||
|
revokeSession: '0x1fa5d6a4', // revokeSession(address)
|
||||||
|
addPermission: '0xcfa8b4bf', // addPermission(address,address,bytes4)
|
||||||
|
removePermission: '0xc31108b8', // removePermission(address,address,bytes4)
|
||||||
|
sessions: '0x662a2bb9', // sessions(address,address)
|
||||||
|
allowedCall: '0xe07c1298', // allowedCall(address,address,address,bytes4)
|
||||||
|
// AereSocialRecoveryModule
|
||||||
|
addGuardian: '0xa526d83b', // addGuardian(address)
|
||||||
|
removeGuardian: '0x71404156', // removeGuardian(address)
|
||||||
|
setThreshold: '0x960bfe04', // setThreshold(uint256)
|
||||||
|
initiateRecovery: '0xa52d92cf', // initiateRecovery(address,address)
|
||||||
|
supportRecovery: '0x57e685bc', // supportRecovery(address)
|
||||||
|
executeRecovery: '0x3bc2c4a7', // executeRecovery(address)
|
||||||
|
cancelRecovery: '0xc90db447', // cancelRecovery(address)
|
||||||
|
guardiansOf: '0x82a44847', // guardiansOf(address)
|
||||||
|
isGuardian: '0xd4ee9734', // isGuardian(address,address)
|
||||||
|
thresholdOf: '0xdc6472cb', // thresholdOf(address)
|
||||||
|
recoveryNonce: '0x49bcad0a', // recoveryNonce(address)
|
||||||
|
activeRecovery: '0x27a61198', // activeRecovery(address)
|
||||||
|
hasApproved: '0xc32a60a5', // hasApproved(address,uint256,address)
|
||||||
|
// AereEntryPointV2
|
||||||
|
getNonce: '0x2d0335ab', // getNonce(address)
|
||||||
|
balanceOf: '0x70a08231', // balanceOf(address)
|
||||||
|
depositTo: '0xb760faf9', // depositTo(address)
|
||||||
|
handleOps: '0x765e827f', // handleOps(PackedUserOperation[],address)
|
||||||
|
getUserOpHash: '0x22cdde4c', // getUserOpHash(PackedUserOperation)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---- Value encoders (all return 64-hex, no 0x prefix) ---------------------
|
||||||
|
|
||||||
|
export function pad32(hex: string): string {
|
||||||
|
const h = clean(hex);
|
||||||
|
if (h.length > 64) throw new Error(`abi: word overflow (${h.length} hex chars)`);
|
||||||
|
return h.padStart(64, '0');
|
||||||
|
}
|
||||||
|
export function encUint(n: bigint | number): string {
|
||||||
|
const v = BigInt(n);
|
||||||
|
if (v < 0n || v > UINT256_MAX) throw new Error(`abi: uint out of range: ${v}`);
|
||||||
|
return pad32(v.toString(16));
|
||||||
|
}
|
||||||
|
export function encAddr(addr: string): string {
|
||||||
|
const h = clean(addr);
|
||||||
|
if (h.length !== 40) throw new Error(`abi: bad address ${addr}`);
|
||||||
|
return pad32(h);
|
||||||
|
}
|
||||||
|
export function encBytes32(b: string): string {
|
||||||
|
return pad32(b);
|
||||||
|
}
|
||||||
|
/** bytes4 right-padded into a 32-byte word (ABI encoding of bytesN is left-aligned). */
|
||||||
|
export function encBytes4(sel: string): string {
|
||||||
|
const h = clean(sel);
|
||||||
|
if (h.length !== 8) throw new Error(`abi: bad bytes4 ${sel}`);
|
||||||
|
return (h + '0'.repeat(56));
|
||||||
|
}
|
||||||
|
/** Dynamic `bytes`: length word + right-padded data. */
|
||||||
|
export function encBytesDyn(hex: string): string {
|
||||||
|
const h = clean(hex);
|
||||||
|
if (h.length % 2 !== 0) throw new Error('abi: odd-length bytes');
|
||||||
|
const byteLen = h.length / 2;
|
||||||
|
const padHex = ((32 - (byteLen % 32)) % 32) * 2;
|
||||||
|
return encUint(BigInt(byteLen)) + h + '0'.repeat(padHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Calldata builders -----------------------------------------------------
|
||||||
|
|
||||||
|
/** execute(address target, uint256 value, bytes data) */
|
||||||
|
export function encodeExecute(target: string, value: bigint, data: string): string {
|
||||||
|
const head = encAddr(target) + encUint(value) + encUint(0x60n);
|
||||||
|
return SEL.execute + head + encBytesDyn(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallStruct { target: string; value: bigint; data: string; }
|
||||||
|
|
||||||
|
/** executeBatch((address,uint256,bytes)[] calls) */
|
||||||
|
export function encodeExecuteBatch(calls: CallStruct[]): string {
|
||||||
|
// outer arg: one dynamic array -> offset 0x20
|
||||||
|
const n = calls.length;
|
||||||
|
const elems = calls.map((c) => {
|
||||||
|
// tuple(address,uint256,bytes): head words [target, value, offset(0x60)] + dyn bytes
|
||||||
|
return encAddr(c.target) + encUint(c.value) + encUint(0x60n) + encBytesDyn(c.data);
|
||||||
|
});
|
||||||
|
// array region: [len][off_0..off_{n-1}][elem_0..]
|
||||||
|
// offsets are relative to the start of the offsets region (just after len word)
|
||||||
|
let cursor = BigInt(n * 32);
|
||||||
|
const offs: string[] = [];
|
||||||
|
for (const e of elems) { offs.push(encUint(cursor)); cursor += BigInt(e.length / 2); }
|
||||||
|
const arrayRegion = encUint(BigInt(n)) + offs.join('') + elems.join('');
|
||||||
|
return SEL.executeBatch + encUint(0x20n) + arrayRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PackedUserOperation tuple encoding -----------------------------------
|
||||||
|
// Tuple order (ERC-4337 v0.7 packed shape used by AereEntryPointV2):
|
||||||
|
// address sender, uint256 nonce, bytes initCode, bytes callData,
|
||||||
|
// bytes32 accountGasLimits, uint256 preVerificationGas, bytes32 gasFees,
|
||||||
|
// bytes paymasterAndData, bytes signature
|
||||||
|
// 4 dynamic members => 9 head words then the dynamic tails in member order.
|
||||||
|
|
||||||
|
export function encodePackedUserOpTuple(op: PackedUserOp): string {
|
||||||
|
const dyn = [op.initCode, op.callData, op.paymasterAndData, op.signature].map(encBytesDyn);
|
||||||
|
const HEAD = 9;
|
||||||
|
let cursor = BigInt(HEAD * 32);
|
||||||
|
const offInit = cursor; cursor += BigInt(dyn[0].length / 2);
|
||||||
|
const offCall = cursor; cursor += BigInt(dyn[1].length / 2);
|
||||||
|
const offPm = cursor; cursor += BigInt(dyn[2].length / 2);
|
||||||
|
const offSig = cursor;
|
||||||
|
const head =
|
||||||
|
encAddr(op.sender) +
|
||||||
|
encUint(op.nonce) +
|
||||||
|
encUint(offInit) +
|
||||||
|
encUint(offCall) +
|
||||||
|
encBytes32(op.accountGasLimits) +
|
||||||
|
encUint(op.preVerificationGas) +
|
||||||
|
encBytes32(op.gasFees) +
|
||||||
|
encUint(offPm) +
|
||||||
|
encUint(offSig);
|
||||||
|
return head + dyn.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** getUserOpHash(PackedUserOperation op) calldata (single tuple arg -> offset 0x20). */
|
||||||
|
export function encodeGetUserOpHash(op: PackedUserOp): string {
|
||||||
|
return SEL.getUserOpHash + encUint(0x20n) + encodePackedUserOpTuple(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** handleOps(PackedUserOperation[] ops, address beneficiary) calldata. */
|
||||||
|
export function encodeHandleOps(ops: PackedUserOp[], beneficiary: string): string {
|
||||||
|
const elems = ops.map(encodePackedUserOpTuple);
|
||||||
|
const n = ops.length;
|
||||||
|
let cursor = BigInt(n * 32);
|
||||||
|
const offs: string[] = [];
|
||||||
|
for (const e of elems) { offs.push(encUint(cursor)); cursor += BigInt(e.length / 2); }
|
||||||
|
const arrayRegion = encUint(BigInt(n)) + offs.join('') + elems.join('');
|
||||||
|
// outer head: [offset to ops array (=0x40), beneficiary]
|
||||||
|
return SEL.handleOps + encUint(0x40n) + encAddr(beneficiary) + arrayRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ERC-4337 v0.7 userOpHash (mirrors AereEntryPointV2.getUserOpHash) -----
|
||||||
|
|
||||||
|
export function computeUserOpHash(op: PackedUserOp, entryPoint: string, chainId: bigint | number): string {
|
||||||
|
const inner = keccak256Hex(
|
||||||
|
encAddr(op.sender) +
|
||||||
|
encUint(op.nonce) +
|
||||||
|
clean(keccak256Hex(op.initCode)) +
|
||||||
|
clean(keccak256Hex(op.callData)) +
|
||||||
|
encBytes32(op.accountGasLimits) +
|
||||||
|
encUint(op.preVerificationGas) +
|
||||||
|
encBytes32(op.gasFees) +
|
||||||
|
clean(keccak256Hex(op.paymasterAndData)),
|
||||||
|
);
|
||||||
|
return keccak256Hex(clean(inner) + encAddr(entryPoint) + encUint(BigInt(chainId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- initData encoders (module install / session scope) --------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AereSessionKeyValidator install/enable payload:
|
||||||
|
* abi.encode(address key, uint48 validAfter, uint48 validUntil,
|
||||||
|
* uint256 maxValuePerOp, address[] targets, bytes4[] selectors)
|
||||||
|
*/
|
||||||
|
export function encodeSessionScope(
|
||||||
|
key: string, validAfter: number, validUntil: number, maxValuePerOp: bigint,
|
||||||
|
targets: string[], selectors: string[],
|
||||||
|
): string {
|
||||||
|
if (targets.length !== selectors.length) throw new Error('abi: targets/selectors length mismatch');
|
||||||
|
// 6 head words; two dynamic arrays (targets, selectors) live in the tail.
|
||||||
|
const HEAD = 6;
|
||||||
|
const targetsRegion = encUint(BigInt(targets.length)) + targets.map(encAddr).join('');
|
||||||
|
const selectorsRegion = encUint(BigInt(selectors.length)) + selectors.map(encBytes4).join('');
|
||||||
|
let cursor = BigInt(HEAD * 32);
|
||||||
|
const offTargets = cursor; cursor += BigInt(targetsRegion.length / 2);
|
||||||
|
const offSelectors = cursor;
|
||||||
|
return (
|
||||||
|
encAddr(key) +
|
||||||
|
encUint(BigInt(validAfter)) +
|
||||||
|
encUint(BigInt(validUntil)) +
|
||||||
|
encUint(maxValuePerOp) +
|
||||||
|
encUint(offTargets) +
|
||||||
|
encUint(offSelectors) +
|
||||||
|
targetsRegion +
|
||||||
|
selectorsRegion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AereSocialRecoveryModule install payload: abi.encode(address[] guardians, uint256 threshold). */
|
||||||
|
export function encodeRecoveryInit(guardians: string[], threshold: bigint | number): string {
|
||||||
|
// head: [offset to guardians (0x40), threshold]; then guardians array.
|
||||||
|
const guardiansRegion = encUint(BigInt(guardians.length)) + guardians.map(encAddr).join('');
|
||||||
|
return encUint(0x40n) + encUint(BigInt(threshold)) + guardiansRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** module-management calldata: installModule / uninstallModule with a bytes payload. */
|
||||||
|
export function encodeModuleCall(selector: string, moduleTypeId: bigint | number, module: string, initData: string): string {
|
||||||
|
const head = encUint(BigInt(moduleTypeId)) + encAddr(module) + encUint(0x60n);
|
||||||
|
return selector + head + encBytesDyn(initData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** isModuleInstalled(uint256,address,bytes) with empty trailing bytes. */
|
||||||
|
export function encodeIsModuleInstalled(moduleTypeId: bigint | number, module: string): string {
|
||||||
|
const head = encUint(BigInt(moduleTypeId)) + encAddr(module) + encUint(0x60n);
|
||||||
|
return SEL.isModuleInstalled + head + encBytesDyn('0x');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Decoders --------------------------------------------------------------
|
||||||
|
|
||||||
|
export function word(data: string, i: number): string {
|
||||||
|
const h = clean(data);
|
||||||
|
const w = h.slice(i * 64, i * 64 + 64);
|
||||||
|
if (w.length !== 64) throw new Error('abi: short returndata');
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
export function decodeUint(data: string, i = 0): bigint { return BigInt('0x' + word(data, i)); }
|
||||||
|
export function decodeBool(data: string, i = 0): boolean { return decodeUint(data, i) !== 0n; }
|
||||||
|
export function decodeAddress(data: string, i = 0): string { return '0x' + word(data, i).slice(24); }
|
||||||
|
|
||||||
|
/** Decode an ABI-encoded `address[]` return (single dynamic array). */
|
||||||
|
export function decodeAddressArray(data: string): string[] {
|
||||||
|
const h = clean(data);
|
||||||
|
// word0 = offset (usually 0x20); read length at that offset.
|
||||||
|
const off = Number(BigInt('0x' + h.slice(0, 64)));
|
||||||
|
const lenPos = off * 2;
|
||||||
|
const len = Number(BigInt('0x' + h.slice(lenPos, lenPos + 64)));
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const wpos = lenPos + 64 + i * 64;
|
||||||
|
out.push('0x' + h.slice(wpos + 24, wpos + 64));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
111
src/account/hash.ts
Normal file
111
src/account/hash.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
// Dependency-free keccak-256 for the AereModularAccount SDK.
|
||||||
|
//
|
||||||
|
// Needed client-side for:
|
||||||
|
// - CREATE2 counterfactual address = keccak256(0xff || factory || saltMix || initCodeHash)
|
||||||
|
// with saltMix = keccak256(abi.encode(rootOwner, salt))
|
||||||
|
// - ERC-4337 v0.7 userOpHash = keccak256(abi.encode(inner, entryPoint, chainid))
|
||||||
|
//
|
||||||
|
// This is the SAME vanilla Keccak-f[1600] used elsewhere in this SDK
|
||||||
|
// (src/corebook/CoreBookClient.ts), verified against the canonical vector
|
||||||
|
// keccak256("") = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
|
||||||
|
// and against ethers.keccak256. No external dependency.
|
||||||
|
|
||||||
|
const KECCAK_RC: bigint[] = [
|
||||||
|
0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
|
||||||
|
0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
|
||||||
|
0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
|
||||||
|
0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
|
||||||
|
0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
|
||||||
|
0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
|
||||||
|
];
|
||||||
|
// rotation offsets r[x][y], flattened as [x + 5y]
|
||||||
|
const KECCAK_ROT: number[] = [
|
||||||
|
0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14,
|
||||||
|
];
|
||||||
|
const M64 = (1n << 64n) - 1n;
|
||||||
|
|
||||||
|
function rotl64(v: bigint, n: number): bigint {
|
||||||
|
if (n === 0) return v;
|
||||||
|
return ((v << BigInt(n)) | (v >> BigInt(64 - n))) & M64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keccakF1600(s: bigint[]): void {
|
||||||
|
for (let r = 0; r < 24; r++) {
|
||||||
|
// theta
|
||||||
|
const c: bigint[] = new Array(5);
|
||||||
|
for (let x = 0; x < 5; x++) c[x] = s[x] ^ s[x + 5] ^ s[x + 10] ^ s[x + 15] ^ s[x + 20];
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
const d = c[(x + 4) % 5] ^ rotl64(c[(x + 1) % 5], 1);
|
||||||
|
for (let y = 0; y < 25; y += 5) s[x + y] ^= d;
|
||||||
|
}
|
||||||
|
// rho + pi
|
||||||
|
const b: bigint[] = new Array(25);
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(s[x + 5 * y], KECCAK_ROT[x + 5 * y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// chi
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 25; y += 5) {
|
||||||
|
s[x + y] = b[x + y] ^ ((~b[((x + 1) % 5) + y] & M64) & b[((x + 2) % 5) + y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// iota
|
||||||
|
s[0] ^= KECCAK_RC[r];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keccak256Bytes(input: Uint8Array): Uint8Array {
|
||||||
|
const rate = 136; // 1088-bit rate for keccak-256
|
||||||
|
const s: bigint[] = new Array(25).fill(0n);
|
||||||
|
const padLen = rate - (input.length % rate);
|
||||||
|
const padded = new Uint8Array(input.length + padLen);
|
||||||
|
padded.set(input);
|
||||||
|
padded[input.length] = 0x01; // keccak (NOT sha3 0x06) domain padding
|
||||||
|
padded[padded.length - 1] |= 0x80;
|
||||||
|
for (let off = 0; off < padded.length; off += rate) {
|
||||||
|
for (let i = 0; i < rate / 8; i++) {
|
||||||
|
let lane = 0n;
|
||||||
|
for (let b2 = 7; b2 >= 0; b2--) lane = (lane << 8n) | BigInt(padded[off + i * 8 + b2]);
|
||||||
|
s[i] ^= lane;
|
||||||
|
}
|
||||||
|
keccakF1600(s);
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(32);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
let lane = s[i];
|
||||||
|
for (let b2 = 0; b2 < 8; b2++) { out[i * 8 + b2] = Number(lane & 0xffn); lane >>= 8n; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip a leading 0x (if any) and lowercase. */
|
||||||
|
export function clean(hex: string): string {
|
||||||
|
return hex.toLowerCase().replace(/^0x/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const h = clean(hex);
|
||||||
|
if (h.length % 2 !== 0) throw new Error('hexToBytes: odd-length hex');
|
||||||
|
const out = new Uint8Array(h.length / 2);
|
||||||
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(b: Uint8Array): string {
|
||||||
|
let s = '0x';
|
||||||
|
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** keccak256 over the bytes of a hex string. Returns a 0x-prefixed 32-byte hash. */
|
||||||
|
export function keccak256Hex(hex: string): string {
|
||||||
|
return bytesToHex(keccak256Bytes(hexToBytes(hex)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** keccak256 over a UTF-8 string (used for the 4-byte selector self-check). */
|
||||||
|
export function keccak256Utf8(text: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(text);
|
||||||
|
return bytesToHex(keccak256Bytes(bytes));
|
||||||
|
}
|
||||||
68
src/account/index.ts
Normal file
68
src/account/index.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// AereModularAccount SDK — ERC-7579-style modular smart accounts on AERE
|
||||||
|
// chain 2800: session keys + M-of-N social recovery, bound to AereEntryPointV2.
|
||||||
|
//
|
||||||
|
// Factory AereModularAccountFactory 0xE3f45Ed4a81f982fF25ad172A72456a1833a440E
|
||||||
|
// Account AereModularAccount (CREATE2 per (rootOwner, salt))
|
||||||
|
// Validator AereSessionKeyValidatorV2 0xC06EAe63Ed12307F56A1506917C48C027D8852ff (module type 1; audited, supersedes flawed V1 0x6e03A3D7…0D26)
|
||||||
|
// Recovery AereSocialRecoveryModule 0x077514DB2a85F239145537e8334CC99d42c9D812 (module type 2)
|
||||||
|
// EntryPoint AereEntryPointV2 0x8D6f40598d552fF0Cb358b6012cF4227B86aF770
|
||||||
|
//
|
||||||
|
// Signature routing enforced by AereModularAccount:
|
||||||
|
// 0x00 || 65-byte ECDSA(personal_sign(userOpHash)) -> root owner
|
||||||
|
// 0x01 || validator(20) || 65-byte ECDSA -> validator module
|
||||||
|
|
||||||
|
export {
|
||||||
|
ModularAccountClient,
|
||||||
|
AERE_MODULAR_ADDRESSES,
|
||||||
|
AERE_ACCOUNT_INIT_CODE_HASH,
|
||||||
|
toChecksumAddress,
|
||||||
|
routeRootSignature,
|
||||||
|
routeValidatorSignature,
|
||||||
|
eip1193PersonalSign,
|
||||||
|
type ModularAccountAddresses,
|
||||||
|
type ModularAccountClientOptions,
|
||||||
|
} from './ModularAccountClient.js';
|
||||||
|
|
||||||
|
export { SessionKeyClient, type ScopeCheck } from './SessionKeyClient.js';
|
||||||
|
export { SocialRecoveryClient, RECOVERY_DELAY_SECONDS } from './SocialRecoveryClient.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AerePQCSocialRecoveryClient,
|
||||||
|
AERE_PQC_SOCIAL_RECOVERY_ABI,
|
||||||
|
RECOVERY_DOMAIN as PQC_RECOVERY_DOMAIN,
|
||||||
|
RECOVERY_DELAY_SECONDS as PQC_RECOVERY_DELAY_SECONDS,
|
||||||
|
MODULE_TYPE_EXECUTOR as PQC_MODULE_TYPE_EXECUTOR,
|
||||||
|
recoveryChallenge as pqcRecoveryChallenge,
|
||||||
|
signRecoveryLeg as signPqcRecoveryLeg,
|
||||||
|
encodeLegs as encodePqcRecoveryLegs,
|
||||||
|
encodeGuardianInstallData as encodePqcGuardianInstallData,
|
||||||
|
type Leg as PQCRecoveryLeg,
|
||||||
|
type GuardianKey as PQCGuardianKey,
|
||||||
|
type PendingRecovery as PQCPendingRecovery,
|
||||||
|
} from './AerePQCSocialRecoveryClient.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SEL as ACCOUNT_SELECTORS,
|
||||||
|
encodeExecute,
|
||||||
|
encodeExecuteBatch,
|
||||||
|
encodeSessionScope,
|
||||||
|
encodeRecoveryInit,
|
||||||
|
computeUserOpHash,
|
||||||
|
type CallStruct,
|
||||||
|
} from './abi.js';
|
||||||
|
|
||||||
|
export { keccak256Hex, keccak256Utf8 } from './hash.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
MODULE_TYPE_VALIDATOR,
|
||||||
|
MODULE_TYPE_EXECUTOR,
|
||||||
|
SELECTOR_NATIVE_TRANSFER,
|
||||||
|
type Eip1193Provider,
|
||||||
|
type PackedUserOp,
|
||||||
|
type GasParams,
|
||||||
|
type SessionScope,
|
||||||
|
type SessionPermission,
|
||||||
|
type SessionInfo,
|
||||||
|
type ActiveRecovery,
|
||||||
|
type UserOpSigner,
|
||||||
|
} from './types.js';
|
||||||
79
src/account/types.ts
Normal file
79
src/account/types.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// Shared types for the AereModularAccount (ERC-7579-style) SDK.
|
||||||
|
|
||||||
|
/** Minimal EIP-1193 provider surface (same shape used across @aere/sdk). */
|
||||||
|
export interface Eip1193Provider {
|
||||||
|
request(args: { method: string; params?: unknown[] }): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERC-4337 v0.7 packed user operation, exactly as AereEntryPointV2 and
|
||||||
|
* AereModularAccount expect it.
|
||||||
|
* accountGasLimits = bytes32( verificationGasLimit<<128 | callGasLimit )
|
||||||
|
* gasFees = bytes32( maxPriorityFeePerGas<<128 | maxFeePerGas )
|
||||||
|
*/
|
||||||
|
export interface PackedUserOp {
|
||||||
|
sender: string;
|
||||||
|
nonce: bigint;
|
||||||
|
initCode: string; // hex; '0x' when the account already exists
|
||||||
|
callData: string; // hex; execute()/executeBatch() on the account
|
||||||
|
accountGasLimits: string; // bytes32 hex
|
||||||
|
preVerificationGas: bigint;
|
||||||
|
gasFees: string; // bytes32 hex
|
||||||
|
paymasterAndData: string; // hex; '0x' for self-paid
|
||||||
|
signature: string; // hex; routed envelope (0x00 root / 0x01 validator)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gas parameters for buildUserOp; sensible defaults are filled in. */
|
||||||
|
export interface GasParams {
|
||||||
|
verificationGasLimit?: bigint; // default 500_000
|
||||||
|
callGasLimit?: bigint; // default 500_000
|
||||||
|
preVerificationGas?: bigint; // default 21_000
|
||||||
|
maxFeePerGas?: bigint; // default: eth_gasPrice (fallback 1 gwei)
|
||||||
|
maxPriorityFeePerGas?: bigint; // default: equal to maxFeePerGas
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One (target, selector) grant inside a session-key scope. */
|
||||||
|
export interface SessionPermission {
|
||||||
|
target: string;
|
||||||
|
/** bytes4 selector; use SELECTOR_NATIVE_TRANSFER for empty-calldata transfers. */
|
||||||
|
selector: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full scope of a session key (mirrors AereSessionKeyValidator.Session + allowlist). */
|
||||||
|
export interface SessionScope {
|
||||||
|
key: string; // session key EOA address
|
||||||
|
validAfter: number; // unix seconds; 0 = no lower bound
|
||||||
|
validUntil: number; // unix seconds; 0 = never expires
|
||||||
|
maxValuePerOp: bigint; // wei cap per call
|
||||||
|
permissions: SessionPermission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain session record read back from the validator. */
|
||||||
|
export interface SessionInfo {
|
||||||
|
enabled: boolean;
|
||||||
|
validAfter: number;
|
||||||
|
validUntil: number;
|
||||||
|
maxValuePerOp: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pending recovery record read back from the social-recovery module. */
|
||||||
|
export interface ActiveRecovery {
|
||||||
|
newOwner: string;
|
||||||
|
executeAfter: number; // unix seconds
|
||||||
|
approvals: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signer over a userOpHash. MUST return a 65-byte (r,s,v) ECDSA signature,
|
||||||
|
* 0x-prefixed, over the EIP-191 personal-sign envelope of `userOpHash`
|
||||||
|
* (i.e. sign(keccak256("\x19Ethereum Signed Message:\n32" || userOpHash))).
|
||||||
|
* This matches AereModularAccount's ECDSA.toEthSignedMessageHash(userOpHash)
|
||||||
|
* recovery for both the root-owner (0x00) and session-key (0x01) routes.
|
||||||
|
*/
|
||||||
|
export type UserOpSigner = (userOpHash: string) => Promise<string>;
|
||||||
|
|
||||||
|
export const MODULE_TYPE_VALIDATOR = 1;
|
||||||
|
export const MODULE_TYPE_EXECUTOR = 2;
|
||||||
|
/** Sentinel selector for native (empty-calldata) transfers in a session scope. */
|
||||||
|
export const SELECTOR_NATIVE_TRANSFER = '0x00000000';
|
||||||
562
src/addresses.ts
562
src/addresses.ts
File diff suppressed because one or more lines are too long
257
src/agentic/AERE402FacilitatorPQCClient.ts
Normal file
257
src/agentic/AERE402FacilitatorPQCClient.ts
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
// AERE402FacilitatorPQCClient — ethers v6 wrapper over AERE402FacilitatorPQC.
|
||||||
|
//
|
||||||
|
// AERE402FacilitatorPQC settles machine (agent-to-service) payments whose spending
|
||||||
|
// authority is rooted in a quantum-durable Falcon key, not a bare secp256k1 signer. The
|
||||||
|
// payer is an AereAgentDID: an agent whose ROOT is a Falcon key in AerePQCKeyRegistry and
|
||||||
|
// whose day-to-day work is signed by cheap, revocable secp256k1 SESSION keys. A payment is
|
||||||
|
// authorized by the DID SESSION signature; the facilitator delegates ALL authorization to
|
||||||
|
// AereAgentDID.authorize (root lifecycle + scope + cumulative spend cap + secp256k1 session
|
||||||
|
// signature + anti-replay), then debits a prepaid vault keyed by the Falcon root and splits
|
||||||
|
// the amount into (amount - 25 bps) to the payee and a 25-bps fee to AereSink.
|
||||||
|
//
|
||||||
|
// Rotating or revoking the Falcon root instantly halts every downstream payment.
|
||||||
|
//
|
||||||
|
// PAYMENT AUTHORIZATION (what the session key signs). The facilitator binds the payment
|
||||||
|
// terms to itself with an actionHash, then the session key signs the DID action digest that
|
||||||
|
// commits to (sessionId, scope, actionHash, amount, actionNonce):
|
||||||
|
// actionHash = keccak256(abi.encode(
|
||||||
|
// PAYMENT_DOMAIN, chainId, facilitator, token, payee, resourceId, deadline))
|
||||||
|
// digest = AereAgentDID.actionDigest(sessionId, scope, actionHash, amount, actionNonce)
|
||||||
|
// PAYMENT_DOMAIN = keccak256("AERE402FacilitatorPQC.v1.payment")
|
||||||
|
// The DID action digest (ACTION_DOMAIN, chain- and DID-bound) is reused verbatim via
|
||||||
|
// AereAgentDIDClient — never reimplemented. The session-key signature is a raw secp256k1
|
||||||
|
// signature over the digest (ethers SigningKey), matching the contract's ecrecover — NOT an
|
||||||
|
// eth_sign personal-message prefix.
|
||||||
|
//
|
||||||
|
// NOTE: AERE402FacilitatorPQC is a repo-only build (no mainnet deployment yet), so this
|
||||||
|
// client has no default address — always pass `facilitator`.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify, SigningKey, Signature,
|
||||||
|
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../addresses.js';
|
||||||
|
import { AereAgentDIDClient } from '../pqc/AereAgentDIDClient.js';
|
||||||
|
|
||||||
|
/** Minimal inline ABI — only the AERE402FacilitatorPQC surface this client uses. */
|
||||||
|
export const AERE402_FACILITATOR_PQC_ABI = [
|
||||||
|
// settle + funding
|
||||||
|
'function settle(uint256 sessionId, bytes32 scope, address token, address payee, uint256 amount, bytes32 resourceId, uint256 deadline, bytes sessionSig)',
|
||||||
|
'function topUp(uint256 rootKeyId, address token, uint256 amount)',
|
||||||
|
'function withdraw(uint256 rootKeyId, address token, uint256 amount, address recipient)',
|
||||||
|
'function setPaused(uint256 rootKeyId, bool value)',
|
||||||
|
'function flushResidual(address token, uint256 amount)',
|
||||||
|
// views
|
||||||
|
'function balances(uint256 rootKeyId, address token) view returns (uint256)',
|
||||||
|
'function paused(uint256 rootKeyId) view returns (bool)',
|
||||||
|
'function paymentActionHash(address token, address payee, bytes32 resourceId, uint256 deadline) view returns (bytes32)',
|
||||||
|
'function controllerOf(uint256 rootKeyId) view returns (address)',
|
||||||
|
'function isController(uint256 rootKeyId, address account) view returns (bool)',
|
||||||
|
'function isSessionValid(uint256 sessionId) view returns (bool)',
|
||||||
|
'function DID() view returns (address)',
|
||||||
|
'function KEY_REGISTRY() view returns (address)',
|
||||||
|
'function SINK() view returns (address)',
|
||||||
|
'function PROTOCOL_FEE_BPS() view returns (uint16)',
|
||||||
|
'function PAYMENT_DOMAIN() view returns (bytes32)',
|
||||||
|
// events
|
||||||
|
'event SettledPQC(uint256 indexed rootKeyId, uint256 indexed sessionId, address indexed payee, address token, uint256 amount, uint256 protocolFee, bytes32 resourceId)',
|
||||||
|
'event TopUp(uint256 indexed rootKeyId, address indexed token, address indexed funder, uint256 amount)',
|
||||||
|
'event Withdrawn(uint256 indexed rootKeyId, address indexed token, address indexed recipient, uint256 amount)',
|
||||||
|
'event PausedSet(uint256 indexed rootKeyId, bool paused, address by)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** keccak256("AERE402FacilitatorPQC.v1.payment"). */
|
||||||
|
export const PAYMENT_DOMAIN = keccak256(toUtf8Bytes('AERE402FacilitatorPQC.v1.payment'));
|
||||||
|
|
||||||
|
export interface AERE402FacilitatorPQCClientOptions {
|
||||||
|
/** AERE402FacilitatorPQC address (required — repo-only build, no mainnet default). */
|
||||||
|
facilitator: string;
|
||||||
|
/** AereAgentDID address the facilitator roots into. Default: AERE_MAINNET.AereAgentDID. */
|
||||||
|
didAddress?: string;
|
||||||
|
/** Chain id used for local actionHash / action-digest derivation. Default: 2800. */
|
||||||
|
chainId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The exact terms of a machine payment (what the session key authorizes). */
|
||||||
|
export interface PQCPaymentParams {
|
||||||
|
sessionId: bigint | number;
|
||||||
|
/** The scope tag for this payment (must equal the session's locked scopeHash). */
|
||||||
|
scope: BytesLike;
|
||||||
|
token: string;
|
||||||
|
payee: string;
|
||||||
|
amount: bigint | number;
|
||||||
|
/** Opaque resource tag, e.g. keccak256("GET /v1/inference"). bytes32. */
|
||||||
|
resourceId: BytesLike;
|
||||||
|
/** Unix seconds after which the payment auth is stale. */
|
||||||
|
deadline: bigint | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PQCPaymentAuthorization {
|
||||||
|
/** actionHash committed to THIS facilitator for these terms. */
|
||||||
|
actionHash: string;
|
||||||
|
/** The DID action digest the session key signed. */
|
||||||
|
digest: string;
|
||||||
|
/** The session action nonce the digest was derived at. */
|
||||||
|
actionNonce: bigint;
|
||||||
|
/** 65-byte secp256k1 signature (r||s||v) by the session key (pass as `sessionSig`). */
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettleResult extends PQCPaymentAuthorization {
|
||||||
|
tx: ContractTransactionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(v: BytesLike): Uint8Array {
|
||||||
|
return v instanceof Uint8Array ? v : getBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes32(v: BytesLike): string {
|
||||||
|
const b = toBytes(v);
|
||||||
|
if (b.length !== 32) throw new Error(`AERE402FacilitatorPQCClient: expected 32-byte value, got ${b.length}`);
|
||||||
|
return hexlify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AERE402FacilitatorPQCClient {
|
||||||
|
readonly address: string;
|
||||||
|
readonly chainId: number;
|
||||||
|
readonly contract: Contract;
|
||||||
|
/** DID client used only for action-digest derivation and on-chain session reads. */
|
||||||
|
readonly did: AereAgentDIDClient;
|
||||||
|
|
||||||
|
constructor(runner: ContractRunner, opts: AERE402FacilitatorPQCClientOptions) {
|
||||||
|
if (!opts?.facilitator) throw new Error('AERE402FacilitatorPQCClient: `facilitator` address is required');
|
||||||
|
this.address = opts.facilitator;
|
||||||
|
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||||
|
this.contract = new Contract(this.address, AERE402_FACILITATOR_PQC_ABI, runner);
|
||||||
|
this.did = new AereAgentDIDClient(runner, { address: opts.didAddress, chainId: this.chainId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- derivation -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local mirror of AERE402FacilitatorPQC._paymentActionHash:
|
||||||
|
* keccak256(abi.encode(PAYMENT_DOMAIN, chainId, facilitator, token, payee, resourceId, deadline))
|
||||||
|
* Cross-check with {@link paymentActionHash} on-chain.
|
||||||
|
*/
|
||||||
|
deriveActionHash(token: string, payee: string, resourceId: BytesLike, deadline: bigint | number): string {
|
||||||
|
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'address', 'address', 'bytes32', 'uint256'],
|
||||||
|
[PAYMENT_DOMAIN, this.chainId, this.address, token, payee, bytes32(resourceId), deadline],
|
||||||
|
);
|
||||||
|
return keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the session-key authorization for a payment at an explicit actionNonce, WITHOUT
|
||||||
|
* sending a transaction. Derives the actionHash and the DID action digest locally and
|
||||||
|
* signs the digest with the raw secp256k1 session private key (canonical low-s, v in
|
||||||
|
* {27,28}). The DID action digest is derived by AereAgentDIDClient (reused, not reimplemented).
|
||||||
|
*/
|
||||||
|
buildAuthorization(p: PQCPaymentParams, actionNonce: bigint | number, sessionPrivateKey: BytesLike): PQCPaymentAuthorization {
|
||||||
|
const actionHash = this.deriveActionHash(p.token, p.payee, p.resourceId, p.deadline);
|
||||||
|
const digest = this.did.deriveActionDigest(p.sessionId, p.scope, actionHash, p.amount, actionNonce);
|
||||||
|
const sk = new SigningKey(hexlify(toBytes(sessionPrivateKey)));
|
||||||
|
const sig: Signature = sk.sign(digest);
|
||||||
|
return { actionHash, digest, actionNonce: BigInt(actionNonce), signature: sig.serialized };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- views ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** On-chain actionHash for these payment terms (cross-check for {@link deriveActionHash}). */
|
||||||
|
async paymentActionHash(token: string, payee: string, resourceId: BytesLike, deadline: bigint | number): Promise<string> {
|
||||||
|
return this.contract.paymentActionHash(token, payee, bytes32(resourceId), deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepaid vault balance of an agent (Falcon root) in `token`. */
|
||||||
|
async balanceOf(rootKeyId: bigint | number, token: string): Promise<bigint> {
|
||||||
|
return this.contract.balances(rootKeyId, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the agent's settlement path is paused. */
|
||||||
|
async isPaused(rootKeyId: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.paused(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agent's current controller (registry owner of the Falcon root), or zero. */
|
||||||
|
async controllerOf(rootKeyId: bigint | number): Promise<string> {
|
||||||
|
return this.contract.controllerOf(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff `account` is the agent's current controller. */
|
||||||
|
async isController(rootKeyId: bigint | number, account: string): Promise<boolean> {
|
||||||
|
return this.contract.isController(rootKeyId, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the session is live (root ACTIVE Falcon, not revoked, unexpired). */
|
||||||
|
async isSessionValid(sessionId: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.isSessionValid(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writes (require a Signer runner) -------------------------------------
|
||||||
|
|
||||||
|
/** Fund an agent's prepaid vault (permissionless). Requires prior token approval. */
|
||||||
|
async topUp(rootKeyId: bigint | number, token: string, amount: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.topUp(rootKeyId, token, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Withdraw idle vault balance (agent controller only). */
|
||||||
|
async withdraw(rootKeyId: bigint | number, token: string, amount: bigint | number, recipient: string): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.withdraw(rootKeyId, token, amount, recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause or unpause an agent's settlement path (agent controller only). */
|
||||||
|
async setPaused(rootKeyId: bigint | number, value: boolean): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.setPaused(rootKeyId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-attempt the sink flush for a residual fee balance (permissionless). */
|
||||||
|
async flushResidual(token: string, amount: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.flushResidual(token, amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Submit settle with a pre-built session-key signature. Caller (runner) MUST be the payee. */
|
||||||
|
async settle(p: PQCPaymentParams, sessionSig: BytesLike): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.settle(
|
||||||
|
p.sessionId, bytes32(p.scope), p.token, p.payee, p.amount, bytes32(p.resourceId), p.deadline, hexlify(toBytes(sessionSig)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- high-level -----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level settle: read the session's current actionNonce on-chain, derive the
|
||||||
|
* actionHash + DID action digest locally (optionally cross-checked on-chain), sign the
|
||||||
|
* digest with the secp256k1 session key, and submit settle via `providerSigner` (the
|
||||||
|
* payee, who also pays gas). The session private key never leaves this process.
|
||||||
|
*
|
||||||
|
* The `providerSigner` address MUST equal `p.payee` — the facilitator enforces
|
||||||
|
* msg.sender == payee so a leaked signature cannot be redirected.
|
||||||
|
*/
|
||||||
|
async settleWithSessionKey(
|
||||||
|
providerSigner: Signer,
|
||||||
|
p: PQCPaymentParams,
|
||||||
|
sessionPrivateKey: BytesLike,
|
||||||
|
verifyOnChain = true,
|
||||||
|
): Promise<SettleResult> {
|
||||||
|
const session = await this.did.getSession(p.sessionId);
|
||||||
|
const actionNonce = session.actionNonce;
|
||||||
|
const built = this.buildAuthorization(p, actionNonce, sessionPrivateKey);
|
||||||
|
|
||||||
|
if (verifyOnChain) {
|
||||||
|
const onChainActionHash = await this.paymentActionHash(p.token, p.payee, p.resourceId, p.deadline);
|
||||||
|
if (onChainActionHash.toLowerCase() !== built.actionHash.toLowerCase()) {
|
||||||
|
throw new Error(`AERE402FacilitatorPQCClient: local actionHash ${built.actionHash} != on-chain ${onChainActionHash}`);
|
||||||
|
}
|
||||||
|
const onChainDigest = await this.did.actionDigest(p.sessionId, p.scope, built.actionHash, p.amount, actionNonce);
|
||||||
|
if (onChainDigest.toLowerCase() !== built.digest.toLowerCase()) {
|
||||||
|
throw new Error(`AERE402FacilitatorPQCClient: local action digest ${built.digest} != on-chain ${onChainDigest}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = this.contract.connect(providerSigner) as Contract;
|
||||||
|
const tx: ContractTransactionResponse = await c.settle(
|
||||||
|
p.sessionId, bytes32(p.scope), p.token, p.payee, p.amount, bytes32(p.resourceId), p.deadline, built.signature,
|
||||||
|
);
|
||||||
|
return { tx, ...built };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,15 @@
|
|||||||
export { AereAgentClient, AERE402SettlementClient, buildAuthTypedData } from './AereAgentClient.js';
|
export { AereAgentClient, AERE402SettlementClient, buildAuthTypedData } from './AereAgentClient.js';
|
||||||
export type { Eip1193Provider, PaymentAuth as AgentPaymentAuth } 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 { createAere402Middleware, buildAere402TypedData } from './AERE402Middleware.js';
|
||||||
export type { Aere402MiddlewareConfig, PaymentAuth } from './AERE402Middleware.js';
|
export type { Aere402MiddlewareConfig, PaymentAuth } from './AERE402Middleware.js';
|
||||||
|
|
||||||
|
|||||||
353
src/cli/aere-cli.ts
Normal file
353
src/cli/aere-cli.ts
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// aere — the AERE Network developer CLI.
|
||||||
|
//
|
||||||
|
// A small, dependency-light read-only CLI over the @aere/sdk TypeScript SDK
|
||||||
|
// (chain 2800). It reuses AereClient / the SDK's pqc module and ethers v6 —
|
||||||
|
// no new runtime dependency, no arg-parsing framework (argv is parsed here).
|
||||||
|
//
|
||||||
|
// SAFETY: every command is READ-ONLY or purely LOCAL. The CLI never signs a
|
||||||
|
// real transaction, never takes a private key, and never persists anything.
|
||||||
|
// `pqc keygen` generates a keypair in-process and, by default, prints ONLY the
|
||||||
|
// public key + sizes — the secret key is withheld unless --show-secret is passed.
|
||||||
|
//
|
||||||
|
// Honesty contract: values are read live from the RPC and printed verbatim.
|
||||||
|
// Nothing is fabricated. On an RPC/connection error the CLI writes a clear
|
||||||
|
// message to stderr and exits non-zero.
|
||||||
|
//
|
||||||
|
// node dist/cli/aere-cli.js <command> [args] [--rpc <url>]
|
||||||
|
//
|
||||||
|
// Commands: chain · block <n|latest> · tx <hash> · addr <address>
|
||||||
|
// pqc schemes · pqc keygen --scheme <1|2|3|4> [--show-secret]
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { formatEther, formatUnits, isAddress, getAddress, hexlify, type JsonRpcProvider } from 'ethers';
|
||||||
|
import { AereClient } from '../client.js';
|
||||||
|
import {
|
||||||
|
SCHEME, SCHEME_NAME, JS_SIGNABLE, LENGTHS, keygen, type SchemeId,
|
||||||
|
} from '../pqc/index.js';
|
||||||
|
|
||||||
|
// ─────────────────────────── static scheme metadata ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native PQC verification precompile per scheme, mirroring the on-chain PURE
|
||||||
|
* function AerePQCAttestation.precompileFor(scheme) (documented in
|
||||||
|
* src/pqc/AerePQCClient.ts). Deterministic: scheme 1→0x0aE1 … 4→0x0aE4.
|
||||||
|
* Cross-checkable on-chain via `AerePQCClient.precompileFor`.
|
||||||
|
*/
|
||||||
|
const PRECOMPILE: Record<SchemeId, string> = {
|
||||||
|
[SCHEME.FALCON512]: '0x0000000000000000000000000000000000000aE1',
|
||||||
|
[SCHEME.FALCON1024]: '0x0000000000000000000000000000000000000aE2',
|
||||||
|
[SCHEME.MLDSA44]: '0x0000000000000000000000000000000000000aE3',
|
||||||
|
[SCHEME.SLHDSA128S]: '0x0000000000000000000000000000000000000aE4',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Public-key byte length per scheme, sourced from the SDK's pqc LENGTHS table. */
|
||||||
|
function pubKeySize(scheme: SchemeId): number {
|
||||||
|
switch (scheme) {
|
||||||
|
case SCHEME.FALCON512: return LENGTHS.falcon512.pubKey;
|
||||||
|
case SCHEME.FALCON1024: return LENGTHS.falcon1024.pubKey;
|
||||||
|
case SCHEME.MLDSA44: return LENGTHS.mldsa44.pubKey;
|
||||||
|
case SCHEME.SLHDSA128S: return LENGTHS.slhdsa128s.pubKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human description of the signature size per scheme (fixed for lattice/hash, variable for Falcon). */
|
||||||
|
function sigSizeDesc(scheme: SchemeId): string {
|
||||||
|
switch (scheme) {
|
||||||
|
case SCHEME.FALCON512:
|
||||||
|
case SCHEME.FALCON1024:
|
||||||
|
return 'variable (compressed; envelope = 40B nonce + 1B header + body)';
|
||||||
|
case SCHEME.MLDSA44: return `${LENGTHS.mldsa44.signature} bytes (fixed)`;
|
||||||
|
case SCHEME.SLHDSA128S: return `${LENGTHS.slhdsa128s.signature} bytes (fixed)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── tiny helpers ────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_RPC = 'https://rpc.aere.network';
|
||||||
|
|
||||||
|
interface Parsed {
|
||||||
|
_: string[];
|
||||||
|
rpc: string;
|
||||||
|
scheme?: string;
|
||||||
|
showSecret: boolean;
|
||||||
|
help: boolean;
|
||||||
|
version: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]): Parsed {
|
||||||
|
const p: Parsed = { _: [], rpc: DEFAULT_RPC, showSecret: false, help: false, version: false };
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a === '--help' || a === '-h') p.help = true;
|
||||||
|
else if (a === '--version' || a === '-V') p.version = true;
|
||||||
|
else if (a === '--rpc') p.rpc = req(args[++i], '--rpc');
|
||||||
|
else if (a.startsWith('--rpc=')) p.rpc = a.slice('--rpc='.length);
|
||||||
|
else if (a === '--scheme') p.scheme = req(args[++i], '--scheme');
|
||||||
|
else if (a.startsWith('--scheme=')) p.scheme = a.slice('--scheme='.length);
|
||||||
|
else if (a === '--show-secret') p.showSecret = true;
|
||||||
|
else if (a.startsWith('-')) throw new UsageError(`unknown option: ${a}`);
|
||||||
|
else p._.push(a);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function req(v: string | undefined, flag: string): string {
|
||||||
|
if (v === undefined) throw new UsageError(`option ${flag} requires a value`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown for bad CLI usage (missing arg, unknown command). Prints usage + exits 2. */
|
||||||
|
class UsageError extends Error {}
|
||||||
|
|
||||||
|
/** Fixed-width " label value" line. */
|
||||||
|
function kv(label: string, value: string | number): string {
|
||||||
|
return ` ${label.padEnd(16)}${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gwei(v: bigint | null | undefined): string {
|
||||||
|
return v == null ? 'n/a' : `${formatUnits(v, 'gwei')} gwei (${v.toString()} wei)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVersion(): string {
|
||||||
|
try {
|
||||||
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
||||||
|
return (JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }).version ?? 'unknown';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── commands ────────────────────────────────────
|
||||||
|
|
||||||
|
async function cmdChain(p: Parsed): Promise<void> {
|
||||||
|
const client = new AereClient({ rpcUrl: p.rpc });
|
||||||
|
// AereClient always builds a JsonRpcProvider; cast to reach send() for the
|
||||||
|
// raw JSON-RPC methods (eth_chainId, net_peerCount) the Provider interface omits.
|
||||||
|
const provider = client.provider as JsonRpcProvider;
|
||||||
|
|
||||||
|
// Core reads — a failure here means we could not talk to the node; let it throw.
|
||||||
|
const chainId = parseInt(await provider.send('eth_chainId', []), 16);
|
||||||
|
const blockNumber = await client.getBlockNumber();
|
||||||
|
const latest = await provider.getBlock('latest');
|
||||||
|
|
||||||
|
// Auxiliary reads — a node may not expose every method; report honestly, never fake.
|
||||||
|
let gasPrice: bigint | null = null;
|
||||||
|
try { gasPrice = (await provider.getFeeData()).gasPrice; } catch { /* leave null */ }
|
||||||
|
|
||||||
|
let validators: string[] | null = null;
|
||||||
|
try { validators = await client.getValidators(); } catch { /* method unsupported */ }
|
||||||
|
|
||||||
|
let peerCount: number | null = null;
|
||||||
|
try { peerCount = parseInt(await provider.send('net_peerCount', []), 16); } catch { /* unsupported */ }
|
||||||
|
|
||||||
|
console.log(`AERE chain — ${p.rpc}`);
|
||||||
|
console.log(kv('Chain ID', `${chainId} (0x${chainId.toString(16)})`));
|
||||||
|
console.log(kv('Block height', blockNumber));
|
||||||
|
console.log(kv('Base fee', gwei(latest?.baseFeePerGas ?? null)));
|
||||||
|
console.log(kv('Gas price', gwei(gasPrice)));
|
||||||
|
console.log(kv('Validators', validators == null ? 'unavailable (qbft_getValidatorsByBlockNumber not supported)' : validators.length));
|
||||||
|
if (validators && validators.length && validators.length <= 12) {
|
||||||
|
for (const v of validators) console.log(` - ${v}`);
|
||||||
|
}
|
||||||
|
console.log(kv('Peers', peerCount == null ? 'unavailable (net_peerCount not supported)' : peerCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdBlock(p: Parsed): Promise<void> {
|
||||||
|
const tagArg = p._[1];
|
||||||
|
if (!tagArg) throw new UsageError('block requires <number|latest>');
|
||||||
|
const tag = tagArg === 'latest' ? 'latest' : Number(tagArg);
|
||||||
|
if (tag !== 'latest' && !Number.isInteger(tag)) throw new UsageError(`invalid block: ${tagArg}`);
|
||||||
|
|
||||||
|
const client = new AereClient({ rpcUrl: p.rpc });
|
||||||
|
const block = await client.provider.getBlock(tag);
|
||||||
|
if (!block) {
|
||||||
|
fail(`block ${tagArg} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Block ${block.number} — ${p.rpc}`);
|
||||||
|
console.log(kv('Hash', block.hash ?? 'n/a'));
|
||||||
|
console.log(kv('Parent', block.parentHash));
|
||||||
|
console.log(kv('Timestamp', `${block.timestamp} (${new Date(block.timestamp * 1000).toISOString()})`));
|
||||||
|
console.log(kv('Proposer', block.miner));
|
||||||
|
console.log(kv('Tx count', block.transactions.length));
|
||||||
|
console.log(kv('Gas used', `${block.gasUsed.toString()} / ${block.gasLimit.toString()}`));
|
||||||
|
console.log(kv('Base fee', gwei(block.baseFeePerGas)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdTx(p: Parsed): Promise<void> {
|
||||||
|
const hash = p._[1];
|
||||||
|
if (!hash) throw new UsageError('tx requires <hash>');
|
||||||
|
|
||||||
|
const client = new AereClient({ rpcUrl: p.rpc });
|
||||||
|
const provider = client.provider;
|
||||||
|
const tx = await provider.getTransaction(hash);
|
||||||
|
if (!tx) {
|
||||||
|
fail(`transaction ${hash} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const receipt = await provider.getTransactionReceipt(hash);
|
||||||
|
|
||||||
|
console.log(`Transaction ${tx.hash} — ${p.rpc}`);
|
||||||
|
console.log(kv('From', tx.from));
|
||||||
|
console.log(kv('To', tx.to ?? '(contract creation)'));
|
||||||
|
console.log(kv('Value', `${formatEther(tx.value)} AERE (${tx.value.toString()} wei)`));
|
||||||
|
console.log(kv('Nonce', tx.nonce));
|
||||||
|
console.log(kv('Type', tx.type ?? 0));
|
||||||
|
console.log(kv('Gas limit', tx.gasLimit.toString()));
|
||||||
|
console.log(kv('Max fee', gwei(tx.maxFeePerGas ?? tx.gasPrice)));
|
||||||
|
console.log(kv('Input size', `${(tx.data.length - 2) / 2} bytes`));
|
||||||
|
|
||||||
|
if (!receipt) {
|
||||||
|
console.log(kv('Status', 'pending (no receipt yet)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(kv('Status', receipt.status === 1 ? 'success (1)' : receipt.status === 0 ? 'reverted (0)' : 'unknown'));
|
||||||
|
console.log(kv('Block', receipt.blockNumber));
|
||||||
|
console.log(kv('Gas used', receipt.gasUsed.toString()));
|
||||||
|
console.log(kv('Eff. gas price', gwei(receipt.gasPrice)));
|
||||||
|
console.log(kv('Logs', receipt.logs.length));
|
||||||
|
if (receipt.contractAddress) console.log(kv('Deployed', receipt.contractAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdAddr(p: Parsed): Promise<void> {
|
||||||
|
const raw = p._[1];
|
||||||
|
if (!raw) throw new UsageError('addr requires <address>');
|
||||||
|
if (!isAddress(raw)) throw new UsageError(`invalid address: ${raw}`);
|
||||||
|
const addr = getAddress(raw);
|
||||||
|
|
||||||
|
const client = new AereClient({ rpcUrl: p.rpc });
|
||||||
|
const provider = client.provider;
|
||||||
|
const [balance, nonce, code] = await Promise.all([
|
||||||
|
client.getNativeBalance(addr),
|
||||||
|
provider.getTransactionCount(addr),
|
||||||
|
provider.getCode(addr),
|
||||||
|
]);
|
||||||
|
const codeSize = (code.length - 2) / 2;
|
||||||
|
|
||||||
|
console.log(`Address ${addr} — ${p.rpc}`);
|
||||||
|
console.log(kv('Balance', `${formatEther(balance)} AERE (${balance.toString()} wei)`));
|
||||||
|
console.log(kv('Nonce', nonce));
|
||||||
|
console.log(kv('Code size', `${codeSize} bytes`));
|
||||||
|
console.log(kv('Type', codeSize > 0 ? 'contract' : 'EOA (no code)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdPqcSchemes(): void {
|
||||||
|
console.log('AERE native PQC verification precompiles (chain 2800):');
|
||||||
|
console.log('');
|
||||||
|
const ids = [SCHEME.FALCON512, SCHEME.FALCON1024, SCHEME.MLDSA44, SCHEME.SLHDSA128S] as const;
|
||||||
|
for (const id of ids) {
|
||||||
|
console.log(` [${id}] ${SCHEME_NAME[id]}`);
|
||||||
|
console.log(kv(' precompile', PRECOMPILE[id]));
|
||||||
|
console.log(kv(' public key', `${pubKeySize(id)} bytes`));
|
||||||
|
console.log(kv(' signature', sigSizeDesc(id)));
|
||||||
|
console.log(kv(' js signing', JS_SIGNABLE[id] ? 'yes (pure-JS keygen + sign in this SDK)' : 'no'));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
console.log('Verify a signature on-chain for free via AerePQCClient.verifySignature (eth_call).');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdPqcKeygen(p: Parsed): void {
|
||||||
|
if (p.scheme === undefined) throw new UsageError('pqc keygen requires --scheme <1|2|3|4>');
|
||||||
|
const n = Number(p.scheme);
|
||||||
|
if (![1, 2, 3, 4].includes(n)) throw new UsageError(`invalid --scheme ${p.scheme} (expected 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s)`);
|
||||||
|
const scheme = n as SchemeId;
|
||||||
|
|
||||||
|
const kp = keygen(scheme);
|
||||||
|
|
||||||
|
console.log(`Generated ${SCHEME_NAME[scheme]} keypair (local, ephemeral — nothing persisted):`);
|
||||||
|
console.log(kv('Scheme', `${scheme} (${SCHEME_NAME[scheme]})`));
|
||||||
|
console.log(kv('Precompile', PRECOMPILE[scheme]));
|
||||||
|
console.log(kv('Public size', `${kp.publicKey.length} bytes`));
|
||||||
|
console.log(kv('Secret size', `${kp.secretKey.length} bytes`));
|
||||||
|
console.log(kv('Public key', hexlify(kp.publicKey)));
|
||||||
|
|
||||||
|
if (p.showSecret) {
|
||||||
|
process.stderr.write('WARNING: printing the SECRET key. Anyone who sees it can forge signatures for this key. Never share, log, or commit it.\n');
|
||||||
|
console.log(kv('Secret key', hexlify(kp.secretKey)));
|
||||||
|
} else {
|
||||||
|
console.log(' [secret key withheld — pass --show-secret to reveal it (DANGER)]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────── help / dispatch ───────────────────────────────
|
||||||
|
|
||||||
|
const USAGE = `aere — AERE Network developer CLI (read-only, chain 2800)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
aere <command> [args] [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
chain Chain id, block height, base fee, gas price, validators, peers
|
||||||
|
block <number|latest> Block summary (hash, parent, timestamp, txs, gas, proposer)
|
||||||
|
tx <hash> Transaction + receipt summary
|
||||||
|
addr <address> Balance, nonce, and code size for an address
|
||||||
|
pqc schemes List the supported PQC schemes, precompiles, and key/sig sizes
|
||||||
|
pqc keygen --scheme <1-4> Generate a PQC keypair locally (public key only, unless --show-secret)
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--rpc <url> RPC endpoint (default: ${DEFAULT_RPC})
|
||||||
|
--scheme <1|2|3|4> PQC scheme: 1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s
|
||||||
|
--show-secret (pqc keygen) also print the secret key — DANGEROUS
|
||||||
|
--version, -V Print the SDK version
|
||||||
|
--help, -h Show this help
|
||||||
|
|
||||||
|
All commands are read-only or purely local. The CLI never signs a real
|
||||||
|
transaction and never accepts a private key.`;
|
||||||
|
|
||||||
|
function fail(msg: string): never {
|
||||||
|
process.stderr.write(`aere: ${msg}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const p = parseArgs(process.argv.slice(2));
|
||||||
|
const cmd = p._[0];
|
||||||
|
|
||||||
|
if (p.version && !cmd) { console.log(readVersion()); return; }
|
||||||
|
if (p.help || !cmd || cmd === 'help') { console.log(USAGE); return; }
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case 'chain':
|
||||||
|
if (p.help) { console.log('aere chain [--rpc <url>] — chain id, height, base fee, gas price, validators, peers'); return; }
|
||||||
|
await cmdChain(p);
|
||||||
|
return;
|
||||||
|
case 'block':
|
||||||
|
if (p.help) { console.log('aere block <number|latest> [--rpc <url>] — block summary'); return; }
|
||||||
|
await cmdBlock(p);
|
||||||
|
return;
|
||||||
|
case 'tx':
|
||||||
|
if (p.help) { console.log('aere tx <hash> [--rpc <url>] — transaction + receipt summary'); return; }
|
||||||
|
await cmdTx(p);
|
||||||
|
return;
|
||||||
|
case 'addr':
|
||||||
|
if (p.help) { console.log('aere addr <address> [--rpc <url>] — balance, nonce, code size'); return; }
|
||||||
|
await cmdAddr(p);
|
||||||
|
return;
|
||||||
|
case 'pqc': {
|
||||||
|
const sub = p._[1];
|
||||||
|
if (p.help || !sub) {
|
||||||
|
console.log('aere pqc schemes — list PQC schemes/precompiles/sizes\naere pqc keygen --scheme <1|2|3|4> [--show-secret] — generate a keypair locally');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === 'schemes') { cmdPqcSchemes(); return; }
|
||||||
|
if (sub === 'keygen') { cmdPqcKeygen(p); return; }
|
||||||
|
throw new UsageError(`unknown pqc subcommand: ${sub}`);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new UsageError(`unknown command: ${cmd}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
if (err instanceof UsageError) {
|
||||||
|
process.stderr.write(`aere: ${err.message}\n\n${USAGE}\n`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
process.stderr.write(`aere: ${msg}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -4,7 +4,7 @@ import {
|
|||||||
} from 'ethers';
|
} from 'ethers';
|
||||||
import { AERE_MAINNET } from './addresses.js';
|
import { AERE_MAINNET } from './addresses.js';
|
||||||
import {
|
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,
|
IDENTITY_ABI, BRIDGE_ABI, FAUCET_ABI, SWAP_FACTORY_ABI, SWAP_PAIR_ABI,
|
||||||
} from './abis.js';
|
} from './abis.js';
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ export interface AereClientOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level AERE Network client. Provides a typed handle to every deployed contract
|
* Top-level AERE Network client. Provides a typed handle to every deployed contract
|
||||||
* Bank28-class integrations need.
|
* consumer-wallet-class integrations need.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const aere = new AereClient({ privateKey: process.env.PK });
|
* 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() {
|
get lockedStaking() {
|
||||||
const c = this.contract(this.addresses.AereLockedStaking, STAKING_V2_ABI, true);
|
const c = this.contract(this.addresses.AereLockedStaking, STAKING_V2_ABI, true);
|
||||||
return {
|
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() {
|
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 {
|
return {
|
||||||
contract: c,
|
contract: c,
|
||||||
delegate: (validator: string, amountWei: bigint) => c.delegate(validator, { value: amountWei }),
|
delegate: (validator: string, amountWei: bigint) => c.delegate(validator, { value: amountWei }),
|
||||||
@ -108,6 +121,35 @@ export class AereClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AereStakingV2 (0x1D95eF6D…55FC) — CANONICAL delegated stake pool (native AERE).
|
||||||
|
* Bug-fix redeploy of AereStaking: settle-on-top-up (no retroactive reward drain),
|
||||||
|
* deregisterValidator/claimUnbonded to recover self-stake, re-register preserves
|
||||||
|
* delegations, timestamp-based accrual at an owner-settable rewardRateBps (default
|
||||||
|
* 800 = 8% APY), and validator commission credited via claimCommission.
|
||||||
|
*/
|
||||||
|
get stakingV2() {
|
||||||
|
const c = this.contract(this.addresses.AereStakingV2, STAKING_DELEGATED_V2_ABI, true);
|
||||||
|
return {
|
||||||
|
contract: c,
|
||||||
|
registerValidator: (commissionBps: number | bigint, selfStakeWei: bigint) =>
|
||||||
|
c.registerValidator(commissionBps, { value: selfStakeWei }),
|
||||||
|
deregisterValidator: () => c.deregisterValidator(),
|
||||||
|
delegate: (validator: string, amountWei: bigint) => c.delegate(validator, { value: amountWei }),
|
||||||
|
unbond: (validator: string, amount: bigint) => c.unbond(validator, amount),
|
||||||
|
claimUnbonded: () => c.claimUnbonded(),
|
||||||
|
claimRewards: (validator: string) => c.claimRewards(validator),
|
||||||
|
claimCommission: () => c.claimCommission(),
|
||||||
|
pendingRewards: (validator: string, delegator: string) =>
|
||||||
|
c.pendingRewards(validator, delegator) as Promise<bigint>,
|
||||||
|
rewardRateBps: () => c.rewardRateBps() as Promise<bigint>,
|
||||||
|
validators: (v: string) => c.validators(v),
|
||||||
|
delegations: (validator: string, delegator: string) => c.delegations(validator, delegator),
|
||||||
|
validatorCount: () => c.validatorCount() as Promise<bigint>,
|
||||||
|
totalStaked: () => c.totalStaked() as Promise<bigint>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** AereIdentity — DID / KYC attestation registry */
|
/** AereIdentity — DID / KYC attestation registry */
|
||||||
get identity() {
|
get identity() {
|
||||||
const c = this.contract(this.addresses.AereIdentity, IDENTITY_ABI, true);
|
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() {
|
get faucet() {
|
||||||
const c = this.contract(this.addresses.AereFaucet, FAUCET_ABI, true);
|
const c = this.contract(this.addresses.AereFaucet, FAUCET_ABI, true);
|
||||||
return {
|
return {
|
||||||
@ -176,7 +223,7 @@ export class AereClient {
|
|||||||
return tx.hash;
|
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<{
|
async getPortfolio(user: string): Promise<{
|
||||||
aere: bigint;
|
aere: bigint;
|
||||||
waere: bigint;
|
waere: bigint;
|
||||||
|
|||||||
@ -153,6 +153,14 @@ export class AereZKScreenClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== AereAIProof ================================ */
|
/* ===================== 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 = {
|
const AI_SEL = {
|
||||||
models: '0x88e98b22', // models(bytes32)
|
models: '0x88e98b22', // models(bytes32)
|
||||||
|
|||||||
219
src/crypto-agility/AereCryptoRegistryClient.ts
Normal file
219
src/crypto-agility/AereCryptoRegistryClient.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
// AereCryptoRegistryClient — ethers v6 wrapper over AereCryptoRegistry (chain 2800).
|
||||||
|
//
|
||||||
|
// AereCryptoRegistry (0xaE6fC596bb3eCcbf5c5D02D67B0Ef065b3Afbaa5) is AERE's
|
||||||
|
// cryptographic-agility registry over the five live native precompiles
|
||||||
|
// (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s, SHAKE256). It maps a governed
|
||||||
|
// algorithmId -> { verifier, wire-format, status, gas, successor } so a consumer
|
||||||
|
// integrates against ONE stable surface and follows scheme deprecations /
|
||||||
|
// successors WITHOUT redeploying, by routing through resolveActive.
|
||||||
|
//
|
||||||
|
// This client exposes the fail-closed view surface (verify / resolveActive /
|
||||||
|
// getAlgorithm / status queries / precompileFor) plus the owner-only write
|
||||||
|
// methods (addAlgorithm / setStatus / setSuccessor / seedLiveSchemes), which
|
||||||
|
// take a Signer.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract, getBytes, hexlify,
|
||||||
|
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../addresses.js';
|
||||||
|
|
||||||
|
/** Minimal inline ABI — only the AereCryptoRegistry surface this client uses. */
|
||||||
|
export const AERE_CRYPTO_REGISTRY_ABI = [
|
||||||
|
// views
|
||||||
|
'function verify(uint256 algorithmId, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||||
|
'function resolveActive(uint256 algorithmId) view returns (uint256)',
|
||||||
|
'function getAlgorithm(uint256 id) view returns (tuple(string name, uint8 scheme, address verifier, uint16 pubKeyLen, uint32 sigLen, uint8 esigHeader, uint8 status, uint256 gasEstimate, uint256 successorId, uint64 addedBlock, bool isSignature))',
|
||||||
|
'function algorithmCount() view returns (uint256)',
|
||||||
|
'function statusOf(uint256 id) view returns (uint8)',
|
||||||
|
'function isActive(uint256 id) view returns (bool)',
|
||||||
|
'function isUsable(uint256 id) view returns (bool)',
|
||||||
|
'function precompileFor(uint8 scheme) view returns (address)',
|
||||||
|
'function seeded() view returns (bool)',
|
||||||
|
'function owner() view returns (address)',
|
||||||
|
// owner writes
|
||||||
|
'function addAlgorithm(string name, uint8 scheme, address verifier, uint16 pubKeyLen, uint32 sigLen, uint8 esigHeader, uint256 gasEstimate, bool isSignature) returns (uint256 id)',
|
||||||
|
'function setStatus(uint256 id, uint8 newStatus)',
|
||||||
|
'function setSuccessor(uint256 id, uint256 successorId)',
|
||||||
|
'function seedLiveSchemes()',
|
||||||
|
// events
|
||||||
|
'event AlgorithmAdded(uint256 indexed id, uint8 indexed scheme, address indexed verifier, string name, bool isSignature)',
|
||||||
|
'event StatusChanged(uint256 indexed id, uint8 oldStatus, uint8 newStatus)',
|
||||||
|
'event SuccessorSet(uint256 indexed id, uint256 indexed successorId)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle status of a registered algorithm (mirrors AereCryptoRegistry.Status).
|
||||||
|
* UNKNOWN=0 (never registered), ACTIVE=1 (recommended, verify runs),
|
||||||
|
* DEPRECATED=2 (still verifies legacy sigs but not usable for new work),
|
||||||
|
* REVOKED=3 (verify always false; terminal).
|
||||||
|
*/
|
||||||
|
export enum CryptoStatus {
|
||||||
|
UNKNOWN = 0,
|
||||||
|
ACTIVE = 1,
|
||||||
|
DEPRECATED = 2,
|
||||||
|
REVOKED = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A decoded algorithm row. */
|
||||||
|
export interface AlgorithmRow {
|
||||||
|
name: string;
|
||||||
|
scheme: number;
|
||||||
|
verifier: string;
|
||||||
|
pubKeyLen: number;
|
||||||
|
sigLen: number;
|
||||||
|
esigHeader: number;
|
||||||
|
status: CryptoStatus;
|
||||||
|
gasEstimate: bigint;
|
||||||
|
successorId: bigint;
|
||||||
|
addedBlock: bigint;
|
||||||
|
isSignature: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AereCryptoRegistryClientOptions {
|
||||||
|
/** AereCryptoRegistry address. Default: AERE_MAINNET mainnet deployment. */
|
||||||
|
address?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(v: BytesLike): Uint8Array {
|
||||||
|
return v instanceof Uint8Array ? v : getBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AereCryptoRegistryClient {
|
||||||
|
readonly address: string;
|
||||||
|
readonly contract: Contract;
|
||||||
|
|
||||||
|
constructor(runner: ContractRunner, opts: AereCryptoRegistryClientOptions = {}) {
|
||||||
|
this.address = opts.address ?? AERE_MAINNET.AereCryptoRegistry;
|
||||||
|
this.contract = new Contract(this.address, AERE_CRYPTO_REGISTRY_ABI, runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- views -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signature under `algorithmId` via the routed verifier. FAIL-CLOSED:
|
||||||
|
* returns false (never throws) for an unknown id, a hash-only row, an
|
||||||
|
* UNKNOWN/REVOKED status, or malformed lengths. A DEPRECATED row still verifies
|
||||||
|
* (legacy) — check {@link isUsable} before adopting a scheme for new work.
|
||||||
|
*/
|
||||||
|
async verify(
|
||||||
|
algorithmId: bigint | number,
|
||||||
|
pubKey: BytesLike,
|
||||||
|
messageHash: BytesLike,
|
||||||
|
signature: BytesLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.contract.verify(
|
||||||
|
algorithmId,
|
||||||
|
hexlify(toBytes(pubKey)),
|
||||||
|
hexlify(toBytes(messageHash)),
|
||||||
|
hexlify(toBytes(signature)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow the successor chain from `algorithmId` to the first ACTIVE row and
|
||||||
|
* return its id. Reverts on a dead-end (NoActiveSuccessor) or a cycle
|
||||||
|
* (SuccessorCycle) — those are genuine registry conditions, not network errors.
|
||||||
|
*/
|
||||||
|
async resolveActive(algorithmId: bigint | number): Promise<bigint> {
|
||||||
|
return this.contract.resolveActive(algorithmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a full algorithm row. Reverts (UnknownAlgorithm) on an unregistered id. */
|
||||||
|
async getAlgorithm(id: bigint | number): Promise<AlgorithmRow> {
|
||||||
|
const r = await this.contract.getAlgorithm(id);
|
||||||
|
return {
|
||||||
|
name: r.name,
|
||||||
|
scheme: Number(r.scheme),
|
||||||
|
verifier: r.verifier,
|
||||||
|
pubKeyLen: Number(r.pubKeyLen),
|
||||||
|
sigLen: Number(r.sigLen),
|
||||||
|
esigHeader: Number(r.esigHeader),
|
||||||
|
status: Number(r.status) as CryptoStatus,
|
||||||
|
gasEstimate: r.gasEstimate,
|
||||||
|
successorId: r.successorId,
|
||||||
|
addedBlock: r.addedBlock,
|
||||||
|
isSignature: r.isSignature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of registered algorithms. Ids run 1..algorithmCount(). */
|
||||||
|
async algorithmCount(): Promise<bigint> {
|
||||||
|
return this.contract.algorithmCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle status of `id` (UNKNOWN for a never-registered id). */
|
||||||
|
async statusOf(id: bigint | number): Promise<CryptoStatus> {
|
||||||
|
return Number(await this.contract.statusOf(id)) as CryptoStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff `id` exists and is ACTIVE. */
|
||||||
|
async isActive(id: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.isActive(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff `id` is a signature scheme that is safe to adopt for NEW work (ACTIVE). */
|
||||||
|
async isUsable(id: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.isUsable(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The verifier currently routing an ACTIVE algorithm for `scheme`
|
||||||
|
* (1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s, 5=SHAKE256).
|
||||||
|
* Reverts (UnknownScheme) if no ACTIVE row serves that scheme.
|
||||||
|
*/
|
||||||
|
async precompileFor(scheme: number): Promise<string> {
|
||||||
|
return this.contract.precompileFor(scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the one-shot seedLiveSchemes() has already run. */
|
||||||
|
async seeded(): Promise<boolean> {
|
||||||
|
return this.contract.seeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current owner (deployer now; Timelock later, founder-signed). */
|
||||||
|
async owner(): Promise<string> {
|
||||||
|
return this.contract.owner();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writes (require a Signer runner, owner-gated on-chain) -----------------
|
||||||
|
|
||||||
|
/** Register a new algorithm row (owner only). The row is created ACTIVE. */
|
||||||
|
async addAlgorithm(
|
||||||
|
signer: Signer,
|
||||||
|
args: {
|
||||||
|
name: string;
|
||||||
|
scheme: number;
|
||||||
|
verifier: string;
|
||||||
|
pubKeyLen: number;
|
||||||
|
sigLen: number;
|
||||||
|
esigHeader: number;
|
||||||
|
gasEstimate: bigint | number;
|
||||||
|
isSignature: boolean;
|
||||||
|
},
|
||||||
|
): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.addAlgorithm(
|
||||||
|
args.name, args.scheme, args.verifier, args.pubKeyLen, args.sigLen,
|
||||||
|
args.esigHeader, args.gasEstimate, args.isSignature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change an algorithm's lifecycle status (owner only). REVOKED is terminal. */
|
||||||
|
async setStatus(signer: Signer, id: bigint | number, newStatus: CryptoStatus): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.setStatus(id, newStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point `id`'s successor at `successorId` (owner only). */
|
||||||
|
async setSuccessor(signer: Signer, id: bigint | number, successorId: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.setSuccessor(id, successorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the five live chain-2800 schemes (owner only, one-shot). */
|
||||||
|
async seedLiveSchemes(signer: Signer): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.seedLiveSchemes();
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/crypto-agility/AereHybridAuthorizerClient.ts
Normal file
232
src/crypto-agility/AereHybridAuthorizerClient.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// AereHybridAuthorizerClient — ethers v6 wrapper over AereHybridAuthorizer (chain 2800).
|
||||||
|
//
|
||||||
|
// AereHybridAuthorizer is the CONSUMER that gives AereCryptoRegistry its value: instead
|
||||||
|
// of pinning a scheme, an account/app authorizes against "whatever scheme is currently
|
||||||
|
// ACTIVE". Every check first calls registry.resolveActive(id) to follow the successor
|
||||||
|
// chain to the live ACTIVE row, then verifies through the registry's routed verifier.
|
||||||
|
//
|
||||||
|
// The payoff is a ZERO-REDEPLOY migration: when the Foundation retires a scheme on the
|
||||||
|
// REGISTRY (addAlgorithm(new) -> setSuccessor(old,new) -> setStatus(old, DEPRECATED|
|
||||||
|
// REVOKED)), every consumer routing through this authorizer starts accepting the NEW
|
||||||
|
// scheme and rejecting the OLD one on the next block — no code change, no redeploy.
|
||||||
|
//
|
||||||
|
// HONEST SCOPE: application-layer authorization only. It does NOT change AERE consensus
|
||||||
|
// (blocks are still Besu QBFT / classical ECDSA). All PQC verification is delegated to the
|
||||||
|
// same live native precompiles the registry routes to. Holds no funds.
|
||||||
|
//
|
||||||
|
// This client exposes the fail-closed view surface (checkAuthorized / checkAuthorizedFor /
|
||||||
|
// algorithmFor / resolvedAlgorithmFor / preferredAlgorithmOf / isProofAuthorized), the
|
||||||
|
// self-service preference write (setPreferredAlgorithm), the owner-only governance writes
|
||||||
|
// (setDefaultAlgorithm / migrate), and the state-changing authorize / authorizeWithPreferred.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract, getBytes, hexlify,
|
||||||
|
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
|
||||||
|
/** Minimal inline ABI — only the AereHybridAuthorizer surface this client uses. */
|
||||||
|
export const AERE_HYBRID_AUTHORIZER_ABI = [
|
||||||
|
// immutable / config views
|
||||||
|
'function registry() view returns (address)',
|
||||||
|
'function owner() view returns (address)',
|
||||||
|
'function defaultAlgorithmId() view returns (uint256)',
|
||||||
|
// preference views
|
||||||
|
'function preferredAlgorithmOf(address account) view returns (uint256)',
|
||||||
|
'function algorithmFor(address account) view returns (uint256)',
|
||||||
|
'function resolvedAlgorithmFor(address account) view returns (uint256)',
|
||||||
|
// authorization views (fail-closed)
|
||||||
|
'function checkAuthorized(uint256 algorithmId, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||||
|
'function checkAuthorizedFor(address account, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||||
|
'function isProofAuthorized(bytes pubKey, bytes32 messageHash) view returns (bool)',
|
||||||
|
// ledger views
|
||||||
|
'function authorizationCount() view returns (uint256)',
|
||||||
|
'function lastResolvedId() view returns (uint256)',
|
||||||
|
'function lastMessageHash() view returns (bytes32)',
|
||||||
|
// self-service write
|
||||||
|
'function setPreferredAlgorithm(uint256 id)',
|
||||||
|
// owner writes
|
||||||
|
'function setDefaultAlgorithm(uint256 id)',
|
||||||
|
'function migrate(uint256 oldId) returns (uint256 newId)',
|
||||||
|
// state-changing authorize
|
||||||
|
'function authorize(uint256 algorithmId, bytes pubKey, bytes32 messageHash, bytes signature) returns (uint256 resolvedId)',
|
||||||
|
'function authorizeWithPreferred(bytes pubKey, bytes32 messageHash, bytes signature) returns (uint256 resolvedId)',
|
||||||
|
// events
|
||||||
|
'event DefaultAlgorithmSet(uint256 indexed id)',
|
||||||
|
'event DefaultMigrated(uint256 indexed oldId, uint256 indexed newId)',
|
||||||
|
'event PreferredAlgorithmSet(address indexed account, uint256 indexed id)',
|
||||||
|
'event Authorized(address indexed caller, uint256 indexed requestedId, uint256 indexed resolvedId, bytes32 messageHash)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface AereHybridAuthorizerClientOptions {
|
||||||
|
/** AereHybridAuthorizer address. Required (no canonical deployment is pinned yet). */
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(v: BytesLike): Uint8Array {
|
||||||
|
return v instanceof Uint8Array ? v : getBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AereHybridAuthorizerClient {
|
||||||
|
readonly address: string;
|
||||||
|
readonly contract: Contract;
|
||||||
|
|
||||||
|
constructor(runner: ContractRunner, opts: AereHybridAuthorizerClientOptions) {
|
||||||
|
if (!opts?.address) throw new Error('AereHybridAuthorizerClient: options.address is required');
|
||||||
|
this.address = opts.address;
|
||||||
|
this.contract = new Contract(this.address, AERE_HYBRID_AUTHORIZER_ABI, runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- config views ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** The AereCryptoRegistry this authorizer routes through. */
|
||||||
|
async registry(): Promise<string> {
|
||||||
|
return this.contract.registry();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current owner (deployer now; Timelock later, founder-signed). */
|
||||||
|
async owner(): Promise<string> {
|
||||||
|
return this.contract.owner();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app-wide default algorithm id (stable reference; re-resolved on every check). */
|
||||||
|
async defaultAlgorithmId(): Promise<bigint> {
|
||||||
|
return this.contract.defaultAlgorithmId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- preference views ------------------------------------------------------
|
||||||
|
|
||||||
|
/** The raw stored preference for `account` (0 == none / use the app default). */
|
||||||
|
async preferredAlgorithmOf(account: string): Promise<bigint> {
|
||||||
|
return this.contract.preferredAlgorithmOf(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective (pre-resolution) algorithm id for `account`: preference if set, else default. */
|
||||||
|
async algorithmFor(account: string): Promise<bigint> {
|
||||||
|
return this.contract.algorithmFor(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ACTIVE algorithm id `account` currently authorizes under, after walking the
|
||||||
|
* successor chain. Reverts (NoActiveSuccessor / SuccessorCycle / UnknownAlgorithm) if
|
||||||
|
* the chain has no ACTIVE row — those are genuine registry conditions, not net errors.
|
||||||
|
*/
|
||||||
|
async resolvedAlgorithmFor(account: string): Promise<bigint> {
|
||||||
|
return this.contract.resolvedAlgorithmFor(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- authorization views (fail-closed) -------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-closed check: is `signature` valid over `messageHash` by `pubKey` under the
|
||||||
|
* currently-ACTIVE scheme reachable from `algorithmId`? Returns false (never throws)
|
||||||
|
* for an unknown id, a dead-end/cyclic successor chain, a malformed input, or a failed
|
||||||
|
* verification. Only an ACTIVE scheme is ever accepted (a REVOKED id is never used).
|
||||||
|
*/
|
||||||
|
async checkAuthorized(
|
||||||
|
algorithmId: bigint | number,
|
||||||
|
pubKey: BytesLike,
|
||||||
|
messageHash: BytesLike,
|
||||||
|
signature: BytesLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.contract.checkAuthorized(
|
||||||
|
algorithmId, hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fail-closed check under `account`'s effective algorithm (its preference, else default). */
|
||||||
|
async checkAuthorizedFor(
|
||||||
|
account: string,
|
||||||
|
pubKey: BytesLike,
|
||||||
|
messageHash: BytesLike,
|
||||||
|
signature: BytesLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return this.contract.checkAuthorizedFor(
|
||||||
|
account, hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether (pubKey, messageHash) was ever recorded by a successful authorize(). */
|
||||||
|
async isProofAuthorized(pubKey: BytesLike, messageHash: BytesLike): Promise<boolean> {
|
||||||
|
return this.contract.isProofAuthorized(hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ledger views ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Number of successful authorize() calls recorded. */
|
||||||
|
async authorizationCount(): Promise<bigint> {
|
||||||
|
return this.contract.authorizationCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ACTIVE algorithm id used by the most recent successful authorization. */
|
||||||
|
async lastResolvedId(): Promise<bigint> {
|
||||||
|
return this.contract.lastResolvedId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message hash of the most recent successful authorization. */
|
||||||
|
async lastMessageHash(): Promise<string> {
|
||||||
|
return this.contract.lastMessageHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- self-service write ----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose a personal preferred algorithm id for the signer. Pass 0 to clear the
|
||||||
|
* preference and fall back to the app default. A non-zero id must resolve to a usable
|
||||||
|
* ACTIVE signature scheme. Permissionless (any account, not owner-gated).
|
||||||
|
*/
|
||||||
|
async setPreferredAlgorithm(signer: Signer, id: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.setPreferredAlgorithm(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- owner writes ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Set the app-wide default algorithm id (owner only). Must resolve to a usable ACTIVE scheme. */
|
||||||
|
async setDefaultAlgorithm(signer: Signer, id: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.setDefaultAlgorithm(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner helper: crystallize the app default onto the ACTIVE successor of `oldId` after a
|
||||||
|
* registry swap. `oldId` must equal the current default. A no-op if the id is still
|
||||||
|
* ACTIVE. Reverts if the chain has no ACTIVE successor.
|
||||||
|
*/
|
||||||
|
async migrate(signer: Signer, oldId: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.migrate(oldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- state-changing authorize ----------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State-changing authorization under the ACTIVE scheme reachable from `algorithmId`.
|
||||||
|
* Reverts if the successor chain has no ACTIVE row (surfacing the registry reason) or
|
||||||
|
* AuthorizationFailed if the routed verifier returns false. Records the proof on success.
|
||||||
|
*/
|
||||||
|
async authorize(
|
||||||
|
signer: Signer,
|
||||||
|
algorithmId: bigint | number,
|
||||||
|
pubKey: BytesLike,
|
||||||
|
messageHash: BytesLike,
|
||||||
|
signature: BytesLike,
|
||||||
|
): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.authorize(
|
||||||
|
algorithmId, hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** State-changing authorization under the signer's effective algorithm (preference, else default). */
|
||||||
|
async authorizeWithPreferred(
|
||||||
|
signer: Signer,
|
||||||
|
pubKey: BytesLike,
|
||||||
|
messageHash: BytesLike,
|
||||||
|
signature: BytesLike,
|
||||||
|
): Promise<ContractTransactionResponse> {
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
return c.authorizeWithPreferred(
|
||||||
|
hexlify(toBytes(pubKey)), hexlify(toBytes(messageHash)), hexlify(toBytes(signature)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/crypto-agility/index.ts
Normal file
18
src/crypto-agility/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// AERE cryptographic-agility surface — the governed registry over AERE's five live
|
||||||
|
// native precompiles (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s, SHAKE256). Integrate
|
||||||
|
// against this one stable surface and follow scheme deprecations / successors WITHOUT
|
||||||
|
// redeploying, by routing through resolveActive.
|
||||||
|
|
||||||
|
export {
|
||||||
|
AereCryptoRegistryClient, AERE_CRYPTO_REGISTRY_ABI, CryptoStatus,
|
||||||
|
type AereCryptoRegistryClientOptions, type AlgorithmRow,
|
||||||
|
} from './AereCryptoRegistryClient.js';
|
||||||
|
|
||||||
|
// AereHybridAuthorizer — the CONSUMER over the registry: authorize against "whatever
|
||||||
|
// scheme is currently ACTIVE" (routes via resolveActive), so a Foundation-governed scheme
|
||||||
|
// swap migrates every consumer with NO redeploy. Owner-governed default + per-account
|
||||||
|
// preference with a safe default.
|
||||||
|
export {
|
||||||
|
AereHybridAuthorizerClient, AERE_HYBRID_AUTHORIZER_ABI,
|
||||||
|
type AereHybridAuthorizerClientOptions,
|
||||||
|
} from './AereHybridAuthorizerClient.js';
|
||||||
116
src/index.ts
116
src/index.ts
@ -1,5 +1,21 @@
|
|||||||
export { AereClient, type AereClientOptions } from './client.js';
|
export { AereClient, type AereClientOptions } from './client.js';
|
||||||
export { AERE_MAINNET, AERE_COREBOOK, type AereContractName, type CoreBookMarketSymbol } from './addresses.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 {
|
export {
|
||||||
ERC20_ABI, WAERE_ABI, STAKING_V2_ABI, STAKING_V1_ABI, LENDING_ABI, STABLE_ABI,
|
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,
|
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';
|
createAere402Middleware, buildAere402TypedData } from './agentic/index.js';
|
||||||
export type { Aere402MiddlewareConfig, PaymentAuth, AgentPaymentAuth, Eip1193Provider } 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.
|
// Compliance primitives — AereProof v0 + ZKScreen + AIProof.
|
||||||
export { AereSanctionsRegistryClient, ChainalysisOracleWrapperClient,
|
export { AereSanctionsRegistryClient, ChainalysisOracleWrapperClient,
|
||||||
AereTravelRuleHashRegistryClient, AereForensicEventRegistryClient,
|
AereTravelRuleHashRegistryClient, AereForensicEventRegistryClient,
|
||||||
@ -55,3 +78,96 @@ export {
|
|||||||
AereAttestationGatewayClient,
|
AereAttestationGatewayClient,
|
||||||
AereCompliancePoolClient,
|
AereCompliancePoolClient,
|
||||||
} from './compliance/index.js';
|
} from './compliance/index.js';
|
||||||
|
|
||||||
|
// PQC signing surface — keygen + internal-interface signing + envelope builders
|
||||||
|
// + ethers v6 wrappers for AerePQCAttestation, AerePQCKeyRegistry (on-chain
|
||||||
|
// proof-of-possession key registry) and AereAgentDID (Falcon-rooted agent DID),
|
||||||
|
// driving AERE's LIVE native post-quantum precompiles (Falcon-512/1024, ML-DSA-44,
|
||||||
|
// SLH-DSA-SHA2-128s) on chain 2800. All four schemes have full pure-JS signing,
|
||||||
|
// proven interoperable against the live precompiles.
|
||||||
|
export * from './pqc/index.js';
|
||||||
|
|
||||||
|
// Cryptographic-agility — AereCryptoRegistry: the governed algorithmId ->
|
||||||
|
// {verifier, wire-format, status, gas, successor} table over the 5 live precompiles.
|
||||||
|
export * from './crypto-agility/index.js';
|
||||||
|
|
||||||
|
// PQC-authorized intents — Falcon/ML-DSA-signed ERC-7683 cross-chain orders for
|
||||||
|
// AereSpokePool.openForPQC: quantum-durable authorization routed through the
|
||||||
|
// crypto-agility registry (scheme migration with no redeploy). Order-builder + digest.
|
||||||
|
export * from './intents/index.js';
|
||||||
|
|
||||||
|
// Seedless hybrid PQC wallet core — passkey daily factor fused with a Falcon-512
|
||||||
|
// quantum-durable root (AerePQCAccount), client-side-encrypted key custody, and
|
||||||
|
// the one-tap "upgrade to post-quantum" attestation. Chain 2800.
|
||||||
|
export * from './wallet/index.js';
|
||||||
|
|
||||||
|
// ERC-7579 modular accounts — session keys + M-of-N social recovery, bound to
|
||||||
|
// AereEntryPointV2 (chain 2800). Deployed 2026-07-10.
|
||||||
|
export {
|
||||||
|
ModularAccountClient,
|
||||||
|
SessionKeyClient,
|
||||||
|
SocialRecoveryClient,
|
||||||
|
AERE_MODULAR_ADDRESSES,
|
||||||
|
AERE_ACCOUNT_INIT_CODE_HASH,
|
||||||
|
RECOVERY_DELAY_SECONDS,
|
||||||
|
MODULE_TYPE_VALIDATOR,
|
||||||
|
MODULE_TYPE_EXECUTOR,
|
||||||
|
SELECTOR_NATIVE_TRANSFER,
|
||||||
|
ACCOUNT_SELECTORS,
|
||||||
|
routeRootSignature,
|
||||||
|
routeValidatorSignature,
|
||||||
|
eip1193PersonalSign,
|
||||||
|
toChecksumAddress,
|
||||||
|
encodeExecute as encodeAccountExecute,
|
||||||
|
encodeExecuteBatch as encodeAccountExecuteBatch,
|
||||||
|
computeUserOpHash,
|
||||||
|
} from './account/index.js';
|
||||||
|
export type {
|
||||||
|
ModularAccountAddresses,
|
||||||
|
ModularAccountClientOptions,
|
||||||
|
PackedUserOp,
|
||||||
|
GasParams,
|
||||||
|
SessionScope,
|
||||||
|
SessionPermission,
|
||||||
|
SessionInfo,
|
||||||
|
ActiveRecovery,
|
||||||
|
UserOpSigner,
|
||||||
|
ScopeCheck,
|
||||||
|
CallStruct,
|
||||||
|
Eip1193Provider as AccountEip1193Provider,
|
||||||
|
} from './account/index.js';
|
||||||
|
|
||||||
|
// AerePQCSocialRecoveryModule — quantum-durable M-of-N social recovery for AereModularAccount:
|
||||||
|
// each guardian is a NIST PQC key in AerePQCKeyRegistry, and a recovery is authorized by
|
||||||
|
// >= threshold DISTINCT guardians PQC-signing a domain-separated challenge. Repo contract +
|
||||||
|
// client; the module is not yet deployed to mainnet (see src/addresses.ts).
|
||||||
|
export {
|
||||||
|
AerePQCSocialRecoveryClient,
|
||||||
|
AERE_PQC_SOCIAL_RECOVERY_ABI,
|
||||||
|
PQC_RECOVERY_DOMAIN,
|
||||||
|
PQC_RECOVERY_DELAY_SECONDS,
|
||||||
|
PQC_MODULE_TYPE_EXECUTOR,
|
||||||
|
pqcRecoveryChallenge,
|
||||||
|
signPqcRecoveryLeg,
|
||||||
|
encodePqcRecoveryLegs,
|
||||||
|
encodePqcGuardianInstallData,
|
||||||
|
type PQCRecoveryLeg,
|
||||||
|
type PQCGuardianKey,
|
||||||
|
type PQCPendingRecovery,
|
||||||
|
} from './account/index.js';
|
||||||
|
|
||||||
|
// Non-custodial post-quantum t-of-n ERC-4337 account (contracts/mpc/AereThresholdAccount.sol).
|
||||||
|
export {
|
||||||
|
AereThresholdAccountClient,
|
||||||
|
createThresholdAccount,
|
||||||
|
signLeg,
|
||||||
|
encodeLegs,
|
||||||
|
userOpChallenge as thresholdUserOpChallenge,
|
||||||
|
execChallenge as thresholdExecChallenge,
|
||||||
|
USEROP_DOMAIN as THRESHOLD_USEROP_DOMAIN,
|
||||||
|
EXEC_DOMAIN as THRESHOLD_EXEC_DOMAIN,
|
||||||
|
AERE_THRESHOLD_ACCOUNT_ABI,
|
||||||
|
AERE_THRESHOLD_ACCOUNT_FACTORY_ABI,
|
||||||
|
type Leg,
|
||||||
|
type MemberKey,
|
||||||
|
} from './mpc/AereThresholdAccountClient.js';
|
||||||
|
|||||||
201
src/intents/AerePQCOrderBuilder.ts
Normal file
201
src/intents/AerePQCOrderBuilder.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
// AerePQCOrderBuilder — build and sign post-quantum-authorized ERC-7683 cross-chain
|
||||||
|
// orders for AereSpokePool.openForPQC (chain 2800).
|
||||||
|
//
|
||||||
|
// A PQC order is the same ERC-7683 gasless-order shape as the ECDSA path, except the
|
||||||
|
// authorization is a NIST post-quantum signature (Falcon-512/1024, ML-DSA-44,
|
||||||
|
// SLH-DSA-128s) over the EIP-712 order digest, verified on-chain by AERE's live native
|
||||||
|
// PQC precompiles routed through the governed AereCryptoRegistry. So the intent is
|
||||||
|
// quantum-durable end to end, and a Foundation scheme swap migrates the accepted scheme
|
||||||
|
// with NO redeploy (the on-chain resolveActive route does the migration).
|
||||||
|
//
|
||||||
|
// This module is PURE byte assembly + a signing call. The digest is computed byte-for-
|
||||||
|
// byte the way AerePQCOrderAuthorizer.orderDigest does (EIP-712, domain "AerePQCOrder"
|
||||||
|
// v1, verifyingContract = the SETTLER), so a signature this builder produces authorizes
|
||||||
|
// exactly the order the pool will open. Signing reuses the audited src/pqc signer, which
|
||||||
|
// returns the exact wire-format signature envelope the registry expects.
|
||||||
|
|
||||||
|
import {
|
||||||
|
AbiCoder, concat, getBytes, hexlify, keccak256, toUtf8Bytes, zeroPadValue,
|
||||||
|
type BytesLike,
|
||||||
|
} from 'ethers';
|
||||||
|
import { SCHEME, type SchemeId, signInternal, verifyLocal, keygen } from '../pqc/index.js';
|
||||||
|
|
||||||
|
const abi = AbiCoder.defaultAbiCoder();
|
||||||
|
|
||||||
|
/** The order-data discriminator AereSpokePool routes: keccak256("AereV3Order"). */
|
||||||
|
export const AERE_ORDER_DATA_TYPE = keccak256(toUtf8Bytes('AereV3Order'));
|
||||||
|
|
||||||
|
/** EIP-712 constants, identical to AerePQCOrderAuthorizer. */
|
||||||
|
const EIP712_DOMAIN_TYPEHASH = keccak256(
|
||||||
|
toUtf8Bytes('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
|
||||||
|
);
|
||||||
|
const DOMAIN_NAME = keccak256(toUtf8Bytes('AerePQCOrder'));
|
||||||
|
const DOMAIN_VERSION = keccak256(toUtf8Bytes('1'));
|
||||||
|
export const PQC_ORDER_TYPEHASH = keccak256(
|
||||||
|
toUtf8Bytes(
|
||||||
|
'AerePQCOrder(address user,uint256 nonce,uint256 originChainId,uint32 openDeadline,uint32 fillDeadline,uint256 algorithmId,bytes32 orderDataType,bytes32 orderDataHash,bytes32 pubKeyHash)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The AereV3Order intent payload carried in `orderData`. */
|
||||||
|
export interface AereV3OrderData {
|
||||||
|
inputToken: string;
|
||||||
|
inputAmount: bigint;
|
||||||
|
outputToken: string;
|
||||||
|
outputAmount: bigint;
|
||||||
|
/** 20-byte destination recipient address (encoded left-padded to bytes32). */
|
||||||
|
recipient: string;
|
||||||
|
destinationChainId: bigint | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PQCGaslessOrder the contract expects (struct field order preserved). */
|
||||||
|
export interface PQCGaslessOrder {
|
||||||
|
user: string;
|
||||||
|
nonce: bigint;
|
||||||
|
originChainId: bigint;
|
||||||
|
openDeadline: number;
|
||||||
|
fillDeadline: number;
|
||||||
|
/** requested crypto-agility registry id; 0 means "use the authorizer default". */
|
||||||
|
algorithmId: bigint;
|
||||||
|
orderDataType: string;
|
||||||
|
orderData: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inputs to assemble a PQCGaslessOrder. */
|
||||||
|
export interface BuildOrderParams {
|
||||||
|
user: string;
|
||||||
|
nonce: bigint | number;
|
||||||
|
originChainId: bigint | number;
|
||||||
|
openDeadline: number;
|
||||||
|
fillDeadline: number;
|
||||||
|
algorithmId?: bigint | number; // default 0 => authorizer default
|
||||||
|
order: AereV3OrderData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ABI-encode the AereV3Order `orderData` blob exactly as the pool decodes it. */
|
||||||
|
export function encodeAereV3OrderData(o: AereV3OrderData): string {
|
||||||
|
return abi.encode(
|
||||||
|
['address', 'uint256', 'address', 'uint256', 'bytes32', 'uint64'],
|
||||||
|
[o.inputToken, o.inputAmount, o.outputToken, o.outputAmount, zeroPadValue(o.recipient, 32), o.destinationChainId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assemble a {@link PQCGaslessOrder} (no signing) from high-level inputs. */
|
||||||
|
export function buildPQCGaslessOrder(p: BuildOrderParams): PQCGaslessOrder {
|
||||||
|
return {
|
||||||
|
user: p.user,
|
||||||
|
nonce: BigInt(p.nonce),
|
||||||
|
originChainId: BigInt(p.originChainId),
|
||||||
|
openDeadline: p.openDeadline,
|
||||||
|
fillDeadline: p.fillDeadline,
|
||||||
|
algorithmId: BigInt(p.algorithmId ?? 0),
|
||||||
|
orderDataType: AERE_ORDER_DATA_TYPE,
|
||||||
|
orderData: encodeAereV3OrderData(p.order),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the EIP-712 order digest (the 32-byte message the PQC key signs), byte-for-
|
||||||
|
* byte identical to AerePQCOrderAuthorizer.orderDigest.
|
||||||
|
* @param settler the consuming AereSpokePool address (EIP-712 verifyingContract).
|
||||||
|
* @param chainId the settler's chain id (block.chainid at verification time).
|
||||||
|
* @param order the PQCGaslessOrder.
|
||||||
|
* @param pubKey the authorized post-quantum public key.
|
||||||
|
*/
|
||||||
|
export function pqcOrderDigest(settler: string, chainId: bigint | number, order: PQCGaslessOrder, pubKey: BytesLike): string {
|
||||||
|
const domainSeparator = keccak256(
|
||||||
|
abi.encode(
|
||||||
|
['bytes32', 'bytes32', 'bytes32', 'uint256', 'address'],
|
||||||
|
[EIP712_DOMAIN_TYPEHASH, DOMAIN_NAME, DOMAIN_VERSION, BigInt(chainId), settler],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const structHash = keccak256(
|
||||||
|
abi.encode(
|
||||||
|
['bytes32', 'address', 'uint256', 'uint256', 'uint32', 'uint32', 'uint256', 'bytes32', 'bytes32', 'bytes32'],
|
||||||
|
[
|
||||||
|
PQC_ORDER_TYPEHASH,
|
||||||
|
order.user,
|
||||||
|
order.nonce,
|
||||||
|
order.originChainId,
|
||||||
|
order.openDeadline,
|
||||||
|
order.fillDeadline,
|
||||||
|
order.algorithmId,
|
||||||
|
order.orderDataType,
|
||||||
|
keccak256(order.orderData),
|
||||||
|
keccak256(hexlify(pubKey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return keccak256(concat(['0x1901', domainSeparator, structHash]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A signed PQC order, ready to submit to AereSpokePool.openForPQC(order, pubKey, signature). */
|
||||||
|
export interface SignedPQCOrder {
|
||||||
|
order: PQCGaslessOrder;
|
||||||
|
/** hex public key (the funder must have bound this via bindPQCKey). */
|
||||||
|
pubKey: string;
|
||||||
|
/** hex signature envelope in the registry wire format for `scheme`. */
|
||||||
|
signature: string;
|
||||||
|
/** the order digest that was signed. */
|
||||||
|
digest: string;
|
||||||
|
/** the scheme the signature was produced under. */
|
||||||
|
scheme: SchemeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pluggable signer over the order digest, for HSM / keystore custody. It receives the
|
||||||
|
* 32-byte digest and must return the registry wire-format signature envelope.
|
||||||
|
*/
|
||||||
|
export type PQCDigestSigner = (digest: Uint8Array) => Uint8Array | Promise<Uint8Array>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build AND sign a PQC order with a raw secret key using the audited src/pqc signer.
|
||||||
|
* Returns everything openForPQC needs. Locally verifies the envelope before returning
|
||||||
|
* (fail-fast: a malformed key or scheme mismatch throws here, not on-chain).
|
||||||
|
*/
|
||||||
|
export function buildAndSignPQCOrder(args: {
|
||||||
|
settler: string;
|
||||||
|
chainId: bigint | number;
|
||||||
|
scheme: SchemeId;
|
||||||
|
publicKey: BytesLike;
|
||||||
|
secretKey: BytesLike;
|
||||||
|
params: BuildOrderParams;
|
||||||
|
}): SignedPQCOrder {
|
||||||
|
const order = buildPQCGaslessOrder(args.params);
|
||||||
|
const pubKey = hexlify(args.publicKey);
|
||||||
|
const digest = pqcOrderDigest(args.settler, args.chainId, order, pubKey);
|
||||||
|
const digestBytes = getBytes(digest);
|
||||||
|
const sig = signInternal(args.scheme, digestBytes, getBytes(args.secretKey));
|
||||||
|
if (!verifyLocal(args.scheme, digestBytes, sig, getBytes(args.publicKey))) {
|
||||||
|
throw new Error('AerePQCOrderBuilder: locally-produced signature failed local verification');
|
||||||
|
}
|
||||||
|
return { order, pubKey, signature: hexlify(sig), digest, scheme: args.scheme };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a PQC order and sign its digest with a pluggable {@link PQCDigestSigner}
|
||||||
|
* (HSM / encrypted keystore). The signer must return a registry wire-format envelope.
|
||||||
|
*/
|
||||||
|
export async function buildAndSignPQCOrderWithSigner(args: {
|
||||||
|
settler: string;
|
||||||
|
chainId: bigint | number;
|
||||||
|
scheme: SchemeId;
|
||||||
|
publicKey: BytesLike;
|
||||||
|
sign: PQCDigestSigner;
|
||||||
|
params: BuildOrderParams;
|
||||||
|
}): Promise<SignedPQCOrder> {
|
||||||
|
const order = buildPQCGaslessOrder(args.params);
|
||||||
|
const pubKey = hexlify(args.publicKey);
|
||||||
|
const digest = pqcOrderDigest(args.settler, args.chainId, order, pubKey);
|
||||||
|
const sig = await args.sign(getBytes(digest));
|
||||||
|
return { order, pubKey, signature: hexlify(sig), digest, scheme: args.scheme };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: a fresh keypair for `scheme` (deterministic from `seed` if given). */
|
||||||
|
export function generateOrderKey(scheme: SchemeId, seed?: Uint8Array) {
|
||||||
|
return keygen(scheme, seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-export the scheme ids for callers building order-signing flows. */
|
||||||
|
export { SCHEME as PQC_SCHEME };
|
||||||
|
export type { SchemeId };
|
||||||
15
src/intents/index.ts
Normal file
15
src/intents/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// AERE PQC-authorized intents — Falcon/ML-DSA-signed ERC-7683 cross-chain orders for
|
||||||
|
// AereSpokePool.openForPQC. Quantum-durable authorization, verified on-chain by AERE's
|
||||||
|
// live native PQC precompiles routed through the governed crypto-agility registry, so
|
||||||
|
// scheme migration needs no redeploy. See AerePQCOrderBuilder for the digest + signer.
|
||||||
|
|
||||||
|
export {
|
||||||
|
AERE_ORDER_DATA_TYPE, PQC_ORDER_TYPEHASH, PQC_SCHEME,
|
||||||
|
encodeAereV3OrderData, buildPQCGaslessOrder, pqcOrderDigest,
|
||||||
|
buildAndSignPQCOrder, buildAndSignPQCOrderWithSigner, generateOrderKey,
|
||||||
|
} from './AerePQCOrderBuilder.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AereV3OrderData, PQCGaslessOrder, BuildOrderParams,
|
||||||
|
SignedPQCOrder, PQCDigestSigner,
|
||||||
|
} from './AerePQCOrderBuilder.js';
|
||||||
176
src/mpc/AereThresholdAccountClient.ts
Normal file
176
src/mpc/AereThresholdAccountClient.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// AereThresholdAccountClient — orchestration client for the on-chain AereThresholdAccount, a
|
||||||
|
// non-custodial ERC-4337 account owned by a t-of-n POST-QUANTUM committee. This client makes the
|
||||||
|
// account USABLE: it derives the exact domain-separated challenge the members must PQC-sign
|
||||||
|
// (mirroring the contract byte-for-byte), produces each member's authorizing leg from their PQC
|
||||||
|
// key (reusing the audited pqc envelope path), encodes the leg array, and submits either the
|
||||||
|
// direct self-relay `executeThreshold` call or an ERC-4337 signature blob. It never holds a key
|
||||||
|
// and never signs on a member's behalf beyond the key material the caller supplies.
|
||||||
|
//
|
||||||
|
// The challenge derivations here are cross-checked against the deployed contract in the repo's
|
||||||
|
// hardhat test (test/AereThresholdAccount.test.js) so SDK<->contract parity is proven, not assumed.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract,
|
||||||
|
AbiCoder,
|
||||||
|
keccak256,
|
||||||
|
toUtf8Bytes,
|
||||||
|
getBytes,
|
||||||
|
hexlify,
|
||||||
|
type Signer,
|
||||||
|
type Provider,
|
||||||
|
type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { SCHEME, type SchemeId } from '../pqc/envelope.js';
|
||||||
|
import { signInternal } from '../pqc/schemes.js';
|
||||||
|
|
||||||
|
const coder = AbiCoder.defaultAbiCoder();
|
||||||
|
|
||||||
|
/** Domain separators — MUST match AereThresholdAccount.sol exactly. Distinct domains guarantee a
|
||||||
|
* signature made for the UserOp path can never authorize the direct-exec path and vice versa. */
|
||||||
|
export const USEROP_DOMAIN = keccak256(toUtf8Bytes('AereThresholdAccount.v1.userop'));
|
||||||
|
export const EXEC_DOMAIN = keccak256(toUtf8Bytes('AereThresholdAccount.v1.exec'));
|
||||||
|
|
||||||
|
/** Default AERE chain id. */
|
||||||
|
export const AERE_CHAIN_ID = 2800n;
|
||||||
|
|
||||||
|
export const AERE_THRESHOLD_ACCOUNT_ABI = [
|
||||||
|
'function entryPoint() view returns (address)',
|
||||||
|
'function committee() view returns (uint8 scheme, uint8 threshold, uint8 size, bytes32 membersHash)',
|
||||||
|
'function execNonce() view returns (uint64)',
|
||||||
|
'function pubKeyAt(uint8 memberIndex) view returns (bytes)',
|
||||||
|
'function userOpChallenge(bytes32 userOpHash) view returns (bytes32)',
|
||||||
|
'function execChallenge(uint64 nonce, address target, uint256 value, bytes data) view returns (bytes32)',
|
||||||
|
'function executeThreshold(address target, uint256 value, bytes data, tuple(uint8 memberIndex, bytes signature)[] legs) returns (uint64 usedNonce)',
|
||||||
|
'function validateUserOp((address sender,uint256 nonce,bytes initCode,bytes callData,bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,bytes paymasterAndData,bytes signature) userOp, bytes32 userOpHash, uint256 missingAccountFunds) returns (uint256)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const AERE_THRESHOLD_ACCOUNT_FACTORY_ABI = [
|
||||||
|
'function createAccount(address entryPoint, uint8 scheme, uint8 threshold, bytes[] pubKeys, bytes32 salt) returns (address account)',
|
||||||
|
'function computeAddress(address entryPoint, uint8 scheme, uint8 threshold, bytes[] pubKeys, bytes32 salt) view returns (address account)',
|
||||||
|
'event AccountCreated(address indexed account, uint8 scheme, uint8 threshold, uint8 size, bytes32 salt)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** One authorizing leg: which committee member, and their PQC signature envelope over the challenge. */
|
||||||
|
export interface Leg {
|
||||||
|
memberIndex: number;
|
||||||
|
signature: string; // 0x-hex envelope in the scheme's on-chain encoding
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A committee member's signing material for producing a leg off-chain. */
|
||||||
|
export interface MemberKey {
|
||||||
|
memberIndex: number;
|
||||||
|
secretKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pure challenge derivations (mirror the contract) ────────────────────────
|
||||||
|
|
||||||
|
/** The exact 32-byte challenge each member must PQC-sign to authorize a UserOp. Mirrors
|
||||||
|
* AereThresholdAccount.userOpChallenge: keccak256(abi.encode(USEROP_DOMAIN, chainId, account, userOpHash)). */
|
||||||
|
export function userOpChallenge(account: string, userOpHash: string, chainId: bigint = AERE_CHAIN_ID): string {
|
||||||
|
return keccak256(coder.encode(['bytes32', 'uint256', 'address', 'bytes32'], [USEROP_DOMAIN, chainId, account, userOpHash]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The exact 32-byte challenge each member must PQC-sign to authorize a direct call at `nonce`.
|
||||||
|
* Mirrors AereThresholdAccount.execChallenge:
|
||||||
|
* keccak256(abi.encode(EXEC_DOMAIN, chainId, account, nonce, target, value, keccak256(data))). */
|
||||||
|
export function execChallenge(
|
||||||
|
account: string,
|
||||||
|
nonce: bigint,
|
||||||
|
target: string,
|
||||||
|
value: bigint,
|
||||||
|
data: string,
|
||||||
|
chainId: bigint = AERE_CHAIN_ID,
|
||||||
|
): string {
|
||||||
|
return keccak256(
|
||||||
|
coder.encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'uint64', 'address', 'uint256', 'bytes32'],
|
||||||
|
[EXEC_DOMAIN, chainId, account, nonce, target, value, keccak256(data)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── leg production + encoding ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Produce one member's authorizing leg by PQC-signing `challenge` with their secret key. The
|
||||||
|
* produced envelope is exactly what the on-chain precompile verifies (via the audited pqc path). */
|
||||||
|
export function signLeg(scheme: SchemeId, member: MemberKey, challenge: string): Leg {
|
||||||
|
const sig = signInternal(scheme, getBytes(challenge), member.secretKey);
|
||||||
|
return { memberIndex: member.memberIndex, signature: hexlify(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ABI-encode a leg array into the ERC-4337 `signature` blob (tuple(uint8,bytes)[]). */
|
||||||
|
export function encodeLegs(legs: Leg[]): string {
|
||||||
|
return coder.encode(['tuple(uint8 memberIndex, bytes signature)[]'], [legs.map((l) => [l.memberIndex, l.signature])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── client ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class AereThresholdAccountClient {
|
||||||
|
readonly contract: Contract;
|
||||||
|
private readonly chainId: bigint;
|
||||||
|
|
||||||
|
constructor(address: string, runner: Signer | Provider, chainId: bigint = AERE_CHAIN_ID) {
|
||||||
|
this.contract = new Contract(address, AERE_THRESHOLD_ACCOUNT_ABI, runner);
|
||||||
|
this.chainId = chainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get address(): Promise<string> {
|
||||||
|
return this.contract.getAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
async committee(): Promise<{ scheme: SchemeId; threshold: number; size: number; membersHash: string }> {
|
||||||
|
const [scheme, threshold, size, membersHash] = await this.contract.committee();
|
||||||
|
return { scheme: Number(scheme) as SchemeId, threshold: Number(threshold), size: Number(size), membersHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execNonce(): Promise<bigint> {
|
||||||
|
return this.contract.execNonce();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locally derive the exec challenge for the given call at the account's CURRENT execNonce. */
|
||||||
|
async execChallengeNow(target: string, value: bigint, data: string): Promise<{ nonce: bigint; challenge: string }> {
|
||||||
|
const nonce = await this.execNonce();
|
||||||
|
const account = await this.address;
|
||||||
|
return { nonce, challenge: execChallenge(account, nonce, target, value, data, this.chainId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** High-level: authorize + execute a single call directly (no bundler). Derives the challenge,
|
||||||
|
* signs it with each supplied member key, and submits `executeThreshold`. */
|
||||||
|
async authorizeAndExecute(
|
||||||
|
target: string,
|
||||||
|
value: bigint,
|
||||||
|
data: string,
|
||||||
|
scheme: SchemeId,
|
||||||
|
members: MemberKey[],
|
||||||
|
): Promise<ContractTransactionResponse> {
|
||||||
|
const { challenge } = await this.execChallengeNow(target, value, data);
|
||||||
|
const legs = members.map((m) => signLeg(scheme, m, challenge));
|
||||||
|
return this.contract.executeThreshold(target, value, data, legs.map((l) => [l.memberIndex, l.signature]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an ERC-4337 `signature` blob: derive the userOp challenge, sign with each member, encode. */
|
||||||
|
buildUserOpSignature(account: string, userOpHash: string, scheme: SchemeId, members: MemberKey[]): string {
|
||||||
|
const challenge = userOpChallenge(account, userOpHash, this.chainId);
|
||||||
|
const legs = members.map((m) => signLeg(scheme, m, challenge));
|
||||||
|
return encodeLegs(legs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deploy (or fetch) a threshold account via the factory, returning its address. */
|
||||||
|
export async function createThresholdAccount(
|
||||||
|
factoryAddress: string,
|
||||||
|
runner: Signer,
|
||||||
|
entryPoint: string,
|
||||||
|
scheme: SchemeId,
|
||||||
|
threshold: number,
|
||||||
|
pubKeys: string[],
|
||||||
|
salt: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const factory = new Contract(factoryAddress, AERE_THRESHOLD_ACCOUNT_FACTORY_ABI, runner);
|
||||||
|
const predicted: string = await factory.computeAddress(entryPoint, scheme, threshold, pubKeys, salt);
|
||||||
|
const tx: ContractTransactionResponse = await factory.createAccount(entryPoint, scheme, threshold, pubKeys, salt);
|
||||||
|
await tx.wait();
|
||||||
|
return predicted;
|
||||||
|
}
|
||||||
88
src/mpc/demo.ts
Normal file
88
src/mpc/demo.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// Runnable demo: node sdk-js/src/mpc/demo.ts
|
||||||
|
//
|
||||||
|
// 1. Runs a real 2-of-3 Feldman VSS DKG (verifies every dealt share).
|
||||||
|
// 2. Signs a message with a 2-of-3 threshold signature and recovers the signer
|
||||||
|
// address locally to prove it equals the DKG group address.
|
||||||
|
// 3. Shows that a below-threshold (1 share) combine recovers to the WRONG address.
|
||||||
|
// 4. Prints the production-signer honesty status.
|
||||||
|
|
||||||
|
import { runDkg, verifyShareAgainstCommitments } from "./dkg.js";
|
||||||
|
import { combineByReconstruction, productionSignerStatus } from "./sign.js";
|
||||||
|
import { addressFromPubKey64, bytesToBigInt, ecdsaSignDigest, hex } from "./secp.js";
|
||||||
|
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||||
|
import { keccak_256 } from "@noble/hashes/sha3";
|
||||||
|
|
||||||
|
function recoverAddr(digest: Uint8Array, sig: Uint8Array): string {
|
||||||
|
const r = sig.slice(0, 32);
|
||||||
|
const s = sig.slice(32, 64);
|
||||||
|
const v = sig[64];
|
||||||
|
const recovery = v - 27;
|
||||||
|
const rs = new Uint8Array(64);
|
||||||
|
rs.set(r, 0);
|
||||||
|
rs.set(s, 32);
|
||||||
|
const sigObj = secp256k1.Signature.fromCompact(rs).addRecoveryBit(recovery);
|
||||||
|
const pub = sigObj.recoverPublicKey(digest).toRawBytes(false).slice(1);
|
||||||
|
return addressFromPubKey64(pub);
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(s = "") {
|
||||||
|
console.log(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const N_MEMBERS = 3;
|
||||||
|
const T = 2;
|
||||||
|
|
||||||
|
line("=== AERE threshold-ECDSA reference demo (Feldman VSS DKG + demonstrator signer) ===");
|
||||||
|
line(`config: t-of-n = ${T}-of-${N_MEMBERS}`);
|
||||||
|
line("");
|
||||||
|
|
||||||
|
// 1) DKG
|
||||||
|
const dkg = runDkg({ n: N_MEMBERS, t: T });
|
||||||
|
line("[1] DKG complete. Every dealt share verified against Feldman commitments.");
|
||||||
|
line(` group pubkey (64B): ${hex(dkg.groupPubKey64)}`);
|
||||||
|
line(` group address : ${dkg.groupAddress}`);
|
||||||
|
line(` shares held by parties (private in real life):`);
|
||||||
|
for (const sh of dkg.shares) line(` party ${sh.index}: share=0x${sh.share.toString(16)}`);
|
||||||
|
// independent re-verification of each share against the AGGREGATE commitments
|
||||||
|
const allVerify = dkg.shares.every((s) => verifyShareAgainstCommitments(s.index, s.share, dkg.verificationCommitments));
|
||||||
|
line(` re-verify all shares vs aggregate commitments: ${allVerify ? "OK" : "FAIL"}`);
|
||||||
|
line("");
|
||||||
|
|
||||||
|
// 2) Threshold sign with parties {1,2}
|
||||||
|
const msg = "AERE B28 vault withdrawal: 12.5 AERE -> 0xVault, nonce 7";
|
||||||
|
const digest = keccak_256(new TextEncoder().encode(msg));
|
||||||
|
line(`[2] Threshold sign with parties {1,2} (t=${T}).`);
|
||||||
|
line(` message: "${msg}"`);
|
||||||
|
line(` digest : ${hex(digest)}`);
|
||||||
|
const signers = [dkg.shares[0], dkg.shares[1]];
|
||||||
|
const { signature } = combineByReconstruction(digest, signers, T);
|
||||||
|
line(` signature (r||s||v): ${hex(signature)}`);
|
||||||
|
const rec = recoverAddr(digest, signature);
|
||||||
|
line(` ecrecover -> ${rec}`);
|
||||||
|
line(` matches group address: ${rec.toLowerCase() === dkg.groupAddress.toLowerCase() ? "YES (verifies on-chain)" : "NO"}`);
|
||||||
|
line("");
|
||||||
|
|
||||||
|
// 2b) A different valid quorum {1,3} must recover to the SAME group address.
|
||||||
|
const signers2 = [dkg.shares[0], dkg.shares[2]];
|
||||||
|
const sig2 = combineByReconstruction(digest, signers2, T).signature;
|
||||||
|
const rec2 = recoverAddr(digest, sig2);
|
||||||
|
line(`[2b] Different quorum {1,3} -> ${rec2} (same group key: ${rec2.toLowerCase() === dkg.groupAddress.toLowerCase() ? "YES" : "NO"})`);
|
||||||
|
line("");
|
||||||
|
|
||||||
|
// 3) Below threshold: 1 share only -> WRONG key -> fails to match group address
|
||||||
|
line(`[3] Below-threshold attempt with only party {1} (need t=${T}).`);
|
||||||
|
const bad = combineByReconstruction(digest, [dkg.shares[0]], T);
|
||||||
|
const badAddr = recoverAddr(digest, bad.signature);
|
||||||
|
line(` ecrecover -> ${badAddr}`);
|
||||||
|
line(` matches group address: ${badAddr.toLowerCase() === dkg.groupAddress.toLowerCase() ? "YES (BUG!)" : "NO (correctly rejected on-chain)"}`);
|
||||||
|
line("");
|
||||||
|
|
||||||
|
// 4) honest production status
|
||||||
|
const st = productionSignerStatus();
|
||||||
|
line("[4] Production non-reconstructing signer (GG20/DKLs) implemented here: " + st.implementedHere);
|
||||||
|
line(" reason: " + st.reason);
|
||||||
|
for (const o of st.auditedOptions) line(` - ${o.name} (${o.language}): ${o.note}`);
|
||||||
|
line("");
|
||||||
|
line("=== demo done ===");
|
||||||
148
src/mpc/dkg.ts
Normal file
148
src/mpc/dkg.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// Feldman Verifiable Secret Sharing (VSS) Distributed Key Generation for t-of-n
|
||||||
|
// threshold secp256k1. This is the GENUINE, non-custodial part of the stack:
|
||||||
|
//
|
||||||
|
// * Every party i acts as a dealer: it samples a random degree-(t-1) polynomial
|
||||||
|
// f_i with constant term a_i0 (its secret contribution), publishes Feldman
|
||||||
|
// commitments C_i,k = a_i,k * G, and privately sends f_i(j) to every party j.
|
||||||
|
// * Each party j verifies each received share against the dealer's commitments:
|
||||||
|
// f_i(j) * G == sum_k C_i,k * j^k
|
||||||
|
// A cheating dealer is caught here (the check fails).
|
||||||
|
// * The group secret is x = sum_i a_i0 (the sum of all constant terms). NO party
|
||||||
|
// ever learns x: each party only sums the shares it received into x_j = sum_i f_i(j),
|
||||||
|
// which is that party's Shamir share of x on a degree-(t-1) polynomial.
|
||||||
|
// * The group public key is X = x*G = sum_i C_i,0, computable from PUBLIC commitments
|
||||||
|
// alone, so everyone agrees on X without anyone holding x.
|
||||||
|
//
|
||||||
|
// This module SIMULATES all n parties in one process to produce a reproducible demo
|
||||||
|
// and test vectors. In a real deployment each party runs its own dealer step and only
|
||||||
|
// its own share leaves the process; the math is identical. The security boundary of
|
||||||
|
// this reference lib is the SIGNING step (see sign.ts), not the DKG.
|
||||||
|
|
||||||
|
import { N, Point, type PointT, modN, invModN, randScalar, pointToPubKey64, pointToAddress } from "./secp.js";
|
||||||
|
|
||||||
|
export interface DkgConfig {
|
||||||
|
n: number; // total members
|
||||||
|
t: number; // threshold (>=t signers required to sign)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyShare {
|
||||||
|
index: number; // participant index i, 1..n (evaluation point, never 0)
|
||||||
|
share: bigint; // x_i = f(i), the party's Shamir share of the group secret
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DkgResult {
|
||||||
|
n: number;
|
||||||
|
t: number;
|
||||||
|
groupPoint: PointT; // X = x*G
|
||||||
|
groupPubKey64: Uint8Array; // 64-byte x||y
|
||||||
|
groupAddress: string; // ecrecover target used by AereThresholdRegistry
|
||||||
|
verificationCommitments: PointT[]; // aggregate commitments C_k = sum_i a_i,k * G, k=0..t-1
|
||||||
|
shares: PartyShare[]; // one Shamir share per party (kept private in real life)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluate polynomial (given coeffs a0..a_{t-1}) at x, mod N. */
|
||||||
|
function evalPoly(coeffs: bigint[], x: bigint): bigint {
|
||||||
|
let acc = 0n;
|
||||||
|
let xp = 1n;
|
||||||
|
for (const a of coeffs) {
|
||||||
|
acc = modN(acc + a * xp);
|
||||||
|
xp = modN(xp * x);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a Shamir share against Feldman commitments C_k: share*G == sum_k C_k * i^k. */
|
||||||
|
export function verifyShareAgainstCommitments(index: number, share: bigint, commitments: PointT[]): boolean {
|
||||||
|
const s = modN(share);
|
||||||
|
const lhs = s === 0n ? Point.ZERO : Point.BASE.multiply(s); // share*G
|
||||||
|
// rhs = sum_k C_k * i^k (i in 1..n, so i^k mod N is always in 1..N-1)
|
||||||
|
let rhs: PointT = Point.ZERO;
|
||||||
|
let ip = 1n;
|
||||||
|
const i = BigInt(index);
|
||||||
|
for (const Ck of commitments) {
|
||||||
|
rhs = rhs.add(Ck.multiply(ip)); // ip is never 0 for our indices
|
||||||
|
ip = modN(ip * i);
|
||||||
|
}
|
||||||
|
return lhs.equals(rhs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a full t-of-n Feldman VSS DKG simulating all n parties.
|
||||||
|
* Returns the group key plus each party's private Shamir share, and verifies every
|
||||||
|
* dealt share against its dealer's commitments (throws if any dealer cheated).
|
||||||
|
*/
|
||||||
|
export function runDkg(cfg: DkgConfig): DkgResult {
|
||||||
|
const { n, t } = cfg;
|
||||||
|
if (!(t >= 1 && t <= n && n >= 1 && n <= 255)) throw new Error("require 1<=t<=n<=255");
|
||||||
|
|
||||||
|
// Each party i (0..n-1) samples a degree-(t-1) polynomial: t coefficients.
|
||||||
|
const polys: bigint[][] = [];
|
||||||
|
const commitmentsPerParty: PointT[][] = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const coeffs: bigint[] = [];
|
||||||
|
for (let k = 0; k < t; k++) coeffs.push(randScalar());
|
||||||
|
polys.push(coeffs);
|
||||||
|
commitmentsPerParty.push(coeffs.map((a) => Point.BASE.multiply(a)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal + verify: party j receives f_i(j) from every dealer i and checks it.
|
||||||
|
for (let dealer = 0; dealer < n; dealer++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
const sh = evalPoly(polys[dealer], BigInt(j));
|
||||||
|
if (!verifyShareAgainstCommitments(j, sh, commitmentsPerParty[dealer])) {
|
||||||
|
throw new Error(`Feldman VSS check failed: dealer ${dealer} share to party ${j}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate: party j's group share x_j = sum_i f_i(j).
|
||||||
|
const shares: PartyShare[] = [];
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
let xj = 0n;
|
||||||
|
for (let dealer = 0; dealer < n; dealer++) xj = modN(xj + evalPoly(polys[dealer], BigInt(j)));
|
||||||
|
shares.push({ index: j, share: xj });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate commitments C_k = sum_i C_i,k. Group key X = C_0.
|
||||||
|
const aggCommit: PointT[] = [];
|
||||||
|
for (let k = 0; k < t; k++) {
|
||||||
|
let acc: PointT | null = null;
|
||||||
|
for (let i = 0; i < n; i++) acc = acc === null ? commitmentsPerParty[i][k] : acc.add(commitmentsPerParty[i][k]);
|
||||||
|
aggCommit.push(acc!);
|
||||||
|
}
|
||||||
|
const groupPoint = aggCommit[0];
|
||||||
|
|
||||||
|
// Sanity: every aggregate share must verify against the AGGREGATE commitments too.
|
||||||
|
for (const s of shares) {
|
||||||
|
if (!verifyShareAgainstCommitments(s.index, s.share, aggCommit)) {
|
||||||
|
throw new Error(`aggregate share ${s.index} fails aggregate commitments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
n,
|
||||||
|
t,
|
||||||
|
groupPoint,
|
||||||
|
groupPubKey64: pointToPubKey64(groupPoint),
|
||||||
|
groupAddress: pointToAddress(groupPoint),
|
||||||
|
verificationCommitments: aggCommit,
|
||||||
|
shares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lagrange coefficient l_i(0) for participant set `indices`, evaluated at x=0, mod N. */
|
||||||
|
export function lagrangeAtZero(indices: number[], i: number): bigint {
|
||||||
|
let num = 1n;
|
||||||
|
let den = 1n;
|
||||||
|
const xi = BigInt(i);
|
||||||
|
for (const jNum of indices) {
|
||||||
|
if (jNum === i) continue;
|
||||||
|
const xj = BigInt(jNum);
|
||||||
|
num = modN(num * xj);
|
||||||
|
den = modN(den * modN(xj - xi));
|
||||||
|
}
|
||||||
|
// num/den mod N
|
||||||
|
return modN(num * invModN(den));
|
||||||
|
}
|
||||||
15
src/mpc/index.ts
Normal file
15
src/mpc/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// AERE MPC/TSS reference: t-of-n threshold ECDSA (Feldman VSS DKG + demonstrator signer)
|
||||||
|
// producing signatures verifiable by contracts/mpc/AereThresholdRegistry.sol.
|
||||||
|
//
|
||||||
|
// Honest scope, in one line: the DKG is genuinely non-custodial; the SIGNER in this
|
||||||
|
// reference reconstructs the secret at a combiner (demonstrator/test-vector generator).
|
||||||
|
// Production non-reconstructing signing (GG20/DKLs) must come from an audited library.
|
||||||
|
// Threshold PQC (ML-DSA/Falcon) is NOT here and is research-stage. See docs/THRESHOLD_PQC.md.
|
||||||
|
|
||||||
|
export * from "./secp.js";
|
||||||
|
export * from "./dkg.js";
|
||||||
|
export * from "./sign.js";
|
||||||
|
// Non-custodial post-quantum t-of-n ERC-4337 account orchestration (contracts/mpc/AereThresholdAccount.sol).
|
||||||
|
export * from "./AereThresholdAccountClient.js";
|
||||||
107
src/mpc/secp.ts
Normal file
107
src/mpc/secp.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// Low-level secp256k1 helpers shared by the threshold-ECDSA DKG and signer.
|
||||||
|
// Thin, dependency-light wrapper over @noble/curves (audited, maintained) so the
|
||||||
|
// threshold logic reads clearly. Nothing here is AERE-specific; it is standard
|
||||||
|
// secp256k1 + Ethereum address derivation.
|
||||||
|
|
||||||
|
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||||
|
import { mod, invert } from "@noble/curves/abstract/modular";
|
||||||
|
import { keccak_256 } from "@noble/hashes/sha3";
|
||||||
|
|
||||||
|
export const N: bigint = secp256k1.CURVE.n; // group order
|
||||||
|
export const N_HALF: bigint = N >> 1n;
|
||||||
|
export const Point = secp256k1.ProjectivePoint;
|
||||||
|
export type PointT = InstanceType<typeof Point>;
|
||||||
|
|
||||||
|
/** A uniformly random non-zero scalar in [1, N-1]. */
|
||||||
|
export function randScalar(): bigint {
|
||||||
|
// randomPrivateKey() already returns a valid, in-range secp256k1 scalar.
|
||||||
|
return bytesToBigInt(secp256k1.utils.randomPrivateKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modN(x: bigint): bigint {
|
||||||
|
return mod(x, N);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invModN(x: bigint): bigint {
|
||||||
|
return invert(mod(x, N), N);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToBigInt(b: Uint8Array): bigint {
|
||||||
|
let x = 0n;
|
||||||
|
for (const byte of b) x = (x << 8n) | BigInt(byte);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bigIntTo32(x: bigint): Uint8Array {
|
||||||
|
const out = new Uint8Array(32);
|
||||||
|
let v = mod(x, 1n << 256n);
|
||||||
|
for (let i = 31; i >= 0; i--) {
|
||||||
|
out[i] = Number(v & 0xffn);
|
||||||
|
v >>= 8n;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hex(b: Uint8Array): string {
|
||||||
|
return "0x" + Buffer.from(b).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromHex(s: string): Uint8Array {
|
||||||
|
return Uint8Array.from(Buffer.from(s.replace(/^0x/, ""), "hex"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 64-byte uncompressed public key (x||y, no 0x04 prefix) for a point. */
|
||||||
|
export function pointToPubKey64(P: PointT): Uint8Array {
|
||||||
|
return P.toRawBytes(false).slice(1); // drop 0x04
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ethereum address (0x + 40 hex) from a point's group public key. */
|
||||||
|
export function pointToAddress(P: PointT): string {
|
||||||
|
const h = keccak_256(pointToPubKey64(P));
|
||||||
|
return "0x" + Buffer.from(h.slice(12)).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addressFromPubKey64(pub64: Uint8Array): string {
|
||||||
|
const h = keccak_256(pub64);
|
||||||
|
return "0x" + Buffer.from(h.slice(12)).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic-free ECDSA sign of a 32-byte digest by a scalar private key,
|
||||||
|
* returned as an Ethereum (r,s,v) 65-byte signature with s in the LOWER half and
|
||||||
|
* v in {27,28}. This is an ordinary ECDSA signature; a threshold signature has the
|
||||||
|
* exact same shape (see the threshold signer for how the same (r,s,v) is produced
|
||||||
|
* collaboratively). `k` may be supplied to make signing deterministic in tests.
|
||||||
|
*/
|
||||||
|
export function ecdsaSignDigest(digest: Uint8Array, priv: bigint, k?: bigint): Uint8Array {
|
||||||
|
const z = mod(bytesToBigInt(digest), N);
|
||||||
|
for (let attempt = 0; attempt < 64; attempt++) {
|
||||||
|
const kk = k ?? randScalar();
|
||||||
|
const R = Point.BASE.multiply(kk);
|
||||||
|
const Raff = R.toAffine();
|
||||||
|
const r = mod(Raff.x, N);
|
||||||
|
if (r === 0n) {
|
||||||
|
if (k) throw new Error("bad supplied k (r=0)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let s = modN(invModN(kk) * modN(z + r * modN(priv)));
|
||||||
|
if (s === 0n) {
|
||||||
|
if (k) throw new Error("bad supplied k (s=0)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// recovery id parity from R.y; flip when we normalize s to low-half.
|
||||||
|
let recovery = (Number(Raff.y & 1n) & 1) | (r !== Raff.x ? 2 : 0);
|
||||||
|
if (s > N_HALF) {
|
||||||
|
s = N - s;
|
||||||
|
recovery ^= 1;
|
||||||
|
}
|
||||||
|
const sig = new Uint8Array(65);
|
||||||
|
sig.set(bigIntTo32(r), 0);
|
||||||
|
sig.set(bigIntTo32(s), 32);
|
||||||
|
sig[64] = 27 + (recovery & 1);
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
throw new Error("ecdsa sign failed to find r,s");
|
||||||
|
}
|
||||||
95
src/mpc/sign.ts
Normal file
95
src/mpc/sign.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// Threshold-ECDSA SIGNING for the AERE reference stack.
|
||||||
|
//
|
||||||
|
// =========================== READ THIS, IT IS THE HONEST BOUNDARY ===========================
|
||||||
|
// The DKG (dkg.ts) is genuinely non-custodial: no party ever holds the group secret x.
|
||||||
|
// SIGNING standard ECDSA from Shamir shares WITHOUT reconstructing x is the hard part of
|
||||||
|
// threshold ECDSA, because s = k^{-1}(z + r*x) requires multiplying two independently shared
|
||||||
|
// secrets (k and x). Doing that securely needs a Multiplicative-to-Additive (MtA) sub-protocol
|
||||||
|
// with Paillier or OT plus range/consistency ZK proofs (GG18/GG20/CGGMP21) or an OT-based
|
||||||
|
// approach (DKLs18/DKLs23). Those are ~thousands of lines of audited protocol and are NOT
|
||||||
|
// reimplemented here.
|
||||||
|
//
|
||||||
|
// This reference offers TWO signing functions with DIFFERENT, clearly-stated trust models:
|
||||||
|
//
|
||||||
|
// 1. combineByReconstruction(...) -- DEMONSTRATOR / TEST-VECTOR GENERATOR.
|
||||||
|
// A designated combiner collects t shares, Lagrange-interpolates the group secret x at
|
||||||
|
// the combiner, and signs a normal ECDSA signature. This TEMPORARILY reconstructs x at
|
||||||
|
// ONE machine. It is perfect for: proving the DKG key is correct, generating signatures
|
||||||
|
// the on-chain AereThresholdRegistry verifies, and demonstrating the t-of-n threshold
|
||||||
|
// (t-1 shares interpolate to the WRONG x and the signature fails). It is NOT the
|
||||||
|
// production non-custodial signing path -- do not run it in production custody.
|
||||||
|
//
|
||||||
|
// 2. productionSignerStatus() -- returns the honest "not implemented here" status and the
|
||||||
|
// pointer to audited production libraries (see docs/THRESHOLD_PQC.md and the MPC notes).
|
||||||
|
//
|
||||||
|
// On-chain, the output of (1) is byte-for-byte identical to a real GG20 threshold signature:
|
||||||
|
// both are (r,s,v) recovering to the group address. The registry cannot tell them apart, which
|
||||||
|
// is exactly why the "t participated" guarantee must come from the off-chain protocol, not the
|
||||||
|
// chain (documented on AereThresholdRegistry).
|
||||||
|
// ============================================================================================
|
||||||
|
|
||||||
|
import { N, N_HALF, Point, modN, invModN, bigIntTo32, bytesToBigInt, ecdsaSignDigest } from "./secp.js";
|
||||||
|
import { type PartyShare, lagrangeAtZero } from "./dkg.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEMONSTRATOR signer. Reconstruct the group secret from t shares (Lagrange at 0) and
|
||||||
|
* produce a standard Ethereum ECDSA signature (r,s,v), low-s, over `digest`.
|
||||||
|
*
|
||||||
|
* @param digest 32-byte message digest (already domain-separated by the caller).
|
||||||
|
* @param shares Exactly-or-more-than t PartyShare objects from the same DKG.
|
||||||
|
* @param t The committee threshold.
|
||||||
|
* @param k Optional fixed nonce for deterministic tests.
|
||||||
|
* @returns { signature (65 bytes), reconstructedPriv } -- reconstructedPriv is exposed ONLY
|
||||||
|
* because this is a demonstrator; production signing never yields it.
|
||||||
|
*/
|
||||||
|
export function combineByReconstruction(
|
||||||
|
digest: Uint8Array,
|
||||||
|
shares: PartyShare[],
|
||||||
|
t: number,
|
||||||
|
k?: bigint,
|
||||||
|
): { signature: Uint8Array; reconstructedPriv: bigint } {
|
||||||
|
// Interpolate over EXACTLY the shares provided. With >= t shares from the same DKG this
|
||||||
|
// recovers the true group secret; with < t shares it recovers a DIFFERENT value (the
|
||||||
|
// "below-threshold" negative case), whose signature will not recover to the group address.
|
||||||
|
void t;
|
||||||
|
const x = reconstructSecret(shares);
|
||||||
|
const signature = ecdsaSignDigest(digest, x, k);
|
||||||
|
return { signature, reconstructedPriv: x };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct the group secret from a chosen subset (any size). Used by tests to show that
|
||||||
|
* an UNDER-threshold subset interpolates to a different secret than the true t-of-n key.
|
||||||
|
*/
|
||||||
|
export function reconstructSecret(shares: PartyShare[]): bigint {
|
||||||
|
const indices = shares.map((s) => s.index);
|
||||||
|
let x = 0n;
|
||||||
|
for (const s of shares) {
|
||||||
|
x = modN(x + modN(s.share * lagrangeAtZero(indices, s.index)));
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductionSignerStatus {
|
||||||
|
implementedHere: false;
|
||||||
|
reason: string;
|
||||||
|
auditedOptions: { name: string; language: string; note: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productionSignerStatus(): ProductionSignerStatus {
|
||||||
|
return {
|
||||||
|
implementedHere: false,
|
||||||
|
reason:
|
||||||
|
"Non-reconstructing threshold-ECDSA signing (GG20/CGGMP21 MtA or DKLs23 OT) is a large, " +
|
||||||
|
"security-critical protocol and is intentionally not reimplemented in this reference. " +
|
||||||
|
"Use an audited library and have this contract verify its (r,s,v) output.",
|
||||||
|
auditedOptions: [
|
||||||
|
{ name: "bnb-chain/tss-lib", language: "Go", note: "GG18/GG20 ECDSA + EdDSA, widely deployed." },
|
||||||
|
{ name: "ZenGo-X/multi-party-ecdsa", language: "Rust", note: "GG18/GG20; also DKLs in silence-labs forks." },
|
||||||
|
{ name: "silence-laboratories/dkls23", language: "Rust", note: "DKLs23 OT-based threshold ECDSA (2-round sign)." },
|
||||||
|
{ name: "taurusgroup/multi-party-sig", language: "Go", note: "CMP/CGGMP21 with identifiable abort." },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
327
src/pqc/AereAgentActionReceiptClient.ts
Normal file
327
src/pqc/AereAgentActionReceiptClient.ts
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
// AereAgentActionReceiptClient — ethers v6 wrapper over AereAgentActionReceipt
|
||||||
|
// (chain 2800): PQC-anchored, publicly verifiable AI provenance.
|
||||||
|
//
|
||||||
|
// An AI agent commits the HASH of an output it produced, signed by a short-lived
|
||||||
|
// secp256k1 SESSION key that AereAgentDID issued under a quantum-durable Falcon
|
||||||
|
// ROOT (Falcon PoP verified on-chain by AERE's live native precompile). The result
|
||||||
|
// is an append-only provenance record anyone can re-verify in ONE read, cross-wired
|
||||||
|
// to the agent's AereAgentBond stake + AereAIReputation score / slashing history.
|
||||||
|
//
|
||||||
|
// - emitReceipt(...): permissionless relay — the SESSION SIGNATURE is the authority.
|
||||||
|
// The session key signs the receipt digest; msg.sender only pays gas.
|
||||||
|
// - verifyReceipt / verifyReceiptOutput: re-check a stored receipt cryptographically.
|
||||||
|
// - provenanceOf: identity + validity + live economic accountability in one call.
|
||||||
|
//
|
||||||
|
// Receipt digest (read straight from the contract; domain- and chain-bound):
|
||||||
|
// receipt digest = keccak256(abi.encode(
|
||||||
|
// RECEIPT_DOMAIN, chainid, address(this), agentId, sessionId, outputHash, scope, nonce))
|
||||||
|
// RECEIPT_DOMAIN = keccak256("AereAgentActionReceipt.v1.receipt")
|
||||||
|
//
|
||||||
|
// The session-key signature is a raw secp256k1 signature over the receipt digest
|
||||||
|
// (ethers SigningKey), matching the contract's ecrecover on the raw digest — NOT an
|
||||||
|
// eth_sign personal-message prefix. This is the SAME session key AereAgentDID's
|
||||||
|
// authorize() uses, so an agent signs both surfaces with one key.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify, zeroPadValue, toBeHex,
|
||||||
|
SigningKey, Signature,
|
||||||
|
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../addresses.js';
|
||||||
|
|
||||||
|
/** Minimal inline ABI — only the AereAgentActionReceipt surface this client uses. */
|
||||||
|
export const AERE_AGENT_ACTION_RECEIPT_ABI = [
|
||||||
|
// write
|
||||||
|
'function emitReceipt(uint256 sessionId, bytes32 scope, bytes32 outputHash, bytes sig) returns (uint256 receiptId)',
|
||||||
|
// digest / cross-wire views
|
||||||
|
'function receiptDigest(uint256 agentId, uint256 sessionId, bytes32 outputHash, bytes32 scope, uint64 nonce) view returns (bytes32)',
|
||||||
|
'function receiptNonceOf(uint256 sessionId) view returns (uint64)',
|
||||||
|
'function bondKeyOf(uint256 rootKeyId) pure returns (bytes32)',
|
||||||
|
'function operatorOf(uint256 rootKeyId) view returns (address)',
|
||||||
|
// verification views
|
||||||
|
'function verifyReceipt(uint256 receiptId) view returns (bool sigValid, bool sessionLiveNow, uint256 agentId, address signer, bytes32 outputHash)',
|
||||||
|
'function verifyReceiptOutput(uint256 receiptId, bytes32 outputHash) view returns (bool)',
|
||||||
|
'function provenanceOf(uint256 receiptId) view returns (uint256 agentId, address operator, address signer, bytes32 outputHash, bytes32 scope, uint64 emittedAt, bool sigValid, bool sessionLiveNow, uint256 reputationScore, uint256 bondAmount, uint256 bondLifetimeSlashed, uint256 operatorTotalSlashed)',
|
||||||
|
// record views
|
||||||
|
'function receiptCount() view returns (uint256)',
|
||||||
|
'function getReceipt(uint256 receiptId) view returns (uint256 agentId, uint256 sessionId, address signer, bytes32 outputHash, bytes32 scope, uint64 nonce, uint64 emittedBlock, uint64 emittedAt, bytes sig)',
|
||||||
|
'function receiptsOf(uint256 rootKeyId) view returns (uint256[])',
|
||||||
|
'function receiptsOfSession(uint256 sessionId) view returns (uint256[])',
|
||||||
|
// wiring views
|
||||||
|
'function did() view returns (address)',
|
||||||
|
'function agentBond() view returns (address)',
|
||||||
|
'function aiReputation() view returns (address)',
|
||||||
|
// event
|
||||||
|
'event ReceiptEmitted(uint256 indexed receiptId, uint256 indexed agentId, uint256 indexed sessionId, address signer, bytes32 outputHash, bytes32 scope, uint64 nonce)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** keccak256("AereAgentActionReceipt.v1.receipt"). */
|
||||||
|
export const RECEIPT_DOMAIN = keccak256(toUtf8Bytes('AereAgentActionReceipt.v1.receipt'));
|
||||||
|
|
||||||
|
export interface AereAgentActionReceiptClientOptions {
|
||||||
|
/** AereAgentActionReceipt address (required — not yet in the mainnet address book). */
|
||||||
|
address: string;
|
||||||
|
/** Chain id used in local digest derivation. Default: 2800. */
|
||||||
|
chainId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptRecord {
|
||||||
|
agentId: bigint;
|
||||||
|
sessionId: bigint;
|
||||||
|
signer: string;
|
||||||
|
outputHash: string;
|
||||||
|
scope: string;
|
||||||
|
nonce: bigint;
|
||||||
|
emittedBlock: bigint;
|
||||||
|
emittedAt: bigint;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptVerification {
|
||||||
|
sigValid: boolean;
|
||||||
|
sessionLiveNow: boolean;
|
||||||
|
agentId: bigint;
|
||||||
|
signer: string;
|
||||||
|
outputHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptProvenance {
|
||||||
|
agentId: bigint;
|
||||||
|
operator: string;
|
||||||
|
signer: string;
|
||||||
|
outputHash: string;
|
||||||
|
scope: string;
|
||||||
|
emittedAt: bigint;
|
||||||
|
sigValid: boolean;
|
||||||
|
sessionLiveNow: boolean;
|
||||||
|
reputationScore: bigint;
|
||||||
|
bondAmount: bigint;
|
||||||
|
bondLifetimeSlashed: bigint;
|
||||||
|
operatorTotalSlashed: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReceiptSignature {
|
||||||
|
/** The 32-byte receipt digest the session key signed. */
|
||||||
|
digest: string;
|
||||||
|
/** The per-session receipt nonce the digest was derived at. */
|
||||||
|
nonce: bigint;
|
||||||
|
/** 65-byte secp256k1 signature (r||s||v) by the session key (pass as `sig` to emitReceipt). */
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmitReceiptResult extends ReceiptSignature {
|
||||||
|
tx: ContractTransactionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(v: BytesLike): Uint8Array {
|
||||||
|
return v instanceof Uint8Array ? v : getBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes32(v: BytesLike): string {
|
||||||
|
const b = toBytes(v);
|
||||||
|
if (b.length !== 32) throw new Error(`AereAgentActionReceiptClient: expected 32-byte value, got ${b.length}`);
|
||||||
|
return hexlify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AereAgentActionReceiptClient {
|
||||||
|
readonly address: string;
|
||||||
|
readonly chainId: number;
|
||||||
|
readonly contract: Contract;
|
||||||
|
|
||||||
|
constructor(runner: ContractRunner, opts: AereAgentActionReceiptClientOptions) {
|
||||||
|
if (!opts?.address) {
|
||||||
|
throw new Error('AereAgentActionReceiptClient: an explicit `address` is required (contract is not in the mainnet address book).');
|
||||||
|
}
|
||||||
|
this.address = opts.address;
|
||||||
|
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||||
|
this.contract = new Contract(this.address, AERE_AGENT_ACTION_RECEIPT_ABI, runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- digest derivation ----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local mirror of AereAgentActionReceipt._receiptDigest — what a session key signs:
|
||||||
|
* keccak256(abi.encode(RECEIPT_DOMAIN, chainid, address(this), agentId, sessionId,
|
||||||
|
* outputHash, scope, nonce))
|
||||||
|
* Cross-check with {@link receiptDigest} on-chain.
|
||||||
|
*/
|
||||||
|
deriveReceiptDigest(
|
||||||
|
agentId: bigint | number,
|
||||||
|
sessionId: bigint | number,
|
||||||
|
outputHash: BytesLike,
|
||||||
|
scope: BytesLike,
|
||||||
|
nonce: bigint | number,
|
||||||
|
): string {
|
||||||
|
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'uint256', 'uint256', 'bytes32', 'bytes32', 'uint64'],
|
||||||
|
[RECEIPT_DOMAIN, this.chainId, this.address, agentId, sessionId, bytes32(outputHash), bytes32(scope), nonce],
|
||||||
|
);
|
||||||
|
return keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canonical AereAgentBond / AereAIReputation agentId for a DID rootKeyId
|
||||||
|
* (bytes32(rootKeyId)). Pure mirror of the contract's {bondKeyOf} — no RPC. */
|
||||||
|
static bondKeyOf(rootKeyId: bigint | number): string {
|
||||||
|
return zeroPadValue(toBeHex(BigInt(rootKeyId)), 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain receipt digest for (agentId, sessionId, outputHash, scope, nonce). */
|
||||||
|
async receiptDigest(
|
||||||
|
agentId: bigint | number,
|
||||||
|
sessionId: bigint | number,
|
||||||
|
outputHash: BytesLike,
|
||||||
|
scope: BytesLike,
|
||||||
|
nonce: bigint | number,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.contract.receiptDigest(agentId, sessionId, bytes32(outputHash), bytes32(scope), nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next receipt nonce for a session (the value the next emit will sign). */
|
||||||
|
async receiptNonceOf(sessionId: bigint | number): Promise<bigint> {
|
||||||
|
return this.contract.receiptNonceOf(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canonical bond/reputation agentId for a DID rootKeyId (on-chain view). */
|
||||||
|
async bondKeyOf(rootKeyId: bigint | number): Promise<string> {
|
||||||
|
return this.contract.bondKeyOf(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator (bond/reputation key) this contract reads for an agent (DID controller). */
|
||||||
|
async operatorOf(rootKeyId: bigint | number): Promise<string> {
|
||||||
|
return this.contract.operatorOf(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- construction path (no tx) --------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the session-key signature for a receipt at an explicit nonce, WITHOUT
|
||||||
|
* sending a transaction. Derives the receipt digest locally and signs it with the
|
||||||
|
* raw secp256k1 session private key (SigningKey over the raw digest, matching the
|
||||||
|
* contract's ecrecover). The serialized signature is canonical low-s, v in {27,28}.
|
||||||
|
*/
|
||||||
|
buildReceiptSignature(
|
||||||
|
agentId: bigint | number,
|
||||||
|
sessionId: bigint | number,
|
||||||
|
outputHash: BytesLike,
|
||||||
|
scope: BytesLike,
|
||||||
|
nonce: bigint | number,
|
||||||
|
sessionPrivateKey: BytesLike,
|
||||||
|
): ReceiptSignature {
|
||||||
|
const digest = this.deriveReceiptDigest(agentId, sessionId, outputHash, scope, nonce);
|
||||||
|
const sk = new SigningKey(hexlify(toBytes(sessionPrivateKey)));
|
||||||
|
const sig: Signature = sk.sign(digest);
|
||||||
|
return { digest, nonce: BigInt(nonce), signature: sig.serialized };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- verification / record views ------------------------------------------
|
||||||
|
|
||||||
|
/** Total receipts ever recorded (receiptIds run 0..receiptCount-1). */
|
||||||
|
async receiptCount(): Promise<bigint> {
|
||||||
|
return this.contract.receiptCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a receipt. Reverts (UnknownReceipt) on an out-of-range id. */
|
||||||
|
async getReceipt(receiptId: bigint | number): Promise<ReceiptRecord> {
|
||||||
|
const [agentId, sessionId, signer, outputHash, scope, nonce, emittedBlock, emittedAt, sig] =
|
||||||
|
await this.contract.getReceipt(receiptId);
|
||||||
|
return { agentId, sessionId, signer, outputHash, scope, nonce, emittedBlock, emittedAt, sig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independently re-verify a stored receipt in one read: re-derives the digest from
|
||||||
|
* the stored fields, recovers the signer from the stored signature, and reports
|
||||||
|
* whether it matches the recorded session key (sigValid) and whether that session is
|
||||||
|
* still live now (sessionLiveNow). A receipt stays genuine forever; sessionLiveNow
|
||||||
|
* tells you if the agent has since been revoked / expired / rotated away.
|
||||||
|
*/
|
||||||
|
async verifyReceipt(receiptId: bigint | number): Promise<ReceiptVerification> {
|
||||||
|
const [sigValid, sessionLiveNow, agentId, signer, outputHash] = await this.contract.verifyReceipt(receiptId);
|
||||||
|
return { sigValid, sessionLiveNow, agentId, signer, outputHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a receipt attests EXACTLY `outputHash` and its stored signature is genuine.
|
||||||
|
* The tamper check at read time: pass the hash of the output you hold; a byte-different
|
||||||
|
* output yields a different hash and returns false. Returns false (no revert) for an
|
||||||
|
* out-of-range receiptId.
|
||||||
|
*/
|
||||||
|
async verifyReceiptOutput(receiptId: bigint | number, outputHash: BytesLike): Promise<boolean> {
|
||||||
|
return this.contract.verifyReceiptOutput(receiptId, bytes32(outputHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full provenance in one call: identity + validity + the agent's live economic
|
||||||
|
* accountability (AereAgentBond stake, AereAIReputation score, slashing history) read
|
||||||
|
* at the canonical cross-wire key. Reverts (UnknownReceipt) on an out-of-range id.
|
||||||
|
*/
|
||||||
|
async provenanceOf(receiptId: bigint | number): Promise<ReceiptProvenance> {
|
||||||
|
const [
|
||||||
|
agentId, operator, signer, outputHash, scope, emittedAt, sigValid, sessionLiveNow,
|
||||||
|
reputationScore, bondAmount, bondLifetimeSlashed, operatorTotalSlashed,
|
||||||
|
] = await this.contract.provenanceOf(receiptId);
|
||||||
|
return {
|
||||||
|
agentId, operator, signer, outputHash, scope, emittedAt, sigValid, sessionLiveNow,
|
||||||
|
reputationScore, bondAmount, bondLifetimeSlashed, operatorTotalSlashed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All receiptIds authored by an agent (append-only, chronological). */
|
||||||
|
async receiptsOf(rootKeyId: bigint | number): Promise<bigint[]> {
|
||||||
|
return this.contract.receiptsOf(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All receiptIds recorded under a session (append-only, chronological). */
|
||||||
|
async receiptsOfSession(sessionId: bigint | number): Promise<bigint[]> {
|
||||||
|
return this.contract.receiptsOfSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The AereAgentDID this receipt contract roots session verification into. */
|
||||||
|
async did(): Promise<string> {
|
||||||
|
return this.contract.did();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writes (require a Signer runner) --------------------------------------
|
||||||
|
|
||||||
|
/** Emit a receipt with a pre-built session-key signature. */
|
||||||
|
async emitReceipt(
|
||||||
|
sessionId: bigint | number,
|
||||||
|
scope: BytesLike,
|
||||||
|
outputHash: BytesLike,
|
||||||
|
sig: BytesLike,
|
||||||
|
): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.emitReceipt(sessionId, bytes32(scope), bytes32(outputHash), hexlify(toBytes(sig)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level: read the session's current receipt nonce on-chain, derive the receipt
|
||||||
|
* digest locally (cross-checked against the on-chain view), sign it with the secp256k1
|
||||||
|
* session key, and submit emitReceipt via `relaySigner`. `agentId` is the session's
|
||||||
|
* Falcon rootKeyId (read it from AereAgentDID.getSession). The session key signs the
|
||||||
|
* raw digest; the relayer only pays gas. `sessionPrivateKey` never leaves this process.
|
||||||
|
*/
|
||||||
|
async emitReceiptWithSessionKey(
|
||||||
|
relaySigner: Signer,
|
||||||
|
agentId: bigint | number,
|
||||||
|
sessionId: bigint | number,
|
||||||
|
scope: BytesLike,
|
||||||
|
outputHash: BytesLike,
|
||||||
|
sessionPrivateKey: BytesLike,
|
||||||
|
verifyDigestOnChain = true,
|
||||||
|
): Promise<EmitReceiptResult> {
|
||||||
|
const nonce = await this.receiptNonceOf(sessionId);
|
||||||
|
const built = this.buildReceiptSignature(agentId, sessionId, outputHash, scope, nonce, sessionPrivateKey);
|
||||||
|
|
||||||
|
if (verifyDigestOnChain) {
|
||||||
|
const onChain = await this.receiptDigest(agentId, sessionId, outputHash, scope, nonce);
|
||||||
|
if (onChain.toLowerCase() !== built.digest.toLowerCase()) {
|
||||||
|
throw new Error(`AereAgentActionReceiptClient: local receipt digest ${built.digest} != on-chain ${onChain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = this.contract.connect(relaySigner) as Contract;
|
||||||
|
const tx: ContractTransactionResponse = await c.emitReceipt(
|
||||||
|
sessionId, bytes32(scope), bytes32(outputHash), built.signature,
|
||||||
|
);
|
||||||
|
return { tx, digest: built.digest, nonce, signature: built.signature };
|
||||||
|
}
|
||||||
|
}
|
||||||
365
src/pqc/AereAgentDIDClient.ts
Normal file
365
src/pqc/AereAgentDIDClient.ts
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
// AereAgentDIDClient — ethers v6 wrapper over AereAgentDID (chain 2800).
|
||||||
|
//
|
||||||
|
// AereAgentDID (0xce641d7d7C10553D82b06B7C21d423550e7522C5) is a decentralized
|
||||||
|
// identity for AI agents whose ROOT authority is a quantum-durable Falcon key held
|
||||||
|
// in AerePQCKeyRegistry, and whose day-to-day work is signed by cheap, short-lived,
|
||||||
|
// revocable secp256k1 SESSION keys.
|
||||||
|
//
|
||||||
|
// - createAgent(rootKeyId): open a DID for an ACTIVE Falcon root key (registry owner only).
|
||||||
|
// - issueSession(...): Falcon-PoP-gated — a Falcon signature by the root key over the
|
||||||
|
// session issuance challenge authorizes a new secp256k1 session (scope + spend cap + expiry).
|
||||||
|
// - authorize(...): the hot path — a secp256k1 signature by the session key over the
|
||||||
|
// action digest, enforced by ecrecover (~3k gas).
|
||||||
|
//
|
||||||
|
// Two challenge preimages (read straight from the contract), both domain- and
|
||||||
|
// chain-bound:
|
||||||
|
// session challenge = keccak256(abi.encode(
|
||||||
|
// SESSION_DOMAIN, chainid, address(this), rootKeyId, nonce, sessionAddr,
|
||||||
|
// scopeHash, spendCap, expiry))
|
||||||
|
// action digest = keccak256(abi.encode(
|
||||||
|
// ACTION_DOMAIN, chainid, address(this), sessionId, scope, actionHash, amount, actionNonce))
|
||||||
|
// SESSION_DOMAIN = keccak256("AereAgentDID.v1.issueSession")
|
||||||
|
// ACTION_DOMAIN = keccak256("AereAgentDID.v1.action")
|
||||||
|
//
|
||||||
|
// The Falcon proof-of-possession for issueSession is built with the src/pqc signer
|
||||||
|
// (schemes.signInternal + envelope.ts) — REUSED, never reimplemented. The session-key
|
||||||
|
// action signature is a raw secp256k1 signature over the action digest (ethers SigningKey).
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify, SigningKey, Signature,
|
||||||
|
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../addresses.js';
|
||||||
|
import { SCHEME, type SchemeId, signInternal } from './schemes.js';
|
||||||
|
|
||||||
|
/** Minimal inline ABI — only the AereAgentDID surface this client uses. */
|
||||||
|
export const AERE_AGENT_DID_ABI = [
|
||||||
|
// writes
|
||||||
|
'function createAgent(uint256 rootKeyId)',
|
||||||
|
'function issueSession(uint256 rootKeyId, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry, bytes popSig) returns (uint256 sessionId)',
|
||||||
|
'function revokeSession(uint256 sessionId)',
|
||||||
|
'function authorize(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, bytes sig) returns (bool)',
|
||||||
|
// challenge / digest views
|
||||||
|
'function sessionChallenge(uint256 rootKeyId, uint64 nonce, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry) view returns (bytes32)',
|
||||||
|
'function currentSessionChallenge(uint256 rootKeyId, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry) view returns (bytes32)',
|
||||||
|
'function actionDigest(uint256 sessionId, bytes32 scope, bytes32 actionHash, uint256 amount, uint64 actionNonce) view returns (bytes32)',
|
||||||
|
// agent / session views
|
||||||
|
'function getAgent(uint256 rootKeyId) view returns (address controller, uint64 createdBlock, uint64 sessionNonce, uint256 sessionCountForAgent)',
|
||||||
|
'function agentExists(uint256 rootKeyId) view returns (bool)',
|
||||||
|
'function sessionCount() view returns (uint256)',
|
||||||
|
'function getSession(uint256 sessionId) view returns (uint256 rootKeyId, address sessionAddr, bytes32 scopeHash, uint256 spendCap, uint256 spent, uint64 issuedBlock, uint64 expiry, uint64 actionNonce, bool revoked)',
|
||||||
|
'function isSessionValid(uint256 sessionId) view returns (bool)',
|
||||||
|
'function remainingSpend(uint256 sessionId) view returns (uint256)',
|
||||||
|
'function sessionsOf(uint256 rootKeyId) view returns (uint256[])',
|
||||||
|
// wiring views
|
||||||
|
'function keyRegistry() view returns (address)',
|
||||||
|
'function agentBond() view returns (address)',
|
||||||
|
'function aiReputation() view returns (address)',
|
||||||
|
// events
|
||||||
|
'event AgentCreated(uint256 indexed rootKeyId, address indexed controller, uint8 rootScheme)',
|
||||||
|
'event SessionIssued(uint256 indexed sessionId, uint256 indexed rootKeyId, address indexed sessionAddr, bytes32 scopeHash, uint256 spendCap, uint64 expiry)',
|
||||||
|
'event SessionRevoked(uint256 indexed sessionId, uint256 indexed rootKeyId, address by)',
|
||||||
|
'event ActionAuthorized(uint256 indexed sessionId, uint256 indexed rootKeyId, bytes32 indexed actionHash, uint256 amount, uint256 spent)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** keccak256("AereAgentDID.v1.issueSession"). */
|
||||||
|
export const SESSION_DOMAIN = keccak256(toUtf8Bytes('AereAgentDID.v1.issueSession'));
|
||||||
|
/** keccak256("AereAgentDID.v1.action"). */
|
||||||
|
export const ACTION_DOMAIN = keccak256(toUtf8Bytes('AereAgentDID.v1.action'));
|
||||||
|
|
||||||
|
export interface AereAgentDIDClientOptions {
|
||||||
|
/** AereAgentDID address. Default: AERE_MAINNET mainnet deployment. */
|
||||||
|
address?: string;
|
||||||
|
/** Chain id used in local challenge/digest derivation. Default: 2800. */
|
||||||
|
chainId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRecord {
|
||||||
|
controller: string;
|
||||||
|
createdBlock: bigint;
|
||||||
|
sessionNonce: bigint;
|
||||||
|
sessionCount: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecord {
|
||||||
|
rootKeyId: bigint;
|
||||||
|
sessionAddr: string;
|
||||||
|
scopeHash: string;
|
||||||
|
spendCap: bigint;
|
||||||
|
spent: bigint;
|
||||||
|
issuedBlock: bigint;
|
||||||
|
expiry: bigint;
|
||||||
|
actionNonce: bigint;
|
||||||
|
revoked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionParams {
|
||||||
|
rootKeyId: bigint | number;
|
||||||
|
sessionAddr: string;
|
||||||
|
scopeHash: BytesLike;
|
||||||
|
spendCap: bigint | number;
|
||||||
|
expiry: bigint | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueSessionPoP {
|
||||||
|
/** The 32-byte Falcon issuance challenge that was signed. */
|
||||||
|
challenge: string;
|
||||||
|
/** The session nonce used to derive the challenge. */
|
||||||
|
nonce: bigint;
|
||||||
|
/** The Falcon proof-of-possession envelope (pass as `popSig` to issueSession). */
|
||||||
|
popSig: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueSessionResult extends IssueSessionPoP {
|
||||||
|
tx: ContractTransactionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionAuthorization {
|
||||||
|
/** The 32-byte action digest the session key signed. */
|
||||||
|
digest: string;
|
||||||
|
/** The action nonce used to derive the digest. */
|
||||||
|
actionNonce: bigint;
|
||||||
|
/** 65-byte secp256k1 signature (r||s||v) by the session key (pass as `sig` to authorize). */
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizeResult extends ActionAuthorization {
|
||||||
|
tx: ContractTransactionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(v: BytesLike): Uint8Array {
|
||||||
|
return v instanceof Uint8Array ? v : getBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes32(v: BytesLike): string {
|
||||||
|
const b = toBytes(v);
|
||||||
|
if (b.length !== 32) throw new Error(`AereAgentDIDClient: expected 32-byte value, got ${b.length}`);
|
||||||
|
return hexlify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AereAgentDIDClient {
|
||||||
|
readonly address: string;
|
||||||
|
readonly chainId: number;
|
||||||
|
readonly contract: Contract;
|
||||||
|
|
||||||
|
constructor(runner: ContractRunner, opts: AereAgentDIDClientOptions = {}) {
|
||||||
|
this.address = opts.address ?? AERE_MAINNET.AereAgentDID;
|
||||||
|
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||||
|
this.contract = new Contract(this.address, AERE_AGENT_DID_ABI, runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- challenge / digest derivation ----------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local mirror of AereAgentDID._sessionChallenge — the Falcon issuance challenge:
|
||||||
|
* keccak256(abi.encode(SESSION_DOMAIN, chainid, address(this), rootKeyId, nonce,
|
||||||
|
* sessionAddr, scopeHash, spendCap, expiry))
|
||||||
|
* Cross-check with {@link sessionChallenge} on-chain.
|
||||||
|
*/
|
||||||
|
deriveSessionChallenge(p: SessionParams, nonce: bigint | number): string {
|
||||||
|
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'uint256', 'uint64', 'address', 'bytes32', 'uint256', 'uint64'],
|
||||||
|
[SESSION_DOMAIN, this.chainId, this.address, p.rootKeyId, nonce, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry],
|
||||||
|
);
|
||||||
|
return keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local mirror of AereAgentDID._actionDigest — what a session key signs to authorize:
|
||||||
|
* keccak256(abi.encode(ACTION_DOMAIN, chainid, address(this), sessionId, scope,
|
||||||
|
* actionHash, amount, actionNonce))
|
||||||
|
* Cross-check with {@link actionDigest} on-chain.
|
||||||
|
*/
|
||||||
|
deriveActionDigest(sessionId: bigint | number, scope: BytesLike, actionHash: BytesLike, amount: bigint | number, actionNonce: bigint | number): string {
|
||||||
|
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'uint256', 'bytes32', 'bytes32', 'uint256', 'uint64'],
|
||||||
|
[ACTION_DOMAIN, this.chainId, this.address, sessionId, bytes32(scope), bytes32(actionHash), amount, actionNonce],
|
||||||
|
);
|
||||||
|
return keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain Falcon issuance challenge for a session's params at an explicit nonce. */
|
||||||
|
async sessionChallenge(p: SessionParams, nonce: bigint | number): Promise<string> {
|
||||||
|
return this.contract.sessionChallenge(p.rootKeyId, nonce, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain current issuance challenge for an agent (uses its current sessionNonce). */
|
||||||
|
async currentSessionChallenge(p: SessionParams): Promise<string> {
|
||||||
|
return this.contract.currentSessionChallenge(p.rootKeyId, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain action digest for (sessionId, scope, actionHash, amount, actionNonce). */
|
||||||
|
async actionDigest(sessionId: bigint | number, scope: BytesLike, actionHash: BytesLike, amount: bigint | number, actionNonce: bigint | number): Promise<string> {
|
||||||
|
return this.contract.actionDigest(sessionId, bytes32(scope), bytes32(actionHash), amount, actionNonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- agent / session views ------------------------------------------------
|
||||||
|
|
||||||
|
/** Read an agent (DID). Reverts (UnknownAgent) if it does not exist. */
|
||||||
|
async getAgent(rootKeyId: bigint | number): Promise<AgentRecord> {
|
||||||
|
const [controller, createdBlock, sessionNonce, sessionCountForAgent] = await this.contract.getAgent(rootKeyId);
|
||||||
|
return { controller, createdBlock, sessionNonce, sessionCount: sessionCountForAgent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff a DID exists for `rootKeyId`. */
|
||||||
|
async agentExists(rootKeyId: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.agentExists(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total sessions ever issued (sessionIds run 0..sessionCount-1). */
|
||||||
|
async sessionCount(): Promise<bigint> {
|
||||||
|
return this.contract.sessionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a session. Reverts (UnknownSession) on an out-of-range id. */
|
||||||
|
async getSession(sessionId: bigint | number): Promise<SessionRecord> {
|
||||||
|
const [rootKeyId, sessionAddr, scopeHash, spendCap, spent, issuedBlock, expiry, actionNonce, revoked] =
|
||||||
|
await this.contract.getSession(sessionId);
|
||||||
|
return { rootKeyId, sessionAddr, scopeHash, spendCap, spent, issuedBlock, expiry, actionNonce, revoked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the session is live: exists, not revoked, unexpired, root still ACTIVE Falcon. */
|
||||||
|
async isSessionValid(sessionId: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.isSessionValid(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remaining spend under a session (0 if invalid/exhausted). */
|
||||||
|
async remainingSpend(sessionId: bigint | number): Promise<bigint> {
|
||||||
|
return this.contract.remainingSpend(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All sessionIds ever issued for an agent (append-only, includes revoked). */
|
||||||
|
async sessionsOf(rootKeyId: bigint | number): Promise<bigint[]> {
|
||||||
|
return this.contract.sessionsOf(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The AerePQCKeyRegistry this DID roots into. */
|
||||||
|
async keyRegistry(): Promise<string> {
|
||||||
|
return this.contract.keyRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- construction path (no tx) --------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Falcon proof-of-possession for issueSession at an explicit nonce,
|
||||||
|
* WITHOUT sending a transaction. Derives the issuance challenge locally and signs
|
||||||
|
* it with the root Falcon key via the src/pqc signer. `rootScheme` must be a
|
||||||
|
* Falcon scheme (1 or 2) — the root of an agent DID is always Falcon.
|
||||||
|
*/
|
||||||
|
buildIssueSessionPoP(p: SessionParams, rootSecretKey: Uint8Array, rootScheme: SchemeId, nonce: bigint | number): IssueSessionPoP {
|
||||||
|
if (rootScheme !== SCHEME.FALCON512 && rootScheme !== SCHEME.FALCON1024) {
|
||||||
|
throw new Error(`AereAgentDIDClient: agent root must be a Falcon scheme (1 or 2), got ${rootScheme}`);
|
||||||
|
}
|
||||||
|
const challenge = this.deriveSessionChallenge(p, nonce);
|
||||||
|
const popSig = signInternal(rootScheme, getBytes(challenge), rootSecretKey);
|
||||||
|
return { challenge, nonce: BigInt(nonce), popSig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the secp256k1 authorization for an action at an explicit actionNonce,
|
||||||
|
* WITHOUT sending a transaction. Derives the action digest locally and signs it
|
||||||
|
* with the raw session private key (SigningKey over the raw digest, matching the
|
||||||
|
* contract's ecrecover on the digest — NOT an eth_sign personal-message prefix).
|
||||||
|
* The serialized signature is canonical low-s with v in {27,28}.
|
||||||
|
*/
|
||||||
|
buildAuthorization(
|
||||||
|
sessionId: bigint | number,
|
||||||
|
scope: BytesLike,
|
||||||
|
actionHash: BytesLike,
|
||||||
|
amount: bigint | number,
|
||||||
|
actionNonce: bigint | number,
|
||||||
|
sessionPrivateKey: BytesLike,
|
||||||
|
): ActionAuthorization {
|
||||||
|
const digest = this.deriveActionDigest(sessionId, scope, actionHash, amount, actionNonce);
|
||||||
|
const sk = new SigningKey(hexlify(toBytes(sessionPrivateKey)));
|
||||||
|
const sig: Signature = sk.sign(digest);
|
||||||
|
return { digest, actionNonce: BigInt(actionNonce), signature: sig.serialized };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writes (require a Signer runner) --------------------------------------
|
||||||
|
|
||||||
|
/** Open the DID for an ACTIVE Falcon root key (registry owner of that key only). */
|
||||||
|
async createAgent(rootKeyId: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.createAgent(rootKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue a session with a pre-built Falcon PoP envelope. */
|
||||||
|
async issueSession(p: SessionParams, popSig: BytesLike): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.issueSession(p.rootKeyId, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry, hexlify(toBytes(popSig)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke a session immediately (agent controller only). */
|
||||||
|
async revokeSession(sessionId: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.revokeSession(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize (and record) an action with a pre-built session-key signature. */
|
||||||
|
async authorize(sessionId: bigint | number, scope: BytesLike, actionHash: BytesLike, amount: bigint | number, sig: BytesLike): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.authorize(sessionId, bytes32(scope), bytes32(actionHash), amount, hexlify(toBytes(sig)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level: read the agent's current sessionNonce on-chain, derive the issuance
|
||||||
|
* challenge locally (cross-checked against currentSessionChallenge), sign it with
|
||||||
|
* the root Falcon key (src/pqc), and submit issueSession. `signer` relays the tx
|
||||||
|
* and pays gas (any account may relay — authorship is the Falcon PoP alone).
|
||||||
|
* `rootSecretKey` never leaves this process.
|
||||||
|
*/
|
||||||
|
async issueSessionWithPoP(
|
||||||
|
signer: Signer,
|
||||||
|
p: SessionParams,
|
||||||
|
rootSecretKey: Uint8Array,
|
||||||
|
rootScheme: SchemeId,
|
||||||
|
verifyChallengeOnChain = true,
|
||||||
|
): Promise<IssueSessionResult> {
|
||||||
|
const agent = await this.getAgent(p.rootKeyId);
|
||||||
|
const nonce = agent.sessionNonce;
|
||||||
|
const built = this.buildIssueSessionPoP(p, rootSecretKey, rootScheme, nonce);
|
||||||
|
|
||||||
|
if (verifyChallengeOnChain) {
|
||||||
|
const onChain = await this.currentSessionChallenge(p);
|
||||||
|
if (onChain.toLowerCase() !== built.challenge.toLowerCase()) {
|
||||||
|
throw new Error(`AereAgentDIDClient: local session challenge ${built.challenge} != on-chain ${onChain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
const tx: ContractTransactionResponse = await c.issueSession(
|
||||||
|
p.rootKeyId, p.sessionAddr, bytes32(p.scopeHash), p.spendCap, p.expiry, hexlify(built.popSig),
|
||||||
|
);
|
||||||
|
return { tx, challenge: built.challenge, nonce, popSig: built.popSig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level: read the session's current actionNonce on-chain, derive the action
|
||||||
|
* digest locally (cross-checked against actionDigest), sign it with the secp256k1
|
||||||
|
* session key, and submit authorize via `relaySigner`. The session key signs the
|
||||||
|
* raw digest; the relayer only pays gas.
|
||||||
|
*/
|
||||||
|
async authorizeWithSessionKey(
|
||||||
|
relaySigner: Signer,
|
||||||
|
sessionId: bigint | number,
|
||||||
|
scope: BytesLike,
|
||||||
|
actionHash: BytesLike,
|
||||||
|
amount: bigint | number,
|
||||||
|
sessionPrivateKey: BytesLike,
|
||||||
|
verifyDigestOnChain = true,
|
||||||
|
): Promise<AuthorizeResult> {
|
||||||
|
const session = await this.getSession(sessionId);
|
||||||
|
const actionNonce = session.actionNonce;
|
||||||
|
const built = this.buildAuthorization(sessionId, scope, actionHash, amount, actionNonce, sessionPrivateKey);
|
||||||
|
|
||||||
|
if (verifyDigestOnChain) {
|
||||||
|
const onChain = await this.actionDigest(sessionId, scope, actionHash, amount, actionNonce);
|
||||||
|
if (onChain.toLowerCase() !== built.digest.toLowerCase()) {
|
||||||
|
throw new Error(`AereAgentDIDClient: local action digest ${built.digest} != on-chain ${onChain}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = this.contract.connect(relaySigner) as Contract;
|
||||||
|
const tx: ContractTransactionResponse = await c.authorize(
|
||||||
|
sessionId, bytes32(scope), bytes32(actionHash), amount, built.signature,
|
||||||
|
);
|
||||||
|
return { tx, digest: built.digest, actionNonce, signature: built.signature };
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/pqc/AerePQCClient.ts
Normal file
234
src/pqc/AerePQCClient.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
// AerePQCClient — ethers v6 wrapper over AerePQCAttestation (chain 2800).
|
||||||
|
//
|
||||||
|
// AerePQCAttestation (0x465d9E3b476BF98Aa1393079e240Db5D2a9bEA6A) turns AERE's
|
||||||
|
// LIVE native post-quantum precompiles (0x0AE1 Falcon-512 / 0x0AE2 Falcon-1024 /
|
||||||
|
// 0x0AE3 ML-DSA-44 / 0x0AE4 SLH-DSA-128s) into a usable primitive: register a
|
||||||
|
// NIST PQC public key, then record attestations whose validity rests on a real
|
||||||
|
// post-quantum signature verified on-chain by the matching precompile.
|
||||||
|
//
|
||||||
|
// This client is the missing SIGNING surface: keygen + internal-interface signing
|
||||||
|
// (schemes.ts) + envelope building (envelope.ts) wired to the contract's methods,
|
||||||
|
// including a one-call signAndAttest() that reads the per-key nonce, derives the
|
||||||
|
// challenge, signs it, and submits attest().
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify,
|
||||||
|
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../addresses.js';
|
||||||
|
import { SCHEME, type SchemeId, keygen, signInternal, verifyLocal } from './schemes.js';
|
||||||
|
|
||||||
|
/** Minimal inline ABI — only the AerePQCAttestation surface this client uses. */
|
||||||
|
export const AERE_PQC_ATTESTATION_ABI = [
|
||||||
|
'function registerKey(uint8 scheme, bytes pubKey) returns (uint256 keyId)',
|
||||||
|
'function attest(uint256 keyId, bytes32 messageHash, bytes signature)',
|
||||||
|
'function verifySignature(uint8 scheme, bytes pubKey, bytes32 messageHash, bytes signature) view returns (bool)',
|
||||||
|
'function attestChallenge(uint256 keyId, uint64 nonce, bytes32 messageHash) view returns (bytes32)',
|
||||||
|
'function attestationId(uint256 keyId, uint64 nonce) pure returns (bytes32)',
|
||||||
|
'function nonceOf(uint256 keyId) view returns (uint64)',
|
||||||
|
'function keyCount() view returns (uint256)',
|
||||||
|
'function getKey(uint256 keyId) view returns (address owner, uint8 scheme, uint64 nonce, bytes pubKey)',
|
||||||
|
'function getAttestation(uint256 keyId, uint64 nonce) view returns (bool exists, bytes32 messageHash, bytes32 challenge, uint256 blockNumber)',
|
||||||
|
'function isValidAttestation(uint256 keyId, uint64 nonce, bytes32 messageHash) view returns (bool)',
|
||||||
|
'function precompileFor(uint8 scheme) pure returns (address)',
|
||||||
|
'function attestationCount() view returns (uint256)',
|
||||||
|
'event KeyRegistered(uint256 indexed keyId, address indexed owner, uint8 indexed scheme, uint256 pubKeyLen)',
|
||||||
|
'event Attested(uint256 indexed keyId, uint8 indexed scheme, uint64 indexed nonce, bytes32 messageHash, bytes32 challenge, uint256 blockNumber)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** keccak256("AerePQCAttestation.v1.challenge") — the challenge domain separator. */
|
||||||
|
export const CHALLENGE_DOMAIN = keccak256(toUtf8Bytes('AerePQCAttestation.v1.challenge'));
|
||||||
|
|
||||||
|
export interface AerePQCClientOptions {
|
||||||
|
/** AerePQCAttestation address. Default: AERE_MAINNET mainnet deployment. */
|
||||||
|
address?: string;
|
||||||
|
/** Chain id used in the challenge derivation. Default: 2800. */
|
||||||
|
chainId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisteredKey {
|
||||||
|
owner: string;
|
||||||
|
scheme: number;
|
||||||
|
nonce: bigint;
|
||||||
|
pubKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttestationRecord {
|
||||||
|
exists: boolean;
|
||||||
|
messageHash: string;
|
||||||
|
challenge: string;
|
||||||
|
blockNumber: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignAndAttestResult {
|
||||||
|
tx: ContractTransactionResponse;
|
||||||
|
keyId: bigint;
|
||||||
|
nonce: bigint;
|
||||||
|
messageHash: string;
|
||||||
|
challenge: string;
|
||||||
|
signature: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(v: BytesLike): Uint8Array {
|
||||||
|
return v instanceof Uint8Array ? v : getBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytes32(v: BytesLike): string {
|
||||||
|
const b = toBytes(v);
|
||||||
|
if (b.length !== 32) throw new Error(`AerePQCClient: expected 32-byte value, got ${b.length}`);
|
||||||
|
return hexlify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AerePQCClient {
|
||||||
|
readonly address: string;
|
||||||
|
readonly chainId: number;
|
||||||
|
readonly contract: Contract;
|
||||||
|
private readonly runner: ContractRunner;
|
||||||
|
|
||||||
|
constructor(runner: ContractRunner, opts: AerePQCClientOptions = {}) {
|
||||||
|
this.runner = runner;
|
||||||
|
this.address = opts.address ?? AERE_MAINNET.AerePQCAttestation;
|
||||||
|
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||||
|
this.contract = new Contract(this.address, AERE_PQC_ATTESTATION_ABI, runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- keygen / signing helpers (re-exported for convenience) ----------------
|
||||||
|
|
||||||
|
/** Generate a keypair for a scheme (1=Falcon-512, 2=Falcon-1024, 3=ML-DSA-44, 4=SLH-DSA-128s). */
|
||||||
|
static keygen(scheme: SchemeId, seed?: Uint8Array) {
|
||||||
|
return keygen(scheme, seed);
|
||||||
|
}
|
||||||
|
/** Sign a 32-byte message with the scheme's internal interface; returns the contract envelope. */
|
||||||
|
static sign(scheme: SchemeId, message: BytesLike, secretKey: Uint8Array): Uint8Array {
|
||||||
|
return signInternal(scheme, toBytes(message), secretKey);
|
||||||
|
}
|
||||||
|
/** Off-chain mirror of the precompile check. */
|
||||||
|
static verifyLocal(scheme: SchemeId, message: BytesLike, signature: Uint8Array, publicKey: Uint8Array): boolean {
|
||||||
|
return verifyLocal(scheme, toBytes(message), signature, publicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- challenge derivation --------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local mirror of AerePQCAttestation.attestChallenge:
|
||||||
|
* keccak256(abi.encode(CHALLENGE_DOMAIN, chainid, address(this), keyId, nonce, messageHash))
|
||||||
|
* Proven byte-identical to the on-chain view. Use `attestChallenge` to cross-check on-chain.
|
||||||
|
*/
|
||||||
|
deriveChallenge(keyId: bigint | number, nonce: bigint | number, messageHash: BytesLike): string {
|
||||||
|
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'uint256', 'uint64', 'bytes32'],
|
||||||
|
[CHALLENGE_DOMAIN, this.chainId, this.address, keyId, nonce, bytes32(messageHash)],
|
||||||
|
);
|
||||||
|
return keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- views -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Free PQC verify via the live precompile. Returns true iff the precompile accepts. */
|
||||||
|
async verifySignature(scheme: SchemeId, pubKey: BytesLike, messageHash: BytesLike, signature: BytesLike): Promise<boolean> {
|
||||||
|
return this.contract.verifySignature(scheme, hexlify(toBytes(pubKey)), bytes32(messageHash), hexlify(toBytes(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a Falcon-512 signature over an ARBITRARY-length message directly via the
|
||||||
|
* live AereFalcon512Verifier (0x4E8e...D8fFC). Unlike the attestation path (which
|
||||||
|
* takes a bytes32 messageHash), this accepts messages longer than 32 bytes, so it
|
||||||
|
* mirrors the AerePQCAccount domain-separated spend check (message = domain||hash,
|
||||||
|
* 64 bytes). Read-only.
|
||||||
|
*/
|
||||||
|
async verifyFalconRaw(pubKey: BytesLike, message: BytesLike, nonce: BytesLike, compSig: BytesLike): Promise<boolean> {
|
||||||
|
const verifier = new Contract(
|
||||||
|
AERE_MAINNET.AereFalcon512Verifier,
|
||||||
|
['function verify(bytes pk, bytes message, bytes nonce, bytes compSig) view returns (bool)'],
|
||||||
|
this.runner,
|
||||||
|
);
|
||||||
|
return verifier.verify(
|
||||||
|
hexlify(toBytes(pubKey)),
|
||||||
|
hexlify(toBytes(message)),
|
||||||
|
hexlify(toBytes(nonce)),
|
||||||
|
hexlify(toBytes(compSig)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain challenge for (keyId, nonce, messageHash). */
|
||||||
|
async attestChallenge(keyId: bigint | number, nonce: bigint | number, messageHash: BytesLike): Promise<string> {
|
||||||
|
return this.contract.attestChallenge(keyId, nonce, bytes32(messageHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic id for the attestation recorded by (keyId, nonce). */
|
||||||
|
async attestationId(keyId: bigint | number, nonce: bigint | number): Promise<string> {
|
||||||
|
return this.contract.attestationId(keyId, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current (next unused) per-key nonce. */
|
||||||
|
async nonceOf(keyId: bigint | number): Promise<bigint> {
|
||||||
|
return this.contract.nonceOf(keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of registered keys (keyIds run 0..keyCount-1). */
|
||||||
|
async keyCount(): Promise<bigint> {
|
||||||
|
return this.contract.keyCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total attestations recorded across all keys. */
|
||||||
|
async attestationCount(): Promise<bigint> {
|
||||||
|
return this.contract.attestationCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a registered key. */
|
||||||
|
async getKey(keyId: bigint | number): Promise<RegisteredKey> {
|
||||||
|
const [owner, scheme, nonce, pubKey] = await this.contract.getKey(keyId);
|
||||||
|
return { owner, scheme: Number(scheme), nonce, pubKey: getBytes(pubKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a recorded attestation by (keyId, nonce). */
|
||||||
|
async getAttestation(keyId: bigint | number, nonce: bigint | number): Promise<AttestationRecord> {
|
||||||
|
const [exists, messageHash, challenge, blockNumber] = await this.contract.getAttestation(keyId, nonce);
|
||||||
|
return { exists, messageHash, challenge, blockNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the attestation at (keyId, nonce) exists and committed to `messageHash`. */
|
||||||
|
async isValidAttestation(keyId: bigint | number, nonce: bigint | number, messageHash: BytesLike): Promise<boolean> {
|
||||||
|
return this.contract.isValidAttestation(keyId, nonce, bytes32(messageHash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live precompile address for a scheme. */
|
||||||
|
async precompileFor(scheme: SchemeId): Promise<string> {
|
||||||
|
return this.contract.precompileFor(scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writes (require a Signer runner) --------------------------------------
|
||||||
|
|
||||||
|
/** Register a PQC public key; returns the sent transaction (keyId is emitted / equals prior keyCount). */
|
||||||
|
async registerKey(scheme: SchemeId, pubKey: BytesLike): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.registerKey(scheme, hexlify(toBytes(pubKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record an attestation with a pre-built signature envelope over the current challenge. */
|
||||||
|
async attest(keyId: bigint | number, messageHash: BytesLike, signature: BytesLike): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.attest(keyId, bytes32(messageHash), hexlify(toBytes(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level: read the key's current nonce, derive the challenge, sign it with
|
||||||
|
* the scheme's internal interface, build the envelope, and submit attest().
|
||||||
|
*
|
||||||
|
* `signer` funds the gas (any account may relay; authorship is the PQC signature
|
||||||
|
* alone). `secretKey` never leaves this process.
|
||||||
|
*/
|
||||||
|
async signAndAttest(
|
||||||
|
signer: Signer,
|
||||||
|
keyId: bigint | number,
|
||||||
|
messageHash: BytesLike,
|
||||||
|
secretKey: Uint8Array,
|
||||||
|
scheme: SchemeId,
|
||||||
|
): Promise<SignAndAttestResult> {
|
||||||
|
const msg = bytes32(messageHash);
|
||||||
|
const nonce = await this.nonceOf(keyId);
|
||||||
|
const challenge = this.deriveChallenge(keyId, nonce, msg);
|
||||||
|
const signature = signInternal(scheme, getBytes(challenge), secretKey);
|
||||||
|
const withSigner = this.contract.connect(signer) as Contract;
|
||||||
|
const tx: ContractTransactionResponse = await withSigner.attest(keyId, msg, hexlify(signature));
|
||||||
|
return { tx, keyId: BigInt(keyId), nonce, messageHash: msg, challenge, signature };
|
||||||
|
}
|
||||||
|
}
|
||||||
275
src/pqc/AerePQCKeyRegistryClient.ts
Normal file
275
src/pqc/AerePQCKeyRegistryClient.ts
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
// AerePQCKeyRegistryClient — ethers v6 wrapper over AerePQCKeyRegistry (chain 2800).
|
||||||
|
//
|
||||||
|
// AerePQCKeyRegistry (0x1eCa3c5ADcBD0b22636D8672b00faC6D89363691) is the
|
||||||
|
// permissionless registry a wallet / agent / app uses to PUBLISH its post-quantum
|
||||||
|
// public keys and prove, on-chain, that it holds the matching private key.
|
||||||
|
// registerKey(scheme, pubKey, signature) stores a key only if `signature` is a
|
||||||
|
// valid PQC signature by `pubKey` over a contract-bound PROOF-OF-POSSESSION
|
||||||
|
// challenge, verified in full by AERE's live native precompile.
|
||||||
|
//
|
||||||
|
// The PoP challenge preimage is (read straight from the contract):
|
||||||
|
// keccak256(abi.encode(
|
||||||
|
// POP_DOMAIN, block.chainid, address(this), owner, scheme, keccak256(pubKey), nonce))
|
||||||
|
// POP_DOMAIN = keccak256("AerePQCKeyRegistry.v1.pop")
|
||||||
|
//
|
||||||
|
// This client wraps the views + writes and adds registerKeyWithPoP(): it reads the
|
||||||
|
// on-chain identity nonce, derives the challenge locally (cross-checked against the
|
||||||
|
// contract's popChallenge view), signs it with the src/pqc signer (schemes.signInternal
|
||||||
|
// + envelope.ts), and submits registerKey. The pqc signing/envelope code is REUSED,
|
||||||
|
// never reimplemented.
|
||||||
|
|
||||||
|
import {
|
||||||
|
Contract, keccak256, toUtf8Bytes, AbiCoder, getBytes, hexlify,
|
||||||
|
type ContractRunner, type Signer, type BytesLike, type ContractTransactionResponse,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../addresses.js';
|
||||||
|
import { type SchemeId, signInternal } from './schemes.js';
|
||||||
|
import { assertPubKey } from './envelope.js';
|
||||||
|
|
||||||
|
/** Minimal inline ABI — only the AerePQCKeyRegistry surface this client uses. */
|
||||||
|
export const AERE_PQC_KEY_REGISTRY_ABI = [
|
||||||
|
// writes
|
||||||
|
'function registerKey(uint8 scheme, bytes pubKey, bytes signature) returns (uint256 keyId)',
|
||||||
|
'function rotateKey(uint256 oldKeyId, uint8 newScheme, bytes newPubKey, bytes signature) returns (uint256 newKeyId)',
|
||||||
|
'function revokeKey(uint256 keyId)',
|
||||||
|
// verification views
|
||||||
|
'function verify(uint8 scheme, bytes pubKey, bytes32 message, bytes signature) view returns (bool)',
|
||||||
|
'function verifyWithKey(uint256 keyId, bytes32 message, bytes signature) view returns (bool)',
|
||||||
|
// challenge views
|
||||||
|
'function popChallenge(address owner, uint8 scheme, bytes pubKey, uint64 nonce) view returns (bytes32)',
|
||||||
|
'function currentPopChallenge(address owner, uint8 scheme, bytes pubKey) view returns (bytes32)',
|
||||||
|
'function identityNonce(address owner) view returns (uint64)',
|
||||||
|
// key views
|
||||||
|
'function getKey(uint256 keyId) view returns (address owner, uint8 scheme, uint8 status, uint64 registeredBlock, uint256 rotatedFromId, uint256 rotatedToId, bytes pubKey)',
|
||||||
|
'function keyCount() view returns (uint256)',
|
||||||
|
'function statusOf(uint256 keyId) view returns (uint8)',
|
||||||
|
'function isActiveKey(uint256 keyId) view returns (bool)',
|
||||||
|
'function schemeOf(uint256 keyId) view returns (uint8)',
|
||||||
|
'function ownerOf(uint256 keyId) view returns (address)',
|
||||||
|
'function keysOf(address owner) view returns (uint256[])',
|
||||||
|
'function precompileFor(uint8 scheme) view returns (address)',
|
||||||
|
// events
|
||||||
|
'event KeyRegistered(uint256 indexed keyId, address indexed owner, uint8 indexed scheme, uint64 popNonce)',
|
||||||
|
'event KeyRotated(uint256 indexed oldKeyId, uint256 indexed newKeyId, address indexed owner)',
|
||||||
|
'event KeyRevoked(uint256 indexed keyId, address indexed owner)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** keccak256("AerePQCKeyRegistry.v1.pop") — the proof-of-possession domain separator. */
|
||||||
|
export const POP_DOMAIN = keccak256(toUtf8Bytes('AerePQCKeyRegistry.v1.pop'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key lifecycle status (mirrors AerePQCKeyRegistry STATUS_*).
|
||||||
|
* NONE=0 (never registered), ACTIVE=1, ROTATED=2 (superseded), REVOKED=3 (terminal).
|
||||||
|
* ACTIVE and ROTATED keys still verify cryptographically; REVOKED never does.
|
||||||
|
*/
|
||||||
|
export enum KeyStatus {
|
||||||
|
NONE = 0,
|
||||||
|
ACTIVE = 1,
|
||||||
|
ROTATED = 2,
|
||||||
|
REVOKED = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AerePQCKeyRegistryClientOptions {
|
||||||
|
/** AerePQCKeyRegistry address. Default: AERE_MAINNET mainnet deployment. */
|
||||||
|
address?: string;
|
||||||
|
/** Chain id used in the local PoP challenge derivation. Default: 2800. */
|
||||||
|
chainId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyRecord {
|
||||||
|
owner: string;
|
||||||
|
scheme: number;
|
||||||
|
status: KeyStatus;
|
||||||
|
registeredBlock: bigint;
|
||||||
|
rotatedFromId: bigint;
|
||||||
|
rotatedToId: bigint;
|
||||||
|
pubKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterKeyPoP {
|
||||||
|
/** The 32-byte proof-of-possession challenge that was signed. */
|
||||||
|
challenge: string;
|
||||||
|
/** The identity nonce used to derive the challenge. */
|
||||||
|
nonce: bigint;
|
||||||
|
/** The PQC proof-of-possession envelope (pass as `signature` to registerKey). */
|
||||||
|
signature: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterKeyResult extends RegisterKeyPoP {
|
||||||
|
tx: ContractTransactionResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBytes(v: BytesLike): Uint8Array {
|
||||||
|
return v instanceof Uint8Array ? v : getBytes(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AerePQCKeyRegistryClient {
|
||||||
|
readonly address: string;
|
||||||
|
readonly chainId: number;
|
||||||
|
readonly contract: Contract;
|
||||||
|
|
||||||
|
constructor(runner: ContractRunner, opts: AerePQCKeyRegistryClientOptions = {}) {
|
||||||
|
this.address = opts.address ?? AERE_MAINNET.AerePQCKeyRegistry;
|
||||||
|
this.chainId = opts.chainId ?? AERE_MAINNET.chainId;
|
||||||
|
this.contract = new Contract(this.address, AERE_PQC_KEY_REGISTRY_ABI, runner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PoP challenge derivation ---------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local mirror of AerePQCKeyRegistry._popChallenge:
|
||||||
|
* keccak256(abi.encode(POP_DOMAIN, chainid, address(this), owner, scheme, keccak256(pubKey), nonce))
|
||||||
|
* Proven byte-identical to the on-chain popChallenge view (see the live/offline
|
||||||
|
* tests). Use {@link popChallenge} to cross-check on-chain.
|
||||||
|
*/
|
||||||
|
derivePopChallenge(owner: string, scheme: SchemeId, pubKey: BytesLike, nonce: bigint | number): string {
|
||||||
|
const pubKeyHash = keccak256(hexlify(toBytes(pubKey)));
|
||||||
|
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'address', 'uint8', 'bytes32', 'uint64'],
|
||||||
|
[POP_DOMAIN, this.chainId, this.address, owner, scheme, pubKeyHash, nonce],
|
||||||
|
);
|
||||||
|
return keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain PoP challenge for (owner, scheme, pubKey) at an explicit nonce. */
|
||||||
|
async popChallenge(owner: string, scheme: SchemeId, pubKey: BytesLike, nonce: bigint | number): Promise<string> {
|
||||||
|
return this.contract.popChallenge(owner, scheme, hexlify(toBytes(pubKey)), nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain PoP challenge for `owner` to register `pubKey` right now (current nonce). */
|
||||||
|
async currentPopChallenge(owner: string, scheme: SchemeId, pubKey: BytesLike): Promise<string> {
|
||||||
|
return this.contract.currentPopChallenge(owner, scheme, hexlify(toBytes(pubKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current per-identity registration nonce (advances on each register/rotate). */
|
||||||
|
async identityNonce(owner: string): Promise<bigint> {
|
||||||
|
return this.contract.identityNonce(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- verification views ----------------------------------------------------
|
||||||
|
|
||||||
|
/** Stateless PQC verification over a raw 32-byte message. Fail-closed (never reverts). */
|
||||||
|
async verify(scheme: SchemeId, pubKey: BytesLike, message: BytesLike, signature: BytesLike): Promise<boolean> {
|
||||||
|
return this.contract.verify(scheme, hexlify(toBytes(pubKey)), hexlify(toBytes(message)), hexlify(toBytes(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a signature with a REGISTERED key. Fail-closed; REVOKED keys never verify. */
|
||||||
|
async verifyWithKey(keyId: bigint | number, message: BytesLike, signature: BytesLike): Promise<boolean> {
|
||||||
|
return this.contract.verifyWithKey(keyId, hexlify(toBytes(message)), hexlify(toBytes(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- key views -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Number of registered keys (keyIds run 0..keyCount-1). */
|
||||||
|
async keyCount(): Promise<bigint> {
|
||||||
|
return this.contract.keyCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a registered key. Reverts (UnknownKey) on an out-of-range keyId. */
|
||||||
|
async getKey(keyId: bigint | number): Promise<KeyRecord> {
|
||||||
|
const [owner, scheme, status, registeredBlock, rotatedFromId, rotatedToId, pubKey] =
|
||||||
|
await this.contract.getKey(keyId);
|
||||||
|
return {
|
||||||
|
owner,
|
||||||
|
scheme: Number(scheme),
|
||||||
|
status: Number(status) as KeyStatus,
|
||||||
|
registeredBlock,
|
||||||
|
rotatedFromId,
|
||||||
|
rotatedToId,
|
||||||
|
pubKey: getBytes(pubKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lifecycle status of a key (NONE for a never-registered id). */
|
||||||
|
async statusOf(keyId: bigint | number): Promise<KeyStatus> {
|
||||||
|
return Number(await this.contract.statusOf(keyId)) as KeyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the key exists and is ACTIVE (current, not rotated/revoked). */
|
||||||
|
async isActiveKey(keyId: bigint | number): Promise<boolean> {
|
||||||
|
return this.contract.isActiveKey(keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All keyIds ever registered by `owner` (append-only, includes retired). */
|
||||||
|
async keysOf(owner: string): Promise<bigint[]> {
|
||||||
|
return this.contract.keysOf(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live precompile address for a scheme. */
|
||||||
|
async precompileFor(scheme: SchemeId): Promise<string> {
|
||||||
|
return this.contract.precompileFor(scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- construction path (no tx) --------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the proof-of-possession for a registerKey at an explicit nonce, WITHOUT
|
||||||
|
* sending a transaction. Asserts the public key's length/header for the scheme,
|
||||||
|
* derives the PoP challenge locally, and signs it with the src/pqc signer.
|
||||||
|
* Returns the challenge + envelope so a caller can relay registerKey itself, or
|
||||||
|
* unit-test the exact bytes registerKey would submit.
|
||||||
|
*/
|
||||||
|
buildRegisterKeyPoP(owner: string, scheme: SchemeId, secretKey: Uint8Array, pubKey: Uint8Array, nonce: bigint | number): RegisterKeyPoP {
|
||||||
|
assertPubKey(scheme, pubKey);
|
||||||
|
const challenge = this.derivePopChallenge(owner, scheme, pubKey, nonce);
|
||||||
|
const signature = signInternal(scheme, getBytes(challenge), secretKey);
|
||||||
|
return { challenge, nonce: BigInt(nonce), signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- writes (require a Signer runner) --------------------------------------
|
||||||
|
|
||||||
|
/** Register a pre-signed PQC public key. `signature` must be a valid PoP envelope. */
|
||||||
|
async registerKey(scheme: SchemeId, pubKey: BytesLike, signature: BytesLike): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.registerKey(scheme, hexlify(toBytes(pubKey)), hexlify(toBytes(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotate `oldKeyId` to a fresh successor key (owner only), proving possession of the NEW key. */
|
||||||
|
async rotateKey(oldKeyId: bigint | number, newScheme: SchemeId, newPubKey: BytesLike, signature: BytesLike): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.rotateKey(oldKeyId, newScheme, hexlify(toBytes(newPubKey)), hexlify(toBytes(signature)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently revoke a key you own (terminal). */
|
||||||
|
async revokeKey(keyId: bigint | number): Promise<ContractTransactionResponse> {
|
||||||
|
return this.contract.revokeKey(keyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level: read the signer's current identity nonce on-chain, derive the PoP
|
||||||
|
* challenge locally (and cross-check it against the contract's popChallenge view),
|
||||||
|
* sign it with the scheme's internal interface (src/pqc), and submit registerKey.
|
||||||
|
*
|
||||||
|
* `signer` is BOTH the tx sender AND the identity the key binds to — the PoP
|
||||||
|
* challenge commits to msg.sender, so the account that pays gas is the owner.
|
||||||
|
* `secretKey` never leaves this process.
|
||||||
|
*
|
||||||
|
* @param verifyChallengeOnChain when true (default) reads popChallenge from the
|
||||||
|
* contract and asserts it equals the locally-derived value before signing,
|
||||||
|
* so a client/contract encoding drift fails fast instead of on-chain.
|
||||||
|
*/
|
||||||
|
async registerKeyWithPoP(
|
||||||
|
signer: Signer,
|
||||||
|
scheme: SchemeId,
|
||||||
|
secretKey: Uint8Array,
|
||||||
|
pubKey: Uint8Array,
|
||||||
|
verifyChallengeOnChain = true,
|
||||||
|
): Promise<RegisterKeyResult> {
|
||||||
|
assertPubKey(scheme, pubKey);
|
||||||
|
const owner = await signer.getAddress();
|
||||||
|
const nonce = await this.identityNonce(owner);
|
||||||
|
const challenge = this.derivePopChallenge(owner, scheme, pubKey, nonce);
|
||||||
|
|
||||||
|
if (verifyChallengeOnChain) {
|
||||||
|
const onChain = await this.popChallenge(owner, scheme, pubKey, nonce);
|
||||||
|
if (onChain.toLowerCase() !== challenge.toLowerCase()) {
|
||||||
|
throw new Error(
|
||||||
|
`AerePQCKeyRegistryClient: local PoP challenge ${challenge} != on-chain ${onChain}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = signInternal(scheme, getBytes(challenge), secretKey);
|
||||||
|
const c = this.contract.connect(signer) as Contract;
|
||||||
|
const tx: ContractTransactionResponse = await c.registerKey(scheme, hexlify(pubKey), hexlify(signature));
|
||||||
|
return { tx, challenge, nonce, signature };
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/pqc/envelope.ts
Normal file
152
src/pqc/envelope.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
// Envelope builders for AerePQCAttestation (chain 2800).
|
||||||
|
//
|
||||||
|
// Pure, dependency-free byte assembly. These functions convert a scheme-native
|
||||||
|
// signature into the exact `signature` bytes that AerePQCAttestation.attest() /
|
||||||
|
// verifySignature() expect, and back. They contain NO crypto — signing lives in
|
||||||
|
// schemes.ts. Every builder asserts the lengths/headers the live contract (and
|
||||||
|
// the native precompiles behind it) require, so a malformed envelope fails here
|
||||||
|
// rather than silently on-chain.
|
||||||
|
//
|
||||||
|
// Envelope shapes the contract expects (from the AerePQCAttestation NatSpec):
|
||||||
|
// Falcon-512 (1): signature = nonce(40) || esig, esig = 0x29 || compressedSig
|
||||||
|
// Falcon-1024 (2): signature = nonce(40) || esig, esig = 0x2A || compressedSig
|
||||||
|
// ML-DSA-44 (3): signature = sig, exactly 2420 bytes (raw FIPS-204 sig)
|
||||||
|
// SLH-DSA-128s(4): signature = sig, exactly 7856 bytes (raw FIPS-205 sig)
|
||||||
|
//
|
||||||
|
// The precompiles behind Falcon use the exact same compressed-signature body
|
||||||
|
// that @noble/post-quantum emits; the only transform is the 1-byte header. The
|
||||||
|
// noble DETACHED Falcon signature is `0x30|logn || nonce(40) || compressedSig`,
|
||||||
|
// so we swap the leading `0x30|logn` header for the contract's `0x20|logn` esig
|
||||||
|
// header and drop it behind the nonce.
|
||||||
|
|
||||||
|
/** Numeric scheme ids used by AerePQCAttestation. */
|
||||||
|
export const SCHEME = {
|
||||||
|
FALCON512: 1,
|
||||||
|
FALCON1024: 2,
|
||||||
|
MLDSA44: 3,
|
||||||
|
SLHDSA128S: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SchemeId = (typeof SCHEME)[keyof typeof SCHEME];
|
||||||
|
|
||||||
|
/** Fixed byte lengths per scheme (public key + raw signature). */
|
||||||
|
export const LENGTHS = {
|
||||||
|
falcon512: { pubKey: 897, pkHeader: 0x09, logn: 9 },
|
||||||
|
falcon1024: { pubKey: 1793, pkHeader: 0x0a, logn: 10 },
|
||||||
|
mldsa44: { pubKey: 1312, signature: 2420 },
|
||||||
|
slhdsa128s: { pubKey: 32, signature: 7856 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Falcon randomized-hash nonce (salt) length, always 40 bytes. */
|
||||||
|
export const FALCON_NONCE_LEN = 40;
|
||||||
|
|
||||||
|
/** noble detached Falcon header byte: 0x30 | logn (0x39 for 512, 0x3A for 1024). */
|
||||||
|
export function falconDetachedHeader(scheme: SchemeId): number {
|
||||||
|
if (scheme === SCHEME.FALCON512) return 0x30 | LENGTHS.falcon512.logn;
|
||||||
|
if (scheme === SCHEME.FALCON1024) return 0x30 | LENGTHS.falcon1024.logn;
|
||||||
|
throw new Error(`envelope: not a Falcon scheme: ${scheme}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contract esig header byte: 0x20 | logn (0x29 for 512, 0x2A for 1024). */
|
||||||
|
export function falconEsigHeader(scheme: SchemeId): number {
|
||||||
|
if (scheme === SCHEME.FALCON512) return 0x20 | LENGTHS.falcon512.logn;
|
||||||
|
if (scheme === SCHEME.FALCON1024) return 0x20 | LENGTHS.falcon1024.logn;
|
||||||
|
throw new Error(`envelope: not a Falcon scheme: ${scheme}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function concat(...parts: Uint8Array[]): Uint8Array {
|
||||||
|
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let off = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
out.set(p, off);
|
||||||
|
off += p.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a noble DETACHED Falcon signature into the contract signature envelope.
|
||||||
|
* detached = (0x30|logn) || nonce(40) || compressedSig
|
||||||
|
* envelope = nonce(40) || (0x20|logn) || compressedSig
|
||||||
|
*/
|
||||||
|
export function falconDetachedToEnvelope(detached: Uint8Array, scheme: SchemeId): Uint8Array {
|
||||||
|
const hdr = falconDetachedHeader(scheme);
|
||||||
|
if (detached.length <= 1 + FALCON_NONCE_LEN) {
|
||||||
|
throw new Error(`envelope: falcon detached signature too short (${detached.length})`);
|
||||||
|
}
|
||||||
|
if (detached[0] !== hdr) {
|
||||||
|
throw new Error(
|
||||||
|
`envelope: falcon detached header 0x${detached[0].toString(16)} != expected 0x${hdr.toString(16)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nonce = detached.subarray(1, 1 + FALCON_NONCE_LEN);
|
||||||
|
const compressed = detached.subarray(1 + FALCON_NONCE_LEN);
|
||||||
|
const esig = concat(Uint8Array.of(falconEsigHeader(scheme)), compressed);
|
||||||
|
return concat(nonce, esig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse of {@link falconDetachedToEnvelope}: recover the noble detached
|
||||||
|
* signature from a contract envelope (for local verification).
|
||||||
|
* envelope = nonce(40) || (0x20|logn) || compressedSig
|
||||||
|
* detached = (0x30|logn) || nonce(40) || compressedSig
|
||||||
|
*/
|
||||||
|
export function falconEnvelopeToDetached(envelope: Uint8Array, scheme: SchemeId): Uint8Array {
|
||||||
|
const esigHdr = falconEsigHeader(scheme);
|
||||||
|
if (envelope.length <= FALCON_NONCE_LEN + 1) {
|
||||||
|
throw new Error(`envelope: falcon envelope too short (${envelope.length})`);
|
||||||
|
}
|
||||||
|
if (envelope[FALCON_NONCE_LEN] !== esigHdr) {
|
||||||
|
throw new Error(
|
||||||
|
`envelope: falcon esig header 0x${envelope[FALCON_NONCE_LEN].toString(16)} != expected 0x${esigHdr.toString(16)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nonce = envelope.subarray(0, FALCON_NONCE_LEN);
|
||||||
|
const compressed = envelope.subarray(FALCON_NONCE_LEN + 1);
|
||||||
|
return concat(Uint8Array.of(falconDetachedHeader(scheme)), nonce, compressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ML-DSA-44 envelope is the raw 2420-byte FIPS-204 signature. Asserts length. */
|
||||||
|
export function mldsaEnvelope(sig: Uint8Array): Uint8Array {
|
||||||
|
if (sig.length !== LENGTHS.mldsa44.signature) {
|
||||||
|
throw new Error(`envelope: ML-DSA-44 signature must be ${LENGTHS.mldsa44.signature} bytes, got ${sig.length}`);
|
||||||
|
}
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SLH-DSA-128s envelope is the raw 7856-byte FIPS-205 signature. Asserts length. */
|
||||||
|
export function slhdsaEnvelope(sig: Uint8Array): Uint8Array {
|
||||||
|
if (sig.length !== LENGTHS.slhdsa128s.signature) {
|
||||||
|
throw new Error(`envelope: SLH-DSA-128s signature must be ${LENGTHS.slhdsa128s.signature} bytes, got ${sig.length}`);
|
||||||
|
}
|
||||||
|
return sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert a public key has the length (and, for Falcon, the header byte) its scheme requires. */
|
||||||
|
export function assertPubKey(scheme: SchemeId, pubKey: Uint8Array): void {
|
||||||
|
switch (scheme) {
|
||||||
|
case SCHEME.FALCON512:
|
||||||
|
if (pubKey.length !== LENGTHS.falcon512.pubKey || pubKey[0] !== LENGTHS.falcon512.pkHeader) {
|
||||||
|
throw new Error(`envelope: Falcon-512 pubKey must be ${LENGTHS.falcon512.pubKey} bytes with header 0x09`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case SCHEME.FALCON1024:
|
||||||
|
if (pubKey.length !== LENGTHS.falcon1024.pubKey || pubKey[0] !== LENGTHS.falcon1024.pkHeader) {
|
||||||
|
throw new Error(`envelope: Falcon-1024 pubKey must be ${LENGTHS.falcon1024.pubKey} bytes with header 0x0A`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case SCHEME.MLDSA44:
|
||||||
|
if (pubKey.length !== LENGTHS.mldsa44.pubKey) {
|
||||||
|
throw new Error(`envelope: ML-DSA-44 pubKey must be ${LENGTHS.mldsa44.pubKey} bytes, got ${pubKey.length}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
case SCHEME.SLHDSA128S:
|
||||||
|
if (pubKey.length !== LENGTHS.slhdsa128s.pubKey) {
|
||||||
|
throw new Error(`envelope: SLH-DSA-128s pubKey must be ${LENGTHS.slhdsa128s.pubKey} bytes, got ${pubKey.length}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error(`envelope: unknown scheme ${scheme}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/pqc/index.ts
Normal file
49
src/pqc/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// AERE PQC signing surface — the SDK helper for AERE's LIVE native post-quantum
|
||||||
|
// verification precompiles on chain 2800, via the AerePQCAttestation registry.
|
||||||
|
//
|
||||||
|
// Full pure-JS signing (keygen + internal-interface sign + local verify) for all
|
||||||
|
// four schemes, proven interoperable against the live precompiles:
|
||||||
|
// 1 = Falcon-512, 2 = Falcon-1024, 3 = ML-DSA-44, 4 = SLH-DSA-SHA2-128s.
|
||||||
|
|
||||||
|
export {
|
||||||
|
SCHEME, SCHEME_NAME, JS_SIGNABLE, LENGTHS,
|
||||||
|
keygen, signInternal, verifyLocal,
|
||||||
|
type SchemeId, type PqcKeyPair,
|
||||||
|
} from './schemes.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FALCON_NONCE_LEN,
|
||||||
|
falconDetachedHeader, falconEsigHeader,
|
||||||
|
falconDetachedToEnvelope, falconEnvelopeToDetached,
|
||||||
|
mldsaEnvelope, slhdsaEnvelope, assertPubKey,
|
||||||
|
} from './envelope.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AerePQCClient, AERE_PQC_ATTESTATION_ABI, CHALLENGE_DOMAIN,
|
||||||
|
type AerePQCClientOptions, type RegisteredKey, type AttestationRecord, type SignAndAttestResult,
|
||||||
|
} from './AerePQCClient.js';
|
||||||
|
|
||||||
|
// AerePQCKeyRegistry — permissionless PQC public-key registry with on-chain
|
||||||
|
// proof-of-possession (registerKeyWithPoP reuses the src/pqc signer). Chain 2800.
|
||||||
|
export {
|
||||||
|
AerePQCKeyRegistryClient, AERE_PQC_KEY_REGISTRY_ABI, POP_DOMAIN, KeyStatus,
|
||||||
|
type AerePQCKeyRegistryClientOptions, type KeyRecord,
|
||||||
|
type RegisterKeyPoP, type RegisterKeyResult,
|
||||||
|
} from './AerePQCKeyRegistryClient.js';
|
||||||
|
|
||||||
|
// AereAgentDID — Falcon-rooted AI-agent DID: Falcon-PoP-gated session issuance +
|
||||||
|
// secp256k1 session-key authorization. Chain 2800.
|
||||||
|
export {
|
||||||
|
AereAgentDIDClient, AERE_AGENT_DID_ABI, SESSION_DOMAIN, ACTION_DOMAIN,
|
||||||
|
type AereAgentDIDClientOptions, type AgentRecord, type SessionRecord, type SessionParams,
|
||||||
|
type IssueSessionPoP, type IssueSessionResult, type ActionAuthorization, type AuthorizeResult,
|
||||||
|
} from './AereAgentDIDClient.js';
|
||||||
|
|
||||||
|
// AereAgentActionReceipt — PQC-anchored, publicly verifiable AI provenance: an agent
|
||||||
|
// commits an output hash signed by a DID session key, cross-wired to its AereAgentBond
|
||||||
|
// stake + AereAIReputation score. Chain 2800.
|
||||||
|
export {
|
||||||
|
AereAgentActionReceiptClient, AERE_AGENT_ACTION_RECEIPT_ABI, RECEIPT_DOMAIN,
|
||||||
|
type AereAgentActionReceiptClientOptions, type ReceiptRecord, type ReceiptVerification,
|
||||||
|
type ReceiptProvenance, type ReceiptSignature, type EmitReceiptResult,
|
||||||
|
} from './AereAgentActionReceiptClient.js';
|
||||||
131
src/pqc/schemes.ts
Normal file
131
src/pqc/schemes.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
// PQC scheme helpers for AERE's live native verification precompiles (chain 2800).
|
||||||
|
//
|
||||||
|
// keygen + internal-interface signing + local verification for each NIST scheme
|
||||||
|
// AerePQCAttestation supports. Signing is pure JS via @noble/post-quantum 0.6.x
|
||||||
|
// (audited). Every helper below has been proven, key-and-signature, against the
|
||||||
|
// LIVE on-chain precompiles: verifySignature(scheme, pubKey, msg, envelope) on
|
||||||
|
// AerePQCAttestation returns true for a signature produced here (see
|
||||||
|
// test/pqc-interop.test.ts).
|
||||||
|
//
|
||||||
|
// CRITICAL INTEROP NOTES (why this matches the precompiles byte-for-byte):
|
||||||
|
// - ML-DSA-44 uses the FIPS-204 INTERNAL interface (ml_dsa44.internal.sign): the
|
||||||
|
// 32-byte message is fed directly as M', with NO 0x00||len(ctx)||ctx external
|
||||||
|
// prefix. The default ml_dsa44.sign (external) would prepend that and NOT match.
|
||||||
|
// - SLH-DSA-128s is SLH-DSA-SHA2-128s (SPHINCS+-SHA2-128s-simple), INTERNAL
|
||||||
|
// interface (slh_dsa_sha2_128s.internal.sign). The SHAKE variant is rejected by
|
||||||
|
// the live precompile (confirmed), so we bind to SHA2 explicitly.
|
||||||
|
// - Falcon-512/1024: noble emits the exact compressed-signature body the
|
||||||
|
// precompile's BouncyCastle verifier accepts; the only transform is the 1-byte
|
||||||
|
// header (0x30|logn detached -> 0x20|logn esig), handled in envelope.ts.
|
||||||
|
|
||||||
|
import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js';
|
||||||
|
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
|
||||||
|
import { falcon512, falcon1024 } from '@noble/post-quantum/falcon.js';
|
||||||
|
import {
|
||||||
|
SCHEME, type SchemeId, LENGTHS,
|
||||||
|
falconDetachedToEnvelope, falconEnvelopeToDetached,
|
||||||
|
mldsaEnvelope, slhdsaEnvelope,
|
||||||
|
} from './envelope.js';
|
||||||
|
|
||||||
|
export { SCHEME, LENGTHS, type SchemeId } from './envelope.js';
|
||||||
|
|
||||||
|
/** Human-readable NIST name for a scheme id. */
|
||||||
|
export const SCHEME_NAME: Record<SchemeId, string> = {
|
||||||
|
[SCHEME.FALCON512]: 'Falcon-512',
|
||||||
|
[SCHEME.FALCON1024]: 'Falcon-1024',
|
||||||
|
[SCHEME.MLDSA44]: 'ML-DSA-44',
|
||||||
|
[SCHEME.SLHDSA128S]: 'SLH-DSA-SHA2-128s',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True iff every scheme id below has full pure-JS signing in this SDK. */
|
||||||
|
export const JS_SIGNABLE: Record<SchemeId, boolean> = {
|
||||||
|
[SCHEME.FALCON512]: true,
|
||||||
|
[SCHEME.FALCON1024]: true,
|
||||||
|
[SCHEME.MLDSA44]: true,
|
||||||
|
[SCHEME.SLHDSA128S]: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PqcKeyPair {
|
||||||
|
/** Raw NIST public key for the scheme (pass to registerKey / verifySignature). */
|
||||||
|
publicKey: Uint8Array;
|
||||||
|
/** Raw NIST secret key (keep private; used only by signInternal). */
|
||||||
|
secretKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensure32(message: Uint8Array): Uint8Array {
|
||||||
|
if (message.length !== 32) {
|
||||||
|
throw new Error(`pqc: signed message must be exactly 32 bytes, got ${message.length}`);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a fresh keypair for `scheme`. Optionally deterministic from a seed. */
|
||||||
|
export function keygen(scheme: SchemeId, seed?: Uint8Array): PqcKeyPair {
|
||||||
|
switch (scheme) {
|
||||||
|
case SCHEME.FALCON512:
|
||||||
|
return falcon512.keygen(seed);
|
||||||
|
case SCHEME.FALCON1024:
|
||||||
|
return falcon1024.keygen(seed);
|
||||||
|
case SCHEME.MLDSA44:
|
||||||
|
return ml_dsa44.keygen(seed);
|
||||||
|
case SCHEME.SLHDSA128S:
|
||||||
|
return slh_dsa_sha2_128s.keygen(seed);
|
||||||
|
default:
|
||||||
|
throw new Error(`pqc: unknown scheme ${scheme}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a 32-byte message with `secretKey` using the scheme's INTERNAL interface
|
||||||
|
* and return the exact `signature` envelope AerePQCAttestation.attest() /
|
||||||
|
* verifySignature() expect (NOT the raw scheme bytes for Falcon — the envelope).
|
||||||
|
*
|
||||||
|
* The message is used verbatim as the signed 32 bytes: for verifySignature() this
|
||||||
|
* is the messageHash; for attest() this is the per-key challenge (build it with
|
||||||
|
* AerePQCClient.deriveChallenge / attestChallenge, then pass it here).
|
||||||
|
*/
|
||||||
|
export function signInternal(scheme: SchemeId, message: Uint8Array, secretKey: Uint8Array): Uint8Array {
|
||||||
|
const m = ensure32(message);
|
||||||
|
switch (scheme) {
|
||||||
|
case SCHEME.FALCON512:
|
||||||
|
return falconDetachedToEnvelope(falcon512.sign(m, secretKey), SCHEME.FALCON512);
|
||||||
|
case SCHEME.FALCON1024:
|
||||||
|
return falconDetachedToEnvelope(falcon1024.sign(m, secretKey), SCHEME.FALCON1024);
|
||||||
|
case SCHEME.MLDSA44:
|
||||||
|
return mldsaEnvelope(ml_dsa44.internal.sign(m, secretKey));
|
||||||
|
case SCHEME.SLHDSA128S:
|
||||||
|
return slhdsaEnvelope(slh_dsa_sha2_128s.internal.sign(m, secretKey));
|
||||||
|
default:
|
||||||
|
throw new Error(`pqc: unknown scheme ${scheme}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an envelope locally (off-chain), mirroring what the live precompile does.
|
||||||
|
* Useful as a fast pre-check before spending gas on attest(). `signature` is the
|
||||||
|
* envelope produced by {@link signInternal}.
|
||||||
|
*/
|
||||||
|
export function verifyLocal(
|
||||||
|
scheme: SchemeId,
|
||||||
|
message: Uint8Array,
|
||||||
|
signature: Uint8Array,
|
||||||
|
publicKey: Uint8Array,
|
||||||
|
): boolean {
|
||||||
|
const m = ensure32(message);
|
||||||
|
try {
|
||||||
|
switch (scheme) {
|
||||||
|
case SCHEME.FALCON512:
|
||||||
|
return falcon512.verify(falconEnvelopeToDetached(signature, SCHEME.FALCON512), m, publicKey);
|
||||||
|
case SCHEME.FALCON1024:
|
||||||
|
return falcon1024.verify(falconEnvelopeToDetached(signature, SCHEME.FALCON1024), m, publicKey);
|
||||||
|
case SCHEME.MLDSA44:
|
||||||
|
return ml_dsa44.internal.verify(signature, m, publicKey);
|
||||||
|
case SCHEME.SLHDSA128S:
|
||||||
|
return slh_dsa_sha2_128s.internal.verify(signature, m, publicKey);
|
||||||
|
default:
|
||||||
|
throw new Error(`pqc: unknown scheme ${scheme}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
392
src/state-window.ts
Normal file
392
src/state-window.ts
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* state-window.ts — refuse to answer a state question the node cannot answer.
|
||||||
|
*
|
||||||
|
* WHY THIS FILE EXISTS
|
||||||
|
* --------------------
|
||||||
|
* The public Aere Network RPC endpoints serve a BOUNDED window of historical
|
||||||
|
* world state. Measured on 2026-08-01 against both https://rpc.aere.network and
|
||||||
|
* https://rpc2.aere.network by binary search on depth:
|
||||||
|
*
|
||||||
|
* depth 511 -> answered
|
||||||
|
* depth 512 -> not answered
|
||||||
|
*
|
||||||
|
* At the measured 517 ms block interval that is about 4 minutes 25 seconds of
|
||||||
|
* history. Block bodies, receipts and logs go back to genesis; WORLD STATE does
|
||||||
|
* not. This is normal for a pruning node and it is not a defect.
|
||||||
|
*
|
||||||
|
* The defect is in HOW the node says no. Measured at the same block, on the same
|
||||||
|
* node, in the same second:
|
||||||
|
*
|
||||||
|
* eth_getProof -> error -32000 "World state unavailable" honest
|
||||||
|
* eth_getBalance -> result: null honest
|
||||||
|
* eth_getCode -> result: null honest
|
||||||
|
* eth_getStorageAt -> result: null honest
|
||||||
|
* eth_call -> error -32603 honest
|
||||||
|
* eth_getTransactionCount -> result: "0x0" A LIE
|
||||||
|
*
|
||||||
|
* A nonce of zero is not a missing answer. It is a well formed, correctly typed,
|
||||||
|
* non-null claim that reads "this address has never signed anything". A caller
|
||||||
|
* cannot tell it apart from a brand new account without already knowing the depth
|
||||||
|
* of the window, which is exactly the thing the caller is trying to avoid needing
|
||||||
|
* to know.
|
||||||
|
*
|
||||||
|
* Proof that "0x0" is false rather than merely stale, taken from a block body so
|
||||||
|
* that it does not itself depend on world state:
|
||||||
|
*
|
||||||
|
* block 8,236,382 contains tx
|
||||||
|
* 0xbfd3620dc705cc1dbf5bf747c02f71f0d4f68227ab8b5ccafe3de15955587ce3
|
||||||
|
* from 0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465 with nonce 0x1e0b7 = 123,063
|
||||||
|
*
|
||||||
|
* eth_getTransactionCount(0xbeb33d20…, 0x7dad5e) -> "0x0" on both endpoints
|
||||||
|
*
|
||||||
|
* WHAT THIS MODULE GUARANTEES
|
||||||
|
* ---------------------------
|
||||||
|
* Every read here either returns a value the node actually holds, or throws
|
||||||
|
* StateWindowError. It never returns a placeholder. A tool that cannot measure
|
||||||
|
* must say so; a wrong answer is worse than no answer.
|
||||||
|
*
|
||||||
|
* Three independent guards, because any one of them alone has a hole:
|
||||||
|
*
|
||||||
|
* 1. PRE-FLIGHT. Numeric block tags are compared against the head before the
|
||||||
|
* call is made. Deeper than the window, the call is not even sent.
|
||||||
|
* 2. POST-FLIGHT. A null / "0x" result is treated as "the node does not hold
|
||||||
|
* this", never coerced to zero, empty or false.
|
||||||
|
* 3. CORROBORATION. eth_getTransactionCount is never trusted on a numeric tag
|
||||||
|
* on its own. A companion eth_getBalance is issued at the SAME tag; that
|
||||||
|
* call is honest about missing state, so if it comes back null the nonce is
|
||||||
|
* discarded. This is what turns the lie back into a refusal.
|
||||||
|
*
|
||||||
|
* The window edge is a RACE, not a fence: a read launched at nominal depth 511
|
||||||
|
* has been observed to land outside the window because the head advanced between
|
||||||
|
* eth_blockNumber and the read. Hence SAFETY_MARGIN_BLOCKS.
|
||||||
|
*
|
||||||
|
* Read-only. This module sends no transactions and holds no keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Measured 2026-08-01 on rpc.aere.network and rpc2.aere.network: 511 answers, 512 does not. */
|
||||||
|
export const DEFAULT_STATE_WINDOW_BLOCKS = 512;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks held back from the nominal edge. The edge is racy: the head can advance
|
||||||
|
* between reading it and issuing the state call, which is how a nominal depth of
|
||||||
|
* 511 was measured falling outside the window.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SAFETY_MARGIN_BLOCKS = 8;
|
||||||
|
|
||||||
|
/** Measured 2026-08-01 over 2000 blocks: 0.5175 s. Used only for human-readable messages. */
|
||||||
|
export const MEASURED_BLOCK_INTERVAL_SECONDS = 0.5175;
|
||||||
|
|
||||||
|
export type StateWindowReason =
|
||||||
|
| 'requested-block-outside-window'
|
||||||
|
| 'node-returned-null'
|
||||||
|
| 'nonce-not-corroborated';
|
||||||
|
|
||||||
|
export interface StateWindowErrorDetail {
|
||||||
|
reason: StateWindowReason;
|
||||||
|
method: string;
|
||||||
|
requestedBlock?: number;
|
||||||
|
head?: number;
|
||||||
|
depth?: number;
|
||||||
|
windowBlocks: number;
|
||||||
|
safetyMarginBlocks: number;
|
||||||
|
/** What the node literally returned, when it returned something. Kept so callers can report it. */
|
||||||
|
rawResult?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown instead of returning a placeholder. Catching this and substituting a
|
||||||
|
* zero re-introduces exactly the bug this module exists to remove; if you need a
|
||||||
|
* value for a deep block, use an archive node, not a default.
|
||||||
|
*/
|
||||||
|
export class StateWindowError extends Error {
|
||||||
|
readonly code = 'STATE_OUTSIDE_WINDOW' as const;
|
||||||
|
readonly detail: StateWindowErrorDetail;
|
||||||
|
|
||||||
|
constructor(detail: StateWindowErrorDetail) {
|
||||||
|
super(StateWindowError.describe(detail));
|
||||||
|
this.name = 'StateWindowError';
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
static describe(d: StateWindowErrorDetail): string {
|
||||||
|
const windowSeconds = Math.round(d.windowBlocks * MEASURED_BLOCK_INTERVAL_SECONDS);
|
||||||
|
const where =
|
||||||
|
d.requestedBlock != null && d.head != null
|
||||||
|
? ` Requested block ${d.requestedBlock} at depth ${d.depth} below head ${d.head}.`
|
||||||
|
: '';
|
||||||
|
const why = {
|
||||||
|
'requested-block-outside-window':
|
||||||
|
'The requested block is deeper than the world-state window this endpoint serves.',
|
||||||
|
'node-returned-null':
|
||||||
|
'The node returned no state for the requested block, which means it no longer holds it.',
|
||||||
|
'nonce-not-corroborated':
|
||||||
|
'eth_getTransactionCount returned a value, but a companion eth_getBalance at the same block returned null, ' +
|
||||||
|
'so the node does not hold the state at that height and the nonce is not real. ' +
|
||||||
|
'This endpoint answers 0x0 for a pruned nonce, which is indistinguishable from a never-used account.',
|
||||||
|
}[d.reason];
|
||||||
|
return (
|
||||||
|
`${d.method}: state unavailable, refusing to guess.${where} ` +
|
||||||
|
`This endpoint serves about ${d.windowBlocks} blocks of world state, roughly ${windowSeconds} seconds. ` +
|
||||||
|
`${why} ` +
|
||||||
|
`NOT MEASURED is the correct value here. Block bodies, receipts and logs remain available at any depth.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal JSON-RPC surface. An ethers v6 provider satisfies this via provider.send. */
|
||||||
|
export type JsonRpcSend = (method: string, params: unknown[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export type BlockTag = number | bigint | 'latest' | 'pending' | 'earliest' | string;
|
||||||
|
|
||||||
|
export interface StateWindowReaderOptions {
|
||||||
|
/** Blocks of world state the endpoint serves. Default 512 (measured). */
|
||||||
|
windowBlocks?: number;
|
||||||
|
/** Blocks held back from the edge to survive the head advancing mid-call. Default 8. */
|
||||||
|
safetyMarginBlocks?: number;
|
||||||
|
/**
|
||||||
|
* Issue a companion eth_getBalance alongside eth_getTransactionCount on numeric
|
||||||
|
* tags. Default true. Turning this off restores the false-zero hazard.
|
||||||
|
*/
|
||||||
|
corroborateNonce?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNumericTag(tag: BlockTag): tag is number | bigint | string {
|
||||||
|
if (typeof tag === 'number' || typeof tag === 'bigint') return true;
|
||||||
|
if (typeof tag !== 'string') return false;
|
||||||
|
return /^0x[0-9a-fA-F]+$/.test(tag) || /^[0-9]+$/.test(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagToNumber(tag: number | bigint | string): number {
|
||||||
|
if (typeof tag === 'number') return tag;
|
||||||
|
if (typeof tag === 'bigint') return Number(tag);
|
||||||
|
return tag.startsWith('0x') ? Number.parseInt(tag, 16) : Number.parseInt(tag, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHexTag(tag: BlockTag): string {
|
||||||
|
if (isNumericTag(tag)) return '0x' + tagToNumber(tag).toString(16);
|
||||||
|
return String(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A result the node gave us that means "I do not hold this", not "the value is zero". */
|
||||||
|
function isAbsent(v: unknown): boolean {
|
||||||
|
return v === null || v === undefined || v === '0x';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads world state, or refuses. Never returns a placeholder.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const reader = new StateWindowReader((m, p) => provider.send(m, p));
|
||||||
|
* await reader.getTransactionCount(addr); // fine, tag defaults to latest
|
||||||
|
* await reader.getTransactionCount(addr, 8_236_382); // throws StateWindowError
|
||||||
|
*/
|
||||||
|
export class StateWindowReader {
|
||||||
|
readonly windowBlocks: number;
|
||||||
|
readonly safetyMarginBlocks: number;
|
||||||
|
readonly corroborateNonce: boolean;
|
||||||
|
private readonly send: JsonRpcSend;
|
||||||
|
|
||||||
|
constructor(send: JsonRpcSend, opts: StateWindowReaderOptions = {}) {
|
||||||
|
this.send = send;
|
||||||
|
this.windowBlocks = opts.windowBlocks ?? DEFAULT_STATE_WINDOW_BLOCKS;
|
||||||
|
this.safetyMarginBlocks = opts.safetyMarginBlocks ?? DEFAULT_SAFETY_MARGIN_BLOCKS;
|
||||||
|
this.corroborateNonce = opts.corroborateNonce ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build from anything with an ethers-style .send(method, params). */
|
||||||
|
static fromProvider(provider: { send: (m: string, p: unknown[]) => Promise<unknown> }, opts: StateWindowReaderOptions = {}): StateWindowReader {
|
||||||
|
return new StateWindowReader((m, p) => provider.send(m, p), opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async head(): Promise<number> {
|
||||||
|
const h = await this.send('eth_blockNumber', []);
|
||||||
|
return Number.parseInt(String(h), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shallowest block this reader will accept right now. Useful for pinning a run. */
|
||||||
|
async oldestServableBlock(): Promise<number> {
|
||||||
|
const head = await this.head();
|
||||||
|
return head - (this.windowBlocks - this.safetyMarginBlocks) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the tag is servable right now. Symbolic tags are always servable.
|
||||||
|
* Racy by nature; the reader re-checks on every call, so prefer the reads.
|
||||||
|
*/
|
||||||
|
async isWithinWindow(tag: BlockTag): Promise<boolean> {
|
||||||
|
if (!isNumericTag(tag)) return true;
|
||||||
|
const head = await this.head();
|
||||||
|
return head - tagToNumber(tag) < this.windowBlocks - this.safetyMarginBlocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guard 1: pre-flight. Throws before the call is sent. */
|
||||||
|
private async assertServable(method: string, tag: BlockTag): Promise<void> {
|
||||||
|
if (!isNumericTag(tag)) return;
|
||||||
|
const n = tagToNumber(tag);
|
||||||
|
const head = await this.head();
|
||||||
|
const depth = head - n;
|
||||||
|
if (depth >= this.windowBlocks - this.safetyMarginBlocks) {
|
||||||
|
throw new StateWindowError({
|
||||||
|
reason: 'requested-block-outside-window',
|
||||||
|
method,
|
||||||
|
requestedBlock: n,
|
||||||
|
head,
|
||||||
|
depth,
|
||||||
|
windowBlocks: this.windowBlocks,
|
||||||
|
safetyMarginBlocks: this.safetyMarginBlocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guard 2: post-flight. A null is a refusal, never a zero. */
|
||||||
|
private assertPresent(method: string, tag: BlockTag, raw: unknown): void {
|
||||||
|
if (!isAbsent(raw)) return;
|
||||||
|
throw new StateWindowError({
|
||||||
|
reason: 'node-returned-null',
|
||||||
|
method,
|
||||||
|
requestedBlock: isNumericTag(tag) ? tagToNumber(tag) : undefined,
|
||||||
|
windowBlocks: this.windowBlocks,
|
||||||
|
safetyMarginBlocks: this.safetyMarginBlocks,
|
||||||
|
rawResult: raw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(address: string, tag: BlockTag = 'latest'): Promise<bigint> {
|
||||||
|
await this.assertServable('eth_getBalance', tag);
|
||||||
|
const raw = await this.send('eth_getBalance', [address, toHexTag(tag)]);
|
||||||
|
this.assertPresent('eth_getBalance', tag, raw);
|
||||||
|
return BigInt(String(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCode(address: string, tag: BlockTag = 'latest'): Promise<string> {
|
||||||
|
await this.assertServable('eth_getCode', tag);
|
||||||
|
const raw = await this.send('eth_getCode', [address, toHexTag(tag)]);
|
||||||
|
// "0x" is a legitimate answer here (an account with no code), so only null counts as absent.
|
||||||
|
if (raw === null || raw === undefined) {
|
||||||
|
this.assertPresent('eth_getCode', tag, raw);
|
||||||
|
}
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStorageAt(address: string, slot: string, tag: BlockTag = 'latest'): Promise<string> {
|
||||||
|
await this.assertServable('eth_getStorageAt', tag);
|
||||||
|
const raw = await this.send('eth_getStorageAt', [address, slot, toHexTag(tag)]);
|
||||||
|
this.assertPresent('eth_getStorageAt', tag, raw);
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(tx: Record<string, unknown>, tag: BlockTag = 'latest'): Promise<string> {
|
||||||
|
await this.assertServable('eth_call', tag);
|
||||||
|
const raw = await this.send('eth_call', [tx, toHexTag(tag)]);
|
||||||
|
this.assertPresent('eth_call', tag, raw);
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dangerous one. On a numeric tag this issues a companion eth_getBalance at
|
||||||
|
* the same block and discards the nonce if that companion comes back null,
|
||||||
|
* because this endpoint answers a pruned nonce with 0x0 rather than an error.
|
||||||
|
*
|
||||||
|
* Guard 3. Without it, a caller that happens to race past the pre-flight check
|
||||||
|
* gets a well formed 0 and believes it.
|
||||||
|
*/
|
||||||
|
async getTransactionCount(address: string, tag: BlockTag = 'latest'): Promise<number> {
|
||||||
|
await this.assertServable('eth_getTransactionCount', tag);
|
||||||
|
const raw = await this.send('eth_getTransactionCount', [address, toHexTag(tag)]);
|
||||||
|
this.assertPresent('eth_getTransactionCount', tag, raw);
|
||||||
|
|
||||||
|
if (this.corroborateNonce && isNumericTag(tag)) {
|
||||||
|
const witness = await this.send('eth_getBalance', [address, toHexTag(tag)]);
|
||||||
|
if (isAbsent(witness)) {
|
||||||
|
throw new StateWindowError({
|
||||||
|
reason: 'nonce-not-corroborated',
|
||||||
|
method: 'eth_getTransactionCount',
|
||||||
|
requestedBlock: tagToNumber(tag),
|
||||||
|
windowBlocks: this.windowBlocks,
|
||||||
|
safetyMarginBlocks: this.safetyMarginBlocks,
|
||||||
|
rawResult: raw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.parseInt(String(raw), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The question behind open finding D-001: has this address ever signed anything?
|
||||||
|
*
|
||||||
|
* Answered ONLY at latest, because that is the only height at which this
|
||||||
|
* endpoint's nonce answer is trustworthy. Returns the evidence alongside the
|
||||||
|
* verdict so the caller can quote it.
|
||||||
|
*/
|
||||||
|
async hasEverSigned(address: string): Promise<{
|
||||||
|
signed: boolean;
|
||||||
|
nonce: number;
|
||||||
|
atBlock: number;
|
||||||
|
method: string;
|
||||||
|
}> {
|
||||||
|
const atBlock = await this.head();
|
||||||
|
const nonce = await this.getTransactionCount(address, 'latest');
|
||||||
|
return {
|
||||||
|
signed: nonce > 0,
|
||||||
|
nonce,
|
||||||
|
atBlock,
|
||||||
|
method: 'eth_getTransactionCount at latest; deep tags are refused because this endpoint answers 0x0 for pruned state',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the real window instead of trusting the constant. Binary search on
|
||||||
|
* depth using eth_getBalance, which is honest about missing state. Re-reads the
|
||||||
|
* head at every step so the moving tip does not skew the result.
|
||||||
|
*
|
||||||
|
* Costs about 2 * log2(maxDepth) RPC calls. Read-only.
|
||||||
|
*/
|
||||||
|
async measureWindow(address = '0x0000000000000000000000000000000000000000', maxDepth = 1_000_000): Promise<{
|
||||||
|
head: number;
|
||||||
|
deepestOkDepth: number;
|
||||||
|
firstFailDepth: number;
|
||||||
|
}> {
|
||||||
|
const probe = async (depth: number): Promise<{ ok: boolean; head: number }> => {
|
||||||
|
const head = await this.head();
|
||||||
|
const raw = await this.send('eth_getBalance', [address, '0x' + (head - depth).toString(16)]).catch(() => null);
|
||||||
|
return { ok: !isAbsent(raw), head };
|
||||||
|
};
|
||||||
|
|
||||||
|
let lo = 0;
|
||||||
|
let hi = maxDepth;
|
||||||
|
let head = await this.head();
|
||||||
|
while (lo + 1 < hi) {
|
||||||
|
const mid = Math.floor((lo + hi) / 2);
|
||||||
|
const r = await probe(mid);
|
||||||
|
head = r.head;
|
||||||
|
if (r.ok) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return { head, deepestOkDepth: lo, firstFailDepth: hi };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free function for callers that already have a block number and a head and just
|
||||||
|
* want the guard. Throws StateWindowError; returns void on success.
|
||||||
|
*/
|
||||||
|
export function assertStateServable(
|
||||||
|
method: string,
|
||||||
|
requestedBlock: number,
|
||||||
|
head: number,
|
||||||
|
windowBlocks = DEFAULT_STATE_WINDOW_BLOCKS,
|
||||||
|
safetyMarginBlocks = DEFAULT_SAFETY_MARGIN_BLOCKS,
|
||||||
|
): void {
|
||||||
|
const depth = head - requestedBlock;
|
||||||
|
if (depth >= windowBlocks - safetyMarginBlocks) {
|
||||||
|
throw new StateWindowError({
|
||||||
|
reason: 'requested-block-outside-window',
|
||||||
|
method,
|
||||||
|
requestedBlock,
|
||||||
|
head,
|
||||||
|
depth,
|
||||||
|
windowBlocks,
|
||||||
|
safetyMarginBlocks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/test/agent-action-receipt.test.ts
Normal file
94
src/test/agent-action-receipt.test.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// Tests for AereAgentActionReceiptClient — PQC-anchored AI provenance.
|
||||||
|
//
|
||||||
|
// OFFLINE only: AereAgentActionReceipt is not yet in the mainnet address book, so there
|
||||||
|
// is no live deployment to read. These tests pin the client's local digest derivation to
|
||||||
|
// a KAT captured directly from the Solidity contract's receiptDigest() view (deployed on
|
||||||
|
// the hardhat network at a deterministic address / chainId), and check that a session-key
|
||||||
|
// signature over that digest recovers to the session address exactly as the contract's
|
||||||
|
// ecrecover would. Because the Solidity side independently asserts receiptDigest ==
|
||||||
|
// keccak256(abi.encode(...)) (see contracts test/AereAgentActionReceipt.test.js
|
||||||
|
// "digest preimage"), matching this KAT proves the SDK encoding agrees with the contract.
|
||||||
|
//
|
||||||
|
// Run: npm run build && node --test dist/test/*.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getAddress, keccak256, toUtf8Bytes, computeAddress, recoverAddress } from 'ethers';
|
||||||
|
import {
|
||||||
|
AereAgentActionReceiptClient, RECEIPT_DOMAIN,
|
||||||
|
} from '../pqc/AereAgentActionReceiptClient.js';
|
||||||
|
|
||||||
|
// ── Frozen KAT captured live from the AereAgentActionReceipt.receiptDigest view ──
|
||||||
|
// (hardhat network: first-deployment address 0x5FbD…0aa3, chainId 31337).
|
||||||
|
const KAT = {
|
||||||
|
address: getAddress('0x5FbDB2315678afecb367f032d93F642f64180aa3'),
|
||||||
|
chainId: 31337,
|
||||||
|
agentId: 3n,
|
||||||
|
sessionId: 5n,
|
||||||
|
outputHash: '0x504366b090b0e2cbbb1a975cbe2558c789b418620876d3589df989a5dc6628af',
|
||||||
|
scope: '0xd7a36943e51c07dfcfbaad7cf952d78d3d3ea8aede2622c66f0736c7a6fdae94',
|
||||||
|
nonce: 7n,
|
||||||
|
digest: '0x74f799f1d9d48b67aa4f0f87deb4795cf27ca9806b70a12dd54bcae8517be6eb',
|
||||||
|
};
|
||||||
|
|
||||||
|
function client() {
|
||||||
|
// A ContractRunner is only needed for on-chain calls; digest derivation is local.
|
||||||
|
return new AereAgentActionReceiptClient({} as never, { address: KAT.address, chainId: KAT.chainId });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('OFFLINE: RECEIPT_DOMAIN matches its keccak256 preimage', () => {
|
||||||
|
assert.equal(RECEIPT_DOMAIN, '0x600b1d777068fcfab8cf4103f2893f284e53c2bfb7ea423be179f47fcfd46c4a');
|
||||||
|
assert.equal(RECEIPT_DOMAIN, keccak256(toUtf8Bytes('AereAgentActionReceipt.v1.receipt')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: deriveReceiptDigest matches the frozen on-chain KAT', () => {
|
||||||
|
const c = client();
|
||||||
|
const local = c.deriveReceiptDigest(KAT.agentId, KAT.sessionId, KAT.outputHash, KAT.scope, KAT.nonce);
|
||||||
|
assert.equal(local, KAT.digest);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: static bondKeyOf mirrors bytes32(rootKeyId)', () => {
|
||||||
|
assert.equal(
|
||||||
|
AereAgentActionReceiptClient.bondKeyOf(7),
|
||||||
|
'0x0000000000000000000000000000000000000000000000000000000000000007',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
AereAgentActionReceiptClient.bondKeyOf(0),
|
||||||
|
'0x0000000000000000000000000000000000000000000000000000000000000000',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: buildReceiptSignature signs the receipt digest and recovers to the session address', () => {
|
||||||
|
const c = client();
|
||||||
|
const sessionPriv = '0x' + '11'.repeat(32);
|
||||||
|
const sessionAddr = computeAddress(sessionPriv);
|
||||||
|
|
||||||
|
const built = c.buildReceiptSignature(
|
||||||
|
KAT.agentId, KAT.sessionId, KAT.outputHash, KAT.scope, KAT.nonce, sessionPriv,
|
||||||
|
);
|
||||||
|
assert.equal(built.digest, KAT.digest, 'signature is over the receipt digest');
|
||||||
|
assert.equal(built.nonce, KAT.nonce);
|
||||||
|
|
||||||
|
// The contract recovers the raw digest with ecrecover (no personal-message prefix).
|
||||||
|
const recovered = recoverAddress(built.digest, built.signature);
|
||||||
|
assert.equal(getAddress(recovered), getAddress(sessionAddr), 'signature must recover to the session key');
|
||||||
|
|
||||||
|
// 65-byte sig, v in {27,28} for the contract _recover guard.
|
||||||
|
assert.equal((built.signature.length - 2) / 2, 65);
|
||||||
|
const v = parseInt(built.signature.slice(-2), 16);
|
||||||
|
assert.ok(v === 27 || v === 28, 'v must be 27 or 28');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: a tampered output hash yields a different digest (tamper-evident)', () => {
|
||||||
|
const c = client();
|
||||||
|
const tamperedOutput = keccak256(toUtf8Bytes('a different output'));
|
||||||
|
const digest2 = c.deriveReceiptDigest(KAT.agentId, KAT.sessionId, tamperedOutput, KAT.scope, KAT.nonce);
|
||||||
|
assert.notEqual(digest2, KAT.digest, 'changing the output hash must change the signed digest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: constructor requires an explicit address', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => new AereAgentActionReceiptClient({} as never, { address: '' }),
|
||||||
|
/explicit `address` is required/,
|
||||||
|
);
|
||||||
|
});
|
||||||
149
src/test/agent-did.test.ts
Normal file
149
src/test/agent-did.test.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// Tests for AereAgentDIDClient (0xce64…22C5) on chain 2800.
|
||||||
|
//
|
||||||
|
// - OFFLINE: domain constants, locally-derived session challenge + action digest
|
||||||
|
// == frozen KATs captured live from the contract views (keccak cross-check), the
|
||||||
|
// Falcon issuance PoP construction path (precompile-valid), and the secp256k1
|
||||||
|
// session-key authorization (recovers to the session address).
|
||||||
|
// - LIVE: sessionCount / keyRegistry / agentExists + local vs on-chain challenge
|
||||||
|
// & digest cross-checks. LIVE asserts SKIP if RPC is down.
|
||||||
|
//
|
||||||
|
// A live issueSession / authorize transaction is NOT attempted: issueSession needs a
|
||||||
|
// created agent rooted in a live Falcon key, and both mutate chain state. The
|
||||||
|
// construction paths (the exact bytes each write would submit) are unit-tested here;
|
||||||
|
// the live write path is deferred to a funded signer + a registered Falcon root key.
|
||||||
|
//
|
||||||
|
// Run: npm run build && node --test dist/test/*.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JsonRpcProvider, getAddress, keccak256, toUtf8Bytes, computeAddress, recoverAddress } from 'ethers';
|
||||||
|
import {
|
||||||
|
AereAgentDIDClient, SESSION_DOMAIN, ACTION_DOMAIN, type SessionParams,
|
||||||
|
} from '../pqc/AereAgentDIDClient.js';
|
||||||
|
import { SCHEME, keygen, verifyLocal } from '../pqc/index.js';
|
||||||
|
|
||||||
|
const RPC = 'https://rpc.aere.network';
|
||||||
|
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||||
|
const did = new AereAgentDIDClient(provider);
|
||||||
|
|
||||||
|
// ── Frozen KAT samples (captured live from the AereAgentDID views) ──
|
||||||
|
const KAT_SESSION: SessionParams = {
|
||||||
|
rootKeyId: 0n,
|
||||||
|
sessionAddr: getAddress('0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3'),
|
||||||
|
scopeHash: '0x' + '22'.repeat(32),
|
||||||
|
spendCap: 1000000n,
|
||||||
|
expiry: 2000000000n,
|
||||||
|
};
|
||||||
|
const KAT_SESSION_NONCE = 5n;
|
||||||
|
const KAT_SESSION_CHALLENGE = '0x764d44f606e5db855e134854f464599a2381df648ef68f4e6ebd20c39bff6fe5';
|
||||||
|
|
||||||
|
const KAT_ACTION = {
|
||||||
|
sessionId: 0n,
|
||||||
|
scope: '0x' + '22'.repeat(32),
|
||||||
|
actionHash: '0x' + '33'.repeat(32),
|
||||||
|
amount: 42n,
|
||||||
|
actionNonce: 7n,
|
||||||
|
};
|
||||||
|
const KAT_ACTION_DIGEST = '0x9f7e02d766a826a1ec7a428673925053df423b2c58e90a079347d7513b252106';
|
||||||
|
|
||||||
|
function isNetworkError(e: unknown): boolean {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||||
|
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||||
|
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetworkError(e)) {
|
||||||
|
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||||
|
console.log(notice);
|
||||||
|
t.skip(notice);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────── OFFLINE ───────────────────────────────
|
||||||
|
|
||||||
|
test('OFFLINE: SESSION_DOMAIN / ACTION_DOMAIN match their keccak256 preimages', () => {
|
||||||
|
assert.equal(SESSION_DOMAIN, '0x4f931e62100bdfb0fc216124bfd281c0dc571560a1846b227ca3a384c5f75323');
|
||||||
|
assert.equal(SESSION_DOMAIN, keccak256(toUtf8Bytes('AereAgentDID.v1.issueSession')));
|
||||||
|
assert.equal(ACTION_DOMAIN, '0x107d0ecfddfd1223692cd8385f7be7d203565c50d5679fe4b1397cec4e0960a2');
|
||||||
|
assert.equal(ACTION_DOMAIN, keccak256(toUtf8Bytes('AereAgentDID.v1.action')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: deriveSessionChallenge matches the frozen on-chain KAT', () => {
|
||||||
|
const local = did.deriveSessionChallenge(KAT_SESSION, KAT_SESSION_NONCE);
|
||||||
|
assert.equal(local, KAT_SESSION_CHALLENGE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: deriveActionDigest matches the frozen on-chain KAT', () => {
|
||||||
|
const local = did.deriveActionDigest(
|
||||||
|
KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce,
|
||||||
|
);
|
||||||
|
assert.equal(local, KAT_ACTION_DIGEST);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: buildIssueSessionPoP produces a Falcon PoP the precompile logic accepts', () => {
|
||||||
|
const { publicKey, secretKey } = keygen(SCHEME.FALCON512);
|
||||||
|
const pop = did.buildIssueSessionPoP(KAT_SESSION, secretKey, SCHEME.FALCON512, KAT_SESSION_NONCE);
|
||||||
|
assert.equal(pop.challenge, KAT_SESSION_CHALLENGE, 'PoP is over the issuance challenge');
|
||||||
|
|
||||||
|
const ok = verifyLocal(SCHEME.FALCON512, Buffer.from(pop.challenge.slice(2), 'hex'), pop.popSig, publicKey);
|
||||||
|
assert.equal(ok, true, 'the Falcon PoP must verify against the issuance challenge');
|
||||||
|
|
||||||
|
// Non-Falcon roots are rejected (an agent DID root is always Falcon).
|
||||||
|
assert.throws(() => did.buildIssueSessionPoP(KAT_SESSION, secretKey, SCHEME.MLDSA44, 0n), /must be a Falcon scheme/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: buildAuthorization signs the action digest and recovers to the session address', () => {
|
||||||
|
const sessionPriv = '0x' + '11'.repeat(32);
|
||||||
|
const sessionAddr = computeAddress(sessionPriv);
|
||||||
|
|
||||||
|
const auth = did.buildAuthorization(
|
||||||
|
KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce, sessionPriv,
|
||||||
|
);
|
||||||
|
assert.equal(auth.digest, KAT_ACTION_DIGEST, 'authorization is over the action digest');
|
||||||
|
|
||||||
|
// The contract recovers the raw digest with ecrecover (no personal-message prefix).
|
||||||
|
const recovered = recoverAddress(auth.digest, auth.signature);
|
||||||
|
assert.equal(getAddress(recovered), getAddress(sessionAddr), 'signature must recover to the session key');
|
||||||
|
|
||||||
|
// 65-byte sig, v in {27,28}.
|
||||||
|
assert.equal((auth.signature.length - 2) / 2, 65);
|
||||||
|
const v = parseInt(auth.signature.slice(-2), 16);
|
||||||
|
assert.ok(v === 27 || v === 28, 'v must be 27 or 28 for the contract _recover guard');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────── LIVE ─────────────────────────────────
|
||||||
|
|
||||||
|
test('LIVE: sessionCount readable, keyRegistry() wired, agentExists(random) false', async (t) => {
|
||||||
|
const out = await onChain(t, async () => ({
|
||||||
|
count: await did.sessionCount(),
|
||||||
|
registry: await did.keyRegistry(),
|
||||||
|
exists: await did.agentExists(123456789),
|
||||||
|
}));
|
||||||
|
if (out === undefined) return;
|
||||||
|
assert.equal(typeof out.count, 'bigint');
|
||||||
|
assert.equal(getAddress(out.registry), getAddress('0x1eCa3c5ADcBD0b22636D8672b00faC6D89363691'));
|
||||||
|
assert.equal(out.exists, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: local session challenge & action digest == the on-chain views', async (t) => {
|
||||||
|
const out = await onChain(t, async () => ({
|
||||||
|
sc: await did.sessionChallenge(KAT_SESSION, KAT_SESSION_NONCE),
|
||||||
|
ad: await did.actionDigest(KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce),
|
||||||
|
}));
|
||||||
|
if (out === undefined) return;
|
||||||
|
assert.equal(did.deriveSessionChallenge(KAT_SESSION, KAT_SESSION_NONCE), out.sc);
|
||||||
|
assert.equal(out.sc, KAT_SESSION_CHALLENGE);
|
||||||
|
assert.equal(
|
||||||
|
did.deriveActionDigest(KAT_ACTION.sessionId, KAT_ACTION.scope, KAT_ACTION.actionHash, KAT_ACTION.amount, KAT_ACTION.actionNonce),
|
||||||
|
out.ad,
|
||||||
|
);
|
||||||
|
assert.equal(out.ad, KAT_ACTION_DIGEST);
|
||||||
|
});
|
||||||
132
src/test/crypto-agility-live.test.ts
Normal file
132
src/test/crypto-agility-live.test.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
// LIVE-CHAIN read tests for AereCryptoRegistry (0xaE6f…baa5) on chain 2800.
|
||||||
|
// Read-only: every call is an eth_call. No transactions are sent.
|
||||||
|
//
|
||||||
|
// Run: npm run build && node --test dist/test/*.js
|
||||||
|
//
|
||||||
|
// If rpc.aere.network is unreachable (offline CI), the network-dependent asserts
|
||||||
|
// SKIP with a clear notice rather than passing silently.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JsonRpcProvider, getAddress } from 'ethers';
|
||||||
|
import { AereCryptoRegistryClient, CryptoStatus } from '../crypto-agility/index.js';
|
||||||
|
|
||||||
|
const RPC = 'https://rpc.aere.network';
|
||||||
|
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||||
|
const registry = new AereCryptoRegistryClient(provider);
|
||||||
|
|
||||||
|
// address(0x0AE1) — the live Falcon-512 precompile.
|
||||||
|
const PRECOMPILE_FALCON512 = getAddress('0x0000000000000000000000000000000000000ae1');
|
||||||
|
|
||||||
|
function isNetworkError(e: unknown): boolean {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||||
|
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||||
|
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an on-chain call; skip the test on a connectivity failure, rethrow real errors. */
|
||||||
|
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetworkError(e)) {
|
||||||
|
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||||
|
console.log(notice);
|
||||||
|
t.skip(notice);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('LIVE: algorithmCount() == 5 (the seeded live schemes)', async (t) => {
|
||||||
|
const n = await onChain(t, () => registry.algorithmCount());
|
||||||
|
if (n === undefined) return;
|
||||||
|
assert.equal(n, 5n, 'AereCryptoRegistry seeded exactly 5 live schemes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: getAlgorithm rows match the seeded live precompile table', async (t) => {
|
||||||
|
const rows = await onChain(t, () => Promise.all([1, 2, 3, 4, 5].map((i) => registry.getAlgorithm(i))));
|
||||||
|
if (rows === undefined) return;
|
||||||
|
|
||||||
|
const [f512, f1024, mldsa, slhdsa, shake] = rows;
|
||||||
|
|
||||||
|
assert.equal(f512.name, 'Falcon-512');
|
||||||
|
assert.equal(f512.scheme, 1);
|
||||||
|
assert.equal(getAddress(f512.verifier), PRECOMPILE_FALCON512);
|
||||||
|
assert.equal(f512.pubKeyLen, 897);
|
||||||
|
assert.equal(f512.esigHeader, 0x29);
|
||||||
|
assert.equal(f512.isSignature, true);
|
||||||
|
assert.equal(f512.status, CryptoStatus.ACTIVE);
|
||||||
|
|
||||||
|
assert.equal(f1024.name, 'Falcon-1024');
|
||||||
|
assert.equal(f1024.pubKeyLen, 1793);
|
||||||
|
assert.equal(f1024.esigHeader, 0x2a);
|
||||||
|
|
||||||
|
assert.equal(mldsa.name, 'ML-DSA-44');
|
||||||
|
assert.equal(mldsa.sigLen, 2420);
|
||||||
|
assert.equal(mldsa.esigHeader, 0);
|
||||||
|
|
||||||
|
assert.equal(slhdsa.name, 'SLH-DSA-128s');
|
||||||
|
assert.equal(slhdsa.sigLen, 7856);
|
||||||
|
|
||||||
|
// SHAKE256 is a HASH entry, not a signature scheme.
|
||||||
|
assert.equal(shake.name, 'SHAKE256');
|
||||||
|
assert.equal(shake.isSignature, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: isActive / isUsable / statusOf behave (signature vs hash-only)', async (t) => {
|
||||||
|
const out = await onChain(t, async () => ({
|
||||||
|
active1: await registry.isActive(1),
|
||||||
|
usable1: await registry.isUsable(1),
|
||||||
|
status1: await registry.statusOf(1),
|
||||||
|
// SHAKE256 (id 5) is ACTIVE but hash-only, so NOT usable as a signature scheme.
|
||||||
|
active5: await registry.isActive(5),
|
||||||
|
usable5: await registry.isUsable(5),
|
||||||
|
// an unregistered id is UNKNOWN / not active / not usable.
|
||||||
|
active99: await registry.isActive(99),
|
||||||
|
usable99: await registry.isUsable(99),
|
||||||
|
status99: await registry.statusOf(99),
|
||||||
|
}));
|
||||||
|
if (out === undefined) return;
|
||||||
|
|
||||||
|
assert.equal(out.active1, true);
|
||||||
|
assert.equal(out.usable1, true);
|
||||||
|
assert.equal(out.status1, CryptoStatus.ACTIVE);
|
||||||
|
|
||||||
|
assert.equal(out.active5, true, 'SHAKE256 row is ACTIVE');
|
||||||
|
assert.equal(out.usable5, false, 'SHAKE256 is hash-only, never usable as a signature scheme');
|
||||||
|
|
||||||
|
assert.equal(out.active99, false);
|
||||||
|
assert.equal(out.usable99, false);
|
||||||
|
assert.equal(out.status99, CryptoStatus.UNKNOWN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: resolveActive(1) == 1 and precompileFor(1) == 0x..0ae1', async (t) => {
|
||||||
|
const out = await onChain(t, async () => ({
|
||||||
|
resolved: await registry.resolveActive(1),
|
||||||
|
precompile: await registry.precompileFor(1),
|
||||||
|
seeded: await registry.seeded(),
|
||||||
|
}));
|
||||||
|
if (out === undefined) return;
|
||||||
|
assert.equal(out.resolved, 1n, 'an ACTIVE row resolves to itself');
|
||||||
|
assert.equal(getAddress(out.precompile), PRECOMPILE_FALCON512);
|
||||||
|
assert.equal(out.seeded, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: verify() is FAIL-CLOSED for garbage / unknown / hash-only inputs', async (t) => {
|
||||||
|
const msg = '0x' + '11'.repeat(32);
|
||||||
|
const out = await onChain(t, async () => ({
|
||||||
|
// unknown algorithmId -> false
|
||||||
|
unknownId: await registry.verify(999, '0x00', msg, '0xdead'),
|
||||||
|
// hash-only entry (SHAKE256) -> refuses to route -> false
|
||||||
|
hashOnly: await registry.verify(5, '0x', msg, '0x'),
|
||||||
|
// valid signature id but wrong-length pubKey + garbage sig -> false
|
||||||
|
garbage: await registry.verify(1, '0x1234', msg, '0xdeadbeef'),
|
||||||
|
}));
|
||||||
|
if (out === undefined) return;
|
||||||
|
assert.equal(out.unknownId, false, 'verify(unknown id) must be false');
|
||||||
|
assert.equal(out.hashOnly, false, 'verify(hash-only id) must be false');
|
||||||
|
assert.equal(out.garbage, false, 'verify(garbage) must be false');
|
||||||
|
});
|
||||||
118
src/test/pqc-envelope.test.ts
Normal file
118
src/test/pqc-envelope.test.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// Deterministic, offline unit tests for the PQC envelope builders + challenge
|
||||||
|
// derivation. No network. Run: npm run build && node --test dist/test/*.js
|
||||||
|
//
|
||||||
|
// Covers:
|
||||||
|
// - Falcon envelope byte layout (esig header 0x29 / 0x2A at offset 40) + round-trip.
|
||||||
|
// - ML-DSA-44 / SLH-DSA-128s fixed-length envelope assertions (2420 / 7856).
|
||||||
|
// - assertPubKey length + Falcon header checks.
|
||||||
|
// - JS challenge derivation == the on-chain attestChallenge view (frozen KAT
|
||||||
|
// captured live from AerePQCAttestation on chain 2800).
|
||||||
|
// - sign -> verifyLocal round-trip for all four schemes (real crypto, offline).
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JsonRpcProvider } from 'ethers';
|
||||||
|
import {
|
||||||
|
SCHEME, keygen, signInternal, verifyLocal,
|
||||||
|
falconDetachedToEnvelope, falconEnvelopeToDetached, falconEsigHeader, falconDetachedHeader,
|
||||||
|
mldsaEnvelope, slhdsaEnvelope, assertPubKey, FALCON_NONCE_LEN, LENGTHS,
|
||||||
|
AerePQCClient, CHALLENGE_DOMAIN,
|
||||||
|
} from '../pqc/index.js';
|
||||||
|
|
||||||
|
function seq(n: number, start = 0): Uint8Array {
|
||||||
|
const a = new Uint8Array(n);
|
||||||
|
for (let i = 0; i < n; i++) a[i] = (start + i) & 0xff;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('CHALLENGE_DOMAIN matches keccak256("AerePQCAttestation.v1.challenge")', () => {
|
||||||
|
assert.equal(CHALLENGE_DOMAIN, '0x5b61a780c63cb4dd4519ba570ac6b1fa5c18cb9ceb6280042cce0d953d6b5856');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Falcon-512 envelope: nonce(40) || 0x29 || compressed, offset-40 header, round-trip', () => {
|
||||||
|
const nonce = seq(FALCON_NONCE_LEN, 1);
|
||||||
|
const compressed = seq(120, 200);
|
||||||
|
const detached = new Uint8Array(1 + nonce.length + compressed.length);
|
||||||
|
detached[0] = falconDetachedHeader(SCHEME.FALCON512); // 0x39
|
||||||
|
detached.set(nonce, 1);
|
||||||
|
detached.set(compressed, 1 + nonce.length);
|
||||||
|
|
||||||
|
const env = falconDetachedToEnvelope(detached, SCHEME.FALCON512);
|
||||||
|
assert.equal(env.length, FALCON_NONCE_LEN + 1 + compressed.length);
|
||||||
|
assert.deepEqual(env.subarray(0, FALCON_NONCE_LEN), nonce, 'nonce preserved at offset 0');
|
||||||
|
assert.equal(env[FALCON_NONCE_LEN], 0x29, 'esig header 0x29 at offset 40');
|
||||||
|
assert.equal(falconEsigHeader(SCHEME.FALCON512), 0x29);
|
||||||
|
assert.deepEqual(env.subarray(FALCON_NONCE_LEN + 1), compressed, 'compressed body preserved');
|
||||||
|
|
||||||
|
// round-trip back to noble detached form
|
||||||
|
const back = falconEnvelopeToDetached(env, SCHEME.FALCON512);
|
||||||
|
assert.deepEqual(back, detached);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Falcon-1024 envelope: esig header 0x2A at offset 40', () => {
|
||||||
|
const nonce = seq(FALCON_NONCE_LEN, 9);
|
||||||
|
const compressed = seq(300, 7);
|
||||||
|
const detached = new Uint8Array(1 + nonce.length + compressed.length);
|
||||||
|
detached[0] = falconDetachedHeader(SCHEME.FALCON1024); // 0x3A
|
||||||
|
detached.set(nonce, 1);
|
||||||
|
detached.set(compressed, 1 + nonce.length);
|
||||||
|
|
||||||
|
const env = falconDetachedToEnvelope(detached, SCHEME.FALCON1024);
|
||||||
|
assert.equal(env[FALCON_NONCE_LEN], 0x2a, 'esig header 0x2A at offset 40');
|
||||||
|
assert.equal(falconEsigHeader(SCHEME.FALCON1024), 0x2a);
|
||||||
|
assert.deepEqual(falconEnvelopeToDetached(env, SCHEME.FALCON1024), detached);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Falcon envelope rejects a wrong detached header byte', () => {
|
||||||
|
const bad = new Uint8Array(1 + FALCON_NONCE_LEN + 10);
|
||||||
|
bad[0] = 0x00; // not 0x39
|
||||||
|
assert.throws(() => falconDetachedToEnvelope(bad, SCHEME.FALCON512), /detached header/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ML-DSA-44 envelope asserts exactly 2420 bytes', () => {
|
||||||
|
assert.equal(mldsaEnvelope(seq(2420)).length, 2420);
|
||||||
|
assert.throws(() => mldsaEnvelope(seq(2419)), /2420 bytes/);
|
||||||
|
assert.throws(() => mldsaEnvelope(seq(2421)), /2420 bytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SLH-DSA-128s envelope asserts exactly 7856 bytes', () => {
|
||||||
|
assert.equal(slhdsaEnvelope(seq(7856)).length, 7856);
|
||||||
|
assert.throws(() => slhdsaEnvelope(seq(7855)), /7856 bytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('assertPubKey enforces per-scheme length + Falcon header', () => {
|
||||||
|
const f512 = new Uint8Array(LENGTHS.falcon512.pubKey);
|
||||||
|
f512[0] = 0x09;
|
||||||
|
assert.doesNotThrow(() => assertPubKey(SCHEME.FALCON512, f512));
|
||||||
|
f512[0] = 0x00;
|
||||||
|
assert.throws(() => assertPubKey(SCHEME.FALCON512, f512), /header/);
|
||||||
|
|
||||||
|
const f1024 = new Uint8Array(LENGTHS.falcon1024.pubKey);
|
||||||
|
f1024[0] = 0x0a;
|
||||||
|
assert.doesNotThrow(() => assertPubKey(SCHEME.FALCON1024, f1024));
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => assertPubKey(SCHEME.MLDSA44, new Uint8Array(1312)));
|
||||||
|
assert.throws(() => assertPubKey(SCHEME.MLDSA44, new Uint8Array(1311)), /1312 bytes/);
|
||||||
|
assert.doesNotThrow(() => assertPubKey(SCHEME.SLHDSA128S, new Uint8Array(32)));
|
||||||
|
assert.throws(() => assertPubKey(SCHEME.SLHDSA128S, new Uint8Array(31)), /32 bytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveChallenge matches the on-chain attestChallenge KAT (frozen from chain 2800)', () => {
|
||||||
|
// KAT captured live from AerePQCAttestation.attestChallenge(5, 9, 0x11..11).
|
||||||
|
const client = new AerePQCClient(new JsonRpcProvider('https://rpc.aere.network')); // constructed, never called
|
||||||
|
const messageHash = '0x' + '11'.repeat(32);
|
||||||
|
const challenge = client.deriveChallenge(5, 9, messageHash);
|
||||||
|
assert.equal(challenge, '0xd5c63545430ca962420f8fa3ba4167131dd9cc2fcef28040ff5d505556f281a2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sign -> verifyLocal round-trips offline for all four schemes', () => {
|
||||||
|
const message = seq(32, 42);
|
||||||
|
for (const scheme of [SCHEME.FALCON512, SCHEME.FALCON1024, SCHEME.MLDSA44, SCHEME.SLHDSA128S] as const) {
|
||||||
|
const { publicKey, secretKey } = keygen(scheme);
|
||||||
|
const env = signInternal(scheme, message, secretKey);
|
||||||
|
assert.equal(verifyLocal(scheme, message, env, publicKey), true, `verifyLocal true for scheme ${scheme}`);
|
||||||
|
// tamper the message -> must fail
|
||||||
|
const bad = seq(32, 43);
|
||||||
|
assert.equal(verifyLocal(scheme, bad, env, publicKey), false, `verifyLocal false on wrong msg, scheme ${scheme}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
78
src/test/pqc-interop.test.ts
Normal file
78
src/test/pqc-interop.test.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
// LIVE-CHAIN interop tests: prove that a signature produced by this SDK is
|
||||||
|
// accepted by AERE's native PQC precompiles, via AerePQCAttestation.verifySignature
|
||||||
|
// (a free eth_call) on chain 2800. No transactions are sent; no chain state changes.
|
||||||
|
//
|
||||||
|
// Run: npm run build && node --test dist/test/*.js
|
||||||
|
//
|
||||||
|
// If rpc.aere.network is unreachable (offline CI), each test SKIPS with a clear
|
||||||
|
// notice rather than passing silently.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JsonRpcProvider, randomBytes, hexlify, getBytes } from 'ethers';
|
||||||
|
import { AerePQCClient, SCHEME, SCHEME_NAME, keygen, signInternal, type SchemeId } from '../pqc/index.js';
|
||||||
|
|
||||||
|
const RPC = 'https://rpc.aere.network';
|
||||||
|
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||||
|
const client = new AerePQCClient(provider);
|
||||||
|
|
||||||
|
function isNetworkError(e: unknown): boolean {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||||
|
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||||
|
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run an on-chain call; skip the test on a connectivity failure, rethrow real errors. */
|
||||||
|
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetworkError(e)) {
|
||||||
|
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||||
|
console.log(notice);
|
||||||
|
t.skip(notice);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scheme of [SCHEME.FALCON512, SCHEME.FALCON1024, SCHEME.MLDSA44, SCHEME.SLHDSA128S] as const) {
|
||||||
|
test(`${SCHEME_NAME[scheme]} (scheme ${scheme}): JS-signed sig accepted by LIVE precompile`, async (t) => {
|
||||||
|
const { publicKey, secretKey } = keygen(scheme);
|
||||||
|
const messageHash = hexlify(randomBytes(32));
|
||||||
|
const envelope = signInternal(scheme, getBytes(messageHash), secretKey);
|
||||||
|
|
||||||
|
const ok = await onChain(t, () => client.verifySignature(scheme as SchemeId, publicKey, messageHash, envelope));
|
||||||
|
if (ok === undefined) return; // skipped
|
||||||
|
assert.equal(ok, true, `LIVE verifySignature(${scheme}) must be true for a genuine SDK signature`);
|
||||||
|
|
||||||
|
// Negative control: a signature over a different message must be rejected on-chain.
|
||||||
|
const otherHash = hexlify(randomBytes(32));
|
||||||
|
const bad = await onChain(t, () => client.verifySignature(scheme as SchemeId, publicKey, otherHash, envelope));
|
||||||
|
if (bad === undefined) return;
|
||||||
|
assert.equal(bad, false, 'LIVE verifySignature must reject a signature over a different message');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('signAndAttest path: sign the on-chain challenge, precompile accepts it (no tx sent)', async (t) => {
|
||||||
|
// Exercises the exact bytes signAndAttest() would submit, but via the free view.
|
||||||
|
const scheme = SCHEME.MLDSA44;
|
||||||
|
const { publicKey, secretKey } = keygen(scheme);
|
||||||
|
const keyId = 0n;
|
||||||
|
const messageHash = hexlify(randomBytes(32));
|
||||||
|
|
||||||
|
// Cross-check local vs on-chain challenge derivation.
|
||||||
|
const nonce = 0n; // fresh key would have nonce 0; attestChallenge is pure over its args.
|
||||||
|
const local = client.deriveChallenge(keyId, nonce, messageHash);
|
||||||
|
const chain = await onChain(t, () => client.attestChallenge(keyId, nonce, messageHash));
|
||||||
|
if (chain === undefined) return;
|
||||||
|
assert.equal(local, chain, 'local deriveChallenge must equal on-chain attestChallenge');
|
||||||
|
|
||||||
|
// Sign the challenge and verify the envelope the precompile would see in attest().
|
||||||
|
const envelope = signInternal(scheme, getBytes(local), secretKey);
|
||||||
|
const ok = await onChain(t, () => client.verifySignature(scheme, publicKey, local, envelope));
|
||||||
|
if (ok === undefined) return;
|
||||||
|
assert.equal(ok, true, 'precompile must accept a signature over the derived challenge');
|
||||||
|
});
|
||||||
139
src/test/pqc-keyregistry.test.ts
Normal file
139
src/test/pqc-keyregistry.test.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
// Tests for AerePQCKeyRegistryClient (0x1eCa…3691) on chain 2800.
|
||||||
|
//
|
||||||
|
// - OFFLINE: PoP domain constant + locally-derived PoP challenge == a frozen KAT
|
||||||
|
// captured live from the contract's popChallenge view (keccak cross-check), and
|
||||||
|
// the construction path (buildRegisterKeyPoP) produces a precompile-valid PoP.
|
||||||
|
// - LIVE: keyCount / precompileFor / verify fail-closed + local PoP challenge ==
|
||||||
|
// the on-chain popChallenge view for a sample. LIVE asserts SKIP if RPC is down.
|
||||||
|
//
|
||||||
|
// A live registerKey transaction is NOT attempted: it needs a funded signer and
|
||||||
|
// mutates chain state. The construction path (the exact bytes registerKey would
|
||||||
|
// submit) is unit-tested here and proven precompile-valid via the free verify view;
|
||||||
|
// the live write path is deferred to a funded signer.
|
||||||
|
//
|
||||||
|
// Run: npm run build && node --test dist/test/*.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { JsonRpcProvider, getAddress, keccak256, toUtf8Bytes } from 'ethers';
|
||||||
|
import { AerePQCKeyRegistryClient, POP_DOMAIN } from '../pqc/AerePQCKeyRegistryClient.js';
|
||||||
|
import { SCHEME, keygen, verifyLocal } from '../pqc/index.js';
|
||||||
|
|
||||||
|
const RPC = 'https://rpc.aere.network';
|
||||||
|
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||||
|
const registry = new AerePQCKeyRegistryClient(provider);
|
||||||
|
|
||||||
|
const PRECOMPILE_FALCON512 = getAddress('0x0000000000000000000000000000000000000ae1');
|
||||||
|
|
||||||
|
// ── Frozen KAT sample (captured live from AerePQCKeyRegistry.popChallenge) ──
|
||||||
|
// owner = Foundation, scheme = Falcon-512, nonce = 3, and a DETERMINISTIC fixed
|
||||||
|
// pubKey pk[i] = (i*7+3) & 0xff with pk[0] = 0x09. popChallenge only keccaks the
|
||||||
|
// pubKey bytes (no length check), so the KAT is fully reproducible offline.
|
||||||
|
const KAT_OWNER = getAddress('0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3');
|
||||||
|
const KAT_NONCE = 3n;
|
||||||
|
const KAT_POP_CHALLENGE = '0x302d918e26c3b202cda2525acddfcc728abb40f52cef994a1eea84206cf390eb';
|
||||||
|
|
||||||
|
function katPubKey(): Uint8Array {
|
||||||
|
const pk = new Uint8Array(897);
|
||||||
|
for (let i = 0; i < 897; i++) pk[i] = (i * 7 + 3) & 0xff;
|
||||||
|
pk[0] = 0x09;
|
||||||
|
return pk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetworkError(e: unknown): boolean {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||||
|
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||||
|
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetworkError(e)) {
|
||||||
|
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||||
|
console.log(notice);
|
||||||
|
t.skip(notice);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────── OFFLINE ───────────────────────────────
|
||||||
|
|
||||||
|
test('OFFLINE: POP_DOMAIN == keccak256("AerePQCKeyRegistry.v1.pop")', () => {
|
||||||
|
assert.equal(POP_DOMAIN, '0x04cc1fb241e197c3eb1534f0ee330640805821be948c47fa7704336db109be91');
|
||||||
|
assert.equal(POP_DOMAIN, keccak256(toUtf8Bytes('AerePQCKeyRegistry.v1.pop')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: derivePopChallenge matches the frozen on-chain popChallenge KAT', () => {
|
||||||
|
const local = registry.derivePopChallenge(KAT_OWNER, SCHEME.FALCON512, katPubKey(), KAT_NONCE);
|
||||||
|
assert.equal(local, KAT_POP_CHALLENGE, 'local PoP challenge must equal the chain-captured KAT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: buildRegisterKeyPoP produces a PoP the precompile logic accepts (verifyLocal)', () => {
|
||||||
|
// A real keypair; sign the PoP challenge for this owner/nonce and verify the
|
||||||
|
// envelope off-chain (mirror of what registerKey does on-chain via the precompile).
|
||||||
|
const { publicKey, secretKey } = keygen(SCHEME.FALCON512);
|
||||||
|
const owner = getAddress('0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3');
|
||||||
|
const nonce = 0n;
|
||||||
|
|
||||||
|
const pop = registry.buildRegisterKeyPoP(owner, SCHEME.FALCON512, secretKey, publicKey, nonce);
|
||||||
|
assert.equal(pop.challenge, registry.derivePopChallenge(owner, SCHEME.FALCON512, publicKey, nonce));
|
||||||
|
|
||||||
|
// The signature is a genuine Falcon sig over the exact 32-byte PoP challenge.
|
||||||
|
const ok = verifyLocal(SCHEME.FALCON512, Buffer.from(pop.challenge.slice(2), 'hex'), pop.signature, publicKey);
|
||||||
|
assert.equal(ok, true, 'PoP envelope must verify against the challenge it was built for');
|
||||||
|
|
||||||
|
// Negative control: it must NOT verify against a different nonce's challenge.
|
||||||
|
const otherChallenge = registry.derivePopChallenge(owner, SCHEME.FALCON512, publicKey, 1n);
|
||||||
|
const bad = verifyLocal(SCHEME.FALCON512, Buffer.from(otherChallenge.slice(2), 'hex'), pop.signature, publicKey);
|
||||||
|
assert.equal(bad, false, 'PoP must be bound to its nonce (replay-proof)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('OFFLINE: buildRegisterKeyPoP rejects a malformed public key', () => {
|
||||||
|
const { secretKey } = keygen(SCHEME.FALCON512);
|
||||||
|
const badPk = new Uint8Array(10); // wrong length + wrong header
|
||||||
|
assert.throws(
|
||||||
|
() => registry.buildRegisterKeyPoP(KAT_OWNER, SCHEME.FALCON512, secretKey, badPk, 0n),
|
||||||
|
/Falcon-512 pubKey/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────── LIVE ─────────────────────────────────
|
||||||
|
|
||||||
|
test('LIVE: keyCount() is readable and precompileFor(1..4) route to 0x0AE1..0x0AE4', async (t) => {
|
||||||
|
const out = await onChain(t, async () => ({
|
||||||
|
count: await registry.keyCount(),
|
||||||
|
p1: await registry.precompileFor(SCHEME.FALCON512),
|
||||||
|
p2: await registry.precompileFor(SCHEME.FALCON1024),
|
||||||
|
p3: await registry.precompileFor(SCHEME.MLDSA44),
|
||||||
|
p4: await registry.precompileFor(SCHEME.SLHDSA128S),
|
||||||
|
}));
|
||||||
|
if (out === undefined) return;
|
||||||
|
assert.equal(typeof out.count, 'bigint');
|
||||||
|
assert.ok(out.count >= 0n);
|
||||||
|
assert.equal(getAddress(out.p1), PRECOMPILE_FALCON512);
|
||||||
|
assert.equal(getAddress(out.p2), getAddress('0x0000000000000000000000000000000000000ae2'));
|
||||||
|
assert.equal(getAddress(out.p3), getAddress('0x0000000000000000000000000000000000000ae3'));
|
||||||
|
assert.equal(getAddress(out.p4), getAddress('0x0000000000000000000000000000000000000ae4'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: derivePopChallenge == on-chain popChallenge for the sample (keccak cross-check)', async (t) => {
|
||||||
|
const pk = katPubKey();
|
||||||
|
const chain = await onChain(t, () => registry.popChallenge(KAT_OWNER, SCHEME.FALCON512, pk, KAT_NONCE));
|
||||||
|
if (chain === undefined) return;
|
||||||
|
const local = registry.derivePopChallenge(KAT_OWNER, SCHEME.FALCON512, pk, KAT_NONCE);
|
||||||
|
assert.equal(local, chain, 'local derivation must equal the on-chain popChallenge view');
|
||||||
|
assert.equal(chain, KAT_POP_CHALLENGE, 'the on-chain value must still equal the frozen KAT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: verify() is fail-closed for a garbage signature', async (t) => {
|
||||||
|
const { publicKey } = keygen(SCHEME.FALCON512);
|
||||||
|
const msg = '0x' + '11'.repeat(32);
|
||||||
|
const bad = await onChain(t, () => registry.verify(SCHEME.FALCON512, publicKey, msg, '0x' + '00'.repeat(50)));
|
||||||
|
if (bad === undefined) return;
|
||||||
|
assert.equal(bad, false, 'a garbage signature must fail the live precompile check');
|
||||||
|
});
|
||||||
112
src/test/pqc-order.test.ts
Normal file
112
src/test/pqc-order.test.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// Unit tests for the PQC order-builder (AerePQCOrderBuilder).
|
||||||
|
//
|
||||||
|
// Pure / offline: no chain. Proves the builder produces a signature that verifies
|
||||||
|
// LOCALLY over its own EIP-712 order digest (mirroring what the live precompile does),
|
||||||
|
// that tampering breaks it, and that the digest is deterministic and field-sensitive.
|
||||||
|
// The byte-for-byte agreement with the on-chain AerePQCOrderAuthorizer.orderDigest and
|
||||||
|
// the end-to-end fill through the real Falcon verifier are proven in the contracts suite
|
||||||
|
// (aerenew/contracts/test/pqc-intents.test.js).
|
||||||
|
//
|
||||||
|
// Run: npm run build && node --test dist/test/*.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getBytes, keccak256, toUtf8Bytes } from 'ethers';
|
||||||
|
import { verifyLocal, SCHEME } from '../pqc/index.js';
|
||||||
|
import {
|
||||||
|
buildAndSignPQCOrder, buildPQCGaslessOrder, pqcOrderDigest, generateOrderKey,
|
||||||
|
encodeAereV3OrderData, AERE_ORDER_DATA_TYPE, PQC_SCHEME,
|
||||||
|
} from '../intents/index.js';
|
||||||
|
|
||||||
|
const SETTLER = '0xCAB1DBA5f6F06198000C20a974d675f1B3181AbD';
|
||||||
|
const CHAIN = 2800n;
|
||||||
|
const USER = '0x000000000000000000000000000000000000d00d';
|
||||||
|
const TOKEN_IN = '0x0000000000000000000000000000000000000a11';
|
||||||
|
const TOKEN_OUT = '0x0000000000000000000000000000000000000b22';
|
||||||
|
const RECIPIENT = '0x0000000000000000000000000000000000000c33';
|
||||||
|
|
||||||
|
function seed48(tag: string): Uint8Array {
|
||||||
|
return getBytes(keccak256(toUtf8Bytes(tag)) + keccak256(toUtf8Bytes(tag + '-2')).slice(2)).slice(0, 48);
|
||||||
|
}
|
||||||
|
function seed32(tag: string): Uint8Array {
|
||||||
|
return getBytes(keccak256(toUtf8Bytes(tag))); // 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseParams(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
user: USER,
|
||||||
|
nonce: 0,
|
||||||
|
originChainId: CHAIN,
|
||||||
|
openDeadline: 2_000_000_000,
|
||||||
|
fillDeadline: 2_000_003_600,
|
||||||
|
algorithmId: 1,
|
||||||
|
order: {
|
||||||
|
inputToken: TOKEN_IN,
|
||||||
|
inputAmount: 1_000_000n,
|
||||||
|
outputToken: TOKEN_OUT,
|
||||||
|
outputAmount: 999_000n,
|
||||||
|
recipient: RECIPIENT,
|
||||||
|
destinationChainId: 1n,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('orderData uses the AereV3Order discriminator', () => {
|
||||||
|
assert.equal(AERE_ORDER_DATA_TYPE, keccak256(toUtf8Bytes('AereV3Order')));
|
||||||
|
const order = buildPQCGaslessOrder(baseParams());
|
||||||
|
assert.equal(order.orderDataType, AERE_ORDER_DATA_TYPE);
|
||||||
|
// orderData round-trips the same encoder.
|
||||||
|
assert.equal(order.orderData, encodeAereV3OrderData(baseParams().order));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Falcon-512: a built+signed order verifies locally over its own digest', () => {
|
||||||
|
const kp = generateOrderKey(PQC_SCHEME.FALCON512, seed48('order-falcon'));
|
||||||
|
const signed = buildAndSignPQCOrder({
|
||||||
|
settler: SETTLER, chainId: CHAIN, scheme: SCHEME.FALCON512,
|
||||||
|
publicKey: kp.publicKey, secretKey: kp.secretKey, params: baseParams(),
|
||||||
|
});
|
||||||
|
assert.equal(signed.scheme, SCHEME.FALCON512);
|
||||||
|
// The digest the builder signed equals a fresh recomputation.
|
||||||
|
assert.equal(signed.digest, pqcOrderDigest(SETTLER, CHAIN, signed.order, signed.pubKey));
|
||||||
|
// The signature verifies locally over that digest.
|
||||||
|
assert.equal(verifyLocal(SCHEME.FALCON512, getBytes(signed.digest), getBytes(signed.signature), kp.publicKey), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ML-DSA-44: a built+signed order verifies locally over its own digest', () => {
|
||||||
|
const kp = generateOrderKey(PQC_SCHEME.MLDSA44, seed32('order-mldsa'));
|
||||||
|
const signed = buildAndSignPQCOrder({
|
||||||
|
settler: SETTLER, chainId: CHAIN, scheme: SCHEME.MLDSA44,
|
||||||
|
publicKey: kp.publicKey, secretKey: kp.secretKey, params: baseParams({ algorithmId: 3 }),
|
||||||
|
});
|
||||||
|
assert.equal(verifyLocal(SCHEME.MLDSA44, getBytes(signed.digest), getBytes(signed.signature), kp.publicKey), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tampered signature fails local verification', () => {
|
||||||
|
const kp = generateOrderKey(PQC_SCHEME.FALCON512, seed48('order-tamper'));
|
||||||
|
const signed = buildAndSignPQCOrder({
|
||||||
|
settler: SETTLER, chainId: CHAIN, scheme: SCHEME.FALCON512,
|
||||||
|
publicKey: kp.publicKey, secretKey: kp.secretKey, params: baseParams(),
|
||||||
|
});
|
||||||
|
const tampered = getBytes(signed.signature);
|
||||||
|
tampered[60] ^= 0x01; // flip a bit deep in the compressed body
|
||||||
|
assert.equal(verifyLocal(SCHEME.FALCON512, getBytes(signed.digest), tampered, kp.publicKey), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the digest is deterministic and field-sensitive', () => {
|
||||||
|
const kp = generateOrderKey(PQC_SCHEME.FALCON512, seed48('order-digest'));
|
||||||
|
const a = buildPQCGaslessOrder(baseParams());
|
||||||
|
const b = buildPQCGaslessOrder(baseParams());
|
||||||
|
const dA = pqcOrderDigest(SETTLER, CHAIN, a, kp.publicKey);
|
||||||
|
const dB = pqcOrderDigest(SETTLER, CHAIN, b, kp.publicKey);
|
||||||
|
assert.equal(dA, dB); // deterministic
|
||||||
|
|
||||||
|
// A different nonce changes the digest.
|
||||||
|
const c = buildPQCGaslessOrder(baseParams({ nonce: 1 }));
|
||||||
|
assert.notEqual(pqcOrderDigest(SETTLER, CHAIN, c, kp.publicKey), dA);
|
||||||
|
// A different settler changes the digest (per-pool domain separation).
|
||||||
|
assert.notEqual(pqcOrderDigest('0x000000000000000000000000000000000000dEaD', CHAIN, a, kp.publicKey), dA);
|
||||||
|
// A different amount changes the digest (payload binding).
|
||||||
|
const e = buildPQCGaslessOrder(baseParams({ order: { ...baseParams().order, inputAmount: 2_000_000n } }));
|
||||||
|
assert.notEqual(pqcOrderDigest(SETTLER, CHAIN, e, kp.publicKey), dA);
|
||||||
|
});
|
||||||
178
src/test/state-window-live.ts
Normal file
178
src/test/state-window-live.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* state-window-live.ts — prove StateWindowReader against the REAL endpoint.
|
||||||
|
*
|
||||||
|
* The unit tests in state-window.test.ts run against a fake node. A fake node
|
||||||
|
* proves the code does what its author expected; it does not prove the endpoint
|
||||||
|
* still behaves the way the author measured. This file closes that gap by
|
||||||
|
* pointing the guard at production and asserting on what comes back.
|
||||||
|
*
|
||||||
|
* It is deliberately NOT part of `npm test`: it needs the network, and a test
|
||||||
|
* suite that fails when the internet is down teaches people to ignore red.
|
||||||
|
*
|
||||||
|
* node dist/test/state-window-live.js # rpc.aere.network
|
||||||
|
* node dist/test/state-window-live.js https://rpc2.aere.network
|
||||||
|
*
|
||||||
|
* Exit 0 = the guard held and the hazard is still real.
|
||||||
|
* Exit 1 = something changed. Read the output; do not silence it.
|
||||||
|
*
|
||||||
|
* Structure, in this order, because a guard that has never been shown to fail
|
||||||
|
* cannot be trusted:
|
||||||
|
*
|
||||||
|
* STEP 1 PLANTED FAILURE. The raw call really does return a false zero.
|
||||||
|
* STEP 2 NEGATIVE CONTROL. With the guards disabled, the reader hands that
|
||||||
|
* false zero straight to the caller.
|
||||||
|
* STEP 3 THE GUARD. With the guards on, the reader refuses.
|
||||||
|
*
|
||||||
|
* If step 1 or step 2 ever stops failing, this file must be revisited: either
|
||||||
|
* the endpoint was fixed, or the measurement it rests on has gone stale.
|
||||||
|
* Read-only. No keys, no transactions.
|
||||||
|
*/
|
||||||
|
import { StateWindowReader, StateWindowError } from '../state-window.js';
|
||||||
|
|
||||||
|
const RPC = process.argv[2] ?? 'https://rpc.aere.network';
|
||||||
|
|
||||||
|
/** Measured: this address signed with nonce 123,063 in block 8,236,382. */
|
||||||
|
const SIGNER = '0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465';
|
||||||
|
const TX_HASH = '0xbfd3620dc705cc1dbf5bf747c02f71f0d4f68227ab8b5ccafe3de15955587ce3';
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
const ok = (label: string, detail: string) => console.log(` PASS ${label}\n ${detail}`);
|
||||||
|
const bad = (label: string, detail: string) => {
|
||||||
|
failures++;
|
||||||
|
console.log(` FAIL ${label}\n ${detail}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function send(method: string, params: unknown[]): Promise<unknown> {
|
||||||
|
const res = await fetch(RPC, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||||
|
});
|
||||||
|
const body = (await res.json()) as { result?: unknown; error?: { code: number; message: string } };
|
||||||
|
if (body.error) throw new Error(`${body.error.code} ${body.error.message}`);
|
||||||
|
return body.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log(`state-window live proof against ${RPC}`);
|
||||||
|
console.log(`run at ${new Date().toISOString()}\n`);
|
||||||
|
|
||||||
|
// Anchor the truth in a block BODY, which is retained at any depth and so does
|
||||||
|
// not itself depend on the world state we are testing.
|
||||||
|
const tx = (await send('eth_getTransactionByHash', [TX_HASH])) as {
|
||||||
|
from: string; nonce: string; blockNumber: string;
|
||||||
|
} | null;
|
||||||
|
if (!tx) {
|
||||||
|
bad('anchor', `the endpoint does not know transaction ${TX_HASH}; cannot proceed`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const deepBlock = Number.parseInt(tx.blockNumber, 16);
|
||||||
|
const trueNonce = Number.parseInt(tx.nonce, 16);
|
||||||
|
const tag = tx.blockNumber;
|
||||||
|
console.log(`ANCHOR block ${deepBlock} body says ${tx.from} signed with nonce ${trueNonce}`);
|
||||||
|
if (tx.from.toLowerCase() !== SIGNER) bad('anchor', `expected ${SIGNER}, block body says ${tx.from}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
console.log('STEP 1 PLANTED FAILURE: the raw call must still lie');
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const rawNonce = await send('eth_getTransactionCount', [SIGNER, tag]);
|
||||||
|
if (rawNonce === '0x0') {
|
||||||
|
ok('eth_getTransactionCount returns a false zero',
|
||||||
|
`result "0x0" at block ${deepBlock}, where the block body proves ${trueNonce}`);
|
||||||
|
} else {
|
||||||
|
bad('eth_getTransactionCount returns a false zero',
|
||||||
|
`expected "0x0", got ${JSON.stringify(rawNonce)}. If this endpoint now answers honestly, ` +
|
||||||
|
`the published documentation must be updated, not this assertion.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBalance = await send('eth_getBalance', [SIGNER, tag]);
|
||||||
|
if (rawBalance === null) {
|
||||||
|
ok('eth_getBalance is honest at the same block', 'result null, which is a refusal and not a value');
|
||||||
|
} else {
|
||||||
|
bad('eth_getBalance is honest at the same block', `expected null, got ${JSON.stringify(rawBalance)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await send('eth_getProof', [SIGNER, [], tag]).then(
|
||||||
|
() => bad('eth_getProof is honest at the same block', 'it answered; the state window may have changed'),
|
||||||
|
(e: Error) => ok('eth_getProof is honest at the same block', `error ${e.message}`),
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
console.log('STEP 2 NEGATIVE CONTROL: with the guards off, the lie gets through');
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const unguarded = new StateWindowReader(send, {
|
||||||
|
windowBlocks: 100_000_000, safetyMarginBlocks: 0, corroborateNonce: false,
|
||||||
|
});
|
||||||
|
const leaked = await unguarded.getTransactionCount(SIGNER, deepBlock);
|
||||||
|
if (leaked === 0) {
|
||||||
|
ok('the disabled guard returns 0', `getTransactionCount answered ${leaked}, which is wrong by ${trueNonce}`);
|
||||||
|
} else {
|
||||||
|
bad('the disabled guard returns 0', `expected 0, got ${leaked}; the negative control no longer controls anything`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
console.log('STEP 3 THE GUARD: it refuses instead of guessing');
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const reader = new StateWindowReader(send);
|
||||||
|
|
||||||
|
await reader.getTransactionCount(SIGNER, deepBlock).then(
|
||||||
|
(v) => bad('pre-flight refuses the deep nonce', `expected a throw, got ${v}`),
|
||||||
|
(e: unknown) =>
|
||||||
|
e instanceof StateWindowError && e.detail.reason === 'requested-block-outside-window'
|
||||||
|
? ok('pre-flight refuses the deep nonce', `StateWindowError ${e.detail.reason}, depth ${e.detail.depth}`)
|
||||||
|
: bad('pre-flight refuses the deep nonce', `wrong error: ${e}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Corroboration is the guard that survives a caller who races past pre-flight.
|
||||||
|
const racedPast = new StateWindowReader(send, { windowBlocks: 100_000_000, safetyMarginBlocks: 0 });
|
||||||
|
await racedPast.getTransactionCount(SIGNER, deepBlock).then(
|
||||||
|
(v) => bad('corroboration catches what pre-flight missed', `expected a throw, got ${v}`),
|
||||||
|
(e: unknown) =>
|
||||||
|
e instanceof StateWindowError && e.detail.reason === 'nonce-not-corroborated'
|
||||||
|
? ok('corroboration catches what pre-flight missed', `StateWindowError ${e.detail.reason}, refused value ${e.detail.rawResult}`)
|
||||||
|
: bad('corroboration catches what pre-flight missed', `wrong error: ${e}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [name, call] of [
|
||||||
|
['getBalance', () => reader.getBalance(SIGNER, deepBlock)],
|
||||||
|
['getCode', () => reader.getCode(SIGNER, deepBlock)],
|
||||||
|
['getStorageAt', () => reader.getStorageAt(SIGNER, '0x0', deepBlock)],
|
||||||
|
] as [string, () => Promise<unknown>][]) {
|
||||||
|
await call().then(
|
||||||
|
(v) => bad(`${name} refuses at depth`, `expected a throw, got ${JSON.stringify(v)}`),
|
||||||
|
(e: unknown) =>
|
||||||
|
e instanceof StateWindowError
|
||||||
|
? ok(`${name} refuses at depth`, `StateWindowError ${e.detail.reason}`)
|
||||||
|
: bad(`${name} refuses at depth`, `wrong error: ${e}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it still answers where the node can actually answer.
|
||||||
|
const live = await reader.getTransactionCount(SIGNER, 'latest');
|
||||||
|
ok('the reader still answers at latest', `nonce ${live}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
console.log('STEP 4 MEASURE the window rather than trusting the constant');
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
const m = await reader.measureWindow(SIGNER, 100_000);
|
||||||
|
console.log(` head ${m.head}, deepest depth answered ${m.deepestOkDepth}, first depth refused ${m.firstFailDepth}`);
|
||||||
|
if (m.firstFailDepth === 512) {
|
||||||
|
ok('the window is still 512 blocks', `511 answers, 512 does not, measured just now`);
|
||||||
|
} else {
|
||||||
|
bad('the window is still 512 blocks',
|
||||||
|
`measured ${m.firstFailDepth}. This is not necessarily a bug, but every published ` +
|
||||||
|
`number that says 512 is now wrong and must be corrected.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${failures === 0 ? 'RESULT: PASS' : `RESULT: FAIL, ${failures} check(s)`}`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('live proof aborted:', e);
|
||||||
|
process.exit(2);
|
||||||
|
});
|
||||||
236
src/test/state-window.test.ts
Normal file
236
src/test/state-window.test.ts
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* state-window.test.ts
|
||||||
|
*
|
||||||
|
* The fake node below is not invented. It reproduces behaviour measured on
|
||||||
|
* 2026-08-01 against https://rpc.aere.network and https://rpc2.aere.network:
|
||||||
|
*
|
||||||
|
* depth < 512 every state call answers
|
||||||
|
* depth >= 512 eth_getBalance -> null
|
||||||
|
* eth_getCode -> null
|
||||||
|
* eth_getStorageAt -> null
|
||||||
|
* eth_getProof -> error -32000 "World state unavailable"
|
||||||
|
* eth_call -> error -32603
|
||||||
|
* eth_getTransactionCount -> "0x0" <-- the false zero
|
||||||
|
*
|
||||||
|
* The address and nonce used here are the measured ones: address
|
||||||
|
* 0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465 signed a transaction with nonce
|
||||||
|
* 123,063 in block 8,236,382, and both public endpoints answer 0x0 when asked
|
||||||
|
* for its nonce at that block.
|
||||||
|
*
|
||||||
|
* Test 1 is the PLANTED FAILURE. It asserts that the naive call really does
|
||||||
|
* return a well formed zero, so that the rest of the file is proving something
|
||||||
|
* that is actually broken rather than guarding a hazard that never existed.
|
||||||
|
*
|
||||||
|
* Run: npm run build && node --test dist/test/state-window.test.js
|
||||||
|
*/
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
StateWindowReader,
|
||||||
|
StateWindowError,
|
||||||
|
assertStateServable,
|
||||||
|
DEFAULT_STATE_WINDOW_BLOCKS,
|
||||||
|
} from '../state-window.js';
|
||||||
|
|
||||||
|
const HEAD = 11_804_000;
|
||||||
|
const WINDOW = 512;
|
||||||
|
|
||||||
|
/** Measured: this address signed with nonce 123,063 in block 8,236,382. */
|
||||||
|
const SIGNER = '0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465';
|
||||||
|
const DEEP_BLOCK = 8_236_382;
|
||||||
|
const TRUE_NONCE_AT_DEEP_BLOCK = 123_063;
|
||||||
|
|
||||||
|
interface FakeNode {
|
||||||
|
send: (method: string, params: unknown[]) => Promise<unknown>;
|
||||||
|
calls: string[];
|
||||||
|
head: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeNode(opts: { head?: number; headAdvancesBy?: number } = {}): FakeNode {
|
||||||
|
const node: FakeNode = { head: opts.head ?? HEAD, calls: [], send: null as never };
|
||||||
|
|
||||||
|
const depthOf = (tag: unknown): number | null => {
|
||||||
|
const s = String(tag);
|
||||||
|
if (!/^0x[0-9a-fA-F]+$/.test(s)) return null; // latest / pending / earliest
|
||||||
|
return node.head - Number.parseInt(s, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.send = async (method: string, params: unknown[]) => {
|
||||||
|
node.calls.push(method);
|
||||||
|
|
||||||
|
if (method === 'eth_blockNumber') {
|
||||||
|
const h = node.head;
|
||||||
|
node.head += opts.headAdvancesBy ?? 0; // simulates the tip moving mid-run
|
||||||
|
return '0x' + h.toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagIndex = method === 'eth_getStorageAt' || method === 'eth_getProof' ? 2 : 1;
|
||||||
|
const depth = depthOf(params[tagIndex]);
|
||||||
|
const pruned = depth !== null && depth >= WINDOW;
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'eth_getBalance':
|
||||||
|
return pruned ? null : '0x1bc16d674ec80000';
|
||||||
|
case 'eth_getCode':
|
||||||
|
return pruned ? null : '0x60806040';
|
||||||
|
case 'eth_getStorageAt':
|
||||||
|
return pruned ? null : '0x' + '00'.repeat(31) + '01';
|
||||||
|
case 'eth_call':
|
||||||
|
if (pruned) throw new Error('-32603 Internal error');
|
||||||
|
return '0x' + '00'.repeat(31) + '08';
|
||||||
|
case 'eth_getProof':
|
||||||
|
if (pruned) throw new Error('-32000 World state unavailable');
|
||||||
|
return { accountProof: [] };
|
||||||
|
case 'eth_getTransactionCount':
|
||||||
|
// THE MEASURED LIE: a well formed zero instead of an error.
|
||||||
|
return pruned ? '0x0' : '0x' + TRUE_NONCE_AT_DEEP_BLOCK.toString(16);
|
||||||
|
default:
|
||||||
|
throw new Error(`unexpected method ${method}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. PLANTED FAILURE: prove the hazard is real before proving it is guarded.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test('planted failure: a raw eth_getTransactionCount at depth returns a false zero', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const raw = await node.send('eth_getTransactionCount', [SIGNER, '0x' + DEEP_BLOCK.toString(16)]);
|
||||||
|
|
||||||
|
assert.equal(raw, '0x0', 'the endpoint under test must answer 0x0 for a pruned nonce');
|
||||||
|
assert.equal(Number.parseInt(String(raw), 16), 0);
|
||||||
|
assert.notEqual(
|
||||||
|
Number.parseInt(String(raw), 16),
|
||||||
|
TRUE_NONCE_AT_DEEP_BLOCK,
|
||||||
|
'and that zero must be WRONG: the address signed with nonce 123063 in this very block',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Contrast: at the same block, the honest calls admit ignorance.
|
||||||
|
assert.equal(await node.send('eth_getBalance', [SIGNER, '0x' + DEEP_BLOCK.toString(16)]), null);
|
||||||
|
await assert.rejects(() => node.send('eth_getProof', [SIGNER, [], '0x' + DEEP_BLOCK.toString(16)]));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. The guard: a refusal, not a zero.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test('getTransactionCount at a deep block throws instead of returning 0', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => reader.getTransactionCount(SIGNER, DEEP_BLOCK),
|
||||||
|
(e: unknown) => {
|
||||||
|
assert.ok(e instanceof StateWindowError, `expected StateWindowError, got ${e}`);
|
||||||
|
assert.equal(e.code, 'STATE_OUTSIDE_WINDOW');
|
||||||
|
assert.equal(e.detail.method, 'eth_getTransactionCount');
|
||||||
|
assert.equal(e.detail.requestedBlock, DEEP_BLOCK);
|
||||||
|
assert.match(e.message, /NOT MEASURED/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the deep call is never even sent: pre-flight rejects it', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send);
|
||||||
|
await assert.rejects(() => reader.getTransactionCount(SIGNER, DEEP_BLOCK));
|
||||||
|
assert.deepEqual(
|
||||||
|
node.calls.filter((c) => c !== 'eth_blockNumber'),
|
||||||
|
[],
|
||||||
|
'no state call should reach a node that cannot answer it',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every other state read refuses at depth too, none returns a placeholder', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send);
|
||||||
|
await assert.rejects(() => reader.getBalance(SIGNER, DEEP_BLOCK), StateWindowError);
|
||||||
|
await assert.rejects(() => reader.getCode(SIGNER, DEEP_BLOCK), StateWindowError);
|
||||||
|
await assert.rejects(() => reader.getStorageAt(SIGNER, '0x0', DEEP_BLOCK), StateWindowError);
|
||||||
|
await assert.rejects(() => reader.call({ to: SIGNER, data: '0x' }, DEEP_BLOCK), StateWindowError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Corroboration: the guard that survives a race past the pre-flight check.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test('corroboration catches a false zero that slipped past the pre-flight check', async () => {
|
||||||
|
// A reader configured with a window far larger than the node really serves.
|
||||||
|
// Pre-flight waves the call through; only the companion eth_getBalance saves it.
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send, { windowBlocks: 10_000_000, safetyMarginBlocks: 0 });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => reader.getTransactionCount(SIGNER, DEEP_BLOCK),
|
||||||
|
(e: unknown) => {
|
||||||
|
assert.ok(e instanceof StateWindowError);
|
||||||
|
assert.equal(e.detail.reason, 'nonce-not-corroborated');
|
||||||
|
assert.equal(e.detail.rawResult, '0x0', 'the refused value must be recorded, not hidden');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.ok(node.calls.includes('eth_getBalance'), 'the companion read must actually be issued');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with corroboration disabled the false zero comes straight back: the guard is load-bearing', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send, {
|
||||||
|
windowBlocks: 10_000_000, safetyMarginBlocks: 0, corroborateNonce: false,
|
||||||
|
});
|
||||||
|
assert.equal(await reader.getTransactionCount(SIGNER, DEEP_BLOCK), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. The window edge is a race, and the margin is what covers it.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test('a nominal depth of 511 is refused, because the head moves mid-call', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send); // margin 8
|
||||||
|
await assert.rejects(() => reader.getBalance(SIGNER, HEAD - 511), StateWindowError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inside the margin the reader answers normally', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send);
|
||||||
|
assert.equal(await reader.getBalance(SIGNER, HEAD - 100), 2000000000000000000n);
|
||||||
|
assert.equal(await reader.getTransactionCount(SIGNER, HEAD - 100), TRUE_NONCE_AT_DEEP_BLOCK);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('symbolic tags are never blocked', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send);
|
||||||
|
assert.equal(await reader.getTransactionCount(SIGNER), TRUE_NONCE_AT_DEEP_BLOCK);
|
||||||
|
assert.equal(await reader.getBalance(SIGNER, 'pending'), 2000000000000000000n);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. hasEverSigned, the D-001 question, answered only where it can be answered.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test('hasEverSigned answers at latest and reports the height it answered at', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send);
|
||||||
|
const r = await reader.hasEverSigned(SIGNER);
|
||||||
|
assert.equal(r.signed, true);
|
||||||
|
assert.equal(r.nonce, TRUE_NONCE_AT_DEEP_BLOCK);
|
||||||
|
assert.equal(r.atBlock, HEAD);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 6. measureWindow finds the real edge instead of trusting the constant.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test('measureWindow recovers the 512-block edge from the node itself', async () => {
|
||||||
|
const node = fakeNode();
|
||||||
|
const reader = new StateWindowReader(node.send);
|
||||||
|
const m = await reader.measureWindow(SIGNER, 100_000);
|
||||||
|
assert.equal(m.deepestOkDepth, WINDOW - 1);
|
||||||
|
assert.equal(m.firstFailDepth, WINDOW);
|
||||||
|
assert.equal(DEFAULT_STATE_WINDOW_BLOCKS, WINDOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 7. The free-function guard, for callers that already hold head and block.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test('assertStateServable throws on depth and stays quiet inside the window', () => {
|
||||||
|
assert.throws(() => assertStateServable('eth_getTransactionCount', DEEP_BLOCK, HEAD), StateWindowError);
|
||||||
|
assert.doesNotThrow(() => assertStateServable('eth_getBalance', HEAD - 10, HEAD));
|
||||||
|
});
|
||||||
278
src/test/wallet.test.ts
Normal file
278
src/test/wallet.test.ts
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
// Unit + LIVE-CHAIN tests for the seedless hybrid PQC wallet core (chain 2800).
|
||||||
|
//
|
||||||
|
// Offline (always run): descriptor construction, client-side Falcon key encrypt/
|
||||||
|
// decrypt round-trip, the one-tap upgrade fusion, CREATE2 address determinism, and
|
||||||
|
// the Falcon proof-of-possession / attestation path INCLUDING the required
|
||||||
|
// negative control (a signature bound to nonce N must FAIL against the challenge
|
||||||
|
// for a different nonce).
|
||||||
|
//
|
||||||
|
// Live (skip if rpc.aere.network is unreachable): the CREATE2 prediction matches
|
||||||
|
// the on-chain AerePQCAccountFactory, and the Falcon signatures verify true on the
|
||||||
|
// LIVE precompile via AerePQCAttestation.verifySignature (a free eth_call) while a
|
||||||
|
// wrong-nonce / wrong-message signature verifies false. No transaction is sent.
|
||||||
|
//
|
||||||
|
// Run: npm run build && node --test dist/test/wallet.test.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
JsonRpcProvider, Contract, getBytes, hexlify, randomBytes, zeroPadValue,
|
||||||
|
} from 'ethers';
|
||||||
|
import { AerePQCClient, SCHEME, verifyLocal } from '../pqc/index.js';
|
||||||
|
import {
|
||||||
|
createSeedlessAccount,
|
||||||
|
generateFalconRoot,
|
||||||
|
encryptFalconSecretKey,
|
||||||
|
decryptFalconSecretKey,
|
||||||
|
upgradeToPostQuantum,
|
||||||
|
predictPqcAccountAddress,
|
||||||
|
buildUpgradeAttestation,
|
||||||
|
verifyUpgradeAttestationLive,
|
||||||
|
signPqcAccountUserOp,
|
||||||
|
verifyPqcAccountUserOpLocal,
|
||||||
|
verifyUserOpFalconLegLive,
|
||||||
|
AERE_WALLET_ADDRESSES,
|
||||||
|
type PasskeyDailyFactor,
|
||||||
|
} from '../wallet/index.js';
|
||||||
|
|
||||||
|
const RPC = 'https://rpc.aere.network';
|
||||||
|
const provider = new JsonRpcProvider(RPC, undefined, { staticNetwork: true });
|
||||||
|
const client = new AerePQCClient(provider);
|
||||||
|
|
||||||
|
// A stable passkey daily factor for construction (address is arbitrary but valid).
|
||||||
|
const PASSKEY: PasskeyDailyFactor = {
|
||||||
|
type: 'passkey',
|
||||||
|
authorityAddress: '0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3',
|
||||||
|
credentialId: 'AQIDBA', // base64url, illustrative
|
||||||
|
label: 'Test device Face ID',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic Falcon key from a fixed 48-byte seed so tests are reproducible.
|
||||||
|
const SEED = new Uint8Array(48).fill(7);
|
||||||
|
|
||||||
|
function isNetworkError(e: unknown): boolean {
|
||||||
|
const code = (e as { code?: string })?.code;
|
||||||
|
if (code && ['NETWORK_ERROR', 'SERVER_ERROR', 'TIMEOUT', 'UNKNOWN_ERROR'].includes(code)) return true;
|
||||||
|
const msg = String((e as Error)?.message ?? e).toLowerCase();
|
||||||
|
return /econn|enotfound|etimedout|fetch failed|network|timeout|socket|getaddrinfo/.test(msg);
|
||||||
|
}
|
||||||
|
async function onChain<T>(t: { skip: (m?: string) => void }, fn: () => Promise<T>): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (isNetworkError(e)) {
|
||||||
|
const notice = `SKIPPED: rpc.aere.network unreachable (${String((e as Error).message).slice(0, 80)})`;
|
||||||
|
console.log(notice);
|
||||||
|
t.skip(notice);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── construction ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('createSeedlessAccount: passkey daily factor, no seed, not yet post-quantum', () => {
|
||||||
|
const d = createSeedlessAccount(PASSKEY);
|
||||||
|
assert.equal(d.chainId, 2800);
|
||||||
|
assert.equal(d.daily.type, 'passkey');
|
||||||
|
assert.equal(d.postQuantum, false);
|
||||||
|
assert.equal(d.root, undefined);
|
||||||
|
assert.equal(d.addresses.pqcAccountFactory, AERE_WALLET_ADDRESSES.pqcAccountFactory);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateFalconRoot: valid 897-byte NIST Falcon-512 public key', () => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
assert.equal(kp.scheme, SCHEME.FALCON512);
|
||||||
|
assert.equal(kp.publicKey.length, 897);
|
||||||
|
assert.equal(kp.publicKey[0], 0x09);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── client-side key custody ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('encrypt/decrypt Falcon secret key round-trips; wrong passphrase + tamper fail', async () => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const enc = await encryptFalconSecretKey(kp, 'correct horse battery', { iterations: 50_000 });
|
||||||
|
assert.equal(enc.cipher, 'AES-256-GCM');
|
||||||
|
assert.equal(enc.kdf, 'PBKDF2-SHA256');
|
||||||
|
assert.equal(enc.publicKey.toLowerCase(), hexlify(kp.publicKey).toLowerCase());
|
||||||
|
// ciphertext must not equal the plaintext secret key
|
||||||
|
assert.notEqual(enc.ciphertext.toLowerCase(), hexlify(kp.secretKey).toLowerCase());
|
||||||
|
|
||||||
|
const back = await decryptFalconSecretKey(enc, 'correct horse battery');
|
||||||
|
assert.deepEqual(Array.from(back.secretKey), Array.from(kp.secretKey));
|
||||||
|
assert.deepEqual(Array.from(back.publicKey), Array.from(kp.publicKey));
|
||||||
|
|
||||||
|
await assert.rejects(() => decryptFalconSecretKey(enc, 'wrong passphrase'), /decryption failed/);
|
||||||
|
|
||||||
|
const tampered = { ...enc, ciphertext: enc.ciphertext.slice(0, -2) + (enc.ciphertext.endsWith('00') ? '11' : '00') };
|
||||||
|
await assert.rejects(() => decryptFalconSecretKey(tampered, 'correct horse battery'), /decryption failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── the one-tap upgrade ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('upgradeToPostQuantum: fuses a Falcon root + counterfactual PQC account', async () => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const enc = await encryptFalconSecretKey(kp, 'passphrase-1234', { iterations: 50_000 });
|
||||||
|
const d0 = createSeedlessAccount(PASSKEY);
|
||||||
|
const d1 = upgradeToPostQuantum(d0, kp.publicKey, { encryptedKey: enc });
|
||||||
|
|
||||||
|
assert.equal(d1.postQuantum, true);
|
||||||
|
assert.equal(d0.postQuantum, false, 'input descriptor must be untouched (immutably upgraded)');
|
||||||
|
assert.ok(d1.root);
|
||||||
|
assert.equal(d1.root!.custody, 'client-encrypted');
|
||||||
|
assert.equal(d1.root!.scheme, SCHEME.FALCON512);
|
||||||
|
assert.equal(d1.root!.publicKey.toLowerCase(), hexlify(kp.publicKey).toLowerCase());
|
||||||
|
assert.match(d1.root!.pqcAccount, /^0x[0-9a-fA-F]{40}$/);
|
||||||
|
assert.ok(d1.root!.encryptedKey, 'client-encrypted custody must embed the encrypted key');
|
||||||
|
|
||||||
|
// client-encrypted custody without a key blob must be rejected
|
||||||
|
assert.throws(() => upgradeToPostQuantum(d0, kp.publicKey), /requires an encryptedKey/);
|
||||||
|
// an encrypted blob for a DIFFERENT key must be rejected
|
||||||
|
const other = generateFalconRoot(new Uint8Array(48).fill(9));
|
||||||
|
const encOther = await encryptFalconSecretKey(other, 'passphrase-1234', { iterations: 50_000 });
|
||||||
|
assert.throws(
|
||||||
|
() => upgradeToPostQuantum(d0, kp.publicKey, { encryptedKey: encOther }),
|
||||||
|
/does not match/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('predictPqcAccountAddress is deterministic and salt-sensitive', () => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const a0 = predictPqcAccountAddress(kp.publicKey, 0n);
|
||||||
|
const a0b = predictPqcAccountAddress(kp.publicKey, 0n);
|
||||||
|
const a1 = predictPqcAccountAddress(kp.publicKey, 1n);
|
||||||
|
assert.equal(a0, a0b);
|
||||||
|
assert.notEqual(a0, a1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Falcon PoP / attestation path (offline) ─────────────────────────────────
|
||||||
|
|
||||||
|
test('upgrade attestation: Falcon PoP verifies locally; WRONG NONCE fails', () => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||||
|
custody: 'secure-enclave', // no key blob needed for this custody
|
||||||
|
});
|
||||||
|
|
||||||
|
const att = buildUpgradeAttestation(client, d, kp.secretKey, { keyId: 0, nonce: 0 });
|
||||||
|
|
||||||
|
// The genuine signature verifies against the challenge it was bound to.
|
||||||
|
assert.equal(
|
||||||
|
verifyLocal(SCHEME.FALCON512, getBytes(att.challenge), att.signature, kp.publicKey),
|
||||||
|
true,
|
||||||
|
'genuine Falcon PoP must verify over its own challenge',
|
||||||
|
);
|
||||||
|
|
||||||
|
// NEGATIVE CONTROL: the same signature must FAIL against the challenge for a
|
||||||
|
// different nonce (replay/rebind protection). This is the core PoP guarantee.
|
||||||
|
const wrongNonceChallenge = client.deriveChallenge(0, 1, att.messageHash);
|
||||||
|
assert.notEqual(wrongNonceChallenge, att.challenge);
|
||||||
|
assert.equal(
|
||||||
|
verifyLocal(SCHEME.FALCON512, getBytes(wrongNonceChallenge), att.signature, kp.publicKey),
|
||||||
|
false,
|
||||||
|
'Falcon PoP bound to nonce 0 must NOT verify against the nonce-1 challenge',
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a signature over a different message hash must fail too.
|
||||||
|
const wrongMsgChallenge = client.deriveChallenge(0, 0, hexlify(randomBytes(32)));
|
||||||
|
assert.equal(
|
||||||
|
verifyLocal(SCHEME.FALCON512, getBytes(wrongMsgChallenge), att.signature, kp.publicKey),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signPqcAccountUserOp: Falcon-signed userOp verifies locally; tamper fails', () => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||||
|
custody: 'secure-enclave',
|
||||||
|
});
|
||||||
|
const op = {
|
||||||
|
sender: d.root!.pqcAccount,
|
||||||
|
nonce: 0n,
|
||||||
|
initCode: '0x',
|
||||||
|
callData: '0x',
|
||||||
|
accountGasLimits: zeroPadValue('0x0f4240000000000000000000000f4240', 32).slice(0, 66),
|
||||||
|
preVerificationGas: 21000n,
|
||||||
|
gasFees: zeroPadValue('0x3b9aca00000000000000000000003b9aca00', 32).slice(0, 66),
|
||||||
|
paymasterAndData: '0x',
|
||||||
|
signature: '0x',
|
||||||
|
};
|
||||||
|
const sig = signPqcAccountUserOp(op, d.addresses.entryPoint, d.chainId, kp.secretKey);
|
||||||
|
assert.match(sig.userOpHash, /^0x[0-9a-fA-F]{64}$/);
|
||||||
|
assert.match(sig.accountSignature, /^0x[0-9a-fA-F]+$/);
|
||||||
|
|
||||||
|
assert.equal(verifyPqcAccountUserOpLocal(sig, kp.publicKey), true);
|
||||||
|
|
||||||
|
// A userOp signature is bound to the exact userOpHash: changing the op changes
|
||||||
|
// the hash, so the same signature no longer verifies for the new op.
|
||||||
|
const op2 = { ...op, nonce: 1n };
|
||||||
|
const sig2 = signPqcAccountUserOp(op2, d.addresses.entryPoint, d.chainId, kp.secretKey);
|
||||||
|
assert.notEqual(sig.userOpHash, sig2.userOpHash);
|
||||||
|
// wrong pubkey also rejected
|
||||||
|
const other = generateFalconRoot(new Uint8Array(48).fill(3));
|
||||||
|
assert.equal(verifyPqcAccountUserOpLocal(sig, other.publicKey), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── LIVE CHAIN (skip if offline) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('LIVE: CREATE2 prediction matches on-chain AerePQCAccountFactory', async (t) => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const salt = 42n;
|
||||||
|
const local = predictPqcAccountAddress(kp.publicKey, salt);
|
||||||
|
const factory = new Contract(
|
||||||
|
AERE_WALLET_ADDRESSES.pqcAccountFactory,
|
||||||
|
['function predictAddress(bytes,uint256) view returns (address)'],
|
||||||
|
provider,
|
||||||
|
);
|
||||||
|
const onchain = await onChain(t, () => factory.predictAddress(hexlify(kp.publicKey), salt));
|
||||||
|
if (onchain === undefined) return;
|
||||||
|
assert.equal(local.toLowerCase(), (onchain as string).toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: upgrade attestation verifies on the precompile; wrong nonce rejected', async (t) => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||||
|
custody: 'secure-enclave',
|
||||||
|
});
|
||||||
|
const att = buildUpgradeAttestation(client, d, kp.secretKey, { keyId: 0, nonce: 0 });
|
||||||
|
|
||||||
|
const ok = await onChain(t, () => verifyUpgradeAttestationLive(client, d, att));
|
||||||
|
if (ok === undefined) return;
|
||||||
|
assert.equal(ok, true, 'LIVE precompile must accept the genuine upgrade attestation');
|
||||||
|
|
||||||
|
// Build a nonce-1 attestation but check the nonce-0 signature against it: the
|
||||||
|
// on-chain challenge differs, so the precompile rejects.
|
||||||
|
const attWrong = { ...att, nonce: 1n };
|
||||||
|
const bad = await onChain(t, () => verifyUpgradeAttestationLive(client, d, attWrong));
|
||||||
|
if (bad === undefined) return;
|
||||||
|
assert.equal(bad, false, 'LIVE precompile must reject the nonce-0 signature under the nonce-1 challenge');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LIVE: Falcon-signed userOp leg verifies on the precompile; wrong hash rejected', async (t) => {
|
||||||
|
const kp = generateFalconRoot(SEED);
|
||||||
|
const d = upgradeToPostQuantum(createSeedlessAccount(PASSKEY), kp.publicKey, {
|
||||||
|
custody: 'secure-enclave',
|
||||||
|
});
|
||||||
|
const op = {
|
||||||
|
sender: d.root!.pqcAccount,
|
||||||
|
nonce: 5n,
|
||||||
|
initCode: '0x',
|
||||||
|
callData: '0x',
|
||||||
|
accountGasLimits: zeroPadValue('0x0f4240000000000000000000000f4240', 32).slice(0, 66),
|
||||||
|
preVerificationGas: 21000n,
|
||||||
|
gasFees: zeroPadValue('0x3b9aca00000000000000000000003b9aca00', 32).slice(0, 66),
|
||||||
|
paymasterAndData: '0x',
|
||||||
|
signature: '0x',
|
||||||
|
};
|
||||||
|
const sig = signPqcAccountUserOp(op, d.addresses.entryPoint, d.chainId, kp.secretKey);
|
||||||
|
|
||||||
|
const ok = await onChain(t, () => verifyUserOpFalconLegLive(client, sig, kp.publicKey));
|
||||||
|
if (ok === undefined) return;
|
||||||
|
assert.equal(ok, true, 'LIVE precompile must accept the Falcon userOp leg');
|
||||||
|
|
||||||
|
const bad = await onChain(t, () =>
|
||||||
|
client.verifySignature(SCHEME.FALCON512, kp.publicKey, hexlify(randomBytes(32)), sig.attestationEnvelope),
|
||||||
|
);
|
||||||
|
if (bad === undefined) return;
|
||||||
|
assert.equal(bad, false, 'LIVE precompile must reject the Falcon leg over a different hash');
|
||||||
|
});
|
||||||
154
src/wallet/descriptor.ts
Normal file
154
src/wallet/descriptor.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// Seedless account descriptor construction + the "upgrade to post-quantum" flow.
|
||||||
|
//
|
||||||
|
// createSeedlessAccount(passkey) -> descriptor with a passkey daily factor
|
||||||
|
// upgradeToPostQuantum(descriptor, ...) -> fuses a Falcon-512 quantum-durable root
|
||||||
|
// predictPqcAccountAddress(...) -> CREATE2 address of the Falcon-owned
|
||||||
|
// AerePQCAccount (mirrors the live factory)
|
||||||
|
//
|
||||||
|
// The prediction is a pure mirror of AerePQCAccountFactory.predictAddress on chain
|
||||||
|
// 2800 and is proven byte-identical to the on-chain view in wallet.test.ts.
|
||||||
|
|
||||||
|
import { keccak256, AbiCoder, concat, getBytes, hexlify, getAddress } from 'ethers';
|
||||||
|
import { AERE_MAINNET } from '../addresses.js';
|
||||||
|
import { SCHEME, LENGTHS } from '../pqc/index.js';
|
||||||
|
import {
|
||||||
|
WALLET_DESCRIPTOR_VERSION,
|
||||||
|
type SeedlessAccountDescriptor,
|
||||||
|
type PasskeyDailyFactor,
|
||||||
|
type WalletAddresses,
|
||||||
|
type CustodyMode,
|
||||||
|
type FalconRoot,
|
||||||
|
type EncryptedFalconKey,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* keccak256 of the AerePQCAccount creation bytecode — the CREATE2 init-code hash
|
||||||
|
* every AerePQCAccountFactory account shares (the factory has no constructor
|
||||||
|
* args; EntryPoint + verifier are factory immutables). Read live from
|
||||||
|
* AerePQCAccountFactory.ACCOUNT_INIT_CODE_HASH() on chain 2800 (2026-07-12) and
|
||||||
|
* re-verified against the on-chain predictAddress in wallet.test.ts.
|
||||||
|
*/
|
||||||
|
export const AERE_PQC_ACCOUNT_INIT_CODE_HASH =
|
||||||
|
'0x05dd938d3980ee22354b9635703e916187d0323259d2c9468954382245bb10ce';
|
||||||
|
|
||||||
|
/** Default chain-2800 addresses the seedless wallet binds to. */
|
||||||
|
export const AERE_WALLET_ADDRESSES: WalletAddresses = {
|
||||||
|
entryPoint: AERE_MAINNET.AereEntryPointV2,
|
||||||
|
passkeyFactory: AERE_MAINNET.AerePasskeyAccountFactoryV2Fixed,
|
||||||
|
pqcAccountFactory: AERE_MAINNET.AerePQCAccountFactory,
|
||||||
|
falconVerifier: AERE_MAINNET.AereFalcon512Verifier,
|
||||||
|
pqcAttestation: AERE_MAINNET.AerePQCAttestation,
|
||||||
|
pqcAccountInitCodeHash: AERE_PQC_ACCOUNT_INIT_CODE_HASH,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateSeedlessAccountOptions {
|
||||||
|
chainId?: number;
|
||||||
|
addresses?: WalletAddresses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a seedless account descriptor from a passkey daily factor. This is the
|
||||||
|
* pre-upgrade state: an everyday passkey authority and NO Falcon root yet.
|
||||||
|
* No seed phrase is generated or required.
|
||||||
|
*/
|
||||||
|
export function createSeedlessAccount(
|
||||||
|
passkey: PasskeyDailyFactor,
|
||||||
|
opts: CreateSeedlessAccountOptions = {},
|
||||||
|
): SeedlessAccountDescriptor {
|
||||||
|
if (passkey.type !== 'passkey') throw new Error('descriptor: daily factor must be a passkey');
|
||||||
|
getAddress(passkey.authorityAddress); // validate checksum/shape
|
||||||
|
return {
|
||||||
|
version: WALLET_DESCRIPTOR_VERSION,
|
||||||
|
chainId: opts.chainId ?? AERE_MAINNET.chainId,
|
||||||
|
daily: passkey,
|
||||||
|
root: undefined,
|
||||||
|
addresses: opts.addresses ?? AERE_WALLET_ADDRESSES,
|
||||||
|
postQuantum: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFalcon512PubKey(pubKey: Uint8Array): void {
|
||||||
|
if (pubKey.length !== LENGTHS.falcon512.pubKey || pubKey[0] !== LENGTHS.falcon512.pkHeader) {
|
||||||
|
throw new Error(
|
||||||
|
`descriptor: Falcon-512 pubKey must be ${LENGTHS.falcon512.pubKey} bytes with 0x09 header`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure mirror of AerePQCAccountFactory.predictAddress(falconPubKey, salt):
|
||||||
|
* saltMix = keccak256(abi.encode(bytes falconPubKey, uint256 salt))
|
||||||
|
* addr = keccak256(0xff || factory || saltMix || initCodeHash)[12:]
|
||||||
|
* Returns the counterfactual AerePQCAccount address (a checksummed address).
|
||||||
|
*/
|
||||||
|
export function predictPqcAccountAddress(
|
||||||
|
falconPubKey: Uint8Array,
|
||||||
|
salt: bigint,
|
||||||
|
factory: string = AERE_WALLET_ADDRESSES.pqcAccountFactory,
|
||||||
|
initCodeHash: string = AERE_PQC_ACCOUNT_INIT_CODE_HASH,
|
||||||
|
): string {
|
||||||
|
assertFalcon512PubKey(falconPubKey);
|
||||||
|
const saltMix = keccak256(
|
||||||
|
AbiCoder.defaultAbiCoder().encode(['bytes', 'uint256'], [hexlify(falconPubKey), salt]),
|
||||||
|
);
|
||||||
|
const digest = keccak256(concat(['0xff', getAddress(factory), saltMix, initCodeHash]));
|
||||||
|
return getAddress('0x' + digest.slice(26));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeOptions {
|
||||||
|
/** CREATE2 salt for the AerePQCAccount. Default 0. */
|
||||||
|
salt?: bigint;
|
||||||
|
/** How the Falcon secret key is custodied. Default 'client-encrypted'. */
|
||||||
|
custody?: CustodyMode;
|
||||||
|
/** Encrypted secret-key blob to embed when custody === 'client-encrypted'. */
|
||||||
|
encryptedKey?: EncryptedFalconKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-tap "upgrade my account to post-quantum" flow: fuse a Falcon-512
|
||||||
|
* quantum-durable root onto an existing seedless descriptor. Returns a NEW
|
||||||
|
* descriptor (does not mutate the input) whose `root` binds:
|
||||||
|
* - the Falcon-512 public key,
|
||||||
|
* - the counterfactual AerePQCAccount it solely owns,
|
||||||
|
* - the custody mode, and
|
||||||
|
* - (for 'client-encrypted') the encrypted key blob.
|
||||||
|
*
|
||||||
|
* This performs NO transaction. Deploying the AerePQCAccount and recording the
|
||||||
|
* upgrade attestation are separate on-chain steps (founder-gated); this builds
|
||||||
|
* the material and the counterfactual address only.
|
||||||
|
*/
|
||||||
|
export function upgradeToPostQuantum(
|
||||||
|
descriptor: SeedlessAccountDescriptor,
|
||||||
|
falconPublicKey: Uint8Array,
|
||||||
|
opts: UpgradeOptions = {},
|
||||||
|
): SeedlessAccountDescriptor {
|
||||||
|
assertFalcon512PubKey(falconPublicKey);
|
||||||
|
const salt = opts.salt ?? 0n;
|
||||||
|
const custody = opts.custody ?? 'client-encrypted';
|
||||||
|
if (custody === 'client-encrypted') {
|
||||||
|
if (!opts.encryptedKey) {
|
||||||
|
throw new Error(
|
||||||
|
"descriptor: custody 'client-encrypted' requires an encryptedKey blob " +
|
||||||
|
'(encrypt the Falcon secret key with encryptFalconSecretKey first)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (opts.encryptedKey.publicKey.toLowerCase() !== hexlify(falconPublicKey).toLowerCase()) {
|
||||||
|
throw new Error('descriptor: encryptedKey.publicKey does not match the Falcon root public key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pqcAccount = predictPqcAccountAddress(
|
||||||
|
falconPublicKey,
|
||||||
|
salt,
|
||||||
|
descriptor.addresses.pqcAccountFactory,
|
||||||
|
descriptor.addresses.pqcAccountInitCodeHash,
|
||||||
|
);
|
||||||
|
const root: FalconRoot = {
|
||||||
|
scheme: SCHEME.FALCON512,
|
||||||
|
publicKey: hexlify(falconPublicKey),
|
||||||
|
pqcAccount,
|
||||||
|
salt: salt.toString(),
|
||||||
|
custody,
|
||||||
|
...(custody === 'client-encrypted' ? { encryptedKey: opts.encryptedKey } : {}),
|
||||||
|
};
|
||||||
|
return { ...descriptor, root, postQuantum: true };
|
||||||
|
}
|
||||||
142
src/wallet/falconKeystore.ts
Normal file
142
src/wallet/falconKeystore.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// Client-side Falcon-512 key generation + encryption for the seedless PQC wallet.
|
||||||
|
//
|
||||||
|
// The Falcon quantum-durable ROOT key is generated on the client (never on a
|
||||||
|
// server) and, in the RECOMMENDED 'client-encrypted' custody mode, persisted ONLY
|
||||||
|
// as an AES-256-GCM ciphertext unlocked by a user passphrase via PBKDF2-SHA256.
|
||||||
|
// This is what makes the quantum-lock co-owner PORTABLE without a seed phrase:
|
||||||
|
// the encrypted blob alone (plus the passphrase) restores the root on any device.
|
||||||
|
//
|
||||||
|
// Crypto is WebCrypto (globalThis.crypto.subtle) — zero extra dependencies,
|
||||||
|
// identical in modern Node (>=20) and browsers. Falcon keygen is the audited
|
||||||
|
// @noble/post-quantum implementation already used across the PQC SDK.
|
||||||
|
|
||||||
|
import { falcon512 } from '@noble/post-quantum/falcon.js';
|
||||||
|
import { hexlify, getBytes, type BytesLike } from 'ethers';
|
||||||
|
import { SCHEME } from '../pqc/index.js';
|
||||||
|
import { LENGTHS } from '../pqc/index.js';
|
||||||
|
import {
|
||||||
|
WALLET_DESCRIPTOR_VERSION,
|
||||||
|
type EncryptedFalconKey,
|
||||||
|
type FalconKeypair,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/** Default PBKDF2 work factor. 600k SHA-256 iterations (OWASP 2023 floor). */
|
||||||
|
export const DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||||
|
|
||||||
|
function subtle(): SubtleCrypto {
|
||||||
|
const c = (globalThis as { crypto?: Crypto }).crypto;
|
||||||
|
if (!c || !c.subtle) {
|
||||||
|
throw new Error('falconKeystore: WebCrypto (globalThis.crypto.subtle) is unavailable in this runtime');
|
||||||
|
}
|
||||||
|
return c.subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBytes(n: number): Uint8Array {
|
||||||
|
const c = (globalThis as { crypto?: Crypto }).crypto;
|
||||||
|
if (!c || !c.getRandomValues) throw new Error('falconKeystore: no CSPRNG (crypto.getRandomValues)');
|
||||||
|
return c.getRandomValues(new Uint8Array(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy any Uint8Array into a fresh, plain-ArrayBuffer-backed buffer for WebCrypto. */
|
||||||
|
function toArrayBuffer(u: Uint8Array): ArrayBuffer {
|
||||||
|
const ab = new ArrayBuffer(u.byteLength);
|
||||||
|
new Uint8Array(ab).set(u);
|
||||||
|
return ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh Falcon-512 keypair for a wallet root. Optionally deterministic
|
||||||
|
* from a 48-byte seed (test vectors only — production keys use the CSPRNG).
|
||||||
|
*/
|
||||||
|
export function generateFalconRoot(seed?: Uint8Array): FalconKeypair {
|
||||||
|
const kp = falcon512.keygen(seed);
|
||||||
|
if (kp.publicKey.length !== LENGTHS.falcon512.pubKey || kp.publicKey[0] !== LENGTHS.falcon512.pkHeader) {
|
||||||
|
throw new Error('falconKeystore: generated Falcon-512 pubkey has an unexpected shape');
|
||||||
|
}
|
||||||
|
return { scheme: SCHEME.FALCON512, publicKey: kp.publicKey, secretKey: kp.secretKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveAesKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
|
||||||
|
const material = await subtle().importKey(
|
||||||
|
'raw',
|
||||||
|
toArrayBuffer(new TextEncoder().encode(passphrase)),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey'],
|
||||||
|
);
|
||||||
|
return subtle().deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt: toArrayBuffer(salt), iterations, hash: 'SHA-256' },
|
||||||
|
material,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptOptions {
|
||||||
|
iterations?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side-encrypt a Falcon-512 secret key under `passphrase`. The public key
|
||||||
|
* is stored in the clear (it is public); the secret key is only ever persisted as
|
||||||
|
* the returned ciphertext. Reversible only by {@link decryptFalconSecretKey} with
|
||||||
|
* the same passphrase.
|
||||||
|
*/
|
||||||
|
export async function encryptFalconSecretKey(
|
||||||
|
keypair: FalconKeypair,
|
||||||
|
passphrase: string,
|
||||||
|
opts: EncryptOptions = {},
|
||||||
|
): Promise<EncryptedFalconKey> {
|
||||||
|
if (!passphrase || passphrase.length < 8) {
|
||||||
|
throw new Error('falconKeystore: passphrase must be at least 8 characters');
|
||||||
|
}
|
||||||
|
const iterations = opts.iterations ?? DEFAULT_PBKDF2_ITERATIONS;
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const key = await deriveAesKey(passphrase, salt, iterations);
|
||||||
|
const ct = new Uint8Array(
|
||||||
|
await subtle().encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(keypair.secretKey)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
version: WALLET_DESCRIPTOR_VERSION,
|
||||||
|
scheme: keypair.scheme,
|
||||||
|
publicKey: hexlify(keypair.publicKey),
|
||||||
|
kdf: 'PBKDF2-SHA256',
|
||||||
|
kdfSalt: hexlify(salt),
|
||||||
|
kdfIterations: iterations,
|
||||||
|
cipher: 'AES-256-GCM',
|
||||||
|
iv: hexlify(iv),
|
||||||
|
ciphertext: hexlify(ct),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a {@link EncryptedFalconKey} back into a usable {@link FalconKeypair}.
|
||||||
|
* Throws on a wrong passphrase or tampered ciphertext (GCM auth-tag failure).
|
||||||
|
*/
|
||||||
|
export async function decryptFalconSecretKey(
|
||||||
|
enc: EncryptedFalconKey,
|
||||||
|
passphrase: string,
|
||||||
|
): Promise<FalconKeypair> {
|
||||||
|
if (enc.kdf !== 'PBKDF2-SHA256' || enc.cipher !== 'AES-256-GCM') {
|
||||||
|
throw new Error(`falconKeystore: unsupported kdf/cipher (${enc.kdf}/${enc.cipher})`);
|
||||||
|
}
|
||||||
|
const salt = getBytes(enc.kdfSalt);
|
||||||
|
const iv = getBytes(enc.iv);
|
||||||
|
const key = await deriveAesKey(passphrase, salt, enc.kdfIterations);
|
||||||
|
let plain: Uint8Array;
|
||||||
|
try {
|
||||||
|
plain = new Uint8Array(
|
||||||
|
await subtle().decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(getBytes(enc.ciphertext))),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new Error('falconKeystore: decryption failed (wrong passphrase or corrupted key blob)');
|
||||||
|
}
|
||||||
|
return { scheme: enc.scheme, publicKey: getBytes(enc.publicKey), secretKey: plain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: hex the public key of a keypair for descriptors / on-chain calls. */
|
||||||
|
export function falconPubKeyHex(kp: FalconKeypair | { publicKey: BytesLike }): string {
|
||||||
|
return hexlify(getBytes(kp.publicKey));
|
||||||
|
}
|
||||||
235
src/wallet/hybridAuth.ts
Normal file
235
src/wallet/hybridAuth.ts
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
// Hybrid-auth surface for the seedless PQC wallet: the Falcon-512 root produces
|
||||||
|
// (a) a one-tap "upgrade to post-quantum" ATTESTATION binding the quantum-durable
|
||||||
|
// root to the passkey account, recordable via AerePQCAttestation, and (b) the
|
||||||
|
// USEROP signature the Falcon-owned AerePQCAccount consumes. Both end in a REAL
|
||||||
|
// Falcon-512 signature the chain verifies on-chain via the live precompile.
|
||||||
|
//
|
||||||
|
// HONEST SCOPE. The everyday spend path is the passkey daily factor: hot,
|
||||||
|
// classical secp256k1-routed, fast. The Falcon root is the QUANTUM-DURABLE
|
||||||
|
// authority for the high-assurance actions (the upgrade itself, recovery,
|
||||||
|
// high-value / co-signed ops). Only the ROOT is post-quantum; the hot session is
|
||||||
|
// not, by design. The AERE chain's own consensus remains classical QBFT/ECDSA —
|
||||||
|
// this is application-layer PQC, not post-quantum consensus.
|
||||||
|
|
||||||
|
import {
|
||||||
|
keccak256, AbiCoder, getBytes, hexlify, toUtf8Bytes, concat,
|
||||||
|
type BytesLike,
|
||||||
|
} from 'ethers';
|
||||||
|
import { falcon512 } from '@noble/post-quantum/falcon.js';
|
||||||
|
import {
|
||||||
|
SCHEME, signInternal, verifyLocal, falconDetachedToEnvelope, FALCON_NONCE_LEN,
|
||||||
|
type AerePQCClient,
|
||||||
|
} from '../pqc/index.js';
|
||||||
|
import { computeUserOpHash } from '../account/abi.js';
|
||||||
|
import type { PackedUserOp } from '../account/types.js';
|
||||||
|
import type { SeedlessAccountDescriptor } from './types.js';
|
||||||
|
|
||||||
|
/** keccak256("AereSeedlessPQCWallet.v1.upgrade") — the upgrade-attestation domain. */
|
||||||
|
export const UPGRADE_ATTESTATION_DOMAIN = keccak256(
|
||||||
|
toUtf8Bytes('AereSeedlessPQCWallet.v1.upgrade'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function bytes32(v: BytesLike): string {
|
||||||
|
const b = getBytes(v);
|
||||||
|
if (b.length !== 32) throw new Error(`hybridAuth: expected 32-byte value, got ${b.length}`);
|
||||||
|
return hexlify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* keccak256("AerePQCAccount.validateUserOp.v1") — the userOp signing DOMAIN. The
|
||||||
|
* AerePQCAccount verifies validateUserOp over abi.encodePacked(domain, userOpHash)
|
||||||
|
* (64 bytes), domain-separated from the raw 32-byte EIP-1271 login surface so a
|
||||||
|
* signature phished through a dapp "login" can never be replayed as a spend. This
|
||||||
|
* MUST match the AerePQCAccount.USEROP_DOMAIN constant. (Adversarial-review HIGH.)
|
||||||
|
*/
|
||||||
|
export const PQC_ACCOUNT_USEROP_DOMAIN = keccak256(
|
||||||
|
toUtf8Bytes('AerePQCAccount.validateUserOp.v1'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The exact 64-byte message the AerePQCAccount Falcon-verifies for a userOp. */
|
||||||
|
export function pqcAccountUserOpMessage(userOpHash: BytesLike): string {
|
||||||
|
return concat([PQC_ACCOUNT_USEROP_DOMAIN, bytes32(userOpHash)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message hash committing an "upgrade to post-quantum" of a specific seedless
|
||||||
|
* account. Binds the Falcon root to the everyday passkey authority AND to the
|
||||||
|
* Falcon-owned PQC account, so an attestation over it cannot be replayed onto a
|
||||||
|
* different account or a different daily factor:
|
||||||
|
*
|
||||||
|
* keccak256(abi.encode(
|
||||||
|
* UPGRADE_ATTESTATION_DOMAIN, chainId,
|
||||||
|
* passkeyAuthority, keccak256(falconPubKey), pqcAccount))
|
||||||
|
*/
|
||||||
|
export function upgradeMessageHash(descriptor: SeedlessAccountDescriptor): string {
|
||||||
|
if (!descriptor.root) {
|
||||||
|
throw new Error('hybridAuth: descriptor has no Falcon root (call upgradeToPostQuantum first)');
|
||||||
|
}
|
||||||
|
const enc = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes32', 'uint256', 'address', 'bytes32', 'address'],
|
||||||
|
[
|
||||||
|
UPGRADE_ATTESTATION_DOMAIN,
|
||||||
|
descriptor.chainId,
|
||||||
|
descriptor.daily.authorityAddress,
|
||||||
|
keccak256(descriptor.root.publicKey),
|
||||||
|
descriptor.root.pqcAccount,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return keccak256(enc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeAttestation {
|
||||||
|
/** The account-binding message hash (see {@link upgradeMessageHash}). */
|
||||||
|
messageHash: string;
|
||||||
|
/** The AerePQCAttestation per-key challenge the Falcon signature covers. */
|
||||||
|
challenge: string;
|
||||||
|
/** keyId used in the challenge derivation. */
|
||||||
|
keyId: bigint;
|
||||||
|
/** per-key nonce used in the challenge derivation. */
|
||||||
|
nonce: bigint;
|
||||||
|
/** The Falcon-512 signature envelope (AerePQCAttestation wire format). */
|
||||||
|
signature: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the one-tap upgrade attestation: derive the account-binding message hash,
|
||||||
|
* derive the AerePQCAttestation challenge for (keyId, nonce, messageHash), and
|
||||||
|
* sign that challenge with the Falcon-512 ROOT secret key. The resulting envelope
|
||||||
|
* is exactly what AerePQCAttestation.attest()/verifySignature() consumes, and its
|
||||||
|
* validity rests on a real Falcon-512 signature the live precompile verifies.
|
||||||
|
*
|
||||||
|
* `client` supplies the challenge derivation (chainId + attestation address);
|
||||||
|
* it is used purely for the pure/local deriveChallenge — no network call here.
|
||||||
|
* `secretKey` never leaves this process.
|
||||||
|
*/
|
||||||
|
export function buildUpgradeAttestation(
|
||||||
|
client: AerePQCClient,
|
||||||
|
descriptor: SeedlessAccountDescriptor,
|
||||||
|
secretKey: Uint8Array,
|
||||||
|
params: { keyId: bigint | number; nonce: bigint | number },
|
||||||
|
): UpgradeAttestation {
|
||||||
|
const messageHash = upgradeMessageHash(descriptor);
|
||||||
|
const keyId = BigInt(params.keyId);
|
||||||
|
const nonce = BigInt(params.nonce);
|
||||||
|
const challenge = client.deriveChallenge(keyId, nonce, messageHash);
|
||||||
|
const signature = signInternal(SCHEME.FALCON512, getBytes(challenge), secretKey);
|
||||||
|
return { messageHash, challenge, keyId, nonce, signature };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READ-ONLY on-chain verification of an upgrade attestation: re-derives the
|
||||||
|
* challenge on-chain, then asks the live Falcon-512 precompile (via
|
||||||
|
* AerePQCAttestation.verifySignature, a free eth_call) whether the envelope is a
|
||||||
|
* valid Falcon signature over that challenge. No transaction; no state change.
|
||||||
|
*/
|
||||||
|
export async function verifyUpgradeAttestationLive(
|
||||||
|
client: AerePQCClient,
|
||||||
|
descriptor: SeedlessAccountDescriptor,
|
||||||
|
att: UpgradeAttestation,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!descriptor.root) throw new Error('hybridAuth: descriptor has no Falcon root');
|
||||||
|
const onChainChallenge = await client.attestChallenge(att.keyId, att.nonce, att.messageHash);
|
||||||
|
if (onChainChallenge.toLowerCase() !== att.challenge.toLowerCase()) return false;
|
||||||
|
return client.verifySignature(
|
||||||
|
SCHEME.FALCON512,
|
||||||
|
getBytes(descriptor.root.publicKey),
|
||||||
|
onChainChallenge,
|
||||||
|
att.signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PqcUserOpSignature {
|
||||||
|
/** The ERC-4337 userOpHash (mirrors AereEntryPointV2.getUserOpHash). */
|
||||||
|
userOpHash: string;
|
||||||
|
/**
|
||||||
|
* The AerePQCAccount signature bytes: abi.encode(bytes nonce, bytes compSig),
|
||||||
|
* where compSig is the Falcon compressed body WITHOUT the 0x29 esig header.
|
||||||
|
* Set this as userOp.signature for handleOps.
|
||||||
|
*/
|
||||||
|
accountSignature: string;
|
||||||
|
/**
|
||||||
|
* The SAME Falcon signature re-cast into the AerePQCAttestation wire envelope
|
||||||
|
* (nonce(40) || 0x29 || compSig), for verifying the Falcon leg via the live
|
||||||
|
* precompile (verifySignature) read-only.
|
||||||
|
*/
|
||||||
|
attestationEnvelope: Uint8Array;
|
||||||
|
/** The raw noble detached signature (0x39 || nonce(40) || compSig). */
|
||||||
|
detached: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an ERC-4337 UserOperation for the Falcon-owned AerePQCAccount with the
|
||||||
|
* quantum-durable ROOT key. Produces both the account-consumable signature
|
||||||
|
* (abi.encode(nonce, compSig)) and the AerePQCAttestation-format envelope for a
|
||||||
|
* free on-chain verification of the Falcon leg.
|
||||||
|
*
|
||||||
|
* The account verifies validateUserOp over the DOMAIN-SEPARATED message
|
||||||
|
* abi.encodePacked(USEROP_DOMAIN, userOpHash) via the SAME live AereFalcon512Verifier
|
||||||
|
* the precompile wraps, so a `true` from {@link verifyUserOpFalconLegLive} is exactly
|
||||||
|
* the check the account performs. (isValidSignature stays raw-hash for EIP-1271
|
||||||
|
* integrators; the domain separation is what stops a login sig authorizing a spend.)
|
||||||
|
*/
|
||||||
|
export function signPqcAccountUserOp(
|
||||||
|
op: PackedUserOp,
|
||||||
|
entryPoint: string,
|
||||||
|
chainId: bigint | number,
|
||||||
|
secretKey: Uint8Array,
|
||||||
|
): PqcUserOpSignature {
|
||||||
|
const userOpHash = computeUserOpHash(op, entryPoint, chainId);
|
||||||
|
const msg = getBytes(pqcAccountUserOpMessage(userOpHash));
|
||||||
|
const detached = falcon512.sign(msg, secretKey);
|
||||||
|
const expectedHdr = 0x30 | 9; // 0x39
|
||||||
|
if (detached[0] !== expectedHdr || detached.length <= 1 + FALCON_NONCE_LEN) {
|
||||||
|
throw new Error('hybridAuth: unexpected Falcon detached signature shape');
|
||||||
|
}
|
||||||
|
const nonce = detached.subarray(1, 1 + FALCON_NONCE_LEN);
|
||||||
|
const compSig = detached.subarray(1 + FALCON_NONCE_LEN);
|
||||||
|
const accountSignature = AbiCoder.defaultAbiCoder().encode(
|
||||||
|
['bytes', 'bytes'],
|
||||||
|
[hexlify(nonce), hexlify(compSig)],
|
||||||
|
);
|
||||||
|
const attestationEnvelope = falconDetachedToEnvelope(detached, SCHEME.FALCON512);
|
||||||
|
return { userOpHash, accountSignature, attestationEnvelope, detached };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Off-chain mirror of the AerePQCAccount Falcon check (fast pre-flight).
|
||||||
|
* Uses the raw noble verify over the 64-byte domain-separated message (the SDK's
|
||||||
|
* verifyLocal helper is locked to 32-byte messages); mirrors falcon512.sign in the
|
||||||
|
* signer, and matches the on-chain AereFalcon512Verifier the account calls. */
|
||||||
|
export function verifyPqcAccountUserOpLocal(
|
||||||
|
sig: PqcUserOpSignature,
|
||||||
|
falconPubKey: BytesLike,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
return falcon512.verify(
|
||||||
|
getBytes(sig.detached),
|
||||||
|
getBytes(pqcAccountUserOpMessage(sig.userOpHash)),
|
||||||
|
getBytes(falconPubKey),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READ-ONLY on-chain verification of a UserOp's Falcon leg via the live precompile
|
||||||
|
* (AerePQCAttestation.verifySignature). This is the exact lattice check the
|
||||||
|
* AerePQCAccount runs in validateUserOp / isValidSignature — proven without
|
||||||
|
* deploying the account or sending a transaction.
|
||||||
|
*/
|
||||||
|
export async function verifyUserOpFalconLegLive(
|
||||||
|
client: AerePQCClient,
|
||||||
|
sig: PqcUserOpSignature,
|
||||||
|
falconPubKey: BytesLike,
|
||||||
|
): Promise<boolean> {
|
||||||
|
// The spend message is domain-separated (64 bytes), so it cannot go through the
|
||||||
|
// 32-byte AerePQCAttestation path; verify it directly via the raw Falcon verifier,
|
||||||
|
// exactly as AerePQCAccount.validateUserOp does.
|
||||||
|
const [nonce, compSig] = AbiCoder.defaultAbiCoder().decode(['bytes', 'bytes'], sig.accountSignature);
|
||||||
|
return client.verifyFalconRaw(
|
||||||
|
falconPubKey,
|
||||||
|
pqcAccountUserOpMessage(sig.userOpHash),
|
||||||
|
nonce,
|
||||||
|
compSig,
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/wallet/index.ts
Normal file
58
src/wallet/index.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// AERE seedless hybrid PQC wallet core (chain 2800).
|
||||||
|
//
|
||||||
|
// A wallet with NO seed phrase: a WebAuthn passkey is the everyday / daily factor
|
||||||
|
// (behind the Universal-Login / AerePasskeyAccountFactoryV2Fixed stack) and a NIST
|
||||||
|
// Falcon-512 co-owner is the quantum-durable root (an AerePQCAccount verified by
|
||||||
|
// the live on-chain AereFalcon512Verifier / AerePQCAttestation precompile).
|
||||||
|
//
|
||||||
|
// createSeedlessAccount(passkey) -> descriptor (pre-upgrade)
|
||||||
|
// generateFalconRoot() -> Falcon-512 keypair
|
||||||
|
// encryptFalconSecretKey(kp, passphrase)-> client-side-encrypted root key
|
||||||
|
// upgradeToPostQuantum(descriptor, pk) -> one-tap PQC upgrade (fuses the root)
|
||||||
|
// buildUpgradeAttestation(...) -> real Falcon sig binding root->account
|
||||||
|
// signPqcAccountUserOp(...) -> Falcon-signed ERC-4337 userOp
|
||||||
|
// verify*Live(...) -> read-only precompile verification
|
||||||
|
//
|
||||||
|
// See docs/PQC-WALLET-DESIGN.md for the design + the custody recommendation.
|
||||||
|
|
||||||
|
export {
|
||||||
|
WALLET_DESCRIPTOR_VERSION,
|
||||||
|
type CustodyMode,
|
||||||
|
type PasskeyDailyFactor,
|
||||||
|
type FalconKeypair,
|
||||||
|
type EncryptedFalconKey,
|
||||||
|
type FalconRoot,
|
||||||
|
type WalletAddresses,
|
||||||
|
type SeedlessAccountDescriptor,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_PBKDF2_ITERATIONS,
|
||||||
|
generateFalconRoot,
|
||||||
|
encryptFalconSecretKey,
|
||||||
|
decryptFalconSecretKey,
|
||||||
|
falconPubKeyHex,
|
||||||
|
type EncryptOptions,
|
||||||
|
} from './falconKeystore.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AERE_PQC_ACCOUNT_INIT_CODE_HASH,
|
||||||
|
AERE_WALLET_ADDRESSES,
|
||||||
|
createSeedlessAccount,
|
||||||
|
predictPqcAccountAddress,
|
||||||
|
upgradeToPostQuantum,
|
||||||
|
type CreateSeedlessAccountOptions,
|
||||||
|
type UpgradeOptions,
|
||||||
|
} from './descriptor.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
UPGRADE_ATTESTATION_DOMAIN,
|
||||||
|
upgradeMessageHash,
|
||||||
|
buildUpgradeAttestation,
|
||||||
|
verifyUpgradeAttestationLive,
|
||||||
|
signPqcAccountUserOp,
|
||||||
|
verifyPqcAccountUserOpLocal,
|
||||||
|
verifyUserOpFalconLegLive,
|
||||||
|
type UpgradeAttestation,
|
||||||
|
type PqcUserOpSignature,
|
||||||
|
} from './hybridAuth.js';
|
||||||
138
src/wallet/types.ts
Normal file
138
src/wallet/types.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
// Types for the AERE seedless hybrid PQC wallet core (chain 2800).
|
||||||
|
//
|
||||||
|
// The wallet fuses two authorities under ONE seedless account descriptor:
|
||||||
|
// 1. a WebAuthn PASSKEY as the everyday / daily factor (hot secp256k1-routed
|
||||||
|
// authority behind the AerePasskeyAccountFactoryV2Fixed / Universal-Login
|
||||||
|
// stack) — fast, biometric, phishing-resistant, but CLASSICALLY secure; and
|
||||||
|
// 2. a NIST Falcon-512 CO-OWNER as the quantum-durable root (an AerePQCAccount,
|
||||||
|
// verified end-to-end by the live on-chain AereFalcon512Verifier /
|
||||||
|
// AerePQCAttestation precompile).
|
||||||
|
//
|
||||||
|
// There is NO seed phrase anywhere. The passkey never leaves the device's
|
||||||
|
// authenticator; the Falcon private key is protected by one of the CustodyMode
|
||||||
|
// options below. See docs/PQC-WALLET-DESIGN.md for the full design + the custody
|
||||||
|
// recommendation flagged for the founder.
|
||||||
|
|
||||||
|
import type { SchemeId } from '../pqc/index.js';
|
||||||
|
|
||||||
|
/** Envelope format version for descriptors + encrypted-key blobs. */
|
||||||
|
export const WALLET_DESCRIPTOR_VERSION = 1 as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Falcon-512 quantum-durable ROOT private key is custodied.
|
||||||
|
*
|
||||||
|
* - 'client-encrypted' — the Falcon secret key is generated on the client and
|
||||||
|
* stored ONLY as an AES-256-GCM ciphertext, unlocked by a user passphrase via
|
||||||
|
* PBKDF2. Portable across devices, recoverable from the encrypted blob alone,
|
||||||
|
* never touches a server in the clear. This is the RECOMMENDED default (see the
|
||||||
|
* design doc): it makes the quantum-lock co-owner portable without a seed.
|
||||||
|
*
|
||||||
|
* - 'secure-enclave' — the Falcon key material is sealed to a device secure
|
||||||
|
* enclave / TPM / StrongBox and never leaves it. Strongest exfiltration
|
||||||
|
* resistance, but NOT portable and NOT recoverable if the device is lost, so it
|
||||||
|
* must be paired with the passkey daily factor + a recovery path.
|
||||||
|
*
|
||||||
|
* - 'passkey-wrapped' — the Falcon key is wrapped by a symmetric key derived from
|
||||||
|
* a PRF/largeBlob extension of the passkey authenticator, so "unlock the Falcon
|
||||||
|
* key" == "present the passkey". One factor, best UX, but it collapses the
|
||||||
|
* quantum-durable root's independence into the passkey's availability.
|
||||||
|
*/
|
||||||
|
export type CustodyMode = 'client-encrypted' | 'secure-enclave' | 'passkey-wrapped';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The everyday / daily authority: a WebAuthn passkey behind a Universal-Login
|
||||||
|
* smart account. In production the on-device credential is a P-256 passkey; the
|
||||||
|
* on-chain owner word this maps to is the secp256k1/passkey owner the
|
||||||
|
* AerePasskeyAccount / Universal-Login account routes to. This descriptor keeps
|
||||||
|
* only PUBLIC material — no private key, no seed.
|
||||||
|
*/
|
||||||
|
export interface PasskeyDailyFactor {
|
||||||
|
type: 'passkey';
|
||||||
|
/** WebAuthn credential id (base64url), if known. Public, non-secret. */
|
||||||
|
credentialId?: string;
|
||||||
|
/**
|
||||||
|
* The on-chain owner authority address this passkey maps to (the hot
|
||||||
|
* secp256k1 / passkey owner word the Universal-Login account authorizes with).
|
||||||
|
*/
|
||||||
|
authorityAddress: string;
|
||||||
|
/** Human label for the device / passkey (e.g. "iPhone Face ID"). */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A generated Falcon-512 keypair (raw NIST bytes). Secret key is sensitive. */
|
||||||
|
export interface FalconKeypair {
|
||||||
|
scheme: SchemeId; // always SCHEME.FALCON512 for the wallet root today
|
||||||
|
publicKey: Uint8Array; // 897-byte NIST Falcon-512 pubkey (0x09 header)
|
||||||
|
secretKey: Uint8Array; // raw NIST secret key — keep private
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client-side-encrypted Falcon secret key. Fully serializable (all hex /
|
||||||
|
* primitive fields), safe to persist or sync: it reveals nothing without the
|
||||||
|
* passphrase. Decryptable ONLY by {@link decryptFalconSecretKey}.
|
||||||
|
*/
|
||||||
|
export interface EncryptedFalconKey {
|
||||||
|
version: typeof WALLET_DESCRIPTOR_VERSION;
|
||||||
|
scheme: SchemeId;
|
||||||
|
/** 0x-hex of the 897-byte Falcon-512 public key (kept in clear; it is public). */
|
||||||
|
publicKey: string;
|
||||||
|
/** Key-derivation function; only PBKDF2-SHA256 today. */
|
||||||
|
kdf: 'PBKDF2-SHA256';
|
||||||
|
/** 0x-hex PBKDF2 salt. */
|
||||||
|
kdfSalt: string;
|
||||||
|
/** PBKDF2 iteration count. */
|
||||||
|
kdfIterations: number;
|
||||||
|
/** AEAD cipher; only AES-256-GCM today. */
|
||||||
|
cipher: 'AES-256-GCM';
|
||||||
|
/** 0x-hex 12-byte GCM IV / nonce. */
|
||||||
|
iv: string;
|
||||||
|
/** 0x-hex ciphertext (includes the GCM auth tag as produced by WebCrypto). */
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Falcon-512 quantum-durable ROOT of an upgraded account: the public key,
|
||||||
|
* the counterfactual AerePQCAccount address it owns, its custody mode, and
|
||||||
|
* (optionally) the encrypted secret-key blob when custody is 'client-encrypted'.
|
||||||
|
*/
|
||||||
|
export interface FalconRoot {
|
||||||
|
scheme: SchemeId;
|
||||||
|
/** 0x-hex of the 897-byte Falcon-512 public key. */
|
||||||
|
publicKey: string;
|
||||||
|
/** CREATE2 counterfactual AerePQCAccount address owned solely by this key. */
|
||||||
|
pqcAccount: string;
|
||||||
|
/** Salt used in the AerePQCAccountFactory CREATE2 derivation. */
|
||||||
|
salt: string; // uint256 as decimal string
|
||||||
|
custody: CustodyMode;
|
||||||
|
/** Present iff custody === 'client-encrypted'. */
|
||||||
|
encryptedKey?: EncryptedFalconKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-chain addresses the wallet binds to (chain 2800 defaults filled in). */
|
||||||
|
export interface WalletAddresses {
|
||||||
|
entryPoint: string;
|
||||||
|
passkeyFactory: string;
|
||||||
|
pqcAccountFactory: string;
|
||||||
|
falconVerifier: string;
|
||||||
|
pqcAttestation: string;
|
||||||
|
/** keccak256 of the AerePQCAccount creation bytecode (factory constant). */
|
||||||
|
pqcAccountInitCodeHash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single seedless account descriptor. Before "upgrade to post-quantum" it
|
||||||
|
* carries only the passkey daily factor (`root` undefined); after the one-tap
|
||||||
|
* upgrade it also carries the Falcon quantum-durable root. Entirely PUBLIC +
|
||||||
|
* serializable except for `root.encryptedKey`, which is a ciphertext.
|
||||||
|
*/
|
||||||
|
export interface SeedlessAccountDescriptor {
|
||||||
|
version: typeof WALLET_DESCRIPTOR_VERSION;
|
||||||
|
chainId: number;
|
||||||
|
/** Everyday authority — the passkey. Always present. */
|
||||||
|
daily: PasskeyDailyFactor;
|
||||||
|
/** Quantum-durable root — the Falcon-512 co-owner. Present once upgraded. */
|
||||||
|
root?: FalconRoot;
|
||||||
|
addresses: WalletAddresses;
|
||||||
|
/** True once upgradeToPostQuantum has fused a Falcon root. */
|
||||||
|
postQuantum: boolean;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user