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 9e66ec0..1e697cc 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -272,6 +272,12 @@ const REMOTE_APPROVAL_FETCH_TIMEOUT_MS = parseInt( process.env.DCP_TELEGRAM_APPROVAL_FETCH_TIMEOUT_MS || '8000', 10 ); +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; @@ -311,6 +317,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('.*')) { @@ -2566,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) => ({ @@ -3968,8 +3991,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 +4326,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..d3f1949 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(); @@ -180,6 +226,143 @@ 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 response = await server.inject({ + method: 'POST', + url: '/v1/vault/sign_x402', + payload: { + network: 'solana', + payload: createX402Payload('test-nonce-auto-approved'), + amount: 0.00001, + currency: 'LEDGER', + 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=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).toBeUndefined(); + }); + + 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(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).toBeUndefined(); + }); + + 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 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', + 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'); + }); }); describe('MCP Unlock Bridge', () => { 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