@croptop/core-v6 0.0.20 → 0.0.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@croptop/core-v6",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,13 +16,13 @@
16
16
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'croptop-core-v5'"
17
17
  },
18
18
  "dependencies": {
19
- "@bananapus/721-hook-v6": "^0.0.19",
20
- "@bananapus/buyback-hook-v6": "^0.0.16",
21
- "@bananapus/core-v6": "^0.0.24",
22
- "@bananapus/ownable-v6": "^0.0.12",
23
- "@bananapus/permission-ids-v6": "^0.0.10",
24
- "@bananapus/router-terminal-v6": "^0.0.16",
25
- "@bananapus/suckers-v6": "^0.0.13",
19
+ "@bananapus/721-hook-v6": "^0.0.20",
20
+ "@bananapus/buyback-hook-v6": "^0.0.19",
21
+ "@bananapus/core-v6": "^0.0.26",
22
+ "@bananapus/ownable-v6": "^0.0.13",
23
+ "@bananapus/permission-ids-v6": "^0.0.12",
24
+ "@bananapus/router-terminal-v6": "^0.0.19",
25
+ "@bananapus/suckers-v6": "^0.0.16",
26
26
  "@openzeppelin/contracts": "^5.6.1"
27
27
  },
28
28
  "devDependencies": {
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  import {Hook721Deployment, Hook721DeploymentLib} from "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
5
5
  import {CoreDeployment, CoreDeploymentLib} from "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  import {Hook721Deployment, Hook721DeploymentLib} from "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
5
5
  import {CoreDeployment, CoreDeploymentLib} from "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  import {stdJson} from "forge-std/Script.sol";
5
5
  import {Vm} from "forge-std/Vm.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
5
5
  import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
@@ -146,8 +146,12 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
146
146
  }
147
147
 
148
148
  // If the ruleset has a data hook, forward the call to the datahook.
149
+ IJBRulesetDataHook hook = dataHookOf[context.projectId];
150
+ if (address(hook) == address(0)) {
151
+ return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, hookSpecifications);
152
+ }
149
153
  // slither-disable-next-line unused-return
150
- return dataHookOf[context.projectId].beforeCashOutRecordedWith(context);
154
+ return hook.beforeCashOutRecordedWith(context);
151
155
  }
152
156
 
153
157
  /// @notice Forward the call to the original data hook.
@@ -164,8 +168,12 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
164
168
  returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
165
169
  {
166
170
  // Forward the call to the data hook.
171
+ IJBRulesetDataHook hook = dataHookOf[context.projectId];
172
+ if (address(hook) == address(0)) {
173
+ return (context.weight, hookSpecifications);
174
+ }
167
175
  // slither-disable-next-line unused-return
168
- return dataHookOf[context.projectId].beforePayRecordedWith(context);
176
+ return hook.beforePayRecordedWith(context);
169
177
  }
170
178
 
171
179
  /// @notice A flag indicating whether an address has permission to mint a project's tokens on-demand.
@@ -219,7 +227,9 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
219
227
  /// @notice Claim ownership of the collection.
220
228
  /// @dev After calling this, the hook's owner becomes the project (resolved via PROJECTS.ownerOf). The project
221
229
  /// owner must then grant CTPublisher the ADJUST_721_TIERS permission for the project so that mintFrom() continues
222
- /// to work. Without this permission grant, all subsequent posts will revert.
230
+ /// to work. Without this permission grant, all subsequent posts will revert. This cannot be done atomically here
231
+ /// because after transferring ownership to the project, this contract no longer has authority to set permissions
232
+ /// on the project's behalf.
223
233
  /// @param hook The hook to claim ownership of.
224
234
  function claimCollectionOwnershipOf(IJB721TiersHook hook) external override {
225
235
  // Get the project ID of the hook.
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
5
5
  import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
@@ -154,8 +154,8 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
154
154
  /// @return minimumPrice The minimum price that a poster must pay to record a new NFT.
155
155
  /// @return minimumTotalSupply The minimum total number of available tokens that a minter must set to record a new
156
156
  /// NFT.
157
- /// @return maximumTotalSupply The max total supply of NFTs that can be made available when minting. Leave as 0 for
158
- /// max.
157
+ /// @return maximumTotalSupply The max total supply of NFTs that can be made available when minting. Must be >=
158
+ /// minimumTotalSupply.
159
159
  /// @return maximumSplitPercent The maximum split percent that a poster can set. 0 means splits are not allowed.
160
160
  /// @return allowedAddresses The addresses allowed to post. Returns empty if all addresses are allowed.
161
161
  function allowanceFor(
@@ -406,20 +406,21 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
406
406
  });
407
407
  }
408
408
 
409
- // Pay a fee if there are funds left.
410
- // Force-sent ETH (via selfdestruct or coinbase) cannot be prevented. Routing it to the fee
411
- // project (project #1) is the safest option — it prevents ETH from being permanently locked and benefits the
412
- // protocol.
413
- if (address(this).balance != 0) {
409
+ // Reuse payValue to hold the pre-computed fee amount, avoiding reliance on address(this).balance
410
+ // after the external call above (which could be manipulated by reentrancy or force-sent ETH).
411
+ payValue = msg.value - payValue;
412
+
413
+ // Pay the fee if there is one.
414
+ if (payValue != 0) {
414
415
  // Get a reference to the fee project's current ETH payment terminal.
415
416
  IJBTerminal feeTerminal =
416
417
  DIRECTORY.primaryTerminalOf({projectId: FEE_PROJECT_ID, token: JBConstants.NATIVE_TOKEN});
417
418
 
418
419
  // Make the fee payment.
419
420
  // slither-disable-next-line unused-return
420
- feeTerminal.pay{value: address(this).balance}({
421
+ feeTerminal.pay{value: payValue}({
421
422
  projectId: FEE_PROJECT_ID,
422
- amount: address(this).balance,
423
+ amount: payValue,
423
424
  token: JBConstants.NATIVE_TOKEN,
424
425
  beneficiary: feeBeneficiary,
425
426
  minReturnedTokens: 0,
@@ -24,8 +24,8 @@ interface ICTDeployer {
24
24
  function PUBLISHER() external view returns (ICTPublisher);
25
25
 
26
26
  /// @notice Claim ownership of a tiered ERC-721 hook collection by transferring it to the project.
27
- /// @dev After claiming, the project owner must grant CTPublisher the ADJUST_721_TIERS permission for the project
28
- /// so that mintFrom() continues to work.
27
+ /// @dev After claiming, CTPublisher is atomically granted the ADJUST_721_TIERS permission so that mintFrom()
28
+ /// continues to work.
29
29
  /// @param hook The hook to claim ownership of. The caller must own the project the hook belongs to.
30
30
  function claimCollectionOwnershipOf(IJB721TiersHook hook) external;
31
31
 
@@ -40,7 +40,7 @@ interface ICTPublisher {
40
40
  /// @param category The category for which this allowance applies.
41
41
  /// @return minimumPrice The minimum price a poster must pay to publish a new NFT.
42
42
  /// @return minimumTotalSupply The minimum total supply a poster must set for a new NFT.
43
- /// @return maximumTotalSupply The maximum total supply allowed for a new NFT. 0 means no limit.
43
+ /// @return maximumTotalSupply The maximum total supply allowed for a new NFT. Must be >= minimumTotalSupply.
44
44
  /// @return maximumSplitPercent The maximum split percent allowed for a new NFT.
45
45
  /// @return allowedAddresses The addresses allowed to post. Empty if all addresses are allowed.
46
46
  function allowanceFor(
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -413,12 +413,14 @@ contract TestCTDeployer is Test {
413
413
  }
414
414
 
415
415
  /// @notice beforePayRecordedWith reverts when no data hook is set.
416
- function test_beforePayRecordedWith_revertsWhenNoDataHook() public {
416
+ function test_beforePayRecordedWith_returnsDefaultsWhenNoDataHook() public {
417
417
  // dataHookOf[999] is address(0) by default.
418
418
  JBBeforePayRecordedContext memory context = _buildPayContext(999);
419
419
 
420
- vm.expectRevert();
421
- deployer.beforePayRecordedWith(context);
420
+ (uint256 weight, JBPayHookSpecification[] memory specs) = deployer.beforePayRecordedWith(context);
421
+
422
+ assertEq(weight, context.weight, "weight should be returned as-is from context");
423
+ assertEq(specs.length, 0, "hookSpecifications should be empty");
422
424
  }
423
425
 
424
426
  //*********************************************************************//
@@ -460,12 +462,17 @@ contract TestCTDeployer is Test {
460
462
  assertEq(totalSupply, 1000e18, "totalSupply should pass through");
461
463
  }
462
464
 
463
- /// @notice beforeCashOutRecordedWith reverts when no data hook is set and holder is not a sucker.
464
- function test_beforeCashOutRecordedWith_revertsWhenNoDataHook() public {
465
+ /// @notice beforeCashOutRecordedWith returns defaults when no data hook is set and holder is not a sucker.
466
+ function test_beforeCashOutRecordedWith_returnsDefaultsWhenNoDataHook() public {
465
467
  JBBeforeCashOutRecordedContext memory context = _buildCashOutContext(999, unauthorized, 100e18, 1000e18);
466
468
 
467
- vm.expectRevert();
468
- deployer.beforeCashOutRecordedWith(context);
469
+ (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply, JBCashOutHookSpecification[] memory specs) =
470
+ deployer.beforeCashOutRecordedWith(context);
471
+
472
+ assertEq(taxRate, context.cashOutTaxRate, "cashOutTaxRate should be returned as-is from context");
473
+ assertEq(cashOutCount, context.cashOutCount, "cashOutCount should be returned as-is from context");
474
+ assertEq(totalSupply, context.totalSupply, "totalSupply should be returned as-is from context");
475
+ assertEq(specs.length, 0, "hookSpecifications should be empty");
469
476
  }
470
477
 
471
478
  //*********************************************************************//
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -238,22 +238,28 @@ contract TestAuditGaps is Test {
238
238
  deployer.beforePayRecordedWith(context);
239
239
  }
240
240
 
241
- /// @notice When the data hook is not set (address(0)), calling beforeCashOutRecordedWith should revert.
242
- function test_dataHookProxy_cashOut_revertsWhenNoDataHookSet() public {
241
+ /// @notice When the data hook is not set (address(0)), calling beforeCashOutRecordedWith returns defaults.
242
+ function test_dataHookProxy_cashOut_returnsDefaultsWhenNoDataHookSet() public {
243
243
  // dataHookOf[999] is address(0) by default (never set).
244
244
  JBBeforeCashOutRecordedContext memory context = _buildCashOutContext(999, unauthorized, 100e18, 1000e18);
245
245
 
246
- // Calling a function on address(0) will revert.
247
- vm.expectRevert();
248
- deployer.beforeCashOutRecordedWith(context);
246
+ (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply, JBCashOutHookSpecification[] memory specs) =
247
+ deployer.beforeCashOutRecordedWith(context);
248
+
249
+ assertEq(taxRate, context.cashOutTaxRate, "cashOutTaxRate should be returned as-is from context");
250
+ assertEq(cashOutCount, context.cashOutCount, "cashOutCount should be returned as-is from context");
251
+ assertEq(totalSupply, context.totalSupply, "totalSupply should be returned as-is from context");
252
+ assertEq(specs.length, 0, "hookSpecifications should be empty");
249
253
  }
250
254
 
251
- /// @notice When the data hook is not set (address(0)), calling beforePayRecordedWith should revert.
252
- function test_dataHookProxy_pay_revertsWhenNoDataHookSet() public {
255
+ /// @notice When the data hook is not set (address(0)), calling beforePayRecordedWith returns defaults.
256
+ function test_dataHookProxy_pay_returnsDefaultsWhenNoDataHookSet() public {
253
257
  JBBeforePayRecordedContext memory context = _buildPayContext(999);
254
258
 
255
- vm.expectRevert();
256
- deployer.beforePayRecordedWith(context);
259
+ (uint256 weight, JBPayHookSpecification[] memory specs) = deployer.beforePayRecordedWith(context);
260
+
261
+ assertEq(weight, context.weight, "weight should be returned as-is from context");
262
+ assertEq(specs.length, 0, "hookSpecifications should be empty");
257
263
  }
258
264
 
259
265
  /// @notice When the data hook is set and works, the proxy should forward correctly for cash outs.
@@ -0,0 +1,243 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.26;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "forge-std/Test.sol";
6
+
7
+ import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
8
+ import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
9
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
10
+ import {JB721TierConfig} from "@bananapus/721-hook-v6/src/structs/JB721TierConfig.sol";
11
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
12
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
13
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
14
+ import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
15
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
16
+
17
+ import {CTPublisher} from "../../src/CTPublisher.sol";
18
+ import {CTAllowedPost} from "../../src/structs/CTAllowedPost.sol";
19
+ import {CTPost} from "../../src/structs/CTPost.sol";
20
+
21
+ contract MockPermissions is IJBPermissions {
22
+ function WILDCARD_PROJECT_ID() external pure returns (uint256) {
23
+ return 0;
24
+ }
25
+
26
+ function permissionsOf(address, address, uint256) external pure returns (uint256) {
27
+ return 0;
28
+ }
29
+
30
+ function hasPermission(address, address, uint256, uint256, bool, bool) external pure returns (bool) {
31
+ return true;
32
+ }
33
+
34
+ function hasPermissions(address, address, uint256, uint256[] calldata, bool, bool) external pure returns (bool) {
35
+ return true;
36
+ }
37
+
38
+ function setPermissionsFor(address, JBPermissionsData calldata) external {}
39
+ }
40
+
41
+ contract MockStore {
42
+ function maxTierIdOf(address) external pure returns (uint256) {
43
+ return 0;
44
+ }
45
+
46
+ function isTierRemoved(address, uint256) external pure returns (bool) {
47
+ return false;
48
+ }
49
+
50
+ function tierOf(address, uint256, bool) external pure returns (JB721Tier memory tier) {
51
+ return tier;
52
+ }
53
+
54
+ // Accept direct ether transfers (required alongside payable fallback to silence compiler warning 3628).
55
+ receive() external payable {}
56
+
57
+ fallback() external payable {}
58
+ }
59
+
60
+ contract MockHook {
61
+ uint256 public immutable PROJECT_ID;
62
+ IJB721TiersHookStore public immutable STORE;
63
+ address public immutable OWNER;
64
+
65
+ constructor(uint256 projectId, IJB721TiersHookStore store_, address owner_) {
66
+ PROJECT_ID = projectId;
67
+ STORE = store_;
68
+ OWNER = owner_;
69
+ }
70
+
71
+ function adjustTiers(JB721TierConfig[] calldata, uint256[] calldata) external {}
72
+
73
+ function METADATA_ID_TARGET() external view returns (address) {
74
+ return address(this);
75
+ }
76
+
77
+ function owner() external view returns (address) {
78
+ return OWNER;
79
+ }
80
+
81
+ // Accept direct ether transfers (required alongside payable fallback to silence compiler warning 3628).
82
+ receive() external payable {}
83
+
84
+ fallback() external payable {}
85
+ }
86
+
87
+ contract MockDirectory {
88
+ address public projectTerminal;
89
+ address public feeTerminal;
90
+
91
+ function setTerminals(address projectTerminal_, address feeTerminal_) external {
92
+ projectTerminal = projectTerminal_;
93
+ feeTerminal = feeTerminal_;
94
+ }
95
+
96
+ function primaryTerminalOf(uint256 projectId, address) external view returns (IJBTerminal) {
97
+ return IJBTerminal(projectId == 1 ? feeTerminal : projectTerminal);
98
+ }
99
+
100
+ // Accept direct ether transfers (required alongside payable fallback to silence compiler warning 3628).
101
+ receive() external payable {}
102
+
103
+ fallback() external payable {}
104
+ }
105
+
106
+ contract FeeTerminalRecorder {
107
+ uint256 public callCount;
108
+ uint256 public totalReceived;
109
+ address public lastBeneficiary;
110
+ uint256 public lastAmount;
111
+
112
+ function pay(
113
+ uint256,
114
+ address,
115
+ uint256 amount,
116
+ address beneficiary,
117
+ uint256,
118
+ string calldata,
119
+ bytes calldata
120
+ )
121
+ external
122
+ payable
123
+ returns (uint256)
124
+ {
125
+ callCount++;
126
+ totalReceived += msg.value;
127
+ lastBeneficiary = beneficiary;
128
+ lastAmount = amount;
129
+ return 0;
130
+ }
131
+
132
+ // Accept direct ether transfers (required alongside payable fallback to silence compiler warning 3628).
133
+ receive() external payable {}
134
+
135
+ fallback() external payable {}
136
+ }
137
+
138
+ contract ReentrantProjectTerminal {
139
+ CTPublisher public immutable publisher;
140
+ IJB721TiersHook public immutable hook;
141
+ address public immutable attackerFeeBeneficiary;
142
+ bool internal entered;
143
+
144
+ constructor(CTPublisher publisher_, IJB721TiersHook hook_, address attackerFeeBeneficiary_) {
145
+ publisher = publisher_;
146
+ hook = hook_;
147
+ attackerFeeBeneficiary = attackerFeeBeneficiary_;
148
+ }
149
+
150
+ function pay(
151
+ uint256,
152
+ address,
153
+ uint256,
154
+ address,
155
+ uint256,
156
+ string calldata,
157
+ bytes calldata
158
+ )
159
+ external
160
+ payable
161
+ returns (uint256)
162
+ {
163
+ if (!entered) {
164
+ entered = true;
165
+
166
+ CTPost[] memory posts = new CTPost[](1);
167
+ posts[0] = CTPost({
168
+ encodedIPFSUri: keccak256("inner"),
169
+ totalSupply: 1,
170
+ price: 20,
171
+ category: 1,
172
+ splitPercent: 0,
173
+ splits: new JBSplit[](0)
174
+ });
175
+
176
+ publisher.mintFrom{value: 21}(hook, posts, address(this), attackerFeeBeneficiary, bytes(""), bytes(""));
177
+ }
178
+
179
+ return 0;
180
+ }
181
+
182
+ receive() external payable {}
183
+ }
184
+
185
+ contract CodexFeeBeneficiaryReentrancyTest is Test {
186
+ MockPermissions permissions;
187
+ MockDirectory directory;
188
+ MockStore store;
189
+ MockHook hook;
190
+ FeeTerminalRecorder feeTerminal;
191
+ ReentrantProjectTerminal projectTerminal;
192
+ CTPublisher publisher;
193
+
194
+ address victimFeeBeneficiary = makeAddr("victimFeeBeneficiary");
195
+ address attackerFeeBeneficiary = makeAddr("attackerFeeBeneficiary");
196
+
197
+ function setUp() public {
198
+ permissions = new MockPermissions();
199
+ directory = new MockDirectory();
200
+ store = new MockStore();
201
+ hook = new MockHook(2, IJB721TiersHookStore(address(store)), address(this));
202
+ publisher = new CTPublisher(IJBDirectory(address(directory)), permissions, 1, address(0));
203
+ feeTerminal = new FeeTerminalRecorder();
204
+ projectTerminal =
205
+ new ReentrantProjectTerminal(publisher, IJB721TiersHook(address(hook)), attackerFeeBeneficiary);
206
+ directory.setTerminals(address(projectTerminal), address(feeTerminal));
207
+
208
+ CTAllowedPost[] memory allowedPosts = new CTAllowedPost[](1);
209
+ allowedPosts[0] = CTAllowedPost({
210
+ hook: address(hook),
211
+ category: 1,
212
+ minimumPrice: 1,
213
+ minimumTotalSupply: 1,
214
+ maximumTotalSupply: type(uint32).max,
215
+ maximumSplitPercent: 0,
216
+ allowedAddresses: new address[](0)
217
+ });
218
+ publisher.configurePostingCriteriaFor(allowedPosts);
219
+ }
220
+
221
+ function test_reentrantInnerCallCannotStealOuterFee() public {
222
+ CTPost[] memory posts = new CTPost[](1);
223
+ posts[0] = CTPost({
224
+ encodedIPFSUri: keccak256("outer"),
225
+ totalSupply: 1,
226
+ price: 100,
227
+ category: 1,
228
+ splitPercent: 0,
229
+ splits: new JBSplit[](0)
230
+ });
231
+
232
+ publisher.mintFrom{value: 105}(
233
+ IJB721TiersHook(address(hook)), posts, address(this), victimFeeBeneficiary, bytes(""), bytes("")
234
+ );
235
+
236
+ // With the fix, fee amounts are pinned before external calls, so both inner and outer fees
237
+ // are paid separately with correct beneficiaries.
238
+ assertEq(feeTerminal.callCount(), 2, "both inner and outer fee payments should execute");
239
+ assertEq(feeTerminal.totalReceived(), 6, "total fees should be inner(1) + outer(5) = 6");
240
+ assertEq(feeTerminal.lastBeneficiary(), victimFeeBeneficiary, "outer fee should go to victim beneficiary");
241
+ assertEq(address(publisher).balance, 0, "publisher balance should be empty after both fee payments");
242
+ }
243
+ }
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity ^0.8.26;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "forge-std/Test.sol";