diff --git a/packages/contracts/src.ts/index.ts b/packages/contracts/src.ts/index.ts index d4c207508a..94eda92401 100644 --- a/packages/contracts/src.ts/index.ts +++ b/packages/contracts/src.ts/index.ts @@ -1,12 +1,11 @@ "use strict"; -import { checkResultErrors, EventFragment, Fragment, Indexed, Interface, JsonFragment, LogDescription, ParamType, Result } from "@ethersproject/abi"; +import { checkResultErrors, EventFragment, Fragment, FunctionFragment, Indexed, Interface, JsonFragment, LogDescription, ParamType, Result } from "@ethersproject/abi"; import { Block, BlockTag, Filter, FilterByBlockHash, Listener, Log, Provider, TransactionReceipt, TransactionRequest, TransactionResponse } from "@ethersproject/abstract-provider"; import { Signer, VoidSigner } from "@ethersproject/abstract-signer"; import { getContractAddress } from "@ethersproject/address"; import { BigNumber, BigNumberish } from "@ethersproject/bignumber"; import { BytesLike, concat, hexlify, isBytes, isHexString } from "@ethersproject/bytes"; -import { Zero } from "@ethersproject/constants"; import { defineReadOnly, deepCopy, getStatic, resolveProperties, shallowCopy } from "@ethersproject/properties"; import { UnsignedTransaction } from "@ethersproject/transactions"; @@ -30,15 +29,16 @@ export interface CallOverrides extends PayableOverrides { from?: string | Promise } -export type ContractFunction = (...params: Array) => Promise; export type EventFilter = { address?: string; - topics?: Array; - // @TODO: Support OR-style topcis; backwards compatible to make this change - //topics?: Array> + topics?: Array>; }; + +export type ContractFunction = (...args: Array) => Promise; + + // The (n + 1)th parameter passed to contract event callbacks export interface Event extends Log { @@ -109,189 +109,255 @@ function resolveAddresses(signerOrProvider: Signer | Provider, value: any, param return Promise.resolve(value); } -type RunFunction = (...params: Array) => Promise; +async function _populateTransaction(contract: Contract, fragment: FunctionFragment, args: Array, overrides?: Overrides): Promise { + overrides = shallowCopy(overrides); + + // Wait for all dependency addresses to be resolved (prefer the signer over the provider) + const resolved = await resolveProperties({ + args: resolveAddresses(contract.signer || contract.provider, args, fragment.inputs), + address: contract.resolvedAddress, + overrides: (resolveProperties(overrides) || { }) + }); + + // The ABI coded transaction + const tx: UnsignedTransaction = { + data: contract.interface.encodeFunctionData(fragment, resolved.args), + to: resolved.address + }; + + // Resolved Overrides + const ro = resolved.overrides; + if (ro.nonce != null) { tx.nonce = BigNumber.from(ro.nonce).toNumber(); } + if (ro.gasLimit != null) { tx.gasLimit = BigNumber.from(ro.gasLimit); } + if (ro.gasPrice != null) { tx.gasPrice = BigNumber.from(ro.gasPrice); } + + // If there was no gasLimit override, but the ABI specifies one use it + if (tx.gasLimit == null && fragment.gas != null) { + tx.gasLimit = BigNumber.from(fragment.gas).add(21000); + } -type RunOptions = { - estimate?: boolean; - callStatic?: boolean; - payable?: boolean; - transaction?: boolean; -}; + // Remvoe the overrides + delete overrides.nonce; + delete overrides.gasLimit; + delete overrides.gasPrice; -/* -export function _populateTransaction(func: FunctionFragment, args: Array, overrides?: any): Promise { - return null; -} + // @TODO: Maybe move all tx property validation to the Signer and Provider? -export function _sendTransaction(func: FunctionFragment, args: Array, overrides?: any): Promise { - return null; -} -*/ + // Make sure there are no stray overrides, which may indicate a + // typo or using an unsupported key. + const leftovers = Object.keys(overrides); + if (leftovers.length) { + logger.throwError(`cannot override ${ leftovers.map((l) => JSON.stringify(l)).join(",") }`, Logger.errors.UNSUPPORTED_OPERATION, { + operation: "overrides", + keys: leftovers + }); + } -function runMethod(contract: Contract, functionName: string, options: RunOptions): RunFunction { - const method = contract.interface.functions[functionName]; - return function(...params): Promise { - let tx: any = {} + return tx; - let blockTag: BlockTag = null; +} - // If 1 extra parameter was passed in, it contains overrides - if (params.length === method.inputs.length + 1 && typeof(params[params.length - 1]) === "object") { - tx = shallowCopy(params.pop()); +async function populateTransaction(contract: Contract, fragment: FunctionFragment, args: Array, overrides?: PayableOverrides): Promise { + overrides = shallowCopy(overrides); - if (tx.blockTag != null) { - blockTag = tx.blockTag; - } + // If the contract was just deployed, wait until it is minded + if (contract.deployTransaction != null) { + await contract._deployed(); + } - delete tx.blockTag; + // Resolved Overrides (keep value for errors) + const ro = await resolveProperties(overrides); + const value = overrides.value; + delete overrides.value; - // Check for unexpected keys (e.g. using "gas" instead of "gasLimit") - for (let key in tx) { - if (!allowedTransactionKeys[key]) { - logger.throwArgumentError(("unknown transaction override - " + key), "overrides", tx); - } - } + const tx = await _populateTransaction(contract, fragment, args, overrides); + + if (ro.value) { + const roValue = BigNumber.from(ro.value); + if (!roValue.isZero() && !fragment.payable) { + logger.throwError("non-payable method cannot override value", Logger.errors.UNSUPPORTED_OPERATION, { + operation: "overrides.value", + value: value + }); } + tx.value = roValue; + } - logger.checkArgumentCount(params.length, method.inputs.length, "passed to contract"); + return tx; +} - // Check overrides make sense - ["data", "to"].forEach(function(key) { - if (tx[key] != null) { - logger.throwError("cannot override " + key, Logger.errors.UNSUPPORTED_OPERATION, { operation: key }); - } - }); +async function populateCallTransaction(contract: Contract, fragment: FunctionFragment, args: Array, overrides?: CallOverrides): Promise { + overrides = shallowCopy(overrides); - // If the contract was just deployed, wait until it is minded - if (contract.deployTransaction != null) { - tx.to = contract._deployed(blockTag).then(() => { - return contract.resolvedAddress; - }); - } else { - tx.to = contract.resolvedAddress; - } + // If the contract was just deployed, wait until it is minded + if (contract.deployTransaction != null) { + let blockTag = undefined; + if (overrides.blockTag) { blockTag = await overrides.blockTag; } + await contract._deployed(blockTag); + } - return resolveAddresses(contract.signer || contract.provider, params, method.inputs).then((params) => { - tx.data = contract.interface.encodeFunctionData(method, params); - if (method.constant || options.callStatic) { + // Resolved Overrides + delete overrides.blockTag; + const ro = await resolveProperties(overrides); + delete overrides.from; - // Call (constant functions) always cost 0 ether - if (options.estimate) { - return Promise.resolve(Zero); - } + const tx = await populateTransaction(contract, fragment, args, overrides); + if (ro.from) { (tx).from = this.interface.constructor.getAddress(ro.from); } - if (!contract.provider && !contract.signer) { - logger.throwError("call (constant functions) require a provider or signer", Logger.errors.UNSUPPORTED_OPERATION, { operation: "call" }) - } + return tx; +} - // Check overrides make sense - ["gasLimit", "gasPrice", "value"].forEach(function(key) { - if (tx[key] != null) { - throw new Error("call cannot override " + key) ; - } - }); - if (options.transaction) { return resolveProperties(tx); } +function buildPopulate(contract: Contract, fragment: FunctionFragment): ContractFunction { + const populate = (fragment.constant) ? populateCallTransaction: populateTransaction; + return async function(...args: Array): Promise { + let overrides: CallOverrides = null; + if (args.length === fragment.inputs.length + 1 && typeof(args[args.length - 1]) === "object") { + overrides = args.pop(); + } + logger.checkArgumentCount(args.length, fragment.inputs.length, "passed to contract"); - return (contract.signer || contract.provider).call(tx, blockTag).then((value) => { + return populate(contract, fragment, args, overrides); + }; +} - try { - let result = contract.interface.decodeFunctionResult(method, value); - if (method.outputs.length === 1) { - result = result[0]; - } - return result; +function buildEstimate(contract: Contract, fragment: FunctionFragment): ContractFunction { + const signerOrProvider = (contract.signer || contract.provider); + const populate = (fragment.constant) ? populateCallTransaction: populateTransaction; + return async function(...args: Array): Promise { + let overrides: CallOverrides = null; + if (args.length === fragment.inputs.length + 1 && typeof(args[args.length - 1]) === "object") { + overrides = args.pop(); + } + logger.checkArgumentCount(args.length, fragment.inputs.length, "passed to contract"); - } catch (error) { - if (error.code === Logger.errors.CALL_EXCEPTION) { - error.address = contract.address; - error.args = params; - error.transaction = tx; - } - throw error; - } - }); + if (!signerOrProvider) { + logger.throwError("estimate require a provider or signer", Logger.errors.UNSUPPORTED_OPERATION, { operation: "estimateGas" }) + } + const tx = await populate(contract, fragment, args, overrides); + return await signerOrProvider.estimateGas(tx); + }; +} + +function buildCall(contract: Contract, fragment: FunctionFragment, collapseSimple: boolean): ContractFunction { + const signerOrProvider = (contract.signer || contract.provider); + const populate = (fragment.constant) ? populateCallTransaction: populateTransaction; + + return async function(...args: Array): Promise { + let overrides: CallOverrides = null; + let blockTag = undefined; + if (args.length === fragment.inputs.length + 1 && typeof(args[args.length - 1]) === "object") { + overrides = shallowCopy(args.pop()); + if (overrides.blockTag) { + blockTag = await overrides.blockTag; + delete overrides.blockTag; } + } + logger.checkArgumentCount(args.length, fragment.inputs.length, "passed to contract"); - // Only computing the transaction estimate - if (options.estimate) { - if (!contract.provider && !contract.signer) { - logger.throwError("estimate require a provider or signer", Logger.errors.UNSUPPORTED_OPERATION, { operation: "estimateGas" }) - } + const tx = await populate(contract, fragment, args, overrides); + const value = await signerOrProvider.call(tx, blockTag); - return (contract.signer || contract.provider).estimateGas(tx); + try { + let result = contract.interface.decodeFunctionResult(fragment, value); + if (collapseSimple && fragment.outputs.length === 1) { + result = result[0]; } + return result; - if (tx.gasLimit == null && method.gas != null) { - tx.gasLimit = BigNumber.from(method.gas).add(21000); + } catch (error) { + if (error.code === Logger.errors.CALL_EXCEPTION) { + error.address = contract.address; + error.args = args; + error.transaction = tx; } + throw error; + } + }; +} - if (tx.value != null && !method.payable) { - logger.throwArgumentError("contract method is not payable", "sendTransaction:" + method.format(), tx); +function buildSend(contract: Contract, fragment: FunctionFragment): ContractFunction { + return async function(...args: Array): Promise { + if (!contract.signer) { + logger.throwError("sending a transaction requires a signer", Logger.errors.UNSUPPORTED_OPERATION, { operation: "sendTransaction" }) + } + + // We allow CallOverrides, since the Signer can accept from + let overrides: CallOverrides = null; + if (args.length === fragment.inputs.length + 1 && typeof(args[args.length - 1]) === "object") { + overrides = shallowCopy(args.pop()); + if (overrides.blockTag != null) { + logger.throwArgumentError(`cannot override "blockTag" in transaction`, "overrides", overrides); } + } + logger.checkArgumentCount(args.length, fragment.inputs.length, "passed to contract"); - if (options.transaction) { return resolveProperties(tx); } + const txRequest = await populateCallTransaction(contract, fragment, args, overrides); - if (!contract.signer) { - logger.throwError("sending a transaction requires a signer", Logger.errors.UNSUPPORTED_OPERATION, { operation: "sendTransaction" }) - } + const tx = await contract.signer.sendTransaction(txRequest); - return contract.signer.sendTransaction(tx).then((tx) => { - const wait = tx.wait.bind(tx); - - tx.wait = (confirmations?: number) => { - return wait(confirmations).then((receipt: ContractReceipt) => { - receipt.events = receipt.logs.map((log) => { - let event: Event = (deepCopy(log)); - - let parsed: LogDescription = null; - try { - parsed = contract.interface.parseLog(log); - } catch (e){ } - - if (parsed) { - event.args = parsed.args; - event.decode = (data: BytesLike, topics?: Array) => { - return this.interface.decodeEventLog(parsed.eventFragment, data, topics); - }; - event.event = parsed.name; - event.eventSignature = parsed.signature; - } - - event.removeListener = () => { return contract.provider; } - event.getBlock = () => { - return contract.provider.getBlock(receipt.blockHash); - } - event.getTransaction = () => { - return contract.provider.getTransaction(receipt.transactionHash); - } - event.getTransactionReceipt = () => { - return Promise.resolve(receipt); - } - - return event; - }); + // Tweak the tw.wait so the receipt has extra properties + const wait = tx.wait.bind(tx); + tx.wait = (confirmations?: number) => { + return wait(confirmations).then((receipt: ContractReceipt) => { + receipt.events = receipt.logs.map((log) => { + let event: Event = (deepCopy(log)); + let parsed: LogDescription = null; + try { + parsed = contract.interface.parseLog(log); + } catch (e){ } + + // Successfully parsed the event log; include it + if (parsed) { + event.args = parsed.args; + event.decode = (data: BytesLike, topics?: Array) => { + return this.interface.decodeEventLog(parsed.eventFragment, data, topics); + }; + event.event = parsed.name; + event.eventSignature = parsed.signature; + } - return receipt; - }); - }; + // Useful operations + event.removeListener = () => { return contract.provider; } + event.getBlock = () => { + return contract.provider.getBlock(receipt.blockHash); + } + event.getTransaction = () => { + return contract.provider.getTransaction(receipt.transactionHash); + } + event.getTransactionReceipt = () => { + return Promise.resolve(receipt); + } - return tx; + return event; + }); + + return receipt; }); - }); + }; + return tx; + }; +} + +function buildDefault(contract: Contract, fragment: FunctionFragment, collapseSimple: boolean): ContractFunction { + if (fragment.constant) { + return buildCall(contract, fragment, collapseSimple); } + return buildSend(contract, fragment); } function getEventTag(filter: EventFilter): string { if (filter.address && (filter.topics == null || filter.topics.length === 0)) { return "*"; } - return (filter.address || "*") + "@" + (filter.topics ? filter.topics.join(":"): ""); -} -interface Bucket { - [name: string]: T; + return (filter.address || "*") + "@" + (filter.topics ? filter.topics.map((topic) => { + if (Array.isArray(topic)) { + return topic.join("|"); + } + return topic; + }).join(":"): ""); } class RunningEvent { @@ -375,7 +441,7 @@ class FragmentRunningEvent extends RunningEvent { readonly interface: Interface; readonly fragment: EventFragment; - constructor(address: string, contractInterface: Interface, fragment: EventFragment, topics?: Array) { + constructor(address: string, contractInterface: Interface, fragment: EventFragment, topics?: Array>) { const filter: EventFilter = { address: address } @@ -461,6 +527,7 @@ export type ContractInterface = string | Array type InterfaceFunc = (contractInterface: ContractInterface) => Interface; + export class Contract { readonly address: string; readonly interface: Interface; @@ -468,28 +535,31 @@ export class Contract { readonly signer: Signer; readonly provider: Provider; - readonly functions: Bucket; + readonly functions: { [ name: string ]: ContractFunction }; - readonly callStatic: Bucket; - readonly estimateGas: Bucket<(...params: Array) => Promise>; - readonly populateTransaction: Bucket<(...params: Array) => Promise>; + readonly callStatic: { [ name: string ]: ContractFunction }; + readonly estimateGas: { [ name: string ]: ContractFunction }; + readonly populateTransaction: { [ name: string ]: ContractFunction }; - readonly filters: Bucket<(...params: Array) => EventFilter>; + readonly filters: { [ name: string ]: (...args: Array) => EventFilter }; - readonly [ name: string ]: ContractFunction | any; + // The meta-class properties + readonly [ key: string ]: ContractFunction | any; + // This will always be an address. This will only differ from + // address if an ENS name was used in the constructor readonly resolvedAddress: Promise; // This is only set if the contract was created with a call to deploy readonly deployTransaction: TransactionResponse; - private _deployedPromise: Promise; + _deployedPromise: Promise; // A list of RunningEvents to track listsners for each event tag - private _runningEvents: { [ eventTag: string ]: RunningEvent }; + _runningEvents: { [ eventTag: string ]: RunningEvent }; // Wrapped functions to call emit and allow deregistration from the provider - private _wrappedEmits: { [ eventTag: string ]: (...args: Array) => void }; + _wrappedEmits: { [ eventTag: string ]: (...args: Array) => void }; constructor(addressOrName: string, contractInterface: ContractInterface, signerOrProvider: Signer | Provider) { logger.checkNew(new.target, Contract); @@ -519,7 +589,7 @@ export class Contract { const uniqueFilters: { [ name: string ]: Array } = { }; Object.keys(this.interface.events).forEach((eventSignature) => { const event = this.interface.events[eventSignature]; - defineReadOnly(this.filters, eventSignature, (...args: Array) => { + defineReadOnly(this.filters, eventSignature, (...args: Array) => { return { address: this.address, topics: this.interface.encodeFilterTopics(event, args) @@ -581,27 +651,27 @@ export class Contract { uniqueNames[name].push(signature); } - // @TODO: This should take in fragment - const run = runMethod(this, signature, { }); - if (this[signature] == null) { - defineReadOnly(this, signature, run); + defineReadOnly(this, signature, buildDefault(this, fragment, true)); } + // We do not collapse simple calls on this bucket, which allows + // frameworks to safely use this without introspection as well as + // allows decoding error recovery. if (this.functions[signature] == null) { - defineReadOnly(this.functions, signature, run); + defineReadOnly(this.functions, signature, buildDefault(this, fragment, false)); } if (this.callStatic[signature] == null) { - defineReadOnly(this.callStatic, signature, runMethod(this, signature, { callStatic: true })); + defineReadOnly(this.callStatic, signature, buildCall(this, fragment, true)); } if (this.populateTransaction[signature] == null) { - defineReadOnly(this.populateTransaction, signature, runMethod(this, signature, { transaction: true })); + defineReadOnly(this.populateTransaction, signature, buildPopulate(this, fragment)); } if (this.estimateGas[signature] == null) { - defineReadOnly(this.estimateGas, signature, runMethod(this, signature, { estimate: true })); + defineReadOnly(this.estimateGas, signature, buildEstimate(this, fragment)); } }); @@ -763,7 +833,11 @@ export class Contract { // Is it a known topichash? (throws if no matching topichash) try { - const fragment = this.interface.getEvent(eventName.topics[0]); + const topic = eventName.topics[0]; + if (typeof(topic) !== "string") { + throw new Error("invalid topic"); // @TODO: May happen for anonymous events + } + const fragment = this.interface.getEvent(topic); return this._normalizeRunningEvent(new FragmentRunningEvent(this.address, this.interface, fragment, eventName.topics)); } catch (error) { } @@ -993,6 +1067,7 @@ export class ContractFactory { defineReadOnly(this, "signer", signer || null); } + // @TODO: Future; rename to populteTransaction? getDeployTransaction(...args: Array): UnsignedTransaction { let tx: UnsignedTransaction = { }; @@ -1093,5 +1168,3 @@ export class ContractFactory { return new Contract(address, contractInterface, signer); } } - -