Skip to content

Commit 9cfdc95

Browse files
authored
Merge pull request #2168 from ploutusFi/master
Ploutos-money yields + merkl incentives
2 parents 8a3f25c + 6bee16f commit 9cfdc95

File tree

2 files changed

+557
-0
lines changed

2 files changed

+557
-0
lines changed
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
// Ploutos Money Market (Aave v3 fork) — yield adapter with Merkl integration (dedup by opportunity id, SES-safe)
2+
// project: 'ploutos-money'
3+
4+
const axios = require('axios')
5+
const sdk = require('@defillama/sdk')
6+
const utils = require('../utils')
7+
const poolAbi = require('./poolAbi')
8+
9+
// ---------- chain maps ----------
10+
const protocolDataProviders = {
11+
base: '0x7dcb86dC49543E14A98F80597696fe5f444f58bC',
12+
arbitrum:'0x0F65a7fBCb69074cF8BE8De1E01Ef573da34bD59',
13+
polygon: '0x6A9b632010226F9bBbf2B6cb8B6990bE3F90cb0e',
14+
katana: '0x4DC446e349bDA9516033E11D63f1851d6B5Fd492',
15+
plasma: '0x9C48A6D3e859ab124A8873D73b2678354D0B4c0A',
16+
hemi: '0x0F65a7fBCb69074cF8BE8De1E01Ef573da34bD59',
17+
}
18+
19+
const CHAIN_NAME = {
20+
base: 'Base',
21+
arbitrum: 'Arbitrum',
22+
polygon: 'Polygon',
23+
katana: 'Katana',
24+
plasma: 'Plasma',
25+
hemi: 'Hemi',
26+
}
27+
28+
// chain IDs
29+
const CHAIN_ID = {
30+
base: 8453,
31+
arbitrum: 42161,
32+
polygon: 137,
33+
katana: 747474,
34+
plasma: 9745,
35+
hemi: 43111,
36+
}
37+
38+
function toMarketUrlParam(market) {
39+
if (market === 'ethereum') return 'mainnet'
40+
if (market === 'avax') return 'avalanche'
41+
if (market === 'xdai') return 'gnosis'
42+
if (market === 'bsc') return 'bnb'
43+
return market
44+
}
45+
46+
// ---------- math ----------
47+
const RAY = 1e27
48+
const aprRayToDecimal = (ray) => Number(ray) / RAY
49+
const aprToApyDecimal = (apr) => Math.pow(1 + apr / 365, 365) - 1
50+
51+
// ---------- helpers ----------
52+
const setToArray = (s) => Array.from(s ? s.values() : [])
53+
54+
/**
55+
* Extracts first valid 0x-address from any string (SES-safe)
56+
*/
57+
function extractAddrLoose(x) {
58+
if (x == null) return ''
59+
const s = String(x)
60+
.toLowerCase()
61+
.replace(/[\u200b-\u200d\uFEFF]/g, '') // remove zero-width chars
62+
.trim()
63+
const m = s.match(/0x[0-9a-f]{40}/i)
64+
return m ? m[0] : ''
65+
}
66+
const normAddr = extractAddrLoose
67+
68+
// ---------- Merkl ----------
69+
let merklCache = null
70+
71+
async function fetchMerkl() {
72+
if (merklCache) return merklCache
73+
try {
74+
const { data } = await axios.get('https://api.merkl.xyz/v4/opportunities', {
75+
params: { mainProtocolId: 'ploutos' },
76+
timeout: 15000,
77+
})
78+
merklCache = Array.isArray(data) ? data : (data ? [data] : [])
79+
} catch (e) {
80+
console.warn('[ploutos]', 'Merkl fetch failed:', e?.message || e)
81+
merklCache = []
82+
}
83+
return merklCache
84+
}
85+
86+
/**
87+
* Build Merkl index by key `${chainId}:${addressLower}`
88+
* Value shape:
89+
* { supplyOps: Map<opId,{apr:number,rewardTokens:string[]}>, borrowOps: Map<...> }
90+
*
91+
* Deduplicated by opportunity ID (it.id) to avoid double-counting the same
92+
* opportunity that appears under both aToken and underlying addresses.
93+
* SES-safe: no direct iteration with spreads.
94+
*/
95+
async function buildMerklIndex() {
96+
const items = await fetchMerkl()
97+
const index = new Map()
98+
99+
for (let idx = 0; idx < items.length; idx++) {
100+
const it = items[idx]
101+
const chainId = Number(it.chainId || 0)
102+
if (!chainId) continue
103+
104+
const side = String(it.type || '').toUpperCase().includes('BORROW') ? 'borrow' : 'supply'
105+
const apr = Number(it.apr || 0)
106+
const opId = String(it.id || '')
107+
if (!opId) continue
108+
109+
// collect reward tokens
110+
const rewardSet = new Set()
111+
const br = (it.rewardsRecord && it.rewardsRecord.breakdowns) || []
112+
for (let j = 0; j < br.length; j++) {
113+
const addr = normAddr(br[j]?.token?.address)
114+
if (addr) rewardSet.add(addr)
115+
}
116+
const rewardTokens = setToArray(rewardSet)
117+
118+
// bind this opportunity to possible keys (identifier, explorerAddress, tokens)
119+
const keysRaw = new Set()
120+
const k1 = normAddr(it.identifier)
121+
const k2 = normAddr(it.explorerAddress)
122+
if (k1) keysRaw.add(k1)
123+
if (k2) keysRaw.add(k2)
124+
const toks = Array.isArray(it.tokens) ? it.tokens : []
125+
for (let k = 0; k < toks.length; k++) {
126+
const a = normAddr(typeof toks[k] === 'string' ? toks[k] : toks[k]?.address)
127+
if (a) keysRaw.add(a)
128+
}
129+
130+
const keysArr = setToArray(keysRaw).map(a => `${chainId}:${a}`)
131+
for (let qi = 0; qi < keysArr.length; qi++) {
132+
const key = keysArr[qi]
133+
const cur = index.get(key) || { supplyOps: new Map(), borrowOps: new Map() }
134+
const bucket = side === 'borrow' ? cur.borrowOps : cur.supplyOps
135+
const ex = bucket.get(opId)
136+
if (ex) {
137+
// merge duplicate entries if the same op appears twice in API
138+
ex.apr += apr
139+
const s = new Set(ex.rewardTokens || [])
140+
for (let ri = 0; ri < rewardTokens.length; ri++) s.add(rewardTokens[ri])
141+
ex.rewardTokens = setToArray(s)
142+
} else {
143+
bucket.set(opId, { apr, rewardTokens })
144+
}
145+
index.set(key, cur)
146+
}
147+
}
148+
149+
return index
150+
}
151+
152+
// ---------- core adapter ----------
153+
async function getApy(market) {
154+
const chain = market
155+
const chainOut = CHAIN_NAME[market] ?? market
156+
const provider = protocolDataProviders[market]
157+
const chainId = CHAIN_ID[market] || 0
158+
if (!provider) return []
159+
160+
const reserves = (await sdk.api.abi.call({
161+
target: provider,
162+
abi: poolAbi.find(m => m.name === 'getAllReservesTokens'),
163+
chain,
164+
})).output
165+
166+
const aTokens = (await sdk.api.abi.call({
167+
target: provider,
168+
abi: poolAbi.find(m => m.name === 'getAllATokens'),
169+
chain,
170+
})).output
171+
172+
const reserveData = (await sdk.api.abi.multiCall({
173+
chain,
174+
abi: poolAbi.find(m => m.name === 'getReserveData'),
175+
calls: reserves.map(p => ({ target: provider, params: p.tokenAddress })),
176+
})).output.map(o => o.output)
177+
178+
const reserveCfg = (await sdk.api.abi.multiCall({
179+
chain,
180+
abi: poolAbi.find(m => m.name === 'getReserveConfigurationData'),
181+
calls: reserves.map(p => ({ target: provider, params: p.tokenAddress })),
182+
})).output.map(o => o.output)
183+
184+
const aSupplies = (await sdk.api.abi.multiCall({
185+
chain,
186+
abi: 'erc20:totalSupply',
187+
calls: aTokens.map(t => ({ target: t.tokenAddress })),
188+
})).output.map(o => o.output)
189+
190+
const underlyingBalances = (await sdk.api.abi.multiCall({
191+
chain,
192+
abi: 'erc20:balanceOf',
193+
calls: aTokens.map((t, i) => ({
194+
target: reserves[i].tokenAddress,
195+
params: [t.tokenAddress],
196+
})),
197+
})).output.map(o => o.output)
198+
199+
const aDecs = (await sdk.api.abi.multiCall({
200+
chain,
201+
abi: 'erc20:decimals',
202+
calls: aTokens.map(t => ({ target: t.tokenAddress })),
203+
})).output.map(o => o.output)
204+
205+
const uDecs = (await sdk.api.abi.multiCall({
206+
chain,
207+
abi: 'erc20:decimals',
208+
calls: reserves.map(p => ({ target: p.tokenAddress })),
209+
})).output.map(o => o.output)
210+
211+
const priceKeys = reserves.map(t => `${chain}:${t.tokenAddress}`).join(',')
212+
const prices = (await axios.get(`https://coins.llama.fi/prices/current/${priceKeys}`)).data?.coins || {}
213+
214+
// Merkl map
215+
const merklMap = await buildMerklIndex()
216+
217+
const out = []
218+
for (let i = 0; i < reserves.length; i++) {
219+
const r = reserves[i]
220+
const cfg = reserveCfg[i]
221+
if (cfg.isFrozen) continue
222+
223+
const symUp = String(r.symbol || '').toUpperCase()
224+
if (symUp === 'GHO' || symUp === 'SGHO' || symUp === 'STKGHO') continue
225+
226+
const price = prices[`${chain}:${r.tokenAddress}`]?.price
227+
if (!price) continue
228+
229+
const supplyAToken = Number(aSupplies[i]) / 10 ** Number(aDecs[i])
230+
const totalSupplyUsd = supplyAToken * price
231+
232+
const underlying = Number(underlyingBalances[i]) / 10 ** Number(uDecs[i])
233+
const tvlUsd = underlying * price
234+
235+
const totalBorrowUsd = Math.max(totalSupplyUsd - tvlUsd, 0)
236+
237+
const data = reserveData[i]
238+
const apyBase = aprToApyDecimal(aprRayToDecimal(data.liquidityRate)) * 100
239+
const apyBaseBorrow = aprToApyDecimal(aprRayToDecimal(data.variableBorrowRate)) * 100
240+
241+
const marketUrlParam = toMarketUrlParam(market)
242+
const url = `https://app.ploutos.money/reserve-overview/?underlyingAsset=${r.tokenAddress.toLowerCase()}&marketName=proto_${marketUrlParam}_v3`
243+
244+
// Merkl match by chainId + (aToken | underlying), deduplicated by opId
245+
const aTok = normAddr(aTokens[i].tokenAddress)
246+
const uTok = normAddr(r.tokenAddress)
247+
248+
const mAT = chainId ? merklMap.get(`${chainId}:${aTok}`) : undefined
249+
const mUA = chainId ? merklMap.get(`${chainId}:${uTok}`) : undefined
250+
251+
// Merge by unique opportunity ID for each side
252+
function unionOps(a, b, side) {
253+
const mapA = a ? (side === 'borrow' ? a.borrowOps : a.supplyOps) : null
254+
const mapB = b ? (side === 'borrow' ? b.borrowOps : b.supplyOps) : null
255+
const ids = new Set()
256+
if (mapA) mapA.forEach((_, id) => ids.add(id))
257+
if (mapB) mapB.forEach((_, id) => ids.add(id))
258+
259+
let aprSum = 0
260+
const rts = new Set()
261+
ids.forEach((id) => {
262+
const rec = (mapA && mapA.get(id)) || (mapB && mapB.get(id)) || null
263+
if (!rec) return
264+
aprSum += Number(rec.apr || 0)
265+
const tokens = rec.rewardTokens || []
266+
for (let t = 0; t < tokens.length; t++) rts.add(tokens[t])
267+
})
268+
return { apr: aprSum, rts: setToArray(rts) }
269+
}
270+
271+
const sup = unionOps(mAT, mUA, 'supply')
272+
const bor = unionOps(mAT, mUA, 'borrow')
273+
274+
const rewardUnion = new Set([...sup.rts, ...bor.rts])
275+
const rewardTokens = setToArray(rewardUnion)
276+
277+
const poolObj = {
278+
pool: `${aTokens[i].tokenAddress}-${(market === 'avax' ? 'avalanche' : market)}`.toLowerCase(),
279+
chain: chainOut,
280+
project: 'ploutos-money',
281+
symbol: r.symbol,
282+
tvlUsd,
283+
apyBase,
284+
apyBaseBorrow,
285+
underlyingTokens: [r.tokenAddress],
286+
totalSupplyUsd,
287+
totalBorrowUsd,
288+
ltv: cfg.ltv / 10000,
289+
borrowable: cfg.borrowingEnabled,
290+
url,
291+
}
292+
293+
if (sup.apr > 0) poolObj.apyReward = sup.apr
294+
if (bor.apr > 0) poolObj.apyRewardBorrow = bor.apr
295+
if (rewardTokens.length) poolObj.rewardTokens = rewardTokens
296+
297+
out.push(poolObj)
298+
}
299+
300+
return out
301+
}
302+
303+
async function apy() {
304+
const markets = Object.keys(protocolDataProviders)
305+
const res = await Promise.allSettled(markets.map(m => getApy(m)))
306+
return res
307+
.filter(r => r.status === 'fulfilled')
308+
.flatMap(r => r.value)
309+
.filter(p => utils.keepFinite(p))
310+
}
311+
312+
module.exports = {
313+
timetravel: false,
314+
apy,
315+
}

0 commit comments

Comments
 (0)