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 — seecontracts/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 samegrantor-verifylogic 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 issuerkeyIdto a tenant, derives tenantStatus(Active/Grace/Inactive) fromperiodEnd/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]valueGrantorRegistrystores askeyId), theTrustSourcetrait, andverify(JWT check + thumbprint + trust check, composed). No network, noalloydependency — 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 ongrantor-verify(forissuer_key_id, and its unit tests round-trip throughverify_jwt) plusalloy(chain reads),poem(HTTP), andsiwe(EIP-4361). Seecrates.mdfor 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?
- Mint side (issuer). Before minting,
grantor-issuercalls itsTrustGate(crates/grantor-issuer/src/gate.rs), which ultimately readsGrantorRegistry.isActive(keyId)over RPC.keyIdis 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"}). - 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 readisActive(kid)from any public RPC viagrantor-verify::verify+ aTrustSourceimpl. 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)#
| Step | HTTP | Module(s) |
|---|---|---|
| 1. App redirects the browser | GET /authorize | http/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 back | POST /authorize/callback | http/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 code | POST /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 JWT | — | grantor-verify::verify_jwt / verify, against /jwks |
Request flow — Grant C (Client Credentials, agents)#
| Step | HTTP | Module(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 endpoint | POST /token (grant_type=client_credentials) | http/token.rs::client_credentials |
| 3. Issuer validates | — | ClientStore::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 mints | — | sub = token::pairwise_sub of the agent address; token::IssuerKey::mint (no nonce claim for Grant C) |
| 5. Resource server verifies | — | grantor-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 callsGrantorRegistrythroughalloy'ssol!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 synchronousgrantor_verify::TrustSource.CachedIsActive<S, C>(cache.rs) wrapsis_activereads 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 tottl_secs); on a transient RPC failure it serves the last good value while withinstaleness_secs; past that bound (or with no cached value at all) it fails closed — returnsfalse, i.e. "not trusted."Clockis injectable (SystemClockin production) so this is unit-tested without a live chain.ChainGate(gate.rs) is the productionTrustGate:is_activegoes throughCachedIsActive;is_agent_keyis read live, uncached, because agent-key registration changes at admin-tx frequency, not per-login frequency. Both readers share one underlying RPC/registry (AlloyReaderisClone).gate.rs's own test (is_active_fails_closed_when_rpc_is_down) exercises the cold-cache + RPC-down case end-to-end throughChainGate.
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-closed — main.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).