From 567d80f13cd4ad96bc51daa02de774564f082ad7 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Thu, 30 Oct 2025 12:43:29 -0400 Subject: [PATCH 1/5] modify totalFee validation to accept empty array for assignDeposit --- src/proposer.ts | 2 +- src/utils/validator.ts | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 11e0a6c..492279a 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -252,7 +252,7 @@ async function discoverAndIngestDeposits(params: { * - For each index i: asset/amount/chain_id must match between input and output * - outputs[i].to must be provided (no to_external) and must be a valid on-chain vault ID * - Fees must be zero: - * - totalFee amounts must all be "0" + * - totalFee must be empty or all amounts must be "0" * - proposerTip must be empty * - protocolFee must be empty * - agentTip must be undefined or empty diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 4ac4be2..afd69a3 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -475,7 +475,7 @@ export async function validateVaultIdOnChain( * - inputs.length === outputs.length * - For each index i: asset/amount/chain_id must match between input and output * - outputs[i].to must be provided (no to_external) and must be a valid on-chain vault ID - * - Fees must be zero (totalFee amounts zero; proposerTip/protocolFee empty; agentTip empty) + * - Fees must be zero (totalFee empty or amounts zero; proposerTip/protocolFee empty; agentTip empty) * Accepts a dependency to validate vault IDs on-chain. */ export async function validateAssignDepositStructure( @@ -497,20 +497,23 @@ export async function validateAssignDepositStructure( ) } - if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { + if (!Array.isArray(intention.totalFee)) { throw new ValidationError( - 'AssignDeposit requires totalFee with zero amount', + 'AssignDeposit totalFee must be an array', 'intention.totalFee', intention.totalFee ) } - const allTotalZero = intention.totalFee.every((f) => f.amount === '0') - if (!allTotalZero) { - throw new ValidationError( - 'AssignDeposit totalFee must be zero', - 'intention.totalFee', - intention.totalFee - ) + // If totalFee is not empty, all amounts must be zero + if (intention.totalFee.length > 0) { + const allTotalZero = intention.totalFee.every((f) => f.amount === '0') + if (!allTotalZero) { + throw new ValidationError( + 'AssignDeposit totalFee must be zero', + 'intention.totalFee', + intention.totalFee + ) + } } if ( Array.isArray(intention.proposerTip) && From 28d94d19315adcb1a016a5e384e2043fd797f8e3 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Thu, 30 Oct 2025 12:54:18 -0400 Subject: [PATCH 2/5] modify validation of CreateVault intentions to allow empty arrays for inputs and outputs --- src/proposer.ts | 4 + src/utils/validator.ts | 196 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 492279a..d4a4a38 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -44,6 +44,7 @@ import { validateId, validateAssignDepositStructure as baseValidateAssignDepositStructure, validateVaultIdOnChain as baseValidateVaultIdOnChain, + validateCreateVaultStructure, } from './utils/validator.js' import { pinBundleToFilecoin, @@ -1023,6 +1024,7 @@ async function handleIntention( // Handle CreateVault intention and trigger seeding if (validatedIntention.action === 'CreateVault') { + validateCreateVaultStructure(validatedIntention) await handleCreateVault({ intention: validatedIntention, validatedController, @@ -1033,6 +1035,8 @@ async function handleIntention( logger, }, }) + // CreateVault doesn't need balance checks or bundling - return empty execution object + return { execution: [] } } // Check for expiry diff --git a/src/utils/validator.ts b/src/utils/validator.ts index afd69a3..b034b38 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -213,12 +213,19 @@ export function validateIntention(intention: Intention): Intention { ) } + // CreateVault allows empty inputs/outputs + const isCreateVault = intention.action === 'CreateVault' + const validated: Intention = { action: intention.action, nonce: validateNonce(intention.nonce, 'intention.nonce'), expiry: validateTimestamp(intention.expiry, 'intention.expiry'), - inputs: validateIntentionInputs(intention.inputs, 'intention.inputs'), - outputs: validateIntentionOutputs(intention.outputs, 'intention.outputs'), + inputs: isCreateVault + ? validateIntentionInputsOptional(intention.inputs, 'intention.inputs') + : validateIntentionInputs(intention.inputs, 'intention.inputs'), + outputs: isCreateVault + ? validateIntentionOutputsOptional(intention.outputs, 'intention.outputs') + : validateIntentionOutputs(intention.outputs, 'intention.outputs'), totalFee: validateTotalFeeAmounts(intention.totalFee, 'intention.totalFee'), proposerTip: validateFeeAmounts( intention.proposerTip, @@ -342,6 +349,107 @@ function validateIntentionOutputs( }) } +/** + * Validates an array of intention inputs (allows empty arrays for CreateVault) + */ +function validateIntentionInputsOptional( + inputs: IntentionInput[], + fieldName: string +): IntentionInput[] { + if (!Array.isArray(inputs)) { + throw new ValidationError( + 'Inputs must be an array', + fieldName, + inputs + ) + } + if (inputs.length === 0) { + return [] + } + return inputs.map((input, index) => { + const fieldPath = `${fieldName}[${index}]` + const validated: IntentionInput = { + asset: validateAddress(input.asset, `${fieldPath}.asset`), + amount: validateBalance(input.amount, `${fieldPath}.amount`), + chain_id: validateId(input.chain_id, `${fieldPath}.chain_id`), + } + + if (input.from !== undefined) { + validated.from = validateId(input.from, `${fieldPath}.from`) + } + + if (input.data !== undefined) { + validated.data = input.data + } + + return validated + }) +} + +/** + * Validates an array of intention outputs (allows empty arrays for CreateVault) + */ +function validateIntentionOutputsOptional( + outputs: IntentionOutput[], + fieldName: string +): IntentionOutput[] { + if (!Array.isArray(outputs)) { + throw new ValidationError( + 'Outputs must be an array', + fieldName, + outputs + ) + } + if (outputs.length === 0) { + return [] + } + return outputs.map((output, index) => { + const fieldPath = `${fieldName}[${index}]` + const validated: IntentionOutput = { + asset: validateAddress(output.asset, `${fieldPath}.asset`), + amount: validateBalance(output.amount, `${fieldPath}.amount`), + chain_id: validateId(output.chain_id, `${fieldPath}.chain_id`), + } + + const hasTo = output.to !== undefined + const hasToExternal = + output.to_external !== undefined && output.to_external !== '' + + if (hasTo && hasToExternal) { + throw new ValidationError( + 'Fields "to" and "to_external" are mutually exclusive', + fieldPath, + output + ) + } + + if (!hasTo && !hasToExternal) { + throw new ValidationError( + 'Either "to" or "to_external" must be provided', + fieldPath, + output + ) + } + + if (hasTo) { + validated.to = validateId(output.to, `${fieldPath}.to`) + } + + if (hasToExternal) { + validated.to_external = validateAddress( + output.to_external!, + `${fieldPath}.to_external` + ) + } + + if (output.data !== undefined) { + validated.data = output.data + } + + return validated + }) +} + /** * Validates an array of fee amounts */ @@ -587,3 +695,87 @@ export async function validateAssignDepositStructure( await validateVaultId(Number(output.to)) } } + +/** + * Validates structural and fee constraints for CreateVault intentions. + * Rules: + * - inputs must be empty (CreateVault doesn't transfer assets) + * - outputs must be empty (CreateVault doesn't transfer assets) + * - All fee arrays must be empty (totalFee, proposerTip, protocolFee, agentTip) + */ +export function validateCreateVaultStructure(intention: Intention): void { + if (!Array.isArray(intention.inputs)) { + throw new ValidationError( + 'CreateVault inputs must be an array', + 'intention.inputs', + intention.inputs + ) + } + if (intention.inputs.length > 0) { + throw new ValidationError( + 'CreateVault inputs must be empty', + 'intention.inputs', + intention.inputs + ) + } + + if (!Array.isArray(intention.outputs)) { + throw new ValidationError( + 'CreateVault outputs must be an array', + 'intention.outputs', + intention.outputs + ) + } + if (intention.outputs.length > 0) { + throw new ValidationError( + 'CreateVault outputs must be empty', + 'intention.outputs', + intention.outputs + ) + } + + if (!Array.isArray(intention.totalFee)) { + throw new ValidationError( + 'CreateVault totalFee must be an array', + 'intention.totalFee', + intention.totalFee + ) + } + if (intention.totalFee.length > 0) { + throw new ValidationError( + 'CreateVault totalFee must be empty', + 'intention.totalFee', + intention.totalFee + ) + } + + if ( + Array.isArray(intention.proposerTip) && + intention.proposerTip.length > 0 + ) { + throw new ValidationError( + 'CreateVault proposerTip must be empty', + 'intention.proposerTip', + intention.proposerTip + ) + } + + if ( + Array.isArray(intention.protocolFee) && + intention.protocolFee.length > 0 + ) { + throw new ValidationError( + 'CreateVault protocolFee must be empty', + 'intention.protocolFee', + intention.protocolFee + ) + } + + if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { + throw new ValidationError( + 'CreateVault agentTip must be empty if provided', + 'intention.agentTip', + intention.agentTip + ) + } +} From 2d92cb51077f1d417214d1f1ca6a2ce44754248c Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Thu, 30 Oct 2025 12:54:32 -0400 Subject: [PATCH 3/5] formatting fix --- src/utils/validator.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/utils/validator.ts b/src/utils/validator.ts index b034b38..3d9c696 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -357,11 +357,7 @@ function validateIntentionInputsOptional( fieldName: string ): IntentionInput[] { if (!Array.isArray(inputs)) { - throw new ValidationError( - 'Inputs must be an array', - fieldName, - inputs - ) + throw new ValidationError('Inputs must be an array', fieldName, inputs) } if (inputs.length === 0) { return [] @@ -394,11 +390,7 @@ function validateIntentionOutputsOptional( fieldName: string ): IntentionOutput[] { if (!Array.isArray(outputs)) { - throw new ValidationError( - 'Outputs must be an array', - fieldName, - outputs - ) + throw new ValidationError('Outputs must be an array', fieldName, outputs) } if (outputs.length === 0) { return [] From c0e9765437af0bd707ce0df150c48ad76ccc5e9f Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Thu, 30 Oct 2025 13:24:12 -0400 Subject: [PATCH 4/5] extract validator functions from proposer completely --- src/proposer.ts | 56 +++++++++++++++++------------------------- src/utils/validator.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index d4a4a38..137ca68 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -42,9 +42,9 @@ import { validateAddress, validateSignature, validateId, - validateAssignDepositStructure as baseValidateAssignDepositStructure, - validateVaultIdOnChain as baseValidateVaultIdOnChain, validateCreateVaultStructure, + createValidators, + type VaultIdValidator, } from './utils/validator.js' import { pinBundleToFilecoin, @@ -138,15 +138,11 @@ const APPROX_7D_BLOCKS = 50400 // In-memory discovery cursors per chainId (last checked block number) const lastCheckedBlockByChain: Record = {} -// Wrapper to use validator's on-chain vault ID validation with contract dependency -const validateVaultIdOnChain = async (vaultId: number): Promise => { - if (!vaultTrackerContract) { - throw new Error('VaultTracker contract not initialized') - } - const nextId = await vaultTrackerContract.nextVaultId() - const nextIdNumber = Number(nextId) - await baseValidateVaultIdOnChain(vaultId, async () => nextIdNumber) -} + +// Validator functions (initialized after contract setup) +let validateVaultIdOnChain: ((vaultId: number) => Promise) | null = null +let validateAssignDepositStructure: ((intention: Intention) => Promise) | null = + null /** * Computes block range hex strings for Alchemy getAssetTransfers requests, @@ -246,28 +242,6 @@ async function discoverAndIngestDeposits(params: { lastCheckedBlockByChain[params.chainId] = toBlockNum } -/** - * Validates structural and fee constraints for AssignDeposit intentions. - * Rules: - * - inputs.length === outputs.length - * - For each index i: asset/amount/chain_id must match between input and output - * - outputs[i].to must be provided (no to_external) and must be a valid on-chain vault ID - * - Fees must be zero: - * - totalFee must be empty or all amounts must be "0" - * - proposerTip must be empty - * - protocolFee must be empty - * - agentTip must be undefined or empty - */ - -// Wrapper to use validator's AssignDeposit structural validation with on-chain vault check -const validateAssignDepositStructure = async ( - intention: Intention -): Promise => { - await baseValidateAssignDepositStructure(intention, async (id: number) => - validateVaultIdOnChain(id) - ) -} - /** * Discovers ERC-20 deposits made by `controller` into the VaultTracker and * ingests them into the local `deposits` table. Uses Alchemy's decoded @@ -1004,6 +978,11 @@ async function handleIntention( // Handle AssignDeposit intention (bypass generic balance checks) if (validatedIntention.action === 'AssignDeposit') { + if (!validateAssignDepositStructure || !validateVaultIdOnChain) { + throw new Error( + 'Validators not initialized. Call initializeProposer() first.' + ) + } const executionObject = await handleAssignDeposit({ intention: validatedIntention, validatedController, @@ -1340,6 +1319,17 @@ async function initializeWalletAndContract() { wallet = walletInstance bundleTrackerContract = await buildBundleTrackerContract() vaultTrackerContract = await buildVaultTrackerContract() + + // Initialize validators with contract dependency + const contractValidator: VaultIdValidator = { + getNextVaultId: async () => { + const nextId = await vaultTrackerContract.nextVaultId() + return Number(nextId) + }, + } + const validators = createValidators(contractValidator) + validateVaultIdOnChain = validators.validateVaultIdOnChain + validateAssignDepositStructure = validators.validateAssignDepositStructure } /** diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 3d9c696..a54ca94 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -771,3 +771,41 @@ export function validateCreateVaultStructure(intention: Intention): void { ) } } + +/** + * Contract interface for vault ID validation. + * Provides a method to get the next unassigned vault ID from the chain. + */ +export interface VaultIdValidator { + getNextVaultId: () => Promise +} + +/** + * Creates configured validator functions that use the provided contract for on-chain validation. + * Returns validators that can check vault IDs and validate AssignDeposit structures. + * + * @param contract - Contract interface that provides nextVaultId + * @returns Configured validator functions + */ +export function createValidators(contract: VaultIdValidator) { + /** + * Validates that a vault ID exists on-chain using the provided contract. + */ + const vaultIdValidator = async (vaultId: number): Promise => { + await validateVaultIdOnChain(vaultId, contract.getNextVaultId) + } + + /** + * Validates AssignDeposit intention structure with on-chain vault ID validation. + */ + const assignDepositValidator = async ( + intention: Intention + ): Promise => { + await validateAssignDepositStructure(intention, vaultIdValidator) + } + + return { + validateVaultIdOnChain: vaultIdValidator, + validateAssignDepositStructure: assignDepositValidator, + } +} From fc7d883d92ae43cf872a4731dfd15fc7f200e384 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Thu, 30 Oct 2025 13:24:22 -0400 Subject: [PATCH 5/5] format fix --- src/proposer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 137ca68..54138ec 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -141,8 +141,9 @@ const lastCheckedBlockByChain: Record = {} // Validator functions (initialized after contract setup) let validateVaultIdOnChain: ((vaultId: number) => Promise) | null = null -let validateAssignDepositStructure: ((intention: Intention) => Promise) | null = - null +let validateAssignDepositStructure: + | ((intention: Intention) => Promise) + | null = null /** * Computes block range hex strings for Alchemy getAssetTransfers requests,