@bananapus/core-v6 0.0.49 → 0.0.52
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/CHANGELOG.md +37 -0
- package/foundry.toml +1 -0
- package/package.json +1 -1
- package/src/JBMultiTerminal.sol +530 -304
- package/src/interfaces/IJBCashOutTerminal.sol +68 -0
- package/src/interfaces/IJBFeeTerminal.sol +0 -3
- package/src/libraries/JBCashOutHookSpecsLib.sol +181 -0
- package/src/libraries/JBConstants.sol +6 -0
- package/src/libraries/JBFees.sol +25 -0
- package/src/libraries/JBHeldFeesLib.sol +288 -0
- package/src/libraries/JBRulesetMetadataResolver.sol +20 -12
- package/src/structs/JBRulesetMetadata.sol +4 -1
- package/test/helpers/JBTest.sol +6 -3
|
@@ -9,6 +9,39 @@ import {JBRuleset} from "../structs/JBRuleset.sol";
|
|
|
9
9
|
|
|
10
10
|
/// @notice A terminal that can be cashed out from.
|
|
11
11
|
interface IJBCashOutTerminal is IJBTerminal {
|
|
12
|
+
/// @notice Atomically cash out a holder's tokens of one project and add the reclaim to another project's
|
|
13
|
+
/// balance (no project tokens minted on the destination side).
|
|
14
|
+
/// @dev Equivalent to calling `cashOutTokensOf` followed by `addToBalanceOf` on the destination project,
|
|
15
|
+
/// except the source-side cash out fee is skipped (the equivalent fee is bound on the destination
|
|
16
|
+
/// project's side instead). Held-fee return is hardcoded to `false` on the destination side — this
|
|
17
|
+
/// entrypoint is for value top-up only, not fee unlock.
|
|
18
|
+
/// @dev The destination terminal is whichever terminal the directory has registered as the beneficiary
|
|
19
|
+
/// project's primary terminal for `tokenToReclaim` (which may itself be a router that swaps before adding
|
|
20
|
+
/// to balance). Cashout-side hooks (if specified by the data hook) execute additively.
|
|
21
|
+
/// @dev Round-trip fee preservation is enforced by snapshotting the beneficiary project's
|
|
22
|
+
/// accounting-context balances on this terminal before and after the routing, and crediting
|
|
23
|
+
/// `_feeFreeSurplusOf` by the per-token delta on each context that grew. The beneficiary project's current
|
|
24
|
+
/// ruleset can set `pauseCrossProjectFeeFreeInflows` to opt out.
|
|
25
|
+
/// @param holder The address whose project tokens are being burned.
|
|
26
|
+
/// @param projectId The ID of the project whose project tokens are being burned.
|
|
27
|
+
/// @param cashOutCount The number of project tokens to burn.
|
|
28
|
+
/// @param tokenToReclaim The terminal token reclaimed from the source project's surplus.
|
|
29
|
+
/// @param beneficiaryProjectId The destination project receiving the reclaim.
|
|
30
|
+
/// @param cashOutMetadata Forwarded to the source project's data hook and any cashout hook specifications.
|
|
31
|
+
/// @param addToBalanceMetadata Forwarded to the destination project's `addToBalanceOf` event.
|
|
32
|
+
/// @return reclaimAmount The gross reclaim amount returned by the store.
|
|
33
|
+
function addToBalanceAfterCashOutTokensOf(
|
|
34
|
+
address holder,
|
|
35
|
+
uint256 projectId,
|
|
36
|
+
uint256 cashOutCount,
|
|
37
|
+
address tokenToReclaim,
|
|
38
|
+
uint256 beneficiaryProjectId,
|
|
39
|
+
bytes calldata cashOutMetadata,
|
|
40
|
+
bytes calldata addToBalanceMetadata
|
|
41
|
+
)
|
|
42
|
+
external
|
|
43
|
+
returns (uint256 reclaimAmount);
|
|
44
|
+
|
|
12
45
|
/// @notice A cash out was processed for a project.
|
|
13
46
|
/// @param rulesetId The ID of the ruleset during the cash out.
|
|
14
47
|
/// @param rulesetCycleNumber The cycle number of the ruleset during the cash out.
|
|
@@ -96,4 +129,39 @@ interface IJBCashOutTerminal is IJBTerminal {
|
|
|
96
129
|
)
|
|
97
130
|
external
|
|
98
131
|
returns (uint256 reclaimAmount);
|
|
132
|
+
|
|
133
|
+
/// @notice Atomically cash out a holder's tokens of one project and pay the reclaim into another. Equivalent
|
|
134
|
+
/// to calling `cashOutTokensOf` followed by `pay` on the destination project, except the source-side cash out
|
|
135
|
+
/// fee is skipped (the equivalent fee is bound on the destination project's side instead).
|
|
136
|
+
/// @dev The destination terminal is whichever terminal the directory has registered as the beneficiary project's
|
|
137
|
+
/// primary terminal for `tokenToReclaim` (which may itself be a router that swaps before paying). Cashout-side
|
|
138
|
+
/// hooks (if specified by the data hook) execute additively.
|
|
139
|
+
/// @dev Round-trip fee preservation is enforced by snapshotting the beneficiary project's accounting-context
|
|
140
|
+
/// balances on this terminal before and after the routing, and crediting `_feeFreeSurplusOf` by the per-token
|
|
141
|
+
/// delta on each context that grew. The beneficiary project's current ruleset can set
|
|
142
|
+
/// `pauseCrossProjectFeeFreeInflows` to opt out.
|
|
143
|
+
/// @param holder The address whose project tokens are being burned.
|
|
144
|
+
/// @param projectId The ID of the project whose project tokens are being burned.
|
|
145
|
+
/// @param cashOutCount The number of project tokens to burn.
|
|
146
|
+
/// @param tokenToReclaim The terminal token reclaimed from the source project's surplus.
|
|
147
|
+
/// @param beneficiaryProjectId The destination project.
|
|
148
|
+
/// @param beneficiary The address that receives the newly minted tokens of the destination project.
|
|
149
|
+
/// @param minTokensOut The minimum number of destination-project tokens that must be minted; reverts otherwise.
|
|
150
|
+
/// @param cashOutMetadata Forwarded to the source project's data hook and any cashout hook specifications.
|
|
151
|
+
/// @param payMetadata Forwarded to the destination project's pay flow.
|
|
152
|
+
/// @return reclaimAmount The gross reclaim amount returned by the store.
|
|
153
|
+
/// @return beneficiaryTokenCount The number of destination-project tokens minted to `beneficiary`.
|
|
154
|
+
function payAfterCashOutTokensOf(
|
|
155
|
+
address holder,
|
|
156
|
+
uint256 projectId,
|
|
157
|
+
uint256 cashOutCount,
|
|
158
|
+
address tokenToReclaim,
|
|
159
|
+
uint256 beneficiaryProjectId,
|
|
160
|
+
address beneficiary,
|
|
161
|
+
uint256 minTokensOut,
|
|
162
|
+
bytes calldata cashOutMetadata,
|
|
163
|
+
bytes calldata payMetadata
|
|
164
|
+
)
|
|
165
|
+
external
|
|
166
|
+
returns (uint256 reclaimAmount, uint256 beneficiaryTokenCount);
|
|
99
167
|
}
|
|
@@ -71,9 +71,6 @@ interface IJBFeeTerminal is IJBTerminal {
|
|
|
71
71
|
address caller
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
-
/// @notice The terminal's fee as a fraction of `JBConstants.MAX_FEE`.
|
|
75
|
-
function FEE() external view returns (uint256);
|
|
76
|
-
|
|
77
74
|
/// @notice The contract that tracks feeless addresses.
|
|
78
75
|
function FEELESS_ADDRESSES() external view returns (IJBFeelessAddresses);
|
|
79
76
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
5
|
+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
6
|
+
|
|
7
|
+
import {IJBCashOutHook} from "../interfaces/IJBCashOutHook.sol";
|
|
8
|
+
import {IJBFeelessAddresses} from "../interfaces/IJBFeelessAddresses.sol";
|
|
9
|
+
import {JBAfterCashOutRecordedContext} from "../structs/JBAfterCashOutRecordedContext.sol";
|
|
10
|
+
import {JBCashOutHookSpecification} from "../structs/JBCashOutHookSpecification.sol";
|
|
11
|
+
import {JBRuleset} from "../structs/JBRuleset.sol";
|
|
12
|
+
import {JBTokenAmount} from "../structs/JBTokenAmount.sol";
|
|
13
|
+
import {JBConstants} from "./JBConstants.sol";
|
|
14
|
+
import {JBFees} from "./JBFees.sol";
|
|
15
|
+
|
|
16
|
+
/// @notice Cash-out hook specification fulfillment for `JBMultiTerminal`. Extracted to reduce terminal
|
|
17
|
+
/// bytecode size, mirroring the `JBHeldFeesLib` pattern.
|
|
18
|
+
/// @dev Called via DELEGATECALL — `address(this)` inside library code is the terminal's address, so token
|
|
19
|
+
/// approvals and ETH-bearing hook calls operate on the terminal's balance and allowances. Events are
|
|
20
|
+
/// emitted from the terminal address. The `caller` field of `HookAfterRecordCashOut` uses raw `msg.sender`
|
|
21
|
+
/// (matching `JBHeldFeesLib`'s precedent) — this means meta-transactions through a trusted forwarder will
|
|
22
|
+
/// surface the forwarder address in the event slot, but the actual hook execution semantics are unaffected.
|
|
23
|
+
library JBCashOutHookSpecsLib {
|
|
24
|
+
// A library that adds default safety checks to ERC20 functionality.
|
|
25
|
+
using SafeERC20 for IERC20;
|
|
26
|
+
|
|
27
|
+
//*********************************************************************//
|
|
28
|
+
// ------------------------------ events ----------------------------- //
|
|
29
|
+
//*********************************************************************//
|
|
30
|
+
|
|
31
|
+
/// @notice A cash out hook was called after a cash out was recorded.
|
|
32
|
+
/// @param hook The cash out hook that was called.
|
|
33
|
+
/// @param context The context passed to the hook.
|
|
34
|
+
/// @param specificationAmount The amount specified for the hook.
|
|
35
|
+
/// @param fee The fee taken from the hook's amount.
|
|
36
|
+
/// @param caller The address that called the cash out function.
|
|
37
|
+
event HookAfterRecordCashOut(
|
|
38
|
+
IJBCashOutHook indexed hook,
|
|
39
|
+
JBAfterCashOutRecordedContext context,
|
|
40
|
+
uint256 specificationAmount,
|
|
41
|
+
uint256 fee,
|
|
42
|
+
address caller
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
//*********************************************************************//
|
|
46
|
+
// --------------------------- custom errors ------------------------- //
|
|
47
|
+
//*********************************************************************//
|
|
48
|
+
|
|
49
|
+
/// @notice Thrown when a hook returns without consuming the full forwarded ERC-20 amount.
|
|
50
|
+
error JBMultiTerminal_TemporaryAllowanceNotConsumed(address token, address spender, uint256 allowance);
|
|
51
|
+
|
|
52
|
+
//*********************************************************************//
|
|
53
|
+
// ----------------------- external functions ------------------------ //
|
|
54
|
+
//*********************************************************************//
|
|
55
|
+
|
|
56
|
+
/// @notice Iterates `specifications`, calling each non-noop hook with the right ETH/ERC-20 setup, and
|
|
57
|
+
/// accumulates the fee-eligible amount across non-feeless hooks.
|
|
58
|
+
/// @dev For each spec: if the hook is feeless, it gets the full spec amount; otherwise the hook gets
|
|
59
|
+
/// `amount - feeAmountFrom(amount)` and the gross spec amount is added to the eligible-for-fees total
|
|
60
|
+
/// (the caller takes the fee separately via `_takeFeeFrom`). Cross-token semantics: the hook context's
|
|
61
|
+
/// `forwardedAmount` carries the post-fee amount in the same token as `beneficiaryReclaimAmount`.
|
|
62
|
+
/// @param feelessAddresses Registry of fee-exempt addresses (consulted per-hook).
|
|
63
|
+
/// @param projectId The project being cashed out from.
|
|
64
|
+
/// @param beneficiaryReclaimAmount The token amount reference (token, decimals, currency, gross value).
|
|
65
|
+
/// @param holder The account whose project tokens were burned.
|
|
66
|
+
/// @param cashOutCount The number of project tokens burned.
|
|
67
|
+
/// @param metadata Bytes forwarded to each hook as `cashOutMetadata`.
|
|
68
|
+
/// @param ruleset The ruleset active during the cash out.
|
|
69
|
+
/// @param cashOutTaxRate The cash out tax rate applied.
|
|
70
|
+
/// @param beneficiary The address forwarded as the hook context's `beneficiary` (typically the user-supplied
|
|
71
|
+
/// recipient or `address(this)` for cross-project flows where the terminal custodies the reclaim mid-flow).
|
|
72
|
+
/// @param specifications The hook specifications returned by the data hook.
|
|
73
|
+
/// @return amountEligibleForFees Total spec amounts (gross) from non-feeless hooks, used by the caller to
|
|
74
|
+
/// charge fees in a single pass.
|
|
75
|
+
function fulfill(
|
|
76
|
+
IJBFeelessAddresses feelessAddresses,
|
|
77
|
+
uint256 projectId,
|
|
78
|
+
JBTokenAmount memory beneficiaryReclaimAmount,
|
|
79
|
+
address holder,
|
|
80
|
+
uint256 cashOutCount,
|
|
81
|
+
bytes memory metadata,
|
|
82
|
+
JBRuleset memory ruleset,
|
|
83
|
+
uint256 cashOutTaxRate,
|
|
84
|
+
address payable beneficiary,
|
|
85
|
+
JBCashOutHookSpecification[] memory specifications
|
|
86
|
+
)
|
|
87
|
+
external
|
|
88
|
+
returns (uint256 amountEligibleForFees)
|
|
89
|
+
{
|
|
90
|
+
JBAfterCashOutRecordedContext memory context = JBAfterCashOutRecordedContext({
|
|
91
|
+
holder: holder,
|
|
92
|
+
projectId: projectId,
|
|
93
|
+
rulesetId: ruleset.id,
|
|
94
|
+
cashOutCount: cashOutCount,
|
|
95
|
+
reclaimedAmount: beneficiaryReclaimAmount,
|
|
96
|
+
forwardedAmount: beneficiaryReclaimAmount,
|
|
97
|
+
cashOutTaxRate: cashOutTaxRate,
|
|
98
|
+
beneficiary: beneficiary,
|
|
99
|
+
hookMetadata: "",
|
|
100
|
+
cashOutMetadata: metadata
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
for (uint256 i; i < specifications.length;) {
|
|
104
|
+
JBCashOutHookSpecification memory specification = specifications[i];
|
|
105
|
+
|
|
106
|
+
// A noop specification is informational only and doesn't trigger the hook.
|
|
107
|
+
if (specification.noop) {
|
|
108
|
+
unchecked {
|
|
109
|
+
++i;
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Get the fee for the specified amount.
|
|
115
|
+
uint256 specificationAmountFee = feelessAddresses.isFeelessFor({
|
|
116
|
+
addr: address(specification.hook), projectId: projectId
|
|
117
|
+
})
|
|
118
|
+
? 0
|
|
119
|
+
: JBFees.standardFeeAmountFrom({amountBeforeFee: specification.amount});
|
|
120
|
+
|
|
121
|
+
// Add the specification's amount to the amount eligible for fees.
|
|
122
|
+
if (specificationAmountFee != 0) {
|
|
123
|
+
amountEligibleForFees += specification.amount;
|
|
124
|
+
specification.amount -= specificationAmountFee;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Pass the correct token `forwardedAmount` to the hook.
|
|
128
|
+
context.forwardedAmount = JBTokenAmount({
|
|
129
|
+
value: specification.amount,
|
|
130
|
+
token: beneficiaryReclaimAmount.token,
|
|
131
|
+
decimals: beneficiaryReclaimAmount.decimals,
|
|
132
|
+
currency: beneficiaryReclaimAmount.currency
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Pass the correct metadata from the data hook's specification.
|
|
136
|
+
context.hookMetadata = specification.metadata;
|
|
137
|
+
|
|
138
|
+
// Trigger any inherited pre-transfer logic.
|
|
139
|
+
// Keep a reference to the amount that'll be paid as a `msg.value`.
|
|
140
|
+
uint256 payValue = _beforeTransferTo({
|
|
141
|
+
to: address(specification.hook), token: beneficiaryReclaimAmount.token, amount: specification.amount
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Fulfill the specification.
|
|
145
|
+
specification.hook.afterCashOutRecordedWith{value: payValue}(context);
|
|
146
|
+
|
|
147
|
+
// Revoke the temporary pull allowance now that the hook call has finished.
|
|
148
|
+
_afterTransferTo({to: address(specification.hook), token: beneficiaryReclaimAmount.token});
|
|
149
|
+
|
|
150
|
+
emit HookAfterRecordCashOut({
|
|
151
|
+
hook: specification.hook,
|
|
152
|
+
context: context,
|
|
153
|
+
specificationAmount: specification.amount,
|
|
154
|
+
fee: specificationAmountFee,
|
|
155
|
+
caller: msg.sender
|
|
156
|
+
});
|
|
157
|
+
unchecked {
|
|
158
|
+
++i;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
//*********************************************************************//
|
|
164
|
+
// ----------------------- private helpers --------------------------- //
|
|
165
|
+
//*********************************************************************//
|
|
166
|
+
|
|
167
|
+
/// @notice Native-token transfers use `msg.value` (return the amount). ERC20 transfers grant a temporary
|
|
168
|
+
/// pull allowance and return 0 (the recipient is expected to consume it within the same call).
|
|
169
|
+
function _beforeTransferTo(address to, address token, uint256 amount) private returns (uint256) {
|
|
170
|
+
if (token == JBConstants.NATIVE_TOKEN) return amount;
|
|
171
|
+
IERC20(token).forceApprove({spender: to, value: amount});
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// @notice Asserts the recipient consumed the temporary ERC20 allowance. No-op for native token.
|
|
176
|
+
function _afterTransferTo(address to, address token) private view {
|
|
177
|
+
if (token == JBConstants.NATIVE_TOKEN) return;
|
|
178
|
+
uint256 allowance = IERC20(token).allowance({owner: address(this), spender: to});
|
|
179
|
+
if (allowance != 0) revert JBMultiTerminal_TemporaryAllowanceNotConsumed(token, to, allowance);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -20,4 +20,10 @@ library JBConstants {
|
|
|
20
20
|
|
|
21
21
|
/// @notice The fee denominator. The protocol fee is `FEE / MAX_FEE` (currently 25/1000 = 2.5%).
|
|
22
22
|
uint16 public constant MAX_FEE = 1000;
|
|
23
|
+
|
|
24
|
+
/// @notice The fee numerator. The protocol fee is `FEE / MAX_FEE` = 25/1000 = 2.5%, charged on outflows.
|
|
25
|
+
uint16 public constant FEE = 25;
|
|
26
|
+
|
|
27
|
+
/// @notice The project ID that receives protocol fees. Should be the first project launched at deployment.
|
|
28
|
+
uint256 public constant FEE_BENEFICIARY_PROJECT_ID = 1;
|
|
23
29
|
}
|
package/src/libraries/JBFees.sol
CHANGED
|
@@ -27,4 +27,29 @@ library JBFees {
|
|
|
27
27
|
function feeAmountFrom(uint256 amountBeforeFee, uint256 feePercent) internal pure returns (uint256) {
|
|
28
28
|
return mulDiv(amountBeforeFee, feePercent, JBConstants.MAX_FEE);
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/// @notice Specialization of `feeAmountFrom` that hardcodes the standard protocol fee
|
|
32
|
+
/// (`JBConstants.FEE / JBConstants.MAX_FEE` = 25 / 1000 = 1 / 40). Both numerator and denominator are
|
|
33
|
+
/// compile-time constants so callers don't need to pass them every call.
|
|
34
|
+
/// @dev Pre-reduced: `mulDiv(amount, 25, 1000)` ≡ `amount / 40` (gcd(25, 1000) = 25). Plain integer
|
|
35
|
+
/// division is exact (rounds down) and shaves the entire `mulDiv` 512-bit pipeline at every fee site.
|
|
36
|
+
/// If `JBConstants.FEE` or `JBConstants.MAX_FEE` changes, the constant `40` here MUST be reduced again.
|
|
37
|
+
/// @param amountBeforeFee The amount before the fee is applied, as a fixed point number.
|
|
38
|
+
/// @return The fee amount, as a fixed point number with the same number of decimals as `amountBeforeFee`.
|
|
39
|
+
function standardFeeAmountFrom(uint256 amountBeforeFee) internal pure returns (uint256) {
|
|
40
|
+
return amountBeforeFee / 40;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// @notice Specialization of `feeAmountResultingIn` that hardcodes the standard protocol fee
|
|
44
|
+
/// (`JBConstants.FEE / JBConstants.MAX_FEE`). Use to back-calculate the standard fee from a known
|
|
45
|
+
/// post-fee payout.
|
|
46
|
+
/// @dev Pre-reduced: `mulDiv(amount, 1000, 1000 - 25) - amount` ≡ `mulDiv(amount, 40, 39) - amount`
|
|
47
|
+
/// (gcd(1000, 975) = 25). `mulDiv` (not raw `*`) preserves overflow-safety for the rare wildly-large
|
|
48
|
+
/// inputs and matches the rounding behavior of `feeAmountResultingIn` exactly. If `JBConstants.FEE` or
|
|
49
|
+
/// `JBConstants.MAX_FEE` changes, the constants `40` and `39` here MUST be reduced again.
|
|
50
|
+
/// @param amountAfterFee The desired post-fee amount, as a fixed point number.
|
|
51
|
+
/// @return The fee amount that, when added to `amountAfterFee`, yields the gross pre-fee amount.
|
|
52
|
+
function standardFeeAmountResultingIn(uint256 amountAfterFee) internal pure returns (uint256) {
|
|
53
|
+
return mulDiv(amountAfterFee, 40, 39) - amountAfterFee;
|
|
54
|
+
}
|
|
30
55
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {IJBDirectory} from "../interfaces/IJBDirectory.sol";
|
|
5
|
+
import {IJBTerminal} from "../interfaces/IJBTerminal.sol";
|
|
6
|
+
import {IJBTerminalStore} from "../interfaces/IJBTerminalStore.sol";
|
|
7
|
+
import {JBFee} from "../structs/JBFee.sol";
|
|
8
|
+
import {JBConstants} from "./JBConstants.sol";
|
|
9
|
+
import {JBFees} from "./JBFees.sol";
|
|
10
|
+
|
|
11
|
+
/// @notice Local callback into the terminal's `executeProcessFee(...)`. Kept here because the function is an
|
|
12
|
+
/// implementation detail of the library/terminal pair, not a shared public interface.
|
|
13
|
+
interface IJBHeldFeesExecutor {
|
|
14
|
+
/// @notice Sends a fee amount from a project to the fee-receiving terminal under an external CALL boundary,
|
|
15
|
+
/// so the library can wrap the call in `try/catch` (revert in the fee route is forgiven, not propagated).
|
|
16
|
+
/// @param projectId The project paying the fee.
|
|
17
|
+
/// @param token The token the fee is denominated in.
|
|
18
|
+
/// @param amount The fee amount.
|
|
19
|
+
/// @param beneficiary The address that receives any platform tokens minted by the fee payment.
|
|
20
|
+
/// @param feeTerminal The terminal that'll receive the fee.
|
|
21
|
+
function executeProcessFee(
|
|
22
|
+
uint256 projectId,
|
|
23
|
+
address token,
|
|
24
|
+
uint256 amount,
|
|
25
|
+
address beneficiary,
|
|
26
|
+
IJBTerminal feeTerminal
|
|
27
|
+
)
|
|
28
|
+
external;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// @notice Held-fee bookkeeping for `JBMultiTerminal`. Extracted to reduce terminal bytecode size.
|
|
32
|
+
/// @dev Called via DELEGATECALL — storage refs (`heldFeesOf`, `nextHeldFeeIndexOf`) point at the terminal's
|
|
33
|
+
/// storage. `address(this)` inside library code is the terminal's address. Events are therefore emitted from
|
|
34
|
+
/// the terminal.
|
|
35
|
+
library JBHeldFeesLib {
|
|
36
|
+
//*********************************************************************//
|
|
37
|
+
// ------------------------------ events ----------------------------- //
|
|
38
|
+
//*********************************************************************//
|
|
39
|
+
|
|
40
|
+
/// @notice Emitted when a fee is sent to the fee-receiving terminal.
|
|
41
|
+
/// @param projectId The project the fee was for.
|
|
42
|
+
/// @param token The token the fee was paid in.
|
|
43
|
+
/// @param amount The fee amount.
|
|
44
|
+
/// @param wasHeld Whether the fee was previously held (true) or processed inline (false).
|
|
45
|
+
/// @param beneficiary The address that received any platform tokens minted by the fee payment.
|
|
46
|
+
/// @param caller The address that triggered the fee processing.
|
|
47
|
+
event ProcessFee(
|
|
48
|
+
uint256 indexed projectId,
|
|
49
|
+
address indexed token,
|
|
50
|
+
uint256 amount,
|
|
51
|
+
bool wasHeld,
|
|
52
|
+
address beneficiary,
|
|
53
|
+
address caller
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/// @notice Emitted when a fee payment reverts and the amount is returned to the project's balance.
|
|
57
|
+
/// @param projectId The project the fee was for.
|
|
58
|
+
/// @param token The token the fee was paid in.
|
|
59
|
+
/// @param feeProjectId The ID of the fee-receiving project.
|
|
60
|
+
/// @param amount The fee amount returned.
|
|
61
|
+
/// @param reason The revert reason from the fee route.
|
|
62
|
+
/// @param caller The address that triggered the fee processing.
|
|
63
|
+
event FeeReverted(
|
|
64
|
+
uint256 indexed projectId,
|
|
65
|
+
address indexed token,
|
|
66
|
+
uint256 indexed feeProjectId,
|
|
67
|
+
uint256 amount,
|
|
68
|
+
bytes reason,
|
|
69
|
+
address caller
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/// @notice Emitted when held fees are returned to the project's balance (e.g. on an `addToBalanceOf`).
|
|
73
|
+
/// @param projectId The project whose held fees were returned.
|
|
74
|
+
/// @param token The token the held fees were denominated in.
|
|
75
|
+
/// @param amount The amount used as the basis for the return calculation.
|
|
76
|
+
/// @param returnedFees The amount of fees actually returned.
|
|
77
|
+
/// @param leftoverAmount Any leftover from the basis amount that did not match a held fee.
|
|
78
|
+
/// @param caller The address that triggered the return.
|
|
79
|
+
event ReturnHeldFees(
|
|
80
|
+
uint256 indexed projectId,
|
|
81
|
+
address indexed token,
|
|
82
|
+
uint256 amount,
|
|
83
|
+
uint256 returnedFees,
|
|
84
|
+
uint256 leftoverAmount,
|
|
85
|
+
address caller
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
//*********************************************************************//
|
|
89
|
+
// ----------------------- internal functions ------------------------ //
|
|
90
|
+
//*********************************************************************//
|
|
91
|
+
|
|
92
|
+
/// @notice Processes up to `count` unlocked held fees for `(projectId, token)`, forwarding each to the fee
|
|
93
|
+
/// terminal and reclaiming the storage slot once the queue is drained.
|
|
94
|
+
/// @dev Re-reads `nextHeldFeeIndexOf[projectId][token]` from storage each iteration to be reentrancy-safe
|
|
95
|
+
/// against any nested call that may have advanced the index. The entry is deleted and the index advanced
|
|
96
|
+
/// BEFORE the external call so a reverting fee route cannot be replayed.
|
|
97
|
+
/// @param heldFeesOf Storage ref to the terminal's per-(project,token) held-fee queue.
|
|
98
|
+
/// @param nextHeldFeeIndexOf Storage ref to the next-index cursor for each (project,token) queue.
|
|
99
|
+
/// @param directory The terminal's directory, used to locate the fee-receiving terminal.
|
|
100
|
+
/// @param store The terminal's store, used to credit the fee back to the project on a failed route.
|
|
101
|
+
/// @param projectId The project to process held fees for.
|
|
102
|
+
/// @param token The token the held fees are denominated in.
|
|
103
|
+
/// @param count The maximum number of held fees to process.
|
|
104
|
+
function processHeldFees(
|
|
105
|
+
mapping(uint256 => mapping(address => JBFee[])) storage heldFeesOf,
|
|
106
|
+
mapping(uint256 => mapping(address => uint256)) storage nextHeldFeeIndexOf,
|
|
107
|
+
IJBDirectory directory,
|
|
108
|
+
IJBTerminalStore store,
|
|
109
|
+
uint256 projectId,
|
|
110
|
+
address token,
|
|
111
|
+
uint256 count
|
|
112
|
+
)
|
|
113
|
+
external
|
|
114
|
+
{
|
|
115
|
+
// Resolve the fee-receiving terminal once outside the loop.
|
|
116
|
+
IJBTerminal feeTerminal =
|
|
117
|
+
directory.primaryTerminalOf({projectId: JBConstants.FEE_BENEFICIARY_PROJECT_ID, token: token});
|
|
118
|
+
|
|
119
|
+
for (uint256 i; i < count;) {
|
|
120
|
+
uint256 currentIndex = nextHeldFeeIndexOf[projectId][token];
|
|
121
|
+
|
|
122
|
+
// Queue exhausted — break early so we can run the array cleanup below.
|
|
123
|
+
if (currentIndex >= heldFeesOf[projectId][token].length) break;
|
|
124
|
+
|
|
125
|
+
JBFee memory heldFee = heldFeesOf[projectId][token][currentIndex];
|
|
126
|
+
|
|
127
|
+
// Fees unlock sequentially; if the head isn't ready, nothing later is either.
|
|
128
|
+
// forge-lint: disable-next-line(block-timestamp)
|
|
129
|
+
if (heldFee.unlockTimestamp > block.timestamp) break;
|
|
130
|
+
|
|
131
|
+
// Delete + advance index BEFORE the external call (reentrancy safety: a reentrant
|
|
132
|
+
// `processHeldFeesOf` cannot re-process the same entry).
|
|
133
|
+
delete heldFeesOf[projectId][token][currentIndex];
|
|
134
|
+
nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
|
|
135
|
+
|
|
136
|
+
processFee({
|
|
137
|
+
store: store,
|
|
138
|
+
projectId: projectId,
|
|
139
|
+
token: token,
|
|
140
|
+
amount: JBFees.standardFeeAmountFrom({amountBeforeFee: heldFee.amount}),
|
|
141
|
+
beneficiary: heldFee.beneficiary,
|
|
142
|
+
feeTerminal: feeTerminal,
|
|
143
|
+
wasHeld: true
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
unchecked {
|
|
147
|
+
++i;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Reclaim the array storage slot once the queue has been fully drained.
|
|
152
|
+
if (
|
|
153
|
+
nextHeldFeeIndexOf[projectId][token] >= heldFeesOf[projectId][token].length
|
|
154
|
+
&& heldFeesOf[projectId][token].length > 0
|
|
155
|
+
) {
|
|
156
|
+
delete heldFeesOf[projectId][token];
|
|
157
|
+
delete nextHeldFeeIndexOf[projectId][token];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// @notice Sends a fee to the fee-receiving terminal under a try/catch boundary.
|
|
162
|
+
/// @dev Routed through `IJBHeldFeesExecutor(address(this)).executeProcessFee(...)` so the call is a real
|
|
163
|
+
/// external CALL (try/catch semantics require it). Under DELEGATECALL `address(this)` is the terminal, so
|
|
164
|
+
/// this becomes a call to the terminal's `executeProcessFee` whose `msg.sender == address(this)` check
|
|
165
|
+
/// passes. On revert the amount is forgiven and added back to the project's balance — by design, a broken
|
|
166
|
+
/// fee route should not permanently lock project funds.
|
|
167
|
+
/// @param store The terminal's store, used for the forgive-on-revert credit.
|
|
168
|
+
/// @param projectId The project paying the fee.
|
|
169
|
+
/// @param token The token the fee is denominated in.
|
|
170
|
+
/// @param amount The fee amount.
|
|
171
|
+
/// @param beneficiary The address that receives any platform tokens minted by the fee payment.
|
|
172
|
+
/// @param feeTerminal The terminal that'll receive the fee.
|
|
173
|
+
/// @param wasHeld Whether the fee was previously held (true) or processed inline (false).
|
|
174
|
+
function processFee(
|
|
175
|
+
IJBTerminalStore store,
|
|
176
|
+
uint256 projectId,
|
|
177
|
+
address token,
|
|
178
|
+
uint256 amount,
|
|
179
|
+
address beneficiary,
|
|
180
|
+
IJBTerminal feeTerminal,
|
|
181
|
+
bool wasHeld
|
|
182
|
+
)
|
|
183
|
+
public
|
|
184
|
+
{
|
|
185
|
+
try IJBHeldFeesExecutor(address(this))
|
|
186
|
+
.executeProcessFee({
|
|
187
|
+
projectId: projectId, token: token, amount: amount, beneficiary: beneficiary, feeTerminal: feeTerminal
|
|
188
|
+
}) {
|
|
189
|
+
emit ProcessFee({
|
|
190
|
+
projectId: projectId,
|
|
191
|
+
token: token,
|
|
192
|
+
amount: amount,
|
|
193
|
+
wasHeld: wasHeld,
|
|
194
|
+
beneficiary: beneficiary,
|
|
195
|
+
caller: msg.sender
|
|
196
|
+
});
|
|
197
|
+
} catch (bytes memory reason) {
|
|
198
|
+
// Forgive the fee, credit it back to the project, and surface the failure for off-chain observability.
|
|
199
|
+
emit FeeReverted({
|
|
200
|
+
projectId: projectId,
|
|
201
|
+
token: token,
|
|
202
|
+
feeProjectId: JBConstants.FEE_BENEFICIARY_PROJECT_ID,
|
|
203
|
+
amount: amount,
|
|
204
|
+
reason: reason,
|
|
205
|
+
caller: msg.sender
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
store.recordAddedBalanceFor({projectId: projectId, token: token, amount: amount});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// @notice Returns held fees up to `amount` in FIFO order from the project's held-fee queue.
|
|
213
|
+
/// @dev Walks the queue from `nextHeldFeeIndexOf` forward. Whole entries are consumed when `leftoverAmount`
|
|
214
|
+
/// covers their post-fee amount; the final entry can be partially consumed by shrinking its `.amount` in
|
|
215
|
+
/// place. The fee implied by the partial-consume branch uses `feeAmountResultingIn` (inverse of
|
|
216
|
+
/// `feeAmountFrom`) so the credited fee aligns with the amount that was actually returned. Dust amounts
|
|
217
|
+
/// below the floor produce `feeAmount == 0`.
|
|
218
|
+
/// @param heldFeesOf Storage ref to the terminal's per-(project,token) held-fee queue.
|
|
219
|
+
/// @param nextHeldFeeIndexOf Storage ref to the next-index cursor for each (project,token) queue.
|
|
220
|
+
/// @param projectId The project to return held fees for.
|
|
221
|
+
/// @param token The token the held fees are denominated in.
|
|
222
|
+
/// @param amount The reference amount to base the return on (typically the inbound addToBalance amount).
|
|
223
|
+
/// @return returnedFees The total fee amount returned to the project.
|
|
224
|
+
function returnHeldFees(
|
|
225
|
+
mapping(uint256 => mapping(address => JBFee[])) storage heldFeesOf,
|
|
226
|
+
mapping(uint256 => mapping(address => uint256)) storage nextHeldFeeIndexOf,
|
|
227
|
+
uint256 projectId,
|
|
228
|
+
address token,
|
|
229
|
+
uint256 amount
|
|
230
|
+
)
|
|
231
|
+
external
|
|
232
|
+
returns (uint256 returnedFees)
|
|
233
|
+
{
|
|
234
|
+
uint256 startIndex = nextHeldFeeIndexOf[projectId][token];
|
|
235
|
+
uint256 numberOfHeldFees = heldFeesOf[projectId][token].length;
|
|
236
|
+
|
|
237
|
+
// Empty queue — nothing to return.
|
|
238
|
+
if (startIndex >= numberOfHeldFees) return 0;
|
|
239
|
+
|
|
240
|
+
uint256 leftoverAmount = amount;
|
|
241
|
+
uint256 count = numberOfHeldFees - startIndex;
|
|
242
|
+
uint256 newStartIndex = startIndex;
|
|
243
|
+
|
|
244
|
+
for (uint256 i; i < count;) {
|
|
245
|
+
JBFee memory heldFee = heldFeesOf[projectId][token][startIndex + i];
|
|
246
|
+
|
|
247
|
+
if (leftoverAmount == 0) {
|
|
248
|
+
break;
|
|
249
|
+
} else {
|
|
250
|
+
// Recompute the fee charged on the stored gross amount so partial returns stay aligned.
|
|
251
|
+
uint256 feeAmount = JBFees.standardFeeAmountFrom({amountBeforeFee: heldFee.amount});
|
|
252
|
+
uint256 amountPaidOut = heldFee.amount - feeAmount;
|
|
253
|
+
|
|
254
|
+
if (leftoverAmount >= amountPaidOut) {
|
|
255
|
+
// Whole entry consumed: credit its full fee, advance the cursor past it.
|
|
256
|
+
unchecked {
|
|
257
|
+
leftoverAmount -= amountPaidOut;
|
|
258
|
+
returnedFees += feeAmount;
|
|
259
|
+
}
|
|
260
|
+
newStartIndex = startIndex + i + 1;
|
|
261
|
+
} else {
|
|
262
|
+
// Partial entry: shrink the stored entry by what we consumed (incl. its own fee).
|
|
263
|
+
feeAmount = JBFees.standardFeeAmountResultingIn({amountAfterFee: leftoverAmount});
|
|
264
|
+
unchecked {
|
|
265
|
+
heldFeesOf[projectId][token][startIndex + i].amount -= (leftoverAmount + feeAmount);
|
|
266
|
+
returnedFees += feeAmount;
|
|
267
|
+
}
|
|
268
|
+
leftoverAmount = 0;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
unchecked {
|
|
273
|
+
++i;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (startIndex != newStartIndex) nextHeldFeeIndexOf[projectId][token] = newStartIndex;
|
|
278
|
+
|
|
279
|
+
emit ReturnHeldFees({
|
|
280
|
+
projectId: projectId,
|
|
281
|
+
token: token,
|
|
282
|
+
amount: amount,
|
|
283
|
+
returnedFees: returnedFees,
|
|
284
|
+
leftoverAmount: leftoverAmount,
|
|
285
|
+
caller: msg.sender
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|