diff --git a/.changeset/twenty-sites-smile.md b/.changeset/twenty-sites-smile.md new file mode 100644 index 0000000000..164b1d8092 --- /dev/null +++ b/.changeset/twenty-sites-smile.md @@ -0,0 +1,5 @@ +--- +"@venusprotocol/evm": minor +--- + +add base Boost tab diff --git a/apps/evm/src/clients/api/__mocks__/index.ts b/apps/evm/src/clients/api/__mocks__/index.ts index 65ed32216d..c552fe8a9f 100644 --- a/apps/evm/src/clients/api/__mocks__/index.ts +++ b/apps/evm/src/clients/api/__mocks__/index.ts @@ -613,6 +613,16 @@ export const useGetAccountTransactionHistory = vi.fn(() => }), ); +export const getSwapQuote = vi.fn(async () => ({ + swapQuote: undefined, +})); +export const useGetSwapQuote = vi.fn(() => + useQuery({ + queryKey: [FunctionKey.GET_SWAP_QUOTE], + queryFn: getSwapQuote, + }), +); + // Mutations export const useApproveToken = vi.fn((_variables: never, options?: MutationObserverOptions) => useMutation({ @@ -677,6 +687,14 @@ export const useBorrow = vi.fn((_variables: never, options?: MutationObserverOpt }), ); +export const useOpenLeveragedPosition = vi.fn( + (_variables: never, options?: MutationObserverOptions) => + useMutation({ + mutationFn: vi.fn(), + ...options, + }), +); + export const withdrawXvs = vi.fn(); export const useWithdrawXvs = (options?: MutationObserverOptions) => useMutation({ diff --git a/apps/evm/src/clients/api/index.ts b/apps/evm/src/clients/api/index.ts index 7961a01059..c4e05de09a 100644 --- a/apps/evm/src/clients/api/index.ts +++ b/apps/evm/src/clients/api/index.ts @@ -31,6 +31,7 @@ export * from './mutations/useSwapTokens'; export * from './mutations/useWithdraw'; export * from './mutations/useImportSupplyPosition'; export * from './mutations/useSetEModeGroup'; +export * from './mutations/useOpenLeveragedPosition'; // Queries export * from './queries/getVaiTreasuryPercentage'; @@ -226,3 +227,6 @@ export * from './queries/getAccountTransactionHistory/useGetAccountTransactionHi export * from './queries/getSimulatedPool'; export * from './queries/getSimulatedPool/useGetSimulatedPool'; + +export * from './queries/getSwapQuote'; +export * from './queries/getSwapQuote/useGetSwapQuote'; diff --git a/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/__tests__/__snapshots__/index.spec.ts.snap b/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/__tests__/__snapshots__/index.spec.ts.snap new file mode 100644 index 0000000000..b579c8c6d9 --- /dev/null +++ b/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/__tests__/__snapshots__/index.spec.ts.snap @@ -0,0 +1,54 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`useOpenLeveragedPosition > calls useSendTransaction with correct parameters 'with single asset' 1`] = ` +{ + "abi": Any, + "address": "0xfakeLeverageManagerContractAddress", + "args": [ + "0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7", + 0n, + 100000000n, + ], + "functionName": "enterSingleAssetLeverage", +} +`; + +exports[`useOpenLeveragedPosition > calls useSendTransaction with correct parameters 'with single asset' 2`] = ` +[ + [ + { + "queryKey": [ + "GET_POOLS", + ], + }, + ], +] +`; + +exports[`useOpenLeveragedPosition > calls useSendTransaction with correct parameters 'with swapQuote' 1`] = ` +{ + "abi": Any, + "address": "0xfakeLeverageManagerContractAddress", + "args": [ + "0x170d3b2da05cc2124334240fB34ad1359e34C562", + 0n, + "0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7", + 100000000n, + 100000000n, + "0x", + ], + "functionName": "enterLeverage", +} +`; + +exports[`useOpenLeveragedPosition > calls useSendTransaction with correct parameters 'with swapQuote' 2`] = ` +[ + [ + { + "queryKey": [ + "GET_POOLS", + ], + }, + ], +] +`; diff --git a/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/__tests__/index.spec.ts b/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/__tests__/index.spec.ts new file mode 100644 index 0000000000..2fc15876b0 --- /dev/null +++ b/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/__tests__/index.spec.ts @@ -0,0 +1,76 @@ +import { lisUsd, usdc } from '__mocks__/models/tokens'; +import { vLisUSD, vUsdc } from '__mocks__/models/vTokens'; +import { queryClient } from 'clients/api'; +import { useGetContractAddress } from 'hooks/useGetContractAddress'; +import { useSendTransaction } from 'hooks/useSendTransaction'; +import { renderHook } from 'testUtils/render'; +import type { ExactInSwapQuote } from 'types'; +import type { Mock } from 'vitest'; +import { useOpenLeveragedPosition } from '..'; + +const fakeSwapQuote: ExactInSwapQuote = { + fromToken: usdc, + toToken: lisUsd, + direction: 'exact-in', + priceImpactPercentage: 0.1, + fromTokenAmountSoldMantissa: 100000000n, + expectedToTokenAmountReceivedMantissa: 100000000n, + minimumToTokenAmountReceivedMantissa: 100000000n, + callData: '0x', +}; + +vi.mock('libs/contracts'); + +describe('useOpenLeveragedPosition', () => { + it.each([ + { + label: 'with swapQuote', + input: { + borrowedVToken: vUsdc, + suppliedVToken: vLisUSD, + swapQuote: fakeSwapQuote, + }, + }, + { + label: 'with single asset', + input: { + vToken: vUsdc, + amountMantissa: 100000000n, + }, + }, + ])('calls useSendTransaction with correct parameters $label', async ({ input }) => { + renderHook(() => useOpenLeveragedPosition()); + + expect(useSendTransaction).toHaveBeenCalledWith({ + fn: expect.any(Function), + onConfirmed: expect.any(Function), + options: undefined, + }); + + const { fn } = (useSendTransaction as jest.Mock).mock.calls[0][0]; + + expect(await fn(input)).toMatchSnapshot({ + abi: expect.any(Array), + }); + + const { onConfirmed } = (useSendTransaction as jest.Mock).mock.calls[0][0]; + await onConfirmed(); + + expect((queryClient.invalidateQueries as Mock).mock.calls).toMatchSnapshot(); + }); + + it('throws error when LeverageManager contract address is not found', async () => { + (useGetContractAddress as Mock).mockImplementation(() => ({ address: undefined })); + + renderHook(() => useOpenLeveragedPosition()); + + const { fn } = (useSendTransaction as Mock).mock.calls[0][0]; + + await expect(async () => + fn({ + vToken: vUsdc, + amountMantissa: 100000000n, + }), + ).rejects.toThrow('somethingWentWrong'); + }); +}); diff --git a/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/index.tsx b/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/index.tsx new file mode 100644 index 0000000000..106c617920 --- /dev/null +++ b/apps/evm/src/clients/api/mutations/useOpenLeveragedPosition/index.tsx @@ -0,0 +1,83 @@ +import type { Account, Address, Chain, Hex, WriteContractParameters } from 'viem'; + +import { queryClient } from 'clients/api'; +import FunctionKey from 'constants/functionKey'; +import { useGetContractAddress } from 'hooks/useGetContractAddress'; +import { type UseSendTransactionOptions, useSendTransaction } from 'hooks/useSendTransaction'; +import { leverageManagerAbi } from 'libs/contracts'; +import { VError } from 'libs/errors'; +import type { ExactInSwapQuote, VToken } from 'types'; + +type OpenLeveragedPositionWithSwapInput = { + swapQuote: ExactInSwapQuote; + borrowedVToken: VToken; + suppliedVToken: VToken; +}; + +type OpenLeveragedPositionWithSingleAssetInput = { + vToken: VToken; + amountMantissa: bigint; +}; + +type OpenLeveragedPositionInput = + | OpenLeveragedPositionWithSwapInput + | OpenLeveragedPositionWithSingleAssetInput; + +type Options = UseSendTransactionOptions; + +export const useOpenLeveragedPosition = (options?: Partial) => { + const { address: leverageManagerContractAddress } = useGetContractAddress({ + name: 'LeverageManager', + }); + + return useSendTransaction({ + fn: (input: OpenLeveragedPositionInput) => { + if (!leverageManagerContractAddress) { + throw new VError({ type: 'unexpected', code: 'somethingWentWrong' }); + } + + if ('swapQuote' in input) { + return { + abi: leverageManagerAbi, + address: leverageManagerContractAddress, + functionName: 'enterLeverage', + args: [ + input.suppliedVToken.address, + 0n, + input.borrowedVToken.address, + input.swapQuote.fromTokenAmountSoldMantissa, + input.swapQuote.minimumToTokenAmountReceivedMantissa, + input.swapQuote.callData, + ], + } as WriteContractParameters< + typeof leverageManagerAbi, + 'enterLeverage', + readonly [Address, bigint, Address, bigint, bigint, Hex], + Chain, + Account + >; + } + + return { + abi: leverageManagerAbi, + address: leverageManagerContractAddress, + functionName: 'enterSingleAssetLeverage', + args: [input.vToken.address, 0n, input.amountMantissa], + } as WriteContractParameters< + typeof leverageManagerAbi, + 'enterSingleAssetLeverage', + readonly [Address, bigint, bigint], + Chain, + Account + >; + }, + onConfirmed: () => { + // TODO: send analytic event + + queryClient.invalidateQueries({ + queryKey: [FunctionKey.GET_POOLS], + }); + }, + options, + }); +}; diff --git a/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/__snapshots__/index.spec.ts.snap b/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/__snapshots__/index.spec.ts.snap index 3eb8e35eed..37117c9d62 100644 --- a/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/__snapshots__/index.spec.ts.snap +++ b/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/__snapshots__/index.spec.ts.snap @@ -1366,6 +1366,689 @@ exports[`getSimulatedPool > returns simulated pool with updated asset balances w } `; +exports[`getSimulatedPool > returns simulated pool with updated asset balances when mutations are provided, including enabling collateral 1`] = ` +{ + "pool": { + "assets": [ + { + "badDebtMantissa": 6789000000000000000n, + "borrowApyPercentage": "-2.3062487835658776", + "borrowBalanceCents": "70925716", + "borrowBalanceTokens": "1852935.597521220541385584", + "borrowCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "borrowPointDistributions": [ + { + "description": "Fake point distribution description", + "extraInfoUrl": "https://fake.url", + "incentive": "3x points", + "logoUrl": "fake-xvs-asset", + "title": "Fake points", + }, + ], + "borrowTokenDistributions": [ + { + "apyPercentage": "4.17469243006608279", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + ], + "borrowerCount": 10, + "cashTokens": "10", + "collateralFactor": 0.5, + "disabledTokenActions": [], + "exchangeRateVTokens": "49.589181233", + "isBorrowable": true, + "isBorrowableByUser": true, + "isCollateralOfUser": true, + "liquidationPenaltyPercentage": 4, + "liquidationThresholdPercentage": 50, + "liquidityCents": "8036465875", + "reserveFactor": 0.25, + "reserveTokens": "1000", + "supplierCount": 100, + "supplyApyPercentage": "0.05225450324405023", + "supplyBalanceCents": "278311516", + "supplyBalanceTokens": "19339683254955736", + "supplyCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "supplyPointDistributions": [ + { + "description": "Fake point distribution description", + "extraInfoUrl": "https://fake.url", + "incentive": "3x points", + "logoUrl": "fake-xvs-asset", + "title": "Fake points", + }, + ], + "supplyTokenDistributions": [ + { + "apyPercentage": "0.11720675342484096", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + ], + "tokenPriceCents": "127.86734", + "userBorrowBalanceCents": "0", + "userBorrowBalanceTokens": "0", + "userBorrowLimitSharePercentage": 0, + "userCollateralFactor": 0.5, + "userLiquidationThresholdPercentage": 50, + "userSupplyBalanceCents": "11508", + "userSupplyBalanceTokens": "90", + "userWalletBalanceCents": "12786", + "userWalletBalanceTokens": "100", + "vToken": { + "address": "0x6d6F697e34145Bb95c54E77482d97cc261Dc237E", + "decimals": 8, + "symbol": "vXVS", + "underlyingToken": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + }, + }, + { + "badDebtMantissa": 0n, + "borrowApyPercentage": "-5.361233028654066", + "borrowBalanceCents": "858721657509635.98728", + "borrowBalanceTokens": "73128320.509651061457900627", + "borrowCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "borrowPointDistributions": [], + "borrowTokenDistributions": [ + { + "apyPercentage": "1.670327607690572731", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + { + "apyPercentage": "1.233105649796123742", + "isActive": true, + "referenceValues": { + "userBorrowBalanceTokens": "1000", + "userSupplyBalanceTokens": "1000", + "userXvsStakedTokens": "1000", + }, + "token": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + "type": "primeSimulation", + }, + { + "apyPercentage": "0.913105649796123742", + "isActive": true, + "token": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + "type": "prime", + }, + ], + "borrowerCount": 10, + "cashTokens": "10", + "collateralFactor": 0.8, + "disabledTokenActions": [ + "borrow", + "supply", + ], + "exchangeRateVTokens": "1", + "isBorrowable": false, + "isBorrowableByUser": false, + "isCollateralOfUser": true, + "liquidationPenaltyPercentage": 4, + "liquidationThresholdPercentage": 80, + "liquidityCents": "1702951959", + "reserveFactor": 0.2, + "reserveTokens": "1000", + "supplierCount": 100, + "supplyApyPercentage": "3.887242555711379188", + "supplyBalanceCents": "1000183891888505.4276", + "supplyBalanceTokens": "4.717199913187927152720001092e+28", + "supplyCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "supplyPointDistributions": [], + "supplyTokenDistributions": [ + { + "apyPercentage": "1.353105649796123742", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + { + "apyPercentage": "0.953105649796123742", + "isActive": true, + "referenceValues": { + "userBorrowBalanceTokens": "1000", + "userSupplyBalanceTokens": "1000", + "userXvsStakedTokens": "1000", + }, + "token": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + "type": "primeSimulation", + }, + { + "apyPercentage": "0.753105649796123742", + "isActive": true, + "token": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + "type": "prime", + }, + ], + "tokenPriceCents": "99.99364", + "userBorrowBalanceCents": "199.98728", + "userBorrowBalanceTokens": "2", + "userBorrowLimitSharePercentage": 0.69, + "userCollateralFactor": 0.8, + "userLiquidationThresholdPercentage": 80, + "userSupplyBalanceCents": "18998.4276", + "userSupplyBalanceTokens": "190", + "userWalletBalanceCents": "0", + "userWalletBalanceTokens": "0", + "vToken": { + "address": "0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7", + "decimals": 8, + "symbol": "vUSDC", + "underlyingToken": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + }, + }, + { + "badDebtMantissa": 0n, + "borrowApyPercentage": "-4.9748661428011145", + "borrowBalanceCents": "3158444721", + "borrowBalanceTokens": "232511166.920938849475104194", + "borrowCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "borrowPointDistributions": [ + { + "description": "Fake point distribution description", + "extraInfoUrl": "https://fake.url", + "incentive": "3x points", + "logoUrl": "fake-xvs-asset", + "title": "Fake points", + }, + ], + "borrowTokenDistributions": [ + { + "apyPercentage": "0.522209972682294832", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + { + "apyPercentage": "1.015310564979612374", + "isActive": true, + "referenceValues": { + "userBorrowBalanceTokens": "1000", + "userSupplyBalanceTokens": "1000", + "userXvsStakedTokens": "1000", + }, + "token": { + "address": "0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c", + "decimals": 6, + "iconSrc": "fake-usdt-asset", + "symbol": "USDT", + }, + "type": "primeSimulation", + }, + ], + "borrowerCount": 10, + "cashTokens": "10", + "collateralFactor": 0.8, + "disabledTokenActions": [ + "swapAndSupply", + ], + "exchangeRateVTokens": "0.981982", + "isBorrowable": true, + "isBorrowableByUser": true, + "isCollateralOfUser": true, + "liquidationPenaltyPercentage": 4, + "liquidationThresholdPercentage": 80, + "liquidityCents": "5534102886", + "reserveFactor": 0.2, + "reserveTokens": "1000", + "supplierCount": 100, + "supplyApyPercentage": "3.593608909332766999", + "supplyBalanceCents": "1098041201011568", + "supplyBalanceTokens": "5.029972090817266864401527367893625e+33", + "supplyCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "supplyPointDistributions": [], + "supplyTokenDistributions": [ + { + "apyPercentage": "0.421719501189155143", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + { + "apyPercentage": "1.753105649796123742", + "isActive": true, + "referenceValues": { + "userBorrowBalanceTokens": "1000", + "userSupplyBalanceTokens": "1000", + "userXvsStakedTokens": "1000", + }, + "token": { + "address": "0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c", + "decimals": 6, + "iconSrc": "fake-usdt-asset", + "symbol": "USDT", + }, + "type": "primeSimulation", + }, + ], + "tokenPriceCents": "100.024602", + "userBorrowBalanceCents": "4000", + "userBorrowBalanceTokens": "40", + "userBorrowLimitSharePercentage": 13.81, + "userCollateralFactor": 0.8, + "userLiquidationThresholdPercentage": 80, + "userSupplyBalanceCents": "10000", + "userSupplyBalanceTokens": "100", + "userWalletBalanceCents": "90000", + "userWalletBalanceTokens": "900", + "vToken": { + "address": "0xb7526572FFE56AB9D7489838Bf2E18e3323b441A", + "decimals": 8, + "symbol": "vUSDT", + "underlyingToken": { + "address": "0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c", + "decimals": 6, + "iconSrc": "fake-usdt-asset", + "symbol": "USDT", + }, + }, + }, + { + "badDebtMantissa": 0n, + "borrowApyPercentage": "-4.050271277344538", + "borrowBalanceCents": "83910350502", + "borrowBalanceTokens": "142662020.229587308931217432", + "borrowCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "borrowPointDistributions": [], + "borrowTokenDistributions": [ + { + "apyPercentage": "0.852697602175970714", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + { + "apyPercentage": "1.753105649796123742", + "isActive": true, + "referenceValues": { + "userBorrowBalanceTokens": "1000", + "userSupplyBalanceTokens": "1000", + "userXvsStakedTokens": "1000", + }, + "token": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + "type": "primeSimulation", + }, + { + "apyPercentage": "0.913105649796123742", + "isActive": true, + "token": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + "type": "prime", + }, + ], + "borrowerCount": 10, + "cashTokens": "10", + "collateralFactor": 0.8, + "disabledTokenActions": [ + "supply", + ], + "exchangeRateVTokens": "1.000003", + "isBorrowable": true, + "isBorrowableByUser": true, + "isCollateralOfUser": false, + "liquidationPenaltyPercentage": 4, + "liquidationThresholdPercentage": 80, + "liquidityCents": "3654492935", + "reserveFactor": 0.2, + "reserveTokens": "1000", + "supplierCount": 100, + "supplyApyPercentage": "2.886396363044176106", + "supplyBalanceCents": "1054707853878", + "supplyBalanceTokens": "51881081291203672464", + "supplyCapTokens": "1.15792089237316195423570985008687907853269984665640564039457584007913129639935e+77", + "supplyPointDistributions": [ + { + "description": "Fake point distribution description", + "extraInfoUrl": "https://fake.url", + "incentive": "3x points", + "logoUrl": "fake-xvs-asset", + "title": "Fake points", + }, + ], + "supplyTokenDistributions": [ + { + "apyPercentage": "0.678420831753642169", + "dailyDistributedTokens": "9999999.5", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "venus", + }, + { + "apyPercentage": "1.753105649796123742", + "isActive": true, + "referenceValues": { + "userBorrowBalanceTokens": "1000", + "userSupplyBalanceTokens": "1000", + "userXvsStakedTokens": "1000", + }, + "token": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + "type": "primeSimulation", + }, + { + "apyPercentage": "0", + "isActive": true, + "token": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + "type": "prime", + }, + ], + "tokenPriceCents": "100.000922", + "userBorrowBalanceCents": "5000", + "userBorrowBalanceTokens": "50", + "userBorrowLimitSharePercentage": 17.26, + "userCollateralFactor": 0.8, + "userLiquidationThresholdPercentage": 80, + "userSupplyBalanceCents": "0", + "userSupplyBalanceTokens": "0", + "userWalletBalanceCents": "11000", + "userWalletBalanceTokens": "110", + "vToken": { + "address": "0x08e0A5575De71037aE36AbfAfb516595fE68e5e4", + "decimals": 8, + "symbol": "vBUSD", + "underlyingToken": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + }, + }, + ], + "comptrollerAddress": "0x94d1820b2d1c7c7452a163983dc888cec546b77d", + "eModeGroups": [ + { + "assetSettings": [ + { + "collateralFactor": 0.6, + "isBorrowable": true, + "liquidationPenaltyPercentage": 0, + "liquidationThresholdPercentage": 62, + "vToken": { + "address": "0x6d6F697e34145Bb95c54E77482d97cc261Dc237E", + "decimals": 8, + "symbol": "vXVS", + "underlyingToken": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + }, + }, + { + "collateralFactor": 0.9, + "isBorrowable": false, + "liquidationPenaltyPercentage": 30, + "liquidationThresholdPercentage": 92, + "vToken": { + "address": "0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7", + "decimals": 8, + "symbol": "vUSDC", + "underlyingToken": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + }, + }, + { + "collateralFactor": 0.9, + "isBorrowable": true, + "liquidationPenaltyPercentage": 30, + "liquidationThresholdPercentage": 92, + "vToken": { + "address": "0xb7526572FFE56AB9D7489838Bf2E18e3323b441A", + "decimals": 8, + "symbol": "vUSDT", + "underlyingToken": { + "address": "0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c", + "decimals": 6, + "iconSrc": "fake-usdt-asset", + "symbol": "USDT", + }, + }, + }, + ], + "id": 0, + "isActive": true, + "isIsolated": false, + "name": "Stablecoins", + }, + { + "assetSettings": [ + { + "collateralFactor": 0.9, + "isBorrowable": true, + "liquidationPenaltyPercentage": 30, + "liquidationThresholdPercentage": 92, + "vToken": { + "address": "0xb7526572FFE56AB9D7489838Bf2E18e3323b441A", + "decimals": 8, + "symbol": "vUSDT", + "underlyingToken": { + "address": "0xA11c8D9DC9b66E209Ef60F0C8D969D3CD988782c", + "decimals": 6, + "iconSrc": "fake-usdt-asset", + "symbol": "USDT", + }, + }, + }, + { + "collateralFactor": 0.9, + "isBorrowable": true, + "liquidationPenaltyPercentage": 30, + "liquidationThresholdPercentage": 92, + "vToken": { + "address": "0x08e0A5575De71037aE36AbfAfb516595fE68e5e4", + "decimals": 8, + "symbol": "vBUSD", + "underlyingToken": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + }, + }, + ], + "id": 1, + "isActive": true, + "isIsolated": false, + "name": "DeFi", + }, + { + "assetSettings": [ + { + "collateralFactor": 0.9, + "isBorrowable": false, + "liquidationPenaltyPercentage": 30, + "liquidationThresholdPercentage": 92, + "vToken": { + "address": "0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7", + "decimals": 8, + "symbol": "vUSDC", + "underlyingToken": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + }, + }, + ], + "id": 2, + "isActive": true, + "isIsolated": true, + "name": "#ToTheMoon", + }, + { + "assetSettings": [ + { + "collateralFactor": 0.6, + "isBorrowable": true, + "liquidationPenaltyPercentage": 0, + "liquidationThresholdPercentage": 62, + "vToken": { + "address": "0x6d6F697e34145Bb95c54E77482d97cc261Dc237E", + "decimals": 8, + "symbol": "vXVS", + "underlyingToken": { + "address": "0xB9e0E753630434d7863528cc73CB7AC638a7c8ff", + "decimals": 18, + "iconSrc": "fake-xvs-asset", + "symbol": "XVS", + }, + }, + }, + { + "collateralFactor": 0.9, + "isBorrowable": false, + "liquidationPenaltyPercentage": 30, + "liquidationThresholdPercentage": 92, + "vToken": { + "address": "0xD5C4C2e2facBEB59D0216D0595d63FcDc6F9A1a7", + "decimals": 8, + "symbol": "vUSDC", + "underlyingToken": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + }, + }, + ], + "id": 3, + "isActive": false, + "isIsolated": false, + "name": "GameFi", + }, + ], + "isIsolated": false, + "name": "Venus", + "userBorrowBalanceCents": "10199.98728", + "userBorrowLimitCents": "28952.74208", + "userHealthFactor": 2.83, + "userLiquidationThresholdCents": "28952.74208", + "userSupplyBalanceCents": "40506.4276", + "userYearlyEarningsCents": "2070", + "vai": { + "borrowAprPercentage": "1.34", + "token": { + "address": "0x5fFbE5302BadED40941A403228E6AD03f93752d9", + "decimals": 18, + "iconSrc": "fake-vai-asset", + "symbol": "VAI", + }, + "tokenPriceCents": "100", + "userBorrowBalanceCents": "1000", + "userBorrowBalanceTokens": "10", + }, + }, +} +`; + exports[`getSimulatedPool > returns undefined pool when no balance mutations are provided 1`] = ` { "pool": undefined, diff --git a/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/index.spec.ts b/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/index.spec.ts index e42b4911a8..fc42fd31e2 100644 --- a/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/index.spec.ts +++ b/apps/evm/src/clients/api/queries/getSimulatedPool/__tests__/index.spec.ts @@ -12,13 +12,17 @@ import { getSimulatedPool } from '..'; const fakeNonPrimeAsset = poolData[0].assets[0]; const fakePrimeAsset = poolData[0].assets[1]; -const generateAssetBalanceMutations = ({ asset }: { asset: Asset }) => { +const generateAssetBalanceMutations = ({ + asset, + enableAsCollateralOfUser, +}: { asset: Asset; enableAsCollateralOfUser?: boolean }) => { const balanceMutations: BalanceMutation[] = [ { type: 'asset', action: 'supply', vTokenAddress: asset.vToken.address, amountTokens: new BigNumber(100), + enableAsCollateralOfUser, }, { type: 'asset', @@ -76,6 +80,21 @@ describe('getSimulatedPool', () => { expect(result).toMatchSnapshot(); }); + it('returns simulated pool with updated asset balances when mutations are provided, including enabling collateral', async () => { + const fakeBalanceMutations = generateAssetBalanceMutations({ + asset: poolData[0].assets[1], + enableAsCollateralOfUser: true, + }); + + const result = await getSimulatedPool({ + publicClient: {} as unknown as PublicClient, + pool: poolData[0], + balanceMutations: fakeBalanceMutations, + }); + + expect(result).toMatchSnapshot(); + }); + it('recalculates Prime APYs when a Prime market is mutated and the user qualifies for Prime rewards', async () => { const readContractMock = vi.fn(async () => ({ supplyAPR: 1000n, diff --git a/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts b/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts index cf401459d5..db1129882a 100644 --- a/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts +++ b/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts @@ -9,7 +9,12 @@ import type { PoolVai, VaiBalanceMutation, } from 'types'; -import { addUserBorrowLimitShares, calculateUserPoolValues, clampToZero } from 'utilities'; +import { + addUserBorrowLimitShares, + areAddressesEqual, + calculateUserPoolValues, + clampToZero, +} from 'utilities'; import { addUserPrimeApys } from './addUserPrimeApys'; export interface GetSimulatedPoolInput { @@ -71,9 +76,11 @@ export const getSimulatedPool = async ({ const mutatedPrimeVTokenAddresses: Address[] = []; let simulatedAssets: Asset[] = pool.assets.map(asset => { - const lowerCasedVTokenAddress = asset.vToken.address.toLowerCase() as Address; + const assetBalanceMutations = assetMutations.filter(balanceMutation => + areAddressesEqual(balanceMutation.vTokenAddress, asset.vToken.address), + ); - if (!mutatedVTokenAddresses.includes(lowerCasedVTokenAddress)) { + if (assetBalanceMutations.length === 0) { return asset; } @@ -83,7 +90,7 @@ export const getSimulatedPool = async ({ ].some(d => d.type === 'prime' || d.type === 'primeSimulation'); if (isAssetPrime) { - mutatedPrimeVTokenAddresses.push(lowerCasedVTokenAddress); + mutatedPrimeVTokenAddresses.push(asset.vToken.address.toLowerCase() as Address); } let supplyBalanceTokens = asset.supplyBalanceTokens; @@ -96,9 +103,15 @@ export const getSimulatedPool = async ({ let userBorrowBalanceTokens = asset.userBorrowBalanceTokens; let userBorrowBalanceCents = asset.userBorrowBalanceCents; - filteredBalanceMutations.forEach(({ action, amountTokens }) => { + let isCollateralOfUser = asset.isCollateralOfUser; + + assetBalanceMutations.forEach(({ action, amountTokens, enableAsCollateralOfUser }) => { const amountCents = amountTokens.multipliedBy(asset.tokenPriceCents); + if (enableAsCollateralOfUser) { + isCollateralOfUser = true; + } + switch (action) { case 'supply': supplyBalanceTokens = supplyBalanceTokens.plus(amountTokens); @@ -149,6 +162,7 @@ export const getSimulatedPool = async ({ userSupplyBalanceCents, userBorrowBalanceTokens, userBorrowBalanceCents, + isCollateralOfUser, }; return simulatedAsset; diff --git a/apps/evm/src/clients/api/queries/getSwapQuote/__ tests__/index.spec.ts b/apps/evm/src/clients/api/queries/getSwapQuote/__ tests__/index.spec.ts new file mode 100644 index 0000000000..611be72df5 --- /dev/null +++ b/apps/evm/src/clients/api/queries/getSwapQuote/__ tests__/index.spec.ts @@ -0,0 +1,248 @@ +import BigNumber from 'bignumber.js'; +import type { Address } from 'viem'; +import type { Mock } from 'vitest'; + +import fakeAddress from '__mocks__/models/address'; +import { busd, usdc } from '__mocks__/models/tokens'; +import { ChainId } from 'types'; +import { restService } from 'utilities'; +import { getSwapQuote } from '..'; +import type { SwapApiResponse } from '../types'; + +vi.mock('utilities/restService'); +vi.mock('utilities/generateTransactionDeadline'); + +const sharedProps = { + chainId: ChainId.BSC_MAINNET, + fromToken: usdc, + toToken: busd, + slippagePercentage: 0.5, + recipientAddress: fakeAddress as Address, +}; + +const fakeApiResponse: SwapApiResponse = { + quotes: [ + { + amountIn: '1000000', + amountInMax: '1000000', + amountOut: '1000000000000000000', + amountOutMin: '9789746164978756079', + protocol: 'Fake Protocol', + priceImpact: 0.8, + swapHelperMulticall: { + target: '0xfakeSwapHelperMulticall', + calldata: { + encodedCall: '0xfakeEncodedCallData', + }, + }, + }, + ], +}; + +describe('getSwapQuote', () => { + beforeEach(() => { + (restService as Mock).mockResolvedValue({ data: fakeApiResponse }); + }); + + it('fetches and formats "exact-in" swap quote correctly', async () => { + const result = await getSwapQuote({ + ...sharedProps, + direction: 'exact-in', + fromTokenAmountTokens: new BigNumber(1), + }); + + expect(restService).toHaveBeenCalledTimes(1); + expect((restService as Mock).mock.calls[0][0]).toMatchInlineSnapshot(` + { + "endpoint": "/find-swap/pcs", + "method": "GET", + "params": { + "chainId": 56, + "deadlineTimestampSecs": 1747386407, + "exactAmountInMantissa": "1000000", + "recipientAddress": "0x3d759121234cd36F8124C21aFe1c6852d2bEd848", + "shouldTransferToReceiver": true, + "slippagePercentage": 0.5, + "tokenInAddress": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "tokenOutAddress": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "type": "exact-in", + }, + } + `); + + expect(result).toMatchInlineSnapshot(` + { + "swapQuote": { + "callData": "0xfakeEncodedCallData", + "direction": "exact-in", + "expectedToTokenAmountReceivedMantissa": 1000000000000000000n, + "fromToken": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + "fromTokenAmountSoldMantissa": 1000000n, + "minimumToTokenAmountReceivedMantissa": 995000000000000000n, + "priceImpactPercentage": 0.8, + "toToken": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + }, + } + `); + }); + + it('fetches and formats "exact-out" swap quote correctly', async () => { + const result = await getSwapQuote({ + ...sharedProps, + direction: 'exact-out', + toTokenAmountTokens: new BigNumber(1), + }); + + expect(restService).toHaveBeenCalledTimes(1); + expect((restService as Mock).mock.calls[0][0]).toMatchInlineSnapshot(` + { + "endpoint": "/find-swap/pcs", + "method": "GET", + "params": { + "chainId": 56, + "deadlineTimestampSecs": 1747386407, + "exactAmountOutMantissa": "1000000", + "recipientAddress": "0x3d759121234cd36F8124C21aFe1c6852d2bEd848", + "shouldTransferToReceiver": true, + "slippagePercentage": 0.5, + "tokenInAddress": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "tokenOutAddress": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "type": "exact-out", + }, + } + `); + + expect(result).toMatchInlineSnapshot(` + { + "swapQuote": { + "callData": "0xfakeEncodedCallData", + "direction": "exact-out", + "expectedFromTokenAmountSoldMantissa": 1000000n, + "fromToken": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + "maximumFromTokenAmountSoldMantissa": 995000n, + "priceImpactPercentage": 0.8, + "toToken": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + "toTokenAmountReceivedMantissa": 1000000000000000000n, + }, + } + `); + }); + + it('fetches and formats "approximate-out" swap quote correctly', async () => { + const result = await getSwapQuote({ + ...sharedProps, + direction: 'approximate-out', + minToTokenAmountTokens: new BigNumber(1), + }); + + expect(restService).toHaveBeenCalledTimes(1); + expect((restService as Mock).mock.calls[0][0]).toMatchInlineSnapshot(` + { + "endpoint": "/find-swap/pcs", + "method": "GET", + "params": { + "chainId": 56, + "deadlineTimestampSecs": 1747386407, + "minAmountOutMantissa": "1000000", + "recipientAddress": "0x3d759121234cd36F8124C21aFe1c6852d2bEd848", + "shouldTransferToReceiver": true, + "slippagePercentage": 0.5, + "tokenInAddress": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "tokenOutAddress": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "type": "approximate-out", + }, + } + `); + + expect(result).toMatchInlineSnapshot(` + { + "swapQuote": { + "callData": "0xfakeEncodedCallData", + "direction": "approximate-out", + "expectedToTokenAmountReceivedMantissa": 1000000000000000000n, + "fromToken": { + "address": "0x16227D60f7a0e586C66B005219dfc887D13C9531", + "decimals": 6, + "iconSrc": "fake-usdc-asset", + "symbol": "USDC", + }, + "fromTokenAmountSoldMantissa": 1000000n, + "minimumToTokenAmountReceivedMantissa": 995000000000000000n, + "priceImpactPercentage": 0.8, + "toToken": { + "address": "0x8301F2213c0eeD49a7E28Ae4c3e91722919B8B47", + "decimals": 18, + "iconSrc": "fake-busd-asset", + "symbol": "BUSD", + }, + }, + } + `); + }); + + it('throws on error in payload', async () => { + (restService as Mock).mockResolvedValue({ data: { error: 'Some error' } }); + + await expect( + getSwapQuote({ + ...sharedProps, + direction: 'approximate-out', + minToTokenAmountTokens: new BigNumber(1), + }), + ).rejects.toMatchObject({ + code: 'somethingWentWrong', + }); + }); + + it('throws on undefined payload', async () => { + (restService as Mock).mockResolvedValue({ data: undefined }); + + await expect( + getSwapQuote({ + ...sharedProps, + direction: 'approximate-out', + minToTokenAmountTokens: new BigNumber(1), + }), + ).rejects.toMatchObject({ + code: 'somethingWentWrong', + }); + }); + + it('throws on empty quotes array', async () => { + (restService as Mock).mockResolvedValue({ + data: { + quotes: [], + }, + }); + + await expect( + getSwapQuote({ + ...sharedProps, + direction: 'approximate-out', + minToTokenAmountTokens: new BigNumber(1), + }), + ).rejects.toMatchObject({ + code: 'noSwapQuoteFound', + }); + }); +}); diff --git a/apps/evm/src/clients/api/queries/getSwapQuote/formatSwapQuote/index.ts b/apps/evm/src/clients/api/queries/getSwapQuote/formatSwapQuote/index.ts new file mode 100644 index 0000000000..409231c9ba --- /dev/null +++ b/apps/evm/src/clients/api/queries/getSwapQuote/formatSwapQuote/index.ts @@ -0,0 +1,83 @@ +import type { + ApproximateOutSwapQuote, + ExactInSwapQuote, + ExactOutSwapQuote, + SwapQuoteDirection, + Token, +} from 'types'; +import type { ApiSwapQuote } from '../types'; +import { subtractSlippagePercentage } from './subtractSlippagePercentage'; + +export const formatSwapQuote = ({ + direction, + fromToken, + toToken, + apiSwapQuote, + slippagePercentage, +}: { + direction: SwapQuoteDirection; + fromToken: Token; + toToken: Token; + apiSwapQuote: ApiSwapQuote; + slippagePercentage: number; +}) => { + const sharedProps = { + fromToken, + toToken, + priceImpactPercentage: apiSwapQuote.priceImpact, + callData: apiSwapQuote.swapHelperMulticall.calldata.encodedCall, + }; + + if (direction === 'exact-in') { + const expectedToTokenAmountReceivedMantissa = BigInt(apiSwapQuote.amountOut); + const minimumToTokenAmountReceivedMantissa = subtractSlippagePercentage({ + amount: expectedToTokenAmountReceivedMantissa, + slippagePercentage, + }); + + const swapQuote: ExactInSwapQuote = { + ...sharedProps, + fromTokenAmountSoldMantissa: BigInt(apiSwapQuote.amountIn), + expectedToTokenAmountReceivedMantissa, + minimumToTokenAmountReceivedMantissa, + direction: 'exact-in', + }; + + return swapQuote; + } + + if (direction === 'exact-out') { + const expectedFromTokenAmountSoldMantissa = BigInt(apiSwapQuote.amountIn); + const maximumFromTokenAmountSoldMantissa = subtractSlippagePercentage({ + amount: expectedFromTokenAmountSoldMantissa, + slippagePercentage, + }); + + const swapQuote: ExactOutSwapQuote = { + ...sharedProps, + expectedFromTokenAmountSoldMantissa, + maximumFromTokenAmountSoldMantissa, + toTokenAmountReceivedMantissa: BigInt(apiSwapQuote.amountOut), + direction: 'exact-out', + }; + + return swapQuote; + } + + // Approximate out swap + const expectedToTokenAmountReceivedMantissa = BigInt(apiSwapQuote.amountOut); + const minimumToTokenAmountReceivedMantissa = subtractSlippagePercentage({ + amount: expectedToTokenAmountReceivedMantissa, + slippagePercentage, + }); + + const swapQuote: ApproximateOutSwapQuote = { + ...sharedProps, + fromTokenAmountSoldMantissa: BigInt(apiSwapQuote.amountIn), + expectedToTokenAmountReceivedMantissa, + minimumToTokenAmountReceivedMantissa, + direction: 'approximate-out', + }; + + return swapQuote; +}; diff --git a/apps/evm/src/clients/api/queries/getSwapQuote/formatSwapQuote/subtractSlippagePercentage/index.ts b/apps/evm/src/clients/api/queries/getSwapQuote/formatSwapQuote/subtractSlippagePercentage/index.ts new file mode 100644 index 0000000000..75df167096 --- /dev/null +++ b/apps/evm/src/clients/api/queries/getSwapQuote/formatSwapQuote/subtractSlippagePercentage/index.ts @@ -0,0 +1,7 @@ +import BigNumber from 'bignumber.js'; + +export const subtractSlippagePercentage = ({ + amount, + slippagePercentage, +}: { amount: bigint; slippagePercentage: number }) => + BigInt(new BigNumber((Number(amount) * (100 - slippagePercentage)) / 100).toFixed(0)); diff --git a/apps/evm/src/clients/api/queries/getSwapQuote/index.ts b/apps/evm/src/clients/api/queries/getSwapQuote/index.ts new file mode 100644 index 0000000000..4cd56c200a --- /dev/null +++ b/apps/evm/src/clients/api/queries/getSwapQuote/index.ts @@ -0,0 +1,82 @@ +import { VError } from 'libs/errors'; +import { convertTokensToMantissa, generateTransactionDeadline, restService } from 'utilities'; +import { formatSwapQuote } from './formatSwapQuote'; +import type { GetSwapQuoteInput, GetSwapQuoteOutput, SwapApiResponse } from './types'; + +export * from './types'; + +export const getSwapQuote = async ({ + chainId, + fromToken, + toToken, + slippagePercentage, + recipientAddress, + ...swapSpecificProps +}: GetSwapQuoteInput): Promise => { + const transactionDeadline = generateTransactionDeadline(); + + const params: Record = { + chainId, + tokenInAddress: fromToken.address, + tokenOutAddress: toToken.address, + slippagePercentage, + recipientAddress, + deadlineTimestampSecs: Number(transactionDeadline), + type: swapSpecificProps.direction, + shouldTransferToReceiver: true, + }; + + if (swapSpecificProps.direction === 'exact-in') { + params.exactAmountInMantissa = convertTokensToMantissa({ + value: swapSpecificProps.fromTokenAmountTokens, + token: fromToken, + }).toFixed(); + } else if (swapSpecificProps.direction === 'exact-out') { + params.exactAmountOutMantissa = convertTokensToMantissa({ + value: swapSpecificProps.toTokenAmountTokens, + token: fromToken, + }).toFixed(); + } else { + // Approximate out swap + params.minAmountOutMantissa = convertTokensToMantissa({ + value: swapSpecificProps.minToTokenAmountTokens, + token: fromToken, + }).toFixed(); + } + + const txsResponse = await restService({ + endpoint: '/find-swap/pcs', + method: 'GET', + params, + }); + + if (txsResponse.data && 'error' in txsResponse.data) { + throw new VError({ + type: 'unexpected', + code: 'somethingWentWrong', + data: { exception: txsResponse.data.error }, + }); + } + + if (!txsResponse.data) { + throw new VError({ type: 'unexpected', code: 'somethingWentWrong' }); + } + + if (txsResponse.data.quotes.length === 0) { + throw new VError({ type: 'swapQuote', code: 'noSwapQuoteFound' }); + } + + const apiSwapQuote = txsResponse.data.quotes[0]; + + const swapQuote = formatSwapQuote({ + apiSwapQuote, + direction: swapSpecificProps.direction, + slippagePercentage: Number(params.slippagePercentage), + fromToken, + toToken, + }); + + return { + swapQuote, + }; +}; diff --git a/apps/evm/src/clients/api/queries/getSwapQuote/types.ts b/apps/evm/src/clients/api/queries/getSwapQuote/types.ts new file mode 100644 index 0000000000..53b682fd8c --- /dev/null +++ b/apps/evm/src/clients/api/queries/getSwapQuote/types.ts @@ -0,0 +1,55 @@ +import type { ChainId, Token } from '@venusprotocol/chains'; +import type { SwapQuote } from 'types'; +import type { Address, Hex } from 'viem'; + +export interface ApiSwapQuote { + amountIn: string; + amountInMax: string; + amountOut: string; + amountOutMin: string; + protocol: string; + priceImpact: number; + swapHelperMulticall: { + target: Address; + calldata: { + encodedCall: Hex; + }; + }; +} + +export interface SwapApiResponse { + quotes: ApiSwapQuote[]; +} + +interface GetSwapQuoteBase { + chainId: ChainId; + recipientAddress: Address; + fromToken: Token; + toToken: Token; + direction: SwapQuote['direction']; + slippagePercentage: number; +} + +export interface GetExactInSwapQuoteInput extends GetSwapQuoteBase { + fromTokenAmountTokens: BigNumber; + direction: 'exact-in'; +} + +export interface GetExactOutSwapQuoteInput extends GetSwapQuoteBase { + toTokenAmountTokens: BigNumber; + direction: 'exact-out'; +} + +export interface GetApproximateOutSwapQuoteInput extends GetSwapQuoteBase { + minToTokenAmountTokens: BigNumber; + direction: 'approximate-out'; +} + +export type GetSwapQuoteInput = + | GetExactInSwapQuoteInput + | GetExactOutSwapQuoteInput + | GetApproximateOutSwapQuoteInput; + +export type GetSwapQuoteOutput = { + swapQuote: SwapQuote | undefined; +}; diff --git a/apps/evm/src/clients/api/queries/getSwapQuote/useGetSwapQuote.ts b/apps/evm/src/clients/api/queries/getSwapQuote/useGetSwapQuote.ts new file mode 100644 index 0000000000..0ce9b5ca55 --- /dev/null +++ b/apps/evm/src/clients/api/queries/getSwapQuote/useGetSwapQuote.ts @@ -0,0 +1,51 @@ +import { type QueryObserverOptions, useQuery } from '@tanstack/react-query'; + +import FunctionKey from 'constants/functionKey'; +import { useGetContractAddress } from 'hooks/useGetContractAddress'; +import type { VError } from 'libs/errors'; +import { useChainId } from 'libs/wallet'; +import { callOrThrow, generatePseudoRandomRefetchInterval } from 'utilities'; +import { + type GetApproximateOutSwapQuoteInput, + type GetExactInSwapQuoteInput, + type GetExactOutSwapQuoteInput, + type GetSwapQuoteOutput, + getSwapQuote, +} from '.'; + +type TrimmedGetSwapQuoteInput = + | Omit + | Omit + | Omit; + +type Options = QueryObserverOptions< + GetSwapQuoteOutput, + VError<'swapQuote' | 'interaction'>, + GetSwapQuoteOutput, + GetSwapQuoteOutput, + [FunctionKey.GET_SWAP_QUOTE, TrimmedGetSwapQuoteInput] +>; + +const refetchInterval = generatePseudoRandomRefetchInterval(); + +export const useGetSwapQuote = (input: TrimmedGetSwapQuoteInput, options?: Partial) => { + const { chainId } = useChainId(); + + const { address: leverageManagerContractAddress } = useGetContractAddress({ + name: 'LeverageManager', + }); + + return useQuery({ + queryKey: [FunctionKey.GET_SWAP_QUOTE, input], + queryFn: () => + callOrThrow({ leverageManagerContractAddress }, params => + getSwapQuote({ + ...input, + chainId, + recipientAddress: params.leverageManagerContractAddress, + }), + ), + refetchInterval, + ...options, + }); +}; diff --git a/apps/evm/src/components/TextField/index.tsx b/apps/evm/src/components/TextField/index.tsx index 213c4dd886..bcd986338f 100644 --- a/apps/evm/src/components/TextField/index.tsx +++ b/apps/evm/src/components/TextField/index.tsx @@ -82,7 +82,7 @@ export const TextField: React.FC = forwardRef {!!label && ( -