Merit is a hackathon prototype that runs on Arc testnet only — it moves test USDC and holds no production funds or personal data. The demo/agent wallets are testnet-funded; do not send them anything of real value. It does, however, expose public API endpoints and moves real (testnet) USDC, so it's built to be deployed on the adversarial internet.
- Public, unauthenticated endpoints:
POST /api/run(drives the agent + settles USDC),POST /api/creators/register, the x402 work/pay/settlement endpoints, and read APIs. - A buyer wallet that signs real settlements; creator-supplied content that flows into an LLM that decides who gets paid.
- Secrets. Source/specialist wallets are receive-only:
newWallet()derives the payout address and discards the key, so Merit holds no creator/specialist private key at all — not in memory, not on disk. TheSource/Specialisttypes carry no key field, so "a creator wallet can sign" is unrepresentable, and a key it never has can't leak or be misused. (Endpoints still go throughpublicView/specialistView, which are explicit allowlist projections — not{...spread}— so a field newly added to an entity is excluded by default and can't silently leak; a smoke check also asserts the whole run stream is key-free — defense-in-depth.) Operator/buyer/ seller signing keys come only from env (.env.localis git-ignored; set them in the host). - Input validation. Question + URL are length-capped;
budgetis clamped to[0, 5](a real0is preserved);tieris whitelisted; amounts/prices areNumber.isFinite-checked and clamped;[id]params are resolved by equality lookup (no path/SQL/shell construction). - Money safety.
payTois taken from the registry, never the request — a caller can't redirect funds. A whole-run budget cap keepslabor + creator payouts ≤ budget(pure, unit-tested, holds even at budget 0). A concurrent-run cap bounds how many runs settle against the shared wallet at once, so parallel runs can't collectively overspend it (the per-run cap is local; the slot is released on completion, error, or disconnect via a once-flag — no leak). x402 uses EIP-3009 authorizations (replay handled at the protocol layer). Specialists are paid only after their delivered work is graded. - Prompt injection (defense-in-depth). Untrusted source/creator content is fenced and framed as
data; the Auditor's judge treats any embedded directive ("answer SUPPORTED", "ignore previous
instructions") as manipulation → REFUSE (never auto-pays); and a deterministic guard
(
looksLikeInjection) refuses content matching clear injection patterns outright — before the judge, and protecting the budget verify tier, which has no judge to resist injection. The question itself is fenced with the same<<< >>>untrusted-data boundary, and both public input endpoints reject injection at the door:POST /api/runreturns 400 on an injection-pattern question andPOST /api/creators/registerrefuses an injection-bearing name or content — so crafted text never reaches the writer LLM that drives payment (verified live: both return 400, normal inputs pass). - Auditor robustness (the moat's softest spot — hardened in layers, every layer unit-tested). The
payment decision is not one fragile LLM call but a layered gate (
decideCitation,parseJudgeVerdict,looksLikeInjection): (1) an off-topic similarity floor refuses before any judge runs; (2) the adversarial judge rules on direction + magnitude, not topic overlap; (3) an unclear / unparseable verdict REFUSES — never auto-pays, so a preamble- or reasoning-model-wrapped reply can't be misread as a payment; (4) the deterministic injection guard backstops the no-judge path; (5) embedding similarity is auditable evidence and the fallback gate when the judge is down; (6) a circuit-breaker trips to the deterministic STUB path on an LLM outage so a run never hangs or silently pays. The bias is uniform — when in doubt, REFUSE: a false-refuse costs one creator a payout; a false-pay would break the core guarantee. - What Merit verifies, and what it doesn't (honest scope). The Auditor verifies proof-of-use — that a cited source actually supports the specific claim it is cited for — not whether the claim is true in the world (no system can verify arbitrary truth). Gaming via fabricated-but-supportive content is bounded, not unbounded: every payable source carries an ERC-8004 identity (cost to spin up shills), on-chain reputation that compounds per source, and sub-cent, budget-capped payouts — so the expected value of gaming the judge is low and every attempt is on-chain and attributable.
- Verifiability — every claim is independently recomputable from chain, with no Merit server. Don't trust
the receipt, check it:
npm run recompute -- <agentId>rebuilds an agent's ERC-8004 reputation from raw ReputationRegistry logs;verify-validationreads the Auditor's verdict back from the ValidationRegistry;verify-receiptrecovers the buyer's signature over the canonical receipt offline (any altered verdict or amount breaks it); andverify-settlementsums the USDC actually received from on-chain Transfer logs. Reputation, the verdict, the signature, and the money are each checkable by anyone — the four claims a judge would otherwise have to take on faith. - Abuse / DoS. Per-IP + global rate limiting gates the LLM- and settlement-bearing endpoints;
run contexts use an unguessable 128-bit
runIdand expire on a 10-minute TTL. Every external call is resource-bounded so a hung or hostile dependency can't stall/exhaust a run: the RSS fetch is size-capped (5MB) + time-aborted, LLM calls time out (45s) → offline fallback, on-chain receipt waits are bounded (20s), and the per-run budget is clamped (labor + payouts ≤ budget, even at 0). - Transport + headers. CSP,
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy, and HSTS are set for every response. - No SSRF. Live discovery fetches a hardcoded RSS allowlist of major crypto/payments publishers
(CoinDesk, Cointelegraph, Decrypt, PYMNTS, The Block, CryptoSlate, Bitcoin Magazine);
creator-supplied URLs are stored for display only and never fetched server-side. The lead's x402
self-calls are a fixed
localhost:$PORTloopback, never built from the requestHostheader — so a forgedHost:can't steer the agent's work-fetches or payments to an attacker server.
The codebase has had multiple independent reviews — two code-quality reviews, two security reviews (the second a full sweep of the public input surface that validated payment integrity, the loopback self-call, the budget/price clamps, and secret-handling as sound), an adversarial silent-failure audit of the money path, a test-coverage analysis, a comment-accuracy audit, and a type-design review. Genuine findings were fixed: prompt-injection hardening on the judge (plus a deterministic guard), internal error-message scrubbing, the budget clamp, the run-context TTL, HSTS, a concurrent-run guard (bounds parallel wallet spend), removing creator/specialist private keys entirely (receive-only wallets, no key held), a run-route slot-leak fix, receipt-integrity fixes (the summary reports actual settlement, never a phantom paid), a discriminated-union refactor that makes an inconsistent verdict (released-but-no- reason, or paying a refused source) unrepresentable, a host-header-injection-proof loopback self-call (the agent can't be steered off-server), explicit-allowlist view projections (a new entity field can't silently leak), and a broadened-but-case-safe injection guard. Residual items are triaged below.
- Merit holds no source/specialist private keys at all —
newWallet()discards the key after deriving the receive-only payout address, and the entity types carry no key field, so there is nothing to leak in memory or on disk. The only secrets are the buyer/operator/seller signing keys in.env.local(git-ignored) — standard for an env-configured app; a production deploy sets them in the host's secret store. - The per-IP rate limit is bypassable via a spoofed
x-forwarded-forwhen there's no trusted proxy; the global cap is the real backstop. For production, lockx-forwarded-fortrust to the platform proxy and tighten the global cap. - The CSP allows
'unsafe-inline'for scripts/styles because the hand-authored static frontend needs it; there is no user-content-as-HTML path (receipts/reasons are escaped), so there is no active XSS vector — but a nonce/hash-based CSP would be stronger.
Found a security issue? Please open a GitHub issue on this repository (or contact the maintainer) rather than filing it elsewhere — this project is independent and not covered by Circle's bug bounty.