docs / internal

view as .md

Code map#

One entry per unit in the workspace: what it does, its key public API, and what it depends on. For how these units fit together, see architecture.md.

Workspace layout#

grantor/
├── Cargo.toml                    # workspace: members = grantor-verify, grantor-issuer
├── contracts/                    # Solidity + Foundry (not a Cargo member)
│   ├── src/GrantorRegistry.sol
│   ├── test/                     # GrantorRegistry.t.sol, .invariant.t.sol, mocks/
│   └── out/                      # forge build artifacts (ABI+bytecode) — consumed by
│                                  # grantor-issuer's alloy `sol!` e2e test bindings
├── crates/
│   ├── grantor-verify/              # pure Rust: JWT verify + on-chain-agnostic trust check
│   └── grantor-issuer/              # Rust + Poem: the OIDC/OAuth2 issuer HTTP server
└── docs/                         # this doc, guides, OpenAPI, llms.txt

Builds use CARGO_TARGET_DIR=~/.cargo-target (set in the shell profile on this box) — build artifacts land there, not in ./target.


contracts/GrantorRegistry#

What: the canonical on-chain trust anchor + credit-subscription billing engine + multiwallet org registry. One per network. An ERC-721 "org pass" (tokenId == tenantId) mints on createTenant.

Key public API (contracts/src/GrantorRegistry.sol):

  • isActive(bytes32 keyId) → bool (line 105) — the trust check: true iff keyId is registered to a tenant whose derived status() is Active or Grace.
  • isAgentKey[tenantId][address] → bool — public mapping; is address registered as an agent (multiwallet) key for that tenant.
  • status(uint256 id) → Status (line 95) — Inactive | Active | Grace, derived live from periodEnd / graceWindow / block.timestamp (never stored, never stale).
  • Lifecycle: createTenant, addAdmin / removeAdmin.
  • Tiers: setTierConfig (simple/unlimited), setTierConfigFull (usage caps + optional trialDuration), disableTier, setTier.
  • Billing: topUp, drawPeriod (permissionless keeper call; charges the tenant's snapshotted fee/period, not live tier config — the owner cannot retroactively drain a tenant), withdrawBalance, setAutoRenew.
  • Registry: registerIssuerKey / deregisterIssuerKey (issuer-key ↔ tenant), registerApp, registerAgentKey (all enforce per-tier usage caps).
  • Admin surface: pause / unpause, setGraceWindow — owner-only, minimal by design (money-handling contract).

Depends on: OpenZeppelin v5 (ERC721, Ownable, Pausable, ReentrancyGuard, SafeERC20/IERC20); an immutable usdc token address and treasury address set at construction.

Tests: contracts/test/GrantorRegistry.t.sol (unit, 415 lines) + GrantorRegistry.invariant.t.sol (invariant/fuzz, 80 lines) + mocks/ (MockUSDC). Built with Foundry (forge); the JSON artifacts under contracts/out/ are loaded directly by grantor-issuer's alloy::sol! macro in its anvil e2e tests (crates/grantor-issuer/src/chain.rs test module), so the Rust and Solidity sides stay bit-for-bit in sync without hand-copied ABIs.


crates/grantor-verify#

What: verifies a Grantor JWT and confirms the issuer key is trusted. Pure library — no chain access; the "is this key active?" check is injected via a trait, so its tests need no network. Chain-free by design (AD-5): it's meant to also compile to WASM to back a thin TS relying-app SDK later, so it carries no alloy dependency.

Key public API (crates/grantor-verify/src/lib.rs):

  • verify_jwt(token, jwk_pem_pub, expected_aud) → Result<Claims, VerifyError> — ES256 signature check, 60s exp leeway, aud match (line 40).
  • issuer_key_id(jwk_json) → Result<[u8; 32], VerifyError> — RFC 7638 JWK thumbprint over the canonical {crv,kty,x,y} EC member set; this is the exact [u8; 32] the Solidity registry stores as keyId (line 52).
  • TrustSource trait — fn is_active(&self, key_id: [u8; 32]) -> bool (synchronous; line 77) — the injection point for "is this key active on-chain."
  • verify(token, jwk_json, jwk_pem_pub, expected_aud, trust) → Result<Claims, VerifyError> — composes verify_jwt + issuer_key_id + TrustSource::is_active into the one full check a relying app needs (line 83).
  • Claims { iss, sub, aud, exp, iat, tid, nonce } — the JWT claim shape.

Depends on: jsonwebtoken, serde/serde_json, sha2. 6 tests.

Non-default sovereign feature (sovereign.rs/sovereign_gate.rs, SP8 Phase 1): RP-side verification of a self-certifying sovereign-tier agent token directly against the on-chain GrantorRegistry — no grantor-issuer process on this path at all (structurally enforced: this crate carries no grantor-issuer dependency, checked in CI). Pulls in semaphore-rs + alloy only when enabled. See docs/sovereign-tier.md and crates/grantor-verify/tests/sovereign_e2e.rs.


crates/grantor-issuer#

What: the OIDC/OAuth2 issuer — holds the ES256 signing key, publishes /jwks, and mints Grantor JWTs for both grants, gated by the on-chain check. Built on Poem (AD-4, for poem-openapi-friendly typed HTTP handlers). Depends on grantor-verify (re-derives/round-trips through issuer_key_id and verify_jwt in its own tests) plus alloy (chain reads), siwe (EIP-4361), and poem/tokio for the server.

Re-exports (crates/grantor-issuer/src/lib.rs): CachedIsActive, Clock, IsActiveSource, SystemClock, AlloyReader, ChainError, ClientStore, ClientStoreError, FileClientStore, ConfigError, IssuerConfig, ChainGate, TrustGate, recover_login_address, verify_login, LoginError, AppState, AuthCode, pairwise_sub, IssuerError, IssuerKey, MintParams.

configcrates/grantor-issuer/src/config.rs#

IssuerConfig { domain, signing_key_pem, contract_addr, rpc_url, tenant_id, tenant_salt, token_ttl_secs, clients_file }. from_env() / the testable from_map(get: impl Fn(&str) -> Option<String>). token_ttl_secs defaults to 3600 if unset; every other field is required (ConfigError::Missing/Bad). Ships a manual, redacting Debug impl — signing_key_pem and tenant_salt print as <redacted> so secrets can't leak into logs.

clientscrates/grantor-issuer/src/clients.rs#

ClientStore trait: is_valid_redirect(client_id, redirect_uri), is_allowed_audience(audience) (both exact-string match). FileClientStore: the only implementation, loaded from a TOML file ([[client]] id, redirect_uris / [[resource]] audience) via load(path) or from_toml_str. Off-chain and swappable by design — see architecture.md's client-registry section for why.

gatecrates/grantor-issuer/src/gate.rs#

TrustGate trait (async_trait, object-safe): is_active(key_id), is_agent_key(tenant_id, key). ChainGate: the production impl — is_active routed through CachedIsActive<AlloyReader, SystemClock>; is_agent_key read live via a cloned AlloyReader (uncached — changes at admin-tx frequency).

chaincrates/grantor-issuer/src/chain.rs#

AlloyReader { rpc_url, registry } (Clone) — reads GrantorRegistry on-chain via an alloy::sol! interface (IGrantorRegistry): is_active(key_id) → Result<bool, ChainError>, is_agent_key(tenant_id, key) → Result<bool, ChainError>. Async by nature (RPC round-trip) — deliberately distinct from grantor_verify::TrustSource (sync). Its test module spawns a live anvil node, deploys MockUSDC + GrantorRegistry from contracts/out/*.json, runs a full tenant lifecycle (configure tier → create tenant → fund → draw a period → register issuer + agent keys), then asserts the reader sees the right is_active/is_agent_key results.

cachecrates/grantor-issuer/src/cache.rs#

IsActiveSource trait (the raw, uncached read; implemented for AlloyReader). Clock trait + SystemClock. CachedIsActive<S, C> — TTL-fresh cache hits skip the RPC call; on a source failure, serves the last good value within staleness_secs, then fails closed (false) past that bound, and on a cold cache with no prior value. Unit-tested entirely against a fake source + a controllable fake clock (no anvil needed).

statecrates/grantor-issuer/src/state.rs#

AppState { key: IssuerKey, cfg: IssuerConfig, gate: Arc<dyn TrustGate>, clients: Arc<dyn ClientStore>, .. } plus private single-use stores: codes (put_code/take_code, expiry-checked) and nonces (use_nonce, with a lazy sweep of stale entries). AuthCode { sub, tid, aud, nonce, pkce_s256, redirect_uri, expires_at }. #[cfg(test)] AppState::for_test() builds a ready-to-use state with a generated key, a permissive fake TrustGate, and a seeded app-1 / resource-api client store — used throughout the HTTP tests.

tokencrates/grantor-issuer/src/token.rs#

IssuerKey — the ES256 (P-256) signing key plus its published JWK identity. generate() / from_pkcs8_pem(pem); jwk_json() (bare public JWK), jwks_json() (the /jwks document, kid = key_id_hex()), key_id_hex(), and mint(&MintParams) → String (signs an ES256 JWT with kid set in the header). key_id: [u8; 32] is computed via grantor_verify::issuer_key_id — the exact value that must match the on-chain keyId. MintParams<'a> { iss, aud, sub, tid, ttl_secs, nonce }. pairwise_sub(wallet, tenant_salt) → Stringsha256(lowercase(wallet) ++ tenant_salt), hex-encoded; same wallet, different (unlinkable) subject per tenant salt.

logincrates/grantor-issuer/src/login.rs#

The EIP-191 crypto primitive shared by both grants: recover_login_address (message, signature) → Result<Address, LoginError> (via alloy_primitives::PrimitiveSignature::recover_address_from_msg) and verify_login(message, signature, expected) → Result<(), LoginError>. LoginError { Signature, Mismatch }. Used directly by Grant A's SIWE signature check and, via http/assertion.rs, by Grant C's client-assertion check.

http/crates/grantor-issuer/src/http/#

  • mod.rsroutes(state: Arc<AppState>) -> BoxEndpoint<'static>: mounts discovery, /jwks, /token, /authorize (+/callback), and /assets (static files, served from CARGO_MANIFEST_DIR/assets so it's CWD-robust), then attaches shared state via .data(state). health_routes() is a separate minimal router for /healthz.
  • discovery.rsopenid_configuration (OIDC discovery JSON off cfg.domain) and jwks (st.key.jwks_json(), application/json).
  • authorize.rs — Grant A's two endpoints. authorize (GET /authorize): requires PKCE S256, checks ClientStore::is_valid_redirect, renders page::render. callback (POST /authorize/callback): parses the SIWE message (siwe crate), checks the SIWE domain against cfg.domain, verifies the EIP-191 signature via msg.verify_eip191 before burning the single-use SIWE nonce (ordering fixed during review to prevent signature-less nonce griefing), calls TrustGate::is_active, re-validates the redirect allowlist, mints a one-time AuthCode keyed by a random 32-byte hex code, and 302s to redirect_uri?code=…&state=… built via url::Url::query_pairs_mut (percent-encoded, no manual string concatenation → no open-redirect / double-? bugs).
  • token.rsPOST /token dispatcher (token() matches grant_type). client_credentials() (Grant C): validates client_assertion/nonce/ issued_at/audience are present, checks ClientStore::is_allowed_audience, delegates signature/nonce/skew checks to assertion::verify, then checks TrustGate::is_agent_key and TrustGate::is_active before minting with a pairwise sub of the recovered agent address.
  • token_code.rsauthorization_code() (Grant A's code exchange): AppState::take_code (single-use + expiry), requires redirect_uri to match the code's stored value (RFC 6749 §4.1.3), verifies PKCE (base64url(sha256(code_verifier)) == stored code_challenge), re-checks TrustGate::is_active at exchange time, and mints the JWT carrying the code's stored sub/tid/aud/nonce.
  • assertion.rs — Grant C's client-assertion primitive. build_message(domain, tenant_id, nonce, issued_at) builds the canonical challenge string ("Grantor client-credentials\ndomain: …\ntenant: …\nnonce: …\nissued-at: …"). verify() recovers the signer via login::recover_login_address before consuming the nonce (same anti-grief ordering as authorize.rs), then checks the ±120s skew (SKEW_SECS) and the single-use nonce.
  • page.rs — the one HTML page in the system: a minimal connect-and-sign form. render() HTML-attribute-escapes all five reflected query params (escape_attr) — fixed a reflected-XSS finding found during code review; there is a regression test for it.

maincrates/grantor-issuer/src/main.rs#

Binary wiring: IssuerConfig::from_env()IssuerKey::from_pkcs8_pemAlloyReader::new(rpc_url, contract_addr)ChainGate::new(reader, 30, 120) (30s cache TTL, 120s staleness bound) → FileClientStore::load(clients_file) (propagated with ? — fail-closed boot) → AppState::new(..)grantor_issuer::http::routes(state), served via poem::Server bound to BIND (default 0.0.0.0:8080).

tests/ (integration)#

grant_a_e2e.rs / grant_c_e2e.rs — live-anvil end-to-end tests: deploy MockUSDC + GrantorRegistry, run the real tenant lifecycle, wire a real ChainGate (not a fake), then drive the actual HTTP flow (SIWE → code → token → verify_jwt, and the client-credentials equivalent) to prove the whole stack — not just each unit — works together.

assets/grantor-login.js#

The wallet-side script for the connect-and-sign page: uses viem (+ WalletConnect) to connect a wallet, build the EIP-4361 SIWE message, sign it, and submit http/page.rs's form to /authorize/callback.

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