-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: add ScrubVault yield pools to scrub project (Kava USDt + Arbitrum USDC) #2678
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
Open
gaspare100
wants to merge
6
commits into
DefiLlama:master
Choose a base branch
from
gaspare100:feat/scrubvault-yield
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+222
−0
Open
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
1231786
feat: add ScrubVault yield pools to scrub project (Kava USDt + Arbitr…
6ddf76a
fix: correct vault URL to https://invest.scrub.money/
d8f8cd7
refactor: address PR review comments - use SDK for getLogs, finite AP…
b32eae5
feat(scrub): daily APR from RewardDistributed events via SDK getLogs
a3707f2
fix(scrub): address PR review — rolling-window APR, safe BigNumber ma…
fe5729f
fix(scrub): overflow-safe TVL and propagate getLogs errors
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| /** | ||
| * ScrubVault yield adapter for DefiLlama. | ||
| * | ||
| * ScrubVault is a delta-neutral managed vault where users deposit USDt (Kava) | ||
| * or USDC (Arbitrum) and receive share tokens representing their proportional | ||
| * ownership. The deposited capital is deployed off-chain across centralised and | ||
| * decentralised exchanges running a delta-neutral strategy that earns yield from | ||
| * funding rates and market-making activity. | ||
| * | ||
| * Why funds are not held in the vault contract: | ||
| * The vault contract functions as an on-chain accounting and settlement layer. | ||
| * Once a deposit batch is processed, the stablecoins are transferred to the | ||
| * strategy wallet and actively deployed on exchanges. The on-chain variable | ||
| * `totalVaultValue` is the authoritative AUM figure — it is updated by the | ||
| * admin/strategy role via `distributeRewards()` each time PnL is settled. | ||
| * TVL and APY reported here are therefore based on that on-chain accounting | ||
| * value, not on a simple `balanceOf` check. | ||
| * | ||
| * APY methodology: | ||
| * APR is derived from all `RewardDistributed` events within the past 3 days. | ||
| * All events are included — negative (loss) days reduce the result so the | ||
| * figure reflects the true rolling return, not just positive days. | ||
| * Rewards are distributed once per day (6 pm - 1 am UTC), so a 3-day | ||
| * lookback always captures at least one event. | ||
| * APR formula: sum(rewardAmount over window) / prevTVL / windowDays * 365 * 100. | ||
| * | ||
| * Logs are fetched via the DefiLlama SDK for both chains: | ||
| * - Arbitrum: single sdk.api.util.getLogs call (no per-request block limit). | ||
| * - Kava: RPC caps eth_getLogs at 10 000 blocks, so the 3-day window | ||
| * (~43 200 blocks) is split into 5 parallel SDK calls. | ||
| * - Kava's latest block is obtained via ethers.providers.JsonRpcProvider | ||
| * because sdk.api.util.getLatestBlock does not support Kava. | ||
| */ | ||
|
|
||
| const sdk = require('@defillama/sdk'); | ||
| const { ethers } = require('ethers'); | ||
|
|
||
| // ─── Constants ──────────────────────────────────────────────────────────────── | ||
|
|
||
| const PROJECT = 'scrub'; | ||
|
|
||
| const VAULTS = { | ||
| kava: { | ||
| chain: 'Kava', | ||
| address: '0x7BFf6c730dA681dF03364c955B165576186370Bc', | ||
| stablecoin: '0x919C1c267BC06a7039e03fcc2eF738525769109c', // USDt on Kava | ||
| symbol: 'USDt', | ||
| decimals: 6, | ||
| poolMeta: 'Delta Neutral USDt Vault', | ||
| logChunkSize: 10000, // Kava RPC limit per eth_getLogs request | ||
| blockTime: 6, // seconds per block | ||
| rpcUrl: 'https://evm.kava.io', | ||
| }, | ||
| arbitrum: { | ||
| chain: 'Arbitrum', | ||
| address: '0x439a923517C4DFD3F3d0ABb0C36E356D39CF3f9D', | ||
| stablecoin: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // native USDC | ||
| symbol: 'USDC', | ||
| decimals: 6, | ||
| poolMeta: 'Delta Neutral USDC Vault', | ||
| logChunkSize: null, // Arbitrum handles large ranges in one call | ||
| blockTime: 0.25, | ||
| rpcUrl: null, // use sdk.api.util.getLatestBlock | ||
| }, | ||
| }; | ||
|
|
||
| // ─── ABI / event setup ─────────────────────────────────────────────────────── | ||
|
|
||
| const TOTAL_VAULT_VALUE_ABI = { | ||
| inputs: [], | ||
| name: 'totalVaultValue', | ||
| outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], | ||
| stateMutability: 'view', | ||
| type: 'function', | ||
| }; | ||
|
|
||
| const rewardIface = new ethers.utils.Interface([ | ||
| 'event RewardDistributed(int256 rewardAmount, uint256 newTotalVaultValue, uint256 timestamp)', | ||
| ]); | ||
| const REWARD_TOPIC = rewardIface.getEventTopic('RewardDistributed'); | ||
|
|
||
| // ─── Helpers ────────────────────────────────────────────────────────────────── | ||
|
|
||
| async function getLatestBlockNumber(chainKey, rpcUrl) { | ||
| if (rpcUrl) { | ||
| // sdk.api.util.getLatestBlock does not support all chains (e.g. Kava). | ||
| // Fall back to a direct JSON-RPC call via ethers — already a dependency. | ||
| const provider = new ethers.providers.JsonRpcProvider(rpcUrl); | ||
| return provider.getBlockNumber(); | ||
| } | ||
| return (await sdk.api.util.getLatestBlock(chainKey)).number; | ||
| } | ||
|
|
||
| async function fetchTvlUsd(chain, vaultAddress, decimals) { | ||
| const { output } = await sdk.api.abi.call({ | ||
| abi: TOTAL_VAULT_VALUE_ABI, | ||
| target: vaultAddress, | ||
| chain, | ||
| }); | ||
| return Number(output) / 10 ** decimals; | ||
| } | ||
|
|
||
| async function fetchRewardLogs(chain, vaultAddress, latestBlock, blockTime, chunkSize) { | ||
| const blockWindow = Math.ceil(3 * 24 * 3600 / blockTime); // 3-day window | ||
| const fromBlock = Math.max(0, latestBlock - blockWindow); | ||
|
|
||
| const logsCall = (from, to) => | ||
| sdk.api.util.getLogs({ | ||
| target: vaultAddress, | ||
| topic: '', | ||
| fromBlock: from, | ||
| toBlock: to, | ||
| keys: [], | ||
| topics: [REWARD_TOPIC], | ||
| chain, | ||
| }).then((r) => r.output || []).catch(() => []); | ||
|
|
||
| if (!chunkSize) { | ||
| return logsCall(fromBlock, latestBlock); | ||
| } | ||
|
|
||
| // Kava: split 3-day window into parallel 10k-block chunks (~5 chunks) | ||
| const chunks = []; | ||
| for (let s = fromBlock; s <= latestBlock; s += chunkSize) { | ||
| chunks.push([s, Math.min(s + chunkSize - 1, latestBlock)]); | ||
| } | ||
| const results = await Promise.all(chunks.map(([f, t]) => logsCall(f, t))); | ||
| return results.flat(); | ||
| } | ||
|
|
||
| function computeDailyApr(logs, decimals) { | ||
| // Parse all events in the window (logs are in chronological order, oldest first). | ||
| const events = []; | ||
| for (const log of logs) { | ||
| try { | ||
| const p = rewardIface.parseLog(log); | ||
| events.push({ reward: p.args.rewardAmount, newTvl: p.args.newTotalVaultValue }); | ||
| } catch (_) { | ||
| // skip unparseable logs | ||
| } | ||
| } | ||
| if (!events.length) return 0; | ||
|
|
||
| // Sum all rewards across the window — negative (loss) days are included so | ||
| // the result reflects the true rolling return, not just positive days. | ||
| let totalReward = ethers.BigNumber.from(0); | ||
| for (const { reward } of events) { | ||
| totalReward = totalReward.add(reward); | ||
| } | ||
|
|
||
| // TVL before the earliest event in the window is used as the denominator. | ||
| const firstPrevTvl = events[0].newTvl.sub(events[0].reward); | ||
| if (firstPrevTvl.lte(0)) return 0; | ||
|
|
||
| // Use formatUnits instead of toNumber() to avoid Number.MAX_SAFE_INTEGER | ||
| // overflow on large balances (toNumber throws above 2^53-1, ~$9B at 6 decimals). | ||
| const totalRewardUsd = Number(ethers.utils.formatUnits(totalReward, decimals)); | ||
| const prevTvlUsd = Number(ethers.utils.formatUnits(firstPrevTvl, decimals)); | ||
| if (prevTvlUsd <= 0) return 0; | ||
|
|
||
| // Annualise over the 3-day lookback window. | ||
| const WINDOW_DAYS = 3; | ||
| const apr = (totalRewardUsd / prevTvlUsd / WINDOW_DAYS) * 365 * 100; | ||
| return Number.isFinite(apr) ? apr : 0; | ||
| } | ||
|
|
||
| // ─── Main ───────────────────────────────────────────────────────────────────── | ||
|
|
||
| const apy = async () => { | ||
| const pools = []; | ||
|
|
||
| for (const [chainKey, vault] of Object.entries(VAULTS)) { | ||
| // Wrap each vault independently so an RPC outage on one chain does not | ||
| // prevent the other chain's pool from being reported. | ||
| try { | ||
| const latestBlock = await getLatestBlockNumber(chainKey, vault.rpcUrl); | ||
|
|
||
| const [tvlUsd, logs] = await Promise.all([ | ||
| fetchTvlUsd(chainKey, vault.address, vault.decimals), | ||
| fetchRewardLogs( | ||
| chainKey, | ||
| vault.address, | ||
| latestBlock, | ||
| vault.blockTime, | ||
| vault.logChunkSize, | ||
| ), | ||
| ]); | ||
|
|
||
| const apyBase = computeDailyApr(logs, vault.decimals); | ||
| const vaultUrl = | ||
| `https://invest.scrub.money/vault/chain/${chainKey}/${vault.address.toLowerCase()}`; | ||
|
|
||
| pools.push({ | ||
| pool: `${vault.address}-${chainKey}`.toLowerCase(), | ||
| chain: vault.chain, | ||
| project: PROJECT, | ||
| symbol: vault.symbol, | ||
| tvlUsd, | ||
| apyBase: Math.round(apyBase * 100) / 100, | ||
| underlyingTokens: [vault.stablecoin], | ||
| poolMeta: vault.poolMeta, | ||
| url: vaultUrl, | ||
| }); | ||
| } catch (err) { | ||
| console.error( | ||
| `[scrub] ${vault.chain} vault ${vault.address} failed: ${err.message}`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return pools; | ||
| }; | ||
|
|
||
| module.exports = { | ||
| timetravel: false, | ||
| apy, | ||
| url: 'https://invest.scrub.money/', | ||
| }; | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.