Skip to content

Commit 3c8e79d

Browse files
refactor: mToken redemption vault balance checks
1 parent c50988f commit 3c8e79d

File tree

3 files changed

+122
-144
lines changed

3 files changed

+122
-144
lines changed

contracts/RedemptionVaultWithMToken.sol

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,6 @@ contract RedemptionVaultWithMToken is RedemptionVault {
142142
false
143143
);
144144

145-
_requireAndUpdateLimit(amountMTokenIn);
146-
147145
uint256 tokenDecimals = _tokenDecimals(tokenOut);
148146

149147
uint256 amountMTokenInCopy = amountMTokenIn;
@@ -158,9 +156,16 @@ contract RedemptionVaultWithMToken is RedemptionVault {
158156
tokenOutCopy
159157
);
160158

161-
_requireAndUpdateAllowance(tokenOutCopy, amountTokenOut);
159+
amountTokenOutWithoutFee = _truncate(
160+
(calcResult.amountMTokenWithoutFee * mTokenRate) / tokenOutRate,
161+
tokenDecimals
162+
);
163+
164+
require(
165+
amountTokenOutWithoutFee >= minReceiveAmountCopy,
166+
"RVMT: minReceiveAmount > actual"
167+
);
162168

163-
mToken.burn(user, calcResult.amountMTokenWithoutFee);
164169
if (calcResult.feeAmount > 0)
165170
_tokenTransferFromUser(
166171
address(mToken),
@@ -169,23 +174,26 @@ contract RedemptionVaultWithMToken is RedemptionVault {
169174
18
170175
);
171176

172-
uint256 amountTokenOutWithoutFeeFrom18 = ((calcResult
173-
.amountMTokenWithoutFee * mTokenRate) / tokenOutRate)
174-
.convertFromBase18(tokenDecimals);
175-
176-
amountTokenOutWithoutFee = amountTokenOutWithoutFeeFrom18
177-
.convertToBase18(tokenDecimals);
178-
179-
require(
180-
amountTokenOutWithoutFee >= minReceiveAmountCopy,
181-
"RVMT: minReceiveAmount > actual"
177+
uint256 contractTokenOutBalance = IERC20(tokenOutCopy).balanceOf(
178+
address(this)
182179
);
183180

184-
_checkAndRedeemMToken(
185-
tokenOutCopy,
186-
amountTokenOutWithoutFeeFrom18,
187-
tokenOutRate
188-
);
181+
_requireAndUpdateLimit(amountMTokenInCopy);
182+
_requireAndUpdateAllowance(tokenOutCopy, amountTokenOut);
183+
mToken.burn(user, calcResult.amountMTokenWithoutFee);
184+
185+
if (
186+
contractTokenOutBalance <
187+
amountTokenOutWithoutFee.convertFromBase18(tokenDecimals)
188+
) {
189+
amountTokenOutWithoutFee = _checkAndRedeemMToken(
190+
tokenOutCopy,
191+
amountTokenOutWithoutFee.convertFromBase18(tokenDecimals),
192+
calcResult.amountMTokenWithoutFee,
193+
mTokenRate,
194+
amountTokenOutWithoutFee
195+
);
196+
}
189197

190198
_tokenTransferToUser(
191199
tokenOutCopy,
@@ -202,33 +210,29 @@ contract RedemptionVaultWithMToken is RedemptionVault {
202210
* underlying asset to this contract
203211
* @param tokenOut tokenOut address
204212
* @param amountTokenOut amount of tokenOut needed (native decimals)
205-
* @param tokenOutRate tokenOut price rate (decimals 18)
213+
* @param amountMTokenIn amount of mToken to use for fallback redeem
214+
* @param mTokenRate mToken price rate (decimals 18)
215+
* @param minReceiveAmount minimum expected amount of tokenOut to receive (decimals 18)
206216
*/
207217
function _checkAndRedeemMToken(
208218
address tokenOut,
209219
uint256 amountTokenOut,
210-
uint256 tokenOutRate
211-
) internal {
220+
uint256 amountMTokenIn,
221+
uint256 mTokenRate,
222+
uint256 minReceiveAmount
223+
) internal returns (uint256 amountTokenOutWithoutFee) {
212224
uint256 contractBalanceTokenOut = IERC20(tokenOut).balanceOf(
213225
address(this)
214226
);
215-
if (contractBalanceTokenOut >= amountTokenOut) return;
216-
217-
uint256 missingAmount = amountTokenOut - contractBalanceTokenOut;
218227
uint256 tokenDecimals = _tokenDecimals(tokenOut);
228+
if (contractBalanceTokenOut >= amountTokenOut) {
229+
return amountTokenOut.convertToBase18(tokenDecimals);
230+
}
219231

220-
uint256 missingAmountBase18 = missingAmount.convertToBase18(
221-
tokenDecimals
222-
);
223232
uint256 mTokenARate = redemptionVault
224233
.mTokenDataFeed()
225234
.getDataInBase18();
226-
227-
uint256 mTokenAAmountNumerator = missingAmountBase18 * tokenOutRate;
228-
uint256 mTokenAAmount = mTokenAAmountNumerator / mTokenARate;
229-
if (mTokenAAmountNumerator % mTokenARate != 0) {
230-
mTokenAAmount += 1;
231-
}
235+
uint256 mTokenAAmount = (amountMTokenIn * mTokenRate) / mTokenARate;
232236

233237
address mTokenA = address(redemptionVault.mToken());
234238

@@ -245,7 +249,12 @@ contract RedemptionVaultWithMToken is RedemptionVault {
245249
redemptionVault.redeemInstant(
246250
tokenOut,
247251
mTokenAAmount,
248-
missingAmountBase18
252+
minReceiveAmount
253+
);
254+
uint256 contractTokenOutBalanceAfterRedeem = IERC20(tokenOut).balanceOf(
255+
address(this)
249256
);
257+
amountTokenOutWithoutFee = (contractTokenOutBalanceAfterRedeem -
258+
contractBalanceTokenOut).convertToBase18(tokenDecimals);
250259
}
251260
}

contracts/testers/RedemptionVaultWithMTokenTest.sol

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@ contract RedemptionVaultWithMTokenTest is RedemptionVaultWithMToken {
88

99
function checkAndRedeemMToken(
1010
address token,
11-
uint256 amount,
12-
uint256 rate
11+
uint256 amountTokenOut,
12+
uint256 amountMTokenIn,
13+
uint256 mTokenRate,
14+
uint256 minReceiveAmount
1315
) external {
14-
_checkAndRedeemMToken(token, amount, rate);
16+
_checkAndRedeemMToken(
17+
token,
18+
amountTokenOut,
19+
amountMTokenIn,
20+
mTokenRate,
21+
minReceiveAmount
22+
);
1523
}
1624
}

test/unit/RedemptionVaultWithMToken.test.ts

Lines changed: 67 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,7 @@ describe('RedemptionVaultWithMToken', function () {
696696
owner,
697697
dataFeed,
698698
redemptionVault,
699+
mFoneToUsdDataFeed,
699700
} = await loadFixture(defaultDeploy);
700701

701702
await addPaymentTokenTest(
@@ -719,12 +720,14 @@ describe('RedemptionVaultWithMToken', function () {
719720
const mTbillBefore = await mTBILL.balanceOf(
720721
redemptionVaultWithMToken.address,
721722
);
722-
const tokenOutRate = await dataFeed.getDataInBase18();
723+
const mFoneRate = await mFoneToUsdDataFeed.getDataInBase18();
723724

724725
await redemptionVaultWithMToken.checkAndRedeemMToken(
725726
stableCoins.dai.address,
726727
parseUnits('500', 9),
727-
tokenOutRate,
728+
parseUnits('1000'),
729+
mFoneRate,
730+
parseUnits('500'),
728731
);
729732

730733
const mTbillAfter = await mTBILL.balanceOf(
@@ -741,6 +744,7 @@ describe('RedemptionVaultWithMToken', function () {
741744
owner,
742745
dataFeed,
743746
redemptionVault,
747+
mFoneToUsdDataFeed,
744748
} = await loadFixture(defaultDeploy);
745749

746750
await addPaymentTokenTest(
@@ -765,12 +769,14 @@ describe('RedemptionVaultWithMToken', function () {
765769
const mTbillBefore = await mTBILL.balanceOf(
766770
redemptionVaultWithMToken.address,
767771
);
768-
const tokenOutRate = await dataFeed.getDataInBase18();
772+
const mFoneRate = await mFoneToUsdDataFeed.getDataInBase18();
769773

770774
await redemptionVaultWithMToken.checkAndRedeemMToken(
771775
stableCoins.dai.address,
772776
parseUnits('1000', 9),
773-
tokenOutRate,
777+
parseUnits('1000'),
778+
mFoneRate,
779+
parseUnits('1000'),
774780
);
775781

776782
const mTbillAfter = await mTBILL.balanceOf(
@@ -791,6 +797,7 @@ describe('RedemptionVaultWithMToken', function () {
791797
owner,
792798
dataFeed,
793799
redemptionVault,
800+
mFoneToUsdDataFeed,
794801
} = await loadFixture(defaultDeploy);
795802

796803
await addPaymentTokenTest(
@@ -808,115 +815,18 @@ describe('RedemptionVaultWithMToken', function () {
808815
true,
809816
);
810817

811-
const tokenOutRate = await dataFeed.getDataInBase18();
818+
const mFoneRate = await mFoneToUsdDataFeed.getDataInBase18();
812819

813820
await expect(
814821
redemptionVaultWithMToken.checkAndRedeemMToken(
815822
stableCoins.dai.address,
816823
parseUnits('1000', 9),
817-
tokenOutRate,
824+
parseUnits('1000'),
825+
mFoneRate,
826+
parseUnits('1000'),
818827
),
819828
).to.be.revertedWith('RVMT: insufficient mToken balance');
820829
});
821-
822-
it('should succeed with truncation-prone rates (ceil rounding)', async () => {
823-
const {
824-
redemptionVaultWithMToken,
825-
stableCoins,
826-
mTBILL,
827-
owner,
828-
dataFeed,
829-
redemptionVault,
830-
mockedAggregatorMToken,
831-
} = await loadFixture(defaultDeploy);
832-
833-
await addPaymentTokenTest(
834-
{ vault: redemptionVaultWithMToken, owner },
835-
stableCoins.dai,
836-
dataFeed.address,
837-
0,
838-
true,
839-
);
840-
await addPaymentTokenTest(
841-
{ vault: redemptionVault, owner },
842-
stableCoins.dai,
843-
dataFeed.address,
844-
0,
845-
true,
846-
);
847-
848-
// Set mTBILL rate to 3 — causes (amount * 1e18) / 3e18 to have remainder
849-
await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 3);
850-
851-
await mintToken(mTBILL, redemptionVaultWithMToken, 100_000);
852-
await mintToken(stableCoins.dai, redemptionVault, 1_000_000);
853-
854-
// Use STABLECOIN_RATE (1e18) — matches what _redeemInstant passes
855-
// for stable tokens via _convertUsdToToken, NOT the data feed rate
856-
// (which is 1.02e18). The inner vault also uses STABLECOIN_RATE for
857-
// stable tokens, so both sides see the same rate and ceil rounding matters.
858-
const tokenOutRate = parseUnits('1', 18);
859-
860-
// Without ceil rounding, the inner vault reverts because:
861-
// mTokenAAmount = (1000e18 * 1e18) / 3e18 = 333...333 (truncated)
862-
// Inner vault: _truncate((333...333 * 3e18) / 1e18, 9) = 999.999999999e18 < 1000e18
863-
await redemptionVaultWithMToken.checkAndRedeemMToken(
864-
stableCoins.dai.address,
865-
parseUnits('1000', 9),
866-
tokenOutRate,
867-
);
868-
869-
const daiAfter = await stableCoins.dai.balanceOf(
870-
redemptionVaultWithMToken.address,
871-
);
872-
expect(daiAfter).to.be.gte(parseUnits('1000', 9));
873-
});
874-
875-
it('should not over-redeem when division is exact', async () => {
876-
const {
877-
redemptionVaultWithMToken,
878-
stableCoins,
879-
mTBILL,
880-
owner,
881-
dataFeed,
882-
redemptionVault,
883-
mockedAggregatorMToken,
884-
} = await loadFixture(defaultDeploy);
885-
886-
await addPaymentTokenTest(
887-
{ vault: redemptionVaultWithMToken, owner },
888-
stableCoins.dai,
889-
dataFeed.address,
890-
0,
891-
true,
892-
);
893-
await addPaymentTokenTest(
894-
{ vault: redemptionVault, owner },
895-
stableCoins.dai,
896-
dataFeed.address,
897-
0,
898-
true,
899-
);
900-
901-
// 1000 * 1e18 / 2e18 = 500 exactly, so no +1 should be applied.
902-
await setRoundData({ mockedAggregator: mockedAggregatorMToken }, 2);
903-
904-
// If rounding is exact ceil, 500 mTBILL is enough. With unconditional +1, this reverts.
905-
await mintToken(mTBILL, redemptionVaultWithMToken, 500);
906-
await mintToken(stableCoins.dai, redemptionVault, 1_000_000);
907-
908-
const tokenOutRate = parseUnits('1', 18);
909-
await redemptionVaultWithMToken.checkAndRedeemMToken(
910-
stableCoins.dai.address,
911-
parseUnits('1000', 9),
912-
tokenOutRate,
913-
);
914-
915-
const daiAfter = await stableCoins.dai.balanceOf(
916-
redemptionVaultWithMToken.address,
917-
);
918-
expect(daiAfter).to.be.gte(parseUnits('1000', 9));
919-
});
920830
});
921831

922832
describe('redeemInstant()', () => {
@@ -1098,7 +1008,7 @@ describe('RedemptionVaultWithMToken', function () {
10981008
stableCoins.dai,
10991009
100,
11001010
{
1101-
revertMessage: 'ERC20: burn amount exceeds balance',
1011+
revertMessage: 'ERC20: transfer amount exceeds balance',
11021012
},
11031013
);
11041014
});
@@ -1745,6 +1655,57 @@ describe('RedemptionVaultWithMToken', function () {
17451655
);
17461656
});
17471657

1658+
it('redeem 100 mFONE with divergent rates (mFONE=$5, mTBILL=$2) => triggers mTBILL redemption', async () => {
1659+
const {
1660+
owner,
1661+
redemptionVaultWithMToken,
1662+
stableCoins,
1663+
mTBILL,
1664+
mFONE,
1665+
mTokenToUsdDataFeed,
1666+
mFoneToUsdDataFeed,
1667+
dataFeed,
1668+
redemptionVault,
1669+
mockedAggregatorMFone,
1670+
} = await loadFixture(defaultDeploy);
1671+
1672+
await addPaymentTokenTest(
1673+
{ vault: redemptionVaultWithMToken, owner },
1674+
stableCoins.dai,
1675+
dataFeed.address,
1676+
0,
1677+
true,
1678+
);
1679+
await addPaymentTokenTest(
1680+
{ vault: redemptionVault, owner },
1681+
stableCoins.dai,
1682+
dataFeed.address,
1683+
0,
1684+
true,
1685+
);
1686+
1687+
await setRoundData({ mockedAggregator: mockedAggregatorMFone }, 5);
1688+
1689+
await mintToken(mFONE, owner, 100_000);
1690+
await mintToken(mTBILL, redemptionVaultWithMToken, 100_000);
1691+
await mintToken(stableCoins.dai, redemptionVault, 1_000_000);
1692+
await approveBase18(owner, mFONE, redemptionVaultWithMToken, 100_000);
1693+
1694+
await redeemInstantWithMTokenTest(
1695+
{
1696+
redemptionVaultWithMToken,
1697+
owner,
1698+
mTBILL,
1699+
mFONE,
1700+
mFoneToUsdDataFeed,
1701+
mTokenToUsdDataFeed,
1702+
useMTokenSleeve: true,
1703+
},
1704+
stableCoins.dai,
1705+
100,
1706+
);
1707+
});
1708+
17481709
it('redeem with waived fee', async () => {
17491710
const {
17501711
owner,

0 commit comments

Comments
 (0)