diff --git a/.changeset/angry-ideas-heal.md b/.changeset/angry-ideas-heal.md new file mode 100644 index 0000000000..9aa1146520 --- /dev/null +++ b/.changeset/angry-ideas-heal.md @@ -0,0 +1,5 @@ +--- +"@venusprotocol/evm": patch +--- + +fix an issue where certain profitable positions can't be closed diff --git a/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/__tests__/index.spec.ts b/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/__tests__/index.spec.ts index f306eb99d0..4e19e533ff 100644 --- a/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/__tests__/index.spec.ts +++ b/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/__tests__/index.spec.ts @@ -264,4 +264,64 @@ describe('getTradeReduceSwapQuotes', () => { ).toBe('10'); expect(result.pnlDsaTokens.toFixed()).toBe('3'); }); + + it('returns direct positive pnl in dsa tokens without a profit swap when the long token matches the dsa token', async () => { + (getSwapQuote as Mock) + .mockResolvedValueOnce({ + swapQuote: makeApproximateOutSwapQuote({ + fromToken: busd, + toToken: usdc, + fromTokenAmountSoldMantissa: 3_000_000_000_000_000_000n, + minimumToTokenAmountReceivedMantissa: 5_000_000n, + }), + }) + .mockResolvedValueOnce({ + swapQuote: makeExactInSwapQuote({ + fromToken: busd, + toToken: usdc, + fromTokenAmountSoldMantissa: 10_000_000_000_000_000_000n, + minimumToTokenAmountReceivedMantissa: 5_000_000n, + }), + }); + + const result = await getTradeReduceSwapQuotes({ + ...sharedInput, + dsaToken: busd, + closeFractionPercentage: 50, + }); + + expect(getSwapQuote).toHaveBeenCalledTimes(2); + expect(result.pnlDsaTokens.toFixed()).toBe('7'); + expect(result.profitSwapQuote).toBeUndefined(); + }); + + it('returns direct negative dsa pnl without a loss swap when the short token matches the dsa token', async () => { + (getSwapQuote as Mock) + .mockResolvedValueOnce({ + swapQuote: makeApproximateOutSwapQuote({ + fromToken: busd, + toToken: usdc, + fromTokenAmountSoldMantissa: 10_000_000_000_000_000_000n, + minimumToTokenAmountReceivedMantissa: 5_000_000n, + }), + }) + .mockResolvedValueOnce({ + swapQuote: makeExactInSwapQuote({ + fromToken: busd, + toToken: usdc, + fromTokenAmountSoldMantissa: 10_000_000_000_000_000_000n, + minimumToTokenAmountReceivedMantissa: 4_000_000n, + }), + }); + + const result = await getTradeReduceSwapQuotes({ + ...sharedInput, + dsaToken: usdc, + closeFractionPercentage: 50, + }); + + expect(getSwapQuote).toHaveBeenCalledTimes(2); + expect(result.pnlDsaTokens.toFixed()).toBe('-1'); + expect(result.lossSwapQuote).toBeUndefined(); + }); }); diff --git a/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/index.ts b/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/index.ts index f7bd4d0c1a..70464ec702 100644 --- a/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/index.ts +++ b/apps/evm/src/clients/api/queries/getTradeReduceSwapQuotes/index.ts @@ -163,11 +163,19 @@ export const getTradeReduceSwapQuotes = async ({ // Calculate actual PnL based on swaps let pnlDsaTokens = new BigNumber(0); - // Closing/Reducing with profit - if (profitSwapQuote?.direction === 'exact-in') { + // Closing/Reducing with profit when long token = DSA token + if (areTokensEqual(longToken, dsaToken) && longProfitAmountDeltaTokens?.isGreaterThan(0)) { + pnlDsaTokens = longProfitAmountDeltaTokens; + } + // Closing/Reducing with loss when short token = DSA token + else if (areTokensEqual(shortToken, dsaToken) && shortLossAmountDeltaTokens?.isGreaterThan(0)) { + pnlDsaTokens = shortLossAmountDeltaTokens.multipliedBy(-1); + } + // Closing/Reducing with profit when long token ≠ DSA token + else if (profitSwapQuote?.direction === 'exact-in') { pnlDsaTokens = getSwapToTokenAmount(profitSwapQuote); } - // Closing/Reducing with loss + // Closing/Reducing with loss when short token ≠ DSA token else if (lossSwapQuote?.direction === 'approximate-out') { pnlDsaTokens = convertMantissaToTokens({ value: lossSwapQuote.fromTokenAmountSoldMantissa, diff --git a/apps/evm/src/libs/errors/handleError/index.ts b/apps/evm/src/libs/errors/handleError/index.ts index b83c4249f5..d8af3db4bc 100644 --- a/apps/evm/src/libs/errors/handleError/index.ts +++ b/apps/evm/src/libs/errors/handleError/index.ts @@ -1,4 +1,4 @@ -import { BaseError } from 'viem'; +import type { BaseError } from 'viem'; import { displayNotification } from 'libs/notifications'; diff --git a/apps/evm/src/pages/Trade/ReduceForm/index.tsx b/apps/evm/src/pages/Trade/ReduceForm/index.tsx index cb6fb07dc8..4d0d10f697 100644 --- a/apps/evm/src/pages/Trade/ReduceForm/index.tsx +++ b/apps/evm/src/pages/Trade/ReduceForm/index.tsx @@ -219,6 +219,8 @@ export const ReduceForm: React.FC = ({ position, closePosition } const isReducingWithProfit = pnlDsaTokens?.isGreaterThan(0); + const isReducingWithLoss = pnlDsaTokens?.isLessThan(0); + const isReducingWithoutPnl = pnlDsaTokens?.isZero(); // Reduce with profit if ( @@ -251,44 +253,43 @@ export const ReduceForm: React.FC = ({ position, closePosition }); } - const isReducingWithLoss = - pnlDsaTokens?.isLessThan(0) && - repayWithLossSwapQuote?.direction === 'exact-in' && - lossSwapQuote?.direction === 'approximate-out'; - - const isReducingWithoutPnl = - pnlDsaTokens?.isZero() && repayWithLossSwapQuote?.direction === 'exact-in'; - const repayShortAmountMantissa = convertTokensToMantissa({ token: position.shortAsset.vToken.underlyingToken, value: debouncedShortAmountTokens, }); + const sanitizedLossSwapQuote = + lossSwapQuote?.direction === 'approximate-out' ? lossSwapQuote : undefined; + // Reduce with loss - if (isReducingWithLoss && !closePosition) { + if (isReducingWithLoss && !closePosition && repayWithLossSwapQuote?.direction === 'exact-in') { return reducePositionWithLoss({ longVTokenAddress: position.longAsset.vToken.address, shortVTokenAddress: position.shortAsset.vToken.address, closeFractionPercentage, repaySwapQuote: repayWithLossSwapQuote, - lossSwapQuote, + lossSwapQuote: sanitizedLossSwapQuote, repayShortAmountMantissa, }); } // Close with loss - if (isReducingWithLoss && closePosition) { + if (isReducingWithLoss && closePosition && repayWithLossSwapQuote?.direction === 'exact-in') { return closePositionWithLoss({ longVTokenAddress: position.longAsset.vToken.address, shortVTokenAddress: position.shortAsset.vToken.address, repaySwapQuote: repayWithLossSwapQuote, - lossSwapQuote, + lossSwapQuote: sanitizedLossSwapQuote, repayShortAmountMantissa, }); } // Reduce without PnL - if (isReducingWithoutPnl && !closePosition) { + if ( + isReducingWithoutPnl && + !closePosition && + repayWithLossSwapQuote?.direction === 'exact-in' + ) { return reducePositionWithLoss({ longVTokenAddress: position.longAsset.vToken.address, shortVTokenAddress: position.shortAsset.vToken.address, @@ -299,7 +300,7 @@ export const ReduceForm: React.FC = ({ position, closePosition } // Close without PnL - if (isReducingWithoutPnl && closePosition) { + if (isReducingWithoutPnl && closePosition && repayWithLossSwapQuote?.direction === 'exact-in') { return closePositionWithLoss({ longVTokenAddress: position.longAsset.vToken.address, shortVTokenAddress: position.shortAsset.vToken.address,