-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(strata-markets): switch from NAV delta to APR × TVL methodology #8825
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,9 @@ type CDOConfig = { | |
| jrt: string; | ||
| srt: string; | ||
| start: string; | ||
| // If set, the adapter reads getAprPairProjected() from this provider address | ||
| // instead of CDOLens when CDOLens returns zero APRs (zero-projection mode). | ||
| provider?: string; | ||
| }; | ||
|
|
||
| const CDOS: CDOConfig[] = [ | ||
|
|
@@ -74,113 +77,81 @@ const CDOS: CDOConfig[] = [ | |
| jrt: "0x1b2b8cFEF0b7B1Fad216b55fefeEb0c3349Da141", | ||
| srt: "0x8a646Edc4633ADBA5Ec87DedaF3Af958e268FE96", | ||
| start: "2026-07-09", | ||
| provider: "0x1FE39BE01BA0AF9f8D61A8a581eb7Df29c0BCe97", | ||
| }, | ||
| ]; | ||
|
|
||
| // events | ||
| const ERC4626_DEPOSIT = "event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares)"; | ||
| const ERC4626_WITHDRAW = "event Withdraw(address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares)"; | ||
| const FEE_ACCRUED = "event FeeAccrued(bool isJrt, uint256 amountToReserve, uint256 amountToTranche)"; | ||
| const RESERVE_REDUCED = "event ReserveReduced(address token, uint256 amount)"; | ||
| const CDO_LENS = "0xeA62e3a2D5FE8D5b66dc8E1bd2405AD23C851f4e"; | ||
|
|
||
| // ABIs | ||
| // ABIs | ||
| const GET_APRS_ABI = | ||
| "function getAPRs(address cdo) external view returns (int64 base, int64 target, int64 jrt, int64 srt)"; | ||
| const GET_APR_PAIR_PROJECTED_ABI = | ||
| "function getAprPairProjected() external view returns (int64 aprTarget, int64 aprBase, uint64 updatedAt)"; | ||
| const STRATEGY_TOTAL_ASSETS_ABI = "function totalAssets() view returns (uint256)"; | ||
| const RESERVE_BPS_ABI = "function reserveBps() view returns (uint256)"; | ||
| const ASSET_ABI = "function asset() view returns (address)"; | ||
| const CONVERT_TO_ASSETS_ABI = "function convertToAssets(address token, uint256 amount, uint8 rounding) view returns (uint256)"; | ||
|
|
||
| const sumLogField = (logs: any[], field: string): bigint => | ||
| logs.reduce<bigint>((acc, l) => acc + BigInt(l[field]), 0n); | ||
| // CDOLens APR values: raw / 1e10 = percentage (e.g. 39162436840 → 3.92%). | ||
| // To convert to a fraction we divide by 1e12 (= 1e10 × 100). | ||
| const APR_PRECISION = BigInt(1e12); | ||
| const SECONDS_PER_YEAR = 365 * 24 * 60 * 60; // 31_536_000 | ||
| const ONE_WAD = 10n ** 18n; | ||
|
|
||
| async function processCDO( | ||
| options: FetchOptions, | ||
| cfg: CDOConfig, | ||
| dailyFees: any, | ||
| dailyRevenue: any, | ||
| dailyProtocolRevenue: any, | ||
| dailySupplySideRevenue: any | ||
| dailySupplySideRevenue: any, | ||
| ) { | ||
| const { fromApi, toApi, getLogs } = options; | ||
| const { toApi } = options; | ||
|
|
||
| const [baseAsset, navStartRaw, navEndRaw, reserveBpsRaw] = await Promise.all([ | ||
| toApi.call({ target: cfg.jrt, abi: ASSET_ABI }) as Promise<string>, | ||
| fromApi.call({ target: cfg.strategy, abi: STRATEGY_TOTAL_ASSETS_ABI }), | ||
| // 1. Read APRs, strategy TVL, reserve fee rate, and base asset in parallel | ||
| const [aprs, tvlRaw, reserveBpsRaw, baseAsset] = await Promise.all([ | ||
| toApi.call({ target: CDO_LENS, abi: GET_APRS_ABI, params: [cfg.cdo] }), | ||
| toApi.call({ target: cfg.strategy, abi: STRATEGY_TOTAL_ASSETS_ABI }), | ||
| toApi.call({ target: cfg.accounting, abi: RESERVE_BPS_ABI }), | ||
| toApi.call({ target: cfg.jrt, abi: ASSET_ABI }) as Promise<string>, | ||
| ]); | ||
|
|
||
| const navStart = BigInt(navStartRaw); | ||
| const navEnd = BigInt(navEndRaw); | ||
| const reserveBps = BigInt(reserveBpsRaw); | ||
|
|
||
| const [ | ||
| jrtDeposits, | ||
| jrtWithdraws, | ||
| srtDeposits, | ||
| srtWithdraws, | ||
| feeAccrued, | ||
| reserveReduced, | ||
| ] = await Promise.all([ | ||
| getLogs({ target: cfg.jrt, eventAbi: ERC4626_DEPOSIT }), | ||
| getLogs({ target: cfg.jrt, eventAbi: ERC4626_WITHDRAW }), | ||
| getLogs({ target: cfg.srt, eventAbi: ERC4626_DEPOSIT }), | ||
| getLogs({ target: cfg.srt, eventAbi: ERC4626_WITHDRAW }), | ||
| getLogs({ target: cfg.accounting, eventAbi: FEE_ACCRUED }), | ||
| getLogs({ target: cfg.cdo, eventAbi: RESERVE_REDUCED }), | ||
| ]); | ||
|
|
||
| const inflows = | ||
| sumLogField(jrtDeposits, "assets") + sumLogField(srtDeposits, "assets"); | ||
| const outflowsToUsers = | ||
| sumLogField(jrtWithdraws, "assets") + sumLogField(srtWithdraws, "assets"); | ||
|
|
||
| let reserveOut = 0n; | ||
| for (const log of reserveReduced) { | ||
| const token = (log.token as string).toLowerCase(); | ||
| if (token === baseAsset.toLowerCase()) { | ||
| reserveOut += BigInt(log.amount); | ||
| } else { | ||
| const inBaseAssets: string = await toApi.call({ | ||
| target: cfg.strategy, | ||
| abi: CONVERT_TO_ASSETS_ABI, | ||
| params: [log.token, log.amount, 0], | ||
| // 2. Determine the base APR (gross yield rate of the underlying strategy) | ||
| // Priority: base → target → provider (for zero-projection markets) | ||
| let baseApr = Number(aprs.base); | ||
| if (baseApr <= 0) { | ||
| const target = Number(aprs.target); | ||
| if (target > 0) { | ||
| baseApr = target; | ||
| } else if (cfg.provider) { | ||
| const projected = await toApi.call({ | ||
| target: cfg.provider, | ||
| abi: GET_APR_PAIR_PROJECTED_ABI, | ||
| }); | ||
| reserveOut += BigInt(inBaseAssets); | ||
| baseApr = Number(projected.aprBase); | ||
| } | ||
| } | ||
|
|
||
| const exitFeeToReserve = sumLogField(feeAccrued, "amountToReserve"); | ||
| const exitFeeToTranche = sumLogField(feeAccrued, "amountToTranche"); | ||
| const exitFeesTotal = exitFeeToReserve + exitFeeToTranche; | ||
|
|
||
| // we calculate this yield from the delta of strategy assets. | ||
| // this can be negative when the strategy marks down, which happens on the | ||
| // RWA-backed CDOs whose NAV follows a discrete oracle (sUSDat/STRC) rather | ||
| // than a monotonic exchange rate. those losses are absorbed by the tranches, | ||
| // so they belong in supply side revenue as a negative, not clamped away. | ||
| // clamping each window at zero only ever books the up moves and ratchets | ||
| // cumulative fees upwards, which is worse under pullHourly because a day is | ||
| // cut into 24 chances to discard downside instead of 1. | ||
| const yieldAmount = navEnd - navStart - inflows + outflowsToUsers + reserveOut; | ||
|
|
||
| // the reserve takes a performance fee out of yield, but does not refund it on | ||
| // a loss, so on a negative window the whole markdown lands on the tranches. | ||
| const ONE = 10n ** 18n; | ||
| const protocolFromYield = | ||
| yieldAmount > 0n ? (yieldAmount * reserveBps) / ONE : 0n; | ||
| const supplyFromYield = yieldAmount - protocolFromYield; | ||
|
|
||
| dailyFees.add(baseAsset, yieldAmount.toString()); | ||
| dailyFees.add(baseAsset, exitFeesTotal.toString()); | ||
|
|
||
| dailyRevenue.add(baseAsset, protocolFromYield.toString()); | ||
| dailyRevenue.add(baseAsset, exitFeeToReserve.toString()); | ||
|
|
||
| dailyProtocolRevenue.add(baseAsset, protocolFromYield.toString()); | ||
| dailyProtocolRevenue.add(baseAsset, exitFeeToReserve.toString()); | ||
|
|
||
| dailySupplySideRevenue.add(baseAsset, supplyFromYield.toString()); | ||
| dailySupplySideRevenue.add(baseAsset, exitFeeToTranche.toString()); | ||
| if (baseApr <= 0) return; | ||
|
|
||
| // 3. Compute yield for the actual time window | ||
| const windowSeconds = options.endTimestamp - options.startTimestamp; | ||
| const tvl = BigInt(tvlRaw); | ||
| const reserveBps = BigInt(reserveBpsRaw); | ||
| const windowYield = | ||
| (tvl * BigInt(baseApr) * BigInt(windowSeconds)) / | ||
| (APR_PRECISION * BigInt(SECONDS_PER_YEAR)); | ||
|
|
||
| // 4. Split into protocol revenue (performance fee) and supply-side | ||
| const protocolRevenue = | ||
| reserveBps > 0n ? (windowYield * reserveBps) / ONE_WAD : 0n; | ||
| const supplySideRevenue = windowYield - protocolRevenue; | ||
|
|
||
| // 5. Report | ||
| dailyFees.add(baseAsset, windowYield.toString()); | ||
| dailyRevenue.add(baseAsset, protocolRevenue.toString()); | ||
| dailyProtocolRevenue.add(baseAsset, protocolRevenue.toString()); | ||
| dailySupplySideRevenue.add(baseAsset, supplySideRevenue.toString()); | ||
|
Comment on lines
+150
to
+154
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Restore redemption-fee accounting. The new reporting path includes only projected strategy yield. It removes the existing Add As per coding guidelines and path instructions, Also applies to: 177-182 🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||
| } | ||
|
|
||
| const fetch = async (options: FetchOptions) => { | ||
|
|
@@ -191,20 +162,20 @@ const fetch = async (options: FetchOptions) => { | |
|
|
||
| const active = CDOS.filter( | ||
| (c) => | ||
| new Date(c.start + "T00:00:00Z").getTime() / 1000 <= options.startTimestamp | ||
| new Date(c.start + "T00:00:00Z").getTime() / 1000 <= options.startTimestamp, | ||
| ); | ||
|
|
||
| await Promise.all( | ||
| active.map(async (cfg) => { | ||
| await processCDO( | ||
| active.map((cfg) => | ||
| processCDO( | ||
| options, | ||
| cfg, | ||
| dailyFees, | ||
| dailyRevenue, | ||
| dailyProtocolRevenue, | ||
| dailySupplySideRevenue | ||
| ); | ||
| }) | ||
| dailySupplySideRevenue, | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| return { | ||
|
|
@@ -216,25 +187,23 @@ const fetch = async (options: FetchOptions) => { | |
| }; | ||
|
|
||
| const methodology = { | ||
| Fees: "Includes yield generated on deposited assets and redemption fees charged by Strata.", | ||
| Revenue: "Protocol revenue consists of performance fees (5-10%) charged by Strata on the yield generated and redemption fees paid by the users.", | ||
| ProtocolRevenue: "Protocol revenue consists of performance and redemption fees collected by Strata, including the portion of fees shared with reserve.", | ||
| SupplySideRevenue: "Net yield distributed to tranches (after performance fees) plus the portion of redemption fees that remain in the tranche. Goes negative on days a strategy marks down, since those losses are absorbed by the tranches.", | ||
| Fees: "Yield generated across all Strata tranches, computed as on-chain base APR × strategy TVL, pro-rated to the time window. APRs are read from CDOLens (falling back to target APR or provider for zero-projection markets like nOPAL).", | ||
| Revenue: "Performance fees collected by the protocol, computed as gross yield × reserveBps.", | ||
| ProtocolRevenue: "Same as Revenue — performance fees accrued to the Strata reserve.", | ||
| SupplySideRevenue: "Net yield distributed to senior and junior tranche depositors after performance fees.", | ||
| }; | ||
|
|
||
| const earliestStart = CDOS.reduce( | ||
| (min, c) => (c.start < min ? c.start : min), | ||
| CDOS[0].start | ||
| CDOS[0].start, | ||
| ); | ||
|
|
||
| const adapter: SimpleAdapter = { | ||
| version: 2, | ||
| pullHourly: true, | ||
| fetch, | ||
| chains: [CHAIN.ETHEREUM], | ||
| start: earliestStart, | ||
| methodology, | ||
|
Comment on lines
+190
to
206
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Set The changed adapter configuration removes As per coding guidelines, “Every 🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||
| allowNegativeValue: true, // strategy NAV can mark down, the loss is absorbed by the tranches | ||
| }; | ||
|
|
||
| export default adapter; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Document the new contract addresses.
Add a comment and source link for the nOPAL APR provider and
CDO_LENS. State each contract's role.As per coding guidelines, “Document every hardcoded rate, address, or magic number with a comment and, where possible, a source link.”
Also applies to: 84-84
🤖 Prompt for AI Agents
Source: Coding guidelines