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 758e94d
Show file tree
Hide file tree
Showing 26 changed files with 632 additions and 552 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
24 changes: 24 additions & 0 deletions src/conditions/base/compound-condition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { z } from 'zod';

import { contractConditionSchema } from './contract';
import { rpcConditionSchema } from './rpc';
import { timeConditionSchema } from './time';

export const compoundConditionSchema: z.ZodSchema = z.object({
conditionType: z.literal('compound').default('compound'),
operator: z.enum(['and', 'or']),
operands: z
.array(
z.lazy(() =>
z.union([
rpcConditionSchema,
timeConditionSchema,
contractConditionSchema,
compoundConditionSchema,
])
)
)
.min(2),
});

export type CompoundConditionProps = z.infer<typeof compoundConditionSchema>;
59 changes: 39 additions & 20 deletions src/conditions/base/condition.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,58 @@
import Joi from 'joi';
import { z } from 'zod';

import { objectEquals } from '../../utils';

type Map = Record<string, unknown>;
import {
CompoundConditionProps,
ContractConditionProps,
RpcConditionProps,
TimeConditionProps,
} from './index';

// Not using discriminated union because of inconsistent Zod types
// Some conditions have ZodEffect types because of .refine() calls
export type ConditionProps =
| RpcConditionProps
| TimeConditionProps
| ContractConditionProps
| CompoundConditionProps;

export class Condition {
public readonly schema = Joi.object();
public readonly defaults: Map = {};

constructor(private readonly value: Record<string, unknown> = {}) {}

public validate(override: Map = {}) {
constructor(
public readonly schema: z.ZodSchema,
public readonly value:
| RpcConditionProps
| TimeConditionProps
| ContractConditionProps
| CompoundConditionProps
) {}

public validate(override: Partial<ConditionProps> = {}): {
data?: ConditionProps;
error?: z.ZodError;
} {
const newValue = {
...this.defaults,
...this.value,
...override,
};
return this.schema.validate(newValue);
const result = this.schema.safeParse(newValue);
if (result.success) {
return { data: result.data };
}
return { error: result.error };
}

public toObj(): Map {
const { error, value } = this.validate(this.value);
public toObj() {
const { data, error } = this.validate(this.value);
if (error) {
throw `Invalid condition: ${error.message}`;
throw new Error(`Invalid condition: ${JSON.stringify(error.issues)}`);
}
return {
...value,
};
return data;
}

public static fromObj<T extends Condition>(
// We disable the eslint rule here because we have to pass args to the constructor
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this: new (...args: any[]) => T,
obj: Map
this: new (...args: unknown[]) => T,
obj: Record<string, unknown>
): T {
return new this(obj);
}
Expand Down
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>;
52 changes: 49 additions & 3 deletions src/conditions/base/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,50 @@
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 ConditionProps } from './condition';
export { Condition } from './condition';
export { ContractCondition } from './contract';
export { RpcCondition } from './rpc';
export { TimeCondition } from './time';

export {
compoundConditionSchema,
type CompoundConditionProps,
} from './compound-condition';

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.

Loading

1 comment on commit 758e94d

@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: 62.50 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: 62.50 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.