@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/USER_JOURNEYS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # banny-retail-v6 -- User Journeys
2
2
 
3
- Every interaction path through `Banny721TokenUriResolver`, traced from entry point to final state. All function signatures and line references are verified against `src/Banny721TokenUriResolver.sol`.
3
+ All interaction paths through `Banny721TokenUriResolver`, traced from entry point to final state. All function signatures, events, and revert names are verified against `src/Banny721TokenUriResolver.sol` and `src/interfaces/IBanny721TokenUriResolver.sol`.
4
4
 
5
5
  ---
6
6
 
@@ -8,11 +8,9 @@ Every interaction path through `Banny721TokenUriResolver`, traced from entry poi
8
8
 
9
9
  The resolver does not handle minting. Banny bodies are minted through the Juicebox 721 tier system. This journey is included for context because it produces the token that all other journeys depend on.
10
10
 
11
- ### Entry Point
11
+ **Entry point**: Payment to a `JBMultiTerminal` for the project that owns the `JB721TiersHook`. The hook's `afterPayRecordedWith` callback mints a 721 token from the appropriate tier.
12
12
 
13
- Payment to a `JBMultiTerminal` for the project that owns the `JB721TiersHook`. The hook's `afterPayRecordedWith` callback mints a 721 token from the appropriate tier.
14
-
15
- ### What the Resolver Does
13
+ **Who can call**: Anyone (via `JBMultiTerminal.pay`).
16
14
 
17
15
  After minting, when any caller requests the token URI:
18
16
 
@@ -21,98 +19,87 @@ JB721TiersHook.tokenURI(tokenId)
21
19
  -> Banny721TokenUriResolver.tokenUriOf(hook, tokenId)
22
20
  ```
23
21
 
24
- **Function**: `tokenUriOf(address hook, uint256 tokenId) external view returns (string memory)` (line 200)
25
-
26
- ### Parameters
27
-
28
- - `hook`: The `JB721TiersHook` contract address.
29
- - `tokenId`: The minted token ID (e.g., `4_000_000_001` for Original Banny #1).
22
+ **Function**: `tokenUriOf(address hook, uint256 tokenId) external view returns (string memory)`
30
23
 
31
- ### State Read
32
-
33
- 1. `_productOfTokenId(hook, tokenId)` calls `hook.STORE().tierOfTokenId(hook, tokenId, false)`.
34
- 2. If `product.category == 0` (body), calls `svgOf(hook, tokenId, true, true)`.
35
- 3. `svgOf` calls `assetIdsOf(hook, tokenId)` which returns empty arrays (no outfits or background attached).
36
- 4. The body SVG is composed with default accessories (necklace, eyes, mouth) and no custom outfits.
24
+ **Parameters**:
25
+ - `hook` -- The `JB721TiersHook` contract address
26
+ - `tokenId` -- The minted token ID (e.g., `4_000_000_001` for Original Banny #1)
37
27
 
38
- ### Result
28
+ **State read**:
29
+ 1. `_productOfTokenId(hook, tokenId)` calls `hook.STORE().tierOfTokenId(hook, tokenId, false)`
30
+ 2. If `product.category == 0` (body), calls `svgOf(hook, tokenId, true, true)`
31
+ 3. `svgOf` calls `assetIdsOf(hook, tokenId)` which returns empty arrays (no outfits or background attached)
32
+ 4. The body SVG is composed with default accessories (necklace, eyes, mouth) and no custom outfits
39
33
 
40
- A base64-encoded data URI containing JSON metadata with:
41
- - Product name (Alien, Pink, Orange, or Original).
42
- - Category name ("Banny body").
43
- - An SVG image with the naked banny body and default accessories.
44
- - Empty `outfitIds` array, `backgroundId: 0`.
34
+ **Result**: A base64-encoded data URI containing JSON metadata with:
35
+ - Product name (Alien, Pink, Orange, or Original)
36
+ - Category name ("Banny body")
37
+ - An SVG image with the naked banny body and default accessories
38
+ - Empty `outfitIds` array, `backgroundId: 0`
45
39
 
46
- ### Edge Cases
40
+ **Events**: None (view function).
47
41
 
48
- - **Token ID does not exist**: `_productOfTokenId` returns a tier with `id == 0`. Function returns `""` (line 205).
49
- - **SVG content not uploaded**: Falls back to IPFS URI via `JBIpfsDecoder` (line 303).
50
- - **Token from category > 17**: Falls back to hook's `baseURI()` for IPFS resolution (line 300).
42
+ **Edge cases**:
43
+ - **Token ID does not exist**: `_productOfTokenId` returns a tier with `id == 0`. Function returns `""`
44
+ - **SVG content not uploaded**: Falls back to IPFS URI via `JBIpfsDecoder`
45
+ - **Token from category > 17**: Falls back to hook's `baseURI()` for IPFS resolution
46
+ - **Unrecognized product**: Reverts `Banny721TokenUriResolver_UnrecognizedProduct`
51
47
 
52
48
  ---
53
49
 
54
50
  ## Journey 2: Dress a Banny with Outfits
55
51
 
56
- ### Entry Point
57
-
58
- ```solidity
59
- function decorateBannyWith(
60
- address hook,
61
- uint256 bannyBodyId,
62
- uint256 backgroundId,
63
- uint256[] calldata outfitIds
64
- ) external nonReentrant
65
- ```
66
-
67
- Line 977. Protected by `ReentrancyGuard`.
68
-
69
- ### Prerequisites
70
-
71
- - Caller owns the banny body NFT (`IERC721(hook).ownerOf(bannyBodyId) == _msgSender()`).
72
- - Body is not locked (`outfitLockedUntil[hook][bannyBodyId] <= block.timestamp`).
73
- - Caller owns each outfit NFT directly, or owns the banny body currently wearing that outfit.
74
- - Caller owns the background NFT directly (or owns the banny body currently using it), if `backgroundId != 0`.
75
- - Caller has approved the resolver for the hook contract (`hook.setApprovalForAll(resolver, true)`).
52
+ **Entry point**: `Banny721TokenUriResolver.decorateBannyWith(address hook, uint256 bannyBodyId, uint256 backgroundId, uint256[] calldata outfitIds)`
76
53
 
77
- ### Parameters
54
+ **Who can call**: The owner of the banny body NFT (`IERC721(hook).ownerOf(bannyBodyId) == _msgSender()`). Protected by `ReentrancyGuard`. ERC-2771 meta-transactions supported.
78
55
 
79
- - `hook`: The `JB721TiersHook` contract address.
80
- - `bannyBodyId`: Token ID of the banny body being dressed. Must be category 0.
81
- - `backgroundId`: Token ID of the background to attach. Pass `0` for no background.
82
- - `outfitIds`: Array of outfit token IDs. Must be in ascending category order (categories 2-17). Pass `[]` for no outfits.
56
+ **Prerequisites**:
57
+ - Caller owns the banny body NFT
58
+ - Body is not locked (`outfitLockedUntil[hook][bannyBodyId] <= block.timestamp`)
59
+ - Caller owns each outfit NFT directly, or owns the banny body currently wearing that outfit
60
+ - Caller owns the background NFT directly (or owns the banny body currently using it), if `backgroundId != 0`
61
+ - Caller has approved the resolver for the hook contract (`hook.setApprovalForAll(resolver, true)`)
83
62
 
84
- ### State Changes
85
-
86
- **Checks (lines 987-997)**:
87
- 1. `_checkIfSenderIsOwner(hook, bannyBodyId)` -- reverts `UnauthorizedBannyBody` if caller is not the body owner.
88
- 2. `_productOfTokenId(hook, bannyBodyId).category` must equal 0 -- reverts `BannyBodyNotBodyCategory`.
89
- 3. `outfitLockedUntil[hook][bannyBodyId]` must be `<= block.timestamp` -- reverts `OutfitChangesLocked`.
90
-
91
- **Event emission (line 999)**:
92
- ```
93
- DecorateBanny(hook, bannyBodyId, backgroundId, outfitIds, _msgSender())
94
- ```
95
-
96
- **Background processing (`_decorateBannyWithBackground`, line 1155)**:
97
-
98
- If the background is changing:
99
- - `_attachedBackgroundIdOf[hook][bannyBodyId]` is updated to `backgroundId` (or cleared to 0).
100
- - `_userOf[hook][backgroundId]` is set to `bannyBodyId`.
101
- - Old background NFT is returned to caller via `_tryTransferFrom` (silent failure OK).
102
- - New background NFT is transferred into the resolver via `_transferFrom` (reverts on failure).
103
-
104
- **Outfit processing (`_decorateBannyWithOutfits`, line 1222)**:
105
-
106
- For each new outfit:
107
- - Authorization verified: caller owns the outfit or the body wearing it.
108
- - Category validated: in range 2-17, ascending order, no conflicts.
109
- - Old outfits up to the current category are transferred out via `_tryTransferFrom`.
110
- - New outfit transferred into the resolver via `_transferFrom` (if not already held).
111
- - `_wearerOf[hook][outfitId]` set to `bannyBodyId`.
112
-
113
- After all new outfits are processed:
114
- - Remaining old outfits are transferred out.
115
- - `_attachedOutfitIdsOf[hook][bannyBodyId]` is overwritten with the new `outfitIds` array (line 1368).
63
+ **Parameters**:
64
+ - `hook` -- The `JB721TiersHook` contract address
65
+ - `bannyBodyId` -- Token ID of the banny body being dressed. Must be category 0
66
+ - `backgroundId` -- Token ID of the background to attach. Pass `0` for no background
67
+ - `outfitIds` -- Array of outfit token IDs. Must be in ascending category order (categories 2--17). Pass `[]` for no outfits
68
+
69
+ **State changes**:
70
+ 1. `_checkIfSenderIsOwner(hook, bannyBodyId)` -- verifies caller owns the body
71
+ 2. `_productOfTokenId(hook, bannyBodyId).category` must equal `_BODY_CATEGORY` (0)
72
+ 3. `outfitLockedUntil[hook][bannyBodyId]` must be `<= block.timestamp`
73
+ 4. Background processing (`_decorateBannyWithBackground`) -- only executes if the background is changing (new `backgroundId` differs from the current one, or the current background is no longer assigned to this body):
74
+ - If a previous background was assigned to this body: old background NFT return attempted via `_tryTransferFrom`. If the return fails, the background change is **aborted** — old background stays attached, new background is not equipped
75
+ - On successful return: `_attachedBackgroundIdOf[hook][bannyBodyId]` updated to `backgroundId` (or cleared to 0)
76
+ - If `backgroundId != 0`: `_userOf[hook][backgroundId]` set to `bannyBodyId`
77
+ - If `backgroundId != 0` and the resolver does not already hold the new background: new background NFT transferred into the resolver via `_transferFrom` (reverts on failure)
78
+ - If `backgroundId == 0` and the return transfer fails: `_attachedBackgroundIdOf` is **not cleared** (background stays attached for recovery)
79
+ 5. Outfit processing (`_decorateBannyWithOutfits`):
80
+ - For each new outfit not already worn by this body: `_wearerOf[hook][outfitId]` set to `bannyBodyId`
81
+ - If there are previous outfits in categories up to the current one: old outfits transfer attempted via `_tryTransferFrom`. If a transfer fails, the outfit is **retained** (not zeroed in the previous outfit tracking array)
82
+ - For each new outfit not already held by the resolver: transferred into the resolver via `_transferFrom`
83
+ - `_storeOutfitsWithRetained` merges the new `outfitIds` with any retained outfits whose transfers failed, then stores the combined array in `_attachedOutfitIdsOf[hook][bannyBodyId]`
84
+
85
+ **Events**: `DecorateBanny(address indexed hook, uint256 indexed bannyBodyId, uint256 indexed backgroundId, uint256[] outfitIds, address caller)` -- emitted before state changes, where `caller = _msgSender()`
86
+
87
+ **Edge cases**:
88
+ - **Empty outfitIds with backgroundId=0**: Strips all outfits and background. All previously equipped NFTs returned to caller
89
+ - **Outfit already worn by this body**: Not re-transferred. `_wearerOf` retains existing value. The outfit stays in resolver custody
90
+ - **Outfit worn by another body owned by caller**: Authorized via `ownerOf(wearerId) == _msgSender()`. The outfit is unlinked from the old body and transferred to the new body's custody
91
+ - **Burned outfit in previous set**: `_tryTransferFrom` returns `false`. The burned outfit is **retained** in the attachment array (phantom entry) rather than removed. This prevents stranding of other assets and keeps the record for potential future recovery
92
+ - **Removed tier in previous set**: `_productOfTokenId` returns category 0. The while loop processes it (category 0 <= any new category) and transfers it out
93
+ - **Categories not ascending**: Reverts `Banny721TokenUriResolver_UnorderedCategories`
94
+ - **Duplicate categories**: Reverts `Banny721TokenUriResolver_UnorderedCategories` (equality fails the `<` check)
95
+ - **Category outside 2--17 as outfit**: Reverts `Banny721TokenUriResolver_UnrecognizedCategory`
96
+ - **Head category conflict**: Reverts `Banny721TokenUriResolver_HeadAlreadyAdded`
97
+ - **Suit category conflict**: Reverts `Banny721TokenUriResolver_SuitAlreadyAdded`
98
+ - **Unauthorized background**: Reverts `Banny721TokenUriResolver_UnauthorizedBackground`
99
+ - **Unauthorized outfit**: Reverts `Banny721TokenUriResolver_UnauthorizedOutfit`
100
+ - **Unrecognized background category**: Reverts `Banny721TokenUriResolver_UnrecognizedBackground`
101
+ - **Source body is locked**: Reverts `Banny721TokenUriResolver_OutfitChangesLocked` (via `_revertIfBodyLocked`)
102
+ - **Reentrancy via safeTransferFrom callback**: Blocked by `nonReentrant` modifier
116
103
 
117
104
  ### Example: Dress with Hat and Glasses
118
105
 
@@ -137,47 +124,32 @@ Result:
137
124
  - Body's tokenURI now renders with hat layer.
138
125
  ```
139
126
 
140
- ### Edge Cases
141
-
142
- - **Empty outfitIds with backgroundId=0**: Strips all outfits and background. All previously equipped NFTs returned to caller.
143
- - **Outfit already worn by this body**: Not re-transferred. `_wearerOf` retains existing value. The outfit stays in resolver custody.
144
- - **Outfit worn by another body owned by caller**: Authorized via `ownerOf(wearerId) == _msgSender()`. The outfit is unlinked from the old body and transferred to the new body's custody.
145
- - **Burned outfit in previous set**: `_tryTransferFrom` silently fails. The burned outfit is removed from the attachment array but no NFT is returned.
146
- - **Removed tier in previous set**: `_productOfTokenId` returns category 0. The while loop processes it (category 0 <= any new category) and transfers it out.
147
- - **Categories not ascending**: Reverts `UnorderedCategories`.
148
- - **Duplicate categories**: Reverts `UnorderedCategories` (equality fails the `<` check).
149
- - **Category 0 or 1 as outfit**: Reverts `UnrecognizedCategory`.
150
- - **Reentrancy via safeTransferFrom callback**: Blocked by `nonReentrant` modifier.
151
-
152
127
  ---
153
128
 
154
129
  ## Journey 3: Undress a Banny
155
130
 
156
131
  Undressing is not a separate function. It is performed by calling `decorateBannyWith` with empty arrays.
157
132
 
158
- ### Entry Point
159
-
160
- ```solidity
161
- decorateBannyWith(hook, bannyBodyId, 0, [])
162
- ```
163
-
164
- ### Parameters
133
+ **Entry point**: `Banny721TokenUriResolver.decorateBannyWith(address hook, uint256 bannyBodyId, 0, [])`
165
134
 
166
- - `hook`: The hook address.
167
- - `bannyBodyId`: The banny body to undress.
168
- - `backgroundId`: `0` (remove background).
169
- - `outfitIds`: `[]` (remove all outfits).
135
+ **Who can call**: The owner of the banny body NFT.
170
136
 
171
- ### State Changes
137
+ **Parameters**:
138
+ - `hook` -- The hook address
139
+ - `bannyBodyId` -- The banny body to undress
140
+ - `backgroundId` -- `0` (remove background)
141
+ - `outfitIds` -- `[]` (remove all outfits)
172
142
 
173
- 1. All checks pass (ownership, not locked, body category).
174
- 2. **Background**: `_attachedBackgroundIdOf[hook][bannyBodyId]` set to 0. Old background NFT returned to caller.
175
- 3. **Outfits**: The new `outfitIds` array is empty, so the merge loop does not execute. The tail loop transfers out all previous outfits. `_attachedOutfitIdsOf[hook][bannyBodyId]` set to `[]`.
176
- 4. All outfit NFTs and the background NFT are returned to `_msgSender()`.
143
+ **State changes**:
144
+ 1. All checks pass (ownership, not locked, body category)
145
+ 2. If a background was attached: old background NFT return attempted via `_tryTransferFrom`. If successful, `_attachedBackgroundIdOf[hook][bannyBodyId]` set to 0. If the transfer fails (e.g., owner is a contract that rejects ERC-721), the background **stays attached** for recovery
146
+ 3. The new `outfitIds` array is empty, so the merge loop does not execute. If there are previous outfits: the tail loop attempts to transfer out all previous outfits via `_tryTransferFrom`. Failed transfers are **retained** in the attached list
147
+ 4. `_storeOutfitsWithRetained` stores `[]` plus any retained outfits in `_attachedOutfitIdsOf[hook][bannyBodyId]`
148
+ 5. Successfully transferred outfits and backgrounds are returned to `_msgSender()`. Failed transfers remain in resolver custody but stay tracked
177
149
 
178
- ### Partial Undress
150
+ **Events**: `DecorateBanny(hook, bannyBodyId, 0, [], caller)`
179
151
 
180
- To remove some outfits but keep others, pass only the outfits you want to keep:
152
+ **Partial undress**: To remove some outfits but keep others, pass only the outfits you want to keep:
181
153
 
182
154
  ```
183
155
  Before: outfitIds = [hat, glasses, necklace]
@@ -185,46 +157,38 @@ Call: decorateBannyWith(hook, bodyId, backgroundId, [necklace])
185
157
  After: hat and glasses returned to caller, necklace stays equipped.
186
158
  ```
187
159
 
188
- ### Edge Cases
189
-
190
- - **Body is locked**: Reverts `OutfitChangesLocked`. Cannot undress during lock period.
191
- - **Some equipped outfits were burned**: `_tryTransferFrom` silently fails for burned tokens. No revert, no NFT returned for those tokens.
192
- - **Caller is not the current owner**: Reverts `UnauthorizedBannyBody`. This can happen if the body was recently transferred.
160
+ **Edge cases**:
161
+ - **Body is locked**: Reverts `Banny721TokenUriResolver_OutfitChangesLocked`. Cannot undress during lock period
162
+ - **Some equipped outfits were burned**: `_tryTransferFrom` returns `false` for burned tokens. No revert — the burned outfits are **retained** in the attached list as phantom entries. No NFT is returned for those tokens
163
+ - **Caller is not the current owner**: Reverts `Banny721TokenUriResolver_UnauthorizedBannyBody`. This can happen if the body was recently transferred
193
164
 
194
165
  ---
195
166
 
196
167
  ## Journey 4: Lock a Banny
197
168
 
198
- ### Entry Point
199
-
200
- ```solidity
201
- function lockOutfitChangesFor(
202
- address hook,
203
- uint256 bannyBodyId
204
- ) public
205
- ```
206
-
207
- Line 1013.
169
+ **Entry point**: `Banny721TokenUriResolver.lockOutfitChangesFor(address hook, uint256 bannyBodyId)`
208
170
 
209
- ### Prerequisites
171
+ **Who can call**: The owner of the banny body NFT (`IERC721(hook).ownerOf(bannyBodyId) == _msgSender()`).
210
172
 
211
- - Caller owns the banny body NFT.
212
-
213
- ### Parameters
214
-
215
- - `hook`: The hook address.
216
- - `bannyBodyId`: The banny body to lock.
217
-
218
- ### State Changes
173
+ **Parameters**:
174
+ - `hook` -- The hook address
175
+ - `bannyBodyId` -- The banny body to lock
219
176
 
220
- 1. `_checkIfSenderIsOwner(hook, bannyBodyId)` -- reverts `UnauthorizedBannyBody` if not owner.
221
- 2. `newLockUntil = block.timestamp + 7 days` (line 1021).
222
- 3. If `currentLockedUntil > newLockUntil`, reverts `CantAccelerateTheLock` (line 1024).
223
- 4. `outfitLockedUntil[hook][bannyBodyId] = newLockUntil` (line 1027).
177
+ **State changes**:
178
+ 1. `_checkIfSenderIsOwner(hook, bannyBodyId)` -- verifies caller owns the body
179
+ 2. `newLockUntil = block.timestamp + 7 days` (constant `_LOCK_DURATION`)
180
+ 3. If `currentLockedUntil > newLockUntil`, reverts
181
+ 4. `outfitLockedUntil[hook][bannyBodyId] = newLockUntil`
224
182
 
225
- ### Result
183
+ **Events**: None. The `lockOutfitChangesFor` function does not emit an event.
226
184
 
227
- The body cannot have its outfits or background changed until `block.timestamp > outfitLockedUntil[hook][bannyBodyId]`.
185
+ **Edge cases**:
186
+ - **Relocking while already locked**: Succeeds if the new expiry is >= the current expiry. Since `newLockUntil = block.timestamp + 7 days`, this effectively extends the lock by whatever time remains
187
+ - **Attempting to shorten the lock**: Reverts `Banny721TokenUriResolver_CantAccelerateTheLock`
188
+ - **Locking an undressed body**: Valid. The body is locked with no outfits. No outfits can be added during the lock period
189
+ - **Lock after transfer**: The new owner can lock. The old owner cannot (they no longer pass `_checkIfSenderIsOwner`)
190
+ - **Lock does not prevent body transfer**: The lock only affects `decorateBannyWith`. The body NFT can still be transferred on the hook contract
191
+ - **No admin override**: The contract owner cannot unlock a body. The lock must expire naturally
228
192
 
229
193
  ### Use Case: Marketplace Sale
230
194
 
@@ -237,49 +201,39 @@ The body cannot have its outfits or background changed until `block.timestamp >
237
201
  6. After 7 days, Bob can undress and receive the hat and suit NFTs.
238
202
  ```
239
203
 
240
- ### Edge Cases
241
-
242
- - **Relocking while already locked**: Succeeds if the new expiry is >= the current expiry. Since `newLockUntil = block.timestamp + 7 days`, this effectively extends the lock by whatever time remains.
243
- - **Locking an undressed body**: Valid. The body is locked with no outfits. No outfits can be added during the lock period.
244
- - **Lock after transfer**: The new owner can lock. The old owner cannot (they no longer pass `_checkIfSenderIsOwner`).
245
- - **Lock does not prevent body transfer**: The lock only affects `decorateBannyWith`. The body NFT can still be transferred on the hook contract.
246
- - **No admin override**: The contract owner cannot unlock a body. The lock must expire naturally.
247
-
248
204
  ---
249
205
 
250
206
  ## Journey 5: Transfer a Decorated Banny
251
207
 
252
208
  The resolver does not intercept or control body transfers. Body transfers happen on the `JB721TiersHook` contract. The resolver observes the ownership change lazily.
253
209
 
254
- ### What Happens
255
-
256
- 1. Alice transfers body #1 to Bob via the hook contract (`hook.safeTransferFrom(alice, bob, bodyId)`).
257
- 2. No resolver functions are called during the transfer.
258
- 3. The resolver's state remains unchanged:
259
- - `_attachedOutfitIdsOf[hook][bodyId]` still contains the outfit array.
260
- - `_attachedBackgroundIdOf[hook][bodyId]` still contains the background ID.
261
- - `_wearerOf[hook][outfitId]` still maps each outfit to `bodyId`.
262
- - `_userOf[hook][backgroundId]` still maps to `bodyId`.
263
- 4. All equipped outfit and background NFTs remain held by the resolver.
264
-
265
- ### What Bob Can Do
210
+ **Entry point**: `JB721TiersHook.safeTransferFrom(address from, address to, uint256 tokenId)` (standard ERC-721 transfer on the hook contract, not the resolver)
266
211
 
267
- Bob now owns the body. All resolver authorization checks (`_checkIfSenderIsOwner`) will pass for Bob.
212
+ **Who can call**: The body owner, or an approved operator on the hook contract.
268
213
 
269
- - **Undress**: `decorateBannyWith(hook, bodyId, 0, [])` returns all equipped NFTs to Bob.
270
- - **Redress**: `decorateBannyWith(hook, bodyId, newBg, [newOutfits])` replaces outfits. Old outfits returned to Bob (even though Alice originally equipped them).
271
- - **Lock**: `lockOutfitChangesFor(hook, bodyId)` locks the body under Bob's control.
214
+ **State changes**:
215
+ 1. No resolver functions are called during the transfer
216
+ 2. Resolver state remains unchanged:
217
+ - `_attachedOutfitIdsOf[hook][bodyId]` still contains the outfit array
218
+ - `_attachedBackgroundIdOf[hook][bodyId]` still contains the background ID
219
+ - `_wearerOf[hook][outfitId]` still maps each outfit to `bodyId`
220
+ - `_userOf[hook][backgroundId]` still maps to `bodyId`
221
+ 3. All equipped outfit and background NFTs remain held by the resolver
272
222
 
273
- ### What Alice Can No Longer Do
223
+ **Events**: None from the resolver. The hook emits the standard ERC-721 `Transfer(from, to, tokenId)`.
274
224
 
275
- Alice cannot call `decorateBannyWith` or `lockOutfitChangesFor` for that body -- she no longer owns it.
225
+ **What the new owner (Bob) can do**:
226
+ - **Undress**: `decorateBannyWith(hook, bodyId, 0, [])` returns all equipped NFTs to Bob
227
+ - **Redress**: `decorateBannyWith(hook, bodyId, newBg, [newOutfits])` replaces outfits. Old outfits returned to Bob (even though Alice originally equipped them)
228
+ - **Lock**: `lockOutfitChangesFor(hook, bodyId)` locks the body under Bob's control
276
229
 
277
- ### Edge Cases
230
+ **What Alice can no longer do**: Alice cannot call `decorateBannyWith` or `lockOutfitChangesFor` for that body -- she no longer owns it.
278
231
 
279
- - **Alice equipped valuable outfits and forgot to undress before selling**: Bob receives full control of all equipped NFTs. This is the intended behavior but creates a seller gotcha. The lock mechanism exists to make this explicit (sellers lock, then sell at a higher price including outfits).
280
- - **Body transferred while locked**: Lock persists. Bob cannot change outfits until the lock expires.
281
- - **Body transferred to a contract**: The new contract owner must be able to call `decorateBannyWith`. If the contract does not implement this call, equipped outfits are effectively locked forever (until the contract is upgraded or has a function to make this call).
282
- - **Double transfer (Alice -> Bob -> Charlie)**: Only Charlie can interact with the body's outfits. Each transfer implicitly transfers outfit control.
232
+ **Edge cases**:
233
+ - **Alice equipped valuable outfits and forgot to undress before selling**: Bob receives full control of all equipped NFTs. This is the intended behavior but creates a seller gotcha. The lock mechanism exists to make this explicit (sellers lock, then sell at a higher price including outfits)
234
+ - **Body transferred while locked**: Lock persists. Bob cannot change outfits until the lock expires
235
+ - **Body transferred to a contract**: The new contract owner must be able to call `decorateBannyWith`. If the contract does not implement this call, equipped outfits are effectively locked forever (until the contract is upgraded or has a function to make this call)
236
+ - **Double transfer (Alice -> Bob -> Charlie)**: Only Charlie can interact with the body's outfits. Each transfer implicitly transfers outfit control
283
237
 
284
238
  ---
285
239
 
@@ -287,31 +241,29 @@ Alice cannot call `decorateBannyWith` or `lockOutfitChangesFor` for that body --
287
241
 
288
242
  A user who owns multiple bodies can move an equipped outfit from one body to another in a single call.
289
243
 
290
- ### Entry Point
291
-
292
- ```solidity
293
- decorateBannyWith(hook, newBodyId, 0, [outfitId])
294
- ```
244
+ **Entry point**: `Banny721TokenUriResolver.decorateBannyWith(address hook, uint256 newBodyId, 0, [outfitId])`
295
245
 
296
246
  Where `outfitId` is currently equipped on `oldBodyId`, and the caller owns both bodies.
297
247
 
298
- ### Prerequisites
248
+ **Who can call**: An address that owns both `newBodyId` and the body currently wearing the outfit (`oldBodyId`).
299
249
 
300
- - Caller owns `newBodyId`.
301
- - Caller owns `oldBodyId` (which currently wears the outfit).
302
- - `newBodyId` is not locked.
250
+ **Prerequisites**:
251
+ - Caller owns `newBodyId`
252
+ - Caller owns `oldBodyId` (which currently wears the outfit)
253
+ - `newBodyId` is not locked
254
+ - `oldBodyId` is not locked (enforced by `_revertIfBodyLocked`)
303
255
 
304
- ### State Changes
256
+ **State changes**:
257
+ 1. Authorization passes: caller does not own the outfit directly (resolver holds it), but caller owns `oldBodyId` which is the `wearerOf(hook, outfitId)`
258
+ 2. `_wearerOf[hook][outfitId]` updated to `newBodyId`
259
+ 3. The outfit is not transferred (resolver already holds it)
260
+ 4. `_attachedOutfitIdsOf[hook][newBodyId]` set to the new array including this outfit
261
+ 5. `_attachedOutfitIdsOf[hook][oldBodyId]` is NOT explicitly updated. However, `wearerOf(hook, outfitId)` now returns `newBodyId`, so `assetIdsOf(hook, oldBodyId)` will exclude this outfit from its filtered result
305
262
 
306
- 1. Authorization passes: caller does not own the outfit directly (resolver holds it), but caller owns `oldBodyId` which is the `wearerOf(hook, outfitId)`.
307
- 2. `_wearerOf[hook][outfitId]` is updated to `newBodyId` (line 1331).
308
- 3. The outfit is not transferred (resolver already holds it, line 1335 check).
309
- 4. `_attachedOutfitIdsOf[hook][newBodyId]` is set to the new array including this outfit.
310
- 5. `_attachedOutfitIdsOf[hook][oldBodyId]` is NOT explicitly updated. However, `wearerOf(hook, outfitId)` will now return `newBodyId`, so `assetIdsOf(hook, oldBodyId)` will exclude this outfit from its filtered result.
263
+ **Events**: `DecorateBanny(hook, newBodyId, 0, [outfitId], caller)`
311
264
 
312
- ### Edge Cases
313
-
314
- - **Old body is locked**: Reverts `OutfitChangesLocked`. A locked source body keeps its currently equipped outfits and background until the lock expires, even if the caller owns both bodies.
265
+ **Edge cases**:
266
+ - **Old body is locked**: Reverts `Banny721TokenUriResolver_OutfitChangesLocked` via `_revertIfBodyLocked`. A locked source body keeps its outfits and background until the lock expires, even if the caller owns both bodies
315
267
 
316
268
  ---
317
269
 
@@ -319,53 +271,46 @@ Where `outfitId` is currently equipped on `oldBodyId`, and the caller owns both
319
271
 
320
272
  ### Step 1: Commit Hashes
321
273
 
322
- **Entry Point**:
323
- ```solidity
324
- function setSvgHashesOf(
325
- uint256[] memory upcs,
326
- bytes32[] memory svgHashes
327
- ) external onlyOwner
328
- ```
274
+ **Entry point**: `Banny721TokenUriResolver.setSvgHashesOf(uint256[] memory upcs, bytes32[] memory svgHashes)`
329
275
 
330
- Line 1130.
276
+ **Who can call**: Contract owner only (`onlyOwner` modifier, from OpenZeppelin `Ownable`).
331
277
 
332
278
  **Parameters**:
333
- - `upcs`: Array of universal product codes to set hashes for.
334
- - `svgHashes`: Array of `keccak256` hashes of the SVG content strings.
279
+ - `upcs` -- Array of universal product codes to set hashes for
280
+ - `svgHashes` -- Array of `keccak256` hashes of the SVG content strings
281
+
282
+ **State changes**:
283
+ 1. For each pair: `svgHashOf[upc] = svgHash`
335
284
 
336
- **State Changes**:
337
- - `svgHashOf[upc] = svgHash` for each pair.
338
- - Reverts `HashAlreadyStored` if any UPC already has a hash set.
339
- - Emits `SetSvgHash(upc, svgHash, caller)`.
285
+ **Events**: `SetSvgHash(uint256 indexed upc, bytes32 indexed svgHash, address caller)` -- emitted once per UPC in the array
286
+
287
+ **Edge cases**:
288
+ - **UPC already has a hash**: Reverts `Banny721TokenUriResolver_HashAlreadyStored` (write-once)
289
+ - **Array length mismatch**: Reverts `Banny721TokenUriResolver_ArrayLengthMismatch`
290
+ - **Partial failure**: The entire call reverts if any single UPC fails
340
291
 
341
292
  ### Step 2: Upload Content
342
293
 
343
- **Entry Point**:
344
- ```solidity
345
- function setSvgContentsOf(
346
- uint256[] memory upcs,
347
- string[] calldata svgContents
348
- ) external
349
- ```
294
+ **Entry point**: `Banny721TokenUriResolver.setSvgContentsOf(uint256[] memory upcs, string[] calldata svgContents)`
350
295
 
351
- Line 1100. **Not restricted to owner** -- anyone can upload content as long as it matches the hash.
296
+ **Who can call**: Anyone. Not restricted to owner -- anyone can upload content as long as it matches the committed hash.
352
297
 
353
298
  **Parameters**:
354
- - `upcs`: Array of universal product codes to upload content for.
355
- - `svgContents`: Array of SVG content strings (without wrapping `<svg>` tags).
299
+ - `upcs` -- Array of universal product codes to upload content for
300
+ - `svgContents` -- Array of SVG content strings (without wrapping `<svg>` tags)
356
301
 
357
- **State Changes**:
358
- - Reverts `ContentsAlreadyStored` if content already set for this UPC.
359
- - Reverts `HashNotFound` if no hash set for this UPC.
360
- - Reverts `ContentsMismatch` if `keccak256(abi.encodePacked(svgContent)) != svgHashOf[upc]`.
361
- - `_svgContentOf[upc] = svgContent`.
362
- - Emits `SetSvgContent(upc, svgContent, caller)`.
302
+ **State changes**:
303
+ 1. For each pair: `_svgContentOf[upc] = svgContent`
363
304
 
364
- ### Edge Cases
305
+ **Events**: `SetSvgContent(uint256 indexed upc, string svgContent, address caller)` -- emitted once per UPC in the array
365
306
 
366
- - **Hash set but content never uploaded**: `_svgOf` falls back to IPFS resolution. Tokens render with IPFS images instead of on-chain SVG.
367
- - **Content with special characters**: Stored verbatim. No sanitization. A `<script>` tag in SVG content would be included in the data URI.
368
- - **Multiple UPCs in one call**: Array lengths must match or `ArrayLengthMismatch` reverts. The entire call reverts if any single UPC fails.
307
+ **Edge cases**:
308
+ - **Content already stored**: Reverts `Banny721TokenUriResolver_ContentsAlreadyStored` (write-once)
309
+ - **No hash set for UPC**: Reverts `Banny721TokenUriResolver_HashNotFound`
310
+ - **Content does not match hash**: Reverts `Banny721TokenUriResolver_ContentsMismatch` (checks `keccak256(abi.encodePacked(svgContent)) != svgHashOf[upc]`)
311
+ - **Array length mismatch**: Reverts `Banny721TokenUriResolver_ArrayLengthMismatch`
312
+ - **Hash set but content never uploaded**: `_svgOf` falls back to IPFS resolution. Tokens render with IPFS images instead of on-chain SVG
313
+ - **Content with special characters**: Stored verbatim. No sanitization. A `<script>` tag in SVG content would be included in the data URI
369
314
 
370
315
  ---
371
316
 
@@ -373,151 +318,208 @@ Line 1100. **Not restricted to owner** -- anyone can upload content as long as i
373
318
 
374
319
  ### Set Metadata
375
320
 
376
- **Entry Point**:
377
- ```solidity
378
- function setMetadata(
379
- string calldata description,
380
- string calldata url,
381
- string calldata baseUri
382
- ) external onlyOwner
383
- ```
321
+ **Entry point**: `Banny721TokenUriResolver.setMetadata(string calldata description, string calldata url, string calldata baseUri)`
322
+
323
+ **Who can call**: Contract owner only (`onlyOwner` modifier).
324
+
325
+ **Parameters**:
326
+ - `description` -- The description to use in token metadata
327
+ - `url` -- The external URL to use in token metadata
328
+ - `baseUri` -- The base URI of the SVG files (used for IPFS fallback)
329
+
330
+ **State changes**:
331
+ 1. `svgDescription = description`
332
+ 2. `svgExternalUrl = url`
333
+ 3. `svgBaseUri = baseUri`
334
+ 4. All three fields are always overwritten. Pass current values for fields you do not want to change. Pass `""` to clear
384
335
 
385
- Line 1065.
336
+ **Events**: `SetMetadata(string description, string externalUrl, string baseUri, address caller)`
386
337
 
387
- **State Changes**:
388
- - `svgDescription = description`
389
- - `svgExternalUrl = url`
390
- - `svgBaseUri = baseUri`
391
- - All three fields are always overwritten. Pass current values for fields you do not want to change. Pass `""` to clear.
338
+ **Edge cases**:
339
+ - **Empty strings**: Valid. Clears the respective metadata field
340
+ - **Non-owner caller**: Reverts with OpenZeppelin `OwnableUnauthorizedAccount`
392
341
 
393
342
  ### Set Product Names
394
343
 
395
- **Entry Point**:
396
- ```solidity
397
- function setProductNames(
398
- uint256[] memory upcs,
399
- string[] memory names
400
- ) external onlyOwner
401
- ```
344
+ **Entry point**: `Banny721TokenUriResolver.setProductNames(uint256[] memory upcs, string[] memory names)`
345
+
346
+ **Who can call**: Contract owner only (`onlyOwner` modifier).
402
347
 
403
- Line 1084.
348
+ **Parameters**:
349
+ - `upcs` -- Array of universal product codes to name
350
+ - `names` -- Array of display names for each product
404
351
 
405
- **State Changes**:
406
- - `_customProductNameOf[upc] = name` for each pair.
407
- - Unlike SVG hashes and content, names are **not write-once**. Names can be overwritten.
408
- - Built-in names for UPCs 1-4 (Alien, Pink, Orange, Original) are hardcoded in `_productNameOf` (line 896-909) and cannot be overridden by this function. The `_productNameOf` function checks UPCs 1-4 first and returns the hardcoded name before checking `_customProductNameOf`.
352
+ **State changes**:
353
+ 1. For each pair: `_customProductNameOf[upc] = name`
409
354
 
410
- ### Edge Cases
355
+ **Events**: `SetProductName(uint256 indexed upc, string name, address caller)` -- emitted once per UPC in the array
411
356
 
412
- - **Overwriting a product name**: No revert. The old name is replaced. This could change how existing NFTs display.
413
- - **Setting name for UPCs 1-4**: The `_customProductNameOf` mapping is written, but `_productNameOf` returns the hardcoded name first. The custom name is never read for these UPCs.
414
- - **Empty name string**: Valid. Sets the custom name to empty, causing `_productNameOf` to return `""` for that UPC.
357
+ **Edge cases**:
358
+ - **Overwriting a product name**: No revert. The old name is replaced. This could change how existing NFTs display (names are mutable, unlike SVG hashes/content)
359
+ - **Setting name for UPCs 1--4**: The `_customProductNameOf` mapping is written, but `_productNameOf` returns the hardcoded name first (Alien, Pink, Orange, Original). The custom name is never read for these UPCs
360
+ - **Empty name string**: Valid. Sets the custom name to empty, causing `_productNameOf` to return `""` for that UPC
361
+ - **Array length mismatch**: Reverts `Banny721TokenUriResolver_ArrayLengthMismatch`
415
362
 
416
363
  ---
417
364
 
418
365
  ## Journey 9: View Functions -- Query Banny State
419
366
 
367
+ All view functions. No access restrictions, no state changes, no events.
368
+
420
369
  ### Get Attached Assets
421
370
 
422
- ```solidity
423
- function assetIdsOf(
424
- address hook,
425
- uint256 bannyBodyId
426
- ) public view returns (uint256 backgroundId, uint256[] memory outfitIds)
427
- ```
371
+ **Entry point**: `Banny721TokenUriResolver.assetIdsOf(address hook, uint256 bannyBodyId) public view returns (uint256 backgroundId, uint256[] memory outfitIds)`
428
372
 
429
- Line 356.
373
+ **Who can call**: Anyone (view function).
374
+
375
+ **Parameters**:
376
+ - `hook` -- The hook address of the collection
377
+ - `bannyBodyId` -- The banny body to query
430
378
 
431
379
  Returns the currently attached background and outfits. Filters by checking `wearerOf` and `userOf` for each stored ID, excluding outfits that have been moved to other bodies.
432
380
 
433
381
  ### Get Outfit Wearer
434
382
 
435
- ```solidity
436
- function wearerOf(address hook, uint256 outfitId) public view returns (uint256)
437
- ```
383
+ **Entry point**: `Banny721TokenUriResolver.wearerOf(address hook, uint256 outfitId) public view returns (uint256)`
384
+
385
+ **Who can call**: Anyone (view function).
386
+
387
+ **Parameters**:
388
+ - `hook` -- The hook address of the collection
389
+ - `outfitId` -- The outfit token ID to query
438
390
 
439
- Line 509. Returns the body ID wearing this outfit, or 0 if unworn. Verifies the outfit is still in the body's `_attachedOutfitIdsOf` array.
391
+ Returns the body ID wearing this outfit, or 0 if unworn. Verifies the outfit is still in the body's `_attachedOutfitIdsOf` array.
440
392
 
441
393
  ### Get Background User
442
394
 
443
- ```solidity
444
- function userOf(address hook, uint256 backgroundId) public view returns (uint256)
445
- ```
395
+ **Entry point**: `Banny721TokenUriResolver.userOf(address hook, uint256 backgroundId) public view returns (uint256)`
396
+
397
+ **Who can call**: Anyone (view function).
398
+
399
+ **Parameters**:
400
+ - `hook` -- The hook address of the collection
401
+ - `backgroundId` -- The background token ID to query
446
402
 
447
- Line 494. Returns the body ID using this background, or 0 if unused. Verifies the background is still the body's `_attachedBackgroundIdOf` entry.
403
+ Returns the body ID using this background, or 0 if unused. Verifies the background is still the body's `_attachedBackgroundIdOf` entry.
448
404
 
449
405
  ### Get SVG
450
406
 
451
- ```solidity
452
- function svgOf(
453
- address hook,
454
- uint256 tokenId,
455
- bool shouldDressBannyBody,
456
- bool shouldIncludeBackgroundOnBannyBody
457
- ) public view returns (string memory)
458
- ```
407
+ **Entry point**: `Banny721TokenUriResolver.svgOf(address hook, uint256 tokenId, bool shouldDressBannyBody, bool shouldIncludeBackgroundOnBannyBody) public view returns (string memory)`
408
+
409
+ **Who can call**: Anyone (view function).
459
410
 
460
- Line 434. Returns the composed SVG for any token. For bodies, can toggle dressing and background. For non-bodies, returns the outfit/background SVG alone.
411
+ **Parameters**:
412
+ - `hook` -- The hook address of the collection
413
+ - `tokenId` -- The token ID to render
414
+ - `shouldDressBannyBody` -- Whether to include the banny body's attached outfits
415
+ - `shouldIncludeBackgroundOnBannyBody` -- Whether to include the banny body's attached background
416
+
417
+ Returns the composed SVG for any token. For bodies, can toggle dressing and background. For non-bodies, returns the outfit/background SVG alone.
461
418
 
462
419
  ### Get Names
463
420
 
464
- ```solidity
465
- function namesOf(
466
- address hook,
467
- uint256 tokenId
468
- ) public view returns (string memory, string memory, string memory)
469
- ```
421
+ **Entry point**: `Banny721TokenUriResolver.namesOf(address hook, uint256 tokenId) public view returns (string memory, string memory, string memory)`
470
422
 
471
- Line 406. Returns (fullName, categoryName, productName).
423
+ **Who can call**: Anyone (view function).
424
+
425
+ **Parameters**:
426
+ - `hook` -- The hook address of the collection
427
+ - `tokenId` -- The token ID to look up
428
+
429
+ Returns `(fullName, categoryName, productName)`.
472
430
 
473
431
  ### Get Lock Status
474
432
 
475
- ```solidity
476
- mapping(address hook => mapping(uint256 upc => uint256)) public outfitLockedUntil;
477
- ```
433
+ **Entry point**: `Banny721TokenUriResolver.outfitLockedUntil(address hook, uint256 upc) public view returns (uint256)`
434
+
435
+ **Who can call**: Anyone (public mapping).
478
436
 
479
- Line 96. Directly readable. Returns the timestamp until which the body is locked, or 0 if never locked.
437
+ **Parameters**:
438
+ - `hook` -- The hook address of the collection
439
+ - `upc` -- The banny body token ID
440
+
441
+ Returns the timestamp until which the body is locked, or 0 if never locked.
480
442
 
481
443
  ---
482
444
 
483
445
  ## Summary: State Machine per Banny Body
484
446
 
485
447
  ```
486
- +-----------+
487
- | NAKED |
488
- | (minted) |
489
- +-----+-----+
490
- |
491
- decorateBannyWith(outfits)
492
- |
493
- +-----v-----+
494
- | DRESSED |<----+
495
- | | |
496
- +-----+-----+ |
497
- | |
498
- +-------------+-------+ |
499
- | | |
500
- decorateBannyWith([]) decorateBannyWith(newOutfits)
501
- | |
502
- +-----v-----+ +---+
503
- | NAKED |
504
- +-----+-----+
505
- |
506
- lockOutfitChangesFor()
507
- |
508
- +-----v-----+
509
- | LOCKED |
510
- | (7 days) |
511
- +-----+-----+
512
- |
513
- block.timestamp > lockUntil
514
- |
515
- +-----v-----+
516
- | UNLOCKED |
517
- | (NAKED) |
518
- +-----------+
448
+ +-----------+
449
+ | NAKED |
450
+ | (minted) |
451
+ +-----+-----+
452
+ |
453
+ decorateBannyWith(outfits)
454
+ |
455
+ +-----v-----+
456
+ | DRESSED |<----+
457
+ | | |
458
+ +--+--+--+--+ |
459
+ | | | |
460
+ +------------+ | +--------+
461
+ | | decorateBannyWith(newOutfits)
462
+ decorateBannyWith([]) |
463
+ | | lockOutfitChangesFor()
464
+ +-----v-----+ |
465
+ | NAKED | |
466
+ +-----+-----+ |
467
+ | |
468
+ lockOutfitChangesFor() |
469
+ | |
470
+ +-----v---------------v--+
471
+ | LOCKED |
472
+ | (7 days) |
473
+ +-----+-------------------+
474
+ |
475
+ block.timestamp > lockUntil
476
+ |
477
+ +-----v-----------+
478
+ | UNLOCKED |
479
+ | (NAKED or |
480
+ | DRESSED) |
481
+ +-----------------+
519
482
  ```
520
483
 
521
484
  The lock state applies equally to dressed and naked bodies. A dressed body can be locked, preventing outfit changes. A naked body can be locked, preventing outfit additions.
522
485
 
523
486
  Body transfers do not change the resolver state. The new owner inherits the current state (dressed/naked, locked/unlocked) and all custody rights.
487
+
488
+ ---
489
+
490
+ ## Events Reference
491
+
492
+ All events defined in `IBanny721TokenUriResolver`:
493
+
494
+ | Event | Emitted by | Signature |
495
+ |-------|-----------|-----------|
496
+ | `DecorateBanny` | `decorateBannyWith` | `DecorateBanny(address indexed hook, uint256 indexed bannyBodyId, uint256 indexed backgroundId, uint256[] outfitIds, address caller)` |
497
+ | `SetMetadata` | `setMetadata` | `SetMetadata(string description, string externalUrl, string baseUri, address caller)` |
498
+ | `SetProductName` | `setProductNames` | `SetProductName(uint256 indexed upc, string name, address caller)` |
499
+ | `SetSvgContent` | `setSvgContentsOf` | `SetSvgContent(uint256 indexed upc, string svgContent, address caller)` |
500
+ | `SetSvgHash` | `setSvgHashesOf` | `SetSvgHash(uint256 indexed upc, bytes32 indexed svgHash, address caller)` |
501
+
502
+ ## Custom Errors Reference
503
+
504
+ All custom errors defined in `Banny721TokenUriResolver`:
505
+
506
+ | Error | Trigger |
507
+ |-------|---------|
508
+ | `Banny721TokenUriResolver_ArrayLengthMismatch` | `upcs` and values arrays have different lengths |
509
+ | `Banny721TokenUriResolver_BannyBodyNotBodyCategory` | `bannyBodyId` is not a category 0 (body) token |
510
+ | `Banny721TokenUriResolver_CantAccelerateTheLock` | New lock expiry would be earlier than current lock |
511
+ | `Banny721TokenUriResolver_ContentsAlreadyStored` | SVG content already uploaded for this UPC |
512
+ | `Banny721TokenUriResolver_ContentsMismatch` | SVG content hash does not match committed hash |
513
+ | `Banny721TokenUriResolver_HashAlreadyStored` | SVG hash already committed for this UPC |
514
+ | `Banny721TokenUriResolver_HashNotFound` | No hash committed for this UPC |
515
+ | `Banny721TokenUriResolver_HeadAlreadyAdded` | Outfit conflicts with an already-equipped head item |
516
+ | `Banny721TokenUriResolver_OutfitChangesLocked` | Body is locked and cannot change outfits |
517
+ | `Banny721TokenUriResolver_SuitAlreadyAdded` | Outfit conflicts with an already-equipped suit item |
518
+ | `Banny721TokenUriResolver_UnauthorizedBackground` | Caller does not own the background or the body using it |
519
+ | `Banny721TokenUriResolver_UnauthorizedBannyBody` | Caller does not own the banny body |
520
+ | `Banny721TokenUriResolver_UnauthorizedOutfit` | Caller does not own the outfit or the body wearing it |
521
+ | `Banny721TokenUriResolver_UnauthorizedTransfer` | NFT received from an external sender (not this contract) |
522
+ | `Banny721TokenUriResolver_UnorderedCategories` | Outfit categories are not in ascending order |
523
+ | `Banny721TokenUriResolver_UnrecognizedBackground` | Token is not a valid background category |
524
+ | `Banny721TokenUriResolver_UnrecognizedCategory` | Outfit category is not in the valid range (2--17) |
525
+ | `Banny721TokenUriResolver_UnrecognizedProduct` | Token does not belong to a recognized product tier |