@bananapus/core-v6 0.0.54 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/script/helpers/CoreDeploymentLib.sol +2 -3
- package/src/JBController.sol +23 -5
- package/src/JBERC20.sol +7 -7
- package/src/JBFeelessAddresses.sol +57 -6
- package/src/JBFundAccessLimits.sol +25 -0
- package/src/JBMultiTerminal.sol +171 -42
- package/src/JBPrices.sol +18 -3
- package/src/JBRulesets.sol +6 -6
- package/src/JBSplits.sol +44 -21
- package/src/JBTokens.sol +1 -1
- package/src/interfaces/IJBFeelessAddresses.sol +24 -4
- package/src/interfaces/IJBFeelessHook.sol +25 -0
- package/src/interfaces/IJBToken.sol +2 -2
- package/src/libraries/JBCashOuts.sol +9 -8
- package/src/libraries/JBFees.sol +4 -3
- package/src/libraries/JBMetadataResolver.sol +6 -13
- package/src/libraries/JBPayoutSplitGroupLib.sol +3 -3
- package/src/libraries/JBRulesetMetadataResolver.sol +3 -0
- package/src/structs/JBFundAccessLimitGroup.sol +1 -0
- package/test/helpers/JBTest.sol +9 -0
- package/test/mock/MockFeelessHook.sol +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/core-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.57",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-core-v6'"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
41
|
+
"@bananapus/permission-ids-v6": "^0.0.26",
|
|
42
42
|
"@chainlink/contracts": "1.5.0",
|
|
43
43
|
"@openzeppelin/contracts": "5.6.1",
|
|
44
44
|
"@prb/math": "4.1.1",
|
|
@@ -43,11 +43,10 @@ library CoreDeploymentLib {
|
|
|
43
43
|
string constant PROJECT_NAME = "nana-core-v6";
|
|
44
44
|
|
|
45
45
|
function getDeployment(string memory path) internal returns (CoreDeployment memory deployment) {
|
|
46
|
-
//
|
|
46
|
+
// Match the current chain ID to the Sphinx network name used in deployment artifacts.
|
|
47
47
|
uint256 chainId = block.chainid;
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
// TODO: get constants without deploy.
|
|
49
|
+
// `SphinxConstants` exposes Sphinx's supported chain ID to network name mapping.
|
|
51
50
|
SphinxConstants sphinxConstants = new SphinxConstants();
|
|
52
51
|
NetworkInfo[] memory networks = sphinxConstants.getNetworkInfoArray();
|
|
53
52
|
|
package/src/JBController.sol
CHANGED
|
@@ -67,6 +67,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
|
|
|
67
67
|
error JBController_NoReservedTokens(uint256 projectId);
|
|
68
68
|
error JBController_OnlyDirectory(address sender, IJBDirectory directory);
|
|
69
69
|
error JBController_PendingReservedTokens(uint256 pendingReservedTokenBalance);
|
|
70
|
+
error JBController_ReservedTokenSplitProjectSameAsOwner(uint256 projectId);
|
|
70
71
|
error JBController_RulesetsAlreadyLaunched(uint256 projectId);
|
|
71
72
|
error JBController_RulesetsArrayEmpty(uint256 projectId, uint256 rulesetConfigurationCount);
|
|
72
73
|
error JBController_RulesetSetTokenNotAllowed(uint256 projectId);
|
|
@@ -382,8 +383,10 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
|
|
|
382
383
|
/// @notice Creates a new Juicebox project in one transaction — mints the project NFT, queues initial rulesets,
|
|
383
384
|
/// and
|
|
384
385
|
/// configures terminals. This is the primary entry point for launching a project.
|
|
385
|
-
/// @dev Anyone can call this on behalf of any owner.
|
|
386
|
-
///
|
|
386
|
+
/// @dev Anyone can call this on behalf of any owner. This is a launch convenience, not owner authorization proof:
|
|
387
|
+
/// frontends and operators must use the transaction sender, an explicit owner signature, or their own deployment
|
|
388
|
+
/// flow to decide whether the owner intentionally launched a configuration. Each sub-operation (mint, queue,
|
|
389
|
+
/// configure) can also be done individually if needed.
|
|
387
390
|
/// @param owner The project's owner. The project ERC-721 will be minted to this address.
|
|
388
391
|
/// @param projectUri The project's metadata URI. This is typically an IPFS hash, optionally with the `ipfs://`
|
|
389
392
|
/// prefix. This can be updated by the project's owner.
|
|
@@ -1061,7 +1064,8 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
|
|
|
1061
1064
|
JBSplit memory split = splits[i];
|
|
1062
1065
|
|
|
1063
1066
|
// Calculate the amount to send to the split.
|
|
1064
|
-
uint256 splitTokenCount =
|
|
1067
|
+
uint256 splitTokenCount =
|
|
1068
|
+
mulDiv({x: tokenCount, y: split.percent, denominator: JBConstants.SPLITS_TOTAL_PERCENT});
|
|
1065
1069
|
|
|
1066
1070
|
// Mints tokens for the split if needed.
|
|
1067
1071
|
if (splitTokenCount > 0) {
|
|
@@ -1116,7 +1120,18 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
|
|
|
1116
1120
|
// sender.
|
|
1117
1121
|
address beneficiary = split.beneficiary != address(0) ? split.beneficiary : messageSender;
|
|
1118
1122
|
|
|
1123
|
+
// Reserved token splits with no project ID mint directly to the beneficiary. A non-zero project ID
|
|
1124
|
+
// takes the "pay another project" path, which treats the reserved tokens as revenue for the
|
|
1125
|
+
// destination project and can mint another batch of tokens according to its ruleset.
|
|
1119
1126
|
if (split.projectId != 0) {
|
|
1127
|
+
if (split.projectId == projectId) {
|
|
1128
|
+
// The source project is not a valid destination for the terminal-payment path.
|
|
1129
|
+
// `sendReservedTokensToSplitsOf` clears the pending reserve balance before this helper
|
|
1130
|
+
// runs. Paying those freshly minted reserves into the same project's terminal would book
|
|
1131
|
+
// them as new revenue, mint another batch of tokens, and start the reserve cycle again.
|
|
1132
|
+
revert JBController_ReservedTokenSplitProjectSameAsOwner({projectId: projectId});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1120
1135
|
// Get a reference to the receiving project's primary payment terminal for the token.
|
|
1121
1136
|
IJBTerminal terminal = token == IJBToken(address(0))
|
|
1122
1137
|
? IJBTerminal(address(0))
|
|
@@ -1264,8 +1279,11 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
|
|
|
1264
1279
|
returns (uint256 beneficiaryTokenCount, uint256 reservedTokenCount)
|
|
1265
1280
|
{
|
|
1266
1281
|
// Compute the beneficiary's portion after removing the reserved share.
|
|
1267
|
-
beneficiaryTokenCount =
|
|
1268
|
-
|
|
1282
|
+
beneficiaryTokenCount = mulDiv({
|
|
1283
|
+
x: tokenCount,
|
|
1284
|
+
y: JBConstants.MAX_RESERVED_PERCENT - reservedPercent,
|
|
1285
|
+
denominator: JBConstants.MAX_RESERVED_PERCENT
|
|
1286
|
+
});
|
|
1269
1287
|
|
|
1270
1288
|
// The remaining tokens are reserved.
|
|
1271
1289
|
reservedTokenCount = tokenCount - beneficiaryTokenCount;
|
package/src/JBERC20.sol
CHANGED
|
@@ -41,7 +41,7 @@ contract JBERC20 is ERC20Votes, ERC20Permit, JBPermissioned, IERC1271, IJBToken
|
|
|
41
41
|
|
|
42
42
|
/// @notice The JBTokens contract that owns this token.
|
|
43
43
|
/// @dev Set via `initialize` because JBERC20 is deployed before JBTokens (circular dependency).
|
|
44
|
-
IJBTokens public
|
|
44
|
+
IJBTokens public tokens;
|
|
45
45
|
|
|
46
46
|
//*********************************************************************//
|
|
47
47
|
// -------------------- private stored properties -------------------- //
|
|
@@ -80,7 +80,7 @@ contract JBERC20 is ERC20Votes, ERC20Permit, JBPermissioned, IERC1271, IJBToken
|
|
|
80
80
|
/// @notice Only the JBTokens contract can call this function.
|
|
81
81
|
// forge-lint: disable-next-line(unwrapped-modifier-logic)
|
|
82
82
|
modifier onlyTokens() {
|
|
83
|
-
if (msg.sender != address(
|
|
83
|
+
if (msg.sender != address(tokens)) revert JBERC20_Unauthorized({caller: msg.sender, tokens: address(tokens)});
|
|
84
84
|
_;
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -130,11 +130,11 @@ contract JBERC20 is ERC20Votes, ERC20Permit, JBPermissioned, IERC1271, IJBToken
|
|
|
130
130
|
/// @return magicValue `0x1626ba7e` if the signature is valid, `0xffffffff` otherwise.
|
|
131
131
|
function isValidSignature(bytes32 hash, bytes memory signature) external view override returns (bytes4 magicValue) {
|
|
132
132
|
// Recover the signer from the signature. Return invalid if recovery fails.
|
|
133
|
-
(address signer, ECDSA.RecoverError error,) = ECDSA.tryRecover(hash, signature);
|
|
133
|
+
(address signer, ECDSA.RecoverError error,) = ECDSA.tryRecover({hash: hash, signature: signature});
|
|
134
134
|
if (error != ECDSA.RecoverError.NoError) return 0xffffffff;
|
|
135
135
|
|
|
136
136
|
// Get the project ID this token belongs to.
|
|
137
|
-
uint256 projectId =
|
|
137
|
+
uint256 projectId = tokens.projectIdOf(IJBToken(address(this)));
|
|
138
138
|
|
|
139
139
|
// Get the project owner (the NFT holder).
|
|
140
140
|
address projectOwner = PROJECTS.ownerOf(projectId);
|
|
@@ -195,8 +195,8 @@ contract JBERC20 is ERC20Votes, ERC20Permit, JBPermissioned, IERC1271, IJBToken
|
|
|
195
195
|
/// @notice Initialize a new project token with the given name, symbol, and owner.
|
|
196
196
|
/// @param name_ The token's name.
|
|
197
197
|
/// @param symbol_ The token's symbol.
|
|
198
|
-
/// @param
|
|
199
|
-
function initialize(string memory name_, string memory symbol_, address
|
|
198
|
+
/// @param tokensAddress The JBTokens contract that manages this token.
|
|
199
|
+
function initialize(string memory name_, string memory symbol_, address tokensAddress) public override {
|
|
200
200
|
// Prevent re-initialization by reverting if a name is already set or if the provided name is empty.
|
|
201
201
|
if (bytes(_name).length != 0 || bytes(name_).length == 0) {
|
|
202
202
|
revert JBERC20_AlreadyInitialized({
|
|
@@ -206,7 +206,7 @@ contract JBERC20 is ERC20Votes, ERC20Permit, JBPermissioned, IERC1271, IJBToken
|
|
|
206
206
|
|
|
207
207
|
_name = name_;
|
|
208
208
|
_symbol = symbol_;
|
|
209
|
-
|
|
209
|
+
tokens = IJBTokens(tokensAddress);
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
//*********************************************************************//
|
|
@@ -5,12 +5,28 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
|
|
|
5
5
|
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
6
6
|
|
|
7
7
|
import {IJBFeelessAddresses} from "./interfaces/IJBFeelessAddresses.sol";
|
|
8
|
+
import {IJBFeelessHook} from "./interfaces/IJBFeelessHook.sol";
|
|
8
9
|
|
|
9
10
|
/// @notice A registry of addresses exempt from the protocol's 2.5% fee. Feeless addresses don't incur fees on
|
|
10
11
|
/// payouts they receive, surplus allowance they use, or cash outs where they are the beneficiary.
|
|
11
12
|
/// @dev All feeless status is managed by the contract owner (typically the protocol multisig).
|
|
12
13
|
/// @dev `projectId = 0` is the wildcard — an address feeless for project 0 is feeless for ALL projects.
|
|
13
14
|
contract JBFeelessAddresses is Ownable, IJBFeelessAddresses, IERC165 {
|
|
15
|
+
//*********************************************************************//
|
|
16
|
+
// --------------------------- custom errors ------------------------- //
|
|
17
|
+
//*********************************************************************//
|
|
18
|
+
|
|
19
|
+
error JBFeelessAddresses_InvalidFeelessHook(IJBFeelessHook hook);
|
|
20
|
+
|
|
21
|
+
//*********************************************************************//
|
|
22
|
+
// --------------------- public stored properties -------------------- //
|
|
23
|
+
//*********************************************************************//
|
|
24
|
+
|
|
25
|
+
/// @notice Optional hook consulted (in addition to the static mappings) when computing feeless status.
|
|
26
|
+
/// @dev OR'd with the mappings — the hook can only widen the feeless set, never shrink it. `address(0)` disables
|
|
27
|
+
/// hook consultation.
|
|
28
|
+
IJBFeelessHook public override feelessHook;
|
|
29
|
+
|
|
14
30
|
//*********************************************************************//
|
|
15
31
|
// -------------------- internal stored properties ------------------- //
|
|
16
32
|
//*********************************************************************//
|
|
@@ -53,19 +69,54 @@ contract JBFeelessAddresses is Ownable, IJBFeelessAddresses, IERC165 {
|
|
|
53
69
|
emit SetFeelessAddress({projectId: projectId, addr: addr, isFeeless: flag, caller: _msgSender()});
|
|
54
70
|
}
|
|
55
71
|
|
|
72
|
+
/// @notice Sets (or clears) the feeless hook consulted by `isFeelessFor`.
|
|
73
|
+
/// @dev Can only be called by this contract's owner (typically the protocol multisig).
|
|
74
|
+
/// @dev If `hook` is non-zero, it must report ERC-165 support for `IJBFeelessHook` or this call reverts.
|
|
75
|
+
/// @param hook The new hook. Pass `address(0)` to disable hook consultation.
|
|
76
|
+
function setFeelessHook(IJBFeelessHook hook) external virtual override onlyOwner {
|
|
77
|
+
if (address(hook) != address(0) && !hook.supportsInterface(type(IJBFeelessHook).interfaceId)) {
|
|
78
|
+
revert JBFeelessAddresses_InvalidFeelessHook(hook);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
feelessHook = hook;
|
|
82
|
+
|
|
83
|
+
emit SetFeelessHook({hook: hook, caller: _msgSender()});
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
//*********************************************************************//
|
|
57
|
-
//
|
|
87
|
+
// ------------------------- external views -------------------------- //
|
|
58
88
|
//*********************************************************************//
|
|
59
89
|
|
|
60
|
-
/// @notice Returns whether the specified address is feeless for a specific project,
|
|
61
|
-
///
|
|
90
|
+
/// @notice Returns whether the specified address is feeless for a specific project, providing the outer caller
|
|
91
|
+
/// of the fee-bearing operation so hooks can scope grants by who initiated the action.
|
|
92
|
+
/// @dev The static admin-set mappings are caller-agnostic and always apply. The hook (if set) receives `caller`
|
|
93
|
+
/// (typically the terminal's `_msgSender()`) and may use it to narrow its grant — e.g. grant an ecosystem
|
|
94
|
+
/// router feeless cash-outs only when the router itself is the caller, not when it appears as a split recipient
|
|
95
|
+
/// of someone else's payout. Hook is invoked via try/catch — a reverting hook is treated as `false`.
|
|
62
96
|
/// @param addr The address to check.
|
|
63
97
|
/// @param projectId The ID of the project to check.
|
|
64
|
-
/// @
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
/// @param caller The outer caller (typically the terminal's `_msgSender()`). Pass `address(0)` for lookups
|
|
99
|
+
/// without caller context.
|
|
100
|
+
/// @return A flag indicating whether the address is feeless.
|
|
101
|
+
function isFeelessFor(address addr, uint256 projectId, address caller) external view override returns (bool) {
|
|
102
|
+
// Static grants are administrative and do not depend on who triggered the fee-bearing operation.
|
|
103
|
+
if (_isFeelessFor[0][addr] || _isFeelessFor[projectId][addr]) return true;
|
|
104
|
+
|
|
105
|
+
IJBFeelessHook hook = feelessHook;
|
|
106
|
+
if (address(hook) == address(0)) return false;
|
|
107
|
+
|
|
108
|
+
// Hook grants are optional and caller-aware; a reverting hook is treated as "not feeless".
|
|
109
|
+
try hook.isFeeless({projectId: projectId, addr: addr, caller: caller}) returns (bool result) {
|
|
110
|
+
return result;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
67
114
|
}
|
|
68
115
|
|
|
116
|
+
//*********************************************************************//
|
|
117
|
+
// -------------------------- public views --------------------------- //
|
|
118
|
+
//*********************************************************************//
|
|
119
|
+
|
|
69
120
|
/// @notice Indicates whether this contract adheres to the specified interface.
|
|
70
121
|
/// @dev See {IERC165-supportsInterface}.
|
|
71
122
|
/// @param interfaceId The ID of the interface to check for adherence to.
|
|
@@ -18,6 +18,9 @@ contract JBFundAccessLimits is JBControlled, IJBFundAccessLimits {
|
|
|
18
18
|
// --------------------------- custom errors ------------------------- //
|
|
19
19
|
//*********************************************************************//
|
|
20
20
|
|
|
21
|
+
error JBFundAccessLimits_DuplicateFundAccessLimitGroup(
|
|
22
|
+
uint256 projectId, uint256 rulesetId, uint256 groupIndex, address terminal, address token
|
|
23
|
+
);
|
|
21
24
|
error JBFundAccessLimits_InvalidPayoutLimitCurrencyOrdering(
|
|
22
25
|
uint256 projectId, uint256 rulesetId, uint256 groupIndex, uint256 limitIndex
|
|
23
26
|
);
|
|
@@ -95,6 +98,28 @@ contract JBFundAccessLimits is JBControlled, IJBFundAccessLimits {
|
|
|
95
98
|
// Set the limits being iterated on.
|
|
96
99
|
JBFundAccessLimitGroup calldata fundAccessLimitGroup = fundAccessLimitGroups[i];
|
|
97
100
|
|
|
101
|
+
// Each terminal/token pair should have exactly one group. The per-group currency ordering checks below
|
|
102
|
+
// prevent duplicate currencies inside one group; this prevents splitting a duplicate currency across two
|
|
103
|
+
// groups for the same terminal/token pair.
|
|
104
|
+
for (uint256 j; j < i;) {
|
|
105
|
+
JBFundAccessLimitGroup calldata previousGroup = fundAccessLimitGroups[j];
|
|
106
|
+
if (
|
|
107
|
+
previousGroup.terminal == fundAccessLimitGroup.terminal
|
|
108
|
+
&& previousGroup.token == fundAccessLimitGroup.token
|
|
109
|
+
) {
|
|
110
|
+
revert JBFundAccessLimits_DuplicateFundAccessLimitGroup({
|
|
111
|
+
projectId: projectId,
|
|
112
|
+
rulesetId: rulesetId,
|
|
113
|
+
groupIndex: i,
|
|
114
|
+
terminal: fundAccessLimitGroup.terminal,
|
|
115
|
+
token: fundAccessLimitGroup.token
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
unchecked {
|
|
119
|
+
++j;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
98
123
|
// Keep a reference to the number of payout limits.
|
|
99
124
|
uint256 numberOfPayoutLimits = fundAccessLimitGroup.payoutLimits.length;
|
|
100
125
|
|
package/src/JBMultiTerminal.sol
CHANGED
|
@@ -64,14 +64,16 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
64
64
|
//*********************************************************************//
|
|
65
65
|
|
|
66
66
|
error JBMultiTerminal_FeeTerminalNotFound(address token);
|
|
67
|
-
error JBMultiTerminal_MintNotAllowed(
|
|
67
|
+
error JBMultiTerminal_MintNotAllowed();
|
|
68
68
|
error JBMultiTerminal_NoMsgValueAllowed(uint256 value);
|
|
69
69
|
error JBMultiTerminal_OverflowAlert(uint256 value, uint256 limit);
|
|
70
70
|
error JBMultiTerminal_PermitAllowanceNotEnough(uint256 amount, uint256 allowance);
|
|
71
71
|
error JBMultiTerminal_RecipientProjectTerminalNotFound(uint256 projectId, address token);
|
|
72
|
+
error JBMultiTerminal_ReentrantTokenTransfer(address token);
|
|
72
73
|
error JBMultiTerminal_SplitHookInvalid(IJBSplitHook hook);
|
|
73
|
-
error JBMultiTerminal_TerminalTokensIncompatible(uint256 projectId, address token, IJBTerminal terminal);
|
|
74
74
|
error JBMultiTerminal_TemporaryAllowanceNotConsumed(address token, address spender, uint256 allowance);
|
|
75
|
+
error JBMultiTerminal_TerminalMigrationToSelf(uint256 projectId, address token);
|
|
76
|
+
error JBMultiTerminal_TerminalTokensIncompatible(uint256 projectId, address token, IJBTerminal terminal);
|
|
75
77
|
error JBMultiTerminal_TokenNotAccepted(address token);
|
|
76
78
|
error JBMultiTerminal_UnderMin(uint256 value, uint256 min);
|
|
77
79
|
|
|
@@ -140,6 +142,18 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
140
142
|
/// @custom:param token The token the fees are held in.
|
|
141
143
|
mapping(uint256 projectId => mapping(address token => uint256)) internal _nextHeldFeeIndexOf;
|
|
142
144
|
|
|
145
|
+
//*********************************************************************//
|
|
146
|
+
// ------------------- transient stored properties ------------------- //
|
|
147
|
+
//*********************************************************************//
|
|
148
|
+
|
|
149
|
+
/// @notice Whether this terminal is currently measuring an incoming ERC-20 balance delta.
|
|
150
|
+
bool transient _acceptingToken;
|
|
151
|
+
|
|
152
|
+
/// @notice Source project ID for the same-terminal split pay currently being recorded.
|
|
153
|
+
/// @dev After `_pay` consumes and clears this value, `_fulfillPayHookSpecificationsFor` reuses the slot to return
|
|
154
|
+
/// the fee basis to `executePayout`.
|
|
155
|
+
uint256 transient _internalSplitPayProjectId;
|
|
156
|
+
|
|
143
157
|
//*********************************************************************//
|
|
144
158
|
// -------------------------- constructor ---------------------------- //
|
|
145
159
|
//*********************************************************************//
|
|
@@ -326,6 +340,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
326
340
|
}
|
|
327
341
|
|
|
328
342
|
if (!_isFeeless({addr: address(split.hook), projectId: projectId})) {
|
|
343
|
+
// Split hooks pull funds out of this terminal, so non-feeless hooks receive the net amount after
|
|
344
|
+
// the standard terminal fee.
|
|
329
345
|
unchecked {
|
|
330
346
|
netPayoutAmount -= _feeAmountFrom(amount);
|
|
331
347
|
}
|
|
@@ -347,6 +363,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
347
363
|
} else if (split.projectId != 0) {
|
|
348
364
|
// Get a reference to the terminal being used.
|
|
349
365
|
IJBTerminal terminal = _primaryTerminalOf({projectId: split.projectId, token: token});
|
|
366
|
+
bool isThisTerminal = terminal == this;
|
|
350
367
|
|
|
351
368
|
// The project must have a terminal to send funds to.
|
|
352
369
|
if (terminal == IJBTerminal(address(0))) {
|
|
@@ -358,7 +375,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
358
375
|
// the fee model taxes value leaving the protocol ecosystem, not internal rebalancing.
|
|
359
376
|
// This payout is eligible for a fee if the funds are leaving this contract and the receiving terminal isn't
|
|
360
377
|
// a feeless address.
|
|
361
|
-
if (
|
|
378
|
+
if (!isThisTerminal && !_isFeeless({addr: address(terminal), projectId: projectId})) {
|
|
379
|
+
// Cross-terminal payouts leave this terminal's custody, so charge the standard terminal fee unless
|
|
380
|
+
// the recipient terminal is feeless.
|
|
362
381
|
unchecked {
|
|
363
382
|
netPayoutAmount -= _feeAmountFrom(amount);
|
|
364
383
|
}
|
|
@@ -367,9 +386,22 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
367
386
|
|
|
368
387
|
// Track the fee-free payout amount. During cashout at zero tax rate, fees apply
|
|
369
388
|
// only up to this accumulated amount, preventing round-trip fee bypass.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
389
|
+
// Revert on any self-referencing payout (the source project paying itself via a split),
|
|
390
|
+
// regardless of which terminal receives the call or which branch (pay vs add-to-balance)
|
|
391
|
+
// is taken. Both shapes are disguised owner actions that the payout pipeline must not
|
|
392
|
+
// silently authorize:
|
|
393
|
+
// - pay branch: the destination terminal's `pay()` mints new project tokens against
|
|
394
|
+
// the project's own surplus, diluting holders out-of-cycle and bypassing the
|
|
395
|
+
// ruleset's `allowOwnerMinting=false` guarantee. This holds even when the
|
|
396
|
+
// destination terminal is a different instance owned by the same project, because
|
|
397
|
+
// every registered terminal can mint via the terminal-as-minter pathway.
|
|
398
|
+
// - addToBalance branch: a same-project add-balance split shuffles surplus between
|
|
399
|
+
// the project's own terminals through the payout pipeline. The same effect is
|
|
400
|
+
// available via `addToBalanceOf` directly without the side effects (locked-split
|
|
401
|
+
// consumption, payout-limit drawdown, fee-free-surplus accounting); routing it
|
|
402
|
+
// through `sendPayoutsOf` is never the right surface.
|
|
403
|
+
// The try-catch in the split group lib catches this revert and restores the balance.
|
|
404
|
+
if (split.projectId == projectId) revert JBMultiTerminal_MintNotAllowed();
|
|
373
405
|
|
|
374
406
|
// Send the `projectId` in the metadata as a referral.
|
|
375
407
|
bytes memory metadata = bytes(abi.encodePacked(projectId));
|
|
@@ -383,21 +415,20 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
383
415
|
amount: netPayoutAmount,
|
|
384
416
|
metadata: metadata
|
|
385
417
|
});
|
|
386
|
-
} else {
|
|
387
|
-
// Revert if this is a self-referencing payout (project paying itself via a split).
|
|
388
|
-
// Same-project pay splits would mint tokens against existing balance without new funds entering.
|
|
389
|
-
// Projects that want to mint should do so explicitly via the controller.
|
|
390
|
-
// Cross-project pay splits on the same terminal are allowed (different project receives the funds).
|
|
391
|
-
// The try-catch in the split group lib catches this revert and restores the balance.
|
|
392
|
-
if (terminal == this && split.projectId == projectId) {
|
|
393
|
-
revert JBMultiTerminal_MintNotAllowed({
|
|
394
|
-
projectId: projectId, splitProjectId: split.projectId, terminal: address(terminal)
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
418
|
|
|
419
|
+
// Same-terminal adds never invoke destination pay hooks, so the full amount remains in the
|
|
420
|
+
// destination project's balance and must be fee-liable on its later zero-tax cashout.
|
|
421
|
+
if (isThisTerminal) _feeFreeSurplusOf[split.projectId][token] += netPayoutAmount;
|
|
422
|
+
} else {
|
|
398
423
|
// Keep a reference to the beneficiary of the payment.
|
|
399
424
|
address beneficiary = split.beneficiary != address(0) ? split.beneficiary : originalMessageSender;
|
|
400
425
|
|
|
426
|
+
// Mark same-terminal split pays so `_pay` can fee pay-hook forwards inline and track only retained
|
|
427
|
+
// value as fee-free surplus.
|
|
428
|
+
if (isThisTerminal) {
|
|
429
|
+
_internalSplitPayProjectId = projectId;
|
|
430
|
+
}
|
|
431
|
+
|
|
401
432
|
_efficientPay({
|
|
402
433
|
terminal: terminal,
|
|
403
434
|
projectId: split.projectId,
|
|
@@ -407,12 +438,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
407
438
|
metadata: metadata
|
|
408
439
|
});
|
|
409
440
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// STORE.balanceOf, causing users to be overcharged fees on zero-tax cashouts.
|
|
415
|
-
_capFeeFreeSurplus({projectId: split.projectId, token: token});
|
|
441
|
+
if (isThisTerminal) {
|
|
442
|
+
feeEligibleAmount += _internalSplitPayProjectId;
|
|
443
|
+
delete _internalSplitPayProjectId;
|
|
444
|
+
}
|
|
416
445
|
}
|
|
417
446
|
} else {
|
|
418
447
|
// If there's a beneficiary, send the funds directly to the beneficiary.
|
|
@@ -423,6 +452,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
423
452
|
// This payout is eligible for a fee since the funds are leaving this contract and the recipient isn't a
|
|
424
453
|
// feeless address.
|
|
425
454
|
if (!_isFeeless({addr: recipient, projectId: projectId})) {
|
|
455
|
+
// Direct payouts leave the terminal, so non-feeless recipients receive the net amount after the
|
|
456
|
+
// standard terminal fee.
|
|
426
457
|
unchecked {
|
|
427
458
|
netPayoutAmount -= _feeAmountFrom(amount);
|
|
428
459
|
}
|
|
@@ -507,6 +538,12 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
507
538
|
account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.MIGRATE_TERMINAL
|
|
508
539
|
});
|
|
509
540
|
|
|
541
|
+
// Migrating to the same terminal would zero this terminal's store balance and then try to re-add it through
|
|
542
|
+
// the external terminal interface. ERC-20 self-transfers produce no balance delta, leaving funds stranded.
|
|
543
|
+
if (address(to) == address(this)) {
|
|
544
|
+
revert JBMultiTerminal_TerminalMigrationToSelf({projectId: projectId, token: token});
|
|
545
|
+
}
|
|
546
|
+
|
|
510
547
|
// The terminal being migrated to must accept the same token as this terminal.
|
|
511
548
|
if (to.accountingContextForTokenOf({projectId: projectId, token: token}).currency == 0) {
|
|
512
549
|
revert JBMultiTerminal_TerminalTokensIncompatible({projectId: projectId, token: token, terminal: to});
|
|
@@ -531,6 +568,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
531
568
|
// This also settles any fee-free surplus liability that would otherwise be lost on the new terminal.
|
|
532
569
|
uint256 feeAmount;
|
|
533
570
|
if (!_isFeeless({addr: address(to), projectId: projectId}) && projectId != _FEE_BENEFICIARY_PROJECT_ID) {
|
|
571
|
+
// Fee processing failures never block migration. If the fee route is broken, `_processFee` credits
|
|
572
|
+
// the fee amount back to this source terminal and emits `FeeReverted`; the post-fee amount still
|
|
573
|
+
// migrates so project funds are not trapped behind project #1 routing issues.
|
|
534
574
|
feeAmount = _takeFeeFrom({
|
|
535
575
|
projectId: projectId,
|
|
536
576
|
token: token,
|
|
@@ -654,7 +694,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
654
694
|
delete _heldFeesOf[projectId][token][currentIndex];
|
|
655
695
|
_nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
|
|
656
696
|
|
|
657
|
-
// Process the fee.
|
|
697
|
+
// Process the standard fee on the original gross amount recorded when the held fee was created.
|
|
658
698
|
_processFee({
|
|
659
699
|
projectId: projectId,
|
|
660
700
|
token: token,
|
|
@@ -1028,11 +1068,20 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1028
1068
|
// Get a reference to the balance before receiving tokens.
|
|
1029
1069
|
uint256 balanceBefore = _balanceOf(token);
|
|
1030
1070
|
|
|
1071
|
+
// Prevent callback-capable tokens from nesting another incoming ERC-20 transfer inside this balance-delta
|
|
1072
|
+
// measurement.
|
|
1073
|
+
if (_acceptingToken) revert JBMultiTerminal_ReentrantTokenTransfer(token);
|
|
1074
|
+
_acceptingToken = true;
|
|
1075
|
+
|
|
1031
1076
|
// Transfer tokens to this terminal from the msg sender.
|
|
1032
1077
|
_transferFrom({from: _msgSender(), to: payable(address(this)), token: token, amount: amount});
|
|
1033
1078
|
|
|
1034
1079
|
// The amount should reflect the change in balance.
|
|
1035
|
-
|
|
1080
|
+
uint256 acceptedAmount = _balanceOf(token) - balanceBefore;
|
|
1081
|
+
|
|
1082
|
+
_acceptingToken = false;
|
|
1083
|
+
|
|
1084
|
+
return acceptedAmount;
|
|
1036
1085
|
}
|
|
1037
1086
|
|
|
1038
1087
|
/// @notice Adds funds to a project's balance without minting tokens.
|
|
@@ -1346,6 +1395,34 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1346
1395
|
}
|
|
1347
1396
|
}
|
|
1348
1397
|
|
|
1398
|
+
/// @notice Emits a `Pay` event. Extracted from `_pay` so the 9-field event payload gets its own stack frame —
|
|
1399
|
+
/// inlining the emit into `_pay` overflows the non-IR build's stack budget.
|
|
1400
|
+
function _emitPay(
|
|
1401
|
+
JBRuleset memory ruleset,
|
|
1402
|
+
uint256 projectId,
|
|
1403
|
+
address payer,
|
|
1404
|
+
address beneficiary,
|
|
1405
|
+
uint256 amount,
|
|
1406
|
+
uint256 newlyIssuedTokenCount,
|
|
1407
|
+
string memory memo,
|
|
1408
|
+
bytes memory metadata
|
|
1409
|
+
)
|
|
1410
|
+
internal
|
|
1411
|
+
{
|
|
1412
|
+
emit Pay({
|
|
1413
|
+
rulesetId: ruleset.id,
|
|
1414
|
+
rulesetCycleNumber: ruleset.cycleNumber,
|
|
1415
|
+
projectId: projectId,
|
|
1416
|
+
payer: payer,
|
|
1417
|
+
beneficiary: beneficiary,
|
|
1418
|
+
amount: amount,
|
|
1419
|
+
newlyIssuedTokenCount: newlyIssuedTokenCount,
|
|
1420
|
+
memo: memo,
|
|
1421
|
+
metadata: metadata,
|
|
1422
|
+
caller: _msgSender()
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1349
1426
|
/// @notice Fund a project on another terminal by granting a temporary pull allowance for this call only.
|
|
1350
1427
|
/// @param terminal The recipient terminal.
|
|
1351
1428
|
/// @param projectId The ID of the project to fund.
|
|
@@ -1433,7 +1510,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1433
1510
|
continue;
|
|
1434
1511
|
}
|
|
1435
1512
|
|
|
1436
|
-
//
|
|
1513
|
+
// Cash-out hooks receive the net amount after the standard fee unless the hook is feeless.
|
|
1437
1514
|
uint256 specificationAmountFee = _isFeeless({addr: address(specification.hook), projectId: projectId})
|
|
1438
1515
|
? 0
|
|
1439
1516
|
: _feeAmountFrom(specification.amount);
|
|
@@ -1489,6 +1566,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1489
1566
|
/// @param beneficiary The address which will receive any tokens that the payment yields.
|
|
1490
1567
|
/// @param newlyIssuedTokenCount The number of tokens issued and sent to the beneficiary.
|
|
1491
1568
|
/// @param metadata Bytes to send along to the emitted event and pay hooks as applicable.
|
|
1569
|
+
/// @param internalSplitPayProjectId The source project when this payment came from a same-terminal split.
|
|
1492
1570
|
function _fulfillPayHookSpecificationsFor(
|
|
1493
1571
|
uint256 projectId,
|
|
1494
1572
|
JBPayHookSpecification[] memory specifications,
|
|
@@ -1497,10 +1575,13 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1497
1575
|
JBRuleset memory ruleset,
|
|
1498
1576
|
address beneficiary,
|
|
1499
1577
|
uint256 newlyIssuedTokenCount,
|
|
1500
|
-
bytes memory metadata
|
|
1578
|
+
bytes memory metadata,
|
|
1579
|
+
uint256 internalSplitPayProjectId
|
|
1501
1580
|
)
|
|
1502
1581
|
internal
|
|
1503
1582
|
{
|
|
1583
|
+
uint256 amountEligibleForFees;
|
|
1584
|
+
|
|
1504
1585
|
// Keep a reference to payment context for the pay hooks.
|
|
1505
1586
|
JBAfterPayRecordedContext memory context = JBAfterPayRecordedContext({
|
|
1506
1587
|
payer: payer,
|
|
@@ -1528,9 +1609,25 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1528
1609
|
continue;
|
|
1529
1610
|
}
|
|
1530
1611
|
|
|
1612
|
+
uint256 specificationAmount = specification.amount;
|
|
1613
|
+
|
|
1614
|
+
if (
|
|
1615
|
+
internalSplitPayProjectId != 0
|
|
1616
|
+
&& !_isFeeless({addr: address(specification.hook), projectId: internalSplitPayProjectId})
|
|
1617
|
+
) {
|
|
1618
|
+
// Same-terminal split pays defer source-side fees until the destination data hook is known. Net
|
|
1619
|
+
// non-feeless hook forwards here so they match ordinary payout semantics before funds leave.
|
|
1620
|
+
// Keep the fee basis local until every hook returns. Writing the transient accumulator before the
|
|
1621
|
+
// hook call would let a reentrant payout overwrite the outer split's pending fee basis.
|
|
1622
|
+
unchecked {
|
|
1623
|
+
amountEligibleForFees += specificationAmount;
|
|
1624
|
+
specificationAmount -= _feeAmountFrom(specificationAmount);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1531
1628
|
// Pass the correct token `forwardedAmount` to the hook.
|
|
1532
1629
|
context.forwardedAmount = JBTokenAmount({
|
|
1533
|
-
value:
|
|
1630
|
+
value: specificationAmount,
|
|
1534
1631
|
token: tokenAmount.token,
|
|
1535
1632
|
decimals: tokenAmount.decimals,
|
|
1536
1633
|
currency: tokenAmount.currency
|
|
@@ -1542,7 +1639,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1542
1639
|
// Trigger any inherited pre-transfer logic.
|
|
1543
1640
|
// Keep a reference to the amount that'll be paid as a `msg.value`.
|
|
1544
1641
|
uint256 payValue = _beforeTransferTo({
|
|
1545
|
-
to: address(specification.hook), token: tokenAmount.token, amount:
|
|
1642
|
+
to: address(specification.hook), token: tokenAmount.token, amount: specificationAmount
|
|
1546
1643
|
});
|
|
1547
1644
|
|
|
1548
1645
|
// Fulfill the specification.
|
|
@@ -1554,13 +1651,17 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1554
1651
|
emit HookAfterRecordPay({
|
|
1555
1652
|
hook: specification.hook,
|
|
1556
1653
|
context: context,
|
|
1557
|
-
specificationAmount:
|
|
1654
|
+
specificationAmount: specificationAmount,
|
|
1558
1655
|
caller: _msgSender()
|
|
1559
1656
|
});
|
|
1560
1657
|
unchecked {
|
|
1561
1658
|
++i;
|
|
1562
1659
|
}
|
|
1563
1660
|
}
|
|
1661
|
+
|
|
1662
|
+
// `_pay` consumed and cleared the source project ID before calling hooks, so the transient slot can now carry
|
|
1663
|
+
// the hook-derived fee basis back to `executePayout`. Publish it only after all untrusted hook calls return.
|
|
1664
|
+
_internalSplitPayProjectId = amountEligibleForFees;
|
|
1564
1665
|
}
|
|
1565
1666
|
|
|
1566
1667
|
/// @notice Internal implementation of payment logic. Records the payment in the store, mints tokens via the
|
|
@@ -1586,6 +1687,11 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1586
1687
|
)
|
|
1587
1688
|
internal
|
|
1588
1689
|
{
|
|
1690
|
+
// Same-terminal split pays are the only inbound pays whose source-side fee was intentionally deferred. Cache
|
|
1691
|
+
// and clear the transient source project before untrusted data/pay hooks can reenter ordinary pay flows.
|
|
1692
|
+
uint256 internalSplitPayProjectId = _internalSplitPayProjectId;
|
|
1693
|
+
if (internalSplitPayProjectId != 0) delete _internalSplitPayProjectId;
|
|
1694
|
+
|
|
1589
1695
|
// Keep a reference to the token amount to forward to the store.
|
|
1590
1696
|
JBTokenAmount memory tokenAmount = _tokenAmountOf({projectId: projectId, token: token, value: amount});
|
|
1591
1697
|
|
|
@@ -1597,6 +1703,20 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1597
1703
|
payer: payer, amount: tokenAmount, projectId: projectId, beneficiary: beneficiary, metadata: metadata
|
|
1598
1704
|
});
|
|
1599
1705
|
|
|
1706
|
+
// Only the value retained in the destination balance needs later cashout fee recovery. Non-feeless pay-hook
|
|
1707
|
+
// forwards pay their source-equivalent fee inline before leaving the project.
|
|
1708
|
+
if (internalSplitPayProjectId != 0) {
|
|
1709
|
+
uint256 feeFreeAmount = tokenAmount.value;
|
|
1710
|
+
for (uint256 i; i < hookSpecifications.length;) {
|
|
1711
|
+
// The store already proved the hook-spec total does not exceed the pay amount.
|
|
1712
|
+
unchecked {
|
|
1713
|
+
feeFreeAmount -= hookSpecifications[i].amount;
|
|
1714
|
+
++i;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
_feeFreeSurplusOf[projectId][token] += feeFreeAmount;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1600
1720
|
// Keep a reference to the number of tokens issued for the beneficiary.
|
|
1601
1721
|
uint256 newlyIssuedTokenCount;
|
|
1602
1722
|
|
|
@@ -1614,17 +1734,19 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1614
1734
|
});
|
|
1615
1735
|
}
|
|
1616
1736
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1737
|
+
// `_pay` already carries ~10 locals (ruleset, tokenCount, hookSpecifications, balanceDiff, tokenAmount,
|
|
1738
|
+
// newlyIssuedTokenCount, internalSplitPayProjectId, feeFreeAmount, plus loop-local `i` and `hookAmount`).
|
|
1739
|
+
// Inlining the 9-arg `emit Pay` here hits "Stack too deep" under the non-IR build, so the emit is extracted
|
|
1740
|
+
// to `_emitPay` which gets its own stack frame.
|
|
1741
|
+
_emitPay({
|
|
1742
|
+
ruleset: ruleset,
|
|
1620
1743
|
projectId: projectId,
|
|
1621
1744
|
payer: payer,
|
|
1622
1745
|
beneficiary: beneficiary,
|
|
1623
1746
|
amount: amount,
|
|
1624
1747
|
newlyIssuedTokenCount: newlyIssuedTokenCount,
|
|
1625
1748
|
memo: memo,
|
|
1626
|
-
metadata: metadata
|
|
1627
|
-
caller: _msgSender()
|
|
1749
|
+
metadata: metadata
|
|
1628
1750
|
});
|
|
1629
1751
|
|
|
1630
1752
|
// If the data hook returned pay hook specifications, fulfill them.
|
|
@@ -1637,7 +1759,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1637
1759
|
ruleset: ruleset,
|
|
1638
1760
|
beneficiary: beneficiary,
|
|
1639
1761
|
newlyIssuedTokenCount: newlyIssuedTokenCount,
|
|
1640
|
-
metadata: metadata
|
|
1762
|
+
metadata: metadata,
|
|
1763
|
+
internalSplitPayProjectId: internalSplitPayProjectId
|
|
1641
1764
|
});
|
|
1642
1765
|
}
|
|
1643
1766
|
}
|
|
@@ -1649,6 +1772,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1649
1772
|
/// @param beneficiary The address which will receive any platform tokens minted.
|
|
1650
1773
|
/// @param feeTerminal The terminal that'll receive the fee.
|
|
1651
1774
|
/// @param wasHeld A flag indicating if the fee to process was held by this terminal.
|
|
1775
|
+
/// @dev Fee-route failures are forgiven instead of reverted so project funds cannot be trapped by project #1
|
|
1776
|
+
/// misconfiguration. The failed fee amount is credited back to the payer project's balance on this terminal.
|
|
1652
1777
|
function _processFee(
|
|
1653
1778
|
uint256 projectId,
|
|
1654
1779
|
address token,
|
|
@@ -1671,10 +1796,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1671
1796
|
caller: _msgSender()
|
|
1672
1797
|
});
|
|
1673
1798
|
} catch (bytes memory reason) {
|
|
1674
|
-
// Fee processing
|
|
1675
|
-
//
|
|
1676
|
-
//
|
|
1677
|
-
// funds. The `FeeReverted` event makes this observable off-chain.
|
|
1799
|
+
// Fee processing is fail-open for project liveness: a broken project #1 terminal or fee route must not
|
|
1800
|
+
// trap payouts, cash outs, allowances, held-fee processing, or terminal migration. The fee is forgiven,
|
|
1801
|
+
// credited back to the originating project on this terminal, and surfaced through `FeeReverted`.
|
|
1678
1802
|
emit FeeReverted({
|
|
1679
1803
|
projectId: projectId,
|
|
1680
1804
|
token: token,
|
|
@@ -1727,6 +1851,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1727
1851
|
// Held fees store the original gross amount that paid out before its fee was removed.
|
|
1728
1852
|
JBFee memory heldFee = _heldFeesOf[projectId][token][i];
|
|
1729
1853
|
|
|
1854
|
+
// Recompute the standard fee associated with the held gross amount.
|
|
1730
1855
|
uint256 feeAmount = _feeAmountFrom(heldFee.amount);
|
|
1731
1856
|
|
|
1732
1857
|
// This is the net amount that originally left the project after the held fee was removed.
|
|
@@ -1892,7 +2017,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
1892
2017
|
/// accounting context.
|
|
1893
2018
|
/// @param beneficiary The address to mint the platform's project's tokens for.
|
|
1894
2019
|
/// @param shouldHoldFees If fees should be tracked and held instead of processing them immediately.
|
|
1895
|
-
/// @return feeAmount The
|
|
2020
|
+
/// @return feeAmount The fee withheld from the current outflow. If immediate fee processing fails, `_processFee`
|
|
2021
|
+
/// credits this amount back to the payer project while the current outflow continues.
|
|
1896
2022
|
function _takeFeeFrom(
|
|
1897
2023
|
uint256 projectId,
|
|
1898
2024
|
address token,
|
|
@@ -2098,11 +2224,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
2098
2224
|
}
|
|
2099
2225
|
|
|
2100
2226
|
/// @notice Returns a flag indicating if interacting with an address should not incur fees.
|
|
2227
|
+
/// @dev Forwards `_msgSender()` (the outer caller of the terminal, with ERC-2771 forwarders unwrapped) to the
|
|
2228
|
+
/// registry so an installed feeless hook can scope its grant by caller — e.g. recognise an ecosystem router
|
|
2229
|
+
/// that wraps cash-out → pay and grant it fee-free cash-outs only when it itself is the caller.
|
|
2101
2230
|
/// @param addr The address to check.
|
|
2102
2231
|
/// @param projectId The ID of the project to check the per-project feeless status for.
|
|
2103
2232
|
/// @return A flag indicating if the address should not incur fees (globally or for the project).
|
|
2104
2233
|
function _isFeeless(address addr, uint256 projectId) internal view returns (bool) {
|
|
2105
|
-
return FEELESS_ADDRESSES.isFeelessFor({addr: addr, projectId: projectId});
|
|
2234
|
+
return FEELESS_ADDRESSES.isFeelessFor({addr: addr, projectId: projectId, caller: _msgSender()});
|
|
2106
2235
|
}
|
|
2107
2236
|
|
|
2108
2237
|
/// @notice The calldata. Preferred to use over `msg.data`.
|
package/src/JBPrices.sol
CHANGED
|
@@ -27,6 +27,7 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
|
|
|
27
27
|
|
|
28
28
|
error JBPrices_PriceFeedAlreadyExists(IJBPriceFeed feed);
|
|
29
29
|
error JBPrices_PriceFeedNotFound(uint256 projectId, uint256 pricingCurrency, uint256 unitCurrency);
|
|
30
|
+
error JBPrices_ZeroPrice(uint256 projectId, uint256 pricingCurrency, uint256 unitCurrency, IJBPriceFeed feed);
|
|
30
31
|
error JBPrices_ZeroPricingCurrency(uint256 projectId, uint256 pricingCurrency);
|
|
31
32
|
error JBPrices_ZeroUnitCurrency(uint256 projectId, uint256 unitCurrency);
|
|
32
33
|
|
|
@@ -191,8 +192,16 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
|
|
|
191
192
|
// Get a reference to the price feed.
|
|
192
193
|
IJBPriceFeed feed = priceFeedFor[projectId][pricingCurrency][unitCurrency];
|
|
193
194
|
|
|
194
|
-
// If the feed exists, return its price.
|
|
195
|
-
if (feed != IJBPriceFeed(address(0)))
|
|
195
|
+
// If the feed exists, return its non-zero price.
|
|
196
|
+
if (feed != IJBPriceFeed(address(0))) {
|
|
197
|
+
uint256 price = feed.currentUnitPrice(decimals);
|
|
198
|
+
if (price == 0) {
|
|
199
|
+
revert JBPrices_ZeroPrice({
|
|
200
|
+
projectId: projectId, pricingCurrency: pricingCurrency, unitCurrency: unitCurrency, feed: feed
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return price;
|
|
204
|
+
}
|
|
196
205
|
|
|
197
206
|
// Try getting the inverse feed.
|
|
198
207
|
feed = priceFeedFor[projectId][unitCurrency][pricingCurrency];
|
|
@@ -202,7 +211,13 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
|
|
|
202
211
|
// is in the range of ~1e9 to ~1e27 (for 18 decimals). Extreme prices outside this range may lose
|
|
203
212
|
// significant precision due to fixed-point division truncation.
|
|
204
213
|
if (feed != IJBPriceFeed(address(0))) {
|
|
205
|
-
|
|
214
|
+
uint256 inversePrice = feed.currentUnitPrice(decimals);
|
|
215
|
+
if (inversePrice == 0) {
|
|
216
|
+
revert JBPrices_ZeroPrice({
|
|
217
|
+
projectId: projectId, pricingCurrency: unitCurrency, unitCurrency: pricingCurrency, feed: feed
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return mulDiv({x: 10 ** decimals, y: 10 ** decimals, denominator: inversePrice});
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
// Check for a default feed (project ID 0) if not found.
|
package/src/JBRulesets.sol
CHANGED
|
@@ -623,11 +623,11 @@ contract JBRulesets is JBControlled, IJBRulesets {
|
|
|
623
623
|
{
|
|
624
624
|
// A subsequent ruleset to one with a duration of 0 should have the next possible weight.
|
|
625
625
|
if (baseRulesetDuration == 0) {
|
|
626
|
-
return mulDiv(
|
|
627
|
-
baseRulesetWeight,
|
|
628
|
-
JBConstants.MAX_WEIGHT_CUT_PERCENT - baseRulesetWeightCutPercent,
|
|
629
|
-
JBConstants.MAX_WEIGHT_CUT_PERCENT
|
|
630
|
-
);
|
|
626
|
+
return mulDiv({
|
|
627
|
+
x: baseRulesetWeight,
|
|
628
|
+
y: JBConstants.MAX_WEIGHT_CUT_PERCENT - baseRulesetWeightCutPercent,
|
|
629
|
+
denominator: JBConstants.MAX_WEIGHT_CUT_PERCENT
|
|
630
|
+
});
|
|
631
631
|
}
|
|
632
632
|
|
|
633
633
|
// The weight should be based off the base ruleset's weight.
|
|
@@ -673,7 +673,7 @@ contract JBRulesets is JBControlled, IJBRulesets {
|
|
|
673
673
|
|
|
674
674
|
for (uint256 i; i < weightCutMultiple;) {
|
|
675
675
|
// Base the new weight on the specified ruleset's weight.
|
|
676
|
-
weight = mulDiv(weight, cutFactor, maxPercent);
|
|
676
|
+
weight = mulDiv({x: weight, y: cutFactor, denominator: maxPercent});
|
|
677
677
|
|
|
678
678
|
// The calculation doesn't need to continue if the weight is 0.
|
|
679
679
|
if (weight == 0) break;
|
package/src/JBSplits.sol
CHANGED
|
@@ -173,14 +173,30 @@ contract JBSplits is JBControlled, IJBSplits {
|
|
|
173
173
|
uint256 numberOfCurrentSplits = currentSplits.length;
|
|
174
174
|
|
|
175
175
|
// Check to see if all locked splits are included in the array of splits which is being set.
|
|
176
|
+
// Duplicate locked splits must be preserved with the same multiplicity. Otherwise a table with two identical
|
|
177
|
+
// locked splits could be collapsed into one matching split and one unrelated split while each old split sees
|
|
178
|
+
// the same new match.
|
|
176
179
|
for (uint256 i; i < numberOfCurrentSplits;) {
|
|
177
180
|
// If not locked, continue.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
// forge-lint: disable-next-line(block-timestamp)
|
|
182
|
+
if (block.timestamp < currentSplits[i].lockedUntil) {
|
|
183
|
+
uint256 requiredCount;
|
|
184
|
+
for (uint256 j; j < numberOfCurrentSplits;) {
|
|
185
|
+
if (
|
|
186
|
+
// forge-lint: disable-next-line(block-timestamp)
|
|
187
|
+
block.timestamp < currentSplits[j].lockedUntil
|
|
188
|
+
&& _isLockedSplitIncluded({split: currentSplits[j], lockedSplit: currentSplits[i]})
|
|
189
|
+
) {
|
|
190
|
+
++requiredCount;
|
|
191
|
+
}
|
|
192
|
+
unchecked {
|
|
193
|
+
++j;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (_includedLockedSplitCount({splits: splits, lockedSplit: currentSplits[i]}) < requiredCount) {
|
|
198
|
+
revert JBSplits_PreviousLockedSplitsNotIncluded({projectId: projectId, rulesetId: rulesetId});
|
|
199
|
+
}
|
|
184
200
|
}
|
|
185
201
|
unchecked {
|
|
186
202
|
++i;
|
|
@@ -332,31 +348,38 @@ contract JBSplits is JBControlled, IJBSplits {
|
|
|
332
348
|
return splits;
|
|
333
349
|
}
|
|
334
350
|
|
|
335
|
-
/// @notice
|
|
351
|
+
/// @notice Count the splits in the provided array that include the locked split.
|
|
336
352
|
/// @param splits The array of splits to check within.
|
|
337
353
|
/// @param lockedSplit The locked split.
|
|
338
|
-
/// @return
|
|
339
|
-
function
|
|
354
|
+
/// @return count The number of matching splits.
|
|
355
|
+
function _includedLockedSplitCount(
|
|
356
|
+
JBSplit[] memory splits,
|
|
357
|
+
JBSplit memory lockedSplit
|
|
358
|
+
)
|
|
359
|
+
internal
|
|
360
|
+
pure
|
|
361
|
+
returns (uint256 count)
|
|
362
|
+
{
|
|
340
363
|
// Keep a reference to the number of splits.
|
|
341
364
|
uint256 numberOfSplits = splits.length;
|
|
342
365
|
|
|
343
366
|
for (uint256 i; i < numberOfSplits;) {
|
|
344
|
-
|
|
345
|
-
JBSplit memory split = splits[i];
|
|
346
|
-
|
|
347
|
-
// Check for sameness.
|
|
348
|
-
if (
|
|
349
|
-
// Allow the lock to be extended.
|
|
350
|
-
split.percent == lockedSplit.percent && split.beneficiary == lockedSplit.beneficiary
|
|
351
|
-
&& split.hook == lockedSplit.hook && split.projectId == lockedSplit.projectId
|
|
352
|
-
&& split.preferAddToBalance == lockedSplit.preferAddToBalance
|
|
353
|
-
&& split.lockedUntil >= lockedSplit.lockedUntil
|
|
354
|
-
) return true;
|
|
367
|
+
if (_isLockedSplitIncluded({split: splits[i], lockedSplit: lockedSplit})) ++count;
|
|
355
368
|
unchecked {
|
|
356
369
|
++i;
|
|
357
370
|
}
|
|
358
371
|
}
|
|
372
|
+
}
|
|
359
373
|
|
|
360
|
-
|
|
374
|
+
/// @notice Determine if a split satisfies the locked split's immutable fields and lock length.
|
|
375
|
+
/// @param split The split to check.
|
|
376
|
+
/// @param lockedSplit The locked split.
|
|
377
|
+
/// @return A flag indicating whether the split includes the locked split.
|
|
378
|
+
function _isLockedSplitIncluded(JBSplit memory split, JBSplit memory lockedSplit) internal pure returns (bool) {
|
|
379
|
+
// Allow the lock to be extended.
|
|
380
|
+
return split.percent == lockedSplit.percent && split.beneficiary == lockedSplit.beneficiary
|
|
381
|
+
&& split.hook == lockedSplit.hook && split.projectId == lockedSplit.projectId
|
|
382
|
+
&& split.preferAddToBalance == lockedSplit.preferAddToBalance
|
|
383
|
+
&& split.lockedUntil >= lockedSplit.lockedUntil;
|
|
361
384
|
}
|
|
362
385
|
}
|
package/src/JBTokens.sol
CHANGED
|
@@ -230,7 +230,7 @@ contract JBTokens is JBControlled, IJBTokens {
|
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
// Initialize the token.
|
|
233
|
-
token.initialize({name: name, symbol: symbol,
|
|
233
|
+
token.initialize({name: name, symbol: symbol, tokensAddress: address(this)});
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
/// @notice Create new tokens for a holder. If the project has an ERC-20 deployed, tokens are minted directly to
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
pragma solidity ^0.8.0;
|
|
3
3
|
|
|
4
|
+
import {IJBFeelessHook} from "./IJBFeelessHook.sol";
|
|
5
|
+
|
|
4
6
|
/// @notice Tracks addresses that are exempt from fees, both globally and on a per-project basis.
|
|
5
7
|
/// @dev `projectId = 0` is the wildcard — an address feeless for project 0 is feeless for ALL projects.
|
|
6
8
|
interface IJBFeelessAddresses {
|
|
@@ -11,12 +13,26 @@ interface IJBFeelessAddresses {
|
|
|
11
13
|
/// @param caller The address that set the feeless status.
|
|
12
14
|
event SetFeelessAddress(uint256 indexed projectId, address indexed addr, bool indexed isFeeless, address caller);
|
|
13
15
|
|
|
14
|
-
/// @notice
|
|
15
|
-
///
|
|
16
|
+
/// @notice The optional hook (set by the owner) that can grant feeless status with arbitrary logic. Set to the
|
|
17
|
+
/// zero address to disable.
|
|
18
|
+
/// @param hook The new feeless hook. The zero address disables hook consultation.
|
|
19
|
+
/// @param caller The address that set the hook.
|
|
20
|
+
event SetFeelessHook(IJBFeelessHook indexed hook, address caller);
|
|
21
|
+
|
|
22
|
+
/// @notice The optional hook consulted (in addition to the static mappings) when computing feeless status.
|
|
23
|
+
/// @dev `address(0)` means no hook is set.
|
|
24
|
+
function feelessHook() external view returns (IJBFeelessHook);
|
|
25
|
+
|
|
26
|
+
/// @notice Returns whether the specified address is feeless for a specific project, providing the outer caller
|
|
27
|
+
/// of the fee-bearing operation so hooks can scope grants by who initiated the action.
|
|
28
|
+
/// @dev Static admin-set mappings are op-agnostic and always apply. The hook (if set) receives `caller` and may
|
|
29
|
+
/// use it to scope its grant (e.g. an ecosystem router can be feeless only when it itself is the caller).
|
|
16
30
|
/// @param addr The address to check.
|
|
17
31
|
/// @param projectId The ID of the project to check.
|
|
18
|
-
/// @
|
|
19
|
-
|
|
32
|
+
/// @param caller The outer caller (typically the terminal's `_msgSender()`). Pass `address(0)` for lookups
|
|
33
|
+
/// without caller context.
|
|
34
|
+
/// @return A flag indicating whether the address is feeless.
|
|
35
|
+
function isFeelessFor(address addr, uint256 projectId, address caller) external view returns (bool);
|
|
20
36
|
|
|
21
37
|
/// @notice Sets whether an address is feeless globally (for all projects).
|
|
22
38
|
/// @param addr The address to set the feeless status of.
|
|
@@ -28,4 +44,8 @@ interface IJBFeelessAddresses {
|
|
|
28
44
|
/// @param addr The address to set the feeless status of.
|
|
29
45
|
/// @param flag A flag indicating whether the address should be feeless for the project.
|
|
30
46
|
function setFeelessAddressFor(uint256 projectId, address addr, bool flag) external;
|
|
47
|
+
|
|
48
|
+
/// @notice Sets (or clears) the feeless hook consulted by `isFeelessFor`.
|
|
49
|
+
/// @param hook The new hook. Pass `address(0)` to disable hook consultation.
|
|
50
|
+
function setFeelessHook(IJBFeelessHook hook) external;
|
|
31
51
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.0;
|
|
3
|
+
|
|
4
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
5
|
+
|
|
6
|
+
/// @notice Optional hook that can grant feeless status to addresses based on arbitrary off-chain or on-chain logic.
|
|
7
|
+
/// @dev Plugged into `JBFeelessAddresses` by the contract owner. The hook is OR'd with the static feeless mappings,
|
|
8
|
+
/// so it can only widen the feeless set, never shrink it.
|
|
9
|
+
/// @dev `JBFeelessAddresses` invokes this hook inside a try/catch — a reverting hook is treated as returning `false`,
|
|
10
|
+
/// so a broken hook cannot brick the fee path in terminals.
|
|
11
|
+
interface IJBFeelessHook is IERC165 {
|
|
12
|
+
/// @notice Returns whether the address should be treated as feeless for the project.
|
|
13
|
+
/// @dev `caller` is the outer caller that triggered the fee-bearing terminal operation (as resolved by the
|
|
14
|
+
/// terminal's `_msgSender()`, so ERC-2771 meta-tx forwarders are unwrapped). It is `address(0)` when the registry
|
|
15
|
+
/// is queried without a caller hint (e.g. a direct off-chain `eth_call` to the registry). Use `caller` together
|
|
16
|
+
/// with `addr` to scope grants: e.g. an ecosystem router can be feeless ONLY when it is itself the entity calling
|
|
17
|
+
/// the terminal (so it gets fee-free cash-outs when zapping, but does NOT get feeless status when it merely
|
|
18
|
+
/// appears as a split recipient of someone else's payout).
|
|
19
|
+
/// @param projectId The ID of the project the fee would be charged on behalf of.
|
|
20
|
+
/// @param addr The address being checked (typically a payout recipient, surplus allowance beneficiary, or
|
|
21
|
+
/// cash-out beneficiary).
|
|
22
|
+
/// @param caller The outer caller that initiated the terminal operation, or `address(0)` if not provided.
|
|
23
|
+
/// @return A flag indicating whether the address is feeless for the project under the hook's custom logic.
|
|
24
|
+
function isFeeless(uint256 projectId, address addr, address caller) external view returns (bool);
|
|
25
|
+
}
|
|
@@ -29,8 +29,8 @@ interface IJBToken {
|
|
|
29
29
|
/// @notice Initializes the token with a name, symbol, and the JBTokens contract.
|
|
30
30
|
/// @param name The token's name.
|
|
31
31
|
/// @param symbol The token's symbol.
|
|
32
|
-
/// @param
|
|
33
|
-
function initialize(string memory name, string memory symbol, address
|
|
32
|
+
/// @param tokensAddress The JBTokens contract that manages this token.
|
|
33
|
+
function initialize(string memory name, string memory symbol, address tokensAddress) external;
|
|
34
34
|
|
|
35
35
|
/// @notice Mints tokens to an account.
|
|
36
36
|
/// @param account The address to mint tokens to.
|
|
@@ -42,7 +42,7 @@ library JBCashOuts {
|
|
|
42
42
|
if (cashOutCount >= totalSupply) return surplus;
|
|
43
43
|
|
|
44
44
|
// Get a reference to the linear proportion.
|
|
45
|
-
uint256 base = mulDiv(surplus, cashOutCount, totalSupply);
|
|
45
|
+
uint256 base = mulDiv({x: surplus, y: cashOutCount, denominator: totalSupply});
|
|
46
46
|
|
|
47
47
|
// These conditions are all part of the same curve.
|
|
48
48
|
// Edge conditions are separated to minimize the operations performed in those cases.
|
|
@@ -50,11 +50,12 @@ library JBCashOuts {
|
|
|
50
50
|
return base;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
return mulDiv(
|
|
54
|
-
base,
|
|
55
|
-
(JBConstants.MAX_CASH_OUT_TAX_RATE - cashOutTaxRate)
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
return mulDiv({
|
|
54
|
+
x: base,
|
|
55
|
+
y: (JBConstants.MAX_CASH_OUT_TAX_RATE - cashOutTaxRate)
|
|
56
|
+
+ mulDiv({x: cashOutTaxRate, y: cashOutCount, denominator: totalSupply}),
|
|
57
|
+
denominator: JBConstants.MAX_CASH_OUT_TAX_RATE
|
|
58
|
+
});
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/// @notice Returns the minimum number of tokens that must be cashed out to receive at least `desiredOutput` of
|
|
@@ -93,9 +94,9 @@ library JBCashOuts {
|
|
|
93
94
|
|
|
94
95
|
// Linear case (no tax): out = surplus * c / totalSupply, so c = ceil(out * totalSupply / surplus).
|
|
95
96
|
if (cashOutTaxRate == 0) {
|
|
96
|
-
uint256 count = mulDiv(desiredOutput, totalSupply, surplus);
|
|
97
|
+
uint256 count = mulDiv({x: desiredOutput, y: totalSupply, denominator: surplus});
|
|
97
98
|
// Round up if the floor division undershoots.
|
|
98
|
-
if (mulDiv(surplus, count, totalSupply) < desiredOutput) count++;
|
|
99
|
+
if (mulDiv({x: surplus, y: count, denominator: totalSupply}) < desiredOutput) count++;
|
|
99
100
|
return count;
|
|
100
101
|
}
|
|
101
102
|
|
package/src/libraries/JBFees.sol
CHANGED
|
@@ -15,7 +15,7 @@ library JBFees {
|
|
|
15
15
|
/// @param feePercent The fee percent, out of `JBConstants.MAX_FEE`.
|
|
16
16
|
/// @return The fee amount, as a fixed point number with the same number of decimals as `amountBeforeFee`.
|
|
17
17
|
function feeAmountFrom(uint256 amountBeforeFee, uint256 feePercent) internal pure returns (uint256) {
|
|
18
|
-
return mulDiv(amountBeforeFee, feePercent, JBConstants.MAX_FEE);
|
|
18
|
+
return mulDiv({x: amountBeforeFee, y: feePercent, denominator: JBConstants.MAX_FEE});
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/// @notice Returns the fee amount that, when added to `amountAfterFee`, produces the gross amount needed to yield
|
|
@@ -25,7 +25,8 @@ library JBFees {
|
|
|
25
25
|
/// @param feePercent The fee percent, out of `JBConstants.MAX_FEE`.
|
|
26
26
|
/// @return The fee amount, as a fixed point number with the same number of decimals as `amountAfterFee`.
|
|
27
27
|
function feeAmountResultingIn(uint256 amountAfterFee, uint256 feePercent) internal pure returns (uint256) {
|
|
28
|
-
return mulDiv(amountAfterFee, JBConstants.MAX_FEE, JBConstants.MAX_FEE - feePercent)
|
|
28
|
+
return mulDiv({x: amountAfterFee, y: JBConstants.MAX_FEE, denominator: JBConstants.MAX_FEE - feePercent})
|
|
29
|
+
- amountAfterFee;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/// @notice Returns the standard protocol fee taken from `amountBeforeFee`.
|
|
@@ -45,6 +46,6 @@ library JBFees {
|
|
|
45
46
|
/// @return The fee amount that, when added to `amountAfterFee`, yields the gross pre-fee amount.
|
|
46
47
|
function standardFeeAmountResultingIn(uint256 amountAfterFee) internal pure returns (uint256) {
|
|
47
48
|
// Use `mulDiv` instead of `amountAfterFee * 40 / 39` to preserve overflow safety.
|
|
48
|
-
return mulDiv(amountAfterFee, 40, 39) - amountAfterFee;
|
|
49
|
+
return mulDiv({x: amountAfterFee, y: 40, denominator: 39}) - amountAfterFee;
|
|
49
50
|
}
|
|
50
51
|
}
|
|
@@ -30,22 +30,15 @@ library JBMetadataResolver {
|
|
|
30
30
|
error JBMetadataResolver_MetadataTooLong(uint256 offset, uint256 maxOffset);
|
|
31
31
|
error JBMetadataResolver_MetadataTooShort(uint256 metadataLength, uint256 minMetadataLength);
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
uint256 constant ID_SIZE = 4;
|
|
33
|
+
// Constants alphabetized per STYLE_GUIDE; trailing comments document derivation.
|
|
35
34
|
uint256 constant ID_OFFSET_SIZE = 1;
|
|
35
|
+
uint256 constant ID_SIZE = 4;
|
|
36
|
+
uint256 constant MIN_METADATA_LENGTH = 37; // RESERVED_SIZE + ID_SIZE + ID_OFFSET_SIZE
|
|
37
|
+
uint256 constant NEXT_ID_OFFSET = 9; // TOTAL_ID_SIZE + ID_SIZE
|
|
38
|
+
uint256 constant RESERVED_SIZE = 32; // 1 * WORD_SIZE — protocol-reserved leading word
|
|
39
|
+
uint256 constant TOTAL_ID_SIZE = 5; // ID_SIZE + ID_OFFSET_SIZE
|
|
36
40
|
uint256 constant WORD_SIZE = 32;
|
|
37
41
|
|
|
38
|
-
// The size that an ID takes in the lookup table (Identifier + Offset).
|
|
39
|
-
uint256 constant TOTAL_ID_SIZE = 5; // ID_SIZE + ID_OFFSET_SIZE;
|
|
40
|
-
|
|
41
|
-
// The amount of bytes to go forward to get to the offset of the next ID (aka. the end of the offset of the current
|
|
42
|
-
// ID).
|
|
43
|
-
uint256 constant NEXT_ID_OFFSET = 9; // TOTAL_ID_SIZE + ID_SIZE;
|
|
44
|
-
|
|
45
|
-
// 1 word (32B) is reserved for the protocol .
|
|
46
|
-
uint256 constant RESERVED_SIZE = 32; // 1 * WORD_SIZE;
|
|
47
|
-
uint256 constant MIN_METADATA_LENGTH = 37; // RESERVED_SIZE + ID_SIZE + ID_OFFSET_SIZE;
|
|
48
|
-
|
|
49
42
|
/// @notice Add an {id: data} entry to an existing metadata. This is an append-only mechanism.
|
|
50
43
|
/// @param originalMetadata The original metadata
|
|
51
44
|
/// @param idToAdd The id to add
|
|
@@ -141,7 +141,7 @@ library JBPayoutSplitGroupLib {
|
|
|
141
141
|
// refund = amount * (netPayoutAmount - sent) / netPayoutAmount. For full consumption this branch is
|
|
142
142
|
// skipped. For zero consumption this refunds the full `amount` (i.e. the gross, fee allocation included).
|
|
143
143
|
if (sent < netPayoutAmount) {
|
|
144
|
-
uint256 refund = mulDiv(amount, netPayoutAmount - sent, netPayoutAmount);
|
|
144
|
+
uint256 refund = mulDiv({x: amount, y: netPayoutAmount - sent, denominator: netPayoutAmount});
|
|
145
145
|
if (refund != 0) {
|
|
146
146
|
store.recordAddedBalanceFor({projectId: projectId, token: token, amount: refund});
|
|
147
147
|
}
|
|
@@ -151,7 +151,7 @@ library JBPayoutSplitGroupLib {
|
|
|
151
151
|
// gross-equivalent of what the hook actually consumed so the held fee scales with consumption rather
|
|
152
152
|
// than with the project's original payout intent.
|
|
153
153
|
if (netPayoutAmount < amount && sent != 0) {
|
|
154
|
-
feeEligibleAmount = mulDiv(amount, sent, netPayoutAmount);
|
|
154
|
+
feeEligibleAmount = mulDiv({x: amount, y: sent, denominator: netPayoutAmount});
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
@@ -192,7 +192,7 @@ library JBPayoutSplitGroupLib {
|
|
|
192
192
|
JBSplit memory split = payoutSplits[i];
|
|
193
193
|
|
|
194
194
|
// The amount to send to the split.
|
|
195
|
-
uint256 payoutAmount = mulDiv(leftoverAmount, split.percent, leftoverPercentage);
|
|
195
|
+
uint256 payoutAmount = mulDiv({x: leftoverAmount, y: split.percent, denominator: leftoverPercentage});
|
|
196
196
|
|
|
197
197
|
// Send the payout (inlined to keep stack pressure manageable with the tuple return).
|
|
198
198
|
// Returns (netPayoutAmount sent, feeEligible gross-equivalent). For non-hook splits and fully-consumed
|
|
@@ -8,6 +8,9 @@ import {JBRulesetMetadata} from "./../structs/JBRulesetMetadata.sol";
|
|
|
8
8
|
/// encodes: reservedPercent, cashOutTaxRate, baseCurrency, 14 boolean flags (pausePay, allowOwnerMinting, etc.),
|
|
9
9
|
/// a data hook address, and 14 bits of custom metadata. Used throughout the protocol to read ruleset configuration
|
|
10
10
|
/// without storing each field separately.
|
|
11
|
+
/// @dev Getter functions are intentionally laid out in bit-position order (low → high), not alphabetical, so that
|
|
12
|
+
/// the source reads as a visual key for the bit layout written by `packRulesetMetadata`. This is the documented
|
|
13
|
+
/// exception to STYLE_GUIDE function ordering for libraries.
|
|
11
14
|
library JBRulesetMetadataResolver {
|
|
12
15
|
function reservedPercent(JBRuleset memory ruleset) internal pure returns (uint16) {
|
|
13
16
|
return uint16(ruleset.metadata >> 4);
|
|
@@ -4,6 +4,7 @@ pragma solidity ^0.8.0;
|
|
|
4
4
|
import {JBCurrencyAmount} from "./JBCurrencyAmount.sol";
|
|
5
5
|
|
|
6
6
|
/// @notice Defines how much a project can withdraw from a specific terminal and token each funding cycle.
|
|
7
|
+
/// @dev A ruleset configuration should include at most one group for each `(terminal, token)` pair.
|
|
7
8
|
/// @dev Example — payout limit of 5 USD in an ETH terminal: the project can distribute up to 5 USD worth of ETH to
|
|
8
9
|
/// its splits per cycle. Example — surplus allowance of 5 USD: the project owner can pull up to 5 USD worth of ETH
|
|
9
10
|
/// from the surplus (balance above payout limits).
|
package/test/helpers/JBTest.sol
CHANGED
|
@@ -37,6 +37,15 @@ contract JBTest is Test {
|
|
|
37
37
|
vm.expectCall(_where, _encodedCall);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/// @notice Calldata for `IJBFeelessAddresses.isFeelessFor(address,uint256,address)`.
|
|
41
|
+
/// @dev `isFeelessFor` is overloaded on the registry; `abi.encodeCall(IJBFeelessAddresses.isFeelessFor, ...)` is
|
|
42
|
+
/// ambiguous after the caller-aware overload was added. Terminals call the 3-arg variant forwarding
|
|
43
|
+
/// `_msgSender()`, so unit tests mocking the registry must encode the 3-arg selector with the expected caller.
|
|
44
|
+
function feelessCalldata(address addr, uint256 projectId, address caller) public pure returns (bytes memory) {
|
|
45
|
+
return
|
|
46
|
+
abi.encodeWithSelector(bytes4(keccak256("isFeelessFor(address,uint256,address)")), addr, projectId, caller);
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
// Multiple calls with different return values
|
|
41
50
|
function mockExpectSubsequent(address _where, bytes memory _encodedCall, bytes[] memory _returns) public {
|
|
42
51
|
// Mocks calls with different return values
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {IJBFeelessHook} from "../../src/interfaces/IJBFeelessHook.sol";
|
|
5
|
+
|
|
6
|
+
import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
|
|
7
|
+
|
|
8
|
+
/// @notice Configurable `IJBFeelessHook` mock for unit tests.
|
|
9
|
+
/// @dev `mode` controls behavior of `isFeeless`:
|
|
10
|
+
/// - 0: returns `defaultResult` for any (projectId, addr)
|
|
11
|
+
/// - 1: reverts with `Nope()`
|
|
12
|
+
/// - 2: reverts via `require(false, "nope")`
|
|
13
|
+
/// - 3: returns `true` iff `(projectId, addr)` has been allowlisted via `setAllowed`
|
|
14
|
+
contract MockFeelessHook is ERC165, IJBFeelessHook {
|
|
15
|
+
error Nope();
|
|
16
|
+
|
|
17
|
+
uint256 public mode;
|
|
18
|
+
bool public defaultResult;
|
|
19
|
+
mapping(uint256 => mapping(address => bool)) internal _allowed;
|
|
20
|
+
|
|
21
|
+
function setMode(uint256 newMode) external {
|
|
22
|
+
mode = newMode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setDefaultResult(bool result) external {
|
|
26
|
+
defaultResult = result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setAllowed(uint256 projectId, address addr, bool flag) external {
|
|
30
|
+
_allowed[projectId][addr] = flag;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isFeeless(uint256 projectId, address addr, address) external view override returns (bool) {
|
|
34
|
+
if (mode == 1) revert Nope();
|
|
35
|
+
if (mode == 2) require(false, "nope");
|
|
36
|
+
if (mode == 3) return _allowed[projectId][addr];
|
|
37
|
+
return defaultResult;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function supportsInterface(bytes4 interfaceId) public view override(IERC165, ERC165) returns (bool) {
|
|
41
|
+
return interfaceId == type(IJBFeelessHook).interfaceId || super.supportsInterface(interfaceId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// @notice Non-conforming "hook" that returns false for the IJBFeelessHook interfaceId in ERC-165.
|
|
46
|
+
/// @dev Used to test the `setFeelessHook` validation guard. Has the `isFeeless` selector so the
|
|
47
|
+
/// call shape matches, but its `supportsInterface` advertises only IERC165, not IJBFeelessHook.
|
|
48
|
+
contract MockNonConformingFeelessHook is ERC165 {
|
|
49
|
+
function isFeeless(uint256, address, address) external pure returns (bool) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// @notice "Hook" with no `supportsInterface` at all — `setFeelessHook` should revert when this is passed.
|
|
55
|
+
contract MockEoaLikeFeelessHook {
|
|
56
|
+
function isFeeless(uint256, address, address) external pure returns (bool) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|