Skip to content

Commit f4a2e4a

Browse files
committed
fix: convertDynamicToFixed prioritizes interest then principal deduction
Amount now clears interest first, then principal. New fixed position principal <= amount. Also fix constructor args after rebase and add exact-full and excess-capped conversion tests.
1 parent f66cc8c commit f4a2e4a

4 files changed

Lines changed: 139 additions & 37 deletions

File tree

script/broker/deploy_broker_20260408.s.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ contract DeployXautBrokers is DeployBase {
5555

5656
function _deployBroker(string memory label, address relayer, address oracle, address deployer) internal {
5757
// Deploy implementation
58-
LendingBroker impl = new LendingBroker(MOOLAH, relayer, oracle, address(0));
58+
LendingBroker impl = new LendingBroker(MOOLAH, address(0));
5959
console.log(string.concat("LendingBroker(", label, ") impl: "), address(impl));
6060

6161
// Deploy proxy

src/broker/LendingBroker.sol

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -439,15 +439,16 @@ contract LendingBroker is
439439
address user = msg.sender;
440440
DynamicLoanPosition storage position = dynamicLoanPositions[user];
441441
if (fixedLoanPositions[user].length >= maxFixedLoanPositions) revert ExceedMaxFixedPositions();
442-
// cap amount by principal
443-
amount = UtilsLib.min(amount, position.principal);
444442
// accrue current rate so normalized debt reflects the latest interest
445443
uint256 rate = IRateCalculator(rateCalculator).accrueRate(address(this));
446444
uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(position.normalizedDebt, rate);
447445
uint256 totalInterest = actualDebt.zeroFloorSub(position.principal);
448446

449-
// force user to repay interest portion when converting to fixed
450-
uint256 interestToRepay = BrokerMath.mulDivCeiling(amount, totalInterest, position.principal);
447+
// prioritize clearing interest first, then principal
448+
uint256 interestToRepay = UtilsLib.min(amount, totalInterest);
449+
uint256 principalToMove = UtilsLib.min(amount - interestToRepay, position.principal);
450+
amount = interestToRepay + principalToMove;
451+
451452
if (interestToRepay > 0) {
452453
// borrow from Moolah to increase user's actual debt at moolah
453454
_borrowFromMoolah(user, interestToRepay);
@@ -456,9 +457,9 @@ contract LendingBroker is
456457
}
457458

458459
position.normalizedDebt = position.normalizedDebt.zeroFloorSub(
459-
BrokerMath.normalizeBorrowAmount(amount + interestToRepay, rate, false)
460+
BrokerMath.normalizeBorrowAmount(amount, rate, false)
460461
);
461-
position.principal -= amount;
462+
position.principal -= principalToMove;
462463

463464
if (position.principal == 0) {
464465
delete dynamicLoanPositions[user];
@@ -473,7 +474,7 @@ contract LendingBroker is
473474
fixedLoanPositions[user].push(
474475
FixedLoanPosition({
475476
posId: fixedPosUuid,
476-
principal: amount + interestToRepay,
477+
principal: amount,
477478
apr: term.apr,
478479
start: start,
479480
end: end,

test/broker/LendingBroker.t.sol

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,20 @@ contract LendingBrokerTest is Test {
181181
);
182182
broker = LendingBroker(payable(address(bProxy)));
183183

184-
LendingBroker bnbImpl = new LendingBroker(address(moolah), address(bnbRelayer), address(oracle), address(WBNB));
184+
LendingBroker bnbImpl = new LendingBroker(address(moolah), address(WBNB));
185185
ERC1967Proxy bnbProxy = new ERC1967Proxy(
186186
address(bnbImpl),
187-
abi.encodeWithSelector(LendingBroker.initialize.selector, ADMIN, MANAGER, BOT, PAUSER, address(rateCalc), 10)
187+
abi.encodeWithSelector(
188+
LendingBroker.initialize.selector,
189+
ADMIN,
190+
MANAGER,
191+
BOT,
192+
PAUSER,
193+
address(rateCalc),
194+
10,
195+
address(bnbRelayer),
196+
address(oracle)
197+
)
188198
);
189199
bnbBroker = LendingBroker(payable(address(bnbProxy)));
190200

@@ -605,15 +615,11 @@ contract LendingBrokerTest is Test {
605615
uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate);
606616
uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0;
607617

618+
// amount > interest => interest fully cleared, remainder moves principal
608619
uint256 convertAmount = 400 ether;
609-
uint256 expectedInterestShare = outstandingInterest == 0
610-
? 0
611-
: BrokerMath.mulDivCeiling(outstandingInterest, convertAmount, principalBefore);
612-
uint256 expectedNormalizedDelta = BrokerMath.normalizeBorrowAmount(
613-
convertAmount + expectedInterestShare,
614-
rate,
615-
true
616-
);
620+
uint256 expectedInterest = outstandingInterest < convertAmount ? outstandingInterest : convertAmount;
621+
uint256 expectedPrincipalMove = convertAmount - expectedInterest;
622+
uint256 expectedNormalizedDelta = BrokerMath.normalizeBorrowAmount(convertAmount, rate, true);
617623
uint256 expectedNormalizedAfter = normalizedBefore > expectedNormalizedDelta
618624
? normalizedBefore - expectedNormalizedDelta
619625
: 0;
@@ -622,12 +628,12 @@ contract LendingBrokerTest is Test {
622628
broker.convertDynamicToFixed(convertAmount, 51);
623629

624630
(uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower);
625-
assertEq(principalAfter, principalBefore - convertAmount, "dynamic principal not reduced by amount");
631+
assertEq(principalAfter, principalBefore - expectedPrincipalMove, "dynamic principal not reduced correctly");
626632
assertApproxEqAbs(normalizedAfter, expectedNormalizedAfter, 1, "normalized debt delta mismatch");
627633

628634
FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower);
629635
assertEq(fixedPositions.length, 1, "fixed position not created");
630-
assertEq(fixedPositions[0].principal, convertAmount + expectedInterestShare, "converted fixed principal incorrect");
636+
assertEq(fixedPositions[0].principal, convertAmount, "fixed principal should equal amount");
631637
assertEq(fixedPositions[0].interestRepaid, 0);
632638
assertEq(fixedPositions[0].principalRepaid, 0);
633639
}
@@ -651,26 +657,91 @@ contract LendingBrokerTest is Test {
651657
uint256 rate = rateCalc.accrueRate(address(broker));
652658
uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate);
653659
uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0;
654-
uint256 expectedNormalizedDelta = BrokerMath.normalizeBorrowAmount(actualDebt, rate, true);
655660

661+
// pass amount > actualDebt => capped to interest + principal
656662
vm.prank(borrower);
657-
broker.convertDynamicToFixed(principalBefore, 52);
663+
broker.convertDynamicToFixed(actualDebt + 100 ether, 52);
658664

659665
(uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower);
660666
assertApproxEqAbs(principalAfter, 0, 1, "dynamic principal should be cleared");
661667
assertApproxEqAbs(normalizedAfter, 0, 1, "dynamic normalized debt should be cleared");
662668

663669
FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower);
664670
assertEq(fixedPositions.length, 1);
665-
assertApproxEqAbs(
666-
fixedPositions[0].principal,
667-
principalBefore + outstandingInterest,
668-
1,
669-
"fixed principal should equal full outstanding debt"
670-
);
671+
uint256 expectedFixed = outstandingInterest + principalBefore;
672+
assertApproxEqAbs(fixedPositions[0].principal, expectedFixed, 1, "fixed principal should equal full debt");
673+
}
674+
675+
function test_convertDynamicToFixed_exactFullAmount() public {
676+
FixedTermAndRate memory term = FixedTermAndRate({ termId: 53, duration: 60 days, apr: 105 * 1e25 });
677+
vm.prank(BOT);
678+
broker.updateFixedTermAndRate(term, false);
679+
680+
uint256 borrowAmt = 500 ether;
681+
vm.prank(borrower);
682+
broker.borrow(borrowAmt);
683+
684+
vm.prank(MANAGER);
685+
rateCalc.setMaxRatePerSecond(address(broker), RATE_SCALE + 5);
686+
vm.prank(BOT);
687+
rateCalc.setRatePerSecond(address(broker), RATE_SCALE + 3);
688+
skip(4 days);
671689

672-
// sanity: normalized delta consumed the whole normalized debt (allowing rounding wiggle)
673-
assertApproxEqAbs(expectedNormalizedDelta, normalizedBefore, 1, "normalized debt delta rounding");
690+
(uint256 principalBefore, uint256 normalizedBefore) = broker.dynamicLoanPositions(borrower);
691+
uint256 rate = rateCalc.accrueRate(address(broker));
692+
uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate);
693+
uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0;
694+
695+
// amount == interest + principal exactly
696+
uint256 convertAmount = outstandingInterest + principalBefore;
697+
698+
vm.prank(borrower);
699+
broker.convertDynamicToFixed(convertAmount, 53);
700+
701+
(uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower);
702+
assertApproxEqAbs(principalAfter, 0, 1, "dynamic principal should be cleared");
703+
assertApproxEqAbs(normalizedAfter, 0, 1, "dynamic normalized debt should be cleared");
704+
705+
FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower);
706+
assertEq(fixedPositions.length, 1);
707+
assertEq(fixedPositions[0].principal, convertAmount, "fixed principal should equal amount");
708+
}
709+
710+
function test_convertDynamicToFixed_excessAmountCapped() public {
711+
FixedTermAndRate memory term = FixedTermAndRate({ termId: 54, duration: 30 days, apr: 105 * 1e25 });
712+
vm.prank(BOT);
713+
broker.updateFixedTermAndRate(term, false);
714+
715+
uint256 borrowAmt = 600 ether;
716+
vm.prank(borrower);
717+
broker.borrow(borrowAmt);
718+
719+
vm.prank(MANAGER);
720+
rateCalc.setMaxRatePerSecond(address(broker), RATE_SCALE + 5);
721+
vm.prank(BOT);
722+
rateCalc.setRatePerSecond(address(broker), RATE_SCALE + 3);
723+
skip(3 days);
724+
725+
(uint256 principalBefore, uint256 normalizedBefore) = broker.dynamicLoanPositions(borrower);
726+
uint256 rate = rateCalc.accrueRate(address(broker));
727+
uint256 actualDebt = BrokerMath.denormalizeBorrowAmount(normalizedBefore, rate);
728+
uint256 outstandingInterest = actualDebt > principalBefore ? actualDebt - principalBefore : 0;
729+
730+
// amount much larger than actualDebt => should be capped
731+
uint256 convertAmount = actualDebt + 999 ether;
732+
733+
vm.prank(borrower);
734+
broker.convertDynamicToFixed(convertAmount, 54);
735+
736+
(uint256 principalAfter, uint256 normalizedAfter) = broker.dynamicLoanPositions(borrower);
737+
assertApproxEqAbs(principalAfter, 0, 1, "dynamic principal should be cleared");
738+
assertApproxEqAbs(normalizedAfter, 0, 1, "dynamic normalized debt should be cleared");
739+
740+
FixedLoanPosition[] memory fixedPositions = broker.userFixedPositions(borrower);
741+
assertEq(fixedPositions.length, 1);
742+
uint256 expectedFixed = outstandingInterest + principalBefore;
743+
assertApproxEqAbs(fixedPositions[0].principal, expectedFixed, 1, "fixed principal capped to actual debt");
744+
assertLe(fixedPositions[0].principal, convertAmount, "fixed principal must be <= amount");
674745
}
675746

676747
// -----------------------------
@@ -1759,7 +1830,7 @@ contract LendingBrokerTest is Test {
17591830

17601831
/// @dev Deploy a fresh broker proxy with RELAYER/ORACLE unset (simulating V1->V2 upgrade)
17611832
function _deployBrokerWithEmptyRelayerOracle() internal returns (LendingBroker) {
1762-
LendingBroker bImpl = new LendingBroker(address(moolah));
1833+
LendingBroker bImpl = new LendingBroker(address(moolah), address(0));
17631834
// Use 6-param initialize (no relayer/oracle) by encoding only the original params
17641835
// and leaving RELAYER/ORACLE as address(0)
17651836
ERC1967Proxy bProxy = new ERC1967Proxy(

test/utils/PositionManager.t.sol

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,20 @@ contract PositionManagerTest is Test {
230230
rateCalc = RateCalculator(address(rcProxy));
231231

232232
// ── Deploy inBroker (LendingBroker for the fixed-term market) ────────────
233-
LendingBroker bImpl = new LendingBroker(address(moolah), address(relayer), address(oracle), address(wbnb));
233+
LendingBroker bImpl = new LendingBroker(address(moolah), address(wbnb));
234234
ERC1967Proxy bProxy = new ERC1967Proxy(
235235
address(bImpl),
236-
abi.encodeWithSelector(LendingBroker.initialize.selector, admin, manager, bot, pauser, address(rateCalc), 100)
236+
abi.encodeWithSelector(
237+
LendingBroker.initialize.selector,
238+
admin,
239+
manager,
240+
bot,
241+
pauser,
242+
address(rateCalc),
243+
100,
244+
address(relayer),
245+
address(oracle)
246+
)
237247
);
238248
inBroker = LendingBroker(payable(address(bProxy)));
239249

@@ -312,10 +322,20 @@ contract PositionManagerTest is Test {
312322

313323
// ── Deploy inBrokerSlis ───────────────────────────────────────────────────
314324
{
315-
LendingBroker bSlisImpl = new LendingBroker(address(moolah), address(relayer), address(oracle), address(wbnb));
325+
LendingBroker bSlisImpl = new LendingBroker(address(moolah), address(wbnb));
316326
ERC1967Proxy bSlisProxy = new ERC1967Proxy(
317327
address(bSlisImpl),
318-
abi.encodeWithSelector(LendingBroker.initialize.selector, admin, manager, bot, pauser, address(rateCalc), 100)
328+
abi.encodeWithSelector(
329+
LendingBroker.initialize.selector,
330+
admin,
331+
manager,
332+
bot,
333+
pauser,
334+
address(rateCalc),
335+
100,
336+
address(relayer),
337+
address(oracle)
338+
)
319339
);
320340
inBrokerSlis = LendingBroker(payable(address(bSlisProxy)));
321341
}
@@ -399,10 +419,20 @@ contract PositionManagerTest is Test {
399419

400420
// ── Deploy inBrokerNative ─────────────────────────────────────────────────
401421
{
402-
LendingBroker bNativeImpl = new LendingBroker(address(moolah), address(relayer), address(oracle), address(wbnb));
422+
LendingBroker bNativeImpl = new LendingBroker(address(moolah), address(wbnb));
403423
ERC1967Proxy bNativeProxy = new ERC1967Proxy(
404424
address(bNativeImpl),
405-
abi.encodeWithSelector(LendingBroker.initialize.selector, admin, manager, bot, pauser, address(rateCalc), 10)
425+
abi.encodeWithSelector(
426+
LendingBroker.initialize.selector,
427+
admin,
428+
manager,
429+
bot,
430+
pauser,
431+
address(rateCalc),
432+
10,
433+
address(relayer),
434+
address(oracle)
435+
)
406436
);
407437
inBrokerNative = LendingBroker(payable(address(bNativeProxy)));
408438
}

0 commit comments

Comments
 (0)