Skip to content

fix: record auto-approved spend#3

Open
TateLyman wants to merge 3 commits into
1lystore:mainfrom
TateLyman:codex/record-auto-approved-spend
Open

fix: record auto-approved spend#3
TateLyman wants to merge 3 commits into
1lystore:mainfrom
TateLyman:codex/record-auto-approved-spend

Conversation

@TateLyman
Copy link
Copy Markdown

Summary

  • record sign_tx and sign_x402 spend even when the request is auto-approved below the approval threshold and no wallet session already exists
  • add an internal budget-ledger session for accounting so this does not grant wallet scope access
  • cover the x402 path with a regression test that verifies the USDC daily budget is reduced

Validation

@dcprotocol/vault@2.0.2 test /Users/tatelyman/Documents/New project 7/dcp/packages/dcp-vault
vitest run

RUN v3.2.4 /Users/tatelyman/Documents/New project 7/dcp/packages/dcp-vault

✓ tests/policy-engine.test.ts (11 tests) 5ms
✓ tests/agent-registry.test.ts (18 tests) 5ms
✓ tests/nonce-store.test.ts (11 tests) 8ms
✓ tests/permission-store.test.ts (26 tests) 7ms
✓ tests/validation.test.ts (25 tests) 11ms
[19:52:13] INFO: incoming request
reqId: "req-1"
req: {
"method": "GET",
"url": "/health",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-1"
res: {
"statusCode": 200
}
responseTime: 1.4485830068588257
[19:52:13] INFO: incoming request
reqId: "req-2"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-2"
res: {
"statusCode": 200
}
responseTime: 115.25916695594788
[19:52:13] INFO: incoming request
reqId: "req-3"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-3"
res: {
"statusCode": 200
}
responseTime: 113.05279099941254
[19:52:13] INFO: incoming request
reqId: "req-4"
req: {
"method": "POST",
"url": "/v1/vault/sign_x402",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-4"
res: {
"statusCode": 200
}
responseTime: 17.73574995994568
[19:52:13] INFO: incoming request
reqId: "req-5"
req: {
"method": "POST",
"url": "/v1/vault/sign_x402",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-5"
res: {
"statusCode": 400
}
responseTime: 0.6247080564498901
[19:52:13] INFO: incoming request
reqId: "req-6"
req: {
"method": "POST",
"url": "/v1/vault/sign_x402",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-6"
res: {
"statusCode": 200
}
responseTime: 2.816249966621399
[19:52:13] INFO: incoming request
reqId: "req-7"
req: {
"method": "GET",
"url": "/budget/check?amount=0&currency=USDC&chain=solana",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-7"
res: {
"statusCode": 200
}
responseTime: 0.18445801734924316
[19:52:13] INFO: incoming request
reqId: "req-8"
req: {
"method": "POST",
"url": "/v1/vault/unlock-mcp",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-8"
res: {
"statusCode": 200
}
responseTime: 0.4420830011367798
[19:52:13] INFO: incoming request
reqId: "req-9"
req: {
"method": "POST",
"url": "/v1/vault/lock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-9"
res: {
"statusCode": 200
}
responseTime: 0.4309999942779541
[19:52:13] INFO: incoming request
reqId: "req-a"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-a"
res: {
"statusCode": 400
}
responseTime: 111.64408302307129
[19:52:13] INFO: incoming request
reqId: "req-b"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-b"
res: {
"statusCode": 400
}
responseTime: 112.92166602611542
[19:52:13] INFO: incoming request
reqId: "req-c"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-c"
res: {
"statusCode": 400
}
responseTime: 110.95754098892212
[19:52:13] INFO: incoming request
reqId: "req-d"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-d"
res: {
"statusCode": 400
}
responseTime: 110.21525001525879
[19:52:13] INFO: incoming request
reqId: "req-e"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-e"
res: {
"statusCode": 429
}
responseTime: 109.11120796203613
[19:52:13] INFO: incoming request
reqId: "req-f"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-f"
res: {
"statusCode": 429
}
responseTime: 0.278999924659729
[19:52:13] INFO: incoming request
reqId: "req-g"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-g"
res: {
"statusCode": 429
}
responseTime: 0.1553339958190918
[19:52:13] INFO: incoming request
reqId: "req-h"
req: {
"method": "POST",
"url": "/v1/vault/unlock",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-h"
res: {
"statusCode": 429
}
responseTime: 0.14808297157287598
[19:52:13] INFO: incoming request
reqId: "req-i"
req: {
"method": "GET",
"url": "/scopes",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-i"
res: {
"statusCode": 200
}
responseTime: 0.15012502670288086
[19:52:13] INFO: incoming request
reqId: "req-j"
req: {
"method": "GET",
"url": "/agents",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-j"
res: {
"statusCode": 200
}
responseTime: 0.1253749132156372
[19:52:13] INFO: incoming request
reqId: "req-k"
req: {
"method": "GET",
"url": "/consent",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-k"
res: {
"statusCode": 200
}
responseTime: 0.08424997329711914
[19:52:13] INFO: incoming request
reqId: "req-l"
req: {
"method": "POST",
"url": "/consent/non-existent-id/approve",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-l"
res: {
"statusCode": 400
}
responseTime: 0.20058298110961914
✓ tests/server.test.ts (16 tests) 1062ms
✓ REST Server > Unlock Rate Limiting > should return 429 after too many failed attempts 445ms
[19:52:13] INFO: incoming request
reqId: "req-m"
req: {
"method": "POST",
"url": "/consent/non-existent-id/deny",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-m"
res: {
"statusCode": 400
}
responseTime: 0.060208916664123535
[19:52:13] INFO: incoming request
reqId: "req-n"
req: {
"method": "POST",
"url": "/revoke/non-existent-agent",
"host": "localhost:80",
"remoteAddress": "127.0.0.1"
}
[19:52:13] INFO: request completed
reqId: "req-n"
res: {
"statusCode": 200
}
responseTime: 0.07862496376037598

Test Files 6 passed (6)
Tests 107 passed (107)
Start at 14:52:12
Duration 1.76s (transform 357ms, setup 113ms, collect 745ms, tests 1.10s, environment 0ms, prepare 478ms)

@dcprotocol/vault@2.0.2 build /Users/tatelyman/Documents/New project 7/dcp/packages/dcp-vault
tsc

@1lystore
Copy link
Copy Markdown
Owner

1lystore commented May 19, 2026

Thanks, this is a real bug and the direction is right. Auto-approved payments still need to debit daily budget even when there is no wallet session. Before we merge, can you tighten the regression coverage?
Add the same auto-approved spend test for /v1/vault/sign, since the implementation changes both sign_tx and sign_x402.
Add a repeated-spend test proving multiple under-threshold auto-approved requests reduce the daily budget and eventually hit the daily limit.
Please rename the synthetic scope from budget.ledger to something clearly internal, e.g. internal.budget.ledger, so it is never confused with a user-grantable scope.
The important invariant: this internal ledger session must only support budget accounting. It must not grant crypto.wallet.solana, sign:solana, or any user capability.

@TateLyman TateLyman force-pushed the codex/record-auto-approved-spend branch from 2676440 to 5b02a73 Compare May 19, 2026 17:09
@TateLyman
Copy link
Copy Markdown
Author

Updated, thanks. I tightened the regression coverage around the accounting invariant:

  • renamed the synthetic ledger scope to internal.budget.ledger;
  • added the same no-session auto-approved spend regression for POST /v1/vault/sign;
  • added a repeated under-threshold spend regression that debits the daily budget and then hits BUDGET_EXCEEDED_DAILY;
  • asserted the internal ledger session grants only internal.budget.ledger, not crypto.wallet.solana, sign:solana, or a user-capability scope.

I also rebased onto current main and re-ran:

  • pnpm --filter @dcprotocol/vault test -> 109 passed
  • pnpm --filter @dcprotocol/vault typecheck
  • git diff --check

@1lystore
Copy link
Copy Markdown
Owner

Thanks, this is the right direction and it addresses the accounting bug we were worried about: auto-approved spend now gets recorded even when there is no user-approved wallet session. The tests for both sign_x402 and sign, plus repeated under-threshold spend hitting BUDGET_EXCEEDED_DAILY, are exactly the invariants we need.

One important blocker before merge: the budget ledger must stay internal only. Right now the fallback creates a normal active agent session with granted_scopes = [internal.budget.ledger], and /agents returns all active sessions. That can leak this synthetic ledger into Desktop/Mobile as if it were a real connected agent, which is bad UX and could confuse users/security review.

Please update this so internal budget accounting does not appear as a user-facing agent/session. A clean fix would be to filter sessions whose scopes are only internal.* out of /agents, while keeping recordSpend/checkBudget behavior unchanged. Also please add a regression assertion that after an auto-approved spend, /agents does not expose the internal.budget.ledger session to clients.

Security invariant remains: internal.budget.ledger must only be usable for budget accounting. It must not become user-grantable, must not satisfy wallet/sign scopes, and must not show up as a connected agent in UI-facing APIs.

@TateLyman
Copy link
Copy Markdown
Author

Pushed the follow-up in 055d963.

Changes:

  • /agents now filters sessions whose scopes are only internal.*, so internal.budget.ledger stays hidden from UI-facing agent/session lists.
  • Budget accounting still uses the internal ledger session for auto-approved sign / sign_x402 spend.
  • Updated the x402, sign, and repeated-spend regressions to assert the synthetic ledger is not exposed through /agents.

Verification:

  • pnpm --config.engine-strict=false --filter @dcprotocol/vault typecheck passed.
  • pnpm --config.engine-strict=false --filter @dcprotocol/vault build passed.
  • Targeted server test was attempted, but this local checkout has better-sqlite3 compiled for Node module ABI 127 while the active Node here is v24 / ABI 137, so Vitest fails before running the suite. CI on the repo Node 22 target should be the useful signal.

@TateLyman TateLyman force-pushed the codex/record-auto-approved-spend branch from 055d963 to 6d0eb7e Compare May 19, 2026 20:09
@TateLyman
Copy link
Copy Markdown
Author

Updated the branch for the internal-session blocker and rebased onto current main.

Changes in the latest push:

  • filters sessions whose granted scopes are only internal.* out of the user-facing /agents response;
  • keeps the internal budget ledger available only for recordSpend / budget accounting;
  • adds regression assertions that auto-approved sign_x402, auto-approved /v1/vault/sign, and repeated auto-approved spend do not expose the internal.budget.ledger session through /agents;
  • keeps the repeated-spend daily-budget exhaustion coverage.

Local validation under Node 22.22.3:

  • pnpm --filter @dcprotocol/vault test -> 109 passed
  • pnpm --filter @dcprotocol/vault typecheck -> passed
  • git diff --check -> passed

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.

2 participants