Skip to content

Commit fc2ad3f

Browse files
committed
feat: per-address yield for wrapped OTokens (wOETH/wOUSD/wsuperOETHb/wOS)
Wrapped OTokens are non-rebasing ERC4626 vaults, so per-holder yield accrues via assetsPerShare rather than balance. Adds WOTokenAddressYield: a per-holder daily series (value, cost basis, yield, roi) built by an ARM-style balance*dR checkpoint, emitted automatically as a companion of the OToken factory whenever a wotoken param is set. Extends the portfolio APY resolver to blend wrapped positions via the underlying OToken's USD rate. Design points worked out during development and folded in: - from is the wrapped vault's first-deposit (genesis) block: the proxy ERC4626 reverts on every view until initialized, so both the WOToken state snapshot and the yield sweep skip it until live, keeping from robust to being set early. - outflows clamp at zero (no phantom negatives); the daily sweep runs once per day (latestBlockOfDay); restart re-hydration loads the previous-day baseline. Validated on a real OETH backfill: value=balance*aps and cumulativeYield=value-cost hold exactly against on-chain, zero negatives, correct across a mid-day restart.
1 parent 1889539 commit fc2ad3f

17 files changed

Lines changed: 479 additions & 73 deletions

File tree

Lines changed: 17 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

schema.graphql

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,25 @@ type WOToken @entity {
446446
assetsPerShare: BigInt!
447447
}
448448

449+
type WOTokenAddressYield @entity {
450+
id: ID! # chainId:wotoken:address:date
451+
chainId: Int! @index
452+
wotoken: String! @index # wrapped ERC4626 token address (the ERC20 whose transfers are tracked)
453+
otoken: String! @index # underlying OToken address (denomination of value/yield; used for USD valuation)
454+
address: String! @index # holder
455+
date: String! @index
456+
timestamp: DateTime!
457+
blockNumber: Int! @index
458+
balance: BigInt! # wrapped-share balance
459+
value: BigInt! # balance * assetsPerShare / 1e18, in underlying OToken units
460+
costBasis: BigInt! # cost of the position, in underlying OToken units
461+
yield: BigInt! # yield accrued on this date, in underlying OToken units
462+
cumulativeYield: BigInt! # lifetime yield, in underlying OToken units
463+
roi: Float!
464+
lastR: BigInt! # last checkpointed assetsPerShare (previewRedeem(1e18))
465+
yieldRemainder: BigInt! # sub-wei accrual carry
466+
}
467+
449468
type OTokenAsset @entity {
450469
id: ID! @index
451470
chainId: Int! @index

src/main-oeth.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,21 @@
1-
import 'tsconfig-paths/register';
1+
import 'tsconfig-paths/register'
22

3+
import { defineSquidProcessor } from '@originprotocol/squid-utils'
4+
import * as exchangeRatesPostProcessor from '@shared/post-processors/exchange-rates'
5+
import { processStatus } from '@templates/processor-status'
6+
import { addresses } from '@utils/addresses'
7+
import { DEFAULT_FIELDS } from '@utils/batch-proccesor-fields'
8+
import { initProcessorFromDump } from '@utils/dumps'
39

4-
5-
import { defineSquidProcessor } from '@originprotocol/squid-utils';
6-
import * as exchangeRatesPostProcessor from '@shared/post-processors/exchange-rates';
7-
import { processStatus } from '@templates/processor-status';
8-
import { addresses } from '@utils/addresses';
9-
import { DEFAULT_FIELDS } from '@utils/batch-proccesor-fields';
10-
import { initProcessorFromDump } from '@utils/dumps';
11-
12-
13-
14-
import { oethProcessor } from './oeth/processors';
15-
import { oethExchangeRatesProcessor } from './oeth/processors/exchange-rates';
16-
import { oethStrategiesProcessor } from './oeth/processors/strategies';
17-
import { createOTokenWithdrawalsProcessor } from './templates/withdrawals';
18-
10+
import { oethProcessors } from './oeth/processors'
11+
import { oethExchangeRatesProcessor } from './oeth/processors/exchange-rates'
12+
import { oethStrategiesProcessor } from './oeth/processors/strategies'
13+
import { createOTokenWithdrawalsProcessor } from './templates/withdrawals'
1914

2015
export const processor = defineSquidProcessor({
2116
stateSchema: 'oeth-processor',
2217
processors: [
23-
oethProcessor,
18+
...oethProcessors,
2419
oethStrategiesProcessor,
2520
oethExchangeRatesProcessor,
2621
createOTokenWithdrawalsProcessor({
@@ -40,4 +35,4 @@ if (require.main === module) {
4035
initProcessorFromDump(processor).catch((error) => {
4136
throw error
4237
})
43-
}
38+
}

src/main-ousd.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { ousdProcessor } from 'ousd/processors/ousd'
1+
import { ousdProcessors } from 'ousd/processors/ousd'
22
import 'tsconfig-paths/register'
33

44
import { defineSquidProcessor } from '@originprotocol/squid-utils'
55
import * as exchangeRatesPostProcessor from '@shared/post-processors/exchange-rates'
6-
import { createOTokenActivityProcessor } from '@templates/otoken/activity-processor/activity-processor'
76
import { createMorphoVaultApyProcessor } from '@templates/morpho/processor'
7+
import { createOTokenActivityProcessor } from '@templates/otoken/activity-processor/activity-processor'
88
import { processStatus } from '@templates/processor-status'
99
import { createOTokenWithdrawalsProcessor } from '@templates/withdrawals'
1010
import { WOUSD_ADDRESS, addresses } from '@utils/addresses'
@@ -19,7 +19,7 @@ import { ousdStrategiesProcessor } from './ousd/processors/strategies/strategies
1919
export const processor = defineSquidProcessor({
2020
stateSchema: 'ousd-processor',
2121
processors: [
22-
ousdProcessor,
22+
...ousdProcessors,
2323
createOTokenActivityProcessor({
2424
from: 11590995,
2525
otokenAddress: addresses.ousd.address,

src/model/generated/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export * from "./poolBoosterBribeExecuted.model"
3636
export * from "./oToken.model"
3737
export * from "./oTokenRawData.model"
3838
export * from "./woToken.model"
39+
export * from "./woTokenAddressYield.model"
3940
export * from "./oTokenAsset.model"
4041
export * from "./oTokenAddress.model"
4142
export * from "./_rebasingOption"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {Entity as Entity_, Column as Column_, PrimaryColumn as PrimaryColumn_, IntColumn as IntColumn_, Index as Index_, StringColumn as StringColumn_, DateTimeColumn as DateTimeColumn_, BigIntColumn as BigIntColumn_, FloatColumn as FloatColumn_} from "@subsquid/typeorm-store"
2+
3+
@Entity_()
4+
export class WOTokenAddressYield {
5+
constructor(props?: Partial<WOTokenAddressYield>) {
6+
Object.assign(this, props)
7+
}
8+
9+
@PrimaryColumn_()
10+
id!: string
11+
12+
@Index_()
13+
@IntColumn_({nullable: false})
14+
chainId!: number
15+
16+
@Index_()
17+
@StringColumn_({nullable: false})
18+
wotoken!: string
19+
20+
@Index_()
21+
@StringColumn_({nullable: false})
22+
otoken!: string
23+
24+
@Index_()
25+
@StringColumn_({nullable: false})
26+
address!: string
27+
28+
@Index_()
29+
@StringColumn_({nullable: false})
30+
date!: string
31+
32+
@DateTimeColumn_({nullable: false})
33+
timestamp!: Date
34+
35+
@Index_()
36+
@IntColumn_({nullable: false})
37+
blockNumber!: number
38+
39+
@BigIntColumn_({nullable: false})
40+
balance!: bigint
41+
42+
@BigIntColumn_({nullable: false})
43+
value!: bigint
44+
45+
@BigIntColumn_({nullable: false})
46+
costBasis!: bigint
47+
48+
@BigIntColumn_({nullable: false})
49+
yield!: bigint
50+
51+
@BigIntColumn_({nullable: false})
52+
cumulativeYield!: bigint
53+
54+
@FloatColumn_({nullable: false})
55+
roi!: number
56+
57+
@BigIntColumn_({nullable: false})
58+
lastR!: bigint
59+
60+
@BigIntColumn_({nullable: false})
61+
yieldRemainder!: bigint
62+
}

src/oeth/processors/oeth.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as baseRewardPool from '@abi/base-reward-pool'
55
import * as erc20 from '@abi/erc20'
66
import { Context, createEvmBatchProcessor, defineProcessor } from '@originprotocol/squid-utils'
77
import { createOTokenActivityProcessor } from '@templates/otoken/activity-processor/activity-processor'
8-
import { createOTokenProcessor2 } from '@templates/otoken/otoken-2'
8+
import { createOTokenProcessors } from '@templates/otoken/otoken-2'
99
import {
1010
CURVE_ETH_OETH_POOL_ADDRESS,
1111
CURVE_FRXETH_OETH_POOL_ADDRESS,
@@ -29,7 +29,7 @@ import {
2929
} from '@utils/addresses'
3030
import { tokensByChain } from '@utils/tokensByChain'
3131

32-
const otokenProcessor = createOTokenProcessor2({
32+
const [otokenProcessor, ...otokenCompanionProcessors] = createOTokenProcessors({
3333
name: 'OETH',
3434
symbol: 'OETH',
3535
// from: 16933090, // https://etherscan.io/tx/0x3b4ece4f5fef04bf7ceaec4f6c6edf700540d7597589f8da0e3a8c94264a3b50
@@ -39,7 +39,13 @@ const otokenProcessor = createOTokenProcessor2({
3939
otokenAddress: OETH_ADDRESS,
4040
wotoken: {
4141
address: WOETH_ADDRESS,
42-
from: 17080507,
42+
// Track the wrapper from the OETH product's birth. The wrapped ERC4626 vault is a proxy that
43+
// reverts on every view until its first deposit (~17080493) initializes it, so both the WOToken
44+
// state snapshot and the per-holder yield tracker skip it until it goes live — no need to pin
45+
// `from` to the exact first-deposit block. (The earlier hardcoded 17080507 sat just past that
46+
// deposit and silently missed the first holder, whose later sends then looked like a negative
47+
// balance.) Robust-by-construction: an early `from` is safe; a late one loses history.
48+
from: 17076206,
4349
},
4450
dripper: [
4551
{
@@ -150,3 +156,7 @@ export const oethProcessor = defineProcessor({
150156
await Promise.all([otokenProcessor.process(ctx), otokenActivityProcessor.process(ctx)])
151157
},
152158
})
159+
160+
// The core OToken processor (merged with activity above) plus any companion processors the
161+
// factory emits — notably the wOETH per-holder yield tracker driven by the `wotoken` param.
162+
export const oethProcessors = [oethProcessor, ...otokenCompanionProcessors]

src/oethb/super-oeth-b.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
1-
import { parseEther } from 'viem';
2-
import { base } from 'viem/chains';
1+
import { parseEther } from 'viem'
2+
import { base } from 'viem/chains'
33

4+
import * as erc20Abi from '@abi/erc20'
5+
import { getPositions } from '@templates/aerodrome/lp'
6+
import { createOTokenActivityProcessor } from '@templates/otoken/activity-processor/activity-processor'
7+
import { createOTokenProcessors } from '@templates/otoken/otoken-2'
8+
import { createOTokenWithdrawalsProcessor } from '@templates/withdrawals'
9+
import { aerodromePools, baseAddresses } from '@utils/addresses-base'
10+
import { tokensByChain } from '@utils/tokensByChain'
411

12+
import { baseCurveAMO } from '../base/strategies'
513

6-
import * as erc20Abi from '@abi/erc20';
7-
import { getPositions } from '@templates/aerodrome/lp';
8-
import { createOTokenActivityProcessor } from '@templates/otoken/activity-processor/activity-processor';
9-
import { createOTokenProcessor2 } from '@templates/otoken/otoken-2';
10-
import { createOTokenWithdrawalsProcessor } from '@templates/withdrawals';
11-
import { aerodromePools, baseAddresses } from '@utils/addresses-base';
12-
import { tokensByChain } from '@utils/tokensByChain';
13-
14-
15-
16-
import { baseCurveAMO } from '../base/strategies';
17-
18-
19-
const otokenProcessor = createOTokenProcessor2({
14+
const [otokenProcessor, ...otokenCompanionProcessors] = createOTokenProcessors({
2015
name: 'Super OETHb',
2116
symbol: 'superOETHb',
2217
from: 17819702,
@@ -124,4 +119,9 @@ const otokenWithdrawalsProcessor = createOTokenWithdrawalsProcessor({
124119
from: 21544908,
125120
})
126121

127-
export const superOETHb = [otokenProcessor, otokenActivityProcessor, otokenWithdrawalsProcessor]
122+
export const superOETHb = [
123+
otokenProcessor,
124+
otokenActivityProcessor,
125+
otokenWithdrawalsProcessor,
126+
...otokenCompanionProcessors,
127+
]

src/ousd/processors/ousd/ousd.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { parseEther } from 'viem'
22
import { mainnet } from 'viem/chains'
33

44
import * as erc20Abi from '@abi/erc20'
5-
import { createOTokenProcessor2 } from '@templates/otoken/otoken-2'
5+
import { createOTokenProcessors } from '@templates/otoken/otoken-2'
66
import {
77
DAI_ADDRESS,
88
OUSD_ADDRESS,
@@ -20,7 +20,7 @@ import { tokensByChain } from '@utils/tokensByChain'
2020
// export const from = 11585978 // OUSDReset
2121
// export const from = 13533937 // https://etherscan.io/tx/0xc9b6fc6a4fad18dad197ff7d0636f74bf066671d75656849a1c45122e00d54cf
2222

23-
export const ousdProcessor = createOTokenProcessor2({
23+
export const ousdProcessors = createOTokenProcessors({
2424
name: 'OUSD',
2525
symbol: 'OUSD',
2626
from: 11590995, // OUSDReset~

src/server-extension/address-apy.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ export class AddressApyResolver {
111111
return this.computeApy('o_token_address_yield', 'otoken', 'balance', chainId, otoken, address)
112112
}
113113

114+
/**
115+
* Realized APR/APY for an address's position in a wrapped OToken (wOETH, wOUSD, wsuperOETHb, wOS).
116+
* The denominator is `value` (balance × assetsPerShare, in underlying OToken units) since a wrapped
117+
* OToken is non-rebasing — its share balance is constant and yield accrues via the share price.
118+
*
119+
* @param chainId Chain ID (e.g. 1 for Ethereum, 8453 for Base, 146 for Sonic)
120+
* @param wotoken Wrapped OToken address (checksummed or lowercase)
121+
* @param address Holder address (checksummed or lowercase)
122+
*
123+
* @example
124+
* { wOTokenAddressApy(chainId: 1, wotoken: "0xdcee...", address: "0xa4c6...") { apr apy heldDays } }
125+
*/
126+
@Query(() => AddressApyResult)
127+
async wOTokenAddressApy(
128+
@Arg('chainId', () => Int) chainId: number,
129+
@Arg('wotoken', () => String) wotoken: string,
130+
@Arg('address', () => String) address: string,
131+
): Promise<AddressApyResult> {
132+
return this.computeApy('wo_token_address_yield', 'wotoken', 'value', chainId, wotoken, address)
133+
}
134+
114135
/**
115136
* Blended realized APR/APY across every Origin position (ARM + OToken) an address
116137
* currently holds. Each position's native APY is weighted by its current USD value.
@@ -146,6 +167,12 @@ export class AddressApyResolver {
146167
FROM o_token_address_yield
147168
WHERE address = $1 AND balance > 0 AND ${chainFilter('chain_id')}
148169
GROUP BY chain_id, otoken
170+
UNION ALL
171+
SELECT chain_id, wotoken,
172+
sum("yield"::numeric), sum(value::numeric)
173+
FROM wo_token_address_yield
174+
WHERE address = $1 AND value > 0 AND ${chainFilter('chain_id')}
175+
GROUP BY chain_id, wotoken
149176
) s
150177
WHERE sum_denom > 0
151178
),
@@ -175,6 +202,19 @@ export class AddressApyResolver {
175202
WHERE oy.address = $1 AND ${chainFilter('oy.chain_id')}
176203
ORDER BY oy.chain_id, oy.otoken, oy.date DESC
177204
)
205+
UNION ALL
206+
(
207+
-- Wrapped-OToken value is already denominated in the underlying OToken (balance × aps),
208+
-- so it is priced with that OToken's own daily USD rate — same scaling as the OToken branch.
209+
SELECT DISTINCT ON (wy.chain_id, wy.wotoken)
210+
wy.chain_id, wy.wotoken AS product,
211+
(wy.value::numeric * ods.rate_usd::numeric / ${E36})::float8
212+
FROM wo_token_address_yield wy
213+
JOIN o_token_daily_stat ods
214+
ON ods.chain_id = wy.chain_id AND ods.otoken = wy.otoken AND ods.date = wy.date
215+
WHERE wy.address = $1 AND ${chainFilter('wy.chain_id')}
216+
ORDER BY wy.chain_id, wy.wotoken, wy.date DESC
217+
)
178218
) w
179219
)
180220
SELECT
@@ -212,8 +252,8 @@ export class AddressApyResolver {
212252
* as query parameters.
213253
*/
214254
private async computeApy(
215-
table: 'arm_address_yield' | 'o_token_address_yield',
216-
filterColumn: 'arm' | 'otoken',
255+
table: 'arm_address_yield' | 'o_token_address_yield' | 'wo_token_address_yield',
256+
filterColumn: 'arm' | 'otoken' | 'wotoken',
217257
denomColumn: 'value' | 'balance',
218258
chainId: number,
219259
filterValue: string,

0 commit comments

Comments
 (0)