From 1d85fbb99bf1f15ae8958e96042f5ad5b76f30ec Mon Sep 17 00:00:00 2001 From: 1lystore Date: Wed, 17 Jun 2026 00:16:45 +0530 Subject: [PATCH 1/5] fix(vault): revoke/delete agent now purges its standing grants (no orphaned sessions) revokeAgentConnection + deleteAgentConnection + cloud-connect revoke now call revokeAgentSessions(name), so an agent's Allow grants don't linger in Active Sessions (and can't be inherited via name reuse). Test added. --- packages/dcp-vault/src/server/index.ts | 14 +++++++++- .../dcp-vault/tests/cloud-connect-mcp.test.ts | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index 80d3a89..d8a9d53 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -2764,8 +2764,13 @@ async function buildServer(): Promise { }); } + // Also kill the agent's standing grants (Allow sessions) — otherwise they + // linger in Active Sessions and could be inherited if the name is reused. + const agent = storage.getAgentConnection(request.params.id); const revoked = storage.revokeAgentConnection(request.params.id); - return { revoked }; + let sessionsRevoked = 0; + if (agent?.name) sessionsRevoked = storage.revokeAgentSessions(agent.name); + return { revoked, sessions_revoked: sessionsRevoked }; }); // ---------------------------------------------------------------------------- @@ -2863,7 +2868,10 @@ async function buildServer(): Promise { } const agentId = request.params.id; + // Capture the name BEFORE deleting so we can purge its standing grants too. + const agentName = storage.getAgentConnection(agentId)?.name; const deleted = storage.deleteAgentConnection(agentId); + if (deleted && agentName) storage.revokeAgentSessions(agentName); // Also delete the local agent config file if it exists if (deleted) { @@ -3775,6 +3783,8 @@ async function buildServer(): Promise { error: { code: 'OWNER_AUTH_REQUIRED', message: 'Owner authentication required' }, }); } + // Purge the agent's standing grants (Allow sessions) on revoke too. + const revokedAgentName = storage.getAgentConnection(request.params.agentId)?.name; const link = storage.getCloudConnectLinkByAgent(request.params.agentId); if (!link) { // No link record — still revoke the agent connection if it exists. @@ -3784,11 +3794,13 @@ async function buildServer(): Promise { error: { code: 'AGENT_NOT_FOUND', message: 'Agent not found' }, }); } + if (revokedAgentName) storage.revokeAgentSessions(revokedAgentName); // Fast-fail at the relay too (denylist + kill refresh chains) — Rule #7. relayClient?.sendCloudConnectRevoke(request.params.agentId); return { revoked: true, agent_id: request.params.agentId }; } const result = storage.revokeCloudConnectLink(link.link_id); + if (revokedAgentName) storage.revokeAgentSessions(revokedAgentName); relayClient?.sendCloudConnectRevoke(result.agent_id || request.params.agentId); return { revoked: true, agent_id: result.agent_id }; } diff --git a/packages/dcp-vault/tests/cloud-connect-mcp.test.ts b/packages/dcp-vault/tests/cloud-connect-mcp.test.ts index 586e2ce..09b2bd0 100644 --- a/packages/dcp-vault/tests/cloud-connect-mcp.test.ts +++ b/packages/dcp-vault/tests/cloud-connect-mcp.test.ts @@ -386,6 +386,34 @@ describe('Cloud-Connect vault MCP handler', () => { expect((res.body as any).result.isError).toBe(true); }); + it('revoking an agent also purges its standing grants (no orphaned sessions)', async () => { + // Give the agent a standing Allow grant. + await server.inject({ + method: 'POST', + url: `/v1/agent-connections/${agentId}/auto-approve`, + headers: owner(), + payload: { scope: 'identity.name' }, + }); + let agentsList = JSON.parse( + (await server.inject({ method: 'GET', url: '/v1/agent-connections', headers: owner() })).body + ); + let me = agentsList.agents.find((a: any) => a.agent_id === agentId); + expect(me.auto_approve_scopes).toContain('identity.name'); + + // Revoke the agent → its sessions must be gone. + await server.inject({ + method: 'POST', + url: `/v1/cloud-connect/agents/${agentId}/revoke`, + headers: owner(), + }); + agentsList = JSON.parse( + (await server.inject({ method: 'GET', url: '/v1/agent-connections', headers: owner() })).body + ); + me = agentsList.agents.find((a: any) => a.agent_id === agentId); + // Either the agent is gone, or it has no active standing grants left. + expect(me ? me.auto_approve_scopes : []).not.toContain('identity.name'); + }); + it('rejects calls for a revoked agent', async () => { await server.inject({ method: 'POST', From 073df8dc560308670a1544ae6713f56eb9c64c28 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Wed, 17 Jun 2026 00:24:45 +0530 Subject: [PATCH 2/5] fix(cloud-connect): rich scope guidance in vault_read/write descriptions + self-correcting deny - vault_read/vault_write descriptions now list canonical scopes (identity.name, credentials.api.) and tell agents to call vault_scope_guide instead of guessing (fixes ChatGPT requesting non-existent 'profile') - SCOPE_NOT_PERMITTED message now points the agent to vault_scope_guide to self-correct - bump @dcprotocol/vault to 3.0.2 --- packages/dcp-vault/package.json | 2 +- packages/dcp-vault/src/server/index.ts | 41 ++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/dcp-vault/package.json b/packages/dcp-vault/package.json index 9230719..c1c6b3c 100644 --- a/packages/dcp-vault/package.json +++ b/packages/dcp-vault/package.json @@ -1,6 +1,6 @@ { "name": "@dcprotocol/vault", - "version": "3.0.1", + "version": "3.0.2", "description": "DCP - Local vault runtime with CLI and server", "type": "module", "main": "./dist/index.js", diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index d8a9d53..073cf34 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -6614,10 +6614,24 @@ const CLOUD_CONNECT_MCP_TOOLS = [ }, { name: 'vault_read', - description: 'Read a credential/data scope (gated by scope + on-device consent).', + description: + "Read data from the user's vault (gated by scope + on-device consent). " + + 'Use EXACT canonical DCP scopes — do NOT invent names like "profile", "user", or "username". ' + + 'Common scopes: identity.name (the user\'s name), identity.email, identity.phone, ' + + 'address.home, credentials.api. (e.g. credentials.api.openai). ' + + 'For "what is my name" use identity.name; for email use identity.email. ' + + 'If unsure which scope, call vault_scope_guide FIRST, then read.', inputSchema: { type: 'object', - properties: { scope: { type: 'string' }, fields: { type: 'array', items: { type: 'string' } } }, + properties: { + scope: { + type: 'string', + description: + 'Exact canonical DCP scope, e.g. identity.name, identity.email, credentials.api.openai. ' + + 'Never guess aliases (profile, username, user.email). Call vault_scope_guide if unsure.', + }, + fields: { type: 'array', items: { type: 'string' } }, + }, required: ['scope'], }, }, @@ -6663,10 +6677,23 @@ const CLOUD_CONNECT_MCP_TOOLS = [ }, { name: 'vault_write', - description: 'Write a credential/data scope (gated by scope + on-device consent).', + description: + "Store data in the user's vault (gated by scope + on-device consent). " + + 'Use EXACT canonical DCP scopes — do NOT invent names. ' + + 'Common scopes: identity.name, identity.email, identity.phone, address.home, ' + + 'credentials.api. (e.g. credentials.api.openai). ' + + 'If unsure which scope, call vault_scope_guide FIRST.', inputSchema: { type: 'object', - properties: { scope: { type: 'string' }, value: {} }, + properties: { + scope: { + type: 'string', + description: + 'Exact canonical DCP scope, e.g. identity.name, credentials.api.openai. ' + + 'Never guess aliases. Call vault_scope_guide if unsure.', + }, + value: {}, + }, required: ['scope'], }, }, @@ -6818,7 +6845,11 @@ Rules: text: JSON.stringify( { error: 'SCOPE_NOT_PERMITTED', - message: `Not permitted: the owner has not granted '${required}' to this agent.`, + message: + `Not permitted: '${required}' is not a granted scope for this agent. ` + + `If you guessed the scope name, it may be wrong — call vault_scope_guide for the ` + + `canonical scopes (e.g. identity.name, identity.email) and retry with the correct one. ` + + `Otherwise the owner needs to grant '${required}' in DCP.`, }, null, 2 From 8cffa1490b098655b4997fd876b40d6f4f712477 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Sat, 20 Jun 2026 00:24:27 +0530 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20@dcprotocol/wallet-core=20=E2=80=94?= =?UTF-8?q?=20shared=20wallet=20brain=20+=20runner=20consolidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the pure, native-dep-free wallet logic into a new @dcprotocol/wallet-core package (Node / browser / React-Native safe): tx build + validation (anti-blind-sign, swap quote/program checks, idempotency rules), on-chain reads, token registry, Jupiter (fee injected), and the execution runner. core/vault/agent now consume it (re-exported for backward compatibility), so wallet logic lives once for desktop + mobile. Vault transfer/swap can route through the shared runner behind DCP_USE_SHARED_RUNNER (default OFF — the proven path stays live). Transfer is on-chain proven on devnet (20-way concurrency / no overspend, idempotent replay, SPL + ATA creation). Adds golden-harness, wallet-tx, and devnet/mainnet verification suites; extracts vault helper modules into server/lib. Also includes client SDK transport additions and agent connection/scope-guide updates. Tests: wallet-core 83 · core 221 · agent 51 · vault 143 · client 55 · telegram 63 · security 27/27 --- packages/dcp-agent/package.json | 1 + packages/dcp-agent/src/connection.ts | 82 ++ packages/dcp-agent/src/http-mcp.ts | 253 ++++ packages/dcp-agent/src/mcp.ts | 259 +++- packages/dcp-agent/src/scope-guide.ts | 63 + packages/dcp-client/src/client.ts | 24 + packages/dcp-client/src/index.ts | 4 + packages/dcp-client/src/local-transport.ts | 117 ++ packages/dcp-client/src/relay-transport.ts | 120 ++ packages/dcp-client/src/types.ts | 83 ++ packages/dcp-core/README.md | 14 +- packages/dcp-core/package.json | 2 + packages/dcp-core/src/index.ts | 8 + packages/dcp-core/src/storage.ts | 29 + packages/dcp-core/src/types.ts | 55 +- packages/dcp-core/src/wallet.ts | 17 + packages/dcp-core/tests/storage.test.ts | 37 + packages/dcp-core/tests/wallet.test.ts | 172 +++ packages/dcp-vault/package.json | 2 + packages/dcp-vault/src/server/index.ts | 1199 ++++++++++++++--- .../src/server/lib/permission-scopes.ts | 31 + .../src/server/lib/unlock-rate-limit.ts | 81 ++ .../dcp-vault/tests/devnet-transfer.test.ts | 256 ++++ .../dcp-vault/tests/golden-harness.test.ts | 246 ++++ .../dcp-vault/tests/mainnet-verify.test.ts | 126 ++ packages/dcp-vault/tests/wallet-tx.test.ts | 188 +++ packages/dcp-wallet-core/README.md | 56 + packages/dcp-wallet-core/package.json | 57 + packages/dcp-wallet-core/src/errors.ts | 59 + packages/dcp-wallet-core/src/index.ts | 75 ++ packages/dcp-wallet-core/src/jupiter.ts | 88 ++ .../dcp-wallet-core/src/runtime/consent.ts | 47 + packages/dcp-wallet-core/src/runtime/ports.ts | 82 ++ packages/dcp-wallet-core/src/runtime/swap.ts | 121 ++ .../dcp-wallet-core/src/runtime/transfer.ts | 123 ++ packages/dcp-wallet-core/src/runtime/types.ts | 69 + packages/dcp-wallet-core/src/solana-reads.ts | 489 +++++++ packages/dcp-wallet-core/src/solana-tx.ts | 416 ++++++ packages/dcp-wallet-core/src/swap.ts | 154 +++ packages/dcp-wallet-core/src/tokens.ts | 60 + .../dcp-wallet-core/tests/jupiter.test.ts | 86 ++ .../tests/runtime-consent.test.ts | 70 + .../tests/runtime-swap.test.ts | 140 ++ .../tests/runtime-transfer.test.ts | 133 ++ .../tests/solana-reads.test.ts | 232 ++++ .../dcp-wallet-core/tests/solana-tx.test.ts | 74 + packages/dcp-wallet-core/tests/swap.test.ts | 135 ++ packages/dcp-wallet-core/tests/tokens.test.ts | 44 + packages/dcp-wallet-core/tsconfig.json | 20 + packages/dcp-wallet-core/vitest.config.ts | 15 + pnpm-lock.yaml | 211 +++ scripts/publish-guard.mjs | 1 + 52 files changed, 6293 insertions(+), 233 deletions(-) create mode 100644 packages/dcp-vault/src/server/lib/permission-scopes.ts create mode 100644 packages/dcp-vault/src/server/lib/unlock-rate-limit.ts create mode 100644 packages/dcp-vault/tests/devnet-transfer.test.ts create mode 100644 packages/dcp-vault/tests/golden-harness.test.ts create mode 100644 packages/dcp-vault/tests/mainnet-verify.test.ts create mode 100644 packages/dcp-vault/tests/wallet-tx.test.ts create mode 100644 packages/dcp-wallet-core/README.md create mode 100644 packages/dcp-wallet-core/package.json create mode 100644 packages/dcp-wallet-core/src/errors.ts create mode 100644 packages/dcp-wallet-core/src/index.ts create mode 100644 packages/dcp-wallet-core/src/jupiter.ts create mode 100644 packages/dcp-wallet-core/src/runtime/consent.ts create mode 100644 packages/dcp-wallet-core/src/runtime/ports.ts create mode 100644 packages/dcp-wallet-core/src/runtime/swap.ts create mode 100644 packages/dcp-wallet-core/src/runtime/transfer.ts create mode 100644 packages/dcp-wallet-core/src/runtime/types.ts create mode 100644 packages/dcp-wallet-core/src/solana-reads.ts create mode 100644 packages/dcp-wallet-core/src/solana-tx.ts create mode 100644 packages/dcp-wallet-core/src/swap.ts create mode 100644 packages/dcp-wallet-core/src/tokens.ts create mode 100644 packages/dcp-wallet-core/tests/jupiter.test.ts create mode 100644 packages/dcp-wallet-core/tests/runtime-consent.test.ts create mode 100644 packages/dcp-wallet-core/tests/runtime-swap.test.ts create mode 100644 packages/dcp-wallet-core/tests/runtime-transfer.test.ts create mode 100644 packages/dcp-wallet-core/tests/solana-reads.test.ts create mode 100644 packages/dcp-wallet-core/tests/solana-tx.test.ts create mode 100644 packages/dcp-wallet-core/tests/swap.test.ts create mode 100644 packages/dcp-wallet-core/tests/tokens.test.ts create mode 100644 packages/dcp-wallet-core/tsconfig.json create mode 100644 packages/dcp-wallet-core/vitest.config.ts diff --git a/packages/dcp-agent/package.json b/packages/dcp-agent/package.json index 6df2322..1563080 100644 --- a/packages/dcp-agent/package.json +++ b/packages/dcp-agent/package.json @@ -57,6 +57,7 @@ "dependencies": { "@dcprotocol/client": "^3.0.0", "@dcprotocol/core": "^3.0.0", + "@dcprotocol/wallet-core": "workspace:*", "@modelcontextprotocol/sdk": "^1.0.0", "@noble/curves": "^1.4.0", "@solana/web3.js": "^1.98.0", diff --git a/packages/dcp-agent/src/connection.ts b/packages/dcp-agent/src/connection.ts index 1b2887a..2246853 100644 --- a/packages/dcp-agent/src/connection.ts +++ b/packages/dcp-agent/src/connection.ts @@ -179,6 +179,88 @@ export class AgentConnection { }; } + /** + * Build + sign + submit a transfer. DCP (the vault) builds the transaction + * itself from the intent, signs it, and submits it to the network. + */ + async transfer(params: { + chain: 'solana'; + to: string; + amount: number; + currency?: string; + mint?: string; + decimals?: number; + confirm?: 'submitted' | 'confirmed'; + description?: string; + idempotencyKey?: string; + }): Promise<{ + chain: string; + from: string; + to: string; + amount: number; + currency: string; + signature: string; + status: string; + explorer_url: string; + remaining_daily?: number; + }> { + this.ensureConnected(); + this.updateState(); + const result = await this.client!.transfer(params); + return { + chain: result.chain, + from: result.from, + to: result.to, + amount: result.amount, + currency: result.currency, + signature: result.signature, + status: result.status, + explorer_url: result.explorerUrl, + remaining_daily: result.remainingDaily, + }; + } + + /** + * Quote + build + sign + submit a swap via Jupiter. + */ + async swap(params: { + chain: 'solana'; + fromToken: string; + toToken: string; + amount: number; + slippageBps?: number; + fromDecimals?: number; + toDecimals?: number; + confirm?: 'submitted' | 'confirmed'; + description?: string; + idempotencyKey?: string; + }): Promise<{ + chain: string; + from_token: string; + to_token: string; + amount: number; + out_amount?: string; + signature: string; + status: string; + explorer_url: string; + remaining_daily?: number; + }> { + this.ensureConnected(); + this.updateState(); + const result = await this.client!.swap(params); + return { + chain: result.chain, + from_token: result.fromToken, + to_token: result.toToken, + amount: result.amount, + out_amount: result.outAmount, + signature: result.signature, + status: result.status, + explorer_url: result.explorerUrl, + remaining_daily: result.remainingDaily, + }; + } + /** * Sign a message */ diff --git a/packages/dcp-agent/src/http-mcp.ts b/packages/dcp-agent/src/http-mcp.ts index fa7a60c..6229930 100644 --- a/packages/dcp-agent/src/http-mcp.ts +++ b/packages/dcp-agent/src/http-mcp.ts @@ -23,16 +23,23 @@ import { import { DcpError } from '@dcprotocol/client'; import { AgentConnection } from './connection.js'; +import { SolanaReader, ReadInputError } from '@dcprotocol/wallet-core'; import { CANONICAL_SCOPE_GUIDE, SCOPE_PROPERTY_DESCRIPTION, VAULT_BUDGET_CHECK_DESCRIPTION, VAULT_GET_ADDRESS_DESCRIPTION, + VAULT_GET_BALANCES_DESCRIPTION, + VAULT_GET_TX_HISTORY_DESCRIPTION, + VAULT_GET_TX_STATUS_DESCRIPTION, VAULT_READ_DESCRIPTION, VAULT_SCOPE_GUIDE_DESCRIPTION, + VAULT_SEARCH_TOKENS_DESCRIPTION, VAULT_SIGN_MESSAGE_DESCRIPTION, VAULT_SIGN_TX_DESCRIPTION, VAULT_SIGN_X402_DESCRIPTION, + VAULT_SWAP_DESCRIPTION, + VAULT_TRANSFER_DESCRIPTION, VAULT_WRITE_DESCRIPTION, } from './scope-guide.js'; import { AgentConfig, AgentError } from './types.js'; @@ -124,6 +131,7 @@ export class HttpMcpServer { private host: string; private port: number; private forceRelay: boolean; + private reader: SolanaReader; constructor(config: AgentConfig, options?: HttpMcpServerOptions) { this.config = config; @@ -131,6 +139,7 @@ export class HttpMcpServer { this.port = options?.port ?? DEFAULT_PORT; this.forceRelay = options?.forceRelay ?? process.env.DCP_FORCE_RELAY === '1'; this.connection = new AgentConnection(config, { forceRelay: this.forceRelay }); + this.reader = new SolanaReader(); } private createMcpServer(): Server { @@ -353,6 +362,79 @@ export class HttpMcpServer { properties: {}, }, }, + { + name: 'vault_get_balances', + description: VAULT_GET_BALANCES_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana. DCP is currently exposed as a Solana wallet.', + }, + }, + }, + }, + { + name: 'vault_get_tx_status', + description: VAULT_GET_TX_STATUS_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + signature: { + type: 'string', + description: 'The base58 Solana transaction signature to look up.', + }, + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana.', + }, + }, + required: ['signature'], + }, + }, + { + name: 'vault_get_tx_history', + description: VAULT_GET_TX_HISTORY_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Max signatures to return. Default 20, maximum 50.', + }, + before: { + type: 'string', + description: 'Optional signature to paginate older activity from.', + }, + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana.', + }, + }, + }, + }, + { + name: 'vault_search_tokens', + description: VAULT_SEARCH_TOKENS_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Token symbol, name, or mint to search for.', + }, + limit: { + type: 'number', + description: 'Max results to return. Default 10, maximum 20.', + }, + }, + required: ['query'], + }, + }, { name: 'vault_read', description: VAULT_READ_DESCRIPTION, @@ -372,6 +454,74 @@ export class HttpMcpServer { required: ['scope'], }, }, + { + name: 'vault_transfer', + description: VAULT_TRANSFER_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana.', + }, + to: { + type: 'string', + description: 'Recipient Solana address.', + }, + amount: { + type: 'number', + description: 'Amount to send, for example 0.05 (SOL) or 1.5 (USDC).', + }, + currency: { + type: 'string', + description: 'SOL for native, or a token symbol like USDC. For an arbitrary SPL token, also pass mint and decimals.', + }, + mint: { + type: 'string', + description: 'Optional SPL token mint address (for tokens not in the registry). Requires decimals.', + }, + decimals: { + type: 'number', + description: 'Optional token decimals; required when mint is provided.', + }, + confirm: { + type: 'string', + enum: ['submitted', 'confirmed'], + description: "Optional. 'confirmed' (default) waits for on-chain confirmation; 'submitted' returns as soon as the tx is broadcast (faster; poll vault_get_tx_status to confirm).", + }, + description: { + type: 'string', + description: 'Short human-readable explanation shown to the user for approval.', + }, + idempotency_key: { + type: 'string', + description: 'Stable unique key for this intended transfer to prevent accidental double-sends.', + }, + }, + required: ['chain', 'to', 'amount'], + }, + }, + { + name: 'vault_swap', + description: VAULT_SWAP_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + chain: { type: 'string', enum: ['solana'], description: 'Use solana.' }, + from_token: { type: 'string', description: "Input token: 'SOL', a symbol like 'USDC', or a mint." }, + to_token: { type: 'string', description: "Output token: 'SOL', a symbol like 'USDC', or a mint." }, + amount: { type: 'number', description: 'Amount of the input token to swap.' }, + slippage_bps: { type: 'number', description: 'Slippage tolerance in basis points (default 50).' }, + from_decimals: { type: 'number', description: 'Decimals for from_token when it is an arbitrary mint.' }, + to_decimals: { type: 'number', description: 'Decimals for to_token when it is an arbitrary mint.' }, + confirm: { type: 'string', enum: ['submitted', 'confirmed'], description: "'confirmed' (default) waits; 'submitted' returns on broadcast." }, + description: { type: 'string', description: 'Short explanation shown to the user for approval.' }, + idempotency_key: { type: 'string', description: 'Stable unique key to prevent accidental double-swaps.' }, + }, + required: ['chain', 'from_token', 'to_token', 'amount'], + }, + }, { name: 'vault_sign_tx', description: VAULT_SIGN_TX_DESCRIPTION, @@ -537,6 +687,48 @@ export class HttpMcpServer { }; } + case 'vault_get_balances': { + const { address } = await this.connection.getAddress('solana'); + const result = await this.reader.getBalances(address); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_get_tx_status': { + const input = args as { signature: string }; + if (!input.signature) { + throw new McpError(ErrorCode.InvalidParams, 'signature is required'); + } + const result = await this.reader.getTxStatus(input.signature); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_get_tx_history': { + const input = (args ?? {}) as { limit?: number; before?: string }; + const { address } = await this.connection.getAddress('solana'); + const result = await this.reader.getTxHistory(address, { + limit: input.limit, + before: input.before, + }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_search_tokens': { + const input = args as { query: string; limit?: number }; + if (!input.query) { + throw new McpError(ErrorCode.InvalidParams, 'query is required'); + } + const result = await this.reader.searchTokens(input.query, input.limit); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + case 'vault_read': { const input = args as { scope: string; fields?: string[] }; if (!input.scope) { @@ -548,6 +740,63 @@ export class HttpMcpServer { }; } + case 'vault_transfer': { + const input = args as { + chain: 'solana'; + to: string; + amount: number; + currency?: string; + mint?: string; + decimals?: number; + confirm?: 'submitted' | 'confirmed'; + description?: string; + idempotency_key?: string; + }; + if (!input.chain || !input.to || input.amount === undefined) { + throw new McpError(ErrorCode.InvalidParams, 'chain, to, and amount are required'); + } + const result = await this.connection.transfer({ + chain: input.chain, + to: input.to, + amount: input.amount, + currency: input.currency, + mint: input.mint, + decimals: input.decimals, + confirm: input.confirm, + description: input.description, + idempotencyKey: input.idempotency_key, + }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_swap': { + const input = args as { + chain: 'solana'; from_token: string; to_token: string; amount: number; + slippage_bps?: number; from_decimals?: number; to_decimals?: number; + confirm?: 'submitted' | 'confirmed'; description?: string; idempotency_key?: string; + }; + if (!input.chain || !input.from_token || !input.to_token || input.amount === undefined) { + throw new McpError(ErrorCode.InvalidParams, 'chain, from_token, to_token, and amount are required'); + } + const result = await this.connection.swap({ + chain: input.chain, + fromToken: input.from_token, + toToken: input.to_token, + amount: input.amount, + slippageBps: input.slippage_bps, + fromDecimals: input.from_decimals, + toDecimals: input.to_decimals, + confirm: input.confirm, + description: input.description, + idempotencyKey: input.idempotency_key, + }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + case 'vault_sign_tx': { const input = args as { chain: 'solana'; @@ -778,6 +1027,10 @@ export class HttpMcpServer { }; } + if (error instanceof ReadInputError) { + throw new McpError(ErrorCode.InvalidParams, error.message); + } + if (error instanceof McpError) { throw error; } diff --git a/packages/dcp-agent/src/mcp.ts b/packages/dcp-agent/src/mcp.ts index 9ac4110..1809532 100644 --- a/packages/dcp-agent/src/mcp.ts +++ b/packages/dcp-agent/src/mcp.ts @@ -5,10 +5,15 @@ * through the relay connection. Unlike dcp-mcp which uses local VaultStorage, * this uses AgentConnection to proxy requests through the relay. * - * MCP Tools (8 tools): + * MCP Tools (13 tools): * - vault_get_address(chain) - Get public address (no consent) * - vault_budget_check(amount, currency) - Check budget (no consent) * - vault_scope_guide() - Show canonical DCP scopes (no consent) + * - vault_get_balances(chain?) - Read SOL/SPL balances (no consent, read-only) + * - vault_get_tx_status(signature) - Check tx status (no consent, read-only) + * - vault_get_tx_history(limit?, before?) - Recent activity (no consent, read-only) + * - vault_search_tokens(query, limit?) - Jupiter token search (no consent, read-only) + * - vault_transfer(chain, to, amount) - Build+sign+submit+confirm SOL transfer (consent required) * - vault_read(scope, fields?) - Read data (consent may be required) * - vault_sign_tx(chain, unsigned_tx, description?) - Sign transaction (consent required) * - vault_sign_message(chain, message, encoding?) - Sign message (consent required) @@ -27,16 +32,23 @@ import { import { DcpError } from '@dcprotocol/client'; import { AgentConnection } from './connection.js'; +import { SolanaReader, ReadInputError } from '@dcprotocol/wallet-core'; import { CANONICAL_SCOPE_GUIDE, SCOPE_PROPERTY_DESCRIPTION, VAULT_BUDGET_CHECK_DESCRIPTION, VAULT_GET_ADDRESS_DESCRIPTION, + VAULT_GET_BALANCES_DESCRIPTION, + VAULT_GET_TX_HISTORY_DESCRIPTION, + VAULT_GET_TX_STATUS_DESCRIPTION, VAULT_READ_DESCRIPTION, VAULT_SCOPE_GUIDE_DESCRIPTION, + VAULT_SEARCH_TOKENS_DESCRIPTION, VAULT_SIGN_MESSAGE_DESCRIPTION, VAULT_SIGN_TX_DESCRIPTION, VAULT_SIGN_X402_DESCRIPTION, + VAULT_SWAP_DESCRIPTION, + VAULT_TRANSFER_DESCRIPTION, VAULT_WRITE_DESCRIPTION, } from './scope-guide.js'; import { AgentConfig, AgentError } from './types.js'; @@ -101,11 +113,13 @@ export class AgentMcpServer { private connection: AgentConnection; private server: Server; private forceRelay: boolean; + private reader: SolanaReader; constructor(config: AgentConfig, options?: { forceRelay?: boolean }) { this.config = config; this.forceRelay = options?.forceRelay ?? process.env.DCP_FORCE_RELAY === '1'; this.connection = new AgentConnection(config, { forceRelay: this.forceRelay }); + this.reader = new SolanaReader(); // Create MCP server this.server = new Server( @@ -196,6 +210,79 @@ export class AgentMcpServer { properties: {}, }, }, + { + name: 'vault_get_balances', + description: VAULT_GET_BALANCES_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana. DCP is currently exposed as a Solana wallet.', + }, + }, + }, + }, + { + name: 'vault_get_tx_status', + description: VAULT_GET_TX_STATUS_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + signature: { + type: 'string', + description: 'The base58 Solana transaction signature to look up.', + }, + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana.', + }, + }, + required: ['signature'], + }, + }, + { + name: 'vault_get_tx_history', + description: VAULT_GET_TX_HISTORY_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Max signatures to return. Default 20, maximum 50.', + }, + before: { + type: 'string', + description: 'Optional signature to paginate older activity from.', + }, + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana.', + }, + }, + }, + }, + { + name: 'vault_search_tokens', + description: VAULT_SEARCH_TOKENS_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Token symbol, name, or mint to search for.', + }, + limit: { + type: 'number', + description: 'Max results to return. Default 10, maximum 20.', + }, + }, + required: ['query'], + }, + }, { name: 'vault_read', description: VAULT_READ_DESCRIPTION, @@ -215,6 +302,74 @@ export class AgentMcpServer { required: ['scope'], }, }, + { + name: 'vault_transfer', + description: VAULT_TRANSFER_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + chain: { + type: 'string', + enum: ['solana'], + description: 'Use solana.', + }, + to: { + type: 'string', + description: 'Recipient Solana address.', + }, + amount: { + type: 'number', + description: 'Amount to send, for example 0.05 (SOL) or 1.5 (USDC).', + }, + currency: { + type: 'string', + description: 'SOL for native, or a token symbol like USDC. For an arbitrary SPL token, also pass mint and decimals.', + }, + mint: { + type: 'string', + description: 'Optional SPL token mint address (for tokens not in the registry). Requires decimals.', + }, + decimals: { + type: 'number', + description: 'Optional token decimals; required when mint is provided.', + }, + confirm: { + type: 'string', + enum: ['submitted', 'confirmed'], + description: "Optional. 'confirmed' (default) waits for on-chain confirmation; 'submitted' returns as soon as the tx is broadcast (faster; poll vault_get_tx_status to confirm).", + }, + description: { + type: 'string', + description: 'Short human-readable explanation shown to the user for approval.', + }, + idempotency_key: { + type: 'string', + description: 'Stable unique key for this intended transfer to prevent accidental double-sends.', + }, + }, + required: ['chain', 'to', 'amount'], + }, + }, + { + name: 'vault_swap', + description: VAULT_SWAP_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + chain: { type: 'string', enum: ['solana'], description: 'Use solana.' }, + from_token: { type: 'string', description: "Input token: 'SOL', a symbol like 'USDC', or a mint." }, + to_token: { type: 'string', description: "Output token: 'SOL', a symbol like 'USDC', or a mint." }, + amount: { type: 'number', description: 'Amount of the input token to swap.' }, + slippage_bps: { type: 'number', description: 'Slippage tolerance in basis points (default 50).' }, + from_decimals: { type: 'number', description: 'Decimals for from_token when it is an arbitrary mint.' }, + to_decimals: { type: 'number', description: 'Decimals for to_token when it is an arbitrary mint.' }, + confirm: { type: 'string', enum: ['submitted', 'confirmed'], description: "'confirmed' (default) waits; 'submitted' returns on broadcast." }, + description: { type: 'string', description: 'Short explanation shown to the user for approval.' }, + idempotency_key: { type: 'string', description: 'Stable unique key to prevent accidental double-swaps.' }, + }, + required: ['chain', 'from_token', 'to_token', 'amount'], + }, + }, { name: 'vault_sign_tx', description: VAULT_SIGN_TX_DESCRIPTION, @@ -377,6 +532,48 @@ export class AgentMcpServer { }; } + case 'vault_get_balances': { + const { address } = await this.connection.getAddress('solana'); + const result = await this.reader.getBalances(address); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_get_tx_status': { + const input = args as { signature: string }; + if (!input.signature) { + throw new McpError(ErrorCode.InvalidParams, 'signature is required'); + } + const result = await this.reader.getTxStatus(input.signature); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_get_tx_history': { + const input = (args ?? {}) as { limit?: number; before?: string }; + const { address } = await this.connection.getAddress('solana'); + const result = await this.reader.getTxHistory(address, { + limit: input.limit, + before: input.before, + }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_search_tokens': { + const input = args as { query: string; limit?: number }; + if (!input.query) { + throw new McpError(ErrorCode.InvalidParams, 'query is required'); + } + const result = await this.reader.searchTokens(input.query, input.limit); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + case 'vault_read': { const input = args as { scope: string; fields?: string[] }; if (!input.scope) { @@ -388,6 +585,63 @@ export class AgentMcpServer { }; } + case 'vault_transfer': { + const input = args as { + chain: 'solana'; + to: string; + amount: number; + currency?: string; + mint?: string; + decimals?: number; + confirm?: 'submitted' | 'confirmed'; + description?: string; + idempotency_key?: string; + }; + if (!input.chain || !input.to || input.amount === undefined) { + throw new McpError(ErrorCode.InvalidParams, 'chain, to, and amount are required'); + } + const result = await this.connection.transfer({ + chain: input.chain, + to: input.to, + amount: input.amount, + currency: input.currency, + mint: input.mint, + decimals: input.decimals, + confirm: input.confirm, + description: input.description, + idempotencyKey: input.idempotency_key, + }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'vault_swap': { + const input = args as { + chain: 'solana'; from_token: string; to_token: string; amount: number; + slippage_bps?: number; from_decimals?: number; to_decimals?: number; + confirm?: 'submitted' | 'confirmed'; description?: string; idempotency_key?: string; + }; + if (!input.chain || !input.from_token || !input.to_token || input.amount === undefined) { + throw new McpError(ErrorCode.InvalidParams, 'chain, from_token, to_token, and amount are required'); + } + const result = await this.connection.swap({ + chain: input.chain, + fromToken: input.from_token, + toToken: input.to_token, + amount: input.amount, + slippageBps: input.slippage_bps, + fromDecimals: input.from_decimals, + toDecimals: input.to_decimals, + confirm: input.confirm, + description: input.description, + idempotencyKey: input.idempotency_key, + }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + case 'vault_sign_tx': { const input = args as { chain: 'solana'; @@ -602,6 +856,9 @@ export class AgentMcpServer { isError: true, }; } + if (error instanceof ReadInputError) { + throw new McpError(ErrorCode.InvalidParams, error.message); + } if (error instanceof McpError) { throw error; } diff --git a/packages/dcp-agent/src/scope-guide.ts b/packages/dcp-agent/src/scope-guide.ts index 86633d9..a136974 100644 --- a/packages/dcp-agent/src/scope-guide.ts +++ b/packages/dcp-agent/src/scope-guide.ts @@ -84,6 +84,69 @@ Rules: - This signs only. It does not broadcast/send the transaction unless your client does that separately. - Manual approval is required when the amount is above the user's approval threshold.`; +export const VAULT_GET_BALANCES_DESCRIPTION = `Read the user's Solana wallet balances (SOL and SPL tokens such as USDC). + +Use this when the user asks how much SOL/USDC/token they have, or when you need to know the balance before building a transfer, swap, or payment. + +Rules: +- Returns balances for the user's own DCP wallet address. You do not pass an address. +- This is a read-only public lookup. It does not sign or send anything. +- No user approval is required.`; + +export const VAULT_GET_TX_STATUS_DESCRIPTION = `Check the on-chain status of a Solana transaction by its signature. + +Use this after you submit a transaction (for example one signed via vault_sign_tx) to see whether it is processed, confirmed, finalized, failed, or not found. + +Rules: +- signature is the base58 Solana transaction signature. +- This is a read-only public lookup. It does not sign or send anything. +- No user approval is required.`; + +export const VAULT_GET_TX_HISTORY_DESCRIPTION = `List recent transaction signatures for the user's Solana wallet. + +Use this when the user asks about their recent activity or history. Returns signatures with light metadata (slot, time, status) and explorer links — not full transaction contents. + +Rules: +- Returns activity for the user's own DCP wallet address. You do not pass an address. +- limit is optional (default 20, maximum 50). Use before with a signature to paginate older activity. +- This is a read-only public lookup. It does not sign or send anything. +- No user approval is required.`; + +export const VAULT_SEARCH_TOKENS_DESCRIPTION = `Search the Solana (Jupiter) token list by symbol, name, or mint. + +Use this to resolve a token the user names (for example "BONK") to its mint address and decimals before a swap or transfer. + +Rules: +- query is the symbol, name, or mint to search for. +- limit is optional (default 10, maximum 20). +- This is a read-only public lookup. It does not sign or send anything. +- No user approval is required.`; + +export const VAULT_TRANSFER_DESCRIPTION = `Send SOL or an SPL token (e.g. USDC) from the user's DCP wallet to a recipient. DCP builds the transfer, signs it in the vault (private key never leaves the device), submits it to Solana, and confirms it. + +Use this when the user asks to send, pay, or transfer funds to an address. Prefer this over manually building a transaction and using vault_sign_tx. + +Rules: +- chain must be solana. to is the recipient's Solana address. amount is the human amount (for example 0.05 SOL, or 1.5 USDC). +- currency is SOL for native, or a token symbol like USDC. For an arbitrary SPL token, pass mint and decimals as well. +- Sending a token to a recipient who has never held it works automatically — DCP creates their token account (the user pays the small one-time rent). +- The user pays the network fee from their own wallet. +- Manual approval is required when the amount is above the user's approval threshold; budget limits are enforced. +- Provide a stable idempotency_key for the intended transfer to avoid accidental double-sends. +- Returns the transaction signature and a confirmation status. If confirmation times out the status is "submitted"; poll vault_get_tx_status with the signature.`; + +export const VAULT_SWAP_DESCRIPTION = `Swap one Solana token for another from the user's DCP wallet, via Jupiter. DCP gets the quote, builds the swap, signs it in the vault (key never leaves the device), submits it, and confirms it. + +Use this when the user asks to swap, convert, or exchange tokens (for example "swap 1 SOL to USDC"). + +Rules: +- chain must be solana. from_token and to_token are 'SOL', a known symbol like 'USDC', or a mint address (with from_decimals/to_decimals for arbitrary mints). +- amount is the amount of the input (from_token) to swap. +- slippage_bps is optional (default 50 = 0.5%). +- Budget and consent are enforced on the input amount; the user pays the network fee. +- Provide a stable idempotency_key to avoid accidental double-swaps. +- Returns the signature, confirmation status, and the estimated output amount.`; + export const VAULT_SIGN_MESSAGE_DESCRIPTION = `Sign a message using the user's DCP vault wallet. Use this when the user asks to sign a login challenge, nonce, authentication message, or other Solana wallet message. The private key never leaves the vault. diff --git a/packages/dcp-client/src/client.ts b/packages/dcp-client/src/client.ts index 0b3983a..1111938 100644 --- a/packages/dcp-client/src/client.ts +++ b/packages/dcp-client/src/client.ts @@ -25,6 +25,10 @@ import type { GetAddressResult, SignTxInput, SignTxResult, + TransferInput, + TransferResult, + SwapInput, + SwapResult, SignMessageInput, SignMessageResult, SignX402Input, @@ -248,6 +252,26 @@ export class DcpClient { return transport.signTx(input); } + /** + * Build + sign + submit a transfer. DCP builds the transaction itself from the + * intent, signs it (with consent + budget), and submits it to the network. + * + * @throws DcpError with CONSENT_REQUIRED if user approval needed + */ + async transfer(input: TransferInput): Promise { + const transport = await this.getTransport(); + return transport.transfer(input); + } + + /** + * Quote + build + sign + submit a swap via Jupiter. + * @throws DcpError with CONSENT_REQUIRED if user approval needed + */ + async swap(input: SwapInput): Promise { + const transport = await this.getTransport(); + return transport.swap(input); + } + /** * Sign a message * diff --git a/packages/dcp-client/src/index.ts b/packages/dcp-client/src/index.ts index a2cb654..4929ac6 100644 --- a/packages/dcp-client/src/index.ts +++ b/packages/dcp-client/src/index.ts @@ -68,7 +68,9 @@ export type { // Input types SignTxInput, + TransferInput, SignMessageInput, + SwapInput, SignX402Input, ReadCredentialInput, WriteCredentialInput, @@ -78,7 +80,9 @@ export type { // Output types GetAddressResult, SignTxResult, + TransferResult, SignMessageResult, + SwapResult, SignX402Result, ReadCredentialResult, WriteCredentialResult, diff --git a/packages/dcp-client/src/local-transport.ts b/packages/dcp-client/src/local-transport.ts index cd3e76c..a6620bd 100644 --- a/packages/dcp-client/src/local-transport.ts +++ b/packages/dcp-client/src/local-transport.ts @@ -16,6 +16,10 @@ import type { GetAddressResult, SignTxInput, SignTxResult, + TransferInput, + TransferResult, + SwapInput, + SwapResult, SignMessageInput, SignMessageResult, SignX402Input, @@ -167,6 +171,119 @@ export class LocalTransport implements Transport { }; } + // -------------------------------------------------------------------------- + // Transfer (DCP builds + signs + submits) + // -------------------------------------------------------------------------- + + async transfer(input: TransferInput): Promise { + const walletScope = `crypto.wallet.${input.chain}`; + const sessionId = this.getSessionId(walletScope); + + const body = this.signRequestBody({ + chain: input.chain, + to: input.to, + amount: input.amount, + currency: input.currency, + mint: input.mint, + decimals: input.decimals, + confirm: input.confirm, + agent_name: this.config.agentName, + session_id: sessionId, + description: input.description, + idempotency_key: input.idempotencyKey, + }); + + const response = await this.fetch('/v1/vault/transfer', { + method: 'POST', + body: this.jsonStringify(body), + }); + + const data = await this.handleResponse<{ + chain: Chain; + from: string; + to: string; + amount: number; + currency: string; + signature: string; + status: string; + explorer_url: string; + remaining_daily?: number; + session_id?: string; + requires_consent?: boolean; + consent_id?: string; + expires_at?: string; + }>(response); + + if (data.requires_consent && data.consent_id) { + throw new DcpError('CONSENT_REQUIRED', 'User consent required for transfer', { + consent_id: data.consent_id, + expires_at: data.expires_at, + approve_url: this.config.localUrl, + }); + } + + if (data.session_id) { + this.cacheSession(walletScope, data.session_id); + } + + return { + chain: data.chain, + from: data.from, + to: data.to, + amount: data.amount, + currency: data.currency, + signature: data.signature, + status: data.status, + explorerUrl: data.explorer_url, + remainingDaily: data.remaining_daily, + sessionId: data.session_id, + }; + } + + // -------------------------------------------------------------------------- + // Swap (Jupiter) + // -------------------------------------------------------------------------- + + async swap(input: SwapInput): Promise { + const walletScope = `crypto.wallet.${input.chain}`; + const sessionId = this.getSessionId(walletScope); + + const body = this.signRequestBody({ + chain: input.chain, + from_token: input.fromToken, + to_token: input.toToken, + amount: input.amount, + slippage_bps: input.slippageBps, + from_decimals: input.fromDecimals, + to_decimals: input.toDecimals, + confirm: input.confirm, + agent_name: this.config.agentName, + session_id: sessionId, + description: input.description, + idempotency_key: input.idempotencyKey, + }); + + const response = await this.fetch('/v1/vault/swap', { method: 'POST', body: this.jsonStringify(body) }); + const data = await this.handleResponse<{ + chain: Chain; from_token: string; to_token: string; amount: number; out_amount?: string; + signature: string; status: string; explorer_url: string; remaining_daily?: number; session_id?: string; + requires_consent?: boolean; consent_id?: string; expires_at?: string; + }>(response); + + if (data.requires_consent && data.consent_id) { + throw new DcpError('CONSENT_REQUIRED', 'User consent required for swap', { + consent_id: data.consent_id, expires_at: data.expires_at, approve_url: this.config.localUrl, + }); + } + if (data.session_id) this.cacheSession(walletScope, data.session_id); + + return { + chain: data.chain, fromToken: data.from_token, toToken: data.to_token, amount: data.amount, + outAmount: data.out_amount, signature: data.signature, status: data.status, + explorerUrl: data.explorer_url, remainingDaily: data.remaining_daily, sessionId: data.session_id, + }; + } + // -------------------------------------------------------------------------- // Sign Message // -------------------------------------------------------------------------- diff --git a/packages/dcp-client/src/relay-transport.ts b/packages/dcp-client/src/relay-transport.ts index e102821..9283363 100644 --- a/packages/dcp-client/src/relay-transport.ts +++ b/packages/dcp-client/src/relay-transport.ts @@ -19,6 +19,10 @@ import type { GetAddressResult, SignTxInput, SignTxResult, + TransferInput, + TransferResult, + SwapInput, + SwapResult, SignMessageInput, SignMessageResult, SignX402Input, @@ -265,6 +269,122 @@ export class RelayTransport implements Transport { }; } + // -------------------------------------------------------------------------- + // Transfer (DCP builds + signs + submits) + // -------------------------------------------------------------------------- + + async transfer(input: TransferInput): Promise { + const walletScope = `crypto.wallet.${input.chain}`; + const sessionId = this.getSessionId(walletScope); + + const params = { + chain: input.chain, + to: input.to, + amount: input.amount, + currency: input.currency, + mint: input.mint, + decimals: input.decimals, + confirm: input.confirm, + description: input.description, + idempotency_key: input.idempotencyKey, + session_id: sessionId, + }; + + const response = await this.relayRequest('vault_transfer', params); + + const data = response as { + chain?: Chain; + from?: string; + to?: string; + amount?: number; + currency?: string; + signature?: string; + status?: string; + explorer_url?: string; + remaining_daily?: number; + session_id?: string; + requires_consent?: boolean; + consent_id?: string; + expires_at?: string; + error?: { code?: string; message?: string }; + }; + + if (data.error) { + throw parseErrorResponse({ error: data.error }); + } + + if (data.requires_consent && data.consent_id) { + throw new DcpError('CONSENT_REQUIRED', 'User consent required for transfer', { + consent_id: data.consent_id, + expires_at: data.expires_at, + }); + } + + if (!data.signature) { + throw new DcpError('INTERNAL_ERROR', 'Invalid response from vault'); + } + + if (data.session_id) { + this.cacheSession(walletScope, data.session_id); + } + + return { + chain: data.chain || input.chain, + from: data.from || '', + to: data.to || input.to, + amount: data.amount ?? input.amount, + currency: data.currency || input.currency || 'SOL', + signature: data.signature, + status: data.status || 'submitted', + explorerUrl: data.explorer_url || `https://solscan.io/tx/${data.signature}`, + remainingDaily: data.remaining_daily, + sessionId: data.session_id, + }; + } + + // -------------------------------------------------------------------------- + // Swap (Jupiter) + // -------------------------------------------------------------------------- + + async swap(input: SwapInput): Promise { + const walletScope = `crypto.wallet.${input.chain}`; + const sessionId = this.getSessionId(walletScope); + + const params = { + chain: input.chain, + from_token: input.fromToken, + to_token: input.toToken, + amount: input.amount, + slippage_bps: input.slippageBps, + from_decimals: input.fromDecimals, + to_decimals: input.toDecimals, + confirm: input.confirm, + description: input.description, + idempotency_key: input.idempotencyKey, + session_id: sessionId, + }; + + const response = await this.relayRequest('vault_swap', params); + const data = response as { + chain?: Chain; from_token?: string; to_token?: string; amount?: number; out_amount?: string; + signature?: string; status?: string; explorer_url?: string; remaining_daily?: number; session_id?: string; + requires_consent?: boolean; consent_id?: string; expires_at?: string; error?: { code?: string; message?: string }; + }; + + if (data.error) throw parseErrorResponse({ error: data.error }); + if (data.requires_consent && data.consent_id) { + throw new DcpError('CONSENT_REQUIRED', 'User consent required for swap', { consent_id: data.consent_id, expires_at: data.expires_at }); + } + if (!data.signature) throw new DcpError('INTERNAL_ERROR', 'Invalid response from vault'); + if (data.session_id) this.cacheSession(walletScope, data.session_id); + + return { + chain: data.chain || input.chain, fromToken: data.from_token || input.fromToken, toToken: data.to_token || input.toToken, + amount: data.amount ?? input.amount, outAmount: data.out_amount, signature: data.signature, status: data.status || 'submitted', + explorerUrl: data.explorer_url || `https://solscan.io/tx/${data.signature}`, remainingDaily: data.remaining_daily, sessionId: data.session_id, + }; + } + // -------------------------------------------------------------------------- // Sign Message // -------------------------------------------------------------------------- diff --git a/packages/dcp-client/src/types.ts b/packages/dcp-client/src/types.ts index bbeebae..c474a98 100644 --- a/packages/dcp-client/src/types.ts +++ b/packages/dcp-client/src/types.ts @@ -92,6 +92,83 @@ export interface SignTxInput { idempotencyKey?: string; } +export interface TransferInput { + /** Blockchain (currently solana) */ + chain: Chain; + /** Recipient address */ + to: string; + /** Amount to send (e.g. SOL, or token units) */ + amount: number; + /** Currency code: SOL for native, or a token symbol like USDC */ + currency?: string; + /** Explicit SPL token mint (for arbitrary tokens not in the registry) */ + mint?: string; + /** Explicit SPL token decimals (required with mint) */ + decimals?: number; + /** Return as soon as broadcast ('submitted') or wait for on-chain confirmation ('confirmed', default) */ + confirm?: 'submitted' | 'confirmed'; + /** Human-readable description for consent UI */ + description?: string; + /** Idempotency key to prevent duplicate transfers */ + idempotencyKey?: string; +} + +export interface TransferResult { + chain: Chain; + /** Sender (the vault wallet) */ + from: string; + /** Recipient */ + to: string; + /** Amount sent */ + amount: number; + /** Currency */ + currency: string; + /** Transaction signature */ + signature: string; + /** Confirmation status: submitted | confirmed | finalized | failed */ + status: string; + /** Block explorer URL */ + explorerUrl: string; + /** Remaining daily budget */ + remainingDaily?: number; + /** Session ID (if a session was used/created) */ + sessionId?: string; +} + +export interface SwapInput { + chain: Chain; + /** Input token: 'SOL', a known symbol like 'USDC', or a mint */ + fromToken: string; + /** Output token: 'SOL', a known symbol like 'USDC', or a mint */ + toToken: string; + /** Amount of the input token to swap */ + amount: number; + /** Slippage tolerance in basis points (default 50 = 0.5%) */ + slippageBps?: number; + /** Decimals for fromToken when it's an arbitrary mint */ + fromDecimals?: number; + /** Decimals for toToken when it's an arbitrary mint */ + toDecimals?: number; + /** Return on broadcast ('submitted') or wait for confirmation ('confirmed', default) */ + confirm?: 'submitted' | 'confirmed'; + description?: string; + idempotencyKey?: string; +} + +export interface SwapResult { + chain: Chain; + fromToken: string; + toToken: string; + amount: number; + /** Estimated output amount (base units), from the quote */ + outAmount?: string; + signature: string; + status: string; + explorerUrl: string; + remainingDaily?: number; + sessionId?: string; +} + export interface SignMessageInput { /** Blockchain to sign for */ chain: Chain; @@ -279,6 +356,12 @@ export interface Transport { /** Sign a transaction */ signTx(input: SignTxInput): Promise; + /** Build + sign + submit a transfer (DCP builds the tx itself) */ + transfer(input: TransferInput): Promise; + + /** Quote + build + sign + submit a swap (via Jupiter) */ + swap(input: SwapInput): Promise; + /** Sign a message */ signMessage(input: SignMessageInput): Promise; diff --git a/packages/dcp-core/README.md b/packages/dcp-core/README.md index 2e31194..50bb4ea 100644 --- a/packages/dcp-core/README.md +++ b/packages/dcp-core/README.md @@ -14,13 +14,23 @@ npm install @dcprotocol/core ```text crypto encryption, signatures, canonical JSON -wallet Solana wallet operations +wallet key custody (create/encrypt/sign) + RE-EXPORTS the pure wallet logic + (tx build/validate/runner) from @dcprotocol/wallet-core storage SQLite vault storage and audit data budget budget and policy helpers pairing signed pairing grants and remote-agent invites -types shared TypeScript types +types shared TypeScript types (VaultError is re-exported from wallet-core) ``` +## Relationship to `@dcprotocol/wallet-core` + +The pure, dependency-light wallet logic — transaction building, validation, the +execution runner, token registry, Jupiter, and the shared `VaultError` — lives in +`@dcprotocol/wallet-core` (no native deps, React-Native-safe). `core` adds **key +custody, storage, and crypto** (which need native modules) on top, and re-exports +`wallet-core` for backward compatibility. Import wallet primitives from either; they +are the same code. + ## Boundaries Core provides building blocks. The vault decides policy, approval, signing, and storage behavior in the running product. diff --git a/packages/dcp-core/package.json b/packages/dcp-core/package.json index 7e8abdb..a15678d 100644 --- a/packages/dcp-core/package.json +++ b/packages/dcp-core/package.json @@ -43,6 +43,8 @@ "author": "DCP Protocol", "license": "Apache-2.0", "dependencies": { + "@dcprotocol/wallet-core": "workspace:*", + "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.98.0", "better-sqlite3": "^11.7.0", "bip39": "^3.1.0", diff --git a/packages/dcp-core/src/index.ts b/packages/dcp-core/src/index.ts index 9199f04..dea864e 100644 --- a/packages/dcp-core/src/index.ts +++ b/packages/dcp-core/src/index.ts @@ -64,6 +64,14 @@ export { exportWalletPrivateKey, exportSolanaPrivateKey, + // Transaction building (build-from-intent) + buildSolanaTransferTx, + buildSplTransferTx, + getSolanaAtaAddress, + verifyTransferTx, + getTransactionSigners, + getTransactionProgramIds, + // Transaction signing signTransaction, signSolanaTransaction, diff --git a/packages/dcp-core/src/storage.ts b/packages/dcp-core/src/storage.ts index 59996c3..03c48b2 100644 --- a/packages/dcp-core/src/storage.ts +++ b/packages/dcp-core/src/storage.ts @@ -1539,6 +1539,35 @@ export class VaultStorage { return stmt.get(idempotencyKey) as SpendEvent | null; } + /** + * Prune old audit + spend history to keep the local DB small (important on + * mobile). This is SAFE for budgeting: the daily budget only looks back 24h, + * and spend retention is clamped to a 2-day floor, so live budget enforcement + * is never affected. Idempotency keys are only relevant to short-lived retries, + * so pruning very old ones is also safe. Returns rows removed per table. + */ + pruneOldEvents(options?: { auditRetentionDays?: number; spendRetentionDays?: number }): { + auditDeleted: number; + spendDeleted: number; + } { + const auditDays = Math.max(1, Math.floor(options?.auditRetentionDays ?? 90)); + const spendDays = Math.max(2, Math.floor(options?.spendRetentionDays ?? 90)); // floor > 24h budget window + const auditCutoff = new Date(Date.now() - auditDays * 24 * 60 * 60 * 1000).toISOString(); + const spendCutoff = new Date(Date.now() - spendDays * 24 * 60 * 60 * 1000).toISOString(); + const a = this.db.prepare('DELETE FROM audit_events WHERE created_at < ?').run(auditCutoff); + const s = this.db.prepare('DELETE FROM spend_events WHERE created_at < ?').run(spendCutoff); + return { auditDeleted: a.changes, spendDeleted: s.changes }; + } + + /** + * Reclaim disk space (shrink the DB file) after pruning. VACUUM rewrites the + * file and briefly locks the DB, so call this during idle/maintenance — never + * on a request hot path. + */ + vacuum(): void { + this.db.exec('VACUUM'); + } + // ========================================================================== // Audit Events // ========================================================================== diff --git a/packages/dcp-core/src/types.ts b/packages/dcp-core/src/types.ts index 8204388..490ce3d 100644 --- a/packages/dcp-core/src/types.ts +++ b/packages/dcp-core/src/types.ts @@ -530,55 +530,12 @@ export interface ProxyConfig { // Error Types (from protocol spec section 7) // ============================================================================ -export type VaultErrorCode = - | 'VAULT_NOT_INITIALIZED' - | 'VAULT_LOCKED' - | 'CONSENT_REQUIRED' - | 'CONSENT_DENIED' - | 'CONSENT_EXPIRED' - | 'CONSENT_TIMEOUT' - | 'CONSENT_NOT_FOUND' - | 'SCOPE_VIOLATION' - | 'BUDGET_EXCEEDED_TX' - | 'BUDGET_EXCEEDED_DAILY' - | 'TOKEN_EXPIRED' - | 'TOKEN_REVOKED' - | 'INVALID_CHAIN' - | 'INVALID_TX' - | 'INVALID_SCHEMA' - | 'IDEMPOTENCY_CONFLICT' - | 'RATE_LIMITED' - | 'RECORD_NOT_FOUND' - | 'INTERNAL_ERROR' - | 'VALIDATION_ERROR' - | 'UNAUTHORIZED' - | 'SERVICE_NOT_TRUSTED' - | 'SERVICE_NOT_FOUND' - | 'SERVICE_ALREADY_TRUSTED' - | 'INVALID_SERVICE_SIGNATURE' - | 'SERVICE_SCOPE_VIOLATION' - | 'INVALID_PUBLIC_KEY'; - -export class VaultError extends Error { - constructor( - public code: VaultErrorCode, - message: string, - public details?: Record - ) { - super(message); - this.name = 'VaultError'; - } - - toJSON() { - return { - error: { - code: this.code, - message: this.message, - details: this.details, - }, - }; - } -} +// VaultError + VaultErrorCode are the shared protocol error type. They live in the +// foundational @dcprotocol/wallet-core package so that every package throws and +// catches the SAME class (keeps `instanceof VaultError` reliable across package +// boundaries). Re-exported here so existing `./types.js` importers are unchanged. +export { VaultError } from '@dcprotocol/wallet-core'; +export type { VaultErrorCode } from '@dcprotocol/wallet-core'; // ============================================================================ // Telegram Notification Types (protocol spec section 15) diff --git a/packages/dcp-core/src/wallet.ts b/packages/dcp-core/src/wallet.ts index 98bb4a2..00223fb 100644 --- a/packages/dcp-core/src/wallet.ts +++ b/packages/dcp-core/src/wallet.ts @@ -278,6 +278,23 @@ export async function signTransaction( return signSolanaTransaction(encryptedKey, masterKey, unsignedTx); } +// ============================================================================ +// Transaction Building + verification — RE-EXPORTED from @dcprotocol/wallet-core +// ============================================================================ +// +// The pure tx build/decode/validation logic lives in @dcprotocol/wallet-core (a +// clean, native-dep-free package) so React Native (mobile) can import it. It is +// re-exported here so existing @dcprotocol/core importers are unchanged. +export { + buildSolanaTransferTx, + buildSplTransferTx, + getSolanaAtaAddress, + verifyTransferTx, + getTransactionSigners, + getTransactionProgramIds, +} from '@dcprotocol/wallet-core'; +export type { TransferVerifyResult } from '@dcprotocol/wallet-core'; + // ============================================================================ // Message Signing // ============================================================================ diff --git a/packages/dcp-core/tests/storage.test.ts b/packages/dcp-core/tests/storage.test.ts index b3ace76..2eb4bc4 100644 --- a/packages/dcp-core/tests/storage.test.ts +++ b/packages/dcp-core/tests/storage.test.ts @@ -776,4 +776,41 @@ describe('Storage Layer', () => { expect(logs[0].notification_type).toBe('test'); }); }); + + describe('Retention pruning', () => { + // Insert a backdated spend row directly (FK off, test-only) so we can exercise + // pruning at arbitrary ages without standing up sessions. + function insertSpend(id: string, key: string, createdAt: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = (storage as any).db; + db.pragma('foreign_keys = OFF'); + db.prepare( + 'INSERT INTO spend_events (id, agent_session_id, amount, currency, chain, operation, destination, idempotency_key, status, tx_signature, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)' + ).run(id, 'sess', 0.01, 'SOL', 'solana', 'transfer', null, key, 'committed', null, createdAt); + } + + it('prunes old rows, keeps recent ones', () => { + const old = new Date(Date.now() - 200 * 24 * 60 * 60 * 1000).toISOString(); + insertSpend('recent', 'recent-key', new Date().toISOString()); + insertSpend('old', 'old-key', old); + storage.logAudit('EXECUTE', 'success', { operation: 'transfer' }); + + const res = storage.pruneOldEvents({ auditRetentionDays: 90, spendRetentionDays: 90 }); + expect(res.spendDeleted).toBe(1); + expect(storage.getSpendByIdempotencyKey('recent-key')).toBeTruthy(); + expect(storage.getSpendByIdempotencyKey('old-key')).toBeFalsy(); // removed + }); + + it('NEVER prunes within the budget window even if asked to (2-day floor)', () => { + const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(); + insertSpend('r12h', 'r12h-key', twelveHoursAgo); + // Aggressive 0-day retention is clamped to a 2-day floor inside storage. + storage.pruneOldEvents({ spendRetentionDays: 0, auditRetentionDays: 0 }); + expect(storage.getSpendByIdempotencyKey('r12h-key')).toBeTruthy(); // budget data safe + }); + + it('vacuum runs without error', () => { + expect(() => storage.vacuum()).not.toThrow(); + }); + }); }); diff --git a/packages/dcp-core/tests/wallet.test.ts b/packages/dcp-core/tests/wallet.test.ts index 43b61a8..273af0f 100644 --- a/packages/dcp-core/tests/wallet.test.ts +++ b/packages/dcp-core/tests/wallet.test.ts @@ -12,10 +12,17 @@ import { describe, it, expect } from 'vitest'; import { Keypair, Transaction, SystemProgram, PublicKey } from '@solana/web3.js'; +import { TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token'; import { generateWalletKeypair, encryptWalletKey, createWallet, + buildSolanaTransferTx, + buildSplTransferTx, + getSolanaAtaAddress, + verifyTransferTx, + getTransactionSigners, + getTransactionProgramIds, signSolanaMessage, importWallet, getPublicAddress, @@ -295,3 +302,168 @@ describe('Wallet Manager', () => { }); }); }); + +describe('buildSolanaTransferTx (build-from-intent)', () => { + const FROM = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; + const TO = '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S'; + const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + it('builds an unsigned SOL transfer with fee-payer = sender and correct lamports', () => { + const b64 = buildSolanaTransferTx(FROM, TO, 0.25, BLOCKHASH); + const tx = Transaction.from(Buffer.from(b64, 'base64')); + + expect(tx.feePayer?.toBase58()).toBe(FROM); + expect(tx.recentBlockhash).toBe(BLOCKHASH); + + const ix = tx.instructions.find((i) => i.programId.equals(SystemProgram.programId))!; + expect(ix).toBeDefined(); + expect(ix.keys[0].pubkey.toBase58()).toBe(FROM); + expect(ix.keys[0].isSigner).toBe(true); + expect(ix.keys[1].pubkey.toBase58()).toBe(TO); + expect(ix.data.readBigUInt64LE(4)).toBe(250_000_000n); + }); + + it('adds a priority fee instruction by default, omits it when 0', () => { + const withFee = Transaction.from(Buffer.from(buildSolanaTransferTx(FROM, TO, 1, BLOCKHASH), 'base64')); + expect(withFee.instructions).toHaveLength(2); // [setComputeUnitPrice, transfer] + const noFee = Transaction.from( + Buffer.from(buildSolanaTransferTx(FROM, TO, 1, BLOCKHASH, { priorityFeeMicroLamports: 0 }), 'base64') + ); + expect(noFee.instructions).toHaveLength(1); // [transfer] + }); + + it('is unsigned (no signatures attached)', () => { + const tx = Transaction.from(Buffer.from(buildSolanaTransferTx(FROM, TO, 1, BLOCKHASH), 'base64')); + expect(tx.signatures.every((s) => s.signature === null)).toBe(true); + }); + + it('rejects self-transfer, bad addresses, and non-positive amounts', () => { + expect(() => buildSolanaTransferTx(FROM, FROM, 1, BLOCKHASH)).toThrow(VaultError); + expect(() => buildSolanaTransferTx('bad', TO, 1, BLOCKHASH)).toThrow(/Invalid from/); + expect(() => buildSolanaTransferTx(FROM, 'bad', 1, BLOCKHASH)).toThrow(/Invalid to/); + expect(() => buildSolanaTransferTx(FROM, TO, 0, BLOCKHASH)).toThrow(/positive/); + expect(() => buildSolanaTransferTx(FROM, TO, -1, BLOCKHASH)).toThrow(/positive/); + expect(() => buildSolanaTransferTx(FROM, TO, 1, '')).toThrow(/blockhash/); + }); +}); + +describe('buildSplTransferTx (SPL token, auto-ATA)', () => { + const FROM = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; + const TO = '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S'; + const MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; // USDC + const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + it('CREATES the recipient ATA when they have never held the token', () => { + const b64 = buildSplTransferTx({ fromAddress: FROM, toAddress: TO, mint: MINT, amount: 1.5, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: true }); + const tx = Transaction.from(Buffer.from(b64, 'base64')); + expect(tx.feePayer?.toBase58()).toBe(FROM); // sender pays + const programs = tx.instructions.map((i) => i.programId.toBase58()); + expect(programs).toContain(ASSOCIATED_TOKEN_PROGRAM_ID.toBase58()); // ATA creation present + expect(programs).toContain(TOKEN_PROGRAM_ID.toBase58()); // token transfer present + }); + + it('omits ATA creation when the recipient already has the account', () => { + const tx = Transaction.from( + Buffer.from(buildSplTransferTx({ fromAddress: FROM, toAddress: TO, mint: MINT, amount: 1, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: false }), 'base64') + ); + const programs = tx.instructions.map((i) => i.programId.toBase58()); + expect(programs).not.toContain(ASSOCIATED_TOKEN_PROGRAM_ID.toBase58()); + expect(programs).toContain(TOKEN_PROGRAM_ID.toBase58()); + }); + + it('derives a deterministic ATA address and rejects bad input', () => { + const ata = getSolanaAtaAddress(MINT, FROM); + expect(ata).toMatch(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/); + expect(() => buildSplTransferTx({ fromAddress: FROM, toAddress: FROM, mint: MINT, amount: 1, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: false })).toThrow(/self-transfer/); + expect(() => buildSplTransferTx({ fromAddress: FROM, toAddress: TO, mint: 'bad', amount: 1, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: false })).toThrow(/Invalid token mint/); + expect(() => buildSplTransferTx({ fromAddress: FROM, toAddress: TO, mint: MINT, amount: 0, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: false })).toThrow(/positive/); + }); +}); + +describe('verifyTransferTx (anti blind-sign for vault_sign_tx)', () => { + const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; + const TO = '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S'; + const OTHER = 'So11111111111111111111111111111111111111112'; + const MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + it('accepts a SOL transfer matching declared amount + destination', () => { + const tx = buildSolanaTransferTx(OWNER, TO, 0.1, BLOCKHASH); + const r = verifyTransferTx({ unsignedTx: tx, owner: OWNER, declaredAmount: 0.1, declaredDestination: TO, currency: 'SOL' }); + expect(r).toEqual({ ok: true, verified: true }); + }); + + it('REJECTS a blob that sends MORE SOL than declared (the attack)', () => { + const tx = buildSolanaTransferTx(OWNER, TO, 10, BLOCKHASH); // blob really sends 10 + const r = verifyTransferTx({ unsignedTx: tx, owner: OWNER, declaredAmount: 0.1, declaredDestination: TO, currency: 'SOL' }); + expect(r.ok).toBe(false); + expect(r.verified).toBe(true); + }); + + it('REJECTS a blob to a DIFFERENT destination than declared', () => { + const tx = buildSolanaTransferTx(OWNER, OTHER, 0.1, BLOCKHASH); // blob sends to OTHER + const r = verifyTransferTx({ unsignedTx: tx, owner: OWNER, declaredAmount: 0.1, declaredDestination: TO, currency: 'SOL' }); + expect(r.ok).toBe(false); + }); + + it('accepts a matching SPL transfer and rejects an over-amount one', () => { + const okTx = buildSplTransferTx({ fromAddress: OWNER, toAddress: TO, mint: MINT, amount: 1.5, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: false }); + expect(verifyTransferTx({ unsignedTx: okTx, owner: OWNER, declaredAmount: 1.5, declaredDestination: TO, currency: 'USDC' })).toEqual({ ok: true, verified: true }); + + const badTx = buildSplTransferTx({ fromAddress: OWNER, toAddress: TO, mint: MINT, amount: 100, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: false }); + expect(verifyTransferTx({ unsignedTx: badTx, owner: OWNER, declaredAmount: 1, declaredDestination: TO, currency: 'USDC' }).ok).toBe(false); + }); + + it('does not enforce (verified:false) for an unrecognized/garbage blob', () => { + const r = verifyTransferTx({ unsignedTx: Buffer.from('not a tx').toString('base64'), owner: OWNER, declaredAmount: 1 }); + expect(r).toEqual({ ok: true, verified: false }); // escape hatch, not enforced + }); +}); + +describe('getTransactionSigners (swap-tx validation)', () => { + it('reports the fee-payer and a single required signer for a built tx', () => { + const tx = buildSolanaTransferTx( + 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm', + '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S', + 0.1, + 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi' + ); + const s = getTransactionSigners(tx); + expect(s.feePayer).toBe('Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'); + expect(s.numRequiredSignatures).toBe(1); // only the owner signs + }); +}); + +describe('getTransactionProgramIds (swap-tx program allow-listing)', () => { + const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; + const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + + function txWithPrograms(programIds: string[]): string { + const tx = new Transaction({ feePayer: new PublicKey(OWNER), recentBlockhash: BLOCKHASH }); + for (const pid of programIds) { + tx.add({ keys: [], programId: new PublicKey(pid), data: Buffer.from([]) }); + } + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); + } + + it('extracts the top-level program ids a tx invokes', () => { + const SYSTEM = '11111111111111111111111111111111'; + const JUP = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; + const r = getTransactionProgramIds(txWithPrograms([SYSTEM, JUP])); + expect(r.resolvable).toBe(true); + expect(r.programIds.sort()).toEqual([SYSTEM, JUP].sort()); + }); + + it('surfaces an unexpected program so the caller can reject it', () => { + const RANDOM = Keypair.generate().publicKey.toBase58(); + const r = getTransactionProgramIds(txWithPrograms([RANDOM])); + expect(r.resolvable).toBe(true); + expect(r.programIds).toContain(RANDOM); + }); + + it('reports resolvable:false for a garbage blob (treat as opaque)', () => { + const r = getTransactionProgramIds(Buffer.from('not a tx').toString('base64')); + expect(r.resolvable).toBe(false); + expect(r.programIds).toEqual([]); + }); +}); diff --git a/packages/dcp-vault/package.json b/packages/dcp-vault/package.json index c1c6b3c..d62c401 100644 --- a/packages/dcp-vault/package.json +++ b/packages/dcp-vault/package.json @@ -30,6 +30,7 @@ "dependencies": { "@dcprotocol/client": "^3.0.0", "@dcprotocol/core": "^3.0.0", + "@dcprotocol/wallet-core": "workspace:*", "@dcprotocol/relay-client": "^3.0.0", "@fastify/cors": "^10.0.2", "@fastify/rate-limit": "^10.0.0", @@ -46,6 +47,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.98.0", "@types/better-sqlite3": "^7.6.0", "@types/node": "^22.10.2", diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index 073cf34..ebe76d2 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -45,6 +45,10 @@ import { envelopeDecrypt, signTransaction, signSolanaMessage, + buildSolanaTransferTx, + buildSplTransferTx, + getSolanaAtaAddress, + verifyTransferTx, type VaultErrorCode, type TrustedService, type AgentConnection, @@ -70,6 +74,27 @@ import { canonicalJson, DEFAULT_RELAY_URL as CORE_DEFAULT_RELAY_URL, } from '@dcprotocol/core'; +import { + validateSwapQuote, + validateSwapTransaction, + idempotencyIntentMatches, + DEFAULT_SWAP_ALLOWED_PROGRAMS, + DEFAULT_JUPITER_PROGRAM_IDS, + resolveToken, + resolveSwapToken, + DEFAULT_KNOWN_TOKENS, + jupiterQuote, + jupiterBuildSwapTx, + DEFAULT_JUPITER_API, + executeTransfer, + executeSwap, + ConsentRequiredError, + type TokenRegistry, + type JupiterConfig, + type TransferPorts, + type SwapPorts, +} from '@dcprotocol/wallet-core'; +import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -84,6 +109,13 @@ import { type SigningKeyPair, type StoredPairingClaim, } from '@dcprotocol/relay-client'; +import { + getUnlockRateLimitKey, + checkUnlockRateLimit, + recordUnlockFailure, + clearUnlockRateLimit, +} from './lib/unlock-rate-limit.js'; +import { normalizePermissionScope, normalizePermissionScopes } from './lib/permission-scopes.js'; // ============================================================================ // Constants @@ -126,112 +158,6 @@ const pendingChallenges: Map = new Map(); // desktop_id -> Ch // ============================================================================ // Unlock Rate Limiting (protocol spec) // ============================================================================ -// 5 failed attempts per minute -> 5 minute lockout -const UNLOCK_MAX_ATTEMPTS = 5; -const UNLOCK_WINDOW_MS = 60 * 1000; // 1 minute window -const UNLOCK_LOCKOUT_MS = 5 * 60 * 1000; // 5 minute lockout - -interface UnlockAttemptTracker { - attempts: number[]; - locked_until: number | null; -} - -const unlockAttempts: Map = new Map(); - -function getUnlockRateLimitKey(request: FastifyRequest): string { - // For localhost, we track by source port as a basic identifier - // In production, this is always localhost so we use a single key - return 'local'; -} - -function checkUnlockRateLimit(key: string): { allowed: boolean; retry_after_seconds?: number } { - const now = Date.now(); - let tracker = unlockAttempts.get(key); - - if (!tracker) { - tracker = { attempts: [], locked_until: null }; - unlockAttempts.set(key, tracker); - } - - // Check if currently locked out - if (tracker.locked_until && now < tracker.locked_until) { - const retry_after_seconds = Math.ceil((tracker.locked_until - now) / 1000); - return { allowed: false, retry_after_seconds }; - } - - // Clear lockout if expired - if (tracker.locked_until && now >= tracker.locked_until) { - tracker.locked_until = null; - tracker.attempts = []; - } - - // Clean up old attempts outside the window - const windowStart = now - UNLOCK_WINDOW_MS; - tracker.attempts = tracker.attempts.filter((t) => t > windowStart); - - return { allowed: true }; -} - -function recordUnlockFailure(key: string): { locked: boolean; retry_after_seconds?: number } { - const now = Date.now(); - let tracker = unlockAttempts.get(key); - - if (!tracker) { - tracker = { attempts: [], locked_until: null }; - unlockAttempts.set(key, tracker); - } - - // Record this attempt - tracker.attempts.push(now); - - // Clean up old attempts outside the window - const windowStart = now - UNLOCK_WINDOW_MS; - tracker.attempts = tracker.attempts.filter((t) => t > windowStart); - - // Check if we've exceeded the limit - if (tracker.attempts.length >= UNLOCK_MAX_ATTEMPTS) { - tracker.locked_until = now + UNLOCK_LOCKOUT_MS; - const retry_after_seconds = Math.ceil(UNLOCK_LOCKOUT_MS / 1000); - return { locked: true, retry_after_seconds }; - } - - return { locked: false }; -} - -function clearUnlockRateLimit(key: string): void { - unlockAttempts.delete(key); -} - -// ============================================================================ -// Permission Scope Normalization -// ============================================================================ -// Fixes old permission_scopes saved without operation prefix (read:/write:/sign:) -// This ensures backward compatibility with agents created before the fix -const SIGNING_CHAINS = ['solana']; - -function normalizePermissionScope(scope: string): string { - // Already has a valid prefix - return as-is - if (scope.startsWith('read:') || scope.startsWith('write:') || scope.startsWith('sign:')) { - return scope; - } - - // Check if it's a chain name that should be sign: - const lowerScope = scope.toLowerCase(); - if (SIGNING_CHAINS.includes(lowerScope)) { - return `sign:${lowerScope}`; - } - - // Everything else gets read: prefix (identity, credentials, etc.) - return `read:${scope}`; -} - -function normalizePermissionScopes(scopes: string[]): string[] { - if (!scopes || !Array.isArray(scopes)) return []; - - // Normalize each scope and remove duplicates - const normalized = scopes.map(normalizePermissionScope); - return [...new Set(normalized)]; -} function getPackageVersion(): string { try { @@ -4513,54 +4439,162 @@ async function buildServer(): Promise { }); // ============================================================================ - // V1 API: Vault Sign (with consent + budget) + // Solana RPC + shared sign core (backs /v1/vault/sign and /v1/vault/transfer) // ============================================================================ - server.post<{ - Body: { - chain: Chain; - unsigned_tx: string; - amount?: number; - currency?: string; - agent_name: string; - session_id?: string; - description?: string; - idempotency_key?: string; - // Agent signature fields (optional, for paired agents) - service_id?: string; - service_signature?: string; - timestamp?: string; - nonce?: string; - }; - }>('/v1/vault/sign', async (request) => { - const { chain, unsigned_tx, amount, currency, agent_name, session_id, description, idempotency_key } = request.body; - let effectiveSessionId = session_id; + // Per-wallet-scope serialization. A wallet must never run two value operations + // concurrently: budget check and spend recording straddle an async sign, so + // without this two transfers could both pass the budget check and overspend. + const walletLocks = new Map>(); + function withWalletLock(scope: string, fn: () => Promise): Promise { + const prev = walletLocks.get(scope) ?? Promise.resolve(); + const run = prev.then(fn, fn); + walletLocks.set(scope, run.then(() => undefined, () => undefined)); + return run; + } - if (!chain || !unsigned_tx || !agent_name) { - throw new VaultError('INTERNAL_ERROR', 'chain, unsigned_tx, and agent_name are required'); + // RPC endpoint is config, never a hardcoded secret. Premium hosts inject + // DCP_SOLANA_RPC_URL (their proxy); OSS gets the public mainnet default. + let solanaConnection: Connection | null = null; + function getSolanaConnection(): Connection { + if (!solanaConnection) { + const rpcUrl = process.env.DCP_SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com'; + solanaConnection = new Connection(rpcUrl, { + commitment: 'confirmed', + confirmTransactionInitialTimeout: 60_000, + }); } + return solanaConnection; + } - // Check if vault is unlocked - if (!storage.isUnlocked()) { - throw new VaultError('VAULT_LOCKED', 'Vault is locked. Please unlock first.'); + // Known SPL tokens by symbol → mint + decimals. Mainnet defaults from wallet-core; + // override per-cluster via env (e.g. DCP_USDC_MINT for devnet). Resolution logic + // (resolveToken / resolveSwapToken) lives in wallet-core; this just supplies the + // registry the vault should use. + const KNOWN_TOKENS: TokenRegistry = { + USDC: { mint: process.env.DCP_USDC_MINT || DEFAULT_KNOWN_TOKENS.USDC.mint, decimals: DEFAULT_KNOWN_TOKENS.USDC.decimals }, + USDT: { mint: process.env.DCP_USDT_MINT || DEFAULT_KNOWN_TOKENS.USDT.mint, decimals: DEFAULT_KNOWN_TOKENS.USDT.decimals }, + }; + + // Cache the recent blockhash. A blockhash is valid ~60-90s; refetching it on + // every transfer costs a full RPC round-trip (~350-600ms). Caching it for a + // few seconds removes that from the hot path. It is PUBLIC data — no secret, + // no security surface. Invalidated on a stale-blockhash submit error. + const BLOCKHASH_TTL_MS = 8_000; + let cachedBlockhash: { blockhash: string; fetchedAt: number } | null = null; + async function getCachedBlockhash(conn: Connection): Promise { + const now = Date.now(); + if (cachedBlockhash && now - cachedBlockhash.fetchedAt < BLOCKHASH_TTL_MS) { + return cachedBlockhash.blockhash; } + const { blockhash } = await rpcRetry(() => conn.getLatestBlockhash()); + cachedBlockhash = { blockhash, fetchedAt: now }; + return blockhash; + } + function invalidateBlockhash(): void { + cachedBlockhash = null; + } - // Check agent permissions FIRST (before session/consent flow) - const requestedScope = `sign:${chain}`; - const agentAuth = verifyLocalAgentRequest(request.body as Record, requestedScope); - if (!agentAuth.authorized && agentAuth.skipConsentFlow) { - throw new VaultError('SERVICE_SCOPE_VIOLATION', agentAuth.reason || 'Scope not permitted for this agent'); + // Retry transient RPC failures (rate limits, network blips) with backoff. + async function rpcRetry(fn: () => Promise, attempts = 3, delayMs = 400): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs * (i + 1))); + } } + throw lastErr; + } - // Determine currency from chain if not provided - const txCurrency = currency || 'SOL'; + // Broadcast a signed tx and poll to commitment, rebroadcasting periodically so + // a dropped transaction still lands while its blockhash is valid. On timeout + // returns a non-blocking `submitted` status (PRD §10.7). + async function submitAndConfirm( + conn: Connection, + signedTxBase64: string, + waitForConfirm = true, + timeoutMs = 45_000, + pollMs = 400 + ): Promise<{ signature: string; status: 'submitted' | 'confirmed' | 'finalized' | 'failed' }> { + const raw = Buffer.from(signedTxBase64, 'base64'); + // Confirmed mode runs preflight simulation (safer, catches errors before + // broadcast). Fast mode skips it — the tx is vault-built so it's well-formed, + // and skipping the simulate round-trip is the point of the fast path. + const signature = await rpcRetry(() => + conn.sendRawTransaction(raw, { skipPreflight: !waitForConfirm, maxRetries: 0 }) + ); + + // Fast path: the RPC accepted the broadcast — return without waiting for the + // network to confirm. The caller can poll status later. + if (!waitForConfirm) { + return { signature, status: 'submitted' }; + } + + const start = Date.now(); + let lastRebroadcast = Date.now(); + for (;;) { + const res = await conn.getSignatureStatuses([signature], { searchTransactionHistory: true }); + const v = res.value?.[0]; + if (v) { + if (v.err) return { signature, status: 'failed' }; + const cs = v.confirmationStatus; + if (cs === 'finalized' || cs === 'confirmed') return { signature, status: cs }; + } + if (Date.now() - start >= timeoutMs) return { signature, status: 'submitted' }; + // Rebroadcast every ~6s in case the leader dropped the tx. + if (Date.now() - lastRebroadcast >= 6_000) { + lastRebroadcast = Date.now(); + try { + await conn.sendRawTransaction(raw, { skipPreflight: true, maxRetries: 0 }); + } catch { + /* ignore — best-effort rebroadcast */ + } + } + await new Promise((r) => setTimeout(r, pollMs)); + } + } + + /** + * Shared budget + consent + session + sign core. This is the EXACT behavior + * that has always backed /v1/vault/sign — extracted so /v1/vault/transfer can + * reuse it unchanged. Callers MUST do unlock + agent-auth checks first (each + * route authenticates its own request body). Do not change this without + * re-running the vault test suite. + */ + async function signWithGuards(args: { + body: Record; + chain: Chain; + unsignedTx: string; + amount?: number; + currency?: string; + agentName: string; + sessionId?: string; + description?: string; + idempotencyKey?: string; + /** When true, do NOT record the spend here — the caller commits it after a + * successful submit (transfer path), so a failed broadcast doesn't consume + * budget or burn the idempotency key. */ + deferSpend?: boolean; + /** Owner-initiated (desktop, authenticated via owner token). The human IS the + * owner and approved in the UI, so skip the agent budget limits + consent + * flow entirely. Only set this from a verified isOwnerRequest(request). */ + ownerApproved?: boolean; + }): Promise< + | { kind: 'consent'; consent_id: string; expires_at: string; reason?: string; message: string } + | { kind: 'signed'; signed_tx: string; signature: string; remaining_daily: number; session_id?: string; spend_session_id: string } + > { + const { chain, unsignedTx, amount, agentName: agent_name, description, idempotencyKey: idempotency_key } = args; + let effectiveSessionId = args.sessionId; + const txCurrency = args.currency || 'SOL'; - // Track if budget check auto-approved this transaction (amount under threshold) - // When true, we skip the session/consent check since user configured this threshold let budgetAutoApproved = false; - // Budget check if amount is provided - if (amount !== undefined && amount > 0) { + // Owner-initiated requests (verified owner token) skip agent budget limits + + // consent — it's the user's own wallet and they approved in the UI. + if (!args.ownerApproved && amount !== undefined && amount > 0) { const budgetResult = budget.checkBudget(amount, txCurrency, chain); if (!budgetResult.allowed) { @@ -4575,7 +4609,6 @@ async function buildServer(): Promise { ? 'BUDGET_EXCEEDED_TX' : 'BUDGET_EXCEEDED_DAILY'; - // Send Telegram notification about budget exceeded (fire and forget) const limits = budget.getLimits(txCurrency); dispatchBudgetExceededNotification({ agent_name, @@ -4595,17 +4628,11 @@ async function buildServer(): Promise { }); } - // If above approval threshold, check for approved consent or require new consent if (budgetResult.requires_approval) { const walletScope = `crypto.wallet.${chain}`; - - // Check if there's an approved consent that can be consumed (approve-once semantics) const consumedConsent = storage.consumeApprovedConsent(agent_name, 'sign_tx', walletScope); if (consumedConsent) { - // Approved consent found and consumed - proceed to signing below - // Skip session check since we already have budget approval budgetAutoApproved = true; - // Log the consumption storage.logAudit('EXECUTE', 'success', { agentName: agent_name, scope: walletScope, @@ -4613,23 +4640,19 @@ async function buildServer(): Promise { details: JSON.stringify({ consent_id: consumedConsent.id, amount, currency: txCurrency }), }); } else { - // No approved consent - create pending consent - const { consent, isNew } = storage.createPendingConsent( + const { consent } = storage.createPendingConsent( agent_name, 'sign_tx', walletScope, - consentDetailsWithDisplayName(request.body as Record, agent_name, { + consentDetailsWithDisplayName(args.body, agent_name, { description, amount, currency: txCurrency, chain, }) ); - - // Telegram notification handled by consent watcher (avoids duplicates) - return { - requires_consent: true, + kind: 'consent', consent_id: consent.id, expires_at: consent.expires_at, reason: 'Amount exceeds approval threshold', @@ -4637,8 +4660,6 @@ async function buildServer(): Promise { }; } } else { - // Amount is under approval threshold - auto-approve without session/consent - // This is the user's configured threshold, so we trust it budgetAutoApproved = true; storage.logAudit('EXECUTE', 'success', { agentName: agent_name, @@ -4649,10 +4670,8 @@ async function buildServer(): Promise { } } - // Get wallet scope const walletScope = `crypto.wallet.${chain}`; - // Try to reuse an existing active session by agent + scope if (!effectiveSessionId) { const existing = findActiveSessionForScope(agent_name, walletScope); if (existing) { @@ -4660,7 +4679,6 @@ async function buildServer(): Promise { } } - // Check for valid session let hasSession = false; if (effectiveSessionId) { const session = storage.getSession(effectiveSessionId); @@ -4672,36 +4690,28 @@ async function buildServer(): Promise { } } - // If no valid session AND not budget auto-approved, check for consent - // Skip this check if budget auto-approved (amount under threshold) - if (!hasSession && !budgetAutoApproved) { - // Check if there's an approved consent that can be consumed (approve-once semantics) + if (!hasSession && !budgetAutoApproved && !args.ownerApproved) { const consumedConsent = storage.consumeApprovedConsent(agent_name, 'sign_tx', walletScope); if (!consumedConsent) { - // No approved consent - create pending consent - const { consent, isNew } = storage.createPendingConsent( + const { consent } = storage.createPendingConsent( agent_name, 'sign_tx', walletScope, - consentDetailsWithDisplayName(request.body as Record, agent_name, { + consentDetailsWithDisplayName(args.body, agent_name, { description, amount, currency: txCurrency, chain, }) ); - - // Telegram notification handled by consent watcher (avoids duplicates) - return { - requires_consent: true, + kind: 'consent', consent_id: consent.id, expires_at: consent.expires_at, message: `Consent required. Approve with: POST /consent/${consent.id}/approve`, }; } - // Approved consent consumed - proceed to signing storage.logAudit('EXECUTE', 'success', { agentName: agent_name, scope: walletScope, @@ -4710,7 +4720,6 @@ async function buildServer(): Promise { }); } - // Get wallet and sign const masterKey = storage.getMasterKey(); const payload = storage.getEncryptedPayload(walletScope); @@ -4718,18 +4727,19 @@ async function buildServer(): Promise { throw new VaultError('RECORD_NOT_FOUND', `No wallet found for chain: ${chain}`); } - // Sign the transaction (signTransaction expects base64 string for Solana) - const signResult = await signTransaction(payload, masterKey, chain, unsigned_tx); + const signResult = await signTransaction(payload, masterKey, chain, unsignedTx); - // Record spend event if amount provided - if (amount !== undefined && amount > 0) { - const spendSessionId = effectiveSessionId || getBudgetLedgerSessionId(agent_name); + const spendSessionId = effectiveSessionId || getBudgetLedgerSessionId(agent_name); + + // Default path records the spend immediately. The transfer path defers it + // until after a successful broadcast (see route) so a failed submit neither + // consumes budget nor burns the idempotency key. + if (!args.deferSpend && !args.ownerApproved && amount !== undefined && amount > 0) { storage.recordSpend(spendSessionId, amount, txCurrency, chain, 'sign_tx', 'committed', { idempotencyKey: idempotency_key, }); } - // Get updated budget info const budgetInfo = budget.checkBudget(0, txCurrency, chain); storage.logAudit('EXECUTE', 'success', { @@ -4740,11 +4750,796 @@ async function buildServer(): Promise { }); return { + kind: 'signed', signed_tx: signResult.signed_tx, signature: signResult.signature, - chain, remaining_daily: budgetInfo.remaining_daily, session_id: effectiveSessionId, + spend_session_id: spendSessionId, + }; + } + + // ============================================================================ + // V1 API: Vault Sign (with consent + budget) + // ============================================================================ + + server.post<{ + Body: { + chain: Chain; + unsigned_tx: string; + amount?: number; + currency?: string; + agent_name: string; + session_id?: string; + description?: string; + idempotency_key?: string; + // Agent signature fields (optional, for paired agents) + service_id?: string; + service_signature?: string; + timestamp?: string; + nonce?: string; + }; + }>('/v1/vault/sign', async (request) => { + const { chain, unsigned_tx, amount, currency, agent_name, session_id, description, idempotency_key } = request.body; + + if (!chain || !unsigned_tx || !agent_name) { + throw new VaultError('INTERNAL_ERROR', 'chain, unsigned_tx, and agent_name are required'); + } + + // Check if vault is unlocked + if (!storage.isUnlocked()) { + throw new VaultError('VAULT_LOCKED', 'Vault is locked. Please unlock first.'); + } + + // Check agent permissions FIRST (before session/consent flow) + const requestedScope = `sign:${chain}`; + const agentAuth = verifyLocalAgentRequest(request.body as Record, requestedScope); + if (!agentAuth.authorized && agentAuth.skipConsentFlow) { + throw new VaultError('SERVICE_SCOPE_VIOLATION', agentAuth.reason || 'Scope not permitted for this agent'); + } + + // Anti blind-sign: if the blob is a simple SOL/SPL transfer, verify it + // actually matches the declared amount/destination before we budget-check, + // consent on, or sign it. Complex/opaque txs (swaps, defi) are not enforced + // here — those go through DCP-built tools. Pure local check, ~sub-ms. + if (chain === 'solana') { + const owner = getWalletAddress(chain as Chain).address; + const verdict = verifyTransferTx({ + unsignedTx: unsigned_tx, + owner, + declaredAmount: amount, + declaredDestination: (request.body as { destination?: string }).destination, + currency, + }); + if (!verdict.ok) { + storage.logAudit('DENY', 'denied', { + agentName: agent_name, + scope: `crypto.wallet.${chain}`, + operation: 'sign_tx_verify', + details: JSON.stringify({ reason: verdict.reason }), + }); + throw new VaultError('INVALID_CHAIN', `Transaction does not match the declared transfer: ${verdict.reason}`); + } + } + + // Serialize value operations on this wallet so concurrent budget checks and + // spend records cannot interleave. + const result = await withWalletLock(`crypto.wallet.${chain}`, () => + signWithGuards({ + body: request.body as Record, + chain, + unsignedTx: unsigned_tx, + amount, + currency, + agentName: agent_name, + sessionId: session_id, + description, + idempotencyKey: idempotency_key, + ownerApproved: isOwnerRequest(request), + }) + ); + + if (result.kind === 'consent') { + return { + requires_consent: true, + consent_id: result.consent_id, + expires_at: result.expires_at, + ...(result.reason ? { reason: result.reason } : {}), + message: result.message, + }; + } + + return { + signed_tx: result.signed_tx, + signature: result.signature, + chain, + remaining_daily: result.remaining_daily, + session_id: result.session_id, + }; + }); + + // ============================================================================ + // Jupiter swap helpers. The OSS engine takes NO platform fee by default — the + // fee bps + fee account are INJECTED by the product (premium build / entitle- + // ment) via env, so the open-source code never embeds DCP's revenue config. + // ============================================================================ + // Jupiter config injected from env (fee value/account + endpoint stay out of OSS; + // the all-or-nothing fee rule + quote/build logic live in wallet-core). + const jupiterConfig: JupiterConfig = { + apiBase: process.env.DCP_JUPITER_API || DEFAULT_JUPITER_API, + feeBps: parseInt(process.env.DCP_SWAP_FEE_BPS || '0', 10), + feeAccount: process.env.DCP_SWAP_FEE_ACCOUNT || undefined, + }; + + // Program allow-list for a Jupiter swap tx. Defaults come from @dcprotocol/wallet-core + // (the single shared source so vault + mobile validate identically); operators can + // extend the Jupiter program ids via env in case Jupiter ships a new program. + const JUPITER_PROGRAM_IDS = (process.env.DCP_JUPITER_PROGRAM_IDS || DEFAULT_JUPITER_PROGRAM_IDS.join(',')) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const SWAP_ALLOWED_PROGRAMS = [...DEFAULT_SWAP_ALLOWED_PROGRAMS, ...JUPITER_PROGRAM_IDS]; + + // ============================================================================ + // Phase 4 strangler: run a transfer through wallet-core's shared runner using + // vault adapters. The Signer adapter delegates to signWithGuards (budget+consent + // +sign) so the gating is REUSED, not rewritten; the runner owns the shared + // sequence (idempotency → build → sign → verify → submit → record-on-success). + // ============================================================================ + async function runTransferViaSharedRunner( + request: FastifyRequest<{ Body: Record }>, + ctx: { + chain: Chain; to: string; amount: number; txCurrency: string; agent_name: string; + session_id?: string; description?: string; idempotency_key?: string; + walletScope: string; from: string; token: { mint: string; decimals: number } | null; + } + ) { + const { chain, to, amount, txCurrency, agent_name, session_id, description, idempotency_key, walletScope, from, token } = ctx; + const owner = isOwnerRequest(request); + const effectiveIdem = idempotency_key || crypto.randomUUID(); + const conn = getSolanaConnection(); + let spendSessionId: string | undefined; + + const ports: TransferPorts = { + signer: { + address: from, + async sign(unsignedTx) { + // Reuse the existing budget + consent + sign core. Defer the spend; the + // runner records it only after a successful submit. + const result = await signWithGuards({ + body: request.body, chain, unsignedTx, amount, currency: txCurrency, + agentName: agent_name, sessionId: session_id, description, + idempotencyKey: effectiveIdem, deferSpend: true, ownerApproved: owner, + }); + if (result.kind === 'consent') { + throw new ConsentRequiredError({ + consentId: result.consent_id, expiresAt: result.expires_at, + reason: result.reason, message: result.message, + }); + } + spendSessionId = result.spend_session_id; + return result.signed_tx; + }, + }, + rpc: { + async getLatestBlockhash() { return getCachedBlockhash(conn); }, + async accountExists(address) { return !!(await rpcRetry(() => conn.getAccountInfo(new PublicKey(address)))); }, + async submit(signedTx, opts) { + try { + return await submitAndConfirm(conn, signedTx, opts.confirm); + } catch (err) { + invalidateBlockhash(); + storage.logAudit('EXECUTE', 'denied', { agentName: agent_name, scope: walletScope, operation: 'transfer_submit', details: JSON.stringify({ to, amount, error: (err as Error).message }) }); + throw new VaultError('INTERNAL_ERROR', `Failed to submit transaction: ${(err as Error).message}`); + } + }, + }, + approval: { async requestApproval() { return true; } }, // gating handled inside signer (signWithGuards) + budget: { + async check() { return { withinDailyLimit: true, needsApproval: false }; }, // gating handled inside signer + async record({ amount: amt, currency: cur, signature }) { + if (!owner && amt > 0 && spendSessionId) { + try { + storage.recordSpend(spendSessionId, amt, cur, chain, 'transfer', 'committed', { destination: to, idempotencyKey: effectiveIdem, txSignature: signature }); + } catch (err) { + if (!(err instanceof VaultError && err.code === 'IDEMPOTENCY_CONFLICT')) throw err; + } + } + }, + }, + idempotency: { + async get(key) { + const prior = storage.getSpendByIdempotencyKey(key); + if (!prior) return null; + return { operation: prior.operation, destination: prior.destination ?? null, currency: prior.currency, amount: prior.amount, signature: prior.tx_signature ?? undefined }; + }, + async commit() {}, // recordSpend handled in budget.record + }, + activity: { + async record(ev) { + storage.logAudit('EXECUTE', ev.status === 'failed' ? 'denied' : 'success', { agentName: agent_name, scope: walletScope, operation: 'transfer', details: JSON.stringify({ to, amount: ev.amount, currency: ev.currency, signature: ev.signature, status: ev.status }) }); + }, + }, + }; + + const waitForConfirm = (request.body.confirm ?? 'confirmed') !== 'submitted'; + try { + const result = await withWalletLock(walletScope, () => executeTransfer({ + chain: 'solana', to, amount, currency: txCurrency, + mint: token?.mint, decimals: token?.decimals, + idempotencyKey: effectiveIdem, confirm: waitForConfirm, description, + }, ports)); + + if (result.idempotentReplay) { + return { chain, from, to, amount, currency: txCurrency, signature: result.signature, status: 'submitted', explorer_url: `https://solscan.io/tx/${result.signature}`, idempotent_replay: true }; + } + const budgetInfo = budget.checkBudget(0, txCurrency, chain); + return { chain, from, to, amount, currency: txCurrency, signature: result.signature, status: result.status, explorer_url: `https://solscan.io/tx/${result.signature}`, remaining_daily: budgetInfo.remaining_daily }; + } catch (err) { + if (err instanceof ConsentRequiredError) { + return { requires_consent: true, consent_id: err.consentId, expires_at: err.expiresAt, ...(err.reason ? { reason: err.reason } : {}), message: err.message }; + } + throw err; + } + } + + // ============================================================================ + // V1 API: Vault Transfer (DCP builds + signs + submits) — Solana SOL + SPL + // ============================================================================ + + server.post<{ + Body: { + chain: Chain; + to: string; + amount: number; + currency?: string; + mint?: string; + decimals?: number; + confirm?: 'submitted' | 'confirmed'; + agent_name: string; + session_id?: string; + description?: string; + idempotency_key?: string; + service_id?: string; + service_signature?: string; + timestamp?: string; + nonce?: string; + }; + }>('/v1/vault/transfer', async (request) => { + const { chain, to, amount, currency, mint, decimals, agent_name, session_id, description, idempotency_key } = request.body; + + if (!chain || !to || amount === undefined || !agent_name) { + throw new VaultError('INTERNAL_ERROR', 'chain, to, amount, and agent_name are required'); + } + if (chain !== 'solana') { + throw new VaultError('INVALID_CHAIN', 'Only solana transfers are supported'); + } + if (!storage.isUnlocked()) { + throw new VaultError('VAULT_LOCKED', 'Vault is locked. Please unlock first.'); + } + const txCurrency = currency || 'SOL'; + const isNative = txCurrency.toUpperCase() === 'SOL' && !mint; + // Resolve SPL token (mint + decimals) up front so an unknown token fails fast. + const token = isNative ? null : resolveToken(txCurrency, KNOWN_TOKENS, mint, decimals); + + // Authenticate the transfer request (sign:chain scope), same as /v1/vault/sign. + const agentAuth = verifyLocalAgentRequest(request.body as Record, `sign:${chain}`); + if (!agentAuth.authorized && agentAuth.skipConsentFlow) { + throw new VaultError('SERVICE_SCOPE_VIOLATION', agentAuth.reason || 'Scope not permitted for this agent'); + } + + const walletScope = `crypto.wallet.${chain}`; + const from = getWalletAddress(chain as Chain).address; + + // ── Phase 4 strangler: shared-runner path (OFF by default) ──────────────── + // When DCP_USE_SHARED_RUNNER=1 the transfer runs through wallet-core's + // executeTransfer using vault adapters. The Signer adapter REUSES the existing, + // tested signWithGuards (budget + consent + sign) — gating logic is re-sequenced + // by the runner, not rewritten. Default OFF keeps the proven path live until the + // new path is on-chain-verified. + if (process.env.DCP_USE_SHARED_RUNNER === '1') { + return runTransferViaSharedRunner(request, { + chain, to, amount, txCurrency, agent_name, session_id, description, + idempotency_key, walletScope, from, token, + }); + } + + // Serialize the whole build → sign → submit → record critical section per + // wallet so two transfers cannot race the budget check or double-submit. + type Outcome = + | { kind: 'replay'; signature: string } + | { kind: 'consent'; consent_id: string; expires_at: string; reason?: string; message: string } + | { kind: 'signed'; signature: string; status: string; remaining_daily: number; session_id?: string }; + + const outcome: Outcome = await withWalletLock(walletScope, async (): Promise => { + // Idempotency: a key replays ONLY the identical transfer. If the same key + // is reused with a DIFFERENT intent (other recipient, amount, or token) we + // must NOT replay the old signature — that would silently confirm a send the + // caller never asked for. Reject the mismatch and demand a fresh key. + if (idempotency_key) { + const prior = storage.getSpendByIdempotencyKey(idempotency_key); + if (prior) { + const sameIntent = idempotencyIntentMatches( + { operation: prior.operation, destination: prior.destination, currency: prior.currency, amount: prior.amount }, + { operation: 'transfer', destination: to, currency: txCurrency, amount } + ); + if (!sameIntent) { + throw new VaultError( + 'IDEMPOTENCY_CONFLICT', + 'This idempotency_key was already used for a different transfer. Use a fresh idempotency_key for a new transaction.' + ); + } + if (prior.tx_signature) return { kind: 'replay', signature: prior.tx_signature }; + } + } + + // A per-transfer nonce embedded as an on-chain memo. With the blockhash + // cache, two DISTINCT transfers (different keys) with identical from/to/ + // amount would otherwise build byte-identical transactions and collide on + // one signature. The memo makes same-key → same tx (idempotent) and + // different-key → different tx (distinct send). No client key → a random + // nonce, so each call is its own distinct send. + const effectiveIdem = idempotency_key || crypto.randomUUID(); + + const conn = getSolanaConnection(); + const blockhash = await getCachedBlockhash(conn); + + let unsignedTx: string; + if (isNative) { + // SOL: if the recipient account doesn't exist yet, a sub-rent-exempt + // amount can't create it — fail with a clear message instead of a + // cryptic on-chain simulation error. + const toInfo = await rpcRetry(() => conn.getAccountInfo(new PublicKey(to))); + if (!toInfo) { + const rentMin = await rpcRetry(() => conn.getMinimumBalanceForRentExemption(0)); + const lamports = Math.round(amount * LAMPORTS_PER_SOL); + if (lamports < rentMin) { + throw new VaultError( + 'INVALID_CHAIN', + `Amount ${amount} SOL is below the ~${(rentMin / LAMPORTS_PER_SOL).toFixed(5)} SOL minimum needed to create a new account. Send at least that much, or send to an account that already exists.` + ); + } + } + unsignedTx = buildSolanaTransferTx(from, to, amount, blockhash, { memo: effectiveIdem }); + } else { + // SPL token: create the recipient's Associated Token Account if they've + // never held this token, so sending to a brand-new wallet "just works". + const recipientAta = getSolanaAtaAddress(token!.mint, to); + const ataInfo = await rpcRetry(() => conn.getAccountInfo(new PublicKey(recipientAta))); + unsignedTx = buildSplTransferTx({ + fromAddress: from, + toAddress: to, + mint: token!.mint, + amount, + decimals: token!.decimals, + blockhash, + createRecipientAta: !ataInfo, + memo: effectiveIdem, + }); + } + + // Reuse the exact budget + consent + sign core; defer the spend until the + // broadcast actually succeeds. + const result = await signWithGuards({ + body: request.body as Record, + chain, + unsignedTx, + amount, + currency: txCurrency, + agentName: agent_name, + sessionId: session_id, + description: description ?? `Send ${amount} SOL to ${to}`, + idempotencyKey: idempotency_key, + deferSpend: true, + ownerApproved: isOwnerRequest(request), + }); + + if (result.kind === 'consent') { + return { + kind: 'consent', + consent_id: result.consent_id, + expires_at: result.expires_at, + reason: result.reason, + message: result.message, + }; + } + + // DCP SUBMITS the signed transaction (with retry + rebroadcast). + // confirm='submitted' returns as soon as it is broadcast (fast path for + // trading agents); the default waits for on-chain confirmation. + const waitForConfirm = (request.body.confirm ?? 'confirmed') !== 'submitted'; + let submitted: { signature: string; status: 'submitted' | 'confirmed' | 'finalized' | 'failed' }; + try { + submitted = await submitAndConfirm(conn, result.signed_tx, waitForConfirm); + } catch (err) { + // A stale cached blockhash is one possible cause — drop it so the next + // attempt refetches. + invalidateBlockhash(); + storage.logAudit('EXECUTE', 'denied', { + agentName: agent_name, + scope: walletScope, + operation: 'transfer_submit', + details: JSON.stringify({ to, amount, error: (err as Error).message }), + }); + // Spend was deferred → budget untouched and idempotency key free to retry. + throw new VaultError('INTERNAL_ERROR', `Failed to submit transaction: ${(err as Error).message}`); + } + + // Commit the spend now that it landed/broadcast (records the signature for + // idempotent replay). Owner-initiated sends do not count against agent + // budget. A concurrent winner may have recorded first. + if (submitted.status !== 'failed' && amount > 0 && !isOwnerRequest(request)) { + try { + storage.recordSpend(result.spend_session_id, amount, txCurrency, chain, 'transfer', 'committed', { + destination: to, + idempotencyKey: effectiveIdem, + txSignature: submitted.signature, + }); + } catch (err) { + if (!(err instanceof VaultError && err.code === 'IDEMPOTENCY_CONFLICT')) throw err; + } + } + + const budgetInfo = budget.checkBudget(0, txCurrency, chain); + + storage.logAudit('EXECUTE', submitted.status === 'failed' ? 'denied' : 'success', { + agentName: agent_name, + scope: walletScope, + operation: 'transfer', + details: JSON.stringify({ to, amount, currency: txCurrency, signature: submitted.signature, status: submitted.status }), + }); + + return { + kind: 'signed', + signature: submitted.signature, + status: submitted.status, + remaining_daily: budgetInfo.remaining_daily, + session_id: result.session_id, + }; + }); + + if (outcome.kind === 'consent') { + return { + requires_consent: true, + consent_id: outcome.consent_id, + expires_at: outcome.expires_at, + ...(outcome.reason ? { reason: outcome.reason } : {}), + message: outcome.message, + }; + } + + if (outcome.kind === 'replay') { + return { + chain, + from, + to, + amount, + currency: txCurrency, + signature: outcome.signature, + status: 'submitted', + explorer_url: `https://solscan.io/tx/${outcome.signature}`, + idempotent_replay: true, + }; + } + + return { + chain, + from, + to, + amount, + currency: txCurrency, + signature: outcome.signature, + status: outcome.status, + explorer_url: `https://solscan.io/tx/${outcome.signature}`, + remaining_daily: outcome.remaining_daily, + session_id: outcome.session_id, + }; + }); + + // ============================================================================ + // Phase 4 strangler: run a swap through wallet-core's shared runner using vault + // adapters. Signer delegates to signWithGuards (budget+consent+sign); SwapProvider + // uses wallet-core jupiter with the injected config; the runner owns the shared + // sequence (idempotency → quote+validate → build+validate → sign → re-validate → + // submit → record-on-success). + // ============================================================================ + async function runSwapViaSharedRunner( + request: FastifyRequest<{ Body: Record }>, + ctx: { + chain: Chain; + input: { mint: string; decimals: number; currency: string }; + output: { mint: string; decimals: number; currency: string }; + amount: number; slippageBps: number; agent_name: string; session_id?: string; + description?: string; idempotency_key?: string; walletScope: string; owner: string; + } + ) { + const { chain, input, output, amount, slippageBps, agent_name, session_id, description, idempotency_key, walletScope, owner } = ctx; + const isOwner = isOwnerRequest(request); + const effectiveIdem = idempotency_key || crypto.randomUUID(); + const conn = getSolanaConnection(); + let spendSessionId: string | undefined; + + const ports: SwapPorts = { + signer: { + address: owner, + async sign(unsignedTx) { + const result = await signWithGuards({ + body: request.body, chain, unsignedTx, amount, currency: input.currency, + agentName: agent_name, sessionId: session_id, + description: description ?? `Swap ${amount} ${input.currency} → ${output.currency}`, + idempotencyKey: effectiveIdem, deferSpend: true, ownerApproved: isOwner, + }); + if (result.kind === 'consent') { + throw new ConsentRequiredError({ consentId: result.consent_id, expiresAt: result.expires_at, reason: result.reason, message: result.message }); + } + spendSessionId = result.spend_session_id; + return result.signed_tx; + }, + }, + rpc: { + async getLatestBlockhash() { return getCachedBlockhash(conn); }, + async accountExists(address) { return !!(await rpcRetry(() => conn.getAccountInfo(new PublicKey(address)))); }, + async submit(signedTx, opts) { + try { + return await submitAndConfirm(conn, signedTx, opts.confirm); + } catch (err) { + storage.logAudit('EXECUTE', 'denied', { agentName: agent_name, scope: walletScope, operation: 'swap_submit', details: JSON.stringify({ from_token: input.currency, to_token: output.currency, error: (err as Error).message }) }); + throw new VaultError('INTERNAL_ERROR', `Failed to submit swap: ${(err as Error).message}`); + } + }, + }, + swapProvider: { + async quote(args) { + return jupiterQuote(jupiterConfig, { + inputMint: args.inputMint, + outputMint: args.outputMint, + amountBaseUnits: args.rawInAmount, + slippageBps: args.slippageBps, + }); + }, + async buildSwapTx(quote, userPublicKey) { return jupiterBuildSwapTx(jupiterConfig, quote, userPublicKey); }, + }, + approval: { async requestApproval() { return true; } }, // gating handled inside signer + budget: { + async check() { return { withinDailyLimit: true, needsApproval: false }; }, + async record({ amount: amt, currency: cur, signature }) { + if (!isOwner && amt > 0 && spendSessionId) { + try { + storage.recordSpend(spendSessionId, amt, cur, chain, 'swap', 'committed', { destination: output.mint, idempotencyKey: effectiveIdem, txSignature: signature }); + } catch (err) { + if (!(err instanceof VaultError && err.code === 'IDEMPOTENCY_CONFLICT')) throw err; + } + } + }, + }, + idempotency: { + async get(key) { + const prior = storage.getSpendByIdempotencyKey(key); + if (!prior) return null; + return { operation: prior.operation, destination: prior.destination ?? null, currency: prior.currency, amount: prior.amount, signature: prior.tx_signature ?? undefined }; + }, + async commit() {}, + }, + config: { + jupiterProgramIds: () => JUPITER_PROGRAM_IDS, + swapAllowedPrograms: () => SWAP_ALLOWED_PROGRAMS, + }, + activity: { + async record(ev) { + storage.logAudit('EXECUTE', ev.status === 'failed' ? 'denied' : 'success', { agentName: agent_name, scope: walletScope, operation: 'swap', details: JSON.stringify({ from_token: input.currency, to_token: output.currency, amount: ev.amount, signature: ev.signature, status: ev.status }) }); + }, + }, + }; + + const waitForConfirm = (request.body.confirm ?? 'confirmed') !== 'submitted'; + try { + const result = await withWalletLock(walletScope, () => executeSwap({ + chain: 'solana', + fromToken: { mint: input.mint, decimals: input.decimals, currency: input.currency }, + toToken: { mint: output.mint, decimals: output.decimals, currency: output.currency }, + amount, slippageBps, idempotencyKey: effectiveIdem, confirm: waitForConfirm, + }, ports)); + + if (result.idempotentReplay) { + return { chain, from_token: input.currency, to_token: output.currency, amount, signature: result.signature, status: 'submitted', explorer_url: `https://solscan.io/tx/${result.signature}`, idempotent_replay: true }; + } + const budgetInfo = budget.checkBudget(0, input.currency, chain); + return { chain, from_token: input.currency, to_token: output.currency, amount, out_amount: result.outAmount, signature: result.signature, status: result.status, explorer_url: `https://solscan.io/tx/${result.signature}`, remaining_daily: budgetInfo.remaining_daily }; + } catch (err) { + if (err instanceof ConsentRequiredError) { + return { requires_consent: true, consent_id: err.consentId, expires_at: err.expiresAt, ...(err.reason ? { reason: err.reason } : {}), message: err.message }; + } + throw err; + } + } + + // ============================================================================ + // V1 API: Vault Swap (Jupiter) — quote → build → validate → sign → submit + // ============================================================================ + + server.post<{ + Body: { + chain: Chain; + from_token: string; + to_token: string; + amount: number; + slippage_bps?: number; + from_decimals?: number; + to_decimals?: number; + confirm?: 'submitted' | 'confirmed'; + agent_name: string; + session_id?: string; + description?: string; + idempotency_key?: string; + service_id?: string; + service_signature?: string; + timestamp?: string; + nonce?: string; + }; + }>('/v1/vault/swap', async (request) => { + const { chain, from_token, to_token, amount, slippage_bps, from_decimals, to_decimals, agent_name, session_id, description, idempotency_key } = request.body; + + if (!chain || !from_token || !to_token || amount === undefined || !agent_name) { + throw new VaultError('INTERNAL_ERROR', 'chain, from_token, to_token, amount, and agent_name are required'); + } + if (chain !== 'solana') { + throw new VaultError('INVALID_CHAIN', 'Only solana swaps are supported'); + } + if (!storage.isUnlocked()) { + throw new VaultError('VAULT_LOCKED', 'Vault is locked. Please unlock first.'); + } + + const agentAuth = verifyLocalAgentRequest(request.body as Record, `sign:${chain}`); + if (!agentAuth.authorized && agentAuth.skipConsentFlow) { + throw new VaultError('SERVICE_SCOPE_VIOLATION', agentAuth.reason || 'Scope not permitted for this agent'); + } + + const slippageBps = slippage_bps ?? 50; + const input = resolveSwapToken(from_token, KNOWN_TOKENS, from_decimals); + const output = resolveSwapToken(to_token, KNOWN_TOKENS, to_decimals); + if (input.mint === output.mint) { + throw new VaultError('INVALID_CHAIN', 'from_token and to_token are the same'); + } + const walletScope = `crypto.wallet.${chain}`; + const owner = getWalletAddress(chain as Chain).address; + + // Phase 4 strangler: shared-runner swap path (OFF by default). + if (process.env.DCP_USE_SHARED_RUNNER === '1') { + return runSwapViaSharedRunner(request, { + chain, input, output, amount, slippageBps, agent_name, session_id, description, + idempotency_key, walletScope, owner, + }); + } + + type Outcome = + | { kind: 'replay'; signature: string } + | { kind: 'consent'; consent_id: string; expires_at: string; reason?: string; message: string } + | { kind: 'signed'; signature: string; status: string; out_amount?: string; remaining_daily: number; session_id?: string }; + + const outcome: Outcome = await withWalletLock(walletScope, async (): Promise => { + // Idempotency: a key replays ONLY the identical swap (same input token, + // amount, and output token). Reuse with a different intent must NOT replay + // an unrelated signature — reject and demand a fresh key. + if (idempotency_key) { + const prior = storage.getSpendByIdempotencyKey(idempotency_key); + if (prior) { + const sameIntent = idempotencyIntentMatches( + { operation: prior.operation, destination: prior.destination, currency: prior.currency, amount: prior.amount }, + { operation: 'swap', destination: output.mint, currency: input.currency, amount } + ); + if (!sameIntent) { + throw new VaultError( + 'IDEMPOTENCY_CONFLICT', + 'This idempotency_key was already used for a different swap. Use a fresh idempotency_key.' + ); + } + if (prior.tx_signature) return { kind: 'replay', signature: prior.tx_signature }; + } + } + const effectiveIdem = idempotency_key || crypto.randomUUID(); + + // Quote + build the swap via Jupiter (with the injected platform fee, if any). + const rawIn = BigInt(Math.round(amount * 10 ** input.decimals)).toString(); + const quote = await jupiterQuote(jupiterConfig, { + inputMint: input.mint, + outputMint: output.mint, + amountBaseUnits: rawIn, + slippageBps, + }); + + // Bind the third-party quote to the caller's intent (shared validator). A + // wrong/stale/swapped quote (different mints, tampered amount, wider slippage) + // must never get built or signed. + const quoteCheck = validateSwapQuote(quote, { + inputMint: input.mint, + outputMint: output.mint, + rawInAmount: rawIn, + slippageBps, + }); + if (!quoteCheck.ok) throw new VaultError('INVALID_CHAIN', quoteCheck.reason!); + + const swapTx = await jupiterBuildSwapTx(jupiterConfig, quote, owner); + + // Validate the third-party-built tx before signing (shared validator): the + // wallet must be the sole signer + fee-payer, and the tx must be a real + // Jupiter route touching only allow-listed programs. Guards against a + // compromised/spoofed Jupiter response slipping in a drain instruction. + const txCheck = validateSwapTransaction(swapTx, { + owner, + jupiterProgramIds: JUPITER_PROGRAM_IDS, + allowedPrograms: SWAP_ALLOWED_PROGRAMS, + }); + if (!txCheck.ok) throw new VaultError('INVALID_CHAIN', txCheck.reason!); + + // Budget + consent are charged on the INPUT being spent. Defer the spend + // until the swap actually broadcasts. + const result = await signWithGuards({ + body: request.body as Record, + chain, + unsignedTx: swapTx, + amount, + currency: input.currency, + agentName: agent_name, + sessionId: session_id, + description: description ?? `Swap ${amount} ${input.currency} → ${output.currency}`, + idempotencyKey: effectiveIdem, + deferSpend: true, + ownerApproved: isOwnerRequest(request), + }); + if (result.kind === 'consent') { + return { kind: 'consent', consent_id: result.consent_id, expires_at: result.expires_at, reason: result.reason, message: result.message }; + } + + const conn = getSolanaConnection(); + const waitForConfirm = (request.body.confirm ?? 'confirmed') !== 'submitted'; + let submitted: { signature: string; status: 'submitted' | 'confirmed' | 'finalized' | 'failed' }; + try { + submitted = await submitAndConfirm(conn, result.signed_tx, waitForConfirm); + } catch (err) { + storage.logAudit('EXECUTE', 'denied', { agentName: agent_name, scope: walletScope, operation: 'swap_submit', details: JSON.stringify({ from_token, to_token, error: (err as Error).message }) }); + throw new VaultError('INTERNAL_ERROR', `Failed to submit swap: ${(err as Error).message}`); + } + + if (submitted.status !== 'failed' && amount > 0 && !isOwnerRequest(request)) { + try { + storage.recordSpend(result.spend_session_id, amount, input.currency, chain, 'swap', 'committed', { + destination: output.mint, + idempotencyKey: effectiveIdem, + txSignature: submitted.signature, + }); + } catch (err) { + if (!(err instanceof VaultError && err.code === 'IDEMPOTENCY_CONFLICT')) throw err; + } + } + + const budgetInfo = budget.checkBudget(0, input.currency, chain); + storage.logAudit('EXECUTE', submitted.status === 'failed' ? 'denied' : 'success', { + agentName: agent_name, + scope: walletScope, + operation: 'swap', + details: JSON.stringify({ from_token: input.currency, to_token: output.currency, amount, signature: submitted.signature, status: submitted.status }), + }); + + return { kind: 'signed', signature: submitted.signature, status: submitted.status, out_amount: quote.outAmount, remaining_daily: budgetInfo.remaining_daily, session_id: result.session_id }; + }); + + if (outcome.kind === 'consent') { + return { requires_consent: true, consent_id: outcome.consent_id, expires_at: outcome.expires_at, ...(outcome.reason ? { reason: outcome.reason } : {}), message: outcome.message }; + } + if (outcome.kind === 'replay') { + return { chain, from_token: input.currency, to_token: output.currency, amount, signature: outcome.signature, status: 'submitted', explorer_url: `https://solscan.io/tx/${outcome.signature}`, idempotent_replay: true }; + } + return { + chain, + from_token: input.currency, + to_token: output.currency, + amount, + out_amount: outcome.out_amount, + signature: outcome.signature, + status: outcome.status, + explorer_url: `https://solscan.io/tx/${outcome.signature}`, + remaining_daily: outcome.remaining_daily, + session_id: outcome.session_id, }; }); @@ -5506,6 +6301,34 @@ async function buildServer(): Promise { return { logs, count: logs.length }; }); + // ============================================================================ + // Background retention: keep the local DB small (mobile-friendly). Conservative + // by default (90 days) and SAFE for budgeting — spend retention is floored well + // beyond the 24h budget window inside storage.pruneOldEvents(). + // ============================================================================ + const RETENTION_AUDIT_DAYS = parseInt(process.env.DCP_AUDIT_RETENTION_DAYS || '90', 10); + const RETENTION_SPEND_DAYS = parseInt(process.env.DCP_SPEND_RETENTION_DAYS || '90', 10); + const runPrune = () => { + try { + if (!storage.isUnlocked()) return; + const r = storage.pruneOldEvents({ + auditRetentionDays: RETENTION_AUDIT_DAYS, + spendRetentionDays: RETENTION_SPEND_DAYS, + }); + if (r.auditDeleted + r.spendDeleted > 0) { + console.log(`[retention] pruned ${r.auditDeleted} audit + ${r.spendDeleted} spend rows`); + } + } catch (err) { + console.error('[retention] prune failed:', err instanceof Error ? err.message : err); + } + }; + const pruneTimer = setInterval(runPrune, 24 * 60 * 60 * 1000); + // Don't keep the process alive just for the timer. + if (typeof pruneTimer.unref === 'function') pruneTimer.unref(); + server.addHook('onClose', async () => { + clearInterval(pruneTimer); + }); + return server; } @@ -5999,6 +6822,10 @@ function getScopeForRelayMethod(method: string, params: Record) return `write:${params.scope || ''}`; case 'vault_sign': return `sign:${params.chain || 'solana'}`; + case 'vault_transfer': + return `sign:${params.chain || 'solana'}`; + case 'vault_swap': + return `sign:${params.chain || 'solana'}`; case 'vault_sign_message': return `sign:${params.chain || 'solana'}`; case 'vault_sign_x402': @@ -6407,6 +7234,12 @@ async function handleRelayRequest( case 'vault_sign': url = '/v1/vault/sign'; break; + case 'vault_transfer': + url = '/v1/vault/transfer'; + break; + case 'vault_swap': + url = '/v1/vault/swap'; + break; case 'vault_sign_message': url = '/v1/vault/sign_message'; break; @@ -6718,6 +7551,10 @@ function mcpToolToRest( return { method: 'POST', url: '/v1/vault/write', body: { ...args } }; case 'vault_sign_tx': return { method: 'POST', url: '/v1/vault/sign', body: { ...args } }; + case 'vault_transfer': + return { method: 'POST', url: '/v1/vault/transfer', body: { ...args } }; + case 'vault_swap': + return { method: 'POST', url: '/v1/vault/swap', body: { ...args } }; case 'vault_sign_message': return { method: 'POST', url: '/v1/vault/sign_message', body: { ...args } }; case 'vault_sign_x402': diff --git a/packages/dcp-vault/src/server/lib/permission-scopes.ts b/packages/dcp-vault/src/server/lib/permission-scopes.ts new file mode 100644 index 0000000..ebeb52e --- /dev/null +++ b/packages/dcp-vault/src/server/lib/permission-scopes.ts @@ -0,0 +1,31 @@ +/** + * Permission scope normalization. Fixes old permission_scopes saved without an + * operation prefix (read:/write:/sign:) for backward compatibility with agents + * created before the prefix was introduced. + */ + +const SIGNING_CHAINS = ['solana']; + +export function normalizePermissionScope(scope: string): string { + // Already has a valid prefix - return as-is + if (scope.startsWith('read:') || scope.startsWith('write:') || scope.startsWith('sign:')) { + return scope; + } + + // Check if it's a chain name that should be sign: + const lowerScope = scope.toLowerCase(); + if (SIGNING_CHAINS.includes(lowerScope)) { + return `sign:${lowerScope}`; + } + + // Everything else gets read: prefix (identity, credentials, etc.) + return `read:${scope}`; +} + +export function normalizePermissionScopes(scopes: string[]): string[] { + if (!scopes || !Array.isArray(scopes)) return []; + + // Normalize each scope and remove duplicates + const normalized = scopes.map(normalizePermissionScope); + return [...new Set(normalized)]; +} diff --git a/packages/dcp-vault/src/server/lib/unlock-rate-limit.ts b/packages/dcp-vault/src/server/lib/unlock-rate-limit.ts new file mode 100644 index 0000000..0ed316b --- /dev/null +++ b/packages/dcp-vault/src/server/lib/unlock-rate-limit.ts @@ -0,0 +1,81 @@ +/** + * Unlock rate limiting — 5 failed passphrase attempts per minute → 5 minute lockout. + * Self-contained, in-memory (localhost only). Extracted from the server module. + */ + +import type { FastifyRequest } from 'fastify'; + +const UNLOCK_MAX_ATTEMPTS = 5; +const UNLOCK_WINDOW_MS = 60 * 1000; // 1 minute window +const UNLOCK_LOCKOUT_MS = 5 * 60 * 1000; // 5 minute lockout + +interface UnlockAttemptTracker { + attempts: number[]; + locked_until: number | null; +} + +const unlockAttempts: Map = new Map(); + +export function getUnlockRateLimitKey(request: FastifyRequest): string { + // For localhost, we track by source port as a basic identifier + // In production, this is always localhost so we use a single key + return 'local'; +} + +export function checkUnlockRateLimit(key: string): { allowed: boolean; retry_after_seconds?: number } { + const now = Date.now(); + let tracker = unlockAttempts.get(key); + + if (!tracker) { + tracker = { attempts: [], locked_until: null }; + unlockAttempts.set(key, tracker); + } + + // Check if currently locked out + if (tracker.locked_until && now < tracker.locked_until) { + const retry_after_seconds = Math.ceil((tracker.locked_until - now) / 1000); + return { allowed: false, retry_after_seconds }; + } + + // Clear lockout if expired + if (tracker.locked_until && now >= tracker.locked_until) { + tracker.locked_until = null; + tracker.attempts = []; + } + + // Clean up old attempts outside the window + const windowStart = now - UNLOCK_WINDOW_MS; + tracker.attempts = tracker.attempts.filter((t) => t > windowStart); + + return { allowed: true }; +} + +export function recordUnlockFailure(key: string): { locked: boolean; retry_after_seconds?: number } { + const now = Date.now(); + let tracker = unlockAttempts.get(key); + + if (!tracker) { + tracker = { attempts: [], locked_until: null }; + unlockAttempts.set(key, tracker); + } + + // Record this attempt + tracker.attempts.push(now); + + // Clean up old attempts outside the window + const windowStart = now - UNLOCK_WINDOW_MS; + tracker.attempts = tracker.attempts.filter((t) => t > windowStart); + + // Check if we've exceeded the limit + if (tracker.attempts.length >= UNLOCK_MAX_ATTEMPTS) { + tracker.locked_until = now + UNLOCK_LOCKOUT_MS; + const retry_after_seconds = Math.ceil(UNLOCK_LOCKOUT_MS / 1000); + return { locked: true, retry_after_seconds }; + } + + return { locked: false }; +} + +export function clearUnlockRateLimit(key: string): void { + unlockAttempts.delete(key); +} diff --git a/packages/dcp-vault/tests/devnet-transfer.test.ts b/packages/dcp-vault/tests/devnet-transfer.test.ts new file mode 100644 index 0000000..c489a4a --- /dev/null +++ b/packages/dcp-vault/tests/devnet-transfer.test.ts @@ -0,0 +1,256 @@ +/** + * DEVNET live integration test for /v1/vault/transfer (build + sign + submit). + * + * Gated behind RUN_DEVNET=1. Uses a pre-funded wallet whose secret is at + * /tmp/dcp-devnet-wallet.json (generated out-of-band, funded via faucet), and a + * Helius devnet RPC for submission. Proves the vault itself BUILDS, SIGNS, + * SUBMITS, and CONFIRMS real transactions — at volume — and that a repeated + * idempotency key never double-sends. + * + * RUN_DEVNET=1 npx vitest run tests/devnet-transfer.test.ts + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildServer } from '../src/index.js'; +import { + VaultStorage, + resetStorage, + generateRecoveryMnemonic, + deriveKeyFromMnemonic, + zeroize, + encryptWalletKey, +} from '@dcprotocol/core'; +import type { FastifyInstance } from 'fastify'; +import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey, Transaction, SystemProgram, sendAndConfirmTransaction } from '@solana/web3.js'; +import { createMint, getOrCreateAssociatedTokenAccount, mintTo, getAccount, getAssociatedTokenAddressSync } from '@solana/spl-token'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Provide a devnet RPC via DCP_DEVNET_RPC (e.g. a Helius devnet URL). Falls back +// to the public devnet endpoint (rate-limited) — never hardcode a keyed URL here. +const RPC = process.env.DCP_DEVNET_RPC || 'https://api.devnet.solana.com'; +const WALLET_FILE = '/tmp/dcp-devnet-wallet.json'; +const N_TX = 20; +const AMOUNT_SOL = 0.00005; // under the 0.0001 SOL approval threshold → auto-approves +const AMOUNT_LAMPORTS = 50_000; + +const runDevnet = process.env.RUN_DEVNET === '1' && fs.existsSync(WALLET_FILE); + +describe.skipIf(!runDevnet)('DEVNET /v1/vault/transfer (live, 20 tx)', () => { + let server: FastifyInstance; + let walletAddress: string; + let conn: Connection; + let funder: Keypair; + const passphrase = 'devnet-test-passphrase'; + + // Pre-create + rent-exempt a destination account (a brand-new account can't + // receive sub-rent-exempt amounts). Uses the funded wallet's own keypair. + async function makeFundedDest(): Promise { + const dest = Keypair.generate().publicKey; + const tx = new Transaction().add( + SystemProgram.transfer({ fromPubkey: funder.publicKey, toPubkey: dest, lamports: 2_000_000 }) + ); + await sendAndConfirmTransaction(conn, tx, [funder], { commitment: 'confirmed' }); + return dest.toBase58(); + } + + beforeAll(async () => { + process.env.DCP_SOLANA_RPC_URL = RPC; + resetStorage(); + const dir = path.join(os.tmpdir(), `dcp-devnet-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.VAULT_DIR = dir; + + // Load the pre-funded devnet wallet and import it as the vault's wallet. + const saved = JSON.parse(fs.readFileSync(WALLET_FILE, 'utf8')) as { address: string; secretKey: number[] }; + const kp = Keypair.fromSecretKey(Uint8Array.from(saved.secretKey)); + funder = kp; + walletAddress = kp.publicKey.toBase58(); + + const storage = new VaultStorage(dir); + storage.initializeSchema(); + const masterKey = deriveKeyFromMnemonic(generateRecoveryMnemonic()); + try { + await storage.storeMasterKeyWithPassphrase(masterKey, passphrase); + const seed = Buffer.from(kp.secretKey.slice(0, 32)); + const { encrypted } = encryptWalletKey( + { chain: 'solana', public_address: walletAddress, key_type: 'ed25519', private_key: seed }, + masterKey + ); + storage.createRecord({ + scope: 'crypto.wallet.solana', + item_type: 'WALLET_KEY', + sensitivity: 'critical', + data: encrypted, + chain: 'solana', + public_address: walletAddress, + }); + } finally { + zeroize(masterKey); + } + storage.close(); + + server = await buildServer(); + await server.ready(); + await server.inject({ method: 'POST', url: '/v1/vault/unlock', payload: { passphrase } }); + + conn = new Connection(RPC, 'confirmed'); + }, 120_000); + + afterAll(async () => { + if (server) await server.close(); + }); + + it(`fires ${N_TX} transfers CONCURRENTLY (load + concurrency) and processes all safely`, async () => { + const startBal = await conn.getBalance(new PublicKey(walletAddress)); + console.log(`[devnet] wallet ${walletAddress} balance: ${startBal / LAMPORTS_PER_SOL} SOL`); + + const dest = await makeFundedDest(); + const destStart = await conn.getBalance(new PublicKey(dest)); + + // IDENTICAL amount + recipient on every tx — the exact case that would + // collide on one signature with a cached blockhash. They stay distinct only + // because each carries a unique memo nonce (the idempotency key). + const expectedReceived = N_TX * AMOUNT_LAMPORTS; + const reqs = Array.from({ length: N_TX }, (_, i) => ({ + chain: 'solana', + to: dest, + amount: AMOUNT_SOL, + currency: 'SOL', + agent_name: 'devnet-test', + idempotency_key: `load-${Date.now()}-${i}`, + })); + + // FIRE ALL AT ONCE. + const t0 = Date.now(); + const results = await Promise.all( + reqs.map((payload) => server.inject({ method: 'POST', url: '/v1/vault/transfer', payload })) + ); + const totalMs = Date.now() - t0; + + const sigs: string[] = []; + let ok = 0; + let confirmed = 0; + for (let i = 0; i < results.length; i++) { + const res = results[i]; + if (res.statusCode !== 200) { + console.log(`[devnet] tx ${i} FAILED ${res.statusCode}:`, res.body.slice(0, 200)); + continue; + } + ok++; + const body = res.json(); + sigs.push(body.signature); + if (body.status === 'confirmed' || body.status === 'finalized') confirmed++; + } + + console.log(`[devnet] ${N_TX} concurrent tx in ${totalMs}ms — ok ${ok}/${N_TX}, confirmed ${confirmed}/${N_TX}, unique sigs ${new Set(sigs).size}, throughput ${(N_TX / (totalMs / 1000)).toFixed(2)} tx/s`); + + // Concurrency correctness: every request succeeded, every tx is unique (no + // budget race, no double-spend, no blockhash collision). + expect(ok).toBe(N_TX); + expect(new Set(sigs).size).toBe(N_TX); + + // On-chain truth: destination received EXACTLY the sum — not more (double + // spend) and not less (dropped/collided). + await new Promise((r) => setTimeout(r, 6000)); + const received = (await conn.getBalance(new PublicKey(dest))) - destStart; + console.log(`[devnet] destination received ${received} lamports (expected ${expectedReceived})`); + expect(received).toBe(expectedReceived); + + // Budget ledger must reflect exactly N_TX spends (no over/under count). + const dailyRes = await server.inject({ method: 'GET', url: '/budget/check?amount=0¤cy=SOL&chain=solana' }); + console.log('[devnet] budget after load:', dailyRes.body); + }, 180_000); + + it('measures latency: confirmed vs fast-submit (with blockhash cache)', async () => { + const dest = await makeFundedDest(); + let i = 0; + const send = async (confirm: 'confirmed' | 'submitted') => { + const t0 = Date.now(); + const res = await server.inject({ + method: 'POST', + url: '/v1/vault/transfer', + payload: { + chain: 'solana', to: dest, amount: (AMOUNT_LAMPORTS + i++) / LAMPORTS_PER_SOL, + currency: 'SOL', agent_name: 'devnet-test', confirm, idempotency_key: `lat-${Date.now()}-${i}`, + }, + }); + expect(res.statusCode).toBe(200); + return Date.now() - t0; + }; + + await send('confirmed'); // warm up the blockhash cache + const conf: number[] = []; + for (let k = 0; k < 3; k++) conf.push(await send('confirmed')); + const fast: number[] = []; + for (let k = 0; k < 3; k++) fast.push(await send('submitted')); + + const avg = (a: number[]) => Math.round(a.reduce((x, y) => x + y, 0) / a.length); + console.log(`[devnet] confirm='confirmed' avg ${avg(conf)}ms ${JSON.stringify(conf)}`); + console.log(`[devnet] confirm='submitted' (fast) avg ${avg(fast)}ms ${JSON.stringify(fast)}`); + expect(avg(fast)).toBeLessThan(avg(conf)); // fast path is faster + expect(avg(fast)).toBeLessThan(1100); // sub-confirmed, broadcast-only + }, 120_000); + + it('does not double-send for a repeated idempotency key', async () => { + const dest = await makeFundedDest(); + const destStart = await conn.getBalance(new PublicKey(dest)); + const key = `idem-${Date.now()}`; + const payload = { + chain: 'solana', to: dest, amount: AMOUNT_SOL, currency: 'SOL', + agent_name: 'devnet-test', idempotency_key: key, + }; + const first = (await server.inject({ method: 'POST', url: '/v1/vault/transfer', payload })).json(); + const second = (await server.inject({ method: 'POST', url: '/v1/vault/transfer', payload })).json(); + + expect(first.signature).toBeTruthy(); + expect(second.signature).toBe(first.signature); + expect(second.idempotent_replay).toBe(true); + + await new Promise((r) => setTimeout(r, 5000)); + const received = (await conn.getBalance(new PublicKey(dest))) - destStart; + expect(received).toBe(AMOUNT_LAMPORTS); // received exactly once + }, 120_000); + + it('sends an SPL token to a BRAND-NEW wallet — auto-creates its token account', async () => { + // Mint a fresh devnet SPL token (6 decimals) and fund the vault wallet with it. + const mint = await createMint(conn, funder, funder.publicKey, null, 6); + const senderAta = await getOrCreateAssociatedTokenAccount(conn, funder, mint, funder.publicKey); + await mintTo(conn, funder, mint, senderAta.address, funder, 1_000_000); // 1.0 token + console.log(`[devnet] minted token ${mint.toBase58()} → vault wallet`); + + // Recipient is a brand-new wallet that has NEVER held this token (no ATA). + const newWallet = Keypair.generate().publicKey; + const recipientAta = getAssociatedTokenAddressSync(mint, newWallet); + const before = await conn.getAccountInfo(recipientAta); + expect(before).toBeNull(); // confirm the token account does not exist yet + + const res = await server.inject({ + method: 'POST', + url: '/v1/vault/transfer', + payload: { + chain: 'solana', + to: newWallet.toBase58(), + amount: 0.00005, // 50 base units, under USDC approval threshold → auto-approves + currency: 'USDC', + mint: mint.toBase58(), + decimals: 6, + agent_name: 'devnet-test', + idempotency_key: `spl-${Date.now()}`, + }, + }); + + if (res.statusCode !== 200) console.log('[devnet] SPL transfer FAILED:', res.body.slice(0, 300)); + expect(res.statusCode).toBe(200); + const body = res.json(); + console.log(`[devnet] SPL transfer to new wallet: ${body.status} ${body.signature?.slice(0, 16)}…`); + expect(body.signature).toBeTruthy(); + expect(['confirmed', 'finalized', 'submitted']).toContain(body.status); + + // The recipient's token account was created AND received the tokens. + await new Promise((r) => setTimeout(r, 5000)); + const acct = await getAccount(conn, recipientAta); + console.log(`[devnet] new wallet token account created, balance: ${acct.amount} base units`); + expect(acct.amount).toBe(50n); // 0.00005 * 1e6 + }, 150_000); +}); diff --git a/packages/dcp-vault/tests/golden-harness.test.ts b/packages/dcp-vault/tests/golden-harness.test.ts new file mode 100644 index 0000000..0666fc2 --- /dev/null +++ b/packages/dcp-vault/tests/golden-harness.test.ts @@ -0,0 +1,246 @@ +/** + * GOLDEN HARNESS — Phase 0 of the wallet-consolidation plan. + * + * Captures the CURRENT vault's decision behavior on the transfer/swap money path + * as a reference oracle, BEFORE Phase 4 rewires those routes onto the shared + * wallet-core runner. Phase 4 runs this same harness against the rewired path + * (behind a feature flag) and asserts byte-for-byte identical records — so any + * behavior drift in the money path is caught. + * + * Scope = the DECISION logic that short-circuits before on-chain submit, which is + * where the rewrite risk concentrates and which is fully deterministic offline: + * - idempotency replay + conflict (transfer + swap) + * - swap quote/program validation rejections + * - budget enforcement (per-tx limit) + consent gating (approval threshold) + * - spend-ledger integrity: a rejected op must NEVER add a ledger row + * + * The on-chain behaviors (real SOL/SPL submit, ATA creation, rent edge, 20-way + * concurrency / no-overspend, failed-submit-no-debit) are covered by the devnet + * oracle in `devnet-transfer.test.ts` (RUN_DEVNET=1) and re-run in Phase 4. + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { buildServer } from '../src/index.js'; +import { + VaultStorage, + resetStorage, + generateRecoveryMnemonic, + deriveKeyFromMnemonic, + zeroize, + createWallet, + getStorage, +} from '@dcprotocol/core'; +import type { FastifyInstance } from 'fastify'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; + +const WSOL = 'So11111111111111111111111111111111111111112'; +const OUT_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +const JUP_V6 = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const SYSTEM = '11111111111111111111111111111111'; + +// Budget tuned so the gating decisions are deterministic. The daily cap is roomy +// (the seeded prior spends count against it — a tight daily cap would make the +// consent scenario trip BUDGET_EXCEEDED_DAILY instead). The per-tx limit + approval +// threshold are what drive the gating scenarios: +// per-tx limit 0.0002 SOL · approval threshold 0.00005 SOL · daily 0.01 SOL +const BUDGET = { + daily_budget: { SOL: 0.01 }, + tx_limit: { SOL: 0.0002 }, + approval_threshold: { SOL: 0.00005 }, +}; + +/** Normalized behavior record — the thing Phase 4 diffs old-vs-new against. */ +interface Record { + statusCode: number; + errorCode?: string; + idempotentReplay?: boolean; + requiresConsent?: boolean; +} + +function classify(res: { statusCode: number; json: () => any }): Record { + const body = res.json(); + if (res.statusCode === 200) { + return { + statusCode: 200, + idempotentReplay: body.idempotent_replay === true || undefined, + requiresConsent: body.requires_consent === true || undefined, + }; + } + return { statusCode: res.statusCode, errorCode: body?.error?.code }; +} + +describe('Golden harness — money-path decision oracle', () => { + let server: FastifyInstance; + let vaultDir: string; + let storage: VaultStorage; + let walletAddress: string; + let seedSessionId: string; + const passphrase = 'golden-passphrase-123'; + + beforeAll(async () => { + resetStorage(); + vaultDir = path.join(os.tmpdir(), `dcp-golden-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.VAULT_DIR = vaultDir; + + storage = new VaultStorage(vaultDir); + storage.initializeSchema(); + const masterKey = deriveKeyFromMnemonic(generateRecoveryMnemonic()); + try { + await storage.storeMasterKeyWithPassphrase(masterKey, passphrase); + const { encrypted, info } = createWallet('solana', masterKey); + storage.createRecord({ + scope: 'crypto.wallet.solana', + item_type: 'WALLET_KEY', + sensitivity: 'critical', + data: encrypted, + chain: 'solana', + public_address: info.public_address, + }); + walletAddress = info.public_address; + // A session is needed only to satisfy the spend_events FK when SEEDING prior + // spends. It is under a DIFFERENT agent name than the requests below, so it + // never auto-approves them — consent/budget gating still applies to 'gh-agent'. + const seedSession = storage.createSession('gh-seed-agent', ['crypto.wallet.solana'], 'session', new Date(Date.now() + 60 * 60 * 1000)); + seedSessionId = seedSession.id; + } finally { + zeroize(masterKey); + } + storage.close(); + + fs.writeFileSync(path.join(vaultDir, 'config.json'), JSON.stringify(BUDGET, null, 2)); + + server = await buildServer(); + await server.ready(); + await server.inject({ method: 'POST', url: '/v1/vault/unlock', payload: { passphrase } }); + }); + + afterAll(async () => { + await server.close(); + resetStorage(); + if (vaultDir && fs.existsSync(vaultDir)) fs.rmSync(vaultDir, { recursive: true, force: true }); + delete process.env.VAULT_DIR; + }); + + afterEach(() => vi.unstubAllGlobals()); + + function seedSpend(op: 'transfer' | 'swap', key: string, destination: string, amount: number, currency: string, signature: string) { + getStorage().recordSpend(seedSessionId, amount, currency, 'solana', op, 'committed', { + destination, + idempotencyKey: key, + txSignature: signature, + }); + } + + function ledgerCount(): number { + return (getStorage() as any).db.prepare('SELECT COUNT(*) AS n FROM spend_events').get().n as number; + } + + /** Stub Jupiter: by default a VALID quote (echoes the request) + a valid swap tx. */ + function stubJupiter(opts: { quoteOverride?: Record; swapPrograms?: string[] } = {}) { + const swapTx = (() => { + const programs = opts.swapPrograms ?? [JUP_V6, SYSTEM]; + const tx = new Transaction({ feePayer: new PublicKey(walletAddress), recentBlockhash: 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi' }); + for (const pid of programs) tx.add({ keys: [], programId: new PublicKey(pid), data: Buffer.from([]) }); + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); + })(); + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + const u = String(url); + if (u.includes('/quote')) { + const params = new URL(u.replace(/^[^?]*/, 'https://x' )).searchParams; + const quote = opts.quoteOverride ?? { + inputMint: params.get('inputMint'), + outputMint: params.get('outputMint'), + inAmount: params.get('amount'), + outAmount: '1', + slippageBps: Number(params.get('slippageBps')), + }; + return { ok: true, status: 200, json: async () => quote }; + } + if (u.includes('/swap')) return { ok: true, status: 200, json: async () => ({ swapTransaction: swapTx }) }; + return { ok: true, status: 200, json: async () => ({}) }; + })); + } + + const swap = (amount: number, key: string) => ({ + chain: 'solana', from_token: 'SOL', to_token: OUT_MINT, to_decimals: 6, + amount, slippage_bps: 50, agent_name: 'gh-agent', idempotency_key: key, + }); + + // ── Transfer idempotency (pre-network) ────────────────────────────────────── + it('GOLDEN transfer/idempotent-replay → replays prior signature', async () => { + const dest = Keypair.generate().publicKey.toBase58(); + seedSpend('transfer', 'g-t-replay', dest, 0.00001, 'SOL', 'SIG_T_REPLAY'); + const res = await server.inject({ method: 'POST', url: '/v1/vault/transfer', + payload: { chain: 'solana', to: dest, amount: 0.00001, currency: 'SOL', agent_name: 'gh-agent', idempotency_key: 'g-t-replay' } }); + expect(classify(res)).toEqual({ statusCode: 200, idempotentReplay: true }); + expect(res.json().signature).toBe('SIG_T_REPLAY'); + }); + + it('GOLDEN transfer/idempotency-conflict → rejects key reused for a different transfer', async () => { + seedSpend('transfer', 'g-t-conflict', Keypair.generate().publicKey.toBase58(), 0.00001, 'SOL', 'SIG_T_C'); + const before = ledgerCount(); + const res = await server.inject({ method: 'POST', url: '/v1/vault/transfer', + payload: { chain: 'solana', to: Keypair.generate().publicKey.toBase58(), amount: 0.00001, currency: 'SOL', agent_name: 'gh-agent', idempotency_key: 'g-t-conflict' } }); + expect(classify(res)).toEqual({ statusCode: 400, errorCode: 'IDEMPOTENCY_CONFLICT' }); + expect(ledgerCount()).toBe(before); // rejected → no ledger row added + }); + + // ── Swap idempotency ──────────────────────────────────────────────────────── + it('GOLDEN swap/idempotent-replay → replays prior signature', async () => { + seedSpend('swap', 'g-s-replay', OUT_MINT, 0.0001, 'SOL', 'SIG_S_REPLAY'); + stubJupiter(); + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swap(0.0001, 'g-s-replay') }); + expect(classify(res)).toEqual({ statusCode: 200, idempotentReplay: true }); + expect(res.json().signature).toBe('SIG_S_REPLAY'); + }); + + it('GOLDEN swap/idempotency-conflict → rejects key reused for a different swap', async () => { + seedSpend('swap', 'g-s-conflict', WSOL, 0.0001, 'SOL', 'SIG_S_C'); // different output mint + stubJupiter(); + const before = ledgerCount(); + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swap(0.0001, 'g-s-conflict') }); + expect(classify(res)).toEqual({ statusCode: 400, errorCode: 'IDEMPOTENCY_CONFLICT' }); + expect(ledgerCount()).toBe(before); + }); + + // ── Swap validation rejections ────────────────────────────────────────────── + it('GOLDEN swap/quote-mismatch → INVALID_CHAIN', async () => { + stubJupiter({ quoteOverride: { inputMint: WSOL, outputMint: SYSTEM, inAmount: '100000', outAmount: '1', slippageBps: 50 } }); + const before = ledgerCount(); + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swap(0.0001, 'g-s-qm') }); + expect(classify(res)).toEqual({ statusCode: 400, errorCode: 'INVALID_CHAIN' }); + expect(ledgerCount()).toBe(before); + }); + + it('GOLDEN swap/unexpected-program → INVALID_CHAIN', async () => { + stubJupiter({ swapPrograms: [JUP_V6, Keypair.generate().publicKey.toBase58()] }); + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swap(0.0001, 'g-s-up') }); + expect(classify(res)).toEqual({ statusCode: 400, errorCode: 'INVALID_CHAIN' }); + }); + + it('GOLDEN swap/not-jupiter-route → INVALID_CHAIN', async () => { + stubJupiter({ swapPrograms: [SYSTEM] }); + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swap(0.0001, 'g-s-nj') }); + expect(classify(res)).toEqual({ statusCode: 400, errorCode: 'INVALID_CHAIN' }); + }); + + // ── Budget + consent gating (valid quote/tx, so we reach signWithGuards) ───── + it('GOLDEN swap/over-per-tx-limit → BUDGET_EXCEEDED_TX, no ledger row', async () => { + stubJupiter(); + const before = ledgerCount(); + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swap(0.0003, 'g-s-budget') }); // 0.0003 > 0.0002 tx_limit + expect(classify(res)).toEqual({ statusCode: 400, errorCode: 'BUDGET_EXCEEDED_TX' }); + expect(ledgerCount()).toBe(before); + }); + + it('GOLDEN swap/over-approval-threshold (no session) → requires consent, no ledger row', async () => { + stubJupiter(); + const before = ledgerCount(); + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swap(0.0001, 'g-s-consent') }); // > 0.00005 threshold, <= limits + expect(classify(res)).toEqual({ statusCode: 200, requiresConsent: true }); + expect(ledgerCount()).toBe(before); + }); +}); diff --git a/packages/dcp-vault/tests/mainnet-verify.test.ts b/packages/dcp-vault/tests/mainnet-verify.test.ts new file mode 100644 index 0000000..597779d --- /dev/null +++ b/packages/dcp-vault/tests/mainnet-verify.test.ts @@ -0,0 +1,126 @@ +/** + * MAINNET on-chain proof for the Phase 4 shared-runner path. This is the gate that + * justifies flipping DCP_USE_SHARED_RUNNER to default: it runs a REAL transfer and a + * REAL Jupiter swap through the new path (executeTransfer/executeSwap via the vault + * adapters) against mainnet, with real funds, and confirms them on-chain. + * + * Gated — only runs when BOTH are present: + * RUN_MAINNET=1 and a funded wallet at /tmp/dcp-devnet-wallet.json + * (format: { address, secretKey: number[] }) + * + * Usage once the wallet has ~0.1 SOL on mainnet: + * RUN_MAINNET=1 DCP_USE_SHARED_RUNNER=1 \ + * npx vitest run tests/mainnet-verify.test.ts + * + * Amounts are intentionally tiny (transfer 0.001 SOL to a recoverable burner we save + * to /tmp/dcp-mainnet-recipient.json; swap 0.003 SOL → USDC, which only loses fees + + * slippage). Total cost ≈ a few cents of SOL. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildServer } from '../src/index.js'; +import { + VaultStorage, resetStorage, generateRecoveryMnemonic, deriveKeyFromMnemonic, + zeroize, encryptWalletKey, +} from '@dcprotocol/core'; +import type { FastifyInstance } from 'fastify'; +import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const RPC = process.env.DCP_MAINNET_RPC || 'https://api.mainnet-beta.solana.com'; +const WALLET_FILE = '/tmp/dcp-devnet-wallet.json'; +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +const runMainnet = process.env.RUN_MAINNET === '1' && fs.existsSync(WALLET_FILE); + +describe.skipIf(!runMainnet)('MAINNET shared-runner proof (real funds)', () => { + let server: FastifyInstance; + let conn: Connection; + let walletAddress: string; + const passphrase = 'mainnet-verify-passphrase'; + + beforeAll(async () => { + // Force the shared-runner path + mainnet RPC for this run. + process.env.DCP_USE_SHARED_RUNNER = '1'; + process.env.DCP_SOLANA_RPC_URL = RPC; + resetStorage(); + const dir = path.join(os.tmpdir(), `dcp-mainnet-${Date.now()}`); + process.env.VAULT_DIR = dir; + + const saved = JSON.parse(fs.readFileSync(WALLET_FILE, 'utf8')) as { address: string; secretKey: number[] }; + const kp = Keypair.fromSecretKey(Uint8Array.from(saved.secretKey)); + walletAddress = kp.publicKey.toBase58(); + + const storage = new VaultStorage(dir); + storage.initializeSchema(); + const masterKey = deriveKeyFromMnemonic(generateRecoveryMnemonic()); + try { + await storage.storeMasterKeyWithPassphrase(masterKey, passphrase); + const seed = Buffer.from(kp.secretKey.slice(0, 32)); + const { encrypted } = encryptWalletKey( + { chain: 'solana', public_address: walletAddress, key_type: 'ed25519', private_key: seed }, + masterKey + ); + storage.createRecord({ scope: 'crypto.wallet.solana', item_type: 'WALLET_KEY', sensitivity: 'critical', data: encrypted, chain: 'solana', public_address: walletAddress }); + } finally { + zeroize(masterKey); + } + storage.close(); + + server = await buildServer(); + await server.ready(); + await server.inject({ method: 'POST', url: '/v1/vault/unlock', payload: { passphrase } }); + conn = new Connection(RPC, 'confirmed'); + }, 60_000); + + afterAll(async () => { if (server) await server.close(); }); + + it('reads a non-zero balance (wallet is funded)', async () => { + const bal = await conn.getBalance(new PublicKey(walletAddress)); + console.log(`[mainnet] ${walletAddress} balance: ${bal / LAMPORTS_PER_SOL} SOL`); + expect(bal).toBeGreaterThan(0.05 * LAMPORTS_PER_SOL); // need a little headroom + }); + + it('real SOL transfer through the shared runner — confirmed on-chain', async () => { + // Recoverable burner — saved so funds aren't lost. + const recipient = Keypair.generate(); + fs.writeFileSync('/tmp/dcp-mainnet-recipient.json', JSON.stringify({ address: recipient.publicKey.toBase58(), secretKey: Array.from(recipient.secretKey) })); + const before = await conn.getBalance(recipient.publicKey); + + const res = await server.inject({ + method: 'POST', url: '/v1/vault/transfer', + payload: { chain: 'solana', to: recipient.publicKey.toBase58(), amount: 0.001, currency: 'SOL', agent_name: 'mainnet-verify', idempotency_key: `mn-xfer-${Date.now()}` }, + }); + if (res.statusCode !== 200) console.log('[mainnet] transfer failed:', res.body.slice(0, 300)); + expect(res.statusCode).toBe(200); + const body = res.json(); + console.log(`[mainnet] transfer ${body.status} ${body.signature}`); + expect(body.signature).toBeTruthy(); + + await new Promise((r) => setTimeout(r, 8000)); + const after = await conn.getBalance(recipient.publicKey); + expect(after - before).toBe(Math.round(0.001 * LAMPORTS_PER_SOL)); + }, 90_000); + + it('real Jupiter swap (SOL → USDC) through the shared runner — confirmed on-chain', async () => { + const res = await server.inject({ + method: 'POST', url: '/v1/vault/swap', + payload: { chain: 'solana', from_token: 'SOL', to_token: 'USDC', amount: 0.003, slippage_bps: 100, agent_name: 'mainnet-verify', idempotency_key: `mn-swap-${Date.now()}` }, + }); + if (res.statusCode !== 200) console.log('[mainnet] swap failed:', res.body.slice(0, 400)); + expect(res.statusCode).toBe(200); + const body = res.json(); + console.log(`[mainnet] swap ${body.status} out=${body.out_amount} ${body.signature}`); + expect(body.signature).toBeTruthy(); + expect(['confirmed', 'finalized', 'submitted']).toContain(body.status); + + // Confirm the USDC ATA now holds a balance. + await new Promise((r) => setTimeout(r, 8000)); + const ata = await conn.getParsedTokenAccountsByOwner(new PublicKey(walletAddress), { mint: new PublicKey(USDC) }); + const usdc = ata.value[0]?.account.data.parsed.info.tokenAmount.uiAmount ?? 0; + console.log(`[mainnet] USDC balance after swap: ${usdc}`); + expect(usdc).toBeGreaterThan(0); + }, 120_000); +}); diff --git a/packages/dcp-vault/tests/wallet-tx.test.ts b/packages/dcp-vault/tests/wallet-tx.test.ts new file mode 100644 index 0000000..3f3381f --- /dev/null +++ b/packages/dcp-vault/tests/wallet-tx.test.ts @@ -0,0 +1,188 @@ +/** + * Wallet transfer/swap route guards (no network): + * - idempotency_key reused for a DIFFERENT intent is rejected (not blindly replayed) + * - idempotency_key reused for the SAME intent replays the prior signature + * - swap quote must match the requested mints/amount/slippage + * - swap tx must be a real Jupiter route invoking only allow-listed programs + * + * The rejection/replay paths all short-circuit BEFORE any signing or submit, so these + * run fully offline (Jupiter HTTP is stubbed for the swap-validation cases). + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { buildServer } from '../src/index.js'; +import { + VaultStorage, + resetStorage, + generateRecoveryMnemonic, + deriveKeyFromMnemonic, + zeroize, + createWallet, + getStorage, +} from '@dcprotocol/core'; +import type { FastifyInstance } from 'fastify'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; + +const WSOL = 'So11111111111111111111111111111111111111112'; +const OUT_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; // USDC mainnet (used as an explicit output mint) +const JUP_V6 = 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4'; +const SYSTEM = '11111111111111111111111111111111'; + +describe('Wallet transfer/swap guards', () => { + let server: FastifyInstance; + let testVaultDir: string; + let storage: VaultStorage; + let walletAddress: string; + let sessionId: string; + const passphrase = 'test-passphrase-123'; + + beforeAll(async () => { + resetStorage(); + testVaultDir = path.join(os.tmpdir(), `dcp-wtx-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.VAULT_DIR = testVaultDir; + + storage = new VaultStorage(testVaultDir); + storage.initializeSchema(); + const masterKey = deriveKeyFromMnemonic(generateRecoveryMnemonic()); + try { + await storage.storeMasterKeyWithPassphrase(masterKey, passphrase); + const { encrypted, info } = createWallet('solana', masterKey); + storage.createRecord({ + scope: 'crypto.wallet.solana', + item_type: 'WALLET_KEY', + sensitivity: 'critical', + data: encrypted, + chain: 'solana', + public_address: info.public_address, + }); + walletAddress = info.public_address; + const session = storage.createSession( + 'wtx-test-agent', + ['crypto.wallet.solana'], + 'session', + new Date(Date.now() + 60 * 60 * 1000) + ); + sessionId = session.id; + } finally { + zeroize(masterKey); + } + storage.close(); + + server = await buildServer(); + await server.ready(); + await server.inject({ method: 'POST', url: '/v1/vault/unlock', payload: { passphrase } }); + }); + + afterAll(async () => { + await server.close(); + resetStorage(); + if (testVaultDir && fs.existsSync(testVaultDir)) fs.rmSync(testVaultDir, { recursive: true, force: true }); + delete process.env.VAULT_DIR; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Seed a committed transfer spend that a later request can collide with. + function seedTransferSpend(key: string, destination: string, amount: number, signature: string) { + getStorage().recordSpend(sessionId, amount, 'SOL', 'solana', 'transfer', 'committed', { + destination, + idempotencyKey: key, + txSignature: signature, + }); + } + + describe('transfer idempotency', () => { + it('rejects the same key reused for a DIFFERENT transfer (no blind replay)', async () => { + const key = `idem-conflict-${Date.now()}`; + const destA = Keypair.generate().publicKey.toBase58(); + const destB = Keypair.generate().publicKey.toBase58(); + seedTransferSpend(key, destA, 0.5, 'SIGAAA'); + + const res = await server.inject({ + method: 'POST', + url: '/v1/vault/transfer', + payload: { chain: 'solana', to: destB, amount: 0.5, currency: 'SOL', agent_name: 'wtx-test-agent', session_id: sessionId, idempotency_key: key }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error.code).toBe('IDEMPOTENCY_CONFLICT'); + }); + + it('replays the prior signature for the SAME transfer intent', async () => { + const key = `idem-replay-${Date.now()}`; + const dest = Keypair.generate().publicKey.toBase58(); + seedTransferSpend(key, dest, 0.5, 'SIGBBB'); + + const res = await server.inject({ + method: 'POST', + url: '/v1/vault/transfer', + payload: { chain: 'solana', to: dest, amount: 0.5, currency: 'SOL', agent_name: 'wtx-test-agent', session_id: sessionId, idempotency_key: key }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.idempotent_replay).toBe(true); + expect(body.signature).toBe('SIGBBB'); + }); + }); + + describe('swap validation', () => { + // Stub Jupiter's quote + swap-build HTTP. `quote` is returned for /quote, and a + // base64 tx built from `swapPrograms` is returned for /swap. + function stubJupiter(quote: Record, swapPrograms?: string[]) { + const swapTransaction = swapPrograms + ? (() => { + const tx = new Transaction({ feePayer: new PublicKey(walletAddress), recentBlockhash: 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi' }); + for (const pid of swapPrograms) tx.add({ keys: [], programId: new PublicKey(pid), data: Buffer.from([]) }); + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); + })() + : undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + const u = String(url); + if (u.includes('/quote')) return { ok: true, status: 200, json: async () => quote }; + if (u.includes('/swap')) return { ok: true, status: 200, json: async () => ({ swapTransaction }) }; + return { ok: true, status: 200, json: async () => ({}) }; + }) + ); + } + + const rawIn = String(Math.round(0.001 * 1e9)); // 0.001 SOL → lamports + const swapPayload = { + chain: 'solana', from_token: 'SOL', to_token: OUT_MINT, to_decimals: 6, + amount: 0.001, slippage_bps: 50, agent_name: 'wtx-test-agent', session_id: sessionId, + }; + + it('rejects a quote whose output mint does not match the request', async () => { + const wrongMint = Keypair.generate().publicKey.toBase58(); + stubJupiter({ inputMint: WSOL, outputMint: wrongMint, inAmount: rawIn, outAmount: '500', slippageBps: 50 }); + + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swapPayload }); + expect(res.statusCode).toBe(400); + expect(res.json().error.message).toMatch(/does not match the requested tokens/i); + }); + + it('rejects a swap tx that invokes an unexpected program', async () => { + const rogue = Keypair.generate().publicKey.toBase58(); + stubJupiter({ inputMint: WSOL, outputMint: OUT_MINT, inAmount: rawIn, outAmount: '500', slippageBps: 50 }, [JUP_V6, rogue]); + + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swapPayload }); + expect(res.statusCode).toBe(400); + expect(res.json().error.message).toMatch(/unexpected program/i); + }); + + it('rejects a tx that is not a recognized Jupiter route', async () => { + stubJupiter({ inputMint: WSOL, outputMint: OUT_MINT, inAmount: rawIn, outAmount: '500', slippageBps: 50 }, [SYSTEM]); + + const res = await server.inject({ method: 'POST', url: '/v1/vault/swap', payload: swapPayload }); + expect(res.statusCode).toBe(400); + expect(res.json().error.message).toMatch(/not a recognized Jupiter route/i); + }); + }); +}); diff --git a/packages/dcp-wallet-core/README.md b/packages/dcp-wallet-core/README.md new file mode 100644 index 0000000..f5c32f7 --- /dev/null +++ b/packages/dcp-wallet-core/README.md @@ -0,0 +1,56 @@ +# @dcprotocol/wallet-core + +The pure **wallet brain** for DCP — transaction building, decoding, validation, the +execution runner, and the shared protocol error type. **Zero native dependencies, no +storage, no key custody, no UI** — so it imports identically in Node (the vault +server, agents), the browser, and React Native (the mobile app). + +This is the single source of truth for wallet *logic*. Higher packages +(`@dcprotocol/core`, `@dcprotocol/vault`, the official apps) build their platform +adapters on top of it, so a fix / feature / fee change happens **once** and every +surface gets it. + +## What's inside + +| Area | Exports | +|---|---| +| **Build** | `buildSolanaTransferTx`, `buildSplTransferTx`, `getSolanaAtaAddress` | +| **Decode / validate** | `verifyTransferTx` (anti-blind-sign), `getTransactionSigners`, `getTransactionProgramIds` | +| **Swap safety** | `validateSwapQuote` (quote↔intent), `validateSwapTransaction` (sole-signer + program allow-list), `idempotencyIntentMatches` | +| **Jupiter** | `jupiterQuote`, `jupiterBuildSwapTx` (fee + endpoint **injected**, never hardcoded) | +| **Tokens** | `resolveToken`, `resolveSwapToken`, `WSOL_MINT`, `DEFAULT_KNOWN_TOKENS` | +| **Reads** | `SolanaReader` (balances, tx status, history, token search) | +| **Runner** | `executeTransfer`, `executeSwap` + the ports (`Signer`, `RpcClient`, `BudgetStore`, `IdempotencyStore`, `ApprovalUI`, `ActivityRecorder`, `ConfigProvider`, `SwapProvider`) | +| **Consent** | `ConsentRequiredError`, `normalizeApproval` (supports both inline-PIN and async approve-later) | +| **Errors** | `VaultError`, `VaultErrorCode` (the shared protocol error type) | + +## Design rules (do not break) + +- **No native deps.** Only `@solana/web3.js`, `@solana/spl-token`, `fetch`. A native + dependency (sqlite/sodium/keychain) would break the React Native build — that is the + whole reason this package exists separately from `@dcprotocol/core`. +- **The brain never signs and never holds a key.** The runner builds + validates, then + delegates signing to the platform's `Signer` port. +- **No business config.** The swap fee (rate + account) and RPC endpoint are *injected* + by the host, never embedded here. + +## Usage (runner) + +```ts +import { executeTransfer, type TransferPorts } from '@dcprotocol/wallet-core'; + +const ports: TransferPorts = { signer, rpc, approval, budget, idempotency, activity }; +const result = await executeTransfer( + { chain: 'solana', to, amount, currency: 'SOL', idempotencyKey }, + ports, +); +``` + +The runner runs: `idempotency → build → validate → budget → approval → sign(port) → +verify-after-sign → submit → record-on-success`. Approval may return a boolean +(inline PIN) or `{ deferred, consentId }` (async consent) — the latter throws +`ConsentRequiredError` for the host to surface as `requires_consent`. + +## License + +Apache-2.0 diff --git a/packages/dcp-wallet-core/package.json b/packages/dcp-wallet-core/package.json new file mode 100644 index 0000000..2d34e4e --- /dev/null +++ b/packages/dcp-wallet-core/package.json @@ -0,0 +1,57 @@ +{ + "name": "@dcprotocol/wallet-core", + "version": "3.0.0", + "description": "Pure, dependency-light DCP wallet brain: tx build, decode, validation, and shared error types. No storage, no key custody, no native deps — safe to import from Node, browser, and React Native.", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/1lystore/dcp.git", + "directory": "packages/dcp-wallet-core" + }, + "homepage": "https://github.com/1lystore/dcp#readme", + "bugs": { + "url": "https://github.com/1lystore/dcp/issues" + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "wallet", + "solana", + "transaction", + "validation" + ], + "author": "DCP Protocol", + "license": "Apache-2.0", + "dependencies": { + "@solana/spl-token": "^0.4.9", + "@solana/web3.js": "^1.98.0" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=22 <23" + } +} diff --git a/packages/dcp-wallet-core/src/errors.ts b/packages/dcp-wallet-core/src/errors.ts new file mode 100644 index 0000000..416d41e --- /dev/null +++ b/packages/dcp-wallet-core/src/errors.ts @@ -0,0 +1,59 @@ +/** + * Shared protocol error type. + * + * Lives in wallet-core (the foundational leaf package) so that EVERY package — + * wallet-core, core, vault, agent, and the private runtimes — throws and catches + * the SAME `VaultError` class. A single class keeps `instanceof VaultError` + * reliable across package boundaries (a second copy would silently break error + * handling). `@dcprotocol/core` re-exports these for backward compatibility. + */ + +export type VaultErrorCode = + | 'VAULT_NOT_INITIALIZED' + | 'VAULT_LOCKED' + | 'CONSENT_REQUIRED' + | 'CONSENT_DENIED' + | 'CONSENT_EXPIRED' + | 'CONSENT_TIMEOUT' + | 'CONSENT_NOT_FOUND' + | 'SCOPE_VIOLATION' + | 'BUDGET_EXCEEDED_TX' + | 'BUDGET_EXCEEDED_DAILY' + | 'TOKEN_EXPIRED' + | 'TOKEN_REVOKED' + | 'INVALID_CHAIN' + | 'INVALID_TX' + | 'INVALID_SCHEMA' + | 'IDEMPOTENCY_CONFLICT' + | 'RATE_LIMITED' + | 'RECORD_NOT_FOUND' + | 'INTERNAL_ERROR' + | 'VALIDATION_ERROR' + | 'UNAUTHORIZED' + | 'SERVICE_NOT_TRUSTED' + | 'SERVICE_NOT_FOUND' + | 'SERVICE_ALREADY_TRUSTED' + | 'INVALID_SERVICE_SIGNATURE' + | 'SERVICE_SCOPE_VIOLATION' + | 'INVALID_PUBLIC_KEY'; + +export class VaultError extends Error { + constructor( + public code: VaultErrorCode, + message: string, + public details?: Record + ) { + super(message); + this.name = 'VaultError'; + } + + toJSON() { + return { + error: { + code: this.code, + message: this.message, + details: this.details, + }, + }; + } +} diff --git a/packages/dcp-wallet-core/src/index.ts b/packages/dcp-wallet-core/src/index.ts new file mode 100644 index 0000000..80d197c --- /dev/null +++ b/packages/dcp-wallet-core/src/index.ts @@ -0,0 +1,75 @@ +/** + * @dcprotocol/wallet-core — the pure DCP wallet brain. + * + * Tx build / decode / validation + the shared protocol error type. Zero native + * deps, no storage, no key custody — importable identically from Node, browser, + * and React Native. Higher packages (core, vault, agent, the private runtimes) + * build their orchestration on top of these primitives. + */ + +export { VaultError } from './errors.js'; +export type { VaultErrorCode } from './errors.js'; + +export { + buildSolanaTransferTx, + buildSplTransferTx, + getSolanaAtaAddress, + verifyTransferTx, + getTransactionSigners, + getTransactionProgramIds, +} from './solana-tx.js'; +export type { TransferVerifyResult } from './solana-tx.js'; + +export { + validateSwapQuote, + validateSwapTransaction, + idempotencyIntentMatches, + DEFAULT_SWAP_ALLOWED_PROGRAMS, + DEFAULT_JUPITER_PROGRAM_IDS, +} from './swap.js'; +export type { ValidationResult, SwapQuote, SwapQuoteIntent, SpendIntent } from './swap.js'; + +// On-chain reads (balances, tx status, history, token search) + the RPC reader. +// Pure web3.js + fetch (RN-safe), so desktop, agents, and mobile share one reader. +export * from './solana-reads.js'; + +// SPL token registry + resolution (pure lookups; per-cluster overrides injected). +export { + WSOL_MINT, + DEFAULT_KNOWN_TOKENS, + resolveToken, + resolveSwapToken, +} from './tokens.js'; +export type { TokenInfo, TokenRegistry } from './tokens.js'; + +// Jupiter swap integration (quote/build; fee + endpoint injected, never hardcoded). +export { DEFAULT_JUPITER_API, jupiterQuote, jupiterBuildSwapTx } from './jupiter.js'; +export type { JupiterConfig } from './jupiter.js'; + +// Runner — the workflow (build → validate → approve → sign → verify → submit → +// record) over the platform ports. Shared by the vault server and mobile. +export { executeTransfer } from './runtime/transfer.js'; +export { executeSwap } from './runtime/swap.js'; +export { ConsentRequiredError, normalizeApproval } from './runtime/consent.js'; +export type { ApprovalOutcome, ConsentDeferred } from './runtime/consent.js'; +export type { + Signer, + RpcClient, + SwapProvider, + ApprovalUI, + BudgetStore, + IdempotencyStore, + ConfigProvider, + ActivityRecorder, + TransferPorts, + SwapPorts, +} from './runtime/ports.js'; +export type { + PriorSpend, + TransferRequest, + SwapRequest, + TokenRef, + ExecResult, + ApprovalRequest, + ActivityEvent, +} from './runtime/types.js'; diff --git a/packages/dcp-wallet-core/src/jupiter.ts b/packages/dcp-wallet-core/src/jupiter.ts new file mode 100644 index 0000000..5547053 --- /dev/null +++ b/packages/dcp-wallet-core/src/jupiter.ts @@ -0,0 +1,88 @@ +/** + * Jupiter swap integration — quote + build, over `fetch`. RN/Node/browser safe. + * + * The fee (rate + account) and API base are INJECTED via `JupiterConfig` — never + * read from env here — so the OSS code carries no business config. The fee is + * all-or-nothing: applied only when both a positive rate AND an account are set. + */ + +import { VaultError } from './errors.js'; +import type { SwapQuote } from './swap.js'; + +/** Public default endpoint (free tier). Operators may override via config. */ +export const DEFAULT_JUPITER_API = 'https://lite-api.jup.ag/swap/v1'; + +export interface JupiterConfig { + /** Base URL, e.g. https://lite-api.jup.ag/swap/v1 */ + apiBase: string; + /** Platform fee in bps (0/undefined = no fee). */ + feeBps?: number; + /** Token account that collects the fee. Required for any fee to apply. */ + feeAccount?: string; +} + +function feeEnabled(cfg: JupiterConfig): boolean { + return (cfg.feeBps ?? 0) > 0 && !!cfg.feeAccount; +} + +function timeoutSignal(timeoutMs: number): AbortSignal | undefined { + if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') { + return AbortSignal.timeout(timeoutMs); + } + if (typeof AbortController === 'undefined') { + return undefined; + } + const controller = new AbortController(); + setTimeout(() => controller.abort(), timeoutMs); + return controller.signal; +} + +/** Retry transient fetch failures (rate limits / blips) with a short backoff. */ +async function fetchWithRetry(input: string, init: RequestInit, attempts = 3, delayMs = 400): Promise { + let lastErr: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await fetch(input, init); + } catch (err) { + lastErr = err; + if (i < attempts - 1) await new Promise((r) => setTimeout(r, delayMs)); + } + } + throw lastErr; +} + +export async function jupiterQuote( + cfg: JupiterConfig, + args: { inputMint: string; outputMint: string; amountBaseUnits: string; slippageBps: number } +): Promise { + let url = `${cfg.apiBase}/quote?inputMint=${args.inputMint}&outputMint=${args.outputMint}&amount=${args.amountBaseUnits}&slippageBps=${args.slippageBps}`; + if (feeEnabled(cfg)) url += `&platformFeeBps=${cfg.feeBps}`; + const res = await fetchWithRetry(url, { signal: timeoutSignal(15_000) }); + if (!res.ok) throw new VaultError('INTERNAL_ERROR', `Jupiter quote failed (${res.status})`); + return res.json() as Promise; +} + +export async function jupiterBuildSwapTx( + cfg: JupiterConfig, + quote: unknown, + userPublicKey: string +): Promise { + const body: Record = { + quoteResponse: quote, + userPublicKey, + wrapAndUnwrapSol: true, + dynamicComputeUnitLimit: true, + prioritizationFeeLamports: 'auto', + }; + if (feeEnabled(cfg)) body.feeAccount = cfg.feeAccount; + const res = await fetch(`${cfg.apiBase}/swap`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: timeoutSignal(20_000), + }); + if (!res.ok) throw new VaultError('INTERNAL_ERROR', `Jupiter swap build failed (${res.status})`); + const data = (await res.json()) as { swapTransaction?: string }; + if (!data.swapTransaction) throw new VaultError('INTERNAL_ERROR', 'Jupiter returned no swap transaction'); + return data.swapTransaction; +} diff --git a/packages/dcp-wallet-core/src/runtime/consent.ts b/packages/dcp-wallet-core/src/runtime/consent.ts new file mode 100644 index 0000000..77090b6 --- /dev/null +++ b/packages/dcp-wallet-core/src/runtime/consent.ts @@ -0,0 +1,47 @@ +/** + * Approval result handling — supports BOTH approval styles: + * + * - "approve-now" (mobile): the ApprovalUI awaits a PIN/biometric and resolves a + * boolean (or { approved }). Approved → continue; denied → CONSENT_DENIED. + * - "approve-later / retry" (desktop + agents): the ApprovalUI creates a pending + * consent and resolves { deferred: true, consentId, ... }. The runner then stops + * and throws `ConsentRequiredError`, which the host maps to its `requires_consent` + * response so the human can approve out-of-band (desktop popup / Telegram / + * synced phone) and the agent retries. + * + * Backward-compatible: an adapter that returns a bare boolean keeps working + * unchanged (that's the mobile path), so this never disturbs existing callers. + */ + +export interface ConsentDeferred { + deferred: true; + consentId: string; + expiresAt: string; + reason?: string; + message: string; +} + +export type ApprovalOutcome = { approved: boolean; deferred?: false } | ConsentDeferred; + +/** Thrown when approval is deferred to an out-of-band consent flow (not an error — + * flow control). Hosts catch it and return their `requires_consent` response. */ +export class ConsentRequiredError extends Error { + readonly kind = 'consent-required'; + readonly consentId: string; + readonly expiresAt: string; + readonly reason?: string; + constructor(consent: { consentId: string; expiresAt: string; reason?: string; message: string }) { + super(consent.message); + this.name = 'ConsentRequiredError'; + this.consentId = consent.consentId; + this.expiresAt = consent.expiresAt; + this.reason = consent.reason; + } +} + +/** Normalize whatever an ApprovalUI returned into a single shape. */ +export function normalizeApproval(result: boolean | ApprovalOutcome): ApprovalOutcome { + if (typeof result === 'boolean') return { approved: result }; + if ('deferred' in result && result.deferred) return result; + return { approved: result.approved }; +} diff --git a/packages/dcp-wallet-core/src/runtime/ports.ts b/packages/dcp-wallet-core/src/runtime/ports.ts new file mode 100644 index 0000000..5e5d27a --- /dev/null +++ b/packages/dcp-wallet-core/src/runtime/ports.ts @@ -0,0 +1,82 @@ +/** + * Ports — the platform-specific dependencies the runtime calls. + * + * The runtime owns the WORKFLOW; each app (desktop, mobile) implements these + * interfaces as adapters. The private key lives ONLY behind `Signer`, in the + * platform adapter — never in the runtime or in wallet-core. + */ + +import type { SwapQuote } from '../swap.js'; +import type { PriorSpend, ApprovalRequest, ActivityEvent } from './types.js'; +import type { ApprovalOutcome } from './consent.js'; + +/** Holds/uses the wallet key. Desktop → delegates to the local vault. Mobile → secure enclave. */ +export interface Signer { + /** The wallet's public address (owner / fee-payer). */ + readonly address: string; + /** Sign an unsigned tx (base64) → signed tx (base64). The key never leaves this adapter. */ + sign(unsignedTxB64: string): Promise; +} + +/** Chain reads + submit. Implemented over a managed RPC proxy. */ +export interface RpcClient { + getLatestBlockhash(): Promise; + /** true if the account exists on-chain (rent / ATA-existence checks). */ + accountExists(address: string): Promise; + submit(signedTxB64: string, opts: { confirm: boolean }): Promise<{ signature: string; status: string }>; +} + +/** Jupiter quote + swap-tx build (network). Fee/config injected by the adapter. */ +export interface SwapProvider { + quote(args: { inputMint: string; outputMint: string; rawInAmount: string; slippageBps: number }): Promise; + buildSwapTx(quote: SwapQuote, owner: string): Promise; +} + +/** Plain-English approval gate (PIN/biometric on mobile, modal on desktop). */ +export interface ApprovalUI { + /** + * Resolve `true`/`{ approved: true }` to approve, `false`/`{ approved: false }` + * to deny, or `{ deferred: true, consentId, ... }` to hand off to an out-of-band + * consent flow (desktop/agents) — the runner then throws `ConsentRequiredError`. + */ + requestApproval(req: ApprovalRequest): Promise; +} + +/** Budget enforcement + spend recording. */ +export interface BudgetStore { + check(amount: number, currency: string): Promise<{ withinDailyLimit: boolean; needsApproval: boolean }>; + record(args: { amount: number; currency: string; signature: string }): Promise; +} + +/** Idempotency lookup + commit (intent→signature). Persistence is platform-specific. */ +export interface IdempotencyStore { + get(key: string): Promise; + /** Persist the successful spend under `key` so a later retry replays it. Called + * by the runtime ONLY after a non-failed submit — never on failure. */ + commit(key: string, spend: PriorSpend & { signature: string }): Promise; +} + +/** Managed config (allow-listed programs, jupiter program ids). */ +export interface ConfigProvider { + jupiterProgramIds(): string[]; + swapAllowedPrograms(): string[]; +} + +/** Persists + formats wallet activity/audit. */ +export interface ActivityRecorder { + record(event: ActivityEvent): Promise; +} + +export interface TransferPorts { + signer: Signer; + rpc: RpcClient; + approval: ApprovalUI; + budget: BudgetStore; + idempotency: IdempotencyStore; + activity: ActivityRecorder; +} + +export interface SwapPorts extends TransferPorts { + swapProvider: SwapProvider; + config: ConfigProvider; +} diff --git a/packages/dcp-wallet-core/src/runtime/swap.ts b/packages/dcp-wallet-core/src/runtime/swap.ts new file mode 100644 index 0000000..5f8102e --- /dev/null +++ b/packages/dcp-wallet-core/src/runtime/swap.ts @@ -0,0 +1,121 @@ +/** + * Swap workflow — shared orchestration over Jupiter. + * + * idempotency → quote → validate-quote → build → validate-tx → budget → approve + * → sign(port) → re-validate-signed → submit → record + * + * Both validators come from wallet-core, so desktop + mobile bound the swap to the + * caller's intent and the program allow-list identically. + */ + +import { validateSwapQuote, validateSwapTransaction, idempotencyIntentMatches } from '../swap.js'; +import { VaultError } from '../errors.js'; +import { ConsentRequiredError, normalizeApproval } from './consent.js'; +import type { SwapPorts } from './ports.js'; +import type { SwapRequest, ExecResult } from './types.js'; + +export async function executeSwap(req: SwapRequest, ports: SwapPorts): Promise { + const { signer, rpc, swapProvider, approval, budget, idempotency, activity, config } = ports; + const owner = signer.address; + const slippageBps = req.slippageBps ?? 50; + + if (req.fromToken.mint === req.toToken.mint) { + throw new VaultError('INVALID_CHAIN', 'from and to tokens are the same'); + } + + // 1. Idempotency (destination = output mint identifies the swap intent). + const key = req.idempotencyKey; + if (key) { + const prior = await idempotency.get(key); + if (prior) { + const sameIntent = idempotencyIntentMatches( + { operation: prior.operation, destination: prior.destination, currency: prior.currency, amount: prior.amount }, + { operation: 'swap', destination: req.toToken.mint, currency: req.fromToken.currency, amount: req.amount } + ); + if (!sameIntent) { + throw new VaultError('IDEMPOTENCY_CONFLICT', 'This idempotency key was already used for a different swap.'); + } + if (prior.signature) return { signature: prior.signature, status: 'submitted', idempotentReplay: true }; + } + } + + // 2. Quote + bind it to the caller's intent BEFORE building anything. + const rawIn = BigInt(Math.round(req.amount * 10 ** req.fromToken.decimals)).toString(); + const quote = await swapProvider.quote({ + inputMint: req.fromToken.mint, + outputMint: req.toToken.mint, + rawInAmount: rawIn, + slippageBps, + }); + const quoteCheck = validateSwapQuote(quote, { + inputMint: req.fromToken.mint, + outputMint: req.toToken.mint, + rawInAmount: rawIn, + slippageBps, + }); + if (!quoteCheck.ok) throw new VaultError('INVALID_CHAIN', quoteCheck.reason!); + + // 3. Build + validate the third-party tx (sole signer + program allow-list). + const swapTx = await swapProvider.buildSwapTx(quote, owner); + const txCheck = validateSwapTransaction(swapTx, { + owner, + jupiterProgramIds: config.jupiterProgramIds(), + allowedPrograms: config.swapAllowedPrograms(), + }); + if (!txCheck.ok) throw new VaultError('INVALID_CHAIN', txCheck.reason!); + + // 4. Budget + approval (charged on the input being spent). + const b = await budget.check(req.amount, req.fromToken.currency); + if (!b.withinDailyLimit) { + throw new VaultError('BUDGET_EXCEEDED_DAILY', 'Swap exceeds the daily budget.'); + } + if (b.needsApproval) { + const decision = normalizeApproval(await approval.requestApproval({ + kind: 'swap', + summary: `Swap ${req.amount} ${req.fromToken.currency} → ${req.toToken.currency}`, + amount: req.amount, + currency: req.fromToken.currency, + })); + if ('deferred' in decision && decision.deferred) throw new ConsentRequiredError(decision); + if (!decision.approved) throw new VaultError('CONSENT_DENIED', 'Swap was not approved.'); + } + + // 5. Sign, then 6. re-validate the SIGNED tx — a signer must not have changed the + // message (fee-payer / signer count / programs stay bounded). + const signedTx = await signer.sign(swapTx); + const signedCheck = validateSwapTransaction(signedTx, { + owner, + jupiterProgramIds: config.jupiterProgramIds(), + allowedPrograms: config.swapAllowedPrograms(), + }); + if (!signedCheck.ok) { + throw new VaultError('INVALID_TX', `Signed swap transaction failed validation: ${signedCheck.reason}`); + } + + // 7. Submit. Record activity always; only DEBIT budget + COMMIT idempotency when + // the swap did not fail (a failed swap is no spend and is free to retry). + const res = await rpc.submit(signedTx, { confirm: req.confirm ?? true }); + const succeeded = res.status !== 'failed'; + if (succeeded) { + await budget.record({ amount: req.amount, currency: req.fromToken.currency, signature: res.signature }); + if (key) { + await idempotency.commit(key, { + operation: 'swap', + destination: req.toToken.mint, + currency: req.fromToken.currency, + amount: req.amount, + signature: res.signature, + }); + } + } + await activity.record({ + kind: 'swap', + amount: req.amount, + currency: req.fromToken.currency, + destination: req.toToken.mint, + signature: res.signature, + status: res.status, + }); + + return { signature: res.signature, status: res.status, outAmount: quote.outAmount }; +} diff --git a/packages/dcp-wallet-core/src/runtime/transfer.ts b/packages/dcp-wallet-core/src/runtime/transfer.ts new file mode 100644 index 0000000..abfd712 --- /dev/null +++ b/packages/dcp-wallet-core/src/runtime/transfer.ts @@ -0,0 +1,123 @@ +/** + * Transfer workflow — the single orchestration shared by desktop + mobile. + * + * idempotency → build → budget → approve → sign(port) → verify-signed → submit → record + * + * Every security gate (idempotency, budget, approval, verify-after-sign) runs + * BEFORE submit, and the private key is only touched behind the Signer port. + */ + +import { buildSolanaTransferTx, buildSplTransferTx, getSolanaAtaAddress, verifyTransferTx } from '../solana-tx.js'; +import { idempotencyIntentMatches } from '../swap.js'; +import { VaultError } from '../errors.js'; +import { ConsentRequiredError, normalizeApproval } from './consent.js'; +import type { TransferPorts } from './ports.js'; +import type { TransferRequest, ExecResult } from './types.js'; + +export async function executeTransfer(req: TransferRequest, ports: TransferPorts): Promise { + const { signer, rpc, approval, budget, idempotency, activity } = ports; + const owner = signer.address; + const isNative = (req.currency || 'SOL').toUpperCase() === 'SOL' && !req.mint; + + // 1. Idempotency: replay an identical prior transfer; reject key reuse for a + // different intent (never blindly replay an unrelated signature). + const key = req.idempotencyKey; + if (key) { + const prior = await idempotency.get(key); + if (prior) { + const sameIntent = idempotencyIntentMatches( + { operation: prior.operation, destination: prior.destination, currency: prior.currency, amount: prior.amount }, + { operation: 'transfer', destination: req.to, currency: req.currency, amount: req.amount } + ); + if (!sameIntent) { + throw new VaultError('IDEMPOTENCY_CONFLICT', 'This idempotency key was already used for a different transfer.'); + } + if (prior.signature) return { signature: prior.signature, status: 'submitted', idempotentReplay: true }; + } + } + + // 2. Build the transaction ourselves (wallet-core, pure). The idempotency key + // doubles as a per-transfer memo nonce so distinct transfers never collide. + const blockhash = await rpc.getLatestBlockhash(); + const memo = key || undefined; + let unsignedTx: string; + if (isNative) { + unsignedTx = buildSolanaTransferTx(owner, req.to, req.amount, blockhash, memo ? { memo } : undefined); + } else { + if (!req.mint || req.decimals === undefined) { + throw new VaultError('INVALID_CHAIN', 'SPL transfer requires mint and decimals'); + } + const recipientAtaExists = await rpc.accountExists(getSolanaAtaAddress(req.mint, req.to)); + unsignedTx = buildSplTransferTx({ + fromAddress: owner, + toAddress: req.to, + mint: req.mint, + amount: req.amount, + decimals: req.decimals, + blockhash, + createRecipientAta: !recipientAtaExists, + memo, + }); + } + + // 3. Budget + explicit approval (plain English) before any signing. + const b = await budget.check(req.amount, req.currency); + if (!b.withinDailyLimit) { + throw new VaultError('BUDGET_EXCEEDED_DAILY', 'Transfer exceeds the daily budget.'); + } + if (b.needsApproval) { + const decision = normalizeApproval(await approval.requestApproval({ + kind: 'transfer', + summary: req.description || `Send ${req.amount} ${req.currency} to ${req.to}`, + amount: req.amount, + currency: req.currency, + destination: req.to, + })); + if ('deferred' in decision && decision.deferred) throw new ConsentRequiredError(decision); + if (!decision.approved) throw new VaultError('CONSENT_DENIED', 'Transfer was not approved.'); + } + + // 4. Sign (key behind the port). + const signedTx = await signer.sign(unsignedTx); + + // 5. Verify the SIGNED tx STILL matches the declared intent — defense against a + // compromised/buggy signer swapping the transaction out from under us. + const verdict = verifyTransferTx({ + unsignedTx: signedTx, + owner, + declaredAmount: req.amount, + declaredDestination: req.to, + currency: req.currency, + }); + if (!verdict.ok) { + throw new VaultError('INVALID_TX', `Signed transaction does not match the requested transfer: ${verdict.reason}`); + } + + // 6. Submit. Record activity always, but only DEBIT budget + COMMIT idempotency + // when the tx did not fail — a failed broadcast must never count as spend and + // must leave the idempotency key free to retry. + const res = await rpc.submit(signedTx, { confirm: req.confirm ?? true }); + const succeeded = res.status !== 'failed'; + if (succeeded) { + await budget.record({ amount: req.amount, currency: req.currency, signature: res.signature }); + if (key) { + await idempotency.commit(key, { + operation: 'transfer', + destination: req.to, + currency: req.currency, + amount: req.amount, + signature: res.signature, + }); + } + } + await activity.record({ + kind: 'transfer', + amount: req.amount, + currency: req.currency, + destination: req.to, + signature: res.signature, + status: res.status, + }); + + return { signature: res.signature, status: res.status }; +} diff --git a/packages/dcp-wallet-core/src/runtime/types.ts b/packages/dcp-wallet-core/src/runtime/types.ts new file mode 100644 index 0000000..a57ef6c --- /dev/null +++ b/packages/dcp-wallet-core/src/runtime/types.ts @@ -0,0 +1,69 @@ +/** + * Request/result + summary types for the wallet runtime workflows. + * Plain data — no platform specifics. + */ + +export interface PriorSpend { + operation: string; + destination?: string | null; + currency: string; + amount: number; + /** Present once the spend successfully broadcast — enables idempotent replay. */ + signature?: string; +} + +export interface TransferRequest { + chain: 'solana'; + to: string; + amount: number; + /** 'SOL' for native, or a token symbol for SPL (with mint + decimals). */ + currency: string; + mint?: string; + decimals?: number; + idempotencyKey?: string; + /** true = wait for confirmation (default); false = return on broadcast. */ + confirm?: boolean; + description?: string; +} + +export interface TokenRef { + mint: string; + decimals: number; + currency: string; +} + +export interface SwapRequest { + chain: 'solana'; + fromToken: TokenRef; + toToken: TokenRef; + amount: number; + slippageBps?: number; + idempotencyKey?: string; + confirm?: boolean; +} + +export interface ExecResult { + signature: string; + status: string; + idempotentReplay?: boolean; + /** Quoted output (base units) for swaps. */ + outAmount?: string; +} + +export interface ApprovalRequest { + kind: 'transfer' | 'swap'; + /** Plain-English description shown to the human. */ + summary: string; + amount: number; + currency: string; + destination?: string; +} + +export interface ActivityEvent { + kind: 'transfer' | 'swap'; + amount: number; + currency: string; + destination?: string; + signature: string; + status: string; +} diff --git a/packages/dcp-wallet-core/src/solana-reads.ts b/packages/dcp-wallet-core/src/solana-reads.ts new file mode 100644 index 0000000..89180f5 --- /dev/null +++ b/packages/dcp-wallet-core/src/solana-reads.ts @@ -0,0 +1,489 @@ +/** + * Solana read helpers — read-only Solana data backing the wallet read tools + * (balances, transaction status, transaction history, token search). These are + * PUBLIC RPC reads — no private key is ever used here, so they require no consent. + * + * Design rules: + * - The RPC endpoint is a CONFIG parameter, never a hardcoded secret. Hosts inject + * DCP_SOLANA_RPC_URL (e.g. their proxy); otherwise the public mainnet default is + * used. The endpoint is NOT an agent-supplied tool param, so an agent cannot point + * reads at an arbitrary RPC to dodge a host's proxy. + * - Cost controls: a short-TTL cache + hard limit caps so a chatty agent cannot fan + * out unbounded RPC calls. History returns signatures + light metadata only (it + * never fans out to getTransaction). + * + * Connection and fetch are injectable for hermetic unit tests. + */ + +import { Connection, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js'; + +// ============================================================================ +// Constants & config +// ============================================================================ + +const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com'; +const DEFAULT_JUPITER_SEARCH_URL = 'https://lite-api.jup.ag/tokens/v2/search'; +const RPC_TIMEOUT_MS = 10_000; +const CACHE_TTL_MS = 10_000; +const CONFIRM_TIMEOUT_MS = 30_000; // PRD §10.7: non-blocking return after this +const CONFIRM_POLL_MS = 2_000; + +const HISTORY_DEFAULT_LIMIT = 20; +const HISTORY_MAX_LIMIT = 50; // PRD §10.3 hard cap +const SEARCH_DEFAULT_LIMIT = 10; +const SEARCH_MAX_LIMIT = 20; +// Cap the token list so an active wallet with hundreds of dust tokens can't +// flood an agent's context. Known/tagged tokens are prioritized. +const MAX_BALANCE_TOKENS = 50; + +const SOLANA_EXPLORER_TX = 'https://solscan.io/tx/'; + +// Classic SPL Token program. USDC/USDT/1LY are all classic SPL. +const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + +// Well-known mints for friendly symbol tagging. +const KNOWN_MINTS: Record = { + EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: 'USDC', + Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB: 'USDT', + Aih3sbAbu39Yn7jB2Qf4btZ5eWtDGQJH2gMfC4qdBAGS: '1LY', +}; + +// Base58 signature: 64-byte sig → 86-88 base58 chars. Loose but excludes junk. +const BASE58_SIG_RE = /^[1-9A-HJ-NP-Za-km-z]{64,90}$/; + +/** + * Thrown for caller-supplied bad input (malformed address/signature) so callers + * can map it to an InvalidParams error rather than an internal error. + */ +export class ReadInputError extends Error { + constructor(message: string) { + super(message); + this.name = 'ReadInputError'; + } +} + +// ============================================================================ +// Public result types +// ============================================================================ + +export interface TokenBalance { + mint: string; + symbol?: string; + amount: string; // raw integer string + decimals: number; + ui: number; // human-readable amount +} + +export interface SolanaBalances { + address: string; + sol: { lamports: number; ui: number }; + tokens: TokenBalance[]; + /** Total non-zero SPL token holdings before the response cap. */ + total_tokens: number; + /** True when `tokens` was truncated to MAX_BALANCE_TOKENS. */ + truncated: boolean; +} + +export type SignatureStatus = 'processed' | 'confirmed' | 'finalized' | 'failed' | 'not_found'; + +export interface TxStatusResult { + signature: string; + status: SignatureStatus; + slot?: number; + confirmations?: number | null; + err?: unknown; + explorer_url: string; +} + +export interface TxHistoryItem { + signature: string; + slot: number; + block_time?: number | null; + status: SignatureStatus; + err?: unknown; + memo?: string | null; + explorer_url: string; +} + +export interface TxHistoryResult { + address: string; + count: number; + transactions: TxHistoryItem[]; +} + +export interface TokenSearchItem { + mint: string; + symbol?: string; + name?: string; + decimals?: number; + icon?: string; +} + +export interface TokenSearchResult { + query: string; + count: number; + tokens: TokenSearchItem[]; +} + +export type ConfirmStatus = 'submitted' | 'processed' | 'confirmed' | 'finalized' | 'failed'; + +export interface ConfirmResult { + signature: string; + status: ConfirmStatus; + slot?: number; + err?: unknown; + explorer_url: string; +} + +// ============================================================================ +// Minimal RPC surface (so tests can inject a mock without @solana/web3.js) +// ============================================================================ + +interface ParsedTokenAccount { + account: { + data: { + parsed: { + info: { + mint: string; + tokenAmount: { + amount: string; + decimals: number; + uiAmount: number | null; + }; + }; + }; + }; + }; +} + +interface SignatureInfo { + signature: string; + slot: number; + blockTime?: number | null; + err: unknown; + confirmationStatus?: string; + memo?: string | null; +} + +export interface SolanaRpc { + getBalance(pubkey: PublicKey): Promise; + getParsedTokenAccountsByOwner( + owner: PublicKey, + filter: { programId: PublicKey } + ): Promise<{ value: ParsedTokenAccount[] }>; + getSignatureStatuses( + signatures: string[], + config?: { searchTransactionHistory?: boolean } + ): Promise<{ value: Array }>; + getSignaturesForAddress( + address: PublicKey, + options?: { limit?: number; before?: string } + ): Promise; + getLatestBlockhash(): Promise<{ blockhash: string; lastValidBlockHeight: number }>; + sendRawTransaction( + raw: Buffer | Uint8Array, + options?: { skipPreflight?: boolean; maxRetries?: number } + ): Promise; +} + +interface SignatureStatusInfo { + slot: number; + confirmations: number | null; + err: unknown; + confirmationStatus?: string; +} + +// ============================================================================ +// Pure helpers (exported for unit tests) +// ============================================================================ + +/** Resolve the RPC URL: explicit override → env → public mainnet default. */ +export function resolveSolanaRpcUrl(override?: string): string { + return override || process.env.DCP_SOLANA_RPC_URL || DEFAULT_RPC_URL; +} + +/** Clamp a requested history limit into [1, HISTORY_MAX_LIMIT]. */ +export function clampHistoryLimit(requested?: number): number { + if (typeof requested !== 'number' || !Number.isFinite(requested)) return HISTORY_DEFAULT_LIMIT; + return Math.max(1, Math.min(HISTORY_MAX_LIMIT, Math.floor(requested))); +} + +/** Clamp a requested search limit into [1, SEARCH_MAX_LIMIT]. */ +export function clampSearchLimit(requested?: number): number { + if (typeof requested !== 'number' || !Number.isFinite(requested)) return SEARCH_DEFAULT_LIMIT; + return Math.max(1, Math.min(SEARCH_MAX_LIMIT, Math.floor(requested))); +} + +/** Map a raw signature-status value into a coarse status string. */ +export function mapSignatureStatus(value: SignatureStatusInfo | null | undefined): SignatureStatus { + if (!value) return 'not_found'; + if (value.err) return 'failed'; + const cs = value.confirmationStatus; + if (cs === 'finalized' || cs === 'confirmed' || cs === 'processed') return cs; + return 'processed'; +} + +function historyItemStatus(info: SignatureInfo): SignatureStatus { + if (info.err) return 'failed'; + const cs = info.confirmationStatus; + if (cs === 'finalized' || cs === 'confirmed' || cs === 'processed') return cs; + return 'confirmed'; +} + +/** Friendly symbol for a mint, if known. */ +export function tagSymbol(mint: string): string | undefined { + return KNOWN_MINTS[mint]; +} + +/** Parse a Jupiter token-search response into our shape. */ +export function parseJupiterSearch(raw: unknown, limit: number): TokenSearchItem[] { + if (!Array.isArray(raw)) return []; + const items: TokenSearchItem[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + const mint = typeof e.id === 'string' ? e.id : typeof e.address === 'string' ? e.address : undefined; + if (!mint) continue; + items.push({ + mint, + symbol: typeof e.symbol === 'string' ? e.symbol : undefined, + name: typeof e.name === 'string' ? e.name : undefined, + decimals: typeof e.decimals === 'number' ? e.decimals : undefined, + icon: typeof e.icon === 'string' ? e.icon : undefined, + }); + if (items.length >= limit) break; + } + return items; +} + +function toPublicKey(address: string): PublicKey { + try { + return new PublicKey(address); + } catch { + throw new ReadInputError(`Invalid Solana address: ${address}`); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function timeoutFetch(fetchImpl: typeof fetch, timeoutMs: number): typeof fetch { + return ((input: Parameters[0], init?: Parameters[1]) => { + const timeout = typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' + ? AbortSignal.timeout(timeoutMs) + : undefined; + if (timeout) { + return fetchImpl(input, { ...init, signal: timeout }); + } + + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + if (!controller) { + return fetchImpl(input, init); + } + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetchImpl(input, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer)); + }) as typeof fetch; +} + +// ============================================================================ +// SolanaReader +// ============================================================================ + +export interface SolanaReaderOptions { + /** Explicit RPC URL override; otherwise env DCP_SOLANA_RPC_URL or public mainnet. */ + rpcUrl?: string; + /** Inject an RPC client (tests). Defaults to a real @solana/web3.js Connection. */ + connection?: SolanaRpc; + /** Inject fetch (tests / token search). Defaults to global fetch. */ + fetchImpl?: typeof fetch; + /** Jupiter token-search base URL override. */ + jupiterSearchUrl?: string; + /** Cache TTL in ms (default 10s). */ + cacheTtlMs?: number; + /** Clock injection for cache expiry (tests). */ + now?: () => number; +} + +export class SolanaReader { + private readonly rpcUrl: string; + private readonly injectedConnection?: SolanaRpc; + private lazyConnection?: SolanaRpc; + private readonly fetchImpl: typeof fetch; + private readonly jupiterSearchUrl: string; + private readonly cacheTtlMs: number; + private readonly now: () => number; + private readonly cache = new Map(); + + constructor(options: SolanaReaderOptions = {}) { + this.rpcUrl = resolveSolanaRpcUrl(options.rpcUrl); + this.injectedConnection = options.connection; + this.fetchImpl = options.fetchImpl ?? fetch; + this.jupiterSearchUrl = options.jupiterSearchUrl ?? DEFAULT_JUPITER_SEARCH_URL; + this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS; + this.now = options.now ?? Date.now; + } + + private rpc(): SolanaRpc { + if (this.injectedConnection) return this.injectedConnection; + if (!this.lazyConnection) { + this.lazyConnection = new Connection(this.rpcUrl, { + commitment: 'confirmed', + fetch: timeoutFetch(this.fetchImpl, RPC_TIMEOUT_MS), + }) as unknown as SolanaRpc; + } + return this.lazyConnection; + } + + private async cached(key: string, produce: () => Promise): Promise { + const hit = this.cache.get(key); + const t = this.now(); + if (hit && hit.expiresAt > t) return hit.value as T; + const value = await produce(); + this.cache.set(key, { value, expiresAt: t + this.cacheTtlMs }); + return value; + } + + /** SOL + SPL token balances for an address (cached). */ + async getBalances(address: string): Promise { + const owner = toPublicKey(address); + return this.cached(`bal:${address}`, async () => { + const rpc = this.rpc(); + const [lamports, parsed] = await Promise.all([ + rpc.getBalance(owner), + rpc.getParsedTokenAccountsByOwner(owner, { programId: TOKEN_PROGRAM_ID }), + ]); + + const allTokens: TokenBalance[] = []; + for (const acct of parsed.value) { + const info = acct.account?.data?.parsed?.info; + if (!info) continue; + const amount = info.tokenAmount.amount; + if (!amount || amount === '0') continue; + allTokens.push({ + mint: info.mint, + symbol: tagSymbol(info.mint), + amount, + decimals: info.tokenAmount.decimals, + ui: info.tokenAmount.uiAmount ?? 0, + }); + } + + // Prioritize known/tagged tokens (USDC, USDT, 1LY), then by balance, so the + // capped list shows what matters rather than arbitrary dust. + allTokens.sort((a, b) => { + const ka = a.symbol ? 1 : 0; + const kb = b.symbol ? 1 : 0; + if (ka !== kb) return kb - ka; + return b.ui - a.ui; + }); + + return { + address, + sol: { lamports, ui: lamports / LAMPORTS_PER_SOL }, + tokens: allTokens.slice(0, MAX_BALANCE_TOKENS), + total_tokens: allTokens.length, + truncated: allTokens.length > MAX_BALANCE_TOKENS, + }; + }); + } + + /** Status of a single transaction signature (not cached — it changes). */ + async getTxStatus(signature: string): Promise { + if (!BASE58_SIG_RE.test(signature)) { + throw new ReadInputError('Invalid transaction signature'); + } + const rpc = this.rpc(); + const res = await rpc.getSignatureStatuses([signature], { searchTransactionHistory: true }); + const value = res.value?.[0] ?? null; + return { + signature, + status: mapSignatureStatus(value), + slot: value?.slot, + confirmations: value?.confirmations, + err: value?.err ?? undefined, + explorer_url: SOLANA_EXPLORER_TX + signature, + }; + } + + /** Recent transaction signatures for an address (cached, capped, no fan-out). */ + async getTxHistory(address: string, options?: { limit?: number; before?: string }): Promise { + const owner = toPublicKey(address); + const limit = clampHistoryLimit(options?.limit); + const before = options?.before; + return this.cached(`hist:${address}:${limit}:${before ?? ''}`, async () => { + const rpc = this.rpc(); + const sigs = await rpc.getSignaturesForAddress(owner, { limit, before }); + const transactions: TxHistoryItem[] = sigs.map((s) => ({ + signature: s.signature, + slot: s.slot, + block_time: s.blockTime ?? null, + status: historyItemStatus(s), + err: s.err ?? undefined, + memo: s.memo ?? null, + explorer_url: SOLANA_EXPLORER_TX + s.signature, + })); + return { address, count: transactions.length, transactions }; + }); + } + + /** Search the Jupiter token list (cached). Degrades gracefully on failure. */ + async searchTokens(query: string, limit?: number): Promise { + const trimmed = (query ?? '').trim(); + if (!trimmed) return { query: trimmed, count: 0, tokens: [] }; + const max = clampSearchLimit(limit); + return this.cached(`tok:${trimmed}:${max}`, async () => { + const url = `${this.jupiterSearchUrl}?query=${encodeURIComponent(trimmed)}`; + const res = await timeoutFetch(this.fetchImpl, RPC_TIMEOUT_MS)(url, { + headers: { accept: 'application/json' }, + }); + if (!res.ok) { + throw new Error(`Token search failed (${res.status})`); + } + const raw = await res.json(); + const tokens = parseJupiterSearch(raw, max); + return { query: trimmed, count: tokens.length, tokens }; + }); + } + + /** Fetch a recent blockhash for building a transaction (not cached). */ + async getLatestBlockhash(): Promise<{ blockhash: string; lastValidBlockHeight: number }> { + return this.rpc().getLatestBlockhash(); + } + + /** Broadcast a base64 signed transaction; returns the signature. */ + async submitTransaction(signedTxBase64: string): Promise { + const raw = Buffer.from(signedTxBase64, 'base64'); + return this.rpc().sendRawTransaction(raw, { skipPreflight: false, maxRetries: 3 }); + } + + /** + * Poll a signature to commitment. Returns as soon as it is confirmed/finalized + * or failed; on timeout returns a non-blocking `submitted` status (PRD §10.7) + * so the agent can poll vault_get_tx_status rather than hang. + */ + async confirmSignature( + signature: string, + options?: { timeoutMs?: number; pollMs?: number } + ): Promise { + const timeoutMs = options?.timeoutMs ?? CONFIRM_TIMEOUT_MS; + const pollMs = options?.pollMs ?? CONFIRM_POLL_MS; + const explorer_url = SOLANA_EXPLORER_TX + signature; + const start = this.now(); + const rpc = this.rpc(); + + for (;;) { + const res = await rpc.getSignatureStatuses([signature], { searchTransactionHistory: true }); + const value = res.value?.[0] ?? null; + const status = mapSignatureStatus(value); + if (status === 'failed' || status === 'confirmed' || status === 'finalized') { + return { signature, status, slot: value?.slot, err: value?.err ?? undefined, explorer_url }; + } + if (this.now() - start >= timeoutMs) { + // Not yet visible/processed within the window — hand back to the agent. + return { signature, status: status === 'processed' ? 'processed' : 'submitted', explorer_url }; + } + await sleep(pollMs); + } + } +} diff --git a/packages/dcp-wallet-core/src/solana-tx.ts b/packages/dcp-wallet-core/src/solana-tx.ts new file mode 100644 index 0000000..2bf9461 --- /dev/null +++ b/packages/dcp-wallet-core/src/solana-tx.ts @@ -0,0 +1,416 @@ +/** + * Pure Solana transaction logic — the shared "wallet brain". + * + * Build, decode, and validate transactions. No private keys, no storage, no I/O, + * no native deps — only @solana/web3.js + @solana/spl-token, so this is safe to + * import identically from Node (vault server), the browser, and React Native + * (mobile). Signing is NEVER done here; callers sign through a platform Signer. + * + * Moved verbatim out of @dcprotocol/core/wallet.ts (which carries native deps and + * cannot be imported by React Native). @dcprotocol/core now re-exports these. + */ + +import { + PublicKey, + Transaction, + VersionedTransaction, + SystemProgram, + ComputeBudgetProgram, + TransactionInstruction, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; +import { + getAssociatedTokenAddressSync, + createAssociatedTokenAccountInstruction, + createTransferCheckedInstruction, +} from '@solana/spl-token'; +import { VaultError } from './errors.js'; + +/** + * Build an UNSIGNED Solana SOL transfer (legacy tx, base64) from a structured + * intent. The vault later signs it only after budget + consent are satisfied. + * Fee-payer is always the sender — DCP never sponsors gas. + * + * @param fromAddress - Sender (the vault wallet) base58 address; also fee-payer + * @param toAddress - Recipient base58 address + * @param amountSol - Amount of SOL to send (e.g. 0.05) + * @param blockhash - A recent blockhash fetched from RPC + * @returns Base64-encoded unsigned legacy transaction (ready for signTransaction) + */ +export function buildSolanaTransferTx( + fromAddress: string, + toAddress: string, + amountSol: number, + blockhash: string, + options?: { + /** Priority fee in micro-lamports per compute unit (helps land during congestion). */ + priorityFeeMicroLamports?: number; + /** Per-transfer nonce embedded as a memo so distinct transfers never collide. */ + memo?: string; + } +): string { + let from: PublicKey; + let to: PublicKey; + try { + from = new PublicKey(fromAddress); + } catch { + throw new VaultError('INVALID_CHAIN', `Invalid from address: ${fromAddress}`); + } + try { + to = new PublicKey(toAddress); + } catch { + throw new VaultError('INVALID_CHAIN', `Invalid to address: ${toAddress}`); + } + if (from.equals(to)) { + throw new VaultError('INVALID_CHAIN', 'Refusing to build a self-transfer (from == to)'); + } + if (typeof amountSol !== 'number' || !Number.isFinite(amountSol) || amountSol <= 0) { + throw new VaultError('INVALID_CHAIN', 'amount must be a positive number of SOL'); + } + const lamports = Math.round(amountSol * LAMPORTS_PER_SOL); + if (lamports <= 0) { + throw new VaultError('INVALID_CHAIN', 'amount is too small (rounds to 0 lamports)'); + } + if (!blockhash) { + throw new VaultError('INVALID_CHAIN', 'blockhash is required to build the transaction'); + } + + const tx = new Transaction(); + tx.feePayer = from; + tx.recentBlockhash = blockhash; + + // Priority fee (compute-unit price) so the transfer lands during congestion. + // Default is a small non-zero tip; pass 0 to disable. + const priorityFee = options?.priorityFeeMicroLamports ?? 1000; + if (priorityFee > 0) { + tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: priorityFee })); + } + + tx.add(SystemProgram.transfer({ fromPubkey: from, toPubkey: to, lamports })); + + if (options?.memo) tx.add(memoInstruction(options.memo)); + + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); +} + +/** Derive the Associated Token Account address for (mint, owner). Pure. */ +export function getSolanaAtaAddress(mint: string, owner: string): string { + return getAssociatedTokenAddressSync(new PublicKey(mint), new PublicKey(owner)).toBase58(); +} + +// SPL Memo program — used to embed a per-transfer nonce so two distinct transfers +// with identical from/to/amount (and a cached blockhash) never produce the same +// transaction signature. Same nonce → same tx (idempotent); different nonce → +// different tx (distinct send). +const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'); +function memoInstruction(memo: string): TransactionInstruction { + return new TransactionInstruction({ keys: [], programId: MEMO_PROGRAM_ID, data: Buffer.from(memo, 'utf8') }); +} + +/** Convert a human token amount to base units (bigint) without float error. */ +function toBaseUnits(amount: number, decimals: number): bigint { + if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { + throw new VaultError('INVALID_CHAIN', 'amount must be a positive number'); + } + const fixed = amount.toFixed(decimals); + const [whole, frac = ''] = fixed.split('.'); + const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + '0'.repeat(decimals)).slice(0, decimals) || '0'); + if (raw <= 0n) { + throw new VaultError('INVALID_CHAIN', 'amount is too small for this token'); + } + return raw; +} + +/** + * Build an UNSIGNED SPL token transfer (e.g. USDC) from a structured intent. + * + * If the recipient has never held this token (no Associated Token Account), the + * transaction CREATES their ATA first — paid by the sender — so sending to a + * brand-new wallet "just works", the way Phantom does. Fee-payer is the sender. + * + * @param createRecipientAta - true when the recipient's ATA does not yet exist + * (the caller checks this via RPC before building) + */ +export function buildSplTransferTx(params: { + fromAddress: string; + toAddress: string; + mint: string; + amount: number; + decimals: number; + blockhash: string; + createRecipientAta: boolean; + priorityFeeMicroLamports?: number; + /** Per-transfer nonce embedded as a memo so distinct transfers never collide. */ + memo?: string; +}): string { + let from: PublicKey; + let to: PublicKey; + let mint: PublicKey; + try { + from = new PublicKey(params.fromAddress); + } catch { + throw new VaultError('INVALID_CHAIN', `Invalid from address: ${params.fromAddress}`); + } + try { + to = new PublicKey(params.toAddress); + } catch { + throw new VaultError('INVALID_CHAIN', `Invalid to address: ${params.toAddress}`); + } + try { + mint = new PublicKey(params.mint); + } catch { + throw new VaultError('INVALID_CHAIN', `Invalid token mint: ${params.mint}`); + } + if (from.equals(to)) { + throw new VaultError('INVALID_CHAIN', 'Refusing to build a self-transfer (from == to)'); + } + if (!params.blockhash) { + throw new VaultError('INVALID_CHAIN', 'blockhash is required to build the transaction'); + } + const rawAmount = toBaseUnits(params.amount, params.decimals); + + const fromAta = getAssociatedTokenAddressSync(mint, from); + const toAta = getAssociatedTokenAddressSync(mint, to); + + const tx = new Transaction(); + tx.feePayer = from; + tx.recentBlockhash = params.blockhash; + + const priorityFee = params.priorityFeeMicroLamports ?? 1000; + if (priorityFee > 0) { + tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: priorityFee })); + } + + // Create the recipient's token account if they've never held this token. + if (params.createRecipientAta) { + tx.add(createAssociatedTokenAccountInstruction(from, toAta, to, mint)); + } + + tx.add(createTransferCheckedInstruction(fromAta, mint, toAta, from, rawAmount, params.decimals)); + + if (params.memo) tx.add(memoInstruction(params.memo)); + + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); +} + +// ============================================================================ +// Transfer verification (anti blind-sign for vault_sign_tx) +// ============================================================================ + +const SYSTEM_PROGRAM_ID = '11111111111111111111111111111111'; +const COMPUTE_BUDGET_PROGRAM_ID = 'ComputeBudget111111111111111111111111111111'; +const TOKEN_PROGRAM_STR = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'; +const ATA_PROGRAM_ID = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; +const MEMO_PROGRAM_STR = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; + +interface NormIx { + programId: string; + keys: string[]; + data: Buffer; +} + +/** Decode a base64 tx (legacy or versioned) into fee-payer + flat instructions. */ +function normalizeTx(unsignedTxB64: string): { feePayer: string; ixs: NormIx[]; resolvable: boolean } { + const buf = Buffer.from(unsignedTxB64, 'base64'); + try { + const tx = Transaction.from(buf); + return { + feePayer: tx.feePayer?.toBase58() ?? '', + ixs: tx.instructions.map((ix) => ({ + programId: ix.programId.toBase58(), + keys: ix.keys.map((k) => k.pubkey.toBase58()), + data: Buffer.from(ix.data), + })), + resolvable: true, + }; + } catch { + /* not a legacy tx */ + } + try { + const vtx = VersionedTransaction.deserialize(buf); + const keys = vtx.message.staticAccountKeys.map((k) => k.toBase58()); + const n = keys.length; + let resolvable = true; + const ixs = vtx.message.compiledInstructions.map((ci) => { + const programId = ci.programIdIndex < n ? keys[ci.programIdIndex] : ((resolvable = false), ''); + const ixKeys = ci.accountKeyIndexes.map((i) => (i < n ? keys[i] : ((resolvable = false), ''))); + return { programId, keys: ixKeys, data: Buffer.from(ci.data) }; + }); + return { feePayer: keys[0] ?? '', ixs, resolvable }; + } catch { + return { feePayer: '', ixs: [], resolvable: false }; + } +} + +export interface TransferVerifyResult { + /** false → the tx contradicts what was declared; the caller MUST refuse to sign. */ + ok: boolean; + reason?: string; + /** true when the tx was a recognized simple SOL/SPL transfer we could fully check. */ + verified: boolean; +} + +/** + * Verify that an agent-supplied unsigned transaction actually matches the + * declared amount/destination before the vault signs it — closing the + * blind-sign gap on vault_sign_tx for simple SOL and SPL transfers. + * + * Returns: + * - { ok:true, verified:true } → a simple transfer that matches the declaration + * - { ok:false, ... } → a simple transfer that CONTRADICTS it → do not sign + * - { ok:true, verified:false } → not a simple SOL/SPL transfer (complex/opaque); + * not enforced here (those go through DCP-built paths) + * + * Pure + local: ~sub-millisecond, no network. + */ +export function verifyTransferTx(params: { + unsignedTx: string; + owner: string; + declaredAmount?: number; + declaredDestination?: string; + currency?: string; +}): TransferVerifyResult { + const { feePayer, ixs, resolvable } = normalizeTx(params.unsignedTx); + if (!resolvable || ixs.length === 0) return { ok: true, verified: false }; + + let solOut = 0n; + const solDests = new Set(); + const tokenTransfers: { destAta: string; amount: bigint; decimals?: number; mint?: string }[] = []; + let hasOther = false; + + for (const ix of ixs) { + if ( + ix.programId === COMPUTE_BUDGET_PROGRAM_ID || + ix.programId === MEMO_PROGRAM_STR || + ix.programId === ATA_PROGRAM_ID + ) { + continue; + } + if (ix.programId === SYSTEM_PROGRAM_ID) { + // SystemProgram.transfer: 4-byte ix index (2) + 8-byte lamports (u64 LE) + if (ix.data.length === 12 && ix.data.readUInt32LE(0) === 2) { + const from = ix.keys[0]; + const to = ix.keys[1]; + if (from !== params.owner) return { ok: false, reason: 'transfer is not from the wallet owner', verified: true }; + solOut += ix.data.readBigUInt64LE(4); + solDests.add(to); + } else { + hasOther = true; + } + } else if (ix.programId === TOKEN_PROGRAM_STR) { + if (ix.data[0] === 12 && ix.data.length >= 10) { + // transferChecked: amount(u64) at 1, decimals at 9; keys [src, mint, dst, owner] + tokenTransfers.push({ + amount: ix.data.readBigUInt64LE(1), + decimals: ix.data[9], + mint: ix.keys[1], + destAta: ix.keys[2], + }); + } else if (ix.data[0] === 3 && ix.data.length >= 9) { + // transfer: amount(u64) at 1; keys [src, dst, owner] + tokenTransfers.push({ amount: ix.data.readBigUInt64LE(1), destAta: ix.keys[1] }); + } else { + hasOther = true; + } + } else { + hasOther = true; + } + } + + // Anything beyond plain transfers (swaps, defi, unknown programs) is not a + // "simple transfer" — out of scope here. Those must go through DCP-built tools. + if (hasOther) return { ok: true, verified: false }; + if (solOut === 0n && tokenTransfers.length === 0) return { ok: true, verified: false }; + + if (feePayer && feePayer !== params.owner) { + return { ok: false, reason: 'fee-payer is not the wallet owner', verified: true }; + } + + // --- SOL checks --- + if (solOut > 0n) { + if (params.declaredAmount !== undefined && (params.currency ?? 'SOL').toUpperCase() === 'SOL') { + const declaredLamports = BigInt(Math.round(params.declaredAmount * LAMPORTS_PER_SOL)); + if (solOut > declaredLamports) { + return { ok: false, reason: `tx sends ${solOut} lamports but only ${declaredLamports} was declared`, verified: true }; + } + } + if (params.declaredDestination && !solDests.has(params.declaredDestination)) { + return { ok: false, reason: 'tx destination does not match the declared destination', verified: true }; + } + } + + // --- SPL checks --- + if (tokenTransfers.length > 0) { + for (const t of tokenTransfers) { + if (params.declaredDestination && t.mint) { + const expectedAta = getAssociatedTokenAddressSync( + new PublicKey(t.mint), + new PublicKey(params.declaredDestination) + ).toBase58(); + if (t.destAta !== expectedAta) { + return { ok: false, reason: 'token destination does not match the declared destination', verified: true }; + } + } + } + if (params.declaredAmount !== undefined) { + const decimals = tokenTransfers.find((t) => t.decimals !== undefined)?.decimals; + if (decimals !== undefined) { + const total = tokenTransfers.reduce((a, t) => a + t.amount, 0n); + const declaredBase = BigInt(Math.round(params.declaredAmount * 10 ** decimals)); + if (total > declaredBase) { + return { ok: false, reason: 'token amount exceeds the declared amount', verified: true }; + } + } + } + } + + return { ok: true, verified: true }; +} + +/** + * Extract the fee-payer and required-signature count from an unsigned tx + * (versioned or legacy). Used to validate a third-party-built swap transaction + * (e.g. from Jupiter) before signing: the vault must be the ONLY required signer + * and the fee-payer, so a tampered tx can't slip in a hidden co-signer. + */ +export function getTransactionSigners(unsignedTxB64: string): { + feePayer: string; + numRequiredSignatures: number; +} { + const buf = Buffer.from(unsignedTxB64, 'base64'); + try { + const vtx = VersionedTransaction.deserialize(buf); + return { + feePayer: vtx.message.staticAccountKeys[0]?.toBase58() ?? '', + numRequiredSignatures: vtx.message.header.numRequiredSignatures, + }; + } catch { + /* not versioned */ + } + const tx = Transaction.from(buf); + const header = tx.compileMessage().header; + return { + feePayer: tx.feePayer?.toBase58() ?? '', + numRequiredSignatures: header.numRequiredSignatures, + }; +} + +/** + * Top-level program IDs invoked by a transaction. + * + * Reliable even for versioned txs that use Address Lookup Tables: Solana forbids + * loading a program account via a LUT, so every invoked program is always present + * in the static account keys. `resolvable: false` means the tx could not be decoded + * (caller should treat it as opaque and refuse to make trust decisions on it). + * + * Pure + local, no network. + */ +export function getTransactionProgramIds(unsignedTxB64: string): { + programIds: string[]; + resolvable: boolean; +} { + const { ixs, resolvable } = normalizeTx(unsignedTxB64); + const set = new Set(); + for (const ix of ixs) if (ix.programId) set.add(ix.programId); + return { programIds: [...set], resolvable }; +} diff --git a/packages/dcp-wallet-core/src/swap.ts b/packages/dcp-wallet-core/src/swap.ts new file mode 100644 index 0000000..81896ad --- /dev/null +++ b/packages/dcp-wallet-core/src/swap.ts @@ -0,0 +1,154 @@ +/** + * Swap + spend safety validators — the funds-critical "wallet brain" checks. + * + * PURE functions: given a quote, a built transaction, or two spend intents, they + * return a pass/fail result. No network, no env, no keys, no I/O. The vault server + * and the mobile runtime both call these so the security checks are IDENTICAL on + * every platform and can never drift. Network (Jupiter fetch), env (fee/config), + * and signing live in the platform adapters, not here. + */ + +import { getTransactionSigners, getTransactionProgramIds } from './solana-tx.js'; + +export interface ValidationResult { + ok: boolean; + reason?: string; +} + +// ============================================================================ +// Swap quote validation (bind the third-party quote to the caller's intent) +// ============================================================================ + +/** Subset of a Jupiter quote we make trust decisions on. */ +export interface SwapQuote { + inputMint?: string; + outputMint?: string; + inAmount?: string; + outAmount?: string; + otherAmountThreshold?: string; + slippageBps?: number; +} + +export interface SwapQuoteIntent { + /** Mint the caller intends to spend. */ + inputMint: string; + /** Mint the caller intends to receive. */ + outputMint: string; + /** Exact input amount in base units (string), as requested. */ + rawInAmount: string; + /** Max slippage the caller authorized, in bps. */ + slippageBps: number; +} + +/** + * Verify a swap quote matches EXACTLY what was requested before anything is built + * or signed off it. Jupiter is the trusted router, but a wrong/stale/swapped quote + * (different mints, a tampered amount, or wider slippage than authorized) must never + * be acted on — the swap stays bounded to the caller's stated intent. + */ +export function validateSwapQuote(quote: SwapQuote, intent: SwapQuoteIntent): ValidationResult { + if (quote.inputMint !== intent.inputMint || quote.outputMint !== intent.outputMint) { + return { ok: false, reason: 'Swap quote does not match the requested tokens' }; + } + if (quote.inAmount !== intent.rawInAmount) { + return { ok: false, reason: 'Swap quote input amount does not match the request' }; + } + if (quote.slippageBps !== undefined && quote.slippageBps > intent.slippageBps) { + return { ok: false, reason: 'Swap quote slippage exceeds the requested limit' }; + } + if (!quote.outAmount || BigInt(quote.outAmount) <= 0n) { + return { ok: false, reason: 'Swap quote returned no output amount' }; + } + return { ok: true }; +} + +// ============================================================================ +// Swap transaction validation (bound what a Jupiter-built tx may do) +// ============================================================================ + +/** Standard Solana programs a Jupiter swap route legitimately invokes. */ +export const DEFAULT_SWAP_ALLOWED_PROGRAMS: readonly string[] = [ + '11111111111111111111111111111111', // System + 'ComputeBudget111111111111111111111111111111', // Compute Budget + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', // SPL Token + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb', // Token-2022 + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', // Associated Token Account + 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr', // Memo +]; + +/** Jupiter aggregator program ids (v6). Operators may extend via config. */ +export const DEFAULT_JUPITER_PROGRAM_IDS: readonly string[] = [ + 'JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4', +]; + +/** + * Validate a third-party-built (Jupiter) swap transaction before signing it: + * - the wallet must be the fee-payer AND the only required signer (no hidden + * co-signer can drain), and + * - the tx must invoke the Jupiter aggregator and ONLY allow-listed programs. + * + * Program ids always live in a tx's static keys (Solana forbids loading a program + * via an address lookup table), so a lookup table cannot smuggle a program past + * this check. Guards against a compromised/spoofed Jupiter response. + */ +export function validateSwapTransaction( + swapTxB64: string, + params: { owner: string; jupiterProgramIds: readonly string[]; allowedPrograms: readonly string[] } +): ValidationResult { + let feePayer: string; + let numRequiredSignatures: number; + try { + ({ feePayer, numRequiredSignatures } = getTransactionSigners(swapTxB64)); + } catch { + // Undecodable → fail closed; never sign something we can't inspect. + return { ok: false, reason: 'Swap transaction could not be decoded for validation' }; + } + if (feePayer !== params.owner) { + return { ok: false, reason: 'Swap transaction fee-payer is not the wallet' }; + } + if (numRequiredSignatures !== 1) { + return { ok: false, reason: 'Swap transaction requires unexpected additional signers' }; + } + + const { programIds, resolvable } = getTransactionProgramIds(swapTxB64); + if (!resolvable) { + return { ok: false, reason: 'Swap transaction could not be decoded for validation' }; + } + const allowed = new Set(params.allowedPrograms); + const unexpected = programIds.find((p) => !allowed.has(p)); + if (unexpected) { + return { ok: false, reason: `Swap transaction invokes an unexpected program: ${unexpected}` }; + } + if (!programIds.some((p) => params.jupiterProgramIds.includes(p))) { + return { ok: false, reason: 'Swap transaction is not a recognized Jupiter route' }; + } + return { ok: true }; +} + +// ============================================================================ +// Idempotency intent rule +// ============================================================================ + +/** The shape of a spend used to compare idempotency intent. */ +export interface SpendIntent { + operation: string; + /** transfer: recipient; swap: output mint. */ + destination?: string | null; + currency: string; + amount: number; +} + +/** + * True when a prior recorded spend represents the SAME intent as the current + * request — so reusing an idempotency key replays the prior result. False means + * the key is being reused for a DIFFERENT operation/recipient/amount/token and the + * caller MUST reject it rather than blindly replay an unrelated signature. + */ +export function idempotencyIntentMatches(prior: SpendIntent, current: SpendIntent): boolean { + return ( + prior.operation === current.operation && + (prior.destination ?? null) === (current.destination ?? null) && + prior.currency === current.currency && + Math.abs(prior.amount - current.amount) <= 1e-9 * Math.max(1, Math.abs(current.amount)) + ); +} diff --git a/packages/dcp-wallet-core/src/tokens.ts b/packages/dcp-wallet-core/src/tokens.ts new file mode 100644 index 0000000..d4a4d1a --- /dev/null +++ b/packages/dcp-wallet-core/src/tokens.ts @@ -0,0 +1,60 @@ +/** + * SPL token registry + resolution. Pure lookups, no env, no I/O. + * + * The mainnet mints below are universal public constants (the same for every + * Solana app), not business config — safe in OSS. Per-cluster overrides (e.g. a + * devnet USDC mint) are injected by the caller via the `registry` argument, so + * wallet-core never reads env. + */ + +import { VaultError } from './errors.js'; + +export const WSOL_MINT = 'So11111111111111111111111111111111111111112'; + +export interface TokenInfo { + mint: string; + decimals: number; +} + +export type TokenRegistry = Record; + +/** Default mainnet registry. Callers may pass an overridden registry (e.g. devnet). */ +export const DEFAULT_KNOWN_TOKENS: TokenRegistry = { + USDC: { mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }, + USDT: { mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 }, + '1LY': { mint: 'Aih3sbAbu39Yn7jB2Qf4btZ5eWtDGQJH2gMfC4qdBAGS', decimals: 6 }, +}; + +/** + * Resolve an SPL token for a transfer: an explicit mint+decimals wins, otherwise + * the known-token registry by currency symbol. + */ +export function resolveToken( + currency: string, + registry: TokenRegistry, + explicitMint?: string, + explicitDecimals?: number +): TokenInfo { + if (explicitMint && explicitDecimals !== undefined) { + return { mint: explicitMint, decimals: explicitDecimals }; + } + const known = registry[currency.toUpperCase()]; + if (known) return known; + throw new VaultError('INVALID_CHAIN', `Unknown token '${currency}'. Provide mint and decimals.`); +} + +/** + * Resolve a token for a swap: 'SOL' → wrapped SOL; a known symbol → its mint; an + * explicit mint+decimals → used as-is. Returns the currency label too. + */ +export function resolveSwapToken( + symbolOrMint: string, + registry: TokenRegistry, + decimals?: number +): { mint: string; decimals: number; currency: string } { + const up = symbolOrMint.toUpperCase(); + if (up === 'SOL') return { mint: WSOL_MINT, decimals: 9, currency: 'SOL' }; + if (registry[up]) return { mint: registry[up].mint, decimals: registry[up].decimals, currency: up }; + if (decimals !== undefined) return { mint: symbolOrMint, decimals, currency: symbolOrMint.slice(0, 6) }; + throw new VaultError('INVALID_CHAIN', `Unknown swap token '${symbolOrMint}'. Provide a mint and decimals.`); +} diff --git a/packages/dcp-wallet-core/tests/jupiter.test.ts b/packages/dcp-wallet-core/tests/jupiter.test.ts new file mode 100644 index 0000000..2045a5f --- /dev/null +++ b/packages/dcp-wallet-core/tests/jupiter.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { jupiterQuote, jupiterBuildSwapTx, DEFAULT_JUPITER_API, VaultError } from '../src/index.js'; + +const WSOL = 'So11111111111111111111111111111111111111112'; +const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; + +afterEach(() => vi.unstubAllGlobals()); + +function ok(json: unknown) { + return { ok: true, status: 200, json: async () => json } as Response; +} + +describe('jupiterQuote', () => { + const args = { inputMint: WSOL, outputMint: USDC, amountBaseUnits: '1000000', slippageBps: 50 }; + + it('builds the quote URL from the request and returns the quote', async () => { + let calledUrl = ''; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { calledUrl = String(url); return ok({ outAmount: '500' }); })); + const q = await jupiterQuote({ apiBase: DEFAULT_JUPITER_API }, args); + expect(q.outAmount).toBe('500'); + expect(calledUrl).toContain(`inputMint=${WSOL}`); + expect(calledUrl).toContain(`outputMint=${USDC}`); + expect(calledUrl).toContain('amount=1000000'); + expect(calledUrl).toContain('slippageBps=50'); + }); + + it('adds platformFeeBps ONLY when both a rate and an account are set', async () => { + let url = ''; + vi.stubGlobal('fetch', vi.fn(async (u: string) => { url = String(u); return ok({ outAmount: '1' }); })); + + await jupiterQuote({ apiBase: DEFAULT_JUPITER_API, feeBps: 50, feeAccount: 'FeeAcct' }, args); + expect(url).toContain('platformFeeBps=50'); + + await jupiterQuote({ apiBase: DEFAULT_JUPITER_API, feeBps: 50 }, args); // no account + expect(url).not.toContain('platformFeeBps'); + + await jupiterQuote({ apiBase: DEFAULT_JUPITER_API, feeBps: 0, feeAccount: 'FeeAcct' }, args); // zero rate + expect(url).not.toContain('platformFeeBps'); + }); + + it('retries a transient fetch failure, then succeeds', async () => { + let n = 0; + vi.stubGlobal('fetch', vi.fn(async () => { if (n++ === 0) throw new Error('blip'); return ok({ outAmount: '7' }); })); + const q = await jupiterQuote({ apiBase: DEFAULT_JUPITER_API }, args); + expect(q.outAmount).toBe('7'); + expect(n).toBe(2); + }); + + it('throws on a non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503 }) as Response)); + await expect(jupiterQuote({ apiBase: DEFAULT_JUPITER_API }, args)).rejects.toThrowError(VaultError); + }); +}); + +describe('jupiterBuildSwapTx', () => { + const quote = { inputMint: WSOL, outputMint: USDC }; + + it('posts the quote + owner and returns the swap tx', async () => { + let body: any; + vi.stubGlobal('fetch', vi.fn(async (_u: string, init: RequestInit) => { + body = JSON.parse(String(init.body)); + return ok({ swapTransaction: 'BASE64TX' }); + })); + const tx = await jupiterBuildSwapTx({ apiBase: DEFAULT_JUPITER_API }, quote, OWNER); + expect(tx).toBe('BASE64TX'); + expect(body.userPublicKey).toBe(OWNER); + expect(body.quoteResponse).toEqual(quote); + expect(body.feeAccount).toBeUndefined(); + }); + + it('includes feeAccount only when the fee is enabled', async () => { + let body: any; + vi.stubGlobal('fetch', vi.fn(async (_u: string, init: RequestInit) => { + body = JSON.parse(String(init.body)); + return ok({ swapTransaction: 'X' }); + })); + await jupiterBuildSwapTx({ apiBase: DEFAULT_JUPITER_API, feeBps: 50, feeAccount: 'FeeAcct' }, quote, OWNER); + expect(body.feeAccount).toBe('FeeAcct'); + }); + + it('throws when Jupiter returns no swap transaction', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ok({}))); + await expect(jupiterBuildSwapTx({ apiBase: DEFAULT_JUPITER_API }, quote, OWNER)).rejects.toThrowError(VaultError); + }); +}); diff --git a/packages/dcp-wallet-core/tests/runtime-consent.test.ts b/packages/dcp-wallet-core/tests/runtime-consent.test.ts new file mode 100644 index 0000000..9e823eb --- /dev/null +++ b/packages/dcp-wallet-core/tests/runtime-consent.test.ts @@ -0,0 +1,70 @@ +/** + * The runner's two approval styles: + * - "approve-now" (boolean) — mobile PIN/biometric, unchanged. + * - "approve-later" (deferred) — desktop/agents: the runner stops and throws + * ConsentRequiredError instead of signing, so the host returns requires_consent. + */ + +import { describe, it, expect } from 'vitest'; +import { buildSolanaTransferTx, executeTransfer, ConsentRequiredError } from '../src/index.js'; +import type { TransferPorts } from '../src/index.js'; + +const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; +const TO = '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S'; +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + +function ports(approvalResult: any): { ports: TransferPorts; calls: { signed: number; submitted: number } } { + const calls = { signed: 0, submitted: 0 }; + return { + calls, + ports: { + signer: { address: OWNER, async sign(tx) { calls.signed++; return tx; } }, + rpc: { + async getLatestBlockhash() { return BLOCKHASH; }, + async accountExists() { return true; }, + async submit() { calls.submitted++; return { signature: 'SIG', status: 'confirmed' }; }, + }, + approval: { async requestApproval() { return approvalResult; } }, + budget: { async check() { return { withinDailyLimit: true, needsApproval: true }; }, async record() {} }, + idempotency: { async get() { return null; }, async commit() {} }, + activity: { async record() {} }, + }, + }; +} + +const req = { chain: 'solana' as const, to: TO, amount: 0.1, currency: 'SOL', idempotencyKey: 'k' }; + +describe('runner approval styles', () => { + it('approve-now: boolean true → signs + submits', async () => { + const { ports: p, calls } = ports(true); + await executeTransfer(req, p); + expect(calls.submitted).toBe(1); + }); + + it('approve-now: boolean false → CONSENT_DENIED, never signs', async () => { + const { ports: p, calls } = ports(false); + await expect(executeTransfer(req, p)).rejects.toMatchObject({ code: 'CONSENT_DENIED' }); + expect(calls.signed).toBe(0); + }); + + it('object { approved: true } → signs', async () => { + const { ports: p, calls } = ports({ approved: true }); + await executeTransfer(req, p); + expect(calls.submitted).toBe(1); + }); + + it('deferred → throws ConsentRequiredError with the consent details, never signs', async () => { + const deferred = { deferred: true, consentId: 'consent_123', expiresAt: '2026-01-01T00:00:00Z', message: 'Approve on your device' }; + const { ports: p, calls } = ports(deferred); + await expect(executeTransfer(req, p)).rejects.toBeInstanceOf(ConsentRequiredError); + try { + await executeTransfer(req, p); + } catch (e) { + const err = e as ConsentRequiredError; + expect(err.consentId).toBe('consent_123'); + expect(err.expiresAt).toBe('2026-01-01T00:00:00Z'); + } + expect(calls.signed).toBe(0); + expect(calls.submitted).toBe(0); + }); +}); diff --git a/packages/dcp-wallet-core/tests/runtime-swap.test.ts b/packages/dcp-wallet-core/tests/runtime-swap.test.ts new file mode 100644 index 0000000..ffb24d2 --- /dev/null +++ b/packages/dcp-wallet-core/tests/runtime-swap.test.ts @@ -0,0 +1,140 @@ +/** + * Swap workflow tests with fake adapters. Proves quote-intent binding, tx program + * validation, idempotency, and approval gating all fire before signing/submitting. + */ + +import { describe, it, expect } from 'vitest'; +import { PublicKey, Transaction } from '@solana/web3.js'; +import { + DEFAULT_SWAP_ALLOWED_PROGRAMS, + DEFAULT_JUPITER_PROGRAM_IDS, + executeSwap, + type SwapQuote, +} from '../src/index.js'; +import type { SwapPorts, PriorSpend } from '../src/index.js'; + +const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; +const WSOL = 'So11111111111111111111111111111111111111112'; +const OUT_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; +const JUP = DEFAULT_JUPITER_PROGRAM_IDS[0]; +const SYSTEM = '11111111111111111111111111111111'; + +function swapTxWith(feePayer: string, programIds: string[]): string { + const t = new Transaction({ feePayer: new PublicKey(feePayer), recentBlockhash: BLOCKHASH }); + for (const pid of programIds) t.add({ keys: [], programId: new PublicKey(pid), data: Buffer.from([]) }); + return t.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); +} + +const rawIn = String(Math.round(0.001 * 1e9)); +const goodQuote: SwapQuote = { inputMint: WSOL, outputMint: OUT_MINT, inAmount: rawIn, outAmount: '500', slippageBps: 50 }; + +interface Calls { signed: number; submitted: number; recorded: number; committed: number; approvalsAsked: number; } + +function makePorts(opts: { + quote?: SwapQuote; + swapTx?: string; + prior?: PriorSpend | null; + withinDailyLimit?: boolean; + needsApproval?: boolean; + approve?: boolean; + submitStatus?: string; +}): { ports: SwapPorts; calls: Calls } { + const calls: Calls = { signed: 0, submitted: 0, recorded: 0, committed: 0, approvalsAsked: 0 }; + const ports: SwapPorts = { + signer: { address: OWNER, async sign(tx) { calls.signed++; return tx; } }, + rpc: { + async getLatestBlockhash() { return BLOCKHASH; }, + async accountExists() { return true; }, + async submit() { calls.submitted++; return { signature: 'SIG_NEW', status: opts.submitStatus ?? 'confirmed' }; }, + }, + swapProvider: { + async quote() { return opts.quote ?? goodQuote; }, + async buildSwapTx() { return opts.swapTx ?? swapTxWith(OWNER, [JUP, SYSTEM]); }, + }, + approval: { async requestApproval() { calls.approvalsAsked++; return opts.approve ?? true; } }, + budget: { + async check() { return { withinDailyLimit: opts.withinDailyLimit ?? true, needsApproval: opts.needsApproval ?? false }; }, + async record() { calls.recorded++; }, + }, + idempotency: { async get() { return opts.prior ?? null; }, async commit() { calls.committed++; } }, + activity: { async record() {} }, + config: { + jupiterProgramIds: () => [...DEFAULT_JUPITER_PROGRAM_IDS], + swapAllowedPrograms: () => [...DEFAULT_SWAP_ALLOWED_PROGRAMS, ...DEFAULT_JUPITER_PROGRAM_IDS], + }, + }; + return { ports, calls }; +} + +const req = { + chain: 'solana' as const, + fromToken: { mint: WSOL, decimals: 9, currency: 'SOL' }, + toToken: { mint: OUT_MINT, decimals: 6, currency: 'USDC' }, + amount: 0.001, + slippageBps: 50, + idempotencyKey: 's1', +}; + +describe('executeSwap', () => { + it('happy path: quote, validate, build, validate-tx, sign, submit, debit + commit', async () => { + const { ports, calls } = makePorts({}); + const res = await executeSwap(req, ports); + expect(res.signature).toBe('SIG_NEW'); + expect(res.outAmount).toBe('500'); + expect(calls).toMatchObject({ signed: 1, submitted: 1, recorded: 1, committed: 1 }); + }); + + it('does NOT debit budget or commit idempotency on a failed submit', async () => { + const { ports, calls } = makePorts({ submitStatus: 'failed' }); + const res = await executeSwap(req, ports); + expect(res.status).toBe('failed'); + expect(calls.recorded).toBe(0); + expect(calls.committed).toBe(0); + }); + + it('rejects a quote whose output mint does not match (never signs)', async () => { + const { ports, calls } = makePorts({ quote: { ...goodQuote, outputMint: SYSTEM } }); + await expect(executeSwap(req, ports)).rejects.toMatchObject({ code: 'INVALID_CHAIN' }); + expect(calls.signed).toBe(0); + }); + + it('rejects a quote with a tampered input amount (never signs)', async () => { + const { ports, calls } = makePorts({ quote: { ...goodQuote, inAmount: '999' } }); + await expect(executeSwap(req, ports)).rejects.toMatchObject({ code: 'INVALID_CHAIN' }); + expect(calls.signed).toBe(0); + }); + + it('rejects a swap tx that invokes an unexpected program (never signs)', async () => { + const rogue = '11111111111111111111111111111112'; + const { ports, calls } = makePorts({ swapTx: swapTxWith(OWNER, [JUP, rogue]) }); + await expect(executeSwap(req, ports)).rejects.toMatchObject({ code: 'INVALID_CHAIN' }); + expect(calls.signed).toBe(0); + }); + + it('rejects a tx that is not a Jupiter route (never signs)', async () => { + const { ports, calls } = makePorts({ swapTx: swapTxWith(OWNER, [SYSTEM]) }); + await expect(executeSwap(req, ports)).rejects.toMatchObject({ code: 'INVALID_CHAIN' }); + expect(calls.signed).toBe(0); + }); + + it('replays a prior identical swap without signing', async () => { + const prior: PriorSpend = { operation: 'swap', destination: OUT_MINT, currency: 'SOL', amount: 0.001, signature: 'SIG_OLD' }; + const { ports, calls } = makePorts({ prior }); + const res = await executeSwap(req, ports); + expect(res).toMatchObject({ signature: 'SIG_OLD', idempotentReplay: true }); + expect(calls.signed).toBe(0); + }); + + it('rejects key reuse for a different swap', async () => { + const prior: PriorSpend = { operation: 'swap', destination: WSOL, currency: 'SOL', amount: 0.001, signature: 'SIG_OLD' }; + const { ports } = makePorts({ prior }); + await expect(executeSwap(req, ports)).rejects.toMatchObject({ code: 'IDEMPOTENCY_CONFLICT' }); + }); + + it('aborts a swap the user denies (never signs)', async () => { + const { ports, calls } = makePorts({ needsApproval: true, approve: false }); + await expect(executeSwap(req, ports)).rejects.toMatchObject({ code: 'CONSENT_DENIED' }); + expect(calls.signed).toBe(0); + }); +}); diff --git a/packages/dcp-wallet-core/tests/runtime-transfer.test.ts b/packages/dcp-wallet-core/tests/runtime-transfer.test.ts new file mode 100644 index 0000000..c2af923 --- /dev/null +++ b/packages/dcp-wallet-core/tests/runtime-transfer.test.ts @@ -0,0 +1,133 @@ +/** + * Transfer workflow tests with fake adapters. These prove the security gates + * (idempotency, budget, approval, verify-after-sign) fire BEFORE any signing or + * submission — i.e. the runtime never moves funds it shouldn't. + */ + +import { describe, it, expect } from 'vitest'; +import { buildSolanaTransferTx, executeTransfer } from '../src/index.js'; +import type { TransferPorts, PriorSpend } from '../src/index.js'; + +const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; +const TO = '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S'; +const OTHER = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + +interface Calls { + signed: number; + submitted: number; + recorded: number; + committed: number; + approvalsAsked: number; +} + +function makePorts(opts: { + prior?: PriorSpend | null; + withinDailyLimit?: boolean; + needsApproval?: boolean; + approve?: boolean; + /** Status returned by submit (default 'confirmed'; use 'failed' to test the failure path). */ + submitStatus?: string; + /** A signer that returns a DIFFERENT tx than asked (simulates a bad/compromised signer). */ + tamperTo?: string; +}): { ports: TransferPorts; calls: Calls } { + const calls: Calls = { signed: 0, submitted: 0, recorded: 0, committed: 0, approvalsAsked: 0 }; + const ports: TransferPorts = { + signer: { + address: OWNER, + async sign(unsignedTxB64) { + calls.signed++; + if (opts.tamperTo) { + // Return a validly-built tx to a DIFFERENT destination than was requested. + return buildSolanaTransferTx(OWNER, opts.tamperTo, 0.1, BLOCKHASH); + } + return unsignedTxB64; // signing doesn't change the message + }, + }, + rpc: { + async getLatestBlockhash() { return BLOCKHASH; }, + async accountExists() { return true; }, + async submit() { calls.submitted++; return { signature: 'SIG_NEW', status: opts.submitStatus ?? 'confirmed' }; }, + }, + approval: { + async requestApproval() { calls.approvalsAsked++; return opts.approve ?? true; }, + }, + budget: { + async check() { return { withinDailyLimit: opts.withinDailyLimit ?? true, needsApproval: opts.needsApproval ?? false }; }, + async record() { calls.recorded++; }, + }, + idempotency: { + async get() { return opts.prior ?? null; }, + async commit() { calls.committed++; }, + }, + activity: { + async record() {}, + }, + }; + return { ports, calls }; +} + +const req = { chain: 'solana' as const, to: TO, amount: 0.1, currency: 'SOL', idempotencyKey: 'k1' }; + +describe('executeTransfer', () => { + it('happy path: builds, signs, verifies, submits, debits budget + commits idempotency', async () => { + const { ports, calls } = makePorts({}); + const res = await executeTransfer(req, ports); + expect(res.signature).toBe('SIG_NEW'); + expect(res.status).toBe('confirmed'); + expect(calls).toMatchObject({ signed: 1, submitted: 1, recorded: 1, committed: 1 }); + }); + + it('does NOT debit budget or commit idempotency on a failed submit', async () => { + const { ports, calls } = makePorts({ submitStatus: 'failed' }); + const res = await executeTransfer(req, ports); + expect(res.status).toBe('failed'); + expect(calls.submitted).toBe(1); + expect(calls.recorded).toBe(0); // budget not debited + expect(calls.committed).toBe(0); // idempotency key stays free to retry + }); + + it('replays a prior identical transfer WITHOUT signing again', async () => { + const prior: PriorSpend = { operation: 'transfer', destination: TO, currency: 'SOL', amount: 0.1, signature: 'SIG_OLD' }; + const { ports, calls } = makePorts({ prior }); + const res = await executeTransfer(req, ports); + expect(res).toMatchObject({ signature: 'SIG_OLD', idempotentReplay: true }); + expect(calls.signed).toBe(0); + expect(calls.submitted).toBe(0); + }); + + it('rejects the same key reused for a DIFFERENT transfer (no signing)', async () => { + const prior: PriorSpend = { operation: 'transfer', destination: OTHER, currency: 'SOL', amount: 0.1, signature: 'SIG_OLD' }; + const { ports, calls } = makePorts({ prior }); + await expect(executeTransfer(req, ports)).rejects.toMatchObject({ code: 'IDEMPOTENCY_CONFLICT' }); + expect(calls.signed).toBe(0); + }); + + it('blocks when over the daily budget (never signs)', async () => { + const { ports, calls } = makePorts({ withinDailyLimit: false }); + await expect(executeTransfer(req, ports)).rejects.toMatchObject({ code: 'BUDGET_EXCEEDED_DAILY' }); + expect(calls.signed).toBe(0); + expect(calls.submitted).toBe(0); + }); + + it('requires approval and aborts if denied (never signs)', async () => { + const { ports, calls } = makePorts({ needsApproval: true, approve: false }); + await expect(executeTransfer(req, ports)).rejects.toMatchObject({ code: 'CONSENT_DENIED' }); + expect(calls.approvalsAsked).toBe(1); + expect(calls.signed).toBe(0); + }); + + it('signs after approval is granted', async () => { + const { ports, calls } = makePorts({ needsApproval: true, approve: true }); + await executeTransfer(req, ports); + expect(calls.approvalsAsked).toBe(1); + expect(calls.submitted).toBe(1); + }); + + it('catches a compromised signer that swaps the destination (never submits)', async () => { + const { ports, calls } = makePorts({ tamperTo: OTHER }); + await expect(executeTransfer(req, ports)).rejects.toMatchObject({ code: 'INVALID_TX' }); + expect(calls.signed).toBe(1); // it did sign… + expect(calls.submitted).toBe(0); // …but verify-after-sign blocked the broadcast + }); +}); diff --git a/packages/dcp-wallet-core/tests/solana-reads.test.ts b/packages/dcp-wallet-core/tests/solana-reads.test.ts new file mode 100644 index 0000000..88310c1 --- /dev/null +++ b/packages/dcp-wallet-core/tests/solana-reads.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PublicKey } from '@solana/web3.js'; +import { + SolanaReader, + clampHistoryLimit, + clampSearchLimit, + mapSignatureStatus, + parseJupiterSearch, + resolveSolanaRpcUrl, + tagSymbol, + type SolanaRpc, +} from '../src/solana-reads.js'; + +const OWNER = '5xJ8Xy9aRMfHV6oC1yQF3W8m6sQF8eD1yU6tQ8rJ7aBc'; +const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +const SIG = '5'.repeat(88); + +function makeRpc(overrides: Partial = {}): SolanaRpc { + return { + getBalance: vi.fn(async () => 0), + getParsedTokenAccountsByOwner: vi.fn(async () => ({ value: [] })), + getSignatureStatuses: vi.fn(async () => ({ value: [null] })), + getSignaturesForAddress: vi.fn(async () => []), + getLatestBlockhash: vi.fn(async () => ({ blockhash: 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi', lastValidBlockHeight: 1 })), + sendRawTransaction: vi.fn(async () => '5'.repeat(88)), + ...overrides, + }; +} + +describe('pure helpers', () => { + it('clampHistoryLimit enforces [1,50] and default 20', () => { + expect(clampHistoryLimit(undefined)).toBe(20); + expect(clampHistoryLimit(0)).toBe(1); + expect(clampHistoryLimit(5)).toBe(5); + expect(clampHistoryLimit(999)).toBe(50); + expect(clampHistoryLimit(NaN)).toBe(20); + expect(clampHistoryLimit(7.9)).toBe(7); + }); + + it('clampSearchLimit enforces [1,20] and default 10', () => { + expect(clampSearchLimit(undefined)).toBe(10); + expect(clampSearchLimit(100)).toBe(20); + expect(clampSearchLimit(3)).toBe(3); + }); + + it('mapSignatureStatus maps null/err/confirmation', () => { + expect(mapSignatureStatus(null)).toBe('not_found'); + expect(mapSignatureStatus({ slot: 1, confirmations: 0, err: { x: 1 } })).toBe('failed'); + expect(mapSignatureStatus({ slot: 1, confirmations: 1, err: null, confirmationStatus: 'finalized' })).toBe('finalized'); + expect(mapSignatureStatus({ slot: 1, confirmations: 1, err: null })).toBe('processed'); + }); + + it('tagSymbol knows USDC, unknown returns undefined', () => { + expect(tagSymbol(USDC_MINT)).toBe('USDC'); + expect(tagSymbol('SomeRandomMint1111111111111111111111111111')).toBeUndefined(); + }); + + it('parseJupiterSearch maps id→mint and respects the limit', () => { + const raw = [ + { id: USDC_MINT, symbol: 'USDC', name: 'USD Coin', decimals: 6, icon: 'x' }, + { id: 'mint2', symbol: 'AAA', decimals: 9 }, + { symbol: 'no-id-skipped' }, + ]; + const out = parseJupiterSearch(raw, 1); + expect(out).toEqual([{ mint: USDC_MINT, symbol: 'USDC', name: 'USD Coin', decimals: 6, icon: 'x' }]); + expect(parseJupiterSearch('not-an-array', 5)).toEqual([]); + }); + + it('resolveSolanaRpcUrl: override > env > default', () => { + expect(resolveSolanaRpcUrl('https://override.test')).toBe('https://override.test'); + const prev = process.env.DCP_SOLANA_RPC_URL; + process.env.DCP_SOLANA_RPC_URL = 'https://env.test'; + expect(resolveSolanaRpcUrl()).toBe('https://env.test'); + if (prev === undefined) delete process.env.DCP_SOLANA_RPC_URL; + else process.env.DCP_SOLANA_RPC_URL = prev; + }); +}); + +describe('SolanaReader.getBalances', () => { + it('returns SOL + non-zero SPL tokens with USDC tagged', async () => { + const rpc = makeRpc({ + getBalance: vi.fn(async () => 1_500_000_000), // 1.5 SOL + getParsedTokenAccountsByOwner: vi.fn(async () => ({ + value: [ + { account: { data: { parsed: { info: { mint: USDC_MINT, tokenAmount: { amount: '2500000', decimals: 6, uiAmount: 2.5 } } } } } }, + { account: { data: { parsed: { info: { mint: 'zeroMint', tokenAmount: { amount: '0', decimals: 0, uiAmount: 0 } } } } } }, + ], + })), + }); + const reader = new SolanaReader({ connection: rpc }); + const out = await reader.getBalances(OWNER); + expect(out.sol.ui).toBe(1.5); + expect(out.tokens).toHaveLength(1); // zero-balance filtered out + expect(out.tokens[0]).toMatchObject({ mint: USDC_MINT, symbol: 'USDC', ui: 2.5 }); + expect(out.total_tokens).toBe(1); + expect(out.truncated).toBe(false); + }); + + it('caps to 50 tokens, prioritizing tagged tokens, and flags truncation', async () => { + const value = [ + // 60 untagged dust tokens with tiny balances + ...Array.from({ length: 60 }, (_, i) => ({ + account: { data: { parsed: { info: { mint: `dust${i}`, tokenAmount: { amount: '1', decimals: 0, uiAmount: 1 } } } } }, + })), + // one tagged USDC at the end of the raw list + { account: { data: { parsed: { info: { mint: USDC_MINT, tokenAmount: { amount: '5000000', decimals: 6, uiAmount: 5 } } } } } }, + ]; + const reader = new SolanaReader({ + connection: makeRpc({ getParsedTokenAccountsByOwner: vi.fn(async () => ({ value })) }), + }); + const out = await reader.getBalances(OWNER); + expect(out.total_tokens).toBe(61); + expect(out.tokens).toHaveLength(50); + expect(out.truncated).toBe(true); + expect(out.tokens[0]).toMatchObject({ symbol: 'USDC' }); // tagged token floated to top + }); + + it('rejects an invalid address before any RPC call', async () => { + const rpc = makeRpc(); + const reader = new SolanaReader({ connection: rpc }); + await expect(reader.getBalances('not-a-valid-address')).rejects.toThrow(/Invalid Solana address/); + expect(rpc.getBalance).not.toHaveBeenCalled(); + }); + + it('caches within the TTL window', async () => { + const rpc = makeRpc({ getBalance: vi.fn(async () => 1) }); + let clock = 1000; + const reader = new SolanaReader({ connection: rpc, now: () => clock, cacheTtlMs: 10_000 }); + await reader.getBalances(OWNER); + await reader.getBalances(OWNER); + expect(rpc.getBalance).toHaveBeenCalledTimes(1); // second hit served from cache + clock += 20_000; + await reader.getBalances(OWNER); + expect(rpc.getBalance).toHaveBeenCalledTimes(2); // expired → refetch + }); +}); + +describe('SolanaReader.getTxStatus', () => { + it('rejects malformed signatures', async () => { + const reader = new SolanaReader({ connection: makeRpc() }); + await expect(reader.getTxStatus('bad sig!')).rejects.toThrow(/Invalid transaction signature/); + }); + + it('returns mapped status + explorer url', async () => { + const rpc = makeRpc({ + getSignatureStatuses: vi.fn(async () => ({ value: [{ slot: 42, confirmations: null, err: null, confirmationStatus: 'finalized' }] })), + }); + const reader = new SolanaReader({ connection: rpc }); + const out = await reader.getTxStatus(SIG); + expect(out.status).toBe('finalized'); + expect(out.slot).toBe(42); + expect(out.explorer_url).toContain(SIG); + }); +}); + +describe('SolanaReader.getTxHistory', () => { + it('clamps the limit and maps items without fanning out', async () => { + const getSignaturesForAddress = vi.fn(async () => [ + { signature: SIG, slot: 10, blockTime: 1700, err: null, confirmationStatus: 'confirmed', memo: null }, + ]); + const reader = new SolanaReader({ connection: makeRpc({ getSignaturesForAddress }) }); + const out = await reader.getTxHistory(OWNER, { limit: 999 }); + expect(getSignaturesForAddress).toHaveBeenCalledWith(expect.any(PublicKey), { limit: 50, before: undefined }); + expect(out.count).toBe(1); + expect(out.transactions[0]).toMatchObject({ signature: SIG, status: 'confirmed', block_time: 1700 }); + }); +}); + +describe('SolanaReader.searchTokens', () => { + it('fetches Jupiter and parses results', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify([{ id: USDC_MINT, symbol: 'USDC', decimals: 6 }]), { status: 200 })); + const reader = new SolanaReader({ connection: makeRpc(), fetchImpl: fetchImpl as unknown as typeof fetch }); + const out = await reader.searchTokens('USDC', 5); + expect(out.count).toBe(1); + expect(out.tokens[0].mint).toBe(USDC_MINT); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it('returns empty for blank query without calling fetch', async () => { + const fetchImpl = vi.fn(); + const reader = new SolanaReader({ connection: makeRpc(), fetchImpl: fetchImpl as unknown as typeof fetch }); + const out = await reader.searchTokens(' '); + expect(out).toEqual({ query: '', count: 0, tokens: [] }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('throws on non-OK response', async () => { + const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 })); + const reader = new SolanaReader({ connection: makeRpc(), fetchImpl: fetchImpl as unknown as typeof fetch }); + await expect(reader.searchTokens('USDC')).rejects.toThrow(/Token search failed/); + }); +}); + +describe('SolanaReader submit/confirm', () => { + it('submitTransaction decodes base64 and returns the signature', async () => { + const sendRawTransaction = vi.fn(async () => SIG); + const reader = new SolanaReader({ connection: makeRpc({ sendRawTransaction }) }); + const sig = await reader.submitTransaction(Buffer.from('hello').toString('base64')); + expect(sig).toBe(SIG); + const passed = sendRawTransaction.mock.calls[0][0] as Buffer; + expect(Buffer.from(passed).toString()).toBe('hello'); + }); + + it('confirmSignature returns confirmed when status reaches confirmed', async () => { + const reader = new SolanaReader({ + connection: makeRpc({ + getSignatureStatuses: vi.fn(async () => ({ value: [{ slot: 9, confirmations: 1, err: null, confirmationStatus: 'confirmed' }] })), + }), + }); + const out = await reader.confirmSignature(SIG, { timeoutMs: 1000, pollMs: 1 }); + expect(out.status).toBe('confirmed'); + expect(out.slot).toBe(9); + }); + + it('confirmSignature returns non-blocking submitted on timeout', async () => { + const reader = new SolanaReader({ + connection: makeRpc({ getSignatureStatuses: vi.fn(async () => ({ value: [null] })) }), + }); + const out = await reader.confirmSignature(SIG, { timeoutMs: 0, pollMs: 1 }); + expect(out.status).toBe('submitted'); + }); + + it('confirmSignature returns failed when the tx errored', async () => { + const reader = new SolanaReader({ + connection: makeRpc({ + getSignatureStatuses: vi.fn(async () => ({ value: [{ slot: 9, confirmations: 0, err: { InstructionError: [] }, confirmationStatus: 'processed' }] })), + }), + }); + const out = await reader.confirmSignature(SIG, { timeoutMs: 1000, pollMs: 1 }); + expect(out.status).toBe('failed'); + }); +}); diff --git a/packages/dcp-wallet-core/tests/solana-tx.test.ts b/packages/dcp-wallet-core/tests/solana-tx.test.ts new file mode 100644 index 0000000..219643b --- /dev/null +++ b/packages/dcp-wallet-core/tests/solana-tx.test.ts @@ -0,0 +1,74 @@ +/** + * Standalone tests for @dcprotocol/wallet-core — proves the pure wallet brain + * works imported directly from the package, with zero native deps. (The full + * behavioral suite also runs in @dcprotocol/core via the re-export, guaranteeing + * backward compatibility.) + */ + +import { describe, it, expect } from 'vitest'; +import { Keypair, Transaction, SystemProgram, PublicKey } from '@solana/web3.js'; +import { + buildSolanaTransferTx, + getSolanaAtaAddress, + verifyTransferTx, + getTransactionSigners, + getTransactionProgramIds, + VaultError, +} from '../src/index.js'; + +const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; +const TO = '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S'; +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; + +describe('wallet-core: build + signers', () => { + it('builds a SOL transfer with the sender as fee-payer + sole signer', () => { + const tx = buildSolanaTransferTx(OWNER, TO, 0.1, BLOCKHASH); + const s = getTransactionSigners(tx); + expect(s.feePayer).toBe(OWNER); + expect(s.numRequiredSignatures).toBe(1); + }); + + it('throws a VaultError (INVALID_CHAIN) on a self-transfer', () => { + expect(() => buildSolanaTransferTx(OWNER, OWNER, 0.1, BLOCKHASH)).toThrowError(VaultError); + }); + + it('derives a deterministic ATA address', () => { + const mint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + expect(getSolanaAtaAddress(mint, OWNER)).toBe(getSolanaAtaAddress(mint, OWNER)); + }); +}); + +describe('wallet-core: anti-blind-sign', () => { + it('passes a matching declared SOL transfer', () => { + const tx = buildSolanaTransferTx(OWNER, TO, 0.1, BLOCKHASH); + expect(verifyTransferTx({ unsignedTx: tx, owner: OWNER, declaredAmount: 0.1, declaredDestination: TO, currency: 'SOL' })) + .toEqual({ ok: true, verified: true }); + }); + + it('rejects a tx that sends more than declared', () => { + const tx = buildSolanaTransferTx(OWNER, TO, 0.1, BLOCKHASH); + expect(verifyTransferTx({ unsignedTx: tx, owner: OWNER, declaredAmount: 0.05, currency: 'SOL' }).ok).toBe(false); + }); + + it('does not enforce (verified:false) on an unrecognized blob', () => { + expect(verifyTransferTx({ unsignedTx: Buffer.from('nope').toString('base64'), owner: OWNER })) + .toEqual({ ok: true, verified: false }); + }); +}); + +describe('wallet-core: program ids (LUT-safe)', () => { + it('extracts the top-level programs a tx invokes', () => { + const SYSTEM = '11111111111111111111111111111111'; + const tx = new Transaction({ feePayer: new PublicKey(OWNER), recentBlockhash: BLOCKHASH }).add( + SystemProgram.transfer({ fromPubkey: new PublicKey(OWNER), toPubkey: new PublicKey(TO), lamports: 1 }) + ); + const b64 = tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); + const r = getTransactionProgramIds(b64); + expect(r.resolvable).toBe(true); + expect(r.programIds).toContain(SYSTEM); + }); + + it('reports resolvable:false on garbage', () => { + expect(getTransactionProgramIds(Buffer.from('garbage').toString('base64')).resolvable).toBe(false); + }); +}); diff --git a/packages/dcp-wallet-core/tests/swap.test.ts b/packages/dcp-wallet-core/tests/swap.test.ts new file mode 100644 index 0000000..847b2f8 --- /dev/null +++ b/packages/dcp-wallet-core/tests/swap.test.ts @@ -0,0 +1,135 @@ +/** + * Funds-critical swap + idempotency validators. These run on every swap and guard + * real money, so they're tested exhaustively for both pass and reject paths. + */ + +import { describe, it, expect } from 'vitest'; +import { Keypair, PublicKey, Transaction } from '@solana/web3.js'; +import { + validateSwapQuote, + validateSwapTransaction, + idempotencyIntentMatches, + DEFAULT_SWAP_ALLOWED_PROGRAMS, + DEFAULT_JUPITER_PROGRAM_IDS, + type SwapQuote, + type SwapQuoteIntent, +} from '../src/index.js'; + +const WSOL = 'So11111111111111111111111111111111111111112'; +const OUT_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; +const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; +const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; +const JUP = DEFAULT_JUPITER_PROGRAM_IDS[0]; +const SYSTEM = '11111111111111111111111111111111'; + +const intent: SwapQuoteIntent = { inputMint: WSOL, outputMint: OUT_MINT, rawInAmount: '1000000', slippageBps: 50 }; +const goodQuote: SwapQuote = { inputMint: WSOL, outputMint: OUT_MINT, inAmount: '1000000', outAmount: '500', slippageBps: 50 }; + +describe('validateSwapQuote', () => { + it('accepts a quote that matches the requested intent', () => { + expect(validateSwapQuote(goodQuote, intent)).toEqual({ ok: true }); + }); + + it('rejects a mismatched output mint', () => { + const r = validateSwapQuote({ ...goodQuote, outputMint: Keypair.generate().publicKey.toBase58() }, intent); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/requested tokens/i); + }); + + it('rejects a mismatched input mint', () => { + expect(validateSwapQuote({ ...goodQuote, inputMint: OUT_MINT }, intent).ok).toBe(false); + }); + + it('rejects a tampered input amount', () => { + const r = validateSwapQuote({ ...goodQuote, inAmount: '999999' }, intent); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/input amount/i); + }); + + it('rejects slippage wider than authorized', () => { + const r = validateSwapQuote({ ...goodQuote, slippageBps: 100 }, intent); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/slippage/i); + }); + + it('accepts slippage tighter than authorized', () => { + expect(validateSwapQuote({ ...goodQuote, slippageBps: 10 }, intent).ok).toBe(true); + }); + + it('rejects a zero / missing output amount', () => { + expect(validateSwapQuote({ ...goodQuote, outAmount: '0' }, intent).ok).toBe(false); + expect(validateSwapQuote({ ...goodQuote, outAmount: undefined }, intent).ok).toBe(false); + }); +}); + +describe('validateSwapTransaction', () => { + const allowed = [...DEFAULT_SWAP_ALLOWED_PROGRAMS, ...DEFAULT_JUPITER_PROGRAM_IDS]; + const cfg = { owner: OWNER, jupiterProgramIds: DEFAULT_JUPITER_PROGRAM_IDS, allowedPrograms: allowed }; + + function tx(feePayer: string, programIds: string[]): string { + const t = new Transaction({ feePayer: new PublicKey(feePayer), recentBlockhash: BLOCKHASH }); + for (const pid of programIds) t.add({ keys: [], programId: new PublicKey(pid), data: Buffer.from([]) }); + return t.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); + } + + it('accepts a single-signer Jupiter route with only allow-listed programs', () => { + expect(validateSwapTransaction(tx(OWNER, [JUP, SYSTEM]), cfg)).toEqual({ ok: true }); + }); + + it('rejects when the fee-payer is not the wallet', () => { + const other = Keypair.generate().publicKey.toBase58(); + const r = validateSwapTransaction(tx(other, [JUP]), cfg); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/fee-payer/i); + }); + + it('rejects an unexpected program', () => { + const rogue = Keypair.generate().publicKey.toBase58(); + const r = validateSwapTransaction(tx(OWNER, [JUP, rogue]), cfg); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/unexpected program/i); + }); + + it('rejects a tx that is not a Jupiter route', () => { + const r = validateSwapTransaction(tx(OWNER, [SYSTEM]), cfg); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/not a recognized Jupiter route/i); + }); + + it('rejects an undecodable blob', () => { + const r = validateSwapTransaction(Buffer.from('garbage').toString('base64'), cfg); + expect(r.ok).toBe(false); + }); +}); + +describe('idempotencyIntentMatches', () => { + const base = { operation: 'transfer', destination: 'DEST', currency: 'SOL', amount: 0.5 }; + + it('matches an identical transfer intent', () => { + expect(idempotencyIntentMatches(base, { ...base })).toBe(true); + }); + + it('does not match a different destination', () => { + expect(idempotencyIntentMatches(base, { ...base, destination: 'OTHER' })).toBe(false); + }); + + it('does not match a different amount', () => { + expect(idempotencyIntentMatches(base, { ...base, amount: 0.6 })).toBe(false); + }); + + it('does not match a different currency', () => { + expect(idempotencyIntentMatches(base, { ...base, currency: 'USDC' })).toBe(false); + }); + + it('does not match a different operation', () => { + expect(idempotencyIntentMatches(base, { ...base, operation: 'swap' })).toBe(false); + }); + + it('tolerates float representation noise on equal amounts', () => { + expect(idempotencyIntentMatches({ ...base, amount: 0.1 + 0.2 }, { ...base, amount: 0.3 })).toBe(true); + }); + + it('treats null and undefined destination as equal', () => { + expect(idempotencyIntentMatches({ ...base, destination: null }, { ...base, destination: undefined })).toBe(true); + }); +}); diff --git a/packages/dcp-wallet-core/tests/tokens.test.ts b/packages/dcp-wallet-core/tests/tokens.test.ts new file mode 100644 index 0000000..223718d --- /dev/null +++ b/packages/dcp-wallet-core/tests/tokens.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveToken, + resolveSwapToken, + DEFAULT_KNOWN_TOKENS, + WSOL_MINT, + VaultError, +} from '../src/index.js'; + +const REG = DEFAULT_KNOWN_TOKENS; +const RANDOM_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +describe('resolveToken (transfer)', () => { + it('prefers an explicit mint + decimals', () => { + expect(resolveToken('WHATEVER', REG, RANDOM_MINT, 9)).toEqual({ mint: RANDOM_MINT, decimals: 9 }); + }); + it('resolves a known symbol from the registry', () => { + expect(resolveToken('usdc', REG)).toEqual(REG.USDC); + }); + it('throws on an unknown token with no explicit mint', () => { + expect(() => resolveToken('NOPE', REG)).toThrowError(VaultError); + }); + it('honors an overridden registry (e.g. devnet mint)', () => { + const custom = { USDC: { mint: 'DevnetUsdcMint11111111111111111111111111111', decimals: 6 } }; + expect(resolveToken('USDC', custom).mint).toBe('DevnetUsdcMint11111111111111111111111111111'); + }); +}); + +describe('resolveSwapToken (swap)', () => { + it('maps SOL to wrapped SOL', () => { + expect(resolveSwapToken('SOL', REG)).toEqual({ mint: WSOL_MINT, decimals: 9, currency: 'SOL' }); + }); + it('resolves a known symbol', () => { + expect(resolveSwapToken('USDC', REG)).toEqual({ mint: REG.USDC.mint, decimals: 6, currency: 'USDC' }); + }); + it('accepts an explicit mint + decimals', () => { + const r = resolveSwapToken(RANDOM_MINT, REG, 6); + expect(r.mint).toBe(RANDOM_MINT); + expect(r.decimals).toBe(6); + }); + it('throws on an unknown token with no decimals', () => { + expect(() => resolveSwapToken('NOPE', REG)).toThrowError(VaultError); + }); +}); diff --git a/packages/dcp-wallet-core/tsconfig.json b/packages/dcp-wallet-core/tsconfig.json new file mode 100644 index 0000000..837faa3 --- /dev/null +++ b/packages/dcp-wallet-core/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/dcp-wallet-core/vitest.config.ts b/packages/dcp-wallet-core/vitest.config.ts new file mode 100644 index 0000000..1330393 --- /dev/null +++ b/packages/dcp-wallet-core/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/index.ts'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef86100..db00a3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@dcprotocol/core': specifier: ^3.0.0 version: link:../dcp-core + '@dcprotocol/wallet-core': + specifier: workspace:* + version: link:../dcp-wallet-core '@modelcontextprotocol/sdk': specifier: ^1.0.0 version: 1.29.0(zod@4.4.3) @@ -105,6 +108,12 @@ importers: packages/dcp-core: dependencies: + '@dcprotocol/wallet-core': + specifier: workspace:* + version: link:../dcp-wallet-core + '@solana/spl-token': + specifier: ^0.4.9 + version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@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) @@ -292,6 +301,9 @@ importers: '@dcprotocol/relay-client': specifier: ^3.0.0 version: link:../dcp-relay-client + '@dcprotocol/wallet-core': + specifier: workspace:* + version: link:../dcp-wallet-core '@fastify/cors': specifier: ^10.0.2 version: 10.1.0 @@ -332,6 +344,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@solana/spl-token': + specifier: ^0.4.9 + version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) '@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) @@ -351,6 +366,28 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@22.19.17)(tsx@4.21.0) + packages/dcp-wallet-core: + dependencies: + '@solana/spl-token': + specifier: ^0.4.9 + version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@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) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.19.17 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.13)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@22.19.17)(tsx@4.21.0) + packages: '@babel/runtime@7.29.2': @@ -881,22 +918,58 @@ packages: cpu: [x64] os: [win32] + '@solana/buffer-layout-utils@0.2.0': + resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==} + engines: {node: '>= 10'} + '@solana/buffer-layout@4.0.1': resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} engines: {node: '>=5.10'} + '@solana/codecs-core@2.0.0-rc.1': + resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==} + peerDependencies: + typescript: '>=5' + '@solana/codecs-core@2.3.0': resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} engines: {node: '>=20.18.0'} peerDependencies: typescript: '>=5.3.3' + '@solana/codecs-data-structures@2.0.0-rc.1': + resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-numbers@2.0.0-rc.1': + resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==} + peerDependencies: + typescript: '>=5' + '@solana/codecs-numbers@2.3.0': resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} engines: {node: '>=20.18.0'} peerDependencies: typescript: '>=5.3.3' + '@solana/codecs-strings@2.0.0-rc.1': + resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: '>=5' + + '@solana/codecs@2.0.0-rc.1': + resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==} + peerDependencies: + typescript: '>=5' + + '@solana/errors@2.0.0-rc.1': + resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==} + hasBin: true + peerDependencies: + typescript: '>=5' + '@solana/errors@2.3.0': resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} engines: {node: '>=20.18.0'} @@ -904,6 +977,29 @@ packages: peerDependencies: typescript: '>=5.3.3' + '@solana/options@2.0.0-rc.1': + resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==} + peerDependencies: + typescript: '>=5' + + '@solana/spl-token-group@0.0.7': + resolution: {integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.3 + + '@solana/spl-token-metadata@0.1.6': + resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.3 + + '@solana/spl-token@0.4.14': + resolution: {integrity: sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.5 + '@solana/web3.js@1.98.4': resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} @@ -1057,6 +1153,13 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bigint-buffer@1.1.5: + resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==} + engines: {node: '>= 10.0.0'} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -1356,6 +1459,9 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastestsmallesttextencoderdecoder@1.0.22: + resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -2591,27 +2697,124 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.2': optional: true + '@solana/buffer-layout-utils@0.2.0(bufferutil@4.1.0)(typescript@5.9.3)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bigint-buffer: 1.1.5 + bignumber.js: 9.3.1 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + '@solana/buffer-layout@4.0.1': dependencies: buffer: 6.0.3 + '@solana/codecs-core@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + '@solana/codecs-core@2.3.0(typescript@5.9.3)': dependencies: '@solana/errors': 2.3.0(typescript@5.9.3) typescript: 5.9.3 + '@solana/codecs-data-structures@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + + '@solana/codecs-numbers@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + '@solana/codecs-numbers@2.3.0(typescript@5.9.3)': dependencies: '@solana/codecs-core': 2.3.0(typescript@5.9.3) '@solana/errors': 2.3.0(typescript@5.9.3) typescript: 5.9.3 + '@solana/codecs-strings@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + fastestsmallesttextencoderdecoder: 1.0.22 + typescript: 5.9.3 + + '@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@2.0.0-rc.1(typescript@5.9.3)': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + typescript: 5.9.3 + '@solana/errors@2.3.0(typescript@5.9.3)': dependencies: chalk: 5.6.2 commander: 14.0.3 typescript: 5.9.3 + '@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 2.0.0-rc.1(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/spl-token-group@0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana/spl-token-metadata@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana/spl-token@0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/buffer-layout-utils': 0.2.0(bufferutil@4.1.0)(typescript@5.9.3) + '@solana/spl-token-group': 0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/spl-token-metadata': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + buffer: 6.0.3 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': dependencies: '@babel/runtime': 7.29.2 @@ -2790,6 +2993,12 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bigint-buffer@1.1.5: + dependencies: + bindings: 1.5.0 + + bignumber.js@9.3.1: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -3133,6 +3342,8 @@ snapshots: fast-uri@3.1.0: {} + fastestsmallesttextencoderdecoder@1.0.22: {} + fastify-plugin@5.1.0: {} fastify@5.8.5: diff --git a/scripts/publish-guard.mjs b/scripts/publish-guard.mjs index 14ab56b..fba55c7 100644 --- a/scripts/publish-guard.mjs +++ b/scripts/publish-guard.mjs @@ -123,6 +123,7 @@ const PARKED_PACKAGES = [ // Packages that CAN be published in Phase 1 const PUBLISHABLE_PACKAGES = [ + '@dcprotocol/wallet-core', '@dcprotocol/core', '@dcprotocol/client', '@dcprotocol/vault', From 436d33ccc571762eaa7047c2453732dd68c885ce Mon Sep 17 00:00:00 2001 From: 1lystore Date: Sat, 20 Jun 2026 00:39:49 +0530 Subject: [PATCH 4/5] fix(build): build @dcprotocol/wallet-core before core (CI fresh-checkout order) In a fresh checkout, core's tsup --dts re-exports VaultError from @dcprotocol/wallet-core and needs its built dist; the root build forced core first, so wallet-core was not yet built (TS2307). Build wallet-core first in the root build + dev:cli scripts. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fdaf9e9..924d51a 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "scripts": { "preinstall": "node scripts/check-node-version.mjs && npx only-allow pnpm", "prepare": "git config core.hooksPath .githooks || true", - "build": "pnpm -r --filter @dcprotocol/core run build && pnpm -r run build", - "dev:cli": "pnpm -r --filter @dcprotocol/core run build && pnpm --filter @dcprotocol/vault run dev --", + "build": "pnpm -r --filter @dcprotocol/wallet-core run build && pnpm -r --filter @dcprotocol/core run build && pnpm -r run build", + "dev:cli": "pnpm -r --filter @dcprotocol/wallet-core run build && pnpm -r --filter @dcprotocol/core run build && pnpm --filter @dcprotocol/vault run dev --", "test": "pnpm -r run test", "clean": "rm -rf packages/*/dist", "publish-guard": "node scripts/publish-guard.mjs" From adf82ba3e78fc74b7675f49f469860f017cf794f Mon Sep 17 00:00:00 2001 From: 1lystore Date: Sat, 20 Jun 2026 00:49:41 +0530 Subject: [PATCH 5/5] fix(security): address CodeQL findings (DoS + rate limiting) - wallet-core toBaseUnits: bound user-supplied decimals to 0..18 before it feeds '0'.repeat(decimals)/toFixed/10n**, closing a resource-exhaustion (DoS) vector CodeQL flagged on solana-tx.ts. Adds a regression test. - vault /v1/vault/transfer + /v1/vault/swap: add explicit per-route rate-limit config (in addition to the global 600/min) so CodeQL sees these money-moving, auth-performing routes are rate-limited. --- packages/dcp-vault/src/server/index.ts | 14 ++++++++++++-- packages/dcp-wallet-core/src/solana-tx.ts | 7 +++++++ .../dcp-wallet-core/tests/solana-tx.test.ts | 19 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/dcp-vault/src/server/index.ts b/packages/dcp-vault/src/server/index.ts index ebe76d2..2e6d11e 100644 --- a/packages/dcp-vault/src/server/index.ts +++ b/packages/dcp-vault/src/server/index.ts @@ -5005,7 +5005,13 @@ async function buildServer(): Promise { timestamp?: string; nonce?: string; }; - }>('/v1/vault/transfer', async (request) => { + }>('/v1/vault/transfer', { + // Explicit per-route rate limit (in addition to the global one) on this + // money-moving, authorization-performing route. Generous so legitimate + // concurrent agent transfers aren't blocked; bounds a runaway/compromised + // local caller. + config: { rateLimit: { max: 600, timeWindow: '1 minute' } }, + }, async (request) => { const { chain, to, amount, currency, mint, decimals, agent_name, session_id, description, idempotency_key } = request.body; if (!chain || !to || amount === undefined || !agent_name) { @@ -5376,7 +5382,11 @@ async function buildServer(): Promise { timestamp?: string; nonce?: string; }; - }>('/v1/vault/swap', async (request) => { + }>('/v1/vault/swap', { + // Explicit per-route rate limit (in addition to the global one) on this + // money-moving, authorization-performing route. + config: { rateLimit: { max: 600, timeWindow: '1 minute' } }, + }, async (request) => { const { chain, from_token, to_token, amount, slippage_bps, from_decimals, to_decimals, agent_name, session_id, description, idempotency_key } = request.body; if (!chain || !from_token || !to_token || amount === undefined || !agent_name) { diff --git a/packages/dcp-wallet-core/src/solana-tx.ts b/packages/dcp-wallet-core/src/solana-tx.ts index 2bf9461..11f93b9 100644 --- a/packages/dcp-wallet-core/src/solana-tx.ts +++ b/packages/dcp-wallet-core/src/solana-tx.ts @@ -112,6 +112,13 @@ function toBaseUnits(amount: number, decimals: number): bigint { if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) { throw new VaultError('INVALID_CHAIN', 'amount must be a positive number'); } + // Bound `decimals` (a user-supplied value) BEFORE it feeds string/bigint sizing. + // Without this, a huge `decimals` makes `'0'.repeat(decimals)` (and toFixed/10n**) + // allocate unbounded memory — a denial-of-service vector. No real SPL token exceeds + // a handful of decimals; 18 is a generous ceiling. + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18) { + throw new VaultError('INVALID_CHAIN', 'decimals must be an integer between 0 and 18'); + } const fixed = amount.toFixed(decimals); const [whole, frac = ''] = fixed.split('.'); const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + '0'.repeat(decimals)).slice(0, decimals) || '0'); diff --git a/packages/dcp-wallet-core/tests/solana-tx.test.ts b/packages/dcp-wallet-core/tests/solana-tx.test.ts index 219643b..55f86d7 100644 --- a/packages/dcp-wallet-core/tests/solana-tx.test.ts +++ b/packages/dcp-wallet-core/tests/solana-tx.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from 'vitest'; import { Keypair, Transaction, SystemProgram, PublicKey } from '@solana/web3.js'; import { buildSolanaTransferTx, + buildSplTransferTx, getSolanaAtaAddress, verifyTransferTx, getTransactionSigners, @@ -16,6 +17,8 @@ import { VaultError, } from '../src/index.js'; +const MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + const OWNER = 'Dq9XEjvhYbSdWQbzbi3LDsjPJvU2n8FYiw3QnSgnHFVm'; const TO = '2ojv9BAiHUrvsm9gxDe7fJSzbNZSJcxZvf8dqmWGHG8S'; const BLOCKHASH = 'GHtXQBsoZHVnNFa9YevAzFr17DJjgHXk3ycTKD5xD3Zi'; @@ -33,8 +36,20 @@ describe('wallet-core: build + signers', () => { }); it('derives a deterministic ATA address', () => { - const mint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; - expect(getSolanaAtaAddress(mint, OWNER)).toBe(getSolanaAtaAddress(mint, OWNER)); + expect(getSolanaAtaAddress(MINT, OWNER)).toBe(getSolanaAtaAddress(MINT, OWNER)); + }); + + it('builds an SPL transfer with normal decimals', () => { + const tx = buildSplTransferTx({ fromAddress: OWNER, toAddress: TO, mint: MINT, amount: 1.5, decimals: 6, blockhash: BLOCKHASH, createRecipientAta: false }); + expect(typeof tx).toBe('string'); + }); + + it('rejects an out-of-range decimals (resource-exhaustion guard)', () => { + // A huge user-supplied `decimals` must NOT be allowed to allocate giant strings. + for (const decimals of [1_000_000_000, 100, 19, -1, 6.5]) { + expect(() => buildSplTransferTx({ fromAddress: OWNER, toAddress: TO, mint: MINT, amount: 1, decimals, blockhash: BLOCKHASH, createRecipientAta: false })) + .toThrowError(VaultError); + } }); });