Skip to content

Conversation

@dkuthoore
Copy link
Collaborator

@dkuthoore dkuthoore commented Oct 28, 2025

Summary

This PR adds support for AssignDeposit intentions, working towards the end-to-end vault seeding flow. The node can now discover on-chain deposits (ERC-20 and ETH, both internal (from contracts) and external(from EOAs), ingest them into a new deposits table, validate and match deposits to AssignDeposit intentions, and credit vault balances at publish time while marking deposits as assigned. The flow is fully auditable and prevents double assignment.

Changes

  • Database

    • Added deposits table and indexes:
      • Columns: id, tx_hash, transfer_uid (UNIQUE), chain_id, depositor (lowercased), token (ERC-20 address or 0x0 for ETH), amount (wei), credited_vault, assigned_at.
    • Updated schema verification to include deposits.
  • Utilities (DB)

    • src/utils/deposits.ts:
      • insertDepositIfMissing: Idempotent insert by transfer_uid.
      • findExactUnassignedDeposit: Select an exact, unassigned deposit by depositor, token, amount, chain_id.
      • markDepositAssigned: Atomically flip assigned_at and persist credited_vault.
  • Proposer: Chain introspection and vault validation

    • validateVaultIdOnChain(vaultId): Ensures 1 <= vaultId < nextVaultId() using VaultTracker.nextVaultId().
    • Introduced shared block-range helper and a 7-day default lookback (~50,400 blocks) for discovery.
  • Deposit discovery and ingestion

    • ERC-20: discoverAndIngestErc20Deposits({ controller, token, chainId, fromBlockHex?, toBlockHex? }) via Alchemy getAssetTransfers with category: ['erc20'] and contractAddresses: [token].
    • ETH: discoverAndIngestEthDeposits({ controller, chainId, fromBlockHex?, toBlockHex? }) via Alchemy getAssetTransfers with category: ['internal', 'external'] (supports deposits from contracts and EOAs).
    • Generic helper consolidates block-range and ingestion logic; ERC-20/ETH functions delegate to it.
    • Each transfer is stored with a stable transfer_uid (Alchemy uniqueId if present; otherwise a deterministic fallback) to ensure idempotency.
  • AssignDeposit validation and handler

    • validateAssignDepositStructure(intention):
      • Enforces 1:1 inputs[i]outputs[i] mapping.
      • Enforces per-index equality of asset, amount, chain_id.
      • Requires outputs[i].to (no to_external) and validates the vault on-chain.
      • Enforces zero fees: all totalFee amounts are "0"; proposerTip and protocolFee empty; agentTip absent or empty.
      • Authorization is implied by discovery/selection: depositor must equal the recovered controller.
    • handleIntention (AssignDeposit branch):
      • Parses optional fromBlock/toBlock hints from inputs[i].data (hex).
      • Discovers deposits (ERC-20 or ETH) for the controller into VaultTracker.
      • Selects one exact unassigned deposit per input (no splitting/aggregation).
      • Builds proof entries: { token, to, amount, deposit_id, depositor }.
      • Caches execution with from: 0 sentinel (no source vault balance is decremented for assignment).
  • Publish-time assignment and crediting

    • In publishBundle, for AssignDeposit executions:
      • markDepositAssigned(deposit_id, to) to atomically assign the deposit row (guards against double assignment).
      • Credit the destination vault’s balance by amount for token.
    • Other intention types continue to use updateBalances(from, to, token, amount).
  • Tests

    • test/integration/deposits.db.test.ts:
      • Verifies idempotent insert via transfer_uid.
      • Verifies exact-match selection and that assigned deposits cannot be re-selected.
      • Verifies assignment persists credited_vault and sets assigned_at.

Notes

  • AssignDeposit allows assigning to any valid vault ID (not necessarily controlled by the depositor).
  • Depositor authorization is enforced by checking the on-chain transfer origin (discovery) against the intention controller.
  • No partial assignment in this version; each input maps to exactly one deposit row of the same amount.
  • Default lookback uses a constant (~50,400 blocks ≈ 7 days). Can be made configurable later.
  • Uses Alchemy’s decoded getAssetTransfers for reliability, pagination (pageKey), and structured data across ERC-20 and ETH.

Next Steps

  • the proposer.ts file is getting really large, especially with the specific functions for handling createVault and AssignDeposit intentions. My plan is to extract the logic for handling specific intentions into a dedicated directory, so the proposer.ts file is cleaner and more easily readable. This aligns with our task of defining our preliminary Intention types, we will have dedicated files for handling each of them.

@dkuthoore dkuthoore changed the title Dk/assign deposit Assign Deposit Handling Oct 28, 2025
@dkuthoore dkuthoore changed the title Assign Deposit Handling [WIP] Assign Deposit Handling Oct 28, 2025
@dkuthoore
Copy link
Collaborator Author

Latest commit changes:

Extract intention-specific logic from proposer into handlers

  • Extracted the CreateVault and AssignDeposit handling logic from src/proposer.ts into dedicated handler modules.
  • New files:
    • src/utils/intentionHandlers/CreateVault.ts: encapsulates vault creation, event parsing, controller mapping, and seeding trigger.
    • src/utils/intentionHandlers/AssignDeposit.ts: encapsulates validation, on-chain deposit discovery (ERC-20 and ETH), exact-match selection, and proof building.
  • src/proposer.ts now delegates to these handlers, improving readability and separation of concerns while preserving existing behavior (including publish-time assignment and balance updates).

@dkuthoore dkuthoore marked this pull request as ready for review October 29, 2025 19:00
@dkuthoore dkuthoore requested a review from pemulis as a code owner October 29, 2025 19:00
depositor TEXT NOT NULL,
token TEXT NOT NULL,
amount NUMERIC(78, 0) NOT NULL,
credited_vault TEXT,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should remove credited_vault from the deposits table or make it into an array of credited_vaults, since there may be more than one. Probably just remove it since it's redundant and we can build the array from deposit_assignment_events.

-- Create indexes for deposits table
CREATE INDEX IF NOT EXISTS idx_deposits_tx_hash ON deposits(tx_hash);
CREATE INDEX IF NOT EXISTS idx_deposits_depositor ON deposits(depositor, chain_id);
CREATE INDEX IF NOT EXISTS idx_deposits_token ON deposits(token);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might want to include chain_id in this index.

if (!forceDropConfirm) {
console.log(chalk.red('\n⚠️ WARNING: This will DELETE ALL DATA in the following tables:'))
console.log(chalk.red(' - bundles, cids, balances, nonces, proposers, vaults'))
console.log(chalk.red(' - bundles, cids, balances, nonces, proposers'))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line should keep vaults, but also add deposit_assignment_events and deposits.

src/proposer.ts Outdated
*/
export async function validateVaultIdOnChain(vaultId: number): Promise<void> {
// Basic sanity
if (!Number.isInteger(vaultId) || vaultId < 1) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should allow 0 as the vaultId since that's the protocol's vault but disallow any < 0.

}

/**
* Computes block range hex strings for Alchemy getAssetTransfers requests,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we getting locked into an Alchemy dependency here or is this something available in generalized libraries for interacting with nodes? Okay if we accept an Alchemy dependency for the moment, just asking.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i can look into it further but all of these methods should be available and pretty similarly structured on any blockchain node/data provider !

src/proposer.ts Outdated
* and ingests them into the local `deposits` table via idempotent inserts.
*/
async function discoverAndIngestEthDeposits(params: {
controller: string
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See earlier note about leaving controller unspecified and getting all ETH deposits from any address.

src/proposer.ts Outdated

// Note: Vault-controller mapping is created from on-chain VaultCreated events
// Seed the proposer's own vault-to-controller mapping
await seedProposerVaultMapping()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did this get added back in? We should still handle this through creating a vault onchain, and then finding what vault is associated with the proposer's address as a controller.

src/proposer.ts Outdated
* This is crucial for allowing the proposer to sign and submit seeding intentions.
*/
// Removed seedProposerVaultMapping(); creation is handled via on-chain events
async function seedProposerVaultMapping() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See note above.

const input: IntentionInput = intention.inputs[i]
const output: IntentionOutput = intention.outputs[i]

// Optional discovery hints in input.data
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, interesting!

execution: [
{
intention,
from: 0,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why from: 0? Should this be from the proposer's address? May be fine as-is since we're working on the proof and execution payloads as a separate task.

Copy link
Collaborator

@pemulis pemulis Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, nevermind. from is the vault number, so it should be 0, you're right.

@dkuthoore
Copy link
Collaborator Author

Changes since feedback

  • Database

    • Removed credited_vault from deposits (redundant; derived from deposit_assignment_events).
    • Dropped the deposits(credited_vault) index.
    • Updated idx_deposits_token to a composite index on (token, chain_id).
    • Adjusted destructive warning list to: bundles, cids, balances, vaults, proposers, deposits, deposit_assignment_events.
  • Validation

    • Moved validateVaultIdOnChain and validateAssignDepositStructure to src/utils/validator.ts.
    • Allowed vault ID 0 (protocol vault) and disallowed negative IDs.
    • In proposer.ts, added thin wrappers to inject contract dependency into validators.
  • Deposit Discovery

    • Removed controller parameter from:
      • discoverAndIngestDeposits
      • discoverAndIngestErc20Deposits
      • discoverAndIngestEthDeposits
    • Removed fromAddress from Alchemy getAssetTransfers requests; now sweep all transfers to VAULT_TRACKER_ADDRESS within the block range.
    • Added an in-memory per-chain cursor (lastCheckedBlockByChain) that advances to the last scanned block after each run.
  • Proposer

    • Removed seedProposerVaultMapping and its invocation during initialization; rely on on-chain VaultCreated events for vault-controller mapping.
    • Kept proposer clean by delegating validation via wrappers and maintaining discovery logic separation.
  • AssignDeposit Handler

    • Updated context to new discovery signatures (no controller param).
    • Continued to enforce authorization by selecting deposits by depositor (the intention’s controller) and asset/chain when assigning.

}
// Ensure contracts are initialized
// Wrapper to use validator's on-chain vault ID validation with contract dependency
const validateVaultIdOnChain = async (vaultId: number): Promise<void> => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could fully extract these validator functions to utils/validator.js, instead of using base validator functions, but I won't be too fussy!

Copy link
Collaborator

@KagemniKarimu KagemniKarimu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is some serious work! Thank you @dkuthoore :)

const TEST_UID = (n: number) => `${TEST_TX}:${n}`
const CTRL = '0xCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCc'
const TOKEN = '0x1111111111111111111111111111111111111111'
const ZERO = '0x0000000000000000000000000000000000000000'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sidenote to maybe move these to the new test fixtures from #51 in a future PR !

@pemulis pemulis merged commit 0b1c9e3 into main Oct 30, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants