diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c89a0976..24b17713 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,60 +106,6 @@ jobs: - name: Generated files are current run: python3 scripts/regen-identity-handles.py --check - # --------------------------------------------------------------------------- - # Verifiers: the committed bb-generated Solidity verifiers reproduce from - # the pinned libid-circuits release. This replaces the reproducibility gate - # that died with the monorepo split — there, the deploy workflow - # recompiled the circuit next to the contracts; here, the circuits live in - # libid-circuits and this job re-derives both verifiers from that repo's - # released verification keys (verifier ⟵ vk alone, no nargo needed) and - # byte-compares against the committed sources. A mismatch means the - # deployed verifier no longer corresponds to the circuit the release - # ships — the web prover and the on-chain verifier must roll together. - # - # solidity/circuits-version pins the release tag. Until that release is - # actually cut on libid-org/libid-circuits, the download step fails with an - # explicit message — that is intended, not flaky. - # - # The download/verify/regenerate/compare chain is scripts/regen-verifiers.sh - # (--check here); the "Regenerate verifiers" dispatch workflow runs the same - # script with --write to refresh the committed files after a pin bump. - # - # publish-crates hangs its `needs:` off this job so nothing publishes over a - # drifted verifier; publish-npm inherits the gate through publish-crates. - # --------------------------------------------------------------------------- - verifiers: - name: Verifiers match circuits release - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # Keyed on the pin file, not the manifest's bb version — the manifest is - # only known after the download, which now happens inside the script. A - # pin bump starts a fresh cache; the script reuses a cached bb when its - # version matches the new manifest and installs via bbup otherwise. - - name: Cache barretenberg - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bb - key: bb-${{ runner.os }}-circuits-${{ hashFiles('solidity/circuits-version') }} - - # Same forge as everywhere else in this workflow, binary only — fmt - # reads foundry.toml but compiles nothing. - - uses: foundry-rs/foundry-toolchain@908c540300062bd5a7e473851cdb4282204cee09 # v1.9.1 - with: - version: ${{ env.FOUNDRY_VERSION }} - - # The whole chain — download the pinned release, verify sha256s, install - # bb at the manifest's version, regenerate with post-processing, - # byte-compare — lives in the script, shared with the "Regenerate - # verifiers" dispatch workflow so the check and the writer cannot drift. - - name: Regenerate verifiers and compare - env: - GH_TOKEN: ${{ github.token }} - run: ./scripts/regen-verifiers.sh --check - # --------------------------------------------------------------------------- # Rust: vendored artifacts, formatting, clippy, tests. Needs forge twice # over — the vendored artifacts are generated from a fresh `forge build`, @@ -454,7 +400,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 if: github.event_name == 'release' && github.event.action == 'published' - needs: [verify-tag, versions, solidity, rust, publish-check, verifiers] + needs: [verify-tag, versions, solidity, rust, publish-check] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/regen-verifiers.yml b/.github/workflows/regen-verifiers.yml deleted file mode 100644 index 03eb8352..00000000 --- a/.github/workflows/regen-verifiers.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Regenerate verifiers - -# Manually dispatched companion to CI's `verifiers` check. After bumping -# solidity/circuits-version on a branch, run this action on that branch (the -# UI's branch selector picks the ref): it runs scripts/regen-verifiers.sh -# --write to regenerate both committed verifiers from the newly pinned -# libid-circuits release, rebuilds the contracts, re-vendors the Rust -# artifacts (they embed the verifier bytecode, so a vk change changes them — -# the ts ABIs are interface-only and unaffected), and pushes the result back -# to the same branch as one signed-off bot commit. If nothing changed, it -# says so and exits green. -# -# Every third-party action is pinned by commit SHA, with the tag in a -# comment, so a moved tag cannot change what executes. - -on: - workflow_dispatch: - -# One writer per branch: two dispatches against the same ref queue instead of -# racing each other's push. -concurrency: - group: regen-verifiers-${{ github.ref }} - cancel-in-progress: false - -permissions: - contents: write - -env: - # Keep in sync with FOUNDRY_VERSION in ci.yml — the regenerated files must - # format and build under the same forge the CI check uses. - FOUNDRY_VERSION: v1.7.1 - -jobs: - regen: - name: Regenerate verifiers and push - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - # persist-credentials so the push step below can use the workflow token; - # full history so the branch push is a plain fast-forward regardless of - # where the ref sits. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: true - submodules: recursive - - # Same key scheme as ci.yml's verifiers job: keyed on the pin file, and - # the script reuses a cached bb only when its version matches the - # manifest. - - name: Cache barretenberg - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 - with: - path: ~/.bb - key: bb-${{ runner.os }}-circuits-${{ hashFiles('solidity/circuits-version') }} - - # Installs forge (and warms the shared artifact cache) via the same - # composite the CI jobs use; the post-regeneration build below then only - # recompiles what the new verifiers touched. - - name: Build contracts - uses: ./.github/actions/forge-build - with: - foundry-version: ${{ env.FOUNDRY_VERSION }} - - - name: Regenerate verifiers from the pinned circuits release - env: - GH_TOKEN: ${{ github.token }} - run: ./scripts/regen-verifiers.sh --write - - - name: Commit and push if anything changed - run: | - set -euo pipefail - if [ -z "$(git status --porcelain)" ]; then - echo "::notice::Verifiers already match the pinned circuits release; nothing to commit." - exit 0 - fi - tag="$(cat solidity/circuits-version)" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git status --short - # The Signed-off-by trailer is what the DCO job checks for. - git commit \ - -m "chore: regenerate ZK verifiers for $tag" \ - -m "Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>" - git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/.gitignore b/.gitignore index e924c361..356143ca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ solidity/broadcast/ ts/node_modules/ ts/packages/*/node_modules/ ts/packages/*/dist/ -solidity/.circuits-check/ -circuits-release/ # Generated from `forge build`, never committed. Both are packaged into their # published artefact (the crate embeds artifacts/ via include_dir!, the npm diff --git a/README.md b/README.md index 7d221d92..71440084 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,28 @@ # libID contracts Smart contracts for libID, laid out per chain. `solidity/` is a self-contained -Foundry project holding the EVM contracts (login wallets, transfer bank -diamond, identity naming); room is reserved for `solana/` and other networks, -and `rust/` and `ts/` ABI wrapper packages are coming. +Foundry project holding the EVM contracts: the ceremony verification path +(Notary Service, Proof Verifier, Platform Verifiers, and the Google JWT root +list the Google verifier reads), the identity naming system, and the +deterministic deployment factory. `rust/` and `ts/` hold the ABI wrapper packages; room is reserved +for `solana/` and other networks. ## Layout ``` solidity/ # Foundry project root contracts/ - login/ # registry, web wallets, OIDC + ZK session verifiers - transfer/ # bank diamond and facets - identity/ # platform handle naming and identity verifiers + ceremony/ # NotaryService, CeremonyProofVerifier, Platform Verifiers, + # GoogleJwtRoots + identity/ # IdentityNames, handle normalization + factory/ # LibidFactory: deterministic CREATE3 deployment WTIA9.sol # wrapped TIA script/Deploy.s.sol lib/ # git submodules (openzeppelin, forge-std) +rust/contracts/ # libid-contracts crate: alloy bindings + embedded artifacts +ts/packages/contracts/ # @libid/contracts: viem ABIs, call builders, identity helpers scripts/ + vendor-artifacts.sh regen-identity-handles.py ``` @@ -45,10 +51,6 @@ CI runs both in every job that compiles the crate or the package, and again in the publish jobs — the published crate and npm package carry the generated output even though git does not. -Some login OIDC flow tests read locally generated proof artifacts from -`circuits/jwt_email/target/`; when those files are absent the tests skip -themselves — that is expected. - ## Handle vectors `solidity/contracts/identity/handles.json` is the source of truth for platform @@ -59,8 +61,10 @@ python3 scripts/regen-identity-handles.py # rewrite generated outputs python3 scripts/regen-identity-handles.py --check # verify nothing drifted ``` -Today this generates `solidity/contracts/identity/HandleVectors.sol`; the Rust -and TypeScript outputs activate once those packages exist in this repo. +This generates `solidity/contracts/identity/HandleVectors.sol`, +`rust/identity/src/handle_vectors.rs` and +`ts/packages/contracts/src/identity/handleVectors.ts`; CI's handle-tables job +fails when any of them drifts from `handles.json`. ## Releasing @@ -91,10 +95,6 @@ Publishing the release triggers CI's release jobs: provenance. A prerelease publishes under its first prerelease identifier as the dist-tag (`1.2.0-rc.1` → `rc`); a plain version under `latest`. -To pick up a new circuits release: bump `solidity/circuits-version` on a -branch, run the "Regenerate verifiers" action on that branch, then review the -bot commit and merge — CI's verifiers job re-proves the regeneration. - ## License Dual-licensed under MIT and Apache-2.0; see `LICENSE-MIT`, `LICENSE-APACHE` diff --git a/rust/contracts/Cargo.toml b/rust/contracts/Cargo.toml index f9c4e712..84b1322d 100644 --- a/rust/contracts/Cargo.toml +++ b/rust/contracts/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" rust-version = "1.94.1" license = "MIT OR Apache-2.0" repository = "https://github.com/libid-org/libid-contracts" -description = "Typed alloy bindings, embedded forge artifacts, and deploy/upgrade helpers for the libid contract stack (login, transfer bank diamond, identity names)." +description = "Typed alloy bindings, embedded forge artifacts, and deploy/upgrade helpers for the libid identity stack (Notary Service, ceremony proof verifier, Google JWT roots, identity names, deterministic factory)." keywords = ["ethereum", "alloy", "contracts", "deployment"] categories = ["cryptography::cryptocurrencies"] # The vendored artifacts are part of the crate: consumers deploy from the diff --git a/rust/contracts/README.md b/rust/contracts/README.md index 203dc0a1..895c1891 100644 --- a/rust/contracts/README.md +++ b/rust/contracts/README.md @@ -1,9 +1,11 @@ # libid-contracts Typed [alloy](https://github.com/alloy-rs/alloy) bindings, embedded forge -artifacts, and deploy/upgrade helpers for the libid contract stack: the login -contracts (`Registry`, `WebWallet`, `WalletFactory`, verifiers), the Bank -EIP-2535 diamond, and the identity-names contracts. +artifacts, and deploy/upgrade helpers for the libid identity stack: the +ceremony verification path (`NotaryService`, `CeremonyProofVerifier`, and +`GoogleJwtRoots`, the signing keys the `google/v1` verifier trusts), the +naming system (`IdentityNames`), and the deterministic deployment factory +(`LibidFactory`). The compiled artifacts are vendored into the crate, so a consumer can deploy or upgrade the whole stack against a live network with **zero filesystem @@ -11,17 +13,17 @@ dependencies at runtime**. They are generated, not committed: `scripts/vendor-artifacts.sh` produces `artifacts/` from `solidity/` and CI runs it before every build, test and publish. Working in this repo, run it once after cloning — the crate embeds the directory with `include_dir!`, so -until it exists `cargo build` fails at macro expansion. Signing stays on the consumer's -side: every helper is generic over an alloy `Provider` you have already wired -with a wallet. +until it exists `cargo build` fails at macro expansion. Signing stays on the +consumer's side: every helper is generic over an alloy `Provider` you have +already wired with a wallet. -## Example: deploy the login stack +## Example: deploy the Notary Service and the Google JWT root list ```rust,no_run -use alloy::{primitives::Address, providers::ProviderBuilder}; +use alloy::{primitives::{Address, U256}, providers::ProviderBuilder}; use libid_contracts::{ - bindings::login::{IRegistryAdmin, Registry, WalletFactory}, - deploy::{deploy_behind_proxy, deploy_contract}, + bindings::ceremony::{GoogleJwtRoots, NotaryService}, + deploy::deploy_behind_proxy, Artifacts, }; @@ -32,69 +34,64 @@ async fn main() -> Result<(), Box> { .wallet(/* your signer */ todo!()) .connect_http("https://rpc.example.org".parse()?); let artifacts = Artifacts::embedded(); - let (owner, notary, backend): (Address, Address, Address) = todo!(); + let (owner, notary_key): (Address, Address) = todo!(); - // WebWallet implementation, then WalletFactory + Registry behind - // ERC1967 proxies. - let wallet_impl = deploy_contract( - &provider, - artifacts.bytecode("WebWallet")?, - "WebWallet (impl)", - ) - .await?; - let factory = deploy_behind_proxy( + // The Notary Service first: every notarized session is verified through + // it, and it charges the Notary Fee for doing so. + let notary = deploy_behind_proxy( &provider, &artifacts, - "WalletFactory", - &WalletFactory::initializeCall { + "NotaryService", + &NotaryService::initializeCall { owner_: owner, - walletImpl_: wallet_impl, - registry_: Address::ZERO, + notary_: notary_key, + fee_: U256::from(1_000), }, None, ) .await?; - let registry = deploy_behind_proxy( + + // The Google JWT root list pays that fee on every rotation. It starts empty: + // a keeper submits a notarized reading of Google's JWKS to seed it. + let roots = deploy_behind_proxy( &provider, &artifacts, - "Registry", - &IRegistryAdmin::initializeCall { - _notary: notary, - _backend: backend, - _walletFactory: factory, - _owner: owner, + "GoogleJwtRoots", + &GoogleJwtRoots::initializeCall { + owner_: owner, + notary_: notary, }, None, ) .await?; - WalletFactory::new(factory, &provider) - .setRegistry(registry) - .send() - .await? - .get_receipt() - .await?; - println!("registry {registry:#x}, resolves via {:#x}", registry); + let fee = GoogleJwtRoots::new(roots, &provider) + .quoteRotation() + .call() + .await?; + println!("roots {roots:#x} verify through {notary:#x}; a rotation costs {fee} wei"); Ok(()) } ``` Other entry points: -- `diamond::deploy_bank_diamond` / `diamond::replace_bank_facets` — the Bank - EIP-2535 deploy and facet upgrade. -- `deploy::load_linked_bytecode` (or `Artifacts::linked_bytecode`) — deploys - and links external libraries (`ZKTranscriptLib` for the generated UltraHonk - verifiers) before returning the creation bytecode. - `deploy::upgrade_uups` — deploy a fresh implementation and `upgradeToAndCall` a UUPS proxy onto it. -- `Artifacts::facet_selectors` / `Artifacts::method_identifiers` — selector - extraction from the vendored `methodIdentifiers`. +- `factory::ensure_factory` / `factory::factory_deploy` — install the + canonical cross-network factory where missing and deploy protocol proxies + through it at name-derived CREATE3 addresses. +- `deploy::load_linked_bytecode` (or `Artifacts::linked_bytecode`) — deploys + and links external libraries before returning the creation bytecode. Nothing + covered today links one; the UltraHonk verifiers the ceremony circuits bring + will. +- `Artifacts::method_identifiers` — selector extraction from the vendored + `methodIdentifiers`. ## Testing Unit tests run everywhere; the integration tests in `tests/anvil.rs` spawn -`anvil` (foundry) and deploy every stack for real. From `rust/`: +`anvil` (foundry) and deploy the stack for real. From `rust/`: ```sh cargo +nightly fmt --all # nightly only — stable rustfmt mangles imports diff --git a/rust/contracts/src/artifacts.rs b/rust/contracts/src/artifacts.rs index 0a2fde19..d07ea8b3 100644 --- a/rust/contracts/src/artifacts.rs +++ b/rust/contracts/src/artifacts.rs @@ -14,10 +14,7 @@ use std::{ use alloy::{ hex, - primitives::{ - Bytes, - FixedBytes, - }, + primitives::Bytes, providers::Provider, }; use include_dir::{ @@ -34,38 +31,19 @@ use crate::error::{ static EMBEDDED: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/artifacts"); /// Every deployable contract the crate covers, as `(file, contract)` — the -/// artifact lives at `.sol/.json`. Interfaces (`IBank`) are -/// vendored for their `methodIdentifiers` but carry no bytecode, so they are -/// not listed here. Keep in sync with `scripts/vendor-artifacts.sh`. +/// artifact lives at `.sol/.json`. Keep in sync with +/// `scripts/vendor-artifacts.sh`. pub const COVERED: &[(&str, &str)] = &[ - // login - ("Registry", "Registry"), - ("Notary", "Notary"), - ("WalletFactory", "WalletFactory"), - ("WebWallet", "WebWallet"), + // ceremony + ("NotaryService", "NotaryService"), + ("CeremonyProofVerifier", "CeremonyProofVerifier"), ("ERC1967Proxy", "ERC1967Proxy"), - ("XZkVerifier", "XZkVerifier"), - ("XHonkVerifier", "XHonkVerifier"), - // oidc — the Google OIDC circuit verifier is generated into `Verifier.sol` - // under the contract name `HonkVerifier` - ("Verifier", "HonkVerifier"), - ("GoogleOidcVerifier", "GoogleOidcVerifier"), - // transfer (bank diamond) - ("Diamond", "Diamond"), - ("DiamondCutFacet", "DiamondCutFacet"), - ("DiamondLoupeFacet", "DiamondLoupeFacet"), - ("OwnershipFacet", "OwnershipFacet"), - ("AdminFacet", "AdminFacet"), - ("VaultFacet", "VaultFacet"), - ("TransferFacet", "TransferFacet"), - ("BankInit", "BankInit"), - ("MockERC20", "MockERC20"), - ("WTIA9", "WTIA9"), + ("GoogleJwtRoots", "GoogleJwtRoots"), // identity ("IdentityNames", "IdentityNames"), - ("IdentityJwksRoots", "IdentityJwksRoots"), // factory ("LibidFactory", "LibidFactory"), + ("WTIA9", "WTIA9"), ]; enum Source { @@ -126,7 +104,7 @@ impl Artifacts { } /// Creation bytecode where the source file and contract names differ - /// (`Verifier.sol` → `HonkVerifier`). + /// (a bb-generated `Verifier.sol` holding `HonkVerifier`, say). pub fn bytecode_named(&self, file: &str, contract: &str) -> Result { let hex_str = self.bytecode_hex(file, contract)?; if hex_str.contains("__$") { @@ -145,9 +123,11 @@ impl Artifacts { /// Creation bytecode with every external library it references deployed /// (recursively) through `provider` and linked in. Mirrors what forge does - /// automatically: the generated UltraHonk verifiers link `ZKTranscriptLib`. - /// For artifacts with no link references this behaves like - /// [`Self::bytecode_named`] (no transaction is sent). + /// automatically. Nothing covered today links a library; the UltraHonk + /// verifiers the ceremony circuits bring link `ZKTranscriptLib`, and this + /// is the path they will deploy through. For artifacts with no link + /// references this behaves like [`Self::bytecode_named`] (no transaction + /// is sent). /// /// `sender` opts into explicit nonce management (see /// [`deploy_contract_from`](crate::deploy::deploy_contract_from)). @@ -184,27 +164,6 @@ impl Artifacts { .collect() } - /// All external function selectors of a facet, decoded from its - /// `methodIdentifiers`. Mirrors `BankDiamondDeployer._selectors`. - pub fn facet_selectors(&self, contract: &str) -> Result>> { - let methods = self.method_identifiers(contract)?; - let mut selectors = Vec::with_capacity(methods.len()); - for (sig, hex_sel) in &methods { - let bytes = hex::decode(hex_sel).map_err(|e| Error::Artifact { - detail: format!("invalid selector hex for {sig}: {e}"), - })?; - let arr: [u8; 4] = - bytes.as_slice().try_into().map_err(|_| Error::Artifact { - detail: format!( - "selector for {sig} is {} bytes, expected 4", - bytes.len() - ), - })?; - selectors.push(FixedBytes::<4>::from(arr)); - } - Ok(selectors) - } - /// The raw `bytecode.object` hex (no `0x`), link placeholders intact. pub(crate) fn bytecode_hex(&self, file: &str, contract: &str) -> Result { let json = self.raw(file, contract)?; @@ -241,8 +200,8 @@ mod tests { use super::*; /// Every covered contract's creation bytecode is present and non-empty. - /// The Honk verifiers carry link placeholders, so only the hex is checked - /// here; linking is exercised by the anvil tests. + /// Only the hex is checked here so a future artifact with link + /// placeholders still passes; linking is the anvil tests' business. #[test] fn every_covered_contract_has_bytecode() { let artifacts = Artifacts::embedded(); @@ -257,7 +216,9 @@ mod tests { } } - /// Contracts without link references decode straight to bytes. + /// Contracts without link references decode straight to bytes — which is + /// every covered contract today, so this doubles as the check that none of + /// them silently grew a library dependency the vendor script must follow. #[test] fn unlinked_contracts_decode() { let artifacts = Artifacts::embedded(); @@ -274,55 +235,4 @@ mod tests { } } } - - /// The Honk verifiers link `ZKTranscriptLib`, and the library artifact - /// they resolve against is vendored alongside them. - #[test] - fn honk_verifiers_link_the_transcript_lib() { - let artifacts = Artifacts::embedded(); - for (file, contract) in [ - ("XHonkVerifier", "XHonkVerifier"), - ("Verifier", "HonkVerifier"), - ] { - let refs = artifacts.link_references(file, contract).unwrap(); - assert!(!refs.is_empty(), "{contract} should carry link references"); - for (lib_path, libs) in &refs { - let stem = std::path::Path::new(lib_path) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap(); - for lib_name in libs.as_object().unwrap().keys() { - let lib_hex = artifacts.bytecode_hex(stem, lib_name).unwrap(); - assert!(!lib_hex.is_empty(), "{lib_name} library missing"); - } - } - } - } - - /// Facet selector extraction: every Bank facet exposes selectors, they are - /// 4 bytes each, and none repeats across facets (a diamondCut would revert - /// on a duplicate). - #[test] - fn facet_selectors_extract_and_are_disjoint() { - let artifacts = Artifacts::embedded(); - let mut all = std::collections::BTreeSet::new(); - for facet in [ - "DiamondCutFacet", - "DiamondLoupeFacet", - "OwnershipFacet", - "AdminFacet", - "VaultFacet", - "TransferFacet", - ] { - let selectors = artifacts.facet_selectors(facet).unwrap(); - assert!(!selectors.is_empty(), "{facet} has no selectors"); - for sel in selectors { - assert!(all.insert(sel), "duplicate selector {sel} in {facet}"); - } - } - // diamondCut(FacetCut[],address,bytes) — the one selector the Diamond - // constructor wires in itself. - let cut = artifacts.method_identifiers("DiamondCutFacet").unwrap(); - assert!(cut.keys().any(|sig| sig.starts_with("diamondCut("))); - } } diff --git a/rust/contracts/src/bindings/ceremony.rs b/rust/contracts/src/bindings/ceremony.rs new file mode 100644 index 00000000..3ba15f4e --- /dev/null +++ b/rust/contracts/src/bindings/ceremony.rs @@ -0,0 +1,173 @@ +//! Bindings for the ceremony verification path (`solidity/contracts/ceremony/`): +//! the Notary Service every notarized session is authenticated through, the +//! Proof Verifier that routes a claim to the Platform Verifier registered for +//! its version, and the Google JWT root list the `google/v1` verifier reads. + +/// Bindings for `ceremony/NotaryService.sol` (which implements +/// `INotaryService`). +/// +/// Authenticates one attestation and charges one fee for it. The digest is +/// derived from the attested bytes on chain, never taken from the caller +/// (REQ-COMMON-49), which is why `verify` is not on this interface: a +/// consumer contract calls it with the fee attached, and the decoded record +/// comes back to that contract, not to an off-chain reader. What an operator +/// does from here is hold the trusted key set, set the fee and withdraw what +/// accrued. +#[allow(clippy::too_many_arguments, unused_attributes)] +mod notary_service_inner { + use alloy::sol; + + sol! { + #[sol(rpc)] + interface NotaryService { + /// `notary_` is the first trusted key; `fee_` may be zero (a + /// deployment may meter at no charge, and the exact-value rule + /// still applies). + function initialize(address owner_, address notary_, uint256 fee_) external; + /// What one verification costs, in the chain's native asset. + /// Readable before a submission is built, so it can be bounded. + function fee() external view returns (uint256); + function setFee(uint256 fee_) external; + /// Add or remove a trusted notary key. Several are held at once so + /// a rotation can overlap: add the incoming key, remove the + /// outgoing one once nothing can still present under it. + function setNotary(address key, bool trusted_) external; + function isTrustedNotary(address key) external view returns (bool); + /// Fees accrue here rather than being forwarded per verification. + function withdraw(address to, uint256 amount) external; + + function owner() external view returns (address); + function pendingOwner() external view returns (address); + function transferOwnership(address newOwner) external; + function acceptOwnership() external; + + event FeeChanged(uint256 previousFee, uint256 newFee); + event NotaryTrustChanged(address indexed key, bool trusted); + event FeesWithdrawn(address indexed to, uint256 amount); + } + } +} + +pub use notary_service_inner::NotaryService; + +/// Bindings for `ceremony/CeremonyProofVerifier.sol` (which implements +/// `IProofVerifier`). +/// +/// The Supported Version Set: which Platform Verifier answers for a +/// `(platformId, verifierVersion)` pair. Governance registers one with +/// `setVerifier`; `IdentityNames.claim` dispatches through `verify`, which is +/// not on this interface for the same reason `NotaryService.verify` is not — +/// it is called by the consumer contract with the fee attached. +#[allow(clippy::too_many_arguments, unused_attributes)] +mod proof_verifier_inner { + use alloy::sol; + + sol! { + #[sol(rpc)] + interface CeremonyProofVerifier { + function initialize(address owner_) external; + /// Register (or, with the zero address, remove) the Platform + /// Verifier for a pair. The verifier must serve `platformId`. + function setVerifier(bytes32 platformId, uint16 verifierVersion, address verifier) external; + /// The Platform Verifier registered for a pair, or zero. + function verifierOf(bytes32 platformId, uint16 verifierVersion) external view returns (address); + /// What one claim under this pair costs: the registered + /// verifier's quote, forwarded whole. + function quote(bytes32 platformId, uint16 verifierVersion) external view returns (uint256); + /// Whether any version is registered for the platform at all. + function verifiesPlatform(bytes32 platformId) external view returns (bool); + /// This chain's identifier, as the digest construction takes it. + function chainId() external view returns (bytes32); + + function owner() external view returns (address); + function pendingOwner() external view returns (address); + function transferOwnership(address newOwner) external; + function acceptOwnership() external; + + event VerifierConfigured(bytes32 indexed platformId, uint16 indexed verifierVersion, address verifier); + } + } +} + +pub use proof_verifier_inner::CeremonyProofVerifier; + +/// Bindings for `ceremony/GoogleJwtRoots.sol` — the signing keys the +/// `google/v1` Platform Verifier trusts, and until when. Starts EMPTY: +/// Google names bind only once a notarized reading of Google's JWKS has +/// landed here. +/// +/// The list is two generations of Google's key set and nothing else: +/// `current` is the latest reading applied, `previous` the reading before +/// it, kept for the tokens still in flight under a key Google has since +/// dropped. A newer reading of the same set restarts `current`'s clock +/// (`ReadingRefreshed`); a newer reading of a different set shifts `current` +/// into `previous` and drops what `previous` held (`KeysRotated`). A +/// generation is trusted until `READING_LIFETIME` after its reading's own +/// `createdAt`, so there is nothing to prune or untrust by hand. +/// +/// A rotation is an ordinary notarized session: the keeper reveals the whole +/// `GET /oauth2/v3/certs` exchange, `rotate` hands the attested bytes and the +/// notary's proof to the Notary Service with the Notary Fee attached (read +/// it with `quoteRotation`, which is the service's `fee()`), and the contract +/// reads the JWKS out of the transcript it vouched for. +#[allow(clippy::too_many_arguments, unused_attributes)] +mod google_jwt_roots_inner { + use alloy::sol; + + sol! { + #[sol(rpc)] + interface GoogleJwtRoots { + /// One reading of Google's key set: the notary's clock, and the + /// limb hash of every modulus it listed, in Google's order. + #[derive(Debug, serde::Serialize, serde::Deserialize)] + struct Generation { + uint64 observedAt; + bytes32[] moduli; + } + + function initialize(address owner_, address notary_) external; + /// The Notary Service a rotation is verified through. + function notaryService() external view returns (address); + function setNotaryService(address notary_) external; + /// What one rotation costs beyond gas: the Notary Fee, forwarded + /// whole. `rotate` must be sent with exactly this value. + function quoteRotation() external view returns (uint256); + /// Permissionless. `attestedData` is the ceremony-common section + /// 9.1 record of the JWKS session, `proof` the notary's + /// authentication of it (a 65-byte EIP-191 signature today). A + /// reading dated no later than the current generation is refused + /// with `NotNewer(createdAt, observedAt)`, and the revert hands + /// the fee back. + function rotate(bytes calldata attestedData, bytes calldata proof) external payable; + + /// What `GooglePlatformVerifier` reads: modulus hash -> when it + /// stops being trusted, zero when neither generation lists it. + function trustedHashExpiresAt(bytes32 modulusHash) external view returns (uint256); + /// Both generations, as stored. + function currentKeys() external view returns (Generation memory current, Generation memory previous); + /// The current generation's `observedAt`: the notary's clock on + /// the reading in force. + function freshestObservedAt() external view returns (uint256); + /// True until the current generation is guaranteed trusted + /// `RENEWAL_MARGIN` from now; true on an empty list. + function needsRotation() external view returns (bool); + function READING_LIFETIME() external view returns (uint256); + function RENEWAL_MARGIN() external view returns (uint256); + function MAX_KEYS() external view returns (uint256); + + function owner() external view returns (address); + function pendingOwner() external view returns (address); + function transferOwnership(address newOwner) external; + function acceptOwnership() external; + + event NotaryServiceChanged(address notary); + /// A different set landed: `kids` and `moduli` in the order + /// Google listed them, `observedAt` the notary's clock. + event KeysRotated(uint64 observedAt, string[] kids, bytes32[] moduli); + /// A newer reading of the set already current. + event ReadingRefreshed(uint64 observedAt); + } + } +} + +pub use google_jwt_roots_inner::GoogleJwtRoots; diff --git a/rust/contracts/src/bindings/identity.rs b/rust/contracts/src/bindings/identity.rs index 4fb50570..d669f847 100644 --- a/rust/contracts/src/bindings/identity.rs +++ b/rust/contracts/src/bindings/identity.rs @@ -1,6 +1,5 @@ //! Bindings for the identity-names stack (`solidity/contracts/identity/`): -//! `IdentityNames`, the per-platform verifiers, and the Google JWKS trust -//! list. +//! `IdentityNames`. /// Bindings for `identity/IdentityNames.sol`. /// @@ -84,63 +83,3 @@ mod names_inner { } pub use names_inner::IdentityNames; - -/// Bindings for `identity/IdentityJwksRoots.sol` — the naming system's own -/// Google JWKS trust list. Starts EMPTY: Google names bind only once a -/// notarized reading of Google's JWKS has landed here. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod jwks_roots_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface IdentityJwksRoots { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct NotarizedJwksProof { - bytes notarySignature; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] domainPath; - bytes32[] endpointPath; - } - - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct JwkClaim { - bytes jwkBytes; - bytes32[] jwkPath; - bytes kid; - bytes nB64url; - } - - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct RootInfo { - bytes32 kidHash; - bytes32 modulusHash; - uint256 observedAt; - uint256 expiresAt; - } - - function initialize(address owner_, address notaryContract_) external; - function untrustModulus(bytes32 modulusHash) external; - function rotate(NotarizedJwksProof calldata proof, JwkClaim[] calldata claims) external; - function prune() external; - function trustedHashExpiresAt(bytes32 modulusHash) external view returns (uint256); - function notaryContract() external view returns (address); - function notary() external view returns (address); - function currentRoots() external view returns (RootInfo[] memory); - function freshestObservedAt() external view returns (uint256); - function needsRotation() external view returns (bool); - - event ModulusRotated(bytes32 indexed kidHash, string kid, bytes32 modulusHash, uint256 expiresAt); - event ModulusUntrusted(bytes32 indexed modulusHash); - event RootApplied(bytes32 indexed kidHash, bytes32 indexed modulusHash, uint256 observedAt, uint256 expiresAt); - event RootPruned(bytes32 indexed kidHash, bytes32 modulusHash); - } - } -} - -pub use jwks_roots_inner::IdentityJwksRoots; diff --git a/rust/contracts/src/bindings/login.rs b/rust/contracts/src/bindings/login.rs deleted file mode 100644 index f667dc39..00000000 --- a/rust/contracts/src/bindings/login.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Bindings for the login stack: `Registry`, `WebWallet`, `WalletFactory`, -//! and the X ZK verifier. Mirrors `solidity/contracts/login/`. - -/// Bindings for `login/Registry.sol`. -/// -/// `register_session` has many parameters by design (matching the Solidity -/// contract interface), so the too_many_arguments lint is suppressed. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod registry_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface Registry { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct FullTlsProof { - bytes notarySignature; - bytes backendSignature; - address userAddress; - address walletAddress; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] domainPath; - bytes32[] usernamePath; - bytes32[] endpointPath; - bytes32[] idPath; - } - - function register_session( - FullTlsProof calldata proof, - string calldata domain, - string calldata username, - string calldata userId, - string calldata endpoint - ) external; - - /// OIDC variant — caller provides ABI-encoded `UserProof` bytes - /// understood by the configured `IOidcVerifier` for `platform`. - function register_session_oidc( - string calldata platform, - bytes calldata oidcProof - ) external; - - function register_session_zk( - string calldata platform, - bytes calldata zkProof - ) external; - - function link_identity_zk( - string calldata platform, - bytes calldata zkProof - ) external; - - function link_identity_oidc( - string calldata platform, - bytes calldata oidcProof, - address sessionAddr - ) external; - - function linkIdentity( - string calldata platform, - string calldata handle, - string calldata userId, - FullTlsProof calldata proof, - string calldata endpoint - ) external; - - function resolve(string calldata platform, string calldata handle) external view returns (address); - function resolveById(string calldata platform, string calldata id) external view returns (address); - function handleHint(string calldata platform, string calldata handle) external view returns (string memory); - function getHandles(address wallet) external view returns (string[] memory platforms, string[] memory handles); - function getCurrentHandles(address wallet) external view returns (string[] memory platforms, string[] memory handles); - function getUserIds(address wallet) external view returns (string[] memory platforms, string[] memory userIds); - function getPlatform(string calldata domain) external view returns (string memory endpoint, string memory handlePrefix); - function zkVerifierOf(string calldata domain) external view returns (address); - function oidcVerifierOf(string calldata platform) external view returns (address); - /// The shared Notary contract the Registry routes attestation checks through. - function notaryContract() external view returns (address); - /// The current notary signer, read through the Notary contract. - function notary() external view returns (address); - function backend() external view returns (address); - function walletFactory() external view returns (address); - - event HandleRegistered(string platform, string handle, address indexed owner); - event SessionRegistered(string platform, string handle, address indexed wallet, address sessionAddr); - event IdentityLinked(string platform, string handle, address indexed wallet); - event HandleChanged(string platform, string userId, string handle); - } - } -} - -pub use registry_inner::Registry; - -/// Owner/deploy-time surface of the Registry: `initialize` (ABI-encoded into -/// the ERC1967 proxy init data) and configuration setters. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod registry_admin_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface IRegistryAdmin { - function initialize(address _notaryContract, address _backend, address _walletFactory, address _owner) external; - function setPlatform( - string calldata domain, - string calldata endpoint, - string calldata handlePrefix, - string calldata idPrefix, - string calldata idSuffix - ) external; - function removePlatform(string calldata domain) external; - function setZkVerifier(string calldata domain, address verifier) external; - function setOidcVerifier(string calldata platform, address verifier) external; - function setBackend(address _backend) external; - function pause() external; - function unpause() external; - } - } -} - -pub use registry_admin_inner::IRegistryAdmin; - -/// Bindings for `login/zk/XZkVerifier.sol`. -/// -/// The Registry calls this verifier through `IZkSessionVerifier`; a consumer -/// uses these bindings to ABI-encode `XProof` (the opaque payload it forwards -/// on `register_session_zk(platform, bytes)`) and to verify proofs via -/// `eth_call` in pre-flight. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod x_zk_verifier_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface XZkVerifier { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct TokenAttestation { - bytes32 bearerHash; - uint32 bearerRangeStart; - uint32 bearerRangeEnd; - bytes sentRevealed; - uint64 timestamp; - bytes notarySignature; - } - - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct MeAttestation { - bytes32 bearerHash; - uint32 bearerRangeStart; - uint32 bearerRangeEnd; - bytes sentRevealed; - uint32 sentPrefixEnd; - uint32 sentSuffixEnd; - bytes recvRevealed; - string handle; - string userId; - address sessionAddr; - uint64 timestamp; - bytes notarySignature; - } - - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct XProof { - bytes proof; - bytes32[] publicInputs; - TokenAttestation tokenAttest; - MeAttestation meAttest; - } - - function initialize( - address _owner, - address _notaryContract, - address _honkVerifier, - bytes calldata _xClientId, - string calldata _endpoint, - string calldata _handlePrefix, - string calldata _platformName - ) external; - - function verifyAndExtract(bytes calldata payload) - external view - returns ( - string memory handle, - address sessionKey, - address walletAddress, - uint256 expiresAt, - bytes32 nullifier, - string memory userId - ); - - function platformName() external view returns (string memory); - function notaryContract() external view returns (address); - function notary() external view returns (address); - - /// client_id allowlist. An EMPTY allowlist denies every client_id; - /// `openClientIds` is the explicit opt-in to accept any app. - function isClientIdAllowed(bytes32 clientIdHash) external view returns (bool); - function openClientIds() external view returns (bool); - function clientIdCount() external view returns (uint256); - function clientIdAt(uint256 index) external view returns (bytes memory); - - function addClientId(bytes calldata _id) external; - function removeClientId(bytes calldata _id) external; - function setOpenClientIds(bool _open) external; - - event ClientIdAdded(bytes clientId); - event ClientIdRemoved(bytes clientId); - event OpenClientIdsSet(bool open); - } - } -} - -pub use x_zk_verifier_inner::XZkVerifier; - -/// Bindings for `login/WebWallet.sol`. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod wallet_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface WebWallet { - function threshold() external view returns (uint8); - function nonce() external view returns (uint256); - function registry() external view returns (address); - function factory() external view returns (address); - function identityCount() external view returns (uint256); - function identityAt(uint256 index) external view returns (bytes32); - function isIdentity(bytes32 identityHash) external view returns (bool); - function sessionCount(bytes32 platformHandleKey) external view returns (uint256); - function sessionAt(bytes32 platformHandleKey, uint256 index) external view returns (address); - function isSession(address addr) external view returns (bool); - function sessionIdentity(address addr) external view returns (bytes32); - function execute( - address target, - uint256 value, - bytes calldata data, - bytes[] calldata sigs - ) external; - function executeBatch( - address[] calldata targets, - uint256[] calldata values, - bytes[] calldata datas, - bytes[] calldata sigs - ) external; - function upgradeToLatest() external; - function setThreshold(uint8 newThreshold) external; - - event SessionRegistered(string platform, string handle, address indexed sessionAddr); - event SessionRevoked(string platform, string handle, address indexed sessionAddr); - event Executed(address indexed target, uint256 value, uint256 nonce); - event NativeReceived(address indexed from, uint256 amount); - event NativeSent(address indexed to, uint256 amount); - } - } -} - -pub use wallet_inner::WebWallet; - -/// Bindings for `login/WalletFactory.sol`. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod factory_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface WalletFactory { - function initialize(address owner_, address walletImpl_, address registry_) external; - function setRegistry(address newRegistry) external; - function latestImplementation() external view returns (address); - function upgradeWalletImplementation(address newImplementation) external; - - event WalletCreated(address indexed wallet, bytes32 indexed firstIdentity); - event WalletImplementationUpgraded(address indexed newImplementation); - } - } -} - -pub use factory_inner::WalletFactory; - -/// Minimal ERC-20 surface (transfer event scanning + balances). -#[allow(clippy::too_many_arguments, unused_attributes)] -mod erc20_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface IERC20 { - function balanceOf(address account) external view returns (uint256); - function transfer(address to, uint256 value) external returns (bool); - function approve(address spender, uint256 value) external returns (bool); - function allowance(address owner, address spender) external view returns (uint256); - - event Transfer(address indexed from, address indexed to, uint256 value); - event Approval(address indexed owner, address indexed spender, uint256 value); - } - } -} - -pub use erc20_inner::IERC20; - -/// UltraHonk verifier interface — matches the `IHonkVerifier` the login and -/// identity verifiers call. Used off-chain in pre-flight to bounce bogus proof -/// bytes before paying gas. View call only; no state mutation. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod honk_verifier_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface IHonkVerifier { - function verify(bytes calldata proof, bytes32[] calldata publicInputs) external view returns (bool); - } - } -} - -pub use honk_verifier_inner::IHonkVerifier; diff --git a/rust/contracts/src/bindings/mod.rs b/rust/contracts/src/bindings/mod.rs index f7a82ba4..0de398fd 100644 --- a/rust/contracts/src/bindings/mod.rs +++ b/rust/contracts/src/bindings/mod.rs @@ -1,10 +1,7 @@ //! Hand-written `alloy::sol!` bindings, kept in lockstep with the Solidity -//! sources in `solidity/contracts`. Grouped by product area. +//! sources in `solidity/contracts`. One module per contract directory. +pub mod ceremony; pub mod factory; pub mod identity; -pub mod login; -pub mod notary; -pub mod oidc; pub mod proxy; -pub mod transfer; diff --git a/rust/contracts/src/bindings/notary.rs b/rust/contracts/src/bindings/notary.rs deleted file mode 100644 index d46dfb51..00000000 --- a/rust/contracts/src/bindings/notary.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Bindings for the shared notary-attestation verifier. Mirrors -//! `solidity/contracts/notary/`. - -/// Bindings for `notary/Notary.sol` (which implements `INotary`). -/// -/// Every other contract holds a pointer to this proxy and calls -/// `verify(digest, proof)`. V1 checks a secp256k1 signature over the -/// EIP-191-prefixed digest against a single stored signer; rotation is -/// `setNotary`, and a change of proof system is a UUPS upgrade of this proxy -/// alone. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod notary_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface Notary { - function initialize(address owner_, address notary_) external; - function setNotary(address notary_) external; - function notary() external view returns (address); - /// True when `proof` is a valid notary attestation of `digest`. - /// Answers false (never reverts) on a malformed proof. - function verify(bytes32 digest, bytes calldata proof) external view returns (bool); - - event NotaryChanged(address indexed previousNotary, address indexed newNotary); - } - } -} - -pub use notary_inner::Notary; diff --git a/rust/contracts/src/bindings/oidc.rs b/rust/contracts/src/bindings/oidc.rs deleted file mode 100644 index 14021f51..00000000 --- a/rust/contracts/src/bindings/oidc.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Bindings for the Google OIDC verifier -//! (`solidity/contracts/login/oidc/GoogleOidcVerifier.sol`). - -/// The surface a deployer and a JWKS rotation listener need. Keep the struct -/// fields in lockstep with the Solidity types. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface GoogleOidcVerifier { - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct NotarizedJwksProof { - bytes notarySignature; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] domainPath; - bytes32[] endpointPath; - } - - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct JwkClaim { - bytes jwkBytes; - bytes32[] jwkPath; - bytes kid; - bytes nB64url; - } - - /// `_verifier` is the deployed Honk circuit verifier - /// (`Verifier.sol:HonkVerifier`); `notaryContract_` is the shared - /// Notary contract; `initialAud` seeds the audience allowlist — - /// without it the verifier is fail-closed and rejects every proof. - function initialize( - address _verifier, - address _owner, - address notaryContract_, - string calldata initialAud - ) external; - - #[derive(Debug, serde::Serialize, serde::Deserialize)] - struct RootInfo { - bytes32 kidHash; - bytes32 modulusHash; - uint256 observedAt; - uint256 expiresAt; - } - - function setExpectedAudience(string calldata clientId) external; - function setExpectedAudienceHash(bytes32 audienceHash) external; - function setRegistry(address r) external; - function untrustModulus(bytes32 modulusHash) external; - - function modulusOfKid(bytes32 kidHash) external view returns (bytes32); - function expiresAtKid(bytes32 kidHash) external view returns (uint256); - function trustedHashExpiresAt(bytes32 modulusHash) external view returns (uint256); - function platformName() external view returns (string memory); - function notaryContract() external view returns (address); - function notary() external view returns (address); - function currentRoots() external view returns (RootInfo[] memory); - function freshestObservedAt() external view returns (uint256); - function needsRotation() external view returns (bool); - - function rotate(NotarizedJwksProof calldata proof, JwkClaim[] calldata claims) external; - function prune() external; - - event ModulusRotated(bytes32 indexed kidHash, string kid, bytes32 modulusHash, uint256 expiresAt); - event ModulusUntrusted(bytes32 indexed modulusHash); - event RootApplied(bytes32 indexed kidHash, bytes32 indexed modulusHash, uint256 observedAt, uint256 expiresAt); - event RootPruned(bytes32 indexed kidHash, bytes32 modulusHash); - event AudienceConfigured(bytes32 indexed audienceHash, string clientId); - } - } -} - -pub use inner::GoogleOidcVerifier; diff --git a/rust/contracts/src/bindings/proxy.rs b/rust/contracts/src/bindings/proxy.rs index 47541372..e1d31f2c 100644 --- a/rust/contracts/src/bindings/proxy.rs +++ b/rust/contracts/src/bindings/proxy.rs @@ -1,4 +1,4 @@ -//! Proxy plumbing shared by every stack: the ERC1967 proxy constructor and +//! Proxy plumbing shared by every proxied contract: the ERC1967 proxy constructor and //! the UUPS upgrade entrypoint. #[allow(clippy::too_many_arguments, unused_attributes)] diff --git a/rust/contracts/src/bindings/transfer.rs b/rust/contracts/src/bindings/transfer.rs deleted file mode 100644 index e4896ee3..00000000 --- a/rust/contracts/src/bindings/transfer.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! Bindings for the Bank diamond (`solidity/contracts/transfer/`): the full -//! `IBank` surface addressed through the diamond, plus the EIP-2535 cut/loupe -//! interfaces and the one-shot `BankInit`. - -/// The Bank diamond's external surface (Vault + Admin + Transfer facets), for -/// callers that address the diamond as one contract. Mirrors -/// `transfer/bank/IBank.sol` and the facet sources. -/// -/// `balanceOf` and `balanceOfTotal` are overloaded exactly as in Solidity; -/// alloy disambiguates the generated Rust methods as `balanceOf_0` -/// (address-keyed) and `balanceOf_1` (identity-keyed), in declaration order. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod bank_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface Bank { - struct NotaryTlsProof { - bytes notarySig; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] bodyMerklePath; - bytes32[] authorMerklePath; - bytes32[] domainMerklePath; - bytes32[] endpointMerklePath; - bytes32[] authorIdMerklePath; - bytes32[] receiverIdMerklePath; - bytes revealedReceiverId; - bytes32[] quotedRefMerklePath; - bytes revealedQuotedRef; - bytes32[] quotedAuthorMerklePath; - bytes revealedQuotedAuthorId; - } - - struct ResourceInfo { - string platform; // API domain (e.g. "api.x.com") - string resourceType; - string resourceId; - string requestPath; - } - - struct SenderInfo { - string author; - bytes revealedAuthor; - bytes revealedAuthorId; - } - - struct BackendSig { - bytes sig; - uint256 timestamp; - } - - // ── Transfer facet ─────────────────────────────────────────── - - /// Token-by-name overload. - function webTransferV2( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverHandle, - string calldata receiverUserId, - string calldata tokenName_, - string calldata amountStr, - uint256 amount, - string calldata sourceUrl - ) external; - - /// Token-by-address overload. - function webTransferV2( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverHandle, - string calldata receiverUserId, - address token, - string calldata amountStr, - uint256 amount, - string calldata sourceUrl - ) external; - - /// Stateless preflight of a TLS-notarized comment proof. - function verifyProof( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverUserId - ) external view returns (bool); - - function transfer_within( - string calldata receiverPlatform, - string calldata receiverHandle, - string calldata receiverUserId, - address token, - uint256 amount - ) external; - - // ── Vault facet ────────────────────────────────────────────── - - function deposit(string calldata platform, string calldata handle, string calldata userId, address token, uint256 amount) external payable; - function withdraw(address recipient, address token, uint256 amount) external; - function balanceOf(address wallet, address token) external view returns (uint256); - function balanceOf(string calldata platform, string calldata handle, string calldata userId, address token) external view returns (uint256); - function balanceOfTotal(address wallet, address token) external view returns (uint256); - function balanceOfTotal(string calldata platform, string calldata handle, string calldata userId, address token) external view returns (uint256); - function registeredBalances(address wallet, address token) external view returns (uint256); - function unregisteredBalances(bytes32 key, address token) external view returns (uint256); - function identityHash(string calldata domain, string calldata username) external pure returns (bytes32); - - // ── Admin facet ────────────────────────────────────────────── - - function pause() external; - function unpause() external; - function setRegistry(address _registry) external; - function registerToken(string calldata name, address token) external; - function unregisterToken(string calldata name) external; - function resolveToken(string calldata name) external view returns (address); - function getRegisteredTokens() external view returns (address[] memory); - function tokenName(address token) external view returns (string memory); - function setPlatformTemplate(string calldata platform, string calldata newTemplate) external; - function clearPlatformTemplates(string calldata platform) external; - function platformTemplateCount(string calldata platform) external view returns (uint256); - function getPlatformTemplate(string calldata platform, uint256 index) external view returns (string memory); - function setResourceTypePrefix(string calldata resourceType, string calldata prefix) external; - function setPlatformWebPrefix(string calldata platform, string calldata prefix) external; - function getPlatformWebPrefix(string calldata platform) external view returns (string memory); - function registry() external view returns (address); - function paused() external view returns (bool); - - // ── Events ─────────────────────────────────────────────────── - - event Deposited(address indexed wallet, bytes32 indexed handleKey, address indexed token, uint256 amount); - event Withdrawn(address indexed wallet, address indexed recipient, address indexed token, uint256 amount); - event TransferWithin(address indexed sender, string receiverPlatform, string receiverHandle, string receiverUserId, address indexed token, uint256 amount); - event WebTransferV3(address indexed senderWallet, address indexed receiverWallet, string receiverPlatform, string receiverHandle, string receiverUserId, address indexed token, uint256 amount, string sourceUrl); - event Paused(address account); - event Unpaused(address account); - event RegistrySet(address indexed oldRegistry, address indexed newRegistry); - event TokenRegistered(string name, address indexed token); - event TokenUnregistered(string name, address indexed token); - } - } -} - -pub use bank_inner::Bank; - -/// EIP-2535 diamond cut interface (`transfer/diamond/interfaces/IDiamondCut.sol`). -#[allow(clippy::too_many_arguments, unused_attributes)] -mod diamond_cut_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface IDiamondCut { - /// `action` is FacetCutAction as its ABI underlying type (0=Add, - /// 1=Replace, 2=Remove); enums encode as uint8, so this yields the - /// same `diamondCut` selector as the Solidity interface. - struct FacetCut { - address facetAddress; - uint8 action; - bytes4[] functionSelectors; - } - function diamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata) external; - - event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata); - } - } -} - -pub use diamond_cut_inner::IDiamondCut; - -/// EIP-2535 loupe + ERC-173 ownership -/// (`transfer/diamond/interfaces/IDiamondLoupe.sol`, `IERC173.sol`). -#[allow(clippy::too_many_arguments, unused_attributes)] -mod diamond_loupe_inner { - use alloy::sol; - - sol! { - #[sol(rpc)] - interface IDiamondLoupe { - struct Facet { - address facetAddress; - bytes4[] functionSelectors; - } - function facets() external view returns (Facet[] memory facets_); - function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory); - function facetAddresses() external view returns (address[] memory); - function facetAddress(bytes4 _functionSelector) external view returns (address); - function supportsInterface(bytes4 _interfaceId) external view returns (bool); - } - } - - sol! { - #[sol(rpc)] - interface IERC173 { - function owner() external view returns (address owner_); - function transferOwnership(address _newOwner) external; - } - } -} - -pub use diamond_loupe_inner::{ - IDiamondLoupe, - IERC173, -}; - -/// One-shot diamond initializer (`transfer/bank/BankInit.sol`). No -/// `#[sol(rpc)]`: only `initCall` is used — ABI-encoded as the diamondCut -/// `_init` delegatecall calldata that runs BankInit in the diamond's storage. -#[allow(clippy::too_many_arguments, unused_attributes)] -mod bank_init_inner { - use alloy::sol; - - sol! { - interface BankInit { - function init(address notary, address backend, address registry) external; - } - } -} - -pub use bank_init_inner::BankInit; diff --git a/rust/contracts/src/deploy.rs b/rust/contracts/src/deploy.rs index dceb2e39..5e05a263 100644 --- a/rust/contracts/src/deploy.rs +++ b/rust/contracts/src/deploy.rs @@ -226,10 +226,13 @@ pub async fn upgrade_uups( } /// Load a contract's creation bytecode, deploying and linking any external -/// libraries it references (the bb-generated UltraHonk verifiers link -/// `ZKTranscriptLib`). Mirrors what `forge` does automatically. For artifacts -/// with no `linkReferences` this behaves like [`Artifacts::bytecode_named`] -/// and sends nothing. +/// libraries it references. Mirrors what `forge` does automatically. For +/// artifacts with no `linkReferences` this behaves like +/// [`Artifacts::bytecode_named`] and sends nothing. +/// +/// Kept although nothing covered today links a library: the bb-generated +/// UltraHonk verifiers link `ZKTranscriptLib`, and the ones the ceremony +/// circuits release will deploy through this path when they land. pub async fn load_linked_bytecode( provider: &P, artifacts: &Artifacts, diff --git a/rust/contracts/src/diamond.rs b/rust/contracts/src/diamond.rs deleted file mode 100644 index 4f624f6b..00000000 --- a/rust/contracts/src/diamond.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! The Bank EIP-2535 diamond: deploy and facet-replace flows. Mirrors -//! `solidity/contracts/transfer/script/BankDiamondDeployer.sol`. - -use alloy::{ - primitives::{ - Address, - Bytes, - }, - providers::Provider, - sol_types::SolCall, -}; - -use crate::{ - artifacts::Artifacts, - bindings::transfer::{ - BankInit, - IDiamondCut, - }, - deploy::{ - deploy_contract_from, - deploy_with_ctor, - }, - error::{ - Error, - Result, - }, -}; - -/// Every facet cut into the Bank diamond after construction. -/// -/// `DiamondCutFacet` is absent on purpose: the Diamond constructor wires that -/// one in itself. Deploy and upgrade both read this list, so a facet added -/// for one is not forgotten by the other. -pub const BANK_FACETS: &[&str] = &[ - "DiamondLoupeFacet", - "OwnershipFacet", - "AdminFacet", - "VaultFacet", - "TransferFacet", -]; - -/// `IDiamondCut.FacetCutAction` values, as their ABI underlying `uint8`. -pub mod facet_cut_action { - /// Add new selectors. - pub const ADD: u8 = 0; - /// Replace the facet behind existing selectors. - pub const REPLACE: u8 = 1; - /// Remove selectors. - pub const REMOVE: u8 = 2; -} - -/// Deploy a fresh Bank as an EIP-2535 diamond and return the diamond address. -/// -/// Deploys DiamondCutFacet, constructs `Diamond(owner, cutFacet)` (the ctor -/// auto-registers the diamondCut selector, so the cut below must NOT re-add -/// DiamondCutFacet), deploys the five remaining facets + `BankInit`, then one -/// `diamondCut` that adds every facet's selectors and delegatecalls -/// `BankInit.init(notary, backend, registry)` to seed the trusted-party -/// pointers, ERC-165 ids, templates, and prefixes. -/// -/// `notary` is the shared Notary contract's proxy address; `backend` and -/// `registry` come from the login deploy. All three must be nonzero or the -/// init reverts. -pub async fn deploy_bank_diamond( - provider: &P, - artifacts: &Artifacts, - owner: Address, - notary_contract: Address, - backend: Address, - registry: Address, -) -> Result
{ - // DiamondCutFacet — the one facet the Diamond ctor wires in itself. - let cut_facet_addr = deploy_contract_from( - provider, - artifacts.bytecode("DiamondCutFacet")?, - "DiamondCutFacet", - None, - ) - .await?; - - // Diamond(address owner, address diamondCutFacet). - let ctor_args = - alloy::sol_types::SolValue::abi_encode_params(&(owner, cut_facet_addr)); - let diamond_addr = deploy_with_ctor( - provider, - &artifacts.bytecode("Diamond")?, - &ctor_args, - "Diamond", - None, - ) - .await?; - - // Remaining facets: deploy each, then read its selectors from the - // artifact. (DiamondCutFacet is intentionally absent — already cut in.) - let cut = deploy_facet_cut(provider, artifacts, facet_cut_action::ADD, None).await?; - - // One-shot initializer, delegatecalled by diamondCut in diamond storage. - let bank_init_addr = - deploy_contract_from(provider, artifacts.bytecode("BankInit")?, "BankInit", None) - .await?; - - let init_calldata: Bytes = BankInit::initCall { - notary: notary_contract, - backend, - registry, - } - .abi_encode() - .into(); - - let diamond = IDiamondCut::new(diamond_addr, provider); - diamond - .diamondCut(cut, bank_init_addr, init_calldata) - .send() - .await - .map_err(|e| Error::Rpc { - detail: format!("Diamond.diamondCut send failed: {e}"), - })? - .get_receipt() - .await - .map_err(|e| Error::Rpc { - detail: format!("Diamond.diamondCut confirmation failed: {e}"), - })?; - - Ok(diamond_addr) -} - -/// Upgrade a deployed Bank diamond: re-deploy every facet in [`BANK_FACETS`] -/// and REPLACE its selectors in one `diamondCut`. There is no implementation -/// slot to re-point — the diamond IS the storage, the facets are the code. -pub async fn replace_bank_facets( - provider: &P, - artifacts: &Artifacts, - diamond: Address, - sender: Option
, -) -> Result<()> { - let cut = - deploy_facet_cut(provider, artifacts, facet_cut_action::REPLACE, sender).await?; - let diamond = IDiamondCut::new(diamond, provider); - let call = diamond.diamondCut(cut, Address::ZERO, Bytes::new()); - let pending = - match sender { - Some(addr) => { - let nonce = provider.get_transaction_count(addr).await.map_err(|e| { - Error::Rpc { - detail: format!("Bank.diamondCut failed to fetch nonce: {e}"), - } - })?; - call.nonce(nonce).send().await - } - None => call.send().await, - } - .map_err(|e| Error::Rpc { - detail: format!("Bank.diamondCut send failed: {e}"), - })?; - pending.get_receipt().await.map_err(|e| Error::Rpc { - detail: format!("Bank.diamondCut confirmation failed: {e}"), - })?; - Ok(()) -} - -/// Deploy every facet in [`BANK_FACETS`] and build the corresponding -/// `FacetCut` array with `action` for each facet's full selector set. -async fn deploy_facet_cut( - provider: &P, - artifacts: &Artifacts, - action: u8, - sender: Option
, -) -> Result> { - let mut cut = Vec::with_capacity(BANK_FACETS.len()); - for &name in BANK_FACETS { - let facet_addr = - deploy_contract_from(provider, artifacts.bytecode(name)?, name, sender) - .await?; - cut.push(IDiamondCut::FacetCut { - facetAddress: facet_addr, - action, - functionSelectors: artifacts.facet_selectors(name)?, - }); - } - Ok(cut) -} diff --git a/rust/contracts/src/lib.rs b/rust/contracts/src/lib.rs index bd160408..a4c41969 100644 --- a/rust/contracts/src/lib.rs +++ b/rust/contracts/src/lib.rs @@ -1,21 +1,22 @@ //! Typed bindings, embedded forge artifacts, and deploy/upgrade helpers for -//! the libid contract stack. +//! the libid identity stack. //! -//! The crate has three layers: +//! The crate has four layers: //! //! - [`bindings`] — hand-written `alloy::sol!` interfaces for every contract a -//! consumer talks to: the login stack (`Registry`, `WebWallet`, -//! `WalletFactory`, verifiers), the Bank diamond, and the identity-names -//! stack. Kept in lockstep with the Solidity sources in `solidity/contracts`. +//! consumer talks to: the ceremony verification path (`NotaryService`, +//! `CeremonyProofVerifier`, and `GoogleJwtRoots`, the Google signing keys +//! the `google/v1` verifier trusts), the naming system (`IdentityNames`), +//! and the deterministic factory. Kept in lockstep with the Solidity +//! sources in `solidity/contracts`. //! - [`artifacts`] — the compiled creation bytecode, link references, and //! method identifiers of every deployable contract, embedded at compile time //! ([`Artifacts::embedded`]) so deployment needs no filesystem at runtime. A //! directory-backed variant ([`Artifacts::from_dir`]) reads a forge `out/` //! tree instead. -//! - [`deploy`] / [`diamond`] — generic deploy and upgrade primitives over any -//! alloy [`Provider`](alloy::providers::Provider): plain deploys, constructor -//! args, ERC1967 proxies, library linking, UUPS upgrades, and the Bank -//! EIP-2535 diamond deploy/facet-replace flows. +//! - [`deploy`] — generic deploy and upgrade primitives over any alloy +//! [`Provider`](alloy::providers::Provider): plain deploys, constructor +//! args, ERC1967 proxies, library linking, and UUPS upgrades. //! - [`factory`] — the deterministic-factory bootstrap: predict the canonical //! cross-network factory address, install it (and the keyless CREATE2 //! deployer it hangs off) where missing, and deploy protocol proxies @@ -27,7 +28,6 @@ pub mod artifacts; pub mod bindings; pub mod deploy; -pub mod diamond; mod error; pub mod factory; diff --git a/rust/contracts/tests/anvil.rs b/rust/contracts/tests/anvil.rs index 82ff6f14..5a816a30 100644 --- a/rust/contracts/tests/anvil.rs +++ b/rust/contracts/tests/anvil.rs @@ -1,5 +1,6 @@ -//! Anvil integration tests: deploy each stack from the embedded artifacts and -//! read a view back. Requires the `anvil` binary on PATH (foundry). +//! Anvil integration tests: deploy the identity stack from the embedded +//! artifacts and read views back. Requires the `anvil` binary on PATH +//! (foundry). use alloy::{ primitives::{ @@ -14,28 +15,18 @@ use alloy::{ }; use libid_contracts::{ bindings::{ - identity::IdentityNames, - login::{ - IRegistryAdmin, - Registry, - WalletFactory, - }, - notary::Notary, - transfer::{ - Bank, - IDiamondLoupe, + ceremony::{ + CeremonyProofVerifier, + GoogleJwtRoots, + NotaryService, }, + identity::IdentityNames, }, deploy::{ deploy_behind_proxy, deploy_contract, - load_linked_bytecode, upgrade_uups, }, - diamond::{ - deploy_bank_diamond, - BANK_FACETS, - }, Artifacts, }; @@ -47,170 +38,79 @@ async fn default_signer(provider: &impl Provider) -> Address { provider.get_accounts().await.expect("accounts")[0] } -/// (a) The login stack: Notary behind ERC1967 proxy first, then WebWallet -/// impl + WalletFactory behind proxy + Registry behind proxy pointing at the -/// Notary, wired together, then view calls. +/// (a) The identity stack in the order `script/Deploy.s.sol` uses: the +/// Notary Service first, then the Proof Verifier, the naming system given a +/// keyspace and pointed at the Proof Verifier, and the Google JWT root list +/// pointed at the Notary Service — every one behind an ERC1967 proxy. Then the views +/// that prove the wiring took. #[tokio::test] -async fn deploys_the_login_stack_behind_proxies() { +async fn deploys_the_identity_stack_behind_proxies() { let provider = test_provider(); let artifacts = Artifacts::embedded(); let deployer = default_signer(&provider).await; - let notary = Address::repeat_byte(0x11); - let backend = Address::repeat_byte(0x22); + let notary_key = Address::repeat_byte(0x11); + let fee = U256::from(1_000); let notary_proxy = deploy_behind_proxy( &provider, &artifacts, - "Notary", - &Notary::initializeCall { + "NotaryService", + &NotaryService::initializeCall { owner_: deployer, - notary_: notary, + notary_: notary_key, + fee_: fee, }, None, ) .await .unwrap(); - let wallet_impl = deploy_contract( + let verifier_proxy = deploy_behind_proxy( &provider, - artifacts.bytecode("WebWallet").unwrap(), - "WebWallet (impl)", + &artifacts, + "CeremonyProofVerifier", + &CeremonyProofVerifier::initializeCall { owner_: deployer }, + None, ) .await .unwrap(); - let factory_proxy = deploy_behind_proxy( + let names_proxy = deploy_behind_proxy( &provider, &artifacts, - "WalletFactory", - &WalletFactory::initializeCall { - owner_: deployer, - walletImpl_: wallet_impl, - registry_: Address::ZERO, // registry not yet deployed - }, + "IdentityNames", + &IdentityNames::initializeCall { owner_: deployer }, None, ) .await .unwrap(); - let registry_proxy = deploy_behind_proxy( + let roots_proxy = deploy_behind_proxy( &provider, &artifacts, - "Registry", - &IRegistryAdmin::initializeCall { - _notaryContract: notary_proxy, - _backend: backend, - _walletFactory: factory_proxy, - _owner: deployer, + "GoogleJwtRoots", + &GoogleJwtRoots::initializeCall { + owner_: deployer, + notary_: notary_proxy, }, None, ) .await .unwrap(); - let factory = WalletFactory::new(factory_proxy, &provider); - factory - .setRegistry(registry_proxy) + // Wire the naming system: the Proof Verifier it dispatches through, and + // a keyspace. The platform id is the platform's own bare name: libID + // namespaces only its own strings. + let names = IdentityNames::new(names_proxy, &provider); + names + .setProofVerifier(verifier_proxy) .send() .await .unwrap() .get_receipt() .await .unwrap(); - - let registry = Registry::new(registry_proxy, &provider); - // notaryContract() is the pointer; notary() reads THROUGH it. - assert_eq!( - registry.notaryContract().call().await.unwrap(), - notary_proxy - ); - assert_eq!(registry.notary().call().await.unwrap(), notary); - assert_eq!(registry.backend().call().await.unwrap(), backend); - assert_eq!( - registry.walletFactory().call().await.unwrap(), - factory_proxy - ); - let factory = WalletFactory::new(factory_proxy, &provider); - assert_eq!( - factory.latestImplementation().call().await.unwrap(), - wallet_impl - ); -} - -/// (b) The full Bank diamond via the diamond helper + BankInit, then loupe -/// and Bank views. -#[tokio::test] -async fn deploys_the_bank_diamond() { - let provider = test_provider(); - let artifacts = Artifacts::embedded(); - let deployer = default_signer(&provider).await; - - let bank_addr = deploy_bank_diamond( - &provider, - &artifacts, - deployer, - Address::repeat_byte(0x11), // notary registry - Address::repeat_byte(0x22), // backend - Address::repeat_byte(0x33), // registry - ) - .await - .unwrap(); - - // Loupe: DiamondCutFacet (ctor-wired) + the five BANK_FACETS. - let loupe = IDiamondLoupe::new(bank_addr, &provider); - let facets = loupe.facetAddresses().call().await.unwrap(); - assert_eq!(facets.len(), BANK_FACETS.len() + 1); - - // BankInit ran in diamond storage: the registry pointer is seeded and the - // initialize templates exist for both platforms. - let bank = Bank::new(bank_addr, &provider); - assert_eq!( - bank.registry().call().await.unwrap(), - Address::repeat_byte(0x33) - ); - assert!(!bank.paused().call().await.unwrap()); - // BankInit seeds 4 templates for api.x.com (2 recipient-full + 2 - // recipient-less) and 2 for api.github.com. - assert_eq!( - bank.platformTemplateCount("api.x.com".into()) - .call() - .await - .unwrap(), - U256::from(4) - ); - assert_eq!( - bank.platformTemplateCount("api.github.com".into()) - .call() - .await - .unwrap(), - U256::from(2) - ); -} - -/// (c) IdentityNames behind proxy, given a keyspace with setPlatform, then -/// views. Wiring a verifier belongs to CeremonyProofVerifier now. -#[tokio::test] -async fn deploys_the_identity_stack() { - let provider = test_provider(); - let artifacts = Artifacts::embedded(); - let deployer = default_signer(&provider).await; - - let names_proxy = deploy_behind_proxy( - &provider, - &artifacts, - "IdentityNames", - &IdentityNames::initializeCall { owner_: deployer }, - None, - ) - .await - .unwrap(); - - // The platform id is the platform's own bare name: libID namespaces - // only its own strings. let platform_id = keccak256(b"github"); - let names = IdentityNames::new(names_proxy, &provider); - // The keyspace and the proof format are configured separately: rules do - // not vary by version, verifiers do. names .setPlatform( platform_id, @@ -229,127 +129,128 @@ async fn deploys_the_identity_stack() { .await .unwrap(); - // Wiring a verifier is the Proof Verifier's call now, not this contract's, - // and this deploy stops at the keyspace. A platform that owns a keyspace - // and can verify nothing says so: answering `address(0)` would tell the - // caller "nobody holds this name" about a platform that is not wired yet. + // The Notary Service holds the key and the fee it was given. + let notary = NotaryService::new(notary_proxy, &provider); + assert!(notary.isTrustedNotary(notary_key).call().await.unwrap()); + assert_eq!(notary.fee().call().await.unwrap(), fee); + + // Nothing is registered against any version yet, and the Proof Verifier + // says so rather than answering for a platform it cannot verify. + let verifier = CeremonyProofVerifier::new(verifier_proxy, &provider); + assert!(!verifier.verifiesPlatform(platform_id).call().await.unwrap()); + assert_eq!( + verifier.verifierOf(platform_id, 1).call().await.unwrap(), + Address::ZERO + ); + + assert_eq!(names.proofVerifier().call().await.unwrap(), verifier_proxy); + // A platform that owns a keyspace and can verify nothing says so: + // answering `address(0)` would tell the caller "nobody holds this name" + // about a platform that is not wired yet. let unwired = names.resolveId(platform_id, "12345".into()).call().await; assert!( unwired.is_err(), "an unwired platform answered instead of reverting UnknownPlatform" ); -} - -/// (d) XHonkVerifier via linked bytecode — exercises the recursive library -/// deploy + link-reference substitution (ZKTranscriptLib). -#[tokio::test] -async fn deploys_the_x_honk_verifier_via_library_linking() { - // The generated UltraHonk verifiers exceed EIP-170 (the target chains - // raise the limit); anvil must be told to accept them. - let provider = ProviderBuilder::new() - .connect_anvil_with_wallet_and_config(|anvil| { - anvil.arg("--disable-code-size-limit") - }) - .expect("anvil spawns"); - let artifacts = Artifacts::embedded(); - - // The raw artifact must refuse the unlinked path… - assert!(artifacts.bytecode("XHonkVerifier").is_err()); - - // …and link + deploy through the linking path. - let linked = load_linked_bytecode( - &provider, - &artifacts, - "XHonkVerifier", - "XHonkVerifier", - None, - ) - .await - .unwrap(); - let addr = deploy_contract(&provider, linked, "XHonkVerifier") - .await - .unwrap(); - - let code = provider.get_code_at(addr).await.unwrap(); - assert!(!code.is_empty(), "XHonkVerifier has no runtime code"); - // The Google OIDC circuit verifier links the same library under - // Verifier.sol/HonkVerifier — cover the file != contract path too. - let linked = artifacts - .linked_bytecode(&provider, "Verifier", "HonkVerifier", None) - .await - .unwrap(); - let addr = deploy_contract(&provider, linked, "HonkVerifier") - .await - .unwrap(); - assert!(!provider.get_code_at(addr).await.unwrap().is_empty()); + // The root list points at the Notary Service, quotes its fee, and starts + // with both generations empty — so it wants a rotation before any Google + // name can bind. + let roots = GoogleJwtRoots::new(roots_proxy, &provider); + assert_eq!(roots.notaryService().call().await.unwrap(), notary_proxy); + assert_eq!(roots.quoteRotation().call().await.unwrap(), fee); + assert!(roots.needsRotation().call().await.unwrap()); + let keys = roots.currentKeys().call().await.unwrap(); + assert_eq!(keys.current.observedAt, 0); + assert!(keys.current.moduli.is_empty()); + assert_eq!(keys.previous.observedAt, 0); + assert!(keys.previous.moduli.is_empty()); } -/// (e) The Notary lifecycle: deploy behind a proxy, rotate the signer with -/// `setNotary`, upgrade the proxy to a freshly deployed implementation via -/// `upgrade_uups`, and check the rotated state survives the upgrade. +/// (b) The Notary Service lifecycle: deploy behind a proxy, add a second +/// trusted key with `setNotary`, change the fee with `setFee`, upgrade the +/// proxy to a freshly deployed implementation via `upgrade_uups`, and check +/// the rotated state survives the upgrade. #[tokio::test] -async fn rotates_and_upgrades_the_notary() { +async fn rotates_and_upgrades_the_notary_service() { let provider = test_provider(); let artifacts = Artifacts::embedded(); let deployer = default_signer(&provider).await; let first = Address::repeat_byte(0x11); - let rotated = Address::repeat_byte(0x33); + let incoming = Address::repeat_byte(0x33); let notary_proxy = deploy_behind_proxy( &provider, &artifacts, - "Notary", - &Notary::initializeCall { + "NotaryService", + &NotaryService::initializeCall { owner_: deployer, notary_: first, + fee_: U256::ZERO, }, None, ) .await .unwrap(); - let notary = Notary::new(notary_proxy, &provider); - assert_eq!(notary.notary().call().await.unwrap(), first); + let notary = NotaryService::new(notary_proxy, &provider); + assert!(notary.isTrustedNotary(first).call().await.unwrap()); + assert!(!notary.isTrustedNotary(incoming).call().await.unwrap()); + assert_eq!(notary.fee().call().await.unwrap(), U256::ZERO); - // A garbage proof answers false rather than reverting. - assert!(!notary - .verify(keccak256(b"digest"), vec![0u8; 65].into()) - .call() + // Rotate: the incoming key is trusted before the outgoing one goes, so + // attestations already made under `first` stay presentable meanwhile. + notary + .setNotary(incoming, true) + .send() .await - .unwrap()); - - // Rotate the signer. + .unwrap() + .get_receipt() + .await + .unwrap(); notary - .setNotary(rotated) + .setNotary(first, false) .send() .await .unwrap() .get_receipt() .await .unwrap(); - assert_eq!(notary.notary().call().await.unwrap(), rotated); + notary + .setFee(U256::from(7)) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(!notary.isTrustedNotary(first).call().await.unwrap()); + assert!(notary.isTrustedNotary(incoming).call().await.unwrap()); + assert_eq!(notary.fee().call().await.unwrap(), U256::from(7)); // Upgrade the proxy to a re-deployed implementation; state survives. let new_impl = upgrade_uups( &provider, &artifacts, notary_proxy, - "Notary", + "NotaryService", Default::default(), None, ) .await .unwrap(); assert_ne!(new_impl, notary_proxy); - assert_eq!(notary.notary().call().await.unwrap(), rotated); + assert!(!notary.isTrustedNotary(first).call().await.unwrap()); + assert!(notary.isTrustedNotary(incoming).call().await.unwrap()); + assert_eq!(notary.fee().call().await.unwrap(), U256::from(7)); + assert_eq!(notary.owner().call().await.unwrap(), deployer); } -/// (f) The deterministic factory, from truly nothing: anvil is started +/// (c) The deterministic factory, from truly nothing: anvil is started /// WITHOUT its predeployed CREATE2 deployer, so `ensure_factory` must /// install it via the keyless presigned transaction (funding the one-time /// signer first), then deploy the factory impl + proxy at their canonical -/// addresses. Then a Notary proxy goes through the factory at its +/// addresses. Then a Notary Service proxy goes through the factory at its /// name-derived CREATE3 address. #[tokio::test] async fn bootstraps_the_deterministic_factory_and_deploys_through_it() { @@ -446,32 +347,40 @@ async fn bootstraps_the_deterministic_factory_and_deploys_through_it() { .unwrap(); assert_eq!(factory_contract.owner().call().await.unwrap(), deployer0); - // A Notary PROXY through the factory: impl via plain CREATE (its address - // doesn't matter), proxy creation code = ERC1967Proxy ++ (impl, initData). - let notary_signer = Address::repeat_byte(0x11); + // A Notary Service PROXY through the factory: impl via plain CREATE (its + // address doesn't matter), proxy creation code = ERC1967Proxy ++ (impl, + // initData). + let notary_key = Address::repeat_byte(0x11); let notary_impl = deploy_contract( &provider, - artifacts.bytecode("Notary").unwrap(), - "Notary (impl)", + artifacts.bytecode("NotaryService").unwrap(), + "NotaryService (impl)", ) .await .unwrap(); - let init_data = Notary::initializeCall { + let init_data = NotaryService::initializeCall { owner_: deployer0, - notary_: notary_signer, + notary_: notary_key, + fee_: U256::from(7), } .abi_encode(); let mut creation_code = artifacts.bytecode("ERC1967Proxy").unwrap().to_vec(); creation_code .extend_from_slice(&(notary_impl, Bytes::from(init_data)).abi_encode_params()); - let predicted = predict_address(factory, "libid.notary"); - let deployed = - factory_deploy(&provider, factory, "libid.notary", creation_code.into()) - .await - .unwrap(); + let predicted = predict_address(factory, "libid.NotaryService"); + let deployed = factory_deploy( + &provider, + factory, + "libid.NotaryService", + creation_code.into(), + ) + .await + .unwrap(); assert_eq!(deployed, predicted); - let notary = Notary::new(deployed, &provider); - assert_eq!(notary.notary().call().await.unwrap(), notary_signer); + let notary = NotaryService::new(deployed, &provider); + assert!(notary.isTrustedNotary(notary_key).call().await.unwrap()); + assert_eq!(notary.fee().call().await.unwrap(), U256::from(7)); + assert_eq!(notary.owner().call().await.unwrap(), deployer0); } diff --git a/scripts/regen-identity-handles.py b/scripts/regen-identity-handles.py index dc6cf605..8629be95 100755 --- a/scripts/regen-identity-handles.py +++ b/scripts/regen-identity-handles.py @@ -8,8 +8,7 @@ rust/identity/src/handle_vectors.rs # Rust constants + vectors ts/packages/contracts/src/identity/handleVectors.ts # TypeScript constants + vectors -The Rust package does not exist in this repo yet; an output is skipped with a -note until its target directory appears. The normalizer +An output is skipped with a note until its target directory appears. The normalizer itself is hand written in each language. Only the constants and the vectors come from here. That is the point: several implementations, one vector table, so a difference between them fails a test instead of writing a different key @@ -34,8 +33,8 @@ SOL_OUT = SOLIDITY_ROOT / "contracts" / "identity" / "HandleVectors.sol" # An output is skipped (with a note) while its parent directory is absent, so -# `--check` holds only the outputs that exist to account. The TypeScript -# package is live; the Rust one is not in this repo yet. +# `--check` holds only the outputs that exist to account. Both the Rust and +# the TypeScript packages are live today. RUST_OUT = REPO_ROOT / "rust" / "identity" / "src" / "handle_vectors.rs" TS_OUT = REPO_ROOT / "ts" / "packages" / "contracts" / "src" / "identity" / "handleVectors.ts" @@ -486,8 +485,7 @@ def main() -> int: outputs = [] skipped = [] outputs.append((SOL_OUT, sol_formatted(gen_sol(spec)))) - # Emit an output only once its package directory exists. TS does; the Rust - # package is not in this repo yet. + # Emit an output only once its package directory exists (both do today). for path, gen in ((RUST_OUT, gen_rust), (TS_OUT, gen_ts)): if path.parent.exists(): outputs.append((path, gen(spec))) diff --git a/scripts/regen-verifiers.sh b/scripts/regen-verifiers.sh deleted file mode 100755 index a1790eff..00000000 --- a/scripts/regen-verifiers.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# Regenerate the bb-generated Solidity verifiers from the pinned -# libid-circuits release. -# -# solidity/circuits-version pins a release tag on libid-org/libid-circuits. -# This script downloads that release's artifacts (per-circuit tarballs plus a -# manifest with their sha256s and the toolchain versions that produced them), -# verifies the hashes, obtains bb at exactly the manifest's version, and -# re-derives both committed verifiers from the released verification keys: -# -# solidity/contracts/login/oidc/Verifier.sol ⟵ jwt_email vk -# solidity/contracts/login/zk/XHonkVerifier.sol ⟵ x-token vk -# (HonkVerifier renamed -# XHonkVerifier — two bb -# verifiers in one -# project would collide) -# -# Post-processing mirrors libid-circuits' scripts/gen-verifier.sh: every -# assembly block is annotated ("memory-safe") for via_ir, then the output is -# formatted under solidity/foundry.toml. -# -# Usage: -# scripts/regen-verifiers.sh --check # byte-compare against the committed -# # files; nonzero + diffstat on drift -# # (what CI's `verifiers` job runs) -# scripts/regen-verifiers.sh --write # overwrite the committed files -# -# Requirements: gh (authenticated; GH_TOKEN in CI), jq, curl, perl, forge. -# bb is used from PATH (or ~/.bb) when it already matches the manifest's -# version; otherwise bbup installs the pinned version into ~/.bb. -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -MODE="${1:-}" -case "$MODE" in - --check|--write) ;; - *) - echo "usage: $0 --check|--write" >&2 - exit 2 - ;; -esac - -TAG="$(cat "$REPO_ROOT/solidity/circuits-version")" -echo "Pinned libid-circuits release: $TAG" - -# Download dir is a real temp dir; the generation dir lives under solidity/ -# (gitignored) because forge fmt formats it under solidity/foundry.toml. -DL="$(mktemp -d "${TMPDIR:-/tmp}/libid-circuits.XXXXXX")" -GEN="$REPO_ROOT/solidity/.circuits-check" -cleanup() { rm -rf "$DL" "$GEN"; } -trap cleanup EXIT -rm -rf "$GEN" -mkdir -p "$GEN" - -# --- Download the release assets ------------------------------------------- -if ! gh release download "$TAG" \ - --repo libid-org/libid-circuits \ - --dir "$DL" \ - --pattern 'libid-circuits-*.tar.gz' \ - --pattern 'manifest.json'; then - echo "::error::libid-circuits release '$TAG' (pinned in solidity/circuits-version) does not exist or has no artifacts yet. Cut that release first, or fix the pin." >&2 - exit 1 -fi - -# --- Verify tarball sha256s against the manifest --------------------------- -# Every tarball must hash to what the manifest promises before anything -# inside it is trusted. -sha256() { - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | cut -d' ' -f1 - else - shasum -a 256 "$1" | cut -d' ' -f1 # macOS - fi -} -for tarball in "$DL"/libid-circuits-*.tar.gz; do - name="$(basename "$tarball")" - want="$(jq -re --arg t "$name" '.tarballs[$t].sha256' "$DL/manifest.json")" - got="$(sha256 "$tarball")" - if [ "$want" != "$got" ]; then - echo "::error::$name sha256 mismatch: manifest says $want, asset is $got" >&2 - exit 1 - fi - echo "$name: sha256 OK" -done - -# --- Extract the circuits we regenerate from ------------------------------- -VERSION="$(jq -re .version "$DL/manifest.json")" -for circuit in jwt_email x-token; do - mkdir -p "$DL/$circuit" - tar -xzf "$DL/libid-circuits-${VERSION}-${circuit}.tar.gz" -C "$DL/$circuit" -done - -# --- bb at the manifest's toolchain version -------------------------------- -# The bb pin comes from the manifest — the release names the version that -# produced its vks, and only that version reproduces them. An existing bb at -# the right version (on PATH or in ~/.bb) is used as-is; otherwise bbup -# installs the pinned version into ~/.bb. -BB_VERSION="$(jq -re .toolchain.bb "$DL/manifest.json")" -BB="" -for candidate in "$(command -v bb || true)" "$HOME/.bb/bb"; do - if [ -n "$candidate" ] && [ -x "$candidate" ] \ - && [ "$("$candidate" --version | tail -1)" = "$BB_VERSION" ]; then - BB="$candidate" - break - fi -done -if [ -z "$BB" ]; then - echo "No bb $BB_VERSION found; installing via bbup." - if ! command -v bbup >/dev/null 2>&1 && [ ! -x "$HOME/.bb/bbup" ]; then - curl -L https://raw.githubusercontent.com/AztecProtocol/aztec-packages/master/barretenberg/bbup/install | bash - fi - PATH="$HOME/.bb:$PATH" bbup --version "$BB_VERSION" - BB="$HOME/.bb/bb" - [ "$("$BB" --version | tail -1)" = "$BB_VERSION" ] || { - echo "::error::bbup installed $("$BB" --version | tail -1), wanted $BB_VERSION" >&2 - exit 1 - } -fi -echo "Using bb $BB_VERSION at $BB" - -# --- Regenerate ------------------------------------------------------------ -"$BB" write_solidity_verifier -k "$DL/jwt_email/vk" \ - -o "$GEN/Verifier.sol" -t evm -"$BB" write_solidity_verifier -k "$DL/x-token/vk" \ - -o "$GEN/XHonkVerifier.sol" -t evm - -perl -i -pe 's/assembly \{/assembly ("memory-safe") \{/g' \ - "$GEN/Verifier.sol" "$GEN/XHonkVerifier.sol" -perl -i -pe 's/contract HonkVerifier is BaseZKHonkVerifier/contract XHonkVerifier is BaseZKHonkVerifier/g' \ - "$GEN/XHonkVerifier.sol" - -(cd "$REPO_ROOT/solidity" && forge fmt .circuits-check/Verifier.sol .circuits-check/XHonkVerifier.sol) - -# --- Compare or write ------------------------------------------------------ -PAIRS=( - "$GEN/Verifier.sol:$REPO_ROOT/solidity/contracts/login/oidc/Verifier.sol" - "$GEN/XHonkVerifier.sol:$REPO_ROOT/solidity/contracts/login/zk/XHonkVerifier.sol" -) - -status=0 -for pair in "${PAIRS[@]}"; do - fresh="${pair%%:*}" - committed="${pair##*:}" - rel="${committed#"$REPO_ROOT"/}" - if [ "$MODE" = "--write" ]; then - if diff -q "$fresh" "$committed" >/dev/null 2>&1; then - echo "$rel is already current." - else - cp "$fresh" "$committed" - echo "$rel rewritten from the $TAG release vk." - fi - elif diff -q "$fresh" "$committed" >/dev/null; then - echo "$rel reproduces from the release vk." - else - echo "::error::$rel does not match the verifier regenerated from the pinned circuits release" >&2 - git diff --no-index --stat "$committed" "$fresh" || true - git diff --no-index "$committed" "$fresh" | head -60 || true - status=1 - fi -done -exit "$status" diff --git a/scripts/vendor-artifacts.sh b/scripts/vendor-artifacts.sh index 5269eafa..0ec69342 100755 --- a/scripts/vendor-artifacts.sh +++ b/scripts/vendor-artifacts.sh @@ -5,8 +5,9 @@ # the artifact JSONs the crate needs from solidity/out into # rust/contracts/artifacts/.sol/.json, pruned to the fields the # crate reads: bytecode.object, bytecode.linkReferences, methodIdentifiers. -# Libraries referenced through linkReferences (e.g. ZKTranscriptLib for the -# Honk verifiers) are followed transitively and vendored too. +# Libraries referenced through linkReferences are followed transitively and +# vendored too (none of the covered contracts links one today; the Honk +# verifiers the ceremony circuits will bring do). # # The result is NOT committed: rust/contracts/artifacts is gitignored and # regenerated on demand. Run this before any cargo command in rust/ — the @@ -27,35 +28,16 @@ DEST="$REPO_ROOT/rust/contracts/artifacts" # ":" — the artifact lives at out/.sol/.json. # Keep in sync with the covered-contract list in rust/contracts/src/artifacts.rs. ARTIFACTS=( - # login - "Registry:Registry" - "Notary:Notary" - "WalletFactory:WalletFactory" - "WebWallet:WebWallet" + # ceremony + "NotaryService:NotaryService" + "CeremonyProofVerifier:CeremonyProofVerifier" "ERC1967Proxy:ERC1967Proxy" - "XZkVerifier:XZkVerifier" - "XHonkVerifier:XHonkVerifier" - # oidc (the Google OIDC circuit verifier is generated into Verifier.sol - # under the contract name HonkVerifier) - "Verifier:HonkVerifier" - "GoogleOidcVerifier:GoogleOidcVerifier" - # transfer (bank diamond) - "Diamond:Diamond" - "DiamondCutFacet:DiamondCutFacet" - "DiamondLoupeFacet:DiamondLoupeFacet" - "OwnershipFacet:OwnershipFacet" - "AdminFacet:AdminFacet" - "VaultFacet:VaultFacet" - "TransferFacet:TransferFacet" - "BankInit:BankInit" - "IBank:IBank" - "MockERC20:MockERC20" - "WTIA9:WTIA9" + "GoogleJwtRoots:GoogleJwtRoots" # identity "IdentityNames:IdentityNames" - "IdentityJwksRoots:IdentityJwksRoots" # factory "LibidFactory:LibidFactory" + "WTIA9:WTIA9" ) if [[ $# -gt 0 ]]; then diff --git a/solidity/circuits-version b/solidity/circuits-version deleted file mode 100644 index 1474d00f..00000000 --- a/solidity/circuits-version +++ /dev/null @@ -1 +0,0 @@ -v0.2.0 diff --git a/solidity/contracts/WTIA9.sol b/solidity/contracts/WTIA9.sol index bb84f5ac..ec575d8f 100644 --- a/solidity/contracts/WTIA9.sol +++ b/solidity/contracts/WTIA9.sol @@ -17,9 +17,9 @@ pragma solidity ^0.8.20; /// /// 1. `withdraw` uses `call` rather than `transfer`. `transfer` forwards a /// fixed 2300 gas stipend, which is enough for an EOA but reverts for a -/// contract recipient whose `receive` does anything at all — including -/// this repo's own WebWallet, which emits an event on native receipt. -/// WETH9 predates that being a known hazard. +/// contract recipient whose `receive` does anything at all — a smart +/// account that logs an event on native receipt, say. WETH9 predates +/// that being a known hazard. /// 2. Balances are decremented *before* the external call /// (checks-effects-interactions), so a malicious `receive` cannot /// re-enter and withdraw twice. The original is safe only by accident diff --git a/solidity/contracts/ceremony/CeremonyAttestation.sol b/solidity/contracts/ceremony/CeremonyAttestation.sol index 6c9d6eb1..070b1eb8 100644 --- a/solidity/contracts/ceremony/CeremonyAttestation.sol +++ b/solidity/contracts/ceremony/CeremonyAttestation.sol @@ -202,27 +202,8 @@ library CeremonyAttestation { requireExactCoverage(block_, length); - // Obsolete line folding is illegal in HTTP/1.1 and defeats the needle: - // `authorization:\r\n Bearer x` normalizes to - // `authorization:\r\nbearer`, because normalization strips the space - // but keeps the CRLF the fold introduced. The header is then never - // counted, and a server honouring the fold authenticates with it. bytes memory revealed = concatRevealed(block_); - for (uint256 i = 0; i + 2 < revealed.length; ++i) { - if (revealed[i] == 0x0d && revealed[i + 1] == 0x0a && (revealed[i + 2] == 0x20 || revealed[i + 2] == 0x09)) - { - revert ObsoleteLineFold(i); - } - } - // Every LF must be part of a CRLF. Otherwise - // `...\nauthorization: Bearer \r\n` starts a header line the - // CRLF-anchored needle never counts, while a lenient platform parser - // honours it. - for (uint256 i = 0; i < revealed.length; ++i) { - if (revealed[i] == 0x0a && (i == 0 || revealed[i - 1] != 0x0d)) { - revert BareLineFeed(i); - } - } + requireCrlfLineEndings(revealed); // Counted over the CONCATENATION, not per range. // @@ -243,7 +224,7 @@ library CeremonyAttestation { // copy the whole revealed transcript again and rescan it, so every // rejected submission would pay twice for the check that rejected it -- // on a buffer the prover sizes. - uint256 headers = _countNeedle(_normalizeHeaderBytes(revealed)); + uint256 headers = _countNeedle(normalizeHeaderBytes(revealed)); if (headers != 1) revert NotOneAuthorizationHeader(headers); // Framing, on RAW bytes at known offsets. Two fixed comparisons make @@ -256,13 +237,62 @@ library CeremonyAttestation { } } - /// @dev Lowercase ASCII and drop every space and horizontal tab, keeping CR - /// and LF (REQ-COMMON-39). Field names and the scheme token are - /// case-insensitive and the colon admits whitespace, so a literal - /// search over raw bytes is evadable. Removing only bytes absent from - /// the needle can create a spurious match, which over-rejects and is - /// safe, but can never hide a real one. - function _normalizeHeaderBytes(bytes memory raw) private pure returns (bytes memory out) { + /// @notice Every line ending in the revealed request bytes is a CRLF, and + /// no line continues the one before it. + /// + /// @dev A CRLF-anchored header count is only as good as the line + /// structure it anchors to, and HTTP/1.1 parsers in the wild accept + /// two shapes the needle cannot see. + /// + /// Obsolete line folding is illegal in HTTP/1.1 and defeats the + /// needle: `authorization:\r\n Bearer x` normalizes to + /// `authorization:\r\nbearer`, because normalization strips the space + /// but keeps the CRLF the fold introduced. The header is then never + /// counted, and a server honouring the fold authenticates with it. + /// + /// Every LF must be part of a CRLF. Otherwise + /// `...\nauthorization: Bearer \r\n` starts a header line the + /// CRLF-anchored needle never counts, while a lenient platform parser + /// honours it. + /// + /// `internal` for the same reason `normalizeHeaderBytes` is: what + /// this refuses decides what a header count can miss, so it is one + /// implementation shared by every count -- the bearer header here and + /// the JWKS root list's `Host` -- rather than one per caller. The + /// `Host` count learned this the hard way: Google's front end honours + /// a bare-LF `Host: storage.googleapis.com` and routes to the storage + /// backend, while a needle count sees only the CRLF one before it. + /// + /// Runs BEFORE the count, over the raw bytes: normalization keeps CR + /// and LF, so the offsets it reports are transcript offsets. + function requireCrlfLineEndings(bytes memory revealed) internal pure { + for (uint256 i = 0; i + 2 < revealed.length; ++i) { + if (revealed[i] == 0x0d && revealed[i + 1] == 0x0a && (revealed[i + 2] == 0x20 || revealed[i + 2] == 0x09)) + { + revert ObsoleteLineFold(i); + } + } + for (uint256 i = 0; i < revealed.length; ++i) { + if (revealed[i] == 0x0a && (i == 0 || revealed[i - 1] != 0x0d)) { + revert BareLineFeed(i); + } + } + } + + /// @notice Lowercase ASCII and drop every space and horizontal tab, keeping + /// CR and LF (REQ-COMMON-39). + /// + /// @dev Field names and the scheme token are case-insensitive and the + /// colon admits whitespace, so a literal search over raw bytes is + /// evadable. Removing only bytes absent from the needle can create a + /// spurious match, which over-rejects and is safe, but can never hide + /// a real one. + /// + /// `internal` for the same reason `concatRevealed` is: a header + /// COUNT reads this, and what it strips decides what a count can + /// miss -- so one implementation of it, shared with the JWKS root + /// list's `Host` scan, rather than two. + function normalizeHeaderBytes(bytes memory raw) internal pure returns (bytes memory out) { out = new bytes(raw.length); uint256 n; for (uint256 i = 0; i < raw.length; ++i) { diff --git a/solidity/contracts/ceremony/GoogleJwtRoots.sol b/solidity/contracts/ceremony/GoogleJwtRoots.sol new file mode 100644 index 00000000..2ee9c539 --- /dev/null +++ b/solidity/contracts/ceremony/GoogleJwtRoots.sol @@ -0,0 +1,919 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; + +import {CeremonyAttestation} from "./CeremonyAttestation.sol"; +import {INotaryService} from "./INotaryService.sol"; + +/// @title GoogleJwtRoots - the signing keys the `google/v1` profile trusts, +/// and until when. +/// +/// @notice Google rotates the keys it signs id_tokens with. This contract is +/// where the Google Platform Verifier learns the current ones, from a +/// notarized reading of Google's published JWKS: a JWT verifies only +/// while the modulus that signed it is listed here and unexpired. +/// +/// @dev The list is two generations of Google's key set, and nothing else. +/// Google's JWKS is an unordered set of a few keys with no "next" +/// marker, so a reading is stored as the set it is: `current` is the +/// latest reading applied, `previous` the reading before it. A newer +/// reading of the same set only restarts `current`'s lifetime; a newer +/// reading of a different set replaces -- `current` becomes `previous`, +/// the reading becomes `current`, and what `previous` held is gone. +/// There is no history and no maintenance: nothing to prune, nothing to +/// untrust, no owner lever over which of Google's keys are trusted. +/// +/// A generation is trusted until READING_LIFETIME after its reading's +/// own `createdAt` -- the notary's clock, not the block the rotation +/// landed in -- so trust lapses by time alone, with no transaction from +/// anyone, and a later reading of the same set extends it. `previous` +/// is kept because Google's rotation and the keeper's reading are not +/// simultaneous: a token minted under the key Google just rotated out +/// is still being presented for the rest of its hour, and a keeper's +/// reading can land in between. One generation back covers that; +/// further back is history. +/// +/// Two generations remember two distinct sets, and that bound is a +/// known tradeoff. Google's backends do not all answer alike during a +/// rotation, so if three distinct sets are ever served at once and two +/// of them lack the same still-live key -- a remove-before-add +/// rotation -- a submitter ordering genuine readings can push that key +/// out until the next reading that lists it lands. Tokens under it fail +/// meanwhile; nothing is inserted; the keeper's next poll sees the key +/// untrusted and restores it. Accepted (2026-09-03) over a per-modulus +/// last-seen list, which would close it at the cost of remembering keys +/// by modulus for a lifetime; revisit if Google's rotation ever shows +/// that shape. +/// +/// The reading is an ordinary notarized session -- the section 9.1 +/// record every Platform Verifier consumes, authenticated by the same +/// Notary Service and charged the same Notary Fee. What differs is the +/// layout the keeper's prover produces: everything in both directions is +/// revealed and nothing is committed. A public key set has nothing to +/// hide, and zero commitments is what lets this contract read the +/// transcript by concatenation safely -- with exact coverage and no +/// commitment, a cut between ranges cannot hide bytes. +/// +/// **Rotation is permissionless, and deliberately has no nullifier.** With +/// an open caller set a one-shot nullifier would BE the attack: a +/// front-runner could consume a reading's digest and brick the honest +/// keeper. Freshness comes from the notary's signed `createdAt` plus the +/// window below, and a reading dated no later than the one in force is +/// refused (`NotNewer`). A rotation pays the Notary Fee, which is the one +/// thing a submitter needs beyond gas -- and a refused one pays nothing, +/// since the revert rolls the fee back with the state. +/// +/// **The reading names no contract.** The attested record is the TLS +/// session alone -- authority, notary clock, transcript -- so the same +/// notarized reading of Google's JWKS is valid anywhere the notary key is +/// trusted. That cross-deployment replay is a feature, and the record +/// must not grow a chain id or a verifying-contract binding. Which key is +/// trusted lives in the shared Notary Service; the transcript checks stay +/// this contract's own. +/// +/// **Why a contract of its own.** A verifier should verify. Rotation is a +/// separate concern with a separate caller set and a separate key list, +/// and a second OIDC provider would want a list of its own, of this +/// shape, pinned to its own host. +/// +/// **The trust model, in one line: the notary attests the reading, time +/// does the expiry, and nobody owns rotation.** Verifiers check the stamp +/// at use-site, and an expired generation stops being trusted with no +/// transaction from anyone. The owner's one lever is the Notary Service +/// pointer: which notary a reading must carry, never which Google key +/// is trusted. +contract GoogleJwtRoots is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { + // ─── State ────────────────────────────────────────────────────── + + /// One reading of Google's key set: when the notary read it, and the + /// limb hash of every modulus it listed, in the order Google listed them. + struct Generation { + uint64 observedAt; + bytes32[] moduli; + } + + /// @custom:storage-location erc7201:libid.storage.GoogleJwtRoots + struct GoogleJwtRootsStorage { + /// Authenticates a reading and charges for it. + INotaryService notary; + /// The latest reading applied. Google's set already lists the key it + /// will sign with next, so this is Google's current AND next keys. + Generation current; + /// The reading before it, kept for the tokens still in flight under a + /// key Google has since dropped. + Generation previous; + } + + // keccak256(abi.encode(uint256(keccak256("libid.storage.GoogleJwtRoots")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant GOOGLE_JWT_ROOTS_STORAGE = + 0x7f78ff13201a03086d4b08e3085224c34a9fc247d0f67d11acd0db52976eb300; + + function _s() private pure returns (GoogleJwtRootsStorage storage $) { + assembly { + $.slot := GOOGLE_JWT_ROOTS_STORAGE + } + } + + // ─── Constants ────────────────────────────────────────────────── + + /// @notice The TLS server name the notary must have authenticated. + bytes32 public constant AUTHORITY = keccak256("www.googleapis.com"); + uint256 public constant FRESHNESS_WINDOW = 1 hours; + uint256 public constant CLOCK_SKEW_GRACE = 5 minutes; + /// How long a generation is trusted after the reading that wrote it, by + /// the reading's own clock. Google's keys overlap for days and a keeper + /// reads weekly, so this is runway for a keeper outage, not a key's life. + uint256 public constant READING_LIFETIME = 30 days; + /// The most keys one reading may carry. Google served four on 2026-09-03, + /// and a notarized reading cannot invent any, so this is headroom: it + /// bounds the set comparison below, not what Google publishes. + uint256 public constant MAX_KEYS = 8; + /// How much trusted runway `needsRotation()` insists on. A reading stamps + /// its set for READING_LIFETIME, so a keeper acting on this signal + /// rotates roughly weekly rather than racing the expiry. + uint256 public constant RENEWAL_MARGIN = 7 days; + + /// @dev The request line the keeper's prover puts on the wire, in + /// origin-form like every other verifier pins it. The path is what + /// separates the key set from everything else googleapis.com serves, + /// and the whole line -- through the CRLF -- is what keeps a query + /// out: `?callback=` turns the same path into JSONP, a 200 whose + /// body begins with bytes the requester chose. + bytes private constant REQUEST_LINE = "GET /oauth2/v3/certs HTTP/1.1\r\n"; + /// @dev googleapis.com serves many virtual hosts under one certificate, so + /// the TLS authority alone does not pin which backend answered. The + /// `Host` header does, and the notary signed it. + bytes private constant HOST_NEEDLE = "\r\nhost:"; + bytes private constant EXPECTED_HOST = "www.googleapis.com"; + bytes private constant STATUS_LINE = "HTTP/1.1 200 "; + bytes private constant HEAD_BOUNDARY = "\r\n\r\n"; + bytes private constant TRANSFER_ENCODING_NEEDLE = "\r\ntransfer-encoding:"; + bytes private constant CHUNKED_NEEDLE = "\r\ntransfer-encoding:chunked\r\n"; + bytes private constant CONTENT_LENGTH_NEEDLE = "\r\ncontent-length:"; + bytes private constant CONTENT_ENCODING_NEEDLE = "\r\ncontent-encoding:"; + bytes private constant KEYS_TOKEN = '"keys"'; + /// @dev 65537, the one public exponent the `google/v1` circuit verifies + /// RS256 under. + bytes private constant EXPECTED_EXPONENT = "AQAB"; + + uint256 private constant MODULUS_BYTES = 256; + uint256 private constant MODULUS_LIMBS = 18; + + // ─── Events ───────────────────────────────────────────────────── + + event NotaryServiceChanged(address notary); + /// A different set than `current` landed: `current` became `previous`, + /// this reading became `current`. `kids` and `moduli` are the reading's + /// keys in the order Google listed them, `observedAt` the notary's clock + /// -- enough for a keeper to follow the list from logs alone. + event KeysRotated(uint64 observedAt, string[] kids, bytes32[] moduli); + /// A newer reading of the set already current: nothing shifted, and the + /// set's lifetime restarted from this reading's clock. + event ReadingRefreshed(uint64 observedAt); + + // ─── Errors ───────────────────────────────────────────────────── + + error ZeroAddress(); + /// @dev A reading whose key set is empty says nothing about what Google + /// publishes; applying it would shift the live set into `previous` + /// on the strength of a document that names no key. Refused rather + /// than charged. + error EmptyKeySet(); + /// @dev More keys in one reading than MAX_KEYS. + error TooManyKeys(uint256 count); + /// @dev The same modulus twice in one reading. Google lists each key + /// once; a document that repeats one is not the document this + /// reads, and a set with a repeat would compare as a set it is not. + error DuplicateKey(bytes32 modulusHash); + /// @dev A JWK whose public exponent is not 65537. The verifier trusts a + /// key by its modulus alone, so a key that would verify under a + /// different exponent must never be listed. + error WrongExponent(bytes found); + /// @dev Exact value, both ways: the Notary Fee is forwarded whole, so there + /// is no overpayment to refund and nothing captured in transit. + error WrongValue(uint256 required, uint256 provided); + error WrongAuthority(bytes32 expected, bytes32 found); + error FutureProof(); + error StaleProof(); + /// @dev Dated no later than the reading in force: a replay of it, or an + /// older reading landing after a newer one. Refused, not ignored, so + /// the Notary Fee rolls back with the state and a keeper that was + /// front-run learns it from the revert rather than from a fee paid + /// for nothing done. + error NotNewer(uint64 createdAt, uint64 observedAt); + /// @dev A direction carries a commitment. The reading must hide nothing: + /// a hidden byte is a byte this contract reads around, and the join + /// below is only safe when there is nothing to read around. + error HiddenBytes(); + /// @dev The first revealed range of a direction does not begin the + /// transcript, so nothing says the bytes read as the request line (or + /// the status line) are it. Raised for either direction; a direction + /// revealing nothing reports `type(uint32).max`. + error RequestLineNotAtOrigin(uint32 start); + /// @dev Not `GET /oauth2/v3/certs HTTP/1.1\r\n`. + error WrongRequestLine(); + error NotOneHostHeader(uint256 count); + error WrongHost(); + /// @dev Not `HTTP/1.1 200 `. + error WrongStatusLine(); + /// @dev A direction with no blank line ending its head. Raised for the + /// request too: its `Host` count runs over the head alone, and a + /// request whose head never ends has no head to count in. + error NoHeadBoundary(); + error BadChunkFraming(); + /// @dev The declared `Content-Length` is not the body the transcript + /// carries. A value that is not a decimal declares no length any + /// body can match. + error ContentLengthMismatch(uint256 declared, uint256 found); + /// @dev A framing header appears more than once. The header the body is + /// located by gets the rule every other read gets (REQ-COMMON-19A): + /// a delimiter matching twice is refused, not chosen from. + error DuplicateFramingHeader(string name, uint256 count); + /// @dev Both `Transfer-Encoding` and `Content-Length` are present, so two + /// parsers could frame two bodies. + error ConflictingFraming(); + /// @dev The response declares a `Content-Encoding`. The prover must not + /// negotiate compression: gzip bytes would only fail later, somewhere + /// in the JSON walk, and an explicit refusal names the cause. + error EncodedBody(); + /// @dev The member is absent, or present but not in the shape read here: + /// `keys` must be an array, `kid`, `n` and `e` must be strings. + error MissingMember(string name); + error AmbiguousMember(string name); + /// @dev A string value with no closing quote, or the `keys` array with no + /// closing bracket (or an element that is not an object). + error UnterminatedMember(string name); + /// @dev A backslash inside a value this contract reads. Neither a kid nor + /// a base64url modulus needs one, and unescaping is a parser this + /// contract does not carry. + error EscapedValue(string name); + /// @dev `{` or `[` inside a JWK object. + error UnexpectedNesting(); + /// @dev Bytes other than SP/HTAB/CR/LF after the closing `}`. + error TrailingBytes(); + error InvalidModulusLength(); + error InvalidB64Char(); + error InvalidB64Length(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @param notary_ The shared Notary Service. Notary key rotation happens + /// THERE; this contract holds only the pointer. + function initialize(address owner_, INotaryService notary_) external initializer { + __Ownable_init(owner_); + __Ownable2Step_init(); + __UUPSUpgradeable_init(); + _setNotaryService(notary_); + } + + // ─── The Notary Service ───────────────────────────────────────── + + function notaryService() external view returns (address) { + return address(_s().notary); + } + + /// @notice Point the list at another Notary Service. + /// @dev The service is the trust root for every reading, so this is the + /// owner's one lever over what a rotation can install -- the same + /// lever every Platform Verifier's owner holds over its trust roots. + function setNotaryService(INotaryService notary_) external onlyOwner { + _setNotaryService(notary_); + } + + function _setNotaryService(INotaryService notary_) private { + if (address(notary_) == address(0)) revert ZeroAddress(); + _s().notary = notary_; + emit NotaryServiceChanged(address(notary_)); + } + + /// @notice What one rotation costs: the Notary Fee, forwarded whole. + function quoteRotation() external view returns (uint256) { + return _s().notary.fee(); + } + + // ─── Rotation ─────────────────────────────────────────────────── + + /// @notice Apply a notarized reading of Google's JWKS. Anyone may call it. + /// The reading is the whole authorization: a keeper needs gas and + /// the Notary Fee, and nothing else -- no role, no allowlist, no + /// owner anywhere in the path. + /// + /// @param attestedData The exact bytes of ceremony-common section 9.1. + /// @param proof The notary's authentication of them, opaque here. + function rotate(bytes calldata attestedData, bytes calldata proof) external payable { + GoogleJwtRootsStorage storage $ = _s(); + + // Exact value, so there is no refund path to get wrong and no value + // can be captured in transit. + uint256 fee = $.notary.fee(); + if (msg.value != fee) revert WrongValue(fee, msg.value); + + // Authenticated and decoded in one call. The Notary Service owns the + // format its key vouches for; everything read below is a field it + // handed back, never a byte this contract decoded on its own. + CeremonyAttestation.AttestedData memory data = $.notary.verify{value: fee}(attestedData, proof); + + // The one thing in the record the notary observed rather than was + // told: the TLS server name it authenticated in the handshake. + if (data.authorityId != AUTHORITY) revert WrongAuthority(AUTHORITY, data.authorityId); + + uint64 createdAt = data.createdAt; + if (createdAt > block.timestamp + CLOCK_SKEW_GRACE) revert FutureProof(); + if (block.timestamp > createdAt + FRESHNESS_WINDOW) revert StaleProof(); + + _requireJwksRequest(data.sent, data.sentTranscriptLength); + bytes memory body = _jwksBody(data.received, data.recvTranscriptLength); + + // The whole set is read before any of it is written: a reading is + // applied entire or refused entire, never half. + (string[] memory kids, bytes32[] memory moduli) = _readKeys(body); + _apply(createdAt, kids, moduli); + } + + // ─── What a verifier, and a keeper, read ──────────────────────── + + /// @notice modulusHash -> when it stops being trusted, or zero. What + /// `GooglePlatformVerifier` reads: the JWT circuit does not expose + /// `kid`, so trust has to be checkable from the modulus alone. + /// @dev A key in both generations reports the current one's stamp: the + /// later reading is the later word that Google still lists it. + function trustedHashExpiresAt(bytes32 modulusHash) external view returns (uint256) { + GoogleJwtRootsStorage storage $ = _s(); + if (_lists($.current.moduli, modulusHash)) return uint256($.current.observedAt) + READING_LIFETIME; + if (_lists($.previous.moduli, modulusHash)) return uint256($.previous.observedAt) + READING_LIFETIME; + return 0; + } + + /// @notice Both generations, as stored. One call tells a keeper the whole + /// state of the list; an empty `current` is a list nothing has + /// been read into yet. + function currentKeys() external view returns (Generation memory current, Generation memory previous) { + GoogleJwtRootsStorage storage $ = _s(); + return ($.current, $.previous); + } + + /// @notice The `createdAt` of the reading in force -- the notary's clock, + /// not the block it landed in. A keeper compares this to + /// wall-clock to decide whether its reading would be news. + function freshestObservedAt() external view returns (uint256) { + return _s().current.observedAt; + } + + /// @notice True when the current generation is not guaranteed trusted + /// RENEWAL_MARGIN from now -- the single bit a keeper polls to + /// decide whether to fetch a fresh reading. True from deployment + /// until the first rotation lands. + function needsRotation() external view returns (bool) { + uint64 observedAt = _s().current.observedAt; + // Nothing has been read yet: true by definition, not by arithmetic on + // a zero stamp that happens to come out right on a live chain. + if (observedAt == 0) return true; + return block.timestamp + RENEWAL_MARGIN >= uint256(observedAt) + READING_LIFETIME; + } + + // ─── The transcript ───────────────────────────────────────────── + + /// @dev The whole of one direction, joined. Three checks make the join + /// safe: the direction tiles the signed length, it hides nothing, and + /// its first range begins at the origin. Together they say every + /// transcript byte is in the result exactly once, at the offset the + /// notary signed it at -- so unlike a Platform Verifier reading around + /// a commitment, this contract may read the concatenation as the + /// document that crossed the wire. + function _wholeDirection(CeremonyAttestation.DirectionBlock memory block_, uint32 length) + private + pure + returns (bytes memory) + { + CeremonyAttestation.requireExactCoverage(block_, length); + if (block_.commitments.length != 0) revert HiddenBytes(); + // Unreachable once the two checks above pass with a nonempty + // transcript, and named for the empty one: no ranges pass coverage + // against a zero signed length, and an out-of-bounds panic would tell + // an operator nothing. + if (block_.revealed.length == 0) revert RequestLineNotAtOrigin(type(uint32).max); + if (block_.revealed[0].start != 0) revert RequestLineNotAtOrigin(block_.revealed[0].start); + return CeremonyAttestation.concatRevealed(block_); + } + + /// @dev The request must be the keeper's, to Google's JWKS endpoint, on + /// Google's own host. + /// + /// The `Host` count is the whole of the virtual-host pin, so it gets + /// every guard the bearer-header count gets, in the same order. The + /// notary vouches that these bytes crossed a TLS session to + /// www.googleapis.com and nothing about their HTTP shape; the prover + /// chose them. Google's front end honours a `Host` line that only a + /// bare LF separates from the one before it, and routes to that + /// backend -- so a count that anchors on CRLF must first insist the + /// request has no other kind of line ending. + function _requireJwksRequest(CeremonyAttestation.DirectionBlock memory block_, uint32 length) private pure { + bytes memory sent = _wholeDirection(block_, length); + if (!_startsWith(sent, REQUEST_LINE)) revert WrongRequestLine(); + CeremonyAttestation.requireCrlfLineEndings(sent); + + // Only the head names a host. A `Host` after the blank line is body + // bytes to the server, whatever the count would make of it, so the + // count stops where the server stops reading headers. The head keeps + // the CRLF that closes its last line, so a line-anchored needle + // matches that line too. + uint256 boundary = _indexOf(sent, HEAD_BOUNDARY, 0); + if (boundary == type(uint256).max) revert NoHeadBoundary(); + bytes memory head = _slice(sent, 0, boundary + 2); + + // Counted over normalized bytes, so a second `Host` cannot hide + // behind case or whitespace (REQ-COMMON-39's rule, applied to a + // different header). Exactly one: with two, which the backend + // honoured is the backend's business, not something this contract + // can know. + bytes memory normalized = CeremonyAttestation.normalizeHeaderBytes(head); + (uint256 count, uint256 at) = _find(normalized, HOST_NEEDLE); + if (count != 1) revert NotOneHostHeader(count); + + uint256 from = at + HOST_NEEDLE.length; + uint256 to = _indexOf(normalized, "\r\n", from); + if (to == type(uint256).max || to - from != EXPECTED_HOST.length) revert WrongHost(); + for (uint256 i = 0; i < EXPECTED_HOST.length; ++i) { + if (normalized[from + i] != EXPECTED_HOST[i]) revert WrongHost(); + } + } + + /// @dev The response body, located by the framing the server itself + /// wrote: the head boundary first, then whichever of chunked or + /// `Content-Length` the head declares. + /// + /// The FIRST head boundary, not the only one. A chunked body ends in + /// `0\r\n\r\n`, so uniqueness -- which the token request demands -- + /// would refuse every real Google response. + function _jwksBody(CeremonyAttestation.DirectionBlock memory block_, uint32 length) + private + pure + returns (bytes memory body) + { + bytes memory received = _wholeDirection(block_, length); + if (!_startsWith(received, STATUS_LINE)) revert WrongStatusLine(); + + uint256 boundary = _indexOf(received, HEAD_BOUNDARY, 0); + if (boundary == type(uint256).max) revert NoHeadBoundary(); + // The head keeps the CRLF that closes its last line, so a line-anchored + // needle matches that line too. + bytes memory head = _slice(received, 0, boundary + 2); + body = _slice(received, boundary + 4, received.length); + + bytes memory normalized = CeremonyAttestation.normalizeHeaderBytes(head); + // The body must be the document, not a compressed form of it. The + // prover sends no `Accept-Encoding`; a server that compresses anyway + // is refused by name rather than somewhere in the JSON walk. + (uint256 encodings,) = _find(normalized, CONTENT_ENCODING_NEEDLE); + if (encodings != 0) revert EncodedBody(); + + (uint256 codings,) = _find(normalized, TRANSFER_ENCODING_NEEDLE); + (uint256 lengths, uint256 at) = _find(normalized, CONTENT_LENGTH_NEEDLE); + // Exactly one framing header, of one kind. Two of either, or one of + // each, is a message two parsers could frame two ways -- the + // request-smuggling shape -- and a field read from revealed bytes is + // refused when its delimiter matches twice, not read from whichever + // copy the server happened to honour. + if (codings > 1) revert DuplicateFramingHeader("transfer-encoding", codings); + if (lengths > 1) revert DuplicateFramingHeader("content-length", lengths); + if (codings != 0 && lengths != 0) revert ConflictingFraming(); + + if (codings != 0) { + // The one coding this reads. Any other frames a body this + // contract cannot locate. + (uint256 chunked,) = _find(normalized, CHUNKED_NEEDLE); + if (chunked != 1) revert BadChunkFraming(); + return _dechunk(body); + } + if (lengths != 0) { + uint256 declared = _decimal(normalized, at + CONTENT_LENGTH_NEEDLE.length, body.length); + if (declared != body.length) revert ContentLengthMismatch(declared, body.length); + } + // Neither: `connection: close` delimits, and the body is what follows + // the head. + } + + /// @dev A decimal run ending at CRLF. Anything else is a length no body + /// can match, and is reported as such rather than parsed leniently. + function _decimal(bytes memory data, uint256 at, uint256 found) private pure returns (uint256 value) { + uint256 digits; + for (; at < data.length; ++at) { + bytes1 c = data[at]; + if (c == 0x0d) break; + if (c < 0x30 || c > 0x39) revert ContentLengthMismatch(value, found); + // A transcript length is a u32; ten digits already exceed it. + if (++digits > 10) revert ContentLengthMismatch(value, found); + value = value * 10 + (uint8(c) - 0x30); + } + } + + /// @dev Strict chunked transfer coding: a hex size of one to eight digits, + /// CRLF, that many bytes, CRLF; a zero size ends it and must be + /// followed by CRLF and nothing more. No extensions, no trailers. + /// Every iteration consumes at least five bytes, so the loop is + /// bounded by the body. + function _dechunk(bytes memory chunked) private pure returns (bytes memory out) { + out = new bytes(chunked.length); + uint256 n; + uint256 at; + while (true) { + uint256 size; + uint256 digits; + while (at < chunked.length) { + uint256 nibble = _hexNibble(chunked[at]); + if (nibble == type(uint256).max) break; + if (++digits > 8) revert BadChunkFraming(); + size = (size << 4) | nibble; + ++at; + } + // Covers a missing size, a chunk extension (`;`) and any other + // byte where CRLF belongs. + if (digits == 0 || !_crlfAt(chunked, at)) revert BadChunkFraming(); + at += 2; + + if (size == 0) { + if (chunked.length - at != 2 || !_crlfAt(chunked, at)) revert BadChunkFraming(); + break; + } + if (at + size + 2 > chunked.length) revert BadChunkFraming(); + for (uint256 i = 0; i < size; ++i) { + out[n++] = chunked[at + i]; + } + at += size; + if (!_crlfAt(chunked, at)) revert BadChunkFraming(); + at += 2; + } + assembly ("memory-safe") { + mstore(out, n) + } + } + + // ─── The key set ──────────────────────────────────────────────── + + /// @dev Read every JWK out of the body, writing nothing. The body is + /// Google's pretty-printed JSON; this reads exactly the shape Google + /// publishes and refuses anything it does not recognise, so it is a + /// checker for one document rather than a JSON parser. + /// + /// `keys` is located by counting: the token appears once in the + /// whole body, or the document is not the one this reads. Objects are + /// flat -- a `{` or `[` inside one is refused -- so each ends at its + /// first `}` and the walk is one forward pass bounded by the body. + /// Past MAX_KEYS the walk only counts, so the refusal names how many + /// keys the reading carries rather than the first one too many. + function _readKeys(bytes memory body) private pure returns (string[] memory kids, bytes32[] memory moduli) { + (uint256 count, uint256 at) = _find(body, KEYS_TOKEN); + if (count == 0) revert MissingMember("keys"); + if (count > 1) revert AmbiguousMember("keys"); + + at = _ws(body, at + KEYS_TOKEN.length); + if (at >= body.length || body[at] != ":") revert MissingMember("keys"); + at = _ws(body, at + 1); + if (at >= body.length || body[at] != "[") revert MissingMember("keys"); + ++at; + + kids = new string[](MAX_KEYS); + moduli = new bytes32[](MAX_KEYS); + uint256 keys; + while (true) { + at = _ws(body, at); + if (at >= body.length) revert UnterminatedMember("keys"); + if (body[at] == "]") { + ++at; + break; + } + if (keys != 0) { + if (body[at] != ",") revert UnterminatedMember("keys"); + at = _ws(body, at + 1); + if (at >= body.length) revert UnterminatedMember("keys"); + } + if (body[at] != "{") revert UnterminatedMember("keys"); + + uint256 close = _objectEnd(body, at); + if (keys < MAX_KEYS) (kids[keys], moduli[keys]) = _readKey(_slice(body, at, close + 1)); + ++keys; + at = close + 1; + } + + if (keys == 0) revert EmptyKeySet(); + if (keys > MAX_KEYS) revert TooManyKeys(keys); + + at = _ws(body, at); + if (at >= body.length || body[at] != "}") revert TrailingBytes(); + if (_ws(body, at + 1) != body.length) revert TrailingBytes(); + + assembly ("memory-safe") { + mstore(kids, keys) + mstore(moduli, keys) + } + + // A set, so a repeat is refused rather than counted twice. Bounded by + // MAX_KEYS squared. + for (uint256 i = 1; i < keys; ++i) { + for (uint256 j = 0; j < i; ++j) { + if (moduli[i] == moduli[j]) revert DuplicateKey(moduli[i]); + } + } + } + + /// @dev The index of the `}` closing the object opened at `open`. + function _objectEnd(bytes memory body, uint256 open) private pure returns (uint256 at) { + for (at = open + 1; at < body.length; ++at) { + bytes1 c = body[at]; + if (c == "}") return at; + if (c == "{" || c == "[") revert UnexpectedNesting(); + } + revert UnterminatedMember("keys"); + } + + /// @dev One JWK: its kid, and the limb hash of its modulus. + /// + /// The exponent is read and pinned, not stored. The `google/v1` + /// circuit verifies RS256 with the public exponent fixed at 65537, + /// and the verifier trusts a key by its modulus alone -- so a key + /// Google published under another exponent would be trusted for a + /// signature the circuit does not check. Google has only ever + /// published `AQAB` here; anything else is refused by name. + function _readKey(bytes memory jwk) private pure returns (string memory kid, bytes32 modulusHash) { + kid = string(_member(jwk, "kid")); + bytes memory nB64url = _member(jwk, "n"); + bytes memory exponent = _member(jwk, "e"); + if (keccak256(exponent) != keccak256(EXPECTED_EXPONENT)) revert WrongExponent(exponent); + + bytes memory rawN = _b64urlDecode(nB64url); + if (rawN.length != MODULUS_BYTES) revert InvalidModulusLength(); + modulusHash = _modulusHash(rawN); + } + + /// @dev One string member of a flat object. The token `"name"` appears + /// exactly once, then `:` and an opening quote with whitespace either + /// side (Google writes a space after the colon), and the value runs + /// to the next quote. A backslash is refused rather than unescaped. + function _member(bytes memory jwk, string memory name) private pure returns (bytes memory) { + bytes memory token = abi.encodePacked('"', name, '"'); + (uint256 count, uint256 at) = _find(jwk, token); + if (count == 0) revert MissingMember(name); + if (count > 1) revert AmbiguousMember(name); + + at = _ws(jwk, at + token.length); + if (at >= jwk.length || jwk[at] != ":") revert MissingMember(name); + at = _ws(jwk, at + 1); + if (at >= jwk.length || jwk[at] != '"') revert MissingMember(name); + ++at; + + uint256 start = at; + for (; at < jwk.length; ++at) { + bytes1 c = jwk[at]; + if (c == '"') return _slice(jwk, start, at); + if (c == "\\") revert EscapedValue(name); + } + revert UnterminatedMember(name); + } + + /// @dev The hash the Google circuit exposes: keccak over the eighteen + /// 120-bit limbs as 32-byte words. The keeper's `decision::modulus_hash` + /// computes the same value, and a test pins one vector across both. + function _modulusHash(bytes memory rawN) private pure returns (bytes32) { + bytes32[18] memory limbs = _splitLimbs(rawN); + bytes memory packed = new bytes(MODULUS_LIMBS * 32); + for (uint256 i = 0; i < MODULUS_LIMBS; ++i) { + bytes32 v = limbs[i]; + assembly { + mstore(add(add(packed, 32), mul(i, 32)), v) + } + } + return keccak256(packed); + } + + /// @dev Apply a set the notary read at `observedAt`. Three outcomes, and + /// the notary's clock alone decides the first. + /// + /// A reading dated no later than the one in force is refused, + /// `NotNewer`. Rotation is open and a reading binds no contract, so + /// anyone can replay any still-fresh reading anywhere, forever; the + /// refusal keeps an older reading from rolling the set back and a + /// replay from re-stamping the lifetime. A front-runner landing the + /// keeper's own reading first consumes nothing -- that reading IS + /// the keeper's, applied -- so the keeper's transaction has nothing + /// left to do, and the revert says so while rolling the Notary Fee + /// back with the state. No later includes equal: a second reading dated the same second + /// cannot swap the set -- the rule for equal evidence everywhere in + /// the protocol (REQ-COMMON-25A). The freshness checks in `rotate` + /// run first, so a stale or future record is named as such. + /// + /// A newer reading of the same set is Google saying the set still + /// stands: its lifetime restarts from this reading, nothing shifts, + /// `previous` stays as it was. A newer reading of a different set is + /// a rotation: what was current is kept one generation for the tokens + /// still in flight under it, and what was previous is gone. + function _apply(uint64 observedAt, string[] memory kids, bytes32[] memory moduli) private { + GoogleJwtRootsStorage storage $ = _s(); + if (observedAt <= $.current.observedAt) revert NotNewer(observedAt, $.current.observedAt); + + if (_sameSet($.current.moduli, moduli)) { + $.current.observedAt = observedAt; + emit ReadingRefreshed(observedAt); + return; + } + + $.previous.observedAt = $.current.observedAt; + $.previous.moduli = $.current.moduli; + $.current.observedAt = observedAt; + $.current.moduli = moduli; + emit KeysRotated(observedAt, kids, moduli); + } + + /// @dev Whether `stored` and `read` list the same moduli, in any order. + /// Both are sets -- a reading with a repeat is refused before it gets + /// here, and every stored generation was once a reading -- so equal + /// length plus every read modulus present is equality. Bounded by + /// MAX_KEYS squared. + function _sameSet(bytes32[] storage stored, bytes32[] memory read) private view returns (bool) { + if (stored.length != read.length) return false; + for (uint256 i = 0; i < read.length; ++i) { + if (!_lists(stored, read[i])) return false; + } + return true; + } + + function _lists(bytes32[] storage moduli, bytes32 modulusHash) private view returns (bool) { + uint256 n = moduli.length; + for (uint256 i = 0; i < n; ++i) { + if (moduli[i] == modulusHash) return true; + } + return false; + } + + // ─── Bytes ────────────────────────────────────────────────────── + + /// @dev How many times `needle` occurs in `haystack`, and where the first + /// occurrence begins. Overlapping occurrences count, which can only + /// over-count -- the direction that fails closed. + function _find(bytes memory haystack, bytes memory needle) private pure returns (uint256 count, uint256 first) { + first = type(uint256).max; + for (uint256 i = 0; i + needle.length <= haystack.length; ++i) { + bool hit = true; + for (uint256 j = 0; j < needle.length; ++j) { + if (haystack[i + j] != needle[j]) { + hit = false; + break; + } + } + if (hit) { + if (count == 0) first = i; + ++count; + } + } + } + + /// @dev The first occurrence of `needle` at or after `from`, or + /// `type(uint256).max`. + function _indexOf(bytes memory haystack, bytes memory needle, uint256 from) private pure returns (uint256) { + for (uint256 i = from; i + needle.length <= haystack.length; ++i) { + bool hit = true; + for (uint256 j = 0; j < needle.length; ++j) { + if (haystack[i + j] != needle[j]) { + hit = false; + break; + } + } + if (hit) return i; + } + return type(uint256).max; + } + + function _startsWith(bytes memory data, bytes memory prefix) private pure returns (bool) { + if (data.length < prefix.length) return false; + for (uint256 i = 0; i < prefix.length; ++i) { + if (data[i] != prefix[i]) return false; + } + return true; + } + + function _slice(bytes memory data, uint256 from, uint256 to) private pure returns (bytes memory out) { + out = new bytes(to - from); + for (uint256 i = 0; i < out.length; ++i) { + out[i] = data[from + i]; + } + } + + /// @dev Skip JSON whitespace: SP, HTAB, CR, LF. + function _ws(bytes memory data, uint256 at) private pure returns (uint256) { + while (at < data.length) { + bytes1 c = data[at]; + if (c != 0x20 && c != 0x09 && c != 0x0d && c != 0x0a) break; + ++at; + } + return at; + } + + function _crlfAt(bytes memory data, uint256 at) private pure returns (bool) { + return at + 2 <= data.length && data[at] == 0x0d && data[at + 1] == 0x0a; + } + + /// @dev The value of a hex digit, or `type(uint256).max` for anything else. + function _hexNibble(bytes1 c) private pure returns (uint256) { + uint8 v = uint8(c); + if (v >= 0x30 && v <= 0x39) return v - 0x30; + if (v >= 0x41 && v <= 0x46) return v - 0x41 + 10; + if (v >= 0x61 && v <= 0x66) return v - 0x61 + 10; + return type(uint256).max; + } + + function _b64urlDecode(bytes memory input) internal pure returns (bytes memory) { + uint256 len = input.length; + while (len > 0 && uint8(input[len - 1]) == 0x3d) len--; + uint256 fullChunks = len / 4; + uint256 tail = len % 4; + if (tail == 1) revert InvalidB64Length(); + uint256 outLen = fullChunks * 3 + (tail == 0 ? 0 : tail - 1); + bytes memory out = new bytes(outLen); + uint256 outIdx = 0; + uint256 inIdx = 0; + for (uint256 i = 0; i < fullChunks; i++) { + uint256 b0 = _b64char(input[inIdx]); + uint256 b1 = _b64char(input[inIdx + 1]); + uint256 b2 = _b64char(input[inIdx + 2]); + uint256 b3 = _b64char(input[inIdx + 3]); + inIdx += 4; + out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); + out[outIdx + 1] = bytes1(uint8(((b1 & 0xf) << 4) | (b2 >> 2))); + out[outIdx + 2] = bytes1(uint8(((b2 & 0x3) << 6) | b3)); + outIdx += 3; + } + if (tail == 2) { + uint256 b0 = _b64char(input[inIdx]); + uint256 b1 = _b64char(input[inIdx + 1]); + out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); + } else if (tail == 3) { + uint256 b0 = _b64char(input[inIdx]); + uint256 b1 = _b64char(input[inIdx + 1]); + uint256 b2 = _b64char(input[inIdx + 2]); + out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); + out[outIdx + 1] = bytes1(uint8(((b1 & 0xf) << 4) | (b2 >> 2))); + } + return out; + } + + function _b64char(bytes1 c) internal pure returns (uint256) { + uint8 v = uint8(c); + if (v >= 65 && v <= 90) return v - 65; + if (v >= 97 && v <= 122) return v - 71; + if (v >= 48 && v <= 57) return uint256(v) + 4; + if (v == 45) return 62; + if (v == 95) return 63; + revert InvalidB64Char(); + } + + /// @dev Eighteen little-endian 120-bit limbs of a 2048-bit big-endian + /// modulus, the way the Google circuit takes its public inputs. Kept + /// verbatim: the keeper and the circuit compute the same split. + function _splitLimbs(bytes memory be) internal pure returns (bytes32[18] memory limbs) { + uint256[8] memory words; + assembly { + let beOffset := add(be, 32) + for { let i := 0 } lt(i, 8) { i := add(i, 1) } { + mstore(add(words, mul(i, 32)), mload(add(beOffset, mul(i, 32)))) + } + } + for (uint256 k = 0; k < 18; k++) { + uint256 startBit = k * 120; + uint256 endBit = startBit + 120; + if (endBit > 2048) endBit = 2048; + uint256 width = endBit - startBit; + uint256 startWord = startBit / 256; + uint256 endWord = (endBit - 1) / 256; + uint256 lv; + if (startWord == endWord) { + uint256 w = words[7 - startWord]; + lv = (w >> (startBit % 256)) & ((uint256(1) << width) - 1); + } else { + uint256 lowWord = words[7 - startWord]; + uint256 highWord = words[7 - endWord]; + uint256 lowShift = startBit % 256; + uint256 lowBits = 256 - lowShift; + uint256 highBits = width - lowBits; + uint256 lowVal = lowWord >> lowShift; + uint256 highMask = (uint256(1) << highBits) - 1; + uint256 highVal = (highWord & highMask) << lowBits; + lv = lowVal | highVal; + } + limbs[k] = bytes32(lv); + } + } + + /// @dev Required by UUPS -- only the owner can upgrade. + function _authorizeUpgrade(address) internal override onlyOwner {} + + /// @dev Renouncing would freeze the Notary Service pointer, and Google's + /// keys would expire with no way to replace them. + function renounceOwnership() public pure override { + revert("renounce disabled"); + } +} diff --git a/solidity/contracts/ceremony/GooglePlatformVerifier.sol b/solidity/contracts/ceremony/GooglePlatformVerifier.sol index 29a1b889..02c01f8c 100644 --- a/solidity/contracts/ceremony/GooglePlatformVerifier.sol +++ b/solidity/contracts/ceremony/GooglePlatformVerifier.sol @@ -7,8 +7,9 @@ import {INotaryService} from "./INotaryService.sol"; import {IPlatformVerifier} from "./IPlatformVerifier.sol"; import {IHonkVerifier, PlatformVerifierBase} from "./PlatformVerifierBase.sol"; -/// @dev Where the deployment keeps the Google signing keys it trusts. -interface IJwksRoots { +/// @dev Where the deployment keeps the Google signing keys it trusts: +/// `GoogleJwtRoots`, kept beside this verifier. +interface IGoogleJwtRoots { function trustedHashExpiresAt(bytes32 modulusHash) external view returns (uint256); } @@ -84,7 +85,7 @@ contract GooglePlatformVerifier is IPlatformVerifier, PlatformVerifierBase { /// @custom:storage-location erc7201:libid.storage.GooglePlatformVerifier struct GoogleStorage { - IJwksRoots jwksRoots; + IGoogleJwtRoots jwtRoots; } // keccak256(abi.encode(uint256(keccak256("libid.storage.GooglePlatformVerifier")) - 1)) & ~bytes32(uint256(0xff)) @@ -96,7 +97,7 @@ contract GooglePlatformVerifier is IPlatformVerifier, PlatformVerifierBase { } } - event JwksRootsChanged(address roots); + event JwtRootsChanged(address roots); error WrongPublicInputCount(uint256 expected, uint256 provided); error DigestMismatch(bytes32 proved, bytes32 recomputed); @@ -130,7 +131,7 @@ contract GooglePlatformVerifier is IPlatformVerifier, PlatformVerifierBase { IHonkVerifier honkVerifier_, bytes32 honkVerifierCodehash_, uint64 futureObservationAllowance_, - IJwksRoots jwksRoots_ + IGoogleJwtRoots jwtRoots_ ) external initializer { // No attestation, so no lifetime and no attestation skew: the signed // `exp` is the whole validity ceiling. The allowance is real though -- @@ -140,23 +141,23 @@ contract GooglePlatformVerifier is IPlatformVerifier, PlatformVerifierBase { __PlatformVerifierBase_init( owner_, notary_, honkVerifier_, honkVerifierCodehash_, 0, 0, futureObservationAllowance_ ); - _setJwksRoots(jwksRoots_); + _setJwtRoots(jwtRoots_); } - function jwksRoots() external view returns (address) { - return address(_g().jwksRoots); + function jwtRoots() external view returns (address) { + return address(_g().jwtRoots); } /// @dev Google rotates signing keys weekly, and every ceremony fails closed /// while an active modulus is untrusted (REQ-PLAT-24). - function setJwksRoots(IJwksRoots roots) external onlyOwner { - _setJwksRoots(roots); + function setJwtRoots(IGoogleJwtRoots roots) external onlyOwner { + _setJwtRoots(roots); } - function _setJwksRoots(IJwksRoots roots) private { + function _setJwtRoots(IGoogleJwtRoots roots) private { if (address(roots) == address(0)) revert ZeroAddress(); - _g().jwksRoots = roots; - emit JwksRootsChanged(address(roots)); + _g().jwtRoots = roots; + emit JwtRootsChanged(address(roots)); } function _platform() internal pure override returns (bytes32) { @@ -314,7 +315,7 @@ contract GooglePlatformVerifier is IPlatformVerifier, PlatformVerifierBase { } } bytes32 modulusHash = keccak256(packed); - uint256 expiresAt = _g().jwksRoots.trustedHashExpiresAt(modulusHash); + uint256 expiresAt = _g().jwtRoots.trustedHashExpiresAt(modulusHash); if (expiresAt == 0 || block.timestamp >= expiresAt) revert UntrustedModulus(modulusHash); } } diff --git a/solidity/contracts/ceremony/test/CeremonyProfile.t.sol b/solidity/contracts/ceremony/test/CeremonyProfile.t.sol index 6952bbdf..01942d13 100644 --- a/solidity/contracts/ceremony/test/CeremonyProfile.t.sol +++ b/solidity/contracts/ceremony/test/CeremonyProfile.t.sol @@ -60,7 +60,7 @@ contract CeremonyProfileTest is Test { } /// @dev The pinned fixture in CeremonyAttestation.t.sol opens with the - /// format tag, the X platform id and the identity session tag. If + /// X API authority id and nothing else names a platform. If /// these constants and that fixture ever drift apart, one of them is /// wrong. function test_theAttestationFixtureUsesTheseConstants() public pure { diff --git a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol index ed27bd00..ed0bfd41 100644 --- a/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GitHubPlatformVerifier.t.sol @@ -395,7 +395,7 @@ contract GitHubPlatformVerifierTest is Test { this.run{value: quote}(s); } - /// REQ-PLAT-52B: mirror of XPlatformVerifier.t.sol:412 test_provesAgainstTheCommitmentsTheNotarySigned + /// REQ-PLAT-52B: mirror of XPlatformVerifier.t.sol:403 test_provesAgainstTheCommitmentsTheNotarySigned /// using this file's TOKEN_COMMITMENT / IDENTITY_COMMITMENT (verified passing as test_P25_…). function test_provesAgainstTheCommitmentsTheNotarySigned() public { /* copy of the X test */ } } diff --git a/solidity/contracts/ceremony/test/GoogleJwtRoots.t.sol b/solidity/contracts/ceremony/test/GoogleJwtRoots.t.sol new file mode 100644 index 00000000..f975400b --- /dev/null +++ b/solidity/contracts/ceremony/test/GoogleJwtRoots.t.sol @@ -0,0 +1,1319 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test, Vm} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +import {AttestationBuilder} from "./AttestationBuilder.sol"; +import {CeremonyAttestation} from "../CeremonyAttestation.sol"; +import {INotaryService} from "../INotaryService.sol"; +import {NotaryService} from "../NotaryService.sol"; +import {GoogleJwtRoots} from "../GoogleJwtRoots.sol"; + +/// `_splitLimbs` is internal so the hash a reading will write can be computed +/// test-side, independent of the list's state: a key's hash is asserted from +/// its bytes, never read back from the contract that wrote it. +contract LimbHasher is GoogleJwtRoots { + function hashOf(bytes memory modulus) external pure returns (bytes32) { + return keccak256(abi.encodePacked(_splitLimbs(modulus))); + } + + function hashOfB64url(string memory modulus) external pure returns (bytes32) { + return keccak256(abi.encodePacked(_splitLimbs(_b64urlDecode(bytes(modulus))))); + } +} + +/// The `google/v1` profile's trust list, fed by a notarized reading of +/// Google's JWKS endpoint. +/// +/// The reading is an ordinary section 9.1 session: built with the same +/// builder every Platform Verifier test uses, signed at test time by the anvil +/// key, and authenticated by a real NotaryService behind a proxy charging a +/// real fee. Nothing here mocks the thing under test. Google's real body rides +/// along as a fixture, so the parser is tested against the document it will +/// read rather than a shape a test author remembered. +contract GoogleJwtRootsTest is Test { + GoogleJwtRoots roots; + NotaryService notary; + LimbHasher hasher; + + address constant OWNER = address(0xA11CE); + address keeper = makeAddr("keeper"); + /// The anvil key. + uint256 constant NOTARY_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + uint256 constant FEE = 0.001 ether; + uint64 constant T0 = 1_770_000_000; + bytes32 constant AUTHORITY = keccak256("www.googleapis.com"); + + /// Google's real body, fetched 2026-09-03: pretty-printed, two keys. + string constant GOOGLE_JWKS = "contracts/ceremony/test/fixtures/google-jwks.json"; + string constant GOOGLE_KID_1 = "943a3a5d7d919625a454e489b75c29adab57acba"; + string constant GOOGLE_KID_2 = "f10f87405a979c1df36df26606734f33cd85c271"; + + /// The keeper's limb-hash vector. `decision::modulus_hash` in the keeper + /// and `_splitLimbs` here must agree on this one, or a key the keeper + /// reports as trusted is one the verifier refuses. + string constant KEEPER_MODULUS = + "BAsSGSAnLjU8Q0pRWF9mbXR7gomQl56lrLO6wcjP1t3k6_L5BQwTGiEoLzY9REtSWWBnbnV8g4qRmJ-mrbS7wsnQ197l7PP6Bg0UGyIpMDc-RUxTWmFob3Z9hIuSmaCnrrW8w8rR2N_m7fT7Bw4VHCMqMTg_Rk1UW2JpcHd-hYyTmqGor7a9xMvS2eDn7vUBCA8WHSQrMjlAR05VXGNqcXh_ho2Um6KpsLe-xczT2uHo7_YCCRAXHiUsMzpBSE9WXWRrcnmAh46VnKOqsbi_xs3U2-Lp8PcDChEYHyYtNDtCSVBXXmVsc3qBiI-WnaSrsrnAx87V3OPq8fgECxIZIA"; + bytes32 constant KEEPER_MODULUS_HASH = 0xc5c1457ba4adf8bb84324e8595bf883639aa7ebd7aa7e7626a0b51f90e6838bc; + + /// The request the keeper's prover sends. The User-Agent value is not + /// read on chain, so the version it carries is immaterial. + bytes constant REQUEST = "GET /oauth2/v3/certs HTTP/1.1\r\n" "host: www.googleapis.com\r\n" "connection: close\r\n" + "accept: application/json\r\n" "user-agent: libid-keeper/0.1.0\r\n" "\r\n"; + bytes constant STATUS_OK = "HTTP/1.1 200 OK\r\n"; + bytes constant CONTENT_TYPE = "content-type: application/json; charset=UTF-8\r\n"; + + function setUp() public { + vm.warp(T0 + 10); + + NotaryService nImpl = new NotaryService(); + notary = NotaryService( + address( + new ERC1967Proxy( + address(nImpl), abi.encodeCall(NotaryService.initialize, (OWNER, vm.addr(NOTARY_KEY), FEE)) + ) + ) + ); + roots = _deployRoots(INotaryService(address(notary))); + hasher = new LimbHasher(); + + vm.deal(address(this), 100 ether); + vm.deal(keeper, 1 ether); + } + + function _deployRoots(INotaryService service) private returns (GoogleJwtRoots) { + GoogleJwtRoots impl = new GoogleJwtRoots(); + return GoogleJwtRoots( + address(new ERC1967Proxy(address(impl), abi.encodeCall(GoogleJwtRoots.initialize, (OWNER, service)))) + ); + } + + // ─── Building a reading ───────────────────────────────────────── + + function _signWith(uint256 key, bytes memory attested) private pure returns (bytes memory) { + bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(attested))); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, ethHash); + return abi.encodePacked(r, s, v); + } + + function _sign(bytes memory attested) private pure returns (bytes memory) { + return _signWith(NOTARY_KEY, attested); + } + + /// The JWKS layout: one revealed range covering the whole direction, + /// nothing committed. + function _whole(bytes memory transcript) private pure returns (AttestationBuilder.Direction memory) { + return AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: transcript})), + commitments: AttestationBuilder.none(), + length: uint32(transcript.length) + }); + } + + function _empty() private pure returns (AttestationBuilder.Direction memory) { + return AttestationBuilder.Direction({ + revealed: new AttestationBuilder.Range[](0), commitments: AttestationBuilder.none(), length: 0 + }); + } + + function _attest(bytes memory sent, bytes memory received, uint64 createdAt) private pure returns (bytes memory) { + return AttestationBuilder.encode(AUTHORITY, createdAt, _whole(sent), _whole(received)); + } + + /// A reading of `body`, framed by `Content-Length`, observed now. + function _reading(bytes memory body) private view returns (bytes memory) { + return _readingAt(body, _now()); + } + + function _readingAt(bytes memory body, uint64 createdAt) private pure returns (bytes memory) { + return _attest(REQUEST, _contentLength(body), createdAt); + } + + function _rotate(bytes memory attested) private { + roots.rotate{value: FEE}(attested, _sign(attested)); + } + + /// Signed BEFORE the expectation: `vm.sign` is an external call, and one + /// inside the call under test would consume the `expectRevert`. + function _refuses(bytes memory attested, bytes memory err) private { + bytes memory sig = _sign(attested); + vm.expectRevert(err); + roots.rotate{value: FEE}(attested, sig); + } + + function _refuses(bytes memory attested, bytes4 selector) private { + _refuses(attested, abi.encodeWithSelector(selector)); + } + + // ─── Framing the response ─────────────────────────────────────── + + function _contentLength(bytes memory body) private pure returns (bytes memory) { + return + abi.encodePacked( + STATUS_OK, CONTENT_TYPE, "content-length: ", vm.toString(body.length), "\r\n", "\r\n", body + ); + } + + /// Chunked transfer coding, `size` bytes per chunk, as Google serves it. + function _chunked(bytes memory body, uint256 size) private pure returns (bytes memory) { + return abi.encodePacked(STATUS_OK, CONTENT_TYPE, "transfer-encoding: chunked\r\n", "\r\n", _chunks(body, size)); + } + + function _chunks(bytes memory body, uint256 size) private pure returns (bytes memory out) { + for (uint256 at = 0; at < body.length; at += size) { + uint256 take = body.length - at < size ? body.length - at : size; + bytes memory chunk = new bytes(take); + for (uint256 i = 0; i < take; ++i) { + chunk[i] = body[at + i]; + } + out = abi.encodePacked(out, _hex(take), "\r\n", chunk, "\r\n"); + } + out = abi.encodePacked(out, "0\r\n\r\n"); + } + + function _hex(uint256 v) private pure returns (bytes memory out) { + if (v == 0) return "0"; + bytes memory digits = "0123456789abcdef"; + while (v != 0) { + out = abi.encodePacked(digits[v & 0xf], out); + v >>= 4; + } + } + + // ─── Building a key set ───────────────────────────────────────── + + /// One JWK the way Google writes them, compact. + function _jwk(string memory kid, string memory n) private pure returns (bytes memory) { + return abi.encodePacked('{"kty":"RSA","use":"sig","kid":"', kid, '","n":"', n, '","e":"AQAB","alg":"RS256"}'); + } + + function _body(bytes memory jwks) private pure returns (bytes memory) { + return abi.encodePacked('{"keys":[', jwks, "]}"); + } + + function _oneKey(string memory kid, string memory seed) private pure returns (bytes memory) { + return _body(_jwk(kid, _modulus(seed))); + } + + function _twoKeys(string memory kidA, string memory seedA, string memory kidB, string memory seedB) + private + pure + returns (bytes memory) + { + return _body(abi.encodePacked(_jwk(kidA, _modulus(seedA)), ",", _jwk(kidB, _modulus(seedB)))); + } + + /// A 2048-bit modulus derived from `seed`, base64url without padding: 256 + /// bytes encode to exactly 342 characters. Different seeds give different + /// keys, which is the only property the caller needs. + function _modulus(string memory seed) private pure returns (string memory) { + return string(_b64url(_bytes(seed, 256))); + } + + /// The limb hash the list writes for the key `seed` derives, computed + /// from the bytes rather than read back. + function _hash(string memory seed) private view returns (bytes32) { + return hasher.hashOf(_bytes(seed, 256)); + } + + function _bytes(string memory seed, uint256 count) private pure returns (bytes memory out) { + out = new bytes(count); + for (uint256 i = 0; i < count; ++i) { + if (i % 32 == 0) { + bytes32 word = keccak256(abi.encodePacked(seed, i / 32)); + for (uint256 j = 0; j < 32 && i + j < count; ++j) { + out[i + j] = word[j]; + } + } + } + } + + function _b64url(bytes memory data) private pure returns (bytes memory out) { + bytes memory alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + uint256 full = data.length / 3; + uint256 tail = data.length % 3; + out = new bytes(full * 4 + (tail == 0 ? 0 : tail + 1)); + uint256 o; + for (uint256 i = 0; i < full; ++i) { + uint256 v = (uint256(uint8(data[3 * i])) << 16) | (uint256(uint8(data[3 * i + 1])) << 8) + | uint256(uint8(data[3 * i + 2])); + out[o++] = alphabet[(v >> 18) & 63]; + out[o++] = alphabet[(v >> 12) & 63]; + out[o++] = alphabet[(v >> 6) & 63]; + out[o++] = alphabet[v & 63]; + } + if (tail == 1) { + uint256 v = uint256(uint8(data[3 * full])) << 16; + out[o++] = alphabet[(v >> 18) & 63]; + out[o++] = alphabet[(v >> 12) & 63]; + } else if (tail == 2) { + uint256 v = (uint256(uint8(data[3 * full])) << 16) | (uint256(uint8(data[3 * full + 1])) << 8); + out[o++] = alphabet[(v >> 18) & 63]; + out[o++] = alphabet[(v >> 12) & 63]; + out[o++] = alphabet[(v >> 6) & 63]; + } + } + + /// `count` keys, `kid-0` .. `kid-`, each with its own modulus + /// (seeded by its kid). + function _manyKeys(uint256 count) private pure returns (bytes memory list) { + for (uint256 i = 0; i < count; ++i) { + string memory kid = string.concat("kid-", vm.toString(i)); + list = abi.encodePacked(list, i == 0 ? "" : ",", _jwk(kid, _modulus(kid))); + } + return _body(list); + } + + /// Install one key `kid` derived from `seed`, observed now, and return the + /// hash it wrote. + function _install(string memory kid, string memory seed) private returns (bytes32) { + _rotate(_reading(_oneKey(kid, seed))); + return _hash(seed); + } + + // ─── Reading the list back ────────────────────────────────────── + + function _current() private view returns (GoogleJwtRoots.Generation memory current) { + (current,) = roots.currentKeys(); + } + + function _previous() private view returns (GoogleJwtRoots.Generation memory previous) { + (, previous) = roots.currentKeys(); + } + + function _one(bytes32 a) private pure returns (bytes32[] memory out) { + out = new bytes32[](1); + out[0] = a; + } + + function _two(bytes32 a, bytes32 b) private pure returns (bytes32[] memory out) { + out = new bytes32[](2); + out[0] = a; + out[1] = b; + } + + function _names(string memory a, string memory b) private pure returns (string[] memory out) { + out = new string[](2); + out[0] = a; + out[1] = b; + } + + function _assertGeneration( + GoogleJwtRoots.Generation memory g, + uint64 observedAt, + bytes32[] memory moduli, + string memory label + ) private pure { + assertEq(g.observedAt, observedAt, string.concat(label, ": observedAt")); + assertEq(g.moduli, moduli, string.concat(label, ": moduli")); + } + + function _assertEmpty(GoogleJwtRoots.Generation memory g, string memory label) private pure { + _assertGeneration(g, 0, new bytes32[](0), label); + } + + /// The one `KeysRotated` among the recorded logs, decoded. + function _lastRotation() private returns (uint64 observedAt, string[] memory kids, bytes32[] memory moduli) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 topic = keccak256("KeysRotated(uint64,string[],bytes32[])"); + uint256 seen; + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter != address(roots) || logs[i].topics[0] != topic) continue; + ++seen; + (observedAt, kids, moduli) = abi.decode(logs[i].data, (uint64, string[], bytes32[])); + } + assertEq(seen, 1, "one KeysRotated per rotation"); + } + + // ─── What it accepts ──────────────────────────────────────────── + + function test_aContentLengthFramedReadingInstallsTheKeys() public { + uint64 at = _now(); + _rotate(_reading(_twoKeys("kid-1", "one", "kid-2", "two"))); + + bytes32 first = _hash("one"); + bytes32 second = _hash("two"); + assertTrue(first != second, "distinct keys"); + _assertGeneration(_current(), at, _two(first, second), "current"); + _assertEmpty(_previous(), "nothing precedes the first reading"); + assertEq(roots.trustedHashExpiresAt(first), at + roots.READING_LIFETIME()); + assertEq(roots.trustedHashExpiresAt(second), at + roots.READING_LIFETIME()); + } + + /// Google serves the key set chunked. The chunk size here cuts the first + /// modulus value three times, so the de-chunker is what reassembles it; + /// the same body under `Content-Length` yields the same hash, which is the + /// framing being transparent -- the second reading refreshes the set + /// rather than rotating it. + function test_aChunkedReadingInstallsTheKeys() public { + bytes memory body = _oneKey("kid-1", "one"); + _rotate(_attest(REQUEST, _chunked(body, 100), _now())); + assertEq(_current().moduli, _one(_hash("one")), "installed through chunked framing"); + + vm.warp(block.timestamp + 1); + _rotate(_reading(body)); + assertEq(_current().moduli, _one(_hash("one")), "framing changed the key"); + _assertEmpty(_previous(), "framing changed the key"); + } + + /// The document this contract exists to read: Google's body, verbatim, + /// pretty-printed with a space after every colon, in the framing Google + /// uses. + function test_googlesRealBodyIsAccepted() public { + bytes memory body = bytes(vm.readFile(GOOGLE_JWKS)); + assertEq(string(_slice(body, 0, 13)), "{\n \"keys\": [", "the fixture is Google's pretty-printed body"); + + vm.recordLogs(); + _rotate(_attest(REQUEST, _chunked(body, 512), _now())); + + (, string[] memory kids, bytes32[] memory moduli) = _lastRotation(); + assertEq(kids, _names(GOOGLE_KID_1, GOOGLE_KID_2), "both Google kids, in Google's order"); + assertTrue(moduli[0] != moduli[1], "two distinct keys"); + assertEq(_current().moduli, moduli); + assertFalse(roots.needsRotation()); + } + + /// Cross-language pin: the keeper's `decision::modulus_hash` and the + /// Google circuit produce this value for this modulus. The test-side + /// hasher is pinned to the same vector, so every `_hash` below is too. + function test_theLimbHashMatchesTheKeeper() public { + assertEq(hasher.hashOfB64url(KEEPER_MODULUS), KEEPER_MODULUS_HASH); + _rotate(_reading(_body(_jwk("keeper", KEEPER_MODULUS)))); + assertEq(_current().moduli, _one(KEEPER_MODULUS_HASH)); + assertGt(roots.trustedHashExpiresAt(KEEPER_MODULUS_HASH), 0); + } + + /// Anyone may submit one. The reading carries its own authority -- the + /// notary signed it -- so gating the caller would only add a keeper to + /// trust. + function test_anybodyMaySubmitARotation() public { + bytes memory attested = _reading(_oneKey("kid-1", "one")); + bytes memory sig = _sign(attested); + vm.prank(keeper); + roots.rotate{value: FEE}(attested, sig); + assertEq(_current().moduli, _one(_hash("one"))); + } + + /// The Notary Fee is the one thing a rotation costs beyond gas, and it + /// accrues where every other attestation's does. + function test_theRotationPaysTheNotaryFee() public { + assertEq(roots.quoteRotation(), FEE); + _install("kid-1", "one"); + assertEq(address(notary).balance, FEE, "the fee reached the Notary Service"); + assertEq(address(roots).balance, 0, "nothing stays with the list"); + } + + // ─── Which readings count ─────────────────────────────────────── + + /// Re-applying the reading in force is refused: `NotNewer`, with the + /// reading's own clock on both sides. Nothing is written -- in particular + /// the lifetime does NOT restart, so spamming one reading for its whole + /// freshness window cannot stretch trust by a second -- and the revert + /// hands the fee back rather than charging it for nothing done. + function test_replayingTheReadingInForceIsRefused() public { + uint64 at = _now(); + bytes memory attested = _reading(_oneKey("kid-1", "one")); + _rotate(attested); + + vm.warp(block.timestamp + 30 minutes); // still inside the window + _refuses(attested, abi.encodeWithSelector(GoogleJwtRoots.NotNewer.selector, at, at)); + + assertEq(address(notary).balance, FEE, "the refused replay paid a fee"); + _assertGeneration(_current(), at, _one(_hash("one")), "current"); + _assertEmpty(_previous(), "a replay shifted the list"); + assertEq( + roots.trustedHashExpiresAt(_hash("one")), at + roots.READING_LIFETIME(), "replay restarted the lifetime" + ); + } + + /// Two readings dated the same second cannot disagree: the second is + /// refused whatever it carries, so equal evidence never swaps the set + /// and drops the keys Google still signs with. + function test_aReadingDatedTheSameSecondIsRefused() public { + uint64 at = _now(); + _rotate(_readingAt(_oneKey("kid-1", "one"), at)); + + _refuses( + _readingAt(_oneKey("kid-1", "two"), at), abi.encodeWithSelector(GoogleJwtRoots.NotNewer.selector, at, at) + ); + + _assertGeneration(_current(), at, _one(_hash("one")), "current"); + _assertEmpty(_previous(), "the same second shifted the list"); + assertEq(roots.trustedHashExpiresAt(_hash("two")), 0, "the same second installed a key"); + } + + /// The rollback that matters: a GENUINELY signed older reading, replayed + /// inside its freshness window after a newer one has landed. The signature + /// verifies; the reading's own clock is what refuses the regression, and + /// the error carries both clocks, so a keeper reading it knows which + /// reading it lost to. + function test_aGenuinelySignedOlderReadingIsRefused() public { + uint64 dated = _now() - 30 minutes; + bytes memory older = _readingAt(_oneKey("kid-1", "retired"), dated); + uint64 at = _now(); + bytes32 live = _install("kid-1", "live"); + + _refuses(older, abi.encodeWithSelector(GoogleJwtRoots.NotNewer.selector, dated, at)); + + _assertGeneration(_current(), at, _one(live), "older reading rolled the set back"); + _assertEmpty(_previous(), "older reading shifted the list"); + assertEq(roots.trustedHashExpiresAt(_hash("retired")), 0, "older reading installed a retired key"); + assertEq(roots.trustedHashExpiresAt(live), at + roots.READING_LIFETIME(), "live key lost trust"); + } + + /// The front-run shape: a stranger lands the keeper's own reading first. + /// The keeper's transaction is refused, its fee comes back with the + /// revert -- gas is all it lost -- and its next reading lands as usual. + /// Nothing was consumed: with no nullifier there is nothing a + /// front-runner can spend on the keeper's behalf. + function test_aFrontRunKeeperLosesGasOnly() public { + uint64 at = _now(); + bytes memory attested = _reading(_oneKey("kid-1", "one")); + bytes memory sig = _sign(attested); + address stranger = makeAddr("stranger"); + vm.deal(stranger, 1 ether); + vm.prank(stranger); + roots.rotate{value: FEE}(attested, sig); + + uint256 funds = keeper.balance; + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector(GoogleJwtRoots.NotNewer.selector, at, at)); + roots.rotate{value: FEE}(attested, sig); + assertEq(keeper.balance, funds, "the refused reading kept the keeper's fee"); + assertEq(address(notary).balance, FEE, "one fee reached the service: the stranger's"); + + vm.warp(at + 1 minutes); + bytes memory next = _reading(_oneKey("kid-1", "one")); + sig = _sign(next); + vm.prank(keeper); + roots.rotate{value: FEE}(next, sig); + assertEq(roots.freshestObservedAt(), at + 1 minutes, "the keeper's next reading landed"); + assertEq(address(notary).balance, 2 * FEE); + } + + /// A newer reading of the set already current is Google saying the set + /// still stands: its lifetime restarts from the new reading, nothing + /// shifts, and the order Google listed the keys in is immaterial. + function test_aNewerReadingOfTheSameSetRefreshesWithoutShifting() public { + uint64 first = _now(); + _rotate(_readingAt(_twoKeys("kid-1", "one", "kid-2", "two"), first)); + bytes32[] memory set = _two(_hash("one"), _hash("two")); + + vm.warp(first + 20 minutes); + uint64 second = first + 10 minutes; + vm.expectEmit(false, false, false, true); + emit GoogleJwtRoots.ReadingRefreshed(second); + _rotate(_readingAt(_twoKeys("kid-2", "two", "kid-1", "one"), second)); // the same set, reversed + + _assertGeneration(_current(), second, set, "refreshed in place, original order kept"); + _assertEmpty(_previous(), "a refresh shifted the list"); + assertEq(roots.trustedHashExpiresAt(_hash("one")), second + roots.READING_LIFETIME(), "lifetime restarted"); + assertEq(roots.trustedHashExpiresAt(_hash("two")), second + roots.READING_LIFETIME(), "lifetime restarted"); + assertEq(roots.freshestObservedAt(), second); + } + + /// A newer reading of a different set is a rotation: what was current is + /// kept one generation back, the reading becomes current, and the event + /// carries the reading's keys in the order Google listed them. + function test_aNewerReadingOfADifferentSetRotates() public { + uint64 first = _now(); + bytes32 old = _install("kid-a", "a"); + + vm.warp(first + 20 minutes); + uint64 second = first + 10 minutes; + bytes32[] memory incoming = _two(_hash("b"), _hash("c")); + vm.expectEmit(false, false, false, true); + emit GoogleJwtRoots.KeysRotated(second, _names("kid-b", "kid-c"), incoming); + _rotate(_readingAt(_twoKeys("kid-b", "b", "kid-c", "c"), second)); + + _assertGeneration(_current(), second, incoming, "current"); + _assertGeneration(_previous(), first, _one(old), "previous"); + assertEq(roots.trustedHashExpiresAt(old), first + roots.READING_LIFETIME(), "the old set keeps its own stamp"); + assertEq(roots.trustedHashExpiresAt(_hash("b")), second + roots.READING_LIFETIME()); + assertEq(roots.trustedHashExpiresAt(_hash("c")), second + roots.READING_LIFETIME()); + assertEq(roots.freshestObservedAt(), second); + } + + /// Two generations and no history: a third distinct set drops the oldest + /// outright, and a key only that set listed stops being trusted -- with + /// no prune, no untrust, no transaction but the rotation itself. + function test_aThirdSetDropsTheOldest() public { + uint64 first = _now(); + bytes32 a = _install("kid-a", "a"); + vm.warp(first + 1 minutes); + uint64 second = _now(); + bytes32 b = _install("kid-b", "b"); + vm.warp(second + 1 minutes); + uint64 third = _now(); + bytes32 c = _install("kid-c", "c"); + + _assertGeneration(_current(), third, _one(c), "current"); + _assertGeneration(_previous(), second, _one(b), "previous"); + assertEq(roots.trustedHashExpiresAt(a), 0, "the oldest set is still trusted"); + assertEq(roots.trustedHashExpiresAt(b), second + roots.READING_LIFETIME()); + assertEq(roots.trustedHashExpiresAt(c), third + roots.READING_LIFETIME()); + } + + /// Google's sets overlap -- a key is usually in the reading before and + /// the reading after -- and a key in both generations carries the current + /// one's stamp: the later reading is the later word that it is listed. + function test_aKeyInBothGenerationsCarriesTheCurrentStamp() public { + uint64 first = _now(); + _rotate(_readingAt(_twoKeys("kid-1", "one", "kid-2", "two"), first)); + vm.warp(first + 1 minutes); + uint64 second = _now(); + _rotate(_readingAt(_twoKeys("kid-2", "two", "kid-3", "three"), second)); + + assertEq(roots.trustedHashExpiresAt(_hash("two")), second + roots.READING_LIFETIME(), "in both: current stamp"); + assertEq(roots.trustedHashExpiresAt(_hash("one")), first + roots.READING_LIFETIME(), "previous only"); + assertEq(roots.trustedHashExpiresAt(_hash("three")), second + roots.READING_LIFETIME(), "current only"); + } + + /// Trust lapses by the reading's own clock, with no transaction from + /// anyone: the stamp is `createdAt + READING_LIFETIME` -- the notary's + /// time, not the block the rotation landed in -- and past it the + /// verifier-facing read is already in the past. + function test_trustLapsesByTheReadingsOwnClockWithNoCall() public { + uint64 observedAt = _now() - 30 minutes; + _rotate(_readingAt(_oneKey("kid-1", "one"), observedAt)); + uint256 stamp = observedAt + roots.READING_LIFETIME(); + assertEq(roots.trustedHashExpiresAt(_hash("one")), stamp, "anchored to the reading, not the block"); + assertTrue(stamp < block.timestamp + roots.READING_LIFETIME(), "block time did not stretch it"); + + vm.warp(stamp); + assertEq(roots.trustedHashExpiresAt(_hash("one")), stamp, "nothing rewrote the stamp"); + assertTrue(roots.trustedHashExpiresAt(_hash("one")) <= block.timestamp, "expired by time alone"); + assertTrue(roots.needsRotation()); + _assertGeneration(_current(), observedAt, _one(_hash("one")), "the generation is still what was read"); + } + + /// The freshest-reading mark is the current generation's clock: it only + /// ever moves forward, whether the newer reading refreshes or rotates, + /// and an older reading is refused against it. + function test_freshestObservedAtIsTheReadingInForce() public { + assertEq(roots.freshestObservedAt(), 0); + uint64 first = _now(); + _rotate(_readingAt(_oneKey("kid-1", "one"), first)); + assertEq(roots.freshestObservedAt(), first); + + _refuses( + _readingAt(_oneKey("kid-1", "one"), first - 10 minutes), + abi.encodeWithSelector(GoogleJwtRoots.NotNewer.selector, first - 10 minutes, first) + ); + assertEq(roots.freshestObservedAt(), first, "an older reading moved the mark"); + + vm.warp(first + 20 minutes); + _rotate(_readingAt(_oneKey("kid-1", "one"), first + 10 minutes)); // same set: refresh + assertEq(roots.freshestObservedAt(), first + 10 minutes, "a refresh must move the mark"); + _rotate(_readingAt(_oneKey("kid-2", "two"), first + 15 minutes)); // different set: rotation + assertEq(roots.freshestObservedAt(), first + 15 minutes, "a rotation must move the mark"); + } + + // ─── What a keeper reads ──────────────────────────────────────── + + /// Before any reading: two empty generations, a zero mark, and a list + /// that wants a rotation. + function test_currentKeysStartsEmpty() public view { + _assertEmpty(_current(), "current"); + _assertEmpty(_previous(), "previous"); + assertEq(roots.freshestObservedAt(), 0); + assertTrue(roots.needsRotation()); + } + + /// The single bit a keeper polls: true until the first rotation, false + /// while the current generation has RENEWAL_MARGIN of trusted runway, + /// true again as the runway shortens -- and long before it expires. + function test_needsRotationTracksTheTrustedRunway() public { + assertTrue(roots.needsRotation(), "an empty list needs a rotation"); + // Whatever the clock says: a fresh deployment on a chain younger than + // the lifetime must still ask for its first reading. + vm.warp(1); + assertTrue(roots.needsRotation(), "an empty list needs a rotation at any block time"); + vm.warp(T0 + 10); + + uint64 at = _now(); + _install("kid-1", "one"); + assertFalse(roots.needsRotation(), "a fresh rotation buys quiet"); + + uint256 stamp = at + roots.READING_LIFETIME(); + vm.warp(stamp - roots.RENEWAL_MARGIN() - 1); + assertFalse(roots.needsRotation(), "still a second of margin to spare"); + vm.warp(stamp - roots.RENEWAL_MARGIN()); + assertTrue(roots.needsRotation(), "the margin should trip before expiry does"); + } + + /// The rotation announces the reading's keys -- kids and limb hashes in + /// the order Google listed them -- with the reading's own timestamp, the + /// notary's clock, so a bot can follow the list from logs alone. + function test_aRotationAnnouncesTheKeysInReadingOrder() public { + uint64 provenAt = _now() - 10 minutes; + + vm.recordLogs(); + _rotate(_readingAt(_manyKeys(3), provenAt)); + + (uint64 observedAt, string[] memory kids, bytes32[] memory moduli) = _lastRotation(); + assertEq(observedAt, provenAt, "observedAt is the notary's clock"); + assertEq(kids.length, 3); + assertEq(moduli.length, 3); + for (uint256 i = 0; i < 3; ++i) { + string memory kid = string.concat("kid-", vm.toString(i)); + assertEq(kids[i], kid, "kid in reading order"); + assertEq(moduli[i], _hash(kid), "modulus in reading order"); + } + assertEq(_current().moduli, moduli, "the event is what was stored"); + } + + // ─── Administration ───────────────────────────────────────────── + + /// The Notary Service is the trust root. Pointing at another one moves + /// the whole list under that service's keys and fee. + function test_theOwnerCanPointAtAnotherNotaryService() public { + uint256 otherKey = 0xC0FFEE; + NotaryService otherImpl = new NotaryService(); + NotaryService other = NotaryService( + address( + new ERC1967Proxy( + address(otherImpl), abi.encodeCall(NotaryService.initialize, (OWNER, vm.addr(otherKey), 2 * FEE)) + ) + ) + ); + + vm.prank(OWNER); + vm.expectEmit(false, false, false, true); + emit GoogleJwtRoots.NotaryServiceChanged(address(other)); + roots.setNotaryService(INotaryService(address(other))); + + assertEq(roots.notaryService(), address(other)); + assertEq(roots.quoteRotation(), 2 * FEE); + + // The old service's key no longer vouches for anything here... + bytes memory attested = _reading(_oneKey("kid-1", "one")); + bytes memory oldSig = _sign(attested); + vm.expectRevert(abi.encodeWithSelector(NotaryService.UntrustedNotary.selector, vm.addr(NOTARY_KEY))); + roots.rotate{value: 2 * FEE}(attested, oldSig); + + // ...and the new one's does, at the new fee. + roots.rotate{value: 2 * FEE}(attested, _signWith(otherKey, attested)); + assertEq(_current().moduli, _one(_hash("one"))); + assertEq(address(other).balance, 2 * FEE); + } + + function test_onlyTheOwnerSetsTheNotaryService() public { + vm.prank(keeper); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, keeper)); + roots.setNotaryService(INotaryService(address(0xD00D))); + } + + function test_aZeroNotaryServiceIsRefused() public { + vm.prank(OWNER); + vm.expectRevert(GoogleJwtRoots.ZeroAddress.selector); + roots.setNotaryService(INotaryService(address(0))); + + GoogleJwtRoots impl = new GoogleJwtRoots(); + vm.expectRevert(GoogleJwtRoots.ZeroAddress.selector); + new ERC1967Proxy(address(impl), abi.encodeCall(GoogleJwtRoots.initialize, (OWNER, INotaryService(address(0))))); + } + + /// Renouncing would freeze the list while Google's keys keep expiring, so + /// the Google verifier would stop accepting proofs with no way back. + function test_renouncingIsDisabled() public { + vm.prank(OWNER); + vm.expectRevert(bytes("renounce disabled")); + roots.renounceOwnership(); + } + + // ─── What it refuses: the fee and the notary ──────────────────── + + /// An exact match, both ways. Underpaying is obvious; overpaying must fail + /// too, so there is no overpayment to refund and no silent overcharge. + function test_refusesAnyValueOtherThanTheFee() public { + bytes memory attested = _reading(_oneKey("kid-1", "one")); + bytes memory sig = _sign(attested); + + vm.expectRevert(abi.encodeWithSelector(GoogleJwtRoots.WrongValue.selector, FEE, FEE - 1)); + roots.rotate{value: FEE - 1}(attested, sig); + + vm.expectRevert(abi.encodeWithSelector(GoogleJwtRoots.WrongValue.selector, FEE, FEE + 1)); + roots.rotate{value: FEE + 1}(attested, sig); + + vm.expectRevert(abi.encodeWithSelector(GoogleJwtRoots.WrongValue.selector, FEE, 0)); + roots.rotate(attested, sig); + } + + /// The signature is the whole authority, so a reading the trusted notary + /// did not sign is worth nothing. Trust lives on the Notary Service, and + /// this list follows it. + function test_refusesAnUntrustedNotaryKey() public { + uint256 stranger = 0xB0B; + bytes memory attested = _reading(_oneKey("kid-1", "one")); + bytes memory sig = _signWith(stranger, attested); + vm.expectRevert(abi.encodeWithSelector(NotaryService.UntrustedNotary.selector, vm.addr(stranger))); + roots.rotate{value: FEE}(attested, sig); + } + + /// The signature is genuine and the key is trusted, but one byte of the + /// record differs -- so the digest the service derives differs and + /// recovery lands on nobody. Nothing in the record can be edited after + /// signing: not the host, not the clock, not a modulus. + function test_refusesATamperedAttestedByte() public { + bytes memory attested = _reading(_oneKey("kid-1", "one")); + bytes memory sig = _sign(attested); + attested[attested.length - 40] = bytes1(uint8(attested[attested.length - 40]) ^ 0x01); + vm.expectPartialRevert(NotaryService.UntrustedNotary.selector); + roots.rotate{value: FEE}(attested, sig); + } + + function test_refusesAMalformedSignature() public { + bytes memory attested = _reading(_oneKey("kid-1", "one")); + vm.expectRevert(NotaryService.MalformedSignature.selector); + roots.rotate{value: FEE}(attested, hex"1234"); + } + + /// A rejected rotation leaves no fee behind. + function test_aRejectedRotationDeliversNoFee() public { + bytes memory attested = _reading(_oneKey("kid-1", "one")); + vm.expectRevert(NotaryService.MalformedSignature.selector); + roots.rotate{value: FEE}(attested, hex"1234"); + assertEq(address(notary).balance, 0, "a rejection kept the fee"); + assertEq(address(roots).balance, 0); + } + + // ─── What it refuses: the session ─────────────────────────────── + + /// A reading genuinely signed for another host is refused on its merits. + /// The authority is the one field the notary observed rather than was + /// told, and it is checked before any byte of the transcript is read. + function test_refusesAnotherAuthority() public { + bytes32 other = keccak256("storage.googleapis.com"); + bytes memory attested = + AttestationBuilder.encode(other, _now(), _whole(REQUEST), _whole(_contentLength(_oneKey("kid-1", "one")))); + _refuses(attested, abi.encodeWithSelector(GoogleJwtRoots.WrongAuthority.selector, AUTHORITY, other)); + } + + function test_refusesAReadingFromTheFuture() public { + uint64 ahead = _now() + uint64(roots.CLOCK_SKEW_GRACE()) + 1; + _refuses(_readingAt(_oneKey("kid-1", "one"), ahead), GoogleJwtRoots.FutureProof.selector); + // And accepts one exactly at the grace, which is the boundary. + _rotate(_readingAt(_oneKey("kid-1", "one"), ahead - 1)); + } + + /// Past the window the reading says nothing about what Google publishes + /// now, which is the only question this list answers. + function test_refusesAStaleReading() public { + uint64 provenAt = _now(); + bytes memory attested = _readingAt(_oneKey("kid-1", "one"), provenAt); + vm.warp(provenAt + roots.FRESHNESS_WINDOW() + 1); + _refuses(attested, GoogleJwtRoots.StaleProof.selector); + } + + /// Both directions must tile the signed length. A gap is where a prover + /// hides bytes, and this contract reads the join as the document. + function test_refusesACoverageGapInTheRequest() public { + uint32 len = uint32(REQUEST.length); + AttestationBuilder.Direction memory sent = AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: _slice(REQUEST, 0, len - 1)})), + commitments: AttestationBuilder.none(), + length: len + }); + bytes memory attested = + AttestationBuilder.encode(AUTHORITY, _now(), sent, _whole(_contentLength(_oneKey("kid-1", "one")))); + _refuses(attested, abi.encodeWithSelector(CeremonyAttestation.CoverageGap.selector, len - 1, len)); + } + + function test_refusesACoverageGapInTheResponse() public { + bytes memory response = _contentLength(_oneKey("kid-1", "one")); + uint32 len = uint32(response.length); + AttestationBuilder.Direction memory received = AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 1, value: _slice(response, 1, len)})), + commitments: AttestationBuilder.none(), + length: len + }); + bytes memory attested = AttestationBuilder.encode(AUTHORITY, _now(), _whole(REQUEST), received); + _refuses(attested, abi.encodeWithSelector(CeremonyAttestation.CoverageGap.selector, 0, 1)); + } + + /// A commitment tiles like a revealed range, so coverage passes -- and the + /// bytes behind it are exactly what this contract must not read around. + function test_refusesAHiddenByteInTheRequest() public { + uint32 len = uint32(REQUEST.length); + AttestationBuilder.Direction memory sent = AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: REQUEST})), + commitments: AttestationBuilder.one( + AttestationBuilder.Commitment({start: len, end: len + 4, value: bytes32(uint256(0x7777))}) + ), + length: len + 4 + }); + bytes memory attested = + AttestationBuilder.encode(AUTHORITY, _now(), sent, _whole(_contentLength(_oneKey("kid-1", "one")))); + _refuses(attested, GoogleJwtRoots.HiddenBytes.selector); + } + + function test_refusesAHiddenByteInTheResponse() public { + bytes memory response = _contentLength(_oneKey("kid-1", "one")); + uint32 len = uint32(response.length); + AttestationBuilder.Direction memory received = AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: response})), + commitments: AttestationBuilder.one( + AttestationBuilder.Commitment({start: len, end: len + 4, value: bytes32(uint256(0x7777))}) + ), + length: len + 4 + }); + bytes memory attested = AttestationBuilder.encode(AUTHORITY, _now(), _whole(REQUEST), received); + _refuses(attested, GoogleJwtRoots.HiddenBytes.selector); + } + + /// An empty direction passes coverage against a zero signed length; it is + /// named rather than left to an out-of-bounds panic. + function test_refusesAnEmptyRequest() public { + bytes memory attested = + AttestationBuilder.encode(AUTHORITY, _now(), _empty(), _whole(_contentLength(_oneKey("kid-1", "one")))); + _refuses(attested, abi.encodeWithSelector(GoogleJwtRoots.RequestLineNotAtOrigin.selector, type(uint32).max)); + } + + function test_refusesAnEmptyResponse() public { + bytes memory attested = AttestationBuilder.encode(AUTHORITY, _now(), _whole(REQUEST), _empty()); + _refuses(attested, abi.encodeWithSelector(GoogleJwtRoots.RequestLineNotAtOrigin.selector, type(uint32).max)); + } + + // ─── What it refuses: the request ─────────────────────────────── + + function _withRequest(bytes memory request) private view returns (bytes memory) { + return _attest(request, _contentLength(_oneKey("kid-1", "one")), _now()); + } + + function test_refusesAPostRequestLine() public { + bytes memory request = + abi.encodePacked("POST /oauth2/v3/certs HTTP/1.1\r\n", _slice(REQUEST, 31, REQUEST.length)); + _refuses(_withRequest(request), GoogleJwtRoots.WrongRequestLine.selector); + } + + /// The path separates the key set from everything else the host serves. + function test_refusesAnotherPath() public { + bytes memory request = + abi.encodePacked("GET /oauth2/v3/certs2 HTTP/1.1\r\n", _slice(REQUEST, 31, REQUEST.length)); + _refuses(_withRequest(request), GoogleJwtRoots.WrongRequestLine.selector); + } + + /// The same path with a query is another document. Google really serves + /// `/oauth2/v3/certs?callback=` as a 200 `text/javascript` JSONP + /// body -- `({...})` -- that begins with bytes the requester chose, + /// so the whole request line through its CRLF is pinned, and a query is + /// refused before any byte of the response is read. + function test_refusesAQueryInTheRequestTarget() public { + bytes memory request = + abi.encodePacked("GET /oauth2/v3/certs?callback=foo HTTP/1.1\r\n", _slice(REQUEST, 31, REQUEST.length)); + _refuses(_withRequest(request), GoogleJwtRoots.WrongRequestLine.selector); + } + + /// Only the origin-form line, like every other verifier. + function test_refusesAnAbsoluteFormRequestTarget() public { + bytes memory request = abi.encodePacked( + "GET https://www.googleapis.com/oauth2/v3/certs HTTP/1.1\r\n", _slice(REQUEST, 31, REQUEST.length) + ); + _refuses(_withRequest(request), GoogleJwtRoots.WrongRequestLine.selector); + } + + /// googleapis.com answers for many hosts under one certificate. With two + /// `Host` headers, which backend answered is the backend's business. + function test_refusesASecondHostHeader() public { + bytes memory request = abi.encodePacked( + "GET /oauth2/v3/certs HTTP/1.1\r\n", + "host: www.googleapis.com\r\n", + "host: storage.googleapis.com\r\n", + "connection: close\r\n\r\n" + ); + _refuses(_withRequest(request), abi.encodeWithSelector(GoogleJwtRoots.NotOneHostHeader.selector, 2)); + } + + /// Field names are case-insensitive and the colon admits whitespace, so + /// the count runs over normalized bytes. + function test_refusesASecondHostHeaderEvadingCaseAndWhitespace() public { + bytes memory request = abi.encodePacked( + "GET /oauth2/v3/certs HTTP/1.1\r\n", + "host: www.googleapis.com\r\n", + "HOST\t : storage.googleapis.com\r\n", + "connection: close\r\n\r\n" + ); + _refuses(_withRequest(request), abi.encodeWithSelector(GoogleJwtRoots.NotOneHostHeader.selector, 2)); + } + + /// The notary vouches for the bytes, not their HTTP shape. Google's front + /// end honours a second `Host` that a bare LF separates from the line + /// before it -- confirmed live: this request is answered by the storage + /// backend -- while a CRLF-anchored count sees one `Host`, the right one. + /// The bare LF is refused before anything is counted, at its offset. + function test_refusesASecondHostHeaderBehindABareLineFeed() public { + bytes memory request = abi.encodePacked( + "GET /oauth2/v3/certs HTTP/1.1\r\n", + "x-a: b\nhost: storage.googleapis.com\r\n", + "host: www.googleapis.com\r\n", + "connection: close\r\n\r\n" + ); + _refuses(_withRequest(request), abi.encodeWithSelector(CeremonyAttestation.BareLineFeed.selector, 37)); + } + + /// An obsolete line fold normalizes to `\r\nhost:` -- the fold's space is + /// stripped, its CRLF kept -- so a folded continuation would be counted as + /// the one legitimate `Host` and pair with a bare-LF second one. The fold + /// is refused first, at the CRLF that starts it. + function test_refusesAnObsoleteLineFoldInTheRequest() public { + bytes memory request = abi.encodePacked( + "GET /oauth2/v3/certs HTTP/1.1\r\n", "x-a: b\r\n host: www.googleapis.com\r\n", "connection: close\r\n\r\n" + ); + _refuses(_withRequest(request), abi.encodeWithSelector(CeremonyAttestation.ObsoleteLineFold.selector, 37)); + + // The pairing the fold enables: a folded right host over a bare-LF + // wrong one. The fold scan runs first and names the fold. + request = abi.encodePacked( + "GET /oauth2/v3/certs HTTP/1.1\r\n", + "x-a: b\r\n host: www.googleapis.com\r\n", + "x-b: c\nhost: storage.googleapis.com\r\n", + "connection: close\r\n\r\n" + ); + _refuses(_withRequest(request), abi.encodeWithSelector(CeremonyAttestation.ObsoleteLineFold.selector, 37)); + } + + /// The server reads hosts from the head. A `Host` after the blank line + /// is body bytes, and does not satisfy the count. + function test_aHostInTheRequestBodyDoesNotCount() public { + bytes memory request = abi.encodePacked( + "GET /oauth2/v3/certs HTTP/1.1\r\n", "connection: close\r\n\r\n", "host: www.googleapis.com\r\n" + ); + _refuses(_withRequest(request), abi.encodeWithSelector(GoogleJwtRoots.NotOneHostHeader.selector, 0)); + } + + /// A head that never ends has no head to count in. + function test_refusesARequestWhoseHeadNeverEnds() public { + bytes memory request = "GET /oauth2/v3/certs HTTP/1.1\r\nhost: www.googleapis.com\r\n"; + _refuses(_withRequest(request), GoogleJwtRoots.NoHeadBoundary.selector); + } + + function test_refusesARequestWithoutAHostHeader() public { + bytes memory request = "GET /oauth2/v3/certs HTTP/1.1\r\nconnection: close\r\n\r\n"; + _refuses(_withRequest(request), abi.encodeWithSelector(GoogleJwtRoots.NotOneHostHeader.selector, 0)); + } + + /// The TLS authority is the certificate's name; the `Host` header is + /// which of Google's backends answered. Both must be Google's JWKS host. + function test_refusesAnotherGoogleHost() public { + bytes memory request = + "GET /oauth2/v3/certs HTTP/1.1\r\nhost: storage.googleapis.com\r\nconnection: close\r\n\r\n"; + _refuses(_withRequest(request), GoogleJwtRoots.WrongHost.selector); + } + + /// A host that is a prefix of the right one, or the right one followed by + /// more, is another host. + function test_refusesAHostThatMerelyContainsTheRightOne() public { + bytes memory request = + "GET /oauth2/v3/certs HTTP/1.1\r\nhost: www.googleapis.com.evil\r\nconnection: close\r\n\r\n"; + _refuses(_withRequest(request), GoogleJwtRoots.WrongHost.selector); + request = "GET /oauth2/v3/certs HTTP/1.1\r\nhost: www.googleapis.co\r\nconnection: close\r\n\r\n"; + _refuses(_withRequest(request), GoogleJwtRoots.WrongHost.selector); + } + + // ─── What it refuses: the response ────────────────────────────── + + function _withResponse(bytes memory response) private view returns (bytes memory) { + return _attest(REQUEST, response, _now()); + } + + /// Only the server's agreement counts. A redirect carries a body too, and + /// it is not the key set. + function test_refusesARedirect() public { + bytes memory response = + abi.encodePacked("HTTP/1.1 302 Found\r\nlocation: /elsewhere\r\ncontent-length: 0\r\n\r\n"); + _refuses(_withResponse(response), GoogleJwtRoots.WrongStatusLine.selector); + } + + function test_refusesAResponseWithoutAHeadBoundary() public { + bytes memory response = abi.encodePacked(STATUS_OK, CONTENT_TYPE, "content-length: 2\r\n", "{}"); + _refuses(_withResponse(response), GoogleJwtRoots.NoHeadBoundary.selector); + } + + /// The prover negotiates no compression, so a `Content-Encoding` is a + /// body this contract cannot read. Refused by name, before the framing + /// is even looked at, rather than as some byte deep in the JSON walk. + function test_refusesAnEncodedBody() public { + bytes memory body = _oneKey("kid-1", "one"); + bytes memory response = abi.encodePacked( + STATUS_OK, + CONTENT_TYPE, + "content-encoding: gzip\r\n", + "content-length: ", + vm.toString(body.length), + "\r\n\r\n", + body + ); + _refuses(_withResponse(response), GoogleJwtRoots.EncodedBody.selector); + } + + /// Every deviation from strict chunked framing is one error: a size that + /// is not hex, a chunk extension, a missing terminator, trailers, an + /// over-long size, and chunk data not followed by CRLF. + function test_refusesBadChunkFraming() public { + bytes memory head = abi.encodePacked(STATUS_OK, CONTENT_TYPE, "transfer-encoding: chunked\r\n\r\n"); + bytes memory body = _oneKey("kid-1", "one"); + bytes memory sizeLine = abi.encodePacked(_hex(body.length), "\r\n"); + + // A size that is not hex. + _refuses( + _withResponse(abi.encodePacked(head, "zz\r\n", body, "\r\n0\r\n\r\n")), + GoogleJwtRoots.BadChunkFraming.selector + ); + // A chunk extension. + _refuses( + _withResponse(abi.encodePacked(head, _hex(body.length), ";ext=1\r\n", body, "\r\n0\r\n\r\n")), + GoogleJwtRoots.BadChunkFraming.selector + ); + // No terminating zero chunk. + _refuses(_withResponse(abi.encodePacked(head, sizeLine, body, "\r\n")), GoogleJwtRoots.BadChunkFraming.selector); + // Trailers after the zero chunk. + _refuses( + _withResponse(abi.encodePacked(head, sizeLine, body, "\r\n0\r\nx-trailer: 1\r\n\r\n")), + GoogleJwtRoots.BadChunkFraming.selector + ); + // Nine hex digits. + _refuses( + _withResponse(abi.encodePacked(head, "000000001\r\n", body, "\r\n0\r\n\r\n")), + GoogleJwtRoots.BadChunkFraming.selector + ); + // Chunk data not followed by CRLF. + _refuses( + _withResponse(abi.encodePacked(head, sizeLine, body, "0\r\n\r\n")), GoogleJwtRoots.BadChunkFraming.selector + ); + // A size past the end of the body. + _refuses( + _withResponse(abi.encodePacked(head, "ffff\r\n", body, "\r\n0\r\n\r\n")), + GoogleJwtRoots.BadChunkFraming.selector + ); + // Bytes after the final CRLF. + _refuses( + _withResponse(abi.encodePacked(head, sizeLine, body, "\r\n0\r\n\r\n\r\n")), + GoogleJwtRoots.BadChunkFraming.selector + ); + // And the strict form of the same body is accepted. + _rotate(_withResponse(abi.encodePacked(head, sizeLine, body, "\r\n0\r\n\r\n"))); + } + + function test_refusesAContentLengthThatIsNotTheBody() public { + bytes memory body = _oneKey("kid-1", "one"); + bytes memory response = abi.encodePacked( + STATUS_OK, CONTENT_TYPE, "content-length: ", vm.toString(body.length + 1), "\r\n\r\n", body + ); + _refuses( + _withResponse(response), + abi.encodeWithSelector(GoogleJwtRoots.ContentLengthMismatch.selector, body.length + 1, body.length) + ); + + // A value that is not a decimal declares no length any body matches. + response = abi.encodePacked(STATUS_OK, CONTENT_TYPE, "content-length: 4x\r\n\r\n", body); + _refuses( + _withResponse(response), + abi.encodeWithSelector(GoogleJwtRoots.ContentLengthMismatch.selector, 4, body.length) + ); + } + + /// With neither framing header, `connection: close` delimits and the body + /// is what follows the head. + function test_acceptsABodyDelimitedByConnectionClose() public { + bytes memory response = abi.encodePacked(STATUS_OK, CONTENT_TYPE, "\r\n", _oneKey("kid-1", "one")); + _rotate(_withResponse(response)); + assertEq(_current().moduli, _one(_hash("one"))); + } + + // ─── What it refuses: the key set ─────────────────────────────── + + function _withBody(bytes memory body) private view returns (bytes memory) { + return _reading(body); + } + + function test_refusesABodyWithoutKeys() public { + _refuses( + _withBody(abi.encodePacked('{"kyes":[', _jwk("kid-1", _modulus("one")), "]}")), + abi.encodeWithSelector(GoogleJwtRoots.MissingMember.selector, "keys") + ); + // Present, but not an array. + _refuses( + _withBody(abi.encodePacked('{"keys":', _jwk("kid-1", _modulus("one")), "}")), + abi.encodeWithSelector(GoogleJwtRoots.MissingMember.selector, "keys") + ); + } + + /// A decoy: two `keys` members, and nothing to say which one Google's + /// parser -- or a verifier's -- would honour. + function test_refusesADecoyKeysMember() public { + bytes memory body = abi.encodePacked( + '{"keys":[', _jwk("kid-1", _modulus("one")), '],"keys":[', _jwk("kid-2", _modulus("two")), "]}" + ); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.AmbiguousMember.selector, "keys")); + } + + function test_refusesAKeyWithoutAModulus() public { + bytes memory body = _body('{"kty":"RSA","kid":"kid-1","e":"AQAB"}'); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.MissingMember.selector, "n")); + } + + function test_refusesAKeyWithoutAKid() public { + bytes memory body = _body(abi.encodePacked('{"kty":"RSA","n":"', _modulus("one"), '","e":"AQAB"}')); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.MissingMember.selector, "kid")); + } + + /// The circuit verifies RS256 under 65537 and nothing else, and the + /// verifier trusts a key by its modulus alone -- so a key published with + /// any other exponent must never be listed, and one that names no + /// exponent is not a key this list can vouch for. + function test_refusesAnotherExponent() public { + bytes memory body = _body(abi.encodePacked('{"kid":"kid-1","n":"', _modulus("one"), '","e":"AQAC"}')); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.WrongExponent.selector, bytes("AQAC"))); + // 65537 in a padded or longer encoding is still not the token pinned. + body = _body(abi.encodePacked('{"kid":"kid-1","n":"', _modulus("one"), '","e":"AQAB="}')); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.WrongExponent.selector, bytes("AQAB="))); + } + + function test_refusesAKeyWithoutAnExponent() public { + bytes memory body = _body(abi.encodePacked('{"kty":"RSA","kid":"kid-1","n":"', _modulus("one"), '"}')); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.MissingMember.selector, "e")); + } + + /// Two `kid` members in one object: a duplicate leaves the value + /// undefined, and different parsers pick different ones. + function test_refusesADuplicateKidMember() public { + bytes memory body = _body(abi.encodePacked('{"kid":"kid-1","kid":"kid-2","n":"', _modulus("one"), '"}')); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.AmbiguousMember.selector, "kid")); + } + + function test_refusesAnUnterminatedKid() public { + bytes memory body = _body(abi.encodePacked('{"n":"', _modulus("one"), '","kid":"kid-1}')); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.UnterminatedMember.selector, "kid")); + } + + function test_refusesAnUnterminatedKeysArray() public { + _refuses( + _withBody(abi.encodePacked('{"keys":[', _jwk("kid-1", _modulus("one")))), + abi.encodeWithSelector(GoogleJwtRoots.UnterminatedMember.selector, "keys") + ); + // An element that is not an object. + _refuses( + _withBody(abi.encodePacked('{"keys":[', _jwk("kid-1", _modulus("one")), ',"x"]}')), + abi.encodeWithSelector(GoogleJwtRoots.UnterminatedMember.selector, "keys") + ); + // An object with no closing brace. + _refuses( + _withBody(abi.encodePacked('{"keys":[{"kid":"kid-1","n":"', _modulus("one"), '"')), + abi.encodeWithSelector(GoogleJwtRoots.UnterminatedMember.selector, "keys") + ); + } + + /// A backslash is refused rather than unescaped: a kid and a base64url + /// modulus need none, and unescaping is a parser this contract does not + /// carry. + function test_refusesAnEscapedValue() public { + bytes memory body = _body(abi.encodePacked('{"kid":"kid\\"1","n":"', _modulus("one"), '"}')); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.EscapedValue.selector, "kid")); + } + + function test_refusesANestedObject() public { + bytes memory body = _body(abi.encodePacked('{"kid":"kid-1","x5c":["MIIC"],"n":"', _modulus("one"), '"}')); + _refuses(_withBody(body), GoogleJwtRoots.UnexpectedNesting.selector); + body = _body(abi.encodePacked('{"kid":"kid-1","inner":{},"n":"', _modulus("one"), '"}')); + _refuses(_withBody(body), GoogleJwtRoots.UnexpectedNesting.selector); + } + + /// One reading carries at most MAX_KEYS keys, and the refusal names how + /// many it carried. The cap itself is accepted. + function test_refusesNineKeys() public { + uint256 max = roots.MAX_KEYS(); + assertEq(max, 8); + _refuses(_withBody(_manyKeys(max + 1)), abi.encodeWithSelector(GoogleJwtRoots.TooManyKeys.selector, max + 1)); + _rotate(_withBody(_manyKeys(max))); + assertEq(_current().moduli.length, max, "the cap itself is a valid reading"); + } + + /// A set, not a list: the same modulus twice is not the document Google + /// publishes, and a set with a repeat would compare as a set it is not. + function test_refusesADuplicateModulus() public { + bytes memory body = _twoKeys("kid-1", "one", "kid-2", "one"); + _refuses(_withBody(body), abi.encodeWithSelector(GoogleJwtRoots.DuplicateKey.selector, _hash("one"))); + } + + function test_refusesTrailingBytes() public { + bytes memory body = _oneKey("kid-1", "one"); + _refuses(_withBody(abi.encodePacked(body, "x")), GoogleJwtRoots.TrailingBytes.selector); + _refuses(_withBody(abi.encodePacked(body, "{}")), GoogleJwtRoots.TrailingBytes.selector); + // No closing brace at all. + _refuses(_withBody(_slice(body, 0, body.length - 1)), GoogleJwtRoots.TrailingBytes.selector); + // But whitespace after it is what Google sends. + _rotate(_withBody(abi.encodePacked(body, "\n"))); + } + + /// The circuit takes a 2048-bit modulus and nothing else. + function test_refusesAShortModulus() public { + bytes memory body = _body(_jwk("kid-1", string(_b64url(_bytes("short", 255))))); + _refuses(_withBody(body), GoogleJwtRoots.InvalidModulusLength.selector); + } + + function test_refusesANonBase64urlCharacter() public { + bytes memory body = _body(_jwk("kid-1", "AAA+")); + _refuses(_withBody(body), GoogleJwtRoots.InvalidB64Char.selector); + } + + function test_refusesAnImpossibleBase64Length() public { + bytes memory body = _body(_jwk("kid-1", "AAAAA")); + _refuses(_withBody(body), GoogleJwtRoots.InvalidB64Length.selector); + } + + /// A reading with no keys says nothing about what Google publishes. It + /// would pay the fee and shift the live set into `previous` on the + /// strength of a document that names no key. + function test_refusesAnEmptyKeySet() public { + _refuses(_withBody('{"keys":[]}'), GoogleJwtRoots.EmptyKeySet.selector); + _refuses(_withBody('{"keys": [ ]}'), GoogleJwtRoots.EmptyKeySet.selector); + } + + /// The header the body is located by gets the rule every other read + /// gets: a delimiter that matches twice is refused, not chosen from. + function test_refusesDuplicateOrConflictingFramingHeaders() public { + bytes memory body = _oneKey("kid-1", "one"); + bytes memory length = abi.encodePacked("content-length: ", vm.toString(body.length), "\r\n"); + _refuses( + _withResponse(abi.encodePacked(STATUS_OK, CONTENT_TYPE, length, length, "\r\n", body)), + abi.encodeWithSelector(GoogleJwtRoots.DuplicateFramingHeader.selector, "content-length", 2) + ); + bytes memory chunked = "transfer-encoding: chunked\r\n"; + _refuses( + _withResponse( + abi.encodePacked( + STATUS_OK, CONTENT_TYPE, chunked, "Transfer-Encoding: identity\r\n", "\r\n", _chunks(body, 64) + ) + ), + abi.encodeWithSelector(GoogleJwtRoots.DuplicateFramingHeader.selector, "transfer-encoding", 2) + ); + _refuses( + _withResponse(abi.encodePacked(STATUS_OK, CONTENT_TYPE, chunked, length, "\r\n", _chunks(body, 64))), + GoogleJwtRoots.ConflictingFraming.selector + ); + // A coding other than chunked frames a body this contract cannot locate. + _refuses( + _withResponse(abi.encodePacked(STATUS_OK, CONTENT_TYPE, "transfer-encoding: gzip\r\n", "\r\n", body)), + GoogleJwtRoots.BadChunkFraming.selector + ); + } + + // ─── Helpers ──────────────────────────────────────────────────── + + /// Read through the cheatcode rather than `block.timestamp`: under via-IR + /// the optimizer treats TIMESTAMP as movable and may re-read it after a + /// `vm.warp`, so a value captured before the warp is not one. + function _now() private view returns (uint64) { + return uint64(vm.getBlockTimestamp()); + } + + function _slice(bytes memory data, uint256 from, uint256 to) private pure returns (bytes memory out) { + out = new bytes(to - from); + for (uint256 i = 0; i < out.length; ++i) { + out[i] = data[from + i]; + } + } +} diff --git a/solidity/contracts/ceremony/test/GoogleJwtRootsRealSession.t.sol b/solidity/contracts/ceremony/test/GoogleJwtRootsRealSession.t.sol new file mode 100644 index 00000000..38ec7ddc --- /dev/null +++ b/solidity/contracts/ceremony/test/GoogleJwtRootsRealSession.t.sol @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test, Vm} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +import {AttestationBuilder} from "./AttestationBuilder.sol"; +import {CeremonyAttestation} from "../CeremonyAttestation.sol"; +import {INotaryService} from "../INotaryService.sol"; +import {NotaryService} from "../NotaryService.sol"; +import {GoogleJwtRoots} from "../GoogleJwtRoots.sol"; + +/// A reading of Google's JWKS that actually crossed the wire. +/// +/// `GoogleJwtRoots.t.sol` builds its sessions: the request is a constant, the +/// response is framed by the test, and the notary is `vm.sign`. Every byte +/// there is one a test author chose. This file starts from the other end: the +/// fixture is a genuine MPC-TLS session with www.googleapis.com, captured on +/// 2026-09-03 by libid-org/notary's `examples/notarize_jwks.rs` through a notary signing +/// with anvil #0, and nothing in it was written by hand -- the request line is +/// what the keeper's prover put on the wire, the head is what Google's front +/// end answered, the framing is Google's, the key set is the one Google +/// published that minute, and the signature is the notary's own. +/// +/// So the positive test here is the deployment's real path end to end, and +/// the negative ones are what a cheat has to do to a real record: change a +/// byte, borrow a signature, wait too long, or re-sign an edited request with +/// a key the service does trust. +contract GoogleJwtRootsRealSessionTest is Test { + GoogleJwtRoots roots; + NotaryService notary; + + address constant OWNER = address(0xA11CE); + uint256 constant FEE = 0.001 ether; + /// anvil #0: the key the notary that produced the fixture signed with. + uint256 constant ANVIL_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + /// A second key the service trusts, for the variants that re-sign the + /// real bytes. Distinct from anvil #0 so a variant that passes is seen to + /// pass on the strength of the service's trust, not the fixture's. + uint256 constant TEST_KEY = 0x7E57; + uint256 constant STRANGER_KEY = 0xB0B; + + string constant SESSION = "contracts/ceremony/test/fixtures/google-jwks-session.json"; + bytes constant REQUEST_LINE = "GET /oauth2/v3/certs HTTP/1.1\r\n"; + bytes constant HEAD_BOUNDARY = "\r\n\r\n"; + uint256 constant NONE = type(uint256).max; + + /// The record and its signature, exactly as the notary emitted them. + bytes record; + bytes signature; + /// The record's `createdAt`, from the fixture's own field. + uint64 createdAt; + /// Who the fixture says signed. + address signer; + + function setUp() public { + string memory json = vm.readFile(SESSION); + record = vm.parseJsonBytes(json, ".attested_data"); + signature = vm.parseJsonBytes(json, ".notary_signature"); + createdAt = uint64(vm.parseJsonUint(json, ".created_at")); + signer = vm.parseJsonAddress(json, ".notary"); + + // A minute after the notary's clock: inside the freshness window, + // outside the clock-skew grace, the way a keeper's submission lands. + vm.warp(createdAt + 60); + + NotaryService impl = new NotaryService(); + notary = NotaryService( + address( + new ERC1967Proxy( + address(impl), abi.encodeCall(NotaryService.initialize, (OWNER, vm.addr(ANVIL_KEY), FEE)) + ) + ) + ); + vm.prank(OWNER); + notary.setNotary(vm.addr(TEST_KEY), true); + roots = _deployRoots(); + + vm.deal(address(this), 1 ether); + } + + function _deployRoots() private returns (GoogleJwtRoots) { + GoogleJwtRoots impl = new GoogleJwtRoots(); + return GoogleJwtRoots( + address( + new ERC1967Proxy( + address(impl), abi.encodeCall(GoogleJwtRoots.initialize, (OWNER, INotaryService(address(notary)))) + ) + ) + ); + } + + /// The four kids Google served in the fixture. Asserted against the body + /// below, so this list cannot drift from the record it describes. + function _kids() private pure returns (string[] memory kids) { + kids = new string[](4); + kids[0] = "d6a0e659c3392d9e592a4b0ad88e7b2b88076909"; + kids[1] = "a8f80b512469959cdc1eeba44b066c2f79944779"; + kids[2] = "943a3a5d7d919625a454e489b75c29adab57acba"; + kids[3] = "f10f87405a979c1df36df26606734f33cd85c271"; + } + + // ─── What the fixture is ──────────────────────────────────────── + + /// The record is the JWKS session layout, signed by the key the fixture + /// names, and its bytes sit where the section 9.1 layout fixes them: the + /// sent direction's first revealed range begins at 48 + 8 + 4 + 8 = 68, + /// the received one at 68 + 136 + 8 + 8 + 4 + 8 = 232. + function test_theRecordIsTheSessionTheFixtureDescribes() public view { + assertEq(signer, vm.addr(ANVIL_KEY), "the fixture was signed by anvil #0"); + assertEq(record.length, 2811); + assertEq(signature.length, 65); + + bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(keccak256(record)); + assertEq(ECDSA.recover(ethHash, signature), signer, "the signature is over these exact bytes"); + + CeremonyAttestation.AttestedData memory data = this.decode(record); + assertEq(data.authorityId, roots.AUTHORITY(), "the notary authenticated www.googleapis.com"); + assertEq(data.createdAt, createdAt, "bytes 32..40 are the fixture's created_at"); + assertEq(data.sentTranscriptLength, 136); + assertEq(data.recvTranscriptLength, 2571); + + // The JWKS layout: everything revealed, nothing committed, in both + // directions -- what lets the contract read each join as the wire. + assertEq(data.sent.revealed.length, 1); + assertEq(data.sent.revealed[0].start, 0); + assertEq(data.sent.revealed[0].end, 136); + assertEq(data.sent.commitments.length, 0); + assertEq(data.received.revealed.length, 1); + assertEq(data.received.revealed[0].start, 0); + assertEq(data.received.revealed[0].end, 2571); + assertEq(data.received.commitments.length, 0); + + assertEq(_slice(record, 68, 68 + REQUEST_LINE.length), REQUEST_LINE, "the request begins at byte 68"); + assertEq(_slice(record, 232, 232 + 17), bytes("HTTP/1.1 200 OK\r\n"), "the response begins at byte 232"); + } + + /// The libid-rs origin-form fix, proven on the wire: hyper writes the + /// request-target as the URI displays, and the prover was handed an + /// absolute one, so without the rewrite this line would read + /// `GET https://www.googleapis.com/oauth2/v3/certs HTTP/1.1` and every + /// verifier would refuse it. + function test_theRealRequestLineIsOriginForm() public view { + bytes memory sent = this.decode(record).sent.revealed[0].value; + assertTrue(_startsWith(sent, REQUEST_LINE), "origin-form request line"); + assertEq(_indexOf(sent, "://", 0), NONE, "an absolute-form target would carry a scheme"); + + bytes memory rest = _slice(sent, REQUEST_LINE.length, sent.length); + assertTrue(_startsWith(rest, "host: www.googleapis.com\r\n"), "the Host the contract pins, right after it"); + assertEq(_count(sent, "\r\nhost:"), 1, "one Host header"); + assertTrue(_endsWith(sent, HEAD_BOUNDARY), "a head and no body"); + } + + /// Google framed the body chunked, with capitalised field names -- the + /// builder-made sessions write them lowercase, so this is the one place + /// the head normalisation is tested against a head a server wrote. One + /// chunk of 0x7ff bytes, then the terminator, no trailers. + function test_googleFramedTheBodyChunked() public view { + bytes memory received = this.decode(record).received.revealed[0].value; + bytes memory head = _head(received); + bytes memory body = _afterHead(received); + + assertTrue(_startsWith(head, "HTTP/1.1 200 OK\r\n")); + assertTrue(_contains(head, "\r\nTransfer-Encoding: chunked\r\n"), "chunked, capitalised as Google writes it"); + assertTrue(_contains(head, "\r\nContent-Type: application/json; charset=UTF-8\r\n")); + assertTrue(_contains(head, "\r\nConnection: close\r\n")); + + bytes memory normalized = CeremonyAttestation.normalizeHeaderBytes(head); + assertEq(_count(normalized, "\r\ntransfer-encoding:chunked\r\n"), 1, "the needle the contract counts"); + assertEq(_count(normalized, "\r\ncontent-length:"), 0, "no length alongside the coding"); + + assertTrue(_startsWith(body, "7ff\r\n"), "one chunk of 2047 bytes"); + assertTrue(_endsWith(body, "\r\n0\r\n\r\n"), "the terminator, no trailers"); + bytes memory json = _dechunk(body); + assertEq(json.length, 0x7ff); + assertTrue(_startsWith(json, "{\n \"keys\": ["), "Google's pretty-printed key set"); + } + + /// The builder used to forge variants below reproduces the real record + /// byte for byte, so a variant differs from the wire in exactly the edit + /// it makes and nothing else. + function test_theBuilderReproducesTheRealRecordByteForByte() public view { + CeremonyAttestation.AttestedData memory data = this.decode(record); + bytes memory rebuilt = AttestationBuilder.encode( + data.authorityId, + data.createdAt, + _whole(data.sent.revealed[0].value), + _whole(data.received.revealed[0].value) + ); + assertEq(rebuilt, record); + } + + // ─── The real rotation ────────────────────────────────────────── + + /// The real bytes, the real signature, a real Notary Service trusting the + /// key that signed: every kid Google published that minute is installed + /// as the current generation, trusted for the lifetime from the notary's + /// clock, and the fee lands where every other attestation's does. + function test_theRealReadingInstallsEveryKidGoogleServed() public { + vm.recordLogs(); + roots.rotate{value: FEE}(record, signature); + + bytes memory json = _realBody(); + string[] memory kids = _kids(); + assertEq(_count(json, "\"kid\""), kids.length, "the list above names every kid in the body"); + for (uint256 i = 0; i < kids.length; ++i) { + assertTrue(_contains(json, abi.encodePacked("\"kid\": \"", kids[i], "\"")), "kid is in the body"); + } + + // The rotation announces the reading's keys in the order Google + // listed them, so the event is what ties each kid to its modulus. + (uint64 observedAt, string[] memory announced, bytes32[] memory moduli) = _rotation(); + assertEq(observedAt, createdAt, "stamped with the notary's clock"); + assertEq(announced, kids, "every kid, in the fixture's order"); + assertEq(moduli.length, kids.length); + uint256 stamp = createdAt + roots.READING_LIFETIME(); + for (uint256 i = 0; i < moduli.length; ++i) { + assertTrue(moduli[i] != bytes32(0), "kid installed"); + assertEq(roots.trustedHashExpiresAt(moduli[i]), stamp, "trusted for the lifetime, from the reading's clock"); + for (uint256 j = 0; j < i; ++j) { + assertTrue(moduli[i] != moduli[j], "four distinct keys"); + } + } + + (GoogleJwtRoots.Generation memory current, GoogleJwtRoots.Generation memory previous) = roots.currentKeys(); + assertEq(current.observedAt, createdAt); + assertEq(current.moduli, moduli, "four moduli, in the fixture's order"); + assertEq(previous.moduli.length, 0, "nothing precedes the first reading"); + assertFalse(roots.needsRotation()); + assertEq(roots.freshestObservedAt(), createdAt); + assertEq(address(notary).balance, FEE, "the fee reached the Notary Service"); + assertEq(address(roots).balance, 0); + } + + /// The same body, re-read under `Content-Length` by a different trusted + /// notary, installs identical modulus hashes on a fresh list: the framing + /// and the signer are transparent to what gets written, which is the + /// keeper's own limb hash of the bytes Google sent. + function test_theSameBodyUnderContentLengthYieldsTheSameKeys() public { + roots.rotate{value: FEE}(record, signature); + + CeremonyAttestation.AttestedData memory data = this.decode(record); + bytes memory json = _realBody(); + bytes memory response = abi.encodePacked( + "HTTP/1.1 200 OK\r\n", + "content-type: application/json; charset=UTF-8\r\n", + "content-length: ", + vm.toString(json.length), + "\r\n\r\n", + json + ); + bytes memory reread = AttestationBuilder.encode( + data.authorityId, data.createdAt, _whole(data.sent.revealed[0].value), _whole(response) + ); + + GoogleJwtRoots again = _deployRoots(); + again.rotate{value: FEE}(reread, _signWith(TEST_KEY, reread)); + + (GoogleJwtRoots.Generation memory viaChunked,) = roots.currentKeys(); + (GoogleJwtRoots.Generation memory viaLength,) = again.currentKeys(); + assertEq(viaLength.moduli.length, _kids().length); + assertEq(viaLength.moduli, viaChunked.moduli, "framing changed the key"); + } + + // ─── The ways to cheat with it ────────────────────────────────── + // + // Two kinds of surgery. Where the point is that no byte may change, the + // edit is made on the raw record at the offset the layout fixes, so the + // signature is checked against exactly what the notary signed minus one + // bit. Where the point is that the CONTENT is refused even under a good + // signature, the record is decoded with `CeremonyAttestation`, the + // request bytes are edited, and the result is re-encoded with + // `AttestationBuilder` and re-signed -- legitimate because the builder + // reproduces the real record byte for byte (see above), so the forged + // record differs from the wire in the edit alone. + + /// One bit inside the first modulus. The signature is the notary's and the + /// key is trusted, but the digest the service derives is of these bytes, + /// so recovery lands on a key nobody trusts -- and the test names which. + function test_aFlippedByteIsRefusedThoughTheSignatureIsGenuine() public { + bytes memory tampered = record; + uint256 at = _indexOf(tampered, "\"n\": \"", 0) + 10; + tampered[at] = bytes1(uint8(tampered[at]) ^ 0x01); + + bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(keccak256(tampered)); + (address recovered, ECDSA.RecoverError err,) = ECDSA.tryRecover(ethHash, signature); + assertEq(uint8(err), uint8(ECDSA.RecoverError.NoError), "a well-formed signature still recovers to someone"); + assertTrue(recovered != signer, "just not to the notary"); + + vm.expectRevert(abi.encodeWithSelector(NotaryService.UntrustedNotary.selector, recovered)); + roots.rotate{value: FEE}(tampered, signature); + } + + /// The real bytes, signed by someone the service never trusted. + function test_aStrangersSignatureOverTheRealBytesIsRefused() public { + bytes memory sig = _signWith(STRANGER_KEY, record); + vm.expectRevert(abi.encodeWithSelector(NotaryService.UntrustedNotary.selector, vm.addr(STRANGER_KEY))); + roots.rotate{value: FEE}(record, sig); + } + + /// Genuine, and too old: past the window the reading says nothing about + /// what Google publishes now. The boundary itself is still inside. + function test_theRealReadingIsRefusedPastItsWindow() public { + uint256 window = roots.FRESHNESS_WINDOW(); + + vm.warp(createdAt + window + 1); + vm.expectRevert(GoogleJwtRoots.StaleProof.selector); + roots.rotate{value: FEE}(record, signature); + + vm.warp(createdAt + window); + roots.rotate{value: FEE}(record, signature); + (GoogleJwtRoots.Generation memory current,) = roots.currentKeys(); + assertEq(current.moduli.length, 4); + } + + /// The real request with a second `Host` smuggled in behind a bare LF -- + /// the line Google's front end honours and a CRLF-anchored count cannot + /// see -- re-signed by a key the service DOES trust. The signature is + /// good; the request is refused before any header is counted, at the + /// offset of the LF: 31 bytes of request line, then `x-a: b`. + function test_aSmuggledHostReSignedByATrustedKeyIsRefused() public { + assertTrue(notary.isTrustedNotary(vm.addr(TEST_KEY)), "the forger holds a trusted key"); + + CeremonyAttestation.AttestedData memory data = this.decode(record); + bytes memory sent = data.sent.revealed[0].value; + bytes memory smuggled = abi.encodePacked( + _slice(sent, 0, REQUEST_LINE.length), + "x-a: b\nhost: storage.googleapis.com\r\n", + _slice(sent, REQUEST_LINE.length, sent.length) + ); + bytes memory forged = AttestationBuilder.encode( + data.authorityId, data.createdAt, _whole(smuggled), _whole(data.received.revealed[0].value) + ); + bytes memory sig = _signWith(TEST_KEY, forged); + + vm.expectRevert(abi.encodeWithSelector(CeremonyAttestation.BareLineFeed.selector, REQUEST_LINE.length + 6)); + roots.rotate{value: FEE}(forged, sig); + } + + // ─── Helpers ──────────────────────────────────────────────────── + + /// `CeremonyAttestation.decode` reads calldata, so the record goes out + /// through an external call to come back decoded. + function decode(bytes calldata data) external pure returns (CeremonyAttestation.AttestedData memory) { + return CeremonyAttestation.decode(data); + } + + /// Google's key set as the contract reads it: the received direction past + /// the head, de-chunked. + function _realBody() private view returns (bytes memory) { + return _dechunk(_afterHead(this.decode(record).received.revealed[0].value)); + } + + /// The one `KeysRotated` among the recorded logs, decoded. + function _rotation() private returns (uint64 observedAt, string[] memory kids, bytes32[] memory moduli) { + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 topic = keccak256("KeysRotated(uint64,string[],bytes32[])"); + uint256 seen; + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter != address(roots) || logs[i].topics[0] != topic) continue; + ++seen; + (observedAt, kids, moduli) = abi.decode(logs[i].data, (uint64, string[], bytes32[])); + } + assertEq(seen, 1, "one KeysRotated per rotation"); + } + + function _signWith(uint256 key, bytes memory attested) private pure returns (bytes memory) { + bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(keccak256(attested)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, ethHash); + return abi.encodePacked(r, s, v); + } + + /// The JWKS layout: one revealed range covering the whole direction, + /// nothing committed. + function _whole(bytes memory transcript) private pure returns (AttestationBuilder.Direction memory) { + return AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: transcript})), + commitments: AttestationBuilder.none(), + length: uint32(transcript.length) + }); + } + + function _head(bytes memory message) private pure returns (bytes memory) { + uint256 boundary = _indexOf(message, HEAD_BOUNDARY, 0); + assertTrue(boundary != NONE, "no head boundary"); + return _slice(message, 0, boundary + 2); + } + + function _afterHead(bytes memory message) private pure returns (bytes memory) { + uint256 boundary = _indexOf(message, HEAD_BOUNDARY, 0); + assertTrue(boundary != NONE, "no head boundary"); + return _slice(message, boundary + 4, message.length); + } + + /// A strict test-side de-chunker: hex size, CRLF, data, CRLF; a zero + /// size followed by CRLF and nothing more ends it. + function _dechunk(bytes memory chunked) private pure returns (bytes memory out) { + uint256 at; + while (true) { + uint256 size; + uint256 lineEnd = _indexOf(chunked, "\r\n", at); + assertTrue(lineEnd != NONE && lineEnd > at, "chunk size line"); + for (; at < lineEnd; ++at) { + uint8 c = uint8(chunked[at]); + uint256 nibble = c <= 0x39 ? c - 0x30 : (c | 0x20) - 0x61 + 10; + size = (size << 4) | nibble; + } + at += 2; + if (size == 0) { + assertEq(chunked.length - at, 2, "no trailers"); + return out; + } + out = abi.encodePacked(out, _slice(chunked, at, at + size)); + at += size; + assertEq(_slice(chunked, at, at + 2), bytes("\r\n"), "CRLF after chunk data"); + at += 2; + } + } + + function _count(bytes memory haystack, bytes memory needle) private pure returns (uint256 count) { + for (uint256 at = _indexOf(haystack, needle, 0); at != NONE; at = _indexOf(haystack, needle, at + 1)) { + ++count; + } + } + + function _contains(bytes memory haystack, bytes memory needle) private pure returns (bool) { + return _indexOf(haystack, needle, 0) != NONE; + } + + function _indexOf(bytes memory haystack, bytes memory needle, uint256 from) private pure returns (uint256) { + for (uint256 i = from; i + needle.length <= haystack.length; ++i) { + bool hit = true; + for (uint256 j = 0; j < needle.length; ++j) { + if (haystack[i + j] != needle[j]) { + hit = false; + break; + } + } + if (hit) return i; + } + return NONE; + } + + function _startsWith(bytes memory data, bytes memory prefix) private pure returns (bool) { + return data.length >= prefix.length && keccak256(_slice(data, 0, prefix.length)) == keccak256(prefix); + } + + function _endsWith(bytes memory data, bytes memory suffix) private pure returns (bool) { + return data.length >= suffix.length + && keccak256(_slice(data, data.length - suffix.length, data.length)) == keccak256(suffix); + } + + function _slice(bytes memory data, uint256 from, uint256 to) private pure returns (bytes memory out) { + out = new bytes(to - from); + for (uint256 i = 0; i < out.length; ++i) { + out[i] = data[from + i]; + } + } +} diff --git a/solidity/contracts/ceremony/test/GooglePlatformVerifier.t.sol b/solidity/contracts/ceremony/test/GooglePlatformVerifier.t.sol index 7118c249..dc8265e4 100644 --- a/solidity/contracts/ceremony/test/GooglePlatformVerifier.t.sol +++ b/solidity/contracts/ceremony/test/GooglePlatformVerifier.t.sol @@ -6,7 +6,7 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import {CeremonyAuthorization} from "../CeremonyAuthorization.sol"; import {CeremonyProfile} from "../CeremonyProfile.sol"; -import {GooglePlatformVerifier, IJwksRoots} from "../GooglePlatformVerifier.sol"; +import {GooglePlatformVerifier, IGoogleJwtRoots} from "../GooglePlatformVerifier.sol"; import {ICeremony} from "../ICeremony.sol"; import {INotaryService} from "../INotaryService.sol"; import {IHonkVerifier, PlatformVerifierBase} from "../PlatformVerifierBase.sol"; @@ -25,7 +25,7 @@ contract Honk is IHonkVerifier { } } -contract Roots is IJwksRoots { +contract Roots is IGoogleJwtRoots { mapping(bytes32 => uint256) public expiry; function trust(bytes32 h, uint256 until) external { @@ -79,7 +79,7 @@ contract GooglePlatformVerifierTest is Test { IHonkVerifier(address(honk)), address(honk).codehash, GOOGLE_ALLOWANCE, - IJwksRoots(address(roots)) + IGoogleJwtRoots(address(roots)) ) ) ) @@ -225,7 +225,7 @@ contract GooglePlatformVerifierTest is Test { IHonkVerifier(address(honk)), address(honk).codehash, GOOGLE_ALLOWANCE, - IJwksRoots(address(roots)) + IGoogleJwtRoots(address(roots)) ) ) ); @@ -348,7 +348,7 @@ contract GooglePlatformVerifierTest is Test { function test_rejectsAnUntrustedModulus() public { Roots empty = new Roots(); vm.prank(OWNER); - verifier.setJwksRoots(IJwksRoots(address(empty))); + verifier.setJwtRoots(IGoogleJwtRoots(address(empty))); GooglePlatformVerifier.GoogleProof memory s = _payload(); vm.expectPartialRevert(GooglePlatformVerifier.UntrustedModulus.selector); this.run(s); @@ -360,7 +360,7 @@ contract GooglePlatformVerifierTest is Test { Roots lapsed = new Roots(); lapsed.trust(_modulusHash(), T0); vm.prank(OWNER); - verifier.setJwksRoots(IJwksRoots(address(lapsed))); + verifier.setJwtRoots(IGoogleJwtRoots(address(lapsed))); GooglePlatformVerifier.GoogleProof memory s = _payload(); vm.expectPartialRevert(GooglePlatformVerifier.UntrustedModulus.selector); this.run(s); @@ -431,12 +431,12 @@ contract GooglePlatformVerifierTest is Test { this.run(s); } - function test_onlyTheOwnerPointsAtJwksRoots() public { + function test_onlyTheOwnerPointsAtJwtRoots() public { vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(this))); - verifier.setJwksRoots(IJwksRoots(address(roots))); + verifier.setJwtRoots(IGoogleJwtRoots(address(roots))); vm.prank(OWNER); vm.expectRevert(PlatformVerifierBase.ZeroAddress.selector); - verifier.setJwksRoots(IJwksRoots(address(0))); + verifier.setJwtRoots(IGoogleJwtRoots(address(0))); } /// @dev A TLSNotary-shaped payload at the Google verifier: word four of diff --git a/solidity/contracts/ceremony/test/UpgradeSafety.t.sol b/solidity/contracts/ceremony/test/UpgradeSafety.t.sol index 9d70aced..51827754 100644 --- a/solidity/contracts/ceremony/test/UpgradeSafety.t.sol +++ b/solidity/contracts/ceremony/test/UpgradeSafety.t.sol @@ -10,19 +10,17 @@ import {CeremonyProofVerifier} from "../CeremonyProofVerifier.sol"; import {NotaryService} from "../NotaryService.sol"; import {XPlatformVerifier} from "../XPlatformVerifier.sol"; import {GitHubPlatformVerifier} from "../GitHubPlatformVerifier.sol"; -import {GooglePlatformVerifier, IJwksRoots} from "../GooglePlatformVerifier.sol"; +import {GooglePlatformVerifier, IGoogleJwtRoots} from "../GooglePlatformVerifier.sol"; import {INotaryService} from "../INotaryService.sol"; import {IHonkVerifier} from "../PlatformVerifierBase.sol"; import {IPlatformVerifier} from "../IPlatformVerifier.sol"; import {IProofVerifier} from "../IProofVerifier.sol"; import {IdentityNames} from "../../identity/IdentityNames.sol"; -import {IdentityJwksRoots} from "../../identity/IdentityJwksRoots.sol"; +import {GoogleJwtRoots} from "../GoogleJwtRoots.sol"; import {HandleVectors} from "../../identity/HandleVectors.sol"; -import {HandleNormalizer} from "../../identity/HandleNormalizer.sol"; import {IdentityNodes} from "../../identity/IdentityNodes.sol"; import {StubPlatformVerifier} from "../../identity/test/StubPlatformVerifier.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; +import {AttestationBuilder} from "./AttestationBuilder.sol"; contract RHonk is IHonkVerifier { function verify(bytes calldata, bytes32[] calldata) external pure returns (bool) { @@ -30,7 +28,7 @@ contract RHonk is IHonkVerifier { } } -contract RRoots is IJwksRoots { +contract RRoots is IGoogleJwtRoots { function trustedHashExpiresAt(bytes32) external pure returns (uint256) { return 0; } @@ -45,6 +43,7 @@ contract UpgradeSafetyTest is Test { bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; bytes32 constant REENTRANCY_SLOT = 0x9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00; bytes32 constant NAMES_ROOT = 0x064503501234cc9c6e116cf4a84c07475158dabb6a3dcee437a89227e23bf200; + bytes32 constant ROOTS_ROOT = 0x7f78ff13201a03086d4b08e3085224c34a9fc247d0f67d11acd0db52976eb300; bytes32 constant X = HandleVectors.PLATFORM_X; function _slot(string memory ns) internal pure returns (bytes32) { @@ -67,6 +66,7 @@ contract UpgradeSafetyTest is Test { 0x92a7c997eeb454ceeeb8ef2d99ba7d4b830287ff966f129f9049c466a8342400 ); assertEq(_slot("libid.storage.IdentityNames"), NAMES_ROOT); + assertEq(_slot("libid.storage.GoogleJwtRoots"), ROOTS_ROOT); } function _implOf(address proxy) internal view returns (address) { @@ -232,7 +232,7 @@ contract UpgradeSafetyTest is Test { vm.prank(OWNER); v.upgradeToAndCall(address(impl2), ""); assertEq(_implOf(address(v)), address(impl2)); - assertEq(v.jwksRoots(), address(roots)); + assertEq(v.jwtRoots(), address(roots)); assertEq(v.honkVerifier(), address(honk)); assertEq(v.notaryService(), address(0)); (,, uint64 c) = v.protocolParameters(); @@ -240,29 +240,122 @@ contract UpgradeSafetyTest is Test { assertEq(v.owner(), OWNER); } - // ─── IdentityJwksRoots ────────────────────────────────────────── + // ─── GoogleJwtRoots ────────────────────────────────────────── - function test_upgrade_IdentityJwksRoots() public { - Notary n = deployNotary(OWNER, NOTARY); - IdentityJwksRoots impl = new IdentityJwksRoots(); + function test_upgrade_GoogleJwtRoots() public { + INotaryService ns = INotaryService(address(_notaryService())); + GoogleJwtRoots impl = new GoogleJwtRoots(); vm.expectRevert(Initializable.InvalidInitialization.selector); - impl.initialize(OWNER, address(n)); - IdentityJwksRoots r = IdentityJwksRoots( - address(new ERC1967Proxy(address(impl), abi.encodeCall(IdentityJwksRoots.initialize, (OWNER, address(n))))) + impl.initialize(OWNER, ns); + GoogleJwtRoots r = GoogleJwtRoots( + address(new ERC1967Proxy(address(impl), abi.encodeCall(GoogleJwtRoots.initialize, (OWNER, ns)))) ); vm.expectRevert(Initializable.InvalidInitialization.selector); - r.initialize(OWNER, address(n)); + r.initialize(OWNER, ns); - IdentityJwksRoots impl2 = new IdentityJwksRoots(); + // Two rotations land BEFORE the upgrade -- two different one-key + // readings, so BOTH generations hold state -- and what has to survive + // is real state in the namespaced slots rather than the empty list + // initialize left behind. + uint256 notaryKey = 0xA11CE5; + vm.prank(OWNER); + NotaryService(address(ns)).setNotary(vm.addr(notaryKey), true); + vm.deal(address(this), 1 ether); + vm.warp(1_770_000_000); + uint64 first = uint64(vm.getBlockTimestamp()); + bytes memory attested = _jwksReading(first, JWKS_MODULUS); + r.rotate{value: 7}(attested, _signed(notaryKey, attested)); + vm.warp(first + 60); + uint64 second = uint64(vm.getBlockTimestamp()); + attested = _jwksReading(second, _otherModulus()); + r.rotate{value: 7}(attested, _signed(notaryKey, attested)); + + (GoogleJwtRoots.Generation memory current, GoogleJwtRoots.Generation memory previous) = r.currentKeys(); + assertEq(current.observedAt, second, "the second rotation landed"); + assertEq(previous.observedAt, first, "the first rotation was kept"); + assertEq(current.moduli.length, 1); + assertEq(previous.moduli.length, 1); + bytes32 newer = current.moduli[0]; + bytes32 older = previous.moduli[0]; + assertTrue(newer != older, "two different keys"); + uint256 lifetime = r.READING_LIFETIME(); + + // The layout those two generations sit in, pinned word by word. + // `notary` is word 0 of the namespace; a Generation is two words -- + // its stamp, then the length word of its dynamic array, whose + // elements hang off keccak256(that word's slot) -- so `current` is + // words 1-2 and `previous` words 3-4. A field added to Generation + // moves `previous`, and an upgraded implementation would then read + // the old generation's stamp as the new field: this is where that + // has to fail, loudly, before any upgrade does. + uint256 base = uint256(ROOTS_ROOT); + assertEq(uint256(vm.load(address(r), bytes32(base))), uint256(uint160(address(ns))), "word 0: notary"); + assertEq(uint256(vm.load(address(r), bytes32(base + 1))), second, "word 1: current.observedAt"); + assertEq(uint256(vm.load(address(r), bytes32(base + 2))), 1, "word 2: current.moduli.length"); + assertEq(vm.load(address(r), keccak256(abi.encode(base + 2))), newer, "current.moduli[0]"); + assertEq(uint256(vm.load(address(r), bytes32(base + 3))), first, "word 3: previous.observedAt"); + assertEq(uint256(vm.load(address(r), bytes32(base + 4))), 1, "word 4: previous.moduli.length"); + assertEq(vm.load(address(r), keccak256(abi.encode(base + 4))), older, "previous.moduli[0]"); + + GoogleJwtRoots impl2 = new GoogleJwtRoots(); _expectNotOwner(); r.upgradeToAndCall(address(impl2), ""); vm.prank(OWNER); r.upgradeToAndCall(address(impl2), ""); assertEq(_implOf(address(r)), address(impl2)); - assertEq(address(r.notaryContract()), address(n)); + assertEq(r.notaryService(), address(ns)); + assertEq(r.quoteRotation(), 7); assertEq(r.owner(), OWNER); + assertEq(r.trustedHashExpiresAt(newer), second + lifetime, "the current generation was lost"); + assertEq(r.trustedHashExpiresAt(older), first + lifetime, "the previous generation was lost"); + (current, previous) = r.currentKeys(); + assertEq(current.observedAt, second); + assertEq(current.moduli.length, 1); + assertEq(current.moduli[0], newer); + assertEq(previous.observedAt, first); + assertEq(previous.moduli.length, 1); + assertEq(previous.moduli[0], older); + assertEq(r.freshestObservedAt(), second); + assertFalse(r.needsRotation()); + } + + function _signed(uint256 key, bytes memory attested) internal pure returns (bytes memory) { + (uint8 v, bytes32 sigR, bytes32 sigS) = + vm.sign(key, keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(attested)))); + return abi.encodePacked(sigR, sigS, v); + } + + /// One notarized reading of Google's JWKS carrying one key: the request + /// and the response revealed whole, nothing committed. + function _jwksReading(uint64 createdAt, string memory modulus) internal pure returns (bytes memory) { + bytes memory sent = "GET /oauth2/v3/certs HTTP/1.1\r\nhost: www.googleapis.com\r\nconnection: close\r\n\r\n"; + bytes memory body = abi.encodePacked('{"keys":[{"kid":"k","n":"', modulus, '","e":"AQAB"}]}'); + bytes memory received = + abi.encodePacked("HTTP/1.1 200 OK\r\ncontent-length: ", vm.toString(body.length), "\r\n\r\n", body); + return AttestationBuilder.encode(keccak256("www.googleapis.com"), createdAt, _whole(sent), _whole(received)); + } + + /// A second 2048-bit modulus: the keeper's vector with its first + /// character changed -- still 342 characters of base64url, a different + /// key. + function _otherModulus() internal pure returns (string memory) { + bytes memory modulus = bytes(JWKS_MODULUS); + modulus[0] = "C"; + return string(modulus); + } + + function _whole(bytes memory transcript) internal pure returns (AttestationBuilder.Direction memory) { + return AttestationBuilder.Direction({ + revealed: AttestationBuilder.one(AttestationBuilder.Range({start: 0, value: transcript})), + commitments: AttestationBuilder.none(), + length: uint32(transcript.length) + }); } + /// A 2048-bit modulus in base64url (the keeper's own test vector). + string internal constant JWKS_MODULUS = + "BAsSGSAnLjU8Q0pRWF9mbXR7gomQl56lrLO6wcjP1t3k6_L5BQwTGiEoLzY9REtSWWBnbnV8g4qRmJ-mrbS7wsnQ197l7PP6Bg0UGyIpMDc-RUxTWmFob3Z9hIuSmaCnrrW8w8rR2N_m7fT7Bw4VHCMqMTg_Rk1UW2JpcHd-hYyTmqGor7a9xMvS2eDn7vUBCA8WHSQrMjlAR05VXGNqcXh_ho2Um6KpsLe-xczT2uHo7_YCCRAXHiUsMzpBSE9WXWRrcnmAh46VnKOqsbi_xs3U2-Lp8PcDChEYHyYtNDtCSVBXXmVsc3qBiI-WnaSrsrnAx87V3OPq8fgECxIZIA"; + // ─── IdentityNames ────────────────────────────────────────────── IdentityNames names; @@ -330,40 +423,12 @@ contract UpgradeSafetyTest is Test { assertEq(at, 1_780_000_000); } - // The two Platform layouts, replicated so the slot arithmetic is measured - // rather than asserted from memory. - struct MainPlatform { - HandleNormalizer.Rules rules; - uint32 latestVersion; - bool configured; - } - - struct PrPlatform { - HandleNormalizer.Rules rules; - bool configured; - } - - MainPlatform mainP; - PrPlatform prP; - - /// Emulate the storage a main-deployed IdentityNames proxy holds for a - /// configured platform, then read it through the PR implementation. - function _emulateMainPlatform(uint32 latestVersion, bool configured) internal { - bytes32 entry = keccak256(abi.encode(X, uint256(NAMES_ROOT) + 3)); - HandleNormalizer.Rules memory r = HandleVectors.rulesFor(X); - uint256 rulesWord = uint256(r.maxLength) | (r.stripLeadingAt ? 1 << 16 : 0) | (r.isEmail ? 1 << 24 : 0) - | (r.allowUnderscore ? 1 << 32 : 0) | (r.allowHyphen ? 1 << 40 : 0); - vm.store(address(names), entry, bytes32(rulesWord)); - uint256 w = uint256(latestVersion) | (configured ? uint256(1) << 32 : 0); - vm.store(address(names), bytes32(uint256(entry) + 1), bytes32(w)); - } - function _claimExternal(address who, uint256 nonce) external { _claimAs(who, nonce); } - /// Binding lost `version` (uint32 at byte offset 28). Stale bits in that - /// word are ignored by the PR's reads, and a fresh write leaves them as-is. + /// `Binding` once carried a `version` (uint32 at byte offset 28). Stale bits + /// in that word are ignored by the current reads, and a fresh write leaves them as-is. function test_bindingStaleVersionWordIsIgnored() public { _names(); vm.prank(OWNER); diff --git a/solidity/contracts/ceremony/test/fixtures/google-jwks-session.json b/solidity/contracts/ceremony/test/fixtures/google-jwks-session.json new file mode 100644 index 00000000..c1a15756 --- /dev/null +++ b/solidity/contracts/ceremony/test/fixtures/google-jwks-session.json @@ -0,0 +1,8 @@ +{ + "notary": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "created_at": 1788431527, + "attested_data": "0x59962e64d51a4d36e444d18d029aff438ce3ddb882eb151a0af0fdde3cb655ee000000006a994ca70000008800000a0b0000000000000001000000000000000000000088474554202f6f61757468322f76332f636572747320485454502f312e310d0a686f73743a207777772e676f6f676c65617069732e636f6d0d0a636f6e6e656374696f6e3a20636c6f73650d0a6163636570743a206170706c69636174696f6e2f6a736f6e0d0a757365722d6167656e743a206c696269642d6b65657065722f302e332e300d0a0d0a00000000000000000000000000000001000000000000000000000a0b485454502f312e3120323030204f4b0d0a5365727665723a2073636166666f6c64696e67206f6e2048545450536572766572320d0a582d5853532d50726f74656374696f6e3a20300d0a582d4672616d652d4f7074696f6e733a2053414d454f524947494e0d0a582d436f6e74656e742d547970652d4f7074696f6e733a206e6f736e6966660d0a446174653a205468752c2030332053657020323032362031303a33313a333520474d540d0a457870697265733a205468752c2030332053657020323032362031353a35343a333120474d540d0a43616368652d436f6e74726f6c3a207075626c69632c206d61782d6167653d31393337362c206d7573742d726576616c69646174652c206e6f2d7472616e73666f726d0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e3b20636861727365743d5554462d380d0a4167653a2033320d0a416c742d5376633a2068333d223a343433223b206d613d323539323030302c68332d32393d223a343433223b206d613d323539323030300d0a4163636570742d52616e6765733a206e6f6e650d0a566172793a204f726967696e2c582d4f726967696e2c526566657265722c4163636570742d456e636f64696e670d0a436f6e6e656374696f6e3a20636c6f73650d0a5472616e736665722d456e636f64696e673a206368756e6b65640d0a0d0a3766660d0a7b0a2020226b657973223a205b0a202020207b0a20202020202022757365223a2022736967222c0a2020202020202265223a202241514142222c0a202020202020226b6964223a202264366130653635396333333932643965353932613462306164383865376232623838303736393039222c0a20202020202022616c67223a20225253323536222c0a202020202020226e223a2022784933496e584f326b4671376a647733484b4c43584d754a77455462496b6b4930516a634249314858444563444e4a496a694345564d3063383633385f43674979586e5f55577459726f6764744766707046354b684e6e3935716b4a306f55375f50696d4432474f7833672d766c6a354c4f325f4c6f6f4846386c65626368674e71735f71536f6a47516a4e7653504d5a42543137373037676f792d6630646f4c6d55556a726b675a30675746776550625f353343704841736c6f5f4a666b507050737461592d4b69646b5a6c36784247446f4f42796f58345a655f736141446d51525061303770476e74636c765452526f75753575627256644d6561414b746356367779494844694f593354757a6573397459674a4644546e35345a6d39684d45414e7668514c645077616377764d5f5970456834475270376a693030513139735f4f514332557a4275334747346e72346a6d7377222c0a202020202020226b7479223a2022525341220a202020207d2c0a202020207b0a2020202020202265223a202241514142222c0a202020202020226b7479223a2022525341222c0a20202020202022616c67223a20225253323536222c0a202020202020226e223a202271686a3546576955444c66756541364f564c626c66585a39414a74374e4c6a464b69546b3875737052616a6b597a545f5269516c426d687652616345715843415931504735796c504a6161656978345965756651486f5a6e676a486d453276376b364f764a6e55414a45384e65347a7a7641386d4551704e627247734634423067334b73303177665762577857516a5a4630684330736173324470484e436a476a75493176537763717164416e70587a4d3442384443714b68394b4461435f4a52325f432d535457685168733352705a4a6f4c695931566a5856704d757937574b426537517253365855546542585f4c4470396137613353366d764a33587555327a4c66715176647869367a3748654f4b75374356667842586a6d574237716b4334422d494e777436676f416841444678733066775f4a41747347307864734c5630446c544c6c635567686238416f63734453725751222c0a202020202020226b6964223a202261386638306235313234363939353963646331656562613434623036366332663739393434373739222c0a20202020202022757365223a2022736967220a202020207d2c0a202020207b0a202020202020226b7479223a2022525341222c0a20202020202022616c67223a20225253323536222c0a202020202020226e223a20227049706e7a4132657a794545524a53786971704c426d4d6549714154482d5636697542744b496962584579596f76756a7278386e697154654f3652497958543675445555763056326b4a38565f69575946787a58593142714b39496663416d6a67305855446f7954566b6f794c734630676a3239394c482d7a773576437679386a6d616d46495a4b41624b63513568704876536974744d31766c2d3676564c2d693247787947624d4139615936487131354e796c53317437454c54596651696d6c6e76786362375f444d30637553355531536662435a4d43704b6868306e72536c5964733234306f7870434a4f563272426168735f456135633774657a53316e77564339575f452d62523954463642486b435f66762d4538446357666b495f366765614a7a4268494e4e7842666a782d77312d575570324a7a3359594657456665516a784d71752d466736634778776b375631367551222c0a202020202020226b6964223a202239343361336135643764393139363235613435346534383962373563323961646162353761636261222c0a2020202020202265223a202241514142222c0a20202020202022757365223a2022736967220a202020207d2c0a202020207b0a20202020202022616c67223a20225253323536222c0a2020202020202265223a202241514142222c0a202020202020226b7479223a2022525341222c0a20202020202022757365223a2022736967222c0a202020202020226b6964223a202266313066383734303561393739633164663336646632363630363733346633336364383563323731222c0a202020202020226e223a20223472593575775a4b3164512d555667423573344e4c79432d75354c43324d5437623847575a7a74694e674d7370304e6e717830704d374f667830777333324e32615a637831302d4a38796451786e4e6239754163662d374c7968794f4963765f5745797a61536255414d4f676f462d6e516d4a6574636b784e6736656b684e6661466354515330542d3239716c325f43424c494d4c36437653682d72306667575273714e326179423777436c37344776364f4f56627661675557686a357a324c366f5f706c6d735044774c56757641376f33574445446a6f712d4958616652516f776a39326b5155656e724f4b443459436f70754c494268656c36564838646f46524e5a364b495351684d634f697657614c555f55744b4b414d6c6f474a696554665f33722d5f6e457273326835774237543746724d4353636d4f376d764651584b68385f34502d4d6c6266675339435576516b7377220a202020207d0a20205d0a7d0a0d0a300d0a0d0a0000000000000000", + "notary_signature": "0xfa3c338f8e2031d3daa408c98a29981a49f5af87bb394f32d660eadedeb85b3f43657694e2128f7e9b9acd268039f16f05d10c4da21528ca900406b64b5ff0351b", + "captured_at": "2026-09-03T10:32:07Z", + "endpoint": "https://www.googleapis.com/oauth2/v3/certs" +} diff --git a/solidity/contracts/ceremony/test/fixtures/google-jwks.json b/solidity/contracts/ceremony/test/fixtures/google-jwks.json new file mode 100644 index 00000000..fab49cfe --- /dev/null +++ b/solidity/contracts/ceremony/test/fixtures/google-jwks.json @@ -0,0 +1,20 @@ +{ + "keys": [ + { + "alg": "RS256", + "e": "AQAB", + "use": "sig", + "kid": "943a3a5d7d919625a454e489b75c29adab57acba", + "n": "pIpnzA2ezyEERJSxiqpLBmMeIqATH-V6iuBtKIibXEyYovujrx8niqTeO6RIyXT6uDUUv0V2kJ8V_iWYFxzXY1BqK9IfcAmjg0XUDoyTVkoyLsF0gj299LH-zw5vCvy8jmamFIZKAbKcQ5hpHvSittM1vl-6vVL-i2GxyGbMA9aY6Hq15NylS1t7ELTYfQimlnvxcb7_DM0cuS5U1SfbCZMCpKhh0nrSlYds240oxpCJOV2rBahs_Ea5c7tezS1nwVC9W_E-bR9TF6BHkC_fv-E8DcWfkI_6geaJzBhINNxBfjx-w1-WUp2Jz3YYFWEfeQjxMqu-Fg6cGxwk7V16uQ", + "kty": "RSA" + }, + { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "f10f87405a979c1df36df26606734f33cd85c271", + "alg": "RS256", + "n": "4rY5uwZK1dQ-UVgB5s4NLyC-u5LC2MT7b8GWZztiNgMsp0Nnqx0pM7Ofx0ws32N2aZcx10-J8ydQxnNb9uAcf-7LyhyOIcv_WEyzaSbUAMOgoF-nQmJetckxNg6ekhNfaFcTQS0T-29ql2_CBLIML6CvSh-r0fgWRsqN2ayB7wCl74Gv6OOVbvagUWhj5z2L6o_plmsPDwLVuvA7o3WDEDjoq-IXafRQowj92kQUenrOKD4YCopuLIBhel6VH8doFRNZ6KISQhMcOivWaLU_UtKKAMloGJieTf_3r-_nErs2h5wB7T7FrMCScmO7mvFQXKh8_4P-MlbfgS9CUvQksw" + } + ] +} diff --git a/solidity/contracts/factory/test/LibidFactory.t.sol b/solidity/contracts/factory/test/LibidFactory.t.sol index 674200f8..3fb17077 100644 --- a/solidity/contracts/factory/test/LibidFactory.t.sol +++ b/solidity/contracts/factory/test/LibidFactory.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Notary} from "../../notary/Notary.sol"; +import {NotaryService} from "../../ceremony/NotaryService.sol"; import {Create3} from "../Create3.sol"; import {LibidFactory} from "../LibidFactory.sol"; import {Ping, Pong} from "./Create3.t.sol"; @@ -96,7 +96,7 @@ contract LibidFactoryTest is Test { } function test_predict_matchesActualForSeveralNames() public { - string[3] memory names = ["libid.notary", "libid.registry", "libid.bank"]; + string[3] memory names = ["libid.NotaryService", "libid.IdentityNames", "libid.GoogleJwtRoots"]; for (uint256 i = 0; i < names.length; i++) { address predicted = factory.predict(names[i]); vm.prank(owner); @@ -117,28 +117,30 @@ contract LibidFactoryTest is Test { /// The intended use: deploy an ERC1967 PROXY through the factory /// (creationCode = proxy creation code ++ abi.encode(impl, initData)); /// the proxied contract works and lives at the name's address. - function test_deploy_notaryProxyThroughFactory() public { + function test_deploy_notaryServiceProxyThroughFactory() public { address signer = makeAddr("notarySigner"); - Notary notaryImpl = new Notary(); + NotaryService notaryImpl = new NotaryService(); bytes memory creationCode = abi.encodePacked( type(ERC1967Proxy).creationCode, - abi.encode(address(notaryImpl), abi.encodeCall(Notary.initialize, (owner, signer))) + abi.encode(address(notaryImpl), abi.encodeCall(NotaryService.initialize, (owner, signer, 7))) ); - address predicted = factory.predict("libid.notary"); + address predicted = factory.predict("libid.NotaryService"); vm.prank(owner); - address deployed = factory.deploy("libid.notary", creationCode); + address deployed = factory.deploy("libid.NotaryService", creationCode); assertEq(deployed, predicted); - Notary notary = Notary(deployed); + NotaryService notary = NotaryService(deployed); assertEq(notary.owner(), owner); - assertEq(notary.notary(), signer); + assertTrue(notary.isTrustedNotary(signer)); + assertEq(notary.fee(), 7); // …and it upgrades in place like any other proxy in the stack. - Notary newImpl = new Notary(); + NotaryService newImpl = new NotaryService(); vm.prank(owner); notary.upgradeToAndCall(address(newImpl), ""); - assertEq(notary.notary(), signer); + assertTrue(notary.isTrustedNotary(signer)); + assertEq(notary.fee(), 7); } // ─── UUPS upgrade of the factory itself ───────────────────────── diff --git a/solidity/contracts/identity/IdentityJwksRoots.sol b/solidity/contracts/identity/IdentityJwksRoots.sol deleted file mode 100644 index afb2bd44..00000000 --- a/solidity/contracts/identity/IdentityJwksRoots.sol +++ /dev/null @@ -1,507 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; - -import {INotary} from "../notary/INotary.sol"; - -/// @title IdentityJwksRoots - which Google signing keys the naming system -/// trusts, and until when. -/// -/// @notice Google rotates the keys it signs id_tokens with. This contract is -/// where the naming system learns the current ones, from a notarized -/// reading of Google's published JWKS. -/// -/// @dev **Rotation is permissionless, and deliberately has no nullifier.** With -/// an open caller set a one-shot nullifier would BE the attack: a -/// front-runner could consume a proof's digest and brick the honest -/// keeper. Freshness comes from the notary signature plus the window -/// below, and re-applying the same rotation is idempotent. -/// -/// **The rotation proof names no contract.** Its digest is over the TLS -/// session alone — domain, randoms, transcript root, timestamp — so the -/// same notarized reading of Google's JWKS is valid anywhere the notary is -/// trusted. That cross-deployment replay is a feature, and the digest -/// shape must not grow a chain id or a verifying-contract binding. Which -/// key is trusted lives in the shared Notary contract; the digest itself -/// stays this contract's own. -/// -/// **Why a contract of its own.** A verifier should verify. Rotation is a -/// separate concern with a separate caller set and a separate key list, -/// and a second OIDC provider would want the same list. -/// -/// **The trust model, in one line: the notary attests the reading, time -/// does the expiry, and nobody owns rotation.** Every entry carries a -/// stamp, verifiers check the stamp at use-site, and an expired key stops -/// being trusted with no transaction from anyone. The owner's only lever -/// over the key list is `untrustModulus`, which can only REMOVE trust. -contract IdentityJwksRoots is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { - bytes32 private constant EXPECTED_DOMAIN_HASH = keccak256(bytes("www.googleapis.com")); - bytes private constant EXPECTED_DOMAIN = bytes("www.googleapis.com"); - bytes private constant EXPECTED_ENDPOINT = bytes("/oauth2/v3/certs"); - uint256 public constant FRESHNESS_WINDOW = 1 hours; - uint256 public constant CLOCK_SKEW_GRACE = 5 minutes; - uint256 public constant DEFAULT_MODULUS_TTL = 30 days; - /// The most distinct kids the list will track at once. Google publishes a - /// handful of overlapping keys; a notarized reading cannot invent kids, so - /// this cap is headroom, not a working limit. It exists so the enumeration - /// below stays a bounded loop no submitter — honest or not — can bloat. - uint256 public constant MAX_TRACKED_KIDS = 16; - /// How much trusted runway `needsRotation()` insists on. Rotation re-stamps - /// every key for DEFAULT_MODULUS_TTL, so a keeper acting on this signal - /// rotates roughly weekly rather than racing the expiry. - uint256 public constant RENEWAL_MARGIN = 7 days; - - /// @notice The Notary contract a rotation's attestation is checked with. - INotary public notaryContract; - - /// @notice kid keccak -> the limb-keccak the circuit produces. - mapping(bytes32 => bytes32) public modulusOfKid; - mapping(bytes32 => uint256) public expiresAtKid; - - /// @notice modulusHash -> expiry. This is what a verifier reads: the JWT - /// circuit does not expose `kid`, so trust has to be checkable from - /// the modulus alone. - mapping(bytes32 => uint256) public trustedHashExpiresAt; - - /// @notice When each kid was last written, by the rotation proof's own - /// timestamp. Rotation is open, so without this a replayed older - /// proof inside the freshness window would roll a kid back to a - /// modulus Google has retired and re-stamp it for another TTL. - mapping(bytes32 => uint256) public rotatedAtKid; - - /// Every kid currently tracked, so keepers can enumerate the list without - /// an indexer. Bounded by MAX_TRACKED_KIDS; expired entries leave via - /// `prune()`, which anyone may call. - bytes32[] private _trackedKids; - /// kid keccak -> index+1 in `_trackedKids` (0 = not tracked). - mapping(bytes32 => uint256) private _trackedKidIndex; - /// The timestamp of the freshest reading ever applied — the notary's - /// observation time, not the block it landed in. A keeper compares this to - /// wall-clock to decide whether its reading would be news. - uint256 public freshestObservedAt; - - /// Reserved for future upgrades (UUPS). New state is appended before this - /// gap and the gap shrunk, so the layout survives. - uint256[47] private __gap; - - event ModulusRotated(bytes32 indexed kidHash, string kid, bytes32 modulusHash, uint256 expiresAt); - event ModulusUntrusted(bytes32 indexed modulusHash); - /// One key written by a rotation, with the reading's own timestamp. What a - /// keeper watches: `observedAt` orders readings, `expiresAt` says when this - /// entry stops being trusted if no rotation lands before then. - event RootApplied(bytes32 indexed kidHash, bytes32 indexed modulusHash, uint256 observedAt, uint256 expiresAt); - /// An expired key left the tracked set. Permissionless — time retired the - /// key, `prune()` merely reclaimed the slot. - event RootPruned(bytes32 indexed kidHash, bytes32 modulusHash); - - error ZeroAddress(); - error TooManyKids(); - error UnknownNotary(); - error WrongDomain(); - error FutureProof(); - error StaleProof(); - error MerkleMismatch(); - error PathTooLong(); - error MissingKidInLeaf(); - error MissingNInLeaf(); - error InvalidModulusLength(); - error InvalidB64Char(); - error InvalidB64Length(); - - /// One notarized reading of Google's JWKS endpoint. - struct NotarizedJwksProof { - bytes notarySignature; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] domainPath; - bytes32[] endpointPath; - } - - /// One key from that reading. - struct JwkClaim { - bytes jwkBytes; - bytes32[] jwkPath; - bytes kid; - bytes nB64url; - } - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @param notaryContract_ The shared Notary contract (INotary). Notary key - /// rotation happens THERE; this contract holds only the pointer. - function initialize(address owner_, address notaryContract_) external initializer { - __Ownable_init(owner_); - __Ownable2Step_init(); - __UUPSUpgradeable_init(); - if (notaryContract_ == address(0)) revert ZeroAddress(); - notaryContract = INotary(notaryContract_); - } - - /// @notice The current notary signer, read through the Notary contract. - function notary() external view returns (address) { - return notaryContract.notary(); - } - - /// @notice Stop trusting a key before anything replaces it. - /// - /// @dev Rotation already retires the key a kid used to carry, which covers - /// the ordinary case. This covers the one that cannot wait: a key - /// Google has not rotated yet, or has rotated in a way this list has - /// not seen. Without it the only remedy for a compromised modulus is - /// an upgrade, and a thirty-day stamp is a long time to hold a key - /// nobody should be signing with. - /// - /// Owner-only, and it can only ever REMOVE trust: the worst an owner - /// does with it is refuse bindings, which the rotation any keeper can - /// submit undoes. - function untrustModulus(bytes32 modulusHash) external onlyOwner { - delete trustedHashExpiresAt[modulusHash]; - emit ModulusUntrusted(modulusHash); - } - - /// @notice Apply a notarized reading of Google's JWKS. Anyone may call it. - /// The proof is the whole authorization: a keeper needs gas and - /// nothing else — no role, no allowlist, no owner anywhere in the - /// path. - function rotate(NotarizedJwksProof calldata proof, JwkClaim[] calldata claims) external { - _rotate(proof, claims); - } - - /// @notice Drop every expired kid from the tracked set. Anyone may call it. - /// - /// @dev Expiry itself needs no transaction — verifiers check the stamp at - /// use-site, so an expired key is already untrusted the second its TTL - /// passes. Pruning only reclaims enumeration slots (and the storage - /// refund). Rotation calls this by itself when the set is full, so - /// even the prune is optional housekeeping. - function prune() external { - _pruneExpired(); - } - - // ─── Keeper views ───────────────────────────────────────────── - - /// One tracked key, as a keeper sees it. - struct RootInfo { - bytes32 kidHash; - bytes32 modulusHash; - /// The notarized reading that last wrote this kid (proof timestamp). - uint256 observedAt; - /// When this entry stops being trusted, absent a fresher rotation. - uint256 expiresAt; - } - - /// @notice Every tracked key with its provenance and expiry, expired ones - /// included until someone prunes. One call tells a keeper the whole - /// state of the list. - function currentRoots() external view returns (RootInfo[] memory infos) { - uint256 n = _trackedKids.length; - infos = new RootInfo[](n); - for (uint256 i = 0; i < n; i++) { - bytes32 kidHash = _trackedKids[i]; - infos[i] = RootInfo({ - kidHash: kidHash, - modulusHash: modulusOfKid[kidHash], - observedAt: rotatedAtKid[kidHash], - expiresAt: expiresAtKid[kidHash] - }); - } - } - - /// @notice True when no key is guaranteed trusted RENEWAL_MARGIN from now — - /// the single bit a keeper polls to decide whether to fetch a fresh - /// reading. True from deployment until the first rotation lands. - function needsRotation() external view returns (bool) { - uint256 horizon = block.timestamp + RENEWAL_MARGIN; - uint256 n = _trackedKids.length; - for (uint256 i = 0; i < n; i++) { - bytes32 modulusHash = modulusOfKid[_trackedKids[i]]; - if (trustedHashExpiresAt[modulusHash] > horizon) return false; - } - return true; - } - - function _rotate(NotarizedJwksProof memory p, JwkClaim[] memory claims) internal { - // The digest deliberately names no chain and no contract (see the - // contract comment); the Notary contract checks the attestation over - // it — EIP-191 + recover + signer compare today. - bytes32 digest = _notaryDigest(p); - if (!notaryContract.verify(digest, p.notarySignature)) revert UnknownNotary(); - - if (p.timestamp > block.timestamp + CLOCK_SKEW_GRACE) revert FutureProof(); - if (block.timestamp > p.timestamp + FRESHNESS_WINDOW) revert StaleProof(); - - if (p.domainHash != EXPECTED_DOMAIN_HASH) revert WrongDomain(); - _verifyLeaf(p.domainPath, p.transcriptRoot, "domain:", EXPECTED_DOMAIN); - _verifyLeaf(p.endpointPath, p.transcriptRoot, "endpoint:", EXPECTED_ENDPOINT); - - // Monotonic high-water mark of reading timestamps. Replaying an older - // proof leaves it alone, so it only ever moves forward. - if (p.timestamp > freshestObservedAt) freshestObservedAt = p.timestamp; - - uint256 expiry = block.timestamp + DEFAULT_MODULUS_TTL; - for (uint256 i = 0; i < claims.length; i++) { - _processClaim(claims[i], p.transcriptRoot, expiry, p.timestamp); - } - } - - function _processClaim(JwkClaim memory c, bytes32 transcriptRoot, uint256 expiry, uint256 provenAt) internal { - if (c.jwkPath.length > 32) revert PathTooLong(); - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked("recv:", c.jwkBytes)))); - if (!_merkleVerifyMem(c.jwkPath, transcriptRoot, leaf)) revert MerkleMismatch(); - - if (!_containsKeyValue(c.jwkBytes, "kid", c.kid)) revert MissingKidInLeaf(); - if (!_containsKeyValue(c.jwkBytes, "n", c.nB64url)) revert MissingNInLeaf(); - - bytes memory rawN = _b64urlDecode(c.nB64url); - if (rawN.length != 256) revert InvalidModulusLength(); - bytes32[18] memory limbs = _splitLimbs(rawN); - - bytes memory packed = new bytes(18 * 32); - for (uint256 i = 0; i < 18; i++) { - bytes32 v = limbs[i]; - assembly { - mstore(add(add(packed, 32), mul(i, 32)), v) - } - } - bytes32 modulusHash = keccak256(packed); - - bytes32 kidHash = keccak256(c.kid); - // Ignore a claim proved no later than the one already applied — the - // monotonic rule that makes replay harmless. Rotation is open and a - // proof binds no contract, so anyone can replay any still-fresh - // reading anywhere, forever; ignoring (never reverting) keeps a - // front-runner from bricking the honest keeper's batch by landing one - // claim from it first, and skipping (never applying) keeps an older - // reading from rolling a kid back or re-stamping its TTL. - if (provenAt < rotatedAtKid[kidHash]) return; - bytes32 previous = modulusOfKid[kidHash]; - // A byte-identical resubmission of the applied reading is a no-op: - // nothing is written, nothing is emitted, and in particular the TTL is - // NOT re-stamped — so spamming the same proof neither grows state nor - // stretches trust. - if (provenAt == rotatedAtKid[kidHash] && previous == modulusHash) return; - rotatedAtKid[kidHash] = provenAt; - - // The key this kid used to carry stops being trusted NOW, rather than - // when its thirty-day stamp runs out. - // - // The verifier resolves by modulus, not by kid, so leaving the old - // entry keeps a retired key usable for the rest of its TTL — which is - // exactly the window a compromised key would be used in. Google - // publishes overlapping keys and tokens live about an hour, so the cost - // is that a token signed by the key Google just retired stops verifying - // a little early; the user signs in again. - if (previous != bytes32(0) && previous != modulusHash) { - delete trustedHashExpiresAt[previous]; - emit ModulusUntrusted(previous); - } else if (previous == bytes32(0)) { - _trackKid(kidHash); - } - - modulusOfKid[kidHash] = modulusHash; - expiresAtKid[kidHash] = expiry; - trustedHashExpiresAt[modulusHash] = expiry; - emit ModulusRotated(kidHash, string(c.kid), modulusHash, expiry); - emit RootApplied(kidHash, modulusHash, provenAt, expiry); - } - - /// Add a kid to the enumeration. Growth is doubly bounded: a kid can only - /// enter through a notarized reading of Google's own JWKS (a submitter - /// cannot invent one), and the set is capped — when full, expired entries - /// are pruned to make room, and only a set full of LIVE keys refuses. - function _trackKid(bytes32 kidHash) internal { - if (_trackedKidIndex[kidHash] != 0) return; - if (_trackedKids.length >= MAX_TRACKED_KIDS) { - _pruneExpired(); - if (_trackedKids.length >= MAX_TRACKED_KIDS) revert TooManyKids(); - } - _trackedKids.push(kidHash); - _trackedKidIndex[kidHash] = _trackedKids.length; - } - - /// Swap-remove every expired kid. `rotatedAtKid` survives on purpose: it is - /// the monotonic floor that keeps a replayed old reading from resurrecting - /// the entry the prune just removed. - function _pruneExpired() internal { - uint256 i = _trackedKids.length; - while (i > 0) { - i--; - bytes32 kidHash = _trackedKids[i]; - if (expiresAtKid[kidHash] > block.timestamp) continue; - - bytes32 modulusHash = modulusOfKid[kidHash]; - delete modulusOfKid[kidHash]; - delete expiresAtKid[kidHash]; - // Only clear the verifier-facing stamp if it is genuinely spent — - // it can sit above the kid's own expiry only if some fresher - // rotation re-trusted the same modulus under another kid. - if (trustedHashExpiresAt[modulusHash] <= block.timestamp) { - delete trustedHashExpiresAt[modulusHash]; - } - - uint256 last = _trackedKids.length - 1; - if (i != last) { - bytes32 moved = _trackedKids[last]; - _trackedKids[i] = moved; - _trackedKidIndex[moved] = i + 1; - } - _trackedKids.pop(); - delete _trackedKidIndex[kidHash]; - emit RootPruned(kidHash, modulusHash); - } - } - - function _verifyLeaf(bytes32[] memory path, bytes32 root, string memory prefix, bytes memory value) internal pure { - if (path.length > 32) revert PathTooLong(); - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - if (!_merkleVerifyMem(path, root, leaf)) revert MerkleMismatch(); - } - - function _merkleVerifyMem(bytes32[] memory path, bytes32 root, bytes32 leaf) internal pure returns (bool) { - bytes32 cur = leaf; - for (uint256 i = 0; i < path.length; i++) { - cur = _hashPair(cur, path[i]); - } - return cur == root; - } - - function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - function _containsKeyValue(bytes memory haystack, bytes memory key, bytes memory value) - internal - pure - returns (bool) - { - return _contains(haystack, abi.encodePacked('"', key, '":"', value, '"')) - || _contains(haystack, abi.encodePacked('"', key, '": "', value, '"')); - } - - function _contains(bytes memory haystack, bytes memory needle) internal pure returns (bool) { - uint256 hLen = haystack.length; - uint256 nLen = needle.length; - if (nLen == 0) return true; - if (nLen > hLen) return false; - for (uint256 i = 0; i + nLen <= hLen; i++) { - bool m = true; - for (uint256 j = 0; j < nLen; j++) { - if (haystack[i + j] != needle[j]) { - m = false; - break; - } - } - if (m) return true; - } - return false; - } - - function _b64urlDecode(bytes memory input) internal pure returns (bytes memory) { - uint256 len = input.length; - while (len > 0 && uint8(input[len - 1]) == 0x3d) len--; - uint256 fullChunks = len / 4; - uint256 tail = len % 4; - if (tail == 1) revert InvalidB64Length(); - uint256 outLen = fullChunks * 3 + (tail == 0 ? 0 : tail - 1); - bytes memory out = new bytes(outLen); - uint256 outIdx = 0; - uint256 inIdx = 0; - for (uint256 i = 0; i < fullChunks; i++) { - uint256 b0 = _b64char(input[inIdx]); - uint256 b1 = _b64char(input[inIdx + 1]); - uint256 b2 = _b64char(input[inIdx + 2]); - uint256 b3 = _b64char(input[inIdx + 3]); - inIdx += 4; - out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); - out[outIdx + 1] = bytes1(uint8(((b1 & 0xf) << 4) | (b2 >> 2))); - out[outIdx + 2] = bytes1(uint8(((b2 & 0x3) << 6) | b3)); - outIdx += 3; - } - if (tail == 2) { - uint256 b0 = _b64char(input[inIdx]); - uint256 b1 = _b64char(input[inIdx + 1]); - out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); - } else if (tail == 3) { - uint256 b0 = _b64char(input[inIdx]); - uint256 b1 = _b64char(input[inIdx + 1]); - uint256 b2 = _b64char(input[inIdx + 2]); - out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); - out[outIdx + 1] = bytes1(uint8(((b1 & 0xf) << 4) | (b2 >> 2))); - } - return out; - } - - function _b64char(bytes1 c) internal pure returns (uint256) { - uint8 v = uint8(c); - if (v >= 65 && v <= 90) return v - 65; - if (v >= 97 && v <= 122) return v - 71; - if (v >= 48 && v <= 57) return uint256(v) + 4; - if (v == 45) return 62; - if (v == 95) return 63; - revert InvalidB64Char(); - } - - function _splitLimbs(bytes memory be) internal pure returns (bytes32[18] memory limbs) { - uint256[8] memory words; - assembly { - let beOffset := add(be, 32) - for { let i := 0 } lt(i, 8) { i := add(i, 1) } { - mstore(add(words, mul(i, 32)), mload(add(beOffset, mul(i, 32)))) - } - } - for (uint256 k = 0; k < 18; k++) { - uint256 startBit = k * 120; - uint256 endBit = startBit + 120; - if (endBit > 2048) endBit = 2048; - uint256 width = endBit - startBit; - uint256 startWord = startBit / 256; - uint256 endWord = (endBit - 1) / 256; - uint256 lv; - if (startWord == endWord) { - uint256 w = words[7 - startWord]; - lv = (w >> (startBit % 256)) & ((uint256(1) << width) - 1); - } else { - uint256 lowWord = words[7 - startWord]; - uint256 highWord = words[7 - endWord]; - uint256 lowShift = startBit % 256; - uint256 lowBits = 256 - lowShift; - uint256 highBits = width - lowBits; - uint256 lowVal = lowWord >> lowShift; - uint256 highMask = (uint256(1) << highBits) - 1; - uint256 highVal = (highWord & highMask) << lowBits; - lv = lowVal | highVal; - } - limbs[k] = bytes32(lv); - } - } - - function _notaryDigest(NotarizedJwksProof memory p) internal pure returns (bytes32) { - return keccak256( - abi.encode( - p.domainHash, - p.clientRandom, - p.serverRandom, - keccak256(p.serverEphemeralKey), - p.transcriptRoot, - p.timestamp - ) - ); - } - function _authorizeUpgrade(address) internal override onlyOwner {} - - /// @dev Renouncing would freeze the notary allowlist, and Google's keys - /// would expire with no way to replace them. - function renounceOwnership() public pure override { - revert("renounce disabled"); - } -} diff --git a/solidity/contracts/identity/IdentityNames.sol b/solidity/contracts/identity/IdentityNames.sol index 9df22c0f..871c3197 100644 --- a/solidity/contracts/identity/IdentityNames.sol +++ b/solidity/contracts/identity/IdentityNames.sol @@ -25,7 +25,7 @@ import {IdentityNodes} from "./IdentityNodes.sol"; /// to, and that address has to be the caller. Nothing else grants a /// binding: there is no owner function that writes, moves or deletes an /// entry in either mapping. An account's own fresh proof retires the -/// handle that account used to hold, and nobody else's — see `bind`. +/// handle that account used to hold, and nobody else's — see `claim`. /// /// **What the owner can still do, stated plainly.** It configures which /// verifiers a platform uses, and a verifier is trusted to report what a @@ -101,7 +101,7 @@ contract IdentityNames is Initializable, UUPSUpgradeable, Ownable2StepUpgradeabl /// **The account id follows from what a version is.** A version is /// another way to prove the SAME account — a notarized session, an /// OIDC token — not another account space. The account did not change, - /// so its id did not change, and `bind` keys every version on the same + /// so its id did not change, and `claim` keys every version on the same /// `idNode(platformId, attested.userId)`. /// /// Read that as a test, not as a rule to remember: a proof format that @@ -157,7 +157,7 @@ contract IdentityNames is Initializable, UUPSUpgradeable, Ownable2StepUpgradeabl /// the plaintext either way, so an indexer never needs this, and only a /// contract that must display a name does. mapping(address => mapping(bytes32 => string)) published; - /// platformId -> its keyspace: handle rules and current version. + /// platformId -> its keyspace: handle rules, and whether it is configured. mapping(bytes32 => Platform) platforms; /// idNode -> the handle node that account last proved, and back. /// @@ -198,8 +198,8 @@ contract IdentityNames is Initializable, UUPSUpgradeable, Ownable2StepUpgradeabl bytes32 private constant IDENTITY_NAMES_STORAGE = 0x064503501234cc9c6e116cf4a84c07475158dabb6a3dcee437a89227e23bf200; - /// @dev All state of this contract sits under one namespaced root, the way - /// `Registry` does. Three things follow. + /// @dev All state of this contract sits under one namespaced root, like + /// every upgradeable contract in this repo. Three things follow. /// /// It cannot collide with the ERC-7201 namespaces of the OpenZeppelin /// upgradeable bases, so this contract owns its slots outright and @@ -267,10 +267,9 @@ contract IdentityNames is Initializable, UUPSUpgradeable, Ownable2StepUpgradeabl /// @notice What a ceremony carried that a binding does not keep. /// - /// @dev Beside `IdentityBound`, not inside it. The two paths share that - /// event, and only a ceremony authenticates an OAuth client -- a field - /// the legacy path could never fill is a field an indexer must always - /// test for emptiness. + /// @dev Beside `IdentityBound`, not inside it: the binding keeps no + /// client identifier, so carrying one on `IdentityBound` would give + /// every indexer a field to test for emptiness. /// /// `clientIdentifier` is the exact bytes the platform authenticated. /// The contract has no use for them: the digest already binds the @@ -285,7 +284,7 @@ contract IdentityNames is Initializable, UUPSUpgradeable, Ownable2StepUpgradeabl /// @notice A handle stopped resolving because the account that held it /// proved a different one. - /// @dev Nobody else's entry can be retired this way. See `bind`. + /// @dev Nobody else's entry can be retired this way. See `claim`. event HandleRetired(bytes32 indexed platformId, bytes32 indexed handleNode, address indexed owner); /// @notice A platform's keyspace was configured or reconfigured. @@ -494,11 +493,9 @@ contract IdentityNames is Initializable, UUPSUpgradeable, Ownable2StepUpgradeabl emit ProofVerifierConfigured(address(verifier)); } - /// @dev Everything after authentication, shared by both entry points. - /// - /// Which proof established a name is the authentication half's - /// business; the keyspace, the ordering and the display are the same - /// whichever half ran. + /// @dev Everything after authentication. Which proof established a name + /// is `claim`'s business; the keyspace, the ordering and the display + /// are decided here. function _write( bytes32 platformId, string memory userId, @@ -590,7 +587,7 @@ contract IdentityNames is Initializable, UUPSUpgradeable, Ownable2StepUpgradeabl /// @notice Withdraw a published handle. Affects the caller's record only. /// /// @dev Publishing is the one thing here a user can undo, and it needs its - /// own door. Passing `publishName: false` to `bind` does NOT clear an + /// own door. Passing `publishName: false` to `claim` does NOT clear an /// earlier publish — a caller that binds again after a rename should /// not silently withdraw a name because a flag defaulted; it refreshes /// the published string to the handle just proved. Withdrawing what diff --git a/solidity/contracts/identity/test/IdentityJwksRoots.t.sol b/solidity/contracts/identity/test/IdentityJwksRoots.t.sol deleted file mode 100644 index 09af364c..00000000 --- a/solidity/contracts/identity/test/IdentityJwksRoots.t.sol +++ /dev/null @@ -1,552 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -import {IdentityJwksRoots} from "../IdentityJwksRoots.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; - -/// The naming system's Google trust list, fed by a notarized reading of -/// Google's JWKS endpoint. -/// -/// The fixture is a real notarized rotation, signed by the deterministic test -/// notary. It is this tree's own copy on purpose: the proof binds no contract, -/// so the same reading is valid wherever that notary is trusted — but reading -/// it out of another tree would be the coupling this system exists without. -contract IdentityJwksRootsTest is Test { - IdentityJwksRoots internal roots; - Notary internal notaryContract; - - address internal constant NOTARY = 0x6f4c950442e1Af093BcfF730381E63Ae9171b87a; - /// A notary this test can sign as, for the cases the fixture cannot reach. - uint256 internal constant TEST_NOTARY_PK = 0xA11CE; - bytes32 internal constant EXPECTED_DOMAIN_HASH = keccak256(bytes("www.googleapis.com")); - - address internal owner = makeAddr("owner"); - address internal keeper = makeAddr("keeper"); - - IdentityJwksRoots.NotarizedJwksProof internal proof; - IdentityJwksRoots.JwkClaim[] internal claims; - uint256 internal provenAt; - - function setUp() public { - notaryContract = deployNotary(owner, NOTARY); - IdentityJwksRoots impl = new IdentityJwksRoots(); - roots = IdentityJwksRoots( - address( - new ERC1967Proxy( - address(impl), abi.encodeCall(IdentityJwksRoots.initialize, (owner, address(notaryContract))) - ) - ) - ); - - _loadFixture(); - vm.warp(provenAt + 60); - } - - function _loadFixture() internal { - string memory j = vm.readFile("contracts/identity/test/fixtures/oidc/jwks-proof.json"); - proof.notarySignature = vm.parseJsonBytes(j, ".notary_signature"); - proof.domainHash = vm.parseJsonBytes32(j, ".domain_hash"); - proof.clientRandom = vm.parseJsonBytes32(j, ".client_random"); - proof.serverRandom = vm.parseJsonBytes32(j, ".server_random"); - proof.serverEphemeralKey = vm.parseJsonBytes(j, ".server_ephemeral_key"); - proof.transcriptRoot = vm.parseJsonBytes32(j, ".transcript_root"); - provenAt = vm.parseJsonUint(j, ".timestamp"); - proof.timestamp = provenAt; - proof.domainPath = vm.parseJsonBytes32Array(j, ".domain_path"); - proof.endpointPath = vm.parseJsonBytes32Array(j, ".endpoint_path"); - - for (uint256 i = 0; i < 16; i++) { - string memory base = string.concat(".claims[", vm.toString(i), "]"); - try this.probeKid(j, base) returns (string memory kid) { - IdentityJwksRoots.JwkClaim memory c; - c.kid = bytes(kid); - c.nB64url = bytes(vm.parseJsonString(j, string.concat(base, ".n_b64url"))); - c.jwkBytes = vm.parseJsonBytes(j, string.concat(base, ".jwk_bytes")); - c.jwkPath = vm.parseJsonBytes32Array(j, string.concat(base, ".jwk_path")); - claims.push(c); - } catch { - break; - } - } - require(claims.length > 0, "fixture carries no claims"); - } - - function probeKid(string memory j, string memory base) external view returns (string memory) { - return vm.parseJsonString(j, string.concat(base, ".kid")); - } - - function _noClaims() internal pure returns (IdentityJwksRoots.JwkClaim[] memory) { - return new IdentityJwksRoots.JwkClaim[](0); - } - - /// A reading signed by `TEST_NOTARY_PK`, naming whatever host is asked for. - /// The digest is over the TLS session alone — no contract appears in it, - /// which is why one reading is valid wherever the notary is trusted. - function _signedProof(bytes32 domainHash) internal view returns (IdentityJwksRoots.NotarizedJwksProof memory p) { - p.domainHash = domainHash; - p.clientRandom = keccak256("client"); - p.serverRandom = keccak256("server"); - p.serverEphemeralKey = bytes("ephemeral-key"); - p.transcriptRoot = keccak256("root"); - p.timestamp = block.timestamp; - p.domainPath = new bytes32[](0); - p.endpointPath = new bytes32[](0); - _sign(p); - } - - /// Sign whatever the proof currently says. Callers that edit the transcript - /// re-sign, since the root is inside the digest. - function _sign(IdentityJwksRoots.NotarizedJwksProof memory p) internal pure { - bytes32 digest = keccak256( - abi.encode( - p.domainHash, - p.clientRandom, - p.serverRandom, - keccak256(p.serverEphemeralKey), - p.transcriptRoot, - p.timestamp - ) - ); - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(TEST_NOTARY_PK, ethHash); - p.notarySignature = abi.encodePacked(r, s, v); - } - - // ─── What it accepts ──────────────────────────────────────────── - - function test_aNotarizedReadingInstallsTheKeys() public { - roots.rotate(proof, claims); - - for (uint256 i = 0; i < claims.length; i++) { - bytes32 kidHash = keccak256(claims[i].kid); - bytes32 modulusHash = roots.modulusOfKid(kidHash); - assertTrue(modulusHash != bytes32(0), "kid installed"); - assertEq(roots.trustedHashExpiresAt(modulusHash), block.timestamp + roots.DEFAULT_MODULUS_TTL()); - } - } - - /// Anyone may submit one. The proof carries its own authority — the notary - /// signed it — so gating the caller would only add a keeper to trust. - function test_anybodyMaySubmitARotation() public { - vm.prank(keeper); - roots.rotate(proof, claims); - - assertTrue(roots.modulusOfKid(keccak256(claims[0].kid)) != bytes32(0)); - } - - /// Re-applying the same reading writes the same values. It must not revert: - /// with an open caller set, a revert would let a front-runner land one - /// claim and brick the honest keeper's batch. - function test_replayingAReadingIsIdempotent() public { - roots.rotate(proof, claims); - bytes32 first = roots.modulusOfKid(keccak256(claims[0].kid)); - - roots.rotate(proof, claims); - assertEq(roots.modulusOfKid(keccak256(claims[0].kid)), first); - } - - // ─── What it refuses ──────────────────────────────────────────── - - /// The signature is the whole authority, so a reading the current notary - /// did not sign is worth nothing. Rotation happens on the shared Notary - /// contract, and this list follows it. - function test_aReadingFromAnUnknownNotaryIsRefused() public { - vm.prank(owner); - notaryContract.setNotary(makeAddr("somebody else")); - - vm.expectRevert(IdentityJwksRoots.UnknownNotary.selector); - roots.rotate(proof, claims); - } - - /// The host is inside the signed digest, so it cannot be edited after the - /// fact: changing it makes the signature recover to somebody else. That is - /// what stops a notarized reading of one host being presented as another's. - function test_theHostCannotBeChangedAfterSigning() public { - IdentityJwksRoots.NotarizedJwksProof memory p = proof; - p.domainHash = keccak256("accounts.example.com"); - - vm.expectRevert(IdentityJwksRoots.UnknownNotary.selector); - roots.rotate(p, claims); - } - - /// And a reading genuinely signed for another host is refused on its - /// merits. Signed here with a key this test holds, since the fixture's - /// notary key is not in the repository. - /// - /// No claims are needed: the host is checked before any key is read, which - /// is the order that matters — the expensive parsing never runs for a - /// reading that was never about Google. - function test_aReadingGenuinelySignedForAnotherHostIsRefused() public { - vm.prank(owner); - notaryContract.setNotary(vm.addr(TEST_NOTARY_PK)); - - IdentityJwksRoots.NotarizedJwksProof memory p = _signedProof(keccak256("accounts.example.com")); - - vm.expectRevert(IdentityJwksRoots.WrongDomain.selector); - roots.rotate(p, _noClaims()); - } - - /// The right host, but a transcript that does not carry it as a leaf. The - /// signature covers the root, not what is under it. - function test_aReadingWithoutTheHostLeafIsRefused() public { - vm.prank(owner); - notaryContract.setNotary(vm.addr(TEST_NOTARY_PK)); - - IdentityJwksRoots.NotarizedJwksProof memory p = _signedProof(keccak256(bytes("www.googleapis.com"))); - - vm.expectRevert(IdentityJwksRoots.MerkleMismatch.selector); - roots.rotate(p, _noClaims()); - } - - /// Past the window the reading says nothing about what Google publishes - /// now, which is the only question this list answers. - function test_aStaleReadingIsRefused() public { - vm.warp(provenAt + roots.FRESHNESS_WINDOW() + 1); - - vm.expectRevert(IdentityJwksRoots.StaleProof.selector); - roots.rotate(proof, claims); - } - - /// Rotation is open, so an older reading replayed inside the window would - /// otherwise roll a kid back to a modulus Google has retired — and re-stamp - /// it for another full TTL. - function test_anOlderReadingCannotRollAKidBack() public { - roots.rotate(proof, claims); - - bytes32 kidHash = keccak256(claims[0].kid); - bytes32 current = roots.modulusOfKid(kidHash); - uint256 currentExpiry = roots.expiresAtKid(kidHash); - - // The same claims, presented as if proved a minute earlier. The - // timestamp is inside the signed digest, so editing it makes the - // signature recover to somebody else — which is the guard working, one - // step earlier than the monotonic check behind it. - IdentityJwksRoots.NotarizedJwksProof memory older = proof; - older.timestamp = provenAt - 60; - - vm.expectRevert(IdentityJwksRoots.UnknownNotary.selector); - roots.rotate(older, claims); - - assertEq(roots.modulusOfKid(kidHash), current, "kid unchanged"); - assertEq(roots.expiresAtKid(kidHash), currentExpiry, "expiry not re-stamped"); - } - - // ─── Losing trust ─────────────────────────────────────────────── - - /// The verifier resolves by modulus, not by kid, so a key that a rotation - /// replaced has to stop being trusted at that moment. Leaving it keeps a - /// retired key usable for the rest of its thirty-day stamp — which is the - /// exact window a compromised key would be used in. - function test_rotatingAKidRetiresTheKeyItCarried() public { - vm.prank(owner); - notaryContract.setNotary(vm.addr(TEST_NOTARY_PK)); - - // Install a first key for this kid, then a second one for the same kid. - bytes32 first = _installKid("kid-1", "modulus-one"); - assertGt(roots.trustedHashExpiresAt(first), 0, "first key trusted"); - - bytes32 second = _installKid("kid-1", "modulus-two"); - assertGt(roots.trustedHashExpiresAt(second), 0, "second key trusted"); - assertEq(roots.trustedHashExpiresAt(first), 0, "the replaced key is no longer trusted"); - } - - /// The case that cannot wait for a rotation: a key Google has not retired, - /// or has retired in a way this list has not seen yet. - function test_theOwnerCanUntrustAKeyOutright() public { - roots.rotate(proof, claims); - - bytes32 kidHash = keccak256(claims[0].kid); - bytes32 modulusHash = roots.modulusOfKid(kidHash); - assertGt(roots.trustedHashExpiresAt(modulusHash), 0); - - vm.prank(owner); - roots.untrustModulus(modulusHash); - - assertEq(roots.trustedHashExpiresAt(modulusHash), 0, "no longer trusted"); - } - - function test_onlyTheOwnerUntrusts() public { - vm.prank(keeper); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, keeper)); - roots.untrustModulus(bytes32(uint256(1))); - } - - /// Install `kid` carrying a key derived from `seed`, and return the modulus - /// hash it wrote. - function _installKid(string memory kid, string memory seed) internal returns (bytes32) { - (IdentityJwksRoots.NotarizedJwksProof memory p, IdentityJwksRoots.JwkClaim[] memory one) = - _kidReading(kid, seed, block.timestamp); - roots.rotate(p, one); - return roots.modulusOfKid(keccak256(bytes(kid))); - } - - /// Build a full notarized reading that carries one key, signed by the - /// notary key this test holds, timestamped `provedAt`. - /// - /// The fixture holds one reading of Google's real keys, and a rotation only - /// retires something when a kid comes back carrying a DIFFERENT key — which - /// no single reading contains. So this builds a second reading: a JWKS leaf - /// of its own, a transcript that carries it beside the domain and endpoint - /// leaves, and the notary key this test holds. - function _kidReading(string memory kid, string memory seed, uint256 provedAt) - internal - view - returns (IdentityJwksRoots.NotarizedJwksProof memory p, IdentityJwksRoots.JwkClaim[] memory one) - { - bytes memory nB64 = _syntheticModulus(seed); - bytes memory jwk = abi.encodePacked('{"kid":"', kid, '","n":"', nB64, '"}'); - - // Three leaves under one root: domain and endpoint, which every reading - // must carry, and this key. Siblings are hashed in sorted order, so a - // path is just the list of them from the leaf upward. - bytes32 domainLeaf = _leaf("domain:", bytes("www.googleapis.com")); - bytes32 endpointLeaf = _leaf("endpoint:", bytes("/oauth2/v3/certs")); - bytes32 jwkLeaf = _leaf("recv:", jwk); - bytes32 pair = _hashPair(domainLeaf, endpointLeaf); - bytes32 root = _hashPair(pair, jwkLeaf); - - p = _signedProof(EXPECTED_DOMAIN_HASH); - p.transcriptRoot = root; - p.domainPath = _path(endpointLeaf, jwkLeaf); - p.endpointPath = _path(domainLeaf, jwkLeaf); - p.timestamp = provedAt; - _sign(p); - - one = new IdentityJwksRoots.JwkClaim[](1); - one[0].jwkBytes = jwk; - one[0].jwkPath = _path(pair); - one[0].kid = bytes(kid); - one[0].nB64url = nB64; - } - - /// A 2048-bit modulus, written straight in base64url so the test needs no - /// encoder: 342 characters decode to exactly the 256 bytes the contract - /// requires. Different seeds give different keys, which is the only property - /// the caller needs. - function _syntheticModulus(string memory seed) internal pure returns (bytes memory out) { - bytes memory alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - bytes32 filler = keccak256(bytes(seed)); - out = new bytes(342); - for (uint256 i = 0; i < 342; i++) { - out[i] = alphabet[uint8(filler[i % 32]) & 0x3f]; - } - } - - function _leaf(string memory prefix, bytes memory value) internal pure returns (bytes32) { - return keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - } - - function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - function _path(bytes32 a) internal pure returns (bytes32[] memory p) { - p = new bytes32[](1); - p[0] = a; - } - - function _path(bytes32 a, bytes32 b) internal pure returns (bytes32[] memory p) { - p = new bytes32[](2); - p[0] = a; - p[1] = b; - } - - // ─── Monotonicity under replay ────────────────────────────────── - - /// The rollback that matters: a GENUINELY signed older reading, replayed - /// inside its freshness window after a newer one has landed. The signature - /// verifies; the per-kid monotonic stamp is what refuses the regression — - /// silently, so a keeper batch is never bricked. - function test_aGenuinelySignedOlderReadingCannotRegressState() public { - vm.prank(owner); - notaryContract.setNotary(vm.addr(TEST_NOTARY_PK)); - - // The older reading exists first (proved 30 min ago), but the NEWER - // one lands on chain first. - (IdentityJwksRoots.NotarizedJwksProof memory oldP, IdentityJwksRoots.JwkClaim[] memory oldC) = - _kidReading("kid-1", "retired-modulus", block.timestamp - 30 minutes); - bytes32 newModulus = _installKid("kid-1", "current-modulus"); - - bytes32 kidHash = keccak256(bytes("kid-1")); - uint256 stamp = roots.rotatedAtKid(kidHash); - uint256 expiry = roots.expiresAtKid(kidHash); - - vm.prank(keeper); - roots.rotate(oldP, oldC); // must not revert, must not apply - - assertEq(roots.modulusOfKid(kidHash), newModulus, "older reading rolled the kid back"); - assertEq(roots.rotatedAtKid(kidHash), stamp, "older reading overwrote the stamp"); - assertEq(roots.expiresAtKid(kidHash), expiry, "older reading re-stamped the TTL"); - assertEq(roots.trustedHashExpiresAt(newModulus), expiry, "current key lost trust"); - } - - /// Resubmitting the exact reading already applied writes nothing — in - /// particular it does NOT re-stamp the TTL, so spamming one proof for its - /// whole freshness window cannot stretch trust by even a second. - function test_anIdenticalResubmissionDoesNotRestampTheTtl() public { - roots.rotate(proof, claims); - bytes32 kidHash = keccak256(claims[0].kid); - uint256 expiry = roots.expiresAtKid(kidHash); - - vm.warp(block.timestamp + 30 minutes); // still inside the window - vm.prank(keeper); - roots.rotate(proof, claims); - - assertEq(roots.expiresAtKid(kidHash), expiry, "replay re-stamped the TTL"); - } - - /// The freshest-reading mark only moves forward, whoever submits. - function test_freshestObservedAtIsMonotonic() public { - assertEq(roots.freshestObservedAt(), 0); - roots.rotate(proof, claims); - assertEq(roots.freshestObservedAt(), provenAt); - - vm.prank(owner); - notaryContract.setNotary(vm.addr(TEST_NOTARY_PK)); - (IdentityJwksRoots.NotarizedJwksProof memory oldP, IdentityJwksRoots.JwkClaim[] memory oldC) = - _kidReading("kid-1", "whatever", provenAt - 10 minutes); - roots.rotate(oldP, oldC); - assertEq(roots.freshestObservedAt(), provenAt, "an older reading moved the mark"); - - vm.warp(provenAt + 20 minutes); - (IdentityJwksRoots.NotarizedJwksProof memory newP, IdentityJwksRoots.JwkClaim[] memory newC) = - _kidReading("kid-1", "whatever", provenAt + 10 minutes); - roots.rotate(newP, newC); - assertEq(roots.freshestObservedAt(), provenAt + 10 minutes, "a newer reading must move the mark"); - } - - // ─── Expiry and pruning ───────────────────────────────────────── - - /// Time alone retires a key: past its stamp the verifier-facing read says - /// expired, with no transaction from anyone. `prune()` — callable by - /// anyone — then reclaims the bookkeeping. - function test_anExpiredKeyIsPrunableByAnyone() public { - roots.rotate(proof, claims); - bytes32 kidHash = keccak256(claims[0].kid); - bytes32 modulusHash = roots.modulusOfKid(kidHash); - uint256 expiry = roots.expiresAtKid(kidHash); - - vm.warp(expiry); // the stamp is spent; use-site checks already refuse it - - vm.expectEmit(true, false, false, true); - emit IdentityJwksRoots.RootPruned(kidHash, modulusHash); - vm.prank(keeper); - roots.prune(); - - assertEq(roots.modulusOfKid(kidHash), bytes32(0), "kid still tracked"); - assertEq(roots.expiresAtKid(kidHash), 0); - assertEq(roots.trustedHashExpiresAt(modulusHash), 0, "spent stamp not cleared"); - assertEq(roots.currentRoots().length, 0, "enumeration not reclaimed"); - // The monotonic floor survives the prune: an old reading still cannot - // resurrect what time retired. - assertEq(roots.rotatedAtKid(kidHash), provenAt, "provenance floor lost"); - } - - /// Pruning is a no-op while the keys are alive. - function test_pruneLeavesLiveKeysAlone() public { - roots.rotate(proof, claims); - uint256 before = roots.currentRoots().length; - - vm.prank(keeper); - roots.prune(); - - assertEq(roots.currentRoots().length, before, "a live key was pruned"); - assertGt(roots.trustedHashExpiresAt(roots.modulusOfKid(keccak256(claims[0].kid))), 0); - } - - /// The tracked set is capped, and the cap defends itself: a full set first - /// sheds its expired entries, and only a set full of LIVE keys refuses. - /// Since every kid must arrive inside a notarized reading of Google's own - /// JWKS, an adversary cannot even choose the kids — this is the ceiling on - /// Google's, not the submitter's, behavior. - function test_theTrackedSetIsBoundedAndSelfPrunes() public { - vm.prank(owner); - notaryContract.setNotary(vm.addr(TEST_NOTARY_PK)); - - uint256 max = roots.MAX_TRACKED_KIDS(); - for (uint256 i = 0; i < max; i++) { - _installKid(string.concat("kid-", vm.toString(i)), vm.toString(i)); - } - assertEq(roots.currentRoots().length, max, "set should sit at the cap"); - - // A brand-new kid while every slot holds a live key: refused. - (IdentityJwksRoots.NotarizedJwksProof memory p, IdentityJwksRoots.JwkClaim[] memory one) = - _kidReading("kid-overflow", "overflow", block.timestamp); - vm.expectRevert(IdentityJwksRoots.TooManyKids.selector); - roots.rotate(p, one); - - // Once the old stamps are spent, the same insert prunes its own room. - vm.warp(block.timestamp + roots.DEFAULT_MODULUS_TTL() + 1); - (p, one) = _kidReading("kid-overflow", "overflow", block.timestamp); - roots.rotate(p, one); - assertEq(roots.currentRoots().length, 1, "expired entries should have been shed"); - assertEq(roots.currentRoots()[0].kidHash, keccak256(bytes("kid-overflow"))); - } - - // ─── What a keeper reads ──────────────────────────────────────── - - /// One call, the whole state: every tracked key with its provenance and - /// expiry. - function test_currentRootsDescribesTheWholeList() public { - roots.rotate(proof, claims); - - IdentityJwksRoots.RootInfo[] memory infos = roots.currentRoots(); - assertEq(infos.length, claims.length, "every claim should be listed"); - for (uint256 i = 0; i < infos.length; i++) { - assertEq(infos[i].modulusHash, roots.modulusOfKid(infos[i].kidHash)); - assertEq(infos[i].observedAt, provenAt, "provenance is the reading's timestamp"); - assertEq(infos[i].expiresAt, roots.trustedHashExpiresAt(infos[i].modulusHash)); - } - } - - /// The single bit a keeper polls: true until the first rotation, false - /// while a key has RENEWAL_MARGIN of trusted runway, true again as the - /// runway shortens — and long before anything actually expires. - function test_needsRotationTracksTheTrustedRunway() public { - assertTrue(roots.needsRotation(), "an empty list needs a rotation"); - - roots.rotate(proof, claims); - assertFalse(roots.needsRotation(), "a fresh rotation buys quiet"); - - bytes32 kidHash = keccak256(claims[0].kid); - uint256 expiry = roots.expiresAtKid(kidHash); - vm.warp(expiry - roots.RENEWAL_MARGIN()); - assertTrue(roots.needsRotation(), "the margin should trip before expiry does"); - } - - /// Each applied key announces itself with the reading's own timestamp, so - /// a bot can follow the list from logs alone. - function test_rotationEmitsRootAppliedWithProvenance() public { - bytes32 kidHash = keccak256(claims[0].kid); - vm.expectEmit(true, false, false, false); - emit IdentityJwksRoots.RootApplied(kidHash, bytes32(0), provenAt, 0); - roots.rotate(proof, claims); - } - - // ─── Administration ───────────────────────────────────────────── - - function test_onlyTheOwnerRotatesTheNotary() public { - vm.prank(keeper); - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, keeper)); - notaryContract.setNotary(keeper); - } - - /// The list reads the notary through the shared Notary contract. - function test_theNotaryIsReadThroughTheNotaryContract() public view { - assertEq(address(roots.notaryContract()), address(notaryContract)); - assertEq(roots.notary(), NOTARY); - } - - /// Renouncing would freeze the list while Google's keys keep expiring, so - /// the naming system would stop accepting Google proofs with no way back. - function test_renouncingIsDisabled() public { - vm.prank(owner); - vm.expectRevert(bytes("renounce disabled")); - roots.renounceOwnership(); - } -} diff --git a/solidity/contracts/identity/test/IdentityNames.t.sol b/solidity/contracts/identity/test/IdentityNames.t.sol index 9fd9681b..0b5e112e 100644 --- a/solidity/contracts/identity/test/IdentityNames.t.sol +++ b/solidity/contracts/identity/test/IdentityNames.t.sol @@ -490,9 +490,9 @@ contract IdentityNamesTest is Test { names.resolveHandle(unknown, "alice"); } - /// `bind` keeps reverting. A handle that arrives inside a proof and does + /// `claim` keeps reverting. A handle that arrives inside a proof and does /// not normalize is a broken proof, and failing loudly is right. - function test_bindStillRefusesAHandleThatDoesNotNormalize() public { + function test_claimStillRefusesAHandleThatDoesNotNormalize() public { _stage("123", "ali ce", alice, 100); vm.prank(alice); vm.expectRevert(HandleNormalizer.BadCharacter.selector); diff --git a/solidity/contracts/identity/test/fixtures/oidc/jwks-proof.json b/solidity/contracts/identity/test/fixtures/oidc/jwks-proof.json deleted file mode 100644 index 891e1bbd..00000000 --- a/solidity/contracts/identity/test/fixtures/oidc/jwks-proof.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "notary_signature": "0xac2dd7d49bc1d763ebec55bf3ea8d4ca3057a6c990a84658cbbc6bda6c6f8fc62b8bf782ecc81fdbe17a49a9900c5ce8599d99bdd4bdba199d98a5d63d3ec7d21c", - "domain_hash": "0x59962e64d51a4d36e444d18d029aff438ce3ddb882eb151a0af0fdde3cb655ee", - "client_random": "0x0000000000000000000000000000000000000000000000000000000000000000", - "server_random": "0x0000000000000000000000000000000000000000000000000000000000000000", - "server_ephemeral_key": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "transcript_root": "0xb66b4209a1c2e90f9bd4318986e32bffce84ba9cadd2690426dc0e19e78924c3", - "timestamp": 1777900352, - "domain_path": [ - "0x3a48ec7938d008c4fbdfc033b9c0ada126a46089e460e5f3af5eda1352f9a4ee", - "0x4fd66d65911c483bedf4d98f568b8f40ce2cc7b652a9cf849b269f07de10c1cd" - ], - "endpoint_path": [ - "0x2fc77341079e14a1103642484ef49d836a16a456f497722239924495e377d4a8", - "0x4fd66d65911c483bedf4d98f568b8f40ce2cc7b652a9cf849b269f07de10c1cd" - ], - "claims": [ - { - "kid": "19caaecde8f485e8f5938f48aba0ce7a3581f027", - "n_b64url": "xGoOLta_JnpIStUy0UMlbQRnMZhRZ3MJqm3TQMb2nXQwstM91mEhjjGikBJoCA4sGXyq9SQmLcicQ-ezhnTD5TCqbLhjz_NndK4IUUaUcG0eOxYMnG0LlLJhZ4BonBjA7m-xDDL-vO7wSVi0t9xbYi4abO48NqJQn65rlE-mhIc15tgLawIm9oq7yoI1jYYsn3Ik_PMp-h8W-CHiUSxDhYfkIuMtnMoBwpjGLiWjzlV-yBNce1V1iKRK7uIuyuypAL3SKAcHCfxQEfAFBUuf3pM2qGcaBCtPpW096QaXdTs8_Tjoq7DwsmaPLOtK5EKzkDUcp0noRlDWB6fPP-rUMw", - "jwk_bytes": "0x7b0a2020202020202265223a202241514142222c0a202020202020226b7479223a2022525341222c0a20202020202022616c67223a20225253323536222c0a20202020202022757365223a2022736967222c0a202020202020226e223a202278476f4f4c74615f4a6e70495374557930554d6c6251526e4d5a68525a334d4a716d3354514d62326e58517773744d39316d45686a6a47696b424a6f43413473475879713953516d4c636963512d657a686e544435544371624c686a7a5f4e6e644b344955556155634730654f78594d6e47304c6c4c4a685a34426f6e426a41376d2d7844444c2d764f3777535669307439786259693461624f34384e714a516e3635726c452d6d684963313574674c6177496d396f7137796f49316a5959736e33496b5f504d702d6838572d434869555378446859666b49754d746e4d6f4277706a474c69576a7a6c562d79424e6365315631694b524b3775497579757970414c33534b41634843667851456641464255756633704d3271476361424374507057303936516158645473385f546a6f71374477736d61504c4f744b35454b7a6b44556370306e6f526c445742366650502d72554d77222c0a202020202020226b6964223a202231396361616563646538663438356538663539333866343861626130636537613335383166303237220a202020207d", - "jwk_path": [ - "0xc757d6ad13f0c4383803188f05d92c1386c91ee1b378bfd61107e8dcd6c73e58", - "0x546e7f882e139fc1436c0b1a28055a8c72d534e32529335e9297875dc7ab05ae" - ] - }, - { - "kid": "8076ddbab415556e169154c6a4a0bdbd4469b79c", - "n_b64url": "uPQl9hY_shV81dJgVp7qJdGcOsEkLD5g6EdpBOuzzul4THxflhFAkzJACPUSUdQpOIw5pKHD9sGhnLoqnOu-n_AdbJtAI_NG56KWawoZY1YQRr6GqJxccMTeh2vEdaM6haiYKbftbCk7RWsvWia0gPhiIfC6_D3OwI6ZVIsMp-U9fTDBo2wEG5fA4DIZn_QU8TAyzi4BJbgQYXmy3LETW-90v5S3ub7WFsaGqL7NEdxER_RQ6m7L2Pl4jIjbN8bkNKeUslXYm56MH1x3hD5RqeKJoZylpgT00XVHs4br3p1iSz4t8uPvvy0Q2oNEVkXgv36i7QK4s_oe_qhZFfW_UQ", - "jwk_bytes": "0x7b0a202020202020226b7479223a2022525341222c0a2020202020202265223a202241514142222c0a20202020202022757365223a2022736967222c0a20202020202022616c67223a20225253323536222c0a202020202020226e223a20227550516c3968595f7368563831644a67567037714a6447634f73456b4c44356736456470424f757a7a756c34544878666c6846416b7a4a4143505553556451704f497735704b4844397347686e4c6f716e4f752d6e5f4164624a7441495f4e4735364b5761776f5a5931595152723647714a7863634d54656832764564614d36686169594b62667462436b37525773765769613067506869496643365f44334f7749365a5649734d702d5539665444426f327745473566413444495a6e5f5155385441797a6934424a62675159586d79334c4554572d3930763553337562375746736147714c374e45647845525f5251366d374c32506c346a496a624e38626b4e4b6555736c58596d35364d483178336844355271654b4a6f5a796c70675430305856487334627233703169537a34743875507676793051326f4e45566b58677633366937514b34735f6f655f71685a4666575f5551222c0a202020202020226b6964223a202238303736646462616234313535353665313639313534633661346130626462643434363962373963220a202020207d", - "jwk_path": [ - "0x5faffac008736e1a823a01a782add8c6911e47106674955396c01954f5f7e301", - "0x546e7f882e139fc1436c0b1a28055a8c72d534e32529335e9297875dc7ab05ae" - ] - } - ] -} \ No newline at end of file diff --git a/solidity/contracts/login/IRegistry.sol b/solidity/contracts/login/IRegistry.sol deleted file mode 100644 index f775b582..00000000 --- a/solidity/contracts/login/IRegistry.sol +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title IRegistry — maps (platform, handle) → wallet address. -interface IRegistry { - /// @notice Resolve a platform handle to its wallet contract address. - /// @return The wallet address, or address(0) if not registered. - function resolve(string calldata platform, string calldata handle) external view returns (address); - - /// @notice Resolve a platform id to its wallet — authoritative, no handle indirection. - /// @return The wallet address, or address(0) if not registered. - function resolveById(string calldata platform, string calldata id) external view returns (address); - - /// @notice The immutable id a handle currently points to ("" if unbound). - function handleHint(string calldata platform, string calldata handle) external view returns (string memory); - - /// @notice Get all (platform, handle) pairs linked to a wallet. - function getHandles(address wallet) external view returns (string[] memory platforms, string[] memory handles); - - /// @notice Get all (platform, userId) pairs linked to a wallet — the - /// immutable identity key (handle is a mutable label). "" for - /// identities registered before the id refactor. - function getUserIds(address wallet) external view returns (string[] memory platforms, string[] memory userIds); - - /// @notice Emitted when a handle is registered or updated. - event HandleRegistered(string platform, string handle, address indexed owner); -} diff --git a/solidity/contracts/login/Registry.sol b/solidity/contracts/login/Registry.sol deleted file mode 100644 index 37a1f9fc..00000000 --- a/solidity/contracts/login/Registry.sol +++ /dev/null @@ -1,743 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; -import {IRegistry} from "./IRegistry.sol"; -import {WalletFactory} from "./WalletFactory.sol"; -import {WebWallet} from "./WebWallet.sol"; -import {IZkSessionVerifier} from "./zk/IZkSessionVerifier.sol"; -import {IOidcVerifier} from "./oidc/IOidcVerifier.sol"; -import {INotary} from "../notary/INotary.sol"; - -/// @title Registry — session registration and identity linking with 2-of-2 proof verification. -contract Registry is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable, PausableUpgradeable, IRegistry { - using ECDSA for bytes32; - - // ─── Proof verification state ────────────────────────────────── - - /// @notice Tolerance for proof timestamps that land slightly ahead of - /// `block.timestamp`. Eden's block head typically trails wall - /// clock by a handful of seconds, so a freshly notarized proof - /// would otherwise revert with `FutureProof`. - uint256 public constant CLOCK_SKEW_GRACE = 5 minutes; - - struct HandleEntry { - string platform; - string handle; - string userId; // immutable platform id; "" for pre-refactor entries - } - - /// @notice Per-domain platform configuration for on-chain verification. - struct PlatformConfig { - string endpoint; // expected API endpoint path, e.g. "/user" - string handlePrefix; // JSON prefix including quotes/colon/whitespace, e.g. '"login":"' or '"email": "' - string idPrefix; // JSON prefix of the immutable id. Empty disables id-binding. - string idSuffix; // JSON suffix anchoring the id's end: '"' for quoted (X), ',' for a bare number (GitHub) - } - - struct IdMeta { - string handle; // current display handle for this id - uint256 verifiedAt; // block.timestamp of last id-handle proof - } - - /// @custom:storage-location erc7201:dyaka.storage.Registry - struct RegistryStorage { - INotary notaryContract; // the shared Notary contract (verifies notary attestations) - address backend; // backend signer - WalletFactory walletFactory; - /// Replay protection: sessionAddress to already used (flat, globally unique). - mapping(address => bool) usedSessionKeys; - /// wallet contract to array of (platform, handle, id) tuples. - mapping(address => HandleEntry[]) contract_to_handles; - /// domain to platform config. A platform is enabled if its endpoint is not empty. - mapping(string => PlatformConfig) platformConfigs; - /// Per-platform ZK verifier. Each verifier owns its circuit, notary key - /// and platform bindings. The Registry dispatches by platform string - /// and tracks only session keys and nullifiers. - mapping(string => IZkSessionVerifier) zkVerifierOf; - /// Replay protection: the domain-wrapped bearer nullifier from the - /// verifier. The Registry consumes it one time. - mapping(bytes32 => bool) usedBearerNullifiers; - /// domain to OIDC verifier. A platform supports OIDC if its verifier is - /// set. The owner manages these. The OIDC path is the alternative to - /// the TLS-notary flow and gives the same claim shape. - mapping(string => IOidcVerifier) oidcVerifierOf; - mapping(string => mapping(string => address)) walletOf; // [platform][id] to wallet (TRUTH) - mapping(string => mapping(string => IdMeta)) idMeta; // [platform][id] to meta (display) - mapping(string => mapping(string => string)) handleHint; // [platform][handle] to id (re-pointable) - } - - // keccak256(abi.encode(uint256(keccak256("dyaka.storage.Registry")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant REGISTRY_STORAGE = 0x42c0aa58641382e077ceeffba10b73e1be97f010be3e8a2a89d2914f8b201b00; - - /// @dev All state of this contract is under one namespaced root. The state - /// cannot collide with the ERC-7201 namespaces of the OpenZeppelin - /// upgradeable base contracts. An upgrade can append a field without - /// slot arithmetic. Append only. Do not reorder or remove a field. - function _s() private pure returns (RegistryStorage storage $) { - assembly { - $.slot := REGISTRY_STORAGE - } - } - - // ─── Storage reads (the ABI the public variables gave) ───────── - - /// @notice The Notary contract this Registry routes attestation checks through. - function notaryContract() external view returns (address) { - return address(_s().notaryContract); - } - - /// @notice The current notary signer, read through the Notary contract. - /// Kept so observers keep the read they had when the key was local. - function notary() external view returns (address) { - return _s().notaryContract.notary(); - } - - function backend() external view returns (address) { - return _s().backend; - } - - function walletFactory() external view returns (WalletFactory) { - return _s().walletFactory; - } - - function usedSessionKeys(address sessionAddr) external view returns (bool) { - return _s().usedSessionKeys[sessionAddr]; - } - - function contract_to_handles(address wallet, uint256 index) - external - view - returns (string memory platform, string memory handle, string memory userId) - { - HandleEntry storage entry = _s().contract_to_handles[wallet][index]; - return (entry.platform, entry.handle, entry.userId); - } - - function platformConfigs(string calldata domain) - external - view - returns (string memory endpoint, string memory handlePrefix, string memory idPrefix, string memory idSuffix) - { - PlatformConfig storage cfg = _s().platformConfigs[domain]; - return (cfg.endpoint, cfg.handlePrefix, cfg.idPrefix, cfg.idSuffix); - } - - function zkVerifierOf(string calldata domain) external view returns (IZkSessionVerifier) { - return _s().zkVerifierOf[domain]; - } - - function usedBearerNullifiers(bytes32 nullifier) external view returns (bool) { - return _s().usedBearerNullifiers[nullifier]; - } - - function oidcVerifierOf(string calldata domain) external view returns (IOidcVerifier) { - return _s().oidcVerifierOf[domain]; - } - - function walletOf(string calldata platform, string calldata userId) external view returns (address) { - return _s().walletOf[platform][userId]; - } - - function idMeta(string calldata platform, string calldata userId) - external - view - returns (string memory handle, uint256 verifiedAt) - { - IdMeta storage meta = _s().idMeta[platform][userId]; - return (meta.handle, meta.verifiedAt); - } - - function handleHint(string calldata platform, string calldata handle) external view returns (string memory) { - return _s().handleHint[platform][handle]; - } - - /// @notice Convenience read for ZK-domain support. - function isZkDomainSupported(string calldata domain) public view returns (bool) { - return address(_s().zkVerifierOf[domain]) != address(0); - } - - // ─── Proof struct (Merkle paths included) ────────────────────── - - struct FullTlsProof { - bytes notarySignature; - bytes backendSignature; - /// Session keypair public address generated by the prover's browser; - /// becomes the session key registered against the wallet. Signed - /// into the _s().backend digest, so attackers cannot rebind it. - address userAddress; - /// Bound to _s().backend sig. `address(0)` for register_session, - /// `msg.sender` for linkIdentity. - address walletAddress; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] domainPath; - bytes32[] usernamePath; - bytes32[] endpointPath; - bytes32[] idPath; // path for the "id":"" recv leaf; empty when userId is "" - } - - // ─── Events ──────────────────────────────────────────────────── - - event SessionRegistered(string platform, string handle, address indexed wallet, address sessionAddr); - event IdentityLinked(string platform, string handle, address indexed wallet); - /// Emitted when an id's current handle changes (rename). Lets the indexer - /// refresh the off-chain label keyed by the immutable id. - event HandleChanged(string platform, string userId, string handle); - - // ─── Errors ──────────────────────────────────────────────────── - - error InvalidMerkleProof(); - error SessionKeyAlreadyUsed(); - error InvalidNotarySignature(); - error InvalidBackendSignature(); - error StaleProof(); - error FutureProof(); - error MerklePathTooLong(); - error NotRegisteredWallet(); - error IdentityAlreadyLinked(); - error UserIdRequired(); - error UnknownId(); - error UnsupportedPlatform(); - error WrongEndpoint(); - error ZkVerifierNotSet(); - error BearerNullifierUsed(); - error ProofExpired(); - error WrongWalletAddr(); - error JwtExpired(); - error OidcVerifierNotSet(); - error LinkWalletMismatch(); - - // ─── Initializer ────────────────────────────────────────────── - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @param _notaryContract The shared Notary contract (INotary) every - /// attestation check routes through. - function initialize(address _notaryContract, address _backend, address _walletFactory, address _owner) - external - initializer - { - __Ownable_init(_owner); - __Ownable2Step_init(); - __UUPSUpgradeable_init(); - __Pausable_init(); - require(_notaryContract != address(0), "zero notary contract"); - require(_backend != address(0), "zero _s().backend"); - require(_walletFactory != address(0), "zero factory"); - _s().notaryContract = INotary(_notaryContract); - _s().backend = _backend; - _s().walletFactory = WalletFactory(_walletFactory); - } - - /// @notice Pause state-changing entrypoints. Owner-only emergency stop and - /// migration-cutover gate (covers the permissionless ZK/OIDC paths a - /// _s().backend-pause can't reach). Admin/owner fns stay callable. - function pause() external onlyOwner { - _pause(); - } - - /// @notice Resume after a pause. - function unpause() external onlyOwner { - _unpause(); - } - - // ─── Admin ───────────────────────────────────────────────────── - - function setBackend(address _backend) external onlyOwner { - require(_backend != address(0), "zero _s().backend"); - _s().backend = _backend; - } - - /// @notice Register or update a supported platform. `idPrefix`/`idSuffix` - /// enable id-binding (X: '"id":"' + '"'; GitHub: '"id":' + ','); pass - /// empty idPrefix to leave it off. idSuffix anchors the id's end. - function setPlatform( - string calldata domain, - string calldata endpoint, - string calldata handlePrefix, - string calldata idPrefix, - string calldata idSuffix - ) external onlyOwner { - require(bytes(domain).length > 0, "empty domain"); - require(bytes(endpoint).length > 0, "empty endpoint"); - require(bytes(handlePrefix).length > 0, "empty key"); - _s().platformConfigs[domain] = PlatformConfig(endpoint, handlePrefix, idPrefix, idSuffix); - } - - /// @notice Remove a platform (clears config, disabling registrations). - function removePlatform(string calldata domain) external onlyOwner { - delete _s().platformConfigs[domain]; - } - - /// @notice Set or clear the ZK verifier for a platform. Pass - /// `IZkSessionVerifier(address(0))` to disable. - function setZkVerifier(string calldata domain, IZkSessionVerifier verifier) external onlyOwner { - require(bytes(domain).length > 0, "empty domain"); - _s().zkVerifierOf[domain] = verifier; - } - - /// @notice Set or clear the OIDC verifier for a platform. Pass - /// `IOidcVerifier(address(0))` to disable. - function setOidcVerifier(string calldata platform, IOidcVerifier verifier) external onlyOwner { - require(bytes(platform).length > 0, "empty platform"); - _s().oidcVerifierOf[platform] = verifier; - } - - // ─── Session registration ────────────────────────────────────── - - /// @notice Register a session address for a social identity. The session - /// key is `proof.userAddress` (notary + backend signed). No - /// loose `sessionAddr` parameter -- a previous version of this - /// function let a mempool front-runner substitute the session - /// key without invalidating the signatures. - function register_session( - FullTlsProof calldata proof, - string calldata domain, - string calldata username, - string calldata userId, - string calldata endpoint - ) external whenNotPaused { - // Session key is proof.userAddress (notary + backend signed); no loose - // sessionAddr param (anti front-run). New identity → walletAddress must - // be the zero sentinel so the factory deploys the wallet. - if (proof.walletAddress != address(0)) revert WrongWalletAddr(); - - _verifyProof(proof, domain, username, userId, endpoint); - - _registerHandle(domain, username, userId, proof.userAddress); - } - - // ─── ZK session registration (dispatcher) ────────────────────── - - /// @notice Register a session via the platform's configured - /// `IZkSessionVerifier`. The verifier owns all platform- - /// specific logic (circuit, notary, attestation shape); the - /// Registry only enforces dispatch + global session-key and - /// nullifier replay protection, then deploys the wallet and - /// registers the session. - function register_session_zk(string calldata platform, bytes calldata zkProof) external whenNotPaused { - IZkSessionVerifier verifier = _s().zkVerifierOf[platform]; - if (address(verifier) == address(0)) revert ZkVerifierNotSet(); - - ( - string memory handle, - address sessionAddr, - address walletAddress, - uint256 expiresAt, - bytes32 nullifier, - string memory userId - ) = verifier.verifyAndExtract(zkProof); - - // Factory deploys the wallet: PI wallet must be the zero sentinel. - if (walletAddress != address(0)) revert WrongWalletAddr(); - if (block.timestamp >= expiresAt) revert ProofExpired(); - _consumeNullifier(nullifier); - _registerHandle(platform, handle, userId, sessionAddr); - } - - /// Link a ZK identity to the *calling* wallet. PI walletAddress must - /// equal msg.sender so a leaked proof cannot be replayed from another - /// wallet -- the binding is committed inside the ZK proof. - function link_identity_zk(string calldata platform, bytes calldata zkProof) external whenNotPaused { - IZkSessionVerifier verifier = _s().zkVerifierOf[platform]; - if (address(verifier) == address(0)) revert ZkVerifierNotSet(); - - ( - string memory handle, - address sessionAddr, - address walletAddress, - uint256 expiresAt, - bytes32 nullifier, - string memory userId - ) = verifier.verifyAndExtract(zkProof); - - if (walletAddress != msg.sender) revert WrongWalletAddr(); - if (block.timestamp >= expiresAt) revert ProofExpired(); - _consumeNullifier(nullifier); - _linkHandle(platform, handle, userId, sessionAddr); - } - - // ─── Shared internals ────────────────────────────────────────── - - /// One-shot consume of a ZK bearer nullifier (replay guard). - function _consumeNullifier(bytes32 nullifier) internal { - if (_s().usedBearerNullifiers[nullifier]) revert BearerNullifierUsed(); - _s().usedBearerNullifiers[nullifier] = true; - } - - /// @dev Point `handle` at `userId` as the id's current label. Clears the id's - /// PREVIOUS handle hint (rename) so an abandoned handle stops resolving here — - /// but only if that hint still points to this id (not recycled to another). - function _bindHandle(string memory platform, string memory handle, string memory userId) internal { - string memory prevHandle = _s().idMeta[platform][userId].handle; - bool renamed = bytes(prevHandle).length != 0 && keccak256(bytes(prevHandle)) != keccak256(bytes(handle)); - if (renamed && keccak256(bytes(_s().handleHint[platform][prevHandle])) == keccak256(bytes(userId))) { - delete _s().handleHint[platform][prevHandle]; - } - _s().idMeta[platform][userId] = IdMeta(handle, block.timestamp); - _s().handleHint[platform][handle] = userId; - if (renamed) emit HandleChanged(platform, userId, handle); - } - - /// Deploy-or-find the wallet for `(platform, handle)` and register the - /// session key. Used by every `register_session_*` (new-identity) path. - function _registerHandle(string memory platform, string memory handle, string memory userId, address sessionAddr) - internal - { - // Escrow is keyed by the immutable platform id — never the mutable - // handle. No handle-key fallback; a registration without an id is - // rejected (legacy handle-keyed wallets are migrated separately). - if (bytes(userId).length == 0) revert UserIdRequired(); - require(sessionAddr != address(0), "zero session"); - if (_s().usedSessionKeys[sessionAddr]) revert SessionKeyAlreadyUsed(); - _s().usedSessionKeys[sessionAddr] = true; - - // ── (platform, id) is the truth ── - address wallet = _s().walletOf[platform][userId]; - if (wallet == address(0)) { - wallet = _s().walletFactory.deploy_web_wallet(platform, handle, userId); - _s().walletOf[platform][userId] = wallet; - _s().contract_to_handles[wallet].push(HandleEntry(platform, handle, userId)); - // New id→wallet binding only; returning users emit SessionRegistered. - emit HandleRegistered(platform, handle, wallet); - } - _bindHandle(platform, handle, userId); - WebWallet(payable(wallet)).register_session(platform, handle, userId, sessionAddr); - emit SessionRegistered(platform, handle, wallet, sessionAddr); - } - - /// Attach `(platform, handle)` to the calling registered wallet and - /// register the session key. Used by every `link_*` path; the caller - /// must have verified the proof is bound to `msg.sender`. - function _linkHandle(string memory platform, string memory handle, string memory userId, address sessionAddr) - internal - { - _linkHandleTo(msg.sender, platform, handle, userId, sessionAddr); - } - - /// Link an identity to an EXPLICIT wallet rather than `msg.sender`. - /// - /// A caller passing a wallet other than `msg.sender` must have already - /// established that the wallet authorized the link — e.g. a signature from - /// one of the wallet's existing session keys carried inside the proof, which - /// is what would make such a transaction sponsorable. No such caller exists - /// today; `_linkHandle` passes `msg.sender`. - function _linkHandleTo( - address wallet, - string memory platform, - string memory handle, - string memory userId, - address sessionAddr - ) internal { - if (_s().contract_to_handles[wallet].length == 0) revert NotRegisteredWallet(); - if (bytes(userId).length == 0) revert UserIdRequired(); - require(sessionAddr != address(0), "zero session"); - if (_s().usedSessionKeys[sessionAddr]) revert SessionKeyAlreadyUsed(); - _s().usedSessionKeys[sessionAddr] = true; - - // ── an id maps to exactly one wallet ── - address linked = _s().walletOf[platform][userId]; - if (linked != address(0) && linked != wallet) revert IdentityAlreadyLinked(); - // Only append a HandleEntry the first time this id links to this wallet. - // Re-linking the same (platform, userId) just refreshes the label/session; - // a duplicate entry would break the one-entry-per-(platform,userId) invariant. - if (linked != wallet) { - _s().walletOf[platform][userId] = wallet; - _s().contract_to_handles[wallet].push(HandleEntry(platform, handle, userId)); - } - _bindHandle(platform, handle, userId); - WebWallet(payable(wallet)).register_session(platform, handle, userId, sessionAddr); - emit IdentityLinked(platform, handle, wallet); - } - - // ─── OIDC session registration ───────────────────────────────── - - /// @notice OIDC variant of `register_session`. The caller passes an - /// opaque OIDC proof (bytes); the configured `IOidcVerifier` - /// for `platform` validates it and extracts the user's - /// identity, session key, and JWT expiry. Downstream wallet - /// deploy + event emission is identical to the TLSN flow, so - /// the indexer treats OIDC registrations the same as any - /// other handle registration. - /// - /// @param platform Platform domain string, e.g. `"www.googleapis.com"`. - /// @param oidcProof Opaque payload understood by the configured - /// verifier (for `GoogleOidcVerifier`: ABI-encoded - /// `UserProof` carrying the Honk proof, public - /// inputs, and the email + session-key plaintexts). - function register_session_oidc(string calldata platform, bytes calldata oidcProof) external whenNotPaused { - IOidcVerifier verifier = _s().oidcVerifierOf[platform]; - if (address(verifier) == address(0)) revert OidcVerifierNotSet(); - - (string memory handle, address sessionAddr, uint256 jwtExp, string memory userId) = - verifier.verifyAndExtract(oidcProof); - - // The verifier already enforces JWT freshness against its own - // CLOCK_SKEW_GRACE, but the Registry re-checks against its own - // tolerance so all session-key bindings share the same liveness - // contract regardless of which provider validated the proof. - if (jwtExp + CLOCK_SKEW_GRACE <= block.timestamp) revert JwtExpired(); - - // Escrow keys on the immutable JWT `sub`, not the mutable email handle. - _registerHandle(platform, handle, userId, sessionAddr); - } - - // ─── OIDC identity linking ───────────────────────────────────── - - /// @notice Link an OIDC identity (e.g. Gmail) to the *existing* wallet - /// that calls this function, instead of deploying a new one - /// (the `register_session_oidc` behaviour). - /// - /// Front-run safe: the OIDC `nonce` is bound to the calling - /// wallet — `verifyAndExtract` returns it and we require it to - /// equal `msg.sender`. The nonce lives inside the provider-signed - /// JWT, so a stolen proof cannot be replayed by another wallet. - /// - /// @param platform Platform domain string, e.g. `"www.googleapis.com"`. - /// @param oidcProof Verifier proof whose JWT `nonce` is this wallet's address. - /// @param sessionAddr Session key to register for the linked handle. - function link_identity_oidc(string calldata platform, bytes calldata oidcProof, address sessionAddr) - external - whenNotPaused - { - // Caller must be a registered WebWallet (its session key authorized - // this via WebWallet.execute) — same consent gate as linkIdentity. - if (_s().contract_to_handles[msg.sender].length == 0) revert NotRegisteredWallet(); - - IOidcVerifier verifier = _s().oidcVerifierOf[platform]; - if (address(verifier) == address(0)) revert OidcVerifierNotSet(); - - (string memory handle, address boundWallet, uint256 jwtExp, string memory userId) = - verifier.verifyAndExtract(oidcProof); - - // The JWT `nonce` is bound to the linking wallet (returned as the - // session/bound address), so the proof is only usable by that wallet - // — closes the front-run window. - if (boundWallet != msg.sender) revert LinkWalletMismatch(); - if (jwtExp + CLOCK_SKEW_GRACE <= block.timestamp) revert JwtExpired(); - - // Escrow keys on the immutable JWT `sub`. `sessionAddr` is the session - // key the caller wants registered for the linked handle. - _linkHandle(platform, handle, userId, sessionAddr); - } - - // ─── Identity linking ────────────────────────────────────────── - - /// Link an additional social identity to msg.sender. Backend-signed - /// `proof.walletAddress` must equal `msg.sender` to prevent mempool replay. - function linkIdentity( - string calldata platform, - string calldata handle, - string calldata userId, - FullTlsProof calldata proof, - string calldata endpoint - ) external whenNotPaused { - // Cheap fail-fast (also enforced by _linkHandle). - if (_s().contract_to_handles[msg.sender].length == 0) revert NotRegisteredWallet(); - if (proof.walletAddress != msg.sender) revert WrongWalletAddr(); - - _verifyProof(proof, platform, handle, userId, endpoint); - - // Delegate to the shared helper (guards + binding live there). - _linkHandle(platform, handle, userId, proof.userAddress); - } - - /// @notice Re-point a handle's hint to its id with a fresh attestation. Permissionless. - function refreshHandleHint( - string calldata platform, - string calldata id, - string calldata handle, - FullTlsProof calldata proof, - string calldata endpoint - ) external whenNotPaused { - _verifyProof(proof, platform, handle, id, endpoint); - if (_s().walletOf[platform][id] == address(0)) revert UnknownId(); - _bindHandle(platform, handle, id); - } - - /// @inheritdoc IRegistry - /// @dev A handle resolves through the id that it points to now. The old - /// `handle_to_contract` fallback is removed. No code has written that - /// mapping since handles stopped being the truth, so the fallback - /// always returned zero. This namespace is append-only, and a dead - /// slot in it stays forever. - function resolve(string calldata platform, string calldata handle) external view returns (address) { - string storage id = _s().handleHint[platform][handle]; - if (bytes(id).length == 0) return address(0); - return _s().walletOf[platform][id]; - } - - /// @inheritdoc IRegistry - function resolveById(string calldata platform, string calldata id) external view returns (address) { - return _s().walletOf[platform][id]; - } - - // ─── Views ───────────────────────────────────────────────────── - - /// @notice Return the platform config for a domain. Empty endpoint means unconfigured. - function getPlatform(string calldata domain) - external - view - returns (string memory endpoint, string memory handlePrefix) - { - PlatformConfig storage pc = _s().platformConfigs[domain]; - return (pc.endpoint, pc.handlePrefix); - } - - function getHandles(address wallet) external view returns (string[] memory platforms, string[] memory handles) { - HandleEntry[] storage entries = _s().contract_to_handles[wallet]; - uint256 len = entries.length; - platforms = new string[](len); - handles = new string[](len); - for (uint256 i = 0; i < len; i++) { - platforms[i] = entries[i].platform; - // Frozen handle — matches the legacy handle-keyed slot kept for - // pre-id consumers. For a rename-safe DISPLAY handle - // use getCurrentHandles (idMeta-backed). - handles[i] = entries[i].handle; - } - } - - /// @notice Like getHandles but returns each id's CURRENT handle (idMeta), - /// rename-safe — for display, never for claim-key computation. - function getCurrentHandles(address wallet) - external - view - returns (string[] memory platforms, string[] memory handles) - { - HandleEntry[] storage entries = _s().contract_to_handles[wallet]; - uint256 len = entries.length; - platforms = new string[](len); - handles = new string[](len); - for (uint256 i = 0; i < len; i++) { - platforms[i] = entries[i].platform; - string memory current = _s().idMeta[entries[i].platform][entries[i].userId].handle; - handles[i] = bytes(current).length > 0 ? current : entries[i].handle; - } - } - - /// @notice Platform user-ids linked to `wallet`, parallel to getHandles. - /// The authoritative claim key for id-keyed accounting. "" for legacy entries. - function getUserIds(address wallet) external view returns (string[] memory platforms, string[] memory userIds) { - HandleEntry[] storage entries = _s().contract_to_handles[wallet]; - uint256 len = entries.length; - platforms = new string[](len); - userIds = new string[](len); - for (uint256 i = 0; i < len; i++) { - platforms[i] = entries[i].platform; - userIds[i] = entries[i].userId; - } - } - - // ─── Internal: full proof verification ───────────────────────── - - /// @notice Verify timestamps, platform/endpoint, domain hash, 2-of-2 sigs, - /// and the 4 merkle leaves. Shared by register/link/refresh. - function _verifyProof( - FullTlsProof calldata proof, - string calldata domain, - string calldata handle, - string calldata userId, - string calldata endpoint - ) internal view returns (PlatformConfig storage pc) { - if (proof.timestamp > block.timestamp + CLOCK_SKEW_GRACE) revert FutureProof(); - if (block.timestamp > proof.timestamp + 1 hours) revert StaleProof(); - - pc = _s().platformConfigs[domain]; - if (bytes(pc.endpoint).length == 0) revert UnsupportedPlatform(); - if (keccak256(bytes(endpoint)) != keccak256(bytes(pc.endpoint))) revert WrongEndpoint(); - - require(keccak256(bytes(domain)) == proof.domainHash, "domain hash mismatch"); - - _verifyNotarySignature(proof); - _verifyBackendSignature(proof); - - _verifyLeaf(proof.domainPath, proof.transcriptRoot, "domain:", bytes(domain)); - _verifyUsernameLeaf(proof.usernamePath, proof.transcriptRoot, pc.handlePrefix, handle); - _verifyLeaf(proof.endpointPath, proof.transcriptRoot, "endpoint:", bytes(endpoint)); - _verifyId(proof, pc, userId); - } - - // ─── Internal: signature verification ────────────────────────── - - function _verifyNotarySignature(FullTlsProof calldata proof) internal view { - // Domain-separated by (chainId, this contract). The notary mirror - // is `crypto::compute_notary_digest` in the original monorepo's - // auth crate. The Notary contract owns the attestation check - // itself (EIP-191 + recover + signer compare today). - bytes32 proofDigest = keccak256( - abi.encode( - block.chainid, - address(this), - proof.domainHash, - proof.clientRandom, - proof.serverRandom, - keccak256(proof.serverEphemeralKey), - proof.transcriptRoot, - proof.timestamp - ) - ); - if (!_s().notaryContract.verify(proofDigest, proof.notarySignature)) revert InvalidNotarySignature(); - } - - function _verifyBackendSignature(FullTlsProof calldata proof) internal view { - bytes32 backendDigest = - keccak256(abi.encode(proof.userAddress, proof.walletAddress, proof.transcriptRoot, proof.timestamp)); - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", backendDigest)); - address signer = ethHash.recover(proof.backendSignature); - if (signer != _s().backend) revert InvalidBackendSignature(); - } - - function _verifyLeaf(bytes32[] calldata path, bytes32 root, string memory prefix, bytes memory value) - internal - pure - { - if (path.length > 32) revert MerklePathTooLong(); - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - if (!MerkleProof.verify(path, root, leaf)) revert InvalidMerkleProof(); - } - - /// @notice Verify the immutable-id recv leaf. - /// Snippet = idPrefix + id + idSuffix. The suffix anchors the id's - /// end (closing `"` for X, `,` for GitHub's bare number) so a - /// truncated number can't match a longer real id. An empty userId is - /// rejected here for fail-early parity with `_registerHandle` / - /// `_linkHandle` (and the ZK/OIDC verifiers), which also require an id. - function _verifyId(FullTlsProof calldata proof, PlatformConfig storage pc, string calldata userId) internal view { - if (bytes(userId).length == 0) revert UserIdRequired(); - require(bytes(pc.idPrefix).length != 0, "id not supported"); - if (proof.idPath.length > 32) revert MerklePathTooLong(); - bytes memory snippet = abi.encodePacked(pc.idPrefix, userId, pc.idSuffix); - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked("recv:", snippet)))); - if (!MerkleProof.verify(proof.idPath, proof.transcriptRoot, leaf)) revert InvalidMerkleProof(); - } - - /// @notice Verify the username Merkle leaf matches the expected JSON snippet. - /// handlePrefix already includes quotes, colon, and any whitespace (e.g. `"login":"` or `"email": "`). - function _verifyUsernameLeaf( - bytes32[] calldata path, - bytes32 root, - string memory handlePrefix, - string memory username - ) internal pure { - if (path.length > 32) revert MerklePathTooLong(); - bytes memory snippet = abi.encodePacked(handlePrefix, username, '"'); - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked("recv:", snippet)))); - if (!MerkleProof.verify(path, root, leaf)) revert InvalidMerkleProof(); - } - - function _authorizeUpgrade(address) internal override onlyOwner {} -} diff --git a/solidity/contracts/login/SnapshotProxy.sol b/solidity/contracts/login/SnapshotProxy.sol deleted file mode 100644 index 5be04da0..00000000 --- a/solidity/contracts/login/SnapshotProxy.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title SnapshotProxy — minimal delegating proxy with snapshotted implementation. -/// @notice Stores the implementation address in the ERC1967 slot at deploy time. -/// NOT automatically upgraded when a beacon changes — each wallet keeps -/// the implementation it was deployed with. The implementation contract -/// can update the slot via UUPS-style self-upgrade (upgrade_to_latest). -contract SnapshotProxy { - /// @dev ERC1967 implementation storage slot. - /// bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) - bytes32 private constant _IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; - - constructor(address implementation) { - assembly { - sstore(_IMPL_SLOT, implementation) - } - } - - fallback() external payable { - assembly { - let impl := sload(0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) - calldatacopy(0, 0, calldatasize()) - let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0) - returndatacopy(0, 0, returndatasize()) - switch result - case 0 { revert(0, returndatasize()) } - default { return(0, returndatasize()) } - } - } -} diff --git a/solidity/contracts/login/WalletFactory.sol b/solidity/contracts/login/WalletFactory.sol deleted file mode 100644 index 55102cd7..00000000 --- a/solidity/contracts/login/WalletFactory.sol +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; -import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; -import {SnapshotProxy} from "./SnapshotProxy.sol"; -import {WebWallet} from "./WebWallet.sol"; - -/// @title WalletFactory — Factory for WebWallet instances -/// @notice Deploys WebWallet instances behind SnapshotProxy using CREATE. -/// Each wallet snapshots the current beacon implementation at deploy time -/// and is NOT automatically upgraded when the beacon changes. -/// Users can optionally upgrade via WebWallet.upgrade_to_latest(). -/// -/// @dev The beacon tracks the latest WebWallet implementation. New deployments -/// read beacon.implementation() and freeze that address in the proxy. -/// Upgrading the beacon only affects future deployments (and wallets whose -/// users explicitly opt in via upgrade_to_latest). -contract WalletFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { - UpgradeableBeacon public beacon; - address public registry; - - /// @notice True if `wallet` was deployed by this factory. - mapping(address => bool) public isRegisteredWallet; - - // ─── Events ───────────────────────────────────────────────────── - - event WalletCreated(address indexed wallet, bytes32 indexed firstIdentity); - event WalletImplementationUpgraded(address indexed newImplementation); - - // ─── Errors ───────────────────────────────────────────────────── - - error OnlyRegistry(); - - // ─── Initializer ──────────────────────────────────────────────── - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @param owner_ Factory owner (the governance multisig). - /// @param walletImpl_ Initial WebWallet implementation address. - /// @param registry_ Registry contract address. - function initialize(address owner_, address walletImpl_, address registry_) external initializer { - __Ownable_init(owner_); - __Ownable2Step_init(); - __UUPSUpgradeable_init(); - beacon = new UpgradeableBeacon(walletImpl_, address(this)); - registry = registry_; - } - - // ─── Wallet deployment ────────────────────────────────────────── - - /// @notice Deploy a new WebWallet for a user's first social identity. - /// Uses CREATE opcode — address is non-deterministic. - /// The wallet's implementation is snapshotted from the beacon at deploy time. - /// Only callable by Registry. - /// - /// @param firstPlatform Platform domain of the user's first identity. - /// @param firstHandle Handle/username of the user's first identity (display). - /// @param firstUserId Immutable platform id of the user's first identity. - /// @return wallet Address of the deployed SnapshotProxy. - function deploy_web_wallet(string calldata firstPlatform, string calldata firstHandle, string calldata firstUserId) - external - returns (address wallet) - { - if (msg.sender != registry) revert OnlyRegistry(); - - // Snapshot current implementation from beacon. - address impl = beacon.implementation(); - - // Deploy SnapshotProxy with CREATE (non-deterministic address). - SnapshotProxy proxy = new SnapshotProxy(impl); - wallet = address(proxy); - - // Initialize the wallet through the proxy. - WebWallet(payable(wallet)).initialize(registry, address(this), firstPlatform, firstHandle); - - // Key the identity by the immutable id (matches WebWallet session keys), so - // a later handle rename/recycle can't make indexers mis-associate the wallet. - bytes32 firstIdentity = keccak256(abi.encode(firstPlatform, firstUserId)); - isRegisteredWallet[wallet] = true; - emit WalletCreated(wallet, firstIdentity); - } - - // ─── Latest implementation ───────────────────────────────────── - - /// @notice Returns the current WebWallet implementation from the beacon. - /// Used by WebWallet.upgradeToLatest() for opt-in upgrades. - function latestImplementation() external view returns (address) { - return beacon.implementation(); - } - - // ─── Beacon upgrade ───────────────────────────────────────────── - - /// @notice Upgrade the WebWallet implementation in the beacon. - /// Only affects future deployments and wallets that opt in. - function upgradeWalletImplementation(address newImplementation) external onlyOwner { - beacon.upgradeTo(newImplementation); - emit WalletImplementationUpgraded(newImplementation); - } - - function setRegistry(address newRegistry) external onlyOwner { - registry = newRegistry; - } - - // ─── Internal ─────────────────────────────────────────────────── - - function _authorizeUpgrade(address) internal override onlyOwner {} -} diff --git a/solidity/contracts/login/WebWallet.sol b/solidity/contracts/login/WebWallet.sol deleted file mode 100644 index 8ed994c2..00000000 --- a/solidity/contracts/login/WebWallet.sol +++ /dev/null @@ -1,389 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; - -interface IWalletFactory { - function latestImplementation() external view returns (address); -} - -/// @title WebWallet — Per-user social identity wallet (SnapshotProxy instance) -/// @notice One instance is deployed per user via WalletFactory. -/// Groups the user's social platform identities and configures -/// the multisig threshold. -/// -/// @dev General multisig executor: any on-chain action (transfers, withdrawals, -/// identity linking, etc.) goes through execute(). The wallet verifies that -/// enough distinct (platform, userId) identities have signed, then forwards -/// the call to the target contract. -/// -/// Session keys are stored per (platform, userId) identity as addresses. -/// The identity key is keccak256(abi.encode(platform, userId)) — keyed by the -/// immutable platform id, NOT the mutable handle, so a rename never moves a -/// session (revoke/threshold stay stable). Registry is the sole caller of -/// register_session(). -contract WebWallet is Initializable, UUPSUpgradeable { - address public registry; - address public factory; - - /// @notice M-of-N approval threshold for multisig. - /// Threshold counts distinct (platform, userId) identities, not distinct keys. - uint8 public threshold; - - /// @notice Replay-protection nonce for execute(). - uint256 public nonce; - - // ─── Identity tracking ────────────────────────────────────────── - - /// @dev Set of active identity keys (platformHandleKey). An identity is active - /// when it has at least one session in _sessionList. - bytes32[] private _identityKeys; - mapping(bytes32 => uint256) private _identityKeyIndex; // key → index in _identityKeys (valid when key is in array) - - // ─── Session storage (address-based) ──────────────────────────── - - /// @notice Maps session address → keccak256(abi.encode(platform, userId)). - /// Zero means not a session. (Field name kept for storage-layout - /// compatibility across the UUPS upgrade; the value is now the id key.) - mapping(address => bytes32) private _sessionPlatformHandle; - - /// @notice identityKey → array of session addresses. - mapping(bytes32 => address[]) private _sessionList; - - /// @notice identityKey → sessionAddr → index+1 in _sessionList (0 = not present). - mapping(bytes32 => mapping(address => uint256)) private _sessionIndex; - - // ─── Events ───────────────────────────────────────────────────── - - event SessionRegistered(string platform, string handle, address indexed sessionAddr); - event SessionRevoked(string platform, string handle, address indexed sessionAddr); - event Executed(address indexed target, uint256 value, uint256 nonce); - event NativeReceived(address indexed from, uint256 amount); - event NativeSent(address indexed to, uint256 amount); - - // ─── Errors ───────────────────────────────────────────────────── - - error InvalidSignature(); - error ThresholdNotMet(); - error OnlyRegistry(); - error OnlySelf(); - error ExecutionFailed(bytes returnData); - error SessionNotFound(); - error ThresholdExceedsIdentityCount(); - error SessionAlreadyRegistered(); - - // ─── Initializer ──────────────────────────────────────────────── - - /// @notice Initialize this wallet. Called by WalletFactory after proxy deployment. - /// @param _registry Registry contract address (sole caller of register_session). - /// @param _factory WalletFactory address (used for opt-in upgrades). - /// @param firstPlatform Platform domain of the user's first identity. - /// @param firstHandle Handle/username of the user's first identity. - function initialize(address _registry, address _factory, string calldata firstPlatform, string calldata firstHandle) - external - initializer - { - registry = _registry; - factory = _factory; - threshold = 1; - // Identity will be added to _identityKeys when the first session is registered - // via register_session (which checks _sessionList[key].length == 0). - } - - receive() external payable { - emit NativeReceived(msg.sender, msg.value); - } - - // ─── Session registration (Registry only) ─────────────────────── - - /// @notice Register a session address for a (platform, userId) identity. Only callable by Registry. - /// @param platform Platform domain (e.g. "x.com"). - /// @param handle Current handle — emitted for display only; NOT part of the key. - /// @param userId Immutable platform id — the identity key is keccak(platform, userId). - /// @param sessionAddr The session address derived from ecrecover. - function register_session( - string calldata platform, - string calldata handle, - string calldata userId, - address sessionAddr - ) external { - if (msg.sender != registry) revert OnlyRegistry(); - require(sessionAddr != address(0), "zero session"); - require(bytes(userId).length != 0, "empty userId"); - - // Prevent duplicate session registration - if (_sessionPlatformHandle[sessionAddr] != bytes32(0)) revert SessionAlreadyRegistered(); - - _addSession(_identityKey(platform, userId), sessionAddr); - - emit SessionRegistered(platform, handle, sessionAddr); - } - - // ─── Session revocation (self-call via execute) ───────────────── - - /// @notice Revoke a session. Only callable by this contract (via execute with multisig). - /// @param platform Platform domain. - /// @param handle Current handle — emitted for display only; NOT part of the key. - /// @param userId Immutable platform id — the identity key is keccak(platform, userId). - /// @param sessionAddr The session address to revoke. - function revokeSession( - string calldata platform, - string calldata handle, - string calldata userId, - address sessionAddr - ) external { - if (msg.sender != address(this)) revert OnlySelf(); - - _removeSession(_identityKey(platform, userId), sessionAddr); - - emit SessionRevoked(platform, handle, sessionAddr); - } - - /// @notice One-time migration: move a session from the legacy (platform, handle) - /// key to the (platform, userId) key. Registry-gated, idempotent. Used to - /// re-key sessions that were registered before the id-keying upgrade. - /// @dev No-op when the session is already under the id key. Emits SessionRegistered - /// (unchanged signature) so indexers pick the session up under the id key. - function reregisterSessionById( - string calldata platform, - string calldata handle, - string calldata userId, - address sessionAddr - ) external { - if (msg.sender != registry) revert OnlyRegistry(); - require(bytes(userId).length != 0, "empty userId"); - - bytes32 newKey = _identityKey(platform, userId); - bytes32 current = _sessionPlatformHandle[sessionAddr]; - if (current == newKey) return; // already id-keyed - bytes32 oldKey = keccak256(abi.encode(platform, handle)); - if (current != oldKey || _sessionIndex[oldKey][sessionAddr] == 0) revert SessionNotFound(); - - _removeSession(oldKey, sessionAddr); // clears _sessionPlatformHandle[sessionAddr] - _addSession(newKey, sessionAddr); - - emit SessionRegistered(platform, handle, sessionAddr); - } - - // ─── Session storage helpers ──────────────────────────────────── - - function _identityKey(string calldata platform, string calldata userId) internal pure returns (bytes32) { - return keccak256(abi.encode(platform, userId)); - } - - /// @dev Add `sessionAddr` under `key`, tracking the identity on first session. - function _addSession(bytes32 key, address sessionAddr) internal { - if (_sessionList[key].length == 0) { - _identityKeyIndex[key] = _identityKeys.length; - _identityKeys.push(key); - } - _sessionPlatformHandle[sessionAddr] = key; - _sessionList[key].push(sessionAddr); - _sessionIndex[key][sessionAddr] = _sessionList[key].length; // 1-indexed - } - - /// @dev Remove `sessionAddr` from `key` (swap-pop), untracking the identity on - /// its last session. Reverts SessionNotFound if not present under `key`. - function _removeSession(bytes32 key, address sessionAddr) internal { - uint256 oneIndexed = _sessionIndex[key][sessionAddr]; - if (oneIndexed == 0) revert SessionNotFound(); - - uint256 idx = oneIndexed - 1; - uint256 lastIdx = _sessionList[key].length - 1; - - if (idx != lastIdx) { - address lastAddr = _sessionList[key][lastIdx]; - _sessionList[key][idx] = lastAddr; - _sessionIndex[key][lastAddr] = oneIndexed; - } - - _sessionList[key].pop(); - delete _sessionIndex[key][sessionAddr]; - delete _sessionPlatformHandle[sessionAddr]; - - // If last session for this identity was removed, clean up identity tracking - if (_sessionList[key].length == 0) { - uint256 keyIdx = _identityKeyIndex[key]; - uint256 lastKeyIdx = _identityKeys.length - 1; - if (keyIdx != lastKeyIdx) { - bytes32 lastKey = _identityKeys[lastKeyIdx]; - _identityKeys[keyIdx] = lastKey; - _identityKeyIndex[lastKey] = keyIdx; - } - _identityKeys.pop(); - delete _identityKeyIndex[key]; - } - } - - // ─── General executor ─────────────────────────────────────────── - - /// @notice Execute an arbitrary call after verifying multisig threshold. - /// @param target Contract to call. - /// @param value ETH value to send. - /// @param data Calldata for the target. - /// @param sigs Signatures over the execute digest. Signer addresses are - /// recovered via ecrecover — no need to pass them separately. - /// @return result Return data from the call. - function execute(address target, uint256 value, bytes calldata data, bytes[] calldata sigs) - external - payable - returns (bytes memory result) - { - uint256 currentNonce = nonce; - bytes32 digest = - keccak256(abi.encode(target, value, keccak256(data), currentNonce, block.chainid, address(this))); - - _requireMultiSig(sigs, digest); - - nonce = currentNonce + 1; - - bool success; - (success, result) = target.call{value: value}(data); - if (!success) revert ExecutionFailed(result); - - emit Executed(target, value, currentNonce); - if (value > 0) emit NativeSent(target, value); - } - - // ─── Batch executor ─────────────────────────────────────────────── - - /// @notice Execute multiple calls atomically after verifying multisig threshold once. - /// @param targets Array of contracts to call. - /// @param values Array of ETH values to send per call. - /// @param datas Array of calldata per call. - /// @param sigs Signatures over the batch digest. - /// @return results Array of return data from each call. - function executeBatch( - address[] calldata targets, - uint256[] calldata values, - bytes[] calldata datas, - bytes[] calldata sigs - ) external payable returns (bytes[] memory results) { - require(targets.length == values.length && values.length == datas.length, "length mismatch"); - - uint256 currentNonce = nonce; - bytes32 dataHashes = keccak256(abi.encode(datas)); - bytes32 digest = keccak256(abi.encode(targets, values, dataHashes, currentNonce, block.chainid, address(this))); - - _requireMultiSig(sigs, digest); - - nonce = currentNonce + 1; - - results = new bytes[](targets.length); - for (uint256 i = 0; i < targets.length; i++) { - bool success; - (success, results[i]) = targets[i].call{value: values[i]}(datas[i]); - if (!success) revert ExecutionFailed(results[i]); - if (values[i] > 0) emit NativeSent(targets[i], values[i]); - } - - emit Executed(address(0), 0, currentNonce); - } - - // ─── Upgrade ───────────────────────────────────────────────────── - - /// @notice Upgrade this wallet to the latest implementation from the factory. - /// Must be called via execute() (self-call with multisig). - function upgradeToLatest() external { - require(msg.sender == address(this), "only via execute()"); - address newImpl = IWalletFactory(factory).latestImplementation(); - upgradeToAndCall(newImpl, ""); - } - - /// @dev Authorize UUPS upgrades — only via self-call (execute with multisig). - function _authorizeUpgrade(address) internal override { - require(msg.sender == address(this), "only via execute()"); - } - - // ─── Self-callable setters (called via execute → self-call) ──── - - /// @notice Set the multisig threshold. Only callable by this contract (via execute). - function setThreshold(uint8 newThreshold) external { - if (msg.sender != address(this)) revert OnlySelf(); - require(newThreshold > 0, "zero threshold"); - if (newThreshold > _identityKeys.length) revert ThresholdExceedsIdentityCount(); - threshold = newThreshold; - } - - // ─── Views ────────────────────────────────────────────────────── - - /// @notice Check if an identity hash has at least one active session. - function isIdentity(bytes32 identityHash) external view returns (bool) { - return _sessionList[identityHash].length > 0; - } - - function identityCount() external view returns (uint256) { - return _identityKeys.length; - } - - function identityAt(uint256 index) external view returns (bytes32) { - return _identityKeys[index]; - } - - function sessionCount(bytes32 platformHandleKey) external view returns (uint256) { - return _sessionList[platformHandleKey].length; - } - - function sessionAt(bytes32 platformHandleKey, uint256 index) external view returns (address) { - return _sessionList[platformHandleKey][index]; - } - - function isSession(address addr) external view returns (bool) { - return _sessionPlatformHandle[addr] != bytes32(0); - } - - function sessionIdentity(address addr) external view returns (bytes32) { - return _sessionPlatformHandle[addr]; - } - - // ─── Internal helpers ─────────────────────────────────────────── - - /// @dev Verify that >= threshold distinct (platform, userId) identities have signed - /// digest. Recovers signer addresses from signatures via ecrecover. - function _requireMultiSig(bytes[] calldata sigs, bytes32 digest) internal view { - uint256 len = sigs.length; - bytes32[] memory seenPairs = new bytes32[](len); - uint256 distinctCount = 0; - - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - - for (uint256 i = 0; i < len; i++) { - address signer = _recoverSigner(sigs[i], ethHash); - - bytes32 key = _sessionPlatformHandle[signer]; - if (key == bytes32(0)) continue; // not an active session — skip - - // Deduplicate by (platform, handle) key - bool isDup = false; - for (uint256 j = 0; j < distinctCount; j++) { - if (seenPairs[j] == key) { - isDup = true; - break; - } - } - if (isDup) continue; - - seenPairs[distinctCount++] = key; - } - - if (distinctCount < threshold) revert ThresholdNotMet(); - } - - /// @dev Recover signer address from a 65-byte signature. - function _recoverSigner(bytes calldata sig, bytes32 ethHash) internal pure returns (address) { - require(sig.length == 65, "bad sig"); - - uint8 v = uint8(sig[64]); - bytes32 r; - bytes32 s; - assembly { - r := calldataload(sig.offset) - s := calldataload(add(sig.offset, 32)) - } - - address recovered = ecrecover(ethHash, v, r, s); - if (recovered == address(0)) revert InvalidSignature(); - return recovered; - } -} diff --git a/solidity/contracts/login/oidc/GoogleOidcVerifier.sol b/solidity/contracts/login/oidc/GoogleOidcVerifier.sol deleted file mode 100644 index b39f4f81..00000000 --- a/solidity/contracts/login/oidc/GoogleOidcVerifier.sol +++ /dev/null @@ -1,793 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; - -import {IOidcVerifier} from "./IOidcVerifier.sol"; -import {IVerifier} from "./Verifier.sol"; -import {INotary} from "../../notary/INotary.sol"; - -/// IOidcVerifier implementation for Google OIDC. -/// -/// Owns three things: -/// -/// 1. A pointer to the shared Notary contract. Used to verify TLS-notary -/// attestations over `googleapis.com/oauth2/v3/certs` that rotate the -/// RSA JWKS. The rotation digest deliberately names no chain and no -/// contract, so the same notarized reading is valid on every deployment -/// that trusts the notary — that replay is a feature; only WHO the -/// notary is lives in the Notary contract. -/// 2. The kid → modulusHash mapping populated by `rotateRoots`. Each entry -/// has an expiry; old keys age out automatically. -/// 3. A reference to the UltraHonk Verifier deployed for the `jwt_email` -/// Noir circuit. `verifyAndExtract` checks the user's ZK proof and -/// ensures its `modulus` public input lines up with a currently-trusted -/// kid. -/// 4. The accepted Google OAuth client id (`expectedAudienceHash`). The -/// `jwt_email` circuit is application-agnostic — it binds whatever `aud` -/// the token carries and exposes its SHA-256 — so this contract is what -/// pins registrations to one app. Configurable at deploy and by the owner. -/// -/// The rotation trust model, in one line: the notary attests the reading, time -/// does the expiry, and nobody owns rotation. `rotate`/`rotateRoots`/`prune` -/// are open to any caller; every trusted key carries a stamp checked at -/// use-site, so an expired key stops being trusted with no transaction from -/// anyone. The owner's only lever over the key list is `untrustModulus`, which -/// can only REMOVE trust. -contract GoogleOidcVerifier is IOidcVerifier, Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { - // ─── Constants ──────────────────────────────────────────────── - bytes32 private constant EXPECTED_DOMAIN_HASH = keccak256(bytes("www.googleapis.com")); - bytes private constant EXPECTED_DOMAIN = bytes("www.googleapis.com"); - bytes private constant EXPECTED_ENDPOINT = bytes("/oauth2/v3/certs"); - uint256 public constant FRESHNESS_WINDOW = 1 hours; - uint256 public constant CLOCK_SKEW_GRACE = 5 minutes; - uint256 public constant DEFAULT_MODULUS_TTL = 30 days; - /// The most distinct kids the list will track at once. Google publishes a - /// handful of overlapping keys; a notarized reading cannot invent kids, so - /// this cap is headroom, not a working limit. It exists so the enumeration - /// stays a bounded loop no submitter — honest or not — can bloat. - uint256 public constant MAX_TRACKED_KIDS = 16; - /// How much trusted runway `needsRotation()` insists on. Rotation re-stamps - /// every key for DEFAULT_MODULUS_TTL, so a keeper acting on this signal - /// rotates roughly weekly rather than racing the expiry. - uint256 public constant RENEWAL_MARGIN = 7 days; - uint256 public constant TOTAL_PUBLIC_INPUTS = 28; - /// Max `aud` (client id) length the jwt_email circuit hashes — must match its - /// `AUDIENCE_MAX`. A configured client id longer than this can never match. - uint256 public constant MAX_AUDIENCE_BYTES = 128; - - // ─── Storage ────────────────────────────────────────────────── - // Owner lives in Ownable2StepUpgradeable's namespaced storage (owner()). - address public registry; - /// UltraHonk verifier for the jwt_email circuit. Set in `initialize` - /// (not immutable — this contract sits behind a UUPS proxy). - IVerifier public verifier; - - /// The Notary contract a rotation's attestation is checked with. - INotary public notaryContract; - /// kid keccak → modulusHash (the value stored is the limb-keccak the - /// circuit produces). - mapping(bytes32 => bytes32) public modulusOfKid; - mapping(bytes32 => uint256) public expiresAtKid; - /// modulusHash → expiry. Lets `verifyAndExtract` check trust without - /// needing kid (the JWT Noir circuit doesn't expose kid). - mapping(bytes32 => uint256) public trustedHashExpiresAt; - - /// SHA-256 of the Google OAuth client id (the JWT `aud`) this verifier - /// accepts. Configurable per deployment: set at `initialize` and mutable by - /// the owner (`setExpectedAudience`). The circuit binds `aud` to the token - /// and exposes SHA-256(aud) as public inputs [24..26); `_verifyAndExtract` - /// checks those halves against this value so a token minted for a DIFFERENT - /// Google OAuth app (confused deputy) cannot register here. - bytes32 public expectedAudienceHash; - - /// Timestamp of the rotation proof that last wrote each kid. Rotation is - /// permissionless, so ANY caller may replay a still-fresh proof; without - /// this, replaying an older proof inside FRESHNESS_WINDOW silently rolls a - /// kid back to a modulus Google has already retired and re-stamps it for - /// another DEFAULT_MODULUS_TTL. Monotonic rather than a one-shot nullifier: - /// a nullifier would let a front-runner consume the digest and brick the - /// honest keeper, which is exactly what the open caller set makes cheap. - mapping(bytes32 => uint256) public rotatedAtKid; - - /// Every kid currently tracked, so keepers can enumerate the list without - /// an indexer. Bounded by MAX_TRACKED_KIDS; expired entries leave via - /// `prune()`, which anyone may call. - bytes32[] private _trackedKids; - /// kid keccak → index+1 in `_trackedKids` (0 = not tracked). - mapping(bytes32 => uint256) private _trackedKidIndex; - /// The timestamp of the freshest reading ever applied — the notary's - /// observation time, not the block it landed in. A keeper compares this to - /// wall-clock to decide whether its reading would be news. - uint256 public freshestObservedAt; - - /// Reserved storage slots for future upgrades (UUPS). New state must be - /// appended before this gap and the gap shrunk to preserve layout. - uint256[47] private __gap; - - // ─── Events / errors ────────────────────────────────────────── - event ModulusRotated(bytes32 indexed kidHash, string kid, bytes32 modulusHash, uint256 expiresAt); - /// A key stopped being trusted before its stamp ran out — either a rotation - /// replaced the modulus a kid carried, or the owner untrusted it outright. - event ModulusUntrusted(bytes32 indexed modulusHash); - /// One key written by a rotation, with the reading's own timestamp. What a - /// keeper watches: `observedAt` orders readings, `expiresAt` says when this - /// entry stops being trusted if no rotation lands before then. - event RootApplied(bytes32 indexed kidHash, bytes32 indexed modulusHash, uint256 observedAt, uint256 expiresAt); - /// An expired key left the tracked set. Permissionless — time retired the - /// key, `prune()` merely reclaimed the slot. - event RootPruned(bytes32 indexed kidHash, bytes32 modulusHash); - /// Emitted whenever the accepted Google client id changes. `clientId` is the - /// plaintext id when configured via the string setter, or "" when set by raw - /// hash; `audienceHash` is authoritative. - event AudienceConfigured(bytes32 indexed audienceHash, string clientId); - - error ZeroAddress(); - error TooManyKids(); - error UnknownNotary(); - error WrongDomain(); - error FutureProof(); - error StaleProof(); - error MerkleMismatch(); - error PathTooLong(); - error WrongChain(); - error WrongRegistry(); - error MissingKidInLeaf(); - error MissingNInLeaf(); - error InvalidModulusLength(); - error InvalidB64Char(); - error InvalidB64Length(); - - error WrongPublicInputCount(); - error BadHonkProof(); - error WrongEmail(); - error WrongAddress(); - error WrongSub(); - error EmptySub(); - error UntrustedModulus(); - error JwtExpired(); - /// Proof's `aud` (SHA-256) doesn't match the configured client id, or no - /// client id has been configured yet. - error WrongAudience(); - /// A client id / audience hash of zero was supplied. - error EmptyAudience(); - /// A client id longer than `MAX_AUDIENCE_BYTES` was supplied — the circuit - /// couldn't produce a matching hash, so it's rejected at config time. - error AudienceTooLong(); - - // ─── Types ──────────────────────────────────────────────────── - struct NotarizedJwksProof { - bytes notarySignature; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] domainPath; - bytes32[] endpointPath; - } - - struct JwkClaim { - bytes jwkBytes; - bytes32[] jwkPath; - bytes kid; - bytes nB64url; - } - - /// User-side: bundles everything needed to verify a JWT Honk proof. - struct UserProof { - bytes honkProof; - bytes32[] publicInputs; - string email; - address sessionKey; - /// Immutable Google account id (JWT `sub`). Validated against the - /// proof's packed `sub` public input; identity binds on this. - string sub; - } - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// Initialize the proxy. Mirrors XZkVerifier's UUPS pattern: the Honk - /// `verifier`, owner, and the Notary pointer are set here (not in the - /// constructor) because this contract sits behind an ERC1967 proxy. - /// @param notaryContract_ The shared Notary contract (INotary). Notary key - /// rotation happens THERE; this contract holds only the pointer. - /// @param clientId The Google OAuth client id whose id_tokens this verifier - /// accepts (the JWT `aud`). Bound into the proof by the circuit and - /// enforced on chain — see `expectedAudienceHash`. Changeable later - /// by the owner via `setExpectedAudience`. - function initialize(IVerifier _verifier, address _owner, address notaryContract_, string calldata clientId) - external - initializer - { - if (address(_verifier) == address(0) || _owner == address(0) || notaryContract_ == address(0)) { - revert ZeroAddress(); - } - __Ownable_init(_owner); - __UUPSUpgradeable_init(); - verifier = _verifier; - _setExpectedAudience(clientId); - notaryContract = INotary(notaryContract_); - } - - /// The current notary signer, read through the Notary contract. - function notary() external view returns (address) { - return notaryContract.notary(); - } - - /// Only the owner may upgrade the implementation. - function _authorizeUpgrade(address) internal override onlyOwner {} - - /// @dev Renouncing ownership is disabled: a zero owner would permanently - /// brick the UUPS upgrade path and every admin function. - function renounceOwnership() public pure override { - revert("renounce disabled"); - } - - // ─── Admin ──────────────────────────────────────────────────── - - /// Record which Registry fronts this verifier. Informational only — - /// `rotateRoots`/`rotate` are permissionless and nothing here gates on this - /// address; it survives from the era when rotation had a caller set, and - /// stays because the storage slot and ABI already shipped. - function setRegistry(address r) external onlyOwner { - registry = r; - } - - /// Stop trusting a key before anything replaces it. - /// - /// Rotation already retires the key a kid used to carry, which covers the - /// ordinary case. This covers the one that cannot wait: a key Google has - /// not rotated yet, or has rotated in a way this list has not seen. - /// Owner-only, and it can only ever REMOVE trust: the worst an owner does - /// with it is refuse logins, which the rotation any keeper can submit - /// undoes. - function untrustModulus(bytes32 modulusHash) external onlyOwner { - delete trustedHashExpiresAt[modulusHash]; - emit ModulusUntrusted(modulusHash); - } - - /// Reconfigure the accepted Google OAuth client id (the JWT `aud`). Use this - /// on upgrade or when rotating which app may register through this verifier. - /// The circuit is application-agnostic, so no redeploy of the Honk verifier - /// is needed — only this on-chain expectation changes. - function setExpectedAudience(string calldata clientId) external onlyOwner { - _setExpectedAudience(clientId); - } - - /// Lower-level variant: set the expected `aud` hash directly (SHA-256 of the - /// client id). Handy when the hash is known but the plaintext id isn't, and - /// used by tests. Prefer `setExpectedAudience` for the readable audit trail. - function setExpectedAudienceHash(bytes32 audienceHash) external onlyOwner { - if (audienceHash == bytes32(0)) revert EmptyAudience(); - expectedAudienceHash = audienceHash; - emit AudienceConfigured(audienceHash, ""); - } - - function _setExpectedAudience(string memory clientId) internal { - uint256 len = bytes(clientId).length; - if (len == 0) revert EmptyAudience(); - // The circuit hashes at most AUDIENCE_MAX (128) bytes of `aud`. A client - // id longer than that could never produce a matching `audience_hash`, so - // configuring it would silently make every registration revert - // WrongAudience. Reject it here (real Google client ids are ~72 bytes). - if (len > MAX_AUDIENCE_BYTES) revert AudienceTooLong(); - bytes32 audienceHash = sha256(bytes(clientId)); - expectedAudienceHash = audienceHash; - emit AudienceConfigured(audienceHash, clientId); - } - - // ─── IPlatformProver ────────────────────────────────────────── - function platformName() external pure override returns (string memory) { - return "google"; - } - - function rotateRoots(bytes calldata rotationProof) external override { - (NotarizedJwksProof memory p, JwkClaim[] memory claims) = - abi.decode(rotationProof, (NotarizedJwksProof, JwkClaim[])); - _rotate(p, claims); - } - - function verifyAndExtract(bytes calldata proof) - external - view - override - returns (string memory handle, address sessionKey, uint256 expiresAt, string memory userId) - { - UserProof memory u = abi.decode(proof, (UserProof)); - return _verifyAndExtract(u); - } - - // ─── Direct entry points (typed; useful for tests / scripts) ── - /// Apply a notarized reading of Google's JWKS. Anyone may call it — the - /// proof is the whole authorization: a keeper needs gas and nothing else. - function rotate(NotarizedJwksProof calldata proof, JwkClaim[] calldata claims) external { - _rotate(proof, claims); - } - - /// Drop every expired kid from the tracked set. Anyone may call it. - /// - /// Expiry itself needs no transaction — `verifyAndExtract` checks the stamp - /// at use-site, so an expired key is already untrusted the second its TTL - /// passes. Pruning only reclaims enumeration slots (and the storage - /// refund). Rotation calls this by itself when the set is full, so even the - /// prune is optional housekeeping. - function prune() external { - _pruneExpired(); - } - - // ─── Keeper views ───────────────────────────────────────────── - - /// One tracked key, as a keeper sees it. - struct RootInfo { - bytes32 kidHash; - bytes32 modulusHash; - /// The notarized reading that last wrote this kid (proof timestamp). - uint256 observedAt; - /// When this entry stops being trusted, absent a fresher rotation. - uint256 expiresAt; - } - - /// Every tracked key with its provenance and expiry, expired ones included - /// until someone prunes. One call tells a keeper the whole state of the - /// list. - function currentRoots() external view returns (RootInfo[] memory infos) { - uint256 n = _trackedKids.length; - infos = new RootInfo[](n); - for (uint256 i = 0; i < n; i++) { - bytes32 kidHash = _trackedKids[i]; - infos[i] = RootInfo({ - kidHash: kidHash, - modulusHash: modulusOfKid[kidHash], - observedAt: rotatedAtKid[kidHash], - expiresAt: expiresAtKid[kidHash] - }); - } - } - - /// True when no key is guaranteed trusted RENEWAL_MARGIN from now — the - /// single bit a keeper polls to decide whether to fetch a fresh reading. - /// True from deployment until the first rotation lands. - function needsRotation() external view returns (bool) { - uint256 horizon = block.timestamp + RENEWAL_MARGIN; - uint256 n = _trackedKids.length; - for (uint256 i = 0; i < n; i++) { - bytes32 modulusHash = modulusOfKid[_trackedKids[i]]; - if (trustedHashExpiresAt[modulusHash] > horizon) return false; - } - return true; - } - - function verifyDirect(UserProof calldata u) - external - view - returns (string memory handle, address sessionKey, uint256 expiresAt, string memory userId) - { - return _verifyAndExtract(u); - } - - // ─── Internal: rotation ─────────────────────────────────────── - function _rotate(NotarizedJwksProof memory p, JwkClaim[] memory claims) internal { - // The digest deliberately names no chain and no contract (see the - // contract comment); the Notary contract checks the attestation over - // it — EIP-191 + recover + signer compare today. - bytes32 digest = _notaryDigest(p); - if (!notaryContract.verify(digest, p.notarySignature)) revert UnknownNotary(); - - if (p.timestamp > block.timestamp + CLOCK_SKEW_GRACE) revert FutureProof(); - if (block.timestamp > p.timestamp + FRESHNESS_WINDOW) revert StaleProof(); - - // PERMISSIONLESS: anyone may submit a rotation. There is deliberately no - // one-shot nullifier here — with an open caller set it would BE the - // attack: a front-runner could consume a proof's digest (optionally with - // a claims subset) and brick the honest keeper's rotation. Freshness is - // enforced by the notary signature plus the FRESHNESS_WINDOW / - // CLOCK_SKEW_GRACE bounds above; re-applying the same rotation is - // idempotent. - - if (p.domainHash != EXPECTED_DOMAIN_HASH) revert WrongDomain(); - _verifyLeaf(p.domainPath, p.transcriptRoot, "domain:", EXPECTED_DOMAIN); - _verifyLeaf(p.endpointPath, p.transcriptRoot, "endpoint:", EXPECTED_ENDPOINT); - - // Monotonic high-water mark of reading timestamps. Replaying an older - // proof leaves it alone, so it only ever moves forward. - if (p.timestamp > freshestObservedAt) freshestObservedAt = p.timestamp; - - uint256 expiry = block.timestamp + DEFAULT_MODULUS_TTL; - for (uint256 i = 0; i < claims.length; i++) { - _processClaim(claims[i], p.transcriptRoot, expiry, p.timestamp); - } - } - - function _notaryDigest(NotarizedJwksProof memory p) internal pure returns (bytes32) { - return keccak256( - abi.encode( - p.domainHash, - p.clientRandom, - p.serverRandom, - keccak256(p.serverEphemeralKey), - p.transcriptRoot, - p.timestamp - ) - ); - } - - function _processClaim(JwkClaim memory c, bytes32 transcriptRoot, uint256 expiry, uint256 provenAt) internal { - if (c.jwkPath.length > 32) revert PathTooLong(); - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked("recv:", c.jwkBytes)))); - if (!_merkleVerifyMem(c.jwkPath, transcriptRoot, leaf)) revert MerkleMismatch(); - - if (!_containsKeyValue(c.jwkBytes, "kid", c.kid)) revert MissingKidInLeaf(); - if (!_containsKeyValue(c.jwkBytes, "n", c.nB64url)) revert MissingNInLeaf(); - - bytes memory rawN = _b64urlDecode(c.nB64url); - if (rawN.length != 256) revert InvalidModulusLength(); - bytes32[18] memory limbs = _splitLimbs(rawN); - - bytes memory packed = new bytes(18 * 32); - for (uint256 i = 0; i < 18; i++) { - bytes32 v = limbs[i]; - assembly { - mstore(add(add(packed, 32), mul(i, 32)), v) - } - } - bytes32 modulusHash = keccak256(packed); - - bytes32 kidHash = keccak256(c.kid); - // Ignore a claim proved no later than the one already applied — the - // monotonic rule that makes replay harmless. Rotation is open and a - // proof binds no contract, so anyone can replay any still-fresh - // reading anywhere, forever; ignoring (never reverting) keeps a - // front-runner from bricking the honest keeper's batch by landing one - // claim from it first, and skipping (never applying) keeps an older - // reading from rolling a kid back or re-stamping its TTL. - if (provenAt < rotatedAtKid[kidHash]) return; - bytes32 previous = modulusOfKid[kidHash]; - // A byte-identical resubmission of the applied reading is a no-op: - // nothing is written, nothing is emitted, and in particular the TTL is - // NOT re-stamped — so spamming the same proof neither grows state nor - // stretches trust. - if (provenAt == rotatedAtKid[kidHash] && previous == modulusHash) return; - rotatedAtKid[kidHash] = provenAt; - - // The key this kid used to carry stops being trusted NOW, rather than - // when its thirty-day stamp runs out. `_verifyAndExtract` resolves by - // modulus, not by kid, so leaving the old entry would keep a retired - // key usable for the rest of its TTL — exactly the window a - // compromised key would be used in. Google publishes overlapping keys - // and tokens live about an hour, so the cost is only that a token - // signed by the key Google just retired stops verifying a little - // early; the user signs in again. - if (previous != bytes32(0) && previous != modulusHash) { - delete trustedHashExpiresAt[previous]; - emit ModulusUntrusted(previous); - } else if (previous == bytes32(0)) { - _trackKid(kidHash); - } - - modulusOfKid[kidHash] = modulusHash; - expiresAtKid[kidHash] = expiry; - trustedHashExpiresAt[modulusHash] = expiry; - emit ModulusRotated(kidHash, string(c.kid), modulusHash, expiry); - emit RootApplied(kidHash, modulusHash, provenAt, expiry); - } - - /// Add a kid to the enumeration. Growth is doubly bounded: a kid can only - /// enter through a notarized reading of Google's own JWKS (a submitter - /// cannot invent one), and the set is capped — when full, expired entries - /// are pruned to make room, and only a set full of LIVE keys refuses. - function _trackKid(bytes32 kidHash) internal { - if (_trackedKidIndex[kidHash] != 0) return; - if (_trackedKids.length >= MAX_TRACKED_KIDS) { - _pruneExpired(); - if (_trackedKids.length >= MAX_TRACKED_KIDS) revert TooManyKids(); - } - _trackedKids.push(kidHash); - _trackedKidIndex[kidHash] = _trackedKids.length; - } - - /// Swap-remove every expired kid. `rotatedAtKid` survives on purpose: it is - /// the monotonic floor that keeps a replayed old reading from resurrecting - /// the entry the prune just removed. - function _pruneExpired() internal { - uint256 i = _trackedKids.length; - while (i > 0) { - i--; - bytes32 kidHash = _trackedKids[i]; - if (expiresAtKid[kidHash] > block.timestamp) continue; - - bytes32 modulusHash = modulusOfKid[kidHash]; - delete modulusOfKid[kidHash]; - delete expiresAtKid[kidHash]; - // Only clear the verifier-facing stamp if it is genuinely spent — - // it can sit above the kid's own expiry only if some fresher - // rotation re-trusted the same modulus under another kid. - if (trustedHashExpiresAt[modulusHash] <= block.timestamp) { - delete trustedHashExpiresAt[modulusHash]; - } - - uint256 last = _trackedKids.length - 1; - if (i != last) { - bytes32 moved = _trackedKids[last]; - _trackedKids[i] = moved; - _trackedKidIndex[moved] = i + 1; - } - _trackedKids.pop(); - delete _trackedKidIndex[kidHash]; - emit RootPruned(kidHash, modulusHash); - } - } - - // ─── Internal: user verification ────────────────────────────── - function _verifyAndExtract(UserProof memory u) - internal - view - returns (string memory handle, address sessionKey, uint256 expiresAt, string memory userId) - { - if (u.publicInputs.length != TOTAL_PUBLIC_INPUTS) revert WrongPublicInputCount(); - - // 1. Honk proof - if (!verifier.verify(u.honkProof, u.publicInputs)) revert BadHonkProof(); - - // Public-input layout (mirrors the circuit): - // [0..18) modulus limbs - // [18..20) email packed (62 bytes) - // [20..22) nonce packed (62 bytes) - // [22] sub packed (31 bytes, JWT `sub`) - // [23] exp - // [24..26) SHA-256(aud), two big-endian 16-byte halves - // [26] chain_id (== block.chainid) - // [27] registry_addr (== msg.sender, the calling Registry) - bytes memory packed; - { - packed = new bytes(18 * 32); - for (uint256 i = 0; i < 18; i++) { - bytes32 v = u.publicInputs[i]; - assembly { - mstore(add(add(packed, 32), mul(i, 32)), v) - } - } - } - bytes32 modulusHash = keccak256(packed); - uint256 modExpiry = trustedHashExpiresAt[modulusHash]; - if (modExpiry == 0 || block.timestamp >= modExpiry) revert UntrustedModulus(); - - // 3. exp - uint256 jwtExp = uint256(u.publicInputs[23]); - if (jwtExp + CLOCK_SKEW_GRACE <= block.timestamp) revert JwtExpired(); - - // 4. email matches packed - { - (bytes32 e0, bytes32 e1) = _pack62(_padTo62(bytes(u.email))); - if (u.publicInputs[18] != e0 || u.publicInputs[19] != e1) revert WrongEmail(); - } - - // 5. session address matches packed nonce - { - (bytes32 n0, bytes32 n1) = _pack62(_padTo62(_addressToAscii(u.sessionKey))); - if (u.publicInputs[20] != n0 || u.publicInputs[21] != n1) revert WrongAddress(); - } - - // 5b. sub (immutable account id) matches packed public input. - // Reject an empty sub up front (defense-in-depth: identity binds on it, - // and Registry would otherwise revert with an opaque UserIdRequired). - { - if (bytes(u.sub).length == 0) revert EmptySub(); - bytes32 s0 = _pack31(_padTo31(bytes(u.sub))); - if (u.publicInputs[22] != s0) revert WrongSub(); - } - - // 6. Deployment binding: the proof commits chain_id + Registry address, - // so a proof minted for one (chain, Registry) cannot be replayed against - // another deployment that shares this verification key. `msg.sender` is - // the Registry that dispatched `verifyAndExtract`. - if (uint256(u.publicInputs[26]) != block.chainid) revert WrongChain(); - if (uint256(u.publicInputs[27]) != uint256(uint160(msg.sender))) revert WrongRegistry(); - - // 7. Audience: the circuit binds the JWT `aud` to the token and exposes - // SHA-256(aud) as publicInputs[24..26) (two big-endian 16-byte halves). - // Enforce it equals the client id this verifier was configured for, so a - // Google-signed id_token minted for a DIFFERENT OAuth app can't be - // replayed here (confused deputy). Layout mirrors the circuit's split: - // high 16 bytes in [24], low 16 bytes in [25]. - bytes32 aud = expectedAudienceHash; - if (aud == bytes32(0)) revert WrongAudience(); // not configured - if ( - uint256(u.publicInputs[24]) != uint256(aud) >> 128 - || uint256(u.publicInputs[25]) != uint256(aud) & type(uint128).max - ) { - revert WrongAudience(); - } - - // `u.email` was already validated against the proof's packed public - // inputs above (lines marked "email matches packed") — return it - // verbatim so the Registry can use it as the display handle. `u.sub` - // is the immutable id identity binds on. - handle = u.email; - sessionKey = u.sessionKey; - expiresAt = jwtExp; - userId = u.sub; - } - - // ─── Internal: shared crypto / decode primitives ────────────── - function _verifyLeaf(bytes32[] memory path, bytes32 root, string memory prefix, bytes memory value) internal pure { - if (path.length > 32) revert PathTooLong(); - bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - if (!_merkleVerifyMem(path, root, leaf)) revert MerkleMismatch(); - } - - function _merkleVerifyMem(bytes32[] memory path, bytes32 root, bytes32 leaf) internal pure returns (bool) { - bytes32 cur = leaf; - for (uint256 i = 0; i < path.length; i++) { - cur = _hashPair(cur, path[i]); - } - return cur == root; - } - - function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - function _containsKeyValue(bytes memory haystack, bytes memory key, bytes memory value) - internal - pure - returns (bool) - { - return _contains(haystack, abi.encodePacked('"', key, '":"', value, '"')) - || _contains(haystack, abi.encodePacked('"', key, '": "', value, '"')); - } - - function _contains(bytes memory haystack, bytes memory needle) internal pure returns (bool) { - uint256 hLen = haystack.length; - uint256 nLen = needle.length; - if (nLen == 0) return true; - if (nLen > hLen) return false; - for (uint256 i = 0; i + nLen <= hLen; i++) { - bool m = true; - for (uint256 j = 0; j < nLen; j++) { - if (haystack[i + j] != needle[j]) { - m = false; - break; - } - } - if (m) return true; - } - return false; - } - - function _b64urlDecode(bytes memory input) internal pure returns (bytes memory) { - uint256 len = input.length; - while (len > 0 && uint8(input[len - 1]) == 0x3d) len--; - uint256 fullChunks = len / 4; - uint256 tail = len % 4; - if (tail == 1) revert InvalidB64Length(); - uint256 outLen = fullChunks * 3 + (tail == 0 ? 0 : tail - 1); - bytes memory out = new bytes(outLen); - uint256 outIdx = 0; - uint256 inIdx = 0; - for (uint256 i = 0; i < fullChunks; i++) { - uint256 b0 = _b64char(input[inIdx]); - uint256 b1 = _b64char(input[inIdx + 1]); - uint256 b2 = _b64char(input[inIdx + 2]); - uint256 b3 = _b64char(input[inIdx + 3]); - inIdx += 4; - out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); - out[outIdx + 1] = bytes1(uint8(((b1 & 0xf) << 4) | (b2 >> 2))); - out[outIdx + 2] = bytes1(uint8(((b2 & 0x3) << 6) | b3)); - outIdx += 3; - } - if (tail == 2) { - uint256 b0 = _b64char(input[inIdx]); - uint256 b1 = _b64char(input[inIdx + 1]); - out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); - } else if (tail == 3) { - uint256 b0 = _b64char(input[inIdx]); - uint256 b1 = _b64char(input[inIdx + 1]); - uint256 b2 = _b64char(input[inIdx + 2]); - out[outIdx] = bytes1(uint8((b0 << 2) | (b1 >> 4))); - out[outIdx + 1] = bytes1(uint8(((b1 & 0xf) << 4) | (b2 >> 2))); - } - return out; - } - - function _b64char(bytes1 c) internal pure returns (uint256) { - uint8 v = uint8(c); - if (v >= 65 && v <= 90) return v - 65; - if (v >= 97 && v <= 122) return v - 71; - if (v >= 48 && v <= 57) return uint256(v) + 4; - if (v == 45) return 62; - if (v == 95) return 63; - revert InvalidB64Char(); - } - - function _splitLimbs(bytes memory be) internal pure returns (bytes32[18] memory limbs) { - uint256[8] memory words; - assembly { - let beOffset := add(be, 32) - for { let i := 0 } lt(i, 8) { i := add(i, 1) } { - mstore(add(words, mul(i, 32)), mload(add(beOffset, mul(i, 32)))) - } - } - for (uint256 k = 0; k < 18; k++) { - uint256 startBit = k * 120; - uint256 endBit = startBit + 120; - if (endBit > 2048) endBit = 2048; - uint256 width = endBit - startBit; - uint256 startWord = startBit / 256; - uint256 endWord = (endBit - 1) / 256; - uint256 lv; - if (startWord == endWord) { - uint256 w = words[7 - startWord]; - lv = (w >> (startBit % 256)) & ((uint256(1) << width) - 1); - } else { - uint256 lowWord = words[7 - startWord]; - uint256 highWord = words[7 - endWord]; - uint256 lowShift = startBit % 256; - uint256 lowBits = 256 - lowShift; - uint256 highBits = width - lowBits; - uint256 lowVal = lowWord >> lowShift; - uint256 highMask = (uint256(1) << highBits) - 1; - uint256 highVal = (highWord & highMask) << lowBits; - lv = lowVal | highVal; - } - limbs[k] = bytes32(lv); - } - } - - function _padTo62(bytes memory s) internal pure returns (bytes memory out) { - require(s.length <= 62, "input >62 bytes"); - out = new bytes(62); - for (uint256 i = 0; i < s.length; i++) { - out[i] = s[i]; - } - } - - function _pack62(bytes memory p) internal pure returns (bytes32 hi, bytes32 lo) { - require(p.length == 62, "not 62"); - uint256 h; - uint256 l; - for (uint256 i = 0; i < 31; i++) { - h = h * 256 + uint8(p[i]); - l = l * 256 + uint8(p[i + 31]); - } - hi = bytes32(h); - lo = bytes32(l); - } - - function _padTo31(bytes memory s) internal pure returns (bytes memory out) { - require(s.length <= 31, "input >31 bytes"); - out = new bytes(31); - for (uint256 i = 0; i < s.length; i++) { - out[i] = s[i]; - } - } - - function _pack31(bytes memory p) internal pure returns (bytes32 packed) { - require(p.length == 31, "not 31"); - uint256 v; - for (uint256 i = 0; i < 31; i++) { - v = v * 256 + uint8(p[i]); - } - packed = bytes32(v); - } - - function _addressToAscii(address a) internal pure returns (bytes memory) { - bytes memory out = new bytes(42); - out[0] = "0"; - out[1] = "x"; - bytes16 hexc = "0123456789abcdef"; - uint160 v = uint160(a); - for (uint256 i = 0; i < 20; i++) { - uint8 b = uint8(v >> ((19 - i) * 8)); - out[2 + i * 2] = hexc[b >> 4]; - out[2 + i * 2 + 1] = hexc[b & 0x0f]; - } - return out; - } -} diff --git a/solidity/contracts/login/oidc/IOidcVerifier.sol b/solidity/contracts/login/oidc/IOidcVerifier.sol deleted file mode 100644 index fa3b8155..00000000 --- a/solidity/contracts/login/oidc/IOidcVerifier.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -/// Plug-in OIDC identity-attestation module for the `Registry`. -/// -/// One implementation per OIDC provider (Google, Telegram, …). Each module -/// owns whatever trust roots its provider exposes (RSA JWKS) and the -/// matching UltraHonk verifier for that provider's JWT circuit. The -/// Registry stays small — it dispatches by `platform`, lets the verifier -/// validate the proof, and writes the resulting identity → wallet mapping -/// using the same code path that backs the TLSN-based X / GitHub flows. -interface IOidcVerifier { - /// Verify a user proof and return the validated identity. - /// - /// `proof` is platform-specific bytes (the verifier decodes them). - /// Returns: - /// * `handle` — human-readable identity from the JWT (e.g. email - /// for Google). The verifier guarantees this equals - /// the value bound inside the circuit's public - /// inputs — the Registry can use it directly as the - /// handle in its `(platform, handle) → wallet` map. - /// * `sessionKey` — Ethereum address the user is binding to. - /// * `expiresAt` — JWT validity deadline (unix seconds). - /// * `userId` — immutable platform user-id (Google JWT `sub`). - /// Escrow keys on this, never the mutable handle. - function verifyAndExtract(bytes calldata proof) - external - view - returns (string memory handle, address sessionKey, uint256 expiresAt, string memory userId); - - /// Process a notary-attested rotation of this verifier's trust roots. - /// For Google OIDC: TLS-notary attestation over the JWKS endpoint. - function rotateRoots(bytes calldata rotationProof) external; - - /// Human-readable platform name (e.g. `"google"`). Used for events - /// and debugging. - function platformName() external view returns (string memory); -} diff --git a/solidity/contracts/login/oidc/Verifier.sol b/solidity/contracts/login/oidc/Verifier.sol deleted file mode 100644 index 26a970ec..00000000 --- a/solidity/contracts/login/oidc/Verifier.sol +++ /dev/null @@ -1,2461 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2022 Aztec -pragma solidity >=0.8.21; - -uint256 constant N = 262144; -uint256 constant LOG_N = 18; -uint256 constant NUMBER_OF_PUBLIC_INPUTS = 36; -uint256 constant VK_HASH = 0x1a1fad94a1ecd83ce953541e1a6e24fc61a62f846bcd31f7e65ed26ad7d6ba08; - -library HonkVerificationKey { - function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { - Honk.VerificationKey memory vk = Honk.VerificationKey({ - circuitSize: uint256(262144), - logCircuitSize: uint256(18), - publicInputsSize: uint256(36), - ql: Honk.G1Point({ - x: uint256(0x1d5eccf7beb2bd6e4d6139fc7030dd30fd9ac6288360a972f9d3e093663b9749), - y: uint256(0x2daf3911a1eb09482454ef198bd85f37d76519ba230e812c687f2a107635b4a8) - }), - qr: Honk.G1Point({ - x: uint256(0x17c25693a8e14f7ebdddd748fb3215cd858d03d3f19d04976bba379082b5d528), - y: uint256(0x1da040a6ed2c59e00277a0e0e39d5adf7f871cde30d0fa1183a47bc321fe8f67) - }), - qo: Honk.G1Point({ - x: uint256(0x2991b455012204251bf866220a55808213c0ed0ede10c5119bcf3a0de3756f41), - y: uint256(0x1d3c2e52f3cd1b82749d5c5d0a87b4061084282be192f6600876035b09fa7cf5) - }), - q4: Honk.G1Point({ - x: uint256(0x1071e060db2aa32a11c888a07f0e286536d0a83ecc64c5f4605bd99f801c9ff1), - y: uint256(0x2fed5ba63f937e80ed923f176bdb7a9b9d403ebb12c7682d60573299f927161c) - }), - qm: Honk.G1Point({ - x: uint256(0x1cfa39cf0fa544b5de7a08554742edaa0ec2762256fc58c503a969ac1dd73719), - y: uint256(0x1349d33245f526c20fe48776dcb3776c68fe2522cc14534f7f159d5796e33a17) - }), - qc: Honk.G1Point({ - x: uint256(0x0f257837a5102e98c50c5ec28ce3973cac5f8fb8bf18f0f4dd46eaf635c81550), - y: uint256(0x2ffc8faa0b09583cf2a270ddd6e017d2d0061c4c7e57533d04ee9830babfd8a5) - }), - qLookup: Honk.G1Point({ - x: uint256(0x09e57c9e52e810c8b8533d1a3954bf01a660071ad560ce125c23eac06320f405), - y: uint256(0x2bc108ca74813679dacc6b0fa539c94a285cd646163cb2ce63d007e87d6b54ae) - }), - qArith: Honk.G1Point({ - x: uint256(0x15dc06ed9016b236564dbf6515dbd3506617b4fbe15524265164af93666964ee), - y: uint256(0x257ad06402f632f0e7eed9af6a8d3aadf30f7be46fb634b2db5260cfbfb6ed5a) - }), - qDeltaRange: Honk.G1Point({ - x: uint256(0x074bd7465151c3df24ac2a25dfab519bd8ed135f56272d72a3d946f4d0ccba37), - y: uint256(0x178a9a8f36aa145fb9c3ad36a22d7a3ef937b7e4a2241bc40eade0410c37ab3f) - }), - qElliptic: Honk.G1Point({ - x: uint256(0x04df8ec8779d604c3220ac2ee3974ec94fe2582f50f9463b02e82b9ec0040931), - y: uint256(0x1c432c588f21994deec3a64950555a4b55d5b889b5332e02f421cfe621d31fd8) - }), - qMemory: Honk.G1Point({ - x: uint256(0x10deefe499f9420af695ffdb4771395ff3481f0ded5ebb10a729f1c10c82e269), - y: uint256(0x2da66cc0eb84e697b92260e588270c415a14e6288015d4722aa249bc6253e3eb) - }), - qNnf: Honk.G1Point({ - x: uint256(0x1f556827bad8c3db1feb3461a344a9d2a9f697d1be0dd224ff9d9acd942fa423), - y: uint256(0x2bb88d4e6f7acdcfbc3f380a38c4136ddd06aa0307478b14ab4fb93d0a0bbb29) - }), - qPoseidon2External: Honk.G1Point({ - x: uint256(0x0d945eb0b5c0239afcc778b403ee0235f37bc2c9049e337e5acc627c5ae42210), - y: uint256(0x094d03a032417e0ae6bece072f8af6b903d051505c11cc75722a24b595dc630b) - }), - qPoseidon2Internal: Honk.G1Point({ - x: uint256(0x1af09c3bc978beb8d0df495a18c739477e109a84acc5d7537e788b809fd99277), - y: uint256(0x161466f6a46f985b36026cd4621e50230245a6c8ad922d31f2f4851f9d887b0f) - }), - s1: Honk.G1Point({ - x: uint256(0x102c88e39baca9594e1b915b4c7c63cf5de6399e3166f8bd33493f6668f92c7b), - y: uint256(0x28df7c0f723f057fdf4730c96a66f6ff91ff81799e984fb440940ee923621b6d) - }), - s2: Honk.G1Point({ - x: uint256(0x2044a388617a3a282e2754a32ac0eb4cfa5476a2bc3a0d6b09ddd176d41207df), - y: uint256(0x150d72a920a8d889ff16ce2c019e51068ffb70825bf2ace436a2b1cfd13d9c5f) - }), - s3: Honk.G1Point({ - x: uint256(0x258ddf4bc87075a4485f97eda24c622b55d945323a6a0258aa962ca278c8546d), - y: uint256(0x29fdc2372e9744f56b054dfc1ce82f7ec9b446cb2e3d359c53d62eb8f4dcff61) - }), - s4: Honk.G1Point({ - x: uint256(0x25cdc5c257ae3248de950263f733c0e77d38b6361ce5bdee745cd9d5411e4f0e), - y: uint256(0x0bf945904c720b62e02bf58b932b42e40b0bb0cf51fdae95bad4f16a5f58d57f) - }), - t1: Honk.G1Point({ - x: uint256(0x26814a59803555635a3b6dfd056d19f4a6c6ffdf80a72db78762e237d58cd5f6), - y: uint256(0x0b9dc32399025796005b2f68c15991f45b8d96069231f48e0d0c34d57593a6a6) - }), - t2: Honk.G1Point({ - x: uint256(0x1f6cd5b6d43f67798ae4655c016f37750e1b5cb997a3adf8540bf55bb9264ec4), - y: uint256(0x0ed62d10b019355f008b46412d0e2d596cc6183848f32c60ec74fe2bcd76b20f) - }), - t3: Honk.G1Point({ - x: uint256(0x23a2906d03204aa4f79b5b80b92eaa2020dfdab8c9db8335bca3c8284b1585b2), - y: uint256(0x24aecae332db48e03b97fc645a7f059116c4d15f7c9a51f17c3830fe7c1e3b42) - }), - t4: Honk.G1Point({ - x: uint256(0x08524b40cf87b668119022f691cfac834c5e938cf9e8bd4bd5847e2408407276), - y: uint256(0x0e1e9d4327e635f547da67aef57e86e699df860731437ec57f9890fe4b3a4205) - }), - id1: Honk.G1Point({ - x: uint256(0x0fc9b284799173bff317ebe1c893ead71243e5b0ae8d2b39697b10fff7c4c1ef), - y: uint256(0x046a84610998570baaec81b831e5b203fe530f5bd8492e301a5eb3f64b6af92f) - }), - id2: Honk.G1Point({ - x: uint256(0x04e38bb550f66dd4cc0bf599150a83bad549f09ac5888a7f76ff5f5be82e33e6), - y: uint256(0x09f26e5bb2265759c68d02adb00491190f7207cc1689607ffa4a7d053be7021d) - }), - id3: Honk.G1Point({ - x: uint256(0x0c4b729451959d81da634887d88dbf8d9bfd0c6c062690df7e436bfe4cd1e76e), - y: uint256(0x20dab2b2a22c393923aea5ce27a953cec07399aff6d5c42f05b0d0653ff524be) - }), - id4: Honk.G1Point({ - x: uint256(0x270a1d39c76987698ed303ea31b4ccb37af98f03c0c0ea1df105af1a2cf34530), - y: uint256(0x1004ba82c884208931427cda4d093d81039f1a352c09d672bc11f6ec50442168) - }), - lagrangeFirst: Honk.G1Point({ - x: uint256(0x0000000000000000000000000000000000000000000000000000000000000001), - y: uint256(0x0000000000000000000000000000000000000000000000000000000000000002) - }), - lagrangeLast: Honk.G1Point({ - x: uint256(0x2a65a82f2cd8853c1548dd31f29785ad1d1c716aa72970dd54aff5adce5014ec), - y: uint256(0x2c3ddf59d3904025f1412bba2aef0a9da0bbb5b22d9dcdbca7a97c0cdacd8f47) - }) - }); - return vk; - } -} - -pragma solidity ^0.8.27; - -interface IVerifier { - function verify(bytes calldata _proof, bytes32[] calldata _publicInputs) external view returns (bool); -} - -/** - * @notice Library of error codes - * @dev You can run `forge inspect Errors errors` to get the selectors for the optimised verifier - */ -library Errors { - error ValueGeLimbMax(); - error ValueGeGroupOrder(); - error ValueGeFieldOrder(); - - error InvertOfZero(); - error NotPowerOfTwo(); - error ModExpFailed(); - - error ProofLengthWrong(); - error ProofLengthWrongWithLogN(uint256 logN, uint256 actualLength, uint256 expectedLength); - error PublicInputsLengthWrong(); - error SumcheckFailed(); - error ShpleminiFailed(); - - error PointAtInfinity(); - - error ConsistencyCheckFailed(); - error GeminiChallengeInSubgroup(); -} - -type Fr is uint256; - -using {add as +} for Fr global; -using {sub as -} for Fr global; -using {mul as *} for Fr global; - -using {notEqual as !=} for Fr global; -using {equal as ==} for Fr global; - -uint256 constant SUBGROUP_SIZE = 256; -uint256 constant MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617; // Prime field order -uint256 constant P = MODULUS; -Fr constant SUBGROUP_GENERATOR = Fr.wrap(0x07b0c561a6148404f086204a9f36ffb0617942546750f230c893619174a57a76); -Fr constant SUBGROUP_GENERATOR_INVERSE = Fr.wrap(0x204bd3277422fad364751ad938e2b5e6a54cf8c68712848a692c553d0329f5d6); -Fr constant MINUS_ONE = Fr.wrap(MODULUS - 1); -Fr constant ONE = Fr.wrap(1); -Fr constant ZERO = Fr.wrap(0); -// Instantiation - -library FrLib { - bytes4 internal constant FRLIB_MODEXP_FAILED_SELECTOR = 0xf8d61709; - - function invert(Fr value) internal view returns (Fr) { - uint256 v = Fr.unwrap(value); - require(v != 0, Errors.InvertOfZero()); - - uint256 result; - - // Call the modexp precompile to invert in the field - assembly ("memory-safe") { - let free := mload(0x40) - mstore(free, 0x20) - mstore(add(free, 0x20), 0x20) - mstore(add(free, 0x40), 0x20) - mstore(add(free, 0x60), v) - mstore(add(free, 0x80), sub(MODULUS, 2)) - mstore(add(free, 0xa0), MODULUS) - let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) - if iszero(success) { - mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) - revert(0, 0x04) - } - result := mload(0x00) - mstore(0x40, add(free, 0xc0)) - } - - return Fr.wrap(result); - } - - function pow(Fr base, uint256 v) internal view returns (Fr) { - uint256 b = Fr.unwrap(base); - // Only works for power of 2 - require(v > 0 && (v & (v - 1)) == 0, Errors.NotPowerOfTwo()); - uint256 result; - - // Call the modexp precompile to invert in the field - assembly ("memory-safe") { - let free := mload(0x40) - mstore(free, 0x20) - mstore(add(free, 0x20), 0x20) - mstore(add(free, 0x40), 0x20) - mstore(add(free, 0x60), b) - mstore(add(free, 0x80), v) - mstore(add(free, 0xa0), MODULUS) - let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) - if iszero(success) { - mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) - revert(0, 0x04) - } - result := mload(0x00) - mstore(0x40, add(free, 0xc0)) - } - - return Fr.wrap(result); - } - - function div(Fr numerator, Fr denominator) internal view returns (Fr) { - unchecked { - return numerator * invert(denominator); - } - } - - function sqr(Fr value) internal pure returns (Fr) { - unchecked { - return value * value; - } - } - - function unwrap(Fr value) internal pure returns (uint256) { - unchecked { - return Fr.unwrap(value); - } - } - - function neg(Fr value) internal pure returns (Fr) { - unchecked { - return Fr.wrap(MODULUS - Fr.unwrap(value)); - } - } - - function from(uint256 value) internal pure returns (Fr) { - unchecked { - require(value < MODULUS, Errors.ValueGeFieldOrder()); - return Fr.wrap(value); - } - } - - function fromBytes32(bytes32 value) internal pure returns (Fr) { - unchecked { - uint256 v = uint256(value); - require(v < MODULUS, Errors.ValueGeFieldOrder()); - return Fr.wrap(v); - } - } - - function toBytes32(Fr value) internal pure returns (bytes32) { - unchecked { - return bytes32(Fr.unwrap(value)); - } - } -} - -// Free functions -function add(Fr a, Fr b) pure returns (Fr) { - unchecked { - return Fr.wrap(addmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); - } -} - -function mul(Fr a, Fr b) pure returns (Fr) { - unchecked { - return Fr.wrap(mulmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); - } -} - -function sub(Fr a, Fr b) pure returns (Fr) { - unchecked { - return Fr.wrap(addmod(Fr.unwrap(a), MODULUS - Fr.unwrap(b), MODULUS)); - } -} - -function notEqual(Fr a, Fr b) pure returns (bool) { - unchecked { - return Fr.unwrap(a) != Fr.unwrap(b); - } -} - -function equal(Fr a, Fr b) pure returns (bool) { - unchecked { - return Fr.unwrap(a) == Fr.unwrap(b); - } -} - -uint256 constant CONST_PROOF_SIZE_LOG_N = 25; - -uint256 constant NUMBER_OF_SUBRELATIONS = 28; -uint256 constant BATCHED_RELATION_PARTIAL_LENGTH = 8; -uint256 constant ZK_BATCHED_RELATION_PARTIAL_LENGTH = 9; -uint256 constant NUMBER_OF_ENTITIES = 41; -// The number of entities added for ZK (gemini_masking_poly) -uint256 constant NUM_MASKING_POLYNOMIALS = 1; -uint256 constant NUMBER_OF_ENTITIES_ZK = NUMBER_OF_ENTITIES + NUM_MASKING_POLYNOMIALS; -uint256 constant NUMBER_UNSHIFTED = 36; -uint256 constant NUMBER_UNSHIFTED_ZK = NUMBER_UNSHIFTED + NUM_MASKING_POLYNOMIALS; -uint256 constant NUMBER_TO_BE_SHIFTED = 5; -uint256 constant PAIRING_POINTS_SIZE = 8; - -uint256 constant FIELD_ELEMENT_SIZE = 0x20; -uint256 constant GROUP_ELEMENT_SIZE = 0x40; - -// Powers of alpha used to batch subrelations (alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)) -uint256 constant NUMBER_OF_ALPHAS = NUMBER_OF_SUBRELATIONS - 1; - -// ENUM FOR WIRES -enum WIRE { - Q_M, - Q_C, - Q_L, - Q_R, - Q_O, - Q_4, - Q_LOOKUP, - Q_ARITH, - Q_RANGE, - Q_ELLIPTIC, - Q_MEMORY, - Q_NNF, - Q_POSEIDON2_EXTERNAL, - Q_POSEIDON2_INTERNAL, - SIGMA_1, - SIGMA_2, - SIGMA_3, - SIGMA_4, - ID_1, - ID_2, - ID_3, - ID_4, - TABLE_1, - TABLE_2, - TABLE_3, - TABLE_4, - LAGRANGE_FIRST, - LAGRANGE_LAST, - W_L, - W_R, - W_O, - W_4, - Z_PERM, - LOOKUP_INVERSES, - LOOKUP_READ_COUNTS, - LOOKUP_READ_TAGS, - W_L_SHIFT, - W_R_SHIFT, - W_O_SHIFT, - W_4_SHIFT, - Z_PERM_SHIFT -} - -library Honk { - struct G1Point { - uint256 x; - uint256 y; - } - - struct VerificationKey { - // Misc Params - uint256 circuitSize; - uint256 logCircuitSize; - uint256 publicInputsSize; - // Selectors - G1Point qm; - G1Point qc; - G1Point ql; - G1Point qr; - G1Point qo; - G1Point q4; - G1Point qLookup; // Lookup - G1Point qArith; // Arithmetic widget - G1Point qDeltaRange; // Delta Range sort - G1Point qMemory; // Memory - G1Point qNnf; // Non-native Field - G1Point qElliptic; // Auxillary - G1Point qPoseidon2External; - G1Point qPoseidon2Internal; - // Copy constraints - G1Point s1; - G1Point s2; - G1Point s3; - G1Point s4; - // Copy identity - G1Point id1; - G1Point id2; - G1Point id3; - G1Point id4; - // Precomputed lookup table - G1Point t1; - G1Point t2; - G1Point t3; - G1Point t4; - // Fixed first and last - G1Point lagrangeFirst; - G1Point lagrangeLast; - } - - struct RelationParameters { - // challenges - Fr eta; - Fr beta; - Fr gamma; - // derived - Fr publicInputsDelta; - } - - struct Proof { - // Pairing point object - Fr[PAIRING_POINTS_SIZE] pairingPointObject; - // Free wires - G1Point w1; - G1Point w2; - G1Point w3; - G1Point w4; - // Lookup helpers - Permutations - G1Point zPerm; - // Lookup helpers - logup - G1Point lookupReadCounts; - G1Point lookupReadTags; - G1Point lookupInverses; - // Sumcheck - Fr[BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; - Fr[NUMBER_OF_ENTITIES] sumcheckEvaluations; - // Shplemini - G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; - Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; - G1Point shplonkQ; - G1Point kzgQuotient; - } - - /// forge-lint: disable-next-item(pascal-case-struct) - struct ZKProof { - // Pairing point object - Fr[PAIRING_POINTS_SIZE] pairingPointObject; - // ZK: Gemini masking polynomial commitment (sent first, right after public inputs) - G1Point geminiMaskingPoly; - // Commitments to wire polynomials - G1Point w1; - G1Point w2; - G1Point w3; - G1Point w4; - // Commitments to logup witness polynomials - G1Point lookupReadCounts; - G1Point lookupReadTags; - G1Point lookupInverses; - // Commitment to grand permutation polynomial - G1Point zPerm; - G1Point[3] libraCommitments; - // Sumcheck - Fr libraSum; - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; - Fr libraEvaluation; - Fr[NUMBER_OF_ENTITIES_ZK] sumcheckEvaluations; // Includes gemini_masking_poly eval at index 0 (first position) - // Shplemini - G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; - Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; - Fr[4] libraPolyEvals; - G1Point shplonkQ; - G1Point kzgQuotient; - } -} - -// ZKTranscript library to generate fiat shamir challenges, the ZK transcript only differest -/// forge-lint: disable-next-item(pascal-case-struct) -struct ZKTranscript { - // Oink - Honk.RelationParameters relationParameters; - Fr[NUMBER_OF_ALPHAS] alphas; // Powers of alpha: [alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)] - Fr[CONST_PROOF_SIZE_LOG_N] gateChallenges; - // Sumcheck - Fr libraChallenge; - Fr[CONST_PROOF_SIZE_LOG_N] sumCheckUChallenges; - // Shplemini - Fr rho; - Fr geminiR; - Fr shplonkNu; - Fr shplonkZ; - // Derived - Fr publicInputsDelta; -} - -library ZKTranscriptLib { - function generateTranscript( - Honk.ZKProof memory proof, - bytes32[] calldata publicInputs, - uint256 vkHash, - uint256 publicInputsSize, - uint256 logN - ) external pure returns (ZKTranscript memory t) { - Fr previousChallenge; - (t.relationParameters, previousChallenge) = - generateRelationParametersChallenges(proof, publicInputs, vkHash, publicInputsSize, previousChallenge); - - (t.alphas, previousChallenge) = generateAlphaChallenges(previousChallenge, proof); - - (t.gateChallenges, previousChallenge) = generateGateChallenges(previousChallenge, logN); - (t.libraChallenge, previousChallenge) = generateLibraChallenge(previousChallenge, proof); - (t.sumCheckUChallenges, previousChallenge) = generateSumcheckChallenges(proof, previousChallenge, logN); - - (t.rho, previousChallenge) = generateRhoChallenge(proof, previousChallenge); - - (t.geminiR, previousChallenge) = generateGeminiRChallenge(proof, previousChallenge, logN); - - (t.shplonkNu, previousChallenge) = generateShplonkNuChallenge(proof, previousChallenge, logN); - - (t.shplonkZ, previousChallenge) = generateShplonkZChallenge(proof, previousChallenge); - return t; - } - - function splitChallenge(Fr challenge) internal pure returns (Fr first, Fr second) { - uint256 challengeU256 = uint256(Fr.unwrap(challenge)); - // Split into two equal 127-bit chunks (254/2) - uint256 lo = challengeU256 & 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 127 bits - uint256 hi = challengeU256 >> 127; - first = FrLib.from(lo); - second = FrLib.from(hi); - } - - function generateRelationParametersChallenges( - Honk.ZKProof memory proof, - bytes32[] calldata publicInputs, - uint256 vkHash, - uint256 publicInputsSize, - Fr previousChallenge - ) internal pure returns (Honk.RelationParameters memory rp, Fr nextPreviousChallenge) { - (rp.eta, previousChallenge) = generateEtaChallenge(proof, publicInputs, vkHash, publicInputsSize); - - (rp.beta, rp.gamma, nextPreviousChallenge) = generateBetaGammaChallenges(previousChallenge, proof); - } - - function generateEtaChallenge( - Honk.ZKProof memory proof, - bytes32[] calldata publicInputs, - uint256 vkHash, - uint256 publicInputsSize - ) internal pure returns (Fr eta, Fr previousChallenge) { - // Size: 1 (vkHash) + publicInputsSize + 8 (geminiMask(2) + 3 wires(6)) - bytes32[] memory round0 = new bytes32[](1 + publicInputsSize + 8); - round0[0] = bytes32(vkHash); - - for (uint256 i = 0; i < publicInputsSize - PAIRING_POINTS_SIZE; i++) { - require(uint256(publicInputs[i]) < P, Errors.ValueGeFieldOrder()); - round0[1 + i] = publicInputs[i]; - } - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - round0[1 + publicInputsSize - PAIRING_POINTS_SIZE + i] = FrLib.toBytes32(proof.pairingPointObject[i]); - } - - // For ZK flavors: hash the gemini masking poly commitment (sent right after public inputs) - round0[1 + publicInputsSize] = bytes32(proof.geminiMaskingPoly.x); - round0[1 + publicInputsSize + 1] = bytes32(proof.geminiMaskingPoly.y); - - // Create the first challenge - // Note: w4 is added to the challenge later on - round0[1 + publicInputsSize + 2] = bytes32(proof.w1.x); - round0[1 + publicInputsSize + 3] = bytes32(proof.w1.y); - round0[1 + publicInputsSize + 4] = bytes32(proof.w2.x); - round0[1 + publicInputsSize + 5] = bytes32(proof.w2.y); - round0[1 + publicInputsSize + 6] = bytes32(proof.w3.x); - round0[1 + publicInputsSize + 7] = bytes32(proof.w3.y); - - previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round0))) % P); - (eta,) = splitChallenge(previousChallenge); - } - - function generateBetaGammaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) - internal - pure - returns (Fr beta, Fr gamma, Fr nextPreviousChallenge) - { - bytes32[7] memory round1; - round1[0] = FrLib.toBytes32(previousChallenge); - round1[1] = bytes32(proof.lookupReadCounts.x); - round1[2] = bytes32(proof.lookupReadCounts.y); - round1[3] = bytes32(proof.lookupReadTags.x); - round1[4] = bytes32(proof.lookupReadTags.y); - round1[5] = bytes32(proof.w4.x); - round1[6] = bytes32(proof.w4.y); - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round1))) % P); - (beta, gamma) = splitChallenge(nextPreviousChallenge); - } - - // Alpha challenges non-linearise the gate contributions - function generateAlphaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) - internal - pure - returns (Fr[NUMBER_OF_ALPHAS] memory alphas, Fr nextPreviousChallenge) - { - // Generate the original sumcheck alpha 0 by hashing zPerm and zLookup - uint256[5] memory alpha0; - alpha0[0] = Fr.unwrap(previousChallenge); - alpha0[1] = proof.lookupInverses.x; - alpha0[2] = proof.lookupInverses.y; - alpha0[3] = proof.zPerm.x; - alpha0[4] = proof.zPerm.y; - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(alpha0))) % P); - Fr alpha; - (alpha,) = splitChallenge(nextPreviousChallenge); - - // Compute powers of alpha for batching subrelations - alphas[0] = alpha; - for (uint256 i = 1; i < NUMBER_OF_ALPHAS; i++) { - alphas[i] = alphas[i - 1] * alpha; - } - } - - function generateGateChallenges(Fr previousChallenge, uint256 logN) - internal - pure - returns (Fr[CONST_PROOF_SIZE_LOG_N] memory gateChallenges, Fr nextPreviousChallenge) - { - previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(Fr.unwrap(previousChallenge)))) % P); - (gateChallenges[0],) = splitChallenge(previousChallenge); - for (uint256 i = 1; i < logN; i++) { - gateChallenges[i] = gateChallenges[i - 1] * gateChallenges[i - 1]; - } - nextPreviousChallenge = previousChallenge; - } - - function generateLibraChallenge(Fr previousChallenge, Honk.ZKProof memory proof) - internal - pure - returns (Fr libraChallenge, Fr nextPreviousChallenge) - { - // 2 comm, 1 sum, 1 challenge - uint256[4] memory challengeData; - challengeData[0] = Fr.unwrap(previousChallenge); - challengeData[1] = proof.libraCommitments[0].x; - challengeData[2] = proof.libraCommitments[0].y; - challengeData[3] = Fr.unwrap(proof.libraSum); - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(challengeData))) % P); - (libraChallenge,) = splitChallenge(nextPreviousChallenge); - } - - function generateSumcheckChallenges(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) - internal - pure - returns (Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckChallenges, Fr nextPreviousChallenge) - { - for (uint256 i = 0; i < logN; i++) { - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH + 1] memory univariateChal; - univariateChal[0] = prevChallenge; - - for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { - univariateChal[j + 1] = proof.sumcheckUnivariates[i][j]; - } - prevChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(univariateChal))) % P); - - (sumcheckChallenges[i],) = splitChallenge(prevChallenge); - } - nextPreviousChallenge = prevChallenge; - } - - // We add Libra claimed eval + 2 libra commitments (grand_sum, quotient) - function generateRhoChallenge(Honk.ZKProof memory proof, Fr prevChallenge) - internal - pure - returns (Fr rho, Fr nextPreviousChallenge) - { - uint256[NUMBER_OF_ENTITIES_ZK + 6] memory rhoChallengeElements; - rhoChallengeElements[0] = Fr.unwrap(prevChallenge); - uint256 i; - for (i = 1; i <= NUMBER_OF_ENTITIES_ZK; i++) { - rhoChallengeElements[i] = Fr.unwrap(proof.sumcheckEvaluations[i - 1]); - } - rhoChallengeElements[i] = Fr.unwrap(proof.libraEvaluation); - i += 1; - rhoChallengeElements[i] = proof.libraCommitments[1].x; - rhoChallengeElements[i + 1] = proof.libraCommitments[1].y; - i += 2; - rhoChallengeElements[i] = proof.libraCommitments[2].x; - rhoChallengeElements[i + 1] = proof.libraCommitments[2].y; - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(rhoChallengeElements))) % P); - (rho,) = splitChallenge(nextPreviousChallenge); - } - - function generateGeminiRChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) - internal - pure - returns (Fr geminiR, Fr nextPreviousChallenge) - { - uint256[] memory gR = new uint256[]((logN - 1) * 2 + 1); - gR[0] = Fr.unwrap(prevChallenge); - - for (uint256 i = 0; i < logN - 1; i++) { - gR[1 + i * 2] = proof.geminiFoldComms[i].x; - gR[2 + i * 2] = proof.geminiFoldComms[i].y; - } - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(gR))) % P); - - (geminiR,) = splitChallenge(nextPreviousChallenge); - } - - function generateShplonkNuChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) - internal - pure - returns (Fr shplonkNu, Fr nextPreviousChallenge) - { - uint256[] memory shplonkNuChallengeElements = new uint256[](logN + 1 + 4); - shplonkNuChallengeElements[0] = Fr.unwrap(prevChallenge); - - for (uint256 i = 1; i <= logN; i++) { - shplonkNuChallengeElements[i] = Fr.unwrap(proof.geminiAEvaluations[i - 1]); - } - - uint256 libraIdx = 0; - for (uint256 i = logN + 1; i <= logN + 4; i++) { - shplonkNuChallengeElements[i] = Fr.unwrap(proof.libraPolyEvals[libraIdx]); - libraIdx++; - } - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkNuChallengeElements))) % P); - (shplonkNu,) = splitChallenge(nextPreviousChallenge); - } - - function generateShplonkZChallenge(Honk.ZKProof memory proof, Fr prevChallenge) - internal - pure - returns (Fr shplonkZ, Fr nextPreviousChallenge) - { - uint256[3] memory shplonkZChallengeElements; - shplonkZChallengeElements[0] = Fr.unwrap(prevChallenge); - - shplonkZChallengeElements[1] = proof.shplonkQ.x; - shplonkZChallengeElements[2] = proof.shplonkQ.y; - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkZChallengeElements))) % P); - (shplonkZ,) = splitChallenge(nextPreviousChallenge); - } - - function loadProof(bytes calldata proof, uint256 logN) internal pure returns (Honk.ZKProof memory p) { - uint256 boundary = 0x0; - - // Pairing point object - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - uint256 limb = uint256(bytes32(proof[boundary:boundary + FIELD_ELEMENT_SIZE])); - // lo limbs (even index) < 2^136, hi limbs (odd index) < 2^120 - require(limb < 2 ** (i % 2 == 0 ? 136 : 120), Errors.ValueGeLimbMax()); - p.pairingPointObject[i] = FrLib.from(limb); - boundary += FIELD_ELEMENT_SIZE; - } - - // Gemini masking polynomial commitment (sent first in ZK flavors, right after pairing points) - p.geminiMaskingPoly = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - // Commitments - p.w1 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.w2 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.w3 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - // Lookup / Permutation Helper Commitments - p.lookupReadCounts = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.lookupReadTags = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.w4 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.lookupInverses = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.zPerm = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.libraCommitments[0] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - p.libraSum = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - // Sumcheck univariates - for (uint256 i = 0; i < logN; i++) { - for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { - p.sumcheckUnivariates[i][j] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - } - - // Sumcheck evaluations (includes gemini_masking_poly eval at index 0 for ZK flavors) - for (uint256 i = 0; i < NUMBER_OF_ENTITIES_ZK; i++) { - p.sumcheckEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - - p.libraEvaluation = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - - p.libraCommitments[1] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.libraCommitments[2] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - // Gemini - // Read gemini fold univariates - for (uint256 i = 0; i < logN - 1; i++) { - p.geminiFoldComms[i] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - } - - // Read gemini a evaluations - for (uint256 i = 0; i < logN; i++) { - p.geminiAEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - - for (uint256 i = 0; i < 4; i++) { - p.libraPolyEvals[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - - // Shplonk - p.shplonkQ = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - // KZG - p.kzgQuotient = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - } -} - -library RelationsLib { - struct EllipticParams { - // Points - Fr x_1; - Fr y_1; - Fr x_2; - Fr y_2; - Fr y_3; - Fr x_3; - // push accumulators into memory - Fr x_double_identity; - } - - // Parameters used within the Memory Relation - // A struct is used to work around stack too deep. This relation has alot of variables - struct MemParams { - Fr memory_record_check; - Fr partial_record_check; - Fr next_gate_access_type; - Fr record_delta; - Fr index_delta; - Fr adjacent_values_match_if_adjacent_indices_match; - Fr adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation; - Fr access_check; - Fr next_gate_access_type_is_boolean; - Fr ROM_consistency_check_identity; - Fr RAM_consistency_check_identity; - Fr timestamp_delta; - Fr RAM_timestamp_check_identity; - Fr memory_identity; - Fr index_is_monotonically_increasing; - } - - // Parameters used within the Non-Native Field Relation - // A struct is used to work around stack too deep. This relation has alot of variables - struct NnfParams { - Fr limb_subproduct; - Fr non_native_field_gate_1; - Fr non_native_field_gate_2; - Fr non_native_field_gate_3; - Fr limb_accumulator_1; - Fr limb_accumulator_2; - Fr nnf_identity; - } - - struct PoseidonExternalParams { - Fr s1; - Fr s2; - Fr s3; - Fr s4; - Fr u1; - Fr u2; - Fr u3; - Fr u4; - Fr t0; - Fr t1; - Fr t2; - Fr t3; - Fr v1; - Fr v2; - Fr v3; - Fr v4; - Fr q_pos_by_scaling; - } - - struct PoseidonInternalParams { - Fr u1; - Fr u2; - Fr u3; - Fr u4; - Fr u_sum; - Fr v1; - Fr v2; - Fr v3; - Fr v4; - Fr s1; - Fr q_pos_by_scaling; - } - - Fr internal constant GRUMPKIN_CURVE_B_PARAMETER_NEGATED = Fr.wrap(17); // -(-17) - uint256 internal constant NEG_HALF_MODULO_P = 0x183227397098d014dc2822db40c0ac2e9419f4243cdcb848a1f0fac9f8000000; - - // Constants for the Non-native Field relation - Fr internal constant LIMB_SIZE = Fr.wrap(uint256(1) << 68); - Fr internal constant SUBLIMB_SHIFT = Fr.wrap(uint256(1) << 14); - - function accumulateRelationEvaluations( - Fr[NUMBER_OF_ENTITIES] memory purportedEvaluations, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges, - Fr powPartialEval - ) internal pure returns (Fr accumulator) { - Fr[NUMBER_OF_SUBRELATIONS] memory evaluations; - - // Accumulate all relations in Ultra Honk - each with varying number of subrelations - accumulateArithmeticRelation(purportedEvaluations, evaluations, powPartialEval); - accumulatePermutationRelation(purportedEvaluations, rp, evaluations, powPartialEval); - accumulateLogDerivativeLookupRelation(purportedEvaluations, rp, evaluations, powPartialEval); - accumulateDeltaRangeRelation(purportedEvaluations, evaluations, powPartialEval); - accumulateEllipticRelation(purportedEvaluations, evaluations, powPartialEval); - accumulateMemoryRelation(purportedEvaluations, rp, evaluations, powPartialEval); - accumulateNnfRelation(purportedEvaluations, evaluations, powPartialEval); - accumulatePoseidonExternalRelation(purportedEvaluations, evaluations, powPartialEval); - accumulatePoseidonInternalRelation(purportedEvaluations, evaluations, powPartialEval); - - // batch the subrelations with the precomputed alpha powers to obtain the full honk relation - accumulator = scaleAndBatchSubrelations(evaluations, subrelationChallenges); - } - - /** - * Aesthetic helper function that is used to index by enum into proof.sumcheckEvaluations, it avoids - * the relation checking code being cluttered with uint256 type casting, which is often a different colour in code - * editors, and thus is noisy. - */ - function wire(Fr[NUMBER_OF_ENTITIES] memory p, WIRE _wire) internal pure returns (Fr) { - return p[uint256(_wire)]; - } - - /** - * Ultra Arithmetic Relation - * - */ - function accumulateArithmeticRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - // Relation 0 - Fr q_arith = wire(p, WIRE.Q_ARITH); - { - Fr neg_half = Fr.wrap(NEG_HALF_MODULO_P); - - Fr accum = (q_arith - Fr.wrap(3)) * (wire(p, WIRE.Q_M) * wire(p, WIRE.W_R) * wire(p, WIRE.W_L)) * neg_half; - accum = accum + (wire(p, WIRE.Q_L) * wire(p, WIRE.W_L)) + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_R)) - + (wire(p, WIRE.Q_O) * wire(p, WIRE.W_O)) + (wire(p, WIRE.Q_4) * wire(p, WIRE.W_4)) + wire(p, WIRE.Q_C); - accum = accum + (q_arith - ONE) * wire(p, WIRE.W_4_SHIFT); - accum = accum * q_arith; - accum = accum * domainSep; - evals[0] = accum; - } - - // Relation 1 - { - Fr accum = wire(p, WIRE.W_L) + wire(p, WIRE.W_4) - wire(p, WIRE.W_L_SHIFT) + wire(p, WIRE.Q_M); - accum = accum * (q_arith - Fr.wrap(2)); - accum = accum * (q_arith - ONE); - accum = accum * q_arith; - accum = accum * domainSep; - evals[1] = accum; - } - } - - function accumulatePermutationRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - Fr grand_product_numerator; - Fr grand_product_denominator; - - { - Fr num = wire(p, WIRE.W_L) + wire(p, WIRE.ID_1) * rp.beta + rp.gamma; - num = num * (wire(p, WIRE.W_R) + wire(p, WIRE.ID_2) * rp.beta + rp.gamma); - num = num * (wire(p, WIRE.W_O) + wire(p, WIRE.ID_3) * rp.beta + rp.gamma); - num = num * (wire(p, WIRE.W_4) + wire(p, WIRE.ID_4) * rp.beta + rp.gamma); - - grand_product_numerator = num; - } - { - Fr den = wire(p, WIRE.W_L) + wire(p, WIRE.SIGMA_1) * rp.beta + rp.gamma; - den = den * (wire(p, WIRE.W_R) + wire(p, WIRE.SIGMA_2) * rp.beta + rp.gamma); - den = den * (wire(p, WIRE.W_O) + wire(p, WIRE.SIGMA_3) * rp.beta + rp.gamma); - den = den * (wire(p, WIRE.W_4) + wire(p, WIRE.SIGMA_4) * rp.beta + rp.gamma); - - grand_product_denominator = den; - } - - // Contribution 2 - { - Fr acc = (wire(p, WIRE.Z_PERM) + wire(p, WIRE.LAGRANGE_FIRST)) * grand_product_numerator; - - acc = acc - - ((wire(p, WIRE.Z_PERM_SHIFT) + (wire(p, WIRE.LAGRANGE_LAST) * rp.publicInputsDelta)) - * grand_product_denominator); - acc = acc * domainSep; - evals[2] = acc; - } - - // Contribution 3 - { - Fr acc = (wire(p, WIRE.LAGRANGE_LAST) * wire(p, WIRE.Z_PERM_SHIFT)) * domainSep; - evals[3] = acc; - } - } - - function accumulateLogDerivativeLookupRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - Fr table_term; - Fr lookup_term; - - // Calculate the write term (the table accumulation) - // table_term = table_1 + γ + table_2 * β + table_3 * β² + table_4 * β³ - { - Fr beta_sqr = rp.beta * rp.beta; - table_term = wire(p, WIRE.TABLE_1) + rp.gamma + (wire(p, WIRE.TABLE_2) * rp.beta) - + (wire(p, WIRE.TABLE_3) * beta_sqr) + (wire(p, WIRE.TABLE_4) * beta_sqr * rp.beta); - } - - // Calculate the read term - // lookup_term = derived_entry_1 + γ + derived_entry_2 * β + derived_entry_3 * β² + q_index * β³ - { - Fr beta_sqr = rp.beta * rp.beta; - Fr derived_entry_1 = wire(p, WIRE.W_L) + rp.gamma + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_L_SHIFT)); - Fr derived_entry_2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_M) * wire(p, WIRE.W_R_SHIFT); - Fr derived_entry_3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_C) * wire(p, WIRE.W_O_SHIFT); - - lookup_term = derived_entry_1 + (derived_entry_2 * rp.beta) + (derived_entry_3 * beta_sqr) - + (wire(p, WIRE.Q_O) * beta_sqr * rp.beta); - } - - Fr lookup_inverse = wire(p, WIRE.LOOKUP_INVERSES) * table_term; - Fr table_inverse = wire(p, WIRE.LOOKUP_INVERSES) * lookup_term; - - Fr inverse_exists_xor = wire(p, WIRE.LOOKUP_READ_TAGS) + wire(p, WIRE.Q_LOOKUP) - - (wire(p, WIRE.LOOKUP_READ_TAGS) * wire(p, WIRE.Q_LOOKUP)); - - // Inverse calculated correctly relation - Fr accumulatorNone = lookup_term * table_term * wire(p, WIRE.LOOKUP_INVERSES) - inverse_exists_xor; - accumulatorNone = accumulatorNone * domainSep; - - // Inverse - Fr accumulatorOne = wire(p, WIRE.Q_LOOKUP) * lookup_inverse - wire(p, WIRE.LOOKUP_READ_COUNTS) * table_inverse; - - Fr read_tag = wire(p, WIRE.LOOKUP_READ_TAGS); - - Fr read_tag_boolean_relation = read_tag * read_tag - read_tag; - - evals[4] = accumulatorNone; - evals[5] = accumulatorOne; - evals[6] = read_tag_boolean_relation * domainSep; - } - - function accumulateDeltaRangeRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - Fr minus_one = ZERO - ONE; - Fr minus_two = ZERO - Fr.wrap(2); - Fr minus_three = ZERO - Fr.wrap(3); - - // Compute wire differences - Fr delta_1 = wire(p, WIRE.W_R) - wire(p, WIRE.W_L); - Fr delta_2 = wire(p, WIRE.W_O) - wire(p, WIRE.W_R); - Fr delta_3 = wire(p, WIRE.W_4) - wire(p, WIRE.W_O); - Fr delta_4 = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_4); - - // Contribution 6 - { - Fr acc = delta_1; - acc = acc * (delta_1 + minus_one); - acc = acc * (delta_1 + minus_two); - acc = acc * (delta_1 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[7] = acc; - } - - // Contribution 7 - { - Fr acc = delta_2; - acc = acc * (delta_2 + minus_one); - acc = acc * (delta_2 + minus_two); - acc = acc * (delta_2 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[8] = acc; - } - - // Contribution 8 - { - Fr acc = delta_3; - acc = acc * (delta_3 + minus_one); - acc = acc * (delta_3 + minus_two); - acc = acc * (delta_3 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[9] = acc; - } - - // Contribution 9 - { - Fr acc = delta_4; - acc = acc * (delta_4 + minus_one); - acc = acc * (delta_4 + minus_two); - acc = acc * (delta_4 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[10] = acc; - } - } - - function accumulateEllipticRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - EllipticParams memory ep; - ep.x_1 = wire(p, WIRE.W_R); - ep.y_1 = wire(p, WIRE.W_O); - - ep.x_2 = wire(p, WIRE.W_L_SHIFT); - ep.y_2 = wire(p, WIRE.W_4_SHIFT); - ep.y_3 = wire(p, WIRE.W_O_SHIFT); - ep.x_3 = wire(p, WIRE.W_R_SHIFT); - - Fr q_sign = wire(p, WIRE.Q_L); - Fr q_is_double = wire(p, WIRE.Q_M); - - // Contribution 10 point addition, x-coordinate check - // q_elliptic * (x3 + x2 + x1)(x2 - x1)(x2 - x1) - y2^2 - y1^2 + 2(y2y1)*q_sign = 0 - Fr x_diff = (ep.x_2 - ep.x_1); - Fr y1_sqr = (ep.y_1 * ep.y_1); - { - // Move to top - Fr partialEval = domainSep; - - Fr y2_sqr = (ep.y_2 * ep.y_2); - Fr y1y2 = ep.y_1 * ep.y_2 * q_sign; - Fr x_add_identity = (ep.x_3 + ep.x_2 + ep.x_1); - x_add_identity = x_add_identity * x_diff * x_diff; - x_add_identity = x_add_identity - y2_sqr - y1_sqr + y1y2 + y1y2; - - evals[11] = x_add_identity * partialEval * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); - } - - // Contribution 11 point addition, x-coordinate check - // q_elliptic * (q_sign * y1 + y3)(x2 - x1) + (x3 - x1)(y2 - q_sign * y1) = 0 - { - Fr y1_plus_y3 = ep.y_1 + ep.y_3; - Fr y_diff = ep.y_2 * q_sign - ep.y_1; - Fr y_add_identity = y1_plus_y3 * x_diff + (ep.x_3 - ep.x_1) * y_diff; - evals[12] = y_add_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); - } - - // Contribution 10 point doubling, x-coordinate check - // (x3 + x1 + x1) (4y1*y1) - 9 * x1 * x1 * x1 * x1 = 0 - // N.B. we're using the equivalence x1*x1*x1 === y1*y1 - curve_b to reduce degree by 1 - { - Fr x_pow_4 = (y1_sqr + GRUMPKIN_CURVE_B_PARAMETER_NEGATED) * ep.x_1; - Fr y1_sqr_mul_4 = y1_sqr + y1_sqr; - y1_sqr_mul_4 = y1_sqr_mul_4 + y1_sqr_mul_4; - Fr x1_pow_4_mul_9 = x_pow_4 * Fr.wrap(9); - - // NOTE: pushed into memory (stack >:'( ) - ep.x_double_identity = (ep.x_3 + ep.x_1 + ep.x_1) * y1_sqr_mul_4 - x1_pow_4_mul_9; - - Fr acc = ep.x_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; - evals[11] = evals[11] + acc; - } - - // Contribution 11 point doubling, y-coordinate check - // (y1 + y1) (2y1) - (3 * x1 * x1)(x1 - x3) = 0 - { - Fr x1_sqr_mul_3 = (ep.x_1 + ep.x_1 + ep.x_1) * ep.x_1; - Fr y_double_identity = x1_sqr_mul_3 * (ep.x_1 - ep.x_3) - (ep.y_1 + ep.y_1) * (ep.y_1 + ep.y_3); - evals[12] = evals[12] + y_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; - } - } - - function accumulateMemoryRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - MemParams memory ap; - - // Compute eta powers locally - Fr eta_two = rp.eta * rp.eta; - Fr eta_three = eta_two * rp.eta; - - /** - * MEMORY - * - * A RAM memory record contains a tuple of the following fields: - * * i: `index` of memory cell being accessed - * * t: `timestamp` of memory cell being accessed (used for RAM, set to 0 for ROM) - * * v: `value` of memory cell being accessed - * * a: `access` type of record. read: 0 = read, 1 = write - * * r: `record` of memory cell. record = access + index * eta + timestamp * eta_two + value * eta_three - * - * A ROM memory record contains a tuple of the following fields: - * * i: `index` of memory cell being accessed - * * v: `value1` of memory cell being accessed (ROM tables can store up to 2 values per index) - * * v2:`value2` of memory cell being accessed (ROM tables can store up to 2 values per index) - * * r: `record` of memory cell. record = index * eta + value2 * eta_two + value1 * eta_three - * - * When performing a read/write access, the values of i, t, v, v2, a, r are stored in the following wires + - * selectors, depending on whether the gate is a RAM read/write or a ROM read - * - * | gate type | i | v2/t | v | a | r | - * | --------- | -- | ----- | -- | -- | -- | - * | ROM | w1 | w2 | w3 | -- | w4 | - * | RAM | w1 | w2 | w3 | qc | w4 | - * - * (for accesses where `index` is a circuit constant, it is assumed the circuit will apply a copy constraint on - * `w2` to fix its value) - * - * - */ - - /** - * Memory Record Check - * Partial degree: 1 - * Total degree: 4 - * - * A ROM/ROM access gate can be evaluated with the identity: - * - * qc + w1 \eta + w2 \eta_two + w3 \eta_three - w4 = 0 - * - * For ROM gates, qc = 0 - */ - ap.memory_record_check = wire(p, WIRE.W_O) * eta_three; - ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_R) * eta_two); - ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_L) * rp.eta); - ap.memory_record_check = ap.memory_record_check + wire(p, WIRE.Q_C); - ap.partial_record_check = ap.memory_record_check; // used in RAM consistency check; deg 1 or 4 - ap.memory_record_check = ap.memory_record_check - wire(p, WIRE.W_4); - - /** - * Contribution 13 & 14 - * ROM Consistency Check - * Partial degree: 1 - * Total degree: 4 - * - * For every ROM read, a set equivalence check is applied between the record witnesses, and a second set of - * records that are sorted. - * - * We apply the following checks for the sorted records: - * - * 1. w1, w2, w3 correctly map to 'index', 'v1, 'v2' for a given record value at w4 - * 2. index values for adjacent records are monotonically increasing - * 3. if, at gate i, index_i == index_{i + 1}, then value1_i == value1_{i + 1} and value2_i == value2_{i + 1} - * - */ - ap.index_delta = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_L); - ap.record_delta = wire(p, WIRE.W_4_SHIFT) - wire(p, WIRE.W_4); - - ap.index_is_monotonically_increasing = ap.index_delta * (ap.index_delta - Fr.wrap(1)); // deg 2 - - ap.adjacent_values_match_if_adjacent_indices_match = (ap.index_delta * MINUS_ONE + ONE) * ap.record_delta; // deg 2 - - evals[14] = ap.adjacent_values_match_if_adjacent_indices_match * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) - * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 - evals[15] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) - * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 - - ap.ROM_consistency_check_identity = ap.memory_record_check * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)); // deg 3 or 7 - - /** - * Contributions 15,16,17 - * RAM Consistency Check - * - * The 'access' type of the record is extracted with the expression `w_4 - ap.partial_record_check` - * (i.e. for an honest Prover `w1 * eta + w2 * eta^2 + w3 * eta^3 - w4 = access`. - * This is validated by requiring `access` to be boolean - * - * For two adjacent entries in the sorted list if _both_ - * A) index values match - * B) adjacent access value is 0 (i.e. next gate is a READ) - * then - * C) both values must match. - * The gate boolean check is - * (A && B) => C === !(A && B) || C === !A || !B || C - * - * N.B. it is the responsibility of the circuit writer to ensure that every RAM cell is initialized - * with a WRITE operation. - */ - Fr access_type = (wire(p, WIRE.W_4) - ap.partial_record_check); // will be 0 or 1 for honest Prover; deg 1 or 4 - ap.access_check = access_type * (access_type - Fr.wrap(1)); // check value is 0 or 1; deg 2 or 8 - - // reverse order we could re-use `ap.partial_record_check` 1 - ((w3' * eta + w2') * eta + w1') * eta - // deg 1 or 4 - ap.next_gate_access_type = wire(p, WIRE.W_O_SHIFT) * eta_three; - ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_R_SHIFT) * eta_two); - ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_L_SHIFT) * rp.eta); - ap.next_gate_access_type = wire(p, WIRE.W_4_SHIFT) - ap.next_gate_access_type; - - Fr value_delta = wire(p, WIRE.W_O_SHIFT) - wire(p, WIRE.W_O); - ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation = - (ap.index_delta * MINUS_ONE + ONE) * value_delta * (ap.next_gate_access_type * MINUS_ONE + ONE); // deg 3 or 6 - - // We can't apply the RAM consistency check identity on the final entry in the sorted list (the wires in the - // next gate would make the identity fail). We need to validate that its 'access type' bool is correct. Can't - // do with an arithmetic gate because of the `eta` factors. We need to check that the *next* gate's access - // type is correct, to cover this edge case - // deg 2 or 4 - ap.next_gate_access_type_is_boolean = - ap.next_gate_access_type * ap.next_gate_access_type - ap.next_gate_access_type; - - // Putting it all together... - evals[16] = ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation - * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 or 8 - evals[17] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 - evals[18] = ap.next_gate_access_type_is_boolean * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 6 - - ap.RAM_consistency_check_identity = ap.access_check * (wire(p, WIRE.Q_O)); // deg 3 or 9 - - /** - * RAM Timestamp Consistency Check - * - * | w1 | w2 | w3 | w4 | - * | index | timestamp | timestamp_check | -- | - * - * Let delta_index = index_{i + 1} - index_{i} - * - * Iff delta_index == 0, timestamp_check = timestamp_{i + 1} - timestamp_i - * Else timestamp_check = 0 - */ - ap.timestamp_delta = wire(p, WIRE.W_R_SHIFT) - wire(p, WIRE.W_R); - ap.RAM_timestamp_check_identity = (ap.index_delta * MINUS_ONE + ONE) * ap.timestamp_delta - wire(p, WIRE.W_O); // deg 3 - - /** - * Complete Contribution 12 - * The complete RAM/ROM memory identity - * Partial degree: - */ - ap.memory_identity = ap.ROM_consistency_check_identity; // deg 3 or 6 - ap.memory_identity = - ap.memory_identity + ap.RAM_timestamp_check_identity * (wire(p, WIRE.Q_4) * wire(p, WIRE.Q_L)); // deg 4 - ap.memory_identity = ap.memory_identity + ap.memory_record_check * (wire(p, WIRE.Q_M) * wire(p, WIRE.Q_L)); // deg 3 or 6 - ap.memory_identity = ap.memory_identity + ap.RAM_consistency_check_identity; // deg 3 or 9 - - // (deg 3 or 9) + (deg 4) + (deg 3) - ap.memory_identity = ap.memory_identity * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 10 - evals[13] = ap.memory_identity; - } - - function accumulateNnfRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - NnfParams memory ap; - - /** - * Contribution 12 - * Non native field arithmetic gate 2 - * deg 4 - * - * _ _ - * / _ _ _ 14 \ - * q_2 . q_4 | (w_1 . w_2) + (w_1 . w_2) + (w_1 . w_4 + w_2 . w_3 - w_3) . 2 - w_3 - w_4 | - * \_ _/ - * - * - */ - ap.limb_subproduct = wire(p, WIRE.W_L) * wire(p, WIRE.W_R_SHIFT) + wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R); - ap.non_native_field_gate_2 = - (wire(p, WIRE.W_L) * wire(p, WIRE.W_4) + wire(p, WIRE.W_R) * wire(p, WIRE.W_O) - wire(p, WIRE.W_O_SHIFT)); - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * LIMB_SIZE; - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 - wire(p, WIRE.W_4_SHIFT); - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 + ap.limb_subproduct; - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * wire(p, WIRE.Q_4); - - ap.limb_subproduct = ap.limb_subproduct * LIMB_SIZE; - ap.limb_subproduct = ap.limb_subproduct + (wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R_SHIFT)); - ap.non_native_field_gate_1 = ap.limb_subproduct; - ap.non_native_field_gate_1 = ap.non_native_field_gate_1 - (wire(p, WIRE.W_O) + wire(p, WIRE.W_4)); - ap.non_native_field_gate_1 = ap.non_native_field_gate_1 * wire(p, WIRE.Q_O); - - ap.non_native_field_gate_3 = ap.limb_subproduct; - ap.non_native_field_gate_3 = ap.non_native_field_gate_3 + wire(p, WIRE.W_4); - ap.non_native_field_gate_3 = ap.non_native_field_gate_3 - (wire(p, WIRE.W_O_SHIFT) + wire(p, WIRE.W_4_SHIFT)); - ap.non_native_field_gate_3 = ap.non_native_field_gate_3 * wire(p, WIRE.Q_M); - - Fr non_native_field_identity = - ap.non_native_field_gate_1 + ap.non_native_field_gate_2 + ap.non_native_field_gate_3; - non_native_field_identity = non_native_field_identity * wire(p, WIRE.Q_R); - - // ((((w2' * 2^14 + w1') * 2^14 + w3) * 2^14 + w2) * 2^14 + w1 - w4) * qm - // deg 2 - ap.limb_accumulator_1 = wire(p, WIRE.W_R_SHIFT) * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L_SHIFT); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_O); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_R); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L); - ap.limb_accumulator_1 = ap.limb_accumulator_1 - wire(p, WIRE.W_4); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * wire(p, WIRE.Q_4); - - // ((((w3' * 2^14 + w2') * 2^14 + w1') * 2^14 + w4) * 2^14 + w3 - w4') * qm - // deg 2 - ap.limb_accumulator_2 = wire(p, WIRE.W_O_SHIFT) * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_R_SHIFT); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_L_SHIFT); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_4); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_O); - ap.limb_accumulator_2 = ap.limb_accumulator_2 - wire(p, WIRE.W_4_SHIFT); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * wire(p, WIRE.Q_M); - - Fr limb_accumulator_identity = ap.limb_accumulator_1 + ap.limb_accumulator_2; - limb_accumulator_identity = limb_accumulator_identity * wire(p, WIRE.Q_O); // deg 3 - - ap.nnf_identity = non_native_field_identity + limb_accumulator_identity; - ap.nnf_identity = ap.nnf_identity * (wire(p, WIRE.Q_NNF) * domainSep); - evals[19] = ap.nnf_identity; - } - - function accumulatePoseidonExternalRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - PoseidonExternalParams memory ep; - - ep.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); - ep.s2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_R); - ep.s3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_O); - ep.s4 = wire(p, WIRE.W_4) + wire(p, WIRE.Q_4); - - ep.u1 = ep.s1 * ep.s1 * ep.s1 * ep.s1 * ep.s1; - ep.u2 = ep.s2 * ep.s2 * ep.s2 * ep.s2 * ep.s2; - ep.u3 = ep.s3 * ep.s3 * ep.s3 * ep.s3 * ep.s3; - ep.u4 = ep.s4 * ep.s4 * ep.s4 * ep.s4 * ep.s4; - // matrix mul v = M_E * u with 14 additions - ep.t0 = ep.u1 + ep.u2; // u_1 + u_2 - ep.t1 = ep.u3 + ep.u4; // u_3 + u_4 - ep.t2 = ep.u2 + ep.u2 + ep.t1; // 2u_2 - // ep.t2 += ep.t1; // 2u_2 + u_3 + u_4 - ep.t3 = ep.u4 + ep.u4 + ep.t0; // 2u_4 - // ep.t3 += ep.t0; // u_1 + u_2 + 2u_4 - ep.v4 = ep.t1 + ep.t1; - ep.v4 = ep.v4 + ep.v4 + ep.t3; - // ep.v4 += ep.t3; // u_1 + u_2 + 4u_3 + 6u_4 - ep.v2 = ep.t0 + ep.t0; - ep.v2 = ep.v2 + ep.v2 + ep.t2; - // ep.v2 += ep.t2; // 4u_1 + 6u_2 + u_3 + u_4 - ep.v1 = ep.t3 + ep.v2; // 5u_1 + 7u_2 + u_3 + 3u_4 - ep.v3 = ep.t2 + ep.v4; // u_1 + 3u_2 + 5u_3 + 7u_4 - - ep.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_EXTERNAL) * domainSep; - evals[20] = evals[20] + ep.q_pos_by_scaling * (ep.v1 - wire(p, WIRE.W_L_SHIFT)); - - evals[21] = evals[21] + ep.q_pos_by_scaling * (ep.v2 - wire(p, WIRE.W_R_SHIFT)); - - evals[22] = evals[22] + ep.q_pos_by_scaling * (ep.v3 - wire(p, WIRE.W_O_SHIFT)); - - evals[23] = evals[23] + ep.q_pos_by_scaling * (ep.v4 - wire(p, WIRE.W_4_SHIFT)); - } - - function accumulatePoseidonInternalRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - PoseidonInternalParams memory ip; - - Fr[4] memory INTERNAL_MATRIX_DIAGONAL = [ - FrLib.from(0x10dc6e9c006ea38b04b1e03b4bd9490c0d03f98929ca1d7fb56821fd19d3b6e7), - FrLib.from(0x0c28145b6a44df3e0149b3d0a30b3bb599df9756d4dd9b84a86b38cfb45a740b), - FrLib.from(0x00544b8338791518b2c7645a50392798b21f75bb60e3596170067d00141cac15), - FrLib.from(0x222c01175718386f2e2e82eb122789e352e105a3b8fa852613bc534433ee428b) - ]; - - // add round constants - ip.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); - - // apply s-box round - ip.u1 = ip.s1 * ip.s1 * ip.s1 * ip.s1 * ip.s1; - ip.u2 = wire(p, WIRE.W_R); - ip.u3 = wire(p, WIRE.W_O); - ip.u4 = wire(p, WIRE.W_4); - - // matrix mul with v = M_I * u 4 muls and 7 additions - ip.u_sum = ip.u1 + ip.u2 + ip.u3 + ip.u4; - - ip.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_INTERNAL) * domainSep; - - ip.v1 = ip.u1 * INTERNAL_MATRIX_DIAGONAL[0] + ip.u_sum; - evals[24] = evals[24] + ip.q_pos_by_scaling * (ip.v1 - wire(p, WIRE.W_L_SHIFT)); - - ip.v2 = ip.u2 * INTERNAL_MATRIX_DIAGONAL[1] + ip.u_sum; - evals[25] = evals[25] + ip.q_pos_by_scaling * (ip.v2 - wire(p, WIRE.W_R_SHIFT)); - - ip.v3 = ip.u3 * INTERNAL_MATRIX_DIAGONAL[2] + ip.u_sum; - evals[26] = evals[26] + ip.q_pos_by_scaling * (ip.v3 - wire(p, WIRE.W_O_SHIFT)); - - ip.v4 = ip.u4 * INTERNAL_MATRIX_DIAGONAL[3] + ip.u_sum; - evals[27] = evals[27] + ip.q_pos_by_scaling * (ip.v4 - wire(p, WIRE.W_4_SHIFT)); - } - - // Batch subrelation evaluations using precomputed powers of alpha - // First subrelation is implicitly scaled by 1, subsequent ones use powers from the subrelationChallenges array - function scaleAndBatchSubrelations( - Fr[NUMBER_OF_SUBRELATIONS] memory evaluations, - Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges - ) internal pure returns (Fr accumulator) { - accumulator = evaluations[0]; - - for (uint256 i = 1; i < NUMBER_OF_SUBRELATIONS; ++i) { - accumulator = accumulator + evaluations[i] * subrelationChallenges[i - 1]; - } - } -} - -library CommitmentSchemeLib { - using FrLib for Fr; - - // Avoid stack too deep - struct ShpleminiIntermediates { - Fr unshiftedScalar; - Fr shiftedScalar; - Fr unshiftedScalarNeg; - Fr shiftedScalarNeg; - // Scalar to be multiplied by [1]₁ - Fr constantTermAccumulator; - // Accumulator for powers of rho - Fr batchingChallenge; - // Linear combination of multilinear (sumcheck) evaluations and powers of rho - Fr batchedEvaluation; - Fr[4] denominators; - Fr[4] batchingScalars; - // 1/(z - r^{2^i}) for i = 0, ..., logSize, dynamically updated - Fr posInvertedDenominator; - // 1/(z + r^{2^i}) for i = 0, ..., logSize, dynamically updated - Fr negInvertedDenominator; - // ν^{2i} * 1/(z - r^{2^i}) - Fr scalingFactorPos; - // ν^{2i+1} * 1/(z + r^{2^i}) - Fr scalingFactorNeg; - // Fold_i(r^{2^i}) reconstructed by Verifier - Fr[] foldPosEvaluations; - } - - // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., m-1 - function computeFoldPosEvaluations( - Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckUChallenges, - Fr batchedEvalAccumulator, - Fr[CONST_PROOF_SIZE_LOG_N] memory geminiEvaluations, - Fr[] memory geminiEvalChallengePowers, - uint256 logSize - ) internal view returns (Fr[] memory) { - Fr[] memory foldPosEvaluations = new Fr[](logSize); - for (uint256 i = logSize; i > 0; --i) { - Fr challengePower = geminiEvalChallengePowers[i - 1]; - Fr u = sumcheckUChallenges[i - 1]; - - Fr batchedEvalRoundAcc = - ((challengePower * batchedEvalAccumulator * Fr.wrap(2)) - geminiEvaluations[i - 1] - * (challengePower * (ONE - u) - u)); - // Divide by the denominator - batchedEvalRoundAcc = batchedEvalRoundAcc * (challengePower * (ONE - u) + u).invert(); - - batchedEvalAccumulator = batchedEvalRoundAcc; - foldPosEvaluations[i - 1] = batchedEvalRoundAcc; - } - return foldPosEvaluations; - } - - function computeSquares(Fr r, uint256 logN) internal pure returns (Fr[] memory) { - Fr[] memory squares = new Fr[](logN); - squares[0] = r; - for (uint256 i = 1; i < logN; ++i) { - squares[i] = squares[i - 1].sqr(); - } - return squares; - } -} - -uint256 constant Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583; // EC group order. F_q - -// Fr utility - -function bytesToFr(bytes calldata proofSection) pure returns (Fr scalar) { - scalar = FrLib.fromBytes32(bytes32(proofSection)); -} - -// EC Point utilities -function bytesToG1Point(bytes calldata proofSection) pure returns (Honk.G1Point memory point) { - uint256 x = uint256(bytes32(proofSection[0x00:0x20])); - uint256 y = uint256(bytes32(proofSection[0x20:0x40])); - require(x < Q && y < Q, Errors.ValueGeGroupOrder()); - - // Reject the point at infinity (0,0). EVM precompiles silently treat (0,0) - // as the identity element, which could zero out commitments. - // On-curve validation (y² = x³ + 3) is handled by the ecAdd/ecMul precompiles - // per EIP-196, so we only need to catch this special case here. - require((x | y) != 0, Errors.PointAtInfinity()); - - point = Honk.G1Point({x: x, y: y}); -} - -function negateInplace(Honk.G1Point memory point) pure returns (Honk.G1Point memory) { - // When y == 0 (order-2 point), negation is the same point. Q - 0 = Q which is >= Q. - if (point.y != 0) { - point.y = Q - point.y; - } - return point; -} - -/** - * Convert the pairing points to G1 points. - * - * The pairing points are serialised as an array of 2 limbs representing two points - * (P0 and P1, used for lhs and rhs of pairing operation). - * - * There are 2 limbs (lo, hi) for each coordinate, so 4 limbs per point, 8 total. - * Layout: [P0.x_lo, P0.x_hi, P0.y_lo, P0.y_hi, P1.x_lo, P1.x_hi, P1.y_lo, P1.y_hi] - * - * @param pairingPoints The pairing points to convert. - * @return lhs P0 point - * @return rhs P1 point - */ -function convertPairingPointsToG1(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) - pure - returns (Honk.G1Point memory lhs, Honk.G1Point memory rhs) -{ - // P0 (lhs): x = lo | (hi << 136) - uint256 lhsX = Fr.unwrap(pairingPoints[0]); - lhsX |= Fr.unwrap(pairingPoints[1]) << 136; - - uint256 lhsY = Fr.unwrap(pairingPoints[2]); - lhsY |= Fr.unwrap(pairingPoints[3]) << 136; - - // P1 (rhs): x = lo | (hi << 136) - uint256 rhsX = Fr.unwrap(pairingPoints[4]); - rhsX |= Fr.unwrap(pairingPoints[5]) << 136; - - uint256 rhsY = Fr.unwrap(pairingPoints[6]); - rhsY |= Fr.unwrap(pairingPoints[7]) << 136; - - // Reconstructed coordinates must be < Q to prevent malleability. - // Without this, two different limb encodings could map to the same curve point - // (via mulmod reduction in on-curve checks) but produce different transcript hashes. - require(lhsX < Q && lhsY < Q && rhsX < Q && rhsY < Q, Errors.ValueGeGroupOrder()); - - lhs.x = lhsX; - lhs.y = lhsY; - rhs.x = rhsX; - rhs.y = rhsY; -} - -/** - * Hash the pairing inputs from the present verification context with those extracted from the public inputs. - * - * @param proofPairingPoints Pairing points from the proof - (public inputs). - * @param accLhs Accumulator point for the left side - result of shplemini. - * @param accRhs Accumulator point for the right side - result of shplemini. - * @return recursionSeparator The recursion separator - generated from hashing the above. - */ -function generateRecursionSeparator( - Fr[PAIRING_POINTS_SIZE] memory proofPairingPoints, - Honk.G1Point memory accLhs, - Honk.G1Point memory accRhs -) pure returns (Fr recursionSeparator) { - // hash the proof aggregated X - // hash the proof aggregated Y - // hash the accum X - // hash the accum Y - - (Honk.G1Point memory proofLhs, Honk.G1Point memory proofRhs) = convertPairingPointsToG1(proofPairingPoints); - - uint256[8] memory recursionSeparatorElements; - - // Proof points - recursionSeparatorElements[0] = proofLhs.x; - recursionSeparatorElements[1] = proofLhs.y; - recursionSeparatorElements[2] = proofRhs.x; - recursionSeparatorElements[3] = proofRhs.y; - - // Accumulator points - recursionSeparatorElements[4] = accLhs.x; - recursionSeparatorElements[5] = accLhs.y; - recursionSeparatorElements[6] = accRhs.x; - recursionSeparatorElements[7] = accRhs.y; - - recursionSeparator = FrLib.from(uint256(keccak256(abi.encodePacked(recursionSeparatorElements))) % P); -} - -/** - * G1 Mul with Separator - * Using the ecAdd and ecMul precompiles - * - * @param basePoint The point to multiply. - * @param other The other point to add. - * @param recursionSeperator The separator to use for the multiplication. - * @return `(recursionSeperator * basePoint) + other`. - */ -function mulWithSeperator(Honk.G1Point memory basePoint, Honk.G1Point memory other, Fr recursionSeperator) - view - returns (Honk.G1Point memory) -{ - Honk.G1Point memory result; - - result = ecMul(recursionSeperator, basePoint); - result = ecAdd(result, other); - - return result; -} - -/** - * G1 Mul - * Takes a Fr value and a G1 point and uses the ecMul precompile to return the result. - * - * @param value The value to multiply the point by. - * @param point The point to multiply. - * @return result The result of the multiplication. - */ -function ecMul(Fr value, Honk.G1Point memory point) view returns (Honk.G1Point memory) { - Honk.G1Point memory result; - - assembly ("memory-safe") { - let free := mload(0x40) - // Write the point into memory (two 32 byte words) - // Memory layout: - // Address | value - // free | point.x - // free + 0x20| point.y - mstore(free, mload(point)) - mstore(add(free, 0x20), mload(add(point, 0x20))) - // Write the scalar into memory (one 32 byte word) - // Memory layout: - // Address | value - // free + 0x40| value - mstore(add(free, 0x40), value) - - // Call the ecMul precompile, it takes in the following - // [point.x, point.y, scalar], and returns the result back into the free memory location. - let success := staticcall(gas(), 0x07, free, 0x60, free, 0x40) - if iszero(success) { - revert(0, 0) - } - // Copy the result of the multiplication back into the result memory location. - // Memory layout: - // Address | value - // result | result.x - // result + 0x20| result.y - mstore(result, mload(free)) - mstore(add(result, 0x20), mload(add(free, 0x20))) - - mstore(0x40, add(free, 0x60)) - } - - return result; -} - -/** - * G1 Add - * Takes two G1 points and uses the ecAdd precompile to return the result. - * - * @param lhs The left hand side of the addition. - * @param rhs The right hand side of the addition. - * @return result The result of the addition. - */ -function ecAdd(Honk.G1Point memory lhs, Honk.G1Point memory rhs) view returns (Honk.G1Point memory) { - Honk.G1Point memory result; - - assembly ("memory-safe") { - let free := mload(0x40) - // Write lhs into memory (two 32 byte words) - // Memory layout: - // Address | value - // free | lhs.x - // free + 0x20| lhs.y - mstore(free, mload(lhs)) - mstore(add(free, 0x20), mload(add(lhs, 0x20))) - - // Write rhs into memory (two 32 byte words) - // Memory layout: - // Address | value - // free + 0x40| rhs.x - // free + 0x60| rhs.y - mstore(add(free, 0x40), mload(rhs)) - mstore(add(free, 0x60), mload(add(rhs, 0x20))) - - // Call the ecAdd precompile, it takes in the following - // [lhs.x, lhs.y, rhs.x, rhs.y], and returns their addition back into the free memory location. - let success := staticcall(gas(), 0x06, free, 0x80, free, 0x40) - if iszero(success) { revert(0, 0) } - - // Copy the result of the addition back into the result memory location. - // Memory layout: - // Address | value - // result | result.x - // result + 0x20| result.y - mstore(result, mload(free)) - mstore(add(result, 0x20), mload(add(free, 0x20))) - - mstore(0x40, add(free, 0x80)) - } - - return result; -} - -function rejectPointAtInfinity(Honk.G1Point memory point) pure { - require((point.x | point.y) != 0, Errors.PointAtInfinity()); -} - -/** - * Check if pairing point limbs are all zero (default/infinity). - * Default pairing points indicate no recursive verification occurred. - */ -function arePairingPointsDefault(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) pure returns (bool) { - uint256 acc = 0; - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - acc |= Fr.unwrap(pairingPoints[i]); - } - return acc == 0; -} - -function pairing(Honk.G1Point memory rhs, Honk.G1Point memory lhs) view returns (bool decodedResult) { - bytes memory input = abi.encodePacked( - rhs.x, - rhs.y, - // Fixed G2 point - uint256(0x198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c2), - uint256(0x1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed), - uint256(0x090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b), - uint256(0x12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa), - lhs.x, - lhs.y, - // G2 point from VK - uint256(0x260e01b251f6f1c7e7ff4e580791dee8ea51d87a358e038b4efe30fac09383c1), - uint256(0x0118c4d5b837bcc2bc89b5b398b5974e9f5944073b32078b7e231fec938883b0), - uint256(0x04fc6369f7110fe3d25156c1bb9a72859cf2a04641f99ba4ee413c80da6a5fe4), - uint256(0x22febda3c0c0632a56475b4214e5615e11e6dd3f96e6cea2854a87d4dacc5e55) - ); - - (bool success, bytes memory result) = address(0x08).staticcall(input); - decodedResult = success && abi.decode(result, (bool)); -} - -abstract contract BaseZKHonkVerifier is IVerifier { - using FrLib for Fr; - - struct PairingInputs { - Honk.G1Point P_0; - Honk.G1Point P_1; - } - - struct SmallSubgroupIpaIntermediates { - Fr[SUBGROUP_SIZE] challengePolyLagrange; - Fr challengePolyEval; - Fr lagrangeFirst; - Fr lagrangeLast; - Fr rootPower; - Fr[SUBGROUP_SIZE] denominators; // this has to disappear - Fr diff; - } - - // Constants for proof length calculation (matching UltraKeccakZKFlavor) - uint256 internal constant NUM_WITNESS_ENTITIES = 8 + NUM_MASKING_POLYNOMIALS; - uint256 internal constant NUM_ELEMENTS_COMM = 2; // uint256 elements for curve points - uint256 internal constant NUM_ELEMENTS_FR = 1; // uint256 elements for field elements - uint256 internal constant NUM_LIBRA_EVALUATIONS = 4; // libra evaluations - - uint256 internal constant LIBRA_COMMITMENTS = 3; - uint256 internal constant LIBRA_EVALUATIONS = 4; - uint256 internal constant LIBRA_UNIVARIATES_LENGTH = 9; - - uint256 internal constant SHIFTED_COMMITMENTS_START = 30; - uint256 internal constant PERMUTATION_ARGUMENT_VALUE_SEPARATOR = 1 << 28; - - uint256 internal immutable $N; - uint256 internal immutable $LOG_N; - uint256 internal immutable $VK_HASH; - uint256 internal immutable $NUM_PUBLIC_INPUTS; - uint256 internal immutable $MSMSize; - - constructor(uint256 _N, uint256 _logN, uint256 _vkHash, uint256 _numPublicInputs) { - $N = _N; - $LOG_N = _logN; - $VK_HASH = _vkHash; - $NUM_PUBLIC_INPUTS = _numPublicInputs; - $MSMSize = NUMBER_UNSHIFTED_ZK + _logN + LIBRA_COMMITMENTS + 2; - } - - function verify(bytes calldata proof, bytes32[] calldata publicInputs) - public - view - override - returns (bool verified) - { - // Calculate expected proof size based on $LOG_N - uint256 expectedProofSize = calculateProofSize($LOG_N); - - // Check the received proof is the expected size where each field element is 32 bytes - require( - proof.length == expectedProofSize, Errors.ProofLengthWrongWithLogN($LOG_N, proof.length, expectedProofSize) - ); - - Honk.VerificationKey memory vk = loadVerificationKey(); - Honk.ZKProof memory p = ZKTranscriptLib.loadProof(proof, $LOG_N); - - require(publicInputs.length == vk.publicInputsSize - PAIRING_POINTS_SIZE, Errors.PublicInputsLengthWrong()); - - // Generate the fiat shamir challenges for the whole protocol - ZKTranscript memory t = - ZKTranscriptLib.generateTranscript(p, publicInputs, $VK_HASH, $NUM_PUBLIC_INPUTS, $LOG_N); - - // Derive public input delta - t.relationParameters.publicInputsDelta = computePublicInputDelta( - publicInputs, - p.pairingPointObject, - t.relationParameters.beta, - t.relationParameters.gamma, /*pubInputsOffset=*/ - 1 - ); - - // Sumcheck - require(verifySumcheck(p, t), Errors.SumcheckFailed()); - require(verifyShplemini(p, vk, t), Errors.ShpleminiFailed()); - - verified = true; - } - - function computePublicInputDelta( - bytes32[] memory publicInputs, - Fr[PAIRING_POINTS_SIZE] memory pairingPointObject, - Fr beta, - Fr gamma, - uint256 offset - ) internal view returns (Fr publicInputDelta) { - Fr numerator = Fr.wrap(1); - Fr denominator = Fr.wrap(1); - - Fr numeratorAcc = gamma + (beta * FrLib.from(PERMUTATION_ARGUMENT_VALUE_SEPARATOR + offset)); - Fr denominatorAcc = gamma - (beta * FrLib.from(offset + 1)); - - { - for (uint256 i = 0; i < $NUM_PUBLIC_INPUTS - PAIRING_POINTS_SIZE; i++) { - Fr pubInput = FrLib.fromBytes32(publicInputs[i]); - - numerator = numerator * (numeratorAcc + pubInput); - denominator = denominator * (denominatorAcc + pubInput); - - numeratorAcc = numeratorAcc + beta; - denominatorAcc = denominatorAcc - beta; - } - - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - Fr pubInput = pairingPointObject[i]; - - numerator = numerator * (numeratorAcc + pubInput); - denominator = denominator * (denominatorAcc + pubInput); - - numeratorAcc = numeratorAcc + beta; - denominatorAcc = denominatorAcc - beta; - } - } - - // Fr delta = numerator / denominator; // TOOO: batch invert later? - publicInputDelta = FrLib.div(numerator, denominator); - } - - function verifySumcheck(Honk.ZKProof memory proof, ZKTranscript memory tp) internal view returns (bool verified) { - Fr roundTargetSum = tp.libraChallenge * proof.libraSum; // default 0 - Fr powPartialEvaluation = Fr.wrap(1); - - // We perform sumcheck reductions over log n rounds ( the multivariate degree ) - for (uint256 round; round < $LOG_N; ++round) { - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariate = proof.sumcheckUnivariates[round]; - Fr totalSum = roundUnivariate[0] + roundUnivariate[1]; - require(totalSum == roundTargetSum, Errors.SumcheckFailed()); - - Fr roundChallenge = tp.sumCheckUChallenges[round]; - - // Update the round target for the next rounf - roundTargetSum = computeNextTargetSum(roundUnivariate, roundChallenge); - powPartialEvaluation = - powPartialEvaluation * (Fr.wrap(1) + roundChallenge * (tp.gateChallenges[round] - Fr.wrap(1))); - } - - // Last round - // For ZK flavors: sumcheckEvaluations has 42 elements - // Index 0 is gemini_masking_poly, indices 1-41 are the regular entities used in relations - Fr[NUMBER_OF_ENTITIES] memory relationsEvaluations; - for (uint256 i = 0; i < NUMBER_OF_ENTITIES; i++) { - relationsEvaluations[i] = proof.sumcheckEvaluations[i + NUM_MASKING_POLYNOMIALS]; // Skip gemini_masking_poly at index 0 - } - Fr grandHonkRelationSum = RelationsLib.accumulateRelationEvaluations( - relationsEvaluations, tp.relationParameters, tp.alphas, powPartialEvaluation - ); - - Fr evaluation = Fr.wrap(1); - for (uint256 i = 2; i < $LOG_N; i++) { - evaluation = evaluation * tp.sumCheckUChallenges[i]; - } - - grandHonkRelationSum = - grandHonkRelationSum * (Fr.wrap(1) - evaluation) + proof.libraEvaluation * tp.libraChallenge; - verified = (grandHonkRelationSum == roundTargetSum); - } - - // Return the new target sum for the next sumcheck round - function computeNextTargetSum(Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariates, Fr roundChallenge) - internal - view - returns (Fr targetSum) - { - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory BARYCENTRIC_LAGRANGE_DENOMINATORS = [ - Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), - Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), - Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000000240), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), - Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), - Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80) - ]; - - // To compute the next target sum, we evaluate the given univariate at a point u (challenge). - - // Performing Barycentric evaluations - // Compute B(x) - Fr numeratorValue = Fr.wrap(1); - for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { - numeratorValue = numeratorValue * (roundChallenge - Fr.wrap(i)); - } - - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory denominatorInverses; - for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { - denominatorInverses[i] = FrLib.invert(BARYCENTRIC_LAGRANGE_DENOMINATORS[i] * (roundChallenge - Fr.wrap(i))); - } - - for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { - targetSum = targetSum + roundUnivariates[i] * denominatorInverses[i]; - } - - // Scale the sum by the value of B(x) - targetSum = targetSum * numeratorValue; - } - - function verifyShplemini(Honk.ZKProof memory proof, Honk.VerificationKey memory vk, ZKTranscript memory tp) - internal - view - returns (bool verified) - { - CommitmentSchemeLib.ShpleminiIntermediates memory mem; // stack - - // - Compute vector (r, r², ... , r²⁽ⁿ⁻¹⁾), where n = log_circuit_size - Fr[] memory powers_of_evaluation_challenge = CommitmentSchemeLib.computeSquares(tp.geminiR, $LOG_N); - // Arrays hold values that will be linearly combined for the gemini and shplonk batch openings - Fr[] memory scalars = new Fr[]($MSMSize); - Honk.G1Point[] memory commitments = new Honk.G1Point[]($MSMSize); - - mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[0]).invert(); - mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[0]).invert(); - - mem.unshiftedScalar = mem.posInvertedDenominator + (tp.shplonkNu * mem.negInvertedDenominator); - mem.shiftedScalar = - tp.geminiR.invert() * (mem.posInvertedDenominator - (tp.shplonkNu * mem.negInvertedDenominator)); - - scalars[0] = Fr.wrap(1); - commitments[0] = proof.shplonkQ; - - /* Batch multivariate opening claims, shifted and unshifted - * The vector of scalars is populated as follows: - * \f[ - * \left( - * - \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), - * \ldots, - * - \rho^{i+k-1} \times \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), - * - \rho^{i+k} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right), - * \ldots, - * - \rho^{k+m-1} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right) - * \right) - * \f] - * - * The following vector is concatenated to the vector of commitments: - * \f[ - * f_0, \ldots, f_{m-1}, f_{\text{shift}, 0}, \ldots, f_{\text{shift}, k-1} - * \f] - * - * Simultaneously, the evaluation of the multilinear polynomial - * \f[ - * \sum \rho^i \cdot f_i + \sum \rho^{i+k} \cdot f_{\text{shift}, i} - * \f] - * at the challenge point \f$ (u_0,\ldots, u_{n-1}) \f$ is computed. - * - * This approach minimizes the number of iterations over the commitments to multilinear polynomials - * and eliminates the need to store the powers of \f$ \rho \f$. - */ - // For ZK flavors: evaluations array is [gemini_masking_poly, qm, qc, ql, qr, ...] - // Start batching challenge at 1, not rho, to match non-ZK pattern - mem.batchingChallenge = Fr.wrap(1); - mem.batchedEvaluation = Fr.wrap(0); - - mem.unshiftedScalarNeg = mem.unshiftedScalar.neg(); - mem.shiftedScalarNeg = mem.shiftedScalar.neg(); - - // Process all NUMBER_UNSHIFTED_ZK evaluations (includes gemini_masking_poly at index 0) - for (uint256 i = 1; i <= NUMBER_UNSHIFTED_ZK; ++i) { - scalars[i] = mem.unshiftedScalarNeg * mem.batchingChallenge; - mem.batchedEvaluation = mem.batchedEvaluation - + (proof.sumcheckEvaluations[i - NUM_MASKING_POLYNOMIALS] * mem.batchingChallenge); - mem.batchingChallenge = mem.batchingChallenge * tp.rho; - } - // g commitments are accumulated at r - // For each of the to be shifted commitments perform the shift in place by - // adding to the unshifted value. - // We do so, as the values are to be used in batchMul later, and as - // `a * c + b * c = (a + b) * c` this will allow us to reduce memory and compute. - // Applied to w1, w2, w3, w4 and zPerm - for (uint256 i = 0; i < NUMBER_TO_BE_SHIFTED; ++i) { - uint256 scalarOff = i + SHIFTED_COMMITMENTS_START; - uint256 evaluationOff = i + NUMBER_UNSHIFTED_ZK; - - scalars[scalarOff] = scalars[scalarOff] + (mem.shiftedScalarNeg * mem.batchingChallenge); - mem.batchedEvaluation = - mem.batchedEvaluation + (proof.sumcheckEvaluations[evaluationOff] * mem.batchingChallenge); - mem.batchingChallenge = mem.batchingChallenge * tp.rho; - } - - commitments[1] = proof.geminiMaskingPoly; - - commitments[2] = vk.qm; - commitments[3] = vk.qc; - commitments[4] = vk.ql; - commitments[5] = vk.qr; - commitments[6] = vk.qo; - commitments[7] = vk.q4; - commitments[8] = vk.qLookup; - commitments[9] = vk.qArith; - commitments[10] = vk.qDeltaRange; - commitments[11] = vk.qElliptic; - commitments[12] = vk.qMemory; - commitments[13] = vk.qNnf; - commitments[14] = vk.qPoseidon2External; - commitments[15] = vk.qPoseidon2Internal; - commitments[16] = vk.s1; - commitments[17] = vk.s2; - commitments[18] = vk.s3; - commitments[19] = vk.s4; - commitments[20] = vk.id1; - commitments[21] = vk.id2; - commitments[22] = vk.id3; - commitments[23] = vk.id4; - commitments[24] = vk.t1; - commitments[25] = vk.t2; - commitments[26] = vk.t3; - commitments[27] = vk.t4; - commitments[28] = vk.lagrangeFirst; - commitments[29] = vk.lagrangeLast; - - // Accumulate proof points - commitments[30] = proof.w1; - commitments[31] = proof.w2; - commitments[32] = proof.w3; - commitments[33] = proof.w4; - commitments[34] = proof.zPerm; - commitments[35] = proof.lookupInverses; - commitments[36] = proof.lookupReadCounts; - commitments[37] = proof.lookupReadTags; - - /* Batch gemini claims from the prover - * place the commitments to gemini aᵢ to the vector of commitments, compute the contributions from - * aᵢ(−r²ⁱ) for i=1, … , n−1 to the constant term accumulator, add corresponding scalars - * - * 1. Moves the vector - * \f[ - * \left( \text{com}(A_1), \text{com}(A_2), \ldots, \text{com}(A_{n-1}) \right) - * \f] - * to the 'commitments' vector. - * - * 2. Computes the scalars: - * \f[ - * \frac{\nu^{2}}{z + r^2}, \frac{\nu^3}{z + r^4}, \ldots, \frac{\nu^{n-1}}{z + r^{2^{n-1}}} - * \f] - * and places them into the 'scalars' vector. - * - * 3. Accumulates the summands of the constant term: - * \f[ - * \sum_{i=2}^{n-1} \frac{\nu^{i} \cdot A_i(-r^{2^i})}{z + r^{2^i}} - * \f] - * and adds them to the 'constant_term_accumulator'. - */ - - // Add contributions from A₀(r) and A₀(-r) to constant_term_accumulator: - // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., $LOG_N - 1 - Fr[] memory foldPosEvaluations = CommitmentSchemeLib.computeFoldPosEvaluations( - tp.sumCheckUChallenges, - mem.batchedEvaluation, - proof.geminiAEvaluations, - powers_of_evaluation_challenge, - $LOG_N - ); - - mem.constantTermAccumulator = foldPosEvaluations[0] * mem.posInvertedDenominator; - mem.constantTermAccumulator = - mem.constantTermAccumulator + (proof.geminiAEvaluations[0] * tp.shplonkNu * mem.negInvertedDenominator); - - mem.batchingChallenge = tp.shplonkNu.sqr(); - uint256 boundary = NUMBER_UNSHIFTED_ZK + 1; - - // Compute Shplonk constant term contributions from Aₗ(± r^{2ˡ}) for l = 1, ..., m-1; - // Compute scalar multipliers for each fold commitment - for (uint256 i = 0; i < $LOG_N - 1; ++i) { - bool dummy_round = i >= ($LOG_N - 1); - - if (!dummy_round) { - // Update inverted denominators - mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[i + 1]).invert(); - mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[i + 1]).invert(); - - // Compute the scalar multipliers for Aₗ(± r^{2ˡ}) and [Aₗ] - mem.scalingFactorPos = mem.batchingChallenge * mem.posInvertedDenominator; - mem.scalingFactorNeg = mem.batchingChallenge * tp.shplonkNu * mem.negInvertedDenominator; - scalars[boundary + i] = mem.scalingFactorNeg.neg() + mem.scalingFactorPos.neg(); - - // Accumulate the const term contribution given by - // v^{2l} * Aₗ(r^{2ˡ}) /(z-r^{2^l}) + v^{2l+1} * Aₗ(-r^{2ˡ}) /(z+ r^{2^l}) - Fr accumContribution = mem.scalingFactorNeg * proof.geminiAEvaluations[i + 1]; - accumContribution = accumContribution + mem.scalingFactorPos * foldPosEvaluations[i + 1]; - mem.constantTermAccumulator = mem.constantTermAccumulator + accumContribution; - } - // Update the running power of v - mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu * tp.shplonkNu; - - commitments[boundary + i] = proof.geminiFoldComms[i]; - } - - boundary += $LOG_N - 1; - - // Finalize the batch opening claim - mem.denominators[0] = Fr.wrap(1).div(tp.shplonkZ - tp.geminiR); - mem.denominators[1] = Fr.wrap(1).div(tp.shplonkZ - SUBGROUP_GENERATOR * tp.geminiR); - mem.denominators[2] = mem.denominators[0]; - mem.denominators[3] = mem.denominators[0]; - - for (uint256 i = 0; i < LIBRA_EVALUATIONS; i++) { - Fr scalingFactor = mem.denominators[i] * mem.batchingChallenge; - mem.batchingScalars[i] = scalingFactor.neg(); - mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu; - mem.constantTermAccumulator = mem.constantTermAccumulator + scalingFactor * proof.libraPolyEvals[i]; - } - scalars[boundary] = mem.batchingScalars[0]; - scalars[boundary + 1] = mem.batchingScalars[1] + mem.batchingScalars[2]; - scalars[boundary + 2] = mem.batchingScalars[3]; - - for (uint256 i = 0; i < LIBRA_COMMITMENTS; i++) { - commitments[boundary++] = proof.libraCommitments[i]; - } - - commitments[boundary] = Honk.G1Point({x: 1, y: 2}); - scalars[boundary++] = mem.constantTermAccumulator; - - require( - checkEvalsConsistency(proof.libraPolyEvals, tp.geminiR, tp.sumCheckUChallenges, proof.libraEvaluation), - Errors.ConsistencyCheckFailed() - ); - - Honk.G1Point memory quotient_commitment = proof.kzgQuotient; - - commitments[boundary] = quotient_commitment; - scalars[boundary] = tp.shplonkZ; // evaluation challenge - - PairingInputs memory pair; - pair.P_0 = batchMul(commitments, scalars); - pair.P_1 = negateInplace(quotient_commitment); - - // Aggregate pairing points (skip if default/infinity — no recursive verification occurred) - if (!arePairingPointsDefault(proof.pairingPointObject)) { - Fr recursionSeparator = generateRecursionSeparator(proof.pairingPointObject, pair.P_0, pair.P_1); - (Honk.G1Point memory P_0_other, Honk.G1Point memory P_1_other) = - convertPairingPointsToG1(proof.pairingPointObject); - - // Validate the points from the proof are on the curve - rejectPointAtInfinity(P_0_other); - rejectPointAtInfinity(P_1_other); - - // accumulate with aggregate points in proof - pair.P_0 = mulWithSeperator(pair.P_0, P_0_other, recursionSeparator); - pair.P_1 = mulWithSeperator(pair.P_1, P_1_other, recursionSeparator); - } - - return pairing(pair.P_0, pair.P_1); - } - - function checkEvalsConsistency( - Fr[LIBRA_EVALUATIONS] memory libraPolyEvals, - Fr geminiR, - Fr[CONST_PROOF_SIZE_LOG_N] memory uChallenges, - Fr libraEval - ) internal view returns (bool check) { - Fr one = Fr.wrap(1); - Fr vanishingPolyEval = geminiR.pow(SUBGROUP_SIZE) - one; - require(vanishingPolyEval != Fr.wrap(0), Errors.GeminiChallengeInSubgroup()); - - SmallSubgroupIpaIntermediates memory mem; - mem.challengePolyLagrange[0] = one; - for (uint256 round = 0; round < $LOG_N; round++) { - uint256 currIdx = 1 + LIBRA_UNIVARIATES_LENGTH * round; - mem.challengePolyLagrange[currIdx] = one; - for (uint256 idx = currIdx + 1; idx < currIdx + LIBRA_UNIVARIATES_LENGTH; idx++) { - mem.challengePolyLagrange[idx] = mem.challengePolyLagrange[idx - 1] * uChallenges[round]; - } - } - - mem.rootPower = one; - mem.challengePolyEval = Fr.wrap(0); - for (uint256 idx = 0; idx < SUBGROUP_SIZE; idx++) { - mem.denominators[idx] = mem.rootPower * geminiR - one; - mem.denominators[idx] = mem.denominators[idx].invert(); - mem.challengePolyEval = mem.challengePolyEval + mem.challengePolyLagrange[idx] * mem.denominators[idx]; - mem.rootPower = mem.rootPower * SUBGROUP_GENERATOR_INVERSE; - } - - Fr numerator = vanishingPolyEval * Fr.wrap(SUBGROUP_SIZE).invert(); - mem.challengePolyEval = mem.challengePolyEval * numerator; - mem.lagrangeFirst = mem.denominators[0] * numerator; - mem.lagrangeLast = mem.denominators[SUBGROUP_SIZE - 1] * numerator; - - mem.diff = mem.lagrangeFirst * libraPolyEvals[2]; - - mem.diff = mem.diff + (geminiR - SUBGROUP_GENERATOR_INVERSE) - * (libraPolyEvals[1] - libraPolyEvals[2] - libraPolyEvals[0] * mem.challengePolyEval); - mem.diff = mem.diff + mem.lagrangeLast * (libraPolyEvals[2] - libraEval) - vanishingPolyEval * libraPolyEvals[3]; - - check = mem.diff == Fr.wrap(0); - } - - // This implementation is the same as above with different constants - function batchMul(Honk.G1Point[] memory base, Fr[] memory scalars) - internal - view - returns (Honk.G1Point memory result) - { - uint256 limit = $MSMSize; - - // Validate all points are on the curve - for (uint256 i = 0; i < limit; ++i) { - rejectPointAtInfinity(base[i]); - } - - bool success = true; - assembly ("memory-safe") { - let free := mload(0x40) - - let count := 0x01 - for {} lt(count, add(limit, 1)) { count := add(count, 1) } { - // Get loop offsets - let base_base := add(base, mul(count, 0x20)) - let scalar_base := add(scalars, mul(count, 0x20)) - - mstore(add(free, 0x40), mload(mload(base_base))) - mstore(add(free, 0x60), mload(add(0x20, mload(base_base)))) - // Add scalar - mstore(add(free, 0x80), mload(scalar_base)) - - success := and(success, staticcall(gas(), 7, add(free, 0x40), 0x60, add(free, 0x40), 0x40)) - // accumulator = accumulator + accumulator_2 - success := and(success, staticcall(gas(), 6, free, 0x80, free, 0x40)) - } - - // Return the result - mstore(result, mload(free)) - mstore(add(result, 0x20), mload(add(free, 0x20))) - } - - require(success, Errors.ShpleminiFailed()); - } - - // Calculate proof size based on log_n (matching UltraKeccakZKFlavor formula) - function calculateProofSize(uint256 logN) internal pure returns (uint256) { - // Witness and Libra commitments - uint256 proofLength = NUM_WITNESS_ENTITIES * NUM_ELEMENTS_COMM; // witness commitments - proofLength += NUM_ELEMENTS_COMM * 3; // Libra concat, grand sum, quotient comms + Gemini masking - - // Sumcheck - proofLength += logN * ZK_BATCHED_RELATION_PARTIAL_LENGTH * NUM_ELEMENTS_FR; // sumcheck univariates - proofLength += NUMBER_OF_ENTITIES_ZK * NUM_ELEMENTS_FR; // sumcheck evaluations - - // Libra and Gemini - proofLength += NUM_ELEMENTS_FR * 2; // Libra sum, claimed eval - proofLength += logN * NUM_ELEMENTS_FR; // Gemini a evaluations - proofLength += NUM_LIBRA_EVALUATIONS * NUM_ELEMENTS_FR; // libra evaluations - - // PCS commitments - proofLength += (logN - 1) * NUM_ELEMENTS_COMM; // Gemini Fold commitments - proofLength += NUM_ELEMENTS_COMM * 2; // Shplonk Q and KZG W commitments - - // Pairing points - proofLength += PAIRING_POINTS_SIZE; // pairing inputs carried on public inputs - - return proofLength * 32; - } - - function loadVerificationKey() internal pure virtual returns (Honk.VerificationKey memory); -} - -contract HonkVerifier is BaseZKHonkVerifier(N, LOG_N, VK_HASH, NUMBER_OF_PUBLIC_INPUTS) { - function loadVerificationKey() internal pure override returns (Honk.VerificationKey memory) { - return HonkVerificationKey.loadVerificationKey(); - } -} diff --git a/solidity/contracts/login/oidc/script/DeployOidc.s.sol b/solidity/contracts/login/oidc/script/DeployOidc.s.sol deleted file mode 100644 index 75680120..00000000 --- a/solidity/contracts/login/oidc/script/DeployOidc.s.sol +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Script, console} from "forge-std/Script.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {GoogleOidcVerifier, IVerifier} from "../GoogleOidcVerifier.sol"; -import {HonkVerifier} from "../Verifier.sol"; - -interface IRegistrySetOidc { - function setOidcVerifier(string calldata platform, address verifier) external; -} - -/** - * Deploy the OIDC verifier stack (HonkVerifier + GoogleOidcVerifier) and - * wire `Registry.setOidcVerifier("www.googleapis.com", …)`. - * - * Runs under the `oidc` Foundry profile (via_ir=false). - * - * Usage: - * FOUNDRY_PROFILE=oidc forge script contracts/login/oidc/script/DeployOidc.s.sol \ - * --rpc-url $RPC_URL --broadcast \ - * --sig "run(address,address)" $REGISTRY $NOTARY_CONTRACT_ADDR - * - * The second argument is the shared Notary CONTRACT (the proxy every - * consumer points at), not a raw signer key. - */ -contract DeployOidc is Script { - function run(address registry, address notaryContract) external { - // DEPLOYER_KEY may arrive with or without a `0x` prefix (dev-start - // stores it stripped in .env; --private-key injects 64 bare hex - // chars). Normalize before parsing so `vm.parseUint` accepts it. - string memory deployerKeyHex = vm.envOr("DEPLOYER_KEY", vm.envOr("PRIVATE_KEY", string(""))); - require(bytes(deployerKeyHex).length > 0, "set DEPLOYER_KEY or PRIVATE_KEY"); - if (bytes(deployerKeyHex).length == 64) { - deployerKeyHex = string.concat("0x", deployerKeyHex); - } - uint256 deployerKey = vm.parseUint(deployerKeyHex); - address deployer = vm.addr(deployerKey); - // Google OAuth client id (the JWT `aud`) this verifier accepts. The - // circuit is application-agnostic, so the deployment is what pins - // registrations to one app — required, no silent default. Changeable - // later via GoogleOidcVerifier.setExpectedAudience(clientId). - string memory gmailClientId = vm.envString("GMAIL_CLIENT_ID"); - require(bytes(gmailClientId).length > 0, "set GMAIL_CLIENT_ID (the accepted Google OAuth client id)"); - console.log("Deployer: ", deployer); - console.log("Registry: ", registry); - console.log("Notary contract:", notaryContract); - console.log("Gmail client id:", gmailClientId); - - vm.startBroadcast(deployerKey); - - HonkVerifier honk = new HonkVerifier(); - // UUPS: deploy implementation, then an ERC1967 proxy initialized with it. - GoogleOidcVerifier googleImpl = new GoogleOidcVerifier(); - ERC1967Proxy googleProxy = new ERC1967Proxy( - address(googleImpl), - abi.encodeCall( - GoogleOidcVerifier.initialize, (IVerifier(address(honk)), deployer, notaryContract, gmailClientId) - ) - ); - GoogleOidcVerifier google = GoogleOidcVerifier(address(googleProxy)); - - IRegistrySetOidc(registry).setOidcVerifier("www.googleapis.com", address(google)); - - vm.stopBroadcast(); - - console.log("---"); - console.log("HONK_VERIFIER_ADDRESS=", address(honk)); - console.log("GOOGLE_OIDC_VERIFIER_ADDRESS=", address(google)); - } -} diff --git a/solidity/contracts/login/test/Registry.t.sol b/solidity/contracts/login/test/Registry.t.sol deleted file mode 100644 index 5c44ce88..00000000 --- a/solidity/contracts/login/test/Registry.t.sol +++ /dev/null @@ -1,1068 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../Registry.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; -import {WalletFactory} from "../WalletFactory.sol"; -import {WebWallet} from "../WebWallet.sol"; - -contract RegistryTest is Test { - Registry registry; - WalletFactory factory; - Notary notaryContract; - WebWallet walletImplV1; - - address owner = address(this); - - uint256 notaryPk = 0xA001; - uint256 backendPk = 0xA002; - uint256 userPk = 0xA003; - uint256 linkSessionPk = 0xA009; - - address notaryAddr; - address backendAddr; - - address userSessionAddr; - address linkSessionAddr; - - // ─── setUp ────────────────────────────────────────────────────── - - function setUp() public { - notaryAddr = vm.addr(notaryPk); - backendAddr = vm.addr(backendPk); - userSessionAddr = vm.addr(userPk); - linkSessionAddr = vm.addr(linkSessionPk); - - walletImplV1 = new WebWallet(); - - Registry rImpl = new Registry(); - WalletFactory fImpl = new WalletFactory(); - - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), abi.encodeCall(WalletFactory.initialize, (owner, address(walletImplV1), address(1))) - ) - ) - ); - - notaryContract = deployNotary(owner, notaryAddr); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall(Registry.initialize, (address(notaryContract), backendAddr, address(factory), owner)) - ) - ) - ); - - factory.setRegistry(address(registry)); - - // Register platforms used in tests - registry.setPlatform("x.com", "api.x.com/2/users/me", '"username":"', '"id":"', '"'); - registry.setPlatform("github.com", "api.github.com/user", '"login":"', '"id":', ","); - - vm.warp(1_000_000); - } - - // ─── proof helpers ────────────────────────────────────────────── - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - function _leaf(string memory prefix, bytes memory value) internal pure returns (bytes32) { - return keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - } - - function _buildPaths(bytes memory domainBytes, bytes memory usernameBytes, bytes memory endpointBytes) - internal - pure - returns ( - bytes32 transcriptRoot, - bytes32[] memory domainPath, - bytes32[] memory usernamePath, - bytes32[] memory endpointPath - ) - { - bytes32 leafD = _leaf("domain:", domainBytes); - bytes32 leafU = _leaf("recv:", usernameBytes); - bytes32 leafE = _leaf("endpoint:", endpointBytes); - - bytes32 hDu = - leafD < leafU ? keccak256(abi.encodePacked(leafD, leafU)) : keccak256(abi.encodePacked(leafU, leafD)); - - transcriptRoot = hDu < leafE ? keccak256(abi.encodePacked(hDu, leafE)) : keccak256(abi.encodePacked(leafE, hDu)); - - domainPath = new bytes32[](2); - domainPath[0] = leafU; - domainPath[1] = leafE; - - usernamePath = new bytes32[](2); - usernamePath[0] = leafD; - usernamePath[1] = leafE; - - endpointPath = new bytes32[](1); - endpointPath[0] = hDu; - } - - struct ProofBundle { - Registry.FullTlsProof proof; - } - - function _buildProof( - string memory domain, - string memory username, - string memory endpoint, - string memory handlePrefix, - address _userAddress, - uint256 ts - ) internal view returns (ProofBundle memory b) { - return _buildProofForWallet(domain, username, endpoint, handlePrefix, _userAddress, address(0), ts); - } - - function _buildProofForWallet( - string memory domain, - string memory username, - string memory endpoint, - string memory handlePrefix, - address _userAddress, - address _walletAddress, - uint256 ts - ) internal view returns (ProofBundle memory b) { - bytes32 clientRandom = bytes32(uint256(111)); - bytes32 serverRandom = bytes32(uint256(222)); - bytes memory serverEphKey = hex"deadbeef"; - bytes32 domainHash = keccak256(bytes(domain)); - - bytes memory usernameSnippet = abi.encodePacked(handlePrefix, username, '"'); - (bytes32 transcriptRoot, bytes32[] memory dp, bytes32[] memory up, bytes32[] memory ep) = - _buildPaths(bytes(domain), usernameSnippet, bytes(endpoint)); - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - bytes32 backendDigest = keccak256(abi.encode(_userAddress, _walletAddress, transcriptRoot, ts)); - - b.proof = Registry.FullTlsProof({ - notarySignature: _sign(0xA001, notaryDigest), - backendSignature: _sign(0xA002, backendDigest), - userAddress: _userAddress, - walletAddress: _walletAddress, - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - domainPath: dp, - usernamePath: up, - endpointPath: ep, - idPath: new bytes32[](0) - }); - } - - function _buildXProof(uint256 ts) internal view returns (ProofBundle memory) { - return _buildProofWithId( - "x.com", "alice", "12345", "api.x.com/2/users/me", '"username":"', '"id":"', '"', userSessionAddr, ts - ); - } - - function _registerXAlice() internal returns (address wallet) { - ProofBundle memory b = _buildXProof(block.timestamp); - registry.register_session(b.proof, "x.com", "alice", "12345", "api.x.com/2/users/me"); - wallet = registry.resolve("x.com", "alice"); - } - - // 4-leaf tree: D, U, E, Id. hDU = h(D,U), hEId = h(E,Id), root = h(hDU, hEId). - function _buildProofWithId( - string memory domain, - string memory username, - string memory userId, - string memory endpoint, - string memory handlePrefix, - string memory idPrefix, - string memory idSuffix, - address _userAddress, - uint256 ts - ) internal view returns (ProofBundle memory b) { - bytes32 clientRandom = bytes32(uint256(111)); - bytes32 serverRandom = bytes32(uint256(222)); - bytes memory serverEphKey = hex"deadbeef"; - bytes32 domainHash = keccak256(bytes(domain)); - - bytes32 leafD = _leaf("domain:", bytes(domain)); - bytes32 leafU = _leaf("recv:", abi.encodePacked(handlePrefix, username, '"')); - bytes32 leafE = _leaf("endpoint:", bytes(endpoint)); - bytes32 leafId = _leaf("recv:", abi.encodePacked(idPrefix, userId, idSuffix)); - - bytes32 hDU = _hash(leafD, leafU); - bytes32 hEId = _hash(leafE, leafId); - bytes32 root = _hash(hDU, hEId); - - bytes32[] memory dp = new bytes32[](2); - dp[0] = leafU; - dp[1] = hEId; - bytes32[] memory up = new bytes32[](2); - up[0] = leafD; - up[1] = hEId; - bytes32[] memory ep = new bytes32[](2); - ep[0] = leafId; - ep[1] = hDU; - bytes32[] memory idp = new bytes32[](2); - idp[0] = leafE; - idp[1] = hDU; - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - root, - ts - ) - ); - bytes32 backendDigest = keccak256(abi.encode(_userAddress, address(0), root, ts)); - - b.proof = Registry.FullTlsProof({ - notarySignature: _sign(0xA001, notaryDigest), - backendSignature: _sign(0xA002, backendDigest), - userAddress: _userAddress, - walletAddress: address(0), - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: root, - timestamp: ts, - domainPath: dp, - usernamePath: up, - endpointPath: ep, - idPath: idp - }); - } - - /// Like `_buildProofWithId` but bound to a linking wallet: `linkIdentity` - /// requires `proof.walletAddress == msg.sender`, so re-bind walletAddress - /// and re-sign the backend digest (notary digest is wallet-independent). - function _linkProofWithId( - string memory domain, - string memory username, - string memory userId, - string memory endpoint, - string memory handlePrefix, - string memory idPrefix, - string memory idSuffix, - address _userAddress, - address _walletAddress, - uint256 ts - ) internal view returns (ProofBundle memory b) { - b = _buildProofWithId(domain, username, userId, endpoint, handlePrefix, idPrefix, idSuffix, _userAddress, ts); - b.proof.walletAddress = _walletAddress; - b.proof.backendSignature = - _sign(0xA002, keccak256(abi.encode(_userAddress, _walletAddress, b.proof.transcriptRoot, ts))); - } - - function _hash(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - // Execute `linkData` on `wallet` via the multisig executor, signed by userPk. - function _execLink(address wallet, bytes memory linkData) internal { - uint256 currentNonce = WebWallet(payable(wallet)).nonce(); - bytes32 execDigest = keccak256( - abi.encode(address(registry), uint256(0), keccak256(linkData), currentNonce, block.chainid, wallet) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(userPk, execDigest); - WebWallet(payable(wallet)).execute(address(registry), 0, linkData, sigs); - } - - // ─── happy path: new user ─────────────────────────────────────── - - function test_registerSession_newUser_deploysWalletAndRegistersSession() public { - address wallet = _registerXAlice(); - - assertTrue(wallet != address(0)); - assertTrue(factory.isRegisteredWallet(wallet)); - assertEq(registry.resolve("x.com", "alice"), wallet); - - WebWallet w = WebWallet(payable(wallet)); - bytes32 key = keccak256(abi.encode("x.com", "12345")); - assertEq(w.sessionCount(key), 1); - assertEq(w.sessionAt(key, 0), userSessionAddr); - - assertTrue(w.isIdentity(key)); - } - - // ─── happy path: existing user ────────────────────────────────── - - function test_registerSession_existingUser_registersNewSession() public { - address wallet = _registerXAlice(); - - uint256 user2Pk = 0xA004; - address user2Addr = vm.addr(user2Pk); - - ProofBundle memory b = _buildProofWithId( - "x.com", "alice", "12345", "api.x.com/2/users/me", '"username":"', '"id":"', '"', user2Addr, block.timestamp - ); - - registry.register_session(b.proof, "x.com", "alice", "12345", "api.x.com/2/users/me"); - - assertEq(registry.resolve("x.com", "alice"), wallet); - - WebWallet w = WebWallet(payable(wallet)); - bytes32 key = keccak256(abi.encode("x.com", "12345")); - assertEq(w.sessionCount(key), 2); - assertEq(w.sessionAt(key, 1), user2Addr); - } - - // ─── invalid proof rejected ───────────────────────────────────── - - function test_registerSession_invalidNotarySignature_reverts() public { - ProofBundle memory b = _buildXProof(block.timestamp); - - // Re-sign notary digest with wrong key - bytes32 notaryDigest = keccak256( - abi.encode( - b.proof.domainHash, - b.proof.clientRandom, - b.proof.serverRandom, - keccak256(b.proof.serverEphemeralKey), - b.proof.transcriptRoot, - b.proof.timestamp - ) - ); - b.proof.notarySignature = _sign(0xDEAD, notaryDigest); - - vm.expectRevert(); - registry.register_session(b.proof, "x.com", "alice", "", "api.x.com/2/users/me"); - } - - function test_registerSession_invalidBackendSignature_reverts() public { - ProofBundle memory b = _buildXProof(block.timestamp); - - bytes32 backendDigest = keccak256(abi.encode(b.proof.userAddress, b.proof.transcriptRoot, b.proof.timestamp)); - b.proof.backendSignature = _sign(notaryPk, backendDigest); - - vm.expectRevert(Registry.InvalidBackendSignature.selector); - registry.register_session(b.proof, "x.com", "alice", "", "api.x.com/2/users/me"); - } - - // ─── session address reuse rejected (flat — same addr any platform) ── - - function test_registerSession_sessionKeyReuse_reverts() public { - _registerXAlice(); - - ProofBundle memory b2 = _buildProofWithId( - "x.com", - "bob", - "67890", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - userSessionAddr, - block.timestamp - ); - - vm.expectRevert(Registry.SessionKeyAlreadyUsed.selector); - registry.register_session(b2.proof, "x.com", "bob", "67890", "api.x.com/2/users/me"); - } - - // ─── stale timestamp rejected ─────────────────────────────────── - - function test_registerSession_staleTimestamp_reverts() public { - ProofBundle memory b = _buildProof( - "x.com", "alice", "api.x.com/2/users/me", '"username":"', userSessionAddr, block.timestamp - 2 hours - ); - - vm.expectRevert(Registry.StaleProof.selector); - registry.register_session(b.proof, "x.com", "alice", "", "api.x.com/2/users/me"); - } - - function test_registerSession_futureTimestamp_reverts() public { - ProofBundle memory b = _buildProof( - "x.com", "alice", "api.x.com/2/users/me", '"username":"', userSessionAddr, block.timestamp + 5 minutes + 1 - ); - - vm.expectRevert(Registry.FutureProof.selector); - registry.register_session(b.proof, "x.com", "alice", "", "api.x.com/2/users/me"); - } - - // ─── WebWallet.register_session reverts if caller is not Registry ─ - - function test_webWalletRegisterSession_revertsIfNotRegistry() public { - address wallet = _registerXAlice(); - vm.expectRevert(WebWallet.OnlyRegistry.selector); - WebWallet(payable(wallet)).register_session("x.com", "alice", "12345", userSessionAddr); - } - - // ─── resolve returns address(0) for unknown handle ────────────── - - function test_resolve_returnsZeroForUnknownHandle() public view { - assertEq(registry.resolve("x.com", "nobody"), address(0)); - } - - // ─── getHandles ───────────────────────────────────────────────── - - function test_getHandles_returnsCorrectEntries() public { - _registerXAlice(); - (string[] memory platforms, string[] memory handles) = registry.getHandles(registry.resolve("x.com", "alice")); - assertEq(platforms.length, 1); - assertEq(platforms[0], "x.com"); - assertEq(handles[0], "alice"); - } - - // ─── linkIdentity tests ───────────────────────────────────────── - - function test_linkIdentity_happyPath() public { - address wallet = _registerXAlice(); - - ProofBundle memory ghProof = _linkProofWithId( - "github.com", - "alice", - "777", - "api.github.com/user", - '"login":"', - '"id":', - ",", - linkSessionAddr, - wallet, - block.timestamp - ); - - bytes memory linkData = - abi.encodeCall(Registry.linkIdentity, ("github.com", "alice", "777", ghProof.proof, "api.github.com/user")); - - uint256 currentNonce = WebWallet(payable(wallet)).nonce(); - bytes32 execDigest = keccak256( - abi.encode(address(registry), uint256(0), keccak256(linkData), currentNonce, block.chainid, wallet) - ); - - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(userPk, execDigest); - - WebWallet(payable(wallet)).execute(address(registry), 0, linkData, sigs); - - assertEq(registry.resolve("github.com", "alice"), wallet); - - (string[] memory platforms,) = registry.getHandles(wallet); - assertEq(platforms.length, 2); - } - - // Re-pointing a handle to a new owner is allowed; only an id mapped to a - // different wallet is a real conflict (see test_linkIdentity_idAlreadyLinked_reverts). - function test_linkIdentity_rePointHandle_allowed() public { - address aliceWallet = _registerXAlice(); - - // bob registers github handle "shared" under id 100. - uint256 bobPk = 0xA007; - address bobAddr = vm.addr(bobPk); - ProofBundle memory bobProof = _buildProofWithId( - "x.com", "shared", "100", "api.x.com/2/users/me", '"username":"', '"id":"', '"', bobAddr, block.timestamp - ); - registry.register_session(bobProof.proof, "x.com", "shared", "100", "api.x.com/2/users/me"); - - // alice links the same handle "shared" under a different id 200 — allowed. - ProofBundle memory ghProof = _linkProofWithId( - "x.com", - "shared", - "200", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - linkSessionAddr, - aliceWallet, - block.timestamp - ); - bytes memory linkData = - abi.encodeCall(Registry.linkIdentity, ("x.com", "shared", "200", ghProof.proof, "api.x.com/2/users/me")); - _execLink(aliceWallet, linkData); - - // handleHint re-points to alice's id; resolve follows. - assertEq(registry.resolveById("x.com", "200"), aliceWallet); - assertEq(registry.resolve("x.com", "shared"), aliceWallet); - } - - /// M2 regression: re-linking the SAME (platform, userId) to the SAME wallet - /// must not append a duplicate HandleEntry (which would double-count - /// that id downstream). The session/label still refresh. - function test_linkIdentity_reLinkSameId_noDuplicateEntry() public { - address wallet = _registerXAlice(); // x.com / alice / id 12345 - - (string[] memory before,) = registry.getUserIds(wallet); - assertEq(before.length, 1, "precondition: one entry"); - - // Re-link the SAME id "12345" to the same wallet with a fresh session key. - address freshSession = vm.addr(0xFEED01); - ProofBundle memory p = _linkProofWithId( - "x.com", - "alice", - "12345", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - freshSession, - wallet, - block.timestamp - ); - bytes memory linkData = - abi.encodeCall(Registry.linkIdentity, ("x.com", "alice", "12345", p.proof, "api.x.com/2/users/me")); - _execLink(wallet, linkData); - - // No duplicate entry — still exactly one (platform, id) for this wallet. - (string[] memory platforms, string[] memory userIds) = registry.getUserIds(wallet); - assertEq(platforms.length, 1, "duplicate HandleEntry appended on re-link"); - assertEq(userIds[0], "12345"); - // Still resolves correctly; the fresh session is now authorized. - assertEq(registry.resolveById("x.com", "12345"), wallet); - assertTrue(WebWallet(payable(wallet)).isSession(freshSession)); - } - - function test_linkIdentity_notRegisteredWallet_reverts() public { - ProofBundle memory ghProof = - _buildProof("github.com", "alice", "api.github.com/user", '"login":"', userSessionAddr, block.timestamp); - - vm.expectRevert(Registry.NotRegisteredWallet.selector); - registry.linkIdentity("github.com", "alice", "", ghProof.proof, "api.github.com/user"); - } - - // ─── Multisig threshold with (platform, handle) pairs ─────────── - - function test_execute_multisigThreshold_distinctPairsRequired() public { - address wallet = _registerXAlice(); - - // Register a second session for same (x.com, alice) - uint256 user2Pk = 0xA006; - address user2Addr = vm.addr(user2Pk); - - ProofBundle memory b2 = _buildProofWithId( - "x.com", "alice", "12345", "api.x.com/2/users/me", '"username":"', '"id":"', '"', user2Addr, block.timestamp - ); - registry.register_session(b2.proof, "x.com", "alice", "12345", "api.x.com/2/users/me"); - - // Link a second identity (use a fresh session address for the new identity) - ProofBundle memory ghProof = _linkProofWithId( - "github.com", - "alice", - "777", - "api.github.com/user", - '"login":"', - '"id":', - ",", - linkSessionAddr, - wallet, - block.timestamp - ); - bytes memory linkData = - abi.encodeCall(Registry.linkIdentity, ("github.com", "alice", "777", ghProof.proof, "api.github.com/user")); - uint256 currentNonce = WebWallet(payable(wallet)).nonce(); - bytes32 execDigest = keccak256( - abi.encode(address(registry), uint256(0), keccak256(linkData), currentNonce, block.chainid, wallet) - ); - bytes[] memory sg1 = new bytes[](1); - sg1[0] = _sign(userPk, execDigest); - WebWallet(payable(wallet)).execute(address(registry), 0, linkData, sg1); - - // Register a session for github.com/alice - uint256 ghSessionPk = 0xA008; - address ghAddr = vm.addr(ghSessionPk); - - ProofBundle memory ghSessionProof = _buildProofWithId( - "github.com", "alice", "777", "api.github.com/user", '"login":"', '"id":', ",", ghAddr, block.timestamp - ); - registry.register_session(ghSessionProof.proof, "github.com", "alice", "777", "api.github.com/user"); - - // Execute with 2 sigs from same pair (x.com/alice) — counts as 1 distinct pair - bytes memory noopData = abi.encodeWithSignature("resolve(string,string)", "x", "y"); - currentNonce = WebWallet(payable(wallet)).nonce(); - execDigest = keccak256( - abi.encode(address(registry), uint256(0), keccak256(noopData), currentNonce, block.chainid, wallet) - ); - - bytes[] memory sg3 = new bytes[](2); - sg3[0] = _sign(userPk, execDigest); - sg3[1] = _sign(user2Pk, execDigest); - - // threshold=1, 2 sigs from 1 pair → 1 distinct pair >= 1 ✓ - WebWallet(payable(wallet)).execute(address(registry), 0, noopData, sg3); - } - - // ─── linkIdentity registers session on wallet ─────────────────── - - function test_linkIdentity_registersSessionOnWallet() public { - address wallet = _registerXAlice(); - - ProofBundle memory ghProof = _linkProofWithId( - "github.com", - "alice", - "777", - "api.github.com/user", - '"login":"', - '"id":', - ",", - linkSessionAddr, - wallet, - block.timestamp - ); - - bytes memory linkData = - abi.encodeCall(Registry.linkIdentity, ("github.com", "alice", "777", ghProof.proof, "api.github.com/user")); - - uint256 currentNonce = WebWallet(payable(wallet)).nonce(); - bytes32 execDigest = keccak256( - abi.encode(address(registry), uint256(0), keccak256(linkData), currentNonce, block.chainid, wallet) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(userPk, execDigest); - WebWallet(payable(wallet)).execute(address(registry), 0, linkData, sigs); - - // Verify that linkSessionAddr is now a session key on the wallet - bytes32 ghKey = keccak256(abi.encode("github.com", "777")); - WebWallet w = WebWallet(payable(wallet)); - assertTrue(w.isSession(linkSessionAddr)); - assertEq(w.sessionCount(ghKey), 1); - assertEq(w.sessionAt(ghKey, 0), linkSessionAddr); - } - - // ─── getHandles returns empty for unknown wallet ──────────────── - - function test_getHandles_returnsEmptyForUnknownWallet() public view { - (string[] memory platforms, string[] memory handles) = registry.getHandles(address(0xDEAD)); - assertEq(platforms.length, 0); - assertEq(handles.length, 0); - } - - // ─── register_session zero address ────────────────────────────── - - function test_registerSession_zeroAddress_reverts() public { - ProofBundle memory b = _buildProofWithId( - "x.com", - "alice", - "12345", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - address(0), - block.timestamp - ); - vm.expectRevert("zero session"); - registry.register_session(b.proof, "x.com", "alice", "12345", "api.x.com/2/users/me"); - } - - // ─── register_session domain hash mismatch ────────────────────── - - function test_registerSession_unsupportedPlatform_reverts() public { - ProofBundle memory b = _buildXProof(block.timestamp); - // Pass unregistered domain - vm.expectRevert(Registry.UnsupportedPlatform.selector); - registry.register_session(b.proof, "wrong.com", "alice", "", "api.x.com/2/users/me"); - } - - // ─── platform userId ──────────────────────────────────────────── - - function test_registerSession_storesUserId() public { - ProofBundle memory b = _buildProofWithId( - "x.com", - "alice", - "12345", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - userSessionAddr, - block.timestamp - ); - registry.register_session(b.proof, "x.com", "alice", "12345", "api.x.com/2/users/me"); - - address wallet = registry.walletOf("x.com", "12345"); - (string[] memory platforms, string[] memory userIds) = registry.getUserIds(wallet); - assertEq(platforms.length, 1); - assertEq(platforms[0], "x.com"); - assertEq(userIds[0], "12345"); - } - - function test_registerSession_wrongUserId_reverts() public { - // Proof commits to id 12345, but caller claims 99999 → id leaf mismatch. - ProofBundle memory b = _buildProofWithId( - "x.com", - "alice", - "12345", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - userSessionAddr, - block.timestamp - ); - vm.expectRevert(Registry.InvalidMerkleProof.selector); - registry.register_session(b.proof, "x.com", "alice", "99999", "api.x.com/2/users/me"); - } - - function test_registerSession_idOnUnsupportedPlatform_reverts() public { - // A platform with empty idPrefix → passing a userId must revert. - registry.setPlatform("noid.com", "api.noid.com/me", '"username":"', "", ""); - ProofBundle memory b = _buildProofWithId( - "noid.com", - "alice", - "999", - "api.noid.com/me", - '"username":"', - '"id":"', - '"', - userSessionAddr, - block.timestamp - ); - vm.expectRevert("id not supported"); - registry.register_session(b.proof, "noid.com", "alice", "999", "api.noid.com/me"); - } - - // GitHub: bare-number id "id":, (suffix ',') — stores the numeric id. - function test_registerSession_githubNumericId_stored() public { - ProofBundle memory b = _buildProofWithId( - "github.com", - "octocat", - "583231", - "api.github.com/user", - '"login":"', - '"id":', - ",", - userSessionAddr, - block.timestamp - ); - registry.register_session(b.proof, "github.com", "octocat", "583231", "api.github.com/user"); - - address wallet = registry.walletOf("github.com", "583231"); - assertTrue(wallet != address(0)); - assertEq(registry.resolveById("github.com", "583231"), wallet); - (, string[] memory userIds) = registry.getUserIds(wallet); - assertEq(userIds[0], "583231"); - } - - // GitHub: a truncated id (claim "583" against real "583231,") must not verify — - // the ',' suffix anchors the number's end, blocking prefix-squatting. - function test_registerSession_githubTruncatedId_reverts() public { - ProofBundle memory b = _buildProofWithId( - "github.com", - "octocat", - "583231", - "api.github.com/user", - '"login":"', - '"id":', - ",", - userSessionAddr, - block.timestamp - ); - vm.expectRevert(Registry.InvalidMerkleProof.selector); - registry.register_session(b.proof, "github.com", "octocat", "583", "api.github.com/user"); - } - - // ─── id-layer: registration / resolution (T1–T12) ─────────────── - - // Register x.com/alice under id 12345 via the id path. - function _registerXAliceId(string memory userId, address sessionAddr, uint256 ts) - internal - returns (address wallet) - { - ProofBundle memory b = _buildProofWithId( - "x.com", "alice", userId, "api.x.com/2/users/me", '"username":"', '"id":"', '"', sessionAddr, ts - ); - registry.register_session(b.proof, "x.com", "alice", userId, "api.x.com/2/users/me"); - wallet = registry.walletOf("x.com", userId); - } - - // T1: register by id deploys wallet, sets walletOf/handleHint/idMeta. - function test_T1_registerById_setsIdState() public { - address wallet = _registerXAliceId("12345", userSessionAddr, block.timestamp); - - assertTrue(wallet != address(0)); // I1: walletOf set to a non-zero wallet - assertEq(registry.walletOf("x.com", "12345"), wallet); - assertEq(registry.handleHint("x.com", "alice"), "12345"); - (string memory h, uint256 verifiedAt) = registry.idMeta("x.com", "12345"); - assertEq(h, "alice"); - assertEq(verifiedAt, block.timestamp); - } - - // T2: resolve(handle) follows handleHint → walletOf. - function test_T2_resolve_viaHint() public { - address wallet = _registerXAliceId("12345", userSessionAddr, block.timestamp); - assertEq(registry.resolve("x.com", "alice"), wallet); - } - - // T3: resolveById(id) → wallet. - function test_T3_resolveById() public { - address wallet = _registerXAliceId("12345", userSessionAddr, block.timestamp); - assertEq(registry.resolveById("x.com", "12345"), wallet); - } - - // T4: recycle — B proves a handle A held; resolve→B, resolveById(A)→A's wallet. - function test_T4_recycle_handlePointsToNewOwner() public { - address walletA = _registerXAliceId("100", userSessionAddr, block.timestamp); - - // B proves the same handle "alice" under a fresh id 200. - uint256 bPk = 0xB001; - address bAddr = vm.addr(bPk); - address walletB = _registerXAliceId("200", bAddr, block.timestamp); - - assertTrue(walletA != walletB); - // I2/I3: hint re-points to B; A still reachable by id. - assertEq(registry.resolve("x.com", "alice"), walletB); - assertEq(registry.resolveById("x.com", "100"), walletA); - assertEq(registry.resolveById("x.com", "200"), walletB); - // A's idMeta untouched. - (string memory hA,) = registry.idMeta("x.com", "100"); - assertEq(hA, "alice"); - } - - // Rename: id 100 re-registers under a new handle. The OLD handle's hint must - // be cleared so resolve(old) no longer returns this wallet. - function test_rename_clearsStaleHandleHint() public { - address wallet = _registerXAliceId("100", userSessionAddr, block.timestamp); - assertEq(registry.resolve("x.com", "alice"), wallet); - - // Same id 100 now proves handle "alice2" with a fresh session key. - address sess2 = vm.addr(0xA11CE2); - ProofBundle memory b = _buildProofWithId( - "x.com", "alice2", "100", "api.x.com/2/users/me", '"username":"', '"id":"', '"', sess2, block.timestamp - ); - vm.expectEmit(false, false, false, true, address(registry)); - emit Registry.HandleChanged("x.com", "100", "alice2"); - registry.register_session(b.proof, "x.com", "alice2", "100", "api.x.com/2/users/me"); - - assertEq(registry.resolve("x.com", "alice2"), wallet); - assertEq(registry.resolveById("x.com", "100"), wallet); - // Old handle hint cleared → no longer resolves to the wallet. - assertEq(registry.resolve("x.com", "alice"), address(0)); - assertEq(registry.handleHint("x.com", "alice"), ""); - // getHandles stays frozen (claim-key stable); getCurrentHandles is current. - (, string[] memory frozenHs) = registry.getHandles(wallet); - assertEq(frozenHs[0], "alice"); - (, string[] memory curHs) = registry.getCurrentHandles(wallet); - assertEq(curHs.length, 1); - assertEq(curHs[0], "alice2"); - } - - // T5: linkIdentity binds a new id to an existing wallet. - function test_T5_linkIdentity_newId() public { - address wallet = _registerXAliceId("100", userSessionAddr, block.timestamp); - - ProofBundle memory ghProof = _linkProofWithId( - "x.com", - "alice2", - "300", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - linkSessionAddr, - wallet, - block.timestamp - ); - bytes memory linkData = - abi.encodeCall(Registry.linkIdentity, ("x.com", "alice2", "300", ghProof.proof, "api.x.com/2/users/me")); - _execLink(wallet, linkData); - - assertEq(registry.walletOf("x.com", "300"), wallet); - assertEq(registry.resolveById("x.com", "300"), wallet); - } - - // T6: linkIdentity for an id already linked elsewhere → revert IdentityAlreadyLinked. - function test_T6_linkIdentity_idAlreadyLinked_reverts() public { - _registerXAliceId("100", userSessionAddr, block.timestamp); // wallet A owns id 100 - - // B's wallet attempts to link id 100 (already A's) → conflict. - uint256 bPk = 0xB002; - address bAddr = vm.addr(bPk); - address walletB = _registerXAliceId("200", bAddr, block.timestamp); - - ProofBundle memory p = _buildProofWithId( - "x.com", - "alice", - "100", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - linkSessionAddr, - block.timestamp - ); - bytes memory linkData = - abi.encodeCall(Registry.linkIdentity, ("x.com", "alice", "100", p.proof, "api.x.com/2/users/me")); - - uint256 currentNonce = WebWallet(payable(walletB)).nonce(); - bytes32 execDigest = keccak256( - abi.encode(address(registry), uint256(0), keccak256(linkData), currentNonce, block.chainid, walletB) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(bPk, execDigest); // bAddr is B's session - - vm.expectRevert(); // wrapped ExecutionFailed(IdentityAlreadyLinked) — I5 - WebWallet(payable(walletB)).execute(address(registry), 0, linkData, sigs); - } - - // T7: refreshHandleHint by a stranger with a fresh proof re-points the hint. - function test_T7_refreshHandleHint_permissionless() public { - address wallet = _registerXAliceId("100", userSessionAddr, block.timestamp); - - // Stranger calls refreshHandleHint with a fresh proof for (alice, 100). - ProofBundle memory p = _buildProofWithId( - "x.com", - "alice", - "100", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - address(0xCAFE), - block.timestamp - ); - vm.prank(address(0xBEEF)); - registry.refreshHandleHint("x.com", "100", "alice", p.proof, "api.x.com/2/users/me"); - - assertEq(registry.handleHint("x.com", "alice"), "100"); - assertEq(registry.resolve("x.com", "alice"), wallet); - } - - // T8: refreshHandleHint for an unknown id → revert. - function test_T8_refreshHandleHint_unknownId_reverts() public { - ProofBundle memory p = _buildProofWithId( - "x.com", - "alice", - "999", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - address(0xCAFE), - block.timestamp - ); - vm.expectRevert(Registry.UnknownId.selector); - registry.refreshHandleHint("x.com", "999", "alice", p.proof, "api.x.com/2/users/me"); - } - - // T9: refresh/register with a wrong id leaf → revert InvalidMerkleProof (I4). - function test_T9_wrongIdLeaf_reverts() public { - // Proof commits to id 100, caller claims 999 → id leaf mismatch. - ProofBundle memory p = _buildProofWithId( - "x.com", - "alice", - "100", - "api.x.com/2/users/me", - '"username":"', - '"id":"', - '"', - userSessionAddr, - block.timestamp - ); - vm.expectRevert(Registry.InvalidMerkleProof.selector); - registry.register_session(p.proof, "x.com", "alice", "999", "api.x.com/2/users/me"); - } - - // T10: github (bare-number id) registers + resolves via the id path. - function test_T10_githubId_resolves() public { - uint256 ghPk = 0xC001; - address ghAddr = vm.addr(ghPk); - ProofBundle memory b = _buildProofWithId( - "github.com", "octocat", "583231", "api.github.com/user", '"login":"', '"id":', ",", ghAddr, block.timestamp - ); - registry.register_session(b.proof, "github.com", "octocat", "583231", "api.github.com/user"); - - address wallet = registry.walletOf("github.com", "583231"); - assertTrue(wallet != address(0)); - assertEq(registry.resolve("github.com", "octocat"), wallet); - assertEq(registry.resolveById("github.com", "583231"), wallet); - } - - // T11: getUserIds returns (platform, id) after an id-register (2.6). - function test_T11_getUserIds_afterIdRegister() public { - address wallet = _registerXAliceId("12345", userSessionAddr, block.timestamp); - (string[] memory platforms, string[] memory userIds) = registry.getUserIds(wallet); - assertEq(platforms.length, 1); - assertEq(platforms[0], "x.com"); - assertEq(userIds[0], "12345"); - } - - // ─── ERC-7201 storage ─────────────────────────────────────────── - - /// The state must be under the ERC-7201 root and not in sequential slots. - /// This test reads the root with `vm.load` and does not use the constant in - /// the contract. The test fails if the constant is wrong. The test also - /// fails if a later edit adds a plain state variable. That variable lands - /// at slot 0. It collides with nothing today, but it breaks the append-only - /// guarantee that the namespace gives. - function test_storageLivesUnderTheErc7201Root() public view { - bytes32 root = keccak256(abi.encode(uint256(keccak256("dyaka.storage.Registry")) - 1)) & ~bytes32(uint256(0xff)); - - // `notaryContract` is the first field of RegistryStorage. The field is - // at the root. - assertTrue(registry.notaryContract() != address(0), "the fixture must set a notary contract"); - assertEq( - address(uint160(uint256(vm.load(address(registry), root)))), - registry.notaryContract(), - "notaryContract is not at the namespace root" - ); - - // No data is in the sequential slots that the contract used before. - for (uint256 i = 0; i < 10; i++) { - assertEq(vm.load(address(registry), bytes32(i)), bytes32(0), "a sequential slot is in use"); - } - } - - /// The getters keep the shape that the public variables generated. Every - /// off-chain binding (alloy `sol!`, viem ABI) continues to work after the - /// layout change. - function test_namespacedGettersKeepThePublicVariableAbi() public { - address wallet = _registerXAliceId("12345", userSessionAddr, block.timestamp); - - assertEq(registry.walletOf("x.com", "12345"), wallet); - (string memory handle, uint256 verifiedAt) = registry.idMeta("x.com", "12345"); - assertEq(handle, "alice"); - assertTrue(verifiedAt != 0); - assertEq(registry.handleHint("x.com", "alice"), "12345"); - - (string memory platform, string memory boundHandle, string memory userId) = - registry.contract_to_handles(wallet, 0); - assertEq(platform, "x.com"); - assertEq(boundHandle, "alice"); - assertEq(userId, "12345"); - - // All four fields. A previous review of this change caught a getter - // that returned only the first two and dropped `idPrefix` and - // `idSuffix`. The contract still compiled and the ABI still had the - // selector, so nothing failed until an id-bound proof was verified. - (string memory endpoint, string memory handlePrefix, string memory idPrefix, string memory idSuffix) = - registry.platformConfigs("x.com"); - assertEq(endpoint, "api.x.com/2/users/me"); - assertEq(handlePrefix, '"username":"'); - assertEq(idPrefix, '"id":"'); - assertEq(idSuffix, '"'); - } - - // ─── initialize double-init ───────────────────────────────────── - - function test_initialize_revertsOnDoubleInit() public { - vm.expectRevert(); - registry.initialize(address(notaryContract), backendAddr, address(factory), owner); - } -} diff --git a/solidity/contracts/login/test/TlsLinkGriefPoC.t.sol b/solidity/contracts/login/test/TlsLinkGriefPoC.t.sol deleted file mode 100644 index 63ff62a9..00000000 --- a/solidity/contracts/login/test/TlsLinkGriefPoC.t.sol +++ /dev/null @@ -1,228 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../Registry.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; -import {WalletFactory} from "../WalletFactory.sol"; -import {WebWallet} from "../WebWallet.sol"; - -/// End-to-end PoC: walletAddress baked into the backend signature blocks -/// proof replay from a different `msg.sender`. Victim's proof is signed -/// with `walletAddress = W_v`; attacker tries to replay from W_atk → -/// contract requires `proof.walletAddress == msg.sender` → revert. -contract TlsLinkGriefPoC is Test { - Registry registry; - WalletFactory factory; - - uint256 constant NOTARY_KEY = 0xBEEF; - uint256 constant BACKEND_KEY = 0xBEED; - uint256 constant VICTIM_BOOT_KEY = 0xCA75; // signs victim wallet's executes - uint256 constant VICTIM_LINK_KEY = 0x713C71; // userAddress inside the leaked link proof - uint256 constant ATTACKER_BOOT_KEY = 0xA77ACE; // signs attacker wallet's executes - - address notaryAddr; - address backendAddr; - address victimBootAddr; - address victimLinkAddr; - address attackerBootAddr; - - string constant PLATFORM = "github.com"; - string constant ENDPOINT = "/user"; - string constant HANDLE_PREFIX = '"login":"'; - string constant VICTIM_TARGET = "victim"; - - function setUp() public { - notaryAddr = vm.addr(NOTARY_KEY); - backendAddr = vm.addr(BACKEND_KEY); - victimBootAddr = vm.addr(VICTIM_BOOT_KEY); - victimLinkAddr = vm.addr(VICTIM_LINK_KEY); - attackerBootAddr = vm.addr(ATTACKER_BOOT_KEY); - - WebWallet walletImpl = new WebWallet(); - WalletFactory fImpl = new WalletFactory(); - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), - abi.encodeCall(WalletFactory.initialize, (address(this), address(walletImpl), address(1))) - ) - ) - ); - Registry rImpl = new Registry(); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall( - Registry.initialize, - (address(deployNotary(address(this), notaryAddr)), backendAddr, address(factory), address(this)) - ) - ) - ) - ); - factory.setRegistry(address(registry)); - // GitHub-style bare-number id binding. - registry.setPlatform(PLATFORM, ENDPOINT, HANDLE_PREFIX, '"id":', ","); - } - - /// Deterministic numeric immutable id for a handle (identity keyed by id). - function _idFor(string memory handle) internal pure returns (string memory) { - return vm.toString(uint256(keccak256(bytes(handle))) % 1_000_000_000); - } - - function _sortedHash(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - function _buildTree(string memory handle, string memory userId) - internal - pure - returns ( - bytes32 root, - bytes32[] memory dPath, - bytes32[] memory uPath, - bytes32[] memory ePath, - bytes32[] memory idPath - ) - { - bytes32 dLeaf = keccak256(bytes.concat(keccak256(abi.encodePacked("domain:", bytes(PLATFORM))))); - bytes memory snippet = abi.encodePacked(bytes(HANDLE_PREFIX), bytes(handle), '"'); - bytes32 uLeaf = keccak256(bytes.concat(keccak256(abi.encodePacked("recv:", snippet)))); - bytes32 eLeaf = keccak256(bytes.concat(keccak256(abi.encodePacked("endpoint:", bytes(ENDPOINT))))); - // GitHub bare-number id leaf: "id":, - bytes memory idSnippet = abi.encodePacked('"id":', bytes(userId), ","); - bytes32 idLeaf = keccak256(bytes.concat(keccak256(abi.encodePacked("recv:", idSnippet)))); - - bytes32 p01 = _sortedHash(dLeaf, uLeaf); - bytes32 p23 = _sortedHash(eLeaf, idLeaf); - root = _sortedHash(p01, p23); - - dPath = new bytes32[](2); - dPath[0] = uLeaf; - dPath[1] = p23; - - uPath = new bytes32[](2); - uPath[0] = dLeaf; - uPath[1] = p23; - - ePath = new bytes32[](2); - ePath[0] = idLeaf; - ePath[1] = p01; - - idPath = new bytes32[](2); - idPath[0] = eLeaf; - idPath[1] = p01; - } - - function _buildProof(string memory handle, string memory userId, address userAddr, address walletAddr) - internal - view - returns (Registry.FullTlsProof memory) - { - (bytes32 root, bytes32[] memory dp, bytes32[] memory up, bytes32[] memory ep, bytes32[] memory idp) = - _buildTree(handle, userId); - - bytes32 domainHash = keccak256(bytes(PLATFORM)); - bytes32 cr = bytes32(uint256(0xC1)); - bytes32 sr = bytes32(uint256(0x5E)); - bytes memory sek = hex"deadbeef"; - uint256 ts = block.timestamp; - - bytes32 nd = - keccak256(abi.encode(block.chainid, address(registry), domainHash, cr, sr, keccak256(sek), root, ts)); - bytes32 nEth = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", nd)); - (uint8 nv, bytes32 nr, bytes32 ns) = vm.sign(NOTARY_KEY, nEth); - - bytes32 bd = keccak256(abi.encode(userAddr, walletAddr, root, ts)); - bytes32 bEth = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", bd)); - (uint8 bv, bytes32 br, bytes32 bs) = vm.sign(BACKEND_KEY, bEth); - - return Registry.FullTlsProof({ - notarySignature: abi.encodePacked(nr, ns, nv), - backendSignature: abi.encodePacked(br, bs, bv), - userAddress: userAddr, - walletAddress: walletAddr, - domainHash: domainHash, - clientRandom: cr, - serverRandom: sr, - serverEphemeralKey: sek, - transcriptRoot: root, - timestamp: ts, - domainPath: dp, - usernamePath: up, - endpointPath: ep, - idPath: idp - }); - } - - function _execSig(uint256 key, address walletAddr, address target, bytes memory data) - internal - view - returns (bytes[] memory sigs) - { - uint256 n = WebWallet(payable(walletAddr)).nonce(); - bytes32 digest = keccak256(abi.encode(target, uint256(0), keccak256(data), n, block.chainid, walletAddr)); - bytes32 eth = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, eth); - sigs = new bytes[](1); - sigs[0] = abi.encodePacked(r, s, v); - } - - function _stripSelector(bytes memory data) internal pure returns (bytes memory out) { - out = new bytes(data.length - 4); - for (uint256 i = 0; i < out.length; i++) { - out[i] = data[i + 4]; - } - } - - /// Backend-signed walletAddress blocks proof replay from a different wallet. - function test_TLS_walletBinding_blocksAttackerReplay_allowsVictimSubmission() public { - // Bootstrap victim's wallet via register_session (walletAddress = 0). - Registry.FullTlsProof memory vBoot = - _buildProof("victim_boot", _idFor("victim_boot"), victimBootAddr, address(0)); - registry.register_session(vBoot, PLATFORM, "victim_boot", _idFor("victim_boot"), ENDPOINT); - address victimWallet = registry.resolve(PLATFORM, "victim_boot"); - - // Bootstrap attacker's wallet. - Registry.FullTlsProof memory aBoot = - _buildProof("attacker_boot", _idFor("attacker_boot"), attackerBootAddr, address(0)); - registry.register_session(aBoot, PLATFORM, "attacker_boot", _idFor("attacker_boot"), ENDPOINT); - address attackerWallet = registry.resolve(PLATFORM, "attacker_boot"); - assertTrue(attackerWallet != victimWallet); - - // VICTIM composes a link tx — backend signs the proof bound to W_v. - Registry.FullTlsProof memory linkProof = - _buildProof(VICTIM_TARGET, _idFor(VICTIM_TARGET), victimLinkAddr, victimWallet); - bytes memory victimLinkData = abi.encodeCall( - Registry.linkIdentity, (PLATFORM, VICTIM_TARGET, _idFor(VICTIM_TARGET), linkProof, ENDPOINT) - ); - - // ATTACKER intercepts the raw calldata. - bytes memory args = _stripSelector(victimLinkData); - ( - string memory dPlatform, - string memory dHandle, - string memory dUserId, - Registry.FullTlsProof memory stolenProof, - string memory dEndpoint - ) = abi.decode(args, (string, string, string, Registry.FullTlsProof, string)); - assertEq(stolenProof.walletAddress, victimWallet); - - // ATTACKER tries to replay from their own wallet. Contract requires - // proof.walletAddress == msg.sender, which is now W_atk → revert. - bytes memory attackerLinkData = - abi.encodeCall(Registry.linkIdentity, (dPlatform, dHandle, dUserId, stolenProof, dEndpoint)); - bytes[] memory attackerSigs = _execSig(ATTACKER_BOOT_KEY, attackerWallet, address(registry), attackerLinkData); - vm.expectRevert(); - WebWallet(payable(attackerWallet)).execute(address(registry), 0, attackerLinkData, attackerSigs); - - assertEq(registry.resolve(PLATFORM, VICTIM_TARGET), address(0)); - - // VICTIM's own submission goes through (msg.sender == W_v). - bytes[] memory victimSigs = _execSig(VICTIM_BOOT_KEY, victimWallet, address(registry), victimLinkData); - WebWallet(payable(victimWallet)).execute(address(registry), 0, victimLinkData, victimSigs); - assertEq(registry.resolve(PLATFORM, VICTIM_TARGET), victimWallet); - } -} diff --git a/solidity/contracts/login/test/WalletFactory.t.sol b/solidity/contracts/login/test/WalletFactory.t.sol deleted file mode 100644 index 1cb7c555..00000000 --- a/solidity/contracts/login/test/WalletFactory.t.sol +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {WalletFactory} from "../WalletFactory.sol"; -import {WebWallet} from "../WebWallet.sol"; - -contract WalletFactoryTest is Test { - WalletFactory factory; - WebWallet walletImpl; - - address owner = makeAddr("owner"); - address registry = makeAddr("registry"); - address stranger = makeAddr("stranger"); - - bytes32 constant IDENTITY_ALICE = keccak256(abi.encode("tiktok", "alice")); - - function setUp() public { - walletImpl = new WebWallet(); - WalletFactory fImpl = new WalletFactory(); - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), abi.encodeCall(WalletFactory.initialize, (owner, address(walletImpl), registry)) - ) - ) - ); - } - - function test_initialize_setsBeacon() public view { - assertTrue(address(factory.beacon()) != address(0)); - } - - function test_initialize_setsRegistry() public view { - assertEq(factory.registry(), registry); - } - - function test_deployWebWallet_onlyRegistry() public { - vm.expectRevert(WalletFactory.OnlyRegistry.selector); - factory.deploy_web_wallet("tiktok", "alice", "alice_id"); - } - - function test_deployWebWallet_success() public { - vm.prank(registry); - address wallet = factory.deploy_web_wallet("tiktok", "alice", "alice_id"); - - assertTrue(wallet != address(0)); - assertTrue(factory.isRegisteredWallet(wallet)); - - WebWallet w = WebWallet(payable(wallet)); - assertEq(w.registry(), registry); - assertEq(w.identityCount(), 0); // identity tracked only after session registration - assertEq(w.threshold(), 1); - } - - function test_deployWebWallet_multipleWalletsPerIdentity() public { - vm.startPrank(registry); - address w1 = factory.deploy_web_wallet("tiktok", "alice", "alice_id"); - address w2 = factory.deploy_web_wallet("tiktok", "alice", "alice_id"); - vm.stopPrank(); - - assertTrue(w1 != w2); - assertTrue(factory.isRegisteredWallet(w1)); - assertTrue(factory.isRegisteredWallet(w2)); - } - - function test_upgradeWalletImplementation_onlyOwner() public { - WebWallet newImpl = new WebWallet(); - vm.expectRevert(); - factory.upgradeWalletImplementation(address(newImpl)); - } - - function test_upgradeWalletImplementation_success() public { - WebWallet newImpl = new WebWallet(); - vm.prank(owner); - factory.upgradeWalletImplementation(address(newImpl)); - assertEq(factory.beacon().implementation(), address(newImpl)); - } - - function test_setRegistry_onlyOwner() public { - vm.expectRevert(); - factory.setRegistry(makeAddr("newRegistry")); - } - - function test_setRegistry_success() public { - address newRegistry = makeAddr("newRegistry"); - vm.prank(owner); - factory.setRegistry(newRegistry); - assertEq(factory.registry(), newRegistry); - } -} diff --git a/solidity/contracts/login/test/WebWallet.t.sol b/solidity/contracts/login/test/WebWallet.t.sol deleted file mode 100644 index 95bd70ff..00000000 --- a/solidity/contracts/login/test/WebWallet.t.sol +++ /dev/null @@ -1,723 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {WalletFactory} from "../WalletFactory.sol"; -import {WebWallet} from "../WebWallet.sol"; - -/// @dev Mock registry that accepts register_session calls. -contract MockRegistryForWalletTest { - function registerSession( - WebWallet wallet, - string calldata platform, - string calldata handle, - string calldata userId, - address sessionAddr - ) external { - wallet.register_session(platform, handle, userId, sessionAddr); - } - - function reregisterById( - WebWallet wallet, - string calldata platform, - string calldata handle, - string calldata userId, - address sessionAddr - ) external { - wallet.reregisterSessionById(platform, handle, userId, sessionAddr); - } - - fallback() external payable {} - receive() external payable {} -} - -contract WebWalletTest is Test { - WebWallet wallet; - MockRegistryForWalletTest mockRegistryContract; - address mockRegistry; - - // Sessions are keyed by the immutable (platform, userId), NOT the handle. - string constant ALICE_ID = "alice_id"; - bytes32 constant IDENTITY_ALICE_TT = keccak256(abi.encode("tiktok", ALICE_ID)); - bytes32 constant IDENTITY_ALICE_KEY = keccak256(abi.encode("tiktok", ALICE_ID)); - - uint256 sessionPk = 0xDEAD; - address sessionAddr; - - function setUp() public { - sessionAddr = vm.addr(sessionPk); - - mockRegistryContract = new MockRegistryForWalletTest(); - mockRegistry = address(mockRegistryContract); - - wallet = new WebWallet(); - wallet.initialize(mockRegistry, address(0), "tiktok", "alice"); - - // Register session via mock registry - mockRegistryContract.registerSession(wallet, "tiktok", "alice", ALICE_ID, sessionAddr); - - vm.warp(1_000_000); - } - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - function _execute(address target, uint256 value, bytes memory data, uint256[] memory pks) internal { - uint256 currentNonce = wallet.nonce(); - bytes32 digest = - keccak256(abi.encode(target, value, keccak256(data), currentNonce, block.chainid, address(wallet))); - - bytes[] memory sigs = new bytes[](pks.length); - for (uint256 i = 0; i < pks.length; i++) { - sigs[i] = _sign(pks[i], digest); - } - - wallet.execute(target, value, data, sigs); - } - - // ── register_session ──────────────────────────────────────────── - - function test_registerSession_storesSession() public view { - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 1); - assertEq(wallet.sessionAt(IDENTITY_ALICE_KEY, 0), sessionAddr); - } - - function test_registerSession_setsSession() public view { - assertTrue(wallet.isSession(sessionAddr)); - } - - function test_registerSession_storesIdentity() public view { - assertEq(wallet.sessionIdentity(sessionAddr), IDENTITY_ALICE_KEY); - } - - function test_registerSession_revertsIfNotRegistry() public { - vm.expectRevert(WebWallet.OnlyRegistry.selector); - wallet.register_session("tiktok", "alice", ALICE_ID, sessionAddr); - } - - function test_registerSession_revertsIfZeroAddress() public { - vm.prank(mockRegistry); - vm.expectRevert("zero session"); - wallet.register_session("tiktok", "alice", ALICE_ID, address(0)); - } - - function test_registerSession_newPairAddsIdentity() public { - assertEq(wallet.identityCount(), 1); - // Register session for a new (platform, handle) pair - uint256 pk2 = 0xBEEF; - address addr2 = vm.addr(pk2); - mockRegistryContract.registerSession(wallet, "github.com", "alice", "alice_gh_id", addr2); - assertEq(wallet.identityCount(), 2); - bytes32 ghKey = keccak256(abi.encode("github.com", "alice_gh_id")); - assertTrue(wallet.isIdentity(ghKey)); - } - - function test_registerSession_existingPairJustAddsKey() public { - assertEq(wallet.identityCount(), 1); - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 1); - // Register another session for same pair - uint256 pk2 = 0xBEEF; - address addr2 = vm.addr(pk2); - mockRegistryContract.registerSession(wallet, "tiktok", "alice", ALICE_ID, addr2); - assertEq(wallet.identityCount(), 1); // still 1 identity - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 2); // 2 sessions - } - - // ── initialize ────────────────────────────────────────────────── - - function test_initialize_setsRegistry() public view { - assertEq(wallet.registry(), mockRegistry); - } - - function test_initialize_setsThresholdToOne() public view { - assertEq(wallet.threshold(), 1); - } - - function test_initialize_addsFirstIdentity() public view { - assertTrue(wallet.isIdentity(IDENTITY_ALICE_TT)); - assertEq(wallet.identityCount(), 1); - } - - function test_initialize_storesIdentityFromPlatformHandle() public { - WebWallet w2 = new WebWallet(); - w2.initialize(mockRegistry, address(0), "discord", "bob"); - bytes32 key = keccak256(abi.encode("discord", "bob_id")); - // Identity is only tracked after a session is registered - assertEq(w2.identityCount(), 0); - // Register a session — identity should now appear - uint256 pk2 = 0xCAFE; - address addr2 = vm.addr(pk2); - mockRegistryContract.registerSession(w2, "discord", "bob", "bob_id", addr2); - assertEq(w2.identityCount(), 1); - assertEq(w2.identityAt(0), key); - } - - // ── execute ───────────────────────────────────────────────────── - - function test_execute_incrementsNonce() public { - assertEq(wallet.nonce(), 0); - - uint256[] memory pks = new uint256[](1); - pks[0] = sessionPk; - _execute(mockRegistry, 0, hex"", pks); - assertEq(wallet.nonce(), 1); - } - - function test_execute_duplicatePairCountsOnce() public { - // Register a second session for the same (tiktok, alice) pair - uint256 pk2 = 0xBEEF; - mockRegistryContract.registerSession(wallet, "tiktok", "alice", ALICE_ID, vm.addr(pk2)); - - uint256[] memory pks = new uint256[](2); - pks[0] = sessionPk; - pks[1] = pk2; - - // threshold=1, 1 distinct pair → should succeed - _execute(mockRegistry, 0, hex"", pks); - assertEq(wallet.nonce(), 1); - } - - function test_execute_differentPairsCountSeparately() public { - // Register a session for a different identity - uint256 pk2 = 0xBEEF; - address addr2 = vm.addr(pk2); - mockRegistryContract.registerSession(wallet, "github.com", "alice", "alice_gh_id", addr2); - - // Set threshold to 2 - uint256[] memory pks1 = new uint256[](1); - pks1[0] = sessionPk; - _execute(address(wallet), 0, abi.encodeCall(WebWallet.setThreshold, (2)), pks1); - - // Now need 2 distinct pairs - uint256[] memory pks2 = new uint256[](2); - pks2[0] = sessionPk; - pks2[1] = pk2; - _execute(mockRegistry, 0, hex"", pks2); - assertEq(wallet.nonce(), 2); - } - - function test_execute_revokedSessionDoesNotCount() public { - // Register a second identity so we can revoke alice and test - uint256 pk2 = 0xBEEF; - address addr2 = vm.addr(pk2); - mockRegistryContract.registerSession(wallet, "github.com", "alice", "alice_gh_id", addr2); - - // Revoke the original tiktok/alice session - uint256[] memory pks = new uint256[](1); - pks[0] = sessionPk; - _execute( - address(wallet), 0, abi.encodeCall(WebWallet.revokeSession, ("tiktok", "alice", ALICE_ID, sessionAddr)), pks - ); - - // Now try to use revoked session — should fail (threshold=1, 0 valid sigs) - bytes memory data = hex""; - uint256 currentNonce = wallet.nonce(); - bytes32 digest = keccak256( - abi.encode(mockRegistry, uint256(0), keccak256(data), currentNonce, block.chainid, address(wallet)) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - wallet.execute(mockRegistry, 0, data, sigs); - } - - // ── revokeSession ─────────────────────────────────────────────── - - function test_revokeSession_removesLastSession_identityDisappears() public { - // Revoke via execute (self-call) - uint256[] memory pks = new uint256[](1); - pks[0] = sessionPk; - _execute( - address(wallet), 0, abi.encodeCall(WebWallet.revokeSession, ("tiktok", "alice", ALICE_ID, sessionAddr)), pks - ); - - assertFalse(wallet.isSession(sessionAddr)); - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 0); - assertFalse(wallet.isIdentity(IDENTITY_ALICE_TT)); - assertEq(wallet.identityCount(), 0); // identity removed from array - } - - function test_revokeSession_removesNonLastSession_identityPersists() public { - // Register a second session for the same pair - uint256 pk2 = 0xBEEF; - address addr2 = vm.addr(pk2); - mockRegistryContract.registerSession(wallet, "tiktok", "alice", ALICE_ID, addr2); - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 2); - - // Revoke the first session - uint256[] memory pks = new uint256[](1); - pks[0] = sessionPk; - _execute( - address(wallet), 0, abi.encodeCall(WebWallet.revokeSession, ("tiktok", "alice", ALICE_ID, sessionAddr)), pks - ); - - assertFalse(wallet.isSession(sessionAddr)); - assertTrue(wallet.isSession(addr2)); - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 1); - assertTrue(wallet.isIdentity(IDENTITY_ALICE_TT)); // still active - assertEq(wallet.identityCount(), 1); // identity still in array - } - - function test_revokeSession_revertsIfNotSelf() public { - vm.expectRevert(WebWallet.OnlySelf.selector); - wallet.revokeSession("tiktok", "alice", ALICE_ID, sessionAddr); - } - - function test_revokeSession_revertsIfNotFound() public { - address fake = makeAddr("fake"); - bytes memory revokeData = abi.encodeCall(WebWallet.revokeSession, ("tiktok", "alice", ALICE_ID, fake)); - - uint256 currentNonce = wallet.nonce(); - bytes32 digest = keccak256( - abi.encode(address(wallet), uint256(0), keccak256(revokeData), currentNonce, block.chainid, address(wallet)) - ); - - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - - vm.expectRevert(); // SessionNotFound wrapped in ExecutionFailed - wallet.execute(address(wallet), 0, revokeData, sigs); - } - - // ── upgradeToLatest ───────────────────────────────────────────── - - function test_upgradeToLatest_revertsIfNotSelf() public { - vm.expectRevert("only via execute()"); - wallet.upgradeToLatest(); - } - - /// @notice Happy path: upgradeToLatest via execute() updates implementation slot. - function test_upgradeToLatest_success() public { - WebWallet implV1 = new WebWallet(); - WebWallet implV2 = new WebWallet(); - - // Deploy factory pointing to implV1 - WalletFactory fImpl = new WalletFactory(); - WalletFactory wFactory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), - abi.encodeCall(WalletFactory.initialize, (address(this), address(implV1), mockRegistry)) - ) - ) - ); - - // Deploy wallet proxy via factory (snapshot implV1) - vm.prank(mockRegistry); - WebWallet proxiedWallet = WebWallet(payable(wFactory.deploy_web_wallet("tiktok", "carol", "carol_id"))); - mockRegistryContract.registerSession(proxiedWallet, "tiktok", "carol", "carol_id", sessionAddr); - - // Upgrade beacon to implV2 - wFactory.upgradeWalletImplementation(address(implV2)); - assertEq(wFactory.latestImplementation(), address(implV2)); - - // ERC1967 impl slot - bytes32 implSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1); - address beforeImpl = address(uint160(uint256(vm.load(address(proxiedWallet), implSlot)))); - assertEq(beforeImpl, address(implV1)); - - // Call upgradeToLatest via execute (self-call) - bytes memory data = abi.encodeCall(WebWallet.upgradeToLatest, ()); - uint256 nonce = proxiedWallet.nonce(); - bytes32 digest = keccak256( - abi.encode( - address(proxiedWallet), uint256(0), keccak256(data), nonce, block.chainid, address(proxiedWallet) - ) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - proxiedWallet.execute(address(proxiedWallet), 0, data, sigs); - - address afterImpl = address(uint160(uint256(vm.load(address(proxiedWallet), implSlot)))); - assertEq(afterImpl, address(implV2)); - } - - /// @notice State (sessions, threshold) is preserved after upgradeToLatest. - function test_upgradeToLatest_statePreserved() public { - WebWallet implV1 = new WebWallet(); - WebWallet implV2 = new WebWallet(); - - WalletFactory fImpl = new WalletFactory(); - WalletFactory wFactory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), - abi.encodeCall(WalletFactory.initialize, (address(this), address(implV1), mockRegistry)) - ) - ) - ); - - vm.prank(mockRegistry); - WebWallet proxiedWallet = WebWallet(payable(wFactory.deploy_web_wallet("tiktok", "carol", "carol_id"))); - mockRegistryContract.registerSession(proxiedWallet, "tiktok", "carol", "carol_id", sessionAddr); - - bytes32 identityKey = keccak256(abi.encode("tiktok", "carol_id")); - assertEq(proxiedWallet.sessionCount(identityKey), 1); - assertEq(proxiedWallet.threshold(), 1); - - // Upgrade beacon and call upgradeToLatest - wFactory.upgradeWalletImplementation(address(implV2)); - bytes memory data = abi.encodeCall(WebWallet.upgradeToLatest, ()); - uint256 nonce = proxiedWallet.nonce(); - bytes32 digest = keccak256( - abi.encode( - address(proxiedWallet), uint256(0), keccak256(data), nonce, block.chainid, address(proxiedWallet) - ) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - proxiedWallet.execute(address(proxiedWallet), 0, data, sigs); - - // State unchanged after upgrade - assertEq(proxiedWallet.sessionCount(identityKey), 1); - assertEq(proxiedWallet.sessionAt(identityKey, 0), sessionAddr); - assertEq(proxiedWallet.threshold(), 1); - assertTrue(proxiedWallet.isSession(sessionAddr)); - } - - /// @notice upgradeToLatest is a no-op (same impl) when factory still points to current impl. - function test_upgradeToLatest_noOpIfAlreadyLatest() public { - WebWallet implV1 = new WebWallet(); - - WalletFactory fImpl = new WalletFactory(); - WalletFactory wFactory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), - abi.encodeCall(WalletFactory.initialize, (address(this), address(implV1), mockRegistry)) - ) - ) - ); - - vm.prank(mockRegistry); - WebWallet proxiedWallet = WebWallet(payable(wFactory.deploy_web_wallet("tiktok", "carol", "carol_id"))); - mockRegistryContract.registerSession(proxiedWallet, "tiktok", "carol", "carol_id", sessionAddr); - - bytes32 implSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1); - address beforeImpl = address(uint160(uint256(vm.load(address(proxiedWallet), implSlot)))); - - // Don't upgrade beacon — factory still returns implV1 - bytes memory data = abi.encodeCall(WebWallet.upgradeToLatest, ()); - uint256 nonce = proxiedWallet.nonce(); - bytes32 digest = keccak256( - abi.encode( - address(proxiedWallet), uint256(0), keccak256(data), nonce, block.chainid, address(proxiedWallet) - ) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - proxiedWallet.execute(address(proxiedWallet), 0, data, sigs); - - address afterImpl = address(uint160(uint256(vm.load(address(proxiedWallet), implSlot)))); - assertEq(afterImpl, beforeImpl); - } - - // ── setThreshold ──────────────────────────────────────────────── - - function test_setThreshold_revertsIfNotSelf() public { - vm.expectRevert(WebWallet.OnlySelf.selector); - wallet.setThreshold(1); - } - - function test_setThreshold_revertsIfZero() public { - bytes memory data = abi.encodeCall(WebWallet.setThreshold, (0)); - uint256 currentNonce = wallet.nonce(); - bytes32 digest = keccak256( - abi.encode(address(wallet), uint256(0), keccak256(data), currentNonce, block.chainid, address(wallet)) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - vm.expectRevert(); // "zero threshold" wrapped in ExecutionFailed - wallet.execute(address(wallet), 0, data, sigs); - } - - function test_setThreshold_revertsIfExceedsIdentityCount() public { - // wallet has 1 identity, try to set threshold to 2 - bytes memory data = abi.encodeCall(WebWallet.setThreshold, (2)); - uint256 currentNonce = wallet.nonce(); - bytes32 digest = keccak256( - abi.encode(address(wallet), uint256(0), keccak256(data), currentNonce, block.chainid, address(wallet)) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - vm.expectRevert(); // ThresholdExceedsIdentityCount wrapped in ExecutionFailed - wallet.execute(address(wallet), 0, data, sigs); - } - - function test_setThreshold_successAtIdentityCount() public { - // Add a second identity - uint256 pk2 = 0xBEEF; - mockRegistryContract.registerSession(wallet, "github.com", "alice", "alice_gh_id", vm.addr(pk2)); - assertEq(wallet.identityCount(), 2); - - // Set threshold to 2 — should succeed - uint256[] memory pks = new uint256[](1); - pks[0] = sessionPk; - _execute(address(wallet), 0, abi.encodeCall(WebWallet.setThreshold, (2)), pks); - assertEq(wallet.threshold(), 2); - } - - // ── register_session duplicate ────────────────────────────────── - - function test_registerSession_revertsDuplicate() public { - // sessionAddr is already registered, try to register again - vm.prank(mockRegistry); - vm.expectRevert(WebWallet.SessionAlreadyRegistered.selector); - wallet.register_session("tiktok", "alice", ALICE_ID, sessionAddr); - } - - function test_registerSession_revertsDuplicateAcrossIdentities() public { - // sessionAddr registered for tiktok/alice, try for github/alice - vm.prank(mockRegistry); - vm.expectRevert(WebWallet.SessionAlreadyRegistered.selector); - wallet.register_session("github.com", "alice", "alice_gh_id", sessionAddr); - } - - // ── execute edge cases ────────────────────────────────────────── - - function test_execute_revertsIfNonceWrong() public { - // Use nonce=999 instead of current (0) - bytes memory data = hex""; - bytes32 digest = keccak256( - abi.encode(mockRegistry, uint256(0), keccak256(data), uint256(999), block.chainid, address(wallet)) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - wallet.execute(mockRegistry, 0, data, sigs); - } - - function test_execute_revertsIfChainIdWrong() public { - bytes memory data = hex""; - // Sign with wrong chainId - bytes32 digest = keccak256( - abi.encode(mockRegistry, uint256(0), keccak256(data), uint256(0), uint256(9999), address(wallet)) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - wallet.execute(mockRegistry, 0, data, sigs); - } - - // ── executeBatch ──────────────────────────────────────────────── - - function test_executeBatch_revertsIfLengthMismatch() public { - address[] memory targets = new address[](2); - uint256[] memory values = new uint256[](1); // mismatch - bytes[] memory datas = new bytes[](2); - bytes[] memory sigs = new bytes[](0); - - vm.expectRevert("length mismatch"); - wallet.executeBatch(targets, values, datas, sigs); - } - - function test_executeBatch_emptyBatchSucceeds() public { - address[] memory targets = new address[](0); - uint256[] memory values = new uint256[](0); - bytes[] memory datas = new bytes[](0); - - uint256 currentNonce = wallet.nonce(); - bytes32 dataHashes = keccak256(abi.encode(datas)); - bytes32 digest = - keccak256(abi.encode(targets, values, dataHashes, currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - - wallet.executeBatch(targets, values, datas, sigs); - assertEq(wallet.nonce(), 1); - } - - function test_executeBatch_revertsIfAnyCallFails() public { - // First call succeeds (noop to mock registry), second reverts - address[] memory targets = new address[](2); - uint256[] memory values = new uint256[](2); - bytes[] memory datas = new bytes[](2); - - targets[0] = mockRegistry; - targets[1] = address(wallet); - values[0] = 0; - values[1] = 0; - datas[0] = hex""; - datas[1] = abi.encodeCall(WebWallet.setThreshold, (0)); // will revert - - uint256 currentNonce = wallet.nonce(); - bytes32 dataHashes = keccak256(abi.encode(datas)); - bytes32 digest = - keccak256(abi.encode(targets, values, dataHashes, currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - - vm.expectRevert(); // ExecutionFailed - wallet.executeBatch(targets, values, datas, sigs); - // Nonce should NOT have incremented - assertEq(wallet.nonce(), 0); - } - - function test_executeBatch_incrementsNonceCorrectly() public { - // Two noop calls - address[] memory targets = new address[](2); - uint256[] memory values = new uint256[](2); - bytes[] memory datas = new bytes[](2); - - targets[0] = mockRegistry; - targets[1] = mockRegistry; - values[0] = 0; - values[1] = 0; - datas[0] = hex""; - datas[1] = hex""; - - uint256 currentNonce = wallet.nonce(); - bytes32 dataHashes = keccak256(abi.encode(datas)); - bytes32 digest = - keccak256(abi.encode(targets, values, dataHashes, currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(sessionPk, digest); - - wallet.executeBatch(targets, values, datas, sigs); - assertEq(wallet.nonce(), 1); // only +1 per batch, not per call - } - - // ── revokeSession cleans up identityKeys ──────────────────────── - - function test_revokeSession_lastSessionCleansUpIdentityKeys() public { - // Already tested above but let's verify identityAt is cleaned - assertEq(wallet.identityCount(), 1); - assertEq(wallet.identityAt(0), IDENTITY_ALICE_KEY); - - uint256[] memory pks = new uint256[](1); - pks[0] = sessionPk; - _execute( - address(wallet), 0, abi.encodeCall(WebWallet.revokeSession, ("tiktok", "alice", ALICE_ID, sessionAddr)), pks - ); - - assertEq(wallet.identityCount(), 0); - } - - // ── initialize double-init ────────────────────────────────────── - - function test_initialize_revertsOnDoubleInit() public { - vm.expectRevert(); - wallet.initialize(mockRegistry, address(0), "tiktok", "alice"); - } - - // ── id-keying: rename stability ───────────────────────────────── - // - // A handle rename must NOT move a session. Sessions are keyed by the - // immutable (platform, userId); the handle is display-only. - - function test_rename_sameIdKeepsSessionsUnderOneIdentity() public { - // setUp registered tiktok/alice under ALICE_ID with display handle "alice". - // Simulate a rename: register another session under the SAME (tiktok, ALICE_ID) - // but with a NEW display handle. - uint256 pk2 = 0xBEEF; - address addr2 = vm.addr(pk2); - mockRegistryContract.registerSession(wallet, "tiktok", "alice_renamed", ALICE_ID, addr2); - - // Both sessions live under the SAME identity key — the rename did not - // create a new identity. - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 2); - assertEq(wallet.identityCount(), 1); - assertEq(wallet.sessionIdentity(addr2), IDENTITY_ALICE_KEY); - - // Revoking by the NEW display handle + same userId works, proving the key - // is the id, not the handle. - uint256[] memory pks = new uint256[](1); - pks[0] = sessionPk; - _execute( - address(wallet), - 0, - abi.encodeCall(WebWallet.revokeSession, ("tiktok", "alice_renamed", ALICE_ID, addr2)), - pks - ); - assertFalse(wallet.isSession(addr2)); - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 1); - assertEq(wallet.identityCount(), 1); - } - - // ── id-keying: threshold counts distinct ids ──────────────────── - - function test_threshold_countsDistinctIdsNotSessions() public { - // Second session under the SAME id (tiktok, ALICE_ID) — different display handle. - uint256 pkSameId = 0xBEEF; - address addrSameId = vm.addr(pkSameId); - mockRegistryContract.registerSession(wallet, "tiktok", "alice_alt", ALICE_ID, addrSameId); - - // A genuinely distinct identity (github, alice_gh_id). - uint256 pkOtherId = 0xF00D; - address addrOtherId = vm.addr(pkOtherId); - mockRegistryContract.registerSession(wallet, "github.com", "alice", "alice_gh_id", addrOtherId); - assertEq(wallet.identityCount(), 2); - - // Raise threshold to 2 (2 distinct identities exist). - uint256[] memory one = new uint256[](1); - one[0] = sessionPk; - _execute(address(wallet), 0, abi.encodeCall(WebWallet.setThreshold, (2)), one); - assertEq(wallet.threshold(), 2); - - bytes memory noop = hex""; - uint256 n = wallet.nonce(); - bytes32 digest = - keccak256(abi.encode(mockRegistry, uint256(0), keccak256(noop), n, block.chainid, address(wallet))); - - // Two sessions under the SAME id → 1 distinct identity → below threshold=2. - bytes[] memory sameIdSigs = new bytes[](2); - sameIdSigs[0] = _sign(sessionPk, digest); - sameIdSigs[1] = _sign(pkSameId, digest); - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - wallet.execute(mockRegistry, 0, noop, sameIdSigs); - - // Two sessions under DISTINCT ids → 2 distinct identities → meets threshold=2. - bytes[] memory distinctSigs = new bytes[](2); - distinctSigs[0] = _sign(sessionPk, digest); - distinctSigs[1] = _sign(pkOtherId, digest); - wallet.execute(mockRegistry, 0, noop, distinctSigs); - assertEq(wallet.nonce(), 2); // setThreshold(+1) then this exec(+1) - } - - // ── id-keying: reregisterSessionById migration ────────────────── - - function test_reregisterSessionById_noOpWhenAlreadyIdKeyed() public { - // sessionAddr is already under the id key (tiktok, ALICE_ID); reregister is a no-op. - mockRegistryContract.reregisterById(wallet, "tiktok", "alice", ALICE_ID, sessionAddr); - assertTrue(wallet.isSession(sessionAddr)); - assertEq(wallet.sessionCount(IDENTITY_ALICE_KEY), 1); - assertEq(wallet.sessionIdentity(sessionAddr), IDENTITY_ALICE_KEY); - } - - function test_reregisterSessionById_migratesLegacyHandleKeyToIdKey() public { - // Seed a "legacy" session by registering with userId == handle, which yields - // the pre-upgrade key keccak256(platform, handle). - uint256 legacyPk = 0xABCD; - address legacyAddr = vm.addr(legacyPk); - mockRegistryContract.registerSession(wallet, "discord", "carol", "carol", legacyAddr); - bytes32 legacyKey = keccak256(abi.encode("discord", "carol")); - bytes32 idKey = keccak256(abi.encode("discord", "carol_real_id")); - assertEq(wallet.sessionCount(legacyKey), 1); - assertEq(wallet.sessionCount(idKey), 0); - - // Migrate it to the immutable id key. - mockRegistryContract.reregisterById(wallet, "discord", "carol", "carol_real_id", legacyAddr); - - assertEq(wallet.sessionCount(legacyKey), 0); - assertEq(wallet.sessionCount(idKey), 1); - assertEq(wallet.sessionAt(idKey, 0), legacyAddr); - assertEq(wallet.sessionIdentity(legacyAddr), idKey); - assertTrue(wallet.isSession(legacyAddr)); - } - - function test_reregisterSessionById_revertsIfNotRegistry() public { - vm.expectRevert(WebWallet.OnlyRegistry.selector); - wallet.reregisterSessionById("tiktok", "alice", ALICE_ID, sessionAddr); - } - - function test_reregisterSessionById_revertsOnEmptyUserId() public { - vm.prank(mockRegistry); - vm.expectRevert("empty userId"); - wallet.reregisterSessionById("tiktok", "alice", "", sessionAddr); - } -} diff --git a/solidity/contracts/login/test/fixtures/oidc/jwks-proof.json b/solidity/contracts/login/test/fixtures/oidc/jwks-proof.json deleted file mode 100644 index 891e1bbd..00000000 --- a/solidity/contracts/login/test/fixtures/oidc/jwks-proof.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "notary_signature": "0xac2dd7d49bc1d763ebec55bf3ea8d4ca3057a6c990a84658cbbc6bda6c6f8fc62b8bf782ecc81fdbe17a49a9900c5ce8599d99bdd4bdba199d98a5d63d3ec7d21c", - "domain_hash": "0x59962e64d51a4d36e444d18d029aff438ce3ddb882eb151a0af0fdde3cb655ee", - "client_random": "0x0000000000000000000000000000000000000000000000000000000000000000", - "server_random": "0x0000000000000000000000000000000000000000000000000000000000000000", - "server_ephemeral_key": "0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "transcript_root": "0xb66b4209a1c2e90f9bd4318986e32bffce84ba9cadd2690426dc0e19e78924c3", - "timestamp": 1777900352, - "domain_path": [ - "0x3a48ec7938d008c4fbdfc033b9c0ada126a46089e460e5f3af5eda1352f9a4ee", - "0x4fd66d65911c483bedf4d98f568b8f40ce2cc7b652a9cf849b269f07de10c1cd" - ], - "endpoint_path": [ - "0x2fc77341079e14a1103642484ef49d836a16a456f497722239924495e377d4a8", - "0x4fd66d65911c483bedf4d98f568b8f40ce2cc7b652a9cf849b269f07de10c1cd" - ], - "claims": [ - { - "kid": "19caaecde8f485e8f5938f48aba0ce7a3581f027", - "n_b64url": "xGoOLta_JnpIStUy0UMlbQRnMZhRZ3MJqm3TQMb2nXQwstM91mEhjjGikBJoCA4sGXyq9SQmLcicQ-ezhnTD5TCqbLhjz_NndK4IUUaUcG0eOxYMnG0LlLJhZ4BonBjA7m-xDDL-vO7wSVi0t9xbYi4abO48NqJQn65rlE-mhIc15tgLawIm9oq7yoI1jYYsn3Ik_PMp-h8W-CHiUSxDhYfkIuMtnMoBwpjGLiWjzlV-yBNce1V1iKRK7uIuyuypAL3SKAcHCfxQEfAFBUuf3pM2qGcaBCtPpW096QaXdTs8_Tjoq7DwsmaPLOtK5EKzkDUcp0noRlDWB6fPP-rUMw", - "jwk_bytes": "0x7b0a2020202020202265223a202241514142222c0a202020202020226b7479223a2022525341222c0a20202020202022616c67223a20225253323536222c0a20202020202022757365223a2022736967222c0a202020202020226e223a202278476f4f4c74615f4a6e70495374557930554d6c6251526e4d5a68525a334d4a716d3354514d62326e58517773744d39316d45686a6a47696b424a6f43413473475879713953516d4c636963512d657a686e544435544371624c686a7a5f4e6e644b344955556155634730654f78594d6e47304c6c4c4a685a34426f6e426a41376d2d7844444c2d764f3777535669307439786259693461624f34384e714a516e3635726c452d6d684963313574674c6177496d396f7137796f49316a5959736e33496b5f504d702d6838572d434869555378446859666b49754d746e4d6f4277706a474c69576a7a6c562d79424e6365315631694b524b3775497579757970414c33534b41634843667851456641464255756633704d3271476361424374507057303936516158645473385f546a6f71374477736d61504c4f744b35454b7a6b44556370306e6f526c445742366650502d72554d77222c0a202020202020226b6964223a202231396361616563646538663438356538663539333866343861626130636537613335383166303237220a202020207d", - "jwk_path": [ - "0xc757d6ad13f0c4383803188f05d92c1386c91ee1b378bfd61107e8dcd6c73e58", - "0x546e7f882e139fc1436c0b1a28055a8c72d534e32529335e9297875dc7ab05ae" - ] - }, - { - "kid": "8076ddbab415556e169154c6a4a0bdbd4469b79c", - "n_b64url": "uPQl9hY_shV81dJgVp7qJdGcOsEkLD5g6EdpBOuzzul4THxflhFAkzJACPUSUdQpOIw5pKHD9sGhnLoqnOu-n_AdbJtAI_NG56KWawoZY1YQRr6GqJxccMTeh2vEdaM6haiYKbftbCk7RWsvWia0gPhiIfC6_D3OwI6ZVIsMp-U9fTDBo2wEG5fA4DIZn_QU8TAyzi4BJbgQYXmy3LETW-90v5S3ub7WFsaGqL7NEdxER_RQ6m7L2Pl4jIjbN8bkNKeUslXYm56MH1x3hD5RqeKJoZylpgT00XVHs4br3p1iSz4t8uPvvy0Q2oNEVkXgv36i7QK4s_oe_qhZFfW_UQ", - "jwk_bytes": "0x7b0a202020202020226b7479223a2022525341222c0a2020202020202265223a202241514142222c0a20202020202022757365223a2022736967222c0a20202020202022616c67223a20225253323536222c0a202020202020226e223a20227550516c3968595f7368563831644a67567037714a6447634f73456b4c44356736456470424f757a7a756c34544878666c6846416b7a4a4143505553556451704f497735704b4844397347686e4c6f716e4f752d6e5f4164624a7441495f4e4735364b5761776f5a5931595152723647714a7863634d54656832764564614d36686169594b62667462436b37525773765769613067506869496643365f44334f7749365a5649734d702d5539665444426f327745473566413444495a6e5f5155385441797a6934424a62675159586d79334c4554572d3930763553337562375746736147714c374e45647845525f5251366d374c32506c346a496a624e38626b4e4b6555736c58596d35364d483178336844355271654b4a6f5a796c70675430305856487334627233703169537a34743875507676793051326f4e45566b58677633366937514b34735f6f655f71685a4666575f5551222c0a202020202020226b6964223a202238303736646462616234313535353665313639313534633661346130626462643434363962373963220a202020207d", - "jwk_path": [ - "0x5faffac008736e1a823a01a782add8c6911e47106674955396c01954f5f7e301", - "0x546e7f882e139fc1436c0b1a28055a8c72d534e32529335e9297875dc7ab05ae" - ] - } - ] -} \ No newline at end of file diff --git a/solidity/contracts/login/test/fixtures/oidc/jwks-snapshot.json b/solidity/contracts/login/test/fixtures/oidc/jwks-snapshot.json deleted file mode 100644 index e4e96524..00000000 --- a/solidity/contracts/login/test/fixtures/oidc/jwks-snapshot.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "keys": [ - { - "e": "AQAB", - "kty": "RSA", - "alg": "RS256", - "use": "sig", - "n": "xGoOLta_JnpIStUy0UMlbQRnMZhRZ3MJqm3TQMb2nXQwstM91mEhjjGikBJoCA4sGXyq9SQmLcicQ-ezhnTD5TCqbLhjz_NndK4IUUaUcG0eOxYMnG0LlLJhZ4BonBjA7m-xDDL-vO7wSVi0t9xbYi4abO48NqJQn65rlE-mhIc15tgLawIm9oq7yoI1jYYsn3Ik_PMp-h8W-CHiUSxDhYfkIuMtnMoBwpjGLiWjzlV-yBNce1V1iKRK7uIuyuypAL3SKAcHCfxQEfAFBUuf3pM2qGcaBCtPpW096QaXdTs8_Tjoq7DwsmaPLOtK5EKzkDUcp0noRlDWB6fPP-rUMw", - "kid": "19caaecde8f485e8f5938f48aba0ce7a3581f027" - }, - { - "kty": "RSA", - "e": "AQAB", - "use": "sig", - "alg": "RS256", - "n": "uPQl9hY_shV81dJgVp7qJdGcOsEkLD5g6EdpBOuzzul4THxflhFAkzJACPUSUdQpOIw5pKHD9sGhnLoqnOu-n_AdbJtAI_NG56KWawoZY1YQRr6GqJxccMTeh2vEdaM6haiYKbftbCk7RWsvWia0gPhiIfC6_D3OwI6ZVIsMp-U9fTDBo2wEG5fA4DIZn_QU8TAyzi4BJbgQYXmy3LETW-90v5S3ub7WFsaGqL7NEdxER_RQ6m7L2Pl4jIjbN8bkNKeUslXYm56MH1x3hD5RqeKJoZylpgT00XVHs4br3p1iSz4t8uPvvy0Q2oNEVkXgv36i7QK4s_oe_qhZFfW_UQ", - "kid": "8076ddbab415556e169154c6a4a0bdbd4469b79c" - } - ] -} diff --git a/solidity/contracts/login/test/oidc/GoogleOidcVerifierFlow.t.sol b/solidity/contracts/login/test/oidc/GoogleOidcVerifierFlow.t.sol deleted file mode 100644 index 35c0ba9c..00000000 --- a/solidity/contracts/login/test/oidc/GoogleOidcVerifierFlow.t.sol +++ /dev/null @@ -1,616 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; - -import "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {HonkVerifier} from "../../oidc/Verifier.sol"; -import {GoogleOidcVerifier} from "../../oidc/GoogleOidcVerifier.sol"; -import {deployNotary} from "../../../notary/test/DeployNotary.sol"; - -/// End-to-end: TLSN-notarized JWKS rotation, then a Honk-proof JWT -/// validation through the GoogleOidcVerifier. -/// -/// Reads two on-disk fixtures: -/// * `contracts/login/test/fixtures/oidc/jwks-proof.json` - rotation proof -/// (notary-signed with the deterministic test key, address -/// `0x6f4c…b87a`). -/// * `circuits/jwt_email/target/{proof,public_inputs,proof_meta.json}` -/// - a Honk proof of a real Google JWT. These are produced by the -/// OIDC CLI on demand and *not* committed (the proof's `exp` -/// ages out within an hour, so refreshing is part of the test's -/// bootstrap). -/// -/// When the per-run circuit artifacts are absent, the test logs a -/// clear "skipped" message and returns - so the suite stays green in -/// CI environments that don't run the CLI. -contract GoogleOidcVerifierFlowTest is Test { - GoogleOidcVerifier verifier; - HonkVerifier honk; - - address constant NOTARY = 0x6f4c950442e1Af093BcfF730381E63Ae9171b87a; - address constant OWNER = address(0xA11CE); - /// Client id the verifier is initialized with. The per-run proof fixture is - /// generated for its own (env) client id, so the fixture-backed tests - /// override the expected hash from the proof's own public inputs via - /// `_configureAudienceFromFixture` rather than relying on this value. - string constant CLIENT_ID = "test-client-id.apps.googleusercontent.com"; - - GoogleOidcVerifier.NotarizedJwksProof rotProof; - GoogleOidcVerifier.JwkClaim[] rotClaims; - uint256 rotTimestamp; - - bytes honkProof; - bytes32[] honkPub; - string jwtEmail; - string jwtSub; - address sessionAddress; - uint256 jwtExp; - bool fixturesPresent; - - function setUp() public { - honk = new HonkVerifier(); - // Deploy behind a UUPS proxy (same shape as production). - GoogleOidcVerifier impl = new GoogleOidcVerifier(); - // rotate()/rotateRoots() are permissionless — no rotator wiring needed. - verifier = GoogleOidcVerifier( - address( - new ERC1967Proxy( - address(impl), - abi.encodeCall( - GoogleOidcVerifier.initialize, (honk, OWNER, address(deployNotary(OWNER, NOTARY)), CLIENT_ID) - ) - ) - ) - ); - - _loadRotation(); - fixturesPresent = _tryLoadUserProof(); - - // Always warp inside the rotation freshness window so the - // rotation-only test runs without the per-run circuit - // artifacts. When the JWT fixtures ARE present, narrow to a - // moment that's inside both freshness windows. - uint256 t = rotTimestamp + 60; - if (fixturesPresent) { - uint256 jwtAnchor = jwtExp - 60; - if (jwtAnchor > t) t = jwtAnchor; - } - vm.warp(t); - } - - // ─── Fixture loaders ────────────────────────────────────────────── - - function _loadRotation() internal { - string memory j = vm.readFile("contracts/login/test/fixtures/oidc/jwks-proof.json"); - rotProof.notarySignature = vm.parseJsonBytes(j, ".notary_signature"); - rotProof.domainHash = vm.parseJsonBytes32(j, ".domain_hash"); - rotProof.clientRandom = vm.parseJsonBytes32(j, ".client_random"); - rotProof.serverRandom = vm.parseJsonBytes32(j, ".server_random"); - rotProof.serverEphemeralKey = vm.parseJsonBytes(j, ".server_ephemeral_key"); - rotProof.transcriptRoot = vm.parseJsonBytes32(j, ".transcript_root"); - rotTimestamp = vm.parseJsonUint(j, ".timestamp"); - rotProof.timestamp = rotTimestamp; - rotProof.domainPath = vm.parseJsonBytes32Array(j, ".domain_path"); - rotProof.endpointPath = vm.parseJsonBytes32Array(j, ".endpoint_path"); - - for (uint256 i = 0; i < 16; i++) { - string memory base = string.concat(".claims[", vm.toString(i), "]"); - try this._probeKid(j, base) returns (string memory kidStr) { - GoogleOidcVerifier.JwkClaim memory c; - c.kid = bytes(kidStr); - c.nB64url = bytes(vm.parseJsonString(j, string.concat(base, ".n_b64url"))); - c.jwkBytes = vm.parseJsonBytes(j, string.concat(base, ".jwk_bytes")); - c.jwkPath = vm.parseJsonBytes32Array(j, string.concat(base, ".jwk_path")); - rotClaims.push(c); - } catch { - break; - } - } - require(rotClaims.length > 0, "no rotation claims"); - } - - function _probeKid(string memory j, string memory base) external view returns (string memory) { - return vm.parseJsonString(j, string.concat(base, ".kid")); - } - - function _tryLoadUserProof() internal returns (bool) { - try this._readBinary("circuits/jwt_email/target/proof") returns (bytes memory proof) { - honkProof = proof; - } catch { - return false; - } - bytes memory raw; - try this._readBinary("circuits/jwt_email/target/public_inputs") returns (bytes memory pub) { - raw = pub; - } catch { - return false; - } - // 28 public inputs: modulus[18] + email[2] + nonce[2] + sub[1] - // + exp[1] + audience_hash[2] + chain_id[1] + registry_addr[1]. - if (raw.length != 28 * 32) return false; - for (uint256 i = 0; i < 28; i++) { - bytes32 v; - assembly { - v := mload(add(add(raw, 32), mul(i, 32))) - } - honkPub.push(v); - } - string memory meta; - try this._readText("circuits/jwt_email/target/proof_meta.json") returns (string memory m) { - meta = m; - } catch { - return false; - } - jwtEmail = vm.parseJsonString(meta, ".email"); - jwtSub = vm.parseJsonString(meta, ".sub"); - sessionAddress = vm.parseAddress(vm.parseJsonString(meta, ".session_address")); - jwtExp = vm.parseJsonUint(meta, ".exp"); - return true; - } - - function _readBinary(string calldata path) external view returns (bytes memory) { - return vm.readFileBinary(path); - } - - function _readText(string calldata path) external view returns (string memory) { - return vm.readFile(path); - } - - function _userProofBytes() internal view returns (bytes memory) { - GoogleOidcVerifier.UserProof memory u = GoogleOidcVerifier.UserProof({ - honkProof: honkProof, publicInputs: honkPub, email: jwtEmail, sessionKey: sessionAddress, sub: jwtSub - }); - return abi.encode(u); - } - - // ─── Tests ──────────────────────────────────────────────────────── - - /// Rotation succeeds and at least one kid lands in storage. Doesn't - /// require the per-run circuit artifacts. - function test_rotation_installs_kids() public { - vm.prank(OWNER); - verifier.rotate(rotProof, rotClaims); - bytes32 kid0Hash = keccak256(rotClaims[0].kid); - assertGt(verifier.expiresAtKid(kid0Hash), block.timestamp, "rotation didn't store kid"); - } - - /// Rotation is PERMISSIONLESS: any caller may submit. This is the point of - /// removing the rotator role — a third-party keeper must be able to keep - /// JWKS fresh without being allowlisted by us. - function test_rotation_permissionless_anyCaller() public { - vm.prank(address(0xBADBAD)); - verifier.rotate(rotProof, rotClaims); - assertGt( - verifier.expiresAtKid(keccak256(rotClaims[0].kid)), - block.timestamp, - "unauthorized caller should have been able to rotate" - ); - } - - /// Re-submitting the same proof is IDEMPOTENT, not a revert. With an open - /// caller set a one-shot nullifier would itself be the attack: a front-runner - /// could consume the digest and brick the honest keeper. - function test_rotation_replay_isIdempotent() public { - verifier.rotate(rotProof, rotClaims); - bytes32 kid0 = keccak256(rotClaims[0].kid); - bytes32 mod0 = verifier.modulusOfKid(kid0); - - vm.prank(address(0xBADBAD)); - verifier.rotate(rotProof, rotClaims); // must NOT revert - - assertEq(verifier.modulusOfKid(kid0), mod0, "replay changed the modulus"); - assertGt(verifier.expiresAtKid(kid0), block.timestamp); - } - - /// A partial submission must not block a later fuller one — the griefing - /// vector the digest nullifier would have created once anyone can call. - function test_rotation_subsetDoesNotBlockFullRotation() public { - GoogleOidcVerifier.JwkClaim[] memory empty = new GoogleOidcVerifier.JwkClaim[](0); - vm.prank(address(0xBADBAD)); - verifier.rotate(rotProof, empty); // front-runner submits nothing useful - - verifier.rotate(rotProof, rotClaims); // honest keeper still lands - assertGt( - verifier.expiresAtKid(keccak256(rotClaims[0].kid)), - block.timestamp, - "subset submission blocked the real rotation" - ); - } - - /// What actually prevents an old proof regressing a newer modulus is the - /// freshness window, NOT the caller gate — so it must still bite. - function test_rotation_staleProof_stillRejected() public { - vm.warp(block.timestamp + verifier.FRESHNESS_WINDOW() + 1); - vm.expectRevert(GoogleOidcVerifier.StaleProof.selector); - verifier.rotate(rotProof, rotClaims); - } - - /// Replaying an OLDER proof inside the freshness window must not roll a kid - /// back to a modulus Google has already retired, nor re-extend its TTL. The - /// caller gate used to make this unreachable; with rotation permissionless - /// anyone can replay a still-fresh proof, so the per-kid `rotatedAtKid` - /// guard is what holds the invariant. - /// - /// Uses a verifier whose notary key the test controls, so it can re-sign the - /// fixture's transcript at a later timestamp (the claims still merkle-verify - /// against the same root). - function test_rotation_olderProofDoesNotRollBackNewer() public { - uint256 notaryPk = 0xB0B; - GoogleOidcVerifier v = _deployWithNotary(vm.addr(notaryPk)); - - GoogleOidcVerifier.NotarizedJwksProof memory older = rotProof; - older.notarySignature = _sign(notaryPk, _digest(older)); - - GoogleOidcVerifier.NotarizedJwksProof memory newer = rotProof; - newer.timestamp = rotProof.timestamp + 10 minutes; - newer.notarySignature = _sign(notaryPk, _digest(newer)); - - // Newer rotation lands first. - vm.warp(newer.timestamp + 1); - v.rotate(newer, rotClaims); - - bytes32 kid0 = keccak256(rotClaims[0].kid); - uint256 provenAfterNewer = v.rotatedAtKid(kid0); - bytes32 modulusAfterNewer = v.modulusOfKid(kid0); - uint256 expiryAfterNewer = v.expiresAtKid(kid0); - assertEq(provenAfterNewer, newer.timestamp, "newer rotation did not record its timestamp"); - - // Anyone replays the OLDER, still-fresh proof later. - vm.warp(older.timestamp + 50 minutes); - vm.prank(address(0xBADBAD)); - v.rotate(older, rotClaims); - - assertEq(v.rotatedAtKid(kid0), provenAfterNewer, "older proof overwrote the provenance stamp"); - assertEq(v.modulusOfKid(kid0), modulusAfterNewer, "older proof rolled the modulus back"); - assertEq(v.expiresAtKid(kid0), expiryAfterNewer, "older proof re-extended the TTL"); - } - - function _deployWithNotary(address notary_) internal returns (GoogleOidcVerifier) { - GoogleOidcVerifier impl = new GoogleOidcVerifier(); - return GoogleOidcVerifier( - address( - new ERC1967Proxy( - address(impl), - abi.encodeCall( - GoogleOidcVerifier.initialize, (honk, OWNER, address(deployNotary(OWNER, notary_)), CLIENT_ID) - ) - ) - ) - ); - } - - /// Mirrors GoogleOidcVerifier._notaryDigest — deliberately an independent - /// reimplementation so a change to the digest breaks this test loudly. - function _digest(GoogleOidcVerifier.NotarizedJwksProof memory p) internal pure returns (bytes32) { - return keccak256( - abi.encode( - p.domainHash, - p.clientRandom, - p.serverRandom, - keccak256(p.serverEphemeralKey), - p.transcriptRoot, - p.timestamp - ) - ); - } - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - /// Resubmitting the exact reading already applied writes nothing — in - /// particular it does NOT re-stamp the TTL, so spamming one proof for its - /// whole freshness window cannot stretch trust by even a second. - function test_rotation_identicalResubmission_doesNotRestampTtl() public { - verifier.rotate(rotProof, rotClaims); - bytes32 kid0 = keccak256(rotClaims[0].kid); - uint256 expiry = verifier.expiresAtKid(kid0); - - vm.warp(rotTimestamp + 50 minutes); // still inside the freshness window - vm.prank(address(0xBADBAD)); - verifier.rotate(rotProof, rotClaims); - - assertEq(verifier.expiresAtKid(kid0), expiry, "replay re-stamped the TTL"); - } - - /// A rotation that hands a kid a NEW key retires the key it used to carry - /// immediately — `_verifyAndExtract` resolves by modulus, so leaving the - /// old entry would keep a retired key usable for the rest of its TTL. - function test_rotation_replacingAKidRetiresItsOldKey() public { - uint256 notaryPk = 0xB0B; - GoogleOidcVerifier v = _deployWithNotary(vm.addr(notaryPk)); - - bytes32 first = _installKid(v, notaryPk, "kid-1", "modulus-one", block.timestamp); - assertGt(v.trustedHashExpiresAt(first), 0, "first key trusted"); - - bytes32 second = _installKid(v, notaryPk, "kid-1", "modulus-two", block.timestamp + 1); - assertGt(v.trustedHashExpiresAt(second), 0, "second key trusted"); - assertEq(v.trustedHashExpiresAt(first), 0, "the replaced key must lose trust NOW"); - } - - /// Time alone retires a key, and anyone may then prune the bookkeeping. - /// The monotonic `rotatedAtKid` floor survives the prune, so an old - /// reading still cannot resurrect what time retired. - function test_prune_permissionless_reclaimsExpiredKeys() public { - verifier.rotate(rotProof, rotClaims); - bytes32 kid0 = keccak256(rotClaims[0].kid); - bytes32 mod0 = verifier.modulusOfKid(kid0); - - vm.warp(verifier.expiresAtKid(kid0)); // the stamp is spent - vm.prank(address(0xBADBAD)); - verifier.prune(); - - assertEq(verifier.modulusOfKid(kid0), bytes32(0), "kid still tracked"); - assertEq(verifier.trustedHashExpiresAt(mod0), 0, "spent stamp not cleared"); - assertEq(verifier.currentRoots().length, 0, "enumeration not reclaimed"); - assertEq(verifier.rotatedAtKid(kid0), rotTimestamp, "provenance floor lost"); - } - - /// Pruning is a no-op while the keys are alive. - function test_prune_leavesLiveKeysAlone() public { - verifier.rotate(rotProof, rotClaims); - uint256 before = verifier.currentRoots().length; - verifier.prune(); - assertEq(verifier.currentRoots().length, before, "a live key was pruned"); - } - - /// What a keeper reads: the whole list in one call, the freshest reading - /// mark, and the single needs-rotation bit. - function test_keeperViews_describeRotationState() public { - assertTrue(verifier.needsRotation(), "an empty list needs a rotation"); - assertEq(verifier.freshestObservedAt(), 0); - - verifier.rotate(rotProof, rotClaims); - - assertFalse(verifier.needsRotation(), "a fresh rotation buys quiet"); - assertEq(verifier.freshestObservedAt(), rotTimestamp, "the mark is the reading's timestamp"); - - GoogleOidcVerifier.RootInfo[] memory infos = verifier.currentRoots(); - assertGt(infos.length, 0); - for (uint256 i = 0; i < infos.length; i++) { - assertEq(infos[i].modulusHash, verifier.modulusOfKid(infos[i].kidHash)); - assertEq(infos[i].observedAt, rotTimestamp); - assertEq(infos[i].expiresAt, verifier.expiresAtKid(infos[i].kidHash)); - } - - bytes32 kid0 = keccak256(rotClaims[0].kid); - vm.warp(verifier.expiresAtKid(kid0) - verifier.RENEWAL_MARGIN()); - assertTrue(verifier.needsRotation(), "the margin should trip before expiry does"); - } - - /// The owner's only lever over the key list: removing trust early. It is - /// owner-gated, and rotation (which anyone can submit) undoes it. - function test_untrustModulus_ownerOnly_removesTrust() public { - verifier.rotate(rotProof, rotClaims); - bytes32 mod0 = verifier.modulusOfKid(keccak256(rotClaims[0].kid)); - - vm.prank(address(0xBADBAD)); - vm.expectRevert(); - verifier.untrustModulus(mod0); - - vm.prank(OWNER); - verifier.untrustModulus(mod0); - assertEq(verifier.trustedHashExpiresAt(mod0), 0, "trust should be gone"); - } - - // ─── Synthetic-reading helpers (mirror IdentityJwksRoots.t.sol) ── - - /// Install `kid` carrying a key derived from `seed` into `v`, via a full - /// synthetic notarized reading signed with `notaryPk` at `provedAt`. - function _installKid( - GoogleOidcVerifier v, - uint256 notaryPk, - string memory kid, - string memory seed, - uint256 provedAt - ) internal returns (bytes32) { - bytes memory nB64 = _syntheticModulus(seed); - bytes memory jwk = abi.encodePacked('{"kid":"', kid, '","n":"', nB64, '"}'); - - bytes32 domainLeaf = _syntheticLeaf("domain:", bytes("www.googleapis.com")); - bytes32 endpointLeaf = _syntheticLeaf("endpoint:", bytes("/oauth2/v3/certs")); - bytes32 jwkLeaf = _syntheticLeaf("recv:", jwk); - bytes32 pair = _syntheticHashPair(domainLeaf, endpointLeaf); - bytes32 root = _syntheticHashPair(pair, jwkLeaf); - - GoogleOidcVerifier.NotarizedJwksProof memory p; - p.domainHash = keccak256(bytes("www.googleapis.com")); - p.clientRandom = keccak256("client"); - p.serverRandom = keccak256("server"); - p.serverEphemeralKey = bytes("ephemeral-key"); - p.transcriptRoot = root; - p.timestamp = provedAt; - p.domainPath = new bytes32[](2); - p.domainPath[0] = endpointLeaf; - p.domainPath[1] = jwkLeaf; - p.endpointPath = new bytes32[](2); - p.endpointPath[0] = domainLeaf; - p.endpointPath[1] = jwkLeaf; - p.notarySignature = _sign(notaryPk, _digest(p)); - - GoogleOidcVerifier.JwkClaim[] memory one = new GoogleOidcVerifier.JwkClaim[](1); - one[0].jwkBytes = jwk; - one[0].jwkPath = new bytes32[](1); - one[0].jwkPath[0] = pair; - one[0].kid = bytes(kid); - one[0].nB64url = nB64; - - vm.warp(provedAt + 1); - v.rotate(p, one); - return v.modulusOfKid(keccak256(bytes(kid))); - } - - /// A 2048-bit modulus written straight in base64url: 342 characters decode - /// to exactly the 256 bytes the contract requires. - function _syntheticModulus(string memory seed) internal pure returns (bytes memory out) { - bytes memory alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - bytes32 filler = keccak256(bytes(seed)); - out = new bytes(342); - for (uint256 i = 0; i < 342; i++) { - out[i] = alphabet[uint8(filler[i % 32]) & 0x3f]; - } - } - - function _syntheticLeaf(string memory prefix, bytes memory value) internal pure returns (bytes32) { - return keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - } - - function _syntheticHashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - /// Renouncing ownership is disabled — it would brick the UUPS upgrade path. - function test_renounceOwnership_disabled() public { - vm.prank(OWNER); - vm.expectRevert(bytes("renounce disabled")); - verifier.renounceOwnership(); - } - - // ─── UUPS upgrade ─────────────────────────────────────────────── - - function test_upgrade_revertsIfNotOwner() public { - GoogleOidcVerifier newImpl = new GoogleOidcVerifier(); - vm.prank(address(0xBEEF)); - vm.expectRevert(); - verifier.upgradeToAndCall(address(newImpl), ""); - } - - function test_upgrade_byOwner_preservesState() public { - // Seed state, then upgrade and confirm it survives. - verifier.rotate(rotProof, rotClaims); - bytes32 kid0Hash = keccak256(rotClaims[0].kid); - uint256 expiryBefore = verifier.expiresAtKid(kid0Hash); - assertGt(expiryBefore, 0, "precondition: kid installed"); - - GoogleOidcVerifier newImpl = new GoogleOidcVerifier(); - vm.prank(OWNER); - verifier.upgradeToAndCall(address(newImpl), ""); - - assertEq(verifier.owner(), OWNER, "owner preserved"); - assertEq(verifier.expiresAtKid(kid0Hash), expiryBefore, "rotated kid preserved"); - // Rotation is permissionless and idempotent across the upgrade. - verifier.rotate(rotProof, rotClaims); - assertEq(verifier.expiresAtKid(kid0Hash), expiryBefore, "replay changed state after upgrade"); - } - - /// User-side verification fails when no rotation has happened (the - /// modulus is not yet trusted). - function test_verify_fails_before_rotation() public { - if (!fixturesPresent) { - emit log("SKIPPED: circuits/jwt_email/target/proof artifacts absent - run oidc-noir-cli to refresh"); - return; - } - vm.expectRevert(GoogleOidcVerifier.UntrustedModulus.selector); - verifier.verifyAndExtract(_userProofBytes()); - } - - /// Full pipeline: rotate, then verify. The verifier returns the - /// validated email plaintext (matches the Registry's `(platform, - /// handle)` storage shape - see Registry.register_session_oidc). - function test_full_pipeline_rotate_then_verify() public { - if (!fixturesPresent) { - emit log("SKIPPED: circuits/jwt_email/target/proof artifacts absent - run oidc-noir-cli to refresh"); - return; - } - // Configure the verifier with the plaintext client id the proof fixture - // was generated for (GMAIL_CLIENT_ID, the same value dev-start feeds the - // prover and the deploy). This drives the real setExpectedAudience path - // (on-chain SHA-256), so a passing proof proves the contract's audience - // check — hashing AND the 16-byte half split — matches the circuit, not - // a value echoed back from the proof itself. - string memory clientId = vm.envOr("GMAIL_CLIENT_ID", string("")); - if (bytes(clientId).length == 0) { - emit log("SKIPPED: set GMAIL_CLIENT_ID (the client id the proof fixture was generated for) to run this test"); - return; - } - vm.prank(OWNER); - verifier.rotate(rotProof, rotClaims); - vm.prank(OWNER); - verifier.setExpectedAudience(clientId); - (string memory handle, address sessionKey, uint256 expiresAt, string memory userId) = verifier.verifyDirect( - GoogleOidcVerifier.UserProof({ - honkProof: honkProof, publicInputs: honkPub, email: jwtEmail, sessionKey: sessionAddress, sub: jwtSub - }) - ); - assertEq(handle, jwtEmail, "verifier should hand back the validated email plaintext"); - assertEq(sessionKey, sessionAddress, "session key passes through"); - assertEq(expiresAt, jwtExp, "exp comes from the JWT public input"); - assertEq(userId, jwtSub, "sub comes from the JWT public input"); - } - - /// Confused-deputy guard: a valid proof whose `aud` is some OTHER Google - /// OAuth app is rejected. Same fixture, but the verifier is configured for a - /// different client id, so its audience-hash public inputs don't match. - function test_verify_rejects_wrong_audience() public { - if (!fixturesPresent) { - emit log("SKIPPED: circuits/jwt_email/target/proof artifacts absent - run oidc-noir-cli to refresh"); - return; - } - vm.prank(OWNER); - verifier.rotate(rotProof, rotClaims); - // Configure a different app than the one the proof was minted for. - vm.prank(OWNER); - verifier.setExpectedAudience("some-other-app.apps.googleusercontent.com"); - vm.expectRevert(GoogleOidcVerifier.WrongAudience.selector); - verifier.verifyDirect( - GoogleOidcVerifier.UserProof({ - honkProof: honkProof, publicInputs: honkPub, email: jwtEmail, sessionKey: sessionAddress, sub: jwtSub - }) - ); - } - - // ─── Audience configuration ───────────────────────────────────── - - /// initialize pins the accepted client id: the stored hash is SHA-256 of it. - function test_initialize_configuresAudience() public view { - assertEq(verifier.expectedAudienceHash(), sha256(bytes(CLIENT_ID)), "init should hash the client id"); - } - - /// The owner can rotate which app may register; the hash tracks the id. - function test_setExpectedAudience_hashesClientId() public { - string memory newId = "another-app.apps.googleusercontent.com"; - vm.prank(OWNER); - verifier.setExpectedAudience(newId); - assertEq(verifier.expectedAudienceHash(), sha256(bytes(newId)), "hash should follow the new client id"); - } - - /// Audience config is owner-gated (both the string and raw-hash setters). - function test_setExpectedAudience_onlyOwner() public { - vm.prank(address(0xBADBAD)); - vm.expectRevert(); - verifier.setExpectedAudience("x.apps.googleusercontent.com"); - - vm.prank(address(0xBADBAD)); - vm.expectRevert(); - verifier.setExpectedAudienceHash(bytes32(uint256(1))); - } - - /// An empty client id / zero hash is rejected — never leaves the verifier - /// silently accepting nothing (or, via the zero-guard, everything). - function test_setExpectedAudience_rejectsEmpty() public { - vm.prank(OWNER); - vm.expectRevert(GoogleOidcVerifier.EmptyAudience.selector); - verifier.setExpectedAudience(""); - - vm.prank(OWNER); - vm.expectRevert(GoogleOidcVerifier.EmptyAudience.selector); - verifier.setExpectedAudienceHash(bytes32(0)); - } - - /// A client id longer than the circuit's AUDIENCE_MAX (128) is rejected at - /// config time — otherwise it would silently make every proof revert. - function test_setExpectedAudience_rejectsTooLong() public { - string memory tooLong = new string(uint256(verifier.MAX_AUDIENCE_BYTES()) + 1); - vm.prank(OWNER); - vm.expectRevert(GoogleOidcVerifier.AudienceTooLong.selector); - verifier.setExpectedAudience(tooLong); - - // Exactly MAX_AUDIENCE_BYTES is still accepted (boundary). - string memory atLimit = new string(uint256(verifier.MAX_AUDIENCE_BYTES())); - vm.prank(OWNER); - verifier.setExpectedAudience(atLimit); - assertEq(verifier.expectedAudienceHash(), sha256(bytes(atLimit)), "boundary length should be accepted"); - } -} diff --git a/solidity/contracts/login/test/oidc/README.md b/solidity/contracts/login/test/oidc/README.md deleted file mode 100644 index 88bc32da..00000000 --- a/solidity/contracts/login/test/oidc/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# OIDC contract tests - -Tests for `contracts/login/oidc/IOidcVerifier.sol` and its implementers -(`GoogleOidcVerifier.sol`) plus the new `Registry.register_session_oidc` -entry point. - -## Running - -```bash -forge test -vv -``` - -(These run in the default profile — the OIDC verifier compiles fine under -`via_ir = true` with the current solc/bb toolchain.) - -## Fixtures - -Two kinds of fixtures, in different states of permanence: - -### Committed fixtures (`contracts/login/test/fixtures/oidc/`) - -- `jwks-snapshot.json` — verbatim Google JWKS response, captured once at - fixture-generation time. The rotation proof binds to this exact byte - sequence via Merkle leaves. -- `jwks-proof.json` — TLSN-notarized attestation over the snapshot, - signed by the deterministic test notary `0x6f4c…b87a` (privkey - `0x00…0042`). The `timestamp` in this proof must overlap with the - user JWT's `exp` for the integration test to pass. Freshness window: - `jwksProof.timestamp + ROTATION_TTL >= jwtExp > block.timestamp` - (see `GoogleOidcVerifier` constants). - -### Per-run fixtures (`circuits/jwt_email/target/`) - -- `proof`, `public_inputs`, `proof_meta.json` — produced by running - the OIDC prover CLI from the original monorepo (after the Rust lift - completes). These ARE gitignored: each is bound to a specific OAuth - code + session keypair + JWT `exp`, so they go stale after ~1 hour. - -A test that depends on these guards itself with a file-existence check -and emits a clear "skipped" message rather than failing CI. To -regenerate, run the CLI (web flow OAuths against Google live, writes -all three files): - -```bash -# in the OIDC prover CLI's directory (original monorepo) -cargo run --release -``` - -The CLI drives an OAuth → JWT → Noir witness → proof → fixture-write -pipeline; the fixtures it emits feed back into `forge test`. diff --git a/solidity/contracts/login/test/oidc/RegistryOidcLinkFlow.t.sol b/solidity/contracts/login/test/oidc/RegistryOidcLinkFlow.t.sol deleted file mode 100644 index 18df006b..00000000 --- a/solidity/contracts/login/test/oidc/RegistryOidcLinkFlow.t.sol +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../../Registry.sol"; -import {WalletFactory} from "../../WalletFactory.sol"; -import {WebWallet} from "../../WebWallet.sol"; -import {IOidcVerifier} from "../../oidc/IOidcVerifier.sol"; -import {deployNotary} from "../../../notary/test/DeployNotary.sol"; - -// A JWT expiry far in the future but well below uint256 max, so the -// `jwtExp + CLOCK_SKEW_GRACE` freshness check can't overflow. -uint256 constant FUTURE = 4_000_000_000; - -/// Stand-in OIDC verifier returning whatever the test set up, so the -/// Registry link dispatcher can be exercised without a real JWT/circuit. -contract StubOidcVerifier is IOidcVerifier { - string public handle = "alice@gmail.com"; - address public nonceAddr; // returned as the `sessionKey`/nonce field - uint256 public expiresAt = FUTURE; - string public userId = "sub_alice"; // immutable JWT `sub` - - function set(string memory h, address a, uint256 e) external { - handle = h; - nonceAddr = a; - expiresAt = e; - // Derive a unique id per handle so distinct identities map to - // distinct wallets (identity keys on the id). - userId = string(abi.encodePacked("sub_", h)); - } - - function setUserId(string memory u) external { - userId = u; - } - - function verifyAndExtract(bytes calldata) - external - view - override - returns (string memory, address, uint256, string memory) - { - return (handle, nonceAddr, expiresAt, userId); - } - - function rotateRoots(bytes calldata) external override {} - - function platformName() external pure override returns (string memory) { - return "stub-oidc"; - } -} - -/// Dispatcher-level tests for `Registry.link_identity_oidc`. The link path -/// attaches an OIDC handle to the *calling* wallet (front-run safe: the JWT -/// nonce must equal msg.sender), instead of deploying a new wallet the way -/// `register_session_oidc` does. -contract RegistryOidcLinkFlowTest is Test { - Registry registry; - WalletFactory factory; - StubOidcVerifier verifier; - - address constant OWNER = address(0xA11CE); - address constant NOTARY = address(0xB0B); - address constant BACKEND = address(0xBE); - string constant PLATFORM = "www.googleapis.com"; - - address wallet; // an existing registered WebWallet (the link target) - - function setUp() public { - WebWallet walletImpl = new WebWallet(); - WalletFactory fImpl = new WalletFactory(); - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), abi.encodeCall(WalletFactory.initialize, (OWNER, address(walletImpl), address(1))) - ) - ) - ); - - Registry rImpl = new Registry(); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall( - Registry.initialize, (address(deployNotary(OWNER, NOTARY)), BACKEND, address(factory), OWNER) - ) - ) - ) - ); - - vm.prank(OWNER); - factory.setRegistry(address(registry)); - - verifier = new StubOidcVerifier(); - vm.prank(OWNER); - registry.setOidcVerifier(PLATFORM, IOidcVerifier(address(verifier))); - - // Stand up an existing wallet by registering a first handle. - verifier.set("existing@gmail.com", address(0xC0FFEE), FUTURE); - registry.register_session_oidc(PLATFORM, hex""); - wallet = registry.resolve(PLATFORM, "existing@gmail.com"); - assertTrue(wallet != address(0)); - } - - function test_link_happy_attachesHandleToCallingWallet() public { - // nonce bound to the calling wallet; fresh session key for the handle. - verifier.set("alice@gmail.com", wallet, FUTURE); - vm.prank(wallet); - registry.link_identity_oidc(PLATFORM, hex"", address(0xDECAF)); - - assertEq(registry.resolve(PLATFORM, "alice@gmail.com"), wallet); - // Sessions are keyed by the immutable platform id (userId), not the - // handle (WebWallet._identityKey(platform, userId) since #111). - assertEq( - WebWallet(payable(wallet)).sessionAt(keccak256(abi.encode(PLATFORM, verifier.userId())), 0), - address(0xDECAF) - ); - } - - function test_link_walletMismatch_reverts() public { - // nonce bound to a different address than the caller → front-run guard. - verifier.set("alice@gmail.com", address(0xBEEF), FUTURE); - vm.prank(wallet); - vm.expectRevert(Registry.LinkWalletMismatch.selector); - registry.link_identity_oidc(PLATFORM, hex"", address(0xDECAF)); - } - - function test_link_unregisteredCaller_reverts() public { - address stranger = address(0x5727A6); - verifier.set("alice@gmail.com", stranger, FUTURE); - vm.prank(stranger); - vm.expectRevert(Registry.NotRegisteredWallet.selector); - registry.link_identity_oidc(PLATFORM, hex"", address(0xDECAF)); - } - - function test_link_idAlreadyLinkedToOtherWallet_reverts() public { - // wallet links alice's id. - verifier.set("alice@gmail.com", wallet, FUTURE); - vm.prank(wallet); - registry.link_identity_oidc(PLATFORM, hex"", address(0xDECAF)); - - // Stand up a second registered wallet. - verifier.set("second@gmail.com", address(0xBEEF11), FUTURE); - registry.register_session_oidc(PLATFORM, hex""); - address wallet2 = registry.resolve(PLATFORM, "second@gmail.com"); - assertTrue(wallet2 != address(0) && wallet2 != wallet); - - // wallet2 tries to link alice's id (already owned by `wallet`) → conflict. - verifier.set("alice@gmail.com", wallet2, FUTURE); - vm.prank(wallet2); - vm.expectRevert(Registry.IdentityAlreadyLinked.selector); - registry.link_identity_oidc(PLATFORM, hex"", address(0xFADE)); - } - - function test_link_unsetPlatform_reverts() public { - verifier.set("alice@gmail.com", wallet, FUTURE); - vm.prank(wallet); - vm.expectRevert(Registry.OidcVerifierNotSet.selector); - registry.link_identity_oidc("not.configured", hex"", address(0xDECAF)); - } - - function test_link_expiredJwt_reverts() public { - vm.warp(1_000_000); - // expiresAt + CLOCK_SKEW_GRACE <= now → JwtExpired. - verifier.set("alice@gmail.com", wallet, 1); - vm.prank(wallet); - vm.expectRevert(Registry.JwtExpired.selector); - registry.link_identity_oidc(PLATFORM, hex"", address(0xDECAF)); - } -} diff --git a/solidity/contracts/login/zk/IZkSessionVerifier.sol b/solidity/contracts/login/zk/IZkSessionVerifier.sol deleted file mode 100644 index 70a17478..00000000 --- a/solidity/contracts/login/zk/IZkSessionVerifier.sol +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// Plug-in ZK identity-attestation module for the `Registry`. -/// -/// One implementation per platform (X, GitHub-via-JWT, TikTok, …). The -/// verifier owns the platform-specific trust anchors (notary key, OAuth -/// client_id, expected request line) and the matching UltraHonk verifier -/// address. Registry stays small — it dispatches by `platform`, lets the -/// verifier validate the proof, and writes the resulting identity → -/// wallet mapping. -/// -/// The return tuple includes a `nullifier` so the Registry can prevent a -/// stolen bearer from being used twice with different session keys — -/// TLSN-based flows must enforce this explicitly (OIDC-based flows get it -/// for free from JWT `exp` + fresh session keys). -interface IZkSessionVerifier { - /// Verify a ZK proof + notary attestations and return the validated - /// identity binding. - /// - /// `proof` is platform-specific ABI-encoded bytes — the verifier - /// owns the codec. Returns: - /// * `handle` — human-readable identity from the proof (e.g. - /// X username). Registry uses it directly as the - /// handle in its `(platform, handle) → wallet` map. - /// * `sessionKey` — Ethereum address the user is binding to. - /// * `expiresAt` — proof validity deadline (unix seconds). - /// * `nullifier` — domain-separated commitment to the OAuth bearer. - /// Registry checks `usedBearerNullifiers[nullifier]` - /// and stores it, blocking bearer-theft → - /// handle-takeover when the same bearer is - /// presented twice with different session keys. - /// Returns wallet binding committed in the proof's public inputs. - /// Registry enforces semantics: `address(0)` for `register_session_zk`, - /// `msg.sender` for `link_identity_zk`. - /// * `userId` — immutable platform user-id from the same proof - /// ("" when unrevealed → handle-key fallback). Registry - /// keys identity by `(platform, userId)` when non-empty. - function verifyAndExtract(bytes calldata proof) - external - view - returns ( - string memory handle, - address sessionKey, - address walletAddress, - uint256 expiresAt, - bytes32 nullifier, - string memory userId - ); - - /// Human-readable platform name (e.g. `"api.x.com"`). Used for events - /// and debugging. - function platformName() external view returns (string memory); -} diff --git a/solidity/contracts/login/zk/XHonkVerifier.sol b/solidity/contracts/login/zk/XHonkVerifier.sol deleted file mode 100644 index 545cdb96..00000000 --- a/solidity/contracts/login/zk/XHonkVerifier.sol +++ /dev/null @@ -1,2461 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2022 Aztec -pragma solidity >=0.8.21; - -uint256 constant N = 131072; -uint256 constant LOG_N = 17; -uint256 constant NUMBER_OF_PUBLIC_INPUTS = 144; -uint256 constant VK_HASH = 0x0ef6521315935086e2161c8343e6e402c97e16770e1db9d53955fe1ca8f86e67; - -library HonkVerificationKey { - function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { - Honk.VerificationKey memory vk = Honk.VerificationKey({ - circuitSize: uint256(131072), - logCircuitSize: uint256(17), - publicInputsSize: uint256(144), - ql: Honk.G1Point({ - x: uint256(0x0867889e76e121ff0ea46eb576fcdb9c764fdd115b01afa7e87b4e3099a2d5ce), - y: uint256(0x07d5b79455c10ea61469a2e382bd963a2f3d67153f0d82e4baee4ef6292becca) - }), - qr: Honk.G1Point({ - x: uint256(0x1715640053f18abe9dafeffc0d5bf5a80cb94eea47f5151555d1e67196676a48), - y: uint256(0x0be32585b8dacfb89120c4484d930de40f141371c75349c7734c4e471c7eddef) - }), - qo: Honk.G1Point({ - x: uint256(0x21ac9f103b1060f6776c5c2b93187360a43079337274e56941ed1ab451142c0a), - y: uint256(0x05647644458128f9509002290b192eb4332e81b00e4bf3bb3f4a83d354c7457a) - }), - q4: Honk.G1Point({ - x: uint256(0x2e7705b2249a201426c5f0dc076eed5f558c60d3ac0c5106f0aaf6d8d03e0e75), - y: uint256(0x0c942280f10309799f8cd9551f484e443bc915bc6c8462d59a21d2211f091cc1) - }), - qm: Honk.G1Point({ - x: uint256(0x2ea756acd0551fbd2a3575f5dc7d014b3b540a19e8a5f28ce8d984db79dfb1b1), - y: uint256(0x25d81ad5e534eebfc6615185e8d96829e9d1758f845758b3013c7f8772cea266) - }), - qc: Honk.G1Point({ - x: uint256(0x0888b3e3d7970e927d86475e7acf0c9c58dfe02aa63ca893ce2ec445a58043af), - y: uint256(0x09771d4d862332c7950d6e3ca05505dadb29a19fda0802ace1a9e8a6b57f1911) - }), - qLookup: Honk.G1Point({ - x: uint256(0x176962206a3bada5864d88e7cb7963c6226a9a49f74960944b1e5ccb2c4d32f8), - y: uint256(0x2fc0e8090751c5c4c66adec65200f5170dbc1f35f5f0a841207104c69694baa4) - }), - qArith: Honk.G1Point({ - x: uint256(0x00f3b169567705eaae7f55fca77b4730bda0b33e30bf821a0df03ecec3ea2da0), - y: uint256(0x0a7cc4a834c095b42672cc204a50f96cdbb6acd0f0734b1511555f64eba98b64) - }), - qDeltaRange: Honk.G1Point({ - x: uint256(0x0ff6bcebe0d10a75dc162bf2c493cb826fe6a763b53fe105956909b275b788f6), - y: uint256(0x04907b4d2ffa96283dc3d84540b5d43da0cfac86c9b0a9e593b9e89d28581417) - }), - qElliptic: Honk.G1Point({ - x: uint256(0x25156f1a49a5638fd864b71e81699df764ebb5613a54d870865534c68a1ef931), - y: uint256(0x0568de5ea5bb65f2673e4e6d50654820e0ad03a4dd508e3e2ef996289b4e579c) - }), - qMemory: Honk.G1Point({ - x: uint256(0x1fd96610c3eba1a291d75d93604b466974e732390ae9c43c4d07d9e2111f3280), - y: uint256(0x166484296c985b88dc75aa05b4a827325d43ada70d79b06477caa4054cf843f1) - }), - qNnf: Honk.G1Point({ - x: uint256(0x28f202265512dde7a5a697d52ed54fc10c7601836a3b3a490afacf8a140fd40a), - y: uint256(0x2d05e6f99b6988423f69c308148b707b067377fe899e028ce73137b0cbf80657) - }), - qPoseidon2External: Honk.G1Point({ - x: uint256(0x18a209b92eb5368292daa81262ced374f49bdb1dbc2a371d402d1327a7991513), - y: uint256(0x09ec706b3f521933afb919beeb32e090a25145ebcea055dd069b3b1878561f84) - }), - qPoseidon2Internal: Honk.G1Point({ - x: uint256(0x2693828b993cb5ff8161abbc576a91e80f505919f95da7bff495288ea76fd89d), - y: uint256(0x039c4f79c2bb9a1a185f35694fb80eae4307f97dc9dd58b062ce91e539caeb8b) - }), - s1: Honk.G1Point({ - x: uint256(0x264eadf3c9ab93582340c9e06e1735ba606bcf411c027c5d6bbec5f20d51514f), - y: uint256(0x12a802d923f9a9c7359e0095b43c36fd30794193f12878846ace49f811cb0db7) - }), - s2: Honk.G1Point({ - x: uint256(0x21c3a35965ac30ef5388ceb4f70b58366e34ba3d85c97714bb27b72d5ce3fef5), - y: uint256(0x0b3d1c44a295f45bb439f4e3ed9c2b1f9bd8d328f7dcd06a845b1c521f4f5f38) - }), - s3: Honk.G1Point({ - x: uint256(0x1eecc81a55fa8c08c709e243a712e349406ae8b0883409366f3148561690b602), - y: uint256(0x0ba194a1bc5e39c5e5a4c727932dd752fcdedef069e885fe1b7747b29354585d) - }), - s4: Honk.G1Point({ - x: uint256(0x0fb2756602018406076881fd28c7cf3ec5daeb9bc4230534a225333e241389ae), - y: uint256(0x016d223be53bedfae5bf6bf3db97976c9ba245f2ab52ad8aae3b873ec832a95a) - }), - t1: Honk.G1Point({ - x: uint256(0x1c6e54d5ec7c8998c794bcb74f4b05723704102c0d45e77533d65d3b3e678fce), - y: uint256(0x282bd3c84f22fb16a533a5b1bd161a0b70b6e0bcbd708d7e141c49e782c9f0b4) - }), - t2: Honk.G1Point({ - x: uint256(0x10eb2b9935f0821f9dca62a3c429e283b566a2449937709e675779c66c3475fa), - y: uint256(0x0a7d17cc6ff2341ca13bea8c672443ca80223cd46685dbf5e9d168be6b8dc0ca) - }), - t3: Honk.G1Point({ - x: uint256(0x0f4f6ad8247c82205ff8736e08980ff8a0787aa6f9c19abd7cf1b2fe9917862d), - y: uint256(0x305661f3a851ebc1af312aecf40c1d39d2f7c6cb8d736d56322dffc97b83322c) - }), - t4: Honk.G1Point({ - x: uint256(0x2988c1ae84ecafedb9fe29695b3884f727c7240dc6e5991fbff42eea3f42b0f3), - y: uint256(0x2a4a2e89796bf81d6feb9e7678bb0e6f29283d220c0590dc6b7e7bcb2aa6bbdd) - }), - id1: Honk.G1Point({ - x: uint256(0x22ffa5f8e5014ee9926d54f97d50c66c3bad3834b6c4cb3df86582ccc7adf78c), - y: uint256(0x0779b2c4bf48ad4ce2b03a8264cab4a8e284e94b58e0bb2a712ddabafc887bf9) - }), - id2: Honk.G1Point({ - x: uint256(0x197e66ad0062d38f6d7a09181720c639d71d76f8ad69befa54f2362145cafde5), - y: uint256(0x15efc53bb45872c4db79526d05a9c1f86672843c8daadb509d0239034a80c2e0) - }), - id3: Honk.G1Point({ - x: uint256(0x2fe9c6b5c56527700584b279a38fdd94221b359482b3da13cb8707fd639a251c), - y: uint256(0x2084fff46655bf593b12063d6b9af2bf8dac34bea210a4ff6f9da678ad53afad) - }), - id4: Honk.G1Point({ - x: uint256(0x28a6cd231f5d48498bf31c8d0f9fdfe593b8e23fa62b3c70d8178084c42f1a75), - y: uint256(0x27cde83c1c7d331f50a28425d09dc5d05149d4439a5246da1067692d366c6a76) - }), - lagrangeFirst: Honk.G1Point({ - x: uint256(0x0000000000000000000000000000000000000000000000000000000000000001), - y: uint256(0x0000000000000000000000000000000000000000000000000000000000000002) - }), - lagrangeLast: Honk.G1Point({ - x: uint256(0x06276f4b34f397425ca87ad494829f15cd4486b6868bcade5523892abfb774af), - y: uint256(0x1908ba2901b815796a23c1b26c18757b807a4356819d91a02a6b9479a905d1e9) - }) - }); - return vk; - } -} - -pragma solidity ^0.8.27; - -interface IVerifier { - function verify(bytes calldata _proof, bytes32[] calldata _publicInputs) external view returns (bool); -} - -/** - * @notice Library of error codes - * @dev You can run `forge inspect Errors errors` to get the selectors for the optimised verifier - */ -library Errors { - error ValueGeLimbMax(); - error ValueGeGroupOrder(); - error ValueGeFieldOrder(); - - error InvertOfZero(); - error NotPowerOfTwo(); - error ModExpFailed(); - - error ProofLengthWrong(); - error ProofLengthWrongWithLogN(uint256 logN, uint256 actualLength, uint256 expectedLength); - error PublicInputsLengthWrong(); - error SumcheckFailed(); - error ShpleminiFailed(); - - error PointAtInfinity(); - - error ConsistencyCheckFailed(); - error GeminiChallengeInSubgroup(); -} - -type Fr is uint256; - -using {add as +} for Fr global; -using {sub as -} for Fr global; -using {mul as *} for Fr global; - -using {notEqual as !=} for Fr global; -using {equal as ==} for Fr global; - -uint256 constant SUBGROUP_SIZE = 256; -uint256 constant MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617; // Prime field order -uint256 constant P = MODULUS; -Fr constant SUBGROUP_GENERATOR = Fr.wrap(0x07b0c561a6148404f086204a9f36ffb0617942546750f230c893619174a57a76); -Fr constant SUBGROUP_GENERATOR_INVERSE = Fr.wrap(0x204bd3277422fad364751ad938e2b5e6a54cf8c68712848a692c553d0329f5d6); -Fr constant MINUS_ONE = Fr.wrap(MODULUS - 1); -Fr constant ONE = Fr.wrap(1); -Fr constant ZERO = Fr.wrap(0); -// Instantiation - -library FrLib { - bytes4 internal constant FRLIB_MODEXP_FAILED_SELECTOR = 0xf8d61709; - - function invert(Fr value) internal view returns (Fr) { - uint256 v = Fr.unwrap(value); - require(v != 0, Errors.InvertOfZero()); - - uint256 result; - - // Call the modexp precompile to invert in the field - assembly ("memory-safe") { - let free := mload(0x40) - mstore(free, 0x20) - mstore(add(free, 0x20), 0x20) - mstore(add(free, 0x40), 0x20) - mstore(add(free, 0x60), v) - mstore(add(free, 0x80), sub(MODULUS, 2)) - mstore(add(free, 0xa0), MODULUS) - let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) - if iszero(success) { - mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) - revert(0, 0x04) - } - result := mload(0x00) - mstore(0x40, add(free, 0xc0)) - } - - return Fr.wrap(result); - } - - function pow(Fr base, uint256 v) internal view returns (Fr) { - uint256 b = Fr.unwrap(base); - // Only works for power of 2 - require(v > 0 && (v & (v - 1)) == 0, Errors.NotPowerOfTwo()); - uint256 result; - - // Call the modexp precompile to invert in the field - assembly ("memory-safe") { - let free := mload(0x40) - mstore(free, 0x20) - mstore(add(free, 0x20), 0x20) - mstore(add(free, 0x40), 0x20) - mstore(add(free, 0x60), b) - mstore(add(free, 0x80), v) - mstore(add(free, 0xa0), MODULUS) - let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) - if iszero(success) { - mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) - revert(0, 0x04) - } - result := mload(0x00) - mstore(0x40, add(free, 0xc0)) - } - - return Fr.wrap(result); - } - - function div(Fr numerator, Fr denominator) internal view returns (Fr) { - unchecked { - return numerator * invert(denominator); - } - } - - function sqr(Fr value) internal pure returns (Fr) { - unchecked { - return value * value; - } - } - - function unwrap(Fr value) internal pure returns (uint256) { - unchecked { - return Fr.unwrap(value); - } - } - - function neg(Fr value) internal pure returns (Fr) { - unchecked { - return Fr.wrap(MODULUS - Fr.unwrap(value)); - } - } - - function from(uint256 value) internal pure returns (Fr) { - unchecked { - require(value < MODULUS, Errors.ValueGeFieldOrder()); - return Fr.wrap(value); - } - } - - function fromBytes32(bytes32 value) internal pure returns (Fr) { - unchecked { - uint256 v = uint256(value); - require(v < MODULUS, Errors.ValueGeFieldOrder()); - return Fr.wrap(v); - } - } - - function toBytes32(Fr value) internal pure returns (bytes32) { - unchecked { - return bytes32(Fr.unwrap(value)); - } - } -} - -// Free functions -function add(Fr a, Fr b) pure returns (Fr) { - unchecked { - return Fr.wrap(addmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); - } -} - -function mul(Fr a, Fr b) pure returns (Fr) { - unchecked { - return Fr.wrap(mulmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); - } -} - -function sub(Fr a, Fr b) pure returns (Fr) { - unchecked { - return Fr.wrap(addmod(Fr.unwrap(a), MODULUS - Fr.unwrap(b), MODULUS)); - } -} - -function notEqual(Fr a, Fr b) pure returns (bool) { - unchecked { - return Fr.unwrap(a) != Fr.unwrap(b); - } -} - -function equal(Fr a, Fr b) pure returns (bool) { - unchecked { - return Fr.unwrap(a) == Fr.unwrap(b); - } -} - -uint256 constant CONST_PROOF_SIZE_LOG_N = 25; - -uint256 constant NUMBER_OF_SUBRELATIONS = 28; -uint256 constant BATCHED_RELATION_PARTIAL_LENGTH = 8; -uint256 constant ZK_BATCHED_RELATION_PARTIAL_LENGTH = 9; -uint256 constant NUMBER_OF_ENTITIES = 41; -// The number of entities added for ZK (gemini_masking_poly) -uint256 constant NUM_MASKING_POLYNOMIALS = 1; -uint256 constant NUMBER_OF_ENTITIES_ZK = NUMBER_OF_ENTITIES + NUM_MASKING_POLYNOMIALS; -uint256 constant NUMBER_UNSHIFTED = 36; -uint256 constant NUMBER_UNSHIFTED_ZK = NUMBER_UNSHIFTED + NUM_MASKING_POLYNOMIALS; -uint256 constant NUMBER_TO_BE_SHIFTED = 5; -uint256 constant PAIRING_POINTS_SIZE = 8; - -uint256 constant FIELD_ELEMENT_SIZE = 0x20; -uint256 constant GROUP_ELEMENT_SIZE = 0x40; - -// Powers of alpha used to batch subrelations (alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)) -uint256 constant NUMBER_OF_ALPHAS = NUMBER_OF_SUBRELATIONS - 1; - -// ENUM FOR WIRES -enum WIRE { - Q_M, - Q_C, - Q_L, - Q_R, - Q_O, - Q_4, - Q_LOOKUP, - Q_ARITH, - Q_RANGE, - Q_ELLIPTIC, - Q_MEMORY, - Q_NNF, - Q_POSEIDON2_EXTERNAL, - Q_POSEIDON2_INTERNAL, - SIGMA_1, - SIGMA_2, - SIGMA_3, - SIGMA_4, - ID_1, - ID_2, - ID_3, - ID_4, - TABLE_1, - TABLE_2, - TABLE_3, - TABLE_4, - LAGRANGE_FIRST, - LAGRANGE_LAST, - W_L, - W_R, - W_O, - W_4, - Z_PERM, - LOOKUP_INVERSES, - LOOKUP_READ_COUNTS, - LOOKUP_READ_TAGS, - W_L_SHIFT, - W_R_SHIFT, - W_O_SHIFT, - W_4_SHIFT, - Z_PERM_SHIFT -} - -library Honk { - struct G1Point { - uint256 x; - uint256 y; - } - - struct VerificationKey { - // Misc Params - uint256 circuitSize; - uint256 logCircuitSize; - uint256 publicInputsSize; - // Selectors - G1Point qm; - G1Point qc; - G1Point ql; - G1Point qr; - G1Point qo; - G1Point q4; - G1Point qLookup; // Lookup - G1Point qArith; // Arithmetic widget - G1Point qDeltaRange; // Delta Range sort - G1Point qMemory; // Memory - G1Point qNnf; // Non-native Field - G1Point qElliptic; // Auxillary - G1Point qPoseidon2External; - G1Point qPoseidon2Internal; - // Copy constraints - G1Point s1; - G1Point s2; - G1Point s3; - G1Point s4; - // Copy identity - G1Point id1; - G1Point id2; - G1Point id3; - G1Point id4; - // Precomputed lookup table - G1Point t1; - G1Point t2; - G1Point t3; - G1Point t4; - // Fixed first and last - G1Point lagrangeFirst; - G1Point lagrangeLast; - } - - struct RelationParameters { - // challenges - Fr eta; - Fr beta; - Fr gamma; - // derived - Fr publicInputsDelta; - } - - struct Proof { - // Pairing point object - Fr[PAIRING_POINTS_SIZE] pairingPointObject; - // Free wires - G1Point w1; - G1Point w2; - G1Point w3; - G1Point w4; - // Lookup helpers - Permutations - G1Point zPerm; - // Lookup helpers - logup - G1Point lookupReadCounts; - G1Point lookupReadTags; - G1Point lookupInverses; - // Sumcheck - Fr[BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; - Fr[NUMBER_OF_ENTITIES] sumcheckEvaluations; - // Shplemini - G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; - Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; - G1Point shplonkQ; - G1Point kzgQuotient; - } - - /// forge-lint: disable-next-item(pascal-case-struct) - struct ZKProof { - // Pairing point object - Fr[PAIRING_POINTS_SIZE] pairingPointObject; - // ZK: Gemini masking polynomial commitment (sent first, right after public inputs) - G1Point geminiMaskingPoly; - // Commitments to wire polynomials - G1Point w1; - G1Point w2; - G1Point w3; - G1Point w4; - // Commitments to logup witness polynomials - G1Point lookupReadCounts; - G1Point lookupReadTags; - G1Point lookupInverses; - // Commitment to grand permutation polynomial - G1Point zPerm; - G1Point[3] libraCommitments; - // Sumcheck - Fr libraSum; - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; - Fr libraEvaluation; - Fr[NUMBER_OF_ENTITIES_ZK] sumcheckEvaluations; // Includes gemini_masking_poly eval at index 0 (first position) - // Shplemini - G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; - Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; - Fr[4] libraPolyEvals; - G1Point shplonkQ; - G1Point kzgQuotient; - } -} - -// ZKTranscript library to generate fiat shamir challenges, the ZK transcript only differest -/// forge-lint: disable-next-item(pascal-case-struct) -struct ZKTranscript { - // Oink - Honk.RelationParameters relationParameters; - Fr[NUMBER_OF_ALPHAS] alphas; // Powers of alpha: [alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)] - Fr[CONST_PROOF_SIZE_LOG_N] gateChallenges; - // Sumcheck - Fr libraChallenge; - Fr[CONST_PROOF_SIZE_LOG_N] sumCheckUChallenges; - // Shplemini - Fr rho; - Fr geminiR; - Fr shplonkNu; - Fr shplonkZ; - // Derived - Fr publicInputsDelta; -} - -library ZKTranscriptLib { - function generateTranscript( - Honk.ZKProof memory proof, - bytes32[] calldata publicInputs, - uint256 vkHash, - uint256 publicInputsSize, - uint256 logN - ) external pure returns (ZKTranscript memory t) { - Fr previousChallenge; - (t.relationParameters, previousChallenge) = - generateRelationParametersChallenges(proof, publicInputs, vkHash, publicInputsSize, previousChallenge); - - (t.alphas, previousChallenge) = generateAlphaChallenges(previousChallenge, proof); - - (t.gateChallenges, previousChallenge) = generateGateChallenges(previousChallenge, logN); - (t.libraChallenge, previousChallenge) = generateLibraChallenge(previousChallenge, proof); - (t.sumCheckUChallenges, previousChallenge) = generateSumcheckChallenges(proof, previousChallenge, logN); - - (t.rho, previousChallenge) = generateRhoChallenge(proof, previousChallenge); - - (t.geminiR, previousChallenge) = generateGeminiRChallenge(proof, previousChallenge, logN); - - (t.shplonkNu, previousChallenge) = generateShplonkNuChallenge(proof, previousChallenge, logN); - - (t.shplonkZ, previousChallenge) = generateShplonkZChallenge(proof, previousChallenge); - return t; - } - - function splitChallenge(Fr challenge) internal pure returns (Fr first, Fr second) { - uint256 challengeU256 = uint256(Fr.unwrap(challenge)); - // Split into two equal 127-bit chunks (254/2) - uint256 lo = challengeU256 & 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 127 bits - uint256 hi = challengeU256 >> 127; - first = FrLib.from(lo); - second = FrLib.from(hi); - } - - function generateRelationParametersChallenges( - Honk.ZKProof memory proof, - bytes32[] calldata publicInputs, - uint256 vkHash, - uint256 publicInputsSize, - Fr previousChallenge - ) internal pure returns (Honk.RelationParameters memory rp, Fr nextPreviousChallenge) { - (rp.eta, previousChallenge) = generateEtaChallenge(proof, publicInputs, vkHash, publicInputsSize); - - (rp.beta, rp.gamma, nextPreviousChallenge) = generateBetaGammaChallenges(previousChallenge, proof); - } - - function generateEtaChallenge( - Honk.ZKProof memory proof, - bytes32[] calldata publicInputs, - uint256 vkHash, - uint256 publicInputsSize - ) internal pure returns (Fr eta, Fr previousChallenge) { - // Size: 1 (vkHash) + publicInputsSize + 8 (geminiMask(2) + 3 wires(6)) - bytes32[] memory round0 = new bytes32[](1 + publicInputsSize + 8); - round0[0] = bytes32(vkHash); - - for (uint256 i = 0; i < publicInputsSize - PAIRING_POINTS_SIZE; i++) { - require(uint256(publicInputs[i]) < P, Errors.ValueGeFieldOrder()); - round0[1 + i] = publicInputs[i]; - } - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - round0[1 + publicInputsSize - PAIRING_POINTS_SIZE + i] = FrLib.toBytes32(proof.pairingPointObject[i]); - } - - // For ZK flavors: hash the gemini masking poly commitment (sent right after public inputs) - round0[1 + publicInputsSize] = bytes32(proof.geminiMaskingPoly.x); - round0[1 + publicInputsSize + 1] = bytes32(proof.geminiMaskingPoly.y); - - // Create the first challenge - // Note: w4 is added to the challenge later on - round0[1 + publicInputsSize + 2] = bytes32(proof.w1.x); - round0[1 + publicInputsSize + 3] = bytes32(proof.w1.y); - round0[1 + publicInputsSize + 4] = bytes32(proof.w2.x); - round0[1 + publicInputsSize + 5] = bytes32(proof.w2.y); - round0[1 + publicInputsSize + 6] = bytes32(proof.w3.x); - round0[1 + publicInputsSize + 7] = bytes32(proof.w3.y); - - previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round0))) % P); - (eta,) = splitChallenge(previousChallenge); - } - - function generateBetaGammaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) - internal - pure - returns (Fr beta, Fr gamma, Fr nextPreviousChallenge) - { - bytes32[7] memory round1; - round1[0] = FrLib.toBytes32(previousChallenge); - round1[1] = bytes32(proof.lookupReadCounts.x); - round1[2] = bytes32(proof.lookupReadCounts.y); - round1[3] = bytes32(proof.lookupReadTags.x); - round1[4] = bytes32(proof.lookupReadTags.y); - round1[5] = bytes32(proof.w4.x); - round1[6] = bytes32(proof.w4.y); - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round1))) % P); - (beta, gamma) = splitChallenge(nextPreviousChallenge); - } - - // Alpha challenges non-linearise the gate contributions - function generateAlphaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) - internal - pure - returns (Fr[NUMBER_OF_ALPHAS] memory alphas, Fr nextPreviousChallenge) - { - // Generate the original sumcheck alpha 0 by hashing zPerm and zLookup - uint256[5] memory alpha0; - alpha0[0] = Fr.unwrap(previousChallenge); - alpha0[1] = proof.lookupInverses.x; - alpha0[2] = proof.lookupInverses.y; - alpha0[3] = proof.zPerm.x; - alpha0[4] = proof.zPerm.y; - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(alpha0))) % P); - Fr alpha; - (alpha,) = splitChallenge(nextPreviousChallenge); - - // Compute powers of alpha for batching subrelations - alphas[0] = alpha; - for (uint256 i = 1; i < NUMBER_OF_ALPHAS; i++) { - alphas[i] = alphas[i - 1] * alpha; - } - } - - function generateGateChallenges(Fr previousChallenge, uint256 logN) - internal - pure - returns (Fr[CONST_PROOF_SIZE_LOG_N] memory gateChallenges, Fr nextPreviousChallenge) - { - previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(Fr.unwrap(previousChallenge)))) % P); - (gateChallenges[0],) = splitChallenge(previousChallenge); - for (uint256 i = 1; i < logN; i++) { - gateChallenges[i] = gateChallenges[i - 1] * gateChallenges[i - 1]; - } - nextPreviousChallenge = previousChallenge; - } - - function generateLibraChallenge(Fr previousChallenge, Honk.ZKProof memory proof) - internal - pure - returns (Fr libraChallenge, Fr nextPreviousChallenge) - { - // 2 comm, 1 sum, 1 challenge - uint256[4] memory challengeData; - challengeData[0] = Fr.unwrap(previousChallenge); - challengeData[1] = proof.libraCommitments[0].x; - challengeData[2] = proof.libraCommitments[0].y; - challengeData[3] = Fr.unwrap(proof.libraSum); - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(challengeData))) % P); - (libraChallenge,) = splitChallenge(nextPreviousChallenge); - } - - function generateSumcheckChallenges(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) - internal - pure - returns (Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckChallenges, Fr nextPreviousChallenge) - { - for (uint256 i = 0; i < logN; i++) { - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH + 1] memory univariateChal; - univariateChal[0] = prevChallenge; - - for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { - univariateChal[j + 1] = proof.sumcheckUnivariates[i][j]; - } - prevChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(univariateChal))) % P); - - (sumcheckChallenges[i],) = splitChallenge(prevChallenge); - } - nextPreviousChallenge = prevChallenge; - } - - // We add Libra claimed eval + 2 libra commitments (grand_sum, quotient) - function generateRhoChallenge(Honk.ZKProof memory proof, Fr prevChallenge) - internal - pure - returns (Fr rho, Fr nextPreviousChallenge) - { - uint256[NUMBER_OF_ENTITIES_ZK + 6] memory rhoChallengeElements; - rhoChallengeElements[0] = Fr.unwrap(prevChallenge); - uint256 i; - for (i = 1; i <= NUMBER_OF_ENTITIES_ZK; i++) { - rhoChallengeElements[i] = Fr.unwrap(proof.sumcheckEvaluations[i - 1]); - } - rhoChallengeElements[i] = Fr.unwrap(proof.libraEvaluation); - i += 1; - rhoChallengeElements[i] = proof.libraCommitments[1].x; - rhoChallengeElements[i + 1] = proof.libraCommitments[1].y; - i += 2; - rhoChallengeElements[i] = proof.libraCommitments[2].x; - rhoChallengeElements[i + 1] = proof.libraCommitments[2].y; - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(rhoChallengeElements))) % P); - (rho,) = splitChallenge(nextPreviousChallenge); - } - - function generateGeminiRChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) - internal - pure - returns (Fr geminiR, Fr nextPreviousChallenge) - { - uint256[] memory gR = new uint256[]((logN - 1) * 2 + 1); - gR[0] = Fr.unwrap(prevChallenge); - - for (uint256 i = 0; i < logN - 1; i++) { - gR[1 + i * 2] = proof.geminiFoldComms[i].x; - gR[2 + i * 2] = proof.geminiFoldComms[i].y; - } - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(gR))) % P); - - (geminiR,) = splitChallenge(nextPreviousChallenge); - } - - function generateShplonkNuChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) - internal - pure - returns (Fr shplonkNu, Fr nextPreviousChallenge) - { - uint256[] memory shplonkNuChallengeElements = new uint256[](logN + 1 + 4); - shplonkNuChallengeElements[0] = Fr.unwrap(prevChallenge); - - for (uint256 i = 1; i <= logN; i++) { - shplonkNuChallengeElements[i] = Fr.unwrap(proof.geminiAEvaluations[i - 1]); - } - - uint256 libraIdx = 0; - for (uint256 i = logN + 1; i <= logN + 4; i++) { - shplonkNuChallengeElements[i] = Fr.unwrap(proof.libraPolyEvals[libraIdx]); - libraIdx++; - } - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkNuChallengeElements))) % P); - (shplonkNu,) = splitChallenge(nextPreviousChallenge); - } - - function generateShplonkZChallenge(Honk.ZKProof memory proof, Fr prevChallenge) - internal - pure - returns (Fr shplonkZ, Fr nextPreviousChallenge) - { - uint256[3] memory shplonkZChallengeElements; - shplonkZChallengeElements[0] = Fr.unwrap(prevChallenge); - - shplonkZChallengeElements[1] = proof.shplonkQ.x; - shplonkZChallengeElements[2] = proof.shplonkQ.y; - - nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkZChallengeElements))) % P); - (shplonkZ,) = splitChallenge(nextPreviousChallenge); - } - - function loadProof(bytes calldata proof, uint256 logN) internal pure returns (Honk.ZKProof memory p) { - uint256 boundary = 0x0; - - // Pairing point object - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - uint256 limb = uint256(bytes32(proof[boundary:boundary + FIELD_ELEMENT_SIZE])); - // lo limbs (even index) < 2^136, hi limbs (odd index) < 2^120 - require(limb < 2 ** (i % 2 == 0 ? 136 : 120), Errors.ValueGeLimbMax()); - p.pairingPointObject[i] = FrLib.from(limb); - boundary += FIELD_ELEMENT_SIZE; - } - - // Gemini masking polynomial commitment (sent first in ZK flavors, right after pairing points) - p.geminiMaskingPoly = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - // Commitments - p.w1 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.w2 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.w3 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - // Lookup / Permutation Helper Commitments - p.lookupReadCounts = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.lookupReadTags = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.w4 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.lookupInverses = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.zPerm = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.libraCommitments[0] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - p.libraSum = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - // Sumcheck univariates - for (uint256 i = 0; i < logN; i++) { - for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { - p.sumcheckUnivariates[i][j] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - } - - // Sumcheck evaluations (includes gemini_masking_poly eval at index 0 for ZK flavors) - for (uint256 i = 0; i < NUMBER_OF_ENTITIES_ZK; i++) { - p.sumcheckEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - - p.libraEvaluation = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - - p.libraCommitments[1] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - p.libraCommitments[2] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - - // Gemini - // Read gemini fold univariates - for (uint256 i = 0; i < logN - 1; i++) { - p.geminiFoldComms[i] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - } - - // Read gemini a evaluations - for (uint256 i = 0; i < logN; i++) { - p.geminiAEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - - for (uint256 i = 0; i < 4; i++) { - p.libraPolyEvals[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); - boundary += FIELD_ELEMENT_SIZE; - } - - // Shplonk - p.shplonkQ = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - boundary += GROUP_ELEMENT_SIZE; - // KZG - p.kzgQuotient = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); - } -} - -library RelationsLib { - struct EllipticParams { - // Points - Fr x_1; - Fr y_1; - Fr x_2; - Fr y_2; - Fr y_3; - Fr x_3; - // push accumulators into memory - Fr x_double_identity; - } - - // Parameters used within the Memory Relation - // A struct is used to work around stack too deep. This relation has alot of variables - struct MemParams { - Fr memory_record_check; - Fr partial_record_check; - Fr next_gate_access_type; - Fr record_delta; - Fr index_delta; - Fr adjacent_values_match_if_adjacent_indices_match; - Fr adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation; - Fr access_check; - Fr next_gate_access_type_is_boolean; - Fr ROM_consistency_check_identity; - Fr RAM_consistency_check_identity; - Fr timestamp_delta; - Fr RAM_timestamp_check_identity; - Fr memory_identity; - Fr index_is_monotonically_increasing; - } - - // Parameters used within the Non-Native Field Relation - // A struct is used to work around stack too deep. This relation has alot of variables - struct NnfParams { - Fr limb_subproduct; - Fr non_native_field_gate_1; - Fr non_native_field_gate_2; - Fr non_native_field_gate_3; - Fr limb_accumulator_1; - Fr limb_accumulator_2; - Fr nnf_identity; - } - - struct PoseidonExternalParams { - Fr s1; - Fr s2; - Fr s3; - Fr s4; - Fr u1; - Fr u2; - Fr u3; - Fr u4; - Fr t0; - Fr t1; - Fr t2; - Fr t3; - Fr v1; - Fr v2; - Fr v3; - Fr v4; - Fr q_pos_by_scaling; - } - - struct PoseidonInternalParams { - Fr u1; - Fr u2; - Fr u3; - Fr u4; - Fr u_sum; - Fr v1; - Fr v2; - Fr v3; - Fr v4; - Fr s1; - Fr q_pos_by_scaling; - } - - Fr internal constant GRUMPKIN_CURVE_B_PARAMETER_NEGATED = Fr.wrap(17); // -(-17) - uint256 internal constant NEG_HALF_MODULO_P = 0x183227397098d014dc2822db40c0ac2e9419f4243cdcb848a1f0fac9f8000000; - - // Constants for the Non-native Field relation - Fr internal constant LIMB_SIZE = Fr.wrap(uint256(1) << 68); - Fr internal constant SUBLIMB_SHIFT = Fr.wrap(uint256(1) << 14); - - function accumulateRelationEvaluations( - Fr[NUMBER_OF_ENTITIES] memory purportedEvaluations, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges, - Fr powPartialEval - ) internal pure returns (Fr accumulator) { - Fr[NUMBER_OF_SUBRELATIONS] memory evaluations; - - // Accumulate all relations in Ultra Honk - each with varying number of subrelations - accumulateArithmeticRelation(purportedEvaluations, evaluations, powPartialEval); - accumulatePermutationRelation(purportedEvaluations, rp, evaluations, powPartialEval); - accumulateLogDerivativeLookupRelation(purportedEvaluations, rp, evaluations, powPartialEval); - accumulateDeltaRangeRelation(purportedEvaluations, evaluations, powPartialEval); - accumulateEllipticRelation(purportedEvaluations, evaluations, powPartialEval); - accumulateMemoryRelation(purportedEvaluations, rp, evaluations, powPartialEval); - accumulateNnfRelation(purportedEvaluations, evaluations, powPartialEval); - accumulatePoseidonExternalRelation(purportedEvaluations, evaluations, powPartialEval); - accumulatePoseidonInternalRelation(purportedEvaluations, evaluations, powPartialEval); - - // batch the subrelations with the precomputed alpha powers to obtain the full honk relation - accumulator = scaleAndBatchSubrelations(evaluations, subrelationChallenges); - } - - /** - * Aesthetic helper function that is used to index by enum into proof.sumcheckEvaluations, it avoids - * the relation checking code being cluttered with uint256 type casting, which is often a different colour in code - * editors, and thus is noisy. - */ - function wire(Fr[NUMBER_OF_ENTITIES] memory p, WIRE _wire) internal pure returns (Fr) { - return p[uint256(_wire)]; - } - - /** - * Ultra Arithmetic Relation - * - */ - function accumulateArithmeticRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - // Relation 0 - Fr q_arith = wire(p, WIRE.Q_ARITH); - { - Fr neg_half = Fr.wrap(NEG_HALF_MODULO_P); - - Fr accum = (q_arith - Fr.wrap(3)) * (wire(p, WIRE.Q_M) * wire(p, WIRE.W_R) * wire(p, WIRE.W_L)) * neg_half; - accum = accum + (wire(p, WIRE.Q_L) * wire(p, WIRE.W_L)) + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_R)) - + (wire(p, WIRE.Q_O) * wire(p, WIRE.W_O)) + (wire(p, WIRE.Q_4) * wire(p, WIRE.W_4)) + wire(p, WIRE.Q_C); - accum = accum + (q_arith - ONE) * wire(p, WIRE.W_4_SHIFT); - accum = accum * q_arith; - accum = accum * domainSep; - evals[0] = accum; - } - - // Relation 1 - { - Fr accum = wire(p, WIRE.W_L) + wire(p, WIRE.W_4) - wire(p, WIRE.W_L_SHIFT) + wire(p, WIRE.Q_M); - accum = accum * (q_arith - Fr.wrap(2)); - accum = accum * (q_arith - ONE); - accum = accum * q_arith; - accum = accum * domainSep; - evals[1] = accum; - } - } - - function accumulatePermutationRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - Fr grand_product_numerator; - Fr grand_product_denominator; - - { - Fr num = wire(p, WIRE.W_L) + wire(p, WIRE.ID_1) * rp.beta + rp.gamma; - num = num * (wire(p, WIRE.W_R) + wire(p, WIRE.ID_2) * rp.beta + rp.gamma); - num = num * (wire(p, WIRE.W_O) + wire(p, WIRE.ID_3) * rp.beta + rp.gamma); - num = num * (wire(p, WIRE.W_4) + wire(p, WIRE.ID_4) * rp.beta + rp.gamma); - - grand_product_numerator = num; - } - { - Fr den = wire(p, WIRE.W_L) + wire(p, WIRE.SIGMA_1) * rp.beta + rp.gamma; - den = den * (wire(p, WIRE.W_R) + wire(p, WIRE.SIGMA_2) * rp.beta + rp.gamma); - den = den * (wire(p, WIRE.W_O) + wire(p, WIRE.SIGMA_3) * rp.beta + rp.gamma); - den = den * (wire(p, WIRE.W_4) + wire(p, WIRE.SIGMA_4) * rp.beta + rp.gamma); - - grand_product_denominator = den; - } - - // Contribution 2 - { - Fr acc = (wire(p, WIRE.Z_PERM) + wire(p, WIRE.LAGRANGE_FIRST)) * grand_product_numerator; - - acc = acc - - ((wire(p, WIRE.Z_PERM_SHIFT) + (wire(p, WIRE.LAGRANGE_LAST) * rp.publicInputsDelta)) - * grand_product_denominator); - acc = acc * domainSep; - evals[2] = acc; - } - - // Contribution 3 - { - Fr acc = (wire(p, WIRE.LAGRANGE_LAST) * wire(p, WIRE.Z_PERM_SHIFT)) * domainSep; - evals[3] = acc; - } - } - - function accumulateLogDerivativeLookupRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - Fr table_term; - Fr lookup_term; - - // Calculate the write term (the table accumulation) - // table_term = table_1 + γ + table_2 * β + table_3 * β² + table_4 * β³ - { - Fr beta_sqr = rp.beta * rp.beta; - table_term = wire(p, WIRE.TABLE_1) + rp.gamma + (wire(p, WIRE.TABLE_2) * rp.beta) - + (wire(p, WIRE.TABLE_3) * beta_sqr) + (wire(p, WIRE.TABLE_4) * beta_sqr * rp.beta); - } - - // Calculate the read term - // lookup_term = derived_entry_1 + γ + derived_entry_2 * β + derived_entry_3 * β² + q_index * β³ - { - Fr beta_sqr = rp.beta * rp.beta; - Fr derived_entry_1 = wire(p, WIRE.W_L) + rp.gamma + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_L_SHIFT)); - Fr derived_entry_2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_M) * wire(p, WIRE.W_R_SHIFT); - Fr derived_entry_3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_C) * wire(p, WIRE.W_O_SHIFT); - - lookup_term = derived_entry_1 + (derived_entry_2 * rp.beta) + (derived_entry_3 * beta_sqr) - + (wire(p, WIRE.Q_O) * beta_sqr * rp.beta); - } - - Fr lookup_inverse = wire(p, WIRE.LOOKUP_INVERSES) * table_term; - Fr table_inverse = wire(p, WIRE.LOOKUP_INVERSES) * lookup_term; - - Fr inverse_exists_xor = wire(p, WIRE.LOOKUP_READ_TAGS) + wire(p, WIRE.Q_LOOKUP) - - (wire(p, WIRE.LOOKUP_READ_TAGS) * wire(p, WIRE.Q_LOOKUP)); - - // Inverse calculated correctly relation - Fr accumulatorNone = lookup_term * table_term * wire(p, WIRE.LOOKUP_INVERSES) - inverse_exists_xor; - accumulatorNone = accumulatorNone * domainSep; - - // Inverse - Fr accumulatorOne = wire(p, WIRE.Q_LOOKUP) * lookup_inverse - wire(p, WIRE.LOOKUP_READ_COUNTS) * table_inverse; - - Fr read_tag = wire(p, WIRE.LOOKUP_READ_TAGS); - - Fr read_tag_boolean_relation = read_tag * read_tag - read_tag; - - evals[4] = accumulatorNone; - evals[5] = accumulatorOne; - evals[6] = read_tag_boolean_relation * domainSep; - } - - function accumulateDeltaRangeRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - Fr minus_one = ZERO - ONE; - Fr minus_two = ZERO - Fr.wrap(2); - Fr minus_three = ZERO - Fr.wrap(3); - - // Compute wire differences - Fr delta_1 = wire(p, WIRE.W_R) - wire(p, WIRE.W_L); - Fr delta_2 = wire(p, WIRE.W_O) - wire(p, WIRE.W_R); - Fr delta_3 = wire(p, WIRE.W_4) - wire(p, WIRE.W_O); - Fr delta_4 = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_4); - - // Contribution 6 - { - Fr acc = delta_1; - acc = acc * (delta_1 + minus_one); - acc = acc * (delta_1 + minus_two); - acc = acc * (delta_1 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[7] = acc; - } - - // Contribution 7 - { - Fr acc = delta_2; - acc = acc * (delta_2 + minus_one); - acc = acc * (delta_2 + minus_two); - acc = acc * (delta_2 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[8] = acc; - } - - // Contribution 8 - { - Fr acc = delta_3; - acc = acc * (delta_3 + minus_one); - acc = acc * (delta_3 + minus_two); - acc = acc * (delta_3 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[9] = acc; - } - - // Contribution 9 - { - Fr acc = delta_4; - acc = acc * (delta_4 + minus_one); - acc = acc * (delta_4 + minus_two); - acc = acc * (delta_4 + minus_three); - acc = acc * wire(p, WIRE.Q_RANGE); - acc = acc * domainSep; - evals[10] = acc; - } - } - - function accumulateEllipticRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - EllipticParams memory ep; - ep.x_1 = wire(p, WIRE.W_R); - ep.y_1 = wire(p, WIRE.W_O); - - ep.x_2 = wire(p, WIRE.W_L_SHIFT); - ep.y_2 = wire(p, WIRE.W_4_SHIFT); - ep.y_3 = wire(p, WIRE.W_O_SHIFT); - ep.x_3 = wire(p, WIRE.W_R_SHIFT); - - Fr q_sign = wire(p, WIRE.Q_L); - Fr q_is_double = wire(p, WIRE.Q_M); - - // Contribution 10 point addition, x-coordinate check - // q_elliptic * (x3 + x2 + x1)(x2 - x1)(x2 - x1) - y2^2 - y1^2 + 2(y2y1)*q_sign = 0 - Fr x_diff = (ep.x_2 - ep.x_1); - Fr y1_sqr = (ep.y_1 * ep.y_1); - { - // Move to top - Fr partialEval = domainSep; - - Fr y2_sqr = (ep.y_2 * ep.y_2); - Fr y1y2 = ep.y_1 * ep.y_2 * q_sign; - Fr x_add_identity = (ep.x_3 + ep.x_2 + ep.x_1); - x_add_identity = x_add_identity * x_diff * x_diff; - x_add_identity = x_add_identity - y2_sqr - y1_sqr + y1y2 + y1y2; - - evals[11] = x_add_identity * partialEval * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); - } - - // Contribution 11 point addition, x-coordinate check - // q_elliptic * (q_sign * y1 + y3)(x2 - x1) + (x3 - x1)(y2 - q_sign * y1) = 0 - { - Fr y1_plus_y3 = ep.y_1 + ep.y_3; - Fr y_diff = ep.y_2 * q_sign - ep.y_1; - Fr y_add_identity = y1_plus_y3 * x_diff + (ep.x_3 - ep.x_1) * y_diff; - evals[12] = y_add_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); - } - - // Contribution 10 point doubling, x-coordinate check - // (x3 + x1 + x1) (4y1*y1) - 9 * x1 * x1 * x1 * x1 = 0 - // N.B. we're using the equivalence x1*x1*x1 === y1*y1 - curve_b to reduce degree by 1 - { - Fr x_pow_4 = (y1_sqr + GRUMPKIN_CURVE_B_PARAMETER_NEGATED) * ep.x_1; - Fr y1_sqr_mul_4 = y1_sqr + y1_sqr; - y1_sqr_mul_4 = y1_sqr_mul_4 + y1_sqr_mul_4; - Fr x1_pow_4_mul_9 = x_pow_4 * Fr.wrap(9); - - // NOTE: pushed into memory (stack >:'( ) - ep.x_double_identity = (ep.x_3 + ep.x_1 + ep.x_1) * y1_sqr_mul_4 - x1_pow_4_mul_9; - - Fr acc = ep.x_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; - evals[11] = evals[11] + acc; - } - - // Contribution 11 point doubling, y-coordinate check - // (y1 + y1) (2y1) - (3 * x1 * x1)(x1 - x3) = 0 - { - Fr x1_sqr_mul_3 = (ep.x_1 + ep.x_1 + ep.x_1) * ep.x_1; - Fr y_double_identity = x1_sqr_mul_3 * (ep.x_1 - ep.x_3) - (ep.y_1 + ep.y_1) * (ep.y_1 + ep.y_3); - evals[12] = evals[12] + y_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; - } - } - - function accumulateMemoryRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Honk.RelationParameters memory rp, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - MemParams memory ap; - - // Compute eta powers locally - Fr eta_two = rp.eta * rp.eta; - Fr eta_three = eta_two * rp.eta; - - /** - * MEMORY - * - * A RAM memory record contains a tuple of the following fields: - * * i: `index` of memory cell being accessed - * * t: `timestamp` of memory cell being accessed (used for RAM, set to 0 for ROM) - * * v: `value` of memory cell being accessed - * * a: `access` type of record. read: 0 = read, 1 = write - * * r: `record` of memory cell. record = access + index * eta + timestamp * eta_two + value * eta_three - * - * A ROM memory record contains a tuple of the following fields: - * * i: `index` of memory cell being accessed - * * v: `value1` of memory cell being accessed (ROM tables can store up to 2 values per index) - * * v2:`value2` of memory cell being accessed (ROM tables can store up to 2 values per index) - * * r: `record` of memory cell. record = index * eta + value2 * eta_two + value1 * eta_three - * - * When performing a read/write access, the values of i, t, v, v2, a, r are stored in the following wires + - * selectors, depending on whether the gate is a RAM read/write or a ROM read - * - * | gate type | i | v2/t | v | a | r | - * | --------- | -- | ----- | -- | -- | -- | - * | ROM | w1 | w2 | w3 | -- | w4 | - * | RAM | w1 | w2 | w3 | qc | w4 | - * - * (for accesses where `index` is a circuit constant, it is assumed the circuit will apply a copy constraint on - * `w2` to fix its value) - * - * - */ - - /** - * Memory Record Check - * Partial degree: 1 - * Total degree: 4 - * - * A ROM/ROM access gate can be evaluated with the identity: - * - * qc + w1 \eta + w2 \eta_two + w3 \eta_three - w4 = 0 - * - * For ROM gates, qc = 0 - */ - ap.memory_record_check = wire(p, WIRE.W_O) * eta_three; - ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_R) * eta_two); - ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_L) * rp.eta); - ap.memory_record_check = ap.memory_record_check + wire(p, WIRE.Q_C); - ap.partial_record_check = ap.memory_record_check; // used in RAM consistency check; deg 1 or 4 - ap.memory_record_check = ap.memory_record_check - wire(p, WIRE.W_4); - - /** - * Contribution 13 & 14 - * ROM Consistency Check - * Partial degree: 1 - * Total degree: 4 - * - * For every ROM read, a set equivalence check is applied between the record witnesses, and a second set of - * records that are sorted. - * - * We apply the following checks for the sorted records: - * - * 1. w1, w2, w3 correctly map to 'index', 'v1, 'v2' for a given record value at w4 - * 2. index values for adjacent records are monotonically increasing - * 3. if, at gate i, index_i == index_{i + 1}, then value1_i == value1_{i + 1} and value2_i == value2_{i + 1} - * - */ - ap.index_delta = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_L); - ap.record_delta = wire(p, WIRE.W_4_SHIFT) - wire(p, WIRE.W_4); - - ap.index_is_monotonically_increasing = ap.index_delta * (ap.index_delta - Fr.wrap(1)); // deg 2 - - ap.adjacent_values_match_if_adjacent_indices_match = (ap.index_delta * MINUS_ONE + ONE) * ap.record_delta; // deg 2 - - evals[14] = ap.adjacent_values_match_if_adjacent_indices_match * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) - * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 - evals[15] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) - * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 - - ap.ROM_consistency_check_identity = ap.memory_record_check * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)); // deg 3 or 7 - - /** - * Contributions 15,16,17 - * RAM Consistency Check - * - * The 'access' type of the record is extracted with the expression `w_4 - ap.partial_record_check` - * (i.e. for an honest Prover `w1 * eta + w2 * eta^2 + w3 * eta^3 - w4 = access`. - * This is validated by requiring `access` to be boolean - * - * For two adjacent entries in the sorted list if _both_ - * A) index values match - * B) adjacent access value is 0 (i.e. next gate is a READ) - * then - * C) both values must match. - * The gate boolean check is - * (A && B) => C === !(A && B) || C === !A || !B || C - * - * N.B. it is the responsibility of the circuit writer to ensure that every RAM cell is initialized - * with a WRITE operation. - */ - Fr access_type = (wire(p, WIRE.W_4) - ap.partial_record_check); // will be 0 or 1 for honest Prover; deg 1 or 4 - ap.access_check = access_type * (access_type - Fr.wrap(1)); // check value is 0 or 1; deg 2 or 8 - - // reverse order we could re-use `ap.partial_record_check` 1 - ((w3' * eta + w2') * eta + w1') * eta - // deg 1 or 4 - ap.next_gate_access_type = wire(p, WIRE.W_O_SHIFT) * eta_three; - ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_R_SHIFT) * eta_two); - ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_L_SHIFT) * rp.eta); - ap.next_gate_access_type = wire(p, WIRE.W_4_SHIFT) - ap.next_gate_access_type; - - Fr value_delta = wire(p, WIRE.W_O_SHIFT) - wire(p, WIRE.W_O); - ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation = - (ap.index_delta * MINUS_ONE + ONE) * value_delta * (ap.next_gate_access_type * MINUS_ONE + ONE); // deg 3 or 6 - - // We can't apply the RAM consistency check identity on the final entry in the sorted list (the wires in the - // next gate would make the identity fail). We need to validate that its 'access type' bool is correct. Can't - // do with an arithmetic gate because of the `eta` factors. We need to check that the *next* gate's access - // type is correct, to cover this edge case - // deg 2 or 4 - ap.next_gate_access_type_is_boolean = - ap.next_gate_access_type * ap.next_gate_access_type - ap.next_gate_access_type; - - // Putting it all together... - evals[16] = ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation - * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 or 8 - evals[17] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 - evals[18] = ap.next_gate_access_type_is_boolean * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 6 - - ap.RAM_consistency_check_identity = ap.access_check * (wire(p, WIRE.Q_O)); // deg 3 or 9 - - /** - * RAM Timestamp Consistency Check - * - * | w1 | w2 | w3 | w4 | - * | index | timestamp | timestamp_check | -- | - * - * Let delta_index = index_{i + 1} - index_{i} - * - * Iff delta_index == 0, timestamp_check = timestamp_{i + 1} - timestamp_i - * Else timestamp_check = 0 - */ - ap.timestamp_delta = wire(p, WIRE.W_R_SHIFT) - wire(p, WIRE.W_R); - ap.RAM_timestamp_check_identity = (ap.index_delta * MINUS_ONE + ONE) * ap.timestamp_delta - wire(p, WIRE.W_O); // deg 3 - - /** - * Complete Contribution 12 - * The complete RAM/ROM memory identity - * Partial degree: - */ - ap.memory_identity = ap.ROM_consistency_check_identity; // deg 3 or 6 - ap.memory_identity = - ap.memory_identity + ap.RAM_timestamp_check_identity * (wire(p, WIRE.Q_4) * wire(p, WIRE.Q_L)); // deg 4 - ap.memory_identity = ap.memory_identity + ap.memory_record_check * (wire(p, WIRE.Q_M) * wire(p, WIRE.Q_L)); // deg 3 or 6 - ap.memory_identity = ap.memory_identity + ap.RAM_consistency_check_identity; // deg 3 or 9 - - // (deg 3 or 9) + (deg 4) + (deg 3) - ap.memory_identity = ap.memory_identity * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 10 - evals[13] = ap.memory_identity; - } - - function accumulateNnfRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - NnfParams memory ap; - - /** - * Contribution 12 - * Non native field arithmetic gate 2 - * deg 4 - * - * _ _ - * / _ _ _ 14 \ - * q_2 . q_4 | (w_1 . w_2) + (w_1 . w_2) + (w_1 . w_4 + w_2 . w_3 - w_3) . 2 - w_3 - w_4 | - * \_ _/ - * - * - */ - ap.limb_subproduct = wire(p, WIRE.W_L) * wire(p, WIRE.W_R_SHIFT) + wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R); - ap.non_native_field_gate_2 = - (wire(p, WIRE.W_L) * wire(p, WIRE.W_4) + wire(p, WIRE.W_R) * wire(p, WIRE.W_O) - wire(p, WIRE.W_O_SHIFT)); - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * LIMB_SIZE; - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 - wire(p, WIRE.W_4_SHIFT); - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 + ap.limb_subproduct; - ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * wire(p, WIRE.Q_4); - - ap.limb_subproduct = ap.limb_subproduct * LIMB_SIZE; - ap.limb_subproduct = ap.limb_subproduct + (wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R_SHIFT)); - ap.non_native_field_gate_1 = ap.limb_subproduct; - ap.non_native_field_gate_1 = ap.non_native_field_gate_1 - (wire(p, WIRE.W_O) + wire(p, WIRE.W_4)); - ap.non_native_field_gate_1 = ap.non_native_field_gate_1 * wire(p, WIRE.Q_O); - - ap.non_native_field_gate_3 = ap.limb_subproduct; - ap.non_native_field_gate_3 = ap.non_native_field_gate_3 + wire(p, WIRE.W_4); - ap.non_native_field_gate_3 = ap.non_native_field_gate_3 - (wire(p, WIRE.W_O_SHIFT) + wire(p, WIRE.W_4_SHIFT)); - ap.non_native_field_gate_3 = ap.non_native_field_gate_3 * wire(p, WIRE.Q_M); - - Fr non_native_field_identity = - ap.non_native_field_gate_1 + ap.non_native_field_gate_2 + ap.non_native_field_gate_3; - non_native_field_identity = non_native_field_identity * wire(p, WIRE.Q_R); - - // ((((w2' * 2^14 + w1') * 2^14 + w3) * 2^14 + w2) * 2^14 + w1 - w4) * qm - // deg 2 - ap.limb_accumulator_1 = wire(p, WIRE.W_R_SHIFT) * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L_SHIFT); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_O); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_R); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; - ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L); - ap.limb_accumulator_1 = ap.limb_accumulator_1 - wire(p, WIRE.W_4); - ap.limb_accumulator_1 = ap.limb_accumulator_1 * wire(p, WIRE.Q_4); - - // ((((w3' * 2^14 + w2') * 2^14 + w1') * 2^14 + w4) * 2^14 + w3 - w4') * qm - // deg 2 - ap.limb_accumulator_2 = wire(p, WIRE.W_O_SHIFT) * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_R_SHIFT); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_L_SHIFT); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_4); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; - ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_O); - ap.limb_accumulator_2 = ap.limb_accumulator_2 - wire(p, WIRE.W_4_SHIFT); - ap.limb_accumulator_2 = ap.limb_accumulator_2 * wire(p, WIRE.Q_M); - - Fr limb_accumulator_identity = ap.limb_accumulator_1 + ap.limb_accumulator_2; - limb_accumulator_identity = limb_accumulator_identity * wire(p, WIRE.Q_O); // deg 3 - - ap.nnf_identity = non_native_field_identity + limb_accumulator_identity; - ap.nnf_identity = ap.nnf_identity * (wire(p, WIRE.Q_NNF) * domainSep); - evals[19] = ap.nnf_identity; - } - - function accumulatePoseidonExternalRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - PoseidonExternalParams memory ep; - - ep.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); - ep.s2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_R); - ep.s3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_O); - ep.s4 = wire(p, WIRE.W_4) + wire(p, WIRE.Q_4); - - ep.u1 = ep.s1 * ep.s1 * ep.s1 * ep.s1 * ep.s1; - ep.u2 = ep.s2 * ep.s2 * ep.s2 * ep.s2 * ep.s2; - ep.u3 = ep.s3 * ep.s3 * ep.s3 * ep.s3 * ep.s3; - ep.u4 = ep.s4 * ep.s4 * ep.s4 * ep.s4 * ep.s4; - // matrix mul v = M_E * u with 14 additions - ep.t0 = ep.u1 + ep.u2; // u_1 + u_2 - ep.t1 = ep.u3 + ep.u4; // u_3 + u_4 - ep.t2 = ep.u2 + ep.u2 + ep.t1; // 2u_2 - // ep.t2 += ep.t1; // 2u_2 + u_3 + u_4 - ep.t3 = ep.u4 + ep.u4 + ep.t0; // 2u_4 - // ep.t3 += ep.t0; // u_1 + u_2 + 2u_4 - ep.v4 = ep.t1 + ep.t1; - ep.v4 = ep.v4 + ep.v4 + ep.t3; - // ep.v4 += ep.t3; // u_1 + u_2 + 4u_3 + 6u_4 - ep.v2 = ep.t0 + ep.t0; - ep.v2 = ep.v2 + ep.v2 + ep.t2; - // ep.v2 += ep.t2; // 4u_1 + 6u_2 + u_3 + u_4 - ep.v1 = ep.t3 + ep.v2; // 5u_1 + 7u_2 + u_3 + 3u_4 - ep.v3 = ep.t2 + ep.v4; // u_1 + 3u_2 + 5u_3 + 7u_4 - - ep.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_EXTERNAL) * domainSep; - evals[20] = evals[20] + ep.q_pos_by_scaling * (ep.v1 - wire(p, WIRE.W_L_SHIFT)); - - evals[21] = evals[21] + ep.q_pos_by_scaling * (ep.v2 - wire(p, WIRE.W_R_SHIFT)); - - evals[22] = evals[22] + ep.q_pos_by_scaling * (ep.v3 - wire(p, WIRE.W_O_SHIFT)); - - evals[23] = evals[23] + ep.q_pos_by_scaling * (ep.v4 - wire(p, WIRE.W_4_SHIFT)); - } - - function accumulatePoseidonInternalRelation( - Fr[NUMBER_OF_ENTITIES] memory p, - Fr[NUMBER_OF_SUBRELATIONS] memory evals, - Fr domainSep - ) internal pure { - PoseidonInternalParams memory ip; - - Fr[4] memory INTERNAL_MATRIX_DIAGONAL = [ - FrLib.from(0x10dc6e9c006ea38b04b1e03b4bd9490c0d03f98929ca1d7fb56821fd19d3b6e7), - FrLib.from(0x0c28145b6a44df3e0149b3d0a30b3bb599df9756d4dd9b84a86b38cfb45a740b), - FrLib.from(0x00544b8338791518b2c7645a50392798b21f75bb60e3596170067d00141cac15), - FrLib.from(0x222c01175718386f2e2e82eb122789e352e105a3b8fa852613bc534433ee428b) - ]; - - // add round constants - ip.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); - - // apply s-box round - ip.u1 = ip.s1 * ip.s1 * ip.s1 * ip.s1 * ip.s1; - ip.u2 = wire(p, WIRE.W_R); - ip.u3 = wire(p, WIRE.W_O); - ip.u4 = wire(p, WIRE.W_4); - - // matrix mul with v = M_I * u 4 muls and 7 additions - ip.u_sum = ip.u1 + ip.u2 + ip.u3 + ip.u4; - - ip.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_INTERNAL) * domainSep; - - ip.v1 = ip.u1 * INTERNAL_MATRIX_DIAGONAL[0] + ip.u_sum; - evals[24] = evals[24] + ip.q_pos_by_scaling * (ip.v1 - wire(p, WIRE.W_L_SHIFT)); - - ip.v2 = ip.u2 * INTERNAL_MATRIX_DIAGONAL[1] + ip.u_sum; - evals[25] = evals[25] + ip.q_pos_by_scaling * (ip.v2 - wire(p, WIRE.W_R_SHIFT)); - - ip.v3 = ip.u3 * INTERNAL_MATRIX_DIAGONAL[2] + ip.u_sum; - evals[26] = evals[26] + ip.q_pos_by_scaling * (ip.v3 - wire(p, WIRE.W_O_SHIFT)); - - ip.v4 = ip.u4 * INTERNAL_MATRIX_DIAGONAL[3] + ip.u_sum; - evals[27] = evals[27] + ip.q_pos_by_scaling * (ip.v4 - wire(p, WIRE.W_4_SHIFT)); - } - - // Batch subrelation evaluations using precomputed powers of alpha - // First subrelation is implicitly scaled by 1, subsequent ones use powers from the subrelationChallenges array - function scaleAndBatchSubrelations( - Fr[NUMBER_OF_SUBRELATIONS] memory evaluations, - Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges - ) internal pure returns (Fr accumulator) { - accumulator = evaluations[0]; - - for (uint256 i = 1; i < NUMBER_OF_SUBRELATIONS; ++i) { - accumulator = accumulator + evaluations[i] * subrelationChallenges[i - 1]; - } - } -} - -library CommitmentSchemeLib { - using FrLib for Fr; - - // Avoid stack too deep - struct ShpleminiIntermediates { - Fr unshiftedScalar; - Fr shiftedScalar; - Fr unshiftedScalarNeg; - Fr shiftedScalarNeg; - // Scalar to be multiplied by [1]₁ - Fr constantTermAccumulator; - // Accumulator for powers of rho - Fr batchingChallenge; - // Linear combination of multilinear (sumcheck) evaluations and powers of rho - Fr batchedEvaluation; - Fr[4] denominators; - Fr[4] batchingScalars; - // 1/(z - r^{2^i}) for i = 0, ..., logSize, dynamically updated - Fr posInvertedDenominator; - // 1/(z + r^{2^i}) for i = 0, ..., logSize, dynamically updated - Fr negInvertedDenominator; - // ν^{2i} * 1/(z - r^{2^i}) - Fr scalingFactorPos; - // ν^{2i+1} * 1/(z + r^{2^i}) - Fr scalingFactorNeg; - // Fold_i(r^{2^i}) reconstructed by Verifier - Fr[] foldPosEvaluations; - } - - // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., m-1 - function computeFoldPosEvaluations( - Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckUChallenges, - Fr batchedEvalAccumulator, - Fr[CONST_PROOF_SIZE_LOG_N] memory geminiEvaluations, - Fr[] memory geminiEvalChallengePowers, - uint256 logSize - ) internal view returns (Fr[] memory) { - Fr[] memory foldPosEvaluations = new Fr[](logSize); - for (uint256 i = logSize; i > 0; --i) { - Fr challengePower = geminiEvalChallengePowers[i - 1]; - Fr u = sumcheckUChallenges[i - 1]; - - Fr batchedEvalRoundAcc = - ((challengePower * batchedEvalAccumulator * Fr.wrap(2)) - geminiEvaluations[i - 1] - * (challengePower * (ONE - u) - u)); - // Divide by the denominator - batchedEvalRoundAcc = batchedEvalRoundAcc * (challengePower * (ONE - u) + u).invert(); - - batchedEvalAccumulator = batchedEvalRoundAcc; - foldPosEvaluations[i - 1] = batchedEvalRoundAcc; - } - return foldPosEvaluations; - } - - function computeSquares(Fr r, uint256 logN) internal pure returns (Fr[] memory) { - Fr[] memory squares = new Fr[](logN); - squares[0] = r; - for (uint256 i = 1; i < logN; ++i) { - squares[i] = squares[i - 1].sqr(); - } - return squares; - } -} - -uint256 constant Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583; // EC group order. F_q - -// Fr utility - -function bytesToFr(bytes calldata proofSection) pure returns (Fr scalar) { - scalar = FrLib.fromBytes32(bytes32(proofSection)); -} - -// EC Point utilities -function bytesToG1Point(bytes calldata proofSection) pure returns (Honk.G1Point memory point) { - uint256 x = uint256(bytes32(proofSection[0x00:0x20])); - uint256 y = uint256(bytes32(proofSection[0x20:0x40])); - require(x < Q && y < Q, Errors.ValueGeGroupOrder()); - - // Reject the point at infinity (0,0). EVM precompiles silently treat (0,0) - // as the identity element, which could zero out commitments. - // On-curve validation (y² = x³ + 3) is handled by the ecAdd/ecMul precompiles - // per EIP-196, so we only need to catch this special case here. - require((x | y) != 0, Errors.PointAtInfinity()); - - point = Honk.G1Point({x: x, y: y}); -} - -function negateInplace(Honk.G1Point memory point) pure returns (Honk.G1Point memory) { - // When y == 0 (order-2 point), negation is the same point. Q - 0 = Q which is >= Q. - if (point.y != 0) { - point.y = Q - point.y; - } - return point; -} - -/** - * Convert the pairing points to G1 points. - * - * The pairing points are serialised as an array of 2 limbs representing two points - * (P0 and P1, used for lhs and rhs of pairing operation). - * - * There are 2 limbs (lo, hi) for each coordinate, so 4 limbs per point, 8 total. - * Layout: [P0.x_lo, P0.x_hi, P0.y_lo, P0.y_hi, P1.x_lo, P1.x_hi, P1.y_lo, P1.y_hi] - * - * @param pairingPoints The pairing points to convert. - * @return lhs P0 point - * @return rhs P1 point - */ -function convertPairingPointsToG1(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) - pure - returns (Honk.G1Point memory lhs, Honk.G1Point memory rhs) -{ - // P0 (lhs): x = lo | (hi << 136) - uint256 lhsX = Fr.unwrap(pairingPoints[0]); - lhsX |= Fr.unwrap(pairingPoints[1]) << 136; - - uint256 lhsY = Fr.unwrap(pairingPoints[2]); - lhsY |= Fr.unwrap(pairingPoints[3]) << 136; - - // P1 (rhs): x = lo | (hi << 136) - uint256 rhsX = Fr.unwrap(pairingPoints[4]); - rhsX |= Fr.unwrap(pairingPoints[5]) << 136; - - uint256 rhsY = Fr.unwrap(pairingPoints[6]); - rhsY |= Fr.unwrap(pairingPoints[7]) << 136; - - // Reconstructed coordinates must be < Q to prevent malleability. - // Without this, two different limb encodings could map to the same curve point - // (via mulmod reduction in on-curve checks) but produce different transcript hashes. - require(lhsX < Q && lhsY < Q && rhsX < Q && rhsY < Q, Errors.ValueGeGroupOrder()); - - lhs.x = lhsX; - lhs.y = lhsY; - rhs.x = rhsX; - rhs.y = rhsY; -} - -/** - * Hash the pairing inputs from the present verification context with those extracted from the public inputs. - * - * @param proofPairingPoints Pairing points from the proof - (public inputs). - * @param accLhs Accumulator point for the left side - result of shplemini. - * @param accRhs Accumulator point for the right side - result of shplemini. - * @return recursionSeparator The recursion separator - generated from hashing the above. - */ -function generateRecursionSeparator( - Fr[PAIRING_POINTS_SIZE] memory proofPairingPoints, - Honk.G1Point memory accLhs, - Honk.G1Point memory accRhs -) pure returns (Fr recursionSeparator) { - // hash the proof aggregated X - // hash the proof aggregated Y - // hash the accum X - // hash the accum Y - - (Honk.G1Point memory proofLhs, Honk.G1Point memory proofRhs) = convertPairingPointsToG1(proofPairingPoints); - - uint256[8] memory recursionSeparatorElements; - - // Proof points - recursionSeparatorElements[0] = proofLhs.x; - recursionSeparatorElements[1] = proofLhs.y; - recursionSeparatorElements[2] = proofRhs.x; - recursionSeparatorElements[3] = proofRhs.y; - - // Accumulator points - recursionSeparatorElements[4] = accLhs.x; - recursionSeparatorElements[5] = accLhs.y; - recursionSeparatorElements[6] = accRhs.x; - recursionSeparatorElements[7] = accRhs.y; - - recursionSeparator = FrLib.from(uint256(keccak256(abi.encodePacked(recursionSeparatorElements))) % P); -} - -/** - * G1 Mul with Separator - * Using the ecAdd and ecMul precompiles - * - * @param basePoint The point to multiply. - * @param other The other point to add. - * @param recursionSeperator The separator to use for the multiplication. - * @return `(recursionSeperator * basePoint) + other`. - */ -function mulWithSeperator(Honk.G1Point memory basePoint, Honk.G1Point memory other, Fr recursionSeperator) - view - returns (Honk.G1Point memory) -{ - Honk.G1Point memory result; - - result = ecMul(recursionSeperator, basePoint); - result = ecAdd(result, other); - - return result; -} - -/** - * G1 Mul - * Takes a Fr value and a G1 point and uses the ecMul precompile to return the result. - * - * @param value The value to multiply the point by. - * @param point The point to multiply. - * @return result The result of the multiplication. - */ -function ecMul(Fr value, Honk.G1Point memory point) view returns (Honk.G1Point memory) { - Honk.G1Point memory result; - - assembly ("memory-safe") { - let free := mload(0x40) - // Write the point into memory (two 32 byte words) - // Memory layout: - // Address | value - // free | point.x - // free + 0x20| point.y - mstore(free, mload(point)) - mstore(add(free, 0x20), mload(add(point, 0x20))) - // Write the scalar into memory (one 32 byte word) - // Memory layout: - // Address | value - // free + 0x40| value - mstore(add(free, 0x40), value) - - // Call the ecMul precompile, it takes in the following - // [point.x, point.y, scalar], and returns the result back into the free memory location. - let success := staticcall(gas(), 0x07, free, 0x60, free, 0x40) - if iszero(success) { - revert(0, 0) - } - // Copy the result of the multiplication back into the result memory location. - // Memory layout: - // Address | value - // result | result.x - // result + 0x20| result.y - mstore(result, mload(free)) - mstore(add(result, 0x20), mload(add(free, 0x20))) - - mstore(0x40, add(free, 0x60)) - } - - return result; -} - -/** - * G1 Add - * Takes two G1 points and uses the ecAdd precompile to return the result. - * - * @param lhs The left hand side of the addition. - * @param rhs The right hand side of the addition. - * @return result The result of the addition. - */ -function ecAdd(Honk.G1Point memory lhs, Honk.G1Point memory rhs) view returns (Honk.G1Point memory) { - Honk.G1Point memory result; - - assembly ("memory-safe") { - let free := mload(0x40) - // Write lhs into memory (two 32 byte words) - // Memory layout: - // Address | value - // free | lhs.x - // free + 0x20| lhs.y - mstore(free, mload(lhs)) - mstore(add(free, 0x20), mload(add(lhs, 0x20))) - - // Write rhs into memory (two 32 byte words) - // Memory layout: - // Address | value - // free + 0x40| rhs.x - // free + 0x60| rhs.y - mstore(add(free, 0x40), mload(rhs)) - mstore(add(free, 0x60), mload(add(rhs, 0x20))) - - // Call the ecAdd precompile, it takes in the following - // [lhs.x, lhs.y, rhs.x, rhs.y], and returns their addition back into the free memory location. - let success := staticcall(gas(), 0x06, free, 0x80, free, 0x40) - if iszero(success) { revert(0, 0) } - - // Copy the result of the addition back into the result memory location. - // Memory layout: - // Address | value - // result | result.x - // result + 0x20| result.y - mstore(result, mload(free)) - mstore(add(result, 0x20), mload(add(free, 0x20))) - - mstore(0x40, add(free, 0x80)) - } - - return result; -} - -function rejectPointAtInfinity(Honk.G1Point memory point) pure { - require((point.x | point.y) != 0, Errors.PointAtInfinity()); -} - -/** - * Check if pairing point limbs are all zero (default/infinity). - * Default pairing points indicate no recursive verification occurred. - */ -function arePairingPointsDefault(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) pure returns (bool) { - uint256 acc = 0; - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - acc |= Fr.unwrap(pairingPoints[i]); - } - return acc == 0; -} - -function pairing(Honk.G1Point memory rhs, Honk.G1Point memory lhs) view returns (bool decodedResult) { - bytes memory input = abi.encodePacked( - rhs.x, - rhs.y, - // Fixed G2 point - uint256(0x198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c2), - uint256(0x1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed), - uint256(0x090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b), - uint256(0x12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa), - lhs.x, - lhs.y, - // G2 point from VK - uint256(0x260e01b251f6f1c7e7ff4e580791dee8ea51d87a358e038b4efe30fac09383c1), - uint256(0x0118c4d5b837bcc2bc89b5b398b5974e9f5944073b32078b7e231fec938883b0), - uint256(0x04fc6369f7110fe3d25156c1bb9a72859cf2a04641f99ba4ee413c80da6a5fe4), - uint256(0x22febda3c0c0632a56475b4214e5615e11e6dd3f96e6cea2854a87d4dacc5e55) - ); - - (bool success, bytes memory result) = address(0x08).staticcall(input); - decodedResult = success && abi.decode(result, (bool)); -} - -abstract contract BaseZKHonkVerifier is IVerifier { - using FrLib for Fr; - - struct PairingInputs { - Honk.G1Point P_0; - Honk.G1Point P_1; - } - - struct SmallSubgroupIpaIntermediates { - Fr[SUBGROUP_SIZE] challengePolyLagrange; - Fr challengePolyEval; - Fr lagrangeFirst; - Fr lagrangeLast; - Fr rootPower; - Fr[SUBGROUP_SIZE] denominators; // this has to disappear - Fr diff; - } - - // Constants for proof length calculation (matching UltraKeccakZKFlavor) - uint256 internal constant NUM_WITNESS_ENTITIES = 8 + NUM_MASKING_POLYNOMIALS; - uint256 internal constant NUM_ELEMENTS_COMM = 2; // uint256 elements for curve points - uint256 internal constant NUM_ELEMENTS_FR = 1; // uint256 elements for field elements - uint256 internal constant NUM_LIBRA_EVALUATIONS = 4; // libra evaluations - - uint256 internal constant LIBRA_COMMITMENTS = 3; - uint256 internal constant LIBRA_EVALUATIONS = 4; - uint256 internal constant LIBRA_UNIVARIATES_LENGTH = 9; - - uint256 internal constant SHIFTED_COMMITMENTS_START = 30; - uint256 internal constant PERMUTATION_ARGUMENT_VALUE_SEPARATOR = 1 << 28; - - uint256 internal immutable $N; - uint256 internal immutable $LOG_N; - uint256 internal immutable $VK_HASH; - uint256 internal immutable $NUM_PUBLIC_INPUTS; - uint256 internal immutable $MSMSize; - - constructor(uint256 _N, uint256 _logN, uint256 _vkHash, uint256 _numPublicInputs) { - $N = _N; - $LOG_N = _logN; - $VK_HASH = _vkHash; - $NUM_PUBLIC_INPUTS = _numPublicInputs; - $MSMSize = NUMBER_UNSHIFTED_ZK + _logN + LIBRA_COMMITMENTS + 2; - } - - function verify(bytes calldata proof, bytes32[] calldata publicInputs) - public - view - override - returns (bool verified) - { - // Calculate expected proof size based on $LOG_N - uint256 expectedProofSize = calculateProofSize($LOG_N); - - // Check the received proof is the expected size where each field element is 32 bytes - require( - proof.length == expectedProofSize, Errors.ProofLengthWrongWithLogN($LOG_N, proof.length, expectedProofSize) - ); - - Honk.VerificationKey memory vk = loadVerificationKey(); - Honk.ZKProof memory p = ZKTranscriptLib.loadProof(proof, $LOG_N); - - require(publicInputs.length == vk.publicInputsSize - PAIRING_POINTS_SIZE, Errors.PublicInputsLengthWrong()); - - // Generate the fiat shamir challenges for the whole protocol - ZKTranscript memory t = - ZKTranscriptLib.generateTranscript(p, publicInputs, $VK_HASH, $NUM_PUBLIC_INPUTS, $LOG_N); - - // Derive public input delta - t.relationParameters.publicInputsDelta = computePublicInputDelta( - publicInputs, - p.pairingPointObject, - t.relationParameters.beta, - t.relationParameters.gamma, /*pubInputsOffset=*/ - 1 - ); - - // Sumcheck - require(verifySumcheck(p, t), Errors.SumcheckFailed()); - require(verifyShplemini(p, vk, t), Errors.ShpleminiFailed()); - - verified = true; - } - - function computePublicInputDelta( - bytes32[] memory publicInputs, - Fr[PAIRING_POINTS_SIZE] memory pairingPointObject, - Fr beta, - Fr gamma, - uint256 offset - ) internal view returns (Fr publicInputDelta) { - Fr numerator = Fr.wrap(1); - Fr denominator = Fr.wrap(1); - - Fr numeratorAcc = gamma + (beta * FrLib.from(PERMUTATION_ARGUMENT_VALUE_SEPARATOR + offset)); - Fr denominatorAcc = gamma - (beta * FrLib.from(offset + 1)); - - { - for (uint256 i = 0; i < $NUM_PUBLIC_INPUTS - PAIRING_POINTS_SIZE; i++) { - Fr pubInput = FrLib.fromBytes32(publicInputs[i]); - - numerator = numerator * (numeratorAcc + pubInput); - denominator = denominator * (denominatorAcc + pubInput); - - numeratorAcc = numeratorAcc + beta; - denominatorAcc = denominatorAcc - beta; - } - - for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { - Fr pubInput = pairingPointObject[i]; - - numerator = numerator * (numeratorAcc + pubInput); - denominator = denominator * (denominatorAcc + pubInput); - - numeratorAcc = numeratorAcc + beta; - denominatorAcc = denominatorAcc - beta; - } - } - - // Fr delta = numerator / denominator; // TOOO: batch invert later? - publicInputDelta = FrLib.div(numerator, denominator); - } - - function verifySumcheck(Honk.ZKProof memory proof, ZKTranscript memory tp) internal view returns (bool verified) { - Fr roundTargetSum = tp.libraChallenge * proof.libraSum; // default 0 - Fr powPartialEvaluation = Fr.wrap(1); - - // We perform sumcheck reductions over log n rounds ( the multivariate degree ) - for (uint256 round; round < $LOG_N; ++round) { - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariate = proof.sumcheckUnivariates[round]; - Fr totalSum = roundUnivariate[0] + roundUnivariate[1]; - require(totalSum == roundTargetSum, Errors.SumcheckFailed()); - - Fr roundChallenge = tp.sumCheckUChallenges[round]; - - // Update the round target for the next rounf - roundTargetSum = computeNextTargetSum(roundUnivariate, roundChallenge); - powPartialEvaluation = - powPartialEvaluation * (Fr.wrap(1) + roundChallenge * (tp.gateChallenges[round] - Fr.wrap(1))); - } - - // Last round - // For ZK flavors: sumcheckEvaluations has 42 elements - // Index 0 is gemini_masking_poly, indices 1-41 are the regular entities used in relations - Fr[NUMBER_OF_ENTITIES] memory relationsEvaluations; - for (uint256 i = 0; i < NUMBER_OF_ENTITIES; i++) { - relationsEvaluations[i] = proof.sumcheckEvaluations[i + NUM_MASKING_POLYNOMIALS]; // Skip gemini_masking_poly at index 0 - } - Fr grandHonkRelationSum = RelationsLib.accumulateRelationEvaluations( - relationsEvaluations, tp.relationParameters, tp.alphas, powPartialEvaluation - ); - - Fr evaluation = Fr.wrap(1); - for (uint256 i = 2; i < $LOG_N; i++) { - evaluation = evaluation * tp.sumCheckUChallenges[i]; - } - - grandHonkRelationSum = - grandHonkRelationSum * (Fr.wrap(1) - evaluation) + proof.libraEvaluation * tp.libraChallenge; - verified = (grandHonkRelationSum == roundTargetSum); - } - - // Return the new target sum for the next sumcheck round - function computeNextTargetSum(Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariates, Fr roundChallenge) - internal - view - returns (Fr targetSum) - { - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory BARYCENTRIC_LAGRANGE_DENOMINATORS = [ - Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), - Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), - Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000000240), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), - Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), - Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), - Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80) - ]; - - // To compute the next target sum, we evaluate the given univariate at a point u (challenge). - - // Performing Barycentric evaluations - // Compute B(x) - Fr numeratorValue = Fr.wrap(1); - for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { - numeratorValue = numeratorValue * (roundChallenge - Fr.wrap(i)); - } - - Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory denominatorInverses; - for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { - denominatorInverses[i] = FrLib.invert(BARYCENTRIC_LAGRANGE_DENOMINATORS[i] * (roundChallenge - Fr.wrap(i))); - } - - for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { - targetSum = targetSum + roundUnivariates[i] * denominatorInverses[i]; - } - - // Scale the sum by the value of B(x) - targetSum = targetSum * numeratorValue; - } - - function verifyShplemini(Honk.ZKProof memory proof, Honk.VerificationKey memory vk, ZKTranscript memory tp) - internal - view - returns (bool verified) - { - CommitmentSchemeLib.ShpleminiIntermediates memory mem; // stack - - // - Compute vector (r, r², ... , r²⁽ⁿ⁻¹⁾), where n = log_circuit_size - Fr[] memory powers_of_evaluation_challenge = CommitmentSchemeLib.computeSquares(tp.geminiR, $LOG_N); - // Arrays hold values that will be linearly combined for the gemini and shplonk batch openings - Fr[] memory scalars = new Fr[]($MSMSize); - Honk.G1Point[] memory commitments = new Honk.G1Point[]($MSMSize); - - mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[0]).invert(); - mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[0]).invert(); - - mem.unshiftedScalar = mem.posInvertedDenominator + (tp.shplonkNu * mem.negInvertedDenominator); - mem.shiftedScalar = - tp.geminiR.invert() * (mem.posInvertedDenominator - (tp.shplonkNu * mem.negInvertedDenominator)); - - scalars[0] = Fr.wrap(1); - commitments[0] = proof.shplonkQ; - - /* Batch multivariate opening claims, shifted and unshifted - * The vector of scalars is populated as follows: - * \f[ - * \left( - * - \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), - * \ldots, - * - \rho^{i+k-1} \times \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), - * - \rho^{i+k} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right), - * \ldots, - * - \rho^{k+m-1} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right) - * \right) - * \f] - * - * The following vector is concatenated to the vector of commitments: - * \f[ - * f_0, \ldots, f_{m-1}, f_{\text{shift}, 0}, \ldots, f_{\text{shift}, k-1} - * \f] - * - * Simultaneously, the evaluation of the multilinear polynomial - * \f[ - * \sum \rho^i \cdot f_i + \sum \rho^{i+k} \cdot f_{\text{shift}, i} - * \f] - * at the challenge point \f$ (u_0,\ldots, u_{n-1}) \f$ is computed. - * - * This approach minimizes the number of iterations over the commitments to multilinear polynomials - * and eliminates the need to store the powers of \f$ \rho \f$. - */ - // For ZK flavors: evaluations array is [gemini_masking_poly, qm, qc, ql, qr, ...] - // Start batching challenge at 1, not rho, to match non-ZK pattern - mem.batchingChallenge = Fr.wrap(1); - mem.batchedEvaluation = Fr.wrap(0); - - mem.unshiftedScalarNeg = mem.unshiftedScalar.neg(); - mem.shiftedScalarNeg = mem.shiftedScalar.neg(); - - // Process all NUMBER_UNSHIFTED_ZK evaluations (includes gemini_masking_poly at index 0) - for (uint256 i = 1; i <= NUMBER_UNSHIFTED_ZK; ++i) { - scalars[i] = mem.unshiftedScalarNeg * mem.batchingChallenge; - mem.batchedEvaluation = mem.batchedEvaluation - + (proof.sumcheckEvaluations[i - NUM_MASKING_POLYNOMIALS] * mem.batchingChallenge); - mem.batchingChallenge = mem.batchingChallenge * tp.rho; - } - // g commitments are accumulated at r - // For each of the to be shifted commitments perform the shift in place by - // adding to the unshifted value. - // We do so, as the values are to be used in batchMul later, and as - // `a * c + b * c = (a + b) * c` this will allow us to reduce memory and compute. - // Applied to w1, w2, w3, w4 and zPerm - for (uint256 i = 0; i < NUMBER_TO_BE_SHIFTED; ++i) { - uint256 scalarOff = i + SHIFTED_COMMITMENTS_START; - uint256 evaluationOff = i + NUMBER_UNSHIFTED_ZK; - - scalars[scalarOff] = scalars[scalarOff] + (mem.shiftedScalarNeg * mem.batchingChallenge); - mem.batchedEvaluation = - mem.batchedEvaluation + (proof.sumcheckEvaluations[evaluationOff] * mem.batchingChallenge); - mem.batchingChallenge = mem.batchingChallenge * tp.rho; - } - - commitments[1] = proof.geminiMaskingPoly; - - commitments[2] = vk.qm; - commitments[3] = vk.qc; - commitments[4] = vk.ql; - commitments[5] = vk.qr; - commitments[6] = vk.qo; - commitments[7] = vk.q4; - commitments[8] = vk.qLookup; - commitments[9] = vk.qArith; - commitments[10] = vk.qDeltaRange; - commitments[11] = vk.qElliptic; - commitments[12] = vk.qMemory; - commitments[13] = vk.qNnf; - commitments[14] = vk.qPoseidon2External; - commitments[15] = vk.qPoseidon2Internal; - commitments[16] = vk.s1; - commitments[17] = vk.s2; - commitments[18] = vk.s3; - commitments[19] = vk.s4; - commitments[20] = vk.id1; - commitments[21] = vk.id2; - commitments[22] = vk.id3; - commitments[23] = vk.id4; - commitments[24] = vk.t1; - commitments[25] = vk.t2; - commitments[26] = vk.t3; - commitments[27] = vk.t4; - commitments[28] = vk.lagrangeFirst; - commitments[29] = vk.lagrangeLast; - - // Accumulate proof points - commitments[30] = proof.w1; - commitments[31] = proof.w2; - commitments[32] = proof.w3; - commitments[33] = proof.w4; - commitments[34] = proof.zPerm; - commitments[35] = proof.lookupInverses; - commitments[36] = proof.lookupReadCounts; - commitments[37] = proof.lookupReadTags; - - /* Batch gemini claims from the prover - * place the commitments to gemini aᵢ to the vector of commitments, compute the contributions from - * aᵢ(−r²ⁱ) for i=1, … , n−1 to the constant term accumulator, add corresponding scalars - * - * 1. Moves the vector - * \f[ - * \left( \text{com}(A_1), \text{com}(A_2), \ldots, \text{com}(A_{n-1}) \right) - * \f] - * to the 'commitments' vector. - * - * 2. Computes the scalars: - * \f[ - * \frac{\nu^{2}}{z + r^2}, \frac{\nu^3}{z + r^4}, \ldots, \frac{\nu^{n-1}}{z + r^{2^{n-1}}} - * \f] - * and places them into the 'scalars' vector. - * - * 3. Accumulates the summands of the constant term: - * \f[ - * \sum_{i=2}^{n-1} \frac{\nu^{i} \cdot A_i(-r^{2^i})}{z + r^{2^i}} - * \f] - * and adds them to the 'constant_term_accumulator'. - */ - - // Add contributions from A₀(r) and A₀(-r) to constant_term_accumulator: - // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., $LOG_N - 1 - Fr[] memory foldPosEvaluations = CommitmentSchemeLib.computeFoldPosEvaluations( - tp.sumCheckUChallenges, - mem.batchedEvaluation, - proof.geminiAEvaluations, - powers_of_evaluation_challenge, - $LOG_N - ); - - mem.constantTermAccumulator = foldPosEvaluations[0] * mem.posInvertedDenominator; - mem.constantTermAccumulator = - mem.constantTermAccumulator + (proof.geminiAEvaluations[0] * tp.shplonkNu * mem.negInvertedDenominator); - - mem.batchingChallenge = tp.shplonkNu.sqr(); - uint256 boundary = NUMBER_UNSHIFTED_ZK + 1; - - // Compute Shplonk constant term contributions from Aₗ(± r^{2ˡ}) for l = 1, ..., m-1; - // Compute scalar multipliers for each fold commitment - for (uint256 i = 0; i < $LOG_N - 1; ++i) { - bool dummy_round = i >= ($LOG_N - 1); - - if (!dummy_round) { - // Update inverted denominators - mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[i + 1]).invert(); - mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[i + 1]).invert(); - - // Compute the scalar multipliers for Aₗ(± r^{2ˡ}) and [Aₗ] - mem.scalingFactorPos = mem.batchingChallenge * mem.posInvertedDenominator; - mem.scalingFactorNeg = mem.batchingChallenge * tp.shplonkNu * mem.negInvertedDenominator; - scalars[boundary + i] = mem.scalingFactorNeg.neg() + mem.scalingFactorPos.neg(); - - // Accumulate the const term contribution given by - // v^{2l} * Aₗ(r^{2ˡ}) /(z-r^{2^l}) + v^{2l+1} * Aₗ(-r^{2ˡ}) /(z+ r^{2^l}) - Fr accumContribution = mem.scalingFactorNeg * proof.geminiAEvaluations[i + 1]; - accumContribution = accumContribution + mem.scalingFactorPos * foldPosEvaluations[i + 1]; - mem.constantTermAccumulator = mem.constantTermAccumulator + accumContribution; - } - // Update the running power of v - mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu * tp.shplonkNu; - - commitments[boundary + i] = proof.geminiFoldComms[i]; - } - - boundary += $LOG_N - 1; - - // Finalize the batch opening claim - mem.denominators[0] = Fr.wrap(1).div(tp.shplonkZ - tp.geminiR); - mem.denominators[1] = Fr.wrap(1).div(tp.shplonkZ - SUBGROUP_GENERATOR * tp.geminiR); - mem.denominators[2] = mem.denominators[0]; - mem.denominators[3] = mem.denominators[0]; - - for (uint256 i = 0; i < LIBRA_EVALUATIONS; i++) { - Fr scalingFactor = mem.denominators[i] * mem.batchingChallenge; - mem.batchingScalars[i] = scalingFactor.neg(); - mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu; - mem.constantTermAccumulator = mem.constantTermAccumulator + scalingFactor * proof.libraPolyEvals[i]; - } - scalars[boundary] = mem.batchingScalars[0]; - scalars[boundary + 1] = mem.batchingScalars[1] + mem.batchingScalars[2]; - scalars[boundary + 2] = mem.batchingScalars[3]; - - for (uint256 i = 0; i < LIBRA_COMMITMENTS; i++) { - commitments[boundary++] = proof.libraCommitments[i]; - } - - commitments[boundary] = Honk.G1Point({x: 1, y: 2}); - scalars[boundary++] = mem.constantTermAccumulator; - - require( - checkEvalsConsistency(proof.libraPolyEvals, tp.geminiR, tp.sumCheckUChallenges, proof.libraEvaluation), - Errors.ConsistencyCheckFailed() - ); - - Honk.G1Point memory quotient_commitment = proof.kzgQuotient; - - commitments[boundary] = quotient_commitment; - scalars[boundary] = tp.shplonkZ; // evaluation challenge - - PairingInputs memory pair; - pair.P_0 = batchMul(commitments, scalars); - pair.P_1 = negateInplace(quotient_commitment); - - // Aggregate pairing points (skip if default/infinity — no recursive verification occurred) - if (!arePairingPointsDefault(proof.pairingPointObject)) { - Fr recursionSeparator = generateRecursionSeparator(proof.pairingPointObject, pair.P_0, pair.P_1); - (Honk.G1Point memory P_0_other, Honk.G1Point memory P_1_other) = - convertPairingPointsToG1(proof.pairingPointObject); - - // Validate the points from the proof are on the curve - rejectPointAtInfinity(P_0_other); - rejectPointAtInfinity(P_1_other); - - // accumulate with aggregate points in proof - pair.P_0 = mulWithSeperator(pair.P_0, P_0_other, recursionSeparator); - pair.P_1 = mulWithSeperator(pair.P_1, P_1_other, recursionSeparator); - } - - return pairing(pair.P_0, pair.P_1); - } - - function checkEvalsConsistency( - Fr[LIBRA_EVALUATIONS] memory libraPolyEvals, - Fr geminiR, - Fr[CONST_PROOF_SIZE_LOG_N] memory uChallenges, - Fr libraEval - ) internal view returns (bool check) { - Fr one = Fr.wrap(1); - Fr vanishingPolyEval = geminiR.pow(SUBGROUP_SIZE) - one; - require(vanishingPolyEval != Fr.wrap(0), Errors.GeminiChallengeInSubgroup()); - - SmallSubgroupIpaIntermediates memory mem; - mem.challengePolyLagrange[0] = one; - for (uint256 round = 0; round < $LOG_N; round++) { - uint256 currIdx = 1 + LIBRA_UNIVARIATES_LENGTH * round; - mem.challengePolyLagrange[currIdx] = one; - for (uint256 idx = currIdx + 1; idx < currIdx + LIBRA_UNIVARIATES_LENGTH; idx++) { - mem.challengePolyLagrange[idx] = mem.challengePolyLagrange[idx - 1] * uChallenges[round]; - } - } - - mem.rootPower = one; - mem.challengePolyEval = Fr.wrap(0); - for (uint256 idx = 0; idx < SUBGROUP_SIZE; idx++) { - mem.denominators[idx] = mem.rootPower * geminiR - one; - mem.denominators[idx] = mem.denominators[idx].invert(); - mem.challengePolyEval = mem.challengePolyEval + mem.challengePolyLagrange[idx] * mem.denominators[idx]; - mem.rootPower = mem.rootPower * SUBGROUP_GENERATOR_INVERSE; - } - - Fr numerator = vanishingPolyEval * Fr.wrap(SUBGROUP_SIZE).invert(); - mem.challengePolyEval = mem.challengePolyEval * numerator; - mem.lagrangeFirst = mem.denominators[0] * numerator; - mem.lagrangeLast = mem.denominators[SUBGROUP_SIZE - 1] * numerator; - - mem.diff = mem.lagrangeFirst * libraPolyEvals[2]; - - mem.diff = mem.diff + (geminiR - SUBGROUP_GENERATOR_INVERSE) - * (libraPolyEvals[1] - libraPolyEvals[2] - libraPolyEvals[0] * mem.challengePolyEval); - mem.diff = mem.diff + mem.lagrangeLast * (libraPolyEvals[2] - libraEval) - vanishingPolyEval * libraPolyEvals[3]; - - check = mem.diff == Fr.wrap(0); - } - - // This implementation is the same as above with different constants - function batchMul(Honk.G1Point[] memory base, Fr[] memory scalars) - internal - view - returns (Honk.G1Point memory result) - { - uint256 limit = $MSMSize; - - // Validate all points are on the curve - for (uint256 i = 0; i < limit; ++i) { - rejectPointAtInfinity(base[i]); - } - - bool success = true; - assembly ("memory-safe") { - let free := mload(0x40) - - let count := 0x01 - for {} lt(count, add(limit, 1)) { count := add(count, 1) } { - // Get loop offsets - let base_base := add(base, mul(count, 0x20)) - let scalar_base := add(scalars, mul(count, 0x20)) - - mstore(add(free, 0x40), mload(mload(base_base))) - mstore(add(free, 0x60), mload(add(0x20, mload(base_base)))) - // Add scalar - mstore(add(free, 0x80), mload(scalar_base)) - - success := and(success, staticcall(gas(), 7, add(free, 0x40), 0x60, add(free, 0x40), 0x40)) - // accumulator = accumulator + accumulator_2 - success := and(success, staticcall(gas(), 6, free, 0x80, free, 0x40)) - } - - // Return the result - mstore(result, mload(free)) - mstore(add(result, 0x20), mload(add(free, 0x20))) - } - - require(success, Errors.ShpleminiFailed()); - } - - // Calculate proof size based on log_n (matching UltraKeccakZKFlavor formula) - function calculateProofSize(uint256 logN) internal pure returns (uint256) { - // Witness and Libra commitments - uint256 proofLength = NUM_WITNESS_ENTITIES * NUM_ELEMENTS_COMM; // witness commitments - proofLength += NUM_ELEMENTS_COMM * 3; // Libra concat, grand sum, quotient comms + Gemini masking - - // Sumcheck - proofLength += logN * ZK_BATCHED_RELATION_PARTIAL_LENGTH * NUM_ELEMENTS_FR; // sumcheck univariates - proofLength += NUMBER_OF_ENTITIES_ZK * NUM_ELEMENTS_FR; // sumcheck evaluations - - // Libra and Gemini - proofLength += NUM_ELEMENTS_FR * 2; // Libra sum, claimed eval - proofLength += logN * NUM_ELEMENTS_FR; // Gemini a evaluations - proofLength += NUM_LIBRA_EVALUATIONS * NUM_ELEMENTS_FR; // libra evaluations - - // PCS commitments - proofLength += (logN - 1) * NUM_ELEMENTS_COMM; // Gemini Fold commitments - proofLength += NUM_ELEMENTS_COMM * 2; // Shplonk Q and KZG W commitments - - // Pairing points - proofLength += PAIRING_POINTS_SIZE; // pairing inputs carried on public inputs - - return proofLength * 32; - } - - function loadVerificationKey() internal pure virtual returns (Honk.VerificationKey memory); -} - -contract XHonkVerifier is BaseZKHonkVerifier(N, LOG_N, VK_HASH, NUMBER_OF_PUBLIC_INPUTS) { - function loadVerificationKey() internal pure override returns (Honk.VerificationKey memory) { - return HonkVerificationKey.loadVerificationKey(); - } -} diff --git a/solidity/contracts/login/zk/XZkVerifier.sol b/solidity/contracts/login/zk/XZkVerifier.sol deleted file mode 100644 index a5086d3a..00000000 --- a/solidity/contracts/login/zk/XZkVerifier.sol +++ /dev/null @@ -1,561 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; -import {IZkSessionVerifier} from "./IZkSessionVerifier.sol"; -import {INotary} from "../../notary/INotary.sol"; - -interface IHonkVerifier { - function verify(bytes calldata proof, bytes32[] calldata publicInputs) external view returns (bool); -} - -/// X ZK identity verifier — small token-only proof + two TLSN attestations. -/// Public-input layout is documented inline above the offset constants. -/// Registry (not this contract) enforces walletAddress semantics. -contract XZkVerifier is IZkSessionVerifier, Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { - struct TokenAttestation { - bytes32 bearerHash; - uint32 bearerRangeStart; - uint32 bearerRangeEnd; - bytes sentRevealed; - uint64 timestamp; - bytes notarySignature; - } - - /// /me sentRevealed = concat of two TLSN-revealed ranges: - /// [0, bearerRangeStart) — request line + headers up to and - /// including `authorization: Bearer `. - /// Ends at `sentPrefixEnd == bearerRangeStart`. - /// [bearerRangeEnd, bearerRangeEnd + 2) — exactly the 2 bytes after - /// the bearer, which MUST be CRLF. - /// Ends at `sentSuffixEnd == bearerRangeEnd + 2`. - /// Anchoring the end of the bearer range canonicalizes `bearer_len`, so - /// one real OAuth bearer hashes to exactly one nullifier (H1). - struct MeAttestation { - bytes32 bearerHash; - uint32 bearerRangeStart; - uint32 bearerRangeEnd; - bytes sentRevealed; - uint32 sentPrefixEnd; - uint32 sentSuffixEnd; - bytes recvRevealed; - string handle; - /// Immutable platform user-id from the same /me response (X `"id":"…"`). - /// "" when the prover did not reveal it (handle-key fallback). - string userId; - address sessionAddr; - uint64 timestamp; - bytes notarySignature; - } - - struct XProof { - bytes proof; - bytes32[] publicInputs; - TokenAttestation tokenAttest; - MeAttestation meAttest; - } - - // PI layout: one byte per slot, low-byte of each bytes32. - // [ 0..32) bearer_hash_token SHA256(bearer || blinder_token) - // [ 32..64) bearer_hash_me SHA256(bearer || blinder_me) - // [ 64..96) nullifier keccak256(bearer_padded || len.be32) - // [ 96..116) wallet_address - // [116..136) session_addr - uint256 private constant OFF_BEARER_HASH_TOKEN = 0; - uint256 private constant OFF_BEARER_HASH_ME = 32; - uint256 private constant OFF_NULLIFIER = 64; - uint256 private constant OFF_WALLET_ADDR = 96; - uint256 private constant OFF_SESSION_ADDR = 116; - uint256 private constant TOTAL_PUBLIC_INPUTS = 136; - - uint256 public constant CLOCK_SKEW_GRACE = 5 minutes; - uint256 private constant ZK_PROOF_TTL = 10 minutes; - uint256 private constant MAX_CLIENT_ID_LEN = 64; - /// Allowlist size cap. Verification scans the revealed request once per - /// entry, so this bounds worst-case gas for `_anyClientIdMatches`. - uint256 private constant MAX_CLIENT_IDS = 16; - uint256 private constant MAX_REVEALED_LEN = 4096; - uint256 private constant MAX_HANDLE_LEN = 32; - - /// Domain-sep tags for the single notary key. - bytes32 internal constant OP_TOKEN_ATTEST = keccak256("XZkVerifier.token.v1"); - bytes32 internal constant OP_ME_ATTEST = keccak256("XZkVerifier.me.v1"); - - /// The Notary contract whose attestations this accepts. The digests still - /// bind `address(this)` + the OP tags; only the check itself is shared. - INotary public notaryContract; - IHonkVerifier public honkVerifier; - string public endpoint; - string public handlePrefix; - string public override platformName; - - /// Allowed OAuth `client_id`s. A /token proof passes when the revealed - /// request carries ANY of them (see `_anyClientIdMatches`). Kept as an array - /// because matching is a substring search over the revealed request, not a - /// lookup by a caller-supplied id. - bytes[] private _clientIds; - /// keccak256(client_id) => allowed. Mirrors `_clientIds` for O(1) membership - /// and dedupe. - mapping(bytes32 => bool) public isClientIdAllowed; - /// When true, ANY client_id is accepted. - /// - /// Deliberately a separate flag: an EMPTY allowlist means DENY ALL, never - /// "allow everyone". Conflating "not configured yet" with "intentionally - /// permissive" would let an unseeded deploy, a botched migration, or removal - /// of the last entry silently drop the app binding (fail-open). Enabling this - /// removes the proof that the notarized /token came from a known OAuth app — - /// any app's token can then register a session. That is the permissionless - /// model, so it must be an explicit, evented choice. - bool public openClientIds; - - /// X `/me` numeric id is JSON `"id":""`. Fixed for this platform, - /// so it's a constant rather than an init param (no deploy-signature change). - string private constant ID_PREFIX = "\"id\":\""; - string private constant ID_SUFFIX = "\""; - - error InvalidPublicInputsLength(); - error ZkVerificationFailed(); - error NotaryVerificationFailed(); - error FutureProof(); - error StaleProof(); - error BearerHashMismatch(); - error ClientIdMismatch(); - error MeRequestPrefixMismatch(); - error MeBearerAdjacencyMismatch(); - error MeBearerEndAdjacencyMismatch(); - error MeBearerEndNotCrlf(); - error MeBadSentLayout(); - error HandleNotFound(); - error IdNotFound(); - error EmptyHandle(); - error HandleTooLong(); - error RevealedTooLong(); - error SessionAddrMismatch(); - error ClientIdAlreadyAllowed(); - error ClientIdNotAllowed(); - error TooManyClientIds(); - error BadClientIdLength(); - - event ClientIdAdded(bytes clientId); - event ClientIdRemoved(bytes clientId); - /// Emitted on every permissive-mode toggle so the trust change is auditable. - event OpenClientIdsSet(bool open); - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @param _notaryContract The shared Notary contract (INotary). Notary key - /// rotation happens THERE; this contract holds only the pointer. - function initialize( - address _owner, - address _notaryContract, - IHonkVerifier _honkVerifier, - bytes calldata _xClientId, - string calldata _endpoint, - string calldata _handlePrefix, - string calldata _platformName - ) external initializer { - __Ownable_init(_owner); - __UUPSUpgradeable_init(); - require(_notaryContract != address(0), "zero notary contract"); - require(address(_honkVerifier) != address(0), "zero verifier"); - require(bytes(_endpoint).length > 0, "empty endpoint"); - require(bytes(_handlePrefix).length > 0, "empty prefix"); - require(bytes(_platformName).length > 0, "empty platform"); - notaryContract = INotary(_notaryContract); - honkVerifier = _honkVerifier; - // Seed the allowlist with the deploying app so existing deployments and - // the unchanged deploy signature keep working. - _addClientId(_xClientId); - endpoint = _endpoint; - handlePrefix = _handlePrefix; - platformName = _platformName; - } - - /// The current notary signer, read through the Notary contract. - function notary() external view returns (address) { - return notaryContract.notary(); - } - - function setHonkVerifier(IHonkVerifier _v) external onlyOwner { - require(address(_v) != address(0), "zero verifier"); - honkVerifier = _v; - } - - /// Allow one more OAuth app's `client_id` to register sessions here. - function addClientId(bytes calldata _id) external onlyOwner { - _addClientId(_id); - } - - /// Revoke a `client_id`. Removing the last entry leaves the allowlist EMPTY, - /// which denies everything (unless `openClientIds` is explicitly on) — that - /// fail-closed outcome is intentional. - function removeClientId(bytes calldata _id) external onlyOwner { - bytes32 key = keccak256(_id); - if (!isClientIdAllowed[key]) revert ClientIdNotAllowed(); - delete isClientIdAllowed[key]; - - uint256 n = _clientIds.length; - for (uint256 i = 0; i < n; i++) { - if (keccak256(_clientIds[i]) == key) { - _clientIds[i] = _clientIds[n - 1]; - _clientIds.pop(); - break; - } - } - emit ClientIdRemoved(_id); - } - - /// Accept ANY client_id. See `openClientIds` — this drops the app binding - /// and is a trust-model change, not a config knob. - function setOpenClientIds(bool _open) external onlyOwner { - openClientIds = _open; - emit OpenClientIdsSet(_open); - } - - function clientIdCount() external view returns (uint256) { - return _clientIds.length; - } - - function clientIdAt(uint256 _i) external view returns (bytes memory) { - return _clientIds[_i]; - } - - function _addClientId(bytes memory _id) internal { - if (_id.length == 0 || _id.length > MAX_CLIENT_ID_LEN) revert BadClientIdLength(); - if (_clientIds.length >= MAX_CLIENT_IDS) revert TooManyClientIds(); - bytes32 key = keccak256(_id); - if (isClientIdAllowed[key]) revert ClientIdAlreadyAllowed(); - isClientIdAllowed[key] = true; - _clientIds.push(_id); - emit ClientIdAdded(_id); - } - - function setEndpoint(string calldata _endpoint) external onlyOwner { - require(bytes(_endpoint).length > 0, "empty endpoint"); - endpoint = _endpoint; - } - - function setHandlePrefix(string calldata _prefix) external onlyOwner { - require(bytes(_prefix).length > 0, "empty prefix"); - handlePrefix = _prefix; - } - - function setPlatformName(string calldata _name) external onlyOwner { - require(bytes(_name).length > 0, "empty platform"); - platformName = _name; - } - - function verifyAndExtract(bytes calldata payload) - external - view - override - returns ( - string memory handle, - address sessionKey, - address walletAddress, - uint256 expiresAt, - bytes32 nullifier, - string memory userId - ) - { - XProof calldata p = _decode(payload); - - if (p.publicInputs.length != TOTAL_PUBLIC_INPUTS) revert InvalidPublicInputsLength(); - if (p.tokenAttest.sentRevealed.length > MAX_REVEALED_LEN) revert RevealedTooLong(); - if (p.meAttest.sentRevealed.length > MAX_REVEALED_LEN) revert RevealedTooLong(); - if (p.meAttest.recvRevealed.length > MAX_REVEALED_LEN) revert RevealedTooLong(); - if (bytes(p.meAttest.handle).length == 0) revert EmptyHandle(); - if (bytes(p.meAttest.handle).length > MAX_HANDLE_LEN) revert HandleTooLong(); - - _checkTimestamp(p.tokenAttest.timestamp); - _checkTimestamp(p.meAttest.timestamp); - - // Dual-blinder cross-bind: same bearer hashes to BOTH commits via the - // circuit, but with different blinders (one per TLSN session). - if (_extractBytes32(p.publicInputs, OFF_BEARER_HASH_TOKEN) != p.tokenAttest.bearerHash) { - revert BearerHashMismatch(); - } - if (_extractBytes32(p.publicInputs, OFF_BEARER_HASH_ME) != p.meAttest.bearerHash) { - revert BearerHashMismatch(); - } - - // sessionAddr from PI must equal notary-signed value in meAttest. - address piSession = _extractAddress(p.publicInputs, OFF_SESSION_ADDR); - if (piSession != p.meAttest.sessionAddr) revert SessionAddrMismatch(); - - // /token: client_id bound + notary sig. - if (!_anyClientIdMatches(p.tokenAttest.sentRevealed)) revert ClientIdMismatch(); - _verifyTokenSig(p.tokenAttest); - - // /me: request structure + handle present in RECV + notary sig. - _verifyMeRequestStructure(p.meAttest); - _verifyHandleInRecv(p.meAttest); - _verifyIdInRecv(p.meAttest); - _verifyMeSig(p.meAttest); - - // ZK proof (token-only). - if (!honkVerifier.verify(p.proof, p.publicInputs)) revert ZkVerificationFailed(); - - handle = p.meAttest.handle; - userId = p.meAttest.userId; - sessionKey = piSession; - walletAddress = _extractAddress(p.publicInputs, OFF_WALLET_ADDR); - bytes32 rawNullifier = _extractBytes32(p.publicInputs, OFF_NULLIFIER); - nullifier = keccak256(abi.encode(keccak256(bytes(platformName)), rawNullifier)); - // TTL anchored to the OLDER of the two attestations -- the proof is - // only as fresh as its stalest input. - uint256 anchorTs = uint256(p.tokenAttest.timestamp) < uint256(p.meAttest.timestamp) - ? uint256(p.tokenAttest.timestamp) - : uint256(p.meAttest.timestamp); - expiresAt = anchorTs + ZK_PROOF_TTL; - } - - function _extractBytes32(bytes32[] calldata pi, uint256 offset) internal pure returns (bytes32 result) { - assembly ("memory-safe") { - let r := 0 - let src := add(pi.offset, mul(offset, 0x20)) - for { let i := 0 } lt(i, 32) { i := add(i, 1) } { - r := or(r, shl(mul(sub(31, i), 8), and(calldataload(add(src, mul(i, 0x20))), 0xff))) - } - result := r - } - } - - function _extractAddress(bytes32[] calldata pi, uint256 offset) internal pure returns (address result) { - assembly ("memory-safe") { - let r := 0 - let src := add(pi.offset, mul(offset, 0x20)) - for { let i := 0 } lt(i, 20) { i := add(i, 1) } { - r := or(r, shl(mul(sub(19, i), 8), and(calldataload(add(src, mul(i, 0x20))), 0xff))) - } - result := r - } - } - - function _decode(bytes calldata payload) internal pure returns (XProof calldata p) { - assembly ("memory-safe") { - p := add(payload.offset, calldataload(payload.offset)) - } - } - - function _checkTimestamp(uint64 ts) internal view { - if (ts > block.timestamp + CLOCK_SKEW_GRACE) revert FutureProof(); - if (block.timestamp > uint256(ts) + ZK_PROOF_TTL) revert StaleProof(); - } - - function _verifyMeRequestStructure(MeAttestation calldata att) internal view { - bytes memory prefix = abi.encodePacked("GET ", endpoint, " HTTP/1.1\r\n"); - bytes memory authSuffix = bytes("authorization: Bearer "); - - bytes memory sent = att.sentRevealed; - - // H1: sentRevealed is concat of [prefix part] ++ [2-byte CRLF post-bearer]. - // Length must be exactly `prefixLen + 2`. `prefixLen` = sentPrefixEnd - // because the prefix range starts at offset 0. - uint256 prefixLen = uint256(att.sentPrefixEnd); - if (sent.length != prefixLen + 2) revert MeBadSentLayout(); - if (prefixLen < prefix.length + authSuffix.length) revert MeRequestPrefixMismatch(); - - // Prefix part [0..prefixLen) must start with `GET ... HTTP/1.1\r\n` and - // end with `authorization: Bearer `. - for (uint256 i = 0; i < prefix.length; i++) { - if (sent[i] != prefix[i]) revert MeRequestPrefixMismatch(); - } - uint256 suffixStart = prefixLen - authSuffix.length; - for (uint256 i = 0; i < authSuffix.length; i++) { - if (sent[suffixStart + i] != authSuffix[i]) revert MeRequestPrefixMismatch(); - } - - // Start adjacency: bearer starts immediately after the revealed prefix. - if (att.sentPrefixEnd != att.bearerRangeStart) revert MeBearerAdjacencyMismatch(); - - // End adjacency (H1): the second revealed range covers exactly the 2 - // bytes immediately after the bearer. - if (att.sentSuffixEnd != att.bearerRangeEnd + 2) revert MeBearerEndAdjacencyMismatch(); - - // The trailing 2 bytes of sentRevealed are the post-bearer bytes. They - // MUST be CRLF. Bearer is base64url; `\r\n` cannot appear inside it, - // so truncating the bearer range and re-anchoring on a fake CRLF - // elsewhere in the request is impossible without rewriting the wire. - if (sent[prefixLen] != 0x0d) revert MeBearerEndNotCrlf(); - if (sent[prefixLen + 1] != 0x0a) revert MeBearerEndNotCrlf(); - } - - function _verifyHandleInRecv(MeAttestation calldata att) internal view { - bytes memory needle = abi.encodePacked(handlePrefix, att.handle, '"'); - bytes memory prefix = bytes(handlePrefix); - bytes memory haystack = att.recvRevealed; - if (prefix.length == 0 || haystack.length < needle.length) revert HandleNotFound(); - - // Single pass: count `handlePrefix` occurrences and verify the full - // needle matches at the unique prefix hit. Rejecting >1 prefix hit - // defends against an unescaped upstream field containing a synthetic - // `"username":""`. - uint256 needleStart = type(uint256).max; - uint256 hits = 0; - uint256 limit = haystack.length - prefix.length + 1; - for (uint256 i = 0; i < limit; i++) { - bool m = true; - for (uint256 j = 0; j < prefix.length; j++) { - if (haystack[i + j] != prefix[j]) { - m = false; - break; - } - } - if (m) { - hits++; - if (hits > 1) revert HandleNotFound(); - needleStart = i; - } - } - if (hits == 0) revert HandleNotFound(); - if (needleStart + needle.length > haystack.length) revert HandleNotFound(); - for (uint256 j = 0; j < needle.length; j++) { - if (haystack[needleStart + j] != needle[j]) revert HandleNotFound(); - } - } - - /// Verify the immutable user-id is present in the notary-attested /me recv - /// bytes as `"id":""`. Empty userId → skip (handle-key fallback). - /// Same unique-prefix-hit guard as the handle check. - function _verifyIdInRecv(MeAttestation calldata att) internal pure { - // Id is required — identity binds on the immutable platform id, not the - // mutable handle. An attestation without a revealed id is rejected. - if (bytes(att.userId).length == 0) revert IdNotFound(); - bytes memory needle = abi.encodePacked(ID_PREFIX, att.userId, ID_SUFFIX); - bytes memory prefix = bytes(ID_PREFIX); - bytes memory haystack = att.recvRevealed; - if (haystack.length < needle.length) revert IdNotFound(); - - uint256 needleStart = type(uint256).max; - uint256 hits = 0; - uint256 limit = haystack.length - prefix.length + 1; - for (uint256 i = 0; i < limit; i++) { - bool m = true; - for (uint256 j = 0; j < prefix.length; j++) { - if (haystack[i + j] != prefix[j]) { - m = false; - break; - } - } - if (m) { - hits++; - if (hits > 1) revert IdNotFound(); - needleStart = i; - } - } - if (hits == 0) revert IdNotFound(); - if (needleStart + needle.length > haystack.length) revert IdNotFound(); - for (uint256 j = 0; j < needle.length; j++) { - if (haystack[needleStart + j] != needle[j]) revert IdNotFound(); - } - } - - function _verifyTokenSig(TokenAttestation calldata att) internal view { - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(this), - keccak256(bytes(platformName)), - OP_TOKEN_ATTEST, - att.bearerHash, - uint256(att.bearerRangeStart), - uint256(att.bearerRangeEnd), - keccak256(att.sentRevealed), - uint256(att.timestamp) - ) - ); - if (!notaryContract.verify(digest, att.notarySignature)) revert NotaryVerificationFailed(); - } - - function _verifyMeSig(MeAttestation calldata att) internal view { - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(this), - keccak256(bytes(platformName)), - OP_ME_ATTEST, - att.bearerHash, - uint256(att.bearerRangeStart), - uint256(att.bearerRangeEnd), - keccak256(att.sentRevealed), - uint256(att.sentPrefixEnd), - uint256(att.sentSuffixEnd), - keccak256(att.recvRevealed), - keccak256(bytes(att.handle)), - keccak256(bytes(att.userId)), - att.sessionAddr, - uint256(att.timestamp) - ) - ); - if (!notaryContract.verify(digest, att.notarySignature)) revert NotaryVerificationFailed(); - } - - /// True when the revealed /token request carries an accepted `client_id`. - /// An empty allowlist matches nothing: "unconfigured" denies rather than - /// allows. - /// - /// Scans the revealed request ONCE, extracts the `client_id=` value, and - /// answers from the `isClientIdAllowed` mirror. The previous shape re-ran - /// the full O(revealedLen) scan per allowlist entry, so a proof from the - /// last-listed app paid MAX_CLIENT_IDS times the dominant cost of - /// `verifyAndExtract` — enough to push `register_session_zk` toward the - /// block gas limit for exactly the multi-app case the allowlist exists to - /// serve. Cost is now independent of how many ids are configured. - function _anyClientIdMatches(bytes memory revealed) internal view returns (bool) { - if (openClientIds) return true; - if (_clientIds.length == 0) return false; - - bytes10 needle = "client_id="; - uint256 revealedLen = revealed.length; - if (revealedLen < 11) return false; - - uint256 limit = revealedLen - 10; - for (uint256 i = 0; i < limit; i++) { - bool m = true; - for (uint256 j = 0; j < 10; j++) { - if (revealed[i + j] != needle[j]) { - m = false; - break; - } - } - if (!m) continue; - - // Value runs to `&`, CR, or end-of-buffer. Bounded by - // MAX_CLIENT_ID_LEN so a pathological revealed buffer cannot make - // this scan unbounded; an over-long value simply fails to match. - uint256 start = i + 10; - uint256 end = start; - while (end < revealedLen) { - bytes1 b = revealed[end]; - if (b == 0x26 || b == 0x0d) break; - unchecked { - ++end; - } - if (end - start > MAX_CLIENT_ID_LEN) break; - } - uint256 valueLen = end - start; - if (valueLen == 0 || valueLen > MAX_CLIENT_ID_LEN) continue; - - bytes memory value = new bytes(valueLen); - for (uint256 j = 0; j < valueLen; j++) { - value[j] = revealed[start + j]; - } - if (isClientIdAllowed[keccak256(value)]) return true; - } - return false; - } - - function _authorizeUpgrade(address) internal override onlyOwner {} - - /// @dev Renouncing ownership is disabled: a zero owner would permanently - /// brick the UUPS upgrade path and every admin function. - function renounceOwnership() public pure override { - revert("renounce disabled"); - } -} diff --git a/solidity/contracts/login/zk/test/XZkVerifierAllowlist.t.sol b/solidity/contracts/login/zk/test/XZkVerifierAllowlist.t.sol deleted file mode 100644 index 3839aa3d..00000000 --- a/solidity/contracts/login/zk/test/XZkVerifierAllowlist.t.sol +++ /dev/null @@ -1,208 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {XZkVerifier} from "../XZkVerifier.sol"; -import {XZkVerifierBindingsTest} from "./XZkVerifierBindings.t.sol"; - -/// Multi-app `client_id` allowlist on XZkVerifier. -/// -/// The security property under test is FAIL-CLOSED: an empty allowlist denies -/// every client_id. "Not configured yet" must never be read as "allow everyone", -/// otherwise an unseeded deploy or the removal of the last entry would silently -/// drop the app binding. Permissive mode exists, but only as the explicit -/// `openClientIds` flag. -/// -/// Inherits the bindings test for its fixtures and attestation builders. -contract XZkVerifierAllowlistTest is XZkVerifierBindingsTest { - address constant WALLET = address(0x1111); - address constant SESSION = address(0x5E5510); - bytes32 constant BEARER_HASH = bytes32(uint256(0xBEEF)); - bytes32 constant NULLIFIER = bytes32(uint256(0xC0FFEE)); - - bytes constant OTHER_CLIENT_ID = bytes("second-app-id-456"); - - /// Payload whose /token body carries `client_id=`, notary-signed - /// over that exact body. Mirrors _buildTokenAttest but with a caller-chosen - /// client_id so a non-allowlisted app can be exercised. - function _payloadWithClientId(bytes memory clientId) internal view returns (bytes memory) { - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(BEARER_HASH, uint64(block.timestamp)); - tokenAttest.sentRevealed = abi.encodePacked("grant_type=authorization_code&client_id=", clientId, "&code=xyz"); - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(verifier), - keccak256(bytes(PLATFORM)), - keccak256("XZkVerifier.token.v1"), - tokenAttest.bearerHash, - uint256(tokenAttest.bearerRangeStart), - uint256(tokenAttest.bearerRangeEnd), - keccak256(tokenAttest.sentRevealed), - uint256(tokenAttest.timestamp) - ) - ); - tokenAttest.notarySignature = _sign(NOTARY_PK, digest); - - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(BEARER_HASH, "alice", SESSION, uint64(block.timestamp)); - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(BEARER_HASH, BEARER_HASH, NULLIFIER, WALLET, SESSION), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - return abi.encode(p); - } - - function _verify(bytes memory payload) internal { - vm.prank(WALLET); - verifier.verifyAndExtract(payload); - } - - // ─── Seeding ───────────────────────────────────────────────────── - - /// initialize() seeds the deploying app, so an unchanged deploy keeps working. - function test_initialize_seedsDeployingClientId() public view { - assertEq(verifier.clientIdCount(), 1); - assertTrue(verifier.isClientIdAllowed(keccak256(X_CLIENT_ID))); - assertEq(verifier.clientIdAt(0), X_CLIENT_ID); - assertFalse(verifier.openClientIds(), "must not start permissive"); - } - - // ─── Fail-closed ───────────────────────────────────────────────── - - /// THE fail-closed property: with the allowlist emptied, every client_id is - /// rejected — including the one that worked a moment ago. A regression that - /// treated "empty" as "allow all" would make this pass verification instead. - function test_emptyAllowlist_deniesEveryClientId() public { - vm.prank(OWNER); - verifier.removeClientId(X_CLIENT_ID); - assertEq(verifier.clientIdCount(), 0); - - vm.expectRevert(XZkVerifier.ClientIdMismatch.selector); - _verify(_payloadWithClientId(X_CLIENT_ID)); - - vm.expectRevert(XZkVerifier.ClientIdMismatch.selector); - _verify(_payloadWithClientId(OTHER_CLIENT_ID)); - } - - function test_unlistedClientId_rejected() public { - vm.expectRevert(XZkVerifier.ClientIdMismatch.selector); - _verify(_payloadWithClientId(OTHER_CLIENT_ID)); - } - - // ─── Multi-app ─────────────────────────────────────────────────── - - function test_addClientId_admitsSecondApp_bothWork() public { - vm.prank(OWNER); - verifier.addClientId(OTHER_CLIENT_ID); - - assertEq(verifier.clientIdCount(), 2); - _verify(_payloadWithClientId(OTHER_CLIENT_ID)); - _verify(_payloadWithClientId(X_CLIENT_ID)); - } - - function test_removeClientId_revokesOnlyThatApp() public { - vm.startPrank(OWNER); - verifier.addClientId(OTHER_CLIENT_ID); - verifier.removeClientId(X_CLIENT_ID); - vm.stopPrank(); - - assertEq(verifier.clientIdCount(), 1); - assertFalse(verifier.isClientIdAllowed(keccak256(X_CLIENT_ID))); - - _verify(_payloadWithClientId(OTHER_CLIENT_ID)); - - vm.expectRevert(XZkVerifier.ClientIdMismatch.selector); - _verify(_payloadWithClientId(X_CLIENT_ID)); - } - - // ─── Permissive mode ───────────────────────────────────────────── - - function test_openClientIds_acceptsUnknownApp() public { - vm.prank(OWNER); - verifier.setOpenClientIds(true); - - _verify(_payloadWithClientId(OTHER_CLIENT_ID)); - } - - /// Permissive mode overrides even an empty allowlist — it is the flag, not - /// emptiness, that grants access. - function test_openClientIds_worksWithEmptyAllowlist() public { - vm.startPrank(OWNER); - verifier.removeClientId(X_CLIENT_ID); - verifier.setOpenClientIds(true); - vm.stopPrank(); - - _verify(_payloadWithClientId(OTHER_CLIENT_ID)); - } - - function test_openClientIds_offAgain_deniesUnknownApp() public { - vm.startPrank(OWNER); - verifier.setOpenClientIds(true); - verifier.setOpenClientIds(false); - vm.stopPrank(); - - vm.expectRevert(XZkVerifier.ClientIdMismatch.selector); - _verify(_payloadWithClientId(OTHER_CLIENT_ID)); - } - - function test_setOpenClientIds_emitsEvent() public { - vm.expectEmit(false, false, false, true, address(verifier)); - emit XZkVerifier.OpenClientIdsSet(true); - vm.prank(OWNER); - verifier.setOpenClientIds(true); - } - - // ─── Admin guards ──────────────────────────────────────────────── - - function test_addClientId_duplicate_reverts() public { - vm.prank(OWNER); - vm.expectRevert(XZkVerifier.ClientIdAlreadyAllowed.selector); - verifier.addClientId(X_CLIENT_ID); - } - - function test_removeClientId_unknown_reverts() public { - vm.prank(OWNER); - vm.expectRevert(XZkVerifier.ClientIdNotAllowed.selector); - verifier.removeClientId(OTHER_CLIENT_ID); - } - - function test_addClientId_emptyOrTooLong_reverts() public { - vm.startPrank(OWNER); - vm.expectRevert(XZkVerifier.BadClientIdLength.selector); - verifier.addClientId(bytes("")); - - vm.expectRevert(XZkVerifier.BadClientIdLength.selector); - verifier.addClientId(new bytes(65)); // MAX_CLIENT_ID_LEN is 64 - vm.stopPrank(); - } - - /// Verification scans once per entry, so the list is capped to bound gas. - function test_addClientId_capEnforced() public { - vm.startPrank(OWNER); - // One seeded at init; fill to the cap of 16. - for (uint256 i = 1; i < 16; i++) { - verifier.addClientId(abi.encodePacked("app-", i)); - } - assertEq(verifier.clientIdCount(), 16); - - vm.expectRevert(XZkVerifier.TooManyClientIds.selector); - verifier.addClientId(bytes("one-too-many")); - vm.stopPrank(); - } - - function test_adminFunctions_onlyOwner() public { - address stranger = address(0xBAD); - - vm.startPrank(stranger); - vm.expectRevert(); - verifier.addClientId(OTHER_CLIENT_ID); - - vm.expectRevert(); - verifier.removeClientId(X_CLIENT_ID); - - vm.expectRevert(); - verifier.setOpenClientIds(true); - vm.stopPrank(); - } -} diff --git a/solidity/contracts/login/zk/test/XZkVerifierBindings.t.sol b/solidity/contracts/login/zk/test/XZkVerifierBindings.t.sol deleted file mode 100644 index b88704b8..00000000 --- a/solidity/contracts/login/zk/test/XZkVerifierBindings.t.sol +++ /dev/null @@ -1,658 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../../Registry.sol"; -import {WalletFactory} from "../../WalletFactory.sol"; -import {WebWallet} from "../../WebWallet.sol"; -import {XZkVerifier, IHonkVerifier} from "../XZkVerifier.sol"; -import {IZkSessionVerifier} from "../IZkSessionVerifier.sol"; -import {Notary} from "../../../notary/Notary.sol"; -import {deployNotary} from "../../../notary/test/DeployNotary.sol"; - -/// Mock honk verifier — always returns the configured value. Lets us -/// exercise XZkVerifier wiring without a real Honk proof. -contract MockHonkVerifier is IHonkVerifier { - bool public ret = true; - - function setRet(bool v) external { - ret = v; - } - - function verify(bytes calldata, bytes32[] calldata) external view returns (bool) { - return ret; - } -} - -/// Bindings for the new token-only XZkVerifier. The on-chain check is -/// orthogonal to the actual Honk proof contents — we cover: -/// 1. Happy path: notary-signed token+me attestations + matching PI. -/// 2. PI walletAddress is returned unchanged (Registry, not verifier, enforces binding). -/// 3. Cross-bind revert: differing bearerHash. -/// 4. sessionAddr mismatch revert: PI sessionAddr != meAttest.sessionAddr. -/// 5. /me request-line structure revert. -/// 6. handle not found in recvRevealed revert. -/// 7. client_id mismatch revert. -contract XZkVerifierBindingsTest is Test { - Registry registry; - WalletFactory factory; - XZkVerifier verifier; - MockHonkVerifier honk; - - uint256 constant NOTARY_PK = 0xA001; - address NOTARY; - Notary notaryContract; - address constant OWNER = address(0xA11CE); - string constant PLATFORM = "api.x.com"; - string constant ENDPOINT = "/2/users/me"; - string constant HANDLE_PREFIX = "\"username\":\""; - bytes constant X_CLIENT_ID = bytes("dev-client-id-123"); - - function setUp() public { - NOTARY = vm.addr(NOTARY_PK); - notaryContract = deployNotary(OWNER, NOTARY); - - WebWallet walletImpl = new WebWallet(); - WalletFactory fImpl = new WalletFactory(); - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), abi.encodeCall(WalletFactory.initialize, (OWNER, address(walletImpl), address(1))) - ) - ) - ); - - Registry rImpl = new Registry(); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall( - Registry.initialize, (address(notaryContract), address(0xBE), address(factory), OWNER) - ) - ) - ) - ); - - vm.prank(OWNER); - factory.setRegistry(address(registry)); - - honk = new MockHonkVerifier(); - XZkVerifier xImpl = new XZkVerifier(); - verifier = XZkVerifier( - address( - new ERC1967Proxy( - address(xImpl), - abi.encodeCall( - XZkVerifier.initialize, - ( - OWNER, - address(notaryContract), - IHonkVerifier(address(honk)), - X_CLIENT_ID, - ENDPOINT, - HANDLE_PREFIX, - PLATFORM - ) - ) - ) - ) - ); - - vm.prank(OWNER); - registry.setZkVerifier(PLATFORM, IZkSessionVerifier(address(verifier))); - vm.warp(1_000_000); - } - - // ─── Builders ──────────────────────────────────────────────────── - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - function _tokenSentRevealed() internal pure returns (bytes memory) { - // `client_id=` somewhere in the body satisfies the matcher. - return abi.encodePacked("grant_type=authorization_code&client_id=", X_CLIENT_ID, "&code=xyz"); - } - - /// Returns the prefix part of /me sentRevealed (ending with - /// `authorization: Bearer `). The full sentRevealed under H1 is this - /// prefix concatenated with the 2-byte `\r\n` CRLF trailer. - function _meSentRevealedPrefix() internal view returns (bytes memory) { - return abi.encodePacked("GET ", ENDPOINT, " HTTP/1.1\r\nhost: api.x.com\r\nauthorization: Bearer "); - } - - function _meRecvRevealed(string memory handle) internal pure returns (bytes memory) { - // Minimal JSON snippet with the handle quoted. - return abi.encodePacked("{\"data\":{\"id\":\"42\",", HANDLE_PREFIX, handle, "\",\"name\":\"X\"}}"); - } - - function _buildTokenAttest(bytes32 bearerHash, uint64 ts) - internal - view - returns (XZkVerifier.TokenAttestation memory att) - { - att.bearerHash = bearerHash; - att.bearerRangeStart = 100; - att.bearerRangeEnd = 200; - att.sentRevealed = _tokenSentRevealed(); - att.timestamp = ts; - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(verifier), - keccak256(bytes(PLATFORM)), - keccak256("XZkVerifier.token.v1"), - att.bearerHash, - uint256(att.bearerRangeStart), - uint256(att.bearerRangeEnd), - keccak256(att.sentRevealed), - uint256(att.timestamp) - ) - ); - att.notarySignature = _sign(NOTARY_PK, digest); - } - - function _buildMeAttest(bytes32 bearerHash, string memory handle, address sessionAddr, uint64 ts) - internal - view - returns (XZkVerifier.MeAttestation memory att) - { - bytes memory prefix = _meSentRevealedPrefix(); - bytes memory recv = _meRecvRevealed(handle); - att.bearerHash = bearerHash; - att.bearerRangeStart = uint32(prefix.length); - att.bearerRangeEnd = att.bearerRangeStart + 100; - // sentRevealed = prefix bytes + CRLF (2 bytes) — H1 layout. - att.sentRevealed = abi.encodePacked(prefix, bytes("\r\n")); - att.sentPrefixEnd = uint32(prefix.length); - att.sentSuffixEnd = att.bearerRangeEnd + 2; - att.recvRevealed = recv; - att.handle = handle; - att.userId = "42"; // matches the "id":"42" snippet in _meRecvRevealed - att.sessionAddr = sessionAddr; - att.timestamp = ts; - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(verifier), - keccak256(bytes(PLATFORM)), - keccak256("XZkVerifier.me.v1"), - att.bearerHash, - uint256(att.bearerRangeStart), - uint256(att.bearerRangeEnd), - keccak256(att.sentRevealed), - uint256(att.sentPrefixEnd), - uint256(att.sentSuffixEnd), - keccak256(att.recvRevealed), - keccak256(bytes(att.handle)), - keccak256(bytes(att.userId)), - att.sessionAddr, - uint256(att.timestamp) - ) - ); - att.notarySignature = _sign(NOTARY_PK, digest); - } - - /// Layout: bearer_hash_token[32] | bearer_hash_me[32] | nullifier[32] - /// | wallet[20] | session[20] = 136 slots, one byte per slot. - function _buildPI( - bytes32 bearerHashToken, - bytes32 bearerHashMe, - bytes32 nullifier, - address wallet, - address sessionAddr - ) internal pure returns (bytes32[] memory pi) { - pi = new bytes32[](136); - for (uint256 i = 0; i < 32; i++) { - pi[i] = bytes32(uint256(uint8(bearerHashToken[i]))); - pi[32 + i] = bytes32(uint256(uint8(bearerHashMe[i]))); - pi[64 + i] = bytes32(uint256(uint8(nullifier[i]))); - } - bytes20 w = bytes20(wallet); - bytes20 s = bytes20(sessionAddr); - for (uint256 i = 0; i < 20; i++) { - pi[96 + i] = bytes32(uint256(uint8(w[i]))); - pi[116 + i] = bytes32(uint256(uint8(s[i]))); - } - } - - function _buildPayload( - bytes32 bearerHash, - bytes32 nullifier, - address wallet, - address sessionAddr, - string memory handle - ) internal view returns (bytes memory) { - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, handle, sessionAddr, uint64(block.timestamp)); - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, wallet, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - return abi.encode(p); - } - - // ─── Tests ─────────────────────────────────────────────────────── - - function test_happyPath() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address wallet = address(0x1111); - - bytes memory payload = _buildPayload(bearerHash, nullifier, wallet, sessionAddr, "alice"); - - vm.prank(wallet); - ( - string memory handle, - address sk, - address piWallet, - uint256 expiresAt, - bytes32 returnedNullifier, - string memory rUserId - ) = verifier.verifyAndExtract(payload); - - assertEq(handle, "alice"); - assertEq(sk, sessionAddr); - assertEq(piWallet, wallet); - assertGt(expiresAt, block.timestamp); - bytes32 expected = keccak256(abi.encode(keccak256(bytes(PLATFORM)), nullifier)); - assertEq(returnedNullifier, expected); - } - - function test_walletAddress_returnedFromPi() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - - // Verify across three distinct wallet PI values to catch a regression - // where the verifier silently masks/truncates/replaces the returned - // walletAddress. Registry consumes this exact value to enforce the - // register-zero / link-msg.sender contract. - address[3] memory wallets = [ - address(0), - address(0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF), - address(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) // Anvil acct 0 - ]; - for (uint256 i = 0; i < wallets.length; i++) { - bytes memory payload = _buildPayload(bearerHash, nullifier, wallets[i], sessionAddr, "alice"); - (,, address piWallet,,,) = verifier.verifyAndExtract(payload); - assertEq(piWallet, wallets[i]); - } - } - - function test_bearerHashMismatch_token_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = - _buildTokenAttest(bytes32(uint256(0xBADBEEF)), uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - bytes memory payload = abi.encode(p); - - vm.prank(w); - vm.expectRevert(XZkVerifier.BearerHashMismatch.selector); - verifier.verifyAndExtract(payload); - } - - /// Symmetric: PI[bearer_hash_me] != meAttest.bearerHash must revert too. - /// Catches the case where /token attestation matches but /me doesn't -- - /// the dual-blinder cross-bind enforces BOTH commits independently. - function test_bearerHashMismatch_me_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bytes32(uint256(0xBADBEEF)), "alice", sessionAddr, uint64(block.timestamp)); - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - bytes memory payload = abi.encode(p); - - vm.prank(w); - vm.expectRevert(XZkVerifier.BearerHashMismatch.selector); - verifier.verifyAndExtract(payload); - } - - /// PI[bearer_hash_token] and PI[bearer_hash_me] must each match THEIR - /// own attestation; they can legitimately differ (two different - /// blinders) but each must equal the corresponding notary commit. - /// Tampering PI[token] alone while leaving attestations valid still - /// reverts on the cross-bind check. - function test_bearerHashMismatch_piTokenTampered_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bytes32(uint256(0xCAFEBABE)), bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - bytes memory payload = abi.encode(p); - - vm.prank(w); - vm.expectRevert(XZkVerifier.BearerHashMismatch.selector); - verifier.verifyAndExtract(payload); - } - - function test_sessionAddrMismatch_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address piSession = address(0x5E5510); - address attestSession = address(0xD1FFE2); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", attestSession, uint64(block.timestamp)); - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, piSession), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - bytes memory payload = abi.encode(p); - - vm.prank(w); - vm.expectRevert(XZkVerifier.SessionAddrMismatch.selector); - verifier.verifyAndExtract(payload); - } - - function test_honkVerifierFalse_reverts() public { - honk.setRet(false); - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - bytes memory payload = _buildPayload(bearerHash, nullifier, w, sessionAddr, "alice"); - vm.prank(w); - vm.expectRevert(XZkVerifier.ZkVerificationFailed.selector); - verifier.verifyAndExtract(payload); - } - - function test_handleNotFound_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - // Replace recvRevealed with something missing the handle. - meAttest.recvRevealed = bytes("{\"data\":{\"id\":\"42\"}}"); - // Re-sign with the tampered field. - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(verifier), - keccak256(bytes(PLATFORM)), - keccak256("XZkVerifier.me.v1"), - meAttest.bearerHash, - uint256(meAttest.bearerRangeStart), - uint256(meAttest.bearerRangeEnd), - keccak256(meAttest.sentRevealed), - uint256(meAttest.sentPrefixEnd), - uint256(meAttest.sentSuffixEnd), - keccak256(meAttest.recvRevealed), - keccak256(bytes(meAttest.handle)), - meAttest.sessionAddr, - uint256(meAttest.timestamp) - ) - ); - meAttest.notarySignature = _sign(NOTARY_PK, digest); - - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - bytes memory payload = abi.encode(p); - - vm.prank(w); - vm.expectRevert(XZkVerifier.HandleNotFound.selector); - verifier.verifyAndExtract(payload); - } - - function test_clientIdMismatch_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - // Replace sentRevealed with a body missing client_id. - tokenAttest.sentRevealed = bytes("grant_type=authorization_code&code=xyz"); - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(verifier), - keccak256(bytes(PLATFORM)), - keccak256("XZkVerifier.token.v1"), - tokenAttest.bearerHash, - uint256(tokenAttest.bearerRangeStart), - uint256(tokenAttest.bearerRangeEnd), - keccak256(tokenAttest.sentRevealed), - uint256(tokenAttest.timestamp) - ) - ); - tokenAttest.notarySignature = _sign(NOTARY_PK, digest); - - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - bytes memory payload = abi.encode(p); - - vm.prank(w); - vm.expectRevert(XZkVerifier.ClientIdMismatch.selector); - verifier.verifyAndExtract(payload); - } - - function test_meRequestPrefix_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - // Tamper sentRevealed: wrong method, but still ends with CRLF after - // the (would-be) bearer. Re-sign so digest matches. - bytes memory badPrefix = bytes("POST /wrong HTTP/1.1\r\nauthorization: Bearer "); - meAttest.sentRevealed = abi.encodePacked(badPrefix, bytes("\r\n")); - meAttest.sentPrefixEnd = uint32(badPrefix.length); - meAttest.bearerRangeStart = meAttest.sentPrefixEnd; - meAttest.sentSuffixEnd = meAttest.bearerRangeEnd + 2; - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(verifier), - keccak256(bytes(PLATFORM)), - keccak256("XZkVerifier.me.v1"), - meAttest.bearerHash, - uint256(meAttest.bearerRangeStart), - uint256(meAttest.bearerRangeEnd), - keccak256(meAttest.sentRevealed), - uint256(meAttest.sentPrefixEnd), - uint256(meAttest.sentSuffixEnd), - keccak256(meAttest.recvRevealed), - keccak256(bytes(meAttest.handle)), - meAttest.sessionAddr, - uint256(meAttest.timestamp) - ) - ); - meAttest.notarySignature = _sign(NOTARY_PK, digest); - - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - bytes memory payload = abi.encode(p); - - vm.prank(w); - vm.expectRevert(XZkVerifier.MeRequestPrefixMismatch.selector); - verifier.verifyAndExtract(payload); - } - - /// H1 — bearer-end CRLF anchor (revert paths). - /// - /// Builds a happy-path me attestation, then for each of the three new H1 - /// guards: corrupts one field, re-signs the notary digest with the new - /// field, asserts the contract reverts on the corresponding error. - /// All three guards are independent of A0.1; they prevent prover-side - /// bearer truncation by canonicalizing `bearer_len` via the trailing - /// CRLF byte pair. - - /// H1: trailing 2 bytes of sentRevealed must equal `\r\n`. - function test_meBearerEndNotCrlf_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - - // Replace the trailing CRLF with garbage; preserve length so layout - // and adjacency checks still pass — only `MeBearerEndNotCrlf` fires. - bytes memory sent = meAttest.sentRevealed; - sent[sent.length - 2] = 0x41; // 'A' - sent[sent.length - 1] = 0x42; // 'B' - meAttest.sentRevealed = sent; - meAttest.notarySignature = _signMe(meAttest); - - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - - vm.prank(w); - vm.expectRevert(XZkVerifier.MeBearerEndNotCrlf.selector); - verifier.verifyAndExtract(abi.encode(p)); - } - - /// H1: sentRevealed length must equal sentPrefixEnd + 2 (prefix bytes + - /// 2-byte CRLF trailer). Anything else = bad multi-range layout. - function test_meBadSentLayout_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - - // Append a third byte so layout != prefixLen + 2. - meAttest.sentRevealed = abi.encodePacked(meAttest.sentRevealed, bytes("X")); - meAttest.notarySignature = _signMe(meAttest); - - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - - vm.prank(w); - vm.expectRevert(XZkVerifier.MeBadSentLayout.selector); - verifier.verifyAndExtract(abi.encode(p)); - } - - /// H1: sentSuffixEnd MUST equal bearerRangeEnd + 2. Catches a prover - /// claiming a 2-byte tail that does not abut the bearer (e.g. CRLF taken - /// from a different line in the request). - function test_meBearerEndAdjacencyMismatch_reverts() public { - bytes32 bearerHash = bytes32(uint256(0xBEEF)); - bytes32 nullifier = bytes32(uint256(0xC0FFEE)); - address sessionAddr = address(0x5E5510); - address w = address(0x1111); - - XZkVerifier.TokenAttestation memory tokenAttest = _buildTokenAttest(bearerHash, uint64(block.timestamp)); - XZkVerifier.MeAttestation memory meAttest = - _buildMeAttest(bearerHash, "alice", sessionAddr, uint64(block.timestamp)); - - // Shift sentSuffixEnd one byte off canonical adjacency. - meAttest.sentSuffixEnd = meAttest.bearerRangeEnd + 3; - meAttest.notarySignature = _signMe(meAttest); - - XZkVerifier.XProof memory p = XZkVerifier.XProof({ - proof: hex"", - publicInputs: _buildPI(bearerHash, bearerHash, nullifier, w, sessionAddr), - tokenAttest: tokenAttest, - meAttest: meAttest - }); - - vm.prank(w); - vm.expectRevert(XZkVerifier.MeBearerEndAdjacencyMismatch.selector); - verifier.verifyAndExtract(abi.encode(p)); - } - - // ─── Helpers ───────────────────────────────────────────────────── - - /// Re-sign a me-attestation digest after one of its fields was mutated. - /// Mirrors `XZkVerifier._verifyMeSig` field order exactly. - function _signMe(XZkVerifier.MeAttestation memory att) internal view returns (bytes memory) { - bytes32 digest = keccak256( - abi.encode( - block.chainid, - address(verifier), - keccak256(bytes(PLATFORM)), - keccak256("XZkVerifier.me.v1"), - att.bearerHash, - uint256(att.bearerRangeStart), - uint256(att.bearerRangeEnd), - keccak256(att.sentRevealed), - uint256(att.sentPrefixEnd), - uint256(att.sentSuffixEnd), - keccak256(att.recvRevealed), - keccak256(bytes(att.handle)), - keccak256(bytes(att.userId)), - att.sessionAddr, - uint256(att.timestamp) - ) - ); - return _sign(NOTARY_PK, digest); - } -} diff --git a/solidity/contracts/login/zk/test/XZkVerifierFlow.t.sol b/solidity/contracts/login/zk/test/XZkVerifierFlow.t.sol deleted file mode 100644 index a360a2b7..00000000 --- a/solidity/contracts/login/zk/test/XZkVerifierFlow.t.sol +++ /dev/null @@ -1,255 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../../Registry.sol"; -import {WalletFactory} from "../../WalletFactory.sol"; -import {WebWallet} from "../../WebWallet.sol"; -import {IZkSessionVerifier} from "../IZkSessionVerifier.sol"; -import {deployNotary} from "../../../notary/test/DeployNotary.sol"; - -/// Stand-in verifier that returns whatever the test author set up. Lets us -/// exercise the Registry dispatcher path without dragging in real -/// circuits / notary signatures (which live in `XZkVerifierBindings.t.sol`, -/// the X-specific cases). -contract StubZkVerifier is IZkSessionVerifier { - string public override platformName = "stub.example"; - string public handle = "alice"; - address public sessionKey = address(0xC0FFEE); - address public walletAddr = address(0); - uint256 public expiresAt = type(uint256).max; - bytes32 public nullifier = bytes32(uint256(0xBEEF)); - string public userId = "id_alice"; - bool public shouldRevert; - bytes public revertMessage; - - function setIdentity(string memory h, address k) external { - handle = h; - sessionKey = k; - // Escrow is keyed by the immutable id; derive a unique id per handle so - // distinct identities map to distinct wallets. - userId = string(abi.encodePacked("id_", h)); - } - - function setUserId(string memory u) external { - userId = u; - } - - function setWalletAddr(address w) external { - walletAddr = w; - } - - function setExpiresAt(uint256 e) external { - expiresAt = e; - } - - function setNullifier(bytes32 n) external { - nullifier = n; - } - - function setRevert(bool b, bytes memory m) external { - shouldRevert = b; - revertMessage = m; - } - - function verifyAndExtract(bytes calldata) - external - view - override - returns ( - string memory _handle, - address _sessionKey, - address _walletAddr, - uint256 _expiresAt, - bytes32 _nullifier, - string memory _userId - ) - { - if (shouldRevert) { - bytes memory m = revertMessage; - assembly { revert(add(m, 0x20), mload(m)) } - } - return (handle, sessionKey, walletAddr, expiresAt, nullifier, userId); - } -} - -/// Dispatcher-level tests for `Registry.register_session_zk(platform, bytes)`. -/// -/// The Registry stays small and platform-agnostic; per-platform proof -/// semantics (circuit, notary signature, attestation shape) live inside -/// each `IZkSessionVerifier` implementation. These tests cover the -/// Registry-side invariants — dispatch, expiry, session-key replay, -/// nullifier replay, and wallet-deploy idempotency — against a stub -/// verifier so the assertions don't depend on circuit specifics. -contract RegistryZkDispatcherTest is Test { - Registry registry; - WalletFactory factory; - StubZkVerifier verifier; - - address constant OWNER = address(0xA11CE); - address constant NOTARY = address(0xB0B); - address constant BACKEND = address(0xBE); - string constant PLATFORM = "api.x.com"; - - function setUp() public { - WebWallet walletImpl = new WebWallet(); - WalletFactory fImpl = new WalletFactory(); - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), abi.encodeCall(WalletFactory.initialize, (OWNER, address(walletImpl), address(1))) - ) - ) - ); - - Registry rImpl = new Registry(); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall( - Registry.initialize, (address(deployNotary(OWNER, NOTARY)), BACKEND, address(factory), OWNER) - ) - ) - ) - ); - - vm.prank(OWNER); - factory.setRegistry(address(registry)); - - verifier = new StubZkVerifier(); - vm.prank(OWNER); - registry.setZkVerifier(PLATFORM, IZkSessionVerifier(address(verifier))); - } - - function test_happyPath_dispatchesAndRegistersHandle() public { - registry.register_session_zk(PLATFORM, hex""); - address wallet = registry.resolve(PLATFORM, "alice"); - assertTrue(wallet != address(0)); - // Session is keyed by the immutable (platform, userId), not the handle. - assertEq( - WebWallet(payable(wallet)).sessionAt(keccak256(abi.encode(PLATFORM, "id_alice")), 0), address(0xC0FFEE) - ); - } - - function test_unsetPlatform_reverts() public { - vm.expectRevert(Registry.ZkVerifierNotSet.selector); - registry.register_session_zk("not.configured", hex""); - } - - function test_expiredProof_reverts() public { - vm.warp(1_000_000); - verifier.setExpiresAt(1_000_000); // boundary: expiresAt == now → ProofExpired - vm.expectRevert(Registry.ProofExpired.selector); - registry.register_session_zk(PLATFORM, hex""); - } - - function test_zeroSessionKey_reverts() public { - verifier.setIdentity("alice", address(0)); - vm.expectRevert(bytes("zero session")); - registry.register_session_zk(PLATFORM, hex""); - } - - function test_sessionKeyReplay_reverts() public { - registry.register_session_zk(PLATFORM, hex""); - // second call with same session key (same handle, even) reverts on - // the global `usedSessionKeys` guard, NOT on handle-already-claimed. - verifier.setNullifier(bytes32(uint256(0xDEAD))); // unique nullifier - vm.expectRevert(Registry.SessionKeyAlreadyUsed.selector); - registry.register_session_zk(PLATFORM, hex""); - } - - function test_nullifierReplay_reverts() public { - registry.register_session_zk(PLATFORM, hex""); - // second call with a different session key but same nullifier - // reverts on the bearer-nullifier replay guard. - verifier.setIdentity("alice", address(0xC0FFEE2)); - vm.expectRevert(Registry.BearerNullifierUsed.selector); - registry.register_session_zk(PLATFORM, hex""); - } - - function test_verifierRevert_propagates() public { - verifier.setRevert(true, bytes("custom verifier rejection")); - vm.expectRevert(bytes("custom verifier rejection")); - registry.register_session_zk(PLATFORM, hex""); - } - - function test_secondHandleDeploysSecondWallet() public { - registry.register_session_zk(PLATFORM, hex""); - address w1 = registry.resolve(PLATFORM, "alice"); - - // Different session key + nullifier + handle → still the same - // platform; expected to deploy a NEW wallet (not link). - verifier.setIdentity("bob", address(0xB0B11E)); - verifier.setNullifier(bytes32(uint256(0xBAD2))); - registry.register_session_zk(PLATFORM, hex""); - address w2 = registry.resolve(PLATFORM, "bob"); - assertTrue(w2 != address(0)); - assertTrue(w2 != w1); - } - - function test_link_zk_happy_attachesHandleToCallingWallet() public { - registry.register_session_zk(PLATFORM, hex""); - address wallet = registry.resolve(PLATFORM, "alice"); - - verifier.setIdentity("alice2", address(0xA11CE2)); - verifier.setWalletAddr(wallet); // PI walletAddress == msg.sender for link - verifier.setNullifier(bytes32(uint256(0x1111))); - vm.prank(wallet); - registry.link_identity_zk(PLATFORM, hex""); - - assertEq(registry.resolve(PLATFORM, "alice2"), wallet); - } - - function test_link_zk_unregisteredCaller_reverts() public { - address stranger = address(0x5727A6); - verifier.setIdentity("bob", address(0xB0B5E5)); - verifier.setWalletAddr(stranger); // matches msg.sender, so we hit NotRegisteredWallet - verifier.setNullifier(bytes32(uint256(0x3333))); - vm.prank(stranger); - vm.expectRevert(Registry.NotRegisteredWallet.selector); - registry.link_identity_zk(PLATFORM, hex""); - } - - /// Register path must reject any non-zero walletAddress in the proof's PI. - /// The factory deploys a fresh wallet, so binding is not allowed. - function test_register_zk_nonZeroWalletAddr_reverts() public { - verifier.setWalletAddr(address(0xDEADBEEF)); - vm.expectRevert(Registry.WrongWalletAddr.selector); - registry.register_session_zk(PLATFORM, hex""); - } - - /// Link path: PI walletAddress must equal msg.sender. Substituting a - /// different wallet (the classic mempool front-run vector) reverts. - function test_link_zk_walletAddr_mismatch_reverts() public { - // Bootstrap alice's wallet. - registry.register_session_zk(PLATFORM, hex""); - address aliceWallet = registry.resolve(PLATFORM, "alice"); - - // Try to link a second handle from a DIFFERENT wallet's pov. - address otherWallet = address(0xB0B0B0B0); - verifier.setIdentity("alice2", address(0xA11CE2)); - verifier.setWalletAddr(aliceWallet); // proof's PI binds to aliceWallet - verifier.setNullifier(bytes32(uint256(0x4242))); - - vm.prank(otherWallet); - vm.expectRevert(Registry.WrongWalletAddr.selector); - registry.link_identity_zk(PLATFORM, hex""); - } - - /// Link path: PI walletAddress == 0 (register-path shape) must NOT be - /// accepted as a link, even from a registered wallet. - function test_link_zk_zeroWalletAddr_reverts() public { - registry.register_session_zk(PLATFORM, hex""); - address aliceWallet = registry.resolve(PLATFORM, "alice"); - - verifier.setIdentity("alice2", address(0xA11CE2)); - verifier.setWalletAddr(address(0)); // register-shape PI - verifier.setNullifier(bytes32(uint256(0x5757))); - - vm.prank(aliceWallet); - vm.expectRevert(Registry.WrongWalletAddr.selector); - registry.link_identity_zk(PLATFORM, hex""); - } -} diff --git a/solidity/contracts/notary/INotary.sol b/solidity/contracts/notary/INotary.sol deleted file mode 100644 index 99ad8b32..00000000 --- a/solidity/contracts/notary/INotary.sol +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title INotary — the one notary check every contract shares. -/// -/// @notice A consumer computes its own digest (its domain separation — chain -/// id, its own address, operation tags — stays its own concern) and -/// asks this contract whether `proof` attests to it. Today a proof is -/// a secp256k1 signature over the EIP-191-prefixed digest; a future -/// implementation may accept a different kind of proof entirely, which -/// is why the parameter is opaque bytes rather than a signature type. -interface INotary { - /// @notice True when `proof` is a valid notary attestation of `digest`. - /// @param digest The consumer-computed digest being attested (pre-EIP-191; - /// the implementation owns whatever wrapping its proof kind needs). - /// @param proof Opaque attestation bytes. V1: a 65-byte ECDSA signature. - /// @dev MUST return false rather than revert on a malformed `proof`, so a - /// consumer can surface its own error. - function verify(bytes32 digest, bytes calldata proof) external view returns (bool); - - /// @notice The current notary signer. Observability for V1's key scheme; a - /// proof-based implementation may return the zero address. - function notary() external view returns (address); -} diff --git a/solidity/contracts/notary/Notary.sol b/solidity/contracts/notary/Notary.sol deleted file mode 100644 index b9a89c31..00000000 --- a/solidity/contracts/notary/Notary.sol +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; -import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; -import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; -import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; - -import {INotary} from "./INotary.sol"; - -/// @title Notary — the single notary-attestation verifier. -/// -/// @notice Every contract that accepts notary attestations holds a pointer to -/// this proxy and calls `verify(digest, proof)`. The digest is the -/// caller's own (each consumer keeps its domain separation exactly -/// where it was); what lives HERE is the one thing they all shared: -/// which notary is trusted, and how an attestation is checked. -/// -/// @dev V1 stores a single signer address and checks a secp256k1 signature -/// over the EIP-191-prefixed digest. Rotation is `setNotary`; a change of -/// proof system is a UUPS upgrade of this contract alone — consumers keep -/// calling the same `verify(bytes32, bytes)`. -/// -/// Upgrade authority: owner (intended to be a multisig / governance). -contract Notary is INotary, Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { - /// @inheritdoc INotary - address public notary; - - event NotaryChanged(address indexed previousNotary, address indexed newNotary); - - error ZeroAddress(); - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @param owner_ Address that will own the contract (should be a multisig). - /// @param notary_ The initial notary signer. - function initialize(address owner_, address notary_) external initializer { - __Ownable_init(owner_); - __Ownable2Step_init(); - __UUPSUpgradeable_init(); - _setNotary(notary_); - } - - /// @notice Rotate the notary signer. - function setNotary(address notary_) external onlyOwner { - _setNotary(notary_); - } - - /// @inheritdoc INotary - /// @dev EIP-191 prefix + ECDSA recover + compare. `tryRecover` rejects - /// wrong-length, malleable (high-s), and unrecoverable signatures by - /// returning an error, so a malformed proof answers false rather than - /// reverting — the consumer surfaces its own error. - function verify(bytes32 digest, bytes calldata proof) external view returns (bool) { - bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(digest); - (address recovered, ECDSA.RecoverError err,) = ECDSA.tryRecover(ethHash, proof); - return err == ECDSA.RecoverError.NoError && recovered == notary; - } - - function _setNotary(address notary_) internal { - if (notary_ == address(0)) revert ZeroAddress(); - emit NotaryChanged(notary, notary_); - notary = notary_; - } - - /// @dev Required by UUPS — only the owner can upgrade the implementation. - function _authorizeUpgrade(address) internal override onlyOwner {} - - /// @dev Renouncing would leave no way to rotate the notary or upgrade. - function renounceOwnership() public pure override { - revert("renounce disabled"); - } -} diff --git a/solidity/contracts/notary/test/DeployNotary.sol b/solidity/contracts/notary/test/DeployNotary.sol deleted file mode 100644 index 20023247..00000000 --- a/solidity/contracts/notary/test/DeployNotary.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -import {Notary} from "../Notary.sol"; - -/// Test-only helper: a Notary behind a fresh ERC1967 proxy, initialized with -/// `owner_` and `signer_`. Every suite that needs a notary check wires one of -/// these, so the wiring lives in one place. -function deployNotary(address owner_, address signer_) returns (Notary) { - Notary impl = new Notary(); - return Notary(address(new ERC1967Proxy(address(impl), abi.encodeCall(Notary.initialize, (owner_, signer_))))); -} diff --git a/solidity/contracts/notary/test/Notary.t.sol b/solidity/contracts/notary/test/Notary.t.sol deleted file mode 100644 index 54926b16..00000000 --- a/solidity/contracts/notary/test/Notary.t.sol +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; - -import {Notary} from "../Notary.sol"; - -contract NotaryTest is Test { - Notary internal notaryContract; - - uint256 internal constant SIGNER_PK = 0xA11CE; - uint256 internal constant OTHER_PK = 0xB0B; - - address internal owner = makeAddr("owner"); - address internal stranger = makeAddr("stranger"); - address internal signer; - - event NotaryChanged(address indexed previousNotary, address indexed newNotary); - - function setUp() public { - signer = vm.addr(SIGNER_PK); - Notary impl = new Notary(); - notaryContract = - Notary(address(new ERC1967Proxy(address(impl), abi.encodeCall(Notary.initialize, (owner, signer))))); - } - - /// EIP-191 signature over `digest`, the way the notary signs. - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - // ─── initialize ───────────────────────────────────────────────── - - function test_initialize_setsOwnerAndNotary() public view { - assertEq(notaryContract.owner(), owner); - assertEq(notaryContract.notary(), signer); - } - - function test_initialize_revertsOnZeroNotary() public { - Notary impl = new Notary(); - vm.expectRevert(Notary.ZeroAddress.selector); - new ERC1967Proxy(address(impl), abi.encodeCall(Notary.initialize, (owner, address(0)))); - } - - function test_initialize_revertsOnDoubleInit() public { - vm.expectRevert(); - notaryContract.initialize(owner, signer); - } - - // ─── verify ───────────────────────────────────────────────────── - - function test_verify_acceptsTheNotarySignature() public view { - bytes32 digest = keccak256("some consumer digest"); - assertTrue(notaryContract.verify(digest, _sign(SIGNER_PK, digest))); - } - - function test_verify_rejectsAnotherSigner() public view { - bytes32 digest = keccak256("some consumer digest"); - assertFalse(notaryContract.verify(digest, _sign(OTHER_PK, digest))); - } - - function test_verify_rejectsASignatureOverAnotherDigest() public view { - bytes memory sig = _sign(SIGNER_PK, keccak256("digest A")); - assertFalse(notaryContract.verify(keccak256("digest B"), sig)); - } - - /// A malformed proof answers false rather than reverting, so a consumer - /// surfaces its own error. - function test_verify_answersFalseOnMalformedProof() public view { - bytes32 digest = keccak256("some consumer digest"); - assertFalse(notaryContract.verify(digest, "")); - assertFalse(notaryContract.verify(digest, hex"deadbeef")); - assertFalse(notaryContract.verify(digest, new bytes(65))); - } - - /// A high-s (malleable) variant of a valid signature is refused. - function test_verify_rejectsAMalleatedSignature() public view { - bytes32 digest = keccak256("some consumer digest"); - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PK, ethHash); - // secp256k1 n - uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; - bytes memory malleated = abi.encodePacked(r, bytes32(n - uint256(s)), v == 27 ? uint8(28) : uint8(27)); - assertFalse(notaryContract.verify(digest, malleated)); - } - - // ─── setNotary ────────────────────────────────────────────────── - - function test_setNotary_rotatesAndEmits() public { - address rotated = vm.addr(OTHER_PK); - vm.prank(owner); - vm.expectEmit(true, true, false, false); - emit NotaryChanged(signer, rotated); - notaryContract.setNotary(rotated); - assertEq(notaryContract.notary(), rotated); - - // The old key stops verifying; the new one starts. - bytes32 digest = keccak256("some consumer digest"); - assertFalse(notaryContract.verify(digest, _sign(SIGNER_PK, digest))); - assertTrue(notaryContract.verify(digest, _sign(OTHER_PK, digest))); - } - - function test_setNotary_revertsIfNotOwner() public { - vm.prank(stranger); - vm.expectRevert(); - notaryContract.setNotary(stranger); - } - - function test_setNotary_revertsOnZeroAddress() public { - vm.prank(owner); - vm.expectRevert(Notary.ZeroAddress.selector); - notaryContract.setNotary(address(0)); - } - - // ─── Ownable2Step ─────────────────────────────────────────────── - - function test_ownership_twoStepTransfer() public { - address newOwner = makeAddr("newOwner"); - vm.prank(owner); - notaryContract.transferOwnership(newOwner); - assertEq(notaryContract.owner(), owner); - assertEq(notaryContract.pendingOwner(), newOwner); - - vm.prank(newOwner); - notaryContract.acceptOwnership(); - assertEq(notaryContract.owner(), newOwner); - } - - function test_renounceOwnership_isDisabled() public { - vm.prank(owner); - vm.expectRevert("renounce disabled"); - notaryContract.renounceOwnership(); - } - - // ─── UUPS upgrade ─────────────────────────────────────────────── - - function test_upgrade_revertsIfNotOwner() public { - Notary newImpl = new Notary(); - vm.prank(stranger); - vm.expectRevert(); - notaryContract.upgradeToAndCall(address(newImpl), ""); - } - - function test_upgrade_byOwner_preservesState() public { - Notary newImpl = new Notary(); - vm.prank(owner); - notaryContract.upgradeToAndCall(address(newImpl), ""); - - assertEq(notaryContract.owner(), owner); - assertEq(notaryContract.notary(), signer); - bytes32 digest = keccak256("some consumer digest"); - assertTrue(notaryContract.verify(digest, _sign(SIGNER_PK, digest))); - } -} diff --git a/solidity/contracts/transfer/MockERC20.sol b/solidity/contracts/transfer/MockERC20.sol deleted file mode 100644 index fe621837..00000000 --- a/solidity/contracts/transfer/MockERC20.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -/// @title MockERC20 — test token with public mint. -contract MockERC20 is ERC20 { - constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} - - /// @notice Anyone can mint — test only. - function mint(address to, uint256 amount) external { - _mint(to, amount); - } -} diff --git a/solidity/contracts/transfer/bank/BankErrors.sol b/solidity/contracts/transfer/bank/BankErrors.sol deleted file mode 100644 index f00b64a0..00000000 --- a/solidity/contracts/transfer/bank/BankErrors.sol +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -// ── Shared Bank custom errors ───────────────────────────────────────────── -// File-level errors imported by the Bank facets + logic libraries. Replaces the -// monolith's mix of `error` declarations and `require("string")` reverts with a -// single, cheap (4-byte selector) error set. Library-private errors (e.g. -// LibString's parse errors) stay in their library. - -// Admin / config -error ZeroAddress(); -error EmptyName(); -error AlreadyRegistered(); -error NotRegistered(); - -// Token registry -error TokenNotRegistered(); - -// Template matching -error TemplateNotFound(); -error NoTemplateMatch(); // no template matched with the correct recipient/amount/token -error TemplateMissingToken(); // template has {amount} but no {token} -error TemplateMissingAmount(); // template has {recipient} but no {amount} -error TemplateMissingTokenAfterRecipient(); // template has {recipient}/{amount} but no {token} - -// Proof / signatures -error InvalidNotarySignature(); -error InvalidBackendSignature(); -error InvalidMerkleProof(); -error MerklePathTooLong(); -error DomainHashMismatch(); - -// Recipient binding (reply-inferred / quote-inferred honor) -error RecipientLessNeedsReplyTarget(); -error ReplyLeafTooShort(); -error ReplyLeafNotInReplyTo(); -error ReplyTargetMismatch(); -error ReplyTargetIdNotQuoted(); -error QuotedRefTooShort(); -error QuotedRefNotObject(); -error QuotedRefNotFlatObject(); -error RefNotQuote(); -error QuotedRefMissingId(); -error QuotedAuthorLeafTooShort(); -error QuotedAuthorLeafNotIncludes(); -error QuotedAuthorMissingQuotedId(); -error QuotedAuthorMissingAuthorId(); -error QuotedAuthorDifferentObject(); -error QuotedAuthorMismatch(); -error EmptyQuotedValue(); // an extracted "…" reply-target / quoted id / author-id value is empty - -// Transfer / escrow -error TransferAlreadyUsed(); -error ZeroAmount(); -error InsufficientBalance(); -error SenderNotRegistered(); -error UserIdRequired(); -error UnknownResourceType(); -error RequestPathMismatch(); -error AmountWeiMismatch(); -error ZeroToken(); -error AuthorNotInRevealedField(); - -// Native asset -error NativeTransferFailed(); -error NativeAmountMismatch(); -error NativeSentWithErc20(); - -// Allowance -error InsufficientAllowance(address token, address owner, address spender, uint256 required, uint256 actual); - -// Source URL -error SourceUrlNotHttps(); -error SourceUrlDomainMismatch(); - -// Lifecycle -error EnforcedPause(); -error ReentrantCall(); -error AlreadyInitialized(); diff --git a/solidity/contracts/transfer/bank/BankInit.sol b/solidity/contracts/transfer/bank/BankInit.sol deleted file mode 100644 index ae7d3ac0..00000000 --- a/solidity/contracts/transfer/bank/BankInit.sol +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; - -import {LibDiamond} from "../diamond/libraries/LibDiamond.sol"; -import {IDiamondCut} from "../diamond/interfaces/IDiamondCut.sol"; -import {IDiamondLoupe} from "../diamond/interfaces/IDiamondLoupe.sol"; -import {IERC173} from "../diamond/interfaces/IERC173.sol"; - -import {LibCoreStorage} from "./storage/LibCoreStorage.sol"; -import {LibTemplateStorage} from "./storage/LibTemplateStorage.sol"; -import {LibTemplate} from "./libraries/LibTemplate.sol"; -import {ZeroAddress, AlreadyInitialized} from "./BankErrors.sol"; - -/// @title BankInit — one-shot diamond initializer (run via diamondCut delegatecall). -/// @dev Runs in the diamond's storage context: sets the trusted-party pointers, -/// primes the reentrancy guard, registers ERC-165 interface ids, and seeds -/// the honor templates + resource/web prefixes (mirrors the old -/// `Bank.initialize`). Fresh deploy — no legacy state to preserve. -contract BankInit { - function init(address notary, address backend, address registry) external { - if (notary == address(0) || backend == address(0) || registry == address(0)) revert ZeroAddress(); - - LibCoreStorage.CoreStorage storage cs = LibCoreStorage.store(); - // One-shot: a later owner diamondCut re-delegatecalling init would otherwise - // silently overwrite the trusted-party pointers and append duplicate templates. - if (cs.initialized) revert AlreadyInitialized(); - cs.initialized = true; - cs.notary = notary; - cs.backend = backend; - cs.registry = registry; - cs.reentrancyStatus = 1; // not-entered (nonReentrant treats 2 as entered) - - LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage(); - ds.supportedInterfaces[type(IERC165).interfaceId] = true; - ds.supportedInterfaces[type(IDiamondCut).interfaceId] = true; - ds.supportedInterfaces[type(IDiamondLoupe).interfaceId] = true; - ds.supportedInterfaces[type(IERC173).interfaceId] = true; - - // Honor templates (mirror the old Bank.initialize). - LibTemplate.addTemplate("api.github.com", "@dyaka-agent honor @{recipient} with {amount} of {token}"); - LibTemplate.addTemplate("api.github.com", "@dyaka-agent honor @{recipient} with {amount} {token}"); - LibTemplate.addTemplate("api.x.com", "@dyaka_agent honor @{recipient} with {amount} of {token}"); - LibTemplate.addTemplate("api.x.com", "@dyaka_agent honor @{recipient} with {amount} {token}"); - // Recipient-less variants (recipient inferred off-chain from the reply/quote - // parent). X only: GitHub reply-target id leaves aren't supplied yet, so - // advertising the syntax on-chain would only revert. Phase 2. - LibTemplate.addTemplate("api.x.com", "@dyaka_agent honor with {amount} of {token}"); - LibTemplate.addTemplate("api.x.com", "@dyaka_agent honor with {amount} {token}"); - - LibTemplateStorage.TemplateStorage storage tls = LibTemplateStorage.store(); - tls.resourceTypePrefixes[keccak256("issue_comment")] = "/repos/"; - tls.resourceTypePrefixes[keccak256("pr_comment")] = "/repos/"; - tls.resourceTypePrefixes[keccak256("pr_review")] = "/repos/"; - tls.resourceTypePrefixes[keccak256("issue_body")] = "/repos/"; - tls.resourceTypePrefixes[keccak256("discussion_comment")] = "/graphql"; - tls.resourceTypePrefixes[keccak256("tweet")] = "/2/tweets"; - - tls.platformWebPrefixes[keccak256("api.github.com")] = "https://github.com/"; - tls.platformWebPrefixes[keccak256("api.x.com")] = "https://x.com/"; - } -} diff --git a/solidity/contracts/transfer/bank/BankModifiers.sol b/solidity/contracts/transfer/bank/BankModifiers.sol deleted file mode 100644 index 8f8525ac..00000000 --- a/solidity/contracts/transfer/bank/BankModifiers.sol +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {LibDiamond} from "../diamond/libraries/LibDiamond.sol"; -import {LibCoreStorage} from "./storage/LibCoreStorage.sol"; -import {EnforcedPause, ReentrantCall} from "./BankErrors.sol"; - -/// @title BankModifiers — shared access / lifecycle modifiers for Bank facets. -/// @dev One definition inherited by every facet (a diamond can't inherit OZ's -/// Ownable/Pausable/ReentrancyGuard per-facet). Owner is the diamond owner -/// (ERC-173); pause + reentrancy flags live in LibCoreStorage. -abstract contract BankModifiers { - modifier onlyOwner() { - LibDiamond.enforceIsContractOwner(); - _; - } - - modifier whenNotPaused() { - if (LibCoreStorage.store().paused) revert EnforcedPause(); - _; - } - - modifier nonReentrant() { - LibCoreStorage.CoreStorage storage cs = LibCoreStorage.store(); - if (cs.reentrancyStatus == 2) revert ReentrantCall(); - cs.reentrancyStatus = 2; - _; - cs.reentrancyStatus = 1; - } -} diff --git a/solidity/contracts/transfer/bank/BankTypes.sol b/solidity/contracts/transfer/bank/BankTypes.sol deleted file mode 100644 index 97d81fab..00000000 --- a/solidity/contracts/transfer/bank/BankTypes.sol +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title BankTypes — shared calldata structs for the Bank facets + logic libs. -/// @dev The honor-transfer call surface (resource / sender / backend-sig / TLS -/// proof). Extracted from the monolith so facets and `LibProof` / -/// `LibRecipient` reference one definition. - -struct ResourceInfo { - string platform; // API domain (e.g. "api.x.com", "api.github.com") - string resourceType; - string resourceId; - string requestPath; -} - -/// `revealedAuthorId` present → id parsed on-chain, id-keyed; empty → handle path. -struct SenderInfo { - string author; - bytes revealedAuthor; - bytes revealedAuthorId; -} - -struct BackendSig { - bytes sig; - uint256 timestamp; -} - -struct NotaryTlsProof { - bytes notarySig; - bytes32 domainHash; - bytes32 clientRandom; - bytes32 serverRandom; - bytes serverEphemeralKey; - bytes32 transcriptRoot; - uint256 timestamp; - bytes32[] bodyMerklePath; - bytes32[] authorMerklePath; - bytes32[] domainMerklePath; - bytes32[] endpointMerklePath; - bytes32[] authorIdMerklePath; // empty for the legacy handle path - bytes32[] receiverIdMerklePath; // recipient-less reply honor: reply-target id leaf - bytes revealedReceiverId; // revealed "in_reply_to_user_id":"" snippet - bytes32[] quotedRefMerklePath; // recipient-less quote honor: referenced_tweets quoted leaf - bytes revealedQuotedRef; // revealed {"type":"quoted","id":""} snippet - bytes32[] quotedAuthorMerklePath; // recipient-less quote honor: includes quoted-tweet author leaf - bytes revealedQuotedAuthorId; // revealed includes span binding "id":"" + "author_id":"" -} diff --git a/solidity/contracts/transfer/bank/IBank.sol b/solidity/contracts/transfer/bank/IBank.sol deleted file mode 100644 index eccf7ac1..00000000 --- a/solidity/contracts/transfer/bank/IBank.sol +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {ResourceInfo, SenderInfo, BackendSig, NotaryTlsProof} from "./BankTypes.sol"; - -/// @title IBank — the Bank diamond's full external surface (Vault + Admin + Transfer -/// facets), for callers that address the diamond as one contract (tests, -/// scripts, the frontend). Structs come from `BankTypes`. -interface IBank { - // ── Vault ──────────────────────────────────────────────────────────── - function deposit( - string calldata platform, - string calldata handle, - string calldata userId, - address token, - uint256 amount - ) external payable; - function withdraw(address recipient, address token, uint256 amount) external; - function balanceOf(address wallet, address token) external view returns (uint256); - function balanceOf(string calldata platform, string calldata handle, string calldata userId, address token) - external - view - returns (uint256); - function balanceOfTotal(address wallet, address token) external view returns (uint256); - function balanceOfTotal(string calldata platform, string calldata handle, string calldata userId, address token) - external - view - returns (uint256); - function registeredBalances(address wallet, address token) external view returns (uint256); - function unregisteredBalances(bytes32 key, address token) external view returns (uint256); - function identityHash(string calldata domain, string calldata username) external pure returns (bytes32); - - // ── Admin ──────────────────────────────────────────────────────────── - function pause() external; - function unpause() external; - function setRegistry(address registry) external; - function registerToken(string calldata name, address token) external; - function unregisterToken(string calldata name) external; - function resolveToken(string calldata name) external view returns (address); - function getRegisteredTokens() external view returns (address[] memory); - function setPlatformTemplate(string calldata platform, string calldata newTemplate) external; - function clearPlatformTemplates(string calldata platform) external; - function platformTemplateCount(string calldata platform) external view returns (uint256); - function getPlatformTemplate(string calldata platform, uint256 index) external view returns (string memory); - function getTemplateParts(string calldata platform, uint256 index) - external - view - returns (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix); - function setResourceTypePrefix(string calldata resourceType, string calldata prefix) external; - function setPlatformWebPrefix(string calldata platform, string calldata prefix) external; - function getPlatformWebPrefix(string calldata platform) external view returns (string memory); - function registry() external view returns (address); - function paused() external view returns (bool); - function tokenName(address token) external view returns (string memory); - - // ── Transfer ───────────────────────────────────────────────────────── - function verifyProof( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverUserId - ) external view returns (bool); - function webTransferV2( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverHandle, - string calldata receiverUserId, - string calldata tokenName_, - string calldata amountStr, - uint256 amount, - string calldata sourceUrl - ) external; - function webTransferV2( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverHandle, - string calldata receiverUserId, - address token, - string calldata amountStr, - uint256 amount, - string calldata sourceUrl - ) external; - function transfer_within( - string calldata receiverPlatform, - string calldata receiverHandle, - string calldata receiverUserId, - address token, - uint256 amount - ) external; -} diff --git a/solidity/contracts/transfer/bank/facets/AdminFacet.sol b/solidity/contracts/transfer/bank/facets/AdminFacet.sol deleted file mode 100644 index a6d7670c..00000000 --- a/solidity/contracts/transfer/bank/facets/AdminFacet.sol +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {BankModifiers} from "../BankModifiers.sol"; -import {LibCoreStorage} from "../storage/LibCoreStorage.sol"; -import {LibTokenStorage} from "../storage/LibTokenStorage.sol"; -import {LibTemplateStorage} from "../storage/LibTemplateStorage.sol"; -import {LibTemplate} from "../libraries/LibTemplate.sol"; -import {LibString} from "../libraries/LibString.sol"; -import {ZeroAddress, EmptyName, AlreadyRegistered, NotRegistered} from "../BankErrors.sol"; - -/// @title AdminFacet — owner-gated Bank configuration. -/// @dev Token registry, honor-template admin, resource-type / web prefixes, the -/// Registry pointer, and pause. Reads/writes the token/template/core storage -/// concerns; owner is the diamond owner (via BankModifiers). -contract AdminFacet is BankModifiers { - event Paused(address account); - event Unpaused(address account); - event RegistrySet(address indexed oldRegistry, address indexed newRegistry); - event TokenRegistered(string name, address indexed token); - event TokenUnregistered(string name, address indexed token); - - // ── Lifecycle ──────────────────────────────────────────────────────── - - /// @notice Pause state-changing entrypoints (emergency stop / migration gate). - function pause() external onlyOwner { - LibCoreStorage.store().paused = true; - emit Paused(msg.sender); - } - - /// @notice Resume after a pause. - function unpause() external onlyOwner { - LibCoreStorage.store().paused = false; - emit Unpaused(msg.sender); - } - - // ── Registry pointer ───────────────────────────────────────────────── - - function setRegistry(address _registry) external onlyOwner { - if (_registry == address(0)) revert ZeroAddress(); - LibCoreStorage.CoreStorage storage cs = LibCoreStorage.store(); - address old = cs.registry; - cs.registry = _registry; - if (old != _registry) emit RegistrySet(old, _registry); - } - - // ── Token registry ─────────────────────────────────────────────────── - - function registerToken(string calldata name, address token) external onlyOwner { - if (bytes(name).length == 0) revert EmptyName(); - LibTokenStorage.TokenStorage storage ts = LibTokenStorage.store(); - bytes32 key = _nameKey(name); - // Name taken? `tokenByName` covers ERC-20s, but the NATIVE asset keeps - // address(0) there (indistinguishable from unset), so a native-registered - // name would slip through — also reject a name already held by the native - // (else registering it for an ERC-20 hijacks the name / desyncs the registry). - if (ts.tokenByName[key] != address(0)) revert AlreadyRegistered(); - // Case-INSENSITIVE for the native: webTransferV2 resolves the native asset - // via LibString.caseInsensitiveEqual, so a case variant (native "TIA" then - // ERC-20 "tia") must be rejected here too — else the registry desyncs and a - // by-name honor can settle the wrong asset. - if ( - bytes(ts.tokenName[address(0)]).length != 0 - && LibString.caseInsensitiveEqual(ts.tokenName[address(0)], name) - ) { - revert AlreadyRegistered(); - } - // Address already registered (blocks a second name for the same token → dup - // entry, and a second native registration). - if (bytes(ts.tokenName[token]).length != 0) revert AlreadyRegistered(); - ts.tokenByName[key] = token; - ts.tokenName[token] = name; - ts.registeredTokens.push(token); - emit TokenRegistered(name, token); - } - - function unregisterToken(string calldata name) external onlyOwner { - LibTokenStorage.TokenStorage storage ts = LibTokenStorage.store(); - bytes32 key = _nameKey(name); - address token = ts.tokenByName[key]; - // token == address(0) is ambiguous: the native asset registered under - // `name`, or a name that was never registered. It is the native ONLY when - // the stored native name matches `name` — otherwise revert (don't wipe it). - if (token == address(0)) { - string memory nativeName = ts.tokenName[address(0)]; - if (bytes(nativeName).length == 0 || _nameKey(nativeName) != key) revert NotRegistered(); - } - delete ts.tokenByName[key]; - delete ts.tokenName[token]; - _removeRegisteredToken(ts, token); - emit TokenUnregistered(name, token); - } - - function resolveToken(string calldata name) external view returns (address) { - return LibTokenStorage.store().tokenByName[_nameKey(name)]; - } - - function getRegisteredTokens() external view returns (address[] memory) { - return LibTokenStorage.store().registeredTokens; - } - - /// @dev Token-name storage key. NB: case-sensitive — the webTransferV2 token - /// lookup is likewise case-sensitive; unifying them case-insensitively is - /// blocked by webTransferV2's stack limit (needs a param-struct refactor). - function _nameKey(string memory name) private pure returns (bytes32) { - return keccak256(bytes(name)); - } - - /// @dev Swap-pop `token` out of the registeredTokens list (no-op if absent). - function _removeRegisteredToken(LibTokenStorage.TokenStorage storage ts, address token) private { - uint256 n = ts.registeredTokens.length; - for (uint256 i = 0; i < n; i++) { - if (ts.registeredTokens[i] == token) { - ts.registeredTokens[i] = ts.registeredTokens[n - 1]; - ts.registeredTokens.pop(); - return; - } - } - } - - // ── Template admin ─────────────────────────────────────────────────── - - /// @notice Add a template for a platform (appends to the list). - function setPlatformTemplate(string calldata platform, string calldata newTemplate) external onlyOwner { - LibTemplate.addTemplate(platform, newTemplate); - } - - /// @notice Remove all templates for a platform. - function clearPlatformTemplates(string calldata platform) external onlyOwner { - LibTemplateStorage.TemplateStorage storage ts = LibTemplateStorage.store(); - bytes32 key = keccak256(bytes(platform)); - delete ts.platformTemplates[key]; - delete ts.templateParts[key]; - } - - function platformTemplateCount(string calldata platform) external view returns (uint256) { - return LibTemplateStorage.store().platformTemplates[keccak256(bytes(platform))].length; - } - - /// @notice The raw stored template string at `index` (verbatim, unparsed). - function getPlatformTemplate(string calldata platform, uint256 index) external view returns (string memory) { - return LibTemplateStorage.store().platformTemplates[keccak256(bytes(platform))][index]; - } - - function getTemplateParts(string calldata platform, uint256 index) - external - view - returns (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) - { - LibTemplateStorage.TemplateParts storage parts = - LibTemplateStorage.store().templateParts[keccak256(bytes(platform))][index]; - return (parts.prefix, parts.afterRecipient, parts.afterAmount, parts.suffix); - } - - // ── Resource / web prefixes ────────────────────────────────────────── - - function setResourceTypePrefix(string calldata resourceType, string calldata prefix) external onlyOwner { - LibTemplateStorage.store().resourceTypePrefixes[keccak256(bytes(resourceType))] = prefix; - } - - function setPlatformWebPrefix(string calldata platform, string calldata prefix) external onlyOwner { - LibTemplateStorage.store().platformWebPrefixes[keccak256(bytes(platform))] = prefix; - } - - /// @notice The web-URL prefix bound to a platform ("" if unset). - function getPlatformWebPrefix(string calldata platform) external view returns (string memory) { - return LibTemplateStorage.store().platformWebPrefixes[keccak256(bytes(platform))]; - } - - // ── Views (parity with the old Bank public state) ───────────────────── - - /// @notice The Registry pointer used for identity resolution. - function registry() external view returns (address) { - return LibCoreStorage.store().registry; - } - - /// @notice Whether state-changing entrypoints are paused. - function paused() external view returns (bool) { - return LibCoreStorage.store().paused; - } - - /// @notice The registered name for a token address ("" if unregistered). - function tokenName(address token) external view returns (string memory) { - return LibTokenStorage.store().tokenName[token]; - } -} diff --git a/solidity/contracts/transfer/bank/facets/TransferFacet.sol b/solidity/contracts/transfer/bank/facets/TransferFacet.sol deleted file mode 100644 index 67956c06..00000000 --- a/solidity/contracts/transfer/bank/facets/TransferFacet.sol +++ /dev/null @@ -1,333 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; - -import {BankModifiers} from "../BankModifiers.sol"; -import {ResourceInfo, SenderInfo, BackendSig, NotaryTlsProof} from "../BankTypes.sol"; -import {IRegistry} from "../../../login/IRegistry.sol"; - -import {LibProof} from "../libraries/LibProof.sol"; -import {LibTemplate} from "../libraries/LibTemplate.sol"; -import {LibRecipient} from "../libraries/LibRecipient.sol"; -import {LibEscrow} from "../libraries/LibEscrow.sol"; -import {LibString} from "../libraries/LibString.sol"; - -import {LibCoreStorage} from "../storage/LibCoreStorage.sol"; -import {LibEscrowStorage} from "../storage/LibEscrowStorage.sol"; -import {LibTemplateStorage} from "../storage/LibTemplateStorage.sol"; -import {LibTokenStorage} from "../storage/LibTokenStorage.sol"; - -import { - UnknownResourceType, - RequestPathMismatch, - AuthorNotInRevealedField, - TemplateNotFound, - TokenNotRegistered, - ZeroToken, - AmountWeiMismatch, - TransferAlreadyUsed, - ZeroAmount, - SenderNotRegistered, - SourceUrlNotHttps, - SourceUrlDomainMismatch -} from "../BankErrors.sol"; - -/// @title TransferFacet — the honor-transfer flow (proof → verify → move funds). -/// @dev Orchestrates the logic libs: `LibProof` (notary + backend + Merkle leaves), -/// `LibTemplate` (command match), `LibRecipient` (recipient-less binding), and -/// `LibEscrow` (id resolve + ledgers). Fresh deploy is id-only — the sender -/// is resolved from the proven author-id, the receiver from the proven -/// recipient id; handles are display-only (event fields). The two -/// `webTransferV2` overloads (token by-name / by-address) share `_webTransfer`. -contract TransferFacet is BankModifiers { - event WebTransferV3( - address indexed senderWallet, - address indexed receiverWallet, - string receiverPlatform, - string receiverHandle, - string receiverUserId, - address indexed token, - uint256 amount, - string sourceUrl - ); - event TransferWithin( - address indexed sender, - string receiverPlatform, - string receiverHandle, - string receiverUserId, - address indexed token, - uint256 amount - ); - - // ── Preflight ──────────────────────────────────────────────────────── - - /// @notice Verify a TLS-notarized comment proof with no state changes. Reverts - /// with a descriptive error on any failure. Returns true when valid. - function verifyProof( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverUserId - ) public view returns (bool) { - bytes32 uid = _uid(resource); - - string memory prefix = LibTemplateStorage.store().resourceTypePrefixes[keccak256(bytes(resource.resourceType))]; - if (bytes(prefix).length == 0) revert UnknownResourceType(); - if (!LibString.contains(bytes(resource.requestPath), bytes(prefix), false)) revert RequestPathMismatch(); - - LibProof.verifyNotarySignature(notaryTlsProof); - LibProof.verifyRecvLeaf(notaryTlsProof.bodyMerklePath, notaryTlsProof.transcriptRoot, revealedSubsection); - LibProof.verifyRecvLeaf(notaryTlsProof.authorMerklePath, notaryTlsProof.transcriptRoot, sender.revealedAuthor); - LibProof.verifyDomainLeaf( - notaryTlsProof.domainMerklePath, notaryTlsProof.transcriptRoot, resource.platform, notaryTlsProof.domainHash - ); - LibProof.verifyEndpointLeaf( - notaryTlsProof.endpointMerklePath, notaryTlsProof.transcriptRoot, resource.requestPath - ); - if (!LibString.contains(sender.revealedAuthor, bytes(sender.author), false)) revert AuthorNotInRevealedField(); - - // id-only: the author id is always proven and parsed at resolve time. - LibProof.verifyRecvLeaf( - notaryTlsProof.authorIdMerklePath, notaryTlsProof.transcriptRoot, sender.revealedAuthorId - ); - - LibProof.verifyBackendSignature( - uid, - revealedSubsection, - sender.revealedAuthor, - sender.revealedAuthorId, - receiverUserId, - backendSigData.sig, - backendSigData.timestamp - ); - - bytes32 platformKey = keccak256(bytes(resource.platform)); - if (LibTemplateStorage.store().platformTemplates[platformKey].length == 0) revert TemplateNotFound(); - (bool matched,,, bool recipientLess) = LibTemplate.matchAnyTemplate(revealedSubsection, platformKey); - if (!matched) revert TemplateNotFound(); - - // Recipient-less honor: the body does not prove the recipient, so preflight - // must apply the SAME reply-target binding that webTransferV2 enforces — - // else verifyProof would green-light a proof the transfer then reverts. - if (recipientLess) { - LibRecipient.verifyRecipientIdLeaf(notaryTlsProof, receiverUserId); - } - - return true; - } - - /// @dev The transfer uid: keccak of platform:resourceType:resourceId. - function _uid(ResourceInfo calldata resource) private pure returns (bytes32) { - return keccak256(abi.encodePacked(resource.platform, ":", resource.resourceType, ":", resource.resourceId)); - } - - // ── Web transfer (comment proof → funds) ───────────────────────────── - - /// @notice Execute a web transfer (token by name) with source URL. - function webTransferV2( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverHandle, - string calldata receiverUserId, - string calldata tokenName_, - string calldata amountStr, - uint256 amount, - string calldata sourceUrl - ) external nonReentrant whenNotPaused { - _validateSourceUrl(resource, sourceUrl); - - LibTokenStorage.TokenStorage storage tks = LibTokenStorage.store(); - address token = tks.tokenByName[keccak256(bytes(tokenName_))]; - if (token == address(0)) { - if (!LibString.caseInsensitiveEqual(tks.tokenName[address(0)], tokenName_)) revert TokenNotRegistered(); - } - - // Arg-binding: reverts unless a template matches these recipient/amount/token. - // Its recipientLess result is AUTHORITATIVE and must gate the reply/quote - // binding here — verifyProof's matchAnyTemplate can classify differently when - // a literal template matches first, which would skip the binding. - if (LibTemplate.verifyTemplate(revealedSubsection, resource.platform, receiverHandle, amountStr, tokenName_)) { - LibRecipient.verifyRecipientIdLeaf(notaryTlsProof, receiverUserId); - } - - (address senderWallet, address receiverWallet) = _webTransfer( - resource, - sender, - revealedSubsection, - notaryTlsProof, - backendSigData, - receiverUserId, - token, - amountStr, - amount - ); - emit WebTransferV3( - senderWallet, receiverWallet, resource.platform, receiverHandle, receiverUserId, token, amount, sourceUrl - ); - } - - /// @notice Execute a web transfer (token by address) with source URL. - function webTransferV2( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverHandle, - string calldata receiverUserId, - address token, - string calldata amountStr, - uint256 amount, - string calldata sourceUrl - ) external nonReentrant whenNotPaused { - _validateSourceUrl(resource, sourceUrl); - if (token == address(0)) revert ZeroToken(); - // Gate the web-transfer surface to registered tokens (parity with the - // by-name overload + VaultFacet.deposit): verifyTemplate matches on the hex - // address, so without this an unregistered ERC-20 would be pulled into the - // ledger via autoTopUp. - if (bytes(LibTokenStorage.store().tokenName[token]).length == 0) revert TokenNotRegistered(); - - // Arg-binding (see the by-name overload): its recipientLess result gates - // the reply/quote binding — authoritative over verifyProof's matchAnyTemplate. - if (LibTemplate.verifyTemplate( - revealedSubsection, resource.platform, receiverHandle, amountStr, Strings.toHexString(token) - )) { - LibRecipient.verifyRecipientIdLeaf(notaryTlsProof, receiverUserId); - } - - (address senderWallet, address receiverWallet) = _webTransfer( - resource, - sender, - revealedSubsection, - notaryTlsProof, - backendSigData, - receiverUserId, - token, - amountStr, - amount - ); - emit WebTransferV3( - senderWallet, receiverWallet, resource.platform, receiverHandle, receiverUserId, token, amount, sourceUrl - ); - } - - /// @dev Shared body of both `webTransferV2` overloads: amount-wei check, proof - /// verification, and the balance move. The recipient-less binding + token - /// resolution differ by overload and stay there. - function _webTransfer( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverUserId, - address token, - string calldata amountStr, - uint256 amount - ) internal returns (address senderWallet, address receiverWallet) { - if (amount != LibString.parseDecimalAmount(amountStr, LibEscrow.tokenDecimals(token))) revert AmountWeiMismatch(); - - bytes32 uid = - _verifyAndGetUid(resource, sender, revealedSubsection, notaryTlsProof, backendSigData, receiverUserId); - (senderWallet, receiverWallet) = _executeTransfer(uid, resource.platform, sender, receiverUserId, token, amount); - } - - // ── Transfer within the Bank (WebWallet → WebWallet) ───────────────── - - /// @notice Transfer between internal Bank balances without touching the - /// chain. Caller must be a registered WebWallet. Lazy-migrates + - /// auto-tops-up. - function transfer_within( - string calldata receiverPlatform, - string calldata receiverHandle, - string calldata receiverUserId, - address token, - uint256 amount - ) external nonReentrant whenNotPaused { - if (amount == 0) revert ZeroAmount(); - - (string[] memory platforms,) = IRegistry(LibCoreStorage.store().registry).getHandles(msg.sender); - if (platforms.length == 0) revert SenderNotRegistered(); - - LibEscrowStorage.EscrowStorage storage es = LibEscrowStorage.store(); - LibEscrow.migrate(msg.sender, token); - LibEscrow.autoTopUp(msg.sender, token, amount); - es.registeredBalances[msg.sender][token] -= amount; - - address receiverWallet = LibEscrow.resolveById(receiverPlatform, receiverUserId); - if (receiverWallet != address(0)) { - LibEscrow.migrate(receiverWallet, token); - es.registeredBalances[receiverWallet][token] += amount; - } else { - es.unregisteredBalances[LibEscrow.escrowKey(receiverPlatform, receiverUserId)][token] += amount; - } - - emit TransferWithin(msg.sender, receiverPlatform, receiverHandle, receiverUserId, token, amount); - } - - // ── Internals ──────────────────────────────────────────────────────── - - /// @dev Verify proof and compute UID. Reverts if invalid or already used. - function _verifyAndGetUid( - ResourceInfo calldata resource, - SenderInfo calldata sender, - bytes calldata revealedSubsection, - NotaryTlsProof calldata notaryTlsProof, - BackendSig calldata backendSigData, - string calldata receiverUserId - ) internal view returns (bytes32 uid) { - uid = _uid(resource); - if (LibCoreStorage.store().usedTransfers[uid]) revert TransferAlreadyUsed(); - verifyProof(resource, sender, revealedSubsection, notaryTlsProof, backendSigData, receiverUserId); - } - - /// @dev Validate caller-provided sourceUrl: https + platform web domain. Empty - /// sourceUrl skips validation. - function _validateSourceUrl(ResourceInfo calldata resource, string calldata sourceUrl) internal view { - if (bytes(sourceUrl).length == 0) return; - if (!LibString.startsWith(bytes(sourceUrl), bytes("https://"))) revert SourceUrlNotHttps(); - - string memory webPrefix = LibTemplateStorage.store().platformWebPrefixes[keccak256(bytes(resource.platform))]; - if (bytes(webPrefix).length > 0 && !LibString.startsWith(bytes(sourceUrl), bytes(webPrefix))) { - revert SourceUrlDomainMismatch(); - } - } - - /// @dev Move funds after verification: deduct sender, credit receiver or escrow, - /// mark uid used. Both parties resolved by proven id (recycle-proof). - function _executeTransfer( - bytes32 uid, - string calldata platform, - SenderInfo calldata sender, - string calldata receiverUserId, - address token, - uint256 amount - ) internal returns (address senderWallet, address receiverWallet) { - if (amount == 0) revert ZeroAmount(); - - senderWallet = LibEscrow.resolveById(platform, LibString.extractId(sender.revealedAuthorId)); - if (senderWallet == address(0)) revert SenderNotRegistered(); - - LibEscrowStorage.EscrowStorage storage es = LibEscrowStorage.store(); - LibEscrow.migrate(senderWallet, token); - LibEscrow.autoTopUp(senderWallet, token, amount); - es.registeredBalances[senderWallet][token] -= amount; - - receiverWallet = LibEscrow.resolveById(platform, receiverUserId); - if (receiverWallet != address(0)) { - LibEscrow.migrate(receiverWallet, token); - es.registeredBalances[receiverWallet][token] += amount; - } else { - es.unregisteredBalances[LibEscrow.escrowKey(platform, receiverUserId)][token] += amount; - } - - LibCoreStorage.store().usedTransfers[uid] = true; - } -} diff --git a/solidity/contracts/transfer/bank/facets/VaultFacet.sol b/solidity/contracts/transfer/bank/facets/VaultFacet.sol deleted file mode 100644 index 98dfa0af..00000000 --- a/solidity/contracts/transfer/bank/facets/VaultFacet.sol +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import {BankModifiers} from "../BankModifiers.sol"; -import {LibEscrow} from "../libraries/LibEscrow.sol"; -import {LibEscrowStorage} from "../storage/LibEscrowStorage.sol"; -import {LibTokenStorage} from "../storage/LibTokenStorage.sol"; -import { - ZeroAmount, - TokenNotRegistered, - NativeAmountMismatch, - NativeSentWithErc20, - InsufficientAllowance, - InsufficientBalance, - NativeTransferFailed -} from "../BankErrors.sol"; - -/// @title VaultFacet — deposits, withdrawals, and balance views. -/// @dev The user-facing money surface. Funds live in the diamond; the ledgers + -/// id-escrow live in LibEscrowStorage, read/written through LibEscrow. -contract VaultFacet is BankModifiers { - using SafeERC20 for IERC20; - - event Deposited(address indexed wallet, bytes32 indexed handleKey, address indexed token, uint256 amount); - event Withdrawn(address indexed wallet, address indexed recipient, address indexed token, uint256 amount); - - /// @notice Top up a user's balance. Credits the resolved wallet, or id-escrow - /// when the recipient hasn't registered yet. - function deposit( - string calldata platform, - string calldata handle, - string calldata userId, - address token, - uint256 amount - ) external payable nonReentrant whenNotPaused { - if (amount == 0) revert ZeroAmount(); - if (bytes(LibTokenStorage.store().tokenName[token]).length == 0) revert TokenNotRegistered(); - - if (token == address(0)) { - if (msg.value != amount) revert NativeAmountMismatch(); - } else { - if (msg.value != 0) revert NativeSentWithErc20(); - uint256 allowed = IERC20(token).allowance(msg.sender, address(this)); - if (allowed < amount) revert InsufficientAllowance(token, msg.sender, address(this), amount, allowed); - IERC20(token).safeTransferFrom(msg.sender, address(this), amount); - } - - LibEscrowStorage.EscrowStorage storage es = LibEscrowStorage.store(); - address wallet = LibEscrow.resolveById(platform, userId); - if (wallet != address(0)) { - LibEscrow.migrate(wallet, token); - es.registeredBalances[wallet][token] += amount; - emit Deposited(wallet, bytes32(0), token, amount); - } else { - bytes32 key = LibEscrow.escrowKey(platform, userId); - es.unregisteredBalances[key][token] += amount; - emit Deposited(address(0), key, token, amount); - } - } - - /// @notice Withdraw from the caller's registered balance to `recipient`. - function withdraw(address recipient, address token, uint256 amount) external nonReentrant whenNotPaused { - if (amount == 0) revert ZeroAmount(); - - LibEscrow.migrate(msg.sender, token); - LibEscrowStorage.EscrowStorage storage es = LibEscrowStorage.store(); - if (es.registeredBalances[msg.sender][token] < amount) revert InsufficientBalance(); - es.registeredBalances[msg.sender][token] -= amount; - - if (token == address(0)) { - (bool ok,) = recipient.call{value: amount}(""); - if (!ok) revert NativeTransferFailed(); - } else { - IERC20(token).safeTransfer(recipient, amount); - } - emit Withdrawn(msg.sender, recipient, token, amount); - } - - /// @notice Registered + id-escrow balance (Bank-held) for a wallet. - function balanceOf(address wallet, address token) external view returns (uint256) { - return LibEscrow.totalBalance(wallet, token); - } - - /// @notice Bank-held balance plus the wallet's own on-chain token balance. - function balanceOfTotal(address wallet, address token) external view returns (uint256) { - uint256 walletBal = token == address(0) ? wallet.balance : IERC20(token).balanceOf(wallet); - return LibEscrow.totalBalance(wallet, token) + walletBal; - } - - /// @notice Bank-held balance for a (platform, id) identity. Registered → the - /// full aggregated total; else the id-escrow slot. Handle is display-only. - function balanceOf(string calldata platform, string calldata handle, string calldata userId, address token) - external - view - returns (uint256) - { - address wallet = LibEscrow.resolveById(platform, userId); - if (wallet != address(0)) return LibEscrow.totalBalance(wallet, token); - if (bytes(userId).length == 0) return 0; - return LibEscrowStorage.store().unregisteredBalances[LibEscrow.escrowKey(platform, userId)][token]; - } - - /// @notice As the (platform,...) `balanceOf`, plus the resolved wallet's own - /// on-chain token balance. - function balanceOfTotal(string calldata platform, string calldata handle, string calldata userId, address token) - external - view - returns (uint256) - { - address wallet = LibEscrow.resolveById(platform, userId); - if (wallet == address(0)) { - if (bytes(userId).length == 0) return 0; - return LibEscrowStorage.store().unregisteredBalances[LibEscrow.escrowKey(platform, userId)][token]; - } - uint256 walletBal = token == address(0) ? wallet.balance : IERC20(token).balanceOf(wallet); - return LibEscrow.totalBalance(wallet, token) + walletBal; - } - - /// @notice Compute the identity hash for a (domain, username) pair. - function identityHash(string calldata domain, string calldata username) external pure returns (bytes32) { - return LibEscrow.identityHash(domain, username); - } - - /// @notice Raw registered-ledger balance for a wallet (matches the old Bank - /// public mapping getter). Aggregation is `balanceOf`. - function registeredBalances(address wallet, address token) external view returns (uint256) { - return LibEscrowStorage.store().registeredBalances[wallet][token]; - } - - /// @notice Raw id-escrow slot balance (matches the old Bank public mapping getter). - function unregisteredBalances(bytes32 key, address token) external view returns (uint256) { - return LibEscrowStorage.store().unregisteredBalances[key][token]; - } -} diff --git a/solidity/contracts/transfer/bank/libraries/LibEscrow.sol b/solidity/contracts/transfer/bank/libraries/LibEscrow.sol deleted file mode 100644 index 4af5f610..00000000 --- a/solidity/contracts/transfer/bank/libraries/LibEscrow.sol +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -import {LibEscrowStorage} from "../storage/LibEscrowStorage.sol"; -import {LibCoreStorage} from "../storage/LibCoreStorage.sol"; -import {IRegistry} from "../../../login/IRegistry.sol"; -import {UserIdRequired, InsufficientBalance, InsufficientAllowance} from "../BankErrors.sol"; - -/// @title LibEscrow — the shared money layer (ledgers + id-escrow) for the Bank -/// vault and transfer facets. -/// @dev Escrow is keyed by the immutable platform userId (recycle-proof), -/// domain-separated with ID_ESCROW_TAG. On a FRESH deploy there is no legacy -/// handle-keyed escrow, so sweeps + totals read the id keyspace only. -library LibEscrow { - using SafeERC20 for IERC20; - - /// @dev Domain-separation tag for the id-keyed escrow keyspace. - string internal constant ID_ESCROW_TAG = "id:v1"; - - /// @dev Escrow slot key, keyed by the immutable platform userId. - function escrowKey(string memory platform, string memory userId) internal pure returns (bytes32) { - if (bytes(userId).length == 0) revert UserIdRequired(); - return keccak256(abi.encode(ID_ESCROW_TAG, platform, userId)); - } - - /// @dev Identity hash for a (domain, username) pair. - function identityHash(string calldata domain, string calldata username) internal pure returns (bytes32) { - return keccak256(abi.encode(domain, username)); - } - - /// @dev Resolve a wallet by its immutable platform userId. Fresh deploy has no - /// pre-id (handle-keyed) users, so there is no handle fallback — a miss is - /// "unregistered" and the caller escrows under the id. - function resolveById(string memory platform, string memory id) internal view returns (address) { - return IRegistry(LibCoreStorage.store().registry).resolveById(platform, id); - } - - /// @dev Token decimals (native defaults to 18). A genuine 0-decimal ERC-20 is - /// honored as 0 — a token whose `decimals()` reverts fails the whole tx. - function tokenDecimals(address token) internal view returns (uint8) { - if (token == address(0)) return 18; - return IERC20Metadata(token).decimals(); - } - - /// @dev Sweep one id-escrow slot into the wallet's registered balance. - function sweepKey(address wallet, address token, bytes32 key) internal { - LibEscrowStorage.EscrowStorage storage es = LibEscrowStorage.store(); - uint256 pending = es.unregisteredBalances[key][token]; - if (pending > 0) { - delete es.unregisteredBalances[key][token]; - es.registeredBalances[wallet][token] += pending; - } - } - - /// @dev Sweep the wallet's id-escrow slots into its registered balance (the - /// ids are proven via the Registry, so recycle-proof). - function migrate(address wallet, address token) internal { - (string[] memory platforms, string[] memory userIds) = - IRegistry(LibCoreStorage.store().registry).getUserIds(wallet); - for (uint256 i = 0; i < platforms.length; i++) { - if (bytes(userIds[i]).length == 0) continue; - sweepKey(wallet, token, keccak256(abi.encode(ID_ESCROW_TAG, platforms[i], userIds[i]))); - } - } - - /// @dev View total: registered + all (id-keyed) unregistered escrow for wallet. - function totalBalance(address wallet, address token) internal view returns (uint256 total) { - LibEscrowStorage.EscrowStorage storage es = LibEscrowStorage.store(); - total = es.registeredBalances[wallet][token]; - (string[] memory platforms, string[] memory userIds) = - IRegistry(LibCoreStorage.store().registry).getUserIds(wallet); - for (uint256 i = 0; i < platforms.length; i++) { - if (bytes(userIds[i]).length == 0) continue; - total += es.unregisteredBalances[keccak256(abi.encode(ID_ESCROW_TAG, platforms[i], userIds[i]))][token]; - } - } - - /// @dev Ensure `wallet` has `amount` registered, pulling any shortfall via its - /// ERC-20 allowance (native tokens can't be pulled → revert on shortfall). - function autoTopUp(address wallet, address token, uint256 amount) internal { - LibEscrowStorage.EscrowStorage storage es = LibEscrowStorage.store(); - uint256 bal = es.registeredBalances[wallet][token]; - if (bal < amount) { - if (token == address(0)) revert InsufficientBalance(); - uint256 shortfall = amount - bal; - uint256 allowed = IERC20(token).allowance(wallet, address(this)); - if (allowed < shortfall) revert InsufficientAllowance(token, wallet, address(this), shortfall, allowed); - IERC20(token).safeTransferFrom(wallet, address(this), shortfall); - es.registeredBalances[wallet][token] += shortfall; - } - } -} diff --git a/solidity/contracts/transfer/bank/libraries/LibProof.sol b/solidity/contracts/transfer/bank/libraries/LibProof.sol deleted file mode 100644 index 07a996cd..00000000 --- a/solidity/contracts/transfer/bank/libraries/LibProof.sol +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; - -import {LibCoreStorage} from "../storage/LibCoreStorage.sol"; -import {NotaryTlsProof} from "../BankTypes.sol"; -import { - InvalidNotarySignature, - InvalidBackendSignature, - InvalidMerkleProof, - MerklePathTooLong, - DomainHashMismatch -} from "../BankErrors.sol"; -import {INotary} from "../../../notary/INotary.sol"; - -/// @title LibProof — TLS-notary proof + backend-signature verification. -/// @dev Merkle-leaf membership (recv / domain / endpoint), the notary attestation -/// signature, and the backend authorization signature. Reads trusted-party -/// pointers from `LibCoreStorage`; ECDSA/Merkle via OpenZeppelin. -library LibProof { - /// @dev The shared leaf verifier: reject an over-long path, hash the - /// double-keccak leaf from `preimage`, and check Merkle membership. - /// Every leaf kind differs only in its `preimage` (a `prefix`+data blob). - function verifyLeaf(bytes32[] calldata path, bytes32 root, bytes memory preimage) internal pure { - if (path.length > 32) revert MerklePathTooLong(); - bytes32 leaf = keccak256(bytes.concat(keccak256(preimage))); - if (!MerkleProof.verify(path, root, leaf)) revert InvalidMerkleProof(); - } - - /// @dev A recv-transcript leaf (`"recv:"` + revealed bytes) — used for the - /// body, author, author-id, reply-target, and quote leaves. - function verifyRecvLeaf(bytes32[] calldata path, bytes32 root, bytes calldata data) internal pure { - verifyLeaf(path, root, abi.encodePacked("recv:", data)); - } - - /// @dev The domain leaf; also binds `domain` to the proof's `expectedDomainHash`. - function verifyDomainLeaf(bytes32[] calldata path, bytes32 root, string calldata domain, bytes32 expectedDomainHash) - internal - pure - { - if (keccak256(bytes(domain)) != expectedDomainHash) revert DomainHashMismatch(); - verifyLeaf(path, root, abi.encodePacked("domain:", domain)); - } - - /// @dev The endpoint (request-path) leaf. - function verifyEndpointLeaf(bytes32[] calldata path, bytes32 root, string calldata endpoint) internal pure { - verifyLeaf(path, root, abi.encodePacked("endpoint:", endpoint)); - } - - /// @dev Verify the notary attestation over the TLS-session digest. - /// Binds `block.chainid` + the current `registry` so a proof can't cross - /// chains or survive a Registry migration. The shared Notary contract - /// (`cs.notary`) owns the attestation check itself. - function verifyNotarySignature(NotaryTlsProof calldata proof) internal view { - LibCoreStorage.CoreStorage storage cs = LibCoreStorage.store(); - bytes32 digest = keccak256( - abi.encode( - block.chainid, - cs.registry, - proof.domainHash, - proof.clientRandom, - proof.serverRandom, - keccak256(proof.serverEphemeralKey), - proof.transcriptRoot, - proof.timestamp - ) - ); - if (!INotary(cs.notary).verify(digest, proof.notarySig)) revert InvalidNotarySignature(); - } - - /// @dev Verify the backend authorization signature. `receiverUserId` is signed - /// (hashed, to stay unambiguous next to the variable-length byte fields) - /// so a relayer can't redirect credit to another id. - function verifyBackendSignature( - bytes32 uid, - bytes calldata revealedSubsection, - bytes calldata revealedAuthor, - bytes calldata revealedAuthorId, - string calldata receiverUserId, - bytes calldata sig, - uint256 timestamp - ) internal view { - // Each of the three dynamic byte fields is independently Merkle-verified - // against transcriptRoot (verifyRecvLeaf on body/author/authorId), so a - // boundary re-split can't be forged — plain encodePacked is safe here. - bytes32 digest = keccak256( - abi.encodePacked( - uid, revealedSubsection, revealedAuthor, revealedAuthorId, keccak256(bytes(receiverUserId)), timestamp - ) - ); - address signer = ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(digest), sig); - if (signer != LibCoreStorage.store().backend) revert InvalidBackendSignature(); - } -} diff --git a/solidity/contracts/transfer/bank/libraries/LibRecipient.sol b/solidity/contracts/transfer/bank/libraries/LibRecipient.sol deleted file mode 100644 index cb546cf4..00000000 --- a/solidity/contracts/transfer/bank/libraries/LibRecipient.sol +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {LibString} from "./LibString.sol"; -import {LibProof} from "./LibProof.sol"; -import {NotaryTlsProof} from "../BankTypes.sol"; -import { - RecipientLessNeedsReplyTarget, - ReplyLeafTooShort, - ReplyLeafNotInReplyTo, - ReplyTargetMismatch, - ReplyTargetIdNotQuoted, - QuotedRefTooShort, - QuotedRefNotObject, - QuotedRefNotFlatObject, - RefNotQuote, - QuotedRefMissingId, - QuotedAuthorLeafTooShort, - QuotedAuthorLeafNotIncludes, - QuotedAuthorMissingQuotedId, - QuotedAuthorMissingAuthorId, - QuotedAuthorDifferentObject, - QuotedAuthorMismatch, - EmptyQuotedValue -} from "../BankErrors.sol"; - -/// @title LibRecipient — recipient binding for the recipient-less honor form. -/// @dev The body proves no `{recipient}`, so the recipient comes from a notarized -/// reply target (`in_reply_to_user_id`) or the quoted tweet's author. Each -/// binding is pinned to its specific field so a proof generator can't reveal -/// an arbitrary transcript id. Uses `LibProof.verifyRecvLeaf` for Merkle -/// membership and `LibString` for structural checks / value extraction. -library LibRecipient { - /// @dev Reply leaf takes precedence; otherwise a quote binding is required. - function verifyRecipientIdLeaf(NotaryTlsProof calldata proof, string calldata receiverUserId) internal pure { - if (proof.revealedReceiverId.length > 0) { - verifyReplyTargetLeaf(proof, receiverUserId); - return; - } - if (proof.revealedQuotedRef.length == 0) revert RecipientLessNeedsReplyTarget(); - verifyQuotedAuthorLeaf(proof, receiverUserId); - } - - /// @dev Reply binding: the revealed `"in_reply_to_user_id":""` leaf, with - /// its id equal to the backend-signed `receiverUserId`. Pinned to the - /// reply-target field (not "some id in the transcript"). - function verifyReplyTargetLeaf(NotaryTlsProof calldata proof, string calldata receiverUserId) internal pure { - bytes calldata leaf = proof.revealedReceiverId; - bytes memory key = bytes('"in_reply_to_user_id":'); - if (leaf.length < key.length) revert ReplyLeafTooShort(); - for (uint256 i = 0; i < key.length; i++) { - if (leaf[i] != key[i]) revert ReplyLeafNotInReplyTo(); - } - LibProof.verifyRecvLeaf(proof.receiverIdMerklePath, proof.transcriptRoot, leaf); - if (keccak256(bytes(extractReplyTargetId(leaf, key.length))) != keccak256(bytes(receiverUserId))) { - revert ReplyTargetMismatch(); - } - } - - /// @dev Quote binding: recipient = author of the quoted tweet, joined across - /// two notarized leaves so the bound id is unambiguously the quoted - /// author (not the sender, a mention, or a reply parent): - /// - `revealedQuotedRef` — a flat `{…}` `referenced_tweets` object - /// containing `"type":"quoted"` and the quoted parent's `"id":""` - /// (field order not assumed). - /// - `revealedQuotedAuthorId` — an `includes.tweets` slice STARTING with - /// `"tweets":[{` (so its first `author_id` is a real includes-tweet - /// author, not the sender's `data.author_id`) that contains the join - /// `"id":""` in the SAME object as the extracted `author_id`. - /// Require the extracted author == `receiverUserId`. - function verifyQuotedAuthorLeaf(NotaryTlsProof calldata proof, string calldata receiverUserId) internal pure { - bytes calldata refLeaf = proof.revealedQuotedRef; - if (refLeaf.length < 2) revert QuotedRefTooShort(); - if (refLeaf[0] != "{" || refLeaf[refLeaf.length - 1] != "}") revert QuotedRefNotObject(); - for (uint256 i = 1; i + 1 < refLeaf.length; i++) { - if (refLeaf[i] == "{" || refLeaf[i] == "}") revert QuotedRefNotFlatObject(); - } - if (!LibString.contains(refLeaf, bytes('"type":"quoted"'), false)) revert RefNotQuote(); - LibProof.verifyRecvLeaf(proof.quotedRefMerklePath, proof.transcriptRoot, refLeaf); - (bool hasQid, uint256 qidPos) = LibString.indexOf(refLeaf, bytes('"id":"'), 0, false); - if (!hasQid) revert QuotedRefMissingId(); - string memory qid = _readQuotedValue(refLeaf, qidPos + 6); // '"id":"'.length == 6 - - bytes calldata authorLeaf = proof.revealedQuotedAuthorId; - bytes memory tweetsKey = bytes('"tweets":[{'); - if (authorLeaf.length <= tweetsKey.length) revert QuotedAuthorLeafTooShort(); - for (uint256 i = 0; i < tweetsKey.length; i++) { - if (authorLeaf[i] != tweetsKey[i]) revert QuotedAuthorLeafNotIncludes(); - } - LibProof.verifyRecvLeaf(proof.quotedAuthorMerklePath, proof.transcriptRoot, authorLeaf); - - // Join: the leaf must contain the quoted tweet object (id == qid). - (bool hasId, uint256 idPos) = LibString.indexOf(authorLeaf, abi.encodePacked('"id":"', qid, '"'), 0, false); - if (!hasId) revert QuotedAuthorMissingQuotedId(); - // Bind the extracted author to the SAME includes-tweet object as the qid - // join: no `}` (object close) between the `author_id` and the qid match. - (bool hasAuthor, uint256 authorPos) = LibString.indexOf(authorLeaf, bytes('"author_id":"'), 0, false); - if (!hasAuthor) revert QuotedAuthorMissingAuthorId(); - uint256 lo = authorPos < idPos ? authorPos : idPos; - uint256 hi = authorPos < idPos ? idPos : authorPos; - for (uint256 i = lo; i < hi; i++) { - if (authorLeaf[i] == "}") revert QuotedAuthorDifferentObject(); - } - string memory rid = _readQuotedValue(authorLeaf, authorPos + 13); // '"author_id":"'.length == 13 - if (keccak256(bytes(rid)) != keccak256(bytes(receiverUserId))) revert QuotedAuthorMismatch(); - } - - /// @dev The `in_reply_to_user_id` value, anchored at `prefixLen` (the length of - /// the already-verified `"in_reply_to_user_id":` prefix). Requires the - /// opening quote, so a trailing colon-separated id can't override it. - function extractReplyTargetId(bytes calldata leaf, uint256 prefixLen) internal pure returns (string memory) { - if (prefixLen >= leaf.length || leaf[prefixLen] != '"') revert ReplyTargetIdNotQuoted(); - return _readQuotedValue(leaf, prefixLen + 1); - } - - /// @dev Read a `"…"` value: from `valueStart` up to the next `"`. The shared - /// extraction behind all three id fields. Reverts on an empty value. - function _readQuotedValue(bytes calldata leaf, uint256 valueStart) internal pure returns (string memory) { - uint256 ve = valueStart; - while (ve < leaf.length && leaf[ve] != '"') ve++; - if (ve <= valueStart) revert EmptyQuotedValue(); - return string(leaf[valueStart:ve]); - } -} diff --git a/solidity/contracts/transfer/bank/libraries/LibString.sol b/solidity/contracts/transfer/bank/libraries/LibString.sol deleted file mode 100644 index a025228c..00000000 --- a/solidity/contracts/transfer/bank/libraries/LibString.sol +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title LibString — Bank-specific byte/string primitives NOT covered by OZ. -/// @dev Only what OpenZeppelin doesn't provide: multi-byte substring search -/// (OZ `Bytes.indexOf` is single-byte), case-insensitive compare/search -/// (OZ `Strings.equal` is case-sensitive), token-char classification, tight -/// author-id parsing, and decimal-amount scaling. For the rest, callers use -/// OZ directly: `Strings.toHexString(address)` and `Bytes.slice(...)` for a -/// memory substring. require-strings replaced with custom errors. -library LibString { - error InvalidRange(); - error MalformedAuthorId(); - error EmptyAuthorId(); - error MultipleDecimalPoints(); - - /// @dev Find `needle` in memory `haystack` from `from` (case-sensitive). - /// @dev The single substring-search primitive. Find `needle` in `haystack` - /// from `from`; `ci` = ASCII case-insensitive. Memory haystack unifies - /// the memory/calldata variants — a calldata arg is copied in, and the - /// found position is identical byte-for-byte, so a caller may slice its - /// ORIGINAL calldata by the returned position. `(true, from)` for an - /// empty needle. - function indexOf(bytes memory haystack, bytes memory needle, uint256 from, bool ci) - internal - pure - returns (bool, uint256) - { - if (needle.length == 0) return (true, from); - if (from + needle.length > haystack.length) return (false, 0); - uint256 searchLen = haystack.length - needle.length + 1; - for (uint256 i = from; i < searchLen; i++) { - bool found = true; - for (uint256 j = 0; j < needle.length; j++) { - bytes1 h = haystack[i + j]; - bytes1 n = needle[j]; - if (ci ? toLower(h) != toLower(n) : h != n) { - found = false; - break; - } - } - if (found) return (true, i); - } - return (false, 0); - } - - /// @dev Substring of calldata bytes `[start, end)`. (For a MEMORY substring - /// use OZ `Bytes.slice` — this calldata variant is native slicing.) - function substringCalldata(bytes calldata data, uint256 start, uint256 end) internal pure returns (string memory) { - if (end < start || end > data.length) revert InvalidRange(); - return string(data[start:end]); - } - - /// @dev Case-insensitive string equality. - function caseInsensitiveEqual(string memory a, string memory b) internal pure returns (bool) { - bytes memory ab = bytes(a); - bytes memory bb = bytes(b); - if (ab.length != bb.length) return false; - for (uint256 i = 0; i < ab.length; i++) { - if (toLower(ab[i]) != toLower(bb[i])) return false; - } - return true; - } - - /// @dev Whether `haystack` contains `needle`; `ci` = case-insensitive. - /// Thin wrapper over [`indexOf`]. - function contains(bytes memory haystack, bytes memory needle, bool ci) internal pure returns (bool) { - (bool found,) = indexOf(haystack, needle, 0, ci); - return found; - } - - /// @dev Whether `haystack` begins with `prefix` (case-sensitive). Distinct from - /// `contains` — the match must be at position 0. - function startsWith(bytes memory haystack, bytes memory prefix) internal pure returns (bool) { - if (haystack.length < prefix.length) return false; - for (uint256 i = 0; i < prefix.length; i++) { - if (haystack[i] != prefix[i]) return false; - } - return true; - } - - /// @dev Parse id from a tight author-id snippet: quoted `"k":""` or bare - /// `"k":[,}]`. Value is after the LAST colon. - function extractId(bytes calldata s) internal pure returns (string memory) { - uint256 n = s.length; - uint256 c = n; - while (c > 0) { - c--; - if (s[c] == ":") break; - } - if (c >= n || s[c] != ":") revert MalformedAuthorId(); - uint256 i = c + 1; - bool quoted = i < n && s[i] == '"'; - if (quoted) i++; - uint256 start = i; - if (quoted) { - while (i < n && s[i] != '"') i++; - } else { - while (i < n && s[i] >= 0x30 && s[i] <= 0x39) i++; - } - if (i <= start) revert EmptyAuthorId(); - return string(s[start:i]); - } - - /// @dev Valid token-name character: a-z, A-Z, 0-9, `$`, `_`, `.`. - function isTokenChar(bytes1 c) internal pure returns (bool) { - return (c >= 0x41 && c <= 0x5A) // A-Z - || (c >= 0x61 && c <= 0x7A) // a-z - || (c >= 0x30 && c <= 0x39) // 0-9 - || c == "$" || c == "_" || c == "."; - } - - /// @dev ASCII lower-casing of a single byte. - function toLower(bytes1 b) internal pure returns (bytes1) { - if (b >= 0x41 && b <= 0x5A) { - return bytes1(uint8(b) + 32); - } - return b; - } - - /// @dev Human decimal amount (e.g. "0.05", "100") → wei at `decimals`. - /// Digits beyond `decimals` precision are truncated. - function parseDecimalAmount(string memory s, uint8 decimals) internal pure returns (uint256) { - bytes memory b = bytes(s); - uint256 intPart = 0; - uint256 fracPart = 0; - uint8 fracDigits = 0; - bool inFrac = false; - for (uint256 i = 0; i < b.length; i++) { - if (b[i] == ".") { - if (inFrac) revert MultipleDecimalPoints(); - inFrac = true; - } else if (b[i] >= "0" && b[i] <= "9") { - if (!inFrac) { - intPart = intPart * 10 + uint256(uint8(b[i]) - 48); - } else if (fracDigits < decimals) { - fracPart = fracPart * 10 + uint256(uint8(b[i]) - 48); - fracDigits++; - } - } - } - uint256 scale = 10 ** decimals; - uint256 fracScale = 10 ** (decimals - fracDigits); - return intPart * scale + fracPart * fracScale; - } -} diff --git a/solidity/contracts/transfer/bank/libraries/LibTemplate.sol b/solidity/contracts/transfer/bank/libraries/LibTemplate.sol deleted file mode 100644 index 7c30a0b2..00000000 --- a/solidity/contracts/transfer/bank/libraries/LibTemplate.sol +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Bytes} from "@openzeppelin/contracts/utils/Bytes.sol"; - -import {LibString} from "./LibString.sol"; -import {LibTemplateStorage} from "../storage/LibTemplateStorage.sol"; -import { - TemplateNotFound, - NoTemplateMatch, - TemplateMissingToken, - TemplateMissingAmount, - TemplateMissingTokenAfterRecipient -} from "../BankErrors.sol"; - -/// @title LibTemplate — honor-command template parsing + matching. -/// @dev Parses a template string into `{prefix}{recipient}{afterRecipient}{amount} -/// {afterAmount}{token}{suffix}` parts and matches a revealed comment body -/// against them, extracting recipient/amount/token. Reads/writes -/// `LibTemplateStorage`; uses `LibString` for search/extraction (+ OZ -/// `Bytes.slice` for memory substrings, `Strings.toHexString` for by-address). -library LibTemplate { - /// @dev Store a template string and parse it into parts. Recognizes: - /// - `{recipient}`+`{amount}`+`{token}` — explicit recipient form. - /// - `{amount}`+`{token}` (no `{recipient}`) — recipient-less form - /// (recipient inferred off-chain from the reply/quote parent). - /// - no `{amount}` — a plain literal template (body must contain it). - function addTemplate(string memory platform, string memory template) internal { - LibTemplateStorage.TemplateStorage storage ts = LibTemplateStorage.store(); - bytes32 key = keccak256(bytes(platform)); - ts.platformTemplates[key].push(template); - - bytes memory t = bytes(template); - (bool foundRcp, uint256 rcpPos) = LibString.indexOf(t, bytes("{recipient}"), 0, false); - if (!foundRcp) { - (bool amtOnly, uint256 amtOnlyPos) = LibString.indexOf(t, bytes("{amount}"), 0, false); - if (!amtOnly) { - ts.templateParts[key].push( - LibTemplateStorage.TemplateParts({ - prefix: template, afterRecipient: "", afterAmount: "", suffix: "", hasPlaceholders: false - }) - ); - return; - } - (bool tokOnly, uint256 tokOnlyPos) = LibString.indexOf(t, bytes("{token}"), amtOnlyPos + 8, false); - if (!tokOnly) revert TemplateMissingToken(); - ts.templateParts[key].push( - LibTemplateStorage.TemplateParts({ - prefix: string(Bytes.slice(t, 0, amtOnlyPos)), - afterRecipient: "", - afterAmount: string(Bytes.slice(t, amtOnlyPos + 8, tokOnlyPos)), - suffix: string(Bytes.slice(t, tokOnlyPos + 7, t.length)), - hasPlaceholders: true - }) - ); - return; - } - (bool foundAmt, uint256 amtPos) = LibString.indexOf(t, bytes("{amount}"), rcpPos + 11, false); - if (!foundAmt) revert TemplateMissingAmount(); - (bool foundTok, uint256 tokPos) = LibString.indexOf(t, bytes("{token}"), amtPos + 8, false); - if (!foundTok) revert TemplateMissingTokenAfterRecipient(); - - ts.templateParts[key].push( - LibTemplateStorage.TemplateParts({ - prefix: string(Bytes.slice(t, 0, rcpPos)), - afterRecipient: string(Bytes.slice(t, rcpPos + 11, amtPos)), - afterAmount: string(Bytes.slice(t, amtPos + 8, tokPos)), - suffix: string(Bytes.slice(t, tokPos + 7, t.length)), - hasPlaceholders: true - }) - ); - } - - /// @dev Whether the i-th template for a platform is an explicit `{recipient}` - /// form. Derived at call time from the raw template string. - function templateHasRecipient(bytes32 platformKey, uint256 i) internal view returns (bool) { - LibTemplateStorage.TemplateStorage storage ts = LibTemplateStorage.store(); - (bool found,) = LibString.indexOf(bytes(ts.platformTemplates[platformKey][i]), bytes("{recipient}"), 0, false); - return found; - } - - /// @dev Try all templates for a platform; return the first match. - /// @return matched Whether any template matched. - /// @return amt Extracted amount (empty for a literal template). - /// @return tok Extracted token (empty for a literal template). - /// @return recipientLess True when the matched template is a placeholder form - /// with NO `{recipient}` — the recipient is then bound off-body via the - /// proven reply-target id leaf. - function matchAnyTemplate(bytes calldata data, bytes32 platformKey) - internal - view - returns (bool, string memory, string memory, bool) - { - LibTemplateStorage.TemplateStorage storage ts = LibTemplateStorage.store(); - uint256 count = ts.templateParts[platformKey].length; - for (uint256 i = 0; i < count; i++) { - (bool matched, string memory amt, string memory tok,) = matchTemplate(data, platformKey, i); - if (matched) { - LibTemplateStorage.TemplateParts storage parts = ts.templateParts[platformKey][i]; - bool recipientLess = parts.hasPlaceholders && !templateHasRecipient(platformKey, i); - return (true, amt, tok, recipientLess); - } - } - return (false, "", "", false); - } - - /// @dev Match one template (by index) against the revealed body. Returns - /// `(matched, extractedAmount, extractedToken, extractedRecipient)`. - /// Literal templates yield empty amount/token/recipient. - function matchTemplate(bytes calldata data, bytes32 platformKey, uint256 templateIndex) - internal - view - returns (bool, string memory, string memory, string memory) - { - LibTemplateStorage.TemplateParts storage p = - LibTemplateStorage.store().templateParts[platformKey][templateIndex]; - bytes memory prefix = bytes(p.prefix); - - // 1. Prefix (case-insensitive). - (bool foundPrefix, uint256 prefixPos) = LibString.indexOf(data, prefix, 0, true); - if (!foundPrefix) return (false, "", "", ""); - - // Simple literal template — a prefix match is sufficient. - if (!p.hasPlaceholders) return (true, "", "", ""); - - bytes memory afterRecipient = bytes(p.afterRecipient); - bytes memory afterAmount = bytes(p.afterAmount); - bytes memory suffix = bytes(p.suffix); - - uint256 recipientStart = prefixPos + prefix.length; - - // 2. afterRecipient (skip over the recipient value). - (bool foundAfterRcp, uint256 afterRcpPos) = LibString.indexOf(data, afterRecipient, recipientStart, true); - if (!foundAfterRcp) return (false, "", "", ""); - string memory recipient = LibString.substringCalldata(data, recipientStart, afterRcpPos); - uint256 amountStart = afterRcpPos + afterRecipient.length; - - // 3. afterAmount (skip over the amount value). - (bool foundAfterAmt, uint256 afterAmtPos) = LibString.indexOf(data, afterAmount, amountStart, true); - if (!foundAfterAmt) return (false, "", "", ""); - string memory amount = LibString.substringCalldata(data, amountStart, afterAmtPos); - uint256 tokenStart = afterAmtPos + afterAmount.length; - - // 4. suffix (or, when empty, the token runs to the next non-token char). - string memory token; - if (suffix.length > 0) { - (bool foundSuffix, uint256 suffixPos) = LibString.indexOf(data, suffix, tokenStart, true); - if (!foundSuffix) return (false, "", "", ""); - token = LibString.substringCalldata(data, tokenStart, suffixPos); - } else { - uint256 tokenEnd = tokenStart; - while (tokenEnd < data.length) { - if (!LibString.isTokenChar(data[tokenEnd])) break; - tokenEnd++; - } - token = LibString.substringCalldata(data, tokenStart, tokenEnd); - } - - if (bytes(amount).length == 0 || bytes(token).length == 0) return (false, "", "", ""); - return (true, amount, token, recipient); - } - - /// @dev Verify a template match and check the extracted amount + token - /// against `amountStr` / `expectedToken`. Reverts if nothing matches. - /// Returns `recipientLess` — true when a recipient-less template matched - /// (the caller binds the recipient via the proven reply-target id leaf). - /// The by-name vs by-address distinction is the caller's: it passes the - /// token NAME or `Strings.toHexString(tokenAddress)` as `expectedToken`. - function verifyTemplate( - bytes calldata body, - string calldata platform, - string calldata receiverHandle, - string calldata amountStr, - string memory expectedToken - ) internal view returns (bool recipientLess) { - bytes32 platformKey = keccak256(bytes(platform)); - uint256 count = LibTemplateStorage.store().platformTemplates[platformKey].length; - if (count == 0) revert TemplateNotFound(); - for (uint256 i = 0; i < count; i++) { - (bool matched, string memory amt, string memory tok, string memory rcp) = - matchTemplate(body, platformKey, i); - if ( - matched && keccak256(bytes(amt)) == keccak256(bytes(amountStr)) - && LibString.caseInsensitiveEqual(tok, expectedToken) - ) { - if (!templateHasRecipient(platformKey, i)) return true; - if (LibString.caseInsensitiveEqual(rcp, receiverHandle)) return false; - } - } - revert NoTemplateMatch(); - } -} diff --git a/solidity/contracts/transfer/bank/storage/LibCoreStorage.sol b/solidity/contracts/transfer/bank/storage/LibCoreStorage.sol deleted file mode 100644 index 1c14f037..00000000 --- a/solidity/contracts/transfer/bank/storage/LibCoreStorage.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title LibCoreStorage — cross-cutting Bank config + lifecycle flags. -/// @dev Trusted-party pointers, replay set, and the pause / reentrancy flags -/// (replacing the OZ Pausable / ReentrancyGuard base contracts a diamond -/// cannot inherit). Ownership lives in `LibDiamond` (ERC-173). Its own -/// namespaced slot, isolated from the other Bank storage concerns. -library LibCoreStorage { - bytes32 internal constant STORAGE_POSITION = keccak256("dyaka.bank.core.v1"); - - struct CoreStorage { - address notary; // the shared Notary contract (INotary proxy) - address backend; // backend signer - address registry; // Registry (resolves WebWallet by id) - mapping(bytes32 => bool) usedTransfers; // replay protection by uid - bool paused; - bool initialized; // one-shot guard: BankInit.init sets it; a re-delegatecall reverts - uint256 reentrancyStatus; // 0/1 = not entered, 2 = entered - } - - function store() internal pure returns (CoreStorage storage cs) { - bytes32 position = STORAGE_POSITION; - assembly { - cs.slot := position - } - } -} diff --git a/solidity/contracts/transfer/bank/storage/LibEscrowStorage.sol b/solidity/contracts/transfer/bank/storage/LibEscrowStorage.sol deleted file mode 100644 index e2d4cb1c..00000000 --- a/solidity/contracts/transfer/bank/storage/LibEscrowStorage.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title LibEscrowStorage — the Bank's token ledgers. -/// @dev The money: registered-wallet balances and id-escrow (unregistered) -/// balances, in their own namespaced slot — isolated from config/templates -/// so only the transfer/vault path touches funds. -library LibEscrowStorage { - bytes32 internal constant STORAGE_POSITION = keccak256("dyaka.bank.escrow.v1"); - - struct EscrowStorage { - // Registered users: wallet → token → amount. - mapping(address => mapping(address => uint256)) registeredBalances; - // Unregistered users (id-escrow): escrowKey → token → amount, where - // escrowKey = keccak256(abi.encode(ID_ESCROW_TAG, platform, userId)). - mapping(bytes32 => mapping(address => uint256)) unregisteredBalances; - } - - function store() internal pure returns (EscrowStorage storage es) { - bytes32 position = STORAGE_POSITION; - assembly { - es.slot := position - } - } -} diff --git a/solidity/contracts/transfer/bank/storage/LibTemplateStorage.sol b/solidity/contracts/transfer/bank/storage/LibTemplateStorage.sol deleted file mode 100644 index 5bff0068..00000000 --- a/solidity/contracts/transfer/bank/storage/LibTemplateStorage.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title LibTemplateStorage — honor-template + resource/web-prefix config. -/// @dev Everything the template-matching path reads: per-platform template -/// strings, their parsed parts, and the resource-type / web-URL prefixes. -/// Its own namespaced slot — isolated from token registry and escrow. -library LibTemplateStorage { - bytes32 internal constant STORAGE_POSITION = keccak256("dyaka.bank.template.v1"); - - /// Parsed honor-template parts (parallel to `platformTemplates[key][i]`). - struct TemplateParts { - string prefix; // text before {recipient} (or full literal for simple templates) - string afterRecipient; // text between {recipient} and {amount} - string afterAmount; // text between {amount} and {token} - string suffix; // text after {token} - bool hasPlaceholders; // true when the template has {recipient}/{amount}/{token} - } - - struct TemplateStorage { - mapping(bytes32 => string[]) platformTemplates; // keccak(platform) → template strings - mapping(bytes32 => TemplateParts[]) templateParts; // parallel parsed parts - mapping(bytes32 => string) resourceTypePrefixes; // keccak(resourceType) → request-path prefix - mapping(bytes32 => string) platformWebPrefixes; // keccak(platform) → web URL prefix - } - - function store() internal pure returns (TemplateStorage storage ts) { - bytes32 position = STORAGE_POSITION; - assembly { - ts.slot := position - } - } -} diff --git a/solidity/contracts/transfer/bank/storage/LibTokenStorage.sol b/solidity/contracts/transfer/bank/storage/LibTokenStorage.sol deleted file mode 100644 index e08e535e..00000000 --- a/solidity/contracts/transfer/bank/storage/LibTokenStorage.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title LibTokenStorage — the ERC-20 token registry. -/// @dev name↔address mapping (both directions) + the registered-token list, -/// in its own namespaced slot — isolated from templates and escrow. -library LibTokenStorage { - bytes32 internal constant STORAGE_POSITION = keccak256("dyaka.bank.token.v1"); - - struct TokenStorage { - mapping(bytes32 => address) tokenByName; // keccak(name) → ERC20 (case-sensitive) - mapping(address => string) tokenName; // ERC20 → canonical display name - address[] registeredTokens; - } - - function store() internal pure returns (TokenStorage storage ts) { - bytes32 position = STORAGE_POSITION; - assembly { - ts.slot := position - } - } -} diff --git a/solidity/contracts/transfer/diamond/Diamond.sol b/solidity/contracts/transfer/diamond/Diamond.sol deleted file mode 100644 index 812cc545..00000000 --- a/solidity/contracts/transfer/diamond/Diamond.sol +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {LibDiamond} from "./libraries/LibDiamond.sol"; -import {IDiamondCut} from "./interfaces/IDiamondCut.sol"; - -/// @title Diamond — EIP-2535 multi-facet proxy. -/// @dev The constructor sets the owner and cuts in the `DiamondCutFacet` so the -/// diamond can be extended. All other facets (loupe, ownership, Bank facets) -/// are added afterward via `diamondCut`. Every call is routed to a facet by -/// selector and executed via `delegatecall` in the diamond's storage context. -contract Diamond { - error FunctionNotFound(bytes4 selector); - - constructor(address _contractOwner, address _diamondCutFacet) payable { - LibDiamond.setContractOwner(_contractOwner); - - IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](1); - bytes4[] memory selectors = new bytes4[](1); - selectors[0] = IDiamondCut.diamondCut.selector; - cut[0] = IDiamondCut.FacetCut({ - facetAddress: _diamondCutFacet, action: IDiamondCut.FacetCutAction.Add, functionSelectors: selectors - }); - LibDiamond.diamondCut(cut, address(0), ""); - } - - fallback() external payable { - LibDiamond.DiamondStorage storage ds; - bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION; - assembly { - ds.slot := position - } - address facet = ds.selectorToFacetAndPosition[msg.sig].facetAddress; - if (facet == address(0)) revert FunctionNotFound(msg.sig); - assembly { - calldatacopy(0, 0, calldatasize()) - let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0) - returndatacopy(0, 0, returndatasize()) - switch result - case 0 { revert(0, returndatasize()) } - default { return(0, returndatasize()) } - } - } - - receive() external payable {} -} diff --git a/solidity/contracts/transfer/diamond/facets/DiamondCutFacet.sol b/solidity/contracts/transfer/diamond/facets/DiamondCutFacet.sol deleted file mode 100644 index 2b68ba1b..00000000 --- a/solidity/contracts/transfer/diamond/facets/DiamondCutFacet.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {LibDiamond} from "../libraries/LibDiamond.sol"; -import {IDiamondCut} from "../interfaces/IDiamondCut.sol"; - -/// @title DiamondCutFacet — add/replace/remove facet functions (owner-gated). -contract DiamondCutFacet is IDiamondCut { - /// @inheritdoc IDiamondCut - function diamondCut(FacetCut[] calldata _diamondCut, address _init, bytes calldata _calldata) external override { - LibDiamond.enforceIsContractOwner(); - LibDiamond.diamondCut(_diamondCut, _init, _calldata); - } -} diff --git a/solidity/contracts/transfer/diamond/facets/DiamondLoupeFacet.sol b/solidity/contracts/transfer/diamond/facets/DiamondLoupeFacet.sol deleted file mode 100644 index eaf4fb7d..00000000 --- a/solidity/contracts/transfer/diamond/facets/DiamondLoupeFacet.sol +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {LibDiamond} from "../libraries/LibDiamond.sol"; -import {IDiamondLoupe} from "../interfaces/IDiamondLoupe.sol"; -import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; - -/// @title DiamondLoupeFacet — EIP-2535 introspection + ERC-165. -contract DiamondLoupeFacet is IDiamondLoupe, IERC165 { - /// @inheritdoc IDiamondLoupe - function facets() external view override returns (Facet[] memory facets_) { - LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage(); - uint256 n = ds.facetAddresses.length; - facets_ = new Facet[](n); - for (uint256 i; i < n; i++) { - address facet = ds.facetAddresses[i]; - facets_[i].facetAddress = facet; - facets_[i].functionSelectors = ds.facetFunctionSelectors[facet].functionSelectors; - } - } - - /// @inheritdoc IDiamondLoupe - function facetFunctionSelectors(address _facet) external view override returns (bytes4[] memory) { - return LibDiamond.diamondStorage().facetFunctionSelectors[_facet].functionSelectors; - } - - /// @inheritdoc IDiamondLoupe - function facetAddresses() external view override returns (address[] memory) { - return LibDiamond.diamondStorage().facetAddresses; - } - - /// @inheritdoc IDiamondLoupe - function facetAddress(bytes4 _functionSelector) external view override returns (address) { - return LibDiamond.diamondStorage().selectorToFacetAndPosition[_functionSelector].facetAddress; - } - - /// @inheritdoc IERC165 - function supportsInterface(bytes4 _interfaceId) external view override returns (bool) { - return LibDiamond.diamondStorage().supportedInterfaces[_interfaceId]; - } -} diff --git a/solidity/contracts/transfer/diamond/facets/OwnershipFacet.sol b/solidity/contracts/transfer/diamond/facets/OwnershipFacet.sol deleted file mode 100644 index da58073e..00000000 --- a/solidity/contracts/transfer/diamond/facets/OwnershipFacet.sol +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {LibDiamond} from "../libraries/LibDiamond.sol"; -import {IERC173} from "../interfaces/IERC173.sol"; - -/// @title OwnershipFacet — ERC-173 ownership backed by diamond storage. -contract OwnershipFacet is IERC173 { - /// @inheritdoc IERC173 - function transferOwnership(address _newOwner) external override { - LibDiamond.enforceIsContractOwner(); - LibDiamond.setContractOwner(_newOwner); - } - - /// @inheritdoc IERC173 - function owner() external view override returns (address owner_) { - owner_ = LibDiamond.contractOwner(); - } -} diff --git a/solidity/contracts/transfer/diamond/interfaces/IDiamondCut.sol b/solidity/contracts/transfer/diamond/interfaces/IDiamondCut.sol deleted file mode 100644 index 5099d2c5..00000000 --- a/solidity/contracts/transfer/diamond/interfaces/IDiamondCut.sol +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title EIP-2535 Diamond — cut interface. -/// @dev Add/replace/remove facet functions on a diamond. Reference: EIP-2535. -interface IDiamondCut { - enum FacetCutAction { - Add, - Replace, - Remove - } - - struct FacetCut { - address facetAddress; - FacetCutAction action; - bytes4[] functionSelectors; - } - - /// @notice Add/replace/remove any number of functions and optionally run a - /// delegatecall `_init` with `_calldata` in one transaction. - /// @param _diamondCut Facets and their function selectors to cut in. - /// @param _init Address of the contract to delegatecall (or `address(0)`). - /// @param _calldata Calldata for the `_init` delegatecall. - function diamondCut(FacetCut[] calldata _diamondCut, address _init, bytes calldata _calldata) external; - - event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata); -} diff --git a/solidity/contracts/transfer/diamond/interfaces/IDiamondLoupe.sol b/solidity/contracts/transfer/diamond/interfaces/IDiamondLoupe.sol deleted file mode 100644 index 0a41bed8..00000000 --- a/solidity/contracts/transfer/diamond/interfaces/IDiamondLoupe.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title EIP-2535 Diamond — loupe (introspection) interface. -/// @dev Lets tools enumerate the facets and their selectors on a diamond. -interface IDiamondLoupe { - struct Facet { - address facetAddress; - bytes4[] functionSelectors; - } - - /// @notice All facet addresses and their function selectors. - function facets() external view returns (Facet[] memory facets_); - - /// @notice Function selectors provided by a facet. - function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory); - - /// @notice All facet addresses used by the diamond. - function facetAddresses() external view returns (address[] memory); - - /// @notice The facet that implements the given selector (or `address(0)`). - function facetAddress(bytes4 _functionSelector) external view returns (address); -} diff --git a/solidity/contracts/transfer/diamond/interfaces/IERC173.sol b/solidity/contracts/transfer/diamond/interfaces/IERC173.sol deleted file mode 100644 index 698f989a..00000000 --- a/solidity/contracts/transfer/diamond/interfaces/IERC173.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @title ERC-173 — contract ownership standard (used by the diamond). -interface IERC173 { - event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); - - /// @notice The address of the owner. - function owner() external view returns (address owner_); - - /// @notice Transfer ownership. Set to `address(0)` to renounce. - function transferOwnership(address _newOwner) external; -} diff --git a/solidity/contracts/transfer/diamond/libraries/LibDiamond.sol b/solidity/contracts/transfer/diamond/libraries/LibDiamond.sol deleted file mode 100644 index d4434e59..00000000 --- a/solidity/contracts/transfer/diamond/libraries/LibDiamond.sol +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IDiamondCut} from "../interfaces/IDiamondCut.sol"; - -/// @title LibDiamond — EIP-2535 diamond storage, cut, and ownership. -/// @dev Reference implementation (diamond-3, Nick Mudge), adapted. Holds the -/// diamond's OWN state (facet routing table + owner) in a fixed namespaced -/// slot, separate from any facet's application storage. -library LibDiamond { - bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("diamond.standard.diamond.storage"); - - struct FacetAddressAndPosition { - address facetAddress; - uint96 functionSelectorPosition; // position in facetFunctionSelectors.functionSelectors - } - - struct FacetFunctionSelectors { - bytes4[] functionSelectors; - uint256 facetAddressPosition; // position of facetAddress in facetAddresses - } - - struct DiamondStorage { - mapping(bytes4 => FacetAddressAndPosition) selectorToFacetAndPosition; - mapping(address => FacetFunctionSelectors) facetFunctionSelectors; - address[] facetAddresses; - mapping(bytes4 => bool) supportedInterfaces; - address contractOwner; - } - - event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); - event DiamondCut(IDiamondCut.FacetCut[] _diamondCut, address _init, bytes _calldata); - - error NotContractOwner(address caller, address owner); - error NoSelectorsInFacet(); - error FacetAddressZeroWithSelectors(); - error FunctionAlreadyExists(bytes4 selector); - error FacetHasNoCode(address facet); - error CannotReplaceWithSameFunction(bytes4 selector); - error RemoveFacetMustBeZeroAddress(address facet); - error CannotRemoveNonexistentFunction(bytes4 selector); - error CannotRemoveImmutableFunction(bytes4 selector); - error IncorrectFacetCutAction(); - error InitAddressZeroWithCalldata(); - error InitCalldataEmptyWithAddress(); - error InitReverted(address init, bytes returndata); - - function diamondStorage() internal pure returns (DiamondStorage storage ds) { - bytes32 position = DIAMOND_STORAGE_POSITION; - assembly { - ds.slot := position - } - } - - function setContractOwner(address _newOwner) internal { - DiamondStorage storage ds = diamondStorage(); - address previousOwner = ds.contractOwner; - ds.contractOwner = _newOwner; - emit OwnershipTransferred(previousOwner, _newOwner); - } - - function contractOwner() internal view returns (address) { - return diamondStorage().contractOwner; - } - - function enforceIsContractOwner() internal view { - address owner_ = diamondStorage().contractOwner; - if (msg.sender != owner_) revert NotContractOwner(msg.sender, owner_); - } - - // ─── Diamond cut ──────────────────────────────────────────────────── - - function diamondCut(IDiamondCut.FacetCut[] memory _diamondCut, address _init, bytes memory _calldata) internal { - for (uint256 i; i < _diamondCut.length; i++) { - bytes4[] memory selectors = _diamondCut[i].functionSelectors; - address facet = _diamondCut[i].facetAddress; - if (selectors.length == 0) revert NoSelectorsInFacet(); - IDiamondCut.FacetCutAction action = _diamondCut[i].action; - if (action == IDiamondCut.FacetCutAction.Add) { - addFunctions(facet, selectors); - } else if (action == IDiamondCut.FacetCutAction.Replace) { - replaceFunctions(facet, selectors); - } else if (action == IDiamondCut.FacetCutAction.Remove) { - removeFunctions(facet, selectors); - } else { - revert IncorrectFacetCutAction(); - } - } - emit DiamondCut(_diamondCut, _init, _calldata); - initializeDiamondCut(_init, _calldata); - } - - function addFunctions(address _facet, bytes4[] memory _selectors) internal { - if (_facet == address(0)) revert FacetAddressZeroWithSelectors(); - DiamondStorage storage ds = diamondStorage(); - uint96 selectorPosition = uint96(ds.facetFunctionSelectors[_facet].functionSelectors.length); - if (selectorPosition == 0) addFacet(ds, _facet); - for (uint256 i; i < _selectors.length; i++) { - bytes4 selector = _selectors[i]; - if (ds.selectorToFacetAndPosition[selector].facetAddress != address(0)) { - revert FunctionAlreadyExists(selector); - } - addFunction(ds, selector, selectorPosition, _facet); - selectorPosition++; - } - } - - function replaceFunctions(address _facet, bytes4[] memory _selectors) internal { - if (_facet == address(0)) revert FacetAddressZeroWithSelectors(); - DiamondStorage storage ds = diamondStorage(); - uint96 selectorPosition = uint96(ds.facetFunctionSelectors[_facet].functionSelectors.length); - if (selectorPosition == 0) addFacet(ds, _facet); - for (uint256 i; i < _selectors.length; i++) { - bytes4 selector = _selectors[i]; - address old = ds.selectorToFacetAndPosition[selector].facetAddress; - if (old == _facet) revert CannotReplaceWithSameFunction(selector); - removeFunction(ds, old, selector); - addFunction(ds, selector, selectorPosition, _facet); - selectorPosition++; - } - } - - function removeFunctions(address _facet, bytes4[] memory _selectors) internal { - DiamondStorage storage ds = diamondStorage(); - // Remove facet address must be address(0). - if (_facet != address(0)) revert RemoveFacetMustBeZeroAddress(_facet); - for (uint256 i; i < _selectors.length; i++) { - bytes4 selector = _selectors[i]; - address old = ds.selectorToFacetAndPosition[selector].facetAddress; - removeFunction(ds, old, selector); - } - } - - function addFacet(DiamondStorage storage ds, address _facet) internal { - enforceHasContractCode(_facet); - ds.facetFunctionSelectors[_facet].facetAddressPosition = ds.facetAddresses.length; - ds.facetAddresses.push(_facet); - } - - function addFunction(DiamondStorage storage ds, bytes4 _selector, uint96 _position, address _facet) internal { - ds.selectorToFacetAndPosition[_selector].functionSelectorPosition = _position; - ds.facetFunctionSelectors[_facet].functionSelectors.push(_selector); - ds.selectorToFacetAndPosition[_selector].facetAddress = _facet; - } - - function removeFunction(DiamondStorage storage ds, address _facet, bytes4 _selector) internal { - if (_facet == address(0)) revert CannotRemoveNonexistentFunction(_selector); - // An immutable function is defined directly in the diamond (facet == diamond). - if (_facet == address(this)) revert CannotRemoveImmutableFunction(_selector); - - uint256 selectorPosition = ds.selectorToFacetAndPosition[_selector].functionSelectorPosition; - uint256 lastSelectorPosition = ds.facetFunctionSelectors[_facet].functionSelectors.length - 1; - if (selectorPosition != lastSelectorPosition) { - bytes4 lastSelector = ds.facetFunctionSelectors[_facet].functionSelectors[lastSelectorPosition]; - ds.facetFunctionSelectors[_facet].functionSelectors[selectorPosition] = lastSelector; - ds.selectorToFacetAndPosition[lastSelector].functionSelectorPosition = uint96(selectorPosition); - } - ds.facetFunctionSelectors[_facet].functionSelectors.pop(); - delete ds.selectorToFacetAndPosition[_selector]; - - if (lastSelectorPosition == 0) { - // Remove the facet address from facetAddresses. - uint256 lastFacetPosition = ds.facetAddresses.length - 1; - uint256 facetPosition = ds.facetFunctionSelectors[_facet].facetAddressPosition; - if (facetPosition != lastFacetPosition) { - address lastFacet = ds.facetAddresses[lastFacetPosition]; - ds.facetAddresses[facetPosition] = lastFacet; - ds.facetFunctionSelectors[lastFacet].facetAddressPosition = facetPosition; - } - ds.facetAddresses.pop(); - delete ds.facetFunctionSelectors[_facet].facetAddressPosition; - } - } - - function initializeDiamondCut(address _init, bytes memory _calldata) internal { - if (_init == address(0)) { - if (_calldata.length != 0) revert InitAddressZeroWithCalldata(); - return; - } - if (_calldata.length == 0) revert InitCalldataEmptyWithAddress(); - enforceHasContractCode(_init); - (bool success, bytes memory returndata) = _init.delegatecall(_calldata); - if (!success) { - if (returndata.length > 0) { - assembly { - revert(add(32, returndata), mload(returndata)) - } - } - revert InitReverted(_init, returndata); - } - } - - function enforceHasContractCode(address _contract) internal view { - if (_contract.code.length == 0) revert FacetHasNoCode(_contract); - } -} diff --git a/solidity/contracts/transfer/script/BankDiamondDeployer.sol b/solidity/contracts/transfer/script/BankDiamondDeployer.sol deleted file mode 100644 index 7defbe5a..00000000 --- a/solidity/contracts/transfer/script/BankDiamondDeployer.sol +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {CommonBase} from "forge-std/Base.sol"; - -import {Diamond} from "../diamond/Diamond.sol"; -import {DiamondCutFacet} from "../diamond/facets/DiamondCutFacet.sol"; -import {DiamondLoupeFacet} from "../diamond/facets/DiamondLoupeFacet.sol"; -import {OwnershipFacet} from "../diamond/facets/OwnershipFacet.sol"; -import {IDiamondCut} from "../diamond/interfaces/IDiamondCut.sol"; - -import {AdminFacet} from "../bank/facets/AdminFacet.sol"; -import {VaultFacet} from "../bank/facets/VaultFacet.sol"; -import {TransferFacet} from "../bank/facets/TransferFacet.sol"; -import {BankInit} from "../bank/BankInit.sol"; - -/// @title BankDiamondDeployer — assemble a fresh Bank diamond (facets + cut + init). -/// @dev Shared by the deploy script and the test suite so both wire the SAME facet -/// set. Facet selectors are read from the compiled `methodIdentifiers` (keys -/// are canonical signatures → `bytes4(keccak256(sig))`), so adding a function -/// to a facet needs no manual selector list here. -abstract contract BankDiamondDeployer is CommonBase { - /// @notice Deploy every facet, cut them into a new diamond, and run `BankInit`. - /// @param owner Diamond owner (ERC-173). - /// @param notary The shared Notary contract (verifies notary attestations). - /// @param backend Backend signer address. - /// @param registry Registry contract (identity resolution). - function deployBankDiamond(address owner, address notary, address backend, address registry) - internal - returns (address diamond) - { - DiamondCutFacet cutFacet = new DiamondCutFacet(); - diamond = address(new Diamond(owner, address(cutFacet))); - - DiamondLoupeFacet loupe = new DiamondLoupeFacet(); - OwnershipFacet ownership = new OwnershipFacet(); - AdminFacet admin = new AdminFacet(); - VaultFacet vault = new VaultFacet(); - TransferFacet transferFacet = new TransferFacet(); - BankInit initializer = new BankInit(); - - IDiamondCut.FacetCut[] memory cut = new IDiamondCut.FacetCut[](5); - cut[0] = _facetCut(address(loupe), "DiamondLoupeFacet"); - cut[1] = _facetCut(address(ownership), "OwnershipFacet"); - cut[2] = _facetCut(address(admin), "AdminFacet"); - cut[3] = _facetCut(address(vault), "VaultFacet"); - cut[4] = _facetCut(address(transferFacet), "TransferFacet"); - - IDiamondCut(diamond) - .diamondCut(cut, address(initializer), abi.encodeCall(BankInit.init, (notary, backend, registry))); - } - - function _facetCut(address facet, string memory name) private view returns (IDiamondCut.FacetCut memory) { - return IDiamondCut.FacetCut({ - facetAddress: facet, action: IDiamondCut.FacetCutAction.Add, functionSelectors: _selectors(name) - }); - } - - /// @dev All external selectors of a facet, from its artifact's methodIdentifiers. - function _selectors(string memory name) private view returns (bytes4[] memory sels) { - string memory json = vm.readFile(string.concat("out/", name, ".sol/", name, ".json")); - string[] memory sigs = vm.parseJsonKeys(json, ".methodIdentifiers"); - sels = new bytes4[](sigs.length); - for (uint256 i = 0; i < sigs.length; i++) { - sels[i] = bytes4(keccak256(bytes(sigs[i]))); - } - } -} diff --git a/solidity/contracts/transfer/script/DeployBankDiamond.s.sol b/solidity/contracts/transfer/script/DeployBankDiamond.s.sol deleted file mode 100644 index 41136f90..00000000 --- a/solidity/contracts/transfer/script/DeployBankDiamond.s.sol +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Script, console} from "forge-std/Script.sol"; -import {BankDiamondDeployer} from "./BankDiamondDeployer.sol"; - -/// @notice Deploy a fresh Bank diamond against an EXISTING Notary contract + -/// Registry (the migration seeds it afterwards). -/// -/// Usage: -/// forge script contracts/script/DeployBankDiamond.s.sol \ -/// --rpc-url $RPC_URL --broadcast --private-key $PRIVATE_KEY -/// -/// Env: -/// NOTARY_CONTRACT_ADDRESS — the shared Notary contract (Bank verifies notary attestations through it). -/// BACKEND_ADDRESS — backend signer address. -/// REGISTRY_CONTRACT_ADDRESS — Registry contract (identity resolution). -contract DeployBankDiamond is Script, BankDiamondDeployer { - function run() external { - string memory keyHex = vm.envOr("DEPLOYER_KEY", vm.envOr("PRIVATE_KEY", string(""))); - require(bytes(keyHex).length > 0, "set DEPLOYER_KEY or PRIVATE_KEY"); - if (bytes(keyHex).length == 64) keyHex = string.concat("0x", keyHex); - uint256 deployerKey = vm.parseUint(keyHex); - address deployer = vm.addr(deployerKey); - - address notaryContract = vm.envAddress("NOTARY_CONTRACT_ADDRESS"); - address backend = vm.envOr("BACKEND_ADDRESS", deployer); - address registry = vm.envAddress("REGISTRY_CONTRACT_ADDRESS"); - - vm.startBroadcast(deployerKey); - address diamond = deployBankDiamond(deployer, notaryContract, backend, registry); - vm.stopBroadcast(); - - console.log("=== Bank diamond deployed ==="); - console.log("Owner: ", deployer); - console.log("BANK_CONTRACT_ADDRESS= ", diamond); - } -} diff --git a/solidity/contracts/transfer/test/BalanceViews.t.sol b/solidity/contracts/transfer/test/BalanceViews.t.sol deleted file mode 100644 index 4ee93dd6..00000000 --- a/solidity/contracts/transfer/test/BalanceViews.t.sol +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IntegrationTest} from "./Integration.t.sol"; -import {WebWallet} from "../../login/WebWallet.sol"; - -contract BalanceViewsTest is IntegrationTest { - // ── balanceOf(address, token) — bank balance only ────────────── - - function test_balanceOf_byAddress_registered() public { - WebWallet alice = _alice(); - _fund("x", "alice", 100e18); - - assertEq(bank.balanceOf(address(alice), address(token)), 100e18); - } - - function test_balanceOf_byAddress_withUnmigrated() public { - // Fund before registration → goes to unregisteredBalances - _fund("x", "alice", 50e18); - // Now register - WebWallet alice = _alice(); - // Fund again after registration → goes to registeredBalances - _fund("x", "alice", 30e18); - - // balanceOf should see both: 30 registered + 50 unmigrated - assertEq(bank.balanceOf(address(alice), address(token)), 80e18); - } - - function test_balanceOf_byAddress_unregisteredWallet() public { - // Unknown wallet address → 0 - assertEq(bank.balanceOf(address(0xdead), address(token)), 0); - } - - // ── balanceOf(platform, handle, token) — bank balance only ───── - - function test_balanceOf_byHandle_registered() public { - _alice(); - _fund("x", "alice", 200e18); - assertEq(bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)), 200e18); - } - - function test_balanceOf_byHandle_unregistered() public { - _fund("x", "nobody", 75e18); - assertEq(bank.balanceOf("api.x.com", "nobody", _idFor("nobody"), address(token)), 75e18); - } - - function test_balanceOf_byHandle_noBalance() public { - assertEq(bank.balanceOf("api.x.com", "ghost", _idFor("ghost"), address(token)), 0); - } - - // ── balanceOfTotal(address, token) — bank + wallet ERC20 ─────── - - function test_balanceOfTotal_byAddress() public { - WebWallet alice = _alice(); - _fund("x", "alice", 100e18); - _fundWallet(alice, 500e18); - // Total = 100 (bank) + 500 (wallet ERC20) - assertEq(bank.balanceOfTotal(address(alice), address(token)), 600e18); - } - - function test_balanceOfTotal_byAddress_onlyBank() public { - WebWallet alice = _alice(); - _fund("x", "alice", 100e18); - assertEq(bank.balanceOfTotal(address(alice), address(token)), 100e18); - } - - function test_balanceOfTotal_byAddress_onlyWallet() public { - WebWallet alice = _alice(); - _fundWallet(alice, 500e18); - assertEq(bank.balanceOfTotal(address(alice), address(token)), 500e18); - } - - function test_balanceOfTotal_byAddress_zero() public { - WebWallet alice = _alice(); - assertEq(bank.balanceOfTotal(address(alice), address(token)), 0); - } - - // ── balanceOfTotal(platform, handle, token) — bank + wallet ──── - - function test_balanceOfTotal_byHandle_registered() public { - WebWallet alice = _alice(); - _fund("x", "alice", 100e18); - _fundWallet(alice, 250e18); - // Total = 100 (bank) + 250 (wallet ERC20) - assertEq(bank.balanceOfTotal("api.x.com", "alice", _idFor("alice"), address(token)), 350e18); - } - - function test_balanceOfTotal_byHandle_unregistered() public { - // Unregistered: only bank balance, no wallet - _fund("x", "nobody", 75e18); - assertEq(bank.balanceOfTotal("api.x.com", "nobody", _idFor("nobody"), address(token)), 75e18); - } - - function test_balanceOfTotal_byHandle_noBalance() public { - assertEq(bank.balanceOfTotal("api.x.com", "ghost", _idFor("ghost"), address(token)), 0); - } -} diff --git a/solidity/contracts/transfer/test/BankDiamond.t.sol b/solidity/contracts/transfer/test/BankDiamond.t.sol deleted file mode 100644 index 10284f4c..00000000 --- a/solidity/contracts/transfer/test/BankDiamond.t.sol +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; - -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {IDiamondCut} from "../diamond/interfaces/IDiamondCut.sol"; -import {IDiamondLoupe} from "../diamond/interfaces/IDiamondLoupe.sol"; -import {IERC173} from "../diamond/interfaces/IERC173.sol"; -import {AdminFacet} from "../bank/facets/AdminFacet.sol"; -import {BankInit} from "../bank/BankInit.sol"; -import {NotRegistered, AlreadyRegistered, AlreadyInitialized} from "../bank/BankErrors.sol"; - -/// @title BankDiamondTest — structural smoke test for the assembled Bank diamond. -/// @dev Verifies the deployer wires facets + runs BankInit, and that every facet -/// stays under the EIP-170 24576-byte limit. -contract BankDiamondTest is Test, BankDiamondDeployer { - uint256 constant EIP170_LIMIT = 24576; - - address diamond; - - function setUp() public { - // init only stores the pointers (no calls) → dummy non-zero addrs suffice. - diamond = deployBankDiamond(address(this), address(1), address(2), address(3)); - } - - /// One-shot: the deploy ran BankInit.init once; a second init via diamondCut - /// (re-delegatecall) must revert — else a later owner cut would silently - /// overwrite the trusted-party pointers and duplicate the honor templates. - function test_bankInit_reinit_reverts() public { - BankInit reinit = new BankInit(); - IDiamondCut.FacetCut[] memory noCut = new IDiamondCut.FacetCut[](0); - vm.expectRevert(AlreadyInitialized.selector); - IDiamondCut(diamond) - .diamondCut(noCut, address(reinit), abi.encodeCall(BankInit.init, (address(1), address(2), address(3)))); - } - - function test_ownerIsDeployer() public view { - assertEq(IERC173(diamond).owner(), address(this)); - } - - function test_loupeListsAllFacets() public view { - // 5 cut facets (loupe, ownership, admin, vault, transfer) + DiamondCutFacet. - assertEq(IDiamondLoupe(diamond).facetAddresses().length, 6); - } - - function test_supportsCoreInterfaces() public view { - assertTrue(IERC165(diamond).supportsInterface(type(IERC165).interfaceId)); - assertTrue(IERC165(diamond).supportsInterface(type(IDiamondCut).interfaceId)); - assertTrue(IERC165(diamond).supportsInterface(type(IDiamondLoupe).interfaceId)); - assertTrue(IERC165(diamond).supportsInterface(type(IERC173).interfaceId)); - } - - function test_initSeededTemplates() public view { - assertEq(AdminFacet(diamond).platformTemplateCount("api.x.com"), 4); - assertEq(AdminFacet(diamond).platformTemplateCount("api.github.com"), 2); - } - - function test_adminRoutingRegisterToken() public { - AdminFacet(diamond).registerToken("$DEV", address(0xBEEF)); - assertEq(AdminFacet(diamond).resolveToken("$DEV"), address(0xBEEF)); - } - - // ── Token-registry guards (review fixes) ────────────────────────────── - - /// A typo / never-registered name must revert — NOT silently wipe the native - /// (address(0)) registration. - function test_unregisterToken_typoDoesNotWipeNative() public { - AdminFacet(diamond).registerToken("TIA", address(0)); // native - AdminFacet(diamond).registerToken("USDC", address(0xBEEF)); - vm.expectRevert(NotRegistered.selector); - AdminFacet(diamond).unregisterToken("USDCC"); // typo - assertEq(AdminFacet(diamond).tokenName(address(0)), "TIA", "native was wiped"); - } - - /// The native asset is still unregisterable by its own name. - function test_unregisterNative_byName_ok() public { - AdminFacet(diamond).registerToken("TIA", address(0)); - AdminFacet(diamond).unregisterToken("TIA"); - assertEq(AdminFacet(diamond).tokenName(address(0)), ""); - } - - /// unregisterToken prunes the address from getRegisteredTokens (no stale entry). - function test_unregisterToken_prunesRegisteredList() public { - AdminFacet(diamond).registerToken("USDC", address(0xBEEF)); - AdminFacet(diamond).registerToken("DAI", address(0xCAFE)); - AdminFacet(diamond).unregisterToken("USDC"); - address[] memory toks = AdminFacet(diamond).getRegisteredTokens(); - for (uint256 i = 0; i < toks.length; i++) { - assertTrue(toks[i] != address(0xBEEF), "unregistered token still listed"); - } - } - - /// Registering the same address under a second name must revert (no dup entry). - function test_registerToken_duplicateAddressReverts() public { - AdminFacet(diamond).registerToken("USDC", address(0xBEEF)); - vm.expectRevert(AlreadyRegistered.selector); - AdminFacet(diamond).registerToken("USDC2", address(0xBEEF)); - } - - /// A name held by the NATIVE asset can't be hijacked for an ERC-20 (native - /// keeps address(0) in tokenByName, so the plain name check can't see it). - function test_registerToken_nativeNameHijack_reverts() public { - AdminFacet(diamond).registerToken("TIA", address(0)); // native under "TIA" - vm.expectRevert(AlreadyRegistered.selector); - AdminFacet(diamond).registerToken("TIA", address(0xBEEF)); // hijack attempt - // Native still owns the name; the registry stays consistent. - assertEq(AdminFacet(diamond).tokenName(address(0)), "TIA"); - assertEq(AdminFacet(diamond).resolveToken("TIA"), address(0)); - } - - /// Case-insensitive: webTransferV2 resolves the native asset case-insensitively, - /// so a case variant of the native name ("TIA" → "tia") can't be hijacked for an - /// ERC-20 either — else the registry desyncs and a by-name honor settles wrong. - function test_registerToken_nativeNameHijack_caseVariant_reverts() public { - AdminFacet(diamond).registerToken("TIA", address(0)); // native under "TIA" - vm.expectRevert(AlreadyRegistered.selector); - AdminFacet(diamond).registerToken("tia", address(0xBEEF)); // case-variant hijack - assertEq(AdminFacet(diamond).tokenName(address(0)), "TIA"); - } - - /// @notice Every deployed facet must fit the EIP-170 contract-size limit. - function test_facetsUnderEip170() public view { - address[] memory facets = IDiamondLoupe(diamond).facetAddresses(); - for (uint256 i = 0; i < facets.length; i++) { - assertLe(facets[i].code.length, EIP170_LIMIT, "facet exceeds EIP-170 24KB"); - } - } -} diff --git a/solidity/contracts/transfer/test/BankDiamondHonor.t.sol b/solidity/contracts/transfer/test/BankDiamondHonor.t.sol deleted file mode 100644 index 663f2de0..00000000 --- a/solidity/contracts/transfer/test/BankDiamondHonor.t.sol +++ /dev/null @@ -1,273 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; - -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {ResourceInfo, SenderInfo, BackendSig, NotaryTlsProof} from "../bank/BankTypes.sol"; -import {TransferFacet} from "../bank/facets/TransferFacet.sol"; -import {VaultFacet} from "../bank/facets/VaultFacet.sol"; -import {AdminFacet} from "../bank/facets/AdminFacet.sol"; -import {IRegistry} from "../../login/IRegistry.sol"; -import {MockERC20} from "../MockERC20.sol"; -import {InvalidMerkleProof, NoTemplateMatch} from "../bank/BankErrors.sol"; - -/// @dev Minimal Notary — accepts an EIP-191 signature from the one signer. -contract MockNotary { - address public immutable notary; - - constructor(address signer_) { - notary = signer_; - } - - function verify(bytes32 digest, bytes calldata proof) external view returns (bool) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - return ECDSA.recover(ethHash, proof) == notary; - } -} - -/// @dev Minimal Registry — id→wallet resolution set directly (no proof harness). -contract MockRegistry is IRegistry { - mapping(bytes32 => address) internal _byId; - mapping(address => string[]) internal _platforms; - mapping(address => string[]) internal _ids; - - function setId(string calldata platform, string calldata id, address wallet) external { - _byId[keccak256(abi.encode(platform, id))] = wallet; - _platforms[wallet].push(platform); - _ids[wallet].push(id); - } - - function resolveById(string calldata platform, string calldata id) external view returns (address) { - return _byId[keccak256(abi.encode(platform, id))]; - } - - function getUserIds(address wallet) external view returns (string[] memory, string[] memory) { - return (_platforms[wallet], _ids[wallet]); - } - - function getHandles(address wallet) external view returns (string[] memory, string[] memory) { - return (_platforms[wallet], _ids[wallet]); - } - - function resolve(string calldata, string calldata) external pure returns (address) { - return address(0); - } - - function handleHint(string calldata, string calldata) external pure returns (string memory) { - return ""; - } -} - -/// @title BankDiamondHonorTest — end-to-end honor flow on the assembled diamond. -/// @dev Validates the ported TransferFacet orchestration (LibProof + LibTemplate + -/// LibEscrow) with real notary/backend signatures + Merkle proofs, using mock -/// Registry/Notary so the identity layer needs no proof harness. Proof -/// math mirrors Integration.t.sol's `_buildBankProofWithId`. -contract BankDiamondHonorTest is Test, BankDiamondDeployer { - uint256 constant NOTARY_KEY = 0xA11CE; - uint256 constant BACKEND_KEY = 0xB0B; - address constant ALICE_WALLET = address(0xA1); - address constant BOB_WALLET = address(0xB2); - - address diamond; - MockRegistry registry; - MockERC20 token; - - function setUp() public { - MockNotary notaryReg = new MockNotary(vm.addr(NOTARY_KEY)); - registry = new MockRegistry(); - diamond = deployBankDiamond(address(this), address(notaryReg), vm.addr(BACKEND_KEY), address(registry)); - - token = new MockERC20("Test", "TST"); - AdminFacet(diamond).registerToken("TST", address(token)); - - // alice + bob are registered (id → wallet); fund alice via deposit. - registry.setId("api.github.com", _idFor("alice"), ALICE_WALLET); - registry.setId("api.github.com", _idFor("bob"), BOB_WALLET); - - token.mint(address(this), 1000e18); - token.approve(diamond, 1000e18); - VaultFacet(diamond).deposit("api.github.com", "alice", _idFor("alice"), address(token), 1000e18); - } - - function test_honorHappyPath_movesBalances() public { - ( - ResourceInfo memory resource, - SenderInfo memory sender, - bytes memory body, - NotaryTlsProof memory proof, - BackendSig memory bsig - ) = _honor( - "repo/x/comment/1", "/repos/o/r/issues/comments/1", "@dyaka-agent honor @bob with 400 TST", "alice", "bob" - ); - - uint256 aliceBefore = VaultFacet(diamond).balanceOf("api.github.com", "alice", _idFor("alice"), address(token)); - TransferFacet(diamond) - .webTransferV2(resource, sender, body, proof, bsig, "bob", _idFor("bob"), "TST", "400", 400e18, ""); - - assertEq( - VaultFacet(diamond).balanceOf("api.github.com", "alice", _idFor("alice"), address(token)), - aliceBefore - 400e18 - ); - assertEq(VaultFacet(diamond).balanceOf("api.github.com", "bob", _idFor("bob"), address(token)), 400e18); - } - - /// Forged author-id (victim id) with no proven author-id leaf → recv-leaf rejects. - function test_honorForgedAuthorId_reverts() public { - ( - ResourceInfo memory resource, - SenderInfo memory sender, - bytes memory body, - NotaryTlsProof memory proof, - BackendSig memory bsig - ) = _honor( - "repo/x/comment/2", "/repos/o/r/issues/comments/2", "@dyaka-agent honor @bob with 400 TST", "alice", "bob" - ); - - sender.revealedAuthorId = abi.encodePacked('"author_id":"', _idFor("bob"), '"'); // forged, not in proof - vm.expectRevert(InvalidMerkleProof.selector); - TransferFacet(diamond) - .webTransferV2(resource, sender, body, proof, bsig, "bob", _idFor("bob"), "TST", "400", 400e18, ""); - } - - /// Body honors bob but caller swaps receiverHandle → recipient bind rejects. - function test_honorRecipientSwap_reverts() public { - ( - ResourceInfo memory resource, - SenderInfo memory sender, - bytes memory body, - NotaryTlsProof memory proof, - BackendSig memory bsig - ) = _honor( - "repo/x/comment/3", "/repos/o/r/issues/comments/3", "@dyaka-agent honor @bob with 400 TST", "alice", "bob" - ); - - vm.expectRevert(NoTemplateMatch.selector); - TransferFacet(diamond) - .webTransferV2( - resource, sender, body, proof, bsig, "attacker", _idFor("attacker"), "TST", "400", 400e18, "" - ); - } - - // ── proof helpers (mirror Integration.t.sol) ───────────────────────── - - function _honor( - string memory resourceId, - string memory requestPath, - string memory bodyStr, - string memory senderHandle, - string memory receiverHandle - ) - internal - view - returns ( - ResourceInfo memory resource, - SenderInfo memory sender, - bytes memory body, - NotaryTlsProof memory proof, - BackendSig memory bsig - ) - { - body = bytes(bodyStr); - bytes memory authorBytes = bytes(senderHandle); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor(senderHandle), '"'); - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - - proof = _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - bsig = _buildBackendSig(uid, body, authorBytes, authorId, _idFor(receiverHandle), block.timestamp); - resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - sender = SenderInfo({author: senderHandle, revealedAuthor: authorBytes, revealedAuthorId: authorId}); - } - - function _idFor(string memory handle) internal pure returns (string memory) { - return string(abi.encodePacked("id-", handle)); - } - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - function _leaf(string memory prefix, bytes memory value) internal pure returns (bytes32) { - return keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - } - - function _hp(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - function _buildBankProofWithId( - string memory apiHost, - string memory requestPath, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - uint256 ts - ) internal view returns (NotaryTlsProof memory proof) { - bytes32 domainHash = keccak256(bytes(apiHost)); - bytes memory serverEphKey = hex"cafebabe"; - - bytes32 leafD = _leaf("domain:", bytes(apiHost)); - bytes32 leafE = _leaf("endpoint:", bytes(requestPath)); - bytes32 leafB = _leaf("recv:", revealedBody); - bytes32 leafA = _leaf("recv:", revealedAuthor); - bytes32 leafId = _leaf("recv:", revealedAuthorId); - - bytes32 hDE = _hp(leafD, leafE); - bytes32 hBA = _hp(leafB, leafA); - bytes32 root4 = _hp(hDE, hBA); - bytes32 transcriptRoot = _hp(root4, leafId); - - proof.domainHash = domainHash; - proof.clientRandom = bytes32(uint256(333)); - proof.serverRandom = bytes32(uint256(444)); - proof.serverEphemeralKey = serverEphKey; - proof.transcriptRoot = transcriptRoot; - proof.timestamp = ts; - - proof.bodyMerklePath = _path3(leafA, hDE, leafId); - proof.authorMerklePath = _path3(leafB, hDE, leafId); - proof.domainMerklePath = _path3(leafE, hBA, leafId); - proof.endpointMerklePath = _path3(leafD, hBA, leafId); - proof.authorIdMerklePath = new bytes32[](1); - proof.authorIdMerklePath[0] = root4; - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - proof.clientRandom, - proof.serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - proof.notarySig = _sign(NOTARY_KEY, notaryDigest); - } - - function _buildBackendSig( - bytes32 uid, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - string memory receiverUserId, - uint256 ts - ) internal pure returns (BackendSig memory) { - bytes32 digest = keccak256( - abi.encodePacked(uid, revealedBody, revealedAuthor, revealedAuthorId, keccak256(bytes(receiverUserId)), ts) - ); - return BackendSig({sig: _sign(BACKEND_KEY, digest), timestamp: ts}); - } - - function _path3(bytes32 a, bytes32 b, bytes32 c) internal pure returns (bytes32[] memory p) { - p = new bytes32[](3); - (p[0], p[1], p[2]) = (a, b, c); - } -} diff --git a/solidity/contracts/transfer/test/DollarToken.t.sol b/solidity/contracts/transfer/test/DollarToken.t.sol deleted file mode 100644 index 8817425b..00000000 --- a/solidity/contracts/transfer/test/DollarToken.t.sol +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {IBank} from "../bank/IBank.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; - -contract DollarTokenTest is Test, BankDiamondDeployer { - IBank bank; - - function setUp() public { - Notary notaryReg = deployNotary(address(this), address(1)); - bank = IBank(deployBankDiamond(address(this), address(notaryReg), address(2), address(3))); - } - - function test_registerDollarTIA() public { - address mockToken = address(0xBEEF); - - // Register $TIA as a token name — should not revert - bank.registerToken("$TIA", mockToken); - - // Verify it resolves - address resolved = bank.resolveToken("$TIA"); - assertEq(resolved, mockToken, "resolveToken should return the registered address"); - } - - function test_templateRenderWithDollarToken() public { - address mockToken = address(0xBEEF); - bank.registerToken("$TIA", mockToken); - - // Set a custom template on a fresh platform - bank.setPlatformTemplate("custom", "@dyaka-agent honor @{recipient} with {amount} {token}"); - - // Verify template parts work - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("custom", 0); - assertEq(prefix, "@dyaka-agent honor @"); - assertEq(afterRecipient, " with "); - assertEq(afterAmount, " "); - assertEq(suffix, ""); - } -} diff --git a/solidity/contracts/transfer/test/EdgeCases.t.sol b/solidity/contracts/transfer/test/EdgeCases.t.sol deleted file mode 100644 index 90e19195..00000000 --- a/solidity/contracts/transfer/test/EdgeCases.t.sol +++ /dev/null @@ -1,590 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -/// @notice Edge-case and boundary tests for the refactored contracts. - -import {Test} from "forge-std/Test.sol"; -import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -import {WebWallet} from "../../login/WebWallet.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {IBank} from "../bank/IBank.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import { - ZeroAmount, - InsufficientBalance, - TokenNotRegistered, - ZeroAddress, - AlreadyRegistered, - EmptyName, - NotRegistered, - NativeSentWithErc20, - InsufficientAllowance -} from "../bank/BankErrors.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; - -// ─── helpers ──────────────────────────────────────────────────────────────── - -contract MockToken is ERC20 { - constructor(string memory sym) ERC20(sym, sym) {} - - function mint(address to, uint256 amt) external { - _mint(to, amt); - } -} - -/// @dev Mock Registry for edge-case tests. Supports resolve and getHandles. -contract MockRegistryForEdge { - struct HandleEntry { - string platform; - string handle; - } - - mapping(string => mapping(string => address)) private _resolve; - mapping(string => mapping(string => string)) private _hint; - mapping(address => HandleEntry[]) private _handles; - - function setResolve(string calldata platform, string calldata handle, address wallet) external { - _resolve[platform][handle] = wallet; - _handles[wallet].push(HandleEntry(platform, handle)); - } - - function setHint(string calldata platform, string calldata handle, string calldata id) external { - _hint[platform][handle] = id; - } - - function resolve(string calldata platform, string calldata handle) external view returns (address) { - return _resolve[platform][handle]; - } - - // This mock keys identity by userId == handle, so resolveById mirrors resolve. - function resolveById(string calldata platform, string calldata id) external view returns (address) { - return _resolve[platform][id]; - } - - // Unbound by default; setHint simulates a handle pointing to a specific id. - function handleHint(string calldata platform, string calldata handle) external view returns (string memory) { - return _hint[platform][handle]; - } - - function getHandles(address wallet) external view returns (string[] memory platforms, string[] memory handles) { - HandleEntry[] storage entries = _handles[wallet]; - uint256 len = entries.length; - platforms = new string[](len); - handles = new string[](len); - for (uint256 i = 0; i < len; i++) { - platforms[i] = entries[i].platform; - handles[i] = entries[i].handle; - } - } - - // This mock keys deposits/transfers by userId == handle, so report the - // handle as the immutable userId — lets _migrate sweep the id-escrow slot. - function getUserIds(address wallet) external view returns (string[] memory platforms, string[] memory userIds) { - HandleEntry[] storage entries = _handles[wallet]; - uint256 len = entries.length; - platforms = new string[](len); - userIds = new string[](len); - for (uint256 i = 0; i < len; i++) { - platforms[i] = entries[i].platform; - userIds[i] = entries[i].handle; - } - } - - // This mock keys identity by userId == handle, so pass the handle as userId. - function registerSession(WebWallet wallet, string calldata platform, string calldata handle, address sessionAddr) - external - { - wallet.register_session(platform, handle, handle, sessionAddr); - } - - fallback() external payable {} - receive() external payable {} -} - -// ─── base setup ───────────────────────────────────────────────────────────── - -abstract contract EdgeBase is Test, BankDiamondDeployer { - address govOwner = makeAddr("govOwner"); - address depositor = makeAddr("depositor"); - MockRegistryForEdge mockRegistryContract; - address mockRegistry; - - uint256 notaryPk = 0xBE01; - uint256 backendPk = 0xBE02; - address notaryAddr; - address backendAddr; - - uint256 alicePk = 0xBE10; - address aliceSessionAddr; - - uint256 bobPk = 0xBE20; - address bobSessionAddr; - - function setUp() public virtual { - notaryAddr = vm.addr(notaryPk); - backendAddr = vm.addr(backendPk); - - aliceSessionAddr = vm.addr(alicePk); - bobSessionAddr = vm.addr(bobPk); - - mockRegistryContract = new MockRegistryForEdge(); - mockRegistry = address(mockRegistryContract); - - vm.warp(1_000_000); - } - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - /// @dev Deploy a WebWallet with a session registered via mock registry. - function _deployWalletWithSession(string memory platform, string memory handle, uint256 sessionPk_) - internal - returns (WebWallet wallet) - { - address sAddr = vm.addr(sessionPk_); - wallet = new WebWallet(); - wallet.initialize(mockRegistry, address(0), platform, handle); - mockRegistryContract.registerSession(wallet, platform, handle, sAddr); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 1. WebWallet edge cases -// ═══════════════════════════════════════════════════════════════════════════ - -contract WebWalletEdgeCases is EdgeBase { - WebWallet wallet; - - function setUp() public override { - super.setUp(); - wallet = _deployWalletWithSession("tiktok", "alice", alicePk); - } - - function test_registerSession_revertsIfNotRegistry() public { - vm.expectRevert(WebWallet.OnlyRegistry.selector); - wallet.register_session("tiktok", "alice", "alice", aliceSessionAddr); - } - - function test_execute_incrementsNonce() public { - assertEq(wallet.nonce(), 0); - - bytes memory data = hex""; - bytes32 digest = keccak256( - abi.encode(mockRegistry, uint256(0), keccak256(data), uint256(0), block.chainid, address(wallet)) - ); - - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(alicePk, digest); - - wallet.execute(mockRegistry, 0, data, sigs); - assertEq(wallet.nonce(), 1); - } - - function test_execute_samePairCountsOnce() public { - mockRegistryContract.registerSession(wallet, "tiktok", "alice", bobSessionAddr); - - bytes memory data = hex""; - bytes32 digest = keccak256( - abi.encode(mockRegistry, uint256(0), keccak256(data), uint256(0), block.chainid, address(wallet)) - ); - - bytes[] memory sigs = new bytes[](2); - sigs[0] = _sign(alicePk, digest); - sigs[1] = _sign(bobPk, digest); - - wallet.execute(mockRegistry, 0, data, sigs); - assertEq(wallet.nonce(), 1); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 2. Bank edge cases -// ═══════════════════════════════════════════════════════════════════════════ - -contract BankEdgeCases is EdgeBase { - // Mirrors AdminFacet's RegistrySet — for expectEmit (no registry() getter on the diamond). - event RegistrySet(address indexed oldRegistry, address indexed newRegistry); - - IBank bank; - MockToken usdc; - MockToken dai; - WebWallet aliceWallet; - - function setUp() public override { - super.setUp(); - - usdc = new MockToken("USDC"); - dai = new MockToken("DAI"); - - aliceWallet = _deployWalletWithSession("tiktok", "alice", alicePk); - mockRegistryContract.setResolve("tiktok", "alice", address(aliceWallet)); - - { - Notary notaryReg = deployNotary(address(this), notaryAddr); - bank = IBank(deployBankDiamond(address(this), address(notaryReg), backendAddr, mockRegistry)); - } - bank.registerToken("USDC", address(usdc)); - bank.registerToken("DAI", address(dai)); - - usdc.mint(depositor, 1_000e18); - dai.mint(depositor, 1_000e18); - vm.startPrank(depositor); - usdc.approve(address(bank), type(uint256).max); - dai.approve(address(bank), type(uint256).max); - vm.stopPrank(); - } - - function _deposit(address tkn, uint256 amt) internal { - vm.prank(depositor); - bank.deposit("tiktok", "alice", "alice", tkn, amt); - } - - function _executeWithdraw(address tkn, uint256 amt) internal { - bytes memory withdrawData = abi.encodeCall(IBank.withdraw, (address(aliceWallet), tkn, amt)); - - uint256 currentNonce = aliceWallet.nonce(); - bytes32 digest = keccak256( - abi.encode( - address(bank), uint256(0), keccak256(withdrawData), currentNonce, block.chainid, address(aliceWallet) - ) - ); - - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(alicePk, digest); - - aliceWallet.execute(address(bank), 0, withdrawData, sigs); - } - - // ── Deposit routing ──────────────────────────────────────────── - - function test_deposit_registeredGoesToRegisteredBalances() public { - _deposit(address(usdc), 100e18); - // Alice is registered, so should be in registeredBalances - assertEq(bank.registeredBalances(address(aliceWallet), address(usdc)), 100e18); - } - - function test_deposit_unregisteredGoesToUnregisteredBalances() public { - vm.prank(depositor); - bank.deposit("tiktok", "bob", "bob", address(usdc), 100e18); - // Bob is not registered — id-escrow is domain-separated by "id:v1" - bytes32 key = keccak256(abi.encode("id:v1", "tiktok", "bob")); - assertEq(bank.unregisteredBalances(key, address(usdc)), 100e18); - } - - // Recycled handle (hint id != provided id): escrow under the id, don't credit prior owner. - function test_deposit_recycledHandle_escrowsUnderIdNotPriorOwner() public { - mockRegistryContract.setHint("tiktok", "alice", "111"); - vm.prank(depositor); - bank.deposit("tiktok", "alice", "999", address(usdc), 100e18); - - assertEq(bank.registeredBalances(address(aliceWallet), address(usdc)), 0); - bytes32 key = keccak256(abi.encode("id:v1", "tiktok", "999")); - assertEq(bank.unregisteredBalances(key, address(usdc)), 100e18); - } - - // Matching hint (same id) still resolves the registered wallet. - function test_deposit_matchingHint_creditsRegisteredWallet() public { - mockRegistryContract.setHint("tiktok", "alice", "alice"); - vm.prank(depositor); - bank.deposit("tiktok", "alice", "alice", address(usdc), 100e18); - assertEq(bank.registeredBalances(address(aliceWallet), address(usdc)), 100e18); - } - - function test_deposit_revertsOnZeroAmount() public { - vm.prank(depositor); - vm.expectRevert(ZeroAmount.selector); - bank.deposit("tiktok", "alice", "alice", address(usdc), 0); - } - - // ── Withdraw ──────────────────────────────────────────────────── - - function test_withdraw_success() public { - _deposit(address(usdc), 100e18); - _executeWithdraw(address(usdc), 50e18); - assertEq(bank.registeredBalances(address(aliceWallet), address(usdc)), 50e18); - assertEq(usdc.balanceOf(address(aliceWallet)), 50e18); - } - - function test_withdraw_revertsIfNotRegisteredWallet() public { - _deposit(address(usdc), 100e18); - address anyone = makeAddr("anyone"); - vm.prank(anyone); - vm.expectRevert(InsufficientBalance.selector); - bank.withdraw(anyone, address(usdc), 100e18); - } - - function test_withdraw_revertsOnOverdraft() public { - _deposit(address(usdc), 50e18); - - bytes memory withdrawData = abi.encodeCall(IBank.withdraw, (address(aliceWallet), address(usdc), 100e18)); - uint256 currentNonce = aliceWallet.nonce(); - bytes32 digest = keccak256( - abi.encode( - address(bank), uint256(0), keccak256(withdrawData), currentNonce, block.chainid, address(aliceWallet) - ) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(alicePk, digest); - - vm.expectRevert(); // InsufficientBalance wrapped in ExecutionFailed - aliceWallet.execute(address(bank), 0, withdrawData, sigs); - } - - // ── Lazy migration ────────────────────────────────────────────── - - function test_withdraw_migratesUnregisteredFirst() public { - // Deposit as unregistered first (before alice is "known" for this key) - // We'll simulate: put tokens in unregisteredBalances for alice's handle - // by depositing for "tiktok"/"alice" when she's NOT resolved yet - - // Create a new bank where alice is not yet resolved - MockRegistryForEdge freshRegistry = new MockRegistryForEdge(); - IBank freshBank; - { - Notary notaryReg2 = deployNotary(address(this), notaryAddr); - freshBank = - IBank(deployBankDiamond(address(this), address(notaryReg2), backendAddr, address(freshRegistry))); - } - freshBank.registerToken("USDC", address(usdc)); - - // Deposit while alice is unregistered - vm.prank(depositor); - usdc.approve(address(freshBank), type(uint256).max); - vm.prank(depositor); - freshBank.deposit("tiktok", "alice", "alice", address(usdc), 100e18); - - bytes32 key = keccak256(abi.encode("id:v1", "tiktok", "alice")); - assertEq(freshBank.unregisteredBalances(key, address(usdc)), 100e18); - - // Now register alice - freshRegistry.setResolve("tiktok", "alice", address(aliceWallet)); - - // Withdraw should migrate first - bytes memory withdrawData = abi.encodeCall(IBank.withdraw, (address(aliceWallet), address(usdc), 60e18)); - uint256 currentNonce = aliceWallet.nonce(); - bytes32 digest = keccak256( - abi.encode( - address(freshBank), - uint256(0), - keccak256(withdrawData), - currentNonce, - block.chainid, - address(aliceWallet) - ) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(alicePk, digest); - aliceWallet.execute(address(freshBank), 0, withdrawData, sigs); - - // Unregistered should be zero, registered should have remainder - assertEq(freshBank.unregisteredBalances(key, address(usdc)), 0); - assertEq(freshBank.registeredBalances(address(aliceWallet), address(usdc)), 40e18); - } - - // ── balanceOf ─────────────────────────────────────────────────── - - function test_balanceOf_handleVersion_registered() public { - _deposit(address(usdc), 100e18); - assertEq(bank.balanceOf("tiktok", "alice", "alice", address(usdc)), 100e18); - } - - function test_balanceOf_handleVersion_unregistered() public { - vm.prank(depositor); - bank.deposit("tiktok", "bob", "bob", address(usdc), 100e18); - assertEq(bank.balanceOf("tiktok", "bob", "bob", address(usdc)), 100e18); - } - - function test_balanceOf_walletVersion() public { - _deposit(address(usdc), 100e18); - assertEq(bank.balanceOf(address(aliceWallet), address(usdc)), 100e18); - } - - function test_balanceOf_returnsZeroForUnknownIdentity() public view { - assertEq(bank.balanceOf("discord", "nobody", "nobody", address(usdc)), 0); - } - - // ── Multiple tokens ───────────────────────────────────────────── - - // ── deposit with unregistered token ──────────────────────────── - - function test_deposit_revertsWithUnregisteredToken() public { - MockToken rando = new MockToken("RANDO"); - rando.mint(depositor, 100e18); - vm.prank(depositor); - rando.approve(address(bank), 100e18); - vm.prank(depositor); - vm.expectRevert(TokenNotRegistered.selector); - bank.deposit("tiktok", "alice", "alice", address(rando), 100e18); - } - - // ── withdraw zero amount ──────────────────────────────────────── - - function test_withdraw_revertsOnZeroAmount() public { - _deposit(address(usdc), 100e18); - bytes memory withdrawData = abi.encodeCall(IBank.withdraw, (address(aliceWallet), address(usdc), 0)); - uint256 currentNonce = aliceWallet.nonce(); - bytes32 digest = keccak256( - abi.encode( - address(bank), uint256(0), keccak256(withdrawData), currentNonce, block.chainid, address(aliceWallet) - ) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(alicePk, digest); - vm.expectRevert(); // ZeroAmount wrapped in ExecutionFailed - aliceWallet.execute(address(bank), 0, withdrawData, sigs); - } - - // ── setRegistry ───────────────────────────────────────────────── - - function test_setRegistry_onlyOwner() public { - address newRegistry = makeAddr("newReg"); - // Non-owner fails - vm.prank(depositor); - vm.expectRevert(); - bank.setRegistry(newRegistry); - } - - function test_setRegistry_updatesRegistry() public { - address newRegistry = makeAddr("newReg"); - vm.expectEmit(true, true, false, true, address(bank)); - emit RegistrySet(mockRegistry, newRegistry); - bank.setRegistry(newRegistry); - assertEq(bank.registry(), newRegistry); - } - - function test_setRegistry_revertsZeroAddress() public { - vm.expectRevert(ZeroAddress.selector); - bank.setRegistry(address(0)); - } - - // ── registerToken / unregisterToken ────────────────────────────── - - function test_registerToken_revertsIfNotOwner() public { - vm.prank(depositor); - vm.expectRevert(); - bank.registerToken("X", address(1)); - } - - function test_registerToken_revertsIfAlreadyRegistered() public { - vm.expectRevert(AlreadyRegistered.selector); - bank.registerToken("USDC", address(usdc)); - } - - function test_registerToken_nativeAssetAllowed() public { - // address(0) is now allowed as native asset. - bank.registerToken("ETH", address(0)); - assertEq(bank.tokenName(address(0)), "ETH"); - } - - function test_registerToken_revertsEmptyName() public { - vm.expectRevert(EmptyName.selector); - bank.registerToken("", address(1)); - } - - function test_unregisterToken_success() public { - bank.unregisterToken("USDC"); - assertEq(bank.resolveToken("USDC"), address(0)); - assertEq(bank.tokenName(address(usdc)), ""); - } - - function test_unregisterToken_revertsIfNotRegistered() public { - vm.expectRevert(NotRegistered.selector); - bank.unregisterToken("NONEXISTENT"); - } - - function test_unregisterToken_revertsIfNotOwner() public { - vm.prank(depositor); - vm.expectRevert(); - bank.unregisterToken("USDC"); - } - - function test_deposit_revertsAfterUnregister() public { - bank.unregisterToken("USDC"); - vm.prank(depositor); - vm.expectRevert(TokenNotRegistered.selector); - bank.deposit("tiktok", "alice", "alice", address(usdc), 100e18); - } - - // ── resolveToken ──────────────────────────────────────────────── - - function test_resolveToken_returnsCorrectAddress() public view { - assertEq(bank.resolveToken("USDC"), address(usdc)); - } - - function test_resolveToken_returnsZeroForUnknown() public view { - assertEq(bank.resolveToken("NOPE"), address(0)); - } - - // ── balanceOf with multiple linked handles ────────────────────── - - function test_balanceOf_aggregatesAcrossHandles() public { - // Setup bob wallet with second handle - WebWallet bobWallet = _deployWalletWithSession("tiktok", "bob", bobPk); - mockRegistryContract.setResolve("tiktok", "bob", address(bobWallet)); - - // Add a second handle for bob - mockRegistryContract.setResolve("github", "bob_gh", address(bobWallet)); - mockRegistryContract.registerSession(bobWallet, "github", "bob_gh", makeAddr("bobGhSession")); - - // Deposit to both handles - vm.prank(depositor); - bank.deposit("tiktok", "bob", "bob", address(usdc), 100e18); - - // Deposit to unregistered second handle directly - // Since mockRegistry already maps github/bob_gh -> bobWallet, deposit goes to registered - vm.prank(depositor); - bank.deposit("github", "bob_gh", "bob_gh", address(usdc), 200e18); - - // Total should aggregate - assertEq(bank.balanceOf(address(bobWallet), address(usdc)), 300e18); - } - - // ── Multiple tokens ───────────────────────────────────────────── - - function test_multipleTokens_independentBalances() public { - vm.prank(depositor); - bank.deposit("tiktok", "alice", "alice", address(usdc), 300e18); - vm.prank(depositor); - bank.deposit("tiktok", "alice", "alice", address(dai), 500e18); - - assertEq(bank.registeredBalances(address(aliceWallet), address(usdc)), 300e18); - assertEq(bank.registeredBalances(address(aliceWallet), address(dai)), 500e18); - - _executeWithdraw(address(usdc), 100e18); - - assertEq(bank.registeredBalances(address(aliceWallet), address(usdc)), 200e18); - assertEq(bank.registeredBalances(address(aliceWallet), address(dai)), 500e18); - } - - // ═══════════════════════════════════════════════════════════════ - // MUTATION-GUARD NEGATIVE TESTS - // ═══════════════════════════════════════════════════════════════ - - // 14. Depositing an ERC20 while also sending native (msg.value > 0) → - // NativeSentWithErc20. depositor is approved, so removing the guard would - // succeed (pull the ERC20 anyway), killing the mutant. - function test_NativeSentWithErc20_reverts() public { - vm.deal(depositor, 1 ether); - vm.prank(depositor); - vm.expectRevert(NativeSentWithErc20.selector); - bank.deposit{value: 1}("tiktok", "alice", "alice", address(usdc), 100e18); - } - - // 15. Depositing an ERC20 without approving the diamond → the allowance guard - // fires with the custom InsufficientAllowance (removing it yields the OZ - // ERC20 transfer error). - function test_InsufficientAllowance_deposit_reverts() public { - address poor = makeAddr("poor"); - usdc.mint(poor, 100e18); // holds tokens but grants no allowance - // Parametrized error → match on the selector prefix (removing the guard - // yields the OZ ERC20 allowance error, a different selector). - vm.prank(poor); - vm.expectPartialRevert(InsufficientAllowance.selector); - bank.deposit("tiktok", "alice", "alice", address(usdc), 100e18); - } -} diff --git a/solidity/contracts/transfer/test/EscrowById.t.sol b/solidity/contracts/transfer/test/EscrowById.t.sol deleted file mode 100644 index f9e433d8..00000000 --- a/solidity/contracts/transfer/test/EscrowById.t.sol +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IntegrationTest} from "./Integration.t.sol"; -import {Registry} from "../../login/Registry.sol"; -import {UserIdRequired} from "../bank/BankErrors.sol"; -import {WebWallet} from "../../login/WebWallet.sol"; - -/// @notice Regression suite for the id-as-truth escrow invariant. -/// -/// Core invariant: unregistered escrow is keyed by the IMMUTABLE platform -/// user-id, domain-separated by the "id:v1" tag. The legacy handle-sweep -/// keyspace (keccak256(abi.encode(platform, handle))) must never collide with -/// it, so a wallet whose handle equals a victim's numeric id string cannot -/// sweep the victim's id-escrow. -contract EscrowByIdTest is IntegrationTest { - string constant XHOST = "api.x.com"; - - /// id-escrow slot key, mirroring Bank._escrowKey - /// (keccak256(abi.encode(ID_ESCROW_TAG, platform, userId))). - function _idKey(string memory platform, string memory userId) internal pure returns (bytes32) { - return keccak256(abi.encode("id:v1", platform, userId)); - } - - /// Legacy handle-keyed slot (pre-id deposits / handle sweep target). - function _handleKey(string memory platform, string memory handle) internal pure returns (bytes32) { - return keccak256(abi.encode(platform, handle)); - } - - /// Deposit `amount` into the id-escrow for (XHOST, userId). Handle is only - /// used for the (unregistered) resolve and never enters the escrow key. - function _fundId(string memory handle, string memory userId, uint256 amount) internal { - token.mint(address(this), amount); - token.approve(address(bank), amount); - bank.deposit(XHOST, handle, userId, address(token), amount); - } - - /// Register a fresh wallet for (handle, userId) with a distinct session key. - function _registerWallet(string memory handle, string memory userId, uint256 pk) internal returns (WebWallet) { - address sessionAddr = vm.addr(pk); - Registry.FullTlsProof memory proof = _buildRegistryProofWithId( - XHOST, handle, userId, "api.test.com/endpoint", '"username":"', sessionAddr, address(0), block.timestamp - ); - registry.register_session(proof, XHOST, handle, userId, "api.test.com/endpoint"); - return WebWallet(payable(registry.resolve(XHOST, handle))); - } - - /// Trigger lazy migration on `wallet` by depositing a tiny amount to its - /// (registered) identity. Returns nothing; side-effect is the sweep. - function _triggerMigrate(string memory handle, string memory userId) internal { - _fundId(handle, userId, 1); - } - - // ── Core invariant: escrow follows the id, not the handle ────────── - - /// Funds deposited to an unregistered id are swept to the wallet that later - /// registers that SAME id, even if the wallet's current handle differs. - function test_idEscrow_migratesByIdAcrossRename() public { - // Deposit to unregistered id "777" (handle at deposit time: "oldname"). - _fundId("oldname", "777", 100e18); - assertEq(bank.unregisteredBalances(_idKey(XHOST, "777"), address(token)), 100e18); - - // The same id "777" later registers under a DIFFERENT handle "newname". - WebWallet w = _registerWallet("newname", "777", 0xE001); - - // Migrate: id-escrow for "777" sweeps into the registered wallet. - _triggerMigrate("newname", "777"); - - assertEq(bank.unregisteredBalances(_idKey(XHOST, "777"), address(token)), 0, "id-escrow not swept"); - // 100e18 deposit + 1 wei migrate trigger. - assertEq(bank.registeredBalances(address(w), address(token)), 100e18 + 1, "registered balance wrong"); - } - - // ── C1 regression: id keyspace must not collide with handle sweep ── - - /// Fund id-escrow for (x, userId="12345"). Then register a DIFFERENT wallet - /// whose HANDLE is literally "12345" (with a different userId). Migrating it - /// must NOT sweep the victim's id-escrow. - function test_idEscrow_notStolenByHandleEqualToVictimId() public { - // Victim: 1000 tokens escrowed under the immutable id "12345". - _fundId("victim_handle", "12345", 1000e18); - bytes32 victimKey = _idKey(XHOST, "12345"); - assertEq(bank.unregisteredBalances(victimKey, address(token)), 1000e18); - - // Attacker registers a wallet whose HANDLE is the numeric string "12345" - // but whose immutable id is something else entirely. - WebWallet attacker = _registerWallet("12345", "id_attacker", 0xA77ACC); - - // Sanity: the attacker's handle-sweep key and the victim's id-key differ. - assertTrue(_handleKey(XHOST, "12345") != victimKey, "domain separation broken"); - - // Trigger migration on the attacker's wallet (handle == "12345"). - _triggerMigrate("12345", "id_attacker"); - - // The victim's id-escrow is UNTOUCHED — the attacker only swept its own - // (empty) handle slot and its own id slot. - assertEq(bank.unregisteredBalances(victimKey, address(token)), 1000e18, "victim id-escrow was swept (C1)"); - // Attacker only received its own 1-wei migrate trigger, nothing of the victim's. - assertEq(bank.registeredBalances(address(attacker), address(token)), 1, "attacker stole funds"); - } - - /// The victim can still claim their escrow by registering the real id. - function test_victimCanClaimAfterCollisionAttempt() public { - _fundId("victim_handle", "12345", 1000e18); - - // Attacker tries the handle-collision sweep first (no-op for the victim). - _registerWallet("12345", "id_attacker", 0xA77ACC); - _triggerMigrate("12345", "id_attacker"); - assertEq(bank.unregisteredBalances(_idKey(XHOST, "12345"), address(token)), 1000e18); - - // The legitimate owner of id "12345" registers (handle "real_owner"). - WebWallet victim = _registerWallet("real_owner", "12345", 0xC1A12); - _triggerMigrate("real_owner", "12345"); - - assertEq(bank.unregisteredBalances(_idKey(XHOST, "12345"), address(token)), 0, "victim could not claim"); - assertEq(bank.registeredBalances(address(victim), address(token)), 1000e18 + 1); - } - - // ── Deposit/read consistency through the id key ──────────────────── - - /// balanceOf(platform, handle, userId) reads the same id-escrow slot a - /// deposit with that userId writes — regardless of the handle string. - function test_balanceOf_readsIdEscrowConsistently() public { - _fundId("some_handle", "999", 42e18); - // Same id, different handle still reads the same slot. - assertEq(bank.balanceOf(XHOST, "some_handle", "999", address(token)), 42e18); - assertEq(bank.balanceOf(XHOST, "another_handle", "999", address(token)), 42e18); - // Different id reads an empty slot. - assertEq(bank.balanceOf(XHOST, "some_handle", "111", address(token)), 0); - } - - /// Empty userId is a write-only revert; reads of an unknown id return 0, - /// not a revert (no escrow slot to key). - function test_balanceOf_emptyId_returnsZero() public view { - assertEq(bank.balanceOf(XHOST, "ghost", "", address(token)), 0); - assertEq(bank.balanceOfTotal(XHOST, "ghost", "", address(token)), 0); - } - - /// Empty userId for an unregistered receiver reverts UserIdRequired. - function test_deposit_emptyIdForUnregistered_reverts() public { - token.mint(address(this), 1e18); - token.approve(address(bank), 1e18); - vm.expectRevert(UserIdRequired.selector); - bank.deposit(XHOST, "ghost", "", address(token), 1e18); - } -} diff --git a/solidity/contracts/transfer/test/ImplicitTemplate.t.sol b/solidity/contracts/transfer/test/ImplicitTemplate.t.sol deleted file mode 100644 index de513c8b..00000000 --- a/solidity/contracts/transfer/test/ImplicitTemplate.t.sol +++ /dev/null @@ -1,168 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {LibTemplate} from "../bank/libraries/LibTemplate.sol"; -import {LibRecipient} from "../bank/libraries/LibRecipient.sol"; -import {ReplyTargetIdNotQuoted} from "../bank/BankErrors.sol"; - -/// @dev Unit harness over the diamond's template/recipient libraries (same code -/// the diamond runs, its own diamond-storage slot). No legacy Bank. -contract BankTemplateHarness { - function addTemplate(string calldata platform, string calldata template) external { - LibTemplate.addTemplate(platform, template); - } - - /// @dev Reverts unless some template matches `body` AND extracts exactly - /// `amt`/`tok` (mirrors the on-chain webTransfer-by-name check). Returns - /// `recipientLess` — true when a recipient-less template matched (the - /// recipient is then bound off-body via the reply-target id leaf). - function checkByName( - bytes calldata body, - string calldata platform, - string calldata receiverHandle, - string calldata amt, - string calldata tok - ) external view returns (bool recipientLess) { - return LibTemplate.verifyTemplate(body, platform, receiverHandle, amt, tok); - } - - /// @dev Mirrors the `verifyProof` classification path: returns whether any - /// template matched and whether the match is recipient-less (derived from - /// the matched template SHAPE, not the extracted recipient value). - function matchAny(bytes calldata body, string calldata platform) - external - view - returns (bool matched, bool recipientLess) - { - (matched,,, recipientLess) = LibTemplate.matchAnyTemplate(body, keccak256(bytes(platform))); - } - - function extractReplyTarget(bytes calldata leaf, uint256 prefixLen) external pure returns (string memory) { - return LibRecipient.extractReplyTargetId(leaf, prefixLen); - } -} - -/// @notice Recipient-less honor templates: `@bot honor with {amount} {token}` -/// (recipient inferred off-chain from the reply/quote parent). Proves the -/// engine extracts amount/token without a `{recipient}` placeholder, flags -/// the match as recipient-less, the explicit form still works (and is not -/// flagged), and the two forms do not cross-match. -contract ImplicitTemplateTest is Test { - BankTemplateHarness h; - - function setUp() public { - h = new BankTemplateHarness(); - // Mirror initialize()'s X templates: explicit + recipient-less, "of" first. - h.addTemplate("api.x.com", "@dyaka_agent honor @{recipient} with {amount} of {token}"); - h.addTemplate("api.x.com", "@dyaka_agent honor @{recipient} with {amount} {token}"); - h.addTemplate("api.x.com", "@dyaka_agent honor with {amount} of {token}"); - h.addTemplate("api.x.com", "@dyaka_agent honor with {amount} {token}"); - } - - function test_implicit_withOf() public view { - // Recipient-less body: no receiverHandle in the body → flagged recipientLess. - assertTrue(h.checkByName(bytes("@dyaka_agent honor with 1.5 of $TIA"), "api.x.com", "", "1.5", "$TIA")); - } - - // ── reply-target id extraction is anchored to the field, not the last colon ── - // `"in_reply_to_user_id":` is 22 bytes. - uint256 constant PREFIX_LEN = 22; - - function test_extractReplyTarget_plain() public view { - assertEq(h.extractReplyTarget(bytes('"in_reply_to_user_id":"12345"'), PREFIX_LEN), "12345"); - } - - function test_extractReplyTarget_ignoresTrailingMentionId() public view { - // A longer revealed snippet whose LAST colon precedes an attacker id must - // still yield the reply target — not the trailing mention id. - bytes memory leaf = bytes('"in_reply_to_user_id":"victim","entities":{"mentions":[{"id":"attacker"'); - assertEq(h.extractReplyTarget(leaf, PREFIX_LEN), "victim"); - } - - function test_extractReplyTarget_rejectsUnquoted() public { - vm.expectRevert(ReplyTargetIdNotQuoted.selector); - h.extractReplyTarget(bytes('"in_reply_to_user_id":12345'), PREFIX_LEN); - } - - function test_implicit_withoutOf() public view { - assertTrue(h.checkByName(bytes("@dyaka_agent honor with 100 USDC"), "api.x.com", "", "100", "USDC")); - } - - function test_implicit_surroundingText() public view { - assertTrue( - h.checkByName(bytes("thanks! @dyaka_agent honor with 0.05 ETH keep it up"), "api.x.com", "", "0.05", "ETH") - ); - } - - function test_explicit_stillMatches_andIsNotRecipientLess() public view { - // Explicit body: recipient handle proven by the body must equal receiverHandle, - // and the match is NOT flagged recipient-less. - assertFalse(h.checkByName(bytes("@dyaka_agent honor @bob with 100 USDC"), "api.x.com", "bob", "100", "USDC")); - } - - function test_explicit_wrongHandle_reverts() public { - // Explicit body, but receiverHandle doesn't match the body's @bob → no match. - vm.expectRevert(); - h.checkByName(bytes("@dyaka_agent honor @bob with 100 USDC"), "api.x.com", "carol", "100", "USDC"); - } - - function test_wrongToken_reverts() public { - vm.expectRevert(); - h.checkByName(bytes("@dyaka_agent honor with 100 USDC"), "api.x.com", "", "100", "USDT"); - } - - // ── cross-matching isolation ─────────────────────────────────── - - // Only recipient-less templates registered -> an explicit-recipient body - // ("honor @bob with ...") must NOT match (prefix "honor with " absent). - function test_noCrossMatch_explicitBodyVsImplicitOnly() public { - BankTemplateHarness h2 = new BankTemplateHarness(); - h2.addTemplate("api.x.com", "@dyaka_agent honor with {amount} of {token}"); - h2.addTemplate("api.x.com", "@dyaka_agent honor with {amount} {token}"); - vm.expectRevert(); - h2.checkByName(bytes("@dyaka_agent honor @bob with 100 USDC"), "api.x.com", "bob", "100", "USDC"); - } - - // Only explicit templates registered -> a recipient-less body must NOT match - // (prefix "honor @" absent). - function test_noCrossMatch_implicitBodyVsExplicitOnly() public { - BankTemplateHarness h2 = new BankTemplateHarness(); - h2.addTemplate("api.x.com", "@dyaka_agent honor @{recipient} with {amount} of {token}"); - h2.addTemplate("api.x.com", "@dyaka_agent honor @{recipient} with {amount} {token}"); - vm.expectRevert(); - h2.checkByName(bytes("@dyaka_agent honor with 100 USDC"), "api.x.com", "", "100", "USDC"); - } - - // ── malformed explicit body (empty extracted recipient) ──────────── - // - // A body shaped like the EXPLICIT template ("honor @ …") but with an empty - // recipient must NOT be promoted to the recipient-less (reply-target-id-bound) - // path just because the extracted recipient string is empty. It is classified - // by the matched template SHAPE, which has a {recipient} placeholder. - - // by-name path: an explicit template extracting an empty recipient does not - // early-accept as recipient-less; it falls through to the handle check, which - // fails for the empty recipient vs the real intended handle → reverts (rejected). - function test_malformedExplicitBody_rejected_byName() public { - vm.expectRevert(); - h.checkByName(bytes("@dyaka_agent honor @ with 5 $TST"), "api.x.com", "bob", "5", "$TST"); - } - - // verifyProof classification path: the malformed explicit body matches but is - // NOT flagged recipient-less, so verifyProof would not require/accept the - // reply-target id leaf for it. - function test_malformedExplicitBody_notRecipientLess_matchAny() public view { - (bool matched, bool recipientLess) = h.matchAny(bytes("@dyaka_agent honor @ with 5 $TST"), "api.x.com"); - assertTrue(matched, "malformed explicit body still matches an explicit template"); - assertFalse(recipientLess, "malformed explicit body must NOT be classified recipient-less"); - } - - // Regression guard: a genuine recipient-less body (no {recipient} in the - // template shape) IS still classified recipient-less by the same path. - function test_genuineImplicitBody_isRecipientLess_matchAny() public view { - (bool matched, bool recipientLess) = h.matchAny(bytes("@dyaka_agent honor with 100 USDC"), "api.x.com"); - assertTrue(matched, "genuine recipient-less body matches"); - assertTrue(recipientLess, "genuine recipient-less body must be classified recipient-less"); - } -} diff --git a/solidity/contracts/transfer/test/Integration.t.sol b/solidity/contracts/transfer/test/Integration.t.sol deleted file mode 100644 index 06841c83..00000000 --- a/solidity/contracts/transfer/test/Integration.t.sol +++ /dev/null @@ -1,2720 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../../login/Registry.sol"; -import {IBank} from "../bank/IBank.sol"; -import {ResourceInfo, SenderInfo, BackendSig, NotaryTlsProof} from "../bank/BankTypes.sol"; -import { - InvalidMerkleProof, - InvalidBackendSignature, - TransferAlreadyUsed, - SenderNotRegistered, - ZeroAmount, - TokenNotRegistered, - NoTemplateMatch, - AmountWeiMismatch, - ReplyTargetMismatch, - RecipientLessNeedsReplyTarget, - QuotedAuthorDifferentObject, - QuotedAuthorMismatch, - QuotedAuthorMissingQuotedId, - RefNotQuote, - QuotedAuthorLeafNotIncludes, - UnknownResourceType, - RequestPathMismatch, - AuthorNotInRevealedField, - TemplateNotFound, - InvalidNotarySignature, - DomainHashMismatch, - MerklePathTooLong, - ZeroToken, - SourceUrlNotHttps, - SourceUrlDomainMismatch, - ReplyLeafTooShort, - ReplyLeafNotInReplyTo, - QuotedRefTooShort, - QuotedRefNotObject, - QuotedRefNotFlatObject, - QuotedRefMissingId, - QuotedAuthorLeafTooShort, - QuotedAuthorMissingAuthorId, - EmptyQuotedValue -} from "../bank/BankErrors.sol"; -import {LibString} from "../bank/libraries/LibString.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; -import {WalletFactory} from "../../login/WalletFactory.sol"; -import {WebWallet} from "../../login/WebWallet.sol"; -import {MockERC20} from "../MockERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @dev V2 mock — just adds version() returning 2. -contract MockWebWalletV2 is WebWallet { - function version() external pure returns (uint256) { - return 2; - } -} - -contract IntegrationTest is Test, BankDiamondDeployer { - // ─── Known test keys ──────────────────────────────────────────── - uint256 constant NOTARY_KEY = uint256(keccak256("notary")); - uint256 constant BACKEND_KEY = uint256(keccak256("backend")); - - address NOTARY; - address BACKEND; - - // ─── Contracts ────────────────────────────────────────────────── - Registry registry; - IBank bank; - WalletFactory factory; - MockERC20 token; - - address owner; - - // ─── Named session keys ───────────────────────────────────────── - uint256 constant ALICE_PK = 0xA001; - uint256 constant ALICE_PK2 = 0xA002; - uint256 constant ALICE_PK3 = 0xA003; - uint256 constant BOB_PK = 0xB001; - uint256 constant CHARLIE_PK = 0xC001; - uint256 constant LINK_PK1 = 0xD001; - uint256 constant LINK_PK2 = 0xD002; - uint256 constant LINK_PK3 = 0xD003; - - // ─── Transfer counter for unique UIDs ─────────────────────────── - uint256 private _transferCounter; - - // ─── setUp ────────────────────────────────────────────────────── - - function setUp() public { - NOTARY = vm.addr(NOTARY_KEY); - BACKEND = vm.addr(BACKEND_KEY); - owner = address(this); - - WebWallet walletImpl = new WebWallet(); - Registry rImpl = new Registry(); - WalletFactory fImpl = new WalletFactory(); - - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), abi.encodeCall(WalletFactory.initialize, (owner, address(walletImpl), address(1))) - ) - ) - ); - - Notary notaryReg = deployNotary(owner, NOTARY); - - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall(Registry.initialize, (address(notaryReg), BACKEND, address(factory), owner)) - ) - ) - ); - - factory.setRegistry(address(registry)); - - // Bank is now an EIP-2535 diamond (BankInit seeds the same templates/prefixes). - bank = IBank(deployBankDiamond(owner, address(notaryReg), BACKEND, address(registry))); - - token = new MockERC20("Test", "TST"); - bank.registerToken("TST", address(token)); - - // Register platforms used in tests (X-style quoted id binding). - registry.setPlatform("api.x.com", "api.test.com/endpoint", '"username":"', '"id":"', '"'); - registry.setPlatform("api.github.com", "api.test.com/endpoint", '"username":"', '"id":"', '"'); - - vm.warp(1_000_000); - } - - /// Deterministic immutable id for a handle (tests key escrow by id). - function _idFor(string memory handle) internal pure returns (string memory) { - return string(abi.encodePacked("id_", handle)); - } - - // ═══════════════════════════════════════════════════════════════ - // FLUENT HELPERS - // ═══════════════════════════════════════════════════════════════ - - // ─── Wallet creation ──────────────────────────────────────────── - - function _alice() internal returns (WebWallet) { - return _walletFor("x", "alice", ALICE_PK); - } - - function _bob() internal returns (WebWallet) { - return _walletFor("x", "bob", BOB_PK); - } - - function _charlie() internal returns (WebWallet) { - return _walletFor("x", "charlie", CHARLIE_PK); - } - - /// Map platform shortname to API domain for Registry registration. - /// In production, OAuth registers under the TLS domain (e.g. "api.x.com"). - /// Bank.webTransfer uses the apiHost to resolve the sender in the Registry. - function _domain(string memory platform) internal pure returns (string memory) { - if (keccak256(bytes(platform)) == keccak256("x")) return "api.x.com"; - if (keccak256(bytes(platform)) == keccak256("github")) return "api.github.com"; - return platform; - } - - function _walletFor(string memory platform, string memory handle, uint256 pk) internal returns (WebWallet) { - string memory domain = _domain(platform); - string memory userId = _idFor(handle); - address sessionAddr = vm.addr(pk); - Registry.FullTlsProof memory proof = _buildRegistryProofWithId( - domain, handle, userId, "api.test.com/endpoint", '"username":"', sessionAddr, address(0), block.timestamp - ); - registry.register_session(proof, domain, handle, userId, "api.test.com/endpoint"); - return WebWallet(payable(registry.resolve(domain, handle))); - } - - // ─── Funding ──────────────────────────────────────────────────── - - function _fund(string memory platform, string memory handle, uint256 amount) internal { - token.mint(address(this), amount); - token.approve(address(bank), amount); - bank.deposit(_domain(platform), handle, _idFor(handle), address(token), amount); - } - - function _fundWallet(WebWallet wallet, uint256 amount) internal { - token.mint(address(wallet), amount); - } - - // ─── Transfers ────────────────────────────────────────────────── - - function _transfer( - string memory senderPlatform, - string memory senderHandle, - string memory receiverPlatform, - string memory receiverHandle, - uint256 amount - ) internal returns (bytes32 uid) { - // Convert wei to human-readable: amount / 1e18 as integer string - string memory amountStr = vm.toString(amount / 1e18); - return _transferWithAmountStr(senderPlatform, senderHandle, receiverPlatform, receiverHandle, amountStr, amount); - } - - function _transferWithAmountStr( - string memory senderPlatform, - string memory senderHandle, - string memory receiverPlatform, - string memory receiverHandle, - string memory amountStr, - uint256 amountWei - ) internal returns (bytes32 uid) { - _transferCounter++; - string memory resourceId = string(abi.encodePacked("repo/", vm.toString(_transferCounter), "/comment/1")); - string memory requestPath = - string(abi.encodePacked("/repos/owner/repo/issues/comments/", vm.toString(_transferCounter))); - - uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - - // Selective disclosure: body = just the comment text, author = separate field. - // id-only: the sender's author-id is always proven (id-path). - bytes memory body = abi.encodePacked("@dyaka-agent honor @", receiverHandle, " with ", amountStr, " TST"); - bytes memory authorBytes = bytes(senderHandle); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor(senderHandle), '"'); - - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor(receiverHandle), block.timestamp); - - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - bank.webTransferV2( - resource, - SenderInfo({author: senderHandle, revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - receiverHandle, - _idFor(receiverHandle), - "TST", - amountStr, - amountWei, - "" - ); - } - - /// Security regression: a forged revealedAuthorId (victim id, not Merkle-proven) - /// is rejected by _verifyRecvLeaf — no unsigned id can redirect resolveById. - function test_webTransfer_forgedAuthorId_reverts() public { - _walletFor("github", "alice", ALICE_PK); // attacker (has a valid proof of own comment) - _walletFor("github", "bob", BOB_PK); // victim - _fund("github", "bob", 1000e18); - - string memory resourceId = "repo/evil/comment/1"; - string memory requestPath = "/repos/owner/repo/issues/comments/777"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @attacker with 400 TST"); - bytes memory authorBytes = bytes("alice"); - - NotaryTlsProof memory notaryProof = - _buildBankProof("api.github.com", requestPath, body, authorBytes, block.timestamp); - BackendSig memory backendSig = _buildBackendSig(uid, body, authorBytes, _idFor("attacker"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - // Forged author-id snippet pointing at the victim, with NO valid - // authorIdMerklePath (the proof above has none) → _verifyRecvLeaf rejects. - bytes memory forgedId = abi.encodePacked('"id":', _idFor("bob"), ","); - vm.expectRevert(InvalidMerkleProof.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: forgedId}), - body, - notaryProof, - backendSig, - "attacker", - _idFor("attacker"), - "TST", - "400", - 400e18, - "" - ); - } - - // C1: the proven body honors bob, but a front-runner swaps receiverHandle to - // their own identity. The on-chain recipient==receiverHandle bind rejects it. - function test_webTransfer_recipientSwap_reverts() public { - string memory resourceId = "repo/x/comment/1"; - string memory requestPath = "/repos/owner/repo/issues/comments/1"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 400 TST"); - bytes memory authorBytes = bytes("alice"); - - NotaryTlsProof memory notaryProof = - _buildBankProof("api.github.com", requestPath, body, authorBytes, block.timestamp); - // Backend signed for the real recipient bob. - BackendSig memory backendSig = _buildBackendSig(uid, body, authorBytes, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(NoTemplateMatch.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: ""}), - body, - notaryProof, - backendSig, - "attacker", // swapped — body says @bob - _idFor("attacker"), - "TST", - "400", - 400e18, - "" - ); - } - - // C1: front-runner keeps receiverHandle=bob (matches body) but swaps the - // receiverUserId. It is bound into the backend digest -> signature rejects it. - function test_webTransfer_receiverIdSwap_reverts() public { - string memory resourceId = "repo/x/comment/2"; - string memory requestPath = "/repos/owner/repo/issues/comments/2"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - // Backend signed for the REAL receiver bob; the call swaps in attacker's id. - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(InvalidBackendSignature.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("attacker"), // swapped id — bound in the digest - "TST", - "400", - 400e18, - "" - ); - } - - /// @dev Transfer with a specific uid (for replay testing) - function _transferWithUid( - string memory resourceId, - string memory senderPlatform, - string memory senderHandle, - string memory receiverPlatform, - string memory receiverHandle, - uint256 amount - ) internal { - string memory amountStr = vm.toString(amount / 1e18); - _transferWithUidAndAmountStr( - resourceId, senderPlatform, senderHandle, receiverPlatform, receiverHandle, amountStr, amount - ); - } - - function _transferWithUidAndAmountStr( - string memory resourceId, - string memory senderPlatform, - string memory senderHandle, - string memory receiverPlatform, - string memory receiverHandle, - string memory amountStr, - uint256 amountWei - ) internal { - string memory requestPath = "/repos/owner/repo/issues/comments/fixed"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - - bytes memory body = abi.encodePacked("@dyaka-agent honor @", receiverHandle, " with ", amountStr, " TST"); - bytes memory authorBytes = bytes(senderHandle); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor(senderHandle), '"'); - - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor(receiverHandle), block.timestamp); - - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - bank.webTransferV2( - resource, - SenderInfo({author: senderHandle, revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - receiverHandle, - _idFor(receiverHandle), - "TST", - amountStr, - amountWei, - "" - ); - } - - // ─── Execute helpers ──────────────────────────────────────────── - - function _execute(WebWallet wallet, address target, bytes memory data, uint256 pk) internal { - uint256[] memory pks = new uint256[](1); - pks[0] = pk; - _executeMulti(wallet, target, 0, data, pks); - } - - function _executeMulti(WebWallet wallet, address target, uint256 value, bytes memory data, uint256[] memory pks) - internal - { - uint256 currentNonce = wallet.nonce(); - bytes32 digest = - keccak256(abi.encode(target, value, keccak256(data), currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](pks.length); - for (uint256 i = 0; i < pks.length; i++) { - sigs[i] = _sign(pks[i], digest); - } - wallet.execute(target, value, data, sigs); - } - - function _executeBatch( - WebWallet wallet, - address[] memory targets, - uint256[] memory values, - bytes[] memory datas, - uint256[] memory pks - ) internal { - uint256 currentNonce = wallet.nonce(); - bytes32 dataHashes = keccak256(abi.encode(datas)); - bytes32 digest = - keccak256(abi.encode(targets, values, dataHashes, currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](pks.length); - for (uint256 i = 0; i < pks.length; i++) { - sigs[i] = _sign(pks[i], digest); - } - wallet.executeBatch(targets, values, datas, sigs); - } - - // ─── Assertions ───────────────────────────────────────────────── - - function _assertBankBalance(string memory platform, string memory handle, uint256 expected) internal view { - assertEq(bank.balanceOf(_domain(platform), handle, _idFor(handle), address(token)), expected); - } - - function _assertBankBalance(WebWallet wallet, uint256 expected) internal view { - assertEq(bank.balanceOf(address(wallet), address(token)), expected); - } - - function _assertTokenBalance(WebWallet wallet, uint256 expected) internal view { - assertEq(token.balanceOf(address(wallet)), expected); - } - - function _assertUnregisteredBalance(string memory platform, string memory handle, uint256 expected) internal view { - bytes32 key = keccak256(abi.encode("id:v1", _domain(platform), _idFor(handle))); - assertEq(bank.unregisteredBalances(key, address(token)), expected); - } - - function _assertRegisteredBalance(WebWallet wallet, uint256 expected) internal view { - assertEq(bank.registeredBalances(address(wallet), address(token)), expected); - } - - // ─── Identity helpers ─────────────────────────────────────────── - - function _linkIdentity(WebWallet wallet, string memory platform, string memory handle, uint256 signerPk) internal { - _linkIdentityWithSessionPk(wallet, platform, handle, signerPk, signerPk); - } - - function _linkIdentityWithSessionPk( - WebWallet wallet, - string memory platform, - string memory handle, - uint256 signerPk, - uint256 newSessionPk - ) internal { - string memory domain = _domain(platform); - string memory userId = _idFor(handle); - Registry.FullTlsProof memory proof = _buildRegistryProofWithId( - domain, - handle, - userId, - "api.test.com/endpoint", - '"username":"', - vm.addr(newSessionPk), - address(wallet), - block.timestamp - ); - bytes memory data = - abi.encodeCall(Registry.linkIdentity, (domain, handle, userId, proof, "api.test.com/endpoint")); - _execute(wallet, address(registry), data, signerPk); - } - - function _addSession(string memory platform, string memory handle, uint256 pk) internal { - string memory domain = _domain(platform); - string memory userId = _idFor(handle); - address sessionAddr = vm.addr(pk); - Registry.FullTlsProof memory proof = _buildRegistryProofWithId( - domain, handle, userId, "api.test.com/endpoint", '"username":"', sessionAddr, address(0), block.timestamp - ); - registry.register_session(proof, domain, handle, userId, "api.test.com/endpoint"); - } - - function _setThreshold(WebWallet wallet, uint8 t, uint256 signerPk) internal { - bytes memory data = abi.encodeCall(WebWallet.setThreshold, (t)); - _execute(wallet, address(wallet), data, signerPk); - } - - function _revokeSession( - WebWallet wallet, - string memory platform, - string memory handle, - address sessionAddr, - uint256 signerPk - ) internal { - bytes memory data = abi.encodeCall( - WebWallet.revokeSession, (_domain(platform), handle, _idFor(handle), sessionAddr) - ); - _execute(wallet, address(wallet), data, signerPk); - } - - // ═══════════════════════════════════════════════════════════════ - // LOW-LEVEL PROOF BUILDERS - // ═══════════════════════════════════════════════════════════════ - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - function _leaf(string memory prefix, bytes memory value) internal pure returns (bytes32) { - return keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - } - - function _buildPaths(bytes memory domainBytes, bytes memory usernameBytes, bytes memory endpointBytes) - internal - pure - returns ( - bytes32 transcriptRoot, - bytes32[] memory domainPath, - bytes32[] memory usernamePath, - bytes32[] memory endpointPath - ) - { - bytes32 leafD = _leaf("domain:", domainBytes); - bytes32 leafU = _leaf("recv:", usernameBytes); - bytes32 leafE = _leaf("endpoint:", endpointBytes); - - bytes32 hDu = - leafD < leafU ? keccak256(abi.encodePacked(leafD, leafU)) : keccak256(abi.encodePacked(leafU, leafD)); - - transcriptRoot = hDu < leafE ? keccak256(abi.encodePacked(hDu, leafE)) : keccak256(abi.encodePacked(leafE, hDu)); - - domainPath = new bytes32[](2); - domainPath[0] = leafU; - domainPath[1] = leafE; - - usernamePath = new bytes32[](2); - usernamePath[0] = leafD; - usernamePath[1] = leafE; - - endpointPath = new bytes32[](1); - endpointPath[0] = hDu; - } - - function _buildRegistryProof( - string memory domain, - string memory username, - string memory endpoint, - string memory handlePrefix, - address userAddress, - uint256 ts - ) internal view returns (Registry.FullTlsProof memory proof) { - return _buildRegistryProofForWallet(domain, username, endpoint, handlePrefix, userAddress, address(0), ts); - } - - function _buildRegistryProofForWallet( - string memory domain, - string memory username, - string memory endpoint, - string memory handlePrefix, - address userAddress, - address walletAddress, - uint256 ts - ) internal view returns (Registry.FullTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(111)); - bytes32 serverRandom = bytes32(uint256(222)); - bytes memory serverEphKey = hex"deadbeef"; - bytes32 domainHash = keccak256(bytes(domain)); - - bytes memory usernameSnippet = abi.encodePacked(handlePrefix, username, '"'); - (bytes32 transcriptRoot, bytes32[] memory dp, bytes32[] memory up, bytes32[] memory ep) = - _buildPaths(bytes(domain), usernameSnippet, bytes(endpoint)); - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - bytes32 backendDigest = keccak256(abi.encode(userAddress, walletAddress, transcriptRoot, ts)); - - proof = Registry.FullTlsProof({ - notarySignature: _sign(NOTARY_KEY, notaryDigest), - backendSignature: _sign(BACKEND_KEY, backendDigest), - userAddress: userAddress, - walletAddress: walletAddress, - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - domainPath: dp, - usernamePath: up, - endpointPath: ep, - idPath: new bytes32[](0) - }); - } - - // 4-leaf tree (D, U, E, Id) — id bound via the quoted "id":"" recv leaf. - function _buildRegistryProofWithId( - string memory domain, - string memory username, - string memory userId, - string memory endpoint, - string memory handlePrefix, - address userAddress, - address walletAddress, - uint256 ts - ) internal view returns (Registry.FullTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(111)); - bytes32 serverRandom = bytes32(uint256(222)); - bytes memory serverEphKey = hex"deadbeef"; - bytes32 domainHash = keccak256(bytes(domain)); - - bytes32 leafD = _leaf("domain:", bytes(domain)); - bytes32 leafU = _leaf("recv:", abi.encodePacked(handlePrefix, username, '"')); - bytes32 leafE = _leaf("endpoint:", bytes(endpoint)); - bytes32 leafId = _leaf("recv:", abi.encodePacked('"id":"', userId, '"')); - - bytes32 hDU = _h(leafD, leafU); - bytes32 hEId = _h(leafE, leafId); - bytes32 root = _h(hDU, hEId); - - bytes32[] memory dp = new bytes32[](2); - dp[0] = leafU; - dp[1] = hEId; - bytes32[] memory up = new bytes32[](2); - up[0] = leafD; - up[1] = hEId; - bytes32[] memory ep = new bytes32[](2); - ep[0] = leafId; - ep[1] = hDU; - bytes32[] memory idp = new bytes32[](2); - idp[0] = leafE; - idp[1] = hDU; - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - root, - ts - ) - ); - bytes32 backendDigest = keccak256(abi.encode(userAddress, walletAddress, root, ts)); - - proof = Registry.FullTlsProof({ - notarySignature: _sign(NOTARY_KEY, notaryDigest), - backendSignature: _sign(BACKEND_KEY, backendDigest), - userAddress: userAddress, - walletAddress: walletAddress, - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: root, - timestamp: ts, - domainPath: dp, - usernamePath: up, - endpointPath: ep, - idPath: idp - }); - } - - function _h(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - function _buildBankProof( - string memory apiHost, - string memory requestPath, - bytes memory revealedBody, - bytes memory revealedAuthor, - uint256 ts - ) internal view returns (NotaryTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(333)); - bytes32 serverRandom = bytes32(uint256(444)); - bytes memory serverEphKey = hex"cafebabe"; - bytes32 domainHash = keccak256(bytes(apiHost)); - - // 4 leaves: domain, endpoint, body (recv), author (recv) - bytes32 leafD = _leaf("domain:", bytes(apiHost)); - bytes32 leafE = _leaf("endpoint:", bytes(requestPath)); - bytes32 leafB = _leaf("recv:", revealedBody); - bytes32 leafA = _leaf("recv:", revealedAuthor); - - // Build Merkle tree from [leafD, leafE, leafB, leafA] - bytes32 hDE = - leafD < leafE ? keccak256(abi.encodePacked(leafD, leafE)) : keccak256(abi.encodePacked(leafE, leafD)); - - bytes32 hBA = - leafB < leafA ? keccak256(abi.encodePacked(leafB, leafA)) : keccak256(abi.encodePacked(leafA, leafB)); - - bytes32 transcriptRoot = - hDE < hBA ? keccak256(abi.encodePacked(hDE, hBA)) : keccak256(abi.encodePacked(hBA, hDE)); - - // Merkle paths for each leaf (sibling + uncle) - bytes32[] memory bodyPath = new bytes32[](2); - bodyPath[0] = leafA; // sibling - bodyPath[1] = hDE; // uncle - - bytes32[] memory authorPath = new bytes32[](2); - authorPath[0] = leafB; // sibling - authorPath[1] = hDE; // uncle - - bytes32[] memory domainPath = new bytes32[](2); - domainPath[0] = leafE; // sibling - domainPath[1] = hBA; // uncle - - bytes32[] memory endpointPath = new bytes32[](2); - endpointPath[0] = leafD; // sibling - endpointPath[1] = hBA; // uncle - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - - proof = NotaryTlsProof({ - notarySig: _sign(NOTARY_KEY, notaryDigest), - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - bodyMerklePath: bodyPath, - authorMerklePath: authorPath, - domainMerklePath: domainPath, - endpointMerklePath: endpointPath, - authorIdMerklePath: new bytes32[](0), - receiverIdMerklePath: new bytes32[](0), - revealedReceiverId: "", - quotedRefMerklePath: new bytes32[](0), - revealedQuotedRef: "", - quotedAuthorMerklePath: new bytes32[](0), - revealedQuotedAuthorId: "" - }); - } - - function _hp(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - /// 5-leaf proof builder (adds the author-id recv leaf) so the id-resolution - /// path can be exercised on-chain. The id leaf is appended at the tree top: - /// its path is the old 4-leaf root, and the other leaves' paths gain it. - function _buildBankProofWithId( - string memory apiHost, - string memory requestPath, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - uint256 ts - ) internal view returns (NotaryTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(333)); - bytes32 serverRandom = bytes32(uint256(444)); - bytes memory serverEphKey = hex"cafebabe"; - bytes32 domainHash = keccak256(bytes(apiHost)); - - bytes32 leafD = _leaf("domain:", bytes(apiHost)); - bytes32 leafE = _leaf("endpoint:", bytes(requestPath)); - bytes32 leafB = _leaf("recv:", revealedBody); - bytes32 leafA = _leaf("recv:", revealedAuthor); - bytes32 leafId = _leaf("recv:", revealedAuthorId); - - bytes32 hDE = _hp(leafD, leafE); - bytes32 hBA = _hp(leafB, leafA); - bytes32 root4 = _hp(hDE, hBA); - bytes32 transcriptRoot = _hp(root4, leafId); - - bytes32[] memory bodyPath = new bytes32[](3); - (bodyPath[0], bodyPath[1], bodyPath[2]) = (leafA, hDE, leafId); - bytes32[] memory authorPath = new bytes32[](3); - (authorPath[0], authorPath[1], authorPath[2]) = (leafB, hDE, leafId); - bytes32[] memory domainPath = new bytes32[](3); - (domainPath[0], domainPath[1], domainPath[2]) = (leafE, hBA, leafId); - bytes32[] memory endpointPath = new bytes32[](3); - (endpointPath[0], endpointPath[1], endpointPath[2]) = (leafD, hBA, leafId); - bytes32[] memory authorIdPath = new bytes32[](1); - authorIdPath[0] = root4; - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - - proof = NotaryTlsProof({ - notarySig: _sign(NOTARY_KEY, notaryDigest), - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - bodyMerklePath: bodyPath, - authorMerklePath: authorPath, - domainMerklePath: domainPath, - endpointMerklePath: endpointPath, - authorIdMerklePath: authorIdPath, - receiverIdMerklePath: new bytes32[](0), - revealedReceiverId: "", - quotedRefMerklePath: new bytes32[](0), - revealedQuotedRef: "", - quotedAuthorMerklePath: new bytes32[](0), - revealedQuotedAuthorId: "" - }); - } - - function _buildBackendSigWithId( - bytes32 uid, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - string memory receiverUserId, - uint256 ts - ) internal pure returns (BackendSig memory) { - bytes32 digest = keccak256( - abi.encodePacked(uid, revealedBody, revealedAuthor, revealedAuthorId, keccak256(bytes(receiverUserId)), ts) - ); - return BackendSig({sig: _sign(BACKEND_KEY, digest), timestamp: ts}); - } - - // Happy path: a non-empty proven revealedAuthorId resolves the sender via - // _extractId -> resolveById (not the handle), and the transfer settles. - function test_webTransfer_idPath_resolvesByExtractedId() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 1000e18); - - string memory resourceId = "repo/x/comment/idpath"; - string memory requestPath = "/repos/owner/repo/issues/comments/5"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - uint256 aliceBefore = bank.balanceOf("api.github.com", "alice", _idFor("alice"), address(token)); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - - assertEq(bank.balanceOf("api.github.com", "alice", _idFor("alice"), address(token)), aliceBefore - 400e18); - assertEq(bank.balanceOf("api.github.com", "bob", _idFor("bob"), address(token)), 400e18); - } - - // ── recipient-less (reply-inferred) honor: recipient bound by the proven - // `in_reply_to_user_id` leaf, NOT by the body ───────────────────────── - - // 6-leaf proof builder: adds the reply-target recv leaf alongside author-id. - // `_hp` is commutative, so sibling order within a level does not matter. - function _buildBankProofWithReceiverId( - string memory apiHost, - string memory requestPath, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - bytes memory revealedReceiverId, - uint256 ts - ) internal view returns (NotaryTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(333)); - bytes32 serverRandom = bytes32(uint256(444)); - bytes memory serverEphKey = hex"cafebabe"; - bytes32 domainHash = keccak256(bytes(apiHost)); - - bytes32 leafD = _leaf("domain:", bytes(apiHost)); - bytes32 leafE = _leaf("endpoint:", bytes(requestPath)); - bytes32 leafB = _leaf("recv:", revealedBody); - bytes32 leafA = _leaf("recv:", revealedAuthor); - bytes32 leafId = _leaf("recv:", revealedAuthorId); - bytes32 leafR = _leaf("recv:", revealedReceiverId); - - bytes32 hDE = _hp(leafD, leafE); - bytes32 hBA = _hp(leafB, leafA); - bytes32 root4 = _hp(hDE, hBA); - bytes32 hIdR = _hp(leafId, leafR); - bytes32 transcriptRoot = _hp(root4, hIdR); - - bytes32[] memory bodyPath = new bytes32[](3); - (bodyPath[0], bodyPath[1], bodyPath[2]) = (leafA, hDE, hIdR); - bytes32[] memory authorPath = new bytes32[](3); - (authorPath[0], authorPath[1], authorPath[2]) = (leafB, hDE, hIdR); - bytes32[] memory domainPath = new bytes32[](3); - (domainPath[0], domainPath[1], domainPath[2]) = (leafE, hBA, hIdR); - bytes32[] memory endpointPath = new bytes32[](3); - (endpointPath[0], endpointPath[1], endpointPath[2]) = (leafD, hBA, hIdR); - bytes32[] memory authorIdPath = new bytes32[](2); - (authorIdPath[0], authorIdPath[1]) = (leafR, root4); - bytes32[] memory receiverIdPath = new bytes32[](2); - (receiverIdPath[0], receiverIdPath[1]) = (leafId, root4); - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - - proof = NotaryTlsProof({ - notarySig: _sign(NOTARY_KEY, notaryDigest), - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - bodyMerklePath: bodyPath, - authorMerklePath: authorPath, - domainMerklePath: domainPath, - endpointMerklePath: endpointPath, - authorIdMerklePath: authorIdPath, - receiverIdMerklePath: receiverIdPath, - revealedReceiverId: revealedReceiverId, - quotedRefMerklePath: new bytes32[](0), - revealedQuotedRef: "", - quotedAuthorMerklePath: new bytes32[](0), - revealedQuotedAuthorId: "" - }); - } - - // Happy path: a recipient-less body ("honor with ...", no @recipient) settles - // to the id proven by the notarized `in_reply_to_user_id` leaf. - function test_webTransfer_recipientLess_bindsProvenReplyTarget() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-implicit-1"; - string memory requestPath = "/2/tweets?ids=tweet-implicit-1"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - // X prepends the parent @bob; the command itself carries no @recipient. - bytes memory body = abi.encodePacked("@bob @dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - bytes memory receiverId = abi.encodePacked('"in_reply_to_user_id":"', _idFor("bob"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithReceiverId( - "api.x.com", requestPath, body, authorBytes, authorId, receiverId, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - uint256 aliceBefore = bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", // display handle (reply parent); recipient bound by the leaf, not the body - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - - assertEq(bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)), aliceBefore - 400e18); - assertEq(bank.balanceOf("api.x.com", "bob", _idFor("bob"), address(token)), 400e18); - } - - // The proven reply target (carol) must equal the backend-supplied receiverUserId - // (bob) — a swapped receiver id reverts even with a valid leaf. - function test_webTransfer_recipientLess_forgedReplyTarget_reverts() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-implicit-2"; - string memory requestPath = "/2/tweets?ids=tweet-implicit-2"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@carol @dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - // Leaf proves carol is the reply target... - bytes memory receiverId = abi.encodePacked('"in_reply_to_user_id":"', _idFor("carol"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithReceiverId( - "api.x.com", requestPath, body, authorBytes, authorId, receiverId, block.timestamp - ); - // ...but the backend signs bob as the receiver id → mismatch. - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(ReplyTargetMismatch.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // A recipient-less body with NO proven reply target (empty receiver-id leaf, - // e.g. a standalone tweet) must revert — recipient-less honors are reply-only. - function test_webTransfer_recipientLess_noReplyTarget_reverts() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-implicit-3"; - string memory requestPath = "/2/tweets?ids=tweet-implicit-3"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - - // 5-leaf builder → revealedReceiverId "" and empty receiverIdMerklePath. - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.x.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(RecipientLessNeedsReplyTarget.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // Preflight parity: verifyProof MUST reject a recipient-less body with no proven - // reply target (empty receiver-id leaf) — the same way webTransferV2 does — - // instead of green-lighting a proof the transfer would then revert on. - function test_verifyProof_recipientLess_noReplyTarget_reverts() public { - string memory resourceId = "tweet-implicit-vp"; - string memory requestPath = "/2/tweets?ids=tweet-implicit-vp"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - - // 5-leaf builder → revealedReceiverId "" and empty receiverIdMerklePath. - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.x.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(RecipientLessNeedsReplyTarget.selector); - bank.verifyProof( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // With a valid proven reply-target leaf matching the receiver id, verifyProof - // returns true (the recipient-less gate must not over-reject the happy path). - function test_verifyProof_recipientLess_validReplyTarget_ok() public { - string memory resourceId = "tweet-implicit-vp2"; - string memory requestPath = "/2/tweets?ids=tweet-implicit-vp2"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@bob @dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - bytes memory receiverId = abi.encodePacked('"in_reply_to_user_id":"', _idFor("bob"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithReceiverId( - "api.x.com", requestPath, body, authorBytes, authorId, receiverId, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - assertTrue( - bank.verifyProof( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - _idFor("bob") - ) - ); - } - - // ── recipient-less (quote-inferred) honor: recipient = author of the quoted - // tweet, joined across the quoted-reference and includes-author leaves ──── - - // 8-leaf-shaped proof builder: adds the quoted-reference and quoted-author recv - // leaves (the quoted-author is self-paired to fill the tree). `_hp` is - // commutative, so sibling order within a level does not matter. - function _buildBankProofWithQuotedAuthor( - string memory apiHost, - string memory requestPath, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - bytes memory revealedQuotedRef, - bytes memory revealedQuotedAuthorId, - uint256 ts - ) internal view returns (NotaryTlsProof memory proof) { - bytes32 domainHash = keccak256(bytes(apiHost)); - bytes32 leafD = _leaf("domain:", bytes(apiHost)); - bytes32 leafE = _leaf("endpoint:", bytes(requestPath)); - bytes32 leafB = _leaf("recv:", revealedBody); - bytes32 leafA = _leaf("recv:", revealedAuthor); - bytes32 leafId = _leaf("recv:", revealedAuthorId); - bytes32 leafQr = _leaf("recv:", revealedQuotedRef); - bytes32 leafQa = _leaf("recv:", revealedQuotedAuthorId); - - bytes32 hDE = _hp(leafD, leafE); - bytes32 hBA = _hp(leafB, leafA); - bytes32 root4 = _hp(hDE, hBA); - bytes32 hIdQr = _hp(leafId, leafQr); - bytes32 hQaQa = _hp(leafQa, leafQa); // pad: quoted-author self-paired - bytes32 rightSub = _hp(hIdQr, hQaQa); - bytes32 transcriptRoot = _hp(root4, rightSub); - - proof.notarySig = _sign( - NOTARY_KEY, - keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - bytes32(uint256(333)), - bytes32(uint256(444)), - keccak256(hex"cafebabe"), - transcriptRoot, - ts - ) - ) - ); - proof.domainHash = domainHash; - proof.clientRandom = bytes32(uint256(333)); - proof.serverRandom = bytes32(uint256(444)); - proof.serverEphemeralKey = hex"cafebabe"; - proof.transcriptRoot = transcriptRoot; - proof.timestamp = ts; - proof.bodyMerklePath = _path3(leafA, hDE, rightSub); - proof.authorMerklePath = _path3(leafB, hDE, rightSub); - proof.domainMerklePath = _path3(leafE, hBA, rightSub); - proof.endpointMerklePath = _path3(leafD, hBA, rightSub); - proof.authorIdMerklePath = _path3(leafQr, hQaQa, root4); - proof.receiverIdMerklePath = new bytes32[](0); - proof.revealedReceiverId = ""; - proof.quotedRefMerklePath = _path3(leafId, hQaQa, root4); - proof.revealedQuotedRef = revealedQuotedRef; - proof.quotedAuthorMerklePath = _path3(leafQa, hIdQr, root4); - proof.revealedQuotedAuthorId = revealedQuotedAuthorId; - } - - function _path3(bytes32 a, bytes32 b, bytes32 c) internal pure returns (bytes32[] memory p) { - p = new bytes32[](3); - (p[0], p[1], p[2]) = (a, b, c); - } - - // Happy path: a recipient-less body ("honor with ...", no @recipient) on a QUOTE - // tweet settles to the author of the quoted tweet, proven by the quoted-reference - // + includes-author leaves. - function test_webTransfer_recipientLess_bindsQuotedAuthor() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-quote-1"; - string memory requestPath = "/2/tweets?ids=tweet-quote-1"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - // bob authored the quoted tweet "qt-99". - bytes memory quotedRef = abi.encodePacked('{"type":"quoted","id":"qt-99"}'); - bytes memory quotedAuthor = abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - uint256 aliceBefore = bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", // display handle; recipient bound by the quoted-author leaf, not the body - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - - assertEq(bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)), aliceBefore - 400e18); - assertEq(bank.balanceOf("api.x.com", "bob", _idFor("bob"), address(token)), 400e18); - } - - // Real X emits `referenced_tweets` objects id-FIRST: `{"id":…,"type":"quoted"}`. - // The quote binding must be field-order-independent — this is the exact shape - // the live quote honor produces (the type-first happy path above proves the - // other order). Regression for the on-chain half of the live quote fix. - function test_webTransfer_recipientLess_bindsQuotedAuthor_idFirstOrder() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-quote-idfirst"; - string memory requestPath = "/2/tweets?ids=tweet-quote-idfirst"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - // id-FIRST, as X actually serializes it. - bytes memory quotedRef = abi.encodePacked('{"id":"qt-99","type":"quoted"}'); - bytes memory quotedAuthor = abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - uint256 aliceBefore = bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - - assertEq(bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)), aliceBefore - 400e18); - assertEq(bank.balanceOf("api.x.com", "bob", _idFor("bob"), address(token)), 400e18); - } - - // L6: the extracted author_id must sit in the SAME includes-tweet object as the - // qid join. A leaf whose FIRST object (author=carol) is a decoy and whose qid - // (qt-99) lives in a LATER object must revert — even when the backend signs the - // decoy author — so a mis-ordered includes.tweets can't credit a non-author. - function test_webTransfer_recipientLess_quote_authorInDifferentObject_reverts() public { - _walletFor("x", "alice", ALICE_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-quote-obj"; - string memory requestPath = "/2/tweets?ids=tweet-quote-obj"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - bytes memory quotedRef = abi.encodePacked('{"type":"quoted","id":"qt-99"}'); - // First object (decoy, carol) has a different id; qt-99 lives in the SECOND. - bytes memory quotedAuthor = abi.encodePacked( - '"tweets":[{"id":"other","author_id":"', - _idFor("carol"), - '"},{"id":"qt-99","author_id":"', - _idFor("bob"), - '"' - ); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - // Backend signs carol (the decoy first-object author) as receiver. - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("carol"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(QuotedAuthorDifferentObject.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "carol", - _idFor("carol"), - "TST", - "400", - 400e18, - "" - ); - } - - // The proven quoted author (carol) must equal the backend receiverUserId (bob) — - // a swapped author id reverts even with a valid, joined leaf. - function test_webTransfer_recipientLess_quote_forgedAuthor_reverts() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-quote-2"; - string memory requestPath = "/2/tweets?ids=tweet-quote-2"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - bytes memory quotedRef = abi.encodePacked('{"type":"quoted","id":"qt-99"}'); - // Leaf proves carol authored the quoted tweet... - bytes memory quotedAuthor = abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("carol"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - // ...but the backend signs bob as the receiver id → mismatch. - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(QuotedAuthorMismatch.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // Wrong-object hijack: the author leaf carries bob's author_id but for a DIFFERENT - // tweet id than the one proven quoted (qt-99) → the join fails and reverts. - function test_webTransfer_recipientLess_quote_wrongQuotedId_reverts() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-quote-3"; - string memory requestPath = "/2/tweets?ids=tweet-quote-3"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - bytes memory quotedRef = abi.encodePacked('{"type":"quoted","id":"qt-99"}'); - // author leaf binds bob but to tweet "qt-OTHER", not the quoted qt-99. - bytes memory quotedAuthor = abi.encodePacked('"tweets":[{"id":"qt-OTHER","author_id":"', _idFor("bob"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(QuotedAuthorMissingQuotedId.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // A non-quoted reference (e.g. a `replied_to` entry) must not pass as a quote - // binding: the ref leaf must contain `"type":"quoted"` (order-independent). - function test_webTransfer_recipientLess_quote_notQuotedRef_reverts() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-quote-4"; - string memory requestPath = "/2/tweets?ids=tweet-quote-4"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - // A replied_to reference, not a quoted one. - bytes memory quotedRef = abi.encodePacked('{"type":"replied_to","id":"qt-99"}'); - bytes memory quotedAuthor = abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(RefNotQuote.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // Leading-decoy hijack: the recv transcript is fully committed, so a proof - // generator can reveal ANY authentic substring as the author leaf — e.g. a - // `data`-object span whose FIRST author_id is the sender (or any non-quoted - // author) followed by the referenced `"id":""`. The join would pass, but - // the extracted author would be the wrong party. The `"tweets":[{` prefix check - // rejects any leaf that is not the includes.tweets array. - function test_webTransfer_recipientLess_quote_nonIncludesLeaf_reverts() public { - _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 1000e18); - - string memory resourceId = "tweet-quote-5"; - string memory requestPath = "/2/tweets?ids=tweet-quote-5"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - bytes memory quotedRef = abi.encodePacked('{"type":"quoted","id":"qt-99"}'); - // A data-object-style span (NOT `"tweets":[{`): first author_id + the - // referenced quoted id. Join passes, author = bob, but it is not includes. - bytes memory quotedAuthor = - abi.encodePacked('"author_id":"', _idFor("bob"), '","referenced_tweets":[{"type":"quoted","id":"qt-99"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(QuotedAuthorLeafNotIncludes.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // Preflight parity: verifyProof accepts a valid quote binding (must not over-reject). - function test_verifyProof_recipientLess_quote_ok() public { - string memory resourceId = "tweet-quote-vp"; - string memory requestPath = "/2/tweets?ids=tweet-quote-vp"; - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka_agent honor with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - bytes memory quotedRef = abi.encodePacked('{"type":"quoted","id":"qt-99"}'); - bytes memory quotedAuthor = abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'); - - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", requestPath, body, authorBytes, authorId, quotedRef, quotedAuthor, block.timestamp - ); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.x.com", resourceType: "tweet", resourceId: resourceId, requestPath: requestPath - }); - - assertTrue( - bank.verifyProof( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - _idFor("bob") - ) - ); - } - - function _buildBackendSig( - bytes32 uid, - bytes memory revealedBody, - bytes memory revealedAuthor, - string memory receiverUserId, - uint256 ts - ) internal pure returns (BackendSig memory) { - // Mirrors Bank._verifyBackendSignature (empty revealedAuthorId adds nothing). - bytes32 digest = - keccak256(abi.encodePacked(uid, revealedBody, revealedAuthor, keccak256(bytes(receiverUserId)), ts)); - return BackendSig({sig: _sign(BACKEND_KEY, digest), timestamp: ts}); - } - - // ═══════════════════════════════════════════════════════════════ - // TESTS - // ═══════════════════════════════════════════════════════════════ - - // 1. Alice registers and her wallet is discoverable - function test_registeredUserIsDiscoverable() public { - WebWallet alice = _alice(); - - assertTrue(address(alice) != address(0)); - assertEq(registry.resolve("api.x.com", "alice"), address(alice)); - - (string[] memory platforms, string[] memory handles) = registry.getHandles(address(alice)); - assertEq(platforms.length, 1); - assertEq(platforms[0], "api.x.com"); - assertEq(handles[0], "alice"); - } - - // 2. Depositing to registered user lands in registered balance - function test_depositToRegisteredUser() public { - WebWallet alice = _alice(); - - _fund("x", "alice", 500e18); - - _assertBankBalance(alice, 500e18); - _assertBankBalance("x", "alice", 500e18); - } - - // 3. Depositing to unregistered user lands in unregistered balance - function test_depositToUnregisteredUser() public { - _fund("x", "charlie", 200e18); - - _assertUnregisteredBalance("x", "charlie", 200e18); - _assertBankBalance("x", "charlie", 200e18); - } - - // 4. Unregistered balance migrates when user registers and does a write - function test_unregisteredBalanceMigratesOnWrite() public { - _fund("x", "charlie", 300e18); - - WebWallet charlie = _charlie(); - _assertBankBalance(charlie, 300e18); - _assertUnregisteredBalance("x", "charlie", 300e18); - _assertRegisteredBalance(charlie, 0); - - _fund("x", "charlie", 10e18); - - _assertRegisteredBalance(charlie, 310e18); - _assertUnregisteredBalance("x", "charlie", 0); - } - - // 5. Alice sends TST to registered Bob via web transfer - function test_webTransferRegisteredToRegistered() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 1000e18); - - _transfer("github", "alice", "github", "bob", 400e18); - - _assertBankBalance("github", "alice", 600e18); - _assertBankBalance("github", "bob", 400e18); - } - - // 6. Alice sends TST to unregistered Charlie; Charlie registers and sees balance - function test_webTransferToUnregisteredThenRegister() public { - _walletFor("github", "alice", ALICE_PK); - _fund("github", "alice", 500e18); - - _transfer("github", "alice", "github", "charlie", 150e18); - - _assertUnregisteredBalance("github", "charlie", 150e18); - - WebWallet charlie = _walletFor("github", "charlie", CHARLIE_PK); - _assertBankBalance(charlie, 150e18); - } - - // 6b. webTransfer with unregistered token by address (address-based overload) - function test_webTransferByTokenAddress_unregistered_reverts() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - - // Deploy a new token that is NOT registered in Bank - MockERC20 unregToken = new MockERC20("Unregistered", "UNREG"); - unregToken.mint(address(this), 10_000e18); - - // Fund alice's wallet directly and approve bank - WebWallet aliceWallet = WebWallet(payable(registry.resolve("api.github.com", "alice"))); - require(unregToken.transfer(address(aliceWallet), 1000e18), "transfer failed"); - // Alice approves Bank to pull tokens (auto top-up) - bytes memory approveData = abi.encodeCall(IERC20.approve, (address(bank), type(uint256).max)); - _execute(aliceWallet, address(unregToken), approveData, ALICE_PK); - - // Use address-based webTransfer overload - _transferCounter++; - string memory resourceId = string(abi.encodePacked("repo/", vm.toString(_transferCounter), "/comment/1")); - string memory requestPath = - string(abi.encodePacked("/repos/owner/repo/issues/comments/", vm.toString(_transferCounter))); - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - // Body must contain the lowercase hex address of the token and the human-readable amount - string memory tokenAddrHex = vm.toLowercase(vm.toString(address(unregToken))); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 400 ", tokenAddrHex); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - // The web-transfer surface is gated to REGISTERED tokens — the by-address - // overload must reject an unregistered ERC-20 (else autoTopUp would pull an - // arbitrary approved token into the ledger). - vm.expectRevert(TokenNotRegistered.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - address(unregToken), - "400", - 400e18, - "" - ); - } - - // 7. Same comment cannot be used for two transfers (replay protection) - function test_replayProtectionOnWebTransfer() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 1000e18); - - _transferWithUid("replay/1", "github", "alice", "github", "bob", 100e18); - - vm.expectRevert(TransferAlreadyUsed.selector); - _transferWithUid("replay/1", "github", "alice", "github", "bob", 100e18); - } - - // 8. Alice withdraws from bank to her wallet via execute() - function test_withdrawViaExecute() public { - WebWallet alice = _alice(); - _fund("x", "alice", 500e18); - - bytes memory data = abi.encodeCall(IBank.withdraw, (address(alice), address(token), 200e18)); - _execute(alice, address(bank), data, ALICE_PK); - - _assertTokenBalance(alice, 200e18); - _assertBankBalance(alice, 300e18); - } - - // 9. Wallet approves ERC20 and deposits for Bob in one batch - function test_batchApproveAndDeposit() public { - WebWallet alice = _alice(); - _bob(); - _fundWallet(alice, 300e18); - - address[] memory targets = new address[](2); - uint256[] memory values = new uint256[](2); - bytes[] memory datas = new bytes[](2); - uint256[] memory pks = new uint256[](1); - - targets[0] = address(token); - targets[1] = address(bank); - values[0] = 0; - values[1] = 0; - datas[0] = abi.encodeCall(IERC20.approve, (address(bank), 300e18)); - datas[1] = abi.encodeCall(IBank.deposit, ("api.x.com", "bob", _idFor("bob"), address(token), 300e18)); - pks[0] = ALICE_PK; - - _executeBatch(alice, targets, values, datas, pks); - - _assertBankBalance("x", "bob", 300e18); - } - - // 10. Multisig with 3 handles from same platform (threshold=3) - function test_multisigThreeSamePlatform() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - - _linkIdentityWithSessionPk(alice, "x", "alice2", ALICE_PK, ALICE_PK2); - - _linkIdentityWithSessionPk(alice, "x", "alice3", ALICE_PK, ALICE_PK3); - - _setThreshold(alice, 3, ALICE_PK); - - // 2 sigs → fails - bytes memory data = abi.encodeCall(Registry.resolve, ("api.x.com", "alice")); - { - uint256[] memory pks2 = new uint256[](2); - pks2[0] = ALICE_PK; - pks2[1] = ALICE_PK2; - uint256 n = alice.nonce(); - bytes32 digest = - keccak256(abi.encode(address(registry), uint256(0), keccak256(data), n, block.chainid, address(alice))); - bytes[] memory sigs = new bytes[](2); - sigs[0] = _sign(pks2[0], digest); - sigs[1] = _sign(pks2[1], digest); - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - alice.execute(address(registry), 0, data, sigs); - } - - // 3 sigs → succeeds - { - uint256[] memory pks3 = new uint256[](3); - pks3[0] = ALICE_PK; - pks3[1] = ALICE_PK2; - pks3[2] = ALICE_PK3; - _executeMulti(alice, address(registry), 0, data, pks3); - } - } - - // 11. Multisig with handles across different platforms (threshold=2) - function test_multisigCrossPlatform() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - - _linkIdentityWithSessionPk(alice, "github", "alice_gh", ALICE_PK, ALICE_PK2); - - _setThreshold(alice, 2, ALICE_PK); - - bytes memory data = abi.encodeCall(Registry.resolve, ("api.x.com", "alice")); - - // 1 sig → fails - { - uint256 n = alice.nonce(); - bytes32 digest = - keccak256(abi.encode(address(registry), uint256(0), keccak256(data), n, block.chainid, address(alice))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(ALICE_PK, digest); - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - alice.execute(address(registry), 0, data, sigs); - } - - // 2 sigs same identity → fails - { - _addSession("x", "alice", ALICE_PK3); - uint256 n = alice.nonce(); - bytes32 digest = - keccak256(abi.encode(address(registry), uint256(0), keccak256(data), n, block.chainid, address(alice))); - bytes[] memory sigs = new bytes[](2); - sigs[0] = _sign(ALICE_PK, digest); - sigs[1] = _sign(ALICE_PK3, digest); - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - alice.execute(address(registry), 0, data, sigs); - } - - // 1 x + 1 github → succeeds - { - uint256[] memory pks = new uint256[](2); - pks[0] = ALICE_PK; - pks[1] = ALICE_PK2; - _executeMulti(alice, address(registry), 0, data, pks); - } - } - - // 12. Upgrade wallet to V2, then execute transfer - function test_upgradeToV2ThenExecute() public { - WebWallet alice = _alice(); - - MockWebWalletV2 v2Impl = new MockWebWalletV2(); - factory.upgradeWalletImplementation(address(v2Impl)); - - bytes memory upgradeData = abi.encodeCall(WebWallet.upgradeToLatest, ()); - _execute(alice, address(alice), upgradeData, ALICE_PK); - - assertEq(MockWebWalletV2(payable(address(alice))).version(), 2); - - _fund("x", "alice", 500e18); - - bytes memory withdrawData = abi.encodeCall(IBank.withdraw, (address(alice), address(token), 200e18)); - _execute(alice, address(bank), withdrawData, ALICE_PK); - - _assertTokenBalance(alice, 200e18); - _assertBankBalance(alice, 300e18); - } - - // 13. Revoked session key no longer counts toward threshold - function test_revokedSessionKeyFails() public { - WebWallet alice = _alice(); - _addSession("x", "alice", ALICE_PK2); - - // Both keys work initially - bytes memory data = abi.encodeCall(Registry.resolve, ("api.x.com", "alice")); - _execute(alice, address(registry), data, ALICE_PK); - - // Revoke key1 - _revokeSession(alice, "x", "alice", vm.addr(ALICE_PK), ALICE_PK2); - - // key1 sig fails - { - uint256 n = alice.nonce(); - bytes32 digest = - keccak256(abi.encode(address(registry), uint256(0), keccak256(data), n, block.chainid, address(alice))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(ALICE_PK, digest); - vm.expectRevert(WebWallet.ThresholdNotMet.selector); - alice.execute(address(registry), 0, data, sigs); - } - - // key2 succeeds - _execute(alice, address(registry), data, ALICE_PK2); - } - - // 14. linkIdentity adds new platform, balance aggregates across platforms - function test_linkIdentityAggregatesBalance() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - - _fund("github", "alice_gh", 250e18); - - _linkIdentityWithSessionPk(alice, "github", "alice_gh", ALICE_PK, LINK_PK1); - - _assertBankBalance(alice, 250e18); - - _fund("x", "alice", 100e18); - _assertBankBalance(alice, 350e18); - } - - // 15. Upgrade then verify state preserved - function test_upgradePreservesState() public { - WebWallet alice = _alice(); - _fund("x", "alice", 500e18); - _linkIdentityWithSessionPk(alice, "github", "alice_gh", ALICE_PK, LINK_PK1); - _setThreshold(alice, 2, ALICE_PK); - - // Upgrade - MockWebWalletV2 v2Impl = new MockWebWalletV2(); - factory.upgradeWalletImplementation(address(v2Impl)); - { - uint256[] memory pks = new uint256[](2); - pks[0] = ALICE_PK; - pks[1] = LINK_PK1; - bytes memory upgradeData = abi.encodeCall(WebWallet.upgradeToLatest, ()); - _executeMulti(alice, address(alice), 0, upgradeData, pks); - } - - // Verify state preserved - assertEq(MockWebWalletV2(payable(address(alice))).version(), 2); - assertEq(alice.threshold(), 2); - assertEq(alice.identityCount(), 2); - assertTrue(alice.isSession(vm.addr(ALICE_PK))); - assertTrue(alice.isSession(vm.addr(LINK_PK1))); - _assertBankBalance(alice, 500e18); - } - - // 16. executeBatch: approve + deposit for another user atomically - function test_batchApproveAndDepositForSelf() public { - WebWallet alice = _alice(); - _fundWallet(alice, 1000e18); - - address[] memory targets = new address[](2); - uint256[] memory values = new uint256[](2); - bytes[] memory datas = new bytes[](2); - uint256[] memory pks = new uint256[](1); - - targets[0] = address(token); - targets[1] = address(bank); - values[0] = 0; - values[1] = 0; - datas[0] = abi.encodeCall(IERC20.approve, (address(bank), 500e18)); - datas[1] = abi.encodeCall(IBank.deposit, ("api.x.com", "alice", _idFor("alice"), address(token), 500e18)); - pks[0] = ALICE_PK; - - _executeBatch(alice, targets, values, datas, pks); - - _assertBankBalance(alice, 500e18); - _assertTokenBalance(alice, 500e18); - } - - // 17. linkIdentity registers session on wallet (cross-contract) - function test_linkIdentityRegistersSession() public { - WebWallet alice = _alice(); - _linkIdentityWithSessionPk(alice, "github", "alice_gh", ALICE_PK, LINK_PK1); - - // Session is keyed by the immutable (platform, userId), not the handle. - bytes32 ghKey = keccak256(abi.encode("api.github.com", _idFor("alice_gh"))); - assertTrue(alice.isSession(vm.addr(LINK_PK1))); - assertEq(alice.sessionCount(ghKey), 1); - assertEq(alice.sessionAt(ghKey, 0), vm.addr(LINK_PK1)); - } - - // 18. webTransfer sender not registered - function test_webTransfer_senderNotRegistered_reverts() public { - _walletFor("github", "bob", BOB_PK); - _fund("github", "bob", 100e18); - // alice is not registered on github - vm.expectRevert(SenderNotRegistered.selector); - _transfer("github", "alice_unregistered", "github", "bob", 50e18); - } - - // 19. webTransfer insufficient balance - function test_webTransfer_insufficientBalance_reverts() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 100e18); - vm.expectRevert(); // _autoTopUp tries safeTransferFrom without allowance - _transfer("github", "alice", "github", "bob", 200e18); - } - - // 20. webTransfer zero amount - function test_webTransfer_zeroAmount_reverts() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 100e18); - vm.expectRevert(ZeroAmount.selector); - _transferWithAmountStr("github", "alice", "github", "bob", "0", 0); - } - - // ─── parseDecimalAmount tests ─────────────────────────────────── - - // 21. Fractional transfer with 0.05 (18 decimals) - function test_webTransfer_fractionalAmount() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 1e18); - - _transferWithAmountStr("github", "alice", "github", "bob", "0.05", 5e16); - - _assertBankBalance("github", "alice", 1e18 - 5e16); - _assertBankBalance("github", "bob", 5e16); - } - - // 22. Fractional transfer with 1.5 (18 decimals) - function test_webTransfer_decimalOnePointFive() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 10e18); - - _transferWithAmountStr("github", "alice", "github", "bob", "1.5", 1.5e18); - - _assertBankBalance("github", "alice", 10e18 - 1.5e18); - _assertBankBalance("github", "bob", 1.5e18); - } - - // 23. Integer amount still works with amountStr - function test_webTransfer_integerAmountStr() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 1000e18); - - _transferWithAmountStr("github", "alice", "github", "bob", "100", 100e18); - - _assertBankBalance("github", "alice", 900e18); - _assertBankBalance("github", "bob", 100e18); - } - - // 24. Amount wei mismatch reverts - function test_webTransfer_amountWeiMismatch_reverts() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 1000e18); - - vm.expectRevert(AmountWeiMismatch.selector); - _transferWithAmountStr("github", "alice", "github", "bob", "100", 99e18); - } - - // ─── Token registration tests ─────────────────────────────────── - - /// Unregistered token name must not silently resolve to native asset. - function test_webTransfer_unregisteredTokenName_reverts() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 100e18); - - // Build a transfer that uses "$FAKE" as token name — not registered. - _transferCounter++; - string memory resourceId = string(abi.encodePacked("repo/", vm.toString(_transferCounter), "/comment/1")); - string memory requestPath = - string(abi.encodePacked("/repos/owner/repo/issues/comments/", vm.toString(_transferCounter))); - - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 50 $FAKE"); - bytes memory authorBytes = bytes("alice"); - - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - NotaryTlsProof memory notaryProof = - _buildBankProof("api.github.com", requestPath, body, authorBytes, block.timestamp); - BackendSig memory backendSig = _buildBackendSig(uid, body, authorBytes, "", block.timestamp); - - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(TokenNotRegistered.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: ""}), - body, - notaryProof, - backendSig, - "bob", - "", - "$FAKE", - "50", - 50e18, - "" - ); - } - - // ═══════════════════════════════════════════════════════════════ - // MUTATION-GUARD NEGATIVE TESTS - // One per revert-guard the mutation sweep found uncaught. Each feeds - // the bad input and asserts the exact custom error fires. - // ═══════════════════════════════════════════════════════════════ - - /// @dev Recompute the notary digest LibProof signs (chainid + registry bound). - function _notaryDigest(NotaryTlsProof memory p) internal view returns (bytes32) { - return keccak256( - abi.encode( - block.chainid, - address(registry), - p.domainHash, - p.clientRandom, - p.serverRandom, - keccak256(p.serverEphemeralKey), - p.transcriptRoot, - p.timestamp - ) - ); - } - - /// @dev A fully valid id-only github honor proof + backend sig + resource, for - /// verifyProof-direct guard tests that then tamper ONE field. - function _validGithubProof(string memory resourceType, string memory requestPath, bytes memory body) - internal - view - returns (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) - { - string memory resourceId = "repo/guard/comment/1"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", resourceType, ":", resourceId)); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - notaryProof = _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - backendSig = _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - resource = ResourceInfo({ - platform: "api.github.com", resourceType: resourceType, resourceId: resourceId, requestPath: requestPath - }); - } - - // 1. Unknown resource type (no seeded prefix) → UnknownResourceType. - function test_UnknownResourceType_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("bogus", "/repos/owner/repo/issues/comments/1", body); - vm.expectRevert(UnknownResourceType.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 2. Valid prefix ("issue_comment" → "/repos/") but the request path lacks it. - // The endpoint leaf is built over the SAME wrong path so it still verifies; - // the prefix check (before sigs) fires first → RequestPathMismatch. - function test_RequestPathMismatch_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/wrong/path", body); - vm.expectRevert(RequestPathMismatch.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 3. sender.author is not a substring of revealedAuthor → AuthorNotInRevealedField - // (the author-id leaf is valid, so the check that fires is this one). - function test_AuthorNotInRevealedField_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - vm.expectRevert(AuthorNotInRevealedField.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "notinside", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 4. verifyProof with the platform's templates cleared → TemplateNotFound (the - // empty-templates guard at the verifyProof level). - function test_TemplateNotFound_emptyTemplates_reverts() public { - bank.clearPlatformTemplates("api.github.com"); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - vm.expectRevert(TemplateNotFound.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 5. A valid proof over an unrelated body that matches no template → matchAny - // returns false → TemplateNotFound. - function test_TemplateNotFound_noMatch_reverts() public { - bytes memory body = abi.encodePacked("totally unrelated text"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - vm.expectRevert(TemplateNotFound.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 6. Notary digest signed by a non-notary key → InvalidNotarySignature. - function test_InvalidNotarySignature_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - notaryProof.notarySig = _sign(uint256(0xBAD), _notaryDigest(notaryProof)); - vm.expectRevert(InvalidNotarySignature.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 7. proof.domainHash points at a different domain (and the notary signs THAT), - // but the domain leaf is over the real platform → DomainHashMismatch. - function test_DomainHashMismatch_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - notaryProof.domainHash = keccak256("wrong.domain"); - notaryProof.notarySig = _sign(NOTARY_KEY, _notaryDigest(notaryProof)); - vm.expectRevert(DomainHashMismatch.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 8. An over-long merkle path (33 > 32) on the first-verified (body) leaf → - // MerklePathTooLong (before the Merkle check). - function test_MerklePathTooLong_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - notaryProof.bodyMerklePath = new bytes32[](33); - vm.expectRevert(MerklePathTooLong.selector); - bank.verifyProof( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - _idFor("bob") - ); - } - - // 9. _executeTransfer: sender's id was never registered (receiver is) → the - // sender resolveById returns 0 → SenderNotRegistered. - function test_SenderNotRegistered_executeTransfer_reverts() public { - _walletFor("github", "bob", BOB_PK); // receiver registered - _fund("github", "bob", 1000e18); - - string memory resourceId = "repo/snr/comment/1"; - string memory requestPath = "/repos/owner/repo/issues/comments/1"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 400 TST"); - bytes memory authorBytes = bytes("ghost"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("ghost"), '"'); - - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(SenderNotRegistered.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "ghost", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // 11. The by-ADDRESS webTransferV2 overload with token == address(0) → ZeroToken. - function test_ZeroToken_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - vm.expectRevert(ZeroToken.selector); - bank.webTransferV2( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - address(0), - "5", - 5e18, - "" - ); - } - - // 12. sourceUrl present but not https → SourceUrlNotHttps. - function test_SourceUrlNotHttps_reverts() public { - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - vm.expectRevert(SourceUrlNotHttps.selector); - bank.webTransferV2( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "5", - 5e18, - "http://github.com/owner/repo" - ); - } - - // 13. sourceUrl https but not under the platform web prefix → SourceUrlDomainMismatch. - // The underlying transfer is otherwise valid (removing the guard settles it). - function test_SourceUrlDomainMismatch_reverts() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - _fund("github", "alice", 1000e18); - - string memory resourceId = "repo/srcurl/comment/1"; - string memory requestPath = "/repos/owner/repo/issues/comments/1"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 400 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(SourceUrlDomainMismatch.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "https://evil.com/owner/repo" - ); - } - - // ── Recipient-less structural guards (LibRecipient) ────────────────── - // Reached end-to-end: a valid recipient-less X body makes verifyTemplate flag - // recipientLess, so webTransferV2 calls verifyRecipientIdLeaf with the tampered - // reply/quote field BEFORE the rest of proof verification. - - ResourceInfo internal _rlResource = ResourceInfo({ - platform: "api.x.com", - resourceType: "tweet", - resourceId: "tweet-rl-guard", - requestPath: "/2/tweets?ids=tweet-rl-guard" - }); - - function _rlBody() internal pure returns (bytes memory) { - return abi.encodePacked("@dyaka_agent honor with 400 TST"); - } - - function _rlSender() internal view returns (SenderInfo memory) { - return SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }); - } - - function _rlBackendSig() internal view returns (BackendSig memory) { - bytes32 uid = keccak256(abi.encodePacked("api.x.com", ":", "tweet", ":", "tweet-rl-guard")); - return _buildBackendSigWithId( - uid, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - _idFor("bob"), - block.timestamp - ); - } - - function _rlSubmit(NotaryTlsProof memory notaryProof) internal { - bank.webTransferV2( - _rlResource, - _rlSender(), - _rlBody(), - notaryProof, - _rlBackendSig(), - "bob", - _idFor("bob"), - "TST", - "400", - 400e18, - "" - ); - } - - // 20. revealedReceiverId shorter than the "in_reply_to_user_id": key. - function test_ReplyLeafTooShort_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithReceiverId( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes('"in_reply"'), - block.timestamp - ); - vm.expectRevert(ReplyLeafTooShort.selector); - _rlSubmit(notaryProof); - } - - // 21. revealedReceiverId long enough but not the in_reply_to_user_id key. - function test_ReplyLeafNotInReplyTo_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithReceiverId( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes('"in_reply_to_user_XX":"9"'), - block.timestamp - ); - vm.expectRevert(ReplyLeafNotInReplyTo.selector); - _rlSubmit(notaryProof); - } - - // 28. reply target value is empty ("") → EmptyQuotedValue (via _readQuotedValue). - function test_EmptyQuotedValue_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithReceiverId( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes('"in_reply_to_user_id":""'), - block.timestamp - ); - vm.expectRevert(EmptyQuotedValue.selector); - _rlSubmit(notaryProof); - } - - // 22. quoted ref shorter than 2 bytes → QuotedRefTooShort. - function test_QuotedRefTooShort_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes("{"), - abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'), - block.timestamp - ); - vm.expectRevert(QuotedRefTooShort.selector); - _rlSubmit(notaryProof); - } - - // 23. quoted ref not a {...} object → QuotedRefNotObject. - function test_QuotedRefNotObject_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes("XY"), - abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'), - block.timestamp - ); - vm.expectRevert(QuotedRefNotObject.selector); - _rlSubmit(notaryProof); - } - - // 24. quoted ref with a nested { } inside → QuotedRefNotFlatObject. - function test_QuotedRefNotFlatObject_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes("{{}}"), - abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'), - block.timestamp - ); - vm.expectRevert(QuotedRefNotFlatObject.selector); - _rlSubmit(notaryProof); - } - - // 25. flat quoted ref with type:quoted but no "id" → QuotedRefMissingId. - function test_QuotedRefMissingId_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes('{"type":"quoted"}'), - abi.encodePacked('"tweets":[{"id":"qt-99","author_id":"', _idFor("bob"), '"'), - block.timestamp - ); - vm.expectRevert(QuotedRefMissingId.selector); - _rlSubmit(notaryProof); - } - - // 26. includes-tweets leaf shorter than the "tweets":[{ key → QuotedAuthorLeafTooShort. - function test_QuotedAuthorLeafTooShort_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes('{"type":"quoted","id":"qt-99"}'), - bytes('"tweets"'), - block.timestamp - ); - vm.expectRevert(QuotedAuthorLeafTooShort.selector); - _rlSubmit(notaryProof); - } - - // 27. includes-tweets leaf has the qid join but no author_id → QuotedAuthorMissingAuthorId. - function test_QuotedAuthorMissingAuthorId_reverts() public { - NotaryTlsProof memory notaryProof = _buildBankProofWithQuotedAuthor( - "api.x.com", - _rlResource.requestPath, - _rlBody(), - bytes("alice"), - abi.encodePacked('"author_id":"', _idFor("alice"), '"'), - bytes('{"type":"quoted","id":"qt-99"}'), - bytes('"tweets":[{"id":"qt-99"'), - block.timestamp - ); - vm.expectRevert(QuotedAuthorMissingAuthorId.selector); - _rlSubmit(notaryProof); - } - - // 29. amountStr with two decimal points → MultipleDecimalPoints (parseDecimalAmount). - // The explicit github template matches "1.2.3" as the amount, then _webTransfer - // parses it. amount (999e18) is deliberately non-matching so removing the guard - // yields AmountWeiMismatch, not this error. - function test_MultipleDecimalPoints_reverts() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - - string memory resourceId = "repo/dec/comment/1"; - string memory requestPath = "/repos/owner/repo/issues/comments/1"; - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 1.2.3 TST"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - vm.expectRevert(LibString.MultipleDecimalPoints.selector); - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "1.2.3", - 999e18, - "" - ); - } - - // 31. by-name webTransferV2 for a platform with NO templates → LibTemplate - // .verifyTemplate hits count == 0 → TemplateNotFound (distinct from the - // verifyProof-level guard; removing it yields NoTemplateMatch instead). - function test_TemplateNotFound_verifyTemplateEmpty_reverts() public { - bank.clearPlatformTemplates("api.github.com"); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 5 TST"); - (NotaryTlsProof memory notaryProof, BackendSig memory backendSig, ResourceInfo memory resource) = - _validGithubProof("issue_comment", "/repos/owner/repo/issues/comments/1", body); - vm.expectRevert(TemplateNotFound.selector); - bank.webTransferV2( - resource, - SenderInfo({ - author: "alice", - revealedAuthor: bytes("alice"), - revealedAuthorId: abi.encodePacked('"author_id":"', _idFor("alice"), '"') - }), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "TST", - "5", - 5e18, - "" - ); - } -} diff --git a/solidity/contracts/transfer/test/IntegrationNewFeatures.t.sol b/solidity/contracts/transfer/test/IntegrationNewFeatures.t.sol deleted file mode 100644 index c9431137..00000000 --- a/solidity/contracts/transfer/test/IntegrationNewFeatures.t.sol +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IntegrationTest} from "./Integration.t.sol"; -import {IBank} from "../bank/IBank.sol"; -import {WebWallet} from "../../login/WebWallet.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @title Integration tests for transfer_within, balanceOf, balanceOfTotal -/// @dev Extends the existing IntegrationTest with new story chapters. -contract IntegrationNewFeaturesTest is IntegrationTest { - // ═══════════════════════════════════════════════════════════════ - // Helper: transfer_within via execute() - // ═══════════════════════════════════════════════════════════════ - - function _transferWithin( - WebWallet sender, - uint256 senderPk, - string memory receiverPlatform, - string memory receiverHandle, - uint256 amount - ) internal { - bytes memory data = abi.encodeCall( - IBank.transfer_within, - (_domain(receiverPlatform), receiverHandle, _idFor(receiverHandle), address(token), amount) - ); - _execute(sender, address(bank), data, senderPk); - } - - function _approveBank(WebWallet wallet, uint256 amount, uint256 pk) internal { - bytes memory data = abi.encodeCall(IERC20.approve, (address(bank), amount)); - _execute(wallet, address(token), data, pk); - } - - // ═══════════════════════════════════════════════════════════════ - // CHAPTER: transfer_within flow - // ═══════════════════════════════════════════════════════════════ - - /// @notice Full transfer_within story: registered→registered, registered→unregistered, then late registration - function test_story_transferWithinFlow() public { - // 1. Alice registers via OAuth, deposits 500 USDC into Bank - WebWallet alice = _alice(); - _fund("x", "alice", 500e18); - _assertBankBalance(alice, 500e18); - - // 2. Bob registers via OAuth - WebWallet bob = _bob(); - - // 3. Alice calls transfer_within("x", "bob", usdc, 200) via execute() - _transferWithin(alice, ALICE_PK, "x", "bob", 200e18); - - // 4. Assert Alice's balanceOf = 300, Bob's balanceOf = 200 - _assertBankBalance("x", "alice", 300e18); - _assertBankBalance("x", "bob", 200e18); - _assertBankBalance(alice, 300e18); - _assertBankBalance(bob, 200e18); - - // 5. Alice calls transfer_within to an unregistered handle "charlie" - _transferWithin(alice, ALICE_PK, "x", "charlie", 100e18); - - // 6. Assert balanceOf("x", "charlie", usdc) = 100 in unregistered slot - _assertBankBalance("x", "charlie", 100e18); - _assertUnregisteredBalance("x", "charlie", 100e18); - _assertBankBalance(alice, 200e18); - - // 7. Charlie later registers — balanceOf reflects the migrated balance - WebWallet charlie = _charlie(); - _assertBankBalance(charlie, 100e18); - _assertBankBalance("x", "charlie", 100e18); - } - - // ═══════════════════════════════════════════════════════════════ - // CHAPTER: balanceOf / balanceOfTotal - // ═══════════════════════════════════════════════════════════════ - - /// @notice Full balance view story: zero → deposit → wallet ERC20 → cross-check variants - function test_story_balanceViews() public { - // 1. Fresh Alice wallet with 0 balance - WebWallet alice = _alice(); - assertEq(bank.balanceOf(address(alice), address(token)), 0); - assertEq(bank.balanceOfTotal(address(alice), address(token)), 0); - - // 2. Alice deposits 300 USDC into Bank - _fund("x", "alice", 300e18); - assertEq(bank.balanceOf(address(alice), address(token)), 300e18); - assertEq(bank.balanceOfTotal(address(alice), address(token)), 300e18); - - // 3. Alice also holds 100 USDC directly in her WebWallet (ERC20 transfer) - _fundWallet(alice, 100e18); - assertEq(bank.balanceOf(address(alice), address(token)), 300e18); // Bank-only unchanged - assertEq(bank.balanceOfTotal(address(alice), address(token)), 400e18); // Bank + wallet ERC20 - - // 4. Test both address and (platform, handle) variants return same values - assertEq( - bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(token)), - bank.balanceOf(address(alice), address(token)) - ); - assertEq( - bank.balanceOfTotal("api.x.com", "alice", _idFor("alice"), address(token)), - bank.balanceOfTotal(address(alice), address(token)) - ); - } - - // ═══════════════════════════════════════════════════════════════ - // CHAPTER: transfer_within auto-top-up - // ═══════════════════════════════════════════════════════════════ - - /// @notice Auto-top-up story: 0 Bank balance, 500 in wallet, transfer pulls from wallet - function test_story_transferWithinAutoTopUp() public { - // Setup: Alice and Bob registered - WebWallet alice = _alice(); - WebWallet bob = _bob(); - - // 1. Alice has 0 Bank balance but 500 USDC in WebWallet - _fundWallet(alice, 500e18); - _assertBankBalance(alice, 0); - _assertTokenBalance(alice, 500e18); - - // Alice approves Bank to pull from her wallet - _approveBank(alice, type(uint256).max, ALICE_PK); - - // 2. Alice calls transfer_within for 200 USDC - _transferWithin(alice, ALICE_PK, "x", "bob", 200e18); - - // 3. Assert Bank pulled 200 from WebWallet, credited Bob - _assertBankBalance(alice, 0); // All pulled, all sent - _assertTokenBalance(alice, 300e18); // 500 - 200 shortfall pulled - _assertBankBalance(bob, 200e18); // Bob received 200 - } -} diff --git a/solidity/contracts/transfer/test/LibStringGuards.t.sol b/solidity/contracts/transfer/test/LibStringGuards.t.sol deleted file mode 100644 index 47b600bb..00000000 --- a/solidity/contracts/transfer/test/LibStringGuards.t.sol +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {LibString} from "../bank/libraries/LibString.sol"; - -/// @dev Test-only harness exposing LibString.substringCalldata. LibTemplate only -/// ever calls it with in-bounds ranges, so its InvalidRange guard is -/// unreachable end-to-end — this isolates it. -contract LibStringHarness { - function substr(bytes calldata data, uint256 start, uint256 end) external pure returns (string memory) { - return LibString.substringCalldata(data, start, end); - } -} - -contract LibStringGuardsTest is Test { - LibStringHarness h; - - function setUp() public { - h = new LibStringHarness(); - } - - // 30. substringCalldata with end > data.length → InvalidRange (removing the - // guard yields an out-of-bounds slice panic instead). - function test_InvalidRange_reverts() public { - bytes memory data = bytes("hello"); - vm.expectRevert(LibString.InvalidRange.selector); - h.substr(data, 0, data.length + 1); - } -} diff --git a/solidity/contracts/transfer/test/NativeAsset.t.sol b/solidity/contracts/transfer/test/NativeAsset.t.sol deleted file mode 100644 index f313e4cc..00000000 --- a/solidity/contracts/transfer/test/NativeAsset.t.sol +++ /dev/null @@ -1,460 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../../login/Registry.sol"; -import {IBank} from "../bank/IBank.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {ResourceInfo, SenderInfo, BackendSig, NotaryTlsProof} from "../bank/BankTypes.sol"; -import {NativeAmountMismatch, NativeTransferFailed, AlreadyRegistered} from "../bank/BankErrors.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; -import {WalletFactory} from "../../login/WalletFactory.sol"; -import {WebWallet} from "../../login/WebWallet.sol"; -import {MockERC20} from "../MockERC20.sol"; - -/// @dev Refuses incoming ETH so a native withdraw's low-level call fails. -contract RejectEther { - receive() external payable { - revert("no ether"); - } -} - -contract NativeAssetTest is Test, BankDiamondDeployer { - uint256 constant NOTARY_KEY = uint256(keccak256("notary")); - uint256 constant BACKEND_KEY = uint256(keccak256("backend")); - address NOTARY; - address BACKEND; - - Registry registry; - IBank bank; - WalletFactory factory; - - uint256 constant ALICE_PK = 0xA001; - uint256 constant BOB_PK = 0xB001; - uint256 private _transferCounter; - - function setUp() public { - NOTARY = vm.addr(NOTARY_KEY); - BACKEND = vm.addr(BACKEND_KEY); - - WebWallet walletImpl = new WebWallet(); - Registry rImpl = new Registry(); - WalletFactory fImpl = new WalletFactory(); - - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), - abi.encodeCall(WalletFactory.initialize, (address(this), address(walletImpl), address(1))) - ) - ) - ); - Notary notaryReg = deployNotary(address(this), NOTARY); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall(Registry.initialize, (address(notaryReg), BACKEND, address(factory), address(this))) - ) - ) - ); - factory.setRegistry(address(registry)); - bank = IBank(deployBankDiamond(address(this), address(notaryReg), BACKEND, address(registry))); - - // Register native asset - bank.registerToken("ETH", address(0)); - - // Register platforms used in tests (X-style quoted id binding). - registry.setPlatform("api.x.com", "api.test.com/endpoint", '"username":"', '"id":"', '"'); - registry.setPlatform("api.github.com", "api.test.com/endpoint", '"username":"', '"id":"', '"'); - - vm.warp(1_000_000); - vm.deal(address(this), 1000 ether); - } - - /// Deterministic immutable id for a handle (escrow keyed by id). - function _idFor(string memory handle) internal pure returns (string memory) { - return string(abi.encodePacked("id_", handle)); - } - - // ─── Helpers ───────────────────────────────────────────────── - - function _domain(string memory platform) internal pure returns (string memory) { - if (keccak256(bytes(platform)) == keccak256("x")) return "api.x.com"; - if (keccak256(bytes(platform)) == keccak256("github")) return "api.github.com"; - return platform; - } - - function _walletFor(string memory platform, string memory handle, uint256 pk) internal returns (WebWallet) { - string memory domain = _domain(platform); - string memory userId = _idFor(handle); - address sessionAddr = vm.addr(pk); - Registry.FullTlsProof memory proof = _buildRegistryProof( - domain, handle, userId, "api.test.com/endpoint", '"username":"', sessionAddr, block.timestamp - ); - registry.register_session(proof, domain, handle, userId, "api.test.com/endpoint"); - return WebWallet(payable(registry.resolve(domain, handle))); - } - - function _alice() internal returns (WebWallet) { - return _walletFor("x", "alice", ALICE_PK); - } - - function _bob() internal returns (WebWallet) { - return _walletFor("x", "bob", BOB_PK); - } - - function _execute(WebWallet wallet, address target, uint256 value, bytes memory data, uint256 pk) internal { - uint256 currentNonce = wallet.nonce(); - bytes32 digest = - keccak256(abi.encode(target, value, keccak256(data), currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(pk, digest); - wallet.execute(target, value, data, sigs); - } - - function _execute(WebWallet wallet, address target, bytes memory data, uint256 pk) internal { - _execute(wallet, target, 0, data, pk); - } - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - function _leaf(string memory prefix, bytes memory value) internal pure returns (bytes32) { - return keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - } - - function _h(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - // 4-leaf tree (D, U, E, Id) — id bound via the quoted "id":"" recv leaf. - function _buildRegistryProof( - string memory domain, - string memory username, - string memory userId, - string memory endpoint, - string memory handlePrefix, - address userAddress, - uint256 ts - ) internal view returns (Registry.FullTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(111)); - bytes32 serverRandom = bytes32(uint256(222)); - bytes memory serverEphKey = hex"deadbeef"; - bytes32 domainHash = keccak256(bytes(domain)); - - bytes32 leafD = _leaf("domain:", bytes(domain)); - bytes32 leafU = _leaf("recv:", abi.encodePacked(handlePrefix, username, '"')); - bytes32 leafE = _leaf("endpoint:", bytes(endpoint)); - bytes32 leafId = _leaf("recv:", abi.encodePacked('"id":"', userId, '"')); - - bytes32 hDU = _h(leafD, leafU); - bytes32 hEId = _h(leafE, leafId); - bytes32 transcriptRoot = _h(hDU, hEId); - - bytes32[] memory dp = new bytes32[](2); - dp[0] = leafU; - dp[1] = hEId; - bytes32[] memory up = new bytes32[](2); - up[0] = leafD; - up[1] = hEId; - bytes32[] memory ep = new bytes32[](2); - ep[0] = leafId; - ep[1] = hDU; - bytes32[] memory idp = new bytes32[](2); - idp[0] = leafE; - idp[1] = hDU; - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - bytes32 backendDigest = keccak256(abi.encode(userAddress, address(0), transcriptRoot, ts)); - - proof = Registry.FullTlsProof({ - notarySignature: _sign(NOTARY_KEY, notaryDigest), - backendSignature: _sign(BACKEND_KEY, backendDigest), - userAddress: userAddress, - walletAddress: address(0), - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - domainPath: dp, - usernamePath: up, - endpointPath: ep, - idPath: idp - }); - } - - /// 5-leaf proof builder (adds the author-id recv leaf) — id-only Bank always - /// verifies the author-id Merkle leaf. Ported verbatim from Integration.t.sol - /// (`_h` is the sorted-pair hash, same as Integration's `_hp`). - function _buildBankProofWithId( - string memory apiHost, - string memory requestPath, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - uint256 ts - ) internal view returns (NotaryTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(333)); - bytes32 serverRandom = bytes32(uint256(444)); - bytes memory serverEphKey = hex"cafebabe"; - bytes32 domainHash = keccak256(bytes(apiHost)); - - bytes32 leafD = _leaf("domain:", bytes(apiHost)); - bytes32 leafE = _leaf("endpoint:", bytes(requestPath)); - bytes32 leafB = _leaf("recv:", revealedBody); - bytes32 leafA = _leaf("recv:", revealedAuthor); - bytes32 leafId = _leaf("recv:", revealedAuthorId); - - bytes32 hDE = _h(leafD, leafE); - bytes32 hBA = _h(leafB, leafA); - bytes32 root4 = _h(hDE, hBA); - bytes32 transcriptRoot = _h(root4, leafId); - - bytes32[] memory bodyPath = new bytes32[](3); - (bodyPath[0], bodyPath[1], bodyPath[2]) = (leafA, hDE, leafId); - bytes32[] memory authorPath = new bytes32[](3); - (authorPath[0], authorPath[1], authorPath[2]) = (leafB, hDE, leafId); - bytes32[] memory domainPath = new bytes32[](3); - (domainPath[0], domainPath[1], domainPath[2]) = (leafE, hBA, leafId); - bytes32[] memory endpointPath = new bytes32[](3); - (endpointPath[0], endpointPath[1], endpointPath[2]) = (leafD, hBA, leafId); - bytes32[] memory authorIdPath = new bytes32[](1); - authorIdPath[0] = root4; - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - - proof = NotaryTlsProof({ - notarySig: _sign(NOTARY_KEY, notaryDigest), - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - bodyMerklePath: bodyPath, - authorMerklePath: authorPath, - domainMerklePath: domainPath, - endpointMerklePath: endpointPath, - authorIdMerklePath: authorIdPath, - receiverIdMerklePath: new bytes32[](0), - revealedReceiverId: "", - quotedRefMerklePath: new bytes32[](0), - revealedQuotedRef: "", - quotedAuthorMerklePath: new bytes32[](0), - revealedQuotedAuthorId: "" - }); - } - - function _buildBackendSigWithId( - bytes32 uid, - bytes memory revealedBody, - bytes memory revealedAuthor, - bytes memory revealedAuthorId, - string memory receiverUserId, - uint256 ts - ) internal pure returns (BackendSig memory) { - bytes32 digest = keccak256( - abi.encodePacked(uid, revealedBody, revealedAuthor, revealedAuthorId, keccak256(bytes(receiverUserId)), ts) - ); - return BackendSig({sig: _sign(BACKEND_KEY, digest), timestamp: ts}); - } - - // ─── Tests ─────────────────────────────────────────────────── - - function test_registerNativeToken() public view { - // Native ETH registered in setUp — assert the reverse map records it. - assertEq(bank.tokenName(address(0)), "ETH"); - } - - function test_depositNativeToRegisteredUser() public { - WebWallet alice = _alice(); - bank.deposit{value: 1 ether}("api.x.com", "alice", _idFor("alice"), address(0), 1 ether); - assertEq(bank.balanceOf(address(alice), address(0)), 1 ether); - } - - function test_depositNativeToUnregisteredUser() public { - bank.deposit{value: 2 ether}("api.x.com", "unknown", _idFor("unknown"), address(0), 2 ether); - bytes32 key = keccak256(abi.encode("id:v1", "api.x.com", _idFor("unknown"))); - assertEq(bank.unregisteredBalances(key, address(0)), 2 ether); - } - - function test_withdrawNativeViaExecute() public { - WebWallet alice = _alice(); - bank.deposit{value: 5 ether}("api.x.com", "alice", _idFor("alice"), address(0), 5 ether); - - uint256 balBefore = address(alice).balance; - bytes memory data = abi.encodeCall(IBank.withdraw, (address(alice), address(0), 3 ether)); - _execute(alice, address(bank), data, ALICE_PK); - assertEq(address(alice).balance, balBefore + 3 ether); - assertEq(bank.balanceOf(address(alice), address(0)), 2 ether); - } - - function test_transferWithinNative() public { - WebWallet alice = _alice(); - WebWallet bob = _bob(); - bank.deposit{value: 10 ether}("api.x.com", "alice", _idFor("alice"), address(0), 10 ether); - - bytes memory data = - abi.encodeCall(IBank.transfer_within, ("api.x.com", "bob", _idFor("bob"), address(0), 4 ether)); - _execute(alice, address(bank), data, ALICE_PK); - - assertEq(bank.balanceOf(address(alice), address(0)), 6 ether); - assertEq(bank.balanceOf(address(bob), address(0)), 4 ether); - } - - function test_transferWithinNative_insufficientBalance_reverts() public { - WebWallet alice = _alice(); - _bob(); - - bytes memory data = - abi.encodeCall(IBank.transfer_within, ("api.x.com", "bob", _idFor("bob"), address(0), 1 ether)); - // Build the execute call manually to use vm.expectRevert - uint256 currentNonce = alice.nonce(); - bytes32 digest = keccak256( - abi.encode(address(bank), uint256(0), keccak256(data), currentNonce, block.chainid, address(alice)) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(ALICE_PK, digest); - vm.expectRevert(); - alice.execute(address(bank), 0, data, sigs); - } - - function test_webTransferNative() public { - _walletFor("github", "alice", ALICE_PK); - _walletFor("github", "bob", BOB_PK); - - // Deposit native for alice - bank.deposit{value: 10 ether}("api.github.com", "alice", _idFor("alice"), address(0), 10 ether); - - // Build webTransfer proof - _transferCounter++; - string memory resourceId = string(abi.encodePacked("repo/", vm.toString(_transferCounter), "/comment/1")); - string memory requestPath = - string(abi.encodePacked("/repos/owner/repo/issues/comments/", vm.toString(_transferCounter))); - bytes32 uid = keccak256(abi.encodePacked("api.github.com", ":", "issue_comment", ":", resourceId)); - bytes memory body = abi.encodePacked("@dyaka-agent honor @bob with 3 ETH"); - bytes memory authorBytes = bytes("alice"); - bytes memory authorId = abi.encodePacked('"author_id":"', _idFor("alice"), '"'); - - NotaryTlsProof memory notaryProof = - _buildBankProofWithId("api.github.com", requestPath, body, authorBytes, authorId, block.timestamp); - BackendSig memory backendSig = - _buildBackendSigWithId(uid, body, authorBytes, authorId, _idFor("bob"), block.timestamp); - - ResourceInfo memory resource = ResourceInfo({ - platform: "api.github.com", resourceType: "issue_comment", resourceId: resourceId, requestPath: requestPath - }); - - bank.webTransferV2( - resource, - SenderInfo({author: "alice", revealedAuthor: authorBytes, revealedAuthorId: authorId}), - body, - notaryProof, - backendSig, - "bob", - _idFor("bob"), - "ETH", - "3", - 3 ether, - "" - ); - - address aliceWallet = registry.resolve("api.github.com", "alice"); - address bobWallet = registry.resolve("api.github.com", "bob"); - assertEq(bank.balanceOf(aliceWallet, address(0)), 7 ether); - assertEq(bank.balanceOf(bobWallet, address(0)), 3 ether); - } - - function test_depositNative_wrongAmount_reverts() public { - _alice(); - vm.expectRevert(NativeAmountMismatch.selector); - bank.deposit{value: 1 ether}("api.x.com", "alice", _idFor("alice"), address(0), 2 ether); - } - - function test_balanceOfNative() public { - WebWallet alice = _alice(); - bank.deposit{value: 7 ether}("api.x.com", "alice", _idFor("alice"), address(0), 7 ether); - assertEq(bank.balanceOf("api.x.com", "alice", _idFor("alice"), address(0)), 7 ether); - assertEq(bank.balanceOf(address(alice), address(0)), 7 ether); - } - - function test_balanceOfTotalNative() public { - WebWallet alice = _alice(); - // Give wallet some native balance directly - vm.deal(address(alice), 3 ether); - bank.deposit{value: 5 ether}("api.x.com", "alice", _idFor("alice"), address(0), 5 ether); - - // balanceOfTotal should include wallet's native balance + bank balance - assertEq(bank.balanceOfTotal(address(alice), address(0)), 5 ether + 3 ether); - assertEq(bank.balanceOfTotal("api.x.com", "alice", _idFor("alice"), address(0)), 5 ether + 3 ether); - } - - function test_walletSendNativeToEOA() public { - WebWallet alice = _alice(); - vm.deal(address(alice), 10 ether); - address recipient = address(0xBEEF); - - // Alice sends ETH to recipient via execute() with value - _execute(alice, recipient, 2 ether, "", ALICE_PK); - - assertEq(recipient.balance, 2 ether); - assertEq(address(alice).balance, 8 ether); - } - - // ═══════════════════════════════════════════════════════════════ - // MUTATION-GUARD NEGATIVE TESTS - // ═══════════════════════════════════════════════════════════════ - - // 16. Withdraw native to a contract whose receive() reverts → the low-level - // call fails → NativeTransferFailed. Removing the guard would silently - // "succeed" (funds debited, transfer failed), killing the mutant. - function test_NativeTransferFailed_reverts() public { - WebWallet alice = _alice(); - bank.deposit{value: 5 ether}("api.x.com", "alice", _idFor("alice"), address(0), 5 ether); - - RejectEther sink = new RejectEther(); - vm.prank(address(alice)); - vm.expectRevert(NativeTransferFailed.selector); - bank.withdraw(address(sink), address(0), 3 ether); - } - - // 17. Re-registering the native asset (address(0), already registered in setUp) - // hits the native branch of AdminFacet.registerToken → AlreadyRegistered. - function test_AlreadyRegistered_native_reverts() public { - // "ETH" -> address(0) was registered in setUp. - vm.expectRevert(AlreadyRegistered.selector); - bank.registerToken("ETH", address(0)); - } - - receive() external payable {} -} diff --git a/solidity/contracts/transfer/test/PausableFlow.t.sol b/solidity/contracts/transfer/test/PausableFlow.t.sol deleted file mode 100644 index d464d81a..00000000 --- a/solidity/contracts/transfer/test/PausableFlow.t.sol +++ /dev/null @@ -1,157 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; -import {Registry} from "../../login/Registry.sol"; -import {IBank} from "../bank/IBank.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {ResourceInfo, SenderInfo, BackendSig, NotaryTlsProof} from "../bank/BankTypes.sol"; -import {EnforcedPause, InsufficientBalance} from "../bank/BankErrors.sol"; - -/// @notice Pausable emergency-stop / migration-cutover gate on Registry + Bank. -/// Proves: pause/unpause are owner-only; a gated entrypoint reverts -/// `EnforcedPause` while paused and resumes its normal behaviour after -/// unpause; admin/owner functions stay callable while paused. -contract PausableFlowTest is Test, BankDiamondDeployer { - Registry registry; - IBank bank; - address owner = address(this); - address notOwner = address(0xBEEF); - - function setUp() public { - Registry rImpl = new Registry(); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - // notary, backend, walletFactory, owner — all must be non-zero. - abi.encodeCall(Registry.initialize, (address(0x11), address(0x12), address(0x13), owner)) - ) - ) - ); - // real registry so withdraw's lazy _migrate (getUserIds) is a no-op, not a call to a codeless addr. - bank = IBank(deployBankDiamond(owner, address(0x21), address(0x22), address(registry))); - } - - // ── access control ───────────────────────────────────────────── - - function test_pause_onlyOwner() public { - vm.prank(notOwner); - vm.expectRevert(); - registry.pause(); - - vm.prank(notOwner); - vm.expectRevert(); - bank.pause(); - } - - function test_unpause_onlyOwner() public { - registry.pause(); - bank.pause(); - vm.prank(notOwner); - vm.expectRevert(); - registry.unpause(); - vm.prank(notOwner); - vm.expectRevert(); - bank.unpause(); - } - - // ── Registry: gated when paused, normal after unpause ────────── - - function test_registry_pausedBlocksRegistration() public { - registry.pause(); - vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); - registry.register_session_zk("api.x.com", ""); - - // Gate lifted → reaches the real path (no verifier configured). - registry.unpause(); - vm.expectRevert(Registry.ZkVerifierNotSet.selector); - registry.register_session_zk("api.x.com", ""); - } - - // ── Bank: gated when paused, normal after unpause ────────────── - - function test_bank_pausedBlocksWithdraw() public { - bank.pause(); - vm.expectRevert(EnforcedPause.selector); - bank.withdraw(owner, address(0), 1); - - // Gate lifted → reaches the real path (no balance to withdraw). - bank.unpause(); - vm.expectRevert(InsufficientBalance.selector); - bank.withdraw(owner, address(0), 1); - } - - /// Both webTransferV2 overloads move user funds, so a pause must block them - /// before any proof work. whenNotPaused runs ahead of proof validation, so a - /// paused call reverts EnforcedPause even with empty proof structs. - function test_bank_pausedBlocksWebTransfer() public { - ResourceInfo memory resource = ResourceInfo("api.x.com", "", "", ""); - NotaryTlsProof memory tls = NotaryTlsProof( - "", - bytes32(0), - bytes32(0), - bytes32(0), - "", - bytes32(0), - 0, - new bytes32[](0), - new bytes32[](0), - new bytes32[](0), - new bytes32[](0), - new bytes32[](0), - new bytes32[](0), - "", - new bytes32[](0), - "", - new bytes32[](0), - "" - ); - BackendSig memory sig = BackendSig("", 0); - - bank.pause(); - - // token-by-name overload - vm.expectRevert(EnforcedPause.selector); - bank.webTransferV2( - resource, SenderInfo("author", "", ""), "", tls, sig, "bob", "id_bob", "$X", "1", 1, "https://x.com/p/1" - ); - - // token-by-address overload - vm.expectRevert(EnforcedPause.selector); - bank.webTransferV2( - resource, - SenderInfo("author", "", ""), - "", - tls, - sig, - "bob", - "id_bob", - address(0xCAFE), - "1", - 1, - "https://x.com/p/1" - ); - } - - // ── admin/owner fns stay callable while paused ──────────────── - - function test_adminWorksWhilePaused() public { - registry.pause(); - bank.pause(); - - // Neither carries whenNotPaused — must still work (so migrate/rotate - // can run during a cutover pause). - registry.setBackend(address(0x99)); - bank.registerToken("$X", address(0xCAFE)); - - assertEq(registry.backend(), address(0x99), "admin setBackend blocked by pause"); - assertTrue(registry.paused(), "registry should be paused"); - assertTrue(bank.paused(), "bank should still be paused"); - // A whenNotPaused entrypoint (withdraw) still reverts EnforcedPause. - vm.expectRevert(EnforcedPause.selector); - bank.withdraw(owner, address(0), 1); - } -} diff --git a/solidity/contracts/transfer/test/RegistryBankRecycle.t.sol b/solidity/contracts/transfer/test/RegistryBankRecycle.t.sol deleted file mode 100644 index 236d8a55..00000000 --- a/solidity/contracts/transfer/test/RegistryBankRecycle.t.sol +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {IBank} from "../bank/IBank.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {RegistryTest} from "../../login/test/Registry.t.sol"; - -/// Registry × Bank integration. Lives here, not in Registry.t.sol, so the -/// login-side Registry tests carry no Bank dependency. Inherits RegistryTest -/// for its fixtures and `_registerXAliceId` helper. -contract RegistryBankRecycleTest is RegistryTest, BankDiamondDeployer { - // T12: Bank balance under A's id survives a handle recycle to B (I2/I3). - function test_T12_bankBalanceSurvivesRecycle() public { - // A Bank diamond wired to this registry. - IBank bank = IBank(deployBankDiamond(owner, notaryAddr, backendAddr, address(registry))); - bank.registerToken("ETH", address(0)); - - // A registers x.com/alice under id 100 and is funded. - address walletA = _registerXAliceId("100", userSessionAddr, block.timestamp); - bank.deposit{value: 1 ether}("x.com", "alice", "100", address(0), 1 ether); - assertEq(bank.balanceOf(walletA, address(0)), 1 ether); - - // B recycles the handle "alice" under a fresh id 200. - uint256 bPk = 0xB003; - address bAddr = vm.addr(bPk); - address walletB = _registerXAliceId("200", bAddr, block.timestamp); - - // Resolve now points to B, but A's funds are intact and reachable by id. - assertEq(registry.resolve("x.com", "alice"), walletB); - assertEq(registry.resolveById("x.com", "100"), walletA); - assertEq(bank.balanceOf(walletA, address(0)), 1 ether); - assertEq(bank.balanceOf(walletB, address(0)), 0); - } -} diff --git a/solidity/contracts/transfer/test/TemplateMatching.t.sol b/solidity/contracts/transfer/test/TemplateMatching.t.sol deleted file mode 100644 index 74ecfc7d..00000000 --- a/solidity/contracts/transfer/test/TemplateMatching.t.sol +++ /dev/null @@ -1,372 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {IBank} from "../bank/IBank.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {TemplateMissingToken, TemplateMissingAmount, TemplateMissingTokenAfterRecipient} from "../bank/BankErrors.sol"; -import {LibTemplate} from "../bank/libraries/LibTemplate.sol"; -import {LibString} from "../bank/libraries/LibString.sol"; -import {LibTemplateStorage} from "../bank/storage/LibTemplateStorage.sol"; - -/// @dev Unit harness for the template/id libraries the diamond actually runs. It -/// calls `LibTemplate`/`LibString` directly (its own diamond-storage slot), -/// so these tests exercise the SAME code the diamond does — no legacy Bank. -contract LibTemplateHarness { - function setPlatformTemplate(string calldata platform, string calldata tmpl) external { - LibTemplate.addTemplate(platform, tmpl); - } - - function platformTemplates(bytes32 key, uint256 i) external view returns (string memory) { - return LibTemplateStorage.store().platformTemplates[key][i]; - } - - function matchTemplate(bytes calldata data, bytes32 key, uint256 i) - external - view - returns (bool, string memory, string memory, string memory) - { - return LibTemplate.matchTemplate(data, key, i); - } - - function matchAnyTemplate(bytes calldata data, bytes32 key) - external - view - returns (bool, string memory, string memory) - { - (bool matched, string memory amt, string memory tok,) = LibTemplate.matchAnyTemplate(data, key); - return (matched, amt, tok); - } - - function extractId(bytes calldata s) external pure returns (string memory) { - return LibString.extractId(s); - } -} - -contract TemplateMatchingTest is Test, BankDiamondDeployer { - // `bank` is the diamond. `harness` calls the same libraries directly for the - // internal matcher/extractId paths the diamond has no external surface for. - IBank bank; - LibTemplateHarness harness; - - function setUp() public { - bank = IBank(deployBankDiamond(address(this), address(1), address(2), address(3))); - harness = new LibTemplateHarness(); - // Mirror the diamond's seeded templates into the harness so the library - // matching tests run against the exact same defaults (no hardcoding). - _mirror("api.x.com"); - _mirror("api.github.com"); - } - - function _mirror(string memory platform) internal { - uint256 n = bank.platformTemplateCount(platform); - for (uint256 i = 0; i < n; i++) { - harness.setPlatformTemplate(platform, bank.getPlatformTemplate(platform, i)); - } - } - - // ─── Template Parsing ───────────────────────────────────────── - - function test_defaultGithubTemplateParts_withOf() public view { - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("api.github.com", 0); - assertEq(prefix, "@dyaka-agent honor @"); - assertEq(afterRecipient, " with "); - assertEq(afterAmount, " of "); - assertEq(suffix, ""); - } - - function test_defaultGithubTemplateParts_withoutOf() public view { - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("api.github.com", 1); - assertEq(prefix, "@dyaka-agent honor @"); - assertEq(afterRecipient, " with "); - assertEq(afterAmount, " "); - assertEq(suffix, ""); - } - - function test_defaultXTemplateParts_withOf() public view { - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("api.x.com", 0); - assertEq(prefix, "@dyaka_agent honor @"); - assertEq(afterRecipient, " with "); - assertEq(afterAmount, " of "); - assertEq(suffix, ""); - } - - function test_defaultXTemplateParts_withoutOf() public view { - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("api.x.com", 1); - assertEq(prefix, "@dyaka_agent honor @"); - assertEq(afterRecipient, " with "); - assertEq(afterAmount, " "); - assertEq(suffix, ""); - } - - /// The diamond stores the raw template string verbatim, read via getPlatformTemplate. - function test_fullTemplateStringStored() public view { - assertEq( - bank.getPlatformTemplate("api.github.com", 0), "@dyaka-agent honor @{recipient} with {amount} of {token}" - ); - assertEq(bank.getPlatformTemplate("api.github.com", 1), "@dyaka-agent honor @{recipient} with {amount} {token}"); - } - - function test_setPlatformTemplateViaAdmin() public { - bank.setPlatformTemplate("newplatform", "@bot honor @{recipient} with {amount} {token}"); - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("newplatform", 0); - assertEq(prefix, "@bot honor @"); - assertEq(afterRecipient, " with "); - assertEq(afterAmount, " "); - assertEq(suffix, ""); - assertEq(bank.getPlatformTemplate("newplatform", 0), "@bot honor @{recipient} with {amount} {token}"); - } - - function test_templateWithMiddleWord() public { - bank.setPlatformTemplate("test", "@libid-bot send @{recipient} then {amount} of {token}"); - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("test", 0); - assertEq(prefix, "@libid-bot send @"); - assertEq(afterRecipient, " then "); - assertEq(afterAmount, " of "); - assertEq(suffix, ""); - } - - function test_templateWithSuffix() public { - bank.setPlatformTemplate("test", "pay @{recipient} now {amount} in {token} done"); - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("test", 0); - assertEq(prefix, "pay @"); - assertEq(afterRecipient, " now "); - assertEq(afterAmount, " in "); - assertEq(suffix, " done"); - } - - /// A template with {amount}/{token} but NO {recipient} is a recipient-less - /// placeholder template (recipient inferred off-chain): prefix runs up to - /// {amount} and afterRecipient is empty. - function test_setTemplateWithoutRecipient_isRecipientLess() public { - bank.setPlatformTemplate("implicit", "@dyaka-agent honor with {amount} {token}"); - (string memory prefix, string memory afterRecipient, string memory afterAmount, string memory suffix) = - bank.getTemplateParts("implicit", 0); - assertEq(prefix, "@dyaka-agent honor with "); - assertEq(afterRecipient, ""); - assertEq(afterAmount, " "); - assertEq(suffix, ""); - } - - /// A recipient-less template still requires {token} after {amount}. - function test_setTemplateRecipientLessRequiresToken() public { - vm.expectRevert(TemplateMissingToken.selector); - bank.setPlatformTemplate("bad", "@dyaka-agent honor with {amount}"); - } - - /// A template with no placeholders at all stays a simple literal. - function test_setTemplateNoPlaceholders_isLiteral() public { - bank.setPlatformTemplate("simple", "@dyaka-agent gm fam"); - (string memory prefix,,,) = bank.getTemplateParts("simple", 0); - assertEq(prefix, "@dyaka-agent gm fam"); - } - - function test_setTemplateRequiresAmountPlaceholder() public { - vm.expectRevert(TemplateMissingAmount.selector); - bank.setPlatformTemplate("bad", "@dyaka-agent honor @{recipient} with {token}"); - } - - function test_setTemplateRequiresTokenPlaceholder() public { - vm.expectRevert(TemplateMissingTokenAfterRecipient.selector); - bank.setPlatformTemplate("bad", "@dyaka-agent honor @{recipient} with {amount}"); - } - - function test_setTemplateRecipientMustBeBeforeAmount() public { - vm.expectRevert(TemplateMissingAmount.selector); - bank.setPlatformTemplate("bad", "@dyaka-agent {amount} @{recipient} {token}"); - } - - // ─── Multiple Templates ────────────────────────────────────── - - function test_multipleTemplatesPerPlatform() public { - bank.setPlatformTemplate("multi", "simple template A"); - bank.setPlatformTemplate("multi", "simple template B"); - assertEq(bank.platformTemplateCount("multi"), 2); - assertEq(bank.getPlatformTemplate("multi", 0), "simple template A"); - assertEq(bank.getPlatformTemplate("multi", 1), "simple template B"); - } - - function test_clearPlatformTemplates() public { - bank.setPlatformTemplate("clearme", "template A"); - bank.setPlatformTemplate("clearme", "template B"); - assertEq(bank.platformTemplateCount("clearme"), 2); - bank.clearPlatformTemplates("clearme"); - assertEq(bank.platformTemplateCount("clearme"), 0); - } - - function test_matchAnyTemplate_matchesSecondTemplate() public { - harness.setPlatformTemplate("multi", "first pattern"); - harness.setPlatformTemplate("multi", "second pattern"); - bytes memory data = bytes("this contains second pattern here"); - (bool ok,,) = harness.matchAnyTemplate(data, keccak256("multi")); - assertTrue(ok); - } - - function test_matchAnyTemplate_noMatch() public { - harness.setPlatformTemplate("multi", "first pattern"); - harness.setPlatformTemplate("multi", "second pattern"); - bytes memory data = bytes("this matches nothing"); - (bool ok,,) = harness.matchAnyTemplate(data, keccak256("multi")); - assertFalse(ok); - } - - // ─── Template Matching (JSON delimiters) ────────────────────── - - function test_matchTemplate_plainText_withoutOf() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.0001 $TIA"); - // Template index 1 = without "of" - (bool ok, string memory amount, string memory token,) = harness.matchTemplate(data, keccak256("api.x.com"), 1); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_plainText_withOf() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.0001 of $TIA"); - // Template index 0 = with "of" - (bool ok, string memory amount, string memory token,) = harness.matchTemplate(data, keccak256("api.x.com"), 0); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchAnyTemplate_withOf() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.0001 of $TIA"); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchAnyTemplate_withoutOf() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.0001 $TIA"); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_textBeforeCommand() public view { - bytes memory data = bytes( - "This is a comment with some text before the template.\n\n@dyaka_agent honor @alice with 0.0001 of $TIA, because we are generous." - ); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_textBeforeCommand_github() public view { - bytes memory data = bytes( - "This is the comment with some works before the main template.\n\n@dyaka-agent honor @alice-test-account with 0.0001 of $TIA, because we are generous." - ); - (bool ok, string memory amount, string memory token) = - harness.matchAnyTemplate(data, keccak256("api.github.com")); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_jsonEncoded() public view { - bytes memory data = bytes('@dyaka_agent honor @alice with 0.0001 $TIA"'); - (bool ok, string memory amount, string memory token,) = harness.matchTemplate(data, keccak256("api.x.com"), 1); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_jsonWithCommaAfterToken() public view { - bytes memory data = bytes('@dyaka_agent honor @alice with 0.0001 $TIA","next_field'); - (bool ok, string memory amount, string memory token,) = harness.matchTemplate(data, keccak256("api.x.com"), 1); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_jsonWithBraceAfterToken() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.0001 $TIA}"); - (bool ok, string memory amount, string memory token,) = harness.matchTemplate(data, keccak256("api.x.com"), 1); - assertTrue(ok); - assertEq(amount, "0.0001"); - assertEq(token, "$TIA"); - } - - // ─── Token character whitelist tests ───────────────────────── - - function test_matchTemplate_trailingExclamation() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.1 $TIA! great work"); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - assertEq(amount, "0.1"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_trailingQuestion() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.1 $TIA?"); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - assertEq(amount, "0.1"); - assertEq(token, "$TIA"); - } - - function test_matchTemplate_trailingPeriodNewline() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 0.1 $TIA.\nMore text"); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - // Dot is a valid token char, so it gets included - assertEq(amount, "0.1"); - assertEq(token, "$TIA."); - } - - function test_matchTemplate_dottedToken() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 100 USDC.e"); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - assertEq(amount, "100"); - assertEq(token, "USDC.e"); - } - - function test_matchTemplate_dottedTokenInSentence() public view { - bytes memory data = bytes("@dyaka_agent honor @alice with 100 USDC.e for her work"); - (bool ok, string memory amount, string memory token) = harness.matchAnyTemplate(data, keccak256("api.x.com")); - assertTrue(ok); - assertEq(amount, "100"); - assertEq(token, "USDC.e"); - } - - // ── extractId (sender id parsing) ───────────────────────────── - - function test_extractId_quoted() public view { - assertEq(harness.extractId(bytes('"author_id":"1234567890"')), "1234567890"); - } - - function test_extractId_bareComma() public view { - assertEq(harness.extractId(bytes('"id":123,')), "123"); - } - - function test_extractId_bareBrace() public view { - assertEq(harness.extractId(bytes('"id":456}')), "456"); - } - - function test_extractId_quotedNonNumeric() public view { - // Quoted ids are taken verbatim (X snowflakes, or test ids like id_alice). - assertEq(harness.extractId(bytes('"author_id":"id_alice"')), "id_alice"); - } - - function test_extractId_malformed_reverts() public { - vm.expectRevert(LibString.MalformedAuthorId.selector); - harness.extractId(bytes("noColonHere")); - } - - function test_extractId_emptyBare_reverts() public { - vm.expectRevert(LibString.EmptyAuthorId.selector); - harness.extractId(bytes('"id":,')); - } -} diff --git a/solidity/contracts/transfer/test/TransferWithin.t.sol b/solidity/contracts/transfer/test/TransferWithin.t.sol deleted file mode 100644 index a0a3b7d7..00000000 --- a/solidity/contracts/transfer/test/TransferWithin.t.sol +++ /dev/null @@ -1,364 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -import {Test} from "forge-std/Test.sol"; -import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../../login/Registry.sol"; -import {IBank} from "../bank/IBank.sol"; -import {BankDiamondDeployer} from "../script/BankDiamondDeployer.sol"; -import {SenderNotRegistered, ZeroAmount, InsufficientBalance, InsufficientAllowance} from "../bank/BankErrors.sol"; -import {Notary} from "../../notary/Notary.sol"; -import {deployNotary} from "../../notary/test/DeployNotary.sol"; -import {WalletFactory} from "../../login/WalletFactory.sol"; -import {WebWallet} from "../../login/WebWallet.sol"; -import {MockERC20} from "../MockERC20.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -contract TransferWithinTest is Test, BankDiamondDeployer { - uint256 constant NOTARY_KEY = uint256(keccak256("notary")); - uint256 constant BACKEND_KEY = uint256(keccak256("backend")); - - address NOTARY; - address BACKEND; - - Registry registry; - IBank bank; - WalletFactory factory; - MockERC20 token; - - uint256 constant ALICE_PK = 0xA001; - uint256 constant BOB_PK = 0xB001; - uint256 constant CHARLIE_PK = 0xC001; - - function setUp() public { - NOTARY = vm.addr(NOTARY_KEY); - BACKEND = vm.addr(BACKEND_KEY); - - WebWallet walletImpl = new WebWallet(); - Registry rImpl = new Registry(); - WalletFactory fImpl = new WalletFactory(); - - factory = WalletFactory( - address( - new ERC1967Proxy( - address(fImpl), - abi.encodeCall(WalletFactory.initialize, (address(this), address(walletImpl), address(1))) - ) - ) - ); - - Notary notaryReg = deployNotary(address(this), NOTARY); - registry = Registry( - address( - new ERC1967Proxy( - address(rImpl), - abi.encodeCall(Registry.initialize, (address(notaryReg), BACKEND, address(factory), address(this))) - ) - ) - ); - - factory.setRegistry(address(registry)); - bank = IBank(deployBankDiamond(address(this), address(notaryReg), BACKEND, address(registry))); - token = new MockERC20("Test", "TST"); - bank.registerToken("TST", address(token)); - - // Register platforms used in tests (X-style quoted id binding). - registry.setPlatform("api.x.com", "api.test.com/endpoint", '"username":"', '"id":"', '"'); - registry.setPlatform("api.github.com", "api.test.com/endpoint", '"username":"', '"id":"', '"'); - - vm.warp(1_000_000); - } - - /// Deterministic immutable id for a handle (escrow keyed by id). - function _idFor(string memory handle) internal pure returns (string memory) { - return string(abi.encodePacked("id_", handle)); - } - - // ─── Helpers ──────────────────────────────────────────────────── - - /// Map platform shortname to API domain for Registry registration. - function _domain(string memory platform) internal pure returns (string memory) { - if (keccak256(bytes(platform)) == keccak256("x")) return "api.x.com"; - if (keccak256(bytes(platform)) == keccak256("github")) return "api.github.com"; - return platform; - } - - function _walletFor(string memory platform, string memory handle, uint256 pk) internal returns (WebWallet) { - string memory domain = _domain(platform); - string memory userId = _idFor(handle); - address sessionAddr = vm.addr(pk); - Registry.FullTlsProof memory proof = _buildRegistryProof( - domain, handle, userId, "api.test.com/endpoint", '"username":"', sessionAddr, block.timestamp - ); - registry.register_session(proof, domain, handle, userId, "api.test.com/endpoint"); - return WebWallet(payable(registry.resolve(domain, handle))); - } - - function _fund(string memory platform, string memory handle, uint256 amount) internal { - token.mint(address(this), amount); - token.approve(address(bank), amount); - bank.deposit(_domain(platform), handle, _idFor(handle), address(token), amount); - } - - function _fundWallet(WebWallet wallet, uint256 amount) internal { - token.mint(address(wallet), amount); - } - - function _execute(WebWallet wallet, address target, bytes memory data, uint256 pk) internal { - uint256 currentNonce = wallet.nonce(); - bytes32 digest = - keccak256(abi.encode(target, uint256(0), keccak256(data), currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(pk, digest); - wallet.execute(target, 0, data, sigs); - } - - function _executeBatch( - WebWallet wallet, - address[] memory targets, - uint256[] memory values, - bytes[] memory datas, - uint256 pk - ) internal { - uint256 currentNonce = wallet.nonce(); - bytes32 dataHashes = keccak256(abi.encode(datas)); - bytes32 digest = - keccak256(abi.encode(targets, values, dataHashes, currentNonce, block.chainid, address(wallet))); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(pk, digest); - wallet.executeBatch(targets, values, datas, sigs); - } - - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - bytes32 ethHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, ethHash); - return abi.encodePacked(r, s, v); - } - - function _leaf(string memory prefix, bytes memory value) internal pure returns (bytes32) { - return keccak256(bytes.concat(keccak256(abi.encodePacked(prefix, value)))); - } - - function _h(bytes32 a, bytes32 b) internal pure returns (bytes32) { - return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); - } - - // 4-leaf tree (D, U, E, Id) — id bound via the quoted "id":"" recv leaf. - function _buildRegistryProof( - string memory domain, - string memory username, - string memory userId, - string memory endpoint, - string memory handlePrefix, - address userAddress, - uint256 ts - ) internal view returns (Registry.FullTlsProof memory proof) { - bytes32 clientRandom = bytes32(uint256(111)); - bytes32 serverRandom = bytes32(uint256(222)); - bytes memory serverEphKey = hex"deadbeef"; - bytes32 domainHash = keccak256(bytes(domain)); - - bytes32 leafD = _leaf("domain:", bytes(domain)); - bytes32 leafU = _leaf("recv:", abi.encodePacked(handlePrefix, username, '"')); - bytes32 leafE = _leaf("endpoint:", bytes(endpoint)); - bytes32 leafId = _leaf("recv:", abi.encodePacked('"id":"', userId, '"')); - - bytes32 hDU = _h(leafD, leafU); - bytes32 hEId = _h(leafE, leafId); - bytes32 transcriptRoot = _h(hDU, hEId); - - bytes32[] memory dp = new bytes32[](2); - dp[0] = leafU; - dp[1] = hEId; - bytes32[] memory up = new bytes32[](2); - up[0] = leafD; - up[1] = hEId; - bytes32[] memory ep = new bytes32[](2); - ep[0] = leafId; - ep[1] = hDU; - bytes32[] memory idp = new bytes32[](2); - idp[0] = leafE; - idp[1] = hDU; - - bytes32 notaryDigest = keccak256( - abi.encode( - block.chainid, - address(registry), - domainHash, - clientRandom, - serverRandom, - keccak256(serverEphKey), - transcriptRoot, - ts - ) - ); - bytes32 backendDigest = keccak256(abi.encode(userAddress, address(0), transcriptRoot, ts)); - - proof = Registry.FullTlsProof({ - notarySignature: _sign(NOTARY_KEY, notaryDigest), - backendSignature: _sign(BACKEND_KEY, backendDigest), - userAddress: userAddress, - walletAddress: address(0), - domainHash: domainHash, - clientRandom: clientRandom, - serverRandom: serverRandom, - serverEphemeralKey: serverEphKey, - transcriptRoot: transcriptRoot, - timestamp: ts, - domainPath: dp, - usernamePath: up, - endpointPath: ep, - idPath: idp - }); - } - - // ─── transfer_within via execute helper ───────────────────────── - - function _transferWithin( - WebWallet sender, - uint256 senderPk, - string memory receiverPlatform, - string memory receiverHandle, - uint256 amount - ) internal { - bytes memory data = abi.encodeCall( - IBank.transfer_within, - (_domain(receiverPlatform), receiverHandle, _idFor(receiverHandle), address(token), amount) - ); - _execute(sender, address(bank), data, senderPk); - } - - // ─── Tests ────────────────────────────────────────────────────── - - /// @notice Happy path: registered sender → registered receiver - function test_transferWithin_registeredToRegistered() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - WebWallet bob = _walletFor("x", "bob", BOB_PK); - - _fund("x", "alice", 1000e18); - - _transferWithin(alice, ALICE_PK, "x", "bob", 400e18); - - assertEq(bank.registeredBalances(address(alice), address(token)), 600e18); - assertEq(bank.registeredBalances(address(bob), address(token)), 400e18); - } - - /// @notice Transfer to unregistered receiver goes to unregisteredBalances - function test_transferWithin_toUnregistered() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - - _fund("x", "alice", 500e18); - - _transferWithin(alice, ALICE_PK, "x", "charlie", 200e18); - - assertEq(bank.registeredBalances(address(alice), address(token)), 300e18); - bytes32 key = keccak256(abi.encode("id:v1", "api.x.com", _idFor("charlie"))); - assertEq(bank.unregisteredBalances(key, address(token)), 200e18); - } - - /// @notice Auto-top-up: Bank balance insufficient, pulls from WebWallet ERC20 - function test_transferWithin_autoTopUp() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - WebWallet bob = _walletFor("x", "bob", BOB_PK); - - // Give alice 200 in bank, 300 as ERC20 on wallet - _fund("x", "alice", 200e18); - _fundWallet(alice, 300e18); - - // Approve bank to pull from wallet - bytes memory approveData = abi.encodeCall(IERC20.approve, (address(bank), type(uint256).max)); - _execute(alice, address(token), approveData, ALICE_PK); - - // Transfer 400 — needs 200 shortfall from ERC20 - _transferWithin(alice, ALICE_PK, "x", "bob", 400e18); - - assertEq(bank.registeredBalances(address(alice), address(token)), 0); - assertEq(bank.registeredBalances(address(bob), address(token)), 400e18); - assertEq(token.balanceOf(address(alice)), 100e18); // 300 - 200 shortfall - } - - /// @notice Fails if sender is not registered - function test_transferWithin_senderNotRegistered_reverts() public { - _walletFor("x", "bob", BOB_PK); - - // Call from an unregistered address - vm.prank(address(0xdead)); - vm.expectRevert(SenderNotRegistered.selector); - bank.transfer_within("x", "bob", "", address(token), 100e18); - } - - /// @notice Fails if insufficient balance and no ERC20 approved - function test_transferWithin_insufficientAndNoApproval_reverts() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - - _fund("x", "alice", 100e18); - - // Try to transfer 200 — shortfall 100, but no approval → revert - bytes memory data = abi.encodeCall(IBank.transfer_within, ("x", "bob", "", address(token), 200e18)); - uint256 currentNonce = alice.nonce(); - bytes32 digest = keccak256( - abi.encode(address(bank), uint256(0), keccak256(data), currentNonce, block.chainid, address(alice)) - ); - bytes[] memory sigs = new bytes[](1); - sigs[0] = _sign(ALICE_PK, digest); - - vm.expectRevert(); // safeTransferFrom will revert (no approval/balance) - alice.execute(address(bank), 0, data, sigs); - } - - /// @notice Zero amount reverts - function test_transferWithin_zeroAmount_reverts() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - - vm.prank(address(alice)); - vm.expectRevert(ZeroAmount.selector); - bank.transfer_within("x", "bob", "", address(token), 0); - } - - // ═══════════════════════════════════════════════════════════════ - // MUTATION-GUARD NEGATIVE TESTS - // ═══════════════════════════════════════════════════════════════ - - // 10. transfer_within from a caller that is NOT a registered wallet (empty - // getHandles) → SenderNotRegistered. The caller holds tokens + approval so - // removing the guard would SUCCEED (auto-top-up pulls), killing the mutant. - function test_SenderNotRegistered_transferWithin_reverts() public { - _walletFor("x", "bob", BOB_PK); // registered receiver - - address stranger = address(0xBEEF); - token.mint(stranger, 100e18); - vm.prank(stranger); - token.approve(address(bank), 100e18); - - vm.prank(stranger); - vm.expectRevert(SenderNotRegistered.selector); - bank.transfer_within("api.x.com", "bob", _idFor("bob"), address(token), 100e18); - } - - // 18. transfer_within NATIVE (address(0)) with bank balance < amount: native - // can't be pulled → LibEscrow.autoTopUp reverts InsufficientBalance. - function test_InsufficientBalance_reverts() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - - vm.prank(address(alice)); - vm.expectRevert(InsufficientBalance.selector); - bank.transfer_within("api.x.com", "bob", _idFor("bob"), address(0), 1 ether); - } - - // 19. transfer_within ERC20 with bank balance < amount AND no allowance → the - // shortfall pull can't be authorized → LibEscrow.autoTopUp reverts the - // custom InsufficientAllowance (removing the guard yields the OZ ERC20 error). - function test_InsufficientAllowance_reverts() public { - WebWallet alice = _walletFor("x", "alice", ALICE_PK); - _walletFor("x", "bob", BOB_PK); - _fund("x", "alice", 100e18); // bank balance 100; transfer 200 → shortfall 100 - - // Parametrized error → match on the selector prefix (removing the guard - // yields the OZ ERC20 allowance error, a different selector). - vm.prank(address(alice)); - vm.expectPartialRevert(InsufficientAllowance.selector); - bank.transfer_within("api.x.com", "bob", _idFor("bob"), address(token), 200e18); - } -} diff --git a/solidity/foundry.toml b/solidity/foundry.toml index 16dc29d0..1cc6fa50 100644 --- a/solidity/foundry.toml +++ b/solidity/foundry.toml @@ -2,8 +2,8 @@ src = "contracts" out = "out" libs = ["lib"] -# Tests live beside the code they cover — contracts/login/test, contracts/ -# transfer/test, contracts/identity/test — so discovery goes through src +# Tests live beside the code they cover — contracts/ceremony/test, contracts/ +# identity/test, contracts/factory/test — so discovery goes through src # rather than one test directory. test = "contracts" solc = "0.8.33" @@ -18,16 +18,11 @@ optimizer_runs = 200 bytecode_hash = "none" cbor_metadata = false fs_permissions = [ - # Local proof artifacts, if someone drops them in; the OIDC flow tests - # self-skip when these files are absent. - { access = "read", path = "circuits/jwt_email/target" }, - { access = "read", path = "contracts/login/test/fixtures/oidc" }, - # The naming system keeps its own copy: the rotation proof binds no - # contract, so the same notarized reading feeds both trust lists, but the - # identity tree must not read out of the login tree. - { access = "read", path = "contracts/identity/test/fixtures/oidc" }, - # TS/Solidity parity fixture for bind-request encoding (vendored from the - # browser identity package; see BindEncoding.t.sol). - { access = "read", path = "contracts/identity/test/fixtures" }, - { access = "read", path = "out" }, # diamond deployer reads facet methodIdentifiers + # Google's real JWKS body, as served today, so the root list's parser is + # tested against the document it will actually read rather than a shape + # a test author remembered (see GoogleJwtRoots.t.sol) -- and a whole + # notarized session of it, signature included, so the real path is tested + # end to end (see GoogleJwtRootsRealSession.t.sol). + { access = "read", path = "contracts/ceremony/test/fixtures" }, + { access = "read", path = "out" }, # the factory tests read creation code from the artifacts ] diff --git a/solidity/script/Deploy.s.sol b/solidity/script/Deploy.s.sol index 2bbc5bbb..03c63744 100644 --- a/solidity/script/Deploy.s.sol +++ b/solidity/script/Deploy.s.sol @@ -1,37 +1,36 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; +pragma solidity ^0.8.24; import {Script, console} from "forge-std/Script.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {Registry} from "../contracts/login/Registry.sol"; -import {WalletFactory} from "../contracts/login/WalletFactory.sol"; -import {WebWallet} from "../contracts/login/WebWallet.sol"; -import {IBank} from "../contracts/transfer/bank/IBank.sol"; -import {BankDiamondDeployer} from "../contracts/transfer/script/BankDiamondDeployer.sol"; -import {Notary} from "../contracts/notary/Notary.sol"; -import {MockERC20} from "../contracts/transfer/MockERC20.sol"; -import {XHonkVerifier} from "../contracts/login/zk/XHonkVerifier.sol"; -import {XZkVerifier, IHonkVerifier} from "../contracts/login/zk/XZkVerifier.sol"; -import {IZkSessionVerifier} from "../contracts/login/zk/IZkSessionVerifier.sol"; import {IdentityNames} from "../contracts/identity/IdentityNames.sol"; import {NotaryService} from "../contracts/ceremony/NotaryService.sol"; +import {INotaryService} from "../contracts/ceremony/INotaryService.sol"; import {CeremonyProofVerifier} from "../contracts/ceremony/CeremonyProofVerifier.sol"; import {IProofVerifier} from "../contracts/ceremony/IProofVerifier.sol"; import {HandleVectors} from "../contracts/identity/HandleVectors.sol"; -import {IdentityJwksRoots} from "../contracts/identity/IdentityJwksRoots.sol"; +import {GoogleJwtRoots} from "../contracts/ceremony/GoogleJwtRoots.sol"; -/// @notice Deploy full stack to any EVM chain. +/// @notice Deploy the identity stack to any EVM chain. +/// +/// Four UUPS proxies, in dependency order: the Notary Service every notarized +/// session is verified through, the Proof Verifier the naming system +/// dispatches claims through, the naming system itself with a keyspace per +/// platform, and the Google JWT root list that pays the Notary Service for +/// each rotation. No Platform Verifier is registered here -- that needs the +/// ceremony circuit artifacts, which arrive with their own release. /// /// Usage: -/// forge script contracts/script/Deploy.s.sol \ +/// forge script script/Deploy.s.sol \ /// --rpc-url http://127.0.0.1:8545 \ /// --broadcast \ /// --private-key $PRIVATE_KEY /// -/// Optional env: -/// NOTARY_ADDRESS — notary signing key address. -/// BACKEND_ADDRESS — backend signing key address. -contract Deploy is Script, BankDiamondDeployer { +/// Env: +/// DEPLOYER_KEY / PRIVATE_KEY — deployer private key (one of the two). +/// NOTARY_ADDRESS — the first trusted notary signing key (default: deployer). +/// NOTARY_FEE_WEI — what one attestation verification costs (default: 0). +contract Deploy is Script { function run() external { string memory deployerKeyHex = vm.envOr("DEPLOYER_KEY", vm.envOr("PRIVATE_KEY", string(""))); require(bytes(deployerKeyHex).length > 0, "set DEPLOYER_KEY or PRIVATE_KEY"); @@ -42,137 +41,38 @@ contract Deploy is Script, BankDiamondDeployer { uint256 deployerKey = vm.parseUint(deployerKeyHex); address deployer = vm.addr(deployerKey); address notaryAddr = vm.envOr("NOTARY_ADDRESS", deployer); - address backendAddr = vm.envOr("BACKEND_ADDRESS", deployer); + uint256 notaryFee = vm.envOr("NOTARY_FEE_WEI", uint256(0)); vm.startBroadcast(deployerKey); - // 0. Notary (UUPS proxy) — the ONE notary-attestation verifier every - // other contract points at. Deployed first so its proxy address can - // be wired into every consumer's initialize. Key rotation is - // `notaryContract.setNotary`; a proof-system change is a UUPS - // upgrade of this proxy alone. - Notary notaryImpl = new Notary(); - Notary notaryContract = Notary( - address(new ERC1967Proxy(address(notaryImpl), abi.encodeCall(Notary.initialize, (deployer, notaryAddr)))) - ); - - // 1. WebWallet implementation (beacon target) - WebWallet walletImpl = new WebWallet(); - - // 2. WalletFactory (UUPS proxy) - WalletFactory factoryImpl = new WalletFactory(); - WalletFactory factory = WalletFactory( - address( - new ERC1967Proxy( - address(factoryImpl), - abi.encodeCall(WalletFactory.initialize, (deployer, address(walletImpl), address(0))) - ) - ) - ); - - // 4. Registry (UUPS proxy) — unified MPC + ZK registration - Registry registryImpl = new Registry(); - Registry registry = Registry( - address( - new ERC1967Proxy( - address(registryImpl), - abi.encodeCall( - Registry.initialize, (address(notaryContract), backendAddr, address(factory), deployer) - ) - ) - ) - ); - - // 5. Bank (EIP-2535 diamond) — facets + BankInit (seeds templates/prefixes). - // Same BANK_CONTRACT_ADDRESS surface as before; the diamond keeps every - // Bank function signature, so backend/frontend need only the new address. - IBank bank = IBank(deployBankDiamond(deployer, address(notaryContract), backendAddr, address(registry))); - - // 6. XHonkVerifier — single UltraHonk verifier for the merged - // X dual-session proof. Called from XZkVerifier. - XHonkVerifier xHonk = new XHonkVerifier(); - - // 7. MockERC20 dev token - MockERC20 devToken = new MockERC20("DEV Token", "DEV"); - - // 8. Wire factory <-> registry - factory.setRegistry(address(registry)); - - // 9. Register dev token in Bank. Name must include the "$" prefix — - // the comment parser keeps it (e.g. "$DEV") and the bot sends that - // exact string as tokenName_, so the on-chain name must match. - bank.registerToken("$DEV", address(devToken)); - - // 10. Mint dev tokens to deployer for testing (1M tokens) - devToken.mint(deployer, 1_000_000 ether); - - // 11. Deploy XZkVerifier (UUPS proxy) and register it with Registry - // under the "api.x.com" platform. One X app serves both the browser - // ZK flow and the backend OAuth flow, so the verifier's client_id is - // just $X_CLIENT_ID (must match the browser's NEXT_PUBLIC_X_CLIENT_ID). - string memory xClientIdStr = vm.envOr("X_CLIENT_ID", string("")); - address xZkVerifierAddr; - if (bytes(xClientIdStr).length > 0) { - XZkVerifier xZkImpl = new XZkVerifier(); - xZkVerifierAddr = address( - new ERC1967Proxy( - address(xZkImpl), - abi.encodeCall( - XZkVerifier.initialize, - ( - deployer, - address(notaryContract), - IHonkVerifier(address(xHonk)), - bytes(xClientIdStr), - "/2/users/me", - "\"username\":\"", - "api.x.com" - ) - ) - ) - ); - registry.setZkVerifier("api.x.com", IZkSessionVerifier(xZkVerifierAddr)); - } - - // 13. Seed supported platforms (mirrors deploy.rs platform_configs) - registry.setPlatform("api.x.com", "/2/users/me", "\"username\":\"", "\"id\":\"", "\""); - registry.setPlatform("api.github.com", "/user", "\"login\":\"", "\"id\":", ","); - registry.setPlatform("discord.com", "/api/users/@me", "\"username\":\"", "\"id\":\"", "\""); - registry.setPlatform("www.googleapis.com", "/oauth2/v2/userinfo", "\"email\": \"", "\"id\": \"", "\""); - - // 14. The naming system: one contract holding the names, and an - // adapter per platform that reads the proof that platform's login - // already produces. Nothing here changes how anybody logs in. - // - // The future allowance differs by platform because they report on - // different scales. A notary states wall-clock time, so an X or - // GitHub observation is never ahead; a Google claim carries the - // token's `exp`, roughly an hour past issuance, because the circuit - // exposes no `iat`. - IdentityNames namesImpl = new IdentityNames(); - address identityNamesAddr = - address(new ERC1967Proxy(address(namesImpl), abi.encodeCall(IdentityNames.initialize, (deployer)))); - IdentityNames names = IdentityNames(identityNamesAddr); - - // The ceremony core the Consumer dispatches through. Without it - // `proofVerifier` reads zero, `quoteClaim` calls address zero and every - // resolver reverts `UnknownPlatform` -- a deployment that can bind - // nothing. - // - // The Notary Service is separate from `Notary` above and must be: that - // one takes a caller-supplied digest, which REQ-COMMON-49 forbids. Both - // are trusted with the same signing key. + // 1. The Notary Service (UUPS proxy) -- the ONE place a notary + // attestation is authenticated, deployed first so its proxy address + // can be wired into every consumer's initialize. It derives the + // digest from the attested bytes itself (REQ-COMMON-49) and charges + // the Notary Fee. Key rotation is `setNotary`; a fee change is + // `setFee`; a proof-system change is a UUPS upgrade of this proxy + // alone. NotaryService notaryServiceImpl = new NotaryService(); address notaryServiceAddr = address( new ERC1967Proxy( - address(notaryServiceImpl), - abi.encodeCall(NotaryService.initialize, (deployer, notaryAddr, vm.envOr("NOTARY_FEE_WEI", uint256(0)))) + address(notaryServiceImpl), abi.encodeCall(NotaryService.initialize, (deployer, notaryAddr, notaryFee)) ) ); + // 2. The ceremony core the Consumer dispatches through. Without it + // `proofVerifier` reads zero, `quoteClaim` calls address zero and + // every resolver reverts `UnknownPlatform` -- a deployment that can + // bind nothing. CeremonyProofVerifier pvImpl = new CeremonyProofVerifier(); address proofVerifierAddr = address(new ERC1967Proxy(address(pvImpl), abi.encodeCall(CeremonyProofVerifier.initialize, (deployer)))); + + // 3. The naming system: one contract holding the names, dispatching + // every claim through the Proof Verifier above. + IdentityNames namesImpl = new IdentityNames(); + address identityNamesAddr = + address(new ERC1967Proxy(address(namesImpl), abi.encodeCall(IdentityNames.initialize, (deployer)))); + IdentityNames names = IdentityNames(identityNamesAddr); names.setProofVerifier(IProofVerifier(proofVerifierAddr)); // A keyspace per platform. Registering a Platform Verifier against a @@ -183,13 +83,16 @@ contract Deploy is Script, BankDiamondDeployer { _wireIdentityPlatform(names, HandleVectors.PLATFORM_GITHUB); _wireIdentityPlatform(names, HandleVectors.PLATFORM_GOOGLE); - // The JWKS root list is the naming system's own. Google's Platform - // Verifier reads the trusted moduli through it, and nothing is trusted - // until a notarized reading of Google's JWKS lands. - IdentityJwksRoots rootsImpl = new IdentityJwksRoots(); - address jwksRootsAddr = address( + // 4. The Google JWT root list, beside the Platform Verifier it serves. + // That verifier reads the trusted moduli through it, and nothing + // is trusted until a notarized reading of Google's JWKS lands -- + // verified through the Notary Service above, like every other + // notarized session, and paying the same fee. + GoogleJwtRoots rootsImpl = new GoogleJwtRoots(); + address jwtRootsAddr = address( new ERC1967Proxy( - address(rootsImpl), abi.encodeCall(IdentityJwksRoots.initialize, (deployer, address(notaryContract))) + address(rootsImpl), + abi.encodeCall(GoogleJwtRoots.initialize, (deployer, INotaryService(notaryServiceAddr))) ) ); @@ -197,18 +100,10 @@ contract Deploy is Script, BankDiamondDeployer { console.log("=== Deployment complete ==="); console.log("Deployer: ", deployer); - console.log("WEBWALLET_IMPL= ", address(walletImpl)); - console.log("WALLET_FACTORY_ADDRESS= ", address(factory)); - console.log("REGISTRY_CONTRACT_ADDRESS= ", address(registry)); - console.log("BANK_CONTRACT_ADDRESS= ", address(bank)); - console.log("DEV_TOKEN_ADDRESS= ", address(devToken)); - console.log("NOTARY_CONTRACT_ADDRESS= ", address(notaryContract)); - console.log("XHonkVerifier: ", address(xHonk)); - console.log("XZkVerifier: ", xZkVerifierAddr); - console.log("IDENTITY_NAMES_ADDRESS= ", identityNamesAddr); console.log("NOTARY_SERVICE_ADDRESS= ", notaryServiceAddr); console.log("CEREMONY_PROOF_VERIFIER_ADDRESS= ", proofVerifierAddr); - console.log("IDENTITY_JWKS_ROOTS_ADDRESS= ", jwksRootsAddr); + console.log("IDENTITY_NAMES_ADDRESS= ", identityNamesAddr); + console.log("GOOGLE_JWT_ROOTS_ADDRESS= ", jwtRootsAddr); console.log("NOTE: no Platform Verifier is registered yet. Add one with"); console.log(" CeremonyProofVerifier.setVerifier once the ceremony"); console.log(" circuit artifacts are released. Until then a platform"); @@ -216,7 +111,7 @@ contract Deploy is Script, BankDiamondDeployer { // Nothing is trusted until a notarized reading of Google's JWKS lands. // Until then every Google claim reverts `UntrustedModulus`, which reads // as a bad proof rather than an unseeded list. - console.log("NOTE: point a JWKS rotation listener at IDENTITY_JWKS_ROOTS_ADDRESS"); + console.log("NOTE: point the keeper at GOOGLE_JWT_ROOTS_ADDRESS"); console.log(" before Google names work. The trust list starts empty."); } diff --git a/ts/packages/contracts/README.md b/ts/packages/contracts/README.md index 17092d33..aec3a923 100644 --- a/ts/packages/contracts/README.md +++ b/ts/packages/contracts/README.md @@ -1,8 +1,9 @@ # @libid/contracts -Typed, viem-ready ABIs for every libid contract, a call builder for every -state-changing function, and the identity helper layer: handle normalization, -proof encoding for `bind`, and name resolution. +Typed, viem-ready ABIs for the libid identity stack (NotaryService, +CeremonyProofVerifier, GoogleJwtRoots, IdentityNames, LibidFactory, WTIA9), a +call builder for every state-changing function, and the identity helper layer: +handle normalization and name resolution. Both `src/abis/` and `src/calls/` are generated from the forge artifacts (`solidity/out`) by `scripts/codegen.mjs`, and neither is committed — CI @@ -22,22 +23,23 @@ pnpm add @libid/contracts viem ```ts import { createPublicClient, http } from 'viem' -import { registryAbi, bankAbi } from '@libid/contracts/abis' +import { googleJwtRootsAbi, identityNamesAbi } from '@libid/contracts/abis' const client = createPublicClient({ transport: http(RPC_URL) }) // Fully typed: viem infers the argument and return types from the ABI. -const wallet = await client.readContract({ - address: REGISTRY, - abi: registryAbi, - functionName: 'walletOf', - args: ['github', '583231'], +const owner = await client.readContract({ + address: IDENTITY_NAMES, + abi: identityNamesAbi, + functionName: 'resolveHandle', + args: [platformId, 'alice'], }) -const tokens = await client.readContract({ - address: BANK, - abi: bankAbi, - functionName: 'getRegisteredTokens', +// The two generations of Google's key set the list holds. +const [current, previous] = await client.readContract({ + address: GOOGLE_JWT_ROOTS, + abi: googleJwtRootsAbi, + functionName: 'currentKeys', }) ``` @@ -62,8 +64,9 @@ to compile rather than reverting on chain. Payable functions take `value` before their arguments and set it on the returned call: ```ts -const deposit = calls.bank.deposit(bank, 1_000n, 'x', 'alice', 'TIA', token, amount) -// { to: `0x…`, value: 1000n, data: `0x…` } +// A JWKS rotation pays the Notary Fee: read it with `quoteRotation` first. +const rotate = calls.googleJwtRoots.rotate(roots, fee, attestedData, proof) +// { to: `0x…`, value: fee, data: `0x…` } ``` ## Resolving a name @@ -91,17 +94,24 @@ const { wallet, idAgrees } = await resolvePair(reader, x, 'alice', '42') const name = await primaryName(reader, wallet!, x) ``` -## Binding a name +## Claiming a name -`bindCall` builds calldata and nothing more — an EOA sends it directly, a -smart account wraps it in its own execute: +A claim is one of the generated builders: the platform, this chain's verifier +version for it, the opaque payload the ceremony produced, and whether to +publish the name. The value is what `quoteClaim` returns for the same pair. +An EOA sends it directly, a smart account wraps it in its own execute: ```ts -import { bindCall, encodeGitHubProof, platformId, PLATFORM_GITHUB_DOMAIN } from '@libid/contracts/identity' +import { calls } from '@libid/contracts/calls' -const proof = encodeGitHubProof(gitHubProof) // from the notarization flow -const call = bindCall(IDENTITY_NAMES, platformId(PLATFORM_GITHUB_DOMAIN), proof, true) -// call = { to, data } — sign and send from the address the proof names. +const fee = await client.readContract({ + address: IDENTITY_NAMES, + abi: identityNamesAbi, + functionName: 'quoteClaim', + args: [platformId(PLATFORM_GITHUB_DOMAIN), 1], +}) +const call = calls.identityNames.claim(IDENTITY_NAMES, fee, platformId(PLATFORM_GITHUB_DOMAIN), 1, payload, true) +// call = { to, value, data } — sign and send from the address the payload names. ``` ## Normalizing a handle locally diff --git a/ts/packages/contracts/package.json b/ts/packages/contracts/package.json index 156dfbaf..6050a785 100644 --- a/ts/packages/contracts/package.json +++ b/ts/packages/contracts/package.json @@ -1,7 +1,7 @@ { "name": "@libid/contracts", "version": "0.6.0", - "description": "Typed viem-ready ABIs for the libid contracts, plus the identity helper layer (handle normalization, resolve, calldata).", + "description": "Typed viem-ready ABIs and call builders for the libid contracts, plus the identity helper layer (handle normalization, name resolution).", "license": "(MIT OR Apache-2.0)", "repository": { "type": "git", @@ -15,8 +15,7 @@ "files": [ "dist", "src", - "!src/**/*.test.ts", - "!src/identity/fixtures" + "!src/**/*.test.ts" ], "exports": { ".": { diff --git a/ts/packages/contracts/scripts/codegen.mjs b/ts/packages/contracts/scripts/codegen.mjs index ae777f33..ca1cff40 100644 --- a/ts/packages/contracts/scripts/codegen.mjs +++ b/ts/packages/contracts/scripts/codegen.mjs @@ -29,28 +29,17 @@ const callsDir = join(packageDir, 'src', 'calls') // Every contract the package ships an ABI for. `file` is the source unit under // solidity/out, `name` the contract inside it, `exportName` what a consumer -// imports. IBank exports as `bankAbi`: it is the facade the whole diamond -// answers to, and the facet ABIs exist for anyone wiring cuts by hand. +// imports. const contracts = [ - { file: 'Registry.sol', name: 'Registry', exportName: 'registryAbi' }, - { file: 'Notary.sol', name: 'Notary', exportName: 'notaryAbi' }, - { file: 'WalletFactory.sol', name: 'WalletFactory', exportName: 'walletFactoryAbi' }, - { file: 'WebWallet.sol', name: 'WebWallet', exportName: 'webWalletAbi' }, - { file: 'IBank.sol', name: 'IBank', exportName: 'bankAbi' }, - { file: 'AdminFacet.sol', name: 'AdminFacet', exportName: 'adminFacetAbi' }, - { file: 'VaultFacet.sol', name: 'VaultFacet', exportName: 'vaultFacetAbi' }, - { file: 'TransferFacet.sol', name: 'TransferFacet', exportName: 'transferFacetAbi' }, - { file: 'DiamondLoupeFacet.sol', name: 'DiamondLoupeFacet', exportName: 'diamondLoupeFacetAbi' }, { file: 'IdentityNames.sol', name: 'IdentityNames', exportName: 'identityNamesAbi' }, - { file: 'IdentityJwksRoots.sol', name: 'IdentityJwksRoots', exportName: 'identityJwksRootsAbi' }, - { file: 'XZkVerifier.sol', name: 'XZkVerifier', exportName: 'xZkVerifierAbi' }, + { file: 'NotaryService.sol', name: 'NotaryService', exportName: 'notaryServiceAbi' }, { - file: 'GoogleOidcVerifier.sol', - name: 'GoogleOidcVerifier', - exportName: 'googleOidcVerifierAbi', + file: 'CeremonyProofVerifier.sol', + name: 'CeremonyProofVerifier', + exportName: 'ceremonyProofVerifierAbi', }, + { file: 'GoogleJwtRoots.sol', name: 'GoogleJwtRoots', exportName: 'googleJwtRootsAbi' }, { file: 'LibidFactory.sol', name: 'LibidFactory', exportName: 'libidFactoryAbi' }, - { file: 'MockERC20.sol', name: 'MockERC20', exportName: 'mockErc20Abi' }, { file: 'WTIA9.sol', name: 'WTIA9', exportName: 'wtia9Abi' }, ] @@ -115,7 +104,7 @@ export const ${exportName} = ${body} as const satisfies Abi /// Arguments are not spelled out here: `ContractFunctionArgs` reads them off /// the ABI, so there is no Solidity-to-TypeScript type table in this script to /// drift from the real one. It resolves overloads to a union of tuples too, -/// which is what lets `webTransferV2` keep one name. +/// which is what keeps one builder per name should a contract ever overload one. /// /// Payable functions take `value` before their arguments. That is the whole /// reason to distinguish them: a caller who forgets the value on a payable diff --git a/ts/packages/contracts/src/calls.anvil.test.ts b/ts/packages/contracts/src/calls.anvil.test.ts index c8c2b0e7..61dfbc87 100644 --- a/ts/packages/contracts/src/calls.anvil.test.ts +++ b/ts/packages/contracts/src/calls.anvil.test.ts @@ -6,8 +6,8 @@ /// whose arguments have to land in the right order — then reads the balances /// back. Without it, "generated" and "usable" are two different claims. /// -/// Skips when anvil or the compiled artifact is missing, like the OIDC -/// proof-artifact tests do. CI always has both: the same job installs foundry +/// Skips when anvil or the compiled artifact is missing. CI always has both: +/// the same job installs foundry /// and builds the contracts this reads. import { spawn, spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' diff --git a/ts/packages/contracts/src/calls.test.ts b/ts/packages/contracts/src/calls.test.ts index d33a40b9..150c5873 100644 --- a/ts/packages/contracts/src/calls.test.ts +++ b/ts/packages/contracts/src/calls.test.ts @@ -1,12 +1,17 @@ import { decodeFunctionData } from 'viem' import { describe, expect, it } from 'vitest' -import { bankAbi } from './abis/bank.js' +import { googleJwtRootsAbi } from './abis/googleJwtRoots.js' import { identityNamesAbi } from './abis/identityNames.js' import { calls } from './index.js' const NAMES = '0x00000000000000000000000000000000000000aa' as const +const ROOTS = '0x00000000000000000000000000000000000000cc' as const const PLATFORM = `0x${'11'.repeat(32)}` as const +// Opaque to the builder: the contract decodes the record and the Notary +// Service checks the proof, so any bytes round-trip through the ABI here. +const ATTESTED = `0x${'a7'.repeat(40)}` as const +const PROOF = `0x${'b8'.repeat(65)}` as const describe('generated call builders', () => { it('encodes a call the ABI decodes back to the same arguments', () => { @@ -30,26 +35,20 @@ describe('generated call builders', () => { }) it('takes value before the arguments on a payable function', () => { - const call = calls.bank.deposit( - NAMES, - 7n, - 'x', - 'alice', - 'TIA', - '0x00000000000000000000000000000000000000bb', - 3n, - ) + // A rotation pays the Notary Fee, so the value is the part a caller must + // not be able to forget: it comes before the attested bytes and the proof. + const call = calls.googleJwtRoots.rotate(ROOTS, 7n, ATTESTED, PROOF) expect(call.value).toBe(7n) - expect(call.to).toBe(NAMES) - expect(decodeFunctionData({ abi: bankAbi, data: call.data })).toEqual({ - functionName: 'deposit', - args: ['x', 'alice', 'TIA', '0x00000000000000000000000000000000000000bb', 3n], + expect(call.to).toBe(ROOTS) + expect(decodeFunctionData({ abi: googleJwtRootsAbi, data: call.data })).toEqual({ + functionName: 'rotate', + args: [ATTESTED, PROOF], }) }) it('namespaces by contract, so colliding names stay distinct', () => { // `initialize` is on almost every contract; the two must not be one export. - expect(calls.identityNames.initialize).not.toBe(calls.registry.initialize) + expect(calls.notaryService.initialize).not.toBe(calls.identityNames.initialize) }) }) diff --git a/ts/packages/contracts/src/identity/resolve.ts b/ts/packages/contracts/src/identity/resolve.ts index fee56b8b..3a6b988e 100644 --- a/ts/packages/contracts/src/identity/resolve.ts +++ b/ts/packages/contracts/src/identity/resolve.ts @@ -1,8 +1,8 @@ /// Reading names from the chain. /// -/// Everything here is a view call. Binding a name needs a proof and a wallet; -/// reading one needs neither, which is the point — any product can resolve a -/// name without touching the login that created it. +/// Everything here is a view call. Claiming a name needs a ceremony payload and a +/// wallet; reading one needs neither, which is the point — any product can +/// resolve a name without touching the ceremony that created it. /// /// The client and the contract address are arguments rather than imports. This /// package ships separately from the repository it currently lives in, so it @@ -26,7 +26,7 @@ export interface NamesReader { /// One view call. /// -/// Routed through a narrow cast for the same reason the wallet package does it: +/// Routed through a narrow cast for one reason: /// viem's `PublicClient`, without its chain type parameters, types /// `authorizationList` as required even for a read. The alternative is to /// spread that noise across every call site here. diff --git a/ts/packages/contracts/tsconfig.build.json b/ts/packages/contracts/tsconfig.build.json index ad49fb21..0a11719f 100644 --- a/ts/packages/contracts/tsconfig.build.json +++ b/ts/packages/contracts/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["src/**/*.test.ts", "src/identity/fixtures"] + "exclude": ["src/**/*.test.ts"] }