From 9b4751b3022bb0406f59422c61aa28fe358c86ee Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:04:23 -0400 Subject: [PATCH 01/15] add vault seeding feature flag to env vars --- src/config/envSchema.ts | 9 +++++++++ src/types/setup.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/config/envSchema.ts b/src/config/envSchema.ts index 6e97e14..d59b7ac 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 (if true, uses AssignDeposit; if false, uses transfer-based seeding)', + defaultValue: false, + transformer: (value) => value === 'true', + }, { name: 'PROPOSER_KEY', required: true, 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) */ From 96f476c0e162254872f1d261947b5c226088d3ee Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:13:38 -0400 Subject: [PATCH 02/15] replace Transfer-based seeding with AssignDeposit seeding --- .env.example | 4 ++++ src/proposer.ts | 53 +++++++++++++++++++++++-------------------------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 9c39718..dd7a170 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,10 @@ 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 +#VAULT_SEEDING=true + + # ───────────────────────────────────────────────────────────────────────────── # WEBHOOK CONFIGURATION diff --git a/src/proposer.ts b/src/proposer.ts index 54138ec..bc772fd 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -818,19 +818,32 @@ 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() + + // 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 + } + + logger.info( + `Creating AssignDeposit seeding intention for new vault ${newVaultId}...` + ) + + const submitterVaultId = PROPOSER_VAULT_ID.value + const currentNonce = await getVaultNonce(submitterVaultId) + const nextNonce = currentNonce + 1 const inputs: IntentionInput[] = [] const outputs: IntentionOutput[] = [] - const tokenSummary = SEED_CONFIG.map( - (token) => `${token.amount} ${token.symbol}` - ).join(', ') - const action = `Transfer ${tokenSummary} to vault #${newVaultId}` for (const token of SEED_CONFIG) { const tokenDecimals = await getSepoliaTokenDecimals(token.address) @@ -839,7 +852,6 @@ async function createAndSubmitSeedingIntention( inputs.push({ asset: token.address, amount: seedAmount.toString(), - from: PROPOSER_VAULT_ID.value, chain_id: 11155111, // Sepolia }) @@ -851,41 +863,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, + action: 'AssignDeposit', nonce: nextNonce, 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} (nonce: ${nextNonce}, submitter vault: ${submitterVaultId}).` ) } From fe235d2b10f5eca5715b2faab657251e5c4ef5c4 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:23:09 -0400 Subject: [PATCH 03/15] update AssignDeposit handler to get 'from' vaultID from controller address, for execution object. because AssignDeposit intentions do not have a 'from' vaultID --- src/proposer.ts | 1 + src/utils/intentionHandlers/AssignDeposit.ts | 42 +++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index bc772fd..c6f887d 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -991,6 +991,7 @@ async function handleIntention( discoverAndIngestEthDeposits, findDepositWithSufficientRemaining, validateVaultIdOnChain, + getVaultsForController, logger, diagnostic, }, diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index cea03d7..eed6c49 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -29,6 +29,7 @@ type AssignDepositContext = { minAmount: string }) => Promise<{ id: number; remaining: string } | null> validateVaultIdOnChain: (vaultId: number) => Promise + getVaultsForController: (controller: string) => Promise logger: { info: (...args: unknown[]) => void } diagnostic: { info: (...args: unknown[]) => void } } @@ -43,6 +44,40 @@ export async function handleAssignDeposit(params: { await context.validateAssignDepositStructure(intention) + // Determine submitter vault for nonce tracking + // 1. If inputs have `from` field, use that (all inputs must have the same `from` value per validator) + // 2. If no `from` field, determine from controller by querying vaults + let submitterVaultId: number | 0 = 0 + const inputsWithFrom = intention.inputs.filter((input) => input.from !== undefined) + if (inputsWithFrom.length > 0) { + // All inputs should have the same `from` value per validator, but double-check + const fromValues = new Set(inputsWithFrom.map((input) => input.from)) + if (fromValues.size > 1) { + throw new Error( + 'AssignDeposit requires all inputs to have the same `from` vault ID' + ) + } + submitterVaultId = inputsWithFrom[0].from as number + } else { + // No `from` field in inputs, determine from controller + const vaults = await context.getVaultsForController(validatedController) + if (vaults.length === 1) { + submitterVaultId = parseInt(vaults[0]) + } else if (vaults.length > 1) { + // Multiple vaults controlled by this controller - use the first one + context.logger.info( + `Controller ${validatedController} controls multiple vaults, using first vault ${vaults[0]} for nonce tracking` + ) + submitterVaultId = parseInt(vaults[0]) + } else { + // No vaults found - cannot determine submitter vault, use 0 (no nonce update) + context.logger.info( + `Controller ${validatedController} does not control any vaults, using from=0 (no nonce update)` + ) + submitterVaultId = 0 + } + } + const zeroAddress = '0x0000000000000000000000000000000000000000' const proof: unknown[] = [] @@ -104,14 +139,17 @@ export async function handleAssignDeposit(params: { context.diagnostic.info('AssignDeposit intention processed', { controller: validatedController, count: intention.inputs.length, + submitterVaultId, }) - context.logger.info('AssignDeposit cached with proof count:', proof.length) + context.logger.info( + `AssignDeposit cached with proof count: ${proof.length}, submitter vault: ${submitterVaultId}` + ) return { execution: [ { intention, - from: 0, + from: submitterVaultId, proof, signature: validatedSignature, }, From f8155c41a3f671ddfaaa80ff30424e9186d64c2b Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:28:59 -0400 Subject: [PATCH 04/15] add helper functions to deposits util --- src/utils/deposits.ts | 92 ++++++++++++++++++++ src/utils/intentionHandlers/AssignDeposit.ts | 4 +- 2 files changed, 95 insertions(+), 1 deletion(-) 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 eed6c49..9c69801 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -48,7 +48,9 @@ export async function handleAssignDeposit(params: { // 1. If inputs have `from` field, use that (all inputs must have the same `from` value per validator) // 2. If no `from` field, determine from controller by querying vaults let submitterVaultId: number | 0 = 0 - const inputsWithFrom = intention.inputs.filter((input) => input.from !== undefined) + const inputsWithFrom = intention.inputs.filter( + (input) => input.from !== undefined + ) if (inputsWithFrom.length > 0) { // All inputs should have the same `from` value per validator, but double-check const fromValues = new Set(inputsWithFrom.map((input) => input.from)) From 9b20c2bebabeaf71be59a163a19c6933ef656466 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:39:54 -0400 Subject: [PATCH 05/15] create AssignDeposit proof type, implement logic to check one or more deposits for AssignDeposit intention --- src/proposer.ts | 119 +++++++++++++++++++++++++++++++++++++++++----- src/types/core.ts | 12 +++++ 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index c6f887d..90b0b99 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -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) @@ -718,18 +721,112 @@ 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 + + const targetAmount = safeBigInt(proofObj.amount) + let remainingToAssign = targetAmount - // 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) + // 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 + + // If deposit_id exists, try to use it first + if (proofObj.deposit_id !== undefined) { + try { + await createAssignmentEventTransactional( + proofObj.deposit_id, + proofObj.amount, + String(proofObj.to) + ) + + // Credit the destination vault balance + const current = await getBalance(proofObj.to, proofObj.token) + const increment = safeBigInt(proofObj.amount) + const newBalance = current + increment + await updateBalance(proofObj.to, proofObj.token, newBalance) + + // Successfully assigned, move to next proof + continue + } catch (error) { + // Check if error is "Not enough remaining" + if ( + error instanceof Error && + error.message.includes('Not enough remaining') + ) { + logger.warn( + `Deposit ${proofObj.deposit_id} exhausted, falling back to multi-deposit combination for token ${proofObj.token}` + ) + // Fall through to multi-deposit combination path + } else { + // Re-throw other errors + throw error + } + } + } + + // Multi-deposit combination path (for deferred selection or fallback) + 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, + }) + throw new Error( + `Insufficient deposits for token ${proofObj.token}: required ${remainingToAssign.toString()}, available ${totalAvailable}` + ) + } + + 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 { for (const proof of execution.proof) { diff --git a/src/types/core.ts b/src/types/core.ts index 8e582b9..34d50b9 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -84,6 +84,18 @@ 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 + deposit_id?: number // Optional: present when deposit was selected at intention time + depositor: string +} + /** * Execution object that wraps verified intentions before bundling. * Used internally by the proposer to accumulate intentions. From 14cab783fceb040a6cde42f3c107f11ab3c12707 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:46:26 -0400 Subject: [PATCH 06/15] update nonce of vault after publishing bundle, but not for CreateVault intentions --- src/proposer.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/proposer.ts b/src/proposer.ts index 90b0b99..418a1a5 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -582,6 +582,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 if from is 0 (edge case safety) + if (execution.from === 0) { + continue + } + const vaultNonce = execution.intention.nonce const vault = execution.from const updateResult = await pool.query( From 7cb08d01e8f4f70ab957d8c6c828434112393be5 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:50:59 -0400 Subject: [PATCH 07/15] add failure log for seeding of new vaults, resilient to failed seeding --- src/utils/intentionHandlers/CreateVault.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/utils/intentionHandlers/CreateVault.ts b/src/utils/intentionHandlers/CreateVault.ts index 4da2017..0aefdf8 100644 --- a/src/utils/intentionHandlers/CreateVault.ts +++ b/src/utils/intentionHandlers/CreateVault.ts @@ -55,8 +55,17 @@ 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) + } 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 From 1a25e18a1f78044fb15a9be775cab25fe6b23da4 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 17:56:30 -0400 Subject: [PATCH 08/15] add informative logging --- src/proposer.ts | 45 +++++++++++++++++----- src/utils/intentionHandlers/CreateVault.ts | 3 ++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 418a1a5..7e48375 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -765,7 +765,10 @@ async function publishBundle(data: string, signature: string, from: string) { const newBalance = current + increment await updateBalance(proofObj.to, proofObj.token, newBalance) - // Successfully assigned, move to next proof + // Successfully assigned, log and move to next proof + logger.info( + `Vault ${proofObj.to} seeded successfully: ${proofObj.amount} ${proofObj.token} assigned from deposit ${proofObj.deposit_id}` + ) continue } catch (error) { // Check if error is "Not enough remaining" @@ -774,7 +777,7 @@ async function publishBundle(data: string, signature: string, from: string) { error.message.includes('Not enough remaining') ) { logger.warn( - `Deposit ${proofObj.deposit_id} exhausted, falling back to multi-deposit combination for token ${proofObj.token}` + `Deposit ${proofObj.deposit_id} exhausted for token ${proofObj.token}, falling back to multi-deposit combination (required: ${proofObj.amount})` ) // Fall through to multi-deposit combination path } else { @@ -785,6 +788,14 @@ async function publishBundle(data: string, signature: string, from: string) { } // Multi-deposit combination path (for deferred selection or fallback) + // This path is used when deposit_id is undefined (deferred selection) or + // when the specified deposit_id was exhausted (fallback from catch block) + const isDeferredSelection = proofObj.deposit_id === undefined + if (isDeferredSelection) { + logger.info( + `Using deferred deposit selection for token ${proofObj.token}, vault ${proofObj.to}: combining deposits to fulfill ${proofObj.amount}` + ) + } let depositsCombined = 0 let totalCredited = 0n @@ -802,9 +813,16 @@ async function publishBundle(data: string, signature: string, from: string) { token: proofObj.token, chain_id: chainId, }) - throw new Error( - `Insufficient deposits for token ${proofObj.token}: required ${remainingToAssign.toString()}, available ${totalAvailable}` - ) + 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) @@ -836,6 +854,10 @@ async function publishBundle(data: string, signature: string, from: string) { logger.info( `Combined ${depositsCombined} deposits to fulfill ${proofObj.amount} ${proofObj.token} for vault ${proofObj.to}` ) + } else if (depositsCombined === 1) { + logger.info( + `Vault ${proofObj.to} seeded successfully: ${proofObj.amount} ${proofObj.token} assigned via deferred deposit selection` + ) } } } else { @@ -941,14 +963,19 @@ async function createAndSubmitSeedingIntention( return } - logger.info( - `Creating AssignDeposit seeding intention for new vault ${newVaultId}...` - ) - const submitterVaultId = PROPOSER_VAULT_ID.value const currentNonce = await getVaultNonce(submitterVaultId) const nextNonce = currentNonce + 1 + // Build token summary for logging + const tokenSummary = SEED_CONFIG.map( + (token) => `${token.amount} ${token.symbol || token.address}` + ).join(', ') + + logger.info( + `Seeding requested for vault ${newVaultId}: controller=${PROPOSER_ADDRESS}, submitterVaultId=${submitterVaultId}, nonce=${nextNonce}, tokens=[${tokenSummary}]` + ) + const inputs: IntentionInput[] = [] const outputs: IntentionOutput[] = [] diff --git a/src/utils/intentionHandlers/CreateVault.ts b/src/utils/intentionHandlers/CreateVault.ts index 0aefdf8..3baa346 100644 --- a/src/utils/intentionHandlers/CreateVault.ts +++ b/src/utils/intentionHandlers/CreateVault.ts @@ -59,6 +59,9 @@ export async function handleCreateVault(params: { // 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}:`, From 993cff469a2da0e5fb841a5dda7ec1a1552db8a9 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 18:05:36 -0400 Subject: [PATCH 09/15] add/update tests --- test/integration/deposits.db.test.ts | 178 +++++++++ .../integration/seeding.assignDeposit.test.ts | 377 ++++++++++++++++++ 2 files changed, 555 insertions(+) create mode 100644 test/integration/seeding.assignDeposit.test.ts 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..f2a8f57 --- /dev/null +++ b/test/integration/seeding.assignDeposit.test.ts @@ -0,0 +1,377 @@ +/** + * 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 updates submitter vault nonce at publish', 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)] + ) + expect(initialNonce.rows[0].nonce).toBe(0) + + // Simulate AssignDeposit intention with nonce = currentNonce + 1 + const intentionNonce = initialNonce.rows[0].nonce + 1 + + // Simulate assignment and nonce update at publish + const deposit = await findNextDepositWithAnyRemaining({ + depositor: PROPOSER_CONTROLLER, + token: TOKEN, + chain_id: SEPOLIA_CHAIN_ID, + }) + + await createAssignmentEventTransactional( + deposit!.id, + '1000', + String(NEW_VAULT_ID) + ) + + // Update nonce (simulating publishBundle behavior) + await pool.query('UPDATE vaults SET nonce = $2 WHERE vault = $1', [ + String(PROPOSER_VAULT_ID), + intentionNonce, + ]) + + // Verify nonce was updated + const updatedNonce = await pool.query( + 'SELECT nonce FROM vaults WHERE vault = $1', + [String(PROPOSER_VAULT_ID)] + ) + expect(updatedNonce.rows[0].nonce).toBe(intentionNonce) + expect(updatedNonce.rows[0].nonce).toBe(1) + }) + + 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) + }) +}) From 44561d0e3803e93ace906f64300b320cc2ba6577 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 18:09:01 -0400 Subject: [PATCH 10/15] update README with seeding details --- README.md | 51 +++++++++++++++++++- src/config/seedingConfig.ts | 12 ++++- src/utils/intentionHandlers/AssignDeposit.ts | 16 ++++++ src/utils/intentionHandlers/CreateVault.ts | 9 ++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a9c56c4..6b1c247 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`. @@ -155,7 +159,52 @@ 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. + +### 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 + +### Configuration + +Enable vault seeding by setting in your `.env`: + +```ini +VAULT_SEEDING=true +PROPOSER_VAULT_ID=1 # Your proposer's vault ID +``` + +The tokens and amounts to seed are configured in `src/config/seedingConfig.ts` via the `SEED_CONFIG` array. Each entry specifies: +- `address`: ERC20 token contract address +- `amount`: Amount to seed (as a decimal string, e.g., "1000.0") +- `symbol`: Token symbol for logging (optional) + +### 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 `SEED_CONFIG` +3. The deposits are on the same chain as configured (default: Sepolia, chain ID 11155111) + +### Seeding Behavior + +- **Disabled (`VAULT_SEEDING=false`):** No seeding occurs. Vaults are created without initial balances. +- **Enabled (`VAULT_SEEDING=true`):** Automatic seeding occurs for all newly created vaults. If seeding fails, vault creation still succeeds. ## Filecoin Pin Setup (Optional) 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/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index 9c69801..aa63b23 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -1,5 +1,21 @@ /** * 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) + * - Determines submitter vault for nonce tracking: + * - If inputs have `from` field: uses that vault ID + * - If no `from` field: queries vaults controlled by the controller + * - If no vaults found: uses from=0 (no nonce update) + * - Sets execution.from to the submitter vault ID for proper nonce tracking + * + * 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 { diff --git a/src/utils/intentionHandlers/CreateVault.ts b/src/utils/intentionHandlers/CreateVault.ts index 3baa346..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' From 61700a7a2f70650c70dae79f9661540f31b95301 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Fri, 31 Oct 2025 18:16:26 -0400 Subject: [PATCH 11/15] update README further --- README.md | 43 +++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 6b1c247..dfce17a 100644 --- a/README.md +++ b/README.md @@ -142,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. @@ -165,6 +164,20 @@ The `deposits` table records raw on-chain deposits, and `deposit_assignment_even 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. @@ -180,32 +193,6 @@ When enabled, the node automatically seeds newly created vaults with initial tok - 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 -### Configuration - -Enable vault seeding by setting in your `.env`: - -```ini -VAULT_SEEDING=true -PROPOSER_VAULT_ID=1 # Your proposer's vault ID -``` - -The tokens and amounts to seed are configured in `src/config/seedingConfig.ts` via the `SEED_CONFIG` array. Each entry specifies: -- `address`: ERC20 token contract address -- `amount`: Amount to seed (as a decimal string, e.g., "1000.0") -- `symbol`: Token symbol for logging (optional) - -### 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 `SEED_CONFIG` -3. The deposits are on the same chain as configured (default: Sepolia, chain ID 11155111) - -### Seeding Behavior - -- **Disabled (`VAULT_SEEDING=false`):** No seeding occurs. Vaults are created without initial balances. -- **Enabled (`VAULT_SEEDING=true`):** Automatic seeding occurs for all newly created vaults. If seeding fails, vault creation still succeeds. - ## 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. From 17c748cdfc2c20e0bfae37c9e4b8df058c10ff5d Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Mon, 3 Nov 2025 15:01:25 -0500 Subject: [PATCH 12/15] update env example --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index dd7a170..463b94f 100644 --- a/.env.example +++ b/.env.example @@ -59,7 +59,8 @@ PROPOSER_KEY=0xYourPrivateKeyHere PROPOSER_VAULT_ID=1 # Feature flag for automatically seeding new vaults with OyaTest Tokens -#VAULT_SEEDING=true +# Default: false (disabled). Set to true to enable automatic vault seeding. +VAULT_SEEDING=false From b9ae69ede50c80fdd90f9314d02d31f9ad108769 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Mon, 3 Nov 2025 15:04:54 -0500 Subject: [PATCH 13/15] remove depositID from proof interface, simplify deposit matching in proposer --- src/proposer.ts | 52 ++------------------ src/types/core.ts | 1 - src/utils/intentionHandlers/AssignDeposit.ts | 1 - 3 files changed, 5 insertions(+), 49 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 7e48375..223c51d 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -750,52 +750,10 @@ async function publishBundle(data: string, signature: string, from: string) { output.asset.toLowerCase() === proofObj.token.toLowerCase() )?.chain_id || 11155111 // Default to Sepolia if not found - // If deposit_id exists, try to use it first - if (proofObj.deposit_id !== undefined) { - try { - await createAssignmentEventTransactional( - proofObj.deposit_id, - proofObj.amount, - String(proofObj.to) - ) - - // Credit the destination vault balance - const current = await getBalance(proofObj.to, proofObj.token) - const increment = safeBigInt(proofObj.amount) - const newBalance = current + increment - await updateBalance(proofObj.to, proofObj.token, newBalance) - - // Successfully assigned, log and move to next proof - logger.info( - `Vault ${proofObj.to} seeded successfully: ${proofObj.amount} ${proofObj.token} assigned from deposit ${proofObj.deposit_id}` - ) - continue - } catch (error) { - // Check if error is "Not enough remaining" - if ( - error instanceof Error && - error.message.includes('Not enough remaining') - ) { - logger.warn( - `Deposit ${proofObj.deposit_id} exhausted for token ${proofObj.token}, falling back to multi-deposit combination (required: ${proofObj.amount})` - ) - // Fall through to multi-deposit combination path - } else { - // Re-throw other errors - throw error - } - } - } - - // Multi-deposit combination path (for deferred selection or fallback) - // This path is used when deposit_id is undefined (deferred selection) or - // when the specified deposit_id was exhausted (fallback from catch block) - const isDeferredSelection = proofObj.deposit_id === undefined - if (isDeferredSelection) { - logger.info( - `Using deferred deposit selection for token ${proofObj.token}, vault ${proofObj.to}: combining deposits to fulfill ${proofObj.amount}` - ) - } + // 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 @@ -856,7 +814,7 @@ async function publishBundle(data: string, signature: string, from: string) { ) } else if (depositsCombined === 1) { logger.info( - `Vault ${proofObj.to} seeded successfully: ${proofObj.amount} ${proofObj.token} assigned via deferred deposit selection` + `Vault ${proofObj.to} assigned successfully: ${proofObj.amount} ${proofObj.token} assigned from deposit` ) } } diff --git a/src/types/core.ts b/src/types/core.ts index 34d50b9..38b97ab 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -92,7 +92,6 @@ export interface AssignDepositProof { token: string to: number amount: string - deposit_id?: number // Optional: present when deposit was selected at intention time depositor: string } diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index aa63b23..ee521ce 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -149,7 +149,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, }) } From b51a68345582d2a06a4d3a4e601cce2d71f80627 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Mon, 3 Nov 2025 15:13:04 -0500 Subject: [PATCH 14/15] ensure from and nonce = 0 for AssignDeposit intentions --- src/proposer.ts | 16 +++--- src/utils/intentionHandlers/AssignDeposit.ts | 52 +++---------------- .../integration/seeding.assignDeposit.test.ts | 25 ++++----- 3 files changed, 24 insertions(+), 69 deletions(-) diff --git a/src/proposer.ts b/src/proposer.ts index 223c51d..d9314bf 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, @@ -364,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), @@ -587,7 +588,7 @@ async function saveBundleData( continue } - // Skip nonce updates if from is 0 (edge case safety) + // Skip nonce updates for protocol-level actions (from=0, e.g., AssignDeposit) if (execution.from === 0) { continue } @@ -921,17 +922,13 @@ async function createAndSubmitSeedingIntention( return } - const submitterVaultId = PROPOSER_VAULT_ID.value - const currentNonce = await getVaultNonce(submitterVaultId) - const nextNonce = currentNonce + 1 - // Build token summary for logging const tokenSummary = SEED_CONFIG.map( (token) => `${token.amount} ${token.symbol || token.address}` ).join(', ') logger.info( - `Seeding requested for vault ${newVaultId}: controller=${PROPOSER_ADDRESS}, submitterVaultId=${submitterVaultId}, nonce=${nextNonce}, tokens=[${tokenSummary}]` + `Seeding requested for vault ${newVaultId}: controller=${PROPOSER_ADDRESS}, protocol-level AssignDeposit (nonce=0, from=0), tokens=[${tokenSummary}]` ) const inputs: IntentionInput[] = [] @@ -957,7 +954,7 @@ async function createAndSubmitSeedingIntention( const intention: Intention = { action: 'AssignDeposit', - nonce: nextNonce, + nonce: 0, // Protocol-level action: nonce=0 (protocol vault) expiry: Math.floor(Date.now() / 1000) + 300, // 5 minute expiry inputs, outputs, @@ -974,7 +971,7 @@ async function createAndSubmitSeedingIntention( await handleIntention(intention, signature, PROPOSER_ADDRESS) logger.info( - `Successfully submitted AssignDeposit seeding intention for vault ${newVaultId} (nonce: ${nextNonce}, submitter vault: ${submitterVaultId}).` + `Successfully submitted AssignDeposit seeding intention for vault ${newVaultId} (protocol-level: nonce=0, from=0).` ) } @@ -1083,7 +1080,6 @@ async function handleIntention( discoverAndIngestEthDeposits, findDepositWithSufficientRemaining, validateVaultIdOnChain, - getVaultsForController, logger, diagnostic, }, diff --git a/src/utils/intentionHandlers/AssignDeposit.ts b/src/utils/intentionHandlers/AssignDeposit.ts index ee521ce..0903ae2 100644 --- a/src/utils/intentionHandlers/AssignDeposit.ts +++ b/src/utils/intentionHandlers/AssignDeposit.ts @@ -7,11 +7,8 @@ * - Discovers deposits from on-chain events (ERC20 or ETH) * - Selects deposits with sufficient remaining balance * - Supports partial deposit assignments (can combine multiple deposits) - * - Determines submitter vault for nonce tracking: - * - If inputs have `from` field: uses that vault ID - * - If no `from` field: queries vaults controlled by the controller - * - If no vaults found: uses from=0 (no nonce update) - * - Sets execution.from to the submitter vault ID for proper nonce tracking + * - 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 @@ -45,7 +42,6 @@ type AssignDepositContext = { minAmount: string }) => Promise<{ id: number; remaining: string } | null> validateVaultIdOnChain: (vaultId: number) => Promise - getVaultsForController: (controller: string) => Promise logger: { info: (...args: unknown[]) => void } diagnostic: { info: (...args: unknown[]) => void } } @@ -60,41 +56,9 @@ export async function handleAssignDeposit(params: { await context.validateAssignDepositStructure(intention) - // Determine submitter vault for nonce tracking - // 1. If inputs have `from` field, use that (all inputs must have the same `from` value per validator) - // 2. If no `from` field, determine from controller by querying vaults - let submitterVaultId: number | 0 = 0 - const inputsWithFrom = intention.inputs.filter( - (input) => input.from !== undefined - ) - if (inputsWithFrom.length > 0) { - // All inputs should have the same `from` value per validator, but double-check - const fromValues = new Set(inputsWithFrom.map((input) => input.from)) - if (fromValues.size > 1) { - throw new Error( - 'AssignDeposit requires all inputs to have the same `from` vault ID' - ) - } - submitterVaultId = inputsWithFrom[0].from as number - } else { - // No `from` field in inputs, determine from controller - const vaults = await context.getVaultsForController(validatedController) - if (vaults.length === 1) { - submitterVaultId = parseInt(vaults[0]) - } else if (vaults.length > 1) { - // Multiple vaults controlled by this controller - use the first one - context.logger.info( - `Controller ${validatedController} controls multiple vaults, using first vault ${vaults[0]} for nonce tracking` - ) - submitterVaultId = parseInt(vaults[0]) - } else { - // No vaults found - cannot determine submitter vault, use 0 (no nonce update) - context.logger.info( - `Controller ${validatedController} does not control any vaults, using from=0 (no nonce update)` - ) - submitterVaultId = 0 - } - } + // 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[] = [] @@ -156,17 +120,17 @@ export async function handleAssignDeposit(params: { context.diagnostic.info('AssignDeposit intention processed', { controller: validatedController, count: intention.inputs.length, - submitterVaultId, + protocolVault: PROTOCOL_VAULT_ID, }) context.logger.info( - `AssignDeposit cached with proof count: ${proof.length}, submitter vault: ${submitterVaultId}` + `AssignDeposit cached with proof count: ${proof.length}, protocol-level action (from=0)` ) return { execution: [ { intention, - from: submitterVaultId, + from: PROTOCOL_VAULT_ID, proof, signature: validatedSignature, }, diff --git a/test/integration/seeding.assignDeposit.test.ts b/test/integration/seeding.assignDeposit.test.ts index f2a8f57..9e74ff5 100644 --- a/test/integration/seeding.assignDeposit.test.ts +++ b/test/integration/seeding.assignDeposit.test.ts @@ -271,7 +271,7 @@ describe('AssignDeposit seeding flow (DB)', () => { expect(errorThrown).toBe(true) }) - test('Nonce tracking: AssignDeposit updates submitter vault nonce at publish', async () => { + test('Nonce tracking: AssignDeposit does not update vault nonces (protocol-level action)', async () => { // Create deposit await insertDepositIfMissing({ tx_hash: TEST_TX, @@ -290,12 +290,10 @@ describe('AssignDeposit seeding flow (DB)', () => { 'SELECT nonce FROM vaults WHERE vault = $1', [String(PROPOSER_VAULT_ID)] ) - expect(initialNonce.rows[0].nonce).toBe(0) + const initialNonceValue = initialNonce.rows[0].nonce - // Simulate AssignDeposit intention with nonce = currentNonce + 1 - const intentionNonce = initialNonce.rows[0].nonce + 1 - - // Simulate assignment and nonce update at publish + // 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, @@ -308,19 +306,16 @@ describe('AssignDeposit seeding flow (DB)', () => { String(NEW_VAULT_ID) ) - // Update nonce (simulating publishBundle behavior) - await pool.query('UPDATE vaults SET nonce = $2 WHERE vault = $1', [ - String(PROPOSER_VAULT_ID), - intentionNonce, - ]) + // AssignDeposit with from=0 should NOT update any vault nonce + // (saveBundleData skips nonce updates when execution.from === 0) - // Verify nonce was updated - const updatedNonce = await pool.query( + // 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(updatedNonce.rows[0].nonce).toBe(intentionNonce) - expect(updatedNonce.rows[0].nonce).toBe(1) + expect(finalNonce.rows[0].nonce).toBe(initialNonceValue) + expect(finalNonce.rows[0].nonce).toBe(0) }) test('findNextDepositWithAnyRemaining: Returns oldest deposit first', async () => { From 3192aa02a1049278d644cb44ba8167c94570b7e9 Mon Sep 17 00:00:00 2001 From: Damian Kuthoore Date: Mon, 3 Nov 2025 15:19:48 -0500 Subject: [PATCH 15/15] remove transfer-based seeding function and references --- src/config/envSchema.ts | 2 +- src/proposer.ts | 49 ----------------------------------------- 2 files changed, 1 insertion(+), 50 deletions(-) diff --git a/src/config/envSchema.ts b/src/config/envSchema.ts index d59b7ac..c087e65 100644 --- a/src/config/envSchema.ts +++ b/src/config/envSchema.ts @@ -143,7 +143,7 @@ export const envSchema: EnvVariable[] = [ required: false, type: 'boolean', description: - 'Enable vault seeding using AssignDeposit (if true, uses AssignDeposit; if false, uses transfer-based seeding)', + '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', }, diff --git a/src/proposer.ts b/src/proposer.ts index d9314bf..f4550a8 100644 --- a/src/proposer.ts +++ b/src/proposer.ts @@ -483,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.