UNPKG

@graphprotocol/toolshed

Version:

A collection of tools and utilities for the Graph Protocol Typescript components

234 lines (203 loc) 8.29 kB
import { Provider, Signer } from 'ethers' import fs from 'fs' import { assertObject } from '../lib/assert' import { logDebug, logError, logWarn } from '../lib/logger' import { ContractList, loadContract } from './contract' export type AddressBookJson<ChainId extends number = number, ContractName extends string = string> = Record< ChainId, Record<ContractName, AddressBookEntry> > export type AddressBookEntry = { address: string proxy?: 'graph' | 'transparent' proxyAdmin?: string implementation?: string } /** * An abstract class to manage an address book * The address book must be a JSON file with the following structure: * { * "<CHAIN_ID>": { * "<CONTRACT_NAME>": { * "address": "<ADDRESS>", * "proxy": "<graph|transparent>", // optional * "proxyAdmin": "<ADDRESS>", // optional * "implementation": "<ADDRESS>", // optional * ... * } * } * Uses generics to allow specifying a ContractName type to indicate which contracts should be loaded from the address book * Implementation should provide: * - `isContractName(name: string): name is ContractName`, a type predicate to check if a given string is a ContractName * - `loadContracts(signerOrProvider?: Signer | Provider): ContractList<ContractName>` to load contracts from the address book */ export abstract class AddressBook<ChainId extends number = number, ContractName extends string = string> { // The path to the address book file public file: string // The chain id of the network the address book should be loaded for public chainId: ChainId // The raw contents of the address book file public addressBook: AddressBookJson<ChainId, ContractName> // Contracts in the address book of type ContractName private validContracts: ContractName[] = [] // Contracts in the address book that are not of type ContractName, these are ignored private invalidContracts: string[] = [] // Type predicate to check if a given string is a ContractName abstract isContractName(name: string): name is ContractName // Method to load valid contracts from the address book abstract loadContracts(signerOrProvider?: Signer | Provider): ContractList<ContractName> /** * Constructor for the `AddressBook` class * * @param _file the path to the address book file * @param _chainId the chain id of the network the address book should be loaded for * @param _strictAssert * * @throws AssertionError if the target file is not a valid address book * @throws Error if the target file does not exist */ constructor(_file: string, _chainId: ChainId, _strictAssert = false) { this.file = _file this.chainId = _chainId logDebug(`Loading address book from ${this.file}.`) // Create empty address book if file doesn't exist if (!fs.existsSync(this.file)) { const emptyAddressBook = { [this.chainId]: {} } fs.writeFileSync(this.file, JSON.stringify(emptyAddressBook, null, 2)) logDebug(`Created new address book at ${this.file}`) } // Load address book and validate its shape const fileContents = JSON.parse(fs.readFileSync(this.file, 'utf8')) as Record<string, unknown> if (typeof fileContents !== 'object' || fileContents === null) { throw new Error('Address book is not an object') } if (!fileContents[this.chainId]) { fileContents[this.chainId] = {} } this.assertAddressBookJson(fileContents) this.addressBook = fileContents this._parseAddressBook() } /** * List entry names in the address book * * @returns a list with all the names of the entries in the address book */ listEntries(): ContractName[] { return this.validContracts } entryExists(name: string): boolean { if (!this.isContractName(name)) { throw new Error(`Contract name ${name} is not a valid contract name`) } return this.addressBook[this.chainId][name] !== undefined } /** * Get an entry from the address book * * @param name the name of the contract to get * @param strict if true it will throw an error if the contract is not found * @returns the address book entry for the contract * Returns an empty address book entry if the contract is not found */ getEntry(name: string): AddressBookEntry { if (!this.isContractName(name)) { throw new Error(`Contract name ${name} is not a valid contract name`) } const entry = this.addressBook[this.chainId][name] this._assertAddressBookEntry(entry) return entry } /** * Save an entry to the address book * Allows partial address book entries to be saved * @param name the name of the contract to save * @param entry the address book entry for the contract */ setEntry(name: ContractName, entry: Partial<AddressBookEntry>): void { if (entry.address === undefined) { entry.address = '0x0000000000000000000000000000000000000000' } this._assertAddressBookEntry(entry) this.addressBook[this.chainId][name] = entry try { fs.writeFileSync(this.file, JSON.stringify(this.addressBook, null, 2)) } catch (e: unknown) { if (e instanceof Error) logError(`Error saving entry: ${e.message}`) else logError(`Error saving entry`) } } /** * Parse address book and separate valid and invalid contracts */ _parseAddressBook() { const contractList = this.addressBook[this.chainId] const contractNames = contractList ? Object.keys(contractList) : [] for (const contract of contractNames) { if (!this.isContractName(contract)) { this.invalidContracts.push(contract) } else { this.validContracts.push(contract) } } if (this.invalidContracts.length > 0) { logWarn( `Detected invalid contracts in address book - these will not be loaded: ${this.invalidContracts.join(', ')}`, ) } } /** * Loads all valid contracts from an address book * * @param addressBook Address book to use * @param signerOrProvider Signer or provider to use * @param enableTxLogging Enable transaction logging to console and output file. Defaults to false. * @returns the loaded contracts */ _loadContracts(signerOrProvider?: Signer | Provider, enableTxLogging?: boolean): ContractList<ContractName> { const contracts = {} as ContractList<ContractName> if (this.listEntries().length == 0) { logError('No valid contracts found in address book') return contracts } for (const contractName of this.listEntries()) { logDebug(`Loading contract ${contractName}`) const contract = loadContract( contractName, this.getEntry(contractName).address, signerOrProvider, enableTxLogging, ) contracts[contractName] = contract } return contracts } // Asserts the provided object has the correct JSON format shape for an address book // This method can be overridden by subclasses to provide custom validation assertAddressBookJson(json: unknown): asserts json is AddressBookJson<ChainId, ContractName> { this._assertAddressBookJson(json) } // Asserts the provided object is a valid address book _assertAddressBookJson(json: unknown): asserts json is AddressBookJson { assertObject(json, 'Assertion failed: address book is not an object') const contractList = json[this.chainId] assertObject(contractList, 'Assertion failed: chain contract list is not an object') const contractNames = Object.keys(contractList) for (const contractName of contractNames) { this._assertAddressBookEntry(contractList[contractName]) } } // Asserts the provided object is a valid address book entry _assertAddressBookEntry(entry: unknown): asserts entry is AddressBookEntry { assertObject(entry) if (!('address' in entry)) { throw new Error('Address book entry must have an address field') } const allowedFields = ['address', 'implementation', 'proxyAdmin', 'proxy'] const entryFields = Object.keys(entry) const invalidFields = entryFields.filter((field) => !allowedFields.includes(field)) if (invalidFields.length > 0) { throw new Error(`Address book entry contains invalid fields: ${invalidFields.join(', ')}`) } } }