diff --git a/src/conditions/base/contract.ts b/src/conditions/base/contract.ts index 83a13b6ae..c4221e1da 100644 --- a/src/conditions/base/contract.ts +++ b/src/conditions/base/contract.ts @@ -1,3 +1,4 @@ +import { ethers } from 'ethers'; import Joi from 'joi'; import { ETH_ADDRESS_REGEXP } from '../const'; @@ -6,31 +7,58 @@ import { RpcCondition, rpcConditionRecord } from './rpc'; export const STANDARD_CONTRACT_TYPES = ['ERC20', 'ERC721']; -const functionAbiVariable = Joi.object({ - internalType: Joi.string(), // TODO is this needed? - name: Joi.string().required(), - type: Joi.string().required(), -}); - const functionAbiSchema = Joi.object({ name: Joi.string().required(), type: Joi.string().valid('function').required(), - inputs: Joi.array().items(functionAbiVariable), - outputs: Joi.array().items(functionAbiVariable), - // TODO: Should we restrict this to 'view'? - stateMutability: Joi.string(), + inputs: Joi.array(), + outputs: Joi.array(), + stateMutability: Joi.string().valid('view', 'pure').required(), }).custom((functionAbi, helper) => { + // Is `functionABI` a valid function fragment? + let asInterface; + try { + asInterface = new ethers.utils.Interface([functionAbi]); + } catch (e: unknown) { + const { message } = e as Error; + return helper.message({ + custom: message, + }); + } + + if (!asInterface.functions) { + return helper.message({ + custom: '"functionAbi" is missing a function fragment', + }); + } + + if (Object.values(asInterface.functions).length !== 1) { + return helper.message({ + custom: '"functionAbi" must contain exactly one function fragment', + }); + } + + // Now we just need to validate against the parent schema // Validate method name const method = helper.state.ancestors[0].method; - if (functionAbi.name !== method) { + + let functionFragment; + try { + functionFragment = asInterface.getFunction(method); + } catch (e) { + return helper.message({ + custom: `"functionAbi" has no matching function for "${method}"`, + }); + } + + if (!functionFragment) { return helper.message({ - custom: '"method" must be the same as "functionAbi.name"', + custom: `"functionAbi" not valid for method: "${method}"`, }); } // Validate nr of parameters const parameters = helper.state.ancestors[0].parameters; - if (functionAbi.inputs?.length !== parameters.length) { + if (functionFragment.inputs.length !== parameters.length) { return helper.message({ custom: '"parameters" must have the same length as "functionAbi.inputs"', }); @@ -39,7 +67,7 @@ const functionAbiSchema = Joi.object({ return functionAbi; }); -export const contractConditionRecord: Record = { +export const contractConditionRecord = { ...rpcConditionRecord, contractAddress: Joi.string().pattern(ETH_ADDRESS_REGEXP).required(), standardContractType: Joi.string() diff --git a/test/unit/conditions/base/contract.test.ts b/test/unit/conditions/base/contract.test.ts index 25fdddd8b..ee988bbb6 100644 --- a/test/unit/conditions/base/contract.test.ts +++ b/test/unit/conditions/base/contract.test.ts @@ -7,7 +7,7 @@ import { import { ContractCondition } from '../../../../src/conditions/base'; import { USER_ADDRESS_PARAM } from '../../../../src/conditions/const'; import { fakeWeb3Provider } from '../../../utils'; -import { testContractConditionObj } from '../../testVariables'; +import { testContractConditionObj, testFunctionAbi } from '../../testVariables'; describe('validation', () => { it('accepts on a valid schema', () => { @@ -103,30 +103,10 @@ describe('accepts either standardContractType or functionAbi but not both or non }); describe('supports custom function abi', () => { - const fakeFunctionAbi = { - name: 'myFunction', - type: 'function', - inputs: [ - { - name: 'account', - type: 'address', - }, - { - name: 'myCustomParam', - type: 'uint256', - }, - ], - outputs: [ - { - name: 'someValue', - type: 'uint256', - }, - ], - }; const contractConditionObj = { ...testContractConditionObj, standardContractType: undefined, - functionAbi: fakeFunctionAbi, + functionAbi: testFunctionAbi, method: 'myFunction', parameters: [USER_ADDRESS_PARAM, ':customParam'], returnValueTest: { @@ -143,7 +123,7 @@ describe('supports custom function abi', () => { const customParams: Record = {}; customParams[myCustomParam] = 1234; - it('accepts custom function abi', async () => { + it('accepts custom function abi with a custom parameter', async () => { const asJson = await conditionContext .withCustomParams(customParams) .toJson(); @@ -151,4 +131,109 @@ describe('supports custom function abi', () => { expect(asJson).toContain(USER_ADDRESS_PARAM); expect(asJson).toContain(myCustomParam); }); + + it.each([ + { + method: 'balanceOf', + functionAbi: { + name: 'balanceOf', + type: 'function', + inputs: [{ name: '_owner', type: 'address' }], + outputs: [{ name: 'balance', type: 'uint256' }], + stateMutability: 'view', + }, + }, + { + method: 'get', + functionAbi: { + name: 'get', + type: 'function', + inputs: [], + outputs: [], + stateMutability: 'pure', + }, + }, + ])('accepts well-formed functionAbi', ({ method, functionAbi }) => { + expect(() => + new ContractCondition({ + ...contractConditionObj, + parameters: functionAbi.inputs.map( + (input) => `fake_parameter_${input}` + ), // + functionAbi, + method, + }).toObj() + ).not.toThrow(); + }); + + it.each([ + { + method: '1234', + expectedError: '"functionAbi.name" must be a string', + functionAbi: { + name: 1234, // invalid value + type: 'function', + inputs: [{ name: '_owner', type: 'address' }], + outputs: [{ name: 'balance', type: 'uint256' }], + stateMutability: 'view', + }, + }, + { + method: 'transfer', + expectedError: '"functionAbi.inputs" must be an array', + functionAbi: { + name: 'transfer', + type: 'function', + inputs: 'invalid value', // invalid value + outputs: [{ name: '_status', type: 'bool' }], + stateMutability: 'pure', + }, + }, + { + method: 'get', + expectedError: + '"functionAbi.stateMutability" must be one of [view, pure]', + functionAbi: { + name: 'get', + type: 'function', + inputs: [], + outputs: [], + stateMutability: 'invalid', // invalid value + }, + }, + { + method: 'test', + expectedError: '"functionAbi.outputs" must be an array', + functionAbi: { + name: 'test', + type: 'function', + inputs: [], + outputs: 'invalid value', // Invalid value + stateMutability: 'pure', + }, + }, + { + method: 'calculatePow', + expectedError: + 'Invalid condition: "parameters" must have the same length as "functionAbi.inputs"', + functionAbi: { + name: 'calculatePow', + type: 'function', + // 'inputs': [] // Missing inputs array + outputs: [{ name: 'result', type: 'uint256' }], + stateMutability: 'view', + }, + }, + ])( + 'rejects malformed functionAbi', + ({ method, expectedError, functionAbi }) => { + expect(() => + new ContractCondition({ + ...contractConditionObj, + functionAbi, + method, + }).toObj() + ).toThrow(expectedError); + } + ); }); diff --git a/test/unit/testVariables.ts b/test/unit/testVariables.ts index ef943513a..36031e816 100644 --- a/test/unit/testVariables.ts +++ b/test/unit/testVariables.ts @@ -48,6 +48,7 @@ export const testContractConditionObj = { export const testFunctionAbi = { name: 'myFunction', type: 'function', + stateMutability: 'view', inputs: [ { internalType: 'address', @@ -67,5 +68,4 @@ export const testFunctionAbi = { type: 'uint256', }, ], - stateMutability: 'view', };