Skip to content

Commit 9940a55

Browse files
authored
Merge pull request #226 from lista-dao/feature/broker-relayer-protocol-fee
feat(broker): charge 10% protocol fee on broker revenue via relayer
2 parents 9ffbb47 + 1c9767e commit 9940a55

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

src/broker/BrokerInterestRelayer.sol

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol
88
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
99
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
1010
import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
11+
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
1112

1213
import { Id, IMoolah, MarketParams, Market, Position } from "../moolah/interfaces/IMoolah.sol";
1314
import { IBrokerBase } from "./interfaces/IBroker.sol";
@@ -32,6 +33,14 @@ contract BrokerInterestRelayer is
3233
// ------- Roles -------
3334
bytes32 public constant MANAGER = keccak256("MANAGER");
3435

36+
// ------- Constants -------
37+
/// @dev WAD scaling for the fee rate (1e18 == 100%), matching Moolah's market fee semantics
38+
uint256 public constant WAD = 1e18;
39+
/// @dev maximum protocol fee rate (25%), mirroring Moolah's MAX_FEE
40+
uint256 public constant MAX_FEE = 0.25e18;
41+
/// @dev default protocol fee rate (10%) applied by initializeV2
42+
uint256 public constant DEFAULT_FEE_RATE = 0.1e18;
43+
3544
// ------- State variables -------
3645
/// @dev Moolah contract
3746
IMoolah public MOOLAH;
@@ -42,6 +51,12 @@ contract BrokerInterestRelayer is
4251
/// @dev vault token
4352
address public token;
4453

54+
// --- V2 storage (appended to preserve layout) ---
55+
/// @dev protocol fee rate charged on broker revenue (interest + penalty), WAD-scaled
56+
uint256 public feeRate;
57+
/// @dev recipient of the protocol fee
58+
address public feeRecipient;
59+
4560
// ------- Modifiers -------
4661
modifier onlyBroker() {
4762
require(brokers.contains(msg.sender), "relayer/not-broker");
@@ -87,6 +102,23 @@ contract BrokerInterestRelayer is
87102
token = _token;
88103
}
89104

105+
/**
106+
* @dev V2 reinitializer: enable the protocol fee on broker revenue at the default 10% rate.
107+
* Must be called atomically via `upgradeToAndCall` by the DEFAULT_ADMIN_ROLE (timelock),
108+
* so the config is set in the same tx the new implementation is wired in.
109+
* The rate can be changed afterwards via `setFeeRate` (MANAGER).
110+
* @param _feeRecipient The recipient of the protocol fee
111+
*/
112+
function initializeV2(address _feeRecipient) external reinitializer(2) onlyRole(DEFAULT_ADMIN_ROLE) {
113+
require(_feeRecipient != address(0), "relayer/zero-address-provided");
114+
115+
feeRate = DEFAULT_FEE_RATE;
116+
feeRecipient = _feeRecipient;
117+
118+
emit SetFeeRate(0, DEFAULT_FEE_RATE);
119+
emit SetFeeRecipient(address(0), _feeRecipient);
120+
}
121+
90122
///////////////////////////////////////
91123
///// External functions /////
92124
///////////////////////////////////////
@@ -100,6 +132,14 @@ contract BrokerInterestRelayer is
100132
// transfer interest from broker
101133
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
102134

135+
// skim the protocol fee on incoming revenue (interest + penalty) before it accrues to the vault.
136+
// Fee is floored so rounding dust stays with the vault (suppliers), never the fee recipient.
137+
uint256 fee = Math.mulDiv(amount, feeRate, WAD, Math.Rounding.Floor);
138+
if (fee > 0 && feeRecipient != address(0)) {
139+
IERC20(token).safeTransfer(feeRecipient, fee);
140+
emit ProtocolFeeCharged(msg.sender, feeRecipient, fee);
141+
}
142+
103143
// get minLoan
104144
uint256 minLoan = MOOLAH.minLoan(MOOLAH.idToMarketParams(IBrokerBase(msg.sender).MARKET_ID()));
105145

@@ -161,6 +201,28 @@ contract BrokerInterestRelayer is
161201
emit RemovedBroker(broker);
162202
}
163203

204+
/**
205+
* @dev Set the protocol fee rate charged on broker revenue (interest + penalty)
206+
* @param _feeRate The new fee rate, WAD-scaled (1e18 == 100%)
207+
*/
208+
function setFeeRate(uint256 _feeRate) external override onlyRole(MANAGER) {
209+
require(_feeRate <= MAX_FEE, "relayer/max-fee-exceeded");
210+
require(_feeRate != feeRate, "broker/same-value-provided");
211+
emit SetFeeRate(feeRate, _feeRate);
212+
feeRate = _feeRate;
213+
}
214+
215+
/**
216+
* @dev Set the recipient of the protocol fee
217+
* @param _feeRecipient The new fee recipient
218+
*/
219+
function setFeeRecipient(address _feeRecipient) external override onlyRole(MANAGER) {
220+
require(_feeRecipient != address(0), "relayer/zero-address-provided");
221+
require(_feeRecipient != feeRecipient, "broker/same-value-provided");
222+
emit SetFeeRecipient(feeRecipient, _feeRecipient);
223+
feeRecipient = _feeRecipient;
224+
}
225+
164226
/// @dev only callable by the DEFAULT_ADMIN_ROLE (must be a TimeLock contract)
165227
function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) {}
166228
}

src/broker/interfaces/IBrokerInterestRelayer.sol

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,25 @@ interface IBrokerInterestRelayer {
1010
*/
1111
function supplyToVault(uint256 amount) external;
1212

13+
/**
14+
* @dev Set the protocol fee rate charged on broker revenue (interest + penalty).
15+
* @param _feeRate The new fee rate, WAD-scaled (1e18 == 100%)
16+
*/
17+
function setFeeRate(uint256 _feeRate) external;
18+
19+
/**
20+
* @dev Set the recipient of the protocol fee.
21+
* @param _feeRecipient The new fee recipient
22+
*/
23+
function setFeeRecipient(address _feeRecipient) external;
24+
1325
/// @dev ------- Events
1426
event AddedBroker(address indexed broker);
1527
event RemovedBroker(address indexed broker);
1628
event InterestAccumulated(address indexed broker, uint256 amount);
1729
event SuppliedToMoolahVault(uint256 amount);
30+
/// @dev protocol fee skimmed from a broker's supplied revenue
31+
event ProtocolFeeCharged(address indexed broker, address indexed feeRecipient, uint256 fee);
32+
event SetFeeRate(uint256 oldFeeRate, uint256 newFeeRate);
33+
event SetFeeRecipient(address oldFeeRecipient, address newFeeRecipient);
1834
}

test/broker/LendingBroker.t.sol

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2986,6 +2986,163 @@ contract LendingBrokerTest is Test {
29862986
Position memory posAfter = moolah.position(marketParams.id(), borrower);
29872987
assertLt(posAfter.borrowShares, posBefore.borrowShares, "clean shares should liquidate");
29882988
}
2989+
2990+
// ==========================================================================
2991+
// Relayer protocol fee: 10% cut on broker revenue (interest + penalty)
2992+
// ==========================================================================
2993+
2994+
uint256 constant FEE_WAD = 1e18;
2995+
address constant FEE_RECIPIENT = address(0xFEE);
2996+
2997+
/// @dev enable the protocol fee on the LISUSD relayer via the V2 reinitializer (rate hardcoded to 10%)
2998+
function _enableRelayerFee(address recipient) internal {
2999+
vm.prank(ADMIN);
3000+
relayer.initializeV2(recipient);
3001+
}
3002+
3003+
function test_relayer_initializeV2_setsFeeConfig() public {
3004+
_enableRelayerFee(FEE_RECIPIENT);
3005+
assertEq(relayer.feeRate(), relayer.DEFAULT_FEE_RATE(), "feeRate not defaulted to 10%");
3006+
assertEq(relayer.feeRate(), 0.1e18, "feeRate not 10%");
3007+
assertEq(relayer.feeRecipient(), FEE_RECIPIENT, "feeRecipient not set");
3008+
}
3009+
3010+
function test_relayer_initializeV2_onlyAdmin() public {
3011+
vm.prank(MANAGER); // MANAGER lacks DEFAULT_ADMIN_ROLE
3012+
vm.expectRevert();
3013+
relayer.initializeV2(FEE_RECIPIENT);
3014+
}
3015+
3016+
function test_relayer_initializeV2_cannotRunTwice() public {
3017+
_enableRelayerFee(FEE_RECIPIENT);
3018+
vm.prank(ADMIN);
3019+
vm.expectRevert(); // reinitializer(2) already consumed
3020+
relayer.initializeV2(FEE_RECIPIENT);
3021+
}
3022+
3023+
function test_relayer_initializeV2_rejectsZeroRecipient() public {
3024+
vm.prank(ADMIN);
3025+
vm.expectRevert(bytes("relayer/zero-address-provided"));
3026+
relayer.initializeV2(address(0));
3027+
}
3028+
3029+
/// @dev the fee is floored: recipient receives exactly floor(amount * feeRate / WAD),
3030+
/// the remainder stays in the relayer/vault flow.
3031+
function test_relayer_supplyToVault_skimsFee() public {
3032+
_enableRelayerFee(FEE_RECIPIENT);
3033+
3034+
uint256 amount = 1000 ether;
3035+
uint256 expectedFee = (amount * 0.1e18) / FEE_WAD; // 100 ether
3036+
3037+
uint256 before = LISUSD.balanceOf(FEE_RECIPIENT);
3038+
_triggerInterestFlush(amount);
3039+
uint256 received = LISUSD.balanceOf(FEE_RECIPIENT) - before;
3040+
3041+
assertEq(received, expectedFee, "fee recipient did not receive 10%");
3042+
}
3043+
3044+
/// @dev with no fee configured (default state) the relayer behaves exactly as before.
3045+
function test_relayer_supplyToVault_noFeeWhenDisabled() public {
3046+
uint256 before = LISUSD.balanceOf(FEE_RECIPIENT);
3047+
_triggerInterestFlush(1000 ether);
3048+
assertEq(LISUSD.balanceOf(FEE_RECIPIENT), before, "no fee should be taken when disabled");
3049+
assertEq(relayer.feeRate(), 0, "feeRate should default to 0");
3050+
}
3051+
3052+
/// @dev end-to-end: repaying a fixed position early routes 10% of both the accrued
3053+
/// interest and the early-repay penalty to the fee recipient.
3054+
function test_relayer_fixedRepay_feeOnInterestAndPenalty() public {
3055+
_enableRelayerFee(FEE_RECIPIENT);
3056+
3057+
FixedTermAndRate memory term = FixedTermAndRate({ termId: 30, duration: 30 days, apr: 110 * 1e25 });
3058+
vm.prank(BOT);
3059+
broker.updateFixedTermAndRate(term, false);
3060+
3061+
uint256 fixedAmt = 500 ether;
3062+
vm.prank(borrower);
3063+
broker.borrow(fixedAmt, 30);
3064+
3065+
skip(10 days); // repay early -> penalty applies
3066+
3067+
FixedLoanPosition memory pos = broker.userFixedPositions(borrower)[0];
3068+
uint256 posId = pos.posId;
3069+
3070+
// full close: interest + principal + penalty
3071+
uint256 repayAmt = broker.getUserTotalDebt(borrower) + 10 ether;
3072+
(uint256 interestRepaid, uint256 penalty, ) = broker.previewRepayFixedLoanPosition(borrower, repayAmt, posId);
3073+
assertGt(interestRepaid, 0, "no interest accrued");
3074+
assertGt(penalty, 0, "no penalty for early repay");
3075+
3076+
// interest and penalty are supplied to the relayer in two separate calls,
3077+
// so the fee is floored on each independently.
3078+
uint256 expectedFee = (interestRepaid * 0.1e18) / FEE_WAD + (penalty * 0.1e18) / FEE_WAD;
3079+
3080+
LISUSD.setBalance(borrower, repayAmt);
3081+
uint256 feeBefore = LISUSD.balanceOf(FEE_RECIPIENT);
3082+
vm.startPrank(borrower);
3083+
IERC20(address(LISUSD)).approve(address(broker), type(uint256).max);
3084+
broker.repay(repayAmt, posId, borrower);
3085+
vm.stopPrank();
3086+
3087+
assertEq(LISUSD.balanceOf(FEE_RECIPIENT) - feeBefore, expectedFee, "fee on interest+penalty mismatch");
3088+
}
3089+
3090+
/// @dev end-to-end: repaying a dynamic position routes 10% of the accrued interest to the fee recipient.
3091+
function test_relayer_dynamicRepay_feeOnInterest() public {
3092+
_enableRelayerFee(FEE_RECIPIENT);
3093+
3094+
uint256 amount = 400 ether;
3095+
vm.prank(borrower);
3096+
broker.borrow(amount);
3097+
3098+
skip(30 days);
3099+
3100+
uint256 accruedInterest = broker.getUserTotalDebt(borrower) - amount;
3101+
assertGt(accruedInterest, 0, "no dynamic interest accrued");
3102+
uint256 expectedFee = (accruedInterest * 0.1e18) / FEE_WAD;
3103+
3104+
LISUSD.setBalance(borrower, accruedInterest); // repay interest only
3105+
uint256 feeBefore = LISUSD.balanceOf(FEE_RECIPIENT);
3106+
vm.startPrank(borrower);
3107+
IERC20(address(LISUSD)).approve(address(broker), type(uint256).max);
3108+
broker.repay(accruedInterest, borrower);
3109+
vm.stopPrank();
3110+
3111+
assertApproxEqAbs(LISUSD.balanceOf(FEE_RECIPIENT) - feeBefore, expectedFee, 1, "fee on dynamic interest mismatch");
3112+
}
3113+
3114+
function test_relayer_setFeeRate_onlyManager() public {
3115+
_enableRelayerFee(FEE_RECIPIENT);
3116+
3117+
vm.prank(address(0xdead));
3118+
vm.expectRevert();
3119+
relayer.setFeeRate(0.2e18);
3120+
3121+
vm.prank(MANAGER);
3122+
relayer.setFeeRate(0.2e18);
3123+
assertEq(relayer.feeRate(), 0.2e18);
3124+
3125+
uint256 tooHigh = relayer.MAX_FEE() + 1;
3126+
vm.prank(MANAGER);
3127+
vm.expectRevert(bytes("relayer/max-fee-exceeded"));
3128+
relayer.setFeeRate(tooHigh);
3129+
}
3130+
3131+
function test_relayer_setFeeRecipient_onlyManager() public {
3132+
_enableRelayerFee(FEE_RECIPIENT);
3133+
3134+
vm.prank(address(0xdead));
3135+
vm.expectRevert();
3136+
relayer.setFeeRecipient(address(0xabcd));
3137+
3138+
vm.prank(MANAGER);
3139+
relayer.setFeeRecipient(address(0xabcd));
3140+
assertEq(relayer.feeRecipient(), address(0xabcd));
3141+
3142+
vm.prank(MANAGER);
3143+
vm.expectRevert(bytes("relayer/zero-address-provided"));
3144+
relayer.setFeeRecipient(address(0));
3145+
}
29893146
}
29903147

29913148
/// @dev Mock swap pair that converts tokenIn -> tokenOut at oracle price.

0 commit comments

Comments
 (0)