diff --git a/src/adaptors/evaa-protocol/getPrices.js b/src/adaptors/evaa-protocol/getPrices.js new file mode 100644 index 0000000000..ec5d0666a5 --- /dev/null +++ b/src/adaptors/evaa-protocol/getPrices.js @@ -0,0 +1,376 @@ +const fetch = require('node-fetch'); +const { Cell, Slice, Dictionary, beginCell } = require('@ton/core'); +const { signVerify } = require('@ton/crypto'); + +const ORACLES = [ + { + id: 0, + address: + '0xd3a8c0b9fd44fd25a49289c631e3ac45689281f2f8cf0744400b4c65bed38e5d', + pubkey: Buffer.from( + 'b404f4a2ebb62f2623b370c89189748a0276c071965b1646b996407f10d72eb9', + 'hex' + ), + }, + { + id: 1, + address: + '0x2c21cabdaa89739de16bde7bc44e86401fac334a3c7e55305fe5e7563043e191', + pubkey: Buffer.from( + '9ad115087520d91b6b45d6a8521eb4616ee6914af07fabdc2e9d1826dbb17078', + 'hex' + ), + }, + { + id: 2, + address: + '0x2eb258ce7b5d02466ab8a178ad8b0ba6ffa7b58ef21de3dc3b6dd359a1e16af0', + pubkey: Buffer.from( + 'e503e02e8a9226b34e7c9deb463cbf7f19bce589362eb448a69a8ee7b2fca631', + 'hex' + ), + }, + { + id: 3, + address: + '0xf9a0769954b4430bca95149fb3d876deb7799d8f74852e0ad4ccc5778ce68b52', + pubkey: Buffer.from( + '9cbf8374cf1f2cf17110134871d580198416e101683f4a61f54cf2a3e4e32070', + 'hex' + ), + }, +]; + +const TTL_ORACLE_DATA_SEC = 120; +const MINIMAL_ORACLES = 3; + +function verifyPricesTimestamp(priceData) { + const timestamp = Date.now() / 1000; + const pricesTime = priceData.timestamp; + return timestamp - pricesTime < TTL_ORACLE_DATA_SEC; +} + +function verifyPricesSign(priceData) { + const message = priceData.dataCell.refs[0].hash(); + const signature = priceData.signature; + const publicKey = priceData.pubkey; + + return signVerify(message, signature, publicKey); +} + +function getMedianPrice(pricesData, assetId) { + try { + const usingPrices = pricesData.filter((x) => x.dict.has(assetId)); + const sorted = usingPrices + .map((x) => x.dict.get(assetId)) + .sort((a, b) => Number(a) - Number(b)); + + if (sorted.length === 0) { + return null; + } + + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2n; + } else { + return sorted[mid]; + } + } catch { + return null; + } +} + +function packAssetsData(assetsData) { + if (assetsData.length === 0) { + throw new Error('No assets data to pack'); + } + return assetsData.reduceRight( + (acc, { assetId, medianPrice }) => + beginCell() + .storeUint(assetId, 256) + .storeCoins(medianPrice) + .storeMaybeRef(acc) + .endCell(), + null + ); +} + +function packPrices(assetsDataCell, oraclesDataCell) { + return beginCell() + .storeRef(assetsDataCell) + .storeRef(oraclesDataCell) + .endCell(); +} + +function readUnaryLength(slice) { + let res = 0; + while (slice.loadBit()) { + res++; + } + return res; +} + +function doGenerateMerkleProof(prefix, slice, n, keys) { + // Reading label + const originalCell = slice.asCell(); + + if (keys.length == 0) { + // no keys to prove, prune the whole subdict + return convertToPrunedBranch(originalCell); + } + + let lb0 = slice.loadBit() ? 1 : 0; + let prefixLength = 0; + let pp = prefix; + + if (lb0 === 0) { + // Short label detected + + // Read + prefixLength = readUnaryLength(slice); + + // Read prefix + for (let i = 0; i < prefixLength; i++) { + pp += slice.loadBit() ? '1' : '0'; + } + } else { + let lb1 = slice.loadBit() ? 1 : 0; + if (lb1 === 0) { + // Long label detected + prefixLength = slice.loadUint(Math.ceil(Math.log2(n + 1))); + for (let i = 0; i < prefixLength; i++) { + pp += slice.loadBit() ? '1' : '0'; + } + } else { + // Same label detected + let bit = slice.loadBit() ? '1' : '0'; + prefixLength = slice.loadUint(Math.ceil(Math.log2(n + 1))); + for (let i = 0; i < prefixLength; i++) { + pp += bit; + } + } + } + + if (n - prefixLength === 0) { + return originalCell; + } else { + let sl = originalCell.beginParse(); + let left = sl.loadRef(); + let right = sl.loadRef(); + // NOTE: Left and right branches are implicitly contain prefixes '0' and '1' + if (!left.isExotic) { + const leftKeys = keys.filter((key) => { + return pp + '0' === key.slice(0, pp.length + 1); + }); + left = doGenerateMerkleProof( + pp + '0', + left.beginParse(), + n - prefixLength - 1, + leftKeys + ); + } + if (!right.isExotic) { + const rightKeys = keys.filter((key) => { + return pp + '1' === key.slice(0, pp.length + 1); + }); + right = doGenerateMerkleProof( + pp + '1', + right.beginParse(), + n - prefixLength - 1, + rightKeys + ); + } + + return beginCell().storeSlice(sl).storeRef(left).storeRef(right).endCell(); + } +} + +function generateMerkleProofDirect(dict, keys, keyObject) { + keys.forEach((key) => { + if (!dict.has(key)) { + throw new Error( + `Trying to generate merkle proof for a missing key "${key}"` + ); + } + }); + const s = beginCell().storeDictDirect(dict).asSlice(); + return doGenerateMerkleProof( + '', + s, + keyObject.bits, + keys.map((key) => + keyObject.serialize(key).toString(2).padStart(keyObject.bits, '0') + ) + ); +} + +function endExoticCell(b) { + let c = b.endCell(); + return new Cell({ exotic: true, bits: c.bits, refs: c.refs }); +} + +function convertToMerkleProof(c) { + return endExoticCell( + beginCell() + .storeUint(3, 8) + .storeBuffer(c.hash(0)) + .storeUint(c.depth(0), 16) + .storeRef(c) + ); +} + +function createOracleDataProof(oracle, data, signature, assets) { + let prunedDict = generateMerkleProofDirect( + data.prices, + assets, + Dictionary.Keys.BigUint(256) + ); + let prunedData = beginCell() + .storeUint(data.timestamp, 32) + .storeMaybeRef(prunedDict) + .endCell(); + let merkleProof = convertToMerkleProof(prunedData); + let oracleDataProof = beginCell() + .storeUint(oracle.id, 32) + .storeRef(merkleProof) + .storeBuffer(signature) + .asSlice(); + return oracleDataProof; +} + +function packOraclesData(oraclesData, assets) { + if (oraclesData.length == 0) { + throw new Error('no oracles data to pack'); + } + let proofs = oraclesData + .sort((d1, d2) => d1.oracle.id - d2.oracle.id) + .map(({ oracle, data, signature }) => + createOracleDataProof(oracle, data, signature, assets) + ); + return proofs.reduceRight( + (acc, val) => beginCell().storeSlice(val).storeMaybeRef(acc).endCell(), + null + ); +} + +async function getPrices(endpoint = 'api.stardust-mainnet.iotaledger.net') { + try { + const prices = await Promise.all( + ORACLES.map(async (oracle) => { + try { + const outputResponse = await fetch( + `https://${endpoint}/api/indexer/v1/outputs/nft/${oracle.address}`, + { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + } + ); + const outputData = await outputResponse.json(); + const priceResponse = await fetch( + `https://${endpoint}/api/core/v2/outputs/${outputData.items[0]}`, + { + headers: { accept: 'application/json' }, + signal: AbortSignal.timeout(5000), + } + ); + const priceData = await priceResponse.json(); + + const data = JSON.parse( + decodeURIComponent( + priceData.output.features[0].data + .replace('0x', '') + .replace(/[0-9a-f]{2}/g, '%$&') + ) + ); + + const pricesCell = Cell.fromBoc( + Buffer.from(data.packedPrices, 'hex') + )[0]; + const signature = Buffer.from(data.signature, 'hex'); + const publicKey = Buffer.from(data.publicKey, 'hex'); + const timestamp = Number(data.timestamp); + + return { + dict: pricesCell + .beginParse() + .loadRef() + .beginParse() + .loadDictDirect( + Dictionary.Keys.BigUint(256), + Dictionary.Values.BigVarUint(4) + ), + dataCell: beginCell() + .storeRef(pricesCell) + .storeBuffer(signature) + .endCell(), + oracleId: oracle.id, + signature, + pubkey: publicKey, + timestamp, + }; + } catch (error) { + console.error( + `Error fetching prices from oracle ${oracle.id}:`, + error + ); + return null; + } + }) + ); + + const validPrices = prices.filter( + (price) => + price && verifyPricesTimestamp(price) && verifyPricesSign(price) + ); + + if (validPrices.length < MINIMAL_ORACLES) { + throw new Error('Not enough valid price data'); + } + + const sortedByTimestamp = validPrices + .slice() + .sort((a, b) => b.timestamp - a.timestamp); + const newerPrices = sortedByTimestamp + .slice(0, MINIMAL_ORACLES) + .sort((a, b) => a.oracleId - b.oracleId); + + const allAssetIds = new Set( + newerPrices.flatMap((p) => Array.from(p.dict.keys())) + ); + + const medianData = Array.from(allAssetIds) + .map((assetId) => ({ + assetId, + medianPrice: getMedianPrice(newerPrices, assetId), + })) + .filter((x) => x.medianPrice !== null); + + const packedMedianData = packAssetsData(medianData); + + const oraclesData = newerPrices.map((x) => ({ + oracle: { id: x.oracleId, pubkey: x.pubkey }, + data: { timestamp: x.timestamp, prices: x.dict }, + signature: x.signature, + })); + + const packedOracleData = packOraclesData( + oraclesData, + medianData.map((x) => x.assetId) + ); + + const dict = Dictionary.empty(); + for (const { assetId, medianPrice } of medianData) { + dict.set(assetId, medianPrice); + } + + return { + dict, + dataCell: packPrices(packedMedianData, packedOracleData), + }; + } catch (error) { + console.error('Error processing prices:', error); + return undefined; + } +} + +module.exports = getPrices; diff --git a/src/adaptors/evaa-protocol/index.js b/src/adaptors/evaa-protocol/index.js index 376234e24f..19db20432f 100644 --- a/src/adaptors/evaa-protocol/index.js +++ b/src/adaptors/evaa-protocol/index.js @@ -3,8 +3,10 @@ const utils = require('../utils'); const fetch = require('node-fetch') const { TonClient } = require("@ton/ton"); const { Address, Cell, Slice, Dictionary, beginCell } = require("@ton/core"); +const { signVerify } = require('@ton/crypto'); const crypto = require("crypto"); -const NFT_ID = '0xfb9874544d76ca49c5db9cc3e5121e4c018bc8a2fb2bfe8f2a38c5b9963492f5'; +const getPrices = require('./getPrices'); +const { getDistributions, calculateRewardApy } = require('./rewardApy'); function sha256Hash(input) { const hash = crypto.createHash("sha256"); @@ -31,6 +33,12 @@ const assets = { tsTON: { assetId: sha256Hash("tsTON"), token: 'EQC98_qAmNEptUtPc7W6xdHh_ZHrBUFpw5Ft_IzNU20QAJav' }, }; +function findAssetKeyByBigIntId(searchAssetId) { + return Object.entries(assets).find(([key, value]) => + BigInt(value.assetId) === searchAssetId + )?.[0]; +} + const MASTER_CONSTANTS = { FACTOR_SCALE: BigInt(1e12), @@ -306,37 +314,6 @@ function calculateCurrentRates(assetConfig, assetData) { }; } -async function getPrices(endpoint = "api.stardust-mainnet.iotaledger.net") { - try { - let result = await fetch(`https://${endpoint}/api/indexer/v1/outputs/nft/${NFT_ID}`, { - headers: { accept: 'application/json' }, - }); - let outputId = await result.json(); - - result = await fetch(`https://${endpoint}/api/core/v2/outputs/${outputId.items[0]}`, { - headers: { accept: 'application/json' }, - }); - - let resData = await result.json(); - - const data = JSON.parse( - decodeURIComponent(resData.output.features[0].data.replace('0x', '').replace(/[0-9a-f]{2}/g, '%$&')), - ); - - const pricesCell = Cell.fromBoc(Buffer.from(data['packedPrices'], 'hex'))[0]; - const signature = Buffer.from(data['signature'], 'hex'); - - return { - dict: pricesCell.beginParse().loadDictDirect(Dictionary.Keys.BigUint(256), Dictionary.Values.BigUint(64)), - dataCell: beginCell().storeRef(pricesCell).storeBuffer(signature).endCell(), - }; - } catch (error) { - console.error(error); - return undefined; - } -} - - // ignore pools with TVL below the threshold const MIN_TVL_USD = 100000; @@ -350,6 +327,7 @@ function calculatePresentValue(index, principalValue) { const getApy = async () => { console.log("Requesting prices") let prices = await getPrices(); + let distributions = await getDistributions(); const client = new TonClient({ endpoint: "https://toncenter.com/api/v2/jsonRPC" }); @@ -365,6 +343,9 @@ const getApy = async () => { console.log(e); } }); + + const rewardApys = calculateRewardApy(distributions, 'main', data,prices); + return Object.entries(assets).map(([tokenSymbol, asset]) => { const { assetId, token } = asset; console.log("Process symbol", tokenSymbol, asset, assetId, token) @@ -398,6 +379,27 @@ const getApy = async () => { console.log(tokenSymbol, "supplyApy", supplyApy * 100); console.log(tokenSymbol, "borrowApy", borrowApy * 100); + const apyRewardData = rewardApys.find( + (rewardApy) => + rewardApy.rewardingAssetId == assetId && + rewardApy.rewardType.toLowerCase() === 'supply' + ); + + const apyReward = apyRewardData ? apyRewardData.apy : undefined; + const rewardTokens = apyRewardData + ? [findAssetKeyByBigIntId(apyRewardData.rewardsAssetId)] + : undefined; + + const apyRewardBorrowData = rewardApys.find( + (rewardApy) => + rewardApy.rewardingAssetId == assetId && + rewardApy.rewardType.toLowerCase() === 'borrow' + ); + + const apyRewardBorrow = apyRewardBorrowData + ? apyRewardBorrowData.apy + : undefined; + return { pool: `evaa-${assetId}-ton`.toLowerCase(), chain: 'Ton', @@ -405,6 +407,9 @@ const getApy = async () => { symbol: tokenSymbol, tvlUsd: totalSupplyUsd - totalBorrowUsd, apyBase: supplyApy * 100, + apyReward, + rewardTokens, + // apyRewardBorrow, underlyingTokens: [token], url: `https://app.evaa.finance/token/${tokenSymbol}`, totalSupplyUsd: totalSupplyUsd, diff --git a/src/adaptors/evaa-protocol/rewardApy.js b/src/adaptors/evaa-protocol/rewardApy.js new file mode 100644 index 0000000000..3b1671328c --- /dev/null +++ b/src/adaptors/evaa-protocol/rewardApy.js @@ -0,0 +1,177 @@ +const fetch = require('node-fetch'); + +function isLeapYear(year) { + return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; +} + +async function getDistributions(endpoint = 'evaa.space') { + try { + let result = await fetch(`https://${endpoint}/query/distributions/list`, { + headers: { accept: 'application/json' }, + }); + let resData = await result.json(); + return resData; + } catch (error) { + console.error(error); + return undefined; + } +} +const isLeap = isLeapYear(new Date().getFullYear()); +const totalSecsInYear = (isLeap ? 366 : 365) * 24 * 60 * 60; + +function calcApy( + rewardAmount, + totalAmount, + rewardingAssetPrice, + rewardsAssetPrice, + rewardingScaleFactor, + rewardsScaleFactor, + totalSecsInCurrentSeason +) { + const rate = rewardingScaleFactor / Number(totalAmount); + const rewardForUnit = + rate * + (((Number(rewardAmount) / rewardsScaleFactor) * (rewardsAssetPrice ?? 0)) / + (rewardingAssetPrice || 1)) * + rewardingScaleFactor; + + return ( + ((rewardForUnit * totalSecsInYear) / + (totalSecsInCurrentSeason * rewardingScaleFactor)) * + 100 + ); +} + +function calculateRewardApy(distributionsResp, pool, data, prices) { + try { + if ( + !distributionsResp?.distributions || + distributionsResp.distributions.length === 0 + ) { + console.log('Invalid distributions data:', distributionsResp); + return []; + } + + const currentCampaign = distributionsResp?.distributions.find( + (campaign) => campaign.started && !campaign.expired + ); + + if (!currentCampaign) { + return []; + } + + const seasonsApy = currentCampaign.seasons + ?.filter((season) => season.started && !season.expired) + ?.filter((season) => season.pool === pool) + ?.map((season) => { + const rewardingAssetId = BigInt(season?.rewarding_asset_id ?? 0); + const rewardsAssetId = BigInt(season?.rewards_asset_id ?? 0); + + const rewardingAssetData = data.assetsData.get(rewardingAssetId); + const rewardsAssetData = data.assetsData.get(rewardsAssetId); + + if (!rewardingAssetData || !rewardsAssetData) { + return []; + } + + const rewardType = + season?.reward_type?.toLowerCase() === 'borrow' ? 'Borrow' : 'Supply'; + + let rewardAmount = Number(season?.rewards_amount) || 0; + + if (rewardType === 'Borrow' && season?.borrow_budget) { + rewardAmount = season.borrow_budget; + } else if (rewardType === 'Supply' && season?.supply_budget) { + rewardAmount = season.supply_budget; + } + + const totalAmountSupply = + rewardingAssetData.totalSupply?.original ?? + rewardingAssetData.totalSupply; + const totalAmountBorrow = + rewardingAssetData.totalBorrow?.original ?? + rewardingAssetData.totalBorrow; + + const totalAmount = + rewardType === 'Borrow' ? totalAmountBorrow : totalAmountSupply; + + if (!totalAmount || totalAmount === '0') { + return []; + } + + const rewardingAssetConfig = data.assetsConfig.get(rewardingAssetId); + const rewardsAssetConfig = data.assetsConfig.get(rewardsAssetId); + + const rewardingScaleFactor = + 10 ** Number(rewardingAssetConfig?.decimals ?? 0); + const rewardsScaleFactor = + 10 ** Number(rewardsAssetConfig?.decimals ?? 0); + + const rewardingPriceData = prices.dict.get(rewardingAssetId); + const rewardsPriceData = prices.dict.get(rewardsAssetId); + + const rewardingAssetPrice = Number(rewardingPriceData); + const rewardsAssetPrice = Number(rewardsPriceData); + + const seasonStart = new Date(season?.campaign_start ?? 0); + const seasonEnd = new Date(season?.campaign_end ?? 0); + const totalSecsInCurrentSeason = (seasonEnd - seasonStart) / 1000; + + if (totalSecsInCurrentSeason <= 0) { + return []; + } + + const baseApy = calcApy( + rewardAmount, + totalAmount, + rewardingAssetPrice, + rewardsAssetPrice, + rewardingScaleFactor, + rewardsScaleFactor, + totalSecsInCurrentSeason + ); + + const result = [ + { + apy: baseApy, + rewardType, + rewardingAssetId, + rewardsAssetId, + }, + ]; + + if ( + rewardType === 'Borrow' && + season?.supply_budget && + season.supply_budget > 0 + ) { + const supplyApy = calcApy( + season.supply_budget, + totalAmountSupply, + rewardingAssetPrice, + rewardsAssetPrice, + rewardingScaleFactor, + rewardsScaleFactor, + totalSecsInCurrentSeason + ); + result.push({ + apy: supplyApy, + rewardType: 'Supply', + rewardingAssetId, + rewardsAssetId, + }); + } + + return result; + }); + return seasonsApy.flat(); + } catch (error) { + console.error(error); + return []; + } +} + +module.exports = { + getDistributions, + calculateRewardApy, +};