diff --git a/fees/cap/config.ts b/fees/cap/config.ts new file mode 100644 index 0000000000..647c80f325 --- /dev/null +++ b/fees/cap/config.ts @@ -0,0 +1,71 @@ +import { CHAIN } from "../../helpers/chains"; + +export const capConfig = { + [CHAIN.ETHEREUM]: { + fromBlock: 22867447, + fromTime: 1751892887, + fromDate: "2025-07-07", + infra: { + oracle: { + address: "0xcD7f45566bc0E7303fB92A93969BB4D3f6e662bb", + fromBlock: 22867447, + }, + lender: { + address: "0x15622c3dbbc5614E6DFa9446603c1779647f01FC", + fromBlock: 22867447, + }, + delegation: { + address: "0xF3E3Eae671000612CE3Fd15e1019154C1a4d693F", + fromBlock: 22867447, + }, + }, + tokens: { + cUSD: { + id: "cUSD", + coingeckoId: "cap-usd", + decimals: 18, + address: "0xcCcc62962d17b8914c62D74FfB843d73B2a3cccC", + fromBlock: 22874015, + }, + stcUSD: { + id: "stcUSD", + coingeckoId: "cap-staked-usd", + decimals: 18, + address: "0x88887bE419578051FF9F4eb6C858A951921D8888", + fromBlock: 22874056, + }, + }, + }, +} as const; + +export const capABI = { + Vault: { + insuranceFund: "function insuranceFund() external view returns (address)", + interestReceiver: + "function interestReceiver() public view returns (address)", + AddAssetEvent: "event AddAsset(address asset)", + }, + Lender: { + ReserveAssetAddedEvent: + "event ReserveAssetAdded(address indexed asset, address vault, address debtToken, address interestReceiver, uint256 id)", + ReserveInterestReceiverUpdatedEvent: + "event ReserveInterestReceiverUpdated(address indexed asset, address interestReceiver)", + RealizeInterestEvent: + "event RealizeInterest(address indexed asset, uint256 realizedInterest, address interestReceiver)", + RepayEvent: + "event Repay(address indexed asset, address indexed agent, (uint256 repaid, uint256 vaultRepaid, uint256 restakerRepaid, uint256 interestRepaid) details)", + }, + FeeReceiver: { + FeesDistributedEvent: "event FeesDistributed(uint256 amount)", + ProtocolFeeClaimed: "event ProtocolFeeClaimed(uint256 amount)", + }, + Delegation: { + DistributeReward: + "event DistributeReward(address agent, address asset, uint256 amount)", + }, +} as const; + +// should be ignored for revenue calculation +export const devAddresses = ["0xc1ab5a9593e6e1662a9a44f84df4f31fc8a76b52"]; + +export const vaultsSymbols = ["cUSD"]; diff --git a/fees/cap/helpers.ts b/fees/cap/helpers.ts new file mode 100644 index 0000000000..f90ef42c35 --- /dev/null +++ b/fees/cap/helpers.ts @@ -0,0 +1,109 @@ +import { FetchOptions } from "../../adapters/types"; +import { capABI, capConfig, devAddresses, vaultsSymbols } from "./config"; + +export const arrayZip = (a: A[], b: B[]) => { + const maxLength = Math.max(a.length, b.length); + return Array.from({ length: maxLength }, (_, i) => [a[i], b[i]]) as [A, B][]; +}; + +export const isKnownVault = (options: FetchOptions, vault: string) => { + const vaults = vaultsSymbols.map( + (symbol) => capConfig[options.chain].tokens[symbol], + ); + return vaults.map((vault) => vault.address.toLowerCase()).includes(vault); +}; + +export const fetchAssetAddresses = async ( + options: FetchOptions, + chain: string, +) => { + const infra = capConfig[chain].infra; + const tokens = capConfig[chain].tokens; + const lender = infra.lender; + + const cUSDVaultAssetAddresses = await options.getLogs({ + eventAbi: capABI.Vault.AddAssetEvent, + target: tokens.cUSD.address, + fromBlock: tokens.cUSD.fromBlock, + }); + + const lenderReserveAssetAddresses = await options.getLogs({ + eventAbi: capABI.Lender.ReserveAssetAddedEvent, + target: lender.address, + fromBlock: lender.fromBlock, + }); + + return [ + ...new Set([ + ...cUSDVaultAssetAddresses.map((event) => event.asset.toLowerCase()), + ...lenderReserveAssetAddresses.map((event) => event.asset.toLowerCase()), + ]), + ]; +}; + +export const fetchVaultConfigs = async (options: FetchOptions) => { + const infra = capConfig[options.chain].infra; + + const assetAddedEvents = await options.getLogs({ + target: infra.lender.address, + eventAbi: capABI.Lender.ReserveAssetAddedEvent, + fromBlock: infra.lender.fromBlock, + }); + + const vaultConfigsByAsset: Record< + string, + { asset: string; vault: string; interestReceivers: string[] } + > = {}; + for (const event of assetAddedEvents) { + const asset = event.asset.toLowerCase(); + const vault = event.vault.toLowerCase(); + if (!isKnownVault(options, vault)) { + continue; + } + + const interestReceiver = event.interestReceiver.toLowerCase(); + if (!vaultConfigsByAsset[asset]) { + vaultConfigsByAsset[asset] = { asset, vault, interestReceivers: [] }; + } else if (vaultConfigsByAsset[asset].vault !== vault) { + throw new Error( + `Vault mismatch for asset ${asset}: ${vaultConfigsByAsset[asset].vault} !== ${vault}`, + ); + } + vaultConfigsByAsset[asset].interestReceivers.push(interestReceiver); + } + + const interestReceiverUpdatedEvents = await options.getLogs({ + target: infra.lender.address, + eventAbi: capABI.Lender.ReserveInterestReceiverUpdatedEvent, + fromBlock: infra.lender.fromBlock, + }); + for (const event of interestReceiverUpdatedEvents) { + const asset = event.asset.toLowerCase(); + const interestReceiver = event.interestReceiver.toLowerCase(); + if (!vaultConfigsByAsset[asset]) { + throw new Error(`Asset ${asset} not found in vaultConfigsByAsset`); + } + vaultConfigsByAsset[asset].interestReceivers.push(interestReceiver); + } + + const vaultConfigs = Object.values(vaultConfigsByAsset); + + const insuranceFunds: string[] = ( + await options.api.batchCall( + vaultConfigs.map(({ vault }) => ({ + target: vault, + abi: capABI.Vault.insuranceFund, + })), + ) + ).map((i) => i.toLowerCase()); + + const result = arrayZip(vaultConfigs, insuranceFunds).map( + ([vaultConfig, insuranceFund]) => ({ + ...vaultConfig, + insuranceFund: devAddresses.includes(insuranceFund) + ? null + : insuranceFund, + }), + ); + return result; +}; diff --git a/fees/cap/index.ts b/fees/cap/index.ts new file mode 100644 index 0000000000..b8608aaa05 --- /dev/null +++ b/fees/cap/index.ts @@ -0,0 +1,118 @@ +import { FetchOptions, SimpleAdapter } from "../../adapters/types"; +import { addTokensReceived } from "../../helpers/token"; +import { CHAIN } from "../../helpers/chains"; +import { capABI, capConfig } from "./config"; +import { fetchAssetAddresses, fetchVaultConfigs } from "./helpers"; + +const fetch = async (options: FetchOptions) => { + const infra = capConfig[options.chain].infra; + const assetAddresses = await fetchAssetAddresses(options, options.chain); + const vaultConfigs = await fetchVaultConfigs(options); + + const feesDistributedLogs = ( + await Promise.all( + vaultConfigs + .map((vaultConfig) => + vaultConfig.interestReceivers.map(async (interestReceiver) => { + const logs = await options.getLogs({ + target: interestReceiver, + eventAbi: capABI.FeeReceiver.FeesDistributedEvent, + }); + + return logs.map((log) => ({ + feeAsset: vaultConfig.vault, // fee is collected in vault assets + amount: log.amount, + })); + }), + ) + .flat(), + ) + ).flat(); + const minterFees = options.createBalances(); + for (const { feeAsset, amount } of feesDistributedLogs) { + minterFees.add(feeAsset, amount); + } + + const protocolFeeClaimedLogs = ( + await Promise.all( + vaultConfigs + .map((vaultConfig) => + vaultConfig.interestReceivers.map(async (interestReceiver) => { + const logs = await options.getLogs({ + target: interestReceiver, + eventAbi: capABI.FeeReceiver.ProtocolFeeClaimed, + }); + + return logs.map((log) => ({ + feeAsset: vaultConfig.vault, // fee is collected in vault assets + amount: log.amount, + })); + }), + ) + .flat(), + ) + ).flat(); + const protocolFees = options.createBalances(); + for (const { feeAsset, amount } of protocolFeeClaimedLogs) { + protocolFees.add(feeAsset, amount); + } + + const restakerFeesLogs = await options.getLogs({ + target: infra.delegation.address, + eventAbi: capABI.Delegation.DistributeReward, + }); + const restakerFees = options.createBalances(); + for (const log of restakerFeesLogs) { + restakerFees.add(log.asset, log.amount); + } + + const insuranceFunds = vaultConfigs + .map((i) => i.insuranceFund) + .filter((i) => i !== null); + const minters = vaultConfigs.map((i) => i.vault); + const insuranceFundFees = + insuranceFunds.length > 0 + ? await addTokensReceived({ + options, + fromAdddesses: minters, + tokens: assetAddresses, + targets: insuranceFunds, + }) + : options.createBalances(); + + const dailyFees = options.createBalances(); + dailyFees.addBalances(minterFees); + dailyFees.addBalances(protocolFees); + dailyFees.addBalances(restakerFees); + dailyFees.addBalances(insuranceFundFees); + + const dailyRevenue = options.createBalances(); + dailyRevenue.addBalances(protocolFees); + + const dailySupplySideRevenue = dailyFees.clone(); + dailySupplySideRevenue.subtract(dailyRevenue) + + return { + dailyFees, + dailySupplySideRevenue, + dailyRevenue: dailyRevenue, + dailyProtocolRevenue: dailyRevenue, + }; +}; + +const methodology = { + Fees: "All fees paid by users for either borrowing (borrow fees + restaker fees) or minting (insurance fund fees).", + Revenue: "Share of borrow fees for protocol", + SupplySideRevenue: "Borrow fees distributed to stakers and restaker fees are distributed to delegators.", + ProtocolRevenue: "Share of borrow fees for protocol", +}; + +const adapter: SimpleAdapter = { + version: 2, + fetch, + chains: [CHAIN.ETHEREUM], + start: capConfig[CHAIN.ETHEREUM].fromDate, + methodology, +}; + +export default adapter;