Skip to content

Commit 92174d0

Browse files
committed
fix(address-apy): guard realized-APY resolvers against corrupt exit-day rows
Per-address yield rows pair a settlement day's yield with the post-change balance, so a holder exiting to dust divides real yield by a near-zero denominator: d = Sum(yield)/Sum(denom) explodes, overflowing power(1+d,365) (500s ~2.3% of holders) or returning absurd APYs (~0.9%). Resolver-only fix, no schema/processor/reindex: - Drop per-day rows where |yield| > 0.2%/day * denom (legit tops out ~0.07%/day across all products; removes every artifact, 0 legit rows). - Clamp |d| <= 0.2%/day before annualizing so power() can't overflow. - Portfolio dust floor: only positions >= $0.01 count as held, so exited holders report productCount 0 / apy 0 instead of a leftover position's rate. - Compound-blend: blend native daily rates by USD weight then annualize once, vs averaging per-product compounded APYs (overstated by up to ~36bps). Validated: exact SQL over Postgres edge cases (13/13), tsc clean. Permanent fix is a value-days accumulator in the checkpoint engine (needs reindex), tracked separately.
1 parent 2c8879e commit 92174d0

1 file changed

Lines changed: 80 additions & 14 deletions

File tree

src/server-extension/address-apy.ts

Lines changed: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,44 @@ import { EntityManager } from 'typeorm'
44
const E18 = 'power(10::numeric, 18)'
55
const E36 = 'power(10::numeric, 36)'
66

7+
/**
8+
* Cap on a single day's fractional return, used to discard corrupt per-day yield rows.
9+
*
10+
* Exit-day / cost-basis-reset checkpoints in the per-address yield series can book a
11+
* `yield` that is large relative to the (collapsed, near-zero) denominator that produced
12+
* it, giving per-day returns from ~0.4%/day up to 1e9+/day. Money-weighting does NOT
13+
* dilute these — Σyield stays large while Σdenom collapses — so the aggregate daily rate
14+
* `d` inflates: severe spikes push d past ~6, where power(1 + d, 365) overflows float8
15+
* (error 22003) and 500s the whole query; milder ones (~0.4–1%/day) yield absurd-but-
16+
* finite rates (150%–6000% APY).
17+
*
18+
* Empirically, legitimate per-day |yield|/denom tops out around 7e-4/day (0.07%) across
19+
* every product (ARM/OToken/wOToken/xOGN, p99 ≤ 3e-4), and the artifact band starts near
20+
* 2e-3/day (0.2%) with an empty gap between. 0.2%/day (a ~73% simple-APR-equivalent
21+
* single day — far above anything these products actually pay) sits in that gap: it drops
22+
* every artifact while leaving real days untouched, so healthy APY/APR is unchanged. The
23+
* filter is symmetric (|yield|) so negative artifacts are caught too, and it bounds
24+
* |d| ≤ MAX_DAILY_RATE, keeping the annualization finite.
25+
*/
26+
const MAX_DAILY_RATE = 0.002
27+
/** `WHERE`-clause fragment dropping corrupt (large-|yield|-vs-dust-denom) days. */
28+
const dailyRateFilter = (yieldCol: string, denomCol: string) =>
29+
`abs(${yieldCol}::numeric) <= ${denomCol}::numeric * ${MAX_DAILY_RATE}`
30+
31+
/**
32+
* Minimum current USD value for a position to count as "held" in the portfolio blend.
33+
*
34+
* The portfolio APY is meant to describe the holder's *current* positions. A withdrawal
35+
* leaves behind share/rounding dust (a few wei), and because a weighted average of a
36+
* single position equals that position's rate regardless of how small its absolute weight
37+
* is, a holder whose only remaining position is dust would otherwise be assigned that
38+
* (now meaningless) position's APY. Excluding positions below one cent drops fully-exited
39+
* holdings entirely — a dust-only holder correctly reports productCount 0 / apy 0 — and
40+
* matches the frontend already hiding dust from the Yield Positions list. Any genuine
41+
* position is worth far more than a cent, so real holdings are never excluded.
42+
*/
43+
const DUST_USD = 0.01
44+
745
/**
846
* Per-address realized yield rate for a holder of an ARM vault or an OToken.
947
*
@@ -174,25 +212,32 @@ export class AddressApyResolver {
174212
const chainFilter = (col: string) => `($2::int IS NULL OR ${col} = $2)`
175213
const sql = `
176214
WITH native AS (
177-
-- per-product native all-time daily rate, over the days the position was held
178-
SELECT chain_id, product, sum_yield / sum_denom AS d_native
215+
-- per-product native all-time daily rate, over the days the position was held.
216+
-- d_native is clamped to ±MAX_DAILY_RATE as a defensive backstop; the per-day
217+
-- filter (${MAX_DAILY_RATE}/day) below already bounds it there by dropping corrupt
218+
-- exit-day rows, so the clamp only bites if a future change removes the filter.
219+
SELECT chain_id, product,
220+
least(greatest((sum_yield / sum_denom)::float8, -${MAX_DAILY_RATE}), ${MAX_DAILY_RATE}) AS d_native
179221
FROM (
180222
SELECT chain_id, arm AS product,
181223
sum("yield"::numeric) AS sum_yield, sum(value::numeric) AS sum_denom
182224
FROM arm_address_yield
183225
WHERE address = $1 AND value > 0 AND ${chainFilter('chain_id')}
226+
AND ${dailyRateFilter('"yield"', 'value')}
184227
GROUP BY chain_id, arm
185228
UNION ALL
186229
SELECT chain_id, otoken,
187230
sum("yield"::numeric), sum(balance::numeric)
188231
FROM o_token_address_yield
189232
WHERE address = $1 AND balance > 0 AND ${chainFilter('chain_id')}
233+
AND ${dailyRateFilter('"yield"', 'balance')}
190234
GROUP BY chain_id, otoken
191235
UNION ALL
192236
SELECT chain_id, wotoken,
193237
sum("yield"::numeric), sum(value::numeric)
194238
FROM wo_token_address_yield
195239
WHERE address = $1 AND value > 0 AND ${chainFilter('chain_id')}
240+
AND ${dailyRateFilter('"yield"', 'value')}
196241
GROUP BY chain_id, wotoken
197242
UNION ALL
198243
-- xOGN staking: holder is the account column, product is the staking-contract address; the
@@ -201,6 +246,7 @@ export class AddressApyResolver {
201246
sum("yield"::numeric), sum(staked_balance::numeric)
202247
FROM es_address_yield
203248
WHERE account = $1 AND staked_balance > 0 AND ${chainFilter('chain_id')}
249+
AND ${dailyRateFilter('"yield"', 'staked_balance')}
204250
GROUP BY chain_id, address
205251
) s
206252
WHERE sum_denom > 0
@@ -257,18 +303,33 @@ export class AddressApyResolver {
257303
ORDER BY ey.chain_id, ey.address, ey.date DESC
258304
)
259305
) w
306+
),
307+
blend AS (
308+
-- One row: the USD-weighted daily rate across the holder's current positions.
309+
-- The dust floor (>= ${DUST_USD}) drops fully-exited positions so their leftover
310+
-- wei can't define the blend; a holder with no position above the floor yields no
311+
-- row here, and the final SELECT then returns 0 / 0.
312+
SELECT
313+
sum(weight.usd_value * native.d_native) AS sum_wd,
314+
sum(weight.usd_value) AS sum_w,
315+
count(*) AS product_count,
316+
sum(weight.usd_value) AS total_usd_value
317+
FROM native
318+
JOIN weight ON weight.chain_id = native.chain_id AND weight.product = native.product
319+
WHERE weight.usd_value >= ${DUST_USD}
260320
)
261321
SELECT
262-
coalesce(
263-
sum(weight.usd_value * (power(1 + native.d_native::float8, 365) - 1))
264-
/ NULLIF(sum(weight.usd_value), 0), 0) AS apy,
265-
coalesce(
266-
sum(weight.usd_value * (native.d_native::float8 * 365))
267-
/ NULLIF(sum(weight.usd_value), 0), 0) AS apr,
268-
count(*) FILTER (WHERE weight.usd_value > 0) AS product_count,
269-
coalesce(sum(weight.usd_value) FILTER (WHERE weight.usd_value > 0), 0) AS total_usd_value
270-
FROM native
271-
JOIN weight ON weight.chain_id = native.chain_id AND weight.product = native.product
322+
-- Blend the native daily rates by current USD weight, THEN annualize once. This
323+
-- compounds the *blended* daily rate rather than averaging each product's already-
324+
-- compounded APY (which overstates the blend when the per-product rates diverge).
325+
-- d is bounded to ±MAX_DAILY_RATE upstream, so power(1 + d, 365) cannot overflow.
326+
CASE WHEN sum_w IS NULL OR sum_w = 0 THEN 0
327+
ELSE power(1 + (sum_wd / sum_w), 365) - 1 END AS apy,
328+
CASE WHEN sum_w IS NULL OR sum_w = 0 THEN 0
329+
ELSE (sum_wd / sum_w) * 365 END AS apr,
330+
coalesce(product_count, 0) AS product_count,
331+
coalesce(total_usd_value, 0) AS total_usd_value
332+
FROM blend
272333
`
273334
const rows: Array<{
274335
apr: number | null
@@ -313,13 +374,18 @@ export class AddressApyResolver {
313374
FROM ${table}
314375
WHERE chain_id = $1 AND ${filterColumn} = lower($2) AND ${holderColumn} = lower($3)
315376
AND ${denomColumn} > 0
377+
AND ${dailyRateFilter('"yield"', denomColumn)}
316378
)
317379
SELECT
318380
held_days,
381+
-- The daily-rate filter bounds |d| ≤ MAX_DAILY_RATE; LEAST/GREATEST is a
382+
-- defensive backstop so power(1 + d, 365) can never overflow float8.
319383
CASE WHEN sum_denom IS NULL OR sum_denom = 0 THEN 0
320-
ELSE (sum_yield / sum_denom)::float8 * 365 END AS apr,
384+
ELSE least(greatest((sum_yield / sum_denom)::float8, -${MAX_DAILY_RATE}), ${MAX_DAILY_RATE}) * 365 END
385+
AS apr,
321386
CASE WHEN sum_denom IS NULL OR sum_denom = 0 THEN 0
322-
ELSE power(1 + (sum_yield / sum_denom)::float8, 365) - 1 END AS apy
387+
ELSE power(1 + least(greatest((sum_yield / sum_denom)::float8, -${MAX_DAILY_RATE}), ${MAX_DAILY_RATE}), 365) - 1 END
388+
AS apy
323389
FROM agg
324390
`
325391
const rows: Array<{ held_days: string | number | null; apr: number | null; apy: number | null }> =

0 commit comments

Comments
 (0)