@bannynet/core-v6 0.0.23 → 0.0.25

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.
Files changed (34) hide show
  1. package/README.md +24 -27
  2. package/foundry.toml +2 -1
  3. package/package.json +22 -12
  4. package/src/Banny721TokenUriResolver.sol +6 -0
  5. package/ADMINISTRATION.md +0 -87
  6. package/ARCHITECTURE.md +0 -101
  7. package/AUDIT_INSTRUCTIONS.md +0 -75
  8. package/RISKS.md +0 -89
  9. package/SKILLS.md +0 -42
  10. package/STYLE_GUIDE.md +0 -610
  11. package/USER_JOURNEYS.md +0 -169
  12. package/foundry.lock +0 -14
  13. package/slither-ci.config.json +0 -10
  14. package/sphinx.lock +0 -521
  15. package/test/Banny721TokenUriResolver.t.sol +0 -694
  16. package/test/BannyAttacks.t.sol +0 -326
  17. package/test/DecorateFlow.t.sol +0 -1091
  18. package/test/Fork.t.sol +0 -2026
  19. package/test/OutfitTransferLifecycle.t.sol +0 -395
  20. package/test/TestAuditGaps.sol +0 -724
  21. package/test/TestQALastMile.t.sol +0 -447
  22. package/test/audit/AntiStrandingRetention.t.sol +0 -422
  23. package/test/audit/BurnedBodyStrandsAssets.t.sol +0 -163
  24. package/test/audit/DuplicateCategoryRetention.t.sol +0 -163
  25. package/test/audit/MergedOutfitExclusivity.t.sol +0 -228
  26. package/test/audit/MigrationHelperVerificationBypass.t.sol +0 -102
  27. package/test/audit/TryTransferFromStrandsAssets.t.sol +0 -197
  28. package/test/regression/ArrayLengthValidation.t.sol +0 -57
  29. package/test/regression/BodyCategoryValidation.t.sol +0 -147
  30. package/test/regression/BurnedTokenCheck.t.sol +0 -186
  31. package/test/regression/CEIReorder.t.sol +0 -209
  32. package/test/regression/ClearMetadata.t.sol +0 -52
  33. package/test/regression/MsgSenderEvents.t.sol +0 -153
  34. package/test/regression/RemovedTierDesync.t.sol +0 -346
@@ -1,163 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- import {Test} from "forge-std/Test.sol";
5
- import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
6
- import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
7
- import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
8
-
9
- import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
10
-
11
- contract DuplicateCategoryMockHook {
12
- mapping(uint256 tokenId => address) public ownerOf;
13
- mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
14
- address public immutable MOCK_STORE;
15
-
16
- constructor(address store) {
17
- MOCK_STORE = store;
18
- }
19
-
20
- function STORE() external view returns (address) {
21
- return MOCK_STORE;
22
- }
23
-
24
- function setOwner(uint256 tokenId, address owner) external {
25
- ownerOf[tokenId] = owner;
26
- }
27
-
28
- function setApprovalForAll(address operator, bool approved) external {
29
- isApprovedForAll[msg.sender][operator] = approved;
30
- }
31
-
32
- function safeTransferFrom(address from, address to, uint256 tokenId) external {
33
- require(
34
- msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
35
- "MockHook: not authorized"
36
- );
37
- ownerOf[tokenId] = to;
38
-
39
- if (to.code.length != 0) {
40
- bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
41
- require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
42
- }
43
- }
44
-
45
- function pricingContext() external pure returns (uint256, uint256, uint256) {
46
- return (1, 18, 0);
47
- }
48
-
49
- function baseURI() external pure returns (string memory) {
50
- return "ipfs://";
51
- }
52
- }
53
-
54
- contract DuplicateCategoryMockStore {
55
- mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
56
-
57
- function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
58
- tiers[hook][tokenId] = tier;
59
- }
60
-
61
- function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
62
- return tiers[hook][tokenId];
63
- }
64
-
65
- // forge-lint: disable-next-line(mixed-case-function)
66
- function encodedTierIPFSUriOf(address, uint256) external pure returns (bytes32) {
67
- return bytes32(0);
68
- }
69
-
70
- // forge-lint: disable-next-line(mixed-case-function)
71
- function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
72
- return bytes32(0);
73
- }
74
- }
75
-
76
- contract ERC721RejectingOwner {
77
- function approveResolver(DuplicateCategoryMockHook hook, address resolver) external {
78
- hook.setApprovalForAll(resolver, true);
79
- }
80
-
81
- function decorate(
82
- Banny721TokenUriResolver resolver,
83
- address hook,
84
- uint256 bodyId,
85
- uint256 backgroundId,
86
- uint256[] memory outfitIds
87
- )
88
- external
89
- {
90
- resolver.decorateBannyWith(hook, bodyId, backgroundId, outfitIds);
91
- }
92
- }
93
-
94
- contract DuplicateCategoryRetentionTest is Test {
95
- Banny721TokenUriResolver resolver;
96
- DuplicateCategoryMockHook hook;
97
- DuplicateCategoryMockStore store;
98
- ERC721RejectingOwner rejector;
99
-
100
- uint256 internal constant BODY_TOKEN = 4_000_000_001;
101
- uint256 internal constant NECKLACE_ONE = 10_000_000_001;
102
- uint256 internal constant NECKLACE_TWO = 11_000_000_001;
103
-
104
- function setUp() public {
105
- store = new DuplicateCategoryMockStore();
106
- hook = new DuplicateCategoryMockHook(address(store));
107
- rejector = new ERC721RejectingOwner();
108
-
109
- resolver = new Banny721TokenUriResolver(
110
- "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", address(this), address(0)
111
- );
112
-
113
- _setupTier(BODY_TOKEN, 4, 0);
114
- _setupTier(NECKLACE_ONE, 10, 3);
115
- _setupTier(NECKLACE_TWO, 11, 3);
116
-
117
- hook.setOwner(BODY_TOKEN, address(rejector));
118
- hook.setOwner(NECKLACE_ONE, address(rejector));
119
- hook.setOwner(NECKLACE_TWO, address(rejector));
120
- rejector.approveResolver(hook, address(resolver));
121
- }
122
-
123
- function test_retainedOutfitCanBypassOnePerCategoryInvariant() public {
124
- uint256[] memory first = new uint256[](1);
125
- first[0] = NECKLACE_ONE;
126
- rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, first);
127
-
128
- uint256[] memory replacement = new uint256[](1);
129
- replacement[0] = NECKLACE_TWO;
130
-
131
- // After the L-1 fix, the duplicate category is detected and the call reverts.
132
- vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_DuplicateCategory.selector);
133
- rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, replacement);
134
- }
135
-
136
- function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
137
- store.setTier(
138
- address(hook),
139
- tokenId,
140
- JB721Tier({
141
- id: tierId,
142
- price: 0.01 ether,
143
- remainingSupply: 100,
144
- initialSupply: 100,
145
- votingUnits: 0,
146
- reserveFrequency: 0,
147
- reserveBeneficiary: address(0),
148
- encodedIPFSUri: bytes32(0),
149
- category: category,
150
- discountPercent: 0,
151
- flags: JB721TierFlags({
152
- allowOwnerMint: false,
153
- transfersPausable: false,
154
- cantBeRemoved: false,
155
- cantIncreaseDiscountPercent: false,
156
- cantBuyWithCredits: false
157
- }),
158
- splitPercent: 0,
159
- resolvedUri: ""
160
- })
161
- );
162
- }
163
- }
@@ -1,228 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- import {Test} from "forge-std/Test.sol";
5
- import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
6
- import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
7
- import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
8
-
9
- import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
10
-
11
- /// @dev Mock hook that supports safeTransferFrom with ERC721Receiver checks.
12
- contract ExclusivityMockHook {
13
- // Maps token IDs to their current owner.
14
- mapping(uint256 tokenId => address) public ownerOf;
15
- // Maps owners to operator approvals.
16
- mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
17
-
18
- // The mock store address, returned by STORE().
19
- address public immutable MOCK_STORE;
20
-
21
- constructor(address store) {
22
- // Store the mock store address at construction time.
23
- MOCK_STORE = store;
24
- }
25
-
26
- /// @dev Returns the mock store address for tier lookups.
27
- function STORE() external view returns (address) {
28
- return MOCK_STORE;
29
- }
30
-
31
- /// @dev Sets the owner of a token ID (test helper).
32
- function setOwner(uint256 tokenId, address owner) external {
33
- ownerOf[tokenId] = owner;
34
- }
35
-
36
- /// @dev Sets operator approval for the caller.
37
- function setApprovalForAll(address operator, bool approved) external {
38
- isApprovedForAll[msg.sender][operator] = approved;
39
- }
40
-
41
- /// @dev Safe transfer that checks ERC721Receiver on contract recipients.
42
- function safeTransferFrom(address from, address to, uint256 tokenId) external {
43
- // Verify the caller is authorized to transfer this token.
44
- require(
45
- msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
46
- "ExclusivityMockHook: not authorized"
47
- );
48
-
49
- // Update ownership to the new owner.
50
- ownerOf[tokenId] = to;
51
-
52
- // If the recipient is a contract, check ERC721Receiver.
53
- if (to.code.length > 0) {
54
- // Call onERC721Received and verify the return selector.
55
- bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
56
- // Revert if the receiver rejects the transfer.
57
- require(retval == IERC721Receiver.onERC721Received.selector, "ExclusivityMockHook: receiver rejected");
58
- }
59
- }
60
-
61
- /// @dev Returns mock pricing context (currency=1, decimals=18, prices=0).
62
- function pricingContext() external pure returns (uint256, uint256, uint256) {
63
- return (1, 18, 0);
64
- }
65
-
66
- /// @dev Returns a mock base URI for metadata.
67
- function baseURI() external pure returns (string memory) {
68
- return "ipfs://";
69
- }
70
- }
71
-
72
- /// @dev Mock store that returns tier data for token IDs.
73
- contract ExclusivityMockStore {
74
- // Maps (hook, tokenId) to tier data.
75
- mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
76
-
77
- /// @dev Sets the tier data for a given (hook, tokenId) pair.
78
- function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
79
- tiers[hook][tokenId] = tier;
80
- }
81
-
82
- /// @dev Returns the tier for a given (hook, tokenId) pair.
83
- function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
84
- return tiers[hook][tokenId];
85
- }
86
-
87
- /// @dev Returns a zero IPFS URI (unused in these tests).
88
- // forge-lint: disable-next-line(mixed-case-function)
89
- function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
90
- return bytes32(0);
91
- }
92
- }
93
-
94
- /// @dev Contract that does NOT implement IERC721Receiver -- transfers to it will revert.
95
- /// Used to simulate a scenario where returning an outfit fails.
96
- contract ERC721Rejector {
97
- /// @dev Approves the resolver as an operator on the hook.
98
- function approveResolver(ExclusivityMockHook hook, address resolver) external {
99
- hook.setApprovalForAll(resolver, true);
100
- }
101
-
102
- /// @dev Calls decorateBannyWith on behalf of this contract.
103
- function decorate(
104
- Banny721TokenUriResolver resolver,
105
- address hook,
106
- uint256 bannyBodyId,
107
- uint256 backgroundId,
108
- uint256[] memory outfitIds
109
- )
110
- external
111
- {
112
- resolver.decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds);
113
- }
114
- }
115
-
116
- /// @title MergedOutfitExclusivityTest
117
- /// @notice Tests that category exclusivity is enforced on the merged set (retained + new outfits),
118
- /// not just the new outfit set alone.
119
- contract MergedOutfitExclusivityTest is Test {
120
- // The resolver under test.
121
- Banny721TokenUriResolver resolver;
122
- // Mock hook for NFT ownership tracking.
123
- ExclusivityMockHook hook;
124
- // Mock store for tier/category lookups.
125
- ExclusivityMockStore store;
126
- // Contract that rejects ERC721 transfers (no IERC721Receiver).
127
- ERC721Rejector rejector;
128
-
129
- // Token IDs follow the pattern used in other banny tests:
130
- // body is category 0, tier ID 4 => token 4_000_000_001
131
- uint256 constant BODY_TOKEN = 4_000_000_001;
132
- // HEAD is category 4, tier ID 11 => token 11_000_000_001
133
- uint256 constant HEAD_TOKEN = 11_000_000_001;
134
- // EYES is category 5, tier ID 12 => token 12_000_000_001
135
- uint256 constant EYES_TOKEN = 12_000_000_001;
136
-
137
- function setUp() public {
138
- // Deploy mock store for tier lookups.
139
- store = new ExclusivityMockStore();
140
- // Deploy mock hook with the store.
141
- hook = new ExclusivityMockHook(address(store));
142
- // Deploy the contract that rejects incoming ERC721 transfers.
143
- rejector = new ERC721Rejector();
144
-
145
- // Deploy the resolver with placeholder SVG paths and no trusted forwarder.
146
- resolver = new Banny721TokenUriResolver(
147
- "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", address(this), address(0)
148
- );
149
-
150
- // Set up tier data: body (category 0), HEAD (category 4), EYES (category 5).
151
- _setupTier(BODY_TOKEN, 4, 0);
152
- _setupTier(HEAD_TOKEN, 11, 4);
153
- _setupTier(EYES_TOKEN, 12, 5);
154
- }
155
-
156
- // -----------------------------------------------------------------------
157
- // Test: Merged set exclusivity with retained outfits
158
- // -----------------------------------------------------------------------
159
- // Scenario: equip a HEAD outfit, make its return fail, then try to equip
160
- // an EYES outfit. The merged set (retained HEAD + new EYES) should violate
161
- // HEAD/EYES exclusivity and revert.
162
- function test_mergedSetExclusivity_retainedHeadBlocksNewEyes() public {
163
- // Give the rejector contract ownership of body, HEAD, and EYES tokens.
164
- hook.setOwner(BODY_TOKEN, address(rejector));
165
- hook.setOwner(HEAD_TOKEN, address(rejector));
166
- hook.setOwner(EYES_TOKEN, address(rejector));
167
-
168
- // Approve the resolver to transfer tokens on behalf of the rejector.
169
- rejector.approveResolver(hook, address(resolver));
170
-
171
- // Step 1: Equip the HEAD outfit on the banny body.
172
- uint256[] memory headOutfit = new uint256[](1);
173
- headOutfit[0] = HEAD_TOKEN;
174
- // This transfers HEAD_TOKEN to the resolver's custody.
175
- rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, headOutfit);
176
-
177
- // Verify HEAD is now worn by the banny body.
178
- assertEq(resolver.wearerOf(address(hook), HEAD_TOKEN), BODY_TOKEN, "HEAD should be worn by body");
179
- // Verify HEAD is in the resolver's custody.
180
- assertEq(hook.ownerOf(HEAD_TOKEN), address(resolver), "HEAD should be in resolver custody");
181
-
182
- // Step 2: Try to replace HEAD with EYES.
183
- // The resolver will try to return HEAD to the rejector, but the rejector
184
- // does not implement IERC721Receiver, so the transfer fails silently.
185
- // HEAD is retained in the merged set. The new set contains only EYES.
186
- // The merged set = [EYES, HEAD] which violates HEAD/EYES exclusivity.
187
- uint256[] memory eyesOutfit = new uint256[](1);
188
- eyesOutfit[0] = EYES_TOKEN;
189
-
190
- // Expect revert because the merged set has both HEAD (retained) and EYES (new).
191
- vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_HeadAlreadyAdded.selector);
192
- // Attempt to decorate with EYES -- should revert due to exclusivity.
193
- rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, eyesOutfit);
194
- }
195
-
196
- // -----------------------------------------------------------------------
197
- // Helpers
198
- // -----------------------------------------------------------------------
199
-
200
- /// @dev Sets up a tier in the mock store for a given token.
201
- function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
202
- // Create tier data with the specified ID and category.
203
- JB721Tier memory tier = JB721Tier({
204
- id: tierId,
205
- price: 0.01 ether,
206
- remainingSupply: 100,
207
- initialSupply: 100,
208
- votingUnits: 0,
209
- reserveFrequency: 0,
210
- reserveBeneficiary: address(0),
211
- encodedIPFSUri: bytes32(0),
212
- category: category,
213
- discountPercent: 0,
214
- flags: JB721TierFlags({
215
- allowOwnerMint: false,
216
- transfersPausable: false,
217
- cantBeRemoved: false,
218
- cantIncreaseDiscountPercent: false,
219
- cantBuyWithCredits: false
220
- }),
221
- splitPercent: 0,
222
- resolvedUri: ""
223
- });
224
-
225
- // Store the tier data in the mock store.
226
- store.setTier(address(hook), tokenId, tier);
227
- }
228
- }
@@ -1,102 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- import {Test} from "forge-std/Test.sol";
5
-
6
- import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
7
-
8
- import {MigrationHelper} from "../../script/helpers/MigrationHelper.sol";
9
-
10
- contract MigrationHelperVerificationBypassTest is Test {
11
- address internal constant ALICE = address(0xA11CE);
12
- address internal constant FALLBACK_RESOLVER = address(0xFA11BAC);
13
-
14
- MockStore internal v4Store;
15
- MockStore internal v5Store;
16
- MockHook internal v4Hook;
17
- MockHook internal v5Hook;
18
- MigrationHelperHarness internal harness;
19
-
20
- function setUp() public {
21
- v4Store = new MockStore();
22
- v5Store = new MockStore();
23
- v4Hook = new MockHook(address(v4Store));
24
- v5Hook = new MockHook(address(v5Store));
25
- harness = new MigrationHelperHarness();
26
- }
27
-
28
- function test_verifyTierBalances_skipsAllOwnersForTierWhenFallbackResolverOwnsAnyOfTier() public {
29
- address[] memory owners = new address[](1);
30
- owners[0] = ALICE;
31
-
32
- uint256[] memory tierIds = new uint256[](1);
33
- tierIds[0] = 7;
34
-
35
- // Alice is over-allocated in V5 versus V4 for tier 7.
36
- v4Store.setTierBalance(address(v4Hook), ALICE, 7, 1);
37
- v5Store.setTierBalance(address(v5Hook), ALICE, 7, 2);
38
-
39
- // One unrelated V4 token of the same tier sits in the fallback resolver.
40
- v4Store.setTierBalance(address(v4Hook), FALLBACK_RESOLVER, 7, 1);
41
-
42
- // Intended behavior would reject Alice's inflation, but the helper skips the tier entirely.
43
- harness.verifyTierBalances(address(v5Hook), address(v4Hook), FALLBACK_RESOLVER, owners, tierIds);
44
- }
45
-
46
- function test_verifyTierBalances_revertsWhenFallbackResolverDoesNotOwnTier() public {
47
- address[] memory owners = new address[](1);
48
- owners[0] = ALICE;
49
-
50
- uint256[] memory tierIds = new uint256[](1);
51
- tierIds[0] = 7;
52
-
53
- v4Store.setTierBalance(address(v4Hook), ALICE, 7, 1);
54
- v5Store.setTierBalance(address(v5Hook), ALICE, 7, 2);
55
-
56
- vm.expectRevert(
57
- bytes(
58
- "V5 tier balance exceeds V4: owner=0x00000000000000000000000000000000000a11ce tier=7 v4Balance=1 v5Balance=2"
59
- )
60
- );
61
- harness.verifyTierBalances(address(v5Hook), address(v4Hook), FALLBACK_RESOLVER, owners, tierIds);
62
- }
63
- }
64
-
65
- contract MigrationHelperHarness {
66
- function verifyTierBalances(
67
- address hookAddress,
68
- address v4HookAddress,
69
- address v4FallbackResolverAddress,
70
- address[] memory owners,
71
- uint256[] memory tierIds
72
- )
73
- external
74
- view
75
- {
76
- MigrationHelper.verifyTierBalances(hookAddress, v4HookAddress, v4FallbackResolverAddress, owners, tierIds);
77
- }
78
- }
79
-
80
- contract MockHook {
81
- address internal immutable _STORE;
82
-
83
- constructor(address store) {
84
- _STORE = store;
85
- }
86
-
87
- function STORE() external view returns (IJB721TiersHookStore) {
88
- return IJB721TiersHookStore(_STORE);
89
- }
90
- }
91
-
92
- contract MockStore {
93
- mapping(address hook => mapping(address owner => mapping(uint256 tierId => uint256))) internal _tierBalanceOf;
94
-
95
- function setTierBalance(address hook, address owner, uint256 tierId, uint256 balance) external {
96
- _tierBalanceOf[hook][owner][tierId] = balance;
97
- }
98
-
99
- function tierBalanceOf(address hook, address owner, uint256 tierId) external view returns (uint256) {
100
- return _tierBalanceOf[hook][owner][tierId];
101
- }
102
- }
@@ -1,197 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- import {Test} from "forge-std/Test.sol";
5
- import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
6
- import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
7
- import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
8
-
9
- import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
10
-
11
- contract StrandMockHook {
12
- mapping(uint256 tokenId => address) public ownerOf;
13
- mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
14
-
15
- address public immutable MOCK_STORE;
16
-
17
- constructor(address store) {
18
- MOCK_STORE = store;
19
- }
20
-
21
- function STORE() external view returns (address) {
22
- return MOCK_STORE;
23
- }
24
-
25
- function setOwner(uint256 tokenId, address owner) external {
26
- ownerOf[tokenId] = owner;
27
- }
28
-
29
- function setApprovalForAll(address operator, bool approved) external {
30
- isApprovedForAll[msg.sender][operator] = approved;
31
- }
32
-
33
- function safeTransferFrom(address from, address to, uint256 tokenId) external {
34
- require(
35
- msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
36
- "StrandMockHook: not authorized"
37
- );
38
-
39
- ownerOf[tokenId] = to;
40
-
41
- if (to.code.length > 0) {
42
- bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
43
- require(retval == IERC721Receiver.onERC721Received.selector, "StrandMockHook: receiver rejected");
44
- }
45
- }
46
-
47
- function pricingContext() external pure returns (uint256, uint256, uint256) {
48
- return (1, 18, 0);
49
- }
50
-
51
- function baseURI() external pure returns (string memory) {
52
- return "ipfs://";
53
- }
54
- }
55
-
56
- contract StrandMockStore {
57
- mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
58
-
59
- function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
60
- tiers[hook][tokenId] = tier;
61
- }
62
-
63
- function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
64
- return tiers[hook][tokenId];
65
- }
66
-
67
- // forge-lint: disable-next-line(mixed-case-function)
68
- function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
69
- return bytes32(0);
70
- }
71
- }
72
-
73
- contract NonReceiverOwner {
74
- function approveResolver(StrandMockHook hook, address resolver) external {
75
- hook.setApprovalForAll(resolver, true);
76
- }
77
-
78
- function decorate(
79
- Banny721TokenUriResolver resolver,
80
- address hook,
81
- uint256 bannyBodyId,
82
- uint256 backgroundId,
83
- uint256[] memory outfitIds
84
- )
85
- external
86
- {
87
- resolver.decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds);
88
- }
89
- }
90
-
91
- contract TryTransferFromStrandsAssetsTest is Test {
92
- Banny721TokenUriResolver resolver;
93
- StrandMockHook hook;
94
- StrandMockStore store;
95
- NonReceiverOwner ownerContract;
96
-
97
- uint256 constant BODY_TOKEN = 4_000_000_001;
98
- uint256 constant BACKGROUND_TOKEN = 5_000_000_001;
99
- uint256 constant NECKLACE_TOKEN = 10_000_000_001;
100
-
101
- function setUp() public {
102
- store = new StrandMockStore();
103
- hook = new StrandMockHook(address(store));
104
- ownerContract = new NonReceiverOwner();
105
-
106
- resolver = new Banny721TokenUriResolver(
107
- "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", address(this), address(0)
108
- );
109
-
110
- _setupTier(BODY_TOKEN, 4, 0);
111
- _setupTier(BACKGROUND_TOKEN, 5, 1);
112
- _setupTier(NECKLACE_TOKEN, 10, 3);
113
-
114
- hook.setOwner(BODY_TOKEN, address(ownerContract));
115
- hook.setOwner(BACKGROUND_TOKEN, address(ownerContract));
116
- hook.setOwner(NECKLACE_TOKEN, address(ownerContract));
117
-
118
- ownerContract.approveResolver(hook, address(resolver));
119
- }
120
-
121
- function test_antiStranding_assetsRetainedWhenOwnerCannotReceiveERC721() public {
122
- // Equip background and necklace outfit.
123
- uint256[] memory outfits = new uint256[](1);
124
- outfits[0] = NECKLACE_TOKEN;
125
-
126
- ownerContract.decorate(resolver, address(hook), BODY_TOKEN, BACKGROUND_TOKEN, outfits);
127
-
128
- assertEq(hook.ownerOf(BACKGROUND_TOKEN), address(resolver), "background should be in resolver custody");
129
- assertEq(hook.ownerOf(NECKLACE_TOKEN), address(resolver), "outfit should be in resolver custody");
130
-
131
- // Try to undress --transfers back to NonReceiverOwner will fail because it doesn't implement IERC721Receiver.
132
- uint256[] memory empty = new uint256[](0);
133
- ownerContract.decorate(resolver, address(hook), BODY_TOKEN, 0, empty);
134
-
135
- // Both NFTs remain in resolver custody (transfer failed silently).
136
- assertEq(hook.ownerOf(BACKGROUND_TOKEN), address(resolver), "background remains in resolver custody");
137
- assertEq(hook.ownerOf(NECKLACE_TOKEN), address(resolver), "outfit remains in resolver custody");
138
-
139
- // KEY CHANGE: State is now PRESERVED --tracking is NOT cleared on failed transfers.
140
- // Background remains tracked because _decorateBannyWithBackground aborts the removal on failed transfer.
141
- assertEq(
142
- resolver.userOf(address(hook), BACKGROUND_TOKEN),
143
- BODY_TOKEN,
144
- "background tracking preserved -- still attached to body"
145
- );
146
-
147
- // Outfit is retained in the attached list because _storeOutfitsWithRetained merges failed transfers.
148
- assertEq(
149
- resolver.wearerOf(address(hook), NECKLACE_TOKEN),
150
- BODY_TOKEN,
151
- "outfit tracking preserved --still worn by body"
152
- );
153
-
154
- // assetIdsOf reflects the retained state.
155
- (uint256 backgroundId, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
156
- assertEq(backgroundId, BACKGROUND_TOKEN, "body still exposes the background");
157
- assertEq(currentOutfits.length, 1, "body still exposes the outfit");
158
- assertEq(currentOutfits[0], NECKLACE_TOKEN, "retained outfit is the necklace");
159
-
160
- // The owner CAN still re-decorate because the assets are still tracked.
161
- // Re-equipping the same outfit (no-op transfer) works fine.
162
- outfits[0] = NECKLACE_TOKEN;
163
- ownerContract.decorate(resolver, address(hook), BODY_TOKEN, BACKGROUND_TOKEN, outfits);
164
-
165
- // State remains consistent.
166
- (backgroundId, currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
167
- assertEq(backgroundId, BACKGROUND_TOKEN, "background re-equipped");
168
- assertEq(currentOutfits.length, 1, "outfit still attached");
169
- assertEq(currentOutfits[0], NECKLACE_TOKEN, "outfit is still the necklace");
170
- }
171
-
172
- function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
173
- JB721Tier memory tier = JB721Tier({
174
- id: tierId,
175
- price: 0.01 ether,
176
- remainingSupply: 100,
177
- initialSupply: 100,
178
- votingUnits: 0,
179
- reserveFrequency: 0,
180
- reserveBeneficiary: address(0),
181
- encodedIPFSUri: bytes32(0),
182
- category: category,
183
- discountPercent: 0,
184
- flags: JB721TierFlags({
185
- allowOwnerMint: false,
186
- transfersPausable: false,
187
- cantBeRemoved: false,
188
- cantIncreaseDiscountPercent: false,
189
- cantBuyWithCredits: false
190
- }),
191
- splitPercent: 0,
192
- resolvedUri: ""
193
- });
194
-
195
- store.setTier(address(hook), tokenId, tier);
196
- }
197
- }