@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/RISKS.md CHANGED
@@ -10,20 +10,19 @@
10
10
  ## 2. Economic / Manipulation Risks
11
11
 
12
12
  - **Outfit theft via banny body transfer.** Equipped outfits and backgrounds travel with the banny body NFT on transfer. If a banny body is sold with valuable outfits equipped, the buyer gains control of all equipped items. Sellers must unequip before selling. Marketplaces may not surface this risk.
13
- - **try-catch silent failures.** `_tryTransferFrom` silently catches all transfer failures. If an outfit NFT is burned or its tier removed, the transfer fails silently. The outfit remains logically "equipped" in state but the NFT is lost. This can create phantom outfits that show in SVG rendering but cannot be recovered.
13
+ - **try-catch silent failures with retention.** `_tryTransferFrom` silently catches all transfer failures and returns `false`. When a transfer fails, the resolver preserves the attachment record instead of clearing state. For backgrounds, the entire background change is aborted. For outfits, failed-to-return items are retained in the attached list via `_storeOutfitsWithRetained`. This prevents NFT stranding — assets remain tracked and recoverable once the transfer issue is resolved (e.g., the owner contract becomes receivable). However, if an outfit NFT is burned or its tier removed, the retained record refers to a non-existent asset, creating a phantom entry in the SVG rendering.
14
14
  - **Lock griefing.** `lockOutfitChangesFor` extends the lock to `block.timestamp + 7 days`. Locking just before selling prevents the buyer from changing outfits for up to 7 days. The lock now also freezes reassignment of currently equipped outfits/backgrounds away from that body during the lock window.
15
15
 
16
16
  ## 3. Access Control
17
17
 
18
- - **No hook validation.** Any address can be passed as `hook`. A malicious hook can return `_msgSender()` from `ownerOf()` to pass authorization checks, execute arbitrary code during `safeTransferFrom`, or return manipulated tier data from `STORE().tierOfTokenId()`.
18
+ - **No hook validation (HIGH impact).** Any address can be passed as `hook`. A malicious hook can return `_msgSender()` from `ownerOf()` to pass authorization checks, execute arbitrary code during `safeTransferFrom`, or return manipulated tier data from `STORE().tierOfTokenId()`.
19
19
  - **SVG content upload is permissionless (with hash).** `setSvgContentsOf` only requires the content to match a pre-committed hash. Safe if hashes are correctly committed.
20
20
  - **onERC721Received restriction.** Only accepts NFTs when `operator == address(this)`. `transferFrom` (non-safe) bypasses this -- NFTs sent via `transferFrom` are permanently locked with no rescue function.
21
21
 
22
22
  ## 4. DoS Vectors
23
23
 
24
- - **Unbounded outfit iteration.** `_attachedOutfitIdsOf` array grows with each decoration and is never compacted. Over time with repeated equip/unequip cycles, gas costs increase.
25
- - **On-chain SVG rendering gas.** `tokenUriOf` constructs full SVGs on-chain with string concatenation. Complex outfits with many layers can exceed block gas limits for `view` calls, making tokens unrenderable by off-chain indexers. Measured gas ceiling: ~609K gas for the worst case (9 non-conflicting outfits + background with on-chain SVG content), well within typical RPC node limits (30M+). Regression test: `test_tokenUri_gasSnapshot_9outfits` in `test/TestQALastMile.t.sol`.
26
- - **External hook calls in view functions.** `tokenUriOf` and `svgOf` call into the hook's store multiple times per outfit. A malicious hook that consumes excessive gas or reverts can make token metadata unretrievable.
24
+ - **External call iteration scales with outfit count.** `_attachedOutfitIdsOf[hook][bannyBodyId]` is replaced wholesale on each `decorateBannyWith` call (not appended to), so the array is bounded by the number of currently equipped outfits, not cumulative history. However, `decorateBannyWith` iterates over both the previous and new outfit arrays to diff them (transferring removed outfits back and new outfits in), so gas cost scales with the number of outfits being equipped/unequipped in a single call.
25
+ - **External hook calls in view functions.** `tokenUriOf` and `svgOf` call into the hook's store multiple times per outfit. A malicious hook that consumes excessive gas or reverts can make token metadata unretrievable. Measured: `tokenUriOf` with a well-behaved hook and 9 equipped outfits costs ~609k gas (see `test_tokenUri_gasSnapshot_9outfits`). The practical ceiling for a malicious hook is bounded only by the caller's gas limit RPC nodes typically cap `eth_call` at 30M+ gas, so even expensive hooks won't fail for off-chain reads, but on-chain consumers (e.g., other contracts calling `tokenURI`) could revert.
27
26
 
28
27
  ## 5. Integration Risks
29
28
 
@@ -37,6 +36,33 @@
37
36
  - Every outfit held by this contract has a corresponding `_wearerOf[hook][outfitId]` pointing to a valid banny body.
38
37
  - Every background held by this contract has a corresponding `_userOf[hook][backgroundId]` pointing to a valid banny body.
39
38
  - `outfitLockedUntil[hook][bannyBodyId]` is monotonically non-decreasing per banny body (lock can only be extended, never shortened).
40
- - After `decorateBannyWith`, all previously equipped outfits not in the new set are transferred back to `_msgSender()` (or silently failed via try-catch).
41
- - `_attachedOutfitIdsOf[hook][bannyBodyId]` matches the outfitIds passed to the most recent `decorateBannyWith` call.
39
+ - After `decorateBannyWith`, all previously equipped outfits not in the new set are either transferred back to `_msgSender()` or retained in the attached list if the transfer failed.
40
+ - `_attachedOutfitIdsOf[hook][bannyBodyId]` contains the outfitIds passed to the most recent `decorateBannyWith` call, plus any retained outfits whose return transfer failed. Category exclusivity is enforced on the merged set (retained + new outfits), not just the new outfit set alone.
42
41
  - SVG content integrity: `keccak256(_svgContentOf[upc]) == svgHashOf[upc]` for all populated entries.
42
+ - NFT custody balance: the number of outfit NFTs held by this contract (`IERC721(hook).balanceOf(address(this))`) equals the total number of outfits currently equipped across all banny bodies for that hook. Violations indicate phantom outfits (equipped in state but NFT lost via try-catch silent failure) or orphaned NFTs (held by contract but not tracked in `_wearerOf`).
43
+
44
+ ## 7. Accepted Behaviors
45
+
46
+ ### 7.1 Failed transfers retain attachment records (anti-stranding)
47
+
48
+ `_tryTransferFrom` catches all transfer failures and returns `false`. When returning a previously equipped item fails, the resolver preserves the attachment record rather than clearing state:
49
+
50
+ - **Backgrounds**: If returning the old background fails, the entire background change is aborted (`return` in `_decorateBannyWithBackground`). The old background stays attached and the new one is not equipped.
51
+ - **Background removal**: If returning the background fails during removal (backgroundId=0), `_attachedBackgroundIdOf` is not cleared. The background stays attached.
52
+ - **Outfits**: Failed-to-return outfits remain non-zero in the `previousOutfitIds` array. `_storeOutfitsWithRetained` appends them to the new outfit list, preserving their attachment record.
53
+
54
+ This prevents NFT stranding — assets held by the resolver stay tracked and recoverable. Once the transfer issue is resolved (e.g., the owner contract implements `IERC721Receiver`), a subsequent `decorateBannyWith` call will successfully return the retained items.
55
+
56
+ For permanently unrecoverable assets (burned NFTs, removed tiers), the retained record creates a phantom entry in the SVG rendering and attached list. This is cosmetically incorrect but not economically exploitable — phantom entries cannot be transferred or sold. The alternative — reverting on any failed transfer — would make `decorateBannyWith` fragile: a single burned outfit would prevent the banny owner from changing ANY outfits.
57
+
58
+ ### 7.2 Lock griefing window is bounded at 7 days
59
+
60
+ `lockOutfitChangesFor` extends the lock to `block.timestamp + 7 days`. A seller who locks just before transferring the banny forces the buyer to wait up to 7 days. This is accepted because: (1) marketplaces can check `outfitLockedUntil` before displaying the item, (2) the lock duration is fixed (not owner-configurable), and (3) the lock prevents a more severe attack where a buyer immediately strips valuable outfits — the lock gives the previous owner time to arrange the sale intentionally.
61
+
62
+ ### 7.3 On-chain SVG rendering gas is well within limits
63
+
64
+ `tokenUriOf` constructs full SVGs on-chain with string concatenation. Measured gas ceiling: ~609K gas for the worst case (9 non-conflicting outfits + background with on-chain SVG content), well within typical RPC node limits (30M+). Regression test: `test_tokenUri_gasSnapshot_9outfits` in `test/TestQALastMile.t.sol`.
65
+
66
+ ### 7.4 Reentrancy in non-guarded functions is harmless
67
+
68
+ `lockOutfitChangesFor` and all view functions (`tokenUriOf`, `svgOf`) are not protected by `nonReentrant`. A malicious hook's `STORE().tierOfTokenId()` could re-enter `lockOutfitChangesFor` during a `tokenUriOf` call, but this is harmless -- `lockOutfitChangesFor` only extends the lock timestamp (monotonically non-decreasing) and has no state that could be corrupted by reentrancy. The view functions themselves are read-only at the contract level (no storage writes), so reentrancy through them cannot extract value.
package/SKILLS.md CHANGED
@@ -23,7 +23,7 @@ On-chain composable NFT avatar system that renders Banny characters with layered
23
23
 
24
24
  | Function | What it does |
25
25
  |----------|-------------|
26
- | `decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds)` | Attaches background and outfits to a body. Transfers assets to contract custody. Validates ownership, lock status, category ordering, slot conflicts. Returns previously attached items to caller. Emits `DecorateBanny`. |
26
+ | `decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds)` | Attaches background and outfits to a body. Transfers assets to contract custody. Validates ownership, lock status, category ordering, slot conflicts. Attempts to return previously attached items to caller — if a return fails, the item is retained (not stranded). Emits `DecorateBanny`. |
27
27
  | `lockOutfitChangesFor(hook, bannyBodyId)` | Locks a body's outfit for 7 days. Cannot accelerate an existing lock. Caller must own the body NFT. |
28
28
 
29
29
  ### Views
@@ -58,6 +58,46 @@ On-chain composable NFT avatar system that renders Banny characters with layered
58
58
  | `@openzeppelin/contracts` | `Ownable`, `ReentrancyGuard`, `ERC2771Context`, `IERC721Receiver`, `Strings` | Access control, reentrancy protection, meta-transactions, safe NFT receipt, string utilities |
59
59
  | `lib/base64` | `Base64` | Base64 encoding for on-chain SVG and JSON metadata |
60
60
 
61
+ ### Deployment / Setup Sequence
62
+
63
+ The resolver is connected to a 721 hook in one of two ways:
64
+
65
+ 1. **At hook deployment**: Pass the resolver address as `tokenUriResolver` in `JBDeploy721TiersHookConfig` when calling `IJB721TiersHookDeployer.deployHookFor(...)`. The hook's `initialize()` stores it in the hook store via `recordSetTokenUriResolver`.
66
+ 2. **After deployment**: Call `hook.setMetadata(...)` with the resolver address as the `tokenUriResolver` parameter. Requires `SET_721_METADATA` permission. Pass `IJB721TokenUriResolver(address(this))` as a sentinel value to leave it unchanged, since `address(0)` clears the resolver.
67
+
68
+ Once set, the hook's store maps `tokenUriResolverOf[hook]` to the resolver address. Any `tokenURI(tokenId)` call on the hook delegates to `resolver.tokenUriOf(hook, tokenId)`, which composes the on-chain SVG.
69
+
70
+ ## Rendering Order
71
+
72
+ The composed SVG layers elements back-to-front. Later layers are drawn on top of earlier layers. The full rendering order for a dressed body is:
73
+
74
+ 1. **Background** (category 1) -- if attached and `shouldIncludeBackgroundOnBannyBody` is true
75
+ 2. **Body** (category 0) -- the base Banny character with color-fill styles (`b1`-`b4`, `a1`-`a3`)
76
+ 3. **Backside** (category 2) -- rendered behind the body in the SVG source but layered by the SVG's internal z-ordering
77
+ 4. **Necklace** (category 3) -- default injected if none attached; custom necklaces are deferred and rendered after suit top (category 11)
78
+ 5. **Head** (category 4) -- if present, suppresses default injection of eyes, mouth
79
+ 6. **Eyes** (category 5) -- default alien or standard eyes injected if no head and no explicit eyes
80
+ 7. **Glasses** (category 6)
81
+ 8. **Mouth** (category 7) -- default mouth injected if no head and no explicit mouth
82
+ 9. **Legs** (category 8)
83
+ 10. **Suit** (category 9)
84
+ 11. **Suit Bottom** (category 10)
85
+ 12. **Suit Top** (category 11)
86
+ 13. **Custom Necklace** -- rendered here (after suit top) if a custom necklace was provided, so it layers on top of clothing
87
+ 14. **Headtop** (category 12)
88
+ 15. **Hand** (category 13)
89
+ 16. **Special overlays** (categories 14-17) -- suit, legs, head, body specials
90
+
91
+ For non-body tokens (outfits, backgrounds), the item is rendered on a grayscale mannequin Banny (`fill:#808080`) for preview.
92
+
93
+ ## SVG Format Requirements
94
+
95
+ - **Canvas**: All SVGs are rendered within a `400x400` viewport: `<svg width="400" height="400" viewBox="0 0 400 400">`.
96
+ - **Fragment, not document**: SVG content stored via `setSvgContentsOf` must NOT include the parent `<svg>` element. The resolver wraps all fragments in the outer SVG element with shared styles (`.o{fill:#050505;}.w{fill:#f9f9f9;}`).
97
+ - **Hash verification only**: The contract validates uploaded SVG content against a pre-registered `keccak256` hash but performs no structural validation (no dimension checks, no viewBox enforcement). Ensuring content renders correctly at 400x400 is the uploader's responsibility.
98
+ - **Body color classes**: Body SVGs use CSS classes `b1`-`b4` (body fills) and `a1`-`a3` (accent fills) which are injected as `<style>` elements based on the body's UPC. Outfit SVGs can reference `.o` (dark, #050505) and `.w` (light, #f9f9f9) classes provided by the wrapper.
99
+ - **Immutability**: Once stored, SVG content cannot be changed. A content or hash error requires deploying a new resolver instance.
100
+
61
101
  ## Key Types
62
102
 
63
103
  ### Asset Categories
@@ -141,7 +181,7 @@ On-chain composable NFT avatar system that renders Banny characters with layered
141
181
  | `_svgContentOf` | `upc => string` | On-chain SVG content (immutable once set) |
142
182
  | `svgHashOf` | `upc => bytes32` | Expected SVG content hash (immutable once set) |
143
183
  | `_customProductNameOf` | `upc => string` | Human-readable product name |
144
- | `outfitLockedUntil` | `hook => upc => uint256` | Timestamp until outfit changes locked |
184
+ | `outfitLockedUntil` | `hook => bannyBodyId => uint256` | Timestamp until outfit changes locked (declared as `upc` but keyed by token ID) |
145
185
  | `_userOf` | `hook => backgroundId => uint256` | Which body uses a background |
146
186
  | `_wearerOf` | `hook => outfitId => uint256` | Which body wears an outfit |
147
187
  | `svgBaseUri` | `string` | IPFS/HTTP base URI for fallback SVG loading |
@@ -157,7 +197,7 @@ On-chain composable NFT avatar system that renders Banny characters with layered
157
197
  5. **Strict ascending category order.** `decorateBannyWith` requires outfits passed in ascending category order. Reverts with `UnorderedCategories` if violated.
158
198
  6. **Slot conflicts block combinations.** Head (4) blocks eyes (5), glasses (6), mouth (7), and headtop (12). Full suit (9) blocks suit top (11) and suit bottom (10). These are enforced at decoration time.
159
199
  7. **Default injection.** If no explicit necklace, eyes, or mouth outfit is attached, the resolver auto-injects defaults during SVG rendering. Alien bodies get `DEFAULT_ALIEN_EYES`; others get `DEFAULT_STANDARD_EYES`.
160
- 8. **Outfits are held in contract custody.** Attached outfits and backgrounds are transferred to `address(this)` via `safeTransferFrom`. They are returned to the caller when detached (by passing a new outfit set that excludes them).
200
+ 8. **Outfits are held in contract custody.** Attached outfits and backgrounds are transferred to `address(this)` via `safeTransferFrom`. They are returned to the caller when detached (by passing a new outfit set that excludes them). If the 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 transfer issue is resolved.
161
201
  9. **Complex outfit ownership rules.** If an outfit is unworn: only its owner can attach it. If already worn by another body: the caller must own THAT body to reassign the outfit. This allows body owners to swap outfits between their own bodies.
162
202
  10. **Token ID encoding.** `tokenId = upc * 1_000_000_000 + sequenceNumber`. The resolver extracts UPC via integer division and sequence via modulo to display inventory counts like "42/100".
163
203
  11. **`onERC721Received` only accepts self-transfers.** Reverts unless `operator == address(this)`. The contract calls `safeTransferFrom` on itself during decoration, triggering this callback.
@@ -166,6 +206,7 @@ On-chain composable NFT avatar system that renders Banny characters with layered
166
206
  14. **Mannequin rendering.** Outfit and background tokens (not bodies) are rendered on a grayscale mannequin Banny for preview purposes. The mannequin has `fill:#808080` styling.
167
207
  15. **ERC2771 meta-transaction support.** Constructor takes a `trustedForwarder` address. All `_msgSender()` calls use `ERC2771Context`, allowing relayers to submit decoration transactions on behalf of users.
168
208
  16. **Empty metadata fields clear the value.** `setMetadata` always writes all three fields. Passing an empty string clears that field. To keep a field unchanged, pass its current value.
209
+ 17. **Outfit locks persist through transfers.** The `outfitLockedUntil` mapping is keyed by `(hook, bannyBodyId)` and is never cleared on transfer. When a locked body NFT is transferred, the new owner inherits the lock and cannot change outfits until it expires. Equipped outfits also travel with the body -- the new owner can unequip them after the lock expires. Sellers should unequip valuable outfits before transferring a body.
169
210
 
170
211
  ## Example Integration
171
212
 
package/STYLE_GUIDE.md CHANGED
@@ -21,7 +21,7 @@ One contract/interface/struct/enum per file. Name the file after the type it con
21
21
 
22
22
  ```solidity
23
23
  // Contracts — pin to exact version
24
- pragma solidity 0.8.26;
24
+ pragma solidity 0.8.28;
25
25
 
26
26
  // Interfaces, structs, enums — caret for forward compatibility
27
27
  pragma solidity ^0.8.0;
@@ -326,7 +326,7 @@ Standard config across all repos:
326
326
 
327
327
  ```toml
328
328
  [profile.default]
329
- solc = '0.8.26'
329
+ solc = '0.8.28'
330
330
  evm_version = 'cancun'
331
331
  optimizer_runs = 200
332
332
  libs = ["node_modules", "lib"]