diff --git a/.env.example b/.env.example index 9c39718..463b94f 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,11 @@ PROPOSER_KEY=0xYourPrivateKeyHere # Create a Vault by calling the VaultTracker 'createVault' function, it will return your VaultID PROPOSER_VAULT_ID=1 +# Feature flag for automatically seeding new vaults with OyaTest Tokens +# Default: false (disabled). Set to true to enable automatic vault seeding. +VAULT_SEEDING=false + + # ───────────────────────────────────────────────────────────────────────────── # WEBHOOK CONFIGURATION diff --git a/README.md b/README.md index a9c56c4..dfce17a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,10 @@ PROPOSER_KEY=your_private_key FILECOIN_PIN_ENABLED=false # Set to true to enable Filecoin pinning FILECOIN_PIN_PRIVATE_KEY=your_filecoin_private_key FILECOIN_PIN_RPC_URL=https://api.calibration.node.glif.io/rpc/v1 # Calibration testnet + +# Optional: Vault seeding configuration +VAULT_SEEDING=false # Set to true to enable automatic vault seeding with AssignDeposit +PROPOSER_VAULT_ID=1 # The internal vault ID used by the proposer for seeding ``` See `.env.example` for a complete list of available configuration options including optional variables like `PORT`, `LOG_LEVEL`, `DATABASE_SSL`, and `DIAGNOSTIC_LOGGER`. @@ -138,9 +142,8 @@ The setup script creates the following tables: - **bundles:** Stores bundle data and nonce. - **cids:** Stores IPFS CIDs corresponding to bundles. - **balances:** Tracks token balances per vault. -- **nonces:** Tracks the latest nonce for each vault. - **proposers:** Records block proposers. -- **vaults:** Maps vault IDs to controller addresses and optional rules. +- **vaults:** Maps vault IDs to controller addresses, optional rules, and nonce tracking. - **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. @@ -155,7 +158,40 @@ 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. +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. + +## Vault Seeding + +When enabled, the node automatically seeds newly created vaults with initial token balances using the `AssignDeposit` intention. This allows new users to receive (testnet) tokens immediately upon vault creation. + + +### Configuration & Prerequisites + +Before enabling vault seeding, ensure: +1. Your `PROPOSER_ADDRESS` has made on-chain deposits to the VaultTracker contract +2. Deposits exist for each token/amount specified in `src/config/seedingConfig.ts` via the `SEED_CONFIG` array. +3. The deposits are on the same chain as configured (default: Sepolia, chain ID 11155111) +4. Your `.env` file contains the following: + +```ini +VAULT_SEEDING=true +PROPOSER_VAULT_ID=1 # Your proposer's vault ID +``` + +### How It Works + +1. **Operational Precondition:** The `PROPOSER_ADDRESS` must have sufficient on-chain deposits for each token specified in `SEED_CONFIG`. These deposits are made directly to the VaultTracker contract on-chain. + +2. **Seeding Flow:** + - When a `CreateVault` intention is processed and a new vault is created on-chain + - If `VAULT_SEEDING=true`, the node automatically creates an `AssignDeposit` intention + - The intention assigns deposits directly from the proposer's on-chain deposits to the new vault + - The seeding intention is bundled and published like any other intention + - At publish time, deposits are assigned and balances are credited to the new vault + +3. **Resilience:** + - Vault creation succeeds even if seeding fails (best-effort seeding) + - If a selected deposit is exhausted between intention time and publish time, the system automatically falls back to combining multiple deposits ## Filecoin Pin Setup (Optional) diff --git a/src/config/envSchema.ts b/src/config/envSchema.ts index 6e97e14..c087e65 100644 --- a/src/config/envSchema.ts +++ b/src/config/envSchema.ts @@ -138,6 +138,15 @@ export const envSchema: EnvVariable[] = [ }, transformer: (value) => parseInt(value), }, + { + name: 'VAULT_SEEDING', + required: false, + type: 'boolean', + description: + 'Enable vault seeding using AssignDeposit. When true, new vaults are automatically seeded with initial token balances via AssignDeposit intentions.', + defaultValue: false, + transformer: (value) => value === 'true', + }, { name: 'PROPOSER_KEY', required: true, diff --git a/src/config/seedingConfig.ts b/src/config/seedingConfig.ts index 798e30f..e763c0b 100644 --- a/src/config/seedingConfig.ts +++ b/src/config/seedingConfig.ts @@ -24,8 +24,16 @@ export const PROPOSER_VAULT_ID = { } /** - * Configuration for the specific ERC20 tokens and amounts to be transferred - * from the proposer's vault to a new user's vault upon creation. + * Configuration for the specific ERC20 tokens and amounts to be assigned + * to new user vaults upon creation via AssignDeposit intentions. + * + * These tokens are assigned directly from on-chain deposits made by PROPOSER_ADDRESS + * to the VaultTracker contract. The proposer must have sufficient deposits for each + * token/amount listed here before seeding will work. + * + * When VAULT_SEEDING=true, a CreateVault intention automatically triggers an + * AssignDeposit intention that assigns these deposits to the new vault. + * * @internal */ export const SEED_CONFIG = [ diff --git a/src/proposer.ts b/src/proposer.ts index 54138ec..f4550a8 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -36,7 +36,7 @@ import { getVaultsForController, updateVaultControllers, } from './utils/vaults.js' -import { PROPOSER_VAULT_ID, SEED_CONFIG } from './config/seedingConfig.js' +import { SEED_CONFIG } from './config/seedingConfig.js' import { validateIntention, validateAddress, @@ -54,6 +54,8 @@ import { sendWebhook } from './utils/webhook.js' import { insertDepositIfMissing, findDepositWithSufficientRemaining, + findNextDepositWithAnyRemaining, + getTotalAvailableDeposits, createAssignmentEventTransactional, } from './utils/deposits.js' import { handleAssignDeposit } from './utils/intentionHandlers/AssignDeposit.js' @@ -64,6 +66,7 @@ import type { ExecutionObject, IntentionInput, IntentionOutput, + AssignDepositProof, } from './types/core.js' const gzip = promisify(zlib.gzip) @@ -361,6 +364,7 @@ async function getLatestNonce(): Promise { * Retrieves the latest nonce for a specific vault from the database. * Returns 0 if no nonce is found for the vault. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars async function getVaultNonce(vaultId: number | string): Promise { const result = await pool.query('SELECT nonce FROM vaults WHERE vault = $1', [ String(vaultId), @@ -479,55 +483,6 @@ async function updateBalance( } } -/** - * Seeds a new vault with initial token balances by transferring them from the - * proposer's vault. - * This is now a fallback/manual method. The primary path is via createAndSubmitSeedingIntention. - */ -/* -async function initializeBalancesForVault(newVaultId: number): Promise { - logger.info( - `Directly seeding new vault (ID: ${newVaultId}) from proposer vault (ID: ${PROPOSER_VAULT_ID.value})...` - ) - - for (const token of SEED_CONFIG) { - try { - const tokenDecimals = await getSepoliaTokenDecimals(token.address) - const seedAmount = parseUnits(token.amount, Number(tokenDecimals)) - - const proposerBalance = await getBalance( - PROPOSER_VAULT_ID.value, - token.address - ) - - if (proposerBalance < seedAmount) { - logger.warn( - `- Insufficient proposer balance for ${token.address}. Have: ${proposerBalance}, Need: ${seedAmount}. Skipping.` - ) - continue - } - - // Use the single, updated function for the transfer - await updateBalances( - PROPOSER_VAULT_ID.value, - newVaultId, - token.address, - seedAmount.toString() - ) - - logger.info( - `- Successfully seeded vault ${newVaultId} with ${token.amount} of token ${token.address}` - ) - } catch (error) { - logger.error( - `- Failed to seed vault ${newVaultId} with token ${token.address}:`, - error - ) - } - } -} -*/ - /** * Records proposer activity in the database. * Updates last_seen timestamp for monitoring. @@ -579,6 +534,16 @@ async function saveBundleData( if (Array.isArray(bundleData.bundle)) { for (const execution of bundleData.bundle) { + // Skip nonce updates for CreateVault (no vault exists yet) + if (execution.intention.action === 'CreateVault') { + continue + } + + // Skip nonce updates for protocol-level actions (from=0, e.g., AssignDeposit) + if (execution.from === 0) { + continue + } + const vaultNonce = execution.intention.nonce const vault = execution.from const updateResult = await pool.query( @@ -718,18 +683,92 @@ 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) { - // Create a transactional assignment event (partial or full) - await createAssignmentEventTransactional( - proof.deposit_id, - proof.amount, - String(proof.to) - ) + // Type assertion: proof should have AssignDepositProof structure + const proofObj = proof as AssignDepositProof - // 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) + const targetAmount = safeBigInt(proofObj.amount) + let remainingToAssign = targetAmount + + // Get chain_id from the corresponding input/output (they should match) + const inputIndex = execution.intention.inputs.findIndex( + (input: IntentionInput) => + input.asset.toLowerCase() === proofObj.token.toLowerCase() + ) + const chainId = + inputIndex >= 0 + ? execution.intention.inputs[inputIndex].chain_id + : execution.intention.outputs.find( + (output: IntentionOutput) => + output.asset.toLowerCase() === proofObj.token.toLowerCase() + )?.chain_id || 11155111 // Default to Sepolia if not found + + // Multi-deposit combination path: always combine deposits at publish time + logger.info( + `Assigning deposits for token ${proofObj.token}, vault ${proofObj.to}: combining deposits to fulfill ${proofObj.amount}` + ) + let depositsCombined = 0 + let totalCredited = 0n + + while (remainingToAssign > 0n) { + const deposit = await findNextDepositWithAnyRemaining({ + depositor: proofObj.depositor, + token: proofObj.token, + chain_id: chainId, + }) + + if (!deposit) { + // No more deposits available - compute total available for error message + const totalAvailable = await getTotalAvailableDeposits({ + depositor: proofObj.depositor, + token: proofObj.token, + chain_id: chainId, + }) + const errorMessage = `Insufficient deposits for token ${proofObj.token}: required ${remainingToAssign.toString()}, available ${totalAvailable}` + logger.error(errorMessage, { + token: proofObj.token, + required: remainingToAssign.toString(), + available: totalAvailable, + vaultId: proofObj.to, + depositor: proofObj.depositor, + chainId, + }) + throw new Error(errorMessage) + } + + const depositRemaining = BigInt(deposit.remaining) + const chunk = + remainingToAssign < depositRemaining + ? remainingToAssign + : depositRemaining + + // Create assignment event for this chunk + await createAssignmentEventTransactional( + deposit.id, + chunk.toString(), + String(proofObj.to) + ) + + remainingToAssign -= chunk + totalCredited += chunk + depositsCombined++ + } + + // Credit the destination vault balance once after all assignments + if (totalCredited > 0n) { + const current = await getBalance(proofObj.to, proofObj.token) + const newBalance = current + totalCredited + await updateBalance(proofObj.to, proofObj.token, newBalance) + } + + if (depositsCombined > 1) { + logger.info( + `Combined ${depositsCombined} deposits to fulfill ${proofObj.amount} ${proofObj.token} for vault ${proofObj.to}` + ) + } else if (depositsCombined === 1) { + logger.info( + `Vault ${proofObj.to} assigned successfully: ${proofObj.amount} ${proofObj.token} assigned from deposit` + ) + } } } else { for (const proof of execution.proof) { @@ -818,19 +857,33 @@ async function updateBalances( /** * Creates and submits a signed intention to seed a new vault with initial tokens. - * This creates an auditable record of the seeding transaction. + * Uses AssignDeposit to assign deposits directly to the new vault. + * Only seeds if VAULT_SEEDING is enabled. */ async function createAndSubmitSeedingIntention( newVaultId: number ): Promise { - logger.info(`Creating seeding intention for new vault ${newVaultId}...`) + const { VAULT_SEEDING } = getEnvConfig() - const inputs: IntentionInput[] = [] - const outputs: IntentionOutput[] = [] + // Skip seeding if VAULT_SEEDING is not enabled + if (!VAULT_SEEDING) { + logger.info( + `Vault seeding is disabled (VAULT_SEEDING=false), skipping seeding for vault ${newVaultId}` + ) + return + } + + // Build token summary for logging const tokenSummary = SEED_CONFIG.map( - (token) => `${token.amount} ${token.symbol}` + (token) => `${token.amount} ${token.symbol || token.address}` ).join(', ') - const action = `Transfer ${tokenSummary} to vault #${newVaultId}` + + logger.info( + `Seeding requested for vault ${newVaultId}: controller=${PROPOSER_ADDRESS}, protocol-level AssignDeposit (nonce=0, from=0), tokens=[${tokenSummary}]` + ) + + const inputs: IntentionInput[] = [] + const outputs: IntentionOutput[] = [] for (const token of SEED_CONFIG) { const tokenDecimals = await getSepoliaTokenDecimals(token.address) @@ -839,7 +892,6 @@ async function createAndSubmitSeedingIntention( inputs.push({ asset: token.address, amount: seedAmount.toString(), - from: PROPOSER_VAULT_ID.value, chain_id: 11155111, // Sepolia }) @@ -851,41 +903,26 @@ async function createAndSubmitSeedingIntention( }) } - const currentNonce = await getVaultNonce(PROPOSER_VAULT_ID.value) - const nextNonce = currentNonce + 1 - const feeAmountInWei = parseUnits('0.0001', 18).toString() - const intention: Intention = { - action: action, - nonce: nextNonce, + action: 'AssignDeposit', + nonce: 0, // Protocol-level action: nonce=0 (protocol vault) expiry: Math.floor(Date.now() / 1000) + 300, // 5 minute expiry inputs, outputs, - totalFee: [ - { - asset: ['ETH'], - amount: '0.0001', - }, - ], - proposerTip: [], // 0 tip for internal seeding - protocolFee: [ - { - asset: '0x0000000000000000000000000000000000000000', // ETH - amount: feeAmountInWei, - chain_id: 11155111, // Sepolia - }, - ], + totalFee: [], // Empty for AssignDeposit + proposerTip: [], // Empty for AssignDeposit + protocolFee: [], // Empty for AssignDeposit } // Proposer signs the intention with its wallet const signature = await wallet.signMessage(JSON.stringify(intention)) // Submit the intention to be processed and bundled - // The controller is the proposer's own address + // The controller is the proposer's own address (who made the deposits) await handleIntention(intention, signature, PROPOSER_ADDRESS) logger.info( - `Successfully submitted seeding intention for vault ${newVaultId}.` + `Successfully submitted AssignDeposit seeding intention for vault ${newVaultId} (protocol-level: nonce=0, from=0).` ) } diff --git a/src/types/core.ts b/src/types/core.ts index 8e582b9..38b97ab 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -84,6 +84,17 @@ export interface Execution { signature?: string } +/** + * Proof structure for AssignDeposit intentions. + * Contains deposit assignment information used at publish time. + */ +export interface AssignDepositProof { + token: string + to: number + amount: string + depositor: string +} + /** * Execution object that wraps verified intentions before bundling. * Used internally by the proposer to accumulate intentions. diff --git a/src/types/setup.ts b/src/types/setup.ts index f82a54f..e63f522 100644 --- a/src/types/setup.ts +++ b/src/types/setup.ts @@ -76,6 +76,8 @@ export interface EnvironmentConfig { PROPOSER_ADDRESS: string /** The internal integer ID of the proposer vault */ PROPOSER_VAULT_ID: number + /** Enable vault seeding using AssignDeposit (default: false) */ + VAULT_SEEDING: boolean /** Private key of the bundle proposer account */ PROPOSER_KEY: string /** Port number for the Express server (default: 3000) */ diff --git a/src/utils/deposits.ts b/src/utils/deposits.ts index a25aa2f..ecd7001 100644 --- a/src/utils/deposits.ts +++ b/src/utils/deposits.ts @@ -139,6 +139,98 @@ export async function findDepositWithSufficientRemaining( return null } +export interface FindDepositWithAnyRemainingParams { + depositor: string + token: string + chain_id: number +} + +/** + * Finds the oldest deposit for a depositor/token/chain with any remaining balance \> 0. + * Returns the first deposit found (oldest by ID) that has remaining \> 0, or null if none found. + */ +export async function findNextDepositWithAnyRemaining( + params: FindDepositWithAnyRemainingParams +): Promise<{ id: number; remaining: string } | null> { + const depositor = params.depositor.toLowerCase() + const token = params.token.toLowerCase() + const chainId = params.chain_id + + 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 + LIMIT 1`, + [depositor, token, chainId] + ) + + if (result.rows.length === 0) { + return null + } + + const row = result.rows[0] + const total = BigInt((row.total as string) ?? '0') + const assigned = BigInt((row.assigned as string) ?? '0') + const remaining = total - assigned + + // Safety check: should never be <= 0 due to HAVING clause, but check anyway + if (remaining <= 0n) { + return null + } + + return { id: row.id as number, remaining: remaining.toString() } +} + +export interface GetTotalAvailableDepositsParams { + depositor: string + token: string + chain_id: number +} + +/** + * Computes the total available (unassigned) amount across all deposits for a depositor/token/chain. + * Returns the sum as a decimal string (wei). + */ +export async function getTotalAvailableDeposits( + params: GetTotalAvailableDepositsParams +): Promise { + const depositor = params.depositor.toLowerCase() + const token = params.token.toLowerCase() + const chainId = params.chain_id + + const result = await pool.query( + `SELECT COALESCE(SUM(remaining), 0) AS total_available + FROM ( + SELECT d.amount::numeric(78,0) - COALESCE(SUM(e.amount)::numeric(78,0), 0) AS remaining + 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 + ) AS remaining_deposits`, + [depositor, token, chainId] + ) + + const totalAvailable = BigInt( + (result.rows[0].total_available as string) ?? '0' + ) + // Ensure non-negative (should never be negative, but safety check) + if (totalAvailable < 0n) { + return '0' + } + return totalAvailable.toString() +} + /** * 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. diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index cea03d7..0903ae2 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -1,5 +1,18 @@ /** * AssignDeposit intention handler + * + * Processes AssignDeposit intentions which assign existing on-chain deposits to vaults. + * + * Key features: + * - Discovers deposits from on-chain events (ERC20 or ETH) + * - Selects deposits with sufficient remaining balance + * - Supports partial deposit assignments (can combine multiple deposits) + * - AssignDeposit is a protocol-level action: always sets execution.from = 0 (protocol vault) + * - Nonces are not relevant for AssignDeposit; conflicts resolved by bundle inclusion order + * + * At publish time, deposits are assigned and balances are credited to destination vaults. + * If a selected deposit is exhausted, the system automatically falls back to combining + * multiple deposits to fulfill the requirement. */ import type { @@ -43,6 +56,10 @@ export async function handleAssignDeposit(params: { await context.validateAssignDepositStructure(intention) + // AssignDeposit is a protocol-level action: always use from=0 (protocol vault) + // Nonces are not relevant for AssignDeposit; conflicts resolved by bundle inclusion order + const PROTOCOL_VAULT_ID = 0 + const zeroAddress = '0x0000000000000000000000000000000000000000' const proof: unknown[] = [] @@ -96,7 +113,6 @@ export async function handleAssignDeposit(params: { token: isEth ? zeroAddress : input.asset, to: output.to as number, amount: input.amount, - deposit_id: match.id, depositor: validatedController, }) } @@ -104,14 +120,17 @@ export async function handleAssignDeposit(params: { context.diagnostic.info('AssignDeposit intention processed', { controller: validatedController, count: intention.inputs.length, + protocolVault: PROTOCOL_VAULT_ID, }) - context.logger.info('AssignDeposit cached with proof count:', proof.length) + context.logger.info( + `AssignDeposit cached with proof count: ${proof.length}, protocol-level action (from=0)` + ) return { execution: [ { intention, - from: 0, + from: PROTOCOL_VAULT_ID, proof, signature: validatedSignature, }, diff --git a/src/utils/intentionHandlers/CreateVault.ts b/src/utils/intentionHandlers/CreateVault.ts index 4da2017..aa2728d 100644 --- a/src/utils/intentionHandlers/CreateVault.ts +++ b/src/utils/intentionHandlers/CreateVault.ts @@ -1,5 +1,14 @@ /** * CreateVault intention handler + * + * Processes CreateVault intentions by: + * 1. Calling the on-chain VaultTracker contract to create the vault + * 2. Parsing the VaultCreated event to get the new vault ID + * 3. Persisting the vault-to-controller mapping in the database + * 4. Optionally scheduling a seeding intention (if VAULT_SEEDING is enabled) + * + * Seeding is best-effort: vault creation succeeds even if seeding fails. + * This allows vaults to be created even when deposits are temporarily unavailable. */ import type { Intention } from '../../types/core.js' @@ -55,8 +64,20 @@ export async function handleCreateVault(params: { // 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) + // 4. Submit an intention to seed it with initial balances (best-effort). + // Seeding failures should not prevent vault creation from succeeding. + try { + await deps.createAndSubmitSeedingIntention(newVaultId) + deps.logger.info( + `Seeding intention scheduled successfully for vault ${newVaultId}` + ) + } catch (seedingError) { + deps.logger.error( + `Seeding scheduling failed for vault ${newVaultId}:`, + seedingError + ) + // Continue - vault creation succeeded, seeding can be retried later if needed + } } catch (error) { deps.logger.error('Failed to process CreateVault intention:', error) throw error diff --git a/test/integration/deposits.db.test.ts b/test/integration/deposits.db.test.ts index d7789e9..870fa95 100644 --- a/test/integration/deposits.db.test.ts +++ b/test/integration/deposits.db.test.ts @@ -15,6 +15,8 @@ import { insertDepositIfMissing, getDepositRemaining, findDepositWithSufficientRemaining, + findNextDepositWithAnyRemaining, + getTotalAvailableDeposits, createAssignmentEventTransactional, } from '../../src/utils/deposits.js' @@ -190,3 +192,179 @@ describe('Partial assignment events (DB)', () => { expect(remaining2).toBe('0') }) }) + +describe('Multi-deposit combination helpers (DB)', () => { + test('findNextDepositWithAnyRemaining: finds oldest deposit with any remaining', async () => { + // Create multiple deposits for the same token + const deposit1 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(200), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '1000', + }) + + const deposit2 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(201), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '500', + }) + + // Should find the oldest (first) deposit + const found = await findNextDepositWithAnyRemaining({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(found?.id).toBe(deposit1.id) + expect(found?.remaining).toBe('1000') + + // Partially assign deposit1 + await createAssignmentEventTransactional(deposit1.id, '300', '10001') + const remaining1 = await getDepositRemaining(deposit1.id) + expect(remaining1).toBe('700') + + // Still finds deposit1 (oldest with remaining > 0) + const found2 = await findNextDepositWithAnyRemaining({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(found2?.id).toBe(deposit1.id) + expect(found2?.remaining).toBe('700') + + // Fully assign deposit1 + await createAssignmentEventTransactional(deposit1.id, '700', '10002') + const remaining1Final = await getDepositRemaining(deposit1.id) + expect(remaining1Final).toBe('0') + + // Now should find deposit2 (oldest remaining) + const found3 = await findNextDepositWithAnyRemaining({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(found3?.id).toBe(deposit2.id) + expect(found3?.remaining).toBe('500') + }) + + test('findNextDepositWithAnyRemaining: returns null when no deposits available', async () => { + const found = await findNextDepositWithAnyRemaining({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(found).toBeNull() + }) + + test('getTotalAvailableDeposits: sums all remaining deposits', async () => { + // Create multiple deposits + const deposit1 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(300), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '1000', + }) + + const deposit2 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(301), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '500', + }) + + const deposit3 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(302), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '200', + }) + + // Total should be sum of all deposits + const total1 = await getTotalAvailableDeposits({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(total1).toBe('1700') // 1000 + 500 + 200 + + // Partially assign deposit1 + await createAssignmentEventTransactional(deposit1.id, '300', '20001') + const total2 = await getTotalAvailableDeposits({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(total2).toBe('1400') // 700 + 500 + 200 + + // Fully assign deposit1 + await createAssignmentEventTransactional(deposit1.id, '700', '20002') + const total3 = await getTotalAvailableDeposits({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(total3).toBe('700') // 0 + 500 + 200 + + // Assign all remaining + await createAssignmentEventTransactional(deposit2.id, '500', '20003') + await createAssignmentEventTransactional(deposit3.id, '200', '20004') + const total4 = await getTotalAvailableDeposits({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(total4).toBe('0') + }) + + test('getTotalAvailableDeposits: returns 0 for non-existent deposits', async () => { + const total = await getTotalAvailableDeposits({ + depositor: '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', + token: TOKEN, + chain_id: 11155111, + }) + expect(total).toBe('0') + }) + + test('getTotalAvailableDeposits: handles multiple deposits with partial assignments', async () => { + // Create deposits with different amounts + const deposit1 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(400), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '1000', + }) + + const deposit2 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(401), + chain_id: 11155111, + depositor: CTRL, + token: TOKEN, + amount: '800', + }) + + // Partially assign both + await createAssignmentEventTransactional(deposit1.id, '600', '30001') + await createAssignmentEventTransactional(deposit2.id, '200', '30002') + + const total = await getTotalAvailableDeposits({ + depositor: CTRL, + token: TOKEN, + chain_id: 11155111, + }) + expect(total).toBe('1000') // 400 + 600 + }) +}) diff --git a/test/integration/seeding.assignDeposit.test.ts b/test/integration/seeding.assignDeposit.test.ts new file mode 100644 index 0000000..9e74ff5 --- /dev/null +++ b/test/integration/seeding.assignDeposit.test.ts @@ -0,0 +1,372 @@ +/** + * Integration tests for AssignDeposit-based vault seeding flow. + * Tests the complete seeding flow including deposit combination and nonce tracking. + */ + +import { + describe, + test, + expect, + beforeAll, + afterAll, + beforeEach, +} from 'bun:test' +import { pool } from '../../src/db.js' +import { + insertDepositIfMissing, + findNextDepositWithAnyRemaining, + getTotalAvailableDeposits, + createAssignmentEventTransactional, +} from '../../src/utils/deposits.js' +import { + createVaultRow, + getVaultsForController, +} from '../../src/utils/vaults.js' + +const TEST_TX = '0xtest-seeding-tx' +const TEST_UID = (n: number) => `${TEST_TX}:${n}` +const PROPOSER_CONTROLLER = '0xDeAdDeAdDeAdDeAdDeAdDeAdDeAdDeAdDeAdDeAd' +const PROPOSER_VAULT_ID = 9999 +const NEW_VAULT_ID = 8888 +const TOKEN = '0x1111111111111111111111111111111111111111' +const SEPOLIA_CHAIN_ID = 11155111 + +beforeAll(async () => { + // Clean up test data + await pool.query('DELETE FROM deposits WHERE tx_hash = $1', [TEST_TX]) + await pool.query('DELETE FROM vaults WHERE vault IN ($1, $2)', [ + String(PROPOSER_VAULT_ID), + String(NEW_VAULT_ID), + ]) + await pool.query( + 'DELETE FROM deposit_assignment_events WHERE deposit_id IN (SELECT id FROM deposits WHERE tx_hash = $1)', + [TEST_TX] + ) + + // Create proposer vault + await createVaultRow(PROPOSER_VAULT_ID, PROPOSER_CONTROLLER, null) +}) + +afterAll(async () => { + // Clean up + await pool.query('DELETE FROM deposits WHERE tx_hash = $1', [TEST_TX]) + await pool.query('DELETE FROM vaults WHERE vault IN ($1, $2)', [ + String(PROPOSER_VAULT_ID), + String(NEW_VAULT_ID), + ]) + await pool.query( + 'DELETE FROM deposit_assignment_events WHERE deposit_id IN (SELECT id FROM deposits WHERE tx_hash = $1)', + [TEST_TX] + ) +}) + +beforeEach(async () => { + // Clean up deposits and assignments, but keep vaults + await pool.query('DELETE FROM deposits WHERE tx_hash = $1', [TEST_TX]) + await pool.query( + 'DELETE FROM deposit_assignment_events WHERE deposit_id IN (SELECT id FROM deposits WHERE tx_hash = $1)', + [TEST_TX] + ) + // Reset proposer vault nonce + await pool.query('UPDATE vaults SET nonce = $2 WHERE vault = $1', [ + String(PROPOSER_VAULT_ID), + 0, + ]) +}) + +describe('AssignDeposit seeding flow (DB)', () => { + test('Happy path: Multi-deposit combination fulfills seeding amount', async () => { + // Create multiple deposits that together fulfill the seeding amount + const deposit1 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(1), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '500', + }) + + const deposit2 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(2), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '300', + }) + + const deposit3 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(3), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '200', + }) + + // Simulate multi-deposit combination: need to assign 1000 total + const targetAmount = BigInt('1000') + let remainingToAssign = targetAmount + const depositIds: number[] = [] + + while (remainingToAssign > 0n) { + const deposit = await findNextDepositWithAnyRemaining({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + + if (!deposit) { + throw new Error('Insufficient deposits') + } + + const depositRemaining = BigInt(deposit.remaining) + const chunk = + remainingToAssign < depositRemaining + ? remainingToAssign + : depositRemaining + + await createAssignmentEventTransactional( + deposit.id, + chunk.toString(), + String(NEW_VAULT_ID) + ) + + depositIds.push(deposit.id) + remainingToAssign -= chunk + } + + // Verify all deposits were used + expect(depositIds.length).toBe(3) + expect(depositIds).toContain(deposit1.id) + expect(depositIds).toContain(deposit2.id) + expect(depositIds).toContain(deposit3.id) + + // Verify deposits are fully assigned + const totalAfter = await getTotalAvailableDeposits({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + expect(totalAfter).toBe('0') + }) + + test('Race condition: Deposit exhausted between intention and publish, fallback succeeds', async () => { + // Create deposits + const deposit1 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(10), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '500', + }) + + const deposit2 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(11), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '500', + }) + + // Simulate: deposit1 was selected at intention time, but was partially consumed + // Assign 300 from deposit1 to another vault (simulating race condition) + await createAssignmentEventTransactional(deposit1.id, '300', '7777') + + // Now try to assign 500 from deposit1 (should fail and fallback) + let fallbackUsed = false + try { + await createAssignmentEventTransactional( + deposit1.id, + '500', + String(NEW_VAULT_ID) + ) + } catch (error) { + // Expected: "Not enough remaining" + if ( + error instanceof Error && + error.message.includes('Not enough remaining') + ) { + fallbackUsed = true + } else { + throw error + } + } + + expect(fallbackUsed).toBe(true) + + // Fallback: use remaining from deposit1 + deposit2 + const remainingFromDeposit1 = BigInt('200') // 500 - 300 + await createAssignmentEventTransactional( + deposit1.id, + remainingFromDeposit1.toString(), + String(NEW_VAULT_ID) + ) + + const remainingNeeded = BigInt('500') - remainingFromDeposit1 // 300 + await createAssignmentEventTransactional( + deposit2.id, + remainingNeeded.toString(), + String(NEW_VAULT_ID) + ) + + // Verify total assigned to new vault is 500 + const assignments = await pool.query( + `SELECT SUM(amount::numeric(78,0)) AS total + FROM deposit_assignment_events + WHERE deposit_id IN ($1, $2) + AND credited_vault = $3`, + [deposit1.id, deposit2.id, String(NEW_VAULT_ID)] + ) + const totalAssigned = BigInt((assignments.rows[0].total as string) ?? '0') + expect(totalAssigned.toString()).toBe('500') + }) + + test('Insufficient deposits: Clear error message with required vs available', async () => { + // Create deposit with insufficient amount + await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(20), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '500', + }) + + const required = BigInt('1000') + const totalAvailable = await getTotalAvailableDeposits({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + + expect(totalAvailable).toBe('500') + expect(BigInt(totalAvailable)).toBeLessThan(required) + + // Verify error message includes both required and available + const deposit = await findNextDepositWithAnyRemaining({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + + expect(deposit).not.toBeNull() + + // Try to assign more than available + let errorThrown = false + try { + await createAssignmentEventTransactional( + deposit!.id, + required.toString(), + String(NEW_VAULT_ID) + ) + } catch (error) { + errorThrown = true + expect(error instanceof Error).toBe(true) + expect(error.message).toContain('Not enough remaining') + } + + expect(errorThrown).toBe(true) + }) + + test('Nonce tracking: AssignDeposit does not update vault nonces (protocol-level action)', async () => { + // Create deposit + await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(30), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '1000', + }) + + // Verify proposer vault exists and get initial nonce + const vaults = await getVaultsForController(PROPOSER_CONTROLLER) + expect(vaults.length).toBeGreaterThan(0) + + const initialNonce = await pool.query( + 'SELECT nonce FROM vaults WHERE vault = $1', + [String(PROPOSER_VAULT_ID)] + ) + const initialNonceValue = initialNonce.rows[0].nonce + + // AssignDeposit is protocol-level: intention has nonce=0 and from=0 + // Simulate assignment (deposit assignment happens, but nonce should not be updated) + const deposit = await findNextDepositWithAnyRemaining({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + + await createAssignmentEventTransactional( + deposit!.id, + '1000', + String(NEW_VAULT_ID) + ) + + // AssignDeposit with from=0 should NOT update any vault nonce + // (saveBundleData skips nonce updates when execution.from === 0) + + // Verify nonce was NOT updated (should remain unchanged) + const finalNonce = await pool.query( + 'SELECT nonce FROM vaults WHERE vault = $1', + [String(PROPOSER_VAULT_ID)] + ) + expect(finalNonce.rows[0].nonce).toBe(initialNonceValue) + expect(finalNonce.rows[0].nonce).toBe(0) + }) + + test('findNextDepositWithAnyRemaining: Returns oldest deposit first', async () => { + // Create deposits in sequence + const deposit1 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(40), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '100', + }) + + const deposit2 = await insertDepositIfMissing({ + tx_hash: TEST_TX, + transfer_uid: TEST_UID(41), + chain_id: SEPOLIA_CHAIN_ID, + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + amount: '200', + }) + + // Should always return deposit1 (oldest by ID) + const found1 = await findNextDepositWithAnyRemaining({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + expect(found1?.id).toBe(deposit1.id) + + // Partially assign deposit1 + await createAssignmentEventTransactional(deposit1.id, '50', '7777') + + // Still returns deposit1 (has remaining) + const found2 = await findNextDepositWithAnyRemaining({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + expect(found2?.id).toBe(deposit1.id) + expect(found2?.remaining).toBe('50') + + // Fully assign deposit1 + await createAssignmentEventTransactional(deposit1.id, '50', '7777') + + // Now returns deposit2 + const found3 = await findNextDepositWithAnyRemaining({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + expect(found3?.id).toBe(deposit2.id) + }) +})