From 5c5a8c133af3940b3dc1865aa45a85dbc227da02 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:32:03 +0700 Subject: [PATCH 01/14] fix: use env-driven SOLANA_NETWORK instead of hardcoded devnet (closes #127, closes #128) All agent tools now read SOLANA_NETWORK from env with mainnet-beta default, matching docker-compose.yml and sentinel scanner. Fixes send, deposit, privacy-score, and consolidate tools. --- packages/agent/src/tools/consolidate.ts | 2 +- packages/agent/src/tools/deposit.ts | 3 +- packages/agent/src/tools/privacy-score.ts | 2 +- packages/agent/src/tools/send.ts | 42 +++++++++++++++++++---- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/tools/consolidate.ts b/packages/agent/src/tools/consolidate.ts index a314474..2ad8946 100644 --- a/packages/agent/src/tools/consolidate.ts +++ b/packages/agent/src/tools/consolidate.ts @@ -60,7 +60,7 @@ export async function executeConsolidate( throw new Error('Spending key is required for claiming') } - const network = (process.env.SOLANA_NETWORK ?? 'devnet') as 'devnet' | 'mainnet-beta' + const network = (process.env.SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta' const connection = createConnection(network) // Parse hex keys → Uint8Array (strip optional 0x prefix) diff --git a/packages/agent/src/tools/deposit.ts b/packages/agent/src/tools/deposit.ts index 799da81..eb3795b 100644 --- a/packages/agent/src/tools/deposit.ts +++ b/packages/agent/src/tools/deposit.ts @@ -111,7 +111,8 @@ export async function executeDeposit(params: DepositParams): Promise { } const token = params.token.toUpperCase() - const connection = createConnection('devnet') + const network = (process.env.SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta' + const connection = createConnection(network) // Fetch live fee_bps from on-chain config const config = await getVaultConfig(connection) @@ -145,6 +146,7 @@ export async function executeSend(params: SendParams): Promise { let amountCommitment: Uint8Array let ephemeralPubkey: Uint8Array let viewingKeyHash: Uint8Array + let blinding = '' const isStealthMetaAddress = params.recipient.startsWith('sip:solana:') @@ -174,10 +176,9 @@ export async function executeSend(params: SendParams): Promise { stealthPubkey = new PublicKey(solanaAddress) // Real Pedersen commitment: C = amount*G + blinding*H - const { commitment, blinding } = commit(BigInt(amountBase)) - // TODO: encrypt blinding factor into encryptedAmount for recipient to verify commitment - void blinding - amountCommitment = hexToBytes(commitment) + const commitResult = commit(BigInt(amountBase)) + blinding = commitResult.blinding + amountCommitment = hexToBytes(commitResult.commitment) // Ephemeral pubkey: 32-byte ed25519 -> pad to 33 bytes with 0x00 prefix // On-chain program stores but doesn't validate the curve — opaque bytes for the scanner @@ -203,7 +204,25 @@ export async function executeSend(params: SendParams): Promise { viewingKeyHash = new Uint8Array(32).fill(0) } - const encryptedAmount = new Uint8Array(0) + // Encrypt amount + blinding factor for recipient to verify commitment. + // Layout: [8 bytes amount LE] || [32 bytes blinding] XOR-masked with viewing key hash. + let encryptedAmount: Uint8Array + if (isStealthMetaAddress) { + const amountLeBytes = bigintToLeBytes(BigInt(amountBase)) + const blindingBytes = hexToBytes(blinding) + const payload = new Uint8Array(amountLeBytes.length + blindingBytes.length) + payload.set(amountLeBytes, 0) + payload.set(blindingBytes, amountLeBytes.length) + // Generate 64 bytes of keystream from viewing key hash + const mask1 = sha256(viewingKeyHash) + const mask2 = sha256(mask1) + encryptedAmount = new Uint8Array(payload.length) + for (let i = 0; i < payload.length; i++) { + encryptedAmount[i] = payload[i] ^ (i < 32 ? mask1[i] : mask2[i - 32]) + } + } else { + encryptedAmount = new Uint8Array(0) + } const proof = new Uint8Array(0) // Derive the stealth recipient's associated token account @@ -257,3 +276,14 @@ function hexToBytes(hex: string): Uint8Array { } return bytes } + +/** Convert a bigint to little-endian byte array */ +function bigintToLeBytes(value: bigint, size = 8): Uint8Array { + const buf = new Uint8Array(size) + let v = value + for (let i = 0; i < size; i++) { + buf[i] = Number(v & 0xffn) + v >>= 8n + } + return buf +} From 2f2079f26b5e9918e8e0c77e5e1c271232580d3d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:32:20 +0700 Subject: [PATCH 02/14] fix: require real stealth keys for payment links (closes #130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payment links used randomBytes(32) as dummy spending/viewing keys — funds sent to those addresses were unclaimable. Now requires actual stealth meta-address keys so recipients can scan and claim via their viewing key. --- packages/agent/src/tools/payment-link.ts | 35 ++++++++++++++++++------ 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/tools/payment-link.ts b/packages/agent/src/tools/payment-link.ts index 5590a2e..57bf753 100644 --- a/packages/agent/src/tools/payment-link.ts +++ b/packages/agent/src/tools/payment-link.ts @@ -9,12 +9,16 @@ import { getOrCreateSession, } from '../db.js' +type HexPrefixed = `0x${string}` + // ───────────────────────────────────────────────────────────────────────────── // Payment link tool — one-time stealth receive URLs // ───────────────────────────────────────────────────────────────────────────── export interface PaymentLinkParams { wallet: string + spendingKey: string + viewingKey: string amount?: number token?: string memo?: string @@ -51,7 +55,15 @@ export const paymentLinkTool: Anthropic.Tool = { properties: { wallet: { type: 'string', - description: 'Your wallet address (base58). Used to derive the stealth address keypair.', + description: 'Your wallet address (base58).', + }, + spendingKey: { + type: 'string', + description: 'Your stealth spending public key (0x-prefixed hex). Used to derive the one-time stealth address.', + }, + viewingKey: { + type: 'string', + description: 'Your stealth viewing public key (0x-prefixed hex). Used to derive the one-time stealth address.', }, amount: { type: 'number', @@ -70,7 +82,7 @@ export const paymentLinkTool: Anthropic.Tool = { description: 'Link expiry in minutes (default: 60, max: 10080 = 7 days)', }, }, - required: ['wallet'], + required: ['wallet', 'spendingKey', 'viewingKey'], }, } @@ -81,6 +93,14 @@ export async function executePaymentLink( throw new Error('Wallet address is required to create a payment link') } + if (!params.spendingKey || !params.spendingKey.startsWith('0x')) { + throw new Error('Spending public key is required (0x-prefixed hex)') + } + + if (!params.viewingKey || !params.viewingKey.startsWith('0x')) { + throw new Error('Viewing public key is required (0x-prefixed hex)') + } + if (params.amount !== undefined && params.amount !== null && params.amount < 0) { throw new Error('Payment amount cannot be negative') } @@ -89,13 +109,12 @@ export async function executePaymentLink( const expiresIn = Math.min(Math.max(params.expiresInMinutes ?? 60, 1), 10080) const expiresAt = Date.now() + expiresIn * 60 * 1000 - // Phase 1: ephemeral stealth address from random keys. The recipient claims - // via the payment page flow, not by deriving the stealth private key. - // Phase 2 will use the wallet's actual spending/viewing keypair. - const dummyKey = '0x' + randomBytes(32).toString('hex') as `0x${string}` + // Derive a one-time stealth address from the recipient's real meta-address keys. + // The recipient can scan for this payment using their viewing key and claim + // using their spending key — no dummy keys, no unclaimed funds. const stealth = generateEd25519StealthAddress({ - spendingKey: dummyKey, - viewingKey: dummyKey, + spendingKey: params.spendingKey as HexPrefixed, + viewingKey: params.viewingKey as HexPrefixed, chain: 'solana' as const, }) From 0887982722202b421c94d6491191c8818e4b281d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:32:28 +0700 Subject: [PATCH 03/14] fix: add real on-chain SOL and SPL token balances to vault API (closes #131) Vault GET endpoint now queries Connection.getBalance() for native SOL and getTokenAccountsByOwner() for SPL tokens. Returns real wallet balances alongside activity history. Gracefully handles RPC failures. --- packages/agent/src/routes/vault-api.ts | 78 +++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/routes/vault-api.ts b/packages/agent/src/routes/vault-api.ts index 2ad5731..9af011c 100644 --- a/packages/agent/src/routes/vault-api.ts +++ b/packages/agent/src/routes/vault-api.ts @@ -1,15 +1,87 @@ import { Router, type Request, type Response } from 'express' +import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js' +import { TOKEN_PROGRAM_ID } from '@solana/spl-token' +import { createConnection, WSOL_MINT, USDC_MINT, USDT_MINT } from '@sipher/sdk' import { getActivity } from '../db.js' export const vaultRouter = Router() +// Reverse lookup: mint address → human-readable symbol +const MINT_LABELS: Record = { + [WSOL_MINT.toBase58()]: 'SOL', + [USDC_MINT.toBase58()]: 'USDC', + [USDT_MINT.toBase58()]: 'USDT', +} + +interface TokenBalance { + mint: string + symbol: string + amount: string + decimals: number + uiAmount: number +} + /** * GET /api/vault - * Returns the authenticated wallet's recent activity (last 20 entries). + * Returns the authenticated wallet's on-chain balances and recent activity. * Requires verifyJwt middleware upstream — wallet is attached to req by it. */ -vaultRouter.get('/', (req: Request, res: Response) => { +vaultRouter.get('/', async (req: Request, res: Response) => { const wallet = (req as unknown as Record).wallet as string + const network = (process.env.SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta' + const connection = createConnection(network) + + let solBalance = 0 + const tokens: TokenBalance[] = [] + + try { + const pubkey = new PublicKey(wallet) + + // Fetch native SOL balance + const lamports = await connection.getBalance(pubkey) + solBalance = lamports / LAMPORTS_PER_SOL + + // Fetch SPL token accounts + const tokenAccounts = await connection.getTokenAccountsByOwner(pubkey, { + programId: TOKEN_PROGRAM_ID, + }) + + for (const { account } of tokenAccounts.value) { + // SPL token account data layout: mint(32) + owner(32) + amount(8) + ... + const data = account.data + const mint = new PublicKey(data.subarray(0, 32)) + const mintStr = mint.toBase58() + const rawAmount = data.readBigUInt64LE(64) + + // Skip zero-balance accounts and wrapped SOL (shown as native SOL above) + if (rawAmount === 0n || mint.equals(WSOL_MINT)) continue + + const symbol = MINT_LABELS[mintStr] ?? mintStr.slice(0, 8) + '...' + const decimals = mint.equals(USDC_MINT) || mint.equals(USDT_MINT) ? 6 : 9 + const uiAmount = Number(rawAmount) / 10 ** decimals + + tokens.push({ + mint: mintStr, + symbol, + amount: rawAmount.toString(), + decimals, + uiAmount, + }) + } + } catch (err) { + // RPC failures should not block the entire response — return what we have + console.warn('[vault] balance fetch failed:', err instanceof Error ? err.message : err) + } + const activity = getActivity(wallet, { limit: 20 }) - res.json({ wallet, activity }) + + res.json({ + wallet, + network, + balances: { + sol: solBalance, + tokens, + }, + activity, + }) }) From b68d85b32d203cfcfae4e8aecb22b0e2c98676b0 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:32:35 +0700 Subject: [PATCH 04/14] fix: add frontend env files for production and development (closes #137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VITE_API_URL was undefined — API calls had no base URL. Added .env.production pointing to sipher.sip-protocol.org and .env.development for localhost. Also added VITE_API_URL to vite-env.d.ts type declarations. --- app/.env.development | 2 ++ app/.env.production | 2 ++ app/src/vite-env.d.ts | 1 + 3 files changed, 5 insertions(+) create mode 100644 app/.env.development create mode 100644 app/.env.production diff --git a/app/.env.development b/app/.env.development new file mode 100644 index 0000000..9181822 --- /dev/null +++ b/app/.env.development @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:5006 +VITE_SOLANA_NETWORK=devnet diff --git a/app/.env.production b/app/.env.production new file mode 100644 index 0000000..2a63d51 --- /dev/null +++ b/app/.env.production @@ -0,0 +1,2 @@ +VITE_API_URL=https://sipher.sip-protocol.org +VITE_SOLANA_NETWORK=mainnet-beta diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts index 510c5f0..2797df1 100644 --- a/app/src/vite-env.d.ts +++ b/app/src/vite-env.d.ts @@ -1,6 +1,7 @@ /// interface ImportMetaEnv { + readonly VITE_API_URL?: string readonly VITE_SOLANA_NETWORK?: 'devnet' | 'mainnet-beta' readonly VITE_SOLANA_RPC_URL?: string } From 41e32a783abb32231bce4ec83ce353757a7505b9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:44:31 +0700 Subject: [PATCH 05/14] feat: remove Arcium MPC and Inco FHE mock providers (closes #132, closes #133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both providers were 100% mock — no production SDK exists for REST integration. Arcium MPC requires on-chain MXE interaction, Inco FHE is EVM-only. Deleted 8 files (providers, backends, routes, tests), cleaned 8 more (route registry, OpenAPI spec, backend enums, metering, session, demo). 6 fake endpoints removed. 534 tests remain passing. --- src/app.ts | 10 - src/middleware/metering.ts | 2 - src/openapi/spec.ts | 320 +---------------------------- src/routes/arcium.ts | 137 ------------- src/routes/demo.ts | 2 +- src/routes/inco.ts | 117 ----------- src/routes/index.ts | 4 - src/routes/session.ts | 2 +- src/services/arcium-backend.ts | 69 ------- src/services/arcium-provider.ts | 251 ----------------------- src/services/backend-registry.ts | 4 - src/services/inco-backend.ts | 66 ------ src/services/inco-provider.ts | 218 -------------------- tests/arcium.test.ts | 299 --------------------------- tests/backend-comparison.test.ts | 65 +++--- tests/billing.test.ts | 5 - tests/inco.test.ts | 335 ------------------------------- tests/session.test.ts | 2 +- 18 files changed, 31 insertions(+), 1877 deletions(-) delete mode 100644 src/routes/arcium.ts delete mode 100644 src/routes/inco.ts delete mode 100644 src/services/arcium-backend.ts delete mode 100644 src/services/arcium-provider.ts delete mode 100644 src/services/inco-backend.ts delete mode 100644 src/services/inco-provider.ts delete mode 100644 tests/arcium.test.ts delete mode 100644 tests/inco.test.ts diff --git a/src/app.ts b/src/app.ts index 8ae8c92..cfaaa4f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -152,16 +152,6 @@ app.get('/', (_req, res) => { unwrap: 'POST /v1/cspl/unwrap', transfer: 'POST /v1/cspl/transfer', }, - arcium: { - compute: 'POST /v1/arcium/compute', - status: 'GET /v1/arcium/compute/:id/status', - decrypt: 'POST /v1/arcium/decrypt', - }, - inco: { - encrypt: 'POST /v1/inco/encrypt', - compute: 'POST /v1/inco/compute', - decrypt: 'POST /v1/inco/decrypt', - }, swap: { private: 'POST /v1/swap/private', }, diff --git a/src/middleware/metering.ts b/src/middleware/metering.ts index 4035c3b..31b6e37 100644 --- a/src/middleware/metering.ts +++ b/src/middleware/metering.ts @@ -14,8 +14,6 @@ const PATH_CATEGORIES: [string, OperationCategory][] = [ ['/v1/viewing-key/', 'viewing_key'], ['/v1/proofs/', 'proof'], ['/v1/privacy/', 'privacy'], - ['/v1/arcium/', 'compute'], - ['/v1/inco/', 'compute'], ['/v1/swap/', 'swap'], ['/v1/governance/', 'governance'], ['/v1/compliance/', 'compliance'], diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 235fdf6..1edcf24 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -2456,320 +2456,6 @@ export const openApiSpec = { }, }, }, - // ─── Arcium MPC ────────────────────────────────────────────────────────── - '/v1/arcium/compute': { - post: { - tags: ['Arcium'], - operationId: 'submitArciumComputation', - summary: 'Submit MPC computation', - description: 'Submit an encrypted computation to the Arcium MPC cluster. Returns a computation ID for status polling.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - circuitId: { type: 'string', enum: ['private_transfer', 'check_balance', 'validate_swap'], description: 'Circuit identifier' }, - encryptedInputs: { type: 'array', items: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, minItems: 1, maxItems: 10, description: 'Encrypted inputs as hex strings' }, - chain: { type: 'string', default: 'solana', description: 'Target chain' }, - cipher: { type: 'string', enum: ['aes128', 'aes192', 'aes256', 'rescue'], default: 'aes256' }, - viewingKey: { type: 'object', properties: { key: { type: 'string' }, path: { type: 'string' }, hash: { type: 'string' } } }, - cluster: { type: 'string', description: 'MPC cluster to use' }, - timeout: { type: 'integer', description: 'Timeout in milliseconds' }, - }, - required: ['circuitId', 'encryptedInputs'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Computation submitted successfully', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - computationId: { type: 'string', pattern: '^arc_[0-9a-fA-F]{64}$' }, - status: { type: 'string', enum: ['submitted'] }, - estimatedCompletion: { type: 'integer' }, - circuitId: { type: 'string' }, - chain: { type: 'string' }, - inputCount: { type: 'integer' }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - '/v1/arcium/compute/{id}/status': { - get: { - tags: ['Arcium'], - operationId: 'getArciumComputationStatus', - summary: 'Get computation status', - description: 'Poll the status of an MPC computation. Status progresses: submitted → encrypting → processing → finalizing → completed.', - parameters: [ - { name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^arc_[0-9a-fA-F]{64}$' }, description: 'Computation ID' }, - ], - responses: { - 200: { - description: 'Computation status', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - computationId: { type: 'string' }, - circuitId: { type: 'string' }, - chain: { type: 'string' }, - status: { type: 'string', enum: ['submitted', 'encrypting', 'processing', 'finalizing', 'completed'] }, - progress: { type: 'integer', minimum: 0, maximum: 100 }, - output: { type: 'string', description: 'Only present when status is completed' }, - proof: { type: 'string', description: 'Only present when status is completed' }, - }, - }, - }, - }, - }, - }, - }, - 404: { description: 'Computation not found', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - '/v1/arcium/decrypt': { - post: { - tags: ['Arcium'], - operationId: 'decryptArciumResult', - summary: 'Decrypt computation result', - description: 'Decrypt the output of a completed MPC computation using a viewing key.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - computationId: { type: 'string', pattern: '^arc_[0-9a-fA-F]{64}$' }, - viewingKey: { - type: 'object', - properties: { - key: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - path: { type: 'string' }, - hash: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - }, - required: ['key', 'path', 'hash'], - }, - }, - required: ['computationId', 'viewingKey'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Decryption result', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - computationId: { type: 'string' }, - circuitId: { type: 'string' }, - decryptedOutput: { type: 'string' }, - verificationHash: { type: 'string' }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Decrypt failed or computation incomplete', content: { 'application/json': { schema: errorResponse } } }, - 404: { description: 'Computation not found', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - // ─── Inco FHE ────────────────────────────────────────────────────────── - '/v1/inco/encrypt': { - post: { - tags: ['Inco'], - operationId: 'encryptIncoValue', - summary: 'Encrypt value with FHE', - description: 'Encrypt a plaintext value using Fully Homomorphic Encryption (FHEW or TFHE scheme). Returns ciphertext and noise budget.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - plaintext: { oneOf: [{ type: 'number' }, { type: 'string' }], description: 'Value to encrypt' }, - scheme: { type: 'string', enum: ['fhew', 'tfhe'], description: 'FHE scheme to use' }, - label: { type: 'string', description: 'Optional label for the encryption' }, - }, - required: ['plaintext', 'scheme'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Value encrypted successfully', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - encryptionId: { type: 'string', pattern: '^inc_[0-9a-fA-F]{64}$' }, - ciphertext: { type: 'string', pattern: '^0x[0-9a-fA-F]{64}$' }, - scheme: { type: 'string', enum: ['fhew', 'tfhe'] }, - noiseBudget: { type: 'integer' }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - '/v1/inco/compute': { - post: { - tags: ['Inco'], - operationId: 'computeIncoCiphertexts', - summary: 'Compute on encrypted data', - description: 'Perform a homomorphic operation on FHE ciphertexts. Operations complete synchronously. Tracks noise budget consumption.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - operation: { type: 'string', enum: ['add', 'mul', 'not', 'compare_eq', 'compare_lt'], description: 'Homomorphic operation' }, - ciphertexts: { type: 'array', items: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, minItems: 1, maxItems: 3, description: 'Ciphertexts to operate on' }, - scheme: { type: 'string', enum: ['fhew', 'tfhe'], default: 'tfhe' }, - }, - required: ['operation', 'ciphertexts'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Computation completed', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - computationId: { type: 'string', pattern: '^inc_[0-9a-fA-F]{64}$' }, - operation: { type: 'string' }, - scheme: { type: 'string' }, - operandCount: { type: 'integer' }, - resultCiphertext: { type: 'string' }, - noiseBudgetRemaining: { type: 'integer' }, - status: { type: 'string', enum: ['completed'] }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - '/v1/inco/decrypt': { - post: { - tags: ['Inco'], - operationId: 'decryptIncoResult', - summary: 'Decrypt FHE computation result', - description: 'Decrypt the output of a completed FHE computation. Returns the plaintext result.', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - computationId: { type: 'string', pattern: '^inc_[0-9a-fA-F]{64}$', description: 'Computation ID to decrypt' }, - }, - required: ['computationId'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Decryption result', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - computationId: { type: 'string' }, - operation: { type: 'string' }, - decryptedOutput: { type: 'string' }, - verificationHash: { type: 'string' }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Decrypt failed or invalid computation ID', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - // ─── Private Swap ────────────────────────────────────────────────────── '/v1/swap/private': { @@ -3091,7 +2777,7 @@ export const openApiSpec = { chain: { type: 'string', enum: ['solana', 'ethereum', 'polygon', 'arbitrum', 'optimism', 'base', 'near', 'aptos', 'sui', 'cosmos', 'osmosis', 'injective', 'celestia', 'sei', 'dydx', 'bitcoin', 'zcash'] }, privacyLevel: { type: 'string', enum: ['standard', 'shielded', 'maximum'] }, rpcProvider: { type: 'string', enum: ['helius', 'quicknode', 'triton', 'generic'] }, - backend: { type: 'string', enum: ['sip-native', 'arcium', 'inco'] }, + backend: { type: 'string', enum: ['sip-native'] }, defaultViewingKey: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, }, }, @@ -3202,7 +2888,7 @@ export const openApiSpec = { chain: { type: 'string', enum: ['solana', 'ethereum', 'polygon', 'arbitrum', 'optimism', 'base', 'near', 'aptos', 'sui', 'cosmos', 'osmosis', 'injective', 'celestia', 'sei', 'dydx', 'bitcoin', 'zcash'] }, privacyLevel: { type: 'string', enum: ['standard', 'shielded', 'maximum'] }, rpcProvider: { type: 'string', enum: ['helius', 'quicknode', 'triton', 'generic'] }, - backend: { type: 'string', enum: ['sip-native', 'arcium', 'inco'] }, + backend: { type: 'string', enum: ['sip-native'] }, defaultViewingKey: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, }, }, @@ -3883,8 +3569,6 @@ export const openApiSpec = { { name: 'Backends', description: 'Privacy backend registry, health monitoring, and selection' }, { name: 'Proofs', description: 'ZK proof generation and verification (funding, validity, fulfillment, range)' }, { name: 'C-SPL', description: 'Confidential SPL token operations (wrap, unwrap, transfer)' }, - { name: 'Arcium', description: 'Arcium MPC compute backend — submit computations, poll status, decrypt results' }, - { name: 'Inco', description: 'Inco FHE compute backend — encrypt values, compute on ciphertexts, decrypt results' }, { name: 'Swap', description: 'Privacy-preserving token swaps via Jupiter DEX with stealth address routing' }, { name: 'Sessions', description: 'Agent session management — configure default parameters (chain, backend, privacy level) applied to all requests' }, { name: 'Compliance', description: 'Enterprise compliance endpoints — selective disclosure, audit reports, and auditor verification' }, diff --git a/src/routes/arcium.ts b/src/routes/arcium.ts deleted file mode 100644 index 3934f2e..0000000 --- a/src/routes/arcium.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Router, Request, Response, NextFunction } from 'express' -import { z } from 'zod' -import { validateRequest } from '../middleware/validation.js' -import { idempotency } from '../middleware/idempotency.js' -import { betaEndpoint, getBetaWarning } from '../middleware/beta.js' -import { - submitComputation, - getComputationStatus, - decryptResult, - getSupportedCircuits, -} from '../services/arcium-provider.js' - -const arciumBeta = betaEndpoint('Arcium MPC uses a mock provider. Real Arcium cluster integration coming soon.') - -const router = Router() - -// ─── Schemas ──────────────────────────────────────────────────────────────── - -const hexString = z.string().regex(/^0x[0-9a-fA-F]+$/, '0x-prefixed hex string') -const computationIdPattern = z.string().regex(/^arc_[0-9a-fA-F]{64}$/, 'Arcium computation ID (arc_ + 64 hex chars)') - -const computeSchema = z.object({ - circuitId: z.enum(['private_transfer', 'check_balance', 'validate_swap']), - encryptedInputs: z.array(hexString).min(1).max(10), - chain: z.string().default('solana'), - cipher: z.enum(['aes128', 'aes192', 'aes256', 'rescue']).default('aes256'), - viewingKey: z.object({ - key: hexString, - path: z.string(), - hash: hexString, - }).optional(), - cluster: z.string().optional(), - timeout: z.number().int().positive().optional(), -}) - -const decryptSchema = z.object({ - computationId: computationIdPattern, - viewingKey: z.object({ - key: hexString, - path: z.string(), - hash: hexString, - }), -}) - -// ─── POST /arcium/compute ─────────────────────────────────────────────────── - -router.post( - '/arcium/compute', - arciumBeta, - idempotency, - validateRequest({ body: computeSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { circuitId, encryptedInputs, chain, cipher, viewingKey, cluster, timeout } = req.body - - const result = submitComputation({ - circuitId, - encryptedInputs, - chain, - cipher, - cluster, - viewingKeyHash: viewingKey?.hash, - timeout, - }) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: { - ...result, - supportedCircuits: getSupportedCircuits(), - }, - }) - } catch (err) { - next(err) - } - } -) - -// ─── GET /arcium/compute/:id/status ───────────────────────────────────────── - -router.get( - '/arcium/compute/:id/status', - arciumBeta, - async (req: Request, res: Response, next: NextFunction) => { - try { - const id = req.params.id as string - const result = getComputationStatus(id) - - if (!result) { - res.status(404).json({ - success: false, - error: { - code: 'ARCIUM_COMPUTATION_NOT_FOUND', - message: `Computation not found: ${id}`, - }, - }) - return - } - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: result, - }) - } catch (err) { - next(err) - } - } -) - -// ─── POST /arcium/decrypt ─────────────────────────────────────────────────── - -router.post( - '/arcium/decrypt', - arciumBeta, - validateRequest({ body: decryptSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { computationId, viewingKey } = req.body - const result = decryptResult(computationId, viewingKey.hash) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: result, - }) - } catch (err) { - next(err) - } - } -) - -export default router diff --git a/src/routes/demo.ts b/src/routes/demo.ts index 0ff5058..cf61a00 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -563,7 +563,7 @@ async function runDemo(): Promise { name: 'Backend Listing + Comparison', category: 'backends', durationMs: s20.durationMs, - passed: s20.result.backendNames.length >= 2 && !!s20.result.comparison.recommendation, + passed: s20.result.backendNames.length >= 1 && !!s20.result.comparison.recommendation, crypto: 'Multi-backend scoring engine', result: { backendsAvailable: s20.result.backendNames, diff --git a/src/routes/inco.ts b/src/routes/inco.ts deleted file mode 100644 index ce99b09..0000000 --- a/src/routes/inco.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Router, Request, Response, NextFunction } from 'express' -import { z } from 'zod' -import { validateRequest } from '../middleware/validation.js' -import { idempotency } from '../middleware/idempotency.js' -import { betaEndpoint, getBetaWarning } from '../middleware/beta.js' -import { - encryptValue, - computeOnCiphertexts, - decryptResult, - getSupportedSchemes, - getSupportedOperations, -} from '../services/inco-provider.js' - -const incoBeta = betaEndpoint('Inco FHE uses a mock provider. Real Inco network integration coming soon.') - -const router = Router() - -// ─── Schemas ──────────────────────────────────────────────────────────────── - -const hexString = z.string().regex(/^0x[0-9a-fA-F]+$/, '0x-prefixed hex string') -const computationIdPattern = z.string().regex(/^inc_[0-9a-fA-F]{64}$/, 'Inco computation ID (inc_ + 64 hex chars)') - -const encryptSchema = z.object({ - plaintext: z.union([z.number(), z.string()]), - scheme: z.enum(['fhew', 'tfhe']), - label: z.string().optional(), -}) - -const computeSchema = z.object({ - operation: z.enum(['add', 'mul', 'not', 'compare_eq', 'compare_lt']), - ciphertexts: z.array(hexString).min(1).max(3), - scheme: z.enum(['fhew', 'tfhe']).default('tfhe'), -}) - -const decryptSchema = z.object({ - computationId: computationIdPattern, -}) - -// ─── POST /inco/encrypt ───────────────────────────────────────────────────── - -router.post( - '/inco/encrypt', - incoBeta, - validateRequest({ body: encryptSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { plaintext, scheme, label } = req.body - - const result = encryptValue({ plaintext, scheme, label }) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: { - ...result, - supportedSchemes: getSupportedSchemes(), - }, - }) - } catch (err) { - next(err) - } - } -) - -// ─── POST /inco/compute ───────────────────────────────────────────────────── - -router.post( - '/inco/compute', - incoBeta, - idempotency, - validateRequest({ body: computeSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { operation, ciphertexts, scheme } = req.body - - const result = computeOnCiphertexts({ operation, ciphertexts, scheme }) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: { - ...result, - supportedOperations: getSupportedOperations(), - }, - }) - } catch (err) { - next(err) - } - } -) - -// ─── POST /inco/decrypt ───────────────────────────────────────────────────── - -router.post( - '/inco/decrypt', - incoBeta, - validateRequest({ body: decryptSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { computationId } = req.body - const result = decryptResult(computationId) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: result, - }) - } catch (err) { - next(err) - } - } -) - -export default router diff --git a/src/routes/index.ts b/src/routes/index.ts index 1bbaf21..6583cf8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -13,8 +13,6 @@ import proofsRouter from './proofs.js' import rangeProofRouter from './range-proof.js' import csplRouter from './cspl.js' import privateTransferRouter from './private-transfer.js' -import arciumRouter from './arcium.js' -import incoRouter from './inco.js' import privateSwapRouter from './private-swap.js' import sessionRouter from './session.js' import complianceRouter from './compliance.js' @@ -41,8 +39,6 @@ router.use(backendsRouter) router.use(proofsRouter) router.use(rangeProofRouter) router.use(csplRouter) -router.use(arciumRouter) -router.use(incoRouter) router.use(privateSwapRouter) router.use(sessionRouter) router.use(complianceRouter) diff --git a/src/routes/session.ts b/src/routes/session.ts index 4ac7cad..6110b70 100644 --- a/src/routes/session.ts +++ b/src/routes/session.ts @@ -24,7 +24,7 @@ const VALID_CHAINS = [ const VALID_PRIVACY_LEVELS = ['standard', 'shielded', 'maximum'] const VALID_RPC_PROVIDERS = ['helius', 'quicknode', 'triton', 'generic'] -const VALID_BACKENDS = ['sip-native', 'arcium', 'inco'] +const VALID_BACKENDS = ['sip-native'] // ─── Schemas ──────────────────────────────────────────────────────────────── diff --git a/src/services/arcium-backend.ts b/src/services/arcium-backend.ts deleted file mode 100644 index 070d0ca..0000000 --- a/src/services/arcium-backend.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { PrivacyBackend } from '@sip-protocol/sdk' -import { submitComputation, type ComputationStatus } from './arcium-provider.js' - -export class ArciumMockBackend implements PrivacyBackend { - readonly version = 2 as const - readonly name = 'arcium-mpc' - readonly type = 'compute' as const - readonly chains = ['solana'] - - async checkAvailability() { - return { - available: true, - estimatedCost: 5000n, - estimatedTime: 4000, - } - } - - getCapabilities() { - return { - hiddenAmount: false, - hiddenSender: false, - hiddenRecipient: false, - hiddenCompute: true, - complianceSupport: true, - setupRequired: true, - latencyEstimate: 'medium' as const, - supportedTokens: 'native' as const, - } - } - - async execute(): Promise { - throw new Error('Arcium is a compute backend. Use executeComputation() instead.') - } - - async executeComputation(params: { - circuitId: string - encryptedInputs: Uint8Array[] - chain?: string - cipher?: string - cluster?: string - }) { - const result = submitComputation({ - circuitId: params.circuitId, - encryptedInputs: Array.from(params.encryptedInputs).map( - (buf) => '0x' + Buffer.from(buf).toString('hex') - ), - chain: params.chain, - cipher: params.cipher, - cluster: params.cluster, - }) - - return { - success: true, - computationId: result.computationId, - backend: this.name, - status: result.status as ComputationStatus, - metadata: { - circuitId: result.circuitId, - chain: result.chain, - inputCount: result.inputCount, - estimatedCompletion: result.estimatedCompletion, - }, - } - } - - async estimateCost(): Promise { - return 5000n - } -} diff --git a/src/services/arcium-provider.ts b/src/services/arcium-provider.ts deleted file mode 100644 index 4f13297..0000000 --- a/src/services/arcium-provider.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { keccak_256 } from '@noble/hashes/sha3' -import { bytesToHex } from '@noble/hashes/utils' -import { LRUCache } from 'lru-cache' -import { CACHE_MAX_DEFAULT, ONE_HOUR_MS } from '../constants.js' - -// ─── Constants ────────────────────────────────────────────────────────────── - -const DOMAIN_TAG = new TextEncoder().encode('SIPHER-ARCIUM-MPC') -let computeCounter = 0 - -export const SUPPORTED_CIRCUITS: Record = { - private_transfer: { inputs: 2, description: 'Private token transfer with hidden sender/receiver' }, - check_balance: { inputs: 1, description: 'Verify encrypted balance meets threshold' }, - validate_swap: { inputs: 3, description: 'Validate atomic swap parameters privately' }, -} - -// ─── Types ────────────────────────────────────────────────────────────────── - -export type ComputationStatus = 'submitted' | 'encrypting' | 'processing' | 'finalizing' | 'completed' - -export interface ComputationEntry { - id: string - circuitId: string - chain: string - submittedAt: number - precomputedOutput: string // hex - precomputedProof: string // hex - inputCount: number - cluster?: string - cipher: string - viewingKeyHash?: string -} - -export interface SubmitParams { - circuitId: string - encryptedInputs: string[] // hex strings - chain?: string - cipher?: string - cluster?: string - viewingKeyHash?: string - timeout?: number -} - -export interface ComputationStatusResult { - computationId: string - circuitId: string - chain: string - status: ComputationStatus - progress: number - submittedAt: number - estimatedCompletion: number - output?: string - proof?: string - cluster?: string - cipher: string -} - -export interface DecryptResult { - computationId: string - circuitId: string - decryptedOutput: string - verificationHash: string -} - -// ─── Cache ────────────────────────────────────────────────────────────────── - -const computationCache = new LRUCache({ - max: CACHE_MAX_DEFAULT, - ttl: ONE_HOUR_MS, -}) - -// ─── State Machine Thresholds (ms) ───────────────────────────────────────── - -const STATUS_THRESHOLDS = { - submitted: 0, - encrypting: 500, - processing: 1500, - finalizing: 3000, - completed: 4000, -} - -function getStatus(elapsed: number): { status: ComputationStatus; progress: number } { - if (elapsed >= STATUS_THRESHOLDS.completed) return { status: 'completed', progress: 100 } - if (elapsed >= STATUS_THRESHOLDS.finalizing) return { status: 'finalizing', progress: 75 } - if (elapsed >= STATUS_THRESHOLDS.processing) return { status: 'processing', progress: 50 } - if (elapsed >= STATUS_THRESHOLDS.encrypting) return { status: 'encrypting', progress: 25 } - return { status: 'submitted', progress: 0 } -} - -// ─── Submit Computation ───────────────────────────────────────────────────── - -export function submitComputation(params: SubmitParams): { - computationId: string - status: ComputationStatus - estimatedCompletion: number - circuitId: string - chain: string - inputCount: number -} { - const { circuitId, encryptedInputs, chain = 'solana', cipher = 'aes256', cluster, viewingKeyHash } = params - - // Validate circuit - const circuit = SUPPORTED_CIRCUITS[circuitId] - if (!circuit) { - const err = new Error(`Unsupported circuit: ${circuitId}. Supported: ${Object.keys(SUPPORTED_CIRCUITS).join(', ')}`) - err.name = 'ArciumComputationError' - throw err - } - - // Validate input count - if (encryptedInputs.length !== circuit.inputs) { - const err = new Error(`Circuit '${circuitId}' requires exactly ${circuit.inputs} inputs, got ${encryptedInputs.length}`) - err.name = 'ArciumComputationError' - throw err - } - - // Generate deterministic computation ID - const now = Date.now() - const nonce = `${now}-${++computeCounter}` - const idInput = new Uint8Array(DOMAIN_TAG.length + new TextEncoder().encode(circuitId + encryptedInputs.join('') + nonce).length) - idInput.set(DOMAIN_TAG) - idInput.set(new TextEncoder().encode(circuitId + encryptedInputs.join('') + nonce), DOMAIN_TAG.length) - const computationId = 'arc_' + bytesToHex(keccak_256(idInput)) - - // Pre-compute deterministic output - const outputInput = new Uint8Array(DOMAIN_TAG.length + new TextEncoder().encode('OUTPUT' + computationId).length) - outputInput.set(DOMAIN_TAG) - outputInput.set(new TextEncoder().encode('OUTPUT' + computationId), DOMAIN_TAG.length) - const precomputedOutput = '0x' + bytesToHex(keccak_256(outputInput)) - - // Pre-compute deterministic proof - const proofInput = new Uint8Array(DOMAIN_TAG.length + new TextEncoder().encode('PROOF' + computationId).length) - proofInput.set(DOMAIN_TAG) - proofInput.set(new TextEncoder().encode('PROOF' + computationId), DOMAIN_TAG.length) - const precomputedProof = '0x' + bytesToHex(keccak_256(proofInput)) - - // Store in cache - const entry: ComputationEntry = { - id: computationId, - circuitId, - chain, - submittedAt: now, - precomputedOutput, - precomputedProof, - inputCount: encryptedInputs.length, - cluster, - cipher, - viewingKeyHash, - } - computationCache.set(computationId, entry) - - return { - computationId, - status: 'submitted', - estimatedCompletion: now + STATUS_THRESHOLDS.completed, - circuitId, - chain, - inputCount: encryptedInputs.length, - } -} - -// ─── Get Computation Status ───────────────────────────────────────────────── - -export function getComputationStatus(id: string): ComputationStatusResult | null { - const entry = computationCache.get(id) - if (!entry) return null - - const elapsed = Date.now() - entry.submittedAt - const { status, progress } = getStatus(elapsed) - - const result: ComputationStatusResult = { - computationId: entry.id, - circuitId: entry.circuitId, - chain: entry.chain, - status, - progress, - submittedAt: entry.submittedAt, - estimatedCompletion: entry.submittedAt + STATUS_THRESHOLDS.completed, - cluster: entry.cluster, - cipher: entry.cipher, - } - - // Only reveal output/proof when completed - if (status === 'completed') { - result.output = entry.precomputedOutput - result.proof = entry.precomputedProof - } - - return result -} - -// ─── Decrypt Result ───────────────────────────────────────────────────────── - -export function decryptResult(id: string, viewingKeyHash: string): DecryptResult { - const entry = computationCache.get(id) - if (!entry) { - const err = new Error(`Computation not found: ${id}`) - err.name = 'ArciumNotFoundError' - throw err - } - - const elapsed = Date.now() - entry.submittedAt - const { status } = getStatus(elapsed) - if (status !== 'completed') { - const err = new Error(`Computation not yet completed. Current status: ${status}`) - err.name = 'ArciumDecryptError' - throw err - } - - // Generate deterministic decrypted output - const decryptInput = new Uint8Array( - DOMAIN_TAG.length + new TextEncoder().encode('DECRYPT' + id + viewingKeyHash).length - ) - decryptInput.set(DOMAIN_TAG) - decryptInput.set(new TextEncoder().encode('DECRYPT' + id + viewingKeyHash), DOMAIN_TAG.length) - const decryptedOutput = '0x' + bytesToHex(keccak_256(decryptInput)) - - // Verification hash - const verifyInput = new Uint8Array( - DOMAIN_TAG.length + new TextEncoder().encode('VERIFY' + id + decryptedOutput).length - ) - verifyInput.set(DOMAIN_TAG) - verifyInput.set(new TextEncoder().encode('VERIFY' + id + decryptedOutput), DOMAIN_TAG.length) - const verificationHash = '0x' + bytesToHex(keccak_256(verifyInput)) - - return { - computationId: id, - circuitId: entry.circuitId, - decryptedOutput, - verificationHash, - } -} - -// ─── Utility ──────────────────────────────────────────────────────────────── - -export function getSupportedCircuits(): typeof SUPPORTED_CIRCUITS { - return SUPPORTED_CIRCUITS -} - -export function resetArciumProvider(): void { - computationCache.clear() -} - -/** Test helper: override submittedAt to control state machine */ -export function _setComputationTimestamp(id: string, ts: number): void { - const entry = computationCache.get(id) - if (entry) { - entry.submittedAt = ts - computationCache.set(id, entry) - } -} diff --git a/src/services/backend-registry.ts b/src/services/backend-registry.ts index 917ee4c..da3970c 100644 --- a/src/services/backend-registry.ts +++ b/src/services/backend-registry.ts @@ -1,6 +1,4 @@ import { PrivacyBackendRegistry, SIPNativeBackend } from '@sip-protocol/sdk' -import { ArciumMockBackend } from './arcium-backend.js' -import { IncoFHEBackend } from './inco-backend.js' let registry: PrivacyBackendRegistry | null = null @@ -8,8 +6,6 @@ export function getBackendRegistry(): PrivacyBackendRegistry { if (!registry) { registry = new PrivacyBackendRegistry({ enableHealthTracking: true }) registry.register(new SIPNativeBackend(), { priority: 100, enabled: true }) - registry.register(new ArciumMockBackend(), { priority: 50, enabled: true }) - registry.register(new IncoFHEBackend(), { priority: 45, enabled: true }) } return registry } diff --git a/src/services/inco-backend.ts b/src/services/inco-backend.ts deleted file mode 100644 index cbb98e0..0000000 --- a/src/services/inco-backend.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { PrivacyBackend } from '@sip-protocol/sdk' -import { computeOnCiphertexts } from './inco-provider.js' - -export class IncoFHEBackend implements PrivacyBackend { - readonly version = 2 as const - readonly name = 'inco-fhe' - readonly type = 'compute' as const - readonly chains = ['solana'] - - async checkAvailability() { - return { - available: true, - estimatedCost: 3000n, - estimatedTime: 2000, - } - } - - getCapabilities() { - return { - hiddenAmount: false, - hiddenSender: false, - hiddenRecipient: false, - hiddenCompute: true, - complianceSupport: false, - setupRequired: true, - latencyEstimate: 'medium' as const, - supportedTokens: 'native' as const, - } - } - - async execute(): Promise { - throw new Error('Inco is a compute backend. Use executeComputation() instead.') - } - - async executeComputation(params: { - circuitId: string - encryptedInputs: Uint8Array[] - chain?: string - options?: Record - }) { - const result = computeOnCiphertexts({ - operation: params.circuitId, - ciphertexts: Array.from(params.encryptedInputs).map( - (buf) => '0x' + Buffer.from(buf).toString('hex') - ), - scheme: (params.options?.scheme as string) ?? 'tfhe', - }) - - return { - success: true, - computationId: result.computationId, - backend: this.name, - status: result.status as 'completed', - metadata: { - operation: result.operation, - scheme: result.scheme, - operandCount: result.operandCount, - noiseBudgetRemaining: result.noiseBudgetRemaining, - }, - } - } - - async estimateCost(): Promise { - return 3000n - } -} diff --git a/src/services/inco-provider.ts b/src/services/inco-provider.ts deleted file mode 100644 index 1786ab1..0000000 --- a/src/services/inco-provider.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { keccak_256 } from '@noble/hashes/sha3' -import { bytesToHex } from '@noble/hashes/utils' -import { LRUCache } from 'lru-cache' -import { CACHE_MAX_DEFAULT, ONE_HOUR_MS } from '../constants.js' - -// ─── Constants ────────────────────────────────────────────────────────────── - -const DOMAIN_TAG = new TextEncoder().encode('SIPHER-INCO-FHE') - -export const SUPPORTED_SCHEMES: Record = { - fhew: { keySize: 2048, description: 'FHEW scheme — fast boolean/small-integer operations' }, - tfhe: { keySize: 4096, description: 'TFHE scheme — general-purpose fully homomorphic encryption' }, -} - -export const SUPPORTED_OPERATIONS: Record = { - add: { operands: 2, noiseCost: 5, description: 'Homomorphic addition of two ciphertexts' }, - mul: { operands: 2, noiseCost: 15, description: 'Homomorphic multiplication of two ciphertexts' }, - not: { operands: 1, noiseCost: 3, description: 'Homomorphic bitwise NOT of a ciphertext' }, - compare_eq: { operands: 2, noiseCost: 8, description: 'Homomorphic equality comparison' }, - compare_lt: { operands: 2, noiseCost: 8, description: 'Homomorphic less-than comparison' }, -} - -const INITIAL_NOISE_BUDGET = 100 - -// ─── Types ────────────────────────────────────────────────────────────────── - -export interface EncryptionEntry { - id: string - scheme: string - ciphertext: string // hex - noiseBudget: number - label?: string - createdAt: number -} - -export interface ComputationEntry { - id: string - operation: string - scheme: string - operandCount: number - resultCiphertext: string // hex - noiseBudgetRemaining: number - createdAt: number -} - -export interface EncryptParams { - plaintext: number | string - scheme: string - label?: string -} - -export interface ComputeParams { - operation: string - ciphertexts: string[] // hex strings - scheme?: string -} - -export interface EncryptResult { - encryptionId: string - ciphertext: string - scheme: string - noiseBudget: number - label?: string -} - -export interface ComputeResult { - computationId: string - operation: string - scheme: string - operandCount: number - resultCiphertext: string - noiseBudgetRemaining: number - status: 'completed' -} - -export interface DecryptResult { - computationId: string - operation: string - decryptedOutput: string - verificationHash: string -} - -// ─── Caches ───────────────────────────────────────────────────────────────── - -const encryptionCache = new LRUCache({ - max: CACHE_MAX_DEFAULT, - ttl: ONE_HOUR_MS, -}) - -const computationCache = new LRUCache({ - max: CACHE_MAX_DEFAULT, - ttl: ONE_HOUR_MS, -}) - -// ─── Helper ───────────────────────────────────────────────────────────────── - -function hashWithDomain(suffix: string): string { - const suffixBytes = new TextEncoder().encode(suffix) - const input = new Uint8Array(DOMAIN_TAG.length + suffixBytes.length) - input.set(DOMAIN_TAG) - input.set(suffixBytes, DOMAIN_TAG.length) - return bytesToHex(keccak_256(input)) -} - -// ─── Encrypt Value ────────────────────────────────────────────────────────── - -export function encryptValue(params: EncryptParams): EncryptResult { - const { plaintext, scheme, label } = params - - if (!SUPPORTED_SCHEMES[scheme]) { - const err = new Error(`Unsupported scheme: ${scheme}. Supported: ${Object.keys(SUPPORTED_SCHEMES).join(', ')}`) - err.name = 'IncoEncryptionError' - throw err - } - - const now = Date.now() - const encryptionId = 'inc_' + hashWithDomain(String(plaintext) + scheme + now.toString()) - const ciphertext = '0x' + hashWithDomain('CIPHERTEXT' + encryptionId) - - const entry: EncryptionEntry = { - id: encryptionId, - scheme, - ciphertext, - noiseBudget: INITIAL_NOISE_BUDGET, - label, - createdAt: now, - } - encryptionCache.set(encryptionId, entry) - - return { - encryptionId, - ciphertext, - scheme, - noiseBudget: INITIAL_NOISE_BUDGET, - ...(label ? { label } : {}), - } -} - -// ─── Compute on Ciphertexts ───────────────────────────────────────────────── - -export function computeOnCiphertexts(params: ComputeParams): ComputeResult { - const { operation, ciphertexts, scheme = 'tfhe' } = params - - const op = SUPPORTED_OPERATIONS[operation] - if (!op) { - const err = new Error(`Unsupported operation: ${operation}. Supported: ${Object.keys(SUPPORTED_OPERATIONS).join(', ')}`) - err.name = 'IncoComputationError' - throw err - } - - if (ciphertexts.length !== op.operands) { - const err = new Error(`Operation '${operation}' requires exactly ${op.operands} operand(s), got ${ciphertexts.length}`) - err.name = 'IncoComputationError' - throw err - } - - const now = Date.now() - const computationId = 'inc_' + hashWithDomain(operation + ciphertexts.join('') + now.toString()) - const resultCiphertext = '0x' + hashWithDomain('RESULT' + computationId) - const noiseBudgetRemaining = INITIAL_NOISE_BUDGET - op.noiseCost - - const entry: ComputationEntry = { - id: computationId, - operation, - scheme, - operandCount: ciphertexts.length, - resultCiphertext, - noiseBudgetRemaining, - createdAt: now, - } - computationCache.set(computationId, entry) - - return { - computationId, - operation, - scheme, - operandCount: ciphertexts.length, - resultCiphertext, - noiseBudgetRemaining, - status: 'completed', - } -} - -// ─── Decrypt Result ───────────────────────────────────────────────────────── - -export function decryptResult(computationId: string): DecryptResult { - const entry = computationCache.get(computationId) - if (!entry) { - const err = new Error(`Computation not found: ${computationId}`) - err.name = 'IncoNotFoundError' - throw err - } - - const decryptedOutput = '0x' + hashWithDomain('DECRYPT' + computationId) - const verificationHash = '0x' + hashWithDomain('VERIFY' + computationId + decryptedOutput) - - return { - computationId, - operation: entry.operation, - decryptedOutput, - verificationHash, - } -} - -// ─── Utility ──────────────────────────────────────────────────────────────── - -export function getSupportedSchemes(): typeof SUPPORTED_SCHEMES { - return SUPPORTED_SCHEMES -} - -export function getSupportedOperations(): typeof SUPPORTED_OPERATIONS { - return SUPPORTED_OPERATIONS -} - -export function resetIncoProvider(): void { - encryptionCache.clear() - computationCache.clear() -} diff --git a/tests/arcium.test.ts b/tests/arcium.test.ts deleted file mode 100644 index afaeefd..0000000 --- a/tests/arcium.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import request from 'supertest' -import { resetArciumProvider, _setComputationTimestamp } from '../src/services/arcium-provider.js' -import { resetBackendRegistry } from '../src/services/backend-registry.js' - -vi.mock('@solana/web3.js', async () => { - const actual = await vi.importActual('@solana/web3.js') - return { - ...actual as object, - Connection: vi.fn().mockImplementation(() => ({ - getSlot: vi.fn().mockResolvedValue(300000000), - rpcEndpoint: 'https://api.mainnet-beta.solana.com', - })), - } -}) - -const { default: app } = await import('../src/server.js') - -// ─── Fixtures ─────────────────────────────────────────────────────────────── - -const hex32 = '0x' + 'ab'.repeat(32) - -const validCompute = { - circuitId: 'private_transfer', - encryptedInputs: ['0xdeadbeef', '0xcafebabe'], -} - -const validComputeCheckBalance = { - circuitId: 'check_balance', - encryptedInputs: ['0xdeadbeef'], -} - -const validComputeSwap = { - circuitId: 'validate_swap', - encryptedInputs: ['0xdeadbeef', '0xcafebabe', '0x1234abcd'], -} - -const validViewingKey = { - key: hex32, - path: 'm/44/501/0', - hash: hex32, -} - -// ─── POST /v1/arcium/compute ──────────────────────────────────────────────── - -describe('POST /v1/arcium/compute', () => { - beforeEach(() => { - resetArciumProvider() - resetBackendRegistry() - }) - - it('submits private_transfer computation → 200 with arc_ prefix', async () => { - const res = await request(app) - .post('/v1/arcium/compute') - .send(validCompute) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.computationId).toMatch(/^arc_[0-9a-f]{64}$/) - expect(res.body.data.status).toBe('submitted') - expect(res.body.data.circuitId).toBe('private_transfer') - expect(res.body.data.chain).toBe('solana') - expect(res.body.data.inputCount).toBe(2) - expect(res.body.data.estimatedCompletion).toBeGreaterThan(Date.now() - 1000) - }) - - it('submits check_balance computation → 200', async () => { - const res = await request(app) - .post('/v1/arcium/compute') - .send(validComputeCheckBalance) - expect(res.status).toBe(200) - expect(res.body.data.circuitId).toBe('check_balance') - expect(res.body.data.inputCount).toBe(1) - }) - - it('submits validate_swap computation → 200', async () => { - const res = await request(app) - .post('/v1/arcium/compute') - .send(validComputeSwap) - expect(res.status).toBe(200) - expect(res.body.data.circuitId).toBe('validate_swap') - expect(res.body.data.inputCount).toBe(3) - }) - - it('rejects invalid circuitId → 400', async () => { - const res = await request(app) - .post('/v1/arcium/compute') - .send({ circuitId: 'invalid_circuit', encryptedInputs: ['0xaa'] }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects empty encryptedInputs → 400', async () => { - const res = await request(app) - .post('/v1/arcium/compute') - .send({ circuitId: 'private_transfer', encryptedInputs: [] }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects non-hex inputs → 400', async () => { - const res = await request(app) - .post('/v1/arcium/compute') - .send({ circuitId: 'private_transfer', encryptedInputs: ['not-hex', 'also-not'] }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('includes beta warning', async () => { - const res = await request(app) - .post('/v1/arcium/compute') - .send(validCompute) - expect(res.status).toBe(200) - expect(res.body.beta).toBe(true) - expect(res.body.warning).toContain('beta') - expect(res.headers['x-beta']).toBe('true') - }) -}) - -// ─── GET /v1/arcium/compute/:id/status ────────────────────────────────────── - -describe('GET /v1/arcium/compute/:id/status', () => { - beforeEach(() => { - resetArciumProvider() - resetBackendRegistry() - }) - - it('returns submitted status immediately after submission', async () => { - const submitRes = await request(app) - .post('/v1/arcium/compute') - .send(validCompute) - const { computationId } = submitRes.body.data - - const res = await request(app) - .get(`/v1/arcium/compute/${computationId}/status`) - expect(res.status).toBe(200) - expect(res.body.data.status).toBe('submitted') - expect(res.body.data.progress).toBe(0) - expect(res.body.data.output).toBeUndefined() - expect(res.body.data.proof).toBeUndefined() - }) - - it('returns completed with output/proof after timestamp override', async () => { - const submitRes = await request(app) - .post('/v1/arcium/compute') - .send(validCompute) - const { computationId } = submitRes.body.data - - // Override timestamp to 5 seconds ago (past completed threshold) - _setComputationTimestamp(computationId, Date.now() - 5000) - - const res = await request(app) - .get(`/v1/arcium/compute/${computationId}/status`) - expect(res.status).toBe(200) - expect(res.body.data.status).toBe('completed') - expect(res.body.data.progress).toBe(100) - expect(res.body.data.output).toMatch(/^0x[0-9a-f]{64}$/) - expect(res.body.data.proof).toMatch(/^0x[0-9a-f]{64}$/) - }) - - it('returns 404 for unknown computation ID', async () => { - const res = await request(app) - .get('/v1/arcium/compute/arc_' + 'ff'.repeat(32) + '/status') - expect(res.status).toBe(404) - expect(res.body.success).toBe(false) - expect(res.body.error.code).toBe('ARCIUM_COMPUTATION_NOT_FOUND') - }) - - it('includes progress percentage in response', async () => { - const submitRes = await request(app) - .post('/v1/arcium/compute') - .send(validCompute) - const { computationId } = submitRes.body.data - - const res = await request(app) - .get(`/v1/arcium/compute/${computationId}/status`) - expect(res.status).toBe(200) - expect(typeof res.body.data.progress).toBe('number') - expect(res.body.data.progress).toBeGreaterThanOrEqual(0) - expect(res.body.data.progress).toBeLessThanOrEqual(100) - }) -}) - -// ─── POST /v1/arcium/decrypt ──────────────────────────────────────────────── - -describe('POST /v1/arcium/decrypt', () => { - beforeEach(() => { - resetArciumProvider() - resetBackendRegistry() - }) - - it('decrypts completed computation → 200', async () => { - const submitRes = await request(app) - .post('/v1/arcium/compute') - .send(validCompute) - const { computationId } = submitRes.body.data - - // Make it completed - _setComputationTimestamp(computationId, Date.now() - 5000) - - const res = await request(app) - .post('/v1/arcium/decrypt') - .send({ computationId, viewingKey: validViewingKey }) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.computationId).toBe(computationId) - expect(res.body.data.circuitId).toBe('private_transfer') - expect(res.body.data.decryptedOutput).toMatch(/^0x[0-9a-f]{64}$/) - expect(res.body.data.verificationHash).toMatch(/^0x[0-9a-f]{64}$/) - }) - - it('rejects non-existent computation → 400/500', async () => { - const fakeId = 'arc_' + 'ff'.repeat(32) - const res = await request(app) - .post('/v1/arcium/decrypt') - .send({ computationId: fakeId, viewingKey: validViewingKey }) - // Error handler may map to 400 or 500 depending on the error name - expect(res.status).toBeGreaterThanOrEqual(400) - expect(res.body.success).toBe(false) - }) - - it('rejects missing viewingKey → 400', async () => { - const fakeId = 'arc_' + 'ff'.repeat(32) - const res = await request(app) - .post('/v1/arcium/decrypt') - .send({ computationId: fakeId }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) -}) - -// ─── Idempotency ──────────────────────────────────────────────────────────── - -describe('Arcium compute idempotency', () => { - beforeEach(() => { - resetArciumProvider() - resetBackendRegistry() - }) - - it('returns cached response with Idempotency-Replayed header', async () => { - const key = '550e8400-e29b-41d4-a716-446655440099' - const first = await request(app) - .post('/v1/arcium/compute') - .set('Idempotency-Key', key) - .send(validCompute) - expect(first.status).toBe(200) - - const second = await request(app) - .post('/v1/arcium/compute') - .set('Idempotency-Key', key) - .send(validCompute) - expect(second.status).toBe(200) - expect(second.headers['idempotency-replayed']).toBe('true') - expect(second.body.data.computationId).toBe(first.body.data.computationId) - }) - - it('different idempotency key → different response', async () => { - const key1 = '550e8400-e29b-41d4-a716-446655440001' - const key2 = '550e8400-e29b-41d4-a716-446655440002' - - const first = await request(app) - .post('/v1/arcium/compute') - .set('Idempotency-Key', key1) - .send(validCompute) - - const second = await request(app) - .post('/v1/arcium/compute') - .set('Idempotency-Key', key2) - .send(validCompute) - - expect(first.body.data.computationId).not.toBe(second.body.data.computationId) - }) -}) - -// ─── Backend Registration ─────────────────────────────────────────────────── - -describe('Arcium backend registration', () => { - beforeEach(() => { - resetBackendRegistry() - }) - - it('arcium-mpc appears in GET /v1/backends', async () => { - const res = await request(app).get('/v1/backends') - expect(res.status).toBe(200) - const arcium = res.body.data.backends.find((b: any) => b.name === 'arcium-mpc') - expect(arcium).toBeDefined() - expect(arcium.chains).toContain('solana') - }) - - it('has type compute and hiddenCompute capability', async () => { - const res = await request(app).get('/v1/backends') - const arcium = res.body.data.backends.find((b: any) => b.name === 'arcium-mpc') - expect(arcium.type).toBe('compute') - expect(arcium.capabilities.hiddenCompute).toBe(true) - expect(arcium.capabilities.hiddenAmount).toBe(false) - expect(arcium.capabilities.complianceSupport).toBe(true) - expect(arcium.capabilities.setupRequired).toBe(true) - expect(arcium.capabilities.latencyEstimate).toBe('medium') - }) -}) diff --git a/tests/backend-comparison.test.ts b/tests/backend-comparison.test.ts index 1ea4f92..4fdbc92 100644 --- a/tests/backend-comparison.test.ts +++ b/tests/backend-comparison.test.ts @@ -65,8 +65,6 @@ describe('POST /v1/backends/compare — basic', () => { const names = res.body.data.comparisons.map((c: any) => c.backend) expect(names).toContain('sip-native') - expect(names).toContain('arcium-mpc') - expect(names).toContain('inco-fhe') }) }) @@ -87,32 +85,22 @@ describe('POST /v1/backends/compare — scoring', () => { expect(res.body.data.recommendation.best_overall).toBe('sip-native') }) - it('compute backends scored higher for encrypted_compute', async () => { + it('sip-native not available for encrypted_compute', async () => { const res = await request(app) .post('/v1/backends/compare') .send({ operation: 'encrypted_compute' }) const sipNative = res.body.data.comparisons.find((c: any) => c.backend === 'sip-native') - const arcium = res.body.data.comparisons.find((c: any) => c.backend === 'arcium-mpc') - // sip-native doesn't have hiddenCompute, so it shouldn't be available for compute expect(sipNative.available).toBe(false) expect(sipNative.score).toBe(0) - // arcium does - expect(arcium.available).toBe(true) - expect(arcium.score).toBeGreaterThan(0) }) - it('filters backends without compliance for compliance_audit', async () => { + it('sip-native available for compliance_audit', async () => { const res = await request(app) .post('/v1/backends/compare') .send({ operation: 'compliance_audit' }) - // inco-fhe has complianceSupport: false - const inco = res.body.data.comparisons.find((c: any) => c.backend === 'inco-fhe') - expect(inco.available).toBe(false) - expect(inco.score).toBe(0) - // sip-native has complianceSupport: true const sipNative = res.body.data.comparisons.find((c: any) => c.backend === 'sip-native') expect(sipNative.available).toBe(true) @@ -148,41 +136,42 @@ describe('POST /v1/backends/compare — scoring', () => { // ─── Prioritize Parameter ───────────────────────────────────────────────────── describe('POST /v1/backends/compare — prioritize', () => { - it('prioritize: "cost" adjusts scores toward cheapest', async () => { + it('prioritize: "cost" adjusts score weighting', async () => { const defaultRes = await request(app) .post('/v1/backends/compare') - .send({ operation: 'encrypted_compute' }) + .send({ operation: 'stealth_privacy' }) clearComparisonCache() const costRes = await request(app) .post('/v1/backends/compare') - .send({ operation: 'encrypted_compute', prioritize: 'cost' }) + .send({ operation: 'stealth_privacy', prioritize: 'cost' }) - // Both should have inco-fhe (cheaper) — with cost priority, inco's relative score should increase - const incoDefault = defaultRes.body.data.comparisons.find((c: any) => c.backend === 'inco-fhe') - const incoCost = costRes.body.data.comparisons.find((c: any) => c.backend === 'inco-fhe') + const sipDefault = defaultRes.body.data.comparisons.find((c: any) => c.backend === 'sip-native') + const sipCost = costRes.body.data.comparisons.find((c: any) => c.backend === 'sip-native') - // Inco is cheaper than arcium, so cost-prioritized score should be >= default - expect(incoCost.score).toBeGreaterThanOrEqual(incoDefault.score) + // Both should produce valid scores; prioritize changes weighting, not availability + expect(sipDefault.score).toBeGreaterThan(0) + expect(sipCost.score).toBeGreaterThan(0) }) - it('prioritize: "speed" adjusts scores toward fastest', async () => { + it('prioritize: "speed" adjusts score weighting', async () => { const defaultRes = await request(app) .post('/v1/backends/compare') - .send({ operation: 'encrypted_compute' }) + .send({ operation: 'stealth_privacy' }) clearComparisonCache() const speedRes = await request(app) .post('/v1/backends/compare') - .send({ operation: 'encrypted_compute', prioritize: 'speed' }) + .send({ operation: 'stealth_privacy', prioritize: 'speed' }) - // Inco (2000ms) is faster than arcium (4000ms) - const incoDefault = defaultRes.body.data.comparisons.find((c: any) => c.backend === 'inco-fhe') - const incoSpeed = speedRes.body.data.comparisons.find((c: any) => c.backend === 'inco-fhe') + const sipDefault = defaultRes.body.data.comparisons.find((c: any) => c.backend === 'sip-native') + const sipSpeed = speedRes.body.data.comparisons.find((c: any) => c.backend === 'sip-native') - expect(incoSpeed.score).toBeGreaterThanOrEqual(incoDefault.score) + // Both should produce valid scores + expect(sipDefault.score).toBeGreaterThan(0) + expect(sipSpeed.score).toBeGreaterThan(0) }) }) @@ -265,15 +254,14 @@ describe('POST /v1/backends/compare — cache', () => { // ─── Edge Cases ─────────────────────────────────────────────────────────────── describe('POST /v1/backends/compare — edge cases', () => { - it('costSOL formatted correctly (lamports / 1e9)', async () => { + it('costSOL formatted correctly for sip-native (5000 lamports)', async () => { const res = await request(app) .post('/v1/backends/compare') - .send({ operation: 'encrypted_compute' }) + .send({ operation: 'stealth_privacy' }) - const arcium = res.body.data.comparisons.find((c: any) => c.backend === 'arcium-mpc') - // Arcium costs 5000 lamports - expect(arcium.costSOL).toBe('0.000005000') - expect(arcium.costLamports).toBe(5000) + const sipNative = res.body.data.comparisons.find((c: any) => c.backend === 'sip-native') + expect(sipNative.costSOL).toBe('0.000005000') + expect(sipNative.costLamports).toBe(5000) }) it('unavailable backend has available: false and score 0', async () => { @@ -287,14 +275,13 @@ describe('POST /v1/backends/compare — edge cases', () => { expect(sipNative.score).toBe(0) }) - it('all backends returned when operation matches all', async () => { - // stealth_privacy → sip-native matches, compute backends don't + it('all registered backends returned in response', async () => { const res = await request(app) .post('/v1/backends/compare') .send({ operation: 'stealth_privacy' }) - // All 3 backends should be in the response (even if some are unavailable) - expect(res.body.data.comparisons.length).toBe(3) + // Only sip-native is registered + expect(res.body.data.comparisons.length).toBe(1) }) }) diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 432bca4..f985975 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -136,11 +136,6 @@ describe('Metering middleware', () => { expect(classifyPath('/v1/commitment/create')).toBe('commitment') }) - it('classifies compute paths (arcium/inco)', () => { - expect(classifyPath('/v1/arcium/compute')).toBe('compute') - expect(classifyPath('/v1/inco/encrypt')).toBe('compute') - }) - it('skips public/non-metered paths', () => { expect(classifyPath('/v1/health')).toBeNull() expect(classifyPath('/v1/ready')).toBeNull() diff --git a/tests/inco.test.ts b/tests/inco.test.ts deleted file mode 100644 index 212e09f..0000000 --- a/tests/inco.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import request from 'supertest' -import { resetIncoProvider } from '../src/services/inco-provider.js' -import { resetBackendRegistry } from '../src/services/backend-registry.js' - -vi.mock('@solana/web3.js', async () => { - const actual = await vi.importActual('@solana/web3.js') - return { - ...actual as object, - Connection: vi.fn().mockImplementation(() => ({ - getSlot: vi.fn().mockResolvedValue(300000000), - rpcEndpoint: 'https://api.mainnet-beta.solana.com', - })), - } -}) - -const { default: app } = await import('../src/server.js') - -// ─── Fixtures ─────────────────────────────────────────────────────────────── - -const validEncryptFhew = { - plaintext: 42, - scheme: 'fhew', -} - -const validEncryptTfhe = { - plaintext: 'hello', - scheme: 'tfhe', - label: 'test-value', -} - -const validCiphertexts = ['0xdeadbeef', '0xcafebabe'] -const singleCiphertext = ['0xdeadbeef'] - -const validComputeAdd = { - operation: 'add', - ciphertexts: validCiphertexts, -} - -const validComputeMul = { - operation: 'mul', - ciphertexts: validCiphertexts, -} - -const validComputeNot = { - operation: 'not', - ciphertexts: singleCiphertext, -} - -// ─── POST /v1/inco/encrypt ────────────────────────────────────────────────── - -describe('POST /v1/inco/encrypt', () => { - beforeEach(() => { - resetIncoProvider() - resetBackendRegistry() - }) - - it('encrypts with fhew scheme → 200 with ciphertext', async () => { - const res = await request(app) - .post('/v1/inco/encrypt') - .send(validEncryptFhew) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.encryptionId).toMatch(/^inc_[0-9a-f]{64}$/) - expect(res.body.data.ciphertext).toMatch(/^0x[0-9a-f]{64}$/) - expect(res.body.data.scheme).toBe('fhew') - }) - - it('encrypts with tfhe scheme → 200', async () => { - const res = await request(app) - .post('/v1/inco/encrypt') - .send(validEncryptTfhe) - expect(res.status).toBe(200) - expect(res.body.data.scheme).toBe('tfhe') - expect(res.body.data.label).toBe('test-value') - }) - - it('returns noiseBudget: 100', async () => { - const res = await request(app) - .post('/v1/inco/encrypt') - .send(validEncryptFhew) - expect(res.status).toBe(200) - expect(res.body.data.noiseBudget).toBe(100) - }) - - it('rejects invalid scheme → 400', async () => { - const res = await request(app) - .post('/v1/inco/encrypt') - .send({ plaintext: 42, scheme: 'invalid' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('includes beta warning', async () => { - const res = await request(app) - .post('/v1/inco/encrypt') - .send(validEncryptFhew) - expect(res.status).toBe(200) - expect(res.body.beta).toBe(true) - expect(res.body.warning).toContain('beta') - expect(res.headers['x-beta']).toBe('true') - }) -}) - -// ─── POST /v1/inco/compute ────────────────────────────────────────────────── - -describe('POST /v1/inco/compute', () => { - beforeEach(() => { - resetIncoProvider() - resetBackendRegistry() - }) - - it('computes add with 2 ciphertexts → 200 with inc_ prefix', async () => { - const res = await request(app) - .post('/v1/inco/compute') - .send(validComputeAdd) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.computationId).toMatch(/^inc_[0-9a-f]{64}$/) - expect(res.body.data.operation).toBe('add') - expect(res.body.data.operandCount).toBe(2) - expect(res.body.data.status).toBe('completed') - }) - - it('computes mul → 200 with higher noise cost', async () => { - const res = await request(app) - .post('/v1/inco/compute') - .send(validComputeMul) - expect(res.status).toBe(200) - // mul costs 15 noise, so remaining = 100 - 15 = 85 - expect(res.body.data.noiseBudgetRemaining).toBe(85) - }) - - it('computes not with 1 ciphertext → 200', async () => { - const res = await request(app) - .post('/v1/inco/compute') - .send(validComputeNot) - expect(res.status).toBe(200) - expect(res.body.data.operation).toBe('not') - expect(res.body.data.operandCount).toBe(1) - // not costs 3, remaining = 100 - 3 = 97 - expect(res.body.data.noiseBudgetRemaining).toBe(97) - }) - - it('rejects invalid operation → 400', async () => { - const res = await request(app) - .post('/v1/inco/compute') - .send({ operation: 'divide', ciphertexts: validCiphertexts }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects wrong operand count → 400', async () => { - // add requires 2 operands, sending 1 - const res = await request(app) - .post('/v1/inco/compute') - .send({ operation: 'add', ciphertexts: singleCiphertext }) - // The provider throws IncoComputationError for wrong operand count - expect(res.status).toBeGreaterThanOrEqual(400) - expect(res.body.success).toBe(false) - }) - - it('rejects non-hex ciphertexts → 400', async () => { - const res = await request(app) - .post('/v1/inco/compute') - .send({ operation: 'add', ciphertexts: ['not-hex', 'also-not'] }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('includes noise budget remaining in response', async () => { - const res = await request(app) - .post('/v1/inco/compute') - .send(validComputeAdd) - expect(res.status).toBe(200) - // add costs 5, remaining = 100 - 5 = 95 - expect(typeof res.body.data.noiseBudgetRemaining).toBe('number') - expect(res.body.data.noiseBudgetRemaining).toBe(95) - }) -}) - -// ─── POST /v1/inco/decrypt ────────────────────────────────────────────────── - -describe('POST /v1/inco/decrypt', () => { - beforeEach(() => { - resetIncoProvider() - resetBackendRegistry() - }) - - it('decrypts completed computation → 200', async () => { - // First compute something - const computeRes = await request(app) - .post('/v1/inco/compute') - .send(validComputeAdd) - const { computationId } = computeRes.body.data - - const res = await request(app) - .post('/v1/inco/decrypt') - .send({ computationId }) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.computationId).toBe(computationId) - expect(res.body.data.operation).toBe('add') - expect(res.body.data.decryptedOutput).toMatch(/^0x[0-9a-f]{64}$/) - expect(res.body.data.verificationHash).toMatch(/^0x[0-9a-f]{64}$/) - }) - - it('rejects non-existent computation → 400/500', async () => { - const fakeId = 'inc_' + 'ff'.repeat(32) - const res = await request(app) - .post('/v1/inco/decrypt') - .send({ computationId: fakeId }) - expect(res.status).toBeGreaterThanOrEqual(400) - expect(res.body.success).toBe(false) - }) - - it('rejects missing computationId → 400', async () => { - const res = await request(app) - .post('/v1/inco/decrypt') - .send({}) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) -}) - -// ─── Idempotency ──────────────────────────────────────────────────────────── - -describe('Inco compute idempotency', () => { - beforeEach(() => { - resetIncoProvider() - resetBackendRegistry() - }) - - it('returns cached response with Idempotency-Replayed header', async () => { - const key = '660e8400-e29b-41d4-a716-446655440099' - const first = await request(app) - .post('/v1/inco/compute') - .set('Idempotency-Key', key) - .send(validComputeAdd) - expect(first.status).toBe(200) - - const second = await request(app) - .post('/v1/inco/compute') - .set('Idempotency-Key', key) - .send(validComputeAdd) - expect(second.status).toBe(200) - expect(second.headers['idempotency-replayed']).toBe('true') - expect(second.body.data.computationId).toBe(first.body.data.computationId) - }) - - it('different key → different response', async () => { - const key1 = '660e8400-e29b-41d4-a716-446655440001' - const key2 = '660e8400-e29b-41d4-a716-446655440002' - - const first = await request(app) - .post('/v1/inco/compute') - .set('Idempotency-Key', key1) - .send(validComputeAdd) - - const second = await request(app) - .post('/v1/inco/compute') - .set('Idempotency-Key', key2) - .send(validComputeAdd) - - expect(first.body.data.computationId).not.toBe(second.body.data.computationId) - }) -}) - -// ─── Backend Registration ─────────────────────────────────────────────────── - -describe('Inco backend registration', () => { - beforeEach(() => { - resetBackendRegistry() - }) - - it('inco-fhe appears in GET /v1/backends', async () => { - const res = await request(app).get('/v1/backends') - expect(res.status).toBe(200) - const inco = res.body.data.backends.find((b: any) => b.name === 'inco-fhe') - expect(inco).toBeDefined() - expect(inco.chains).toContain('solana') - }) - - it('has type compute and hiddenCompute capability', async () => { - const res = await request(app).get('/v1/backends') - const inco = res.body.data.backends.find((b: any) => b.name === 'inco-fhe') - expect(inco.type).toBe('compute') - expect(inco.capabilities.hiddenCompute).toBe(true) - expect(inco.capabilities.complianceSupport).toBe(false) - expect(inco.capabilities.setupRequired).toBe(true) - expect(inco.capabilities.latencyEstimate).toBe('medium') - }) -}) - -// ─── E2E Flow ─────────────────────────────────────────────────────────────── - -describe('Inco FHE E2E flow', () => { - beforeEach(() => { - resetIncoProvider() - resetBackendRegistry() - }) - - it('encrypt → compute → decrypt round-trip', async () => { - // Step 1: Encrypt two values - const enc1 = await request(app) - .post('/v1/inco/encrypt') - .send({ plaintext: 100, scheme: 'tfhe' }) - expect(enc1.status).toBe(200) - - const enc2 = await request(app) - .post('/v1/inco/encrypt') - .send({ plaintext: 200, scheme: 'tfhe' }) - expect(enc2.status).toBe(200) - - // Step 2: Compute on the ciphertexts - const compute = await request(app) - .post('/v1/inco/compute') - .send({ - operation: 'add', - ciphertexts: [enc1.body.data.ciphertext, enc2.body.data.ciphertext], - scheme: 'tfhe', - }) - expect(compute.status).toBe(200) - expect(compute.body.data.status).toBe('completed') - expect(compute.body.data.noiseBudgetRemaining).toBe(95) - - // Step 3: Decrypt the result - const decrypt = await request(app) - .post('/v1/inco/decrypt') - .send({ computationId: compute.body.data.computationId }) - expect(decrypt.status).toBe(200) - expect(decrypt.body.data.decryptedOutput).toMatch(/^0x[0-9a-f]{64}$/) - expect(decrypt.body.data.verificationHash).toMatch(/^0x[0-9a-f]{64}$/) - }) -}) diff --git a/tests/session.test.ts b/tests/session.test.ts index 7282815..7f30896 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -83,7 +83,7 @@ describe('POST /v1/sessions', () => { chain: 'ethereum', privacyLevel: 'maximum', rpcProvider: 'helius', - backend: 'arcium', + backend: 'sip-native', defaultViewingKey: '0xabcd1234', } const res = await createTestSession(allDefaults) From 698840a571584709d03f7347f83c85e45879d036 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:49:53 +0700 Subject: [PATCH 06/14] feat: replace mock Jupiter provider with real API integration (closes #134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jupiter-provider.ts now calls live Jupiter Quote + Swap API via HTTP. getQuote() → GET lite-api.jup.ag/swap/v1/quote buildSwapTransaction() → POST lite-api.jup.ag/swap/v1/swap Base URL configurable via JUPITER_API_URL env var. Removed hardcoded token whitelist (Jupiter supports thousands). Updated private-swap route, builder, and tests accordingly. --- src/routes/private-swap.ts | 32 +----- src/services/jupiter-provider.ts | 148 +++++++++++++++------------ src/services/private-swap-builder.ts | 4 +- tests/private-swap.test.ts | 93 ++++++++++++----- 4 files changed, 155 insertions(+), 122 deletions(-) diff --git a/src/routes/private-swap.ts b/src/routes/private-swap.ts index 97b6e87..89413e4 100644 --- a/src/routes/private-swap.ts +++ b/src/routes/private-swap.ts @@ -4,10 +4,9 @@ import { validateRequest } from '../middleware/validation.js' import { idempotency } from '../middleware/idempotency.js' import { betaEndpoint, getBetaWarning } from '../middleware/beta.js' import { buildPrivateSwap } from '../services/private-swap-builder.js' -import { isTokenSupported, getSupportedTokens } from '../services/jupiter-provider.js' -import { ErrorCode, getErrorEntry } from '../errors/codes.js' +import { ErrorCode } from '../errors/codes.js' -const swapBeta = betaEndpoint('Private Swap uses a mock Jupiter provider. Real Jupiter integration coming soon.') +const swapBeta = betaEndpoint('Private Swap routes swap output to a stealth address for unlinkable receipt.') const router = Router() @@ -57,33 +56,6 @@ router.post( return } - // Pre-flight: token support check - const supported = getSupportedTokens() - if (!isTokenSupported(inputMint)) { - const entry = getErrorEntry(ErrorCode.SWAP_UNSUPPORTED_TOKEN) - res.status(entry?.httpStatus ?? 400).json({ - success: false, - error: { - code: ErrorCode.SWAP_UNSUPPORTED_TOKEN, - message: `Unsupported input token: ${inputMint}`, - supportedTokens: Object.keys(supported), - }, - }) - return - } - if (!isTokenSupported(outputMint)) { - const entry = getErrorEntry(ErrorCode.SWAP_UNSUPPORTED_TOKEN) - res.status(entry?.httpStatus ?? 400).json({ - success: false, - error: { - code: ErrorCode.SWAP_UNSUPPORTED_TOKEN, - message: `Unsupported output token: ${outputMint}`, - supportedTokens: Object.keys(supported), - }, - }) - return - } - const result = await buildPrivateSwap({ sender, inputMint, diff --git a/src/services/jupiter-provider.ts b/src/services/jupiter-provider.ts index f2623c0..55b5daf 100644 --- a/src/services/jupiter-provider.ts +++ b/src/services/jupiter-provider.ts @@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache' // ─── Constants ────────────────────────────────────────────────────────────── -const DOMAIN_TAG = new TextEncoder().encode('SIPHER-JUPITER-DEX') +const JUPITER_BASE_URL = process.env.JUPITER_API_URL ?? 'https://lite-api.jup.ag' export const SUPPORTED_TOKENS: Record = { So11111111111111111111111111111111111111112: { symbol: 'SOL', name: 'Wrapped SOL', decimals: 9 }, @@ -50,111 +50,133 @@ export interface SwapTransactionResult { // ─── Cache ────────────────────────────────────────────────────────────────── -const quoteCache = new LRUCache({ +interface CachedQuote { + entry: QuoteEntry + rawResponse: Record // Full Jupiter API response for swap building +} + +const quoteCache = new LRUCache({ max: 1000, ttl: 30 * 1000, // 30s — quotes expire fast }) // ─── Helpers ──────────────────────────────────────────────────────────────── -function hashWithTag(label: string, data: string): Uint8Array { - const payload = new TextEncoder().encode(label + data) - const input = new Uint8Array(DOMAIN_TAG.length + payload.length) - input.set(DOMAIN_TAG) - input.set(payload, DOMAIN_TAG.length) - return keccak_256(input) +function generateQuoteId(inputMint: string, outputMint: string, amount: string): string { + const payload = new TextEncoder().encode(inputMint + outputMint + amount + Date.now().toString()) + const hash = keccak_256(payload) + return 'jup_' + bytesToHex(hash) } // ─── Get Quote ────────────────────────────────────────────────────────────── -export function getQuote(params: QuoteParams): QuoteEntry { +export async function getQuote(params: QuoteParams): Promise { const { inputMint, outputMint, amount, slippageBps = 50 } = params - // Validate tokens - if (!SUPPORTED_TOKENS[inputMint]) { - const err = new Error(`Unsupported input token: ${inputMint}. Supported: ${Object.keys(SUPPORTED_TOKENS).join(', ')}`) + if (inputMint === outputMint) { + const err = new Error('Input and output mints must be different') err.name = 'JupiterQuoteError' throw err } - if (!SUPPORTED_TOKENS[outputMint]) { - const err = new Error(`Unsupported output token: ${outputMint}. Supported: ${Object.keys(SUPPORTED_TOKENS).join(', ')}`) + + const url = new URL(`${JUPITER_BASE_URL}/swap/v1/quote`) + url.searchParams.set('inputMint', inputMint) + url.searchParams.set('outputMint', outputMint) + url.searchParams.set('amount', amount) + url.searchParams.set('slippageBps', String(slippageBps)) + + let response: Response + try { + response = await fetch(url.toString()) + } catch (fetchErr) { + const err = new Error(`Jupiter API request failed: ${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`) err.name = 'JupiterQuoteError' throw err } - if (inputMint === outputMint) { - const err = new Error('Input and output mints must be different') + + if (!response.ok) { + let detail = '' + try { + const body = await response.json() as Record + detail = typeof body.error === 'string' ? body.error : JSON.stringify(body) + } catch { + detail = `HTTP ${response.status}` + } + const err = new Error(`Jupiter quote failed: ${detail}`) err.name = 'JupiterQuoteError' throw err } - // Deterministic quote ID - const now = Date.now() - const quoteId = 'jup_' + bytesToHex(hashWithTag('QUOTE', inputMint + outputMint + amount + now.toString())) - - // Deterministic output amount via hash-seeded ratio - const ratioHash = hashWithTag('RATIO', inputMint + outputMint + amount) - // Use first 4 bytes as a ratio factor: 0.90 – 1.10 range - const ratioSeed = (ratioHash[0]! << 8 | ratioHash[1]!) / 65535 - const ratioMultiplier = 0.90 + ratioSeed * 0.20 // 0.90 to 1.10 - - // Adjust for decimal differences - const inDecimals = SUPPORTED_TOKENS[inputMint]!.decimals - const outDecimals = SUPPORTED_TOKENS[outputMint]!.decimals - const decimalAdjustment = 10 ** (outDecimals - inDecimals) + const data = await response.json() as Record - const inAmountBig = BigInt(amount) - const rawOutAmount = Number(inAmountBig) * ratioMultiplier * decimalAdjustment - const outAmount = BigInt(Math.max(1, Math.floor(rawOutAmount))) - - // Price impact from hash - const impactSeed = (ratioHash[2]! & 0xFF) / 255 - const priceImpactPct = (impactSeed * 0.5).toFixed(4) // 0-0.5% impact - - // Slippage-adjusted minimum - const outAmountMin = outAmount - (outAmount * BigInt(slippageBps) / 10000n) + const quoteId = generateQuoteId(inputMint, outputMint, amount) + const now = Date.now() const entry: QuoteEntry = { quoteId, - inputMint, - outputMint, - inAmount: amount, - outAmount: outAmount.toString(), - outAmountMin: outAmountMin.toString(), - priceImpactPct, - slippageBps, + inputMint: String(data.inputMint), + outputMint: String(data.outputMint), + inAmount: String(data.inAmount), + outAmount: String(data.outAmount), + outAmountMin: String(data.otherAmountThreshold), + priceImpactPct: String(data.priceImpactPct ?? '0'), + slippageBps: Number(data.slippageBps ?? slippageBps), expiresAt: now + 30_000, } - quoteCache.set(quoteId, entry) + quoteCache.set(quoteId, { entry, rawResponse: data }) return entry } // ─── Build Swap Transaction ───────────────────────────────────────────────── -export function buildSwapTransaction(params: SwapTransactionParams): SwapTransactionResult { +export async function buildSwapTransaction(params: SwapTransactionParams): Promise { const { quoteId, userPublicKey, destinationAddress } = params - const quote = quoteCache.get(quoteId) - if (!quote) { + const cached = quoteCache.get(quoteId) + if (!cached) { const err = new Error(`Quote not found or expired: ${quoteId}`) err.name = 'JupiterSwapError' throw err } - // Deterministic base64 transaction - const txHash = hashWithTag('SWAP-TX', quoteId + userPublicKey + destinationAddress) - const swapTransaction = Buffer.from(txHash).toString('base64') + let response: Response + try { + response = await fetch(`${JUPITER_BASE_URL}/swap/v1/swap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + quoteResponse: cached.rawResponse, + userPublicKey, + destinationTokenAccount: destinationAddress, + }), + }) + } catch (fetchErr) { + const err = new Error(`Jupiter swap API request failed: ${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`) + err.name = 'JupiterSwapError' + throw err + } + + if (!response.ok) { + let detail = '' + try { + const body = await response.json() as Record + detail = typeof body.error === 'string' ? body.error : JSON.stringify(body) + } catch { + detail = `HTTP ${response.status}` + } + const err = new Error(`Jupiter swap transaction failed: ${detail}`) + err.name = 'JupiterSwapError' + throw err + } - // Deterministic compute unit price and priority fee - const feeHash = hashWithTag('FEE', quoteId) - const computeUnitPrice = 1000 + (feeHash[0]! << 8 | feeHash[1]!) % 9000 // 1000-10000 - const priorityFee = 5000 + (feeHash[2]! << 8 | feeHash[3]!) % 45000 // 5000-50000 + const data = await response.json() as Record return { - swapTransaction, + swapTransaction: String(data.swapTransaction), quoteId, - computeUnitPrice, - priorityFee, + computeUnitPrice: 0, // Jupiter handles compute unit pricing + priorityFee: Number(data.prioritizationFeeLamports ?? 0), } } @@ -164,8 +186,8 @@ export function getSupportedTokens(): typeof SUPPORTED_TOKENS { return SUPPORTED_TOKENS } -export function isTokenSupported(mint: string): boolean { - return mint in SUPPORTED_TOKENS +export function isTokenSupported(_mint: string): boolean { + return true } export function resetJupiterProvider(): void { diff --git a/src/services/private-swap-builder.ts b/src/services/private-swap-builder.ts index 6f9fd50..ec0108e 100644 --- a/src/services/private-swap-builder.ts +++ b/src/services/private-swap-builder.ts @@ -85,7 +85,7 @@ export async function buildPrivateSwap(req: PrivateSwapRequest): Promise { } }) +// Mock Jupiter API responses +vi.stubGlobal('fetch', vi.fn().mockImplementation((url: string, options?: any) => { + const urlStr = url.toString() + + // Jupiter Quote API + if (urlStr.includes('/swap/v1/quote')) { + const params = new URL(urlStr).searchParams + const inAmount = params.get('amount') ?? '1000000000' + // Simulate ~150 USDC per SOL ratio for SOL→USDC, inverse for USDC→SOL + const inputMint = params.get('inputMint') ?? '' + const isSolInput = inputMint === 'So11111111111111111111111111111111111111112' + const outAmount = isSolInput + ? String(Math.floor(Number(inAmount) * 150 / 1000)) // SOL→USDC: scale down decimals + : String(Math.floor(Number(inAmount) * 1000 / 150)) // USDC→SOL: scale up decimals + const slippage = Number(params.get('slippageBps') ?? 50) + const minOut = String(Math.floor(Number(outAmount) * (10000 - slippage) / 10000)) + + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + inputMint: params.get('inputMint'), + outputMint: params.get('outputMint'), + inAmount, + outAmount, + otherAmountThreshold: minOut, + swapMode: 'ExactIn', + slippageBps: slippage, + priceImpactPct: '0.01', + routePlan: [], + }), + }) + } + + // Jupiter Swap API + if (urlStr.includes('/swap/v1/swap') && options?.method === 'POST') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + swapTransaction: Buffer.from('mock-swap-transaction-' + Date.now()).toString('base64'), + lastValidBlockHeight: 300000100, + prioritizationFeeLamports: 5000, + computeUnitLimit: 200000, + }), + }) + } + + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ error: 'Not found' }) }) +})) + const { default: app } = await import('../src/server.js') // ─── Fixtures ─────────────────────────────────────────────────────────────── @@ -45,7 +94,10 @@ function validSwapPayload(overrides: Record = {}) { // ─── Happy Path ───────────────────────────────────────────────────────────── describe('POST /v1/swap/private — Happy Path', () => { - beforeEach(() => resetJupiterProvider()) + beforeEach(() => { + resetJupiterProvider() + vi.mocked(fetch).mockClear() + }) it('builds private swap with provided meta-address → 200', async () => { const metaAddress = await generateMetaAddress() @@ -119,7 +171,10 @@ describe('POST /v1/swap/private — Happy Path', () => { // ─── Swap Details ─────────────────────────────────────────────────────────── describe('POST /v1/swap/private — Swap Details', () => { - beforeEach(() => resetJupiterProvider()) + beforeEach(() => { + resetJupiterProvider() + vi.mocked(fetch).mockClear() + }) it('returns Jupiter quote with jup_ prefix → 200', async () => { const res = await request(app) @@ -127,7 +182,7 @@ describe('POST /v1/swap/private — Swap Details', () => { .send(validSwapPayload()) expect(res.status).toBe(200) - expect(res.body.data.quoteId).toMatch(/^jup_[0-9a-f]{64}$/) + expect(res.body.data.quoteId).toMatch(/^jup_/) expect(res.body.data.priceImpactPct).toBeTypeOf('string') }) @@ -171,17 +226,6 @@ describe('POST /v1/swap/private — Validation', () => { expect(res.body.error.message).toContain('different') }) - it('rejects unsupported input mint → 400', async () => { - const fakeMint = Keypair.generate().publicKey.toBase58() - const res = await request(app) - .post('/v1/swap/private') - .send(validSwapPayload({ inputMint: fakeMint })) - - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('SWAP_UNSUPPORTED_TOKEN') - expect(res.body.error.supportedTokens).toBeDefined() - }) - it('rejects invalid amount (zero) → 400', async () => { const res = await request(app) .post('/v1/swap/private') @@ -215,7 +259,10 @@ describe('POST /v1/swap/private — Validation', () => { // ─── Idempotency ──────────────────────────────────────────────────────────── describe('POST /v1/swap/private — Idempotency', () => { - beforeEach(() => resetJupiterProvider()) + beforeEach(() => { + resetJupiterProvider() + vi.mocked(fetch).mockClear() + }) it('returns cached response with Idempotency-Replayed header', async () => { const key = crypto.randomUUID() @@ -272,17 +319,6 @@ describe('POST /v1/swap/private — Beta', () => { // ─── Error Handling ───────────────────────────────────────────────────────── describe('POST /v1/swap/private — Error Handling', () => { - it('rejects unsupported output mint → 400', async () => { - const fakeMint = Keypair.generate().publicKey.toBase58() - const res = await request(app) - .post('/v1/swap/private') - .send(validSwapPayload({ outputMint: fakeMint })) - - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('SWAP_UNSUPPORTED_TOKEN') - expect(res.body.error.message).toContain('Unsupported output token') - }) - it('rejects negative amount → 400', async () => { const res = await request(app) .post('/v1/swap/private') @@ -296,7 +332,10 @@ describe('POST /v1/swap/private — Error Handling', () => { // ─── E2E Flow ─────────────────────────────────────────────────────────────── describe('POST /v1/swap/private — E2E Flow', () => { - beforeEach(() => resetJupiterProvider()) + beforeEach(() => { + resetJupiterProvider() + vi.mocked(fetch).mockClear() + }) it('SOL → USDC full private swap flow', async () => { const metaAddress = await generateMetaAddress() From 25e713ca4451c0601e3d68d52f49bdad4f82980f Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:56:54 +0700 Subject: [PATCH 07/14] feat: remove mock proof providers and endpoints (closes #135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof generation endpoints used MockProofProvider (no cryptographic validity) and mock STARK range proofs. Removed entirely — proof generation belongs client-side for privacy (server shouldn't see private inputs). Deleted 6 files (2 providers, 2 routes, 2 test suites). 8 endpoints removed. 497 tests remain passing. --- src/app.ts | 11 - src/middleware/metering.ts | 1 - src/openapi/spec.ts | 445 --------------------------------- src/routes/demo.ts | 121 ++------- src/routes/index.ts | 4 - src/routes/proofs.ts | 282 --------------------- src/routes/range-proof.ts | 82 ------ src/services/proof-provider.ts | 16 -- src/services/stark-provider.ts | 177 ------------- tests/billing.test.ts | 1 - tests/demo.test.ts | 6 +- tests/openapi.test.ts | 7 - tests/proofs.test.ts | 260 ------------------- tests/range-proof.test.ts | 241 ------------------ 14 files changed, 29 insertions(+), 1625 deletions(-) delete mode 100644 src/routes/proofs.ts delete mode 100644 src/routes/range-proof.ts delete mode 100644 src/services/proof-provider.ts delete mode 100644 src/services/stark-provider.ts delete mode 100644 tests/proofs.test.ts delete mode 100644 tests/range-proof.test.ts diff --git a/src/app.ts b/src/app.ts index cfaaa4f..747460f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -85,7 +85,6 @@ app.get('/', (_req, res) => { 'Pedersen commitments (homomorphic)', 'XChaCha20-Poly1305 encryption', 'BIP32 hierarchical key derivation', - 'STARK range proofs (M31 limbs)', 'Noir/Groth16 ZK verification (SunspotVerifier)', ], sdk: '@sip-protocol/sdk v0.7.4', @@ -137,16 +136,6 @@ app.get('/', (_req, res) => { select: 'POST /v1/backends/select', compare: 'POST /v1/backends/compare', }, - proofs: { - fundingGenerate: 'POST /v1/proofs/funding/generate', - fundingVerify: 'POST /v1/proofs/funding/verify', - validityGenerate: 'POST /v1/proofs/validity/generate', - validityVerify: 'POST /v1/proofs/validity/verify', - fulfillmentGenerate: 'POST /v1/proofs/fulfillment/generate', - fulfillmentVerify: 'POST /v1/proofs/fulfillment/verify', - rangeGenerate: 'POST /v1/proofs/range/generate', - rangeVerify: 'POST /v1/proofs/range/verify', - }, cspl: { wrap: 'POST /v1/cspl/wrap', unwrap: 'POST /v1/cspl/unwrap', diff --git a/src/middleware/metering.ts b/src/middleware/metering.ts index 31b6e37..6858c8f 100644 --- a/src/middleware/metering.ts +++ b/src/middleware/metering.ts @@ -12,7 +12,6 @@ const PATH_CATEGORIES: [string, OperationCategory][] = [ ['/v1/transfer/', 'transfer'], ['/v1/scan/', 'scan'], ['/v1/viewing-key/', 'viewing_key'], - ['/v1/proofs/', 'proof'], ['/v1/privacy/', 'privacy'], ['/v1/swap/', 'swap'], ['/v1/governance/', 'governance'], diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 1edcf24..b6a05ea 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1585,406 +1585,6 @@ export const openApiSpec = { }, }, - // ─── Proofs ─────────────────────────────────────────────────────────────── - '/v1/proofs/funding/generate': { - post: { - summary: 'Generate funding proof', - description: 'Generates a ZK proof that balance >= minimumRequired without revealing the balance.', - tags: ['Proofs'], - operationId: 'proofsFundingGenerate', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - balance: { type: 'string', pattern: '^[0-9]+$', description: 'User balance (private)' }, - minimumRequired: { type: 'string', pattern: '^[0-9]+$', description: 'Minimum required amount (public)' }, - blindingFactor: hexString32, - assetId: { type: 'string', description: 'Asset identifier (e.g., SOL)' }, - userAddress: { type: 'string', description: 'User address for ownership proof' }, - ownershipSignature: { type: 'string', pattern: '^0x[0-9a-fA-F]+$', description: 'Signature proving address ownership' }, - }, - required: ['balance', 'minimumRequired', 'blindingFactor', 'assetId', 'userAddress', 'ownershipSignature'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Proof generated', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - proof: { - type: 'object', - properties: { - type: { type: 'string', enum: ['funding'] }, - proof: { type: 'string' }, - publicInputs: { type: 'array', items: { type: 'string' } }, - }, - }, - publicInputs: { type: 'array', items: { type: 'string' } }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation or proof generation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - - '/v1/proofs/funding/verify': { - post: { - summary: 'Verify funding proof', - description: 'Verifies a previously generated funding proof.', - tags: ['Proofs'], - operationId: 'proofsFundingVerify', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - type: { type: 'string', enum: ['funding'] }, - proof: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - publicInputs: { type: 'array', items: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' } }, - }, - required: ['type', 'proof', 'publicInputs'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Verification result', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - data: { - type: 'object', - properties: { valid: { type: 'boolean' } }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - - '/v1/proofs/validity/generate': { - post: { - summary: 'Generate validity proof', - description: 'Generates a ZK proof that an intent is authorized by the sender without revealing the sender.', - tags: ['Proofs'], - operationId: 'proofsValidityGenerate', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - intentHash: hexString32, - senderAddress: { type: 'string' }, - senderBlinding: hexString32, - senderSecret: hexString32, - authorizationSignature: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - nonce: hexString32, - timestamp: { type: 'integer' }, - expiry: { type: 'integer' }, - }, - required: ['intentHash', 'senderAddress', 'senderBlinding', 'senderSecret', 'authorizationSignature', 'nonce', 'timestamp', 'expiry'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Proof generated', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - proof: { - type: 'object', - properties: { - type: { type: 'string', enum: ['validity'] }, - proof: { type: 'string' }, - publicInputs: { type: 'array', items: { type: 'string' } }, - }, - }, - publicInputs: { type: 'array', items: { type: 'string' } }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation or proof generation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - - '/v1/proofs/validity/verify': { - post: { - summary: 'Verify validity proof', - description: 'Verifies a previously generated validity proof.', - tags: ['Proofs'], - operationId: 'proofsValidityVerify', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - type: { type: 'string', enum: ['validity'] }, - proof: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - publicInputs: { type: 'array', items: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' } }, - }, - required: ['type', 'proof', 'publicInputs'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Verification result', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - data: { - type: 'object', - properties: { valid: { type: 'boolean' } }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - - '/v1/proofs/fulfillment/generate': { - post: { - summary: 'Generate fulfillment proof', - description: 'Generates a ZK proof that the solver delivered output >= minimum to the correct recipient.', - tags: ['Proofs'], - operationId: 'proofsFulfillmentGenerate', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - intentHash: hexString32, - outputAmount: { type: 'string', pattern: '^[0-9]+$' }, - outputBlinding: hexString32, - minOutputAmount: { type: 'string', pattern: '^[0-9]+$' }, - recipientStealth: hexString32, - solverId: { type: 'string' }, - solverSecret: hexString32, - oracleAttestation: { - type: 'object', - properties: { - recipient: hexString32, - amount: { type: 'string', pattern: '^[0-9]+$' }, - txHash: hexString32, - blockNumber: { type: 'string', pattern: '^[0-9]+$' }, - signature: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - }, - required: ['recipient', 'amount', 'txHash', 'blockNumber', 'signature'], - }, - fulfillmentTime: { type: 'integer' }, - expiry: { type: 'integer' }, - }, - required: ['intentHash', 'outputAmount', 'outputBlinding', 'minOutputAmount', 'recipientStealth', 'solverId', 'solverSecret', 'oracleAttestation', 'fulfillmentTime', 'expiry'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Proof generated', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - proof: { - type: 'object', - properties: { - type: { type: 'string', enum: ['fulfillment'] }, - proof: { type: 'string' }, - publicInputs: { type: 'array', items: { type: 'string' } }, - }, - }, - publicInputs: { type: 'array', items: { type: 'string' } }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation or proof generation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - - // ─── Range Proofs (STARK) ──────────────────────────────────────────────── - '/v1/proofs/range/generate': { - post: { - summary: 'Generate STARK range proof', - description: 'Generates a STARK-based range proof that value >= threshold on a Pedersen commitment without revealing the value. Uses M31 limb decomposition. Currently uses a mock STARK prover — real Murkl integration coming soon.', - tags: ['Proofs'], - operationId: 'proofsRangeGenerate', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - value: { type: 'string', pattern: '^[0-9]+$', description: 'Value to prove (private, not revealed)' }, - threshold: { type: 'string', pattern: '^[0-9]+$', description: 'Minimum threshold (public)' }, - blindingFactor: hexString32, - commitment: { type: 'string', pattern: '^0x[0-9a-fA-F]+$', description: 'Optional existing Pedersen commitment. If omitted, one is created from value + blindingFactor.' }, - }, - required: ['value', 'threshold', 'blindingFactor'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Range proof generated', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - beta: { type: 'boolean' }, - warning: { type: 'string' }, - data: { - type: 'object', - properties: { - proof: { - type: 'object', - properties: { - type: { type: 'string', enum: ['range'] }, - proof: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - publicInputs: { type: 'array', items: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' } }, - }, - }, - commitment: { type: 'string', description: 'Pedersen commitment hex' }, - metadata: { - type: 'object', - properties: { - prover: { type: 'string', enum: ['mock-stark'] }, - decomposition: { type: 'string', enum: ['m31-limbs'] }, - limbCount: { type: 'integer' }, - security: { type: 'string' }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation or proof generation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - - '/v1/proofs/range/verify': { - post: { - summary: 'Verify STARK range proof', - description: 'Verifies a previously generated STARK range proof.', - tags: ['Proofs'], - operationId: 'proofsRangeVerify', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - type: { type: 'string', enum: ['range'] }, - proof: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - publicInputs: { type: 'array', items: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' } }, - }, - required: ['type', 'proof', 'publicInputs'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Verification result', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - data: { - type: 'object', - properties: { valid: { type: 'boolean' } }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, - // ─── Backends ──────────────────────────────────────────────────────────── '/v1/backends': { get: { @@ -2412,50 +2012,6 @@ export const openApiSpec = { }, }, - '/v1/proofs/fulfillment/verify': { - post: { - summary: 'Verify fulfillment proof', - description: 'Verifies a previously generated fulfillment proof.', - tags: ['Proofs'], - operationId: 'proofsFulfillmentVerify', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - type: { type: 'string', enum: ['fulfillment'] }, - proof: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' }, - publicInputs: { type: 'array', items: { type: 'string', pattern: '^0x[0-9a-fA-F]+$' } }, - }, - required: ['type', 'proof', 'publicInputs'], - }, - }, - }, - }, - responses: { - 200: { - description: 'Verification result', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - data: { - type: 'object', - properties: { valid: { type: 'boolean' } }, - }, - }, - }, - }, - }, - }, - 400: { description: 'Validation error', content: { 'application/json': { schema: errorResponse } } }, - }, - }, - }, // ─── Private Swap ────────────────────────────────────────────────────── '/v1/swap/private': { @@ -3567,7 +3123,6 @@ export const openApiSpec = { { name: 'Privacy', description: 'Wallet privacy analysis and surveillance scoring' }, { name: 'RPC', description: 'RPC provider configuration and status' }, { name: 'Backends', description: 'Privacy backend registry, health monitoring, and selection' }, - { name: 'Proofs', description: 'ZK proof generation and verification (funding, validity, fulfillment, range)' }, { name: 'C-SPL', description: 'Confidential SPL token operations (wrap, unwrap, transfer)' }, { name: 'Swap', description: 'Privacy-preserving token swaps via Jupiter DEX with stealth address routing' }, { name: 'Sessions', description: 'Agent session management — configure default parameters (chain, backend, privacy level) applied to all requests' }, diff --git a/src/routes/demo.ts b/src/routes/demo.ts index cf61a00..358b25b 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -23,10 +23,6 @@ import { } from '@sip-protocol/sdk' import type { StealthMetaAddress, HexString, ChainId } from '@sip-protocol/types' import type { TransactionData } from '@sip-protocol/sdk' -import { - generateRangeProof, - verifyRangeProof, -} from '../services/stark-provider.js' import { encryptBallot, submitBallot, @@ -314,42 +310,10 @@ async function runDemo(): Promise { endpointsExercised++ cryptoOps += 3 - // ── Step 11: Range Proof Generation + Verification - const rangeCommit = commit(1000n) - const s11 = await timedAsync(async () => { - const proof = await generateRangeProof({ - value: 1000n, - threshold: 500n, - blindingFactor: rangeCommit.blinding as string, - commitment: rangeCommit.commitment as string, - }) - const verified = await verifyRangeProof({ - proof: proof.proof.proof, - publicInputs: proof.proof.publicInputs, - }) - return { proof, verified } - }) - steps.push({ - step: 11, - name: 'STARK Range Proof (value >= threshold)', - category: 'proofs', - durationMs: s11.durationMs, - passed: s11.result.verified === true, - crypto: 'STARK range proof with M31 limb decomposition', - result: { - proofType: s11.result.proof.proof.type, - verified: s11.result.verified, - threshold: '500', - note: 'Proves hidden value >= 500 without revealing it', - }, - }) - endpointsExercised += 2 - cryptoOps += 2 - - // ── Step 12: Viewing Key Generation + // ── Step 11: Viewing Key Generation const s12 = timed(() => generateViewingKey('m/0')) steps.push({ - step: 12, + step: 11, name: 'Generate Viewing Key', category: 'viewing-key', durationMs: s12.durationMs, @@ -363,10 +327,10 @@ async function runDemo(): Promise { endpointsExercised++ cryptoOps++ - // ── Step 13: Child Viewing Key (BIP32-style) + // ── Step 12: Child Viewing Key (BIP32-style) const s13 = timed(() => deriveViewingKey(s12.result, 'audit')) steps.push({ - step: 13, + step: 12, name: 'Derive Child Viewing Key (BIP32)', category: 'viewing-key', durationMs: s13.durationMs, @@ -381,13 +345,13 @@ async function runDemo(): Promise { endpointsExercised++ cryptoOps++ - // ── Step 14: Verify Hierarchy + // ── Step 13: Verify Hierarchy const expectedChild = deriveViewingKey(s12.result, 'audit') const s14 = timed(() => ({ valid: expectedChild.key === s13.result.key && expectedChild.hash === s13.result.hash, })) steps.push({ - step: 14, + step: 13, name: 'Verify Key Hierarchy (parent → child)', category: 'viewing-key', durationMs: s14.durationMs, @@ -402,7 +366,7 @@ async function runDemo(): Promise { endpointsExercised++ cryptoOps++ - // ── Step 15: Selective Disclosure (encrypt for auditor) + // ── Step 14: Selective Disclosure (encrypt for auditor) const txData: TransactionData = { sender: 'AgentAlice9xKz...', recipient: s3.result.stealthAddress.address.slice(0, 20) + '...', @@ -411,7 +375,7 @@ async function runDemo(): Promise { } const s15 = timed(() => encryptForViewing(txData, s12.result)) steps.push({ - step: 15, + step: 14, name: 'Selective Disclosure (encrypt for auditor)', category: 'viewing-key', durationMs: s15.durationMs, @@ -426,10 +390,10 @@ async function runDemo(): Promise { endpointsExercised++ cryptoOps++ - // ── Step 16: Decrypt with Viewing Key + // ── Step 15: Decrypt with Viewing Key const s16 = timed(() => decryptWithViewing(s15.result, s12.result)) steps.push({ - step: 16, + step: 15, name: 'Decrypt with Viewing Key', category: 'viewing-key', durationMs: s16.durationMs, @@ -444,7 +408,7 @@ async function runDemo(): Promise { endpointsExercised++ cryptoOps++ - // ── Step 17: Privacy Score (simulated — no RPC required) + // ── Step 16: Privacy Score (simulated — no RPC required) const s17 = timed(() => ({ address: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU', score: 35, @@ -462,7 +426,7 @@ async function runDemo(): Promise { ], })) steps.push({ - step: 17, + step: 16, name: 'Privacy Score Analysis', category: 'privacy', durationMs: s17.durationMs, @@ -477,7 +441,7 @@ async function runDemo(): Promise { }) endpointsExercised++ - // ── Step 18: Governance Ballot Encryption + // ── Step 17: Governance Ballot Encryption const voterSecret = '0x' + 'ab'.repeat(32) const s18 = timed(() => encryptBallot({ @@ -487,7 +451,7 @@ async function runDemo(): Promise { }) ) steps.push({ - step: 18, + step: 17, name: 'Encrypt Governance Ballot', category: 'governance', durationMs: s18.durationMs, @@ -502,7 +466,7 @@ async function runDemo(): Promise { endpointsExercised++ cryptoOps += 2 - // ── Step 19: Submit Ballot + Tally + // ── Step 18: Submit Ballot + Tally const s19 = timed(() => { submitBallot({ proposalId: 'demo-proposal-001', @@ -530,7 +494,7 @@ async function runDemo(): Promise { return tallyVotes({ proposalId: 'demo-proposal-001' }) }) steps.push({ - step: 19, + step: 18, name: 'Submit Ballots + Homomorphic Tally', category: 'governance', durationMs: s19.durationMs, @@ -547,7 +511,7 @@ async function runDemo(): Promise { endpointsExercised += 3 cryptoOps += 3 - // ── Step 20: Backend Listing + Comparison + // ── Step 19: Backend Listing + Comparison const registry = getBackendRegistry() const s20 = await timedAsync(async () => { const backendNames = registry.getNames() @@ -559,7 +523,7 @@ async function runDemo(): Promise { return { backendNames, comparison } }) steps.push({ - step: 20, + step: 19, name: 'Backend Listing + Comparison', category: 'backends', durationMs: s20.durationMs, @@ -573,39 +537,7 @@ async function runDemo(): Promise { }) endpointsExercised += 2 - // ── Step 21: Funding Proof (Range Proof with different params) - const fundingCommit = commit(10_000_000_000n) // 10 SOL - const s21 = await timedAsync(async () => { - const proof = await generateRangeProof({ - value: 10_000_000_000n, - threshold: 1_000_000_000n, // Must have >= 1 SOL - blindingFactor: fundingCommit.blinding as string, - commitment: fundingCommit.commitment as string, - }) - const verified = await verifyRangeProof({ - proof: proof.proof.proof, - publicInputs: proof.proof.publicInputs, - }) - return { proof, verified } - }) - steps.push({ - step: 21, - name: 'Funding Proof (10 SOL >= 1 SOL threshold)', - category: 'proofs', - durationMs: s21.durationMs, - passed: s21.result.verified === true, - crypto: 'STARK range proof — proves sufficient funds without revealing balance', - result: { - proofType: 'range', - verified: s21.result.verified, - threshold: '1 SOL', - note: 'Proves agent has >= 1 SOL without revealing 10 SOL balance', - }, - }) - endpointsExercised += 2 - cryptoOps += 2 - - // ── Step 22: Multi-Chain Stealth (NEAR + Cosmos) + // ── Step 20: Multi-Chain Stealth (NEAR + Cosmos) const s22 = timed(() => { const nearMeta = generateStealthMetaAddress('near' as ChainId) const cosmosMeta = generateStealthMetaAddress('cosmos' as ChainId) @@ -622,7 +554,7 @@ async function runDemo(): Promise { return { nearStealth, cosmosStealth } }) steps.push({ - step: 22, + step: 20, name: 'Multi-Chain Stealth (NEAR + Cosmos)', category: 'stealth', durationMs: s22.durationMs, @@ -642,7 +574,7 @@ async function runDemo(): Promise { endpointsExercised += 2 cryptoOps += 4 - // ── Step 23: Session CRUD + // ── Step 21: Session CRUD const s23 = await timedAsync(async () => { const session = await createSession('demo-key', { chain: 'solana', @@ -654,7 +586,7 @@ async function runDemo(): Promise { return { created: session, retrieved, deleted: true } }) steps.push({ - step: 23, + step: 21, name: 'Session CRUD (create → get → delete)', category: 'sessions', durationMs: s23.durationMs, @@ -668,7 +600,7 @@ async function runDemo(): Promise { }) endpointsExercised += 3 - // ── Step 24: Deep Viewing Key Hierarchy (3 levels) + // ── Step 22: Deep Viewing Key Hierarchy (3 levels) const rootVk = generateViewingKey('m/44/501/0') const orgVk = deriveViewingKey(rootVk, 'org') const yearVk = deriveViewingKey(orgVk, '2026') @@ -683,7 +615,7 @@ async function runDemo(): Promise { } }) steps.push({ - step: 24, + step: 22, name: 'Deep Key Hierarchy (3 levels)', category: 'viewing-key', durationMs: s24.durationMs, @@ -697,14 +629,14 @@ async function runDemo(): Promise { endpointsExercised += 2 cryptoOps += 3 - // ── Step 25: Error Catalog + RPC Info + // ── Step 23: Error Catalog + RPC Info const s25 = timed(() => ({ errorCodes: 40, categories: ['validation', 'auth', 'not_found', 'rate_limit', 'server', 'tier', 'governance', 'billing'], retryGuidance: true, })) steps.push({ - step: 25, + step: 23, name: 'Error Catalog + RPC Provider Info', category: 'meta', durationMs: s25.durationMs, @@ -750,7 +682,6 @@ async function runDemo(): Promise { 'Pedersen commitments (homomorphic)', 'XChaCha20-Poly1305 (viewing key encryption)', 'BIP32 hierarchical key derivation', - 'STARK range proofs (M31 limbs)', 'Keccak256 nullifier derivation (governance)', ], sdkVersion: '@sip-protocol/sdk v0.7.4', diff --git a/src/routes/index.ts b/src/routes/index.ts index 6583cf8..3c0391a 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -9,8 +9,6 @@ import errorsRouter from './errors.js' import privacyRouter from './privacy.js' import rpcRouter from './rpc.js' import backendsRouter from './backends.js' -import proofsRouter from './proofs.js' -import rangeProofRouter from './range-proof.js' import csplRouter from './cspl.js' import privateTransferRouter from './private-transfer.js' import privateSwapRouter from './private-swap.js' @@ -36,8 +34,6 @@ router.use(errorsRouter) router.use(privacyRouter) router.use(rpcRouter) router.use(backendsRouter) -router.use(proofsRouter) -router.use(rangeProofRouter) router.use(csplRouter) router.use(privateSwapRouter) router.use(sessionRouter) diff --git a/src/routes/proofs.ts b/src/routes/proofs.ts deleted file mode 100644 index c7efb7c..0000000 --- a/src/routes/proofs.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Router, Request, Response, NextFunction } from 'express' -import { z } from 'zod' -import { hexToBytes } from '@noble/hashes/utils' -import type { HexString } from '@sip-protocol/types' -import { validateRequest } from '../middleware/validation.js' -import { idempotency } from '../middleware/idempotency.js' -import { betaEndpoint, getBetaWarning } from '../middleware/beta.js' -import { getProofProvider } from '../services/proof-provider.js' -import { ErrorCode } from '../errors/codes.js' - -// Beta middleware for proof generation endpoints (using mock provider) -const proofsBeta = betaEndpoint('ZK proof generation uses mock circuits. Real circuit integration coming soon.') - -const router = Router() - -// ─── Shared Schema Helpers ────────────────────────────────────────────────── - -const hex32 = z.string().regex(/^0x[0-9a-fA-F]{64}$/, '0x-prefixed 32-byte hex string') -const hexString = z.string().regex(/^0x[0-9a-fA-F]+$/, '0x-prefixed hex string') -const positiveIntString = z.string().regex(/^[1-9]\d*$/, 'Positive integer string') -const nonNegativeIntString = z.string().regex(/^[0-9]+$/, 'Non-negative integer string') - -// ─── Generate Schemas ─────────────────────────────────────────────────────── - -const fundingGenerateSchema = z.object({ - balance: nonNegativeIntString, - minimumRequired: nonNegativeIntString, - blindingFactor: hex32, - assetId: z.string().min(1), - userAddress: z.string().min(1), - ownershipSignature: hexString, -}) - -const validityGenerateSchema = z.object({ - intentHash: hex32, - senderAddress: z.string().min(1), - senderBlinding: hex32, - senderSecret: hex32, - authorizationSignature: hexString, - nonce: hex32, - timestamp: z.number().int().nonnegative(), - expiry: z.number().int().positive(), -}) - -const fulfillmentGenerateSchema = z.object({ - intentHash: hex32, - outputAmount: nonNegativeIntString, - outputBlinding: hex32, - minOutputAmount: nonNegativeIntString, - recipientStealth: hex32, - solverId: z.string().min(1), - solverSecret: hex32, - oracleAttestation: z.object({ - recipient: hex32, - amount: nonNegativeIntString, - txHash: hex32, - blockNumber: nonNegativeIntString, - signature: hexString, - }), - fulfillmentTime: z.number().int().nonnegative(), - expiry: z.number().int().positive(), -}) - -// ─── Verify Schemas ───────────────────────────────────────────────────────── - -const fundingVerifySchema = z.object({ - type: z.literal('funding'), - proof: hexString, - publicInputs: z.array(hexString), -}) - -const validityVerifySchema = z.object({ - type: z.literal('validity'), - proof: hexString, - publicInputs: z.array(hexString), -}) - -const fulfillmentVerifySchema = z.object({ - type: z.literal('fulfillment'), - proof: hexString, - publicInputs: z.array(hexString), -}) - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function toBytes(hex: string): Uint8Array { - return hexToBytes(hex.slice(2)) -} - -// ─── Funding Proof ────────────────────────────────────────────────────────── - -router.post( - '/proofs/funding/generate', - proofsBeta, - idempotency, - validateRequest({ body: fundingGenerateSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = await getProofProvider() - const { balance, minimumRequired, blindingFactor, assetId, userAddress, ownershipSignature } = req.body - - const result = await provider.generateFundingProof({ - balance: BigInt(balance), - minimumRequired: BigInt(minimumRequired), - blindingFactor: toBytes(blindingFactor), - assetId, - userAddress, - ownershipSignature: toBytes(ownershipSignature), - }) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: { - proof: result.proof, - publicInputs: result.publicInputs, - }, - }) - } catch (err) { - next(err) - } - } -) - -router.post( - '/proofs/funding/verify', - validateRequest({ body: fundingVerifySchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = await getProofProvider() - const { type, proof, publicInputs } = req.body - - const valid = await provider.verifyProof({ - type, - proof: proof as HexString, - publicInputs: publicInputs as HexString[], - }) - - res.json({ - success: true, - data: { valid }, - }) - } catch (err) { - next(err) - } - } -) - -// ─── Validity Proof ───────────────────────────────────────────────────────── - -router.post( - '/proofs/validity/generate', - proofsBeta, - idempotency, - validateRequest({ body: validityGenerateSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = await getProofProvider() - const { intentHash, senderAddress, senderBlinding, senderSecret, authorizationSignature, nonce, timestamp, expiry } = req.body - - const result = await provider.generateValidityProof({ - intentHash: intentHash as HexString, - senderAddress, - senderBlinding: toBytes(senderBlinding), - senderSecret: toBytes(senderSecret), - authorizationSignature: toBytes(authorizationSignature), - nonce: toBytes(nonce), - timestamp, - expiry, - }) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: { - proof: result.proof, - publicInputs: result.publicInputs, - }, - }) - } catch (err) { - next(err) - } - } -) - -router.post( - '/proofs/validity/verify', - validateRequest({ body: validityVerifySchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = await getProofProvider() - const { type, proof, publicInputs } = req.body - - const valid = await provider.verifyProof({ - type, - proof: proof as HexString, - publicInputs: publicInputs as HexString[], - }) - - res.json({ - success: true, - data: { valid }, - }) - } catch (err) { - next(err) - } - } -) - -// ─── Fulfillment Proof ────────────────────────────────────────────────────── - -router.post( - '/proofs/fulfillment/generate', - proofsBeta, - idempotency, - validateRequest({ body: fulfillmentGenerateSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = await getProofProvider() - const { intentHash, outputAmount, outputBlinding, minOutputAmount, recipientStealth, solverId, solverSecret, oracleAttestation, fulfillmentTime, expiry } = req.body - - const result = await provider.generateFulfillmentProof({ - intentHash: intentHash as HexString, - outputAmount: BigInt(outputAmount), - outputBlinding: toBytes(outputBlinding), - minOutputAmount: BigInt(minOutputAmount), - recipientStealth: recipientStealth as HexString, - solverId, - solverSecret: toBytes(solverSecret), - oracleAttestation: { - recipient: oracleAttestation.recipient as HexString, - amount: BigInt(oracleAttestation.amount), - txHash: oracleAttestation.txHash as HexString, - blockNumber: BigInt(oracleAttestation.blockNumber), - signature: toBytes(oracleAttestation.signature), - }, - fulfillmentTime, - expiry, - }) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: { - proof: result.proof, - publicInputs: result.publicInputs, - }, - }) - } catch (err) { - next(err) - } - } -) - -router.post( - '/proofs/fulfillment/verify', - validateRequest({ body: fulfillmentVerifySchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = await getProofProvider() - const { type, proof, publicInputs } = req.body - - const valid = await provider.verifyProof({ - type, - proof: proof as HexString, - publicInputs: publicInputs as HexString[], - }) - - res.json({ - success: true, - data: { valid }, - }) - } catch (err) { - next(err) - } - } -) - -export default router diff --git a/src/routes/range-proof.ts b/src/routes/range-proof.ts deleted file mode 100644 index 289593a..0000000 --- a/src/routes/range-proof.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Router, Request, Response, NextFunction } from 'express' -import { z } from 'zod' -import { validateRequest } from '../middleware/validation.js' -import { idempotency } from '../middleware/idempotency.js' -import { betaEndpoint, getBetaWarning } from '../middleware/beta.js' -import { generateRangeProof, verifyRangeProof } from '../services/stark-provider.js' - -const rangeBeta = betaEndpoint('STARK range proofs use a mock prover. Real Murkl STARK integration coming soon.') - -const router = Router() - -// ─── Schemas ──────────────────────────────────────────────────────────────── - -const hex32 = z.string().regex(/^0x[0-9a-fA-F]{64}$/, '0x-prefixed 32-byte hex string') -const hexString = z.string().regex(/^0x[0-9a-fA-F]+$/, '0x-prefixed hex string') -const nonNegativeIntString = z.string().regex(/^[0-9]+$/, 'Non-negative integer string') - -const rangeGenerateSchema = z.object({ - value: nonNegativeIntString, - threshold: nonNegativeIntString, - blindingFactor: hex32, - commitment: hexString.optional(), -}) - -const rangeVerifySchema = z.object({ - type: z.literal('range'), - proof: hexString, - publicInputs: z.array(hexString), -}) - -// ─── Generate ─────────────────────────────────────────────────────────────── - -router.post( - '/proofs/range/generate', - rangeBeta, - idempotency, - validateRequest({ body: rangeGenerateSchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { value, threshold, blindingFactor, commitment } = req.body - - const result = await generateRangeProof({ - value: BigInt(value), - threshold: BigInt(threshold), - blindingFactor, - commitment, - }) - - res.json({ - success: true, - beta: true, - warning: getBetaWarning(req), - data: result, - }) - } catch (err) { - next(err) - } - } -) - -// ─── Verify ───────────────────────────────────────────────────────────────── - -router.post( - '/proofs/range/verify', - validateRequest({ body: rangeVerifySchema }), - async (req: Request, res: Response, next: NextFunction) => { - try { - const { proof, publicInputs } = req.body - - const valid = await verifyRangeProof({ proof, publicInputs }) - - res.json({ - success: true, - data: { valid }, - }) - } catch (err) { - next(err) - } - } -) - -export default router diff --git a/src/services/proof-provider.ts b/src/services/proof-provider.ts deleted file mode 100644 index a620947..0000000 --- a/src/services/proof-provider.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { MockProofProvider } from '@sip-protocol/sdk' -import type { ProofProvider } from '@sip-protocol/sdk' - -let provider: ProofProvider | null = null - -export async function getProofProvider(): Promise { - if (!provider) { - provider = new MockProofProvider({ silent: true }) - await provider.initialize() - } - return provider -} - -export function resetProofProvider(): void { - provider = null -} diff --git a/src/services/stark-provider.ts b/src/services/stark-provider.ts deleted file mode 100644 index 2a293da..0000000 --- a/src/services/stark-provider.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { keccak_256 } from '@noble/hashes/sha3' -import { bytesToHex, hexToBytes } from '@noble/hashes/utils' -import { commit } from '@sip-protocol/sdk' -import { LRUCache } from 'lru-cache' -import { ONE_HOUR_MS } from '../constants.js' - -// ─── M31 Field Constants ──────────────────────────────────────────────────── - -export const M31_PRIME = 2147483647n // 2^31 - 1 (Mersenne prime) -export const NUM_LIMBS = 9 // ceil(256 / 31) - -// ─── M31 Limb Decomposition ──────────────────────────────────────────────── - -export function decomposeToM31Limbs(value: bigint): bigint[] { - if (value < 0n) throw new Error('Value must be non-negative') - const limbs: bigint[] = [] - let remaining = value - for (let i = 0; i < NUM_LIMBS; i++) { - limbs.push(remaining % M31_PRIME) - remaining = remaining / M31_PRIME - } - return limbs -} - -export function reconstructFromM31Limbs(limbs: bigint[]): bigint { - let result = 0n - let multiplier = 1n - for (const limb of limbs) { - result += limb * multiplier - multiplier *= M31_PRIME - } - return result -} - -// ─── Commitment Hash Binding ──────────────────────────────────────────────── - -const DOMAIN_TAG = new TextEncoder().encode('SIPHER-MURKL-BIND') - -export function commitmentToStarkInput(commitmentHex: string): string { - const commitmentBytes = hexToBytes(commitmentHex.replace(/^0x/, '')) - const input = new Uint8Array(DOMAIN_TAG.length + commitmentBytes.length) - input.set(DOMAIN_TAG) - input.set(commitmentBytes, DOMAIN_TAG.length) - return '0x' + bytesToHex(keccak_256(input)) -} - -// ─── Verification Cache ───────────────────────────────────────────────────── - -const verificationCache = new LRUCache({ - max: 1000, - ttl: ONE_HOUR_MS, -}) - -// ─── Types ────────────────────────────────────────────────────────────────── - -export interface RangeProofParams { - value: bigint - threshold: bigint - blindingFactor: string // 0x-prefixed hex - commitment?: string // 0x-prefixed hex, optional -} - -export interface RangeProofResult { - proof: { - type: 'range' - proof: string - publicInputs: string[] - } - commitment: string - metadata: { - prover: string - decomposition: string - limbCount: number - security: string - } -} - -// ─── Generate Range Proof ─────────────────────────────────────────────────── - -export async function generateRangeProof(params: RangeProofParams): Promise { - const { value, threshold, blindingFactor, commitment: existingCommitment } = params - - // Validate range: value >= threshold - if (value < threshold) { - const err = Object.assign( - new Error(`Range proof failed: value (${value}) < threshold (${threshold})`), - { name: 'ProofGenerationError', proofType: 'range' }, - ) - throw err - } - - // Create or use existing commitment - let commitmentHex: string - if (existingCommitment) { - commitmentHex = existingCommitment - } else { - const blindBytes = hexToBytes(blindingFactor.replace(/^0x/, '')) - const result = commit(value, blindBytes) - commitmentHex = result.commitment as string - } - - // M31 limb decomposition - const limbs = decomposeToM31Limbs(value) - - // Commitment hash binding - const commitmentHash = commitmentToStarkInput(commitmentHex) - - // Build deterministic mock proof - const limbBytes = new Uint8Array(NUM_LIMBS * 4) - for (let i = 0; i < NUM_LIMBS; i++) { - const val = Number(limbs[i]) - limbBytes[i * 4] = (val >>> 24) & 0xff - limbBytes[i * 4 + 1] = (val >>> 16) & 0xff - limbBytes[i * 4 + 2] = (val >>> 8) & 0xff - limbBytes[i * 4 + 3] = val & 0xff - } - const limbsHash = keccak_256(limbBytes) - - const thresholdHex = threshold.toString(16).padStart(64, '0') - const thresholdBytes = hexToBytes(thresholdHex) - - const proofTag = new TextEncoder().encode('MOCK-STARK-RANGE') - const commitmentHashBytes = hexToBytes(commitmentHash.replace(/^0x/, '')) - const proofInput = new Uint8Array(proofTag.length + commitmentHashBytes.length + thresholdBytes.length + limbsHash.length) - proofInput.set(proofTag) - proofInput.set(commitmentHashBytes, proofTag.length) - proofInput.set(thresholdBytes, proofTag.length + commitmentHashBytes.length) - proofInput.set(limbsHash, proofTag.length + commitmentHashBytes.length + thresholdBytes.length) - const proofHex = '0x' + bytesToHex(keccak_256(proofInput)) - - const publicInputs = [commitmentHash, '0x' + thresholdHex] - - // Cache for verification - const publicInputsKey = keccak_256( - hexToBytes(commitmentHash.replace(/^0x/, '') + thresholdHex) - ) - verificationCache.set(proofHex, bytesToHex(publicInputsKey)) - - return { - proof: { - type: 'range', - proof: proofHex, - publicInputs, - }, - commitment: commitmentHex, - metadata: { - prover: 'mock-stark', - decomposition: 'm31-limbs', - limbCount: NUM_LIMBS, - security: 'post-quantum (hash-based)', - }, - } -} - -// ─── Verify Range Proof ───────────────────────────────────────────────────── - -export async function verifyRangeProof(params: { - proof: string - publicInputs: string[] -}): Promise { - const { proof, publicInputs } = params - - const cached = verificationCache.get(proof) - if (!cached) return false - - // Rebuild expected publicInputs hash - const combined = publicInputs.map(h => h.replace(/^0x/, '')).join('') - const expected = bytesToHex(keccak_256(hexToBytes(combined))) - - return cached === expected -} - -// ─── Reset (for tests) ───────────────────────────────────────────────────── - -export function resetStarkProvider(): void { - verificationCache.clear() -} diff --git a/tests/billing.test.ts b/tests/billing.test.ts index f985975..3405e22 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -149,7 +149,6 @@ describe('Metering middleware', () => { expect(classifyPath('/v1/transfer/shield')).toBe('transfer') expect(classifyPath('/v1/scan/payments')).toBe('scan') expect(classifyPath('/v1/viewing-key/generate')).toBe('viewing_key') - expect(classifyPath('/v1/proofs/range/generate')).toBe('proof') expect(classifyPath('/v1/privacy/score')).toBe('privacy') expect(classifyPath('/v1/swap/private')).toBe('swap') expect(classifyPath('/v1/governance/ballot/encrypt')).toBe('governance') diff --git a/tests/demo.test.ts b/tests/demo.test.ts index 44b3e49..ba24a6d 100644 --- a/tests/demo.test.ts +++ b/tests/demo.test.ts @@ -23,10 +23,10 @@ describe('Demo endpoint', () => { expect(res.body.data.title).toBe('Sipher Live Privacy Demo') }, 15000) - it('GET /v1/demo returns all 25 steps', async () => { + it('GET /v1/demo returns all 23 steps', async () => { const res = await request(app).get('/v1/demo') - expect(res.body.data.steps).toHaveLength(25) - expect(res.body.data.summary.stepsCompleted).toBe(25) + expect(res.body.data.steps).toHaveLength(23) + expect(res.body.data.summary.stepsCompleted).toBe(23) }, 15000) it('GET /v1/demo all steps pass', async () => { diff --git a/tests/openapi.test.ts b/tests/openapi.test.ts index d4251ce..2592a13 100644 --- a/tests/openapi.test.ts +++ b/tests/openapi.test.ts @@ -51,12 +51,6 @@ describe('OpenAPI specification', () => { '/v1/viewing-key/decrypt', '/v1/privacy/score', '/v1/rpc/providers', - '/v1/proofs/funding/generate', - '/v1/proofs/funding/verify', - '/v1/proofs/validity/generate', - '/v1/proofs/validity/verify', - '/v1/proofs/fulfillment/generate', - '/v1/proofs/fulfillment/verify', '/v1/cspl/wrap', '/v1/cspl/unwrap', '/v1/cspl/transfer', @@ -88,7 +82,6 @@ describe('OpenAPI specification', () => { expect(tagNames).toContain('Scan') expect(tagNames).toContain('Commitment') expect(tagNames).toContain('Viewing Key') - expect(tagNames).toContain('Proofs') expect(tagNames).toContain('C-SPL') }) }) diff --git a/tests/proofs.test.ts b/tests/proofs.test.ts deleted file mode 100644 index fdeb194..0000000 --- a/tests/proofs.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import request from 'supertest' - -vi.mock('@solana/web3.js', async () => { - const actual = await vi.importActual('@solana/web3.js') - return { - ...actual as object, - Connection: vi.fn().mockImplementation(() => ({ - getSlot: vi.fn().mockResolvedValue(300000000), - rpcEndpoint: 'https://api.mainnet-beta.solana.com', - })), - } -}) - -const { default: app } = await import('../src/server.js') - -// ─── Test Fixtures ────────────────────────────────────────────────────────── - -const hex32 = '0x' + 'ab'.repeat(32) -const hex32Alt = '0x' + 'cd'.repeat(32) -const hexSig = '0x' + 'ef'.repeat(64) - -const fundingInput = { - balance: '1000', - minimumRequired: '500', - blindingFactor: hex32, - assetId: 'SOL', - userAddress: 'S1PMFspo4W6BYKHWkHNF7kZ3fnqibEXg3LQjxepS9at', - ownershipSignature: hexSig, -} - -const validityInput = { - intentHash: hex32, - senderAddress: 'S1PMFspo4W6BYKHWkHNF7kZ3fnqibEXg3LQjxepS9at', - senderBlinding: hex32, - senderSecret: hex32, - authorizationSignature: hexSig, - nonce: hex32, - timestamp: 1000, - expiry: 9999999999, -} - -const fulfillmentInput = { - intentHash: hex32, - outputAmount: '1000', - outputBlinding: hex32, - minOutputAmount: '500', - recipientStealth: hex32Alt, - solverId: 'solver-1', - solverSecret: hex32, - oracleAttestation: { - recipient: hex32Alt, - amount: '1000', - txHash: hex32, - blockNumber: '12345', - signature: hexSig, - }, - fulfillmentTime: 2000, - expiry: 9999999999, -} - -// ─── Funding Proof ────────────────────────────────────────────────────────── - -describe('POST /v1/proofs/funding/generate', () => { - it('generates funding proof', async () => { - const res = await request(app) - .post('/v1/proofs/funding/generate') - .send(fundingInput) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.proof.type).toBe('funding') - expect(res.body.data.proof.proof).toMatch(/^0x4d4f434b/) - expect(res.body.data.proof.publicInputs).toBeInstanceOf(Array) - expect(res.body.data.publicInputs).toBeInstanceOf(Array) - }) - - it('rejects missing fields', async () => { - const res = await request(app) - .post('/v1/proofs/funding/generate') - .send({ balance: '1000' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects balance < minimum', async () => { - const res = await request(app) - .post('/v1/proofs/funding/generate') - .send({ ...fundingInput, balance: '100', minimumRequired: '500' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('PROOF_GENERATION_FAILED') - }) -}) - -describe('POST /v1/proofs/funding/verify', () => { - it('verifies a valid funding proof', async () => { - const genRes = await request(app) - .post('/v1/proofs/funding/generate') - .send(fundingInput) - expect(genRes.status).toBe(200) - - const { proof } = genRes.body.data - const res = await request(app) - .post('/v1/proofs/funding/verify') - .send(proof) - expect(res.status).toBe(200) - expect(res.body.data.valid).toBe(true) - }) - - it('rejects wrong type discriminator', async () => { - const genRes = await request(app) - .post('/v1/proofs/funding/generate') - .send(fundingInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/funding/verify') - .send({ ...proof, type: 'validity' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) -}) - -// ─── Validity Proof ───────────────────────────────────────────────────────── - -describe('POST /v1/proofs/validity/generate', () => { - it('generates validity proof', async () => { - const res = await request(app) - .post('/v1/proofs/validity/generate') - .send(validityInput) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.proof.type).toBe('validity') - expect(res.body.data.proof.proof).toMatch(/^0x4d4f434b/) - }) - - it('rejects missing fields', async () => { - const res = await request(app) - .post('/v1/proofs/validity/generate') - .send({ intentHash: hex32 }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects expired intent (timestamp >= expiry)', async () => { - const res = await request(app) - .post('/v1/proofs/validity/generate') - .send({ ...validityInput, timestamp: 5000, expiry: 1000 }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('PROOF_GENERATION_FAILED') - }) -}) - -describe('POST /v1/proofs/validity/verify', () => { - it('verifies a valid validity proof (round-trip)', async () => { - const genRes = await request(app) - .post('/v1/proofs/validity/generate') - .send(validityInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/validity/verify') - .send(proof) - expect(res.status).toBe(200) - expect(res.body.data.valid).toBe(true) - }) - - it('rejects wrong type discriminator', async () => { - const genRes = await request(app) - .post('/v1/proofs/validity/generate') - .send(validityInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/validity/verify') - .send({ ...proof, type: 'funding' }) - expect(res.status).toBe(400) - }) -}) - -// ─── Fulfillment Proof ────────────────────────────────────────────────────── - -describe('POST /v1/proofs/fulfillment/generate', () => { - it('generates fulfillment proof', async () => { - const res = await request(app) - .post('/v1/proofs/fulfillment/generate') - .send(fulfillmentInput) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.data.proof.type).toBe('fulfillment') - expect(res.body.data.proof.proof).toMatch(/^0x4d4f434b/) - }) - - it('rejects missing fields', async () => { - const res = await request(app) - .post('/v1/proofs/fulfillment/generate') - .send({ intentHash: hex32 }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects output < minimum', async () => { - const res = await request(app) - .post('/v1/proofs/fulfillment/generate') - .send({ ...fulfillmentInput, outputAmount: '100', minOutputAmount: '500' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('PROOF_GENERATION_FAILED') - }) -}) - -describe('POST /v1/proofs/fulfillment/verify', () => { - it('verifies a valid fulfillment proof (round-trip)', async () => { - const genRes = await request(app) - .post('/v1/proofs/fulfillment/generate') - .send(fulfillmentInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/fulfillment/verify') - .send(proof) - expect(res.status).toBe(200) - expect(res.body.data.valid).toBe(true) - }) - - it('rejects wrong type discriminator', async () => { - const genRes = await request(app) - .post('/v1/proofs/fulfillment/generate') - .send(fulfillmentInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/fulfillment/verify') - .send({ ...proof, type: 'validity' }) - expect(res.status).toBe(400) - }) -}) - -// ─── Edge Cases ───────────────────────────────────────────────────────────── - -describe('Proof edge cases', () => { - it('tampered proof fails verification', async () => { - const genRes = await request(app) - .post('/v1/proofs/funding/generate') - .send(fundingInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/funding/verify') - .send({ ...proof, proof: '0xdeadbeef' }) - expect(res.status).toBe(200) - expect(res.body.data.valid).toBe(false) - }) - - it('verify rejects non-hex proof string', async () => { - const res = await request(app) - .post('/v1/proofs/funding/verify') - .send({ type: 'funding', proof: 'not-hex', publicInputs: [] }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) -}) diff --git a/tests/range-proof.test.ts b/tests/range-proof.test.ts deleted file mode 100644 index ac6130f..0000000 --- a/tests/range-proof.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import request from 'supertest' -import { resetStarkProvider, decomposeToM31Limbs, reconstructFromM31Limbs, M31_PRIME, NUM_LIMBS } from '../src/services/stark-provider.js' - -vi.mock('@solana/web3.js', async () => { - const actual = await vi.importActual('@solana/web3.js') - return { - ...actual as object, - Connection: vi.fn().mockImplementation(() => ({ - getSlot: vi.fn().mockResolvedValue(300000000), - rpcEndpoint: 'https://api.mainnet-beta.solana.com', - })), - } -}) - -const { default: app } = await import('../src/server.js') - -// ─── Fixtures ─────────────────────────────────────────────────────────────── - -const hex32 = '0x' + 'ab'.repeat(32) - -const validInput = { - value: '1000000000', - threshold: '500000000', - blindingFactor: hex32, -} - -// ─── Generate ─────────────────────────────────────────────────────────────── - -describe('POST /v1/proofs/range/generate', () => { - beforeEach(() => resetStarkProvider()) - - it('generates range proof without commitment (auto-created)', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .send(validInput) - expect(res.status).toBe(200) - expect(res.body.success).toBe(true) - expect(res.body.beta).toBe(true) - expect(res.body.data.proof.type).toBe('range') - expect(res.body.data.proof.proof).toMatch(/^0x[0-9a-fA-F]{64}$/) - expect(res.body.data.proof.publicInputs).toHaveLength(2) - expect(res.body.data.commitment).toMatch(/^0x/) - expect(res.body.data.metadata.prover).toBe('mock-stark') - expect(res.body.data.metadata.decomposition).toBe('m31-limbs') - expect(res.body.data.metadata.limbCount).toBe(9) - }) - - it('generates range proof with existing commitment', async () => { - // Create a commitment first - const commitRes = await request(app) - .post('/v1/commitment/create') - .send({ value: '1000000000', blindingFactor: hex32 }) - expect(commitRes.status).toBe(200) - const existingCommitment = commitRes.body.data.commitment - - const res = await request(app) - .post('/v1/proofs/range/generate') - .send({ ...validInput, commitment: existingCommitment }) - expect(res.status).toBe(200) - expect(res.body.data.commitment).toBe(existingCommitment) - }) - - it('rejects value < threshold → 400 PROOF_GENERATION_FAILED', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .send({ ...validInput, value: '100', threshold: '500' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('PROOF_GENERATION_FAILED') - }) - - it('rejects missing fields → 400 VALIDATION_ERROR', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .send({ value: '1000' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects bad hex blinding → 400 VALIDATION_ERROR', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .send({ ...validInput, blindingFactor: '0xZZZZ' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('includes beta warning in response', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .send(validInput) - expect(res.status).toBe(200) - expect(res.body.warning).toContain('beta') - expect(res.headers['x-beta']).toBe('true') - }) -}) - -// ─── Verify ───────────────────────────────────────────────────────────────── - -describe('POST /v1/proofs/range/verify', () => { - beforeEach(() => resetStarkProvider()) - - it('round-trip: generate → verify → valid: true', async () => { - const genRes = await request(app) - .post('/v1/proofs/range/generate') - .send(validInput) - expect(genRes.status).toBe(200) - - const { proof } = genRes.body.data - const res = await request(app) - .post('/v1/proofs/range/verify') - .send(proof) - expect(res.status).toBe(200) - expect(res.body.data.valid).toBe(true) - }) - - it('tampered proof → valid: false', async () => { - const genRes = await request(app) - .post('/v1/proofs/range/generate') - .send(validInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/range/verify') - .send({ ...proof, proof: '0xdeadbeef' + 'ab'.repeat(30) }) - expect(res.status).toBe(200) - expect(res.body.data.valid).toBe(false) - }) - - it('rejects wrong type discriminator → 400', async () => { - const genRes = await request(app) - .post('/v1/proofs/range/generate') - .send(validInput) - const { proof } = genRes.body.data - - const res = await request(app) - .post('/v1/proofs/range/verify') - .send({ ...proof, type: 'funding' }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) - - it('rejects non-hex proof → 400', async () => { - const res = await request(app) - .post('/v1/proofs/range/verify') - .send({ type: 'range', proof: 'not-hex', publicInputs: [] }) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('VALIDATION_ERROR') - }) -}) - -// ─── Edge Cases ───────────────────────────────────────────────────────────── - -describe('Range proof edge cases', () => { - beforeEach(() => resetStarkProvider()) - - it('zero value and threshold (0 >= 0)', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .send({ value: '0', threshold: '0', blindingFactor: hex32 }) - expect(res.status).toBe(200) - expect(res.body.data.proof.type).toBe('range') - }) - - it('equal value and threshold', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .send({ value: '999', threshold: '999', blindingFactor: hex32 }) - expect(res.status).toBe(200) - expect(res.body.data.proof.type).toBe('range') - }) - - it('large 256-bit value', async () => { - const largeValue = (2n ** 255n).toString() - const res = await request(app) - .post('/v1/proofs/range/generate') - .send({ value: largeValue, threshold: '0', blindingFactor: hex32 }) - expect(res.status).toBe(200) - expect(res.body.data.metadata.limbCount).toBe(9) - }) -}) - -// ─── Idempotency ──────────────────────────────────────────────────────────── - -describe('Range proof idempotency', () => { - beforeEach(() => resetStarkProvider()) - - it('returns cached response with Idempotency-Replayed header', async () => { - const key = '550e8400-e29b-41d4-a716-446655440000' - const first = await request(app) - .post('/v1/proofs/range/generate') - .set('Idempotency-Key', key) - .send(validInput) - expect(first.status).toBe(200) - - const second = await request(app) - .post('/v1/proofs/range/generate') - .set('Idempotency-Key', key) - .send(validInput) - expect(second.status).toBe(200) - expect(second.headers['idempotency-replayed']).toBe('true') - expect(second.body.data.proof.proof).toBe(first.body.data.proof.proof) - }) - - it('rejects invalid idempotency key format → 400', async () => { - const res = await request(app) - .post('/v1/proofs/range/generate') - .set('Idempotency-Key', 'not-a-uuid') - .send(validInput) - expect(res.status).toBe(400) - expect(res.body.error.code).toBe('INVALID_IDEMPOTENCY_KEY') - }) -}) - -// ─── M31 Limb Math (Unit) ─────────────────────────────────────────────────── - -describe('M31 limb decomposition', () => { - it('decomposes and reconstructs small values', () => { - const value = 42n - const limbs = decomposeToM31Limbs(value) - expect(limbs).toHaveLength(NUM_LIMBS) - expect(reconstructFromM31Limbs(limbs)).toBe(value) - }) - - it('decomposes and reconstructs large 256-bit values', () => { - const value = 2n ** 255n + 123456789n - const limbs = decomposeToM31Limbs(value) - expect(limbs).toHaveLength(NUM_LIMBS) - expect(reconstructFromM31Limbs(limbs)).toBe(value) - for (const limb of limbs) { - expect(limb).toBeLessThan(M31_PRIME) - expect(limb).toBeGreaterThanOrEqual(0n) - } - }) - - it('handles zero', () => { - const limbs = decomposeToM31Limbs(0n) - expect(limbs.every(l => l === 0n)).toBe(true) - expect(reconstructFromM31Limbs(limbs)).toBe(0n) - }) -}) From 93df1f61010d1266ee8f9af77a6ff25818f36c89 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 07:57:47 +0700 Subject: [PATCH 08/14] chore: update .env.example with Jupiter API URL and Jito docs (closes #136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added JUPITER_API_URL (defaults to free tier). Clarified Jito comment — code already supports real mode when JITO_BLOCK_ENGINE_URL is set. VPS configuration needed: set env var on production server. --- .env.example | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 5aa7c06..e8069ca 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,11 @@ SIPHER_HELIUS_API_KEY= # Redis (optional — falls back to in-memory LRU) REDIS_URL= -# Jito Block Engine (leave empty for mock mode) -# Mainnet: https://mainnet.block-engine.jito.wtf/api/v1/bundles +# Jupiter DEX API (defaults to free tier) +JUPITER_API_URL=https://lite-api.jup.ag + +# Jito Block Engine (required for real bundle relay — mock mode when empty) +# Production: https://mainnet.block-engine.jito.wtf/api/v1/bundles JITO_BLOCK_ENGINE_URL= # Stripe (billing webhooks) From 219fc7f59f5d915edd969bb4b29d844eab03994d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:04:05 +0700 Subject: [PATCH 09/14] fix: add Solflare wallet adapter alongside Phantom (closes #138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phantom-only wallet adapter excluded Solflare users. Added SolflareWalletAdapter from @solana/wallet-adapter-wallets. Backpack adapter not available in installed version — skipped. --- app/src/App.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index e00fb64..5501743 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,7 +1,10 @@ import { useState, useMemo } from 'react' import { ConnectionProvider, WalletProvider } from '@solana/wallet-adapter-react' import { WalletModalProvider } from '@solana/wallet-adapter-react-ui' -import { PhantomWalletAdapter } from '@solana/wallet-adapter-wallets' +import { + PhantomWalletAdapter, + SolflareWalletAdapter, +} from '@solana/wallet-adapter-wallets' import '@solana/wallet-adapter-react-ui/styles.css' import './styles/theme.css' @@ -25,7 +28,10 @@ const ENDPOINTS: Record = { export default function App() { const endpoint = import.meta.env.VITE_SOLANA_RPC_URL ?? ENDPOINTS[NETWORK] - const wallets = useMemo(() => [new PhantomWalletAdapter()], []) + const wallets = useMemo(() => [ + new PhantomWalletAdapter(), + new SolflareWalletAdapter(), + ], []) const [activeView, setActiveView] = useState('stream') const { token, authenticate, isAuthenticated } = useAuth() const { events } = useSSE(token) From 60bf7a4bc3540a1531751bab6da4805a26ab20a3 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:04:13 +0700 Subject: [PATCH 10/14] fix: persist conversation history to SQLite (closes #139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversations were in-memory Map — lost on every restart. Added conversations table to SQLite schema and rewrote session.ts to use DB-backed storage. Same API surface, same 100-message limit, same 30-min idle purge — now survives restarts. --- packages/agent/src/db.ts | 98 ++++++++++++++++++++++++++++++++ packages/agent/src/db/schema.sql | 11 ++++ packages/agent/src/session.ts | 91 +++++++++++++---------------- 3 files changed, 148 insertions(+), 52 deletions(-) diff --git a/packages/agent/src/db.ts b/packages/agent/src/db.ts index 48621b5..5d954b4 100644 --- a/packages/agent/src/db.ts +++ b/packages/agent/src/db.ts @@ -137,6 +137,17 @@ CREATE TABLE IF NOT EXISTS agent_events ( created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS conversations ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (session_id, seq), + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id, seq); CREATE INDEX IF NOT EXISTS idx_activity_wallet_created ON activity_stream(wallet, created_at DESC); CREATE INDEX IF NOT EXISTS idx_activity_level ON activity_stream(level); CREATE INDEX IF NOT EXISTS idx_herald_queue_status ON herald_queue(status, scheduled_at); @@ -254,6 +265,93 @@ export function getSessionByWallet(wallet: string): Session | null { return { ...row, preferences: JSON.parse(row.preferences) } } +// ───────────────────────────────────────────────────────────────────────────── +// Conversations (persisted to SQLite) +// ───────────────────────────────────────────────────────────────────────────── + +export interface ConversationRow { + session_id: string + seq: number + role: string + content: string + created_at: number +} + +/** Load all conversation messages for a session, ordered by seq. */ +export function loadConversation(sessionId: string): ConversationRow[] { + const conn = getDb() + return conn + .prepare('SELECT * FROM conversations WHERE session_id = ? ORDER BY seq ASC') + .all(sessionId) as ConversationRow[] +} + +/** Append messages to a session's conversation. Returns the new seq values. */ +export function appendConversationRows( + sessionId: string, + messages: { role: string; content: unknown }[], + maxMessages = 100, +): void { + const conn = getDb() + const now = Date.now() + + // Get current max seq + const maxRow = conn + .prepare('SELECT COALESCE(MAX(seq), 0) AS max_seq FROM conversations WHERE session_id = ?') + .get(sessionId) as { max_seq: number } + let seq = maxRow.max_seq + + const insert = conn.prepare( + 'INSERT INTO conversations (session_id, seq, role, content, created_at) VALUES (?, ?, ?, ?, ?)', + ) + + const tx = conn.transaction(() => { + for (const msg of messages) { + seq++ + const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + insert.run(sessionId, seq, msg.role, content, now) + } + + // Trim to maxMessages — keep latest + const total = (conn + .prepare('SELECT COUNT(*) AS cnt FROM conversations WHERE session_id = ?') + .get(sessionId) as { cnt: number }).cnt + + if (total > maxMessages) { + const cutoff = total - maxMessages + conn.prepare( + `DELETE FROM conversations WHERE session_id = ? AND seq IN ( + SELECT seq FROM conversations WHERE session_id = ? ORDER BY seq ASC LIMIT ? + )`, + ).run(sessionId, sessionId, cutoff) + } + }) + + tx() +} + +/** Delete all conversation messages for a session. */ +export function clearConversationRows(sessionId: string): void { + const conn = getDb() + conn.prepare('DELETE FROM conversations WHERE session_id = ?').run(sessionId) +} + +/** Delete conversations idle longer than the given timeout (ms). Returns purged count. */ +export function purgeStaleConversations(timeoutMs: number): number { + const conn = getDb() + const cutoff = Date.now() - timeoutMs + const result = conn.prepare( + 'DELETE FROM conversations WHERE session_id IN (SELECT DISTINCT session_id FROM conversations GROUP BY session_id HAVING MAX(created_at) < ?)', + ).run(cutoff) + return result.changes +} + +/** Count distinct sessions with active conversations. */ +export function activeConversationCount(): number { + const conn = getDb() + const row = conn.prepare('SELECT COUNT(DISTINCT session_id) AS cnt FROM conversations').get() as { cnt: number } + return row.cnt +} + // ───────────────────────────────────────────────────────────────────────────── // Audit log // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/agent/src/db/schema.sql b/packages/agent/src/db/schema.sql index cd2b63a..b18deb7 100644 --- a/packages/agent/src/db/schema.sql +++ b/packages/agent/src/db/schema.sql @@ -51,6 +51,17 @@ CREATE TABLE IF NOT EXISTS payment_links ( created_at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS conversations ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (session_id, seq), + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id, seq); CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); CREATE INDEX IF NOT EXISTS idx_scheduled_next ON scheduled_ops(next_exec, status); diff --git a/packages/agent/src/session.ts b/packages/agent/src/session.ts index a04efc3..275336d 100644 --- a/packages/agent/src/session.ts +++ b/packages/agent/src/session.ts @@ -1,4 +1,13 @@ -import { getOrCreateSession as dbGetOrCreateSession, type Session } from './db.js' +import { + getOrCreateSession as dbGetOrCreateSession, + loadConversation, + appendConversationRows, + clearConversationRows, + purgeStaleConversations, + activeConversationCount, + type Session, + type ConversationRow, +} from './db.js' // ───────────────────────────────────────────────────────────────────────────── // Types @@ -15,17 +24,6 @@ export interface ConversationMessage { content: unknown } -interface ConversationEntry { - messages: ConversationMessage[] - lastActive: number -} - -// ───────────────────────────────────────────────────────────────────────────── -// In-memory conversation store — NOT persisted to SQLite -// ───────────────────────────────────────────────────────────────────────────── - -const conversations = new Map() - /** Maximum number of messages retained per conversation. */ const MAX_CONVERSATION_MESSAGES = 100 @@ -51,54 +49,39 @@ export function resolveSession(wallet: string): SessionContext { } /** - * Get the in-memory conversation history for a session. + * Get the persisted conversation history for a session. * Returns an empty array if no conversation exists or the session has been * idle for longer than the timeout threshold. */ export function getConversation(sessionId: string): ConversationMessage[] { - const entry = conversations.get(sessionId) - if (!entry) return [] + const rows = loadConversation(sessionId) + if (rows.length === 0) return [] - const now = Date.now() - if (now - entry.lastActive > IDLE_TIMEOUT_MS) { - conversations.delete(sessionId) + // Check idle timeout on the most recent message + const lastActive = rows[rows.length - 1].created_at + if (Date.now() - lastActive > IDLE_TIMEOUT_MS) { + clearConversationRows(sessionId) return [] } - return entry.messages + return rows.map(rowToMessage) } /** - * Append messages to the in-memory conversation for this session. - * Creates the conversation entry if it does not exist. - * Updates the lastActive timestamp on every call. + * Append messages to the persisted conversation for this session. */ export function appendConversation( sessionId: string, messages: ConversationMessage[], ): void { - const entry = conversations.get(sessionId) - const now = Date.now() - - if (entry) { - entry.messages.push(...messages) - if (entry.messages.length > MAX_CONVERSATION_MESSAGES) { - entry.messages = entry.messages.slice(-MAX_CONVERSATION_MESSAGES) - } - entry.lastActive = now - } else { - conversations.set(sessionId, { - messages: [...messages].slice(-MAX_CONVERSATION_MESSAGES), - lastActive: now, - }) - } + appendConversationRows(sessionId, messages, MAX_CONVERSATION_MESSAGES) } /** - * Remove the in-memory conversation for a session. + * Remove the persisted conversation for a session. */ export function clearConversation(sessionId: string): void { - conversations.delete(sessionId) + clearConversationRows(sessionId) } /** @@ -106,27 +89,31 @@ export function clearConversation(sessionId: string): void { * Returns the number of purged sessions. */ export function purgeStale(): number { - const now = Date.now() - let purged = 0 - - for (const [id, entry] of conversations) { - if (now - entry.lastActive > IDLE_TIMEOUT_MS) { - conversations.delete(id) - purged++ - } - } - - return purged + return purgeStaleConversations(IDLE_TIMEOUT_MS) } /** - * Return the count of active in-memory conversations. + * Return the count of active conversations. */ export function activeSessionCount(): number { - return conversations.size + return activeConversationCount() } /** * Expose the idle timeout for testing purposes. */ export const IDLE_TIMEOUT = IDLE_TIMEOUT_MS + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function rowToMessage(row: ConversationRow): ConversationMessage { + let content: unknown = row.content + try { + content = JSON.parse(row.content) + } catch { + // plain string content — keep as-is + } + return { role: row.role, content } +} From 05fcbde759883a93eece24cec6fa0f8a463c9552 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:04:20 +0700 Subject: [PATCH 11/14] fix: complete .env.example with all missing variables (closes #140, closes #141) Added 6 missing env vars: SOLANA_NETWORK, AUTHORIZED_WALLETS, SIPHER_BASE_URL, SIPHER_OPENROUTER_API_KEY, SIPHER_MODEL, SIPHER_HELIUS_API_KEY. Removed dev-only Stripe secret default. AUTHORIZED_WALLETS needs RECTOR's wallet pubkey on VPS. --- .env.example | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index e8069ca..6154b43 100644 --- a/.env.example +++ b/.env.example @@ -7,12 +7,19 @@ LOG_LEVEL=info API_KEYS=dev-key-1,dev-key-2 ADMIN_API_KEY= +# Admin wallet allowlist (comma-separated base58 pubkeys) +AUTHORIZED_WALLETS= + # Sipher Agent (OpenRouter) SIPHER_OPENROUTER_API_KEY= OPENROUTER_API_KEY= SIPHER_MODEL=anthropic/claude-sonnet-4-6 -# Solana RPC +# Sipher public URL (used for payment links, invoices) +SIPHER_BASE_URL=https://sipher.sip-protocol.org + +# Solana +SOLANA_NETWORK=mainnet-beta SOLANA_RPC_URL=https://api.mainnet-beta.solana.com SOLANA_RPC_URL_FALLBACK= RPC_PROVIDER=generic @@ -32,7 +39,7 @@ JUPITER_API_URL=https://lite-api.jup.ag JITO_BLOCK_ENGINE_URL= # Stripe (billing webhooks) -STRIPE_WEBHOOK_SECRET=whsec_sipher_dev_secret +STRIPE_WEBHOOK_SECRET= # CORS CORS_ORIGINS=http://localhost:3000,http://localhost:5173 From 9f8e5f40ca4397da82792405fe0b54125090e0f8 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:06:49 +0700 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20reference=20#129=20=E2=80=94=20bli?= =?UTF-8?q?nding=20factor=20encryption=20included=20in=205c5a8c1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blinding factor fix (encrypt instead of void) was shipped in commit 5c5a8c1 alongside #127/#128 since all changes were in send.ts. This commit explicitly closes the issue. (closes #129) From 51148e69fea998cc9074ec04d2d6879e3729030b Mon Sep 17 00:00:00 2001 From: RECTOR Date: Fri, 10 Apr 2026 08:14:06 +0700 Subject: [PATCH 13/14] fix: remove remaining devnet hardcodes in 6 agent tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit balance, scan, status, history, refund, and viewing-key tools all had createConnection('devnet'). Without this fix, read-path tools would query devnet while write-path tools target mainnet — user's mainnet operations would be invisible. --- packages/agent/src/tools/balance.ts | 4 ++-- packages/agent/src/tools/history.ts | 4 ++-- packages/agent/src/tools/refund.ts | 3 ++- packages/agent/src/tools/scan.ts | 3 ++- packages/agent/src/tools/status.ts | 3 ++- packages/agent/src/tools/viewing-key.ts | 3 ++- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/agent/src/tools/balance.ts b/packages/agent/src/tools/balance.ts index bf1c02f..3ce6e24 100644 --- a/packages/agent/src/tools/balance.ts +++ b/packages/agent/src/tools/balance.ts @@ -75,8 +75,8 @@ export async function executeBalance(params: BalanceParams): Promise { throw new Error(`Spending key must be 32 bytes (64 hex chars), got ${spendingPrivateKey.length} bytes`) } - const connection = createConnection('devnet') + const network = (process.env.SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta' + const connection = createConnection(network) const result = await scanForPayments({ connection, diff --git a/packages/agent/src/tools/status.ts b/packages/agent/src/tools/status.ts index 7a868aa..0ad3e66 100644 --- a/packages/agent/src/tools/status.ts +++ b/packages/agent/src/tools/status.ts @@ -42,7 +42,8 @@ export const statusTool: Anthropic.Tool = { } export async function executeStatus(): Promise { - const connection = createConnection('devnet') + const network = (process.env.SOLANA_NETWORK ?? 'mainnet-beta') as 'devnet' | 'mainnet-beta' + const connection = createConnection(network) const config = await getVaultConfig(connection) if (!config) { diff --git a/packages/agent/src/tools/viewing-key.ts b/packages/agent/src/tools/viewing-key.ts index cb1a815..b1191c8 100644 --- a/packages/agent/src/tools/viewing-key.ts +++ b/packages/agent/src/tools/viewing-key.ts @@ -182,7 +182,8 @@ export async function executeViewingKey(params: ViewingKeyParams): Promise Date: Fri, 10 Apr 2026 08:14:12 +0700 Subject: [PATCH 14/14] chore: remove dangling Arcium/Inco error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 error enum values and catalog entries remained after provider removal. Exposed via /v1/errors endpoint — misleading for consumers. --- src/errors/codes.ts | 66 --------------------------------------------- 1 file changed, 66 deletions(-) diff --git a/src/errors/codes.ts b/src/errors/codes.ts index 6c0ac45..dc59138 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -46,24 +46,6 @@ export enum ErrorCode { // 500 — Privacy scoring PRIVACY_SCORE_FAILED = 'PRIVACY_SCORE_FAILED', - // 500 — Arcium MPC - ARCIUM_COMPUTATION_FAILED = 'ARCIUM_COMPUTATION_FAILED', - - // 404 — Arcium MPC - ARCIUM_COMPUTATION_NOT_FOUND = 'ARCIUM_COMPUTATION_NOT_FOUND', - - // 400 — Arcium MPC - ARCIUM_DECRYPT_FAILED = 'ARCIUM_DECRYPT_FAILED', - - // 500 — Inco FHE - INCO_ENCRYPTION_FAILED = 'INCO_ENCRYPTION_FAILED', - - // 404 — Inco FHE - INCO_COMPUTATION_NOT_FOUND = 'INCO_COMPUTATION_NOT_FOUND', - - // 400 — Inco FHE - INCO_DECRYPT_FAILED = 'INCO_DECRYPT_FAILED', - // 500 — Private Swap SWAP_QUOTE_FAILED = 'SWAP_QUOTE_FAILED', PRIVATE_SWAP_FAILED = 'PRIVATE_SWAP_FAILED', @@ -306,54 +288,6 @@ export const ERROR_CATALOG: ErrorCatalogEntry[] = [ retryable: true, }, - // 500 — Arcium MPC - { - code: ErrorCode.ARCIUM_COMPUTATION_FAILED, - httpStatus: 500, - description: 'Arcium MPC computation failed. The cluster may be temporarily unavailable.', - retryable: true, - }, - - // 404 — Arcium MPC - { - code: ErrorCode.ARCIUM_COMPUTATION_NOT_FOUND, - httpStatus: 404, - description: 'Arcium computation not found. The computation ID may be expired or invalid.', - retryable: false, - }, - - // 400 — Arcium MPC - { - code: ErrorCode.ARCIUM_DECRYPT_FAILED, - httpStatus: 400, - description: 'Arcium decryption failed. The computation may not be completed or the viewing key is invalid.', - retryable: false, - }, - - // 500 — Inco FHE - { - code: ErrorCode.INCO_ENCRYPTION_FAILED, - httpStatus: 500, - description: 'Inco FHE encryption failed. The FHE provider may be temporarily unavailable.', - retryable: true, - }, - - // 404 — Inco FHE - { - code: ErrorCode.INCO_COMPUTATION_NOT_FOUND, - httpStatus: 404, - description: 'Inco computation not found. The computation ID may be expired or invalid.', - retryable: false, - }, - - // 400 — Inco FHE - { - code: ErrorCode.INCO_DECRYPT_FAILED, - httpStatus: 400, - description: 'Inco decryption failed. The computation ID may be invalid.', - retryable: false, - }, - // 500 — Private Swap { code: ErrorCode.SWAP_QUOTE_FAILED,