HardhatDeFi
15,000 USDC
View results
Submission Details
Severity: medium
Invalid

Incorrect redemption amount when position tokens are split

Summary

When a holder of position tokens sends an odd number of tokens to another address, then upon attempting to redeem, the latest address receives fewer collateral tokens than it is entitled to.

Vulnerability Details

The vulnerability occurs in the following scenario:

  1. A user creates a contingent pool and receives position tokens

  2. The user transfers an odd number of position tokens to another address

  3. When both addresses attempt to redeem their position tokens, the latest recipient receives fewer collateral tokens than they should

This is demonstrated in the proof of concept test which shows:

describe("proofOfConcept", async () => {
let s: SetupOutput;
const depositAmount = 10_000_000;
const expectedWithdrawalAmount = 9_970_000; // 30_000 is fees
beforeEach(async () => {
// Fetch the setup fixture.
s = await loadFixture(setup);
});
it("Should return the expected withdrawal amount when there is only one position token holder", async () => {
// ---------
// Act 1: Register collateral token.
// ---------
await s.aaveDIVAWrapper
.connect(s.owner)
.registerCollateralToken(collateralToken);
// ---------
// Act 2: Create contingent pool with small collatreal amount
// ---------
await s.aaveDIVAWrapper
.connect(s.impersonatedSigner)
.createContingentPool({
...s.createContingentPoolParams,
collateralAmount: depositAmount,
shortRecipient: s.impersonatedSigner.address
});
// Get poolId and pool parameters
const poolId = await getPoolIdFromAaveDIVAWrapperEvent(s.aaveDIVAWrapper);
const poolParams = await s.diva.getPoolParameters(poolId);
// ---------
// Act 3: Fast forward time and confirm pool to allow redemption
// ---------
const nextBlockTimestamp = Number(poolParams.expiryTime) + 1;
await mine(nextBlockTimestamp);
await s.diva
.connect(s.dataProvider)
.setFinalReferenceValue(poolId, parseUnits("10"), false);
// ---------
// Act 4: Redeem position tokens
// ---------
const longTokenContract = await ethers.getContractAt(
"IERC20",
poolParams.longToken
);
const shortTokenContract = await ethers.getContractAt(
"IERC20",
poolParams.shortToken
);
const longTokenBalance = await longTokenContract.balanceOf(
s.impersonatedSigner.address
);
// Redeem long position
const longRecipientBalanceBeforeRedeem =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner.address
);
await longTokenContract
.connect(s.impersonatedSigner)
.approve(s.aaveDIVAWrapper.target, longTokenBalance);
await s.aaveDIVAWrapper
.connect(s.impersonatedSigner)
.redeemPositionToken(
poolParams.longToken,
longTokenBalance,
s.impersonatedSigner.address
);
const longRecipientBalanceAfterRedeem =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner.address
);
const longDelta = longRecipientBalanceAfterRedeem - longRecipientBalanceBeforeRedeem
// Redeem short position
const acc1ShortTokenBalance = await shortTokenContract.balanceOf(
s.impersonatedSigner.address
);
// Redeem Short position by Acc1
const acc1BalanceBefore =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner
);
await shortTokenContract
.connect(s.impersonatedSigner)
.approve(s.aaveDIVAWrapper.target, acc1ShortTokenBalance);
await s.aaveDIVAWrapper
.connect(s.impersonatedSigner)
.redeemPositionToken(
poolParams.shortToken,
acc1ShortTokenBalance,
s.impersonatedSigner.address
);
const acc1BalanceAfter =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner
);
const acc1Delta = acc1BalanceAfter - acc1BalanceBefore
expect(longDelta + acc1Delta).eq(expectedWithdrawalAmount)
});
it("Should return the expected withdrawal amount when there are multiple position token holders", async () => {
// ---------
// Act 1: Register collateral token.
// ---------
await s.aaveDIVAWrapper
.connect(s.owner)
.registerCollateralToken(collateralToken);
// ---------
// Act 2: Create contingent pool with small collatreal amount
// ---------
await s.aaveDIVAWrapper
.connect(s.impersonatedSigner)
.createContingentPool({
...s.createContingentPoolParams,
collateralAmount: depositAmount,
shortRecipient: s.impersonatedSigner.address
});
// Get poolId and pool parameters
const poolId = await getPoolIdFromAaveDIVAWrapperEvent(s.aaveDIVAWrapper);
const poolParams = await s.diva.getPoolParameters(poolId);
// ---------
// Act 3: Fast forward time and confirm pool to allow redemption
// ---------
const nextBlockTimestamp = Number(poolParams.expiryTime) + 1;
await mine(nextBlockTimestamp);
await s.diva
.connect(s.dataProvider)
.setFinalReferenceValue(poolId, parseUnits("10"), false);
// ---------
// Act 4: Redeem position tokens
// ---------
const longTokenContract = await ethers.getContractAt(
"IERC20",
poolParams.longToken
);
const shortTokenContract = await ethers.getContractAt(
"IERC20",
poolParams.shortToken
);
const longTokenBalance = await longTokenContract.balanceOf(
s.impersonatedSigner.address
);
// Redeem long position
const longRecipientBalanceBeforeRedeem =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner.address
);
await longTokenContract
.connect(s.impersonatedSigner)
.approve(s.aaveDIVAWrapper.target, longTokenBalance);
await s.aaveDIVAWrapper
.connect(s.impersonatedSigner)
.redeemPositionToken(
poolParams.longToken,
longTokenBalance,
s.impersonatedSigner.address
);
const longRecipientBalanceAfterRedeem =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner.address
);
const longDelta = longRecipientBalanceAfterRedeem - longRecipientBalanceBeforeRedeem
// Redeem short position
// To send any amount to another address
await shortTokenContract
.connect(s.impersonatedSigner)
.transfer(s.acc2, 2497000)
// Redeem short position by Acc1
const acc1ShortTokenBalance = await shortTokenContract.balanceOf(
s.impersonatedSigner.address
);
const acc1BalanceBefore =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner
);
await shortTokenContract
.connect(s.impersonatedSigner)
.approve(s.aaveDIVAWrapper.target, acc1ShortTokenBalance);
await s.aaveDIVAWrapper
.connect(s.impersonatedSigner)
.redeemPositionToken(
poolParams.shortToken,
acc1ShortTokenBalance,
s.impersonatedSigner.address
);
const acc1BalanceAfter =
await s.collateralTokenContract.balanceOf(
s.impersonatedSigner
);
const acc1Delta = acc1BalanceAfter - acc1BalanceBefore
// Redeem Short position by Acc2
const acc2BalanceBefore =
await s.collateralTokenContract.balanceOf(
s.acc2
);
const acc2ShortTokenBalance = await shortTokenContract.balanceOf(
s.acc2.address
);
await shortTokenContract
.connect(s.acc2)
.approve(s.aaveDIVAWrapper.target, acc2ShortTokenBalance);
await s.aaveDIVAWrapper
.connect(s.acc2)
.redeemPositionToken(
poolParams.shortToken,
acc2ShortTokenBalance,
s.acc2.address
);
const acc2BalanceAfter =
await s.collateralTokenContract.balanceOf(
s.acc2
);
const acc2Delta = acc2BalanceAfter - acc2BalanceBefore
// AssertionError: expected 9969999 to equal 9970000.
// + expected - actual
// -9969999
// +9970000
expect(longDelta + acc1Delta + acc2Delta).eq(expectedWithdrawalAmount)
});
});

The issue arises due to rounding errors when calculating redemption amounts, specifically in the redeemPositionToken function of the DIVA Protocol.

Impact

Users who receive transferred position tokens may receive slightly less collateral tokens than they are entitled to when redeeming. While the amounts are small, this could accumulate to material losses if exploited at scale.

Tools Used

  • Manual code review

  • Performing formal verification with Quint

  • Hardhat test suite

Recommendations

Unfortunately, I was unable to find a suitable solution within the given time frame :(

Updates

Lead Judging Commences

bube Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Appeal created

0x180db Submitter
9 months ago
bube Lead Judge
9 months ago
bube Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.