Authoritative build contract. Every package MUST conform to the types, interfaces, ports, and REST shapes below. The product spec is
../AgentGate-PRD-Solo-Build-Plan.md. Payment plan: Plan B (default) — native CSPR transfer withtransfer_id = noncecarrying HTTP 402 semantics, verified via CSPR.cloud REST in live mode and via the local devnet in mock mode. APaymentVerifier/ChainClientseam keeps Plan A (x402 Facilitator) pluggable later.
agentgate/
├── package.json # workspaces root, scripts: dev/demo/typecheck/test/build
├── tsconfig.base.json
├── vitest.config.ts # root config picking up packages/*/test + e2e/
├── .env.example .gitignore LICENSE (MIT) README.md
├── docs/SPEC.md docs/ARCHITECTURE.md
├── .github/workflows/ci.yml
├── contracts/ # Rust/Odra workspace (NOT an npm workspace)
│ └── agentgate-registry/
├── packages/
│ ├── shared/ # @agentgate/shared — types, config, money, logger, errors. ZERO runtime deps.
│ ├── chain/ # @agentgate/chain — ChainClient impls: MockChainHttpClient + LiveCasperClient
│ ├── devnet/ # @agentgate/devnet — in-memory mock chain HTTP server (express)
│ ├── middleware/ # @agentgate/middleware — 402 paywall reverse proxy + admin API
│ ├── client/ # @agentgate/client — agent-side pay helper (parse 402 → pay → retry)
│ ├── oracle/ # @agentgate/oracle — RWA feed: USD/IDR + gold spot + confidence
│ ├── buyer-agent/ # @agentgate/buyer-agent — LLM decision loop demo agent
│ └── cli/ # @agentgate/cli — `agentgate wrap|buy|list|status|pause|resume|demo-accounts`
├── dashboard/ # @agentgate/dashboard — Next.js 14 App Router + Tailwind (also the landing page)
├── e2e/ # loop.test.ts — full mock-mode loop, in-process servers
└── scripts/
├── dev.ts # boots devnet+oracle+middleware (mock mode) concurrently
└── demo.ts # one-shot scripted demo: register → 402 → pay → serve → attest → score
Conventions:
"type": "module"everywhere. TSmoduleResolution: "bundler",strict: true. No build step for Node packages — run from source withtsx. Each package'spackage.jsonexportspoints at./src/index.ts.- Workspace deps use
"@agentgate/shared": "workspace:*"→ NO, npm doesn't supportworkspace:*ranges with install older semantics reliably — use"*"as the version range for internal deps (npm workspaces resolves locally). - Every server package exports
createApp(deps): express.Express(pure factory, no listen) ANDstartServer(opts): Promise<{ port: number; close(): Promise<void> }>so e2e can boot in-process on port 0. - Logging:
createLogger(name)from shared — leveled JSON lines to stdout ({ts,level,name,msg,...fields}). - All packages:
npm run typecheck(tsc --noEmit) andnpm test(vitest run, if tests exist) must pass.
AGENTGATE_MODE = "mock" | "live" (default mock). Shared loadConfig() reads process.env once, validates, throws on invalid combos (e.g. live mode without CSPR_CLOUD_API_KEY, live mode with default admin token).
.env.example (root, documented):
AGENTGATE_MODE=mock
# --- ports ---
DEVNET_PORT=4030
ORACLE_PORT=4010
MIDDLEWARE_PORT=4021
DASHBOARD_PORT=3000
# --- mock mode ---
DEVNET_URL=http://localhost:4030
# --- middleware ---
AGENTGATE_ADMIN_TOKEN=dev-admin-token # MUST be changed in live mode (config refuses default)
INVOICE_TTL_MS=300000
UPSTREAM_TIMEOUT_MS=30000
# --- live mode (Casper Testnet) — fill before deploy; deploy itself is out of scope for now ---
CASPER_NODE_URL=https://node.testnet.casper.network/rpc
CSPR_CLOUD_API_URL=https://api.testnet.cspr.cloud
CSPR_CLOUD_API_KEY=
CSPR_CLOUD_STREAMING_URL=wss://streaming.testnet.cspr.cloud
CASPER_NETWORK=casper-test
REGISTRY_CONTRACT_PACKAGE_HASH=
GATE_SIGNER_PEM_PATH= # middleware/attestor key
BUYER_SIGNER_PEM_PATH= # buyer agent key
SELLER_SIGNER_PEM_PATH= # CLI / seller key
# --- LLM ---
ANTHROPIC_API_KEY=
LLM_MODEL=claude-sonnet-4-6
# --- oracle ---
ORACLE_STATIC=0 # 1 = serve deterministic fixture data (offline demo)
# --- buyer agent ---
BUYER_BUDGET_CSPR=5
# --- demo accounts (mock mode only) ---
MOCK_BUYER_ACCOUNT=
MOCK_SELLER_ACCOUNT=
src/types.ts — copy verbatim:
/** All money values are motes of native CSPR as decimal strings (1 CSPR = 1_000_000_000 motes). U512-safe via bigint. */
export type Motes = string;
export interface ServiceRecord {
id: number;
name: string;
description: string;
endpointUrl: string; // PUBLIC gateway URL (middleware /svc/:id) — never the upstream
priceMotes: Motes;
paymentTarget: string; // "account-hash-<64hex>"
owner: string; // public key hex ("01..."/"02...")
attestor: string; // public key hex allowed to record attestations
active: boolean;
createdAt: number; // unix ms
}
export interface ServiceScore { totalCalls: number; successCalls: number; }
export interface AttestationRecord {
serviceId: number;
paymentDeployHash: string;
success: boolean;
timestamp: number; // unix ms
recordTxHash: string; // the attestation tx itself
}
export interface ActivityEvent {
kind: 'service_registered' | 'payment' | 'attestation';
txHash: string;
serviceId: number | null;
amountMotes?: Motes;
success?: boolean;
timestamp: number;
detail: string; // human-readable one-liner for the dashboard feed
}
export interface PaymentRequirements {
scheme: string; // 'exact'
network: string; // 'mock' | 'casper-test'
maxAmountRequired: string; // price in motes, decimal string
asset: string; // 'CSPR'
payTo: string; // account-hash-<64hex>
resource: string; // absolute /svc/:id URL
description: string; // service name
maxTimeoutSeconds: number; // invoiceTtlMs / 1000
extra: {
nonce: string; // u64 decimal — required transfer_id
serviceId: number;
expiresAtMs: number; // unix ms
settlement: 'casper-native-transfer';
transferIdEncoding: 'u64-decimal';
};
}
export interface PaymentRequiredResponse {
x402Version: number; // 1
error: string; // human-readable reason
accepts: PaymentRequirements[]; // >= 1 entry
}
export interface PaymentPayload {
x402Version: number; // 1
scheme: string; // 'exact'
network: string; // chain network
payload: { transaction: string; transferId: string; from?: string };
}
export interface SettlementResponse {
success: boolean;
transaction: string;
network: string;
payer?: string;
errorReason?: string;
}
export interface RegisterServiceInput {
name: string;
description: string;
endpointUrl: string;
priceMotes: Motes;
paymentTarget: string;
attestor: string;
}
export interface VerifyTransferQuery {
deployHash: string;
expectedTarget: string; // account-hash
minAmountMotes: Motes;
expectedTransferId: string;
maxAgeMs: number;
}
export type VerifyResult =
| { ok: true; amountMotes: Motes; from: string; timestamp: number }
| { ok: false; reason: 'not_found' | 'wrong_target' | 'amount_too_low' | 'wrong_transfer_id' | 'expired' | 'pending' };
export interface SignerRef { kind: 'mock'; publicKey: string } // mock
export interface PemSignerRef { kind: 'pem'; pemPath: string } // live
export type AnySigner = SignerRef | PemSignerRef;
export interface ChainClient {
readonly network: string;
getService(id: number): Promise<ServiceRecord | null>;
listServices(): Promise<ServiceRecord[]>;
getScore(id: number): Promise<ServiceScore>;
listAttestations(serviceId: number, limit?: number): Promise<AttestationRecord[]>;
listRecentActivity(limit?: number): Promise<ActivityEvent[]>;
getBalance(account: string): Promise<Motes>;
verifyTransfer(q: VerifyTransferQuery): Promise<VerifyResult>;
registerService(input: RegisterServiceInput, signer: AnySigner): Promise<{ serviceId: number; txHash: string }>;
recordAttestation(input: { serviceId: number; paymentDeployHash: string; success: boolean }, signer: AnySigner): Promise<{ txHash: string }>;
setActive(serviceId: number, active: boolean, signer: AnySigner): Promise<{ txHash: string }>;
transfer(input: { to: string; amountMotes: Motes; transferId: string }, signer: AnySigner): Promise<{ deployHash: string }>;
}src/money.ts: csprToMotes(cspr: string): Motes (decimal string × 1e9, bigint, throws on >9 dp or negative), motesToCspr(m: Motes): string (trim trailing zeros), addMotes, compareMotes(a,b): -1|0|1, formatCspr(m: Motes): string ("0.5 CSPR"). All bigint-backed; never use Number for money.
src/nonce.ts: randomNonce(): string — 63-bit random from crypto.getRandomValues, decimal string (valid u64 transfer_id).
src/config.ts: loadConfig(env = process.env): AgentGateConfig per §1. src/logger.ts: tiny JSON logger with debug/info/warn/error, level from LOG_LEVEL. src/errors.ts: AgentGateError(code, message, httpStatus).
Score → trust badge (shared src/trust.ts, used by dashboard + buyer agent):
trustTier(score: ServiceScore): 'new' | 'reliable' | 'trusted' — new if totalCalls < 5; trusted if totalCalls ≥ 25 AND success ratio ≥ 0.95; reliable if totalCalls ≥ 5 AND ratio ≥ 0.9; else new.
In-memory state simulating exactly what we use on Casper: account balances (motes), native transfers with transfer_id, the registry contract's state, and an activity log. Deterministic-ish tx hashes: sha256(JSON.stringify(payload) + monotonicCounter) hex, 64 chars.
REST (all JSON):
| Method/Path | Body / Query | Returns |
|---|---|---|
POST /faucet |
{account, amountMotes} |
{balanceMotes} |
GET /chain/balance/:account |
{account, balanceMotes} |
|
POST /chain/transfers |
{fromPublicKey, to, amountMotes, transferId} |
{deployHash, timestamp} — debits sender (account-hash derived account-hash-<sha256(pubkey) hex>), credits target; 400 insufficient_balance |
GET /chain/transfers/:deployHash |
transfer record or 404 | |
POST /chain/register-service |
RegisterServiceInput & {ownerPublicKey} |
{serviceId, txHash} — also activity event |
POST /chain/attestations |
{serviceId, paymentDeployHash, success, byPublicKey} |
{txHash} — 403 if byPublicKey ≠ service.attestor and ≠ owner; 404 unknown service; 409 duplicate paymentDeployHash for that service |
POST /chain/services/:id/active |
{active, byPublicKey} |
{txHash} — owner only |
GET /chain/services |
ServiceRecord[] |
|
GET /chain/services/:id |
ServiceRecord or 404 |
|
GET /chain/services/:id/score |
ServiceScore |
|
GET /chain/services/:id/attestations?limit= |
AttestationRecord[] (newest first) |
|
GET /chain/activity?limit= |
ActivityEvent[] (newest first, default 50) |
|
GET /healthz |
{ok:true, network:'mock'} |
Mirrors the on-chain rules of the Odra contract (auth, duplicate attestation guard, active flag) so mock and live behave identically.
createChainClient(config): ChainClient— picks impl byconfig.mode.MockChainHttpClient— fetch againstDEVNET_URL, maps REST ↔ChainClient. Account-hash derivation helpermockAccountHash(publicKey)=account-hash-+ sha256(publicKey) hex.LiveCasperClient— casper-js-sdk v5 (casper-js-sdk@^5.0.12) + CSPR.cloud REST:- reads: services/score/attestations via CSPR.cloud contract-state + the deployed registry's named keys; activity via CSPR.cloud deploys/transfers endpoints;
verifyTransfervia CSPR.cloudGET /transfers?deploy_hash=checking target account-hash, amount,transfer_id, age. CSPR.cloud auth header is the raw token (noBearerprefix). - writes: native transfer with id via SDK
TransferDeployItem/SessionBuilder;register_service/record_attestation/set_activevia ContractCallBuilder againstREGISTRY_CONTRACT_PACKAGE_HASH. - Anything that requires the not-yet-deployed contract throws
AgentGateError('NOT_DEPLOYED', 'requires REGISTRY_CONTRACT_PACKAGE_HASH — run contracts deploy first', 503)when the hash is unset, with the call path otherwise fully implemented and compiling. Mark with// ⚠️ verify against deployed contractwhere arg names/gas need on-chain confirmation.
- reads: services/score/attestations via CSPR.cloud contract-state + the deployed registry's named keys; activity via CSPR.cloud deploys/transfers endpoints;
402 paywall reverse proxy. State: upstream map (data/upstreams.json, atomic writes) + nonce/invoice store (in-memory Map + TTL sweep; interface InvoiceStore so Redis can swap in).
Flow for ALL /svc/:id (GET/POST pass-through):
- Look up service by id from
ChainClient(60s cache). 404 if unknown, 403service_inactiveif!active. - No
X-PAYMENTheader → 402 withPaymentRequiredResponseJSON body (x402Version:1,error:"X-PAYMENT header is required",accepts:[requirements(freshNonce)]). Invoice persisted (nonce → {serviceId, expiresAtMs, used:false}). - With
X-PAYMENTheader (base64-encodedPaymentPayload):- Decode and validate
x402Version===1,scheme==="exact",network===chain.network; extractpayload.transaction+payload.transferId. Malformed → 402 + fresh requirements +error:"invalid_payment_header". - Invoice lookup by
transferId: must exist, match serviceId, be unused, be withinexpiresAtMs— else 402 fresh requirements + reason (invoice_expired,invoice_used,unknown_nonce). chain.verifyTransfer({deployHash: transaction, expectedTarget: service.paymentTarget, minAmountMotes: service.priceMotes, expectedTransferId: transferId, maxAgeMs: INVOICE_TTL_MS})→ on{ok:false}402 + fresh requirements + reason.pending→ 402 + same requirements (nonce kept) +Retry-After: 2(seconds) +error:"settlement_pending".- Mark nonce used before proxying (single-use even if upstream fails).
- Decode and validate
- Proxy to upstream (undici/fetch, timeout
UPSTREAM_TIMEOUT_MS, max body 1 MiB both ways, strip hop-by-hop headers, forward query string + JSON body, pass through status/content-type). - Respond to buyer, then async
chain.recordAttestation({serviceId, paymentDeployHash, success: upstreamOk})signed by gate signer; never blocks the response; on failure log + retry once after 5s.
Admin API (Bearer AGENTGATE_ADMIN_TOKEN):
POST /admin/services{serviceId, upstreamUrl}→ 204 (validates URL http/https, rejects private/loopback hosts in live mode — SSRF guard; allowed in mock for local demo).GET /admin/services→ mappings.DELETE /admin/services/:id→ 204.GET /svc/:id/meta(public) →{service, score, trustTier}without paying.GET /healthz.
Hardening: helmet, JSON body limit 256 KiB inbound, rate limit 60 req/min/IP on /svc, structured request logs, graceful SIGTERM shutdown, never leak upstream URL in responses or errors.
export interface PayAndFetchResult { status: number; body: unknown; paid: boolean; requirements?: PaymentRequirements; settlement?: SettlementResponse; deployHash?: string; priceMotes?: Motes; }
export interface AgentGateClientOpts { chain: ChainClient; signer: AnySigner; maxPriceMotes?: Motes; logger?: Logger; settleDelayMs?: number; }
export function createAgentGateClient(opts): {
fetchPaid(url: string, init?: RequestInit): Promise<PayAndFetchResult>;
}fetchPaid: GET → if 402: parsePaymentRequired selects the accepts[] entry matching chain.network + scheme:"exact" (throws NETWORK_MISMATCH if none), refuses if maxAmountRequired > maxPriceMotes (PRICE_EXCEEDED). chain.transfer({to: req.payTo, amountMotes: req.maxAmountRequired, transferId: req.extra.nonce}, signer), wait settleDelayMs (default 0 mock / 3000 live), retry with X-PAYMENT: encodeXPayment(...). On a paid 200 decodes X-PAYMENT-RESPONSE into settlement. Retries Retry-After (seconds) 402-pending (error:"settlement_pending") up to 5×. Non-402 first responses pass straight through.
GET /feed → { pairs: { usd_idr: {value, sources: [{name, value}], confidence}, xau_usd: {...} }, asOf, attribution }.
Also GET /feed/usd-idr, GET /feed/gold, GET /healthz.
Sources (free, no key): https://open.er-api.com/v6/latest/USD (IDR) and https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json (idr + xau→invert for XAU/USD). 5s timeout per source, 60s in-memory cache, confidence = max(0, 1 - (maxDev/mean) * 10) rounded 2dp across available sources; single source → 0.5. ORACLE_STATIC=1 or all sources failing → deterministic fixture (usd_idr 16250.5, xau_usd 3310.25, confidence 0.97, attribution: 'static-fixture'). Never throws to the consumer — always 200 with best-effort data.
npm run agent -- --task "Get today's USD/IDR rate and gold price, summarize for a treasury report" [--budget 5].
Loop (each step logged as pretty console block AND logs/decisions.jsonl):
chain.listServices()→ table (id, name, price CSPR, trust tier, score).- LLM (
LlmClientseam:AnthropicLlmvia rawfetchtohttps://api.anthropic.com/v1/messageswithLLM_MODEL, else deterministicMockLlm) gets task + catalog JSON → returns{serviceId, reason}(MockLlm: cheapest active service whose name/description matches task keywords; ties → highest trust). - Budget check (
BUYER_BUDGET_CSPRcap, refuse + log if exceeded), thenclient.fetchPaid(service.endpointUrl). - LLM summarizes the data for the task → final report printed.
- Prints receipt: payment deployHash, amount, remaining budget, and (after 2s) the attestation it triggered (
chain.listAttestations).
Bin name agentgate (root script npm run agentgate -- ... + package bin field).
agentgate wrap <upstreamUrl> --price <cspr> --name <name> [--description <d>] [--gateway <url=http://localhost:4021>] [--payment-target <account-hash>]chain.registerService(endpointUrl =<gateway>/svc/<id>— two-step: register with placeholder, then the devnet/contract computes id; SOLUTION: registry computesendpoint_urlitself as<gateway>/svc/<id>? NO — keep it simple: CLI sendsendpointUrl: '<gateway>/svc/?'is ugly. Decision:registerServicereturnsserviceId; the canonical public URL is ALWAYS<gateway>/svc/<serviceId>; theendpointUrlfield stored on-chain is set by the CLI to<gateway>/svc/{id}template resolved client-side via a secondsetEndpoint? Too much. Final decision: the registry storesgatewayBaseUrlprovided at registration;ServiceRecord.endpointUrlis computed by every reader as${gatewayBaseUrl}/svc/${id}. To keepServiceRecordunchanged, devnet + contract store the base URL internally and return the computedendpointUrl. CLI passesendpointUrl = <gateway base>; readers see the full computed URL.POST <gateway>/admin/serviceswith the upstream mapping (admin token from env).- Prints: service id, public endpoint, dashboard URL
http://localhost:3000/services/<id>, register txHash.
agentgate list— catalog table with scores/tiers.agentgate status <id>— service + score + recent attestations.agentgate demo-accounts(mock only) — creates buyer/seller accounts, faucets 1000 CSPR each, prints export lines.- Payment target default: derived from seller signer (mock:
mockAccountHash(publicKey); live: account hash of PEM key).
Odra latest 2.x (odra = "2.7.2" or newest resolving), rust-toolchain.toml pinning the nightly that OdraVM tests need; cargo odra test green; cargo odra build produces wasm (record gas notes in contract README).
Module AgentGateRegistry:
- Storage:
services_count: Var<u64>;services: Mapping<u64, Service>;scores: Mapping<u64, (u64, u64)>(total, success);seen_payments: Mapping<(u64, String), bool>duplicate-attestation guard;attestations: Mapping<u64, Vec<Attestation>>capped at last 100 per service (drop oldest). Service { name: String, description: String, gateway_base_url: String, price: U512, payment_target: Address, owner: Address, attestor: Address, active: bool, created_at: u64 }- Entrypoints:
register_service(name, description, gateway_base_url, price: U512, payment_target: Address, attestor: Address) -> u64— validates non-empty name, price ≥ 1000 motes; caller = owner; emitsServiceRegistered.record_attestation(service_id: u64, payment_deploy_hash: String, success: bool)— caller must be attestor or owner; service must exist & be active; rejects duplicate payment_deploy_hash per service; bumps score; appends capped list; emitsAttestationRecorded.set_active(service_id: u64, active: bool)— owner only; emitsServiceStatusChanged.get_service(service_id) -> Option<Service>,get_score(service_id) -> (u64, u64),services_count() -> u64,get_attestations(service_id) -> Vec<Attestation>.
- Errors enum via
odra::execution_error!(NotAuthorized, ServiceNotFound, ServiceInactive, DuplicateAttestation, InvalidPrice, EmptyName). - Tests (OdraVM): happy register+get, count increments, score math, attestor auth (owner ok / attestor ok / stranger reverts), duplicate payment hash reverts, inactive service reverts attestation, set_active auth, attestation cap behavior, price/name validation. ≥10 tests.
bin/build_schema.rsif Odra needs it per template; keep template's livenet example with adeploy.rs(documented, NOT run).
Also serves as the landing page (PRD judging criterion #7).
/— landing: hero ("Stripe for AI agents on Casper"), one-command pitch with copyablenpx agentgate wrap ..., how-it-works 3 steps (wrap → discover → get paid), live stats strip (services, total calls, revenue — from/api/stats), roadmap section (mainnet, x402 Facilitator Plan A, streaming, MCP), links (GitHub/X/docs)./catalog— service cards: name, description, price (CSPR), trust badge, score, active dot; links to detail./services/[id]— detail: metadata, trust badge tier, score donut/sparkbar, copyable endpoint + 402 curl snippet, live attestation feed (5s polling), revenue counter (price × successCalls, plusgetBalance(paymentTarget)shown in mock)./activity— global live feed ofActivityEvents with tx hashes (mock: plain code text; live: link tohttps://testnet.cspr.live/deploy/<hash>), 5s polling, "LIVE" pulse indicator.- API routes (
app/api/*/route.ts,runtime = 'nodejs',dynamic = 'force-dynamic'):/api/services,/api/services/[id](record+score+attestations),/api/activity,/api/stats— thin wrappers overcreateChainClient(loadConfig()). Client components poll with SWRrefreshInterval: 5000. next.config.mjs:transpilePackages: ['@agentgate/shared','@agentgate/chain'].- Design: dark theme, distinctive (not default-shadcn-looking): near-black
#0A0E14bg, electric red accent#FF3B30(Casper-adjacent) + neutral grays,Space Groteskheadings /Interbody /JetBrains Monofor hashes & money, generous spacing, real empty/loading/error states.npm run buildmust succeed with zero type errors.
Root package.json scripts:
dev—tsx scripts/dev.ts(devnet → seed demo accounts → oracle → middleware; prints next steps)dev:dashboard—npm run dev -w dashboarddemo—tsx scripts/demo.ts: boots devnet+oracle+middleware in-process (ports from env), creates accounts + faucet, wraps the oracle via the CLI's programmatic API (wrapService()exported from cli), runs the buyer agent loop once (MockLlm if no key), prints the two tx hashes (payment + attestation) + final score + where to see it in the dashboard. Exits 0.typecheck—tsc --noEmitin every TS package + dashboard (npm run typecheck --workspaces --if-present)test—vitest run(packages unit tests + e2e)build— dashboardnext buildagent,agentgate— forwarders into the packages.
e2e/loop.test.ts (vitest): boots devnet/oracle/middleware in-process on port 0; asserts the full loop: faucet → wrap (register + admin mapping) → unpaid GET = 402 with valid invoice → underpay rejected → pay exact → 200 with oracle JSON → replayed proof = 402 invoice_used → attestation recorded (poll ≤5s) → score = (1,1) → activity feed contains payment + attestation + registration. Plus expired invoice test (TTL 100ms) and inactive service test.
npm install && npm run typecheck && npm test && npm run buildall green from a fresh clone;cargo odra testgreen incontracts/.npm run democompletes the full PRD §2 loop offline and prints 2 tx hashes.- No secrets in repo;
.env.examplecomplete; MIT LICENSE; README with architecture diagram (mermaid), quickstart, mode matrix, deploy runbook (documented, not executed); CI workflow (node job + contracts job). - Live-mode code paths compile and are unit-tested where possible without a node; every spot that needs the deployed contract throws
NOT_DEPLOYEDwith a clear message and is listed indocs/DEPLOY.md.