Hestr overview

Hestr is the spending control layer for AI agents on Robinhood Chain. An owner creates an onchain mandate for an agent address: a payment token, a per-transaction limit, UTC daily and monthly budgets, approved recipients, permitted service IDs, an approval threshold and an expiry. The agent requests payments within that mandate; the HestrMandates contract checks every rule and reserves the amount in escrow; a provider delivers and records a receipt hash; the owner accepts, disputes, or lets the challenge period run; the contract settles or refunds.

Enforcement lives in the contract. The console, the API, the SDK and the MCP tools can explain, queue and relay, but none of them can move funds outside a mandate's rules.

What is in the repository

Path What it is
apps/web Next.js app: landing page, console (/app/*), HTTP API (/api/*), docs (/docs)
packages/contracts Foundry project: HestrMandates.sol, MockUSD.sol (local only), tests, deploy script, deployment manifests
packages/protocol Shared TypeScript: chain config, generated ABI, EIP-712 types, the policy evaluator, state machine, Hestr escrow HTTP scheme
packages/db Drizzle schema, SQL migrations, database client (PostgreSQL or PGlite)
packages/sdk Agent SDK (HestrClient) and the hestr-agent CLI
packages/mcp MCP server exposing the SDK as tools
services/indexer Durable event indexer (confirmations, checkpoints, reorg handling, replayable projections)
examples/provider Example paid service (deterministic text transformation) using the hestr-escrow/1 exchange
scripts Local chain, deployment, dev database, dev stack, end-to-end tests

Operating modes

Mode When Behaviour
Local development HESTR_CHAIN_ID=31337 with a deployed local contract Anvil, MockUSD test token, embedded PostgreSQL, example provider, demo agent, relayer. Real contracts, fake money.
Configured network HESTR_CHAIN_ID=4663 (or another verified chain) with HESTR_CONTRACT_ADDRESS and a verified token Live reads and writes against that deployment.
Unconfigured No contract address Setup guidance only; every balance-dependent action is disabled; nothing is fabricated.

The status bar in the console and GET /api/status always say which mode, chain, contract and index height are in effect.

Roles and keys

  • Owner: a wallet. Creates, funds, revokes mandates; approves or rejects above-threshold requests by signing EIP-712 messages; accepts or disputes deliveries. Signs in to the console with Sign-In With Ethereum.
  • Agent: a separate key. Signs payment requests (EIP-712) and authenticates to the API. It can never fund, approve, withdraw or change a policy. Never give an agent the owner wallet, a seed phrase, or a key that holds other funds.
  • Provider: the service being paid. Receives escrowed settlements and submits delivery receipts from its own key.
  • Resolver: an explicitly configured address that decides disputes (full or partial). It has no other power.
  • Relayer (optional, server-side): pays gas to submit agent-signed authorizations. Never present in browser code.

What Hestr does not claim

  • The contracts are tested, not audited. See Status.
  • A delivery receipt hash proves that a delivery was recorded, not that the work was correct.
  • Service IDs limit what can be paid for; they do not prove a provider performs that service.
  • x402 is not integrated (see x402); the escrow exchange is Hestr's own scheme.
  • Public chain data is public. Only application metadata is private.
  • There is no affiliation with Robinhood. Robinhood Chain is the target network.

Setup

Prerequisites

  • Node.js 20.9+ (tested with 24.16) and pnpm 9 (corepack enable or npm i -g pnpm@9)
  • Foundry 1.8 (forge, anvil) on your PATH or in ~/.foundry/bin — https://getfoundry.sh
  • Windows, macOS or Linux. No Docker needed: local PostgreSQL is embedded.

One-command local stack

pnpm install
pnpm contracts:build      # forge build (also run after any contract change, then `pnpm abi`)
pnpm dev                  # anvil → deploy → PostgreSQL → web + indexer + provider

pnpm dev (scripts/dev-stack.mjs):

  1. starts Anvil on http://127.0.0.1:27430 (chain 31337, 1-second blocks) if nothing answers there;
  2. deploys MockUSD and HestrMandates with forge script (resolver = Anvil account #8, challenge period 10 minutes, resolution timeout 1 hour), mints 100,000 tUSD to the ten Anvil accounts, and writes the repository-root .env.local (--fresh forces a redeploy);
  3. starts embedded PostgreSQL on 127.0.0.1:27440 (data in ~/.hestr/pg-27440);
  4. starts the example provider on http://127.0.0.1:27420, the indexer, and the web app on http://127.0.0.1:27400.

Then:

  1. Import Anvil account #0 (0xf39F…2266, the owner) into a browser wallet and point the wallet at chain 31337 / http://127.0.0.1:27430.
  2. Open http://127.0.0.1:27400/app, connect, sign in.
  3. Agents → register the demo agent 0x7099…79C8 (Anvil account #1).
  4. Mandates → New → choose the agent, add the provider 0x3C44…93BC as an approved recipient, keep text.transform, review, sign, fund.
  5. Overview → Run demo: word-stats runs without approval; summarize-outline (60 tUSD) stops in Approvals until you approve it, then continues to escrow and delivery.
  6. Open the payment page: accept, dispute (then resolve as the resolver), or wait for the challenge period and claim.

Without a browser wallet, the same flow runs from the command line:

pnpm demo                                     # hestr-agent demo → 402 → policy → escrow → delivery
pnpm --filter @hestr/sdk demo -- --op summarize-outline   # waits for your approval in the console
node --import tsx scripts/e2e-local.mjs       # full API + contract end-to-end test (40 checks)
node apps/web/scripts/ui-flow.mjs             # drives the real console UI with a stub wallet over CDP (run against `next build && next start -p 27400`)
node apps/web/scripts/smoke.mjs               # HTTP smoke test of every route

Fresh start

pnpm dev --fresh redeploys the contracts and truncates the local database. Restarting Anvil without --fresh is detected (no code at the recorded address) and handled the same way.

Individual services

pnpm chain                 # anvil only
pnpm chain:deploy          # deploy to the running anvil, rewrite .env.local
pnpm db:local              # embedded PostgreSQL only
pnpm web                   # next dev on 27400
pnpm indexer               # the indexer worker
pnpm provider              # the example provider on 27420
pnpm mcp                   # the MCP server (stdio)

Checks

pnpm contracts:test        # forge test (unit + fuzz + invariants)
pnpm typecheck             # every package
pnpm lint                  # eslint (web)
pnpm test                  # vitest (protocol, indexer, …)
pnpm build                 # next build
pnpm verify                # all of the above

Environment variables

See .env.example at the repository root. pnpm dev writes .env.local for the local stack; the web app, the indexer, the provider, the SDK CLI and the MCP server all read it (through scripts/with-env.mjs or Node's --env-file-if-exists).

Ports: web 27400 (dev) / 27410 (next start), provider 27420, Anvil 27430, PostgreSQL 27440.

Contract: HestrMandates

Source: packages/contracts/src/HestrMandates.sol (Solidity 0.8.26, EVM cancun, via-IR, OpenZeppelin Contracts 5.7.0). One deployment serves exactly one payment token; the resolver, challenge period and resolution timeout are immutable constructor parameters.

Mandate

Field Meaning
owner creator; the only address that can fund, withdraw, revoke, approve, accept and dispute
agent the only address whose call or EIP-712 signature can authorize a payment
token must equal the deployment token
balance / reserved funds held / funds reserved in open escrow; available = balance − reserved
txLimit hard cap per payment; approvals cannot raise it
dailyLimit / monthlyLimit caps per UTC calendar day / UTC calendar month (see below)
approvalThreshold amounts above it need an owner-signed Approval
recipients / services allowlists (addresses; keccak256 of the service identifier string)
expiry no authorizations at or after this timestamp
revoked set once by revoke(); no new authorizations afterwards
policyVersion always 1 in this version; bound into every signature so a future policy-change flow cannot replay old requests

Mandates are immutable except for revocation. To change any limit, revoke and create a replacement. Pending approvals for a revoked mandate can never be used because authorizePayment checks revoked before anything else.

Authorization

authorizePayment(PaymentRequest req, bytes agentSignature, Approval approval, bytes approvalSignature) performs, in this order:

  1. mandate exists · policy version · not revoked · not expired
  2. supported token · quote not expired (block.timestamp <= quoteExpiry) · delivery deadline in the future
  3. recipient allowlisted · service allowlisted
  4. amount > 0 · amount ≤ txLimit · today's authorized + amount ≤ dailyLimit · this month's authorized + amount ≤ monthlyLimit
  5. amount ≤ available funds
  6. request id unused (and non-zero) · nonce unused
  7. if amount > approvalThreshold: an Approval must be supplied (see Payments)
  8. caller is the agent, or agentSignature is a valid EIP-712 signature by the agent over req (EOA or ERC-1271)
  9. if required: the approval matches every field of the request, is unexpired, its nonce is unused, and approvalSignature is valid for the owner

Then, atomically: the day and month counters are increased by the amount, the nonce and request id are marked used, reserved is increased, and the Payment is stored as Escrowed. Settlement never counts the amount again.

previewAuthorization(req, approvalProvided) runs steps 1–7 as a view and returns the first failing ReasonCode (0 = ok). It is advisory: another authorization can consume the budget before yours is mined.

Time-window semantics

  • dayIndex = timestamp / 86400 — the UTC calendar day. A daily limit resets at 00:00:00 UTC.
  • monthIndex = year * 12 + (month − 1) from the proleptic Gregorian UTC date (UtcTime.sol, Hinnant's civil-from-days). A monthly limit resets on the first of the month at 00:00:00 UTC. It is not a rolling 30-day window.
  • Counters count authorized amounts at authorization time. Refunds (expired delivery, resolver decision, timeout) return funds to the available balance but do not restore consumed allowance. The console shows this on every mandate page.

Escrow state machine (contract states)

None ──authorizePayment──▶ Escrowed ──submitDelivery (recipient, before deadline)──▶ Delivered
Escrowed ──refundExpired (anyone, after deadline)──▶ Refunded
Delivered ──acceptDelivery (owner)──▶ Settled
Delivered ──claimSettlement (anyone, after challengePeriod)──▶ Settled
Delivered ──dispute (owner, within challengePeriod)──▶ Disputed
Disputed ──resolveDispute(providerAmount) (resolver)──▶ Settled (providerAmount > 0) | Refunded (providerAmount = 0)
Disputed ──resolveByTimeout (anyone, after resolutionTimeout)──▶ Refunded

Settled and Refunded are terminal. Every settlement path goes through one internal _settle that releases reserved exactly once, so double settlement and double refund are impossible, and withdraw can never touch reserved funds. Transfers use SafeERC20; every fund-moving function is nonReentrant.

Events

MandateCreated, MandateFunded, MandateWithdrawn, MandateRevoked, PaymentAuthorized, DeliverySubmitted, PaymentDisputed, PaymentSettled(requestId, mandateId, recipient, providerAmount, refundedAmount, reason). The indexer rebuilds every projection from these alone.

Tests (forge test)

58 tests: creation/funding/withdrawal; every ReasonCode failure path (unauthorized agent, wrong signer, signature over another request, wrong token, disallowed recipient/service, revoked, expiry boundary, quote-expiry boundary, deadline, zero amount, per-tx cap, daily cap with reset at 00:00 UTC, monthly cap with reset on the 1st, insufficient available funds counting reserved escrow, duplicate request id, replayed nonce, missing/mismatched/expired/forged/replayed approval, approval not bypassing hard caps or recipient rules); delivery, owner acceptance, claim after the challenge period, dispute with partial resolution, timeout refund, expired-delivery refund, allowance not restored by refunds, revocation with outstanding escrow, withdrawal of reserved funds refused, concurrent requests approaching a limit; reentrancy through a malicious token on settlement and withdrawal; a fuzz over amounts and funding; an independent EIP-712 digest check with vm.eip712HashTypedData; UtcTime against JavaScript-computed constants; and a handler-based invariant suite (48 runs × 64 calls) proving token.balanceOf(contract) == totalBalances == funded − withdrawn − paidOut and reserved == Σ open escrow ≤ balance for every mandate.

Review status: implemented and tested, not audited. Do not treat the tests as an audit.

Payments, approvals and settlement

Workflow states

The application tracks a request through more states than the contract knows. Only the onchain states mean that funds moved.

State Where Meaning
requested app evaluated; nothing signed or sent
awaiting_approval app above the threshold; waiting for the owner
approved app owner signed; expires if not submitted in time
rejected app owner rejected; no funds moved
expired app quote or approval expired before submission
submitted app transaction sent, waiting for inclusion
failed app reverted or could not be sent; no funds moved
escrowed onchain amount reserved in the mandate
delivered onchain provider recorded a receipt hash; challenge period running
disputed onchain owner disputed; locked until the resolver decides or the timeout passes
settled onchain provider paid (fully or partially)
refunded onchain escrow returned to the mandate's available balance

The console never shows success before a transaction receipt supports it: creation shows simulating → waiting for wallet → submitted → included → confirmed, and a reverted transaction is reported as failed with the decoded reason.

Approval queue

When amount > approvalThreshold, the agent calls POST /api/agent/approvals. The server re-evaluates the request; only requests that would pass every other rule are queued, so the owner never sees something the contract would reject anyway. The owner signs an EIP-712 Approval in the console that binds:

mandateId, policyVersion, requestId, token, amount, recipient, serviceId, jobHash, expiry, nonce under the domain { name: "Hestr", version: "1", chainId, verifyingContract }.

The server verifies the signature against the signed-in owner (EOA or ERC-1271 through the RPC) and that the struct matches the stored request before storing it. The agent then polls GET /api/agent/approvals/:requestId; the signature is only released to the mandate's agent. On submission, the server takes the approval from its own store, never from the agent's payload, so an agent cannot forge or reuse one. Duplicate decisions are refused (409), expired requests flip to expired, and a consumed approval is marked consumed by the indexer when PaymentAuthorized{approvalUsed: true} is seen.

An approval cannot raise txLimit, extend expiry, bypass the allowlists or spend reserved funds. An owner who needs a larger cap creates a new mandate.

Escrow and disputes

  1. Reserve — authorizePayment reserves the amount.
  2. Deliver — the provider calls submitDelivery(requestId, receiptHash) before deliveryDeadline. The challenge period (challengePeriod, read from the contract) starts.
  3. Accept — the owner may call acceptDelivery at any time during the challenge period to settle immediately.
  4. Claim — after the challenge period, anyone (typically the provider) may call claimSettlement.
  5. Dispute — within the challenge period the owner may call dispute. Funds stay locked.
  6. Resolve — the configured resolver calls resolveDispute(requestId, providerAmount) with any amount from 0 to the escrow; the rest returns to the mandate.
  7. Timeout — if the resolver does nothing for resolutionTimeout, anyone may call resolveByTimeout, which refunds the owner in full. This favours the owner by design and is disclosed in the console.
  8. Missed delivery — if no delivery arrives by the deadline, anyone may call refundExpired.

The resolver is a configured address chosen at deployment (see the Settings page). This is a trusted arbiter, not trustless adjudication. It cannot touch undisputed escrow, balances or policies.

A receipt hash is keccak256 of the provider's canonical result document. It proves that the provider committed to a result at that time; it does not prove the result is correct. The challenge period exists so the owner can check.

Relay versus direct submission

  • Relay (POST /api/agent/payments): the server verifies the agent's API signature, re-evaluates, fetches the stored approval if needed, simulates, sends with the server-held relayer key and waits for the receipt. Idempotent per request id (processed_tx), serialised per process so the relayer nonce never collides. If the simulation reverts, the decoded reason is returned with 422 and nothing is sent.
  • Direct (submitMode: 'direct' in the SDK): the agent sends authorizePayment from its own account (it needs gas) and then calls POST /api/agent/payments/record so the console trail is complete. The SDK falls back to direct submission automatically when the API answers relayer_unavailable.

Retries cannot double charge: the contract refuses a reused request id or nonce, and the relay's idempotency key returns the original transaction.

Integration: SDK, API, MCP and the example provider

Every integration path ends in the same place: an agent-signed EIP-712 PaymentRequest, an owner-signed Approval when the amount needs one, and HestrMandates.authorizePayment. Nothing below can bypass a rule.

TypeScript SDK (@hestr/sdk)

import { HestrClient } from '@hestr/sdk'

const hestr = new HestrClient({
  apiUrl: 'http://127.0.0.1:27400',      // the Hestr app
  rpcUrl: 'http://127.0.0.1:27430',
  chainId: 31337,
  contract: '0x…',                        // HestrMandates
  agentPrivateKey: process.env.HESTR_AGENT_PRIVATE_KEY, // the agent's own key — never the owner's
  submitMode: 'relay',                    // or 'direct' (agent pays gas)
}).useMandate(1n)

await hestr.getMandate(1n)                // MandateView from the chain
await hestr.getBudget(1n)                 // available, spent today / this month, remaining, active
const e = await hestr.evaluate({ mandateId: 1n, recipient, service: 'text.transform', amount: 750_000n, jobHash, quoteExpiry, deliveryDeadline })
// e.evaluation.checks (every rule, pass/fail), e.contractCode (previewAuthorization), e.requiresApproval
const a = await hestr.requestApproval(e.request, quote); await hestr.waitForApproval(e.request.requestId)
const s = await hestr.submitPayment(e.request, { approval: a.approval, approvalSignature: a.signature })
await hestr.getPayment(s.requestId)       // onchain state + receipt + workflow state

// Or, for a provider that speaks hestr-escrow/1:
const { response, quote, proof, receipt } = await hestr.fetchWithEscrow(url, { method: 'POST', body })

Policy evaluation is advisory until the contract executes: concurrent spending from the same mandate can make a previously valid request fail at submission. The SDK surfaces the contract's decoded reason when that happens.

CLI: hestr-agent demo | budget <id> | evaluate <id> <recipient> <service> <amount> (see packages/sdk/src/cli.ts; env in .env.example).

HTTP API

Agent routes carry an EIP-191 signature by the agent over hestr-agent-auth/1\n{METHOD} {path}\n{timestamp}\n{keccak(body)} in x-hestr-agent, x-hestr-timestamp (±5 min), x-hestr-signature. Owner routes need the Sign-In-With-Ethereum session cookie and same-origin requests. Public routes need neither.

Method Path Auth Purpose
GET /api/status public mode, chain, contract params, index freshness
GET /api/mandates/:id public (+owner extras) live mandate + budget + indexed history; label, agent name and request trail only for the owner
GET /api/payments/:requestId public (+owner extras) onchain state, receipt, workflow state; approval trail only for the owner
POST /api/auth/nonce, /api/auth/verify, /api/auth/logout; GET /api/auth/session — SIWE
GET/POST /api/agents, DELETE /api/agents/:id owner agent registry (private names)
GET /api/mandates owner the owner's mandates + unconfirmed drafts
POST /api/mandates/draft, PATCH/DELETE /api/mandates/draft/:id owner offchain draft and transaction progress
GET /api/approvals?status= owner queue / history
POST /api/approvals/:id/decide owner {decision: 'approve', approval, signature} or {decision: 'reject', note}
GET /api/payments owner all payments and requests
GET /api/activity?kind=&result=&mandateId=&from=&to=&format=csv owner activity log, CSV export
GET /api/agent/mandates agent active mandates for the signing agent
POST /api/agent/evaluate agent advisory evaluation
POST /api/agent/approvals, GET /api/agent/approvals/:requestId agent request / poll an approval
POST /api/agent/payments agent relay a signed authorization
POST /api/agent/payments/record agent record a directly submitted authorization
GET /api/indexer/tick Authorization: Bearer CRON_SECRET one bounded indexer pass
POST /api/demo/run owner, local mode only streams the demo agent flow (NDJSON)

Errors are { error: { code, message, details } } with meaningful HTTP status codes (401 unauthenticated, 403 wrong principal, 409 state conflict, 422 policy rejected, 503 unconfigured).

MCP server (@hestr/mcp)

Stdio server with five tools: hestr_get_budget, hestr_evaluate_request, hestr_request_payment (waits for owner approval up to a timeout, then submits), hestr_get_payment, hestr_fetch_paid_resource (fetch + pay on 402). Each tool calls the SDK, which calls the contract; an MCP tool cannot bypass the owner's approval because the approval signature only ever comes from the owner's wallet through the console.

{ "mcpServers": { "hestr": {
  "command": "pnpm", "args": ["--filter", "@hestr/mcp", "start"],
  "env": { "HESTR_API_URL": "http://127.0.0.1:27400", "HESTR_RPC_URL": "http://127.0.0.1:27430",
           "HESTR_CHAIN_ID": "31337", "HESTR_CONTRACT_ADDRESS": "0x…",
           "HESTR_AGENT_PRIVATE_KEY": "<agent key, never the owner>", "HESTR_MANDATE_ID": "1" }
} } }

Smoke test: node packages/mcp/test/mcp-smoke.mjs (real MCP client over stdio).

Example provider (examples/provider)

A deterministic paid service: uppercase, slugify, sort-lines, word-stats, json-keys (0.25–1.00 tUSD) and summarize-outline (60 tUSD, deliberately above a typical approval threshold). No external AI credentials.

Exchange (hestr-escrow/1, Hestr's own scheme — not x402):

  1. POST /transform {op, input} → 402 with Hestr-Payment-Required: base64(EscrowQuote) — amount, recipient, service + serviceId, jobHash (keccak of the canonical job), quoteExpiry, deliveryDeadline, chainId, contract.
  2. The agent authorizes the payment (SDK payForQuote).
  3. POST /transform again with Hestr-Payment: base64({quoteId, requestId, mandateId, txHash}). The provider reads the payment from the contract and checks state Escrowed, recipient, amount, serviceId and jobHash against its quote. It performs the work, computes receiptHash = keccak(canonical result), calls submitDelivery from its own key, and answers 200 with the result and Hestr-Payment-Receipt.
  4. A repeated proof returns the stored result (idempotent: true); GET /jobs/:requestId returns the job; POST /claim/:requestId claims after the challenge period.

x402 status: not available (scheme gap)

Verified on 2026-11-01 (UTC):

  • The x402 reference facilitator (https://x402.org/facilitator/supported) lists exact, upto and batch-settlement for Base Sepolia and exact for several non-EVM networks. It does not list Robinhood Chain (eip155:4663).
  • A third-party facilitator, Priors (https://facilitator.priors.trade/supported), lists { x402Version: 1, scheme: "exact", network: "robinhood" }, { x402Version: 1, scheme: "exact", network: "eip155:4663" } and { x402Version: 2, scheme: "exact", network: "eip155:4663" }, settling from signer 0x81da8C0a62B9eb84308dfB90108B59a018632319.
  • The x402 exact scheme on EVM (specs/schemes/exact/scheme_exact_evm.md in the x402-foundation/x402 repository) settles by direct token transfer to payTo: EIP-3009 transferWithAuthorization by default, Permit2 permitWitnessTransferFrom through x402ExactPermit2Proxy, or ERC-7710 delegations. The facilitator broadcasts and "cannot modify the amount or destination".
  • The x402 headers are PAYMENT-REQUIRED, PAYMENT-SIGNATURE and PAYMENT-RESPONSE; facilitator endpoints are /verify and /settle. npm packages are @x402/core, @x402/evm, @x402/next, @x402/fetch, @x402/mcp, among others.

Why Hestr cannot plug into exact

Hestr's product is the escrow: the payer's funds are reserved in the mandate contract, counted against daily and monthly caps, and released to the provider only after delivery plus acceptance or an unchallenged period. The exact scheme moves funds immediately and directly from the payer to the payee. A standard facilitator verifies an EIP-3009/Permit2 authorization against the token, not a call into HestrMandates, so it can neither enforce a mandate nor create escrow. Bridging the two would mean either giving up escrow (settle directly, which the mandate contract would not see) or writing a custom scheme that facilitators do not understand.

x402's batch-settlement scheme does use an escrow contract with off-chain vouchers, but it is a different escrow model (seller-redeemed vouchers, no owner acceptance or dispute window) and is listed only for Base Sepolia.

What Hestr does instead

  • The escrow payment API (hestr-escrow/1: Hestr-Payment-Required / Hestr-Payment / Hestr-Payment-Receipt) is its own, fully implemented integration. It is deliberately not labeled x402 or standards-compliant.
  • Standard x402 integration is marked unavailable in the product until a compatible adapter exists. A plausible future adapter is a Hestr-specific x402 scheme (for example hestr-escrow) with a facilitator whose /verify calls previewAuthorization and whose /settle relays authorizePayment; that would still require facilitator support that does not exist today.
  • Provider authors who want plain x402 on Robinhood Chain can use the Priors facilitator with USDG's EIP-3009 support; they would not get mandates or escrow.

Data, indexing and privacy

Two kinds of data

Public chain data — mandates, balances, reserved escrow, payments, receipts, settlements, every event. Anyone can read these from the contract or an explorer; Hestr's public API returns them to anyone (/api/mandates/:id, /api/payments/:requestId).

Private application metadata — agent names, mandate labels and drafts, provider quotes, policy evaluations, approval requests and decisions, notes, and the activity log. Stored in PostgreSQL, scoped to the owner address, returned only to a session signed in as that owner. Sample data on the landing page is explicitly labeled and never mixed with connected-wallet data.

Database (packages/db)

Drizzle ORM with SQL migrations (packages/db/drizzle). Drivers by DATABASE_URL: postgres:// (node-postgres pool; production and the dev stack), pglite://dir (embedded, single-process) and memory:// (tests). Migrations apply automatically outside production; in production run pnpm db:migrate (or set AUTO_MIGRATE=1).

Tables: sessions, auth_nonces, agents, mandate_meta, payment_requests, approvals, activity (private); chain_events, mandates, payments, spend_windows, indexer_checkpoints, processed_tx (projections and bookkeeping). Amounts are numeric(78,0) base units.

Indexer (services/indexer)

  • Authority: chain events decide balances and financial status. The console reads live contract views for the current numbers and the index for history; the freshness line on every page shows the indexed block, its age and STALE when the index lags.
  • Idempotent: each log is keyed chainId:blockHash:logIndex; projections are applied in the same transaction as the event insert, exactly once.
  • Confirmations: only blocks ≤ head − HESTR_CONFIRMATIONS are processed (0 locally, 2 by default on a configured network).
  • Reorg-aware: every pass re-reads the checkpoint block's hash; on mismatch it walks back to the last block whose stored hash still matches, deletes orphaned events and replays the projections.
  • Durable checkpoints: indexer_checkpoints stores the last processed block number and hash after every chunk, plus the head and the last error.
  • Reconciliation: rebuildProjections() recomputes mandates, payments, spend_windows and chain-side activity purely from chain_events; a restart simply resumes from the checkpoint.
  • Where it runs: pnpm indexer as a long-running process next to the database (VPS, container, systemd). Serverless functions are request-scoped and must not host it. For deployments without a worker, GET /api/indexer/tick runs one bounded pass (Vercel cron, every minute, 40-second budget) and HESTR_INDEX_ON_READ=1 runs a throttled pass on API reads. Both are fallbacks that keep the console usable, not replacements for the worker.

Activity log

Every meaningful transition is appended, never updated: who (actor and role: owner, agent, provider, resolver, anyone), when, kind, amount and token, mandate and provider, result and reason, transaction hash and block when one exists, and the source (chain from indexed events, app from workflow actions). Filter by kind, result, mandate and time range; export the filtered view as CSV from /app/activity.

Retries and double charging

The relay stores an idempotency key per request id before sending and returns the original transaction on a retry. The contract refuses reused request ids and nonces. A failed simulation sends nothing. Therefore a client retry can never create a second escrow for the same quote.

Deployment

Live deployment (Robinhood Chain, chain id 4663): HestrMandates at 0xFaa601D5f94991aeDDc46fdA520A6bA287c43D8f, deployed from the operator's wallet through /app/deploy on 2026-11-03 UTC (block 104970299, tx 0xda9fb238…ebb273); payment token USDG, resolver 0xAD7d9A785054B592B9F4cA76255018d736850EF9, 3-day challenge period, 30-day resolution timeout. Runtime bytecode re-verified against the compiled artifact by network:configure; source verified on Sourcify (creation and runtime match). The manifest is packages/contracts/deployments/4663.json. The contracts are tested, not audited.

The web app can also be hosted before any contract exists: without HESTR_* variables it runs in unconfigured mode (landing page, docs and a console gate that lists what is missing), and /api/status reports that state.

1. Contracts (Robinhood Chain, chain id 4663)

Option A — from your wallet (no key leaves the wallet). Open the console at /app/deploy, connect the wallet that will pay gas, keep USDG as the payment token, set the resolver (defaults to the connected address), check the parameters (the page reads the token and estimates the fee) and deploy. After inclusion the page reads the immutables back from the new contract and shows the manifest; download it as packages/contracts/deployments/4663.json, then from the repository root:

pnpm network:configure packages/contracts/deployments/4663.json --verify --vercel --ship

The page also reports the manifest to the site (POST /api/deployments); the server re-verifies it on chain (code, runtime bytecode with immutables masked, immutables, creation receipt) and stores only those public facts, listed at GET /api/deployments. An operator can therefore find a fresh deployment with node scripts/find-deployment.mjs --site https://www.hestragent.xyz (or by deployer address through the explorer) and run pnpm network:configure --chain 4663 --address 0x… --deployed-at-block N … without any paste.

network:configure re-checks everything on chain before it writes anything: chain id, code present, runtime bytecode equal to the compiled artifact (immutable slots masked), immutables read back, payment token on the verified list, deployment block from the transaction receipt. --verify submits the sources to Sourcify through forge verify-contract, --vercel upserts the HESTR_* variables on the linked Vercel project, --ship deploys the site and reads /api/status back. Without flags it only writes the manifest and prints the environment block.

Option B — Foundry script with a deployer key you hold on your own machine.

cd packages/contracts
export HESTR_TOKEN=0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168     # USDG (6 decimals) — verified on-chain 2026-11-01
export HESTR_RESOLVER=0x…                                        # the dispute resolver you control
export HESTR_CHALLENGE_PERIOD=259200                             # 3 days
export HESTR_RESOLUTION_TIMEOUT=2592000                          # 30 days
forge script script/Deploy.s.sol:Deploy --rpc-url https://rpc.mainnet.chain.robinhood.com \
  --broadcast --verify --verifier sourcify --private-key $DEPLOYER_KEY

This writes packages/contracts/deployments/4663.json (contract, token, resolver, periods, block). Verify the source on Sourcify or Blockscout (https://robinhoodchain.blockscout.com) and record the transaction in this file. Robinhood Chain is an Arbitrum Nitro L2 using ETH for gas; cancun opcodes (MCOPY) used by OpenZeppelin 5.7 were confirmed executing on chain 4663 by read-only eth_call on 2026-10-05 (see Verification).

Review status: the contracts are tested (58 Foundry tests including invariants) but not audited. Say so wherever the deployment is described.

2. Database

Any PostgreSQL 14+ (Neon, Supabase, RDS, a VPS). On Vercel the Neon marketplace integration writes DATABASE_URL for you; on the Neon Free plan schedule the cron tick every 15 minutes and keep HESTR_INDEX_ON_READ=1, otherwise the compute never sleeps. Apply migrations once:

DATABASE_URL=postgres://… pnpm db:migrate

3. Web app (Vercel or any Node host)

  • Vercel (CLI upload from the repository root, no Git connection required): the project's root directory is apps/web with "include files outside root" enabled so the workspace packages install; apps/web/vercel.json pins the Next.js preset and schedules GET /api/indexer/tick every 15 minutes (the tick answers 200 skipped until the network and database are configured; tick-on-read keeps the index fresh between runs). The root .vercelignore keeps tools/, contract artifacts, screenshots and every .env* file out of the upload; its patterns are anchored so src/app/docs is never matched.
  • Minimum variables for an unconfigured host: SESSION_SECRET (the app refuses to start in production without it), CRON_SECRET, NEXT_PUBLIC_SITE_URL (falls back to Vercel's production URL when unset).
  • Environment (see .env.example): HESTR_CHAIN_ID=4663, HESTR_RPC_URL, optional HESTR_PUBLIC_RPC_URL (browser-reachable), HESTR_CONTRACT_ADDRESS, HESTR_TOKEN_ADDRESS/SYMBOL/NAME/DECIMALS, HESTR_RESOLVER_ADDRESS, HESTR_CHALLENGE_PERIOD, HESTR_RESOLUTION_TIMEOUT, HESTR_DEPLOYED_AT_BLOCK, HESTR_CONFIRMATIONS=2, DATABASE_URL, SESSION_SECRET (random, 32+ bytes), NEXT_PUBLIC_SITE_URL, optional HESTR_RELAYER_PRIVATE_KEY (a dedicated, modestly funded key; server-side only), optional NEXT_PUBLIC_REPO_URL.
  • Never set HESTR_DEMO_AGENT_PRIVATE_KEY or HESTR_PROVIDER_URL on a live deployment; the demo runner refuses to run outside local mode anyway.
  • The landing page and /docs render dynamically so the footer always shows the live mode, network and contract.

4. Indexer worker (required for a healthy index)

Run pnpm indexer as a long-running process with the same HESTR_* and DATABASE_URL variables, for example with systemd, Docker, or a small VPS:

pnpm install --prod=false
pnpm --filter @hestr/indexer start

The Vercel cron tick is a bounded fallback (≈40 s of indexing per minute), not a replacement.

5. Example provider (optional, for demos)

pnpm provider with HESTR_PROVIDER_PRIVATE_KEY (its own key with a little ETH for submitDelivery). Anyone can write a provider: implement the hestr-escrow/1 exchange and verify escrow against the contract before doing work.

6. Agents

Give each agent its own key and register its address under Agents. Supply the key to the SDK/MCP process via HESTR_AGENT_PRIVATE_KEY. Without a relayer the agent needs ETH for gas (submitMode: 'direct').

Remaining external prerequisites

  • A deployer wallet with ETH on Robinhood Chain and explicit authorization to deploy (not done).
  • A resolver address and the people or process behind it.
  • A PostgreSQL instance and a host for the indexer worker.
  • (Optional) a relayer key funded with ETH.
  • (Optional) a custom domain; NEXT_PUBLIC_SITE_URL must match it for SIWE.

Token

$HESTR is the Hestr token on Robinhood Chain (chain id 4663). It is separate from the protocol: mandates pay providers in the deployment's payment token (USDG on Robinhood Chain), and no rule in HestrMandates depends on $HESTR.

Ticker $HESTR
Network Robinhood Chain (4663)
Contract address 0x55af869ba8e39c09e367A5aFa9b7B7cd0fC5c939
Official channels the Token section of the landing page · x.com/HestrAgent

The only official contract address is the one shown on the landing page's Token section and posted by @HestrAgent. Any other address, airdrop, presale or "claim" is not Hestr.

How the address gets published (operators)

The site never shows an address that was not verified on chain. apps/web/config/token.json holds the facts and is written only by:

pnpm token:ca 0xYourTokenAddress            # checksums, reads name/symbol/decimals/totalSupply by eth_call on 4663, writes config + this page
pnpm token:ca 0xYourTokenAddress --ship     # same, then deploys the site to production and reads the live page back
pnpm token:ca --clear                       # back to "soon"

The script refuses addresses without code on chain 4663 and prints what it read before writing anything.

Verification sources

Everything the product depends on externally was checked on 2026-11-01 (UTC) from this machine. Nothing here was copied from a third-party project's README as authoritative configuration.

Robinhood Chain

Fact How it was verified Value
Chain id eth_chainId on https://rpc.mainnet.chain.robinhood.com (over DNS-over-HTTPS; the local ISP hijacks robinhood.com) and on https://robinhood.drpc.org 0x1237 = 4663
Network name / gas token Chainlist entry for chain 4663; third-party RPC docs (QuickNode, Dwellir, Chainstack) agree the chain is Arbitrum Nitro based with ETH as gas Robinhood Chain, ETH
Explorer Chainlist lists Blockscout https://robinhoodchain.blockscout.com and RobinScanner; Chainstack's MetaMask guide lists robinscan.io Blockscout used in the app
Testnet third-party docs list chain id 46630 listed as unverified in chains.ts
Official docs host docs.robinhood.com is DNS-hijacked on the development network and could not be fetched; the official RPC itself answered over DoH noted; see Status
cancun opcodes MCOPY/TSTORE confirmed by read-only eth_call on chain 4663 on 2026-10-05 (earlier project on this machine, almachain/docs/NETWORKS.md) contracts target cancun

Payment token

Fact How it was verified Value
USDG address eth_call decimals(), symbol(), name() on 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 via robinhood.drpc.org decimals 6, symbol USDG, name Global Dollar

Only this token is listed as verified in packages/protocol/src/chains.ts; any other token requires HESTR_TOKEN_SYMBOL and HESTR_TOKEN_DECIMALS to be supplied explicitly and should be verified the same way.

x402

Fact How it was verified
Reference facilitator supported kinds GET https://x402.org/facilitator/supported — no eip155:4663 entry
Priors facilitator supported kinds GET https://facilitator.priors.trade/supported — exact v1 and v2 on eip155:4663, signer 0x81da…2319
exact EVM settlement mechanics specs/schemes/exact/scheme_exact_evm.md in github.com/x402-foundation/x402 (EIP-3009 / Permit2 proxy / ERC-7710; funds go directly to payTo)
Protocol headers and packages README of the same repository (PAYMENT-REQUIRED, PAYMENT-SIGNATURE, PAYMENT-RESPONSE; @x402/* packages; /verify, /settle)
CDP facilitator https://api.cdp.coinbase.com/platform/v2/x402/supported could not be fetched from this machine (TLS interception); not relied on

Dependencies

Versions were taken from the npm registry on 2026-11-01 and pinned in package.json files with a pnpm-lock.yaml: Next 16.4.3, React 19.3.1, wagmi 3.7.9, viem 2.57.14, TanStack Query 5.105.1, Drizzle ORM 0.45.4 / drizzle-kit 0.31.11, PGlite 0.5.14, pg 8.23.1, zod 4.6.7, @modelcontextprotocol/sdk 1.32.2, Tailwind CSS 4.3.3, TypeScript 5.9.3, vitest 5.0.5, Foundry 1.8.0, OpenZeppelin Contracts 5.7.0, solc 0.8.26.

Brand

The dominant logo blue was measured from assets/hestr-logo.png (1254×1254, solid background): mean of the blue pixels #5b91be (93.7 % of the image). Derived assets in apps/web/public/brand are resized copies on the same blue; no background removal was applied.

Status and limitations

Version 0.2.0 — local development is complete end to end. HestrMandates is deployed on Robinhood Chain at 0xFaa601D5f94991aeDDc46fdA520A6bA287c43D8f (Sourcify-verified) and https://www.hestragent.xyz runs against it; the contracts are tested, not audited.

What works today

  • HestrMandates with 58 passing Foundry tests (unit, fuzz, invariants, reentrancy, EIP-712 cross-check).
  • Console: wallet sign-in (SIWE), agent registry, mandate creation with a pre-signing summary and staged transaction states, funding, withdrawal of uncommitted funds, revocation, approval queue with EIP-712 signing, payment detail with accept / dispute / claim / refund / resolve actions by role, activity log with filters and CSV export, settings with the live mode and deployment parameters.
  • Landing page (art direction after contentarchitecture.dev, re-composed for Hestr): floating pill navigation with a live status marquee and page minimap; split hero with a dense typographic spiral around the untouched mark on its blue disc; a scroll "dive in" stage (panel compresses, a circle opens onto the next section); a receipt-style walk through the 17 contract checks with a scroll-driven counter; character seams between paper and dark sections; a staggered rule cascade beside a character field sampled from the mark; the interactive policy walkthrough (production evaluator) and the labeled allowed/blocked illustration; the real _checkPolicy() source in a code panel; a showcase of real console captures drawn as character fields that reveal on hover; settlement, a switchable owner/agent ticket card, FAQ accordion, final call and an honest footer. Geist Sans / Geist Mono.
  • SDK, CLI, MCP server, example provider, indexer, relay, demo runner.
  • Automated checks, all passing on 2026-11-02 (UTC) against the local stack: forge test 58/58; vitest 15 (protocol) + 1 (indexer replay on PGlite) + 4 (web); scripts/e2e-local.mjs 40/40 API + contract checks; apps/web/scripts/ui-flow.mjs 23/23 browser-driven console checks (connect → SIWE → register → create + fund through the form → demo → approve in the queue → delivery → accept → settled); apps/web/scripts/smoke.mjs 29/29 HTTP checks on the production build; packages/mcp/test/mcp-smoke.mjs PASS; eslint and typecheck clean; next build clean.

Known limitations

  • Not audited. Treat the contracts as reviewed by tests only.
  • Single token per deployment, by design. No multi-asset conversion or price oracle.
  • Immutable mandates. Changing limits means revoke + replace.
  • x402 unavailable (scheme gap; see x402).
  • Resolver is a trusted party; timeout refunds favour the owner.
  • EOA and ERC-1271 signers are supported for owners and agents; account-abstraction flows beyond ERC-1271 were not tested.
  • Robinhood Chain official documentation site could not be fetched from the development network; chain facts were verified against the official RPC and on-chain calls instead.
  • No live data: the product has no users, volume, partners or revenue, and claims none.
  • Sample data on the landing page is labeled as such and is static.
  • The dev stack uses Anvil's public development keys; they must never be reused anywhere else.