Skip to content

Commit

Permalink
chore: separate schemas from conditions to allow better combinations/…
Browse files Browse the repository at this point in the history
…reuse of schemas (#591)
  • Loading branch information
derekpierre authored Oct 15, 2024
2 parents 2a51cac + 92e5d73 commit 5afedd8
Show file tree
Hide file tree
Showing 22 changed files with 472 additions and 379 deletions.
112 changes: 13 additions & 99 deletions packages/taco/src/conditions/base/contract.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,17 @@
import { ETH_ADDRESS_REGEXP } from '@nucypher/shared';
import { ethers } from 'ethers';
import { z } from 'zod';

import { Condition } from '../condition';
import { OmitConditionType, paramOrContextParamSchema } from '../shared';

import { rpcConditionSchema } from './rpc';

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

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;
}

const functionsInAbi = Object.values(asInterface.functions || {});
return functionsInAbi.length === 1;
},
{
message: '"functionAbi" must contain a single function definition',
path: ['functionAbi'],
},
)
.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"',
path: ['parameters'],
},
);

export type FunctionAbiProps = z.infer<typeof functionAbiSchema>;

export const ContractConditionType = 'contract';

export const contractConditionSchema = rpcConditionSchema
.extend({
conditionType: z
.literal(ContractConditionType)
.default(ContractConditionType),
contractAddress: z.string().regex(ETH_ADDRESS_REGEXP).length(42),
standardContractType: z.enum(['ERC20', 'ERC721']).optional(),
method: z.string(),
functionAbi: functionAbiSchema.optional(),
parameters: z.array(paramOrContextParamSchema),
})
// 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",
path: ['standardContractType'],
},
);

export type ContractConditionProps = z.infer<typeof contractConditionSchema>;
import {
ContractConditionProps,
contractConditionSchema,
ContractConditionType,
} from '../schemas/contract';
import { OmitConditionType } from '../shared';

export {
ContractConditionProps,
contractConditionSchema,
ContractConditionType,
FunctionAbiProps,
} from '../schemas/contract';

export class ContractCondition extends Condition {
constructor(value: OmitConditionType<ContractConditionProps>) {
Expand Down
1 change: 1 addition & 0 deletions packages/taco/src/conditions/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
// avoid circular dependency on Condition class.

export * as contract from './contract';
export * as jsonApi from './json-api';
export * as rpc from './rpc';
export * as time from './time';
44 changes: 13 additions & 31 deletions packages/taco/src/conditions/base/json-api.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,21 @@
import { JSONPath } from '@astronautlabs/jsonpath';
import { z } from 'zod';

import { Condition } from '../condition';
import { OmitConditionType, returnValueTestSchema } from '../shared';

export const JsonApiConditionType = 'json-api';

const validateJSONPath = (jsonPath: string): boolean => {
try {
JSONPath.parse(jsonPath);
return true;
} catch (error) {
return false;
}
};

export const jsonPathSchema = z
.string()
.refine((val) => validateJSONPath(val), {
message: 'Invalid JSONPath expression',
});

export const JsonApiConditionSchema = z.object({
conditionType: z.literal(JsonApiConditionType).default(JsonApiConditionType),
endpoint: z.string().url(),
parameters: z.record(z.string(), z.unknown()).optional(),
query: jsonPathSchema.optional(),
returnValueTest: returnValueTestSchema, // Update to allow multiple return values after expanding supported methods
});
import {
JsonApiConditionProps,
jsonApiConditionSchema,
JsonApiConditionType,
} from '../schemas/json-api';
import { OmitConditionType } from '../shared';

export type JsonApiConditionProps = z.infer<typeof JsonApiConditionSchema>;
export {
JsonApiConditionProps,
jsonApiConditionSchema,
JsonApiConditionType,
jsonPathSchema,
} from '../schemas/json-api';

export class JsonApiCondition extends Condition {
constructor(value: OmitConditionType<JsonApiConditionProps>) {
super(JsonApiConditionSchema, {
super(jsonApiConditionSchema, {
conditionType: JsonApiConditionType,
...value,
});
Expand Down
36 changes: 11 additions & 25 deletions packages/taco/src/conditions/base/rpc.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,16 @@
import { z } from 'zod';

import { baseConditionSchema, Condition } from '../condition';
import { SUPPORTED_CHAIN_IDS } from '../const';
import { Condition } from '../condition';
import {
EthAddressOrUserAddressSchema,
OmitConditionType,
paramOrContextParamSchema,
returnValueTestSchema,
} from '../shared';
import createUnionSchema from '../zod';

export const RpcConditionType = 'rpc';

export const rpcConditionSchema = baseConditionSchema.extend({
conditionType: z.literal(RpcConditionType).default(RpcConditionType),
chain: createUnionSchema(SUPPORTED_CHAIN_IDS),
method: z.enum(['eth_getBalance']),
parameters: z.union([
z.array(EthAddressOrUserAddressSchema).nonempty(),
// Using tuple here because ordering matters
z.tuple([EthAddressOrUserAddressSchema, paramOrContextParamSchema]),
]),
returnValueTest: returnValueTestSchema, // Update to allow multiple return values after expanding supported methods
});
RpcConditionProps,
rpcConditionSchema,
RpcConditionType,
} from '../schemas/rpc';
import { OmitConditionType } from '../shared';

export type RpcConditionProps = z.infer<typeof rpcConditionSchema>;
export {
RpcConditionProps,
rpcConditionSchema,
RpcConditionType,
} from '../schemas/rpc';

export class RpcCondition extends Condition {
constructor(value: OmitConditionType<RpcConditionProps>) {
Expand Down
29 changes: 11 additions & 18 deletions packages/taco/src/conditions/base/time.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
import { z } from 'zod';

import { Condition } from '../condition';
import {
TimeConditionProps,
timeConditionSchema,
TimeConditionType,
} from '../schemas/time';
import { OmitConditionType } from '../shared';

import { rpcConditionSchema } from './rpc';

// TimeCondition is an RpcCondition with the method set to 'blocktime' and no parameters
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { parameters: _, ...restShape } = rpcConditionSchema.shape;

export const TimeConditionType = 'time';
export const TimeConditionMethod = 'blocktime';

export const timeConditionSchema = z.object({
...restShape,
conditionType: z.literal(TimeConditionType).default(TimeConditionType),
method: z.literal(TimeConditionMethod).default(TimeConditionMethod),
});

export type TimeConditionProps = z.infer<typeof timeConditionSchema>;
export {
TimeConditionMethod,
TimeConditionProps,
timeConditionSchema,
TimeConditionType,
} from '../schemas/time';

export class TimeCondition extends Condition {
constructor(value: OmitConditionType<TimeConditionProps>) {
Expand Down
71 changes: 11 additions & 60 deletions packages/taco/src/conditions/compound-condition.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,16 @@
import { z } from 'zod';

import { contractConditionSchema } from './base/contract';
import { rpcConditionSchema } from './base/rpc';
import { timeConditionSchema } from './base/time';
import { baseConditionSchema, Condition, ConditionProps } from './condition';
import { maxNestedDepth } from './multi-condition';
import { sequentialConditionSchema } from './sequential';
import { Condition, ConditionProps } from './condition';
import {
CompoundConditionProps,
compoundConditionSchema,
CompoundConditionType,
} from './schemas/compound';
import { OmitConditionType } from './shared';

export const CompoundConditionType = 'compound';

export const compoundConditionSchema: z.ZodSchema = baseConditionSchema
.extend({
conditionType: z
.literal(CompoundConditionType)
.default(CompoundConditionType),
operator: z.enum(['and', 'or', 'not']),
operands: z
.array(
z.lazy(() =>
z.union([
rpcConditionSchema,
timeConditionSchema,
contractConditionSchema,
compoundConditionSchema,
sequentialConditionSchema,
]),
),
)
.min(1)
.max(5),
})
.refine(
(condition) => {
// 'and' and 'or' operators must have at least 2 operands
if (['and', 'or'].includes(condition.operator)) {
return condition.operands.length >= 2;
}

// 'not' operator must have exactly 1 operand
if (condition.operator === 'not') {
return condition.operands.length === 1;
}

// We test positive cases exhaustively, so we return false here:
return false;
},
({ operands, operator }) => ({
message: `Invalid number of operands ${operands.length} for operator "${operator}"`,
path: ['operands'],
}),
)
.refine(
(condition) => maxNestedDepth(2)(condition),
{
message: 'Exceeded max nested depth of 2 for multi-condition type',
path: ['operands'],
}, // Max nested depth of 2
);

export type CompoundConditionProps = z.infer<typeof compoundConditionSchema>;
export {
CompoundConditionProps,
compoundConditionSchema,
CompoundConditionType,
} from './schemas/compound';

export type ConditionOrProps = Condition | ConditionProps;

Expand Down
4 changes: 1 addition & 3 deletions packages/taco/src/conditions/condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { z } from 'zod';

import { USER_ADDRESS_PARAMS } from './const';

export const baseConditionSchema = z.object({
conditionType: z.string(),
});
export { baseConditionSchema } from './schemas/common';

type ConditionSchema = z.ZodSchema;
export type ConditionProps = z.infer<ConditionSchema>;
Expand Down
Loading

0 comments on commit 5afedd8

Please sign in to comment.