Skip to content

Commit

Permalink
Add schemas folder to house all zod schemas for conditions, context, …
Browse files Browse the repository at this point in the history
…etc.

Add common single schema that encompasses all possible condition schemas.
  • Loading branch information
derekpierre committed Sep 27, 2024
1 parent 3bd8816 commit ebd5f9e
Show file tree
Hide file tree
Showing 10 changed files with 356 additions and 0 deletions.
65 changes: 65 additions & 0 deletions packages/taco/src/conditions/schemas/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { EthAddressSchema } from '@nucypher/shared';
import {
USER_ADDRESS_PARAM_DEFAULT,
USER_ADDRESS_PARAM_EXTERNAL_EIP4361,
} from '@nucypher/taco-auth';
import { Primitive, z, ZodLiteral } from 'zod';

import { CONTEXT_PARAM_PREFIX, CONTEXT_PARAM_REGEXP } from '../const';

export const contextParamSchema = z.string().regex(CONTEXT_PARAM_REGEXP);

// We want to discriminate between ContextParams and plain strings
// If a string starts with `:`, it's a ContextParam
export const plainStringSchema = z.string().refine(
(str) => {
return !str.startsWith(CONTEXT_PARAM_PREFIX);
},
{
message: `String must not be a context parameter i.e. not start with "${CONTEXT_PARAM_PREFIX}"`,
},
);

export const UserAddressSchema = z.enum([
USER_ADDRESS_PARAM_DEFAULT,
USER_ADDRESS_PARAM_EXTERNAL_EIP4361,
]);

export const EthAddressOrUserAddressSchema = z.union([
EthAddressSchema,
UserAddressSchema,
]);

export const baseConditionSchema = z.object({
conditionType: z.string(),
});

// Source: https://github.com/colinhacks/zod/issues/831#issuecomment-1063481764
const createUnion = <
T extends Readonly<[Primitive, Primitive, ...Primitive[]]>,
>(
values: T,
) => {
const zodLiterals = values.map((value) => z.literal(value)) as unknown as [
ZodLiteral<Primitive>,
ZodLiteral<Primitive>,
...ZodLiteral<Primitive>[],
];
return z.union(zodLiterals);
};

function createUnionSchema<T extends readonly Primitive[]>(values: T) {
if (values.length === 0) {
return z.never();
}

if (values.length === 1) {
return z.literal(values[0]);
}

return createUnion(
values as unknown as Readonly<[Primitive, Primitive, ...Primitive[]]>,
);
}

export default createUnionSchema;
46 changes: 46 additions & 0 deletions packages/taco/src/conditions/schemas/compound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { z } from 'zod';

import { maxNestedDepth } from '../multi-condition';

import { baseConditionSchema } from './common';
import { anyConditionSchema } from './utils';

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(anyConditionSchema).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>;
15 changes: 15 additions & 0 deletions packages/taco/src/conditions/schemas/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from 'zod';

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

import { plainStringSchema } from './common';

export const contextParamSchema = z.string().regex(CONTEXT_PARAM_REGEXP);

const paramSchema = z.union([plainStringSchema, z.boolean(), z.number()]);

export const paramOrContextParamSchema: z.ZodSchema = z.union([
paramSchema,
contextParamSchema,
z.lazy(() => z.array(paramOrContextParamSchema)),
]);
97 changes: 97 additions & 0 deletions packages/taco/src/conditions/schemas/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { ETH_ADDRESS_REGEXP } from '@nucypher/shared';
import { ethers } from 'ethers';
import { z } from 'zod';

import { paramOrContextParamSchema } from './context';
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>;
8 changes: 8 additions & 0 deletions packages/taco/src/conditions/schemas/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * as common from './common';
export * as compound from './compound';
export * as context from './context';
export * as contract from './contract';
export * as returnValueTest from './return-value-test';
export * as rpc from './rpc';
export * as sequential from './sequential';
export * as time from './time';
11 changes: 11 additions & 0 deletions packages/taco/src/conditions/schemas/return-value-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod';

import { paramOrContextParamSchema } from './context';

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

export type ReturnValueTestProps = z.infer<typeof returnValueTestSchema>;
26 changes: 26 additions & 0 deletions packages/taco/src/conditions/schemas/rpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { z } from 'zod';

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

import createUnionSchema, {
baseConditionSchema,
EthAddressOrUserAddressSchema,
} from './common';
import { paramOrContextParamSchema } from './context';
import { returnValueTestSchema } from './return-value-test';

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

export type RpcConditionProps = z.infer<typeof rpcConditionSchema>;
52 changes: 52 additions & 0 deletions packages/taco/src/conditions/schemas/sequential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { z } from 'zod';

import { maxNestedDepth } from '../multi-condition';

import { baseConditionSchema, plainStringSchema } from './common';
import { anyConditionSchema } from './utils';

export const SequentialConditionType = 'sequential';

export const conditionVariableSchema: z.ZodSchema = z.object({
varName: plainStringSchema,
condition: anyConditionSchema,
});
export type ConditionVariableProps = z.infer<typeof conditionVariableSchema>;

export const sequentialConditionSchema: z.ZodSchema = baseConditionSchema
.extend({
conditionType: z
.literal(SequentialConditionType)
.default(SequentialConditionType),
conditionVariables: z.array(conditionVariableSchema).min(2).max(5),
})
.refine(
(condition) => maxNestedDepth(2)(condition),
{
message: 'Exceeded max nested depth of 2 for multi-condition type',
path: ['conditionVariables'],
}, // Max nested depth of 2
)
.refine(
// check for duplicate var names
(condition) => {
const seen = new Set();
return condition.conditionVariables.every(
(child: ConditionVariableProps) => {
if (seen.has(child.varName)) {
return false;
}
seen.add(child.varName);
return true;
},
);
},
{
message: 'Duplicate variable names are not allowed',
path: ['conditionVariables'],
},
);

export type SequentialConditionProps = z.infer<
typeof sequentialConditionSchema
>;
18 changes: 18 additions & 0 deletions packages/taco/src/conditions/schemas/time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from 'zod';

import { rpcConditionSchema } from './rpc';

// TimeCondition is an RpcCondition with the method set to 'blocktime' and no parameters

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

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { parameters: _, ...restShape } = rpcConditionSchema.shape;
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>;
18 changes: 18 additions & 0 deletions packages/taco/src/conditions/schemas/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from 'zod';

import { compoundConditionSchema } from '../compound-condition';

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

export const anyConditionSchema: z.ZodSchema = z.lazy(() =>
z.union([
rpcConditionSchema,
timeConditionSchema,
contractConditionSchema,
compoundConditionSchema,
sequentialConditionSchema,
]),
);

0 comments on commit ebd5f9e

Please sign in to comment.