diff --git a/src/characters/pre-recipient.ts b/src/characters/pre-recipient.ts index a1b624169..1fb3066a5 100644 --- a/src/characters/pre-recipient.ts +++ b/src/characters/pre-recipient.ts @@ -8,7 +8,7 @@ import { } from '@nucypher/nucypher-core'; import { ethers } from 'ethers'; -import { ConditionSet } from '../conditions'; +import { Condition, ConditionContext } from '../conditions'; import { Keyring } from '../keyring'; import { PolicyMessageKit } from '../kits/message'; import { RetrievalResult } from '../kits/retrieval'; @@ -106,8 +106,11 @@ export class PreTDecDecrypter { .map((condition) => JSON.parse(condition.toString())) .reduce((acc: Record[], val) => acc.concat(val), []); - const conditionContext = - ConditionSet.fromConditionList(conditions).buildContext(provider); + const conditionsList = conditions.map((ele: Record) => { + return Condition.fromObj(ele); + }); + + const conditionContext = new ConditionContext(conditionsList, provider); const policyMessageKits = messageKits.map((mk) => PolicyMessageKit.fromMessageKit( diff --git a/src/conditions/base/condition.ts b/src/conditions/base/condition.ts index 16c8c95ec..f4caa12c2 100644 --- a/src/conditions/base/condition.ts +++ b/src/conditions/base/condition.ts @@ -26,7 +26,6 @@ export class Condition { } return { ...value, - _class: this.constructor.name, }; } @@ -36,7 +35,6 @@ export class Condition { this: new (...args: any[]) => T, obj: Map ): T { - delete obj._class; return new this(obj); } diff --git a/src/conditions/base/contract.ts b/src/conditions/base/contract.ts index 4f4b5dd3c..83a13b6ae 100644 --- a/src/conditions/base/contract.ts +++ b/src/conditions/base/contract.ts @@ -2,12 +2,12 @@ import Joi from 'joi'; import { ETH_ADDRESS_REGEXP } from '../const'; -import { RpcCondition, rpcConditionSchema } from './rpc'; +import { RpcCondition, rpcConditionRecord } from './rpc'; export const STANDARD_CONTRACT_TYPES = ['ERC20', 'ERC721']; const functionAbiVariable = Joi.object({ - internalType: Joi.string().required(), + internalType: Joi.string(), // TODO is this needed? name: Joi.string().required(), type: Joi.string().required(), }); @@ -18,7 +18,7 @@ const functionAbiSchema = Joi.object({ inputs: Joi.array().items(functionAbiVariable), outputs: Joi.array().items(functionAbiVariable), // TODO: Should we restrict this to 'view'? - // stateMutability: Joi.string().valid('view').required(), + stateMutability: Joi.string(), }).custom((functionAbi, helper) => { // Validate method name const method = helper.state.ancestors[0].method; @@ -39,8 +39,8 @@ const functionAbiSchema = Joi.object({ return functionAbi; }); -const contractMethodSchemas: Record = { - ...rpcConditionSchema, +export const contractConditionRecord: Record = { + ...rpcConditionRecord, contractAddress: Joi.string().pattern(ETH_ADDRESS_REGEXP).required(), standardContractType: Joi.string() .valid(...STANDARD_CONTRACT_TYPES) @@ -50,8 +50,10 @@ const contractMethodSchemas: Record = { parameters: Joi.array().required(), }; +export const contractConditionSchema = Joi.object(contractConditionRecord) + // At most one of these keys needs to be present + .xor('standardContractType', 'functionAbi'); + export class ContractCondition extends RpcCondition { - public readonly schema = Joi.object(contractMethodSchemas) - // At most one of these keys needs to be present - .xor('standardContractType', 'functionAbi'); + public readonly schema = contractConditionSchema; } diff --git a/src/conditions/base/rpc.ts b/src/conditions/base/rpc.ts index 4a088817d..2eb7d2202 100644 --- a/src/conditions/base/rpc.ts +++ b/src/conditions/base/rpc.ts @@ -18,7 +18,7 @@ const makeParameters = () => })), }); -export const rpcConditionSchema = { +export const rpcConditionRecord = { chain: Joi.number() .valid(...SUPPORTED_CHAINS) .required(), @@ -29,6 +29,8 @@ export const rpcConditionSchema = { returnValueTest: returnValueTestSchema.required(), }; +export const rpcConditionSchema = Joi.object(rpcConditionRecord); + export class RpcCondition extends Condition { - public readonly schema = Joi.object(rpcConditionSchema); + public readonly schema = rpcConditionSchema; } diff --git a/src/conditions/base/time.ts b/src/conditions/base/time.ts index 8b136e4a7..4f4671dd0 100644 --- a/src/conditions/base/time.ts +++ b/src/conditions/base/time.ts @@ -2,20 +2,22 @@ import Joi from 'joi'; import { omit } from '../../utils'; -import { RpcCondition, rpcConditionSchema } from './rpc'; +import { RpcCondition, rpcConditionRecord } from './rpc'; -const BLOCKTIME_METHOD = 'blocktime'; +export const BLOCKTIME_METHOD = 'blocktime'; -const timeConditionSchema = { +export const timeConditionRecord: Record = { // TimeCondition is an RpcCondition with the method set to 'blocktime' and no parameters - ...omit(rpcConditionSchema, ['parameters']), + ...omit(rpcConditionRecord, ['parameters']), method: Joi.string().valid(BLOCKTIME_METHOD).required(), }; +export const timeConditionSchema = Joi.object(timeConditionRecord); + export class TimeCondition extends RpcCondition { public readonly defaults: Record = { method: BLOCKTIME_METHOD, }; - public readonly schema = Joi.object(timeConditionSchema); + public readonly schema = timeConditionSchema; } diff --git a/src/conditions/compound-condition.ts b/src/conditions/compound-condition.ts new file mode 100644 index 000000000..fe0249677 --- /dev/null +++ b/src/conditions/compound-condition.ts @@ -0,0 +1,31 @@ +import Joi from 'joi'; + +import { Condition } from './base/condition'; +import { contractConditionSchema } from './base/contract'; +import { rpcConditionSchema } from './base/rpc'; +import { timeConditionSchema } from './base/time'; + +const OR_OPERATOR = 'or'; +const AND_OPERATOR = 'and'; + +const LOGICAL_OPERATORS = [AND_OPERATOR, OR_OPERATOR]; + +export const compoundConditionSchema = Joi.object({ + operator: Joi.string() + .valid(...LOGICAL_OPERATORS) + .required(), + operands: Joi.array() + .min(2) + .items( + rpcConditionSchema, + timeConditionSchema, + contractConditionSchema, + Joi.link('#compoundCondition') + ) + .required() + .valid(), +}).id('compoundCondition'); + +export class CompoundCondition extends Condition { + public readonly schema = compoundConditionSchema; +} diff --git a/src/conditions/condition-set.ts b/src/conditions/condition-set.ts index 29417b068..674a9d5b7 100644 --- a/src/conditions/condition-set.ts +++ b/src/conditions/condition-set.ts @@ -1,68 +1,54 @@ import { Conditions as WASMConditions } from '@nucypher/nucypher-core'; -import deepEqual from 'deep-equal'; import { ethers } from 'ethers'; -import { toJSON } from '../utils'; +import { objectEquals, toJSON } from '../utils'; -import { Condition } from './base'; +import { + Condition, + ContractCondition, + RpcCondition, + TimeCondition, +} from './base'; +import { BLOCKTIME_METHOD } from './base/time'; +import { CompoundCondition } from './compound-condition'; import { ConditionContext } from './context'; -import { Operator } from './operator'; - -type ConditionOrOperator = Condition | Operator; export type ConditionSetJSON = { - conditions: ({ operator: string } | Record)[]; + condition: Record; }; export class ConditionSet { - constructor(public readonly conditions: ReadonlyArray) {} + constructor(public readonly condition: Condition) {} - public validate() { - // Expects [Condition, Operator, Condition, Operator, ...], where the last element is a Condition + public toObj(): ConditionSetJSON { + // TODO add version here + const conditionData = this.condition.toObj(); + return { condition: conditionData }; + } - if (this.conditions.length % 2 === 0) { - throw new Error( - 'conditions must be odd length, every other element being an operator' - ); + public static fromObj(obj: ConditionSetJSON): ConditionSet { + // version specific logic can go here + const underlyingConditionData = obj.condition; + + if (underlyingConditionData.operator) { + return new ConditionSet(new CompoundCondition(underlyingConditionData)); } - this.conditions.forEach((cndOrOp: ConditionOrOperator, index) => { - if (index % 2 && !(cndOrOp instanceof Operator)) { - throw new Error( - `index ${index} must be an Operator, got ${cndOrOp.constructor.name} instead` - ); - } - if (!(index % 2) && cndOrOp instanceof Operator) { - throw new Error( - `index ${index} must be a Condition, got ${cndOrOp.constructor.name} instead` - ); + if (underlyingConditionData.method) { + if (underlyingConditionData.method === BLOCKTIME_METHOD) { + return new ConditionSet(new TimeCondition(underlyingConditionData)); } - }); - return true; - } - public toObj(): ConditionSetJSON { - const conditions = this.conditions.map((cnd) => cnd.toObj()); - return { conditions }; - } + if (underlyingConditionData.contractAddress) { + return new ConditionSet(new ContractCondition(underlyingConditionData)); + } - public static fromObj(obj: ConditionSetJSON): ConditionSet { - const conditions = obj.conditions.map((cnd) => { - if ('operator' in cnd) { - return Operator.fromObj(cnd as Record); + if ((underlyingConditionData.method as string).startsWith('eth_')) { + return new ConditionSet(new RpcCondition(underlyingConditionData)); } - return Condition.fromObj(cnd); - }); - return new ConditionSet(conditions); - } + } - public static fromConditionList(list: ReadonlyArray>) { - return new ConditionSet( - list.map((ele: Record) => { - if ('operator' in ele) return Operator.fromObj(ele); - return Condition.fromObj(ele); - }) - ); + throw new Error('Invalid condition: unrecognized condition data'); } public toJson(): string { @@ -80,22 +66,10 @@ export class ConditionSet { public buildContext( provider: ethers.providers.Web3Provider ): ConditionContext { - return new ConditionContext(this.toWASMConditions(), provider); + return new ConditionContext([this.condition], provider); } public equals(other: ConditionSet): boolean { - // TODO: This is a hack to make the equals method work for Condition - // TODO: Implement proper casting from Conditon to _class type - const thisConditions = this.conditions.map((cnd) => { - const asObj = cnd.toObj(); - delete asObj._class; - return asObj; - }); - const otherConditions = other.conditions.map((cnd) => { - const asObj = cnd.toObj(); - delete asObj._class; - return asObj; - }); - return deepEqual(thisConditions, otherConditions); + return objectEquals(this.condition.toObj(), other.condition.toObj()); } } diff --git a/src/conditions/context/context.ts b/src/conditions/context/context.ts index f966dfc5c..dc385245b 100644 --- a/src/conditions/context/context.ts +++ b/src/conditions/context/context.ts @@ -2,6 +2,7 @@ import { Conditions as WASMConditions } from '@nucypher/nucypher-core'; import { ethers } from 'ethers'; import { fromJSON, toJSON } from '../../utils'; +import { Condition } from '../base/condition'; import { USER_ADDRESS_PARAM } from '../const'; import { TypedSignature, WalletAuthenticationProvider } from './providers'; @@ -16,7 +17,7 @@ export class ConditionContext { private readonly walletAuthProvider: WalletAuthenticationProvider; constructor( - private readonly conditions: WASMConditions, + private readonly conditions: ReadonlyArray, // TODO: We don't always need a web3 provider, only in cases where some specific context parameters are used // TODO: Consider making this optional or introducing a different pattern to handle that private readonly web3Provider: ethers.providers.Web3Provider, @@ -42,8 +43,11 @@ export class ConditionContext { const requestedParameters = new Set(); // Search conditions for parameters - const { conditions } = fromJSON(this.conditions.toString()); - for (const cond of conditions) { + const conditions = this.conditions.map((cnd) => cnd.toObj()); + const conditionsToCheck = fromJSON( + new WASMConditions(toJSON(conditions)).toString() + ); + for (const cond of conditionsToCheck) { // Check return value test const rvt = cond.returnValueTest.value; if (typeof rvt === 'string' && rvt.startsWith(CONTEXT_PARAM_PREFIX)) { diff --git a/src/conditions/index.ts b/src/conditions/index.ts index 8047d8129..7e335a0e0 100644 --- a/src/conditions/index.ts +++ b/src/conditions/index.ts @@ -5,6 +5,6 @@ export { predefined, base }; export { Condition } from './base/condition'; export type { ConditionSetJSON } from './condition-set'; export { ConditionSet } from './condition-set'; -export { Operator } from './operator'; +export { CompoundCondition } from './compound-condition'; export type { CustomContextParam } from './context'; export { ConditionContext } from './context'; diff --git a/src/conditions/operator.ts b/src/conditions/operator.ts deleted file mode 100644 index 51a6cdb2e..000000000 --- a/src/conditions/operator.ts +++ /dev/null @@ -1,26 +0,0 @@ -export class Operator { - static readonly LOGICAL_OPERATORS: ReadonlyArray = ['and', 'or']; - static readonly AND = new Operator('and'); - static readonly OR = new Operator('or'); - - public constructor(public readonly operator: string) { - if (!Operator.LOGICAL_OPERATORS.includes(operator)) { - throw `"${operator}" must be one of [${Operator.LOGICAL_OPERATORS.join( - ', ' - )}]`; - } - this.operator = operator; - } - - public toObj() { - return { operator: this.operator }; - } - - public static fromObj(obj: Record) { - return new Operator(obj.operator); - } - - public equals(other: Operator): boolean { - return this.operator === other.operator; - } -} diff --git a/test/docs/cbd.test.ts b/test/docs/cbd.test.ts index 5d4be4a84..b5de452be 100644 --- a/test/docs/cbd.test.ts +++ b/test/docs/cbd.test.ts @@ -74,10 +74,10 @@ describe('Get Started (CBD PoC)', () => { parameters: [5954], }); - const conditions = new ConditionSet([ - NFTOwnership, + const conditions = new ConditionSet( + NFTOwnership // Other conditions can be added here - ]); + ); // 4. Build a Strategy const newStrategy = PreStrategy.create(newCohort); @@ -101,7 +101,7 @@ describe('Get Started (CBD PoC)', () => { }, }; const NFTBalance = new ContractCondition(NFTBalanceConfig); - const newConditions = new ConditionSet([NFTBalance]); + const newConditions = new ConditionSet(NFTBalance); const plaintext = 'this is a secret'; const encrypter = newDeployed.makeEncrypter(newConditions); const encryptedMessageKit = encrypter.encryptMessagePre(plaintext); @@ -123,14 +123,13 @@ describe('Get Started (CBD PoC)', () => { // const expectedAddresses = fakeUrsulas().map((u) => u.checksumAddress); - const condObj = conditions.conditions[0].toObj(); + const condObj = conditions.condition.toObj(); expect(newCohort.ursulaAddresses).toEqual(expectedAddresses); expect(condObj.parameters).toEqual([5954]); expect(condObj.chain).toEqual(5); expect(condObj.contractAddress).toEqual( '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D' ); - expect(conditions.validate()).toEqual(true); expect(publishToBlockchainSpy).toHaveBeenCalled(); expect(getUrsulasSpy).toHaveBeenCalledTimes(2); expect(generateKFragsSpy).toHaveBeenCalled(); diff --git a/test/integration/enrico.test.ts b/test/integration/enrico.test.ts index bd90e5d5a..86eb4f18e 100644 --- a/test/integration/enrico.test.ts +++ b/test/integration/enrico.test.ts @@ -105,7 +105,7 @@ describe('enrico', () => { chain: 5, }); - const conditions = new ConditionSet([ownsBufficornNFT]); + const conditions = new ConditionSet(ownsBufficornNFT); const enrico = new Enrico(policyKey, undefined, conditions); const encrypted = enrico.encryptMessagePre(toBytes(message)); @@ -135,8 +135,8 @@ describe('enrico', () => { parameters: [6969], }); - const conditions = new ConditionSet([ownsBufficornNFT]); - const updatedConditions = new ConditionSet([ownsNonsenseNFT]); + const conditions = new ConditionSet(ownsBufficornNFT); + const updatedConditions = new ConditionSet(ownsNonsenseNFT); const enrico = new Enrico(policyKey, undefined, conditions); const encrypted = enrico.encryptMessagePre( diff --git a/test/integration/pre.test.ts b/test/integration/pre.test.ts index 1a9fb5600..1db2b8df2 100644 --- a/test/integration/pre.test.ts +++ b/test/integration/pre.test.ts @@ -1,13 +1,13 @@ import { CapsuleFrag, reencrypt } from '@nucypher/nucypher-core'; import { conditions, Enrico, MessageKit, PolicyMessageKit } from '../../src'; +import { CompoundCondition } from '../../src/conditions'; import { RetrievalResult } from '../../src/kits/retrieval'; import { toBytes, zip } from '../../src/utils'; import { fakeAlice, fakeBob, fakeUrsulas, reencryptKFrags } from '../utils'; const { predefined: { ERC721Ownership }, - Operator, ConditionSet, } = conditions; @@ -94,11 +94,12 @@ describe('proxy reencryption', () => { chain: 1, parameters: [1], }); - const conditionsSet = new ConditionSet([ - genuineUndead, - Operator.OR, - gnomePals, - ]); + const conditionsSet = new ConditionSet( + new CompoundCondition({ + operator: 'or', + operands: [genuineUndead.toObj(), gnomePals.toObj()], + }) + ); const enrico = new Enrico(policyEncryptingKey, undefined, conditionsSet); const encryptedMessage = enrico.encryptMessagePre(plaintext); diff --git a/test/unit/cbd-strategy.test.ts b/test/unit/cbd-strategy.test.ts index 44e2adc49..743c65d0b 100644 --- a/test/unit/cbd-strategy.test.ts +++ b/test/unit/cbd-strategy.test.ts @@ -38,7 +38,7 @@ const ownsNFT = new ERC721Ownership({ parameters: [3591], chain: 5, }); -const conditionSet = new ConditionSet([ownsNFT]); +const conditionSet = new ConditionSet(ownsNFT); const ursulas = fakeUrsulas().slice(0, 3); const variant = FerveoVariant.Precomputed; diff --git a/test/unit/conditions/base/condition.test.ts b/test/unit/conditions/base/condition.test.ts index dc9b310ab..2a567434c 100644 --- a/test/unit/conditions/base/condition.test.ts +++ b/test/unit/conditions/base/condition.test.ts @@ -50,7 +50,6 @@ describe('serialization', () => { const contract = new ContractCondition(testContractConditionObj); expect(contract.toObj()).toEqual({ ...testContractConditionObj, - _class: 'ContractCondition', }); }); @@ -59,7 +58,6 @@ describe('serialization', () => { expect(contract.toObj()).toEqual({ ...contract.defaults, ...testContractConditionObj, - _class: 'ERC721Ownership', }); }); }); diff --git a/test/unit/conditions/base/contract.test.ts b/test/unit/conditions/base/contract.test.ts new file mode 100644 index 000000000..3be6bdcd6 --- /dev/null +++ b/test/unit/conditions/base/contract.test.ts @@ -0,0 +1,151 @@ +import { SecretKey } from '@nucypher/nucypher-core'; + +import { ConditionSet, CustomContextParam } from '../../../../src/conditions'; +import { ContractCondition } from '../../../../src/conditions/base'; +import { USER_ADDRESS_PARAM } from '../../../../src/conditions/const'; +import { fakeWeb3Provider } from '../../../utils'; +import { testContractConditionObj } from '../../testVariables'; + +describe('validation', () => { + it('accepts on a valid schema', () => { + const contract = new ContractCondition(testContractConditionObj); + expect(contract.toObj()).toEqual({ + ...testContractConditionObj, + }); + }); + + it('rejects an invalid schema', () => { + const badContractCondition = { + ...testContractConditionObj, + // Intentionally removing `contractAddress` + contractAddress: undefined, + }; + const badEvm = new ContractCondition(badContractCondition); + expect(() => badEvm.toObj()).toThrow( + 'Invalid condition: "contractAddress" is required' + ); + + const { error } = badEvm.validate(badContractCondition); + expect(error?.message).toEqual('"contractAddress" is required'); + }); +}); + +describe('accepts either standardContractType or functionAbi but not both or none', () => { + const standardContractType = 'ERC20'; + const functionAbi = { + inputs: [ + { + name: '_owner', + type: 'address', + }, + ], + name: 'balanceOf', + outputs: [ + { + name: 'balance', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }; + + it('accepts standardContractType', () => { + const conditionObj = { + ...testContractConditionObj, + standardContractType, + functionAbi: undefined, + }; + const contractCondition = new ContractCondition(conditionObj); + expect(contractCondition.toObj()).toEqual({ + ...conditionObj, + }); + }); + + it('accepts functionAbi', () => { + const conditionObj = { + ...testContractConditionObj, + functionAbi, + standardContractType: undefined, + }; + const contractCondition = new ContractCondition(conditionObj); + expect(contractCondition.toObj()).toEqual({ + ...conditionObj, + }); + }); + + it('rejects both', () => { + const conditionObj = { + ...testContractConditionObj, + standardContractType, + functionAbi, + }; + const contractCondition = new ContractCondition(conditionObj); + expect(() => contractCondition.toObj()).toThrow( + '"value" contains a conflict between exclusive peers [standardContractType, functionAbi]' + ); + }); + + it('rejects none', () => { + const conditionObj = { + ...testContractConditionObj, + standardContractType: undefined, + functionAbi: undefined, + }; + const contractCondition = new ContractCondition(conditionObj); + expect(() => contractCondition.toObj()).toThrow( + '"value" must contain at least one of [standardContractType, functionAbi]' + ); + }); +}); + +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, + method: 'myFunction', + parameters: [USER_ADDRESS_PARAM, ':customParam'], + returnValueTest: { + index: 0, + comparator: '==', + value: USER_ADDRESS_PARAM, + }, + }; + const contractCondition = new ContractCondition(contractConditionObj); + const web3Provider = fakeWeb3Provider(SecretKey.random().toBEBytes()); + const conditionSet = new ConditionSet(contractCondition); + const conditionContext = conditionSet.buildContext(web3Provider); + const myCustomParam = ':customParam'; + const customParams: Record = {}; + customParams[myCustomParam] = 1234; + + it('accepts custom function abi', async () => { + const asJson = await conditionContext + .withCustomParams(customParams) + .toJson(); + expect(asJson).toBeDefined(); + expect(asJson).toContain(USER_ADDRESS_PARAM); + expect(asJson).toContain(myCustomParam); + }); +}); diff --git a/test/unit/conditions/base/rpc.test.ts b/test/unit/conditions/base/rpc.test.ts index 8803854cf..ccd65f63a 100644 --- a/test/unit/conditions/base/rpc.test.ts +++ b/test/unit/conditions/base/rpc.test.ts @@ -6,7 +6,6 @@ describe('validation', () => { const rpc = new RpcCondition(testRpcConditionObj); expect(rpc.toObj()).toEqual({ ...testRpcConditionObj, - _class: 'RpcCondition', }); }); diff --git a/test/unit/conditions/base/time.test.ts b/test/unit/conditions/base/time.test.ts index 05b459dfc..a043c8eba 100644 --- a/test/unit/conditions/base/time.test.ts +++ b/test/unit/conditions/base/time.test.ts @@ -16,7 +16,6 @@ describe('validation', () => { returnValueTest, chain: 5, method: 'blocktime', - _class: 'TimeCondition', }); }); diff --git a/test/unit/conditions/compound-condition.test.ts b/test/unit/conditions/compound-condition.test.ts new file mode 100644 index 000000000..396b6fddf --- /dev/null +++ b/test/unit/conditions/compound-condition.test.ts @@ -0,0 +1,85 @@ +import { CompoundCondition } from '../../../src/conditions'; +import { ERC721Ownership } from '../../../src/conditions/predefined/erc721'; +import { + testContractConditionObj, + testRpcConditionObj, + testTimeConditionObj, +} from '../testVariables'; + +describe('validate', () => { + const ownsBufficornNFT = ERC721Ownership.fromObj({ + contractAddress: '0x1e988ba4692e52Bc50b375bcC8585b95c48AaD77', + parameters: [3591], + chain: 5, + }).toObj(); + + it('accepts or operator', () => { + const orCondition = new CompoundCondition({ + operator: 'or', + operands: [ownsBufficornNFT, testTimeConditionObj], + }).toObj(); + + expect(orCondition.operator).toEqual('or'); + expect(orCondition.operands).toEqual([ + ownsBufficornNFT, + testTimeConditionObj, + ]); + }); + + it('accepts and operator', () => { + const orCondition = new CompoundCondition({ + operator: 'and', + operands: [testContractConditionObj, testTimeConditionObj], + }).toObj(); + + expect(orCondition.operator).toEqual('and'); + expect(orCondition.operands).toEqual([ + testContractConditionObj, + testTimeConditionObj, + ]); + }); + + it('rejects an invalid operator', () => { + expect(() => + new CompoundCondition({ + operator: 'not-an-operator', + operands: [testRpcConditionObj, testTimeConditionObj], + }).toObj() + ).toThrow('"operator" must be one of [and, or]'); + }); + + it('rejects invalid number of operands = 0', () => { + expect(() => + new CompoundCondition({ + operator: 'or', + operands: [], + }).toObj() + ).toThrow('"operands" must contain at least 2 items'); + }); + + it('rejects invalid number of operands = 1', () => { + expect(() => + new CompoundCondition({ + operator: 'or', + operands: [testRpcConditionObj], + }).toObj() + ).toThrow('"operands" must contain at least 2 items'); + }); + + it('it allows recursive compound conditions', () => { + const compoundCondition = new CompoundCondition({ + operator: 'and', + operands: [ + testContractConditionObj, + testTimeConditionObj, + testRpcConditionObj, + { + operator: 'or', + operands: [ownsBufficornNFT, testContractConditionObj], + }, + ], + }).toObj(); + expect(compoundCondition.operator).toEqual('and'); + expect(compoundCondition.operands).toHaveLength(4); + }); +}); diff --git a/test/unit/conditions/condition-set.test.ts b/test/unit/conditions/condition-set.test.ts index 8a9ec9be7..bc3104008 100644 --- a/test/unit/conditions/condition-set.test.ts +++ b/test/unit/conditions/condition-set.test.ts @@ -1,75 +1,270 @@ -import { Condition, ConditionSet, Operator } from '../../../src/conditions'; +import { CompoundCondition, ConditionSet } from '../../../src/conditions'; +import { + ContractCondition, + RpcCondition, + TimeCondition, +} from '../../../src/conditions/base'; +import { USER_ADDRESS_PARAM } from '../../../src/conditions/const'; import { ERC721Balance } from '../../../src/conditions/predefined'; import { TEST_CHAIN_ID, TEST_CONTRACT_ADDR, - TEST_CONTRACT_ADDR_2, + testFunctionAbi, + testReturnValueTest, +} from '../testVariables'; +import { + testContractConditionObj, + testRpcConditionObj, + testTimeConditionObj, } from '../testVariables'; describe('condition set', () => { - describe('validation', () => { - const cond1 = new ERC721Balance({ - contractAddress: TEST_CONTRACT_ADDR, - }); - const cond2 = new ERC721Balance({ - contractAddress: TEST_CONTRACT_ADDR_2, - }); + const erc721BalanceCondition = new ERC721Balance({ + chain: TEST_CHAIN_ID, + contractAddress: TEST_CONTRACT_ADDR, + }); - it('validates on a correct set', async () => { - const validSets = [[cond1, Operator.AND, cond2], [cond1]].map( - (set) => new ConditionSet(set) - ); + const contractConditionNoAbi = new ContractCondition( + testContractConditionObj + ); - validSets.forEach((set) => { - expect(set.validate()).toBeTruthy(); - }); - }); + const customParamKey = ':customParam'; + const contractConditionWithAbiObj = { + ...testContractConditionObj, + standardContractType: undefined, + functionAbi: testFunctionAbi, + method: testFunctionAbi.name, + parameters: [USER_ADDRESS_PARAM, customParamKey], + returnValueTest: { + ...testReturnValueTest, + }, + }; + const contractConditionWithAbi = new ContractCondition( + contractConditionWithAbiObj + ); - it('throws on an invalid set', async () => { - const setWithInvalidLength = new ConditionSet([cond1, cond2]); - expect(() => setWithInvalidLength.validate()).toThrow( - 'conditions must be odd length, every other element being an operator' - ); - - const setWithOperatorInsteadOfComparator = new ConditionSet([ - cond1, - Operator.AND, - Operator.AND, - ]); - expect(() => setWithOperatorInsteadOfComparator.validate()).toThrow( - 'index 2 must be a Condition, got Operator instead' - ); - - const setWithConditionInsteadOfOperator = new ConditionSet([ - cond1, - cond2, - cond1, - ]); - expect(() => setWithConditionInsteadOfOperator.validate()).toThrow( - 'index 1 must be an Operator, got ERC721Balance instead' - ); - }); + const rpcCondition = new RpcCondition(testRpcConditionObj); + const timeCondition = new TimeCondition(testTimeConditionObj); + const compoundCondition = new CompoundCondition({ + operator: 'and', + operands: [ + testContractConditionObj, + testTimeConditionObj, + testRpcConditionObj, + { + operator: 'or', + operands: [testTimeConditionObj, testContractConditionObj], + }, + ], }); describe('serialization', () => { - it('serializes to and from json', async () => { - const set = new ConditionSet([ - new ERC721Balance({ - chain: TEST_CHAIN_ID, - contractAddress: TEST_CONTRACT_ADDR, - }), - ]); - const setJson = set.toJson(); - expect(setJson).toBeDefined(); - expect(setJson).toContain('chain'); - expect(setJson).toContain(TEST_CHAIN_ID.toString()); - expect(setJson).toContain('contractAddress'); - expect(setJson).toContain(TEST_CONTRACT_ADDR.toString()); - - const setFromJson = ConditionSet.fromJSON(setJson); - expect(setFromJson).toBeDefined(); - expect(setFromJson.conditions.length).toEqual(1); - expect(setFromJson.conditions[0]).toBeInstanceOf(Condition); // TODO: This should arguably be an ERC721Balance + it.each([ + erc721BalanceCondition, + contractConditionNoAbi, + contractConditionWithAbi, + rpcCondition, + timeCondition, + compoundCondition, + ])('serializes to and from json', async (condition) => { + const conditionSet = new ConditionSet(condition); + const conditionSetJson = conditionSet.toJson(); + expect(conditionSetJson).toBeDefined(); + const conditionSetFromJson = ConditionSet.fromJSON(conditionSetJson); + expect(conditionSetFromJson).toBeDefined(); + expect(conditionSetFromJson.equals(conditionSetFromJson)).toBeTruthy(); }); }); + + it.each([ + // no "operator" nor "method" value + { + randoKey: 'randoValue', + otherKey: 'otherValue', + }, + // invalid "method" and no "contractAddress" + { + method: 'doWhatIWant', + returnValueTest: { + index: 0, + comparator: '>', + value: '100', + }, + chain: 5, + }, + // condition with wrong method "method" and no contract address + { + ...testTimeConditionObj, + method: 'doWhatIWant', + }, + // rpc condition (no contract address) with disallowed method + { + ...testRpcConditionObj, + method: 'isPolicyActive', + }, + ])("can't determine condition type", async (invalidCondition) => { + expect(() => { + ConditionSet.fromObj({ + condition: invalidCondition, + }); + }).toThrow('unrecognized condition data'); + }); + + it('erc721 condition serialization', async () => { + const conditionSet = new ConditionSet(erc721BalanceCondition); + + const erc721BalanceConditionObj = erc721BalanceCondition.toObj(); + const conditionSetJson = conditionSet.toJson(); + expect(conditionSetJson).toBeDefined(); + expect(conditionSetJson).toContain('chain'); + expect(conditionSetJson).toContain(TEST_CHAIN_ID.toString()); + expect(conditionSetJson).toContain('contractAddress'); + expect(conditionSetJson).toContain( + erc721BalanceConditionObj.contractAddress + ); + expect(conditionSetJson).toContain('standardContractType'); + expect(conditionSetJson).toContain('ERC721'); + expect(conditionSetJson).toContain('method'); + expect(conditionSetJson).toContain(erc721BalanceConditionObj.method); + expect(conditionSetJson).toContain('returnValueTest'); + + expect(conditionSetJson).not.toContain('functionAbi'); + expect(conditionSetJson).not.toContain('operator'); + expect(conditionSetJson).not.toContain('operands'); + + const conditionSetFromJson = ConditionSet.fromJSON(conditionSetJson); + expect(conditionSetFromJson).toBeDefined(); + expect(conditionSetFromJson.condition).toBeInstanceOf(ContractCondition); + }); + + it('contract condition no abi serialization', async () => { + const conditionSet = new ConditionSet(contractConditionNoAbi); + + const conditionSetJson = conditionSet.toJson(); + expect(conditionSetJson).toBeDefined(); + expect(conditionSetJson).toContain('chain'); + expect(conditionSetJson).toContain(TEST_CHAIN_ID.toString()); + expect(conditionSetJson).toContain('contractAddress'); + expect(conditionSetJson).toContain( + testContractConditionObj.contractAddress + ); + expect(conditionSetJson).toContain('standardContractType'); + expect(conditionSetJson).toContain( + testContractConditionObj.standardContractType + ); + expect(conditionSetJson).toContain('method'); + expect(conditionSetJson).toContain(testContractConditionObj.method); + expect(conditionSetJson).toContain('parameters'); + expect(conditionSetJson).toContain(testContractConditionObj.parameters[0]); + expect(conditionSetJson).toContain('returnValueTest'); + expect(conditionSetJson).not.toContain('functionAbi'); + expect(conditionSetJson).not.toContain('operator'); + expect(conditionSetJson).not.toContain('operands'); + + const conditionSetFromJson = ConditionSet.fromJSON(conditionSetJson); + expect(conditionSetFromJson).toBeDefined(); + expect(conditionSetFromJson.condition).toBeInstanceOf(ContractCondition); + }); + + it('contract condition with abi serialization', async () => { + const conditionSet = new ConditionSet(contractConditionWithAbi); + + const conditionSetJson = conditionSet.toJson(); + expect(conditionSetJson).toBeDefined(); + expect(conditionSetJson).toContain('chain'); + expect(conditionSetJson).toContain(TEST_CHAIN_ID.toString()); + expect(conditionSetJson).toContain('contractAddress'); + expect(conditionSetJson).toContain( + contractConditionWithAbiObj.contractAddress + ); + expect(conditionSetJson).toContain('method'); + expect(conditionSetJson).toContain(contractConditionWithAbiObj.method); + expect(conditionSetJson).toContain('parameters'); + expect(conditionSetJson).toContain( + contractConditionWithAbiObj.parameters[0] + ); + expect(conditionSetJson).toContain( + contractConditionWithAbiObj.parameters[1] + ); + expect(conditionSetJson).toContain('returnValueTest'); + expect(conditionSetJson).toContain('functionAbi'); + + expect(conditionSetJson).not.toContain('standardContractType'); + expect(conditionSetJson).not.toContain('operator'); + expect(conditionSetJson).not.toContain('operands'); + + const conditionSetFromJson = ConditionSet.fromJSON(conditionSetJson); + expect(conditionSetFromJson).toBeDefined(); + expect(conditionSetFromJson.condition).toBeInstanceOf(ContractCondition); + }); + + it('time condition serialization', async () => { + const conditionSet = new ConditionSet(timeCondition); + + const conditionSetJson = conditionSet.toJson(); + expect(conditionSetJson).toBeDefined(); + expect(conditionSetJson).toContain('chain'); + expect(conditionSetJson).toContain(TEST_CHAIN_ID.toString()); + expect(conditionSetJson).toContain('method'); + expect(conditionSetJson).toContain(testTimeConditionObj.method); + expect(conditionSetJson).toContain('returnValueTest'); + expect(conditionSetJson).not.toContain('parameters'); + expect(conditionSetJson).not.toContain('contractAddress'); + expect(conditionSetJson).not.toContain('standardContractType'); + expect(conditionSetJson).not.toContain('functionAbi'); + expect(conditionSetJson).not.toContain('operator'); + expect(conditionSetJson).not.toContain('operands'); + + const conditionSetFromJson = ConditionSet.fromJSON(conditionSetJson); + expect(conditionSetFromJson).toBeDefined(); + expect(conditionSetFromJson.condition).toBeInstanceOf(TimeCondition); + }); + + it('rpc condition serialization', async () => { + const conditionSet = new ConditionSet(rpcCondition); + + const conditionSetJson = conditionSet.toJson(); + expect(conditionSetJson).toBeDefined(); + expect(conditionSetJson).toContain('chain'); + expect(conditionSetJson).toContain(TEST_CHAIN_ID.toString()); + expect(conditionSetJson).toContain('method'); + expect(conditionSetJson).toContain(testRpcConditionObj.method); + expect(conditionSetJson).toContain('parameters'); + expect(conditionSetJson).toContain(testRpcConditionObj.parameters[0]); + expect(conditionSetJson).toContain('returnValueTest'); + expect(conditionSetJson).not.toContain('contractAddress'); + expect(conditionSetJson).not.toContain('standardContractType'); + expect(conditionSetJson).not.toContain('functionAbi'); + expect(conditionSetJson).not.toContain('operator'); + expect(conditionSetJson).not.toContain('operands'); + + const conditionSetFromJson = ConditionSet.fromJSON(conditionSetJson); + expect(conditionSetFromJson).toBeDefined(); + expect(conditionSetFromJson.condition).toBeInstanceOf(RpcCondition); + }); + + it('compound condition serialization', async () => { + const conditionSet = new ConditionSet(compoundCondition); + const compoundConditionObj = compoundCondition.toObj(); + + const conditionSetJson = conditionSet.toJson(); + expect(conditionSetJson).toContain('operator'); + expect(conditionSetJson).toContain(compoundConditionObj.operator); + expect(conditionSetJson).toContain('operands'); + + expect(conditionSetJson).toBeDefined(); + expect(conditionSetJson).toContain('chain'); + expect(conditionSetJson).toContain(TEST_CHAIN_ID.toString()); + expect(conditionSetJson).toContain('method'); + expect(conditionSetJson).toContain(testRpcConditionObj.method); + expect(conditionSetJson).toContain(testTimeConditionObj.method); + expect(conditionSetJson).toContain(testContractConditionObj.method); + expect(conditionSetJson).toContain('parameters'); + expect(conditionSetJson).toContain(testRpcConditionObj.parameters[0]); + expect(conditionSetJson).toContain(testContractConditionObj.parameters[0]); + + const conditionSetFromJson = ConditionSet.fromJSON(conditionSetJson); + expect(conditionSetFromJson).toBeDefined(); + expect(conditionSetFromJson.condition).toBeInstanceOf(CompoundCondition); + }); }); diff --git a/test/unit/conditions/context.test.ts b/test/unit/conditions/context.test.ts index b86fb0d0c..958454cd1 100644 --- a/test/unit/conditions/context.test.ts +++ b/test/unit/conditions/context.test.ts @@ -26,7 +26,7 @@ describe('serialization', () => { value: USER_ADDRESS_PARAM, }, }); - const conditionContext = new ConditionSet([rpcCondition]).buildContext( + const conditionContext = new ConditionSet(rpcCondition).buildContext( web3Provider ); const asJson = await conditionContext.toJson(); @@ -48,7 +48,7 @@ describe('context parameters', () => { }, }; const contractCondition = new ContractCondition(contractConditionObj); - const conditionSet = new ConditionSet([contractCondition]); + const conditionSet = new ConditionSet(contractCondition); const conditionContext = conditionSet.buildContext(web3Provider); describe('return value test', () => { @@ -94,9 +94,9 @@ describe('context parameters', () => { ...contractConditionObj, parameters: [USER_ADDRESS_PARAM, customParamKey], }); - const conditionContext = new ConditionSet([ - customContractCondition, - ]).buildContext(web3Provider); + const conditionContext = new ConditionSet( + customContractCondition + ).buildContext(web3Provider); await expect(async () => conditionContext.toObj()).rejects.toThrow( `Missing custom context parameter(s): ${customParamKey}` @@ -108,9 +108,9 @@ describe('context parameters', () => { ...contractConditionObj, parameters: [USER_ADDRESS_PARAM, 100], }); - const conditionContext = new ConditionSet([ - customContractCondition, - ]).buildContext(web3Provider); + const conditionContext = new ConditionSet( + customContractCondition + ).buildContext(web3Provider); const asObj = await conditionContext.toObj(); expect(asObj).toBeDefined(); diff --git a/test/unit/conditions/operator.test.ts b/test/unit/conditions/operator.test.ts deleted file mode 100644 index c6c4d3ed6..000000000 --- a/test/unit/conditions/operator.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Operator } from '../../../src/conditions'; - -describe('validate', () => { - it('accepts a valid operator', () => { - const op = new Operator('or'); - expect(op.operator).toEqual('or'); - }); - - it('rejects an invalid operator', () => { - expect(() => new Operator('not-an-operator')).toThrow( - '"not-an-operator" must be one of [and, or]' - ); - }); -}); diff --git a/test/unit/pre-strategy.test.ts b/test/unit/pre-strategy.test.ts index 0879ce9b4..3b15ba0eb 100644 --- a/test/unit/pre-strategy.test.ts +++ b/test/unit/pre-strategy.test.ts @@ -37,7 +37,7 @@ const ownsNFT = new ERC721Ownership({ parameters: [3591], chain: 5, }); -const conditionSet = new ConditionSet([ownsNFT]); +const conditionSet = new ConditionSet(ownsNFT); const mockedUrsulas = fakeUrsulas().slice(0, 3); const makePreStrategy = async () => { diff --git a/test/unit/testVariables.ts b/test/unit/testVariables.ts index c199a3043..ef943513a 100644 --- a/test/unit/testVariables.ts +++ b/test/unit/testVariables.ts @@ -19,6 +19,16 @@ export const testReturnValueTest = { value: '100', }; +export const testTimeConditionObj = { + returnValueTest: { + index: 0, + comparator: '>', + value: '100', + }, + method: 'blocktime', + chain: 5, +}; + export const testRpcConditionObj = { chain: TEST_CHAIN_ID, method: 'eth_getBalance', @@ -57,4 +67,5 @@ export const testFunctionAbi = { type: 'uint256', }, ], + stateMutability: 'view', };