UNPKG

0xweb

Version:

Contract package manager and other web3 tools

427 lines (376 loc) 14.6 kB
import di from 'a-di'; import alot from 'alot'; import { type TAbiItem } from '@dequanto/types/TAbi'; import { IBlockchainExplorer } from '@dequanto/explorer/IBlockchainExplorer'; import { $address } from '@dequanto/utils/$address'; import { $require } from '@dequanto/utils/$require'; import { GeneratorFromAbi } from './GeneratorFromAbi'; import { TAddress } from '@dequanto/models/TAddress'; import { File, Directory } from 'atma-io'; import { class_Uri, obj_setProperty } from 'atma-utils'; import { BlockchainExplorerFactory } from '@dequanto/explorer/BlockchainExplorerFactory'; import { TPlatform } from '@dequanto/models/TPlatform'; import { $path } from '@dequanto/utils/$path'; import { $logger, l } from '@dequanto/utils/$logger'; import { Web3Client } from '@dequanto/clients/Web3Client'; import { Web3ClientFactory } from '@dequanto/clients/Web3ClientFactory'; import { EvmBytecode } from '@dequanto/evm/EvmBytecode'; import { HardhatProvider } from '@dequanto/hardhat/HardhatProvider'; import { TEth } from '@dequanto/models/TEth'; import { $hex } from '@dequanto/utils/$hex'; import { SolidityParser } from '@dequanto/solidity/SolidityParser'; export interface IGenerateOptions { platform: TPlatform target?: 'js' | 'ts' | 'mjs' | 'cjs' // Class name to create name: string // Contract name to extract from *.sol contractName?: string defaultAddress?: TAddress source: { abi?: string | TAddress | TAbiItem[] code?: string path?: string } flags?: { useHardhatForSolFiles?: boolean } output?: string outputFileExt?: string location?: string /** * a) Slot to read the implementation address from * b) The implementation address * c) Method function to read the implementation address from */ implementation?: TEth.Hex | TEth.Address | string /** ABI will be save alongside with TS classes */ saveAbi?: boolean /** Sources will not be saved if FALSE */ saveSources?: boolean } const KEYS = { 'platform': 1, 'name': 1, 'defaultAddress': 1, 'source.abi': 1, 'source.code': 1, 'source.path': 1, 'output': 1, 'implementation': 1 }; export class Generator { explorer: IBlockchainExplorer; client: Web3Client; constructor (public options: IGenerateOptions) { let { platform, } = options; this.explorer = BlockchainExplorerFactory.get(platform); this.client = Web3ClientFactory.get(platform); if (options.defaultAddress == null && $address.isValid(options.source.abi)) { options.defaultAddress = options.source.abi; } } static async generateFromSol (path: string) { let name = /(?<contractName>[^\\/]+).sol$/.exec(path)?.groups?.contractName; $require.notEmpty(name, `Contract name not resolved from the path ${path}`); $require.True(await File.existsAsync(path), `${path} does not exist`); let generator = new Generator({ platform: 'hardhat', name: name, source: { path }, output: './0xc/hardhat/', saveSources: false, }); return generator.generate(); } /** * @deprecated Was possible to generate the Contract Class based on the meta information header in TS file */ static async generateForClass (path: string) { let i = path.indexOf('*'); if (i > -1) { let base = path.substring(0, i).replace(/\\/g, '/'); let glob = path.substring(i).replace(/\\/g, '/'); let files = await Directory.readFilesAsync(base, glob); await alot(files) .forEachAsync(async file => { await this.generateForClass(file.uri.toString()); }) .toArrayAsync({ threads: 1 }); return; } let jsCode = await File.readAsync <string> (path, { skipHooks: true }); let startIdx = jsCode.indexOf('/*'); let endIdx = jsCode.indexOf('*/'); if (startIdx === -1 || endIdx === -1) { throw new Error(`${path} should contain dequanto options in comment`); } let header = jsCode.substring(startIdx, endIdx); let lines = header.split('\n'); let rgxOpts = /(?<key>[\w.]+)\s*:\s*(?<value>[^\n]+)/; let options = {} as IGenerateOptions; for (let line of lines) { let match = rgxOpts.exec(line); if (match == null) { continue; } let key = match.groups.key.trim(); let value: any = match.groups.value.trim(); if (value === 'true') { value = true; } else if (value === 'false') { value = false; } else if (/^[\d\.]$/.test(value)) { value = Number(value) } if (key in KEYS === false) { throw new Error(`Invalid options key ${key}`); } obj_setProperty(options, key, value); } // make Contracts in dequanto package relative to dequanto root let rgxRoot = /[\\\/]dequanto[\\\/].+/; if (rgxRoot.test(path)) { let root = path.replace(rgxRoot, '/dequanto/'); options.output = class_Uri.combine(root, options.output); } let generator = new Generator({ ...options, target: 'ts', location: new class_Uri(path).toDir().toString() }); await generator.generate(); } async generate () { let { name, platform: network, output, implementation: implSource, source } = this.options; let abi: TAbiItem[]; // compiled json meta output let artifact: string; // the SOL source file let sourceMain: string; let implementation: TAddress; let sources: IGeneratorSources; if (source.code == null && source.path == null) { // Load from block-explorer by address (follows also proxy) let result = await this.getAbi({ implementation: implSource as TEth.Address }); abi = result.abiJson; implementation = result.implementation; sources = await this.getSources(name, { implementation, sourcePath: result.sourcePath, contractName: result.contractName, }); } else { // From local JSON or SOL file let result = await this.getContractData(); abi = result.abi; sources = result.source; artifact = result.artifact; sourceMain = /\.sol$/.test(source.path) ? source.path : void 0; } let generator = di.resolve(GeneratorFromAbi); let address = this.options.defaultAddress; return await generator.generate(abi, { target: this.options.target, network: network, name: name, contractName: sources?.contractName, address: address, output: output, outputFileExt: this.options.outputFileExt, implementation: implementation, sources: sources?.files, sourceMain: sourceMain, saveAbi: this.options.saveAbi, saveSources: this.options.saveSources, client: this.client, artifact }); } private async getAbi(opts: { implementation: TEth.Address }) { let abi = this.options.source.abi; $require.notNull(abi, `Abi not provided to get the Abi Json from`); if (Array.isArray(abi)) { return { abiJson: abi, implementation: opts.implementation }; } let sourcePath: string; let contractName: string; let abiJson: TAbiItem[] let implementation: TAddress; if (abi.startsWith('0x')) { let { abi, implementation: impl } = await this.getAbiByAddress(opts); abiJson = abi; implementation = impl; } else { let path = abi; let json = await this.readFile(path) if (Array.isArray(json)) { // simple json with abi as an array abiJson = json; } else { // should be compiled json artifact abiJson = json.abi; sourcePath = json.sourceName; contractName = json.contractName; } } $require.notNull(abiJson, `Abi not resolved from ${abi}`); return { abiJson, implementation, sourcePath, contractName }; } private async getSources (name: string, opts: { implementation?: TAddress, contractName?: string, sourcePath?: string location?: string }): Promise<{ contractName: string, files: { [path: string]: { content: string } } }> { if (opts.sourcePath != null) { let contractName = opts.contractName ?? name; let { path, code } = await this.resolveSourcePath(opts.sourcePath, opts); if (path == null) { console.error(`Source path not found: ${opts.sourcePath}`); return null; } return { contractName, files: { [path]: { content: code } } }; } if ($address.isValid(opts.implementation) === false) { return null; } $logger.log('Loading contract source code from blockchain explorer.'); let meta = await this.explorer.getContractSource(opts.implementation); if (meta?.SourceCode == null) { $logger.log('No contract source found.'); return null; } return meta.SourceCode; } private async resolveSourcePath (path: string, opts?: { // current directory, in case we have loaded *.json artifact previously location?: string }): Promise<{ path: string, code: string }> { if (opts?.location != null && path.startsWith('.')) { let absPath = $path.normalize(class_Uri.combine(opts.location, path)); if (await File.existsAsync(absPath)) { return { path: absPath, code: await File.readAsync(absPath) }; } } if (await File.existsAsync(path)) { return { path, code: await File.readAsync(path) }; } let nodePath = `node_modules/${path}`; if (await File.existsAsync(nodePath)) { return { path: nodePath, code: await File.readAsync(nodePath) }; } return { path: null, code: null }; } private async getContractData (): Promise<{ abi: TAbiItem[] bytecode?: TEth.Hex artifact?: string source: { contractName: string, files: { [path: string]: { content: string } } } }> { let { code, path } = this.options.source ?? {}; if (code == null && path == null) { throw new Error(`getContractData was called without "code" and "path"`); } if (typeof path === 'string' && path.endsWith('.json')) { let json = await this.readFile(path); if (Array.isArray(json)) { return { abi: json, bytecode: null, artifact: null, source: null } } let { abi, bytecode, sourceName, contractName } = json $require.True(Array.isArray(abi), `Invalid abi json: ${path}. Expected the "abi" property to be the array.`); let source = await this.getSources(contractName, { sourcePath: sourceName, contractName, location: new class_Uri(path).toDir() }); return { abi, bytecode, artifact: path, source }; } if (this.options?.flags?.useHardhatForSolFiles === true) { let provider = new HardhatProvider(); let result = path != null ? await provider.compileSol(path) : await provider.compileCode(code); return result; } let { abi, source } = await SolidityParser.extractAbi({ path, code }, this.options.contractName); return { abi, source }; } private async readFile<T = any> (path: string) { let location = this.options.location; if (location && $path.isAbsolute(path) === false) { // if path not relative, check the file at ClassFile location let relPath = class_Uri.combine(location, path); if (await File.existsAsync(relPath)) { path = relPath; } } let content = await File.readAsync <T> (path); return content; } private async getAbiByAddress (opts: { implementation: string }) { let address = $require.Address(this.options.source?.abi as any, 'contract address is not valid'); let explorer = $require.notNull(this.explorer, `Explorer not resolved for network: ${this.options.platform}`); try { $logger.log(`Loading contracts ABI for ${address}. `) let { abi, implementation } = await explorer.getContractAbi(address, opts); let hasProxy = $address.eq(address, implementation) === false; $logger.log(`Proxy detected: ${hasProxy ? 'YES' : 'NO' }`, hasProxy ? implementation : ''); let abiJson = JSON.parse(abi) as TAbiItem[]; return { abi: abiJson, implementation }; } catch (error) { let message = `ABI is not resolved from ${this.options.platform}/${address}: ${error.message ?? error}. Extract from bytecode...`; l`${message}` let code = await this.client.getCode(address); if ($hex.isEmpty(code)) { throw new Error(`${this.options.platform}:${address} is not a contract`); } let evm = new EvmBytecode(code); let abi = await evm.getAbi(); return { abi }; } } } export interface IGeneratorSources { contractName: string, files: { [path: string]: { content: string } } }