docs / internal

view as .md

Architecture#

A map for contributors: the polyglot split, the components, the trust flow, both grant flows module-by-module, the on-chain gate + cache, and the client registry seam. For the product-level pitch see docs/index.md; for the full design rationale see docs/superpowers/specs/2026-07-21-passport-web3-oauth-design.md (the "spec"); for a code-level map of each unit see crates.md.

The polyglot split (AD-1)#

On-chain = Solidity (the EVM's native language). Everything off-chain = Rust-first. TypeScript only at the SDK edge (the relying-app SDK, because web apps are JS/TS). — spec §12, AD-1

This is not a style preference, it is the trust boundary:

  • On-chain = trust + money. GrantorRegistry (Solidity, contracts/) is the one place that holds value and the one place that says which issuer keys are legitimate. It is deliberately small, immutable where possible, and reviewed like money-handling code (heavy Foundry invariant/fuzz coverage — see contracts/test/GrantorRegistry.invariant.t.sol).
  • Off-chain = auth, in Rust. The issuer (grantor-issuer) and the shared verify core (grantor-verify) are Rust. A login never writes to the chain; it only reads the registry. AD-4 picked Poem (poem-openapi) for the issuer's typed HTTP handlers + OpenAPI; AD-5 put JWT verification and the on-chain trust check in one shared Rust crate so there is a single source of truth for what "a valid, trusted Grantor token" means.
  • TypeScript only at the edge. The escape hatch for a Rust contract exists (Arbitrum Stylus, spec AD-1) but is not used by default — the default on-chain language stays Solidity. TS shows up only where the runtime forces it: browser wallet interaction (assets/grantor-login.js, viem + WalletConnect) and the future relying-app SDK, which per AD-5 will wrap the same grantor-verify logic compiled to WASM rather than reimplementing it.

Components#

                 reads paid/registered (public RPC, no operator infra)
   ┌───────────────┐ ───────────────────────────────────► ┌──────────────────┐
   │ grantor-         │                                       │ GrantorRegistry    │
   │ issuer (Rust)  │ ◄─────────────────────────────────── │  (Solidity, L2)  │
   │  Poem HTTP     │        isActive / isAgentKey          │  trust anchor +  │
   └───────┬────────┘                                       │  billing         │
           │ mints ES256 JWT                                └──────────────────┘
           ▼                                                       ▲
   end-user / app / agent                                          │ isActive(keyId)
           │                                                       │ (client-side, any RPC)
           ▼ verifies via /jwks + on-chain check                   │
   ┌────────────────────┐                                          │
   │ grantor-verify       │ ─────────────────────────────────────────┘
   │ (Rust; used by the │
   │  issuer directly,  │
   │  → WASM for the    │
   │  TS SDK later)     │
   └────────────────────┘
  • contracts/GrantorRegistry. The canonical trust anchor and billing engine (contracts/src/GrantorRegistry.sol). Maps an issuer keyId to a tenant, derives tenant Status (Active/Grace/Inactive) from periodEnd/graceWindow/now (never stored stale — status(), GrantorRegistry.sol:95), and exposes the one view the rest of the system depends on: isActive(bytes32 keyId) → bool (GrantorRegistry.sol:105). Also tracks the multiwallet agent-key registry (isAgentKey, a public mapping) and per-tenant usage caps (apps/issuer keys/agents) and a time-boxed free-trial window.
  • crates/grantor-verify. Pure, chain-free Rust: verify_jwt (ES256 + exp/aud), issuer_key_id (RFC 7638 JWK thumbprint — the exact [u8; 32] value GrantorRegistry stores as keyId), the TrustSource trait, and verify (JWT check + thumbprint + trust check, composed). No network, no alloy dependency — deliberately, so it can later compile to WASM (AD-5) for the TS relying-app SDK.
  • crates/grantor-issuer. The OIDC/OAuth2 issuer: holds the ES256 signing key, publishes /jwks, mints JWTs, and gates every mint on the on-chain check. Depends on grantor-verify (for issuer_key_id, and its unit tests round-trip through verify_jwt) plus alloy (chain reads), poem (HTTP), and siwe (EIP-4361). See crates.md for the module breakdown.

The trust flow#

Every mint and every verification reduces to the same question the registry answers: is this issuer key registered to a tenant that is paid up?

  1. Mint side (issuer). Before minting, grantor-issuer calls its TrustGate (crates/grantor-issuer/src/gate.rs), which ultimately reads GrantorRegistry.isActive(keyId) over RPC. keyId is always this issuer's own key (AppState.key.key_id, crates/grantor-issuer/src/state.rs:21). If the tenant isn't Active/Grace, the issuer refuses to mint (403 {"error":"inactive"}).
  2. Verify side (relying app). A relying app fetches /jwks (crates/grantor-issuer/src/http/discovery.rs), verifies the JWT signature/claims the standard OIDC way, and — for revocation awareness beyond the token TTL — can independently read isActive(kid) from any public RPC via grantor-verify::verify + a TrustSource impl. No call to any operator-run service is required; the registry is the only shared dependency, and it's public.

The keyId is the hinge: it is simultaneously the JWKS kid (token.rs::IssuerKey::key_id_hex, embedded in the JWT header) and the on-chain registry key (issuerKeyToTenant in GrantorRegistry.sol:52), computed identically on both sides by issuer_key_id (RFC 7638 thumbprint).

Request flow — Grant A (Authorization Code + SIWE, humans)#

StepHTTPModule(s)
1. App redirects the browserGET /authorizehttp/authorize.rs::authorize — requires PKCE S256, checks ClientStore::is_valid_redirect, renders the HTML page
2. Issuer serves the connect-and-sign page(response)http/page.rs::render (all 5 reflected query params HTML-escaped) + assets/grantor-login.js (viem: builds + signs the EIP-4361 message in-browser)
3. Wallet signs; page posts backPOST /authorize/callbackhttp/authorize.rs::callback — parses the SIWE message (siwe crate), checks domain against cfg.domain, verifies the EIP-191 signature before burning the SIWE nonce (AppState::use_nonce), calls TrustGate::is_active, re-checks the redirect allowlist, derives sub via token::pairwise_sub, stores a one-time AuthCode (state.rs::AuthCode, ~60s TTL) via AppState::put_code, and 302s to redirect_uri?code=…&state=…
4. App exchanges the codePOST /token (grant_type=authorization_code)http/token.rs::token dispatches to http/token_code.rs::authorization_code — takes the code (AppState::take_code, single-use + expiry), requires redirect_uri to match, verifies PKCE (base64url(sha256(code_verifier)) == stored challenge), re-checks TrustGate::is_active, mints via token::IssuerKey::mint
5. App verifies the JWTgrantor-verify::verify_jwt / verify, against /jwks

Request flow — Grant C (Client Credentials, agents)#

StepHTTPModule(s)
1. Agent builds + signs the challenge(off-band)Canonical message from http/assertion.rs::build_message (domain/tenant/nonce/issued-at), signed EIP-191
2. Agent calls the token endpointPOST /token (grant_type=client_credentials)http/token.rs::client_credentials
3. Issuer validatesClientStore::is_allowed_audience (audience must be pre-registered) → http/assertion.rs::verify (recovers the signer via login::recover_login_address before burning the nonce — anti-grief; checks the ±120s skew) → TrustGate::is_agent_key(tenant_id, signer) (on-chain multiwallet check) → TrustGate::is_active(issuer key_id)
4. Issuer mintssub = token::pairwise_sub of the agent address; token::IssuerKey::mint (no nonce claim for Grant C)
5. Resource server verifiesgrantor-verify, against /jwks

Both flows pause at exactly one shared checkpoint — TrustGate::is_active — where billing, revocation, and the freeloader-fork defense all live (spec §5).

The on-chain gate + cache#

TrustGate (trait, gate.rs)           — object-safe async trait: is_active, is_agent_key
  └─ ChainGate (production impl)     — gate.rs
       ├─ is_active  → CachedIsActive<AlloyReader, SystemClock>   (cache.rs)
       │                    └─ AlloyReader::is_active              (chain.rs)
       └─ is_agent_key → AlloyReader::is_agent_key (uncached)       (chain.rs)
  • AlloyReader (crates/grantor-issuer/src/chain.rs) wraps an RPC URL + registry address and calls GrantorRegistry through alloy's sol! bindings (isActive(bytes32), isAgentKey(uint256,address)). It is async by nature (an RPC round-trip), which is why it's a distinct type from the synchronous grantor_verify::TrustSource.
  • CachedIsActive<S, C> (cache.rs) wraps is_active reads with a short-TTL cache and the fail-closed semantics from spec §10: a fresh hit is served without an RPC call (bounding revocation latency to ttl_secs); on a transient RPC failure it serves the last good value while within staleness_secs; past that bound (or with no cached value at all) it fails closed — returns false, i.e. "not trusted." Clock is injectable (SystemClock in production) so this is unit-tested without a live chain.
  • ChainGate (gate.rs) is the production TrustGate: is_active goes through CachedIsActive; is_agent_key is read live, uncached, because agent-key registration changes at admin-tx frequency, not per-login frequency. Both readers share one underlying RPC/registry (AlloyReader is Clone).
  • gate.rs's own test (is_active_fails_closed_when_rpc_is_down) exercises the cold-cache + RPC-down case end-to-end through ChainGate.

The client registry seam (ClientStore)#

crates/grantor-issuer/src/clients.rs defines a second, deliberately off-chain allowlist: which relying-app client_id → redirect_uris (Grant A) and which resource audiences (Grant C) this issuer will actually issue tokens for.

pub trait ClientStore: Send + Sync {
    fn is_valid_redirect(&self, client_id: &str, redirect_uri: &str) -> bool;
    fn is_allowed_audience(&self, audience: &str) -> bool;
}

Why off-chain: GrantorRegistry.registerApp only counts apps toward a tier's usage cap (GrantorRegistry.sol:261) — it never stores redirect URIs or audience strings. Exact-match redirect/audience validation is what closes the open-redirect and audience-spoofing gaps (see docs/superpowers/specs/2026-07-22-passport-client-registry-design.md), and that's a lookup problem, not a trust/money problem, so there's no reason to pay gas for it or route it through the chain's block-time latency.

FileClientStore is the only implementation today: it loads a TOML file (CLIENTS_FILE) at startup and is fail-closedmain.rs propagates FileClientStore::load's error with ?, so a missing or malformed file refuses to boot rather than booting with an empty (or worse, permissive) allowlist. Being behind a trait is the point: a hosted, multi-tenant service can later swap in a DbClientStore without touching any HTTP handler. (Known current limitation, tracked in the build ledger: the registry is global per issuer process — correct for today's single-tenant deploy, but it will need to become tenant-scoped for a future hosted service.)

See also#

  • crates.md — per-module code map (what/API/depends-on) for every unit named above.
  • docs/guide/concepts.md — the product-facing mental model (same trust flow, written for integrators rather than contributors).
  • docs/llms-full.txt — the full machine reference (endpoints, claims, error codes, config).

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.