Skip to content

Commit

Permalink
Update Conditions API (#267)
Browse files Browse the repository at this point in the history
  • Loading branch information
piotr-roslaniec committed Sep 5, 2023
2 parents a78bc93 + 84c8ef4 commit 67e43cd
Show file tree
Hide file tree
Showing 34 changed files with 869 additions and 725 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"test:lint": "eslint src test --ext .ts",
"test:exports": "ts-unused-exports tsconfig.json --ignoreFiles src/index.ts",
"test:prettier": "prettier \"src/**/*.ts\" \"test/**/*.ts\" --list-different",
"test:unit": "jest --detectOpenHandles --forceExit --runInBand",
"test:unit": "jest --detectOpenHandles --forceExit",
"watch:build": "tsc -p tsconfig.json -w",
"watch:test": "jest --watch",
"cov": "run-s build test:unit && open-cli coverage/index.html",
Expand All @@ -56,9 +56,9 @@
"axios": "^1.5.0",
"deep-equal": "^2.2.1",
"ethers": "^5.7.2",
"joi": "^17.10.0",
"qs": "^6.10.1",
"semver": "^7.5.2"
"semver": "^7.5.2",
"zod": "^3.22.1"
},
"devDependencies": {
"@babel/core": "^7.22.11",
Expand Down
3 changes: 2 additions & 1 deletion src/agents/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ChainId, ChecksumAddress } from '../types';
import { ChecksumAddress } from '../types';
import { ChainId } from '../web3';

type Contracts = {
readonly SUBSCRIPTION_MANAGER: ChecksumAddress | undefined;
Expand Down
49 changes: 0 additions & 49 deletions src/conditions/base/condition.ts

This file was deleted.

155 changes: 79 additions & 76 deletions src/conditions/base/contract.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,90 @@
import { ethers } from 'ethers';
import Joi from 'joi';
import { z } from 'zod';

import { ETH_ADDRESS_REGEXP } from '../const';

import { RpcCondition, rpcConditionRecord } from './rpc';
import { rpcConditionSchema } from './rpc';

export const STANDARD_CONTRACT_TYPES = ['ERC20', 'ERC721'];
// TODO: Consider replacing with `z.unknown`:
// Since Solidity types are tied to Solidity version, we may not be able to accurately represent them in Zod.
// Alternatively, find a TS Solidity type lib.
const EthBaseTypes: [string, ...string[]] = [
'bool',
'string',
'address',
...Array.from({ length: 32 }, (_v, i) => `bytes${i + 1}`), // bytes1 through bytes32
'bytes',
...Array.from({ length: 32 }, (_v, i) => `uint${8 * (i + 1)}`), // uint8 through uint256
...Array.from({ length: 32 }, (_v, i) => `int${8 * (i + 1)}`), // int8 through int256
];

const functionAbiSchema = Joi.object({
name: Joi.string().required(),
type: Joi.string().valid('function').required(),
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,
});
}
const functionAbiVariableSchema = z
.object({
name: z.string(),
type: z.enum(EthBaseTypes),
internalType: z.enum(EthBaseTypes), // TODO: Do we need to validate this?
})
.strict();

if (!asInterface.functions) {
return helper.message({
custom: '"functionAbi" is missing a function fragment',
});
}
const functionAbiSchema = z
.object({
name: z.string(),
type: z.literal('function'),
inputs: z.array(functionAbiVariableSchema).min(0),
outputs: z.array(functionAbiVariableSchema).nonempty(),
stateMutability: z.union([z.literal('view'), z.literal('pure')]),
})
.strict()
.refine(
(functionAbi) => {
let asInterface;
try {
// `stringify` here because ethers.utils.Interface doesn't accept a Zod schema
asInterface = new ethers.utils.Interface(JSON.stringify([functionAbi]));
} catch (e) {
return false;
}

if (Object.values(asInterface.functions).length !== 1) {
return helper.message({
custom: '"functionAbi" must contain exactly one function fragment',
});
}
const functionsInAbi = Object.values(asInterface.functions || {});
return functionsInAbi.length === 1;
},
{
message: '"functionAbi" must contain a single function definition',
}
)
.refine(
(functionAbi) => {
const asInterface = new ethers.utils.Interface(
JSON.stringify([functionAbi])
);
const nrOfInputs = asInterface.fragments[0].inputs.length;
return functionAbi.inputs.length === nrOfInputs;
},
{
message: '"parameters" must have the same length as "functionAbi.inputs"',
}
);

// Now we just need to validate against the parent schema
// Validate method name
const method = helper.state.ancestors[0].method;
export type FunctionAbiProps = z.infer<typeof functionAbiSchema>;

let functionFragment;
try {
functionFragment = asInterface.getFunction(method);
} catch (e) {
return helper.message({
custom: `"functionAbi" has no matching function for "${method}"`,
});
}
export const contractConditionSchema = rpcConditionSchema
.extend({
conditionType: z.literal('contract').default('contract'),
contractAddress: z.string().regex(ETH_ADDRESS_REGEXP),
standardContractType: z.enum(['ERC20', 'ERC721']).optional(),
method: z.string(),
functionAbi: functionAbiSchema.optional(),
parameters: z.array(z.unknown()),
})
// Adding this custom logic causes the return type to be ZodEffects instead of ZodObject
// https://github.com/colinhacks/zod/issues/2474
.refine(
// A check to see if either 'standardContractType' or 'functionAbi' is set
(data) => Boolean(data.standardContractType) !== Boolean(data.functionAbi),
{
message:
"At most one of the fields 'standardContractType' and 'functionAbi' must be defined",
}
);

if (!functionFragment) {
return helper.message({
custom: `"functionAbi" not valid for method: "${method}"`,
});
}

// Validate nr of parameters
const parameters = helper.state.ancestors[0].parameters;
if (functionFragment.inputs.length !== parameters.length) {
return helper.message({
custom: '"parameters" must have the same length as "functionAbi.inputs"',
});
}

return functionAbi;
});

export const contractConditionRecord = {
...rpcConditionRecord,
contractAddress: Joi.string().pattern(ETH_ADDRESS_REGEXP).required(),
standardContractType: Joi.string()
.valid(...STANDARD_CONTRACT_TYPES)
.optional(),
method: Joi.string().required(),
functionAbi: functionAbiSchema.optional(),
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 = contractConditionSchema;
}
export type ContractConditionProps = z.infer<typeof contractConditionSchema>;
44 changes: 40 additions & 4 deletions src/conditions/base/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,40 @@
export { Condition } from './condition';
export { ContractCondition } from './contract';
export { RpcCondition } from './rpc';
export { TimeCondition } from './time';
import {
CompoundConditionProps,
compoundConditionSchema,
} from '../compound-condition';
import { Condition } from '../condition';

import { ContractConditionProps, contractConditionSchema } from './contract';
import { RpcConditionProps, rpcConditionSchema } from './rpc';
import { TimeConditionProps, timeConditionSchema } from './time';

// Exporting classes here instead of their respective schema files to
// avoid circular dependency on Condition class.

export class CompoundCondition extends Condition {
constructor(value: CompoundConditionProps) {
super(compoundConditionSchema, value);
}
}

export class ContractCondition extends Condition {
constructor(value: ContractConditionProps) {
super(contractConditionSchema, value);
}
}

export class RpcCondition extends Condition {
constructor(value: RpcConditionProps) {
super(rpcConditionSchema, value);
}
}

export class TimeCondition extends Condition {
constructor(value: TimeConditionProps) {
super(timeConditionSchema, value);
}
}

export { type ContractConditionProps } from './contract';
export { type RpcConditionProps } from './rpc';
export { type TimeConditionProps } from './time';
29 changes: 0 additions & 29 deletions src/conditions/base/return-value.ts

This file was deleted.

47 changes: 12 additions & 35 deletions src/conditions/base/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,16 @@
import Joi from 'joi';
import { z } from 'zod';

import { SUPPORTED_CHAINS } from '../const';
import { SUPPORTED_CHAIN_IDS } from '../const';
import createUnionSchema from '../zod';

import { Condition } from './condition';
import {
ethAddressOrUserAddressSchema,
returnValueTestSchema,
} from './return-value';
import { EthAddressOrUserAddressSchema, returnValueTestSchema } from './shared';

const rpcMethodSchemas: Record<string, Joi.Schema> = {
eth_getBalance: Joi.array().items(ethAddressOrUserAddressSchema).required(),
balanceOf: Joi.array().items(ethAddressOrUserAddressSchema).required(),
};
export const rpcConditionSchema = z.object({
conditionType: z.literal('rpc').default('rpc'),
chain: createUnionSchema(SUPPORTED_CHAIN_IDS),
method: z.enum(['eth_getBalance', 'balanceOf']),
parameters: z.array(EthAddressOrUserAddressSchema),
returnValueTest: returnValueTestSchema,
});

const makeParameters = () =>
Joi.array().when('method', {
switch: Object.keys(rpcMethodSchemas).map((method) => ({
is: method,
then: rpcMethodSchemas[method],
})),
});

export const rpcConditionRecord = {
chain: Joi.number()
.valid(...SUPPORTED_CHAINS)
.required(),
method: Joi.string()
.valid(...Object.keys(rpcMethodSchemas))
.required(),
parameters: makeParameters(),
returnValueTest: returnValueTestSchema.required(),
};

export const rpcConditionSchema = Joi.object(rpcConditionRecord);

export class RpcCondition extends Condition {
public readonly schema = rpcConditionSchema;
}
export type RpcConditionProps = z.infer<typeof rpcConditionSchema>;
18 changes: 18 additions & 0 deletions src/conditions/base/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from 'zod';

import { ETH_ADDRESS_REGEXP, USER_ADDRESS_PARAM } from '../const';

export const returnValueTestSchema = z.object({
index: z.number().optional(),
comparator: z.enum(['==', '>', '<', '>=', '<=', '!=']),
value: z.unknown(),
});

export type ReturnValueTestProps = z.infer<typeof returnValueTestSchema>;

const EthAddressSchema = z.string().regex(ETH_ADDRESS_REGEXP);
const UserAddressSchema = z.literal(USER_ADDRESS_PARAM);
export const EthAddressOrUserAddressSchema = z.union([
EthAddressSchema,
UserAddressSchema,
]);
Loading

0 comments on commit 67e43cd

Please sign in to comment.