Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 28 additions & 33 deletions src/proposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ import {
validateAddress,
validateSignature,
validateId,
validateAssignDepositStructure as baseValidateAssignDepositStructure,
validateVaultIdOnChain as baseValidateVaultIdOnChain,
validateCreateVaultStructure,
createValidators,
type VaultIdValidator,
} from './utils/validator.js'
import {
pinBundleToFilecoin,
Expand Down Expand Up @@ -137,15 +138,12 @@ const APPROX_7D_BLOCKS = 50400

// In-memory discovery cursors per chainId (last checked block number)
const lastCheckedBlockByChain: Record<number, number> = {}
// Wrapper to use validator's on-chain vault ID validation with contract dependency
const validateVaultIdOnChain = async (vaultId: number): Promise<void> => {
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<void>) | null = null
let validateAssignDepositStructure:
| ((intention: Intention) => Promise<void>)
| null = null

/**
* Computes block range hex strings for Alchemy getAssetTransfers requests,
Expand Down Expand Up @@ -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<void> => {
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
Expand Down Expand Up @@ -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,
Expand All @@ -1023,6 +1004,7 @@ async function handleIntention(

// Handle CreateVault intention and trigger seeding
if (validatedIntention.action === 'CreateVault') {
validateCreateVaultStructure(validatedIntention)
await handleCreateVault({
intention: validatedIntention,
validatedController,
Expand All @@ -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
Expand Down Expand Up @@ -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
}

/**
Expand Down
249 changes: 237 additions & 12 deletions src/utils/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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(
Expand All @@ -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) &&
Expand Down Expand Up @@ -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<number>
}

/**
* 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<void> => {
await validateVaultIdOnChain(vaultId, contract.getNextVaultId)
}

/**
* Validates AssignDeposit intention structure with on-chain vault ID validation.
*/
const assignDepositValidator = async (
intention: Intention
): Promise<void> => {
await validateAssignDepositStructure(intention, vaultIdValidator)
}

return {
validateVaultIdOnChain: vaultIdValidator,
validateAssignDepositStructure: assignDepositValidator,
}
}