diff --git a/.env.sample b/.env.sample index 0fb4452a..12f00c19 100644 --- a/.env.sample +++ b/.env.sample @@ -17,3 +17,11 @@ CRON_SECRET= # Note: Not everything is supported on sepolia # Default: mainnet NEXT_PUBLIC_NETWORK=mainnet + +# To enable API caching +VK_REDIS_KV_URL= +VK_REDIS_KV_REST_API_READ_ONLY_TOKEN= +VK_REDIS_REDIS_URL= +VK_REDIS_KV_REST_API_TOKEN= +VK_REDIS_KV_REST_API_URL= +VK_REDIS_PREFIX="strkfarm::beta" \ No newline at end of file diff --git a/package.json b/package.json index 048a4713..4f3971fa 100755 --- a/package.json +++ b/package.json @@ -34,10 +34,11 @@ "@prisma/client": "5.18.0", "@starknet-react/chains": "3.0.0", "@starknet-react/core": "3.0.1", - "@strkfarm/sdk": "^1.0.28", + "@strkfarm/sdk": "^1.0.35", "@tanstack/query-core": "5.28.0", "@types/mixpanel-browser": "2.49.0", "@types/mustache": "4.2.5", + "@upstash/redis": "^1.34.7", "@vercel/analytics": "1.2.2", "@vercel/speed-insights": "1.0.12", "@xyflow/react": "^12.4.4", @@ -53,6 +54,7 @@ "graphql": "16.9.0", "jotai": "2.6.4", "jotai-tanstack-query": "0.8.5", + "lodash.debounce": "^4.0.8", "mixpanel": "^0.18.0", "mixpanel-browser": "2.49.0", "mustache": "4.2.0", @@ -75,6 +77,7 @@ "wonka": "6.3.4" }, "devDependencies": { + "@types/lodash.debounce": "^4.0.9", "@types/node": "20", "@types/react": "18", "@types/react-dom": "18", diff --git a/src/app/api/lib.ts b/src/app/api/lib.ts new file mode 100644 index 00000000..12a9a1ab --- /dev/null +++ b/src/app/api/lib.ts @@ -0,0 +1,151 @@ +import { STRKFarmStrategyAPIResult } from '@/store/strkfarm.atoms'; +import { Redis } from '@upstash/redis'; +import { Contract, RpcProvider, uint256 } from 'starknet'; + +const kvRedis = new Redis({ + url: process.env.VK_REDIS_KV_REST_API_URL, + token: process.env.VK_REDIS_KV_REST_API_TOKEN, +}); + +export async function getDataFromRedis( + key: string, + url: string, + revalidate: number, +) { + if (url.includes('no_cache=true')) { + // force no cache + return null; + } + const cacheData: any = await kvRedis.get(key); + if ( + cacheData && + new Date().getTime() - new Date(cacheData.lastUpdated).getTime() < + revalidate * 1000 + ) { + console.log(`Cache hit for ${key}`); + return cacheData; + } + + return null; +} + +export default kvRedis; + +export const getRewardsInfo = async ( + strategies: Pick[], +) => { + const funder = + '0x02D6cf6182259ee62A001EfC67e62C1fbc0dF109D2AA4163EB70D6d1074F0173'; + const allowedStrats = [ + { + id: 'vesu_fusion_eth', + // ! consider exchange rate of vToken + maxRewardsPerDay: 0.047, // in token units + maxAPY: 200, // in percent + underlyingTokenName: 'ETH', + decimals: 18, + rewardToken: + '0x021fe2ca1b7e731e4a5ef7df2881356070c5d72db4b2d19f9195f6b641f75df0', + }, + ]; + + const provider = new RpcProvider({ + nodeUrl: process.env.RPC_URL!, + }); + + const rewardsInfo: { + id: string; + reward: number; + tvlUsd: number; + rewardAPY: number; + rewardDecimals: number; + maxRewardsPerDay: number; + rewardToken: string; + funder: string; + receiver: string; + }[] = []; + for (const strat of strategies) { + const stratId = strat.id; + const stratAllowed = allowedStrats.find( + (allowedStrat) => allowedStrat.id === stratId, + ); + if (stratAllowed) { + // Fetch the price of the underlying token + const priceResponse = await fetch( + `${process.env.HOSTNAME}/api/price/${stratAllowed.underlyingTokenName}`, + ); + const priceData = await priceResponse.json(); + // consider token price of vToken + const clsVToken = await provider.getClassAt(stratAllowed.rewardToken); + const tokenContractVToken = new Contract( + clsVToken.abi, + stratAllowed.rewardToken, + provider, + ); + const shareValue = await tokenContractVToken.call('convert_to_assets', [ + uint256.bnToUint256((1e18).toString()), + ]); + console.log(`shareValue::${stratId}::${shareValue}`); + const tokenPrice = + (priceData.price * + Number( + (BigInt(shareValue.toString()) * BigInt(10000)) / + BigInt((1e18).toString()), + )) / + 10000; + console.log( + `RewardCalc::${stratId}::tokenPrice::${tokenPrice}, underlyingTokenPrice::${priceData.price}`, + ); + + const tvlUsd = strat.tvlUsd; + console.log(`RewardCalc::${stratId}::tvlUsd::${tvlUsd}`); + + // Calculate the hourly reward based on TVL and token price + const rewardBasedOnTVL = + (tvlUsd * stratAllowed.maxAPY) / (100 * 365 * 24 * tokenPrice); + console.log( + `RewardCalc::${stratId}::ewardBasedOnTVL::${rewardBasedOnTVL}`, + ); + console.log(`RewardCalc::${stratId}::tvl::${tvlUsd}`); + + // Ensure the reward does not exceed max rewards per day + let finalReward = Math.min( + rewardBasedOnTVL, + stratAllowed.maxRewardsPerDay / 24, + ); + console.log(`RewardCalc::${stratId}::finalReward::${finalReward}`); + + // if less bal available, use the available balance + const rewardToken = stratAllowed.rewardToken; + const cls = await provider.getClassAt(rewardToken); + const tokenContract = new Contract(cls.abi, rewardToken, provider); + const available = await tokenContract.balanceOf(funder); + const availableBal = + Number( + BigInt(available.toString()) / + BigInt(10 ** (stratAllowed.decimals - 4)), + ) / 10000; + console.log( + `RewardCalc::${stratId}::availableBal::${availableBal.toString()}`, + ); + + finalReward = Math.min(finalReward, availableBal); + console.log(`RewardCalc::${stratId}::finalReward::${finalReward}`); + + // Calculate the reward APY + rewardsInfo.push({ + id: stratId, + reward: finalReward, + rewardDecimals: stratAllowed.decimals, + tvlUsd, + rewardAPY: ((finalReward * 24 * 365) / (tvlUsd / tokenPrice)) * 100, + maxRewardsPerDay: stratAllowed.maxRewardsPerDay, + rewardToken: stratAllowed.rewardToken, + funder, + receiver: strat.contract[0].address, + }); + } + } + + return rewardsInfo; +}; diff --git a/src/app/api/price/[name]/route.ts b/src/app/api/price/[name]/route.ts index 17510053..0daaf8d7 100644 --- a/src/app/api/price/[name]/route.ts +++ b/src/app/api/price/[name]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { getMainnetConfig, PricerRedis } from '@strkfarm/sdk'; export const revalidate = 300; // 5 mins +export const dynamic = 'force-dynamic'; // only meant for backend calls async function initRedis() { diff --git a/src/app/api/raffle/_route.ts b/src/app/api/raffle/_route.ts new file mode 100644 index 00000000..df33e809 --- /dev/null +++ b/src/app/api/raffle/_route.ts @@ -0,0 +1,157 @@ +// import { NextResponse } from 'next/server'; + +// import { db } from '@/db'; +// import { getStrategies } from '@/store/strategies.atoms'; +// import { standariseAddress } from '@/utils'; + +// export async function POST(req: Request) { +// const { address, type } = await req.json(); + +// if (!address || !type) { +// return NextResponse.json({ +// success: false, +// message: 'address not found', +// user: null, +// }); +// } + +// if (!type || !['SHARED_ON_X', 'ACTIVE_DEPOSITS', 'REGISTER'].includes(type)) { +// return NextResponse.json({ +// success: false, +// message: 'Invalid type', +// }); +// } + +// // standardised address +// let parsedAddress = address; +// try { +// parsedAddress = standariseAddress(address); +// } catch (e) { +// throw new Error('Invalid address'); +// } + +// const user = await db.user.findFirst({ +// where: { +// address: parsedAddress, +// }, +// }); + +// if (!user) { +// return NextResponse.json({ +// success: false, +// message: 'User not found', +// user: null, +// }); +// } + +// if (type === 'REGISTER') { +// const raffleUser = await db.raffle.findFirst({ +// where: { +// userId: user.id, +// }, +// }); + +// if (raffleUser) { +// return NextResponse.json({ +// success: false, +// message: 'User already registered', +// user: raffleUser, +// }); +// } + +// const createdUser = await db.raffle.create({ +// data: { +// isRaffleParticipant: true, +// User: { +// connect: { +// address: parsedAddress, +// }, +// }, +// }, +// }); + +// return NextResponse.json({ +// success: true, +// message: 'User registered for raffle successfully', +// user: createdUser, +// }); +// } + +// const raffleUser = await db.raffle.findFirst({ +// where: { +// userId: user.id, +// isRaffleParticipant: true, +// }, +// }); + +// if (!raffleUser) { +// return NextResponse.json({ +// success: false, +// message: 'Registered user not found', +// user: null, +// }); +// } + +// if (type === 'SHARED_ON_X') { +// const updatedUser = await db.raffle.update({ +// where: { +// raffleId: raffleUser.raffleId, +// }, +// data: { +// sharedOnX: true, +// }, +// }); + +// return NextResponse.json({ +// success: true, +// message: 'Shared on X successfully', +// user: updatedUser, +// }); +// } + +// if (type === 'ACTIVE_DEPOSITS') { +// const strategies = getStrategies(); + +// const values = strategies.map(async (strategy) => { +// const balanceInfo = await strategy.getUserTVL(parsedAddress); +// return { +// id: strategy.id, +// usdValue: balanceInfo.usdValue, +// tokenInfo: { +// name: balanceInfo.tokenInfo.name, +// symbol: balanceInfo.tokenInfo.name, +// logo: balanceInfo.tokenInfo.logo, +// decimals: balanceInfo.tokenInfo.decimals, +// displayDecimals: balanceInfo.tokenInfo.displayDecimals, +// }, +// amount: balanceInfo.amount.toEtherStr(), +// }; +// }); + +// const result = await Promise.all(values); +// const sum = result.reduce((acc, item) => acc + item.usdValue, 0); + +// if (sum > 10) { +// const createdUser = await db.raffle.update({ +// where: { +// raffleId: raffleUser.raffleId, +// }, +// data: { +// activeDeposits: true, +// }, +// }); + +// return NextResponse.json({ +// success: true, +// message: 'Active deposits found', +// user: createdUser, +// }); +// } + +// return NextResponse.json({ +// success: false, +// message: 'No active deposits found', +// user: null, +// }); +// } +// } diff --git a/src/app/api/raffle/route.ts b/src/app/api/raffle/route.ts deleted file mode 100644 index d4182be3..00000000 --- a/src/app/api/raffle/route.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { NextResponse } from 'next/server'; - -import { db } from '@/db'; -import { getStrategies } from '@/store/strategies.atoms'; -import { standariseAddress } from '@/utils'; - -export async function POST(req: Request) { - const { address, type } = await req.json(); - - if (!address || !type) { - return NextResponse.json({ - success: false, - message: 'address not found', - user: null, - }); - } - - if (!type || !['SHARED_ON_X', 'ACTIVE_DEPOSITS', 'REGISTER'].includes(type)) { - return NextResponse.json({ - success: false, - message: 'Invalid type', - }); - } - - // standardised address - let parsedAddress = address; - try { - parsedAddress = standariseAddress(address); - } catch (e) { - throw new Error('Invalid address'); - } - - const user = await db.user.findFirst({ - where: { - address: parsedAddress, - }, - }); - - if (!user) { - return NextResponse.json({ - success: false, - message: 'User not found', - user: null, - }); - } - - if (type === 'REGISTER') { - const raffleUser = await db.raffle.findFirst({ - where: { - userId: user.id, - }, - }); - - if (raffleUser) { - return NextResponse.json({ - success: false, - message: 'User already registered', - user: raffleUser, - }); - } - - const createdUser = await db.raffle.create({ - data: { - isRaffleParticipant: true, - User: { - connect: { - address: parsedAddress, - }, - }, - }, - }); - - return NextResponse.json({ - success: true, - message: 'User registered for raffle successfully', - user: createdUser, - }); - } - - const raffleUser = await db.raffle.findFirst({ - where: { - userId: user.id, - isRaffleParticipant: true, - }, - }); - - if (!raffleUser) { - return NextResponse.json({ - success: false, - message: 'Registered user not found', - user: null, - }); - } - - if (type === 'SHARED_ON_X') { - const updatedUser = await db.raffle.update({ - where: { - raffleId: raffleUser.raffleId, - }, - data: { - sharedOnX: true, - }, - }); - - return NextResponse.json({ - success: true, - message: 'Shared on X successfully', - user: updatedUser, - }); - } - - if (type === 'ACTIVE_DEPOSITS') { - const strategies = getStrategies(); - - const values = strategies.map(async (strategy) => { - const balanceInfo = await strategy.getUserTVL(parsedAddress); - return { - id: strategy.id, - usdValue: balanceInfo.usdValue, - tokenInfo: { - name: balanceInfo.tokenInfo.name, - symbol: balanceInfo.tokenInfo.name, - logo: balanceInfo.tokenInfo.logo, - decimals: balanceInfo.tokenInfo.decimals, - displayDecimals: balanceInfo.tokenInfo.displayDecimals, - }, - amount: balanceInfo.amount.toEtherStr(), - }; - }); - - const result = await Promise.all(values); - const sum = result.reduce((acc, item) => acc + item.usdValue, 0); - - if (sum > 10) { - const createdUser = await db.raffle.update({ - where: { - raffleId: raffleUser.raffleId, - }, - data: { - activeDeposits: true, - }, - }); - - return NextResponse.json({ - success: true, - message: 'Active deposits found', - user: createdUser, - }); - } - - return NextResponse.json({ - success: false, - message: 'No active deposits found', - user: null, - }); - } -} diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts new file mode 100644 index 00000000..f991e0a3 --- /dev/null +++ b/src/app/api/revalidate/route.ts @@ -0,0 +1,32 @@ +import axios from 'axios'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + console.error('Authorization header:', authHeader); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + console.error('Unauthorized request'); + return new Response('Unauthorized', { + status: 401, + }); + } + console.error('Revalidating...', `${process.env.HOSTNAME}/api/strategies`); + const prom1 = axios(`${process.env.HOSTNAME}/api/strategies?no_cache=true`); + const prom2 = axios(`${process.env.HOSTNAME}/api/stats?no_cache=true`); + + const result = await Promise.all([prom1, prom2]); + console.error('Revalidation complete'); + const res1 = await result[0].data; + const res2 = await result[1].data; + console.error(`Value 1: ${res1.lastUpdated}`); + console.error(`Value 2: ${res2.lastUpdated}`); + return NextResponse.json( + { + revalidated: true, + now: Date.now(), + }, + { + status: 200, + }, + ); +} diff --git a/src/app/api/rewards/route.ts b/src/app/api/rewards/route.ts new file mode 100644 index 00000000..626cbac9 --- /dev/null +++ b/src/app/api/rewards/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { getRewardsInfo } from '../lib'; + +export const revalidate = 0; +export const dynamic = 'force-dynamic'; + +export async function GET(_request: Request) { + console.log('GET /api/rewards '); + const result = await fetch( + `${process.env.HOSTNAME}/api/strategies?no_cache=true`, + ); + const stratsRes = await result.json(); + const strategies = stratsRes.strategies; + const lastUpdated = new Date(stratsRes.lastUpdated); + const now = new Date(); + if (now.getTime() - lastUpdated.getTime() > 60000000) { + console.error('Strategies are stale', lastUpdated, now); + return new Response('Strategies are stale', { + status: 500, + }); + } + + const rewardsInfo = await getRewardsInfo(strategies); + + return NextResponse.json( + { + lastUpdated: new Date().toISOString(), + rewards: rewardsInfo, + }, + { + status: 200, + }, + ); +} diff --git a/src/app/api/stats/[address]/route.ts b/src/app/api/stats/[address]/route.ts index cbbfcb6d..32a9d69c 100755 --- a/src/app/api/stats/[address]/route.ts +++ b/src/app/api/stats/[address]/route.ts @@ -1,6 +1,8 @@ import { getStrategies } from '@/store/strategies.atoms'; +import { StrategyWise } from '@/store/utils.atoms'; import { standariseAddress } from '@/utils'; import { NextResponse } from 'next/server'; +import { AmountsInfo } from '@/strategies/IStrategy'; export const revalidate = 0; @@ -17,40 +19,52 @@ export async function GET(_req: Request, context: any) { } const strategies = getStrategies(); - const values = strategies.map(async (strategy) => { + const values: Promise[] = strategies.map(async (strategy) => { if (strategy.isLive()) { - const balanceInfo = await strategy.getUserTVL(pAddr); + const balanceInfo: AmountsInfo = await strategy.getUserTVL(pAddr); + if (balanceInfo.amounts.length == 1) { + return { + id: strategy.id, + usdValue: balanceInfo.usdValue, + holdings: [ + { + tokenInfo: balanceInfo.amounts[0].tokenInfo, + amount: balanceInfo.amounts[0].amount, + usdValue: balanceInfo.amounts[0].usdValue, + }, + ], + }; + } + const summary = await strategy.computeSummaryValue( + balanceInfo.amounts.map((a) => ({ + tokenInfo: a.tokenInfo, + amount: a.amount, + })), + strategy.settings.quoteToken, + 'stats[address]', + ); return { id: strategy.id, usdValue: balanceInfo.usdValue, - tokenInfo: { - name: balanceInfo.tokenInfo.name, - symbol: balanceInfo.tokenInfo.name, - logo: balanceInfo.tokenInfo.logo, - decimals: balanceInfo.tokenInfo.decimals, - displayDecimals: balanceInfo.tokenInfo.displayDecimals, - }, - amount: balanceInfo.amount.toEtherStr(), + holdings: [ + { + tokenInfo: strategy.settings.quoteToken, + amount: summary, + usdValue: balanceInfo.usdValue, + }, + ], }; } return { id: strategy.id, usdValue: 0, - tokenInfo: { - name: '', - symbol: '', - logo: '', - decimals: 0, - displayDecimals: 0, - }, - amount: 0, + holdings: [], }; }); const result = await Promise.all(values); const sum = result.reduce((acc, item) => acc + item.usdValue, 0); - console.log({ pAddr, sum }); return NextResponse.json({ holdingsUSD: sum, strategyWise: result, diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts index 45c2aee5..93c03a76 100755 --- a/src/app/api/stats/route.ts +++ b/src/app/api/stats/route.ts @@ -1,9 +1,23 @@ import { getStrategies } from '@/store/strategies.atoms'; import { NextResponse } from 'next/server'; +import { getDataFromRedis } from '../lib'; export const revalidate = 1800; +export const dynamic = 'force-dynamic'; + +const REDIS_KEY = `${process.env.VK_REDIS_PREFIX}::stats`; export async function GET(_req: Request) { + const cacheData = await getDataFromRedis(REDIS_KEY, _req.url, revalidate); + if (cacheData) { + const resp = NextResponse.json(cacheData); + resp.headers.set( + 'Cache-Control', + `s-maxage=${revalidate}, stale-while-revalidate=300`, + ); + return resp; + } + const strategies = getStrategies(); console.log('strategies', strategies.length); @@ -35,7 +49,12 @@ export async function GET(_req: Request) { const response = NextResponse.json({ tvl: result.reduce((a, b) => a + b, 0), + lastUpdated: new Date().toISOString(), }); + response.headers.set( + 'Cache-Control', + `s-maxage=${revalidate}, stale-while-revalidate=180`, + ); return response; } diff --git a/src/app/api/strategies/route.ts b/src/app/api/strategies/route.ts index 48a54959..cc6981b6 100755 --- a/src/app/api/strategies/route.ts +++ b/src/app/api/strategies/route.ts @@ -1,22 +1,24 @@ import { NextResponse } from 'next/server'; import { atom } from 'jotai'; -import ZkLendAtoms from '@/store/zklend.store'; import { PoolInfo, PoolType } from '@/store/pools'; -import NostraLendingAtoms from '@/store/nostralending.store'; import { RpcProvider } from 'starknet'; import { getLiveStatusNumber, getStrategies } from '@/store/strategies.atoms'; -import { MY_STORE } from '@/store'; import MyNumber from '@/utils/MyNumber'; import { IStrategy, NFTInfo, TokenInfo } from '@/strategies/IStrategy'; import { STRKFarmStrategyAPIResult } from '@/store/strkfarm.atoms'; +import { MY_STORE } from '@/store'; import VesuAtoms, { vesu } from '@/store/vesu.store'; import EndurAtoms, { endur } from '@/store/endur.store'; +import kvRedis, { getDataFromRedis, getRewardsInfo } from '../lib'; -export const revalidate = 3600; // 1 hr +export const revalidate = 1800; // 30 minutes +export const dynamic = 'force-dynamic'; const allPoolsAtom = atom((get) => { const pools: PoolInfo[] = []; - const poolAtoms = [ZkLendAtoms, NostraLendingAtoms, VesuAtoms, EndurAtoms]; + // undo + const poolAtoms = [VesuAtoms, EndurAtoms]; + // const poolAtoms: ProtocolAtoms[] = []; return poolAtoms.reduce((_pools, p) => _pools.concat(get(p.pools)), pools); }); @@ -24,18 +26,20 @@ async function getPools(store: any, retry = 0) { const allPools: PoolInfo[] | undefined = store.get(allPoolsAtom); console.log('allPools', allPools?.length); - const minProtocolsRequired = [vesu.name, endur.name]; + // undo + const minProtocolsRequired: string[] = [vesu.name, endur.name]; const hasRequiredPools = minProtocolsRequired.every((p) => { + if (minProtocolsRequired.length == 0) return true; if (!allPools) return false; return allPools.some((pool) => { - console.log('pool.protocol.name', pool.protocol.name); + console.log(new Date(), 'pool.protocol.name', pool.protocol.name); return ( pool.protocol.name === p && (pool.type == PoolType.Lending || pool.type == PoolType.Staking) ); }); }); - console.log('hasRequiredPools', hasRequiredPools); + console.log(new Date(), 'hasRequiredPools', hasRequiredPools); const MAX_RETRIES = 120; if (retry >= MAX_RETRIES) { throw new Error('Failed to fetch pools'); @@ -55,18 +59,27 @@ async function getStrategyInfo( ): Promise { const tvl = await strategy.getTVL(); - return { + const data = { name: strategy.name, id: strategy.id, apy: strategy.netYield, - depositToken: strategy - .depositMethods({ + apySplit: { + baseApy: strategy.netYield, + rewardsApy: 0, + }, + depositToken: ( + await strategy.depositMethods({ amount: MyNumber.fromZero(), address: '', provider, isMax: false, }) - .map((t) => t.tokenInfo.token), + )[0].amounts.map((t) => ({ + symbol: t.tokenInfo.symbol, + name: t.tokenInfo.name, + address: t.tokenInfo.address.address, + decimals: t.tokenInfo.decimals, + })), leverage: strategy.leverage, contract: strategy.holdingTokens.map((t) => ({ name: t.name, @@ -78,7 +91,7 @@ async function getStrategyInfo( value: strategy.liveStatus, }, riskFactor: strategy.riskFactor, - logo: strategy.holdingTokens[0].logo, + logos: strategy.metadata.depositTokens.map((t) => t.logo), isAudited: strategy.settings.auditUrl ? true : false, auditUrl: strategy.settings.auditUrl, actions: strategy.actions.map((action) => { @@ -99,9 +112,35 @@ async function getStrategyInfo( }), investmentFlows: strategy.investmentFlows, }; + + const rewardsInfo = await getRewardsInfo([ + { + id: strategy.id, + tvlUsd: data.tvlUsd, + contract: data.contract, + }, + ]); + if (rewardsInfo.length > 0) { + data.apySplit.rewardsApy = rewardsInfo[0].rewardAPY / 100; + data.apy += rewardsInfo[0].rewardAPY / 100; + } + return data; } +const REDIS_KEY = `${process.env.VK_REDIS_PREFIX}::strategies`; + export async function GET(req: Request) { + console.log('GET /api/strategies', req.url); + const cacheData = await getDataFromRedis(REDIS_KEY, req.url, revalidate); + if (cacheData) { + const resp = NextResponse.json(cacheData); + resp.headers.set( + 'Cache-Control', + `s-maxage=${revalidate}, stale-while-revalidate=300`, + ); + return resp; + } + const allPools = await getPools(MY_STORE); const strategies = getStrategies(); @@ -119,33 +158,42 @@ export async function GET(req: Request) { // } // }); - const stratsDataProms: any[] = []; - const _strats = strategies.sort((a, b) => { + const stratsDataProms: Promise[] = []; + for (let i = 0; i < strategies.length; i++) { + stratsDataProms.push(getStrategyInfo(strategies[i])); + } + const stratsData = await Promise.all(stratsDataProms); + + const _strats = stratsData.sort((a, b) => { // sort based on risk factor, live status and apy const aRisk = a.riskFactor; const bRisk = b.riskFactor; - const aLive = getLiveStatusNumber(a.liveStatus); - const bLive = getLiveStatusNumber(b.liveStatus); + const aLive = a.status.number; + const bLive = b.status.number; if (aLive !== bLive) return aLive - bLive; if (aRisk !== bRisk) return aRisk - bRisk; - return b.netYield - a.netYield; + return b.apy - a.apy; }); - for (let i = 0; i < _strats.length; i++) { - stratsDataProms.push(getStrategyInfo(_strats[i])); - } - - const stratsData = await Promise.all(stratsDataProms); try { - return NextResponse.json({ + const data = { status: true, - strategies: stratsData, - }); + strategies: _strats, + lastUpdated: new Date().toISOString(), + }; + await kvRedis.set(REDIS_KEY, data); + const response = NextResponse.json(data); + response.headers.set( + 'Cache-Control', + `s-maxage=${revalidate}, stale-while-revalidate=300`, + ); + return response; } catch (err) { console.error('Error /api/strategies', err); return NextResponse.json({ status: false, strategies: [], + lastUpdated: new Date().toISOString(), }); } } diff --git a/src/app/raffle/_page.tsx b/src/app/raffle/_page.tsx new file mode 100644 index 00000000..0db1e8ed --- /dev/null +++ b/src/app/raffle/_page.tsx @@ -0,0 +1,115 @@ +// import { NextPage } from 'next'; +// import Image from 'next/image'; +// import React from 'react'; +// import { Button } from '@chakra-ui/react'; + +// import ActiveDeposits from './_components/active-deposits'; +// import RaffleTimer from './_components/raffle-timer'; +// import RegisterRaffle from './_components/register-raffle'; +// import ShareOnX from './_components/share-on-x'; +// import TotalTickets from './_components/total-tickets'; +// import { getHosturl } from '@/utils'; + +// const Raffle: NextPage = () => { +// return ( +//
+//
+//
+//

+// Devcon raffle +//

+//

+// Each day, we shall select 3 winners who will receive exclusive merch +// during Starkspace (Devcon, Bangkok) +//

+ +// +//
+ +// Raffle Hero Image +//
+ +//
+//
+//

+// Earn Raffle tickets for every task and increase chances to win +//

+ +//
+//

+// Raffle end's in +//

+// +//
+//
+//
+ +//
+//
+//

Tasks

+//
+//

+// Participate and +// +// {' '} +// get Raffle tickets +// +//

+ +// +//
+ +//
+// + +// + +// +//
+//
+//
+ +//
+//
+// Rules: +//
+//

+// 1. 3 unique winners will be selected each day +//

+//

+// 2. You just have to register once and you will be part of each round +// automatically +//

+//

+// 3. You have to register if you want to participate. This mean you or +// anyone on your behalf will be available to collect the merch.{' '} +//

+//

+// 4. The rewards will be in the form of exclusive merch reserved for you +//

+//

+// 5. Selected winners can collect their merch on 13th Nov, from The Fig +// Lobby, Bangkok +//

+//

+// 6. Winners will be announced on our socials (i.e. X, TG, etc.) +// everyday +//

+//
+//
+// ); +// }; + +// export default Raffle; diff --git a/src/app/raffle/page.tsx b/src/app/raffle/page.tsx deleted file mode 100644 index e260a452..00000000 --- a/src/app/raffle/page.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { NextPage } from 'next'; -import Image from 'next/image'; -import React from 'react'; -import { Button } from '@chakra-ui/react'; - -import ActiveDeposits from './_components/active-deposits'; -import RaffleTimer from './_components/raffle-timer'; -import RegisterRaffle from './_components/register-raffle'; -import ShareOnX from './_components/share-on-x'; -import TotalTickets from './_components/total-tickets'; -import { getHosturl } from '@/utils'; - -const Raffle: NextPage = () => { - return ( -
-
-
-

- Devcon raffle -

-

- Each day, we shall select 3 winners who will receive exclusive merch - during Starkspace (Devcon, Bangkok) -

- - -
- - Raffle Hero Image -
- -
-
-

- Earn Raffle tickets for every task and increase chances to win -

- -
-

- Raffle end's in -

- -
-
-
- -
-
-

Tasks

-
-

- Participate and - - {' '} - get Raffle tickets - -

- - -
- -
- - - - - -
-
-
- -
-
- Rules: -
-

- 1. 3 unique winners will be selected each day -

-

- 2. You just have to register once and you will be part of each round - automatically -

-

- 3. You have to register if you want to participate. This mean you or - anyone on your behalf will be available to collect the merch.{' '} -

-

- 4. The rewards will be in the form of exclusive merch reserved for you -

-

- 5. Selected winners can collect their merch on 13th Nov, from The Fig - Lobby, Bangkok -

-

- 6. Winners will be announced on our socials (i.e. X, TG, etc.) - everyday -

-
-
- ); -}; - -export default Raffle; diff --git a/src/app/slinks/layout.tsx b/src/app/slinks/layout.tsx deleted file mode 100755 index 93546b28..00000000 --- a/src/app/slinks/layout.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { GoogleAnalytics } from '@next/third-parties/google'; -import { Analytics } from '@vercel/analytics/react'; -import React from 'react'; - -import '../globals.css'; -import { getHosturl } from '@/utils'; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - - - - - - - - - - - - - - - - - {children} - - - - - ); -} diff --git a/src/app/slinks/page.tsx b/src/app/slinks/page.tsx deleted file mode 100755 index c9704949..00000000 --- a/src/app/slinks/page.tsx +++ /dev/null @@ -1,163 +0,0 @@ -'use client'; -import TxButton from '@/components/TxButton'; -import { addressAtom } from '@/store/claims.atoms'; -import { StrategyInfo, strategiesAtom } from '@/store/strategies.atoms'; -import { StrategyTxProps } from '@/store/transactions.atom'; -import MyNumber from '@/utils/MyNumber'; -import { - Avatar, - Box, - Button, - Card, - Link, - Container, - Flex, - Input, - Text, - Center, -} from '@chakra-ui/react'; -import { useProvider } from '@starknet-react/core'; -import { useAtomValue } from 'jotai'; -import { Metadata } from 'next'; -import { useMemo, useState } from 'react'; - -const metadata: Metadata = { - title: 'STRKFarm | Yield aggregator on Starknet', - description: - 'Find and invest in high yield pools. STRKFarm is the best yield aggregator on Starknet.', -}; - -function GetCardSimple(strat: StrategyInfo) { - const [amount, setAmount] = useState(MyNumber.fromZero()); - const address = useAtomValue(addressAtom); - const { provider } = useProvider(); - const depositMethods = strat.depositMethods({ - amount, - address: address || '', - provider, - isMax: false, - }); - - const balData = useAtomValue(depositMethods[0].balanceAtom); - - const balance = useMemo(() => { - return balData.data?.amount || MyNumber.fromZero(); - }, [balData]); - - const txInfo: StrategyTxProps = useMemo(() => { - return { - strategyId: strat.id, - actionType: 'deposit', - amount, - tokenAddr: depositMethods[0].tokenInfo.token, - }; - }, [amount, balData]); - - const maxAmount: MyNumber = useMemo(() => { - return balance; - }, [balance]); - - // Function to reset the input fields to their initial state - const resetDepositForm = () => { - setAmount(MyNumber.fromZero()); - }; - - return ( - - - - - {strat.name}{' '} - - {(strat.netYield * 100).toFixed(2)}% APY - - - - Bal: {balance.toEtherToFixedDecimals(2)}{' '} - {depositMethods[0].tokenInfo.name} - - - - { - const value = event.target.value; - if (value && Number(value) > 0) - setAmount( - MyNumber.fromEther(value, depositMethods[0].tokenInfo.decimals), - ); - else { - setAmount( - new MyNumber('0', depositMethods[0].tokenInfo.decimals), - ); - } - }} - width={'40%'} - /> - - - - - - ); -} - -export default function Slinks() { - const strategies = useAtomValue(strategiesAtom); - return ( - - {/*
- -
*/} - - Choose a strategy and invest - {strategies - .filter((s) => s.isLive()) - .map((strat) => GetCardSimple(strat))} - - -
- -
- -
- ); -} diff --git a/src/app/slinks/template.tsx b/src/app/slinks/template.tsx deleted file mode 100755 index 55454e2f..00000000 --- a/src/app/slinks/template.tsx +++ /dev/null @@ -1,117 +0,0 @@ -'use client'; - -import Navbar, { getConnectors } from '@/components/Navbar'; -import { MY_STORE } from '@/store'; -import { - Center, - ChakraBaseProvider, - Container, - Flex, - extendTheme, -} from '@chakra-ui/react'; -import { mainnet } from '@starknet-react/chains'; -import { StarknetConfig, jsonRpcProvider } from '@starknet-react/core'; -import { Provider as JotaiProvider } from 'jotai'; -import mixpanel from 'mixpanel-browser'; -import Image from 'next/image'; -import { usePathname } from 'next/navigation'; -import * as React from 'react'; -import { isMobile } from 'react-device-detect'; -import { Toaster } from 'react-hot-toast'; -import { RpcProviderOptions, constants } from 'starknet'; - -mixpanel.init('118f29da6a372f0ccb6f541079cad56b'); - -const theme = extendTheme({ - colors: { - transparent: 'rgba(0, 0, 0, 0)', - opacity_50p: 'rgba(0, 0, 0, 0.5)', - color1: 'rgba(86, 118, 254, 1)', - color1_65p: 'rgba(86, 118, 254, 0.65)', - color1_50p: 'rgba(86, 118, 254, 0.5)', - color1_35p: 'rgba(86, 118, 254, 0.35)', - color1_light: '#bcc9ff80', - color2: 'rgb(127 73 229)', - color2Text: 'rgb(165 118 255)', - color2_65p: 'rgba(104, 51, 205, 0.65)', - color2_50p: 'rgba(104, 51, 205, 0.5)', - highlight: '#272932', // light grey - light_grey: '#9d9d9d', - disabled_text: '#818181', - disabled_bg: '#5f5f5f', - purple: '#6F4FF2', - cyan: '#22F3DF', - bg: '#1A1C26', // dark blue - }, - fontSizes: { - large: '50px', - }, - space: { - large: '50px', - }, - sizes: { - prose: '100%', - }, - components: { - MenuItem: { - bg: 'highlight', - }, - }, - fonts: { - heading: `'Courier New', Courier, monospace`, - body: `'Courier New', Courier, monospace`, - }, -}); - -// @ts-ignore -BigInt.prototype.toJSON = function () { - return this.toString(); -}; - -export const CONNECTOR_NAMES = ['Braavos', 'Argent X', 'Argent (mobile)']; // 'Argent Web Wallet']; - -export default function Template({ children }: { children: React.ReactNode }) { - const chains = [mainnet]; - const provider = jsonRpcProvider({ - rpc: (_chain) => { - const args: RpcProviderOptions = { - nodeUrl: - 'https://rpc.nethermind.io/mainnet-juno?apikey=t1HPjhplOyEQpxqVMhpwLGuwmOlbXN0XivWUiPAxIBs0kHVK', - chainId: constants.StarknetChainId.SN_MAIN, - }; - return args; - }, - }); - const pathname = usePathname(); - - function _getIconNode(icon: typeof import('*.svg'), alt: string) { - return ( -
- {alt} -
- ); - } - - return ( - - - - - - - {children} - - - - - - - ); -} diff --git a/src/app/strategy/[strategyId]/_components/FlowChart.tsx b/src/app/strategy/[strategyId]/_components/FlowChart.tsx index fd39ef25..5813026c 100644 --- a/src/app/strategy/[strategyId]/_components/FlowChart.tsx +++ b/src/app/strategy/[strategyId]/_components/FlowChart.tsx @@ -181,8 +181,8 @@ function getNodesAndEdges( {flow.subItems.map((item) => ( - - + + ))}
{item.key}:{item.value}{item.key}:{item.value}
@@ -193,7 +193,7 @@ function getNodesAndEdges( style = { ...style, ...flow.style }; } const _node: FlowNode = { - id: `${level}_${nodes.length}`, + id: flow.id || `${level}_${nodes.length}`, position: { x: 0, y: 0 }, // doesnt matter as we use dagre for layout data: { label: reactElement }, style, @@ -254,6 +254,10 @@ function InternalFlowChart(props: FlowChartProps) { const proOptions = { hideAttribution: true }; + if (strategyCached && strategyCached.investmentFlows.length == 0) { + return null; + } + if (strategyCached && strategyCached.investmentFlows.length > 0) return (
@@ -264,7 +268,7 @@ function InternalFlowChart(props: FlowChartProps) { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} // minZoom={1} - // maxZoom={1} + maxZoom={1} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} diff --git a/src/app/strategy/[strategyId]/_components/Strategy.tsx b/src/app/strategy/[strategyId]/_components/Strategy.tsx index 160e9380..04f31ada 100755 --- a/src/app/strategy/[strategyId]/_components/Strategy.tsx +++ b/src/app/strategy/[strategyId]/_components/Strategy.tsx @@ -4,6 +4,7 @@ import { Alert, AlertIcon, Avatar, + AvatarGroup, Badge, Box, Card, @@ -11,16 +12,11 @@ import { Flex, Grid, GridItem, + HStack, Link, ListItem, OrderedList, Spinner, - Tab, - TabIndicator, - TabList, - TabPanel, - TabPanels, - Tabs, Text, Tooltip, VStack, @@ -31,12 +27,11 @@ import { atom, useAtomValue, useSetAtom } from 'jotai'; import mixpanel from 'mixpanel-browser'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import Deposit from '@/components/Deposit'; import HarvestTime from '@/components/HarvestTime'; -import { DUMMY_BAL_ATOM } from '@/store/balance.atoms'; +import { DUMMY_BAL_ATOM, returnEmptyBal } from '@/store/balance.atoms'; import { addressAtom } from '@/store/claims.atoms'; import { strategiesAtom, StrategyInfo } from '@/store/strategies.atoms'; -import { transactionsAtom, TxHistoryAtom } from '@/store/transactions.atom'; +import { TxHistoryAtom } from '@/store/transactions.atom'; import { capitalize, getTokenInfoFromAddr, @@ -50,22 +45,21 @@ import { StrategyParams } from '../page'; import FlowChart from './FlowChart'; import { isMobile } from 'react-device-detect'; import { getRiskExplaination } from '@strkfarm/sdk'; +import { + STRKFarmBaseAPYsAtom, + STRKFarmStrategyAPIResult, +} from '@/store/strkfarm.atoms'; +import { TokenDeposit } from './TokenDeposit'; const Strategy = ({ params }: StrategyParams) => { const address = useAtomValue(addressAtom); const strategies = useAtomValue(strategiesAtom); - const transactions = useAtomValue(transactionsAtom); const [isMounted, setIsMounted] = useState(false); const strategy: StrategyInfo | undefined = useMemo(() => { const id = params.strategyId; - - console.log('id', id); - return strategies.find((s) => s.id === id); - }, [params.strategyId, strategies]); - - console.log('strategy', strategy); + }, [params.strategyId, strategies.map((id) => id).toString()]); const strategyAddress = useMemo(() => { const holdingTokens = strategy?.holdingTokens; @@ -82,18 +76,17 @@ const Strategy = ({ params }: StrategyParams) => { setBalQueryEnable(true); }, []); - const balData = useAtomValue(strategy?.balanceAtom || DUMMY_BAL_ATOM); + const balData = useAtomValue(strategy?.balanceSummaryAtom || DUMMY_BAL_ATOM); + const individualBalances = useAtomValue( + strategy?.balancesAtom || atom([returnEmptyBal()]), + ); // fetch tx history const txHistoryAtom = useMemo( - () => - TxHistoryAtom( - strategyAddress, - address!, - strategy?.balanceAtom || DUMMY_BAL_ATOM, - ), - [address, strategyAddress, balData], + () => TxHistoryAtom(strategyAddress, address!), + [address, strategyAddress], ); + const txHistoryResult = useAtomValue(txHistoryAtom); const txHistory = useMemo(() => { if (txHistoryResult.data) { @@ -112,7 +105,7 @@ const Strategy = ({ params }: StrategyParams) => { txHistoryResult.isLoading, ); return txHistoryResult.data || { findManyInvestment_flows: [] }; - }, [txHistoryResult.data]); + }, [JSON.stringify(txHistoryResult.data)]); // compute profit // profit doesnt change quickly in real time, but total deposit amount can change @@ -163,12 +156,35 @@ const Strategy = ({ params }: StrategyParams) => { setIsMounted(true); }, []); + const strategiesInfo = useAtomValue(STRKFarmBaseAPYsAtom); + const strategyCached = useMemo(() => { + if (!strategiesInfo || !strategiesInfo.data) return null; + const strategiesList: STRKFarmStrategyAPIResult[] = + strategiesInfo.data.strategies; + return strategiesList.find((s: any) => s.id === params.strategyId); + }, [strategiesInfo, params.strategyId]); + if (!isMounted) return null; return ( <> - + + {strategy && + strategy.metadata.depositTokens.length > 0 && + strategy.metadata.depositTokens.map((token: any) => { + return ( + + ); + })} + {strategy && strategy.metadata.depositTokens.length == 0 && ( + + )} + { > How does it work? - {strategy.description} - + {getUniqueById( strategy.actions.map((p) => ({ @@ -254,28 +270,32 @@ const Strategy = ({ params }: StrategyParams) => { : 'Connect wallet'} - - - - Net earnings - - = 0 ? 'cyan' : 'red'} - > - {address && profit !== 0 && !strategy?.isRetired() - ? `${profit?.toFixed(balData.data.tokenInfo?.displayDecimals || 2)} ${balData.data.tokenInfo?.name}` - : '-'} - - - + {!strategy.settings.isTransactionHistDisabled && ( + + + + Net earnings + + = 0 ? 'cyan' : 'red'} + > + {address && + profit !== 0 && + !strategy?.isRetired() + ? `${profit?.toFixed(balData.data.tokenInfo?.displayDecimals || 2)} ${balData.data.tokenInfo?.name}` + : '-'} + + + + )} )} - {(balData.isLoading || - balData.isPending || - !balData.data?.tokenInfo) && ( + {(balData.isLoading || !balData.data?.tokenInfo) && ( Your Holdings: {address ? ( @@ -290,6 +310,35 @@ const Strategy = ({ params }: StrategyParams) => { Your Holdings: Error )} + + {/* Show individual holdings is more tokens */} + {individualBalances.length > 1 && + balData.data?.amount.compare('0', 'gt') && ( + + +

Detailed Split:

+ {individualBalances.map((bx, index) => { + return ( + + {bx?.amount.toEtherToFixedDecimals( + bx.tokenInfo?.displayDecimals || 2, + )}{' '} + {bx?.tokenInfo?.name} + + ); + })} +
+
+ )} + {address && balData.data && strategy.id == 'xstrk_sensei' && @@ -346,105 +395,13 @@ const Strategy = ({ params }: StrategyParams) => { - - - - { - // mixpanel.track('All pools clicked') - }} - > - Deposit - - { - // mixpanel.track('Strategies opened') - }} - > - Withdraw - - - - - - - {strategy.settings.alerts != undefined && ( - - {strategy.settings.alerts - .filter((a) => a.tab == 'deposit' || a.tab == 'all') - .map((alert, index) => ( - - - {alert.text} - - ))} - - )} - - - - {strategy.settings.alerts != undefined && ( - - {strategy.settings.alerts - .filter( - (a) => a.tab == 'withdraw' || a.tab == 'all', - ) - .map((alert, index) => ( - - - {alert.text} - - ))} - - )} - - - - + {!strategy || + (strategy.isSingleTokenDepositView && ( + + ))} + {strategy && !strategy.isSingleTokenDepositView && ( + + )} @@ -453,7 +410,7 @@ const Strategy = ({ params }: StrategyParams) => { Behind the scenes - + Actions done automatically by the strategy (smart-contract) with an investment of $1000 @@ -486,75 +443,70 @@ const Strategy = ({ params }: StrategyParams) => { Yield - {strategy.actions.map((action, index) => ( - - - {index + 1} - {')'} {action.name} - - ( + - {' '} - {action.pool.pool.name} on - {' '} - {action.pool.protocol.name} - - - ${Number(action.amount).toLocaleString()} yields{' '} - {action.isDeposit - ? (action.pool.apr * 100).toFixed(2) - : -(action.pool.borrow.apr * 100).toFixed(2)} - % - - - ${Number(action.amount).toLocaleString()} - - - {action.isDeposit - ? (action.pool.apr * 100).toFixed(2) - : -(action.pool.borrow.apr * 100).toFixed(2)} - % - - - ))} - {strategy.actions.length == 0 && ( + + {index + 1} + {')'} {action.name} + + + {' '} + {action.token.name} on + {' '} + {action.protocol.name} + + + ${Number(action.amount).toLocaleString()} yields{' '} + {(action.apy * 100).toFixed(2)}% + + + ${Number(action.amount).toLocaleString()} + + + {(action.apy * 100).toFixed(2)}% + + + ))} + {(!strategyCached || strategyCached.actions.length == 0) && (
@@ -585,8 +537,8 @@ const Strategy = ({ params }: StrategyParams) => { {r} @@ -623,10 +575,16 @@ const Strategy = ({ params }: StrategyParams) => { Transaction history - - There may be delays fetching data. If your transaction{' '} - {`isn't`} found, try again later. - + {!strategy.settings.isTransactionHistDisabled && ( + + There may be delays fetching data. If your transaction{' '} + {`isn't`} found, try again later. + + )} {txHistoryResult.isSuccess && ( <> @@ -699,13 +657,25 @@ const Strategy = ({ params }: StrategyParams) => { )} {/* If no filtered tx */} - {txHistory.findManyInvestment_flows.length === 0 && ( + {!strategy.settings.isTransactionHistDisabled && + txHistory.findManyInvestment_flows.length === 0 && ( + + No transactions found + + )} + {strategy.settings.isTransactionHistDisabled && ( - No transactions found + Transaction history is not available for this strategy yet. + If enabled in future, will include the entire history. )} diff --git a/src/app/strategy/[strategyId]/_components/TokenDeposit.tsx b/src/app/strategy/[strategyId]/_components/TokenDeposit.tsx new file mode 100644 index 00000000..4319b924 --- /dev/null +++ b/src/app/strategy/[strategyId]/_components/TokenDeposit.tsx @@ -0,0 +1,141 @@ +import Deposit from '@/components/Deposit'; +import { StrategyInfo } from '@/store/strategies.atoms'; +import { + Alert, + AlertIcon, + Card, + Tab, + TabIndicator, + TabList, + TabPanel, + TabPanels, + Tabs, + VStack, +} from '@chakra-ui/react'; +import { useState } from 'react'; + +interface TokenDepositProps { + strategy: StrategyInfo; + isDualToken?: boolean; +} + +export function TokenDeposit(props: TokenDepositProps) { + const [tabIndex, setTabIndex] = useState(0); + const { strategy } = props; + return ( + + { + setTabIndex(index); + }} + > + + { + // mixpanel.track('All pools clicked') + }} + > + Deposit + + { + // mixpanel.track('Strategies opened') + }} + > + Withdraw + + + + + + {tabIndex == 0 && ( + <> + + {strategy.settings.alerts != undefined && ( + + {strategy.settings.alerts + .filter((a) => a.tab == 'deposit' || a.tab == 'all') + .map((alert, index) => ( + + + {alert.text} + + ))} + + )} + + )} + + + {tabIndex == 1 && ( + <> + + {strategy.settings.alerts != undefined && ( + + {strategy.settings.alerts + .filter((a) => a.tab == 'withdraw' || a.tab == 'all') + .map((alert, index) => ( + + + {alert.text} + + ))} + + )} + + )} + + + + + ); +} diff --git a/src/components/AmountInput.tsx b/src/components/AmountInput.tsx new file mode 100644 index 00000000..990b2209 --- /dev/null +++ b/src/components/AmountInput.tsx @@ -0,0 +1,641 @@ +import React, { + useState, + useCallback, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, +} from 'react'; +import { + Box, + Text, + GridItem, + NumberInput, + NumberInputField, + Image as ImageC, + NumberInputStepper, + NumberIncrementStepper, + NumberDecrementStepper, + Button, + Tooltip, + Grid, + Menu, + MenuButton, + Center, + MenuList, + MenuItem, + Link, +} from '@chakra-ui/react'; +import { useAccount } from '@starknet-react/core'; +import { useAtom, useAtomValue, Atom, useSetAtom } from 'jotai'; +import { TokenInfo as TokenInfoV2, Web3Number } from '@strkfarm/sdk'; +import { + AmountInputInfo, + depositAtom, + DepositAtomType, + inputsInfoAtom, + updateInputInfoAtom, +} from './Deposit'; +import mixpanel from 'mixpanel-browser'; +import { AtomWithQueryResult } from 'jotai-tanstack-query'; +import { BalanceResult, DUMMY_BAL_ATOM } from '@/store/balance.atoms'; +import { StrategyInfo } from '@/store/strategies.atoms'; +import MyNumber from '@/utils/MyNumber'; +import LoadingWrap from './LoadingWrap'; +import { ChevronDownIcon } from '@chakra-ui/icons'; +import { MyMenuItemProps, MyMenuListProps } from '@/utils'; +import debounce from 'lodash.debounce'; + +interface AmountInputProps { + index: number; + tokenInfo: TokenInfoV2; + balanceAtom?: Atom>; + strategy: StrategyInfo; + isDeposit: boolean; + buttonText: string; + supportedTokens: TokenInfoV2[]; +} + +export interface AmountInputRef { + reset: () => void; +} + +/** + * AmountInput component handles token amount input with balance and TVL validation + * @param props Component properties including token info, strategy details, and mode + * @param ref Ref to expose reset functionality to parent + */ +const AmountInput = forwardRef( + (props: AmountInputProps, ref: React.ForwardedRef) => { + // Input state + const [dirty, setDirty] = useState(false); + + // External state + const tvlInfo = useAtomValue(props.strategy.tvlAtom); + const { address } = useAccount(); + const [depositInfo, setDepositInfo] = useAtom(depositAtom); + const setInputInfo = useSetAtom(updateInputInfoAtom); + const inputsInfo = useAtomValue(inputsInfoAtom); + const [simulatedMaxAmount, setSimulatedMaxAmount] = useState({ + isSet: false, + amount: 1e18, + }); // some random big amount to start with + + const inputInfo = useMemo(() => { + if (props.index < 0 || props.index >= inputsInfo.length) + throw new Error(`Invalid index: ${props.index}`); + return inputsInfo[props.index]; + }, [inputsInfo, props.index]); + + // Default token state + const [selectedMarket, setSelectedMarket] = useState( + props.tokenInfo, + ); + + useImperativeHandle(ref, () => ({ + reset: () => { + setDirty(false); + updateTokenInfo({ + amount: Web3Number.fromWei('0', props.tokenInfo.decimals), + tokenInfo: props.tokenInfo, + isMaxClicked: false, + rawAmount: '', + }); + }, + })); + + // Get balance data + const balData = useAtomValue(props.balanceAtom || DUMMY_BAL_ATOM); + const balance = useMemo(() => { + return balData.data?.amount || MyNumber.fromZero(); + }, [balData]); + + const isMinAmountError: boolean = useMemo(() => { + if (!dirty) return false; + + const isAtleastOneNonZero = inputsInfo.some((item) => item.amount.gt(0)); + if (isAtleastOneNonZero) { + return false; + } + return true; + }, [inputsInfo]); + /** + * Calculate maximum allowed amount based on: + * - TVL limits for deposits + * - Balance minus gas reserves for specific tokens + * - Zero as minimum + */ + const maxAmount: MyNumber = useMemo(() => { + const currentTVL = Number( + tvlInfo.data?.amounts[0].amount.toFixed(6) || 0, + ); + const maxAllowed = + props.isDeposit && props.strategy.settings.maxTVL !== 0 + ? props.strategy.settings.maxTVL - currentTVL + : Number(balance.toEtherToFixedDecimals(8)); + + const adjustedMaxAllowed = MyNumber.fromEther( + maxAllowed.toFixed(6), + selectedMarket.decimals, + ); + + // Reserve gas amounts for specific tokens + let reducedBalance = balance; + if (props.isDeposit) { + if (selectedMarket.name === 'STRK') { + reducedBalance = balance.subtract( + MyNumber.fromEther('1.5', selectedMarket.decimals), + ); + } else if (selectedMarket.name === 'ETH') { + reducedBalance = balance.subtract( + MyNumber.fromEther('0.001', selectedMarket.decimals), + ); + } + } + + // simulation check + const postSimulationMax = MyNumber.min( + adjustedMaxAllowed, + MyNumber.fromEther( + simulatedMaxAmount.amount.toFixed(13), + selectedMarket.decimals, + ), + ); + + const min = MyNumber.min(reducedBalance, postSimulationMax); + return MyNumber.max( + min, + MyNumber.fromEther('0', selectedMarket.decimals), + ); + }, [ + balance, + props.strategy, + selectedMarket, + props.isDeposit, + tvlInfo, + simulatedMaxAmount.amount, + ]); + + function onAmountChange( + _amt: MyNumber, + isMaxClicked: boolean, + rawAmount = _amt.toEtherStr(), + _token = props.tokenInfo, + ) { + updateTokenInfo({ + tokenInfo: _token, + amount: Web3Number.fromWei(_amt.toString(), _token.decimals), + isMaxClicked, + rawAmount, + }); + + checkAndTriggerOnAmountsChange(_amt, _token, inputsInfo, depositInfo); + } + + function checkAndTriggerOnAmountsChange( + _amt: MyNumber, + _token: TokenInfoV2 = props.tokenInfo, + _inputsInfo: AmountInputInfo[], + _depositInfo: DepositAtomType, + ) { + // if onAmountsChange defined + const isAllTokenInfosDefined = _inputsInfo.every( + (item) => item.tokenInfo, + ); + console.log( + 'onAmountsChange [2.1]', + props.index, + isAllTokenInfosDefined, + props.buttonText, + _inputsInfo, + _depositInfo.onAmountsChange, + ); + if (!isAllTokenInfosDefined || !_depositInfo.onAmountsChange) { + return; + } + const _amtWeb3 = Web3Number.fromWei(_amt.toString(), _token.decimals); + console.log('onAmountsChange [2.2]', _amtWeb3.toString(), props.index); + try { + setDepositInfo({ + ..._depositInfo, + loading: true, + }); + _depositInfo + .onAmountsChange( + { + amountInfo: { + amount: _amtWeb3, + tokenInfo: _token, + }, + index: props.index, + }, + _inputsInfo.map((item, index) => { + if (index == props.index) { + return { + amount: _amtWeb3, + tokenInfo: _token, + }; + } + return { + amount: item.amount, + tokenInfo: item.tokenInfo!, + }; + }), + ) + .then((output) => { + console.log('onAmountsChange [2.3]', JSON.stringify(output)); + output.map((item, _index) => { + console.log( + 'onAmountsChange [2.4]', + item.amount.toString(), + item.tokenInfo.symbol, + ); + setInputInfo({ + index: _index, + info: { + ..._inputsInfo[_index], + ...item, + rawAmount: Number(item.amount.toFixed(6)).toString(), + }, + }); + }); + setDepositInfo({ + ..._depositInfo, + loading: false, + }); + }) + .catch((err) => { + console.log('onAmountsChange [2.4]', err); + setDepositInfo({ + ..._depositInfo, + loading: false, + }); + }); + } catch (err) { + console.log('onAmountsChange [2.5] err', err); + } + } + + function BalanceComponent(props: { + token: TokenInfoV2; + strategy: StrategyInfo; + buttonText: string; + }) { + const handleMaxClick = useCallback(() => { + onAmountChange(maxAmount, true); + mixpanel.track('Chose max amount', { + strategyId: props.strategy.id, + strategyName: props.strategy.name, + buttonText: props.buttonText, + amount: inputInfo.amount.toFixed(2), + token: selectedMarket.name, + maxAmount: maxAmount.toEtherStr(), + address, + }); + }, [ + maxAmount, + inputInfo, + selectedMarket, + props.strategy, + props.buttonText, + address, + ]); + + const isLoading = balData.isLoading || balData.isPending; + + return ( + + Available balance + + + + {balance.toEtherToFixedDecimals(4)} + + + + + + ); + } + + useEffect(() => { + console.log(`onAmountsChange [10.2]`, inputInfo, props.index); + }, [JSON.stringify(inputsInfo)]); + + function updateTokenInfo(inputInfo: AmountInputInfo) { + const { amount, tokenInfo: _t, isMaxClicked, rawAmount } = inputInfo; + console.log( + `onAmountsChange [10.1]`, + amount.toWei(), + inputInfo, + props.index, + props.buttonText, + ); + const tokenInfo = _t!; + const _amount = Web3Number.fromWei(amount.toWei(), tokenInfo.decimals); + setInputInfo({ + index: props.index, + info: { + amount: _amount, + tokenInfo, + isMaxClicked, + rawAmount, + }, + }); + } + + useEffect(() => { + console.log('onAmountsChange [3]', props); + updateTokenInfo({ + amount: Web3Number.fromWei('0', props.tokenInfo.decimals), + tokenInfo: props.tokenInfo, + isMaxClicked: false, + rawAmount: '', + }); + }, []); + + useEffect(() => { + if ( + !depositInfo || + !depositInfo.onAmountsChange || + simulatedMaxAmount.isSet + ) { + return; + } + // simulate deposits using a large amount + const amt = new Web3Number(10000000, props.tokenInfo.decimals); + depositInfo + .onAmountsChange( + { + amountInfo: { + amount: amt, + tokenInfo: props.tokenInfo, + }, + index: props.index, + }, + inputsInfo.map((item, index) => { + if (index == props.index) { + return { + amount: amt, + tokenInfo: props.tokenInfo, + }; + } + return { + amount: Web3Number.fromWei('0', item.tokenInfo?.decimals || 0), + tokenInfo: item.tokenInfo!, + }; + }), + ) + .then((output) => { + console.log('onAmountsChange [3.1]', output); + output.map((item, _index) => { + if (_index == props.index) { + setSimulatedMaxAmount({ + isSet: true, + amount: Number(item.amount.toFixed(13)), + }); + } + }); + }); + }, [depositInfo.onAmountsChange, simulatedMaxAmount.isSet]); + + const handleDebouncedChange = useCallback( + debounce( + ( + newAmount: MyNumber, + valueStr: string, + _inputsInfo: AmountInputInfo[], + _depositInfo: DepositAtomType, + ) => { + checkAndTriggerOnAmountsChange( + newAmount, + props.tokenInfo, + _inputsInfo, + _depositInfo, + ); + + // Track user input + mixpanel.track('Enter amount', { + strategyId: props.strategy.id, + strategyName: props.strategy.name, + buttonText: props.buttonText, + amount: inputInfo.amount.toFixed(2), + token: selectedMarket.name, + maxAmount: maxAmount.toEtherStr(), + address, + }); + }, + 400, + ), + [], + ); // ms delay + + return ( + + {/* Token selection and balance display */} + + + + } + bgColor={'highlight'} + borderColor={'bg'} + borderWidth={'1px'} + color="color2" + _hover={{ + bg: 'bg', + }} + > +
+ + {props.tokenInfo.symbol} +
+
+ + {props.supportedTokens.map((token) => ( + { + if (selectedMarket.name !== token.symbol) { + setSelectedMarket(token); + setDirty(false); + onAmountChange( + new MyNumber('0', token.decimals), + inputInfo.isMaxClicked, + '', + token, + ); + } + }} + > +
+ + {token.symbol} +
+
+ ))} +
+
+
+ + + +
+ + {/* Amount input with validation */} + { + const newAmount = + valueStr && Number(valueStr) > 0 + ? MyNumber.fromEther(valueStr, selectedMarket.decimals) + : new MyNumber('0', selectedMarket.decimals); + + setDirty(true); + updateTokenInfo({ + tokenInfo: props.tokenInfo, + amount: Web3Number.fromWei( + newAmount.toString(), + props.tokenInfo.decimals, + ), + isMaxClicked: inputInfo.isMaxClicked, + rawAmount: valueStr, + }); + handleDebouncedChange(newAmount, valueStr, inputsInfo, depositInfo); + }} + marginTop={'10px'} + keepWithinRange={false} + clampValueOnBlur={false} + value={inputInfo.rawAmount} + isDisabled={maxAmount.isZero()} + > + + + + + + + + {/* Validation error messages */} + {simulatedMaxAmount.amount == 0 && ( + + The liquidity at the current market price, is only in{' '} + { + inputsInfo.find((_, index) => index != props.index)?.tokenInfo + ?.symbol + } + . It may be in {props.tokenInfo.symbol}, when the market price + re-aligns. + + } + > + + The liquidity at the current market price, is only in{' '} + { + inputsInfo.find((_, index) => index != props.index)?.tokenInfo + ?.symbol + } + .{' '} + + Learn more + + . + + + )} + {isMinAmountError && dirty && ( + + Amount must be greater than 0 + + )} + {inputInfo.amount.gt(maxAmount.toEtherStr()) && ( + + Amount must be less than {maxAmount.toEtherToFixedDecimals(2)} + + )} +
+ ); + }, +); + +AmountInput.displayName = 'AmountInput'; +export default AmountInput; diff --git a/src/components/Deposit.tsx b/src/components/Deposit.tsx index f44075b7..b8afa76a 100755 --- a/src/components/Deposit.tsx +++ b/src/components/Deposit.tsx @@ -1,356 +1,369 @@ -import { DUMMY_BAL_ATOM } from '@/store/balance.atoms'; import { StrategyInfo } from '@/store/strategies.atoms'; import { StrategyTxProps } from '@/store/transactions.atom'; import { DepositActionInputs, IStrategyActionHook, - TokenInfo, + onStratAmountsChangeFn, } from '@/strategies/IStrategy'; -import { MyMenuItemProps, MyMenuListProps } from '@/utils'; +import { convertToMyNumber, convertToV1TokenInfo } from '@/utils'; import MyNumber from '@/utils/MyNumber'; -import { ChevronDownIcon } from '@chakra-ui/icons'; import { Alert, AlertIcon, Box, - Button, Center, Flex, - Grid, - GridItem, - Image as ImageC, - Menu, - MenuButton, - MenuItem, - MenuList, - NumberDecrementStepper, - NumberIncrementStepper, - NumberInput, - NumberInputField, - NumberInputStepper, Progress, Spinner, Text, - Tooltip, + VStack, } from '@chakra-ui/react'; import { useAccount } from '@starknet-react/core'; -import { useAtomValue } from 'jotai'; -import mixpanel from 'mixpanel-browser'; -import { useEffect, useMemo, useState } from 'react'; -import LoadingWrap from './LoadingWrap'; +import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useEffect, useMemo, useRef, useState } from 'react'; import TxButton from './TxButton'; import { provider } from '@/constants'; +import { + SingleActionAmount, + TokenInfo as TokenInfoV2, + Web3Number, +} from '@strkfarm/sdk'; +import AmountInput, { AmountInputRef } from './AmountInput'; +import { addressAtom } from '@/store/claims.atoms'; interface DepositProps { strategy: StrategyInfo; // ? If you want to add more button text, you can add here // ? @dev ensure below actionType is updated accordingly buttonText: 'Deposit' | 'Redeem'; - callsInfo: (inputs: DepositActionInputs) => IStrategyActionHook[]; + callsInfo: (inputs: DepositActionInputs) => Promise; + isDualToken: boolean; +} +/** + * Information about a token amount input field + */ +export interface AmountInputInfo { + /** Whether the max button was clicked */ + isMaxClicked: boolean; + /** Raw string value entered in input field (e.g. '', '0.1') */ + rawAmount: string; + /** Converted Web3Number value for calculations and transactions */ + amount: Web3Number; + /** Token information, null if not yet selected */ + tokenInfo: TokenInfoV2 | null; +} + +/** + * State shape for deposit form + */ +export type DepositAtomType = { + /** Array of atoms containing input info for each token */ + inputsInfo: PrimitiveAtom[]; + // deposit/withdrawMethods can return multiple options (which are actions) + actionIndex: number; + loading?: boolean; + /** Optional callback when amounts change */ + onAmountsChange?: onStratAmountsChangeFn; +}; + +function getInputInfoAtoms() { + return [1, 2].map((i) => { + return atom({ + isMaxClicked: false, + amount: Web3Number.fromWei('0', 0), + tokenInfo: null, + rawAmount: '', + }); + }); } +/** + * Derived atom of input infos + */ +export const inputsInfoAtoms = getInputInfoAtoms(); + +/** + * Derived atom of input infos + */ +export const inputsInfoAtom = atom((get) => { + return inputsInfoAtoms.map((i) => get(i)); +}); + +// Create deposit atom with 2 token inputs (current max supported) +export const depositAtom = atom({ + inputsInfo: inputsInfoAtoms, + actionIndex: 0, + loading: false, + onAmountsChange: undefined, +}); + +/** + * Derived atom that checks if max button was clicked for any input + */ +const isMaxClickedAtom = atom((get) => { + const inputInfos = get(inputsInfoAtom); + return inputInfos.some((a) => a.isMaxClicked); +}); + +export const updateInputInfoAtom = atom( + null, + ( + get, + set, + { index, info }: { index: number; info: Partial }, + ) => { + const inputInfo = get(inputsInfoAtoms[index]); + const newInputInfo = { ...inputInfo, ...info }; + console.log(`onAmountsChange [2]`, index, info, newInputInfo); + set(inputsInfoAtoms[index], newInputInfo); + }, +); + +export const resetDepositFormAtom = atom(null, (get, set) => { + const inputInfos = get(inputsInfoAtom); + set(depositAtom, { + inputsInfo: getInputInfoAtoms(), + actionIndex: 0, + loading: false, + onAmountsChange: undefined, + }); + inputInfos.map((item, index) => { + set(inputsInfoAtoms[index], { + ...item, + isMaxClicked: false, + rawAmount: '', + amount: Web3Number.fromWei('0', 0), + }); + }); +}); export default function Deposit(props: DepositProps) { + return ; +} + +function InternalDeposit(props: DepositProps) { const { address } = useAccount(); - const [dirty, setDirty] = useState(false); - const [isMaxClicked, setIsMaxClicked] = useState(false); + const [callsInfo, setCallsInfo] = useState([]); + const [depositInfo, setDepositInfo] = useAtom(depositAtom); + const firstInputInfo = useAtomValue(inputsInfoAtoms[0]); + const isMaxClicked = useAtomValue(isMaxClickedAtom); + const inputsInfo = useAtomValue(inputsInfoAtom); + const resetDepositForm = useSetAtom(resetDepositFormAtom); - const tvlInfo = useAtomValue(props.strategy.tvlAtom); + useEffect(() => { + resetDepositForm(); + }, []); - // This is the selected market token - const [selectedMarket, setSelectedMarket] = useState( - props.callsInfo({ - amount: MyNumber.fromZero(), - address: address || '0x0', - provider, - isMax: isMaxClicked, - })[0].tokenInfo, - ); + const inputRef1 = useRef(null); + const inputRef2 = useRef(null); - // This is processed amount stored in MyNumber format and meant for sending tx - const [amount, setAmount] = useState( - MyNumber.fromEther('0', selectedMarket.decimals), - ); + const inputRefs = useMemo(() => { + return [inputRef1, inputRef2]; + }, [inputRef1, inputRef2]); - // This is used to store the raw amount entered by the user - const [rawAmount, setRawAmount] = useState(''); + // since we use a separate jotai provider, + // need to set this again here + const setAddress = useSetAtom(addressAtom); + useEffect(() => { + setAddress(address); + }, [address]); + + useEffect(() => { + const amount1 = new MyNumber( + firstInputInfo.amount.toWei() || '0', + firstInputInfo.tokenInfo?.decimals || 0, + ); + let amount2: MyNumber | undefined = undefined; + if (inputsInfo[1].tokenInfo) { + amount2 = new MyNumber( + inputsInfo[1].amount.toWei() || '0', + inputsInfo[1].tokenInfo?.decimals || 0, + ); + } + console.log( + 'Deposit calls [0]', + amount1.toString(), + amount2?.toString(), + address, + firstInputInfo.tokenInfo?.decimals, + inputsInfo[1].tokenInfo?.decimals, + ); + props + .callsInfo({ + amount: amount1, + amount2, + address: address || '0x0', + provider, + isMax: isMaxClicked, + }) + .then((calls) => { + console.log('Deposit calls', calls); + setCallsInfo(calls); + setDepositInfo({ + ...depositInfo, + onAmountsChange: calls[0].onAmountsChange, + }); + }) + .catch((e) => { + console.error('Error in deposit methods', e); + }); + }, [ + JSON.stringify(props.callsInfo), + address, + JSON.stringify(inputsInfo), + JSON.stringify(firstInputInfo), + isMaxClicked, + ]); + // This is used to store the raw amount entered by the user const isDeposit = useMemo(() => props.buttonText === 'Deposit', [props]); + const [loadingInvestmentSummary, setLoadingInvestmentSummary] = + useState(false); + const [investedSummary, setInvestedSummary] = useState( + null, + ); + useEffect(() => { + if (!callsInfo.length) { + setInvestedSummary(null); + return; + } + if (callsInfo[0].amounts.length == 1) { + setInvestedSummary(firstInputInfo.amount); + setLoadingInvestmentSummary(false); + return; + } + setLoadingInvestmentSummary(true); + const amounts: SingleActionAmount[] = inputsInfo.map((a) => { + return { + amount: a.amount, + tokenInfo: a.tokenInfo!, + }; + }); + props.strategy + .computeSummaryValue( + amounts, + props.strategy.settings.quoteToken, + 'depositcomp', + ) + .then((summary) => { + setInvestedSummary(summary); + setLoadingInvestmentSummary(false); + }) + .catch((e) => { + console.error('Error in computeSummaryValue', e); + setInvestedSummary(null); + setLoadingInvestmentSummary(false); + }); + }, [ + // arrays and complex objects tend to trigger re-renders too much + // below is a workaround + JSON.stringify(inputsInfo), + props.strategy.settings.quoteToken, + callsInfo.length, + callsInfo.length ? callsInfo[0].amounts.length : 0, + firstInputInfo.amount.toString(), + ]); + // use to maintain tx history and show toasts const txInfo: StrategyTxProps = useMemo(() => { return { strategyId: props.strategy.id, actionType: isDeposit ? 'deposit' : 'withdraw', - amount, - tokenAddr: selectedMarket.token, + amount: investedSummary + ? convertToMyNumber(investedSummary) + : MyNumber.fromZero(), + tokenAddr: props.strategy.settings.quoteToken.address.address, }; - }, [amount, props]); - - // Function to reset the input fields to their initial state - const resetDepositForm = () => { - setAmount(MyNumber.fromEther('0', selectedMarket.decimals)); - setRawAmount(''); - setDirty(false); - }; + }, [props]); // constructs tx calls - const { calls, actions } = useMemo(() => { - const actions = props.callsInfo({ - amount, - address: address || '0x0', - provider, - isMax: isMaxClicked, - }); - const hook = actions.find((a) => a.tokenInfo.name === selectedMarket.name); - if (!hook) return { calls: [], actions }; - return { calls: hook.calls, actions }; - }, [selectedMarket, amount, address, provider, isMaxClicked]); - - const balData = useAtomValue( - actions.find((a) => a.tokenInfo.name === selectedMarket.name) - ?.balanceAtom || DUMMY_BAL_ATOM, - ); - const balance = useMemo(() => { - return balData.data?.amount || MyNumber.fromZero(); - }, [balData]); - // const { balance, isLoading, isError } = useERC20Balance(selectedMarket); - - const maxAmount: MyNumber = useMemo(() => { - const currentTVl = tvlInfo.data?.amount || MyNumber.fromZero(); - const maxAllowed = - props.buttonText == 'Deposit' && props.strategy.settings.maxTVL != 0 - ? props.strategy.settings.maxTVL - Number(currentTVl.toEtherStr()) - : Number(balance.toEtherToFixedDecimals(8)); - const adjustedMaxAllowed = MyNumber.fromEther( - maxAllowed.toFixed(6), - selectedMarket.decimals, - ); - let reducedBalance = balance; - if (props.buttonText === 'Deposit') { - if (selectedMarket.name === 'STRK') { - reducedBalance = balance.subtract( - MyNumber.fromEther('1.5', selectedMarket.decimals), - ); - } else if (selectedMarket.name === 'ETH') { - reducedBalance = balance.subtract( - MyNumber.fromEther('0.001', selectedMarket.decimals), - ); - } - } - console.log('Deposit:: reducedBalance2', reducedBalance.toEtherStr()); - const min = MyNumber.min(reducedBalance, adjustedMaxAllowed); - return MyNumber.max(min, MyNumber.fromEther('0', selectedMarket.decimals)); - }, [balance, props.strategy, selectedMarket]); + const { calls } = useMemo(() => { + const hook = callsInfo[depositInfo.actionIndex]; + if (!hook) return { calls: [] }; + return { calls: hook.calls }; + }, [address, provider, isMaxClicked, callsInfo, depositInfo]); + const tvlInfo = useAtomValue(props.strategy.tvlAtom); const isTVLFull = useMemo(() => { return ( props.strategy.settings.maxTVL != 0 && - tvlInfo.data?.amount.compare( + tvlInfo.data?.amounts[0].amount.greaterThan( props.strategy.settings.maxTVL.toFixed(6), - 'gt', ) ); }, [tvlInfo]); - useEffect(() => { - if (isMaxClicked) { - setRawAmount(maxAmount.toEtherStr()); - setAmount(maxAmount); + const canSubmit = useMemo(() => { + if (isTVLFull && isDeposit) { + return false; + } + if (depositInfo.loading) { + return false; + } + if (!investedSummary || loadingInvestmentSummary) { + return false; } - }, [maxAmount, isMaxClicked]); + // todo consider max cap of each token as well + return inputsInfo.some((a) => a.amount.greaterThan(0)); + }, [ + depositInfo, + loadingInvestmentSummary, + investedSummary, + inputsInfo, + isTVLFull, + isDeposit, + ]); - function BalanceComponent(props: { - token: TokenInfo; - strategy: StrategyInfo; - buttonText: string; - }) { - return ( - - Available balance - - - - {balance.toEtherToFixedDecimals(4)} - - - - - - ); - } return ( - - - - } - bgColor={'highlight'} - borderColor={'bg'} - borderWidth={'1px'} - color="color2" - _hover={{ - bg: 'bg', - }} - > -
- {/* */} - {balData.data && balData.data.tokenInfo - ? balData.data.tokenInfo.name - : '-'} -
-
- - {actions.map((dep) => ( - { - if (selectedMarket.name != dep.tokenInfo.name) { - setSelectedMarket(dep.tokenInfo); - setAmount(new MyNumber('0', dep.tokenInfo.decimals)); - setDirty(false); - setRawAmount(''); - } - }} - > -
- {' '} - {dep.tokenInfo.name} -
-
- ))} -
-
-
- - - -
- - {/* add min max validations and show err */} - { - if (value && Number(value) > 0) - setAmount(MyNumber.fromEther(value, selectedMarket.decimals)); - else { - setAmount(new MyNumber('0', selectedMarket.decimals)); - } - setIsMaxClicked(false); - setRawAmount(value); - setDirty(true); - mixpanel.track('Enter amount', { - strategyId: props.strategy.id, - strategyName: props.strategy.name, - buttonText: props.buttonText, - amount: amount.toEtherStr(), - token: selectedMarket.name, - maxAmount: maxAmount.toEtherStr(), - address, - }); - }} - marginTop={'10px'} - keepWithinRange={false} - clampValueOnBlur={false} - value={rawAmount} - isDisabled={maxAmount.isZero()} - > - - - - - - - {amount.isZero() && dirty && ( - - Require amount {'>'} 0 - - )} - {amount.compare(maxAmount.toEtherStr(), 'gt') && ( - - Amount to be less than {maxAmount.toEtherToFixedDecimals(2)} - - )} + + {/* // todo wont work with multiple token options for now */} + {callsInfo.length && + callsInfo[0].amounts.map((inputAmtInfo, index) => { + return ( + c.amounts[index].tokenInfo, + )} + /> + ); + })} +
{ + resetDepositForm(); + inputRefs.forEach((ref) => { + if (ref.current) { + ref.current.reset(); + } + }); + }} />
@@ -364,11 +377,13 @@ export default function Deposit(props: DepositProps) { {!tvlInfo || !tvlInfo?.data ? ( ) : ( - Number(tvlInfo.data?.amount.toFixedStr(2)).toLocaleString() + Number( + tvlInfo.data?.amounts[0].amount.toFixed(2), + ).toLocaleString() )} {' / '} {props.strategy.settings.maxTVL.toLocaleString()}{' '} - {selectedMarket.name} + {inputsInfo[0].tokenInfo?.symbol} = ({ strategy, balData }) => { return strategyInfo.leverage || 0; }, [strategyInfo]); + const defaultAPYTooltip = + 'Current APY including any fees. Net returns subject to change based on market conditions.'; return ( - + + + {strategy.metadata.apyMethodology || defaultAPYTooltip} + + {strategyInfo && ( + + + Strategy APY: + + Including fees and Defi spring rewards + + + + {(strategyInfo.apySplit.baseApy * 100).toFixed(2)}% + + + )} + {strategyInfo && strategyInfo.apySplit.rewardsApy > 0 && ( + + + Rewards APY: + + Incentives by STRKFarm + + + + {(strategyInfo.apySplit.rewardsApy * 100).toFixed(2)}% + + + )} + + } + > = ({ strategy, balData }) => { - {/* - - - 🔥{leverage.toFixed(2)}x boosted - {leverage == 0 && ( - - )} - - - */} + {strategyInfo && strategyInfo.apySplit.rewardsApy > 0 && ( + + + + 🔥 Boosted + {leverage == 0 && ( + + )} + + + + )} {!isMobile && !strategy.settings.hideHarvestInfo && ( diff --git a/src/components/YieldCard.tsx b/src/components/YieldCard.tsx index 33cb6881..770adeb4 100644 --- a/src/components/YieldCard.tsx +++ b/src/components/YieldCard.tsx @@ -28,6 +28,7 @@ import { Tr, VStack, } from '@chakra-ui/react'; +import { ContractAddr } from '@strkfarm/sdk'; import { useAtomValue } from 'jotai'; import mixpanel from 'mixpanel-browser'; import { useMemo } from 'react'; @@ -70,9 +71,9 @@ export function StrategyInfo(props: YieldCardProps) { return ( - - {pool.pool.logos.map((logo) => ( - + + {pool.pool.logos.map((logo, index) => ( + ))} @@ -135,11 +136,18 @@ function getAPRWithToolTip(pool: PoolInfo) { {pool.aprSplits.map((split) => { return ( - - - {split.title} {split.description ? `(${split.description})` : ''} - - + + + {split.title}: + + {split.description} + + + {split.apr === 'Err' ? split.apr : (split.apr * 100).toFixed(2)}% @@ -190,25 +198,30 @@ function StrategyAPY(props: YieldCardProps) { <> {getAPRWithToolTip(pool)} - {/* {pool.additional && pool.additional.leverage && ( - - - - - ⚡ - - - {pool.additional.leverage.toFixed(1)}X - + {pool.aprSplits.length && + pool.aprSplits.some((a) => a.title == 'Rewards APY') && ( + + + + + ⚡ + + + Boosted + + - - - )} */} + + )} )} @@ -220,23 +233,27 @@ export function getStrategyWiseHoldingsInfo( id: string, ) { const amount = userData?.strategyWise.find((item) => item.id === id); + const defaultTokenInfo = { + name: 'N/A', + symbol: 'N/A', + address: ContractAddr.from('0x0'), + decimals: 0, + logo: '', + displayDecimals: 2, + }; if (!amount) { return { usdValue: 0, amount: 0, - tokenInfo: { - symbol: '', - decimals: 0, - displayDecimals: 0, - logo: '', - name: '', - }, + tokenInfo: defaultTokenInfo, }; } return { usdValue: amount.usdValue, - amount: Number(amount.amount), - tokenInfo: amount.tokenInfo, + amount: amount.holdings.length ? Number(amount.holdings[0].amount) : 0, + tokenInfo: amount.holdings.length + ? amount.holdings[0].tokenInfo + : defaultTokenInfo, }; } diff --git a/src/constants.ts b/src/constants.ts index afe88058..47bbe3d6 100755 --- a/src/constants.ts +++ b/src/constants.ts @@ -57,6 +57,7 @@ export const CONSTANTS = { }, STRKFarm: { BASE_APR_API: '/api/strategies', + // BASE_APR_API: 'https://app.strkfarm.com/api/strategies', }, MY_SWAP: { POOLS_API: '/myswap/data/pools/all.json', diff --git a/src/store/balance.atoms.ts b/src/store/balance.atoms.ts index 5b966af3..48f00544 100755 --- a/src/store/balance.atoms.ts +++ b/src/store/balance.atoms.ts @@ -20,7 +20,7 @@ export interface BalanceResult { tokenInfo: TokenInfo | undefined; } -function returnEmptyBal(): BalanceResult { +export function returnEmptyBal(): BalanceResult { return { amount: MyNumber.fromZero(), tokenInfo: undefined, @@ -31,6 +31,7 @@ export async function getERC20Balance( token: TokenInfo | undefined, address: string | undefined, ) { + console.log('getERC20Balance', token?.token, address); if (!token) return returnEmptyBal(); if (!address) return returnEmptyBal(); @@ -39,7 +40,6 @@ export async function getERC20Balance( }); const erc20Contract = new Contract(ERC20Abi, token.token, provider); const balance = await erc20Contract.call('balanceOf', [address]); - console.log('erc20 balData', token.token, balance.toString()); return { amount: new MyNumber(balance.toString(), token.decimals), tokenInfo: token, @@ -50,7 +50,6 @@ export async function getERC4626Balance( token: TokenInfo | undefined, address: string | undefined, ) { - console.log('balData isERC4626', token?.token); if (!token) return returnEmptyBal(); if (!address) return returnEmptyBal(); @@ -64,12 +63,6 @@ export async function getERC4626Balance( ]); const asset = await erc4626Contract.call('asset', []); - console.log( - 'erc4626 balData', - token.token, - balance, - standariseAddress(asset as string), - ); const assetInfo = getTokenInfoFromAddr(standariseAddress(asset as string)); if (!assetInfo) { throw new Error('ERC4626: Asset not found'); @@ -108,7 +101,6 @@ export async function getERC721PositionValue( const tokenId = num.getDecimalString(address); result = await erc721Contract.call('describe_position', [tokenId]); } - console.log('erc721 position balData', token.address, result); const tokenInfo = getTokenInfoFromName(token.config.mainTokenName); return { amount: new MyNumber( @@ -122,7 +114,7 @@ export async function getERC721PositionValue( export function getERC20BalanceAtom(token: TokenInfo | undefined) { return atomWithQuery((get) => { return { - queryKey: ['getERC20Balance', token?.token], + queryKey: ['getERC20Balance', token?.token, get(addressAtom)], queryFn: async ({ queryKey }: any): Promise => { return getERC20Balance(token, get(addressAtom)); }, @@ -148,11 +140,9 @@ function getERC721PositionValueAtom(token: NFTInfo | undefined) { return { queryKey: ['getERC721PositionValue', token?.address], queryFn: async ({ queryKey }: any): Promise => { - console.log('getERC721PositionValueAtom', token); try { return await getERC721PositionValue(token, get(addressAtom)); } catch (e) { - console.error('getERC721PositionValueAtome', e); return returnEmptyBal(); } }, @@ -166,10 +156,8 @@ export async function getBalance( address: string, ) { if (token) { - console.log('token getBalance', token); if (Object.prototype.hasOwnProperty.call(token, 'isERC4626')) { const _token = token; - console.log('token getBalance isERC4626', _token.isERC4626); if (_token.isERC4626) return getERC4626Balance(_token, address); } else { const _token = token; @@ -187,10 +175,8 @@ export function getBalanceAtom( enabledAtom: Atom, ) { if (token) { - console.log('token getBalanceAtom', token); if (Object.prototype.hasOwnProperty.call(token, 'isERC4626')) { const _token = token; - console.log('token getBalanceAtom isERC4626', _token.isERC4626); if (_token.isERC4626) return getERC4626BalanceAtom(_token); } else { const _token = token; diff --git a/src/store/endur.store.ts b/src/store/endur.store.ts index aa66ce64..734d1590 100644 --- a/src/store/endur.store.ts +++ b/src/store/endur.store.ts @@ -1,15 +1,8 @@ import { atom } from 'jotai'; -import RewardsAbi from '@/abi/rewards.abi.json'; - -import CONSTANTS, { provider } from '@/constants'; -import { AutoXSTRKStrategy } from '@/strategies/auto_xstrk.strat'; import { StrategyLiveStatus } from '@/strategies/IStrategy'; import { getTokenInfoFromName } from '@/utils'; import { customAtomWithFetch } from '@/utils/customAtomWithFetch'; -import { customAtomWithQuery } from '@/utils/customAtomWithQuery'; -import MyNumber from '@/utils/MyNumber'; -import { Contract } from 'starknet'; import { IDapp } from './IDapp.store'; import { Category, PoolInfo, PoolType } from './pools'; @@ -41,38 +34,6 @@ const EndurAtoms = { url: 'https://app.endur.fi/api/stats', queryKey: 'Endur_stats', }), - rewardInfo: customAtomWithQuery({ - queryKey: 'Endur_reward', - queryFn: async () => { - const REWARDS_CONTRACT = - '0x3065fe1dacfaa108a764a9db51d9a5d1cbe7e23a8a9c9e13f12c291da1c1dbb'; - const contract = new Contract(RewardsAbi, REWARDS_CONTRACT, provider); - const data: any = await contract.call('get_config', []); - - const autoxSTRK = new AutoXSTRKStrategy( - 'autoxstrk', - 'autoxstrk', - CONSTANTS.CONTRACTS.AutoxSTRKFarm, - { - maxTVL: 2000000, - }, - ); - const STRK = getTokenInfoFromName('STRK'); - const rewardsPerSecond = new MyNumber( - data.rewards_per_second.toString(), - STRK.decimals, - ); - const tvl = await autoxSTRK.getTVL(); - - const apy = - Number( - rewardsPerSecond - .operate('mul', 365 * 24 * 60 * 60) - .toEtherToFixedDecimals(2), - ) / Number(tvl.amount.toEtherToFixedDecimals(2)); - return apy; - }, - }), pools: atom((get) => { const empty: PoolInfo[] = []; const stats = get(EndurAtoms.endurStats); diff --git a/src/store/protocols.ts b/src/store/protocols.ts index 568fa3d4..ff784d5a 100644 --- a/src/store/protocols.ts +++ b/src/store/protocols.ts @@ -10,15 +10,15 @@ import NostraDegenAtoms, { nostraDegen } from './nostradegen.store'; import NostraDexAtoms, { nostraDex } from './nostradex.store'; import NostraLendingAtoms, { nostraLending } from './nostralending.store'; import { Category, isPoolRetired, PoolInfo, PoolType } from './pools'; -import { getLiveStatusEnum } from './strategies.atoms'; import STRKFarmAtoms, { strkfarm, STRKFarmStrategyAPIResult, } from './strkfarm.atoms'; import VesuAtoms, { vesu } from './vesu.store'; import ZkLendAtoms, { zkLend } from './zklend.store'; +import { getLiveStatusEnum } from '@/strategies/IStrategy'; -export const PROTOCOLS = [ +export const getProtocols = () => [ { name: endur.name, class: endur, @@ -103,16 +103,19 @@ export const PROTOCOLS = [ export const ALL_FILTER = 'All'; -const allProtocols = PROTOCOLS.map((p) => ({ - name: p.name, - logo: p.class.logo, -})); +const allProtocols = () => { + return getProtocols().map((p) => ({ + name: p.name, + logo: p.class.logo, + })); +}; export const filters = { categories: [...Object.values(Category)], types: [...Object.values(PoolType)], - protocols: allProtocols.filter( - (p, index) => allProtocols.findIndex((_p) => _p.name === p.name) === index, + protocols: allProtocols().filter( + (p, index) => + allProtocols().findIndex((_p) => _p.name === p.name) === index, ), }; @@ -167,7 +170,7 @@ export const privatePoolsAtom = atom((get) => { export const allPoolsAtomUnSorted = atom((get) => { const pools: PoolInfo[] = []; - return PROTOCOLS.reduce( + return getProtocols().reduce( (_pools, p) => _pools.concat(get(p.atoms.pools)), pools, ); @@ -184,11 +187,11 @@ export function getPoolInfoFromStrategy( } else if (strat.name.includes('ETH')) { category.push(Category.ETH); } - return { + const item = { pool: { id: strat.id, name: strat.name, - logos: [strat.logo], + logos: [...strat.logos], }, protocol: { name: 'STRKFarm', @@ -199,8 +202,8 @@ export function getPoolInfoFromStrategy( apr: strat.apy, aprSplits: [ { - apr: strat.apy, - title: 'Net Yield', + apr: strat.apySplit.baseApy, + title: 'Strategy APY', description: 'Includes fees & Defi spring rewards', }, ], @@ -222,6 +225,15 @@ export function getPoolInfoFromStrategy( is_promoted: strat.name.includes('Stake'), }, }; + + if (strat.apySplit.rewardsApy > 0) { + item.aprSplits.push({ + apr: strat.apySplit.rewardsApy, + title: 'Rewards APY', + description: 'Additional incentives by STRKFarm', + }); + } + return item; } export const allPoolsAtomWithStrategiesUnSorted = atom((get) => { diff --git a/src/store/strategies.atoms.tsx b/src/store/strategies.atoms.tsx index 2b7bb056..3414d656 100755 --- a/src/store/strategies.atoms.tsx +++ b/src/store/strategies.atoms.tsx @@ -6,18 +6,20 @@ import { } from '@/strategies/IStrategy'; import CONSTANTS from '@/constants'; import Mustache from 'mustache'; -import { getTokenInfoFromName } from '@/utils'; +import { convertToV2TokenInfo, getTokenInfoFromName } from '@/utils'; import { allPoolsAtomUnSorted, privatePoolsAtom } from './protocols'; -import EndurAtoms, { endur } from './endur.store'; -import { getDefaultPoolInfo, PoolInfo } from './pools'; +import { endur } from './endur.store'; +import { PoolInfo } from './pools'; import { AutoTokenStrategy } from '@/strategies/auto_strk.strat'; import { DeltaNeutralMM } from '@/strategies/delta_neutral_mm'; import { DeltaNeutralMM2 } from '@/strategies/delta_neutral_mm_2'; import { DeltaNeutralMMVesuEndur } from '@/strategies/delta_neutral_mm_vesu_endur'; import { Box, Link } from '@chakra-ui/react'; -import { VesuRebalanceStrategies } from '@strkfarm/sdk'; +import { EkuboCLVaultStrategies, VesuRebalanceStrategies } from '@strkfarm/sdk'; import { VesuRebalanceStrategy } from '@/strategies/vesu_rebalance'; import { atomWithQuery } from 'jotai-tanstack-query'; +import { EkuboClStrategy } from '@/strategies/ekubo_cl_vault'; +import { ReactNode } from 'react'; export interface StrategyInfo extends IStrategyProps { name: string; @@ -73,6 +75,7 @@ export function getStrategies() { isAudited: true, isPaused: false, alerts: alerts2, + quoteToken: convertToV2TokenInfo(getTokenInfoFromName('STRK')), }, ); const autoUSDCStrategy = new AutoTokenStrategy( @@ -86,6 +89,7 @@ export function getStrategies() { isAudited: true, isPaused: false, alerts: alerts2, + quoteToken: convertToV2TokenInfo(getTokenInfoFromName('USDC')), }, ); @@ -124,6 +128,7 @@ export function getStrategies() { isAudited: true, alerts, isPaused: true, + quoteToken: convertToV2TokenInfo(getTokenInfoFromName('USDC')), }, ); @@ -140,6 +145,7 @@ export function getStrategies() { alerts, isAudited: true, isPaused: true, + quoteToken: convertToV2TokenInfo(getTokenInfoFromName('ETH')), }, ); const deltaNeutralMMSTRKETH = new DeltaNeutralMM( @@ -155,6 +161,7 @@ export function getStrategies() { isAudited: true, alerts, isPaused: true, + quoteToken: convertToV2TokenInfo(getTokenInfoFromName('STRK')), }, ); @@ -171,6 +178,7 @@ export function getStrategies() { alerts, isAudited: false, isPaused: true, + quoteToken: convertToV2TokenInfo(getTokenInfoFromName('ETH')), }, ); @@ -196,21 +204,36 @@ export function getStrategies() { text: 'Depeg-risk: If xSTRK price on DEXes deviates from expected price, you may lose money or may have to wait for the price to recover.', tab: 'all', }, - { - type: 'info', - text: 'There is an ongoing issue with Braavos, because of which your transactions may fail. We will update here once it is resolved.', - tab: 'all', - }, ], isAudited: false, + quoteToken: convertToV2TokenInfo(getTokenInfoFromName('STRK')), }, ); const vesuRebalanceStrats = VesuRebalanceStrategies.map((v) => { return new VesuRebalanceStrategy( - getTokenInfoFromName(v.depositTokens[0].symbol), + getTokenInfoFromName(v.depositTokens[0]?.symbol || ''), + v.name, + v.description as string, + v, + StrategyLiveStatus.HOT, + { + maxTVL: 0, + isAudited: v.auditUrl ? true : false, + auditUrl: v.auditUrl, + isPaused: false, + alerts: [], + quoteToken: convertToV2TokenInfo( + getTokenInfoFromName(v.depositTokens[0]?.symbol || ''), + ), + }, + ); + }); + + const ekuboCLStrats = EkuboCLVaultStrategies.map((v) => { + return new EkuboClStrategy( v.name, - v.description, + v.description as ReactNode, v, StrategyLiveStatus.HOT, { @@ -221,10 +244,14 @@ export function getStrategies() { alerts: [ { type: 'info', - text: 'There is an ongoing issue with Braavos that may cause your transactions to fail. We will provide an update here once it is resolved.', + text: 'Depending on the current position range and price, your input amounts are automatially adjusted to nearest required amounts', tab: 'all', }, ], + quoteToken: convertToV2TokenInfo( + getTokenInfoFromName(v.depositTokens[1]?.symbol || ''), + ), + isTransactionHistDisabled: true, }, ); }); @@ -240,6 +267,7 @@ export function getStrategies() { // }, // ); + // undo const strategies: IStrategy[] = [ autoStrkStrategy, autoUSDCStrategy, @@ -249,6 +277,7 @@ export function getStrategies() { deltaNeutralMMETHUSDCReverse, deltaNeutralxSTRKSTRK, ...vesuRebalanceStrats, + ...ekuboCLStrats, // xSTRKStrategy, ]; @@ -260,17 +289,8 @@ export const STRATEGIES_INFO = getStrategies(); export const getPrivatePools = (get: any) => { // A placeholder to fetch any external pools/rewards info // that is not necessarily available in the allPools (i.e. not public) - const endurRewardInfo = get(EndurAtoms.rewardInfo); - const endurRewardPoolInfo = getDefaultPoolInfo(); - endurRewardPoolInfo.pool.id = 'endur_strk_reward'; - endurRewardPoolInfo.protocol.name = endur.name; - endurRewardPoolInfo.protocol.link = endur.link; - endurRewardPoolInfo.protocol.logo = endur.logo; - endurRewardPoolInfo.pool.name = 'STRK'; - endurRewardPoolInfo.pool.logos = [getTokenInfoFromName('STRK').logo]; - endurRewardPoolInfo.apr = endurRewardInfo.data || 0; - return [endurRewardPoolInfo]; + return []; }; const strategiesAtomAsync = atomWithQuery((get) => { @@ -325,17 +345,3 @@ export function getLiveStatusNumber(status: StrategyLiveStatus) { } return 5; } - -export function getLiveStatusEnum(status: number) { - if (status == 1) { - return StrategyLiveStatus.HOT; - } - if (status == 2) { - return StrategyLiveStatus.NEW; - } else if (status == 3) { - return StrategyLiveStatus.ACTIVE; - } else if (status == 4) { - return StrategyLiveStatus.COMING_SOON; - } - return StrategyLiveStatus.RETIRED; -} diff --git a/src/store/strkfarm.atoms.ts b/src/store/strkfarm.atoms.ts index 9901e676..79acf8b3 100644 --- a/src/store/strkfarm.atoms.ts +++ b/src/store/strkfarm.atoms.ts @@ -12,14 +12,23 @@ import { PoolType, ProtocolAtoms, } from './pools'; -import { getLiveStatusEnum } from './strategies.atoms'; import { IInvestmentFlow } from '@strkfarm/sdk'; +import { getLiveStatusEnum } from '@/strategies/IStrategy'; export interface STRKFarmStrategyAPIResult { name: string; id: string; apy: number; - depositToken: string[]; + apySplit: { + baseApy: number; + rewardsApy: number; + }; + depositToken: { + name: string; + address: string; + symbol: string; + decimals: number; + }[]; leverage: number; contract: { name: string; address: string }[]; tvlUsd: number; @@ -28,7 +37,7 @@ export interface STRKFarmStrategyAPIResult { value: string; }; riskFactor: number; - logo: string; + logos: string[]; isAudited: boolean; auditUrl?: string; actions: { @@ -63,20 +72,32 @@ export class STRKFarm extends IDapp { const isStable = poolName.includes('USDC') || poolName.includes('USDT'); const categories: Category[] = getCategoriesFromName(poolName, isStable); + const rewardsApy: APRSplit[] = []; + if (rawPool.apySplit.rewardsApy > 0) { + rewardsApy.push({ + apr: rawPool.apySplit.rewardsApy, + title: 'Rewards APY', + description: 'Incentives by STRKFarm', + }); + } + const poolInfo: PoolInfo = { pool: { id: rawPool.id, name: poolName, - logos: [rawPool.logo], + logos: [...rawPool.logos], }, protocol: { name: this.name, link: `/strategy/${rawPool.id}`, logo: this.logo, }, - apr: 0, + apr: + rewardsApy.length && rewardsApy[0].apr != 'Err' + ? rewardsApy[0].apr + : 0, tvl: rawPool.tvlUsd, - aprSplits: [], + aprSplits: [...rewardsApy], category: categories, type: PoolType.Derivatives, lending: { @@ -94,6 +115,7 @@ export class STRKFarm extends IDapp { is_promoted: poolName.includes('Stake'), }, }; + console.log('rawPool', poolName, poolInfo); return poolInfo; }); } @@ -106,10 +128,10 @@ export class STRKFarm extends IDapp { if (data.isSuccess) { const item = aprData.find((doc) => doc.id === p.pool.id); if (item) { - baseAPY = item.apy; + baseAPY = item.apySplit.baseApy; splitApr = { - apr: baseAPY, - title: 'Net APY', + apr: item.apySplit.baseApy, + title: 'Strategy APY', description: 'Includes fees & Defi spring rewards', }; } diff --git a/src/store/transactions.atom.ts b/src/store/transactions.atom.ts index 81a44faf..47558680 100755 --- a/src/store/transactions.atom.ts +++ b/src/store/transactions.atom.ts @@ -4,15 +4,14 @@ import { TOKENS } from '@/constants'; import { capitalize, standariseAddress } from '@/utils'; import MyNumber from '@/utils/MyNumber'; -import { Atom, Getter, Setter, atom } from 'jotai'; +import { Getter, Setter, atom } from 'jotai'; import toast from 'react-hot-toast'; import { RpcProvider, TransactionExecutionStatus } from 'starknet'; import { StrategyInfo, strategiesAtom } from './strategies.atoms'; import { createAtomWithStorage } from './utils.atoms'; -import { atomWithQuery, AtomWithQueryResult } from 'jotai-tanstack-query'; +import { atomWithQuery } from 'jotai-tanstack-query'; import { gql } from '@apollo/client'; import apolloClient from '@/utils/apolloClient'; -import { BalanceResult } from './balance.atoms'; export interface StrategyTxProps { strategyId: string; @@ -81,19 +80,15 @@ async function getTxHistory( export const newTxsAtom = atom([]); -export const TxHistoryAtom = ( - contract: string, - owner: string, - balData: Atom>, -) => +export const TxHistoryAtom = (contract: string, owner: string) => atomWithQuery((get) => ({ // balData just to trigger a refetch - queryKey: ['tx_history', { contract, owner }, get(balData)], + queryKey: ['tx_history', contract, owner, JSON.stringify(get(newTxsAtom))], queryFn: async ({ queryKey }: any): Promise => { const [, { contract, owner }] = queryKey; const res = await getTxHistory(contract, owner); - console.log('TxHistoryAtom res', res); + console.log('TxHistoryAtom res', res, contract, owner, queryKey); // add new txs from local cache const newTxs = get(newTxsAtom); console.log('TxHistoryAtom newTxs', newTxs); @@ -187,11 +182,14 @@ async function waitForTransaction( set(transactionsAtom, txs); let newTxs = get(newTxsAtom); + console.log('waitForTransaction newTxs', newTxs); const txExists = newTxs.find( (t) => t.txHash.toLowerCase() === tx.txHash.toLowerCase(), ); + console.log('waitForTransaction txExists', txExists); if (!txExists) { newTxs = [...newTxs, tx]; + console.log('waitForTransaction newTxs2', newTxs); set(newTxsAtom, newTxs); } } @@ -240,6 +238,8 @@ async function isTxAccepted(txHash: string) { } async function initToast(tx: TransactionInfo, get: Getter, set: Setter) { + console.log('Deposit txInfo 2', tx, tx.info.amount.toEtherStr()); + const msg = StrategyTxPropsToMessage(tx.info, get); await toast.promise( waitForTransaction(tx, get, set), diff --git a/src/store/utils.atoms.ts b/src/store/utils.atoms.ts index 12faccf9..9d9db431 100755 --- a/src/store/utils.atoms.ts +++ b/src/store/utils.atoms.ts @@ -3,6 +3,7 @@ import { atomWithQuery } from 'jotai-tanstack-query'; import { atomWithStorage, createJSONStorage } from 'jotai/utils'; import { addressAtom } from './claims.atoms'; import fetchWithRetry from '@/utils/fetchWithRetry'; +import { SingleTokenInfo } from '@strkfarm/sdk'; export interface BlockInfo { data: { @@ -85,17 +86,10 @@ export const dAppStatsAtom = atomWithQuery((get) => ({ }, })); -interface StrategyWise { +export interface StrategyWise { id: string; usdValue: number; - amount: string; - tokenInfo: { - name: string; - symbol: string; - logo: string; - decimals: number; - displayDecimals: number; - }; + holdings: SingleTokenInfo[]; } export interface UserStats { diff --git a/src/store/vesu.store.ts b/src/store/vesu.store.ts index 3ed6dd79..81855c8a 100644 --- a/src/store/vesu.store.ts +++ b/src/store/vesu.store.ts @@ -57,7 +57,7 @@ function bitIntToNumber(info: { value: string; decimals: number }) { async function getVesuPoolInfo(pool: VesuPool, asset: VesuAsset) { try { const tokenInfo = getTokenInfoFromName(asset.symbol); - const price = await getPrice(tokenInfo); + const price = await getPrice(tokenInfo, 'ekubo'); const tvlInToken = bitIntToNumber(asset.stats.totalSupplied); console.log('Vesu pool', pool, asset, price, tvlInToken); const myPool: PoolInfo = { diff --git a/src/strategies/IStrategy.ts b/src/strategies/IStrategy.ts index c0d2cc57..bee05523 100755 --- a/src/strategies/IStrategy.ts +++ b/src/strategies/IStrategy.ts @@ -1,11 +1,28 @@ import { IDapp } from '@/store/IDapp.store'; -import { BalanceResult, getBalanceAtom } from '@/store/balance.atoms'; +import { + BalanceResult, + getBalanceAtom, + returnEmptyBal, +} from '@/store/balance.atoms'; import { IndexedPoolData } from '@/store/endur.store'; import { LendingSpace } from '@/store/lending.base'; import { Category, PoolInfo } from '@/store/pools'; import { zkLend } from '@/store/zklend.store'; +import { + convertToV2TokenInfo, + convertToV2Web3Number, + getPrice, + MyTokenInfo, + MyWeb3Number, +} from '@/utils'; import MyNumber from '@/utils/MyNumber'; -import { IInvestmentFlow, IStrategyMetadata } from '@strkfarm/sdk'; +import { + IInvestmentFlow, + IStrategyMetadata, + SingleActionAmount, + TokenInfo as TokenInfoV2, + Web3Number, +} from '@strkfarm/sdk'; import { Atom, atom } from 'jotai'; import { AtomWithQueryResult, atomWithQuery } from 'jotai-tanstack-query'; import { ReactNode } from 'react'; @@ -69,10 +86,24 @@ export enum StrategyLiveStatus { HOT = 'Hot & New 🔥', } +export type onStratAmountsChangeFn = ( + change: { + amountInfo: SingleActionAmount; + index: number; + }, + allAmounts: SingleActionAmount[], +) => Promise; + export interface IStrategyActionHook { - tokenInfo: TokenInfo; calls: Call[]; - balanceAtom: Atom>; + amounts: { + tokenInfo: TokenInfoV2; + balanceAtom: Atom>; + }[]; + + // if strategy wants to relate different input amounts, + // config this fn + onAmountsChange?: onStratAmountsChangeFn; } export interface IStrategySettings { @@ -87,16 +118,24 @@ export interface IStrategySettings { isAudited?: boolean; auditUrl?: string; isPaused?: boolean; + quoteToken: TokenInfoV2; // used to show the holdings in this token, + isTransactionHistDisabled?: boolean; } export interface AmountInfo { - amount: MyNumber; + amount: Web3Number; usdValue: number; - tokenInfo: TokenInfo; + tokenInfo: TokenInfoV2; +} + +export interface AmountsInfo { + usdValue: number; + amounts: AmountInfo[]; } export interface DepositActionInputs { amount: MyNumber; + amount2?: MyNumber; // used in dual token deposits address: string; provider: ProviderInterface; isMax: boolean; @@ -116,7 +155,7 @@ export class IStrategyProps { readonly liveStatus: StrategyLiveStatus; readonly id: string; readonly name: string; - readonly description: string; + readonly description: string | ReactNode; readonly settings: IStrategySettings; readonly metadata: IStrategyMetadata; exchanges: IDapp[] = []; @@ -130,13 +169,19 @@ export class IStrategyProps { leverage: number = 0; fee_factor = 0; // in absolute terms, not % status = StrategyStatus.UNINTIALISED; + isSingleTokenDepositView = true; readonly rewardTokens: { logo: string }[]; readonly holdingTokens: (TokenInfo | NFTInfo)[]; balEnabled = atom(false); - readonly balanceAtom: Atom>; - readonly tvlAtom: Atom>; + // summary of balance in some quote token + // as required by the strategy + balanceSummaryAtom: Atom>; + // a strategy can have multiple balance tokens, this is for that + balanceAtoms: Atom>[] = []; + balancesAtom: Atom; + readonly tvlAtom: Atom>; riskFactor: number = 5; risks: string[] = [ @@ -151,19 +196,23 @@ export class IStrategyProps { return `Risk factor: ${this.riskFactor}/5 (${factorLevel} risk)`; } - depositMethods = (inputs: DepositActionInputs): IStrategyActionHook[] => { + depositMethods = async ( + inputs: DepositActionInputs, + ): Promise => { return []; }; - withdrawMethods = (inputs: WithdrawActionInputs): IStrategyActionHook[] => { + withdrawMethods = async ( + inputs: WithdrawActionInputs, + ): Promise => { return []; }; - getTVL = async (): Promise => { + getTVL = async (): Promise => { throw new Error('getTVL: Not implemented'); }; - getUserTVL = async (user: string): Promise => { + getUserTVL = async (user: string): Promise => { throw new Error('getTVL: Not implemented'); }; @@ -178,32 +227,102 @@ export class IStrategyProps { constructor( id: string, name: string, - description: string, + description: string | ReactNode, rewardTokens: { logo: string }[], holdingTokens: (TokenInfo | NFTInfo)[], liveStatus: StrategyLiveStatus, settings: IStrategySettings, metadata: IStrategyMetadata, + balanceAtom?: Atom>, ) { this.id = id; this.name = name; this.description = description; this.rewardTokens = rewardTokens; this.holdingTokens = holdingTokens; - console.log('calling getBalanceAtom', id, holdingTokens[0]); - this.balanceAtom = getBalanceAtom(holdingTokens[0], this.balEnabled); + this.balanceSummaryAtom = + balanceAtom || getBalanceAtom(holdingTokens[0], this.balEnabled); this.liveStatus = liveStatus; this.settings = settings; this.metadata = metadata; this.tvlAtom = atomWithQuery((get) => { return { queryKey: ['tvl', this.id], - queryFn: async ({ queryKey }: any): Promise => { + queryFn: async ({ queryKey }: any): Promise => { return this.getTVL(); }, refetchInterval: 15000, }; }); + this.balancesAtom = this.getBalancesAtom(); + } + + getBalancesAtom() { + return atom((get) => { + return this.balanceAtoms.map((atom) => { + const res = get(atom); + if (!res.data) { + return returnEmptyBal(); + } + return res.data; + }); + }); + } + + async computeSummaryValue( + amounts: SingleActionAmount[], + quoteToken: MyTokenInfo, + source: string, + ): Promise { + const valuesProm = amounts.map((amount) => { + return this.getValueInQuoteToken( + convertToV2Web3Number(amount.amount), + convertToV2TokenInfo(amount.tokenInfo), + quoteToken, + source, + ); + }); + const values = await Promise.all(valuesProm); + const total = values.reduce( + (acc, amount) => { + return acc.plus(amount); + }, + Web3Number.fromWei('0', quoteToken.decimals), + ); + return total; + } + + async getValueInQuoteToken( + amount: MyWeb3Number, + tokenInfo: MyTokenInfo, + quoteToken: MyTokenInfo, + source: string, + ): Promise { + if (tokenInfo.address.eq(quoteToken.address)) { + return amount; + } + + const price = await getPrice( + tokenInfo, + `getValueInQuoteToken::1::${source}`, + ); + const priceQuote = await getPrice( + quoteToken, + `getValueInQuoteToken::2::${source}`, + ); + + const amt = amount.multipliedBy(price).dividedBy(priceQuote); + + // adjust decimals + const decimals = tokenInfo.decimals; + const quoteDecimals = quoteToken.decimals; + if (decimals > quoteDecimals) { + return amt.dividedBy(10 ** (decimals - quoteDecimals)); + } + if (decimals < quoteDecimals) { + return amt.multipliedBy(10 ** (quoteDecimals - decimals)); + } + return amt; } } @@ -214,12 +333,13 @@ export class IStrategy extends IStrategyProps { id: string, tag: string, name: string, - description: string, + description: string | ReactNode, rewardTokens: { logo: string }[], holdingTokens: (TokenInfo | NFTInfo)[], liveStatus = StrategyLiveStatus.ACTIVE, settings: IStrategySettings, metadata: IStrategyMetadata, + balanceAtom?: Atom>, ) { super( id, @@ -230,6 +350,7 @@ export class IStrategy extends IStrategyProps { liveStatus, settings, metadata, + balanceAtom, ); this.tag = tag; } @@ -390,3 +511,17 @@ export class IStrategy extends IStrategyProps { return this.status === StrategyStatus.SOLVING; } } + +export function getLiveStatusEnum(status: number) { + if (status == 1) { + return StrategyLiveStatus.HOT; + } + if (status == 2) { + return StrategyLiveStatus.NEW; + } else if (status == 3) { + return StrategyLiveStatus.ACTIVE; + } else if (status == 4) { + return StrategyLiveStatus.COMING_SOON; + } + return StrategyLiveStatus.RETIRED; +} diff --git a/src/strategies/auto_strk.strat.ts b/src/strategies/auto_strk.strat.ts index 35f3f576..575d091e 100755 --- a/src/strategies/auto_strk.strat.ts +++ b/src/strategies/auto_strk.strat.ts @@ -12,17 +12,16 @@ import { import ERC20Abi from '@/abi/erc20.abi.json'; import AutoStrkAbi from '@/abi/autoStrk.abi.json'; import MasterAbi from '@/abi/master.abi.json'; -import MyNumber from '@/utils/MyNumber'; import { Contract, uint256 } from 'starknet'; -import { atom } from 'jotai'; +import { getBalance, getERC20Balance } from '@/store/balance.atoms'; import { - DUMMY_BAL_ATOM, - getBalance, - getBalanceAtom, - getERC20Balance, - getERC20BalanceAtom, -} from '@/store/balance.atoms'; -import { getPrice, getTokenInfoFromName } from '@/utils'; + buildStrategyActionHook, + convertToV2TokenInfo, + DummyStrategyActionHook, + getPrice, + getTokenInfoFromName, + ZeroAmountsInfo, +} from '@/utils'; import { zkLend } from '@/store/zklend.store'; import { ContractAddr, IStrategyMetadata, Web3Number } from '@strkfarm/sdk'; @@ -72,7 +71,8 @@ export class AutoTokenStrategy extends IStrategy { symbol: tokenInfo.name, decimals: tokenInfo.decimals, address: ContractAddr.from(tokenInfo.token), - logo: '', + logo: tokenInfo.logo, + displayDecimals: tokenInfo.displayDecimals, }, ], protocols: [], @@ -147,45 +147,50 @@ export class AutoTokenStrategy extends IStrategy { getUserTVL = async (user: string) => { if (this.liveStatus == StrategyLiveStatus.COMING_SOON) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); // returns zToken const balanceInfo = await getBalance(this.holdingTokens[0], user); if (!balanceInfo.tokenInfo) { - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); } - const price = await getPrice(this.token); + const price = await getPrice(this.token, 'autostrk'); console.log('getUserTVL autoc', price, balanceInfo.amount.toEtherStr()); + const usdValue = Number(balanceInfo.amount.toEtherStr()) * price; return { - amount: balanceInfo.amount, - usdValue: Number(balanceInfo.amount.toEtherStr()) * price, - tokenInfo: balanceInfo.tokenInfo, + usdValue, + amounts: [ + { + amount: Web3Number.fromWei( + balanceInfo.amount.toString(), + balanceInfo.tokenInfo.decimals, + ), + usdValue: Number(balanceInfo.amount.toEtherStr()) * price, + tokenInfo: convertToV2TokenInfo(balanceInfo.tokenInfo), + }, + ], }; }; getTVL = async () => { - if (!this.isLive()) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + if (!this.isLive()) return ZeroAmountsInfo([this.token]); const zTokenInfo = getTokenInfoFromName(this.lpTokenName); const bal = await getERC20Balance(zTokenInfo, this.strategyAddress); - const price = await getPrice(this.token); + const price = await getPrice(this.token, 'autostrk2'); + const usdValue = Number(bal.amount.toEtherStr()) * price; return { - amount: bal.amount, - usdValue: Number(bal.amount.toEtherStr()) * price, - tokenInfo: this.token, + usdValue, + amounts: [ + { + amount: Web3Number.fromWei( + bal.amount.toString(), + bal.amount.decimals, + ), + usdValue: Number(bal.amount.toEtherStr()) * price, + tokenInfo: convertToV2TokenInfo(this.token), + }, + ], }; }; @@ -195,7 +200,7 @@ export class AutoTokenStrategy extends IStrategy { // this.leverage = this.netYield / normalYield; // } - depositMethods = (inputs: DepositActionInputs) => { + depositMethods = async (inputs: DepositActionInputs) => { const { amount, address, provider } = inputs; const baseTokenInfo: TokenInfo = TOKENS.find( (t) => t.name == this.token.name, @@ -206,16 +211,8 @@ export class AutoTokenStrategy extends IStrategy { if (!address || address == '0x0') { return [ - { - tokenInfo: baseTokenInfo, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - { - tokenInfo: zTokenInfo, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, + DummyStrategyActionHook([baseTokenInfo]), + DummyStrategyActionHook([zTokenInfo]), ]; } @@ -261,33 +258,19 @@ export class AutoTokenStrategy extends IStrategy { const calls2 = [call21, call22]; return [ - { - tokenInfo: baseTokenInfo, - calls: calls1, - balanceAtom: getBalanceAtom(baseTokenInfo, atom(true)), - }, - { - tokenInfo: zTokenInfo, - calls: calls2, - balanceAtom: getBalanceAtom(zTokenInfo, atom(true)), - }, + buildStrategyActionHook(calls1, [baseTokenInfo]), + buildStrategyActionHook(calls2, [zTokenInfo]), ]; }; - withdrawMethods = (inputs: WithdrawActionInputs) => { + withdrawMethods = async (inputs: WithdrawActionInputs) => { const { amount, address, provider } = inputs; const frmToken: TokenInfo = TOKENS.find( (t) => t.token == this.strategyAddress, ) as TokenInfo; if (!address || address == '0x0') { - return [ - { - tokenInfo: frmToken, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - ]; + return [DummyStrategyActionHook([frmToken])]; } // const baseTokenContract = new Contract(ERC20Abi, baseTokenInfo.token, provider); @@ -316,12 +299,6 @@ export class AutoTokenStrategy extends IStrategy { const calls = [call1, call2]; - return [ - { - tokenInfo: frmToken, - calls, - balanceAtom: getERC20BalanceAtom(frmToken), - }, - ]; + return [buildStrategyActionHook(calls, [frmToken])]; }; } diff --git a/src/strategies/auto_xstrk.strat.ts b/src/strategies/auto_xstrk.strat.ts index 1266f459..cbb7995c 100644 --- a/src/strategies/auto_xstrk.strat.ts +++ b/src/strategies/auto_xstrk.strat.ts @@ -1,6 +1,7 @@ import CONSTANTS, { TOKENS, provider } from '@/constants'; import { PoolInfo } from '@/store/pools'; import { + AmountsInfo, DepositActionInputs, IStrategy, IStrategySettings, @@ -14,14 +15,15 @@ import AutoStrkAbi from '@/abi/autoStrk.abi.json'; import MasterAbi from '@/abi/master.abi.json'; import MyNumber from '@/utils/MyNumber'; import { Contract, num, uint256 } from 'starknet'; -import { atom } from 'jotai'; +import { getBalance } from '@/store/balance.atoms'; import { - DUMMY_BAL_ATOM, - getBalance, - getBalanceAtom, - getERC20BalanceAtom, -} from '@/store/balance.atoms'; -import { getPrice, getTokenInfoFromName } from '@/utils'; + buildStrategyActionHook, + convertToV2TokenInfo, + DummyStrategyActionHook, + getPrice, + getTokenInfoFromName, + ZeroAmountsInfo, +} from '@/utils'; import { endur } from '@/store/endur.store'; import { ContractAddr, IStrategyMetadata, Web3Number } from '@strkfarm/sdk'; @@ -69,6 +71,7 @@ export class AutoXSTRKStrategy extends IStrategy { decimals: tokenInfo.decimals, address: ContractAddr.from(tokenInfo.token), logo: '', + displayDecimals: tokenInfo.displayDecimals, }, ], protocols: [], @@ -165,37 +168,33 @@ export class AutoXSTRKStrategy extends IStrategy { getUserTVL = async (user: string) => { if (this.liveStatus == StrategyLiveStatus.COMING_SOON) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); // returns zToken const balanceInfo = await getBalance(this.holdingTokens[0], user); if (!balanceInfo.tokenInfo) { - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); } - const price = await getPrice(this.token); + const price = await getPrice(this.token, 'autoxstrk'); console.log('getUserTVL autoc', price, balanceInfo.amount.toEtherStr()); + const usdValue = Number(balanceInfo.amount.toEtherStr()) * price; return { - amount: balanceInfo.amount, - usdValue: Number(balanceInfo.amount.toEtherStr()) * price, - tokenInfo: balanceInfo.tokenInfo, + usdValue, + amounts: [ + { + amount: Web3Number.fromWei( + balanceInfo.amount.toString(), + balanceInfo.tokenInfo.decimals, + ), + usdValue: Number(balanceInfo.amount.toEtherStr()) * price, + tokenInfo: convertToV2TokenInfo(balanceInfo.tokenInfo), + }, + ], }; }; - getTVL = async () => { - if (!this.isLive()) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + getTVL = async (): Promise => { + if (!this.isLive()) return ZeroAmountsInfo([this.token]); const strategyContract = new Contract( AutoStrkAbi, @@ -216,11 +215,20 @@ export class AutoXSTRKStrategy extends IStrategy { (await strategyContract.call('total_assets', [])).toString(), STRKINfo.decimals, ); - const price = await getPrice(this.token); + const price = await getPrice(this.token, 'autoxstrk2'); + const usdValue = Number(totalAssets.toEtherStr()) * price; return { - amount: totalAssets, - usdValue: Number(totalAssets.toEtherStr()) * price, - tokenInfo: STRKINfo, + usdValue, + amounts: [ + { + amount: Web3Number.fromWei( + totalAssets.toString(), + totalAssets.decimals, + ), + usdValue: Number(totalAssets.toEtherStr()) * price, + tokenInfo: convertToV2TokenInfo(STRKINfo), + }, + ], }; } else if (isxSTRK) { const xSTRKTotalAssets = new MyNumber( @@ -240,17 +248,26 @@ export class AutoXSTRKStrategy extends IStrategy { ).toString(), STRKINfo.decimals, ); - const price = await getPrice(this.token); + const price = await getPrice(this.token, 'autoxstrk3'); + const usdValue = Number(strkAmount.toEtherStr()) * price; return { - amount: strkAmount, - usdValue: Number(strkAmount.toEtherStr()) * price, - tokenInfo: STRKINfo, + usdValue, + amounts: [ + { + amount: Web3Number.fromWei( + strkAmount.toString(), + strkAmount.decimals, + ), + usdValue: Number(strkAmount.toEtherStr()) * price, + tokenInfo: convertToV2TokenInfo(STRKINfo), + }, + ], }; } throw new Error(`getTVL asset not STRK or xSTRK`); }; - depositMethods = (inputs: DepositActionInputs) => { + depositMethods = async (inputs: DepositActionInputs) => { const { amount, address, provider } = inputs; const baseTokenInfo: TokenInfo = TOKENS.find( (t) => t.name == this.token.name, @@ -261,16 +278,8 @@ export class AutoXSTRKStrategy extends IStrategy { if (!address || address == '0x0') { return [ - { - tokenInfo: baseTokenInfo, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - { - tokenInfo: xTokenInfo, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, + DummyStrategyActionHook([baseTokenInfo]), + DummyStrategyActionHook([xTokenInfo]), ]; } @@ -317,33 +326,19 @@ export class AutoXSTRKStrategy extends IStrategy { const calls2 = [call21, call22]; return [ - { - tokenInfo: baseTokenInfo, - calls: calls1, - balanceAtom: getBalanceAtom(baseTokenInfo, atom(true)), - }, - { - tokenInfo: xTokenInfo, - calls: calls2, - balanceAtom: getBalanceAtom(xTokenInfo, atom(true)), - }, + buildStrategyActionHook(calls1, [baseTokenInfo]), + buildStrategyActionHook(calls2, [xTokenInfo]), ]; }; - withdrawMethods = (inputs: WithdrawActionInputs) => { + withdrawMethods = async (inputs: WithdrawActionInputs) => { const { amount, address, provider } = inputs; const frmToken: TokenInfo = TOKENS.find( (t) => t.token == this.strategyAddress, ) as TokenInfo; if (!address || address == '0x0') { - return [ - { - tokenInfo: frmToken, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - ]; + return [DummyStrategyActionHook([frmToken])]; } // const baseTokenContract = new Contract(ERC20Abi, baseTokenInfo.token, provider); @@ -372,12 +367,6 @@ export class AutoXSTRKStrategy extends IStrategy { const calls = [call2]; - return [ - { - tokenInfo: frmToken, - calls, - balanceAtom: getERC20BalanceAtom(frmToken), - }, - ]; + return [buildStrategyActionHook(calls, [frmToken])]; }; } diff --git a/src/strategies/delta_neutral_mm.ts b/src/strategies/delta_neutral_mm.ts index 49ddc9d2..ca143b5a 100755 --- a/src/strategies/delta_neutral_mm.ts +++ b/src/strategies/delta_neutral_mm.ts @@ -1,6 +1,7 @@ import CONSTANTS, { NFTS, TokenName } from '@/constants'; import { PoolInfo } from '@/store/pools'; import { + AmountsInfo, DepositActionInputs, IStrategy, IStrategySettings, @@ -16,9 +17,16 @@ import DeltaNeutralAbi from '@/abi/deltraNeutral.abi.json'; import MyNumber from '@/utils/MyNumber'; import { Call, Contract, uint256 } from 'starknet'; import { nostraLending } from '@/store/nostralending.store'; -import { getPrice, getTokenInfoFromName, standariseAddress } from '@/utils'; import { - DUMMY_BAL_ATOM, + buildStrategyActionHook, + convertToV2TokenInfo, + DummyStrategyActionHook, + getPrice, + getTokenInfoFromName, + standariseAddress, + ZeroAmountsInfo, +} from '@/utils'; +import { getBalance, getBalanceAtom, getERC20Balance, @@ -72,7 +80,8 @@ export class DeltaNeutralMM extends IStrategy { symbol: tokenInfo.name, decimals: tokenInfo.decimals, address: ContractAddr.from(tokenInfo.token), - logo: '', + logo: tokenInfo.logo, + displayDecimals: tokenInfo.displayDecimals, }, ], protocols: [], @@ -289,18 +298,12 @@ export class DeltaNeutralMM extends IStrategy { ]; } - depositMethods = (inputs: DepositActionInputs) => { + depositMethods = async (inputs: DepositActionInputs) => { const { amount, address, provider } = inputs; const baseTokenInfo = this.token; if (!address || address == '0x0') { - return [ - { - tokenInfo: baseTokenInfo, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - ]; + return [DummyStrategyActionHook([baseTokenInfo])]; } const baseTokenContract = new Contract( @@ -326,46 +329,36 @@ export class DeltaNeutralMM extends IStrategy { const calls1 = [call11, call12]; - return [ - { - tokenInfo: baseTokenInfo, - calls: calls1, - balanceAtom: getBalanceAtom(baseTokenInfo, atom(true)), - }, - ]; + return [buildStrategyActionHook(calls1, [baseTokenInfo])]; }; - getUserTVL = async (user: string) => { + getUserTVL = async (user: string): Promise => { if (this.liveStatus == StrategyLiveStatus.COMING_SOON) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); const balanceInfo = await getBalance(this.holdingTokens[0], user); if (!balanceInfo.tokenInfo) { - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); } - const price = await getPrice(balanceInfo.tokenInfo); + const price = await getPrice(balanceInfo.tokenInfo, 'dnmm1'); console.log('getUserTVL dnmm', price, balanceInfo.amount.toEtherStr()); + const usdValue = Number(balanceInfo.amount.toEtherStr()) * price; return { - amount: balanceInfo.amount, - usdValue: Number(balanceInfo.amount.toEtherStr()) * price, - tokenInfo: balanceInfo.tokenInfo, + usdValue, + amounts: [ + { + amount: Web3Number.fromWei( + balanceInfo.amount.toString(), + balanceInfo.tokenInfo.decimals, + ), + usdValue: Number(balanceInfo.amount.toEtherStr()) * price, + tokenInfo: convertToV2TokenInfo(balanceInfo.tokenInfo), + }, + ], }; }; - getTVL = async () => { - if (!this.isLive()) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + getTVL = async (): Promise => { + if (!this.isLive()) return ZeroAmountsInfo([this.token]); try { const mainTokenName = this.token.name; @@ -377,23 +370,25 @@ export class DeltaNeutralMM extends IStrategy { const discountFactor = this.stepAmountFactors[4]; const amount = bal.amount.operate('div', 1 + discountFactor); console.log('getTVL1', amount.toString()); - const price = await getPrice(this.token); + const price = await getPrice(this.token, 'dnmm11'); + const usdValue = Number(amount.toEtherStr()) * price; return { - amount, - usdValue: Number(amount.toEtherStr()) * price, - tokenInfo: this.token, + usdValue, + amounts: [ + { + usdValue, + amount: Web3Number.fromWei(amount.toString(), amount.decimals), + tokenInfo: convertToV2TokenInfo(this.token), + }, + ], }; } catch (error) { console.error('Error fetching TVL:', error); - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); } }; - withdrawMethods = (inputs: WithdrawActionInputs) => { + withdrawMethods = async (inputs: WithdrawActionInputs) => { const { amount, address, provider, isMax } = inputs; const mainToken = { ...this.token }; @@ -404,13 +399,7 @@ export class DeltaNeutralMM extends IStrategy { ); if (!address || address == '0x0') { - return [ - { - tokenInfo: mainToken, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - ]; + return [DummyStrategyActionHook([mainToken])]; } const strategyContract = new Contract( @@ -439,11 +428,11 @@ export class DeltaNeutralMM extends IStrategy { throw new Error('DeltaMM: NFT not found'); } return [ - { - tokenInfo: mainToken, + buildStrategyActionHook( calls, - balanceAtom: getBalanceAtom(nftInfo, atom(true)), - }, + [mainToken], + [getBalanceAtom(nftInfo, atom(true))], + ), ]; }; } diff --git a/src/strategies/delta_neutral_mm_2.ts b/src/strategies/delta_neutral_mm_2.ts index 4f1bcc1b..d6915afa 100644 --- a/src/strategies/delta_neutral_mm_2.ts +++ b/src/strategies/delta_neutral_mm_2.ts @@ -1,11 +1,21 @@ import { TokenName } from '@/constants'; import { DeltaNeutralMM } from './delta_neutral_mm'; -import { IStrategySettings, StrategyLiveStatus, TokenInfo } from './IStrategy'; +import { + AmountsInfo, + IStrategySettings, + StrategyLiveStatus, + TokenInfo, +} from './IStrategy'; import { nostraLending } from '@/store/nostralending.store'; import { zkLend } from '@/store/zklend.store'; -import MyNumber from '@/utils/MyNumber'; -import { getPrice, getTokenInfoFromName } from '@/utils'; +import { + convertToV2TokenInfo, + getPrice, + getTokenInfoFromName, + ZeroAmountsInfo, +} from '@/utils'; import { getERC20Balance } from '@/store/balance.atoms'; +import { Web3Number } from '@strkfarm/sdk'; export class DeltaNeutralMM2 extends DeltaNeutralMM { constructor( @@ -32,13 +42,8 @@ export class DeltaNeutralMM2 extends DeltaNeutralMM { ); } - getTVL = async () => { - if (!this.isLive()) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + getTVL = async (): Promise => { + if (!this.isLive()) return ZeroAmountsInfo([this.token]); try { const mainTokenName = this.token.name; @@ -50,19 +55,21 @@ export class DeltaNeutralMM2 extends DeltaNeutralMM { const discountFactor = this.stepAmountFactors[4]; const amount = bal.amount.operate('div', 1 + discountFactor); console.log('getTVL1', amount.toString()); - const price = await getPrice(this.token); + const price = await getPrice(this.token, 'dnmm2'); + const usdValue = Number(amount.toEtherStr()) * price; return { - amount, - usdValue: Number(amount.toEtherStr()) * price, - tokenInfo: this.token, + usdValue, + amounts: [ + { + amount: Web3Number.fromWei(amount.toString(), amount.decimals), + usdValue, + tokenInfo: convertToV2TokenInfo(this.token), + }, + ], }; } catch (error) { console.error('Error fetching TVL:', error); - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); } }; } diff --git a/src/strategies/delta_neutral_mm_vesu_endur.ts b/src/strategies/delta_neutral_mm_vesu_endur.ts index c90cfbb6..fe981248 100644 --- a/src/strategies/delta_neutral_mm_vesu_endur.ts +++ b/src/strategies/delta_neutral_mm_vesu_endur.ts @@ -1,6 +1,7 @@ import { provider, TokenName } from '@/constants'; import { DeltaNeutralMM } from './delta_neutral_mm'; import { + AmountsInfo, IStrategySettings, Step, StrategyAction, @@ -8,12 +9,18 @@ import { TokenInfo, } from './IStrategy'; import MyNumber from '@/utils/MyNumber'; -import { getEndpoint, getTokenInfoFromName } from '@/utils'; +import { + convertToV2TokenInfo, + getEndpoint, + getTokenInfoFromName, + ZeroAmountsInfo, +} from '@/utils'; import { vesu } from '@/store/vesu.store'; import { endur } from '@/store/endur.store'; import { PoolInfo } from '@/store/pools'; import { Contract } from 'starknet'; import { fetchQuotes, QuoteRequest } from '@avnu/avnu-sdk'; +import { Web3Number } from '@strkfarm/sdk'; export class DeltaNeutralMMVesuEndur extends DeltaNeutralMM { vesuPoolName = 'Re7 xSTRK'; @@ -197,13 +204,8 @@ export class DeltaNeutralMMVesuEndur extends DeltaNeutralMM { return [...actions, strategyAction]; } - getTVL = async () => { - if (!this.isLive()) - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + getTVL = async (): Promise => { + if (!this.isLive()) return ZeroAmountsInfo([this.token]); try { const resp = await fetch( @@ -232,25 +234,27 @@ export class DeltaNeutralMMVesuEndur extends DeltaNeutralMM { const xSTRKPrice = await this.getXSTRKPrice(); const collateralInSTRK = Number(collateralXSTRK.toEtherToFixedDecimals(6)) * xSTRKPrice; + const usdValue = + Number(collateralUSDValue.toEtherStr()) - + Number(debtUSDValue.toEtherStr()); return { - amount: MyNumber.fromEther( - ( - collateralInSTRK - Number(debtSTRK.toEtherToFixedDecimals(6)) - ).toFixed(6), - data.data[0].collateral.decimals, - ), - usdValue: - Number(collateralUSDValue.toEtherStr()) - - Number(debtUSDValue.toEtherStr()), - tokenInfo: this.token, + usdValue, + amounts: [ + { + amount: new Web3Number( + ( + collateralInSTRK - Number(debtSTRK.toEtherToFixedDecimals(6)) + ).toFixed(6), + data.data[0].collateral.decimals, + ), + usdValue, + tokenInfo: convertToV2TokenInfo(this.token), + }, + ], }; } catch (error) { console.error('Error fetching TVL:', error); - return { - amount: MyNumber.fromEther('0', this.token.decimals), - usdValue: 0, - tokenInfo: this.token, - }; + return ZeroAmountsInfo([this.token]); } }; diff --git a/src/strategies/ekubo_cl_vault.ts b/src/strategies/ekubo_cl_vault.ts new file mode 100644 index 00000000..3d5e3339 --- /dev/null +++ b/src/strategies/ekubo_cl_vault.ts @@ -0,0 +1,372 @@ +import CONSTANTS from '@/constants'; +import { + AmountsInfo, + DepositActionInputs, + IStrategy, + IStrategyActionHook, + IStrategySettings, + onStratAmountsChangeFn, + StrategyLiveStatus, + StrategyStatus, + TokenInfo, + WithdrawActionInputs, +} from './IStrategy'; +import { + ContractAddr, + getMainnetConfig, + Global, + IStrategyMetadata, + PricerFromApi, + Web3Number, + CLVaultStrategySettings, + EkuboCLVault, + SingleActionAmount, +} from '@strkfarm/sdk'; +import MyNumber from '@/utils/MyNumber'; +import { PoolInfo } from '@/store/pools'; +import { + BalanceResult, + getBalanceAtom, + returnEmptyBal, +} from '@/store/balance.atoms'; +import { atom } from 'jotai'; +import { + buildStrategyActionHook, + convertToMyNumber, + convertToV1TokenInfo, + convertToV2TokenInfo, + convertToV2Web3Number, + getTokenInfoFromName, +} from '@/utils'; +import { atomWithQuery } from 'jotai-tanstack-query'; +import { addressAtom } from '@/store/claims.atoms'; +import { ReactNode } from 'react'; + +export class EkuboClStrategy extends IStrategy { + clVault: EkuboCLVault; + isSingleTokenDepositView: boolean = false; + constructor( + name: string, + description: string | ReactNode, + strategy: IStrategyMetadata, + liveStatus: StrategyLiveStatus, + settings: IStrategySettings, + ) { + const rewardTokens = [{ logo: CONSTANTS.LOGOS.STRK }]; + const holdingTokens: TokenInfo[] = [ + { + name: strategy.name, + token: strategy.address.address, + address: strategy.address.address, + isERC4626: false, + decimals: 18, + displayDecimals: 2, + logo: CONSTANTS.LOGOS.STRK, // todo make it to dual token + minAmount: MyNumber.fromEther('0.01', 18), + maxAmount: MyNumber.fromEther('10000000000000', 18), + stepAmount: MyNumber.fromEther('0.01', 18), + }, + ]; + + const config = getMainnetConfig( + process.env.NEXT_PUBLIC_RPC_URL!, + 'pending', + ); + const tokens = Global.getDefaultTokens(); + const pricer = new PricerFromApi(config, tokens); + const clVault = new EkuboCLVault(config, pricer, strategy); + + const token0Info = getTokenInfoFromName(strategy.depositTokens[0].symbol); + const token1Info = getTokenInfoFromName(strategy.depositTokens[1].symbol); + super( + `ekubo_cl_${strategy.name.split(' ')[1].toLowerCase().replaceAll('/', '')}`, + name, + name, + description, + rewardTokens, + holdingTokens, + liveStatus, + settings, + clVault.metadata, + ); + + this.clVault = clVault; + this.riskFactor = strategy.risk.netRisk; + + const risks = [...this.risks]; + this.risks = [ + this.getSafetyFactorLine(), + 'Your original investment is safe. If you deposit 100 tokens, you will always get at least 100 tokens back, unless due to below reasons.', + 'The deposits are supplied on Ekubo, a concentrated liquidity AMM, which can experience impermanent loss. Though, given this a pool of highly corelated tokens, the chances of a loss are very low.', + // `The strategy tries to keep the position around ${this.metadata.additionalInfo.newBounds.lower} to ${this.metadata.additionalInfo.newBounds.upper} range in tick space to provide maximum utility of the capital, but this can lead to relatively high impermanent loss sometimes`, + 'Sometimes, the strategy may not earn yield for a short period. This happens when its temporarily out of range. During this time, we pause and observe before making any changes. Rebalancing too often could lead to unnecessary fees from withdrawals and swaps on Ekubo, so we try to avoid that unless its really needed.', + ...risks, + ]; + + this.balanceAtoms = [ + this.getEkuboStratBalanceAtom(token0Info), + this.getEkuboStratBalanceAtom(token1Info), + ]; + this.balanceSummaryAtom = this.getSummaryBalanceAtom(); + this.balancesAtom = this.getBalancesAtom(); + } + + getTVL = async (): Promise => { + console.log('getTVL [1]'); + const res = await this.clVault.getTVL(); + return { + usdValue: res.usdValue, + amounts: [res.token0, res.token1], + }; + }; + + getUserTVL = async (user: string): Promise => { + console.log('getUserTVL [1]', user); + const res = await this.clVault.getUserTVL(ContractAddr.from(user)); + return { + usdValue: res.usdValue, + amounts: [res.token0, res.token1], + }; + }; + + async onAmountsChange( + ...args: Parameters + ): Promise { + console.log('onAmountsChange [1]'); + const changes = args[0]; + const allAmounts = args[1]; + console.log('onAmountsChange [1.1]', changes, allAmounts); + const isToken0Change = changes.index == 0; + const input = { + token0: isToken0Change + ? { + ...changes.amountInfo, + } + : { + amount: Web3Number.fromWei('0', allAmounts[0].tokenInfo.decimals), + tokenInfo: allAmounts[0].tokenInfo, + }, + token1: isToken0Change + ? { + amount: Web3Number.fromWei('0', allAmounts[1].tokenInfo.decimals), + tokenInfo: allAmounts[1].tokenInfo, + } + : { ...changes.amountInfo }, + }; + console.log( + 'onAmountsChange [1.2]', + [input.token0, input.token1].map((item) => ({ + amount: item.amount.toFixed(6), + tokenInfo: item.tokenInfo, + })), + ); + const output = await this.clVault.matchInputAmounts(input); + console.log( + 'onAmountsChange [1.3]', + [output.token0, output.token1].map((item) => ({ + amount: item.amount.toFixed(6), + tokenInfo: item.tokenInfo, + })), + ); + return [output.token0, output.token1]; + } + + depositMethods = async (inputs: DepositActionInputs) => { + const { amount, address, provider, amount2 } = inputs; + const token0Info = getTokenInfoFromName( + this.metadata.depositTokens[0].symbol, + ); + const token1Info = getTokenInfoFromName( + this.metadata.depositTokens[1].symbol, + ); + console.log( + 'Deposit calls [1]', + amount.toString(), + amount2?.toString(), + address, + ); + if (!address || address == '0x0' || !amount2) { + return [ + { + ...buildStrategyActionHook([], [token0Info, token1Info]), + onAmountsChange: this.onAmountsChange.bind(this), + }, + ]; + } + console.log('Deposit calls [2]', amount, amount2); + const amt = Web3Number.fromWei(amount.toString(), token0Info.decimals); + const amt2 = Web3Number.fromWei(amount2.toString(), token1Info.decimals); + const calls = await this.clVault.depositCall( + { + token0: { + tokenInfo: this.clVault.metadata.depositTokens[0], + amount: amt, + }, + token1: { + tokenInfo: this.clVault.metadata.depositTokens[1], + amount: amt2, + }, + }, + ContractAddr.from(address), + ); + console.log('Deposit calls [3]', calls); + return [ + { + ...buildStrategyActionHook(calls, [token0Info, token1Info]), + onAmountsChange: this.onAmountsChange.bind(this), + }, + ]; + }; + + withdrawMethods = async ( + inputs: WithdrawActionInputs, + ): Promise => { + const { amount, address, provider, amount2 } = inputs; + const output = { + calls: [], + amounts: [ + { + tokenInfo: this.metadata.depositTokens[0], + balanceAtom: this.balanceAtoms[0], + }, + { + tokenInfo: this.metadata.depositTokens[1], + balanceAtom: this.balanceAtoms[1], + }, + ], + onAmountsChange: this.onAmountsChange.bind(this), + }; + if (!address || address == '0x0' || !amount2) { + return [output]; + } + + console.log('Withdraw calls [1]'); + const amt = Web3Number.fromWei(amount.toString(), amount.decimals); + const amt2 = Web3Number.fromWei(amount2.toString(), amount.decimals); + const calls = await this.clVault.withdrawCall( + { + token0: { + tokenInfo: this.clVault.metadata.depositTokens[0], + amount: amt, + }, + token1: { + tokenInfo: this.clVault.metadata.depositTokens[1], + amount: amt2, + }, + }, + ContractAddr.from(address), + ContractAddr.from(address), + ); + + return [ + { + ...output, + calls, + }, + ]; + }; + + async solve(pools: PoolInfo[], amount: string) { + const yieldInfo = await this.clVault.netAPY('pending', 16000); + this.netYield = yieldInfo; + this.leverage = 1; + + this.investmentFlows = await this.clVault.getInvestmentFlows(); + + this.postSolve(); + + this.status = StrategyStatus.SOLVED; + } + + getEkuboStratBalanceAtom = (underlyingToken: TokenInfo) => { + const holdingBalAtom = getBalanceAtom(this.holdingTokens[0], atom(true)); + return atomWithQuery((get) => { + return { + queryKey: [ + 'getEkuboStratBalanceAtom', + this.holdingTokens[0].address, + underlyingToken.token, + get(addressAtom), + JSON.stringify(get(holdingBalAtom).data), + ], + queryFn: async ({ queryKey }: any): Promise => { + try { + console.log('getEkuboStratBalanceAtom [-1]', queryKey); + const bal = get(holdingBalAtom); + if (!bal.data) { + return returnEmptyBal(); + } + console.log('getEkuboStratBalanceAtom [0]', bal.data); + const userTVL = await this.getUserTVL(get(addressAtom) || ''); + const amountInfo = userTVL.amounts.find((amountInfo) => + amountInfo.tokenInfo.address.eqString(underlyingToken.token), + ); + if (!amountInfo) { + return returnEmptyBal(); + } + console.log('getEkuboStratBalanceAtom [1]', amountInfo); + return { + amount: MyNumber.fromEther( + amountInfo.amount.toString(), + amountInfo.tokenInfo.decimals, + ), + tokenInfo: underlyingToken, + }; + } catch (e) { + console.error('getEkuboStratBalanceAtom err', e); + return returnEmptyBal(); + } + }, + refetchInterval: 10000, + }; + }); + }; + + getSummaryBalanceAtom = () => { + return atomWithQuery((get) => { + return { + queryKey: [ + 'getEkuboStratBalanceAtom', + ...[get(this.balanceAtoms[0]), get(this.balanceAtoms[1])].map( + (b) => `${b.data?.amount.toString()}-${b.data?.tokenInfo?.address}`, + ), + get(addressAtom), + ], + queryFn: async ({ queryKey }: any): Promise => { + const bal1 = get(this.balanceAtoms[0]); + const bal2 = get(this.balanceAtoms[1]); + console.log('getSummaryBalanceAtom', bal1.data, bal2.data); + if ( + !bal1.data || + !bal2.data || + !bal1.data.tokenInfo || + !bal2.data.tokenInfo + ) { + return returnEmptyBal(); + } + console.log('getSummaryBalanceAtom [0]', bal1.data, bal2.data); + const bal1Data = bal1.data; + const bal2Data = bal2.data; + const amounts: SingleActionAmount[] = [bal1Data, bal2Data].map( + (b) => ({ + amount: convertToV2Web3Number(b.amount), + tokenInfo: convertToV2TokenInfo(b.tokenInfo!), + }), + ); + console.log('getSummaryBalanceAtom [1]', amounts); + const amountWeb3Number = await this.computeSummaryValue( + amounts, + this.settings.quoteToken, + 'ekubo::summary', + ); + console.log('getSummaryBalanceAtom [2]', amountWeb3Number); + return { + amount: convertToMyNumber(amountWeb3Number), + tokenInfo: convertToV1TokenInfo(this.settings.quoteToken), + }; + }, + refetchInterval: 10000, + }; + }); + }; +} diff --git a/src/strategies/vesu_rebalance.ts b/src/strategies/vesu_rebalance.ts index c0207577..87a0be9e 100644 --- a/src/strategies/vesu_rebalance.ts +++ b/src/strategies/vesu_rebalance.ts @@ -1,8 +1,9 @@ import CONSTANTS from '@/constants'; import { - AmountInfo, + AmountsInfo, DepositActionInputs, IStrategy, + IStrategyActionHook, IStrategySettings, StrategyLiveStatus, StrategyStatus, @@ -19,9 +20,13 @@ import { Web3Number, VesuRebalanceSettings, } from '@strkfarm/sdk'; -import MyNumber from '@/utils/MyNumber'; import { PoolInfo } from '@/store/pools'; -import { DUMMY_BAL_ATOM, getBalanceAtom } from '@/store/balance.atoms'; +import { + buildStrategyActionHook, + DummyStrategyActionHook, + ZeroAmountsInfo, +} from '@/utils'; +import { getBalanceAtom } from '@/store/balance.atoms'; import { atom } from 'jotai'; export class VesuRebalanceStrategy extends IStrategy { @@ -79,38 +84,35 @@ export class VesuRebalanceStrategy extends IStrategy { ]; } - getTVL = async (): Promise => { + getTVL = async (): Promise => { const res = await this.vesuRebalance.getTVL(); return { - amount: new MyNumber(res.amount.toWei(), res.amount.decimals), usdValue: res.usdValue, - tokenInfo: this.asset, + amounts: [res], }; }; - getUserTVL = async (user: string): Promise => { - const res = await this.vesuRebalance.getUserTVL(ContractAddr.from(user)); - return { - amount: new MyNumber(res.amount.toWei(), res.amount.decimals), - usdValue: res.usdValue, - tokenInfo: this.asset, - }; + getUserTVL = async (user: string): Promise => { + try { + const res = await this.vesuRebalance.getUserTVL(ContractAddr.from(user)); + return { + usdValue: res.usdValue, + amounts: [res], + }; + } catch (e) { + console.error('Error getting user TVL:', e); + return ZeroAmountsInfo([this.asset]); + } }; - depositMethods = (inputs: DepositActionInputs) => { + depositMethods = async (inputs: DepositActionInputs) => { const { amount, address, provider } = inputs; if (!address || address == '0x0') { - return [ - { - tokenInfo: this.asset, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - ]; + return [DummyStrategyActionHook([this.asset])]; } const amt = Web3Number.fromWei(amount.toString(), amount.decimals); - const calls = this.vesuRebalance.depositCall( + const calls = await this.vesuRebalance.depositCall( { tokenInfo: this.vesuRebalance.asset(), amount: amt, @@ -118,29 +120,19 @@ export class VesuRebalanceStrategy extends IStrategy { ContractAddr.from(address), ); - return [ - { - tokenInfo: this.asset, - calls, - balanceAtom: getBalanceAtom(this.asset, atom(true)), - }, - ]; + return [buildStrategyActionHook(calls, [this.asset])]; }; - withdrawMethods = (inputs: WithdrawActionInputs) => { + withdrawMethods = async ( + inputs: WithdrawActionInputs, + ): Promise => { const { amount, address, provider } = inputs; if (!address || address == '0x0') { - return [ - { - tokenInfo: this.holdingTokens[0] as TokenInfo, - calls: [], - balanceAtom: DUMMY_BAL_ATOM, - }, - ]; + return [DummyStrategyActionHook([this.holdingTokens[0] as TokenInfo])]; } const amt = Web3Number.fromWei(amount.toString(), amount.decimals); - const calls = this.vesuRebalance.withdrawCall( + const calls = await this.vesuRebalance.withdrawCall( { tokenInfo: this.vesuRebalance.asset(), amount: amt, @@ -151,9 +143,13 @@ export class VesuRebalanceStrategy extends IStrategy { return [ { - tokenInfo: this.holdingTokens[0] as TokenInfo, calls, - balanceAtom: getBalanceAtom(this.holdingTokens[0], atom(true)), + amounts: [ + { + balanceAtom: getBalanceAtom(this.holdingTokens[0], atom(true)), + tokenInfo: this.vesuRebalance.asset(), + }, + ], }, ]; }; diff --git a/src/utils.ts b/src/utils.ts index 02d9c1da..662fcd71 100755 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,27 @@ import { MenuItemProps, MenuListProps } from '@chakra-ui/react'; -import { num } from 'starknet'; +import { Call, num } from 'starknet'; import { TOKENS } from './constants'; import toast from 'react-hot-toast'; -import { TokenInfo } from './strategies/IStrategy'; +import { + AmountsInfo, + IStrategyActionHook, + TokenInfo, +} from './strategies/IStrategy'; +import { + ContractAddr, + TokenInfo as TokenInfoV2, + Web3Number, +} from '@strkfarm/sdk'; import fetchWithRetry from './utils/fetchWithRetry'; +import { + BalanceResult, + DUMMY_BAL_ATOM, + getBalanceAtom, +} from './store/balance.atoms'; +import { Atom, atom } from 'jotai'; +import { AtomWithQueryResult } from 'jotai-tanstack-query'; +import assert from 'assert'; +import MyNumber from './utils/MyNumber'; export function getUniqueStrings(arr: Array) { const _arr: string[] = []; @@ -84,7 +102,7 @@ export function getTokenInfoFromName(tokenName: string) { (t) => t.name.toLowerCase() === tokenName.toLowerCase(), ); if (!info) { - throw new Error('Token not found'); + throw new Error(`Token not found: ${tokenName}`); } return info; } @@ -150,7 +168,8 @@ export function copyReferralLink(refCode: string) { }); } -export async function getPrice(tokenInfo: TokenInfo) { +export async function getPrice(tokenInfo: MyMultiTokenInfo, source?: string) { + console.log(`getPrice::${source}`, tokenInfo.name); try { return await getPriceFromMyAPI(tokenInfo); } catch (e) { @@ -158,7 +177,7 @@ export async function getPrice(tokenInfo: TokenInfo) { } console.log('getPrice coinbase', tokenInfo.name); const priceInfo = await fetchWithRetry( - `https://api.coinbase.com/v2/prices/${tokenInfo.name}-USDT/spot`, + `https://api.coinbase.com/v2/prices/${convertToV2TokenInfo(tokenInfo).symbol}-USDT/spot`, {}, `Error fetching price for ${tokenInfo.name}`, ); @@ -191,11 +210,11 @@ export function getHosturl() { } } -export async function getPriceFromMyAPI(tokenInfo: TokenInfo) { +export async function getPriceFromMyAPI(tokenInfo: MyMultiTokenInfo) { console.log('getPrice from redis', tokenInfo.name); const endpoint = getEndpoint(); - const url = `${endpoint}/api/price/${tokenInfo.name}`; + const url = `${endpoint}/api/price/${convertToV2TokenInfo(tokenInfo).symbol}`; console.log('getPrice url', url); const priceInfoRes = await fetch(url); const priceInfo = await priceInfoRes.json(); @@ -203,6 +222,8 @@ export async function getPriceFromMyAPI(tokenInfo: TokenInfo) { const priceTime = new Date(priceInfo.timestamp); if (now.getTime() - priceTime.getTime() > 900000) { // 15 mins + console.log('getPrice priceInfo', priceInfo); + console.log('getPrice priceTime', now, tokenInfo.name); throw new Error('Price is stale'); } const price = Number(priceInfo.price); @@ -231,3 +252,129 @@ export function timeAgo(date: Date): string { year: '2-digit', }); } + +export type MyMultiTokenInfo = TokenInfo | TokenInfoV2; +export type MyTokenInfo = TokenInfoV2; + +export function convertToV2TokenInfo(token: MyMultiTokenInfo): MyTokenInfo { + const _token: any = token; + const isTokenInfoV1 = _token.token !== undefined; + if (!isTokenInfoV1) { + return token as TokenInfoV2; + } + return { + name: token.name, + symbol: token.name, + address: ContractAddr.from((token as TokenInfo).token), + decimals: token.decimals, + logo: token.logo, + displayDecimals: token.displayDecimals, + }; +} + +export function convertToV1TokenInfo( + token: MyMultiTokenInfo, + isERC4626 = false, +): TokenInfo { + const _token: any = token; + const isTokenInfoV1 = _token.token !== undefined; + if (isTokenInfoV1) { + return token as TokenInfo; + } + + const v2Token = token as TokenInfoV2; + return { + name: v2Token.symbol, + address: v2Token.address.address, + decimals: v2Token.decimals, + logo: v2Token.logo, + displayDecimals: v2Token.displayDecimals, + token: v2Token.address.address, + isERC4626, + minAmount: MyNumber.fromEther('0', v2Token.decimals), + maxAmount: MyNumber.fromEther('0', v2Token.decimals), + stepAmount: new MyNumber('1', v2Token.decimals), + }; +} + +export type MyMultiWeb3Number = Web3Number | MyNumber; +export type MyWeb3Number = Web3Number; +export function convertToV2Web3Number(amount: MyMultiWeb3Number): MyWeb3Number { + console.log( + 'convertToV2Web3Number', + typeof amount, + amount instanceof Web3Number, + ); + if (amount instanceof Web3Number) { + return amount; + } + return Web3Number.fromWei(amount.toString(), amount.decimals); +} + +export function convertToMyNumber(amount: MyWeb3Number): MyNumber { + return new MyNumber(amount.toWei(), amount.decimals); +} + +export function ZeroAmountsInfo(tokens: MyMultiTokenInfo[]): AmountsInfo { + const res: AmountsInfo = { + usdValue: 0, + amounts: [], + }; + for (let i = 0; i < tokens.length; i++) { + const token: any = tokens[i]; + const isTokenInfoV1 = token.token !== undefined; + res.amounts.push({ + amount: Web3Number.fromWei('0', tokens[i].decimals), + tokenInfo: isTokenInfoV1 + ? convertToV2TokenInfo(TOKENS[i]) + : (tokens[i] as TokenInfoV2), + usdValue: 0, + }); + } + return res; +} + +export function DummyStrategyActionHook( + tokens: MyMultiTokenInfo[], +): IStrategyActionHook { + return buildStrategyActionHook([], tokens, null, true); +} + +export function buildStrategyActionHook( + calls: Call[], + tokens: MyMultiTokenInfo[], + balanceAtoms: Atom>[] | null = null, + isDummy: boolean = false, +): IStrategyActionHook { + if (balanceAtoms) { + assert( + balanceAtoms.length === tokens.length, + 'balanceAtoms length mismatch', + ); + } + return { + calls, + amounts: tokens.map((token, index) => { + const _token: any = token; + const isTokenInfoV1 = _token.token !== undefined; + const tokenInfoV1 = getTokenInfoFromName(_token.symbol || _token.name); + if (!tokenInfoV1) { + if (!tokenInfoV1) { + throw new Error('Token not found'); + } + } + let balanceAtom = balanceAtoms ? balanceAtoms[index] : null; + if (!balanceAtom) { + balanceAtom = isDummy + ? DUMMY_BAL_ATOM + : getBalanceAtom(tokenInfoV1, atom(true)); + } + return { + balanceAtom, + tokenInfo: isTokenInfoV1 + ? convertToV2TokenInfo(_token) + : (token as TokenInfoV2), + }; + }), + }; +} diff --git a/src/utils/MyNumber.ts b/src/utils/MyNumber.ts index 85da1ae7..f31199aa 100755 --- a/src/utils/MyNumber.ts +++ b/src/utils/MyNumber.ts @@ -2,6 +2,10 @@ import BigNumber from 'bignumber.js'; import { ethers } from 'ethers'; const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); +BigNumber.config({ + DECIMAL_PLACES: 18, +}); + export default class MyNumber { bigNumber: BigNumber; decimals: number; diff --git a/vercel.json b/vercel.json index e90f4767..e597d0f8 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,14 @@ { "path": "/api/raffle/luckyWinner?winnersCount=3", "schedule": "0 8 * * *" + }, + { + "path": "/api/strategies?no_cache=true", + "schedule": "*/9 * * * *" + }, + { + "path": "/api/stats?no_cache=true", + "schedule": "*/9 * * * *" } ] } diff --git a/yarn.lock b/yarn.lock index 7cce3eb7..cd5018b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1922,10 +1922,10 @@ dependencies: "@prisma/debug" "5.18.0" -"@puppeteer/browsers@2.8.0": - version "2.8.0" - resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.8.0.tgz#9d592933cbefc66c37823770844b8cbac52607dd" - integrity sha512-yTwt2KWRmCQAfhvbCRjebaSX8pV1//I0Y3g+A7f/eS7gf0l4eRJoUCvcYdVtboeU4CTOZQuqYbZNS8aBYb8ROQ== +"@puppeteer/browsers@2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.10.0.tgz#a6e55bf85bfcc819e5e8c79f6122cccaa52515a4" + integrity sha512-HdHF4rny4JCvIcm7V1dpvpctIGqM3/Me255CB44vW7hDG1zYMmcBMjpNqZEDxdCfXGLkx5kP0+Jz5DUS+ukqtA== dependencies: debug "^4.4.0" extract-zip "^2.0.1" @@ -2205,10 +2205,10 @@ viem "^2.19.1" zod "^3.22.4" -"@strkfarm/sdk@^1.0.28": - version "1.0.28" - resolved "https://registry.yarnpkg.com/@strkfarm/sdk/-/sdk-1.0.28.tgz#1fad880609c3e323593671c32c2abb6163f847d9" - integrity sha512-0ghvlHm432T9sc1HBWVNZ8eRYen0nY2/Vb5EzomW9mAjXdglcr35ho3J237ZhWi7FJvimCxxb4fKDtMlXTS4YA== +"@strkfarm/sdk@^1.0.35": + version "1.0.35" + resolved "https://registry.yarnpkg.com/@strkfarm/sdk/-/sdk-1.0.35.tgz#307cd2beb53cb12bc66c4aab08560ff5b316ca5e" + integrity sha512-QG1Oes92QwUvusG8kY8GFWE3CxHr5Zde/ZM9bU1DhwGk0FRMkj0TbN19pNnVOEv4Odw9Tj990uISFphBm/mhHw== dependencies: "@avnu/avnu-sdk" "^3.0.2" axios "^1.7.2" @@ -2315,6 +2315,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/lodash.debounce@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz#0f5f21c507bce7521b5e30e7a24440975ac860a5" + integrity sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ== + dependencies: + "@types/lodash" "*" + "@types/lodash.mergewith@4.6.7": version "4.6.7" resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.7.tgz#eaa65aa5872abdd282f271eae447b115b2757212" @@ -2345,11 +2352,11 @@ "@types/node" "*" "@types/node@*", "@types/node@^22.5.5": - version "22.13.14" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.14.tgz#70d84ec91013dcd2ba2de35532a5a14c2b4cc912" - integrity sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w== + version "22.14.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.14.1.tgz#53b54585cec81c21eee3697521e31312d6ca1e6f" + integrity sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw== dependencies: - undici-types "~6.20.0" + undici-types "~6.21.0" "@types/node@18.15.13": version "18.15.13" @@ -2566,6 +2573,13 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@upstash/redis@^1.34.7": + version "1.34.7" + resolved "https://registry.yarnpkg.com/@upstash/redis/-/redis-1.34.7.tgz#7cf4909bc94bf1ad91d9ee81ff93dc8126cda56c" + integrity sha512-EJ/g+ttDL3MFS8WNDLj/Buybo3FpPw2IAFl4X8lxh8/l7YTq6kTqT6FKkq8eYRdN6lGlVw+W6qnmym734m39Tg== + dependencies: + crypto-js "^4.2.0" + "@vercel/analytics@1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@vercel/analytics/-/analytics-1.2.2.tgz#715d8f203a170c06ba36b363e03b048c03060d5d" @@ -3256,9 +3270,9 @@ bare-events@^2.2.0, bare-events@^2.5.4: integrity sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA== bare-fs@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.0.2.tgz#a879c7b5d9242663ef80d75d6b99c2c6701664d6" - integrity sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw== + version "4.1.2" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.1.2.tgz#5b048298019f489979d5a6afb480f5204ad4e89b" + integrity sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA== dependencies: bare-events "^2.5.4" bare-path "^3.0.0" @@ -3489,10 +3503,10 @@ chroma.ts@1.0.10: resolved "https://registry.yarnpkg.com/chroma.ts/-/chroma.ts-1.0.10.tgz#2b965d8f2c2eee44d25072902e5787fe259d4565" integrity sha512-0FOQiB6LaiOwoyaxP+a4d3sCIOSf7YvBKj3TfeQM4ZBb2yskRxe4FlT2P4YNpHz7kIB5rXsfmpyniyrYRRyVHw== -chromium-bidi@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-2.1.2.tgz#b0710279f993128d4e0b41c892209ea093217d97" - integrity sha512-vtRWBK2uImo5/W2oG6/cDkkHSm+2t6VHgnj+Rcwhb0pP74OoUb4GipyRX/T/y39gYQPhioP0DPShn+A7P6CHNw== +chromium-bidi@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-3.0.0.tgz#bfb0549db96552d42377401aadc0198a1bbb3e9f" + integrity sha512-ZOGRDAhBMX1uxL2Cm2TDuhImbrsEz5A/tTcVU6RpXEWaTNUNwsHW6njUXizh51Ir6iqHbKAfhA2XK33uBcLo5A== dependencies: mitt "^3.0.1" zod "^3.24.1" @@ -3844,6 +3858,11 @@ crossws@^0.2.4: resolved "https://registry.yarnpkg.com/crossws/-/crossws-0.2.4.tgz#82a8b518bff1018ab1d21ced9e35ffbe1681ad03" integrity sha512-DAxroI2uSOgUKLz00NX6A8U/8EE3SZHmIND+10jkVSaypvyt57J5JEOxAQOL6lQxyzi/wZbTIwssU1uy69h5Vg== +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + css-box-model@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" @@ -4123,10 +4142,10 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -devtools-protocol@0.0.1413902: - version "0.0.1413902" - resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1413902.tgz#a0f00fe9eb25ab337a8f9656a29e0a1a69f42401" - integrity sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ== +devtools-protocol@0.0.1425554: + version "0.0.1425554" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1425554.tgz#51ed2fed1405f56783d24a393f7c75b6bbb58029" + integrity sha512-uRfxR6Nlzdzt0ihVIkV+sLztKgs7rgquY/Mhcv1YNCWDh5IZgl5mnn2aeEnW5stYTE0wwiF4RYVz8eMEpV1SEw== didyoumean@^1.2.2: version "1.2.2" @@ -6336,6 +6355,11 @@ lodash-es@^4.17.21: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== + lodash.isequal@4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" @@ -7234,28 +7258,28 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -puppeteer-core@24.4.0: - version "24.4.0" - resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.4.0.tgz#a301c58344fe939b487704593681ea9f913fe6f8" - integrity sha512-eFw66gCnWo0X8Hyf9KxxJtms7a61NJVMiSaWfItsFPzFBsjsWdmcNlBdsA1WVwln6neoHhsG+uTVesKmTREn/g== +puppeteer-core@24.6.1: + version "24.6.1" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.6.1.tgz#fc2ea21a49d6d8240cc959b729a12ba976ac140a" + integrity sha512-sMCxsY+OPWO2fecBrhIeCeJbWWXJ6UaN997sTid6whY0YT9XM0RnxEwLeUibluIS5/fRmuxe1efjb5RMBsky7g== dependencies: - "@puppeteer/browsers" "2.8.0" - chromium-bidi "2.1.2" + "@puppeteer/browsers" "2.10.0" + chromium-bidi "3.0.0" debug "^4.4.0" - devtools-protocol "0.0.1413902" + devtools-protocol "0.0.1425554" typed-query-selector "^2.12.0" ws "^8.18.1" puppeteer@^24.4.0: - version "24.4.0" - resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.4.0.tgz#fb45a67e72f4e6e34db8f404ef61cdd42099e6e6" - integrity sha512-E4JhJzjS8AAI+6N/b+Utwarhz6zWl3+MR725fal+s3UlOlX2eWdsvYYU+Q5bXMjs9eZEGkNQroLkn7j11s2k1Q== + version "24.6.1" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.6.1.tgz#828308e1e05654c4ca87399e677d10e3eeb32702" + integrity sha512-/4ocGfu8LNvDbWUqJZV2VmwEWpbOdJa69y2Jivd213tV0ekAtUh/bgT1hhW63SDN/CtrEucOPwoomZ+9M+eBEg== dependencies: - "@puppeteer/browsers" "2.8.0" - chromium-bidi "2.1.2" + "@puppeteer/browsers" "2.10.0" + chromium-bidi "3.0.0" cosmiconfig "^9.0.0" - devtools-protocol "0.0.1413902" - puppeteer-core "24.4.0" + devtools-protocol "0.0.1425554" + puppeteer-core "24.6.1" typed-query-selector "^2.12.0" qs@6.14.0, qs@^6.7.0: @@ -8822,10 +8846,10 @@ undici-types@~6.19.2: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== -undici-types@~6.20.0: - version "6.20.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" - integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== unenv@^1.9.0: version "1.10.0"