#!/usr/bin/env node /** * Forge Verification Proxy — bridges Forge to Blockscout * * Strategy 1: Forward to Etherscan-compatible API with module/action in query * Strategy 2 (fallback): Forward to Blockscout v2 API * * Forge sends: POST with JSON { contractaddress, sourceCode, codeformat, ... } * Blockscout Etherscan API expects: ?module=contract&action=verifysourcecode (in URL) * * Usage: BLOCKSCOUT_URL=http://192.168.11.140:4000 node server.js * Forge: --verifier-url "http://localhost:3080/" */ import http from 'node:http'; const PORT = parseInt(process.env.PORT || '3080', 10); const BLOCKSCOUT_URL = (process.env.BLOCKSCOUT_URL || 'http://192.168.11.140:4000').replace(/\/$/, ''); /** Parse body as JSON or application/x-www-form-urlencoded (Forge/Etherscan style). */ function parseBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { if (!body || !body.trim()) { resolve({}); return; } const contentType = (req.headers['content-type'] || '').toLowerCase(); if (contentType.includes('application/x-www-form-urlencoded')) { try { const params = new URLSearchParams(body); const payload = {}; for (const [k, v] of params) { if (v !== undefined && v !== '') payload[k] = v; } resolve(payload); } catch (e) { reject(e); } return; } try { resolve(JSON.parse(body)); } catch (e) { reject(e); } }); req.on('error', reject); }); } function send(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } /** * Forward to Blockscout Etherscan API with module/action in query. * Same JSON body, but URL includes required params. */ async function forwardEtherscanFormat(payload) { const query = new URLSearchParams({ module: 'contract', action: 'verifysourcecode' }); const path = `/api/?${query}`; // Blockscout's Etherscan-compatible endpoint expects classic form fields, not JSON. // Keep the Forge payload keys, but serialize them as application/x-www-form-urlencoded. const form = new URLSearchParams(); for (const [key, value] of Object.entries(payload)) { if (value === undefined || value === null || value === '') continue; form.set(key, String(value)); } const body = form.toString(); const url = new URL(path, BLOCKSCOUT_URL); return new Promise((resolve, reject) => { const req = http.request( { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body), Host: url.hostname + (url.port ? ':' + url.port : ''), }, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {}, raw: data }); } catch { resolve({ status: res.statusCode, data: null, raw: data }); } }); } ); req.on('error', reject); req.write(body); req.end(); }); } /** * Forward to Blockscout v2 flattened-code verification API. */ async function forwardV2Flattened(payload) { const addr = payload.contractaddress || payload.contractAddress; const sourceCode = payload.sourceCode ?? payload.source_code; const codeformat = (payload.codeformat || '').toLowerCase(); const isStandardJson = codeformat === 'solidity-standard-json-input' || (typeof sourceCode === 'string' && sourceCode.trimStart().startsWith('{') && sourceCode.includes('"sources"')); const path = isStandardJson ? `/api/v2/smart-contracts/${addr}/verification/via/standard-input` : `/api/v2/smart-contracts/${addr}/verification/via/flattened-code`; const v2Body = { compiler_version: payload.compilerversion || payload.compilerVersion || 'v0.8.20+commit.a1b79de6', contract_name: payload.contractname || payload.contractName || 'Contract', license_type: payload.licensetype || payload.licenseType || 'mit', is_optimization_enabled: [true, '1', 1, 'true'].includes(payload.optimizationUsed ?? payload.optimization_used), optimization_runs: parseInt(payload.runs ?? payload.optimization_runs ?? '200', 10) || 200, evm_version: payload.evmversion || payload.evm_version || 'london', autodetect_constructor_args: payload.autodetectConstructorArguments !== false, source_code: typeof sourceCode === 'string' ? sourceCode : JSON.stringify(sourceCode), }; if (payload.constructorArguments) v2Body.constructor_args = payload.constructorArguments; const body = JSON.stringify(v2Body); const url = new URL(path, BLOCKSCOUT_URL); return new Promise((resolve, reject) => { const req = http.request( { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {}, raw: data }); } catch { resolve({ status: res.statusCode, data: null, raw: data }); } }); } ); req.on('error', reject); req.write(body); req.end(); }); } /** * Forward to Blockscout v2 verification API for Standard JSON input. */ async function forwardV2StandardInput(payload) { const addr = payload.contractaddress || payload.contractAddress; const sourceCode = payload.sourceCode ?? payload.source_code; const standardJson = typeof sourceCode === 'string' ? sourceCode : JSON.stringify(sourceCode); const path = `/api/v2/smart-contracts/${addr}/verification/via/standard-input`; const boundary = `----forge-verification-proxy-${Math.random().toString(16).slice(2)}`; const parts = []; const appendField = (name, value) => { if (value === undefined || value === null || value === '') return; parts.push(Buffer.from(`--${boundary}\r\n`)); parts.push(Buffer.from(`Content-Disposition: form-data; name="${name}"\r\n\r\n`)); parts.push(Buffer.from(`${value}\r\n`)); }; const appendFile = (name, filename, content, contentType = 'application/json') => { parts.push(Buffer.from(`--${boundary}\r\n`)); parts.push(Buffer.from(`Content-Disposition: form-data; name="${name}"; filename="${filename}"\r\n`)); parts.push(Buffer.from(`Content-Type: ${contentType}\r\n\r\n`)); parts.push(Buffer.isBuffer(content) ? content : Buffer.from(String(content))); parts.push(Buffer.from('\r\n')); }; const compilerVersion = payload.compilerversion || payload.compilerVersion || 'v0.8.20+commit.a1b79de6'; const contractName = payload.contractname || payload.contractName || 'Contract'; const licenseType = payload.licensetype || payload.licenseType || 'mit'; const constructorArgs = payload.constructor_args ?? payload.constructorArguments ?? payload.constructorArgumentsHex ?? payload.constructorArgs ?? ''; appendField('compiler_version', compilerVersion); appendField('contract_name', contractName); appendField('autodetect_constructor_args', String(payload.autodetectConstructorArguments !== false)); appendField('license_type', licenseType); appendField('constructor_args', constructorArgs); if (payload.evmversion || payload.evm_version) appendField('evm_version', payload.evmversion || payload.evm_version); if (payload.optimizationUsed !== undefined || payload.optimization_used !== undefined) { appendField('is_optimization_enabled', String([true, '1', 1, 'true'].includes(payload.optimizationUsed ?? payload.optimization_used))); } if (payload.runs !== undefined || payload.optimization_runs !== undefined) { appendField('optimization_runs', String(parseInt(payload.runs ?? payload.optimization_runs ?? '200', 10) || 200)); } appendFile('files[0]', 'standard-input.json', standardJson, 'application/json'); parts.push(Buffer.from(`--${boundary}--\r\n`)); const body = Buffer.concat(parts); const url = new URL(path, BLOCKSCOUT_URL); return new Promise((resolve, reject) => { const req = http.request( { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname, method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length, }, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : {}, raw: data }); } catch { resolve({ status: res.statusCode, data: null, raw: data }); } }); } ); req.on('error', reject); req.write(body); req.end(); }); } function toEtherscanResponse(result) { const { status, data, raw } = result; if (status >= 200 && status < 300 && data?.status === '1') { return { status: '1', message: data.message || 'OK', result: data.result ?? 'Verification submitted' }; } if (status >= 200 && status < 300) { return { status: '1', message: 'OK', result: data?.result ?? 'Verification submitted' }; } // Blockscout may return HTML (502/500) or invalid JSON when DB/migrations fail let msg = data?.message || data?.error; if (!msg && raw) { if (raw.trimStart().startsWith('<')) { msg = 'Blockscout returned HTML (likely DB down or migrations needed). Run scripts/fix-blockscout-ssl-and-migrations.sh'; } else if (raw.length > 200) { msg = raw.slice(0, 200) + '...'; } else { msg = raw; } } return { status: '0', message: msg || 'Verification failed', result: null, }; } /** Forward GET/other requests to Blockscout (getabi, checkverifystatus, etc.) */ function proxyToBlockscout(req, res) { let targetPath = (req.url || '/').startsWith('/api') ? req.url : '/api' + (req.url === '/' ? '' : req.url); // Blockscout redirects /api to /api/ — use /api/ to avoid 301 if (targetPath.startsWith('/api?') || targetPath === '/api') { targetPath = '/api/' + (targetPath.slice(4) || ''); } const url = new URL(targetPath, BLOCKSCOUT_URL); const proxyReq = http.request( { hostname: url.hostname, port: url.port || (url.protocol === 'https:' ? 443 : 80), path: url.pathname + url.search, method: req.method, headers: { host: url.host }, }, (proxyRes) => { const headers = { ...proxyRes.headers }; delete headers['transfer-encoding']; res.writeHead(proxyRes.statusCode || 200, headers); proxyRes.pipe(res); } ); proxyReq.on('error', (e) => { console.error('[forge-verification-proxy]', e.message); send(res, 502, { status: '0', message: 'Blockscout unreachable', result: null }); }); if (req.method === 'POST' || req.method === 'PUT') { req.pipe(proxyReq); } else { proxyReq.end(); } } const server = http.createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } const path = (req.url || '/').split('?')[0]; if (req.method === 'GET') { await proxyToBlockscout(req, res); return; } if (req.method !== 'POST') { send(res, 405, { status: '0', message: 'Method not allowed', result: null }); return; } let payload; try { payload = await parseBody(req); } catch (e) { send(res, 400, { status: '0', message: 'Invalid request body (JSON or form)', result: null }); return; } if (!payload.contractaddress && !payload.contractAddress) { send(res, 400, { status: '0', message: 'Params contractaddress and sourceCode are required', result: null, }); return; } const codeformat = (payload.codeformat || '').toLowerCase(); const sourceCode = payload.sourceCode ?? payload.source_code; const isStandardJson = codeformat === 'solidity-standard-json-input' || (typeof sourceCode === 'string' && sourceCode.trimStart().startsWith('{') && sourceCode.includes('"sources"')); // Etherscan API expects Standard JSON in sourceCode; flattened Solidity causes "Invalid JSON". // Try v2 API first for flattened code; use multipart standard-input when the payload is Standard JSON. const tryV2First = !isStandardJson; try { let result; let out; if (tryV2First) { result = await forwardV2Flattened(payload); out = toEtherscanResponse(result); if (out.status !== '1') { console.error('[forge-verification-proxy] v2 API failed:', out.message, '- trying Etherscan format...'); result = await forwardEtherscanFormat(payload); const etherOut = toEtherscanResponse(result); send(res, 200, etherOut.status === '1' ? etherOut : out); return; } } else { result = await forwardV2StandardInput(payload); out = toEtherscanResponse(result); if (out.status !== '1') { console.error('[forge-verification-proxy] v2 standard-input failed:', out.message, '- trying Etherscan format...'); result = await forwardEtherscanFormat(payload); const etherOut = toEtherscanResponse(result); send(res, 200, etherOut.status === '1' ? etherOut : out); return; } } send(res, 200, out); } catch (e) { console.error('[forge-verification-proxy]', e.message); send(res, 500, { status: '0', message: e.message || 'Proxy error', result: null, }); } }); server.listen(PORT, '0.0.0.0', () => { console.log(`[forge-verification-proxy] Listening on port ${PORT}`); console.log(`[forge-verification-proxy] Blockscout: ${BLOCKSCOUT_URL}`); console.log(`[forge-verification-proxy] Forge: --verifier-url "http://:${PORT}/"`); });