Skip to content

Commit

Permalink
feat!: add typed conditions api
Browse files Browse the repository at this point in the history
  • Loading branch information
piotr-roslaniec committed Aug 22, 2023
1 parent a98cee4 commit e5b0976
Show file tree
Hide file tree
Showing 29 changed files with 735 additions and 620 deletions.
8 changes: 4 additions & 4 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,17 +56,17 @@
"axios": "^0.21.1",
"deep-equal": "^2.2.1",
"ethers": "^5.4.1",
"joi": "^17.7.0",
"qs": "^6.10.1",
"semver": "^7.5.2"
"semver": "^7.5.2",
"zod": "^3.22.1"
},
"devDependencies": {
"@babel/core": "^7.18.10",
"@babel/preset-env": "^7.15.6",
"@skypack/package-check": "^0.2.2",
"@typechain/ethers-v5": "^9.0.0",
"@types/deep-equal": "^1.0.1",
"@types/jest": "^26.0.24",
"@types/jest": "^29.5.3",
"@types/qs": "^6.9.7",
"@types/semver": "^7.5.0",
"@typescript-eslint/eslint-plugin": "^4.0.1",
Expand Down
44 changes: 0 additions & 44 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>;
47 changes: 43 additions & 4 deletions src/conditions/base/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,43 @@
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 {
contractConditionSchema,
type ContractConditionProps,
} from './contract';
export { rpcConditionSchema, type RpcConditionProps } from './rpc';
export { timeConditionSchema, type TimeConditionProps } from './time';
29 changes: 0 additions & 29 deletions src/conditions/base/return-value.ts

This file was deleted.

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

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

import { Condition } from './condition';
import {
ethAddressOrUserAddressSchema,
returnValueTestSchema,
} from './return-value';
export const returnValueTestSchema = z.object({
index: z.number().optional(),
comparator: z.enum(['==', '>', '<', '>=', '<=', '!=']),
value: z.union([z.string(), z.number(), z.boolean()]),
});

const rpcMethodSchemas: Record<string, Joi.Schema> = {
eth_getBalance: Joi.array().items(ethAddressOrUserAddressSchema).required(),
balanceOf: Joi.array().items(ethAddressOrUserAddressSchema).required(),
};
export type ReturnValueTestProps = z.infer<typeof returnValueTestSchema>;

const makeParameters = () =>
Joi.array().when('method', {
switch: Object.keys(rpcMethodSchemas).map((method) => ({
is: method,
then: rpcMethodSchemas[method],
})),
});
const EthAddressOrUserAddressSchema = z.array(
z.union([z.string().regex(ETH_ADDRESS_REGEXP), z.literal(USER_ADDRESS_PARAM)])
);

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 = z.object({
conditionType: z.literal('rpc').default('rpc'),
chain: z.union([
z.literal(137),
z.literal(80001),
z.literal(5),
z.literal(1),
]),
method: z.enum(['eth_getBalance', 'balanceOf']),
parameters: EthAddressOrUserAddressSchema,
returnValueTest: returnValueTestSchema,
});

export const rpcConditionSchema = Joi.object(rpcConditionRecord);

export class RpcCondition extends Condition {
public readonly schema = rpcConditionSchema;
}
export type RpcConditionProps = z.infer<typeof rpcConditionSchema>;
Loading

1 comment on commit e5b0976

@github-actions
Copy link

Choose a reason for hiding this comment

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

Bundled size for the package is listed below:

build/main/src/characters: 74.22 KB
build/main/src/kits: 19.53 KB
build/main/src/conditions/context: 42.97 KB
build/main/src/conditions/predefined: 19.53 KB
build/main/src/conditions/base: 46.88 KB
build/main/src/conditions: 156.25 KB
build/main/src/agents: 39.06 KB
build/main/src/sdk/strategy: 35.16 KB
build/main/src/sdk: 46.88 KB
build/main/src/policies: 19.53 KB
build/main/src: 433.59 KB
build/main/types/ethers-contracts/factories: 82.03 KB
build/main/types/ethers-contracts: 152.34 KB
build/main/types: 156.25 KB
build/main: 648.44 KB
build/module/src/characters: 74.22 KB
build/module/src/kits: 19.53 KB
build/module/src/conditions/context: 42.97 KB
build/module/src/conditions/predefined: 19.53 KB
build/module/src/conditions/base: 46.88 KB
build/module/src/conditions: 156.25 KB
build/module/src/agents: 39.06 KB
build/module/src/sdk/strategy: 31.25 KB
build/module/src/sdk: 42.97 KB
build/module/src/policies: 19.53 KB
build/module/src: 425.78 KB
build/module/types/ethers-contracts/factories: 82.03 KB
build/module/types/ethers-contracts: 152.34 KB
build/module/types: 156.25 KB
build/module: 640.63 KB
build: 1.26 MB

Please sign in to comment.