diff --git a/packages/taco/integration-test/condition-lingo.test.ts b/packages/taco/integration-test/condition-lingo.test.ts index 18796e6f2..cbf705951 100644 --- a/packages/taco/integration-test/condition-lingo.test.ts +++ b/packages/taco/integration-test/condition-lingo.test.ts @@ -125,7 +125,7 @@ describe.skipIf(!process.env.RUNNING_IN_CI)( }); const conditionExpr = new ConditionExpression(overallCondition); await validateConditionExpression(conditionExpr); - }, 15000); + }, 20000); test('validate ecdsa condition lingo and supported curves consistency', async () => { // Split SUPPORTED_ECDSA_CURVES into chunks of 5 to respect the operand limit const preGeneratedVerifyingKeys = { @@ -182,5 +182,44 @@ describe.skipIf(!process.env.RUNNING_IN_CI)( const conditionExpr = new ConditionExpression(jsonCondition); await validateConditionExpression(conditionExpr); }, 15000); + + test('validate conditions with operations', async () => { + const sequentialCondition = new SequentialCondition({ + conditionVariables: [ + { + varName: 'rpcValue', + condition: testRpcConditionObj, + operations: [ + { operation: 'abs' }, + { operation: '+=', value: BigInt('1000000000000000') }, + ], + }, + { + // add some operations + varName: 'timeValue', + condition: { + ...testTimeConditionObj, + returnValueTest: { + comparator: '>', + value: 100, + operations: [ + { operation: 'abs' }, + { operation: 'index', value: 0 }, + { operation: '+=', value: 5 }, + { operation: '*=', value: ':multiplier' }, // context variable + ], + }, + }, + }, + { + varName: 'contractValue', + condition: testContractConditionObj, + operations: [{ operation: '-=', value: 5.5 }], + }, + ], + }); + const conditionExpr = new ConditionExpression(sequentialCondition); + await validateConditionExpression(conditionExpr); + }, 15000); }, ); diff --git a/packages/taco/integration-test/encrypt-decrypt.test.ts b/packages/taco/integration-test/encrypt-decrypt.test.ts index 79ef78475..0cbd94168 100644 --- a/packages/taco/integration-test/encrypt-decrypt.test.ts +++ b/packages/taco/integration-test/encrypt-decrypt.test.ts @@ -69,6 +69,23 @@ describe.skipIf(!process.env.RUNNING_IN_CI)( }, }); + const forcedZeroBalanceDueToOperations = + new conditions.base.rpc.RpcCondition({ + chain: CHAIN_ID, + method: 'eth_getBalance', + parameters: [':userAddress', 'latest'], + returnValueTest: { + comparator: '==', + value: 0, + operations: [ + { operation: '*=', value: 0 }, + // no-op additional operations + { operation: 'abs' }, + { operation: 'int' }, + ], + }, + }); + const balanceLessThanMaxUintBigInt = new conditions.base.rpc.RpcCondition( { chain: CHAIN_ID, @@ -84,6 +101,7 @@ describe.skipIf(!process.env.RUNNING_IN_CI)( const compoundCondition = CompoundCondition.and([ hasPositiveBalance, balanceLessThanMaxUintBigInt, + forcedZeroBalanceDueToOperations, ]); const messageKit = await encrypt( diff --git a/packages/taco/schema-docs/condition-schemas.md b/packages/taco/schema-docs/condition-schemas.md index dc32bdfdd..b4bbc3430 100644 --- a/packages/taco/schema-docs/condition-schemas.md +++ b/packages/taco/schema-docs/condition-schemas.md @@ -249,11 +249,12 @@ _(\*) Required._ _Object containing the following properties:_ -| Property | Type | -| :-------------------- | :-------------------------------------------------------------- | -| `index` | `number` (_int, ≥0_) | -| **`comparator`** (\*) | `'==' \| '>' \| '<' \| '>=' \| '<=' \| '!=' \| 'in' \| '!in'` | -| **`value`** (\*) | [BlockchainParamOrContextParam](#blockchainparamorcontextparam) | +| Property | Description | Type | +| :-------------------- | :---------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | +| `index` | | `number` (_int, ≥0_) | +| **`comparator`** (\*) | | `'==' \| '>' \| '<' \| '>=' \| '<=' \| '!=' \| 'in' \| '!in'` | +| `operations` | Optional operations to perform on the obtained result before comparison | _Array of at least 1 and at most 5 [VariableOperation](#variableoperation) items_ | +| **`value`** (\*) | | [BlockchainParamOrContextParam](#blockchainparamorcontextparam) | _(\*) Required._ @@ -263,11 +264,12 @@ Test to perform on a value. Supports comparison operators like ==, >, <, >=, <=, _Object containing the following properties:_ -| Property | Type | -| :-------------------- | :------------------------------------------------------------ | -| `index` | `number` (_int, ≥0_) | -| **`comparator`** (\*) | `'==' \| '>' \| '<' \| '>=' \| '<=' \| '!=' \| 'in' \| '!in'` | -| **`value`** (\*) | [ParamOrContextParam](#paramorcontextparam) | +| Property | Description | Type | +| :-------------------- | :---------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | +| `index` | | `number` (_int, ≥0_) | +| **`comparator`** (\*) | | `'==' \| '>' \| '<' \| '>=' \| '<=' \| '!=' \| 'in' \| '!in'` | +| `operations` | Optional operations to perform on the obtained result before comparison | _Array of at least 1 and at most 5 [VariableOperation](#variableoperation) items_ | +| **`value`** (\*) | | [ParamOrContextParam](#paramorcontextparam) | _(\*) Required._ @@ -291,10 +293,11 @@ _(\*) Required._ _Object containing the following properties:_ -| Property | Type | -| :------------------- | :---------------------------- | -| **`varName`** (\*) | [PlainString](#plainstring) | -| **`condition`** (\*) | [AnyCondition](#anycondition) | +| Property | Description | Type | +| :------------------- | :-------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | +| **`varName`** (\*) | Any string that is not a Context Parameter i.e. does not start with `:`. | [PlainString](#plainstring) | +| **`condition`** (\*) | | [AnyCondition](#anycondition) | +| `operations` | Optional operations to perform on the obtained condition result before storing it | _Array of at least 1 and at most 5 [VariableOperation](#variableoperation) items_ | _(\*) Required._ @@ -373,6 +376,25 @@ _Object containing the following properties:_ _(\*) Required._ +## VariableOperation + +An operation that can be performed on an obtained result. + +_Object containing the following properties:_ + +| Property | Type | +| :------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`operation`** (\*) | `'+=' \| '-=' \| '*=' \| '/=' \| '%=' \| 'index' \| 'round' \| 'abs' \| 'avg' \| 'ceil' \| 'ethToWei' \| 'floor' \| 'len' \| 'max' \| 'min' \| 'sum' \| 'weiToEth' \| 'bool' \| 'float' \| 'int' \| ...` | +| `value` | [ParamOrContextParam](#paramorcontextparam) | + +_(\*) Required._ + +## VariableOperationsArray + +Optional operations to perform on the obtained result + +_Array of at least 1 and at most 5 [VariableOperation](#variableoperation) items._ (_optional_) + ## More resources For more information, please refer to the TACo documentation: diff --git a/packages/taco/src/conditions/schemas/export-for-zod-doc-gen.ts b/packages/taco/src/conditions/schemas/export-for-zod-doc-gen.ts index 83fd2b6d5..2a061a74a 100755 --- a/packages/taco/src/conditions/schemas/export-for-zod-doc-gen.ts +++ b/packages/taco/src/conditions/schemas/export-for-zod-doc-gen.ts @@ -23,3 +23,4 @@ export * from './rpc'; export * from './sequential'; export * from './signing'; export * from './time'; +export * from './variable-operation'; diff --git a/packages/taco/src/conditions/schemas/return-value-test.ts b/packages/taco/src/conditions/schemas/return-value-test.ts index 7a220db57..72e190219 100644 --- a/packages/taco/src/conditions/schemas/return-value-test.ts +++ b/packages/taco/src/conditions/schemas/return-value-test.ts @@ -4,10 +4,14 @@ import { blockchainParamOrContextParamSchema, paramOrContextParamSchema, } from './context'; +import { variableOperationsArraySchema } from './variable-operation'; const returnValueTestBaseSchema = z.object({ index: z.number().int().nonnegative().optional(), comparator: z.enum(['==', '>', '<', '>=', '<=', '!=', 'in', '!in']), + operations: variableOperationsArraySchema.describe( + 'Optional operations to perform on the obtained result before comparison', + ), }); const requireNonEmptyArrayIfComparatorIsIn = (data: { diff --git a/packages/taco/src/conditions/schemas/sequential.ts b/packages/taco/src/conditions/schemas/sequential.ts index 1f8998c36..3bf54e13c 100644 --- a/packages/taco/src/conditions/schemas/sequential.ts +++ b/packages/taco/src/conditions/schemas/sequential.ts @@ -7,6 +7,7 @@ import { baseConditionSchema, plainStringSchema } from './common'; import { CompoundConditionType } from './compound'; import { IfThenElseConditionType } from './if-then-else'; import { anyConditionSchema } from './utils'; +import { variableOperationsArraySchema } from './variable-operation'; const getAllNestedConditionVariableNames = ( condition: ConditionProps, @@ -50,11 +51,19 @@ const noDuplicateVarNames = (condition: ConditionProps): boolean => { export const SequentialConditionType = 'sequential'; export const conditionVariableSchema: z.ZodSchema = z.lazy(() => - z.object({ - varName: plainStringSchema, - condition: anyConditionSchema, - }), + z + .object({ + varName: plainStringSchema, + condition: anyConditionSchema, + operations: variableOperationsArraySchema.describe( + 'Optional operations to perform on the obtained condition result before storing it', + ), + }) + .describe( + 'Executes a condition and stores the result as a variable within a sequential condition.', + ), ); + export type ConditionVariableProps = z.infer; export const sequentialConditionSchema: z.ZodSchema = baseConditionSchema diff --git a/packages/taco/src/conditions/schemas/variable-operation.ts b/packages/taco/src/conditions/schemas/variable-operation.ts new file mode 100644 index 000000000..822c89fde --- /dev/null +++ b/packages/taco/src/conditions/schemas/variable-operation.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; + +import { paramOrContextParamSchema } from './context'; + +export const OPERATOR_FUNCTIONS = [ + '+=', + '-=', + '*=', + '/=', + '%=', + 'index', + 'round', + // operations that don't require 2nd value + 'abs', + 'avg', + 'ceil', + 'ethToWei', + 'floor', + 'len', + 'max', + 'min', + 'sum', + 'weiToEth', + // casting + 'bool', + 'float', + 'int', + 'str', +] as const; + +export const UNARY_OPERATOR_FUNCTIONS = [ + 'abs', + 'avg', + 'ceil', + 'ethToWei', + 'floor', + 'len', + 'max', + 'min', + 'sum', + 'weiToEth', + // casting + 'bool', + 'float', + 'int', + 'str', +]; + +export const variableOperationSchema = z + .object({ + operation: z.enum(OPERATOR_FUNCTIONS), + value: paramOrContextParamSchema.optional(), + }) + .refine( + (data) => { + if ( + UNARY_OPERATOR_FUNCTIONS.includes(data.operation) && + data.value !== undefined + ) { + return false; + } + return true; + }, + { + message: 'Value not allowed for this operation', + path: ['value'], + }, + ) + .refine( + (data) => { + if ( + !UNARY_OPERATOR_FUNCTIONS.includes(data.operation) && + data.value === undefined + ) { + return false; + } + return true; + }, + { + message: 'Value must be defined for operation', + path: ['value'], + }, + ) + .describe('An operation that can be performed on an obtained result.'); + +export const MAX_VARIABLE_OPERATIONS = 5; + +export const variableOperationsArraySchema = z + .array(variableOperationSchema) + .min(1) + .max(MAX_VARIABLE_OPERATIONS) + .optional() + .describe('Optional operations to perform on the obtained result'); diff --git a/packages/taco/test/conditions/return-value-test.test.ts b/packages/taco/test/conditions/return-value-test.test.ts index 00a251031..b08f10bf7 100644 --- a/packages/taco/test/conditions/return-value-test.test.ts +++ b/packages/taco/test/conditions/return-value-test.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; +import { + MAX_VARIABLE_OPERATIONS, + OPERATOR_FUNCTIONS, + UNARY_OPERATOR_FUNCTIONS, +} from '../../src/conditions/schemas/variable-operation'; import { blockchainReturnValueTestSchema, returnValueTestSchema, @@ -64,5 +69,70 @@ import { }); }, ); + it.each(OPERATOR_FUNCTIONS)('allows valid operations', (operation) => { + const result = schema.safeParse({ + comparator: '==', + value: 10, + operations: [ + { + operation: operation, + value: UNARY_OPERATOR_FUNCTIONS.includes(operation) ? undefined : 5, + }, + ], + }); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + it('requires at least one operation if defined', () => { + const result = schema.safeParse({ + comparator: '==', + value: 10, + operations: [], + }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error!.format()).toMatchObject({ + operations: { + _errors: ['Array must contain at least 1 element(s)'], + }, + }); + }); + it(`allows at most ${MAX_VARIABLE_OPERATIONS} operations`, () => { + const result = schema.safeParse({ + comparator: '==', + value: 10, + operations: Array.from( + { length: MAX_VARIABLE_OPERATIONS + 1 }, + (_, i) => ({ + operation: '+=', + value: i + 1, + }), + ), + }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error!.format()).toMatchObject({ + operations: { + _errors: [ + `Array must contain at most ${MAX_VARIABLE_OPERATIONS} element(s)`, + ], + }, + }); + }); + it('allows multiple valid operations', () => { + const result = schema.safeParse({ + comparator: '==', + value: 10, + operations: [ + { operation: 'index', value: 1 }, + { operation: '+=', value: 5 }, + { operation: '*=', value: 2.5 }, + { operation: 'abs' }, + { operation: '+=', value: BigInt('1000000000000000') }, + ], + }); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); }); }); diff --git a/packages/taco/test/conditions/sequential.test.ts b/packages/taco/test/conditions/sequential.test.ts index 559f70d4f..8336a3b2d 100644 --- a/packages/taco/test/conditions/sequential.test.ts +++ b/packages/taco/test/conditions/sequential.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from 'vitest'; import { CompoundConditionType } from '../../src/conditions/compound-condition'; import { IfThenElseConditionType } from '../../src/conditions/if-then-else-condition'; +import { + MAX_VARIABLE_OPERATIONS, + OPERATOR_FUNCTIONS, + UNARY_OPERATOR_FUNCTIONS, +} from '../../src/conditions/schemas/variable-operation'; import { ConditionVariableProps, SequentialCondition, @@ -387,4 +392,132 @@ describe('validation', () => { }); expect(condition.value.conditionType).toEqual(SequentialConditionType); }); + it.each(OPERATOR_FUNCTIONS)('allows valid operations', (operation) => { + const conditionObj = { + conditionType: SequentialConditionType, + conditionVariables: [ + { + varName: 'var1', + condition: testRpcConditionObj, + operations: [ + { + operation: operation, + value: UNARY_OPERATOR_FUNCTIONS.includes(operation) + ? undefined + : 5, + }, + ], + }, + rpcConditionVariable, + timeConditionVariable, + contractConditionVariable, + ], + }; + const result = SequentialCondition.validate( + sequentialConditionSchema, + conditionObj, + ); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual(conditionObj); + }); + it('requires at least one operation if defined', () => { + const conditionObj = { + conditionType: SequentialConditionType, + conditionVariables: [ + { + varName: 'var1', + condition: testRpcConditionObj, + operations: [], + }, + contractConditionVariable, + ], + }; + const result = SequentialCondition.validate( + sequentialConditionSchema, + conditionObj, + ); + expect(result.error).toBeDefined(); + expect(result.error?.format()).toMatchObject({ + conditionVariables: { + '0': { + operations: { + _errors: ['Array must contain at least 1 element(s)'], + }, + }, + }, + }); + expect(result.data).toBeUndefined(); + }); + it(`allows at most ${MAX_VARIABLE_OPERATIONS} operations`, () => { + const conditionObj = { + conditionType: SequentialConditionType, + conditionVariables: [ + { + varName: 'var1', + condition: testRpcConditionObj, + operations: Array.from( + { length: MAX_VARIABLE_OPERATIONS + 1 }, + (_, i) => ({ + operation: '*=', + value: i + 1, + }), + ), + }, + contractConditionVariable, + ], + }; + const result = SequentialCondition.validate( + sequentialConditionSchema, + conditionObj, + ); + expect(result.error).toBeDefined(); + expect(result.error?.format()).toMatchObject({ + conditionVariables: { + '0': { + operations: { + _errors: [ + `Array must contain at most ${MAX_VARIABLE_OPERATIONS} element(s)`, + ], + }, + }, + }, + }); + expect(result.data).toBeUndefined(); + }); + it('allows multiple operations', () => { + const conditionObj = { + conditionType: SequentialConditionType, + conditionVariables: [ + { + varName: 'var1', + condition: testRpcConditionObj, + operations: [ + { operation: 'index', value: 1 }, + { operation: '*=', value: 2.5 }, + { operation: '-=', value: 5.5 }, + { operation: 'int' }, + { operation: '+=', value: BigInt('1000000000000000') }, + ], + }, + { + varName: 'var2', + condition: testTimeConditionObj, + operations: [ + { operation: 'ceil' }, + { operation: '/=', value: 2.5 }, + { operation: 'floor' }, + { operation: 'ethToWei' }, + { operation: 'weiToEth' }, + ], + }, + contractConditionVariable, + ], + }; + const result = SequentialCondition.validate( + sequentialConditionSchema, + conditionObj, + ); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual(conditionObj); + }); }); diff --git a/packages/taco/test/conditions/variable-operation.test.ts b/packages/taco/test/conditions/variable-operation.test.ts new file mode 100644 index 000000000..b2ecc578e --- /dev/null +++ b/packages/taco/test/conditions/variable-operation.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + OPERATOR_FUNCTIONS, + UNARY_OPERATOR_FUNCTIONS, + variableOperationSchema, +} from '../../src/conditions/schemas/variable-operation'; + +describe('validates schema', () => { + it.each(OPERATOR_FUNCTIONS)('allows valid operation', (operation) => { + const result = variableOperationSchema.safeParse({ + operation: operation, + value: UNARY_OPERATOR_FUNCTIONS.includes(operation) ? undefined : 5, + }); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + it.each(UNARY_OPERATOR_FUNCTIONS)( + 'disallows operations when value is missing for operation that requires a value', + (operation) => { + const result = variableOperationSchema.safeParse({ + operation: operation, + value: 5, // value should be omitted for these operations + }); + expect(result.success).toBe(false); + expect(result.error!.format()).toMatchObject({ + value: { + _errors: ['Value not allowed for this operation'], + }, + }); + }, + ); + + it.each( + OPERATOR_FUNCTIONS.filter((op) => !UNARY_OPERATOR_FUNCTIONS.includes(op)), + )( + 'disallows operations when value is not provided for operation that requires a value', + (operation) => { + const result = variableOperationSchema.safeParse({ + operation: operation, + // value is missing for these operations + }); + expect(result.success).toBe(false); + expect(result.error!.format()).toMatchObject({ + value: { + _errors: ['Value must be defined for operation'], + }, + }); + }, + ); +});