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/scripts/shared/db-setup.js b/scripts/shared/db-setup.js index d4a9fb3..b48c548 100644 --- a/scripts/shared/db-setup.js +++ b/scripts/shared/db-setup.js @@ -74,6 +74,27 @@ 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, + 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 +); ` /** @@ -90,12 +111,23 @@ 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, chain_id); + +-- 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; DROP TABLE IF EXISTS balances CASCADE; @@ -145,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, vaults')) + 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) @@ -171,7 +203,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', 'deposit_assignment_events') ORDER BY table_name `) diff --git a/src/config/dbSettings.ts b/src/config/dbSettings.ts index 6112970..a8b7826 100644 --- a/src/config/dbSettings.ts +++ b/src/config/dbSettings.ts @@ -39,4 +39,6 @@ export const REQUIRED_TABLES = [ 'nonces', 'proposers', 'vaults', + 'deposits', + 'deposit_assignment_events', ] as const diff --git a/src/proposer.ts b/src/proposer.ts index ebcbf04..1b7c259 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -34,7 +34,7 @@ import { resolveIntentionENS } from './utils/ensResolver.js' import { getControllersForVault, getVaultsForController, - createVaultRow, + updateVaultControllers, } from './utils/vaults.js' import { PROPOSER_VAULT_ID, SEED_CONFIG } from './config/seedingConfig.js' import { @@ -42,12 +42,21 @@ import { validateAddress, validateSignature, validateId, + validateAssignDepositStructure as baseValidateAssignDepositStructure, + validateVaultIdOnChain as baseValidateVaultIdOnChain, } from './utils/validator.js' import { pinBundleToFilecoin, initializeFilecoinPin, } from './utils/filecoinPin.js' import { sendWebhook } from './utils/webhook.js' +import { + insertDepositIfMissing, + findDepositWithSufficientRemaining, + createAssignmentEventTransactional, +} from './utils/deposits.js' +import { handleAssignDeposit } from './utils/intentionHandlers/AssignDeposit.js' +import { handleCreateVault } from './utils/intentionHandlers/CreateVault.js' import type { Intention, BundleData, @@ -106,6 +115,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[] = [] @@ -121,6 +132,181 @@ let s: ReturnType // Initialization flag 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) { + throw new Error('VaultTracker contract not initialized') + } + const nextId = await vaultTrackerContract.nextVaultId() + const nextIdNumber = Number(nextId) + await baseValidateVaultIdOnChain(vaultId, async () => nextIdNumber) +} + +/** + * Computes block range hex strings for Alchemy getAssetTransfers requests, + * defaulting to a ~7 day lookback if not provided. + */ +async function computeBlockRange( + 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 } +} + +/** + * Generic discovery for deposits into VaultTracker using Alchemy's decoded + * asset transfers. Supports ERC-20 and ETH (internal/external) categories. + */ + +async function discoverAndIngestDeposits(params: { + chainId: number + categories: Array<'erc20' | 'internal' | 'external'> + token?: string + fromBlockHex?: string + toBlockHex?: string +}): Promise { + if (!isInitialized) { + throw new Error('Proposer not initialized') + } + if (params.chainId !== 11155111) { + throw new Error('Unsupported chain_id for discovery') + } + + const { fromBlockHex, toBlockHex, toBlockNum } = await computeBlockRange( + params.chainId, + 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, + 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' + + // Deterministic transfer UID to avoid dependency on provider-specific IDs + const transferUid = `${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) + + // Advance cursor for this chain to the block we just scanned up to + lastCheckedBlockByChain[params.chainId] = toBlockNum +} + +/** + * Validates structural and fee constraints for AssignDeposit intentions. + * Rules: + * - inputs.length === outputs.length + * - For each index i: asset/amount/chain_id must match between input and output + * - outputs[i].to must be provided (no to_external) and must be a valid on-chain vault ID + * - Fees must be zero: + * - totalFee amounts must all be "0" + * - proposerTip must be empty + * - protocolFee must be empty + * - agentTip must be undefined or empty + */ + +// Wrapper to use validator's AssignDeposit structural validation with on-chain vault check +const validateAssignDepositStructure = async ( + intention: Intention +): Promise => { + await baseValidateAssignDepositStructure(intention, async (id: number) => + validateVaultIdOnChain(id) + ) +} + +/** + * Discovers ERC-20 deposits made by `controller` into the VaultTracker and + * ingests them into the local `deposits` table. Uses Alchemy's decoded + * 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. + */ +async function discoverAndIngestErc20Deposits(params: { + token: string + chainId: number + fromBlockHex?: string + toBlockHex?: string +}): Promise { + await discoverAndIngestDeposits({ + 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. + */ +async function discoverAndIngestEthDeposits(params: { + chainId: number + fromBlockHex?: string + toBlockHex?: string +}): Promise { + await discoverAndIngestDeposits({ + chainId: params.chainId, + categories: ['internal', 'external'], + fromBlockHex: params.fromBlockHex, + toBlockHex: params.toBlockHex, + }) +} + /** * Initializes the BundleTracker contract with ABI and provider. * Connects the wallet for transaction signing. @@ -550,8 +736,27 @@ async function publishBundle(data: string, signature: string, from: string) { 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) + + if (execution.intention?.action === 'AssignDeposit') { + // Publish-time crediting for AssignDeposit + for (const proof of execution.proof) { + // 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) + 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') @@ -794,57 +999,38 @@ async function handleIntention( JSON.stringify(validatedIntention) ) + // Handle AssignDeposit intention (bypass generic balance checks) + if (validatedIntention.action === 'AssignDeposit') { + const executionObject = await handleAssignDeposit({ + intention: validatedIntention, + validatedController, + validatedSignature, + context: { + validateAssignDepositStructure, + discoverAndIngestErc20Deposits, + discoverAndIngestEthDeposits, + findDepositWithSufficientRemaining, + validateVaultIdOnChain, + logger, + diagnostic, + }, + }) + 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. - // Prefer insert-only creation; falls back to update-only seeding elsewhere if needed - await createVaultRow(newVaultId, validatedController, null) - - // 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, + updateVaultControllers, + createAndSubmitSeedingIntention, + logger, + }, + }) } // Check for expiry @@ -1091,8 +1277,6 @@ export async function initializeProposer() { logger.info('Initializing proposer module...') - // Note: Vault-controller mapping is created from on-chain VaultCreated events - // Initialize wallet and contract await initializeWalletAndContract() @@ -1121,11 +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. - */ -// Removed seedProposerVaultMapping(); creation is handled via on-chain events +// 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 diff --git a/src/utils/deposits.ts b/src/utils/deposits.ts new file mode 100644 index 0000000..a25aa2f --- /dev/null +++ b/src/utils/deposits.ts @@ -0,0 +1,217 @@ +/** + * ╔═══════════════════════════════════════════════════════════════════════════╗ + * ║ 🌪️ 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() + } +} + +/** + * 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() + } +} diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts new file mode 100644 index 0000000..cea03d7 --- /dev/null +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -0,0 +1,120 @@ +/** + * AssignDeposit intention handler + */ + +import type { + Intention, + ExecutionObject, + IntentionInput, + IntentionOutput, +} from '../../types/core.js' + +type AssignDepositContext = { + validateAssignDepositStructure: (intention: Intention) => Promise + discoverAndIngestErc20Deposits: (args: { + token: string + chainId: number + fromBlockHex?: string + toBlockHex?: string + }) => Promise + discoverAndIngestEthDeposits: (args: { + chainId: number + fromBlockHex?: string + toBlockHex?: string + }) => Promise + 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 } +} + +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({ + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } else { + await context.discoverAndIngestErc20Deposits({ + token: input.asset, + chainId: input.chain_id, + fromBlockHex, + toBlockHex, + }) + } + + 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}` + ) + } + + 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..4da2017 --- /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 + updateVaultControllers: ( + 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.updateVaultControllers(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 + } +} diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 1ed1262..4ac4be2 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -437,3 +437,150 @@ 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)) + } +} diff --git a/test/integration/deposits.db.test.ts b/test/integration/deposits.db.test.ts new file mode 100644 index 0000000..d7789e9 --- /dev/null +++ b/test/integration/deposits.db.test.ts @@ -0,0 +1,192 @@ +/** + * 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, + getDepositRemaining, + findDepositWithSufficientRemaining, + createAssignmentEventTransactional, +} 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('sufficient-remaining selection and full assignment event', 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 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: '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') + }) +})