@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/ADMINISTRATION.md +41 -27
- package/ARCHITECTURE.md +141 -28
- package/AUDIT_INSTRUCTIONS.md +118 -70
- package/CHANGE_LOG.md +14 -2
- package/README.md +45 -6
- package/RISKS.md +21 -4
- package/SKILLS.md +23 -11
- package/STYLE_GUIDE.md +1 -1
- package/USER_JOURNEYS.md +246 -132
- package/package.json +8 -8
- package/script/ConfigureFeeProject.s.sol +1 -1
- package/script/Deploy.s.sol +1 -1
- package/script/helpers/CroptopDeploymentLib.sol +1 -1
- package/src/CTDeployer.sol +14 -4
- package/src/CTProjectOwner.sol +1 -1
- package/src/CTPublisher.sol +11 -10
- package/src/interfaces/ICTDeployer.sol +2 -2
- package/src/interfaces/ICTPublisher.sol +1 -1
- package/test/CTDeployer.t.sol +15 -8
- package/test/CTProjectOwner.t.sol +1 -1
- package/test/CTPublisher.t.sol +1 -1
- package/test/ClaimCollectionOwnership.t.sol +1 -1
- package/test/CroptopAttacks.t.sol +1 -1
- package/test/TestAuditGaps.sol +16 -10
- package/test/audit/CodexFeeBeneficiaryReentrancy.t.sol +243 -0
- package/test/regression/DuplicateUriFeeEvasion.t.sol +1 -1
- package/test/regression/FeeEvasion.t.sol +1 -1
- package/test/regression/StaleTierIdMapping.t.sol +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@croptop/core-v6",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
20
|
-
"@bananapus/buyback-hook-v6": "^0.0.
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/ownable-v6": "^0.0.
|
|
23
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
24
|
-
"@bananapus/router-terminal-v6": "^0.0.
|
|
25
|
-
"@bananapus/suckers-v6": "^0.0.
|
|
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";
|
package/script/Deploy.s.sol
CHANGED
|
@@ -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";
|
package/src/CTDeployer.sol
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
package/src/CTProjectOwner.sol
CHANGED
package/src/CTPublisher.sol
CHANGED
|
@@ -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.
|
|
158
|
-
///
|
|
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
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if
|
|
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:
|
|
421
|
+
feeTerminal.pay{value: payValue}({
|
|
421
422
|
projectId: FEE_PROJECT_ID,
|
|
422
|
-
amount:
|
|
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,
|
|
28
|
-
///
|
|
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.
|
|
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(
|
package/test/CTDeployer.t.sol
CHANGED
|
@@ -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
|
|
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
|
-
|
|
421
|
-
|
|
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
|
|
464
|
-
function
|
|
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
|
-
|
|
468
|
-
|
|
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
|
//*********************************************************************//
|
package/test/CTPublisher.t.sol
CHANGED
package/test/TestAuditGaps.sol
CHANGED
|
@@ -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
|
|
242
|
-
function
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
252
|
-
function
|
|
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
|
-
|
|
256
|
-
|
|
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
|
+
}
|