Skip to content

Pick a direction for the spend guard (own persisted JS guard or move to Circle policy) #9

Description

@wd7zfpysvs-ui

Summary

The original feedback asked us to move the $10/day spend guard from in-memory JS to on-chain enforcement via ZeroDev session-key policies — making the smart contract literally unable to sign an over-limit tx. PR #5 migrated payments from ZeroDev to Circle Programmable Wallets, which kills the session-key path. The current guard (payment.js:23-31, backed by ClickHouse via memory.js:69-88) is now persistent across restarts — that's better than before — but it's still a JS-side check, not enforced by the wallet itself.

This issue is about picking a direction and committing. The current half-way state is harder to defend on stage than either pure option.

Problem

A judge with on-chain credentials (the feedback called out a founding engineer at a contracts company) will ask: "what stops the JS from being modified to skip the check?" Today's honest answer is "nothing — it's a trust-me guard." That undercuts the rest of the on-chain narrative.

Two viable directions, with different tradeoffs:

Option A — Own the persisted JS guard. Call it what it is: a daily spend ledger backed by ClickHouse. Demo it: show the row appearing in ClickHouse after each payment, show the guard refusing the next purchase when the day's cap is hit. Honest, simple, and the ClickHouse demo beat is reinforced.

Option B — Move enforcement into Circle's transaction policies. Circle Programmable Wallets supports server-side policy rules at the wallet level (spend limits, allowlists, multi-sig). If a policy rule denies the tx, Circle's API rejects it before submission. That's enforced by the custodian, not by our JS. Closer to "the wallet literally cannot do it" — though it's still custodial enforcement, not smart-contract enforcement.

This issue tracks the decision and the implementation of whichever path we pick.

Proposed Implementation

Step 1 — decide (60 minutes, no code)

Document the chosen approach in a paragraph in the README under "Spend Guard." Either:

  • "$10/day enforced by ClickHouse-backed ledger before each Circle transaction submission. Persists across restarts. JS-side; can be bypassed by modifying the agent source, not by an external attacker."

OR

  • "$10/day enforced by Circle transaction policies attached to the agent wallet. Even a modified agent cannot submit an over-limit tx — Circle's API rejects it. Configured at wallet provisioning."

Step 2A — if "own it" (~30 lines in README + 0 lines in code)

The code already does this. Add the README paragraph, update the demo script to include a "watch the ClickHouse row appear" beat, and add a guarded over-limit attempt to the demo (set MAX_DAILY_USD = 0.5 for one demo run, try to buy a $1 item, show the guard refusing).

Step 2B — if "move to Circle policy" (~80 lines)

  • Provisioning script (scripts/setup-circle-policy.js, ~50 lines): uses Circle's policy API to attach a dailyLimit rule of $10 USD to CIRCLE_WALLET_ID. Idempotent.
  • payment.js (~20 lines): remove checkSpendLimit and the agent_spend table. Catch Circle's POLICY_DENIED (or whatever the exact error code is — verify against Circle docs) and surface it as "daily spend cap exceeded" rather than a generic submission error.
  • README (~10 lines): update the spend-guard description; add npm run setup:circle-policy to onboarding.

Out of scope (explicit non-goals)

Risks / open questions

  • Circle policy support: confirm that Circle Developer-Controlled Wallets supports a per-wallet dailyLimit policy rule in the current SDK before committing to Option B. If it doesn't, Option A is the only choice.
  • Demo timing: Option B is more code; if the demo is imminent, Option A may be the right call even if Option B is the "better" answer technically.
  • Reset semantics: if Option B, confirm Circle's policy reset window matches our "midnight UTC" assumption. Mismatched windows are worse than no enforcement.

Test Cases

Unit (Option A)

  • checkSpendLimit throws when getSpendToday() + amountUSD exceeds MAX_DAILY_USD.
  • getSpendToday returns the sum of today's agent_spend rows and ignores yesterday's rows.

Integration (Option B)

  • npm run setup:circle-policy attaches the daily-limit policy to the wallet; circle wallet get (or equivalent API call) reflects the attached policy.
  • A transaction that would exceed $10 returns Circle's POLICY_DENIED error.

End-to-end (either option)

  • Run the agent twice with a $9 product. First run succeeds. Second run fails at the payment step with the spend-cap error before any tx is submitted (Option A) or with a POLICY_DENIED (Option B).
  • On the day boundary (or after a manual reset), the agent can purchase again.

Negative / security

  • Concurrent invocations: two agent runs racing to spend $9 each should result in exactly one success and one cap-exceeded error, not two successes. (Option A: read-then-write is not atomic — call out as a known limitation if not fixing here. Option B: Circle's policy is the authority.)

Acceptance criteria

  • A decision is documented in the README under "Spend Guard."
  • The code and the README agree (no stale "in-memory limit, resets on process restart" language).
  • The demo script includes a beat that visibly demonstrates the guard refusing an over-limit purchase.

References

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions