From af69d76c1db5ca7e10af3d5124073f2c4b89dee6 Mon Sep 17 00:00:00 2001 From: Tate Lyman Date: Sat, 16 May 2026 14:51:42 -0500 Subject: [PATCH 1/3] fix: record auto-approved spend --- packages/dcp-vault/src/server/index.ts | 28 +++++++++++++++++--- packages/dcp-vault/tests/server.test.ts | 34 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index 9e66ec0..291d1ea 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -272,6 +272,7 @@ const REMOTE_APPROVAL_FETCH_TIMEOUT_MS = parseInt( process.env.DCP_TELEGRAM_APPROVAL_FETCH_TIMEOUT_MS || '8000', 10 ); +const BUDGET_LEDGER_SCOPE = 'budget.ledger'; interface RemoteApprovalCommand { id: string; @@ -311,6 +312,23 @@ function findActiveSessionForScope(agentName: string, scope: string): string | u return undefined; } +function getBudgetLedgerSessionId(agentName: string): string { + const existing = findActiveSessionForScope(agentName, BUDGET_LEDGER_SCOPE); + if (existing) { + return existing; + } + + const session = storage.createSession( + agentName, + [BUDGET_LEDGER_SCOPE], + 'once', + new Date(Date.now() + 24 * 60 * 60 * 1000), + { purpose: 'Budget ledger for auto-approved spend' } + ); + + return session.id; +} + function scopeMatches(pattern: string, scope: string): boolean { if (pattern === scope) return true; if (pattern.endsWith('.*')) { @@ -3968,8 +3986,9 @@ async function buildServer(): Promise { const signResult = await signTransaction(payload, masterKey, chain, unsigned_tx); // Record spend event if amount provided - if (amount !== undefined && amount > 0 && effectiveSessionId) { - storage.recordSpend(effectiveSessionId, amount, txCurrency, chain, 'sign_tx', 'committed', { + if (amount !== undefined && amount > 0) { + const spendSessionId = effectiveSessionId || getBudgetLedgerSessionId(agent_name); + storage.recordSpend(spendSessionId, amount, txCurrency, chain, 'sign_tx', 'committed', { idempotencyKey: idempotency_key, }); } @@ -4302,8 +4321,9 @@ async function buildServer(): Promise { const signature = signSolanaMessage(encryptedKey, masterKey, payload, 'base64'); - if (parsedAmount !== undefined && currency && effectiveSessionId) { - storage.recordSpend(effectiveSessionId, parsedAmount, currency, chain, 'sign_x402', 'committed', { + if (parsedAmount !== undefined && currency) { + const spendSessionId = effectiveSessionId || getBudgetLedgerSessionId(agent_name); + storage.recordSpend(spendSessionId, parsedAmount, currency, chain, 'sign_x402', 'committed', { destination: recipient, }); } diff --git a/packages/dcp-vault/tests/server.test.ts b/packages/dcp-vault/tests/server.test.ts index 74303ef..c9ee6c2 100644 --- a/packages/dcp-vault/tests/server.test.ts +++ b/packages/dcp-vault/tests/server.test.ts @@ -180,6 +180,40 @@ describe('REST Server', () => { const body = JSON.parse(response.body); expect(body.error.message).toContain('currency is required'); }); + + it('should record auto-approved x402 spend without an existing wallet session', async () => { + const payload = Buffer.from(JSON.stringify({ + x402Version: 1, + network: 'solana', + resource: 'https://api.example.test/low-value', + nonce: 'test-nonce-auto-approved', + })).toString('base64'); + + const response = await server.inject({ + method: 'POST', + url: '/v1/vault/sign_x402', + payload: { + network: 'solana', + payload, + amount: 0.00001, + currency: 'USDC', + recipient: 'pay-sh-test-recipient', + purpose: 'x402 auto-approved ledger test', + agent_name: 'x402-budget-agent', + }, + }); + + expect(response.statusCode).toBe(200); + + const budgetResponse = await server.inject({ + method: 'GET', + url: '/budget/check?amount=0¤cy=USDC&chain=solana', + }); + + expect(budgetResponse.statusCode).toBe(200); + const budgetBody = JSON.parse(budgetResponse.body); + expect(budgetBody.remaining.daily).toBeCloseTo(4.99999, 8); + }); }); describe('MCP Unlock Bridge', () => { From 2b16328e985042afd327690c5066156331216759 Mon Sep 17 00:00:00 2001 From: Tate Lyman Date: Tue, 19 May 2026 12:08:17 -0500 Subject: [PATCH 2/3] test: cover auto-approved spend accounting --- packages/dcp-vault/package.json | 1 + packages/dcp-vault/src/server/index.ts | 2 +- packages/dcp-vault/tests/server.test.ts | 164 ++++++++++++++++++++++-- pnpm-lock.yaml | 7 +- 4 files changed, 160 insertions(+), 14 deletions(-) diff --git a/packages/dcp-vault/package.json b/packages/dcp-vault/package.json index 1b8893d..3d1b4de 100644 --- a/packages/dcp-vault/package.json +++ b/packages/dcp-vault/package.json @@ -46,6 +46,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@solana/web3.js": "^1.98.0", "@types/better-sqlite3": "^7.6.0", "@types/node": "^22.10.2", "@types/prompts": "^2.4.9", diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index 291d1ea..840cb32 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -272,7 +272,7 @@ const REMOTE_APPROVAL_FETCH_TIMEOUT_MS = parseInt( process.env.DCP_TELEGRAM_APPROVAL_FETCH_TIMEOUT_MS || '8000', 10 ); -const BUDGET_LEDGER_SCOPE = 'budget.ledger'; +const BUDGET_LEDGER_SCOPE = 'internal.budget.ledger'; interface RemoteApprovalCommand { id: string; diff --git a/packages/dcp-vault/tests/server.test.ts b/packages/dcp-vault/tests/server.test.ts index c9ee6c2..5931e93 100644 --- a/packages/dcp-vault/tests/server.test.ts +++ b/packages/dcp-vault/tests/server.test.ts @@ -21,6 +21,31 @@ import type { FastifyInstance } from 'fastify'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { Keypair, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; + +function createUnsignedSolanaTransfer(feePayerAddress: string): string { + const tx = new Transaction({ + feePayer: new PublicKey(feePayerAddress), + recentBlockhash: Keypair.generate().publicKey.toBase58(), + }).add( + SystemProgram.transfer({ + fromPubkey: new PublicKey(feePayerAddress), + toPubkey: Keypair.generate().publicKey, + lamports: 1, + }) + ); + + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); +} + +function createX402Payload(nonce: string): string { + return Buffer.from(JSON.stringify({ + x402Version: 1, + network: 'solana', + resource: `https://api.example.test/${nonce}`, + nonce, + })).toString('base64'); +} describe('REST Server', () => { let server: FastifyInstance; @@ -69,6 +94,27 @@ describe('REST Server', () => { } storage.close(); // Close so server can open its own connection + fs.writeFileSync( + path.join(testVaultDir, 'config.json'), + JSON.stringify({ + daily_budget: { + LEDGER: 0.0001, + TXLEDGER: 0.0001, + REPEAT: 0.0001, + }, + tx_limit: { + LEDGER: 0.0001, + TXLEDGER: 0.0001, + REPEAT: 0.0001, + }, + approval_threshold: { + LEDGER: 0.00005, + TXLEDGER: 0.00005, + REPEAT: 0.00005, + }, + }, null, 2) + ); + // Build and start the server (will create its own storage connection) server = await buildServer(); await server.ready(); @@ -182,21 +228,14 @@ describe('REST Server', () => { }); it('should record auto-approved x402 spend without an existing wallet session', async () => { - const payload = Buffer.from(JSON.stringify({ - x402Version: 1, - network: 'solana', - resource: 'https://api.example.test/low-value', - nonce: 'test-nonce-auto-approved', - })).toString('base64'); - const response = await server.inject({ method: 'POST', url: '/v1/vault/sign_x402', payload: { network: 'solana', - payload, + payload: createX402Payload('test-nonce-auto-approved'), amount: 0.00001, - currency: 'USDC', + currency: 'LEDGER', recipient: 'pay-sh-test-recipient', purpose: 'x402 auto-approved ledger test', agent_name: 'x402-budget-agent', @@ -207,12 +246,115 @@ describe('REST Server', () => { const budgetResponse = await server.inject({ method: 'GET', - url: '/budget/check?amount=0¤cy=USDC&chain=solana', + url: '/budget/check?amount=0¤cy=LEDGER&chain=solana', + }); + + expect(budgetResponse.statusCode).toBe(200); + const budgetBody = JSON.parse(budgetResponse.body); + expect(budgetBody.remaining.daily).toBeCloseTo(0.00009, 8); + + const agentsResponse = await server.inject({ + method: 'GET', + url: '/agents', + }); + const agentsBody = JSON.parse(agentsResponse.body); + const ledgerSession = agentsBody.agents.find( + (agent: { agent_name: string }) => agent.agent_name === 'x402-budget-agent' + ); + + expect(ledgerSession.granted_scopes).toEqual(['internal.budget.ledger']); + expect(ledgerSession.granted_scopes).not.toContain('crypto.wallet.solana'); + expect(ledgerSession.granted_scopes).not.toContain('sign:solana'); + }); + + it('should record auto-approved sign transaction spend without an existing wallet session', async () => { + await server.inject({ + method: 'POST', + url: '/v1/vault/unlock', + payload: { passphrase }, + }); + + const response = await server.inject({ + method: 'POST', + url: '/v1/vault/sign', + payload: { + chain: 'solana', + unsigned_tx: createUnsignedSolanaTransfer(x402WalletAddress), + amount: 0.00001, + currency: 'TXLEDGER', + agent_name: 'sign-budget-agent', + idempotency_key: 'sign-budget-agent-auto-approved-1', + }, + }); + + expect(response.statusCode).toBe(200); + + const budgetResponse = await server.inject({ + method: 'GET', + url: '/budget/check?amount=0¤cy=TXLEDGER&chain=solana', }); expect(budgetResponse.statusCode).toBe(200); const budgetBody = JSON.parse(budgetResponse.body); - expect(budgetBody.remaining.daily).toBeCloseTo(4.99999, 8); + expect(budgetBody.remaining.daily).toBeCloseTo(0.00009, 8); + + const agentsResponse = await server.inject({ + method: 'GET', + url: '/agents', + }); + const agentsBody = JSON.parse(agentsResponse.body); + const ledgerSession = agentsBody.agents.find( + (agent: { agent_name: string }) => agent.agent_name === 'sign-budget-agent' + ); + + expect(ledgerSession.granted_scopes).toEqual(['internal.budget.ledger']); + expect(ledgerSession.granted_scopes).not.toContain('crypto.wallet.solana'); + expect(ledgerSession.granted_scopes).not.toContain('sign:solana'); + }); + + it('should debit repeated under-threshold auto-approved spend until the daily limit is reached', async () => { + for (const nonce of ['repeat-auto-1', 'repeat-auto-2']) { + const response = await server.inject({ + method: 'POST', + url: '/v1/vault/sign_x402', + payload: { + network: 'solana', + payload: createX402Payload(nonce), + amount: 0.00004, + currency: 'REPEAT', + recipient: 'pay-sh-repeat-recipient', + purpose: 'x402 repeated auto-approved ledger test', + agent_name: 'repeat-budget-agent', + }, + }); + + expect(response.statusCode).toBe(200); + } + + const remainingResponse = await server.inject({ + method: 'GET', + url: '/budget/check?amount=0¤cy=REPEAT&chain=solana', + }); + const remainingBody = JSON.parse(remainingResponse.body); + expect(remainingBody.remaining.daily).toBeCloseTo(0.00002, 8); + + const overLimitResponse = await server.inject({ + method: 'POST', + url: '/v1/vault/sign_x402', + payload: { + network: 'solana', + payload: createX402Payload('repeat-auto-3'), + amount: 0.00004, + currency: 'REPEAT', + recipient: 'pay-sh-repeat-recipient', + purpose: 'x402 repeated auto-approved ledger test', + agent_name: 'repeat-budget-agent', + }, + }); + + expect(overLimitResponse.statusCode).toBe(400); + const overLimitBody = JSON.parse(overLimitResponse.body); + expect(overLimitBody.error.code).toBe('BUDGET_EXCEEDED_DAILY'); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92816aa..3f19284 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@solana/web3.js': + specifier: ^1.98.0 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) '@types/better-sqlite3': specifier: ^7.6.0 version: 7.6.13 @@ -4069,7 +4072,7 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)): dependencies: ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -4082,7 +4085,7 @@ snapshots: delay: 5.0.0 es6-promisify: 5.0.0 eyes: 0.1.8 - isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)) json-stringify-safe: 5.0.1 stream-json: 1.9.1 uuid: 8.3.2 From 6d0eb7e5d936d76508c32abd4dcd8e0d18b6820c Mon Sep 17 00:00:00 2001 From: Tate Lyman Date: Tue, 19 May 2026 14:27:28 -0500 Subject: [PATCH 3/3] Hide internal budget ledger sessions from agents --- packages/dcp-vault/src/server/index.ts | 7 ++++++- packages/dcp-vault/tests/server.test.ts | 19 +++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index 840cb32..1e697cc 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -274,6 +274,11 @@ const REMOTE_APPROVAL_FETCH_TIMEOUT_MS = parseInt( ); const BUDGET_LEDGER_SCOPE = 'internal.budget.ledger'; +function isInternalOnlySession(session: { granted_scopes?: string[] }): boolean { + const scopes = session.granted_scopes || []; + return scopes.length > 0 && scopes.every((scope) => scope.startsWith('internal.')); +} + interface RemoteApprovalCommand { id: string; consent_id: string; @@ -2584,7 +2589,7 @@ async function buildServer(): Promise { // ============================================================================ server.get('/agents', async () => { - const sessions = storage.listActiveSessions(); + const sessions = storage.listActiveSessions().filter((session) => !isInternalOnlySession(session)); return { agents: sessions.map((s) => ({ diff --git a/packages/dcp-vault/tests/server.test.ts b/packages/dcp-vault/tests/server.test.ts index 5931e93..d3f1949 100644 --- a/packages/dcp-vault/tests/server.test.ts +++ b/packages/dcp-vault/tests/server.test.ts @@ -262,9 +262,7 @@ describe('REST Server', () => { (agent: { agent_name: string }) => agent.agent_name === 'x402-budget-agent' ); - expect(ledgerSession.granted_scopes).toEqual(['internal.budget.ledger']); - expect(ledgerSession.granted_scopes).not.toContain('crypto.wallet.solana'); - expect(ledgerSession.granted_scopes).not.toContain('sign:solana'); + expect(ledgerSession).toBeUndefined(); }); it('should record auto-approved sign transaction spend without an existing wallet session', async () => { @@ -307,9 +305,7 @@ describe('REST Server', () => { (agent: { agent_name: string }) => agent.agent_name === 'sign-budget-agent' ); - expect(ledgerSession.granted_scopes).toEqual(['internal.budget.ledger']); - expect(ledgerSession.granted_scopes).not.toContain('crypto.wallet.solana'); - expect(ledgerSession.granted_scopes).not.toContain('sign:solana'); + expect(ledgerSession).toBeUndefined(); }); it('should debit repeated under-threshold auto-approved spend until the daily limit is reached', async () => { @@ -338,6 +334,17 @@ describe('REST Server', () => { const remainingBody = JSON.parse(remainingResponse.body); expect(remainingBody.remaining.daily).toBeCloseTo(0.00002, 8); + const agentsResponse = await server.inject({ + method: 'GET', + url: '/agents', + }); + const agentsBody = JSON.parse(agentsResponse.body); + const ledgerSession = agentsBody.agents.find( + (agent: { agent_name: string }) => agent.agent_name === 'repeat-budget-agent' + ); + + expect(ledgerSession).toBeUndefined(); + const overLimitResponse = await server.inject({ method: 'POST', url: '/v1/vault/sign_x402',