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 iffkeyIdis registered to a tenant whose derivedstatus()isActiveorGrace.isAgentKey[tenantId][address] → bool— public mapping; isaddressregistered as an agent (multiwallet) key for that tenant.status(uint256 id) → Status(line 95) —Inactive | Active | Grace, derived live fromperiodEnd/graceWindow/block.timestamp(never stored, never stale).- Lifecycle:
createTenant,addAdmin/removeAdmin. - Tiers:
setTierConfig(simple/unlimited),setTierConfigFull(usage caps + optionaltrialDuration),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, 60sexpleeway,audmatch (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 askeyId(line 52).TrustSourcetrait —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>— composesverify_jwt+issuer_key_id+TrustSource::is_activeinto 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.
config — crates/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.
clients — crates/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.
gate — crates/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).
chain — crates/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.
cache — crates/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).
state — crates/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.
token — crates/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) → String — sha256(lowercase(wallet) ++ tenant_salt), hex-encoded; same wallet, different (unlinkable) subject per tenant salt.
login — crates/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.rs—routes(state: Arc<AppState>) -> BoxEndpoint<'static>: mounts discovery,/jwks,/token,/authorize(+/callback), and/assets(static files, served fromCARGO_MANIFEST_DIR/assetsso it's CWD-robust), then attaches shared state via.data(state).health_routes()is a separate minimal router for/healthz.discovery.rs—openid_configuration(OIDC discovery JSON offcfg.domain) andjwks(st.key.jwks_json(),application/json).authorize.rs— Grant A's two endpoints.authorize(GET /authorize): requires PKCES256, checksClientStore::is_valid_redirect, renderspage::render.callback(POST /authorize/callback): parses the SIWE message (siwecrate), checks the SIWEdomainagainstcfg.domain, verifies the EIP-191 signature viamsg.verify_eip191before burning the single-use SIWE nonce (ordering fixed during review to prevent signature-less nonce griefing), callsTrustGate::is_active, re-validates the redirect allowlist, mints a one-timeAuthCodekeyed by a random 32-byte hex code, and302s toredirect_uri?code=…&state=…built viaurl::Url::query_pairs_mut(percent-encoded, no manual string concatenation → no open-redirect / double-?bugs).token.rs—POST /tokendispatcher (token()matchesgrant_type).client_credentials()(Grant C): validatesclient_assertion/nonce/issued_at/audienceare present, checksClientStore::is_allowed_audience, delegates signature/nonce/skew checks toassertion::verify, then checksTrustGate::is_agent_keyandTrustGate::is_activebefore minting with a pairwisesubof the recovered agent address.token_code.rs—authorization_code()(Grant A's code exchange):AppState::take_code(single-use + expiry), requiresredirect_urito match the code's stored value (RFC 6749 §4.1.3), verifies PKCE (base64url(sha256(code_verifier)) == stored code_challenge), re-checksTrustGate::is_activeat exchange time, and mints the JWT carrying the code's storedsub/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 vialogin::recover_login_addressbefore consuming the nonce (same anti-grief ordering asauthorize.rs), then checks the±120sskew (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.
main — crates/grantor-issuer/src/main.rs#
Binary wiring: IssuerConfig::from_env() → IssuerKey::from_pkcs8_pem → AlloyReader::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.