@bannynet/core-v6 0.0.14 → 0.0.15

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/ARCHITECTURE.md CHANGED
@@ -96,3 +96,5 @@ For IPFS-backed assets without on-chain SVG content, the resolver falls back to
96
96
  **Fixed category ordering for outfit layering.** Outfits must be passed in ascending category order (2-17) and only one outfit per category is allowed. This constraint eliminates ambiguity in SVG z-ordering -- the category number directly determines the layer position. It also enables the resolver to insert default accessories (necklace, eyes, mouth) at the correct z-position when no custom one is equipped and no full-head item occludes them.
97
97
 
98
98
  **Equipped assets travel with the body.** When a body NFT is transferred, all equipped outfits and backgrounds remain attached. The new owner inherits them and can unequip to receive the outfit NFTs. This was chosen over auto-unequip to preserve the dressed Banny as a complete visual unit, but it means sellers should unequip valuable outfits before listing.
99
+
100
+ **Outfits burn with the body.** When a body NFT is burned, equipped outfits and backgrounds held by the resolver become permanently unrecoverable. There is no recovery function — outfits share the body's fate. Users must unequip outfits before burning the body if they want to keep them.
package/RISKS.md CHANGED
@@ -63,6 +63,10 @@ For permanently unrecoverable assets (burned NFTs, removed tiers), the retained
63
63
 
64
64
  `tokenUriOf` constructs full SVGs on-chain with string concatenation. Measured gas ceiling: ~609K gas for the worst case (9 non-conflicting outfits + background with on-chain SVG content), well within typical RPC node limits (30M+). Regression test: `test_tokenUri_gasSnapshot_9outfits` in `test/TestQALastMile.t.sol`.
65
65
 
66
- ### 7.4 Reentrancy in non-guarded functions is harmless
66
+ ### 7.4 Outfits burn alongside the body
67
+
68
+ When a banny body NFT is burned (e.g. via cash-out), any equipped outfits and backgrounds held by the resolver are permanently unrecoverable. The resolver has no recovery function and this is intentional — outfits are part of the body's identity and share its fate. Users who want to preserve outfits must unequip them before burning the body.
69
+
70
+ ### 7.5 Reentrancy in non-guarded functions is harmless
67
71
 
68
72
  `lockOutfitChangesFor` and all view functions (`tokenUriOf`, `svgOf`) are not protected by `nonReentrant`. A malicious hook's `STORE().tierOfTokenId()` could re-enter `lockOutfitChangesFor` during a `tokenUriOf` call, but this is harmless -- `lockOutfitChangesFor` only extends the lock timestamp (monotonically non-decreasing) and has no state that could be corrupted by reentrancy. The view functions themselves are read-only at the contract level (no storage writes), so reentrancy through them cannot extract value.
@@ -0,0 +1,34 @@
1
+ # 🔐 Security Review — banny-retail-v6
2
+
3
+ ---
4
+
5
+ ## Scope
6
+
7
+ | | |
8
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
9
+ | **Mode** | ALL / default |
10
+ | **Files reviewed** | `Add.Denver.s.sol` · `Deploy.s.sol` · `Drop1.s.sol`<br>`BannyverseDeploymentLib.sol` · `MigrationHelper.sol` · `Banny721TokenUriResolver.sol` |
11
+ | **Confidence threshold (1-100)** | 75 |
12
+
13
+ ---
14
+
15
+ ## Findings
16
+
17
+ _No confirmed findings._
18
+
19
+ ---
20
+
21
+ Findings List
22
+
23
+ | # | Confidence | Title |
24
+ |---|---|---|
25
+
26
+ ---
27
+
28
+ ## Leads
29
+
30
+ _None._
31
+
32
+ ---
33
+
34
+ > ⚠️ This review was performed by an AI assistant. AI analysis can never verify the complete absence of vulnerabilities and no guarantee of security is given. Team security reviews, bug bounty programs, and on-chain monitoring are strongly recommended. For a consultation regarding your projects' security, visit [https://www.pashov.com](https://www.pashov.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bannynet/core-v6",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  "@bananapus/suckers-v6": "^0.0.18",
28
28
  "@croptop/core-v6": "^0.0.23",
29
29
  "@openzeppelin/contracts": "^5.6.1",
30
- "@rev-net/core-v6": "^0.0.18",
30
+ "@rev-net/core-v6": "^0.0.21",
31
31
  "keccak": "^3.0.4"
32
32
  },
33
33
  "devDependencies": {
@@ -44,7 +44,7 @@ contract Drop1Script is Script, Sphinx {
44
44
  );
45
45
 
46
46
  // Get the hook address by using the deployer.
47
- hook = JB721TiersHook(address(revnet.basic_deployer.tiered721HookOf(bannyverse.revnetId)));
47
+ hook = JB721TiersHook(address(revnet.owner.tiered721HookOf(bannyverse.revnetId)));
48
48
  deploy();
49
49
  }
50
50
 
@@ -44,7 +44,7 @@ contract Drop1Script is Script, Sphinx {
44
44
  );
45
45
 
46
46
  // Get the hook address by using the deployer.
47
- hook = JB721TiersHook(address(revnet.basic_deployer.tiered721HookOf(bannyverse.revnetId)));
47
+ hook = JB721TiersHook(address(revnet.owner.tiered721HookOf(bannyverse.revnetId)));
48
48
  deploy();
49
49
  }
50
50
 
@@ -0,0 +1,158 @@
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 {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
7
+
8
+ import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
9
+
10
+ contract MockBurnableHook is Test {
11
+ mapping(uint256 => address) public owners;
12
+ mapping(address => mapping(address => bool)) public isApprovedForAll;
13
+ address public immutable MOCK_STORE;
14
+
15
+ constructor(address store) {
16
+ MOCK_STORE = store;
17
+ }
18
+
19
+ function STORE() external view returns (address) {
20
+ return MOCK_STORE;
21
+ }
22
+
23
+ function setOwner(uint256 tokenId, address owner) external {
24
+ owners[tokenId] = owner;
25
+ }
26
+
27
+ function ownerOf(uint256 tokenId) external view returns (address) {
28
+ address owner = owners[tokenId];
29
+ require(owner != address(0), "ERC721: token does not exist");
30
+ return owner;
31
+ }
32
+
33
+ function burn(uint256 tokenId) external {
34
+ owners[tokenId] = address(0);
35
+ }
36
+
37
+ function setApprovalForAll(address operator, bool approved) external {
38
+ isApprovedForAll[msg.sender][operator] = approved;
39
+ }
40
+
41
+ function safeTransferFrom(address from, address to, uint256 tokenId) external {
42
+ address owner = owners[tokenId];
43
+ require(owner != address(0), "ERC721: token does not exist");
44
+ require(
45
+ msg.sender == owner || msg.sender == from || isApprovedForAll[from][msg.sender], "MockHook: not authorized"
46
+ );
47
+ owners[tokenId] = to;
48
+ if (to.code.length > 0) {
49
+ bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
50
+ require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
51
+ }
52
+ }
53
+
54
+ function pricingContext() external pure returns (uint256, uint256) {
55
+ return (1, 18);
56
+ }
57
+
58
+ function baseURI() external pure returns (string memory) {
59
+ return "ipfs://";
60
+ }
61
+ }
62
+
63
+ contract MockBurnableStore {
64
+ mapping(address => mapping(uint256 => JB721Tier)) public tiers;
65
+
66
+ function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
67
+ tiers[hook][tokenId] = tier;
68
+ }
69
+
70
+ function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
71
+ return tiers[hook][tokenId];
72
+ }
73
+
74
+ function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
75
+ return bytes32(0);
76
+ }
77
+ }
78
+
79
+ contract BurnedBodyStrandsAssetsTest is Test {
80
+ Banny721TokenUriResolver resolver;
81
+ MockBurnableHook hook;
82
+ MockBurnableStore store;
83
+
84
+ address deployer = makeAddr("deployer");
85
+ address alice = makeAddr("alice");
86
+
87
+ uint256 constant BODY1 = 1_000_000_001;
88
+ uint256 constant BODY2 = 1_000_000_002;
89
+ uint256 constant BACKGROUND = 2_000_000_001;
90
+ uint256 constant OUTFIT = 3_000_000_001;
91
+
92
+ function setUp() public {
93
+ store = new MockBurnableStore();
94
+ hook = new MockBurnableHook(address(store));
95
+
96
+ vm.prank(deployer);
97
+ resolver = new Banny721TokenUriResolver(
98
+ "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, address(0)
99
+ );
100
+
101
+ _setupTier(BODY1, 1, 0);
102
+ _setupTier(BODY2, 1, 0);
103
+ _setupTier(BACKGROUND, 2, 1);
104
+ _setupTier(OUTFIT, 3, 2);
105
+
106
+ hook.setOwner(BODY1, alice);
107
+ hook.setOwner(BODY2, alice);
108
+ hook.setOwner(BACKGROUND, alice);
109
+ hook.setOwner(OUTFIT, alice);
110
+
111
+ vm.prank(alice);
112
+ hook.setApprovalForAll(address(resolver), true);
113
+ }
114
+
115
+ function test_burningDressedBodyPermanentlyStrandsAttachedAssets() public {
116
+ uint256[] memory outfitIds = new uint256[](1);
117
+ outfitIds[0] = OUTFIT;
118
+
119
+ vm.prank(alice);
120
+ resolver.decorateBannyWith(address(hook), BODY1, BACKGROUND, outfitIds);
121
+
122
+ assertEq(resolver.userOf(address(hook), BACKGROUND), BODY1);
123
+ assertEq(resolver.wearerOf(address(hook), OUTFIT), BODY1);
124
+
125
+ hook.burn(BODY1);
126
+
127
+ vm.expectRevert(bytes("ERC721: token does not exist"));
128
+ vm.prank(alice);
129
+ resolver.decorateBannyWith(address(hook), BODY2, BACKGROUND, outfitIds);
130
+
131
+ uint256[] memory emptyOutfits = new uint256[](0);
132
+ vm.expectRevert(bytes("ERC721: token does not exist"));
133
+ vm.prank(alice);
134
+ resolver.decorateBannyWith(address(hook), BODY1, 0, emptyOutfits);
135
+ }
136
+
137
+ function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
138
+ JB721Tier memory tier = JB721Tier({
139
+ id: tierId,
140
+ price: 0.01 ether,
141
+ remainingSupply: 100,
142
+ initialSupply: 100,
143
+ votingUnits: 0,
144
+ reserveFrequency: 0,
145
+ reserveBeneficiary: address(0),
146
+ encodedIPFSUri: bytes32(0),
147
+ category: category,
148
+ discountPercent: 0,
149
+ allowOwnerMint: false,
150
+ transfersPausable: false,
151
+ cannotBeRemoved: false,
152
+ cannotIncreaseDiscountPercent: false,
153
+ splitPercent: 0,
154
+ resolvedUri: ""
155
+ });
156
+ store.setTier(address(hook), tokenId, tier);
157
+ }
158
+ }