diff --git a/libs/ghaction/.eslintrc b/libs/ghaction/.eslintrc new file mode 100644 index 0000000000..f57f93144e --- /dev/null +++ b/libs/ghaction/.eslintrc @@ -0,0 +1,15 @@ +{ + "extends": "../../.eslintrc", + "rules": { + "dot-notation": "off", + "no-unused-vars": "off", + "no-use-before-define": "off" + }, + "env": { + "browser": true, + "amd": true, + "node": true, + "es6": true + }, + "ignorePatterns": ["!**/*"] +} \ No newline at end of file diff --git a/libs/ghaction/.npmignore b/libs/ghaction/.npmignore new file mode 100644 index 0000000000..aa8e45f12b --- /dev/null +++ b/libs/ghaction/.npmignore @@ -0,0 +1 @@ +src/ \ No newline at end of file diff --git a/libs/ghaction/README.md b/libs/ghaction/README.md new file mode 100644 index 0000000000..5055697b95 --- /dev/null +++ b/libs/ghaction/README.md @@ -0,0 +1,7 @@ +# ghaction + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ghaction` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/ghaction/package.json b/libs/ghaction/package.json new file mode 100644 index 0000000000..36c47cf9f4 --- /dev/null +++ b/libs/ghaction/package.json @@ -0,0 +1,40 @@ +{ + "name": "@remix-project/ghaction", + "version": "0.1.0", + "description": "Tools to help with github actions for running solidity tests", + "main": "src/index.js", + "types": "src/index.d.ts", + "contributors": [ + { + "name": "David Disu", + "email": "daviddisu8@gmail.com" + } + ], + "dependencies": { + "ethers": "^5.7.2", + "ganache": "^7.5.0", + "chai": "^4.3.7", + "@remix-project/remix-solidity": "^0.5.5", + "@ethereum-waffle/chai": "^3.4.4" + }, + "devDependencies": { + "typescript": "^3.7.4" + }, + "scripts": { + "build": "tsc" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ethereum/remix-project.git" + }, + "author": "Remix Team", + "license": "MIT", + "bugs": { + "url": "https://github.com/ethereum/remix-project/issues" + }, + "homepage": "https://github.com/ethereum/remix-project/tree/master/libs/ghaction#readme", + "typings": "src/index.d.ts" +} \ No newline at end of file diff --git a/libs/ghaction/src/artefacts-helper.ts b/libs/ghaction/src/artefacts-helper.ts new file mode 100644 index 0000000000..357a97dd18 --- /dev/null +++ b/libs/ghaction/src/artefacts-helper.ts @@ -0,0 +1,22 @@ +import { CompilationResult } from '@remix-project/remix-solidity' +import * as fs from 'fs/promises' +import * as path from 'path' + +declare global { + const remixContractArtefactsPath: string +} + +export async function getArtefactsByContractName (contractIdentifier: string) { + const contractArtefacts = await fs.readdir(global.remixContractArtefactsPath) + let contract + + for (const artefactFile of contractArtefacts) { + const artefact = await fs.readFile(path.join(global.remixContractArtefactsPath, artefactFile), 'utf-8') + const artefactJSON: CompilationResult = JSON.parse(artefact) + const contractFullPath = (Object.keys(artefactJSON.contracts!)).find((contractName) => artefactJSON.contracts![contractName] && artefactJSON.contracts![contractName][contractIdentifier]) + + contract = contractFullPath ? artefactJSON.contracts![contractFullPath!][contractIdentifier] : undefined + if (contract) break + } + return contract +} \ No newline at end of file diff --git a/libs/ghaction/src/chai.ts b/libs/ghaction/src/chai.ts new file mode 100644 index 0000000000..f27c0f0232 --- /dev/null +++ b/libs/ghaction/src/chai.ts @@ -0,0 +1,7 @@ +import * as chai from 'chai' +import { waffleChai } from '@ethereum-waffle/chai' + +chai.use(waffleChai) + +export * from 'chai' +export { chai } diff --git a/libs/ghaction/src/ethers.ts b/libs/ghaction/src/ethers.ts new file mode 100644 index 0000000000..0940523c23 --- /dev/null +++ b/libs/ghaction/src/ethers.ts @@ -0,0 +1,9 @@ +import { ethers } from 'ethers' +import * as ganache from 'ganache' +import * as hhEtherMethods from './methods' + +global.ganacheProvider = ganache.provider({ logging: { quiet: true } }) +for(const method in hhEtherMethods) Object.defineProperty(ethers, method, { value: hhEtherMethods[method]}) + +export * from 'ethers' +export { ethers } \ No newline at end of file diff --git a/libs/ghaction/src/index.ts b/libs/ghaction/src/index.ts new file mode 100644 index 0000000000..49dd0864dc --- /dev/null +++ b/libs/ghaction/src/index.ts @@ -0,0 +1,2 @@ +export * from './chai' +export * from './ethers' \ No newline at end of file diff --git a/libs/ghaction/src/methods.ts b/libs/ghaction/src/methods.ts new file mode 100644 index 0000000000..9669fea72c --- /dev/null +++ b/libs/ghaction/src/methods.ts @@ -0,0 +1,244 @@ +import { ethers } from "ethers" +import { getArtefactsByContractName } from './artefacts-helper' +import { SignerWithAddress } from './signer' +declare global { + const ganacheProvider: any +} + +const isFactoryOptions = (signerOrOptions) => { + if (!signerOrOptions || signerOrOptions === undefined || signerOrOptions instanceof ethers.Signer) return false + return true +} + +const isArtifact = (artifact) => { + const { + contractName, + sourceName, + abi, + bytecode, + deployedBytecode, + linkReferences, + deployedLinkReferences, + } = artifact + + return ( + typeof contractName === "string" && + typeof sourceName === "string" && + Array.isArray(abi) && + typeof bytecode === "string" && + typeof deployedBytecode === "string" && + linkReferences !== undefined && + deployedLinkReferences !== undefined + ) +} + +function linkBytecode(artifact, libraries) { + let bytecode = artifact.bytecode + + for (const { sourceName, libraryName, address } of libraries) { + const linkReferences = artifact.linkReferences[sourceName][libraryName] + for (const { start, length } of linkReferences) { + bytecode = + bytecode.substr(0, 2 + start * 2) + + address.substr(2) + + bytecode.substr(2 + (start + length) * 2) + } + } + + return bytecode +} + +const collectLibrariesAndLink = async (artifact, libraries) => { + const neededLibraries = [] + for (const [sourceName, sourceLibraries] of Object.entries(artifact.linkReferences)) { + for (const libName of Object.keys(sourceLibraries)) { + neededLibraries.push({ sourceName, libName }) + } + } + + const linksToApply = new Map() + for (const [linkedLibraryName, linkedLibraryAddress] of Object.entries(libraries)) { + if (!ethers.utils.isAddress(linkedLibraryAddress)) { + throw new Error( + `You tried to link the contract ${artifact.contractName} with the library ${linkedLibraryName}, but provided this invalid address: ${linkedLibraryAddress}` + ) + } + + const matchingNeededLibraries = neededLibraries.filter((lib) => { + return ( + lib.libName === linkedLibraryName || + `${lib.sourceName}:${lib.libName}` === linkedLibraryName + ) + }) + + if (matchingNeededLibraries.length === 0) { + let detailedMessage + if (neededLibraries.length > 0) { + const libraryFQNames = neededLibraries + .map((lib) => `${lib.sourceName}:${lib.libName}`) + .map((x) => `* ${x}`) + .join("\n") + detailedMessage = `The libraries needed are: + ${libraryFQNames}` + } else { + detailedMessage = "This contract doesn't need linking any libraries." + } + throw new Error( + `You tried to link the contract ${artifact.contractName} with ${linkedLibraryName}, which is not one of its libraries. + ${detailedMessage}` + ) + } + + if (matchingNeededLibraries.length > 1) { + const matchingNeededLibrariesFQNs = matchingNeededLibraries + .map(({ sourceName, libName }) => `${sourceName}:${libName}`) + .map((x) => `* ${x}`) + .join("\n") + throw new Error( + `The library name ${linkedLibraryName} is ambiguous for the contract ${artifact.contractName}. + It may resolve to one of the following libraries: + ${matchingNeededLibrariesFQNs} + To fix this, choose one of these fully qualified library names and replace where appropriate.` + ) + } + + const [neededLibrary] = matchingNeededLibraries + + const neededLibraryFQN = `${neededLibrary.sourceName}:${neededLibrary.libName}` + + // The only way for this library to be already mapped is + // for it to be given twice in the libraries user input: + // once as a library name and another as a fully qualified library name. + if (linksToApply.has(neededLibraryFQN)) { + throw new Error( + `The library names ${neededLibrary.libName} and ${neededLibraryFQN} refer to the same library and were given as two separate library links. + Remove one of them and review your library links before proceeding.` + ) + } + + linksToApply.set(neededLibraryFQN, { + sourceName: neededLibrary.sourceName, + libraryName: neededLibrary.libName, + address: linkedLibraryAddress, + }) + } + + if (linksToApply.size < neededLibraries.length) { + const missingLibraries = neededLibraries + .map((lib) => `${lib.sourceName}:${lib.libName}`) + .filter((libFQName) => !linksToApply.has(libFQName)) + .map((x) => `* ${x}`) + .join("\n") + + throw new Error( + `The contract ${artifact.contractName} is missing links for the following libraries: + ${missingLibraries}` + ) + } + + return linkBytecode(artifact, [...linksToApply.values()]) +} + +// Convert output.contracts.. in Artifact object compatible form +const resultToArtifact = (result) => { + const { fullyQualifiedName, artefact } = result + return { + contractName: fullyQualifiedName.split(':')[1], + sourceName: fullyQualifiedName.split(':')[0], + abi: artefact.abi, + bytecode: artefact.evm.bytecode.object, + deployedBytecode: artefact.evm.deployedBytecode.object, + linkReferences: artefact.evm.bytecode.linkReferences, + deployedLinkReferences: artefact.evm.deployedBytecode.linkReferences + } +} + +const getContractFactory = async (contractNameOrABI: ethers.ContractInterface, bytecode?: string, signerOrOptions = null) => { + if (bytecode && contractNameOrABI) { + return new ethers.ContractFactory(contractNameOrABI, bytecode, signerOrOptions || (new ethers.providers.Web3Provider(ganacheProvider)).getSigner()) + } else if (typeof contractNameOrABI === 'string') { + const contract = await getArtefactsByContractName(contractNameOrABI) + + if (contract) { + return new ethers.ContractFactory(contract.abi, contract.evm.bytecode.object, signerOrOptions || (new ethers.providers.Web3Provider(ganacheProvider)).getSigner()) + } else { + throw new Error('Contract artefacts not found') + } + } else { + throw new Error('Invalid contract name or ABI provided') + } +} + +const getContractAt = async (contractNameOrABI: ethers.ContractInterface, address: string, signer = null) => { + const provider = new ethers.providers.Web3Provider(ganacheProvider) + + if(typeof contractNameOrABI === 'string') { + try { + const result = await getArtefactsByContractName(contractNameOrABI) + + if (result) { + return new ethers.Contract(address, result.abi, signer || provider.getSigner()) + } else { + throw new Error('Contract artefacts not found') + } + } catch(e) { throw e } + } else { + return new ethers.Contract(address, contractNameOrABI, signer || provider.getSigner()) + } +} + +const getSigner = async (address: string) => { + const provider = new ethers.providers.Web3Provider(ganacheProvider) + const signer = provider.getSigner(address) + + return SignerWithAddress.create(signer) +} + +const getSigners = async () => { + try { + const provider = new ethers.providers.Web3Provider(ganacheProvider) + const accounts = await provider.listAccounts() + + return await Promise.all( accounts.map((account) => getSigner(account))) + } catch(err) { throw err } +} + +const getContractFactoryFromArtifact = async (artifact, signerOrOptions = null) => { + let libraries = {} + let signer + + if (!isArtifact(artifact)) { + throw new Error( + `You are trying to create a contract factory from an artifact, but you have not passed a valid artifact parameter.` + ) + } + + if (isFactoryOptions(signerOrOptions)) { + signer = signerOrOptions.signer; + libraries = signerOrOptions.libraries ?? {}; + } else { + signer = signerOrOptions; + } + + if (artifact.bytecode === "0x") { + throw new Error( + `You are trying to create a contract factory for the contract ${artifact.contractName}, which is abstract and can't be deployed. +If you want to call a contract using ${artifact.contractName} as its interface use the "getContractAt" function instead.` + ) + } + + const linkedBytecode = await collectLibrariesAndLink(artifact, libraries) + return new ethers.ContractFactory(artifact.abi, linkedBytecode || artifact.bytecode, signer || (new ethers.providers.Web3Provider(web3Provider)).getSigner()) +} + +const getContractAtFromArtifact = async (artifact, address, signerOrOptions = null) => { + if (!isArtifact(artifact)) { + throw new Error( + `You are trying to create a contract factory from an artifact, but you have not passed a valid artifact parameter.` + ) + } + + return await getContractAt(artifact.abi, address, signerOrOptions) +} + +export { getContractAtFromArtifact, getContractFactoryFromArtifact, getSigners, getSigner, getContractAt, getContractFactory } \ No newline at end of file diff --git a/libs/ghaction/src/signer.ts b/libs/ghaction/src/signer.ts new file mode 100644 index 0000000000..61c9ee5b83 --- /dev/null +++ b/libs/ghaction/src/signer.ts @@ -0,0 +1,42 @@ +import { ethers } from "ethers" + +export class SignerWithAddress extends ethers.Signer { + static async create(signer) { + return new SignerWithAddress(await signer.getAddress(), signer) + } + + constructor(address, _signer) { + super() + this.address = address + this._signer = _signer + this.provider = _signer.provider + } + + async getAddress() { + return this.address + } + + signMessage(message){ + return this._signer.signMessage(message) + } + + signTransaction(transaction) { + return this._signer.signTransaction(transaction) + } + + sendTransaction(transaction) { + return this._signer.sendTransaction(transaction) + } + + connect(provider) { + return new SignerWithAddress(this.address, this._signer.connect(provider)) + } + + _signTypedData(...params) { + return this._signer._signTypedData(...params) + } + + toJSON() { + return `` + } +} diff --git a/libs/ghaction/tsconfig.json b/libs/ghaction/tsconfig.json new file mode 100644 index 0000000000..ec3a5d0c25 --- /dev/null +++ b/libs/ghaction/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["jest", "node"] + }, + "include": ["**/*.ts"] +} \ No newline at end of file diff --git a/libs/ghaction/tsconfig.lib.json b/libs/ghaction/tsconfig.lib.json new file mode 100644 index 0000000000..736089467e --- /dev/null +++ b/libs/ghaction/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "declaration": true, + "rootDir": "./src", + "types": ["node"] + }, + "exclude": [ + "**/*.spec.ts" + ], + "include": ["**/*.ts"] + } + \ No newline at end of file diff --git a/tsconfig.paths.json b/tsconfig.paths.json index 88d06365a1..e0e9f4ac27 100644 --- a/tsconfig.paths.json +++ b/tsconfig.paths.json @@ -145,6 +145,9 @@ "@remix-ui/locale-module": [ "libs/remix-ui/locale-module/src/index.ts" ], + "@remixproject/ghaction-helper": [ + "libs/ghaction-helper/src/index.ts" + ] } } } \ No newline at end of file