@croptop/core-v6 0.0.16 → 0.0.18
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/AUDIT_INSTRUCTIONS.md +458 -0
- package/CHANGE_LOG.md +253 -0
- package/RISKS.md +37 -226
- package/SKILLS.md +1 -1
- package/USER_JOURNEYS.md +598 -0
- package/package.json +8 -8
- package/script/ConfigureFeeProject.s.sol +1 -2
- package/src/CTDeployer.sol +9 -0
- package/src/CTProjectOwner.sol +4 -1
- package/src/CTPublisher.sol +19 -3
- package/src/interfaces/ICTDeployer.sol +2 -0
- package/test/CTDeployer.t.sol +608 -0
- package/test/CTProjectOwner.t.sol +185 -0
- package/test/CTPublisher.t.sol +134 -0
- package/test/ClaimCollectionOwnership.t.sol +315 -0
- package/test/Fork.t.sol +3 -4
- package/test/TestAuditGaps.sol +689 -0
|
@@ -0,0 +1,185 @@
|
|
|
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 {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
|
|
8
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
9
|
+
import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
|
|
10
|
+
import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
|
|
11
|
+
import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
|
|
12
|
+
|
|
13
|
+
import {CTProjectOwner} from "../src/CTProjectOwner.sol";
|
|
14
|
+
import {ICTPublisher} from "../src/interfaces/ICTPublisher.sol";
|
|
15
|
+
|
|
16
|
+
/// @notice Unit tests for CTProjectOwner.
|
|
17
|
+
contract CTProjectOwnerTest is Test {
|
|
18
|
+
CTProjectOwner projectOwner;
|
|
19
|
+
|
|
20
|
+
IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
|
|
21
|
+
IJBProjects projects = IJBProjects(makeAddr("projects"));
|
|
22
|
+
ICTPublisher publisher = ICTPublisher(makeAddr("publisher"));
|
|
23
|
+
|
|
24
|
+
address operator = makeAddr("operator");
|
|
25
|
+
address from = makeAddr("from");
|
|
26
|
+
|
|
27
|
+
function setUp() public {
|
|
28
|
+
projectOwner = new CTProjectOwner(permissions, projects, publisher);
|
|
29
|
+
|
|
30
|
+
// Mock setPermissionsFor to succeed by default.
|
|
31
|
+
vm.mockCall(
|
|
32
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
//*********************************************************************//
|
|
37
|
+
// --- Constructor --------------------------------------------------- //
|
|
38
|
+
//*********************************************************************//
|
|
39
|
+
|
|
40
|
+
/// @notice Verify that the constructor sets all three immutables correctly.
|
|
41
|
+
function test_constructor() public {
|
|
42
|
+
assertEq(address(projectOwner.PERMISSIONS()), address(permissions));
|
|
43
|
+
assertEq(address(projectOwner.PROJECTS()), address(projects));
|
|
44
|
+
assertEq(address(projectOwner.PUBLISHER()), address(publisher));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//*********************************************************************//
|
|
48
|
+
// --- onERC721Received ---------------------------------------------- //
|
|
49
|
+
//*********************************************************************//
|
|
50
|
+
|
|
51
|
+
/// @notice When PROJECTS sends a project NFT, the contract grants ADJUST_721_TIERS permission to PUBLISHER and
|
|
52
|
+
/// returns the correct selector.
|
|
53
|
+
function test_onERC721Received_fromProjects_grantsPermission() public {
|
|
54
|
+
uint256 tokenId = 42;
|
|
55
|
+
|
|
56
|
+
// Build the expected arguments for setPermissionsFor.
|
|
57
|
+
uint8[] memory permissionIds = new uint8[](1);
|
|
58
|
+
permissionIds[0] = JBPermissionIds.ADJUST_721_TIERS;
|
|
59
|
+
|
|
60
|
+
vm.expectCall(
|
|
61
|
+
address(permissions),
|
|
62
|
+
abi.encodeCall(
|
|
63
|
+
IJBPermissions.setPermissionsFor,
|
|
64
|
+
(
|
|
65
|
+
address(projectOwner),
|
|
66
|
+
JBPermissionsData({
|
|
67
|
+
operator: address(publisher),
|
|
68
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
69
|
+
projectId: uint64(tokenId), // safe: mirrors CTProjectOwner.onERC721Received
|
|
70
|
+
permissionIds: permissionIds
|
|
71
|
+
})
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Call onERC721Received as if PROJECTS sent the NFT.
|
|
77
|
+
vm.prank(address(projects));
|
|
78
|
+
bytes4 retval = projectOwner.onERC721Received(operator, from, tokenId, "");
|
|
79
|
+
|
|
80
|
+
assertEq(retval, IERC721Receiver.onERC721Received.selector);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// @notice Calling onERC721Received from any address other than PROJECTS must revert.
|
|
84
|
+
function test_onERC721Received_fromNonProjects_reverts() public {
|
|
85
|
+
address notProjects = makeAddr("notProjects");
|
|
86
|
+
|
|
87
|
+
vm.prank(notProjects);
|
|
88
|
+
vm.expectRevert();
|
|
89
|
+
projectOwner.onERC721Received(operator, from, 1, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// @notice The permission is set with projectId equal to uint64(tokenId).
|
|
93
|
+
function test_onERC721Received_correctProjectId() public {
|
|
94
|
+
uint256 tokenId = type(uint64).max; // Use a large tokenId to confirm truncation.
|
|
95
|
+
|
|
96
|
+
uint8[] memory permissionIds = new uint8[](1);
|
|
97
|
+
permissionIds[0] = JBPermissionIds.ADJUST_721_TIERS;
|
|
98
|
+
|
|
99
|
+
vm.expectCall(
|
|
100
|
+
address(permissions),
|
|
101
|
+
abi.encodeCall(
|
|
102
|
+
IJBPermissions.setPermissionsFor,
|
|
103
|
+
(
|
|
104
|
+
address(projectOwner),
|
|
105
|
+
JBPermissionsData({
|
|
106
|
+
operator: address(publisher),
|
|
107
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
108
|
+
projectId: uint64(tokenId), // safe: mirrors CTProjectOwner.onERC721Received
|
|
109
|
+
permissionIds: permissionIds
|
|
110
|
+
})
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
vm.prank(address(projects));
|
|
116
|
+
projectOwner.onERC721Received(operator, from, tokenId, "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// @notice Transferring multiple different project NFTs sets permissions for each one independently.
|
|
120
|
+
function test_onERC721Received_multipleProjects() public {
|
|
121
|
+
uint256 tokenId1 = 10;
|
|
122
|
+
uint256 tokenId2 = 99;
|
|
123
|
+
|
|
124
|
+
uint8[] memory permissionIds = new uint8[](1);
|
|
125
|
+
permissionIds[0] = JBPermissionIds.ADJUST_721_TIERS;
|
|
126
|
+
|
|
127
|
+
// Expect the first call with tokenId1.
|
|
128
|
+
vm.expectCall(
|
|
129
|
+
address(permissions),
|
|
130
|
+
abi.encodeCall(
|
|
131
|
+
IJBPermissions.setPermissionsFor,
|
|
132
|
+
(
|
|
133
|
+
address(projectOwner),
|
|
134
|
+
JBPermissionsData({
|
|
135
|
+
operator: address(publisher),
|
|
136
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
137
|
+
projectId: uint64(tokenId1), // safe: mirrors CTProjectOwner.onERC721Received
|
|
138
|
+
permissionIds: permissionIds
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
vm.prank(address(projects));
|
|
145
|
+
bytes4 retval1 = projectOwner.onERC721Received(operator, from, tokenId1, "");
|
|
146
|
+
assertEq(retval1, IERC721Receiver.onERC721Received.selector);
|
|
147
|
+
|
|
148
|
+
// Expect the second call with tokenId2.
|
|
149
|
+
vm.expectCall(
|
|
150
|
+
address(permissions),
|
|
151
|
+
abi.encodeCall(
|
|
152
|
+
IJBPermissions.setPermissionsFor,
|
|
153
|
+
(
|
|
154
|
+
address(projectOwner),
|
|
155
|
+
JBPermissionsData({
|
|
156
|
+
operator: address(publisher),
|
|
157
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
158
|
+
projectId: uint64(tokenId2), // safe: mirrors CTProjectOwner.onERC721Received
|
|
159
|
+
permissionIds: permissionIds
|
|
160
|
+
})
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
vm.prank(address(projects));
|
|
166
|
+
bytes4 retval2 = projectOwner.onERC721Received(operator, from, tokenId2, "");
|
|
167
|
+
assertEq(retval2, IERC721Receiver.onERC721Received.selector);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// @notice Fuzz: any tokenId from PROJECTS succeeds and returns the correct selector.
|
|
171
|
+
function test_onERC721Received_fuzz(uint256 tokenId) public {
|
|
172
|
+
vm.prank(address(projects));
|
|
173
|
+
bytes4 retval = projectOwner.onERC721Received(operator, from, tokenId, "");
|
|
174
|
+
assertEq(retval, IERC721Receiver.onERC721Received.selector);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// @notice Fuzz: any non-PROJECTS sender reverts.
|
|
178
|
+
function test_onERC721Received_fuzz_nonProjects_reverts(address sender) public {
|
|
179
|
+
vm.assume(sender != address(projects));
|
|
180
|
+
|
|
181
|
+
vm.prank(sender);
|
|
182
|
+
vm.expectRevert();
|
|
183
|
+
projectOwner.onERC721Received(operator, from, 1, "");
|
|
184
|
+
}
|
|
185
|
+
}
|
package/test/CTPublisher.t.sol
CHANGED
|
@@ -636,6 +636,140 @@ contract TestCTPublisher is Test {
|
|
|
636
636
|
// --- Multiple Posts With Different Split Percents ------------------- //
|
|
637
637
|
//*********************************************************************//
|
|
638
638
|
|
|
639
|
+
//*********************************************************************//
|
|
640
|
+
// --- Fee Validation in mintFrom ------------------------------------- //
|
|
641
|
+
//*********************************************************************//
|
|
642
|
+
|
|
643
|
+
function test_mintFrom_insufficientEthForFee_reverts() public {
|
|
644
|
+
_configureCategoryWithSplits(5, 0.01 ether, 1, 100, 0);
|
|
645
|
+
_setupMintMocks();
|
|
646
|
+
|
|
647
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
648
|
+
posts[0] = CTPost({
|
|
649
|
+
encodedIPFSUri: keccak256("fee-test"),
|
|
650
|
+
totalSupply: 10,
|
|
651
|
+
price: 1 ether,
|
|
652
|
+
category: 5,
|
|
653
|
+
splitPercent: 0,
|
|
654
|
+
splits: new JBSplit[](0)
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Fee = 1 ether / 20 = 0.05 ether. Total needed = 1.05 ether.
|
|
658
|
+
// Send only 0.04 ether — less than just the fee.
|
|
659
|
+
uint256 fee = 1 ether / 20;
|
|
660
|
+
vm.prank(poster);
|
|
661
|
+
vm.expectRevert(
|
|
662
|
+
abi.encodeWithSelector(CTPublisher.CTPublisher_InsufficientEthSent.selector, 1 ether + fee, 0.04 ether)
|
|
663
|
+
);
|
|
664
|
+
publisher.mintFrom{value: 0.04 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function test_mintFrom_exactPriceNoFee_reverts() public {
|
|
668
|
+
_configureCategoryWithSplits(5, 0.01 ether, 1, 100, 0);
|
|
669
|
+
_setupMintMocks();
|
|
670
|
+
|
|
671
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
672
|
+
posts[0] = CTPost({
|
|
673
|
+
encodedIPFSUri: keccak256("exact-price"),
|
|
674
|
+
totalSupply: 10,
|
|
675
|
+
price: 1 ether,
|
|
676
|
+
category: 5,
|
|
677
|
+
splitPercent: 0,
|
|
678
|
+
splits: new JBSplit[](0)
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// Send exactly 1 ether — covers price but not the 0.05 fee.
|
|
682
|
+
// After fee deduction: payValue = 1 - 0.05 = 0.95, which is < totalPrice (1).
|
|
683
|
+
vm.prank(poster);
|
|
684
|
+
vm.expectRevert(abi.encodeWithSelector(CTPublisher.CTPublisher_InsufficientEthSent.selector, 1 ether, 1 ether));
|
|
685
|
+
publisher.mintFrom{value: 1 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function test_mintFrom_exactPricePlusFee_succeeds() public {
|
|
689
|
+
_configureCategoryWithSplits(5, 0.01 ether, 1, 100, 0);
|
|
690
|
+
_setupMintMocks();
|
|
691
|
+
|
|
692
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
693
|
+
posts[0] = CTPost({
|
|
694
|
+
encodedIPFSUri: keccak256("exact-fee"),
|
|
695
|
+
totalSupply: 10,
|
|
696
|
+
price: 1 ether,
|
|
697
|
+
category: 5,
|
|
698
|
+
splitPercent: 0,
|
|
699
|
+
splits: new JBSplit[](0)
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Send exactly 1.05 ether (price + fee). Should not revert with InsufficientEthSent.
|
|
703
|
+
uint256 fee = 1 ether / 20;
|
|
704
|
+
vm.prank(poster);
|
|
705
|
+
try publisher.mintFrom{value: 1 ether + fee}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "") {}
|
|
706
|
+
catch (bytes memory reason) {
|
|
707
|
+
assertTrue(
|
|
708
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
709
|
+
bytes4(reason) != CTPublisher.CTPublisher_InsufficientEthSent.selector,
|
|
710
|
+
"should not revert with InsufficientEthSent"
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function test_mintFrom_feeProject_noFeeDeducted() public {
|
|
716
|
+
// Configure a category on a hook whose PROJECT_ID == FEE_PROJECT_ID (1).
|
|
717
|
+
address feeHook = makeAddr("feeHook");
|
|
718
|
+
address feeHookStore = makeAddr("feeHookStore");
|
|
719
|
+
vm.mockCall(feeHook, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(hookOwner));
|
|
720
|
+
vm.mockCall(feeHook, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(feeProjectId));
|
|
721
|
+
vm.mockCall(feeHook, abi.encodeWithSelector(IJB721TiersHook.STORE.selector), abi.encode(feeHookStore));
|
|
722
|
+
vm.mockCall(
|
|
723
|
+
feeHookStore, abi.encodeWithSelector(IJB721TiersHookStore.maxTierIdOf.selector), abi.encode(uint256(0))
|
|
724
|
+
);
|
|
725
|
+
vm.mockCall(feeHook, abi.encodeWithSelector(IJB721TiersHook.adjustTiers.selector), abi.encode());
|
|
726
|
+
vm.mockCall(feeHook, abi.encodeWithSelector(bytes4(keccak256("METADATA_ID_TARGET()"))), abi.encode(address(0)));
|
|
727
|
+
vm.mockCall(
|
|
728
|
+
address(directory),
|
|
729
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector),
|
|
730
|
+
abi.encode(makeAddr("terminal"))
|
|
731
|
+
);
|
|
732
|
+
vm.mockCall(makeAddr("terminal"), "", abi.encode(uint256(0)));
|
|
733
|
+
|
|
734
|
+
CTAllowedPost[] memory allowed = new CTAllowedPost[](1);
|
|
735
|
+
allowed[0] = CTAllowedPost({
|
|
736
|
+
hook: feeHook,
|
|
737
|
+
category: 5,
|
|
738
|
+
minimumPrice: 0.01 ether,
|
|
739
|
+
minimumTotalSupply: 1,
|
|
740
|
+
maximumTotalSupply: 100,
|
|
741
|
+
maximumSplitPercent: 0,
|
|
742
|
+
allowedAddresses: new address[](0)
|
|
743
|
+
});
|
|
744
|
+
vm.prank(hookOwner);
|
|
745
|
+
publisher.configurePostingCriteriaFor(allowed);
|
|
746
|
+
|
|
747
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
748
|
+
posts[0] = CTPost({
|
|
749
|
+
encodedIPFSUri: keccak256("fee-project-post"),
|
|
750
|
+
totalSupply: 10,
|
|
751
|
+
price: 1 ether,
|
|
752
|
+
category: 5,
|
|
753
|
+
splitPercent: 0,
|
|
754
|
+
splits: new JBSplit[](0)
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// Send exactly the price with no fee. Should not revert with InsufficientEthSent.
|
|
758
|
+
vm.prank(poster);
|
|
759
|
+
try publisher.mintFrom{value: 1 ether}(IJB721TiersHook(feeHook), posts, poster, poster, "", "") {}
|
|
760
|
+
catch (bytes memory reason) {
|
|
761
|
+
assertTrue(
|
|
762
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
763
|
+
bytes4(reason) != CTPublisher.CTPublisher_InsufficientEthSent.selector,
|
|
764
|
+
"fee project should not charge fee"
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
//*********************************************************************//
|
|
770
|
+
// --- Multiple Posts With Different Split Percents ------------------- //
|
|
771
|
+
//*********************************************************************//
|
|
772
|
+
|
|
639
773
|
function test_mintFrom_multiplePostsDifferentSplits() public {
|
|
640
774
|
// Category 5 allows up to 50% splits.
|
|
641
775
|
_configureCategoryWithSplits(5, 0, 1, 100, 500_000_000);
|
|
@@ -0,0 +1,315 @@
|
|
|
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 {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
|
|
8
|
+
import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
|
|
9
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
10
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
11
|
+
import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
|
|
12
|
+
import {IJBOwnable} from "@bananapus/ownable-v6/src/interfaces/IJBOwnable.sol";
|
|
13
|
+
import {IJB721Hook} from "@bananapus/721-hook-v6/src/interfaces/IJB721Hook.sol";
|
|
14
|
+
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
15
|
+
import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookDeployer.sol";
|
|
16
|
+
import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
|
|
17
|
+
import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
|
|
18
|
+
import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
|
|
19
|
+
import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
|
|
20
|
+
import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
|
|
21
|
+
|
|
22
|
+
import {CTDeployer} from "../src/CTDeployer.sol";
|
|
23
|
+
import {CTPublisher} from "../src/CTPublisher.sol";
|
|
24
|
+
import {ICTPublisher} from "../src/interfaces/ICTPublisher.sol";
|
|
25
|
+
import {CTAllowedPost} from "../src/structs/CTAllowedPost.sol";
|
|
26
|
+
import {CTDeployerAllowedPost} from "../src/structs/CTDeployerAllowedPost.sol";
|
|
27
|
+
import {CTProjectConfig} from "../src/structs/CTProjectConfig.sol";
|
|
28
|
+
import {CTSuckerDeploymentConfig} from "../src/structs/CTSuckerDeploymentConfig.sol";
|
|
29
|
+
|
|
30
|
+
/// @title ClaimCollectionOwnershipTest
|
|
31
|
+
/// @notice Integration tests for the post-claimCollectionOwnership permission lifecycle:
|
|
32
|
+
/// 1. Deploy a croptop collection
|
|
33
|
+
/// 2. Claim ownership (transfers hook ownership to project)
|
|
34
|
+
/// 3. Verify permissions are correctly transferred
|
|
35
|
+
/// 4. Verify post-claim the hook owner changes and publisher permissions must be re-granted
|
|
36
|
+
contract ClaimCollectionOwnershipTest is Test {
|
|
37
|
+
CTDeployer ctDeployer;
|
|
38
|
+
CTPublisher publisher;
|
|
39
|
+
|
|
40
|
+
IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
|
|
41
|
+
IJBProjects projects = IJBProjects(makeAddr("projects"));
|
|
42
|
+
IJB721TiersHookDeployer hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
|
|
43
|
+
IJBSuckerRegistry suckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
|
|
44
|
+
IJBController controller = IJBController(makeAddr("controller"));
|
|
45
|
+
|
|
46
|
+
address owner = makeAddr("owner");
|
|
47
|
+
address newOwner = makeAddr("newOwner");
|
|
48
|
+
address unauthorized = makeAddr("unauthorized");
|
|
49
|
+
address hookAddr = makeAddr("hook");
|
|
50
|
+
|
|
51
|
+
uint256 projectCount = 5;
|
|
52
|
+
uint256 deployedProjectId = projectCount + 1; // 6
|
|
53
|
+
uint256 feeProjectId = 1;
|
|
54
|
+
|
|
55
|
+
// Track which permissions were set.
|
|
56
|
+
PermissionRecord[] permissionRecords;
|
|
57
|
+
|
|
58
|
+
struct PermissionRecord {
|
|
59
|
+
address account;
|
|
60
|
+
address operator;
|
|
61
|
+
uint256 projectId;
|
|
62
|
+
uint8[] permissionIds;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setUp() public {
|
|
66
|
+
// Mock permissions.setPermissionsFor (called in CTDeployer constructor + deployment).
|
|
67
|
+
vm.mockCall(
|
|
68
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Mock permissions.hasPermission to return true by default.
|
|
72
|
+
vm.mockCall(
|
|
73
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Mock sucker registry.
|
|
77
|
+
vm.mockCall(
|
|
78
|
+
address(suckerRegistry), abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector), abi.encode(false)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Deploy publisher.
|
|
82
|
+
publisher = new CTPublisher(IJBDirectory(makeAddr("directory")), permissions, feeProjectId, address(0));
|
|
83
|
+
|
|
84
|
+
// Deploy the CTDeployer.
|
|
85
|
+
ctDeployer = new CTDeployer(
|
|
86
|
+
permissions, projects, hookDeployer, ICTPublisher(address(publisher)), suckerRegistry, address(0)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Fund test accounts.
|
|
90
|
+
vm.deal(owner, 100 ether);
|
|
91
|
+
vm.deal(newOwner, 100 ether);
|
|
92
|
+
vm.deal(unauthorized, 100 ether);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//*********************************************************************//
|
|
96
|
+
// --- Full Lifecycle: Deploy -> Claim -> Verify ---------------------- //
|
|
97
|
+
//*********************************************************************//
|
|
98
|
+
|
|
99
|
+
/// @notice Tests the full lifecycle: deploy project, then claim collection ownership.
|
|
100
|
+
/// After claiming, the hook's owner should become the project (via transferOwnershipToProject).
|
|
101
|
+
function test_fullLifecycle_deploy_claim_ownershipTransfers() public {
|
|
102
|
+
// Step 1: Deploy the project.
|
|
103
|
+
_mockDeployProjectInfra();
|
|
104
|
+
|
|
105
|
+
CTProjectConfig memory config = _defaultProjectConfig();
|
|
106
|
+
CTSuckerDeploymentConfig memory suckerConfig = _emptySuckerConfig();
|
|
107
|
+
|
|
108
|
+
(uint256 projectId, IJB721TiersHook hook) = ctDeployer.deployProjectFor(owner, config, suckerConfig, controller);
|
|
109
|
+
assertEq(projectId, deployedProjectId, "project ID should match");
|
|
110
|
+
|
|
111
|
+
// Step 2: After deployment, the CTDeployer is the hook's owner.
|
|
112
|
+
// (The deployer owns the hook because it deployed it.)
|
|
113
|
+
// Mock hook.PROJECT_ID() to return deployedProjectId.
|
|
114
|
+
vm.mockCall(address(hook), abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(projectId));
|
|
115
|
+
|
|
116
|
+
// Mock PROJECTS.ownerOf(projectId) to return owner.
|
|
117
|
+
vm.mockCall(address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, projectId), abi.encode(owner));
|
|
118
|
+
|
|
119
|
+
// Mock JBOwnable.transferOwnershipToProject to succeed.
|
|
120
|
+
vm.mockCall(address(hook), abi.encodeWithSelector(IJBOwnable.transferOwnershipToProject.selector), abi.encode());
|
|
121
|
+
|
|
122
|
+
// Step 3: Owner claims collection ownership.
|
|
123
|
+
vm.prank(owner);
|
|
124
|
+
ctDeployer.claimCollectionOwnershipOf(hook);
|
|
125
|
+
|
|
126
|
+
// Step 4: After claiming, the hook ownership has been transferred to the project.
|
|
127
|
+
// Verify transferOwnershipToProject was called with the correct projectId.
|
|
128
|
+
// (If it wasn't, the mock would not match and the call would have failed.)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// @notice After claiming ownership, the hook's owner is resolved via PROJECTS.ownerOf(projectId).
|
|
132
|
+
/// If the project NFT is transferred to a new owner, the new owner becomes the hook's owner.
|
|
133
|
+
function test_postClaim_projectTransfer_newOwnerControlsHook() public {
|
|
134
|
+
// Mock hook.PROJECT_ID().
|
|
135
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(deployedProjectId));
|
|
136
|
+
|
|
137
|
+
// Initially, 'owner' owns the project.
|
|
138
|
+
vm.mockCall(
|
|
139
|
+
address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, deployedProjectId), abi.encode(owner)
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Mock transferOwnershipToProject.
|
|
143
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.transferOwnershipToProject.selector), abi.encode());
|
|
144
|
+
|
|
145
|
+
// Owner claims collection ownership.
|
|
146
|
+
vm.prank(owner);
|
|
147
|
+
ctDeployer.claimCollectionOwnershipOf(IJB721TiersHook(hookAddr));
|
|
148
|
+
|
|
149
|
+
// Now simulate the project NFT being transferred to newOwner.
|
|
150
|
+
vm.mockCall(
|
|
151
|
+
address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, deployedProjectId), abi.encode(newOwner)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// After the hook is owned by the project, the JBOwnable.owner() resolves to PROJECTS.ownerOf(projectId).
|
|
155
|
+
// Verify that if newOwner holds the project NFT, they are the effective owner.
|
|
156
|
+
// Mock hook.owner() to return newOwner (simulating JBOwnable resolution).
|
|
157
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(newOwner));
|
|
158
|
+
|
|
159
|
+
// Verify: the old owner can no longer claim (since they don't own the project NFT anymore).
|
|
160
|
+
vm.prank(owner);
|
|
161
|
+
vm.expectRevert(
|
|
162
|
+
abi.encodeWithSelector(CTDeployer.CTDeployer_NotOwnerOfProject.selector, deployedProjectId, hookAddr, owner)
|
|
163
|
+
);
|
|
164
|
+
ctDeployer.claimCollectionOwnershipOf(IJB721TiersHook(hookAddr));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// @notice After claimCollectionOwnership, the deployer's permissions from address(this) are
|
|
168
|
+
/// no longer relevant. The project owner must grant CTPublisher the ADJUST_721_TIERS
|
|
169
|
+
/// permission for mintFrom() to work. Without it, posts would revert.
|
|
170
|
+
///
|
|
171
|
+
/// This test verifies the permission gap: after claim, the hook checks permissions
|
|
172
|
+
/// against the project (not the deployer), so the publisher needs new permissions.
|
|
173
|
+
function test_postClaim_publisherNeedsNewPermissions() public {
|
|
174
|
+
// After claiming, hook.owner() resolves to PROJECTS.ownerOf(projectId) = owner.
|
|
175
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(owner));
|
|
176
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(deployedProjectId));
|
|
177
|
+
|
|
178
|
+
// The publisher's configurePostingCriteriaFor calls _requirePermissionFrom(hook.owner(), ...).
|
|
179
|
+
// After claiming, hook.owner() is the project owner, not the deployer.
|
|
180
|
+
// So the project owner must grant CTPublisher the ADJUST_721_TIERS permission.
|
|
181
|
+
|
|
182
|
+
// Mock permissions: simulate that the project owner has NOT yet granted the publisher permission.
|
|
183
|
+
vm.mockCall(
|
|
184
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(false)
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// The publisher's configurePostingCriteriaFor should revert because the publisher
|
|
188
|
+
// doesn't have permission from the new owner.
|
|
189
|
+
CTAllowedPost[] memory allowedPosts = new CTAllowedPost[](1);
|
|
190
|
+
allowedPosts[0] = CTAllowedPost({
|
|
191
|
+
hook: hookAddr,
|
|
192
|
+
category: 1,
|
|
193
|
+
minimumPrice: 0.01 ether,
|
|
194
|
+
minimumTotalSupply: 1,
|
|
195
|
+
maximumTotalSupply: 100,
|
|
196
|
+
maximumSplitPercent: 0,
|
|
197
|
+
allowedAddresses: new address[](0)
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Expect the publisher to revert due to missing permissions.
|
|
201
|
+
vm.expectRevert(
|
|
202
|
+
abi.encodeWithSelector(
|
|
203
|
+
JBPermissioned.JBPermissioned_Unauthorized.selector,
|
|
204
|
+
owner, // account (hook.owner())
|
|
205
|
+
address(this), // caller (us, calling configurePostingCriteriaFor)
|
|
206
|
+
deployedProjectId,
|
|
207
|
+
JBPermissionIds.ADJUST_721_TIERS
|
|
208
|
+
)
|
|
209
|
+
);
|
|
210
|
+
publisher.configurePostingCriteriaFor(allowedPosts);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// @notice After granting the publisher new permissions, posting works again.
|
|
214
|
+
function test_postClaim_publisherWorksAfterPermissionGrant() public {
|
|
215
|
+
// After claiming, hook.owner() = owner.
|
|
216
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(owner));
|
|
217
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(deployedProjectId));
|
|
218
|
+
|
|
219
|
+
// The project owner grants CTPublisher the ADJUST_721_TIERS permission.
|
|
220
|
+
// Simulate: hasPermission returns true for publisher calling from owner's context.
|
|
221
|
+
vm.mockCall(
|
|
222
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
CTAllowedPost[] memory allowedPosts = new CTAllowedPost[](1);
|
|
226
|
+
allowedPosts[0] = CTAllowedPost({
|
|
227
|
+
hook: hookAddr,
|
|
228
|
+
category: 2,
|
|
229
|
+
minimumPrice: 0.05 ether,
|
|
230
|
+
minimumTotalSupply: 1,
|
|
231
|
+
maximumTotalSupply: 50,
|
|
232
|
+
maximumSplitPercent: 0,
|
|
233
|
+
allowedAddresses: new address[](0)
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// This should succeed because the publisher has the correct permission.
|
|
237
|
+
publisher.configurePostingCriteriaFor(allowedPosts);
|
|
238
|
+
|
|
239
|
+
// Verify the allowance was set.
|
|
240
|
+
(uint256 minPrice, uint256 minSupply, uint256 maxSupply,,) = publisher.allowanceFor(hookAddr, 2);
|
|
241
|
+
assertEq(minPrice, 0.05 ether, "minimum price should be configured after re-grant");
|
|
242
|
+
assertEq(minSupply, 1, "minimum supply should be configured");
|
|
243
|
+
assertEq(maxSupply, 50, "maximum supply should be configured");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// @notice Claiming twice for the same hook should succeed (idempotent — just calls
|
|
247
|
+
/// transferOwnershipToProject again, which the hook handles internally).
|
|
248
|
+
function test_claim_calledTwice_succeeds() public {
|
|
249
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(deployedProjectId));
|
|
250
|
+
vm.mockCall(
|
|
251
|
+
address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, deployedProjectId), abi.encode(owner)
|
|
252
|
+
);
|
|
253
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.transferOwnershipToProject.selector), abi.encode());
|
|
254
|
+
|
|
255
|
+
// First claim.
|
|
256
|
+
vm.prank(owner);
|
|
257
|
+
ctDeployer.claimCollectionOwnershipOf(IJB721TiersHook(hookAddr));
|
|
258
|
+
|
|
259
|
+
// Second claim (should not revert with our mocks).
|
|
260
|
+
vm.prank(owner);
|
|
261
|
+
ctDeployer.claimCollectionOwnershipOf(IJB721TiersHook(hookAddr));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/// @notice Non-owner cannot claim even after project ownership changes.
|
|
265
|
+
function test_claim_revertsForNonProjectOwner() public {
|
|
266
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(deployedProjectId));
|
|
267
|
+
vm.mockCall(
|
|
268
|
+
address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, deployedProjectId), abi.encode(owner)
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
vm.prank(unauthorized);
|
|
272
|
+
vm.expectRevert(
|
|
273
|
+
abi.encodeWithSelector(
|
|
274
|
+
CTDeployer.CTDeployer_NotOwnerOfProject.selector, deployedProjectId, hookAddr, unauthorized
|
|
275
|
+
)
|
|
276
|
+
);
|
|
277
|
+
ctDeployer.claimCollectionOwnershipOf(IJB721TiersHook(hookAddr));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
//*********************************************************************//
|
|
281
|
+
// --- Internal Helpers ---------------------------------------------- //
|
|
282
|
+
//*********************************************************************//
|
|
283
|
+
|
|
284
|
+
function _defaultProjectConfig() internal pure returns (CTProjectConfig memory) {
|
|
285
|
+
return CTProjectConfig({
|
|
286
|
+
terminalConfigurations: new JBTerminalConfig[](0),
|
|
287
|
+
projectUri: "https://croptop.test/",
|
|
288
|
+
allowedPosts: new CTDeployerAllowedPost[](0),
|
|
289
|
+
contractUri: "https://croptop.test/contract",
|
|
290
|
+
name: "TestCrop",
|
|
291
|
+
symbol: "TC",
|
|
292
|
+
salt: bytes32(0)
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function _emptySuckerConfig() internal pure returns (CTSuckerDeploymentConfig memory) {
|
|
297
|
+
return CTSuckerDeploymentConfig({deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: bytes32(0)});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function _mockDeployProjectInfra() internal {
|
|
301
|
+
vm.mockCall(address(controller), abi.encodeWithSelector(IJBController.PROJECTS.selector), abi.encode(projects));
|
|
302
|
+
vm.mockCall(address(projects), abi.encodeWithSelector(IJBProjects.count.selector), abi.encode(projectCount));
|
|
303
|
+
vm.mockCall(
|
|
304
|
+
address(hookDeployer),
|
|
305
|
+
abi.encodeWithSelector(IJB721TiersHookDeployer.deployHookFor.selector),
|
|
306
|
+
abi.encode(IJB721TiersHook(hookAddr))
|
|
307
|
+
);
|
|
308
|
+
vm.mockCall(
|
|
309
|
+
address(controller),
|
|
310
|
+
abi.encodeWithSelector(IJBController.launchProjectFor.selector),
|
|
311
|
+
abi.encode(deployedProjectId)
|
|
312
|
+
);
|
|
313
|
+
vm.mockCall(address(projects), abi.encodeWithSelector(IERC721.transferFrom.selector), abi.encode());
|
|
314
|
+
}
|
|
315
|
+
}
|
package/test/Fork.t.sol
CHANGED
|
@@ -27,7 +27,7 @@ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressReg
|
|
|
27
27
|
import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
|
|
28
28
|
import {JBOptimismSuckerDeployer} from "@bananapus/suckers-v6/src/deployers/JBOptimismSuckerDeployer.sol";
|
|
29
29
|
import {JBOptimismSucker} from "@bananapus/suckers-v6/src/JBOptimismSucker.sol";
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
import {IOPMessenger} from "@bananapus/suckers-v6/src/interfaces/IOPMessenger.sol";
|
|
32
32
|
import {IOPStandardBridge} from "@bananapus/suckers-v6/src/interfaces/IOPStandardBridge.sol";
|
|
33
33
|
import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
|
|
@@ -141,8 +141,7 @@ contract ForkTest is Test {
|
|
|
141
141
|
tokens[0] = JBTokenMapping({
|
|
142
142
|
localToken: address(JBConstants.NATIVE_TOKEN),
|
|
143
143
|
minGas: 200_000,
|
|
144
|
-
remoteToken: bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))
|
|
145
|
-
minBridgeAmount: 0.001 ether
|
|
144
|
+
remoteToken: bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))
|
|
146
145
|
});
|
|
147
146
|
|
|
148
147
|
JBSuckerDeployerConfig[] memory deployerConfigurations = new JBSuckerDeployerConfig[](1);
|
|
@@ -214,7 +213,7 @@ contract ForkTest is Test {
|
|
|
214
213
|
|
|
215
214
|
// Deploy and configure the singleton.
|
|
216
215
|
JBOptimismSucker singleton = new JBOptimismSucker(
|
|
217
|
-
opSuckerDeployer, jbDirectory, jbPermissions, jbTokens,
|
|
216
|
+
opSuckerDeployer, jbDirectory, jbPermissions, jbTokens, 1, suckerRegistry, trustedForwarder
|
|
218
217
|
);
|
|
219
218
|
opSuckerDeployer.configureSingleton(singleton);
|
|
220
219
|
|