diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f2e7ad74..2b0fb1cab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,6 +36,8 @@ jobs: # Basic deployment to make sure everything is ok. # Could be removed in the future if not relevant. run: npm run deploy + - name: Test Timelock Deployment + run: npm run deploy:timelock - name: Run coverage run: npm run coverage - name: Upload coverage reports to Codecov diff --git a/CHANGELOG.md b/CHANGELOG.md index 50d0b80fd..6619cd574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - [x] `IexecPoco2Delegate.sol` ### Features +- Migrate scripts to TypeScript: (#184) + - `getFunctionSignatures.js`, `common-test-snapshot.js`, `test-storage.js`, `timelock.js` - Migrated utility files to TypeScript : (#183) - `FactoryDeployer.js`, `constants.js`, `odb-tools.js` - Removed deprecated `scripts/ens/sidechain.js` diff --git a/migrations/utils/getFunctionSignatures.d.ts b/migrations/utils/getFunctionSignatures.d.ts deleted file mode 100644 index 6def47725..000000000 --- a/migrations/utils/getFunctionSignatures.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-FileCopyrightText: 2024 IEXEC BLOCKCHAIN TECH -// SPDX-License-Identifier: Apache-2.0 - -export function getFunctionSignatures(abi: any[]): string; diff --git a/migrations/utils/getFunctionSignatures.js b/migrations/utils/getFunctionSignatures.js deleted file mode 100644 index ba1404f57..000000000 --- a/migrations/utils/getFunctionSignatures.js +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-FileCopyrightText: 2023-2024 IEXEC BLOCKCHAIN TECH -// SPDX-License-Identifier: Apache-2.0 - -/***************************************************************************** - * Tools * - *****************************************************************************/ -function getSerializedObject(entry) { - return entry.type == 'tuple' - ? `(${entry.components.map(getSerializedObject).join(',')})` - : entry.type; -} -function getFunctionSignatures(abi) { - return [ - ...abi.filter((entry) => entry.type == 'receive').map((entry) => 'receive;'), - ...abi.filter((entry) => entry.type == 'fallback').map((entry) => 'fallback;'), - ...abi - .filter((entry) => entry.type == 'function') - .map((entry) => `${entry.name}(${entry.inputs.map(getSerializedObject).join(',')});`), - ] - .filter(Boolean) - .join(''); -} -exports.getFunctionSignatures = getFunctionSignatures; diff --git a/migrations/utils/getFunctionSignatures.ts b/migrations/utils/getFunctionSignatures.ts new file mode 100644 index 000000000..dac6706b2 --- /dev/null +++ b/migrations/utils/getFunctionSignatures.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2023-2025 IEXEC BLOCKCHAIN TECH +// SPDX-License-Identifier: Apache-2.0 + +interface AbiParameter { + type: string; + components?: AbiParameter[]; +} + +function getSerializedObject(entry: AbiParameter): string { + return entry.type === 'tuple' + ? `(${entry.components?.map(getSerializedObject).join(',') ?? ''})` + : entry.type; +} + +export function getFunctionSignatures(abi: any[]): string { + return [ + ...abi.filter((entry) => entry.type === 'receive').map(() => 'receive;'), + ...abi.filter((entry) => entry.type === 'fallback').map(() => 'fallback;'), + ...abi + .filter((entry) => entry.type === 'function') + .map( + (entry) => + `${entry.name}(${entry.inputs?.map(getSerializedObject).join(',') ?? ''});`, + ), + ] + .filter(Boolean) + .join(''); +} diff --git a/package.json b/package.json index 60b13afb8..c18355529 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "prepare": "husky", "build": "npx hardhat compile", "deploy": "npx hardhat deploy", - "test-storage-layout": "npx hardhat run scripts/test-storage.js", + "deploy:timelock": "hardhat run scripts/deploy-timelock.ts", + "test-storage-layout": "npx hardhat run scripts/test-storage.ts", "test": "REPORT_GAS=true npx hardhat test", "autotest": "./test.sh", "coverage": "npx hardhat coverage", diff --git a/scripts/common-test-snapshot.js b/scripts/common-test-snapshot.ts similarity index 55% rename from scripts/common-test-snapshot.js rename to scripts/common-test-snapshot.ts index 9f8becf1c..bf9c2695f 100644 --- a/scripts/common-test-snapshot.js +++ b/scripts/common-test-snapshot.ts @@ -1,13 +1,11 @@ -// SPDX-FileCopyrightText: 2024 IEXEC BLOCKCHAIN TECH +// SPDX-FileCopyrightText: 2024-2025 IEXEC BLOCKCHAIN TECH // SPDX-License-Identifier: Apache-2.0 -async function resetNetworkToInitialState() { +import hre from 'hardhat'; + +export async function resetNetworkToInitialState(): Promise { console.log( 'Reset network to a fresh state to ensure same initial snapshot state between tests', ); await hre.network.provider.send('hardhat_reset'); } - -module.exports = { - resetNetworkToInitialState, -}; diff --git a/scripts/deploy-timelock.ts b/scripts/deploy-timelock.ts new file mode 100644 index 000000000..8e983ac69 --- /dev/null +++ b/scripts/deploy-timelock.ts @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2020-2025 IEXEC BLOCKCHAIN TECH +// SPDX-License-Identifier: Apache-2.0 + +import { duration } from '@nomicfoundation/hardhat-network-helpers/dist/src/helpers/time'; +import hre, { ethers } from 'hardhat'; +import { TimelockController__factory } from '../typechain'; +import { FactoryDeployerHelper } from '../utils/FactoryDeployerHelper'; +const CONFIG = require('../config/config.json'); + +/** + * Deploy TimelockController contract using the generic factory. + */ + +export const deploy = async () => { + console.log('Deploying TimelockController..'); + const chainId = (await ethers.provider.getNetwork()).chainId; + const [owner] = await hre.ethers.getSigners(); + const deploymentOptions = CONFIG.chains[chainId] || CONFIG.chains.default; + const salt = process.env.SALT || deploymentOptions.v5.salt || ethers.constants.HashZero; + + // Initialize factory deployer + const factoryDeployer = new FactoryDeployerHelper(owner, salt); + + // Deploy TimelockController + const ONE_WEEK_IN_SECONDS = duration.days(7); + const ADMINISTRATORS = [ + '0x9ED07B5DB7dAD3C9a0baA3E320E68Ce779063249', + '0x36e19bc6374c9cea5eb86622cf04c6b144b5b59c', + '0x56fa2d29a54b5349cd5d88ffa584bffb2986a656', + '0x9a78ecd77595ea305c6e5a0daed3669b17801d09', + '0xb5ad0c32fc5fcb5e4cba4c81f523e6d47a82ecd7', + '0xb906dc99340d0f3162dbc5b2539b0ad075649bcf', + ]; + const PROPOSERS = [ + '0x0B3a38b0A47aB0c5E8b208A703de366751Df5916', // v5 deployer + ]; + const EXECUTORS = [ + '0x0B3a38b0A47aB0c5E8b208A703de366751Df5916', // v5 deployer + ]; + const constructorArgs = [ONE_WEEK_IN_SECONDS, ADMINISTRATORS, PROPOSERS, EXECUTORS]; + const timelockFactory = new TimelockController__factory(owner); + await factoryDeployer.deployWithFactory(timelockFactory, constructorArgs); +}; + +if (require.main === module) { + deploy().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/hardhat-fixture-deployer.ts b/scripts/hardhat-fixture-deployer.ts index ce7b7c7bc..ab0c45f5e 100644 --- a/scripts/hardhat-fixture-deployer.ts +++ b/scripts/hardhat-fixture-deployer.ts @@ -6,7 +6,7 @@ import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; import { deployments, ethers } from 'hardhat'; import { IexecInterfaceNative__factory } from '../typechain'; import { getIexecAccounts } from '../utils/poco-tools'; -const { resetNetworkToInitialState } = require('./common-test-snapshot'); +import { resetNetworkToInitialState } from './common-test-snapshot'; const deploy = require('../deploy/0_deploy'); const deployEns = require('../deploy/1_deploy-ens'); diff --git a/scripts/test-storage.js b/scripts/test-storage.js deleted file mode 100644 index f9df5d706..000000000 --- a/scripts/test-storage.js +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-FileCopyrightText: 2023 IEXEC BLOCKCHAIN TECH -// SPDX-License-Identifier: Apache-2.0 - -const fs = require('fs'); -const path = require('path'); -const semver = require('semver'); -const { findAll } = require('solidity-ast/utils'); -const { astDereferencer } = require('solidity-ast/utils'); -const { solcInputOutputDecoder } = require('@openzeppelin/upgrades-core/dist/src-decoder'); -const { extractStorageLayout } = require('@openzeppelin/upgrades-core/dist/storage/extract'); -const { getStorageUpgradeReport } = require('@openzeppelin/upgrades-core/dist/storage'); - -const layouts = {}; - -const build = 'artifacts/build-info'; -for (const artifact of fs.readdirSync(build)) { - const { solcVersion, input, output } = JSON.parse(fs.readFileSync(path.join(build, artifact))); - const decoder = solcInputOutputDecoder(input, output); - const deref = astDereferencer(output); - - for (const src in output.contracts) { - // Skip if no AST - if (!output.sources[src].ast) continue; - for (const contractDef of findAll('ContractDefinition', output.sources[src].ast)) { - // Skip libraries and interfaces that don't have storage anyway - if (['library', 'interface'].includes(contractDef.contractKind)) continue; - // Store storage layout for this version of this contract - layouts[contractDef.name] ??= {} - layouts[contractDef.name][solcVersion] = extractStorageLayout( - contractDef, - decoder, - deref, - output.contracts[src][contractDef.name].storageLayout, - ); - } - } -} - -for (const [ name, versions ] of Object.entries(layouts)) { - const keys = Object.keys(versions).sort(semver.compare); - switch (keys.length) { - case 0: // should never happen - case 1: // contract only available in one version - continue; - default: - console.log(`[${name}]`); - keys.slice(0,-1).forEach((v, i) => { - const report = getStorageUpgradeReport(versions[v], versions[keys[i+1]], {}); - if (report.ok) { - console.log(`- ${v} → ${keys[i+1]}: storage layout is compatible`); - } else { - console.log(report.explain()); - process.exitCode = 1; - } - }); - break; - } -} diff --git a/scripts/test-storage.ts b/scripts/test-storage.ts new file mode 100644 index 000000000..bc0943e8f --- /dev/null +++ b/scripts/test-storage.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2024-2025 IEXEC BLOCKCHAIN TECH +// SPDX-License-Identifier: Apache-2.0 + +import { ValidationOptions } from '@openzeppelin/upgrades-core'; +import { solcInputOutputDecoder } from '@openzeppelin/upgrades-core/dist/src-decoder'; +import { getStorageUpgradeReport } from '@openzeppelin/upgrades-core/dist/storage'; +import { extractStorageLayout } from '@openzeppelin/upgrades-core/dist/storage/extract'; +import fs from 'fs'; +import path from 'path'; +import semver from 'semver'; +import { astDereferencer, findAll } from 'solidity-ast/utils'; + +interface ContractLayouts { + [contractName: string]: { + [version: string]: any; + }; +} + +/** + * Checks storage layout compatibility between different versions of contracts + * @returns true if all storage layouts are compatible, false otherwise + */ +export function checkStorageLayoutCompatibility(): boolean { + const layouts: ContractLayouts = {}; + const buildDir = 'artifacts/build-info'; + + // Read and process all build artifacts + for (const artifact of fs.readdirSync(buildDir)) { + const buildInfo = JSON.parse(fs.readFileSync(path.join(buildDir, artifact), 'utf8')); + const { solcVersion, input, output } = buildInfo; + const decoder = solcInputOutputDecoder(input, output); + const deref = astDereferencer(output); + + // Process each contract in the build output + for (const src in output.contracts) { + // Skip if no AST + if (!output.sources[src].ast) continue; + + // Process each contract definition + for (const contractDef of findAll('ContractDefinition', output.sources[src].ast)) { + // Skip libraries and interfaces that don't have storage + if (['library', 'interface'].includes(contractDef.contractKind)) continue; + + // Initialize storage layout for this contract if not exists + layouts[contractDef.name] = layouts[contractDef.name] || {}; + + // Store storage layout for this version + layouts[contractDef.name][solcVersion] = extractStorageLayout( + contractDef, + decoder, + deref, + output.contracts[src][contractDef.name].storageLayout, + ); + } + } + } + + let hasIncompatibleLayouts = false; + + // Check compatibility between versions + for (const [name, versions] of Object.entries(layouts)) { + const keys = Object.keys(versions).sort(semver.compare); + switch (keys.length) { + case 0: // should never happen + case 1: // contract only available in one version + continue; + default: + console.log(`[${name}]`); + keys.slice(0, -1).forEach((v, i) => { + const report = getStorageUpgradeReport( + versions[v], + versions[keys[i + 1]], + {} as Required, + ); + if (report.ok) { + console.log(`- ${v} → ${keys[i + 1]}: storage layout is compatible`); + } else { + console.log(report.explain()); + hasIncompatibleLayouts = true; + } + }); + break; + } + } + + return !hasIncompatibleLayouts; +} + +// Run the check if this file is being run directly +if (require.main === module) { + const success = checkStorageLayoutCompatibility(); + process.exit(success ? 0 : 1); +} diff --git a/scripts/timelock.js b/scripts/timelock.js deleted file mode 100644 index 2278de106..000000000 --- a/scripts/timelock.js +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-FileCopyrightText: 2020 IEXEC BLOCKCHAIN TECH -// SPDX-License-Identifier: Apache-2.0 - -const { ethers } = require('ethers'); -const CONFIG = require('../config/config.json'); - -artifacts = { - require: (name) => { - try { - return require(`${process.cwd()}/build/contracts/${name}.json`); - } catch {} - try { - return require(`${process.cwd()}/node_modules/${name}.json`); - } catch {} - }, -}; - -const FACTORY = require('@iexec/solidity/deployment/factory.json'); -const TimelockController = artifacts.require('TimelockController'); - -const LIBRARIES = []; - -/***************************************************************************** - * Tools * - *****************************************************************************/ -class FactoryDeployer { - constructor(options) { - this._factory = new ethers.Contract(FACTORY.address, FACTORY.abi, options.wallet); - this._salt = options.salt || ethers.utils.randomBytes(32); - } - - async deploy(artefact, options = {}) { - console.log(`[factoryDeployer] ${artefact.contractName}`); - const libraryAddresses = await Promise.all( - LIBRARIES.filter( - ({ contractName }) => artefact.bytecode.search(contractName) != -1, - ).map(({ contractName, networks }) => ({ - pattern: new RegExp( - `__${contractName}${'_'.repeat(38 - contractName.length)}`, - 'g', - ), - address: networks[options.chainid].address, - })), - ); - - const constructorABI = artefact.abi.find((e) => e.type == 'constructor'); - const coreCode = libraryAddresses.reduce( - (code, { pattern, address }) => code.replace(pattern, address.slice(2).toLowerCase()), - artefact.bytecode, - ); - const argsCode = constructorABI - ? ethers.utils.defaultAbiCoder - .encode( - constructorABI.inputs.map((e) => e.type), - options.args || [], - ) - .slice(2) - : ''; - const code = coreCode + argsCode; - const salt = options.salt || this._salt || ethers.constants.HashZero; - const predicted = options.call - ? await this._factory.predictAddressWithCall(code, salt, options.call) - : await this._factory.predictAddress(code, salt); - - if ((await this._factory.provider.getCode(predicted)) == '0x') { - console.log(`[factory] Preparing to deploy ${artefact.contractName} ...`); - options.call - ? await this._factory.createContractAndCall(code, salt, options.call) - : await this._factory.createContract(code, salt); - console.log(`[factory] ${artefact.contractName} successfully deployed at ${predicted}`); - } else { - console.log(`[factory] ${artefact.contractName} already deployed at ${predicted}`); - } - artefact.networks[await this._factory.signer.getChainId()] = { address: predicted }; - } -} - -(async () => { - const provider = new ethers.getDefaultProvider(process.env.NODE); - const wallet = new ethers.Wallet(process.env.MNEMONIC, provider); - const chainid = await wallet.getChainId(); - const deploymentOptions = CONFIG.chains[chainid] || CONFIG.chains.default; - - // Deployer - const deployer = new FactoryDeployer({ - wallet, - chainid, - salt: deploymentOptions.v5.salt, - }); - await deployer.deploy(TimelockController, { - args: [ - 86400 * 7, // 7 days - [ - '0x9ED07B5DB7dAD3C9a0baA3E320E68Ce779063249', - '0x36e19bc6374c9cea5eb86622cf04c6b144b5b59c', - '0x56fa2d29a54b5349cd5d88ffa584bffb2986a656', - '0x9a78ecd77595ea305c6e5a0daed3669b17801d09', - '0xb5ad0c32fc5fcb5e4cba4c81f523e6d47a82ecd7', - '0xb906dc99340d0f3162dbc5b2539b0ad075649bcf', - ], - [ - '0x0B3a38b0A47aB0c5E8b208A703de366751Df5916', // v5 deployer - ], - [ - '0x0B3a38b0A47aB0c5E8b208A703de366751Df5916', // v5 deployer - ], - ], - }); -})().catch(console.error);