Skip to content

Commit 118cf17

Browse files
committed
feat: per-address yield for xOGN staking
xOGN was the last yield-bearing Origin product on the portfolio page without per-address yield. It's a MasterChef-style fixed-rate staking contract; adds ESAddressYield, a per-staker daily OGN-reward series built by the same balance*dR accumulator with R = accRewardPerShare (PRECISION 1e12, verified on-chain). balance tracks xOGN points (reward share); stakedBalance tracks the OGN principal (APY denominator). Captures unclaimed pending rewards — essential, since long-term stakers rarely claim (summing ESReward would show zero). Resolver: adds esAddressApy and a 4th UNION branch to addressApy (holder is the account column; USD-weighted by OGN principal x OGN price), degrading gracefully when the OGN price is unavailable. Restart re-hydration loads the previous-day baseline. Validated on the xOGN launch window: 121 stakers, zero negatives, cumulativeYield within 98.8-99.7% of on-chain claimed+previewRewards (the ~1% gap is stored-vs-live accRewardPerShare, self-correcting), per-address APY varies by lock multiplier, and yields stay continuous across a mid-day restart.
1 parent fc2ad3f commit 118cf17

9 files changed

Lines changed: 353 additions & 14 deletions

File tree

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

schema.graphql

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,27 @@ type ESYield @entity {
13941394
apy: Float!
13951395
}
13961396

1397+
# Per-holder realized staking yield, one row per staker per day.
1398+
# xOGN is a MasterChef-style accumulator: rewards (OGN) accrue as points * d(accRewardPerShare)/1e12.
1399+
# `balance` is the reward-share basis (xOGN points); `stakedBalance` is the OGN principal, which is
1400+
# the position value and the APY/roi denominator. yield/cumulativeYield are denominated in OGN.
1401+
type ESAddressYield @entity {
1402+
id: ID! # chainId:address:account:date
1403+
chainId: Int! @index
1404+
address: String! @index # xOGN staking contract
1405+
account: String! @index # staker
1406+
date: String! @index
1407+
timestamp: DateTime!
1408+
blockNumber: Int! @index
1409+
balance: BigInt! # xOGN points held (reward-share basis)
1410+
stakedBalance: BigInt! # OGN principal staked (position value / denominator)
1411+
yield: BigInt! # OGN rewards accrued on this date
1412+
cumulativeYield: BigInt! # lifetime OGN rewards accrued (claimed + pending)
1413+
roi: Float! # cumulativeYield / stakedBalance
1414+
lastR: BigInt! # last checkpointed accRewardPerShare
1415+
yieldRemainder: BigInt! # sub-unit accrual carry
1416+
}
1417+
13971418
type ESLockup @entity {
13981419
id: ID! # `chainId:address:account:lockupId` or `lockupId`
13991420
chainId: Int! @index

src/main-mainnet.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { mainnet } from 'viem/chains'
1010
import { defineSquidProcessor } from '@originprotocol/squid-utils'
1111
import * as exchangeRates from '@shared/post-processors/exchange-rates'
1212
import { ccip } from '@templates/ccip'
13-
import { createESTracker } from '@templates/exponential-staking'
13+
import { createESAddressYieldProcessor, createESTracker } from '@templates/exponential-staking'
1414
import { createFRRSProcessor } from '@templates/fixed-rate-rewards-source'
1515
import { createGovernanceProcessor } from '@templates/governance'
1616
import { createPoolsProcessor } from '@templates/pools/pools'
@@ -55,6 +55,7 @@ export const processor = defineSquidProcessor({
5555
rewardsAddress: '0x7609c88e5880e934dd3a75bcfef44e31b1badb8b',
5656
yieldType: 'fixed',
5757
}),
58+
createESAddressYieldProcessor({ from: 19919745, address: XOGN_ADDRESS }),
5859
createFRRSProcessor({ from: 19917521, address: OGN_REWARDS_SOURCE_ADDRESS }),
5960
coingeckoProcessor,
6061
...originArmProcessors,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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 ESAddressYield {
5+
constructor(props?: Partial<ESAddressYield>) {
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+
address!: string
19+
20+
@Index_()
21+
@StringColumn_({nullable: false})
22+
account!: string
23+
24+
@Index_()
25+
@StringColumn_({nullable: false})
26+
date!: string
27+
28+
@DateTimeColumn_({nullable: false})
29+
timestamp!: Date
30+
31+
@Index_()
32+
@IntColumn_({nullable: false})
33+
blockNumber!: number
34+
35+
@BigIntColumn_({nullable: false})
36+
balance!: bigint
37+
38+
@BigIntColumn_({nullable: false})
39+
stakedBalance!: bigint
40+
41+
@BigIntColumn_({nullable: false})
42+
yield!: bigint
43+
44+
@BigIntColumn_({nullable: false})
45+
cumulativeYield!: bigint
46+
47+
@FloatColumn_({nullable: false})
48+
roi!: number
49+
50+
@BigIntColumn_({nullable: false})
51+
lastR!: bigint
52+
53+
@BigIntColumn_({nullable: false})
54+
yieldRemainder!: bigint
55+
}

src/model/generated/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export * from "./frrsStrategistUpdated.model"
9898
export * from "./esToken.model"
9999
export * from "./esAccount.model"
100100
export * from "./esYield.model"
101+
export * from "./esAddressYield.model"
101102
export * from "./esLockup.model"
102103
export * from "./_esLockupState"
103104
export * from "./esLockupEvent.model"

src/server-extension/address-apy.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,27 @@ export class AddressApyResolver {
132132
return this.computeApy('wo_token_address_yield', 'wotoken', 'value', chainId, wotoken, address)
133133
}
134134

135+
/**
136+
* Realized APR/APY for an address's xOGN staking position. Rewards accrue in OGN; the denominator
137+
* is `staked_balance` (the OGN principal). Note the holder is the `account` column here (the
138+
* `address` column is the staking contract).
139+
*
140+
* @param chainId Chain ID (1 for Ethereum — xOGN is mainnet-only)
141+
* @param staking xOGN staking contract address (checksummed or lowercase)
142+
* @param account Staker address (checksummed or lowercase)
143+
*
144+
* @example
145+
* { esAddressApy(chainId: 1, staking: "0x6389...", account: "0x6330...") { apr apy heldDays } }
146+
*/
147+
@Query(() => AddressApyResult)
148+
async esAddressApy(
149+
@Arg('chainId', () => Int) chainId: number,
150+
@Arg('staking', () => String) staking: string,
151+
@Arg('account', () => String) account: string,
152+
): Promise<AddressApyResult> {
153+
return this.computeApy('es_address_yield', 'address', 'staked_balance', chainId, staking, account, 'account')
154+
}
155+
135156
/**
136157
* Blended realized APR/APY across every Origin position (ARM + OToken) an address
137158
* currently holds. Each position's native APY is weighted by its current USD value.
@@ -173,6 +194,14 @@ export class AddressApyResolver {
173194
FROM wo_token_address_yield
174195
WHERE address = $1 AND value > 0 AND ${chainFilter('chain_id')}
175196
GROUP BY chain_id, wotoken
197+
UNION ALL
198+
-- xOGN staking: holder is the account column, product is the staking-contract address; the
199+
-- denominator is the OGN principal (staked_balance), matching the OGN-denominated yield.
200+
SELECT chain_id, address AS product,
201+
sum("yield"::numeric), sum(staked_balance::numeric)
202+
FROM es_address_yield
203+
WHERE account = $1 AND staked_balance > 0 AND ${chainFilter('chain_id')}
204+
GROUP BY chain_id, address
176205
) s
177206
WHERE sum_denom > 0
178207
),
@@ -215,6 +244,18 @@ export class AddressApyResolver {
215244
WHERE wy.address = $1 AND ${chainFilter('wy.chain_id')}
216245
ORDER BY wy.chain_id, wy.wotoken, wy.date DESC
217246
)
247+
UNION ALL
248+
(
249+
-- xOGN staking value = OGN principal × current OGN/USD price. OGN is mainnet-only and its
250+
-- daily stat has no date column, so the latest price is used as the "current value" ruler.
251+
SELECT DISTINCT ON (ey.chain_id, ey.address)
252+
ey.chain_id, ey.address AS product,
253+
(ey.staked_balance::numeric / ${E18})::float8
254+
* (SELECT price_usd FROM ogn_daily_stat ORDER BY timestamp DESC LIMIT 1)
255+
FROM es_address_yield ey
256+
WHERE ey.account = $1 AND ${chainFilter('ey.chain_id')}
257+
ORDER BY ey.chain_id, ey.address, ey.date DESC
258+
)
218259
) w
219260
)
220261
SELECT
@@ -252,12 +293,15 @@ export class AddressApyResolver {
252293
* as query parameters.
253294
*/
254295
private async computeApy(
255-
table: 'arm_address_yield' | 'o_token_address_yield' | 'wo_token_address_yield',
256-
filterColumn: 'arm' | 'otoken' | 'wotoken',
257-
denomColumn: 'value' | 'balance',
296+
table: 'arm_address_yield' | 'o_token_address_yield' | 'wo_token_address_yield' | 'es_address_yield',
297+
filterColumn: 'arm' | 'otoken' | 'wotoken' | 'address',
298+
denomColumn: 'value' | 'balance' | 'staked_balance',
258299
chainId: number,
259300
filterValue: string,
260301
address: string,
302+
// Which column holds the account. Most tables put it in `address`; es_address_yield uses `account`
303+
// (its `address` column is the staking contract). Internal constant, never user input.
304+
holderColumn: 'address' | 'account' = 'address',
261305
): Promise<AddressApyResult> {
262306
const manager = await this.tx()
263307
const sql = `
@@ -267,7 +311,7 @@ export class AddressApyResolver {
267311
sum("yield"::numeric) AS sum_yield,
268312
sum(${denomColumn}::numeric) AS sum_denom
269313
FROM ${table}
270-
WHERE chain_id = $1 AND ${filterColumn} = lower($2) AND address = lower($3)
314+
WHERE chain_id = $1 AND ${filterColumn} = lower($2) AND ${holderColumn} = lower($3)
271315
AND ${denomColumn} > 0
272316
)
273317
SELECT

0 commit comments

Comments
 (0)