UNPKG

@m3s/smart-contract

Version:

A modular toolkit for generating, compiling, deploying, and interacting with Ethereum-compatible smart contracts

315 lines (313 loc) 17.9 kB
import { exec } from "child_process"; import { promisify } from "util"; import * as fs from "fs/promises"; import * as path from "path"; import { ethers, ContractFactory } from "ethers"; const execAsync = promisify(exec); function getAbiInputs(abi, type, name) { return abi.find((item) => item.type === type && (type !== 'function' || item.name === name))?.inputs || []; } function validateArgs(args = [], abiInputs) { if (args.length !== abiInputs.length) { throw new Error(`Incorrect number of arguments: expected ${abiInputs.length} (${abiInputs.map(i => `${i.type} ${i.name}`).join(', ')}), got ${args.length}`); } } export default class SolidityCompiler { workDir; solcVersion; compilerSettings; hardhatConfigFileName; preserveOutput; constructor(config) { this.workDir = config.workDir; this.solcVersion = config.solcVersion; this.compilerSettings = config.compilerSettings; this.hardhatConfigFileName = config.hardhatConfigFileName; this.preserveOutput = config.preserveOutput; console.log(`[SolidityCompiler] Initialized with solc version: ${this.solcVersion}, workDir: ${this.workDir}, preserveOutput: ${this.preserveOutput}`); } async compileProxyContractSource(proxySource, proxyContractName) { const hashInput = proxyContractName + proxySource + JSON.stringify(this.compilerSettings) + this.solcVersion; const hash = Buffer.from(hashInput).toString('base64').replace(/[/+=]/g, '').substring(0, 8); const baseDirForProxyCompilation = path.join(this.workDir, 'm3s_proxies_cache'); const proxyCompilationInstanceDir = path.join(baseDirForProxyCompilation, `proxy_${hash}`); console.log(`[SolidityCompiler] Using directory for proxy compilation: ${proxyCompilationInstanceDir}`); await fs.mkdir(proxyCompilationInstanceDir, { recursive: true }); try { const hardhatConfigPath = path.join(proxyCompilationInstanceDir, this.hardhatConfigFileName); const contractsDirPath = path.join(proxyCompilationInstanceDir, 'contracts'); await fs.mkdir(contractsDirPath, { recursive: true }); const contractPath = path.join(contractsDirPath, `${proxyContractName}.sol`); await fs.writeFile(contractPath, proxySource); const hardhatConfigContent = ` module.exports = { solidity: { version: "${this.solcVersion}", settings: ${JSON.stringify(this.compilerSettings, null, 2)} }, paths: { sources: "./contracts", artifacts: "./artifacts" }, ${this.compilerSettings?.customSettings ? `...${JSON.stringify(this.compilerSettings.customSettings, null, 2)}` : ''} };`; await fs.writeFile(hardhatConfigPath, hardhatConfigContent); const packageJsonPath = path.join(proxyCompilationInstanceDir, 'package.json'); const ozContractsVersion = "^5.0.0"; await fs.writeFile(packageJsonPath, JSON.stringify({ name: `proxy-compilation-${hash}`, version: "1.0.0", dependencies: { "@openzeppelin/contracts": ozContractsVersion } }, null, 2)); let installCommand = `cd "${proxyCompilationInstanceDir}" && npm install --legacy-peer-deps`; try { console.log(`[SolidityCompiler] Running npm install in ${proxyCompilationInstanceDir}`); await execAsync(installCommand); } catch (installError) { console.warn(`[SolidityCompiler] npm install failed in ${proxyCompilationInstanceDir}, proxy compilation may fail: ${installError.message}`); } let hardhatCommand = "npx hardhat"; try { const projectHardhatPath = path.resolve(process.cwd(), 'node_modules', '.bin', 'hardhat'); await fs.access(projectHardhatPath); hardhatCommand = `"${projectHardhatPath}"`; } catch { /* npx is fine as fallback */ } const compileCommand = `cd "${proxyCompilationInstanceDir}" && ${hardhatCommand} compile --config "${hardhatConfigPath}" --force`; console.log(`[SolidityCompiler] Executing proxy compile: ${compileCommand}`); const { stdout, stderr } = await execAsync(compileCommand); if (stdout) console.log(`[SolidityCompiler] Proxy compile stdout:\n${stdout}`); if (stderr) console.warn(`[SolidityCompiler] Proxy compile stderr:\n${stderr}`); const artifactPath = path.join(proxyCompilationInstanceDir, 'artifacts', 'contracts', `${proxyContractName}.sol`, `${proxyContractName}.json`); console.log(`[SolidityCompiler] Reading proxy artifact: ${artifactPath}`); const artifactJson = await fs.readFile(artifactPath, 'utf-8'); const artifact = JSON.parse(artifactJson); if (!artifact.abi || !artifact.bytecode) { throw new Error("Proxy compilation succeeded but artifact missing ABI or bytecode."); } return { abi: artifact.abi, bytecode: artifact.bytecode, contractName: artifact.contractName || proxyContractName, sourceName: artifact.sourceName || `${proxyContractName}.sol` }; } finally { if (this.preserveOutput) { console.log(`[SolidityCompiler] Preserving output for proxy in: ${proxyCompilationInstanceDir}`); } } } async getStandardProxyArtifacts() { const proxyContractName = "M3S_ERC1967Proxy"; const proxySource = ` // SPDX-License-Identifier: MIT pragma solidity ^${this.solcVersion.startsWith('0.') ? this.solcVersion : '0.8.20'}; import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; contract ${proxyContractName} is ERC1967Proxy { constructor(address logic, bytes memory data) ERC1967Proxy(logic, data) {} } `; return this.compileProxyContractSource(proxySource, proxyContractName); } async compile(input) { const { sourceCode, language, contractName: inputContractName, compilerOptions } = input; console.log(`[SolidityCompiler] Attempting to compile '${inputContractName || 'contract'}'. Language: ${language}, PreserveOutput: ${this.preserveOutput}`); if (language.toLowerCase() !== 'solidity') { throw new Error(`[SolidityCompiler] This compiler only supports 'solidity'. Language provided: ${language}`); } let contractDir; const effectiveCompilerSettings = { ...this.compilerSettings, ...compilerOptions }; try { const contractName = inputContractName || sourceCode.match(/contract\s+([a-zA-Z0-9_]+)/)?.[1]; if (!contractName) throw new Error("Could not determine contract name from source or input"); const hashInput = contractName + sourceCode + JSON.stringify(effectiveCompilerSettings) + this.solcVersion; const hash = Buffer.from(hashInput).toString('base64').replace(/[/+=]/g, '').substring(0, 8); contractDir = path.join(this.workDir, `${contractName}_${hash}`); const artifactPath = path.join(contractDir, 'artifacts', 'contracts', `${contractName}.sol`, `${contractName}.json`); let versionForHardhat = this.solcVersion; const pragmaRegex = /pragma\s+solidity\s+([^\s;]+)\s*;/; const pragmaMatch = sourceCode.match(pragmaRegex); if (pragmaMatch && pragmaMatch[1]) { const pragmaVersionString = pragmaMatch[1]; const specificVersionRegex = /(\d+\.\d+\.\d+)/; const specificVersionMatch = pragmaVersionString.match(specificVersionRegex); if (specificVersionMatch && specificVersionMatch[0]) { versionForHardhat = specificVersionMatch[0]; } } // --- Check Cache --- if (this.preserveOutput) { try { const existingArtifactJson = await fs.readFile(artifactPath, 'utf-8'); const existingArtifact = JSON.parse(existingArtifactJson); if (existingArtifact.abi && existingArtifact.bytecode && existingArtifact.metadata && existingArtifact.metadata.compilerVersion === versionForHardhat) { console.log(`[SolidityCompiler] Using cached artifact for ${contractName} (version: ${versionForHardhat}) from ${contractDir}`); const factory = new ContractFactory(existingArtifact.abi, existingArtifact.bytecode); const artifacts = { abi: existingArtifact.abi, bytecode: existingArtifact.bytecode, contractName: existingArtifact.contractName, sourceName: existingArtifact.sourceName, }; const metadata = { compiler: existingArtifact.metadata.compiler || 'hardhat', compilerVersion: existingArtifact.metadata.compilerVersion, language: existingArtifact.metadata.language || 'solidity', contractName: existingArtifact.metadata.contractName || existingArtifact.contractName, isUpgradeable: existingArtifact.metadata.isUpgradeable, upgradeabilityType: existingArtifact.metadata.upgradeabilityType }; return this._buildCompiledOutput(artifacts, factory, metadata); } } catch (readError) { /* Cache miss, continue */ } } // --- Compile --- console.log(`[SolidityCompiler] Compiling ${contractName} using Solidity ${versionForHardhat} in ${contractDir}`); await fs.rm(contractDir, { recursive: true, force: true }); await fs.mkdir(contractDir, { recursive: true }); const hardhatConfigPath = path.join(contractDir, this.hardhatConfigFileName); const contractsDirPath = path.join(contractDir, 'contracts'); await fs.mkdir(contractsDirPath, { recursive: true }); const contractPath = path.join(contractsDirPath, `${contractName}.sol`); await fs.writeFile(contractPath, sourceCode); const hardhatConfigContent = ` module.exports = { solidity: { version: "${versionForHardhat}", settings: ${JSON.stringify(effectiveCompilerSettings, null, 2)} }, paths: { sources: "./contracts", artifacts: "./artifacts" }, ...${JSON.stringify(effectiveCompilerSettings?.customSettings || {}, null, 2)} };`; await fs.writeFile(hardhatConfigPath, hardhatConfigContent); // --- Install dependencies for upgradeable contracts --- const isUpgradeableImplementation = sourceCode.includes('@openzeppelin/contracts-upgradeable'); if (isUpgradeableImplementation) { const packageJsonPath = path.join(contractDir, 'package.json'); const ozContractsUpgradeableVersion = "^5.0.0"; await fs.writeFile(packageJsonPath, JSON.stringify({ name: `implementation-compilation-${hash}`, version: "1.0.0", dependencies: { "@openzeppelin/contracts-upgradeable": ozContractsUpgradeableVersion, } }, null, 2)); let installCommand = `cd "${contractDir}" && npm install --legacy-peer-deps`; try { console.log(`[SolidityCompiler] Running npm install for implementation in ${contractDir}`); await execAsync(installCommand); } catch (installError) { console.warn(`[SolidityCompiler] npm install for implementation failed in ${contractDir}, compilation may fail: ${installError.message}`); } } let hardhatCommand = "npx hardhat"; try { const projectHardhatPath = path.resolve(process.cwd(), 'node_modules', '.bin', 'hardhat'); await fs.access(projectHardhatPath); hardhatCommand = `"${projectHardhatPath}"`; } catch { /* npx is fine */ } const compileCommand = `cd "${contractDir}" && ${hardhatCommand} compile --config "${hardhatConfigPath}" --force`; console.log(`[SolidityCompiler] Executing implementation compile: ${compileCommand}`); const { stdout, stderr } = await execAsync(compileCommand); if (stdout) console.log(`[SolidityCompiler] Implementation compile stdout:\n${stdout}`); if (stderr) console.warn(`[SolidityCompiler] Implementation compile stderr:\n${stderr}`); const artifactJson = await fs.readFile(artifactPath, 'utf-8'); const artifact = JSON.parse(artifactJson); if (!artifact.abi || !artifact.bytecode) { throw new Error("Compilation succeeded but artifact missing ABI or bytecode."); } const factory = new ContractFactory(artifact.abi, artifact.bytecode); const finalArtifacts = { abi: artifact.abi, bytecode: artifact.bytecode, contractName: artifact.contractName, sourceName: artifact.sourceName, }; const hasInitializeFunction = finalArtifacts.abi.some((item) => item.type === "function" && item.name === "initialize"); const hasExplicitConstructorArgs = finalArtifacts.abi.some((item) => item.type === "constructor" && item.inputs && item.inputs.length > 0); const isUUPS = sourceCode.includes('UUPSUpgradeable') || sourceCode.includes('_authorizeUpgrade'); let isUpgradeable = false; let upgradeabilityType = undefined; if (hasInitializeFunction && !hasExplicitConstructorArgs) { isUpgradeable = true; upgradeabilityType = isUUPS ? 'uups' : 'transparent'; } const finalMetadata = { compiler: 'hardhat', compilerVersion: versionForHardhat, language: 'solidity', contractName: finalArtifacts.contractName, isUpgradeable, upgradeabilityType }; if (this.preserveOutput) { const artifactToSave = { ...artifact }; artifactToSave.metadata = { ...(artifactToSave.metadata || {}), ...finalMetadata }; await fs.writeFile(artifactPath, JSON.stringify(artifactToSave, null, 2)); } return this._buildCompiledOutput(finalArtifacts, factory, finalMetadata); } catch (error) { console.error(`[SolidityCompiler] Compilation failed: ${error.message}`, error.stack); if (error.stderr) console.error("Compilation stderr:", error.stderr); throw new Error(`Solidity compilation failed: ${error.message}`); } finally { if (contractDir && !this.preserveOutput) { try { await fs.rm(contractDir, { recursive: true, force: true }); console.log(`[SolidityCompiler] Cleaned up temporary contract directory: ${contractDir}`); } catch (cleanupError) { console.warn(`[SolidityCompiler] Failed to cleanup temporary contract directory ${contractDir}: ${cleanupError.message}`); } } } } _buildCompiledOutput(artifacts, factory, metadata) { const getDeploymentArgsSpec = (opts) => { if (opts?.proxy) { return getAbiInputs(artifacts.abi, 'function', 'initialize'); } return getAbiInputs(artifacts.abi, 'constructor'); }; return { artifacts, getDeploymentArgsSpec, getRegularDeploymentData: async (constructorArgs) => { const abiInputs = getAbiInputs(artifacts.abi, 'constructor'); validateArgs(constructorArgs || [], abiInputs); const deployTx = await factory.getDeployTransaction(...(constructorArgs || [])); return { type: 'regular', data: deployTx.data, value: deployTx.value?.toString() || "0" }; }, getProxyDeploymentData: async (initializeArgs) => { const abiInputs = getAbiInputs(artifacts.abi, 'function', 'initialize'); validateArgs(initializeArgs || [], abiInputs); const implDeployTx = await factory.getDeployTransaction(); const logicInterface = new ethers.Interface(artifacts.abi); const initDataForLogic = logicInterface.encodeFunctionData('initialize', initializeArgs || []); const proxyArtifacts = await this.getStandardProxyArtifacts(); return { type: 'proxy', implementation: { data: implDeployTx.data, value: implDeployTx.value?.toString() || "0" }, proxy: { bytecode: proxyArtifacts.bytecode, abi: proxyArtifacts.abi, logicInitializeData: initDataForLogic, value: "0" } }; }, metadata }; } } //# sourceMappingURL=solidityCompiler.js.map