Skip to content

Commit 640f022

Browse files
committed
refactor(wotoken-yield,es-address-yield): deterministic day-end sweeps
Both processors swept passive holders on ctx.latestBlockOfDay, which keys off per-batch lastBlockPerDay/blocks.at(-1) and therefore fires on batch boundaries — non-deterministic timing and, during backfill, one previewRedeem/accRewardPerShare + a full holder-rewrite per batch instead of per day (the RPC/write bloat seen live). Add forEachBlockByDay: a driver that closes out each UTC day once, at that day's true last block (next block is a different day, with a cross-batch carry so a boundary on a batch split still lands on the real last block), and only sweeps the batch tail when ctx.isHead — keeping the current day fresh live without redundant backfill sweeps. Validated on the xOGN launch window (mainnet 19919745->19990000): a single run and a run split at 19955000 produce byte-identical completed-day data (checksum f3e9b04d…), each completed day's active holders share one day-end block, zero negatives.
1 parent 118cf17 commit 640f022

3 files changed

Lines changed: 108 additions & 54 deletions

File tree

src/templates/exponential-staking/es-address-yield.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as abi from '@abi/exponential-staking'
22
import { ESAddressYield } from '@model'
33
import { Block, Context, EvmBatchProcessor, Processor, logFilter } from '@originprotocol/squid-utils'
4+
import { DayBoundaryCarry, forEachBlockByDay } from '@utils/for-each-block-by-day'
45

56
// xOGN is a MasterChef-style accumulator: previewRewards(user) == balanceOf(user) *
67
// (accRewardPerShare - rewardDebtPerShare(user)) / 1e12. So accRewardPerShare is scaled by 1e12 and a
@@ -39,6 +40,7 @@ export const createESAddressYieldProcessor = ({
3940
// Persisted across batches, seeded once from persisted rows (see `hydrate`).
4041
const currentDayRows = new Map<string, ESAddressYield>()
4142
const previousDayRows = new Map<string, ESAddressYield>()
43+
const dayCarry: DayBoundaryCarry = {}
4244
let hydrated = false
4345

4446
const hydrate = async (ctx: Context) => {
@@ -67,8 +69,9 @@ export const createESAddressYieldProcessor = ({
6769
name: `xOGN Address Yield ${staking}`,
6870
from,
6971
setup: (p: EvmBatchProcessor) => {
70-
// includeAllBlocks so the daily forced checkpoint can fire on the last block of each day even
71-
// when no stake/unstake occurred (matches the ARM/otoken batch).
72+
// includeAllBlocks so the day-boundary detection sees consecutive blocks (and thus every true
73+
// end-of-day) even on days with no stake/unstake. Redundant on mainnet (already all-blocks from
74+
// block 15M) but keeps this processor self-sufficient.
7275
p.includeAllBlocks({ from })
7376
p.addLog(stakeFilter.value)
7477
p.addLog(unstakeFilter.value)
@@ -143,28 +146,30 @@ export const createESAddressYieldProcessor = ({
143146
changedYieldRows.set(row.id, row)
144147
}
145148

146-
for (const block of ctx.blocks) {
147-
for (const log of block.logs) {
148-
if (stakeFilter.matches(log)) {
149-
// Stake mints `points` xOGN and locks `amount` OGN (new stake or the relock half of an
150-
// extend). Accrue at the old balance first, then apply both deltas.
151-
const event = abi.events.Stake.decode(log)
152-
const R = await getR(block)
153-
checkpoint(event.user, block, R, event.points, event.amount)
154-
} else if (unstakeFilter.matches(log)) {
155-
// Unstake burns `points` xOGN and withdraws OGN principal. `amount` is the post-penalty
156-
// withdrawn amount; the full-exit clamp above zeroes any residual once points hit 0.
157-
const event = abi.events.Unstake.decode(log)
158-
const R = await getR(block)
159-
checkpoint(event.user, block, R, -event.points, -event.amount)
149+
// Deterministic per-day accrual: stake/unstake checkpoint at exact event time; each day's pure
150+
// reward accrual is closed out once, at that day's true last block (`onDayEnd`), regardless of
151+
// how blocks are batched. At the chain head the current day is also swept for freshness.
152+
await forEachBlockByDay(ctx, dayCarry, {
153+
onBlock: async (block) => {
154+
for (const log of block.logs) {
155+
if (stakeFilter.matches(log)) {
156+
// Stake mints `points` xOGN and locks `amount` OGN (new stake or the relock half of an
157+
// extend). Accrue at the old balance first, then apply both deltas.
158+
const event = abi.events.Stake.decode(log)
159+
const R = await getR(block)
160+
checkpoint(event.user, block, R, event.points, event.amount)
161+
} else if (unstakeFilter.matches(log)) {
162+
// Unstake burns `points` xOGN and withdraws OGN principal. `amount` is the post-penalty
163+
// withdrawn amount; the full-exit clamp above zeroes any residual once points hit 0.
164+
const event = abi.events.Unstake.decode(log)
165+
const R = await getR(block)
166+
checkpoint(event.user, block, R, -event.points, -event.amount)
167+
}
160168
}
161-
}
162-
163-
// Daily forced checkpoint: close out each day's accrual for every staker with a live balance,
164-
// including passive ones. Skip when there are no active stakers so we never call the contract
165-
// before it has any (keeps `from` robust). `latestBlockOfDay` fires on each day's last block
166-
// and the batch tail, so one sweep/day completes the series and the tail keeps today fresh.
167-
if (block.header.height >= from && ctx.latestBlockOfDay(block)) {
169+
},
170+
onDayEnd: async (block) => {
171+
if (block.header.height < from) return
172+
// Skip when there are no active stakers so we never call the contract before it has any.
168173
const activeStakers: string[] = []
169174
for (const account of new Set([...currentDayRows.keys(), ...previousDayRows.keys()])) {
170175
const seedRow = currentDayRows.get(account) ?? previousDayRows.get(account)
@@ -174,8 +179,8 @@ export const createESAddressYieldProcessor = ({
174179
const R = await getR(block)
175180
for (const account of activeStakers) checkpoint(account, block, R, 0n, 0n)
176181
}
177-
}
178-
}
182+
},
183+
})
179184

180185
await ctx.store.upsert([...changedYieldRows.values()])
181186
},

src/templates/wotoken-yield/wotoken-yield.ts

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as wotokenAbi from '@abi/woeth'
22
import { WOTokenAddressYield } from '@model'
33
import { Block, Context, EvmBatchProcessor, Processor, logFilter } from '@originprotocol/squid-utils'
44
import { ADDRESS_ZERO } from '@utils/addresses'
5+
import { DayBoundaryCarry, forEachBlockByDay } from '@utils/for-each-block-by-day'
56

67
const ONE = 10n ** 18n
78

@@ -47,6 +48,7 @@ export const createWOTokenYieldProcessor = ({
4748
// Persisted across batches, seeded once from persisted rows (see `hydrate`).
4849
const currentDayRows = new Map<string, WOTokenAddressYield>()
4950
const previousDayRows = new Map<string, WOTokenAddressYield>()
51+
const dayCarry: DayBoundaryCarry = {}
5052
let hydrated = false
5153
let warnedNegativeBalance = false
5254

@@ -77,8 +79,9 @@ export const createWOTokenYieldProcessor = ({
7779
name,
7880
from,
7981
setup: (p: EvmBatchProcessor) => {
80-
// includeAllBlocks so the daily forced checkpoint can fire on frequency/end-of-day blocks
81-
// even when no wrapped transfer occurred that day (matches the ARM/otoken-state batch).
82+
// includeAllBlocks so the day-boundary detection sees consecutive blocks (and thus every true
83+
// end-of-day) even on days with no wrapped transfer. Redundant on these chains — the OToken
84+
// processor already includes all blocks from an earlier block — but keeps this self-sufficient.
8285
p.includeAllBlocks({ from })
8386
p.addLog(transferFilter.value)
8487
},
@@ -171,33 +174,32 @@ export const createWOTokenYieldProcessor = ({
171174
changedYieldRows.set(row.id, row)
172175
}
173176

174-
for (const block of ctx.blocks) {
175-
for (const log of block.logs) {
176-
if (transferFilter.matches(log)) {
177-
// Mint = from 0 (wrap); burn = to 0 (unwrap); peer transfer otherwise.
178-
// Every inbound (wrap or peer-in) marks cost basis at current R: for a wrap you deposit
179-
// `shares * R` underlying assets; a peer-in is acquired at current market value. Outbounds
180-
// reduce cost basis proportionally (handled inside checkpoint).
181-
const event = wotokenAbi.events.Transfer.decode(log)
182-
const R = await getR(block)
183-
const fromZero = event.from.toLowerCase() === ADDRESS_ZERO
184-
const toZero = event.to.toLowerCase() === ADDRESS_ZERO
185-
if (!fromZero) {
186-
checkpoint(event.from, block, R, -event.value)
187-
}
188-
if (!toZero) {
189-
checkpoint(event.to, block, R, event.value, (event.value * R) / ONE)
177+
// Deterministic per-day accrual: transfers checkpoint at exact event time; each day's pure
178+
// share-appreciation is closed out once, at that day's true last block (`onDayEnd`), regardless
179+
// of how blocks are batched. At the chain head the current day is also swept for freshness.
180+
await forEachBlockByDay(ctx, dayCarry, {
181+
onBlock: async (block) => {
182+
for (const log of block.logs) {
183+
if (transferFilter.matches(log)) {
184+
// Mint = from 0 (wrap); burn = to 0 (unwrap); peer transfer otherwise.
185+
// Every inbound (wrap or peer-in) marks cost basis at current R: for a wrap you deposit
186+
// `shares * R` underlying assets; a peer-in is acquired at current market value.
187+
// Outbounds reduce cost basis proportionally (handled inside checkpoint).
188+
const event = wotokenAbi.events.Transfer.decode(log)
189+
const R = await getR(block)
190+
const fromZero = event.from.toLowerCase() === ADDRESS_ZERO
191+
const toZero = event.to.toLowerCase() === ADDRESS_ZERO
192+
if (!fromZero) {
193+
checkpoint(event.from, block, R, -event.value)
194+
}
195+
if (!toZero) {
196+
checkpoint(event.to, block, R, event.value, (event.value * R) / ONE)
197+
}
190198
}
191199
}
192-
}
193-
194-
// Daily forced checkpoint: close out each day's pure share-appreciation accrual for every
195-
// known holder, including passive ones with no events. `latestBlockOfDay` is true on the last
196-
// block of each day *and* the last block of the batch, so one sweep per day completes the
197-
// daily series during backfill and the batch-tail sweep keeps the current day fresh while
198-
// live. Intra-day sweeps would only overwrite the same dated row, so they are unnecessary
199-
// here (unlike ARM, which also needs sub-daily ArmState snapshots).
200-
if (block.header.height >= from && ctx.latestBlockOfDay(block)) {
200+
},
201+
onDayEnd: async (block) => {
202+
if (block.header.height < from) return
201203
// Collect holders with a live balance first, and skip the block entirely when there are
202204
// none. Before the vault's first deposit there are no holders and its ERC4626 views revert,
203205
// so we must not call previewRedeem (getR) yet. This keeps `from` robust: it can sit at (or
@@ -211,8 +213,8 @@ export const createWOTokenYieldProcessor = ({
211213
const R = await getR(block)
212214
for (const account of activeHolders) checkpoint(account, block, R, 0n)
213215
}
214-
}
215-
}
216+
},
217+
})
216218

217219
await ctx.store.upsert([...changedYieldRows.values()])
218220
},

src/utils/for-each-block-by-day.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Block, Context } from '@originprotocol/squid-utils'
2+
3+
const utcDate = (block: Block) => new Date(block.header.timestamp).toISOString().slice(0, 10)
4+
5+
export interface DayBoundaryCarry {
6+
lastBlock?: Block
7+
}
8+
9+
/**
10+
* Deterministically drive per-day work over a batch, independent of batch boundaries.
11+
*
12+
* `onBlock` runs for every block in order. `onDayEnd(block)` runs once per UTC day, at that day's
13+
* true last block — detected by the next block being a different UTC day. `carry` bridges a day
14+
* boundary that lands exactly on a batch split, so the day is still finalized at its real last block
15+
* (not wherever the batch happened to end). When the batch is at the chain head, the final (still
16+
* in-progress) day is also swept at the latest block so the current day stays fresh while live.
17+
*
18+
* This replaces `ctx.latestBlockOfDay`, whose `lastBlockPerDay` / `blocks.at(-1)` are computed per
19+
* batch and therefore fire on batch boundaries — non-deterministic timing and redundant work.
20+
*
21+
* `carry` must persist across batches: declare it once at processor scope.
22+
*/
23+
export const forEachBlockByDay = async (
24+
ctx: Context,
25+
carry: DayBoundaryCarry,
26+
handlers: {
27+
onBlock: (block: Block) => Promise<void>
28+
onDayEnd: (dayEndBlock: Block) => Promise<void>
29+
},
30+
) => {
31+
const blocks = ctx.blocks
32+
for (let i = 0; i < blocks.length; i++) {
33+
const block = blocks[i]
34+
const prev = i === 0 ? carry.lastBlock : blocks[i - 1]
35+
// A new UTC day has started, so `prev` was the previous day's last block — finalize it there.
36+
if (prev && utcDate(prev) !== utcDate(block)) {
37+
await handlers.onDayEnd(prev)
38+
}
39+
await handlers.onBlock(block)
40+
}
41+
if (blocks.length > 0) {
42+
const tail = blocks[blocks.length - 1]
43+
carry.lastBlock = tail
44+
// At the chain head the current day is incomplete; sweep the latest block for live freshness.
45+
if (ctx.isHead) await handlers.onDayEnd(tail)
46+
}
47+
}

0 commit comments

Comments
 (0)