Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Feynman Audit — Verified Findings

## Scope
- Language: Solidity 0.8.26
- Modules analyzed: `src/Banny721TokenUriResolver.sol`, `src/interfaces/IBanny721TokenUriResolver.sol`, `script/Deploy.s.sol`, `script/Drop1.s.sol`, `script/Add.Denver.s.sol`, `script/helpers/MigrationHelper.sol`, `script/helpers/BannyverseDeploymentLib.sol`
- Functions analyzed: 75
- Lines interrogated: full in-scope Solidity review

## Verification Summary
| ID | Original Severity | Verdict | Final Severity |
|----|-------------------|---------|----------------|
| FF-001 | HIGH | TRUE POSITIVE | HIGH |

## Function-State Matrix
Focused high-risk entries:

| Function | Reads | Writes | Guards | Calls |
|----------|-------|--------|--------|-------|
| `decorateBannyWith` | body owner, body category, lock, attached background/outfits | `_attachedBackgroundIdOf`, `_userOf`, `_attachedOutfitIdsOf`, `_wearerOf` | body ownership, body category, lock, `nonReentrant` | hook `ownerOf`, hook `safeTransferFrom`, store `tierOfTokenId` |
| `_decorateBannyWithBackground` | previous background, background owner, background user, background tier | `_attachedBackgroundIdOf`, `_userOf` | caller/background ownership rules, lock inheritance | `_tryTransferFrom`, `_transferFrom` |
| `_decorateBannyWithOutfits` | previous outfits, outfit owner, outfit wearer, outfit tiers | `_attachedOutfitIdsOf`, `_wearerOf` | caller/outfit ownership rules, ordering/conflict rules, lock inheritance | `_tryTransferFrom`, `_transferFrom` |
| `lockOutfitChangesFor` | body owner, existing lock | `outfitLockedUntil` | body ownership | hook `ownerOf` |
| `setSvgContentsOf` | `svgHashOf`, `_svgContentOf` | `_svgContentOf` | hash existence/match | none |
| `setSvgHashesOf` | `svgHashOf` | `svgHashOf` | `onlyOwner` | none |

## Guard Consistency Analysis
- Custody-changing paths consistently require body ownership on entry, but return paths do not require successful asset delivery.
- `_transferFrom` is hard-fail for incoming custody, while `_tryTransferFrom` is soft-fail for outgoing custody. That asymmetry is the root cause of the verified issue below.

## Inverse Operation Parity
- Equip path: writes custody state, then requires incoming `safeTransferFrom` to succeed.
- Unequip/replace path: clears or overwrites custody state first, but silently ignores failed outgoing `safeTransferFrom`.
- Result: equip and unequip are not true inverses when the receiver cannot accept ERC-721s or transfers are otherwise blocked.

## Verified Findings (TRUE POSITIVES only)

### Finding FF-001: HIGH — Silent return-transfer failures permanently strand live equipped NFTs in the resolver
**Severity:** HIGH
**Module:** `Banny721TokenUriResolver`
**Function:** `_decorateBannyWithBackground`, `_decorateBannyWithOutfits`, `_tryTransferFrom`
**Lines:** `src/Banny721TokenUriResolver.sol:L1212-L1231`, `src/Banny721TokenUriResolver.sol:L1332-L1392`, `src/Banny721TokenUriResolver.sol:L1424-L1426`
**Verification:** Hybrid — code trace + PoC (`test/audit/TryTransferFromStrandsAssets.t.sol`)

**Feynman Question that exposed this:**
> What breaks if the outgoing `safeTransferFrom` fails after state has already been cleared or overwritten?

**The code:**
```solidity
_attachedBackgroundIdOf[hook][bannyBodyId] = 0;
_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId});

...

_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId});
...
_attachedOutfitIdsOf[hook][bannyBodyId] = outfitIds;

...

try IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId}) {} catch {}
```

**Why this is wrong:**
The resolver treats a failed outgoing NFT transfer as non-fatal, but it updates the attachment mappings before or regardless of whether the transfer succeeded. If the recipient cannot receive ERC-721s, or any other live transfer failure occurs, the NFT stays owned by the resolver while `userOf`, `wearerOf`, and `assetIdsOf` stop exposing it as attached. From that point onward, authorization also breaks: the caller no longer owns the NFT, and the NFT is no longer considered attached to any body, so there is no path to reclaim it.

**Verification evidence:**
- Code trace:
- Background path clears `_attachedBackgroundIdOf` before `_tryTransferFrom` at `src/Banny721TokenUriResolver.sol:L1227-L1231`.
- Outfit path eventually overwrites `_attachedOutfitIdsOf` after best-effort return transfers at `src/Banny721TokenUriResolver.sol:L1372-L1392`.
- `userOf` and `wearerOf` only acknowledge assets still present in the attachment mappings at `src/Banny721TokenUriResolver.sol:L497-L527`.
- `_tryTransferFrom` swallows every revert at `src/Banny721TokenUriResolver.sol:L1424-L1426`.
- PoC:
- `forge test --match-path test/audit/TryTransferFromStrandsAssets.t.sol -vvv`
- Result: pass
- Demonstrated sequence: a body owned by a contract that rejects ERC-721 receipts equips a background and outfit, then undresses; both return transfers fail silently; both NFTs remain owned by the resolver; `userOf`, `wearerOf`, and `assetIdsOf` report nothing; subsequent reclaim attempts revert with `UnauthorizedOutfit` / `UnauthorizedBackground`.

**Attack scenario:**
1. A body is owned by a contract account that does not implement `IERC721Receiver`, or a live transfer failure is otherwise induced on outgoing returns.
2. The contract equips a background and/or outfit, transferring them into resolver custody.
3. The body owner later replaces or removes those assets.
4. `_tryTransferFrom` catches the revert, leaving the live NFT inside the resolver.
5. Attachment state is already cleared or overwritten, so the NFT is no longer recoverable through normal decoration flows.

**Impact:**
- Conditional permanent NFT custody loss.
- Breaks the repo’s stated invariant that every equipped NFT held by the resolver is recoverable by the current body owner.
- Affects both outfits and backgrounds, not just burned or removed-tier assets.

**Suggested fix:**
```solidity
// Only clear attachment state after a successful outgoing transfer, and
// distinguish expected terminal cases (burned token / removed tier) from
// recoverable live-transfer failures.
```

## False Positives Eliminated
- None material after verification.

## Downgraded Findings
- None.

## LOW Findings (verified by inspection)
- None worth reporting.

## Summary
- Total functions analyzed: 75
- Raw findings (pre-verification): 0 CRITICAL | 1 HIGH | 0 MEDIUM | 0 LOW
- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED
- Final: 1 HIGH | 0 MEDIUM | 0 LOW
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# N E M E S I S — Raw Findings

## Scope
- Files in scope:
- `src/Banny721TokenUriResolver.sol`
- `src/interfaces/IBanny721TokenUriResolver.sol`
- `script/Deploy.s.sol`
- `script/Drop1.s.sol`
- `script/Add.Denver.s.sol`
- `script/helpers/MigrationHelper.sol`
- `script/helpers/BannyverseDeploymentLib.sol`
- Files intentionally ignored during analysis: `.audit/findings/*`

## Phase 0 — Nemesis Recon

**Language:** Solidity

**Attack Goals**
1. Permanently strand equipped NFTs inside the resolver.
2. Reassign or steal outfits/backgrounds without owning the item or the body wearing it.
3. Break attachment-state invariants so metadata and custody diverge.
4. Misdeploy the resolver/hook stack with bad initialization or chain-specific parameters.

**Novel Code**
- `src/Banny721TokenUriResolver.sol` — custom custody + SVG composition + lock logic.
- `script/Deploy.s.sol` — custom multi-protocol deployment composition (resolver + revnet + hook + suckers).

**Value Stores + Initial Coupling Hypothesis**
- Resolver-held NFTs
- Outflows: `_tryTransferFrom`, `_transferFrom`, `decorateBannyWith`
- Suspected coupled state:
- `_attachedOutfitIdsOf` ↔ `_wearerOf`
- `_attachedBackgroundIdOf` ↔ `_userOf`
- SVG storage
- Outflows: token metadata reads
- Suspected coupled state:
- `svgHashOf` ↔ `_svgContentOf`

**Complex Paths**
- `decorateBannyWith` → `_decorateBannyWithBackground` + `_decorateBannyWithOutfits` with merge-style diffing and external ERC-721 transfers.
- `Deploy.s.sol::deploy()` composing deterministic resolver deployment with revnet + 721 hook deployment.

**Priority Order**
1. `decorateBannyWith` and its background/outfit helpers.
2. Outgoing custody return paths using `_tryTransferFrom`.
3. Deployment/configuration scripts.

## Phase 1 — Unified Nemesis Map

| Function | Writes A | Writes B | A↔B Pair | Sync Status |
|----------|----------|----------|----------|-------------|
| `_decorateBannyWithBackground` | `_attachedBackgroundIdOf` | `_userOf` | background attachment ↔ user | Gap if outgoing transfer fails |
| `_decorateBannyWithOutfits` | `_attachedOutfitIdsOf` | `_wearerOf` | outfit attachment ↔ wearer | Gap if outgoing transfer fails |
| `setSvgHashesOf` | `svgHashOf` | — | svg hash ↔ content | Synced by later `setSvgContentsOf` |
| `setSvgContentsOf` | `_svgContentOf` | validates `svgHashOf` | svg hash ↔ content | Synced |

## Pass 1 — Feynman (Full)

### Primary Suspects
1. `_tryTransferFrom` swallows every transfer failure.
2. Background state is cleared/overwritten before old background return is known to have succeeded.
3. Outfit attachment array is overwritten after best-effort returns, regardless of whether the assets actually left resolver custody.

### Raw Feynman Finding
- `FF-RAW-001`
- Severity: HIGH
- Title: Silent outgoing transfer failures may leave live NFTs stranded after attachment state is cleared
- Touched state:
- `_attachedBackgroundIdOf`, `_userOf`
- `_attachedOutfitIdsOf`, `_wearerOf`

## Pass 2 — State Inconsistency (Full)

### New Gaps
1. `_attachedBackgroundIdOf` can be cleared while the old background remains owned by the resolver.
2. `_attachedOutfitIdsOf` can be overwritten while removed outfits remain owned by the resolver.
3. `userOf` / `wearerOf` then lazily mask the stale mapping because they rely on the cleared attachment side.

### Mutation Matrix Delta
- Background remove/replace path: writes attachment side first, then performs best-effort outgoing transfer.
- Outfit remove/replace path: performs best-effort outgoing transfers, then overwrites array side even when the outgoing transfer failed.

## Pass 3 — Feynman Re-interrogation

### Root Cause
- The code assumes failed outgoing transfers only happen for dead assets (burned / removed-tier), but `_tryTransferFrom` also swallows live transfer failures such as a recipient contract rejecting ERC-721 receipts.

### Consequence
- Once the outgoing transfer revert is swallowed, the live NFT is still in resolver custody but no longer authorized for recovery because the “attached” side was already cleared.

## Pass 4 — State Re-analysis

### Confirmed Coupled-Pair Failure
- The same root cause breaks both coupled pairs:
- `_attachedBackgroundIdOf` ↔ `_userOf`
- `_attachedOutfitIdsOf` ↔ `_wearerOf`

## Convergence
- No additional verified findings surfaced in scripts or SVG storage after the fourth pass.

## Raw Findings Summary
| ID | Source | Severity | Status |
|----|--------|----------|--------|
| FF-RAW-001 / SI-RAW-001 | Feynman + State cross-feed | HIGH | Verified true positive |

## Verification Notes
- PoC added: `test/audit/TryTransferFromStrandsAssets.t.sol`
- PoC result: pass
- Full regression suite result: `forge test` passed
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# N E M E S I S — Verified Findings

## Scope
- Language: Solidity
- Modules analyzed:
- `src/Banny721TokenUriResolver.sol`
- `src/interfaces/IBanny721TokenUriResolver.sol`
- `script/Deploy.s.sol`
- `script/Drop1.s.sol`
- `script/Add.Denver.s.sol`
- `script/helpers/MigrationHelper.sol`
- `script/helpers/BannyverseDeploymentLib.sol`
- Functions analyzed: 75
- Coupled state pairs mapped: 3
- Mutation paths traced: 8
- Nemesis loop iterations: 4 passes (Feynman full → State full → Feynman targeted → State targeted)

## Nemesis Map (Phase 1 Cross-Reference)

| Function | Writes A | Writes B | A↔B Pair | Sync Status |
|----------|----------|----------|----------|-------------|
| `_decorateBannyWithBackground` | `_attachedBackgroundIdOf` | `_userOf` | background attachment ↔ user | `GAP` when old-background return fails |
| `_decorateBannyWithOutfits` | `_attachedOutfitIdsOf` | `_wearerOf` | outfit attachment ↔ wearer | `GAP` when old-outfit return fails |
| `setSvgHashesOf` | `svgHashOf` | — | svg hash ↔ content | synced by design |
| `setSvgContentsOf` | `_svgContentOf` | validates `svgHashOf` | svg hash ↔ content | synced |

## Verification Summary
| ID | Source | Coupled Pair | Breaking Op | Severity | Verdict |
|----|--------|-------------|-------------|----------|---------|
| NM-001 | Cross-feed P2→P3 | outfit/background attachment ↔ wearer/user | `decorateBannyWith()` | HIGH | TRUE POSITIVE |

## Verified Findings (TRUE POSITIVES only)

### Finding NM-001: HIGH — Best-effort unequip transfers can permanently strand live NFTs in resolver custody
**Severity:** HIGH
**Source:** Cross-feed P2→P3
**Verification:** Hybrid

**Coupled Pair:** `_attachedOutfitIdsOf` ↔ `_wearerOf`, `_attachedBackgroundIdOf` ↔ `_userOf`
**Invariant:** Every live equipped NFT held by the resolver must remain recoverable by the current owner of the body it is attached to.

**Feynman Question that exposed it:**
> What breaks if the outgoing `safeTransferFrom` reverts after the attachment state has already been cleared or overwritten?

**State Mapper gap that confirmed it:**
> The remove/replace paths update the attachment side of the pair even when the NFT never leaves resolver custody, and the read side (`userOf` / `wearerOf`) then masks the stale relationship because it trusts the cleared attachment side.

**Breaking Operation:** `decorateBannyWith()` at `src/Banny721TokenUriResolver.sol:983`
- Modifies State A:
- background path writes `_attachedBackgroundIdOf[hook][bannyBodyId]` at `src/Banny721TokenUriResolver.sol:1213` and clears it at `src/Banny721TokenUriResolver.sol:1227`
- outfit path overwrites `_attachedOutfitIdsOf[hook][bannyBodyId]` at `src/Banny721TokenUriResolver.sol:1392`
- Does NOT ensure State B remains consistent when the return transfer fails:
- best-effort return calls at `src/Banny721TokenUriResolver.sol:1218`, `src/Banny721TokenUriResolver.sol:1231`, `src/Banny721TokenUriResolver.sol:1338`, `src/Banny721TokenUriResolver.sol:1380`
- unconditional swallow at `src/Banny721TokenUriResolver.sol:1424-1426`

**Trigger Sequence:**
1. A body owned by a contract that rejects ERC-721 receipts equips a live background and live outfit.
2. The body owner later calls `decorateBannyWith(hook, bodyId, 0, [])` to unequip them.
3. `_tryTransferFrom` catches the outgoing transfer revert for the background/outfit return.
4. The resolver still owns the NFTs, but the body’s attachment state has already been cleared or overwritten.
5. `assetIdsOf`, `userOf`, and `wearerOf` now report nothing.
6. Re-attaching the same live NFTs reverts because the resolver is the owner and the items are no longer considered attached to any body.

**Consequence:**
- Conditional permanent custody loss of live outfits/backgrounds.
- Violates the repo’s stated recoverability invariant.
- Affects both background and outfit return flows.

**Masking Code**:
```solidity
try IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId}) {} catch {}
```

**Verification Evidence:**
- Code trace:
- `src/Banny721TokenUriResolver.sol:1213-1218`
- `src/Banny721TokenUriResolver.sol:1227-1231`
- `src/Banny721TokenUriResolver.sol:1332-1392`
- `src/Banny721TokenUriResolver.sol:1424-1426`
- PoC:
- `test/audit/TryTransferFromStrandsAssets.t.sol`
- Command: `forge test --match-path test/audit/TryTransferFromStrandsAssets.t.sol -vvv`
- Result: pass
- Regression check:
- Command: `forge test`
- Result: full suite passed

**Fix:**
```solidity
// Only clear or overwrite attachment mappings after a successful outgoing transfer,
// or preserve a recoverable attachment record for all live-transfer failures.
```

## Feedback Loop Discoveries
- The issue required both auditors:
- State pass identified the coupled-pair desync.
- Feynman re-interrogation explained why the desync becomes permanent only when a live transfer failure, not a burn, is swallowed.

## False Positives Eliminated
- Burned-token and removed-tier paths are intentionally tolerated by `_tryTransferFrom`; those cases do not leave a live NFT trapped in resolver custody.
- No deployment-script finding survived verification.

## Downgraded Findings
- None.

## Summary
- Total functions analyzed: 75
- Coupled state pairs mapped: 3
- Nemesis loop iterations: 4
- Raw findings (pre-verification): 0 C | 1 H | 0 M | 0 L
- Feedback loop discoveries: 1
- After verification: 1 TRUE POSITIVE | 0 FALSE POSITIVE | 0 DOWNGRADED
- Final: 0 CRITICAL | 1 HIGH | 0 MEDIUM | 0 LOW
Loading