diff --git a/src/proposer.ts b/src/proposer.ts index 11e0a6c..54138ec 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -42,8 +42,9 @@ import { validateAddress, validateSignature, validateId, - validateAssignDepositStructure as baseValidateAssignDepositStructure, - validateVaultIdOnChain as baseValidateVaultIdOnChain, + validateCreateVaultStructure, + createValidators, + type VaultIdValidator, } from './utils/validator.js' import { pinBundleToFilecoin, @@ -137,15 +138,12 @@ 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, @@ -245,28 +243,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 amounts must all 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 @@ -1003,6 +979,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, @@ -1023,6 +1004,7 @@ async function handleIntention( // Handle CreateVault intention and trigger seeding if (validatedIntention.action === 'CreateVault') { + validateCreateVaultStructure(validatedIntention) await handleCreateVault({ intention: validatedIntention, validatedController, @@ -1033,6 +1015,8 @@ async function handleIntention( logger, }, }) + // CreateVault doesn't need balance checks or bundling - return empty execution object + return { execution: [] } } // Check for expiry @@ -1336,6 +1320,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 4ac4be2..a54ca94 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,99 @@ 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 */ @@ -475,7 +575,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 +597,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) && @@ -584,3 +687,125 @@ 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 + ) + } +} + +/** + * 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, + } +}