1- // Ploutos money market (Aave v3 fork) — yield adapter with Merkl integration via Map (clean , SES-safe)
1+ // Ploutos Money Market (Aave v3 fork) — yield adapter with Merkl integration (dedup by opportunity id , SES-safe)
22// project: 'ploutos-money'
33
44const axios = require ( 'axios' )
@@ -25,7 +25,7 @@ const CHAIN_NAME = {
2525 hemi : 'Hemi' ,
2626}
2727
28- // chainIds (актуальные)
28+ // chain IDs
2929const CHAIN_ID = {
3030 base : 8453 ,
3131 arbitrum : 42161 ,
@@ -48,16 +48,17 @@ const RAY = 1e27
4848const aprRayToDecimal = ( ray ) => Number ( ray ) / RAY
4949const aprToApyDecimal = ( apr ) => Math . pow ( 1 + apr / 365 , 365 ) - 1
5050
51- // ---------- utils ----------
52- const WARN = ( ...a ) => console . warn ( '[ploutos]' , ...a )
51+ // ---------- helpers ----------
5352const setToArray = ( s ) => Array . from ( s ? s . values ( ) : [ ] )
5453
55- // Вытягивает первое валидное 0x-адресное вхождение из «грязной» строки (SES-safe)
54+ /**
55+ * Extracts first valid 0x-address from any string (SES-safe)
56+ */
5657function extractAddrLoose ( x ) {
5758 if ( x == null ) return ''
5859 const s = String ( x )
5960 . toLowerCase ( )
60- . replace ( / [ \u200b - \u200d \uFEFF ] / g, '' ) // удалить zero-width
61+ . replace ( / [ \u200b - \u200d \uFEFF ] / g, '' ) // remove zero-width chars
6162 . trim ( )
6263 const m = s . match ( / 0 x [ 0 - 9 a - f ] { 40 } / i)
6364 return m ? m [ 0 ] : ''
@@ -76,16 +77,20 @@ async function fetchMerkl() {
7677 } )
7778 merklCache = Array . isArray ( data ) ? data : ( data ? [ data ] : [ ] )
7879 } catch ( e ) {
79- WARN ( 'Merkl fetch failed:' , e ?. message || e )
80+ console . warn ( '[ploutos]' , 'Merkl fetch failed:' , e ?. message || e )
8081 merklCache = [ ]
8182 }
8283 return merklCache
8384}
8485
8586/**
86- * Индекс по ключу `${chainId}:${addressLower}`
87- * Map<string, { supply:{apr:number,rewardTokens:string[]}, borrow:{apr:number,rewardTokens:string[]} }>
88- * SES-safe: без спредов/for..of по Set
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.
8994 */
9095async function buildMerklIndex ( ) {
9196 const items = await fetchMerkl ( )
@@ -97,9 +102,11 @@ async function buildMerklIndex() {
97102 if ( ! chainId ) continue
98103
99104 const side = String ( it . type || '' ) . toUpperCase ( ) . includes ( 'BORROW' ) ? 'borrow' : 'supply'
100- const apr = Number ( it . apr || 0 )
105+ const apr = Number ( it . apr || 0 )
106+ const opId = String ( it . id || '' )
107+ if ( ! opId ) continue
101108
102- // reward tokens
109+ // collect reward tokens
103110 const rewardSet = new Set ( )
104111 const br = ( it . rewardsRecord && it . rewardsRecord . breakdowns ) || [ ]
105112 for ( let j = 0 ; j < br . length ; j ++ ) {
@@ -108,13 +115,12 @@ async function buildMerklIndex() {
108115 }
109116 const rewardTokens = setToArray ( rewardSet )
110117
111- // keys
118+ // bind this opportunity to possible keys (identifier, explorerAddress, tokens)
112119 const keysRaw = new Set ( )
113120 const k1 = normAddr ( it . identifier )
114121 const k2 = normAddr ( it . explorerAddress )
115122 if ( k1 ) keysRaw . add ( k1 )
116123 if ( k2 ) keysRaw . add ( k2 )
117-
118124 const toks = Array . isArray ( it . tokens ) ? it . tokens : [ ]
119125 for ( let k = 0 ; k < toks . length ; k ++ ) {
120126 const a = normAddr ( typeof toks [ k ] === 'string' ? toks [ k ] : toks [ k ] ?. address )
@@ -124,14 +130,18 @@ async function buildMerklIndex() {
124130 const keysArr = setToArray ( keysRaw ) . map ( a => `${ chainId } :${ a } ` )
125131 for ( let qi = 0 ; qi < keysArr . length ; qi ++ ) {
126132 const key = keysArr [ qi ]
127- const cur = index . get ( key ) || {
128- supply : { apr : 0 , rewardTokens : [ ] } ,
129- borrow : { apr : 0 , rewardTokens : [ ] } ,
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 } )
130144 }
131- cur [ side ] . apr += apr
132- const curSet = new Set ( cur [ side ] . rewardTokens )
133- for ( let rtIdx = 0 ; rtIdx < rewardTokens . length ; rtIdx ++ ) curSet . add ( rewardTokens [ rtIdx ] )
134- cur [ side ] . rewardTokens = setToArray ( curSet )
135145 index . set ( key , cur )
136146 }
137147 }
@@ -231,34 +241,39 @@ async function getApy(market) {
231241 const marketUrlParam = toMarketUrlParam ( market )
232242 const url = `https://app.ploutos.money/reserve-overview/?underlyingAsset=${ r . tokenAddress . toLowerCase ( ) } &marketName=proto_${ marketUrlParam } _v3`
233243
234- // Merkl match by chainId + (aToken | underlying)
244+ // Merkl match by chainId + (aToken | underlying), deduplicated by opId
235245 const aTok = normAddr ( aTokens [ i ] . tokenAddress )
236246 const uTok = normAddr ( r . tokenAddress )
237247
238248 const mAT = chainId ? merklMap . get ( `${ chainId } :${ aTok } ` ) : undefined
239249 const mUA = chainId ? merklMap . get ( `${ chainId } :${ uTok } ` ) : undefined
240250
241- let apyReward
242- let apyRewardBorrow
243- const rewardSet = new Set ( )
244-
245- if ( mAT ?. supply ) {
246- if ( mAT . supply . apr > 0 ) apyReward = ( apyReward || 0 ) + mAT . supply . apr
247- for ( const rt of mAT . supply . rewardTokens || [ ] ) rewardSet . add ( rt )
248- }
249- if ( mUA ?. supply ) {
250- if ( mUA . supply . apr > 0 ) apyReward = ( apyReward || 0 ) + mUA . supply . apr
251- for ( const rt of mUA . supply . rewardTokens || [ ] ) rewardSet . add ( rt )
252- }
253- if ( mAT ?. borrow ) {
254- if ( mAT . borrow . apr > 0 ) apyRewardBorrow = ( apyRewardBorrow || 0 ) + mAT . borrow . apr
255- for ( const rt of mAT . borrow . rewardTokens || [ ] ) rewardSet . add ( rt )
256- }
257- if ( mUA ?. borrow ) {
258- if ( mUA . borrow . apr > 0 ) apyRewardBorrow = ( apyRewardBorrow || 0 ) + mUA . borrow . apr
259- for ( const rt of mUA . borrow . rewardTokens || [ ] ) rewardSet . add ( rt )
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 ) }
260269 }
261270
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+
262277 const poolObj = {
263278 pool : `${ aTokens [ i ] . tokenAddress } -${ ( market === 'avax' ? 'avalanche' : market ) } ` . toLowerCase ( ) ,
264279 chain : chainOut ,
@@ -275,10 +290,9 @@ async function getApy(market) {
275290 url,
276291 }
277292
278- const rewardTokens = setToArray ( rewardSet )
293+ if ( sup . apr > 0 ) poolObj . apyReward = sup . apr
294+ if ( bor . apr > 0 ) poolObj . apyRewardBorrow = bor . apr
279295 if ( rewardTokens . length ) poolObj . rewardTokens = rewardTokens
280- if ( apyReward > 0 ) poolObj . apyReward = apyReward // Merkl APR уже в процентах
281- if ( apyRewardBorrow > 0 ) poolObj . apyRewardBorrow = apyRewardBorrow
282296
283297 out . push ( poolObj )
284298 }
0 commit comments