From e4e972861f7dc5f6e69fc968ad84bd1b50a3861d Mon Sep 17 00:00:00 2001 From: 1lystore Date: Tue, 19 May 2026 22:23:32 +0530 Subject: [PATCH 01/10] feat: add mobile agent pairing invite --- packages/dcp-agent/package.json | 6 +- packages/dcp-agent/src/config.ts | 16 +- packages/dcp-agent/src/index.ts | 129 +++++++++++++++- packages/dcp-agent/src/mobile-pairing.ts | 138 ++++++++++++++++++ packages/dcp-agent/src/types.ts | 55 +++++++ .../dcp-agent/tests/mobile-pairing.test.ts | 64 ++++++++ pnpm-lock.yaml | 17 +++ 7 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 packages/dcp-agent/src/mobile-pairing.ts create mode 100644 packages/dcp-agent/tests/mobile-pairing.test.ts diff --git a/packages/dcp-agent/package.json b/packages/dcp-agent/package.json index e41bd97..3c4d586 100644 --- a/packages/dcp-agent/package.json +++ b/packages/dcp-agent/package.json @@ -55,16 +55,18 @@ "author": "DCP Protocol", "license": "Apache-2.0", "dependencies": { - "@dcprotocol/core": "^2.0.1", "@dcprotocol/client": "^2.0.1", + "@dcprotocol/core": "^2.0.1", "@modelcontextprotocol/sdk": "^1.0.0", "@noble/curves": "^1.4.0", "chalk": "^5.3.0", "commander": "^12.1.0", - "ora": "^8.0.1" + "ora": "^8.0.1", + "qrcode-terminal": "^0.12.0" }, "devDependencies": { "@types/node": "^22.10.2", + "@types/qrcode-terminal": "^0.12.2", "tsup": "^8.3.5", "tsx": "^4.19.2", "typescript": "^5.7.2", diff --git a/packages/dcp-agent/src/config.ts b/packages/dcp-agent/src/config.ts index cf07bbb..682759d 100644 --- a/packages/dcp-agent/src/config.ts +++ b/packages/dcp-agent/src/config.ts @@ -13,7 +13,7 @@ import { generateSigningKeyPair, type SignedPairingGrant, } from '@dcprotocol/core'; -import { AgentConfig, AgentError } from './types.js'; +import { AgentConfig, AgentError, type MobilePendingConfig } from './types.js'; // ============================================================================ // Constants @@ -151,6 +151,20 @@ export function saveConfig(config: AgentConfig): void { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); } +/** + * Save pending mobile pairing material. + * + * This is not a usable AgentConfig yet. The mobile vault must approve the + * invite and return vault identity before this can be promoted to a runtime + * agent config. + */ +export function saveMobilePendingConfig(config: MobilePendingConfig): void { + ensureConfigDir(); + const safeInviteId = config.invite_id.replace(/[^a-zA-Z0-9_-]/g, '_'); + const configPath = path.join(CONFIG_DIR, `${safeInviteId}.mobile-pending.json`); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); +} + /** * Load agent configuration * diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index e15b89a..09a652c 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -23,11 +23,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import qrcode from 'qrcode-terminal'; import { parseAndVerifyGrant, createConfigFromGrant, exchangePairingGrant, saveConfig, + saveMobilePendingConfig, loadConfig, listConfigs, deleteConfig, @@ -39,12 +41,20 @@ import { runMcpServer } from './mcp.js'; import { runHttpMcpServer } from './http-mcp.js'; import { AgentError } from './types.js'; import { processSecretsRequest, fetchSecret, fetchSecrets } from './secrets.js'; +import { createMobilePairingInvite } from './mobile-pairing.js'; import { configureOpenClawCommand, installServiceCommand, uninstallServiceCommand, } from './commands/install-service.js'; -import type { PairOptions, RunOptions, StatusOptions } from './types.js'; +import type { + MobileAgentClient, + MobileAgentEnvironment, + MobileDcpScope, + PairOptions, + RunOptions, + StatusOptions, +} from './types.js'; // ============================================================================ // Helpers @@ -201,6 +211,103 @@ async function pairCommand(grantToken: string, options: PairOptions): Promise = { + 'claude-desktop': 'Claude Desktop', + cursor: 'Cursor', + vscode: 'VS Code', + hermes: 'Hermes', + openclaw: 'OpenClaw', + mcp: 'MCP Agent', + custom: 'Custom Agent', + hosted: 'Hosted Agent', + }; + return names[client] || 'DCP Agent'; +} + +async function mobilePairCommand(options: MobilePairOptions): Promise { + try { + const client = options.client || 'custom'; + const environment = options.environment || (client === 'hermes' || client === 'openclaw' ? 'vps' : 'local'); + const scopes = (options.scope?.length ? options.scope : ['read:wallet.address', 'sign:solana']) as MobileDcpScope[]; + const daily = parseNumberOption(options.dailyBudget, 0, '--daily-budget'); + const approvalThreshold = parseNumberOption(options.approvalThreshold, 0, '--approval-threshold'); + const ttlSeconds = parseNumberOption(options.ttlSeconds, 10 * 60, '--ttl-seconds'); + + const created = createMobilePairingInvite({ + client, + environment, + agentName: options.name || defaultAgentName(client), + relayUrl: options.relayUrl, + requestedScopes: scopes, + requestedBudget: { + daily, + currency: options.currency || 'USDC', + approval_threshold: approvalThreshold, + }, + ttlSeconds, + }); + + saveMobilePendingConfig(created.pendingConfig); + + if (options.json) { + console.log(JSON.stringify({ + invite: created.invite, + invite_url: created.inviteUrl, + }, null, 2)); + return; + } + + console.log(); + console.log(chalk.bold('DCP Mobile Pairing')); + console.log(dim('Scan this QR with DCP Mobile, or paste the pairing URL into the app.')); + console.log(); + + if (!options.noQr) { + qrcode.generate(created.inviteUrl, { small: true }); + console.log(); + } + + console.log(` ${dim('Agent:')} ${created.invite.agent_name}`); + console.log(` ${dim('Client:')} ${created.invite.agent_client}`); + console.log(` ${dim('Environment:')} ${created.invite.environment}`); + console.log(` ${dim('Invite ID:')} ${created.invite.invite_id}`); + console.log(` ${dim('Expires:')} ${created.invite.expires_at}`); + console.log(); + console.log(chalk.bold('Pairing URL:')); + console.log(created.inviteUrl); + console.log(); + console.log(dim('Pending agent key material was saved locally with 0600 permissions.')); + console.log(dim('The mobile vault must approve the invite before this agent can request DCP actions.')); + } catch (err) { + error(err instanceof Error ? err.message : 'Failed to create mobile pairing invite'); + process.exit(1); + } +} + function getAgentDataDir(): string { const homeDir = process.env.HOME || process.env.USERPROFILE || '.'; return path.join(homeDir, '.dcp', 'agents'); @@ -550,6 +657,26 @@ program .option('-n, --name ', 'Override agent name') .action(pairCommand); +const mobileCommand = program + .command('mobile') + .description('DCP Mobile pairing commands'); + +mobileCommand + .command('pair') + .description('Create a DCP Mobile pairing invite') + .option('--client ', 'Agent client: claude-desktop, cursor, vscode, hermes, openclaw, mcp, custom, hosted', 'custom') + .option('--environment ', 'Environment: local, vps, hosted, dev') + .option('-n, --name ', 'Agent display name') + .option('--relay-url ', 'Mobile relay API/base URL') + .option('--scope ', 'Requested scope. Repeat for multiple scopes.', (value, previous: string[] = []) => [...previous, value]) + .option('--daily-budget ', 'Requested daily budget', '0') + .option('--currency ', 'Budget currency: USDC or SOL', 'USDC') + .option('--approval-threshold ', 'Auto-approval threshold', '0') + .option('--ttl-seconds ', 'Invite lifetime in seconds', '600') + .option('--json', 'Print machine-readable JSON') + .option('--no-qr', 'Do not print terminal QR') + .action(mobilePairCommand); + program .command('run') .description('Run the agent') diff --git a/packages/dcp-agent/src/mobile-pairing.ts b/packages/dcp-agent/src/mobile-pairing.ts new file mode 100644 index 0000000..c5382c1 --- /dev/null +++ b/packages/dcp-agent/src/mobile-pairing.ts @@ -0,0 +1,138 @@ +import { randomUUID } from 'node:crypto'; +import { signMessage, generateSigningKeyPair } from '@dcprotocol/core'; +import type { + MobileAgentClient, + MobileAgentEnvironment, + MobileDcpScope, + MobilePairingBudget, + MobilePairingInvite, + MobilePendingConfig, +} from './types.js'; + +export const MOBILE_PAIRING_TYPE = 'dcp_agent_pairing'; +export const MOBILE_PAIRING_VERSION = '1.0'; +export const DEFAULT_MOBILE_RELAY_URL = 'https://relay.dcp.1ly.store'; + +const SUPPORTED_CLIENTS = new Set([ + 'claude-desktop', + 'cursor', + 'vscode', + 'hermes', + 'openclaw', + 'mcp', + 'custom', + 'hosted', +]); + +const SUPPORTED_ENVIRONMENTS = new Set([ + 'local', + 'vps', + 'hosted', + 'dev', +]); + +const SUPPORTED_SCOPES = new Set([ + 'read:wallet.address', + 'sign:solana', + 'vault_get_address', + 'vault_budget_check', + 'vault_sign_tx', + 'vault_sign_message', +]); + +export interface CreateMobilePairingInviteInput { + client: MobileAgentClient; + environment: MobileAgentEnvironment; + agentName: string; + relayUrl?: string; + requestedScopes?: MobileDcpScope[]; + requestedBudget?: MobilePairingBudget; + ttlSeconds?: number; +} + +export interface CreatedMobilePairingInvite { + invite: MobilePairingInvite; + inviteUrl: string; + pendingConfig: MobilePendingConfig; +} + +function canonicalJson(value: Record): string { + return JSON.stringify(value, Object.keys(value).sort()); +} + +function encodeMobilePairingInvite(invite: MobilePairingInvite): string { + return `dcp://pair?invite=${encodeURIComponent(JSON.stringify(invite))}`; +} + +function assertSupported(input: CreateMobilePairingInviteInput): void { + if (!SUPPORTED_CLIENTS.has(input.client)) { + throw new Error(`Unsupported mobile agent client: ${input.client}`); + } + if (!SUPPORTED_ENVIRONMENTS.has(input.environment)) { + throw new Error(`Unsupported mobile agent environment: ${input.environment}`); + } + for (const scope of input.requestedScopes || []) { + if (!SUPPORTED_SCOPES.has(scope)) { + throw new Error(`Unsupported mobile MVP scope: ${scope}`); + } + } +} + +export function createMobilePairingInvite( + input: CreateMobilePairingInviteInput +): CreatedMobilePairingInvite { + assertSupported(input); + + const keypair = generateSigningKeyPair(); + const createdAt = new Date(); + const ttlSeconds = input.ttlSeconds ?? 10 * 60; + const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1000); + const inviteId = `mob_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + const requestedBudget = input.requestedBudget ?? { + daily: 0, + currency: 'USDC', + approval_threshold: 0, + }; + + const unsignedInvite: Omit = { + type: MOBILE_PAIRING_TYPE, + version: MOBILE_PAIRING_VERSION, + relay_url: (input.relayUrl || process.env.DCP_MOBILE_RELAY_URL || DEFAULT_MOBILE_RELAY_URL).replace(/\/$/, ''), + invite_id: inviteId, + agent_public_key: keypair.publicKey.toString('base64'), + agent_name: input.agentName.trim(), + agent_client: input.client, + environment: input.environment, + requested_scopes: input.requestedScopes ?? ['read:wallet.address', 'sign:solana'], + requested_budget: requestedBudget, + created_at: createdAt.toISOString(), + expires_at: expiresAt.toISOString(), + nonce: randomUUID(), + }; + + const signature = Buffer.from( + signMessage(Buffer.from(canonicalJson(unsignedInvite), 'utf8'), keypair.privateKey) + ).toString('base64'); + + const invite: MobilePairingInvite = { + ...unsignedInvite, + signature, + }; + const inviteUrl = encodeMobilePairingInvite(invite); + + return { + invite, + inviteUrl, + pendingConfig: { + invite_id: inviteId, + invite_url: inviteUrl, + invite, + service_keypair: { + public: keypair.publicKey.toString('base64'), + private: keypair.privateKey.toString('base64'), + }, + pairing_status: 'pending_mobile_approval', + created_at: createdAt.toISOString(), + }, + }; +} diff --git a/packages/dcp-agent/src/types.ts b/packages/dcp-agent/src/types.ts index 34ba059..ff2f8b6 100644 --- a/packages/dcp-agent/src/types.ts +++ b/packages/dcp-agent/src/types.ts @@ -102,6 +102,61 @@ export interface VpsPendingConfig { agent_name?: string; } +export type MobileAgentClient = + | 'claude-desktop' + | 'cursor' + | 'vscode' + | 'hermes' + | 'openclaw' + | 'mcp' + | 'custom' + | 'hosted'; + +export type MobileAgentEnvironment = 'local' | 'vps' | 'hosted' | 'dev'; + +export type MobileDcpScope = + | 'read:wallet.address' + | 'sign:solana' + | 'vault_get_address' + | 'vault_budget_check' + | 'vault_sign_tx' + | 'vault_sign_message'; + +export interface MobilePairingBudget { + daily: number; + currency: 'SOL' | 'USDC'; + approval_threshold: number; +} + +export interface MobilePairingInvite { + type: 'dcp_agent_pairing'; + version: '1.0'; + relay_url: string; + invite_id: string; + agent_public_key: string; + agent_name: string; + agent_client: MobileAgentClient; + environment: MobileAgentEnvironment; + requested_scopes: MobileDcpScope[]; + requested_budget: MobilePairingBudget; + created_at: string; + expires_at: string; + nonce: string; + signature?: string; +} + +export interface MobilePendingConfig { + invite_id: string; + invite_url: string; + invite: MobilePairingInvite; + service_keypair: { + public: string; + private: string; + }; + pairing_status: 'pending_mobile_approval'; + created_at: string; +} + /** * Check if a config is a VPS pending config (not yet approved) */ diff --git a/packages/dcp-agent/tests/mobile-pairing.test.ts b/packages/dcp-agent/tests/mobile-pairing.test.ts new file mode 100644 index 0000000..12abfec --- /dev/null +++ b/packages/dcp-agent/tests/mobile-pairing.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { verifySignature } from '@dcprotocol/core'; +import { createMobilePairingInvite } from '../src/mobile-pairing.js'; + +function canonicalInvitePayload(invite: Record): string { + const { signature: _signature, ...unsigned } = invite; + return JSON.stringify(unsigned, Object.keys(unsigned).sort()); +} + +describe('mobile pairing', () => { + it('creates a signed DCP Mobile pairing invite URL', () => { + const { invite, inviteUrl, pendingConfig } = createMobilePairingInvite({ + client: 'claude-desktop', + environment: 'local', + agentName: 'Claude Desktop', + relayUrl: 'https://relay.example.test/', + requestedScopes: ['read:wallet.address', 'sign:solana'], + requestedBudget: { + daily: 5, + currency: 'USDC', + approval_threshold: 0.01, + }, + ttlSeconds: 300, + }); + + expect(invite.type).toBe('dcp_agent_pairing'); + expect(invite.version).toBe('1.0'); + expect(invite.relay_url).toBe('https://relay.example.test'); + expect(invite.agent_client).toBe('claude-desktop'); + expect(invite.environment).toBe('local'); + expect(invite.requested_scopes).toEqual(['read:wallet.address', 'sign:solana']); + expect(invite.signature).toBeTruthy(); + expect(inviteUrl.startsWith('dcp://pair?invite=')).toBe(true); + expect(pendingConfig.service_keypair.private).toBeTruthy(); + expect(pendingConfig.invite_id).toBe(invite.invite_id); + }); + + it('signs the invite with the generated agent public key', () => { + const { invite } = createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Test Agent', + }); + + const valid = verifySignature( + Buffer.from(canonicalInvitePayload(invite), 'utf8'), + Buffer.from(invite.signature!, 'base64'), + Buffer.from(invite.agent_public_key, 'base64') + ); + + expect(valid).toBe(true); + }); + + it('rejects unsupported MVP scopes', () => { + expect(() => + createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Bad Scope Agent', + requestedScopes: ['read:api.key' as never], + }) + ).toThrow('Unsupported mobile MVP scope'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92816aa..6ab0b17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,10 +31,16 @@ importers: ora: specifier: ^8.0.1 version: 8.2.0 + qrcode-terminal: + specifier: ^0.12.0 + version: 0.12.0 devDependencies: '@types/node': specifier: ^22.10.2 version: 22.19.17 + '@types/qrcode-terminal': + specifier: ^0.12.2 + version: 0.12.2 tsup: specifier: ^8.3.5 version: 8.5.1(postcss@8.5.13)(tsx@4.21.0)(typescript@5.9.3) @@ -1293,6 +1299,9 @@ packages: '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + '@types/qrcode-terminal@0.12.2': + resolution: {integrity: sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2185,6 +2194,10 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + qrcode-terminal@0.12.0: + resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} + hasBin: true + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -3401,6 +3414,8 @@ snapshots: '@types/node': 22.19.17 kleur: 3.0.3 + '@types/qrcode-terminal@0.12.2': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -4348,6 +4363,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + qrcode-terminal@0.12.0: {} + qs@6.15.1: dependencies: side-channel: 1.1.0 From d24b5bd31b060989f967c5ba63e6f0a1690dd7d6 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Tue, 19 May 2026 22:59:21 +0530 Subject: [PATCH 02/10] feat: complete mobile relay pairing runtime --- packages/dcp-agent/src/config.ts | 38 +++- packages/dcp-agent/src/index.ts | 30 ++- packages/dcp-agent/src/mobile-pairing.ts | 56 ++++- packages/dcp-agent/src/types.ts | 12 + .../dcp-agent/tests/mobile-pairing.test.ts | 33 ++- packages/dcp-relay/src/index.ts | 10 +- packages/dcp-relay/src/relay.ts | 215 +++++++++++++++++- packages/dcp-relay/src/store.ts | 135 +++++++++++ packages/dcp-relay/src/types.ts | 84 +++++++ packages/dcp-relay/tests/relay.test.ts | 147 +++++++++++- 10 files changed, 751 insertions(+), 9 deletions(-) diff --git a/packages/dcp-agent/src/config.ts b/packages/dcp-agent/src/config.ts index 682759d..0d570ed 100644 --- a/packages/dcp-agent/src/config.ts +++ b/packages/dcp-agent/src/config.ts @@ -13,7 +13,7 @@ import { generateSigningKeyPair, type SignedPairingGrant, } from '@dcprotocol/core'; -import { AgentConfig, AgentError, type MobilePendingConfig } from './types.js'; +import { AgentConfig, AgentError, type MobilePairingApprovalStatus, type MobilePendingConfig } from './types.js'; // ============================================================================ // Constants @@ -165,6 +165,42 @@ export function saveMobilePendingConfig(config: MobilePendingConfig): void { fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); } +export function promoteMobilePendingConfig( + pending: MobilePendingConfig, + approval: MobilePairingApprovalStatus +): AgentConfig { + if ( + approval.status !== 'approved' || + !approval.agent_id || + !approval.vault_id || + !approval.vault_hpke_public_key || + !approval.vault_signing_public_key + ) { + throw new AgentError('INVALID_GRANT', 'Mobile pairing approval is incomplete'); + } + + const config: AgentConfig = { + agent_id: approval.agent_id, + agent_name: pending.invite.agent_name, + vault_id: approval.vault_id, + mode: 'mcp', + vault_hpke_public_key: approval.vault_hpke_public_key, + vault_signing_public_key: approval.vault_signing_public_key, + relay_url: pending.invite.relay_url, + service_keypair: pending.service_keypair, + paired_at: new Date().toISOString(), + grant_expires_at: pending.invite.expires_at, + }; + + saveConfig(config); + const safeInviteId = pending.invite_id.replace(/[^a-zA-Z0-9_-]/g, '_'); + const pendingPath = path.join(CONFIG_DIR, `${safeInviteId}.mobile-pending.json`); + if (fs.existsSync(pendingPath)) { + fs.unlinkSync(pendingPath); + } + return config; +} + /** * Load agent configuration * diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index 09a652c..9898373 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -30,6 +30,7 @@ import { exchangePairingGrant, saveConfig, saveMobilePendingConfig, + promoteMobilePendingConfig, loadConfig, listConfigs, deleteConfig, @@ -41,7 +42,7 @@ import { runMcpServer } from './mcp.js'; import { runHttpMcpServer } from './http-mcp.js'; import { AgentError } from './types.js'; import { processSecretsRequest, fetchSecret, fetchSecrets } from './secrets.js'; -import { createMobilePairingInvite } from './mobile-pairing.js'; +import { createMobilePairingInvite, waitForMobilePairingApproval } from './mobile-pairing.js'; import { configureOpenClawCommand, installServiceCommand, @@ -223,6 +224,8 @@ interface MobilePairOptions { ttlSeconds?: string; json?: boolean; noQr?: boolean; + wait?: boolean; + waitSeconds?: string; } function parseNumberOption(value: string | undefined, fallback: number, label: string): number { @@ -302,6 +305,29 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { console.log(); console.log(dim('Pending agent key material was saved locally with 0600 permissions.')); console.log(dim('The mobile vault must approve the invite before this agent can request DCP actions.')); + + if (options.wait) { + const waitSeconds = parseNumberOption(options.waitSeconds, ttlSeconds, '--wait-seconds'); + console.log(); + console.log(dim(`Waiting up to ${waitSeconds}s for DCP Mobile approval...`)); + const status = await waitForMobilePairingApproval( + created.invite.relay_url, + created.invite.invite_id, + { timeoutMs: waitSeconds * 1000 } + ); + + if (status.status !== 'approved') { + error(`Mobile pairing ${status.status}${status.error ? `: ${status.error}` : ''}`); + process.exit(1); + } + + success('Mobile pairing approved'); + console.log(` ${dim('Agent ID:')} ${status.agent_id}`); + console.log(` ${dim('Vault ID:')} ${status.vault_id}`); + const config = promoteMobilePendingConfig(created.pendingConfig, status); + console.log(dim(`Saved agent config: ${config.agent_id}`)); + console.log(dim(`Next: run "dcp-agent run --agent ${config.agent_id} --mode mcp" from your MCP host.`)); + } } catch (err) { error(err instanceof Error ? err.message : 'Failed to create mobile pairing invite'); process.exit(1); @@ -675,6 +701,8 @@ mobileCommand .option('--ttl-seconds ', 'Invite lifetime in seconds', '600') .option('--json', 'Print machine-readable JSON') .option('--no-qr', 'Do not print terminal QR') + .option('--wait', 'Wait until DCP Mobile approves or denies the pairing') + .option('--wait-seconds ', 'How long --wait should poll for approval') .action(mobilePairCommand); program diff --git a/packages/dcp-agent/src/mobile-pairing.ts b/packages/dcp-agent/src/mobile-pairing.ts index c5382c1..3e95016 100644 --- a/packages/dcp-agent/src/mobile-pairing.ts +++ b/packages/dcp-agent/src/mobile-pairing.ts @@ -4,6 +4,7 @@ import type { MobileAgentClient, MobileAgentEnvironment, MobileDcpScope, + MobilePairingApprovalStatus, MobilePairingBudget, MobilePairingInvite, MobilePendingConfig, @@ -56,8 +57,20 @@ export interface CreatedMobilePairingInvite { pendingConfig: MobilePendingConfig; } -function canonicalJson(value: Record): string { - return JSON.stringify(value, Object.keys(value).sort()); +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(',')}]`; + } + + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); } function encodeMobilePairingInvite(invite: MobilePairingInvite): string { @@ -136,3 +149,42 @@ export function createMobilePairingInvite( }, }; } + +export async function getMobilePairingStatus( + relayUrl: string, + inviteId: string +): Promise { + const response = await fetch( + `${relayUrl.replace(/\/$/, '')}/v1/mobile/pairings/${encodeURIComponent(inviteId)}/status` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch mobile pairing status (${response.status})`); + } + + return (await response.json()) as MobilePairingApprovalStatus; +} + +export async function waitForMobilePairingApproval( + relayUrl: string, + inviteId: string, + options: { timeoutMs?: number; pollIntervalMs?: number } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000; + const pollIntervalMs = options.pollIntervalMs ?? 2000; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const status = await getMobilePairingStatus(relayUrl, inviteId); + if (status.status !== 'pending') { + return status; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return { + status: 'expired', + invite_id: inviteId, + error: 'Timed out waiting for mobile approval', + }; +} diff --git a/packages/dcp-agent/src/types.ts b/packages/dcp-agent/src/types.ts index ff2f8b6..33e14f5 100644 --- a/packages/dcp-agent/src/types.ts +++ b/packages/dcp-agent/src/types.ts @@ -157,6 +157,18 @@ export interface MobilePendingConfig { created_at: string; } +export interface MobilePairingApprovalStatus { + status: 'pending' | 'approved' | 'denied' | 'expired' | 'not_found'; + invite_id?: string; + agent_id?: string; + vault_id?: string; + vault_hpke_public_key?: string; + vault_signing_public_key?: string; + approved_scopes?: MobileDcpScope[]; + approved_budget?: MobilePairingBudget; + error?: string; +} + /** * Check if a config is a VPS pending config (not yet approved) */ diff --git a/packages/dcp-agent/tests/mobile-pairing.test.ts b/packages/dcp-agent/tests/mobile-pairing.test.ts index 12abfec..b955199 100644 --- a/packages/dcp-agent/tests/mobile-pairing.test.ts +++ b/packages/dcp-agent/tests/mobile-pairing.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; import { verifySignature } from '@dcprotocol/core'; -import { createMobilePairingInvite } from '../src/mobile-pairing.js'; +import { canonicalJson, createMobilePairingInvite } from '../src/mobile-pairing.js'; function canonicalInvitePayload(invite: Record): string { const { signature: _signature, ...unsigned } = invite; - return JSON.stringify(unsigned, Object.keys(unsigned).sort()); + return canonicalJson(unsigned); } describe('mobile pairing', () => { @@ -51,6 +51,35 @@ describe('mobile pairing', () => { expect(valid).toBe(true); }); + it('covers nested budget fields in the invite signature', () => { + const { invite } = createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Budget Integrity Agent', + requestedBudget: { + daily: 1, + currency: 'USDC', + approval_threshold: 0, + }, + }); + + const tampered = { + ...invite, + requested_budget: { + ...invite.requested_budget, + daily: 999, + }, + }; + + const valid = verifySignature( + Buffer.from(canonicalInvitePayload(tampered), 'utf8'), + Buffer.from(invite.signature!, 'base64'), + Buffer.from(invite.agent_public_key, 'base64') + ); + + expect(valid).toBe(false); + }); + it('rejects unsupported MVP scopes', () => { expect(() => createMobilePairingInvite({ diff --git a/packages/dcp-relay/src/index.ts b/packages/dcp-relay/src/index.ts index 8c52bbc..bca80dd 100644 --- a/packages/dcp-relay/src/index.ts +++ b/packages/dcp-relay/src/index.ts @@ -21,7 +21,7 @@ // ============================================================================ export { RelayServer } from './relay.js'; -export { MessageStore, ConnectionStore, RateLimiter } from './store.js'; +export { MessageStore, ConnectionStore, RateLimiter, MobilePairingStore } from './store.js'; export { authenticateRegistration, verifyRegistrationSignature, @@ -48,6 +48,14 @@ export { type StoredPairingClaim, type PairingClaimResponse, type PairingApprovalStatus, + type MobileAgentClient, + type MobileAgentEnvironment, + type MobileDcpScope, + type MobilePairingBudget, + type MobilePairingInvite, + type MobilePairingApprovalRequest, + type MobilePairingRecord, + type MobilePairingStatus, // Constants RELAY_VERSION, diff --git a/packages/dcp-relay/src/relay.ts b/packages/dcp-relay/src/relay.ts index e11ee6f..2123bf0 100644 --- a/packages/dcp-relay/src/relay.ts +++ b/packages/dcp-relay/src/relay.ts @@ -35,13 +35,16 @@ import type { PairingClaimResponse, PairingApprovalStatus, StoredPairingClaim, + MobilePairingApprovalRequest, + MobilePairingStatus, + MobilePairingInvite, } from './types.js'; import { RelayError, DEFAULT_RELAY_CONFIG, RELAY_VERSION, } from './types.js'; -import { MessageStore, ConnectionStore, RateLimiter, PairingClaimStore } from './store.js'; +import { MessageStore, ConnectionStore, RateLimiter, PairingClaimStore, MobilePairingStore } from './store.js'; import { authenticateRegistration, authenticateRequest, closeAuth, type AuthConfig } from './auth.js'; // ============================================================================ @@ -54,6 +57,7 @@ export class RelayServer { private connectionStore: ConnectionStore; private rateLimiter: RateLimiter; private pairingClaimStore: PairingClaimStore; + private mobilePairingStore: MobilePairingStore; private config: RelayConfig; private authConfig: AuthConfig; private heartbeatInterval: ReturnType | null = null; @@ -74,6 +78,7 @@ export class RelayServer { this.config.rateLimitWindowMs ); this.pairingClaimStore = new PairingClaimStore(); + this.mobilePairingStore = new MobilePairingStore(); this.server = Fastify({ logger: this.config.debug @@ -135,6 +140,7 @@ export class RelayServer { this.messageStore.close(); this.rateLimiter.close(); this.pairingClaimStore.close(); + this.mobilePairingStore.close(); closeAuth(); await this.server.close(); } @@ -157,6 +163,7 @@ export class RelayServer { ...this.connectionStore.getStats(), rateLimit: this.rateLimiter.getStats(), pairingClaims: this.pairingClaimStore.getStats(), + mobilePairings: this.mobilePairingStore.getStats(), timestamp: new Date().toISOString(), })); @@ -168,6 +175,7 @@ export class RelayServer { const connectionStats = this.connectionStore.getStats(); const rateLimitStats = this.rateLimiter.getStats(); const pairingStats = this.pairingClaimStore.getStats(); + const mobilePairingStats = this.mobilePairingStore.getStats(); const format = request.query.format; @@ -200,6 +208,9 @@ export class RelayServer { '# HELP dcp_relay_pairing_claims_pending Pending pairing claims', '# TYPE dcp_relay_pairing_claims_pending gauge', `dcp_relay_pairing_claims_pending ${pairingStats.pendingClaims}`, + '# HELP dcp_relay_mobile_pairings_total Total mobile pairings', + '# TYPE dcp_relay_mobile_pairings_total gauge', + `dcp_relay_mobile_pairings_total ${mobilePairingStats.totalMobilePairings}`, '', ]; reply.header('Content-Type', 'text/plain; charset=utf-8'); @@ -212,6 +223,7 @@ export class RelayServer { connections: connectionStats, rateLimit: rateLimitStats, pairingClaims: pairingStats, + mobilePairings: mobilePairingStats, websockets: { vaultConnections: this.wsConnections.size, clientConnections: this.clientSockets.size, @@ -310,6 +322,63 @@ export class RelayServer { return reply.send({ success: true }); } ); + + // ======================================================================== + // DCP Mobile Pairing Routes (Agent QR → Mobile approval → Agent polling) + // ======================================================================== + + this.server.post<{ + Params: { inviteId: string }; + Body: MobilePairingApprovalRequest; + }>( + '/v1/mobile/pairings/:inviteId/approve', + async (request, reply) => { + return this.handleMobilePairingApprove( + request.params.inviteId, + request.body, + reply + ); + } + ); + + this.server.post<{ + Params: { inviteId: string }; + Body: { denied_reason?: string }; + }>( + '/v1/mobile/pairings/:inviteId/deny', + async (request, reply) => { + const record = this.mobilePairingStore.deny( + request.params.inviteId, + request.body?.denied_reason + ); + return reply.send({ + success: true, + invite_id: record.invite_id, + status: record.status, + }); + } + ); + + this.server.get<{ Params: { inviteId: string } }>( + '/v1/mobile/pairings/:inviteId/status', + async (request, reply) => { + return this.handleMobilePairingStatus(request.params.inviteId, reply); + } + ); + + this.server.post<{ + Params: { vaultId: string }; + Body: { public_key: string; signing_public_key?: string }; + }>( + '/v1/mobile/vaults/:vaultId/online', + async (request, reply) => { + if (!request.body?.public_key) { + return reply.status(400).send({ error: 'Missing public_key' }); + } + this.connectionStore.register(request.params.vaultId, request.body.public_key); + return reply.send({ success: true, vault_id: request.params.vaultId }); + } + ); } private async handleRequest( @@ -676,6 +745,61 @@ export class RelayServer { }); } + private async handleMobilePairingApprove( + inviteId: string, + body: MobilePairingApprovalRequest, + reply: FastifyReply + ): Promise { + const validationError = this.validateMobileApproval(inviteId, body); + if (validationError) { + return reply.status(400).send({ success: false, error: validationError }); + } + + const record = this.mobilePairingStore.approve(body); + + if (this.config.debug) { + console.log( + `Mobile pairing approved: ${record.invite_id} (agent: ${record.agent_id}, vault: ${record.vault_id})` + ); + } + + return reply.send({ + success: true, + invite_id: record.invite_id, + status: record.status, + agent_id: record.agent_id, + vault_id: record.vault_id, + vault_hpke_public_key: record.vault_hpke_public_key, + vault_signing_public_key: record.vault_signing_public_key, + }); + } + + private async handleMobilePairingStatus( + inviteId: string, + reply: FastifyReply + ): Promise { + const record = this.mobilePairingStore.get(inviteId); + + if (!record) { + return reply.send({ + status: 'pending', + invite_id: inviteId, + } satisfies MobilePairingStatus); + } + + return reply.send({ + status: record.status, + invite_id: record.invite_id, + agent_id: record.agent_id, + vault_id: record.vault_id, + vault_hpke_public_key: record.vault_hpke_public_key, + vault_signing_public_key: record.vault_signing_public_key, + approved_scopes: record.approved_scopes, + approved_budget: record.approved_budget, + error: record.denied_reason, + } satisfies MobilePairingStatus); + } + // -------------------------------------------------------------------------- // WebSocket Handler // -------------------------------------------------------------------------- @@ -1156,6 +1280,95 @@ export class RelayServer { return null; } + private validateMobileApproval( + inviteId: string, + body: MobilePairingApprovalRequest + ): string | null { + if (!body || typeof body !== 'object') { + return 'Missing approval body'; + } + + const invite = body.invite; + if (!invite || typeof invite !== 'object') { + return 'Missing pairing invite'; + } + if (invite.invite_id !== inviteId) { + return 'Invite ID mismatch'; + } + if (!body.vault_id || !body.agent_id) { + return 'Missing vault_id or agent_id'; + } + if (!body.vault_hpke_public_key || !body.vault_signing_public_key) { + return 'Missing vault relay public keys'; + } + if (!Array.isArray(body.approved_scopes) || body.approved_scopes.length === 0) { + return 'approved_scopes must be a non-empty array'; + } + if (!body.approved_budget || typeof body.approved_budget.daily !== 'number') { + return 'approved_budget is required'; + } + + const inviteError = this.validateMobileInvite(invite); + if (inviteError) { + return inviteError; + } + + const requestedScopes = new Set(invite.requested_scopes); + const unrequestedScope = body.approved_scopes.find((scope) => !requestedScopes.has(scope)); + if (unrequestedScope) { + return `Approved scope was not requested: ${unrequestedScope}`; + } + + return null; + } + + private validateMobileInvite(invite: MobilePairingInvite): string | null { + if (invite.type !== 'dcp_agent_pairing' || invite.version !== '1.0') { + return 'Unsupported mobile pairing invite'; + } + if (!invite.signature) { + return 'Invite signature is required'; + } + + const expiresAt = new Date(invite.expires_at).getTime(); + if (!Number.isFinite(expiresAt)) { + return 'Invalid invite expiry'; + } + if (Date.now() > expiresAt) { + return 'Invite expired'; + } + + try { + const { signature: _signature, ...unsignedInvite } = invite; + const message = Buffer.from(this.canonicalJson(unsignedInvite), 'utf8'); + const signature = Buffer.from(invite.signature, 'base64'); + const publicKey = Buffer.from(invite.agent_public_key, 'base64'); + if (!ed25519.verify(signature, message, publicKey)) { + return 'Invalid invite signature'; + } + } catch (err) { + return 'Failed to verify invite signature: ' + (err instanceof Error ? err.message : 'unknown error'); + } + + return null; + } + + private canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => this.canonicalJson(item)).join(',')}]`; + } + + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${this.canonicalJson(record[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); + } + // -------------------------------------------------------------------------- // Getters (for testing) // -------------------------------------------------------------------------- diff --git a/packages/dcp-relay/src/store.ts b/packages/dcp-relay/src/store.ts index f7ac149..970d6c3 100644 --- a/packages/dcp-relay/src/store.ts +++ b/packages/dcp-relay/src/store.ts @@ -20,6 +20,8 @@ import type { RelayConfig, PairingClaim, StoredPairingClaim, + MobilePairingApprovalRequest, + MobilePairingRecord, } from './types.js'; import { RelayError, MESSAGE_TTL_MS } from './types.js'; import { createHash, randomUUID } from 'node:crypto'; @@ -775,3 +777,136 @@ export class PairingClaimStore { } } } + +// ============================================================================ +// DCP Mobile Pairing Store +// ============================================================================ + +/** Mobile pairing status TTL: enough time for the QR terminal to poll. */ +const MOBILE_PAIRING_TTL_MS = 10 * 60 * 1000; +const MOBILE_PAIRING_RESOLVED_TTL_MS = 60 * 60 * 1000; + +export class MobilePairingStore { + private records: Map = new Map(); + private cleanupInterval: ReturnType | null = null; + + constructor() { + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 60_000); + } + + approve(request: MobilePairingApprovalRequest): MobilePairingRecord { + const now = Date.now(); + const existing = this.records.get(request.invite.invite_id); + if (existing && existing.status === 'approved') { + return existing; + } + + const record: MobilePairingRecord = { + invite_id: request.invite.invite_id, + invite: request.invite, + received_at: existing?.received_at ?? now, + status: 'approved', + vault_id: request.vault_id, + vault_hpke_public_key: request.vault_hpke_public_key, + vault_signing_public_key: request.vault_signing_public_key, + agent_id: request.agent_id, + approved_scopes: request.approved_scopes, + approved_budget: request.approved_budget, + resolved_at: now, + }; + + this.records.set(record.invite_id, record); + return record; + } + + deny(inviteId: string, deniedReason?: string): MobilePairingRecord { + const now = Date.now(); + const existing = this.records.get(inviteId); + const record: MobilePairingRecord = { + invite_id: inviteId, + invite: existing?.invite ?? { + type: 'dcp_agent_pairing', + version: '1.0', + relay_url: '', + invite_id: inviteId, + agent_public_key: '', + agent_name: '', + agent_client: 'custom', + environment: 'dev', + requested_scopes: [], + requested_budget: { daily: 0, currency: 'USDC', approval_threshold: 0 }, + created_at: new Date(now).toISOString(), + expires_at: new Date(now + MOBILE_PAIRING_TTL_MS).toISOString(), + nonce: '', + }, + received_at: existing?.received_at ?? now, + status: 'denied', + resolved_at: now, + denied_reason: deniedReason, + }; + + this.records.set(inviteId, record); + return record; + } + + get(inviteId: string): MobilePairingRecord | undefined { + const record = this.records.get(inviteId); + if (!record) return undefined; + + const now = Date.now(); + if (record.status === 'approved' || record.status === 'denied') { + return record; + } + + if (now - record.received_at > MOBILE_PAIRING_TTL_MS) { + record.status = 'expired'; + record.resolved_at = now; + } + + return record; + } + + cleanup(): number { + const now = Date.now(); + let removed = 0; + + for (const [inviteId, record] of this.records) { + if (record.status !== 'approved' && record.status !== 'denied' && now - record.received_at > MOBILE_PAIRING_TTL_MS) { + record.status = 'expired'; + record.resolved_at = now; + } + + if (record.resolved_at && now - record.resolved_at > MOBILE_PAIRING_RESOLVED_TTL_MS) { + this.records.delete(inviteId); + removed++; + } + } + + return removed; + } + + getStats(): { totalMobilePairings: number; approvedMobilePairings: number; deniedMobilePairings: number } { + let approved = 0; + let denied = 0; + + for (const record of this.records.values()) { + if (record.status === 'approved') approved++; + if (record.status === 'denied') denied++; + } + + return { + totalMobilePairings: this.records.size, + approvedMobilePairings: approved, + deniedMobilePairings: denied, + }; + } + + close(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } +} diff --git a/packages/dcp-relay/src/types.ts b/packages/dcp-relay/src/types.ts index cd2e283..a262a0a 100644 --- a/packages/dcp-relay/src/types.ts +++ b/packages/dcp-relay/src/types.ts @@ -304,3 +304,87 @@ export interface PairingApprovalStatus { vault_id?: string; error?: string; } + +// ============================================================================ +// DCP Mobile Pairing Types (Agent QR → Mobile approval → Agent polling) +// ============================================================================ + +export type MobileAgentClient = + | 'claude-desktop' + | 'cursor' + | 'vscode' + | 'hermes' + | 'openclaw' + | 'mcp' + | 'custom' + | 'hosted'; + +export type MobileAgentEnvironment = 'local' | 'vps' | 'hosted' | 'dev'; + +export type MobileDcpScope = + | 'read:wallet.address' + | 'sign:solana' + | 'vault_get_address' + | 'vault_budget_check' + | 'vault_sign_tx' + | 'vault_sign_message'; + +export interface MobilePairingBudget { + daily: number; + currency: 'SOL' | 'USDC'; + approval_threshold: number; +} + +export interface MobilePairingInvite { + type: 'dcp_agent_pairing'; + version: '1.0'; + relay_url: string; + invite_id: string; + agent_public_key: string; + agent_name: string; + agent_client: MobileAgentClient; + environment: MobileAgentEnvironment; + requested_scopes: MobileDcpScope[]; + requested_budget: MobilePairingBudget; + created_at: string; + expires_at: string; + nonce: string; + signature?: string; +} + +export interface MobilePairingApprovalRequest { + invite: MobilePairingInvite; + vault_id: string; + agent_id: string; + vault_hpke_public_key: string; + vault_signing_public_key: string; + approved_scopes: MobileDcpScope[]; + approved_budget: MobilePairingBudget; +} + +export interface MobilePairingRecord { + invite_id: string; + invite: MobilePairingInvite; + received_at: number; + status: 'approved' | 'denied' | 'expired'; + vault_id?: string; + vault_hpke_public_key?: string; + vault_signing_public_key?: string; + agent_id?: string; + approved_scopes?: MobileDcpScope[]; + approved_budget?: MobilePairingBudget; + resolved_at?: number; + denied_reason?: string; +} + +export interface MobilePairingStatus { + status: 'pending' | 'approved' | 'denied' | 'expired' | 'not_found'; + invite_id?: string; + agent_id?: string; + vault_id?: string; + vault_hpke_public_key?: string; + vault_signing_public_key?: string; + approved_scopes?: MobileDcpScope[]; + approved_budget?: MobilePairingBudget; + error?: string; +} diff --git a/packages/dcp-relay/tests/relay.test.ts b/packages/dcp-relay/tests/relay.test.ts index ec32a83..787f184 100644 --- a/packages/dcp-relay/tests/relay.test.ts +++ b/packages/dcp-relay/tests/relay.test.ts @@ -5,9 +5,11 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { ed25519 } from '@noble/curves/ed25519'; import { MessageStore, ConnectionStore, RateLimiter } from '../src/store.js'; import { RelayServer } from '../src/relay.js'; -import type { RelayEnvelope, RelayResponseEnvelope } from '../src/types.js'; +import type { MobilePairingInvite, RelayEnvelope, RelayResponseEnvelope } from '../src/types.js'; import { RelayError, RELAY_VERSION } from '../src/types.js'; // ============================================================================ @@ -35,6 +37,46 @@ function createTestResponse(requestId: string): RelayResponseEnvelope { }; } +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(',')}]`; + } + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function createMobileInvite(overrides: Partial = {}): MobilePairingInvite { + const privateKey = randomBytes(32); + const publicKey = ed25519.getPublicKey(privateKey); + const unsigned: Omit = { + type: 'dcp_agent_pairing', + version: '1.0', + relay_url: 'http://127.0.0.1:8422', + invite_id: `mob_${Date.now()}_${Math.random().toString(36).slice(2)}`, + agent_public_key: Buffer.from(publicKey).toString('base64'), + agent_name: 'Test Mobile Agent', + agent_client: 'custom', + environment: 'dev', + requested_scopes: ['read:wallet.address', 'sign:solana'], + requested_budget: { daily: 5, currency: 'USDC', approval_threshold: 0 }, + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + nonce: randomBytes(16).toString('hex'), + ...overrides, + }; + const signature = ed25519.sign(Buffer.from(canonicalJson(unsigned), 'utf8'), privateKey); + return { + ...unsigned, + signature: Buffer.from(signature).toString('base64'), + }; +} + // ============================================================================ // MessageStore Tests // ============================================================================ @@ -490,6 +532,109 @@ describe('RelayServer', () => { }); }); + describe('Mobile pairing endpoints', () => { + it('returns pending until the mobile vault approves an invite', async () => { + const invite = createMobileInvite(); + + const pendingResponse = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/status` + ); + const pending = await pendingResponse.json(); + + expect(pendingResponse.status).toBe(200); + expect(pending.status).toBe('pending'); + + const approveResponse = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invite, + vault_id: 'vault_mobile_test', + agent_id: 'agent_mobile_test', + vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), + vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), + approved_scopes: invite.requested_scopes, + approved_budget: invite.requested_budget, + }), + } + ); + const approved = await approveResponse.json(); + + expect(approveResponse.status).toBe(200); + expect(approved.status).toBe('approved'); + expect(approved.agent_id).toBe('agent_mobile_test'); + + const statusResponse = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/status` + ); + const status = await statusResponse.json(); + + expect(status.status).toBe('approved'); + expect(status.vault_id).toBe('vault_mobile_test'); + expect(status.vault_hpke_public_key).toBe(Buffer.alloc(32, 1).toString('base64')); + expect(status.approved_scopes).toEqual(invite.requested_scopes); + }); + + it('rejects forged mobile approval payloads', async () => { + const invite = createMobileInvite(); + const forged = { + ...invite, + requested_budget: { + ...invite.requested_budget, + daily: 999, + }, + }; + + const response = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invite: forged, + vault_id: 'vault_mobile_test', + agent_id: 'agent_mobile_test', + vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), + vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), + approved_scopes: forged.requested_scopes, + approved_budget: forged.requested_budget, + }), + } + ); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('Invalid invite signature'); + }); + + it('rejects scopes the agent did not request', async () => { + const invite = createMobileInvite({ requested_scopes: ['read:wallet.address'] }); + + const response = await fetch( + `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + invite, + vault_id: 'vault_mobile_test', + agent_id: 'agent_mobile_test', + vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), + vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), + approved_scopes: ['read:wallet.address', 'sign:solana'], + approved_budget: invite.requested_budget, + }), + } + ); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain('Approved scope was not requested'); + }); + }); + describe('Request endpoint', () => { it('should reject request for unconnected vault', async () => { const envelope = createTestEnvelope({ vault_id: 'vault_not_connected' }); From d91b7da29eff99d4520a56b4869dd858a1456074 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Tue, 19 May 2026 23:04:36 +0530 Subject: [PATCH 03/10] feat: add agent relay smoke test --- packages/dcp-agent/src/index.ts | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index 9898373..2ae744b 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -38,6 +38,7 @@ import { findAgent, } from './config.js'; import { AgentProxy } from './proxy.js'; +import { AgentConnection } from './connection.js'; import { runMcpServer } from './mcp.js'; import { runHttpMcpServer } from './http-mcp.js'; import { AgentError } from './types.js'; @@ -228,6 +229,13 @@ interface MobilePairOptions { waitSeconds?: string; } +interface SmokeOptions { + agent?: string; + signMessage?: string; + forceRelay?: boolean; + json?: boolean; +} + function parseNumberOption(value: string | undefined, fallback: number, label: string): number { if (value === undefined) return fallback; const parsed = Number(value); @@ -339,6 +347,81 @@ function getAgentDataDir(): string { return path.join(homeDir, '.dcp', 'agents'); } +function loadAgentForCommand(agentIdOrName?: string) { + if (agentIdOrName) { + const config = findAgent(agentIdOrName); + if (!config) { + error(`Agent not found: ${agentIdOrName}`); + console.log(dim('Available agents:')); + listConfigs().forEach((c) => console.log(dim(` - ${c.agent_name} (${c.agent_id})`))); + process.exit(1); + } + return config; + } + + const config = getDefaultAgent(); + if (!config) { + error('No agent configured. Run "dcp-agent mobile pair --wait" first.'); + process.exit(1); + } + return config; +} + +async function smokeCommand(options: SmokeOptions): Promise { + const config = loadAgentForCommand(options.agent); + const connection = new AgentConnection(config, { forceRelay: options.forceRelay ?? true }); + + try { + if (!options.json) { + console.log(); + console.log(chalk.bold('DCP Agent Smoke Test')); + console.log(dim('─'.repeat(50))); + console.log(` ${dim('Agent:')} ${config.agent_name} (${config.agent_id})`); + console.log(` ${dim('Vault:')} ${config.vault_id}`); + console.log(` ${dim('Relay:')} ${config.relay_url}`); + console.log(); + } + + await connection.connect(); + const address = await connection.getAddress('solana'); + const result: Record = { address }; + + if (!options.json) { + success(`Wallet address: ${address.address}`); + } + + if (options.signMessage) { + if (!options.json) { + console.log(dim('Waiting for mobile approval of sign_message...')); + } + const signed = await connection.signMessage({ + chain: 'solana', + message: options.signMessage, + encoding: 'utf8', + description: 'DCP mobile smoke test', + }); + result.signature = signed; + if (!options.json) { + success(`Signature: ${signed.signature}`); + } + } + + await connection.close(); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } + } catch (err) { + await connection.close().catch(() => undefined); + if (options.json) { + console.log(JSON.stringify({ error: err instanceof Error ? err.message : 'Smoke test failed' }, null, 2)); + } else { + error(err instanceof Error ? err.message : 'Smoke test failed'); + } + process.exit(1); + } +} + async function runCommand(options: RunOptions): Promise { // Get agent config let config; @@ -717,6 +800,15 @@ program .option('--force-relay', 'Force relay mode (skip local vault detection)') .action(runCommand); +program + .command('smoke') + .description('Test a paired agent against the vault/relay without configuring Claude') + .option('-a, --agent ', 'Agent ID or name to test (default: first configured)') + .option('--sign-message ', 'Also request a mobile-approved Solana message signature') + .option('--force-relay', 'Force relay mode (default for smoke)', true) + .option('-j, --json', 'Output as JSON') + .action(smokeCommand); + program .command('status') .description('Show agent status') From ff0c04ce84b5671750134a3a6f38ee02c530bf33 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Tue, 19 May 2026 23:23:41 +0530 Subject: [PATCH 04/10] feat: use canonical agent ids for mobile pairing --- packages/dcp-agent/src/index.ts | 4 ++++ packages/dcp-agent/src/mobile-pairing.ts | 17 +++++++++++++++++ packages/dcp-agent/src/types.ts | 1 + packages/dcp-agent/tests/mobile-pairing.test.ts | 4 ++++ packages/dcp-relay/src/relay.ts | 3 +++ packages/dcp-relay/src/store.ts | 1 + packages/dcp-relay/src/types.ts | 1 + packages/dcp-relay/tests/relay.test.ts | 9 +++++---- 8 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index 2ae744b..190dde0 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -217,6 +217,7 @@ interface MobilePairOptions { client?: MobileAgentClient; environment?: MobileAgentEnvironment; name?: string; + agentId?: string; relayUrl?: string; scope?: string[]; dailyBudget?: string; @@ -272,6 +273,7 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { client, environment, agentName: options.name || defaultAgentName(client), + agentId: options.agentId, relayUrl: options.relayUrl, requestedScopes: scopes, requestedBudget: { @@ -303,6 +305,7 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { } console.log(` ${dim('Agent:')} ${created.invite.agent_name}`); + console.log(` ${dim('Agent ID:')} ${created.invite.requested_agent_id}`); console.log(` ${dim('Client:')} ${created.invite.agent_client}`); console.log(` ${dim('Environment:')} ${created.invite.environment}`); console.log(` ${dim('Invite ID:')} ${created.invite.invite_id}`); @@ -776,6 +779,7 @@ mobileCommand .option('--client ', 'Agent client: claude-desktop, cursor, vscode, hermes, openclaw, mcp, custom, hosted', 'custom') .option('--environment ', 'Environment: local, vps, hosted, dev') .option('-n, --name ', 'Agent display name') + .option('--agent-id ', 'Agent ID to request. Defaults to canonical DCP ID for known clients.') .option('--relay-url ', 'Mobile relay API/base URL') .option('--scope ', 'Requested scope. Repeat for multiple scopes.', (value, previous: string[] = []) => [...previous, value]) .option('--daily-budget ', 'Requested daily budget', '0') diff --git a/packages/dcp-agent/src/mobile-pairing.ts b/packages/dcp-agent/src/mobile-pairing.ts index 3e95016..25961c1 100644 --- a/packages/dcp-agent/src/mobile-pairing.ts +++ b/packages/dcp-agent/src/mobile-pairing.ts @@ -45,6 +45,7 @@ export interface CreateMobilePairingInviteInput { client: MobileAgentClient; environment: MobileAgentEnvironment; agentName: string; + agentId?: string; relayUrl?: string; requestedScopes?: MobileDcpScope[]; requestedBudget?: MobilePairingBudget; @@ -77,6 +78,20 @@ function encodeMobilePairingInvite(invite: MobilePairingInvite): string { return `dcp://pair?invite=${encodeURIComponent(JSON.stringify(invite))}`; } +export function canonicalMobileAgentId(client: MobileAgentClient): string { + const ids: Record = { + 'claude-desktop': 'agent_claude_desktop', + cursor: 'agent_cursor', + vscode: 'agent_vscode', + hermes: 'agent_hermes_local', + openclaw: 'agent_openclaw_local', + mcp: 'agent_local_mcp', + custom: `agent_mobile_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + hosted: `agent_hosted_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + }; + return ids[client]; +} + function assertSupported(input: CreateMobilePairingInviteInput): void { if (!SUPPORTED_CLIENTS.has(input.client)) { throw new Error(`Unsupported mobile agent client: ${input.client}`); @@ -101,6 +116,7 @@ export function createMobilePairingInvite( const ttlSeconds = input.ttlSeconds ?? 10 * 60; const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1000); const inviteId = `mob_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + const requestedAgentId = input.agentId?.trim() || canonicalMobileAgentId(input.client); const requestedBudget = input.requestedBudget ?? { daily: 0, currency: 'USDC', @@ -112,6 +128,7 @@ export function createMobilePairingInvite( version: MOBILE_PAIRING_VERSION, relay_url: (input.relayUrl || process.env.DCP_MOBILE_RELAY_URL || DEFAULT_MOBILE_RELAY_URL).replace(/\/$/, ''), invite_id: inviteId, + requested_agent_id: requestedAgentId, agent_public_key: keypair.publicKey.toString('base64'), agent_name: input.agentName.trim(), agent_client: input.client, diff --git a/packages/dcp-agent/src/types.ts b/packages/dcp-agent/src/types.ts index 33e14f5..d41c479 100644 --- a/packages/dcp-agent/src/types.ts +++ b/packages/dcp-agent/src/types.ts @@ -133,6 +133,7 @@ export interface MobilePairingInvite { version: '1.0'; relay_url: string; invite_id: string; + requested_agent_id: string; agent_public_key: string; agent_name: string; agent_client: MobileAgentClient; diff --git a/packages/dcp-agent/tests/mobile-pairing.test.ts b/packages/dcp-agent/tests/mobile-pairing.test.ts index b955199..e86eed3 100644 --- a/packages/dcp-agent/tests/mobile-pairing.test.ts +++ b/packages/dcp-agent/tests/mobile-pairing.test.ts @@ -26,6 +26,7 @@ describe('mobile pairing', () => { expect(invite.type).toBe('dcp_agent_pairing'); expect(invite.version).toBe('1.0'); expect(invite.relay_url).toBe('https://relay.example.test'); + expect(invite.requested_agent_id).toBe('agent_claude_desktop'); expect(invite.agent_client).toBe('claude-desktop'); expect(invite.environment).toBe('local'); expect(invite.requested_scopes).toEqual(['read:wallet.address', 'sign:solana']); @@ -40,8 +41,11 @@ describe('mobile pairing', () => { client: 'custom', environment: 'dev', agentName: 'Test Agent', + agentId: 'agent_custom_test', }); + expect(invite.requested_agent_id).toBe('agent_custom_test'); + const valid = verifySignature( Buffer.from(canonicalInvitePayload(invite), 'utf8'), Buffer.from(invite.signature!, 'base64'), diff --git a/packages/dcp-relay/src/relay.ts b/packages/dcp-relay/src/relay.ts index 2123bf0..e920840 100644 --- a/packages/dcp-relay/src/relay.ts +++ b/packages/dcp-relay/src/relay.ts @@ -1298,6 +1298,9 @@ export class RelayServer { if (!body.vault_id || !body.agent_id) { return 'Missing vault_id or agent_id'; } + if (body.agent_id !== invite.requested_agent_id) { + return 'Agent ID mismatch'; + } if (!body.vault_hpke_public_key || !body.vault_signing_public_key) { return 'Missing vault relay public keys'; } diff --git a/packages/dcp-relay/src/store.ts b/packages/dcp-relay/src/store.ts index 970d6c3..388eb8e 100644 --- a/packages/dcp-relay/src/store.ts +++ b/packages/dcp-relay/src/store.ts @@ -831,6 +831,7 @@ export class MobilePairingStore { version: '1.0', relay_url: '', invite_id: inviteId, + requested_agent_id: '', agent_public_key: '', agent_name: '', agent_client: 'custom', diff --git a/packages/dcp-relay/src/types.ts b/packages/dcp-relay/src/types.ts index a262a0a..b282481 100644 --- a/packages/dcp-relay/src/types.ts +++ b/packages/dcp-relay/src/types.ts @@ -340,6 +340,7 @@ export interface MobilePairingInvite { version: '1.0'; relay_url: string; invite_id: string; + requested_agent_id: string; agent_public_key: string; agent_name: string; agent_client: MobileAgentClient; diff --git a/packages/dcp-relay/tests/relay.test.ts b/packages/dcp-relay/tests/relay.test.ts index 787f184..5b34909 100644 --- a/packages/dcp-relay/tests/relay.test.ts +++ b/packages/dcp-relay/tests/relay.test.ts @@ -59,6 +59,7 @@ function createMobileInvite(overrides: Partial = {}): Mobil version: '1.0', relay_url: 'http://127.0.0.1:8422', invite_id: `mob_${Date.now()}_${Math.random().toString(36).slice(2)}`, + requested_agent_id: 'agent_claude_desktop', agent_public_key: Buffer.from(publicKey).toString('base64'), agent_name: 'Test Mobile Agent', agent_client: 'custom', @@ -552,7 +553,7 @@ describe('RelayServer', () => { body: JSON.stringify({ invite, vault_id: 'vault_mobile_test', - agent_id: 'agent_mobile_test', + agent_id: 'agent_claude_desktop', vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), approved_scopes: invite.requested_scopes, @@ -564,7 +565,7 @@ describe('RelayServer', () => { expect(approveResponse.status).toBe(200); expect(approved.status).toBe('approved'); - expect(approved.agent_id).toBe('agent_mobile_test'); + expect(approved.agent_id).toBe('agent_claude_desktop'); const statusResponse = await fetch( `http://127.0.0.1:${testPort}/v1/mobile/pairings/${encodeURIComponent(invite.invite_id)}/status` @@ -595,7 +596,7 @@ describe('RelayServer', () => { body: JSON.stringify({ invite: forged, vault_id: 'vault_mobile_test', - agent_id: 'agent_mobile_test', + agent_id: 'agent_claude_desktop', vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), approved_scopes: forged.requested_scopes, @@ -620,7 +621,7 @@ describe('RelayServer', () => { body: JSON.stringify({ invite, vault_id: 'vault_mobile_test', - agent_id: 'agent_mobile_test', + agent_id: 'agent_claude_desktop', vault_hpke_public_key: Buffer.alloc(32, 1).toString('base64'), vault_signing_public_key: Buffer.alloc(32, 2).toString('base64'), approved_scopes: ['read:wallet.address', 'sign:solana'], From 255bd2d56ad14d5258465ebf6686609959bcbc60 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Tue, 19 May 2026 23:52:40 +0530 Subject: [PATCH 05/10] feat: add mobile install command --- packages/dcp-agent/src/index.ts | 111 +++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index 190dde0..3f04093 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -21,6 +21,7 @@ import chalk from 'chalk'; import ora, { type Ora } from 'ora'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as os from 'node:os'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import qrcode from 'qrcode-terminal'; @@ -228,6 +229,7 @@ interface MobilePairOptions { noQr?: boolean; wait?: boolean; waitSeconds?: string; + configureMcp?: boolean; } interface SmokeOptions { @@ -260,6 +262,80 @@ function defaultAgentName(client: MobileAgentClient): string { return names[client] || 'DCP Agent'; } +function mcpServerConfig(agentId: string): { command: string; args: string[] } { + return { + command: 'npx', + args: ['-y', '@dcprotocol/agent', 'run', '--mode', 'mcp', '--agent', agentId, '--force-relay'], + }; +} + +function claudeDesktopConfigPath(): string | null { + const home = os.homedir(); + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + return appData ? path.join(appData, 'Claude', 'claude_desktop_config.json') : null; + } + return path.join(home, '.config', 'Claude', 'claude_desktop_config.json'); +} + +function readJsonFile(filePath: string): Record { + if (!fs.existsSync(filePath)) return {}; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return raw.trim() ? JSON.parse(raw) as Record : {}; + } catch { + throw new Error(`Could not parse existing MCP config: ${filePath}`); + } +} + +function upsertClaudeDesktopMcp(agentId: string): string { + const configPath = claudeDesktopConfigPath(); + if (!configPath) { + throw new Error('Could not locate Claude Desktop config path on this OS'); + } + + const config = readJsonFile(configPath); + const mcpServers = config.mcpServers && typeof config.mcpServers === 'object' + ? config.mcpServers as Record + : {}; + mcpServers.dcp = mcpServerConfig(agentId); + config.mcpServers = mcpServers; + + fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + return configPath; +} + +function printMcpInstallHelp(client: MobileAgentClient, agentId: string): void { + const serverConfig = mcpServerConfig(agentId); + if (client === 'cursor') { + const deepLink = `cursor://anysphere.cursor-deeplink/mcp/install?name=dcp&config=${Buffer.from(JSON.stringify(serverConfig)).toString('base64')}`; + console.log(); + console.log(chalk.bold('Cursor MCP install link:')); + console.log(deepLink); + return; + } + + console.log(); + console.log(chalk.bold('MCP config:')); + console.log(JSON.stringify({ mcpServers: { dcp: serverConfig } }, null, 2)); +} + +function configureMcpForClient(client: MobileAgentClient, agentId: string): void { + if (client === 'claude-desktop') { + const configPath = upsertClaudeDesktopMcp(agentId); + success('Claude Desktop MCP config updated'); + console.log(` ${dim('Config:')} ${configPath}`); + console.log(dim('Restart Claude Desktop to load the DCP mobile-backed server.')); + return; + } + + printMcpInstallHelp(client, agentId); +} + async function mobilePairCommand(options: MobilePairOptions): Promise { try { const client = options.client || 'custom'; @@ -337,7 +413,11 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { console.log(` ${dim('Vault ID:')} ${status.vault_id}`); const config = promoteMobilePendingConfig(created.pendingConfig, status); console.log(dim(`Saved agent config: ${config.agent_id}`)); - console.log(dim(`Next: run "dcp-agent run --agent ${config.agent_id} --mode mcp" from your MCP host.`)); + if (options.configureMcp) { + configureMcpForClient(client, config.agent_id); + } else { + console.log(dim(`Next: run "dcp-agent run --agent ${config.agent_id} --mode mcp --force-relay" from your MCP host.`)); + } } } catch (err) { error(err instanceof Error ? err.message : 'Failed to create mobile pairing invite'); @@ -345,6 +425,14 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { } } +async function mobileInstallCommand(options: MobilePairOptions): Promise { + await mobilePairCommand({ + ...options, + wait: true, + configureMcp: options.configureMcp ?? true, + }); +} + function getAgentDataDir(): string { const homeDir = process.env.HOME || process.env.USERPROFILE || '.'; return path.join(homeDir, '.dcp', 'agents'); @@ -790,8 +878,29 @@ mobileCommand .option('--no-qr', 'Do not print terminal QR') .option('--wait', 'Wait until DCP Mobile approves or denies the pairing') .option('--wait-seconds ', 'How long --wait should poll for approval') + .option('--configure-mcp', 'After approval, configure the local MCP client when supported') .action(mobilePairCommand); +mobileCommand + .command('install') + .description('Pair DCP Mobile and configure this MCP client when supported') + .option('--client ', 'Agent client: claude-desktop, cursor, vscode, hermes, openclaw, mcp, custom, hosted', 'claude-desktop') + .option('--environment ', 'Environment: local, vps, hosted, dev') + .option('-n, --name ', 'Agent display name') + .option('--agent-id ', 'Agent ID to request. Defaults to canonical DCP ID for known clients.') + .option('--relay-url ', 'Mobile relay API/base URL') + .option('--scope ', 'Requested scope. Repeat for multiple scopes.', (value, previous: string[] = []) => [...previous, value]) + .option('--daily-budget ', 'Requested daily budget', '0') + .option('--currency ', 'Budget currency: USDC or SOL', 'USDC') + .option('--approval-threshold ', 'Auto-approval threshold', '0') + .option('--ttl-seconds ', 'Invite lifetime in seconds', '600') + .option('--json', 'Print machine-readable JSON') + .option('--no-qr', 'Do not print terminal QR') + .option('--wait-seconds ', 'How long to wait for approval') + .option('--configure-mcp', 'Configure the local MCP client when supported', true) + .option('--no-configure-mcp', 'Do not write MCP client config; print next steps only') + .action(mobileInstallCommand); + program .command('run') .description('Run the agent') From 24113f46b093ab94acca0282039869b4184d4080 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Wed, 20 May 2026 00:26:44 +0530 Subject: [PATCH 06/10] fix: reuse core canonical json for mobile pairing --- packages/dcp-agent/src/mobile-pairing.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/dcp-agent/src/mobile-pairing.ts b/packages/dcp-agent/src/mobile-pairing.ts index 25961c1..b57dee0 100644 --- a/packages/dcp-agent/src/mobile-pairing.ts +++ b/packages/dcp-agent/src/mobile-pairing.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { signMessage, generateSigningKeyPair } from '@dcprotocol/core'; +import { canonicalJson, signMessage, generateSigningKeyPair } from '@dcprotocol/core'; import type { MobileAgentClient, MobileAgentEnvironment, @@ -58,21 +58,7 @@ export interface CreatedMobilePairingInvite { pendingConfig: MobilePendingConfig; } -export function canonicalJson(value: unknown): string { - if (Array.isArray(value)) { - return `[${value.map((item) => canonicalJson(item)).join(',')}]`; - } - - if (value && typeof value === 'object') { - const record = value as Record; - return `{${Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) - .join(',')}}`; - } - - return JSON.stringify(value); -} +export { canonicalJson }; function encodeMobilePairingInvite(invite: MobilePairingInvite): string { return `dcp://pair?invite=${encodeURIComponent(JSON.stringify(invite))}`; From 0a7d98a4c51499f925ebd0c1368cd8af5e77e728 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Wed, 20 May 2026 00:32:02 +0530 Subject: [PATCH 07/10] chore: add issue templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 108 +++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 106 ++++++++++++++++++++ 3 files changed, 222 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..19154d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,108 @@ +name: Bug report +description: Create a report to help us improve DCP. +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Please include enough detail for us to reproduce it safely. + + If this involves leaked secrets, private keys, unauthorized signing, or a live exploit, do not open a public issue. Email security@1ly.store instead. + - type: textarea + id: summary + attributes: + label: What happened? + description: Describe the bug and the impact. + placeholder: DCP did X when I expected Y. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened instead? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Include commands, config, agent flow, or UI steps. + placeholder: | + 1. Run ... + 2. Connect ... + 3. Approve/sign ... + 4. See error ... + validations: + required: true + - type: dropdown + id: component + attributes: + label: Affected component + multiple: true + options: + - Desktop app + - Mobile app + - Vault + - Agent + - MCP + - Telegram approvals + - Relay + - CLI + - SDK / client + - Documentation + - Unsure + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Share relevant versions and platform details. + placeholder: | + OS: + Node: + pnpm: + DCP package/version or commit: + Agent/client used: + validations: + required: true + - type: dropdown + id: security_impact + attributes: + label: Security or privacy impact + description: Select any area this may affect. + multiple: true + options: + - None known + - Approval flow + - Wallet/signing + - Budget enforcement + - Secret/key storage + - Logs or notifications + - Agent permissions + - User privacy + - Unsure + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs, screenshots, or traces + description: Redact private keys, tokens, recovery phrases, API keys, and personal data. + render: shell + - type: textarea + id: regression + attributes: + label: Regression details + description: Did this work before? If yes, which version/commit was good? + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I have removed private keys, tokens, recovery phrases, and sensitive personal data. + required: true + - label: I searched existing issues and did not find a duplicate. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..697c3d2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: mailto:security@1ly.store + about: Report private keys, unauthorized signing, live exploits, or sensitive security issues privately. + - name: General discussion + url: https://github.com/1lystore/dcp/discussions + about: Ask questions, share ideas, or discuss broader DCP usage. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0adc608 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,106 @@ +name: Feature request +description: Suggest an idea for this project. +title: "[Feature]: " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Use this form for product ideas, developer experience improvements, and protocol/API requests. + + DCP treats security and user experience as core product requirements. Please include any approval, signing, privacy, or recovery implications. + - type: textarea + id: problem + attributes: + label: Problem + description: What user pain or workflow limitation are you trying to solve? + placeholder: It is hard to... + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the feature in plain language. + validations: + required: true + - type: dropdown + id: user_type + attributes: + label: Who is this for? + multiple: true + options: + - Wallet owner + - AI agent user + - App developer + - MCP user + - Protocol integrator + - OSS contributor + - Other + validations: + required: true + - type: dropdown + id: component + attributes: + label: Affected component + multiple: true + options: + - Desktop app + - Mobile app + - Vault + - Agent + - MCP + - Telegram approvals + - Relay + - CLI + - SDK / client + - Documentation + - Unsure + validations: + required: true + - type: textarea + id: ux + attributes: + label: UX expectations + description: What should the user see, click, approve, or understand? + placeholder: The user should be able to... + validations: + required: true + - type: dropdown + id: security_area + attributes: + label: Security or privacy considerations + description: Select any areas that need special care. + multiple: true + options: + - None known + - Approval flow + - Wallet/signing + - Budget enforcement + - Secret/key storage + - Logs or notifications + - Agent permissions + - User privacy + - Recovery / backup + - Unsure + validations: + required: true + - type: textarea + id: mvp + attributes: + label: MVP scope + description: What is the smallest useful version of this feature? + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: What other approaches did you consider? + - type: checkboxes + id: checks + attributes: + label: Before submitting + options: + - label: I searched existing issues and did not find a duplicate. + required: true + - label: I described any security or privacy implications I am aware of. + required: true From 385942453fd5ee468958dfc538315854fd60624d Mon Sep 17 00:00:00 2001 From: 1lystore Date: Wed, 20 May 2026 23:23:07 +0530 Subject: [PATCH 08/10] feat: add mobile credential smoke support --- packages/dcp-agent/src/index.ts | 37 +++++++++++++++++++ packages/dcp-agent/src/mobile-pairing.ts | 10 ++++- packages/dcp-agent/src/types.ts | 4 +- .../dcp-agent/tests/mobile-pairing.test.ts | 11 ++++++ packages/dcp-client/src/relay-transport.ts | 17 ++++++++- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index 3f04093..768fecd 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -235,6 +235,10 @@ interface MobilePairOptions { interface SmokeOptions { agent?: string; signMessage?: string; + readScope?: string; + writeScope?: string; + writeName?: string; + writeValue?: string; forceRelay?: boolean; json?: boolean; } @@ -497,6 +501,35 @@ async function smokeCommand(options: SmokeOptions): Promise { } } + if (options.writeScope) { + if (!options.writeValue) { + throw new Error('--write-value is required with --write-scope'); + } + if (!options.json) { + console.log(dim(`Waiting for mobile approval of vault_write ${options.writeScope}...`)); + } + const written = await connection.writeCredential(options.writeScope, { + name: options.writeName || options.writeScope.split('.').at(-1) || options.writeScope, + value: options.writeValue, + }); + result.write = written; + if (!options.json) { + success(`Write approved: ${written.scope}`); + } + } + + if (options.readScope) { + if (!options.json) { + console.log(dim(`Waiting for mobile approval of vault_read ${options.readScope}...`)); + } + const read = await connection.readCredential(options.readScope); + result.read = read; + if (!options.json) { + const keys = read.data ? Object.keys(read.data).join(', ') : 'no data'; + success(`Read approved: ${read.scope} (${keys})`); + } + } + await connection.close(); if (options.json) { @@ -918,6 +951,10 @@ program .description('Test a paired agent against the vault/relay without configuring Claude') .option('-a, --agent ', 'Agent ID or name to test (default: first configured)') .option('--sign-message ', 'Also request a mobile-approved Solana message signature') + .option('--read-scope ', 'Also request a mobile-approved vault_read for a scope, e.g. credentials.api.openai') + .option('--write-scope ', 'Also request a mobile-approved vault_write for a scope, e.g. credentials.api.openai') + .option('--write-name ', 'Display name to store with --write-scope') + .option('--write-value ', 'Secret value to store with --write-scope') .option('--force-relay', 'Force relay mode (default for smoke)', true) .option('-j, --json', 'Output as JSON') .action(smokeCommand); diff --git a/packages/dcp-agent/src/mobile-pairing.ts b/packages/dcp-agent/src/mobile-pairing.ts index b57dee0..c9eac2f 100644 --- a/packages/dcp-agent/src/mobile-pairing.ts +++ b/packages/dcp-agent/src/mobile-pairing.ts @@ -41,6 +41,14 @@ const SUPPORTED_SCOPES = new Set([ 'vault_sign_message', ]); +function isSupportedMobileScope(scope: string): scope is MobileDcpScope { + return ( + SUPPORTED_SCOPES.has(scope as MobileDcpScope) || + scope.startsWith('read:credentials.api.') || + scope.startsWith('write:credentials.api.') + ); +} + export interface CreateMobilePairingInviteInput { client: MobileAgentClient; environment: MobileAgentEnvironment; @@ -86,7 +94,7 @@ function assertSupported(input: CreateMobilePairingInviteInput): void { throw new Error(`Unsupported mobile agent environment: ${input.environment}`); } for (const scope of input.requestedScopes || []) { - if (!SUPPORTED_SCOPES.has(scope)) { + if (!isSupportedMobileScope(scope)) { throw new Error(`Unsupported mobile MVP scope: ${scope}`); } } diff --git a/packages/dcp-agent/src/types.ts b/packages/dcp-agent/src/types.ts index d41c479..8b5157c 100644 --- a/packages/dcp-agent/src/types.ts +++ b/packages/dcp-agent/src/types.ts @@ -120,7 +120,9 @@ export type MobileDcpScope = | 'vault_get_address' | 'vault_budget_check' | 'vault_sign_tx' - | 'vault_sign_message'; + | 'vault_sign_message' + | `read:credentials.api.${string}` + | `write:credentials.api.${string}`; export interface MobilePairingBudget { daily: number; diff --git a/packages/dcp-agent/tests/mobile-pairing.test.ts b/packages/dcp-agent/tests/mobile-pairing.test.ts index e86eed3..430470f 100644 --- a/packages/dcp-agent/tests/mobile-pairing.test.ts +++ b/packages/dcp-agent/tests/mobile-pairing.test.ts @@ -84,6 +84,17 @@ describe('mobile pairing', () => { expect(valid).toBe(false); }); + it('allows mobile API credential read and write scopes', () => { + const { invite } = createMobilePairingInvite({ + client: 'custom', + environment: 'dev', + agentName: 'Credential Agent', + requestedScopes: ['read:credentials.api.openai', 'write:credentials.api.openai'], + }); + + expect(invite.requested_scopes).toEqual(['read:credentials.api.openai', 'write:credentials.api.openai']); + }); + it('rejects unsupported MVP scopes', () => { expect(() => createMobilePairingInvite({ diff --git a/packages/dcp-client/src/relay-transport.ts b/packages/dcp-client/src/relay-transport.ts index 9be1493..e102821 100644 --- a/packages/dcp-client/src/relay-transport.ts +++ b/packages/dcp-client/src/relay-transport.ts @@ -401,6 +401,8 @@ export class RelayTransport implements Transport { const data = response as { scope?: string; data?: Record; + name?: string; + value?: unknown; sensitivity?: 'standard' | 'sensitive' | 'critical'; is_reference?: boolean; requires_consent?: boolean; @@ -428,7 +430,7 @@ export class RelayTransport implements Transport { return { scope: data.scope || scope, - data: data.data ?? null, + data: data.data ?? (data.value !== undefined ? { name: data.name, value: data.value } : null), sensitivity: data.sensitivity, isReference: data.is_reference, sessionId: data.session_id, @@ -445,9 +447,19 @@ export class RelayTransport implements Transport { ): Promise { const sessionId = this.getSessionId(scope); + const secretValue = typeof data.value === 'string' + ? data.value + : typeof data.api_key === 'string' + ? data.api_key + : typeof data.token === 'string' + ? data.token + : undefined; + const params = { scope, data, + name: typeof data.name === 'string' ? data.name : undefined, + value: secretValue, session_id: sessionId, }; @@ -457,6 +469,7 @@ export class RelayTransport implements Transport { scope?: string; created?: boolean; updated?: boolean; + ok?: boolean; sensitivity?: 'standard' | 'sensitive' | 'critical'; requires_consent?: boolean; consent_id?: string; @@ -483,7 +496,7 @@ export class RelayTransport implements Transport { return { scope: result.scope || scope, - created: result.created ?? false, + created: result.created ?? result.ok === true, updated: result.updated ?? false, sensitivity: result.sensitivity, sessionId: result.session_id, From 46ef44594b9fe72b72429a6f3314f471296c6eee Mon Sep 17 00:00:00 2001 From: 1lystore Date: Thu, 21 May 2026 15:41:09 +0530 Subject: [PATCH 09/10] feat: add mobile approval push routing --- packages/dcp-relay/src/index.ts | 13 +++- packages/dcp-relay/src/relay.ts | 93 +++++++++++++++++++++++++- packages/dcp-relay/src/store.ts | 42 ++++++++++++ packages/dcp-relay/src/types.ts | 11 +++ packages/dcp-relay/tests/relay.test.ts | 33 +++++++++ 5 files changed, 188 insertions(+), 4 deletions(-) diff --git a/packages/dcp-relay/src/index.ts b/packages/dcp-relay/src/index.ts index bca80dd..3eec219 100644 --- a/packages/dcp-relay/src/index.ts +++ b/packages/dcp-relay/src/index.ts @@ -21,7 +21,7 @@ // ============================================================================ export { RelayServer } from './relay.js'; -export { MessageStore, ConnectionStore, RateLimiter, MobilePairingStore } from './store.js'; +export { MessageStore, ConnectionStore, RateLimiter, MobilePairingStore, PushTokenStore } from './store.js'; export { authenticateRegistration, verifyRegistrationSignature, @@ -43,6 +43,7 @@ export { type LongPollResponse, type StoredMessage, type VaultConnection, + type PushTokenRegistration, type RelayErrorCode, type PairingClaim, type StoredPairingClaim, @@ -84,6 +85,7 @@ interface RelayCliOptions { host: string; debug: boolean; rateLimitPerMinute: number; + expoPushUrl: string; } function parseArgs(): RelayCliOptions { @@ -94,6 +96,7 @@ function parseArgs(): RelayCliOptions { let host = process.env.DCP_RELAY_HOST || DEFAULT_RELAY_CONFIG.host; let debug = process.env.DCP_RELAY_DEBUG === 'true' || false; let rateLimitPerMinute = parseInt(process.env.DCP_RELAY_RATE_LIMIT || '', 10) || DEFAULT_RELAY_CONFIG.rateLimitPerMinute; + let expoPushUrl = process.env.DCP_EXPO_PUSH_URL ?? DEFAULT_RELAY_CONFIG.expoPushUrl; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -105,6 +108,8 @@ function parseArgs(): RelayCliOptions { debug = true; } else if (arg === '--rate-limit' || arg === '-r') { rateLimitPerMinute = parseInt(args[++i], 10) || rateLimitPerMinute; + } else if (arg === '--no-push') { + expoPushUrl = ''; } else if (arg === '--help') { console.log(` DCP Relay - Encrypted message bus for cloud MCP clients @@ -124,6 +129,7 @@ Environment Variables: DCP_RELAY_HOST Host to bind to DCP_RELAY_DEBUG Enable debug logging (true/false) DCP_RELAY_RATE_LIMIT Max requests per vault per minute + DCP_EXPO_PUSH_URL Expo push API endpoint; set empty with --no-push to disable Examples: dcp-relay # Start on default port @@ -135,11 +141,11 @@ Examples: } } - return { port, host, debug, rateLimitPerMinute }; + return { port, host, debug, rateLimitPerMinute, expoPushUrl }; } async function main(): Promise { - const { port, host, debug, rateLimitPerMinute } = parseArgs(); + const { port, host, debug, rateLimitPerMinute, expoPushUrl } = parseArgs(); const relay = new RelayServer({ port, @@ -147,6 +153,7 @@ async function main(): Promise { debug, enableLongPoll: true, rateLimitPerMinute, + expoPushUrl, }); // Handle shutdown diff --git a/packages/dcp-relay/src/relay.ts b/packages/dcp-relay/src/relay.ts index e920840..5ff9242 100644 --- a/packages/dcp-relay/src/relay.ts +++ b/packages/dcp-relay/src/relay.ts @@ -44,7 +44,7 @@ import { DEFAULT_RELAY_CONFIG, RELAY_VERSION, } from './types.js'; -import { MessageStore, ConnectionStore, RateLimiter, PairingClaimStore, MobilePairingStore } from './store.js'; +import { MessageStore, ConnectionStore, RateLimiter, PairingClaimStore, MobilePairingStore, PushTokenStore } from './store.js'; import { authenticateRegistration, authenticateRequest, closeAuth, type AuthConfig } from './auth.js'; // ============================================================================ @@ -58,6 +58,7 @@ export class RelayServer { private rateLimiter: RateLimiter; private pairingClaimStore: PairingClaimStore; private mobilePairingStore: MobilePairingStore; + private pushTokenStore: PushTokenStore; private config: RelayConfig; private authConfig: AuthConfig; private heartbeatInterval: ReturnType | null = null; @@ -79,6 +80,7 @@ export class RelayServer { ); this.pairingClaimStore = new PairingClaimStore(); this.mobilePairingStore = new MobilePairingStore(); + this.pushTokenStore = new PushTokenStore(); this.server = Fastify({ logger: this.config.debug @@ -164,6 +166,7 @@ export class RelayServer { rateLimit: this.rateLimiter.getStats(), pairingClaims: this.pairingClaimStore.getStats(), mobilePairings: this.mobilePairingStore.getStats(), + push: this.pushTokenStore.getStats(), timestamp: new Date().toISOString(), })); @@ -176,6 +179,7 @@ export class RelayServer { const rateLimitStats = this.rateLimiter.getStats(); const pairingStats = this.pairingClaimStore.getStats(); const mobilePairingStats = this.mobilePairingStore.getStats(); + const pushStats = this.pushTokenStore.getStats(); const format = request.query.format; @@ -211,6 +215,9 @@ export class RelayServer { '# HELP dcp_relay_mobile_pairings_total Total mobile pairings', '# TYPE dcp_relay_mobile_pairings_total gauge', `dcp_relay_mobile_pairings_total ${mobilePairingStats.totalMobilePairings}`, + '# HELP dcp_relay_push_tokens_registered Registered mobile push tokens', + '# TYPE dcp_relay_push_tokens_registered gauge', + `dcp_relay_push_tokens_registered ${pushStats.registeredPushTokens}`, '', ]; reply.header('Content-Type', 'text/plain; charset=utf-8'); @@ -224,6 +231,7 @@ export class RelayServer { rateLimit: rateLimitStats, pairingClaims: pairingStats, mobilePairings: mobilePairingStats, + push: pushStats, websockets: { vaultConnections: this.wsConnections.size, clientConnections: this.clientSockets.size, @@ -379,6 +387,40 @@ export class RelayServer { return reply.send({ success: true, vault_id: request.params.vaultId }); } ); + + this.server.post<{ + Body: { + vault_id: string; + token: string; + platform?: 'ios' | 'android' | 'web' | 'unknown'; + device_id?: string; + }; + }>( + '/v1/devices/push-token', + async (request, reply) => { + const { vault_id, token, platform, device_id } = request.body; + if (!vault_id || !token) { + return reply.status(400).send({ error: 'Missing vault_id or token' }); + } + if (!this.isExpoPushToken(token)) { + return reply.status(400).send({ error: 'Unsupported push token format' }); + } + + const record = this.pushTokenStore.register({ + vault_id, + token, + platform: platform ?? 'unknown', + device_id, + }); + + return reply.send({ + success: true, + vault_id: record.vault_id, + platform: record.platform, + updated_at: new Date(record.updated_at).toISOString(), + }); + } + ); } private async handleRequest( @@ -468,6 +510,8 @@ export class RelayServer { this.messageStore.markDelivered(envelope.request_id); } + void this.notifyMobilePush(envelope); + return reply.status(202).send({ queued: true, accepted: true, @@ -1133,6 +1177,8 @@ export class RelayServer { this.messageStore.markDelivered(envelope.request_id); } + void this.notifyMobilePush(envelope); + this.sendWsMessage(ws, { type: 'ack', payload: { request_id: envelope.request_id, accepted: true }, @@ -1184,6 +1230,47 @@ export class RelayServer { this.unregisterClientRequest(response.request_id); } + private isExpoPushToken(token: string): boolean { + return /^Expo(nent)?PushToken\[[A-Za-z0-9_-]+\]$/.test(token); + } + + private async notifyMobilePush(envelope: RelayEnvelope): Promise { + if (!this.config.expoPushUrl) return; + const registration = this.pushTokenStore.get(envelope.vault_id); + if (!registration) return; + + try { + const response = await fetch(this.config.expoPushUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + to: registration.token, + title: 'DCP approval needed', + body: 'Open DCP to review this request.', + sound: 'default', + priority: 'high', + data: { + type: 'approval_pending', + vault_id: envelope.vault_id, + request_id: envelope.request_id, + }, + }), + }); + + if (!response.ok && this.config.debug) { + const text = await response.text().catch(() => ''); + console.error(`Expo push failed (${response.status})${text ? `: ${text}` : ''}`); + } + } catch (err) { + if (this.config.debug) { + console.error('Expo push dispatch failed:', err); + } + } + } + private sendWsMessage(ws: WebSocket, msg: WsMessage): void { try { ws.send(JSON.stringify(msg)); @@ -1383,4 +1470,8 @@ export class RelayServer { getConnectionStore(): ConnectionStore { return this.connectionStore; } + + getPushTokenStore(): PushTokenStore { + return this.pushTokenStore; + } } diff --git a/packages/dcp-relay/src/store.ts b/packages/dcp-relay/src/store.ts index 388eb8e..3c1f568 100644 --- a/packages/dcp-relay/src/store.ts +++ b/packages/dcp-relay/src/store.ts @@ -22,6 +22,7 @@ import type { StoredPairingClaim, MobilePairingApprovalRequest, MobilePairingRecord, + PushTokenRegistration, } from './types.js'; import { RelayError, MESSAGE_TTL_MS } from './types.js'; import { createHash, randomUUID } from 'node:crypto'; @@ -48,6 +49,7 @@ export class MessageStore { debug: config.debug ?? false, rateLimitPerMinute: config.rateLimitPerMinute ?? 60, rateLimitWindowMs: config.rateLimitWindowMs ?? 60_000, + expoPushUrl: config.expoPushUrl ?? 'https://exp.host/--/api/v2/push/send', }; // Start cleanup interval @@ -911,3 +913,43 @@ export class MobilePairingStore { } } } + +// ============================================================================ +// Mobile Push Token Store +// ============================================================================ + +const PUSH_TOKEN_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; + +export class PushTokenStore { + private tokensByVault: Map = new Map(); + + register(input: Omit): PushTokenRegistration { + const record: PushTokenRegistration = { + ...input, + platform: input.platform ?? 'unknown', + updated_at: Date.now(), + }; + this.tokensByVault.set(input.vault_id, record); + return record; + } + + get(vaultId: string): PushTokenRegistration | undefined { + const record = this.tokensByVault.get(vaultId); + if (!record) return undefined; + if (Date.now() - record.updated_at > PUSH_TOKEN_MAX_AGE_MS) { + this.tokensByVault.delete(vaultId); + return undefined; + } + return record; + } + + remove(vaultId: string): boolean { + return this.tokensByVault.delete(vaultId); + } + + getStats(): { registeredPushTokens: number } { + return { + registeredPushTokens: this.tokensByVault.size, + }; + } +} diff --git a/packages/dcp-relay/src/types.ts b/packages/dcp-relay/src/types.ts index b282481..f0c4dce 100644 --- a/packages/dcp-relay/src/types.ts +++ b/packages/dcp-relay/src/types.ts @@ -157,6 +157,14 @@ export interface VaultConnection { ws?: unknown; // WebSocket reference } +export interface PushTokenRegistration { + vault_id: string; + token: string; + platform?: 'ios' | 'android' | 'web' | 'unknown'; + device_id?: string; + updated_at: number; +} + // ============================================================================ // Error Types (from protocol spec section 7.3) // ============================================================================ @@ -219,6 +227,8 @@ export interface RelayConfig { rateLimitPerMinute: number; /** Rate limit window in ms (default: 60000 = 1 minute) */ rateLimitWindowMs: number; + /** Expo push API endpoint. Empty disables outbound push delivery. */ + expoPushUrl: string; } export const DEFAULT_RELAY_CONFIG: RelayConfig = { @@ -231,6 +241,7 @@ export const DEFAULT_RELAY_CONFIG: RelayConfig = { debug: false, rateLimitPerMinute: 60, rateLimitWindowMs: 60_000, + expoPushUrl: 'https://exp.host/--/api/v2/push/send', }; // ============================================================================ diff --git a/packages/dcp-relay/tests/relay.test.ts b/packages/dcp-relay/tests/relay.test.ts index 5b34909..51982e7 100644 --- a/packages/dcp-relay/tests/relay.test.ts +++ b/packages/dcp-relay/tests/relay.test.ts @@ -637,6 +637,39 @@ describe('RelayServer', () => { }); describe('Request endpoint', () => { + it('registers mobile push tokens without exposing approval details', async () => { + const response = await fetch(`http://127.0.0.1:${testPort}/v1/devices/push-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vault_id: 'vault_push', + token: 'ExpoPushToken[abc123_DEF-456]', + platform: 'android', + device_id: 'device_test', + }), + }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.vault_id).toBe('vault_push'); + expect(server.getPushTokenStore().get('vault_push')?.token).toBe('ExpoPushToken[abc123_DEF-456]'); + }); + + it('rejects unsupported push token formats', async () => { + const response = await fetch(`http://127.0.0.1:${testPort}/v1/devices/push-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vault_id: 'vault_push', + token: 'plain-secret-token', + platform: 'android', + }), + }); + + expect(response.status).toBe(400); + }); + it('should reject request for unconnected vault', async () => { const envelope = createTestEnvelope({ vault_id: 'vault_not_connected' }); From 8ea28159dcb2115daafa59e1ff00c04961bc1dd1 Mon Sep 17 00:00:00 2001 From: 1lystore Date: Thu, 21 May 2026 21:41:00 +0530 Subject: [PATCH 10/10] feat: support mobile agent approvals --- packages/dcp-agent/package.json | 1 + packages/dcp-agent/src/index.ts | 103 +++++++++++++++--- packages/dcp-agent/src/mobile-pairing.ts | 28 ++++- packages/dcp-agent/src/types.ts | 2 +- .../dcp-agent/tests/mobile-pairing.test.ts | 1 + packages/dcp-relay/src/relay.ts | 41 ++++++- packages/dcp-relay/src/store.ts | 20 +++- packages/dcp-relay/src/types.ts | 4 +- pnpm-lock.yaml | 7 +- 9 files changed, 184 insertions(+), 23 deletions(-) diff --git a/packages/dcp-agent/package.json b/packages/dcp-agent/package.json index 3c4d586..8e84d19 100644 --- a/packages/dcp-agent/package.json +++ b/packages/dcp-agent/package.json @@ -59,6 +59,7 @@ "@dcprotocol/core": "^2.0.1", "@modelcontextprotocol/sdk": "^1.0.0", "@noble/curves": "^1.4.0", + "@solana/web3.js": "^1.98.0", "chalk": "^5.3.0", "commander": "^12.1.0", "ora": "^8.0.1", diff --git a/packages/dcp-agent/src/index.ts b/packages/dcp-agent/src/index.ts index 768fecd..48f328b 100644 --- a/packages/dcp-agent/src/index.ts +++ b/packages/dcp-agent/src/index.ts @@ -25,6 +25,7 @@ import * as os from 'node:os'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import qrcode from 'qrcode-terminal'; +import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; import { parseAndVerifyGrant, createConfigFromGrant, @@ -44,7 +45,7 @@ import { runMcpServer } from './mcp.js'; import { runHttpMcpServer } from './http-mcp.js'; import { AgentError } from './types.js'; import { processSecretsRequest, fetchSecret, fetchSecrets } from './secrets.js'; -import { createMobilePairingInvite, waitForMobilePairingApproval } from './mobile-pairing.js'; +import { createMobilePairingInvite, publishMobilePairingInvite, waitForMobilePairingApproval } from './mobile-pairing.js'; import { configureOpenClawCommand, installServiceCommand, @@ -222,11 +223,12 @@ interface MobilePairOptions { relayUrl?: string; scope?: string[]; dailyBudget?: string; - currency?: 'SOL' | 'USDC'; + currency?: 'SOL' | 'USDC' | '1LY'; approvalThreshold?: string; ttlSeconds?: string; json?: boolean; noQr?: boolean; + largeQr?: boolean; wait?: boolean; waitSeconds?: string; configureMcp?: boolean; @@ -235,10 +237,14 @@ interface MobilePairOptions { interface SmokeOptions { agent?: string; signMessage?: string; + signTxAmount?: string; + signTxCurrency?: string; + signTxDestination?: string; readScope?: string; writeScope?: string; writeName?: string; writeValue?: string; + skipAddress?: boolean; forceRelay?: boolean; json?: boolean; } @@ -344,7 +350,7 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { try { const client = options.client || 'custom'; const environment = options.environment || (client === 'hermes' || client === 'openclaw' ? 'vps' : 'local'); - const scopes = (options.scope?.length ? options.scope : ['read:wallet.address', 'sign:solana']) as MobileDcpScope[]; + const scopes = (options.scope?.length ? options.scope : []) as MobileDcpScope[]; const daily = parseNumberOption(options.dailyBudget, 0, '--daily-budget'); const approvalThreshold = parseNumberOption(options.approvalThreshold, 0, '--approval-threshold'); const ttlSeconds = parseNumberOption(options.ttlSeconds, 10 * 60, '--ttl-seconds'); @@ -358,18 +364,20 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { requestedScopes: scopes, requestedBudget: { daily, - currency: options.currency || 'USDC', + currency: options.currency || 'SOL', approval_threshold: approvalThreshold, }, ttlSeconds, }); + await publishMobilePairingInvite(created.invite); saveMobilePendingConfig(created.pendingConfig); if (options.json) { console.log(JSON.stringify({ invite: created.invite, invite_url: created.inviteUrl, + short_invite_url: created.shortInviteUrl, }, null, 2)); return; } @@ -380,7 +388,7 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { console.log(); if (!options.noQr) { - qrcode.generate(created.inviteUrl, { small: true }); + qrcode.generate(created.shortInviteUrl, { small: !options.largeQr }); console.log(); } @@ -392,7 +400,8 @@ async function mobilePairCommand(options: MobilePairOptions): Promise { console.log(` ${dim('Expires:')} ${created.invite.expires_at}`); console.log(); console.log(chalk.bold('Pairing URL:')); - console.log(created.inviteUrl); + console.log(created.shortInviteUrl); + console.log(dim('Full signed invite is stored on relay and verified by the mobile app.')); console.log(); console.log(dim('Pending agent key material was saved locally with 0600 permissions.')); console.log(dim('The mobile vault must approve the invite before this agent can request DCP actions.')); @@ -462,6 +471,28 @@ function loadAgentForCommand(agentIdOrName?: string) { return config; } +function buildSmokeTransferTx(fromAddress: string, destinationAddress: string, amountSol: number): string { + if (!Number.isFinite(amountSol) || amountSol <= 0) { + throw new Error('--sign-tx-amount must be a positive SOL amount'); + } + + const fromPubkey = new PublicKey(fromAddress); + const toPubkey = new PublicKey(destinationAddress); + const lamports = Math.max(1, Math.round(amountSol * LAMPORTS_PER_SOL)); + const tx = new Transaction({ + feePayer: fromPubkey, + recentBlockhash: Keypair.generate().publicKey.toBase58(), + }).add( + SystemProgram.transfer({ + fromPubkey, + toPubkey, + lamports, + }) + ); + + return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64'); +} + async function smokeCommand(options: SmokeOptions): Promise { const config = loadAgentForCommand(options.agent); const connection = new AgentConnection(config, { forceRelay: options.forceRelay ?? true }); @@ -478,11 +509,16 @@ async function smokeCommand(options: SmokeOptions): Promise { } await connection.connect(); - const address = await connection.getAddress('solana'); - const result: Record = { address }; + const result: Record = {}; - if (!options.json) { - success(`Wallet address: ${address.address}`); + const needsAddress = !options.skipAddress || Boolean(options.signTxAmount); + const address = needsAddress ? await connection.getAddress('solana') : null; + if (address) { + result.address = address; + + if (!options.json) { + success(`Wallet address: ${address.address}`); + } } if (options.signMessage) { @@ -501,6 +537,41 @@ async function smokeCommand(options: SmokeOptions): Promise { } } + if (options.signTxAmount) { + if (!address) { + throw new Error('Wallet address is required to build the smoke transaction'); + } + const amount = parseNumberOption(options.signTxAmount, Number.NaN, '--sign-tx-amount'); + const currency = (options.signTxCurrency || 'SOL').toUpperCase(); + if (currency !== 'SOL') { + throw new Error('--sign-tx-currency currently supports SOL for generated smoke transactions'); + } + const destination = options.signTxDestination || Keypair.generate().publicKey.toBase58(); + const unsignedTx = buildSmokeTransferTx(address.address, destination, amount); + + if (!options.json) { + console.log(dim(`Waiting for mobile approval of vault_sign_tx ${amount} SOL...`)); + console.log(dim(`Destination: ${destination}`)); + } + + const signedTx = await connection.signTx({ + chain: 'solana', + unsignedTx, + amount, + currency, + destination, + description: `DCP mobile smoke test transfer (${amount} SOL)`, + idempotencyKey: `smoke-${Date.now()}`, + }); + result.sign_tx = signedTx; + if (!options.json) { + success(`Transaction signed: ${signedTx.signature}`); + if (signedTx.remaining_daily !== undefined) { + success(`Remaining daily budget: ${signedTx.remaining_daily} ${currency}`); + } + } + } + if (options.writeScope) { if (!options.writeValue) { throw new Error('--write-value is required with --write-scope'); @@ -904,11 +975,12 @@ mobileCommand .option('--relay-url ', 'Mobile relay API/base URL') .option('--scope ', 'Requested scope. Repeat for multiple scopes.', (value, previous: string[] = []) => [...previous, value]) .option('--daily-budget ', 'Requested daily budget', '0') - .option('--currency ', 'Budget currency: USDC or SOL', 'USDC') + .option('--currency ', 'Budget currency: SOL, USDC, or 1LY', 'SOL') .option('--approval-threshold ', 'Auto-approval threshold', '0') .option('--ttl-seconds ', 'Invite lifetime in seconds', '600') .option('--json', 'Print machine-readable JSON') .option('--no-qr', 'Do not print terminal QR') + .option('--large-qr', 'Print a larger terminal QR for difficult scanners') .option('--wait', 'Wait until DCP Mobile approves or denies the pairing') .option('--wait-seconds ', 'How long --wait should poll for approval') .option('--configure-mcp', 'After approval, configure the local MCP client when supported') @@ -924,11 +996,12 @@ mobileCommand .option('--relay-url ', 'Mobile relay API/base URL') .option('--scope ', 'Requested scope. Repeat for multiple scopes.', (value, previous: string[] = []) => [...previous, value]) .option('--daily-budget ', 'Requested daily budget', '0') - .option('--currency ', 'Budget currency: USDC or SOL', 'USDC') + .option('--currency ', 'Budget currency: SOL, USDC, or 1LY', 'SOL') .option('--approval-threshold ', 'Auto-approval threshold', '0') .option('--ttl-seconds ', 'Invite lifetime in seconds', '600') .option('--json', 'Print machine-readable JSON') .option('--no-qr', 'Do not print terminal QR') + .option('--large-qr', 'Print a larger terminal QR for difficult scanners') .option('--wait-seconds ', 'How long to wait for approval') .option('--configure-mcp', 'Configure the local MCP client when supported', true) .option('--no-configure-mcp', 'Do not write MCP client config; print next steps only') @@ -950,11 +1023,15 @@ program .command('smoke') .description('Test a paired agent against the vault/relay without configuring Claude') .option('-a, --agent ', 'Agent ID or name to test (default: first configured)') - .option('--sign-message ', 'Also request a mobile-approved Solana message signature') + .option('--sign-message ', 'Request a Solana message signature for login/auth challenge testing') + .option('--sign-tx-amount ', 'Generate and request signing for a smoke-test SOL transfer amount') + .option('--sign-tx-currency ', 'Currency for --sign-tx-amount (currently SOL)', 'SOL') + .option('--sign-tx-destination
', 'Destination public key for generated smoke-test transfer') .option('--read-scope ', 'Also request a mobile-approved vault_read for a scope, e.g. credentials.api.openai') .option('--write-scope ', 'Also request a mobile-approved vault_write for a scope, e.g. credentials.api.openai') .option('--write-name ', 'Display name to store with --write-scope') .option('--write-value ', 'Secret value to store with --write-scope') + .option('--skip-address', 'Skip the initial wallet address check') .option('--force-relay', 'Force relay mode (default for smoke)', true) .option('-j, --json', 'Output as JSON') .action(smokeCommand); diff --git a/packages/dcp-agent/src/mobile-pairing.ts b/packages/dcp-agent/src/mobile-pairing.ts index c9eac2f..a954131 100644 --- a/packages/dcp-agent/src/mobile-pairing.ts +++ b/packages/dcp-agent/src/mobile-pairing.ts @@ -63,6 +63,7 @@ export interface CreateMobilePairingInviteInput { export interface CreatedMobilePairingInvite { invite: MobilePairingInvite; inviteUrl: string; + shortInviteUrl: string; pendingConfig: MobilePendingConfig; } @@ -72,6 +73,14 @@ function encodeMobilePairingInvite(invite: MobilePairingInvite): string { return `dcp://pair?invite=${encodeURIComponent(JSON.stringify(invite))}`; } +function encodeShortMobilePairingInvite(invite: MobilePairingInvite): string { + const params = new URLSearchParams({ + relay: invite.relay_url, + invite_id: invite.invite_id, + }); + return `dcp://pair?${params.toString()}`; +} + export function canonicalMobileAgentId(client: MobileAgentClient): string { const ids: Record = { 'claude-desktop': 'agent_claude_desktop', @@ -113,7 +122,7 @@ export function createMobilePairingInvite( const requestedAgentId = input.agentId?.trim() || canonicalMobileAgentId(input.client); const requestedBudget = input.requestedBudget ?? { daily: 0, - currency: 'USDC', + currency: 'SOL', approval_threshold: 0, }; @@ -127,7 +136,7 @@ export function createMobilePairingInvite( agent_name: input.agentName.trim(), agent_client: input.client, environment: input.environment, - requested_scopes: input.requestedScopes ?? ['read:wallet.address', 'sign:solana'], + requested_scopes: input.requestedScopes ?? [], requested_budget: requestedBudget, created_at: createdAt.toISOString(), expires_at: expiresAt.toISOString(), @@ -143,10 +152,12 @@ export function createMobilePairingInvite( signature, }; const inviteUrl = encodeMobilePairingInvite(invite); + const shortInviteUrl = encodeShortMobilePairingInvite(invite); return { invite, inviteUrl, + shortInviteUrl, pendingConfig: { invite_id: inviteId, invite_url: inviteUrl, @@ -161,6 +172,19 @@ export function createMobilePairingInvite( }; } +export async function publishMobilePairingInvite(invite: MobilePairingInvite): Promise { + const response = await fetch(`${invite.relay_url.replace(/\/$/, '')}/v1/mobile/pairings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invite }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`Failed to publish mobile pairing invite (${response.status})${text ? `: ${text}` : ''}`); + } +} + export async function getMobilePairingStatus( relayUrl: string, inviteId: string diff --git a/packages/dcp-agent/src/types.ts b/packages/dcp-agent/src/types.ts index 8b5157c..a9f492f 100644 --- a/packages/dcp-agent/src/types.ts +++ b/packages/dcp-agent/src/types.ts @@ -126,7 +126,7 @@ export type MobileDcpScope = export interface MobilePairingBudget { daily: number; - currency: 'SOL' | 'USDC'; + currency: 'SOL' | 'USDC' | '1LY'; approval_threshold: number; } diff --git a/packages/dcp-agent/tests/mobile-pairing.test.ts b/packages/dcp-agent/tests/mobile-pairing.test.ts index 430470f..459e363 100644 --- a/packages/dcp-agent/tests/mobile-pairing.test.ts +++ b/packages/dcp-agent/tests/mobile-pairing.test.ts @@ -45,6 +45,7 @@ describe('mobile pairing', () => { }); expect(invite.requested_agent_id).toBe('agent_custom_test'); + expect(invite.requested_scopes).toEqual([]); const valid = verifySignature( Buffer.from(canonicalInvitePayload(invite), 'utf8'), diff --git a/packages/dcp-relay/src/relay.ts b/packages/dcp-relay/src/relay.ts index 5ff9242..0c07637 100644 --- a/packages/dcp-relay/src/relay.ts +++ b/packages/dcp-relay/src/relay.ts @@ -335,6 +335,43 @@ export class RelayServer { // DCP Mobile Pairing Routes (Agent QR → Mobile approval → Agent polling) // ======================================================================== + this.server.post<{ + Body: { invite?: MobilePairingInvite }; + }>( + '/v1/mobile/pairings', + async (request, reply) => { + const invite = request.body?.invite; + if (!invite || typeof invite !== 'object') { + return reply.status(400).send({ success: false, error: 'Missing pairing invite' }); + } + const inviteError = this.validateMobileInvite(invite); + if (inviteError) { + return reply.status(400).send({ success: false, error: inviteError }); + } + const record = this.mobilePairingStore.registerInvite(invite); + return reply.send({ + success: true, + invite_id: record.invite_id, + status: record.status, + }); + } + ); + + this.server.get<{ Params: { inviteId: string } }>( + '/v1/mobile/pairings/:inviteId/invite', + async (request, reply) => { + const record = this.mobilePairingStore.get(request.params.inviteId); + if (!record || record.status === 'expired') { + return reply.status(404).send({ success: false, error: 'Pairing invite not found' }); + } + return reply.send({ + invite_id: record.invite_id, + invite: record.invite, + status: record.status, + }); + } + ); + this.server.post<{ Params: { inviteId: string }; Body: MobilePairingApprovalRequest; @@ -1391,8 +1428,8 @@ export class RelayServer { if (!body.vault_hpke_public_key || !body.vault_signing_public_key) { return 'Missing vault relay public keys'; } - if (!Array.isArray(body.approved_scopes) || body.approved_scopes.length === 0) { - return 'approved_scopes must be a non-empty array'; + if (!Array.isArray(body.approved_scopes)) { + return 'approved_scopes must be an array'; } if (!body.approved_budget || typeof body.approved_budget.daily !== 'number') { return 'approved_budget is required'; diff --git a/packages/dcp-relay/src/store.ts b/packages/dcp-relay/src/store.ts index 3c1f568..b62f2fc 100644 --- a/packages/dcp-relay/src/store.ts +++ b/packages/dcp-relay/src/store.ts @@ -798,6 +798,24 @@ export class MobilePairingStore { }, 60_000); } + registerInvite(invite: MobilePairingApprovalRequest['invite']): MobilePairingRecord { + const now = Date.now(); + const existing = this.records.get(invite.invite_id); + if (existing && existing.status !== 'expired') { + return existing; + } + + const record: MobilePairingRecord = { + invite_id: invite.invite_id, + invite, + received_at: now, + status: 'pending', + }; + + this.records.set(record.invite_id, record); + return record; + } + approve(request: MobilePairingApprovalRequest): MobilePairingRecord { const now = Date.now(); const existing = this.records.get(request.invite.invite_id); @@ -839,7 +857,7 @@ export class MobilePairingStore { agent_client: 'custom', environment: 'dev', requested_scopes: [], - requested_budget: { daily: 0, currency: 'USDC', approval_threshold: 0 }, + requested_budget: { daily: 0, currency: 'SOL', approval_threshold: 0 }, created_at: new Date(now).toISOString(), expires_at: new Date(now + MOBILE_PAIRING_TTL_MS).toISOString(), nonce: '', diff --git a/packages/dcp-relay/src/types.ts b/packages/dcp-relay/src/types.ts index f0c4dce..d1f1acc 100644 --- a/packages/dcp-relay/src/types.ts +++ b/packages/dcp-relay/src/types.ts @@ -342,7 +342,7 @@ export type MobileDcpScope = export interface MobilePairingBudget { daily: number; - currency: 'SOL' | 'USDC'; + currency: 'SOL' | 'USDC' | '1LY'; approval_threshold: number; } @@ -378,7 +378,7 @@ export interface MobilePairingRecord { invite_id: string; invite: MobilePairingInvite; received_at: number; - status: 'approved' | 'denied' | 'expired'; + status: 'pending' | 'approved' | 'denied' | 'expired'; vault_id?: string; vault_hpke_public_key?: string; vault_signing_public_key?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c23706..98dde7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@noble/curves': specifier: ^1.4.0 version: 1.9.7 + '@solana/web3.js': + specifier: ^1.98.0 + version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) chalk: specifier: ^5.3.0 version: 5.6.2 @@ -4087,7 +4090,7 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)): + isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: ws: 7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -4100,7 +4103,7 @@ snapshots: delay: 5.0.0 es6-promisify: 5.0.0 eyes: 0.1.8 - isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)) + isomorphic-ws: 4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)) json-stringify-safe: 5.0.1 stream-json: 1.9.1 uuid: 8.3.2