Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support prefix notation for compound conditions #226

Merged
merged 8 commits into from
Jun 19, 2023
9 changes: 6 additions & 3 deletions src/characters/pre-recipient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -106,8 +106,11 @@ export class PreTDecDecrypter {
.map((condition) => JSON.parse(condition.toString()))
.reduce((acc: Record<string, string>[], val) => acc.concat(val), []);

const conditionContext =
ConditionSet.fromConditionList(conditions).buildContext(provider);
const conditionsList = conditions.map((ele: Record<string, string>) => {
return Condition.fromObj(ele);
});

const conditionContext = new ConditionContext(conditionsList, provider);

const policyMessageKits = messageKits.map((mk) =>
PolicyMessageKit.fromMessageKit(
Expand Down
2 changes: 0 additions & 2 deletions src/conditions/base/condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export class Condition {
}
return {
...value,
_class: this.constructor.name,
};
}

Expand All @@ -36,7 +35,6 @@ export class Condition {
this: new (...args: any[]) => T,
obj: Map
): T {
delete obj._class;
return new this(obj);
}

Expand Down
18 changes: 10 additions & 8 deletions src/conditions/base/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think yes, working on a related change here: #228

name: Joi.string().required(),
type: Joi.string().required(),
});
Expand All @@ -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;
Expand All @@ -39,8 +39,8 @@ const functionAbiSchema = Joi.object({
return functionAbi;
});

const contractMethodSchemas: Record<string, Joi.Schema> = {
...rpcConditionSchema,
export const contractConditionRecord: Record<string, Joi.Schema> = {
...rpcConditionRecord,
contractAddress: Joi.string().pattern(ETH_ADDRESS_REGEXP).required(),
standardContractType: Joi.string()
.valid(...STANDARD_CONTRACT_TYPES)
Expand All @@ -50,8 +50,10 @@ const contractMethodSchemas: Record<string, Joi.Schema> = {
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;
}
6 changes: 4 additions & 2 deletions src/conditions/base/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const makeParameters = () =>
})),
});

export const rpcConditionSchema = {
export const rpcConditionRecord = {
chain: Joi.number()
.valid(...SUPPORTED_CHAINS)
.required(),
Expand All @@ -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;
}
12 changes: 7 additions & 5 deletions src/conditions/base/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Joi.Schema> = {
// 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<string, unknown> = {
method: BLOCKTIME_METHOD,
};

public readonly schema = Joi.object(timeConditionSchema);
public readonly schema = timeConditionSchema;
}
31 changes: 31 additions & 0 deletions src/conditions/compound-condition.ts
Original file line number Diff line number Diff line change
@@ -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;
}
94 changes: 34 additions & 60 deletions src/conditions/condition-set.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[];
condition: Record<string, unknown>;
};

export class ConditionSet {
constructor(public readonly conditions: ReadonlyArray<ConditionOrOperator>) {}
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<string, string>);
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<Record<string, string>>) {
return new ConditionSet(
list.map((ele: Record<string, string>) => {
if ('operator' in ele) return Operator.fromObj(ele);
return Condition.fromObj(ele);
})
);
throw new Error('Invalid condition: unrecognized condition data');
}

public toJson(): string {
Expand All @@ -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());
}
}
10 changes: 7 additions & 3 deletions src/conditions/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -16,7 +17,7 @@ export class ConditionContext {
private readonly walletAuthProvider: WalletAuthenticationProvider;

constructor(
private readonly conditions: WASMConditions,
private readonly conditions: ReadonlyArray<Condition>,
// 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,
Expand All @@ -42,8 +43,11 @@ export class ConditionContext {
const requestedParameters = new Set<string>();

// 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)) {
Expand Down
2 changes: 1 addition & 1 deletion src/conditions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
26 changes: 0 additions & 26 deletions src/conditions/operator.ts

This file was deleted.

11 changes: 5 additions & 6 deletions test/docs/cbd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down
Loading