Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,17 @@
"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"
},
"devDependencies": {
"@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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export class ContractCompilerServiceImpl implements ContractCompilerService {
public async compileContract(
params: CompilationParams,
): Promise<CompilationResult> {
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');
}
Expand Down
130 changes: 130 additions & 0 deletions src/core/utils/__tests__/unit/solc-loader.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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);
});
});
151 changes: 140 additions & 11 deletions src/core/utils/solc-loader.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<SolcCompiler>>();
let solcReleaseListPromise: Promise<SolcReleaseList> | null = null;

interface SolcReleaseList {
releases: Record<string, string>;
latestRelease: string;
}

export interface SolcVersionRequest {
version?: string;
contractContent?: string;
}

export async function loadSolcVersion(
request: SolcVersionRequest,
): Promise<SolcCompiler> {
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<SolcCompiler>((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<string> {
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<SolcReleaseList> {
if (!solcReleaseListPromise) {
solcReleaseListPromise = fetchJson<SolcReleaseList>(SOLC_RELEASE_LIST_URL);
}
return solcReleaseListPromise;
}

function fetchJson<T>(url: string): Promise<T> {
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);
});
}