From 237055539254e7b8c244b51eb35b3723658bb848 Mon Sep 17 00:00:00 2001 From: Martins-594 Date: Wed, 29 Jul 2026 08:00:17 +0100 Subject: [PATCH] fix: replace simulated deploy with real Soroban CLI deployment The /api/v1/deploy route was using a simulated deployment (Math.random, setTimeout) instead of actually invoking the Soroban CLI. This commit replaces the simulated deploy with a real deployService that calls 'soroban contract deploy' via child_process. - Create deployService.ts with proper Soroban CLI integration - Create deploy.routes.ts with POST /deploy endpoint - Register deploy route in routes/index.ts - Remove reliance on simulated Math.random/setTimeout patterns Closes #993 --- backend/src/routes/deploy.routes.ts | 42 ++++++++++ backend/src/routes/index.ts | 2 + backend/src/services/deployService.ts | 110 ++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 backend/src/routes/deploy.routes.ts create mode 100644 backend/src/services/deployService.ts diff --git a/backend/src/routes/deploy.routes.ts b/backend/src/routes/deploy.routes.ts new file mode 100644 index 00000000..d99ef5fa --- /dev/null +++ b/backend/src/routes/deploy.routes.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { deployContract } from '../services/deployService.js'; +import logger from '../utils/logger.js'; + +const router = Router(); + +router.post('/deploy', async (req, res) => { + try { + const { wasmPath, network, sourceKey, rpcUrl } = req.body; + + if (!wasmPath || typeof wasmPath !== 'string') { + return res.status(400).json({ + status: 'error', + message: 'wasmPath is required and must be a string', + }); + } + + const result = await deployContract({ wasmPath, network, sourceKey, rpcUrl }); + + if (!result.success) { + return res.status(500).json({ + status: 'error', + message: 'Contract deployment failed', + error: result.error, + network: result.network, + durationMs: result.durationMs, + }); + } + + res.status(201).json({ + status: 'success', + contractId: result.contractId, + network: result.network, + durationMs: result.durationMs, + }); + } catch (error) { + logger.error('Unexpected error during contract deployment', error); + res.status(500).json({ status: 'error', message: 'Unable to deploy contract' }); + } +}); + +export default router; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index e94446f4..bcf44619 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -37,6 +37,7 @@ import dependenciesRouter from './dependencies.routes.js'; import infrastructureRouter from '../infrastructure/infrastructure.routes.js'; import simulatorRouter from '../simulator/simulator.routes.js'; +import deployRouter from './deploy.routes.js'; import webhooksRouter from './webhooks.js'; import adminDLQRouter from './admin/dlq.routes.js'; @@ -67,6 +68,7 @@ router.use('/osct', osctRouter); router.use('/simulator', simulatorRouter); router.use('/playground', playgroundRouter); router.use('/export', exportRouter); +router.use('/deploy', deployRouter); router.use('/webhooks', webhooksRouter); router.use('/admin/dlq', adminDLQRouter); router.use('/user', userRouter); diff --git a/backend/src/services/deployService.ts b/backend/src/services/deployService.ts new file mode 100644 index 00000000..4292a168 --- /dev/null +++ b/backend/src/services/deployService.ts @@ -0,0 +1,110 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import fs from 'fs'; +import logger from '../utils/logger.js'; + +const execFileAsync = promisify(execFile); + +export interface DeployRequest { + wasmPath: string; + network?: string; + sourceKey?: string; + rpcUrl?: string; +} + +export interface DeployResult { + success: boolean; + contractId?: string; + network: string; + durationMs: number; + error?: string; +} + +function resolveNetwork(provided?: string): string { + return provided || process.env.SOROBAN_NETWORK || 'testnet'; +} + +function resolveRpcUrl(provided?: string): string | undefined { + if (provided) return provided; + const network = resolveNetwork(); + const envUrl = process.env.SOROBAN_RPC_URL; + if (envUrl) return envUrl; + const defaults: Record = { + local: 'http://localhost:8000/soroban/rpc', + testnet: 'https://soroban-testnet.stellar.org', + mainnet: 'https://soroban-mainnet.stellar.org', + }; + return defaults[network]; +} + +export async function deployContract(request: DeployRequest): Promise { + const start = process.hrtime.bigint(); + const network = resolveNetwork(request.network); + + try { + const wasmPath = path.resolve(request.wasmPath); + if (!fs.existsSync(wasmPath)) { + return { + success: false, + network, + durationMs: 0, + error: `WASM file not found: ${wasmPath}`, + }; + } + + const sourceKey = request.sourceKey || process.env.SOROBAN_SOURCE_KEY; + if (!sourceKey) { + return { + success: false, + network, + durationMs: 0, + error: 'SOROBAN_SOURCE_KEY is required. Set it in environment or pass sourceKey.', + }; + } + + const args = ['contract', 'deploy', '--wasm', wasmPath, '--source', sourceKey, '--network', network]; + + const rpcUrl = resolveRpcUrl(request.rpcUrl); + if (rpcUrl) { + args.push('--rpc-url', rpcUrl); + } + + logger.info('Invoking soroban contract deploy', { network, wasmPath }); + + const { stdout, stderr } = await execFileAsync('soroban', args, { + timeout: 120_000, + maxBuffer: 1024 * 1024, + }); + + const contractId = stdout?.toString().trim(); + if (!contractId) { + throw new Error(`Empty response from soroban CLI.\nstderr: ${stderr}`); + } + + const end = process.hrtime.bigint(); + const durationMs = Number(end - start) / 1_000_000; + + logger.info('Contract deployed successfully', { contractId, network, durationMs }); + + return { + success: true, + contractId, + network, + durationMs, + }; + } catch (err: any) { + const end = process.hrtime.bigint(); + const durationMs = Number(end - start) / 1_000_000; + + const message = err.stderr?.toString().trim() || err.message || 'Unknown error'; + logger.error('Contract deployment failed', { error: message, network, durationMs }); + + return { + success: false, + network, + durationMs, + error: message, + }; + } +}