Skip to content

Vault Allocation History REST API Spec #396

@rossgalloway

Description

@rossgalloway

Date: 2026-04-24

Goal: Add a Kong-owned, vault-scoped allocation history endpoint that serves debt reallocations, strategy allocation states, and DOA annotations from Kong REST cache with minimal frontend processing.

Architecture: Kong uses its indexed V3 vault/debt allocator events as the canonical event source, enriches those events with transaction metadata, materializes historical block-end allocation states using archive RPC at each relevant block, joins DOA optimizer records as annotations, then serves a cached timeline through a new REST endpoint.

Tech Stack: Kong monorepo, Bun/TypeScript, Next.js route handlers, Postgres, Redis/Keyv, viem archive RPC clients, Yearn V3 vault/debt allocator ABIs.


1. Background

The yearn.fi add-flow-chart branch has a product/data spec at:

  • yearn.fi/docs/manual-allocation-reallocation-data-spec.md

That spec establishes the desired product behavior:

  • Show a vault-scoped allocation timeline.
  • Explain both DOA optimizer-driven reallocations and manual/operator reallocations.
  • Treat on-chain events as canonical.
  • Treat DOA records as annotations/proposals, not proof of execution.

Ross confirmed two decisions for the Kong version:

  1. Kong has reliable archive RPC access, so Kong should materialize historical block-end states directly.
  2. Add transaction metadata needed for classification: transactionFrom, transactionTo, and inputSelector.

2. Current Kong Context

Relevant files verified in Kong:

  • REST docs: docs/rest.md
  • REST routes:
    • packages/web/app/api/rest/snapshot/[chainId]/[address]/route.ts
    • packages/web/app/api/rest/reports/[chainId]/[address]/route.ts
  • REST refresh scripts:
    • packages/web/app/api/rest/snapshot/refresh-snapshot.ts
    • packages/web/app/api/rest/reports/refresh.ts
    • packages/web/app/api/rest/reports/refresh-historical.ts
  • REST cache helper: packages/web/app/api/rest/cache.ts
  • DB connection: packages/web/app/api/db/index.ts
  • Eventsource migration: packages/db/migrations/sqls/20240214020032-eventsource-up.sql
  • V3 vault snapshot hook and useful projection helpers: packages/ingest/abis/yearn/3/vault/snapshot/hook.ts
  • V3 vault ABI: packages/ingest/abis/yearn/3/vault/abi.ts
  • Debt manager factory hook: packages/ingest/abis/yearn/3/debtManagerFactory/event/hook.ts
  • Debt allocator ABI: packages/ingest/abis/yearn/3/debtAllocator/abi.ts
  • ABI fanout config: config/abis.yaml
  • RPC pool: packages/lib/rpcs.ts

Kong already declares debt allocator indexing:

- abiPath: 'yearn/3/debtAllocator'
  things: {
    label: 'debtAllocator',
    filter: []
  }

Kong already discovers debt allocators from the debt manager factory event:

'event NewDebtAllocator(address indexed allocator, address indexed vault)'

Kong's current evmlog table stores:

chain_id
address
event_name
signature
topics
args
hook
block_number
block_time
log_index
transaction_hash
transaction_index

It does not currently store transaction sender, transaction recipient, or input selector.

3. Ownership Boundaries

Kong owns

  • Raw event lookup for relevant vault/debt allocator events.
  • Transaction metadata enrichment.
  • Historical state materialization via archive RPC.
  • DOA join/annotation.
  • Redis/REST cached shaped default response plus durable Postgres rows for full history/windowed responses.
  • Stable response schema for yearn.fi and other clients.

yearn.fi owns

  • Fetching the Kong endpoint.
  • Rendering chart panels/Sankey/flow views.
  • Optional client-side filtering/display choices.

DOA source owns

  • Proposal/recommendation records.
  • Optimizer metadata like current/target ratios, APR deltas, explanation text, source key/revision.

DOA data must not be treated as an executed allocation unless matched to canonical on-chain evidence.

4. Endpoint

Add a separate REST endpoint:

GET /api/rest/allocation-history/:chainId/:address

Examples:

curl -s https://kong.yearn.fi/api/rest/allocation-history/1/0x6faf8b7ffee3306efcfc2ba9fec912b4d49834c1 | jq
curl -s 'https://kong.yearn.fi/api/rest/allocation-history/1/0x6faf8b7ffee3306efcfc2ba9fec912b4d49834c1?fromBlock=21000000&includeRawEvents=true' | jq

Query params:

Param Type Default Notes
fromBlock number supported from durable state/transition tables in v1 Lower bound for returned timeline. Response must include an anchor state immediately before the window when one exists.
toBlock number latest Public v1 only supports latest; non-latest must return 400 Unsupported query parameter for v1. Internal DB helpers may accept toBlock for refresh/backfill/future window support.
fromTimestamp ISO/string deferred Alternative lower bound. Defer until explicit block-resolution support exists.
limit number 100 transitions Max transitions returned from the materialized timeline. Public v1 hard max: 500 transitions, or 200 when includeRawEvents=true. Values below 1 or above the applicable max must return 400 Invalid limit. The route applies this after loading from Postgres or a shaped cache.
includeRawEvents boolean false Include raw source events for debugging. Raw events must not be embedded in the default Redis value; load them from Postgres/event tables for the returned window. When true, enforce the lower raw-event limit cap.
includeDoa boolean true Include DOA annotations/proposals. V1 may strip these from a shaped response when clients opt out.
refresh boolean false Optional internal/admin-only cache bypass; do not expose publicly unless guarded.

4.1 Cache key policy

V1 must not advertise arbitrary historical windows unless the route can serve them deterministically.

Recommended v1 behavior:

  • Refresh/precompute writes the full materialized per-vault timeline to durable Postgres tables (allocation_state, allocation_transition, and source event/DOA references). Do not require one giant Redis value for full history.
  • Redis v1 has exactly one normative shaped default key:
rest:vault_allocation_history:{chainId}:{vaultLower}:default
  • The :default value is the default public response only: last 100 classified transitions plus current live tail, all referenced states, the anchor state before the window, and DOA annotations by default. It must stay below Kong's existing Redis single-key/MSET hard limit.
  • The public route uses :default only when the request matches default shaping. For fromBlock, allowed larger limit, or includeRawEvents=true, the route should query durable Postgres rows and shape the response on demand.
  • Raw events must not be embedded in the default Redis value. Load raw events for the selected response window from evmlog/source-event queries when includeRawEvents=true.
  • When a route window omits earlier transitions, the response must include every AllocationState referenced by returned transitions plus the anchor state immediately before the first returned transition, so fromStateId references never point to omitted states.
  • toBlock values other than latest should return 400 Unsupported query parameter for v1 unless/until a normalized window cache exists.
  • fromTimestamp should return 400 Unsupported query parameter for v1 until explicit timestamp-to-block resolution is implemented.
  • Future-only: if later adding Redis caches for arbitrary shaped windows, use a separate key with a normalized windowHash. This is not a v1 key and the route must not read it unless that future cache writer exists:
rest:vault_allocation_history:{chainId}:{vaultLower}:window:{windowHash}

Response headers should match current REST behavior:

Cache-Control: public, max-age=900, s-maxage=900, stale-while-revalidate=600
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET,OPTIONS

5. Data Model

5.1 Top-level response

type VaultAllocationTimeline = {
  schemaVersion: 1
  generatedAt: string
  vault: VaultAllocationVault
  range: AllocationHistoryRange
  strategies: AllocationHistoryStrategy[]
  states: AllocationState[]
  transitions: AllocationTransition[]
  pendingDoaProposals?: DoaProposal[]
  events?: AllocationSourceEvent[] // only when includeRawEvents=true
}

5.2 Vault metadata

type VaultAllocationVault = {
  chainId: number
  address: `0x${string}`
  name: string | null
  symbol: string | null
  assetAddress: `0x${string}` | null
  assetSymbol: string | null
  assetDecimals: number | null
}

5.3 Range metadata

type AllocationHistoryRange = {
  fromBlock: number | null
  toBlock: number | 'latest'
  fromTimestampUtc: string | null
  toTimestampUtc: string | null
  defaultWindow: boolean
}

5.4 Strategy directory

type AllocationHistoryStrategy = {
  address: `0x${string}`
  name: string | null
  firstSeenBlock: number | null
  lastSeenBlock: number | null
  status: 'active' | 'inactive' | 'unknown'
}

5.5 Source events

Every event returned or used internally should normalize to this shape:

type AllocationSourceEvent = {
  id: string // `${chainId}:${transactionHash}:${logIndex}`
  chainId: number
  vaultAddress: `0x${string}`
  sourceAddress: `0x${string}` // vault, debt allocator, manager, etc.
  sourceLabel: 'vault' | 'debtAllocator' | 'debtManagerFactory' | 'unknown'
  eventName: string
  signature: `0x${string}`
  blockNumber: number
  blockTimestamp: number
  blockTimestampUtc: string
  transactionHash: `0x${string}`
  transactionIndex: number
  logIndex: number
  transactionFrom: `0x${string}` | null
  transactionTo: `0x${string}` | null
  inputSelector: `0x${string}` | null // first 4 bytes, e.g. 0x12345678
  strategyAddress?: `0x${string}` | null
  args: Record<string, string | boolean | string[] | null>
}

Event arg normalization rules:

  • All ABI integer-like args, including uint/int values, role masks, block numbers embedded in args, ratios, and enum/change-type values, must be serialized as decimal strings to avoid JS precision loss.
  • Addresses should be checksum-canonicalized where possible.
  • Booleans remain booleans.

5.6 Allocation states

States are the API's canonical chart substrate. In v1, each historical state is the vault allocation at end of block after all relevant transactions in that block have executed. This matches what archive eth_call at blockNumber can actually return.

State IDs must be stable and deterministic:

Historical state: allocation-state:{chainId}:{vaultLower}:block:{blockNumber}
Live-tail state:  allocation-state:{chainId}:{vaultLower}:live:{latestBlockNumber}

Use lowercase vault addresses inside IDs for stability; continue returning checksum addresses in address fields.

type AllocationState = {
  id: string
  chainId: number
  vaultAddress: `0x${string}`
  stateGranularity: 'block_end' | 'latest'
  blockNumber: number | null
  blockTimestamp: number | null
  timestampUtc: string
  transactionHash: `0x${string}` | null
  totalAssets: string
  totalDebt: string
  totalIdle: string | null
  unallocatedBps: number
  sourceEventIds: string[]
  strategies: AllocationStateStrategy[]
}

type AllocationStateStrategy = {
  strategyAddress: `0x${string}`
  name: string | null
  status: 'active' | 'inactive' | 'unknown'
  currentDebt: string
  currentDebtBps: number
  maxDebt: string | null
  maxDebtBps: number | null
  targetDebtRatioBps: number | null
  maxDebtRatioBps: number | null
  activation: string | null
  lastReport: string | null
}

Rules:

  • currentDebtBps = currentDebt / totalAssets * 10_000.
  • unallocatedBps = max(totalAssets - sum(strategy.currentDebt), 0) / totalAssets * 10_000.
  • Use string integers for raw token-unit quantities.
  • Round bps for display only after computing from raw integers.
  • Use totalAssets as denominator to match current UI semantics.
  • If totalAssets == 0, set all bps to 0 and keep raw debts.

5.7 Transitions

Transition IDs must be stable and deterministic:

Historical transition: allocation-transition:{chainId}:{vaultLower}:block:{blockNumber}
Live-tail transition:  allocation-transition:{chainId}:{vaultLower}:live:{latestBlockNumber}

The first historical transition has fromStateId: null. Every later historical transition links to the previous materialized block state. The live-tail transition links from the last historical state to the live-tail state and has sourceEventIds: [], transactionHash: null, and effects: [].

type AllocationTransitionKind =
  | 'doa_proposal'
  | 'doa_execution'
  | 'allocator_execution'
  | 'manual_debt_update'
  | 'manual_config_change'
  | 'report_only_state_change'
  | 'strategy_lifecycle_change'
  | 'bad_debt_purchase'
  | 'current_live_tail'
  | 'unknown'

type ActorClassification = {
  address: `0x${string}` | null
  type:
    | 'doa_keeper'
    | 'debt_allocator_keeper'
    | 'governance'
    | 'management'
    | 'role_manager'
    | 'vault_role_holder'
    | 'externally_owned_account'
    | 'contract'
    | 'unknown'
  label: string | null
}

type AllocationTransitionEffect = {
  kind: AllocationTransitionKind
  sourceEventIds: string[]
  transactionHash: `0x${string}` | null
  transactionFrom: `0x${string}` | null
  transactionTo: `0x${string}` | null
  inputSelector: `0x${string}` | null
  actor: ActorClassification
  summary: string
}

type AllocationTransition = {
  id: string
  kind: AllocationTransitionKind
  fromStateId: string | null
  toStateId: string
  timestampUtc: string
  blockNumber: number | null
  transactionHash: `0x${string}` | null // null when multiple relevant txs contributed to one block-end state
  transactionHashes?: `0x${string}`[]
  transactionFrom: `0x${string}` | null
  transactionTo: `0x${string}` | null
  inputSelector: `0x${string}` | null
  actor: ActorClassification
  sourceEventIds: string[]
  effects: AllocationTransitionEffect[]
  doa?: DoaAnnotation
  summary: string
}

6. Events Needed

6.1 V3 vault events

Required:

Event Why
DebtUpdated(strategy,current_debt,new_debt) Canonical proof that strategy debt changed through vault allocation logic.
StrategyReported(strategy,gain,loss,current_debt,protocol_fees,total_fees,total_refunds) Keeps debt state accurate when reports change observable strategy debt.
StrategyChanged(strategy,change_type) Adds/removes/revokes strategies and controls the visible strategy universe.
UpdatedMaxDebtForStrategy(sender,strategy,new_debt) Manual/config intent and max-debt context.
DebtPurchased(strategy,amount) Bad-debt purchase context. Yearn V3 also emits DebtUpdated, so state is covered without this event, but reason/classification is not.
UpdateDefaultQueue(new_default_queue) Queue context.
UpdateUseDefaultQueue(use_default_queue) Queue behavior context.
RoleSet(account,role) Role classification context.
RoleStatusChanged(role,status) Present in the local V3 vault ABI; needed for role/open-role actor classification if emitted on target chains.
UpdateRoleManager(role_manager) Role-manager classification context.
UpdateAccountant(accountant) Context for vault operations and governance/config changes.

Useful but not required for v1 chart panels:

  • UpdateAutoAllocate(auto_allocate)
  • UpdateMinimumTotalIdle(minimum_total_idle)
  • Shutdown()

RoleStatusChanged exists in the local Kong V3 vault ABI. Include it in the event query and projection model where emitted; if a target deployment does not emit it, the absence should simply degrade role-status classification rather than block timeline generation.

6.2 Debt manager factory events

Required:

Event Why
NewDebtAllocator(allocator,vault) Links each vault to its debt allocator contract.

6.3 Debt allocator events

Kong's checked ABI includes these event names:

  • GovernanceTransferred(previousGovernance,newGovernance)
  • UpdateKeeper(keeper,allowed)
  • UpdateMaxAcceptableBaseFee(newMaxAcceptableBaseFee)
  • UpdateMaxDebtUpdateLoss(newMaxDebtUpdateLoss)
  • UpdateMinimumChange(newMinimumChange)
  • UpdateMinimumWait(newMinimumWait)
  • UpdateStrategyDebtRatios(strategy,newTargetRatio,newMaxRatio,newTotalDebtRatio)

Required for allocation history:

Event Why
UpdateStrategyDebtRatios(strategy,newTargetRatio,newMaxRatio,newTotalDebtRatio) Captures allocator target/max ratio intent, including DOA-driven proposed targets.
UpdateKeeper(keeper,allowed) Classifies keeper-driven allocator updates/executions.
GovernanceTransferred(previousGovernance,newGovernance) Classification and governance context.

Useful config context:

  • UpdateMaxAcceptableBaseFee
  • UpdateMaxDebtUpdateLoss
  • UpdateMinimumChange
  • UpdateMinimumWait

7. Transaction Metadata

7.1 Required fields

For every source event used in allocation history, Kong must expose:

transactionFrom: `0x${string}` | null
transactionTo: `0x${string}` | null
inputSelector: `0x${string}` | null

Rationale:

  • transactionFrom identifies the sender/actor.
  • transactionTo distinguishes direct vault calls from calls to debt allocator, DOA applicator, multisig, modules, etc.
  • inputSelector distinguishes function-level paths when sender/recipient are not enough.

7.2 Recommended storage

Prefer a normalized transaction metadata table instead of duplicating fields into every evmlog row:

CREATE TABLE evmtransaction (
  chain_id int4 NOT NULL,
  transaction_hash text NOT NULL,
  block_number int8 NOT NULL,
  block_time timestamptz NULL,
  "from" text NULL,
  "to" text NULL,
  input_selector text NULL,
  transaction_index int4 NULL,
  lookup_status text NOT NULL DEFAULT 'ok',
  lookup_attempts int4 NOT NULL DEFAULT 0,
  last_lookup_at timestamptz NULL,
  last_error text NULL,
  updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT evmtransaction_status_check CHECK (lookup_status IN ('ok', 'retryable_error', 'not_found')),
  CONSTRAINT evmtransaction_pkey PRIMARY KEY (chain_id, transaction_hash)
);

CREATE INDEX evmtransaction_idx_chain_from ON evmtransaction(chain_id, "from");
CREATE INDEX evmtransaction_idx_chain_to ON evmtransaction(chain_id, "to");

Notes:

  • Only store the first 4 bytes of input as input_selector for this feature.
  • Full tx input can be added later if there is a strong need, but avoid it for v1.
  • Join from evmlog to evmtransaction on (chain_id, transaction_hash).
  • lookup_status values: ok, retryable_error, not_found. Do not let transient RPC failures become permanent null metadata.
  • getMissingTransactionHashes must include hashes with no row and hashes with lookup_status = 'retryable_error' whose last_lookup_at is older than the retry backoff. It must not treat any existing row as permanently complete.
  • Prefer not inserting a row at all for ordinary transient RPC/network failures. If a row is inserted for a failed lookup, store only status/attempt/error metadata until a successful lookup fills from/to/input_selector.
  • Use not_found only for confirmed non-retryable cases after a bounded retry policy, not for single-attempt RPC timeouts.

7.3 Enrichment path

During refresh/precompute:

  1. Query relevant evmlog rows.
  2. Identify transaction hashes missing from evmtransaction or marked retryable_error past the retry backoff.
  3. Fetch each tx with archive/full RPC via viem getTransaction.
  4. On success, upsert metadata with lookup_status = 'ok'; on transient RPC/network failure, either skip inserting a row or upsert lookup_status = 'retryable_error', increment lookup_attempts, and set last_error/last_lookup_at; on confirmed permanent absence after bounded retries, mark lookup_status = 'not_found'.
  5. Continue materialization using the enriched rows, with classifier confidence degraded when metadata is unavailable.

Pseudo-code:

const tx = await rpcs.next(chainId).getTransaction({ hash })
await upsertTransaction({
  chainId,
  transactionHash: hash,
  blockNumber,
  blockTime: blockTimestampSeconds, // insert with to_timestamp($blockTimestampSeconds)
  from: tx.from,
  to: tx.to,
  inputSelector: tx.input && tx.input !== '0x' ? tx.input.slice(0, 10) : null,
  transactionIndex,
})

8. Historical State Reconstruction

8.1 Decision

Use Kong archive RPC to materialize historical block-end states.

Important constraint: archive eth_call with blockNumber returns state at the end of that block, not after a specific transaction/log inside the block. Therefore v1 must define states as block-end states and collapse all relevant vault events in the same block into one materialized state. If product requirements later need exact intra-block post-transaction states, that is a different implementation requiring traces or deterministic event-delta reconstruction; do not imply archive RPC alone can provide it.

Events alone are not enough because:

  • DebtUpdated only gives one strategy's before/after debt.
  • StrategyReported can alter current debt without being a manual allocation command.
  • StrategyChanged changes the visible strategy set.
  • Percentages need historical totalAssets at the same block.
  • Current snapshot table is latest-only.

8.2 State materialization inputs

For each relevant blockNumber that affects a vault:

  1. Build the candidate strategy universe at that block as all ever-seen strategies up to and including the block, not only currently active strategies. Sources should include StrategyChanged, DebtUpdated, DebtPurchased, StrategyReported, UpdatedMaxDebtForStrategy, default queue events, and any allocator ratio events that reference a strategy.
  2. Separately derive each candidate strategy's status at the block (active, revoked, inactive, etc.) from StrategyChanged/queue context. Do not remove a revoked strategy from the RPC read set merely because it is revoked; revoked strategies can still have non-zero historical debt and must be included so strategy debt breakdowns reconcile to vault totalDebt.
  3. Resolve the vault's debt allocator at that block from NewDebtAllocator history.
  4. Project historical role state, role-manager state, role open/closed status, debt allocator keeper state, and queue/default-queue state using only events at or before that block. Do not read these from latest snapshots for historical classification.
  5. Run archive RPC multicalls at blockNumber:
    • vault.totalAssets()
    • vault.totalDebt() if available, otherwise sum strategy debts
    • vault.totalIdle() if available, otherwise compute max(totalAssets - totalDebt, 0) as a fallback and mark the value as computed in implementation logs/tests
    • vault.strategies(strategy) for each known strategy
    • allocator getStrategyTargetRatio(strategy) if allocator exists
    • allocator getStrategyMaxRatio(strategy) if allocator exists
  6. Attach names/metadata from current thing/snapshot data where historical metadata is unavailable.

The existing projectStrategies(chainId, vault, blockNumber, snapshot?) helper is a useful starting point but is not safe as-is for allocation history: it removes strategies on revoke, which can underreport debt if a revoked strategy still has non-zero debt. Add a block-bounded projectCandidateStrategies(...) variant that returns all ever-seen strategies up to the block plus separately computed per-strategy status. projectDebtAllocator(chainId, vault) currently returns latest allocator only; add a block-bounded variant. Queue/default-queue, role, role-status, role-manager, and allocator-keeper projections also need block-bounded variants. The existing extractDebts(...) logic already calls vault strategies(strategy) and allocator ratios, but it currently reads latest state. Add a historical variant that accepts blockNumber and passes blockNumber/blockTag into viem multicall.

8.2.1 Current live tail

The current_live_tail transition is explicit v1 output, not implied by historical event processing.

After materializing all relevant event blocks, refresh must fetch the latest safe block for the chain and materialize one latest state using the same multicall set as historical states, plus vault.totalAssets(), vault.totalDebt(), and vault.totalIdle().

Rules:

  • If there are no historical states, the live-tail transition has fromStateId: null.
  • Otherwise it links from the last historical block state to allocation-state:{chainId}:{vaultLower}:live:{latestBlockNumber}.
  • If latestBlockNumber equals the last materialized historical block and the state is identical, skip the live-tail state/transition to avoid duplicate IDs and redundant panels.
  • Live-tail state has stateGranularity: 'latest', sourceEventIds: [], and transactionHash: null.
  • Live-tail transition has kind: 'current_live_tail', sourceEventIds: [], transactionHash: null, transactionHashes: [], and effects: [].
  • blockNumber, blockTimestamp, and timestampUtc come from the latest block used for the multicall, not from generation time. generatedAt remains the response generation time.

8.3 Event grouping

Group source events into materialized states by block:

chainId + vaultAddress + blockNumber

Within the block group, sort events by transaction_index, then logIndex, and keep all source event ids. Multiple relevant transactions in the same block must map to the same block-end state in v1.

Transitions expose one block-level transition per materialized block-end state. The transition has:

  • a primary kind, chosen by highest-priority effect for backwards compatibility,
  • sourceEventIds spanning all relevant events in the block,
  • transactionHashes spanning all relevant txs in the block,
  • effects[], with one classified effect per transaction/event cluster, including per-effect actor metadata.

This avoids losing information when one block includes, for example, StrategyReported, DebtUpdated, and config events. Do not emit multiple AllocationTransition rows pointing at the same toStateId in v1; use effects[] for that detail.

Do not label block-end states as exact post-transaction states.

8.4 State cache/storage

Recommended Postgres tables:

CREATE TABLE allocation_state (
  chain_id int4 NOT NULL,
  vault_address text NOT NULL,
  materialization_run_id text NOT NULL,
  state_id text NOT NULL,
  state_granularity text NOT NULL DEFAULT 'block_end',
  block_number int8 NULL,
  block_time timestamptz NULL,
  transaction_hash text NULL,
  total_assets numeric NOT NULL,
  total_debt numeric NOT NULL,
  total_idle numeric NULL,
  unallocated_bps numeric NOT NULL,
  strategies jsonb NOT NULL,
  source_event_ids jsonb NOT NULL,
  generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT allocation_state_pkey PRIMARY KEY (chain_id, vault_address, state_id),
  CONSTRAINT allocation_state_granularity_check CHECK (state_granularity IN ('block_end', 'latest'))
);

CREATE INDEX allocation_state_idx_vault_block
  ON allocation_state(chain_id, vault_address, block_number);

CREATE TABLE allocation_transition (
  chain_id int4 NOT NULL,
  vault_address text NOT NULL,
  materialization_run_id text NOT NULL,
  transition_id text NOT NULL,
  kind text NOT NULL,
  from_state_id text NULL,
  to_state_id text NOT NULL,
  block_number int8 NULL,
  block_time timestamptz NULL,
  transaction_hash text NULL,
  transaction_hashes jsonb NULL,
  transaction_from text NULL,
  transaction_to text NULL,
  input_selector text NULL,
  actor jsonb NULL,
  source_event_ids jsonb NOT NULL,
  effects jsonb NOT NULL DEFAULT '[]'::jsonb,
  doa jsonb NULL,
  summary text NOT NULL,
  generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT allocation_transition_pkey PRIMARY KEY (chain_id, vault_address, transition_id)
);

CREATE INDEX allocation_transition_idx_vault_block
  ON allocation_transition(chain_id, vault_address, block_number);

CREATE TABLE allocation_doa_proposal (
  chain_id int4 NOT NULL,
  vault_address text NOT NULL,
  materialization_run_id text NOT NULL,
  proposal_id text NOT NULL,
  proposal_time timestamptz NULL,
  status text NOT NULL,
  matched_transition_id text NULL,
  payload jsonb NOT NULL,
  generated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT allocation_doa_proposal_status_check CHECK (status IN ('pending', 'unmatched', 'stale')),
  CONSTRAINT allocation_doa_proposal_pkey PRIMARY KEY (chain_id, vault_address, proposal_id)
);

CREATE INDEX allocation_doa_proposal_idx_vault_time
  ON allocation_doa_proposal(chain_id, vault_address, proposal_time);

Source-event and DOA persistence contract:

  • allocation_* tables' vault_address values are stored as checksum-canonical addresses from getAddress(...), matching Kong evmlog.address storage and REST DB helper behavior. Lowercase vault addresses are used only in Redis/cache keys and deterministic IDs.
  • Route and refresh queries must canonicalize request vault addresses with getAddress(address) before DB predicates; do not mix lowercase and checksum equality in allocation tables.
  • Raw source events remain canonical in evmlog; do not duplicate large raw event payloads into Redis.
  • allocation_transition.source_event_ids and each effects[].sourceEventIds store deterministic source event ids (${chainId}:${transactionHash}:${logIndex}) that can be resolved back to evmlog plus evmtransaction metadata.
  • If query-time normalization from evmlog is too expensive, add a narrow allocation_source_event_cache table later, but v1 should first rely on evmlog + ids unless benchmarks prove otherwise.
  • Matched DOA annotations may be stored inline in allocation_transition.doa; unmatched/pending/stale proposals must be persisted in allocation_doa_proposal so the route can return pendingDoaProposals without calling yearn.fi.
  • The refresh job owns replacing the rows for one (chainId, vault) in a single materialization run so states, transitions, DOA proposals, and cached default output are generated from consistent inputs.
  • Replacement must be explicit: generate one materialization_run_id per vault refresh, open a DB transaction, delete existing allocation_state, allocation_transition, and allocation_doa_proposal rows for (chain_id, vault_address), insert the complete new row set with the new run id, commit, then write Redis. This prevents stale or duplicate live-tail rows because live-tail IDs include latestBlockNumber and would otherwise accumulate across refreshes.
  • If a future implementation chooses not to delete-and-replace, it must add an active-run filter/table and all Postgres fallback queries must filter to the active materialization_run_id. V1 should use delete-and-replace for simplicity.

Redis should cache only the shaped default response in v1:

rest:vault_allocation_history:{chainId}:{vaultLower}:default

Do not store the full materialized vault timeline or raw event history in one Redis value. Kong's cacheMSet has a 10 MB single-key hard limit, so full history must live in Postgres tables (or, in a future design, in paged/segmented Redis keys). :window:{windowHash} keys are future-only and must not be route dependencies until a matching writer exists.

Durable allocation_state/allocation_transition tables are normative for v1 so fromBlock, larger limit, and includeRawEvents=true can be served without exceeding Redis limits. Redis is an acceleration layer for the default shaped response, not the source of truth for full history.

9. DOA Integration

9.1 DOA role

DOA data is an annotation layer.

DOA records provide:

  • proposal timestamp
  • optimizer observed current ratios
  • optimizer target ratios
  • APR before/after metadata
  • explanation text
  • source key/revision

They do not prove execution.

9.2 Annotation shape

type DoaAnnotation = {
  sourceKey: string
  proposalTimestampUtc: string | null
  optimizerCurrentApr: number | null
  optimizerProposedApr: number | null
  explain: string | null
  strategyTargets: Array<{
    strategyAddress: `0x${string}`
    currentRatioBps: number | null
    targetRatioBps: number | null
    currentApr?: number | null
    targetApr?: number | null
  }>
  confidence: 'exact' | 'high' | 'medium' | 'low'
  matchReason: string
}

type DoaProposal = DoaAnnotation & {
  status: 'pending' | 'unmatched' | 'stale'
}

9.3 Matching rules

Use these signals, strongest first:

  1. Same chainId and vault.
  2. Debt allocator UpdateStrategyDebtRatios target ratios match the DOA target ratios.
  3. Nearby DebtUpdated transaction changes strategy debts in the same direction as DOA targets.
  4. Transaction path matches known DOA applicator/keeper addresses using transactionFrom, transactionTo, and/or inputSelector.
  5. Event timestamp is near proposal timestamp.

Classification examples:

  • DOA proposal matched to allocator ratio event only: kind = 'doa_proposal'.
  • DOA proposal matched to actual DebtUpdated state transition: kind = 'doa_execution'.
  • DOA proposal with no matched on-chain event: include in pendingDoaProposals, do not create an executed state.

Status and aging rules for unmatched DOA proposals:

  • Define these as config constants in the DOA helper, with v1 defaults: executionMatchWindowHours = 24, pendingWindowHours = 72, and staleAfterDays = 30.
  • pending: no matched on-chain event yet and now - proposal_time <= pendingWindowHours.
  • unmatched: no matched on-chain event and pendingWindowHours < now - proposal_time <= staleAfterDays.
  • stale: proposal is older than staleAfterDays, or superseded by a newer proposal for the same vault/strategy target set, or disappeared from the authoritative DOA source during refresh.
  • A proposal can only become matched/inline doa when matching signals meet the confidence threshold within the execution match window. Otherwise it stays proposal-only and must not change allocation state or kind.
  • Persist all three proposal-only statuses in allocation_doa_proposal; the route may default to returning pending/recent unmatched and can include stale proposals only when requested by a future debug flag.

Known DOA keeper/applicator addresses should be config data, not hard-coded into frontend chart code.

10. Classification Rules

Apply classification to transition groups, not individual raw events. Final classification is DOA-aware: run DOA matching before assigning final kind, or run a second finalization pass that upgrades eligible transitions to doa_execution. The classifier must receive matchedDoa/DOA match evidence; otherwise the first rule below cannot fire and matched executions would be mislabeled as allocator/manual activity.

Suggested final rules:

if (matchedDoa && groupHas('DebtUpdated')) return 'doa_execution'
if (groupHas('DebtPurchased')) return 'bad_debt_purchase'
if (groupHas('DebtUpdated') && sourceEventMixIndicatesAllocatorPath()) return 'allocator_execution'
if (groupHas('DebtUpdated')) return 'manual_debt_update'
if (groupHas('StrategyReported')) return 'report_only_state_change'
if (groupHas('StrategyChanged')) return 'strategy_lifecycle_change'
if (groupHasAny(['UpdatedMaxDebtForStrategy', 'UpdateDefaultQueue', 'UpdateUseDefaultQueue', 'RoleSet', 'RoleStatusChanged', 'UpdateRoleManager'])) return 'manual_config_change'
return 'unknown'

Do not classify allocator execution solely with txTo === debtAllocator. Safe/module/applicator paths often make top-level transactionTo a Safe, module, relayer, or applicator even though the allocator/vault path executed downstream. Prefer, in order:

  1. matched DOA proposal/applicator evidence,
  2. configured DOA keeper/applicator actor sets,
  3. historical debt allocator keeper status at the block,
  4. source event mix (UpdateStrategyDebtRatios plus DebtUpdated, allocator/vault source addresses),
  5. input selector when it identifies an allocator/applicator/vault function,
  6. top-level transactionFrom/transactionTo as supporting evidence only.

Actor classification should run at both levels:

  • AllocationTransition.actor is the best top-level summary actor for the block-level transition.
  • Each AllocationTransitionEffect.actor is the actor for that specific transaction/event cluster. This prevents a mixed same-block transition from misleadingly attributing all effects to the same actor.

Actor classification should use:

  • current/historical vault role events where practical
  • debt allocator keeper history
  • configured DOA keeper/applicator addresses
  • governance/role manager addresses
  • transaction metadata

11. Implementation Plan

Task 1: Add transaction metadata migration

Objective: Store transactionFrom, transactionTo, and inputSelector in Kong.

Files:

  • Create: packages/db/migrations/YYYYMMDDHHMMSS-evmtransaction.js
  • Create: packages/db/migrations/sqls/YYYYMMDDHHMMSS-evmtransaction-up.sql
  • Create: packages/db/migrations/sqls/YYYYMMDDHHMMSS-evmtransaction-down.sql

Steps:

  1. Add the evmtransaction table from section 7.2.
  2. Add indexes on (chain_id, from) and (chain_id, to).
  3. Add lookup_status, lookup_attempts, last_lookup_at, and last_error columns so transient RPC failures are retryable and do not become permanent null metadata.
  4. Add down migration that drops indexes/table.
  5. Add the db-migrate JS wrapper under packages/db/migrations/ that loads the up/down SQL files, following packages/db/migrations/20240214020032-eventsource.js.
  6. Verify migration syntax with the repo's DB migration workflow.

Acceptance criteria:

  • Transaction metadata can be upserted once per tx hash.
  • Transient lookup failures are retried by status/backoff and are not treated as completed rows merely because a row exists.
  • The db-migrate wrapper exists, points at the correct SQL files, and is what applies the migration.
  • evmlog can join transaction metadata without schema duplication.

Task 1b: Add allocation-history persistence migrations

Objective: Persist materialized allocation history, matched DOA annotations, and pending DOA proposals so Redis is not the source of truth for full history.

Files:

  • Create: packages/db/migrations/YYYYMMDDHHMMSS-allocation-history.js
  • Create: packages/db/migrations/sqls/YYYYMMDDHHMMSS-allocation-history-up.sql
  • Create: packages/db/migrations/sqls/YYYYMMDDHHMMSS-allocation-history-down.sql

Steps:

  1. Add allocation_state, allocation_transition, and allocation_doa_proposal tables from section 8.4.
  2. Add indexes on (chain_id, vault_address, block_number) for state/transition lookup and (chain_id, vault_address, proposal_time) for pending proposal lookup.
  3. Store matched DOA annotations inline in allocation_transition.doa; store unmatched/pending/stale proposals in allocation_doa_proposal with the status rules from section 9.3.
  4. Store source-event references as deterministic ids in allocation_transition.source_event_ids and effects[].sourceEventIds; resolve raw event payloads from evmlog on demand.
  5. Store vault_address as checksum-canonical getAddress(...) output in allocation tables; reserve lowercase addresses for Redis keys and deterministic IDs.
  6. Include materialization_run_id on all allocation-history tables for audit/debugging, while v1 refresh uses transactionally delete-and-replace semantics for active rows.
  7. Add the db-migrate JS wrapper under packages/db/migrations/, following the existing SQL-wrapper pattern.
  8. Add down migration that drops these tables/indexes.

Acceptance criteria:

  • Full history can be served from Postgres without reading Redis.
  • includeRawEvents=true can resolve returned-window source events from persisted ids plus evmlog/evmtransaction.
  • pendingDoaProposals can be returned without calling yearn.fi or depending on Redis-only DOA history.
  • Allocation table DB predicates use checksum vault_address values, while IDs/cache keys use lowercase addresses.
  • Refresh replacement cannot leave duplicate/stale live-tail rows for a vault after a newer run commits.
  • The migration wrapper exists and points at the correct SQL files.

Task 2: Add transaction metadata DB helpers

Objective: Query and upsert tx metadata from REST refresh code.

Files:

  • Create: packages/web/app/api/rest/allocation-history/transactions.ts

Required functions:

type TransactionMetadata = {
  chainId: number
  transactionHash: `0x${string}`
  blockNumber: number
  blockTime: bigint | number | null // unix seconds; db timestamptz reads are parsed to BigInt seconds in packages/web/app/api/db/index.ts
  from: `0x${string}` | null
  to: `0x${string}` | null
  inputSelector: `0x${string}` | null
  transactionIndex: number | null
  lookupStatus: 'ok' | 'retryable_error' | 'not_found'
  lookupAttempts: number
  lastLookupAt: string | null
  lastError: string | null
}

async function getMissingTransactionHashes(
  chainId: number,
  hashes: `0x${string}`[],
): Promise<`0x${string}`[]>

async function upsertTransactionMetadata(rows: TransactionMetadata[]): Promise<void>

Acceptance criteria:

  • Uses parameterized SQL only.
  • Treats blockTime internally as unix seconds (bigint | number). When inserting into a timestamptz column, use to_timestamp($blockTimeSeconds); when reading through packages/web/app/api/db/index.ts, expect BigInt seconds and convert to API blockTimestamp/timestampUtc explicitly.
  • De-dupes hashes before querying.
  • getMissingTransactionHashes returns hashes with no row plus retryable rows past backoff; it excludes only successful ok rows and confirmed not_found rows.
  • Upsert is idempotent.

Task 3: Add transaction RPC enrichment

Objective: Fetch missing tx metadata during refresh/precompute.

Files:

  • Create: packages/web/app/api/rest/allocation-history/enrich-transactions.ts

Implementation notes:

  • Initialize the RPC pool before use and tear it down in finally:
await rpcs.up()
try {
  // enrichment/materialization work
} finally {
  await rpcs.down()
}
  • Use rpcs.next(chainId) from packages/lib/rpcs.ts if import boundaries allow it from web; otherwise add a web-local RPC helper following the same env vars.
  • Use getTransaction({ hash }).
  • inputSelector = tx.input && tx.input !== '0x' ? tx.input.slice(0, 10) : null.
  • Batch/concurrency-limit calls to avoid RPC bursts.
  • Classify lookup failures: transient RPC/network/timeouts remain retryable; confirmed missing/pruned txs may become not_found only after bounded retries or explicit non-retryable provider response.
  • Do not upsert a metadata row with null from/to/inputSelector and lookup_status = 'ok' after a failed lookup.

Acceptance criteria:

  • Enrichment tolerates missing/pruned txs by marking them not_found only after the retry policy, while transient failures stay retryable.
  • Refresh does not fail the entire vault because one tx metadata lookup fails.
  • Logs failed tx hashes with chainId and status without leaking full calldata or secrets.
  • A later refresh retries retryable_error rows and can upgrade them to ok, improving actor/path classification over time.

Task 4: Add allocation-history event query

Objective: Fetch source events for one vault/range. Support a raw evmlog pass for hash discovery and a normalized pass after transaction metadata enrichment.

Files:

  • Create: packages/web/app/api/rest/allocation-history/db.ts

Query behavior:

  • Raw pass: query relevant evmlog rows without requiring evmtransaction so refresh can collect transaction hashes before enrichment.
  • Normalized pass: after enrichment, join evmtransaction for tx metadata and return AllocationSourceEvent[].
  • V3 vault events: canonicalize the request address with getAddress(address) and query evmlog.address = $vaultChecksum. Do not lowercase DB equality values.
  • Debt allocator events: resolve allocator(s) for the vault from NewDebtAllocator, canonicalize allocator addresses with getAddress, then query evmlog.address = ANY($allocatorChecksumAddresses) for allocator event set.
  • Debt manager factory events: include NewDebtAllocator where the JSON vault arg matches the requested vault. Because JSON args may not be stored with consistent casing, compare as lower(args->>'vault') = lower($vaultChecksum) or normalize args to checksum form during extraction before querying.
  • Join evmtransaction for tx metadata.
  • Order ascending by block_number, transaction_index, log_index.

Acceptance criteria:

  • Redis/cache keys should use lowercase addresses.
  • DB equality predicates should use checksum-canonicalized addresses from getAddress, matching existing ingestion (packages/ingest/extract/evmlogs.ts) and REST helpers.
  • Return checksum-canonicalized addresses in API fields when possible.
  • Returns normalized AllocationSourceEvent[].
  • Public route rejects non-latest toBlock with 400 in v1; internal helpers may accept toBlock for refresh/backfill/future window support.

Task 5: Add historical state materializer

Objective: Convert source event groups into block-end allocation states via archive RPC.

Files:

  • Create: packages/web/app/api/rest/allocation-history/materialize-state.ts

Implementation notes:

  • Group source events by (chainId, vaultAddress, blockNumber) for materialized states.
  • For each block group, determine candidate strategy universe at blockNumber as all ever-seen strategies up to that block, then derive status separately. Do not drop revoked/inactive strategies before reading their historical debt.
  • Resolve debt allocator at blockNumber.
  • Multicall at historical block:
    • totalAssets()
    • totalDebt() if available, otherwise sum strategy debts
    • totalIdle() if available, otherwise compute max(totalAssets - totalDebt, 0) as fallback
    • strategies(strategy) for every candidate ever-seen strategy up to the block, including revoked/inactive candidates
    • allocator target/max ratio methods if allocator exists
  • Build one AllocationState per relevant block using allocation-state:{chainId}:{vaultLower}:block:{blockNumber}.
  • After historical states, materialize the explicit current live tail from the latest safe block using allocation-state:{chainId}:{vaultLower}:live:{latestBlockNumber} unless it would duplicate the final historical state.
  • Link each historical state to source event ids; live-tail state uses sourceEventIds: [].

Acceptance criteria:

  • Historical states use archive block reads, not latest snapshots.
  • Historical states are explicitly block-end states; multiple same-block relevant transactions do not pretend to have distinct post-transaction states.
  • Bps calculations are based on totalAssets at that block.
  • totalDebt and totalIdle are populated from vault reads or explicitly documented computed fallbacks.
  • State IDs are deterministic and match the formats in section 5.6.
  • Zero-total-assets vaults do not throw division errors.
  • Strategy set changes are reflected in later states.
  • Revoked/inactive strategies with non-zero debt remain in the candidate read set and allocation breakdown until their historical debt is zero, so strategy debt can reconcile to vault totalDebt.

Task 6: Add transition classifier

Objective: Classify each state transition and actor.

Files:

  • Create: packages/web/app/api/rest/allocation-history/classify.ts

Acceptance criteria:

  • Produces one block-level AllocationTransition per materialized block-end state and one optional current_live_tail transition.
  • Transition IDs are deterministic and match the formats in section 5.7.
  • Populates effects[] with classified transaction/event-cluster details so mixed same-block activity does not collapse into a misleading single label.
  • Includes tx metadata and actor metadata on each transition/effect when available.
  • Separates doa_execution, allocator_execution, manual_debt_update, manual_config_change, report_only_state_change, strategy_lifecycle_change, bad_debt_purchase, and unknown.
  • Accepts matched DOA evidence before final kind assignment or exposes a finalization pass that can upgrade matched transitions to doa_execution.
  • Classification rules are unit-tested with fixture events.

Task 7: Add DOA source adapter and matcher

Objective: Join DOA proposal data as annotations.

Files:

  • Create: packages/web/app/api/rest/allocation-history/doa.ts

Implementation notes:

  • If DOA records are only available from yearn.fi Redis/API today, add a thin adapter and keep it behind includeDoa.
  • Prefer copying/pulling DOA history into Kong during refresh so clients only call Kong.
  • Match by chain/vault, allocator target-ratio event, debt deltas, tx path, and timestamp proximity.

Acceptance criteria:

  • Matched proposals annotate transitions before final classification.
  • Unmatched proposals are returned as pendingDoaProposals with concrete pending, unmatched, or stale status.
  • DOA proposal alone never creates an executed allocation state.

Task 8: Add refresh/precompute script

Objective: Precompute and cache allocation history per vault.

Files:

  • Create: packages/web/app/api/rest/allocation-history/refresh.ts
  • Create: packages/web/app/api/rest/allocation-history/redis.ts

Behavior:

  1. Initialize archive RPC clients with await rpcs.up() and call await rpcs.down() in a finally block before process exit.
  2. Fetch vault list.
  3. For each vault, fetch raw relevant evmlog rows/source events without requiring transaction metadata.
  4. Collect and de-dupe transaction hashes from the raw events.
  5. Enrich missing tx metadata into evmtransaction.
  6. Re-query or merge normalized source events with joined transaction metadata.
  7. Materialize states via archive RPC.
  8. Match DOA proposals to source-event groups/materialized transitions and persist unmatched/pending/stale proposal rows.
  9. Run final DOA-aware classification for transitions/effects, using matchedDoa evidence where present.
  10. Write durable state/transition rows and source-event/DOA references with transactionally delete-and-replace semantics for the (chainId, vault) row set. This is required for v1 because Redis must not hold full raw history and stale live-tail rows must not accumulate.
  11. Build the shaped default response from durable rows: last 100 classified transitions plus current live tail, all referenced states, the anchor state before the returned window, DOA annotations, and no raw events.
  12. Cache only that shaped default response in Redis using the existing Keyv-compatible wrapper format:
await cacheMSet([[
  key,
  JSON.stringify({ value: timeline }),
]])

Do not write raw JSON directly if the route reads with keyv.get.

Acceptance criteria:

  • Runs for one vault by env args or CLI args for testing.
  • Supports batch size env var similar to existing REST refresh scripts.
  • Writes cache key rest:vault_allocation_history:{chainId}:{vaultLower}:default with the shaped default response and JSON.stringify({ value: timeline }), matching existing REST refresh scripts.
  • Verifies the serialized default cache value is below Kong's Redis MSET hard limit; if it is too large, reduce default shape or skip Redis write and rely on Postgres route path rather than throwing the whole refresh.
  • Non-default windows (fromBlock, large but allowed limit, includeRawEvents=true) are served from durable Postgres rows in v1. :window:{windowHash} Redis keys are future-only and must not be read unless a matching writer is implemented.
  • DOA matching happens before final classification, so matched executions are emitted as doa_execution rather than stale manual/allocator labels.
  • Each vault refresh commits a complete replacement row set for (chainId, vault) or rolls back; it cannot leave old live-tail rows visible alongside the new live tail.
  • Does not block snapshot/report refresh jobs.

Task 9: Add REST route

Objective: Serve cached allocation timelines.

Files:

  • Create: packages/web/app/api/rest/allocation-history/[chainId]/[address]/route.ts

Behavior:

  • Validate chainId and address.
  • Validate limit: default 100, max 500, max 200 when includeRawEvents=true; non-integer, < 1, or over-cap values return 400.
  • For default params, try rest:vault_allocation_history:{chainId}:{vaultLower}:default from Redis first.
  • On Redis miss, oversized-cache skip, or cache parse failure, fall back to durable Postgres state/transition/DOA rows and shape the same default response. Return 404 only if both Redis and Postgres source rows are missing.
  • For fromBlock, allowed larger limit, or includeRawEvents=true, query durable Postgres state/transition/source-event rows and shape the response on demand. Do not rely on the default Redis value for non-default windows.
  • :window:{windowHash} Redis reads are future-only and must be guarded by the existence of a matching writer.
  • When applying fromBlock/limit, include all states referenced by returned transitions plus the anchor state before the first returned transition.
  • Return 404 only when neither Redis nor durable Postgres rows can supply the requested default/windowed response.
  • Support OPTIONS with CORS headers.
  • Use the same cache-control posture as existing REST endpoints.

Acceptance criteria:

  • GET /api/rest/allocation-history/:chainId/:address returns VaultAllocationTimeline.
  • Invalid params return 400.
  • Over-limit requests (limit > 500, or limit > 200 with includeRawEvents=true) return 400 and do not query raw events.
  • Missing Redis plus missing Postgres source rows returns 404; Redis miss alone must not 404 if durable rows exist.
  • Windowed responses never contain dangling fromStateId/toStateId references.
  • CORS works.

Task 10: Add tests and docs

Objective: Verify endpoint behavior and document the API.

Files:

  • Create: packages/web/app/api/rest/allocation-history/*.spec.ts
  • Modify: docs/rest.md

Tests:

  • Classifier unit tests, including DOA-aware finalization where a matched DOA + DebtUpdated becomes doa_execution.
  • Event grouping tests.
  • Bps calculation tests.
  • Strategy universe tests where a revoked/inactive strategy with non-zero debt remains in the historical allocation breakdown.
  • Route param validation/cache-hit/cache-miss tests if existing test harness supports route tests.
  • Limit validation tests for default caps, includeRawEvents=true lower cap, negative/non-integer limits, and over-cap 400 responses.
  • Redis-miss fallback test: default request should shape from Postgres rows when :default is absent.
  • Window shaping tests that verify anchor states and no dangling fromStateId/toStateId references.
  • Cache-size tests or assertions that default Redis values stay below cacheMSet single-key hard limit.
  • Live-tail tests covering ID format, empty source events, duplicate suppression when latest block equals the last historical block, and delete-and-replace behavior that prevents stale live tails after a later refresh.
  • Transaction metadata retry tests where transient failures remain retryable and a later successful lookup upgrades classification metadata.
  • DOA proposal status tests for pending, unmatched, and stale aging/supersession rules.

Docs:

  • Add endpoint to docs/rest.md.
  • Include response schema and one compact example.
  • Note that DOA is annotation-only.

12. Acceptance Criteria

The feature is complete when:

  1. Kong can return a vault-scoped allocation history from:

    GET /api/rest/allocation-history/:chainId/:address
  2. The response includes:

    • vault metadata
    • strategy directory
    • block-end allocation states
    • classified transitions
    • tx metadata: transactionFrom, transactionTo, inputSelector
    • DOA annotations and unmatched/pending proposals when requested
  3. Historical states are computed with archive RPC at the relevant block, not from latest snapshots.

  4. DebtUpdated, DebtPurchased, StrategyReported, StrategyChanged, vault config events, NewDebtAllocator, and debt allocator ratio/keeper events are represented.

  5. DOA proposals do not create executed allocation states unless matched to on-chain events.

  6. yearn.fi can render chart panels from states + transitions without replaying contract events or making archive RPC calls.

  7. State and transition IDs are deterministic, including same-block and current-live-tail cases.

  8. Default Redis values use the :default key, are Keyv-compatible, omit raw events, and stay below Kong's cacheMSet single-key hard limit.

  9. Existing REST endpoints continue to work.

  10. Tests pass:

bun run --elide-lines 0 --filter web lint
bun run --elide-lines 0 --filter ingest lint
bun run --elide-lines 0 --filter web test

13. Open Questions / Follow-ups

These are not blockers for v1:

  1. Should StrategyReported transitions be visible chart panels, or hidden state updates that only affect subsequent visible allocation transitions?
  2. What is the canonical DOA keeper/applicator address set per chain?
  3. Should Kong persist full DOA proposal history in Postgres, or read/copy from the existing Redis source during refresh?
  4. Should clients be able to request a deployedDebtOnly denominator mode later, or is totalAssets always canonical?

14. Recommended v1 Defaults

  • Endpoint: GET /api/rest/allocation-history/:chainId/:address
  • Default cache object: shaped default public response only, stored at rest:vault_allocation_history:{chainId}:{vaultLower}:default.
  • Full history source: durable Postgres allocation_state/allocation_transition rows, not one Redis value.
  • Default public response: last 100 classified transitions plus current live tail, including all referenced states plus the anchor state before the window.
  • Include DOA annotations by default.
  • Omit raw events unless includeRawEvents=true; when requested, load raw events from Postgres/event tables for the selected window.
  • Use durable evmtransaction table.
  • Use durable allocation state/transition tables in v1; Redis is only an acceleration layer for the shaped default response.
  • Use archive RPC for every historical state.
  • Use totalAssets as denominator.
  • Treat DOA as annotation/proposal-only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions