@bananapus/core-v6 0.0.47 → 0.0.49
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/foundry.toml
CHANGED
package/package.json
CHANGED
package/src/JBMultiTerminal.sol
CHANGED
|
@@ -319,7 +319,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
319
319
|
address originalMessageSender
|
|
320
320
|
)
|
|
321
321
|
external
|
|
322
|
-
returns (uint256 netPayoutAmount)
|
|
322
|
+
returns (uint256 netPayoutAmount, uint256 feeEligibleAmount)
|
|
323
323
|
{
|
|
324
324
|
// NOTICE: May only be called by this terminal itself.
|
|
325
325
|
require(msg.sender == address(this));
|
|
@@ -334,36 +334,24 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
334
334
|
revert JBMultiTerminal_SplitHookInvalid({hook: split.hook});
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
// This payout is eligible for a fee since the funds are leaving this contract and the split hook isn't a
|
|
338
|
-
// feeless address.
|
|
339
337
|
if (!_isFeeless({addr: address(split.hook), projectId: projectId})) {
|
|
340
338
|
unchecked {
|
|
341
339
|
netPayoutAmount -= _feeAmountFrom(amount);
|
|
342
340
|
}
|
|
343
341
|
}
|
|
344
342
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
terminal: address(this), projectId: projectId, token: token
|
|
351
|
-
}).decimals,
|
|
343
|
+
// Delegate the partial-pull-aware hook invocation to the library so this branch stays compact.
|
|
344
|
+
// The library builds the hook context internally and infers fee-eligibility from
|
|
345
|
+
// `netPayoutAmount < amount` (`true` only when a fee was deducted above).
|
|
346
|
+
(netPayoutAmount, feeEligibleAmount) = JBPayoutSplitGroupLib.invokeSplitHookWithPartial({
|
|
347
|
+
split: split,
|
|
352
348
|
projectId: projectId,
|
|
353
|
-
|
|
354
|
-
|
|
349
|
+
token: token,
|
|
350
|
+
amount: amount,
|
|
351
|
+
netPayoutAmount: netPayoutAmount,
|
|
352
|
+
store: STORE
|
|
355
353
|
});
|
|
356
354
|
|
|
357
|
-
// Trigger any inherited pre-transfer logic.
|
|
358
|
-
// Get a reference to the amount being paid in `msg.value`.
|
|
359
|
-
uint256 payValue = _beforeTransferTo({to: address(split.hook), token: token, amount: netPayoutAmount});
|
|
360
|
-
|
|
361
|
-
// If this terminal's token is the native token, send it in `msg.value`.
|
|
362
|
-
split.hook.processSplitWith{value: payValue}(context);
|
|
363
|
-
|
|
364
|
-
// Revoke the temporary pull allowance now that the hook call has finished.
|
|
365
|
-
_afterTransferTo({to: address(split.hook), token: token});
|
|
366
|
-
|
|
367
355
|
// Otherwise, if a project is specified, make a payment to it.
|
|
368
356
|
} else if (split.projectId != 0) {
|
|
369
357
|
// Get a reference to the terminal being used.
|
|
@@ -383,6 +371,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
383
371
|
unchecked {
|
|
384
372
|
netPayoutAmount -= _feeAmountFrom(amount);
|
|
385
373
|
}
|
|
374
|
+
feeEligibleAmount = amount;
|
|
386
375
|
}
|
|
387
376
|
|
|
388
377
|
// Track the fee-free payout amount. During cashout at zero tax rate, fees apply
|
|
@@ -446,6 +435,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
|
|
|
446
435
|
unchecked {
|
|
447
436
|
netPayoutAmount -= _feeAmountFrom(amount);
|
|
448
437
|
}
|
|
438
|
+
feeEligibleAmount = amount;
|
|
449
439
|
}
|
|
450
440
|
|
|
451
441
|
// If there's a beneficiary, send the funds directly to the beneficiary. Otherwise send to the
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
pragma solidity 0.8.28;
|
|
3
3
|
|
|
4
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
5
|
+
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
4
6
|
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
5
7
|
|
|
8
|
+
import {IJBSplitHook} from "../interfaces/IJBSplitHook.sol";
|
|
6
9
|
import {IJBSplits} from "../interfaces/IJBSplits.sol";
|
|
7
10
|
import {IJBTerminalStore} from "../interfaces/IJBTerminalStore.sol";
|
|
8
11
|
import {JBSplit} from "../structs/JBSplit.sol";
|
|
12
|
+
import {JBSplitHookContext} from "../structs/JBSplitHookContext.sol";
|
|
9
13
|
import {JBConstants} from "./JBConstants.sol";
|
|
10
14
|
|
|
11
15
|
/// @notice Minimal callback surface used only by this library to call back into the terminal's `executePayout(...)`.
|
|
@@ -18,7 +22,11 @@ interface IJBPayoutSplitGroupExecutor {
|
|
|
18
22
|
/// @param token The token being paid out.
|
|
19
23
|
/// @param amount The amount assigned to the split.
|
|
20
24
|
/// @param originalMessageSender The account that started the payout flow.
|
|
21
|
-
/// @return netPayoutAmount The amount
|
|
25
|
+
/// @return netPayoutAmount The amount the split recipient actually received (may be less than the
|
|
26
|
+
/// post-fee amount if a split hook accepted a partial pull).
|
|
27
|
+
/// @return feeEligibleAmount The gross-equivalent of `netPayoutAmount` that should accrue held fees.
|
|
28
|
+
/// Equals `amount` on a fully-consumed non-feeless payout, `0` on feeless or when the hook took nothing,
|
|
29
|
+
/// and a scaled value `amount * sent / netOffered` for a partial pull.
|
|
22
30
|
function executePayout(
|
|
23
31
|
JBSplit calldata split,
|
|
24
32
|
uint256 projectId,
|
|
@@ -27,7 +35,7 @@ interface IJBPayoutSplitGroupExecutor {
|
|
|
27
35
|
address originalMessageSender
|
|
28
36
|
)
|
|
29
37
|
external
|
|
30
|
-
returns (uint256 netPayoutAmount);
|
|
38
|
+
returns (uint256 netPayoutAmount, uint256 feeEligibleAmount);
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
/// @notice Handles distributing payouts to a project's split recipients. Iterates through each split, sends the
|
|
@@ -48,6 +56,105 @@ library JBPayoutSplitGroupLib {
|
|
|
48
56
|
address caller
|
|
49
57
|
);
|
|
50
58
|
|
|
59
|
+
/// @notice Invokes a split hook with partial-pull-aware allowance handling.
|
|
60
|
+
/// @dev For ERC-20: grants the hook an allowance, calls `processSplitWith`, and revokes any unconsumed
|
|
61
|
+
/// allowance. For native ETH: pushes via `msg.value`. The hook may take less than offered (revert or
|
|
62
|
+
/// short-pull); the unsent portion is routed back to the project balance via `store.recordAddedBalanceFor`,
|
|
63
|
+
/// scaled to include the proportional fee allocation so the held fee is effectively charged only on the
|
|
64
|
+
/// consumed amount. Fee-eligibility is inferred from `netPayoutAmount < amount`.
|
|
65
|
+
/// @dev Called via DELEGATECALL from the terminal, so `address(this)` is the terminal and `msg.sender`
|
|
66
|
+
/// observed by the hook is the terminal — hooks that check `DIRECTORY.isTerminalOf(msg.sender)` continue
|
|
67
|
+
/// to work unchanged.
|
|
68
|
+
/// @param split The split (must have a non-zero hook address).
|
|
69
|
+
/// @param projectId The originating project ID.
|
|
70
|
+
/// @param token The token being distributed. Use `JBConstants.NATIVE_TOKEN` for ETH.
|
|
71
|
+
/// @param amount The gross amount allocated to this split (pre-fee).
|
|
72
|
+
/// @param netPayoutAmount The amount the hook is offered (post-fee for non-feeless splits, == amount otherwise).
|
|
73
|
+
/// @param store The terminal store used to credit any refund back to the project and to look up decimals.
|
|
74
|
+
/// @return sent The amount the hook actually received.
|
|
75
|
+
/// @return feeEligibleAmount The gross-equivalent of `sent` (used for held-fee accounting). Zero for
|
|
76
|
+
/// feeless splits or when the hook consumed nothing.
|
|
77
|
+
function invokeSplitHookWithPartial(
|
|
78
|
+
JBSplit calldata split,
|
|
79
|
+
uint256 projectId,
|
|
80
|
+
address token,
|
|
81
|
+
uint256 amount,
|
|
82
|
+
uint256 netPayoutAmount,
|
|
83
|
+
IJBTerminalStore store
|
|
84
|
+
)
|
|
85
|
+
external
|
|
86
|
+
returns (uint256 sent, uint256 feeEligibleAmount)
|
|
87
|
+
{
|
|
88
|
+
// Native vs ERC-20 governs the transfer mechanism (push via msg.value vs allowance pull).
|
|
89
|
+
bool isNative = token == JBConstants.NATIVE_TOKEN;
|
|
90
|
+
|
|
91
|
+
// Build the hook context inline so the terminal call site doesn't have to. `decimals` is looked up from
|
|
92
|
+
// the terminal store's recorded accounting context for this (projectId, token) pair.
|
|
93
|
+
JBSplitHookContext memory context = JBSplitHookContext({
|
|
94
|
+
token: token,
|
|
95
|
+
amount: netPayoutAmount,
|
|
96
|
+
decimals: store.accountingContextOf({terminal: address(this), projectId: projectId, token: token}).decimals,
|
|
97
|
+
projectId: projectId,
|
|
98
|
+
groupId: uint256(uint160(token)),
|
|
99
|
+
split: split
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Set up the transfer: ETH is pushed via `value:` on the hook call; ERC-20 grants the hook a pull
|
|
103
|
+
// allowance for the offered net amount.
|
|
104
|
+
uint256 payValue;
|
|
105
|
+
if (isNative) {
|
|
106
|
+
payValue = netPayoutAmount;
|
|
107
|
+
} else {
|
|
108
|
+
SafeERC20.forceApprove({token: IERC20(token), spender: address(split.hook), value: netPayoutAmount});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Wrap the hook call in try/catch so a reverting hook does not bubble out. On revert no tokens leave
|
|
112
|
+
// this contract (transferFrom inside the hook is rolled back; pushed ETH is returned). The success
|
|
113
|
+
// flag drives the native-ETH `sent` computation below — we cannot use a balance delta because the
|
|
114
|
+
// hook may reenter into this terminal (pay/cashOut/etc.) and shift our balance independently of its
|
|
115
|
+
// own consumption.
|
|
116
|
+
bool hookOk;
|
|
117
|
+
try split.hook.processSplitWith{value: payValue}(context) {
|
|
118
|
+
hookOk = true;
|
|
119
|
+
} catch {}
|
|
120
|
+
|
|
121
|
+
if (isNative) {
|
|
122
|
+
// Native ETH is pushed via `value:`. There is no on-the-fly "give some back" mechanism — a
|
|
123
|
+
// successful hook consumed exactly `netPayoutAmount`; a reverting hook consumed 0 (the EVM
|
|
124
|
+
// refunds the value on revert). Any side-effects the hook produced via reentrant terminal
|
|
125
|
+
// calls (pay/addToBalance/cashOut) are recorded through those calls' own bookkeeping and must
|
|
126
|
+
// not bleed into this split's consumption accounting.
|
|
127
|
+
sent = hookOk ? netPayoutAmount : 0;
|
|
128
|
+
} else {
|
|
129
|
+
// ERC-20 hooks pull via `transferFrom` against the allowance we granted. The allowance delta
|
|
130
|
+
// is the only consumption measure that is robust against reentrant balance manipulation: the
|
|
131
|
+
// hook cannot raise its own allowance, and any pull on this allowance reduces it 1:1 with
|
|
132
|
+
// what the hook actually received. Reentrant flows through other paths use independent
|
|
133
|
+
// allowances/values and so cannot inflate this measurement.
|
|
134
|
+
sent = netPayoutAmount - IERC20(token).allowance({owner: address(this), spender: address(split.hook)});
|
|
135
|
+
|
|
136
|
+
// Revoke any unconsumed ERC-20 allowance immediately after the call so the hook can never pull later.
|
|
137
|
+
SafeERC20.forceApprove({token: IERC20(token), spender: address(split.hook), value: 0});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// If the hook took less than offered, refund the proportional gross portion to the project's balance.
|
|
141
|
+
// refund = amount * (netPayoutAmount - sent) / netPayoutAmount. For full consumption this branch is
|
|
142
|
+
// skipped. For zero consumption this refunds the full `amount` (i.e. the gross, fee allocation included).
|
|
143
|
+
if (sent < netPayoutAmount) {
|
|
144
|
+
uint256 refund = mulDiv(amount, netPayoutAmount - sent, netPayoutAmount);
|
|
145
|
+
if (refund != 0) {
|
|
146
|
+
store.recordAddedBalanceFor({projectId: projectId, token: token, amount: refund});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `netPayoutAmount < amount` iff the terminal deducted a fee above (non-feeless split). Report the
|
|
151
|
+
// gross-equivalent of what the hook actually consumed so the held fee scales with consumption rather
|
|
152
|
+
// than with the project's original payout intent.
|
|
153
|
+
if (netPayoutAmount < amount && sent != 0) {
|
|
154
|
+
feeEligibleAmount = mulDiv(amount, sent, netPayoutAmount);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
51
158
|
/// @notice Sends payouts to the payout splits group specified in a project's ruleset.
|
|
52
159
|
/// @param splits The splits contract to read splits from.
|
|
53
160
|
/// @param store The terminal store used to restore balance when a payout fails.
|
|
@@ -87,14 +194,25 @@ library JBPayoutSplitGroupLib {
|
|
|
87
194
|
// The amount to send to the split.
|
|
88
195
|
uint256 payoutAmount = mulDiv(leftoverAmount, split.percent, leftoverPercentage);
|
|
89
196
|
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
197
|
+
// Send the payout (inlined to keep stack pressure manageable with the tuple return).
|
|
198
|
+
// Returns (netPayoutAmount sent, feeEligible gross-equivalent). For non-hook splits and fully-consumed
|
|
199
|
+
// hook splits, `feeEligible` equals `payoutAmount` (non-feeless) or 0 (feeless). For a partial split-hook
|
|
200
|
+
// pull, `feeEligible` scales with consumed. Failed payouts consume the payout limit by design — the
|
|
201
|
+
// try/catch keeps a single bad split from DoS-ing the rest and restores balance.
|
|
202
|
+
uint256 netPayoutAmount;
|
|
203
|
+
try IJBPayoutSplitGroupExecutor(address(this))
|
|
204
|
+
.executePayout({
|
|
205
|
+
split: split, projectId: projectId, token: token, amount: payoutAmount, originalMessageSender: caller
|
|
206
|
+
}) returns (
|
|
207
|
+
uint256 sentAmount, uint256 feeEligible
|
|
208
|
+
) {
|
|
209
|
+
netPayoutAmount = sentAmount;
|
|
210
|
+
amountEligibleForFees += feeEligible;
|
|
211
|
+
} catch (bytes memory failureReason) {
|
|
212
|
+
emit PayoutReverted({
|
|
213
|
+
projectId: projectId, split: split, amount: payoutAmount, reason: failureReason, caller: caller
|
|
214
|
+
});
|
|
215
|
+
store.recordAddedBalanceFor({projectId: projectId, token: token, amount: payoutAmount});
|
|
98
216
|
}
|
|
99
217
|
|
|
100
218
|
if (payoutAmount != 0) {
|
|
@@ -123,47 +241,4 @@ library JBPayoutSplitGroupLib {
|
|
|
123
241
|
}
|
|
124
242
|
}
|
|
125
243
|
}
|
|
126
|
-
|
|
127
|
-
/// @notice Sends a payout to a split.
|
|
128
|
-
/// @param store The terminal store used to restore balance when a payout fails.
|
|
129
|
-
/// @param split The split to pay.
|
|
130
|
-
/// @param projectId The ID of the project the split was specified by.
|
|
131
|
-
/// @param token The address of the token to pay out.
|
|
132
|
-
/// @param amount The total amount that the split is paid.
|
|
133
|
-
/// @param caller The original caller of the terminal payout flow.
|
|
134
|
-
/// @return netPayoutAmount The amount sent to the split after subtracting fees.
|
|
135
|
-
function _sendPayoutToSplit(
|
|
136
|
-
IJBTerminalStore store,
|
|
137
|
-
JBSplit memory split,
|
|
138
|
-
uint256 projectId,
|
|
139
|
-
address token,
|
|
140
|
-
uint256 amount,
|
|
141
|
-
address caller
|
|
142
|
-
)
|
|
143
|
-
private
|
|
144
|
-
returns (uint256 netPayoutAmount)
|
|
145
|
-
{
|
|
146
|
-
// Failed split payouts consume the payout limit by design. The try-catch prevents a single
|
|
147
|
-
// split from DoS-ing the entire payout. Failed splits' amounts are returned to the project balance via
|
|
148
|
-
// `recordAddedBalanceFor`. Payout limit consumption is correct because the project authorized the
|
|
149
|
-
// distribution.
|
|
150
|
-
try IJBPayoutSplitGroupExecutor(address(this))
|
|
151
|
-
.executePayout({
|
|
152
|
-
split: split, projectId: projectId, token: token, amount: amount, originalMessageSender: caller
|
|
153
|
-
}) returns (
|
|
154
|
-
uint256 payoutAmount
|
|
155
|
-
) {
|
|
156
|
-
return payoutAmount;
|
|
157
|
-
} catch (bytes memory failureReason) {
|
|
158
|
-
emit PayoutReverted({
|
|
159
|
-
projectId: projectId, split: split, amount: amount, reason: failureReason, caller: caller
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// Add balance back to the project.
|
|
163
|
-
store.recordAddedBalanceFor({projectId: projectId, token: token, amount: amount});
|
|
164
|
-
|
|
165
|
-
// Since the payout failed the netPayoutAmount is zero.
|
|
166
|
-
return 0;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
244
|
}
|
package/test/helpers/JBTest.sol
CHANGED
|
@@ -11,6 +11,27 @@ import {Test} from "lib/forge-std/src/Test.sol";
|
|
|
11
11
|
contract JBTest is Test {
|
|
12
12
|
using JBRulesetMetadataResolver for JBRulesetMetadata;
|
|
13
13
|
|
|
14
|
+
/// @notice The pinned CREATE2 address of `JBPayoutSplitGroupLib` from `foundry.toml`.
|
|
15
|
+
/// @dev Must match the `libraries = [...]` entry. Tests etch the runtime code here so
|
|
16
|
+
/// `JBMultiTerminal`'s delegatecalls into the library resolve.
|
|
17
|
+
address internal constant _JB_PAYOUT_SPLIT_GROUP_LIB = 0xE5767922e5f8d18c57C4685289Ef92D859eb980b;
|
|
18
|
+
|
|
19
|
+
/// @notice Deploy `JBPayoutSplitGroupLib` locally and etch its runtime bytecode at the pinned
|
|
20
|
+
/// address from `foundry.toml`. Forge auto-deploys linked libraries only when no `libraries`
|
|
21
|
+
/// entry is configured; once the address is pinned (needed so deploy-all-v6 can read
|
|
22
|
+
/// `artifacts/JBMultiTerminal.json::bytecode.object` as valid hex), unit tests must plant the
|
|
23
|
+
/// runtime code themselves or any path through `executePayout` / `sendPayoutsToSplitGroupOf`
|
|
24
|
+
/// reverts with `delegatecall to non-contract address`.
|
|
25
|
+
function _etchPayoutSplitGroupLib() internal {
|
|
26
|
+
bytes memory creationCode = vm.getCode("JBPayoutSplitGroupLib");
|
|
27
|
+
address libImpl;
|
|
28
|
+
assembly {
|
|
29
|
+
libImpl := create(0, add(creationCode, 0x20), mload(creationCode))
|
|
30
|
+
}
|
|
31
|
+
require(libImpl != address(0), "JBPayoutSplitGroupLib deploy failed");
|
|
32
|
+
vm.etch(_JB_PAYOUT_SPLIT_GROUP_LIB, libImpl.code);
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
function mockExpect(address _where, bytes memory _encodedCall, bytes memory _returns) public {
|
|
15
36
|
vm.mockCall(_where, _encodedCall, _returns);
|
|
16
37
|
vm.expectCall(_where, _encodedCall);
|
|
@@ -290,6 +290,9 @@ contract TestBaseWorkflow is JBTest, DeployPermit2 {
|
|
|
290
290
|
|
|
291
291
|
// Deploys and initializes contracts for testing.
|
|
292
292
|
function setUp() public virtual {
|
|
293
|
+
// Plant `JBPayoutSplitGroupLib` at its pre-linked address so terminal delegatecalls resolve.
|
|
294
|
+
_etchPayoutSplitGroupLib();
|
|
295
|
+
|
|
293
296
|
_jbPermissions = new JBPermissions(_trustedForwarder);
|
|
294
297
|
_jbProjects = new JBProjects(_multisig, address(0), _trustedForwarder);
|
|
295
298
|
_jbDirectory = new JBDirectory(_jbPermissions, _jbProjects, _multisig);
|