Skip to content

Commit ab7c673

Browse files
authored
feat: implement FeeManager system for protocol-wide fee handling (#4394)
### Description This PR adds a new fee management system to the protocol, providing a unified way to handle fees across different features. The system supports multiple fee calculation methods (fixed, percentage, hybrid), configurable fee recipients, and extensible hooks for dynamic fee adjustments like staking-based exemptions. ### Changes - Added `FeeManagerFacet` with comprehensive fee management capabilities - Implemented `StakingExemptionHook` as example to provide fee exemptions based on staked token amounts - Created `FeeTypes` library defining standard fee type constants - Integrated the fee system with the existing tipping functionality `TippingFacet`​ - Added deployment scripts for the new facet - Updated the space factory deployment to include the fee manager facet ### Checklist - [x] Tests added where required - [x] Documentation updated where applicable - [x] Changes adhere to the repository's contribution guidelines
1 parent 8ad3e07 commit ab7c673

File tree

18 files changed

+2285
-70
lines changed

18 files changed

+2285
-70
lines changed

packages/contracts/scripts/deployments/diamonds/DeploySpaceFactory.s.sol

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {DeployMockLegacyArchitect} from "../facets/DeployMockLegacyArchitect.s.s
2323
import {DeployPartnerRegistry} from "../facets/DeployPartnerRegistry.s.sol";
2424
import {DeployPlatformRequirements} from "../facets/DeployPlatformRequirements.s.sol";
2525
import {DeployWalletLink} from "../facets/DeployWalletLink.s.sol";
26+
import {DeployFeeManager} from "../facets/DeployFeeManager.s.sol";
2627
import {LibString} from "solady/utils/LibString.sol";
2728

2829
// contracts
@@ -152,6 +153,7 @@ contract DeploySpaceFactory is IDiamondInitHelper, DiamondHelper, Deployer {
152153
facetHelper.add("EIP712Facet");
153154
facetHelper.add("PartnerRegistry");
154155
facetHelper.add("FeatureManagerFacet");
156+
facetHelper.add("FeeManagerFacet");
155157
facetHelper.add("SpaceProxyInitializer");
156158
facetHelper.add("SpaceFactoryInit");
157159

@@ -272,6 +274,13 @@ contract DeploySpaceFactory is IDiamondInitHelper, DiamondHelper, Deployer {
272274
DeployFeatureManager.makeInitData()
273275
);
274276

277+
facet = facetHelper.getDeployedAddress("FeeManagerFacet");
278+
addFacet(
279+
makeCut(facet, FacetCutAction.Add, DeployFeeManager.selectors()),
280+
facet,
281+
DeployFeeManager.makeInitData(deployer)
282+
);
283+
275284
address spaceProxyInitializer = facetHelper.getDeployedAddress("SpaceProxyInitializer");
276285
spaceFactoryInit = facetHelper.getDeployedAddress("SpaceFactoryInit");
277286
spaceFactoryInitData = DeploySpaceFactoryInit.makeInitData(spaceProxyInitializer);
@@ -346,7 +355,7 @@ contract DeploySpaceFactory is IDiamondInitHelper, DiamondHelper, Deployer {
346355
DeployPlatformRequirements.makeInitData(
347356
deployer, // feeRecipient
348357
500, // membershipBps 5%
349-
0.005 ether, // membershipFee
358+
0.0005 ether, // membershipFee
350359
1000, // membershipFreeAllocation
351360
365 days, // membershipDuration
352361
0.001 ether // membershipMinPrice
@@ -395,6 +404,12 @@ contract DeploySpaceFactory is IDiamondInitHelper, DiamondHelper, Deployer {
395404
facet,
396405
DeployFeatureManager.makeInitData()
397406
);
407+
} else if (facetName.eq("FeeManagerFacet")) {
408+
addFacet(
409+
makeCut(facet, FacetCutAction.Add, DeployFeeManager.selectors()),
410+
facet,
411+
DeployFeeManager.makeInitData(deployer)
412+
);
398413
}
399414
}
400415
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.29;
3+
4+
// interfaces
5+
import {IDiamond} from "@towns-protocol/diamond/src/Diamond.sol";
6+
import {IFeeManager} from "src/factory/facets/fee/IFeeManager.sol";
7+
8+
// libraries
9+
import {LibDeploy} from "@towns-protocol/diamond/src/utils/LibDeploy.sol";
10+
import {DynamicArrayLib} from "solady/utils/DynamicArrayLib.sol";
11+
12+
library DeployFeeManager {
13+
using DynamicArrayLib for DynamicArrayLib.DynamicArray;
14+
15+
function selectors() internal pure returns (bytes4[] memory res) {
16+
DynamicArrayLib.DynamicArray memory arr = DynamicArrayLib.p().reserve(8);
17+
arr.p(IFeeManager.calculateFee.selector);
18+
arr.p(IFeeManager.chargeFee.selector);
19+
arr.p(IFeeManager.setFeeConfig.selector);
20+
arr.p(IFeeManager.setFeeHook.selector);
21+
arr.p(IFeeManager.setProtocolFeeRecipient.selector);
22+
arr.p(IFeeManager.getFeeConfig.selector);
23+
arr.p(IFeeManager.getFeeHook.selector);
24+
arr.p(IFeeManager.getProtocolFeeRecipient.selector);
25+
26+
bytes32[] memory selectors_ = arr.asBytes32Array();
27+
assembly ("memory-safe") {
28+
res := selectors_
29+
}
30+
}
31+
32+
function makeCut(
33+
address facetAddress,
34+
IDiamond.FacetCutAction action
35+
) internal pure returns (IDiamond.FacetCut memory) {
36+
return IDiamond.FacetCut(facetAddress, action, selectors());
37+
}
38+
39+
function deploy() internal returns (address) {
40+
return LibDeploy.deployCode("FeeManagerFacet.sol", "");
41+
}
42+
43+
function makeInitData(address protocolFeeRecipient) internal pure returns (bytes memory) {
44+
return abi.encodeCall(IFeeManager.__FeeManagerFacet__init, protocolFeeRecipient);
45+
}
46+
}

packages/contracts/scripts/interactions/InteractPostDeploy.s.sol

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ pragma solidity ^0.8.23;
33

44
// interfaces
55
import {IImplementationRegistry} from "../../src/factory/facets/registry/IImplementationRegistry.sol";
6-
import {ISpaceDelegation} from "src/base/registry/facets/delegation/ISpaceDelegation.sol";
6+
import {IFeeManager} from "../../src/factory/facets/fee/IFeeManager.sol";
77
import {IMainnetDelegation} from "src/base/registry/facets/mainnet/IMainnetDelegation.sol";
88
import {ISpaceOwner} from "src/spaces/facets/owner/ISpaceOwner.sol";
99
import {INodeOperator} from "src/base/registry/facets/operator/INodeOperator.sol";
@@ -12,6 +12,8 @@ import {ISubscriptionModule} from "src/apps/modules/subscription/ISubscriptionMo
1212

1313
// libraries
1414
import {NodeOperatorStatus} from "src/base/registry/facets/operator/NodeOperatorStorage.sol";
15+
import {FeeCalculationMethod} from "../../src/factory/facets/fee/FeeManagerStorage.sol";
16+
import {FeeTypesLib} from "../../src/factory/facets/fee/FeeTypesLib.sol";
1517

1618
// contracts
1719
import {MAX_CLAIMABLE_SUPPLY} from "./InteractClaimCondition.s.sol";
@@ -49,6 +51,14 @@ contract InteractPostDeploy is Interaction {
4951
IImplementationRegistry(spaceFactory).addImplementation(baseRegistry);
5052
IImplementationRegistry(spaceFactory).addImplementation(riverAirdrop);
5153
IImplementationRegistry(spaceFactory).addImplementation(appRegistry);
54+
IFeeManager(spaceFactory).setFeeConfig(
55+
FeeTypesLib.TIP_MEMBER,
56+
deployer,
57+
FeeCalculationMethod.PERCENT,
58+
50,
59+
0,
60+
true
61+
);
5262
ISubscriptionModule(subscriptionModule).setSpaceFactory(spaceFactory);
5363
IMainnetDelegation(baseRegistry).setProxyDelegation(proxyDelegation);
5464
IRewardsDistribution(baseRegistry).setRewardNotifier(deployer, true);
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.29;
3+
4+
// interfaces
5+
import {IFeeHook, FeeHookResult} from "./IFeeHook.sol";
6+
import {IFeeManagerBase} from "./IFeeManager.sol";
7+
8+
// libraries
9+
import {BasisPoints} from "src/utils/libraries/BasisPoints.sol";
10+
import {CurrencyTransfer} from "src/utils/libraries/CurrencyTransfer.sol";
11+
import {CustomRevert} from "src/utils/libraries/CustomRevert.sol";
12+
import {FeeCalculationMethod, FeeConfig, FeeManagerStorage} from "./FeeManagerStorage.sol";
13+
import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol";
14+
15+
/// @title FeeManagerBase
16+
/// @notice Base contract with internal fee management logic
17+
abstract contract FeeManagerBase is IFeeManagerBase {
18+
using CustomRevert for bytes4;
19+
using FeeManagerStorage for FeeManagerStorage.Layout;
20+
21+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
22+
/* INTERNAL CALCULATIONS */
23+
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
24+
25+
/// @notice Calculates base fee before hook processing
26+
/// @param config Fee configuration
27+
/// @param amount Base amount for percentage calculations
28+
/// @return baseFee The calculated base fee
29+
function _calculateBaseFee(
30+
FeeConfig storage config,
31+
uint256 amount
32+
) internal view returns (uint256 baseFee) {
33+
FeeCalculationMethod method = config.method;
34+
if (method == FeeCalculationMethod.FIXED) {
35+
return config.fixedFee;
36+
} else if (method == FeeCalculationMethod.PERCENT) {
37+
return BasisPoints.calculate(amount, config.bps);
38+
} else if (method == FeeCalculationMethod.HYBRID) {
39+
uint256 percentFee = BasisPoints.calculate(amount, config.bps);
40+
return FixedPointMathLib.max(percentFee, config.fixedFee);
41+
}
42+
return 0;
43+
}
44+
45+
/// @notice Calculates fee for estimation (view function)
46+
/// @dev Does not modify state, calls hook's calculateFee if configured
47+
/// @param feeType The type of fee to calculate
48+
/// @param user The address that would be charged
49+
/// @param amount The base amount for percentage calculations
50+
/// @param extraData Additional data passed to hooks
51+
/// @return finalFee The calculated fee amount
52+
function _calculateFee(
53+
bytes32 feeType,
54+
address user,
55+
uint256 amount,
56+
bytes calldata extraData
57+
) internal view returns (uint256 finalFee) {
58+
FeeManagerStorage.Layout storage $ = FeeManagerStorage.getLayout();
59+
FeeConfig storage config = $.feeConfigs[feeType];
60+
61+
// Check if fee is configured and enabled
62+
if (!config.enabled) return 0;
63+
64+
// Calculate base fee
65+
uint256 baseFee = _calculateBaseFee(config, amount);
66+
67+
// Apply hook if configured
68+
address hook = config.hook;
69+
if (hook != address(0)) {
70+
try IFeeHook(hook).calculateFee(feeType, user, baseFee, extraData) returns (
71+
FeeHookResult memory result
72+
) {
73+
return result.finalFee;
74+
} catch {
75+
// If hook fails, fall back to base fee
76+
return baseFee;
77+
}
78+
}
79+
80+
return baseFee;
81+
}
82+
83+
/// @notice Charges fee and transfers it (state-changing)
84+
/// @dev Calls hook's onChargeFee if configured, then transfers currency
85+
/// @dev Note: `user` is metadata for hooks/events. Actual payment comes from msg.sender.
86+
/// @param feeType The type of fee to charge
87+
/// @param user The address for whom the fee is being charged (for hooks/events)
88+
/// @param amount The base amount for percentage calculations
89+
/// @param currency The currency contract (address(0) for native token)
90+
/// @param context Additional context passed to hooks
91+
/// @return finalFee The actual fee charged
92+
function _chargeFee(
93+
bytes32 feeType,
94+
address user,
95+
uint256 amount,
96+
address currency,
97+
uint256 maxFee,
98+
bytes calldata context
99+
) internal virtual returns (uint256 finalFee) {
100+
FeeManagerStorage.Layout storage $ = FeeManagerStorage.getLayout();
101+
FeeConfig storage config = _getFeeConfig(feeType);
102+
103+
// Check if fee is configured and enabled
104+
if (!config.enabled) return 0;
105+
106+
// Calculate base fee
107+
uint256 baseFee = _calculateBaseFee(config, amount);
108+
109+
// Apply hook if configured
110+
address hook = config.hook;
111+
if (hook != address(0)) {
112+
FeeHookResult memory result = IFeeHook(hook).onChargeFee(
113+
feeType,
114+
user,
115+
baseFee,
116+
context
117+
);
118+
119+
finalFee = result.finalFee;
120+
} else {
121+
finalFee = baseFee;
122+
}
123+
124+
// Enforce slippage protection
125+
if (finalFee > maxFee) FeeManager__ExceedsMaxFee.selector.revertWith();
126+
127+
// Convert address(0) to NATIVE_TOKEN for CurrencyTransfer library
128+
address feeCurrency = currency == address(0) ? CurrencyTransfer.NATIVE_TOKEN : currency;
129+
130+
// For native token, validate msg.value matches maxFee
131+
if (feeCurrency == CurrencyTransfer.NATIVE_TOKEN && msg.value != maxFee) {
132+
CurrencyTransfer.MsgValueMismatch.selector.revertWith();
133+
}
134+
135+
// Transfer fee and/or refund excess if maxFee > 0
136+
if (maxFee > 0) {
137+
address recipient = config.recipient;
138+
if (recipient == address(0)) recipient = $.protocolFeeRecipient;
139+
140+
CurrencyTransfer.transferFeeWithRefund(
141+
feeCurrency,
142+
msg.sender,
143+
recipient,
144+
finalFee,
145+
maxFee
146+
);
147+
148+
if (finalFee > 0) {
149+
emit FeeCharged(feeType, user, currency, finalFee, recipient);
150+
}
151+
}
152+
}
153+
154+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
155+
/* INTERNAL CONFIGURATION */
156+
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
157+
158+
/// @notice Sets fee configuration
159+
/// @param feeType The fee type identifier
160+
/// @param recipient Fee recipient (uses global if zero address)
161+
/// @param method Calculation method
162+
/// @param bps Basis points (1-10000)
163+
/// @param fixedFee Fixed fee amount
164+
/// @param enabled Whether the fee is active
165+
function _setFeeConfig(
166+
bytes32 feeType,
167+
address recipient,
168+
FeeCalculationMethod method,
169+
uint16 bps,
170+
uint128 fixedFee,
171+
bool enabled
172+
) internal {
173+
if (recipient == address(0)) FeeManager__InvalidRecipient.selector.revertWith();
174+
if (bps > BasisPoints.MAX_BPS) FeeManager__InvalidBps.selector.revertWith();
175+
176+
FeeConfig storage config = _getFeeConfig(feeType);
177+
config.recipient = recipient;
178+
config.lastUpdated = uint48(block.timestamp);
179+
config.bps = bps;
180+
config.method = method;
181+
config.enabled = enabled;
182+
config.fixedFee = fixedFee;
183+
184+
emit FeeConfigured(feeType, recipient, method, bps, fixedFee, enabled);
185+
}
186+
187+
/// @notice Sets fee hook
188+
/// @param feeType The fee type identifier
189+
/// @param hook Address of the hook contract (zero to remove)
190+
function _setFeeHook(bytes32 feeType, address hook) internal {
191+
FeeConfig storage config = _getFeeConfig(feeType);
192+
config.hook = hook;
193+
emit FeeHookSet(feeType, hook);
194+
}
195+
196+
/// @notice Sets protocol fee recipient
197+
/// @param recipient New protocol fee recipient
198+
function _setProtocolFeeRecipient(address recipient) internal {
199+
if (recipient == address(0)) FeeManager__InvalidRecipient.selector.revertWith();
200+
FeeManagerStorage.Layout storage $ = FeeManagerStorage.getLayout();
201+
$.protocolFeeRecipient = recipient;
202+
emit ProtocolFeeRecipientSet(recipient);
203+
}
204+
205+
/*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/
206+
/* INTERNAL GETTERS */
207+
/*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/
208+
209+
/// @notice Returns fee configuration
210+
/// @param feeType The fee type identifier
211+
/// @return config The fee configuration
212+
function _getFeeConfig(bytes32 feeType) internal view returns (FeeConfig storage config) {
213+
return FeeManagerStorage.getLayout().feeConfigs[feeType];
214+
}
215+
216+
/// @notice Returns fee hook address
217+
/// @param feeType The fee type identifier
218+
/// @return hook The hook contract address
219+
function _getFeeHook(bytes32 feeType) internal view returns (address hook) {
220+
FeeConfig storage config = _getFeeConfig(feeType);
221+
return config.hook;
222+
}
223+
224+
/// @notice Returns protocol fee recipient
225+
/// @return recipient The protocol fee recipient address
226+
function _getProtocolFeeRecipient() internal view returns (address recipient) {
227+
return FeeManagerStorage.getLayout().protocolFeeRecipient;
228+
}
229+
}

0 commit comments

Comments
 (0)