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

Update Conditions API #267

Merged
merged 3 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Comment on lines +8 to +10
Copy link
Contributor Author

Choose a reason for hiding this comment

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

RFC

Copy link
Contributor

Choose a reason for hiding this comment

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

Potentially use this?

https://abitype.dev/api/zod

Copy link
Contributor

Choose a reason for hiding this comment

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

Potentially use this?

https://abitype.dev/api/zod

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually I think you could probably replace pretty much anything having to do with interfacing with contracts with this maybe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, it's likely. I've already started looking into viem: #271

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?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

RFC

Copy link
Member

Choose a reason for hiding this comment

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

All this contract validation seems a little bit tricky, and I think there is a low probability of having a bad ABI contract. I would leave this as is.

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah, it seems likely that ethers or viem could produce a useful error for the user if the abi isn't valid.

})
.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
Loading