From 9f670dc6470d3701b83aac07794748e02f73c6ee Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 16:57:03 -0400 Subject: [PATCH 01/24] create deposits table --- scripts/shared/db-setup.js | 22 +++++++++++++++++++++- src/config/dbSettings.ts | 2 ++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/shared/db-setup.js b/scripts/shared/db-setup.js index 3b8b34e..dd08327 100644 --- a/scripts/shared/db-setup.js +++ b/scripts/shared/db-setup.js @@ -74,6 +74,19 @@ CREATE TABLE IF NOT EXISTS vaults ( rules TEXT, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); + +-- Create the deposits table (tracks on-chain deposits to VaultTracker) +CREATE TABLE IF NOT EXISTS deposits ( + id SERIAL PRIMARY KEY, + tx_hash TEXT NOT NULL, + transfer_uid TEXT NOT NULL UNIQUE, + chain_id INTEGER NOT NULL, + depositor TEXT NOT NULL, + token TEXT NOT NULL, + amount NUMERIC(78, 0) NOT NULL, + credited_vault TEXT, + assigned_at TIMESTAMPTZ +); ` /** @@ -90,12 +103,19 @@ CREATE INDEX IF NOT EXISTS idx_bundles_ipfs_cid ON bundles(ipfs_cid); -- Create GIN index on vaults controllers array for efficient lookups CREATE INDEX IF NOT EXISTS idx_vaults_controllers ON vaults USING GIN (controllers); + +-- Create indexes for deposits table +CREATE INDEX IF NOT EXISTS idx_deposits_tx_hash ON deposits(tx_hash); +CREATE INDEX IF NOT EXISTS idx_deposits_depositor ON deposits(depositor, chain_id); +CREATE INDEX IF NOT EXISTS idx_deposits_token ON deposits(token); +CREATE INDEX IF NOT EXISTS idx_deposits_vault ON deposits(credited_vault); ` /** * SQL statements for dropping tables */ export const dropTablesSql = ` +DROP TABLE IF EXISTS deposits CASCADE; DROP TABLE IF EXISTS bundles CASCADE; DROP TABLE IF EXISTS cids CASCADE; DROP TABLE IF EXISTS balances CASCADE; @@ -171,7 +191,7 @@ export async function setupDatabase(options) { SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' - AND table_name IN ('bundles', 'cids', 'balances', 'nonces', 'proposers', 'vaults') + AND table_name IN ('bundles', 'cids', 'balances', 'nonces', 'proposers', 'vaults', 'deposits') ORDER BY table_name `) diff --git a/src/config/dbSettings.ts b/src/config/dbSettings.ts index b508fd7..66cc66b 100644 --- a/src/config/dbSettings.ts +++ b/src/config/dbSettings.ts @@ -38,4 +38,6 @@ export const REQUIRED_TABLES = [ 'balances', 'nonces', 'proposers', + 'vaults', + 'deposits', ] as const From a82c54ef1a7776ca92277df7ef3cf2b76b3ce59f Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:02:11 -0400 Subject: [PATCH 02/24] create deposit helper functions --- src/utils/deposits.ts | 135 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/utils/deposits.ts diff --git a/src/utils/deposits.ts b/src/utils/deposits.ts new file mode 100644 index 0000000..3c893fc --- /dev/null +++ b/src/utils/deposits.ts @@ -0,0 +1,135 @@ +/** + * ╔═══════════════════════════════════════════════════════════════════════════╗ + * ║ 🌪️ OYA PROTOCOL NODE 🌪️ ║ + * ║ Deposit Utilities ║ + * ╚═══════════════════════════════════════════════════════════════════════════╝ + * + * Persistence helpers for the `deposits` table. + * - Idempotent insertion by `transfer_uid` + * - Querying exact, unassigned deposits + * - Marking a deposit as assigned atomically + * + * @packageDocumentation + */ + +import { pool } from '../db.js' +import { createLogger } from './logger.js' + +const logger = createLogger('Deposits') + +export interface InsertDepositParams { + tx_hash: string + transfer_uid: string + chain_id: number + depositor: string + token: string + amount: string // wei as decimal string +} + +export async function insertDepositIfMissing( + params: InsertDepositParams +): Promise<{ id: number }> { + const txHash = params.tx_hash + const uid = params.transfer_uid + const chainId = params.chain_id + const depositor = params.depositor.toLowerCase() + const token = params.token.toLowerCase() + const amount = params.amount + + const client = await pool.connect() + try { + const insert = await client.query( + `INSERT INTO deposits (tx_hash, transfer_uid, chain_id, depositor, token, amount) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (transfer_uid) DO NOTHING + RETURNING id`, + [txHash, uid, chainId, depositor, token, amount] + ) + + if (insert.rows.length > 0) { + return { id: insert.rows[0].id as number } + } + + const select = await client.query( + `SELECT id FROM deposits WHERE transfer_uid = $1`, + [uid] + ) + if (select.rows.length === 0) { + logger.error( + 'insertDepositIfMissing: row not found after ON CONFLICT DO NOTHING', + { + transfer_uid: uid, + } + ) + throw new Error('Deposit insert failed') + } + return { id: select.rows[0].id as number } + } finally { + client.release() + } +} + +export interface FindExactUnassignedParams { + depositor: string + token: string + amount: string + chain_id: number +} + +export async function findExactUnassignedDeposit( + params: FindExactUnassignedParams +): Promise<{ id: number } | null> { + const depositor = params.depositor.toLowerCase() + const token = params.token.toLowerCase() + const amount = params.amount + const chainId = params.chain_id + + const result = await pool.query( + `SELECT id + FROM deposits + WHERE depositor = $1 + AND LOWER(token) = LOWER($2) + AND amount = $3 + AND chain_id = $4 + AND assigned_at IS NULL + ORDER BY id ASC + LIMIT 1`, + [depositor, token, amount, chainId] + ) + + if (result.rows.length === 0) return null + return { id: result.rows[0].id as number } +} + +export async function markDepositAssigned( + deposit_id: number, + credited_vault: string +): Promise { + const client = await pool.connect() + try { + await client.query('BEGIN') + const update = await client.query( + `UPDATE deposits + SET credited_vault = $2, + assigned_at = CURRENT_TIMESTAMP + WHERE id = $1 + AND assigned_at IS NULL + RETURNING id`, + [deposit_id, String(credited_vault)] + ) + if (update.rows.length === 0) { + await client.query('ROLLBACK') + throw new Error('Deposit already assigned or not found') + } + await client.query('COMMIT') + } catch (error) { + try { + await client.query('ROLLBACK') + } catch (rollbackError) { + logger.warn('Rollback failed during markDepositAssigned', rollbackError) + } + throw error + } finally { + client.release() + } +} From bcb380c66e8fc43029737fef8f5deb62de0e57ee Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:05:59 -0400 Subject: [PATCH 03/24] create on-chain vault validator --- src/proposer.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/proposer.ts b/src/proposer.ts index db19ec0..47a0c59 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -106,6 +106,8 @@ export interface VaultTrackerContract extends ethers.BaseContract { _controller: string, overrides?: ethers.Overrides ): Promise + /** Returns the next unassigned vault ID (acts as current vault count). */ + nextVaultId(): Promise } let cachedIntentions: ExecutionObject[] = [] @@ -120,6 +122,28 @@ let s: ReturnType // Initialization flag let isInitialized = false +/** + * Validates that a vault ID exists on-chain by checking it is within range + * [1, nextVaultId - 1]. Throws if invalid or out of range. + */ +export async function validateVaultIdOnChain(vaultId: number): Promise { + // Basic sanity + if (!Number.isInteger(vaultId) || vaultId < 1) { + throw new Error('Invalid vault ID') + } + // Ensure contracts are initialized + if (!vaultTrackerContract) { + throw new Error('VaultTracker contract not initialized') + } + const nextId = await vaultTrackerContract.nextVaultId() + const nextIdNumber = Number(nextId) + if (!Number.isFinite(nextIdNumber)) { + throw new Error('Could not determine nextVaultId from chain') + } + if (vaultId >= nextIdNumber) { + throw new Error('Vault ID does not exist on-chain') + } +} /** * Initializes the BundleTracker contract with ABI and provider. From a460d3c0b6102d50eddb0d0e4e25702a6f789064 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:36:01 -0400 Subject: [PATCH 04/24] create ERC20 and ETH deposit discovery functions --- src/proposer.ts | 168 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 155 insertions(+), 13 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 47a0c59..e6a61b8 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -48,6 +48,7 @@ import { initializeFilecoinPin, } from './utils/filecoinPin.js' import { sendWebhook } from './utils/webhook.js' +import { insertDepositIfMissing } from './utils/deposits.js' import type { Intention, BundleData, @@ -122,27 +123,168 @@ let s: ReturnType // Initialization flag let isInitialized = false + +// ~7 days at ~12s blocks +const APPROX_7D_BLOCKS = 50400 /** * Validates that a vault ID exists on-chain by checking it is within range * [1, nextVaultId - 1]. Throws if invalid or out of range. */ export async function validateVaultIdOnChain(vaultId: number): Promise { - // Basic sanity - if (!Number.isInteger(vaultId) || vaultId < 1) { - throw new Error('Invalid vault ID') - } - // Ensure contracts are initialized - if (!vaultTrackerContract) { - throw new Error('VaultTracker contract not initialized') + // Basic sanity + if (!Number.isInteger(vaultId) || vaultId < 1) { + throw new Error('Invalid vault ID') + } + // Ensure contracts are initialized + if (!vaultTrackerContract) { + throw new Error('VaultTracker contract not initialized') + } + const nextId = await vaultTrackerContract.nextVaultId() + const nextIdNumber = Number(nextId) + if (!Number.isFinite(nextIdNumber)) { + throw new Error('Could not determine nextVaultId from chain') + } + if (vaultId >= nextIdNumber) { + throw new Error('Vault ID does not exist on-chain') + } +} + +/** + * Computes block range hex strings for Alchemy getAssetTransfers requests, + * defaulting to a ~7 day lookback if not provided. + */ +async function computeBlockRange( + fromBlockHex?: string, + toBlockHex?: string +): Promise<{ fromBlockHex: string; toBlockHex: string }> { + const provider = (await sepoliaAlchemy.config.getProvider()) as unknown as { + getBlockNumber: () => Promise } - const nextId = await vaultTrackerContract.nextVaultId() - const nextIdNumber = Number(nextId) - if (!Number.isFinite(nextIdNumber)) { - throw new Error('Could not determine nextVaultId from chain') + const latest = await provider.getBlockNumber() + const resolvedTo = toBlockHex ?? '0x' + latest.toString(16) + const fromBlock = Math.max(0, latest - APPROX_7D_BLOCKS) + const resolvedFrom = fromBlockHex ?? '0x' + fromBlock.toString(16) + return { fromBlockHex: resolvedFrom, toBlockHex: resolvedTo } +} + +/** + * Generic discovery for deposits into VaultTracker using Alchemy's decoded + * asset transfers. Supports ERC-20 and ETH (internal/external) categories. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function discoverAndIngestDeposits(params: { + controller: string + chainId: number + categories: Array<'erc20' | 'internal' | 'external'> + token?: string + fromBlockHex?: string + toBlockHex?: string +}): Promise { + const controller = validateAddress(params.controller, 'controller') + + if (!isInitialized) { + throw new Error('Proposer not initialized') } - if (vaultId >= nextIdNumber) { - throw new Error('Vault ID does not exist on-chain') + if (params.chainId !== 11155111) { + throw new Error('Unsupported chain_id for discovery') } + + const { fromBlockHex, toBlockHex } = await computeBlockRange( + params.fromBlockHex, + params.toBlockHex + ) + + let pageKey: string | undefined = undefined + do { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const req: any = { + fromBlock: fromBlockHex, + toBlock: toBlockHex, + fromAddress: controller, + toAddress: VAULT_TRACKER_ADDRESS, + category: params.categories, + withMetadata: true, + excludeZeroValue: true, + } + if (params.token) { + req.contractAddresses = [validateAddress(params.token, 'token')] + } + if (pageKey) req.pageKey = pageKey + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res: any = await sepoliaAlchemy.core.getAssetTransfers(req) + const transfers = res?.transfers ?? [] + for (const t of transfers) { + const txHash: string = t.hash + const raw = t.rawContract || {} + const rawAddr: string | undefined = raw.address + const rawValueHex: string | undefined = raw.value + if (!rawValueHex) continue + + // Determine token address: ERC-20 uses raw.address; ETH uses zero address + const tokenAddr = rawAddr ?? '0x0000000000000000000000000000000000000000' + + const uniqueId: string | undefined = (t as { uniqueId?: string }).uniqueId + const transferUid = uniqueId ?? `${txHash}:${tokenAddr}:${rawValueHex}` + const amountWei = BigInt(rawValueHex).toString() + + await insertDepositIfMissing({ + tx_hash: txHash, + transfer_uid: transferUid, + chain_id: params.chainId, + depositor: t.from, + token: tokenAddr, + amount: amountWei, + }) + } + pageKey = res?.pageKey + } while (pageKey) +} + +/** + * Discovers ERC-20 deposits made by `controller` into the VaultTracker and + * ingests them into the local `deposits` table. Uses Alchemy's decoded + * getAssetTransfers API for reliability and simplicity. + * + * If fromBlock/toBlock are not provided, computes a default lookback window + * of ~7 days by subtracting ~50,400 blocks from the latest block. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function discoverAndIngestErc20Deposits(params: { + controller: string + token: string + chainId: number + fromBlockHex?: string + toBlockHex?: string +}): Promise { + await discoverAndIngestDeposits({ + controller: params.controller, + chainId: params.chainId, + categories: ['erc20'], + token: params.token, + fromBlockHex: params.fromBlockHex, + toBlockHex: params.toBlockHex, + }) +} + +/** + * Discovers ETH (internal) deposits made by `controller` into the VaultTracker + * and ingests them into the local `deposits` table via idempotent inserts. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function discoverAndIngestEthDeposits(params: { + controller: string + chainId: number + fromBlockHex?: string + toBlockHex?: string +}): Promise { + await discoverAndIngestDeposits({ + controller: params.controller, + chainId: params.chainId, + categories: ['internal', 'external'], + fromBlockHex: params.fromBlockHex, + toBlockHex: params.toBlockHex, + }) } /** From 1a958cf5ce07d81010bc33a0e4f2cc82372b392c Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:40:44 -0400 Subject: [PATCH 05/24] create AssignDeposit validator function --- src/proposer.ts | 251 ++++++++++++++++++++++++++++++------------------ 1 file changed, 158 insertions(+), 93 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index e6a61b8..97404b1 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -154,91 +154,156 @@ export async function validateVaultIdOnChain(vaultId: number): Promise { * defaulting to a ~7 day lookback if not provided. */ async function computeBlockRange( - fromBlockHex?: string, - toBlockHex?: string + fromBlockHex?: string, + toBlockHex?: string ): Promise<{ fromBlockHex: string; toBlockHex: string }> { - const provider = (await sepoliaAlchemy.config.getProvider()) as unknown as { - getBlockNumber: () => Promise - } - const latest = await provider.getBlockNumber() - const resolvedTo = toBlockHex ?? '0x' + latest.toString(16) - const fromBlock = Math.max(0, latest - APPROX_7D_BLOCKS) - const resolvedFrom = fromBlockHex ?? '0x' + fromBlock.toString(16) - return { fromBlockHex: resolvedFrom, toBlockHex: resolvedTo } + const provider = (await sepoliaAlchemy.config.getProvider()) as unknown as { + getBlockNumber: () => Promise + } + const latest = await provider.getBlockNumber() + const resolvedTo = toBlockHex ?? '0x' + latest.toString(16) + const fromBlock = Math.max(0, latest - APPROX_7D_BLOCKS) + const resolvedFrom = fromBlockHex ?? '0x' + fromBlock.toString(16) + return { fromBlockHex: resolvedFrom, toBlockHex: resolvedTo } } /** * Generic discovery for deposits into VaultTracker using Alchemy's decoded * asset transfers. Supports ERC-20 and ETH (internal/external) categories. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars + async function discoverAndIngestDeposits(params: { - controller: string - chainId: number - categories: Array<'erc20' | 'internal' | 'external'> - token?: string - fromBlockHex?: string - toBlockHex?: string + controller: string + chainId: number + categories: Array<'erc20' | 'internal' | 'external'> + token?: string + fromBlockHex?: string + toBlockHex?: string }): Promise { - const controller = validateAddress(params.controller, 'controller') + const controller = validateAddress(params.controller, 'controller') + + if (!isInitialized) { + throw new Error('Proposer not initialized') + } + if (params.chainId !== 11155111) { + throw new Error('Unsupported chain_id for discovery') + } - if (!isInitialized) { - throw new Error('Proposer not initialized') + const { fromBlockHex, toBlockHex } = await computeBlockRange( + params.fromBlockHex, + params.toBlockHex + ) + + let pageKey: string | undefined = undefined + do { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const req: any = { + fromBlock: fromBlockHex, + toBlock: toBlockHex, + fromAddress: controller, + toAddress: VAULT_TRACKER_ADDRESS, + category: params.categories, + withMetadata: true, + excludeZeroValue: true, + } + if (params.token) { + req.contractAddresses = [validateAddress(params.token, 'token')] + } + if (pageKey) req.pageKey = pageKey + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res: any = await sepoliaAlchemy.core.getAssetTransfers(req) + const transfers = res?.transfers ?? [] + for (const t of transfers) { + const txHash: string = t.hash + const raw = t.rawContract || {} + const rawAddr: string | undefined = raw.address + const rawValueHex: string | undefined = raw.value + if (!rawValueHex) continue + + // Determine token address: ERC-20 uses raw.address; ETH uses zero address + const tokenAddr = rawAddr ?? '0x0000000000000000000000000000000000000000' + + const uniqueId: string | undefined = (t as { uniqueId?: string }).uniqueId + const transferUid = uniqueId ?? `${txHash}:${tokenAddr}:${rawValueHex}` + const amountWei = BigInt(rawValueHex).toString() + + await insertDepositIfMissing({ + tx_hash: txHash, + transfer_uid: transferUid, + chain_id: params.chainId, + depositor: t.from, + token: tokenAddr, + amount: amountWei, + }) + } + pageKey = res?.pageKey + } while (pageKey) +} + +/** + * 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 + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function validateAssignDepositStructure(intention: Intention): Promise { + if (!Array.isArray(intention.inputs) || !Array.isArray(intention.outputs)) { + throw new Error('AssignDeposit requires inputs and outputs arrays') + } + if (intention.inputs.length !== intention.outputs.length) { + throw new Error('AssignDeposit requires 1:1 mapping between inputs and outputs') + } + + // Zero-fee enforcement + if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { + throw new Error('AssignDeposit requires totalFee with zero amount') + } + const allTotalZero = intention.totalFee.every((f) => f.amount === '0') + if (!allTotalZero) { + throw new Error('AssignDeposit totalFee must be zero') } - if (params.chainId !== 11155111) { - throw new Error('Unsupported chain_id for discovery') + if (Array.isArray(intention.proposerTip) && intention.proposerTip.length > 0) { + throw new Error('AssignDeposit proposerTip must be empty') } + if (Array.isArray(intention.protocolFee) && intention.protocolFee.length > 0) { + throw new Error('AssignDeposit protocolFee must be empty') + } + if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { + throw new Error('AssignDeposit agentTip must be empty if provided') + } + + for (let i = 0; i < intention.inputs.length; i++) { + const input: IntentionInput = intention.inputs[i] + const output: IntentionOutput = intention.outputs[i] + + if (!output || (output.to === undefined && !output.to_external)) { + throw new Error('AssignDeposit requires outputs[].to (vault ID)') + } + if (output.to_external !== undefined) { + throw new Error('AssignDeposit does not support to_external') + } - const { fromBlockHex, toBlockHex } = await computeBlockRange( - params.fromBlockHex, - params.toBlockHex - ) - - let pageKey: string | undefined = undefined - do { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const req: any = { - fromBlock: fromBlockHex, - toBlock: toBlockHex, - fromAddress: controller, - toAddress: VAULT_TRACKER_ADDRESS, - category: params.categories, - withMetadata: true, - excludeZeroValue: true, + if (input.asset.toLowerCase() !== output.asset.toLowerCase()) { + throw new Error('AssignDeposit input/output asset mismatch at index ' + i) } - if (params.token) { - req.contractAddresses = [validateAddress(params.token, 'token')] + if (input.amount !== output.amount) { + throw new Error('AssignDeposit input/output amount mismatch at index ' + i) } - if (pageKey) req.pageKey = pageKey - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const res: any = await sepoliaAlchemy.core.getAssetTransfers(req) - const transfers = res?.transfers ?? [] - for (const t of transfers) { - const txHash: string = t.hash - const raw = t.rawContract || {} - const rawAddr: string | undefined = raw.address - const rawValueHex: string | undefined = raw.value - if (!rawValueHex) continue - - // Determine token address: ERC-20 uses raw.address; ETH uses zero address - const tokenAddr = rawAddr ?? '0x0000000000000000000000000000000000000000' - - const uniqueId: string | undefined = (t as { uniqueId?: string }).uniqueId - const transferUid = uniqueId ?? `${txHash}:${tokenAddr}:${rawValueHex}` - const amountWei = BigInt(rawValueHex).toString() - - await insertDepositIfMissing({ - tx_hash: txHash, - transfer_uid: transferUid, - chain_id: params.chainId, - depositor: t.from, - token: tokenAddr, - amount: amountWei, - }) + if (input.chain_id !== output.chain_id) { + throw new Error('AssignDeposit input/output chain_id mismatch at index ' + i) } - pageKey = res?.pageKey - } while (pageKey) + + // Validate on-chain vault existence + await validateVaultIdOnChain(Number(output.to)) + } } /** @@ -251,20 +316,20 @@ async function discoverAndIngestDeposits(params: { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async function discoverAndIngestErc20Deposits(params: { - controller: string - token: string - chainId: number - fromBlockHex?: string - toBlockHex?: string + controller: string + token: string + chainId: number + fromBlockHex?: string + toBlockHex?: string }): Promise { - await discoverAndIngestDeposits({ - controller: params.controller, - chainId: params.chainId, - categories: ['erc20'], - token: params.token, - fromBlockHex: params.fromBlockHex, - toBlockHex: params.toBlockHex, - }) + await discoverAndIngestDeposits({ + controller: params.controller, + chainId: params.chainId, + categories: ['erc20'], + token: params.token, + fromBlockHex: params.fromBlockHex, + toBlockHex: params.toBlockHex, + }) } /** @@ -273,18 +338,18 @@ async function discoverAndIngestErc20Deposits(params: { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async function discoverAndIngestEthDeposits(params: { - controller: string - chainId: number - fromBlockHex?: string - toBlockHex?: string + controller: string + chainId: number + fromBlockHex?: string + toBlockHex?: string }): Promise { - await discoverAndIngestDeposits({ - controller: params.controller, - chainId: params.chainId, - categories: ['internal', 'external'], - fromBlockHex: params.fromBlockHex, - toBlockHex: params.toBlockHex, - }) + await discoverAndIngestDeposits({ + controller: params.controller, + chainId: params.chainId, + categories: ['internal', 'external'], + fromBlockHex: params.fromBlockHex, + toBlockHex: params.toBlockHex, + }) } /** From 6569490b71f4e91626f1c6649d8f183380a3ec0c Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:48:36 -0400 Subject: [PATCH 06/24] create AssignDeposit handler in proposer --- src/proposer.ts | 200 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 150 insertions(+), 50 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 97404b1..696e772 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -48,7 +48,10 @@ import { initializeFilecoinPin, } from './utils/filecoinPin.js' import { sendWebhook } from './utils/webhook.js' -import { insertDepositIfMissing } from './utils/deposits.js' +import { + insertDepositIfMissing, + findExactUnassignedDeposit, +} from './utils/deposits.js' import type { Intention, BundleData, @@ -171,7 +174,7 @@ async function computeBlockRange( * Generic discovery for deposits into VaultTracker using Alchemy's decoded * asset transfers. Supports ERC-20 and ETH (internal/external) categories. */ - + async function discoverAndIngestDeposits(params: { controller: string chainId: number @@ -253,57 +256,71 @@ async function discoverAndIngestDeposits(params: { * - protocolFee must be empty * - agentTip must be undefined or empty */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export async function validateAssignDepositStructure(intention: Intention): Promise { - if (!Array.isArray(intention.inputs) || !Array.isArray(intention.outputs)) { - throw new Error('AssignDeposit requires inputs and outputs arrays') - } - if (intention.inputs.length !== intention.outputs.length) { - throw new Error('AssignDeposit requires 1:1 mapping between inputs and outputs') - } + +export async function validateAssignDepositStructure( + intention: Intention +): Promise { + if (!Array.isArray(intention.inputs) || !Array.isArray(intention.outputs)) { + throw new Error('AssignDeposit requires inputs and outputs arrays') + } + if (intention.inputs.length !== intention.outputs.length) { + throw new Error( + 'AssignDeposit requires 1:1 mapping between inputs and outputs' + ) + } - // Zero-fee enforcement - if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { - throw new Error('AssignDeposit requires totalFee with zero amount') - } - const allTotalZero = intention.totalFee.every((f) => f.amount === '0') - if (!allTotalZero) { - throw new Error('AssignDeposit totalFee must be zero') - } - if (Array.isArray(intention.proposerTip) && intention.proposerTip.length > 0) { - throw new Error('AssignDeposit proposerTip must be empty') - } - if (Array.isArray(intention.protocolFee) && intention.protocolFee.length > 0) { - throw new Error('AssignDeposit protocolFee must be empty') - } - if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { - throw new Error('AssignDeposit agentTip must be empty if provided') - } + // Zero-fee enforcement + if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { + throw new Error('AssignDeposit requires totalFee with zero amount') + } + const allTotalZero = intention.totalFee.every((f) => f.amount === '0') + if (!allTotalZero) { + throw new Error('AssignDeposit totalFee must be zero') + } + if ( + Array.isArray(intention.proposerTip) && + intention.proposerTip.length > 0 + ) { + throw new Error('AssignDeposit proposerTip must be empty') + } + if ( + Array.isArray(intention.protocolFee) && + intention.protocolFee.length > 0 + ) { + throw new Error('AssignDeposit protocolFee must be empty') + } + if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { + throw new Error('AssignDeposit agentTip must be empty if provided') + } - for (let i = 0; i < intention.inputs.length; i++) { - const input: IntentionInput = intention.inputs[i] - const output: IntentionOutput = intention.outputs[i] + for (let i = 0; i < intention.inputs.length; i++) { + const input: IntentionInput = intention.inputs[i] + const output: IntentionOutput = intention.outputs[i] - if (!output || (output.to === undefined && !output.to_external)) { - throw new Error('AssignDeposit requires outputs[].to (vault ID)') - } - if (output.to_external !== undefined) { - throw new Error('AssignDeposit does not support to_external') - } + if (!output || (output.to === undefined && !output.to_external)) { + throw new Error('AssignDeposit requires outputs[].to (vault ID)') + } + if (output.to_external !== undefined) { + throw new Error('AssignDeposit does not support to_external') + } - if (input.asset.toLowerCase() !== output.asset.toLowerCase()) { - throw new Error('AssignDeposit input/output asset mismatch at index ' + i) - } - if (input.amount !== output.amount) { - throw new Error('AssignDeposit input/output amount mismatch at index ' + i) - } - if (input.chain_id !== output.chain_id) { - throw new Error('AssignDeposit input/output chain_id mismatch at index ' + i) - } + if (input.asset.toLowerCase() !== output.asset.toLowerCase()) { + throw new Error('AssignDeposit input/output asset mismatch at index ' + i) + } + if (input.amount !== output.amount) { + throw new Error( + 'AssignDeposit input/output amount mismatch at index ' + i + ) + } + if (input.chain_id !== output.chain_id) { + throw new Error( + 'AssignDeposit input/output chain_id mismatch at index ' + i + ) + } - // Validate on-chain vault existence - await validateVaultIdOnChain(Number(output.to)) - } + // Validate on-chain vault existence + await validateVaultIdOnChain(Number(output.to)) + } } /** @@ -314,7 +331,6 @@ export async function validateAssignDepositStructure(intention: Intention): Prom * If fromBlock/toBlock are not provided, computes a default lookback window * of ~7 days by subtracting ~50,400 blocks from the latest block. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars async function discoverAndIngestErc20Deposits(params: { controller: string token: string @@ -336,7 +352,6 @@ async function discoverAndIngestErc20Deposits(params: { * Discovers ETH (internal) deposits made by `controller` into the VaultTracker * and ingests them into the local `deposits` table via idempotent inserts. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars async function discoverAndIngestEthDeposits(params: { controller: string chainId: number @@ -1025,6 +1040,91 @@ async function handleIntention( JSON.stringify(validatedIntention) ) + // Handle AssignDeposit intention (bypass generic balance checks) + if (validatedIntention.action === 'AssignDeposit') { + // Structural and zero-fee validation + await validateAssignDepositStructure(validatedIntention) + + const zeroAddress = '0x0000000000000000000000000000000000000000' + const proof: unknown[] = [] + + for (let i = 0; i < validatedIntention.inputs.length; i++) { + const input = validatedIntention.inputs[i] + const output = validatedIntention.outputs[i] + + // Optional discovery hints in input.data + let fromBlockHex: string | undefined + let toBlockHex: string | undefined + if (input.data) { + try { + const parsed = JSON.parse(input.data) + if (typeof parsed.fromBlock === 'string') fromBlockHex = parsed.fromBlock + if (typeof parsed.toBlock === 'string') toBlockHex = parsed.toBlock + } catch { + // ignore malformed hints + } + } + + const isEth = input.asset.toLowerCase() === zeroAddress + if (isEth) { + await discoverAndIngestEthDeposits({ + controller: validatedController, + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } else { + await discoverAndIngestErc20Deposits({ + controller: validatedController, + token: input.asset, + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } + + const match = await findExactUnassignedDeposit({ + depositor: validatedController, + token: isEth ? zeroAddress : input.asset, + amount: input.amount, + chain_id: input.chain_id, + }) + if (!match) { + throw new Error( + `No exact unassigned deposit found for asset ${input.asset} amount ${input.amount}` + ) + } + + proof.push({ + token: isEth ? zeroAddress : input.asset, + to: output.to as number, + amount: input.amount, + deposit_id: match.id, + depositor: validatedController, + }) + } + + const executionObject: ExecutionObject = { + execution: [ + { + intention: validatedIntention, + from: 0, + proof, + signature: validatedSignature, + }, + ], + } + cachedIntentions.push(executionObject) + + diagnostic.info('AssignDeposit intention processed', { + controller: validatedController, + count: validatedIntention.inputs.length, + processingTime: Date.now() - startTime, + }) + logger.info('AssignDeposit cached. Total cached intentions:', cachedIntentions.length) + return executionObject + } + // Handle CreateVault intention and trigger seeding if (validatedIntention.action === 'CreateVault') { try { From 297b6118a005272d6b4a1e00365a5d39ad10039e Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:50:54 -0400 Subject: [PATCH 07/24] add AssignDeposit logic to publishBundle --- src/proposer.ts | 200 ++++++++++++++++++++++++++---------------------- 1 file changed, 110 insertions(+), 90 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 696e772..7092e94 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -51,6 +51,7 @@ import { sendWebhook } from './utils/webhook.js' import { insertDepositIfMissing, findExactUnassignedDeposit, + markDepositAssigned, } from './utils/deposits.js' import type { Intention, @@ -256,7 +257,7 @@ async function discoverAndIngestDeposits(params: { * - protocolFee must be empty * - agentTip must be undefined or empty */ - + export async function validateAssignDepositStructure( intention: Intention ): Promise { @@ -791,15 +792,30 @@ async function publishBundle(data: string, signature: string, from: string) { logger.warn('Filecoin pinning failed (bundle still valid):', err.message) }) try { - for (const execution of bundleData.bundle) { - if (!Array.isArray(execution.proof)) { - logger.error('Invalid proof structure in execution:', execution) - throw new Error('Invalid proof structure') - } - for (const proof of execution.proof) { - await updateBalances(proof.from, proof.to, proof.token, proof.amount) - } - } + for (const execution of bundleData.bundle) { + if (!Array.isArray(execution.proof)) { + logger.error('Invalid proof structure in execution:', execution) + throw new Error('Invalid proof structure') + } + + if (execution.intention?.action === 'AssignDeposit') { + // Publish-time crediting for AssignDeposit + for (const proof of execution.proof) { + // Mark the specific deposit row as assigned (transaction inside helper) + await markDepositAssigned(proof.deposit_id, String(proof.to)) + + // Credit the destination vault balance + const current = await getBalance(proof.to, proof.token) + const increment = safeBigInt(proof.amount) + const newBalance = current + increment + await updateBalance(proof.to, proof.token, newBalance) + } + } else { + for (const proof of execution.proof) { + await updateBalances(proof.from, proof.to, proof.token, proof.amount) + } + } + } logger.info('Balances updated successfully') } catch (error) { logger.error('Failed to update balances:', error) @@ -1040,90 +1056,94 @@ async function handleIntention( JSON.stringify(validatedIntention) ) - // Handle AssignDeposit intention (bypass generic balance checks) - if (validatedIntention.action === 'AssignDeposit') { - // Structural and zero-fee validation - await validateAssignDepositStructure(validatedIntention) - - const zeroAddress = '0x0000000000000000000000000000000000000000' - const proof: unknown[] = [] - - for (let i = 0; i < validatedIntention.inputs.length; i++) { - const input = validatedIntention.inputs[i] - const output = validatedIntention.outputs[i] - - // Optional discovery hints in input.data - let fromBlockHex: string | undefined - let toBlockHex: string | undefined - if (input.data) { - try { - const parsed = JSON.parse(input.data) - if (typeof parsed.fromBlock === 'string') fromBlockHex = parsed.fromBlock - if (typeof parsed.toBlock === 'string') toBlockHex = parsed.toBlock - } catch { - // ignore malformed hints - } - } + // Handle AssignDeposit intention (bypass generic balance checks) + if (validatedIntention.action === 'AssignDeposit') { + // Structural and zero-fee validation + await validateAssignDepositStructure(validatedIntention) - const isEth = input.asset.toLowerCase() === zeroAddress - if (isEth) { - await discoverAndIngestEthDeposits({ - controller: validatedController, - chainId: input.chain_id, - fromBlockHex, - toBlockHex, - }) - } else { - await discoverAndIngestErc20Deposits({ - controller: validatedController, - token: input.asset, - chainId: input.chain_id, - fromBlockHex, - toBlockHex, - }) - } + const zeroAddress = '0x0000000000000000000000000000000000000000' + const proof: unknown[] = [] - const match = await findExactUnassignedDeposit({ - depositor: validatedController, - token: isEth ? zeroAddress : input.asset, - amount: input.amount, - chain_id: input.chain_id, - }) - if (!match) { - throw new Error( - `No exact unassigned deposit found for asset ${input.asset} amount ${input.amount}` - ) - } + for (let i = 0; i < validatedIntention.inputs.length; i++) { + const input = validatedIntention.inputs[i] + const output = validatedIntention.outputs[i] - proof.push({ - token: isEth ? zeroAddress : input.asset, - to: output.to as number, - amount: input.amount, - deposit_id: match.id, - depositor: validatedController, - }) - } + // Optional discovery hints in input.data + let fromBlockHex: string | undefined + let toBlockHex: string | undefined + if (input.data) { + try { + const parsed = JSON.parse(input.data) + if (typeof parsed.fromBlock === 'string') + fromBlockHex = parsed.fromBlock + if (typeof parsed.toBlock === 'string') toBlockHex = parsed.toBlock + } catch { + // ignore malformed hints + } + } - const executionObject: ExecutionObject = { - execution: [ - { - intention: validatedIntention, - from: 0, - proof, - signature: validatedSignature, - }, - ], - } - cachedIntentions.push(executionObject) - - diagnostic.info('AssignDeposit intention processed', { - controller: validatedController, - count: validatedIntention.inputs.length, - processingTime: Date.now() - startTime, - }) - logger.info('AssignDeposit cached. Total cached intentions:', cachedIntentions.length) - return executionObject - } + const isEth = input.asset.toLowerCase() === zeroAddress + if (isEth) { + await discoverAndIngestEthDeposits({ + controller: validatedController, + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } else { + await discoverAndIngestErc20Deposits({ + controller: validatedController, + token: input.asset, + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } + + const match = await findExactUnassignedDeposit({ + depositor: validatedController, + token: isEth ? zeroAddress : input.asset, + amount: input.amount, + chain_id: input.chain_id, + }) + if (!match) { + throw new Error( + `No exact unassigned deposit found for asset ${input.asset} amount ${input.amount}` + ) + } + + proof.push({ + token: isEth ? zeroAddress : input.asset, + to: output.to as number, + amount: input.amount, + deposit_id: match.id, + depositor: validatedController, + }) + } + + const executionObject: ExecutionObject = { + execution: [ + { + intention: validatedIntention, + from: 0, + proof, + signature: validatedSignature, + }, + ], + } + cachedIntentions.push(executionObject) + + diagnostic.info('AssignDeposit intention processed', { + controller: validatedController, + count: validatedIntention.inputs.length, + processingTime: Date.now() - startTime, + }) + logger.info( + 'AssignDeposit cached. Total cached intentions:', + cachedIntentions.length + ) + return executionObject + } // Handle CreateVault intention and trigger seeding if (validatedIntention.action === 'CreateVault') { From 5df347aa66f27c76470aaa9fc1ff6a1d3f7d4f15 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:57:16 -0400 Subject: [PATCH 08/24] create deposits db tests --- src/proposer.ts | 54 +++++++------- test/integration/deposits.db.test.ts | 106 +++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 test/integration/deposits.db.test.ts diff --git a/src/proposer.ts b/src/proposer.ts index 7092e94..1b9e542 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -49,9 +49,9 @@ import { } from './utils/filecoinPin.js' import { sendWebhook } from './utils/webhook.js' import { - insertDepositIfMissing, - findExactUnassignedDeposit, - markDepositAssigned, + insertDepositIfMissing, + findExactUnassignedDeposit, + markDepositAssigned, } from './utils/deposits.js' import type { Intention, @@ -792,30 +792,30 @@ async function publishBundle(data: string, signature: string, from: string) { logger.warn('Filecoin pinning failed (bundle still valid):', err.message) }) try { - for (const execution of bundleData.bundle) { - if (!Array.isArray(execution.proof)) { - logger.error('Invalid proof structure in execution:', execution) - throw new Error('Invalid proof structure') - } - - if (execution.intention?.action === 'AssignDeposit') { - // Publish-time crediting for AssignDeposit - for (const proof of execution.proof) { - // Mark the specific deposit row as assigned (transaction inside helper) - await markDepositAssigned(proof.deposit_id, String(proof.to)) - - // Credit the destination vault balance - const current = await getBalance(proof.to, proof.token) - const increment = safeBigInt(proof.amount) - const newBalance = current + increment - await updateBalance(proof.to, proof.token, newBalance) - } - } else { - for (const proof of execution.proof) { - await updateBalances(proof.from, proof.to, proof.token, proof.amount) - } - } - } + for (const execution of bundleData.bundle) { + if (!Array.isArray(execution.proof)) { + logger.error('Invalid proof structure in execution:', execution) + throw new Error('Invalid proof structure') + } + + if (execution.intention?.action === 'AssignDeposit') { + // Publish-time crediting for AssignDeposit + for (const proof of execution.proof) { + // Mark the specific deposit row as assigned (transaction inside helper) + await markDepositAssigned(proof.deposit_id, String(proof.to)) + + // Credit the destination vault balance + const current = await getBalance(proof.to, proof.token) + const increment = safeBigInt(proof.amount) + const newBalance = current + increment + await updateBalance(proof.to, proof.token, newBalance) + } + } else { + for (const proof of execution.proof) { + await updateBalances(proof.from, proof.to, proof.token, proof.amount) + } + } + } logger.info('Balances updated successfully') } catch (error) { logger.error('Failed to update balances:', error) diff --git a/test/integration/deposits.db.test.ts b/test/integration/deposits.db.test.ts new file mode 100644 index 0000000..20b2ba9 --- /dev/null +++ b/test/integration/deposits.db.test.ts @@ -0,0 +1,106 @@ +/** + * Integration tests for deposits utils against a real database. + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test' +import { pool } from '../../src/db.js' +import { + insertDepositIfMissing, + findExactUnassignedDeposit, + markDepositAssigned, +} from '../../src/utils/deposits.js' + +const TEST_TX = '0xtest-deposit-tx' +const TEST_UID = (n: number) => `${TEST_TX}:${n}` +const CTRL = '0xCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCc' +const TOKEN = '0x1111111111111111111111111111111111111111' +const ZERO = '0x0000000000000000000000000000000000000000' + +beforeAll(async () => { + await pool.query("DELETE FROM deposits WHERE tx_hash = $1", [TEST_TX]) +}) + +afterAll(async () => { + await pool.query("DELETE FROM deposits WHERE tx_hash = $1", [TEST_TX]) +}) + +beforeEach(async () => { + await pool.query("DELETE FROM deposits WHERE tx_hash = $1", [TEST_TX]) +}) + +describe('Deposits utils (DB)', () => { + test('insertDepositIfMissing: idempotent by transfer_uid', async () => { + const p = { + tx_hash: TEST_TX, + transfer_uid: TEST_UID(1), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '1000', + } + const r1 = await insertDepositIfMissing(p) + const r2 = await insertDepositIfMissing(p) + expect(r1.id).toBeDefined() + expect(r2.id).toBe(r1.id) + + const row = await pool.query('SELECT * FROM deposits WHERE id = $1', [r1.id]) + expect(row.rows[0].tx_hash).toBe(TEST_TX) + expect(row.rows[0].depositor).toBe(CTRL.toLowerCase()) + expect(row.rows[0].token).toBe(TOKEN.toLowerCase()) + expect(row.rows[0].amount).toBe('1000') + expect(row.rows[0].assigned_at).toBeNull() + }) + + test('findExactUnassignedDeposit then markDepositAssigned', async () => { + // two deposits, distinct amounts + await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(2), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '123', + }) + const ins = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(3), + chain_id: 11155111, + depositor: CTRL, + token: ZERO, // ETH + amount: '555', + }) + + const found1 = await findExactUnassignedDeposit({ + depositor: CTRL, + token: TOKEN, + amount: '123', + chain_id: 11155111, + }) + expect(found1?.id).toBeDefined() + + const found2 = await findExactUnassignedDeposit({ + depositor: CTRL, + token: ZERO, + amount: '555', + chain_id: 11155111, + }) + expect(found2?.id).toBe(ins.id) + + // mark assigned + await markDepositAssigned(found2!.id, '9999') + const reread = await pool.query('SELECT credited_vault, assigned_at FROM deposits WHERE id = $1', [found2!.id]) + expect(reread.rows[0].credited_vault).toBe('9999') + expect(reread.rows[0].assigned_at).not.toBeNull() + + // no longer available for selection + const notFound = await findExactUnassignedDeposit({ + depositor: CTRL, + token: ZERO, + amount: '555', + chain_id: 11155111, + }) + expect(notFound).toBeNull() + }) +}) + + From ad7e9651805b27fc5f1bf3d7b4670bbd001bb331 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Tue, 28 Oct 2025 17:57:37 -0400 Subject: [PATCH 09/24] formatting fixes --- test/integration/deposits.db.test.ts | 160 ++++++++++++++------------- 1 file changed, 85 insertions(+), 75 deletions(-) diff --git a/test/integration/deposits.db.test.ts b/test/integration/deposits.db.test.ts index 20b2ba9..5ac9693 100644 --- a/test/integration/deposits.db.test.ts +++ b/test/integration/deposits.db.test.ts @@ -2,12 +2,19 @@ * Integration tests for deposits utils against a real database. */ -import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test' +import { + describe, + test, + expect, + beforeAll, + afterAll, + beforeEach, +} from 'bun:test' import { pool } from '../../src/db.js' import { - insertDepositIfMissing, - findExactUnassignedDeposit, - markDepositAssigned, + insertDepositIfMissing, + findExactUnassignedDeposit, + markDepositAssigned, } from '../../src/utils/deposits.js' const TEST_TX = '0xtest-deposit-tx' @@ -17,90 +24,93 @@ const TOKEN = '0x1111111111111111111111111111111111111111' const ZERO = '0x0000000000000000000000000000000000000000' beforeAll(async () => { - await pool.query("DELETE FROM deposits WHERE tx_hash = $1", [TEST_TX]) + await pool.query('DELETE FROM deposits WHERE tx_hash = $1', [TEST_TX]) }) afterAll(async () => { - await pool.query("DELETE FROM deposits WHERE tx_hash = $1", [TEST_TX]) + await pool.query('DELETE FROM deposits WHERE tx_hash = $1', [TEST_TX]) }) beforeEach(async () => { - await pool.query("DELETE FROM deposits WHERE tx_hash = $1", [TEST_TX]) + await pool.query('DELETE FROM deposits WHERE tx_hash = $1', [TEST_TX]) }) describe('Deposits utils (DB)', () => { - test('insertDepositIfMissing: idempotent by transfer_uid', async () => { - const p = { - tx_hash: TEST_TX, - transfer_uid: TEST_UID(1), - chain_id: 11155111, - depositor: CTRL, - token: TOKEN, - amount: '1000', - } - const r1 = await insertDepositIfMissing(p) - const r2 = await insertDepositIfMissing(p) - expect(r1.id).toBeDefined() - expect(r2.id).toBe(r1.id) + test('insertDepositIfMissing: idempotent by transfer_uid', async () => { + const p = { + tx_hash: TEST_TX, + transfer_uid: TEST_UID(1), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '1000', + } + const r1 = await insertDepositIfMissing(p) + const r2 = await insertDepositIfMissing(p) + expect(r1.id).toBeDefined() + expect(r2.id).toBe(r1.id) - const row = await pool.query('SELECT * FROM deposits WHERE id = $1', [r1.id]) - expect(row.rows[0].tx_hash).toBe(TEST_TX) - expect(row.rows[0].depositor).toBe(CTRL.toLowerCase()) - expect(row.rows[0].token).toBe(TOKEN.toLowerCase()) - expect(row.rows[0].amount).toBe('1000') - expect(row.rows[0].assigned_at).toBeNull() - }) + const row = await pool.query('SELECT * FROM deposits WHERE id = $1', [ + r1.id, + ]) + expect(row.rows[0].tx_hash).toBe(TEST_TX) + expect(row.rows[0].depositor).toBe(CTRL.toLowerCase()) + expect(row.rows[0].token).toBe(TOKEN.toLowerCase()) + expect(row.rows[0].amount).toBe('1000') + expect(row.rows[0].assigned_at).toBeNull() + }) - test('findExactUnassignedDeposit then markDepositAssigned', async () => { - // two deposits, distinct amounts - await insertDepositIfMissing({ - tx_hash: TEST_TX, - transfer_uid: TEST_UID(2), - chain_id: 11155111, - depositor: CTRL, - token: TOKEN, - amount: '123', - }) - const ins = await insertDepositIfMissing({ - tx_hash: TEST_TX, - transfer_uid: TEST_UID(3), - chain_id: 11155111, - depositor: CTRL, - token: ZERO, // ETH - amount: '555', - }) + test('findExactUnassignedDeposit then markDepositAssigned', async () => { + // two deposits, distinct amounts + await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(2), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '123', + }) + const ins = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(3), + chain_id: 11155111, + depositor: CTRL, + token: ZERO, // ETH + amount: '555', + }) - const found1 = await findExactUnassignedDeposit({ - depositor: CTRL, - token: TOKEN, - amount: '123', - chain_id: 11155111, - }) - expect(found1?.id).toBeDefined() + const found1 = await findExactUnassignedDeposit({ + depositor: CTRL, + token: TOKEN, + amount: '123', + chain_id: 11155111, + }) + expect(found1?.id).toBeDefined() - const found2 = await findExactUnassignedDeposit({ - depositor: CTRL, - token: ZERO, - amount: '555', - chain_id: 11155111, - }) - expect(found2?.id).toBe(ins.id) + const found2 = await findExactUnassignedDeposit({ + depositor: CTRL, + token: ZERO, + amount: '555', + chain_id: 11155111, + }) + expect(found2?.id).toBe(ins.id) - // mark assigned - await markDepositAssigned(found2!.id, '9999') - const reread = await pool.query('SELECT credited_vault, assigned_at FROM deposits WHERE id = $1', [found2!.id]) - expect(reread.rows[0].credited_vault).toBe('9999') - expect(reread.rows[0].assigned_at).not.toBeNull() + // mark assigned + await markDepositAssigned(found2!.id, '9999') + const reread = await pool.query( + 'SELECT credited_vault, assigned_at FROM deposits WHERE id = $1', + [found2!.id] + ) + expect(reread.rows[0].credited_vault).toBe('9999') + expect(reread.rows[0].assigned_at).not.toBeNull() - // no longer available for selection - const notFound = await findExactUnassignedDeposit({ - depositor: CTRL, - token: ZERO, - amount: '555', - chain_id: 11155111, - }) - expect(notFound).toBeNull() - }) + // no longer available for selection + const notFound = await findExactUnassignedDeposit({ + depositor: CTRL, + token: ZERO, + amount: '555', + chain_id: 11155111, + }) + expect(notFound).toBeNull() + }) }) - - From b5a094dc6ca81814d608e7e1939f185fa6297ca8 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 14:43:15 -0400 Subject: [PATCH 10/24] extract intention-specific logic into util files --- src/proposer.ts | 157 +++---------------- src/utils/intentionHandlers/AssignDeposit.ts | 124 +++++++++++++++ src/utils/intentionHandlers/CreateVault.ts | 64 ++++++++ 3 files changed, 214 insertions(+), 131 deletions(-) create mode 100644 src/utils/intentionHandlers/AssignDeposit.ts create mode 100644 src/utils/intentionHandlers/CreateVault.ts diff --git a/src/proposer.ts b/src/proposer.ts index 1b9e542..05ff25e 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -53,6 +53,8 @@ import { findExactUnassignedDeposit, markDepositAssigned, } from './utils/deposits.js' +import { handleAssignDeposit } from './utils/intentionHandlers/AssignDeposit.js' +import { handleCreateVault } from './utils/intentionHandlers/CreateVault.js' import type { Intention, BundleData, @@ -1058,143 +1060,36 @@ async function handleIntention( // Handle AssignDeposit intention (bypass generic balance checks) if (validatedIntention.action === 'AssignDeposit') { - // Structural and zero-fee validation - await validateAssignDepositStructure(validatedIntention) - - const zeroAddress = '0x0000000000000000000000000000000000000000' - const proof: unknown[] = [] - - for (let i = 0; i < validatedIntention.inputs.length; i++) { - const input = validatedIntention.inputs[i] - const output = validatedIntention.outputs[i] - - // Optional discovery hints in input.data - let fromBlockHex: string | undefined - let toBlockHex: string | undefined - if (input.data) { - try { - const parsed = JSON.parse(input.data) - if (typeof parsed.fromBlock === 'string') - fromBlockHex = parsed.fromBlock - if (typeof parsed.toBlock === 'string') toBlockHex = parsed.toBlock - } catch { - // ignore malformed hints - } - } - - const isEth = input.asset.toLowerCase() === zeroAddress - if (isEth) { - await discoverAndIngestEthDeposits({ - controller: validatedController, - chainId: input.chain_id, - fromBlockHex, - toBlockHex, - }) - } else { - await discoverAndIngestErc20Deposits({ - controller: validatedController, - token: input.asset, - chainId: input.chain_id, - fromBlockHex, - toBlockHex, - }) - } - - const match = await findExactUnassignedDeposit({ - depositor: validatedController, - token: isEth ? zeroAddress : input.asset, - amount: input.amount, - chain_id: input.chain_id, - }) - if (!match) { - throw new Error( - `No exact unassigned deposit found for asset ${input.asset} amount ${input.amount}` - ) - } - - proof.push({ - token: isEth ? zeroAddress : input.asset, - to: output.to as number, - amount: input.amount, - deposit_id: match.id, - depositor: validatedController, - }) - } - - const executionObject: ExecutionObject = { - execution: [ - { - intention: validatedIntention, - from: 0, - proof, - signature: validatedSignature, - }, - ], - } - cachedIntentions.push(executionObject) - - diagnostic.info('AssignDeposit intention processed', { - controller: validatedController, - count: validatedIntention.inputs.length, - processingTime: Date.now() - startTime, + const executionObject = await handleAssignDeposit({ + intention: validatedIntention, + validatedController, + validatedSignature, + context: { + validateAssignDepositStructure, + discoverAndIngestErc20Deposits, + discoverAndIngestEthDeposits, + findExactUnassignedDeposit, + validateVaultIdOnChain, + logger, + diagnostic, + }, }) - logger.info( - 'AssignDeposit cached. Total cached intentions:', - cachedIntentions.length - ) + cachedIntentions.push(executionObject) return executionObject } // Handle CreateVault intention and trigger seeding if (validatedIntention.action === 'CreateVault') { - try { - // This is the entry point for creating and seeding a new vault. - logger.info('Processing CreateVault intention...') - - // 1. Call the on-chain contract to create the vault. - // The `validatedController` address becomes the controller of the new vault. - const tx = await vaultTrackerContract.createVault(validatedController) - const receipt = await tx.wait() // Wait for the transaction to be mined - - if (!receipt) { - throw new Error('Transaction receipt is null, mining may have failed.') - } - - // 2. Find and parse the VaultCreated event to get the new vault ID. - let newVaultId: number | null = null - for (const log of receipt.logs) { - try { - const parsedLog = vaultTrackerContract.interface.parseLog(log) - if (parsedLog && parsedLog.name === 'VaultCreated') { - // The first argument of the event is the vaultId - newVaultId = Number(parsedLog.args[0]) - break - } - } catch { - // Ignore logs that are not from the VaultTracker ABI - } - } - - if (newVaultId === null) { - throw new Error( - 'Could not find VaultCreated event in transaction logs.' - ) - } - - logger.info(`On-chain vault created with ID: ${newVaultId}`) - - // 3. Persist the new vault-to-controller mapping to the database. - // This is the canonical source of truth for vault ownership. - await upsertVaultControllers(newVaultId, [validatedController]) - - // 4. After the vault is created and its controller is mapped, - // submit an intention to seed it with initial balances. - await createAndSubmitSeedingIntention(newVaultId) - } catch (error) { - logger.error('Failed to process CreateVault intention:', error) - // Re-throw or handle the error as appropriate for your application - throw error - } + await handleCreateVault({ + intention: validatedIntention, + validatedController, + deps: { + vaultTrackerContract, + upsertVaultControllers, + createAndSubmitSeedingIntention, + logger, + }, + }) } // Check for expiry diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts new file mode 100644 index 0000000..703b607 --- /dev/null +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -0,0 +1,124 @@ +/** + * AssignDeposit intention handler + */ + +import type { + Intention, + ExecutionObject, + IntentionInput, + IntentionOutput, +} from '../../types/core.js' + +type AssignDepositContext = { + validateAssignDepositStructure: (intention: Intention) => Promise + discoverAndIngestErc20Deposits: (args: { + controller: string + token: string + chainId: number + fromBlockHex?: string + toBlockHex?: string + }) => Promise + discoverAndIngestEthDeposits: (args: { + controller: string + chainId: number + fromBlockHex?: string + toBlockHex?: string + }) => Promise + findExactUnassignedDeposit: (args: { + depositor: string + token: string + amount: string + chain_id: number + }) => Promise<{ id: number } | null> + validateVaultIdOnChain: (vaultId: number) => Promise + logger: { info: (...args: unknown[]) => void } + diagnostic: { info: (...args: unknown[]) => void } +} + +export async function handleAssignDeposit(params: { + intention: Intention + validatedController: string + validatedSignature: string + context: AssignDepositContext +}): Promise { + const { intention, validatedController, validatedSignature, context } = params + + await context.validateAssignDepositStructure(intention) + + const zeroAddress = '0x0000000000000000000000000000000000000000' + const proof: unknown[] = [] + + for (let i = 0; i < intention.inputs.length; i++) { + const input: IntentionInput = intention.inputs[i] + const output: IntentionOutput = intention.outputs[i] + + // Optional discovery hints in input.data + let fromBlockHex: string | undefined + let toBlockHex: string | undefined + if (input.data) { + try { + const parsed = JSON.parse(input.data) + if (typeof parsed.fromBlock === 'string') + fromBlockHex = parsed.fromBlock + if (typeof parsed.toBlock === 'string') toBlockHex = parsed.toBlock + } catch { + // ignore malformed hints + } + } + + const isEth = input.asset.toLowerCase() === zeroAddress + if (isEth) { + await context.discoverAndIngestEthDeposits({ + controller: validatedController, + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } else { + await context.discoverAndIngestErc20Deposits({ + controller: validatedController, + token: input.asset, + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } + + const match = await context.findExactUnassignedDeposit({ + depositor: validatedController, + token: isEth ? zeroAddress : input.asset, + amount: input.amount, + chain_id: input.chain_id, + }) + if (!match) { + throw new Error( + `No exact unassigned deposit found for asset ${input.asset} amount ${input.amount}` + ) + } + + proof.push({ + token: isEth ? zeroAddress : input.asset, + to: output.to as number, + amount: input.amount, + deposit_id: match.id, + depositor: validatedController, + }) + } + + context.diagnostic.info('AssignDeposit intention processed', { + controller: validatedController, + count: intention.inputs.length, + }) + context.logger.info('AssignDeposit cached with proof count:', proof.length) + + return { + execution: [ + { + intention, + from: 0, + proof, + signature: validatedSignature, + }, + ], + } +} diff --git a/src/utils/intentionHandlers/CreateVault.ts b/src/utils/intentionHandlers/CreateVault.ts new file mode 100644 index 0000000..6e301b9 --- /dev/null +++ b/src/utils/intentionHandlers/CreateVault.ts @@ -0,0 +1,64 @@ +/** + * CreateVault intention handler + */ + +import type { Intention } from '../../types/core.js' +import type { VaultTrackerContract } from '../../proposer.js' + +export async function handleCreateVault(params: { + intention: Intention + validatedController: string + deps: { + vaultTrackerContract: VaultTrackerContract + upsertVaultControllers: ( + vaultId: number, + controllers: string[] + ) => Promise + createAndSubmitSeedingIntention: (newVaultId: number) => Promise + logger: { + info: (...args: unknown[]) => void + error: (...args: unknown[]) => void + } + } +}): Promise { + const { validatedController, deps } = params + + try { + deps.logger.info('Processing CreateVault intention...') + + // 1. Call the on-chain contract to create the vault. + const tx = await deps.vaultTrackerContract.createVault(validatedController) + const receipt = await tx.wait() + if (!receipt) { + throw new Error('Transaction receipt is null, mining may have failed.') + } + + // 2. Parse the VaultCreated event to get the new vault ID. + let newVaultId: number | null = null + for (const log of receipt.logs) { + try { + const parsedLog = deps.vaultTrackerContract.interface.parseLog(log) + if (parsedLog && parsedLog.name === 'VaultCreated') { + newVaultId = Number(parsedLog.args[0]) + break + } + } catch { + // Ignore logs that are not from the VaultTracker ABI + } + } + if (newVaultId === null) { + throw new Error('Could not find VaultCreated event in transaction logs.') + } + + deps.logger.info(`On-chain vault created with ID: ${newVaultId}`) + + // 3. Persist the new vault-to-controller mapping to the database. + await deps.upsertVaultControllers(newVaultId, [validatedController]) + + // 4. Submit an intention to seed it with initial balances. + await deps.createAndSubmitSeedingIntention(newVaultId) + } catch (error) { + deps.logger.error('Failed to process CreateVault intention:', error) + throw error + } +} From 1cf8c73a6438eb3eb5cc826bbc92d1bb8dc742e7 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 14:53:03 -0400 Subject: [PATCH 11/24] lint fixes --- scripts/shared/db-setup.js | 2 +- src/proposer.ts | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/shared/db-setup.js b/scripts/shared/db-setup.js index 3c45501..dd08327 100644 --- a/scripts/shared/db-setup.js +++ b/scripts/shared/db-setup.js @@ -165,7 +165,7 @@ export async function setupDatabase(options) { if (!forceDropConfirm) { console.log(chalk.red('\n⚠️ WARNING: This will DELETE ALL DATA in the following tables:')) - console.log(chalk.red(' - bundles, cids, balances, nonces, proposers, vaults')) + console.log(chalk.red(' - bundles, cids, balances, nonces, proposers')) console.log(chalk.yellow('\nTo confirm, set FORCE_DROP=true or remove --drop-existing flag')) await pool.end() process.exit(1) diff --git a/src/proposer.ts b/src/proposer.ts index 1cbdfd2..05ff25e 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -34,7 +34,7 @@ import { resolveIntentionENS } from './utils/ensResolver.js' import { getControllersForVault, getVaultsForController, - createVaultRow, + upsertVaultControllers, } from './utils/vaults.js' import { PROPOSER_VAULT_ID, SEED_CONFIG } from './config/seedingConfig.js' import { @@ -1336,7 +1336,8 @@ export async function initializeProposer() { logger.info('Initializing proposer module...') - // Note: Vault-controller mapping is created from on-chain VaultCreated events + // Seed the proposer's own vault-to-controller mapping + await seedProposerVaultMapping() // Initialize wallet and contract await initializeWalletAndContract() @@ -1370,7 +1371,19 @@ export function getSepoliaAlchemy() { * Ensures the proposer's vault-to-controller mapping is seeded in the database. * This is crucial for allowing the proposer to sign and submit seeding intentions. */ -// Removed seedProposerVaultMapping(); creation is handled via on-chain events +async function seedProposerVaultMapping() { + try { + await upsertVaultControllers(PROPOSER_VAULT_ID.value, [PROPOSER_ADDRESS]) + logger.info( + `Proposer vault mapping seeded: Vault ${PROPOSER_VAULT_ID.value} -> Controller ${PROPOSER_ADDRESS}` + ) + } catch (error) { + logger.error('Failed to seed proposer vault mapping:', error) + // We throw here because if the proposer can't control its own vault, + // it won't be able to perform critical functions like seeding new vaults. + throw new Error('Could not seed proposer vault mapping') + } +} /** * Exports IPFS node for health checks From 718f8df7539d07544830b03264751ba90e1c8aee Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:00:04 -0400 Subject: [PATCH 12/24] function name change --- src/proposer.ts | 6 +++--- src/utils/intentionHandlers/CreateVault.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 05ff25e..2a851bf 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -34,7 +34,7 @@ import { resolveIntentionENS } from './utils/ensResolver.js' import { getControllersForVault, getVaultsForController, - upsertVaultControllers, + updateVaultControllers, } from './utils/vaults.js' import { PROPOSER_VAULT_ID, SEED_CONFIG } from './config/seedingConfig.js' import { @@ -1085,7 +1085,7 @@ async function handleIntention( validatedController, deps: { vaultTrackerContract, - upsertVaultControllers, + updateVaultControllers, createAndSubmitSeedingIntention, logger, }, @@ -1373,7 +1373,7 @@ export function getSepoliaAlchemy() { */ async function seedProposerVaultMapping() { try { - await upsertVaultControllers(PROPOSER_VAULT_ID.value, [PROPOSER_ADDRESS]) + await updateVaultControllers(PROPOSER_VAULT_ID.value, [PROPOSER_ADDRESS]) logger.info( `Proposer vault mapping seeded: Vault ${PROPOSER_VAULT_ID.value} -> Controller ${PROPOSER_ADDRESS}` ) diff --git a/src/utils/intentionHandlers/CreateVault.ts b/src/utils/intentionHandlers/CreateVault.ts index 6e301b9..4da2017 100644 --- a/src/utils/intentionHandlers/CreateVault.ts +++ b/src/utils/intentionHandlers/CreateVault.ts @@ -10,7 +10,7 @@ export async function handleCreateVault(params: { validatedController: string deps: { vaultTrackerContract: VaultTrackerContract - upsertVaultControllers: ( + updateVaultControllers: ( vaultId: number, controllers: string[] ) => Promise @@ -53,7 +53,7 @@ export async function handleCreateVault(params: { deps.logger.info(`On-chain vault created with ID: ${newVaultId}`) // 3. Persist the new vault-to-controller mapping to the database. - await deps.upsertVaultControllers(newVaultId, [validatedController]) + await deps.updateVaultControllers(newVaultId, [validatedController]) // 4. Submit an intention to seed it with initial balances. await deps.createAndSubmitSeedingIntention(newVaultId) From 7ead857be350f96adb5cd812e20eb4a64a0f5c0f Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:15:59 -0400 Subject: [PATCH 13/24] add new deposit assignment events table --- scripts/shared/db-setup.js | 16 +++++++++++++++- src/config/dbSettings.ts | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/shared/db-setup.js b/scripts/shared/db-setup.js index dd08327..1a00208 100644 --- a/scripts/shared/db-setup.js +++ b/scripts/shared/db-setup.js @@ -87,6 +87,15 @@ CREATE TABLE IF NOT EXISTS deposits ( credited_vault TEXT, assigned_at TIMESTAMPTZ ); + +-- Create the deposit_assignment_events table (supports partial assignments) +CREATE TABLE IF NOT EXISTS deposit_assignment_events ( + id SERIAL PRIMARY KEY, + deposit_id INTEGER NOT NULL REFERENCES deposits(id) ON DELETE CASCADE, + amount NUMERIC(78, 0) NOT NULL, + credited_vault TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); ` /** @@ -109,12 +118,17 @@ CREATE INDEX IF NOT EXISTS idx_deposits_tx_hash ON deposits(tx_hash); CREATE INDEX IF NOT EXISTS idx_deposits_depositor ON deposits(depositor, chain_id); CREATE INDEX IF NOT EXISTS idx_deposits_token ON deposits(token); CREATE INDEX IF NOT EXISTS idx_deposits_vault ON deposits(credited_vault); + +-- Create indexes for deposit_assignment_events table +CREATE INDEX IF NOT EXISTS idx_assignment_deposit ON deposit_assignment_events(deposit_id); +CREATE INDEX IF NOT EXISTS idx_assignment_credited_vault ON deposit_assignment_events(credited_vault); ` /** * SQL statements for dropping tables */ export const dropTablesSql = ` +DROP TABLE IF EXISTS deposit_assignment_events CASCADE; DROP TABLE IF EXISTS deposits CASCADE; DROP TABLE IF EXISTS bundles CASCADE; DROP TABLE IF EXISTS cids CASCADE; @@ -191,7 +205,7 @@ export async function setupDatabase(options) { SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' - AND table_name IN ('bundles', 'cids', 'balances', 'nonces', 'proposers', 'vaults', 'deposits') + AND table_name IN ('bundles', 'cids', 'balances', 'nonces', 'proposers', 'vaults', 'deposits', 'deposit_assignment_events') ORDER BY table_name `) diff --git a/src/config/dbSettings.ts b/src/config/dbSettings.ts index 66cc66b..a8b7826 100644 --- a/src/config/dbSettings.ts +++ b/src/config/dbSettings.ts @@ -40,4 +40,5 @@ export const REQUIRED_TABLES = [ 'proposers', 'vaults', 'deposits', + 'deposit_assignment_events', ] as const From 94a7e3ac2e16191507bbd95fcf6a3b4519081442 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:21:55 -0400 Subject: [PATCH 14/24] add partial assignment functions to deposit utility --- src/utils/deposits.ts | 147 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/src/utils/deposits.ts b/src/utils/deposits.ts index 3c893fc..9005170 100644 --- a/src/utils/deposits.ts +++ b/src/utils/deposits.ts @@ -133,3 +133,150 @@ export async function markDepositAssigned( client.release() } } + +/** + * Returns remaining (unassigned) amount for a deposit as a decimal string (wei). + */ +export async function getDepositRemaining(deposit_id: number): Promise { + const result = await pool.query( + `SELECT d.amount::numeric(78,0) AS total, + COALESCE(SUM(e.amount)::numeric(78,0), 0) AS assigned + FROM deposits d + LEFT JOIN deposit_assignment_events e ON e.deposit_id = d.id + WHERE d.id = $1 + GROUP BY d.id`, + [deposit_id] + ) + if (result.rows.length === 0) { + throw new Error('Deposit not found') + } + const total = BigInt((result.rows[0].total as string) ?? '0') + const assigned = BigInt((result.rows[0].assigned as string) ?? '0') + const remaining = total - assigned + if (remaining < 0n) { + // Should never happen; indicates historical inconsistency + return '0' + } + return remaining.toString() +} + +export interface FindDepositWithRemainingParams { + depositor: string + token: string + chain_id: number + minAmount: string // wei +} + +/** + * Finds the oldest deposit for a depositor/token/chain where remaining \>= minAmount. + */ +export async function findDepositWithSufficientRemaining( + params: FindDepositWithRemainingParams +): Promise<{ id: number; remaining: string } | null> { + const depositor = params.depositor.toLowerCase() + const token = params.token.toLowerCase() + const chainId = params.chain_id + const minAmount = BigInt(params.minAmount) + + const result = await pool.query( + `SELECT d.id, + d.amount::numeric(78,0) AS total, + COALESCE(SUM(e.amount)::numeric(78,0), 0) AS assigned + FROM deposits d + LEFT JOIN deposit_assignment_events e ON e.deposit_id = d.id + WHERE d.depositor = $1 + AND LOWER(d.token) = LOWER($2) + AND d.chain_id = $3 + GROUP BY d.id + HAVING (d.amount::numeric(78,0) - COALESCE(SUM(e.amount)::numeric(78,0), 0)) >= 0 + ORDER BY d.id ASC`, + [depositor, token, chainId] + ) + + for (const row of result.rows) { + const total = BigInt((row.total as string) ?? '0') + const assigned = BigInt((row.assigned as string) ?? '0') + const remaining = total - assigned + if (remaining >= minAmount) { + return { id: row.id as number, remaining: remaining.toString() } + } + } + return null +} + +/** + * Creates a partial/full assignment event for a deposit within a transaction. + * Ensures we do not over-assign by locking the deposit row and recomputing remaining. + * Returns the new assignment id and whether the deposit became fully assigned. + */ +export async function createAssignmentEventTransactional( + deposit_id: number, + amount: string, + credited_vault: string +): Promise<{ assignmentId: number; fullyAssigned: boolean }> { + const client = await pool.connect() + try { + await client.query('BEGIN') + + // Lock the deposit row + const depRes = await client.query( + `SELECT amount::numeric(78,0) AS total FROM deposits WHERE id = $1 FOR UPDATE`, + [deposit_id] + ) + if (depRes.rows.length === 0) { + await client.query('ROLLBACK') + throw new Error('Deposit not found') + } + const total = BigInt((depRes.rows[0].total as string) ?? '0') + + const assignedRes = await client.query( + `SELECT COALESCE(SUM(amount)::numeric(78,0), 0) AS assigned + FROM deposit_assignment_events WHERE deposit_id = $1`, + [deposit_id] + ) + const assigned = BigInt((assignedRes.rows[0].assigned as string) ?? '0') + + const req = BigInt(amount) + const remaining = total - assigned + if (req <= 0n) { + await client.query('ROLLBACK') + throw new Error('Assignment amount must be positive') + } + if (req > remaining) { + await client.query('ROLLBACK') + throw new Error('Not enough remaining to assign from deposit') + } + + const ins = await client.query( + `INSERT INTO deposit_assignment_events (deposit_id, amount, credited_vault) + VALUES ($1, $2, $3) + RETURNING id`, + [deposit_id, amount, String(credited_vault)] + ) + const assignmentId = ins.rows[0].id as number + + const newAssigned = assigned + req + const fullyAssigned = newAssigned === total + if (fullyAssigned) { + await client.query( + `UPDATE deposits SET assigned_at = CURRENT_TIMESTAMP WHERE id = $1`, + [deposit_id] + ) + } + + await client.query('COMMIT') + return { assignmentId, fullyAssigned } + } catch (error) { + try { + await client.query('ROLLBACK') + } catch (rollbackError) { + logger.warn( + 'Rollback failed during createAssignmentEventTransactional', + rollbackError + ) + } + throw error + } finally { + client.release() + } +} From 733b208404553d1f2d276c77c3089740b61ae32d Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:24:08 -0400 Subject: [PATCH 15/24] Update AssignDeposit handler to support partial assignment --- src/proposer.ts | 4 +-- src/utils/intentionHandlers/AssignDeposit.ts | 26 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 2a851bf..69896a8 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -50,7 +50,7 @@ import { import { sendWebhook } from './utils/webhook.js' import { insertDepositIfMissing, - findExactUnassignedDeposit, + findDepositWithSufficientRemaining, markDepositAssigned, } from './utils/deposits.js' import { handleAssignDeposit } from './utils/intentionHandlers/AssignDeposit.js' @@ -1068,7 +1068,7 @@ async function handleIntention( validateAssignDepositStructure, discoverAndIngestErc20Deposits, discoverAndIngestEthDeposits, - findExactUnassignedDeposit, + findDepositWithSufficientRemaining, validateVaultIdOnChain, logger, diagnostic, diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index 703b607..a277450 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -24,12 +24,12 @@ type AssignDepositContext = { fromBlockHex?: string toBlockHex?: string }) => Promise - findExactUnassignedDeposit: (args: { - depositor: string - token: string - amount: string - chain_id: number - }) => Promise<{ id: number } | null> + findDepositWithSufficientRemaining: (args: { + depositor: string + token: string + chain_id: number + minAmount: string + }) => Promise<{ id: number; remaining: string } | null> validateVaultIdOnChain: (vaultId: number) => Promise logger: { info: (...args: unknown[]) => void } diagnostic: { info: (...args: unknown[]) => void } @@ -84,15 +84,15 @@ export async function handleAssignDeposit(params: { }) } - const match = await context.findExactUnassignedDeposit({ - depositor: validatedController, - token: isEth ? zeroAddress : input.asset, - amount: input.amount, - chain_id: input.chain_id, - }) + const match = await context.findDepositWithSufficientRemaining({ + depositor: validatedController, + token: isEth ? zeroAddress : input.asset, + chain_id: input.chain_id, + minAmount: input.amount, + }) if (!match) { throw new Error( - `No exact unassigned deposit found for asset ${input.asset} amount ${input.amount}` + `No deposit with sufficient remaining found for asset ${input.asset} amount ${input.amount}` ) } From fe1301322f14576092e8eb1d516004f94171d072 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:30:56 -0400 Subject: [PATCH 16/24] update proposer to createAssignmentEvent --- src/proposer.ts | 14 +++++++---- src/utils/intentionHandlers/AssignDeposit.ts | 26 ++++++++++---------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 69896a8..1bde05e 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -50,8 +50,8 @@ import { import { sendWebhook } from './utils/webhook.js' import { insertDepositIfMissing, - findDepositWithSufficientRemaining, - markDepositAssigned, + findDepositWithSufficientRemaining, + createAssignmentEventTransactional, } from './utils/deposits.js' import { handleAssignDeposit } from './utils/intentionHandlers/AssignDeposit.js' import { handleCreateVault } from './utils/intentionHandlers/CreateVault.js' @@ -803,8 +803,12 @@ async function publishBundle(data: string, signature: string, from: string) { if (execution.intention?.action === 'AssignDeposit') { // Publish-time crediting for AssignDeposit for (const proof of execution.proof) { - // Mark the specific deposit row as assigned (transaction inside helper) - await markDepositAssigned(proof.deposit_id, String(proof.to)) + // Create a transactional assignment event (partial or full) + await createAssignmentEventTransactional( + proof.deposit_id, + proof.amount, + String(proof.to) + ) // Credit the destination vault balance const current = await getBalance(proof.to, proof.token) @@ -1068,7 +1072,7 @@ async function handleIntention( validateAssignDepositStructure, discoverAndIngestErc20Deposits, discoverAndIngestEthDeposits, - findDepositWithSufficientRemaining, + findDepositWithSufficientRemaining, validateVaultIdOnChain, logger, diagnostic, diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index a277450..389c8cc 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -24,12 +24,12 @@ type AssignDepositContext = { fromBlockHex?: string toBlockHex?: string }) => Promise - findDepositWithSufficientRemaining: (args: { - depositor: string - token: string - chain_id: number - minAmount: string - }) => Promise<{ id: number; remaining: string } | null> + findDepositWithSufficientRemaining: (args: { + depositor: string + token: string + chain_id: number + minAmount: string + }) => Promise<{ id: number; remaining: string } | null> validateVaultIdOnChain: (vaultId: number) => Promise logger: { info: (...args: unknown[]) => void } diagnostic: { info: (...args: unknown[]) => void } @@ -84,15 +84,15 @@ export async function handleAssignDeposit(params: { }) } - const match = await context.findDepositWithSufficientRemaining({ - depositor: validatedController, - token: isEth ? zeroAddress : input.asset, - chain_id: input.chain_id, - minAmount: input.amount, - }) + const match = await context.findDepositWithSufficientRemaining({ + depositor: validatedController, + token: isEth ? zeroAddress : input.asset, + chain_id: input.chain_id, + minAmount: input.amount, + }) if (!match) { throw new Error( - `No deposit with sufficient remaining found for asset ${input.asset} amount ${input.amount}` + `No deposit with sufficient remaining found for asset ${input.asset} amount ${input.amount}` ) } From 3274c18ccc13e876c2a9ab993e2c5c35f775749d Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:34:16 -0400 Subject: [PATCH 17/24] update deposits db test --- src/proposer.ts | 2 +- test/integration/deposits.db.test.ts | 71 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/proposer.ts b/src/proposer.ts index 1bde05e..ca70d6f 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -51,7 +51,7 @@ import { sendWebhook } from './utils/webhook.js' import { insertDepositIfMissing, findDepositWithSufficientRemaining, - createAssignmentEventTransactional, + createAssignmentEventTransactional, } from './utils/deposits.js' import { handleAssignDeposit } from './utils/intentionHandlers/AssignDeposit.js' import { handleCreateVault } from './utils/intentionHandlers/CreateVault.js' diff --git a/test/integration/deposits.db.test.ts b/test/integration/deposits.db.test.ts index 5ac9693..f532adf 100644 --- a/test/integration/deposits.db.test.ts +++ b/test/integration/deposits.db.test.ts @@ -15,6 +15,9 @@ import { insertDepositIfMissing, findExactUnassignedDeposit, markDepositAssigned, + getDepositRemaining, + findDepositWithSufficientRemaining, + createAssignmentEventTransactional, } from '../../src/utils/deposits.js' const TEST_TX = '0xtest-deposit-tx' @@ -114,3 +117,71 @@ describe('Deposits utils (DB)', () => { expect(notFound).toBeNull() }) }) + +describe('Partial assignment events (DB)', () => { + test('remaining decreases with partial assignments and assigned_at set when fully assigned', async () => { + const ins = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(100), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '1000', + }) + + const before = await getDepositRemaining(ins.id) + expect(before).toBe('1000') + + // Assign 400 + await createAssignmentEventTransactional(ins.id, '400', '8001') + const after400 = await getDepositRemaining(ins.id) + expect(after400).toBe('600') + + // Not fully assigned yet + const row1 = await pool.query('SELECT assigned_at FROM deposits WHERE id = $1', [ins.id]) + expect(row1.rows[0].assigned_at).toBeNull() + + // Assign remaining 600 + await createAssignmentEventTransactional(ins.id, '600', '8002') + const afterFull = await getDepositRemaining(ins.id) + expect(afterFull).toBe('0') + + const row2 = await pool.query('SELECT assigned_at FROM deposits WHERE id = $1', [ins.id]) + expect(row2.rows[0].assigned_at).not.toBeNull() + }) + + test('selection uses sufficient remaining and fails on over-assignment', async () => { + const ins = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(101), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '700', + }) + + // Oldest deposit with remaining >= 500 should be this one initially + const pick1 = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + minAmount: '500', + }) + expect(pick1?.id).toBe(ins.id) + + // Assign 650 (leave 50) + await createAssignmentEventTransactional(ins.id, '650', '9001') + const remaining = await getDepositRemaining(ins.id) + expect(remaining).toBe('50') + + // Cannot assign 100 now + await expect( + createAssignmentEventTransactional(ins.id, '100', '9002') + ).rejects.toThrow() + + // But can assign 50 + await createAssignmentEventTransactional(ins.id, '50', '9003') + const remaining2 = await getDepositRemaining(ins.id) + expect(remaining2).toBe('0') + }) +}) From a7f34dc4ec5b7ad0d9e91c737c556d8adab212d5 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:44:33 -0400 Subject: [PATCH 18/24] remove unused deposits functions, update test, update README --- README.md | 6 + src/utils/deposits.ts | 63 --------- test/integration/deposits.db.test.ts | 195 ++++++++++++++------------- 3 files changed, 106 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 70ff26c..a9c56c4 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ The setup script creates the following tables: - **nonces:** Tracks the latest nonce for each vault. - **proposers:** Records block proposers. - **vaults:** Maps vault IDs to controller addresses and optional rules. +- **deposits:** Records on-chain deposits for assignment to vault balances. +- **deposit_assignment_events:** Records partial or full assignment events against deposits. A deposit becomes fully assigned when the sum of its assignment events equals its original amount; in that case, `deposits.assigned_at` is set automatically. If deploying to Heroku, run the migration script as follows: @@ -151,6 +153,10 @@ chmod +x migrations/1_createTable.sh Alternatively, execute the SQL commands manually in your PostgreSQL instance. +### Partial Deposit Assignments + +The `deposits` table records raw on-chain deposits, and `deposit_assignment_events` records partial or full assignments against those deposits. A deposit’s remaining amount is `deposits.amount - SUM(deposit_assignment_events.amount)`; when it reaches zero, `deposits.assigned_at` is set. + ## Filecoin Pin Setup (Optional) The Oya node supports optional archival storage on Filecoin using the [filecoin-pin SDK](https://github.com/filecoin-shipyard/filecoin-pin). When enabled, bundle data is automatically uploaded to both IPFS and Filecoin for redundancy and long-term permanence. diff --git a/src/utils/deposits.ts b/src/utils/deposits.ts index 9005170..f9de590 100644 --- a/src/utils/deposits.ts +++ b/src/utils/deposits.ts @@ -69,70 +69,7 @@ export async function insertDepositIfMissing( } } -export interface FindExactUnassignedParams { - depositor: string - token: string - amount: string - chain_id: number -} - -export async function findExactUnassignedDeposit( - params: FindExactUnassignedParams -): Promise<{ id: number } | null> { - const depositor = params.depositor.toLowerCase() - const token = params.token.toLowerCase() - const amount = params.amount - const chainId = params.chain_id - const result = await pool.query( - `SELECT id - FROM deposits - WHERE depositor = $1 - AND LOWER(token) = LOWER($2) - AND amount = $3 - AND chain_id = $4 - AND assigned_at IS NULL - ORDER BY id ASC - LIMIT 1`, - [depositor, token, amount, chainId] - ) - - if (result.rows.length === 0) return null - return { id: result.rows[0].id as number } -} - -export async function markDepositAssigned( - deposit_id: number, - credited_vault: string -): Promise { - const client = await pool.connect() - try { - await client.query('BEGIN') - const update = await client.query( - `UPDATE deposits - SET credited_vault = $2, - assigned_at = CURRENT_TIMESTAMP - WHERE id = $1 - AND assigned_at IS NULL - RETURNING id`, - [deposit_id, String(credited_vault)] - ) - if (update.rows.length === 0) { - await client.query('ROLLBACK') - throw new Error('Deposit already assigned or not found') - } - await client.query('COMMIT') - } catch (error) { - try { - await client.query('ROLLBACK') - } catch (rollbackError) { - logger.warn('Rollback failed during markDepositAssigned', rollbackError) - } - throw error - } finally { - client.release() - } -} /** * Returns remaining (unassigned) amount for a deposit as a decimal string (wei). diff --git a/test/integration/deposits.db.test.ts b/test/integration/deposits.db.test.ts index f532adf..5446b3e 100644 --- a/test/integration/deposits.db.test.ts +++ b/test/integration/deposits.db.test.ts @@ -12,9 +12,7 @@ import { } from 'bun:test' import { pool } from '../../src/db.js' import { - insertDepositIfMissing, - findExactUnassignedDeposit, - markDepositAssigned, + insertDepositIfMissing, getDepositRemaining, findDepositWithSufficientRemaining, createAssignmentEventTransactional, @@ -63,7 +61,7 @@ describe('Deposits utils (DB)', () => { expect(row.rows[0].assigned_at).toBeNull() }) - test('findExactUnassignedDeposit then markDepositAssigned', async () => { +test('sufficient-remaining selection and full assignment event', async () => { // two deposits, distinct amounts await insertDepositIfMissing({ tx_hash: TEST_TX, @@ -82,106 +80,113 @@ describe('Deposits utils (DB)', () => { amount: '555', }) - const found1 = await findExactUnassignedDeposit({ + const found1 = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + minAmount: '123', + }) + expect(found1?.id).toBeDefined() + + const found2 = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: ZERO, + chain_id: 11155111, + minAmount: '555', + }) + expect(found2?.id).toBe(ins.id) + + // full assignment via event + await createAssignmentEventTransactional(found2!.id, '555', '9999') + const remaining = await getDepositRemaining(found2!.id) + expect(remaining).toBe('0') + const reread = await pool.query( + 'SELECT assigned_at FROM deposits WHERE id = $1', + [found2!.id] + ) + expect(reread.rows[0].assigned_at).not.toBeNull() + + // no longer available for selection for any positive amount + const notFound = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: ZERO, + chain_id: 11155111, + minAmount: '1', + }) + expect(notFound).toBeNull() + }) +}) + +describe('Partial assignment events (DB)', () => { + test('remaining decreases with partial assignments and assigned_at set when fully assigned', async () => { + const ins = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(100), + chain_id: 11155111, depositor: CTRL, token: TOKEN, - amount: '123', - chain_id: 11155111, + amount: '1000', }) - expect(found1?.id).toBeDefined() - const found2 = await findExactUnassignedDeposit({ - depositor: CTRL, - token: ZERO, - amount: '555', - chain_id: 11155111, - }) - expect(found2?.id).toBe(ins.id) + const before = await getDepositRemaining(ins.id) + expect(before).toBe('1000') + + // Assign 400 + await createAssignmentEventTransactional(ins.id, '400', '8001') + const after400 = await getDepositRemaining(ins.id) + expect(after400).toBe('600') - // mark assigned - await markDepositAssigned(found2!.id, '9999') - const reread = await pool.query( - 'SELECT credited_vault, assigned_at FROM deposits WHERE id = $1', - [found2!.id] + // Not fully assigned yet + const row1 = await pool.query( + 'SELECT assigned_at FROM deposits WHERE id = $1', + [ins.id] ) - expect(reread.rows[0].credited_vault).toBe('9999') - expect(reread.rows[0].assigned_at).not.toBeNull() + expect(row1.rows[0].assigned_at).toBeNull() - // no longer available for selection - const notFound = await findExactUnassignedDeposit({ + // Assign remaining 600 + await createAssignmentEventTransactional(ins.id, '600', '8002') + const afterFull = await getDepositRemaining(ins.id) + expect(afterFull).toBe('0') + + const row2 = await pool.query( + 'SELECT assigned_at FROM deposits WHERE id = $1', + [ins.id] + ) + expect(row2.rows[0].assigned_at).not.toBeNull() + }) + + test('selection uses sufficient remaining and fails on over-assignment', async () => { + const ins = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(101), + chain_id: 11155111, depositor: CTRL, - token: ZERO, - amount: '555', + token: TOKEN, + amount: '700', + }) + + // Oldest deposit with remaining >= 500 should be this one initially + const pick1 = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: TOKEN, chain_id: 11155111, + minAmount: '500', }) - expect(notFound).toBeNull() + expect(pick1?.id).toBe(ins.id) + + // Assign 650 (leave 50) + await createAssignmentEventTransactional(ins.id, '650', '9001') + const remaining = await getDepositRemaining(ins.id) + expect(remaining).toBe('50') + + // Cannot assign 100 now + await expect( + createAssignmentEventTransactional(ins.id, '100', '9002') + ).rejects.toThrow() + + // But can assign 50 + await createAssignmentEventTransactional(ins.id, '50', '9003') + const remaining2 = await getDepositRemaining(ins.id) + expect(remaining2).toBe('0') }) }) - -describe('Partial assignment events (DB)', () => { - test('remaining decreases with partial assignments and assigned_at set when fully assigned', async () => { - const ins = await insertDepositIfMissing({ - tx_hash: TEST_TX, - transfer_uid: TEST_UID(100), - chain_id: 11155111, - depositor: CTRL, - token: TOKEN, - amount: '1000', - }) - - const before = await getDepositRemaining(ins.id) - expect(before).toBe('1000') - - // Assign 400 - await createAssignmentEventTransactional(ins.id, '400', '8001') - const after400 = await getDepositRemaining(ins.id) - expect(after400).toBe('600') - - // Not fully assigned yet - const row1 = await pool.query('SELECT assigned_at FROM deposits WHERE id = $1', [ins.id]) - expect(row1.rows[0].assigned_at).toBeNull() - - // Assign remaining 600 - await createAssignmentEventTransactional(ins.id, '600', '8002') - const afterFull = await getDepositRemaining(ins.id) - expect(afterFull).toBe('0') - - const row2 = await pool.query('SELECT assigned_at FROM deposits WHERE id = $1', [ins.id]) - expect(row2.rows[0].assigned_at).not.toBeNull() - }) - - test('selection uses sufficient remaining and fails on over-assignment', async () => { - const ins = await insertDepositIfMissing({ - tx_hash: TEST_TX, - transfer_uid: TEST_UID(101), - chain_id: 11155111, - depositor: CTRL, - token: TOKEN, - amount: '700', - }) - - // Oldest deposit with remaining >= 500 should be this one initially - const pick1 = await findDepositWithSufficientRemaining({ - depositor: CTRL, - token: TOKEN, - chain_id: 11155111, - minAmount: '500', - }) - expect(pick1?.id).toBe(ins.id) - - // Assign 650 (leave 50) - await createAssignmentEventTransactional(ins.id, '650', '9001') - const remaining = await getDepositRemaining(ins.id) - expect(remaining).toBe('50') - - // Cannot assign 100 now - await expect( - createAssignmentEventTransactional(ins.id, '100', '9002') - ).rejects.toThrow() - - // But can assign 50 - await createAssignmentEventTransactional(ins.id, '50', '9003') - const remaining2 = await getDepositRemaining(ins.id) - expect(remaining2).toBe('0') - }) -}) From 15be67565eff4ada77b3ba6e20ec35e9bca8c6f1 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:45:18 -0400 Subject: [PATCH 19/24] format fixes --- src/utils/deposits.ts | 2 - test/integration/deposits.db.test.ts | 76 ++++++++++++++-------------- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/src/utils/deposits.ts b/src/utils/deposits.ts index f9de590..a25aa2f 100644 --- a/src/utils/deposits.ts +++ b/src/utils/deposits.ts @@ -69,8 +69,6 @@ export async function insertDepositIfMissing( } } - - /** * Returns remaining (unassigned) amount for a deposit as a decimal string (wei). */ diff --git a/test/integration/deposits.db.test.ts b/test/integration/deposits.db.test.ts index 5446b3e..d7789e9 100644 --- a/test/integration/deposits.db.test.ts +++ b/test/integration/deposits.db.test.ts @@ -12,10 +12,10 @@ import { } from 'bun:test' import { pool } from '../../src/db.js' import { - insertDepositIfMissing, - getDepositRemaining, - findDepositWithSufficientRemaining, - createAssignmentEventTransactional, + insertDepositIfMissing, + getDepositRemaining, + findDepositWithSufficientRemaining, + createAssignmentEventTransactional, } from '../../src/utils/deposits.js' const TEST_TX = '0xtest-deposit-tx' @@ -61,7 +61,7 @@ describe('Deposits utils (DB)', () => { expect(row.rows[0].assigned_at).toBeNull() }) -test('sufficient-remaining selection and full assignment event', async () => { + test('sufficient-remaining selection and full assignment event', async () => { // two deposits, distinct amounts await insertDepositIfMissing({ tx_hash: TEST_TX, @@ -80,39 +80,39 @@ test('sufficient-remaining selection and full assignment event', async () => { amount: '555', }) - const found1 = await findDepositWithSufficientRemaining({ - depositor: CTRL, - token: TOKEN, - chain_id: 11155111, - minAmount: '123', - }) - expect(found1?.id).toBeDefined() - - const found2 = await findDepositWithSufficientRemaining({ - depositor: CTRL, - token: ZERO, - chain_id: 11155111, - minAmount: '555', - }) - expect(found2?.id).toBe(ins.id) - - // full assignment via event - await createAssignmentEventTransactional(found2!.id, '555', '9999') - const remaining = await getDepositRemaining(found2!.id) - expect(remaining).toBe('0') - const reread = await pool.query( - 'SELECT assigned_at FROM deposits WHERE id = $1', - [found2!.id] - ) - expect(reread.rows[0].assigned_at).not.toBeNull() - - // no longer available for selection for any positive amount - const notFound = await findDepositWithSufficientRemaining({ - depositor: CTRL, - token: ZERO, - chain_id: 11155111, - minAmount: '1', - }) + const found1 = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + minAmount: '123', + }) + expect(found1?.id).toBeDefined() + + const found2 = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: ZERO, + chain_id: 11155111, + minAmount: '555', + }) + expect(found2?.id).toBe(ins.id) + + // full assignment via event + await createAssignmentEventTransactional(found2!.id, '555', '9999') + const remaining = await getDepositRemaining(found2!.id) + expect(remaining).toBe('0') + const reread = await pool.query( + 'SELECT assigned_at FROM deposits WHERE id = $1', + [found2!.id] + ) + expect(reread.rows[0].assigned_at).not.toBeNull() + + // no longer available for selection for any positive amount + const notFound = await findDepositWithSufficientRemaining({ + depositor: CTRL, + token: ZERO, + chain_id: 11155111, + minAmount: '1', + }) expect(notFound).toBeNull() }) }) From 13bbb258da506c7705d64a4a538405e2c9ad4a0c Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 15:48:53 -0400 Subject: [PATCH 20/24] remove alchemy uniqueID for transferUid --- src/proposer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index ca70d6f..45f98c3 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -230,8 +230,8 @@ async function discoverAndIngestDeposits(params: { // Determine token address: ERC-20 uses raw.address; ETH uses zero address const tokenAddr = rawAddr ?? '0x0000000000000000000000000000000000000000' - const uniqueId: string | undefined = (t as { uniqueId?: string }).uniqueId - const transferUid = uniqueId ?? `${txHash}:${tokenAddr}:${rawValueHex}` + // Deterministic transfer UID to avoid dependency on provider-specific IDs + const transferUid = `${txHash}:${tokenAddr}:${rawValueHex}` const amountWei = BigInt(rawValueHex).toString() await insertDepositIfMissing({ From 30b2c4692195ef026945b0fa5e7a9a0202580067 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 21:53:43 -0400 Subject: [PATCH 21/24] remove credited_vault column, add chain_id to token index, fixed conole log message with all table names --- scripts/shared/db-setup.js | 6 ++---- src/proposer.ts | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/shared/db-setup.js b/scripts/shared/db-setup.js index 1a00208..b48c548 100644 --- a/scripts/shared/db-setup.js +++ b/scripts/shared/db-setup.js @@ -84,7 +84,6 @@ CREATE TABLE IF NOT EXISTS deposits ( depositor TEXT NOT NULL, token TEXT NOT NULL, amount NUMERIC(78, 0) NOT NULL, - credited_vault TEXT, assigned_at TIMESTAMPTZ ); @@ -116,8 +115,7 @@ CREATE INDEX IF NOT EXISTS idx_vaults_controllers ON vaults USING GIN (controlle -- Create indexes for deposits table CREATE INDEX IF NOT EXISTS idx_deposits_tx_hash ON deposits(tx_hash); CREATE INDEX IF NOT EXISTS idx_deposits_depositor ON deposits(depositor, chain_id); -CREATE INDEX IF NOT EXISTS idx_deposits_token ON deposits(token); -CREATE INDEX IF NOT EXISTS idx_deposits_vault ON deposits(credited_vault); +CREATE INDEX IF NOT EXISTS idx_deposits_token ON deposits(token, chain_id); -- Create indexes for deposit_assignment_events table CREATE INDEX IF NOT EXISTS idx_assignment_deposit ON deposit_assignment_events(deposit_id); @@ -179,7 +177,7 @@ export async function setupDatabase(options) { if (!forceDropConfirm) { console.log(chalk.red('\n⚠️ WARNING: This will DELETE ALL DATA in the following tables:')) - console.log(chalk.red(' - bundles, cids, balances, nonces, proposers')) + console.log(chalk.red(' - bundles, cids, balances, vaults, proposers, deposits, deposit_assignment_events')) console.log(chalk.yellow('\nTo confirm, set FORCE_DROP=true or remove --drop-existing flag')) await pool.end() process.exit(1) diff --git a/src/proposer.ts b/src/proposer.ts index 45f98c3..65fd7ba 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -817,8 +817,8 @@ async function publishBundle(data: string, signature: string, from: string) { await updateBalance(proof.to, proof.token, newBalance) } } else { - for (const proof of execution.proof) { - await updateBalances(proof.from, proof.to, proof.token, proof.amount) + for (const proof of execution.proof) { + await updateBalances(proof.from, proof.to, proof.token, proof.amount) } } } From ad56d717f288bd96438ffa37922d2b7ea3d1748c Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 22:01:14 -0400 Subject: [PATCH 22/24] extract validator functions from proposer into validator file --- src/proposer.ts | 94 ++++----------------------- src/utils/validator.ts | 141 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 81 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 65fd7ba..6a1bd68 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -42,6 +42,8 @@ import { validateAddress, validateSignature, validateId, + validateAssignDepositStructure as baseValidateAssignDepositStructure, + validateVaultIdOnChain as baseValidateVaultIdOnChain, } from './utils/validator.js' import { pinBundleToFilecoin, @@ -132,27 +134,14 @@ let isInitialized = false // ~7 days at ~12s blocks const APPROX_7D_BLOCKS = 50400 -/** - * Validates that a vault ID exists on-chain by checking it is within range - * [1, nextVaultId - 1]. Throws if invalid or out of range. - */ -export async function validateVaultIdOnChain(vaultId: number): Promise { - // Basic sanity - if (!Number.isInteger(vaultId) || vaultId < 1) { - throw new Error('Invalid vault ID') - } - // Ensure contracts are initialized +// 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) - if (!Number.isFinite(nextIdNumber)) { - throw new Error('Could not determine nextVaultId from chain') - } - if (vaultId >= nextIdNumber) { - throw new Error('Vault ID does not exist on-chain') - } + await baseValidateVaultIdOnChain(vaultId, async () => nextIdNumber) } /** @@ -260,70 +249,13 @@ async function discoverAndIngestDeposits(params: { * - agentTip must be undefined or empty */ -export async function validateAssignDepositStructure( +// Wrapper to use validator's AssignDeposit structural validation with on-chain vault check +const validateAssignDepositStructure = async ( intention: Intention -): Promise { - if (!Array.isArray(intention.inputs) || !Array.isArray(intention.outputs)) { - throw new Error('AssignDeposit requires inputs and outputs arrays') - } - if (intention.inputs.length !== intention.outputs.length) { - throw new Error( - 'AssignDeposit requires 1:1 mapping between inputs and outputs' - ) - } - - // Zero-fee enforcement - if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { - throw new Error('AssignDeposit requires totalFee with zero amount') - } - const allTotalZero = intention.totalFee.every((f) => f.amount === '0') - if (!allTotalZero) { - throw new Error('AssignDeposit totalFee must be zero') - } - if ( - Array.isArray(intention.proposerTip) && - intention.proposerTip.length > 0 - ) { - throw new Error('AssignDeposit proposerTip must be empty') - } - if ( - Array.isArray(intention.protocolFee) && - intention.protocolFee.length > 0 - ) { - throw new Error('AssignDeposit protocolFee must be empty') - } - if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { - throw new Error('AssignDeposit agentTip must be empty if provided') - } - - for (let i = 0; i < intention.inputs.length; i++) { - const input: IntentionInput = intention.inputs[i] - const output: IntentionOutput = intention.outputs[i] - - if (!output || (output.to === undefined && !output.to_external)) { - throw new Error('AssignDeposit requires outputs[].to (vault ID)') - } - if (output.to_external !== undefined) { - throw new Error('AssignDeposit does not support to_external') - } - - if (input.asset.toLowerCase() !== output.asset.toLowerCase()) { - throw new Error('AssignDeposit input/output asset mismatch at index ' + i) - } - if (input.amount !== output.amount) { - throw new Error( - 'AssignDeposit input/output amount mismatch at index ' + i - ) - } - if (input.chain_id !== output.chain_id) { - throw new Error( - 'AssignDeposit input/output chain_id mismatch at index ' + i - ) - } - - // Validate on-chain vault existence - await validateVaultIdOnChain(Number(output.to)) - } +): Promise => { + await baseValidateAssignDepositStructure(intention, async (id: number) => + validateVaultIdOnChain(id) + ) } /** @@ -817,8 +749,8 @@ async function publishBundle(data: string, signature: string, from: string) { await updateBalance(proof.to, proof.token, newBalance) } } else { - for (const proof of execution.proof) { - await updateBalances(proof.from, proof.to, proof.token, proof.amount) + for (const proof of execution.proof) { + await updateBalances(proof.from, proof.to, proof.token, proof.amount) } } } diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 1ed1262..ff4d1f6 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -437,3 +437,144 @@ export function handleValidationError(error: unknown): { error: error instanceof Error ? error.message : 'Unknown validation error', } } + +/** + * Validates that a vault ID exists on-chain by checking it is within range + * [0, nextVaultId - 1]. Throws if invalid or out of range. + * Accepts a dependency to fetch nextVaultId to avoid coupling to contract code. + */ +export async function validateVaultIdOnChain( + vaultId: number, + getNextVaultId: () => Promise +): Promise { + if (!Number.isInteger(vaultId) || vaultId < 0) { + throw new ValidationError('Invalid vault ID', 'vaultId', vaultId) + } + + const nextIdNumber = await getNextVaultId() + if (!Number.isFinite(nextIdNumber)) { + throw new ValidationError( + 'Could not determine nextVaultId from chain', + 'nextVaultId', + nextIdNumber + ) + } + if (vaultId >= nextIdNumber) { + throw new ValidationError( + 'Vault ID does not exist on-chain', + 'vaultId', + vaultId, + { nextVaultId: nextIdNumber } + ) + } +} + +/** + * 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 zero; proposerTip/protocolFee empty; agentTip empty) + * Accepts a dependency to validate vault IDs on-chain. + */ +export async function validateAssignDepositStructure( + intention: Intention, + validateVaultId: (vaultId: number) => Promise +): Promise { + if (!Array.isArray(intention.inputs) || !Array.isArray(intention.outputs)) { + throw new ValidationError( + 'AssignDeposit requires inputs and outputs arrays', + 'intention', + intention + ) + } + if (intention.inputs.length !== intention.outputs.length) { + throw new ValidationError( + 'AssignDeposit requires 1:1 mapping between inputs and outputs', + 'intention', + { inputs: intention.inputs.length, outputs: intention.outputs.length } + ) + } + + if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { + throw new ValidationError( + 'AssignDeposit requires totalFee with zero amount', + '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 (Array.isArray(intention.proposerTip) && intention.proposerTip.length > 0) { + throw new ValidationError( + 'AssignDeposit proposerTip must be empty', + 'intention.proposerTip', + intention.proposerTip + ) + } + if (Array.isArray(intention.protocolFee) && intention.protocolFee.length > 0) { + throw new ValidationError( + 'AssignDeposit protocolFee must be empty', + 'intention.protocolFee', + intention.protocolFee + ) + } + if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { + throw new ValidationError( + 'AssignDeposit agentTip must be empty if provided', + 'intention.agentTip', + intention.agentTip + ) + } + + for (let i = 0; i < intention.inputs.length; i++) { + const input = intention.inputs[i] + const output = intention.outputs[i] + + if (!output || (output.to === undefined && !output.to_external)) { + throw new ValidationError( + 'AssignDeposit requires outputs[].to (vault ID)', + `intention.outputs[${i}].to`, + output + ) + } + if (output.to_external !== undefined) { + throw new ValidationError( + 'AssignDeposit does not support to_external', + `intention.outputs[${i}].to_external`, + output.to_external + ) + } + + if (input.asset.toLowerCase() !== output.asset.toLowerCase()) { + throw new ValidationError( + 'AssignDeposit input/output asset mismatch', + `intention.inputs[${i}].asset`, + { input: input.asset, output: output.asset } + ) + } + if (input.amount !== output.amount) { + throw new ValidationError( + 'AssignDeposit input/output amount mismatch', + `intention.inputs[${i}].amount`, + { input: input.amount, output: output.amount } + ) + } + if (input.chain_id !== output.chain_id) { + throw new ValidationError( + 'AssignDeposit input/output chain_id mismatch', + `intention.inputs[${i}].chain_id`, + { input: input.chain_id, output: output.chain_id } + ) + } + + await validateVaultId(Number(output.to)) + } +} From e1bc1286fe84c061346ea214b6d589d7438aef6c Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 22:15:48 -0400 Subject: [PATCH 23/24] remove controller from discovery function inputs, remove fromAddress from alchemy getTransfers call, add lastCheckedBlock variable --- src/proposer.ts | 70 +++--- src/utils/intentionHandlers/AssignDeposit.ts | 4 - src/utils/validator.ts | 242 ++++++++++--------- 3 files changed, 162 insertions(+), 154 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 6a1bd68..12bb6a2 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -42,8 +42,8 @@ import { validateAddress, validateSignature, validateId, - validateAssignDepositStructure as baseValidateAssignDepositStructure, - validateVaultIdOnChain as baseValidateVaultIdOnChain, + validateAssignDepositStructure as baseValidateAssignDepositStructure, + validateVaultIdOnChain as baseValidateVaultIdOnChain, } from './utils/validator.js' import { pinBundleToFilecoin, @@ -134,6 +134,9 @@ let isInitialized = false // ~7 days at ~12s blocks 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) { @@ -149,17 +152,23 @@ const validateVaultIdOnChain = async (vaultId: number): Promise => { * defaulting to a ~7 day lookback if not provided. */ async function computeBlockRange( - fromBlockHex?: string, - toBlockHex?: string -): Promise<{ fromBlockHex: string; toBlockHex: string }> { + chainId: number, + fromBlockHex?: string, + toBlockHex?: string +): Promise<{ fromBlockHex: string; toBlockHex: string; toBlockNum: number }> { const provider = (await sepoliaAlchemy.config.getProvider()) as unknown as { getBlockNumber: () => Promise } const latest = await provider.getBlockNumber() - const resolvedTo = toBlockHex ?? '0x' + latest.toString(16) - const fromBlock = Math.max(0, latest - APPROX_7D_BLOCKS) - const resolvedFrom = fromBlockHex ?? '0x' + fromBlock.toString(16) - return { fromBlockHex: resolvedFrom, toBlockHex: resolvedTo } + const resolvedTo = toBlockHex ?? '0x' + latest.toString(16) + const toBlockNum = parseInt(resolvedTo, 16) + const defaultFrom = Math.max(0, latest - APPROX_7D_BLOCKS) + const cursorFrom = + lastCheckedBlockByChain[chainId] !== undefined + ? Math.min(toBlockNum, lastCheckedBlockByChain[chainId] + 1) + : defaultFrom + const resolvedFrom = fromBlockHex ?? '0x' + cursorFrom.toString(16) + return { fromBlockHex: resolvedFrom, toBlockHex: resolvedTo, toBlockNum } } /** @@ -168,14 +177,12 @@ async function computeBlockRange( */ async function discoverAndIngestDeposits(params: { - controller: string - chainId: number - categories: Array<'erc20' | 'internal' | 'external'> - token?: string - fromBlockHex?: string - toBlockHex?: string + chainId: number + categories: Array<'erc20' | 'internal' | 'external'> + token?: string + fromBlockHex?: string + toBlockHex?: string }): Promise { - const controller = validateAddress(params.controller, 'controller') if (!isInitialized) { throw new Error('Proposer not initialized') @@ -184,10 +191,11 @@ async function discoverAndIngestDeposits(params: { throw new Error('Unsupported chain_id for discovery') } - const { fromBlockHex, toBlockHex } = await computeBlockRange( - params.fromBlockHex, - params.toBlockHex - ) + const { fromBlockHex, toBlockHex, toBlockNum } = await computeBlockRange( + params.chainId, + params.fromBlockHex, + params.toBlockHex + ) let pageKey: string | undefined = undefined do { @@ -195,7 +203,6 @@ async function discoverAndIngestDeposits(params: { const req: any = { fromBlock: fromBlockHex, toBlock: toBlockHex, - fromAddress: controller, toAddress: VAULT_TRACKER_ADDRESS, category: params.categories, withMetadata: true, @@ -234,6 +241,9 @@ async function discoverAndIngestDeposits(params: { } pageKey = res?.pageKey } while (pageKey) + + // Advance cursor for this chain to the block we just scanned up to + lastCheckedBlockByChain[params.chainId] = toBlockNum } /** @@ -267,14 +277,12 @@ const validateAssignDepositStructure = async ( * of ~7 days by subtracting ~50,400 blocks from the latest block. */ async function discoverAndIngestErc20Deposits(params: { - controller: string - token: string - chainId: number - fromBlockHex?: string - toBlockHex?: string + token: string + chainId: number + fromBlockHex?: string + toBlockHex?: string }): Promise { await discoverAndIngestDeposits({ - controller: params.controller, chainId: params.chainId, categories: ['erc20'], token: params.token, @@ -288,13 +296,11 @@ async function discoverAndIngestErc20Deposits(params: { * and ingests them into the local `deposits` table via idempotent inserts. */ async function discoverAndIngestEthDeposits(params: { - controller: string - chainId: number - fromBlockHex?: string - toBlockHex?: string + chainId: number + fromBlockHex?: string + toBlockHex?: string }): Promise { await discoverAndIngestDeposits({ - controller: params.controller, chainId: params.chainId, categories: ['internal', 'external'], fromBlockHex: params.fromBlockHex, diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index 389c8cc..cea03d7 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -12,14 +12,12 @@ import type { type AssignDepositContext = { validateAssignDepositStructure: (intention: Intention) => Promise discoverAndIngestErc20Deposits: (args: { - controller: string token: string chainId: number fromBlockHex?: string toBlockHex?: string }) => Promise discoverAndIngestEthDeposits: (args: { - controller: string chainId: number fromBlockHex?: string toBlockHex?: string @@ -69,14 +67,12 @@ export async function handleAssignDeposit(params: { const isEth = input.asset.toLowerCase() === zeroAddress if (isEth) { await context.discoverAndIngestEthDeposits({ - controller: validatedController, chainId: input.chain_id, fromBlockHex, toBlockHex, }) } else { await context.discoverAndIngestErc20Deposits({ - controller: validatedController, token: input.asset, chainId: input.chain_id, fromBlockHex, diff --git a/src/utils/validator.ts b/src/utils/validator.ts index ff4d1f6..4ac4be2 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -445,28 +445,28 @@ export function handleValidationError(error: unknown): { */ export async function validateVaultIdOnChain( vaultId: number, - getNextVaultId: () => Promise + getNextVaultId: () => Promise ): Promise { - if (!Number.isInteger(vaultId) || vaultId < 0) { - throw new ValidationError('Invalid vault ID', 'vaultId', vaultId) - } - - const nextIdNumber = await getNextVaultId() - if (!Number.isFinite(nextIdNumber)) { - throw new ValidationError( - 'Could not determine nextVaultId from chain', - 'nextVaultId', - nextIdNumber - ) - } - if (vaultId >= nextIdNumber) { - throw new ValidationError( - 'Vault ID does not exist on-chain', - 'vaultId', - vaultId, - { nextVaultId: nextIdNumber } - ) - } + if (!Number.isInteger(vaultId) || vaultId < 0) { + throw new ValidationError('Invalid vault ID', 'vaultId', vaultId) + } + + const nextIdNumber = await getNextVaultId() + if (!Number.isFinite(nextIdNumber)) { + throw new ValidationError( + 'Could not determine nextVaultId from chain', + 'nextVaultId', + nextIdNumber + ) + } + if (vaultId >= nextIdNumber) { + throw new ValidationError( + 'Vault ID does not exist on-chain', + 'vaultId', + vaultId, + { nextVaultId: nextIdNumber } + ) + } } /** @@ -479,102 +479,108 @@ export async function validateVaultIdOnChain( * Accepts a dependency to validate vault IDs on-chain. */ export async function validateAssignDepositStructure( - intention: Intention, - validateVaultId: (vaultId: number) => Promise + intention: Intention, + validateVaultId: (vaultId: number) => Promise ): Promise { - if (!Array.isArray(intention.inputs) || !Array.isArray(intention.outputs)) { - throw new ValidationError( - 'AssignDeposit requires inputs and outputs arrays', - 'intention', - intention - ) - } - if (intention.inputs.length !== intention.outputs.length) { - throw new ValidationError( - 'AssignDeposit requires 1:1 mapping between inputs and outputs', - 'intention', - { inputs: intention.inputs.length, outputs: intention.outputs.length } - ) - } - - if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { - throw new ValidationError( - 'AssignDeposit requires totalFee with zero amount', - '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 (Array.isArray(intention.proposerTip) && intention.proposerTip.length > 0) { - throw new ValidationError( - 'AssignDeposit proposerTip must be empty', - 'intention.proposerTip', - intention.proposerTip - ) - } - if (Array.isArray(intention.protocolFee) && intention.protocolFee.length > 0) { - throw new ValidationError( - 'AssignDeposit protocolFee must be empty', - 'intention.protocolFee', - intention.protocolFee - ) - } - if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { - throw new ValidationError( - 'AssignDeposit agentTip must be empty if provided', - 'intention.agentTip', - intention.agentTip - ) - } - - for (let i = 0; i < intention.inputs.length; i++) { - const input = intention.inputs[i] - const output = intention.outputs[i] - - if (!output || (output.to === undefined && !output.to_external)) { - throw new ValidationError( - 'AssignDeposit requires outputs[].to (vault ID)', - `intention.outputs[${i}].to`, - output - ) - } - if (output.to_external !== undefined) { - throw new ValidationError( - 'AssignDeposit does not support to_external', - `intention.outputs[${i}].to_external`, - output.to_external - ) - } - - if (input.asset.toLowerCase() !== output.asset.toLowerCase()) { - throw new ValidationError( - 'AssignDeposit input/output asset mismatch', - `intention.inputs[${i}].asset`, - { input: input.asset, output: output.asset } - ) - } - if (input.amount !== output.amount) { - throw new ValidationError( - 'AssignDeposit input/output amount mismatch', - `intention.inputs[${i}].amount`, - { input: input.amount, output: output.amount } - ) - } - if (input.chain_id !== output.chain_id) { - throw new ValidationError( - 'AssignDeposit input/output chain_id mismatch', - `intention.inputs[${i}].chain_id`, - { input: input.chain_id, output: output.chain_id } - ) - } - - await validateVaultId(Number(output.to)) - } + if (!Array.isArray(intention.inputs) || !Array.isArray(intention.outputs)) { + throw new ValidationError( + 'AssignDeposit requires inputs and outputs arrays', + 'intention', + intention + ) + } + if (intention.inputs.length !== intention.outputs.length) { + throw new ValidationError( + 'AssignDeposit requires 1:1 mapping between inputs and outputs', + 'intention', + { inputs: intention.inputs.length, outputs: intention.outputs.length } + ) + } + + if (!Array.isArray(intention.totalFee) || intention.totalFee.length === 0) { + throw new ValidationError( + 'AssignDeposit requires totalFee with zero amount', + '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 ( + Array.isArray(intention.proposerTip) && + intention.proposerTip.length > 0 + ) { + throw new ValidationError( + 'AssignDeposit proposerTip must be empty', + 'intention.proposerTip', + intention.proposerTip + ) + } + if ( + Array.isArray(intention.protocolFee) && + intention.protocolFee.length > 0 + ) { + throw new ValidationError( + 'AssignDeposit protocolFee must be empty', + 'intention.protocolFee', + intention.protocolFee + ) + } + if (Array.isArray(intention.agentTip) && intention.agentTip.length > 0) { + throw new ValidationError( + 'AssignDeposit agentTip must be empty if provided', + 'intention.agentTip', + intention.agentTip + ) + } + + for (let i = 0; i < intention.inputs.length; i++) { + const input = intention.inputs[i] + const output = intention.outputs[i] + + if (!output || (output.to === undefined && !output.to_external)) { + throw new ValidationError( + 'AssignDeposit requires outputs[].to (vault ID)', + `intention.outputs[${i}].to`, + output + ) + } + if (output.to_external !== undefined) { + throw new ValidationError( + 'AssignDeposit does not support to_external', + `intention.outputs[${i}].to_external`, + output.to_external + ) + } + + if (input.asset.toLowerCase() !== output.asset.toLowerCase()) { + throw new ValidationError( + 'AssignDeposit input/output asset mismatch', + `intention.inputs[${i}].asset`, + { input: input.asset, output: output.asset } + ) + } + if (input.amount !== output.amount) { + throw new ValidationError( + 'AssignDeposit input/output amount mismatch', + `intention.inputs[${i}].amount`, + { input: input.amount, output: output.amount } + ) + } + if (input.chain_id !== output.chain_id) { + throw new ValidationError( + 'AssignDeposit input/output chain_id mismatch', + `intention.inputs[${i}].chain_id`, + { input: input.chain_id, output: output.chain_id } + ) + } + + await validateVaultId(Number(output.to)) + } } From 49c4d82e0f8bb39ff95f64642ff2b0e24211c957 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Wed, 29 Oct 2025 22:20:18 -0400 Subject: [PATCH 24/24] remove seedProposerVaultMapping function --- src/proposer.ts | 83 +++++++++++++++++++------------------------------ 1 file changed, 32 insertions(+), 51 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 12bb6a2..1b7c259 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -152,23 +152,23 @@ const validateVaultIdOnChain = async (vaultId: number): Promise => { * defaulting to a ~7 day lookback if not provided. */ async function computeBlockRange( - chainId: number, - fromBlockHex?: string, - toBlockHex?: string + chainId: number, + fromBlockHex?: string, + toBlockHex?: string ): Promise<{ fromBlockHex: string; toBlockHex: string; toBlockNum: number }> { const provider = (await sepoliaAlchemy.config.getProvider()) as unknown as { getBlockNumber: () => Promise } const latest = await provider.getBlockNumber() - const resolvedTo = toBlockHex ?? '0x' + latest.toString(16) - const toBlockNum = parseInt(resolvedTo, 16) - const defaultFrom = Math.max(0, latest - APPROX_7D_BLOCKS) - const cursorFrom = - lastCheckedBlockByChain[chainId] !== undefined - ? Math.min(toBlockNum, lastCheckedBlockByChain[chainId] + 1) - : defaultFrom - const resolvedFrom = fromBlockHex ?? '0x' + cursorFrom.toString(16) - return { fromBlockHex: resolvedFrom, toBlockHex: resolvedTo, toBlockNum } + const resolvedTo = toBlockHex ?? '0x' + latest.toString(16) + const toBlockNum = parseInt(resolvedTo, 16) + const defaultFrom = Math.max(0, latest - APPROX_7D_BLOCKS) + const cursorFrom = + lastCheckedBlockByChain[chainId] !== undefined + ? Math.min(toBlockNum, lastCheckedBlockByChain[chainId] + 1) + : defaultFrom + const resolvedFrom = fromBlockHex ?? '0x' + cursorFrom.toString(16) + return { fromBlockHex: resolvedFrom, toBlockHex: resolvedTo, toBlockNum } } /** @@ -177,13 +177,12 @@ async function computeBlockRange( */ async function discoverAndIngestDeposits(params: { - chainId: number - categories: Array<'erc20' | 'internal' | 'external'> - token?: string - fromBlockHex?: string - toBlockHex?: string + chainId: number + categories: Array<'erc20' | 'internal' | 'external'> + token?: string + fromBlockHex?: string + toBlockHex?: string }): Promise { - if (!isInitialized) { throw new Error('Proposer not initialized') } @@ -192,10 +191,10 @@ async function discoverAndIngestDeposits(params: { } const { fromBlockHex, toBlockHex, toBlockNum } = await computeBlockRange( - params.chainId, - params.fromBlockHex, - params.toBlockHex - ) + params.chainId, + params.fromBlockHex, + params.toBlockHex + ) let pageKey: string | undefined = undefined do { @@ -242,8 +241,8 @@ async function discoverAndIngestDeposits(params: { pageKey = res?.pageKey } while (pageKey) - // Advance cursor for this chain to the block we just scanned up to - lastCheckedBlockByChain[params.chainId] = toBlockNum + // Advance cursor for this chain to the block we just scanned up to + lastCheckedBlockByChain[params.chainId] = toBlockNum } /** @@ -277,10 +276,10 @@ const validateAssignDepositStructure = async ( * of ~7 days by subtracting ~50,400 blocks from the latest block. */ async function discoverAndIngestErc20Deposits(params: { - token: string - chainId: number - fromBlockHex?: string - toBlockHex?: string + token: string + chainId: number + fromBlockHex?: string + toBlockHex?: string }): Promise { await discoverAndIngestDeposits({ chainId: params.chainId, @@ -296,9 +295,9 @@ async function discoverAndIngestErc20Deposits(params: { * and ingests them into the local `deposits` table via idempotent inserts. */ async function discoverAndIngestEthDeposits(params: { - chainId: number - fromBlockHex?: string - toBlockHex?: string + chainId: number + fromBlockHex?: string + toBlockHex?: string }): Promise { await discoverAndIngestDeposits({ chainId: params.chainId, @@ -1278,9 +1277,6 @@ export async function initializeProposer() { logger.info('Initializing proposer module...') - // Seed the proposer's own vault-to-controller mapping - await seedProposerVaultMapping() - // Initialize wallet and contract await initializeWalletAndContract() @@ -1309,23 +1305,8 @@ export function getSepoliaAlchemy() { return sepoliaAlchemy } -/** - * Ensures the proposer's vault-to-controller mapping is seeded in the database. - * This is crucial for allowing the proposer to sign and submit seeding intentions. - */ -async function seedProposerVaultMapping() { - try { - await updateVaultControllers(PROPOSER_VAULT_ID.value, [PROPOSER_ADDRESS]) - logger.info( - `Proposer vault mapping seeded: Vault ${PROPOSER_VAULT_ID.value} -> Controller ${PROPOSER_ADDRESS}` - ) - } catch (error) { - logger.error('Failed to seed proposer vault mapping:', error) - // We throw here because if the proposer can't control its own vault, - // it won't be able to perform critical functions like seeding new vaults. - throw new Error('Could not seed proposer vault mapping') - } -} +// Removed explicit seedProposerVaultMapping. Vault-controller associations are +// discovered from on-chain events and should not be force-seeded at runtime. /** * Exports IPFS node for health checks