@bannynet/core-v6 0.0.11 → 0.0.13

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 (42) hide show
  1. package/ADMINISTRATION.md +42 -31
  2. package/ARCHITECTURE.md +41 -3
  3. package/AUDIT_INSTRUCTIONS.md +68 -41
  4. package/CHANGE_LOG.md +28 -7
  5. package/README.md +53 -1
  6. package/RISKS.md +33 -7
  7. package/SKILLS.md +44 -3
  8. package/STYLE_GUIDE.md +2 -2
  9. package/USER_JOURNEYS.md +327 -325
  10. package/foundry.toml +1 -1
  11. package/package.json +8 -8
  12. package/script/Add.Denver.s.sol +1 -1
  13. package/script/Deploy.s.sol +1 -1
  14. package/script/Drop1.s.sol +1 -1
  15. package/script/helpers/BannyverseDeploymentLib.sol +1 -1
  16. package/script/helpers/MigrationHelper.sol +1 -1
  17. package/src/Banny721TokenUriResolver.sol +132 -24
  18. package/test/Banny721TokenUriResolver.t.sol +1 -1
  19. package/test/BannyAttacks.t.sol +1 -1
  20. package/test/DecorateFlow.t.sol +1 -1
  21. package/test/Fork.t.sol +1 -1
  22. package/test/OutfitTransferLifecycle.t.sol +1 -1
  23. package/test/TestAuditGaps.sol +1 -1
  24. package/test/TestQALastMile.t.sol +1 -1
  25. package/test/audit/AntiStrandingRetention.t.sol +392 -0
  26. package/test/audit/MergedOutfitExclusivity.t.sol +223 -0
  27. package/test/audit/TryTransferFromStrandsAssets.t.sol +192 -0
  28. package/test/regression/ArrayLengthValidation.t.sol +1 -1
  29. package/test/regression/BodyCategoryValidation.t.sol +1 -1
  30. package/test/regression/BurnedTokenCheck.t.sol +1 -1
  31. package/test/regression/CEIReorder.t.sol +1 -1
  32. package/test/regression/ClearMetadata.t.sol +1 -1
  33. package/test/regression/MsgSenderEvents.t.sol +1 -1
  34. package/test/regression/RemovedTierDesync.t.sol +1 -1
  35. package/deployments/banny-core-v5/arbitrum/Banny721TokenUriResolver.json +0 -1809
  36. package/deployments/banny-core-v5/arbitrum_sepolia/Banny721TokenUriResolver.json +0 -1795
  37. package/deployments/banny-core-v5/base/Banny721TokenUriResolver.json +0 -1810
  38. package/deployments/banny-core-v5/base_sepolia/Banny721TokenUriResolver.json +0 -1796
  39. package/deployments/banny-core-v5/ethereum/Banny721TokenUriResolver.json +0 -1795
  40. package/deployments/banny-core-v5/optimism/Banny721TokenUriResolver.json +0 -1810
  41. package/deployments/banny-core-v5/optimism_sepolia/Banny721TokenUriResolver.json +0 -1796
  42. package/deployments/banny-core-v5/sepolia/Banny721TokenUriResolver.json +0 -1795
package/ADMINISTRATION.md CHANGED
@@ -6,40 +6,40 @@ Admin privileges and their scope in banny-retail-v6. The contract (`Banny721Toke
6
6
 
7
7
  | Role | How Assigned | Scope |
8
8
  |------|-------------|-------|
9
- | **Contract Owner** | Set via constructor `Ownable(owner)` (line 174). Transferable via OpenZeppelin `transferOwnership()`. | Global: SVG asset management, metadata, product naming |
10
- | **NFT Body Owner** | Whoever holds the banny body token on the JB721TiersHook contract. Checked via `IERC721(hook).ownerOf(bannyBodyId)` (line 605). | Per-token: decoration, locking |
9
+ | **Contract Owner** | Set via constructor `Ownable(owner)`. Transferable via OpenZeppelin `transferOwnership()`. | Global: SVG asset management, metadata, product naming |
10
+ | **NFT Body Owner** | Whoever holds the banny body token on the JB721TiersHook contract. Checked via `IERC721(hook).ownerOf(bannyBodyId)`. | Per-token: decoration, locking |
11
11
  | **NFT Outfit/Background Owner** | Whoever holds the outfit or background token on the hook contract. | Per-token: authorize equipping to a body they own |
12
12
  | **Anyone (Permissionless)** | No restriction. | Upload SVG content (if matching hash exists) |
13
- | **Trusted Forwarder** | Set at construction via `ERC2771Context(trustedForwarder)` (line 175). Immutable after deploy. | Meta-transaction relay: `_msgSender()` resolves to the relayed sender |
13
+ | **Trusted Forwarder** | Set at construction via `ERC2771Context(trustedForwarder)`. Immutable after deploy. | Meta-transaction relay: `_msgSender()` resolves to the relayed sender |
14
14
 
15
15
  ## Privileged Functions
16
16
 
17
17
  ### Banny721TokenUriResolver -- Owner-Only Functions
18
18
 
19
- | Function | Line | Guard | What It Does |
20
- |----------|------|-------|-------------|
21
- | `setMetadata(description, url, baseUri)` | 1054-1068 | `onlyOwner` | Overwrites the token metadata description, external URL, and SVG base URI. All three fields are always written (pass current value to keep, empty string to clear). |
22
- | `setProductNames(upcs, names)` | 1073-1084 | `onlyOwner` | Sets custom display names for products identified by UPC. Names are stored in `_customProductNameOf` mapping. Can overwrite previously set names. |
23
- | `setSvgHashesOf(upcs, svgHashes)` | 1119-1134 | `onlyOwner` | Commits keccak256 hashes for SVG content keyed by UPC. Each hash can only be set once -- reverts with `HashAlreadyStored` if the UPC already has a hash. This is the gating step that controls which SVG content can later be uploaded permissionlessly. |
19
+ | Function | Guard | What It Does |
20
+ |----------|-------|-------------|
21
+ | `setMetadata(description, url, baseUri)` | `onlyOwner` | Overwrites the token metadata description, external URL, and SVG base URI. All three fields are always written (pass current value to keep, empty string to clear). |
22
+ | `setProductNames(upcs, names)` | `onlyOwner` | Sets custom display names for products identified by UPC. Names are stored in `_customProductNameOf` mapping. Can overwrite previously set names. |
23
+ | `setSvgHashesOf(upcs, svgHashes)` | `onlyOwner` | Commits keccak256 hashes for SVG content keyed by UPC. Each hash can only be set once -- reverts with `HashAlreadyStored` if the UPC already has a hash. This is the gating step that controls which SVG content can later be uploaded permissionlessly. |
24
24
 
25
25
  ### Banny721TokenUriResolver -- Permissionless Functions
26
26
 
27
- | Function | Line | Guard | What It Does |
28
- |----------|------|-------|-------------|
29
- | `setSvgContentsOf(upcs, svgContents)` | 1089-1113 | None (anyone) | Stores SVG content for a UPC, but only if: (1) a hash was previously committed by the owner via `setSvgHashesOf`, (2) `keccak256(content) == storedHash`, and (3) content has not already been stored (`ContentsAlreadyStored`). This is the permissionless "lazy upload" mechanism. |
27
+ | Function | Guard | What It Does |
28
+ |----------|-------|-------------|
29
+ | `setSvgContentsOf(upcs, svgContents)` | None (anyone) | Stores SVG content for a UPC, but only if: (1) a hash was previously committed by the owner via `setSvgHashesOf`, (2) `keccak256(content) == storedHash`, and (3) content has not already been stored (`ContentsAlreadyStored`). This is the permissionless "lazy upload" mechanism. |
30
30
 
31
31
  ### Banny721TokenUriResolver -- NFT-Owner Functions
32
32
 
33
- | Function | Line | Guard | What It Does |
34
- |----------|------|-------|-------------|
35
- | `decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds)` | 969-999 | `_checkIfSenderIsOwner` (line 979) + `nonReentrant` | Equips/unequips outfits and background on a banny body. Caller must own the body token. For each outfit/background: caller must own the asset directly, OR own the banny body that currently wears/uses it. Transfers outfit NFTs into the resolver contract (custodial). |
36
- | `lockOutfitChangesFor(hook, bannyBodyId)` | 1005-1019 | `_checkIfSenderIsOwner` (line 1007) | Locks a banny body's outfit for 7 days (`_LOCK_DURATION`). Lock can only be extended, never shortened (`CantAccelerateTheLock`, line 1016). Prevents `decorateBannyWith` during the lock period. |
33
+ | Function | Guard | What It Does |
34
+ |----------|-------|-------------|
35
+ | `decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds)` | `_checkIfSenderIsOwner` + `nonReentrant` | Equips/unequips outfits and background on a banny body. Caller must own the body token. For each outfit/background: caller must own the asset directly, OR own the banny body that currently wears/uses it. Transfers outfit NFTs into the resolver contract (custodial). |
36
+ | `lockOutfitChangesFor(hook, bannyBodyId)` | `_checkIfSenderIsOwner` | Locks a banny body's outfit for 7 days (`_LOCK_DURATION`). Lock can only be extended, never shortened (`CantAccelerateTheLock`). Prevents `decorateBannyWith` during the lock period. |
37
37
 
38
38
  ### Banny721TokenUriResolver -- Restricted Receiver
39
39
 
40
- | Function | Line | Guard | What It Does |
41
- |----------|------|-------|-------------|
42
- | `onERC721Received(operator, from, tokenId, data)` | 1027-1046 | `operator == address(this)` (line 1043) | Only accepts incoming NFT transfers when the resolver itself initiated the transfer. Rejects all direct user transfers to the resolver contract with `UnauthorizedTransfer`. |
40
+ | Function | Guard | What It Does |
41
+ |----------|-------|-------------|
42
+ | `onERC721Received(operator, from, tokenId, data)` | `operator == address(this)` | Only accepts incoming NFT transfers when the resolver itself initiated the transfer. Rejects all direct user transfers to the resolver contract with `UnauthorizedTransfer`. |
43
43
 
44
44
  ## Asset Management
45
45
 
@@ -49,37 +49,48 @@ Admin privileges and their scope in banny-retail-v6. The contract (`Banny721Toke
49
49
  2. **Anyone** can then upload the actual SVG content via `setSvgContentsOf()`, provided the content's keccak256 hash matches the owner-committed hash. This is intentional: the owner sets the commitment, and anyone can fulfill it (useful for gas-efficient lazy uploading).
50
50
 
51
51
  **Immutability of stored content:**
52
- - Once a hash is set for a UPC, it cannot be changed (line 1127: `HashAlreadyStored`).
53
- - Once content is uploaded for a UPC, it cannot be replaced (line 1097: `ContentsAlreadyStored`).
52
+ - Once a hash is set for a UPC, it cannot be changed (`HashAlreadyStored`).
53
+ - Once content is uploaded for a UPC, it cannot be replaced (`ContentsAlreadyStored`).
54
54
  - There is no function to delete or modify stored SVG hashes or content.
55
55
 
56
56
  **Product names:**
57
- - Can be overwritten by the owner at any time via `setProductNames()`. There is no immutability guard on names -- the owner can rename products freely. The built-in names for UPCs 1-4 (Alien, Pink, Orange, Original) are hardcoded in `_productNameOf()` (lines 888-902) and cannot be overridden.
57
+ - Can be overwritten by the owner at any time via `setProductNames()`. There is no immutability guard on names -- the owner can rename products freely. The built-in names for UPCs 1-4 (Alien, Pink, Orange, Original) are hardcoded in `_productNameOf()` and cannot be overridden.
58
58
 
59
59
  **Metadata:**
60
60
  - `svgDescription`, `svgExternalUrl`, and `svgBaseUri` can be changed by the owner at any time via `setMetadata()`. All three are always overwritten in a single call.
61
61
 
62
+ ## Custodial Model
63
+
64
+ When a user equips an outfit or background on a banny body via `decorateBannyWith()`, the outfit/background NFT is transferred from the user's wallet into the `Banny721TokenUriResolver` contract. The resolver holds these NFTs in custody until the user unequips them (by calling `decorateBannyWith()` again with different outfits).
65
+
66
+ **Trust assumptions:**
67
+ - Users must trust that the resolver contract's `decorateBannyWith()` function correctly returns NFTs when outfits are changed. There is no separate `withdraw()` or `rescue()` function.
68
+ - The `onERC721Received` guard (`operator == address(this)`) prevents anyone from sending arbitrary NFTs to the resolver. Only self-initiated transfers during `decorateBannyWith()` are accepted.
69
+ - If the banny body NFT is transferred to a new owner while outfits are equipped, the new body owner can unequip and claim the outfit NFTs (the ownership check in `_checkIfSenderIsOwner` is against the current body owner).
70
+ - The `lockOutfitChangesFor()` function can lock outfits for up to 7 days per call (extendable). During a lock, neither the body owner nor anyone else can change the equipped outfits.
71
+ - There is no admin override to rescue stuck outfit NFTs. If a bug in `decorateBannyWith()` prevents unequipping, the outfits remain locked in the resolver permanently.
72
+
62
73
  ## Immutable Configuration
63
74
 
64
75
  The following are set at construction and cannot be changed:
65
76
 
66
77
  | Property | Set At | Value |
67
78
  |----------|--------|-------|
68
- | `BANNY_BODY` | Constructor (line 177) | Base SVG path for all banny body rendering |
69
- | `DEFAULT_NECKLACE` | Constructor (line 178) | Default necklace SVG injected when no custom necklace equipped |
70
- | `DEFAULT_MOUTH` | Constructor (line 179) | Default mouth SVG injected when no custom mouth equipped |
71
- | `DEFAULT_STANDARD_EYES` | Constructor (line 180) | Default eyes SVG for non-alien bodies |
72
- | `DEFAULT_ALIEN_EYES` | Constructor (line 181) | Default eyes SVG for alien bodies |
73
- | `trustedForwarder` | Constructor (line 175) | ERC-2771 forwarder address for meta-transactions |
74
- | `_LOCK_DURATION` | Constant (line 63) | 7 days -- hardcoded, not configurable |
75
- | Body color fills | Hardcoded in `_fillsFor()` (lines 661-685) | Color palettes for Alien, Pink, Orange, Original body types |
76
- | Category IDs | Constants (lines 65-82) | 18 category slots (0-17), hardcoded |
79
+ | `BANNY_BODY` | Constructor | Base SVG path for all banny body rendering |
80
+ | `DEFAULT_NECKLACE` | Constructor | Default necklace SVG injected when no custom necklace equipped |
81
+ | `DEFAULT_MOUTH` | Constructor | Default mouth SVG injected when no custom mouth equipped |
82
+ | `DEFAULT_STANDARD_EYES` | Constructor | Default eyes SVG for non-alien bodies |
83
+ | `DEFAULT_ALIEN_EYES` | Constructor | Default eyes SVG for alien bodies |
84
+ | `trustedForwarder` | Constructor | ERC-2771 forwarder address for meta-transactions |
85
+ | `_LOCK_DURATION` | Constant | 7 days -- hardcoded, not configurable |
86
+ | Body color fills | Hardcoded in `_fillsFor()` | Color palettes for Alien, Pink, Orange, Original body types |
87
+ | Category IDs | Constants | 18 category slots (0-17), hardcoded |
77
88
 
78
89
  ## Admin Boundaries
79
90
 
80
91
  **What the owner CANNOT do:**
81
92
 
82
- - **Cannot move or steal user NFTs.** The resolver only holds custody of outfit/background NFTs that users voluntarily equip. The `onERC721Received` guard (line 1043) ensures only self-initiated transfers are accepted.
93
+ - **Cannot move or steal user NFTs.** The resolver only holds custody of outfit/background NFTs that users voluntarily equip. The `onERC721Received` guard ensures only self-initiated transfers are accepted.
83
94
  - **Cannot modify stored SVG content.** Once hash + content are committed, they are permanent. No delete or update function exists.
84
95
  - **Cannot modify stored SVG hashes.** Each UPC's hash is write-once.
85
96
  - **Cannot change the lock duration.** The 7-day lock is a compile-time constant.
package/ARCHITECTURE.md CHANGED
@@ -13,6 +13,16 @@ src/
13
13
  └── IBanny721TokenUriResolver.sol — Interface for outfit and asset operations
14
14
  ```
15
15
 
16
+ ## UPC System
17
+
18
+ A UPC (Universal Product Code) is the tier ID from the 721 hook's tier system, used as the primary identifier for every asset type (bodies, backgrounds, outfits). Each tier in the `IJB721TiersHook` has an `id` and a `category` -- the UPC is that `id`.
19
+
20
+ - **Bodies** (category 0) have hardcoded UPCs with built-in color palettes: Alien (1), Pink (2), Orange (3), Original (4).
21
+ - **Outfits and backgrounds** use arbitrary UPCs assigned when their tiers are created in the 721 hook. Their SVG content is stored on-chain via the hash-then-upload flow described below.
22
+ - **Token IDs** encode the UPC: `tokenId = UPC * 1_000_000_000 + mintNumber`. The resolver recovers the tier (and thus UPC and category) via `tierOfTokenId` from the hook's store.
23
+
24
+ All asset storage, outfit attachment, and SVG generation are keyed by UPC.
25
+
16
26
  ## Key Operations
17
27
 
18
28
  ### Asset Storage
@@ -22,7 +32,8 @@ Owner → setSvgHashesOf(upcs, hashes)
22
32
 
23
33
  Anyone → setSvgContentsOf(upcs, contents)
24
34
  → Upload SVG content matching registered hashes
25
- → Content validated against hash before storage
35
+ → Content validated: keccak256(content) must equal stored hash
36
+ → Content is immutable once stored (cannot be overwritten)
26
37
 
27
38
  Owner → setProductNames(upcs, names)
28
39
  → Register product names for UPCs
@@ -33,7 +44,7 @@ Owner → setProductNames(upcs, names)
33
44
  Body Owner → decorateBannyWith(hook, bodyId, backgroundId, outfitIds)
34
45
  → Attach outfit and background NFTs to a body NFT
35
46
  → Outfit/background NFTs transferred to resolver contract
36
- → Previous outfits returned to owner
47
+ → Previous outfits returned to owner (if transfer fails, retained in attached list)
37
48
  → Composite SVG generated from layered components
38
49
 
39
50
  Body Owner → lockOutfitChangesFor(hook, bodyId)
@@ -45,11 +56,26 @@ Body Owner → lockOutfitChangesFor(hook, bodyId)
45
56
  JB721TiersHook → tokenURI(tokenId)
46
57
  → Banny721TokenUriResolver.tokenURI(hook, tokenId)
47
58
  → Look up body tier and any attached outfits
48
- → Compose SVG layers (background body outfits)
59
+ → Compose SVG layers in fixed z-order (back to front):
60
+ 1. Background (if attached)
61
+ 2. Body (with color palette fills from UPC)
62
+ 3. Outfits in category order:
63
+ Backside(2) → Necklace(3)* → Head(4) → Eyes(5)† →
64
+ Glasses(6) → Mouth(7)† → Legs(8) → Suit(9) →
65
+ Suit Bottom(10) → Suit Top(11) → Necklace‡ →
66
+ Headtop(12) → Hand(13) → Special categories(14-17)
67
+ (* = default necklace inserted if no custom equipped)
68
+ († = default inserted if no custom equipped and no full Head)
69
+ (‡ = custom necklace rendered here, above suit layers)
70
+ → Wrap in <svg> container (400x400 viewBox)
49
71
  → Encode as base64 data URI with JSON metadata
50
72
  → Return fully on-chain SVG
51
73
  ```
52
74
 
75
+ Non-body tokens are shown on a grey mannequin Banny (body fills set to `none`, outline to `#808080`).
76
+
77
+ For IPFS-backed assets without on-chain SVG content, the resolver falls back to an `<image href="...">` tag referencing the tier's encoded IPFS URI at 400x400 pixels.
78
+
53
79
  ## Dependencies
54
80
  - `@bananapus/721-hook-v6` — NFT tier system (IJB721TiersHook, IJB721TokenUriResolver)
55
81
  - `@bananapus/core-v6` — Core protocol interfaces
@@ -58,3 +84,15 @@ JB721TiersHook → tokenURI(tokenId)
58
84
  - `@rev-net/core-v6` — Revnet integration
59
85
  - `@openzeppelin/contracts` — Ownable, ERC2771, ReentrancyGuard, Strings
60
86
  - `keccak` — Hashing utilities
87
+
88
+ ## Design Decisions
89
+
90
+ **Hash-then-upload for SVG storage.** The owner registers a keccak256 hash first (`setSvgHashesOf`, owner-only), then anyone can upload the matching content (`setSvgContentsOf`, permissionless). This separates content approval from upload gas costs -- the owner commits to exactly which artwork is valid, and community members or bots can pay the gas to actually store it. Both hash and content are immutable once set, preventing artwork tampering after mint.
91
+
92
+ **7-day outfit lock.** `lockOutfitChangesFor` freezes a body's outfit for 7 days (`_LOCK_DURATION`). This exists so that a dressed Banny can be used as a stable visual identity (e.g. a PFP or on-chain avatar) without risk of the outfit changing underneath external references. The lock can be extended but never shortened -- calling it again resets the timer to 7 days from now, and reverts if the existing lock expires later than the new one would.
93
+
94
+ **UPC as tier ID.** Rather than introducing a separate asset registry, the resolver reuses the 721 hook's tier ID as the universal product code. This means asset identity, pricing, supply caps, and category are all managed by the existing tier system with no additional storage. The resolver is purely a read/compose layer on top of the hook's data.
95
+
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
+
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.
@@ -6,12 +6,12 @@ Target: `Banny721TokenUriResolver` -- a single-contract system that manages on-c
6
6
 
7
7
  | Contract | Lines | Role |
8
8
  |----------|-------|------|
9
- | `Banny721TokenUriResolver` | ~1,404 | Token URI resolver, outfit custody, SVG storage, decoration logic, lock mechanism |
9
+ | `Banny721TokenUriResolver` | ~1,428 | Token URI resolver, outfit custody, SVG storage, decoration logic, lock mechanism |
10
10
  | `IBanny721TokenUriResolver` | ~175 | Interface: events, view functions, mutating functions |
11
11
 
12
12
  Inheritance chain: `Ownable`, `ERC2771Context`, `ReentrancyGuard`, `IJB721TokenUriResolver`, `IBanny721TokenUriResolver`, `IERC721Receiver`.
13
13
 
14
- Compiler: Solidity 0.8.26, Cancun EVM, via-IR optimizer (200 runs).
14
+ Compiler: Solidity 0.8.28, Cancun EVM, via-IR optimizer (200 runs).
15
15
 
16
16
  ## Architecture Overview
17
17
 
@@ -63,15 +63,15 @@ There are 18 categories (0-17), each representing a layer or slot:
63
63
  | 16 | `_SPECIAL_HEAD_CATEGORY` | Special Head | Special outfit slot |
64
64
  | 17 | `_SPECIAL_BODY_CATEGORY` | Special Body | Special outfit slot |
65
65
 
66
- Categories 0 and 1 cannot be used as outfits (enforced at line 1275). Outfits must be categories 2-17.
66
+ Categories 0 and 1 cannot be used as outfits (enforced at line 1299). Outfits must be categories 2-17.
67
67
 
68
68
  ### Conflict Rules
69
69
 
70
70
  Two conflict rules prevent contradictory outfits:
71
- 1. **Head blocks face pieces**: If category 4 (Head) is equipped, categories 5 (Eyes), 6 (Glasses), 7 (Mouth), and 12 (HeadTop) are rejected (line 1289-1294).
72
- 2. **Suit blocks top/bottom**: If category 9 (Suit) is equipped, categories 10 (SuitBottom) and 11 (SuitTop) are rejected (line 1295-1299).
71
+ 1. **Head blocks face pieces**: If category 4 (Head) is equipped, categories 5 (Eyes), 6 (Glasses), 7 (Mouth), and 12 (HeadTop) are rejected (line 1312-1318).
72
+ 2. **Suit blocks top/bottom**: If category 9 (Suit) is equipped, categories 10 (SuitBottom) and 11 (SuitTop) are rejected (line 1319-1323).
73
73
 
74
- Outfits must be passed in **ascending category order** (line 1280-1281). No two outfits can share a category.
74
+ Outfits must be passed in **ascending category order** (line 1304-1305). No two outfits can share a category.
75
75
 
76
76
  ## Outfit Decoration System
77
77
 
@@ -80,13 +80,13 @@ Outfits must be passed in **ascending category order** (line 1280-1281). No two
80
80
  `decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds)` is the single entry point for all decoration changes. There is no separate "undress" function -- undressing is accomplished by calling `decorateBannyWith` with an empty `outfitIds` array and `backgroundId = 0`.
81
81
 
82
82
  The function:
83
- 1. Verifies the caller owns the banny body (line 987).
84
- 2. Verifies the body token is actually category 0 (line 990).
85
- 3. Verifies the body is not locked (line 995).
86
- 4. Delegates background handling to `_decorateBannyWithBackground` (line 1004).
87
- 5. Delegates outfit handling to `_decorateBannyWithOutfits` (line 1007).
83
+ 1. Verifies the caller owns the banny body (line 993).
84
+ 2. Verifies the body token is actually category 0 (line 996).
85
+ 3. Verifies the body is not locked (line 1001).
86
+ 4. Delegates background handling to `_decorateBannyWithBackground` (line 1010).
87
+ 5. Delegates outfit handling to `_decorateBannyWithOutfits` (line 1013).
88
88
 
89
- ### Background Decoration (`_decorateBannyWithBackground`, line 1155)
89
+ ### Background Decoration (`_decorateBannyWithBackground`, line 1173)
90
90
 
91
91
  - If `backgroundId != 0`, the caller must own the background NFT or own the banny body currently using it.
92
92
  - The background must be category 1 (`_BACKGROUND_CATEGORY`).
@@ -94,19 +94,19 @@ The function:
94
94
  - The old background is returned via `_tryTransferFrom` (silent failure on burned tokens).
95
95
  - The new background is transferred into the resolver via `_transferFrom` (reverts on failure).
96
96
 
97
- ### Outfit Decoration (`_decorateBannyWithOutfits`, line 1222)
97
+ ### Outfit Decoration (`_decorateBannyWithOutfits`, line 1243)
98
98
 
99
99
  This is the most complex function. It performs a **merge-style iteration** over two arrays: the new `outfitIds` and the previously equipped `previousOutfitIds`.
100
100
 
101
101
  For each new outfit:
102
- 1. Authorization: caller must own the outfit OR own the banny body currently wearing it (lines 1257-1268).
103
- 2. Category validation: must be 2-17, ascending order, no conflicts (lines 1275-1300).
104
- 3. The inner `while` loop transfers out old outfits up to the current category (lines 1308-1326).
105
- 4. If the outfit is not already worn by this body, state is updated and the outfit is transferred in (lines 1329-1337).
102
+ 1. Authorization: caller must own the outfit OR own the banny body currently wearing it (lines 1278-1293).
103
+ 2. Category validation: must be 2-17, ascending order, no conflicts (lines 1299-1324).
104
+ 3. The inner `while` loop transfers out old outfits up to the current category (lines 1332-1350).
105
+ 4. If the outfit is not already worn by this body, state is updated and the outfit is transferred in (lines 1353-1361).
106
106
 
107
- After all new outfits are processed, a second `while` loop (line 1348) transfers out any remaining old outfits.
107
+ After all new outfits are processed, a second `while` loop (line 1372) transfers out any remaining old outfits.
108
108
 
109
- Finally, `_attachedOutfitIdsOf[hook][bannyBodyId]` is overwritten wholesale with the new array (line 1368).
109
+ Finally, `_attachedOutfitIdsOf[hook][bannyBodyId]` is overwritten wholesale with the new array (line 1392).
110
110
 
111
111
  ## Custody Model
112
112
 
@@ -132,8 +132,8 @@ Every outfit NFT held by the resolver must be recoverable by the current owner o
132
132
  `lockOutfitChangesFor(hook, bannyBodyId)` locks a body for 7 days (`_LOCK_DURATION = 7 days = 604,800 seconds`). While locked, `decorateBannyWith` reverts with `OutfitChangesLocked`.
133
133
 
134
134
  Rules:
135
- - Only the body owner can lock (line 1015).
136
- - Lock can only be extended, never shortened (line 1024). Attempting to set a shorter lock reverts with `CantAccelerateTheLock`.
135
+ - Only the body owner can lock (line 1021).
136
+ - Lock can only be extended, never shortened (line 1030). Attempting to set a shorter lock reverts with `CantAccelerateTheLock`.
137
137
  - Equal-time relocks succeed (the `>` check allows `currentLockedUntil == newLockUntil`).
138
138
  - The lock survives body transfers -- a buyer who receives a locked body cannot change outfits until the lock expires.
139
139
  - The `outfitLockedUntil` mapping is public and readable by marketplaces.
@@ -144,19 +144,19 @@ Rules:
144
144
 
145
145
  ### Hash-Then-Reveal Pattern
146
146
 
147
- 1. Owner calls `setSvgHashesOf(upcs, svgHashes)` to commit `keccak256` hashes (line 1130). Hashes are write-once per UPC.
148
- 2. Anyone calls `setSvgContentsOf(upcs, svgContents)` to reveal content (line 1100). Content must match the stored hash. Content is write-once per UPC.
149
- 3. If content is not yet uploaded, `_svgOf` falls back to IPFS resolution via `JBIpfsDecoder` (line 936-942).
147
+ 1. Owner calls `setSvgHashesOf(upcs, svgHashes)` to commit `keccak256` hashes (line 1138). Hashes are write-once per UPC.
148
+ 2. Anyone calls `setSvgContentsOf(upcs, svgContents)` to reveal content (line 1108). Content must match the stored hash. Content is write-once per UPC.
149
+ 3. If content is not yet uploaded, `_svgOf` falls back to IPFS resolution via `JBIpfsDecoder` (line 936-949).
150
150
 
151
151
  ### SVG Composition
152
152
 
153
- `tokenUriOf` (line 200) builds a complete on-chain data URI:
153
+ `tokenUriOf` (line 203) builds a complete on-chain data URI:
154
154
  1. For non-body tokens: renders the outfit SVG on a grey mannequin banny.
155
155
  2. For body tokens: composes background + body + all outfit layers in category order.
156
156
 
157
157
  The body SVG uses CSS classes (`.b1`-`.b4`, `.a1`-`.a3`) with color fills specific to each body type (Alien=green, Pink=pink, Orange=orange, Original=yellow).
158
158
 
159
- Default accessories (necklace, eyes, mouth) are injected when no custom outfit occupies that slot. The necklace has special layering: it is stored during iteration but rendered after `_SUIT_TOP_CATEGORY` (line 880-884).
159
+ Default accessories (necklace, eyes, mouth) are injected when no custom outfit occupies that slot. The necklace has special layering: it is stored during iteration but rendered after `_SUIT_TOP_CATEGORY` (line 885-890).
160
160
 
161
161
  ### SVG Sanitization
162
162
 
@@ -174,7 +174,7 @@ If a non-zero forwarder is set, that forwarder contract can append arbitrary sen
174
174
 
175
175
  ## `onERC721Received` Gate
176
176
 
177
- The resolver implements `IERC721Receiver.onERC721Received` (line 1038) and rejects all incoming transfers unless `operator == address(this)`. This means:
177
+ The resolver implements `IERC721Receiver.onERC721Received` (line 1044) and rejects all incoming transfers unless `operator == address(this)`. This means:
178
178
  - Only the resolver itself can send NFTs to itself (via its own `_transferFrom` calls).
179
179
  - Users cannot accidentally send NFTs directly to the resolver.
180
180
  - If a user sends an NFT via `transferFrom` (not `safeTransferFrom`), the callback is not triggered and the NFT is silently deposited. This is an inherent ERC-721 limitation.
@@ -183,24 +183,24 @@ The resolver implements `IERC721Receiver.onERC721Received` (line 1038) and rejec
183
183
 
184
184
  ### 1. Outfit Authorization Logic (CRITICAL)
185
185
 
186
- File: `src/Banny721TokenUriResolver.sol`, lines 1254-1268.
186
+ File: `src/Banny721TokenUriResolver.sol`, lines 1278-1293.
187
187
 
188
188
  The authorization check for outfits allows the caller to use an outfit if they either own it directly or own the banny body currently wearing it. A historical bug (L18, now fixed) allowed `ownerOf(0)` bypass when `wearerOf` returned 0 for unworn outfits. Verify the current fix is sound:
189
- - Line 1262: `if (wearerId == 0) revert` -- ensures unworn outfits require direct ownership.
190
- - Line 1266: `ownerOf(wearerId)` -- verifies caller owns the body wearing the outfit.
189
+ - Line 1283: `if (wearerId == 0) revert` -- ensures unworn outfits require direct ownership.
190
+ - Line 1287: `ownerOf(wearerId)` -- verifies caller owns the body wearing the outfit.
191
191
 
192
192
  Look for: any path where an attacker can pass authorization without actually owning the outfit or the body wearing it.
193
193
 
194
194
  ### 2. Merge Iteration in `_decorateBannyWithOutfits` (HIGH)
195
195
 
196
- Lines 1250-1368. The merge between new `outfitIds` and `previousOutfitIds` is complex. The inner `while` loop advances through previous outfits, transferring them out. The second `while` loop (line 1348) handles remaining previous outfits.
196
+ Lines 1271-1392. The merge between new `outfitIds` and `previousOutfitIds` is complex. The inner `while` loop advances through previous outfits, transferring them out. The second `while` loop (line 1372) handles remaining previous outfits.
197
197
 
198
198
  Look for:
199
199
  - Off-by-one errors in the `previousOutfitIndex` counter.
200
200
  - Skipped outfits that should be returned.
201
201
  - Double-transfer of outfits (both in the inner while and the tail while).
202
202
  - Removed-tier outfits (category=0) causing infinite loops or skipped entries.
203
- - The `_isInArray` check at line 1352 preventing outfits in the new set from being transferred out.
203
+ - The `_isInArray` check at line 1376 preventing outfits in the new set from being transferred out.
204
204
 
205
205
  ### 3. Custody Accounting Consistency (HIGH)
206
206
 
@@ -211,11 +211,11 @@ These four mappings must remain consistent. After every `decorateBannyWith` call
211
211
  - The background in `_attachedBackgroundIdOf[hook][bodyId]` should have `_userOf[hook][backgroundId] == bodyId`.
212
212
  - Every outfit/background held by the resolver should be tracked in these mappings.
213
213
 
214
- **Note**: `_attachedOutfitIdsOf` is overwritten wholesale at line 1368, but `_wearerOf` is only set for *new* outfits at line 1331. Outfits that were already worn by this body retain their `_wearerOf` entry from the previous call. Verify this does not cause stale state.
214
+ **Note**: `_attachedOutfitIdsOf` is overwritten wholesale at line 1392, but `_wearerOf` is only set for *new* outfits at line 1355. Outfits that were already worn by this body retain their `_wearerOf` entry from the previous call. Verify this does not cause stale state.
215
215
 
216
216
  ### 4. `_tryTransferFrom` Silent Failures (MEDIUM)
217
217
 
218
- Line 1390-1393. When returning old outfits, transfer failures are silently caught. This is intentional (handles burned tokens) but could mask real bugs.
218
+ Line 1424-1428. When returning old outfits, transfer failures are silently caught. This is intentional (handles burned tokens) but could mask real bugs.
219
219
 
220
220
  Look for: scenarios where `_tryTransferFrom` silently fails but the state mappings (`_wearerOf`, `_attachedOutfitIdsOf`) have already been updated, causing an outfit to be "lost" -- not held by the resolver, not returned to the owner, but still tracked as worn.
221
221
 
@@ -227,13 +227,13 @@ Verify: a malicious hook cannot affect outfits custodied from a different (legit
227
227
 
228
228
  ### 6. CEI Ordering in Background Replacement (MEDIUM)
229
229
 
230
- `_decorateBannyWithBackground` (lines 1192-1203) updates state before transfers. Verify:
231
- - `_attachedBackgroundIdOf` and `_userOf` are updated at lines 1192-1193 before the try-transfer at line 1197 and the incoming transfer at line 1202.
232
- - No reachable state where a reentrancy callback during the safeTransferFrom at line 1202 could observe inconsistent state.
230
+ `_decorateBannyWithBackground` (lines 1212-1224) updates state before transfers. Verify:
231
+ - `_attachedBackgroundIdOf` and `_userOf` are updated at lines 1213-1214 before the try-transfer at line 1218 and the incoming transfer at line 1223.
232
+ - No reachable state where a reentrancy callback during the safeTransferFrom at line 1223 could observe inconsistent state.
233
233
 
234
234
  ### 7. SVG Content Safety (LOW)
235
235
 
236
- SVG content is stored verbatim. While this is a view-function-only concern, verify that the encoding in `_encodeTokenUri` (line 622) cannot produce malformed JSON that breaks parsers. Specifically check for unescaped characters in `svgDescription`, `svgExternalUrl`, and custom product names injected into the JSON.
236
+ SVG content is stored verbatim. While this is a view-function-only concern, verify that the encoding in `_encodeTokenUri` (line 628) cannot produce malformed JSON that breaks parsers. Specifically check for unescaped characters in `svgDescription`, `svgExternalUrl`, and custom product names injected into the JSON.
237
237
 
238
238
  ## Key Invariants to Test
239
239
 
@@ -266,7 +266,7 @@ RPC_ETHEREUM_MAINNET=<your-rpc-url> forge test --match-path test/Fork.t.sol -vvv
266
266
  forge test --gas-report
267
267
  ```
268
268
 
269
- **Test suite**: 11 test files, ~130+ tests.
269
+ **Test suite**: 14 test files, ~230+ tests.
270
270
 
271
271
  | File | Purpose |
272
272
  |------|---------|
@@ -281,10 +281,13 @@ forge test --gas-report
281
281
  | `regression/MsgSenderEvents.t.sol` | ERC-2771 event correctness |
282
282
  | `regression/BurnedTokenCheck.t.sol` | Burned token handling |
283
283
  | `regression/ClearMetadata.t.sol` | Metadata clearing |
284
+ | `OutfitTransferLifecycle.t.sol` | Outfit transfer lifecycle flows |
285
+ | `TestAuditGaps.sol` | Audit gap coverage: ERC-2771 meta-transaction flows (forwarder relay, spoofing prevention, owner-only relay), SVG rendering edge cases (special characters, script tags, unicode, long content, JSON-breaking chars), SVG composition validation (default decorations, alien vs standard eyes, background inclusion/exclusion) |
286
+ | `TestQALastMile.t.sol` | QA last-mile edge cases |
284
287
 
285
288
  **Untested areas** (potential audit additions):
286
- - Meta-transaction flows with a real forwarder (all tests use `address(0)`).
287
- - SVG content containing special characters or potential injection payloads.
289
+ - Meta-transaction flows with a real forwarder (basic coverage exists in TestAuditGaps.sol; advanced scenarios with a real forwarder remain untested -- all tests use `address(0)`).
290
+ - SVG content containing special characters or potential injection payloads (basic coverage exists in TestAuditGaps.sol for script tags, unicode, JSON-breaking chars, and long content; advanced payloads remain untested).
288
291
  - Gas consumption for `tokenUriOf` with maximum outfit count.
289
292
  - Ownership transfer of the resolver (`transferOwnership`) and continued admin access.
290
293
  - Product name overwriting (no write-once protection on `_customProductNameOf`).
@@ -325,3 +328,27 @@ The resolver makes external calls to the `hook` and its `STORE()` but does not c
325
328
  | `UnrecognizedBackground` | Token is not category 1 |
326
329
  | `UnrecognizedCategory` | Outfit category not in 2-17 range |
327
330
  | `UnrecognizedProduct` | Body UPC not 1-4 (Alien/Pink/Orange/Original) |
331
+
332
+ ## How to Report Findings
333
+
334
+ For each finding:
335
+
336
+ 1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
337
+ 2. **Affected contract(s)** -- exact file path and line numbers
338
+ 3. **Description** -- what is wrong, in plain language
339
+ 4. **Trigger sequence** -- step-by-step, minimal steps to reproduce
340
+ 5. **Impact** -- what an attacker gains, what a user loses
341
+ 6. **Proof** -- code trace or Foundry test
342
+ 7. **Fix** -- minimal code change
343
+
344
+ **Severity guide:**
345
+ - **CRITICAL**: Permanent NFT custody loss, unauthorized outfit theft.
346
+ - **HIGH**: Conditional NFT loss, authorization bypass, broken custody invariant.
347
+ - **MEDIUM**: State inconsistency without fund loss, griefing that locks outfits.
348
+ - **LOW**: Cosmetic SVG issues, informational, edge-case-only.
349
+
350
+ ## Previous Audit Findings
351
+
352
+ | ID | Severity | Status | Description |
353
+ |----|----------|--------|-------------|
354
+ | L18 | HIGH | FIXED | `ownerOf(0)` bypass in outfit authorization. When `wearerOf` returned 0 for unworn outfits, `hook.ownerOf(0)` could succeed for some hooks, allowing unauthorized outfit use. Fixed by adding `if (wearerId == 0) revert` guard at line 1283. Regression test in `DecorateFlow.t.sol`. |
package/CHANGE_LOG.md CHANGED
@@ -2,13 +2,21 @@
2
2
 
3
3
  This document describes all changes between `banny-retail` (v5) and `banny-retail-v6` (v6).
4
4
 
5
+ ## Summary
6
+
7
+ - **Key bug fixes**: Default eyes incorrectly selected based on outfit UPC instead of body UPC; decoration operations blocked when a previously-equipped item was burned/removed.
8
+ - **Fault-tolerant transfers**: New `_tryTransferFrom()` wraps returns of previously-equipped items in try-catch — a burned or removed outfit no longer blocks the entire `decorateBannyWith()` operation.
9
+ - **Richer metadata**: `setSvgBaseUri()` replaced by `setMetadata()` which sets description, external URL, and base URI together. Token JSON `description` and `external_url` are now dynamic.
10
+ - **Body category validation**: `decorateBannyWith()` now verifies the `bannyBodyId` actually belongs to a body-category tier before proceeding.
11
+ - **Batch setter safety**: Array length mismatch checks added to `setProductNames()`, `setSvgContentsOf()`, and `setSvgHashesOf()`.
12
+
5
13
  ---
6
14
 
7
15
  ## 1. Breaking Changes
8
16
 
9
17
  ### Solidity Version Bump
10
18
  - **v5:** `pragma solidity 0.8.23;`
11
- - **v6:** `pragma solidity 0.8.26;`
19
+ - **v6:** `pragma solidity 0.8.28;`
12
20
 
13
21
  ### Dependency Imports Updated
14
22
  All `@bananapus/721-hook-v5` imports replaced with `@bananapus/721-hook-v6`:
@@ -46,11 +54,20 @@ All `@bananapus/721-hook-v5` imports replaced with `@bananapus/721-hook-v6`:
46
54
  - **v6 adds:** A check that the `bannyBodyId` actually belongs to a body-category tier (`_BODY_CATEGORY == 0`). If not, reverts with `Banny721TokenUriResolver_BannyBodyNotBodyCategory()`.
47
55
  - **v5:** No such check existed; any token ID could be passed as a banny body.
48
56
 
49
- ### `_tryTransferFrom()` -- Fault-Tolerant Transfers
50
- - **v6 adds:** `_tryTransferFrom(address hook, address from, address to, uint256 assetId)` -- wraps `safeTransferFrom` in a try-catch, silently succeeding if the transfer fails (e.g., if the token was burned or its tier removed).
57
+ ### `_tryTransferFrom()` -- Fault-Tolerant, Return-Aware Transfers
58
+ - **v6 adds:** `_tryTransferFrom(address hook, address from, address to, uint256 assetId) returns (bool success)` -- wraps `safeTransferFrom` in a try-catch and returns whether the transfer succeeded.
51
59
  - Used in `_decorateBannyWithBackground()` and `_decorateBannyWithOutfits()` when returning previously equipped items.
60
+ - When the return transfer fails, **state is preserved** instead of cleared — preventing NFT stranding:
61
+ - **Backgrounds**: Failed return aborts the entire background change (old background stays attached, new one is not equipped).
62
+ - **Outfits**: Failed-to-return outfits are retained in the attached list via `_storeOutfitsWithRetained()`.
52
63
  - **v5:** Used `_transferFrom()` (which reverts on failure) for all transfers, meaning a single burned/removed outfit could block the entire decoration operation.
53
64
 
65
+ > **Why this mattered**: In v5, if a project owner removed a tier that contained an equipped outfit, the Banny body owner could never change decorations again — the `safeTransferFrom` for the removed item would revert, permanently blocking the `decorateBannyWith()` function. This was the most-reported user issue.
66
+
67
+ ### `_storeOutfitsWithRetained()` -- Anti-Stranding Merge
68
+ - **v6 adds:** `_storeOutfitsWithRetained(address hook, uint256 bannyBodyId, uint256[] memory outfitIds, uint256[] memory previousOutfitIds)` -- stores the new outfit array, appending any previously equipped outfits whose return transfer failed (non-zero entries in `previousOutfitIds`).
69
+ - This ensures that NFTs held by the resolver but not successfully returned to the owner remain tracked and recoverable in subsequent `decorateBannyWith` calls.
70
+
54
71
  ### `_isInArray()` Helper
55
72
  - **v6 adds:** `_isInArray(uint256 value, uint256[] memory array)` -- checks if a value is present in an array.
56
73
  - Used during outfit cleanup to skip outfits being re-equipped rather than transferring them out and back in.
@@ -72,6 +89,8 @@ All `@bananapus/721-hook-v5` imports replaced with `@bananapus/721-hook-v6`:
72
89
  - **v5 (bug):** `_outfitContentsFor()` used the current outfit's `upc` to decide alien vs. standard default eyes: `if (upc == ALIEN_UPC)`. This checked the UPC of the *outfit being iterated*, not the banny body.
73
90
  - **v6 (fix):** `_outfitContentsFor()` now accepts an additional `bodyUpc` parameter and uses `if (bodyUpc == ALIEN_UPC)` to correctly select default eyes based on the banny body type.
74
91
 
92
+ > **Why this mattered**: The bug caused alien Bannys to get standard eyes and vice versa when any outfit was equipped, breaking the visual identity of the NFT. The fix ensures default eyes are always selected based on the body type, not whatever outfit happens to be iterated.
93
+
75
94
  ### Improved Background Authorization Logic
76
95
  - **v6:** `_decorateBannyWithBackground()` now explicitly checks if an unused background (where `userId == 0`) can only be attached by its owner. In v5, the authorization check `_msgSender() != owner && _msgSender() != IERC721(hook).ownerOf(userOf(hook, backgroundId))` could behave unexpectedly when `userOf()` returned 0 (querying `ownerOf(0)` on the hook).
77
96
 
@@ -171,7 +190,7 @@ No struct changes. Both versions use `JB721Tier` from the respective `721-hook`
171
190
 
172
191
  ### `_bannyBodySvgOf()` Relocated
173
192
  - **v5:** Located after `_msgSender()` / `_msgData()` overrides (line ~700).
174
- - **v6:** Relocated to immediately after `_categoryNameOf()` (line ~532), grouped with other internal view functions.
193
+ - **v6:** Relocated to immediately before `_categoryNameOf()` (line ~538), grouped with other internal view functions.
175
194
 
176
195
  ### `_contextSuffixLength()` Relocated
177
196
  - **v5:** Located before `_bannyBodySvgOf()` (line ~562).
@@ -194,8 +213,8 @@ No struct changes. Both versions use `JB721Tier` from the respective `721-hook`
194
213
  - **v6 adds:** `// forge-lint: disable-next-line(mixed-case-variable)` above `DEFAULT_ALIEN_EYES`, `DEFAULT_MOUTH`, `DEFAULT_NECKLACE`, `DEFAULT_STANDARD_EYES`, and `BANNY_BODY`.
195
214
 
196
215
  ### Import Order Change
197
- - **v5:** OpenZeppelin imports mixed with `@bananapus` imports.
198
- - **v6:** OpenZeppelin imports first, then `@bananapus` imports, separated by a blank line.
216
+ - **v5:** `@bananapus` imports first, then OpenZeppelin imports.
217
+ - **v6:** OpenZeppelin imports first, then `@bananapus` imports.
199
218
 
200
219
  ### Slither Annotations
201
220
  - **v6 adds:** `// slither-disable-next-line calls-loop` on several `IERC721(hook).ownerOf()` calls inside loops.
@@ -219,4 +238,6 @@ No struct changes. Both versions use `JB721Tier` from the respective `721-hook`
219
238
  | N/A | `_encodeTokenUri()` | Extracted from `tokenUriOf()` |
220
239
  | `_outfitContentsFor(hook, outfitIds)` | `_outfitContentsFor(hook, outfitIds, bodyUpc)` | Added `bodyUpc` param (bug fix) |
221
240
  | `@bananapus/721-hook-v5` | `@bananapus/721-hook-v6` | Dependency upgrade |
222
- | `pragma solidity 0.8.23` | `pragma solidity 0.8.26` | Compiler version bump |
241
+ | `pragma solidity 0.8.23` | `pragma solidity 0.8.28` | Compiler version bump |
242
+
243
+ > **Cross-repo impact**: The `pricingContext()` return change (3 values → 2) is driven by the upstream `nana-721-hook-v6` `IJB721TiersHook` interface change. The `@bananapus/721-hook-v6` dependency brings in the new tier splits system, though Banny Retail does not use tier splits.
package/README.md CHANGED
@@ -8,6 +8,8 @@ On-chain composable avatar system for Juicebox 721 collections -- manages Banny
8
8
 
9
9
  Banny is a composable NFT character system built on top of Juicebox 721 hooks. Each Banny is a body NFT (Alien, Pink, Orange, or Original) that can wear outfit NFTs and sit on a background NFT. The resolver composes these layers into a single SVG image, fully on-chain.
10
10
 
11
+ Outfit changes can be locked for 7 days via `lockOutfitChangesFor`. This proves that a Banny's appearance is stable -- useful for PFPs, social displays, or any context where others rely on a Banny looking the way it does right now. While locked, neither the owner nor anyone else can change or remove the body's outfits or background.
12
+
11
13
  ### How It Works
12
14
 
13
15
  ```
@@ -33,6 +35,41 @@ Banny is a composable NFT character system built on top of Juicebox 721 hooks. E
33
35
  → Falls back to IPFS base URI if on-chain content not yet stored
34
36
  ```
35
37
 
38
+ ### Decoration Flow
39
+
40
+ ```mermaid
41
+ sequenceDiagram
42
+ participant Owner as Body Owner
43
+ participant Resolver as Banny721TokenUriResolver
44
+ participant Hook as 721 Hook (NFT Contract)
45
+
46
+ Owner->>Resolver: decorateBannyWith(hook, bodyId, bgId, outfitIds)
47
+ Resolver->>Hook: ownerOf(bodyId) -- verify caller owns body
48
+ Hook-->>Resolver: owner address
49
+
50
+ Note over Resolver: Check body is not locked
51
+
52
+ alt Background is changing
53
+ Resolver->>Hook: transferFrom(owner, resolver, bgId)
54
+ Note over Resolver: Store bgId as body's background
55
+ opt Previous background exists
56
+ Resolver->>Hook: transferFrom(resolver, owner, prevBgId)
57
+ end
58
+ end
59
+
60
+ loop Each outfit in outfitIds
61
+ Resolver->>Hook: transferFrom(owner, resolver, outfitId)
62
+ Note over Resolver: Track outfitId as worn by body
63
+ opt Previous outfit in same category
64
+ Resolver->>Hook: transferFrom(resolver, owner, prevOutfitId)
65
+ end
66
+ end
67
+
68
+ Note over Resolver: tokenURI(bodyId) now renders composed SVG
69
+ Owner->>Resolver: tokenURI(bodyId)
70
+ Resolver-->>Owner: base64-encoded JSON with layered SVG
71
+ ```
72
+
36
73
  ### Asset Categories
37
74
 
38
75
  | Category ID | Name | Slot Rules |
@@ -139,9 +176,24 @@ script/
139
176
  | `setProductNames` | Contract owner only |
140
177
  | `setMetadata` | Contract owner only |
141
178
 
179
+ ## Supported Chains
180
+
181
+ Deployed via Sphinx deterministic deployment (`script/Deploy.s.sol`).
182
+
183
+ | Network | Chain ID |
184
+ |---------|----------|
185
+ | Ethereum | 1 |
186
+ | Optimism | 10 |
187
+ | Base | 8453 |
188
+ | Arbitrum | 42161 |
189
+ | Ethereum Sepolia | 11155111 |
190
+ | Optimism Sepolia | 11155420 |
191
+ | Base Sepolia | 84532 |
192
+ | Arbitrum Sepolia | 421614 |
193
+
142
194
  ## Risks
143
195
 
144
- - **Outfit custody:** Attached outfits and backgrounds are held by the resolver contract. If the contract has a bug in the return logic, assets could be stuck.
196
+ - **Outfit custody:** Attached outfits and backgrounds are held by the resolver contract. If a return transfer fails (e.g., owner is a non-receiver contract), the asset is retained in the attached list rather than stranded — the owner can retry once the issue is resolved. Permanently unrecoverable assets (burned NFTs) create phantom entries in SVG rendering.
145
197
  - **7-day lock is fixed.** Cannot be shortened or cancelled once set. The lock duration is hardcoded.
146
198
  - **SVG immutability.** Once SVG content is stored on-chain for a UPC, it cannot be changed. A mistake in the content requires deploying a new resolver.
147
199
  - **Hash registration is owner-only, but content upload is not.** Anyone can call `setSvgContentsOf` as long as the content matches the registered hash. This is by design for lazy uploads.