From 3afe79308bf9ba530194711af297256fb99f3e7a Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Mon, 16 Mar 2026 13:53:15 +0300 Subject: [PATCH 1/4] feat(mcp): accept walletkit signers in factory --- packages/mcp/README.md | 31 +++++---- packages/mcp/src/__tests__/factory.spec.ts | 63 +++++++++++++++++ packages/mcp/src/factory.ts | 78 +++++++++++++--------- packages/mcp/src/types/config.ts | 42 +++++++++++- 4 files changed, 163 insertions(+), 51 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 149aff75d..7e6958065 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -436,26 +436,25 @@ The package also exports a programmatic API for building custom MCP servers: ```typescript import { createTonWalletMCP } from '@ton/mcp'; -import { Signer, WalletV5R1Adapter, TonWalletKit, MemoryStorageAdapter, Network } from '@ton/walletkit'; +import { Signer } from '@ton/walletkit'; -// Initialize TonWalletKit -const network = Network.mainnet(); -const kit = new TonWalletKit({ - networks: { [network.chainId]: {} }, - storage: new MemoryStorageAdapter(), -}); -await kit.waitForReady(); - -// Create wallet from mnemonic +// Create signer from mnemonic const signer = await Signer.fromMnemonic(mnemonic, { type: 'ton' }); -const walletAdapter = await WalletV5R1Adapter.create(signer, { - client: kit.getApiClient(network), - network, + +// Create MCP server directly from signer +const server = await createTonWalletMCP({ + signer, + network: 'mainnet', + walletVersion: 'v5r1', }); -const wallet = await kit.addWallet(walletAdapter); +``` + +If you already have a custom wallet adapter, you can still pass it directly: + +```typescript +import { createTonWalletMCP } from '@ton/mcp'; -// Create MCP server -const server = await createTonWalletMCP({ wallet }); +const server = await createTonWalletMCP({ wallet: walletAdapter }); ``` The same factory also supports registry mode: diff --git a/packages/mcp/src/__tests__/factory.spec.ts b/packages/mcp/src/__tests__/factory.spec.ts index 59b1d6680..e8da144f9 100644 --- a/packages/mcp/src/__tests__/factory.spec.ts +++ b/packages/mcp/src/__tests__/factory.spec.ts @@ -12,6 +12,7 @@ import { join } from 'node:path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { Network, Signer } from '@ton/walletkit'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -25,7 +26,9 @@ vi.mock('../runtime/wallet-runtime.js', () => ({ })); import { createStandardWalletRecord, createEmptyConfig, saveConfig } from '../registry/config.js'; +import { AgenticWalletAdapter } from '../contracts/agentic_wallet/AgenticWalletAdapter.js'; import { createTonWalletMCP } from '../factory.js'; +import { createApiClient } from '../utils/ton-client.js'; function parseToolResult(result: Awaited>) { const first = result.content[0]; @@ -272,6 +275,9 @@ describe('createTonWalletMCP registry mode', () => { try { const listed = parseToolResult(await client.callTool({ name: 'list_wallets', arguments: {} })); const current = parseToolResult(await client.callTool({ name: 'get_current_wallet', arguments: {} })); + // const network = parseToolResult( + // await client.callTool({ name: 'get_network_config', arguments: { network: 'mainnet' } }), + // ); expect(listed.wallets[0]).toMatchObject({ id: wallet.id, @@ -388,3 +394,60 @@ describe('createTonWalletMCP registry mode', () => { } }); }); + +describe('createTonWalletMCP single-wallet mode', () => { + it('accepts a WalletKit signer directly', async () => { + const signer = await Signer.fromPrivateKey(Buffer.alloc(32, 7)); + const server = await createTonWalletMCP({ + signer, + network: 'mainnet', + }); + const client = new Client({ name: 'mcp-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + try { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + expect(names).toContain('get_wallet'); + expect(names).not.toContain('list_wallets'); + + const wallet = parseToolResult(await client.callTool({ name: 'get_wallet', arguments: {} })); + expect(wallet).toMatchObject({ + success: true, + network: 'mainnet', + }); + expect(typeof wallet.address).toBe('string'); + } finally { + await client.close(); + await server.close(); + } + }); + + it('detects agentic wallet version from the adapter when walletVersion is omitted', async () => { + const signer = await Signer.fromPrivateKey(Buffer.alloc(32, 9)); + const clientApi = createApiClient('mainnet'); + const adapter = await AgenticWalletAdapter.create(signer, { + client: clientApi, + network: Network.mainnet(), + walletAddress: 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c', + }); + const server = await createTonWalletMCP({ wallet: adapter }); + const client = new Client({ name: 'mcp-test', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + try { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + expect(names).toContain('agentic_deploy_subwallet'); + } finally { + await client.close(); + await server.close(); + } + }); +}); diff --git a/packages/mcp/src/factory.ts b/packages/mcp/src/factory.ts index 2612b1b4f..66a687d6f 100644 --- a/packages/mcp/src/factory.ts +++ b/packages/mcp/src/factory.ts @@ -11,11 +11,11 @@ */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { WalletAdapter } from '@ton/walletkit'; +import { Network, WalletV4R2Adapter, WalletV5R1Adapter } from '@ton/walletkit'; import { z } from 'zod'; -import type { IContactResolver } from './types/contacts.js'; -import type { NetworkConfig } from './services/McpWalletService.js'; +import { AgenticWalletAdapter } from './contracts/agentic_wallet/AgenticWalletAdapter.js'; +import type { TonMcpConfig, SupportedWalletAdapter } from './types/config.js'; import { McpWalletService } from './services/McpWalletService.js'; import { WalletRegistryService } from './services/WalletRegistryService.js'; import { @@ -36,41 +36,17 @@ import { createMcpWalletManagementTools, } from './tools/index.js'; import { createMcpDnsTools } from './tools/dns-tools.js'; +import { createApiClient } from './utils/ton-client.js'; const SERVER_NAME = 'ton-mcp'; const SERVER_VERSION = '0.1.0'; -export interface TonMcpFactoryConfig { - /** - * Optional fixed wallet for backward-compatible single-wallet mode. - * If omitted, the server runs in config-backed registry mode. - */ - wallet?: WalletAdapter; - - /** - * Optional wallet version. - * If omitted, the server uses the wallet version of the wallet. - */ - walletVersion?: 'agentic' | 'v4r2' | 'v5r1'; - - /** - * Optional contact resolver for name-to-address resolution. - */ - contacts?: IContactResolver; - - /** - * Network-specific configuration (API keys). - */ - networks?: { - mainnet?: NetworkConfig; - testnet?: NetworkConfig; - }; - +type TonMcpFactoryConfig = TonMcpConfig & { /** * Optional shared session manager for agentic onboarding callback handling. */ agenticSessionManager?: AgenticSetupSessionManager; -} +}; function extendWithWalletSelector(schema: TSchema) { if (!(schema instanceof z.ZodObject)) { @@ -84,14 +60,50 @@ function extendWithWalletSelector(schema: TSchema) }); } +async function createWalletAdapterFromSigner(config: TonMcpConfig): Promise { + if (!config.signer) { + return undefined; + } + + const networkKey = config.network ?? 'mainnet'; + const network = networkKey === 'testnet' ? Network.testnet() : Network.mainnet(); + const apiClient = createApiClient(networkKey, config.networks?.[networkKey]?.apiKey); + const walletVersion = config.walletVersion ?? 'v5r1'; + + if (walletVersion === 'agentic') { + return AgenticWalletAdapter.create(config.signer, { + client: apiClient, + network, + walletAddress: config.agenticWalletAddress, + walletNftIndex: config.agenticWalletNftIndex, + collectionAddress: config.agenticCollectionAddress, + }); + } + + return walletVersion === 'v4r2' + ? WalletV4R2Adapter.create(config.signer, { + client: apiClient, + network, + }) + : WalletV5R1Adapter.create(config.signer, { + client: apiClient, + network, + }); +} + export async function createTonWalletMCP(config: TonMcpFactoryConfig): Promise { const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION, }); + if (config.wallet && config.signer) { + throw new Error('Provide either wallet or signer, not both.'); + } + const registry = new WalletRegistryService(config.contacts, config.networks); const knownJettonsTools = createMcpKnownJettonsTools(); + const singleWallet = config.wallet ?? (await createWalletAdapterFromSigner(config)); // Helper to register tools with type assertion (Zod version mismatch workaround) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -101,9 +113,9 @@ export async function createTonWalletMCP(config: TonMcpFactoryConfig): Promise Date: Mon, 16 Mar 2026 23:16:42 +0300 Subject: [PATCH 2/4] feat(mcp): persist wallet secrets outside config --- packages/mcp/llms.txt | 2 +- .../mcp/skills/ton-manage-wallets/SKILL.md | 2 +- .../AgenticOnboardingService.spec.ts | 6 +- .../AgenticSetupSessionManager.spec.ts | 4 +- .../__tests__/WalletRegistryService.spec.ts | 396 ++++++---- packages/mcp/src/__tests__/config.spec.ts | 435 +++++++++- packages/mcp/src/__tests__/factory.spec.ts | 52 +- packages/mcp/src/__tests__/sanitize.spec.ts | 125 +++ packages/mcp/src/registry/config-path.ts | 32 + .../mcp/src/registry/config-persistence.ts | 288 +++++++ packages/mcp/src/registry/config.ts | 746 ++++++------------ .../mcp/src/registry/private-key-field.ts | 17 + .../mcp/src/registry/private-key-files.ts | 409 ++++++++++ packages/mcp/src/runtime/wallet-runtime.ts | 107 +-- .../src/services/AgenticOnboardingService.ts | 22 +- .../services/AgenticSetupSessionManager.ts | 23 +- .../mcp/src/services/WalletRegistryService.ts | 241 +++--- .../mcp/src/tools/agentic-onboarding-tools.ts | 117 +-- packages/mcp/src/tools/responses.ts | 44 ++ packages/mcp/src/tools/sanitize.ts | 106 +-- .../mcp/src/tools/wallet-management-tools.ts | 264 ++----- 21 files changed, 2242 insertions(+), 1196 deletions(-) create mode 100644 packages/mcp/src/__tests__/sanitize.spec.ts create mode 100644 packages/mcp/src/registry/config-path.ts create mode 100644 packages/mcp/src/registry/config-persistence.ts create mode 100644 packages/mcp/src/registry/private-key-field.ts create mode 100644 packages/mcp/src/registry/private-key-files.ts create mode 100644 packages/mcp/src/tools/responses.ts diff --git a/packages/mcp/llms.txt b/packages/mcp/llms.txt index 946584788..894a630ad 100644 --- a/packages/mcp/llms.txt +++ b/packages/mcp/llms.txt @@ -282,5 +282,5 @@ Parameters: - Registry mode uses the local TON config file from `~/.config/ton/config.json` or `TON_CONFIG_PATH` - Agentic onboarding callback state is persisted in the local config; in stdio mode use `AGENTIC_CALLBACK_BASE_URL` and/or `AGENTIC_CALLBACK_PORT` when you need a stable callback endpoint across restarts - Registry management responses are sanitized and do not expose mnemonic, private keys, operator private keys, or Toncenter API keys -- Read tools can work with imported agentic wallets without `operator_private_key`; write tools cannot +- Read tools can work with imported agentic wallets without `private_key`; write tools cannot - **Default flow:** After any send, poll get_transaction_status until completed or failed. User can specify whether to check status. diff --git a/packages/mcp/skills/ton-manage-wallets/SKILL.md b/packages/mcp/skills/ton-manage-wallets/SKILL.md index 6066b51dd..66bed31ae 100644 --- a/packages/mcp/skills/ton-manage-wallets/SKILL.md +++ b/packages/mcp/skills/ton-manage-wallets/SKILL.md @@ -55,6 +55,6 @@ Manage the local wallet registry and perform advanced agentic wallet operations - Use available shell/browser tools to open dashboard URLs for the user whenever possible - For confirmations and small option sets, prefer the host client's structured confirmation/choice UI when available; otherwise use a short natural-language yes/no prompt and never require an exact magic word - Registry data is stored in `~/.config/ton/config.json` (or `TON_CONFIG_PATH`) -- Read tools work with imported agentic wallets that don't yet have an `operator_private_key`; write tools require it +- Read tools work with imported agentic wallets that don't yet have a `private_key`; write tools require it - Management tool responses never expose private keys, mnemonics, or API keys - To create a brand new agentic wallet, use the `ton-create-wallet` skill instead diff --git a/packages/mcp/src/__tests__/AgenticOnboardingService.spec.ts b/packages/mcp/src/__tests__/AgenticOnboardingService.spec.ts index ca5c7cb33..af833596c 100644 --- a/packages/mcp/src/__tests__/AgenticOnboardingService.spec.ts +++ b/packages/mcp/src/__tests__/AgenticOnboardingService.spec.ts @@ -17,7 +17,7 @@ describe('AgenticOnboardingService', () => { const pendingDeployment: PendingAgenticDeployment = { id: 'setup-1', network: 'mainnet', - operator_private_key: '0xpriv', + secret_file: '/tmp/setup-1.private-key', operator_public_key: '0xfeed', name: 'Agent Alpha', source: 'MCP flow', @@ -33,7 +33,7 @@ describe('AgenticOnboardingService', () => { network: 'mainnet', address: 'UQBynBO23ywHy_CgarY9NK9FTz0yDsG82PtcbSTQgGoXwnZF', owner_address: 'UQAcIXCxCd_gAqQ8RK0UA9vvlVA7wWjV41l2URKVxaMVLeM5', - operator_private_key: '0xpriv', + secret_file: '/tmp/agent-1.private-key', operator_public_key: '0xfeed', source: 'MCP flow', collection_address: 'EQByQ19qvWxW7VibSbGEgZiYMqilHY5y1a_eeSL2VaXhfy07', @@ -94,7 +94,7 @@ describe('AgenticOnboardingService', () => { expect(registry.createPendingAgenticSetup).toHaveBeenCalledWith({ network: 'mainnet', - operatorPrivateKey: expect.stringMatching(/^0x[0-9a-f]+$/i), + privateKey: expect.stringMatching(/^0x[0-9a-f]+$/i), operatorPublicKey: expect.stringMatching(/^0x[0-9a-f]+$/i), name: 'Agent Alpha', source: 'MCP flow', diff --git a/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts b/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts index dd1c47b22..e51db07ed 100644 --- a/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts +++ b/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts @@ -97,11 +97,11 @@ describe('AgenticSetupSessionManager', () => { await manager.createSession('setup-3'); manager.markCompleted('setup-3'); - expect(manager.getSession('setup-3')).toBeNull(); + expect(manager.getSession('setup-3')).toBeUndefined(); await manager.createSession('setup-4'); manager.cancelSession('setup-4'); - expect(manager.getSession('setup-4')).toBeNull(); + expect(manager.getSession('setup-4')).toBeUndefined(); }); it('restores persisted callback payloads and callback urls from config-backed store', async () => { diff --git a/packages/mcp/src/__tests__/WalletRegistryService.spec.ts b/packages/mcp/src/__tests__/WalletRegistryService.spec.ts index cc0971381..bebc6d698 100644 --- a/packages/mcp/src/__tests__/WalletRegistryService.spec.ts +++ b/packages/mcp/src/__tests__/WalletRegistryService.spec.ts @@ -6,9 +6,9 @@ * */ -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -49,9 +49,8 @@ import { createEmptyConfig, createPendingAgenticDeployment, createStandardWalletRecord, - loadConfig, - saveConfig, } from '../registry/config.js'; +import { loadConfig, saveConfig } from '../registry/config-persistence.js'; import { WalletRegistryService } from '../services/WalletRegistryService.js'; describe('WalletRegistryService', () => { @@ -61,6 +60,22 @@ describe('WalletRegistryService', () => { const originalConfigPath = process.env.TON_CONFIG_PATH; let tempDir = ''; + function resolveSecretPath(filePath: string): string { + return resolve(dirname(process.env.TON_CONFIG_PATH!), filePath); + } + + function walletMnemonic(id: string, mnemonic: string) { + return { wallets: { [id]: { mnemonic } } }; + } + + function walletPrivateKey(id: string, privateKey: string) { + return { wallets: { [id]: { private_key: privateKey } } }; + } + + function pendingDeploymentSecret(id: string, privateKey: string) { + return { pendingAgenticDeployments: { [id]: privateKey } }; + } + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'ton-mcp-wallet-registry-')); process.env.TON_CONFIG_PATH = join(tempDir, 'config.json'); @@ -137,18 +152,20 @@ describe('WalletRegistryService', () => { network: 'mainnet', walletVersion: 'v5r1', address: mainAddress, - mnemonic: 'abandon '.repeat(23) + 'about', }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: standard.id, - wallets: [standard], - networks: { - mainnet: { - toncenter_api_key: 'main-key', + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: standard.id, + wallets: [standard], + networks: { + mainnet: { + toncenter_api_key: 'main-key', + }, }, }, - }); + walletMnemonic(standard.id, 'abandon '.repeat(23) + 'about'), + ); const close = vi.fn(); mocks.createMcpWalletServiceFromStoredWallet.mockResolvedValue({ @@ -169,19 +186,63 @@ describe('WalletRegistryService', () => { }); }); + it('supports inline v2 secrets before creating a signing service', async () => { + writeFileSync( + process.env.TON_CONFIG_PATH!, + JSON.stringify({ + version: 2, + active_wallet_id: 'wallet-1', + networks: {}, + wallets: [ + { + id: 'wallet-1', + type: 'standard', + name: 'Inline standard', + network: 'mainnet', + wallet_version: 'v5r1', + address: mainAddress, + mnemonic: 'abandon '.repeat(23) + 'about', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + }), + 'utf-8', + ); + mocks.createMcpWalletServiceFromStoredWallet.mockResolvedValue({ + service: { getAddress: () => mainAddress }, + close: vi.fn(), + }); + + const registry = new WalletRegistryService(); + await registry.createWalletService(undefined, { requiresSigning: true }); + + expect(mocks.createMcpWalletServiceFromStoredWallet).toHaveBeenCalledWith({ + wallet: expect.objectContaining({ + id: 'wallet-1', + secret_type: 'mnemonic', + }), + contacts: undefined, + toncenterApiKey: undefined, + requiresSigning: true, + }); + }); + it('uses external network overrides when config does not have a toncenter api key', async () => { const standard = createStandardWalletRecord({ name: 'Primary wallet', network: 'mainnet', walletVersion: 'v5r1', address: mainAddress, - mnemonic: 'abandon '.repeat(23) + 'about', - }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: standard.id, - wallets: [standard], }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: standard.id, + wallets: [standard], + }, + walletMnemonic(standard.id, 'abandon '.repeat(23) + 'about'), + ); mocks.createMcpWalletServiceFromStoredWallet.mockResolvedValue({ service: { getAddress: () => standard.address }, @@ -203,34 +264,30 @@ describe('WalletRegistryService', () => { }); }); - it('rejects write-mode access for agentic wallets without operator key', async () => { - const wallet = createAgenticWalletRecord({ - name: 'Read-only agent', - network: 'mainnet', - address: agentAddress, - ownerAddress, - }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: wallet.id, - wallets: [wallet], - }); - - const registry = new WalletRegistryService(); - - await expect(registry.createWalletService(undefined, { requiresSigning: true })).rejects.toThrow( - /missing operator_private_key/i, - ); - expect(mocks.createMcpWalletServiceFromStoredWallet).not.toHaveBeenCalled(); - }); - - it('rejects write contexts for agentic wallets without operator key', async () => { - const wallet = createAgenticWalletRecord({ - name: 'Read-only agent', - network: 'mainnet', - address: agentAddress, - ownerAddress, - }); + it.each([ + { + name: 'agentic wallets without operator key', + wallet: createAgenticWalletRecord({ + name: 'Read-only agent', + network: 'mainnet', + address: agentAddress, + ownerAddress, + }), + expectedError: /missing private_key/i, + }, + { + name: 'standard wallets with an unreadable secret_file', + wallet: createStandardWalletRecord({ + name: 'Broken standard', + network: 'mainnet', + walletVersion: 'v5r1', + address: agentAddress, + secretFile: 'private-keys/missing.private-key', + secretType: 'private_key', + }), + expectedError: /missing signing credentials/i, + }, + ])('rejects write-mode access for $name', async ({ wallet, expectedError }) => { saveConfig({ ...createEmptyConfig(), active_wallet_id: wallet.id, @@ -239,24 +296,24 @@ describe('WalletRegistryService', () => { const registry = new WalletRegistryService(); - await expect(registry.createWalletService(undefined, { requiresSigning: true })).rejects.toThrow( - /missing operator_private_key/i, - ); + await expect(registry.createWalletService(undefined, { requiresSigning: true })).rejects.toThrow(expectedError); expect(mocks.createMcpWalletServiceFromStoredWallet).not.toHaveBeenCalled(); }); it('validates and imports an agentic wallet while recovering operator key from pending setup', async () => { const pending = createPendingAgenticDeployment({ network: 'mainnet', - operatorPrivateKey: '0xpending', operatorPublicKey: '0xbeef', name: 'Pending root agent', source: 'Started from MCP', }); - saveConfig({ - ...createEmptyConfig(), - pending_agentic_deployments: [pending], - }); + saveConfig( + { + ...createEmptyConfig(), + pending_agentic_deployments: [pending], + }, + pendingDeploymentSecret(pending.id, '0xpending'), + ); mocks.validateAgenticWalletAddress.mockResolvedValue({ address: agentAddress, balanceNano: '42', @@ -281,28 +338,83 @@ describe('WalletRegistryService', () => { expect(result.wallet).toMatchObject({ type: 'agentic', name: 'Pending root agent', - operator_private_key: '0xpending', + secret_file: expect.any(String), operator_public_key: '0xbeef', source: 'Started from MCP', }); const stored = loadConfig(); expect(stored?.active_wallet_id).toBe(result.wallet.id); - expect(stored?.pending_agentic_deployments).toBeUndefined(); + expect(stored?.pending_agentic_deployments).toEqual([]); + }); + + it('imports an agentic wallet read-only when a reused secret does not match the current on-chain operator key', async () => { + const wallet = createAgenticWalletRecord({ + name: 'Existing agent', + network: 'mainnet', + address: agentAddress, + ownerAddress, + operatorPublicKey: '0xold-public', + }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: wallet.id, + wallets: [wallet], + }, + walletPrivateKey(wallet.id, '0xold-private'), + ); + const oldSecretPath = resolveSecretPath((loadConfig()?.wallets[0] as { secret_file: string }).secret_file); + mocks.validateAgenticWalletAddress.mockResolvedValue({ + address: agentAddress, + balanceNano: '42', + balanceTon: '0.000000042', + ownerAddress, + operatorPublicKey: '0xnew-public', + originOperatorPublicKey: '0x1234', + collectionAddress: ownerAddress, + deployedByUser: true, + name: 'On-chain agent', + }); + mocks.resolveOperatorCredentials.mockImplementationOnce(async () => { + throw new Error('Private key does not match the current agent operator public key.'); + }); + + const registry = new WalletRegistryService(); + const result = await registry.importAgenticWallet({ + address: agentAddress, + network: 'mainnet', + }); + + expect(result.recoveredPendingKeyDraft).toBe(false); + expect(result.updatedExisting).toBe(true); + expect(result.wallet).toMatchObject({ + id: wallet.id, + operator_public_key: '0xnew-public', + }); + expect(result.wallet).not.toHaveProperty('secret_file'); + expect(loadConfig()?.wallets[0]).toMatchObject({ + id: wallet.id, + operator_public_key: '0xnew-public', + }); + expect(loadConfig()?.wallets[0]).not.toHaveProperty('secret_file'); + expect(existsSync(oldSecretPath)).toBe(false); }); it('completes a pending root-agent setup and makes the imported wallet active', async () => { const pending = createPendingAgenticDeployment({ network: 'mainnet', - operatorPrivateKey: '0xpending', operatorPublicKey: '0xbeef', name: 'Pending agent', source: 'Pending source', }); - saveConfig({ - ...createEmptyConfig(), - pending_agentic_deployments: [pending], - }); + saveConfig( + { + ...createEmptyConfig(), + pending_agentic_deployments: [pending], + }, + pendingDeploymentSecret(pending.id, '0xpending'), + ); const registry = new WalletRegistryService(); const wallet = await registry.completePendingAgenticSetup({ @@ -325,7 +437,7 @@ describe('WalletRegistryService', () => { expect(wallet).toMatchObject({ type: 'agentic', name: 'Imported root agent', - operator_private_key: '0xpending', + secret_file: expect.any(String), source: 'Completed from callback', deployed_by_user: true, }); @@ -333,7 +445,7 @@ describe('WalletRegistryService', () => { const stored = loadConfig(); expect(stored?.active_wallet_id).toBe(wallet.id); expect(stored?.wallets).toEqual([expect.objectContaining({ id: wallet.id })]); - expect(stored?.pending_agentic_deployments).toBeUndefined(); + expect(stored?.pending_agentic_deployments).toEqual([]); }); it('starts an agentic key rotation and stores only a pending draft until completion', async () => { @@ -342,15 +454,17 @@ describe('WalletRegistryService', () => { network: 'mainnet', address: agentAddress, ownerAddress, - operatorPrivateKey: '0xold-private', operatorPublicKey: '0xold-public', collectionAddress: ownerAddress, }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: wallet.id, - wallets: [wallet], - }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: wallet.id, + wallets: [wallet], + }, + walletPrivateKey(wallet.id, '0xold-private'), + ); const registry = new WalletRegistryService(); const result = await registry.startAgenticKeyRotation({}); @@ -360,24 +474,36 @@ describe('WalletRegistryService', () => { expect(result.dashboardUrl).toContain('action=change-public-key'); expect(result.pendingRotation).toMatchObject({ wallet_id: wallet.id, - operator_private_key: '0xgenerated-private', + secret_file: expect.any(String), operator_public_key: '0xgenerated-public', }); const stored = loadConfig(); expect(stored?.wallets[0]).toMatchObject({ id: wallet.id, - operator_private_key: '0xold-private', + secret_file: expect.any(String), operator_public_key: '0xold-public', }); + expect( + readFileSync( + resolveSecretPath((stored?.wallets[0] as { secret_file: string }).secret_file), + 'utf-8', + ).trim(), + ).toBe('0xold-private'); expect(stored?.pending_agentic_key_rotations).toEqual([ expect.objectContaining({ id: result.pendingRotation.id, wallet_id: wallet.id, - operator_private_key: '0xgenerated-private', + secret_file: expect.any(String), operator_public_key: '0xgenerated-public', }), ]); + expect( + readFileSync( + resolveSecretPath((stored?.pending_agentic_key_rotations?.[0] as { secret_file: string }).secret_file), + 'utf-8', + ).trim(), + ).toBe('0xgenerated-private'); }); it('completes an agentic key rotation after the on-chain operator public key changes', async () => { @@ -386,20 +512,26 @@ describe('WalletRegistryService', () => { network: 'mainnet', address: agentAddress, ownerAddress, - operatorPrivateKey: '0xold-private', operatorPublicKey: '0xold-public', collectionAddress: ownerAddress, }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: wallet.id, - wallets: [wallet], - }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: wallet.id, + wallets: [wallet], + }, + walletPrivateKey(wallet.id, '0xold-private'), + ); const registry = new WalletRegistryService(); const started = await registry.startAgenticKeyRotation({ walletSelector: wallet.id, }); + const oldSecretPath = resolveSecretPath((loadConfig()?.wallets[0] as { secret_file: string }).secret_file); + const pendingSecretPath = resolveSecretPath( + (loadConfig()?.pending_agentic_key_rotations?.[0] as { secret_file: string }).secret_file, + ); mocks.validateAgenticWalletAddress.mockResolvedValue({ address: agentAddress, balanceNano: '42', @@ -416,11 +548,16 @@ describe('WalletRegistryService', () => { expect(completed.wallet).toMatchObject({ id: wallet.id, - operator_private_key: '0xgenerated-private', + secret_file: expect.any(String), operator_public_key: '0xgenerated-public', }); expect(completed.dashboardUrl).toBe(`https://dashboard.test/agent/${wallet.address}`); - expect(loadConfig()?.pending_agentic_key_rotations).toBeUndefined(); + expect(loadConfig()?.pending_agentic_key_rotations).toEqual([]); + expect(resolveSecretPath((loadConfig()?.wallets[0] as { secret_file: string }).secret_file)).toBe( + pendingSecretPath, + ); + expect(existsSync(oldSecretPath)).toBe(false); + expect(existsSync(pendingSecretPath)).toBe(true); }); it('rejects agentic key rotation completion when the on-chain operator public key does not match', async () => { @@ -429,20 +566,22 @@ describe('WalletRegistryService', () => { network: 'mainnet', address: agentAddress, ownerAddress, - operatorPrivateKey: '0xold-private', operatorPublicKey: '0xold-public', collectionAddress: ownerAddress, }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: wallet.id, - wallets: [wallet], - }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: wallet.id, + wallets: [wallet], + }, + walletPrivateKey(wallet.id, '0xold-private'), + ); const registry = new WalletRegistryService(); const started = await registry.startAgenticKeyRotation({ walletSelector: wallet.id, - operatorPrivateKey: '0xmanual-private', + privateKey: '0xmanual-private', }); mocks.validateAgenticWalletAddress.mockResolvedValue({ address: agentAddress, @@ -461,62 +600,51 @@ describe('WalletRegistryService', () => { expect(loadConfig()?.pending_agentic_key_rotations).toHaveLength(1); }); - it('rejects pending root-agent completion when operator public key does not match the pending setup', async () => { - const pending = createPendingAgenticDeployment({ - network: 'mainnet', - operatorPrivateKey: '0xpending', - operatorPublicKey: '0xbeef', - name: 'Pending agent', - }); - saveConfig({ - ...createEmptyConfig(), - pending_agentic_deployments: [pending], - }); - - const registry = new WalletRegistryService(); - - await expect( - registry.completePendingAgenticSetup({ - setupId: pending.id, - validatedWallet: { - address: agentAddress, - balanceNano: '100', - balanceTon: '0.0000001', - ownerAddress, - operatorPublicKey: '0xdead', - collectionAddress: ownerAddress, - deployedByUser: true, - }, - }), - ).rejects.toThrow(/pending operator key does not match/i); - }); - - it('rejects completion when the validated wallet operator key does not match the pending setup', async () => { + it.each([ + { + name: 'without origin operator public key', + validatedWallet: { + address: agentAddress, + balanceNano: '100', + balanceTon: '0.0000001', + ownerAddress, + operatorPublicKey: '0xdead', + collectionAddress: ownerAddress, + deployedByUser: true, + }, + }, + { + name: 'with origin operator public key', + validatedWallet: { + address: agentAddress, + balanceNano: '100', + balanceTon: '0.0000001', + ownerAddress, + operatorPublicKey: '0xdead', + originOperatorPublicKey: '0xfeed', + collectionAddress: ownerAddress, + deployedByUser: true, + }, + }, + ])('rejects pending root-agent completion on operator key mismatch $name', async ({ validatedWallet }) => { const pending = createPendingAgenticDeployment({ network: 'mainnet', - operatorPrivateKey: '0xpending', operatorPublicKey: '0xbeef', }); - saveConfig({ - ...createEmptyConfig(), - pending_agentic_deployments: [pending], - }); + saveConfig( + { + ...createEmptyConfig(), + pending_agentic_deployments: [pending], + }, + pendingDeploymentSecret(pending.id, '0xpending'), + ); const registry = new WalletRegistryService(); await expect( registry.completePendingAgenticSetup({ setupId: pending.id, - validatedWallet: { - address: agentAddress, - balanceNano: '100', - balanceTon: '0.0000001', - ownerAddress, - operatorPublicKey: '0xdead', - originOperatorPublicKey: '0xfeed', - collectionAddress: ownerAddress, - deployedByUser: true, - }, + validatedWallet, }), ).rejects.toThrow(/pending operator key does not match/i); }); diff --git a/packages/mcp/src/__tests__/config.spec.ts b/packages/mcp/src/__tests__/config.spec.ts index 26cadb4b9..e50dd4c8c 100644 --- a/packages/mcp/src/__tests__/config.spec.ts +++ b/packages/mcp/src/__tests__/config.spec.ts @@ -6,15 +6,16 @@ * */ -import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ConfigError, + CURRENT_TON_CONFIG_VERSION, DEFAULT_AGENTIC_COLLECTION_ADDRESS, createAgenticWalletRecord, createEmptyConfig, @@ -23,22 +24,38 @@ import { findWallet, getActiveWallet, getAgenticCollectionAddress, - listPendingAgenticDeployments, - loadConfig, - loadConfigWithMigration, removePendingAgenticDeployment, removeWallet, - saveConfig, setActiveWallet, upsertPendingAgenticDeployment, upsertWallet, } from '../registry/config.js'; +import { + deleteConfig, + loadConfig, + loadConfigWithMigration, + saveConfig, + saveConfigTransition, +} from '../registry/config-persistence.js'; +import { LEGACY_AGENTIC_PRIVATE_KEY_FIELD } from '../registry/private-key-field.js'; describe('mcp config registry', () => { const baseAddress = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; const originalConfigPath = process.env.TON_CONFIG_PATH; let tempDir = ''; + function resolveSecretPath(filePath: string): string { + return resolve(dirname(process.env.TON_CONFIG_PATH!), filePath); + } + + function walletSecrets(id: string, secret: { mnemonic?: string; private_key?: string }) { + return { wallets: { [id]: secret } }; + } + + function pendingDeploymentSecrets(id: string, privateKey: string) { + return { pendingAgenticDeployments: { [id]: privateKey } }; + } + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'ton-mcp-config-')); process.env.TON_CONFIG_PATH = join(tempDir, 'config.json'); @@ -60,22 +77,34 @@ describe('mcp config registry', () => { network: 'mainnet', walletVersion: 'v5r1', address: baseAddress, - mnemonic: 'a '.repeat(24).trim(), }); const config = upsertWallet(createEmptyConfig(), standard, { setActive: true }); - saveConfig(config); + saveConfig(config, walletSecrets(standard.id, { mnemonic: 'a '.repeat(24).trim() })); const loaded = loadConfig(); - expect(loaded?.version).toBe(2); + expect(loaded?.version).toBe(CURRENT_TON_CONFIG_VERSION); expect(loaded?.wallets).toHaveLength(1); expect(loaded?.active_wallet_id).toBe(standard.id); + expect(loaded?.wallets[0]).toMatchObject({ + secret_file: expect.any(String), + secret_type: 'mnemonic', + }); + expect( + readFileSync( + loaded?.wallets[0]?.type === 'standard' ? resolveSecretPath(loaded.wallets[0].secret_file!) : '', + 'utf-8', + ).trim(), + ).toBe('a '.repeat(24).trim()); const fileMode = statSync(process.env.TON_CONFIG_PATH!).mode & 0o777; expect(fileMode).toBe(0o600); + const mnemonicFileMode = + statSync(resolveSecretPath((loaded?.wallets[0] as { secret_file: string }).secret_file)).mode & 0o777; + expect(mnemonicFileMode).toBe(0o600); }); - it('migrates legacy config payloads to v2 on first read', async () => { + it('migrates legacy config payloads to the current version on first read', async () => { writeFileSync( process.env.TON_CONFIG_PATH!, JSON.stringify({ @@ -88,12 +117,246 @@ describe('mcp config registry', () => { ); const migrated = await loadConfigWithMigration(); - expect(migrated?.version).toBe(2); + expect(migrated?.version).toBe(CURRENT_TON_CONFIG_VERSION); expect(migrated?.wallets).toHaveLength(1); expect(migrated?.wallets[0]?.name).toBe('Migrated wallet'); expect(migrated?.wallets[0]?.type).toBe('standard'); expect(migrated?.wallets[0]?.network).toBe('testnet'); expect(migrated?.networks.testnet?.toncenter_api_key).toBe('legacy-key'); + expect(migrated?.wallets[0]).toMatchObject({ + secret_file: expect.any(String), + secret_type: 'mnemonic', + }); + }); + + it('reads inline secrets from v2 payloads without writing files on load', () => { + writeFileSync( + process.env.TON_CONFIG_PATH!, + JSON.stringify({ + version: 2, + active_wallet_id: 'wallet-1', + networks: {}, + wallets: [ + { + id: 'wallet-1', + type: 'standard', + name: 'Inline standard', + network: 'mainnet', + wallet_version: 'v5r1', + address: baseAddress, + mnemonic: 'abandon '.repeat(23) + 'about', + private_key: '0x' + '11'.repeat(32), + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + { + id: 'wallet-2', + type: 'agentic', + name: 'Inline agent', + network: 'mainnet', + address: baseAddress, + owner_address: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + [LEGACY_AGENTIC_PRIVATE_KEY_FIELD]: '0x' + '22'.repeat(32), + operator_public_key: '0xbeef', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + pending_agentic_deployments: [ + { + id: 'setup-1', + network: 'mainnet', + [LEGACY_AGENTIC_PRIVATE_KEY_FIELD]: '0x' + '33'.repeat(32), + operator_public_key: '0xcafe', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + pending_agentic_key_rotations: [ + { + id: 'rotation-1', + wallet_id: 'wallet-2', + network: 'mainnet', + wallet_address: baseAddress, + owner_address: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + [LEGACY_AGENTIC_PRIVATE_KEY_FIELD]: '0x' + '44'.repeat(32), + operator_public_key: '0xfade', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + }), + 'utf-8', + ); + + const loaded = loadConfig(); + expect(loaded?.version).toBe(CURRENT_TON_CONFIG_VERSION); + expect(loaded?.wallets[0]).toMatchObject({ + mnemonic: 'abandon '.repeat(23) + 'about', + secret_type: 'mnemonic', + }); + expect(loaded?.wallets[1]).toMatchObject({ + private_key: '0x' + '22'.repeat(32), + }); + expect(loaded?.pending_agentic_deployments?.[0]).toMatchObject({ + private_key: '0x' + '33'.repeat(32), + }); + expect(loaded?.pending_agentic_key_rotations?.[0]).toMatchObject({ + private_key: '0x' + '44'.repeat(32), + }); + expect(loaded?.wallets[1]).not.toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + expect(loaded?.pending_agentic_deployments?.[0]).not.toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + expect(loaded?.pending_agentic_key_rotations?.[0]).not.toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + expect(loaded?.wallets[1]).not.toHaveProperty('secret_type'); + expect(loaded?.pending_agentic_deployments?.[0]).not.toHaveProperty('secret_type'); + expect(loaded?.pending_agentic_key_rotations?.[0]).not.toHaveProperty('secret_type'); + expect(loaded?.wallets[0]).not.toHaveProperty('secret_file'); + expect(loaded?.wallets[1]).not.toHaveProperty('secret_file'); + expect(loaded?.pending_agentic_deployments?.[0]).not.toHaveProperty('secret_file'); + expect(loaded?.pending_agentic_key_rotations?.[0]).not.toHaveProperty('secret_file'); + + const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')) as Record; + expect(persisted.version).toBe(2); + const wallets = persisted.wallets as Array>; + const deployments = persisted.pending_agentic_deployments as Array>; + const rotations = persisted.pending_agentic_key_rotations as Array>; + expect(wallets[0]).toHaveProperty('mnemonic'); + expect(wallets[1]).toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + expect(deployments[0]).toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + expect(rotations[0]).toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + }); + + it('upgrades a v2 config file to the current version on migration load', async () => { + writeFileSync( + process.env.TON_CONFIG_PATH!, + JSON.stringify({ + version: 2, + active_wallet_id: 'wallet-1', + networks: {}, + wallets: [ + { + id: 'wallet-1', + type: 'standard', + name: 'Inline standard', + network: 'mainnet', + wallet_version: 'v5r1', + address: baseAddress, + mnemonic: 'abandon '.repeat(23) + 'about', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + }), + 'utf-8', + ); + + const loaded = await loadConfigWithMigration(); + expect(loaded?.version).toBe(CURRENT_TON_CONFIG_VERSION); + + const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')) as Record; + expect(persisted.version).toBe(CURRENT_TON_CONFIG_VERSION); + expect((persisted.wallets as Array>)[0]).not.toHaveProperty('mnemonic'); + expect((persisted.wallets as Array>)[0]).toHaveProperty('secret_file'); + }); + + it('materializes inline secrets from loaded v2 configs on explicit save', () => { + writeFileSync( + process.env.TON_CONFIG_PATH!, + JSON.stringify({ + version: 2, + active_wallet_id: 'wallet-1', + networks: {}, + wallets: [ + { + id: 'wallet-1', + type: 'standard', + name: 'Inline standard', + network: 'mainnet', + wallet_version: 'v5r1', + address: baseAddress, + mnemonic: 'abandon '.repeat(23) + 'about', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + { + id: 'wallet-2', + type: 'agentic', + name: 'Inline agent', + network: 'mainnet', + address: baseAddress, + owner_address: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + private_key: '0x' + '22'.repeat(32), + operator_public_key: '0xbeef', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + pending_agentic_deployments: [ + { + id: 'setup-1', + network: 'mainnet', + private_key: '0x' + '33'.repeat(32), + operator_public_key: '0xcafe', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + pending_agentic_key_rotations: [ + { + id: 'rotation-1', + wallet_id: 'wallet-2', + network: 'mainnet', + wallet_address: baseAddress, + owner_address: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + private_key: '0x' + '44'.repeat(32), + operator_public_key: '0xfade', + created_at: '2026-03-16T00:00:00.000Z', + updated_at: '2026-03-16T00:00:00.000Z', + }, + ], + }), + 'utf-8', + ); + + const loaded = loadConfig()!; + const saved = saveConfigTransition(loaded, loaded); + + expect(saved.version).toBe(CURRENT_TON_CONFIG_VERSION); + expect(saved.wallets[0]).toMatchObject({ + secret_file: expect.any(String), + secret_type: 'mnemonic', + }); + expect(saved.wallets[1]).toMatchObject({ + secret_file: expect.any(String), + }); + expect(saved.wallets[1]).not.toHaveProperty('secret_type'); + expect(saved.pending_agentic_deployments?.[0]).toMatchObject({ + secret_file: expect.any(String), + }); + expect(saved.pending_agentic_deployments?.[0]).not.toHaveProperty('secret_type'); + expect(saved.pending_agentic_key_rotations?.[0]).toMatchObject({ + secret_file: expect.any(String), + }); + expect(saved.pending_agentic_key_rotations?.[0]).not.toHaveProperty('secret_type'); + expect( + readFileSync(resolveSecretPath((saved.wallets[0] as { secret_file: string }).secret_file), 'utf-8').trim(), + ).toBe('abandon '.repeat(23) + 'about'); + expect( + readFileSync( + resolveSecretPath((saved.pending_agentic_key_rotations?.[0] as { secret_file: string }).secret_file), + 'utf-8', + ).trim(), + ).toBe('0x' + '44'.repeat(32)); + + const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')) as Record; + const wallets = persisted.wallets as Array>; + const deployments = persisted.pending_agentic_deployments as Array>; + const rotations = persisted.pending_agentic_key_rotations as Array>; + expect(wallets[0]).not.toHaveProperty('mnemonic'); + expect(wallets[0]).not.toHaveProperty('private_key'); + expect(wallets[1]).not.toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + expect(deployments[0]).not.toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); + expect(rotations[0]).not.toHaveProperty(LEGACY_AGENTIC_PRIVATE_KEY_FIELD); }); it('selects by explicit wallet selector then active wallet', () => { @@ -102,7 +365,6 @@ describe('mcp config registry', () => { network: 'mainnet', walletVersion: 'v5r1', address: baseAddress, - mnemonic: 'a '.repeat(24).trim(), }); const second = createAgenticWalletRecord({ name: 'Agent wallet', @@ -140,7 +402,7 @@ describe('mcp config registry', () => { const removed = removeWallet(config, first.id); expect(removed.removed).toMatchObject({ id: first.id, removed: true }); - expect(findWallet(removed.config, first.id)).toBeNull(); + expect(findWallet(removed.config, first.id)).toBeUndefined(); expect(getActiveWallet(removed.config)?.id).toBe(second.id); expect(removed.config.wallets).toEqual([ expect.objectContaining({ id: first.id, removed: true }), @@ -175,37 +437,162 @@ describe('mcp config registry', () => { const draft = createPendingAgenticDeployment({ name: 'Pending agent', network: 'testnet', - operatorPrivateKey: '0x1111', operatorPublicKey: '0xabcd', source: 'Draft source', collectionAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS, }); - const config = upsertPendingAgenticDeployment(createEmptyConfig(), draft); - - saveConfig(config); + saveConfig( + { + ...createEmptyConfig(), + pending_agentic_deployments: [draft], + }, + pendingDeploymentSecrets(draft.id, '0x1111'), + ); const loaded = loadConfig(); - expect(listPendingAgenticDeployments(loaded ?? createEmptyConfig())).toEqual([ + expect((loaded ?? createEmptyConfig()).pending_agentic_deployments).toEqual([ expect.objectContaining({ id: draft.id, name: 'Pending agent', network: 'testnet', - operator_private_key: '0x1111', + secret_file: expect.any(String), operator_public_key: '0xabcd', source: 'Draft source', }), ]); + expect( + readFileSync( + resolveSecretPath( + ((loaded ?? createEmptyConfig()).pending_agentic_deployments[0] as { secret_file: string }) + .secret_file, + ), + 'utf-8', + ).trim(), + ).toBe('0x1111'); + }); + + it('stores generated secret file paths relative to the config directory', () => { + const standard = createStandardWalletRecord({ + name: 'Primary wallet', + network: 'mainnet', + walletVersion: 'v5r1', + address: baseAddress, + }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: standard.id, + wallets: [standard], + }, + walletSecrets(standard.id, { mnemonic: 'abandon '.repeat(23) + 'about' }), + ); + + const loaded = loadConfig(); + expect((loaded?.wallets[0] as { secret_file: string }).secret_file).toBe( + `private-keys/wallets/${standard.id}.mnemonic`, + ); }); it('removes pending drafts by id', () => { const draft = createPendingAgenticDeployment({ network: 'mainnet', - operatorPrivateKey: '0x2222', operatorPublicKey: '0xbeef', }); - const config = upsertPendingAgenticDeployment(createEmptyConfig(), draft); - const nextConfig = removePendingAgenticDeployment(config, { id: draft.id }); - expect(listPendingAgenticDeployments(nextConfig)).toEqual([]); + const saved = saveConfigTransition( + createEmptyConfig(), + upsertPendingAgenticDeployment(createEmptyConfig(), draft), + pendingDeploymentSecrets(draft.id, '0x2222'), + ); + const secretPath = resolveSecretPath( + (saved.pending_agentic_deployments?.[0] as { secret_file: string }).secret_file, + ); + const nextConfig = removePendingAgenticDeployment(saved, { id: draft.id }); + saveConfigTransition(saved, nextConfig); + expect(nextConfig.pending_agentic_deployments).toEqual([]); + expect(existsSync(secretPath)).toBe(false); + }); + + it('removes wallet secret files when deleting wallets and config', () => { + const standard = createStandardWalletRecord({ + name: 'Primary wallet', + network: 'mainnet', + walletVersion: 'v5r1', + address: baseAddress, + }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: standard.id, + wallets: [standard], + }, + walletSecrets(standard.id, { mnemonic: 'abandon '.repeat(23) + 'about' }), + ); + + const loaded = loadConfig(); + const secretPath = resolveSecretPath((loaded?.wallets[0] as { secret_file: string }).secret_file); + const removed = removeWallet(loaded ?? createEmptyConfig(), standard.id); + saveConfigTransition(loaded ?? createEmptyConfig(), removed.config); + + expect(existsSync(secretPath)).toBe(false); + + const other = createStandardWalletRecord({ + name: 'Secondary wallet', + network: 'mainnet', + walletVersion: 'v5r1', + address: baseAddress, + }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: other.id, + wallets: [other], + }, + walletSecrets(other.id, { + mnemonic: 'legal winner thank year wave sausage worth useful legal winner thank yellow', + }), + ); + + const reloaded = loadConfig(); + const otherSecretPath = resolveSecretPath((reloaded?.wallets[0] as { secret_file: string }).secret_file); + expect(deleteConfig()).toBe(true); + expect(existsSync(process.env.TON_CONFIG_PATH!)).toBe(false); + expect(existsSync(otherSecretPath)).toBe(false); + }); + + it('does not delete old secret files when config write fails', () => { + const standard = createStandardWalletRecord({ + name: 'Primary wallet', + network: 'mainnet', + walletVersion: 'v5r1', + address: baseAddress, + }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: standard.id, + wallets: [standard], + }, + walletSecrets(standard.id, { mnemonic: 'abandon '.repeat(23) + 'about' }), + ); + + const loaded = loadConfig(); + const secretPath = resolveSecretPath((loaded?.wallets[0] as { secret_file: string }).secret_file); + chmodSync(process.env.TON_CONFIG_PATH!, 0o400); + + try { + expect(() => + saveConfigTransition( + loaded ?? createEmptyConfig(), + removeWallet(loaded ?? createEmptyConfig(), standard.id).config, + ), + ).toThrow(); + expect(existsSync(secretPath)).toBe(true); + expect(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')).toContain( + (loaded?.wallets[0] as { id: string }).id, + ); + } finally { + chmodSync(process.env.TON_CONFIG_PATH!, 0o600); + } }); it('throws for unsupported config version', () => { @@ -237,7 +624,7 @@ describe('mcp config registry compatibility with real CLI config', () => { it.skipIf(!existsSync(realConfigPath))('loads the real user config without migration errors', async () => { process.env.TON_CONFIG_PATH = realConfigPath; const config = await loadConfigWithMigration(); - expect(config?.version).toBe(2); + expect(config?.version).toBe(CURRENT_TON_CONFIG_VERSION); expect(config?.wallets.length ?? 0).toBeGreaterThan(0); }); }); diff --git a/packages/mcp/src/__tests__/factory.spec.ts b/packages/mcp/src/__tests__/factory.spec.ts index e8da144f9..ed7cc667c 100644 --- a/packages/mcp/src/__tests__/factory.spec.ts +++ b/packages/mcp/src/__tests__/factory.spec.ts @@ -25,7 +25,8 @@ vi.mock('../runtime/wallet-runtime.js', () => ({ deriveStandardWalletAddress: mocks.deriveStandardWalletAddress, })); -import { createStandardWalletRecord, createEmptyConfig, saveConfig } from '../registry/config.js'; +import { createStandardWalletRecord, createEmptyConfig } from '../registry/config.js'; +import { saveConfig } from '../registry/config-persistence.js'; import { AgenticWalletAdapter } from '../contracts/agentic_wallet/AgenticWalletAdapter.js'; import { createTonWalletMCP } from '../factory.js'; import { createApiClient } from '../utils/ton-client.js'; @@ -221,9 +222,9 @@ describe('createTonWalletMCP registry mode', () => { expect(String(started.dashboardUrl)).toContain('/create?'); expect(String(started.callbackUrl)).toContain('/agentic/callback/'); expect(started.pendingDeployment).toMatchObject({ - has_operator_private_key: true, + has_private_key: true, }); - expect(started.pendingDeployment).not.toHaveProperty('operator_private_key'); + expect(started.pendingDeployment).not.toHaveProperty('private_key'); const pending = parseToolResult( await client.callTool({ @@ -236,9 +237,9 @@ describe('createTonWalletMCP registry mode', () => { count: 1, }); expect(pending.setups[0]?.pendingDeployment).toMatchObject({ - has_operator_private_key: true, + has_private_key: true, }); - expect(pending.setups[0]?.pendingDeployment).not.toHaveProperty('operator_private_key'); + expect(pending.setups[0]?.pendingDeployment).not.toHaveProperty('private_key'); } finally { await client.close(); await server.close(); @@ -252,19 +253,20 @@ describe('createTonWalletMCP registry mode', () => { network: 'mainnet', walletVersion: 'v5r1', address: firstAddress, - mnemonic: 'abandon '.repeat(23) + 'about', - privateKey: '0x' + '11'.repeat(32), }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: wallet.id, - wallets: [wallet], - networks: { - mainnet: { - toncenter_api_key: 'super-secret-key', + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: wallet.id, + wallets: [wallet], + networks: { + mainnet: { + toncenter_api_key: 'super-secret-key', + }, }, }, - }); + { wallets: { [wallet.id]: { mnemonic: 'abandon '.repeat(23) + 'about' } } }, + ); const client = new Client({ name: 'mcp-test', version: '1.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -282,7 +284,7 @@ describe('createTonWalletMCP registry mode', () => { expect(listed.wallets[0]).toMatchObject({ id: wallet.id, has_mnemonic: true, - has_private_key: true, + has_private_key: false, }); expect(listed.wallets[0]).not.toHaveProperty('mnemonic'); expect(listed.wallets[0]).not.toHaveProperty('private_key'); @@ -290,7 +292,7 @@ describe('createTonWalletMCP registry mode', () => { expect(current.wallet).toMatchObject({ id: wallet.id, has_mnemonic: true, - has_private_key: true, + has_private_key: false, }); expect(current.wallet).not.toHaveProperty('mnemonic'); expect(current.wallet).not.toHaveProperty('private_key'); @@ -306,13 +308,15 @@ describe('createTonWalletMCP registry mode', () => { network: 'mainnet', walletVersion: 'v5r1', address: firstAddress, - mnemonic: 'abandon '.repeat(23) + 'about', - }); - saveConfig({ - ...createEmptyConfig(), - active_wallet_id: first.id, - wallets: [first], }); + saveConfig( + { + ...createEmptyConfig(), + active_wallet_id: first.id, + wallets: [first], + }, + { wallets: { [first.id]: { mnemonic: 'abandon '.repeat(23) + 'about' } } }, + ); const closeContext = vi.fn(); mocks.createMcpWalletServiceFromStoredWallet.mockImplementation(async ({ wallet }) => ({ @@ -386,7 +390,7 @@ describe('createTonWalletMCP registry mode', () => { }); expect(result.isError).toBe(true); const text = result.content[0] && 'text' in result.content[0] ? result.content[0].text : ''; - expect(text).toContain('operator_private_key'); + expect(text).toContain('private_key'); expect(mocks.createMcpWalletServiceFromStoredWallet).not.toHaveBeenCalled(); } finally { await client.close(); diff --git a/packages/mcp/src/__tests__/sanitize.spec.ts b/packages/mcp/src/__tests__/sanitize.spec.ts new file mode 100644 index 000000000..238861b00 --- /dev/null +++ b/packages/mcp/src/__tests__/sanitize.spec.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { describe, expect, it } from 'vitest'; + +import { + createAgenticWalletRecord, + createPendingAgenticDeployment, + createPendingAgenticKeyRotation, + createStandardWalletRecord, +} from '../registry/config.js'; +import { sanitizePrivateKeyBackedValue, sanitizeStoredWallet } from '../tools/sanitize.js'; + +describe('sanitize', () => { + const address = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; + const ownerAddress = 'EQByQ19qvWxW7VibSbGEgZiYMqilHY5y1a_eeSL2VaXhfy07'; + + it.each([ + { + name: 'standard wallets', + value: { + ...createStandardWalletRecord({ + name: 'Primary', + network: 'mainnet', + walletVersion: 'v5r1', + address, + }), + mnemonic: 'abandon '.repeat(23) + 'about', + }, + sanitize: sanitizeStoredWallet, + expected: { + has_mnemonic: true, + has_private_key: false, + }, + }, + { + name: 'agentic wallets', + value: { + ...createAgenticWalletRecord({ + name: 'Agent', + network: 'mainnet', + address, + ownerAddress, + operatorPublicKey: '0xbeef', + }), + private_key: '0x' + '11'.repeat(32), + }, + sanitize: sanitizeStoredWallet, + expected: { + has_private_key: true, + }, + }, + { + name: 'pending agentic deployments', + value: { + ...createPendingAgenticDeployment({ + network: 'mainnet', + operatorPublicKey: '0xcafe', + }), + private_key: '0x' + '22'.repeat(32), + }, + sanitize: sanitizePrivateKeyBackedValue, + expected: { + has_private_key: true, + }, + }, + { + name: 'pending agentic rotations', + value: { + ...createPendingAgenticKeyRotation({ + walletId: 'wallet-1', + network: 'mainnet', + walletAddress: address, + ownerAddress, + operatorPublicKey: '0xfade', + }), + private_key: '0x' + '33'.repeat(32), + }, + sanitize: sanitizePrivateKeyBackedValue, + expected: { + has_private_key: true, + }, + }, + ])('omits secret fields for $name', ({ value, sanitize, expected }) => { + const sanitized = sanitize(value); + + expect(sanitized).toMatchObject(expected); + expect(sanitized).not.toHaveProperty('mnemonic'); + expect(sanitized).not.toHaveProperty('private_key'); + expect(sanitized).not.toHaveProperty('secret_file'); + }); + + it('treats unreadable secret files as missing secrets', () => { + const standardWallet = { + ...createStandardWalletRecord({ + name: 'Broken primary', + network: 'mainnet', + walletVersion: 'v5r1', + address, + }), + secret_file: 'private-keys/wallets/missing.mnemonic', + secret_type: 'mnemonic' as const, + }; + const pendingDeployment = { + ...createPendingAgenticDeployment({ + network: 'mainnet', + operatorPublicKey: '0xcafe', + }), + secret_file: 'private-keys/pending-agentic-deployments/missing.private-key', + }; + + expect(sanitizeStoredWallet(standardWallet)).toMatchObject({ + has_mnemonic: false, + has_private_key: false, + }); + expect(sanitizePrivateKeyBackedValue(pendingDeployment)).toMatchObject({ + has_private_key: false, + }); + }); +}); diff --git a/packages/mcp/src/registry/config-path.ts b/packages/mcp/src/registry/config-path.ts new file mode 100644 index 000000000..b93522cb4 --- /dev/null +++ b/packages/mcp/src/registry/config-path.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { chmodSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const DEFAULT_CONFIG_FILE = join(homedir(), '.config', 'ton', 'config.json'); +const ENV_CONFIG_PATH = 'TON_CONFIG_PATH'; + +export function getConfigPath(): string { + return process.env[ENV_CONFIG_PATH]?.trim() || DEFAULT_CONFIG_FILE; +} + +export function getConfigDir(): string { + return dirname(getConfigPath()); +} + +export function chmodIfExists(path: string, mode: number): void { + try { + if (existsSync(path)) { + chmodSync(path, mode); + } + } catch { + // Best-effort only. + } +} diff --git a/packages/mcp/src/registry/config-persistence.ts b/packages/mcp/src/registry/config-persistence.ts new file mode 100644 index 000000000..ecf2ab200 --- /dev/null +++ b/packages/mcp/src/registry/config-persistence.ts @@ -0,0 +1,288 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; + +import { + MemoryStorageAdapter, + Network, + Signer, + TonWalletKit, + WalletV4R2Adapter, + WalletV5R1Adapter, +} from '@ton/walletkit'; + +import { ConfigError, createStandardWalletRecord, CURRENT_TON_CONFIG_VERSION, normalizeConfig } from './config.js'; +import type { StandardWalletVersion, TonConfig, TonConfigVersion, TonNetwork } from './config.js'; +import { + cleanupOrphanSecretFiles, + deleteAllSecretFiles, + materializeSecrets, + migrateFromV2Config, +} from './private-key-files.js'; +import type { SecretMaterializationInput } from './private-key-files.js'; +import { chmodIfExists, getConfigDir, getConfigPath } from './config-path.js'; +import { parsePrivateKeyInput } from '../utils/private-key.js'; +import { createApiClient } from '../utils/ton-client.js'; + +interface LegacyTonConfig { + mnemonic?: string; + private_key?: string; + network?: TonNetwork; + wallet_version?: StandardWalletVersion; + toncenter_api_key?: string; +} + +interface PreparedLoadedConfig { + config: TonConfig; + shouldPersist: boolean; + previousConfig: TonConfig | null; + secretInputs?: SecretMaterializationInput; +} + +function isSupportedConfigVersion(version: unknown): version is TonConfigVersion { + return version === 2 || version === CURRENT_TON_CONFIG_VERSION; +} + +function isLegacyConfig(raw: unknown): raw is LegacyTonConfig { + if (!raw || typeof raw !== 'object') { + return false; + } + + const candidate = raw as Record; + return ( + !('version' in candidate) && + ('mnemonic' in candidate || + 'private_key' in candidate || + 'network' in candidate || + 'wallet_version' in candidate || + 'toncenter_api_key' in candidate) + ); +} + +async function deriveLegacyWalletAddress(config: LegacyTonConfig): Promise { + if (!config.mnemonic && !config.private_key) { + throw new ConfigError('Legacy config does not contain mnemonic or private_key and cannot be migrated.'); + } + + const network = config.network === 'testnet' ? 'testnet' : 'mainnet'; + const walletVersion = config.wallet_version === 'v4r2' ? 'v4r2' : 'v5r1'; + const kit = new TonWalletKit({ + networks: { + [(network === 'testnet' ? Network.testnet() : Network.mainnet()).chainId]: { + apiClient: createApiClient(network, config.toncenter_api_key), + }, + }, + storage: new MemoryStorageAdapter(), + }); + await kit.waitForReady(); + + try { + const signer = config.mnemonic + ? await Signer.fromMnemonic(config.mnemonic.trim().split(/\s+/), { type: 'ton' }) + : await Signer.fromPrivateKey(parsePrivateKeyInput(config.private_key!).seed); + const networkObject = network === 'testnet' ? Network.testnet() : Network.mainnet(); + const adapter = + walletVersion === 'v4r2' + ? await WalletV4R2Adapter.create(signer, { + client: kit.getApiClient(networkObject), + network: networkObject, + }) + : await WalletV5R1Adapter.create(signer, { + client: kit.getApiClient(networkObject), + network: networkObject, + }); + return adapter.getAddress(); + } finally { + await kit.close(); + } +} + +async function migrateLegacyConfig(legacy: LegacyTonConfig): Promise<{ + config: TonConfig; + secretInputs: SecretMaterializationInput; +}> { + const network = legacy.network === 'testnet' ? 'testnet' : 'mainnet'; + const walletVersion = legacy.wallet_version === 'v4r2' ? 'v4r2' : 'v5r1'; + const address = await deriveLegacyWalletAddress(legacy); + const migratedWallet = createStandardWalletRecord({ + name: 'Migrated wallet', + network, + walletVersion, + address, + idPrefix: 'migrated-wallet', + }); + + return { + config: { + version: CURRENT_TON_CONFIG_VERSION, + active_wallet_id: migratedWallet.id, + networks: { + [network]: legacy.toncenter_api_key + ? { + toncenter_api_key: legacy.toncenter_api_key, + } + : undefined, + }, + wallets: [migratedWallet], + pending_agentic_deployments: [], + pending_agentic_key_rotations: [], + agentic_setup_sessions: [], + }, + secretInputs: { + wallets: { + [migratedWallet.id]: { + ...(legacy.mnemonic?.trim() ? { mnemonic: legacy.mnemonic.trim() } : {}), + ...(legacy.private_key?.trim() ? { private_key: legacy.private_key.trim() } : {}), + }, + }, + }, + }; +} + +function writeConfigFile(config: TonConfig): TonConfig { + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + chmodIfExists(getConfigDir(), 0o700); + writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { + encoding: 'utf-8', + mode: 0o600, + }); + chmodIfExists(getConfigDir(), 0o700); + chmodIfExists(getConfigPath(), 0o600); + return config; +} + +export function saveConfigTransition( + previousConfig: TonConfig | null | undefined, + nextConfig: TonConfig, + secretInputs?: SecretMaterializationInput, +): TonConfig { + const writtenConfig = writeConfigFile(normalizeConfig(materializeSecrets(nextConfig, secretInputs))); + cleanupOrphanSecretFiles(previousConfig, writtenConfig); + return writtenConfig; +} + +export function saveConfig(config: TonConfig, secretInputs?: SecretMaterializationInput): TonConfig { + return saveConfigTransition(undefined, config, secretInputs); +} + +function parseConfigFile(): { configPath: string; raw: unknown } | null { + const configPath = getConfigPath(); + if (!existsSync(configPath)) { + return null; + } + + try { + return { + configPath, + raw: JSON.parse(readFileSync(configPath, 'utf-8')), + }; + } catch (error) { + throw new ConfigError( + `Failed to read config at ${configPath}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } +} + +async function prepareLoadedConfig( + raw: unknown, + options: { allowLegacyMigration: boolean; persistCurrentVersion: boolean }, +): Promise { + if (isLegacyConfig(raw)) { + if (!options.allowLegacyMigration) { + throw new ConfigError( + 'Unsupported legacy config. Use loadConfigWithMigration() to migrate the unversioned config into the current format.', + ); + } + + const migrated = await migrateLegacyConfig(raw); + return { + config: migrated.config, + shouldPersist: true, + previousConfig: null, + secretInputs: migrated.secretInputs, + }; + } + + return prepareVersionedConfig(raw, options); +} + +function prepareVersionedConfig(raw: unknown, options: { persistCurrentVersion: boolean }): PreparedLoadedConfig { + if (!raw || typeof raw !== 'object' || !('version' in raw)) { + throw new ConfigError('Unsupported config format.'); + } + + const version = (raw as { version?: unknown }).version; + if (!isSupportedConfigVersion(version)) { + throw new ConfigError(`Unsupported config version ${String(version)}.`); + } + + const migrated = migrateFromV2Config(raw as TonConfig); + return { + config: migrated.config, + shouldPersist: options.persistCurrentVersion && (migrated.changed || version !== CURRENT_TON_CONFIG_VERSION), + previousConfig: raw as TonConfig, + }; +} + +function finalizeLoadedConfig(prepared: PreparedLoadedConfig): TonConfig { + return prepared.shouldPersist + ? saveConfigTransition(prepared.previousConfig, prepared.config, prepared.secretInputs) + : normalizeConfig(prepared.config); +} + +export function loadConfig(): TonConfig | null { + const parsed = parseConfigFile(); + if (!parsed) { + return null; + } + + if (isLegacyConfig(parsed.raw)) { + throw new ConfigError( + `Unsupported legacy config. Re-import wallets into the current format or use CLI setup to migrate it. (${parsed.configPath})`, + ); + } + + return finalizeLoadedConfig(prepareVersionedConfig(parsed.raw, { persistCurrentVersion: false })); +} + +export async function loadConfigWithMigration(): Promise { + const parsed = parseConfigFile(); + if (!parsed) { + return null; + } + + return await finalizeLoadedConfig( + await prepareLoadedConfig(parsed.raw, { allowLegacyMigration: true, persistCurrentVersion: true }), + ); +} + +export function deleteConfig(): boolean { + try { + const parsed = parseConfigFile(); + if (!parsed) { + return false; + } + + if ( + parsed.raw && + typeof parsed.raw === 'object' && + 'version' in parsed.raw && + isSupportedConfigVersion((parsed.raw as { version?: unknown }).version) + ) { + deleteAllSecretFiles(migrateFromV2Config(parsed.raw as TonConfig).config); + } + + if (existsSync(getConfigPath())) { + unlinkSync(getConfigPath()); + } + return true; + } catch { + return false; + } +} diff --git a/packages/mcp/src/registry/config.ts b/packages/mcp/src/registry/config.ts index 678c0986c..9aaef387c 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -6,26 +6,16 @@ * */ -import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; - -import { - MemoryStorageAdapter, - Network, - Signer, - TonWalletKit, - WalletV4R2Adapter, - WalletV5R1Adapter, -} from '@ton/walletkit'; - import { formatAssetAddress, formatWalletAddress, normalizeAddressForComparison } from '../utils/address.js'; -import { parsePrivateKeyInput } from '../utils/private-key.js'; -import { createApiClient } from '../utils/ton-client.js'; +import { LEGACY_AGENTIC_PRIVATE_KEY_FIELD, readPrivateKeyField } from './private-key-field.js'; +import type { LegacyPrivateKeyCompatible } from './private-key-field.js'; export type TonNetwork = 'mainnet' | 'testnet'; export type StandardWalletVersion = 'v5r1' | 'v4r2'; export type StoredWalletType = 'standard' | 'agentic'; +export type SecretType = 'mnemonic' | 'private_key'; +export type TonConfigVersion = 2 | 3; +export const CURRENT_TON_CONFIG_VERSION = 3 as const; export interface ConfigNetwork { toncenter_api_key?: string; @@ -47,14 +37,14 @@ export interface StoredWalletBase { export interface StoredStandardWallet extends StoredWalletBase { type: 'standard'; wallet_version: StandardWalletVersion; - mnemonic?: string; - private_key?: string; + secret_file?: string; + secret_type?: SecretType; } export interface StoredAgenticWallet extends StoredWalletBase { type: 'agentic'; owner_address: string; - operator_private_key?: string; + secret_file?: string; operator_public_key?: string; source?: string; collection_address?: string; @@ -67,7 +57,7 @@ export type StoredWallet = StoredStandardWallet | StoredAgenticWallet; export interface PendingAgenticDeployment { id: string; network: TonNetwork; - operator_private_key: string; + secret_file?: string; operator_public_key: string; name?: string; source?: string; @@ -83,7 +73,7 @@ export interface PendingAgenticKeyRotation { wallet_address: string; owner_address: string; collection_address?: string; - operator_private_key: string; + secret_file?: string; operator_public_key: string; created_at: string; updated_at: string; @@ -116,353 +106,193 @@ export interface StoredAgenticSetupSession { } export interface TonConfig { - version: 2; + version: TonConfigVersion; active_wallet_id: string | null; networks: { mainnet?: ConfigNetwork; testnet?: ConfigNetwork; }; wallets: StoredWallet[]; - pending_agentic_deployments?: PendingAgenticDeployment[]; - pending_agentic_key_rotations?: PendingAgenticKeyRotation[]; - agentic_setup_sessions?: StoredAgenticSetupSession[]; -} - -interface LegacyTonConfig { - mnemonic?: string; - private_key?: string; - network?: TonNetwork; - wallet_version?: StandardWalletVersion; - toncenter_api_key?: string; + pending_agentic_deployments: PendingAgenticDeployment[]; + pending_agentic_key_rotations: PendingAgenticKeyRotation[]; + agentic_setup_sessions: StoredAgenticSetupSession[]; } export class ConfigError extends Error {} -const DEFAULT_CONFIG_FILE = join(homedir(), '.config', 'ton', 'config.json'); -const ENV_CONFIG_PATH = 'TON_CONFIG_PATH'; export const DEFAULT_AGENTIC_COLLECTION_ADDRESS = 'EQByQ19qvWxW7VibSbGEgZiYMqilHY5y1a_eeSL2VaXhfy07'; function nowIso(): string { return new Date().toISOString(); } -function isWalletRemoved(wallet: StoredWallet): boolean { - return wallet.removed === true; -} +function toPublicNetwork(network: ConfigNetwork | undefined, currentNetwork: TonNetwork): ConfigNetwork | undefined { + if (!network) { + return undefined; + } -function normalizeConfig(raw: TonConfig): TonConfig { - const normalizePendingDeployment = (deployment: PendingAgenticDeployment): PendingAgenticDeployment => ({ - ...deployment, - ...(deployment.name ? { name: deployment.name.trim() } : {}), - ...(deployment.source ? { source: deployment.source.trim() } : {}), - ...(deployment.collection_address - ? { - collection_address: formatAssetAddress(deployment.collection_address, deployment.network), - } - : {}), - }); - const normalizePendingKeyRotation = (rotation: PendingAgenticKeyRotation): PendingAgenticKeyRotation => ({ - ...rotation, - wallet_address: formatWalletAddress(rotation.wallet_address, rotation.network), - owner_address: formatWalletAddress(rotation.owner_address, rotation.network), - ...(rotation.collection_address + return { + ...(network.toncenter_api_key ? { toncenter_api_key: network.toncenter_api_key.trim() } : {}), + ...(network.agentic_collection_address ? { - collection_address: formatAssetAddress(rotation.collection_address, rotation.network), + agentic_collection_address: formatAssetAddress(network.agentic_collection_address, currentNetwork), } : {}), - }); - const normalizeStoredWallet = (wallet: StoredWallet): StoredWallet => - wallet.type === 'standard' - ? { - ...wallet, - address: formatWalletAddress(wallet.address, wallet.network), - } - : { - ...wallet, - address: formatWalletAddress(wallet.address, wallet.network), - owner_address: formatWalletAddress(wallet.owner_address, wallet.network), - ...(wallet.collection_address - ? { - collection_address: formatAssetAddress(wallet.collection_address, wallet.network), - } - : {}), - }; - const normalizeSetupSession = (session: StoredAgenticSetupSession): StoredAgenticSetupSession => ({ - ...session, - }); + }; +} - return { - version: 2, - active_wallet_id: raw.active_wallet_id ?? null, - networks: { - mainnet: raw.networks?.mainnet - ? { - ...raw.networks.mainnet, - ...(raw.networks.mainnet.agentic_collection_address - ? { - agentic_collection_address: formatAssetAddress( - raw.networks.mainnet.agentic_collection_address, - 'mainnet', - ), - } - : {}), - } - : undefined, - testnet: raw.networks?.testnet - ? { - ...raw.networks.testnet, - ...(raw.networks.testnet.agentic_collection_address - ? { - agentic_collection_address: formatAssetAddress( - raw.networks.testnet.agentic_collection_address, - 'testnet', - ), - } - : {}), - } - : undefined, +function stripLegacySecretFields< + T extends { + secret_file?: string; + secret_type?: SecretType; + }, +>( + value: T & + LegacyPrivateKeyCompatible & { + mnemonic?: string; }, - wallets: Array.isArray(raw.wallets) ? raw.wallets.map(normalizeStoredWallet) : [], - ...(Array.isArray(raw.pending_agentic_deployments) && raw.pending_agentic_deployments.length > 0 - ? { - pending_agentic_deployments: raw.pending_agentic_deployments.map(normalizePendingDeployment), - } - : {}), - ...(Array.isArray(raw.pending_agentic_key_rotations) && raw.pending_agentic_key_rotations.length > 0 - ? { - pending_agentic_key_rotations: raw.pending_agentic_key_rotations.map(normalizePendingKeyRotation), - } - : {}), - ...(Array.isArray(raw.agentic_setup_sessions) && raw.agentic_setup_sessions.length > 0 +): T { + const { + mnemonic: _mnemonic, + private_key: _privateKey, + [LEGACY_AGENTIC_PRIVATE_KEY_FIELD]: _legacyPrivateKey, + ...rest + } = value; + + return rest as T; +} + +function normalizeSecretBackedCollection< + T extends { + network: TonNetwork; + collection_address?: string; + secret_file?: string; + secret_type?: SecretType; + }, +>(value: T): T { + const normalized = stripLegacySecretFields(value); + return { + ...normalized, + ...(normalized.collection_address ? { - agentic_setup_sessions: raw.agentic_setup_sessions.map(normalizeSetupSession), + collection_address: formatAssetAddress(normalized.collection_address, normalized.network), } : {}), }; } -function isLegacyConfig(raw: unknown): raw is LegacyTonConfig { - if (!raw || typeof raw !== 'object') { - return false; +function normalizeStoredWallet(wallet: StoredWallet): StoredWallet { + if (wallet.type === 'standard') { + const normalized = stripLegacySecretFields(wallet); + const legacy = wallet as StoredStandardWallet & { + mnemonic?: string; + private_key?: string; + }; + const mnemonic = legacy.mnemonic?.trim() || undefined; + const privateKey = legacy.private_key?.trim() || undefined; + const secretType = normalized.secret_type ?? (mnemonic ? 'mnemonic' : privateKey ? 'private_key' : undefined); + return { + ...normalized, + name: normalized.name.trim(), + address: formatWalletAddress(wallet.address, wallet.network), + ...(secretType ? { secret_type: secretType } : {}), + ...(normalized.secret_file || !mnemonic ? {} : { mnemonic }), + ...(normalized.secret_file || mnemonic || !privateKey ? {} : { private_key: privateKey }), + }; } - const candidate = raw as Record; - return ( - !('version' in candidate) && - ('mnemonic' in candidate || - 'private_key' in candidate || - 'network' in candidate || - 'wallet_version' in candidate || - 'toncenter_api_key' in candidate) - ); + const normalized = normalizeSecretBackedCollection(wallet); + const privateKey = + readPrivateKeyField(wallet as StoredAgenticWallet & LegacyPrivateKeyCompatible)?.trim() || undefined; + return { + ...normalized, + name: normalized.name.trim(), + address: formatWalletAddress(wallet.address, wallet.network), + owner_address: formatWalletAddress(wallet.owner_address, wallet.network), + ...(normalized.source ? { source: normalized.source.trim() } : {}), + ...(normalized.secret_file || !privateKey ? {} : { private_key: privateKey }), + }; } -async function deriveLegacyWalletAddress(config: LegacyTonConfig): Promise { - if (!config.mnemonic && !config.private_key) { - throw new ConfigError('Legacy config does not contain mnemonic or private_key and cannot be migrated.'); +function normalizePendingRecord( + value: PendingAgenticDeployment | PendingAgenticKeyRotation, +): PendingAgenticDeployment | PendingAgenticKeyRotation { + const normalized = normalizeSecretBackedCollection(value); + const privateKey = + readPrivateKeyField( + value as + | (PendingAgenticDeployment & LegacyPrivateKeyCompatible) + | (PendingAgenticKeyRotation & LegacyPrivateKeyCompatible), + )?.trim() || undefined; + + if ('wallet_address' in normalized) { + return { + ...normalized, + wallet_address: formatWalletAddress(normalized.wallet_address, normalized.network), + owner_address: formatWalletAddress(normalized.owner_address, normalized.network), + ...(normalized.secret_file || !privateKey ? {} : { private_key: privateKey }), + }; } - const network = config.network === 'testnet' ? 'testnet' : 'mainnet'; - const walletVersion = config.wallet_version === 'v4r2' ? 'v4r2' : 'v5r1'; - const kit = new TonWalletKit({ - networks: { - [(network === 'testnet' ? Network.testnet() : Network.mainnet()).chainId]: { - apiClient: createApiClient(network, config.toncenter_api_key), - }, - }, - storage: new MemoryStorageAdapter(), - }); - await kit.waitForReady(); - - try { - const signer = config.mnemonic - ? await Signer.fromMnemonic(config.mnemonic.trim().split(/\s+/), { type: 'ton' }) - : await Signer.fromPrivateKey(parsePrivateKeyInput(config.private_key!).seed); - const networkObject = network === 'testnet' ? Network.testnet() : Network.mainnet(); - const adapter = - walletVersion === 'v4r2' - ? await WalletV4R2Adapter.create(signer, { - client: kit.getApiClient(networkObject), - network: networkObject, - }) - : await WalletV5R1Adapter.create(signer, { - client: kit.getApiClient(networkObject), - network: networkObject, - }); - return adapter.getAddress(); - } finally { - await kit.close(); - } + return { + ...normalized, + ...(normalized.name ? { name: normalized.name.trim() } : {}), + ...(normalized.source ? { source: normalized.source.trim() } : {}), + ...(normalized.secret_file || !privateKey ? {} : { private_key: privateKey }), + }; } -async function migrateLegacyConfig(legacy: LegacyTonConfig): Promise { - const network = legacy.network === 'testnet' ? 'testnet' : 'mainnet'; - const walletVersion = legacy.wallet_version === 'v4r2' ? 'v4r2' : 'v5r1'; - const address = await deriveLegacyWalletAddress(legacy); - const migratedWallet = createStandardWalletRecord({ - name: 'Migrated wallet', - network, - walletVersion, - address, - mnemonic: legacy.mnemonic?.trim(), - privateKey: legacy.private_key?.trim(), - idPrefix: 'migrated-wallet', - }); - +export function normalizeConfig(raw: TonConfig): TonConfig { return { - version: 2, - active_wallet_id: migratedWallet.id, + version: CURRENT_TON_CONFIG_VERSION, + active_wallet_id: raw.active_wallet_id ?? null, networks: { - [network]: legacy.toncenter_api_key - ? { - toncenter_api_key: legacy.toncenter_api_key, - } - : undefined, + mainnet: toPublicNetwork(raw.networks?.mainnet, 'mainnet'), + testnet: toPublicNetwork(raw.networks?.testnet, 'testnet'), }, - wallets: [migratedWallet], + wallets: Array.isArray(raw.wallets) ? raw.wallets.map(normalizeStoredWallet) : [], + pending_agentic_deployments: Array.isArray(raw.pending_agentic_deployments) + ? raw.pending_agentic_deployments.map( + (deployment) => normalizePendingRecord(deployment) as PendingAgenticDeployment, + ) + : [], + pending_agentic_key_rotations: Array.isArray(raw.pending_agentic_key_rotations) + ? raw.pending_agentic_key_rotations.map( + (rotation) => normalizePendingRecord(rotation) as PendingAgenticKeyRotation, + ) + : [], + agentic_setup_sessions: Array.isArray(raw.agentic_setup_sessions) ? raw.agentic_setup_sessions : [], }; } -export function getConfigPath(): string { - return process.env[ENV_CONFIG_PATH]?.trim() || DEFAULT_CONFIG_FILE; -} - -export function getConfigDir(): string { - return dirname(getConfigPath()); -} - -export function configExists(): boolean { - return existsSync(getConfigPath()); -} - export function createEmptyConfig(): TonConfig { return { - version: 2, + version: CURRENT_TON_CONFIG_VERSION, active_wallet_id: null, networks: {}, wallets: [], + pending_agentic_deployments: [], + pending_agentic_key_rotations: [], + agentic_setup_sessions: [], }; } -function chmodIfExists(path: string, mode: number): void { - try { - if (existsSync(path)) { - chmodSync(path, mode); - } - } catch { - // Best-effort only. - } -} - -export function ensureConfigPermissions(): void { - chmodIfExists(getConfigDir(), 0o700); - chmodIfExists(getConfigPath(), 0o600); -} - -export function loadConfig(): TonConfig | null { - const configPath = getConfigPath(); - if (!existsSync(configPath)) { - return null; - } - - let raw: unknown; - try { - raw = JSON.parse(readFileSync(configPath, 'utf-8')); - } catch (error) { - throw new ConfigError( - `Failed to read config at ${configPath}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - - if (!raw || typeof raw !== 'object' || !('version' in raw)) { - throw new ConfigError( - `Unsupported legacy config at ${configPath}. Re-import wallets into the v2 format or use CLI setup to migrate it.`, - ); - } - - const version = (raw as { version?: unknown }).version; - if (version !== 2) { - throw new ConfigError(`Unsupported config version ${String(version)} at ${configPath}.`); - } - - return normalizeConfig(raw as TonConfig); -} - -export async function loadConfigWithMigration(): Promise { - const configPath = getConfigPath(); - if (!existsSync(configPath)) { - return null; - } - - let raw: unknown; - try { - raw = JSON.parse(readFileSync(configPath, 'utf-8')); - } catch (error) { - throw new ConfigError( - `Failed to read config at ${configPath}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - - if (isLegacyConfig(raw)) { - const migrated = await migrateLegacyConfig(raw); - saveConfig(migrated); - return migrated; - } - - if (!raw || typeof raw !== 'object' || !('version' in raw)) { - throw new ConfigError(`Unsupported config format at ${configPath}.`); - } - - const version = (raw as { version?: unknown }).version; - if (version !== 2) { - throw new ConfigError(`Unsupported config version ${String(version)} at ${configPath}.`); - } - - return normalizeConfig(raw as TonConfig); -} - -export function saveConfig(config: TonConfig): void { - mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); - chmodIfExists(getConfigDir(), 0o700); - writeFileSync(getConfigPath(), JSON.stringify(normalizeConfig(config), null, 2) + '\n', { - encoding: 'utf-8', - mode: 0o600, - }); - ensureConfigPermissions(); -} - -export function deleteConfig(): boolean { - try { - if (existsSync(getConfigPath())) { - unlinkSync(getConfigPath()); - return true; - } - return false; - } catch { - return false; - } -} - -export function listWallets(config: TonConfig): StoredWallet[] { - return config.wallets.filter((wallet) => !isWalletRemoved(wallet)); -} - -export function getActiveWallet(config: TonConfig): StoredWallet | null { +export function getActiveWallet(config: TonConfig): StoredWallet | undefined { if (!config.active_wallet_id) { - return null; + return undefined; } - return config.wallets.find((wallet) => wallet.id === config.active_wallet_id && !isWalletRemoved(wallet)) ?? null; + return config.wallets.find((wallet) => wallet.id === config.active_wallet_id && wallet.removed !== true); } -export function findWallet(config: TonConfig, selector: string): StoredWallet | null { +export function findWallet(config: TonConfig, selector: string): StoredWallet | undefined { const normalized = selector.trim().toLowerCase(); const normalizedRawAddress = normalizeAddressForComparison(selector); if (!normalized) { - return null; + return undefined; } const exact = config.wallets.find((wallet) => { - if (isWalletRemoved(wallet)) { + if (wallet.removed === true) { return false; } return ( @@ -479,25 +309,23 @@ export function findWallet(config: TonConfig, selector: string): StoredWallet | const partial = config.wallets.find( (wallet) => - !isWalletRemoved(wallet) && + wallet.removed !== true && (wallet.id.toLowerCase().startsWith(normalized) || wallet.address.toLowerCase().startsWith(normalized)), ); - return partial ?? null; + return partial; } -export function findWalletByAddress(config: TonConfig, network: TonNetwork, address: string): StoredWallet | null { +export function findWalletByAddress(config: TonConfig, network: TonNetwork, address: string): StoredWallet | undefined { const normalizedAddress = normalizeAddressForComparison(address); if (!normalizedAddress) { - return null; + return undefined; } - return ( - config.wallets.find( - (wallet) => - !isWalletRemoved(wallet) && - wallet.network === network && - normalizeAddressForComparison(wallet.address)?.toLowerCase() === normalizedAddress.toLowerCase(), - ) ?? null + return config.wallets.find( + (wallet) => + wallet.removed !== true && + wallet.network === network && + normalizeAddressForComparison(wallet.address)?.toLowerCase() === normalizedAddress.toLowerCase(), ); } @@ -509,16 +337,17 @@ function touchWallet(wallet: T): T { } export function upsertWallet(config: TonConfig, wallet: StoredWallet, options?: { setActive?: boolean }): TonConfig { - const duplicate = findWalletByAddress(config, wallet.network, wallet.address); + const publicWallet = normalizeStoredWallet(wallet); + const duplicate = findWalletByAddress(config, publicWallet.network, publicWallet.address); if (duplicate && duplicate.id !== wallet.id) { throw new ConfigError( - `Wallet address ${wallet.address} is already configured as "${duplicate.name}" (${duplicate.id}) on ${wallet.network}.`, + `Wallet address ${publicWallet.address} is already configured as "${duplicate.name}" (${duplicate.id}) on ${publicWallet.network}.`, ); } - const existingIndex = config.wallets.findIndex((item) => item.id === wallet.id); + const existingIndex = config.wallets.findIndex((item) => item.id === publicWallet.id); const now = nowIso(); - const nextWallet = + const nextWallet = ( existingIndex === -1 ? { ...wallet, created_at: wallet.created_at || now, updated_at: wallet.updated_at || now } : { @@ -526,7 +355,8 @@ export function upsertWallet(config: TonConfig, wallet: StoredWallet, options?: ...wallet, created_at: config.wallets[existingIndex].created_at, updated_at: now, - }; + } + ) as StoredWallet; const nextWallets = [...config.wallets]; if (existingIndex === -1) { @@ -542,10 +372,13 @@ export function upsertWallet(config: TonConfig, wallet: StoredWallet, options?: }; } -export function removeWallet(config: TonConfig, selector: string): { config: TonConfig; removed: StoredWallet | null } { +export function removeWallet( + config: TonConfig, + selector: string, +): { config: TonConfig; removed: StoredWallet | undefined } { const wallet = findWallet(config, selector); if (!wallet) { - return { config, removed: null }; + return { config, removed: undefined }; } const removedWallet = { @@ -555,7 +388,7 @@ export function removeWallet(config: TonConfig, selector: string): { config: Ton updated_at: nowIso(), }; const nextWallets = config.wallets.map((item) => (item.id === wallet.id ? removedWallet : item)); - const nextVisibleWallets = nextWallets.filter((item) => !isWalletRemoved(item)); + const nextVisibleWallets = nextWallets.filter((item) => item.removed !== true); const nextActive = config.active_wallet_id === wallet.id ? (nextVisibleWallets[0]?.id ?? null) : (config.active_wallet_id ?? null); @@ -572,10 +405,10 @@ export function removeWallet(config: TonConfig, selector: string): { config: Ton export function setActiveWallet( config: TonConfig, selector: string, -): { config: TonConfig; wallet: StoredWallet | null } { +): { config: TonConfig; wallet: StoredWallet | undefined } { const wallet = findWallet(config, selector); if (!wallet) { - return { config, wallet: null }; + return { config, wallet: undefined }; } return { @@ -588,16 +421,73 @@ export function setActiveWallet( }; } -export function listPendingAgenticDeployments(config: TonConfig): PendingAgenticDeployment[] { - return [...(config.pending_agentic_deployments ?? [])]; +function upsertTimestampedCollectionItem( + items: T[], + item: T, +): T[] { + const existingIndex = items.findIndex((existingItem) => existingItem.id === item.id); + const now = nowIso(); + const existingItem = existingIndex === -1 ? null : items[existingIndex]!; + const nextItem = !existingItem + ? { + ...item, + created_at: item.created_at || now, + updated_at: item.updated_at || now, + } + : { + ...existingItem, + ...item, + created_at: existingItem.created_at, + updated_at: now, + }; + + const nextItems = [...items]; + if (existingIndex === -1) { + nextItems.push(nextItem); + } else { + nextItems[existingIndex] = nextItem; + } + + return nextItems; } -export function listPendingAgenticKeyRotations(config: TonConfig): PendingAgenticKeyRotation[] { - return [...(config.pending_agentic_key_rotations ?? [])]; +function matchesPendingDeployment( + deployment: PendingAgenticDeployment, + input: { + id?: string; + network?: TonNetwork; + operatorPublicKey?: string; + }, +): boolean { + if (input.id && deployment.id !== input.id) { + return false; + } + if (input.network && deployment.network !== input.network) { + return false; + } + if ( + input.operatorPublicKey && + deployment.operator_public_key.trim().toLowerCase() !== input.operatorPublicKey.trim().toLowerCase() + ) { + return false; + } + return true; } -export function listAgenticSetupSessions(config: TonConfig): StoredAgenticSetupSession[] { - return [...(config.agentic_setup_sessions ?? [])]; +function matchesPendingKeyRotation( + rotation: PendingAgenticKeyRotation, + input: { + id?: string; + walletId?: string; + }, +): boolean { + if (input.id && rotation.id !== input.id) { + return false; + } + if (input.walletId && rotation.wallet_id !== input.walletId) { + return false; + } + return true; } export function findPendingAgenticDeployment( @@ -607,54 +497,14 @@ export function findPendingAgenticDeployment( network?: TonNetwork; operatorPublicKey?: string; }, -): PendingAgenticDeployment | null { - return ( - (config.pending_agentic_deployments ?? []).find((deployment) => { - if (input.id && deployment.id !== input.id) { - return false; - } - if (input.network && deployment.network !== input.network) { - return false; - } - if ( - input.operatorPublicKey && - deployment.operator_public_key.trim().toLowerCase() !== input.operatorPublicKey.trim().toLowerCase() - ) { - return false; - } - return true; - }) ?? null - ); +): PendingAgenticDeployment | undefined { + return config.pending_agentic_deployments.find((deployment) => matchesPendingDeployment(deployment, input)); } export function upsertPendingAgenticDeployment(config: TonConfig, deployment: PendingAgenticDeployment): TonConfig { - const pendingDeployments = config.pending_agentic_deployments ?? []; - const existingIndex = pendingDeployments.findIndex((item) => item.id === deployment.id); - const now = nowIso(); - const existingDeployment = existingIndex === -1 ? null : pendingDeployments[existingIndex]!; - const nextDeployment = !existingDeployment - ? { - ...deployment, - created_at: deployment.created_at || now, - updated_at: deployment.updated_at || now, - } - : { - ...existingDeployment, - ...deployment, - created_at: existingDeployment.created_at, - updated_at: now, - }; - - const nextDeployments = [...pendingDeployments]; - if (existingIndex === -1) { - nextDeployments.push(nextDeployment); - } else { - nextDeployments[existingIndex] = nextDeployment; - } - return { ...config, - ...(nextDeployments.length > 0 ? { pending_agentic_deployments: nextDeployments } : {}), + pending_agentic_deployments: upsertTimestampedCollectionItem(config.pending_agentic_deployments, deployment), }; } @@ -664,48 +514,14 @@ export function findPendingAgenticKeyRotation( id?: string; walletId?: string; }, -): PendingAgenticKeyRotation | null { - return ( - (config.pending_agentic_key_rotations ?? []).find((rotation) => { - if (input.id && rotation.id !== input.id) { - return false; - } - if (input.walletId && rotation.wallet_id !== input.walletId) { - return false; - } - return true; - }) ?? null - ); +): PendingAgenticKeyRotation | undefined { + return config.pending_agentic_key_rotations.find((rotation) => matchesPendingKeyRotation(rotation, input)); } export function upsertPendingAgenticKeyRotation(config: TonConfig, rotation: PendingAgenticKeyRotation): TonConfig { - const rotations = config.pending_agentic_key_rotations ?? []; - const existingIndex = rotations.findIndex((item) => item.id === rotation.id); - const now = nowIso(); - const existingRotation = existingIndex === -1 ? null : rotations[existingIndex]!; - const nextRotation = !existingRotation - ? { - ...rotation, - created_at: rotation.created_at || now, - updated_at: rotation.updated_at || now, - } - : { - ...existingRotation, - ...rotation, - created_at: existingRotation.created_at, - updated_at: now, - }; - - const nextRotations = [...rotations]; - if (existingIndex === -1) { - nextRotations.push(nextRotation); - } else { - nextRotations[existingIndex] = nextRotation; - } - return { ...config, - ...(nextRotations.length > 0 ? { pending_agentic_key_rotations: nextRotations } : {}), + pending_agentic_key_rotations: upsertTimestampedCollectionItem(config.pending_agentic_key_rotations, rotation), }; } @@ -717,31 +533,15 @@ export function removePendingAgenticDeployment( operatorPublicKey?: string; }, ): TonConfig { - const nextDeployments = (config.pending_agentic_deployments ?? []).filter((deployment) => { - if (input.id && deployment.id === input.id) { - return false; - } - - if ( - input.network && - input.operatorPublicKey && - deployment.network === input.network && - deployment.operator_public_key.trim().toLowerCase() === input.operatorPublicKey.trim().toLowerCase() - ) { - return false; - } - - return true; - }); - - if (nextDeployments.length === 0) { - const { pending_agentic_deployments: _pending, ...rest } = config; - return rest; - } - return { ...config, - pending_agentic_deployments: nextDeployments, + pending_agentic_deployments: config.pending_agentic_deployments.filter((deployment) => { + if (input.id && deployment.id === input.id) { + return false; + } + + return !(input.network && input.operatorPublicKey && matchesPendingDeployment(deployment, input)); + }), }; } @@ -752,35 +552,17 @@ export function removePendingAgenticKeyRotation( walletId?: string; }, ): TonConfig { - const nextRotations = (config.pending_agentic_key_rotations ?? []).filter((rotation) => { - if (input.id && rotation.id === input.id) { - return false; - } - if (input.walletId && rotation.wallet_id === input.walletId) { - return false; - } - return true; - }); - - if (nextRotations.length === 0) { - const { pending_agentic_key_rotations: _rotations, ...rest } = config; - return rest; - } - return { ...config, - pending_agentic_key_rotations: nextRotations, + pending_agentic_key_rotations: config.pending_agentic_key_rotations.filter( + (rotation) => !matchesPendingKeyRotation(rotation, input), + ), }; } -export function findAgenticSetupSession(config: TonConfig, setupId: string): StoredAgenticSetupSession | null { - return (config.agentic_setup_sessions ?? []).find((session) => session.setup_id === setupId) ?? null; -} - export function upsertAgenticSetupSession(config: TonConfig, session: StoredAgenticSetupSession): TonConfig { - const sessions = config.agentic_setup_sessions ?? []; - const existingIndex = sessions.findIndex((item) => item.setup_id === session.setup_id); - const nextSessions = [...sessions]; + const existingIndex = config.agentic_setup_sessions.findIndex((item) => item.setup_id === session.setup_id); + const nextSessions = [...config.agentic_setup_sessions]; if (existingIndex === -1) { nextSessions.push(session); @@ -795,16 +577,9 @@ export function upsertAgenticSetupSession(config: TonConfig, session: StoredAgen } export function removeAgenticSetupSession(config: TonConfig, setupId: string): TonConfig { - const nextSessions = (config.agentic_setup_sessions ?? []).filter((session) => session.setup_id !== setupId); - - if (nextSessions.length === 0) { - const { agentic_setup_sessions: _sessions, ...rest } = config; - return rest; - } - return { ...config, - agentic_setup_sessions: nextSessions, + agentic_setup_sessions: config.agentic_setup_sessions.filter((session) => session.setup_id !== setupId), }; } @@ -830,13 +605,6 @@ export function normalizeNetwork(value: string | undefined | null, fallback: Ton return value === 'testnet' ? 'testnet' : fallback; } -export function normalizeWalletVersion( - value: string | undefined | null, - fallback: StandardWalletVersion = 'v5r1', -): StandardWalletVersion { - return value === 'v4r2' ? 'v4r2' : fallback; -} - export function getToncenterApiKey(config: TonConfig | null, network: TonNetwork): string | undefined { const envKey = process.env.TONCENTER_API_KEY?.trim(); if (envKey) { @@ -867,20 +635,21 @@ export function createStandardWalletRecord(input: { network: TonNetwork; walletVersion: StandardWalletVersion; address: string; - mnemonic?: string; - privateKey?: string; + secretFile?: string; + secretType?: SecretType; idPrefix?: string; }): StoredStandardWallet { const now = nowIso(); + const id = createWalletId(input.idPrefix ?? input.name); return { - id: createWalletId(input.idPrefix ?? input.name), + id, name: input.name, type: 'standard', network: input.network, wallet_version: input.walletVersion, address: formatWalletAddress(input.address, input.network), - ...(input.mnemonic ? { mnemonic: input.mnemonic } : {}), - ...(input.privateKey ? { private_key: input.privateKey } : {}), + ...(input.secretFile ? { secret_file: input.secretFile } : {}), + ...(input.secretType ? { secret_type: input.secretType } : {}), created_at: now, updated_at: now, }; @@ -891,7 +660,7 @@ export function createAgenticWalletRecord(input: { network: TonNetwork; address: string; ownerAddress: string; - operatorPrivateKey?: string; + secretFile?: string; operatorPublicKey?: string; source?: string; collectionAddress?: string; @@ -900,14 +669,15 @@ export function createAgenticWalletRecord(input: { idPrefix?: string; }): StoredAgenticWallet { const now = nowIso(); + const id = createWalletId(input.idPrefix ?? input.name); return { - id: createWalletId(input.idPrefix ?? input.name), + id, name: input.name, type: 'agentic', network: input.network, address: formatWalletAddress(input.address, input.network), owner_address: formatWalletAddress(input.ownerAddress, input.network), - ...(input.operatorPrivateKey ? { operator_private_key: input.operatorPrivateKey } : {}), + ...(input.secretFile ? { secret_file: input.secretFile } : {}), ...(input.operatorPublicKey ? { operator_public_key: input.operatorPublicKey } : {}), ...(input.source ? { source: input.source } : {}), ...(input.collectionAddress @@ -922,7 +692,6 @@ export function createAgenticWalletRecord(input: { export function createPendingAgenticDeployment(input: { network: TonNetwork; - operatorPrivateKey: string; operatorPublicKey: string; name?: string; source?: string; @@ -930,10 +699,10 @@ export function createPendingAgenticDeployment(input: { idPrefix?: string; }): PendingAgenticDeployment { const now = nowIso(); + const id = createWalletId(input.idPrefix ?? input.name ?? 'pending-agentic'); return { - id: createWalletId(input.idPrefix ?? input.name ?? 'pending-agentic'), + id, network: input.network, - operator_private_key: input.operatorPrivateKey, operator_public_key: input.operatorPublicKey, ...(input.name?.trim() ? { name: input.name.trim() } : {}), ...(input.source?.trim() ? { source: input.source.trim() } : {}), @@ -951,13 +720,13 @@ export function createPendingAgenticKeyRotation(input: { walletAddress: string; ownerAddress: string; collectionAddress?: string; - operatorPrivateKey: string; operatorPublicKey: string; idPrefix?: string; }): PendingAgenticKeyRotation { const now = nowIso(); + const id = createWalletId(input.idPrefix ?? 'pending-agentic-key-rotation'); return { - id: createWalletId(input.idPrefix ?? 'pending-agentic-key-rotation'), + id, wallet_id: input.walletId, network: input.network, wallet_address: formatWalletAddress(input.walletAddress, input.network), @@ -965,7 +734,6 @@ export function createPendingAgenticKeyRotation(input: { ...(input.collectionAddress ? { collection_address: formatAssetAddress(input.collectionAddress, input.network) } : {}), - operator_private_key: input.operatorPrivateKey, operator_public_key: input.operatorPublicKey, created_at: now, updated_at: now, diff --git a/packages/mcp/src/registry/private-key-field.ts b/packages/mcp/src/registry/private-key-field.ts new file mode 100644 index 000000000..8c08942af --- /dev/null +++ b/packages/mcp/src/registry/private-key-field.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export const LEGACY_AGENTIC_PRIVATE_KEY_FIELD = 'operator_private_key' as const; + +export type LegacyPrivateKeyCompatible = { private_key?: string } & Partial< + Record +>; + +export function readPrivateKeyField(value: LegacyPrivateKeyCompatible): string | undefined { + return value.private_key ?? value[LEGACY_AGENTIC_PRIVATE_KEY_FIELD]; +} diff --git a/packages/mcp/src/registry/private-key-files.ts b/packages/mcp/src/registry/private-key-files.ts new file mode 100644 index 000000000..54c0c243f --- /dev/null +++ b/packages/mcp/src/registry/private-key-files.ts @@ -0,0 +1,409 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +import type { + PendingAgenticDeployment, + PendingAgenticKeyRotation, + SecretType, + StoredAgenticWallet, + StoredStandardWallet, + StoredWallet, + TonConfig, +} from './config.js'; +import { chmodIfExists, getConfigDir } from './config-path.js'; +import { LEGACY_AGENTIC_PRIVATE_KEY_FIELD, readPrivateKeyField } from './private-key-field.js'; +import type { LegacyPrivateKeyCompatible } from './private-key-field.js'; + +export type SecretReadableValue = { + secret_file?: string; + secret_type?: SecretType; +} & LegacyPrivateKeyCompatible & { + mnemonic?: string; + }; +export type StandardSecretInput = StoredStandardWallet & SecretReadableValue; +export interface SecretMaterializationInput { + wallets?: Record; + pendingAgenticDeployments?: Record; + pendingAgenticKeyRotations?: Record; +} +type InlineSecretMaterial = { type: SecretType; value: string }; + +const PRIVATE_KEYS_DIR = 'private-keys'; + +function trimSecret(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function resolvePrivateKeyPath(filePath: string): string { + return isAbsolute(filePath) ? filePath : resolve(getConfigDir(), filePath); +} + +function persistSecretFile( + filePath: string | undefined, + value: string | undefined, + pathParts: string[], +): string | undefined { + const normalizedValue = trimSecret(value); + if (!normalizedValue) { + return trimSecret(filePath); + } + + const providedPath = filePath?.trim(); + const targetPath = providedPath + ? resolvePrivateKeyPath(providedPath) + : join(getConfigDir(), PRIVATE_KEYS_DIR, ...pathParts); + mkdirSync(dirname(targetPath), { recursive: true, mode: 0o700 }); + chmodIfExists(dirname(targetPath), 0o700); + writeFileSync(targetPath, normalizedValue + '\n', { + encoding: 'utf-8', + mode: 0o600, + }); + chmodIfExists(targetPath, 0o600); + return providedPath || relative(getConfigDir(), targetPath); +} + +function readSecretFile(filePath: string | undefined): string | undefined { + const normalizedPath = filePath?.trim(); + if (!normalizedPath) { + return undefined; + } + + const resolvedPath = resolvePrivateKeyPath(normalizedPath); + if (!existsSync(resolvedPath)) { + return undefined; + } + + return trimSecret(readFileSync(resolvedPath, 'utf-8')); +} + +export function omitSecretRefFields( + value: T, +): Omit { + const { secret_file: _secretFile, secret_type: _secretType, ...rest } = value; + return rest; +} + +export function omitInlineSecretFields( + value: T & + LegacyPrivateKeyCompatible & { + mnemonic?: string; + }, +): Omit { + const { + mnemonic: _mnemonic, + private_key: _privateKey, + [LEGACY_AGENTIC_PRIVATE_KEY_FIELD]: _legacyPrivateKey, + ...rest + } = value; + + return rest as Omit; +} + +function readInlineSecret(value: SecretReadableValue): InlineSecretMaterial | undefined { + const mnemonic = trimSecret(value.mnemonic); + if (mnemonic) { + return { type: 'mnemonic', value: mnemonic }; + } + + const privateKey = trimSecret(readPrivateKeyField(value)); + if (privateKey) { + return { type: 'private_key', value: privateKey }; + } + + return undefined; +} + +function persistPrivateKeyRecord( + value: + | (StoredAgenticWallet & LegacyPrivateKeyCompatible) + | (PendingAgenticDeployment & LegacyPrivateKeyCompatible) + | (PendingAgenticKeyRotation & LegacyPrivateKeyCompatible), + pathParts: string[], +): StoredAgenticWallet | PendingAgenticDeployment | PendingAgenticKeyRotation { + const { + private_key: _privateKey, + secret_type: _secretType, + secret_file: _secretFile, + ...publicValue + } = value as typeof value & { + secret_file?: string; + secret_type?: SecretType; + }; + const secretFile = persistSecretFile(value.secret_file, value.private_key, pathParts); + return { + ...publicValue, + ...(secretFile ? { secret_file: secretFile } : {}), + }; +} + +export function persistStandardSecretRef(wallet: StandardSecretInput): StoredStandardWallet { + const { mnemonic: _mnemonic, private_key: _privateKey, secret_file: _secretFile, ...publicWallet } = wallet; + const secretType = wallet.mnemonic ? 'mnemonic' : wallet.private_key ? 'private_key' : wallet.secret_type; + const secretFile = wallet.mnemonic + ? persistSecretFile(wallet.secret_file, wallet.mnemonic, ['wallets', `${wallet.id}.mnemonic`]) + : wallet.private_key + ? persistSecretFile(wallet.secret_file, wallet.private_key, ['wallets', `${wallet.id}.private-key`]) + : wallet.secret_file; + return { + ...publicWallet, + ...(secretFile ? { secret_file: secretFile } : {}), + ...(secretType ? { secret_type: secretType } : {}), + }; +} + +function materializePrivateKeyRecord( + value: + | (StoredAgenticWallet & LegacyPrivateKeyCompatible) + | (PendingAgenticDeployment & LegacyPrivateKeyCompatible) + | (PendingAgenticKeyRotation & LegacyPrivateKeyCompatible), + pathParts: string[], + overridePrivateKey?: string, +): StoredAgenticWallet | PendingAgenticDeployment | PendingAgenticKeyRotation { + const inlineSecret = readInlineSecret(value); + const privateKey = + trimSecret(overridePrivateKey) ?? (inlineSecret?.type === 'private_key' ? inlineSecret.value : undefined); + return persistPrivateKeyRecord( + { + ...value, + ...(privateKey ? { private_key: privateKey } : {}), + }, + pathParts, + ); +} + +function materializeStandardWallet( + wallet: StandardSecretInput, + overrides?: { mnemonic?: string; private_key?: string }, +): StoredStandardWallet { + const inlineSecret = readInlineSecret(wallet); + const mnemonic = + trimSecret(overrides?.mnemonic) ?? (inlineSecret?.type === 'mnemonic' ? inlineSecret.value : undefined); + const privateKey = + trimSecret(overrides?.private_key) ?? (inlineSecret?.type === 'private_key' ? inlineSecret.value : undefined); + return persistStandardSecretRef({ + ...wallet, + ...(mnemonic ? { mnemonic } : {}), + ...(privateKey ? { private_key: privateKey } : {}), + }); +} + +function materializeStoredWallet( + wallet: StoredWallet, + overrides?: { mnemonic?: string; private_key?: string }, +): StoredWallet { + if (wallet.removed) { + return omitSecretRefFields(wallet) as StoredWallet; + } + + if (wallet.type === 'standard') { + return materializeStandardWallet(wallet, overrides); + } + + return materializePrivateKeyRecord( + wallet as StoredAgenticWallet & LegacyPrivateKeyCompatible, + ['wallets', `${wallet.id}.private-key`], + overrides?.private_key, + ) as StoredWallet; +} + +function materializePrivateKeyCollection< + T extends + | (StoredAgenticWallet & LegacyPrivateKeyCompatible) + | (PendingAgenticDeployment & LegacyPrivateKeyCompatible) + | (PendingAgenticKeyRotation & LegacyPrivateKeyCompatible), +>(values: T[], overrides: Record, pathSegments: string[]): T[] { + return values.map( + (value) => + materializePrivateKeyRecord(value, [...pathSegments, `${value.id}.private-key`], overrides[value.id]) as T, + ); +} + +export function materializeSecrets(config: TonConfig, secretInputs: SecretMaterializationInput = {}): TonConfig { + const walletSecrets = secretInputs.wallets ?? {}; + const pendingDeploymentSecrets = secretInputs.pendingAgenticDeployments ?? {}; + const pendingRotationSecrets = secretInputs.pendingAgenticKeyRotations ?? {}; + + return { + ...config, + wallets: config.wallets.map((wallet) => materializeStoredWallet(wallet, walletSecrets[wallet.id])), + pending_agentic_deployments: materializePrivateKeyCollection( + config.pending_agentic_deployments as Array, + pendingDeploymentSecrets, + ['pending-agentic-deployments'], + ), + pending_agentic_key_rotations: materializePrivateKeyCollection( + config.pending_agentic_key_rotations as Array, + pendingRotationSecrets, + ['pending-agentic-key-rotations'], + ), + }; +} + +export function readSecretMaterial(value: SecretReadableValue): InlineSecretMaterial | undefined { + const fileSecret = readSecretFile(value.secret_file); + if (fileSecret) { + return { + type: value.secret_type ?? 'private_key', + value: fileSecret, + }; + } + + return readInlineSecret(value); +} + +export function readSecret(value: SecretReadableValue): string | undefined { + return readSecretMaterial(value)?.value; +} + +function deleteSecretFile(filePath: string | undefined): void { + const normalizedPath = filePath?.trim(); + if (!normalizedPath) { + return; + } + + try { + const resolvedPath = resolvePrivateKeyPath(normalizedPath); + if (existsSync(resolvedPath)) { + unlinkSync(resolvedPath); + } + } catch { + // Best-effort only. + } +} + +function migrateLegacyPrivateKeyField( + value: T, +): { + value: T; + changed: boolean; +} { + const legacyPrivateKey = value[LEGACY_AGENTIC_PRIVATE_KEY_FIELD]; + if (!legacyPrivateKey) { + return { value, changed: false }; + } + + const { [LEGACY_AGENTIC_PRIVATE_KEY_FIELD]: _legacyPrivateKey, ...rest } = value; + return { + changed: true, + value: { + ...rest, + private_key: value.private_key ?? legacyPrivateKey, + } as T, + }; +} + +function migrateLegacyPrivateKeyCollection( + values: T[] | undefined, +): { + values: T[]; + changed: boolean; +} { + let changed = false; + const migratedValues = (values ?? []).map((value) => { + const migrated = migrateLegacyPrivateKeyField(value); + const privateKey = trimSecret(readPrivateKeyField(migrated.value)); + if (migrated.changed || privateKey) { + changed = true; + } + return { + ...(omitInlineSecretFields(migrated.value) as T), + ...(privateKey ? { private_key: privateKey } : {}), + } as T; + }); + + return { + values: migratedValues, + changed, + }; +} + +export function migrateFromV2Config(rawConfig: TonConfig): { config: TonConfig; changed: boolean } { + let changed = false; + + const wallets = rawConfig.wallets.map((wallet) => { + if (wallet.type === 'standard') { + const candidate = wallet as StandardSecretInput; + const mnemonic = trimSecret(candidate.mnemonic); + const privateKey = trimSecret(candidate.private_key); + if (!candidate.secret_file && (mnemonic || privateKey)) { + changed = true; + } + return { + ...(omitInlineSecretFields(wallet) as StoredStandardWallet), + ...(candidate.secret_file ? {} : mnemonic ? { mnemonic, secret_type: 'mnemonic' as const } : {}), + ...(candidate.secret_file || mnemonic || !privateKey + ? {} + : { private_key: privateKey, secret_type: 'private_key' as const }), + } as StoredWallet; + } + + const { values, changed: agenticChanged } = migrateLegacyPrivateKeyCollection([ + wallet as StoredAgenticWallet & LegacyPrivateKeyCompatible, + ]); + changed ||= agenticChanged; + return values![0] as StoredWallet; + }); + + const { values: pendingDeployments, changed: deploymentsChanged } = migrateLegacyPrivateKeyCollection( + rawConfig.pending_agentic_deployments as + | Array + | undefined, + ); + const { values: pendingRotations, changed: rotationsChanged } = migrateLegacyPrivateKeyCollection( + rawConfig.pending_agentic_key_rotations as + | Array + | undefined, + ); + changed ||= deploymentsChanged || rotationsChanged; + + return { + changed, + config: { + ...rawConfig, + wallets, + pending_agentic_deployments: pendingDeployments, + pending_agentic_key_rotations: pendingRotations, + }, + }; +} + +export function collectSecretFiles(config: TonConfig | null | undefined): Set { + if (!config) { + return new Set(); + } + + return new Set( + [ + ...config.wallets.map((wallet) => wallet.secret_file), + ...(config.pending_agentic_deployments ?? []).map((deployment) => deployment.secret_file), + ...(config.pending_agentic_key_rotations ?? []).map((rotation) => rotation.secret_file), + ].filter((filePath): filePath is string => Boolean(filePath?.trim())), + ); +} + +export function cleanupOrphanSecretFiles( + before: TonConfig | null | undefined, + after: TonConfig | null | undefined, +): void { + const nextFiles = collectSecretFiles(after); + for (const filePath of collectSecretFiles(before)) { + if (!nextFiles.has(filePath)) { + deleteSecretFile(filePath); + } + } +} + +export function deleteAllSecretFiles(config: TonConfig | null | undefined): void { + for (const filePath of collectSecretFiles(config)) { + deleteSecretFile(filePath); + } +} diff --git a/packages/mcp/src/runtime/wallet-runtime.ts b/packages/mcp/src/runtime/wallet-runtime.ts index 5cb6b5ab9..dbec41f90 100644 --- a/packages/mcp/src/runtime/wallet-runtime.ts +++ b/packages/mcp/src/runtime/wallet-runtime.ts @@ -28,6 +28,8 @@ import type { StoredWallet, TonNetwork, } from '../registry/config.js'; +import { ConfigError } from '../registry/config.js'; +import { readSecret, readSecretMaterial } from '../registry/private-key-files.js'; import { parsePrivateKeyInput } from '../utils/private-key.js'; import { createApiClient } from '../utils/ton-client.js'; @@ -101,30 +103,22 @@ export async function createStandardAdapter(input: { }); } -async function createServiceFromStoredStandard( - wallet: StoredStandardWallet, - contacts: IContactResolver | undefined, - toncenterApiKey?: string, -): Promise { - const signer = await createSignerFromSecrets({ - mnemonic: wallet.mnemonic, - privateKey: wallet.private_key, - }); - const kit = createKit(wallet.network, toncenterApiKey); +async function createWalletServiceWithAdapter(input: { + network: TonNetwork; + contacts: IContactResolver | undefined; + toncenterApiKey?: string; + createAdapter: (kit: TonWalletKitType) => Promise; +}): Promise { + const kit = createKit(input.network, input.toncenterApiKey); await kit.waitForReady(); try { - const adapter = await createStandardAdapter({ - signer, - kit, - network: wallet.network, - walletVersion: wallet.wallet_version, - }); + const adapter = await input.createAdapter(kit); await addWallet(kit, adapter); const service = await McpWalletService.create({ wallet: adapter, - contacts, + contacts: input.contacts, networks: { - [wallet.network]: toncenterApiKey ? { apiKey: toncenterApiKey } : undefined, + [input.network]: input.toncenterApiKey ? { apiKey: input.toncenterApiKey } : undefined, }, }); return { @@ -139,44 +133,59 @@ async function createServiceFromStoredStandard( } } +async function createServiceFromStoredStandard( + wallet: StoredStandardWallet, + contacts: IContactResolver | undefined, + toncenterApiKey?: string, +): Promise { + const secret = readSecretMaterial(wallet); + if (!secret) { + throw new ConfigError( + `Wallet "${wallet.name}" is missing signing credentials. Re-import it with mnemonic or private key before using write tools.`, + ); + } + const signer = await createSignerFromSecrets({ + ...(secret.type === 'mnemonic' ? { mnemonic: secret.value } : {}), + ...(secret.type === 'private_key' ? { privateKey: secret.value } : {}), + }); + return createWalletServiceWithAdapter({ + network: wallet.network, + contacts, + toncenterApiKey, + createAdapter: (kit) => + createStandardAdapter({ + signer, + kit, + network: wallet.network, + walletVersion: wallet.wallet_version, + }), + }); +} + async function createServiceFromStoredAgentic( wallet: StoredAgenticWallet, contacts: IContactResolver | undefined, toncenterApiKey?: string, requiresSigning?: boolean, ): Promise { - if (requiresSigning && !wallet.operator_private_key) { - throw new Error(`Agentic wallet "${wallet.name}" is missing operator_private_key.`); - } - const signer = wallet.operator_private_key - ? await createSignerFromSecrets({ privateKey: wallet.operator_private_key }) - : await createPlaceholderSigner(); - const kit = createKit(wallet.network, toncenterApiKey); - await kit.waitForReady(); - try { - const adapter = await AgenticWalletAdapter.create(signer, { - client: kit.getApiClient(getKitNetwork(wallet.network)), - network: getKitNetwork(wallet.network), - walletAddress: wallet.address, - }); - await addWallet(kit, adapter); - const service = await McpWalletService.create({ - wallet: adapter, - contacts, - networks: { - [wallet.network]: toncenterApiKey ? { apiKey: toncenterApiKey } : undefined, - }, - }); - return { - service, - close: async () => { - await Promise.allSettled([service.close(), closeKitSafely(kit)]); - }, - }; - } catch (error) { - await closeKitSafely(kit); - throw error; + const privateKey = readSecret(wallet); + if (requiresSigning && !privateKey) { + throw new ConfigError( + `Wallet "${wallet.name}" is missing private_key. Rotate the operator key with agentic_rotate_operator_key before using write tools.`, + ); } + const signer = privateKey ? await createSignerFromSecrets({ privateKey }) : await createPlaceholderSigner(); + return createWalletServiceWithAdapter({ + network: wallet.network, + contacts, + toncenterApiKey, + createAdapter: (kit) => + AgenticWalletAdapter.create(signer, { + client: kit.getApiClient(getKitNetwork(wallet.network)), + network: getKitNetwork(wallet.network), + walletAddress: wallet.address, + }), + }); } export async function createMcpWalletServiceFromStoredWallet(input: { diff --git a/packages/mcp/src/services/AgenticOnboardingService.ts b/packages/mcp/src/services/AgenticOnboardingService.ts index 706a615ed..3bab42fe8 100644 --- a/packages/mcp/src/services/AgenticOnboardingService.ts +++ b/packages/mcp/src/services/AgenticOnboardingService.ts @@ -13,16 +13,6 @@ import type { WalletRegistryService } from './WalletRegistryService.js'; import type { AgenticSetupSessionManager } from './AgenticSetupSessionManager.js'; import type { AgenticDeployCallbackPayload, AgenticSetupSession } from './AgenticSetupSessionManager.js'; -function getDefaultAgenticSource(source?: string): string { - const trimmed = source?.trim(); - return trimmed || 'Deployed via @ton/mcp'; -} - -function getDefaultAgenticName(name: string | undefined, operatorPublicKey: string): string { - const trimmed = name?.trim(); - return trimmed || `Agent ${operatorPublicKey.replace(/^0x/i, '').slice(0, 6)}`; -} - function payloadMatchesNetwork(payload: AgenticDeployCallbackPayload, network: TonNetwork): boolean { const chainId = String(payload.network?.chainId ?? ''); return network === 'mainnet' @@ -33,7 +23,7 @@ function payloadMatchesNetwork(payload: AgenticDeployCallbackPayload, network: T export interface AgenticRootWalletSetupStatus { setupId: string; pendingDeployment: PendingAgenticDeployment; - session: AgenticSetupSession | null; + session?: AgenticSetupSession; status: AgenticSetupSession['status'] | 'pending_without_callback'; dashboardUrl?: string; } @@ -60,11 +50,11 @@ export class AgenticOnboardingService { }> { const network = normalizeNetwork(input.network, 'mainnet'); const operator = await generateOperatorKeyPair(); - const resolvedName = getDefaultAgenticName(input.name, operator.publicKey); - const resolvedSource = getDefaultAgenticSource(input.source); + const resolvedName = input.name?.trim() || `Agent ${operator.publicKey.replace(/^0x/i, '').slice(0, 6)}`; + const resolvedSource = input.source?.trim() || 'Deployed via @ton/mcp'; const pendingDeployment = await this.registry.createPendingAgenticSetup({ network, - operatorPrivateKey: operator.privateKey, + privateKey: operator.privateKey, operatorPublicKey: operator.publicKey, name: resolvedName, source: resolvedSource, @@ -95,10 +85,10 @@ export class AgenticOnboardingService { return pending.map((deployment) => this.composeStatus(deployment)); } - async getRootWalletSetup(setupId: string): Promise { + async getRootWalletSetup(setupId: string): Promise { const pending = await this.registry.getPendingAgenticSetup(setupId); if (!pending) { - return null; + return undefined; } return this.composeStatus(pending); } diff --git a/packages/mcp/src/services/AgenticSetupSessionManager.ts b/packages/mcp/src/services/AgenticSetupSessionManager.ts index 5088556a7..8852cd361 100644 --- a/packages/mcp/src/services/AgenticSetupSessionManager.ts +++ b/packages/mcp/src/services/AgenticSetupSessionManager.ts @@ -9,15 +9,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { createServer } from 'node:http'; -import { - createEmptyConfig, - findAgenticSetupSession, - listAgenticSetupSessions, - loadConfig, - removeAgenticSetupSession, - saveConfig, - upsertAgenticSetupSession, -} from '../registry/config.js'; +import { createEmptyConfig, removeAgenticSetupSession, upsertAgenticSetupSession } from '../registry/config.js'; +import { loadConfig, saveConfigTransition } from '../registry/config-persistence.js'; import type { AgenticSetupStatus, StoredAgenticSetupSession } from '../registry/config.js'; export interface AgenticDeployCallbackPayload { @@ -63,20 +56,20 @@ export interface AgenticSetupSessionStore { export class ConfigBackedAgenticSetupSessionStore implements AgenticSetupSessionStore { listSessions(): StoredAgenticSetupSession[] { - return listAgenticSetupSessions(loadConfig() ?? createEmptyConfig()); + return [...(loadConfig() ?? createEmptyConfig()).agentic_setup_sessions]; } upsertSession(session: StoredAgenticSetupSession): void { const config = loadConfig() ?? createEmptyConfig(); - saveConfig(upsertAgenticSetupSession(config, session)); + saveConfigTransition(config, upsertAgenticSetupSession(config, session)); } removeSession(setupId: string): void { const config = loadConfig() ?? createEmptyConfig(); - if (!findAgenticSetupSession(config, setupId)) { + if (!config.agentic_setup_sessions.find((session) => session.setup_id === setupId)) { return; } - saveConfig(removeAgenticSetupSession(config, setupId)); + saveConfigTransition(config, removeAgenticSetupSession(config, setupId)); } } @@ -297,11 +290,11 @@ export class AgenticSetupSessionManager { return { ...session }; } - getSession(setupId: string): AgenticSetupSession | null { + getSession(setupId: string): AgenticSetupSession | undefined { this.syncFromStore(); this.cleanupExpiredSessions(); const session = this.sessions.get(setupId); - return session ? { ...session } : null; + return session ? { ...session } : undefined; } listSessions(): AgenticSetupSession[] { diff --git a/packages/mcp/src/services/WalletRegistryService.ts b/packages/mcp/src/services/WalletRegistryService.ts index 036e4af1e..dff885b74 100644 --- a/packages/mcp/src/services/WalletRegistryService.ts +++ b/packages/mcp/src/services/WalletRegistryService.ts @@ -20,21 +20,18 @@ import { findWalletByAddress, getActiveWallet, getAgenticCollectionAddress, - listPendingAgenticDeployments, - listPendingAgenticKeyRotations, - listWallets, - loadConfigWithMigration, normalizeNetwork, removePendingAgenticDeployment, removePendingAgenticKeyRotation, removeWallet, - saveConfig, setActiveWallet, + updateNetworkConfig, upsertPendingAgenticDeployment, upsertPendingAgenticKeyRotation, - updateNetworkConfig, upsertWallet, } from '../registry/config.js'; +import { loadConfigWithMigration, saveConfigTransition } from '../registry/config-persistence.js'; +import { omitSecretRefFields, readSecret } from '../registry/private-key-files.js'; import type { ConfigNetwork, PendingAgenticDeployment, @@ -97,9 +94,34 @@ export class WalletRegistryService { return config?.networks[network]?.toncenter_api_key?.trim() || undefined; } + private async getReusableAgenticSecretFile( + value: { secret_file?: string }, + expectedOperatorPublicKey?: string, + ): Promise { + const secretFile = value.secret_file?.trim(); + if (!secretFile) { + return undefined; + } + + const secret = readSecret({ secret_file: secretFile }); + if (!secret) { + return undefined; + } + + if (expectedOperatorPublicKey) { + try { + await resolveOperatorCredentials(secret, expectedOperatorPublicKey); + } catch { + return undefined; + } + } + + return secretFile; + } + private assertWalletSupportsSigning(wallet: StoredWallet): void { if (wallet.type === 'standard') { - if (!wallet.mnemonic && !wallet.private_key) { + if (!readSecret(wallet)) { throw new ConfigError( `Wallet "${wallet.name}" is missing signing credentials. Re-import it with mnemonic or private key before using write tools.`, ); @@ -107,22 +129,42 @@ export class WalletRegistryService { return; } - if (!wallet.operator_private_key) { + if (!readSecret(wallet)) { throw new ConfigError( - `Wallet "${wallet.name}" is missing operator_private_key. Rotate the operator key with agentic_rotate_operator_key before using write tools.`, + `Wallet "${wallet.name}" is missing private_key. Rotate the operator key with agentic_rotate_operator_key before using write tools.`, ); } } + private requireSelectedWallet(config: TonConfig, walletSelector?: string): StoredWallet { + const wallet = walletSelector ? findWallet(config, walletSelector) : getActiveWallet(config); + if (!wallet) { + throw new ConfigError( + walletSelector + ? `Wallet "${walletSelector}" was not found.` + : 'No active wallet configured. Import a wallet first or set one active.', + ); + } + return wallet; + } + + private requireAgenticWallet(config: TonConfig, walletSelector?: string): StoredAgenticWallet { + const wallet = this.requireSelectedWallet(config, walletSelector); + if (wallet.type !== 'agentic') { + throw new ConfigError(`Wallet "${wallet.name}" is not an agentic wallet.`); + } + return wallet; + } + async loadConfig(): Promise { return (await loadConfigWithMigration()) ?? createEmptyConfig(); } async listWallets(): Promise { - return listWallets(await this.loadConfig()); + return (await this.loadConfig()).wallets.filter((wallet) => wallet.removed !== true); } - async getCurrentWallet(): Promise { + async getCurrentWallet(): Promise { return getActiveWallet(await this.loadConfig()); } @@ -145,10 +187,10 @@ export class WalletRegistryService { async setNetworkConfig(network: TonNetwork, patch: Partial): Promise { const config = await this.loadConfig(); const nextConfig = updateNetworkConfig(config, network, patch); - saveConfig(nextConfig); + const persistedConfig = saveConfigTransition(config, nextConfig); return { - toncenter_api_key: this.resolveToncenterApiKey(nextConfig, network), - agentic_collection_address: getAgenticCollectionAddress(nextConfig, network), + toncenter_api_key: this.resolveToncenterApiKey(persistedConfig, network), + agentic_collection_address: getAgenticCollectionAddress(persistedConfig, network), }; } @@ -158,8 +200,13 @@ export class WalletRegistryService { if (!result.wallet) { throw new ConfigError(`Wallet "${selector}" was not found.`); } - saveConfig(result.config); - return result.wallet; + const walletId = result.wallet.id; + const persistedConfig = saveConfigTransition(config, result.config); + const wallet = findWallet(persistedConfig, walletId); + if (!wallet) { + throw new ConfigError(`Wallet "${walletId}" was not found after saving config.`); + } + return wallet; } async removeWallet(selector: string): Promise<{ removedWalletId: string; activeWalletId: string | null }> { @@ -168,8 +215,8 @@ export class WalletRegistryService { if (!result.removed) { throw new ConfigError(`Wallet "${selector}" was not found.`); } - saveConfig(result.config); - return { removedWalletId: result.removed.id, activeWalletId: result.config.active_wallet_id }; + const persistedConfig = saveConfigTransition(config, result.config); + return { removedWalletId: result.removed.id, activeWalletId: persistedConfig.active_wallet_id }; } async createWalletService( @@ -177,25 +224,18 @@ export class WalletRegistryService { options?: { requiresSigning?: boolean }, ): Promise { const config = await this.loadConfig(); - const wallet = walletSelector ? findWallet(config, walletSelector) : getActiveWallet(config); - if (!wallet) { - throw new ConfigError( - walletSelector - ? `Wallet "${walletSelector}" was not found.` - : 'No active wallet configured. Import a wallet first or set one active.', - ); - } + const selectedWallet = this.requireSelectedWallet(config, walletSelector); if (options?.requiresSigning) { - this.assertWalletSupportsSigning(wallet); + this.assertWalletSupportsSigning(selectedWallet); } - const toncenterApiKey = this.resolveToncenterApiKey(config, wallet.network); + const toncenterApiKey = this.resolveToncenterApiKey(config, selectedWallet.network); const context = await createMcpWalletServiceFromStoredWallet({ - wallet, + wallet: selectedWallet, contacts: this.contacts, toncenterApiKey, requiresSigning: options?.requiresSigning, }); - return { ...context, wallet }; + return { ...context, wallet: selectedWallet }; } async validateAgenticWallet(input: { @@ -260,24 +300,18 @@ export class WalletRegistryService { } const validatedOperatorPublicKey = validated.operatorPublicKey; - let operatorPrivateKey: string | undefined; - let operatorPublicKey = validatedOperatorPublicKey; + let secretFile: string | undefined; const matchedPendingDeployment = validatedOperatorPublicKey ? findPendingAgenticDeployment(config, { network, operatorPublicKey: validatedOperatorPublicKey, }) - : null; + : undefined; if (matchedPendingDeployment) { - operatorPrivateKey = matchedPendingDeployment.operator_private_key; - } else if (existingWallet?.type === 'agentic' && existingWallet.operator_private_key) { - operatorPrivateKey = existingWallet.operator_private_key; - } - - if (operatorPrivateKey && validatedOperatorPublicKey) { - operatorPublicKey = (await resolveOperatorCredentials(operatorPrivateKey, validatedOperatorPublicKey)) - .publicKey; + secretFile = await this.getReusableAgenticSecretFile(matchedPendingDeployment, validatedOperatorPublicKey); + } else if (existingWallet?.type === 'agentic') { + secretFile = await this.getReusableAgenticSecretFile(existingWallet, validatedOperatorPublicKey); } const record = createAgenticWalletRecord({ @@ -290,8 +324,8 @@ export class WalletRegistryService { network, address: validated.address, ownerAddress: validated.ownerAddress, - operatorPrivateKey, - operatorPublicKey, + secretFile, + operatorPublicKey: validatedOperatorPublicKey, source: matchedPendingDeployment?.source || 'Manual import', collectionAddress: validated.collectionAddress, originOperatorPublicKey: validated.originOperatorPublicKey, @@ -300,23 +334,28 @@ export class WalletRegistryService { const walletToSave = existingWallet?.type === 'agentic' - ? { - ...existingWallet, + ? ({ + ...omitSecretRefFields(existingWallet), ...record, + secret_file: secretFile, id: existingWallet.id, created_at: existingWallet.created_at, updated_at: existingWallet.updated_at, - } + } as StoredAgenticWallet) : record; const nextConfig = removePendingAgenticDeployment(upsertWallet(config, walletToSave, { setActive: true }), { network, operatorPublicKey: validatedOperatorPublicKey, }); - saveConfig(nextConfig); + const persistedConfig = saveConfigTransition(config, nextConfig); + const wallet = findWallet(persistedConfig, walletToSave.id); + if (!wallet || wallet.type !== 'agentic') { + throw new ConfigError(`Wallet "${walletToSave.id}" was not found after saving config.`); + } return { - wallet: walletToSave, + wallet, recoveredPendingKeyDraft: Boolean(matchedPendingDeployment), updatedExisting: Boolean(existingWallet), dashboardUrl: buildAgenticDashboardLink(walletToSave.address), @@ -325,55 +364,54 @@ export class WalletRegistryService { async startAgenticKeyRotation(input: { walletSelector?: string; - operatorPrivateKey?: string; + privateKey?: string; }): Promise { const config = await this.loadConfig(); - const wallet = input.walletSelector ? findWallet(config, input.walletSelector) : getActiveWallet(config); - if (!wallet) { - throw new ConfigError( - input.walletSelector - ? `Wallet "${input.walletSelector}" was not found.` - : 'No active wallet configured. Import a wallet first or set one active.', - ); - } - if (wallet.type !== 'agentic') { - throw new ConfigError(`Wallet "${wallet.name}" is not an agentic wallet.`); - } + const selectedWallet = this.requireAgenticWallet(config, input.walletSelector); - const operatorCredentials = input.operatorPrivateKey - ? await resolveOperatorCredentials(input.operatorPrivateKey) + const operatorCredentials = input.privateKey + ? await resolveOperatorCredentials(input.privateKey) : await generateOperatorKeyPair(); const pendingRotation = createPendingAgenticKeyRotation({ - walletId: wallet.id, - network: wallet.network, - walletAddress: wallet.address, - ownerAddress: wallet.owner_address, - collectionAddress: wallet.collection_address, - operatorPrivateKey: operatorCredentials.privateKey, + walletId: selectedWallet.id, + network: selectedWallet.network, + walletAddress: selectedWallet.address, + ownerAddress: selectedWallet.owner_address, + collectionAddress: selectedWallet.collection_address, operatorPublicKey: operatorCredentials.publicKey, - idPrefix: wallet.name, + idPrefix: selectedWallet.name, }); - const updatedExisting = Boolean(findPendingAgenticKeyRotation(config, { walletId: wallet.id })); + const updatedExisting = Boolean(findPendingAgenticKeyRotation(config, { walletId: selectedWallet.id })); const nextConfig = upsertPendingAgenticKeyRotation( - removePendingAgenticKeyRotation(config, { walletId: wallet.id }), + removePendingAgenticKeyRotation(config, { walletId: selectedWallet.id }), pendingRotation, ); - saveConfig(nextConfig); + const persistedConfig = saveConfigTransition(config, nextConfig, { + pendingAgenticKeyRotations: { + [pendingRotation.id]: operatorCredentials.privateKey, + }, + }); + const storedPendingRotation = findPendingAgenticKeyRotation(persistedConfig, { id: pendingRotation.id }); + if (!storedPendingRotation) { + throw new ConfigError( + `Pending agentic key rotation "${pendingRotation.id}" was not found after saving config.`, + ); + } return { - wallet, - pendingRotation, - dashboardUrl: buildAgenticChangeKeyDeepLink(wallet.address, operatorCredentials.publicKey), + wallet: selectedWallet as StoredAgenticWallet, + pendingRotation: storedPendingRotation, + dashboardUrl: buildAgenticChangeKeyDeepLink(selectedWallet.address, operatorCredentials.publicKey), updatedExisting, }; } async listPendingAgenticKeyRotations(): Promise { - return listPendingAgenticKeyRotations(await this.loadConfig()); + return [...(await this.loadConfig()).pending_agentic_key_rotations]; } - async getPendingAgenticKeyRotation(rotationId: string): Promise { + async getPendingAgenticKeyRotation(rotationId: string): Promise { return findPendingAgenticKeyRotation(await this.loadConfig(), { id: rotationId }); } @@ -411,18 +449,22 @@ export class WalletRegistryService { } const updatedWallet: StoredAgenticWallet = { - ...wallet, - operator_private_key: pendingRotation.operator_private_key, + ...(wallet as StoredAgenticWallet), + ...(pendingRotation.secret_file ? { secret_file: pendingRotation.secret_file } : {}), operator_public_key: pendingRotation.operator_public_key, }; const nextConfig = removePendingAgenticKeyRotation( upsertWallet(config, updatedWallet, { setActive: wallet.id === config.active_wallet_id }), { id: pendingRotation.id }, ); - saveConfig(nextConfig); + const persistedConfig = saveConfigTransition(config, nextConfig); + const persistedWallet = findWallet(persistedConfig, updatedWallet.id); + if (!persistedWallet || persistedWallet.type !== 'agentic') { + throw new ConfigError(`Wallet "${updatedWallet.id}" was not found after saving config.`); + } return { - wallet: updatedWallet, + wallet: persistedWallet, pendingRotation, dashboardUrl: buildAgenticDashboardLink(wallet.address), }; @@ -430,20 +472,20 @@ export class WalletRegistryService { async cancelAgenticKeyRotation(rotationId: string): Promise { const config = await this.loadConfig(); - saveConfig(removePendingAgenticKeyRotation(config, { id: rotationId })); + saveConfigTransition(config, removePendingAgenticKeyRotation(config, { id: rotationId })); } async listPendingAgenticSetups(): Promise { - return listPendingAgenticDeployments(await this.loadConfig()); + return [...(await this.loadConfig()).pending_agentic_deployments]; } - async getPendingAgenticSetup(setupId: string): Promise { + async getPendingAgenticSetup(setupId: string): Promise { return findPendingAgenticDeployment(await this.loadConfig(), { id: setupId }); } async createPendingAgenticSetup(input: { network: TonNetwork; - operatorPrivateKey: string; + privateKey: string; operatorPublicKey: string; name?: string; source?: string; @@ -451,8 +493,17 @@ export class WalletRegistryService { }): Promise { const config = await this.loadConfig(); const deployment = createPendingAgenticDeployment(input); - saveConfig(upsertPendingAgenticDeployment(config, deployment)); - return deployment; + const nextConfig = upsertPendingAgenticDeployment(config, deployment); + const persistedConfig = saveConfigTransition(config, nextConfig, { + pendingAgenticDeployments: { + [deployment.id]: input.privateKey, + }, + }); + const persistedDeployment = findPendingAgenticDeployment(persistedConfig, { id: deployment.id }); + if (!persistedDeployment) { + throw new ConfigError(`Pending agentic setup "${deployment.id}" was not found after saving config.`); + } + return persistedDeployment; } async removePendingAgenticSetup(input: { @@ -461,7 +512,7 @@ export class WalletRegistryService { operatorPublicKey?: string; }): Promise { const config = await this.loadConfig(); - saveConfig(removePendingAgenticDeployment(config, input)); + saveConfigTransition(config, removePendingAgenticDeployment(config, input)); } async completePendingAgenticSetup(input: { @@ -505,7 +556,7 @@ export class WalletRegistryService { network: pending.network, address: input.validatedWallet.address, ownerAddress: input.validatedWallet.ownerAddress, - operatorPrivateKey: pending.operator_private_key, + secretFile: pending.secret_file, operatorPublicKey: pending.operator_public_key || input.validatedWallet.operatorPublicKey, source: input.source?.trim() || pending.source?.trim() || 'Deployed via @ton/mcp', collectionAddress: input.validatedWallet.collectionAddress || pending.collection_address, @@ -515,19 +566,23 @@ export class WalletRegistryService { const walletToSave = existingWallet?.type === 'agentic' - ? { + ? ({ ...existingWallet, ...record, id: existingWallet.id, created_at: existingWallet.created_at, updated_at: existingWallet.updated_at, - } + } as StoredAgenticWallet) : record; const nextConfig = removePendingAgenticDeployment(upsertWallet(config, walletToSave, { setActive: true }), { id: pending.id, }); - saveConfig(nextConfig); - return walletToSave; + const persistedConfig = saveConfigTransition(config, nextConfig); + const wallet = findWallet(persistedConfig, walletToSave.id); + if (!wallet || wallet.type !== 'agentic') { + throw new ConfigError(`Wallet "${walletToSave.id}" was not found after saving config.`); + } + return wallet; } } diff --git a/packages/mcp/src/tools/agentic-onboarding-tools.ts b/packages/mcp/src/tools/agentic-onboarding-tools.ts index 30e3d01bf..122c89cac 100644 --- a/packages/mcp/src/tools/agentic-onboarding-tools.ts +++ b/packages/mcp/src/tools/agentic-onboarding-tools.ts @@ -10,42 +10,11 @@ import { z } from 'zod'; import type { AgenticOnboardingService } from '../services/AgenticOnboardingService.js'; import { - sanitizeRootWalletSetup, - sanitizeRootWalletSetups, - sanitizePendingAgenticDeployment, - sanitizeWallet, + sanitizeAgenticRootWalletSetupStatus, + sanitizePrivateKeyBackedValue, + sanitizeStoredWallet, } from './sanitize.js'; -import type { ToolResponse } from './types.js'; - -function successResponse(data: unknown): ToolResponse { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ success: true, ...((data as object | null) ?? {}) }, null, 2), - }, - ], - }; -} - -function errorResponse(error: unknown): ToolResponse { - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }, - null, - 2, - ), - }, - ], - isError: true, - }; -} +import { wrapToolHandler } from './responses.js'; const startAgenticRootWalletSetupSchema = z.object({ network: z.enum(['mainnet', 'testnet']).optional().describe('Network for the new root wallet (default: mainnet)'), @@ -71,76 +40,56 @@ export function createMcpAgenticOnboardingTools(onboarding: AgenticOnboardingSer description: 'Start first-root-agent setup: generate operator keys, persist a pending draft, and return dashboard and callback URLs. Agents with shell/browser access should open the dashboard URL. Waiting for callback_received applies to long-lived stdio/HTTP server sessions; raw CLI should complete manually with walletAddress.', inputSchema: startAgenticRootWalletSetupSchema, - handler: async (args: z.infer): Promise => { - try { - const result = await onboarding.startRootWalletSetup(args); - return successResponse({ - ...result, - pendingDeployment: sanitizePendingAgenticDeployment(result.pendingDeployment), - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const result = await onboarding.startRootWalletSetup(args); + return { + ...result, + pendingDeployment: sanitizePrivateKeyBackedValue(result.pendingDeployment), + }; + }), }, list_pending_agentic_root_wallet_setups: { description: 'List pending root-agent onboarding drafts and their callback/session status.', inputSchema: z.object({}), - handler: async (): Promise => { - try { - const setups = await onboarding.listRootWalletSetups(); - return successResponse({ - setups: sanitizeRootWalletSetups(setups), - count: setups.length, - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async () => { + const setups = await onboarding.listRootWalletSetups(); + return { + setups: setups.map((setup) => sanitizeAgenticRootWalletSetupStatus(setup)), + count: setups.length, + }; + }), }, get_agentic_root_wallet_setup: { description: 'Get one pending root-agent onboarding draft by setup id.', inputSchema: setupIdSchema, - handler: async (args: z.infer): Promise => { - try { - const setup = await onboarding.getRootWalletSetup(args.setupId); - return successResponse({ setup: sanitizeRootWalletSetup(setup) }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const setup = await onboarding.getRootWalletSetup(args.setupId); + return { setup: sanitizeAgenticRootWalletSetupStatus(setup) }; + }), }, complete_agentic_root_wallet_setup: { description: 'Complete root-agent onboarding from callback payload or manually supplied wallet address, then import the resulting wallet and make it active.', inputSchema: completeAgenticRootWalletSetupSchema, - handler: async (args: z.infer): Promise => { - try { - const result = await onboarding.completeRootWalletSetup(args); - return successResponse({ - ...result, - wallet: sanitizeWallet(result.wallet), - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const result = await onboarding.completeRootWalletSetup(args); + return { + ...result, + wallet: sanitizeStoredWallet(result.wallet), + }; + }), }, cancel_agentic_root_wallet_setup: { description: 'Cancel a pending root-agent onboarding draft and remove its pending state.', inputSchema: setupIdSchema, - handler: async (args: z.infer): Promise => { - try { - await onboarding.cancelRootWalletSetup(args.setupId); - return successResponse({ setupId: args.setupId, cancelled: true }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + await onboarding.cancelRootWalletSetup(args.setupId); + return { setupId: args.setupId, cancelled: true }; + }), }, }; } diff --git a/packages/mcp/src/tools/responses.ts b/packages/mcp/src/tools/responses.ts new file mode 100644 index 000000000..dcd2e60ca --- /dev/null +++ b/packages/mcp/src/tools/responses.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { ToolResponse } from './types.js'; + +export function wrapToolHandler( + handler: (args: TArgs) => Promise, +): (args: TArgs) => Promise { + return async (args: TArgs): Promise => { + try { + const data = await handler(args); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ success: true, ...((data as object | null) ?? {}) }, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } + }; +} diff --git a/packages/mcp/src/tools/sanitize.ts b/packages/mcp/src/tools/sanitize.ts index 9b01585c2..2e79b14ab 100644 --- a/packages/mcp/src/tools/sanitize.ts +++ b/packages/mcp/src/tools/sanitize.ts @@ -11,66 +11,46 @@ import type { PendingAgenticDeployment, PendingAgenticKeyRotation, StoredAgenticWallet, - StoredStandardWallet, StoredWallet, } from '../registry/config.js'; +import { omitInlineSecretFields, omitSecretRefFields, readSecretMaterial } from '../registry/private-key-files.js'; import type { AgenticRootWalletSetupStatus } from '../services/AgenticOnboardingService.js'; -export type PublicStandardWallet = Omit & { - has_mnemonic: boolean; - has_private_key: boolean; -}; -export type PublicAgenticWallet = Omit & { - has_operator_private_key: boolean; -}; -export type PublicStoredWallet = PublicStandardWallet | PublicAgenticWallet; - -export interface PublicNetworkConfig { +interface PublicNetworkConfig { has_toncenter_api_key: boolean; agentic_collection_address?: string; } -export type PublicPendingAgenticDeployment = Omit & { - has_operator_private_key: boolean; -}; -export type PublicPendingAgenticKeyRotation = Omit & { - has_operator_private_key: boolean; -}; - -export interface PublicAgenticRootWalletSetupStatus extends Omit { - pendingDeployment: PublicPendingAgenticDeployment; +export function sanitizePrivateKeyBackedValue( + value: StoredAgenticWallet | PendingAgenticDeployment | PendingAgenticKeyRotation, +) { + const secret = readSecretMaterial(value); + return { + ...(omitSecretRefFields(omitInlineSecretFields(value)) as Omit), + has_private_key: Boolean(secret), + }; } -export function sanitizeStoredWallet(wallet: StoredWallet | null): PublicStoredWallet | null { +export function sanitizeStoredWallet(wallet: StoredWallet | null | undefined) { if (!wallet) { return null; } if (wallet.type === 'standard') { - const { mnemonic: _mnemonic, private_key: _privateKey, ...publicWallet } = wallet; + const secret = readSecretMaterial(wallet); return { - ...publicWallet, - has_mnemonic: Boolean(wallet.mnemonic), - has_private_key: Boolean(wallet.private_key), + ...(omitSecretRefFields(omitInlineSecretFields(wallet)) as Omit< + typeof wallet, + 'secret_file' | 'secret_type' + >), + has_mnemonic: secret?.type === 'mnemonic', + has_private_key: secret?.type === 'private_key', }; } - const { operator_private_key: _operatorPrivateKey, ...publicWallet } = wallet; - return { - ...publicWallet, - has_operator_private_key: Boolean(wallet.operator_private_key), - }; -} - -export function sanitizeStoredWallets(wallets: StoredWallet[]): PublicStoredWallet[] { - return wallets - .map((wallet) => sanitizeStoredWallet(wallet)) - .filter((wallet): wallet is PublicStoredWallet => wallet !== null); + return sanitizePrivateKeyBackedValue(wallet); } -export const sanitizeWallet = sanitizeStoredWallet; -export const sanitizeWallets = sanitizeStoredWallets; - export function sanitizeNetworkConfig(config: ConfigNetwork): PublicNetworkConfig { return { has_toncenter_api_key: Boolean(config.toncenter_api_key), @@ -78,56 +58,14 @@ export function sanitizeNetworkConfig(config: ConfigNetwork): PublicNetworkConfi }; } -export function sanitizePendingAgenticDeployment(deployment: PendingAgenticDeployment): PublicPendingAgenticDeployment { - const { operator_private_key: _operatorPrivateKey, ...publicDeployment } = deployment; - return { - ...publicDeployment, - has_operator_private_key: Boolean(deployment.operator_private_key), - }; -} - -export function sanitizePendingAgenticDeployments( - deployments: PendingAgenticDeployment[], -): PublicPendingAgenticDeployment[] { - return deployments.map((deployment) => sanitizePendingAgenticDeployment(deployment)); -} - -export function sanitizePendingAgenticKeyRotation( - rotation: PendingAgenticKeyRotation, -): PublicPendingAgenticKeyRotation { - const { operator_private_key: _operatorPrivateKey, ...publicRotation } = rotation; - return { - ...publicRotation, - has_operator_private_key: Boolean(rotation.operator_private_key), - }; -} - -export function sanitizePendingAgenticKeyRotations( - rotations: PendingAgenticKeyRotation[], -): PublicPendingAgenticKeyRotation[] { - return rotations.map((rotation) => sanitizePendingAgenticKeyRotation(rotation)); -} - -export function sanitizeAgenticRootWalletSetupStatus( - setup: AgenticRootWalletSetupStatus | null, -): PublicAgenticRootWalletSetupStatus | null { +export function sanitizeAgenticRootWalletSetupStatus(setup: AgenticRootWalletSetupStatus | null | undefined) { if (!setup) { return null; } return { ...setup, - pendingDeployment: sanitizePendingAgenticDeployment(setup.pendingDeployment), + session: setup.session ?? null, + pendingDeployment: sanitizePrivateKeyBackedValue(setup.pendingDeployment), }; } - -export function sanitizeAgenticRootWalletSetupStatuses( - setups: AgenticRootWalletSetupStatus[], -): PublicAgenticRootWalletSetupStatus[] { - return setups - .map((setup) => sanitizeAgenticRootWalletSetupStatus(setup)) - .filter(Boolean) as PublicAgenticRootWalletSetupStatus[]; -} - -export const sanitizeRootWalletSetup = sanitizeAgenticRootWalletSetupStatus; -export const sanitizeRootWalletSetups = sanitizeAgenticRootWalletSetupStatuses; diff --git a/packages/mcp/src/tools/wallet-management-tools.ts b/packages/mcp/src/tools/wallet-management-tools.ts index 82b291548..3dbc13c12 100644 --- a/packages/mcp/src/tools/wallet-management-tools.ts +++ b/packages/mcp/src/tools/wallet-management-tools.ts @@ -10,44 +10,8 @@ import { z } from 'zod'; import type { WalletRegistryService } from '../services/WalletRegistryService.js'; import { normalizeNetwork } from '../registry/config.js'; -import { - sanitizeNetworkConfig, - sanitizePendingAgenticKeyRotation, - sanitizePendingAgenticKeyRotations, - sanitizeWallet, - sanitizeWallets, -} from './sanitize.js'; -import type { ToolResponse } from './types.js'; - -function successResponse(data: unknown): ToolResponse { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ success: true, ...((data as object | null) ?? {}) }, null, 2), - }, - ], - }; -} - -function errorResponse(error: unknown): ToolResponse { - return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }, - null, - 2, - ), - }, - ], - isError: true, - }; -} +import { wrapToolHandler } from './responses.js'; +import { sanitizeNetworkConfig, sanitizePrivateKeyBackedValue, sanitizeStoredWallet } from './sanitize.js'; // const getNetworkConfigSchema = z.object({ // network: z.enum(['mainnet', 'testnet']).describe('Network to inspect'), @@ -90,7 +54,7 @@ const rotateOperatorKeySchema = z.object({ .string() .optional() .describe('Optional wallet id, name, or address. Uses the active wallet when omitted.'), - operatorPrivateKey: z.string().optional().describe('Optional replacement operator private key'), + privateKey: z.string().optional().describe('Optional replacement private key'), }); const pendingOperatorKeyRotationSchema = z.object({ @@ -102,58 +66,41 @@ export function createMcpWalletManagementTools(registry: WalletRegistryService) list_wallets: { description: 'List all wallets stored in the local TON config registry.', inputSchema: z.object({}), - handler: async (): Promise => { - try { - const wallets = await registry.listWallets(); - const current = await registry.getCurrentWallet(); - return successResponse({ - wallets: sanitizeWallets(wallets), - count: wallets.length, - activeWalletId: current?.id ?? null, - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async () => { + const wallets = await registry.listWallets(); + const current = await registry.getCurrentWallet(); + return { + wallets: wallets.map((wallet) => sanitizeStoredWallet(wallet)), + count: wallets.length, + activeWalletId: current?.id ?? null, + }; + }), }, get_current_wallet: { description: 'Get the currently active wallet from the local TON config registry.', inputSchema: z.object({}), - handler: async (): Promise => { - try { - const wallet = await registry.getCurrentWallet(); - return successResponse({ wallet: sanitizeWallet(wallet) }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async () => { + const wallet = await registry.getCurrentWallet(); + return { wallet: sanitizeStoredWallet(wallet) }; + }), }, set_active_wallet: { description: 'Set the active wallet by id, name, or address.', inputSchema: setActiveWalletSchema, - handler: async (args: z.infer): Promise => { - try { - const wallet = await registry.setActiveWallet(args.walletSelector); - return successResponse({ wallet: sanitizeWallet(wallet), activeWalletId: wallet.id }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const wallet = await registry.setActiveWallet(args.walletSelector); + return { wallet: sanitizeStoredWallet(wallet), activeWalletId: wallet.id }; + }), }, remove_wallet: { description: 'Soft-delete a stored wallet from the local TON config registry.', inputSchema: removeWalletSchema, - handler: async (args: z.infer): Promise => { - try { - const result = await registry.removeWallet(args.walletSelector); - return successResponse(result); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => + registry.removeWallet(args.walletSelector), + ), }, // get_network_config: { @@ -175,150 +122,113 @@ export function createMcpWalletManagementTools(registry: WalletRegistryService) set_network_config: { description: 'Update Toncenter or agentic collection settings for a network.', inputSchema: setNetworkConfigSchema, - handler: async (args: z.infer): Promise => { - try { - const config = await registry.setNetworkConfig(args.network, { - ...(args.toncenterApiKey !== undefined ? { toncenter_api_key: args.toncenterApiKey } : {}), - ...(args.agenticCollectionAddress !== undefined - ? { agentic_collection_address: args.agenticCollectionAddress } - : {}), - }); - return successResponse({ - network: args.network, - config: sanitizeNetworkConfig(config), - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const config = await registry.setNetworkConfig(args.network, { + ...(args.toncenterApiKey !== undefined ? { toncenter_api_key: args.toncenterApiKey } : {}), + ...(args.agenticCollectionAddress !== undefined + ? { agentic_collection_address: args.agenticCollectionAddress } + : {}), + }); + return { + network: args.network, + config: sanitizeNetworkConfig(config), + }; + }), }, validate_agentic_wallet: { description: 'Validate an existing agentic wallet address against the expected network and collection.', inputSchema: validateAgenticWalletSchema, - handler: async (args: z.infer): Promise => { - try { - const wallet = await registry.validateAgenticWallet(args); - return successResponse({ wallet }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => ({ + wallet: await registry.validateAgenticWallet(args), + })), }, list_agentic_wallets_by_owner: { description: 'List agentic wallets owned by a given main wallet address.', inputSchema: listAgenticWalletsByOwnerSchema, - handler: async (args: z.infer): Promise => { - try { - const wallets = await registry.listAgenticWalletsByOwner(args); - return successResponse({ - ownerAddress: args.ownerAddress, - network: normalizeNetwork(args.network, 'mainnet'), - wallets, - count: wallets.length, - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const wallets = await registry.listAgenticWalletsByOwner(args); + return { + ownerAddress: args.ownerAddress, + network: normalizeNetwork(args.network, 'mainnet'), + wallets, + count: wallets.length, + }; + }), }, import_agentic_wallet: { description: 'Import an existing agentic wallet into the local TON config registry. Recovers a matching pending key draft when available; otherwise import is read-only until agentic_rotate_operator_key is completed.', inputSchema: importAgenticWalletSchema, - handler: async (args: z.infer): Promise => { - try { - const result = await registry.importAgenticWallet(args); - return successResponse({ - ...result, - wallet: sanitizeWallet(result.wallet), - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const result = await registry.importAgenticWallet(args); + return { + ...result, + wallet: sanitizeStoredWallet(result.wallet), + }; + }), }, rotate_operator_key: { description: 'Start agentic operator key rotation: generate or accept a replacement operator key, persist a pending draft, and return a dashboard URL for the on-chain change. Agents with shell/browser access should open the dashboard URL instead of asking the user to copy it manually.', inputSchema: rotateOperatorKeySchema, - handler: async (args: z.infer): Promise => { - try { - const result = await registry.startAgenticKeyRotation(args); - return successResponse({ - ...result, - wallet: sanitizeWallet(result.wallet), - pendingRotation: sanitizePendingAgenticKeyRotation(result.pendingRotation), - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const result = await registry.startAgenticKeyRotation(args); + return { + ...result, + wallet: sanitizeStoredWallet(result.wallet), + pendingRotation: sanitizePrivateKeyBackedValue(result.pendingRotation), + }; + }), }, list_pending_operator_key_rotations: { description: 'List pending agentic operator key rotations stored in the local TON config registry.', inputSchema: z.object({}), - handler: async (): Promise => { - try { - const rotations = await registry.listPendingAgenticKeyRotations(); - return successResponse({ - rotations: sanitizePendingAgenticKeyRotations(rotations), - count: rotations.length, - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async () => { + const rotations = await registry.listPendingAgenticKeyRotations(); + return { + rotations: rotations.map((rotation) => sanitizePrivateKeyBackedValue(rotation)), + count: rotations.length, + }; + }), }, get_pending_operator_key_rotation: { description: 'Get one pending agentic operator key rotation by id.', inputSchema: pendingOperatorKeyRotationSchema, - handler: async (args: z.infer): Promise => { - try { - const rotation = await registry.getPendingAgenticKeyRotation(args.rotationId); - return successResponse({ - rotation: rotation ? sanitizePendingAgenticKeyRotation(rotation) : null, - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const rotation = await registry.getPendingAgenticKeyRotation(args.rotationId); + return { + rotation: rotation ? sanitizePrivateKeyBackedValue(rotation) : null, + }; + }), }, complete_rotate_operator_key: { description: 'Complete an agentic operator key rotation after the dashboard transaction lands on chain, then update the stored operator key locally.', inputSchema: pendingOperatorKeyRotationSchema, - handler: async (args: z.infer): Promise => { - try { - const result = await registry.completeAgenticKeyRotation(args.rotationId); - return successResponse({ - ...result, - wallet: sanitizeWallet(result.wallet), - pendingRotation: sanitizePendingAgenticKeyRotation(result.pendingRotation), - }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + const result = await registry.completeAgenticKeyRotation(args.rotationId); + return { + ...result, + wallet: sanitizeStoredWallet(result.wallet), + pendingRotation: sanitizePrivateKeyBackedValue(result.pendingRotation), + }; + }), }, cancel_rotate_operator_key: { description: 'Cancel a pending agentic operator key rotation and discard its stored replacement key.', inputSchema: pendingOperatorKeyRotationSchema, - handler: async (args: z.infer): Promise => { - try { - await registry.cancelAgenticKeyRotation(args.rotationId); - return successResponse({ rotationId: args.rotationId, cancelled: true }); - } catch (error) { - return errorResponse(error); - } - }, + handler: wrapToolHandler(async (args: z.infer) => { + await registry.cancelAgenticKeyRotation(args.rotationId); + return { rotationId: args.rotationId, cancelled: true }; + }), }, }; } From 523e8d707a818a2506f516cf2ac9a709cc1588cd Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Tue, 17 Mar 2026 14:33:41 +0300 Subject: [PATCH 3/4] feat: add encryption --- .../__tests__/WalletRegistryService.spec.ts | 20 ++++- packages/mcp/src/__tests__/config.spec.ts | 56 +++++++++++--- .../mcp/src/__tests__/protected-file.spec.ts | 42 +++++++++++ .../mcp/src/registry/config-persistence.ts | 6 +- .../mcp/src/registry/private-key-files.ts | 6 +- packages/mcp/src/registry/protected-file.ts | 74 +++++++++++++++++++ 6 files changed, 184 insertions(+), 20 deletions(-) create mode 100644 packages/mcp/src/__tests__/protected-file.spec.ts create mode 100644 packages/mcp/src/registry/protected-file.ts diff --git a/packages/mcp/src/__tests__/WalletRegistryService.spec.ts b/packages/mcp/src/__tests__/WalletRegistryService.spec.ts index bebc6d698..0482effe3 100644 --- a/packages/mcp/src/__tests__/WalletRegistryService.spec.ts +++ b/packages/mcp/src/__tests__/WalletRegistryService.spec.ts @@ -6,7 +6,13 @@ * */ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdtempSync, + readFileSync as rawReadFileSync, + rmSync, + writeFileSync as rawWriteFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -51,6 +57,7 @@ import { createStandardWalletRecord, } from '../registry/config.js'; import { loadConfig, saveConfig } from '../registry/config-persistence.js'; +import { readFileSync } from '../registry/protected-file.js'; import { WalletRegistryService } from '../services/WalletRegistryService.js'; describe('WalletRegistryService', () => { @@ -187,7 +194,7 @@ describe('WalletRegistryService', () => { }); it('supports inline v2 secrets before creating a signing service', async () => { - writeFileSync( + rawWriteFileSync( process.env.TON_CONFIG_PATH!, JSON.stringify({ version: 2, @@ -490,6 +497,9 @@ describe('WalletRegistryService', () => { 'utf-8', ).trim(), ).toBe('0xold-private'); + expect( + rawReadFileSync(resolveSecretPath((stored?.wallets[0] as { secret_file: string }).secret_file), 'utf-8'), + ).not.toContain('0xold-private'); expect(stored?.pending_agentic_key_rotations).toEqual([ expect.objectContaining({ id: result.pendingRotation.id, @@ -504,6 +514,12 @@ describe('WalletRegistryService', () => { 'utf-8', ).trim(), ).toBe('0xgenerated-private'); + expect( + rawReadFileSync( + resolveSecretPath((stored?.pending_agentic_key_rotations?.[0] as { secret_file: string }).secret_file), + 'utf-8', + ), + ).not.toContain('0xgenerated-private'); }); it('completes an agentic key rotation after the on-chain operator public key changes', async () => { diff --git a/packages/mcp/src/__tests__/config.spec.ts b/packages/mcp/src/__tests__/config.spec.ts index e50dd4c8c..e65bdb2e1 100644 --- a/packages/mcp/src/__tests__/config.spec.ts +++ b/packages/mcp/src/__tests__/config.spec.ts @@ -6,7 +6,15 @@ * */ -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync as rawReadFileSync, + rmSync, + statSync, + writeFileSync as rawWriteFileSync, +} from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; @@ -37,6 +45,7 @@ import { saveConfig, saveConfigTransition, } from '../registry/config-persistence.js'; +import { readFileSync } from '../registry/protected-file.js'; import { LEGACY_AGENTIC_PRIVATE_KEY_FIELD } from '../registry/private-key-field.js'; describe('mcp config registry', () => { @@ -96,6 +105,12 @@ describe('mcp config registry', () => { 'utf-8', ).trim(), ).toBe('a '.repeat(24).trim()); + expect( + rawReadFileSync( + loaded?.wallets[0]?.type === 'standard' ? resolveSecretPath(loaded.wallets[0].secret_file!) : '', + 'utf-8', + ), + ).not.toContain('a '.repeat(24).trim()); const fileMode = statSync(process.env.TON_CONFIG_PATH!).mode & 0o777; expect(fileMode).toBe(0o600); @@ -105,7 +120,7 @@ describe('mcp config registry', () => { }); it('migrates legacy config payloads to the current version on first read', async () => { - writeFileSync( + rawWriteFileSync( process.env.TON_CONFIG_PATH!, JSON.stringify({ mnemonic: 'abandon '.repeat(23) + 'about', @@ -130,7 +145,7 @@ describe('mcp config registry', () => { }); it('reads inline secrets from v2 payloads without writing files on load', () => { - writeFileSync( + rawWriteFileSync( process.env.TON_CONFIG_PATH!, JSON.stringify({ version: 2, @@ -215,7 +230,7 @@ describe('mcp config registry', () => { expect(loaded?.pending_agentic_deployments?.[0]).not.toHaveProperty('secret_file'); expect(loaded?.pending_agentic_key_rotations?.[0]).not.toHaveProperty('secret_file'); - const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')) as Record; + const persisted = JSON.parse(rawReadFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')) as Record; expect(persisted.version).toBe(2); const wallets = persisted.wallets as Array>; const deployments = persisted.pending_agentic_deployments as Array>; @@ -227,7 +242,7 @@ describe('mcp config registry', () => { }); it('upgrades a v2 config file to the current version on migration load', async () => { - writeFileSync( + rawWriteFileSync( process.env.TON_CONFIG_PATH!, JSON.stringify({ version: 2, @@ -253,14 +268,15 @@ describe('mcp config registry', () => { const loaded = await loadConfigWithMigration(); expect(loaded?.version).toBe(CURRENT_TON_CONFIG_VERSION); - const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')) as Record; + const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!)) as Record; expect(persisted.version).toBe(CURRENT_TON_CONFIG_VERSION); expect((persisted.wallets as Array>)[0]).not.toHaveProperty('mnemonic'); expect((persisted.wallets as Array>)[0]).toHaveProperty('secret_file'); + expect(rawReadFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')).not.toContain('"version":'); }); it('materializes inline secrets from loaded v2 configs on explicit save', () => { - writeFileSync( + rawWriteFileSync( process.env.TON_CONFIG_PATH!, JSON.stringify({ version: 2, @@ -341,14 +357,23 @@ describe('mcp config registry', () => { expect( readFileSync(resolveSecretPath((saved.wallets[0] as { secret_file: string }).secret_file), 'utf-8').trim(), ).toBe('abandon '.repeat(23) + 'about'); + expect( + rawReadFileSync(resolveSecretPath((saved.wallets[0] as { secret_file: string }).secret_file), 'utf-8'), + ).not.toContain('abandon '.repeat(23) + 'about'); expect( readFileSync( resolveSecretPath((saved.pending_agentic_key_rotations?.[0] as { secret_file: string }).secret_file), 'utf-8', ).trim(), ).toBe('0x' + '44'.repeat(32)); + expect( + rawReadFileSync( + resolveSecretPath((saved.pending_agentic_key_rotations?.[0] as { secret_file: string }).secret_file), + 'utf-8', + ), + ).not.toContain('0x' + '44'.repeat(32)); - const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')) as Record; + const persisted = JSON.parse(readFileSync(process.env.TON_CONFIG_PATH!)) as Record; const wallets = persisted.wallets as Array>; const deployments = persisted.pending_agentic_deployments as Array>; const rotations = persisted.pending_agentic_key_rotations as Array>; @@ -469,6 +494,15 @@ describe('mcp config registry', () => { 'utf-8', ).trim(), ).toBe('0x1111'); + expect( + rawReadFileSync( + resolveSecretPath( + ((loaded ?? createEmptyConfig()).pending_agentic_deployments[0] as { secret_file: string }) + .secret_file, + ), + 'utf-8', + ), + ).not.toContain('0x1111'); }); it('stores generated secret file paths relative to the config directory', () => { @@ -587,16 +621,14 @@ describe('mcp config registry', () => { ), ).toThrow(); expect(existsSync(secretPath)).toBe(true); - expect(readFileSync(process.env.TON_CONFIG_PATH!, 'utf-8')).toContain( - (loaded?.wallets[0] as { id: string }).id, - ); + expect(readFileSync(process.env.TON_CONFIG_PATH!)).toContain((loaded?.wallets[0] as { id: string }).id); } finally { chmodSync(process.env.TON_CONFIG_PATH!, 0o600); } }); it('throws for unsupported config version', () => { - writeFileSync( + rawWriteFileSync( process.env.TON_CONFIG_PATH!, JSON.stringify({ version: 999, diff --git a/packages/mcp/src/__tests__/protected-file.spec.ts b/packages/mcp/src/__tests__/protected-file.spec.ts new file mode 100644 index 000000000..f59d2b14a --- /dev/null +++ b/packages/mcp/src/__tests__/protected-file.spec.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { mkdtempSync, readFileSync as rawReadFileSync, rmSync, writeFileSync as rawWriteFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { readFileSync, writeFileSync } from '../registry/protected-file.js'; + +describe('protected file wrapper', () => { + let tempDir = ''; + let filePath = ''; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'ton-mcp-protected-file-')); + filePath = join(tempDir, 'secret.txt'); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('writes protected content while reading back the original plaintext', () => { + writeFileSync(filePath, 'super secret value\n'); + + expect(readFileSync(filePath)).toBe('super secret value\n'); + expect(rawReadFileSync(filePath)).not.toEqual(Buffer.from('super secret value\n', 'utf-8')); + }); + + it('supports legacy plaintext files without migration', () => { + rawWriteFileSync(filePath, 'legacy plaintext\n', 'utf-8'); + + expect(readFileSync(filePath)).toBe('legacy plaintext\n'); + }); +}); diff --git a/packages/mcp/src/registry/config-persistence.ts b/packages/mcp/src/registry/config-persistence.ts index ecf2ab200..2d3d399fe 100644 --- a/packages/mcp/src/registry/config-persistence.ts +++ b/packages/mcp/src/registry/config-persistence.ts @@ -6,7 +6,7 @@ * */ -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; import { MemoryStorageAdapter, @@ -27,6 +27,7 @@ import { } from './private-key-files.js'; import type { SecretMaterializationInput } from './private-key-files.js'; import { chmodIfExists, getConfigDir, getConfigPath } from './config-path.js'; +import { readFileSync, writeFileSync } from './protected-file.js'; import { parsePrivateKeyInput } from '../utils/private-key.js'; import { createApiClient } from '../utils/ton-client.js'; @@ -149,7 +150,6 @@ function writeConfigFile(config: TonConfig): TonConfig { mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); chmodIfExists(getConfigDir(), 0o700); writeFileSync(getConfigPath(), JSON.stringify(config, null, 2) + '\n', { - encoding: 'utf-8', mode: 0o600, }); chmodIfExists(getConfigDir(), 0o700); @@ -180,7 +180,7 @@ function parseConfigFile(): { configPath: string; raw: unknown } | null { try { return { configPath, - raw: JSON.parse(readFileSync(configPath, 'utf-8')), + raw: JSON.parse(readFileSync(configPath)), }; } catch (error) { throw new ConfigError( diff --git a/packages/mcp/src/registry/private-key-files.ts b/packages/mcp/src/registry/private-key-files.ts index 54c0c243f..f31ea4569 100644 --- a/packages/mcp/src/registry/private-key-files.ts +++ b/packages/mcp/src/registry/private-key-files.ts @@ -6,7 +6,7 @@ * */ -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { @@ -21,6 +21,7 @@ import type { import { chmodIfExists, getConfigDir } from './config-path.js'; import { LEGACY_AGENTIC_PRIVATE_KEY_FIELD, readPrivateKeyField } from './private-key-field.js'; import type { LegacyPrivateKeyCompatible } from './private-key-field.js'; +import { readFileSync, writeFileSync } from './protected-file.js'; export type SecretReadableValue = { secret_file?: string; @@ -64,7 +65,6 @@ function persistSecretFile( mkdirSync(dirname(targetPath), { recursive: true, mode: 0o700 }); chmodIfExists(dirname(targetPath), 0o700); writeFileSync(targetPath, normalizedValue + '\n', { - encoding: 'utf-8', mode: 0o600, }); chmodIfExists(targetPath, 0o600); @@ -82,7 +82,7 @@ function readSecretFile(filePath: string | undefined): string | undefined { return undefined; } - return trimSecret(readFileSync(resolvedPath, 'utf-8')); + return trimSecret(readFileSync(resolvedPath)); } export function omitSecretRefFields( diff --git a/packages/mcp/src/registry/protected-file.ts b/packages/mcp/src/registry/protected-file.ts new file mode 100644 index 000000000..f8cbcf6ee --- /dev/null +++ b/packages/mcp/src/registry/protected-file.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) TonTech. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { readFileSync as nodeReadFileSync, writeFileSync as nodeWriteFileSync } from 'node:fs'; +import type { Mode, PathOrFileDescriptor } from 'node:fs'; +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +const ENCRYPTION_ALGORITHM = 'aes-256-gcm'; +const PROTECTED_FILE_MAGIC = Buffer.from([0x8a, 0x54, 0x4d, 0x01]); +const ENCRYPTION_KEY_BYTES = 32; +const ENCRYPTION_IV_BYTES = 12; +const ENCRYPTION_TAG_BYTES = 16; + +type ProtectedFileWriteOptions = { + mode?: Mode; + flag?: string; +}; + +const HEADER_LENGTH = PROTECTED_FILE_MAGIC.length + ENCRYPTION_KEY_BYTES + ENCRYPTION_IV_BYTES + ENCRYPTION_TAG_BYTES; + +function encodeProtectedText(value: string): Buffer { + const key = randomBytes(ENCRYPTION_KEY_BYTES); + const iv = randomBytes(ENCRYPTION_IV_BYTES); + const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + + return Buffer.concat([PROTECTED_FILE_MAGIC, key, iv, authTag, encrypted]); +} + +function decodeProtectedText(value: Buffer): string { + if ( + value.length < PROTECTED_FILE_MAGIC.length || + !value.subarray(0, PROTECTED_FILE_MAGIC.length).equals(PROTECTED_FILE_MAGIC) + ) { + return value.toString('utf-8'); + } + + if (value.length < HEADER_LENGTH) { + throw new Error('Invalid protected file format.'); + } + + let offset = PROTECTED_FILE_MAGIC.length; + const key = value.subarray(offset, offset + ENCRYPTION_KEY_BYTES); + offset += ENCRYPTION_KEY_BYTES; + const iv = value.subarray(offset, offset + ENCRYPTION_IV_BYTES); + offset += ENCRYPTION_IV_BYTES; + const authTag = value.subarray(offset, offset + ENCRYPTION_TAG_BYTES); + offset += ENCRYPTION_TAG_BYTES; + const encrypted = value.subarray(offset); + + const decipher = createDecipheriv(ENCRYPTION_ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8'); +} + +export function readFileSync(path: PathOrFileDescriptor): string { + const raw = nodeReadFileSync(path); + return decodeProtectedText(raw); +} + +export function writeFileSync(path: PathOrFileDescriptor, data: string, options?: ProtectedFileWriteOptions): void { + const protectedData = encodeProtectedText(data); + nodeWriteFileSync(path, protectedData, { + ...(options?.mode !== undefined ? { mode: options.mode } : {}), + ...(options?.flag ? { flag: options.flag } : {}), + }); +} From c0ed0dd3cabd9c9b4836605d4a8a3decd76aec7a Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Wed, 18 Mar 2026 12:47:57 +0300 Subject: [PATCH 4/4] docs(mcp): document key storage setup and add changeset --- .changeset/tough-pianos-poke.md | 5 +++++ packages/mcp/README.md | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/tough-pianos-poke.md diff --git a/.changeset/tough-pianos-poke.md b/.changeset/tough-pianos-poke.md new file mode 100644 index 000000000..dcb1f523e --- /dev/null +++ b/.changeset/tough-pianos-poke.md @@ -0,0 +1,5 @@ +--- +'@ton/mcp': patch +--- + +Persist wallet secrets in protected files and allow creating the MCP server directly from a WalletKit signer diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 7e6958065..7e11f2257 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -123,6 +123,29 @@ HTTP mode keeps a separate MCP session/transport per client session id, so multi | `AGENTIC_CALLBACK_HOST` | `127.0.0.1` | Host for the local callback server in stdio mode | | `AGENTIC_CALLBACK_PORT` | random free port | Port for the local callback server in stdio mode | +## Key Storage + +`@ton/mcp` stores secrets differently depending on the runtime mode. + +### Registry mode + +- Wallet metadata is stored in `~/.config/ton/config.json` by default, or at `TON_CONFIG_PATH` if provided. +- Mnemonics and private keys are not kept inline in the registry after persistence. The config stores only a `secret_file` reference. +- Secret material is written into separate files under `/private-keys/...`, including wallet secrets, pending agentic deployment secrets, and pending agentic key rotation secrets. +- The config file and every secret file are written with strict filesystem permissions: files use `0600`, directories use `0700`. +- Both config and secret files go through the same `protected-file` layer, so the raw bytes on disk do not contain the plaintext mnemonic or private key. +- Legacy inline secrets from older config formats are still readable on load and are moved to `secret_file` storage on the next save or migration. +- When a wallet, pending deployment, or pending rotation is removed, orphaned secret files are deleted as part of the config transition. + +### Single-wallet mode + +- If you start the server with `MNEMONIC` or `PRIVATE_KEY`, the wallet is created directly from those values. +- In this mode the secret is not persisted by `@ton/mcp` into the local registry. It is kept in process memory for the lifetime of the MCP server. + +### Security note + +The built-in `protected-file` wrapper helps avoid writing mnemonics and private keys to disk in readable form, but it is not a replacement for an OS keychain, HSM, or external KMS. If you need stronger host-level secret protection, provide credentials through your own secret-management layer in single-wallet/serverless mode. + ## Available Tools In registry mode, wallet-scoped tools below also accept optional `walletSelector`. If omitted, the active wallet is used.