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:
- Kong has reliable archive RPC access, so Kong should materialize historical block-end states directly.
- 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:
- Query relevant
evmlog rows.
- Identify transaction hashes missing from
evmtransaction or marked retryable_error past the retry backoff.
- Fetch each tx with archive/full RPC via viem
getTransaction.
- 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'.
- 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:
- 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.
- 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.
- Resolve the vault's debt allocator at that block from
NewDebtAllocator history.
- 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.
- 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
- 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:
- Same
chainId and vault.
- Debt allocator
UpdateStrategyDebtRatios target ratios match the DOA target ratios.
- Nearby
DebtUpdated transaction changes strategy debts in the same direction as DOA targets.
- Transaction path matches known DOA applicator/keeper addresses using
transactionFrom, transactionTo, and/or inputSelector.
- 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:
- matched DOA proposal/applicator evidence,
- configured DOA keeper/applicator actor sets,
- historical debt allocator keeper status at the block,
- source event mix (
UpdateStrategyDebtRatios plus DebtUpdated, allocator/vault source addresses),
- input selector when it identifies an allocator/applicator/vault function,
- 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:
- Add the
evmtransaction table from section 7.2.
- Add indexes on
(chain_id, from) and (chain_id, to).
- 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.
- Add down migration that drops indexes/table.
- Add the db-migrate JS wrapper under
packages/db/migrations/ that loads the up/down SQL files, following packages/db/migrations/20240214020032-eventsource.js.
- 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:
- Add
allocation_state, allocation_transition, and allocation_doa_proposal tables from section 8.4.
- Add indexes on
(chain_id, vault_address, block_number) for state/transition lookup and (chain_id, vault_address, proposal_time) for pending proposal lookup.
- 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.
- Store source-event references as deterministic ids in
allocation_transition.source_event_ids and effects[].sourceEventIds; resolve raw event payloads from evmlog on demand.
- Store
vault_address as checksum-canonical getAddress(...) output in allocation tables; reserve lowercase addresses for Redis keys and deterministic IDs.
- Include
materialization_run_id on all allocation-history tables for audit/debugging, while v1 refresh uses transactionally delete-and-replace semantics for active rows.
- Add the db-migrate JS wrapper under
packages/db/migrations/, following the existing SQL-wrapper pattern.
- 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:
- Initialize archive RPC clients with
await rpcs.up() and call await rpcs.down() in a finally block before process exit.
- Fetch vault list.
- For each vault, fetch raw relevant
evmlog rows/source events without requiring transaction metadata.
- Collect and de-dupe transaction hashes from the raw events.
- Enrich missing tx metadata into
evmtransaction.
- Re-query or merge normalized source events with joined transaction metadata.
- Materialize states via archive RPC.
- Match DOA proposals to source-event groups/materialized transitions and persist unmatched/pending/stale proposal rows.
- Run final DOA-aware classification for transitions/effects, using
matchedDoa evidence where present.
- 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.
- 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.
- 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:
-
Kong can return a vault-scoped allocation history from:
GET /api/rest/allocation-history/:chainId/:address
-
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
-
Historical states are computed with archive RPC at the relevant block, not from latest snapshots.
-
DebtUpdated, DebtPurchased, StrategyReported, StrategyChanged, vault config events, NewDebtAllocator, and debt allocator ratio/keeper events are represented.
-
DOA proposals do not create executed allocation states unless matched to on-chain events.
-
yearn.fi can render chart panels from states + transitions without replaying contract events or making archive RPC calls.
-
State and transition IDs are deterministic, including same-block and current-live-tail cases.
-
Default Redis values use the :default key, are Keyv-compatible, omit raw events, and stay below Kong's cacheMSet single-key hard limit.
-
Existing REST endpoints continue to work.
-
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:
- Should
StrategyReported transitions be visible chart panels, or hidden state updates that only affect subsequent visible allocation transitions?
- What is the canonical DOA keeper/applicator address set per chain?
- Should Kong persist full DOA proposal history in Postgres, or read/copy from the existing Redis source during refresh?
- 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.
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-chartbranch has a product/data spec at:yearn.fi/docs/manual-allocation-reallocation-data-spec.mdThat spec establishes the desired product behavior:
Ross confirmed two decisions for the Kong version:
transactionFrom,transactionTo, andinputSelector.2. Current Kong Context
Relevant files verified in Kong:
docs/rest.mdpackages/web/app/api/rest/snapshot/[chainId]/[address]/route.tspackages/web/app/api/rest/reports/[chainId]/[address]/route.tspackages/web/app/api/rest/snapshot/refresh-snapshot.tspackages/web/app/api/rest/reports/refresh.tspackages/web/app/api/rest/reports/refresh-historical.tspackages/web/app/api/rest/cache.tspackages/web/app/api/db/index.tspackages/db/migrations/sqls/20240214020032-eventsource-up.sqlpackages/ingest/abis/yearn/3/vault/snapshot/hook.tspackages/ingest/abis/yearn/3/vault/abi.tspackages/ingest/abis/yearn/3/debtManagerFactory/event/hook.tspackages/ingest/abis/yearn/3/debtAllocator/abi.tsconfig/abis.yamlpackages/lib/rpcs.tsKong already declares debt allocator indexing:
Kong already discovers debt allocators from the debt manager factory event:
'event NewDebtAllocator(address indexed allocator, address indexed vault)'Kong's current
evmlogtable stores:It does not currently store transaction sender, transaction recipient, or input selector.
3. Ownership Boundaries
Kong owns
yearn.fi owns
DOA source owns
DOA data must not be treated as an executed allocation unless matched to canonical on-chain evidence.
4. Endpoint
Add a separate REST endpoint:
Examples:
Query params:
fromBlocktoBlocklatest; non-latest must return400 Unsupported query parameter for v1. Internal DB helpers may accepttoBlockfor refresh/backfill/future window support.fromTimestamplimitincludeRawEvents=true. Values below 1 or above the applicable max must return400 Invalid limit. The route applies this after loading from Postgres or a shaped cache.includeRawEventsincludeDoarefresh4.1 Cache key policy
V1 must not advertise arbitrary historical windows unless the route can serve them deterministically.
Recommended v1 behavior:
allocation_state,allocation_transition, and source event/DOA references). Do not require one giant Redis value for full history.:defaultvalue 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.:defaultonly when the request matches default shaping. ForfromBlock, allowed largerlimit, orincludeRawEvents=true, the route should query durable Postgres rows and shape the response on demand.evmlog/source-event queries whenincludeRawEvents=true.AllocationStatereferenced by returned transitions plus the anchor state immediately before the first returned transition, sofromStateIdreferences never point to omitted states.toBlockvalues other thanlatestshould return400 Unsupported query parameter for v1unless/until a normalized window cache exists.fromTimestampshould return400 Unsupported query parameter for v1until explicit timestamp-to-block resolution is implemented.windowHash. This is not a v1 key and the route must not read it unless that future cache writer exists:Response headers should match current REST behavior:
5. Data Model
5.1 Top-level response
5.2 Vault metadata
5.3 Range metadata
5.4 Strategy directory
5.5 Source events
Every event returned or used internally should normalize to this shape:
Event arg normalization rules:
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_callatblockNumbercan actually return.State IDs must be stable and deterministic:
Use lowercase vault addresses inside IDs for stability; continue returning checksum addresses in address fields.
Rules:
currentDebtBps = currentDebt / totalAssets * 10_000.unallocatedBps = max(totalAssets - sum(strategy.currentDebt), 0) / totalAssets * 10_000.totalAssetsas denominator to match current UI semantics.totalAssets == 0, set all bps to0and keep raw debts.5.7 Transitions
Transition IDs must be stable and deterministic:
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 hassourceEventIds: [],transactionHash: null, andeffects: [].6. Events Needed
6.1 V3 vault events
Required:
DebtUpdated(strategy,current_debt,new_debt)StrategyReported(strategy,gain,loss,current_debt,protocol_fees,total_fees,total_refunds)StrategyChanged(strategy,change_type)UpdatedMaxDebtForStrategy(sender,strategy,new_debt)DebtPurchased(strategy,amount)DebtUpdated, so state is covered without this event, but reason/classification is not.UpdateDefaultQueue(new_default_queue)UpdateUseDefaultQueue(use_default_queue)RoleSet(account,role)RoleStatusChanged(role,status)UpdateRoleManager(role_manager)UpdateAccountant(accountant)Useful but not required for v1 chart panels:
UpdateAutoAllocate(auto_allocate)UpdateMinimumTotalIdle(minimum_total_idle)Shutdown()RoleStatusChangedexists 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:
NewDebtAllocator(allocator,vault)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:
UpdateStrategyDebtRatios(strategy,newTargetRatio,newMaxRatio,newTotalDebtRatio)UpdateKeeper(keeper,allowed)GovernanceTransferred(previousGovernance,newGovernance)Useful config context:
UpdateMaxAcceptableBaseFeeUpdateMaxDebtUpdateLossUpdateMinimumChangeUpdateMinimumWait7. Transaction Metadata
7.1 Required fields
For every source event used in allocation history, Kong must expose:
Rationale:
transactionFromidentifies the sender/actor.transactionTodistinguishes direct vault calls from calls to debt allocator, DOA applicator, multisig, modules, etc.inputSelectordistinguishes 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
evmlogrow:Notes:
input_selectorfor this feature.evmlogtoevmtransactionon(chain_id, transaction_hash).lookup_statusvalues:ok,retryable_error,not_found. Do not let transient RPC failures become permanent null metadata.getMissingTransactionHashesmust include hashes with no row and hashes withlookup_status = 'retryable_error'whoselast_lookup_atis older than the retry backoff. It must not treat any existing row as permanently complete.from/to/input_selector.not_foundonly for confirmed non-retryable cases after a bounded retry policy, not for single-attempt RPC timeouts.7.3 Enrichment path
During refresh/precompute:
evmlogrows.evmtransactionor markedretryable_errorpast the retry backoff.getTransaction.lookup_status = 'ok'; on transient RPC/network failure, either skip inserting a row or upsertlookup_status = 'retryable_error', incrementlookup_attempts, and setlast_error/last_lookup_at; on confirmed permanent absence after bounded retries, marklookup_status = 'not_found'.Pseudo-code:
8. Historical State Reconstruction
8.1 Decision
Use Kong archive RPC to materialize historical block-end states.
Important constraint: archive
eth_callwithblockNumberreturns 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:
DebtUpdatedonly gives one strategy's before/after debt.StrategyReportedcan alter current debt without being a manual allocation command.StrategyChangedchanges the visible strategy set.totalAssetsat the same block.snapshottable is latest-only.8.2 State materialization inputs
For each relevant
blockNumberthat affects a vault:StrategyChanged,DebtUpdated,DebtPurchased,StrategyReported,UpdatedMaxDebtForStrategy, default queue events, and any allocator ratio events that reference a strategy.active,revoked,inactive, etc.) fromStrategyChanged/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 vaulttotalDebt.NewDebtAllocatorhistory.blockNumber:vault.totalAssets()vault.totalDebt()if available, otherwise sum strategy debtsvault.totalIdle()if available, otherwise computemax(totalAssets - totalDebt, 0)as a fallback and mark the value as computed in implementation logs/testsvault.strategies(strategy)for each known strategygetStrategyTargetRatio(strategy)if allocator existsgetStrategyMaxRatio(strategy)if allocator existsthing/snapshotdata 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-boundedprojectCandidateStrategies(...)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 existingextractDebts(...)logic already calls vaultstrategies(strategy)and allocator ratios, but it currently reads latest state. Add a historical variant that acceptsblockNumberand passesblockNumber/blockTaginto viem multicall.8.2.1 Current live tail
The
current_live_tailtransition 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(), andvault.totalIdle().Rules:
fromStateId: null.allocation-state:{chainId}:{vaultLower}:live:{latestBlockNumber}.latestBlockNumberequals the last materialized historical block and the state is identical, skip the live-tail state/transition to avoid duplicate IDs and redundant panels.stateGranularity: 'latest',sourceEventIds: [], andtransactionHash: null.kind: 'current_live_tail',sourceEventIds: [],transactionHash: null,transactionHashes: [], andeffects: [].blockNumber,blockTimestamp, andtimestampUtccome from the latest block used for the multicall, not from generation time.generatedAtremains the response generation time.8.3 Event grouping
Group source events into materialized states by block:
Within the block group, sort events by
transaction_index, thenlogIndex, 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:
kind, chosen by highest-priority effect for backwards compatibility,sourceEventIdsspanning all relevant events in the block,transactionHashesspanning 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 multipleAllocationTransitionrows pointing at the sametoStateIdin v1; useeffects[]for that detail.Do not label block-end states as exact post-transaction states.
8.4 State cache/storage
Recommended Postgres tables:
Source-event and DOA persistence contract:
allocation_*tables'vault_addressvalues are stored as checksum-canonical addresses fromgetAddress(...), matching Kongevmlog.addressstorage and REST DB helper behavior. Lowercase vault addresses are used only in Redis/cache keys and deterministic IDs.getAddress(address)before DB predicates; do not mix lowercase and checksum equality in allocation tables.evmlog; do not duplicate large raw event payloads into Redis.allocation_transition.source_event_idsand eacheffects[].sourceEventIdsstore deterministic source event ids (${chainId}:${transactionHash}:${logIndex}) that can be resolved back toevmlogplusevmtransactionmetadata.evmlogis too expensive, add a narrowallocation_source_event_cachetable later, but v1 should first rely onevmlog+ ids unless benchmarks prove otherwise.allocation_transition.doa; unmatched/pending/stale proposals must be persisted inallocation_doa_proposalso the route can returnpendingDoaProposalswithout calling yearn.fi.(chainId, vault)in a single materialization run so states, transitions, DOA proposals, and cached default output are generated from consistent inputs.materialization_run_idper vault refresh, open a DB transaction, delete existingallocation_state,allocation_transition, andallocation_doa_proposalrows 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 includelatestBlockNumberand would otherwise accumulate across refreshes.materialization_run_id. V1 should use delete-and-replace for simplicity.Redis should cache only the shaped default response in v1:
Do not store the full materialized vault timeline or raw event history in one Redis value. Kong's
cacheMSethas 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_transitiontables are normative for v1 sofromBlock, largerlimit, andincludeRawEvents=truecan 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:
They do not prove execution.
9.2 Annotation shape
9.3 Matching rules
Use these signals, strongest first:
chainIdand vault.UpdateStrategyDebtRatiostarget ratios match the DOA target ratios.DebtUpdatedtransaction changes strategy debts in the same direction as DOA targets.transactionFrom,transactionTo, and/orinputSelector.Classification examples:
kind = 'doa_proposal'.DebtUpdatedstate transition:kind = 'doa_execution'.pendingDoaProposals, do not create an executed state.Status and aging rules for unmatched DOA proposals:
executionMatchWindowHours = 24,pendingWindowHours = 72, andstaleAfterDays = 30.pending: no matched on-chain event yet andnow - proposal_time <= pendingWindowHours.unmatched: no matched on-chain event andpendingWindowHours < now - proposal_time <= staleAfterDays.stale: proposal is older thanstaleAfterDays, or superseded by a newer proposal for the same vault/strategy target set, or disappeared from the authoritative DOA source during refresh.matched/inlinedoawhen matching signals meet the confidence threshold within the execution match window. Otherwise it stays proposal-only and must not change allocation state orkind.allocation_doa_proposal; the route may default to returningpending/recentunmatchedand 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 todoa_execution. The classifier must receivematchedDoa/DOA match evidence; otherwise the first rule below cannot fire and matched executions would be mislabeled as allocator/manual activity.Suggested final rules:
Do not classify allocator execution solely with
txTo === debtAllocator. Safe/module/applicator paths often make top-leveltransactionToa Safe, module, relayer, or applicator even though the allocator/vault path executed downstream. Prefer, in order:UpdateStrategyDebtRatiosplusDebtUpdated, allocator/vault source addresses),transactionFrom/transactionToas supporting evidence only.Actor classification should run at both levels:
AllocationTransition.actoris the best top-level summary actor for the block-level transition.AllocationTransitionEffect.actoris 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:
11. Implementation Plan
Task 1: Add transaction metadata migration
Objective: Store
transactionFrom,transactionTo, andinputSelectorin Kong.Files:
packages/db/migrations/YYYYMMDDHHMMSS-evmtransaction.jspackages/db/migrations/sqls/YYYYMMDDHHMMSS-evmtransaction-up.sqlpackages/db/migrations/sqls/YYYYMMDDHHMMSS-evmtransaction-down.sqlSteps:
evmtransactiontable from section 7.2.(chain_id, from)and(chain_id, to).lookup_status,lookup_attempts,last_lookup_at, andlast_errorcolumns so transient RPC failures are retryable and do not become permanent null metadata.packages/db/migrations/that loads the up/down SQL files, followingpackages/db/migrations/20240214020032-eventsource.js.Acceptance criteria:
evmlogcan 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:
packages/db/migrations/YYYYMMDDHHMMSS-allocation-history.jspackages/db/migrations/sqls/YYYYMMDDHHMMSS-allocation-history-up.sqlpackages/db/migrations/sqls/YYYYMMDDHHMMSS-allocation-history-down.sqlSteps:
allocation_state,allocation_transition, andallocation_doa_proposaltables from section 8.4.(chain_id, vault_address, block_number)for state/transition lookup and(chain_id, vault_address, proposal_time)for pending proposal lookup.allocation_transition.doa; store unmatched/pending/stale proposals inallocation_doa_proposalwith the status rules from section 9.3.allocation_transition.source_event_idsandeffects[].sourceEventIds; resolve raw event payloads fromevmlogon demand.vault_addressas checksum-canonicalgetAddress(...)output in allocation tables; reserve lowercase addresses for Redis keys and deterministic IDs.materialization_run_idon all allocation-history tables for audit/debugging, while v1 refresh uses transactionally delete-and-replace semantics for active rows.packages/db/migrations/, following the existing SQL-wrapper pattern.Acceptance criteria:
includeRawEvents=truecan resolve returned-window source events from persisted ids plusevmlog/evmtransaction.pendingDoaProposalscan be returned without calling yearn.fi or depending on Redis-only DOA history.vault_addressvalues, while IDs/cache keys use lowercase addresses.Task 2: Add transaction metadata DB helpers
Objective: Query and upsert tx metadata from REST refresh code.
Files:
packages/web/app/api/rest/allocation-history/transactions.tsRequired functions:
Acceptance criteria:
blockTimeinternally as unix seconds (bigint | number). When inserting into atimestamptzcolumn, useto_timestamp($blockTimeSeconds); when reading throughpackages/web/app/api/db/index.ts, expect BigInt seconds and convert to APIblockTimestamp/timestampUtcexplicitly.getMissingTransactionHashesreturns hashes with no row plus retryable rows past backoff; it excludes only successfulokrows and confirmednot_foundrows.Task 3: Add transaction RPC enrichment
Objective: Fetch missing tx metadata during refresh/precompute.
Files:
packages/web/app/api/rest/allocation-history/enrich-transactions.tsImplementation notes:
finally:rpcs.next(chainId)frompackages/lib/rpcs.tsif import boundaries allow it fromweb; otherwise add a web-local RPC helper following the same env vars.getTransaction({ hash }).inputSelector = tx.input && tx.input !== '0x' ? tx.input.slice(0, 10) : null.not_foundonly after bounded retries or explicit non-retryable provider response.from/to/inputSelectorandlookup_status = 'ok'after a failed lookup.Acceptance criteria:
not_foundonly after the retry policy, while transient failures stay retryable.retryable_errorrows and can upgrade them took, improving actor/path classification over time.Task 4: Add allocation-history event query
Objective: Fetch source events for one vault/range. Support a raw
evmlogpass for hash discovery and a normalized pass after transaction metadata enrichment.Files:
packages/web/app/api/rest/allocation-history/db.tsQuery behavior:
evmlogrows without requiringevmtransactionso refresh can collect transaction hashes before enrichment.evmtransactionfor tx metadata and returnAllocationSourceEvent[].getAddress(address)and queryevmlog.address = $vaultChecksum. Do not lowercase DB equality values.NewDebtAllocator, canonicalize allocator addresses withgetAddress, then queryevmlog.address = ANY($allocatorChecksumAddresses)for allocator event set.NewDebtAllocatorwhere the JSON vault arg matches the requested vault. Because JSON args may not be stored with consistent casing, compare aslower(args->>'vault') = lower($vaultChecksum)or normalize args to checksum form during extraction before querying.evmtransactionfor tx metadata.block_number,transaction_index,log_index.Acceptance criteria:
getAddress, matching existing ingestion (packages/ingest/extract/evmlogs.ts) and REST helpers.AllocationSourceEvent[].toBlockwith 400 in v1; internal helpers may accepttoBlockfor 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:
packages/web/app/api/rest/allocation-history/materialize-state.tsImplementation notes:
(chainId, vaultAddress, blockNumber)for materialized states.blockNumberas all ever-seen strategies up to that block, then derive status separately. Do not drop revoked/inactive strategies before reading their historical debt.blockNumber.totalAssets()totalDebt()if available, otherwise sum strategy debtstotalIdle()if available, otherwise computemax(totalAssets - totalDebt, 0)as fallbackstrategies(strategy)for every candidate ever-seen strategy up to the block, including revoked/inactive candidatesAllocationStateper relevant block usingallocation-state:{chainId}:{vaultLower}:block:{blockNumber}.allocation-state:{chainId}:{vaultLower}:live:{latestBlockNumber}unless it would duplicate the final historical state.sourceEventIds: [].Acceptance criteria:
totalAssetsat that block.totalDebtandtotalIdleare populated from vault reads or explicitly documented computed fallbacks.totalDebt.Task 6: Add transition classifier
Objective: Classify each state transition and actor.
Files:
packages/web/app/api/rest/allocation-history/classify.tsAcceptance criteria:
AllocationTransitionper materialized block-end state and one optionalcurrent_live_tailtransition.effects[]with classified transaction/event-cluster details so mixed same-block activity does not collapse into a misleading single label.doa_execution,allocator_execution,manual_debt_update,manual_config_change,report_only_state_change,strategy_lifecycle_change,bad_debt_purchase, andunknown.doa_execution.Task 7: Add DOA source adapter and matcher
Objective: Join DOA proposal data as annotations.
Files:
packages/web/app/api/rest/allocation-history/doa.tsImplementation notes:
includeDoa.Acceptance criteria:
pendingDoaProposalswith concretepending,unmatched, orstalestatus.Task 8: Add refresh/precompute script
Objective: Precompute and cache allocation history per vault.
Files:
packages/web/app/api/rest/allocation-history/refresh.tspackages/web/app/api/rest/allocation-history/redis.tsBehavior:
await rpcs.up()and callawait rpcs.down()in afinallyblock before process exit.evmlogrows/source events without requiring transaction metadata.evmtransaction.matchedDoaevidence where present.(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.Do not write raw JSON directly if the route reads with
keyv.get.Acceptance criteria:
rest:vault_allocation_history:{chainId}:{vaultLower}:defaultwith the shaped default response andJSON.stringify({ value: timeline }), matching existing REST refresh scripts.fromBlock, large but allowedlimit,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_executionrather than stale manual/allocator labels.(chainId, vault)or rolls back; it cannot leave old live-tail rows visible alongside the new live tail.Task 9: Add REST route
Objective: Serve cached allocation timelines.
Files:
packages/web/app/api/rest/allocation-history/[chainId]/[address]/route.tsBehavior:
chainIdand address.limit: default 100, max 500, max 200 whenincludeRawEvents=true; non-integer,< 1, or over-cap values return 400.rest:vault_allocation_history:{chainId}:{vaultLower}:defaultfrom Redis first.fromBlock, allowed largerlimit, orincludeRawEvents=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.fromBlock/limit, include all states referenced by returned transitions plus the anchor state before the first returned transition.OPTIONSwith CORS headers.Acceptance criteria:
GET /api/rest/allocation-history/:chainId/:addressreturnsVaultAllocationTimeline.limit > 500, orlimit > 200withincludeRawEvents=true) return 400 and do not query raw events.fromStateId/toStateIdreferences.Task 10: Add tests and docs
Objective: Verify endpoint behavior and document the API.
Files:
packages/web/app/api/rest/allocation-history/*.spec.tsdocs/rest.mdTests:
DebtUpdatedbecomesdoa_execution.includeRawEvents=truelower cap, negative/non-integer limits, and over-cap 400 responses.:defaultis absent.fromStateId/toStateIdreferences.cacheMSetsingle-key hard limit.pending,unmatched, andstaleaging/supersession rules.Docs:
docs/rest.md.12. Acceptance Criteria
The feature is complete when:
Kong can return a vault-scoped allocation history from:
The response includes:
transactionFrom,transactionTo,inputSelectorHistorical states are computed with archive RPC at the relevant block, not from latest snapshots.
DebtUpdated,DebtPurchased,StrategyReported,StrategyChanged, vault config events,NewDebtAllocator, and debt allocator ratio/keeper events are represented.DOA proposals do not create executed allocation states unless matched to on-chain events.
yearn.fi can render chart panels from
states+transitionswithout replaying contract events or making archive RPC calls.State and transition IDs are deterministic, including same-block and current-live-tail cases.
Default Redis values use the
:defaultkey, are Keyv-compatible, omit raw events, and stay below Kong'scacheMSetsingle-key hard limit.Existing REST endpoints continue to work.
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 test13. Open Questions / Follow-ups
These are not blockers for v1:
StrategyReportedtransitions be visible chart panels, or hidden state updates that only affect subsequent visible allocation transitions?deployedDebtOnlydenominator mode later, or istotalAssetsalways canonical?14. Recommended v1 Defaults
GET /api/rest/allocation-history/:chainId/:addressrest:vault_allocation_history:{chainId}:{vaultLower}:default.allocation_state/allocation_transitionrows, not one Redis value.includeRawEvents=true; when requested, load raw events from Postgres/event tables for the selected window.evmtransactiontable.totalAssetsas denominator.