diff --git a/package.json b/package.json index 748b98185..1fed3469f 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,9 @@ "commander": "14.0.2", "ethers": "6.16.0", "handlebars": "4.7.8", - "supports-hyperlinks": "3.0.0", + "semver": "^7.7.3", "solc": "0.8.33", + "supports-hyperlinks": "3.0.0", "zod": "4.2.1", "zustand": "5.0.9" }, @@ -69,6 +70,7 @@ "@types/handlebars": "4.1.0", "@types/jest": "30.0.0", "@types/node": "25.0.3", + "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "8.50.0", "@typescript-eslint/parser": "8.50.0", "copyfiles": "2.4.1", diff --git a/src/core/services/contract-compiler/contract-compiler-service.ts b/src/core/services/contract-compiler/contract-compiler-service.ts index 6a0def841..2854f2c55 100644 --- a/src/core/services/contract-compiler/contract-compiler-service.ts +++ b/src/core/services/contract-compiler/contract-compiler-service.ts @@ -16,7 +16,10 @@ export class ContractCompilerServiceImpl implements ContractCompilerService { public async compileContract( params: CompilationParams, ): Promise { - const solc = await loadSolcVersion(params.solidityVersion); + const solc = await loadSolcVersion({ + version: params.solidityVersion, + contractContent: params.contractContent, + }); if (!params.contractContent.trim()) { throw new Error('Contract content is empty'); } diff --git a/src/core/utils/__tests__/unit/solc-loader.test.ts b/src/core/utils/__tests__/unit/solc-loader.test.ts new file mode 100644 index 000000000..95532f3e9 --- /dev/null +++ b/src/core/utils/__tests__/unit/solc-loader.test.ts @@ -0,0 +1,130 @@ +import { EventEmitter } from 'events'; + +jest.mock('solc', () => { + const loadRemoteVersion = jest.fn(); + return { + __esModule: true, + default: { + loadRemoteVersion, + }, + }; +}); + +jest.mock('https', () => { + const get = jest.fn(); + return { + __esModule: true, + default: { + get, + }, + }; +}); + +function mockReleaseList( + mockHttpsGet: jest.Mock, + releaseList: { + releases: Record; + latestRelease: string; +}, +) { + mockHttpsGet.mockImplementation((_url, callback) => { + const response = new EventEmitter() as EventEmitter & { + statusCode?: number; + }; + response.statusCode = 200; + callback(response); + process.nextTick(() => { + response.emit('data', JSON.stringify(releaseList)); + response.emit('end'); + }); + return { on: jest.fn() }; + }); +} + +async function loadSolcVersionFresh() { + jest.resetModules(); + const [{ default: solcModule }, { default: httpsModule }, { loadSolcVersion }] = + await Promise.all([ + import('solc'), + import('https'), + import('@/core/utils/solc-loader'), + ]); + + return { + loadSolcVersion, + solcModule, + mockLoadRemoteVersion: solcModule.loadRemoteVersion as jest.Mock, + mockHttpsGet: httpsModule.get as jest.Mock, + }; +} + +describe('loadSolcVersion', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the installed solc when no version or pragma is provided', async () => { + const { loadSolcVersion, solcModule, mockLoadRemoteVersion, mockHttpsGet } = + await loadSolcVersionFresh(); + + const result = await loadSolcVersion({}); + + expect(result).toBe(solcModule); + expect(mockLoadRemoteVersion).not.toHaveBeenCalled(); + expect(mockHttpsGet).not.toHaveBeenCalled(); + }); + + it('resolves pragma range to the newest matching release', async () => { + const { loadSolcVersion, mockLoadRemoteVersion, mockHttpsGet } = + await loadSolcVersionFresh(); + const releaseList = { + latestRelease: '0.8.23', + releases: { + '0.8.20': 'soljson-v0.8.20+commit.12345678.js', + '0.8.23': 'soljson-v0.8.23+commit.abcdef12.js', + }, + }; + + mockReleaseList(mockHttpsGet, releaseList); + mockLoadRemoteVersion.mockImplementation((version, callback) => { + (callback as (err: Error | null, solcSpecific: unknown) => void)( + null, + { version }, + ); + }); + + const result = await loadSolcVersion({ + contractContent: 'pragma solidity ^0.8.0; contract Foo {}', + }); + + expect(mockLoadRemoteVersion).toHaveBeenCalledTimes(1); + const [requestedVersion] = mockLoadRemoteVersion.mock.calls[0]; + expect(requestedVersion).toBe('v0.8.23+commit.abcdef12'); + expect(result).toEqual({ version: 'v0.8.23+commit.abcdef12' }); + }); + + it('uses cached compiler versions for repeated requests', async () => { + const { loadSolcVersion, mockLoadRemoteVersion, mockHttpsGet } = + await loadSolcVersionFresh(); + const releaseList = { + latestRelease: '0.8.23', + releases: { + '0.8.23': 'soljson-v0.8.23+commit.abcdef12.js', + }, + }; + + mockReleaseList(mockHttpsGet, releaseList); + mockLoadRemoteVersion.mockImplementation((version, callback) => { + (callback as (err: Error | null, solcSpecific: unknown) => void)( + null, + { version }, + ); + }); + + const request = { version: '^0.8.0' }; + await loadSolcVersion(request); + await loadSolcVersion(request); + + expect(mockLoadRemoteVersion).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/utils/solc-loader.ts b/src/core/utils/solc-loader.ts index 219f67077..c233fe0b4 100644 --- a/src/core/utils/solc-loader.ts +++ b/src/core/utils/solc-loader.ts @@ -1,27 +1,156 @@ import type { SolcCompiler } from '@/core/types/shared.types'; +import https from 'https'; +import semver from 'semver'; import solc from 'solc'; -export function loadSolcVersion( - version: string | undefined, +const SOLC_RELEASE_LIST_URL = 'https://binaries.soliditylang.org/bin/list.json'; +const solcVersionCache = new Map>(); +let solcReleaseListPromise: Promise | null = null; + +interface SolcReleaseList { + releases: Record; + latestRelease: string; +} + +export interface SolcVersionRequest { + version?: string; + contractContent?: string; +} + +export async function loadSolcVersion( + request: SolcVersionRequest, ): Promise { - if (version) { - return new Promise((resolve, reject) => { + const requestedVersion = + request.version?.trim() || + extractSolidityVersionFromPragma(request.contractContent); + + if (!requestedVersion) { + return solc as unknown as SolcCompiler; + } + + const resolvedVersion = await resolveSolcVersion(requestedVersion); + if (!solcVersionCache.has(resolvedVersion)) { + const loadPromise = new Promise((resolve, reject) => { solc.loadRemoteVersion( - version, + resolvedVersion, (err: Error | null, solcSpecific: unknown) => { if (err) { - reject(err); - throw new Error( - `There was a problem with using Solidity compiler in version ${version}`, - err, + reject( + new Error( + `There was a problem with using Solidity compiler in version ${resolvedVersion}: ${err.message}`, + ), ); + return; } resolve(solcSpecific as SolcCompiler); }, ); + }).catch((error) => { + solcVersionCache.delete(resolvedVersion); + throw error; }); - } else { - return Promise.resolve(solc as unknown as SolcCompiler); + + solcVersionCache.set(resolvedVersion, loadPromise); } + + return solcVersionCache.get(resolvedVersion)!; +} + +function extractSolidityVersionFromPragma( + contractContent?: string, +): string | undefined { + if (!contractContent) { + return undefined; + } + const match = contractContent.match(/pragma\s+solidity\s+([^;]+);/i); + return match?.[1].trim(); +} + +async function resolveSolcVersion(versionInput: string): Promise { + const normalizedVersion = versionInput.trim(); + if (isSolcBuildVersion(normalizedVersion)) { + return normalizedVersion; + } + + const releaseList = await getSolcReleaseList(); + const availableVersions = Object.keys(releaseList.releases).filter((ver) => + Boolean(semver.valid(ver)), + ); + + const exactVersion = semver.valid(normalizedVersion); + if (exactVersion) { + const release = releaseList.releases[exactVersion]; + if (!release) { + throw new Error( + `No Solidity compiler release found for version ${exactVersion}`, + ); + } + return releaseFileToVersion(release); + } + + const range = semver.validRange(normalizedVersion); + if (!range) { + throw new Error( + `Unsupported Solidity version format "${versionInput}". Provide a valid version or semver range.`, + ); + } + + const matchingVersion = semver.maxSatisfying(availableVersions, range); + if (!matchingVersion) { + throw new Error( + `No Solidity compiler release satisfies range ${versionInput}`, + ); + } + + const release = releaseList.releases[matchingVersion]; + if (!release) { + throw new Error( + `No Solidity compiler release found for version ${matchingVersion}`, + ); + } + return releaseFileToVersion(release); +} + +function isSolcBuildVersion(version: string): boolean { + return /^(v)?\d+\.\d+\.\d+\+commit\.[0-9a-f]{8}$/i.test(version); +} + +function releaseFileToVersion(releaseFile: string): string { + return releaseFile.replace(/^soljson-/, '').replace(/\.js$/, ''); +} + +function getSolcReleaseList(): Promise { + if (!solcReleaseListPromise) { + solcReleaseListPromise = fetchJson(SOLC_RELEASE_LIST_URL); + } + return solcReleaseListPromise; +} + +function fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + https + .get(url, (res) => { + if (res.statusCode && res.statusCode >= 400) { + reject( + new Error(`Failed to fetch ${url}: status ${res.statusCode}`), + ); + res.resume(); + return; + } + + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data) as T); + } catch (error) { + reject(error); + } + }); + }) + .on('error', reject); + }); }