feat(anchor): spend ix — policy enforcement + PDA-signed SPL transfer#2
Merged
acedatacloud-dev merged 1 commit intoMay 4, 2026
Conversation
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-chainPolicy. The owner is intentionally not a signer on this ix — that's the entire point of the architecture.Policy gates (evaluation order)
!policy.pausedVaultPausedclock < policy.expires_atVaultExpiredamount > 0PerCallCapExceeded(intentional reuse)amount <= policy.per_call_capPerCallCapExceededused_todayiftoday != policy.day_indexused_today + amount <= daily_capDailyCapExceededendpoint_hash ∈ allowlist[..len]EndpointNotAllowedsigner.key == policy.delegation_keyDelegationKeyMismatchnonce > policy.last_nonceNonceReplayThen a PDA-signed
token::transferwith seeds["vault", owner, agent_id, bump]. State commit is the last step:used_today += amount; last_nonce = args.nonce. EmitsSpendEvent.Account constraints
vault["vault", vault.owner, vault.agent_id]policy["policy", vault],has_one = vaultusdc_mintaddress = vault.usdc_mint(locked at create-time)vault_usdc_atausdc_mint,vault)recipient_usdc_atainit_if_neededix in the same tx if necessary — kept the program ix lean.has_one = vaulton 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:
used_today = 250000,last_nonce = 1, vault ATA debited, recipient ATA creditedamount > per_call_capPerCallCapExceededDailyCapExceededEndpointNotAllowedDelegationKeyMismatchNonceReplayEach 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
amount > 0reusesPerCallCapExceeded: semantically a zero-amount spend exceeds a "meaningful spend" bar and we want to keep the error enum tight. Open to aNonZeroAmountvariant if reviewers prefer.day_indexcheck 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.init_if_neededforrecipient_usdc_ata: the upstream x402 facilitator always has a pre-existing USDC ATA. Addinginit_if_neededwould 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
Out of scope
pause/resume/clawback/update_policy— own PR after this landsPlan reference
.plans/X402GUARD.md§6 + §9 Day 2 Track A.Verification
anchor build && anchor testfrom the workspace root. Reviewer machine.